forgedock 1.48.0 → 1.49.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-cli.mjs +18 -10
- package/bin/lean/batch.test.mjs +106 -2
- package/bin/lean/conformance.mjs +18 -5
- package/bin/lean/conformance.test.mjs +26 -0
- package/bin/lean/effects.mjs +16 -5
- package/bin/lean/execution.mjs +57 -20
- package/bin/lean/execution.test.mjs +68 -1
- package/bin/lean/lanecheck.mjs +44 -12
- package/bin/lean/lanecheck.test.mjs +23 -0
- package/bin/lean/lean.mjs +20 -3
- package/bin/lean/lean.test.mjs +20 -0
- package/bin/lean/orchestrate.mjs +66 -15
- package/bin/lean/prompts.mjs +15 -2
- package/bin/lean/review-trigger.mjs +25 -0
- package/bin/lean/review-trigger.test.mjs +28 -1
- package/bin/lean/schedd.mjs +11 -3
- package/bin/lean/schedd.test.mjs +20 -3
- package/docs/CONFIG.md +3 -1
- package/package.json +1 -1
- package/scripts/file-deferred-followups.sh +1 -1
- package/scripts/owner-route-parity.test.sh +6 -0
- package/scripts/plan-validate-live.mjs +24 -7
package/bin/lean/schedd.test.mjs
CHANGED
|
@@ -76,6 +76,10 @@ await d.closed;
|
|
|
76
76
|
return child;
|
|
77
77
|
};
|
|
78
78
|
h.client = { scheddDir: h.sched, spawnDaemon: null };
|
|
79
|
+
// A forge.yaml with host admission off, so a test that needs N live lanes does not depend on this host's free memory/cores
|
|
80
|
+
// (liveLaneCap derives the cap from them). Run the clients from h.work via h.inWork to use it.
|
|
81
|
+
writeFileSync(join(work, "forge.yaml"), "engine:\n admission_enabled: false\n");
|
|
82
|
+
h.inWork = async (fn) => { const prev = process.cwd(); process.chdir(work); try { return await fn(); } finally { process.chdir(prev); } };
|
|
79
83
|
h.launched = () => readFileSync(h.launches, "utf8").trim().split("\n").filter(Boolean).map(Number).sort();
|
|
80
84
|
h.lanePids = () => listBatches(h.root).flatMap((id) => Object.values(openBatch(h.root, id).store.load()?.running ?? {}).map((r) => r.pid).filter(Boolean));
|
|
81
85
|
h.stubPids = () => (existsSync(h.lanePidFile) ? readFileSync(h.lanePidFile, "utf8").trim().split("\n").filter(Boolean).map(Number) : []);
|
|
@@ -119,7 +123,7 @@ describe("schedd host scheduler (#4192)", () => {
|
|
|
119
123
|
const d = await h.daemon();
|
|
120
124
|
const submit = (issues) => orchestrateFromCli([...issues, "--parallel", "2", "--repo", "o/r", "--no-prefilter"], h.client);
|
|
121
125
|
// One capture for the three concurrent clients (console.log is process-wide).
|
|
122
|
-
const all = capture(() => Promise.all([submit(["1", "2"]), submit(["4"]), submit(["5"])]));
|
|
126
|
+
const all = capture(() => h.inWork(() => Promise.all([submit(["1", "2"]), submit(["4"]), submit(["5"])])));
|
|
123
127
|
await until(() => h.launched().length === 4 && h.lanePids().length === 4);
|
|
124
128
|
const reg = JSON.parse(readFileSync(join(h.sched, "registry.json"), "utf8"));
|
|
125
129
|
assert.equal(Object.keys(reg.slots).length, 4);
|
|
@@ -197,7 +201,7 @@ await orchestrateFromCli(["1", "2", "--parallel", "2", "--repo", "o/r", "--no-pr
|
|
|
197
201
|
try {
|
|
198
202
|
await h.daemon();
|
|
199
203
|
const client = { ...h.client, io: { gh: async (a) => (a[0] === "issue" ? JSON.stringify({ state: a[2] === "9" ? "CLOSED" : "OPEN" }) : "o/r\n") } };
|
|
200
|
-
const { res } = await withRoot(h, () => capture(async () => {
|
|
204
|
+
const { res } = await withRoot(h, () => h.inWork(() => capture(async () => {
|
|
201
205
|
const first = orchestrateFromCli(["1", "--parallel", "3", "--repo", "o/r", "--no-prefilter"], h.client);
|
|
202
206
|
await until(() => h.launched().length === 1 && h.lanePids().length === 1);
|
|
203
207
|
const [id] = listBatches(h.root);
|
|
@@ -217,7 +221,7 @@ await orchestrateFromCli(["1", "2", "--parallel", "2", "--repo", "o/r", "--no-pr
|
|
|
217
221
|
const again = await orchestrateFromCli(["--resume", id], client);
|
|
218
222
|
assert.deepEqual(again.rows.map((r) => [r.issue, r.outcome]), [[1, "merged"], [2, "merged"], [4, "merged"], [6, "merged"]]);
|
|
219
223
|
return done;
|
|
220
|
-
}));
|
|
224
|
+
})));
|
|
221
225
|
assert.equal(res.rows.length, 3);
|
|
222
226
|
assert.deepEqual(h.launched(), [1, 2, 4, 6], "each issue launched once");
|
|
223
227
|
} finally { await h.cleanup(); }
|
|
@@ -644,6 +648,19 @@ await orchestrateFromCli(["1", "2", "--parallel", "2", "--repo", "o/r", "--no-pr
|
|
|
644
648
|
assert.equal(env.COMPUTESHARE_TOKEN, "daemon-tok");
|
|
645
649
|
});
|
|
646
650
|
|
|
651
|
+
it("5131: a daemon secret wins over an empty pin, a non-empty pin wins (REG-1 #5142), batchEnv pins no empty value", () => {
|
|
652
|
+
assert.equal(mergeEnv({ COMPUTESHARE_TOKEN: "daemon-tok" }, { COMPUTESHARE_TOKEN: "" }).COMPUTESHARE_TOKEN, "daemon-tok");
|
|
653
|
+
assert.equal(mergeEnv({ COMPUTESHARE_TOKEN: "daemon-tok" }, { COMPUTESHARE_TOKEN: "client-tok" }).COMPUTESHARE_TOKEN, "client-tok", "a non-empty passthrough pin wins (release review #5142 REG-1)");
|
|
654
|
+
assert.equal(mergeEnv({ GH_TOKEN: "daemon-acct" }, { GH_TOKEN: "pinned-acct" }).GH_TOKEN, "pinned-acct", "an env_passthrough GH_TOKEN keeps the batch's account");
|
|
655
|
+
assert.equal(mergeEnv({ PATH: "/d" }, { COMPUTESHARE_TOKEN: "client-tok" }).COMPUTESHARE_TOKEN, "client-tok");
|
|
656
|
+
assert.equal(mergeEnv({ COMPUTESHARE_TOKEN: "" }, { COMPUTESHARE_TOKEN: "client-tok" }).COMPUTESHARE_TOKEN, "client-tok");
|
|
657
|
+
assert.equal(mergeEnv({}, { COMPUTESHARE_TOKEN: "" }).COMPUTESHARE_TOKEN, undefined, "an empty pin never overrides");
|
|
658
|
+
assert.equal(mergeEnv({ CLAUDE_CONFIG_DIR: "/d" }, { CLAUDE_CONFIG_DIR: "/c" }).CLAUDE_CONFIG_DIR, "/c", "non-secret pins keep winning");
|
|
659
|
+
const kept = batchEnv({ PATH: "/", COMPUTESHARE_TOKEN: "", GH_HOST: "" }, { passthrough: ["COMPUTESHARE_TOKEN"], exists: () => true });
|
|
660
|
+
assert.deepEqual(Object.keys(kept), ["PATH"]);
|
|
661
|
+
assert.deepEqual(batchEnv({ COMPUTESHARE_TOKEN: "client-tok" }, { passthrough: ["COMPUTESHARE_TOKEN"] }), { COMPUTESHARE_TOKEN: "client-tok" });
|
|
662
|
+
});
|
|
663
|
+
|
|
647
664
|
it("#4734: the pin keeps non-secret provider selectors, drops secrets and session ids, and droppedEnv names what was left out", () => {
|
|
648
665
|
const client = { PATH: "/", GH_TOKEN: "fresh", CLAUDE_CODE_USE_BEDROCK: "1", ANTHROPIC_BASE_URL: "http://proxy", ANTHROPIC_MODEL: "m", ANTHROPIC_API_KEY: "k",
|
|
649
666
|
FORGEDOCK_POOL_TOKEN: "p", CLAUDE_CONFIG_DIR: "/acct", CLAUDE_CODE_SESSION_ID: "s", CLAUDE_PID: "1" };
|
package/docs/CONFIG.md
CHANGED
|
@@ -1030,7 +1030,8 @@ execution:
|
|
|
1030
1030
|
| `allow_actions` | boolean | No | `true` accepts a GitHub Actions endpoint. Default `false`. |
|
|
1031
1031
|
| `budget_minutes`, `budget_usd` | number | No | The `execution.budget` per batch: once the batch's sandbox minutes / estimated USD (settled lanes plus the running ones' elapsed time) reach the cap, no new remote lane is admitted. Missing, non-numeric or negative means no cap. |
|
|
1032
1032
|
| `day_budget_minutes`, `day_budget_usd` | number | No | The same cap per host UTC day, summed over every batch on the host from the host-day store (`execution-budget.json` in `~/.forge/sched/`, or `FORGEDOCK_EXECUTION_BUDGET_FILE`). A corrupt or unreadable store refuses remote lanes while a day cap is set; remove the file to reset it. |
|
|
1033
|
-
| `fallback_local` | boolean | No | `true`: a remote-placed lane runs on the host while the provider reports no agent online (`GET {endpoint}/status -> {"online": N}`, the built-in router answers it with `online`, `total` and per-agent `ok`/`paused`), so a batch completes locally with every pool agent offline or asleep (forge#4547). Default `false`: such lanes defer (`sandbox.capacity`). A missing or failing `/status` is unknown and never triggers it. |
|
|
1033
|
+
| `fallback_local` | boolean | No | `true`: a remote-placed lane runs on the host while the provider reports no agent online (`GET {endpoint}/status -> {"online": N}`, the built-in router answers it with `online`, `total` and per-agent `ok`/`paused`), so a batch completes locally with every pool agent offline or asleep (forge#4547). Default `false`: such lanes defer (`sandbox.capacity`). A missing or failing `/status` is unknown and never triggers it. It also covers a sustained `sandbox.capacity`: an issue the pool refused `capacity_fallback_after` times (default 3) for capacity runs on the host when the host has headroom (the host-busy guard; a busy host or a failing probe keeps it waiting remote) (forge#5112). |
|
|
1034
|
+
| `capacity_fallback_after` | integer | No | With `fallback_local: true`: how many `sandbox.capacity` deferrals of one issue make it run on the host, subject to the host-busy guard (forge#5112). Default: 3. A missing, non-integer or non-positive value uses the default. |
|
|
1034
1035
|
| `account_rotator` | string | No | `tokenbaton`: the `claude` on PATH is the tokenbaton shim, which runs each call on the logged-in account with the most room (`CLAUDE_CONFIG_DIR=~/.claude-<n>`) (forge#5094). forgedock reads `~/.tokenbaton/current` (the account number) and `~/.tokenbaton/live.<n>` (`<5h used %> <5h reset s> <weekly used %> <weekly reset s> <updated s>`), re-read on every check and never written. An account has headroom when its 5-hour use is under 95% and its weekly use under 100% (a window whose reset has passed counts as 0). While any account has headroom no host-wide usage hold is recorded and admissions continue; they stop only when every account is exhausted and resume when the first one resets. A lane whose call hits a usage limit retries that same call (same session) on the next account at once, at most once per account, before it pauses or defers. `forgedock limits show` lists each account's 5-hour use. Any other value, or unreadable files: off (per-account holds as before). |
|
|
1035
1036
|
| `warm_sandboxes` | integer | No | Warm sandboxes per repo the pool agent keeps ready (checkout at the base ref, dependency cache primed). When `N > 0` the launch spec carries `warm: N` and the lane first calls `POST {endpoint}/sandboxes/claim`; a ready warm sandbox takes the lane (refreshed to the lane's base ref with an incremental fetch before work starts), and when none is ready, the claim fails or the endpoint lacks the route the lane starts cold. Default `0` (off): no claim call and the launch spec is unchanged (forge#4912). |
|
|
1036
1037
|
| `unreachable_window_min` | number | No | Remote providers: how long a lane keeps polling (with backoff, up to 30s) after the provider's transport fails (connection refused/reset, `fetch failed`, timeout, 502/503/504) before it ends `deferred` with `sandbox.unreachable` (forge#4672). Inside the window the lane never ends and the sandbox is never torn down; after it the sandbox is kept and adopted on the next attempt or `--resume`; the engine-error breaker does not count it. `adopt` applies the same window. Default: 5 |
|
|
@@ -1271,6 +1272,7 @@ review:
|
|
|
1271
1272
|
| `domains.*` | string | No | Domain-specific notes keyed by review agent name (`billing`, `auth`, `database`, `frontend`, `api`, `infra`, `security`) |
|
|
1272
1273
|
| `max_filed_findings` | integer >= 0 | No | Per-review cap on ISSUE dispositions (`scripts/cap-findings.sh`, applied by `review-pr` §6B.5 and `review-pr-staging` §7B (via `review-finalize.sh classify`) after classification). Default `2`; `1` when the reviewed PR is itself a fix for a `review-finding` issue, where every filed HIGH/CRITICAL consumes that budget. ISSUE findings are ranked by severity, then confidence, then safety-domain agent (security, auth, billing, concurrency, database), then input order; an unparseable severity or confidence ranks as MEDIUM/LIKELY. The overflow becomes `NOTE capped`: listed in the PR body and counted as `notes_capped` in `FORGE:NOTE_DISPOSITION`, never filed and never dropped. **HIGH/CRITICAL findings are never capped** and still block; `ISSUE fix-efficacy-*` findings (the linked issue is not fixed, or the PR re-introduces its defect) are never capped either and count against the review-finding budget like HIGH. `0` is valid (cap every MEDIUM/LOW finding); a non-numeric or negative value falls back to the default with a warning. An explicit value overrides both defaults. |
|
|
1273
1274
|
| `key_paths` | map(string→list of strings) | No | Domain-to-file mapping used by `/work-on` investigation and `/review-pr` agents to quickly locate relevant files. Keys are domain names matching issue labels; values are lists of file path patterns (glob-style, relative to repo root). When present, agents use this table instead of inferring files from labels and issue body. |
|
|
1275
|
+
| `enforce_domains` | string or list | No | Lean lanes (#5099). Review domains whose touch makes the conformance gate enforce for the whole lane (sticky, like a `risk_paths` hit): any of `frontend`, `auth`, `infra`, `database`, `api`, as a comma-separated string or a block list. Unknown names are dropped. Default empty: lanes stay in shadow (ADR `devdocs/decisions/4398-risk-path-enforce.md`: enforce a domain only once its shadow false-block rate is near 0). Independently of this key, the lean reviewer always gets a `<domain_checks>` block naming the domains the diff touches, using `scripts/review-roster.sh` path triggers (for example `web/`, `app/`, `*.tsx`, `next.config.*` → frontend, with the dynamic-API-in-static/ISR-tree check). It reports domain findings as `regressions` rows, which block even in shadow. The run log gets `REVIEW_DOMAINS {domains, enforced}`. |
|
|
1274
1276
|
|
|
1275
1277
|
**Commands that use this section**: `review-pr`, `review-pr-agents` (all domain agents), `work-on` (Phase 1B investigation)
|
|
1276
1278
|
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "forgedock",
|
|
3
|
-
"version": "1.
|
|
3
|
+
"version": "1.49.0",
|
|
4
4
|
"description": "Autonomous development pipeline for coding agents. GitHub as institutional memory, with native Claude Code and OpenCode entrypoints.",
|
|
5
5
|
"bin": {
|
|
6
6
|
"forgedock": "bin/forgedock.mjs"
|
|
@@ -69,7 +69,7 @@ CLASSES="engineering post-deploy external ops verify-now host"
|
|
|
69
69
|
# bin/lean/conformance.mjs ownerRoute; word boundaries spelled (^|[^[:alnum:]_]) / ([^[:alnum:]_]|$), no \b, for BSD grep (bash 3.2 hosts).
|
|
70
70
|
POSTPROM_ERE='(after|post)[- ]?(promotion|release|deploy)|in production after|production[- ]verif|promot(e|ion)|rollout|purge|deploy[- ]time'
|
|
71
71
|
ROPROD_ERE='ssh[[:space:]]+[[:alnum:]_.-]*-monitor([^[:alnum:]_]|$)|(^|[^[:alnum:]_])obs-psql([^[:alnum:]_]|$)|(^|[^[:alnum:]_])prod(uction)?[ _-]monitor([^[:alnum:]_]|$)|(^|[^[:alnum:]_])read[- ]only[^[:alnum:]_.]([^.]{0,38}[^[:alnum:]_.])?prod(uction)?([^[:alnum:]_]|$)|(^|[^[:alnum:]_])prod(uction)?[^[:alnum:]_.]([^.]{0,28}[^[:alnum:]_.])?(query|queries|capture|telemetry|logs?)([^[:alnum:]_]|$)'
|
|
72
|
-
EXTNEED_ERE='credential|secret|api[- ]?key|token|password|passphrase|ssh[- ]?keys?([^[:alnum:]_]|$)|private[- ]key|(^|[^[:alnum:]_])login([^[:alnum:]_]|$)|(^|[^[:alnum:]_])sudo([^[:alnum:]_]|$)|spend|budget|billing|purchase|payment|account|third[- ]party|vendor|customer|contract sign|legal|(^|[^[:alnum:]_])(delete|drop|truncate|wipe|destroy|modify|migrate|write)([^[:alnum:]_]|$)'
|
|
72
|
+
EXTNEED_ERE='credential|secret|api[- ]?key|token|password|passphrase|ssh[- ]?keys?([^[:alnum:]_]|$)|private[- ]key|(^|[^[:alnum:]_])login([^[:alnum:]_]|$)|(^|[^[:alnum:]_])sudo([^[:alnum:]_]|$)|spend|budget|billing|purchase|payment|account|third[- ]party|vendor|customer|contract sign|legal|(^|[^[:alnum:]_])(delete|drop|truncate|wipe|destroy|modify|migrate|write|update|restart|rotate|remove|grant|alter)([^[:alnum:]_]|$)'
|
|
73
73
|
owner_route() { # why -> post-deploy | host | external
|
|
74
74
|
case "$1" in "owner: orchestrator"*|"owner: promote"*) echo "post-deploy"; return 0 ;; esac
|
|
75
75
|
if printf '%s' "$1" | grep -qiE "$POSTPROM_ERE" && ! printf '%s' "$1" | grep -qiE "$EXTNEED_ERE"; then echo "post-deploy"
|
|
@@ -26,6 +26,12 @@ external|needs the prod ssh key to read production logs
|
|
|
26
26
|
external|product manager decides the pricing page
|
|
27
27
|
external|production dataset retention policy
|
|
28
28
|
external|provide the Stripe API key, needed before rollout
|
|
29
|
+
external|ssh ovh-monitor and restart the worker
|
|
30
|
+
external|rotate the prod monitor webhook
|
|
31
|
+
external|update the production telemetry config
|
|
32
|
+
external|remove the stale prod monitor alert
|
|
33
|
+
external|grant read-only prod access to the analyst
|
|
34
|
+
external|alter the production query timeout
|
|
29
35
|
post-deploy|verify the dashboard after release
|
|
30
36
|
CASES
|
|
31
37
|
echo "passed=$PASS failed=$FAIL"
|
|
@@ -9,7 +9,8 @@
|
|
|
9
9
|
*/
|
|
10
10
|
import { execFileSync } from "node:child_process";
|
|
11
11
|
import { fileURLToPath } from "node:url";
|
|
12
|
-
import { parseDependsOn } from "../bin/engine/phases.mjs";
|
|
12
|
+
import { parseDependsOn, isTrustedComment } from "../bin/engine/phases.mjs";
|
|
13
|
+
import { parseState } from "../bin/engine/state.mjs";
|
|
13
14
|
|
|
14
15
|
/** Milestones in train order. Input: [{ title, number, due_on }]. */
|
|
15
16
|
export function orderMilestones(milestones) {
|
|
@@ -60,7 +61,9 @@ export async function readLive({ gh, repo }) {
|
|
|
60
61
|
* Parallel-start gate: dependencies of `issues` (not themselves in the batch) that are not merged into `baseOf(issue)`.
|
|
61
62
|
* A dep is merged when it is CLOSED by a PR that is merged with that base, or whose merge commit is an ancestor of
|
|
62
63
|
* that base (`isAncestor(sha, base)`, e.g. it arrived through a milestone sync). With no linked PR (GitHub links only
|
|
63
|
-
* default-branch merges, #5086) the
|
|
64
|
+
* default-branch merges, #5086) the PR is looked up instead, in order: the trusted FORGE:STATE `mergedTo` record whose PR reads merged
|
|
65
|
+
* with the recorded commit (#5120), the merged `feat/issue-<dep>-lean` PR, then merged PRs on the base whose title/body closes the
|
|
66
|
+
* dependency (#5120). Each lookup that fails finds nothing. Unreadable issues are skipped (fail open:
|
|
64
67
|
* the engine's own openDependency still guards each lane); an unreadable dep counts as unmet.
|
|
65
68
|
* `sha`/`kind` (#5031): `kind` is "commit" when the dependency merged but its commit is not on the base yet (`sha`), else "other".
|
|
66
69
|
* @returns {Promise<Array<{ issue: number, dep: number, base: string, why: string, sha: string|null, kind: "commit"|"other" }>>}
|
|
@@ -95,12 +98,26 @@ export async function unmetDependencies({ issues, baseOf, gh, repo, isAncestor }
|
|
|
95
98
|
await check(parse(await gh(["pr", "view", String(pr.number), ...r, "--json", "baseRefName,mergedAt,mergeCommit"])));
|
|
96
99
|
if (merged) break;
|
|
97
100
|
}
|
|
98
|
-
// #5086: GitHub links closing PRs only for default-branch merges, so a milestone-lane dependency has no references.
|
|
99
|
-
// Fall back to the lane PR by head-branch convention; a failed or non-array lookup finds nothing
|
|
101
|
+
// #5086/#5120: GitHub links closing PRs only for default-branch merges, so a milestone-lane dependency has no references.
|
|
102
|
+
// Fall back to the engine's record, the lane PR by head-branch convention, then PR bodies; a failed or non-array lookup finds nothing.
|
|
103
|
+
const list = async (args) => { try { const j = parse(await gh(["pr", "list", ...r, ...args])); return Array.isArray(j) ? j : []; } catch { return []; } };
|
|
100
104
|
if (!merged && !missing) {
|
|
101
|
-
|
|
102
|
-
|
|
103
|
-
|
|
105
|
+
try {
|
|
106
|
+
const rec = parse(await gh(["api", `repos/${repo ?? "{owner}/{repo}"}/issues/${dep}`]));
|
|
107
|
+
const m = isTrustedComment(rec) && typeof rec.body === "string" ? parseState(rec.body)?.mergedTo : null;
|
|
108
|
+
if (m && m.lane === base && Number.isInteger(m.pr) && typeof m.sha === "string" && /^[0-9a-f]{7,64}$/i.test(m.sha)) {
|
|
109
|
+
const p = parse(await gh(["pr", "view", String(m.pr), ...r, "--json", "baseRefName,mergedAt,mergeCommit"]));
|
|
110
|
+
if (p?.mergeCommit?.oid === m.sha) await check(p);
|
|
111
|
+
}
|
|
112
|
+
} catch { /* no usable record */ }
|
|
113
|
+
}
|
|
114
|
+
if (!merged && !missing) {
|
|
115
|
+
for (const p of await list(["--head", `feat/issue-${dep}-lean`, "--state", "merged", "--json", "number,baseRefName,mergedAt,mergeCommit", "--limit", "5"])) { await check(p); if (merged) break; }
|
|
116
|
+
}
|
|
117
|
+
if (!merged && !missing) {
|
|
118
|
+
const closes = new RegExp(`\\b(?:close[sd]?|fix(?:e[sd])?|resolve[sd]?)\\b:?\\s+(?:[\\w.-]+/[\\w.-]+)?#${dep}(?!\\d)`, "i");
|
|
119
|
+
const prs = await list(["--state", "merged", "--base", base, "--search", `${dep} in:body`, "--json", "number,baseRefName,mergedAt,mergeCommit,title,body", "--limit", "20"]);
|
|
120
|
+
for (const p of prs.filter((x) => closes.test(`${x?.title ?? ""}\n${x?.body ?? ""}`))) { await check(p); if (merged) break; }
|
|
104
121
|
}
|
|
105
122
|
if (!merged) sha = missing;
|
|
106
123
|
if (!merged) why = missing ? `merge commit ${missing.slice(0, 10)} is not on ${base}` : `closed but not merged into ${base}`;
|