mandrel 2.36.0 → 2.37.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/.agents/docs/configuration.md +25 -0
- package/.agents/rules/testing-standards.md +56 -0
- package/.agents/scripts/boot-sweep.js +7 -1
- package/.agents/scripts/check-context-budget.js +195 -103
- package/.agents/scripts/check-cyclomatic.js +112 -42
- package/.agents/scripts/check-generated-validator.js +202 -0
- package/.agents/scripts/check-lifecycle-lint.js +2 -68
- package/.agents/scripts/check-schema-references.js +2 -28
- package/.agents/scripts/drain-pending-cleanup.js +18 -1
- package/.agents/scripts/lib/baselines/components.js +32 -2
- package/.agents/scripts/lib/baselines/env-overrides.js +1 -1
- package/.agents/scripts/lib/bootstrap/branch-protection.js +1 -1
- package/.agents/scripts/lib/bootstrap/install-ledger.js +1 -1
- package/.agents/scripts/lib/bootstrap/prompt.js +1 -1
- package/.agents/scripts/lib/bootstrap/quality-bootstrap.js +2 -2
- package/.agents/scripts/lib/checks/loop-health.js +1 -1
- package/.agents/scripts/lib/cli-args.js +0 -2
- package/.agents/scripts/lib/close-validation/gates.js +1 -1
- package/.agents/scripts/lib/close-validation/process.js +1 -1
- package/.agents/scripts/lib/config/sync-agentrc.js +1 -1
- package/.agents/scripts/lib/config-settings-schema.js +46 -6
- package/.agents/scripts/lib/coverage-baseline.js +2 -2
- package/.agents/scripts/lib/cpu-pool.js +90 -10
- package/.agents/scripts/lib/crap-utils.js +6 -2
- package/.agents/scripts/lib/cyclomatic-ceiling.js +28 -6
- package/.agents/scripts/lib/cyclomatic-scope.js +144 -0
- package/.agents/scripts/lib/dependency-version.js +86 -0
- package/.agents/scripts/lib/duplicate-search.js +1 -1
- package/.agents/scripts/lib/errors/index.js +1 -1
- package/.agents/scripts/lib/feedback-loop/graduator-core.js +2 -2
- package/.agents/scripts/lib/feedback-loop/prior-feedback-fetcher.js +1 -1
- package/.agents/scripts/lib/feedback-loop/retro-proposals-graduator.js +1 -1
- package/.agents/scripts/lib/generated/agentrc-validator.js +16 -0
- package/.agents/scripts/lib/install-cmd-parser.js +1 -1
- package/.agents/scripts/lib/knip-entry-sync.js +1 -57
- package/.agents/scripts/lib/maintainability-utils.js +108 -10
- package/.agents/scripts/lib/observability/source-classifier.js +1 -0
- package/.agents/scripts/lib/orchestration/plan-persist/story-ops.js +70 -9
- package/.agents/scripts/lib/orchestration/pr-watch.js +1 -30
- package/.agents/scripts/lib/orchestration/reassert-status-column.js +2 -2
- package/.agents/scripts/lib/orchestration/review-providers/codex.js +3 -3
- package/.agents/scripts/lib/orchestration/review-providers/findings-renderer.js +1 -1
- package/.agents/scripts/lib/orchestration/review-providers/native.js +9 -2
- package/.agents/scripts/lib/orchestration/review-providers/review-depth.js +2 -2
- package/.agents/scripts/lib/orchestration/review-providers/security-review.js +3 -3
- package/.agents/scripts/lib/orchestration/single-story-close/phases/options.js +52 -1
- package/.agents/scripts/lib/orchestration/story-close/format-autofix.js +2 -5
- package/.agents/scripts/lib/orchestration/story-follow-ups.js +1 -1
- package/.agents/scripts/lib/orchestration/ticket-lease.js +34 -9
- package/.agents/scripts/lib/orchestration/ticketing/reads.js +3 -1
- package/.agents/scripts/lib/preflight-runner.js +3 -3
- package/.agents/scripts/lib/qa/qa-session.js +1 -1
- package/.agents/scripts/lib/runtime-deps/manifest.js +1 -7
- package/.agents/scripts/lib/runtime-deps/scan-imports.js +3 -58
- package/.agents/scripts/lib/single-story-sweep/sweep-lock.js +284 -25
- package/.agents/scripts/lib/skills/walk-skill-files.js +1 -1
- package/.agents/scripts/lib/source-text/strip-js-comments.js +115 -0
- package/.agents/scripts/lib/test-isolate/runner.js +3 -3
- package/.agents/scripts/lib/test-runner-contract.js +14 -6
- package/.agents/scripts/lib/test-tiers.js +135 -18
- package/.agents/scripts/lib/transpile.js +16 -3
- package/.agents/scripts/lib/worktree/lifecycle/pending-cleanup.js +169 -31
- package/.agents/scripts/lib/worktree/node-modules-strategy.js +2 -5
- package/.agents/scripts/pr-watch-with-update.js +305 -137
- package/.agents/scripts/providers/github/tickets.js +109 -16
- package/.agents/scripts/quality-preview.js +162 -70
- package/.agents/scripts/run-test-profile.js +8 -5
- package/.agents/scripts/run-tests.js +79 -14
- package/.agents/scripts/single-story-close.js +0 -2
- package/.agents/scripts/single-story-init.js +5 -1
- package/docs/CHANGELOG.md +32 -0
- package/lib/cli/migrate.js +100 -94
- package/lib/migrations/helpers/retire-agentrc-key.js +200 -0
- package/lib/migrations/index.js +32 -33
- package/lib/migrations/steps/2.1.0-retire-mi-drop-knobs.js +21 -80
- package/lib/migrations/steps/2.1.0-retire-verify-concurrency-cap.js +15 -87
- package/lib/migrations/steps/2.11.0-retire-max-seed-words.js +13 -71
- package/lib/migrations/steps/2.20.0-retire-codebase-snapshot.js +13 -101
- package/lib/migrations/steps/2.32.0-retire-lint-baseline-command.js +13 -100
- package/package.json +7 -3
- package/.agents/scripts/lib/audit-suite/frontmatter-lint.js +0 -32
- package/.agents/scripts/lib/baselines/maintainability-baseline-save.js +0 -37
- package/.agents/scripts/lib/cli/parse-numeric.js +0 -60
- package/.agents/scripts/lib/close-validation/telemetry.js +0 -79
- package/.agents/scripts/lib/orchestration/label-transitions.js +0 -44
- package/.agents/scripts/lib/orchestration/parked-follow-ons.js +0 -147
- package/.agents/scripts/lib/orchestration/phase-runner.js +0 -88
- package/.agents/scripts/lib/orchestration/recut.js +0 -56
|
@@ -487,6 +487,272 @@ async function evaluateGreenWatch({
|
|
|
487
487
|
};
|
|
488
488
|
}
|
|
489
489
|
|
|
490
|
+
/**
|
|
491
|
+
* Resolve the knobs, temp root and working directory one watch run needs.
|
|
492
|
+
*
|
|
493
|
+
* Split out of `runPrWatch` because config resolution must not abort the
|
|
494
|
+
* watch: a broken `.agentrc` should degrade to defaults, not turn a CI probe
|
|
495
|
+
* into a crash.
|
|
496
|
+
*
|
|
497
|
+
* @param {{ config?: object, tempRoot?: string, logger: object, flags: object }} params
|
|
498
|
+
* @returns {{ knobs: object, effectiveTempRoot: string, cwd: string }}
|
|
499
|
+
*/
|
|
500
|
+
function resolveWatchContext({ config, tempRoot, logger, flags }) {
|
|
501
|
+
const resolvedConfig =
|
|
502
|
+
config !== undefined ? config : safeResolveConfig(logger);
|
|
503
|
+
const knobs = resolveWatchKnobs({ config: resolvedConfig, flags });
|
|
504
|
+
const effectiveTempRoot =
|
|
505
|
+
tempRoot ?? resolvedConfig?.project?.paths?.tempRoot ?? 'temp';
|
|
506
|
+
return { knobs, effectiveTempRoot, cwd: process.cwd() };
|
|
507
|
+
}
|
|
508
|
+
|
|
509
|
+
/**
|
|
510
|
+
* Build the argument bag for the underlying watch port.
|
|
511
|
+
*
|
|
512
|
+
* Each injectable port is spread in only when supplied so the port keeps its
|
|
513
|
+
* own default — passing `undefined` explicitly would override a default with
|
|
514
|
+
* nothing and break every caller that relies on it.
|
|
515
|
+
*
|
|
516
|
+
* @param {object} params
|
|
517
|
+
* @returns {object}
|
|
518
|
+
*/
|
|
519
|
+
function buildWatchArgs({
|
|
520
|
+
prRef,
|
|
521
|
+
repo,
|
|
522
|
+
cwd,
|
|
523
|
+
knobs,
|
|
524
|
+
ghPrChecksFn,
|
|
525
|
+
ghPrViewFn,
|
|
526
|
+
ghPrUpdateBranchFn,
|
|
527
|
+
sleepFn,
|
|
528
|
+
logger,
|
|
529
|
+
}) {
|
|
530
|
+
return {
|
|
531
|
+
prUrl: prRef,
|
|
532
|
+
repo,
|
|
533
|
+
cwd,
|
|
534
|
+
maxPolls: knobs.maxPolls,
|
|
535
|
+
maxUpdates: knobs.maxUpdates,
|
|
536
|
+
maxResumes: knobs.maxResumes,
|
|
537
|
+
pollIntervalMs: knobs.pollIntervalMs,
|
|
538
|
+
...(ghPrChecksFn ? { ghPrChecksFn } : {}),
|
|
539
|
+
...(ghPrViewFn ? { ghPrViewFn } : {}),
|
|
540
|
+
...(ghPrUpdateBranchFn ? { ghPrUpdateBranchFn } : {}),
|
|
541
|
+
...(sleepFn ? { sleepFn } : {}),
|
|
542
|
+
logger,
|
|
543
|
+
};
|
|
544
|
+
}
|
|
545
|
+
|
|
546
|
+
/**
|
|
547
|
+
* One terminal outcome of the watch: the attach window is spent and no
|
|
548
|
+
* required check ever attached, or the pull request could not be read at all.
|
|
549
|
+
*
|
|
550
|
+
* The two are distinguished structurally — never against `gh`'s stderr prose
|
|
551
|
+
* (Story #4890) — because they route oppositely: CI that has not started is
|
|
552
|
+
* slow (exit 2, keep watching), while an unreadable PR is a `gh` / access
|
|
553
|
+
* fault (exit 1).
|
|
554
|
+
*
|
|
555
|
+
* @param {object} params
|
|
556
|
+
* @returns {number} exit code
|
|
557
|
+
*/
|
|
558
|
+
function reportUnattachedOrError({
|
|
559
|
+
result,
|
|
560
|
+
envelope,
|
|
561
|
+
prNumber,
|
|
562
|
+
knobs,
|
|
563
|
+
logger,
|
|
564
|
+
print,
|
|
565
|
+
}) {
|
|
566
|
+
const notYetStarted = Boolean(
|
|
567
|
+
result.requiredChecksEmpty && result.prResolvable,
|
|
568
|
+
);
|
|
569
|
+
print(
|
|
570
|
+
JSON.stringify({
|
|
571
|
+
...envelope,
|
|
572
|
+
requiredChecksEmpty: Boolean(result.requiredChecksEmpty),
|
|
573
|
+
notYetStarted,
|
|
574
|
+
}),
|
|
575
|
+
);
|
|
576
|
+
if (notYetStarted) {
|
|
577
|
+
logger.warn?.(
|
|
578
|
+
`[pr-watch] no required check has attached to PR #${prNumber} within the ` +
|
|
579
|
+
`${Math.round(knobs.attachWindowMs / 1000)}s attach window (${result.attachRetries} re-resolutions), ` +
|
|
580
|
+
'and the pull request still reads back fine — this is CI that has not started, NOT a red check. ' +
|
|
581
|
+
'Keep polling natively:',
|
|
582
|
+
);
|
|
583
|
+
logger.warn?.('[pr-watch] gh pr checks <pr> --watch');
|
|
584
|
+
return STILL_RUNNING_EXIT_CODE;
|
|
585
|
+
}
|
|
586
|
+
logger.error?.(
|
|
587
|
+
`[pr-watch] could not resolve required checks: ${result.error} — the pull request itself could ` +
|
|
588
|
+
'not be read, so this is a `gh` / access fault rather than CI that has not started.',
|
|
589
|
+
);
|
|
590
|
+
return 1;
|
|
591
|
+
}
|
|
592
|
+
|
|
593
|
+
/**
|
|
594
|
+
* Settle a watch whose observed required checks all came back green.
|
|
595
|
+
*
|
|
596
|
+
* Never green on an undercount (Story #4873): the observed set is whatever
|
|
597
|
+
* `gh pr checks --required` returned on the FIRST probe, so a context a
|
|
598
|
+
* ruleset attached later can leave every check we knew about green while
|
|
599
|
+
* GitHub still refuses the merge. An unreconcilable set reports unresolved
|
|
600
|
+
* (exit 2, keep watching) rather than a false green or a false red.
|
|
601
|
+
*
|
|
602
|
+
* @param {object} params
|
|
603
|
+
* @returns {Promise<number>} exit code
|
|
604
|
+
*/
|
|
605
|
+
async function settleGreenWatch({
|
|
606
|
+
result,
|
|
607
|
+
envelope,
|
|
608
|
+
readMergeState,
|
|
609
|
+
storyId,
|
|
610
|
+
prNumber,
|
|
611
|
+
guardPrRef,
|
|
612
|
+
effectiveTempRoot,
|
|
613
|
+
cwd,
|
|
614
|
+
readDigestFn,
|
|
615
|
+
retireDigestFn,
|
|
616
|
+
headShaFn,
|
|
617
|
+
reArmAutoMergeFn,
|
|
618
|
+
blockDeliveryFn,
|
|
619
|
+
logger,
|
|
620
|
+
print,
|
|
621
|
+
}) {
|
|
622
|
+
const reconciliation = reconcileGreenVerdict({
|
|
623
|
+
observedRequired: result.requiredChecks,
|
|
624
|
+
mergeStateStatus: await readMergeState(),
|
|
625
|
+
});
|
|
626
|
+
if (!reconciliation.reconciled) {
|
|
627
|
+
print(JSON.stringify({ ...envelope, reconciliation }));
|
|
628
|
+
logger.warn?.(
|
|
629
|
+
`[pr-watch] withholding the green verdict: ${reconciliation.reason}. ` +
|
|
630
|
+
'Re-run the watch once the repository settles, or inspect branch protection for a context this watch never saw.',
|
|
631
|
+
);
|
|
632
|
+
return STILL_RUNNING_EXIT_CODE;
|
|
633
|
+
}
|
|
634
|
+
const guard = await evaluateGreenWatch({
|
|
635
|
+
storyId,
|
|
636
|
+
prNumber,
|
|
637
|
+
prRef: guardPrRef,
|
|
638
|
+
tempRoot: effectiveTempRoot,
|
|
639
|
+
cwd,
|
|
640
|
+
readDigestFn,
|
|
641
|
+
retireDigestFn,
|
|
642
|
+
headShaFn,
|
|
643
|
+
reArmFn: reArmAutoMergeFn,
|
|
644
|
+
blockFn: blockDeliveryFn,
|
|
645
|
+
logger,
|
|
646
|
+
});
|
|
647
|
+
print(JSON.stringify({ ...envelope, reconciliation, rerunGuard: guard }));
|
|
648
|
+
if (guard.exitCode === 0) {
|
|
649
|
+
logger.info?.('[pr-watch] all required checks green.');
|
|
650
|
+
}
|
|
651
|
+
return guard.exitCode;
|
|
652
|
+
}
|
|
653
|
+
|
|
654
|
+
/**
|
|
655
|
+
* Slow-but-not-red: the poll cap AND the resume budget are exhausted with
|
|
656
|
+
* checks still pending and none failed. Never exit 1, never `timed_out` —
|
|
657
|
+
* hand off to the host's interval loop and exit 2.
|
|
658
|
+
*
|
|
659
|
+
* @param {object} params
|
|
660
|
+
* @returns {number} exit code
|
|
661
|
+
*/
|
|
662
|
+
function reportStillRunning({ result, envelope, logger, print }) {
|
|
663
|
+
print(JSON.stringify(envelope));
|
|
664
|
+
const stillPending = Object.entries(result.outcomes)
|
|
665
|
+
.filter(([, v]) => v === 'still-running')
|
|
666
|
+
.map(([k]) => k)
|
|
667
|
+
.join(', ');
|
|
668
|
+
logger.warn?.(
|
|
669
|
+
`[pr-watch] required check(s) still running after ${result.polls} polls + ${result.resumesApplied} resumes: ${stillPending}. Keep polling natively:`,
|
|
670
|
+
);
|
|
671
|
+
logger.warn?.('[pr-watch] gh pr checks <pr> --watch');
|
|
672
|
+
return STILL_RUNNING_EXIT_CODE;
|
|
673
|
+
}
|
|
674
|
+
|
|
675
|
+
/**
|
|
676
|
+
* The failing checks, excluding every non-failing state **and**
|
|
677
|
+
* `still-running`: when the cap fires with a mixed failed+pending map,
|
|
678
|
+
* `promotePendingToStillRunning` has rewritten the pending entries, and a
|
|
679
|
+
* still-running check is slow, not red — including it here would let it become
|
|
680
|
+
* the digest's "primary" failing check and mispoint the diagnosis.
|
|
681
|
+
*
|
|
682
|
+
* @param {Record<string, string>} outcomes
|
|
683
|
+
* @returns {Array<{ name: string, outcome: string }>}
|
|
684
|
+
*/
|
|
685
|
+
function collectFailures(outcomes) {
|
|
686
|
+
return Object.entries(outcomes)
|
|
687
|
+
.filter(
|
|
688
|
+
([, v]) =>
|
|
689
|
+
v !== 'success' &&
|
|
690
|
+
v !== 'neutral' &&
|
|
691
|
+
v !== 'skipped' &&
|
|
692
|
+
v !== 'still-running',
|
|
693
|
+
)
|
|
694
|
+
.map(([name, outcome]) => ({ name, outcome }));
|
|
695
|
+
}
|
|
696
|
+
|
|
697
|
+
/**
|
|
698
|
+
* Genuine red check — exit 1 immediately, disarm auto-merge, write the digest,
|
|
699
|
+
* and surface the fix-loop handoff.
|
|
700
|
+
*
|
|
701
|
+
* @param {object} params
|
|
702
|
+
* @returns {Promise<number>} exit code
|
|
703
|
+
*/
|
|
704
|
+
async function settleRedWatch({
|
|
705
|
+
result,
|
|
706
|
+
envelope,
|
|
707
|
+
storyId,
|
|
708
|
+
prNumber,
|
|
709
|
+
guardPrRef,
|
|
710
|
+
effectiveTempRoot,
|
|
711
|
+
cwd,
|
|
712
|
+
writeDigestFn,
|
|
713
|
+
headShaFn,
|
|
714
|
+
disarmAutoMergeFn,
|
|
715
|
+
blockDeliveryFn,
|
|
716
|
+
logger,
|
|
717
|
+
print,
|
|
718
|
+
}) {
|
|
719
|
+
const failures = collectFailures(result.outcomes);
|
|
720
|
+
const red = failures.map((f) => `${f.name}=${f.outcome}`).join(', ');
|
|
721
|
+
logger.error?.(`[pr-watch] required check(s) not green: ${red}`);
|
|
722
|
+
const redOutcome = await handleRedWatch({
|
|
723
|
+
storyId,
|
|
724
|
+
prNumber,
|
|
725
|
+
prRef: guardPrRef,
|
|
726
|
+
failures,
|
|
727
|
+
tempRoot: effectiveTempRoot,
|
|
728
|
+
cwd,
|
|
729
|
+
writeDigestFn,
|
|
730
|
+
headShaFn,
|
|
731
|
+
disarmFn: disarmAutoMergeFn,
|
|
732
|
+
blockFn: blockDeliveryFn,
|
|
733
|
+
logger,
|
|
734
|
+
});
|
|
735
|
+
print(
|
|
736
|
+
JSON.stringify({
|
|
737
|
+
...envelope,
|
|
738
|
+
classification: classifyFailure(failures[0]?.name),
|
|
739
|
+
rerunGuard: {
|
|
740
|
+
verdict: 'red',
|
|
741
|
+
headSha: redOutcome.headSha,
|
|
742
|
+
autoMergeDisarmed: redOutcome.disarm.disarmed,
|
|
743
|
+
disarmDetail: redOutcome.disarm.detail,
|
|
744
|
+
digestPath: redOutcome.digestPaths?.jsonPath ?? null,
|
|
745
|
+
blocked: redOutcome.blocked,
|
|
746
|
+
},
|
|
747
|
+
}),
|
|
748
|
+
);
|
|
749
|
+
logger.error?.(
|
|
750
|
+
'[pr-watch] a required check failed. Read the digest, reproduce the failure, and apply the smallest fix at source, ' +
|
|
751
|
+
'then push a new commit — re-running the failed job is forbidden (`.agents/rules/ci-remediation.md` § Verifier).',
|
|
752
|
+
);
|
|
753
|
+
return 1;
|
|
754
|
+
}
|
|
755
|
+
|
|
490
756
|
/**
|
|
491
757
|
* Run the watch loop and resolve to the exit code. Exported for tests so
|
|
492
758
|
* the green / red / still-running / BEHIND paths can be exercised with
|
|
@@ -557,21 +823,12 @@ export async function runPrWatch({
|
|
|
557
823
|
if (!Number.isInteger(prNumber) || prNumber < 1)
|
|
558
824
|
throw new TypeError('runPrWatch: --pr requires a positive integer');
|
|
559
825
|
|
|
560
|
-
const
|
|
561
|
-
config
|
|
562
|
-
|
|
563
|
-
|
|
564
|
-
flags: {
|
|
565
|
-
pollIntervalMs,
|
|
566
|
-
maxPolls,
|
|
567
|
-
maxResumes,
|
|
568
|
-
maxUpdates,
|
|
569
|
-
attachWindowMs,
|
|
570
|
-
},
|
|
826
|
+
const { knobs, effectiveTempRoot, cwd } = resolveWatchContext({
|
|
827
|
+
config,
|
|
828
|
+
tempRoot,
|
|
829
|
+
logger,
|
|
830
|
+
flags: { pollIntervalMs, maxPolls, maxResumes, maxUpdates, attachWindowMs },
|
|
571
831
|
});
|
|
572
|
-
const effectiveTempRoot =
|
|
573
|
-
tempRoot ?? resolvedConfig?.project?.paths?.tempRoot ?? 'temp';
|
|
574
|
-
const cwd = process.cwd();
|
|
575
832
|
|
|
576
833
|
// `gh` has NO `<owner/repo>#<number>` argument form — it parses that string
|
|
577
834
|
// as a BRANCH NAME, which is why every `--repo` invocation used to fail at
|
|
@@ -596,20 +853,17 @@ export async function runPrWatch({
|
|
|
596
853
|
probeMergeState({ prRef: guardPrRef, prUrl: prRef, repo, cwd, prNumber });
|
|
597
854
|
|
|
598
855
|
const result = await watchWithAttachWindow({
|
|
599
|
-
watchArgs: {
|
|
600
|
-
|
|
856
|
+
watchArgs: buildWatchArgs({
|
|
857
|
+
prRef,
|
|
601
858
|
repo,
|
|
602
859
|
cwd,
|
|
603
|
-
|
|
604
|
-
|
|
605
|
-
|
|
606
|
-
|
|
607
|
-
|
|
608
|
-
...(ghPrViewFn ? { ghPrViewFn } : {}),
|
|
609
|
-
...(ghPrUpdateBranchFn ? { ghPrUpdateBranchFn } : {}),
|
|
610
|
-
...(sleepFn ? { sleepFn } : {}),
|
|
860
|
+
knobs,
|
|
861
|
+
ghPrChecksFn,
|
|
862
|
+
ghPrViewFn,
|
|
863
|
+
ghPrUpdateBranchFn,
|
|
864
|
+
sleepFn,
|
|
611
865
|
logger,
|
|
612
|
-
},
|
|
866
|
+
}),
|
|
613
867
|
attachWindowMs: knobs.attachWindowMs,
|
|
614
868
|
retryIntervalMs: knobs.pollIntervalMs,
|
|
615
869
|
sleepFn: sleepFn ?? defaultSleep,
|
|
@@ -637,141 +891,55 @@ export async function runPrWatch({
|
|
|
637
891
|
};
|
|
638
892
|
|
|
639
893
|
if (result.requiredChecksEmpty || result.error) {
|
|
640
|
-
|
|
641
|
-
|
|
642
|
-
|
|
643
|
-
|
|
644
|
-
|
|
645
|
-
|
|
646
|
-
|
|
647
|
-
|
|
648
|
-
requiredChecksEmpty: Boolean(result.requiredChecksEmpty),
|
|
649
|
-
notYetStarted,
|
|
650
|
-
}),
|
|
651
|
-
);
|
|
652
|
-
if (notYetStarted) {
|
|
653
|
-
logger.warn?.(
|
|
654
|
-
`[pr-watch] no required check has attached to PR #${prNumber} within the ` +
|
|
655
|
-
`${Math.round(knobs.attachWindowMs / 1000)}s attach window (${result.attachRetries} re-resolutions), ` +
|
|
656
|
-
'and the pull request still reads back fine — this is CI that has not started, NOT a red check. ' +
|
|
657
|
-
'Keep polling natively:',
|
|
658
|
-
);
|
|
659
|
-
logger.warn?.('[pr-watch] gh pr checks <pr> --watch');
|
|
660
|
-
return STILL_RUNNING_EXIT_CODE;
|
|
661
|
-
}
|
|
662
|
-
logger.error?.(
|
|
663
|
-
`[pr-watch] could not resolve required checks: ${result.error} — the pull request itself could ` +
|
|
664
|
-
'not be read, so this is a `gh` / access fault rather than CI that has not started.',
|
|
665
|
-
);
|
|
666
|
-
return 1;
|
|
894
|
+
return reportUnattachedOrError({
|
|
895
|
+
result,
|
|
896
|
+
envelope,
|
|
897
|
+
prNumber,
|
|
898
|
+
knobs,
|
|
899
|
+
logger,
|
|
900
|
+
print,
|
|
901
|
+
});
|
|
667
902
|
}
|
|
668
903
|
|
|
669
904
|
if (result.green) {
|
|
670
|
-
|
|
671
|
-
|
|
672
|
-
|
|
673
|
-
|
|
674
|
-
// repository's own verdict before any green is issued — an unreconcilable
|
|
675
|
-
// set reports unresolved (exit 2, keep watching) rather than a false green
|
|
676
|
-
// or a false red.
|
|
677
|
-
const reconciliation = reconcileGreenVerdict({
|
|
678
|
-
observedRequired: result.requiredChecks,
|
|
679
|
-
mergeStateStatus: await readMergeState(),
|
|
680
|
-
});
|
|
681
|
-
if (!reconciliation.reconciled) {
|
|
682
|
-
print(JSON.stringify({ ...envelope, reconciliation }));
|
|
683
|
-
logger.warn?.(
|
|
684
|
-
`[pr-watch] withholding the green verdict: ${reconciliation.reason}. ` +
|
|
685
|
-
'Re-run the watch once the repository settles, or inspect branch protection for a context this watch never saw.',
|
|
686
|
-
);
|
|
687
|
-
return STILL_RUNNING_EXIT_CODE;
|
|
688
|
-
}
|
|
689
|
-
const guard = await evaluateGreenWatch({
|
|
905
|
+
return await settleGreenWatch({
|
|
906
|
+
result,
|
|
907
|
+
envelope,
|
|
908
|
+
readMergeState,
|
|
690
909
|
storyId,
|
|
691
910
|
prNumber,
|
|
692
|
-
|
|
693
|
-
|
|
911
|
+
guardPrRef,
|
|
912
|
+
effectiveTempRoot,
|
|
694
913
|
cwd,
|
|
695
914
|
readDigestFn,
|
|
696
915
|
retireDigestFn,
|
|
697
916
|
headShaFn,
|
|
698
|
-
|
|
699
|
-
|
|
917
|
+
reArmAutoMergeFn,
|
|
918
|
+
blockDeliveryFn,
|
|
700
919
|
logger,
|
|
920
|
+
print,
|
|
701
921
|
});
|
|
702
|
-
print(JSON.stringify({ ...envelope, reconciliation, rerunGuard: guard }));
|
|
703
|
-
if (guard.exitCode === 0) {
|
|
704
|
-
logger.info?.('[pr-watch] all required checks green.');
|
|
705
|
-
}
|
|
706
|
-
return guard.exitCode;
|
|
707
922
|
}
|
|
708
923
|
|
|
709
|
-
// Slow-but-not-red: the cap AND resume budget are exhausted with checks
|
|
710
|
-
// still pending and none failed. Never exit 1, never `timed_out` — hand
|
|
711
|
-
// off to the host's interval loop and exit 2.
|
|
712
924
|
if (result.stillRunning) {
|
|
713
|
-
|
|
714
|
-
const stillPending = Object.entries(result.outcomes)
|
|
715
|
-
.filter(([, v]) => v === 'still-running')
|
|
716
|
-
.map(([k]) => k)
|
|
717
|
-
.join(', ');
|
|
718
|
-
logger.warn?.(
|
|
719
|
-
`[pr-watch] required check(s) still running after ${result.polls} polls + ${result.resumesApplied} resumes: ${stillPending}. Keep polling natively:`,
|
|
720
|
-
);
|
|
721
|
-
logger.warn?.('[pr-watch] gh pr checks <pr> --watch');
|
|
722
|
-
return STILL_RUNNING_EXIT_CODE;
|
|
925
|
+
return reportStillRunning({ result, envelope, logger, print });
|
|
723
926
|
}
|
|
724
927
|
|
|
725
|
-
|
|
726
|
-
|
|
727
|
-
|
|
728
|
-
// fires with a mixed failed+pending map, promotePendingToStillRunning has
|
|
729
|
-
// rewritten the pending entries, and a still-running check is slow, not
|
|
730
|
-
// red — including it here would let it become the digest's "primary"
|
|
731
|
-
// failing check and mispoint the diagnosis at a slow check.
|
|
732
|
-
const failures = Object.entries(result.outcomes)
|
|
733
|
-
.filter(
|
|
734
|
-
([, v]) =>
|
|
735
|
-
v !== 'success' &&
|
|
736
|
-
v !== 'neutral' &&
|
|
737
|
-
v !== 'skipped' &&
|
|
738
|
-
v !== 'still-running',
|
|
739
|
-
)
|
|
740
|
-
.map(([name, outcome]) => ({ name, outcome }));
|
|
741
|
-
const red = failures.map((f) => `${f.name}=${f.outcome}`).join(', ');
|
|
742
|
-
logger.error?.(`[pr-watch] required check(s) not green: ${red}`);
|
|
743
|
-
const redOutcome = await handleRedWatch({
|
|
928
|
+
return await settleRedWatch({
|
|
929
|
+
result,
|
|
930
|
+
envelope,
|
|
744
931
|
storyId,
|
|
745
932
|
prNumber,
|
|
746
|
-
|
|
747
|
-
|
|
748
|
-
tempRoot: effectiveTempRoot,
|
|
933
|
+
guardPrRef,
|
|
934
|
+
effectiveTempRoot,
|
|
749
935
|
cwd,
|
|
750
936
|
writeDigestFn,
|
|
751
937
|
headShaFn,
|
|
752
|
-
|
|
753
|
-
|
|
938
|
+
disarmAutoMergeFn,
|
|
939
|
+
blockDeliveryFn,
|
|
754
940
|
logger,
|
|
941
|
+
print,
|
|
755
942
|
});
|
|
756
|
-
print(
|
|
757
|
-
JSON.stringify({
|
|
758
|
-
...envelope,
|
|
759
|
-
classification: classifyFailure(failures[0]?.name),
|
|
760
|
-
rerunGuard: {
|
|
761
|
-
verdict: 'red',
|
|
762
|
-
headSha: redOutcome.headSha,
|
|
763
|
-
autoMergeDisarmed: redOutcome.disarm.disarmed,
|
|
764
|
-
disarmDetail: redOutcome.disarm.detail,
|
|
765
|
-
digestPath: redOutcome.digestPaths?.jsonPath ?? null,
|
|
766
|
-
blocked: redOutcome.blocked,
|
|
767
|
-
},
|
|
768
|
-
}),
|
|
769
|
-
);
|
|
770
|
-
logger.error?.(
|
|
771
|
-
'[pr-watch] a required check failed. Read the digest, reproduce the failure, and apply the smallest fix at source, ' +
|
|
772
|
-
'then push a new commit — re-running the failed job is forbidden (`.agents/rules/ci-remediation.md` § Verifier).',
|
|
773
|
-
);
|
|
774
|
-
return 1;
|
|
775
943
|
}
|
|
776
944
|
|
|
777
945
|
/** Resolve config without letting a config error abort the watch. */
|
|
@@ -267,35 +267,40 @@ export class TicketGateway {
|
|
|
267
267
|
* abort `/plan` persist with `1..k-1` already live on the tracker.
|
|
268
268
|
*
|
|
269
269
|
* Retry alone is not sufficient for that failure mode — a POST whose
|
|
270
|
-
* response is lost would double-create on the retry
|
|
271
|
-
*
|
|
272
|
-
*
|
|
273
|
-
*
|
|
270
|
+
* response is lost would double-create on the retry. Story #5112 closed
|
|
271
|
+
* that: `findExisting` is consulted **before every retry POST**, and a hit
|
|
272
|
+
* is adopted instead of re-created, so a response-lost first attempt files
|
|
273
|
+
* exactly one issue. `/plan` persist supplies the plan-fingerprint lookup
|
|
274
|
+
* its resume path already uses (`plan-persist`'s `createStoryIssues`);
|
|
275
|
+
* callers with no content identity omit it and keep the pre-#5112
|
|
276
|
+
* retry-only behaviour.
|
|
274
277
|
*
|
|
275
278
|
* @field-manifest POST /repos/{owner}/{repo}/issues: number, id, node_id,
|
|
276
279
|
* html_url
|
|
277
280
|
*
|
|
278
|
-
* @param {{
|
|
281
|
+
* @param {{
|
|
282
|
+
* title: string,
|
|
283
|
+
* body: string,
|
|
284
|
+
* labels?: string[],
|
|
285
|
+
* findExisting?: (() => Promise<object|null>)|null,
|
|
286
|
+
* }} payload
|
|
279
287
|
* @returns {Promise<{
|
|
280
288
|
* id: number,
|
|
281
289
|
* number: number,
|
|
282
290
|
* internalId: number,
|
|
283
291
|
* nodeId: string,
|
|
284
292
|
* url: string,
|
|
293
|
+
* adopted: boolean,
|
|
285
294
|
* boardAdd: { added: boolean, reason?: string },
|
|
286
295
|
* }>}
|
|
287
296
|
*/
|
|
288
|
-
async createIssue({ title, body, labels = [] }) {
|
|
289
|
-
const
|
|
290
|
-
|
|
291
|
-
|
|
292
|
-
|
|
293
|
-
|
|
294
|
-
|
|
295
|
-
}),
|
|
296
|
-
{ label: `createIssue "${title}"`, onRetry: defaultRetryWarn },
|
|
297
|
-
);
|
|
298
|
-
const issue = parseApiJson(result);
|
|
297
|
+
async createIssue({ title, body, labels = [], findExisting = null }) {
|
|
298
|
+
const { issue, adopted } = await this._createIssueOrAdopt({
|
|
299
|
+
title,
|
|
300
|
+
body,
|
|
301
|
+
labels,
|
|
302
|
+
findExisting,
|
|
303
|
+
});
|
|
299
304
|
this._listCache.clear();
|
|
300
305
|
|
|
301
306
|
const boardAdd = await addIssueToBoard({
|
|
@@ -311,10 +316,86 @@ export class TicketGateway {
|
|
|
311
316
|
internalId: issue.id,
|
|
312
317
|
nodeId: issue.node_id,
|
|
313
318
|
url: issue.html_url,
|
|
319
|
+
adopted,
|
|
314
320
|
boardAdd,
|
|
315
321
|
};
|
|
316
322
|
}
|
|
317
323
|
|
|
324
|
+
/**
|
|
325
|
+
* POST one issue and parse the created resource out of the response.
|
|
326
|
+
*
|
|
327
|
+
* @param {{ title: string, body: string, labels: string[] }} payload
|
|
328
|
+
* @returns {Promise<object>}
|
|
329
|
+
*/
|
|
330
|
+
async _postIssue(payload) {
|
|
331
|
+
return parseApiJson(
|
|
332
|
+
await this._gh.api({
|
|
333
|
+
method: 'POST',
|
|
334
|
+
endpoint: `/repos/${this.owner}/${this.repo}/issues`,
|
|
335
|
+
body: payload,
|
|
336
|
+
}),
|
|
337
|
+
);
|
|
338
|
+
}
|
|
339
|
+
|
|
340
|
+
/**
|
|
341
|
+
* Resolve the ambiguity a lost response leaves behind: did attempt 1 land?
|
|
342
|
+
* `ECONNRESET` after the server committed the create looks exactly like
|
|
343
|
+
* `ECONNRESET` before it, so the only authority is the server's own state.
|
|
344
|
+
*
|
|
345
|
+
* @param {(() => Promise<object|null>)|null} findExisting
|
|
346
|
+
* @returns {Promise<object|null>} the already-created issue, or `null`.
|
|
347
|
+
*/
|
|
348
|
+
async _findAlreadyCreated(findExisting) {
|
|
349
|
+
return typeof findExisting === 'function' ? await findExisting() : null;
|
|
350
|
+
}
|
|
351
|
+
|
|
352
|
+
/**
|
|
353
|
+
* The idempotent half of {@link TicketGateway#createIssue}: POST once, and
|
|
354
|
+
* on every subsequent attempt look for an already-created issue *before*
|
|
355
|
+
* posting again, so a retry adopts rather than duplicates.
|
|
356
|
+
*
|
|
357
|
+
* @param {{ title: string, body: string, labels: string[], findExisting: (() => Promise<object|null>)|null }} args
|
|
358
|
+
* @returns {Promise<{ issue: object, adopted: boolean }>}
|
|
359
|
+
*/
|
|
360
|
+
async _createIssueOrAdopt({ title, body, labels, findExisting }) {
|
|
361
|
+
let posts = 0;
|
|
362
|
+
return withTransientRetry(
|
|
363
|
+
async () => {
|
|
364
|
+
if (posts > 0) {
|
|
365
|
+
const issue = await this._findAlreadyCreated(findExisting);
|
|
366
|
+
if (issue) return { issue, adopted: true };
|
|
367
|
+
}
|
|
368
|
+
posts += 1;
|
|
369
|
+
const issue = await this._postIssue({ title, body, labels });
|
|
370
|
+
return { issue, adopted: false };
|
|
371
|
+
},
|
|
372
|
+
{ label: `createIssue "${title}"`, onRetry: defaultRetryWarn },
|
|
373
|
+
);
|
|
374
|
+
}
|
|
375
|
+
|
|
376
|
+
/**
|
|
377
|
+
* Append logins to an issue's assignee set via GitHub's additive assignees
|
|
378
|
+
* endpoint. No read-before-write and no replace semantics, so it cannot
|
|
379
|
+
* evict an assignee another run added between our read and our write. A
|
|
380
|
+
* non-array or empty list is a no-op.
|
|
381
|
+
*
|
|
382
|
+
* @param {number} ticketId
|
|
383
|
+
* @param {string[]|undefined} logins
|
|
384
|
+
*/
|
|
385
|
+
async _addAssignees(ticketId, logins) {
|
|
386
|
+
if (!(logins?.length > 0)) return;
|
|
387
|
+
await withTransientRetry(
|
|
388
|
+
() =>
|
|
389
|
+
this._gh.api({
|
|
390
|
+
method: 'POST',
|
|
391
|
+
endpoint: `/repos/${this.owner}/${this.repo}/issues/${ticketId}/assignees`,
|
|
392
|
+
body: { assignees: logins },
|
|
393
|
+
}),
|
|
394
|
+
{ label: `addAssignees #${ticketId}`, onRetry: defaultRetryWarn },
|
|
395
|
+
);
|
|
396
|
+
this.invalidateTicket(ticketId);
|
|
397
|
+
}
|
|
398
|
+
|
|
318
399
|
/**
|
|
319
400
|
* Add/remove labels on an issue. When the only mutation is "add", uses the
|
|
320
401
|
* additive labels endpoint (POST /issues/{n}/labels) for atomicity and to
|
|
@@ -370,11 +451,23 @@ export class TicketGateway {
|
|
|
370
451
|
* {@link TicketGateway#_applyLabelMutations}. Both are the write half of the
|
|
371
452
|
* fan-outs Story #4952 raised; the reads in this file were already wrapped.
|
|
372
453
|
*
|
|
454
|
+
* `mutations.addAssignees` (Story #5112) is the **additive** assignee
|
|
455
|
+
* write, on the same shape as the additive label POST above: it appends to
|
|
456
|
+
* the assignee set instead of replacing it. The lease claim
|
|
457
|
+
* (`lib/orchestration/ticket-lease.js`) needs that — a replacing PATCH
|
|
458
|
+
* evicts a simultaneous claimer silently, so both runs read a clean
|
|
459
|
+
* `[self]` on verify and both believe they hold the lease. Appending makes
|
|
460
|
+
* the collision *observable* as a co-assignment, which is exactly what the
|
|
461
|
+
* lease's `lost-race` back-out keys on. `mutations.assignees` (replace) is
|
|
462
|
+
* unchanged and still used by the steal and release paths.
|
|
463
|
+
*
|
|
464
|
+
* @field-manifest POST /repos/{owner}/{repo}/issues/{n}/assignees: assignees
|
|
373
465
|
* @field-manifest PATCH /repos/{owner}/{repo}/issues/{n}:
|
|
374
466
|
* body, assignees, state, state_reason, labels
|
|
375
467
|
*/
|
|
376
468
|
/* node:coverage ignore next */
|
|
377
469
|
async updateTicket(ticketId, mutations) {
|
|
470
|
+
await this._addAssignees(ticketId, mutations.addAssignees);
|
|
378
471
|
const patch = {};
|
|
379
472
|
if (mutations.body !== undefined) patch.body = mutations.body;
|
|
380
473
|
if (mutations.assignees) patch.assignees = mutations.assignees;
|