mandrel 2.24.0 → 2.26.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/agents/acceptance-critic.md +19 -21
- package/.agents/agents/auditor.md +16 -19
- package/.agents/agents/story-worker.md +41 -54
- package/.agents/audit-checklists/architecture.md +5 -1
- package/.agents/audit-checklists/baselines.md +21 -0
- package/.agents/audit-checklists/quality.md +3 -0
- package/.agents/docs/agentrc-reference.json +2 -1
- package/.agents/docs/configuration.md +2 -1
- package/.agents/docs/quality-gates.md +80 -18
- package/.agents/docs/workflows.md +4 -2
- package/.agents/instructions.md +1 -1
- package/.agents/rules/ci-remediation.md +68 -3
- package/.agents/schemas/agentrc.schema.json +6 -1
- package/.agents/schemas/audit-rules.json +15 -0
- package/.agents/schemas/baselines/audit-baselines-envelope.schema.json +242 -0
- package/.agents/schemas/baselines/baseline-envelope.schema.json +8 -0
- package/.agents/schemas/baselines/crap.schema.json +13 -1
- package/.agents/schemas/crap-report.schema.json +37 -0
- package/.agents/schemas/model-attribution.schema.json +4 -0
- package/.agents/scripts/acceptance-eval.js +124 -15
- package/.agents/scripts/audit-baselines.js +136 -0
- package/.agents/scripts/audit-labels-bootstrap.js +16 -95
- package/.agents/scripts/audit-to-stories.js +33 -3
- package/.agents/scripts/check-arch-cycles.js +12 -93
- package/.agents/scripts/check-baseline-drift.js +16 -3
- package/.agents/scripts/check-baselines.js +19 -3
- package/.agents/scripts/check-context-budget.js +95 -14
- package/.agents/scripts/check-cyclomatic.js +214 -0
- package/.agents/scripts/check-schema-references.js +392 -0
- package/.agents/scripts/check-test-temp-hygiene.js +121 -1
- package/.agents/scripts/check-workflow-timeouts.js +291 -0
- package/.agents/scripts/cleanup-repo-test-temp.js +7 -5
- package/.agents/scripts/deliver-light.js +3 -4
- package/.agents/scripts/deliver-recover.js +13 -0
- package/.agents/scripts/diagnose-friction.js +85 -19
- package/.agents/scripts/lib/audit-baselines/engine.js +177 -0
- package/.agents/scripts/lib/audit-baselines/gate-surface.js +63 -0
- package/.agents/scripts/lib/audit-baselines/headroom.js +72 -0
- package/.agents/scripts/lib/audit-baselines/hotspots.js +69 -0
- package/.agents/scripts/lib/audit-baselines/kinds.js +313 -0
- package/.agents/scripts/lib/audit-baselines/outliers.js +100 -0
- package/.agents/scripts/lib/audit-baselines/read.js +87 -0
- package/.agents/scripts/lib/audit-baselines/staleness.js +123 -0
- package/.agents/scripts/lib/audit-baselines/surface-entry.js +106 -0
- package/.agents/scripts/lib/audit-baselines/trend.js +125 -0
- package/.agents/scripts/lib/audit-baselines/weights.js +193 -0
- package/.agents/scripts/lib/audit-suite/index.js +0 -5
- package/.agents/scripts/lib/audit-suite/selector.js +9 -62
- package/.agents/scripts/lib/audit-to-stories/audit-label-taxonomy.js +177 -0
- package/.agents/scripts/lib/audit-to-stories/audit-lenses.js +1 -0
- package/.agents/scripts/lib/audit-to-stories/build-story-body.js +35 -3
- package/.agents/scripts/lib/audit-to-stories/group-findings.js +20 -11
- package/.agents/scripts/lib/audit-to-stories/parse-audit-md.js +23 -9
- package/.agents/scripts/lib/audit-to-stories/seed-from-findings.js +9 -2
- package/.agents/scripts/lib/baseline-loader.js +0 -0
- package/.agents/scripts/lib/baseline-schema-registry.js +13 -1
- package/.agents/scripts/lib/baselines/diff-scope-cli.js +22 -160
- package/.agents/scripts/lib/baselines/duplication-scanner.js +27 -0
- package/.agents/scripts/lib/baselines/git-base.js +0 -0
- package/.agents/scripts/lib/baselines/kinds/crap.js +499 -57
- package/.agents/scripts/lib/baselines/preview-gates.js +58 -15
- package/.agents/scripts/lib/baselines/reader.js +10 -0
- package/.agents/scripts/lib/baselines/refresh-service.js +69 -11
- package/.agents/scripts/lib/baselines/scope.js +39 -90
- package/.agents/scripts/lib/baselines/writer.js +16 -7
- package/.agents/scripts/lib/changed-files.js +8 -1
- package/.agents/scripts/lib/cli-args.js +115 -1
- package/.agents/scripts/lib/close-validation/runner.js +70 -25
- package/.agents/scripts/lib/config-settings-schema-delivery.js +5 -0
- package/.agents/scripts/lib/crap-engine.js +124 -27
- package/.agents/scripts/lib/crap-method-identity.js +153 -0
- package/.agents/scripts/lib/crap-utils.js +86 -13
- package/.agents/scripts/lib/cyclomatic-ceiling.js +265 -0
- package/.agents/scripts/lib/env-loader.js +46 -16
- package/.agents/scripts/lib/feedback-loop/audit-results-graduator.js +0 -2
- package/.agents/scripts/lib/feedback-loop/prior-feedback-fetcher.js +0 -2
- package/.agents/scripts/lib/feedback-loop/retro-proposals-graduator.js +0 -2
- package/.agents/scripts/lib/findings/route-finding.js +123 -17
- package/.agents/scripts/lib/findings/severity.js +80 -2
- package/.agents/scripts/lib/git/cached-fetch.js +0 -0
- package/.agents/scripts/lib/git-utils.js +136 -80
- package/.agents/scripts/lib/import-graph.js +156 -0
- package/.agents/scripts/lib/label-constants.js +17 -0
- package/.agents/scripts/lib/observability/runtime-friction.js +17 -2
- package/.agents/scripts/lib/observability/source-classifier.js +175 -2
- package/.agents/scripts/lib/orchestration/acceptance-eval-decision.js +110 -17
- package/.agents/scripts/lib/orchestration/ceremony-routing.js +17 -12
- package/.agents/scripts/lib/orchestration/check-baselines/phases/compare.js +36 -6
- package/.agents/scripts/lib/orchestration/check-baselines/phases/evaluate.js +5 -0
- package/.agents/scripts/lib/orchestration/check-baselines/phases/floors.js +12 -1
- package/.agents/scripts/lib/orchestration/check-baselines/phases/report.js +8 -1
- package/.agents/scripts/lib/orchestration/ci-rerun-guard.js +548 -0
- package/.agents/scripts/lib/orchestration/deliver-recover.js +2 -2
- package/.agents/scripts/lib/orchestration/git-cleanup/phases/phase-drivers.js +10 -5
- package/.agents/scripts/lib/orchestration/git-cleanup/phases/render.js +39 -3
- package/.agents/scripts/lib/orchestration/lifecycle/listeners/watcher.js +71 -20
- package/.agents/scripts/lib/orchestration/light-backstop.js +20 -3
- package/.agents/scripts/lib/orchestration/light-escalation.js +82 -3
- package/.agents/scripts/lib/orchestration/light-suitability.js +103 -5
- package/.agents/scripts/lib/orchestration/plan-context.js +119 -66
- package/.agents/scripts/lib/orchestration/plan-persist/fan-out-gate.js +31 -5
- package/.agents/scripts/lib/orchestration/plan-persist/run-plan-persist.js +216 -109
- package/.agents/scripts/lib/orchestration/plan-persist/story-ops.js +61 -13
- package/.agents/scripts/lib/orchestration/plan-persist/supersede-ops.js +79 -22
- package/.agents/scripts/lib/orchestration/plan-text-hygiene.js +51 -20
- package/.agents/scripts/lib/orchestration/planning/authoring-context.js +70 -74
- package/.agents/scripts/lib/orchestration/planning/memory-pool-advisory.js +231 -0
- package/.agents/scripts/lib/orchestration/resolve-stories.js +18 -17
- package/.agents/scripts/lib/orchestration/retro-proposals.js +0 -0
- package/.agents/scripts/lib/orchestration/run-epilogue.js +12 -0
- package/.agents/scripts/lib/orchestration/run-scoped-config.js +276 -0
- package/.agents/scripts/lib/orchestration/single-story-close/phases/base-sync.js +41 -10
- package/.agents/scripts/lib/orchestration/single-story-close/phases/confirm-merge.js +125 -37
- package/.agents/scripts/lib/orchestration/single-story-close/phases/normalize-pr-title.js +6 -6
- package/.agents/scripts/lib/orchestration/single-story-close/phases/options.js +42 -38
- package/.agents/scripts/lib/orchestration/single-story-close/phases/pull-request.js +129 -21
- package/.agents/scripts/lib/orchestration/single-story-close/phases/push.js +6 -1
- package/.agents/scripts/lib/orchestration/single-story-close/runner.js +267 -125
- package/.agents/scripts/lib/orchestration/spec-budget.js +16 -5
- package/.agents/scripts/lib/orchestration/story-follow-ups.js +182 -95
- package/.agents/scripts/lib/orchestration/ticket-validator-conflicts.js +22 -0
- package/.agents/scripts/lib/orchestration/ticket-validator.js +5 -11
- package/.agents/scripts/lib/orchestration/ticketing/reads.js +4 -4
- package/.agents/scripts/lib/reserved-test-ids.js +77 -0
- package/.agents/scripts/lib/story-adjacency.js +3 -3
- package/.agents/scripts/lib/story-plan.js +137 -42
- package/.agents/scripts/lib/templates/decomposer-prompts.js +4 -4
- package/.agents/scripts/lib/test-runner-contract.js +134 -0
- package/.agents/scripts/lib/test-tiers.js +11 -2
- package/.agents/scripts/lib/ticket-body-sections.js +1 -1
- package/.agents/scripts/lib/util/concurrent-map.js +17 -0
- package/.agents/scripts/lib/util/parse-id-list.js +103 -0
- package/.agents/scripts/lib/util/poll-loop.js +18 -2
- package/.agents/scripts/lib/wave-runner/live-probe.js +29 -13
- package/.agents/scripts/lib/wave-runner/ready-set.js +254 -43
- package/.agents/scripts/lib/workers/combined-mi-crap-worker.js +10 -13
- package/.agents/scripts/lib/workers/crap-worker.js +8 -13
- package/.agents/scripts/lib/workers/maintainability-report-worker.js +4 -10
- package/.agents/scripts/lib/workers/maintainability-worker.js +4 -10
- package/.agents/scripts/lib/workers/serve-worker-messages.js +35 -0
- package/.agents/scripts/lib/worktree/git-hooks.js +206 -0
- package/.agents/scripts/lib/worktree/lifecycle/creation.js +6 -0
- package/.agents/scripts/lib/worktree-manager.js +14 -0
- package/.agents/scripts/plan-run-epilogue.js +17 -5
- package/.agents/scripts/pr-watch-with-update.js +568 -213
- package/.agents/scripts/providers/github/tickets.js +33 -10
- package/.agents/scripts/provision-git-hooks.js +85 -0
- package/.agents/scripts/quality-preview.js +137 -28
- package/.agents/scripts/resolve-stories.js +4 -1
- package/.agents/scripts/run-coverage.js +86 -35
- package/.agents/scripts/run-lint.js +20 -0
- package/.agents/scripts/run-tests.js +36 -36
- package/.agents/scripts/single-story-close.js +34 -2
- package/.agents/scripts/single-story-confirm-merge.js +22 -6
- package/.agents/scripts/single-story-init.js +7 -0
- package/.agents/scripts/stories-wave-tick.js +308 -47
- package/.agents/scripts/story-plan.js +65 -9
- package/.agents/scripts/update-coverage-baseline.js +34 -4
- package/.agents/scripts/update-crap-baseline.js +42 -4
- package/.agents/scripts/update-duplication-baseline.js +209 -83
- package/.agents/scripts/update-maintainability-baseline.js +31 -4
- package/.agents/scripts/validate-docs-freshness.js +1 -0
- package/.agents/skills/core/diagnose-friction/SKILL.md +4 -1
- package/.agents/skills/core/gates-and-baselines/SKILL.md +17 -11
- package/.agents/skills/skills.index.json +2 -2
- package/.agents/templates/single-story-body.md +16 -8
- package/.agents/workflows/audit-architecture.md +69 -13
- package/.agents/workflows/audit-baselines.md +289 -0
- package/.agents/workflows/audit-navigability.md +5 -4
- package/.agents/workflows/audit-quality.md +26 -0
- package/.agents/workflows/audit-to-stories.md +30 -4
- package/.agents/workflows/deliver.md +92 -97
- package/.agents/workflows/helpers/acceptance-self-eval.md +47 -10
- package/.agents/workflows/helpers/audit-lens-core.md +25 -4
- package/.agents/workflows/helpers/code-quality-guardrails.md +9 -2
- package/.agents/workflows/helpers/deliver-digest.md +41 -21
- package/.agents/workflows/helpers/deliver-reference.md +95 -4
- package/.agents/workflows/helpers/deliver-story-reference.md +174 -52
- package/.agents/workflows/helpers/deliver-story.md +104 -119
- package/.agents/workflows/helpers/plan-reference.md +49 -5
- package/.agents/workflows/memory-consolidate.md +116 -0
- package/.agents/workflows/plan.md +62 -75
- package/README.md +13 -6
- package/docs/CHANGELOG.md +93 -0
- package/lib/cli/update.js +14 -11
- package/lib/cli/version-check.js +9 -1
- package/package.json +9 -4
- package/.agents/schemas/friction-event.schema.json +0 -56
- package/.agents/scripts/lib/feedback-loop/memory-freshness.js +0 -707
|
@@ -12,15 +12,16 @@
|
|
|
12
12
|
* created; this is a direct, synchronous watch with a real exit code.
|
|
13
13
|
*
|
|
14
14
|
* Slow-vs-failed semantics (Story #4358):
|
|
15
|
-
* - GREEN — every required check terminal + green → exit 0
|
|
15
|
+
* - GREEN — every required check terminal + green → exit 0, unless the
|
|
16
|
+
* no-rerun guard says otherwise (below).
|
|
16
17
|
* - RED — one or more required checks genuinely failed → exit 1
|
|
17
18
|
* IMMEDIATELY, consuming no resume budget. On red the CLI
|
|
18
|
-
*
|
|
19
|
-
*
|
|
20
|
-
*
|
|
21
|
-
*
|
|
22
|
-
* it requires `--story` (Story #4539: the digest
|
|
23
|
-
* Epic-scoped and therefore never written on the only
|
|
19
|
+
* disarms native auto-merge and writes
|
|
20
|
+
* `temp/story-<id>-ci-digest.{json,md}` (failing check name,
|
|
21
|
+
* head SHA, run id + run link, a `gh run view --log-failed`
|
|
22
|
+
* tail, and a coarse classification). The digest is scoped by
|
|
23
|
+
* filename, so it requires `--story` (Story #4539: the digest
|
|
24
|
+
* was Epic-scoped and therefore never written on the only
|
|
24
25
|
* delivery path v2 has).
|
|
25
26
|
* - STILL-RUNNING — the poll cap fired with checks still pending and
|
|
26
27
|
* none failed; the watcher re-armed up to
|
|
@@ -28,27 +29,104 @@
|
|
|
28
29
|
* `still-running` verdict → exit 2 (NEVER 1, NEVER
|
|
29
30
|
* `timed_out`). The CLI prints the `gh pr checks --watch`
|
|
30
31
|
* handoff so the host can keep polling on its own cadence.
|
|
32
|
+
* - UNRESOLVED — every observed required check is green but the observed
|
|
33
|
+
* set does not reconcile with the repository's own verdict
|
|
34
|
+
* (Story #4873) → exit 2, same "keep watching" semantics as
|
|
35
|
+
* still-running. Withholding is the point: this watcher has
|
|
36
|
+
* reported green on a PR GitHub was reporting as BLOCKED.
|
|
37
|
+
* - NOT-YET-STARTED — the attach window was spent and NO required context
|
|
38
|
+
* ever attached, while the pull request itself kept reading back
|
|
39
|
+
* fine (Story #4890) → exit 2, the same "keep watching"
|
|
40
|
+
* semantics. This used to map onto the red exit code, which
|
|
41
|
+
* misroutes the caller twice over: the module reserves exit 1 for
|
|
42
|
+
* a required check that GENUINELY FAILED, and the red path writes
|
|
43
|
+
* a CI digest naming the failing check — so a caller routed onto
|
|
44
|
+
* it by a set that was merely empty found nothing to read. A
|
|
45
|
+
* still-empty required set is a slow condition, and the module
|
|
46
|
+
* already models slow as exit 2. A `gh` fault the PR probe cannot
|
|
47
|
+
* see past is still exit 1.
|
|
48
|
+
*
|
|
49
|
+
* Two GitHub oracles are deliberately not trusted on a single reading
|
|
50
|
+
* (Story #4873). An EMPTY `gh pr checks --required` probe is re-resolved
|
|
51
|
+
* within {@link REQUIRED_CONTEXT_ATTACH_WINDOW_MS} before it is believed —
|
|
52
|
+
* a ruleset attaches its required contexts asynchronously, and a required
|
|
53
|
+
* context that is an aggregator job gated on every other tier is by
|
|
54
|
+
* construction the LAST to appear (#4890 measured 16m52s), so a watch launched
|
|
55
|
+
* right after `gh pr create` used to fail a delivery whose CI had not been
|
|
56
|
+
* asked to start yet. And a GREEN verdict is issued only after
|
|
57
|
+
* {@link reconcileGreenVerdict} confirms the repository agrees; an
|
|
58
|
+
* unreconcilable set reports unresolved instead of green.
|
|
59
|
+
*
|
|
60
|
+
* No-rerun enforcement (Story #4865). `rules/ci-remediation.md` § Verifier
|
|
61
|
+
* forbids re-running a failed job to reach green; this CLI is the point
|
|
62
|
+
* that enforces it. Enforcement acts on the **first red** — GitHub's native
|
|
63
|
+
* auto-merge fires server-side and races any post-green detection, so a
|
|
64
|
+
* green can already have merged by the time it is observed. On red the
|
|
65
|
+
* watcher disarms auto-merge and records the head SHA; on green it reads
|
|
66
|
+
* the digest and adjudicates: a green on the SAME head SHA is a forbidden
|
|
67
|
+
* re-run (exit 1, `agent::blocked`, `meta::framework-gap` required), while
|
|
68
|
+
* a green on a NEW head SHA is a fix at source — the digest is retired,
|
|
69
|
+
* auto-merge is re-armed, and the delivery proceeds. A delivery that never
|
|
70
|
+
* went red has no digest and is untouched. Mechanism:
|
|
71
|
+
* `lib/orchestration/ci-rerun-guard.js`.
|
|
31
72
|
*
|
|
32
73
|
* Config (Story #4356 namespace, read via `getCiDelivery`):
|
|
33
74
|
* - `delivery.ci.watch.pollIntervalMs`
|
|
34
75
|
* - `delivery.ci.watch.maxPolls`
|
|
35
76
|
* - `delivery.ci.watch.maxResumes`
|
|
77
|
+
* - `delivery.ci.watch.attachWindowMs` (Story #4890)
|
|
36
78
|
* CLI flags override config; config overrides the framework fallback.
|
|
37
79
|
*
|
|
38
80
|
* Usage:
|
|
39
81
|
* node .agents/scripts/pr-watch-with-update.js --pr <n> --story <id>
|
|
40
82
|
* [--repo owner/repo] [--max-updates N] [--poll-interval-ms MS]
|
|
41
|
-
* [--max-polls N] [--max-resumes N]
|
|
83
|
+
* [--max-polls N] [--max-resumes N] [--attach-window-ms MS]
|
|
42
84
|
*/
|
|
43
|
-
import { spawnSync } from 'node:child_process';
|
|
44
|
-
import { mkdirSync, writeFileSync } from 'node:fs';
|
|
45
|
-
import path from 'node:path';
|
|
46
85
|
import { parseArgs } from 'node:util';
|
|
47
86
|
import { runAsCli } from './lib/cli-utils.js';
|
|
48
87
|
import { getCiDelivery } from './lib/config/ci.js';
|
|
49
88
|
import { resolveConfig } from './lib/config-resolver.js';
|
|
89
|
+
import { gh as defaultGh } from './lib/gh-exec.js';
|
|
50
90
|
import { Logger } from './lib/Logger.js';
|
|
91
|
+
import {
|
|
92
|
+
blockStoryDelivery,
|
|
93
|
+
classifyFailure,
|
|
94
|
+
classifyGreenVerdict,
|
|
95
|
+
disarmAutoMerge,
|
|
96
|
+
formatRerunViolation,
|
|
97
|
+
readCiDigest,
|
|
98
|
+
resolveDigestScope,
|
|
99
|
+
resolvePrHeadSha,
|
|
100
|
+
retireCiDigest,
|
|
101
|
+
writeCiDigest,
|
|
102
|
+
} from './lib/orchestration/ci-rerun-guard.js';
|
|
51
103
|
import { watchPrToTerminal } from './lib/orchestration/lifecycle/listeners/watcher.js';
|
|
104
|
+
import { enableAutoMergeWith } from './lib/orchestration/single-story-close/phases/auto-merge.js';
|
|
105
|
+
import { sleep as defaultSleep } from './lib/util/poll-loop.js';
|
|
106
|
+
|
|
107
|
+
/** Exit code reserved for the slow-but-not-red `still-running` verdict. */
|
|
108
|
+
export const STILL_RUNNING_EXIT_CODE = 2;
|
|
109
|
+
|
|
110
|
+
/**
|
|
111
|
+
* How long a probe that resolved NO required contexts keeps being retried
|
|
112
|
+
* before the watch stops waiting for one (Stories #4873, #4890).
|
|
113
|
+
*
|
|
114
|
+
* A repository ruleset attaches its required contexts to a pull request
|
|
115
|
+
* asynchronously, and the arrival latency is set by the SLOWEST context in the
|
|
116
|
+
* set. Story #4873 measured tens of seconds on a cold repo and calibrated the
|
|
117
|
+
* window at 90s; #4890 measured **16m52s** on this repository, because its
|
|
118
|
+
* required context is an aggregator job gated on every other tier and is
|
|
119
|
+
* therefore, by construction, the last check to appear. A 90s window still
|
|
120
|
+
* exhausted, so the watch still aborted on a PR whose CI was working exactly
|
|
121
|
+
* as designed.
|
|
122
|
+
*
|
|
123
|
+
* The default therefore covers a late aggregator with margin rather than a
|
|
124
|
+
* fast ruleset, and it is operator-tunable on the `delivery.ci.watch.*` ladder
|
|
125
|
+
* (`attachWindowMs`) for a repository whose contexts arrive on a different
|
|
126
|
+
* cadence. Spending the window costs nothing but wall-clock on a PR nobody
|
|
127
|
+
* could merge yet; exhausting it too early costs the whole delivery.
|
|
128
|
+
*/
|
|
129
|
+
export const REQUIRED_CONTEXT_ATTACH_WINDOW_MS = 1_200_000;
|
|
52
130
|
|
|
53
131
|
/** Framework fallbacks when neither a CLI flag nor config supplies a value. */
|
|
54
132
|
export const WATCH_DEFAULTS = Object.freeze({
|
|
@@ -56,10 +134,161 @@ export const WATCH_DEFAULTS = Object.freeze({
|
|
|
56
134
|
maxPolls: 180,
|
|
57
135
|
maxUpdates: 3,
|
|
58
136
|
maxResumes: 3,
|
|
137
|
+
attachWindowMs: REQUIRED_CONTEXT_ATTACH_WINDOW_MS,
|
|
59
138
|
});
|
|
60
139
|
|
|
61
|
-
/**
|
|
62
|
-
|
|
140
|
+
/**
|
|
141
|
+
* Merge-state values that reconcile an observed all-green required set with
|
|
142
|
+
* the repository's own view of the pull request (Story #4873).
|
|
143
|
+
*
|
|
144
|
+
* `BLOCKED` is the measured false-green: the watcher's `gh pr checks
|
|
145
|
+
* --required` set came back SMALLER than branch protection's — a context
|
|
146
|
+
* attached after the first probe, so it was never in `requiredChecks` — every
|
|
147
|
+
* check the watcher knew about was green, and it reported green while GitHub
|
|
148
|
+
* still refused the merge. `UNKNOWN` / an unreadable probe is not a
|
|
149
|
+
* reconciliation either: it is the absence of the second opinion, and a green
|
|
150
|
+
* verdict is exactly the verdict that must not be issued on absent evidence.
|
|
151
|
+
*/
|
|
152
|
+
const RECONCILED_MERGE_STATES = Object.freeze(
|
|
153
|
+
new Set(['CLEAN', 'UNSTABLE', 'HAS_HOOKS', 'BEHIND', 'DRAFT']),
|
|
154
|
+
);
|
|
155
|
+
|
|
156
|
+
/**
|
|
157
|
+
* Does an observed-green required set reconcile with the repository's own
|
|
158
|
+
* verdict on the PR? Pure — exported so the rule is reviewable as code.
|
|
159
|
+
*
|
|
160
|
+
* @param {{ observedRequired?: string[], mergeStateStatus?: string|null }} args
|
|
161
|
+
* @returns {{ reconciled: boolean, mergeStateStatus: string|null, reason: string }}
|
|
162
|
+
*/
|
|
163
|
+
export function reconcileGreenVerdict({
|
|
164
|
+
observedRequired = [],
|
|
165
|
+
mergeStateStatus,
|
|
166
|
+
} = {}) {
|
|
167
|
+
const state = String(mergeStateStatus ?? '')
|
|
168
|
+
.trim()
|
|
169
|
+
.toUpperCase();
|
|
170
|
+
const observed = observedRequired.length;
|
|
171
|
+
if (!state) {
|
|
172
|
+
return {
|
|
173
|
+
reconciled: false,
|
|
174
|
+
mergeStateStatus: null,
|
|
175
|
+
reason:
|
|
176
|
+
`observed ${observed} required check(s) green, but the repository's merge state ` +
|
|
177
|
+
'could not be read — the observed set cannot be reconciled, so the green verdict is withheld',
|
|
178
|
+
};
|
|
179
|
+
}
|
|
180
|
+
if (RECONCILED_MERGE_STATES.has(state)) {
|
|
181
|
+
return {
|
|
182
|
+
reconciled: true,
|
|
183
|
+
mergeStateStatus: state,
|
|
184
|
+
reason: `observed ${observed} required check(s) green and the repository reports mergeStateStatus=${state}`,
|
|
185
|
+
};
|
|
186
|
+
}
|
|
187
|
+
return {
|
|
188
|
+
reconciled: false,
|
|
189
|
+
mergeStateStatus: state,
|
|
190
|
+
reason:
|
|
191
|
+
`observed ${observed} required check(s) green, but the repository reports ` +
|
|
192
|
+
`mergeStateStatus=${state} — branch protection is enforcing a context this watch did not observe`,
|
|
193
|
+
};
|
|
194
|
+
}
|
|
195
|
+
|
|
196
|
+
/** Default merge-state probe: one `gh pr view --json mergeStateStatus`. */
|
|
197
|
+
async function defaultMergeStateProbe({ prRef }) {
|
|
198
|
+
try {
|
|
199
|
+
const view = await defaultGh.pr.view(prRef, ['mergeStateStatus']);
|
|
200
|
+
return typeof view?.mergeStateStatus === 'string'
|
|
201
|
+
? view.mergeStateStatus
|
|
202
|
+
: null;
|
|
203
|
+
} catch {
|
|
204
|
+
return null;
|
|
205
|
+
}
|
|
206
|
+
}
|
|
207
|
+
|
|
208
|
+
/**
|
|
209
|
+
* Adapt the watch loop's own `ghPrViewFn` port — which already spawns
|
|
210
|
+
* `gh pr view --json mergeStateStatus` for BEHIND recovery — into the
|
|
211
|
+
* reconciliation probe, so a caller that injected one port does not have to
|
|
212
|
+
* inject a second for the same `gh` call.
|
|
213
|
+
*/
|
|
214
|
+
function mergeStateProbeFromView(ghPrViewFn) {
|
|
215
|
+
return async ({ prUrl, repo, cwd }) => {
|
|
216
|
+
try {
|
|
217
|
+
const view = await ghPrViewFn({ prUrl, repo, cwd });
|
|
218
|
+
if (view?.status !== 0) return null;
|
|
219
|
+
const parsed = JSON.parse(String(view.stdout ?? '').trim());
|
|
220
|
+
return typeof parsed?.mergeStateStatus === 'string'
|
|
221
|
+
? parsed.mergeStateStatus
|
|
222
|
+
: null;
|
|
223
|
+
} catch {
|
|
224
|
+
return null;
|
|
225
|
+
}
|
|
226
|
+
};
|
|
227
|
+
}
|
|
228
|
+
|
|
229
|
+
/**
|
|
230
|
+
* Run the watch, re-resolving a required-check set that came back EMPTY until
|
|
231
|
+
* the attach window is spent (Stories #4873 AC-3, #4890 AC-1). Every other
|
|
232
|
+
* terminal — green, red, still-running — returns on the first arm exactly as
|
|
233
|
+
* before.
|
|
234
|
+
*
|
|
235
|
+
* Re-arming the whole call is what makes convergence possible: the required
|
|
236
|
+
* check NAMES are resolved once per `watchPrToTerminal` call, so a context that
|
|
237
|
+
* attaches minutes later is only ever seen by a fresh call.
|
|
238
|
+
*
|
|
239
|
+
* `probePrResolvable` is the **structural** classifier, re-read every round: a
|
|
240
|
+
* required set that is empty while the pull request itself reads back fine is
|
|
241
|
+
* CI that has not started, so the window is worth spending; a pull request that
|
|
242
|
+
* does not read back at all is a `gh` fault, so the window is not spent on it
|
|
243
|
+
* and the caller reports the failure immediately. `gh` overloads its exit code
|
|
244
|
+
* across both conditions, and this deliberately does not fall back to matching
|
|
245
|
+
* its stderr prose — a human-readable string is not a classification contract.
|
|
246
|
+
*
|
|
247
|
+
* @returns {Promise<object>} the watch result plus `attachRetries`, and
|
|
248
|
+
* `prResolvable` whenever the required set stayed empty.
|
|
249
|
+
*/
|
|
250
|
+
async function watchWithAttachWindow({
|
|
251
|
+
watchArgs,
|
|
252
|
+
attachWindowMs,
|
|
253
|
+
retryIntervalMs,
|
|
254
|
+
sleepFn,
|
|
255
|
+
nowMsFn,
|
|
256
|
+
probePrResolvable,
|
|
257
|
+
logger,
|
|
258
|
+
}) {
|
|
259
|
+
const deadline = nowMsFn() + attachWindowMs;
|
|
260
|
+
// The window is wall-clock, but the retry count is also capped: a caller
|
|
261
|
+
// running with a zero poll interval (every unit test, and a config that
|
|
262
|
+
// sets one) would otherwise spin the window out as a tight loop. Flooring
|
|
263
|
+
// the assumed cadence at 5s bounds the attempts without changing the
|
|
264
|
+
// wall-clock bound that governs a real watch.
|
|
265
|
+
const maxRetries = Math.ceil(
|
|
266
|
+
attachWindowMs / Math.max(retryIntervalMs, 5000),
|
|
267
|
+
);
|
|
268
|
+
let result = await watchPrToTerminal(watchArgs);
|
|
269
|
+
let retries = 0;
|
|
270
|
+
let prResolvable;
|
|
271
|
+
while (result.requiredChecksEmpty) {
|
|
272
|
+
prResolvable = await probePrResolvable();
|
|
273
|
+
if (!prResolvable) break;
|
|
274
|
+
if (retries >= maxRetries || nowMsFn() >= deadline) break;
|
|
275
|
+
retries += 1;
|
|
276
|
+
logger?.warn?.(
|
|
277
|
+
`[pr-watch] no required context has attached yet (${result.error}) — the pull request reads ` +
|
|
278
|
+
'back fine, so this is CI that has not started; re-resolving the required set within the ' +
|
|
279
|
+
`${Math.round(attachWindowMs / 1000)}s attach window (attempt ${retries}).`,
|
|
280
|
+
);
|
|
281
|
+
await sleepFn(retryIntervalMs);
|
|
282
|
+
result = await watchPrToTerminal(watchArgs);
|
|
283
|
+
}
|
|
284
|
+
return {
|
|
285
|
+
...result,
|
|
286
|
+
attachRetries: retries,
|
|
287
|
+
...(result.requiredChecksEmpty
|
|
288
|
+
? { prResolvable: Boolean(prResolvable) }
|
|
289
|
+
: {}),
|
|
290
|
+
};
|
|
291
|
+
}
|
|
63
292
|
|
|
64
293
|
function parsePositiveInt(raw, fallback) {
|
|
65
294
|
if (raw == null) return fallback;
|
|
@@ -76,8 +305,8 @@ function parsePositiveInt(raw, fallback) {
|
|
|
76
305
|
*
|
|
77
306
|
* @param {object} opts
|
|
78
307
|
* @param {object|null} [opts.config] resolved config (or a bare bag).
|
|
79
|
-
* @param {object} [opts.flags] `{ pollIntervalMs, maxPolls, maxResumes, maxUpdates }`.
|
|
80
|
-
* @returns {{ pollIntervalMs: number, maxPolls: number, maxResumes: number, maxUpdates: number }}
|
|
308
|
+
* @param {object} [opts.flags] `{ pollIntervalMs, maxPolls, maxResumes, maxUpdates, attachWindowMs }`.
|
|
309
|
+
* @returns {{ pollIntervalMs: number, maxPolls: number, maxResumes: number, maxUpdates: number, attachWindowMs: number }}
|
|
81
310
|
*/
|
|
82
311
|
export function resolveWatchKnobs({ config, flags = {} } = {}) {
|
|
83
312
|
const watch = getCiDelivery(config).watch ?? {};
|
|
@@ -96,165 +325,164 @@ export function resolveWatchKnobs({ config, flags = {} } = {}) {
|
|
|
96
325
|
WATCH_DEFAULTS.maxResumes,
|
|
97
326
|
),
|
|
98
327
|
maxUpdates: pick(flags.maxUpdates, undefined, WATCH_DEFAULTS.maxUpdates),
|
|
328
|
+
attachWindowMs: pick(
|
|
329
|
+
flags.attachWindowMs,
|
|
330
|
+
watch.attachWindowMs,
|
|
331
|
+
WATCH_DEFAULTS.attachWindowMs,
|
|
332
|
+
),
|
|
99
333
|
};
|
|
100
334
|
}
|
|
101
335
|
|
|
102
|
-
/**
|
|
103
|
-
|
|
104
|
-
|
|
105
|
-
* next move (which `/loop` unit to reach for), not a root-cause verdict.
|
|
106
|
-
*
|
|
107
|
-
* @param {string} name failing required-check name.
|
|
108
|
-
* @returns {'test'|'lint'|'baseline'|'build'|'unknown'}
|
|
109
|
-
*/
|
|
110
|
-
export function classifyFailure(name) {
|
|
111
|
-
const n = String(name ?? '').toLowerCase();
|
|
112
|
-
if (/lint|format|biome|markdownlint/.test(n)) return 'lint';
|
|
113
|
-
if (/baseline|coverage|crap|maintainab|duplicat/.test(n)) return 'baseline';
|
|
114
|
-
if (/build|compile|typecheck|bundle/.test(n)) return 'build';
|
|
115
|
-
if (/test|spec|validate|ci|check/.test(n)) return 'test';
|
|
116
|
-
return 'unknown';
|
|
117
|
-
}
|
|
118
|
-
|
|
119
|
-
/**
|
|
120
|
-
* Default `gh run view --log-failed` spawn — pulls the tail of the failed
|
|
121
|
-
* job log so the digest carries an actionable excerpt. Best-effort:
|
|
122
|
-
* returns an empty tail when the run id is unknown or `gh` errors.
|
|
123
|
-
* Exported indirectly via `writeCiDigest` injection so tests can stub
|
|
124
|
-
* without shelling out.
|
|
125
|
-
*/
|
|
126
|
-
function ghRunLogTail({ runId, cwd, spawnFn = spawnSync, maxLines = 40 }) {
|
|
127
|
-
if (!runId) return '';
|
|
128
|
-
const result = spawnFn('gh', ['run', 'view', String(runId), '--log-failed'], {
|
|
129
|
-
cwd,
|
|
130
|
-
encoding: 'utf-8',
|
|
131
|
-
shell: false,
|
|
132
|
-
maxBuffer: 10 * 1024 * 1024,
|
|
133
|
-
});
|
|
134
|
-
const out = (result.stdout ?? '').trim();
|
|
135
|
-
if (out.length === 0) return '';
|
|
136
|
-
const lines = out.split('\n');
|
|
137
|
-
return lines.slice(-maxLines).join('\n');
|
|
336
|
+
/** Default re-arm: the sanctioned auto-merge enablement path. */
|
|
337
|
+
function defaultReArm({ cwd, prNumber }) {
|
|
338
|
+
return enableAutoMergeWith({ cwd, prNumber });
|
|
138
339
|
}
|
|
139
340
|
|
|
140
341
|
/**
|
|
141
|
-
*
|
|
142
|
-
*
|
|
143
|
-
*
|
|
144
|
-
*
|
|
342
|
+
* Red path (Story #4865). Disarm native auto-merge FIRST — that is the
|
|
343
|
+
* race-free moment, before any green can exist — then record the digest so
|
|
344
|
+
* the green path can adjudicate against the head SHA the red happened on.
|
|
345
|
+
*
|
|
346
|
+
* A disarm failure is a **blocker**, not a warning: an armed PR whose
|
|
347
|
+
* required check went red can still be merged by GitHub the instant a
|
|
348
|
+
* re-run turns it green, which is precisely what this guard exists to stop.
|
|
349
|
+
*
|
|
350
|
+
* @returns {Promise<{ headSha: string|null, disarm: object, digestPaths: object|null, blocked: boolean }>}
|
|
145
351
|
*/
|
|
146
|
-
function
|
|
147
|
-
|
|
148
|
-
|
|
149
|
-
|
|
150
|
-
|
|
151
|
-
|
|
352
|
+
async function handleRedWatch({
|
|
353
|
+
storyId,
|
|
354
|
+
prNumber,
|
|
355
|
+
prRef,
|
|
356
|
+
failures,
|
|
357
|
+
tempRoot,
|
|
358
|
+
cwd,
|
|
359
|
+
writeDigestFn,
|
|
360
|
+
headShaFn,
|
|
361
|
+
disarmFn,
|
|
362
|
+
blockFn,
|
|
363
|
+
logger,
|
|
364
|
+
}) {
|
|
365
|
+
const disarm = disarmFn({ prRef, cwd });
|
|
366
|
+
const scope = resolveDigestScope({ storyId });
|
|
367
|
+
const headSha = scope ? headShaFn({ prRef, cwd }) : null;
|
|
368
|
+
let digestPaths = null;
|
|
152
369
|
try {
|
|
153
|
-
|
|
154
|
-
|
|
155
|
-
|
|
156
|
-
|
|
157
|
-
|
|
158
|
-
|
|
159
|
-
|
|
160
|
-
|
|
161
|
-
|
|
370
|
+
digestPaths = writeDigestFn({
|
|
371
|
+
storyId,
|
|
372
|
+
prNumber,
|
|
373
|
+
headSha,
|
|
374
|
+
failures,
|
|
375
|
+
tempRoot,
|
|
376
|
+
cwd,
|
|
377
|
+
prRef,
|
|
378
|
+
});
|
|
379
|
+
} catch (err) {
|
|
380
|
+
logger.warn?.(
|
|
381
|
+
`[pr-watch] failed to write CI digest (non-fatal): ${err?.message ?? err}`,
|
|
382
|
+
);
|
|
162
383
|
}
|
|
384
|
+
let blocked = false;
|
|
385
|
+
if (!disarm.disarmed) {
|
|
386
|
+
logger.error?.(
|
|
387
|
+
`[pr-watch] BLOCKER: auto-merge could NOT be disarmed on PR #${prNumber} (${disarm.detail}). ` +
|
|
388
|
+
'An armed PR can merge the moment a re-run turns it green — the no-rerun rule cannot be enforced.',
|
|
389
|
+
);
|
|
390
|
+
const outcome = await blockFn({
|
|
391
|
+
storyId,
|
|
392
|
+
body: [
|
|
393
|
+
'### Auto-merge could not be disarmed after a red check — delivery blocked',
|
|
394
|
+
'',
|
|
395
|
+
`A required check went red on PR #${prNumber}, but disarming native auto-merge failed:`,
|
|
396
|
+
'',
|
|
397
|
+
`> ${disarm.detail}`,
|
|
398
|
+
'',
|
|
399
|
+
'While the PR stays armed, GitHub can merge it server-side the instant the',
|
|
400
|
+
'checks read green — including a green reached by re-running the failed job,',
|
|
401
|
+
'which `.agents/rules/ci-remediation.md` § Verifier forbids. Disarm the PR by',
|
|
402
|
+
'hand (or fix the `gh` fault), then resume the delivery.',
|
|
403
|
+
].join('\n'),
|
|
404
|
+
});
|
|
405
|
+
blocked = Boolean(outcome?.blocked);
|
|
406
|
+
} else {
|
|
407
|
+
logger.error?.(
|
|
408
|
+
`[pr-watch] native auto-merge ${disarm.alreadyUnarmed ? 'was already un-armed' : 'DISARMED'} on PR #${prNumber} — ` +
|
|
409
|
+
'it is re-armed only by a green on a NEW head SHA.',
|
|
410
|
+
);
|
|
411
|
+
}
|
|
412
|
+
if (digestPaths) {
|
|
413
|
+
logger.error?.(`[pr-watch] CI failure digest → ${digestPaths.jsonPath}`);
|
|
414
|
+
}
|
|
415
|
+
return { headSha, disarm, digestPaths, blocked };
|
|
163
416
|
}
|
|
164
417
|
|
|
165
418
|
/**
|
|
166
|
-
*
|
|
167
|
-
*
|
|
168
|
-
*
|
|
169
|
-
* alone) a red check wrote no digest at all, despite the module header
|
|
170
|
-
* advertising one. v2.0.0 removed the Epic tier; Story scope is the only
|
|
171
|
-
* scope.
|
|
419
|
+
* Green path (Story #4865). Adjudicate the green against any digest the
|
|
420
|
+
* scope recorded, and return the exit code with the report the caller
|
|
421
|
+
* prints.
|
|
172
422
|
*
|
|
173
|
-
* @
|
|
174
|
-
* @returns {{ kind: 'story', id: number } | null}
|
|
423
|
+
* @returns {Promise<{ verdict: string, reason: string, exitCode: number, headSha: string|null, reArmed?: boolean, blocked?: boolean }>}
|
|
175
424
|
*/
|
|
176
|
-
|
|
177
|
-
|
|
178
|
-
const parsed = Number.parseInt(String(storyId), 10);
|
|
179
|
-
return Number.isInteger(parsed) && parsed > 0
|
|
180
|
-
? { kind: 'story', id: parsed }
|
|
181
|
-
: null;
|
|
182
|
-
}
|
|
183
|
-
|
|
184
|
-
/**
|
|
185
|
-
* Write the CI failure digest (`.json` + `.md`) for a red watch. Returns
|
|
186
|
-
* the two paths written, or `null` when no story id was supplied (the
|
|
187
|
-
* digest is scoped by filename and has nothing to key on). Exported for
|
|
188
|
-
* tests.
|
|
189
|
-
*
|
|
190
|
-
* @param {object} opts
|
|
191
|
-
* @param {number|string|null} [opts.storyId] The v2 delivery scope.
|
|
192
|
-
* @param {number} opts.prNumber
|
|
193
|
-
* @param {Array<{name:string, outcome:string}>} opts.failures
|
|
194
|
-
* @param {string} opts.tempRoot
|
|
195
|
-
* @param {string} opts.cwd
|
|
196
|
-
* @param {string} opts.prRef
|
|
197
|
-
* @param {Function} [opts.runIdFn]
|
|
198
|
-
* @param {Function} [opts.logTailFn]
|
|
199
|
-
* @returns {{ jsonPath: string, mdPath: string } | null}
|
|
200
|
-
*/
|
|
201
|
-
export function writeCiDigest({
|
|
202
|
-
storyId = null,
|
|
425
|
+
async function evaluateGreenWatch({
|
|
426
|
+
storyId,
|
|
203
427
|
prNumber,
|
|
204
|
-
|
|
428
|
+
prRef,
|
|
205
429
|
tempRoot,
|
|
206
430
|
cwd,
|
|
207
|
-
|
|
208
|
-
|
|
209
|
-
|
|
431
|
+
readDigestFn,
|
|
432
|
+
retireDigestFn,
|
|
433
|
+
headShaFn,
|
|
434
|
+
reArmFn,
|
|
435
|
+
blockFn,
|
|
436
|
+
logger,
|
|
210
437
|
}) {
|
|
211
438
|
const scope = resolveDigestScope({ storyId });
|
|
212
|
-
if (!scope)
|
|
213
|
-
|
|
214
|
-
|
|
215
|
-
|
|
216
|
-
|
|
217
|
-
|
|
218
|
-
|
|
219
|
-
|
|
220
|
-
|
|
221
|
-
|
|
222
|
-
|
|
223
|
-
|
|
224
|
-
|
|
225
|
-
|
|
226
|
-
|
|
439
|
+
if (!scope) {
|
|
440
|
+
return {
|
|
441
|
+
verdict: 'clean',
|
|
442
|
+
reason: 'no --story scope: no digest can be keyed, guard inert',
|
|
443
|
+
exitCode: 0,
|
|
444
|
+
headSha: null,
|
|
445
|
+
};
|
|
446
|
+
}
|
|
447
|
+
const digest = readDigestFn({ storyId, tempRoot, cwd });
|
|
448
|
+
if (!digest) {
|
|
449
|
+
return {
|
|
450
|
+
verdict: 'clean',
|
|
451
|
+
reason: 'no digest for this scope: this delivery never went red',
|
|
452
|
+
exitCode: 0,
|
|
453
|
+
headSha: null,
|
|
454
|
+
};
|
|
455
|
+
}
|
|
456
|
+
const headSha = headShaFn({ prRef, cwd });
|
|
457
|
+
const { verdict, reason } = classifyGreenVerdict({ digest, headSha });
|
|
458
|
+
if (verdict === 'fix-at-source') {
|
|
459
|
+
retireDigestFn({ storyId, tempRoot, cwd });
|
|
460
|
+
const reArm = await reArmFn({ cwd, prNumber });
|
|
461
|
+
const reArmed = Boolean(reArm?.enabled);
|
|
462
|
+
logger.info?.(
|
|
463
|
+
`[pr-watch] green on a NEW head SHA (${reason}) — fix at source; digest retired, ` +
|
|
464
|
+
`auto-merge ${reArmed ? 're-armed' : `NOT re-armed (${reArm?.reason ?? 'unknown'})`}.`,
|
|
465
|
+
);
|
|
466
|
+
return { verdict, reason, exitCode: 0, headSha, reArmed };
|
|
467
|
+
}
|
|
468
|
+
const body = formatRerunViolation({ digest, headSha, prNumber, reason });
|
|
469
|
+
logger.error?.(
|
|
470
|
+
`[pr-watch] FORBIDDEN CI RE-RUN: ${reason}. Required check \`${digest.failingCheck}\` was red on this exact commit.`,
|
|
471
|
+
);
|
|
472
|
+
logger.error?.(
|
|
473
|
+
`[pr-watch] run link: ${digest.runUrl ?? `run id ${digest.runId ?? 'unresolved'}`} — classification: ${digest.classification ?? 'unknown'}`,
|
|
474
|
+
);
|
|
475
|
+
logger.error?.(
|
|
476
|
+
'[pr-watch] fix the root cause and push a new commit, or file a `meta::framework-gap` issue carrying the run link and failure signature.',
|
|
477
|
+
);
|
|
478
|
+
const outcome = await blockFn({ storyId, body });
|
|
479
|
+
return {
|
|
480
|
+
verdict,
|
|
481
|
+
reason,
|
|
482
|
+
exitCode: 1,
|
|
483
|
+
headSha,
|
|
484
|
+
blocked: Boolean(outcome?.blocked),
|
|
227
485
|
};
|
|
228
|
-
const dir = path.isAbsolute(tempRoot) ? tempRoot : path.join(cwd, tempRoot);
|
|
229
|
-
mkdirSync(dir, { recursive: true });
|
|
230
|
-
const base = `${scope.kind}-${scope.id}-ci-digest`;
|
|
231
|
-
const jsonPath = path.join(dir, `${base}.json`);
|
|
232
|
-
const mdPath = path.join(dir, `${base}.md`);
|
|
233
|
-
writeFileSync(jsonPath, `${JSON.stringify(digest, null, 2)}\n`);
|
|
234
|
-
const md = [
|
|
235
|
-
`# CI failure digest — Story #${scope.id} (PR #${prNumber})`,
|
|
236
|
-
'',
|
|
237
|
-
`- **Failing check:** \`${digest.failingCheck}\` (${digest.failingOutcome})`,
|
|
238
|
-
`- **Run id:** ${runId ?? 'unresolved'}`,
|
|
239
|
-
`- **Classification:** ${classification}`,
|
|
240
|
-
`- **Generated:** ${digest.generatedAt}`,
|
|
241
|
-
'',
|
|
242
|
-
failures.length > 1
|
|
243
|
-
? `Other non-green checks: ${failures
|
|
244
|
-
.slice(1)
|
|
245
|
-
.map((f) => `\`${f.name}\`=${f.outcome}`)
|
|
246
|
-
.join(', ')}`
|
|
247
|
-
: '',
|
|
248
|
-
'',
|
|
249
|
-
'## `gh run view --log-failed` tail',
|
|
250
|
-
'',
|
|
251
|
-
'```text',
|
|
252
|
-
logTail || '(no failed-log output available)',
|
|
253
|
-
'```',
|
|
254
|
-
'',
|
|
255
|
-
].join('\n');
|
|
256
|
-
writeFileSync(mdPath, md);
|
|
257
|
-
return { jsonPath, mdPath };
|
|
258
486
|
}
|
|
259
487
|
|
|
260
488
|
/**
|
|
@@ -262,17 +490,24 @@ export function writeCiDigest({
|
|
|
262
490
|
* the green / red / still-running / BEHIND paths can be exercised with
|
|
263
491
|
* injected `gh` spawns and no `process.exit`.
|
|
264
492
|
*
|
|
265
|
-
* 0 → all required checks green.
|
|
266
|
-
* 1 → a required check genuinely failed (red)
|
|
267
|
-
*
|
|
493
|
+
* 0 → all required checks green (and the no-rerun guard cleared them).
|
|
494
|
+
* 1 → a required check genuinely failed (red), OR the green was reached
|
|
495
|
+
* by a forbidden re-run of the same commit, OR the pull request itself
|
|
496
|
+
* could not be read (a `gh` / access fault).
|
|
497
|
+
* 2 → slow-but-not-red: still-running (cap + resume budget exhausted, none
|
|
498
|
+
* red), an unreconcilable green, or no required context attached within
|
|
499
|
+
* the attach window while the PR kept reading back fine (#4890).
|
|
268
500
|
*
|
|
269
501
|
* @param {object} opts
|
|
270
502
|
* @param {number} opts.prNumber
|
|
271
|
-
* @param {string|null} [opts.repo]
|
|
503
|
+
* @param {string|null} [opts.repo] `owner/repo`; passed to `gh` as `--repo`.
|
|
272
504
|
* @param {number|string} [opts.maxUpdates]
|
|
273
505
|
* @param {number|string} [opts.pollIntervalMs]
|
|
274
506
|
* @param {number|string} [opts.maxPolls]
|
|
275
507
|
* @param {number|string} [opts.maxResumes]
|
|
508
|
+
* @param {number|string} [opts.attachWindowMs] override the required-context
|
|
509
|
+
* attach window for one run (flag → `delivery.ci.watch.attachWindowMs` →
|
|
510
|
+
* {@link REQUIRED_CONTEXT_ATTACH_WINDOW_MS}).
|
|
276
511
|
* @param {object|null} [opts.config] resolved config (defaults to resolveConfig()).
|
|
277
512
|
* @param {string} [opts.tempRoot] digest output dir (default `temp`).
|
|
278
513
|
* @param {Function} [opts.ghPrChecksFn] inject for tests
|
|
@@ -280,6 +515,12 @@ export function writeCiDigest({
|
|
|
280
515
|
* @param {Function} [opts.ghPrUpdateBranchFn] inject for tests
|
|
281
516
|
* @param {Function} [opts.sleepFn] inject for tests
|
|
282
517
|
* @param {Function} [opts.writeDigestFn] inject for tests (default writeCiDigest)
|
|
518
|
+
* @param {Function} [opts.readDigestFn] inject for tests (default readCiDigest)
|
|
519
|
+
* @param {Function} [opts.retireDigestFn] inject for tests (default retireCiDigest)
|
|
520
|
+
* @param {Function} [opts.headShaFn] inject for tests (default resolvePrHeadSha)
|
|
521
|
+
* @param {Function} [opts.disarmAutoMergeFn] inject for tests (default disarmAutoMerge)
|
|
522
|
+
* @param {Function} [opts.reArmAutoMergeFn] inject for tests (default enableAutoMergeWith)
|
|
523
|
+
* @param {Function} [opts.blockDeliveryFn] inject for tests (default blockStoryDelivery)
|
|
283
524
|
* @param {object} [opts.logger]
|
|
284
525
|
* @param {(line: string) => void} [opts.print] stdout sink (default process.stdout)
|
|
285
526
|
* @returns {Promise<number>} process exit code.
|
|
@@ -299,6 +540,15 @@ export async function runPrWatch({
|
|
|
299
540
|
ghPrUpdateBranchFn,
|
|
300
541
|
sleepFn,
|
|
301
542
|
writeDigestFn = writeCiDigest,
|
|
543
|
+
readDigestFn = readCiDigest,
|
|
544
|
+
retireDigestFn = retireCiDigest,
|
|
545
|
+
headShaFn = resolvePrHeadSha,
|
|
546
|
+
disarmAutoMergeFn = disarmAutoMerge,
|
|
547
|
+
reArmAutoMergeFn = defaultReArm,
|
|
548
|
+
blockDeliveryFn = blockStoryDelivery,
|
|
549
|
+
mergeStateProbeFn,
|
|
550
|
+
attachWindowMs,
|
|
551
|
+
nowMsFn = Date.now,
|
|
302
552
|
logger = Logger,
|
|
303
553
|
print = (line) => process.stdout.write(`${line}\n`),
|
|
304
554
|
} = {}) {
|
|
@@ -309,63 +559,156 @@ export async function runPrWatch({
|
|
|
309
559
|
config !== undefined ? config : safeResolveConfig(logger);
|
|
310
560
|
const knobs = resolveWatchKnobs({
|
|
311
561
|
config: resolvedConfig,
|
|
312
|
-
flags: {
|
|
562
|
+
flags: {
|
|
563
|
+
pollIntervalMs,
|
|
564
|
+
maxPolls,
|
|
565
|
+
maxResumes,
|
|
566
|
+
maxUpdates,
|
|
567
|
+
attachWindowMs,
|
|
568
|
+
},
|
|
313
569
|
});
|
|
314
570
|
const effectiveTempRoot =
|
|
315
571
|
tempRoot ?? resolvedConfig?.project?.paths?.tempRoot ?? 'temp';
|
|
572
|
+
const cwd = process.cwd();
|
|
573
|
+
|
|
574
|
+
// `gh` has NO `<owner/repo>#<number>` argument form — it parses that string
|
|
575
|
+
// as a BRANCH NAME, which is why every `--repo` invocation used to fail at
|
|
576
|
+
// the first probe with a misleading `gh-checks-failed:status=1` (#4890). The
|
|
577
|
+
// repository therefore travels two sanctioned ways, never as a composed ref:
|
|
578
|
+
// - a real `--repo` flag on the watch ports, which build their own argv;
|
|
579
|
+
// - a canonical PR URL for the no-rerun-guard helpers, which take a bare
|
|
580
|
+
// ref and no repository of their own.
|
|
581
|
+
// With `--repo` omitted, `gh` infers the repository from the cwd's remote.
|
|
582
|
+
const prRef = String(prNumber);
|
|
583
|
+
const guardPrRef = repo
|
|
584
|
+
? `https://github.com/${repo}/pull/${prNumber}`
|
|
585
|
+
: prRef;
|
|
316
586
|
|
|
317
|
-
//
|
|
318
|
-
//
|
|
319
|
-
//
|
|
320
|
-
const
|
|
587
|
+
// One merge-state probe, resolved once and used for both readings that need
|
|
588
|
+
// the repository's own view of the PR: the empty-required-set classification
|
|
589
|
+
// below, and the green-verdict reconciliation further down.
|
|
590
|
+
const probeMergeState =
|
|
591
|
+
mergeStateProbeFn ??
|
|
592
|
+
(ghPrViewFn ? mergeStateProbeFromView(ghPrViewFn) : defaultMergeStateProbe);
|
|
593
|
+
const readMergeState = () =>
|
|
594
|
+
probeMergeState({ prRef: guardPrRef, prUrl: prRef, repo, cwd, prNumber });
|
|
321
595
|
|
|
322
|
-
const result = await
|
|
323
|
-
|
|
324
|
-
|
|
325
|
-
|
|
326
|
-
|
|
327
|
-
|
|
328
|
-
|
|
329
|
-
|
|
330
|
-
|
|
331
|
-
|
|
332
|
-
|
|
596
|
+
const result = await watchWithAttachWindow({
|
|
597
|
+
watchArgs: {
|
|
598
|
+
prUrl: prRef,
|
|
599
|
+
repo,
|
|
600
|
+
cwd,
|
|
601
|
+
maxPolls: knobs.maxPolls,
|
|
602
|
+
maxUpdates: knobs.maxUpdates,
|
|
603
|
+
maxResumes: knobs.maxResumes,
|
|
604
|
+
pollIntervalMs: knobs.pollIntervalMs,
|
|
605
|
+
...(ghPrChecksFn ? { ghPrChecksFn } : {}),
|
|
606
|
+
...(ghPrViewFn ? { ghPrViewFn } : {}),
|
|
607
|
+
...(ghPrUpdateBranchFn ? { ghPrUpdateBranchFn } : {}),
|
|
608
|
+
...(sleepFn ? { sleepFn } : {}),
|
|
609
|
+
logger,
|
|
610
|
+
},
|
|
611
|
+
attachWindowMs: knobs.attachWindowMs,
|
|
612
|
+
retryIntervalMs: knobs.pollIntervalMs,
|
|
613
|
+
sleepFn: sleepFn ?? defaultSleep,
|
|
614
|
+
nowMsFn,
|
|
615
|
+
// Structural, not prose: the PR reads back ⇒ `gh` works and the empty
|
|
616
|
+
// required set is CI that has not started yet.
|
|
617
|
+
probePrResolvable: async () => (await readMergeState()) !== null,
|
|
333
618
|
logger,
|
|
334
619
|
});
|
|
335
620
|
|
|
336
621
|
// Always print the final outcomes map so the operator (and the
|
|
337
622
|
// workflow log) can see exactly which check blocked.
|
|
338
|
-
|
|
339
|
-
|
|
340
|
-
|
|
341
|
-
|
|
342
|
-
|
|
343
|
-
|
|
344
|
-
|
|
345
|
-
|
|
346
|
-
|
|
347
|
-
|
|
348
|
-
|
|
349
|
-
|
|
350
|
-
|
|
351
|
-
);
|
|
623
|
+
const envelope = {
|
|
624
|
+
prNumber,
|
|
625
|
+
checkOutcomes: result.outcomes,
|
|
626
|
+
requiredChecks: result.requiredChecks,
|
|
627
|
+
polls: result.polls,
|
|
628
|
+
updatesApplied: result.updatesApplied,
|
|
629
|
+
resumesApplied: result.resumesApplied,
|
|
630
|
+
terminal: result.terminal,
|
|
631
|
+
green: result.green,
|
|
632
|
+
stillRunning: result.stillRunning,
|
|
633
|
+
...(result.attachRetries ? { attachRetries: result.attachRetries } : {}),
|
|
634
|
+
...(result.error ? { error: result.error } : {}),
|
|
635
|
+
};
|
|
352
636
|
|
|
353
|
-
if (result.error) {
|
|
637
|
+
if (result.requiredChecksEmpty || result.error) {
|
|
638
|
+
// The attach window is spent and the required set is still empty. Classify
|
|
639
|
+
// it structurally — never against `gh`'s stderr prose (Story #4890).
|
|
640
|
+
const notYetStarted = Boolean(
|
|
641
|
+
result.requiredChecksEmpty && result.prResolvable,
|
|
642
|
+
);
|
|
643
|
+
print(
|
|
644
|
+
JSON.stringify({
|
|
645
|
+
...envelope,
|
|
646
|
+
requiredChecksEmpty: Boolean(result.requiredChecksEmpty),
|
|
647
|
+
notYetStarted,
|
|
648
|
+
}),
|
|
649
|
+
);
|
|
650
|
+
if (notYetStarted) {
|
|
651
|
+
logger.warn?.(
|
|
652
|
+
`[pr-watch] no required check has attached to PR #${prNumber} within the ` +
|
|
653
|
+
`${Math.round(knobs.attachWindowMs / 1000)}s attach window (${result.attachRetries} re-resolutions), ` +
|
|
654
|
+
'and the pull request still reads back fine — this is CI that has not started, NOT a red check. ' +
|
|
655
|
+
'Keep polling natively:',
|
|
656
|
+
);
|
|
657
|
+
logger.warn?.('[pr-watch] gh pr checks <pr> --watch');
|
|
658
|
+
return STILL_RUNNING_EXIT_CODE;
|
|
659
|
+
}
|
|
354
660
|
logger.error?.(
|
|
355
|
-
`[pr-watch] could not resolve required checks: ${result.error}
|
|
661
|
+
`[pr-watch] could not resolve required checks: ${result.error} — the pull request itself could ` +
|
|
662
|
+
'not be read, so this is a `gh` / access fault rather than CI that has not started.',
|
|
356
663
|
);
|
|
357
664
|
return 1;
|
|
358
665
|
}
|
|
359
666
|
|
|
360
667
|
if (result.green) {
|
|
361
|
-
|
|
362
|
-
|
|
668
|
+
// Never green on an undercount (Story #4873). The observed required set is
|
|
669
|
+
// whatever `gh pr checks --required` returned on the FIRST probe; when a
|
|
670
|
+
// ruleset attached a context after that, every check we knew about can be
|
|
671
|
+
// green while GitHub still refuses the merge. Reconcile against the
|
|
672
|
+
// repository's own verdict before any green is issued — an unreconcilable
|
|
673
|
+
// set reports unresolved (exit 2, keep watching) rather than a false green
|
|
674
|
+
// or a false red.
|
|
675
|
+
const reconciliation = reconcileGreenVerdict({
|
|
676
|
+
observedRequired: result.requiredChecks,
|
|
677
|
+
mergeStateStatus: await readMergeState(),
|
|
678
|
+
});
|
|
679
|
+
if (!reconciliation.reconciled) {
|
|
680
|
+
print(JSON.stringify({ ...envelope, reconciliation }));
|
|
681
|
+
logger.warn?.(
|
|
682
|
+
`[pr-watch] withholding the green verdict: ${reconciliation.reason}. ` +
|
|
683
|
+
'Re-run the watch once the repository settles, or inspect branch protection for a context this watch never saw.',
|
|
684
|
+
);
|
|
685
|
+
return STILL_RUNNING_EXIT_CODE;
|
|
686
|
+
}
|
|
687
|
+
const guard = await evaluateGreenWatch({
|
|
688
|
+
storyId,
|
|
689
|
+
prNumber,
|
|
690
|
+
prRef: guardPrRef,
|
|
691
|
+
tempRoot: effectiveTempRoot,
|
|
692
|
+
cwd,
|
|
693
|
+
readDigestFn,
|
|
694
|
+
retireDigestFn,
|
|
695
|
+
headShaFn,
|
|
696
|
+
reArmFn: reArmAutoMergeFn,
|
|
697
|
+
blockFn: blockDeliveryFn,
|
|
698
|
+
logger,
|
|
699
|
+
});
|
|
700
|
+
print(JSON.stringify({ ...envelope, reconciliation, rerunGuard: guard }));
|
|
701
|
+
if (guard.exitCode === 0) {
|
|
702
|
+
logger.info?.('[pr-watch] all required checks green.');
|
|
703
|
+
}
|
|
704
|
+
return guard.exitCode;
|
|
363
705
|
}
|
|
364
706
|
|
|
365
707
|
// Slow-but-not-red: the cap AND resume budget are exhausted with checks
|
|
366
708
|
// still pending and none failed. Never exit 1, never `timed_out` — hand
|
|
367
709
|
// off to the host's interval loop and exit 2.
|
|
368
710
|
if (result.stillRunning) {
|
|
711
|
+
print(JSON.stringify(envelope));
|
|
369
712
|
const stillPending = Object.entries(result.outcomes)
|
|
370
713
|
.filter(([, v]) => v === 'still-running')
|
|
371
714
|
.map(([k]) => k)
|
|
@@ -377,8 +720,8 @@ export async function runPrWatch({
|
|
|
377
720
|
return STILL_RUNNING_EXIT_CODE;
|
|
378
721
|
}
|
|
379
722
|
|
|
380
|
-
// Genuine red check — exit 1 immediately,
|
|
381
|
-
// the fix-loop handoff.
|
|
723
|
+
// Genuine red check — exit 1 immediately, disarm auto-merge, write the
|
|
724
|
+
// digest, and surface the fix-loop handoff.
|
|
382
725
|
// Exclude 'still-running' as well as the non-failing states: when the cap
|
|
383
726
|
// fires with a mixed failed+pending map, promotePendingToStillRunning has
|
|
384
727
|
// rewritten the pending entries, and a still-running check is slow, not
|
|
@@ -395,26 +738,36 @@ export async function runPrWatch({
|
|
|
395
738
|
.map(([name, outcome]) => ({ name, outcome }));
|
|
396
739
|
const red = failures.map((f) => `${f.name}=${f.outcome}`).join(', ');
|
|
397
740
|
logger.error?.(`[pr-watch] required check(s) not green: ${red}`);
|
|
398
|
-
|
|
399
|
-
|
|
400
|
-
|
|
401
|
-
|
|
402
|
-
|
|
403
|
-
|
|
404
|
-
|
|
405
|
-
|
|
406
|
-
|
|
407
|
-
|
|
408
|
-
|
|
409
|
-
logger
|
|
410
|
-
|
|
411
|
-
|
|
412
|
-
|
|
413
|
-
|
|
414
|
-
|
|
415
|
-
|
|
741
|
+
const redOutcome = await handleRedWatch({
|
|
742
|
+
storyId,
|
|
743
|
+
prNumber,
|
|
744
|
+
prRef: guardPrRef,
|
|
745
|
+
failures,
|
|
746
|
+
tempRoot: effectiveTempRoot,
|
|
747
|
+
cwd,
|
|
748
|
+
writeDigestFn,
|
|
749
|
+
headShaFn,
|
|
750
|
+
disarmFn: disarmAutoMergeFn,
|
|
751
|
+
blockFn: blockDeliveryFn,
|
|
752
|
+
logger,
|
|
753
|
+
});
|
|
754
|
+
print(
|
|
755
|
+
JSON.stringify({
|
|
756
|
+
...envelope,
|
|
757
|
+
classification: classifyFailure(failures[0]?.name),
|
|
758
|
+
rerunGuard: {
|
|
759
|
+
verdict: 'red',
|
|
760
|
+
headSha: redOutcome.headSha,
|
|
761
|
+
autoMergeDisarmed: redOutcome.disarm.disarmed,
|
|
762
|
+
disarmDetail: redOutcome.disarm.detail,
|
|
763
|
+
digestPath: redOutcome.digestPaths?.jsonPath ?? null,
|
|
764
|
+
blocked: redOutcome.blocked,
|
|
765
|
+
},
|
|
766
|
+
}),
|
|
767
|
+
);
|
|
416
768
|
logger.error?.(
|
|
417
|
-
'[pr-watch] a required check failed. Read the digest,
|
|
769
|
+
'[pr-watch] a required check failed. Read the digest, reproduce the failure, and apply the smallest fix at source, ' +
|
|
770
|
+
'then push a new commit — re-running the failed job is forbidden (`.agents/rules/ci-remediation.md` § Verifier).',
|
|
418
771
|
);
|
|
419
772
|
return 1;
|
|
420
773
|
}
|
|
@@ -441,6 +794,7 @@ async function main() {
|
|
|
441
794
|
'poll-interval-ms': { type: 'string' },
|
|
442
795
|
'max-polls': { type: 'string' },
|
|
443
796
|
'max-resumes': { type: 'string' },
|
|
797
|
+
'attach-window-ms': { type: 'string' },
|
|
444
798
|
},
|
|
445
799
|
strict: false,
|
|
446
800
|
});
|
|
@@ -452,6 +806,7 @@ async function main() {
|
|
|
452
806
|
pollIntervalMs: values['poll-interval-ms'],
|
|
453
807
|
maxPolls: values['max-polls'],
|
|
454
808
|
maxResumes: values['max-resumes'],
|
|
809
|
+
attachWindowMs: values['attach-window-ms'],
|
|
455
810
|
});
|
|
456
811
|
}
|
|
457
812
|
|