forge-workflow 0.1.0-beta.4 → 0.1.0-beta.5
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.md +14 -7
- package/CHANGELOG.md +43 -1
- package/README.md +6 -2
- package/bin/forge-cmd.js +20 -0
- package/bin/forge.js +16 -374
- package/docs/INDEX.md +1 -1
- package/docs/guides/BEADS_GITHUB_SYNC.md +2 -31
- package/docs/guides/MIGRATION.md +4 -4
- package/docs/guides/SETUP.md +16 -16
- package/docs/reference/COMMANDS.md +8 -5
- package/docs/reference/INSIGHTS_RECAP.md +9 -20
- package/docs/reference/RELEASE.md +5 -3
- package/docs/reference/TOOLCHAIN.md +8 -0
- package/docs/reference/protected-state-surfaces.md +4 -4
- package/docs/reference/shepherd.md +54 -25
- package/lefthook.yml +12 -0
- package/lib/activation/ensure-forge-home.js +33 -15
- package/lib/adapters/pr-state-adapter.js +344 -142
- package/lib/audit-evidence.js +71 -110
- package/lib/capped-jsonl-log.js +236 -0
- package/lib/commands/_registry.js +2 -2
- package/lib/commands/clean.js +196 -32
- package/lib/commands/dev.js +4 -33
- package/lib/commands/hooks.js +223 -25
- package/lib/commands/insights.js +8 -3
- package/lib/commands/merge.js +600 -40
- package/lib/commands/pr.js +1 -1
- package/lib/commands/preflight.js +11 -2
- package/lib/commands/prime.js +21 -8
- package/lib/commands/push.js +41 -51
- package/lib/commands/recall.js +60 -16
- package/lib/commands/recap.js +6 -1
- package/lib/commands/release.js +17 -2
- package/lib/commands/setup.js +191 -94
- package/lib/commands/shepherd.js +13 -1
- package/lib/commands/ship.js +22 -23
- package/lib/commands/skill.js +119 -11
- package/lib/commands/status.js +17 -1
- package/lib/commands/test.js +24 -34
- package/lib/commands/worktree.js +220 -42
- package/lib/core/runtime-graph.js +1 -1
- package/lib/doc-assertions.js +297 -0
- package/lib/existing-tdd-gate.js +253 -0
- package/lib/forge-context.js +1 -4
- package/lib/forge-issues.js +56 -32
- package/lib/git-defaults.js +56 -0
- package/lib/harness-capability-matrix.js +3 -3
- package/lib/hook-renderer.js +93 -4
- package/lib/insights.js +96 -80
- package/lib/kernel/backing-issue.js +14 -2
- package/lib/kernel/broker.js +16 -0
- package/lib/kernel/cli-broker-factory.js +12 -1
- package/lib/kernel/close-on-merge.js +154 -0
- package/lib/kernel/fs-class.js +42 -25
- package/lib/kernel/sqlite-driver.js +153 -29
- package/lib/lefthook-wiring.js +21 -1
- package/lib/memory/router.js +16 -1
- package/lib/memory-digest.js +47 -15
- package/lib/memory-recall-events.js +145 -0
- package/lib/memory-recall.js +71 -10
- package/lib/merge-rules.js +8 -4
- package/lib/npm-publish-workflow.js +272 -0
- package/lib/orientation.js +68 -43
- package/lib/plugin-catalog.js +14 -4
- package/lib/pr-bundle.js +5 -6
- package/lib/pr-monitor/journal.js +18 -2
- package/lib/pr-monitor/reconcile-executor.js +224 -41
- package/lib/pr-monitor/render-summary.js +196 -0
- package/lib/pr-monitor/shepherd-lease.js +10 -1
- package/lib/pr-monitor/watch-lifecycle.js +13 -1
- package/lib/pr-pull.js +33 -14
- package/lib/pr-shepherd.js +34 -8
- package/lib/preflight/gates.js +65 -18
- package/lib/preflight/runner.js +5 -0
- package/lib/project-memory.js +33 -1
- package/lib/protected-state-authority.js +305 -0
- package/lib/protected-state-surfaces.js +64 -44
- package/lib/release-readiness.js +51 -4
- package/lib/shell-utils.js +1 -1
- package/lib/skills-sync.js +6 -3
- package/lib/smart-merge.js +28 -4
- package/lib/symlink-utils.js +74 -26
- package/lib/upgrade-safety.js +39 -0
- package/lib/using-forge.js +19 -6
- package/package.json +6 -7
- package/scripts/doc-asserting-tests.js +158 -0
- package/scripts/lib/behavioral-eval-runner.js +310 -0
- package/scripts/lib/behavioral-eval-runtime.js +456 -0
- package/scripts/lib/eval-evidence.js +328 -0
- package/scripts/lib/eval-runner.js +81 -41
- package/scripts/lib/immutable-eval-corpus.js +309 -0
- package/scripts/lib/promotion-evidence-loader.js +94 -0
- package/scripts/lib/promotion-scorecard.js +314 -0
- package/scripts/npm-release-receipt.js +134 -0
- package/scripts/process-tree.js +761 -0
- package/scripts/protected-state-check.js +47 -22
- package/scripts/run-command-eval.js +29 -1
- package/scripts/sync-d20-audit.js +172 -0
- package/scripts/test-full-suite.js +249 -37
- package/scripts/test.js +176 -43
- package/skills/review/SKILL.md +4 -11
- package/skills/review/evals/scorecard.json +3 -3
- package/skills/rollback/SKILL.md +4 -11
- package/skills/rollback/evals/scorecard.json +3 -3
- package/skills/shepherd/SKILL.md +20 -14
- package/skills/shepherd/evals/scorecard.json +2 -2
- package/skills/ship/SKILL.md +4 -12
- package/skills/ship/evals/scorecard.json +3 -3
- package/skills/worktree/SKILL.md +6 -1
- package/skills/worktree/evals/scorecard.json +2 -2
- package/lib/beads-setup.js +0 -538
- package/lib/beads-sync-scaffold.js +0 -189
- package/lib/pat-setup.js +0 -207
- package/lib/pr-monitor/render-sticky.js +0 -206
- package/lib/pr-monitor/upsert-sticky.js +0 -169
- package/scripts/beads-context.sh +0 -577
- package/scripts/beads-migrate-to-dolt.sh +0 -7
- package/scripts/beads-upgrade-smoke.sh +0 -284
- package/scripts/lib/beads-migrate-to-dolt.mjs +0 -503
|
@@ -7,12 +7,11 @@
|
|
|
7
7
|
* debounce guard. This module is the thin, side-effecting dispatcher over them:
|
|
8
8
|
* it gathers the two state sets (GitHub via `gh`, kernel via the broker), runs the
|
|
9
9
|
* actions `reconcile()` emits (spawn/stop/reap watchers, upsert/retire kernel_pr
|
|
10
|
-
* rows), owns the SINGLETON DAEMON lease lifecycle, and provides the
|
|
11
|
-
* `fireAndForget()` trigger
|
|
10
|
+
* rows), owns the SINGLETON DAEMON lease lifecycle, and provides the approved-seam
|
|
11
|
+
* `fireAndForget()` trigger used by session-start, push, and ship.
|
|
12
12
|
*
|
|
13
13
|
* The NON-BLOCKING / ERROR-SWALLOWING contract is paramount: `fireAndForget()`
|
|
14
|
-
* MUST never throw and never affect the command that triggered it
|
|
15
|
-
* from a `finally` in the dispatch chokepoint). Every spawn is modeled on
|
|
14
|
+
* MUST never throw and never affect the command that triggered it. Every spawn is modeled on
|
|
16
15
|
* `watch-lifecycle.startPrWatcherDetached` (detached, `stdio:'ignore'`,
|
|
17
16
|
* `windowsHide:true`, `.unref()`, no-op `'error'` listener) so a failed launch
|
|
18
17
|
* degrades to "not started" rather than crashing.
|
|
@@ -39,10 +38,69 @@ const shepherdLease = require('./shepherd-lease');
|
|
|
39
38
|
const journal = require('./journal');
|
|
40
39
|
const { reconcile: defaultReconcile } = require('./reconcile');
|
|
41
40
|
const { tick: defaultTick } = require('./reconcile-tick');
|
|
42
|
-
const { startPrWatcherDetached,
|
|
41
|
+
const { startPrWatcherDetached, forgeBin } = require('./watch-lifecycle');
|
|
43
42
|
const brokerMod = require('../kernel/broker');
|
|
44
43
|
|
|
45
44
|
const { STALE_MS } = shepherdLease;
|
|
45
|
+
const CANONICAL_REPOSITORY = /^[A-Za-z0-9_.-]+\/[A-Za-z0-9_.-]+$/;
|
|
46
|
+
const GH_COMMAND_TIMEOUT_MS = 30_000;
|
|
47
|
+
const LINKAGE_FIELDS = ['issue_id', 'worktree_id', 'journal_ptr'];
|
|
48
|
+
|
|
49
|
+
function normalizeRepository(value) {
|
|
50
|
+
return typeof value === 'string' && CANONICAL_REPOSITORY.test(value.trim())
|
|
51
|
+
? value.trim().toLowerCase()
|
|
52
|
+
: null;
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
/** Resolve the GitHub identity used by merge binding, never a bare repo basename. */
|
|
56
|
+
function resolveCanonicalRepository(runGh) {
|
|
57
|
+
try {
|
|
58
|
+
const raw = runGh(['repo', 'view', '--json', 'owner,name']);
|
|
59
|
+
const parsed = typeof raw === 'string' ? JSON.parse(raw) : raw;
|
|
60
|
+
const owner = parsed && parsed.owner && parsed.owner.login;
|
|
61
|
+
const name = parsed && parsed.name;
|
|
62
|
+
if (typeof owner !== 'string' || owner.trim() === '' || typeof name !== 'string' || name.trim() === '') return null;
|
|
63
|
+
return normalizeRepository(`${owner}/${name}`);
|
|
64
|
+
} catch {
|
|
65
|
+
return null;
|
|
66
|
+
}
|
|
67
|
+
}
|
|
68
|
+
|
|
69
|
+
/**
|
|
70
|
+
* Persist the daemon's latest lifecycle outcome beside the lease. This status
|
|
71
|
+
* file is deliberately separate from `shepherd.reconcile`: that sentinel's
|
|
72
|
+
* mtime is the cold-trigger debounce clock and diagnostics must never move it.
|
|
73
|
+
*/
|
|
74
|
+
function writeDaemonDiagnostic(gitCommonDir, entry, opts = {}) {
|
|
75
|
+
try {
|
|
76
|
+
const file = path.join(gitCommonDir, 'forge', 'shepherd.status.json');
|
|
77
|
+
fs.mkdirSync(path.dirname(file), { recursive: true });
|
|
78
|
+
const now = opts.now || (() => Date.now());
|
|
79
|
+
const payload = {
|
|
80
|
+
...entry,
|
|
81
|
+
pid: opts.pid ?? process.pid,
|
|
82
|
+
at: new Date(now()).toISOString(),
|
|
83
|
+
};
|
|
84
|
+
fs.writeFileSync(file, `${JSON.stringify(payload)}\n`);
|
|
85
|
+
return true;
|
|
86
|
+
} catch {
|
|
87
|
+
return false;
|
|
88
|
+
}
|
|
89
|
+
}
|
|
90
|
+
|
|
91
|
+
/** Record through an injected test seam or the separate durable status file. */
|
|
92
|
+
function recordDaemonDiagnostic(opts, gitCommonDir, kind, detail) {
|
|
93
|
+
const entry = {
|
|
94
|
+
kind,
|
|
95
|
+
...(detail ? { detail: (detail && detail.message) || String(detail) } : {}),
|
|
96
|
+
};
|
|
97
|
+
try {
|
|
98
|
+
if (typeof opts.recordDiagnostic === 'function') opts.recordDiagnostic(entry);
|
|
99
|
+
else writeDaemonDiagnostic(gitCommonDir, entry, { now: opts.now });
|
|
100
|
+
} catch {
|
|
101
|
+
/* diagnostics must never affect a command or daemon lifecycle */
|
|
102
|
+
}
|
|
103
|
+
}
|
|
46
104
|
|
|
47
105
|
/** Normalize a lease watcher entry to the W-S4b `{pr, repo, pid, startedAt}` shape. */
|
|
48
106
|
function normalizeWatcher(entry) {
|
|
@@ -65,10 +123,10 @@ function claimPath(dir) {
|
|
|
65
123
|
* kill-time re-verification token — orphan reaping refuses to kill a pid unless
|
|
66
124
|
* this marker still equals the watcher entry's `startedAt`.
|
|
67
125
|
*/
|
|
68
|
-
function writeClaimMarker(projectRoot, repo, pr, startedAt) {
|
|
126
|
+
function writeClaimMarker(projectRoot, repo, pr, startedAt, gitCommonDir) {
|
|
69
127
|
if (repo == null || pr == null || startedAt == null) return;
|
|
70
128
|
try {
|
|
71
|
-
const dir = journal.journalDir({ root: projectRoot, repo, pr });
|
|
129
|
+
const dir = journal.journalDir({ root: projectRoot, gitCommonDir, repo, pr });
|
|
72
130
|
fs.writeFileSync(claimPath(dir), String(startedAt));
|
|
73
131
|
} catch {
|
|
74
132
|
/* best-effort — a missing marker just means the pid is treated as unverifiable (never reaped) */
|
|
@@ -76,10 +134,10 @@ function writeClaimMarker(projectRoot, repo, pr, startedAt) {
|
|
|
76
134
|
}
|
|
77
135
|
|
|
78
136
|
/** Read the start-time marker for `(repo, pr)`, or null when absent/unreadable. */
|
|
79
|
-
function readClaimMarker(projectRoot, repo, pr) {
|
|
137
|
+
function readClaimMarker(projectRoot, repo, pr, gitCommonDir) {
|
|
80
138
|
if (repo == null || pr == null) return null;
|
|
81
139
|
try {
|
|
82
|
-
const dir = journal.journalDir({ root: projectRoot, repo, pr });
|
|
140
|
+
const dir = journal.journalDir({ root: projectRoot, gitCommonDir, repo, pr });
|
|
83
141
|
return fs.readFileSync(claimPath(dir), 'utf8').trim();
|
|
84
142
|
} catch {
|
|
85
143
|
return null;
|
|
@@ -91,10 +149,10 @@ function readClaimMarker(projectRoot, repo, pr) {
|
|
|
91
149
|
* a future PID reuse can't match a STALE marker and get treated as the live watcher.
|
|
92
150
|
* Best-effort; a missing marker is fine (an unverifiable pid is never reaped anyway).
|
|
93
151
|
*/
|
|
94
|
-
function removeClaimMarker(projectRoot, repo, pr) {
|
|
152
|
+
function removeClaimMarker(projectRoot, repo, pr, gitCommonDir) {
|
|
95
153
|
if (repo == null || pr == null) return;
|
|
96
154
|
try {
|
|
97
|
-
const dir = journal.journalDir({ root: projectRoot, repo, pr });
|
|
155
|
+
const dir = journal.journalDir({ root: projectRoot, gitCommonDir, repo, pr });
|
|
98
156
|
fs.rmSync(claimPath(dir), { force: true });
|
|
99
157
|
} catch {
|
|
100
158
|
/* best-effort marker cleanup */
|
|
@@ -110,14 +168,17 @@ function removeClaimMarker(projectRoot, repo, pr) {
|
|
|
110
168
|
*/
|
|
111
169
|
async function gatherDesired(gitCommonDir, opts = {}) {
|
|
112
170
|
const runGh = opts.runGh || ((args) => require('node:child_process').execFileSync('gh', args, {
|
|
113
|
-
encoding: 'utf8', timeout:
|
|
171
|
+
cwd: opts.projectRoot || process.cwd(), encoding: 'utf8', timeout: GH_COMMAND_TIMEOUT_MS, windowsHide: true,
|
|
114
172
|
}));
|
|
115
173
|
// The broker is a live kernel handle from createLocalBroker (listOpenPrs/upsertPr/
|
|
116
174
|
// retirePr are INSTANCE methods) — the daemon threads one in. Never fall back to the
|
|
117
175
|
// broker MODULE namespace: those methods don't exist there and every call would
|
|
118
176
|
// silently no-op behind the catch (this exact bug shipped once — keep it gone).
|
|
119
177
|
const broker = opts.broker || null;
|
|
120
|
-
|
|
178
|
+
// Explicit repo injection remains a test/programmatic seam. Production resolves the
|
|
179
|
+
// owner/name pair from GitHub; a bare basename is never inferred from a failed lookup.
|
|
180
|
+
const suppliedRepo = typeof opts.repo === 'string' && opts.repo.trim() ? opts.repo.trim() : null;
|
|
181
|
+
const repo = suppliedRepo || resolveCanonicalRepository(runGh);
|
|
121
182
|
|
|
122
183
|
let ghPrs = [];
|
|
123
184
|
// `listingOk` distinguishes "GitHub says zero open PRs" from "the gh call failed"
|
|
@@ -135,22 +196,85 @@ async function gatherDesired(gitCommonDir, opts = {}) {
|
|
|
135
196
|
listingOk = false;
|
|
136
197
|
}
|
|
137
198
|
|
|
199
|
+
if (!repo) return { openPrs: [], gitCommonDir, listingOk: false, repositoryOk: false };
|
|
200
|
+
|
|
138
201
|
let prRows = [];
|
|
139
202
|
try {
|
|
140
203
|
if (broker) prRows = await broker.listOpenPrs(gitCommonDir);
|
|
141
204
|
} catch {
|
|
142
205
|
/* kernel unavailable → treat as no linkage; the GitHub-driven desired set still stands */
|
|
143
206
|
}
|
|
144
|
-
// Key by (repo, number)
|
|
145
|
-
//
|
|
146
|
-
//
|
|
147
|
-
|
|
148
|
-
|
|
207
|
+
// Key by canonical (repo, number). A single bare-name row is accepted
|
|
208
|
+
// only as a one-way compatibility bridge; it is rewritten under the canonical key
|
|
209
|
+
// and retired by the same reconcile pass. The common-dir natural key plus an exact
|
|
210
|
+
// basename/number match preserves links across force-pushes; cross-repository,
|
|
211
|
+
// ambiguous, and other malformed rows never bind.
|
|
212
|
+
const canonicalRepo = normalizeRepository(repo);
|
|
213
|
+
const legacyRepo = canonicalRepo ? canonicalRepo.slice(canonicalRepo.lastIndexOf('/') + 1) : null;
|
|
214
|
+
const validPrNumber = (value) => (Number.isSafeInteger(value) && value > 0)
|
|
215
|
+
|| (typeof value === 'string' && /^[1-9][0-9]*$/.test(value));
|
|
216
|
+
const rows = Array.isArray(prRows) ? prRows : [];
|
|
217
|
+
if (canonicalRepo) {
|
|
218
|
+
const counts = new Map();
|
|
219
|
+
const conflicting = rows.some((row) => {
|
|
220
|
+
if (!row || !validPrNumber(row.number)) return true;
|
|
221
|
+
const rowRepo = normalizeRepository(row.repo);
|
|
222
|
+
const canonical = rowRepo === canonicalRepo;
|
|
223
|
+
const legacy = typeof row.repo === 'string' && row.repo.trim().toLowerCase() === legacyRepo;
|
|
224
|
+
if (!canonical && !legacy) return true;
|
|
225
|
+
const number = String(row.number);
|
|
226
|
+
const count = counts.get(number) || { canonical: 0, legacy: 0 };
|
|
227
|
+
if (canonical) count.canonical += 1;
|
|
228
|
+
else count.legacy += 1;
|
|
229
|
+
counts.set(number, count);
|
|
230
|
+
return count.canonical > 1 || count.legacy > 1;
|
|
231
|
+
});
|
|
232
|
+
if (conflicting) return { openPrs: [], gitCommonDir, listingOk: false, repositoryOk: false };
|
|
233
|
+
}
|
|
234
|
+
const exactRows = new Map();
|
|
235
|
+
const legacyRows = new Map();
|
|
236
|
+
const add = (map, number, row) => {
|
|
237
|
+
if (!validPrNumber(number)) return;
|
|
238
|
+
const list = map.get(String(number)) || [];
|
|
239
|
+
list.push(row);
|
|
240
|
+
map.set(String(number), list);
|
|
241
|
+
};
|
|
242
|
+
for (const row of rows) {
|
|
243
|
+
if (canonicalRepo && normalizeRepository(row && row.repo) === canonicalRepo) add(exactRows, row.number, row);
|
|
244
|
+
else if (legacyRepo && row && typeof row.repo === 'string' && row.repo.trim().toLowerCase() === legacyRepo) add(legacyRows, row.number, row);
|
|
245
|
+
else if (!canonicalRepo && row && row.repo === repo) add(exactRows, row.number, row);
|
|
246
|
+
}
|
|
247
|
+
const coalesceLinkage = (canonical, legacy) => {
|
|
248
|
+
if (!canonical || !legacy) return canonical || legacy || null;
|
|
249
|
+
const merged = { ...canonical };
|
|
250
|
+
for (const field of LINKAGE_FIELDS) {
|
|
251
|
+
const canonicalValue = canonical[field] ?? null;
|
|
252
|
+
const legacyValue = legacy[field] ?? null;
|
|
253
|
+
if (canonicalValue != null && legacyValue != null && canonicalValue !== legacyValue) return null;
|
|
254
|
+
if (canonicalValue == null && legacyValue != null) merged[field] = legacyValue;
|
|
255
|
+
}
|
|
256
|
+
return merged;
|
|
257
|
+
};
|
|
258
|
+
const mergedRows = new Map();
|
|
259
|
+
for (const number of new Set([...exactRows.keys(), ...legacyRows.keys()])) {
|
|
260
|
+
const exact = exactRows.get(number) || [];
|
|
261
|
+
const legacy = legacyRows.get(number) || [];
|
|
262
|
+
const merged = coalesceLinkage(exact[0], legacy[0]);
|
|
263
|
+
if (exact.length > 0 && legacy.length > 0 && !merged) {
|
|
264
|
+
return { openPrs: [], gitCommonDir, listingOk: false, repositoryOk: false };
|
|
265
|
+
}
|
|
266
|
+
if (merged) mergedRows.set(number, merged);
|
|
267
|
+
}
|
|
268
|
+
const rowForPr = (p) => {
|
|
269
|
+
if (canonicalRepo) return mergedRows.get(String(p.number)) || null;
|
|
270
|
+
const exact = exactRows.get(String(p.number)) || [];
|
|
271
|
+
return exact.length === 1 ? exact[0] : null;
|
|
272
|
+
};
|
|
149
273
|
|
|
150
274
|
const openPrs = ghPrs.map((p) => {
|
|
151
|
-
const row =
|
|
275
|
+
const row = rowForPr(p);
|
|
152
276
|
return {
|
|
153
|
-
repo,
|
|
277
|
+
repo: canonicalRepo || repo,
|
|
154
278
|
number: p.number,
|
|
155
279
|
branch: p.headRefName ?? null,
|
|
156
280
|
headSha: p.headRefOid ?? null,
|
|
@@ -169,7 +293,8 @@ async function gatherDesired(gitCommonDir, opts = {}) {
|
|
|
169
293
|
async function gatherObserved(gitCommonDir, lock, opts = {}) {
|
|
170
294
|
const broker = opts.broker || null; // live kernel handle threaded by the daemon; never the module namespace
|
|
171
295
|
const isAlive = opts.isAlive || shepherdLease.pidAlive;
|
|
172
|
-
const readClaim = opts.readClaim
|
|
296
|
+
const readClaim = opts.readClaim
|
|
297
|
+
|| ((repo, pr) => readClaimMarker(opts.projectRoot, repo, pr, gitCommonDir));
|
|
173
298
|
const now = (opts.now || (() => Date.now()))();
|
|
174
299
|
|
|
175
300
|
let prRows = [];
|
|
@@ -206,7 +331,8 @@ function verifiedKill(entry, ctx) {
|
|
|
206
331
|
if (!entry || entry.pid == null || entry.startedAt == null) return false;
|
|
207
332
|
const isAlive = ctx.isAlive || shepherdLease.pidAlive;
|
|
208
333
|
if (!isAlive(entry.pid)) return false;
|
|
209
|
-
const readClaim = ctx.readClaim
|
|
334
|
+
const readClaim = ctx.readClaim
|
|
335
|
+
|| ((e) => readClaimMarker(ctx.projectRoot, e.repo, e.pr, ctx.gitCommonDir));
|
|
210
336
|
const claim = readClaim(entry);
|
|
211
337
|
if (claim == null || String(claim) !== String(entry.startedAt)) return false;
|
|
212
338
|
try {
|
|
@@ -228,7 +354,9 @@ const ACTION_HANDLERS = {
|
|
|
228
354
|
startWatcher(action, s) {
|
|
229
355
|
const startedAt = new Date(s.now()).toISOString();
|
|
230
356
|
const repo = action.pr.repo ?? s.repo ?? null;
|
|
231
|
-
const res = s.spawnWatcher({
|
|
357
|
+
const res = s.spawnWatcher({
|
|
358
|
+
prNumber: action.pr.number, cwd: s.projectRoot, gitCommonDir: s.gitCommonDir,
|
|
359
|
+
});
|
|
232
360
|
const pid = res && res.pid != null ? res.pid : null;
|
|
233
361
|
// A pid-less result means the watcher is already running (ship/push/adopt started it,
|
|
234
362
|
// startPrWatcherDetached → {started:false, reason:'already-running'}) or the spawn
|
|
@@ -254,9 +382,12 @@ const ACTION_HANDLERS = {
|
|
|
254
382
|
},
|
|
255
383
|
async upsertPrRow(action, s) {
|
|
256
384
|
try {
|
|
257
|
-
if (s.broker)
|
|
385
|
+
if (!s.broker) return true;
|
|
386
|
+
const result = await s.broker.upsertPr(action.row);
|
|
387
|
+
return result !== false && !(result && result.ok === false);
|
|
258
388
|
} catch {
|
|
259
389
|
/* derived reconcile state — a failed upsert retries on the next converge */
|
|
390
|
+
return false;
|
|
260
391
|
}
|
|
261
392
|
},
|
|
262
393
|
async retire(action, s) {
|
|
@@ -272,7 +403,8 @@ const ACTION_HANDLERS = {
|
|
|
272
403
|
};
|
|
273
404
|
|
|
274
405
|
/**
|
|
275
|
-
* Dispatch a reconcile action set
|
|
406
|
+
* Dispatch a reconcile action set in order. A failed kernel upsert stops the pass so
|
|
407
|
+
* dependent retire actions retry on the next converge. Returns the updated
|
|
276
408
|
* watcher entry list (`{pr,repo,pid,startedAt}[]`) for the caller to publish via
|
|
277
409
|
* `updateWatchers`. `ctx.watchers` seeds the current set (from observed state).
|
|
278
410
|
*/
|
|
@@ -280,8 +412,10 @@ async function execute(actions, ctx = {}) {
|
|
|
280
412
|
const s = {
|
|
281
413
|
broker: ctx.broker || null, // live kernel handle threaded by the daemon; never the module namespace
|
|
282
414
|
spawnWatcher: ctx.spawnWatcher || startPrWatcherDetached,
|
|
283
|
-
writeClaim: ctx.writeClaim
|
|
284
|
-
|
|
415
|
+
writeClaim: ctx.writeClaim
|
|
416
|
+
|| ((e) => writeClaimMarker(ctx.projectRoot, e.repo, e.pr, e.startedAt, ctx.gitCommonDir)),
|
|
417
|
+
removeClaim: ctx.removeClaim
|
|
418
|
+
|| ((e) => removeClaimMarker(ctx.projectRoot, e.repo, e.pr, ctx.gitCommonDir)),
|
|
285
419
|
now: ctx.now || (() => Date.now()),
|
|
286
420
|
repo: ctx.repo,
|
|
287
421
|
projectRoot: ctx.projectRoot,
|
|
@@ -292,7 +426,7 @@ async function execute(actions, ctx = {}) {
|
|
|
292
426
|
|
|
293
427
|
for (const action of (Array.isArray(actions) ? actions : [])) {
|
|
294
428
|
const handler = ACTION_HANDLERS[action && action.type];
|
|
295
|
-
if (handler
|
|
429
|
+
if (handler && (await handler(action, s)) === false) break;
|
|
296
430
|
}
|
|
297
431
|
return s.watchers;
|
|
298
432
|
}
|
|
@@ -367,6 +501,10 @@ async function runDaemon(projectRoot, opts = {}) {
|
|
|
367
501
|
const release = opts.release || shepherdLease.release;
|
|
368
502
|
const converge = opts.convergeOnce || convergeOnce;
|
|
369
503
|
const now = opts.now || (() => Date.now());
|
|
504
|
+
// Tests that replace acquisition own the matching ownership seam as well.
|
|
505
|
+
// Production always verifies the real shared lock by exact pid+token.
|
|
506
|
+
const ownsLease = opts.ownsLease
|
|
507
|
+
|| (opts.acquire ? (() => true) : shepherdLease.owns);
|
|
370
508
|
// Injectable exit so a lifecycle test can assert the daemon actually exits
|
|
371
509
|
// (finding 4). `opts.exit === false` keeps the process alive (legacy test mode).
|
|
372
510
|
const exit = typeof opts.exit === 'function'
|
|
@@ -375,6 +513,7 @@ async function runDaemon(projectRoot, opts = {}) {
|
|
|
375
513
|
|
|
376
514
|
const res = acquire(projectRoot, { gitCommonDir });
|
|
377
515
|
if (!res.ok) {
|
|
516
|
+
exit(0);
|
|
378
517
|
// A live, fresh foreign daemon owns this repo — exit immediately, spawn nothing.
|
|
379
518
|
return { ok: false, reason: 'foreign-lease' };
|
|
380
519
|
}
|
|
@@ -413,7 +552,10 @@ async function runDaemon(projectRoot, opts = {}) {
|
|
|
413
552
|
|
|
414
553
|
if (opts.once) {
|
|
415
554
|
const conv = await converge(projectRoot, convergeArgs);
|
|
416
|
-
if (conv.desiredCount === 0)
|
|
555
|
+
if (conv.desiredCount === 0) {
|
|
556
|
+
recordDaemonDiagnostic(opts, gitCommonDir, 'retired-no-open-prs');
|
|
557
|
+
await retire();
|
|
558
|
+
}
|
|
417
559
|
return { ok: true, token, ...conv };
|
|
418
560
|
}
|
|
419
561
|
|
|
@@ -423,10 +565,30 @@ async function runDaemon(projectRoot, opts = {}) {
|
|
|
423
565
|
let lastWatchers = []; // finding 2: thread the live watcher set across passes
|
|
424
566
|
let timer = null;
|
|
425
567
|
|
|
568
|
+
const retireForLeaseLoss = async () => {
|
|
569
|
+
stopped = true;
|
|
570
|
+
if (timer) clearInterval(timer);
|
|
571
|
+
recordDaemonDiagnostic(opts, gitCommonDir, 'lease-lost');
|
|
572
|
+
await retire();
|
|
573
|
+
exit(0);
|
|
574
|
+
};
|
|
575
|
+
|
|
576
|
+
const stillOwnsLease = () => {
|
|
577
|
+
try {
|
|
578
|
+
return ownsLease(projectRoot, { gitCommonDir, token });
|
|
579
|
+
} catch {
|
|
580
|
+
return false;
|
|
581
|
+
}
|
|
582
|
+
};
|
|
583
|
+
|
|
426
584
|
const runPass = async () => {
|
|
427
585
|
// A tick that fires while the previous pass is still in flight (converge slower
|
|
428
586
|
// than intervalMs) returns immediately, so passes never race on start/stop/reap.
|
|
429
587
|
if (stopped || inFlight) return;
|
|
588
|
+
if (!stillOwnsLease()) {
|
|
589
|
+
await retireForLeaseLoss();
|
|
590
|
+
return;
|
|
591
|
+
}
|
|
430
592
|
inFlight = true;
|
|
431
593
|
try {
|
|
432
594
|
// Thread the live watcher set + a fresh heartbeat stamp so gatherObserved
|
|
@@ -434,22 +596,26 @@ async function runDaemon(projectRoot, opts = {}) {
|
|
|
434
596
|
// saw lease:null every tick and re-started a watcher for every PR forever.
|
|
435
597
|
const passLock = { watchers: lastWatchers, heartbeatAt: new Date(now()).toISOString() };
|
|
436
598
|
const conv = await converge(projectRoot, { ...convergeArgs, lock: passLock });
|
|
599
|
+
if (!stillOwnsLease()) {
|
|
600
|
+
await retireForLeaseLoss();
|
|
601
|
+
return;
|
|
602
|
+
}
|
|
437
603
|
if (conv && Array.isArray(conv.watchers)) lastWatchers = conv.watchers;
|
|
438
604
|
// Superseded: a newer daemon reclaimed our stale lease. Stop and exit — retire()
|
|
439
605
|
// won't touch the foreign lock (release is token-guarded), so the new owner is
|
|
440
606
|
// left intact; we just stop spawning/reaping behind it.
|
|
441
607
|
if (conv && conv.leaseLost) {
|
|
442
|
-
|
|
443
|
-
if (timer) clearInterval(timer);
|
|
444
|
-
await retire();
|
|
445
|
-
exit(0);
|
|
608
|
+
await retireForLeaseLoss();
|
|
446
609
|
} else if (conv && conv.desiredCount === 0) {
|
|
447
610
|
stopped = true;
|
|
448
611
|
if (timer) clearInterval(timer);
|
|
612
|
+
recordDaemonDiagnostic(opts, gitCommonDir, 'retired-no-open-prs');
|
|
449
613
|
await retire();
|
|
450
614
|
exit(0);
|
|
451
615
|
}
|
|
452
|
-
} catch {
|
|
616
|
+
} catch (error) {
|
|
617
|
+
recordDaemonDiagnostic(opts, gitCommonDir, 'converge-failed', error);
|
|
618
|
+
if (!stillOwnsLease()) await retireForLeaseLoss();
|
|
453
619
|
/* a bad converge pass never crashes the daemon — the next tick retries */
|
|
454
620
|
} finally {
|
|
455
621
|
inFlight = false;
|
|
@@ -478,9 +644,12 @@ async function runDaemon(projectRoot, opts = {}) {
|
|
|
478
644
|
*/
|
|
479
645
|
function launchDaemon(ctx = {}) {
|
|
480
646
|
const harness = ctx.harness || {};
|
|
647
|
+
const commonRoot = path.basename(ctx.gitCommonDir || '').toLowerCase() === '.git'
|
|
648
|
+
? path.dirname(ctx.gitCommonDir)
|
|
649
|
+
: ctx.projectRoot;
|
|
481
650
|
if (harness.hasBgShell && typeof harness.runBgShell === 'function') {
|
|
482
651
|
try {
|
|
483
|
-
harness.runBgShell([forgeBin(), 'shepherd', 'daemon']);
|
|
652
|
+
harness.runBgShell([process.execPath, forgeBin(), 'shepherd', 'daemon'], { cwd: commonRoot });
|
|
484
653
|
return { launched: true, via: 'bg-shell' };
|
|
485
654
|
} catch {
|
|
486
655
|
/* fall through to the detached fail-safe */
|
|
@@ -491,12 +660,17 @@ function launchDaemon(ctx = {}) {
|
|
|
491
660
|
const child = spawnFn(
|
|
492
661
|
process.execPath,
|
|
493
662
|
[forgeBin(), 'shepherd', 'daemon'],
|
|
494
|
-
{ cwd:
|
|
663
|
+
{ cwd: commonRoot, detached: true, stdio: 'ignore', windowsHide: true },
|
|
495
664
|
);
|
|
496
|
-
if (child && typeof child.on === 'function')
|
|
665
|
+
if (child && typeof child.on === 'function') {
|
|
666
|
+
child.on('error', (error) => {
|
|
667
|
+
recordDaemonDiagnostic(ctx, ctx.gitCommonDir, 'launch-failed', error);
|
|
668
|
+
});
|
|
669
|
+
}
|
|
497
670
|
if (child && typeof child.unref === 'function') child.unref();
|
|
498
671
|
return { launched: true, via: 'detached', pid: child && child.pid != null ? child.pid : null };
|
|
499
|
-
} catch {
|
|
672
|
+
} catch (error) {
|
|
673
|
+
recordDaemonDiagnostic(ctx, ctx.gitCommonDir, 'launch-failed', error);
|
|
500
674
|
return { launched: false };
|
|
501
675
|
}
|
|
502
676
|
}
|
|
@@ -570,7 +744,7 @@ function emptyEnum(gitCommonDir) {
|
|
|
570
744
|
}
|
|
571
745
|
|
|
572
746
|
/**
|
|
573
|
-
* The
|
|
747
|
+
* The session-start / successful-push / successful-ship trigger. Runs the `tick()` debounce; the hot
|
|
574
748
|
* path (a fresh daemon lease) short-circuits in-process with a single lock read
|
|
575
749
|
* and no spawn. Only on the cold (G3) path does it ARBITRATE via the O_EXCL lease:
|
|
576
750
|
* the acquire-winner launches the singleton daemon (which does the real
|
|
@@ -586,16 +760,24 @@ function emptyEnum(gitCommonDir) {
|
|
|
586
760
|
*/
|
|
587
761
|
function fireAndForget(ctx = {}) {
|
|
588
762
|
try {
|
|
763
|
+
const env = ctx.env || process.env;
|
|
589
764
|
// Operator kill-switch (agent-agnostic): a set FORGE_SHEPHERD_DISABLE turns the
|
|
590
765
|
// autonomous trigger fully inert — no lease, no enumeration, no daemon spawn.
|
|
591
|
-
if (
|
|
766
|
+
if (
|
|
767
|
+
env.FORGE_SHEPHERD_DISABLE
|
|
768
|
+
|| env.NODE_ENV === 'test'
|
|
769
|
+
|| env.BUN_ENV === 'test'
|
|
770
|
+
|| env.CI
|
|
771
|
+
|| env.GITHUB_ACTIONS
|
|
772
|
+
|| env.GITLAB_CI
|
|
773
|
+
) return;
|
|
592
774
|
const projectRoot = ctx.projectRoot;
|
|
593
775
|
if (!projectRoot) return;
|
|
594
776
|
// A dry-run must have ZERO side effects, and the trigger must CREATE NOTHING in an
|
|
595
777
|
// uninitialized / setup-or-init TARGET repo (no-lazy-create invariant, same as prime).
|
|
596
778
|
// Both guards run BEFORE any git/lock touch so `setup --dry-run` / `init` stay
|
|
597
779
|
// side-effect- AND output-clean (kernelInitialized is silent). Checked here, not the
|
|
598
|
-
// caller, so every trigger site
|
|
780
|
+
// caller, so every approved trigger site is covered uniformly.
|
|
599
781
|
if (ctx.dryRun) return;
|
|
600
782
|
if (!(ctx.kernelInitialized || kernelInitialized)(projectRoot)) return;
|
|
601
783
|
// Config kill-switch (same gate ship/push/adopt honor): a maintainer who ran
|
|
@@ -654,6 +836,7 @@ module.exports = {
|
|
|
654
836
|
convergeOnce,
|
|
655
837
|
runDaemon,
|
|
656
838
|
launchDaemon,
|
|
839
|
+
writeDaemonDiagnostic,
|
|
657
840
|
defaultBuildBroker,
|
|
658
841
|
fireAndForget,
|
|
659
842
|
};
|
|
@@ -0,0 +1,196 @@
|
|
|
1
|
+
'use strict';
|
|
2
|
+
|
|
3
|
+
/**
|
|
4
|
+
* PR-monitor Actions-summary renderer. It turns one read-only
|
|
5
|
+
* `gatherPrBundle` result (lib/pr-bundle.js) into deterministic Markdown for
|
|
6
|
+
* the workflow's `GITHUB_STEP_SUMMARY` surface.
|
|
7
|
+
*
|
|
8
|
+
* This is presentation only: it displays the canonical verdict supplied by
|
|
9
|
+
* `forge shepherd <pr> --pull --json`, lists unresolved review threads and CI
|
|
10
|
+
* state, and never merges or resolves anything. The `pr-verdict:*` label is a
|
|
11
|
+
* cheap visibility projection of that same verdict, not merge authority.
|
|
12
|
+
*
|
|
13
|
+
* @module pr-monitor/render-summary
|
|
14
|
+
*/
|
|
15
|
+
|
|
16
|
+
/**
|
|
17
|
+
* Presentation-only headline for each canonical merge verdict (lib/pr-pull.js).
|
|
18
|
+
* The verdict is computed once by pr-pull and passed in; this map only decides
|
|
19
|
+
* how it is displayed, so there is no second verdict ladder to drift.
|
|
20
|
+
*/
|
|
21
|
+
const VERDICT_HEADLINE = {
|
|
22
|
+
UNKNOWN: '⚪ **Verdict: `unknown`** — a signal was unreadable; state unconfirmed (fail-closed).',
|
|
23
|
+
'BLOCKED-CONFLICT': '🔀 **Verdict: `blocked-conflict`** — branch conflicts with base; rebase/merge and resolve.',
|
|
24
|
+
BEHIND: '⬇️ **Verdict: `behind`** — branch is behind base; update/rebase (protection requires up-to-date).',
|
|
25
|
+
'BLOCKED-CHECKS': '🔴 **Verdict: `blocked-checks`** — a required check is failing/missing; fix it.',
|
|
26
|
+
'BLOCKED-THREADS': '🟠 **Verdict: `blocked-threads`** — unresolved review threads need addressing.',
|
|
27
|
+
'REVIEW-PENDING': '🟡 **Verdict: `review-pending`** — awaiting review / settle window; not ready yet.',
|
|
28
|
+
'CLEAN-MERGEABLE': '🟢 **Verdict: `clean-mergeable`** — green + zero unresolved threads; ready for a human to merge.',
|
|
29
|
+
};
|
|
30
|
+
|
|
31
|
+
/** Render the one-line headline for a canonical verdict, failing closed. */
|
|
32
|
+
function verdictHeadline(verdict) {
|
|
33
|
+
return VERDICT_HEADLINE[String(verdict || '').toUpperCase()] || VERDICT_HEADLINE.UNKNOWN;
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
/**
|
|
37
|
+
* Render untrusted text as a Markdown code span without allowing its backticks
|
|
38
|
+
* or line breaks to change the surrounding summary structure.
|
|
39
|
+
*
|
|
40
|
+
* CommonMark permits a code span to use more than one backtick. Pick a fence
|
|
41
|
+
* longer than every run in the value, and flatten CR/LF so the summary stays
|
|
42
|
+
* one line per diagnostic.
|
|
43
|
+
*/
|
|
44
|
+
function mdCode(value) {
|
|
45
|
+
const text = String(value ?? '').replace(/[\r\n]+/g, ' ');
|
|
46
|
+
let longestRun = 0;
|
|
47
|
+
let currentRun = 0;
|
|
48
|
+
for (const character of text) {
|
|
49
|
+
if (character === '`') {
|
|
50
|
+
currentRun += 1;
|
|
51
|
+
longestRun = Math.max(longestRun, currentRun);
|
|
52
|
+
} else {
|
|
53
|
+
currentRun = 0;
|
|
54
|
+
}
|
|
55
|
+
}
|
|
56
|
+
const fence = '`'.repeat(longestRun + 1);
|
|
57
|
+
const content = text.startsWith('`') || text.endsWith('`') ? ` ${text} ` : text;
|
|
58
|
+
return `${fence}${content}${fence}`;
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
/** Cap threads listed per author so a noisy PR cannot produce an enormous summary. */
|
|
62
|
+
const MAX_THREADS_PER_AUTHOR = 8;
|
|
63
|
+
|
|
64
|
+
/** Group unresolved review-thread comments by author in deterministic order. */
|
|
65
|
+
function groupByAuthor(comments) {
|
|
66
|
+
const byAuthor = new Map();
|
|
67
|
+
for (const comment of (Array.isArray(comments) ? comments : [])) {
|
|
68
|
+
const author = String(comment.author || 'unknown');
|
|
69
|
+
if (!byAuthor.has(author)) byAuthor.set(author, []);
|
|
70
|
+
byAuthor.get(author).push(comment);
|
|
71
|
+
}
|
|
72
|
+
return [...byAuthor.entries()].sort(
|
|
73
|
+
(a, b) => (b[1].length - a[1].length) || a[0].localeCompare(b[0]),
|
|
74
|
+
);
|
|
75
|
+
}
|
|
76
|
+
|
|
77
|
+
/** One-line locator for a thread: `path:line` when known, else its id. */
|
|
78
|
+
function threadLocator(thread) {
|
|
79
|
+
if (thread.path) return thread.line != null ? `${thread.path}:${thread.line}` : thread.path;
|
|
80
|
+
return thread.threadId || '(thread)';
|
|
81
|
+
}
|
|
82
|
+
|
|
83
|
+
/** Render unresolved review threads, preserving fail-closed availability. */
|
|
84
|
+
function renderThreads(bundle, lines) {
|
|
85
|
+
// Empty arrays are ambiguous when the adapter could not read comments. Only
|
|
86
|
+
// an explicit available:true read may report zero unresolved threads.
|
|
87
|
+
if (bundle.unresolvedCommentsAvailable !== true) {
|
|
88
|
+
const why = bundle.unresolvedCommentsError || 'thread read unavailable (capability absent)';
|
|
89
|
+
lines.push('### Review threads');
|
|
90
|
+
lines.push(`⚠️ Review threads were **unreadable** this pass (${mdCode(why)}) — not treated as zero. Re-run once the read recovers.`);
|
|
91
|
+
lines.push('');
|
|
92
|
+
return;
|
|
93
|
+
}
|
|
94
|
+
|
|
95
|
+
const comments = Array.isArray(bundle.unresolvedComments) ? bundle.unresolvedComments : [];
|
|
96
|
+
if (comments.length === 0) {
|
|
97
|
+
lines.push('### Review threads');
|
|
98
|
+
lines.push('✅ No unresolved review threads.');
|
|
99
|
+
lines.push('');
|
|
100
|
+
return;
|
|
101
|
+
}
|
|
102
|
+
|
|
103
|
+
const groups = groupByAuthor(comments);
|
|
104
|
+
lines.push(`### Unresolved review threads (${comments.length})`);
|
|
105
|
+
lines.push('');
|
|
106
|
+
for (const [author, threads] of groups) {
|
|
107
|
+
lines.push(`- **${author}** — ${threads.length}`);
|
|
108
|
+
for (const thread of threads.slice(0, MAX_THREADS_PER_AUTHOR)) {
|
|
109
|
+
lines.push(` - ${mdCode(threadLocator(thread))}`);
|
|
110
|
+
}
|
|
111
|
+
if (threads.length > MAX_THREADS_PER_AUTHOR) {
|
|
112
|
+
lines.push(` - …and ${threads.length - MAX_THREADS_PER_AUTHOR} more`);
|
|
113
|
+
}
|
|
114
|
+
}
|
|
115
|
+
lines.push('');
|
|
116
|
+
}
|
|
117
|
+
|
|
118
|
+
/** Render failing and pending checks, preserving fail-closed availability. */
|
|
119
|
+
function renderChecks(bundle, lines) {
|
|
120
|
+
// Only ciAvailable:true permits a clean-check claim. Missing or false means
|
|
121
|
+
// the read did not complete, so empty arrays must not look green.
|
|
122
|
+
if (bundle.ciAvailable !== true) {
|
|
123
|
+
lines.push('### Checks');
|
|
124
|
+
lines.push('⚠️ Checks were **unreadable** this pass — not treated as green. Re-run once the read recovers.');
|
|
125
|
+
lines.push('');
|
|
126
|
+
return;
|
|
127
|
+
}
|
|
128
|
+
|
|
129
|
+
const ci = bundle.ci || {};
|
|
130
|
+
const failing = Array.isArray(ci.failing) ? ci.failing : [];
|
|
131
|
+
const pending = Array.isArray(ci.pending) ? ci.pending : [];
|
|
132
|
+
lines.push('### Checks');
|
|
133
|
+
if (failing.length === 0 && pending.length === 0) {
|
|
134
|
+
lines.push('✅ No failing or pending checks.');
|
|
135
|
+
} else {
|
|
136
|
+
if (failing.length > 0) {
|
|
137
|
+
lines.push(`- ❌ **Failing (${failing.length}):** ${failing.map((check) => mdCode(check.name || '?')).join(', ')}`);
|
|
138
|
+
}
|
|
139
|
+
if (pending.length > 0) {
|
|
140
|
+
lines.push(`- ⏳ **Pending (${pending.length}):** ${pending.map((check) => mdCode(check.name || '?')).join(', ')}`);
|
|
141
|
+
}
|
|
142
|
+
}
|
|
143
|
+
lines.push('');
|
|
144
|
+
}
|
|
145
|
+
|
|
146
|
+
/**
|
|
147
|
+
* Render the PR monitor's Actions job summary.
|
|
148
|
+
*
|
|
149
|
+
* @param {object} bundle - a `gatherPrBundle` result (lib/pr-bundle.js)
|
|
150
|
+
* @param {object} [opts]
|
|
151
|
+
* @param {Date} [opts.now] - injected clock for deterministic output
|
|
152
|
+
* @param {string} [opts.verdict] - canonical `--pull` verdict
|
|
153
|
+
* @param {string[]} [opts.unreadable] - unreadable signal names from `--pull`
|
|
154
|
+
* @param {string|number} [opts.pr] - PR number for the CLI diagnostics hint
|
|
155
|
+
* @returns {{ body: string }}
|
|
156
|
+
*/
|
|
157
|
+
function renderSummary(bundle = {}, opts = {}) {
|
|
158
|
+
const now = opts.now instanceof Date ? opts.now : new Date();
|
|
159
|
+
const lines = ['## 🔭 Forge PR Monitor', ''];
|
|
160
|
+
const verdict = String(opts.verdict || '').toUpperCase();
|
|
161
|
+
const isUnknown = verdict === 'UNKNOWN' || !VERDICT_HEADLINE[verdict];
|
|
162
|
+
const unreadable = Array.isArray(opts.unreadable) ? opts.unreadable.filter(Boolean) : [];
|
|
163
|
+
|
|
164
|
+
lines.push(verdictHeadline(opts.verdict));
|
|
165
|
+
if (isUnknown && unreadable.length > 0) {
|
|
166
|
+
lines.push('');
|
|
167
|
+
lines.push(`> Unreadable signal(s): ${unreadable.map((signal) => mdCode(signal)).join(', ')}.`);
|
|
168
|
+
}
|
|
169
|
+
lines.push('');
|
|
170
|
+
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._');
|
|
171
|
+
lines.push('');
|
|
172
|
+
|
|
173
|
+
renderThreads(bundle, lines);
|
|
174
|
+
renderChecks(bundle, lines);
|
|
175
|
+
|
|
176
|
+
const branch = bundle.branch || {};
|
|
177
|
+
if ((branch.behind || 0) > 0) {
|
|
178
|
+
lines.push(`> Branch is **${branch.behind}** commit(s) behind base.`);
|
|
179
|
+
lines.push('');
|
|
180
|
+
}
|
|
181
|
+
|
|
182
|
+
const pr = opts.pr || bundle.pr || '<pr>';
|
|
183
|
+
lines.push('---');
|
|
184
|
+
lines.push(`Detailed JSON: ${mdCode(`forge shepherd ${pr} --pull --json`)}`);
|
|
185
|
+
lines.push(`_Updated ${now.toISOString()} · summary-only monitor · labels state, never merges, never resolves threads._`);
|
|
186
|
+
|
|
187
|
+
return { body: lines.join('\n') };
|
|
188
|
+
}
|
|
189
|
+
|
|
190
|
+
module.exports = {
|
|
191
|
+
renderSummary,
|
|
192
|
+
verdictHeadline,
|
|
193
|
+
groupByAuthor,
|
|
194
|
+
threadLocator,
|
|
195
|
+
MAX_THREADS_PER_AUTHOR,
|
|
196
|
+
};
|