forgedock 1.47.0 → 1.48.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/lean/conformance.mjs +16 -5
- package/bin/lean/conformance.test.mjs +18 -0
- package/bin/lean/issuewatch.mjs +134 -37
- package/bin/lean/issuewatch.test.mjs +123 -1
- package/docs/CONFIG.md +2 -0
- package/docs/ISSUE-WATCH.md +22 -1
- package/package.json +1 -1
- package/scripts/file-deferred-followups.sh +1 -1
- package/scripts/owner-route-parity.test.sh +6 -0
- package/scripts/plan-validate-live.mjs +18 -6
package/bin/lean/conformance.mjs
CHANGED
|
@@ -266,9 +266,18 @@ export function tmpdirInputs(command) {
|
|
|
266
266
|
|
|
267
267
|
const tmpInputProblem = (id, p) => `${id}: reads ${p}, which the command does not create; the run TMPDIR is per run, so the engine's rerun and red-green check will not find it. Create the file inside the command (\`… > ${p} && …\`) or use a path the repo tracks`;
|
|
268
268
|
|
|
269
|
-
const
|
|
270
|
-
const
|
|
271
|
-
const
|
|
269
|
+
const E2E_TERM = String.raw`(?:e2e|end-to-end|integration|playwright|cypress|live[- ]stack)`;
|
|
270
|
+
const E2E_WORD_RE = new RegExp(String.raw`\b${E2E_TERM}\b`, "i");
|
|
271
|
+
const MOCKED_TERM = String.raw`(?:mock\w*|stub\w*|fake\w*|unit|vitest|jest|component tests?|route tests?)`;
|
|
272
|
+
const MOCKED_RE = new RegExp(String.raw`\b${MOCKED_TERM}`, "i");
|
|
273
|
+
// #5116: a negated mock ("do not mock", "no stubs") or a fake clock is not a substitute for the e2e check.
|
|
274
|
+
const NEG_MOCK_RE = /\b(?:do(?:es)? not|don't|never|no|not|without)\s+(?:use\s+|any\s+)?(?:mock\w*|stub\w*|fake\w*)/gi;
|
|
275
|
+
const FAKE_CLOCK_RE = /\bfake[- ](?:timers?|clocks?)\b/gi;
|
|
276
|
+
// A weakener governs the e2e term only within a few words and the same clause (no . or ; between them).
|
|
277
|
+
const GAP = String.raw`(?:[\s,]+\w+){0,3}?[\s,]+`;
|
|
278
|
+
const DROP_BEFORE_RE = new RegExp(String.raw`\b(?:no|not|without|never|drop(?:ped|s|ping)?|skip(?:ped|s|ping)?|replac\w+|downgrad\w+|swap\w*)${GAP}${E2E_TERM}\b`, "i");
|
|
279
|
+
const DROP_AFTER_RE = new RegExp(String.raw`\b${E2E_TERM}${GAP}(?:replaced|dropped|skipped|swapped|downgraded)\b`, "i");
|
|
280
|
+
const INSTEAD_OF_RE = new RegExp(String.raw`\b${MOCKED_TERM}[^.;]{0,60}?\b(?:instead of|rather than|in lieu of)\b${GAP}${E2E_TERM}\b`, "i");
|
|
272
281
|
const PROBE_RE = /forge-probe|acceptance[- ]probe/i;
|
|
273
282
|
const NEG_PROBE_RE = /\b(?:no|not|without|never)\b[^.;]{0,30}(?:forge-probe|acceptance[- ]probe)/i;
|
|
274
283
|
/**
|
|
@@ -282,7 +291,9 @@ export function weakenedE2eDecisions(c) {
|
|
|
282
291
|
const elsewhere = (id) => JSON.stringify({ r: c?.requirements ?? [], d: c?.deliverables ?? [], k: decisions.filter((k) => k?.id !== id) });
|
|
283
292
|
return decisions.filter((k) => {
|
|
284
293
|
const choice = String(k?.choice ?? "");
|
|
285
|
-
if (!E2E_WORD_RE.test(choice)
|
|
294
|
+
if (!E2E_WORD_RE.test(choice)) return false;
|
|
295
|
+
const mocked = choice.replace(NEG_MOCK_RE, " ").replace(FAKE_CLOCK_RE, " ");
|
|
296
|
+
if (!MOCKED_RE.test(mocked) || !(DROP_BEFORE_RE.test(mocked) || DROP_AFTER_RE.test(mocked) || INSTEAD_OF_RE.test(mocked))) return false;
|
|
286
297
|
const own = `${choice} ${k?.justification ?? ""}`;
|
|
287
298
|
return !((PROBE_RE.test(own) && !NEG_PROBE_RE.test(own)) || /forge-probe/i.test(elsewhere(k?.id)));
|
|
288
299
|
}).map((k) => k.id).filter(Boolean);
|
|
@@ -1228,7 +1239,7 @@ export function bucketProblems(r) {
|
|
|
1228
1239
|
const POST_PROMOTION_RE = /(?:after|post)[- ]?(?:promotion|release|deploy)|in production after|production[- ]verif|promot(?:e|ion)|rollout|purge|deploy[- ]time/i;
|
|
1229
1240
|
export const READONLY_PROD_RE = /\bssh\s+[\w.-]*-monitor\b|\bobs-psql\b|\bprod(?:uction)?[ _-]monitor\b|\bread[- ]only\b[^.]{0,40}\bprod(?:uction)?\b|\bprod(?:uction)?\b[^.]{0,30}\b(?:query|queries|capture|telemetry|logs?)\b/i;
|
|
1230
1241
|
/** An item that truly needs the outside world (or a write to production) stays external even when it also reads production. */
|
|
1231
|
-
export const EXTERNAL_NEED_RE = /credential|secret|api[- ]?key|token|password|passphrase|ssh[- ]?keys?\b|private[- ]key|\blogin\b|\bsudo\b|spend|budget|billing|purchase|payment|account|third[- ]party|vendor|customer|contract sign|legal|\b(?:delete|drop|truncate|wipe|destroy|modify|migrate|write)\b/i;
|
|
1242
|
+
export const EXTERNAL_NEED_RE = /credential|secret|api[- ]?key|token|password|passphrase|ssh[- ]?keys?\b|private[- ]key|\blogin\b|\bsudo\b|spend|budget|billing|purchase|payment|account|third[- ]party|vendor|customer|contract sign|legal|\b(?:delete|drop|truncate|wipe|destroy|modify|migrate|write|update|restart|rotate|remove|grant|alter)\b/i;
|
|
1232
1243
|
/**
|
|
1233
1244
|
* #5078: where an owner deferral goes. `owner:orchestrator`, `owner:promote:*` or post-promotion text -> post-deploy (the
|
|
1234
1245
|
* rollup); read-only production data or capture (with no credential/spend/account/third-party/customer need) -> host; else external (credentials, spend, an account, a third party or a customer).
|
|
@@ -1325,6 +1325,16 @@ describe("#5087 a plan decision may not weaken an e2e/integration criterion to m
|
|
|
1325
1325
|
assert.deepEqual(weakenedE2eDecisions({ ...C, deliverables: [...C.deliverables, { id: "D9", path: "x.md", change: "declare a forge-probe block" }], decisions: [K2] }), []);
|
|
1326
1326
|
assert.deepEqual(weakenedE2eDecisions({ ...C, decisions: [{ ...K2, justification: "no acceptance probe" }] }), ["K2"]);
|
|
1327
1327
|
});
|
|
1328
|
+
it("#5116 negated or strengthening wording is not a weakening; real weakenings still are", () => {
|
|
1329
|
+
for (const choice of ["Run the Playwright e2e suite against the live stack; do not mock the API", "Integration tests use fake timers, no sleeps", "E2E check hits the real server rather than a stubbed one"]) {
|
|
1330
|
+
const c = { ...C, decisions: [{ id: "K1", choice, source: "doc" }] };
|
|
1331
|
+
assert.deepEqual(weakenedE2eDecisions(c), [], choice);
|
|
1332
|
+
assert.ok(!validateContract(c, ISSUE).problems.some((p) => p.startsWith("K1:")), choice);
|
|
1333
|
+
}
|
|
1334
|
+
for (const choice of ["No Playwright e2e; integration coverage via route and component tests", "Replace the e2e test with mocked unit tests", "Skip e2e, use vitest instead", "Use mocked unit tests instead of the e2e check"]) {
|
|
1335
|
+
assert.deepEqual(weakenedE2eDecisions({ ...C, decisions: [{ id: "K1", choice, source: "doc" }] }), ["K1"], choice);
|
|
1336
|
+
}
|
|
1337
|
+
});
|
|
1328
1338
|
it("leaves unrelated decisions and contracts without decisions alone", () => {
|
|
1329
1339
|
assert.deepEqual(weakenedE2eDecisions(C), []);
|
|
1330
1340
|
assert.deepEqual(weakenedE2eDecisions({ ...C, decisions: [{ id: "K1", choice: "Run the Playwright e2e suite against the live stack", source: "doc" }, { id: "K3", choice: "Use unit tests with mocks for the parser", source: "spec" }] }), []);
|
|
@@ -1383,4 +1393,12 @@ describe("release review #5103", () => {
|
|
|
1383
1393
|
}
|
|
1384
1394
|
assert.equal(route5103({ bucket: "owner:decide", why: "Should we delete production data older than 90 days?" }), "external");
|
|
1385
1395
|
});
|
|
1396
|
+
it("#5115: production-mutating verbs naming a prod monitor stay external, never host", () => {
|
|
1397
|
+
for (const why of ["ssh ovh-monitor and restart the worker", "rotate the prod monitor webhook", "update the production telemetry config",
|
|
1398
|
+
"remove the stale prod monitor alert", "grant read-only prod access to the analyst", "alter the production query timeout"]) {
|
|
1399
|
+
assert.equal(RO5103.test(why), true, `read-only prod phrase: ${why}`);
|
|
1400
|
+
assert.equal(host(why), false, why);
|
|
1401
|
+
assert.equal(route5103({ bucket: "owner:x", why }), "external", why);
|
|
1402
|
+
}
|
|
1403
|
+
});
|
|
1386
1404
|
});
|
package/bin/lean/issuewatch.mjs
CHANGED
|
@@ -25,7 +25,7 @@ import { noComments } from "./render.mjs";
|
|
|
25
25
|
import { listAssociations } from "./gh-assoc.mjs";
|
|
26
26
|
import { verifyMerge, verifyComment } from "./issuewatch-verify.mjs";
|
|
27
27
|
|
|
28
|
-
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", "sweep_needs_human", "max_launches"];
|
|
28
|
+
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", "sweep_needs_human", "max_launches", "trust_app", "trust_app_labels"];
|
|
29
29
|
export const TRUSTED = new Set(["OWNER", "MEMBER", "COLLABORATOR"]);
|
|
30
30
|
const NAME_RE = /^[A-Za-z0-9][A-Za-z0-9._-]*$/;
|
|
31
31
|
const RETRIES = 3;
|
|
@@ -79,8 +79,11 @@ export function validateWatch(name, cfg = {}) {
|
|
|
79
79
|
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,
|
|
80
80
|
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),
|
|
81
81
|
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,
|
|
82
|
-
max_launches: int("max_launches", null), sweep_needs_human: cfg.sweep_needs_human === true || String(cfg.sweep_needs_human).toLowerCase() === "true"
|
|
82
|
+
max_launches: int("max_launches", null), sweep_needs_human: cfg.sweep_needs_human === true || String(cfg.sweep_needs_human).toLowerCase() === "true",
|
|
83
|
+
trust_app: cfg.trust_app === true || String(cfg.trust_app).toLowerCase() === "true", trust_app_labels: list(cfg.trust_app_labels) };
|
|
83
84
|
if (cfg.sweep_needs_human !== undefined && !/^(true|false)$/i.test(String(cfg.sweep_needs_human))) throw new Error(`watch ${name}: sweep_needs_human must be true or false`);
|
|
85
|
+
if (cfg.trust_app !== undefined && !/^(true|false)$/i.test(String(cfg.trust_app))) throw new Error(`watch ${name}: trust_app must be true or false`);
|
|
86
|
+
if (w.trust_app_labels.length && !w.trust_app) throw new Error(`watch ${name}: trust_app_labels needs trust_app: true`);
|
|
84
87
|
if (!["per-issue", "batch", "release"].includes(w.mode)) throw new Error(`watch ${name}: mode must be per-issue, batch or release`);
|
|
85
88
|
if (w.mode === "release") validateRelease(name, cfg, w, int);
|
|
86
89
|
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`);
|
|
@@ -123,37 +126,64 @@ export function issueVersion(issue) {
|
|
|
123
126
|
return null;
|
|
124
127
|
}
|
|
125
128
|
|
|
126
|
-
/**
|
|
127
|
-
export function
|
|
129
|
+
/** The first selector `issue` fails, as a reason (`missing label x`, `excluded label x`, ...), or null when all match. `search` is applied by gh, not here. */
|
|
130
|
+
export function selectorMiss(issue, sel) {
|
|
128
131
|
const names = labelNames(issue);
|
|
129
|
-
if (sel.milestone === "none" ? issue.milestone : sel.milestone && milestoneSlug(issue.milestone?.title) !== milestoneSlug(sel.milestone)) return
|
|
130
|
-
|
|
131
|
-
if (
|
|
132
|
-
|
|
133
|
-
if (
|
|
134
|
-
if (sel.
|
|
135
|
-
return
|
|
132
|
+
if (sel.milestone === "none" ? issue.milestone : sel.milestone && milestoneSlug(issue.milestone?.title) !== milestoneSlug(sel.milestone)) return "milestone";
|
|
133
|
+
const missing = (sel.labels ?? []).find((l) => !names.includes(l));
|
|
134
|
+
if (missing !== undefined) return `missing label ${missing}`;
|
|
135
|
+
const excluded = (sel.exclude_labels ?? []).find((l) => names.includes(l));
|
|
136
|
+
if (excluded !== undefined) return `excluded label ${excluded}`;
|
|
137
|
+
if (sel.title_regex && !new RegExp(sel.title_regex).test(issue.title ?? "")) return "title_regex";
|
|
138
|
+
if (sel.since && !(Date.parse(issue.createdAt) >= Date.parse(sel.since))) return "since";
|
|
139
|
+
if (sel.version && issueVersion(issue) !== sel.version) return "version";
|
|
140
|
+
return null;
|
|
136
141
|
}
|
|
137
142
|
|
|
143
|
+
/** AND of every set selector. `search` is applied by gh, not here. */
|
|
144
|
+
export const matchesSelectors = (issue, sel) => !selectorMiss(issue, sel);
|
|
145
|
+
|
|
138
146
|
const CHECK_RE = /^\s*[-*]\s+\[ \]\s+(.*)$/;
|
|
139
147
|
/** True when the body has an unchecked `— defer` checklist item. */
|
|
140
148
|
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); });
|
|
141
149
|
|
|
142
150
|
// Bot authorship comes from the author only, never the body: a `FORGE:DEFERRED` marker in an outsider's issue is not
|
|
143
151
|
// the App. `gh` reports the ForgeDock App as a bare login with no `[bot]` suffix (#4467), so it is matched by slug.
|
|
144
|
-
|
|
152
|
+
const bareLogin = (i) => String(i.author?.login ?? "").trim().toLowerCase().replace(/^app\//, "").replace(/\[bot\]$/, "");
|
|
153
|
+
export const isBot = (i) => { const l = String(i.author?.login ?? ""); return /\[bot\]$|^app\//i.test(l) || i.author?.is_bot === true || bareLogin(i) === APP_SLUG; };
|
|
154
|
+
/** #5088: the issue's author is this repo's ForgeDock App (`app/<slug>`, `<slug>[bot]` or the bare slug); never another bot. */
|
|
155
|
+
// Release review of #5123 (AUTH-1): the slug alone is a username anyone could register. Require a bot signal too: the
|
|
156
|
+
// REST/gh forms `<slug>[bot]` / `app/<slug>`, or `is_bot: true` (gh --json author) for the bare GraphQL login.
|
|
157
|
+
export const isOwnApp = (i) => {
|
|
158
|
+
if (bareLogin(i) !== APP_SLUG) return false;
|
|
159
|
+
const l = String(i.author?.login ?? "").trim();
|
|
160
|
+
return /\[bot\]$|^app\//i.test(l) || i.author?.is_bot === true;
|
|
161
|
+
};
|
|
145
162
|
const isFollowup = (i) => /^follow-up\b/i.test(i.title ?? "");
|
|
146
163
|
|
|
147
|
-
/** `
|
|
148
|
-
|
|
164
|
+
/** {kind, reason}: `kind` as classifyIssue returns it; `reason` says why (shown by the per-issue/batch `--dry-run`). */
|
|
165
|
+
function classifyWhy(issue, watch) {
|
|
166
|
+
const login = issue.author?.login ?? "unknown";
|
|
149
167
|
if (isBot(issue)) {
|
|
150
|
-
if (
|
|
151
|
-
|
|
152
|
-
|
|
168
|
+
if (isFollowup(issue)) { // trust_app never overrides the follow-up rules: a post-deploy-only follow-up folds, never claims (#5088 K3)
|
|
169
|
+
if (watch.include_followups !== "defer-only") return { kind: null, reason: `bot follow-up by ${login} (include_followups: none)` };
|
|
170
|
+
if (postDeployOnly(issue.body)) return watch.postdeploy_rollup ? { kind: "fold", reason: "post-deploy-only follow-up" } : { kind: null, reason: "post-deploy-only follow-up (no postdeploy_rollup)" };
|
|
171
|
+
return hasUnresolvedDefer(issue.body) ? { kind: "claim", reason: "defer follow-up" } : { kind: null, reason: "bot follow-up without an unchecked defer item" };
|
|
172
|
+
}
|
|
173
|
+
if (!watch.trust_app || !isOwnApp(issue)) return { kind: null, reason: `bot author ${login}` };
|
|
174
|
+
const want = watch.trust_app_labels ?? [];
|
|
175
|
+
if (want.length && !labelNames(issue).some((l) => want.includes(l))) return { kind: null, reason: `bot author ${login} (trust_app_labels not matched)` };
|
|
176
|
+
return { kind: "claim", reason: "ForgeDock App author (trust_app)" };
|
|
153
177
|
}
|
|
154
|
-
return TRUSTED.has(issue.authorAssociation) ? "claim" : null;
|
|
178
|
+
return TRUSTED.has(issue.authorAssociation) ? { kind: "claim", reason: `trusted author (${issue.authorAssociation})` } : { kind: null, reason: `untrusted author ${login} (${issue.authorAssociation ?? "unknown"})` };
|
|
155
179
|
}
|
|
156
180
|
|
|
181
|
+
/**
|
|
182
|
+
* `claim` (a lane/batch takes it), `fold` (post-deploy-only follow-up for the rollup), or null (not for this watch).
|
|
183
|
+
* #5088: with `trust_app: true` an issue the repo's own ForgeDock App authored is claimed (narrowed by `trust_app_labels`).
|
|
184
|
+
*/
|
|
185
|
+
export const classifyIssue = (issue, watch) => classifyWhy(issue, watch).kind;
|
|
186
|
+
|
|
157
187
|
/** `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. */
|
|
158
188
|
export function laneFor(issue, watch, { branches = {}, defaults = [] } = {}) {
|
|
159
189
|
const staging = branches.staging || DEFAULT_STAGING_BRANCH;
|
|
@@ -281,21 +311,11 @@ export async function tick({ watch, repo, io, state, dir, branches = {}, default
|
|
|
281
311
|
if (capped && state.phase === "running") state.phase = "draining";
|
|
282
312
|
if (state.phase === "draining") { if (!state.inflight.length && !Object.keys(state.verifyFrom ?? {}).length) state.phase = "done"; return state; } // a merge mid-verification holds the drain
|
|
283
313
|
|
|
284
|
-
const room =
|
|
285
|
-
const
|
|
286
|
-
|
|
287
|
-
const candidates = [];
|
|
288
|
-
const assoc = await listAssociations(io.gh, repo); // #4859: not a `gh issue list` field
|
|
289
|
-
for (const raw of await ghJson(args)) {
|
|
290
|
-
const i = { ...raw, authorAssociation: assoc.get(raw.number) };
|
|
291
|
-
if (!matchesSelectors(i, watch)) continue;
|
|
292
|
-
const kind = classifyIssue(i, watch);
|
|
293
|
-
if (kind === "fold") { await fold({ issue: i, watch, repo, io, state }); continue; }
|
|
294
|
-
if (kind === "claim" && !state.claimed.includes(i.number)) candidates.push(i);
|
|
295
|
-
}
|
|
296
|
-
const ordered = watch.mode === "batch" ? dagOrder(candidates) : candidates;
|
|
314
|
+
const room = roomFor(watch, state);
|
|
315
|
+
const plan = await planClaims({ watch, repo, io, state, branches, defaults, room });
|
|
316
|
+
for (const i of plan.folds) await fold({ issue: i, watch, repo, io, state });
|
|
297
317
|
const taken = [];
|
|
298
|
-
for (const i of
|
|
318
|
+
for (const i of plan.candidates) {
|
|
299
319
|
if (taken.length >= room) break;
|
|
300
320
|
const lane = laneFor(i, watch, { branches, defaults });
|
|
301
321
|
if (!claimIssue(repoDir(repo, io.env), i.number, watch.name)) continue;
|
|
@@ -308,6 +328,50 @@ export async function tick({ watch, repo, io, state, dir, branches = {}, default
|
|
|
308
328
|
return state;
|
|
309
329
|
}
|
|
310
330
|
|
|
331
|
+
const roomFor = (watch, state) => { const used = claimsUsed(state); return Math.min(watch.max_inflight - state.inflight.length, watch.max_issues ? watch.max_issues - used.issues : Infinity, watch.max_launches ? watch.max_launches - used.launches : Infinity); };
|
|
332
|
+
|
|
333
|
+
/**
|
|
334
|
+
* #5088: what one pass would take, without writing anything. `candidates` are the issues `tick` tries to claim (in order);
|
|
335
|
+
* `claim` [{number, lane, reason}] and `skip` [{number, reason}] are the read-only forecast (registry read, never written;
|
|
336
|
+
* `room` caps the claims), `fold` the post-deploy-only follow-up numbers (`folds` the issues).
|
|
337
|
+
*/
|
|
338
|
+
export async function planClaims({ watch, repo, io, state, branches = {}, defaults = [], room = Infinity }) {
|
|
339
|
+
const args = ["issue", "list", "-R", repo, "--state", "open", "--limit", "200", "--json", "number,title,body,labels,milestone,author,createdAt"];
|
|
340
|
+
if (watch.search) args.push("--search", watch.search);
|
|
341
|
+
const found = [], folds = [], skip = [], why = new Map();
|
|
342
|
+
const assoc = await listAssociations(io.gh, repo); // #4859: not a `gh issue list` field
|
|
343
|
+
for (const raw of JSON.parse(String(await io.gh(args)))) {
|
|
344
|
+
const i = { ...raw, authorAssociation: assoc.get(raw.number) };
|
|
345
|
+
const miss = selectorMiss(i, watch);
|
|
346
|
+
if (miss) { skip.push({ number: i.number, reason: miss }); continue; }
|
|
347
|
+
const c = classifyWhy(i, watch);
|
|
348
|
+
if (c.kind === "fold") { folds.push(i); continue; }
|
|
349
|
+
if (c.kind !== "claim") { skip.push({ number: i.number, reason: c.reason }); continue; }
|
|
350
|
+
if (state.claimed.includes(i.number)) { skip.push({ number: i.number, reason: "already claimed by this watch" }); continue; }
|
|
351
|
+
found.push(i); why.set(i.number, c.reason);
|
|
352
|
+
}
|
|
353
|
+
const candidates = watch.mode === "batch" ? dagOrder(found) : found;
|
|
354
|
+
const registry = readJson(join(repoDir(repo, io.env), "claims.json")) ?? {};
|
|
355
|
+
const claim = [];
|
|
356
|
+
for (const i of candidates) {
|
|
357
|
+
const owner = registry[i.number];
|
|
358
|
+
if (owner && owner !== watch.name) { skip.push({ number: i.number, reason: `already claimed by watch ${owner}` }); continue; }
|
|
359
|
+
let lane;
|
|
360
|
+
try { lane = laneFor(i, watch, { branches, defaults }); } catch (e) { skip.push({ number: i.number, reason: String(e?.message ?? e) }); continue; }
|
|
361
|
+
if (claim.length >= room) { skip.push({ number: i.number, reason: `over capacity (room ${Math.max(0, room)})` }); continue; }
|
|
362
|
+
claim.push({ number: i.number, lane, reason: why.get(i.number) });
|
|
363
|
+
}
|
|
364
|
+
return { candidates, claim, fold: folds.map((i) => i.number), folds, skip };
|
|
365
|
+
}
|
|
366
|
+
|
|
367
|
+
/** The per-issue/batch `--dry-run` lines of a planClaims result. */
|
|
368
|
+
export function formatClaimPlan(plan) {
|
|
369
|
+
const out = plan.claim.length ? plan.claim.map((c) => `would claim #${c.number} -> ${c.lane}: ${c.reason}`) : ["would claim: none"];
|
|
370
|
+
for (const n of plan.fold) out.push(`would fold #${n}`);
|
|
371
|
+
for (const s of plan.skip) out.push(`skip #${s.number}: ${s.reason}`);
|
|
372
|
+
return out.join("\n");
|
|
373
|
+
}
|
|
374
|
+
|
|
311
375
|
/** True when the registry leaves issue `n` to watch `name`: unclaimed, or already this watch's own claim (crash-safe re-claim, #4825). */
|
|
312
376
|
export const unclaimedOrOwn = (registry, n, name) => !registry?.[n] || registry[n] === name;
|
|
313
377
|
|
|
@@ -402,7 +466,7 @@ export async function runWatch({ watch, repo, io, dir, branches, defaults, once
|
|
|
402
466
|
const judgePrompt = ({ decision, options, policy, policyPath, precedent }) => `You settle one decision for a repository from its own policy file (${policyPath ?? "none"}) and prior decisions. Reply with only a JSON object {"choice","clause","rejected","reversibility":"reversible|one-way","confidence":"high|medium|low","anchors":[]}. "clause" quotes the policy clause or precedent that supports the choice; leave it empty when none applies.\n\nPolicy:\n${policy}\n\nPrior decisions:\n${JSON.stringify((precedent ?? []).slice(0, 20))}\n\nDecision needed:\n${decision}\n${(options ?? []).length ? `Options: ${options.join(" | ")}\n` : ""}`;
|
|
403
467
|
const CLI = join(dirname(fileURLToPath(import.meta.url)), "..", "forgedock.mjs");
|
|
404
468
|
function realIo(repo, env = process.env) {
|
|
405
|
-
const gh = (
|
|
469
|
+
const gh = ghRunner(execFileSync, (l) => console.error(l));
|
|
406
470
|
const root = batchRoot(env);
|
|
407
471
|
return { env, gh, now: () => Date.now(), sleep: (ms) => new Promise((r) => setTimeout(r, ms)),
|
|
408
472
|
classify: (issues, themes) => { // the one LLM judgment of release mode: issues no theme_map rule classifies
|
|
@@ -433,9 +497,37 @@ function realIo(repo, env = process.env) {
|
|
|
433
497
|
} };
|
|
434
498
|
}
|
|
435
499
|
|
|
500
|
+
/** #5088: what a failed gh call does to the watch pass, from its argv (the callers that swallow it degrade; the rest fail). */
|
|
501
|
+
export function ghFailureEffect(args) {
|
|
502
|
+
const a = (args ?? []).map(String), s = a.join(" ");
|
|
503
|
+
if (a[0] === "api" && /(^| )repos\/[^ ]+\/issues\?state=open/.test(s)) return "pass degraded: author associations unknown, every author untrusted (nothing claimed or settled this pass)";
|
|
504
|
+
if (a[0] === "api" && (a[1] === "user" || a[1] === "app")) return "no effect: the App slug stands in for the engine's own login";
|
|
505
|
+
if (/\/protection\/required_status_checks|\/rules\/branches\//.test(s)) return "verification degraded: required checks unknown, a merge is verified without them";
|
|
506
|
+
if (/\/check-runs|\/commits\/[^ /]+\/status/.test(s)) return "verification degraded: check states unread";
|
|
507
|
+
if (/\/compare\//.test(s)) return "verification pending: merge placement unknown";
|
|
508
|
+
return "the error is raised to its caller (that step fails)";
|
|
509
|
+
}
|
|
510
|
+
|
|
511
|
+
/**
|
|
512
|
+
* #5088: `gh(args)→stdout` over `exec` (execFileSync's signature) with gh's stderr captured instead of leaked. A failure
|
|
513
|
+
* throws an Error naming the call (`.status`, `.stderr` kept); an HTTP 401/403 first `warn`s one line with the call and
|
|
514
|
+
* its effect on the pass (ghFailureEffect).
|
|
515
|
+
*/
|
|
516
|
+
export function ghRunner(exec, warn = () => {}) {
|
|
517
|
+
return (args) => {
|
|
518
|
+
try { return exec("gh", args, { encoding: "utf8", maxBuffer: 64 * 1024 * 1024, stdio: ["ignore", "pipe", "pipe"] }); } catch (e) {
|
|
519
|
+
const call = args.slice(0, 3).join(" "), stderr = String(e?.stderr ?? "");
|
|
520
|
+
const first = stderr.split("\n").map((l) => l.trim()).find(Boolean) ?? String(e?.message ?? e).split("\n")[0];
|
|
521
|
+
const code = /HTTP (40[13])\b/.exec(stderr)?.[1];
|
|
522
|
+
if (code) warn(`issue-watch: gh ${call} -> HTTP ${code}: ${first}; effect: ${ghFailureEffect(args)}`);
|
|
523
|
+
throw Object.assign(new Error(`gh ${call} failed: ${first}`), { status: e?.status, stderr });
|
|
524
|
+
}
|
|
525
|
+
};
|
|
526
|
+
}
|
|
527
|
+
|
|
436
528
|
const flag = (argv, f) => { const i = argv.indexOf(f); return i >= 0 ? argv[i + 1] : null; };
|
|
437
529
|
|
|
438
|
-
/** `forgedock issue-watch run [--name w] [--repo o/r] [--once] [--interval s] | status [--name w] [--repo o/r]`; `--dry-run`
|
|
530
|
+
/** `forgedock issue-watch run [--name w] [--repo o/r] [--once] [--interval s] | status [--name w] [--repo o/r]`; `--dry-run` writes nothing: mode release prints the would-cut set, per-issue/batch the would-claim/fold/skip plan (#5088) and the needs-human classification. */
|
|
439
531
|
export async function issueWatchFromCli(argv, deps = {}) {
|
|
440
532
|
const [op] = argv;
|
|
441
533
|
const usage = "usage: forgedock issue-watch run [--name <w>] [--repo owner/repo] [--once] [--interval <s>] [--dry-run] | status [--name <w>] [--repo owner/repo]";
|
|
@@ -455,10 +547,15 @@ export async function issueWatchFromCli(argv, deps = {}) {
|
|
|
455
547
|
return { ok: true };
|
|
456
548
|
}
|
|
457
549
|
const ctx = decisionContext({ loadPolicy, readFile, cwd }), shared = { matches: matchesSelectors, fold, labelNames, isBot, TRUSTED };
|
|
458
|
-
if (argv.includes("--dry-run") && watch.mode !== "release") { // #
|
|
459
|
-
const
|
|
550
|
+
if (argv.includes("--dry-run") && watch.mode !== "release") { // #5088 would-claim/skip with reasons, then #4793 the needs-human classification; nothing written
|
|
551
|
+
const real = deps.io ?? realIo(repo, env), branches = readBranches(cwd);
|
|
552
|
+
const defaults = await defaultBranches({ repo, io: { gh: async (a) => real.gh(a) }, branches });
|
|
553
|
+
const state = readJson(join(dir, "status.json")) ?? fresh(watch, repo);
|
|
554
|
+
const { claim, fold: folded, skip } = await planClaims({ watch, repo, io: real, state, branches, defaults, room: roomFor(watch, state) });
|
|
555
|
+
print(formatClaimPlan({ claim, fold: folded, skip }));
|
|
556
|
+
const { rows } = await decisionStep({ watch, repo, io: real, state, ctx, shared, dryRun: true });
|
|
460
557
|
print(formatDecisionDryRun(rows));
|
|
461
|
-
return { ok: true, dryRun: { rows } };
|
|
558
|
+
return { ok: true, dryRun: { rows, claim, fold: folded, skip } };
|
|
462
559
|
}
|
|
463
560
|
if (argv.includes("--dry-run")) {
|
|
464
561
|
const real = deps.io ?? realIo(repo, env), branches = readBranches(cwd);
|
|
@@ -5,7 +5,7 @@ import assert from "node:assert/strict";
|
|
|
5
5
|
import { mkdirSync, writeFileSync, existsSync, readFileSync } from "node:fs";
|
|
6
6
|
import { tmpDir } from "../tests/_tmp.mjs";
|
|
7
7
|
import { join } from "node:path";
|
|
8
|
-
import { unclaimedOrOwn, parseWatches, validateWatch, loadWatches, matchesSelectors, issueVersion, hasUnresolvedDefer, classifyIssue, laneFor, dagOrder, claimIssue, acquireLock, tick, runWatch, formatStatus, repoDir, watchDir, issueWatchFromCli } from "./issuewatch.mjs";
|
|
8
|
+
import { unclaimedOrOwn, parseWatches, validateWatch, loadWatches, matchesSelectors, issueVersion, hasUnresolvedDefer, classifyIssue, laneFor, dagOrder, claimIssue, acquireLock, tick, runWatch, formatStatus, repoDir, watchDir, issueWatchFromCli, isOwnApp, selectorMiss, planClaims, ghFailureEffect, ghRunner } from "./issuewatch.mjs";
|
|
9
9
|
import { openBatch } from "./batch.mjs";
|
|
10
10
|
|
|
11
11
|
const tmp = () => tmpDir("iw-");
|
|
@@ -419,3 +419,125 @@ test("#4793 formatStatus lists decided / evidence-gathered / folded / external-k
|
|
|
419
419
|
decisions: { rows: [{ action: "decided" }, { action: "evidence-needed" }, { action: "post-deploy-only" }, { action: "external", kept: true }, { action: "external", kept: true }] } };
|
|
420
420
|
assert.match(formatStatus(s, w, 0), /needs-human decisions: decided 1 evidence-gathered 1 folded 1 external-kept 2/);
|
|
421
421
|
});
|
|
422
|
+
|
|
423
|
+
// ── #5088: opt-in trust for the repo's own ForgeDock App, claim dry-run, named 401/403 ──────────────────────────────
|
|
424
|
+
const appIss = (n, login = "rapiercraft-forgedock[bot]", o = {}) => iss(n, { title: `fix: app ${n}`, authorAssociation: "NONE", author: { login }, ...o });
|
|
425
|
+
|
|
426
|
+
test("#5088 trust_app / trust_app_labels are validated watch keys, default off", () => {
|
|
427
|
+
assert.equal(W().trust_app, false);
|
|
428
|
+
assert.deepEqual(W().trust_app_labels, []);
|
|
429
|
+
assert.equal(W({ trust_app: "true" }).trust_app, true);
|
|
430
|
+
assert.equal(W({ trust_app: "false" }).trust_app, false);
|
|
431
|
+
assert.deepEqual(W({ trust_app: true, trust_app_labels: ["operator"] }).trust_app_labels, ["operator"]);
|
|
432
|
+
assert.throws(() => W({ trust_app: "yes" }), /trust_app must be true or false/);
|
|
433
|
+
assert.throws(() => W({ trust_app_labels: ["operator"] }), /trust_app_labels needs trust_app: true/);
|
|
434
|
+
assert.deepEqual(parseWatches("issue_watch:\n watches:\n a:\n trust_app: true\n trust_app_labels: [operator, intake]\n").a, { trust_app: "true", trust_app_labels: ["operator", "intake"] });
|
|
435
|
+
});
|
|
436
|
+
|
|
437
|
+
test("#5088 trust_app claims the repo's own App issues only; other bots and NONE humans stay out", () => {
|
|
438
|
+
const on = W({ trust_app: true });
|
|
439
|
+
// Release review #5123 (AUTH-1): the bare slug counts only with a bot signal; a human who registers the slug is never the App.
|
|
440
|
+
assert.ok(isOwnApp(appIss(1, "rapiercraft-forgedock", { author: { login: "rapiercraft-forgedock", is_bot: true } })));
|
|
441
|
+
assert.ok(!isOwnApp(appIss(1, "rapiercraft-forgedock", { author: { login: "rapiercraft-forgedock", is_bot: false } })), "human with the slug as login");
|
|
442
|
+
assert.equal(classifyIssue(appIss(1, "rapiercraft-forgedock", { author: { login: "rapiercraft-forgedock", is_bot: false } }), on), null, "human slug never claimed");
|
|
443
|
+
for (const l of ["rapiercraft-forgedock[bot]", "app/rapiercraft-forgedock"]) {
|
|
444
|
+
assert.ok(isOwnApp(appIss(1, l)), l);
|
|
445
|
+
assert.equal(classifyIssue(appIss(1, l), on), "claim", l);
|
|
446
|
+
}
|
|
447
|
+
for (const l of ["forge[bot]", "dependabot[bot]", "rapiercraft-forgedock-evil"]) assert.equal(classifyIssue(appIss(2, l), on), null, l);
|
|
448
|
+
assert.ok(!isOwnApp(appIss(2, "rapiercraft-forgedock-evil")));
|
|
449
|
+
assert.equal(classifyIssue(iss(3, { authorAssociation: "NONE", author: { login: "mallory" } }), on), null);
|
|
450
|
+
assert.equal(classifyIssue(iss(4), on), "claim"); // trusted humans unchanged
|
|
451
|
+
const narrow = W({ trust_app: true, trust_app_labels: ["operator"] });
|
|
452
|
+
assert.equal(classifyIssue(appIss(5), narrow), null);
|
|
453
|
+
assert.equal(classifyIssue(appIss(5, undefined, { labels: lbl("operator") }), narrow), "claim");
|
|
454
|
+
// App follow-ups stay governed by include_followups (post-deploy-only folds, never claims)
|
|
455
|
+
const defer = "- [ ] `R1: x` — defer: separate work\n", pd = "- [ ] `R2: y` — post-deploy: check later\n";
|
|
456
|
+
const fu = (body) => appIss(6, undefined, { title: "follow-up: 1 deferred item(s)", body });
|
|
457
|
+
const onFu = W({ trust_app: true, include_followups: "defer-only", postdeploy_rollup: "50" });
|
|
458
|
+
assert.equal(classifyIssue(fu(defer), onFu), "claim");
|
|
459
|
+
assert.equal(classifyIssue(fu(pd), onFu), "fold");
|
|
460
|
+
assert.equal(classifyIssue(fu("no items"), onFu), null);
|
|
461
|
+
assert.equal(classifyIssue(fu(pd), W({ trust_app: true })), null);
|
|
462
|
+
});
|
|
463
|
+
|
|
464
|
+
test("#5088 regression: without trust_app an App-authored issue is not claimed", async () => {
|
|
465
|
+
for (const l of ["rapiercraft-forgedock[bot]", "app/rapiercraft-forgedock", "rapiercraft-forgedock"]) assert.equal(classifyIssue(appIss(1, l), W()), null, l);
|
|
466
|
+
const io = fakeIo(tmp(), [appIss(1), iss(2)]);
|
|
467
|
+
const s = await tick({ watch: W(), repo: "o/r", io, state: { phase: "running", claimed: [], inflight: [], merged: [], verified: [], failed: [], folded: [], retries: {}, lanes: {}, batch: null } });
|
|
468
|
+
assert.deepEqual(s.claimed, [2]);
|
|
469
|
+
});
|
|
470
|
+
|
|
471
|
+
test("#5088 tick with trust_app claims and launches the App issue, not a NONE human or another bot", async () => {
|
|
472
|
+
const io = fakeIo(tmp(), [appIss(1), appIss(2, "forge[bot]"), iss(3, { authorAssociation: "NONE", author: { login: "mallory" } }), iss(4)]);
|
|
473
|
+
const s = await tick({ watch: W({ trust_app: true }), repo: "o/r", io, state: { phase: "running", claimed: [], inflight: [], merged: [], verified: [], failed: [], folded: [], retries: {}, lanes: {}, batch: null } });
|
|
474
|
+
assert.deepEqual(s.claimed, [1, 4]);
|
|
475
|
+
assert.deepEqual(io.launched.map(([n]) => n), [1, 4]);
|
|
476
|
+
});
|
|
477
|
+
|
|
478
|
+
test("#5088 selectorMiss names the failing selector", () => {
|
|
479
|
+
assert.equal(selectorMiss(iss(1, { labels: lbl("epic") }), W({ exclude_labels: ["epic"] })), "excluded label epic");
|
|
480
|
+
assert.equal(selectorMiss(iss(1), W({ labels: ["ready"] })), "missing label ready");
|
|
481
|
+
assert.equal(selectorMiss(iss(1), W()), null);
|
|
482
|
+
});
|
|
483
|
+
|
|
484
|
+
for (const mode of ["per-issue", "batch"]) {
|
|
485
|
+
test(`#5088 --dry-run (${mode}) lists would-claim and skipped issues with reasons and writes nothing`, async () => {
|
|
486
|
+
const dir = tmp(), cwd = tmp();
|
|
487
|
+
writeFileSync(join(cwd, "forge.yaml"), `branches:\n default: main\nissue_watch:\n watches:\n intake:\n mode: ${mode}\n trust_app: true\n exclude_labels: [epic]\n`);
|
|
488
|
+
const io = fakeIo(dir, [appIss(1), iss(2, { labels: lbl("epic") }), iss(3, { authorAssociation: "NONE", author: { login: "mallory" } }), iss(4), appIss(5, "dependabot[bot]")]);
|
|
489
|
+
io.gh = ((orig) => async (a) => (a[0] === "repo" ? (a.includes("nameWithOwner") ? JSON.stringify({ nameWithOwner: "o/r" }) : "main") : orig(a)))(io.gh);
|
|
490
|
+
const rdir = repoDir("o/r", io.env);
|
|
491
|
+
mkdirSync(rdir, { recursive: true });
|
|
492
|
+
writeFileSync(join(rdir, "claims.json"), JSON.stringify({ 4: "other" }) + "\n");
|
|
493
|
+
const out = [];
|
|
494
|
+
const r = await issueWatchFromCli(["run", "--name", "intake", "--dry-run"], { cwd, env: io.env, io, print: (l) => out.push(l) });
|
|
495
|
+
const t = out.join("\n");
|
|
496
|
+
assert.match(t, /would claim #1 -> staging: ForgeDock App author \(trust_app\)/);
|
|
497
|
+
assert.match(t, /skip #2: excluded label epic/);
|
|
498
|
+
assert.match(t, /skip #3: untrusted author mallory \(NONE\)/);
|
|
499
|
+
assert.match(t, /skip #4: already claimed by watch other/);
|
|
500
|
+
assert.match(t, /skip #5: bot author dependabot\[bot\]/);
|
|
501
|
+
assert.deepEqual(r.dryRun.claim, [{ number: 1, lane: "staging", reason: "ForgeDock App author (trust_app)" }]);
|
|
502
|
+
assert.ok(Array.isArray(r.dryRun.rows));
|
|
503
|
+
assert.deepEqual(JSON.parse(readFileSync(join(rdir, "claims.json"), "utf8")), { 4: "other" });
|
|
504
|
+
assert.ok(!existsSync(join(watchDir("o/r", "intake", io.env), "status.json")));
|
|
505
|
+
assert.deepEqual(io.launched, []);
|
|
506
|
+
assert.deepEqual(io.batchOps, []);
|
|
507
|
+
});
|
|
508
|
+
}
|
|
509
|
+
|
|
510
|
+
test("#5088 planClaims: own registry claim stays claimable, room caps the rest as over capacity", async () => {
|
|
511
|
+
const io = fakeIo(tmp(), [iss(1), iss(2), iss(3)]);
|
|
512
|
+
const rdir = repoDir("o/r", io.env);
|
|
513
|
+
mkdirSync(rdir, { recursive: true });
|
|
514
|
+
writeFileSync(join(rdir, "claims.json"), JSON.stringify({ 1: "w" }) + "\n");
|
|
515
|
+
const p = await planClaims({ watch: W(), repo: "o/r", io, state: { claimed: [3], inflight: [] }, room: 1 });
|
|
516
|
+
assert.deepEqual(p.claim.map((c) => c.number), [1]);
|
|
517
|
+
assert.deepEqual(p.skip, [{ number: 3, reason: "already claimed by this watch" }, { number: 2, reason: "over capacity (room 1)" }]);
|
|
518
|
+
});
|
|
519
|
+
|
|
520
|
+
test("#5088 ghRunner names the failing call; 401/403 also warn with the effect on the pass", () => {
|
|
521
|
+
const assocArgs = ["api", "repos/o/r/issues?state=open&per_page=100", "--paginate", "--jq", ".[]"];
|
|
522
|
+
assert.match(ghFailureEffect(assocArgs), /every author untrusted/);
|
|
523
|
+
assert.match(ghFailureEffect(["api", "user", "--jq", ".login"]), /^no effect/);
|
|
524
|
+
assert.match(ghFailureEffect(["api", "app", "--jq", ".slug"]), /^no effect/);
|
|
525
|
+
assert.match(ghFailureEffect(["api", "repos/o/r/branches/staging/protection/required_status_checks"]), /required checks unknown/);
|
|
526
|
+
assert.match(ghFailureEffect(["issue", "list", "-R", "o/r"]), /raised to its caller/);
|
|
527
|
+
const warned = [];
|
|
528
|
+
const fail = (stderr) => () => { throw Object.assign(new Error("Command failed"), { status: 1, stderr }); };
|
|
529
|
+
const gh403 = ghRunner(fail("gh: Resource not accessible by integration (HTTP 403)\n"), (l) => warned.push(l));
|
|
530
|
+
assert.throws(() => gh403(assocArgs), (e) => /gh api repos\/o\/r\/issues\?state=open&per_page=100 --paginate failed: gh: Resource not accessible by integration \(HTTP 403\)/.test(e.message) && e.status === 1);
|
|
531
|
+
assert.equal(warned.length, 1);
|
|
532
|
+
assert.match(warned[0], /gh api repos\/o\/r\/issues\?state=open.* -> HTTP 403: .*effect: pass degraded: .*every author untrusted/);
|
|
533
|
+
const gh401 = ghRunner(fail("gh: A JSON web token could not be decoded (HTTP 401)"), (l) => warned.push(l));
|
|
534
|
+
assert.throws(() => gh401(["api", "app", "--jq", ".slug"]), /gh api app --jq failed/);
|
|
535
|
+
assert.match(warned[1], /gh api app --jq -> HTTP 401: .*effect: no effect/);
|
|
536
|
+
const gh500 = ghRunner(fail("gh: Server Error (HTTP 500)"), (l) => warned.push(l));
|
|
537
|
+
assert.throws(() => gh500(["issue", "list"]), /gh issue list failed: gh: Server Error/);
|
|
538
|
+
assert.equal(warned.length, 2);
|
|
539
|
+
let seen;
|
|
540
|
+
assert.equal(ghRunner((cmd, args, opts) => { seen = { cmd, args, opts }; return "out"; })(["repo", "view"]), "out");
|
|
541
|
+
assert.equal(seen.cmd, "gh");
|
|
542
|
+
assert.deepEqual(seen.opts.stdio, ["ignore", "pipe", "pipe"]);
|
|
543
|
+
});
|
package/docs/CONFIG.md
CHANGED
|
@@ -717,6 +717,8 @@ issue_watch:
|
|
|
717
717
|
| `mode` | string | No | `per-issue` (default), `batch` or `release` (pool, then cut themed releases). |
|
|
718
718
|
| `parallel` | integer | No | K of the batch in `batch` mode. Default 4. |
|
|
719
719
|
| `include_followups` | string | No | `none` (default) or `defer-only`. |
|
|
720
|
+
| `trust_app` | boolean | No | Claim issues authored by this repo's ForgeDock App. Other bots and `NONE`-association authors stay untrusted. Default false. |
|
|
721
|
+
| `trust_app_labels` | list | No | Needs `trust_app`: an App-authored issue must carry one of these labels. |
|
|
720
722
|
| `postdeploy_rollup` | issue number | No | Release issue that collects post-deploy-only follow-ups as checklist lines. |
|
|
721
723
|
| `sweep_needs_human` | boolean | No | Also settle `needs-human` escalations that predate the watch (the backlog). Default false; `forgedock issue-watch run --dry-run` classifies them first and writes nothing. |
|
|
722
724
|
| `max_launches` | integer | No | Total lane launches (claims plus requeues by the decision engine); at N the watch drains. Default: none. Decision-stage issues also count against `max_issues`. |
|
package/docs/ISSUE-WATCH.md
CHANGED
|
@@ -34,7 +34,7 @@ Each watch has `~/.forge/watch/<owner>__<repo>/<name>/` (or `$FORGEDOCK_WATCH_DI
|
|
|
34
34
|
|
|
35
35
|
## Selectors
|
|
36
36
|
|
|
37
|
-
All optional, AND-ed. Only issues from an owner, member or collaborator are claimed (bot-filed issues only through `include_followups`).
|
|
37
|
+
All optional, AND-ed. Only issues from an owner, member or collaborator are claimed (bot-filed issues only through `include_followups` or `trust_app`).
|
|
38
38
|
|
|
39
39
|
| Key | Meaning |
|
|
40
40
|
|---|---|
|
|
@@ -65,6 +65,27 @@ The lane follows the repo rule: a milestone issue goes to `milestone/<slug>` (fo
|
|
|
65
65
|
|
|
66
66
|
`include_followups: defer-only` claims bot-filed `follow-up:` issues that have an unchecked `— defer` item. Post-deploy-only follow-ups are not claimed: with `postdeploy_rollup: <issue>` each is appended once as a checklist line to that release issue. Other bot issues stay excluded.
|
|
67
67
|
|
|
68
|
+
## Issues filed by the ForgeDock App
|
|
69
|
+
|
|
70
|
+
When agents and `gh` run as the ForgeDock App, the issues they file are authored by `rapiercraft-forgedock[bot]` with association `NONE`, so a watch skips them. `trust_app: true` (default false) claims issues authored by this repo's own ForgeDock App. Other bots and outsiders (`NONE` humans) stay excluded. It is an explicit opt-in because the App also files issues from untrusted inputs (prod-watch, ingestion). `trust_app_labels: [operator]` (needs `trust_app`) narrows it further: the App issue must carry one of those labels. `follow-up:` issues from the App still follow `include_followups`.
|
|
71
|
+
|
|
72
|
+
## Dry run
|
|
73
|
+
|
|
74
|
+
In `per-issue` and `batch` mode, `forgedock issue-watch run --name <w> --dry-run` prints what one pass would claim and why, then the needs-human classification. It writes nothing: no claim, no status, no launch.
|
|
75
|
+
|
|
76
|
+
```
|
|
77
|
+
would claim #12 -> staging: ForgeDock App author (trust_app)
|
|
78
|
+
would fold #14
|
|
79
|
+
skip #13: excluded label epic
|
|
80
|
+
skip #15: untrusted author mallory (NONE)
|
|
81
|
+
skip #16: already claimed by watch other
|
|
82
|
+
skip #17: over capacity (room 4)
|
|
83
|
+
```
|
|
84
|
+
|
|
85
|
+
## gh errors
|
|
86
|
+
|
|
87
|
+
A failed `gh` call raises an error that names the call. An HTTP 401 or 403 also prints one line naming the call and its effect on the pass, for example `issue-watch: gh api repos/o/r/issues?state=open&per_page=100 --paginate -> HTTP 403: ...; effect: pass degraded: author associations unknown, every author untrusted (nothing claimed or settled this pass)`. Under an App token, `gh api user` (403) and `gh api app` (401) report `no effect`.
|
|
88
|
+
|
|
68
89
|
## Merge verification
|
|
69
90
|
|
|
70
91
|
A closed issue is not taken on trust. When a claimed issue closes (in `per-issue` and `batch` modes), the watch checks:
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "forgedock",
|
|
3
|
-
"version": "1.
|
|
3
|
+
"version": "1.48.1",
|
|
4
4
|
"description": "Autonomous development pipeline for coding agents. GitHub as institutional memory, with native Claude Code and OpenCode entrypoints.",
|
|
5
5
|
"bin": {
|
|
6
6
|
"forgedock": "bin/forgedock.mjs"
|
|
@@ -69,7 +69,7 @@ CLASSES="engineering post-deploy external ops verify-now host"
|
|
|
69
69
|
# bin/lean/conformance.mjs ownerRoute; word boundaries spelled (^|[^[:alnum:]_]) / ([^[:alnum:]_]|$), no \b, for BSD grep (bash 3.2 hosts).
|
|
70
70
|
POSTPROM_ERE='(after|post)[- ]?(promotion|release|deploy)|in production after|production[- ]verif|promot(e|ion)|rollout|purge|deploy[- ]time'
|
|
71
71
|
ROPROD_ERE='ssh[[:space:]]+[[:alnum:]_.-]*-monitor([^[:alnum:]_]|$)|(^|[^[:alnum:]_])obs-psql([^[:alnum:]_]|$)|(^|[^[:alnum:]_])prod(uction)?[ _-]monitor([^[:alnum:]_]|$)|(^|[^[:alnum:]_])read[- ]only[^[:alnum:]_.]([^.]{0,38}[^[:alnum:]_.])?prod(uction)?([^[:alnum:]_]|$)|(^|[^[:alnum:]_])prod(uction)?[^[:alnum:]_.]([^.]{0,28}[^[:alnum:]_.])?(query|queries|capture|telemetry|logs?)([^[:alnum:]_]|$)'
|
|
72
|
-
EXTNEED_ERE='credential|secret|api[- ]?key|token|password|passphrase|ssh[- ]?keys?([^[:alnum:]_]|$)|private[- ]key|(^|[^[:alnum:]_])login([^[:alnum:]_]|$)|(^|[^[:alnum:]_])sudo([^[:alnum:]_]|$)|spend|budget|billing|purchase|payment|account|third[- ]party|vendor|customer|contract sign|legal|(^|[^[:alnum:]_])(delete|drop|truncate|wipe|destroy|modify|migrate|write)([^[:alnum:]_]|$)'
|
|
72
|
+
EXTNEED_ERE='credential|secret|api[- ]?key|token|password|passphrase|ssh[- ]?keys?([^[:alnum:]_]|$)|private[- ]key|(^|[^[:alnum:]_])login([^[:alnum:]_]|$)|(^|[^[:alnum:]_])sudo([^[:alnum:]_]|$)|spend|budget|billing|purchase|payment|account|third[- ]party|vendor|customer|contract sign|legal|(^|[^[:alnum:]_])(delete|drop|truncate|wipe|destroy|modify|migrate|write|update|restart|rotate|remove|grant|alter)([^[:alnum:]_]|$)'
|
|
73
73
|
owner_route() { # why -> post-deploy | host | external
|
|
74
74
|
case "$1" in "owner: orchestrator"*|"owner: promote"*) echo "post-deploy"; return 0 ;; esac
|
|
75
75
|
if printf '%s' "$1" | grep -qiE "$POSTPROM_ERE" && ! printf '%s' "$1" | grep -qiE "$EXTNEED_ERE"; then echo "post-deploy"
|
|
@@ -26,6 +26,12 @@ external|needs the prod ssh key to read production logs
|
|
|
26
26
|
external|product manager decides the pricing page
|
|
27
27
|
external|production dataset retention policy
|
|
28
28
|
external|provide the Stripe API key, needed before rollout
|
|
29
|
+
external|ssh ovh-monitor and restart the worker
|
|
30
|
+
external|rotate the prod monitor webhook
|
|
31
|
+
external|update the production telemetry config
|
|
32
|
+
external|remove the stale prod monitor alert
|
|
33
|
+
external|grant read-only prod access to the analyst
|
|
34
|
+
external|alter the production query timeout
|
|
29
35
|
post-deploy|verify the dashboard after release
|
|
30
36
|
CASES
|
|
31
37
|
echo "passed=$PASS failed=$FAIL"
|
|
@@ -59,7 +59,8 @@ export async function readLive({ gh, repo }) {
|
|
|
59
59
|
/**
|
|
60
60
|
* Parallel-start gate: dependencies of `issues` (not themselves in the batch) that are not merged into `baseOf(issue)`.
|
|
61
61
|
* A dep is merged when it is CLOSED by a PR that is merged with that base, or whose merge commit is an ancestor of
|
|
62
|
-
* that base (`isAncestor(sha, base)`, e.g. it arrived through a milestone sync).
|
|
62
|
+
* that base (`isAncestor(sha, base)`, e.g. it arrived through a milestone sync). With no linked PR (GitHub links only
|
|
63
|
+
* default-branch merges, #5086) the merged `feat/issue-<dep>-lean` PR is looked up instead. Unreadable issues are skipped (fail open:
|
|
63
64
|
* the engine's own openDependency still guards each lane); an unreadable dep counts as unmet.
|
|
64
65
|
* `sha`/`kind` (#5031): `kind` is "commit" when the dependency merged but its commit is not on the base yet (`sha`), else "other".
|
|
65
66
|
* @returns {Promise<Array<{ issue: number, dep: number, base: string, why: string, sha: string|null, kind: "commit"|"other" }>>}
|
|
@@ -83,12 +84,23 @@ export async function unmetDependencies({ issues, baseOf, gh, repo, isAncestor }
|
|
|
83
84
|
else {
|
|
84
85
|
let merged = false;
|
|
85
86
|
let missing = null;
|
|
87
|
+
// One PR is "on the base" when it merged there or its merge commit is an ancestor of it.
|
|
88
|
+
const check = async (p) => {
|
|
89
|
+
if (!p?.mergedAt) return;
|
|
90
|
+
const oid = p.mergeCommit?.oid;
|
|
91
|
+
if (p.baseRefName === base || (oid && isAncestor && await isAncestor(oid, base))) merged = true;
|
|
92
|
+
else if (oid) missing ??= oid;
|
|
93
|
+
};
|
|
86
94
|
for (const pr of d.closedByPullRequestsReferences ?? []) {
|
|
87
|
-
|
|
88
|
-
if (
|
|
89
|
-
|
|
90
|
-
|
|
91
|
-
|
|
95
|
+
await check(parse(await gh(["pr", "view", String(pr.number), ...r, "--json", "baseRefName,mergedAt,mergeCommit"])));
|
|
96
|
+
if (merged) break;
|
|
97
|
+
}
|
|
98
|
+
// #5086: GitHub links closing PRs only for default-branch merges, so a milestone-lane dependency has no references.
|
|
99
|
+
// Fall back to the lane PR by head-branch convention; a failed or non-array lookup finds nothing (still refused).
|
|
100
|
+
if (!merged && !missing) {
|
|
101
|
+
let prs = [];
|
|
102
|
+
try { prs = parse(await gh(["pr", "list", ...r, "--head", `feat/issue-${dep}-lean`, "--state", "merged", "--json", "number,baseRefName,mergedAt,mergeCommit", "--limit", "5"])); } catch { prs = []; }
|
|
103
|
+
for (const p of Array.isArray(prs) ? prs : []) { await check(p); if (merged) break; }
|
|
92
104
|
}
|
|
93
105
|
if (!merged) sha = missing;
|
|
94
106
|
if (!merged) why = missing ? `merge commit ${missing.slice(0, 10)} is not on ${base}` : `closed but not merged into ${base}`;
|