@haystackeditor/cli 0.13.3 → 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.
- package/dist/assets/skills/submit.md +17 -0
- package/dist/commands/config.d.ts +12 -0
- package/dist/commands/config.js +139 -3
- package/dist/commands/setup.js +565 -75
- package/dist/index.js +30 -3
- package/dist/types.d.ts +12 -0
- package/dist/types.js +3 -0
- package/package.json +1 -1
|
@@ -85,6 +85,23 @@ Use `--auto-fix` only for issues like:
|
|
|
85
85
|
haystack submit --auto-fix
|
|
86
86
|
```
|
|
87
87
|
|
|
88
|
+
Repos that want auto-fix on by default for every `haystack submit` can opt
|
|
89
|
+
in via `.haystack.json`:
|
|
90
|
+
|
|
91
|
+
```json
|
|
92
|
+
{
|
|
93
|
+
"preferences": {
|
|
94
|
+
"auto_fix": true
|
|
95
|
+
}
|
|
96
|
+
}
|
|
97
|
+
```
|
|
98
|
+
|
|
99
|
+
When set, `haystack submit` applies the `haystack:auto-fix` label without
|
|
100
|
+
needing the explicit flag. Auto-fix is still alpha — use the repo-level
|
|
101
|
+
opt-in only when the team is comfortable with the sandbox fixer running on
|
|
102
|
+
mechanical findings. `haystack submit --no-auto-fix` overrides the repo
|
|
103
|
+
default for a single submission.
|
|
104
|
+
|
|
88
105
|
### Review Mode
|
|
89
106
|
|
|
90
107
|
> **Do NOT use `--review` unless the user explicitly asks for human review.**
|
|
@@ -21,5 +21,17 @@ export declare function getAutoMergeStatus(): Promise<void>;
|
|
|
21
21
|
export declare function enableAutoMerge(): Promise<void>;
|
|
22
22
|
export declare function disableAutoMerge(): Promise<void>;
|
|
23
23
|
export declare function handleAutoMerge(action?: string): Promise<void>;
|
|
24
|
+
/**
|
|
25
|
+
* Check if auto-fix is enabled for the current repo.
|
|
26
|
+
* Returns false if .haystack.json is missing or on error.
|
|
27
|
+
*
|
|
28
|
+
* Mirrors isAutoMergeEnabled() — direct file read so missing config returns
|
|
29
|
+
* false instead of process.exit(1).
|
|
30
|
+
*/
|
|
31
|
+
export declare function isAutoFixEnabled(): Promise<boolean>;
|
|
32
|
+
export declare function getAutoFixStatus(): Promise<void>;
|
|
33
|
+
export declare function enableAutoFix(): Promise<void>;
|
|
34
|
+
export declare function disableAutoFix(): Promise<void>;
|
|
35
|
+
export declare function handleAutoFix(action?: string): Promise<void>;
|
|
24
36
|
export declare function handleWaitForReviewers(action?: string, reviewers?: string[]): Promise<void>;
|
|
25
37
|
export {};
|
package/dist/commands/config.js
CHANGED
|
@@ -11,6 +11,10 @@ import { execSync } from 'child_process';
|
|
|
11
11
|
import { resolveAuthContext } from '../utils/auth.js';
|
|
12
12
|
import { AI_REVIEWER_SOURCES, AI_REVIEWER_DISPLAY_NAMES, AI_REVIEWER_BOT_USERNAMES } from '../types.js';
|
|
13
13
|
const API_BASE = 'https://haystackeditor.com/api/preferences';
|
|
14
|
+
const REPO_PREFERENCE_DEFAULTS = {
|
|
15
|
+
auto_merge: false,
|
|
16
|
+
auto_fix: false,
|
|
17
|
+
};
|
|
14
18
|
const AGENTIC_TOOL_LABELS = {
|
|
15
19
|
'opencode': 'OpenCode (Haystack billing)',
|
|
16
20
|
'claude-code': 'Claude Code (your Claude Max subscription)',
|
|
@@ -49,8 +53,7 @@ function saveHaystackConfig(config, configPath) {
|
|
|
49
53
|
}
|
|
50
54
|
function getPreference(key) {
|
|
51
55
|
const { config } = loadHaystackConfig();
|
|
52
|
-
|
|
53
|
-
return config.preferences?.[key] ?? defaults[key];
|
|
56
|
+
return config.preferences?.[key] ?? REPO_PREFERENCE_DEFAULTS[key];
|
|
54
57
|
}
|
|
55
58
|
function setPreference(key, value) {
|
|
56
59
|
const { config, path } = loadHaystackConfig();
|
|
@@ -165,6 +168,19 @@ export async function handleAgenticTool(tool) {
|
|
|
165
168
|
// ============================================================================
|
|
166
169
|
// Auto-merge (repo-level via .haystack.json)
|
|
167
170
|
// ============================================================================
|
|
171
|
+
/**
|
|
172
|
+
* Surface unexpected errors when reading repo-level preferences while keeping
|
|
173
|
+
* the silent fallback for the expected "no .haystack.json" case. ENOENT means
|
|
174
|
+
* the user simply hasn't opted in; everything else (parse errors, permission
|
|
175
|
+
* denied, not-a-git-repo) is a config problem the user should see — silently
|
|
176
|
+
* defaulting to disabled would otherwise hide the misconfiguration.
|
|
177
|
+
*/
|
|
178
|
+
function logPreferenceLookupFailure(preference, err) {
|
|
179
|
+
if (err?.code === 'ENOENT')
|
|
180
|
+
return;
|
|
181
|
+
const message = err instanceof Error ? err.message : String(err);
|
|
182
|
+
console.error(chalk.dim(`[haystack] Could not resolve ${preference} preference (${message}); defaulting to disabled.`));
|
|
183
|
+
}
|
|
168
184
|
/**
|
|
169
185
|
* Check if auto-merge is enabled for the current repo.
|
|
170
186
|
* Returns false if .haystack.json is missing or on error.
|
|
@@ -180,10 +196,51 @@ export async function isAutoMergeEnabled() {
|
|
|
180
196
|
const config = JSON.parse(raw);
|
|
181
197
|
return config.preferences?.auto_merge ?? false;
|
|
182
198
|
}
|
|
183
|
-
catch {
|
|
199
|
+
catch (err) {
|
|
200
|
+
logPreferenceLookupFailure('auto_merge', err);
|
|
184
201
|
return false;
|
|
185
202
|
}
|
|
186
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
|
+
}
|
|
187
244
|
export async function getAutoMergeStatus() {
|
|
188
245
|
const enabled = getPreference('auto_merge');
|
|
189
246
|
console.log(chalk.bold('\nAuto-merge:'), enabled
|
|
@@ -198,6 +255,7 @@ export async function getAutoMergeStatus() {
|
|
|
198
255
|
console.log(chalk.dim('Auto-merge is disabled. PRs require manual merge.'));
|
|
199
256
|
console.log(chalk.dim('Enable with: haystack config auto-merge on'));
|
|
200
257
|
}
|
|
258
|
+
printDependencyNote(enabled);
|
|
201
259
|
console.log(chalk.dim('(Stored in .haystack.json — applies to all repo contributors)'));
|
|
202
260
|
console.log();
|
|
203
261
|
}
|
|
@@ -206,6 +264,7 @@ export async function enableAutoMerge() {
|
|
|
206
264
|
console.log(chalk.green('\nAuto-merge enabled.\n'));
|
|
207
265
|
console.log(chalk.dim('PRs submitted via `haystack submit` will be auto-merged'));
|
|
208
266
|
console.log(chalk.dim('when analysis finds no issues (safe to merge).'));
|
|
267
|
+
printDependencyNote(true);
|
|
209
268
|
console.log(chalk.dim('Commit .haystack.json to share with your team.\n'));
|
|
210
269
|
}
|
|
211
270
|
export async function disableAutoMerge() {
|
|
@@ -213,6 +272,7 @@ export async function disableAutoMerge() {
|
|
|
213
272
|
console.log(chalk.yellow('\nAuto-merge disabled.\n'));
|
|
214
273
|
console.log(chalk.dim('PRs will require manual merge after analysis.'));
|
|
215
274
|
console.log(chalk.dim('Re-enable with: haystack config auto-merge on'));
|
|
275
|
+
printDependencyNote(false);
|
|
216
276
|
console.log(chalk.dim('Commit .haystack.json to share with your team.\n'));
|
|
217
277
|
}
|
|
218
278
|
export async function handleAutoMerge(action) {
|
|
@@ -235,6 +295,82 @@ export async function handleAutoMerge(action) {
|
|
|
235
295
|
}
|
|
236
296
|
}
|
|
237
297
|
// ============================================================================
|
|
298
|
+
// Auto-fix (repo-level via .haystack.json) — alpha
|
|
299
|
+
// ============================================================================
|
|
300
|
+
/**
|
|
301
|
+
* Check if auto-fix is enabled for the current repo.
|
|
302
|
+
* Returns false if .haystack.json is missing or on error.
|
|
303
|
+
*
|
|
304
|
+
* Mirrors isAutoMergeEnabled() — direct file read so missing config returns
|
|
305
|
+
* false instead of process.exit(1).
|
|
306
|
+
*/
|
|
307
|
+
export async function isAutoFixEnabled() {
|
|
308
|
+
try {
|
|
309
|
+
const root = execSync('git rev-parse --show-toplevel', { encoding: 'utf-8' }).trim();
|
|
310
|
+
const raw = readFileSync(resolve(root, '.haystack.json'), 'utf-8');
|
|
311
|
+
const config = JSON.parse(raw);
|
|
312
|
+
return config.preferences?.auto_fix ?? false;
|
|
313
|
+
}
|
|
314
|
+
catch (err) {
|
|
315
|
+
logPreferenceLookupFailure('auto_fix', err);
|
|
316
|
+
return false;
|
|
317
|
+
}
|
|
318
|
+
}
|
|
319
|
+
export async function getAutoFixStatus() {
|
|
320
|
+
const enabled = getPreference('auto_fix');
|
|
321
|
+
console.log(chalk.bold('\nAuto-fix (alpha):'), enabled
|
|
322
|
+
? chalk.green('enabled')
|
|
323
|
+
: chalk.yellow('disabled'));
|
|
324
|
+
console.log();
|
|
325
|
+
if (enabled) {
|
|
326
|
+
console.log(chalk.dim('PRs submitted via `haystack submit` will be labeled'));
|
|
327
|
+
console.log(chalk.dim('haystack:auto-fix. Analysis will dispatch the alpha'));
|
|
328
|
+
console.log(chalk.dim('sandbox fixer for straightforward mechanical issues.'));
|
|
329
|
+
}
|
|
330
|
+
else {
|
|
331
|
+
console.log(chalk.dim('Auto-fix is disabled. Issues surface in the Feed for review.'));
|
|
332
|
+
console.log(chalk.dim('Enable with: haystack config auto-fix on'));
|
|
333
|
+
}
|
|
334
|
+
console.log(chalk.yellow('⚠ Auto-fix is alpha. Expect rough edges.'));
|
|
335
|
+
console.log(chalk.dim('(Stored in .haystack.json — applies to all repo contributors)'));
|
|
336
|
+
console.log();
|
|
337
|
+
}
|
|
338
|
+
export async function enableAutoFix() {
|
|
339
|
+
setPreference('auto_fix', true);
|
|
340
|
+
console.log(chalk.green('\nAuto-fix (alpha) enabled.\n'));
|
|
341
|
+
console.log(chalk.dim('PRs submitted via `haystack submit` will be labeled'));
|
|
342
|
+
console.log(chalk.dim('haystack:auto-fix. The sandbox fixer will attempt'));
|
|
343
|
+
console.log(chalk.dim('straightforward mechanical fixes before issues hit the Feed.'));
|
|
344
|
+
console.log(chalk.yellow('⚠ Auto-fix is alpha. Expect rough edges.'));
|
|
345
|
+
console.log(chalk.dim('Commit .haystack.json to share with your team.\n'));
|
|
346
|
+
}
|
|
347
|
+
export async function disableAutoFix() {
|
|
348
|
+
setPreference('auto_fix', false);
|
|
349
|
+
console.log(chalk.yellow('\nAuto-fix disabled.\n'));
|
|
350
|
+
console.log(chalk.dim('Issues will surface in the Feed for human review.'));
|
|
351
|
+
console.log(chalk.dim('Re-enable with: haystack config auto-fix on'));
|
|
352
|
+
console.log(chalk.dim('Commit .haystack.json to share with your team.\n'));
|
|
353
|
+
}
|
|
354
|
+
export async function handleAutoFix(action) {
|
|
355
|
+
switch (action?.toLowerCase()) {
|
|
356
|
+
case 'on':
|
|
357
|
+
case 'enable':
|
|
358
|
+
case 'true':
|
|
359
|
+
return enableAutoFix();
|
|
360
|
+
case 'off':
|
|
361
|
+
case 'disable':
|
|
362
|
+
case 'false':
|
|
363
|
+
return disableAutoFix();
|
|
364
|
+
case 'status':
|
|
365
|
+
case undefined:
|
|
366
|
+
return getAutoFixStatus();
|
|
367
|
+
default:
|
|
368
|
+
console.error(chalk.red(`\nUnknown action: ${action}`));
|
|
369
|
+
console.log('\nUsage: haystack config auto-fix [on|off|status]\n');
|
|
370
|
+
process.exit(1);
|
|
371
|
+
}
|
|
372
|
+
}
|
|
373
|
+
// ============================================================================
|
|
238
374
|
// Wait-for-reviewers (repo-level via .haystack.json)
|
|
239
375
|
// ============================================================================
|
|
240
376
|
/** Reverse map: bot username → friendly source name */
|
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
|
@@ -23,7 +23,7 @@ import { Command } from 'commander';
|
|
|
23
23
|
import { statusCommand } from './commands/status.js';
|
|
24
24
|
import { initCommand } from './commands/init.js';
|
|
25
25
|
import { authListCommand, authUseCommand, loginCommand, logoutCommand, whoamiCommand, } from './commands/login.js';
|
|
26
|
-
import { handleAgenticTool, handleAutoMerge, handleWaitForReviewers, isAutoMergeEnabled } from './commands/config.js';
|
|
26
|
+
import { handleAgenticTool, handleAutoMerge, handleAutoFix, handleWaitForReviewers, isAutoMergeEnabled, isAutoFixEnabled } from './commands/config.js';
|
|
27
27
|
import { installSkills, listSkills } from './commands/skills.js';
|
|
28
28
|
import { hooksInstall, hooksStatus, hooksUpdate } from './commands/hooks.js';
|
|
29
29
|
import { submitCommand } from './commands/submit.js';
|
|
@@ -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.13.
|
|
41
|
+
.version('0.13.4');
|
|
42
42
|
program
|
|
43
43
|
.command('init')
|
|
44
44
|
.description('Create .haystack.json configuration')
|
|
@@ -110,7 +110,7 @@ program
|
|
|
110
110
|
.option('--draft', 'Create as draft PR')
|
|
111
111
|
.option('--review [reviewer]', 'Request human review (BLOCKS auto-merge — only use when explicitly asked)')
|
|
112
112
|
.option('--force', 'Skip pre-PR triage checks')
|
|
113
|
-
.option('--auto-fix', 'Alpha auto-fix for straightforward mechanical issues (
|
|
113
|
+
.option('--auto-fix', 'Alpha auto-fix for straightforward mechanical issues (default: from .haystack.json preferences.auto_fix; --no-auto-fix to disable)')
|
|
114
114
|
.option('--auto-merge', 'Apply auto-merge label (default: from .haystack.json, --no-auto-merge to disable)')
|
|
115
115
|
.option('--no-wait', 'Skip waiting for analysis results')
|
|
116
116
|
.option('--max-turns <n>', 'Max agentic turns per triage checker (overrides .haystack.json triage.maxTurns)', (v) => parseInt(v, 10))
|
|
@@ -189,6 +189,12 @@ Examples:
|
|
|
189
189
|
if (options.autoMerge === undefined) {
|
|
190
190
|
options.autoMerge = await isAutoMergeEnabled();
|
|
191
191
|
}
|
|
192
|
+
// Resolve --auto-fix default from .haystack.json (preferences.auto_fix).
|
|
193
|
+
// Auto-fix remains alpha — the explicit --auto-fix flag still surfaces
|
|
194
|
+
// discouragement warnings; opting in via repo config is the supported path.
|
|
195
|
+
if (options.autoFix === undefined) {
|
|
196
|
+
options.autoFix = await isAutoFixEnabled();
|
|
197
|
+
}
|
|
192
198
|
return submitCommand(options);
|
|
193
199
|
});
|
|
194
200
|
program
|
|
@@ -373,6 +379,27 @@ Examples:
|
|
|
373
379
|
haystack config auto-merge off # Disable auto-merge
|
|
374
380
|
`)
|
|
375
381
|
.action(handleAutoMerge);
|
|
382
|
+
config
|
|
383
|
+
.command('auto-fix [action]')
|
|
384
|
+
.description('(Alpha) Auto-fix mechanical issues on PRs submitted via haystack submit (on|off|status)')
|
|
385
|
+
.addHelpText('after', `
|
|
386
|
+
Actions:
|
|
387
|
+
on, enable Enable auto-fix for mechanical issues
|
|
388
|
+
off, disable Disable auto-fix
|
|
389
|
+
status Show current status (default)
|
|
390
|
+
|
|
391
|
+
⚠ Auto-fix is alpha. When enabled, PRs submitted via \`haystack submit\`
|
|
392
|
+
are labeled haystack:auto-fix. The Haystack analysis pipeline then
|
|
393
|
+
dispatches the sandbox fixer for issues classified as straightforward
|
|
394
|
+
and mechanical. Judgment calls, policy violations, and weak coverage
|
|
395
|
+
findings still surface in the Feed for human review.
|
|
396
|
+
|
|
397
|
+
Examples:
|
|
398
|
+
haystack config auto-fix # Show current status
|
|
399
|
+
haystack config auto-fix on # Enable auto-fix (alpha)
|
|
400
|
+
haystack config auto-fix off # Disable auto-fix
|
|
401
|
+
`)
|
|
402
|
+
.action(handleAutoFix);
|
|
376
403
|
config
|
|
377
404
|
.command('wait-for-reviewers [action] [reviewers...]')
|
|
378
405
|
.description('Configure which AI reviewers the merge queue waits for')
|
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 */
|