forge-workflow 0.1.0-beta.2 → 0.1.0-beta.3
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/.forge/hooks/check-tdd.js +79 -5
- package/.forge/hooks/forge-native-hook.js +194 -8
- package/AGENTS.md +1 -0
- package/CHANGELOG.md +28 -0
- package/QUICKSTART.md +6 -2
- package/README.md +3 -1
- package/bin/forge.js +90 -19
- package/docs/guides/SETUP.md +4 -1
- package/docs/guides/SUPPORT.md +5 -0
- package/docs/reference/COMMANDS.md +9 -0
- package/docs/reference/shepherd.md +42 -2
- package/lib/activation/ensure-forge-home.js +135 -0
- package/lib/adapters/beads-kernel-compat.js +67 -0
- package/lib/adoption-profiles.js +17 -4
- package/lib/beads-detect.js +60 -0
- package/lib/beads-nudge.js +91 -0
- package/lib/commands/_aliases.js +248 -0
- package/lib/commands/_issue.js +39 -0
- package/lib/commands/_manifest.js +2 -0
- package/lib/commands/_registry.js +14 -0
- package/lib/commands/_resolve-command-opts.js +0 -31
- package/lib/commands/gate.js +19 -2
- package/lib/commands/hooks.js +139 -4
- package/lib/commands/init.js +26 -20
- package/lib/commands/memory.js +81 -0
- package/lib/commands/migrate.js +0 -161
- package/lib/commands/plan.js +48 -8
- package/lib/commands/pr.js +88 -0
- package/lib/commands/push.js +66 -0
- package/lib/commands/recall.js +67 -12
- package/lib/commands/recap.js +18 -4
- package/lib/commands/release.js +14 -1
- package/lib/commands/remember.js +86 -20
- package/lib/commands/setup.js +135 -72
- package/lib/commands/shepherd.js +67 -2
- package/lib/commands/ship.js +40 -4
- package/lib/commands/worktree.js +60 -4
- package/lib/core/runtime-graph.js +34 -3
- package/lib/gate-events.js +54 -55
- package/lib/global-flags.js +30 -0
- package/lib/grounding/context-events.js +230 -0
- package/lib/grounding/read-first.js +112 -0
- package/lib/hook-renderer.js +93 -3
- package/lib/kernel/backing-issue.js +7 -1
- package/lib/kernel/owned-kernel.js +43 -0
- package/lib/kernel/sqlite-driver.js +37 -1
- package/lib/pr-monitor/auto-actions.js +175 -0
- package/lib/pr-monitor/digest.js +206 -0
- package/lib/pr-monitor/render-sticky.js +43 -8
- package/lib/pr-monitor/upsert-sticky.js +169 -0
- package/lib/pr-pull.js +43 -2
- package/lib/release-readiness.js +17 -1
- package/lib/upgrade-safety.js +53 -1
- package/lib/workflow/enforce-stage.js +59 -2
- package/package.json +2 -2
- package/scripts/pr-auto-actions.js +93 -0
- package/scripts/pr-verdict-label.js +50 -0
|
@@ -0,0 +1,175 @@
|
|
|
1
|
+
'use strict';
|
|
2
|
+
|
|
3
|
+
/**
|
|
4
|
+
* PR-monitor Tier-2 auto-actions — the SAFE, fail-closed *action* half of the
|
|
5
|
+
* shepherd (issue addf5297, epic c2d398e5). Tier-1 (lib/pr-pull.js computeVerdict
|
|
6
|
+
* + the pr-monitor workflow) LABELS a PR's state but takes no action, so PRs that
|
|
7
|
+
* only need "master merged in" sit untended until a human/agent nudges them.
|
|
8
|
+
*
|
|
9
|
+
* This module decides — PURELY, from the SAME `forge shepherd <pr> --pull --json`
|
|
10
|
+
* payload the monitor already computes — whether the monitor may take one of two
|
|
11
|
+
* surface-safe actions:
|
|
12
|
+
* 1. `updateBranch` — merge base into an OTHERWISE-CLEAN-but-BEHIND PR (the
|
|
13
|
+
* "last mile" case). This is the highest-value, safest action: it clears the
|
|
14
|
+
* BEHIND churn without ever touching a PR that has a real blocker.
|
|
15
|
+
* 2. `rerunFlaky` — re-run a required check whose failure is INFRASTRUCTURAL
|
|
16
|
+
* (cancelled / timed-out / stale / startup-failure), never a real test
|
|
17
|
+
* FAILURE/ERROR.
|
|
18
|
+
*
|
|
19
|
+
* It NEVER merges, NEVER resolves review threads, NEVER force-pushes, NEVER edits
|
|
20
|
+
* code. It only decides; the workflow executes the `gh` calls (and owns
|
|
21
|
+
* per-head-SHA idempotency markers). Every gate below is fail-CLOSED: any missing
|
|
22
|
+
* field, degraded read, real failure, fork, draft, or unclassifiable signal
|
|
23
|
+
* yields `should:false`.
|
|
24
|
+
*
|
|
25
|
+
* @module pr-monitor/auto-actions
|
|
26
|
+
*/
|
|
27
|
+
|
|
28
|
+
/**
|
|
29
|
+
* Check conclusions that are INFRASTRUCTURAL flakes — a re-run may legitimately
|
|
30
|
+
* turn them green. Mirrors lib/pr-shepherd.js `isFailed`'s not-green terminal
|
|
31
|
+
* conclusions MINUS the genuinely-broken ones (FAILURE/ERROR/ACTION_REQUIRED).
|
|
32
|
+
*/
|
|
33
|
+
const INFRA_CONCLUSIONS = new Set(['CANCELLED', 'TIMED_OUT', 'STALE', 'STARTUP_FAILURE']);
|
|
34
|
+
|
|
35
|
+
/** Conclusions that mean the code is genuinely broken — NEVER auto-rerun these. */
|
|
36
|
+
const REAL_FAILURE_CONCLUSIONS = new Set(['FAILURE', 'ERROR', 'ACTION_REQUIRED']);
|
|
37
|
+
|
|
38
|
+
/**
|
|
39
|
+
* Pull the numeric Actions run id from a job/run "details" URL
|
|
40
|
+
* (`.../actions/runs/<run>/job/<job>` or `.../actions/runs/<run>`). Returns null
|
|
41
|
+
* when absent — a null run id fails the rerun decision closed.
|
|
42
|
+
*
|
|
43
|
+
* @param {string} url
|
|
44
|
+
* @returns {string | null}
|
|
45
|
+
*/
|
|
46
|
+
function runIdFromUrl(url) {
|
|
47
|
+
const m = String(url || '').match(/\/runs\/(\d+)/);
|
|
48
|
+
return m ? m[1] : null;
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
/**
|
|
52
|
+
* A payload is DEGRADED (some verdict-relevant read failed or the head moved
|
|
53
|
+
* mid-gather) when its evidence lists unreadable sources or a torn read. Acting
|
|
54
|
+
* on a degraded gather could act on stale/false state, so both actions fail
|
|
55
|
+
* closed on it — even though `verdict==='BEHIND'` already implies a clean read,
|
|
56
|
+
* this stays an explicit, independent guard.
|
|
57
|
+
*
|
|
58
|
+
* @param {object} payload
|
|
59
|
+
* @returns {boolean}
|
|
60
|
+
*/
|
|
61
|
+
function isDegraded(payload) {
|
|
62
|
+
const ev = (payload && payload.evidence) || {};
|
|
63
|
+
const unreadable = Array.isArray(ev.unreadable) ? ev.unreadable : [];
|
|
64
|
+
return unreadable.length > 0 || ev.tornRead === true;
|
|
65
|
+
}
|
|
66
|
+
|
|
67
|
+
/**
|
|
68
|
+
* Decide whether to auto-update (merge base into) an otherwise-clean-but-BEHIND
|
|
69
|
+
* PR. Fires ONLY for the "last mile" case:
|
|
70
|
+
* - verdict is exactly `BEHIND` (which itself guarantees rank-1 UNKNOWN and
|
|
71
|
+
* rank-2 BLOCKED-CONFLICT did NOT fire — i.e. the read was clean and there is
|
|
72
|
+
* no conflict);
|
|
73
|
+
* - the PR is NOT a draft and NOT a fork (a base-repo token cannot push a fork
|
|
74
|
+
* branch, and forks are out of scope);
|
|
75
|
+
* - the read is not degraded;
|
|
76
|
+
* - and the ONLY blocker is the behind-base one — every other blocker type
|
|
77
|
+
* (failing/missing/skipped/pending required checks, bot-status gates,
|
|
78
|
+
* unresolved threads, changes-requested / review-required, conflict) is
|
|
79
|
+
* absent. `blockers[]` is computed independently of the verdict precedence,
|
|
80
|
+
* so it still lists lower-precedence blockers that `BEHIND` masks — which is
|
|
81
|
+
* exactly why we key on it rather than on the single verdict string.
|
|
82
|
+
*
|
|
83
|
+
* @param {object} payload - the `--pull --json` payload.
|
|
84
|
+
* @param {{ isFork?: boolean }} [opts]
|
|
85
|
+
* @returns {{ should: boolean, reason: string }}
|
|
86
|
+
*/
|
|
87
|
+
function decideUpdateBranch(payload, opts = {}) {
|
|
88
|
+
const skip = (reason) => ({ should: false, reason });
|
|
89
|
+
if (!payload || typeof payload !== 'object') return skip('no payload — fail closed');
|
|
90
|
+
if (opts.isFork) return skip('fork PR — a base-repo token cannot update a fork branch');
|
|
91
|
+
if (isDegraded(payload)) return skip('degraded/torn read — fail closed');
|
|
92
|
+
if (payload.verdict !== 'BEHIND') return skip(`verdict ${payload.verdict || 'UNKNOWN'} is not BEHIND`);
|
|
93
|
+
if (payload.draft === true) return skip('draft PR — not ready to advance');
|
|
94
|
+
if (!Array.isArray(payload.blockers)) return skip('blockers[] unavailable — fail closed');
|
|
95
|
+
const others = payload.blockers.filter((b) => b && b.type !== 'behind');
|
|
96
|
+
if (others.length > 0) {
|
|
97
|
+
return skip(`other blocker(s) present: ${others.map((b) => b.type).join(', ')}`);
|
|
98
|
+
}
|
|
99
|
+
return { should: true, reason: 'otherwise-clean-behind — only blocker is behind-base; merge base in' };
|
|
100
|
+
}
|
|
101
|
+
|
|
102
|
+
/**
|
|
103
|
+
* Decide whether to re-run flaky REQUIRED checks. Fires ONLY when EVERY failing
|
|
104
|
+
* required check is infrastructural (cancelled/timed-out/stale/startup-failure)
|
|
105
|
+
* with a derivable run id, and NONE is a real FAILURE/ERROR/ACTION_REQUIRED. A
|
|
106
|
+
* single real failure, an unclassifiable conclusion, a required-failing check
|
|
107
|
+
* with no matching `failures[]` entry, or a missing run id fails the WHOLE
|
|
108
|
+
* decision closed (never rerun a genuinely-broken PR, never loop on a real bug).
|
|
109
|
+
*
|
|
110
|
+
* @param {object} payload - the `--pull --json` payload.
|
|
111
|
+
* @returns {{ should: boolean, checks: Array<{name:string,conclusion:string,runId:string|null}>, runIds: string[], reason: string }}
|
|
112
|
+
*/
|
|
113
|
+
function decideRerun(payload) {
|
|
114
|
+
const empty = (reason) => ({ should: false, checks: [], runIds: [], reason });
|
|
115
|
+
if (!payload || typeof payload !== 'object') return empty('no payload — fail closed');
|
|
116
|
+
if (isDegraded(payload)) return empty('degraded/torn read — fail closed');
|
|
117
|
+
|
|
118
|
+
const rc = payload.requiredChecks || {};
|
|
119
|
+
const failingNames = Array.isArray(rc.failing) ? rc.failing : [];
|
|
120
|
+
if (failingNames.length === 0) return empty('no failing required checks');
|
|
121
|
+
|
|
122
|
+
const failures = Array.isArray(payload.failures) ? payload.failures : [];
|
|
123
|
+
const conclByName = new Map();
|
|
124
|
+
const urlByName = new Map();
|
|
125
|
+
for (const f of failures) {
|
|
126
|
+
if (!f || !f.name) continue;
|
|
127
|
+
if (!conclByName.has(f.name)) {
|
|
128
|
+
conclByName.set(f.name, String(f.conclusion || '').toUpperCase());
|
|
129
|
+
urlByName.set(f.name, f.jobUrl || f.detailsUrl || null);
|
|
130
|
+
}
|
|
131
|
+
}
|
|
132
|
+
|
|
133
|
+
const picked = [];
|
|
134
|
+
for (const name of failingNames) {
|
|
135
|
+
const concl = conclByName.get(name);
|
|
136
|
+
if (!concl) return empty(`required check "${name}" has no known conclusion — cannot confirm flaky, fail closed`);
|
|
137
|
+
if (REAL_FAILURE_CONCLUSIONS.has(concl)) return empty(`required check "${name}" is a real failure (${concl}) — never rerun`);
|
|
138
|
+
if (!INFRA_CONCLUSIONS.has(concl)) return empty(`required check "${name}" conclusion ${concl} is not classified infrastructural — fail closed`);
|
|
139
|
+
picked.push({ name, conclusion: concl, runId: runIdFromUrl(urlByName.get(name)) });
|
|
140
|
+
}
|
|
141
|
+
|
|
142
|
+
const runIds = [...new Set(picked.map((p) => p.runId).filter(Boolean))];
|
|
143
|
+
if (runIds.length === 0) return empty('no run id derivable from failure jobUrl — fail closed');
|
|
144
|
+
return {
|
|
145
|
+
should: true,
|
|
146
|
+
checks: picked,
|
|
147
|
+
runIds,
|
|
148
|
+
reason: `all ${picked.length} failing required check(s) are infrastructural (${picked.map((p) => p.conclusion).join(', ')})`,
|
|
149
|
+
};
|
|
150
|
+
}
|
|
151
|
+
|
|
152
|
+
/**
|
|
153
|
+
* Compute the full auto-action decision from a `--pull --json` payload. Pure and
|
|
154
|
+
* independently testable — no I/O, no `gh`, no side effects.
|
|
155
|
+
*
|
|
156
|
+
* @param {object} payload
|
|
157
|
+
* @param {{ isFork?: boolean }} [opts]
|
|
158
|
+
* @returns {{ updateBranch: object, rerunFlaky: object }}
|
|
159
|
+
*/
|
|
160
|
+
function decideAutoActions(payload, opts = {}) {
|
|
161
|
+
return {
|
|
162
|
+
updateBranch: decideUpdateBranch(payload, opts),
|
|
163
|
+
rerunFlaky: decideRerun(payload),
|
|
164
|
+
};
|
|
165
|
+
}
|
|
166
|
+
|
|
167
|
+
module.exports = {
|
|
168
|
+
decideAutoActions,
|
|
169
|
+
decideUpdateBranch,
|
|
170
|
+
decideRerun,
|
|
171
|
+
runIdFromUrl,
|
|
172
|
+
isDegraded,
|
|
173
|
+
INFRA_CONCLUSIONS,
|
|
174
|
+
REAL_FAILURE_CONCLUSIONS,
|
|
175
|
+
};
|
|
@@ -0,0 +1,206 @@
|
|
|
1
|
+
'use strict';
|
|
2
|
+
|
|
3
|
+
/**
|
|
4
|
+
* PR-shepherd digest — the thin CONSUMER half of the constant monitor (epic
|
|
5
|
+
* c2d398e5, 33e1bbd3). The constant watch loop is the PRODUCER: it writes the
|
|
6
|
+
* per-PR NDJSON journals under `.forge/pr-monitor/<repo>-<pr>/` (the `forge
|
|
7
|
+
* shepherd events` pull surface reads those same records back). Nothing, though,
|
|
8
|
+
* surfaced those events to a working agent. This module is a pure READER: it
|
|
9
|
+
* reads the NEW budget events across all PR journals since a persisted per-PR
|
|
10
|
+
* CONSUMER cursor, renders a COMPACT capped summary, and advances the cursor —
|
|
11
|
+
* the exact payload a harness hook (Claude UserPromptSubmit) injects each turn.
|
|
12
|
+
*
|
|
13
|
+
* The CORE (events/journal/watch/monitor) is untouched: this only READS the
|
|
14
|
+
* journal via `journal.readEventsSince` and keeps its OWN `consumer.cursor`
|
|
15
|
+
* (distinct from the watcher's snapshot), so consumption never disturbs
|
|
16
|
+
* production. Every function is fail-open — a bad journal degrades to skipped,
|
|
17
|
+
* never throws — because it feeds a hook that must never block a prompt.
|
|
18
|
+
*
|
|
19
|
+
* @module pr-monitor/digest
|
|
20
|
+
*/
|
|
21
|
+
|
|
22
|
+
const fs = require('node:fs');
|
|
23
|
+
const path = require('node:path');
|
|
24
|
+
|
|
25
|
+
const journalMod = require('./journal');
|
|
26
|
+
|
|
27
|
+
/**
|
|
28
|
+
* The event types worth surfacing on each turn — the ACTIONABLE transitions
|
|
29
|
+
* (verdict flip, a failed check, a new review thread, terminal merge/close).
|
|
30
|
+
* Everything else (head pushes, green checks, degraded notices) stays in the
|
|
31
|
+
* journal for `forge shepherd events` but is NOT pushed, to keep the injected
|
|
32
|
+
* context tiny.
|
|
33
|
+
*/
|
|
34
|
+
const BUDGET_TYPES = Object.freeze(new Set([
|
|
35
|
+
'verdict.changed', 'check.failed', 'thread.opened', 'pr.merged', 'pr.closed',
|
|
36
|
+
]));
|
|
37
|
+
|
|
38
|
+
const DEFAULT_CAP = 8;
|
|
39
|
+
const CONSUMER_CURSOR_FILE = 'consumer.cursor';
|
|
40
|
+
|
|
41
|
+
/** Absolute `.forge/pr-monitor` root for a project. */
|
|
42
|
+
function monitorRoot(root) {
|
|
43
|
+
return path.join(root, '.forge', 'pr-monitor');
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
/** The per-PR consumer cursor path (distinct from the watcher's snapshot/pid). */
|
|
47
|
+
function cursorPath(dir) {
|
|
48
|
+
return path.join(dir, CONSUMER_CURSOR_FILE);
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
/**
|
|
52
|
+
* List absolute PR journal dirs (those containing an events.ndjson). Fail-open:
|
|
53
|
+
* a missing monitor root or unreadable dir yields []. Injectable fs for tests.
|
|
54
|
+
*
|
|
55
|
+
* @param {string} root
|
|
56
|
+
* @param {{ readdirSync?: Function, existsSync?: Function }} [deps]
|
|
57
|
+
* @returns {string[]}
|
|
58
|
+
*/
|
|
59
|
+
function discoverPrDirs(root, deps = {}) {
|
|
60
|
+
const readdir = deps.readdirSync || fs.readdirSync;
|
|
61
|
+
const exists = deps.existsSync || fs.existsSync;
|
|
62
|
+
try {
|
|
63
|
+
return readdir(monitorRoot(root), { withFileTypes: true })
|
|
64
|
+
.filter((e) => e.isDirectory())
|
|
65
|
+
.map((e) => path.join(monitorRoot(root), e.name))
|
|
66
|
+
.filter((dir) => exists(path.join(dir, 'events.ndjson')));
|
|
67
|
+
} catch {
|
|
68
|
+
return [];
|
|
69
|
+
}
|
|
70
|
+
}
|
|
71
|
+
|
|
72
|
+
/**
|
|
73
|
+
* Read a PR's consumer cursor (the last consumed seq). Fail-open → 0 (no cursor,
|
|
74
|
+
* unreadable, or malformed all mean "start from the beginning").
|
|
75
|
+
*
|
|
76
|
+
* @param {string} dir
|
|
77
|
+
* @param {{ readFileSync?: Function }} [deps]
|
|
78
|
+
* @returns {number}
|
|
79
|
+
*/
|
|
80
|
+
function readConsumerCursor(dir, deps = {}) {
|
|
81
|
+
const readFile = deps.readFileSync || fs.readFileSync;
|
|
82
|
+
try {
|
|
83
|
+
const obj = JSON.parse(readFile(cursorPath(dir), 'utf8'));
|
|
84
|
+
const seq = Number(obj && obj.seq);
|
|
85
|
+
return Number.isFinite(seq) && seq >= 0 ? seq : 0;
|
|
86
|
+
} catch {
|
|
87
|
+
return 0;
|
|
88
|
+
}
|
|
89
|
+
}
|
|
90
|
+
|
|
91
|
+
/**
|
|
92
|
+
* Persist a PR's consumer cursor. Fail-open: an unwritable cursor returns false
|
|
93
|
+
* (next turn re-reads the same events — a duplicate nudge, never a crash).
|
|
94
|
+
*
|
|
95
|
+
* @param {string} dir
|
|
96
|
+
* @param {number} seq
|
|
97
|
+
* @param {{ writeFileSync?: Function }} [deps]
|
|
98
|
+
* @returns {boolean}
|
|
99
|
+
*/
|
|
100
|
+
function writeConsumerCursor(dir, seq, deps = {}) {
|
|
101
|
+
const writeFile = deps.writeFileSync || fs.writeFileSync;
|
|
102
|
+
try {
|
|
103
|
+
writeFile(cursorPath(dir), `${JSON.stringify({ seq: Number(seq) || 0 })}\n`);
|
|
104
|
+
return true;
|
|
105
|
+
} catch {
|
|
106
|
+
return false;
|
|
107
|
+
}
|
|
108
|
+
}
|
|
109
|
+
|
|
110
|
+
/**
|
|
111
|
+
* A compact one-line label for a budget event. PURE. Bounded so injected context
|
|
112
|
+
* stays tiny; the full record is always available via `forge shepherd events`.
|
|
113
|
+
*
|
|
114
|
+
* @param {object} e - a journal event.
|
|
115
|
+
* @returns {string}
|
|
116
|
+
*/
|
|
117
|
+
function renderEventLine(e) {
|
|
118
|
+
const pr = e.pr != null ? `#${e.pr}` : '#?';
|
|
119
|
+
const d = e.data || {};
|
|
120
|
+
let detail;
|
|
121
|
+
switch (e.type) {
|
|
122
|
+
case 'verdict.changed': detail = (e.verdict && (e.verdict.verdict || e.verdict.state)) || d.verdict || d.to || ''; break;
|
|
123
|
+
case 'check.failed': detail = d.name || d.check || ''; break;
|
|
124
|
+
case 'thread.opened': detail = d.author ? `by ${d.author}` : (d.path || ''); break;
|
|
125
|
+
case 'pr.merged': detail = 'merged'; break;
|
|
126
|
+
case 'pr.closed': detail = 'closed'; break;
|
|
127
|
+
default: detail = '';
|
|
128
|
+
}
|
|
129
|
+
const suffix = detail ? `: ${String(detail).slice(0, 60)}` : '';
|
|
130
|
+
return `- PR ${pr} ${e.type}${suffix}`;
|
|
131
|
+
}
|
|
132
|
+
|
|
133
|
+
/**
|
|
134
|
+
* PURE: filter events to the budget types, cap the count, and render lines.
|
|
135
|
+
*
|
|
136
|
+
* @param {object[]} events
|
|
137
|
+
* @param {{ cap?: number }} [opts]
|
|
138
|
+
* @returns {{ lines: string[], total: number }}
|
|
139
|
+
*/
|
|
140
|
+
function renderDigestLines(events, { cap = DEFAULT_CAP } = {}) {
|
|
141
|
+
const budget = (Array.isArray(events) ? events : []).filter((e) => e && BUDGET_TYPES.has(e.type));
|
|
142
|
+
const lines = budget.map(renderEventLine);
|
|
143
|
+
return { lines: lines.slice(0, cap), total: lines.length };
|
|
144
|
+
}
|
|
145
|
+
|
|
146
|
+
/**
|
|
147
|
+
* Format the compact injected block (a header + capped lines + an overflow
|
|
148
|
+
* pointer), or '' when there is nothing to surface. PURE.
|
|
149
|
+
*/
|
|
150
|
+
function formatBlock(lines, total, cap, prs) {
|
|
151
|
+
if (lines.length === 0) return '';
|
|
152
|
+
const on = prs.length ? ` on PR(s) ${prs.join(', ')}` : '';
|
|
153
|
+
const more = total > cap ? `\n(+${total - cap} more — see \`forge shepherd events <pr> --since <seq>\`)` : '';
|
|
154
|
+
return `[forge PR shepherd] ${total} new event(s)${on}:\n${lines.join('\n')}${more}`;
|
|
155
|
+
}
|
|
156
|
+
|
|
157
|
+
/**
|
|
158
|
+
* Collect a compact digest of NEW budget events across every PR journal since the
|
|
159
|
+
* per-PR consumer cursor, advancing each cursor past EVERYTHING read (budget and
|
|
160
|
+
* non-budget alike, so skipped types never re-surface). Fail-open throughout.
|
|
161
|
+
*
|
|
162
|
+
* @param {object} args
|
|
163
|
+
* @param {string} args.root - project root.
|
|
164
|
+
* @param {object} [args.journal] - journal module (test injection).
|
|
165
|
+
* @param {number} [args.cap] - max lines in the block.
|
|
166
|
+
* @param {object} [args.fsDeps] - injectable fs for discovery + cursor I/O.
|
|
167
|
+
* @returns {{ text: string, total: number, prs: string[] }}
|
|
168
|
+
*/
|
|
169
|
+
function collectDigest({ root, journal = journalMod, cap = DEFAULT_CAP, fsDeps = {} } = {}) {
|
|
170
|
+
const dirs = discoverPrDirs(root, fsDeps);
|
|
171
|
+
const allLines = [];
|
|
172
|
+
const prs = new Set();
|
|
173
|
+
for (const dir of dirs) {
|
|
174
|
+
const cursor = readConsumerCursor(dir, fsDeps);
|
|
175
|
+
let evs;
|
|
176
|
+
try {
|
|
177
|
+
evs = journal.readEventsSince(dir, cursor);
|
|
178
|
+
} catch {
|
|
179
|
+
evs = [];
|
|
180
|
+
}
|
|
181
|
+
if (!Array.isArray(evs) || evs.length === 0) continue;
|
|
182
|
+
const maxSeq = evs.reduce((m, e) => Math.max(m, Number(e.seq) || 0), cursor);
|
|
183
|
+
for (const e of evs) {
|
|
184
|
+
if (!e || !BUDGET_TYPES.has(e.type)) continue;
|
|
185
|
+
allLines.push(renderEventLine(e));
|
|
186
|
+
if (e.pr != null) prs.add(String(e.pr));
|
|
187
|
+
}
|
|
188
|
+
writeConsumerCursor(dir, maxSeq, fsDeps);
|
|
189
|
+
}
|
|
190
|
+
const capped = allLines.slice(0, cap);
|
|
191
|
+
return { text: formatBlock(capped, allLines.length, cap, [...prs]), total: allLines.length, prs: [...prs] };
|
|
192
|
+
}
|
|
193
|
+
|
|
194
|
+
module.exports = {
|
|
195
|
+
BUDGET_TYPES,
|
|
196
|
+
DEFAULT_CAP,
|
|
197
|
+
monitorRoot,
|
|
198
|
+
cursorPath,
|
|
199
|
+
discoverPrDirs,
|
|
200
|
+
readConsumerCursor,
|
|
201
|
+
writeConsumerCursor,
|
|
202
|
+
renderEventLine,
|
|
203
|
+
renderDigestLines,
|
|
204
|
+
formatBlock,
|
|
205
|
+
collectDigest,
|
|
206
|
+
};
|
|
@@ -5,12 +5,13 @@
|
|
|
5
5
|
* result (lib/pr-bundle.js) into the Markdown body of the single sticky PR
|
|
6
6
|
* comment the pr-monitor GitHub workflow keeps up to date.
|
|
7
7
|
*
|
|
8
|
-
* This is the SURFACE half of the monitor: it
|
|
9
|
-
*
|
|
10
|
-
*
|
|
11
|
-
*
|
|
12
|
-
*
|
|
13
|
-
*
|
|
8
|
+
* This is the SURFACE half of the monitor: it leads with the one-line actionable
|
|
9
|
+
* verdict (mirroring the `pr-verdict:*` label the workflow lands), then lists the
|
|
10
|
+
* unresolved review threads (grouped by author, ANY author) plus the failing and
|
|
11
|
+
* pending checks so async review-bot / human feedback in a window nobody is
|
|
12
|
+
* watching cannot rot. The verdict LABELS state (check-failed / threads-open /
|
|
13
|
+
* mergeable / …); it is NOT a merge action — it never merges, never resolves
|
|
14
|
+
* threads, never blocks, and is fail-closed (`unknown` on unreadable signals).
|
|
14
15
|
*
|
|
15
16
|
* Pure and deterministic: same bundle + same injected clock → same body, which
|
|
16
17
|
* is what lets the workflow rewrite the sticky comment in place without churn.
|
|
@@ -18,6 +19,33 @@
|
|
|
18
19
|
* @module pr-monitor/render-sticky
|
|
19
20
|
*/
|
|
20
21
|
|
|
22
|
+
/**
|
|
23
|
+
* Presentation-only headline for each canonical merge verdict (lib/pr-pull.js).
|
|
24
|
+
* The verdict VALUE is computed once by pr-pull (`forge shepherd --pull --json`)
|
|
25
|
+
* and passed in — this map only decides how to DISPLAY it, so there is no second
|
|
26
|
+
* verdict ladder to drift.
|
|
27
|
+
*/
|
|
28
|
+
const VERDICT_HEADLINE = {
|
|
29
|
+
UNKNOWN: '⚪ **Verdict: `unknown`** — a signal was unreadable; state unconfirmed (fail-closed).',
|
|
30
|
+
'BLOCKED-CONFLICT': '🔀 **Verdict: `blocked-conflict`** — branch conflicts with base; rebase/merge and resolve.',
|
|
31
|
+
BEHIND: '⬇️ **Verdict: `behind`** — branch is behind base; update/rebase (protection requires up-to-date).',
|
|
32
|
+
'BLOCKED-CHECKS': '🔴 **Verdict: `blocked-checks`** — a required check is failing/missing; fix it.',
|
|
33
|
+
'BLOCKED-THREADS': '🟠 **Verdict: `blocked-threads`** — unresolved review threads need addressing.',
|
|
34
|
+
'REVIEW-PENDING': '🟡 **Verdict: `review-pending`** — awaiting review / settle window; not ready yet.',
|
|
35
|
+
'CLEAN-MERGEABLE': '🟢 **Verdict: `clean-mergeable`** — green + zero unresolved threads; ready for a human to merge.',
|
|
36
|
+
};
|
|
37
|
+
|
|
38
|
+
/**
|
|
39
|
+
* Render the one-line verdict headline for a canonical verdict string. Unknown or
|
|
40
|
+
* missing input falls closed to the `unknown` headline.
|
|
41
|
+
*
|
|
42
|
+
* @param {string} verdict
|
|
43
|
+
* @returns {string}
|
|
44
|
+
*/
|
|
45
|
+
function verdictHeadline(verdict) {
|
|
46
|
+
return VERDICT_HEADLINE[String(verdict || '').toUpperCase()] || VERDICT_HEADLINE.UNKNOWN;
|
|
47
|
+
}
|
|
48
|
+
|
|
21
49
|
/** Hidden HTML marker: the workflow finds its prior comment by this string and
|
|
22
50
|
* UPDATES it in place, so the monitor never spams a PR with new comments. */
|
|
23
51
|
const STICKY_MARKER = '<!-- forge-pr-monitor -->';
|
|
@@ -130,7 +158,13 @@ function renderStickyComment(bundle = {}, opts = {}) {
|
|
|
130
158
|
lines.push(STICKY_MARKER);
|
|
131
159
|
lines.push('## 🔭 Forge PR Monitor');
|
|
132
160
|
lines.push('');
|
|
133
|
-
|
|
161
|
+
// Lead with the actionable verdict — the SAME value as the pr-verdict:* label
|
|
162
|
+
// and `forge shepherd --pull --json` (passed in via opts.verdict, computed once
|
|
163
|
+
// by pr-pull). Surface only: it labels state; this monitor **does not merge**
|
|
164
|
+
// and never resolves review threads.
|
|
165
|
+
lines.push(verdictHeadline(opts.verdict));
|
|
166
|
+
lines.push('');
|
|
167
|
+
lines.push('_Surfaces open review + check state so async feedback never rots. This monitor **does not merge** and never resolves review threads — a human merges in the GitHub UI._');
|
|
134
168
|
lines.push('');
|
|
135
169
|
|
|
136
170
|
renderThreads(bundle, lines);
|
|
@@ -143,13 +177,14 @@ function renderStickyComment(bundle = {}, opts = {}) {
|
|
|
143
177
|
}
|
|
144
178
|
|
|
145
179
|
lines.push('---');
|
|
146
|
-
lines.push(`<sub>Updated ${now.toISOString()} · surface-only monitor · never merges, never
|
|
180
|
+
lines.push(`<sub>Updated ${now.toISOString()} · surface-only monitor · labels state, never merges, never resolves threads.</sub>`);
|
|
147
181
|
|
|
148
182
|
return { marker: STICKY_MARKER, body: lines.join('\n') };
|
|
149
183
|
}
|
|
150
184
|
|
|
151
185
|
module.exports = {
|
|
152
186
|
renderStickyComment,
|
|
187
|
+
verdictHeadline,
|
|
153
188
|
groupByAuthor,
|
|
154
189
|
threadLocator,
|
|
155
190
|
STICKY_MARKER,
|
|
@@ -0,0 +1,169 @@
|
|
|
1
|
+
'use strict';
|
|
2
|
+
|
|
3
|
+
/**
|
|
4
|
+
* PR-monitor sticky-comment upsert — race-safe, converges to EXACTLY ONE sticky
|
|
5
|
+
* comment per PR even under a concurrent burst of workflow runs.
|
|
6
|
+
*
|
|
7
|
+
* Why this exists: the pr-monitor workflow deliberately has NO `concurrency:`
|
|
8
|
+
* group. A per-PR group does not help — GitHub's queue replacement cancels the
|
|
9
|
+
* previously-PENDING run in a group UNCONDITIONALLY (independent of
|
|
10
|
+
* cancel-in-progress), and this workflow's triggers (check_suite:completed fires
|
|
11
|
+
* ~10+ times per push, plus reviews/comments) burst hard, so a group left a trail
|
|
12
|
+
* of CANCELLED runs that render as red/non-SUCCESS checks and tripped merge-gate
|
|
13
|
+
* tooling (kernel issue 97e6a146). Dropping the group removes the cancellations,
|
|
14
|
+
* but then two concurrent first-runs on a PR with no sticky yet would BOTH find
|
|
15
|
+
* nothing and BOTH create one → duplicate sticky comments. This module closes
|
|
16
|
+
* that race deterministically instead.
|
|
17
|
+
*
|
|
18
|
+
* Reconcile-to-one algorithm:
|
|
19
|
+
* 1. List marker comments. If none, create one, then RE-LIST (a concurrent run
|
|
20
|
+
* may have created its own in the same burst).
|
|
21
|
+
* 2. Pick the deterministic survivor: the LOWEST comment id (oldest). Every
|
|
22
|
+
* concurrent run picks the SAME survivor, so they never fight.
|
|
23
|
+
* 3. Update the survivor with the latest body; delete every other marker
|
|
24
|
+
* comment. Deletes are idempotent (a 404 means a peer already removed it).
|
|
25
|
+
*
|
|
26
|
+
* A create that lands AFTER a run's re-list is self-healed by the next event:
|
|
27
|
+
* every run reconciles to one, and events keep arriving, so the PR converges to a
|
|
28
|
+
* single sticky comment.
|
|
29
|
+
*
|
|
30
|
+
* @module pr-monitor/upsert-sticky
|
|
31
|
+
*/
|
|
32
|
+
|
|
33
|
+
const { execFileSync } = require('node:child_process');
|
|
34
|
+
|
|
35
|
+
/** Ids of comments whose body carries the sticky marker. */
|
|
36
|
+
function markerCommentIds(comments, marker) {
|
|
37
|
+
return (Array.isArray(comments) ? comments : [])
|
|
38
|
+
.filter((comment) => typeof comment.body === 'string' && comment.body.includes(marker))
|
|
39
|
+
.map((comment) => comment.id);
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
/** Ascending by numeric id, so the survivor (index 0) is the oldest comment. */
|
|
43
|
+
function sortIdsAscending(ids) {
|
|
44
|
+
return [...ids].sort((left, right) => Number(left) - Number(right));
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
/**
|
|
48
|
+
* Drive a client to exactly one sticky comment. `client` abstracts the GitHub
|
|
49
|
+
* calls so the reconcile logic is unit-testable without the network:
|
|
50
|
+
* - list() → array of { id, body }
|
|
51
|
+
* - create() → create a new sticky comment (body supplied by the client)
|
|
52
|
+
* - update(id) → overwrite comment `id` with the latest body
|
|
53
|
+
* - remove(id) → delete comment `id` (must tolerate an already-deleted 404)
|
|
54
|
+
*
|
|
55
|
+
* @returns {Promise<{ survivor: (number|string|null), deleted: Array<number|string> }>}
|
|
56
|
+
*/
|
|
57
|
+
async function upsertStickyComment({ marker }, client) {
|
|
58
|
+
let ids = markerCommentIds(await client.list(), marker);
|
|
59
|
+
|
|
60
|
+
if (ids.length === 0) {
|
|
61
|
+
await client.create();
|
|
62
|
+
// Re-list: a concurrent run may have created its own sticky in this burst.
|
|
63
|
+
ids = markerCommentIds(await client.list(), marker);
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
if (ids.length === 0) {
|
|
67
|
+
// The just-created comment is not visible yet (eventual consistency); its
|
|
68
|
+
// body is already correct, and the next event will reconcile if a peer raced.
|
|
69
|
+
return { survivor: null, deleted: [] };
|
|
70
|
+
}
|
|
71
|
+
|
|
72
|
+
ids = sortIdsAscending(ids);
|
|
73
|
+
const survivor = ids[0];
|
|
74
|
+
await client.update(survivor);
|
|
75
|
+
|
|
76
|
+
const deleted = [];
|
|
77
|
+
for (const id of ids.slice(1)) {
|
|
78
|
+
await client.remove(id);
|
|
79
|
+
deleted.push(id);
|
|
80
|
+
}
|
|
81
|
+
|
|
82
|
+
return { survivor, deleted };
|
|
83
|
+
}
|
|
84
|
+
|
|
85
|
+
// Single choke point for the `gh` CLI, matching lib/commands/merge.js. `gh` is a
|
|
86
|
+
// hardcoded literal (never user input) and args are an array (no shell), so the
|
|
87
|
+
// S4036 PATH-search finding is a false positive in this developer-tool context;
|
|
88
|
+
// one annotation here covers every call site. `encoding: 'utf8'` also pipes
|
|
89
|
+
// stderr onto the thrown error, so isAlreadyGone() below can classify failures.
|
|
90
|
+
function runGh(args) {
|
|
91
|
+
return execFileSync('gh', args, { encoding: 'utf8' }); // NOSONAR S4036 - hardcoded CLI (gh), args array (no shell), developer-tool context
|
|
92
|
+
}
|
|
93
|
+
|
|
94
|
+
/**
|
|
95
|
+
* True only when a `gh api` failure means the target comment is already absent
|
|
96
|
+
* (HTTP 404 / 410) — the benign "a peer got there first" case. Auth failures,
|
|
97
|
+
* rate limits (403), and every other error return false so they propagate and
|
|
98
|
+
* surface a diagnostic instead of silently breaking the exactly-one invariant.
|
|
99
|
+
*/
|
|
100
|
+
function isAlreadyGone(error) {
|
|
101
|
+
const text = `${error && error.stderr ? error.stderr : ''} ${error && error.message ? error.message : ''}`;
|
|
102
|
+
return /HTTP 404|HTTP 410|\bNot Found\b|\bGone\b/i.test(text);
|
|
103
|
+
}
|
|
104
|
+
|
|
105
|
+
/**
|
|
106
|
+
* GitHub-backed client (shells to `gh api`, like lib/pr-monitor/gather.js). The
|
|
107
|
+
* reconcile logic above is unit-tested; the error-tolerance in update/remove is
|
|
108
|
+
* too, via an injectable `run`. create/update both send the same pre-rendered
|
|
109
|
+
* payload file the render step wrote, so the body is identical.
|
|
110
|
+
*/
|
|
111
|
+
function ghStickyClient({ repo, pr, payloadFile, run = runGh }) {
|
|
112
|
+
return {
|
|
113
|
+
async list() {
|
|
114
|
+
const out = run(['api', `repos/${repo}/issues/${pr}/comments`, '--paginate']);
|
|
115
|
+
return out && out.trim() ? JSON.parse(out) : [];
|
|
116
|
+
},
|
|
117
|
+
async create() {
|
|
118
|
+
run(['api', '-X', 'POST', `repos/${repo}/issues/${pr}/comments`, '--input', payloadFile]);
|
|
119
|
+
},
|
|
120
|
+
async update(id) {
|
|
121
|
+
try {
|
|
122
|
+
run(['api', '-X', 'PATCH', `repos/${repo}/issues/comments/${id}`, '--input', payloadFile]);
|
|
123
|
+
} catch (error) {
|
|
124
|
+
if (!isAlreadyGone(error)) {
|
|
125
|
+
throw error;
|
|
126
|
+
}
|
|
127
|
+
// Our chosen survivor was deleted by a peer whose survivor had a lower id:
|
|
128
|
+
// the peer's sticky wins, exactly-one still holds, so we are done.
|
|
129
|
+
}
|
|
130
|
+
},
|
|
131
|
+
async remove(id) {
|
|
132
|
+
try {
|
|
133
|
+
run(['api', '-X', 'DELETE', `repos/${repo}/issues/comments/${id}`]);
|
|
134
|
+
} catch (error) {
|
|
135
|
+
if (!isAlreadyGone(error)) {
|
|
136
|
+
throw error;
|
|
137
|
+
}
|
|
138
|
+
// Already gone (a concurrent run deleted it first) — the goal state holds.
|
|
139
|
+
}
|
|
140
|
+
},
|
|
141
|
+
};
|
|
142
|
+
}
|
|
143
|
+
|
|
144
|
+
async function main() {
|
|
145
|
+
const repo = process.env.GH_REPO;
|
|
146
|
+
const pr = process.env.PR;
|
|
147
|
+
const payloadFile = process.env.STICKY_PAYLOAD_FILE || 'monitor-payload.json';
|
|
148
|
+
const { STICKY_MARKER } = require('./render-sticky');
|
|
149
|
+
|
|
150
|
+
const client = ghStickyClient({ repo, pr, payloadFile });
|
|
151
|
+
const { survivor, deleted } = await upsertStickyComment({ marker: STICKY_MARKER }, client);
|
|
152
|
+
const survivorLabel = survivor === null ? 'created (not yet visible)' : survivor;
|
|
153
|
+
console.log(`Sticky comment reconciled to one: survivor=${survivorLabel}, deleted=${deleted.length}`);
|
|
154
|
+
}
|
|
155
|
+
|
|
156
|
+
if (require.main === module) {
|
|
157
|
+
main().catch((error) => {
|
|
158
|
+
console.error(error.message);
|
|
159
|
+
process.exit(1);
|
|
160
|
+
});
|
|
161
|
+
}
|
|
162
|
+
|
|
163
|
+
module.exports = {
|
|
164
|
+
upsertStickyComment,
|
|
165
|
+
markerCommentIds,
|
|
166
|
+
sortIdsAscending,
|
|
167
|
+
isAlreadyGone,
|
|
168
|
+
ghStickyClient,
|
|
169
|
+
};
|