forgedock 1.46.0 → 1.48.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/bin/engine/outcome-codes.mjs +2 -1
- package/bin/engine-cli.mjs +22 -8
- package/bin/lean/conformance.e2e.test.mjs +20 -0
- package/bin/lean/conformance.mjs +99 -9
- package/bin/lean/conformance.test.mjs +121 -4
- package/bin/lean/context.mjs +108 -6
- package/bin/lean/context.test.mjs +84 -2
- package/bin/lean/coupled.mjs +38 -0
- package/bin/lean/coupled.test.mjs +57 -0
- package/bin/lean/decision-engine.mjs +5 -1
- package/bin/lean/decision-engine.test.mjs +21 -0
- package/bin/lean/drift.mjs +3 -2
- package/bin/lean/drift.test.mjs +23 -1
- package/bin/lean/effects.mjs +3 -2
- package/bin/lean/execution.mjs +41 -8
- package/bin/lean/execution.test.mjs +72 -2
- package/bin/lean/integration.mjs +2 -2
- package/bin/lean/integration.test.mjs +22 -1
- package/bin/lean/issuewatch.mjs +134 -37
- package/bin/lean/issuewatch.test.mjs +123 -1
- package/bin/lean/lane.mjs +13 -0
- package/bin/lean/lane.test.mjs +28 -1
- package/bin/lean/lean.mjs +60 -12
- package/bin/lean/lean.test.mjs +80 -0
- package/bin/lean/limits.mjs +6 -2
- package/bin/lean/limits.test.mjs +86 -0
- package/bin/lean/milestonesync.mjs +2 -2
- package/bin/lean/milestonesync.test.mjs +22 -0
- package/bin/lean/orchestrate.mjs +79 -9
- package/bin/lean/probe.mjs +4 -2
- package/bin/lean/probe.test.mjs +4 -0
- package/bin/lean/prompts.mjs +18 -11
- package/bin/lean/rotator.mjs +96 -0
- package/bin/lean/rotator.test.mjs +143 -0
- package/bin/lean/routecheck.mjs +48 -0
- package/bin/lean/routecheck.test.mjs +69 -0
- package/bin/lean/sandbox-provider.contract.test.mjs +9 -0
- package/bin/lean/sandbox-remote.mjs +4 -2
- package/bin/lean/verify.mjs +2 -1
- package/bin/review-staging.mjs +20 -3
- package/commands/deploy-info.md +4 -4
- package/commands/issue.md +6 -1
- package/commands/review-pr-agents/frontend.md +1 -0
- package/commands/review-pr-agents/infra.md +2 -0
- package/commands/review-pr-staging.md +2 -0
- package/commands/work-on/build/context.md +7 -1
- package/commands/work-on/investigate.md +15 -3
- package/docs/CONFIG.md +21 -2
- package/docs/ISSUE-WATCH.md +22 -1
- package/docs/OUTCOME-CODES.md +2 -1
- package/package.json +1 -1
- package/scripts/file-deferred-followups.sh +125 -28
- package/scripts/file-deferred-followups.test.sh +49 -0
- package/scripts/issue-create.sh +7 -0
- package/scripts/issue-create.test.sh +13 -0
- package/scripts/issue-dedup.sh +5 -4
- package/scripts/issue-dedup.test.sh +15 -0
- package/scripts/owner-route-parity.test.sh +32 -0
- package/scripts/plan-validate-live.mjs +18 -6
- package/scripts/prior-attempts.mjs +72 -0
- package/scripts/prior-attempts.test.mjs +52 -0
- package/scripts/review-finalize.sh +35 -4
- package/scripts/review-finalize.test.sh +10 -0
- package/scripts/review-prepare.sh +16 -4
- package/scripts/review-prepare.test.sh +8 -0
- package/scripts/review-roster.sh +3 -1
- package/scripts/review-roster.test.sh +30 -2
- package/scripts/review-staging-pre.sh +8 -6
- package/scripts/review-staging-pre.test.sh +11 -1
package/bin/lean/execution.mjs
CHANGED
|
@@ -20,8 +20,10 @@
|
|
|
20
20
|
*
|
|
21
21
|
* `remote` (#4254, sandbox-remote.mjs) reaches a runner pool or remote session through a transport (`execution.endpoint`,
|
|
22
22
|
* credential `execution.token_env` from the env or the keychain). It is never a default, and a GitHub Actions endpoint
|
|
23
|
-
* needs `allow_actions: true`. A sandbox that fails to start is a typed outcome, not an engine-error: `
|
|
24
|
-
*
|
|
23
|
+
* needs `allow_actions: true`. A sandbox that fails to start is a typed outcome, not an engine-error: `deferred` `sandbox.capacity`
|
|
24
|
+
* when the provider marks the error retryable (429/503), `deferred` `sandbox.transient` for any other 5xx (#5095), `deferred`
|
|
25
|
+
* `sandbox.auth` for 401/403, else `needs-human` `sandbox.start_failed` (a 4xx config error). #5095: a sandbox that exits without
|
|
26
|
+
* a result while its log tail shows a usage limit ends `deferred` `rate_limited` held under `remoteHoldId` (never the breaker). A provider that
|
|
25
27
|
* sets `handle.minutes` (billable sandbox minutes) has them used as the lane's sandbox minutes (#4255 below).
|
|
26
28
|
*
|
|
27
29
|
* Sandbox cost and budget (#4255): a provider may implement `usage(handle, {startedAt, endedAt}) -> {minutes, cost_usd}`;
|
|
@@ -60,6 +62,7 @@ import { classifyStep, heavyConfig, memAvailableMb } from "./heavy.mjs";
|
|
|
60
62
|
import { detectPins } from "./toolchain.mjs";
|
|
61
63
|
import { parseContractComment, noComments } from "./render.mjs";
|
|
62
64
|
import { parseRelayCommands } from "./relay.mjs";
|
|
65
|
+
import { detectUsageLimit, parseSessionLimitResetEpochMs } from "../runner.mjs";
|
|
63
66
|
|
|
64
67
|
export const EXECUTION_MODES = Object.freeze(["local", "remote", "hybrid"]);
|
|
65
68
|
export const SANDBOX_PROVIDERS = Object.freeze(["local", "container", "remote", "pool"]);
|
|
@@ -128,8 +131,9 @@ export function parseExecution(sec = {}) {
|
|
|
128
131
|
const warmSandboxes = str(s.warm_sandboxes) !== "" && Number.isInteger(wsN) && wsN > 0 ? wsN : 0; // #4912: warm sandboxes the pool agent keeps ready; 0 = off
|
|
129
132
|
const relay = Object.fromEntries(Object.entries(parseRelayCommands(s.relay_commands)).filter(([c]) => CAPABILITY_RE.test(c))); // #4778
|
|
130
133
|
const uw = Number(str(s.unreachable_window_min));
|
|
134
|
+
const accountRotator = str(s.account_rotator) === "tokenbaton" ? "tokenbaton" : null; // #5094: rotator.mjs reads its per-account quota state
|
|
131
135
|
const unreachableWindowMin = str(s.unreachable_window_min) !== "" && Number.isFinite(uw) && uw >= 0 ? uw : null; // unset: providerLanes defaults to 5
|
|
132
|
-
return { mode, maxRemote, provider, image, secrets, budget, ...(hostCapabilities.length ? { hostCapabilities } : {}), ...(hostCapabilityPatterns.length ? { hostCapabilityPatterns } : {}), ...(endpoint ? { endpoint } : {}), ...(tokenEnv ? { tokenEnv } : {}), ...(cacheStore ? { cacheStore } : {}), ...(allowActions ? { allowActions } : {}), ...(fallbackLocal ? { fallbackLocal } : {}), ...(heavy !== null ? { heavy } : {}), ...(unreachableWindowMin !== null ? { unreachableWindowMin } : {}), ...(Object.keys(relay).length ? { relay } : {}), ...(warmSandboxes ? { warmSandboxes } : {}) };
|
|
136
|
+
return { mode, maxRemote, provider, image, secrets, budget, ...(hostCapabilities.length ? { hostCapabilities } : {}), ...(hostCapabilityPatterns.length ? { hostCapabilityPatterns } : {}), ...(endpoint ? { endpoint } : {}), ...(tokenEnv ? { tokenEnv } : {}), ...(cacheStore ? { cacheStore } : {}), ...(allowActions ? { allowActions } : {}), ...(fallbackLocal ? { fallbackLocal } : {}), ...(heavy !== null ? { heavy } : {}), ...(unreachableWindowMin !== null ? { unreachableWindowMin } : {}), ...(Object.keys(relay).length ? { relay } : {}), ...(warmSandboxes ? { warmSandboxes } : {}), ...(accountRotator ? { accountRotator } : {}) };
|
|
133
137
|
}
|
|
134
138
|
|
|
135
139
|
const round1 = (x) => Math.round(x * 10) / 10;
|
|
@@ -206,7 +210,7 @@ export function placement(exec) {
|
|
|
206
210
|
/** Host-only capabilities and the issue-text signals that mean a lane needs them (#4542). */
|
|
207
211
|
const CAPABILITY_SIGNALS = Object.freeze({
|
|
208
212
|
sops: /\.secrets\/[^\s`'")]*\.enc\.ya?ml|\bsops\b|\bage key\b|\.age\//i,
|
|
209
|
-
prod_monitor: /\bssh\s+[\w.-]*-monitor\b|\b(?:ovh|netcup)-monitor\b|\bobs-psql\b|\bprod(?:uction)? monitor/i,
|
|
213
|
+
prod_monitor: /\bssh\s+[\w.-]*-monitor\b|\b(?:ovh|netcup)-monitor\b|\bobs-psql\b|\bprod(?:uction)?[ _-]monitor|\bread[- ]only (?:prod(?:uction)? )?(?:prod(?:uction)? )?telemetry\b/i,
|
|
210
214
|
ops_credentials: /\bcredentials\.yaml\b|\bops[ _-]credentials\b/i,
|
|
211
215
|
});
|
|
212
216
|
|
|
@@ -225,7 +229,8 @@ export function detectNeeds(text, exec = null) {
|
|
|
225
229
|
|
|
226
230
|
/** Declared host capabilities a remote lane's block summary names (#4542); empty when it names none the host has. */
|
|
227
231
|
export function hostCapabilityBlock(summary, caps = null) {
|
|
228
|
-
|
|
232
|
+
// #5080: with no declared capabilities, the read-only prod monitor (the dispatching host's, never a secret) still counts
|
|
233
|
+
const have = new Set(caps?.hostCapabilities?.length ? caps.hostCapabilities : ["prod_monitor"]);
|
|
229
234
|
const named = detectNeeds(summary, caps);
|
|
230
235
|
return named.length && named.every((n) => have.has(n)) ? named : [];
|
|
231
236
|
}
|
|
@@ -531,17 +536,33 @@ export function providerByName(name, opts) {
|
|
|
531
536
|
throw e;
|
|
532
537
|
}
|
|
533
538
|
|
|
539
|
+
/** #5095: the usage-limit hold key of a remote provider's account (the pool's), known to the dispatcher before any lane runs. */
|
|
540
|
+
export const remoteHoldId = (provider) => `remote:${String(provider?.name ?? "remote")}`;
|
|
541
|
+
const TRANSIENT_RETRY_S = 60;
|
|
542
|
+
const LIMIT_RE = /usage limit hit; resets at ([^\n;]+)/i;
|
|
543
|
+
const RESETS_RE = /\bresets?\s+(?:at\s+)?(\d{1,2}(?::\d{2})?\s*[ap]m\s*\([^)]+\)|\d{1,2}:\d{2}\s*\([^)]+\))/i;
|
|
544
|
+
/** #5095: the usage-limit reset text in a lane log tail (the lean finish line, else the CLI's own limit message), or null. */
|
|
545
|
+
export function usageLimitInTail(tail) {
|
|
546
|
+
const t = String(tail ?? "");
|
|
547
|
+
const lean = LIMIT_RE.exec(t);
|
|
548
|
+
if (lean) return { resetAt: lean[1].trim() };
|
|
549
|
+
if (!detectUsageLimit("", t)) return null;
|
|
550
|
+
return { resetAt: RESETS_RE.exec(t)?.[1]?.trim() ?? null };
|
|
551
|
+
}
|
|
552
|
+
|
|
534
553
|
/**
|
|
535
554
|
* Lane seams for `orchestrate()` over a provider, the same contract as batch.mjs `detachedLanes`. `ledgerFor(issue)`
|
|
536
|
-
* returns that sandbox's ledger (ledger.mjs `makeLedger`).
|
|
555
|
+
* returns that sandbox's ledger (ledger.mjs `makeLedger`). #5095: with `holdId` every `rate_limited` result is held under it
|
|
556
|
+
* (`backendId`; the sandbox's own id kept as `laneBackendId`), so orchestrate can place lanes away from a limited pool.
|
|
537
557
|
*/
|
|
538
558
|
export function providerLanes({ provider, laneArgs, ledgerFor, env = process.env, secrets = [], pollMs = 5000, sleep = (ms) => new Promise((r) => setTimeout(r, ms)),
|
|
539
|
-
costPerMinute = 0, now = () => Date.now(), unreachableWindowMs = 300000, logTail = null }) {
|
|
559
|
+
costPerMinute = 0, now = () => Date.now(), unreachableWindowMs = 300000, logTail = null, holdId = null }) {
|
|
540
560
|
const cpm = nonNeg(Number(costPerMinute)) ?? 0;
|
|
541
561
|
// #4255: the sandbox's usage: the provider's `usage()`, else the billable minutes it put on the handle (#4254), else wall
|
|
542
562
|
// clock since the handle's startedAt; cost is the provider's, else minutes x cost_per_minute.
|
|
543
563
|
const withUsage = async (r, h) => {
|
|
544
564
|
if (!r || typeof r !== "object") return r;
|
|
565
|
+
if (holdId && r.code === OUTCOME_CODES.RATE_LIMITED) r = { ...r, backendId: holdId, ...(r.backendId && r.backendId !== holdId ? { laneBackendId: r.backendId } : {}) };
|
|
545
566
|
const endedAt = now(), startedAt = Number.isFinite(h?.startedAt) ? h.startedAt : endedAt;
|
|
546
567
|
let u = null;
|
|
547
568
|
try { u = typeof provider.usage === "function" ? await provider.usage(h, { startedAt, endedAt }) : null; } catch { u = null; }
|
|
@@ -585,7 +606,15 @@ export function providerLanes({ provider, laneArgs, ledgerFor, env = process.env
|
|
|
585
606
|
const last = await g(() => provider.fetchResult(h)); // it may have written the result just before exiting
|
|
586
607
|
if (last) return last;
|
|
587
608
|
const why = `sandbox ${h.id} ${st} without a result`;
|
|
588
|
-
|
|
609
|
+
const tail = await tailOf(h);
|
|
610
|
+
// #5095: the pool account hit its usage limit: a known-time wait (held, never the breaker), not an engine-error
|
|
611
|
+
const lim = usageLimitInTail(tail);
|
|
612
|
+
if (lim) {
|
|
613
|
+
const at = lim.resetAt ? parseSessionLimitResetEpochMs(lim.resetAt, now()) : undefined;
|
|
614
|
+
return { terminalReason: "deferred", code: OUTCOME_CODES.RATE_LIMITED, resetAt: lim.resetAt ?? "unknown", resetAtEpochMs: Number.isFinite(at) ? at : null,
|
|
615
|
+
detail: `${why}: usage limit hit; resets at ${lim.resetAt ?? "unknown"}` };
|
|
616
|
+
}
|
|
617
|
+
return isCloneFailure(tail) ? cloneDeferred(h.id, why) : { terminalReason: "engine-error", detail: why };
|
|
589
618
|
}
|
|
590
619
|
await sleep(pollMs);
|
|
591
620
|
}
|
|
@@ -596,6 +625,10 @@ export function providerLanes({ provider, laneArgs, ledgerFor, env = process.env
|
|
|
596
625
|
? { terminalReason: "deferred", code: OUTCOME_CODES.SANDBOX_AUTH, retryInSeconds: 300, detail: `sandbox ${id} could not start (auth): ${errText(e)}; ${e?.hop === "agent" ? "the pool agent refused the router (re-enroll or fix the agent token)" : "check the pool token (COMPUTESHARE_TOKEN or ~/.config/computeshare/forgedock.token)"}` }
|
|
597
626
|
: e?.retryable === true
|
|
598
627
|
? { terminalReason: "deferred", code: OUTCOME_CODES.SANDBOX_CAPACITY, detail: `sandbox ${id} could not start (capacity): ${errText(e)}`, ...(Number(e.retryInSeconds) > 0 ? { retryInSeconds: Number(e.retryInSeconds) } : {}) }
|
|
628
|
+
// #5095: a 5xx from the pool router or agent is infrastructure: deferred with backoff (capped), never needs-human
|
|
629
|
+
: (Number.isInteger(e?.status) && e.status >= 500 && e.status <= 599) || e?.transient === true
|
|
630
|
+
? { terminalReason: "deferred", code: OUTCOME_CODES.SANDBOX_TRANSIENT, retryInSeconds: Number(e.retryInSeconds) > 0 ? Number(e.retryInSeconds) : TRANSIENT_RETRY_S,
|
|
631
|
+
detail: `sandbox ${id} could not start (transient${Number.isInteger(e?.status) ? ` HTTP ${e.status}` : ""}): ${errText(e)}` }
|
|
599
632
|
: isCloneFailure(errText(e)) ? cloneDeferred(id, errText(e))
|
|
600
633
|
: { terminalReason: "needs-human", code: OUTCOME_CODES.SANDBOX_START_FAILED, detail: `sandbox ${id} failed to start: ${errText(e)}` });
|
|
601
634
|
return {
|
|
@@ -7,7 +7,7 @@ import { mkdirSync, writeFileSync, existsSync, readFileSync } from "node:fs";
|
|
|
7
7
|
import { join } from "node:path";
|
|
8
8
|
import { tmpDir } from "../tests/_tmp.mjs";
|
|
9
9
|
import { renderContract } from "./render.mjs";
|
|
10
|
-
import { parseExecution, SANDBOX_PROVIDERS, detectNeeds, hostCapabilityBlock, placementText, contractHostNeeds, placement, placeLane, validationWeight, hostHeadroom, scopedEnv, providerLanes, isTransportError, isCloneFailure, containerProvider, providerByName, openExecutionBudget } from "./execution.mjs";
|
|
10
|
+
import { parseExecution, SANDBOX_PROVIDERS, detectNeeds, hostCapabilityBlock, placementText, contractHostNeeds, placement, placeLane, validationWeight, hostHeadroom, scopedEnv, providerLanes, remoteHoldId, usageLimitInTail, isTransportError, isCloneFailure, containerProvider, providerByName, openExecutionBudget } from "./execution.mjs";
|
|
11
11
|
import { makeLedger, countLiveLanes } from "./ledger.mjs";
|
|
12
12
|
import { openBatch } from "./batch.mjs";
|
|
13
13
|
import { orchestrate, formatTable, formatSandboxSummary } from "./orchestrate.mjs";
|
|
@@ -25,7 +25,7 @@ function fakeProvider(opts = {}) {
|
|
|
25
25
|
async start({ issue, env, secrets }) {
|
|
26
26
|
p.calls.push(["start", issue]);
|
|
27
27
|
p.ledgerAtStart = opts.ledger?.entries("sandbox").map((e) => e.value) ?? null;
|
|
28
|
-
if (opts.startThrows) throw Object.assign(new Error("boom: no capacity"), opts.startThrows ===
|
|
28
|
+
if (opts.startThrows) throw Object.assign(new Error("boom: no capacity"), typeof opts.startThrows === "number" ? { status: opts.startThrows } : {});
|
|
29
29
|
p.live.add(issue);
|
|
30
30
|
p.env = env; p.secrets = secrets;
|
|
31
31
|
if (opts.finish) { const r = opts.finish(issue); if (r !== undefined) { p.results[issue] = r; p.live.delete(issue); } }
|
|
@@ -757,6 +757,14 @@ describe("host capabilities in placement (#4542)", () => {
|
|
|
757
757
|
assert.deepEqual(hostCapabilityBlock("owner must create an account", caps), []);
|
|
758
758
|
});
|
|
759
759
|
|
|
760
|
+
it("#5080 the read-only prod monitor is a host capability by its own name or as read-only production telemetry, declared or not", () => {
|
|
761
|
+
const text = "D1 is gated by contract decision K1 on read-only production telemetry: a host-only prod_monitor capability this sandbox lacks";
|
|
762
|
+
assert.deepEqual(detectNeeds(text), ["prod_monitor"]);
|
|
763
|
+
assert.deepEqual(hostCapabilityBlock(text, null), ["prod_monitor"]);
|
|
764
|
+
assert.deepEqual(hostCapabilityBlock(text, { hostCapabilities: ["sops"] }), []);
|
|
765
|
+
assert.deepEqual(hostCapabilityBlock("no sops key", null), []);
|
|
766
|
+
});
|
|
767
|
+
|
|
760
768
|
it("#4785 placementText: body, FORGE:DECISION comments and the last frozen contract's validate commands", () => {
|
|
761
769
|
const exec = parseExecution({ mode: "remote", max_remote: 2, host_capabilities: "promtool" });
|
|
762
770
|
const k = (cmd) => "<!-- FORGE:CONTRACT -->\n<!-- FORGE:PLAN -->\n" + renderContract({ requirements: [], deliverables: [], validate: [{ id: "V1", command: cmd }] }, "abcdef123456");
|
|
@@ -831,3 +839,65 @@ describe("host capabilities in placement (#4542)", () => {
|
|
|
831
839
|
assert.equal(rows.find((r) => r.issue === 5).outcome, "merged");
|
|
832
840
|
});
|
|
833
841
|
});
|
|
842
|
+
|
|
843
|
+
describe("#5095: launch 5xx and a pool usage limit are deferred, never needs-human or engine-error", () => {
|
|
844
|
+
const lanesFor = (prov, extra = {}) => providerLanes({ provider: prov, laneArgs: () => [], ledgerFor: ledgerIn(tmpDir("exec-5095-")), pollMs: 1, now: () => 1000, sleep: async () => {}, ...extra });
|
|
845
|
+
|
|
846
|
+
it("#5095 start HTTP 5xx (500/502/504): deferred with a retry delay, never needs-human", async () => {
|
|
847
|
+
for (const status of [500, 502, 504]) {
|
|
848
|
+
const r = await lanesFor(fakeProvider({ startThrows: status })).runOne(7, null);
|
|
849
|
+
assert.equal(r.terminalReason, "deferred", `HTTP ${status}`);
|
|
850
|
+
assert.equal(r.code, "sandbox.transient");
|
|
851
|
+
assert.equal(r.retryInSeconds, 60);
|
|
852
|
+
assert.match(r.detail, new RegExp(`HTTP ${status}`));
|
|
853
|
+
}
|
|
854
|
+
// a Retry-After the pool sent wins over the default delay
|
|
855
|
+
const prov = fakeProvider();
|
|
856
|
+
prov.start = async () => { throw Object.assign(new Error("sandbox launch: HTTP 500"), { status: 500, transient: true, retryInSeconds: 12 }); };
|
|
857
|
+
assert.equal((await lanesFor(prov).runOne(7, null)).retryInSeconds, 12);
|
|
858
|
+
});
|
|
859
|
+
|
|
860
|
+
it("a non-auth 4xx config error stays needs-human sandbox.start_failed; 429/503 stay sandbox.capacity", async () => {
|
|
861
|
+
const r = await lanesFor(fakeProvider({ startThrows: 422 })).runOne(7, null);
|
|
862
|
+
assert.deepEqual([r.terminalReason, r.code], ["needs-human", "sandbox.start_failed"]);
|
|
863
|
+
const prov = fakeProvider();
|
|
864
|
+
prov.start = async () => { throw Object.assign(new Error("sandbox launch: HTTP 503"), { status: 503, transient: true, retryable: true }); };
|
|
865
|
+
assert.equal((await lanesFor(prov).runOne(7, null)).code, "sandbox.capacity");
|
|
866
|
+
});
|
|
867
|
+
|
|
868
|
+
it("usageLimitInTail reads the lean finish line and the CLI's own limit message", () => {
|
|
869
|
+
assert.deepEqual(usageLimitInTail("x\n[lane] deferred: usage limit hit; resets at 3:20pm (UTC)\n"), { resetAt: "3:20pm (UTC)" });
|
|
870
|
+
assert.equal(usageLimitInTail("sandbox exited 1\nclone ok"), null);
|
|
871
|
+
assert.equal(usageLimitInTail(""), null);
|
|
872
|
+
});
|
|
873
|
+
|
|
874
|
+
it("a remote lane with no result and a usage limit in its log tail ends deferred rate_limited, held under the pool id, and never trips the breaker", async () => {
|
|
875
|
+
const prov = fakeProvider({ finish: () => null });
|
|
876
|
+
const holdId = remoteHoldId(prov);
|
|
877
|
+
assert.equal(holdId, "remote:fake");
|
|
878
|
+
const lanes = lanesFor(prov, { holdId, logTail: async () => "building...\nusage limit hit; resets at 3:20pm (UTC)\n" });
|
|
879
|
+
const r = await lanes.runOne(7, null);
|
|
880
|
+
assert.equal(r.terminalReason, "deferred");
|
|
881
|
+
assert.equal(r.code, "rate_limited");
|
|
882
|
+
assert.equal(r.resetAt, "3:20pm (UTC)");
|
|
883
|
+
assert.ok(Number.isFinite(r.resetAtEpochMs));
|
|
884
|
+
assert.equal(r.backendId, "remote:fake");
|
|
885
|
+
assert.ok(prov.calls.some((c) => c[0] === "teardown"));
|
|
886
|
+
// the same lane ends through orchestrate: a breaker of one engine-error never trips, the issue is requeued behind the hold
|
|
887
|
+
let t = 0, calls = 0;
|
|
888
|
+
const rows = await orchestrate({ issues: [7], parallel: 1, readBody: async () => "", now: () => t, sleep: async (ms) => { t += ms ?? 1; }, pollMs: 1,
|
|
889
|
+
breaker: { errors: 1, window_min: 30 }, identity: () => "local",
|
|
890
|
+
runOne: async () => (++calls === 1 ? { ...r, resetAtEpochMs: t + 60_000 } : { terminalReason: "merged", pr: 1 }) });
|
|
891
|
+
assert.ok(!rows.some((x) => x.outcome === "breaker"), "a usage limit is not an engine-error");
|
|
892
|
+
assert.equal(rows.find((x) => x.issue === 7).outcome, "merged");
|
|
893
|
+
});
|
|
894
|
+
|
|
895
|
+
it("a sandbox's own rate_limited result is held under the pool id, its own id kept as laneBackendId", async () => {
|
|
896
|
+
const prov = fakeProvider({ finish: () => ({ terminalReason: "deferred", code: "rate_limited", backendId: "cli:abc", resetAtEpochMs: 5000 }) });
|
|
897
|
+
const r = await lanesFor(prov, { holdId: remoteHoldId(prov) }).runOne(7, null);
|
|
898
|
+
assert.deepEqual([r.backendId, r.laneBackendId], ["remote:fake", "cli:abc"]);
|
|
899
|
+
// without a usage-limit signature the no-result path stays engine-error
|
|
900
|
+
const plain = await lanesFor(fakeProvider({ finish: () => null }), { logTail: async () => "exit 1" }).runOne(7, null);
|
|
901
|
+
assert.equal(plain.terminalReason, "engine-error");
|
|
902
|
+
});
|
|
903
|
+
});
|
package/bin/lean/integration.mjs
CHANGED
|
@@ -14,7 +14,7 @@
|
|
|
14
14
|
* or over N, until a green run.
|
|
15
15
|
* The workflow is dispatched on the milestone branch and awaited through runCiVerify (verify.mjs).
|
|
16
16
|
*/
|
|
17
|
-
import { milestoneSlug, readBranches, DEFAULT_FEATURE_PATTERN } from "./lane.mjs";
|
|
17
|
+
import { listMilestones, milestoneSlug, readBranches, DEFAULT_FEATURE_PATTERN } from "./lane.mjs";
|
|
18
18
|
import { readMilestone } from "./drift.mjs";
|
|
19
19
|
import { runCiVerify } from "./verify.mjs";
|
|
20
20
|
import { parseForgeYaml } from "../forge-utils.mjs";
|
|
@@ -174,7 +174,7 @@ export async function runMilestone({ io, repo, ms, cfg, pattern, nowFn = () => D
|
|
|
174
174
|
|
|
175
175
|
/** Every open milestone → {number, title, description}. */
|
|
176
176
|
export async function listOpenMilestones({ gh, repo }) {
|
|
177
|
-
return arr(
|
|
177
|
+
return arr(await listMilestones({ gh, repo, state: "open" })).map((m) => ({ number: m.number, title: m.title, description: m.description ?? "" }));
|
|
178
178
|
}
|
|
179
179
|
|
|
180
180
|
/** Run every due active milestone (optionally only `only`, a title). Sequential: one suite at a time. Resolves the per-milestone results. */
|
|
@@ -2,7 +2,7 @@
|
|
|
2
2
|
// SPDX-License-Identifier: AGPL-3.0-or-later
|
|
3
3
|
import { describe, it } from "node:test";
|
|
4
4
|
import assert from "node:assert/strict";
|
|
5
|
-
import { parseIntegration, parseIntegrationState, renderState, isDue, failingSpecs, runMilestone, runIntegration, makeIntegration, runIntegrationCli, emptyState } from "./integration.mjs";
|
|
5
|
+
import { parseIntegration, parseIntegrationState, renderState, isDue, failingSpecs, runMilestone, runIntegration, makeIntegration, runIntegrationCli, emptyState, listOpenMilestones } from "./integration.mjs";
|
|
6
6
|
import { orchestrate } from "./orchestrate.mjs";
|
|
7
7
|
|
|
8
8
|
const SHA = "a".repeat(40), SHA2 = "b".repeat(40);
|
|
@@ -187,3 +187,24 @@ describe("integration status", () => {
|
|
|
187
187
|
assert.equal(off.length, 1, "without ci.workflow only milestones with a state block");
|
|
188
188
|
});
|
|
189
189
|
});
|
|
190
|
+
|
|
191
|
+
/** #5084: a GitHub-like milestones endpoint: pages of 100; without --paginate only page 1; `--jq '.[] | @json'` prints one object per line. */
|
|
192
|
+
const pagedMilestones = (all) => (a) => {
|
|
193
|
+
const state = /state=(\w+)/.exec(a[1])?.[1] ?? "open";
|
|
194
|
+
const items = all.filter((m) => state === "all" || m.state === state);
|
|
195
|
+
const pages = []; for (let i = 0; i < items.length; i += 100) pages.push(items.slice(i, i + 100));
|
|
196
|
+
if (!a.includes("--paginate")) return JSON.stringify(pages[0] ?? []);
|
|
197
|
+
if (a.includes("--jq")) return items.map((m) => JSON.stringify(m)).join("\n") + "\n";
|
|
198
|
+
return pages.map((p) => JSON.stringify(p)).join("");
|
|
199
|
+
};
|
|
200
|
+
const manyMilestones = (target, n = 120) => [...Array.from({ length: n }, (_, i) => ({ number: i + 1, title: `M ${i + 1}`, state: i % 2 ? "open" : "closed", description: "" })), { number: n + 1, ...target }];
|
|
201
|
+
|
|
202
|
+
describe("listOpenMilestones pagination (#5084)", () => {
|
|
203
|
+
it("(#5084) listOpenMilestones returns open milestones past page 1", async () => {
|
|
204
|
+
const all = manyMilestones({ title: "Target", state: "open" }, 220).map((m) => ({ ...m, state: "open" }));
|
|
205
|
+
const gh = async (a) => pagedMilestones(all)(a);
|
|
206
|
+
const got = await listOpenMilestones({ gh, repo: "o/r" });
|
|
207
|
+
assert.equal(got.length, 221);
|
|
208
|
+
assert.deepEqual(got.at(-1), { number: 221, title: "Target", description: "" });
|
|
209
|
+
});
|
|
210
|
+
});
|
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);
|