forgedock 1.37.0 → 1.38.1
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/bin/engine/outcome-codes.mjs +12 -3
- package/bin/engine/telemetry-seam.mjs +5 -0
- package/bin/engine-cli.mjs +97 -18
- package/bin/forgedock.mjs +13 -2
- package/bin/lean/activation.mjs +76 -0
- package/bin/lean/activation.test.mjs +75 -0
- package/bin/lean/admission.mjs +17 -1
- package/bin/lean/batch.mjs +51 -21
- package/bin/lean/batch.test.mjs +95 -5
- package/bin/lean/conformance.mjs +53 -4
- package/bin/lean/conformance.test.mjs +24 -1
- package/bin/lean/decision-engine.mjs +217 -0
- package/bin/lean/decision-engine.test.mjs +159 -0
- package/bin/lean/effects.mjs +39 -13
- package/bin/lean/execution.mjs +74 -5
- package/bin/lean/execution.test.mjs +64 -1
- package/bin/lean/explore.test.mjs +1 -1
- package/bin/lean/guards.mjs +3 -2
- package/bin/lean/guards.test.mjs +24 -3
- package/bin/lean/heavy.mjs +15 -2
- package/bin/lean/heavy.test.mjs +31 -2
- package/bin/lean/issuewatch-release.mjs +245 -0
- package/bin/lean/issuewatch-release.test.mjs +244 -0
- package/bin/lean/issuewatch.mjs +386 -0
- package/bin/lean/issuewatch.test.mjs +280 -0
- package/bin/lean/lane.mjs +4 -3
- package/bin/lean/lane.test.mjs +12 -0
- package/bin/lean/lean.mjs +103 -16
- package/bin/lean/lean.test.mjs +127 -5
- package/bin/lean/needs-human-premise.mjs +177 -0
- package/bin/lean/needs-human-premise.test.mjs +136 -0
- package/bin/lean/orchestrate-blocked.test.mjs +71 -0
- package/bin/lean/orchestrate.mjs +93 -21
- package/bin/lean/postdeploy-check.mjs +88 -2
- package/bin/lean/postdeploy-check.test.mjs +78 -0
- package/bin/lean/prodwatch-sources.mjs +165 -0
- package/bin/lean/prodwatch-sources.test.mjs +108 -0
- package/bin/lean/prompts.mjs +9 -3
- package/bin/lean/recovery.test.mjs +14 -0
- package/bin/lean/relay.mjs +182 -0
- package/bin/lean/relay.test.mjs +214 -0
- package/bin/lean/sandbox-provider.contract.test.mjs +13 -1
- package/bin/lean/sandbox-remote.mjs +53 -9
- package/bin/lean/sched.test.mjs +30 -0
- package/bin/lean/schedd-health.mjs +69 -0
- package/bin/lean/schedd-health.test.mjs +51 -0
- package/bin/lean/schedd.mjs +29 -12
- package/bin/lean/schedd.test.mjs +40 -4
- package/bin/lean/sequence.mjs +57 -24
- package/bin/lean/sequence.test.mjs +71 -0
- package/bin/pool/agent.mjs +15 -1
- package/bin/pool/router.mjs +43 -5
- package/bin/runner.mjs +18 -1
- package/commands/deploy-pr.md +12 -0
- package/commands/forgedock-init.md +1 -0
- package/commands/orchestrate/phase-4-execution.md +1 -0
- package/commands/orchestrate.md +1 -0
- package/commands/review-pr.md +17 -13
- package/docs/CONFIG.md +73 -2
- package/docs/DECISIONS.md +32 -0
- package/docs/ISSUE-WATCH.md +95 -0
- package/docs/NORTHSTAR-TEMPLATE.md +21 -0
- package/docs/OUTCOME-CODES.md +2 -1
- package/docs/TELEMETRY.md +7 -0
- package/package.json +1 -2
- package/pool/README.md +1 -1
- package/pool/image/Dockerfile +2 -1
- package/pool/image/cs-relay +31 -0
- package/pool/image/entrypoint.sh +10 -0
- package/pool/install/install-schedd.sh +22 -0
- package/pool/install/unit-hardening.sh +6 -0
- package/scripts/postdeploy-rollup-link.sh +40 -0
- package/scripts/postdeploy-rollup-link.test.sh +46 -0
- package/scripts/refresh-bot-token.sh +122 -0
- package/scripts/review-delta.sh +14 -4
- package/scripts/review-delta.test.sh +21 -0
- package/scripts/review-finalize.sh +69 -25
- package/scripts/review-finalize.test.sh +32 -0
- package/scripts/review-pr-arguments.test.sh +10 -0
- package/scripts/review-pr-changed-verification.test.sh +54 -0
- package/scripts/review-pr-delta-wiring.test.sh +1 -1
|
@@ -0,0 +1,386 @@
|
|
|
1
|
+
// SPDX-FileCopyrightText: Copyright (c) RapierCraft Studios
|
|
2
|
+
// SPDX-License-Identifier: AGPL-3.0-or-later
|
|
3
|
+
/**
|
|
4
|
+
* `forgedock issue-watch` (#4765): named, scoped, capped watches that claim matching open issues and feed them to lanes.
|
|
5
|
+
* One watch = one (repo, name) with its own dir `~/.forge/watch/<repo>/<name>/` (or `FORGEDOCK_WATCH_DIR`), lock and
|
|
6
|
+
* `status.json`; `~/.forge/watch/<repo>/claims.json` is the repo-wide registry that gives an issue at most one claim across
|
|
7
|
+
* watches. Selectors, caps and the lane rule are pure functions; gh, the clock and lane/batch launching are injected (`io`).
|
|
8
|
+
* `mode: per-issue` runs `forgedock run-issue` per claim; `mode: batch` `--add`s claims into one long-lived orchestrate
|
|
9
|
+
* batch (batch.mjs `addIssues`, so K, the `Depends on` DAG and the report apply) and starts a new one after it reports.
|
|
10
|
+
* No LLM calls.
|
|
11
|
+
*/
|
|
12
|
+
import { existsSync, mkdirSync, readFileSync, rmdirSync } from "node:fs";
|
|
13
|
+
import { execFileSync, spawn } from "node:child_process";
|
|
14
|
+
import { homedir, hostname } from "node:os";
|
|
15
|
+
import { dirname, join } from "node:path";
|
|
16
|
+
import { fileURLToPath } from "node:url";
|
|
17
|
+
import { writeAtomic, batchRoot, openBatch, listBatches } from "./batch.mjs";
|
|
18
|
+
import { milestoneSlug, readBranches, isDefaultLane, defaultBranches, DEFAULT_STAGING_BRANCH, DEFAULT_FEATURE_PATTERN } from "./lane.mjs";
|
|
19
|
+
import { postDeployOnly } from "./prefilter.mjs";
|
|
20
|
+
import { releaseTick, formatDryRun } from "./issuewatch-release.mjs";
|
|
21
|
+
import { loadPolicy } from "./decision-engine.mjs";
|
|
22
|
+
import { APP_SLUG } from "../init-repo.mjs";
|
|
23
|
+
import { noComments } from "./render.mjs";
|
|
24
|
+
|
|
25
|
+
const WATCH_KEYS = ["milestone", "labels", "exclude_labels", "search", "title_regex", "since", "version", "lane", "max_issues", "max_inflight", "until", "mode", "parallel", "include_followups", "postdeploy_rollup", "threshold", "immediate", "max_release_issues", "theme_map", "tracker"];
|
|
26
|
+
const TRUSTED = new Set(["OWNER", "MEMBER", "COLLABORATOR"]);
|
|
27
|
+
const NAME_RE = /^[A-Za-z0-9][A-Za-z0-9._-]*$/;
|
|
28
|
+
const RETRIES = 3;
|
|
29
|
+
const readJson = (f) => { try { return JSON.parse(readFileSync(f, "utf8")); } catch { return null; } };
|
|
30
|
+
|
|
31
|
+
// ── config ───────────────────────────────────────────────────────────────────────────────────────────────────
|
|
32
|
+
const scalar = (v) => {
|
|
33
|
+
const s = String(v ?? "").replace(/\s+#.*$/, "").trim();
|
|
34
|
+
if (/^\{.*\}$/.test(s)) return Object.fromEntries(s.slice(1, -1).split(",").map((x) => x.split(/:(.*)/s).slice(0, 2).map((y) => scalar(y))).filter(([k]) => k).map(([k, v]) => [k, v !== "" && Number.isFinite(Number(v)) ? Number(v) : v]));
|
|
35
|
+
if (/^\[.*\]$/.test(s)) return s.slice(1, -1).split(",").map((x) => scalar(x)).filter((x) => x !== "");
|
|
36
|
+
return s.replace(/^(["'])(.*)\1$/, "$2");
|
|
37
|
+
};
|
|
38
|
+
|
|
39
|
+
/** The `issue_watch.watches.<name>.<key>` block of forge.yaml text (three nesting levels, which parseForgeYaml does not read): `{name: {key: value}}`. */
|
|
40
|
+
export function parseWatches(raw) {
|
|
41
|
+
const out = {};
|
|
42
|
+
let inSec = false, inWatches = false, cur = null, sub = null;
|
|
43
|
+
for (const line of String(raw ?? "").split(/\r?\n/)) {
|
|
44
|
+
if (/^\s*(#|$)/.test(line)) continue;
|
|
45
|
+
const indent = /^\s*/.exec(line)[0].length;
|
|
46
|
+
const m = /^\s*([A-Za-z_][\w.-]*):\s*(.*)$/.exec(line);
|
|
47
|
+
if (!m) continue;
|
|
48
|
+
if (indent === 0) { inSec = m[1] === "issue_watch"; inWatches = false; cur = null; continue; }
|
|
49
|
+
if (!inSec) continue;
|
|
50
|
+
if (indent <= 2) { inWatches = m[1] === "watches"; cur = null; continue; }
|
|
51
|
+
if (!inWatches) continue;
|
|
52
|
+
if (indent <= 4) { cur = m[1]; out[cur] = {}; sub = null; continue; }
|
|
53
|
+
if (indent <= 6) sub = null;
|
|
54
|
+
if (cur && sub) { out[cur][sub][m[1]] = scalar(m[2]); continue; }
|
|
55
|
+
if (cur && m[1] === "theme_map" && m[2] === "") { out[cur].theme_map = {}; sub = "theme_map"; continue; } // one nested block: theme -> rule list
|
|
56
|
+
if (cur) out[cur][m[1]] = scalar(m[2]);
|
|
57
|
+
}
|
|
58
|
+
return out;
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
/** Validate one watch and return it with defaults; throws on an unknown key, bad mode, regex, date or number. */
|
|
62
|
+
export function validateWatch(name, cfg = {}) {
|
|
63
|
+
if (!NAME_RE.test(String(name))) throw new Error(`invalid watch name ${JSON.stringify(name)}`);
|
|
64
|
+
for (const k of Object.keys(cfg)) if (!WATCH_KEYS.includes(k)) throw new Error(`watch ${name}: unknown key ${k}`);
|
|
65
|
+
const list = (v) => (Array.isArray(v) ? v : v ? String(v).split(",").map((x) => x.trim()).filter(Boolean) : []);
|
|
66
|
+
const int = (k, d) => {
|
|
67
|
+
if (cfg[k] === undefined || cfg[k] === "") return d;
|
|
68
|
+
const n = Number(cfg[k]);
|
|
69
|
+
if (!Number.isInteger(n) || n < 1) throw new Error(`watch ${name}: ${k} must be a positive integer`);
|
|
70
|
+
return n;
|
|
71
|
+
};
|
|
72
|
+
const w = { name, milestone: cfg.milestone || null, labels: list(cfg.labels), exclude_labels: list(cfg.exclude_labels), search: cfg.search || null, title_regex: cfg.title_regex || null,
|
|
73
|
+
since: cfg.since || null, version: cfg.version ? String(cfg.version) : null, lane: cfg.lane || null, max_issues: int("max_issues", null), max_inflight: int("max_inflight", 4),
|
|
74
|
+
until: cfg.until || null, mode: cfg.mode || "per-issue", parallel: int("parallel", 4), include_followups: cfg.include_followups || "none", postdeploy_rollup: cfg.postdeploy_rollup ? Number(String(cfg.postdeploy_rollup).replace("#", "")) : null };
|
|
75
|
+
if (!["per-issue", "batch", "release"].includes(w.mode)) throw new Error(`watch ${name}: mode must be per-issue, batch or release`);
|
|
76
|
+
if (w.mode === "release") validateRelease(name, cfg, w, int);
|
|
77
|
+
else for (const k of ["threshold", "immediate", "max_release_issues", "theme_map", "tracker"]) if (cfg[k] !== undefined) throw new Error(`watch ${name}: ${k} needs mode: release`);
|
|
78
|
+
if (!["none", "defer-only"].includes(w.include_followups)) throw new Error(`watch ${name}: include_followups must be none or defer-only`);
|
|
79
|
+
if (w.title_regex) { try { new RegExp(w.title_regex); } catch { throw new Error(`watch ${name}: invalid title_regex`); } }
|
|
80
|
+
for (const k of ["until", "since"]) if (w[k] && !Number.isFinite(Date.parse(w[k]))) throw new Error(`watch ${name}: ${k} is not an ISO time`);
|
|
81
|
+
if (w.version && !/^\d+\.\d+$/.test(w.version)) throw new Error(`watch ${name}: version must be x.y`);
|
|
82
|
+
if (w.postdeploy_rollup != null && !(Number.isInteger(w.postdeploy_rollup) && w.postdeploy_rollup > 0)) throw new Error(`watch ${name}: postdeploy_rollup must be an issue number`);
|
|
83
|
+
return w;
|
|
84
|
+
}
|
|
85
|
+
|
|
86
|
+
/** `mode: release` keys: threshold {min_issues,min_score,max_age_days}, immediate [label:x|priority:Px], max_release_issues, theme_map {theme: [label:|path:|scope:]}, tracker. */
|
|
87
|
+
function validateRelease(name, cfg, w, int) {
|
|
88
|
+
const th = cfg.threshold && typeof cfg.threshold === "object" ? cfg.threshold : {};
|
|
89
|
+
for (const k of Object.keys(th)) if (!["min_issues", "min_score", "max_age_days"].includes(k)) throw new Error(`watch ${name}: unknown threshold key ${k}`);
|
|
90
|
+
for (const [k, v] of Object.entries(th)) if (!(Number(v) > 0)) throw new Error(`watch ${name}: threshold.${k} must be a positive number`);
|
|
91
|
+
w.threshold = Object.fromEntries(Object.entries(th).map(([k, v]) => [k, Number(v)]));
|
|
92
|
+
w.immediate = (Array.isArray(cfg.immediate) ? cfg.immediate : cfg.immediate ? [cfg.immediate] : []).map(String);
|
|
93
|
+
for (const r of w.immediate) if (!/^(label:.+|priority:P[0-3])$/i.test(r)) throw new Error(`watch ${name}: immediate entry ${r} must be label:<name> or priority:P0..P3`);
|
|
94
|
+
if (!Object.keys(w.threshold).length && !w.immediate.length) throw new Error(`watch ${name}: mode release needs a threshold or immediate trigger`);
|
|
95
|
+
w.max_release_issues = int("max_release_issues", 40);
|
|
96
|
+
const tm = cfg.theme_map && typeof cfg.theme_map === "object" ? cfg.theme_map : {};
|
|
97
|
+
w.theme_map = Object.fromEntries(Object.entries(tm).map(([t, rules]) => [t, [].concat(rules).map(String)]));
|
|
98
|
+
for (const [t, rules] of Object.entries(w.theme_map)) for (const r of rules) if (!/^(label|path|scope):.+/.test(r)) throw new Error(`watch ${name}: theme_map.${t} rule ${r} must be label:, path: or scope:`);
|
|
99
|
+
w.tracker = cfg.tracker ? Number(String(cfg.tracker).replace("#", "")) : null;
|
|
100
|
+
if (w.tracker != null && !(Number.isInteger(w.tracker) && w.tracker > 0)) throw new Error(`watch ${name}: tracker must be an issue number`);
|
|
101
|
+
}
|
|
102
|
+
|
|
103
|
+
/** All watches of a forge.yaml text, validated. */
|
|
104
|
+
export const loadWatches = (raw) => Object.fromEntries(Object.entries(parseWatches(raw)).map(([n, c]) => [n, validateWatch(n, c)]));
|
|
105
|
+
|
|
106
|
+
// ── selectors ────────────────────────────────────────────────────────────────────────────────────────────────
|
|
107
|
+
const labelNames = (i) => (i.labels ?? []).map((l) => (typeof l === "string" ? l : l?.name)).filter(Boolean);
|
|
108
|
+
|
|
109
|
+
/** An issue's target version: x.y from its milestone title, else from a `version:x.y` label; null when none. */
|
|
110
|
+
export function issueVersion(issue) {
|
|
111
|
+
const m = /(\d+\.\d+)/.exec(issue.milestone?.title ?? "");
|
|
112
|
+
if (m) return m[1];
|
|
113
|
+
for (const l of labelNames(issue)) { const v = /^version:(\d+\.\d+)$/i.exec(l); if (v) return v[1]; }
|
|
114
|
+
return null;
|
|
115
|
+
}
|
|
116
|
+
|
|
117
|
+
/** AND of every set selector. `search` is applied by gh, not here. */
|
|
118
|
+
export function matchesSelectors(issue, sel) {
|
|
119
|
+
const names = labelNames(issue);
|
|
120
|
+
if (sel.milestone === "none" ? issue.milestone : sel.milestone && milestoneSlug(issue.milestone?.title) !== milestoneSlug(sel.milestone)) return false;
|
|
121
|
+
if ((sel.labels ?? []).some((l) => !names.includes(l))) return false;
|
|
122
|
+
if ((sel.exclude_labels ?? []).some((l) => names.includes(l))) return false;
|
|
123
|
+
if (sel.title_regex && !new RegExp(sel.title_regex).test(issue.title ?? "")) return false;
|
|
124
|
+
if (sel.since && !(Date.parse(issue.createdAt) >= Date.parse(sel.since))) return false;
|
|
125
|
+
if (sel.version && issueVersion(issue) !== sel.version) return false;
|
|
126
|
+
return true;
|
|
127
|
+
}
|
|
128
|
+
|
|
129
|
+
const CHECK_RE = /^\s*[-*]\s+\[ \]\s+(.*)$/;
|
|
130
|
+
/** True when the body has an unchecked `— defer` checklist item. */
|
|
131
|
+
export const hasUnresolvedDefer = (body) => noComments(body).split("\n").some((l) => { const t = CHECK_RE.exec(l)?.[1]; return t != null && /\s[—-]+\s*defer\b/.test(t); });
|
|
132
|
+
|
|
133
|
+
// Bot authorship comes from the author only, never the body: a `FORGE:DEFERRED` marker in an outsider's issue is not
|
|
134
|
+
// the App. `gh` reports the ForgeDock App as a bare login with no `[bot]` suffix (#4467), so it is matched by slug.
|
|
135
|
+
const isBot = (i) => { const l = String(i.author?.login ?? ""); return /\[bot\]$|^app\//i.test(l) || i.author?.is_bot === true || l.toLowerCase().replace(/^app\//, "").replace(/\[bot\]$/, "") === APP_SLUG; };
|
|
136
|
+
const isFollowup = (i) => /^follow-up\b/i.test(i.title ?? "");
|
|
137
|
+
|
|
138
|
+
/** `claim` (a lane/batch takes it), `fold` (post-deploy-only follow-up for the rollup), or null (not for this watch). */
|
|
139
|
+
export function classifyIssue(issue, watch) {
|
|
140
|
+
if (isBot(issue)) {
|
|
141
|
+
if (!isFollowup(issue) || watch.include_followups !== "defer-only") return null;
|
|
142
|
+
if (postDeployOnly(issue.body)) return watch.postdeploy_rollup ? "fold" : null;
|
|
143
|
+
return hasUnresolvedDefer(issue.body) ? "claim" : null;
|
|
144
|
+
}
|
|
145
|
+
return TRUSTED.has(issue.authorAssociation) ? "claim" : null;
|
|
146
|
+
}
|
|
147
|
+
|
|
148
|
+
/** `milestone/<slug>` (forge.yaml feature pattern) for a milestone issue, else staging; a pinned `lane:` wins but is never a default branch. Throws on a default-branch lane. */
|
|
149
|
+
export function laneFor(issue, watch, { branches = {}, defaults = [] } = {}) {
|
|
150
|
+
const staging = branches.staging || DEFAULT_STAGING_BRANCH;
|
|
151
|
+
const slug = issue.milestone?.title ? milestoneSlug(issue.milestone.title) : "";
|
|
152
|
+
const lane = watch.lane || (slug ? (branches.featurePattern || DEFAULT_FEATURE_PATTERN).replace("{slug}", slug) : staging);
|
|
153
|
+
if (isDefaultLane(lane, defaults)) throw new Error(`lane ${lane} is a default branch; a watch never targets it`);
|
|
154
|
+
return lane;
|
|
155
|
+
}
|
|
156
|
+
|
|
157
|
+
/** Order `issues` so an issue named in another's `Depends on #N` line comes after it (stable otherwise). */
|
|
158
|
+
export function dagOrder(issues) {
|
|
159
|
+
const nums = new Set(issues.map((i) => i.number));
|
|
160
|
+
const deps = (i) => [...String(i.body ?? "").matchAll(/^\s*(?:[-*]\s*)?\**depends on\**:?\s*(.+)$/gim)].flatMap((m) => [...m[1].matchAll(/#(\d+)/g)].map((x) => +x[1])).filter((n) => nums.has(n) && n !== i.number);
|
|
161
|
+
const out = [], seen = new Set();
|
|
162
|
+
const visit = (i, stack = new Set()) => {
|
|
163
|
+
if (seen.has(i.number) || stack.has(i.number)) return;
|
|
164
|
+
stack.add(i.number);
|
|
165
|
+
for (const d of deps(i)) visit(issues.find((x) => x.number === d), stack);
|
|
166
|
+
seen.add(i.number); out.push(i);
|
|
167
|
+
};
|
|
168
|
+
issues.forEach((i) => visit(i));
|
|
169
|
+
return out;
|
|
170
|
+
}
|
|
171
|
+
|
|
172
|
+
// ── state: dir, lock, claims ───────────────────────────────────────────────────────────────────────────────────
|
|
173
|
+
export const watchRoot = (env = process.env) => env.FORGEDOCK_WATCH_DIR || join(homedir(), ".forge", "watch");
|
|
174
|
+
export const repoDir = (repo, env = process.env) => join(watchRoot(env), String(repo).replace("/", "__"));
|
|
175
|
+
export const watchDir = (repo, name, env = process.env) => join(repoDir(repo, env), name);
|
|
176
|
+
|
|
177
|
+
const sleepSync = (ms) => Atomics.wait(new Int32Array(new SharedArrayBuffer(4)), 0, 0, ms);
|
|
178
|
+
const pidAlive = (pid) => { try { process.kill(pid, 0); return true; } catch (e) { return e.code === "EPERM"; } };
|
|
179
|
+
|
|
180
|
+
/** Take the (repo, name) lock; throws when a live watcher on this host holds it. A dead holder is taken over. @returns {() => void} release */
|
|
181
|
+
export function acquireLock(dir, { pid = process.pid, host = hostname(), alive = pidAlive } = {}) {
|
|
182
|
+
const f = join(dir, "lock");
|
|
183
|
+
const held = readJson(f);
|
|
184
|
+
if (held && held.host === host && held.pid !== pid && alive(held.pid)) throw new Error(`watch already running (pid ${held.pid}); lock ${f}`);
|
|
185
|
+
writeAtomic(f, JSON.stringify({ pid, host }) + "\n");
|
|
186
|
+
return () => { const now = readJson(f); if (now?.pid === pid) { try { writeAtomic(f, "{}\n"); } catch { /* best effort */ } } };
|
|
187
|
+
}
|
|
188
|
+
|
|
189
|
+
/**
|
|
190
|
+
* Claim `issue` for `name` in the repo-wide registry; false when another watch already has it. Serialised across processes by a
|
|
191
|
+
* mkdir lock. Idempotent for the holder: the registry is written before the watch saves its own state or launches, so after a
|
|
192
|
+
* crash or a throwing launch the same watch re-claims its issue on the next pass instead of losing it (release #4820 BUG-1).
|
|
193
|
+
*/
|
|
194
|
+
export function claimIssue(rdir, issue, name) {
|
|
195
|
+
const lock = join(rdir, "claims.lock");
|
|
196
|
+
mkdirSync(rdir, { recursive: true });
|
|
197
|
+
for (let i = 0; ; i++) {
|
|
198
|
+
try { mkdirSync(lock); break; } catch (e) { if (e.code !== "EEXIST" || i > 200) throw e; sleepSync(10); }
|
|
199
|
+
}
|
|
200
|
+
try {
|
|
201
|
+
const f = join(rdir, "claims.json");
|
|
202
|
+
const claims = readJson(f) ?? {};
|
|
203
|
+
if (claims[issue]) return claims[issue] === name;
|
|
204
|
+
claims[issue] = name;
|
|
205
|
+
writeAtomic(f, JSON.stringify(claims) + "\n");
|
|
206
|
+
return true;
|
|
207
|
+
} finally { rmdirSync(lock); }
|
|
208
|
+
}
|
|
209
|
+
|
|
210
|
+
// ── the watch ────────────────────────────────────────────────────────────────────────────────────────────────
|
|
211
|
+
const fresh = (watch, repo) => ({ name: watch.name, repo, phase: "running", started: null, claimed: [], inflight: [], merged: [], verified: [], failed: [], folded: [], retries: {}, lanes: {}, batch: null, laneFor: {} });
|
|
212
|
+
|
|
213
|
+
/**
|
|
214
|
+
* One pass. `io`: `gh(args)→string`, `now()→ms`, `launch(issue,lane)→{poll()→null|{code}}` (per-issue), `batch.{add(id,issues),start(issues,parallel,lane)→id,reported(id)→bool}` (batch mode).
|
|
215
|
+
* Settles inflight issues, applies the caps, claims new ones. Mutates and returns `state`; `state.phase === "done"` when a capped watch has drained.
|
|
216
|
+
*/
|
|
217
|
+
export async function tick({ watch, repo, io, state, dir, branches = {}, defaults = [], handles = new Map() }) {
|
|
218
|
+
const now = io.now();
|
|
219
|
+
state.started ??= now;
|
|
220
|
+
const ghJson = async (args) => JSON.parse(String(await io.gh(args)));
|
|
221
|
+
const closed = async (n) => (await ghJson(["issue", "view", String(n), "-R", repo, "--json", "state"])).state === "CLOSED";
|
|
222
|
+
const fail = (n) => { state.inflight = state.inflight.filter((x) => x !== n); state.failed.push(n); };
|
|
223
|
+
const win = (n) => { state.inflight = state.inflight.filter((x) => x !== n); state.merged.push(n); state.verified.push(n); };
|
|
224
|
+
|
|
225
|
+
for (const n of [...state.inflight]) {
|
|
226
|
+
if (watch.mode === "batch") { if (await closed(n)) win(n); continue; }
|
|
227
|
+
const h = handles.get(n);
|
|
228
|
+
if (!h) { handles.set(n, io.launch(n, state.lanes[n])); continue; } // a restarted watch re-launches (run-issue resumes from GitHub)
|
|
229
|
+
const r = h.poll();
|
|
230
|
+
if (!r) continue;
|
|
231
|
+
handles.delete(n);
|
|
232
|
+
if (r.code === 0) { if (await closed(n)) win(n); else { state.inflight = state.inflight.filter((x) => x !== n); state.merged.push(n); state.pendingVerify = [...(state.pendingVerify ?? []), n]; } }
|
|
233
|
+
else if (r.code === 75 && (state.retries[n] = (state.retries[n] ?? 0) + 1) <= RETRIES) handles.set(n, io.launch(n, state.lanes[n]));
|
|
234
|
+
else fail(n);
|
|
235
|
+
}
|
|
236
|
+
for (const n of [...(state.pendingVerify ?? [])]) if (await closed(n)) { state.verified.push(n); state.pendingVerify = state.pendingVerify.filter((x) => x !== n); }
|
|
237
|
+
if (watch.mode === "batch" && state.batch && io.batch.reported(state.batch)) { state.inflight.forEach(fail); state.batch = null; }
|
|
238
|
+
|
|
239
|
+
const capped = (watch.max_issues && state.claimed.length >= watch.max_issues) || (watch.until && now >= Date.parse(watch.until));
|
|
240
|
+
if (capped && state.phase === "running") state.phase = "draining";
|
|
241
|
+
if (state.phase === "draining") { if (!state.inflight.length) state.phase = "done"; return state; }
|
|
242
|
+
|
|
243
|
+
const room = Math.min(watch.max_inflight - state.inflight.length, watch.max_issues ? watch.max_issues - state.claimed.length : Infinity);
|
|
244
|
+
const args = ["issue", "list", "-R", repo, "--state", "open", "--limit", "200", "--json", "number,title,body,labels,milestone,author,authorAssociation,createdAt"];
|
|
245
|
+
if (watch.search) args.push("--search", watch.search);
|
|
246
|
+
const candidates = [];
|
|
247
|
+
for (const i of await ghJson(args)) {
|
|
248
|
+
if (!matchesSelectors(i, watch)) continue;
|
|
249
|
+
const kind = classifyIssue(i, watch);
|
|
250
|
+
if (kind === "fold") { await fold({ issue: i, watch, repo, io, state }); continue; }
|
|
251
|
+
if (kind === "claim" && !state.claimed.includes(i.number)) candidates.push(i);
|
|
252
|
+
}
|
|
253
|
+
const ordered = watch.mode === "batch" ? dagOrder(candidates) : candidates;
|
|
254
|
+
const taken = [];
|
|
255
|
+
for (const i of ordered) {
|
|
256
|
+
if (taken.length >= room) break;
|
|
257
|
+
const lane = laneFor(i, watch, { branches, defaults });
|
|
258
|
+
if (!claimIssue(repoDir(repo, io.env), i.number, watch.name)) continue;
|
|
259
|
+
state.claimed.push(i.number); state.inflight.push(i.number); state.lanes[i.number] = lane; taken.push({ n: i.number, lane });
|
|
260
|
+
}
|
|
261
|
+
if (taken.length && watch.mode === "batch") {
|
|
262
|
+
const nums = taken.map((t) => t.n);
|
|
263
|
+
if (state.batch) io.batch.add(state.batch, nums); else state.batch = await io.batch.start(nums, watch.parallel, taken[0].lane);
|
|
264
|
+
} else for (const t of taken) handles.set(t.n, io.launch(t.n, t.lane));
|
|
265
|
+
return state;
|
|
266
|
+
}
|
|
267
|
+
|
|
268
|
+
/** True when the registry leaves issue `n` to watch `name`: unclaimed, or already this watch's own claim (crash-safe re-claim, #4825). */
|
|
269
|
+
export const unclaimedOrOwn = (registry, n, name) => !registry?.[n] || registry[n] === name;
|
|
270
|
+
|
|
271
|
+
/** `mode: release` pass: wires the pure selectors, claim registry and lane rule into releaseTick (bin/lean/issuewatch-release.mjs). */
|
|
272
|
+
async function releaseStep({ watch, repo, io, state, dir, branches = {}, defaults = [], dryRun = false, cwd = process.cwd() }) {
|
|
273
|
+
const args = ["issue", "list", "-R", repo, "--state", "open", "--limit", "200", "--json", "number,title,body,labels,milestone,author,authorAssociation,createdAt"];
|
|
274
|
+
if (watch.search) args.push("--search", watch.search);
|
|
275
|
+
const registry = () => readJson(join(repoDir(repo, io.env), "claims.json")) ?? {};
|
|
276
|
+
const listOpen = async () => {
|
|
277
|
+
const all = JSON.parse(String(await io.gh(args)));
|
|
278
|
+
if (!dryRun) for (const i of all) if (matchesSelectors(i, watch) && classifyIssue(i, watch) === "fold") await fold({ issue: i, watch, repo, io, state });
|
|
279
|
+
return all;
|
|
280
|
+
};
|
|
281
|
+
const eligible = (i) => matchesSelectors(i, watch) && classifyIssue(i, watch) === "claim" && !state.claimed.includes(i.number) && unclaimedOrOwn(registry(), i.number, watch.name); // release #4820 CONC-1: own claims stay eligible
|
|
282
|
+
const p = loadPolicy({ io: { readFile: (f) => readFileSync(f, "utf8") }, cwd });
|
|
283
|
+
return releaseTick({ watch, repo, io, state, defaults, listOpen, eligible, claim: (n) => claimIssue(repoDir(repo, io.env), n, watch.name), laneFor: (i) => laneFor(i, watch, { branches, defaults }), dagOrder,
|
|
284
|
+
policy: { path: p.policyPath, text: p.policyText }, dryRun });
|
|
285
|
+
}
|
|
286
|
+
|
|
287
|
+
/** Append a post-deploy-only follow-up to the rollup issue's checklist, once per follow-up. */
|
|
288
|
+
async function fold({ issue, watch, repo, io, state }) {
|
|
289
|
+
if (state.folded.includes(issue.number)) return;
|
|
290
|
+
const target = String(watch.postdeploy_rollup);
|
|
291
|
+
const body = JSON.parse(String(await io.gh(["issue", "view", target, "-R", repo, "--json", "body"]))).body ?? "";
|
|
292
|
+
if (!new RegExp(`#${issue.number}\\b`).test(body)) await io.gh(["issue", "edit", target, "-R", repo, "--body", `${body.replace(/\s*$/, "")}\n- [ ] #${issue.number} ${issue.title} — post-deploy: folded follow-up\n`]);
|
|
293
|
+
state.folded.push(issue.number);
|
|
294
|
+
}
|
|
295
|
+
|
|
296
|
+
/** Status text: selector, claims against max_issues, inflight, merged, verified, failed, time remaining. */
|
|
297
|
+
export function formatStatus(s, watch, now = Date.now()) {
|
|
298
|
+
const sel = ["milestone", "labels", "exclude_labels", "search", "title_regex", "since", "version", "lane"].filter((k) => (Array.isArray(watch[k]) ? watch[k].length : watch[k])).map((k) => `${k}=${[].concat(watch[k]).join(",")}`).join(" ") || "(all trusted issues)";
|
|
299
|
+
const left = watch.until ? `${Math.max(0, Math.round((Date.parse(watch.until) - now) / 60000))}m` : "forever";
|
|
300
|
+
return [`watch ${s.name} (${s.repo}) [${s.phase}, ${watch.mode}]`, ` selector: ${sel}`,
|
|
301
|
+
` claims: ${s.claimed.length}/${watch.max_issues ?? "∞"} inflight: ${s.inflight.length} merged: ${s.merged.length} verified: ${s.verified.length} failed: ${s.failed.length}`, ` time remaining: ${left}`,
|
|
302
|
+
...(watch.mode === "release" ? [` pool: ${(s.pool ?? []).length} releases: ${(s.releases ?? []).length + (s.release ? 1 : 0)}${s.release ? ` active: ${s.release.theme} (${s.release.issues.length} issues, batch ${s.release.batch})` : ""} llm-classified: ${s.llm ?? 0}`] : [])].join("\n");
|
|
303
|
+
}
|
|
304
|
+
|
|
305
|
+
/** Run until done (capped) or `once`; writes status.json each pass. */
|
|
306
|
+
export async function runWatch({ watch, repo, io, dir, branches, defaults, once = false, intervalMs = 300000, release = () => {}, cwd }) {
|
|
307
|
+
const state = readJson(join(dir, "status.json")) ?? fresh(watch, repo);
|
|
308
|
+
const handles = new Map();
|
|
309
|
+
try {
|
|
310
|
+
do {
|
|
311
|
+
if (watch.mode === "release") await releaseStep({ watch, repo, io, state, dir, branches, defaults, cwd }); else await tick({ watch, repo, io, state, dir, branches, defaults, handles });
|
|
312
|
+
writeAtomic(join(dir, "status.json"), JSON.stringify(state) + "\n");
|
|
313
|
+
if (state.phase === "done" || once) break;
|
|
314
|
+
await io.sleep(intervalMs);
|
|
315
|
+
} while (true);
|
|
316
|
+
} finally { release(); }
|
|
317
|
+
return state;
|
|
318
|
+
}
|
|
319
|
+
|
|
320
|
+
// ── real io + CLI ───────────────────────────────────────────────────────────────────────────────────────────
|
|
321
|
+
const CLI = join(dirname(fileURLToPath(import.meta.url)), "..", "forgedock.mjs");
|
|
322
|
+
function realIo(repo, env = process.env) {
|
|
323
|
+
const gh = (args) => execFileSync("gh", args, { encoding: "utf8", maxBuffer: 64 * 1024 * 1024 });
|
|
324
|
+
const root = batchRoot(env);
|
|
325
|
+
return { env, gh, now: () => Date.now(), sleep: (ms) => new Promise((r) => setTimeout(r, ms)),
|
|
326
|
+
classify: (issues, themes) => { // the one LLM judgment of release mode: issues no theme_map rule classifies
|
|
327
|
+
const prompt = `Classify each GitHub issue into exactly one theme of [${themes.join(", ")}], or "general" when none fits. Reply with only a JSON object {"<number>": "<theme>"}.\n\n${issues.map((i) => `#${i.number} ${i.title}\n${String(i.body ?? "").slice(0, 600)}`).join("\n---\n")}`;
|
|
328
|
+
return JSON.parse(/\{[\s\S]*\}/.exec(execFileSync("claude", ["--print"], { input: prompt, encoding: "utf8", maxBuffer: 8 * 1024 * 1024 }))[0]);
|
|
329
|
+
},
|
|
330
|
+
launch: (n, lane) => {
|
|
331
|
+
let res = null;
|
|
332
|
+
const p = spawn(process.execPath, [CLI, "run-issue", String(n), "--lane", lane], { detached: true, stdio: "ignore", cwd: process.cwd(), env });
|
|
333
|
+
p.on("close", (code) => { res = { code: code ?? 1 }; }); p.on("error", () => { res = { code: 1 }; });
|
|
334
|
+
return { poll: () => res };
|
|
335
|
+
},
|
|
336
|
+
batch: {
|
|
337
|
+
add: (id, issues) => openBatch(root, id).addIssues(issues),
|
|
338
|
+
reported: (id) => existsSync(join(root, id, "report.json")),
|
|
339
|
+
start: async (issues, parallel, lane) => {
|
|
340
|
+
const before = new Set(listBatches(root));
|
|
341
|
+
spawn(process.execPath, [CLI, "orchestrate", ...issues.map(String), "--parallel", String(parallel), "--lane", lane, "--repo", repo], { detached: true, stdio: "ignore", cwd: process.cwd(), env }).unref();
|
|
342
|
+
for (let i = 0; i < 60; i++) { const id = listBatches(root).find((b) => !before.has(b)); if (id) return id; await new Promise((r) => setTimeout(r, 1000)); }
|
|
343
|
+
throw new Error("orchestrate did not create a batch within 60s");
|
|
344
|
+
},
|
|
345
|
+
} };
|
|
346
|
+
}
|
|
347
|
+
|
|
348
|
+
const flag = (argv, f) => { const i = argv.indexOf(f); return i >= 0 ? argv[i + 1] : null; };
|
|
349
|
+
|
|
350
|
+
/** `forgedock issue-watch run [--name w] [--repo o/r] [--once] [--interval s] | status [--name w] [--repo o/r]`; `--dry-run` (mode release) prints the would-cut set and writes nothing. */
|
|
351
|
+
export async function issueWatchFromCli(argv, deps = {}) {
|
|
352
|
+
const [op] = argv;
|
|
353
|
+
const usage = "usage: forgedock issue-watch run [--name <w>] [--repo owner/repo] [--once] [--interval <s>] [--dry-run] | status [--name <w>] [--repo owner/repo]";
|
|
354
|
+
if (!["run", "status"].includes(op)) throw new Error(usage);
|
|
355
|
+
const cwd = deps.cwd ?? process.cwd(), env = deps.env ?? process.env, print = deps.print ?? ((l) => console.log(l));
|
|
356
|
+
let raw = ""; try { raw = readFileSync(join(cwd, "forge.yaml"), "utf8"); } catch { /* no forge.yaml: the default watch */ }
|
|
357
|
+
const watches = loadWatches(raw);
|
|
358
|
+
const name = flag(argv, "--name") ?? (Object.keys(watches).length === 1 ? Object.keys(watches)[0] : "default");
|
|
359
|
+
const watch = watches[name] ?? (name === "default" ? validateWatch("default", {}) : null);
|
|
360
|
+
if (!watch) throw new Error(`no watch ${name} in forge.yaml issue_watch.watches`);
|
|
361
|
+
const io = deps.io ?? realIo(null, env);
|
|
362
|
+
const repo = flag(argv, "--repo") ?? JSON.parse(String(await io.gh(["repo", "view", "--json", "nameWithOwner"]))).nameWithOwner;
|
|
363
|
+
const dir = watchDir(repo, name, env);
|
|
364
|
+
if (op === "status") {
|
|
365
|
+
const s = readJson(join(dir, "status.json"));
|
|
366
|
+
print(s ? formatStatus(s, watch, io.now()) : `watch ${name}: no status yet`);
|
|
367
|
+
return { ok: true };
|
|
368
|
+
}
|
|
369
|
+
if (argv.includes("--dry-run")) {
|
|
370
|
+
if (watch.mode !== "release") throw new Error("--dry-run needs a watch with mode: release");
|
|
371
|
+
const real = deps.io ?? realIo(repo, env), branches = readBranches(cwd);
|
|
372
|
+
const defaults = await defaultBranches({ repo, io: { gh: async (a) => real.gh(a) }, branches });
|
|
373
|
+
const state = readJson(join(dir, "status.json")) ?? fresh(watch, repo);
|
|
374
|
+
const r = await releaseStep({ watch, repo, io: real, state, dir, branches, defaults, dryRun: true, cwd });
|
|
375
|
+
print(r.decision ? formatDryRun(r.decision, r.pool, r.themes) : "release in flight; nothing to cut");
|
|
376
|
+
return { ok: true, dryRun: r };
|
|
377
|
+
}
|
|
378
|
+
mkdirSync(dir, { recursive: true });
|
|
379
|
+
const release = acquireLock(dir);
|
|
380
|
+
const real = deps.io ?? realIo(repo, env);
|
|
381
|
+
const branches = readBranches(cwd);
|
|
382
|
+
const defaults = await defaultBranches({ repo, io: { gh: async (a) => real.gh(a) }, branches });
|
|
383
|
+
const state = await runWatch({ watch, repo, io: real, dir, branches, defaults, cwd, once: argv.includes("--once"), intervalMs: (Number(flag(argv, "--interval")) || 300) * 1000, release });
|
|
384
|
+
print(formatStatus(state, watch, real.now()));
|
|
385
|
+
return { ok: true, state };
|
|
386
|
+
}
|