@haystackeditor/cli 0.15.11 → 0.15.12
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/dist/commands/submit.js +19 -2
- package/dist/triage/runner.js +15 -5
- package/dist/utils/git.d.ts +32 -0
- package/dist/utils/git.js +101 -0
- package/package.json +3 -2
package/dist/commands/submit.js
CHANGED
|
@@ -433,7 +433,16 @@ export async function submitCommand(options) {
|
|
|
433
433
|
try {
|
|
434
434
|
await ensureHaystackLabels(remoteInfo.owner, remoteInfo.repo, ['haystack:auto-merge'], githubToken);
|
|
435
435
|
await addLabelsToIssue(remoteInfo.owner, remoteInfo.repo, prNumber, ['haystack:auto-merge'], githubToken);
|
|
436
|
-
|
|
436
|
+
// With --review, the PR still enters the auto-merge queue but the
|
|
437
|
+
// merge queue's needs-review gate holds it until a human approves
|
|
438
|
+
// (removes haystack:needs-review or marks it reviewed). Don't print a
|
|
439
|
+
// bare "Auto-merge enabled" that reads as if it'll merge unreviewed.
|
|
440
|
+
if (options.review) {
|
|
441
|
+
console.log(chalk.green('✓ Auto-merge enabled (held until a human approves this PR)'));
|
|
442
|
+
}
|
|
443
|
+
else {
|
|
444
|
+
console.log(chalk.green('✓ Auto-merge enabled for this PR'));
|
|
445
|
+
}
|
|
437
446
|
}
|
|
438
447
|
catch (err) {
|
|
439
448
|
console.log(chalk.yellow(`⚠ Could not set auto-merge label: ${err.message}`));
|
|
@@ -482,7 +491,15 @@ export async function submitCommand(options) {
|
|
|
482
491
|
console.log(` ${chalk.dim('Title:')} ${options.title || getLastCommitMessage()}`);
|
|
483
492
|
console.log(` ${chalk.dim('Branch:')} ${currentBranch} → ${baseBranch}`);
|
|
484
493
|
if (options.autoMerge) {
|
|
485
|
-
|
|
494
|
+
// --review and auto-merge are not contradictory: the PR is queued but the
|
|
495
|
+
// merge queue's needs-review gate blocks the merge until a human approves.
|
|
496
|
+
// Report it that way instead of a flat "Auto-merge if safe".
|
|
497
|
+
if (options.review) {
|
|
498
|
+
console.log(` ${chalk.dim('Merge:')} ${chalk.yellow('Held for human review, then auto-merge')}`);
|
|
499
|
+
}
|
|
500
|
+
else {
|
|
501
|
+
console.log(` ${chalk.dim('Merge:')} ${chalk.green('Auto-merge if safe')}`);
|
|
502
|
+
}
|
|
486
503
|
}
|
|
487
504
|
if (options.autoFix) {
|
|
488
505
|
console.log(` ${chalk.dim('Fix:')} ${chalk.cyan('Auto-fix alpha enabled')}`);
|
package/dist/triage/runner.js
CHANGED
|
@@ -10,6 +10,7 @@ import { join } from 'path';
|
|
|
10
10
|
import chalk from 'chalk';
|
|
11
11
|
import { buildCodeReviewPrompt, buildRulesValidatorPrompt, buildIntentDriftPrompt } from './prompts.js';
|
|
12
12
|
import { findRelevantTraces } from './traces.js';
|
|
13
|
+
import { resolveDiffBaseRef } from '../utils/git.js';
|
|
13
14
|
import { trackError } from '../utils/telemetry.js';
|
|
14
15
|
// ============================================================================
|
|
15
16
|
// Constants
|
|
@@ -272,11 +273,20 @@ export async function runTriage(gitRoot, baseBranch, options = {}) {
|
|
|
272
273
|
rmSync(triageDir, { recursive: true });
|
|
273
274
|
}
|
|
274
275
|
mkdirSync(triageDir, { recursive: true });
|
|
276
|
+
// Resolve the ref to diff against once. Prefers origin/<base> over the bare
|
|
277
|
+
// local <base> ref (which is often stale and balloons the diff with already-
|
|
278
|
+
// merged code). Every git read below — the precomputed diff, the agent diff
|
|
279
|
+
// commands in the prompts, and the changed-file scan in findRelevantTraces —
|
|
280
|
+
// uses this single resolved ref so they all see the same fork point.
|
|
281
|
+
const diffBaseRef = resolveDiffBaseRef(baseBranch);
|
|
282
|
+
if (diffBaseRef !== baseBranch) {
|
|
283
|
+
console.log(chalk.dim(` Diff base: ${diffBaseRef}`));
|
|
284
|
+
}
|
|
275
285
|
// Pre-compute the diff once so sub-agents don't waste turns running git diff.
|
|
276
286
|
// Includes 10 lines of context around each hunk for reviewability.
|
|
277
287
|
let precomputedDiff = null;
|
|
278
288
|
try {
|
|
279
|
-
const diffOutput = execSync(`git diff ${
|
|
289
|
+
const diffOutput = execSync(`git diff ${diffBaseRef}...HEAD -U10`, { cwd: gitRoot, encoding: 'utf-8', maxBuffer: 5 * 1024 * 1024 }).trim();
|
|
280
290
|
// Only inline if the diff is under 100K chars — beyond that, let the agent
|
|
281
291
|
// run git diff itself (it can paginate or review file-by-file).
|
|
282
292
|
if (diffOutput.length > 0 && diffOutput.length <= 100_000) {
|
|
@@ -292,7 +302,7 @@ export async function runTriage(gitRoot, baseBranch, options = {}) {
|
|
|
292
302
|
const codeReviewOutput = join(triageDir, 'code-review.json');
|
|
293
303
|
checkers.push({
|
|
294
304
|
name: 'code-review',
|
|
295
|
-
prompt: buildCodeReviewPrompt(
|
|
305
|
+
prompt: buildCodeReviewPrompt(diffBaseRef, codeReviewOutput, maxTurns['code-review'], timeoutMs, precomputedDiff),
|
|
296
306
|
outputFile: codeReviewOutput,
|
|
297
307
|
maxTurns: maxTurns['code-review'],
|
|
298
308
|
});
|
|
@@ -303,7 +313,7 @@ export async function runTriage(gitRoot, baseBranch, options = {}) {
|
|
|
303
313
|
const agentPolicyFiles = discoverAgentPolicyFiles(gitRoot);
|
|
304
314
|
if (hasRulesYaml || agentPolicyFiles.length > 0) {
|
|
305
315
|
const rulesValidatorOutput = join(triageDir, 'rules-validator.json');
|
|
306
|
-
const rulesPrompt = buildRulesValidatorPrompt(
|
|
316
|
+
const rulesPrompt = buildRulesValidatorPrompt(diffBaseRef, rulesYaml, rulesValidatorOutput, maxTurns['rules-validator'], timeoutMs, agentPolicyFiles, precomputedDiff);
|
|
307
317
|
if (rulesPrompt) {
|
|
308
318
|
checkers.push({
|
|
309
319
|
name: 'rules-validator',
|
|
@@ -314,10 +324,10 @@ export async function runTriage(gitRoot, baseBranch, options = {}) {
|
|
|
314
324
|
}
|
|
315
325
|
}
|
|
316
326
|
// 3. Intent drift (only if relevant trace files exist)
|
|
317
|
-
const traceFiles = findRelevantTraces(gitRoot,
|
|
327
|
+
const traceFiles = findRelevantTraces(gitRoot, diffBaseRef);
|
|
318
328
|
if (traceFiles.length > 0) {
|
|
319
329
|
const intentDriftOutput = join(triageDir, 'intent-drift.json');
|
|
320
|
-
const driftPrompt = buildIntentDriftPrompt(
|
|
330
|
+
const driftPrompt = buildIntentDriftPrompt(diffBaseRef, traceFiles, intentDriftOutput, maxTurns['intent-drift'], timeoutMs, precomputedDiff);
|
|
321
331
|
if (driftPrompt) {
|
|
322
332
|
checkers.push({
|
|
323
333
|
name: 'intent-drift',
|
package/dist/utils/git.d.ts
CHANGED
|
@@ -90,6 +90,38 @@ export declare function getLastCommitSha(): string;
|
|
|
90
90
|
* Returns the combined log, or empty string if no commits or on error.
|
|
91
91
|
*/
|
|
92
92
|
export declare function getCommitMessagesSinceBase(baseBranch: string): string;
|
|
93
|
+
/**
|
|
94
|
+
* Resolve the git ref to diff a PR branch against for review.
|
|
95
|
+
*
|
|
96
|
+
* A bare local `<base>` ref is frequently stale (not checked out or pulled in a
|
|
97
|
+
* while). When it sits behind HEAD's true fork point, the three-dot merge-base
|
|
98
|
+
* jumps far back in history and inflates the review diff to include code from
|
|
99
|
+
* already-merged PRs — reviewers then mis-attribute pre-existing code to this
|
|
100
|
+
* PR. So we prefer the freshly-fetched remote base. This mirrors
|
|
101
|
+
* getCommitMessagesSinceBase, which already compares against the remote base.
|
|
102
|
+
*
|
|
103
|
+
* Returns a fully-qualified ref (`refs/remotes/origin/<base>` or
|
|
104
|
+
* `refs/heads/<base>`) so a tag that happens to share the name can never shadow
|
|
105
|
+
* the ref we mean — `git rev-parse <name>` prefers refs/tags/* over both
|
|
106
|
+
* refs/heads/* and refs/remotes/*. The bare `<base>` name is only returned as a
|
|
107
|
+
* last resort when nothing qualified resolves (the eventual `git diff` then
|
|
108
|
+
* errors and the caller falls back to running it itself).
|
|
109
|
+
*
|
|
110
|
+
* Resolution:
|
|
111
|
+
* 1. Best-effort fetch the remote base (`haystack submit` is already online —
|
|
112
|
+
* it pushes and opens a PR — so one ref fetch is cheap).
|
|
113
|
+
* 2. If the fetch succeeded, the remote-tracking ref is the authoritative
|
|
114
|
+
* remote tip — use it (even over a local <base> with unpushed commits; the
|
|
115
|
+
* PR will merge into the remote base, not the local one).
|
|
116
|
+
* 3. If the fetch failed (offline, private origin without ambient git creds,
|
|
117
|
+
* base not on remote), don't blindly trust a possibly-stale *cached*
|
|
118
|
+
* remote-tracking ref — pick whichever of it and local <base> is more
|
|
119
|
+
* advanced (the one that is not an ancestor of the other), since a base
|
|
120
|
+
* behind HEAD's fork point is exactly what balloons the diff.
|
|
121
|
+
* 4. Fall back to whatever single qualified ref resolves, then the bare
|
|
122
|
+
* <base> name (preserves prior behavior rather than breaking triage).
|
|
123
|
+
*/
|
|
124
|
+
export declare function resolveDiffBaseRef(baseBranch: string): string;
|
|
93
125
|
/**
|
|
94
126
|
* Get the number of commits ahead of base branch
|
|
95
127
|
*/
|
package/dist/utils/git.js
CHANGED
|
@@ -366,6 +366,107 @@ export function getCommitMessagesSinceBase(baseBranch) {
|
|
|
366
366
|
}
|
|
367
367
|
}
|
|
368
368
|
}
|
|
369
|
+
/** True if `ref` resolves to a commit in the current repo. */
|
|
370
|
+
function gitRefResolves(ref) {
|
|
371
|
+
try {
|
|
372
|
+
execSync(`git rev-parse --verify --quiet ${ref}^{commit}`, {
|
|
373
|
+
stdio: ['pipe', 'pipe', 'pipe'],
|
|
374
|
+
});
|
|
375
|
+
return true;
|
|
376
|
+
}
|
|
377
|
+
catch {
|
|
378
|
+
return false;
|
|
379
|
+
}
|
|
380
|
+
}
|
|
381
|
+
/** True if `ancestor` is an ancestor of (or equal to) `descendant`. */
|
|
382
|
+
function gitIsAncestor(ancestor, descendant) {
|
|
383
|
+
try {
|
|
384
|
+
// exit 0 => ancestor; exit 1 => not; both non-throwing for us via stdio pipe
|
|
385
|
+
execSync(`git merge-base --is-ancestor ${ancestor} ${descendant}`, {
|
|
386
|
+
stdio: ['pipe', 'pipe', 'pipe'],
|
|
387
|
+
});
|
|
388
|
+
return true;
|
|
389
|
+
}
|
|
390
|
+
catch {
|
|
391
|
+
return false;
|
|
392
|
+
}
|
|
393
|
+
}
|
|
394
|
+
/**
|
|
395
|
+
* Resolve the git ref to diff a PR branch against for review.
|
|
396
|
+
*
|
|
397
|
+
* A bare local `<base>` ref is frequently stale (not checked out or pulled in a
|
|
398
|
+
* while). When it sits behind HEAD's true fork point, the three-dot merge-base
|
|
399
|
+
* jumps far back in history and inflates the review diff to include code from
|
|
400
|
+
* already-merged PRs — reviewers then mis-attribute pre-existing code to this
|
|
401
|
+
* PR. So we prefer the freshly-fetched remote base. This mirrors
|
|
402
|
+
* getCommitMessagesSinceBase, which already compares against the remote base.
|
|
403
|
+
*
|
|
404
|
+
* Returns a fully-qualified ref (`refs/remotes/origin/<base>` or
|
|
405
|
+
* `refs/heads/<base>`) so a tag that happens to share the name can never shadow
|
|
406
|
+
* the ref we mean — `git rev-parse <name>` prefers refs/tags/* over both
|
|
407
|
+
* refs/heads/* and refs/remotes/*. The bare `<base>` name is only returned as a
|
|
408
|
+
* last resort when nothing qualified resolves (the eventual `git diff` then
|
|
409
|
+
* errors and the caller falls back to running it itself).
|
|
410
|
+
*
|
|
411
|
+
* Resolution:
|
|
412
|
+
* 1. Best-effort fetch the remote base (`haystack submit` is already online —
|
|
413
|
+
* it pushes and opens a PR — so one ref fetch is cheap).
|
|
414
|
+
* 2. If the fetch succeeded, the remote-tracking ref is the authoritative
|
|
415
|
+
* remote tip — use it (even over a local <base> with unpushed commits; the
|
|
416
|
+
* PR will merge into the remote base, not the local one).
|
|
417
|
+
* 3. If the fetch failed (offline, private origin without ambient git creds,
|
|
418
|
+
* base not on remote), don't blindly trust a possibly-stale *cached*
|
|
419
|
+
* remote-tracking ref — pick whichever of it and local <base> is more
|
|
420
|
+
* advanced (the one that is not an ancestor of the other), since a base
|
|
421
|
+
* behind HEAD's fork point is exactly what balloons the diff.
|
|
422
|
+
* 4. Fall back to whatever single qualified ref resolves, then the bare
|
|
423
|
+
* <base> name (preserves prior behavior rather than breaking triage).
|
|
424
|
+
*/
|
|
425
|
+
export function resolveDiffBaseRef(baseBranch) {
|
|
426
|
+
const remoteRef = `refs/remotes/origin/${baseBranch}`;
|
|
427
|
+
const localRef = `refs/heads/${baseBranch}`;
|
|
428
|
+
// Freshen the remote base so the merge-base reflects the true fork point.
|
|
429
|
+
// Fetch with a fully-qualified refspec rather than a bare
|
|
430
|
+
// `git fetch origin <base>`:
|
|
431
|
+
// - Explicit destination: in clones whose configured fetch refspec does
|
|
432
|
+
// not cover <base> — a `--single-branch` clone, or `haystack submit
|
|
433
|
+
// --base release/x` — a bare fetch updates only FETCH_HEAD and leaves
|
|
434
|
+
// refs/remotes/origin/<base> stale or absent.
|
|
435
|
+
// - Qualified source (`refs/heads/<base>`): if the remote has both a
|
|
436
|
+
// branch and a tag named <base> (e.g. a `release` / `v1.0` base), an
|
|
437
|
+
// unqualified source could resolve to the tag and force the
|
|
438
|
+
// remote-tracking ref to it, making triage diff against the tag.
|
|
439
|
+
// `+` force-updates the remote-tracking ref to the remote tip (correct even
|
|
440
|
+
// if <base> was rewound on the remote).
|
|
441
|
+
let fetched = false;
|
|
442
|
+
try {
|
|
443
|
+
execSync(`git fetch origin +refs/heads/${baseBranch}:${remoteRef}`, {
|
|
444
|
+
stdio: ['pipe', 'pipe', 'pipe'],
|
|
445
|
+
});
|
|
446
|
+
fetched = true;
|
|
447
|
+
}
|
|
448
|
+
catch {
|
|
449
|
+
// Offline / no creds / base not on remote — fall through to degraded
|
|
450
|
+
// selection below using whatever refs already exist locally.
|
|
451
|
+
}
|
|
452
|
+
const remoteResolves = gitRefResolves(remoteRef);
|
|
453
|
+
// Happy path: we just refreshed the remote base, so it's authoritative.
|
|
454
|
+
if (fetched && remoteResolves)
|
|
455
|
+
return remoteRef;
|
|
456
|
+
// Degraded path: fetch failed (or didn't produce the ref). Prefer the more
|
|
457
|
+
// up-to-date of the cached remote ref and the local branch; only a base that
|
|
458
|
+
// lags HEAD's fork point balloons the diff, so pick the one that's ahead.
|
|
459
|
+
const localResolves = gitRefResolves(localRef);
|
|
460
|
+
if (remoteResolves && localResolves) {
|
|
461
|
+
// If the remote ref is an ancestor of the local ref, local is ahead → use it.
|
|
462
|
+
return gitIsAncestor(remoteRef, localRef) ? localRef : remoteRef;
|
|
463
|
+
}
|
|
464
|
+
if (remoteResolves)
|
|
465
|
+
return remoteRef;
|
|
466
|
+
if (localResolves)
|
|
467
|
+
return localRef;
|
|
468
|
+
return baseBranch;
|
|
469
|
+
}
|
|
369
470
|
/**
|
|
370
471
|
* Get the number of commits ahead of base branch
|
|
371
472
|
*/
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@haystackeditor/cli",
|
|
3
|
-
"version": "0.15.
|
|
3
|
+
"version": "0.15.12",
|
|
4
4
|
"description": "Set up Haystack for your project — automated PR review, triage, and merge queue",
|
|
5
5
|
"type": "module",
|
|
6
6
|
"bin": {
|
|
@@ -11,7 +11,8 @@
|
|
|
11
11
|
"dev": "tsc --watch",
|
|
12
12
|
"start": "node dist/index.js",
|
|
13
13
|
"check:schemas": "node scripts/check-schemas.mjs",
|
|
14
|
-
"
|
|
14
|
+
"release": "node scripts/release.mjs",
|
|
15
|
+
"prepublishOnly": "node scripts/no-direct-publish.mjs"
|
|
15
16
|
},
|
|
16
17
|
"keywords": [
|
|
17
18
|
"haystack",
|