forgedock 1.50.0 → 1.51.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 -0
- package/bin/engine-cli.mjs +90 -9
- package/bin/forgedock.mjs +30 -2
- package/bin/lean/batch-health.mjs +219 -0
- package/bin/lean/batch-health.test.mjs +156 -0
- package/bin/lean/batch.mjs +3 -3
- package/bin/lean/batch.test.mjs +236 -8
- package/bin/lean/conformance.mjs +50 -5
- package/bin/lean/conformance.test.mjs +39 -1
- package/bin/lean/context.mjs +8 -2
- package/bin/lean/context.test.mjs +11 -1
- package/bin/lean/credentials.mjs +18 -0
- package/bin/lean/execution.mjs +11 -6
- package/bin/lean/execution.test.mjs +28 -0
- package/bin/lean/fixclosereasons.mjs +52 -0
- package/bin/lean/fixclosereasons.test.mjs +64 -0
- package/bin/lean/lean.mjs +28 -17
- package/bin/lean/lean.test.mjs +74 -13
- package/bin/lean/orchestrate-blocked.test.mjs +47 -0
- package/bin/lean/orchestrate.mjs +124 -12
- package/bin/lean/prefilter.mjs +4 -3
- package/bin/lean/prefilter.test.mjs +5 -4
- package/bin/lean/prompts.mjs +4 -2
- package/bin/lean/recovery.test.mjs +5 -5
- package/bin/lean/sandbox-provider.contract.test.mjs +15 -1
- package/bin/pool/agent.mjs +3 -1
- package/bin/pool/router.mjs +72 -7
- package/bin/review-staging.mjs +2 -0
- package/bin/runner.mjs +24 -0
- package/commands/review-pr-staging.md +2 -2
- package/docs/CONFIG.md +39 -1
- package/docs/OUTCOME-CODES.md +2 -1
- package/package.json +1 -1
- package/pool/README.md +2 -0
- package/pool/image/cs-cred +8 -1
- package/scripts/review-staging-post.sh +70 -25
- package/scripts/review-staging-post.test.sh +40 -8
|
@@ -3,7 +3,7 @@
|
|
|
3
3
|
import { readFileSync } from "node:fs";
|
|
4
4
|
import { describe, it } from "node:test";
|
|
5
5
|
import assert from "node:assert/strict";
|
|
6
|
-
import { removedNamedPaths, removedPathsOf, shellSyntaxError, unrunnableValidate, noopCommand, substituteVerdict, describesUnrunnableCommand, vitestQuietFlag, normalizeRunnerCommand, cliUsageError, validateContract, applyAmendments, amendmentKindProblems, precheck, hotspotGrowth, reproFailures, isBugIssue, parseReview, checkCompleteness, decide, closePlan, isInvestigation, formatShadowSummary, followupItems, inIssue, isTestPath, normalizeEvidencePath, mergeReviewRows, reviewSkeleton, missingIds, resolveEvidencePath, movingBaseDiff, scopeText, evidencePathLine, evidencePathOnly, explainFailures, resolveForgedockPaths, missingTool, repoTracks, tmpdirInputs, isScopeCheck, isPureScopeCheck, dropScopeValidates, tagOutOfBand, outOfBandRequirements, isOutOfBand, followupMap, renderPendingFollowups, VERIFY_MODES, OUT_OF_BAND_RE, FOLLOWUP_CLASSES, followupClass, BUCKETS, bucketKind, bucketProblems, bucketTally, isE2eCommand, e2eVerified, impliedDocDeliverables, applyValidateRecipes, deferChecklessPostDeploy, substituteShellProblem, reproEnvMiss, ORCHESTRATOR_BUCKET, ownerRoute, weakenedE2eDecisions } from "./conformance.mjs";
|
|
6
|
+
import { removedNamedPaths, removedPathsOf, shellSyntaxError, unrunnableValidate, noopCommand, substituteVerdict, describesUnrunnableCommand, vitestQuietFlag, normalizeRunnerCommand, cliUsageError, validateContract, applyAmendments, amendmentKindProblems, precheck, hotspotGrowth, reproFailures, isBugIssue, parseReview, checkCompleteness, decide, closePlan, isInvestigation, formatShadowSummary, followupItems, inIssue, isTestPath, normalizeEvidencePath, mergeReviewRows, reviewSkeleton, missingIds, resolveEvidencePath, movingBaseDiff, scopeText, evidencePathLine, evidencePathOnly, reaskable, matchCriterion, criterionLines, fillEngineRows, explainFailures, resolveForgedockPaths, missingTool, repoTracks, tmpdirInputs, isScopeCheck, isPureScopeCheck, dropScopeValidates, tagOutOfBand, outOfBandRequirements, isOutOfBand, followupMap, renderPendingFollowups, VERIFY_MODES, OUT_OF_BAND_RE, FOLLOWUP_CLASSES, followupClass, BUCKETS, bucketKind, bucketProblems, bucketTally, isE2eCommand, e2eVerified, impliedDocDeliverables, applyValidateRecipes, deferChecklessPostDeploy, substituteShellProblem, reproEnvMiss, ORCHESTRATOR_BUCKET, ownerRoute, weakenedE2eDecisions } from "./conformance.mjs";
|
|
7
7
|
|
|
8
8
|
const ISSUE = "## Requirements\n1. The scrape route must release the spend reservation on 429.\n2. The search route must release the spend reservation on 429.\n\n- [ ] Billing pricing is unchanged.";
|
|
9
9
|
const Q1 = "The scrape route must release the spend reservation on 429.";
|
|
@@ -494,6 +494,38 @@ describe("conformance review: the engine computes the verdict from complete rows
|
|
|
494
494
|
it("incomplete: a coverage quote paraphrased instead of verbatim", () => {
|
|
495
495
|
assert.equal(decide({ ...good, issue_coverage: [{ quote: "scrape should release money on rate limits", status: "covered" }] }, ctx).state, "review.incomplete");
|
|
496
496
|
});
|
|
497
|
+
it("#5164 a near-verbatim coverage misquote is requoted to the criterion line and the review completes", () => {
|
|
498
|
+
const issue = "## Acceptance Criteria\n- [ ] Every manifest step kind from #38826 compiles to engine nodes, with a test per kind.\n- [ ] The scrape route must release the spend reservation on 429.";
|
|
499
|
+
const k = { ...C, requirements: [{ id: "R1", quote: "The scrape route must release the spend reservation on 429.", disposition: "deliver" }, { id: "R2", quote: Q2, disposition: "deliver" }] };
|
|
500
|
+
const quote = "Every manifest step kind from #38829 compiles to engine node";
|
|
501
|
+
const rv = { ...good, issue_coverage: [{ quote, contract_ids: ["R1"], status: "covered" }, { quote: "The scrape route must release the spend reservation on 429.", status: "covered" }] };
|
|
502
|
+
const d = decide(rv, { ...ctx, contract: k, issueText: `${issue}\n${ISSUE}` });
|
|
503
|
+
assert.equal(d.state, "conforms");
|
|
504
|
+
assert.equal(d.review.issue_coverage[0].quote, "Every manifest step kind from #38826 compiles to engine nodes, with a test per kind.");
|
|
505
|
+
assert.equal(d.review.issue_coverage[0].requoted, quote, "the substitution is recorded in the review");
|
|
506
|
+
});
|
|
507
|
+
it("#5164 matchCriterion: loose prefix or a unique token match; a paraphrase, an ambiguous or a short quote matches nothing", () => {
|
|
508
|
+
const t = "- [ ] Every manifest step kind from #38826 compiles to engine nodes, with a test per kind.\n- The scrape route must release the spend reservation on 429.\n- The search route must release the spend reservation on 429.";
|
|
509
|
+
assert.deepEqual(criterionLines(t)[0], "Every manifest step kind from #38826 compiles to engine nodes, with a test per kind.");
|
|
510
|
+
assert.equal(matchCriterion("every manifest step kind from #1 compiles to engine node with a test per kinds", t), criterionLines(t)[0], "prefix tier");
|
|
511
|
+
assert.equal(matchCriterion("The scrape route must release the spend reservations on 429", t), "The scrape route must release the spend reservation on 429.");
|
|
512
|
+
assert.equal(matchCriterion("route must release the spend reservation", t), null, "two lines tie");
|
|
513
|
+
assert.equal(matchCriterion("scrape should release money on rate limits", t), null);
|
|
514
|
+
assert.equal(matchCriterion("engine node", t), null);
|
|
515
|
+
});
|
|
516
|
+
it("#5164 a quote that matches no criterion still fails as incomplete, naming the nearest criterion line; it is re-askable, a real gap is not", () => {
|
|
517
|
+
const d = decide({ ...good, issue_coverage: [{ quote: "scrape should release money on rate limits", status: "covered" }] }, ctx);
|
|
518
|
+
assert.equal(d.state, "review.incomplete");
|
|
519
|
+
assert.equal(d.review.issue_coverage[0].requoted, undefined);
|
|
520
|
+
const problems = d.failures.map((f) => f.detail);
|
|
521
|
+
assert.match(problems[0], /nearest criterion line: "The scrape route must release/);
|
|
522
|
+
assert.ok(reaskable(problems));
|
|
523
|
+
assert.ok(reaskable(["R1: evidence path /layout.tsx does not exist"]), "#4327 evidence paths stay re-askable");
|
|
524
|
+
assert.ok(!reaskable([...problems, "requirement R1 needs exactly one row (has 0)"]));
|
|
525
|
+
assert.ok(!reaskable(["issue_coverage is missing or empty"]));
|
|
526
|
+
assert.ok(!reaskable([]));
|
|
527
|
+
assert.deepEqual(fillEngineRows(good, ctx).issue_coverage, good.issue_coverage, "verbatim rows are untouched");
|
|
528
|
+
});
|
|
497
529
|
it("omission: an issue requirement missing from or narrowed by the contract", () => {
|
|
498
530
|
const d = decide({ ...good, issue_coverage: [...good.issue_coverage.slice(0, 2), { quote: "Billing pricing is unchanged.", status: "missing" }] }, ctx);
|
|
499
531
|
assert.deepEqual(codes(d), ["conformance.omission"]);
|
|
@@ -1367,6 +1399,12 @@ describe("#5087 a plan decision may not weaken an e2e/integration criterion to m
|
|
|
1367
1399
|
assert.deepEqual(weakenedE2eDecisions({ ...C, decisions: [{ id: "K1", choice, source: "doc" }] }), [], choice);
|
|
1368
1400
|
}
|
|
1369
1401
|
});
|
|
1402
|
+
it("#5157 negated verbs and bare negators before a preposition still weaken e2e", () => {
|
|
1403
|
+
for (const choice of ["We do not need to run e2e; mocked route tests cover it", "will not rely on e2e, mock the API", "not with e2e: unit tests used", "not for e2e, unit tests used", "no need to run e2e; mocks cover it", "no need for e2e, mocks cover it"]) {
|
|
1404
|
+
assert.deepEqual(weakenedE2eDecisions({ ...C, decisions: [{ id: "K1", choice, source: "doc" }] }), ["K1"], choice);
|
|
1405
|
+
}
|
|
1406
|
+
assert.deepEqual(weakenedE2eDecisions({ ...C, decisions: [{ id: "K1", choice: "Keep playwright e2e coverage; add unit tests (no new fixtures needed for e2e)", source: "doc" }] }), []);
|
|
1407
|
+
});
|
|
1370
1408
|
it("leaves unrelated decisions and contracts without decisions alone", () => {
|
|
1371
1409
|
assert.deepEqual(weakenedE2eDecisions(C), []);
|
|
1372
1410
|
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" }] }), []);
|
package/bin/lean/context.mjs
CHANGED
|
@@ -212,9 +212,15 @@ const failuresOf = (st) => (Array.isArray(st?.failureClasses) ? [...new Set(st.f
|
|
|
212
212
|
const recordLine = (body) => oneLine(noComments(body).split("\n").map((l) => l.trim()).find((l) => l && !/^#+\s/.test(l)) ?? "");
|
|
213
213
|
const markerOf = (body) => /^\s*<!--\s*FORGE:([A-Z_]+)\s*-->/.exec(String(body ?? ""))?.[1] ?? null;
|
|
214
214
|
|
|
215
|
+
/**
|
|
216
|
+
* #5152: a closed record's result for the builder context. Delivered (`workflow:merged`) or moved (`workflow:decomposed`)
|
|
217
|
+
* scope reads `completed` whatever its `stateReason`, so a legacy NOT_PLANNED close of delivered work never reads as dropped.
|
|
218
|
+
*/
|
|
219
|
+
export const closedResult = (rec) => (["workflow:merged", "workflow:decomposed"].some((l) => (rec?.labels ?? []).includes(l)) ? "completed" : rec?.stateReason ?? "closed");
|
|
215
220
|
async function readIssue(gh, repo, n) {
|
|
216
221
|
const j = JSON.parse(String(await gh(["api", `repos/${repo}/issues/${n}`])));
|
|
217
|
-
return { number: Number(j.number ?? n), title: oneLine(j.title, 120), state: String(j.state ?? "").toUpperCase() || null, stateReason: j.state_reason ?? null, body: String(j.body ?? ""), pr: Boolean(j.pull_request)
|
|
222
|
+
return { number: Number(j.number ?? n), title: oneLine(j.title, 120), state: String(j.state ?? "").toUpperCase() || null, stateReason: j.state_reason ?? null, body: String(j.body ?? ""), pr: Boolean(j.pull_request),
|
|
223
|
+
labels: (Array.isArray(j.labels) ? j.labels : []).map((l) => String(l?.name ?? l)) };
|
|
218
224
|
}
|
|
219
225
|
async function readComments(gh, repo, n) {
|
|
220
226
|
return parseLines(await gh(["api", "--paginate", `repos/${repo}/issues/${n}/comments`, "--jq", ".[] | {id, body, author_association, user: {type: .user.type, login: .user.login}} | @json"]));
|
|
@@ -324,7 +330,7 @@ export async function gatherContextFacts({ io, repo, issue, iss, comments = [],
|
|
|
324
330
|
const st = stateOf(rec.body);
|
|
325
331
|
const reason = codeOf(st?.terminalReason), fails = failuresOf(st);
|
|
326
332
|
facts.links.push({ number: n, rel: r, kind: rec.pr ? "pr" : "issue", title: rec.title, state: rec.state, code: outcomeOf(st),
|
|
327
|
-
result: reason ?? (rec.state === "CLOSED" ? rec
|
|
333
|
+
result: reason ?? (rec.state === "CLOSED" ? closedResult(rec) : null), ...(fails.length ? { failures: fails } : {}), ...(r === "parent" || r === "depends-on" ? { body: stripComments(rec.body) } : {}) });
|
|
328
334
|
}
|
|
329
335
|
});
|
|
330
336
|
|
|
@@ -3,7 +3,7 @@
|
|
|
3
3
|
import { describe, it } from "node:test";
|
|
4
4
|
import assert from "node:assert/strict";
|
|
5
5
|
import { normaliseFacts, renderFactsRecord, FACTS_MARKER } from "./facts.mjs";
|
|
6
|
-
import { gatherContextFacts, compileContextPack, contextPackFor, contextArm, contextPackCacheKey, linkStateStamp, LINK_STAMP_MAX, parseContextPackConfig, laneType, priorAttempts, issueSymbols, bodyLinks, parentOf, renderContextRecord, DEFAULT_CONTEXT_BYTES, noComments, subjectTerms, subjectHistory, MAX_SUBJECT_ITEMS, MAX_SUBJECT_QUERIES } from "./context.mjs";
|
|
6
|
+
import { closedResult, gatherContextFacts, compileContextPack, contextPackFor, contextArm, contextPackCacheKey, linkStateStamp, LINK_STAMP_MAX, parseContextPackConfig, laneType, priorAttempts, issueSymbols, bodyLinks, parentOf, renderContextRecord, DEFAULT_CONTEXT_BYTES, noComments, subjectTerms, subjectHistory, MAX_SUBJECT_ITEMS, MAX_SUBJECT_QUERIES } from "./context.mjs";
|
|
7
7
|
|
|
8
8
|
const REPO = "o/r";
|
|
9
9
|
const BOT = { author_association: "NONE", user: { type: "Bot", login: "forge[bot]" } };
|
|
@@ -570,3 +570,13 @@ describe("#5085: subject history and recall status", () => {
|
|
|
570
570
|
assert.equal(compileContextPack({ issue: 1, links: [], attempts: [], ledger: [], facts: [], recall: absent.recall }).recall.present, false, "the empty pack carries it too");
|
|
571
571
|
});
|
|
572
572
|
});
|
|
573
|
+
|
|
574
|
+
describe("#5152 builder context: delivered or moved scope never reads as dropped", () => {
|
|
575
|
+
it("a closed workflow:merged or workflow:decomposed dependency is completed whatever its stateReason (legacy not_planned too)", () => {
|
|
576
|
+
assert.equal(closedResult({ stateReason: "not_planned", labels: ["workflow:merged"] }), "completed");
|
|
577
|
+
assert.equal(closedResult({ stateReason: "not_planned", labels: ["bug", "workflow:decomposed"] }), "completed");
|
|
578
|
+
assert.equal(closedResult({ stateReason: "not_planned", labels: ["workflow:invalid"] }), "not_planned", "dropped scope keeps its reason");
|
|
579
|
+
assert.equal(closedResult({ stateReason: "completed", labels: [] }), "completed");
|
|
580
|
+
assert.equal(closedResult({ stateReason: null }), "closed");
|
|
581
|
+
});
|
|
582
|
+
});
|
package/bin/lean/credentials.mjs
CHANGED
|
@@ -27,3 +27,21 @@ export function resolveCredential(name, { env = process.env, keychain = defaultK
|
|
|
27
27
|
if (v !== undefined && v !== null && String(v) !== "") return String(v);
|
|
28
28
|
try { const k = keychain(String(name), platform); return k === undefined || k === null || String(k) === "" ? null : String(k); } catch { return null; }
|
|
29
29
|
}
|
|
30
|
+
|
|
31
|
+
/**
|
|
32
|
+
* #5159: a keychain lookup memoised per name for `ttlMs` (a miss or a failure is cached as null too), so a resolver run
|
|
33
|
+
* on every request (#5144) spawns `secret-tool`/`security` at most once per TTL instead of on every HTTP call.
|
|
34
|
+
*/
|
|
35
|
+
export function memoKeychain({ keychain = defaultKeychain, ttlMs = 60000, now = Date.now } = {}) {
|
|
36
|
+
const cache = new Map();
|
|
37
|
+
return (name, platform) => {
|
|
38
|
+
const key = `${platform ?? ""}\u0000${name}`;
|
|
39
|
+
const hit = cache.get(key);
|
|
40
|
+
const t = now();
|
|
41
|
+
if (hit && t - hit.at < ttlMs) return hit.value;
|
|
42
|
+
let value = null;
|
|
43
|
+
try { const k = keychain(name, platform); value = k === undefined || k === null || String(k) === "" ? null : String(k); } catch { value = null; }
|
|
44
|
+
cache.set(key, { at: t, value });
|
|
45
|
+
return value;
|
|
46
|
+
};
|
|
47
|
+
}
|
package/bin/lean/execution.mjs
CHANGED
|
@@ -58,7 +58,7 @@ import { isLaneAlive, launchLane, writeAtomic } from "./batch.mjs";
|
|
|
58
58
|
import { schedDir } from "./sched.mjs";
|
|
59
59
|
import { dockerLabels, safeSegment } from "./ledger.mjs";
|
|
60
60
|
import { OUTCOME_CODES } from "../engine/outcome-codes.mjs";
|
|
61
|
-
import { resolveCredential } from "./credentials.mjs";
|
|
61
|
+
import { resolveCredential, memoKeychain, defaultKeychain } from "./credentials.mjs";
|
|
62
62
|
import { httpTransport, isActionsEndpoint, remoteProvider } from "./sandbox-remote.mjs";
|
|
63
63
|
import { ADMISSION_DEFAULTS } from "./admission.mjs";
|
|
64
64
|
import { classifyStep, heavyConfig, memAvailableMb } from "./heavy.mjs";
|
|
@@ -515,10 +515,11 @@ export function poolCredential(exec = {}, { env = process.env, router = null, ke
|
|
|
515
515
|
/**
|
|
516
516
|
* The remote transport for forge.yaml `execution` settings: `opts.transport` when injected, else `httpTransport` on
|
|
517
517
|
* `exec.endpoint` with the credential `exec.tokenEnv` from `credentials`, or by default poolCredential (#5131: env,
|
|
518
|
-
* keychain, `token_file`, the matching local router's token
|
|
518
|
+
* keychain, `token_file`, the matching local router's token; #5159: the keychain lookup is memoised per transport, `keychain`
|
|
519
|
+
* is its seam). `fetchFn` is a test seam. Throws `execution.provider.misconfigured`
|
|
519
520
|
* without an endpoint, `execution.provider.actions_refused` for a GitHub Actions endpoint without `allow_actions`.
|
|
520
521
|
*/
|
|
521
|
-
export function remoteTransportFor(exec = {}, { transport = null, env = process.env, credentials = null, router, fetchFn } = {}) {
|
|
522
|
+
export function remoteTransportFor(exec = {}, { transport = null, env = process.env, credentials = null, router, fetchFn, keychain } = {}) {
|
|
522
523
|
if (transport) return transport;
|
|
523
524
|
if (!exec?.endpoint) throw typedError("execution.provider.misconfigured", "execution.provider remote needs execution.endpoint");
|
|
524
525
|
if (isActionsEndpoint(exec.endpoint) && exec.allowActions !== true) {
|
|
@@ -526,8 +527,10 @@ export function remoteTransportFor(exec = {}, { transport = null, env = process.
|
|
|
526
527
|
}
|
|
527
528
|
// #5144: a getter, so the bearer is resolved on every request (a token fixed or rotated mid-batch is picked up), not once here.
|
|
528
529
|
const rt = credentials ? null : (router !== undefined ? router : poolRouterConfig({ env }));
|
|
530
|
+
// #5159: the env and token files are re-read per request; the sync keychain lookup is memoised (misses too) for its TTL.
|
|
531
|
+
const kc = credentials ? null : memoKeychain({ keychain: keychain ?? defaultKeychain });
|
|
529
532
|
const token = credentials ? () => (exec.tokenEnv ? credentials(exec.tokenEnv) : null)
|
|
530
|
-
: () => poolCredential({ provider: "remote", ...exec }, { env, router: rt }).token;
|
|
533
|
+
: () => poolCredential({ provider: "remote", ...exec }, { env, router: rt, keychain: kc }).token;
|
|
531
534
|
return httpTransport({ endpoint: exec.endpoint, token, ...(fetchFn ? { fetchFn } : {}) });
|
|
532
535
|
}
|
|
533
536
|
|
|
@@ -565,13 +568,15 @@ export function providerByName(name, opts) {
|
|
|
565
568
|
const c = poolRouterConfig({ env, ...(opts?.poolConfigPath ? { path: opts.poolConfigPath } : {}) });
|
|
566
569
|
return c ? { endpoint: router?.endpoint ?? c.endpoint, token: c.token } : router;
|
|
567
570
|
};
|
|
568
|
-
|
|
571
|
+
// #5159: one memoised keychain per provider, so a `token_env` absent from the env does not spawn the keychain per request.
|
|
572
|
+
const keychain = memoKeychain({ keychain: opts?.keychain ?? defaultKeychain });
|
|
573
|
+
const resolve = () => poolCredential({ ...exec, provider: name, ...(endpoint ? { endpoint } : {}) }, { env, router: routerNow(), keychain });
|
|
569
574
|
// A token read from a file is resolved under a fixed name so remoteProvider redacts it like any `token_env` credential.
|
|
570
575
|
// #5144: set even when no token exists yet, so one that appears later is still resolved per call (and redacted).
|
|
571
576
|
const fixed = pool ? "FORGEDOCK_POOL_TOKEN" : "FORGEDOCK_REMOTE_TOKEN";
|
|
572
577
|
const tokenEnv = exec.tokenEnv ?? fixed;
|
|
573
578
|
// #4685/#5131: an empty env/keychain lookup of `token_env` falls back to `token_file`, then the (matching) router's token file.
|
|
574
|
-
const credentials = (n) => (n === tokenEnv ? resolve().token : resolveCredential(n, { env })); // per call,
|
|
579
|
+
const credentials = (n) => (n === tokenEnv ? resolve().token : resolveCredential(n, { env, keychain })); // per call, keychain memoised (#5159)
|
|
575
580
|
const settings = { ...exec, ...(endpoint ? { endpoint } : {}), ...(tokenEnv ? { tokenEnv } : {}), ...(forgeYaml ? { forgeYaml } : {}),
|
|
576
581
|
...(typeof opts?.prefetch === "function" ? { prefetch: opts.prefetch } : {}) }; // #4778: dispatch-time relay pre-fetch
|
|
577
582
|
return remoteProvider({ batch: opts.batch, repo: opts.repo, settings, ...(pool ? { name: "pool" } : {}), env, credentials,
|
|
@@ -1025,6 +1025,34 @@ describe("remote/pool credential resolver (#5131)", () => {
|
|
|
1025
1025
|
assert.equal(hit.seen.at(-1).auth, "Bearer env-late-2");
|
|
1026
1026
|
});
|
|
1027
1027
|
|
|
1028
|
+
it("5159: a token_env absent from the env runs the keychain once, not on every request; the token_file bearer and a later env token still apply", async () => {
|
|
1029
|
+
const dir = tmpDir("kc-5159-");
|
|
1030
|
+
const file = join(dir, "explicit.token");
|
|
1031
|
+
writeFileSync(file, "file-tok\n");
|
|
1032
|
+
const hit = recorder();
|
|
1033
|
+
let calls = 0;
|
|
1034
|
+
const keychain = () => { calls++; return null; };
|
|
1035
|
+
const env = {};
|
|
1036
|
+
const p = providerByName("remote", { batch: { id: "b5159" }, repo: "o/r", exec: parseExecution({ mode: "remote", max_remote: "1", provider: "remote", endpoint: "https://runner.example.com", token_env: "COMPUTESHARE_TOKEN", token_file: file }),
|
|
1037
|
+
env, poolConfigPath: join(dir, "none.json"), fetchFn: hit.fetchFn, keychain });
|
|
1038
|
+
for (let i = 0; i < 4; i++) await p.capacity();
|
|
1039
|
+
assert.ok(hit.seen.length >= 4);
|
|
1040
|
+
assert.ok(hit.seen.every((x) => x.auth === "Bearer file-tok"), "every request carries the token_file bearer");
|
|
1041
|
+
assert.ok(calls <= 1, `keychain ran ${calls} times`);
|
|
1042
|
+
env.COMPUTESHARE_TOKEN = "env-late";
|
|
1043
|
+
await p.capacity();
|
|
1044
|
+
assert.equal(hit.seen.at(-1).auth, "Bearer env-late");
|
|
1045
|
+
let tcalls = 0;
|
|
1046
|
+
const tenv = {};
|
|
1047
|
+
const t = remoteTransportFor({ endpoint: "https://runner.example.com", tokenEnv: "COMPUTESHARE_TOKEN", tokenFile: file }, { env: tenv, router: null, fetchFn: hit.fetchFn, keychain: () => { tcalls++; return null; } });
|
|
1048
|
+
for (let i = 0; i < 4; i++) await t.capacity();
|
|
1049
|
+
assert.equal(hit.seen.at(-1).auth, "Bearer file-tok");
|
|
1050
|
+
assert.equal(tcalls, 1, "remoteTransportFor: one keychain lookup across requests");
|
|
1051
|
+
tenv.COMPUTESHARE_TOKEN = "env-late-2";
|
|
1052
|
+
await t.capacity();
|
|
1053
|
+
assert.equal(hit.seen.at(-1).auth, "Bearer env-late-2");
|
|
1054
|
+
});
|
|
1055
|
+
|
|
1028
1056
|
it("5131: remoteTransportFor's default credentials (gc) fall back to the matching router token", async () => {
|
|
1029
1057
|
const hit = recorder();
|
|
1030
1058
|
const t = remoteTransportFor({ endpoint: "http://127.0.0.1:7710" }, { env: {}, router: { endpoint: "http://127.0.0.1:7710", token: "router-tok" }, fetchFn: hit.fetchFn });
|
|
@@ -0,0 +1,52 @@
|
|
|
1
|
+
// SPDX-FileCopyrightText: Copyright (c) RapierCraft Studios
|
|
2
|
+
// SPDX-License-Identifier: AGPL-3.0-or-later
|
|
3
|
+
/**
|
|
4
|
+
* `forgedock fix-close-reasons <owner/repo> [--milestone <m>] [--apply]` (#5177, deferred from #5152). No LLM.
|
|
5
|
+
* Migration helper: an issue closed `not planned` that carries `workflow:merged` or `workflow:decomposed` was delivered
|
|
6
|
+
* (or moved to sub-issues), not dropped. It is re-closed as `completed` with a comment. One PATCH changes the reason in place
|
|
7
|
+
* (no reopen window). A `workflow:invalid` issue is a real drop and is never touched. Dry-run unless `--apply`.
|
|
8
|
+
*/
|
|
9
|
+
const DELIVERED = ["workflow:merged", "workflow:decomposed"];
|
|
10
|
+
const isNotPlanned = (r) => String(r ?? "").toUpperCase().replace(/\s/g, "_") === "NOT_PLANNED";
|
|
11
|
+
|
|
12
|
+
const commentFor = (label) => label === "workflow:decomposed"
|
|
13
|
+
? "Close reason corrected to `completed`: this issue was decomposed and its scope moved to its sub-issues, it was not dropped (`workflow:decomposed`). Re-closed by `forgedock fix-close-reasons` (#5177)."
|
|
14
|
+
: "Close reason corrected to `completed`: the work was delivered and merged, it was not dropped (`workflow:merged`). Re-closed by `forgedock fix-close-reasons` (#5177).";
|
|
15
|
+
|
|
16
|
+
/** Closed `not planned` issues that were delivered: [{number, label}], by issue number, once each. */
|
|
17
|
+
export async function findMisclosed({ gh, repo, milestone }) {
|
|
18
|
+
const found = new Map();
|
|
19
|
+
for (const label of DELIVERED) {
|
|
20
|
+
const out = await gh(["issue", "list", "-R", repo, "--state", "closed", "--label", label, ...(milestone ? ["--milestone", milestone] : []), "--limit", "1000", "--json", "number,stateReason,labels"]);
|
|
21
|
+
for (const i of JSON.parse(String(out || "[]"))) {
|
|
22
|
+
const names = (i.labels ?? []).map((l) => (typeof l === "string" ? l : l.name));
|
|
23
|
+
if (!isNotPlanned(i.stateReason) || names.includes("workflow:invalid") || found.has(i.number)) continue;
|
|
24
|
+
found.set(i.number, { number: i.number, label });
|
|
25
|
+
}
|
|
26
|
+
}
|
|
27
|
+
return [...found.values()].sort((a, b) => a.number - b.number);
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
export async function runFixCloseReasonsCli(argv, deps = {}) {
|
|
31
|
+
const val = (n) => { const i = argv.indexOf(n); return i >= 0 ? argv[i + 1] : null; };
|
|
32
|
+
const repo = argv[0] && !argv[0].startsWith("--") ? argv[0] : (val("--repo") ?? deps.repo ?? null);
|
|
33
|
+
if (!repo || !/^[^/\s]+\/[^/\s]+$/.test(repo)) throw new Error("usage: forgedock fix-close-reasons <owner/repo> [--milestone <title|number>] [--apply]");
|
|
34
|
+
const milestone = val("--milestone");
|
|
35
|
+
if (argv.includes("--milestone") && (!milestone || milestone.startsWith("--"))) throw new Error("--milestone requires a milestone title or number");
|
|
36
|
+
const apply = argv.includes("--apply");
|
|
37
|
+
const gh = deps.gh ?? (await import("./effects.mjs")).makeLeanIo({ repo }).gh;
|
|
38
|
+
const print = deps.print ?? ((t) => console.log(t));
|
|
39
|
+
const items = await findMisclosed({ gh, repo, milestone });
|
|
40
|
+
let changed = 0, failed = 0;
|
|
41
|
+
for (const { number, label } of items) {
|
|
42
|
+
if (!apply) { print(`would re-close #${number} as completed (${label})`); continue; }
|
|
43
|
+
try {
|
|
44
|
+
await gh(["api", "-X", "PATCH", `repos/${repo}/issues/${number}`, "-f", "state=closed", "-f", "state_reason=completed"]);
|
|
45
|
+
await gh(["issue", "comment", String(number), "-R", repo, "--body", commentFor(label)]);
|
|
46
|
+
changed++;
|
|
47
|
+
print(`re-closed #${number} as completed (${label})`);
|
|
48
|
+
} catch (e) { failed++; print(`#${number}: failed: ${String(e?.message ?? e).slice(0, 160)}`); }
|
|
49
|
+
}
|
|
50
|
+
print(`fix-close-reasons: ${items.length} found, ${changed} changed, ${failed} failed${apply ? "" : " (dry run, pass --apply to write)"}`);
|
|
51
|
+
return failed ? 1 : 0;
|
|
52
|
+
}
|
|
@@ -0,0 +1,64 @@
|
|
|
1
|
+
// SPDX-FileCopyrightText: Copyright (c) RapierCraft Studios
|
|
2
|
+
// SPDX-License-Identifier: AGPL-3.0-or-later
|
|
3
|
+
import assert from "node:assert/strict";
|
|
4
|
+
import { describe, it } from "node:test";
|
|
5
|
+
import { runFixCloseReasonsCli } from "./fixclosereasons.mjs";
|
|
6
|
+
|
|
7
|
+
const L = (...n) => n.map((name) => ({ name }));
|
|
8
|
+
function fake({ lists, failOn } = {}) {
|
|
9
|
+
const calls = [], out = [];
|
|
10
|
+
const gh = async (a) => {
|
|
11
|
+
calls.push(a);
|
|
12
|
+
if (a[0] === "issue" && a[1] === "list") return JSON.stringify(lists[a[a.indexOf("--label") + 1]] ?? []);
|
|
13
|
+
if (a[0] === "api" && failOn === a[3].split("/").pop()) throw new Error("boom");
|
|
14
|
+
return "";
|
|
15
|
+
};
|
|
16
|
+
return { gh, calls, out, print: (t) => out.push(t) };
|
|
17
|
+
}
|
|
18
|
+
const lists = {
|
|
19
|
+
"workflow:merged": [
|
|
20
|
+
{ number: 1, stateReason: "NOT_PLANNED", labels: L("workflow:merged") },
|
|
21
|
+
{ number: 2, stateReason: "COMPLETED", labels: L("workflow:merged") },
|
|
22
|
+
{ number: 3, stateReason: "NOT_PLANNED", labels: L("workflow:merged", "workflow:invalid") },
|
|
23
|
+
],
|
|
24
|
+
"workflow:decomposed": [
|
|
25
|
+
{ number: 4, stateReason: "NOT_PLANNED", labels: L("workflow:decomposed") },
|
|
26
|
+
{ number: 1, stateReason: "NOT_PLANNED", labels: L("workflow:merged", "workflow:decomposed") },
|
|
27
|
+
],
|
|
28
|
+
};
|
|
29
|
+
|
|
30
|
+
describe("#5177 fix-close-reasons", () => {
|
|
31
|
+
it("re-closes NOT_PLANNED merged and decomposed issues as completed with a comment; leaves the rest", async () => {
|
|
32
|
+
const f = fake({ lists });
|
|
33
|
+
assert.equal(await runFixCloseReasonsCli(["o/r", "--apply"], f), 0);
|
|
34
|
+
const patches = f.calls.filter((a) => a[0] === "api");
|
|
35
|
+
assert.deepEqual(patches.map((a) => a[3]), ["repos/o/r/issues/1", "repos/o/r/issues/4"]);
|
|
36
|
+
for (const p of patches) assert.deepEqual(p.slice(0, 3).concat(p.slice(4)), ["api", "-X", "PATCH", "-f", "state=closed", "-f", "state_reason=completed"]);
|
|
37
|
+
const comments = f.calls.filter((a) => a[1] === "comment");
|
|
38
|
+
assert.deepEqual(comments.map((a) => a[2]), ["1", "4"]);
|
|
39
|
+
assert.match(comments[0][comments[0].indexOf("--body") + 1], /delivered and merged/);
|
|
40
|
+
assert.match(comments[1][comments[1].indexOf("--body") + 1], /decomposed/);
|
|
41
|
+
assert.match(f.out.at(-1), /2 found, 2 changed, 0 failed/);
|
|
42
|
+
});
|
|
43
|
+
it("is a dry run without --apply: no write", async () => {
|
|
44
|
+
const f = fake({ lists });
|
|
45
|
+
assert.equal(await runFixCloseReasonsCli(["o/r"], f), 0);
|
|
46
|
+
assert.equal(f.calls.filter((a) => a[0] !== "issue" || a[1] !== "list").length, 0);
|
|
47
|
+
assert.match(f.out.join("\n"), /would re-close #1/);
|
|
48
|
+
});
|
|
49
|
+
it("--milestone narrows the list query", async () => {
|
|
50
|
+
const f = fake({ lists: {} });
|
|
51
|
+
await runFixCloseReasonsCli(["o/r", "--milestone", "M1"], f);
|
|
52
|
+
for (const c of f.calls) assert.equal(c[c.indexOf("--milestone") + 1], "M1");
|
|
53
|
+
assert.equal(f.calls.length, 2);
|
|
54
|
+
});
|
|
55
|
+
it("counts a failed write and exits 1, continuing with the next issue", async () => {
|
|
56
|
+
const f = fake({ lists, failOn: "1" });
|
|
57
|
+
assert.equal(await runFixCloseReasonsCli(["o/r", "--apply"], f), 1);
|
|
58
|
+
assert.match(f.out.at(-1), /2 found, 1 changed, 1 failed/);
|
|
59
|
+
});
|
|
60
|
+
it("throws usage without a repo", async () => {
|
|
61
|
+
await assert.rejects(runFixCloseReasonsCli([], fake()), /usage: forgedock fix-close-reasons/);
|
|
62
|
+
await assert.rejects(runFixCloseReasonsCli(["--apply"], fake()), /usage/);
|
|
63
|
+
});
|
|
64
|
+
});
|
package/bin/lean/lean.mjs
CHANGED
|
@@ -15,7 +15,7 @@ import { ciPollDelayMs, ENGINE_VERSION, DEFAULT_MAX_SESSION_LIMIT_PAUSES, DEFAUL
|
|
|
15
15
|
import { openDependency, laneMergedPrs, dependencyOnLane, isTrustedComment, decomposedSubissues, isDecomposedRecord, reopenRevokedDecomposition } from "../engine/phases.mjs";
|
|
16
16
|
import { createHash, randomBytes } from "node:crypto";
|
|
17
17
|
import { otherLaneOwner } from "./lanelock.mjs";
|
|
18
|
-
import { applyValidateRecipes, deferChecklessPostDeploy, reproEnvMiss, resolveForgedockPaths, describesUnrunnableCommand, removedPathsOf, TRACKED, normalizeKindModes, blockingFailures, unverifiedRequirements, mutationTargets, mutationFailures, runMutationCheck, MUTATION_SURVIVED, isBugIssue, scopeText, explainFailures, inIssue, validateContract, cliUsageError, normalizeRunnerCommand, applyAmendments, amendmentKindProblems, precheck, alreadyFixedOnBase, READONLY_PROD_RE, EXTERNAL_NEED_RE, repoTracks, dropScopeValidates, tagOutOfBand, parseReview, decide, mergeReviewRows,
|
|
18
|
+
import { applyValidateRecipes, deferChecklessPostDeploy, reproEnvMiss, resolveForgedockPaths, describesUnrunnableCommand, removedPathsOf, TRACKED, normalizeKindModes, blockingFailures, unverifiedRequirements, mutationTargets, mutationFailures, runMutationCheck, MUTATION_SURVIVED, isBugIssue, scopeText, explainFailures, inIssue, validateContract, cliUsageError, normalizeRunnerCommand, applyAmendments, amendmentKindProblems, precheck, alreadyFixedOnBase, READONLY_PROD_RE, EXTERNAL_NEED_RE, repoTracks, dropScopeValidates, tagOutOfBand, parseReview, decide, mergeReviewRows, reaskable, criterionLines, closePlan, followupItems, renderPendingFollowups, bucketTally, pendingTracking, unnamedEdges, structureNote, impliedDocDeliverables } from "./conformance.mjs";
|
|
19
19
|
import { checkAdmission, hostParallelism, ADMISSION_RETRY_SECONDS } from "./admission.mjs";
|
|
20
20
|
import { namedPaths, isTrustedIssueComment } from "./prefilter.mjs";
|
|
21
21
|
import { checksAt } from "./postdeploy-check.mjs";
|
|
@@ -91,6 +91,8 @@ export const RECOVER_HOW = Object.freeze({
|
|
|
91
91
|
[OUTCOME_CODES.CONFORMANCE_UNJUSTIFIED_DEFERRAL]: "an earlier run deferred a requirement without a sufficient why after one builder resume; the branch holds its work. For each named gap either deliver the requirement (adding any needed file as an `add-deliverable` amendment) or, when the clause is optional, unquantified or outside the issue's acceptance criteria, record a `defer` amendment with a concrete why so the engine files a tracked follow-up instead of stopping",
|
|
92
92
|
[OUTCOME_CODES.ENV_REPRO_UNRUNNABLE]: "the repro harness could not run the contract's repro_test on head (timeout, missing runner or dependencies); the change may already be correct. Do not edit the deliverables or stub dependencies for it and never install anything globally: run the repro command through the repo's own toolchain and installed dependencies, and when it cannot run in this checkout, fix the repro_test command (not the code) so it does",
|
|
93
93
|
});
|
|
94
|
+
/** #5168: retry-in for a `backend.auth` deferral; the batch holds the account for it (an operator `--set-env` fix runs under a new identity). */
|
|
95
|
+
const AUTH_RETRY_SECONDS = 900;
|
|
94
96
|
/** #4323/#4334: precheck failure codes that are environment errors (state "environment"), never a builder resume. */
|
|
95
97
|
const ENV_CODES = Object.freeze([OUTCOME_CODES.ENV_TOOL_MISSING, OUTCOME_CODES.ENV_REPRO_UNRUNNABLE, ENV_VALIDATE_TIMEOUT, OUTCOME_CODES.CONTRACT_INVALID_COMMAND]);
|
|
96
98
|
export const DEFAULT_REVIEWER_MODEL = "claude-sonnet-5-5";
|
|
@@ -488,7 +490,7 @@ export async function runCore(o) {
|
|
|
488
490
|
}
|
|
489
491
|
if (["merged", "invalid", "needs-human", "budget-exceeded"].includes(reason)) {
|
|
490
492
|
if (reason === "budget-exceeded") T.close = ["left open", "recoverable budget.exceeded"]; // #4505: not a human escalation
|
|
491
|
-
else if (reason === "merged") T.close = [extra.close === "partial" ? "issue closed (partial)" : "issue closed (completed)", "label workflow:merged"];
|
|
493
|
+
else if (reason === "merged") T.close = [extra.keptOpen ? "left open (follow-up pending)" : extra.close === "partial" ? "issue closed (partial)" : "issue closed (completed)", "label workflow:merged"]; // #5159
|
|
492
494
|
else if (reason === "invalid" && extra.close === "already-fixed") T.close = ["issue closed (completed)", "already fixed on the base"]; // #5080
|
|
493
495
|
else if (reason === "invalid") T.close = ["issue closed (not planned)", "label workflow:invalid"];
|
|
494
496
|
else { T.close = ["left open", "label needs-human"]; T.anomalies.push(`needs-human: ${String(detail).split("\n")[0].slice(0, 300)}`); }
|
|
@@ -636,6 +638,9 @@ export async function runCore(o) {
|
|
|
636
638
|
const par = verifyParallelism();
|
|
637
639
|
return { ...deps, ...(runTmp ? { TMPDIR: runTmp } : {}), ...(par ? { FORGE_HOST_PARALLELISM: par } : {}), ...(toolchain?.env ?? {}) };
|
|
638
640
|
}
|
|
641
|
+
/** #5168: a failed claude login ends the lane `deferred` `backend.auth`, held per account by the batch (never the relaunch or the breaker). */
|
|
642
|
+
const authDeferral = (e) => Object.assign(e, { deferCode: OUTCOME_CODES.BACKEND_AUTH, retryInSeconds: AUTH_RETRY_SECONDS, backendId: laneBackendId(process.env, rotator),
|
|
643
|
+
deferDetail: `claude CLI authentication failed (login expired or invalid); held until the login is fixed: ${String(e?.message ?? e).split("\n")[0].slice(0, 300)}` });
|
|
639
644
|
/** Runner call with the per-run TMPDIR and FORGE_HOST_PARALLELISM; a usage limit pauses and resumes the same step, or defers past the cap (#4101). */
|
|
640
645
|
async function callRunner(args) {
|
|
641
646
|
let pauses = 0, rotations = 0;
|
|
@@ -649,6 +654,7 @@ export async function runCore(o) {
|
|
|
649
654
|
try { return await io.runner({ ...args, system, env: { ...laneEnv(), FORGE_DOCKER_LABELS: labels, ...(runTmp ? { CLAUDE_CODE_TMPDIR: runTmp } : {}), ...(toolchain?.env ?? {}), ...heavy, ...profEnv } }); }
|
|
650
655
|
catch (e) {
|
|
651
656
|
const epoch = Number.isFinite(e?.resetAtEpochMs) ? e.resetAtEpochMs : null;
|
|
657
|
+
if (e?.authFailure === true && e.usageLimit !== true && epoch === null) throw authDeferral(e); // #5168: an account condition, never engine-error
|
|
652
658
|
if (!(e?.usageLimit === true || epoch !== null)) throw e;
|
|
653
659
|
if (rotatorRetry(rotator, rotations)) { rotations++; ev({ event: "PHASE_ACCOUNT_ROTATED", phase: args.role }); continue; } // #5094: same args (resume too), next account
|
|
654
660
|
const waitMs = epoch !== null && epoch > now() ? epoch - now() : DEFAULT_SESSION_LIMIT_WAIT_MS;
|
|
@@ -1002,9 +1008,10 @@ export async function runCore(o) {
|
|
|
1002
1008
|
// #4337: with decomposition.close_parent, the tracker closes only once its trail and label exist: dependents then wait on the sub-issues, not on it.
|
|
1003
1009
|
if (await label("decomposed") && marked && closeParent) {
|
|
1004
1010
|
try {
|
|
1005
|
-
|
|
1011
|
+
// #5152: the scope moved to the sub-issues, it was not dropped: `completed`, never `not planned`
|
|
1012
|
+
await ghw(["issue", "close", String(issue), "-R", repo, "--reason", "completed", "--comment",
|
|
1006
1013
|
`Decomposed into ${refs(filed)}; this tracker is closed and its dependents wait on the sub-issues. A trusted \`FORGE:DECOMPOSED:REVOKED\` reopens it.`]);
|
|
1007
|
-
T.decisions.push(`Closed the decomposed parent (
|
|
1014
|
+
T.decisions.push(`Closed the decomposed parent (completed; scope moved to sub-issues); tracked by ${refs(filed)}`);
|
|
1008
1015
|
} catch { /* best effort: the parent stays open, as before #4337 */ }
|
|
1009
1016
|
}
|
|
1010
1017
|
return await finish("decomposed", `split into ${refs(filed)}`, { code: OUTCOME_CODES.DECOMPOSED, subissues: filed, decomposition: b.rationale });
|
|
@@ -1086,7 +1093,7 @@ export async function runCore(o) {
|
|
|
1086
1093
|
try {
|
|
1087
1094
|
iss = JSON.parse(await ghc(["issue", "view", String(issue), "-R", repo, "--json", "number,title,body,state,comments,labels,updatedAt"]));
|
|
1088
1095
|
} catch (e) {
|
|
1089
|
-
if (e?.deferCode) return finish("deferred", e.deferDetail ?? String(e.message), { code: e.deferCode, ...(e.retryInSeconds ? { retryInSeconds: e.retryInSeconds } : {}) }, e.deferCode); // #4662
|
|
1096
|
+
if (e?.deferCode) return finish("deferred", e.deferDetail ?? String(e.message), { code: e.deferCode, ...(e.retryInSeconds ? { retryInSeconds: e.retryInSeconds } : {}), ...(e.backendId ? { backendId: e.backendId } : {}) }, e.deferCode); // #4662; #5168 backendId
|
|
1090
1097
|
return finish("engine-error", `cannot read issue: ${e.message}`);
|
|
1091
1098
|
}
|
|
1092
1099
|
const priorState = stateOf(iss.body); // #4400: the last run's end (the lease write keeps its terminal fields), read before this run ends
|
|
@@ -1721,9 +1728,10 @@ export async function runCore(o) {
|
|
|
1721
1728
|
if (closes) {
|
|
1722
1729
|
T.plan = "skipped"; T.implement = "skipped"; T.review = "skipped"; T.pr = "none"; T.merge = "n/a";
|
|
1723
1730
|
T.decisions.push(`Builder verdict: ${result.status}`);
|
|
1724
|
-
|
|
1731
|
+
// #5152: already-fixed is delivered behaviour (`completed`); only invalid is dropped scope (`not planned`)
|
|
1732
|
+
await ghw(["issue", "close", String(issue), "-R", repo, "--reason", result.status === "already-fixed" ? "completed" : "not planned"]);
|
|
1725
1733
|
await label("invalid");
|
|
1726
|
-
return await finish("invalid", result.summary ?? result.status);
|
|
1734
|
+
return await finish("invalid", result.summary ?? result.status, result.status === "already-fixed" ? { close: "already-fixed" } : {});
|
|
1727
1735
|
}
|
|
1728
1736
|
const blocked = await endIfBlocked();
|
|
1729
1737
|
if (blocked) return blocked;
|
|
@@ -2236,16 +2244,16 @@ export async function runCore(o) {
|
|
|
2236
2244
|
const ctx = { contract: effective, changedFiles: files, issueText, pathExists: (x) => listed.has(x), repoFiles: listed, ignore: IGNORE, e2e: { checks: vcfg.e2e?.checks ?? [], isSpec: (x) => e2eSpecs([x], vcfg.e2e).length > 0 } };
|
|
2237
2245
|
let d = null, first = null;
|
|
2238
2246
|
for (let t = 0; t < 3; t++) {
|
|
2239
|
-
// #4327: a third run only when the repaired reply is incomplete solely by evidence paths
|
|
2247
|
+
// #4327/#5164: a third run only when the repaired reply is incomplete solely by reviewer slips (unresolved evidence paths, coverage misquotes).
|
|
2240
2248
|
if (t === 2) {
|
|
2241
|
-
if (d.state !== "review.incomplete" || !
|
|
2242
|
-
T.decisions.push(`Review incomplete only by unresolved evidence paths (${(d.missing ?? []).join(", ")}); reviewer re-asked once more`);
|
|
2249
|
+
if (d.state !== "review.incomplete" || !reaskable(d.failures.map((f) => f.detail))) break;
|
|
2250
|
+
T.decisions.push(`Review incomplete only by unresolved evidence paths or coverage misquotes (${(d.missing ?? []).join(", ")}); reviewer re-asked once more`);
|
|
2243
2251
|
}
|
|
2244
2252
|
// #4160: a reply with rows missing is re-asked for those ids only; an unparseable one gets the full prompt again.
|
|
2245
2253
|
const repair = first && (d.state === "review.incomplete" || d.state === "review.inconsistent");
|
|
2246
2254
|
await renew();
|
|
2247
2255
|
const rr = await callRunner({ role: "reviewer", issue, cwd: wt, model: reviewerModel, resume: null, system: REVIEWER_SYSTEM, readOnly: true,
|
|
2248
|
-
prompt: repair ? reviewerRepairPrompt({ missing: d.missing ?? [], problems: d.failures.map((f) => f.detail), rows: first })
|
|
2256
|
+
prompt: repair ? reviewerRepairPrompt({ missing: d.missing ?? [], problems: d.failures.map((f) => f.detail), rows: first, criteria: (d.missing ?? []).includes("issue_coverage") ? criterionLines(issueText) : [] })
|
|
2249
2257
|
: reviewerPrompt({ issue: iss, comments: trustedComments, contract: effective, result, files, diff, stat, base, structure: structureLine, ignore: IGNORE, domains, investigationVerdict: investigationVerdict(await io.fs.read(`${wt}/.forge-lean/investigation.md`).catch(() => null)) }) });
|
|
2250
2258
|
await renew();
|
|
2251
2259
|
await attempt("review", ++reviewN, rr, null, "review");
|
|
@@ -2289,7 +2297,7 @@ export async function runCore(o) {
|
|
|
2289
2297
|
run.findings = parseVerifierFindings(rr?.text, { pathExists: (x) => listed.has(x) });
|
|
2290
2298
|
run.cost_usd = num(rr?.costUsd) ?? 0;
|
|
2291
2299
|
} catch (e) {
|
|
2292
|
-
if (e?.deferUsageLimit) throw e;
|
|
2300
|
+
if (e?.deferUsageLimit || e?.deferCode === OUTCOME_CODES.BACKEND_AUTH) throw e; // #5168: a login failure (backend.auth) is not swallowed as a verifier error
|
|
2293
2301
|
run.error = String(e?.code || e?.message || e).split("\n")[0].slice(0, 200);
|
|
2294
2302
|
T.anomalies.push(`Independent verifier failed at ${head.slice(0, 12)}: ${run.error}`);
|
|
2295
2303
|
}
|
|
@@ -3062,9 +3070,12 @@ export async function runCore(o) {
|
|
|
3062
3070
|
}
|
|
3063
3071
|
}
|
|
3064
3072
|
const partial = closing.reason === "partial";
|
|
3065
|
-
|
|
3066
|
-
if (
|
|
3067
|
-
|
|
3073
|
+
// #5152: a merged change is `completed` even when partial: the remainder is tracked (FORGE:TRACKED), not dropped
|
|
3074
|
+
if (!keepOpen) await ghw(["issue", "close", String(issue), "-R", repo, "--reason", "completed"]).catch(() => {});
|
|
3075
|
+
if (keepOpen) { // #5159: the issue is not closed while a follow-up is pending, so the note must not say it is
|
|
3076
|
+
if (partial || closing.tracked.length) try { await post("FORGE:TRACKED", `## Left open: follow-up pending\nMerged by PR #${pr}; the issue stays open until the pending follow-up is filed (FORGE:FOLLOWUP_PENDING).${partial ? " The investigation's stated question is not answered in this change." : ""}${closing.tracked.length ? ` Already tracked in ${closing.tracked.join(", ")}.` : ""}`); } catch { /* the PR body links them too */ }
|
|
3077
|
+
} else if (partial) {
|
|
3078
|
+
try { await post("FORGE:TRACKED", `## Closed as partial\nClosed as completed by PR #${pr}; the investigation's stated question is not answered in this change and is carried forward in ${closing.tracked.join(", ")}.`); } catch { /* the PR body links them too */ }
|
|
3068
3079
|
} else if (closing.tracked.length) {
|
|
3069
3080
|
try { await post("FORGE:TRACKED", `## Remaining work tracked\nClosed as completed by PR #${pr}. The contracted deferred and post-deploy requirements are tracked in ${closing.tracked.join(", ")}.`); } catch { /* the PR body links them too */ }
|
|
3070
3081
|
}
|
|
@@ -3089,13 +3100,13 @@ export async function runCore(o) {
|
|
|
3089
3100
|
await gatePassed("merge_ci", "ci_red"); // #4375: merged means the required checks are green after a CI fix round
|
|
3090
3101
|
let mergedTo = null; // #4711: FORGE:STATE records the lane and merge commit, so a dependent's check needs no PR search
|
|
3091
3102
|
if (lane) try { const sha = JSON.parse(String(await ghc(["pr", "view", String(pr), "-R", repo, "--json", "mergeCommit"])) || "{}").mergeCommit?.oid; if (typeof sha === "string" && sha) mergedTo = { lane, pr, sha }; } catch { /* the PR-ancestry rule still applies */ }
|
|
3092
|
-
return await finish("merged", `PR #${pr}`, { pr, ...(mergedTo ? { mergedTo } : {}), ...(partial ? { close: "partial" } : {}), structure: T.structure ?? null, ...(milestone ? { milestone } : {}), ...(lane ? { lane } : {}) });
|
|
3103
|
+
return await finish("merged", `PR #${pr}`, { pr, ...(mergedTo ? { mergedTo } : {}), ...(partial ? { close: "partial" } : {}), ...(keepOpen ? { keptOpen: true } : {}), structure: T.structure ?? null, ...(milestone ? { milestone } : {}), ...(lane ? { lane } : {}) });
|
|
3093
3104
|
} catch (e) {
|
|
3094
3105
|
if (e?.deferUsageLimit) {
|
|
3095
3106
|
const when = e.resetAt || (Number.isFinite(e.resetAtEpochMs) ? new Date(e.resetAtEpochMs).toISOString() : "unknown");
|
|
3096
3107
|
return finish("deferred", `usage limit hit; resets at ${when}`, { code: OUTCOME_CODES.RATE_LIMITED, resetAt: when, resetAtEpochMs: Number.isFinite(e.resetAtEpochMs) ? e.resetAtEpochMs : null, backendId: laneBackendId(process.env, rotator) }, OUTCOME_CODES.RATE_LIMITED);
|
|
3097
3108
|
}
|
|
3098
|
-
if (e?.deferCode) return finish("deferred", e.deferDetail ?? String(e.message), { code: e.deferCode, ...(e.retryInSeconds ? { retryInSeconds: e.retryInSeconds } : {}) }, e.deferCode);
|
|
3109
|
+
if (e?.deferCode) return finish("deferred", e.deferDetail ?? String(e.message), { code: e.deferCode, ...(e.retryInSeconds ? { retryInSeconds: e.retryInSeconds } : {}), ...(e.backendId ? { backendId: e.backendId } : {}) }, e.deferCode); // #5168: backendId keys the account hold
|
|
3099
3110
|
// #4505: a turn overrun is mechanical: a non-human end the batch recovers, needs-human only once a budget recovery failed too
|
|
3100
3111
|
if (e?.laneCode === OUTCOME_CODES.BUDGET_EXCEEDED && !(recover?.final && recover.code === OUTCOME_CODES.BUDGET_EXCEEDED)) {
|
|
3101
3112
|
return finish("budget-exceeded", e.message, { code: OUTCOME_CODES.BUDGET_EXCEEDED, branch, ...(recover ? { recovery: true } : {}) }, OUTCOME_CODES.BUDGET_EXCEEDED);
|