forgedock 1.17.0 → 1.18.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 +24 -2
- package/bin/engine/phases.mjs +153 -102
- package/bin/engine/runlog.mjs +7 -0
- package/bin/engine-cli.mjs +5 -0
- package/bin/engine.mjs +84 -10
- package/bin/forgedock.mjs +1 -1
- package/bin/hooks/pre-tool-use.mjs +65 -32
- package/bin/runner.mjs +63 -4
- package/bin/stats/github-source.mjs +89 -18
- package/bin/stats/stats.test.mjs +89 -1
- package/commands/cleanup.md +10 -3
- package/commands/deploy-pr.md +64 -0
- package/commands/fix-ci.md +20 -6
- package/commands/milestone.md +34 -17
- package/commands/orchestrate/phase-4-execution.md +11 -4
- package/commands/review-pr-staging.md +142 -519
- package/commands/review-pr.md +490 -3128
- package/commands/test-gate.md +25 -10
- package/commands/work-on/build/architect.md +13 -1
- package/commands/work-on/build/implement.md +2 -1
- package/commands/work-on/build/validate.md +2 -0
- package/commands/work-on/build.md +107 -86
- package/commands/work-on/close.md +50 -15
- package/commands/work-on/decompose.md +72 -34
- package/commands/work-on/investigate.md +9 -12
- package/commands/work-on/remediate.md +27 -13
- package/commands/work-on/review.md +5 -3
- package/commands/work-on.md +1 -2
- package/docs/CONFIG.md +44 -25
- package/docs/OUTCOME-CODES.md +7 -2
- package/docs/RELEASE-NOTES.md +12 -0
- package/docs/STATS.md +2 -1
- package/docs/TELEMETRY.md +2 -2
- package/docs/WORK-ON-RUNTIME.md +6 -0
- package/docs/spec/forge-protocol-v1.md +2 -2
- package/package.json +1 -1
- package/packages/protocol/src/phases.js +5 -5
- package/scripts/cap-findings.sh +1 -1
- package/scripts/cap-findings.test.sh +1 -1
- package/scripts/check-spec-bash.sh +5 -1
- package/scripts/check-spec-bash.test.sh +20 -0
- package/scripts/close-parent-c3.test.sh +69 -0
- package/scripts/diff-size.sh +93 -4
- package/scripts/diff-size.test.sh +59 -2
- package/scripts/forge-root.test.sh +13 -11
- package/scripts/publish-staging-sync.test.sh +48 -0
- package/scripts/resolve-deploy-gate.sh +32 -6
- package/scripts/resolve-deploy-gate.test.sh +42 -1
- package/scripts/review-finalize.sh +1427 -0
- package/scripts/review-finalize.test.sh +833 -0
- package/scripts/review-pr-delta-wiring.test.sh +19 -72
- package/scripts/review-pr-domain-map.test.sh +18 -13
- package/scripts/review-pr-gate2.test.sh +9 -65
- package/scripts/review-pr-staging-scope.test.sh +14 -98
- package/scripts/review-prepare.sh +48 -12
- package/scripts/review-prepare.test.sh +19 -1
- package/scripts/review-roster.sh +16 -2
- package/scripts/review-roster.test.sh +22 -0
- package/scripts/trusted-comments.test.sh +9 -2
- package/scripts/wait-ci-green.test.sh +3 -3
package/bin/engine.mjs
CHANGED
|
@@ -4,15 +4,15 @@
|
|
|
4
4
|
* GitHub state (phase.detectOutcome). All effects are injected → fully testable.
|
|
5
5
|
*/
|
|
6
6
|
import { fileURLToPath } from "node:url";
|
|
7
|
-
import { readFileSync } from "node:fs";
|
|
7
|
+
import { readFileSync, rmSync } from "node:fs";
|
|
8
8
|
import { createHash, scryptSync } from "node:crypto";
|
|
9
9
|
import { hostname as osHostname, homedir } from "node:os";
|
|
10
10
|
import { appendEvent, readLog, deriveState, rewriteLog } from "./engine/runlog.mjs";
|
|
11
|
-
import { pickPhase, TERMINAL_REASONS, issueSnapshot, hasInvestigationExit, postCycleMarker, nextCycleNumber } from "./engine/phases.mjs";
|
|
11
|
+
import { pickPhase, TERMINAL_REASONS, issueSnapshot, hasInvestigationExit, postCycleMarker, nextCycleNumber, newestCycle, cycleResetProvable, remediationHeadStale, decompositionRevoked, openDependency } from "./engine/phases.mjs";
|
|
12
12
|
import { reconcileState } from "./engine/reconcile.mjs";
|
|
13
13
|
import { makeProjector } from "./engine/projector.mjs";
|
|
14
|
-
import { VALID_BACKENDS } from "./runner.mjs";
|
|
15
|
-
import { OUTCOME_CODES, UNCLASSIFIED, normalizeCode, codeForRunnerError, codeForTerminalReason } from "./engine/outcome-codes.mjs";
|
|
14
|
+
import { VALID_BACKENDS, resolveRunTmpRoot, runTmpDir } from "./runner.mjs";
|
|
15
|
+
import { OUTCOME_CODES, UNCLASSIFIED, normalizeCode, codeForRunnerError, codeForTerminalReason, isDiskQuotaError, isDiskQuotaText } from "./engine/outcome-codes.mjs";
|
|
16
16
|
|
|
17
17
|
// Exported (forge#2175) so bin/engine-cli.mjs can render "failed N/M attempts"
|
|
18
18
|
// diagnostics without duplicating the retry budget constant.
|
|
@@ -390,6 +390,27 @@ export async function runIssue(opts) {
|
|
|
390
390
|
remote = cur;
|
|
391
391
|
({ state, action } = reconcileState(local, remote));
|
|
392
392
|
}
|
|
393
|
+
// forge#3932: `Depends on` admission. A trusted `**Depends on**: #N` line whose dependency is still OPEN
|
|
394
|
+
// refuses the run BEFORE any state/lease write (mirrors deferredResult), so building on top of an
|
|
395
|
+
// unmerged dependency cannot conflict with it. Checked on every start; a closed dependency, no
|
|
396
|
+
// declaration or an untrusted body proceeds. An unreadable body/dependency fails closed.
|
|
397
|
+
{
|
|
398
|
+
let dep = null;
|
|
399
|
+
try { dep = await openDependency(issue, io); } catch (e) {
|
|
400
|
+
dep = { unreadable: true, detail: `issue ${issue} **Depends on** check failed: ${e?.message || e}` };
|
|
401
|
+
}
|
|
402
|
+
if (dep?.unreadable) {
|
|
403
|
+
return { terminalReason: "dependency-unreadable", code: OUTCOME_CODES.DEPENDENCY_UNREADABLE, detail: dep.detail };
|
|
404
|
+
}
|
|
405
|
+
if (dep?.open?.length) {
|
|
406
|
+
return {
|
|
407
|
+
terminalReason: "dependency-open",
|
|
408
|
+
code: OUTCOME_CODES.DEPENDENCY_OPEN,
|
|
409
|
+
dependencies: dep.open,
|
|
410
|
+
detail: `issue ${issue} **Depends on** ${dep.open.map((n) => `#${n}`).join(", ")} which is still open; re-run once it is closed`,
|
|
411
|
+
};
|
|
412
|
+
}
|
|
413
|
+
}
|
|
393
414
|
// forge#3824: hoisted so RUN_START can carry the repo (lazy, memoised; null when unresolved).
|
|
394
415
|
let repoPromise = null;
|
|
395
416
|
const resolveRepo = () => (repoPromise ??= (async () => {
|
|
@@ -412,6 +433,20 @@ export async function runIssue(opts) {
|
|
|
412
433
|
// and regress committed/issue/v (C2).
|
|
413
434
|
rewriteLog(dir, issue, eventsFromIndex(state, await runMeta()));
|
|
414
435
|
state = deriveState(readLog(dir, issue));
|
|
436
|
+
// forge#3931: the index does not carry the cycle, so a hydrated log always starts at cycle 0. When a
|
|
437
|
+
// trusted FORGE:CYCLE exists, cycle-scoped progress (or a needs-human/phase-complete/engine-error terminal)
|
|
438
|
+
// belongs to an older cycle ONLY when provably so (cycleResetProvable: terminal index, issue OPEN, no needs-human/remediating label, no open PR): reset via the existing RUN_NEXT_CYCLE event so build runs again instead of
|
|
439
|
+
// terminating. An unreadable trail resets nothing (fail closed). merged/decomposed/invalid terminals stay.
|
|
440
|
+
if (state.committed.some((p) => ["build", "review", "remediate", "close"].includes(p)) &&
|
|
441
|
+
state.terminal && ["needs-human", "phase-complete", "engine-error"].includes(state.terminalReason) &&
|
|
442
|
+
await cycleResetProvable(state, io)) {
|
|
443
|
+
let cyc = 0;
|
|
444
|
+
try { cyc = await newestCycle(issue, io); } catch { cyc = 0; }
|
|
445
|
+
if (cyc > 0) {
|
|
446
|
+
appendEvent(dir, issue, { event: "RUN_NEXT_CYCLE", issue });
|
|
447
|
+
state = deriveState(readLog(dir, issue));
|
|
448
|
+
}
|
|
449
|
+
}
|
|
415
450
|
}
|
|
416
451
|
|
|
417
452
|
// forge#3511: explicit `--retry` reopens a transient `engine-error` terminal
|
|
@@ -424,12 +459,19 @@ export async function runIssue(opts) {
|
|
|
424
459
|
// forge#3562: `phase-complete` is also retryable, but as a NEW cycle
|
|
425
460
|
// (RUN_NEXT_CYCLE clears committed) rather than a reopen of the same one.
|
|
426
461
|
let reopened = false;
|
|
462
|
+
let reopenUncommit = null;
|
|
427
463
|
if (retry && state?.terminal) {
|
|
428
|
-
|
|
464
|
+
// forge#3862 / forge#3932: a `decomposed` terminal is reopened at build when trusted GitHub state shows
|
|
465
|
+
// the decomposition revoked (a trusted FORGE:DECOMPOSED:REVOKED newer than FORGE:DECOMPOSED); the
|
|
466
|
+
// revocation alone suffices now that the size gate is gone. Anything else keeps the terminal; an
|
|
467
|
+
// unreadable comment trail fails closed.
|
|
468
|
+
if (state.terminalReason === "decomposed" && await decompositionRevoked(issue, io)) {
|
|
469
|
+
reopenUncommit = ["build", "decompose"];
|
|
470
|
+
} else if (state.terminalReason !== "engine-error" && state.terminalReason !== "phase-complete" &&
|
|
429
471
|
state.terminalReason !== "needs-human") {
|
|
430
472
|
return {
|
|
431
473
|
terminalReason: "not-retryable",
|
|
432
|
-
detail: `issue ${issue} ended ${state.terminalReason}; --retry only resumes an engine-error, phase-complete or needs-human run`,
|
|
474
|
+
detail: `issue ${issue} ended ${state.terminalReason}; --retry only resumes an engine-error, phase-complete or needs-human run (or a decomposed run whose FORGE:DECOMPOSED was revoked by a trusted FORGE:DECOMPOSED:REVOKED)`,
|
|
433
475
|
};
|
|
434
476
|
}
|
|
435
477
|
// forge#3571: a human clearing `needs-human` is the hand-back signal. Reopen
|
|
@@ -457,6 +499,13 @@ export async function runIssue(opts) {
|
|
|
457
499
|
};
|
|
458
500
|
}
|
|
459
501
|
}
|
|
502
|
+
// forge#3931: a remediate that ended needs-human was recorded against an older PR head. When the
|
|
503
|
+
// live head differs from the head in its trusted trail, un-commit remediate AND review so review
|
|
504
|
+
// re-runs at the new head instead of the old RE-ESCALATED result replaying. Unreadable => no change.
|
|
505
|
+
if (state.terminalReason === "needs-human" && !reopenUncommit &&
|
|
506
|
+
state.committed[state.committed.length - 1] === "remediate" && await remediationHeadStale(state, io)) {
|
|
507
|
+
reopenUncommit = ["remediate", "review"];
|
|
508
|
+
}
|
|
460
509
|
// forge#3562 (SEC-3): a new cycle discards `committed`, so check the live
|
|
461
510
|
// issue first. A CLOSED issue has nothing left to run; an unreadable
|
|
462
511
|
// snapshot fails closed rather than resetting a possibly-finished run.
|
|
@@ -486,6 +535,7 @@ export async function runIssue(opts) {
|
|
|
486
535
|
appendEvent(dir, issue, {
|
|
487
536
|
event: state.terminalReason === "phase-complete" ? "RUN_NEXT_CYCLE" : "RUN_REOPEN",
|
|
488
537
|
issue,
|
|
538
|
+
...(reopenUncommit ? { uncommit: reopenUncommit } : {}),
|
|
489
539
|
});
|
|
490
540
|
state = deriveState(readLog(dir, issue));
|
|
491
541
|
reopened = true;
|
|
@@ -779,7 +829,7 @@ export async function runIssue(opts) {
|
|
|
779
829
|
// "needs-human" — this is the engine/tool breaking, not a genuine
|
|
780
830
|
// human-judgment block (see #2244/#2261). Any other thrown error is
|
|
781
831
|
// a true unexpected crash and keeps propagating unchanged.
|
|
782
|
-
if (e.code === "NO_API_KEY" || e.code === "NO_SDK" || e.code === "CLI_BACKEND_FAILED" || e.code === "CLI_MAX_TURNS") {
|
|
832
|
+
if (e.code === "NO_API_KEY" || e.code === "NO_SDK" || e.code === "CLI_BACKEND_FAILED" || e.code === "CLI_MAX_TURNS" || isDiskQuotaError(e)) {
|
|
783
833
|
// forge#2241: when the runner attached a session-limit reset time
|
|
784
834
|
// (bin/runner.mjs's extractSessionLimitResetTime(), only ever set
|
|
785
835
|
// for a genuine session-limit CLI_BACKEND_FAILED — never
|
|
@@ -789,7 +839,7 @@ export async function runIssue(opts) {
|
|
|
789
839
|
const resetSuffix = e.resetAt ? ` (resets: ${e.resetAt})` : "";
|
|
790
840
|
const detail = e.code === "CLI_MAX_TURNS"
|
|
791
841
|
? `phase ${phase.id}: CLI_MAX_TURNS - max-turns exhausted${e.numTurns != null ? ` after ${e.numTurns} turns` : ""}: ${e.message}`
|
|
792
|
-
: `phase ${phase.id}: ${e.code} - ${e.message}${resetSuffix}`;
|
|
842
|
+
: `phase ${phase.id}: ${e.code ?? "DISK_QUOTA"} - ${e.message}${resetSuffix}`;
|
|
793
843
|
// forge#2240 (review finding): this fail-fast path previously left
|
|
794
844
|
// phase_exit unreported — a caller tailing progress output would see
|
|
795
845
|
// "→ phase X started" and then nothing, dangling exactly on the
|
|
@@ -845,6 +895,9 @@ export async function runIssue(opts) {
|
|
|
845
895
|
await emitOutcome({ event: "PHASE_DEFERRED", phase: phase.id, code: normalizeCode(outcome.code), reason: outcome.detail });
|
|
846
896
|
return { terminalReason: "deferred", code: normalizeCode(outcome.code), detail: outcome.detail };
|
|
847
897
|
}
|
|
898
|
+
// forge#3572: a phase that returned blocked because the disk/quota ran out is a retryable infra failure.
|
|
899
|
+
if (isDiskQuotaText(outcome.detail))
|
|
900
|
+
return await terminate(state, "engine-error", outcome.detail, OUTCOME_CODES.INFRA_DISK_QUOTA);
|
|
848
901
|
if (!isRemediationHandoff)
|
|
849
902
|
return await terminate(state, outcome.reason || "needs-human", outcome.detail, outcome.code);
|
|
850
903
|
} else {
|
|
@@ -910,8 +963,8 @@ export async function runIssue(opts) {
|
|
|
910
963
|
// is seen), phase.id is "decompose" — not "investigate" — so this
|
|
911
964
|
// exemption does not apply and the normal terminate() path below fires,
|
|
912
965
|
// ending the run for real.
|
|
913
|
-
//
|
|
914
|
-
const isDecomposeHandoff =
|
|
966
|
+
// forge#3862: only investigate hands off (decomposition is a planning decision); build never reports "decomposed".
|
|
967
|
+
const isDecomposeHandoff = phase.id === "investigate" && terminalReason === "decomposed";
|
|
915
968
|
// forge#3624: remediate AUTO-LANDED whose issue is not yet CLOSED+workflow:merged hands off
|
|
916
969
|
// to `close` instead of ending the run (close is idempotent). Exempts BOTH terminate paths.
|
|
917
970
|
const isCloseHandoff = phase.id === "remediate" && outcome.status === "committed" &&
|
|
@@ -980,10 +1033,24 @@ export async function runIssue(opts) {
|
|
|
980
1033
|
try { await projector.setLabel(issue, "needs-human"); } catch { /* best-effort */ }
|
|
981
1034
|
}
|
|
982
1035
|
}
|
|
1036
|
+
// forge#3572: drop the per-run scratch dir (venvs, session data) on a terminal outcome. Kept for
|
|
1037
|
+
// `engine-error` so `--retry` resumes with it, and when another live engine owns the issue.
|
|
1038
|
+
if (reason !== "engine-error" && foreign === undefined) cleanupRunTmpdir(issue);
|
|
983
1039
|
return { terminalReason: reason, code: outcomeCode, detail };
|
|
984
1040
|
}
|
|
985
1041
|
}
|
|
986
1042
|
|
|
1043
|
+
/** Best-effort removal of the issue's per-run scratch dir. Confined to the resolved tmp root; never throws. */
|
|
1044
|
+
export function cleanupRunTmpdir(issue, { cwd = process.cwd(), env = process.env, rm = rmSync } = {}) {
|
|
1045
|
+
try {
|
|
1046
|
+
const root = resolveRunTmpRoot(cwd, env);
|
|
1047
|
+
const dir = runTmpDir(root, issue);
|
|
1048
|
+
if (!dir || dir === root) return false;
|
|
1049
|
+
rm(dir, { recursive: true, force: true });
|
|
1050
|
+
return true;
|
|
1051
|
+
} catch { return false; }
|
|
1052
|
+
}
|
|
1053
|
+
|
|
987
1054
|
async function runPhaseWithRetry(phase, state, ctx) {
|
|
988
1055
|
const { io, runner, dir, issue, commandsDir, maxAttempts, backend, model, resolveRepo, agentId, emitTelemetry } = ctx;
|
|
989
1056
|
// forge#3499: build the phase's full argument set ONCE, up front. Every
|
|
@@ -1080,6 +1147,13 @@ async function runPhaseWithRetry(phase, state, ctx) {
|
|
|
1080
1147
|
});
|
|
1081
1148
|
throw e;
|
|
1082
1149
|
}
|
|
1150
|
+
// forge#3572: a full disk / exhausted quota (EDQUOT/ENOSPC) is infra, not content: retrying burns the budget on the same
|
|
1151
|
+
// failure, so rethrow fail-fast and let runIssue end it as a retryable `engine-error` (never needs-human).
|
|
1152
|
+
if (isDiskQuotaError(e)) {
|
|
1153
|
+
await recordAttempt(attempt, null, t0, null);
|
|
1154
|
+
appendEvent(dir, issue, { event: "PHASE_FAILED", phase: phase.id, attempt, reason: e.message, code: codeForRunnerError(e), maxAttempts });
|
|
1155
|
+
throw e;
|
|
1156
|
+
}
|
|
1083
1157
|
if (e.code === "NO_API_KEY" || e.code === "NO_SDK" || e.code === "CLI_BACKEND_FAILED") throw e;
|
|
1084
1158
|
// forge#2377: no `usage` field here — the runner threw, so no result
|
|
1085
1159
|
// (and therefore no usage data) was ever produced for this attempt.
|
package/bin/forgedock.mjs
CHANGED
|
@@ -3972,7 +3972,7 @@ switch (command) {
|
|
|
3972
3972
|
// an uncaught throw, which already set exitCode = 1 below. Now that
|
|
3973
3973
|
// runIssue() resolves cleanly with a terminalReason instead of throwing,
|
|
3974
3974
|
// preserve that same non-zero exit code for this reason too.
|
|
3975
|
-
if (result && (result.terminalReason === "needs-human" || result.terminalReason === "engine-error" || result.terminalReason === "not-retryable")) exitCode = 1;
|
|
3975
|
+
if (result && (result.terminalReason === "needs-human" || result.terminalReason === "engine-error" || result.terminalReason === "not-retryable" || result.terminalReason === "dependency-open" || result.terminalReason === "dependency-unreadable")) exitCode = 1;
|
|
3976
3976
|
// forge#3574: a lease-held deferral did NOT run the issue; exit 75
|
|
3977
3977
|
// (EX_TEMPFAIL) so orchestrators neither count it as done nor as a failure.
|
|
3978
3978
|
if (result && result.terminalReason === "deferred") exitCode = 75;
|
|
@@ -116,14 +116,12 @@ async function getInvariants() {
|
|
|
116
116
|
* starting Claude Code (operator-set only — same process.env semantics as
|
|
117
117
|
* the gist guard above).
|
|
118
118
|
*
|
|
119
|
-
* 9. Main-checkout mutation guard (issue #3650) —
|
|
120
|
-
* Enforced (exit 2)
|
|
121
|
-
*
|
|
122
|
-
*
|
|
123
|
-
*
|
|
124
|
-
*
|
|
125
|
-
* Enforcement becomes the default once those specs use worktrees (#3873).
|
|
126
|
-
* When enforced it denies `git checkout|switch|stash|merge`, `git reset --hard`, and
|
|
119
|
+
* 9. Main-checkout mutation guard (issue #3650) — enforced by default
|
|
120
|
+
* Enforced (exit 2) unless FORGE_ENFORCE_MAIN_CHECKOUT_GIT=0, which downgrades it
|
|
121
|
+
* to a stderr warning (#3873). Ref-only verbs are narrowed: `git branch -d/-D/-m/-f`
|
|
122
|
+
* is denied only for the branch checked out in the target dir, and
|
|
123
|
+
* `git restore --staged` (index only) is allowed.
|
|
124
|
+
* It denies `git checkout|switch|stash|merge`, `git reset --hard`, and
|
|
127
125
|
* `gh pr checkout` when the shell's cwd is the main (non-worktree) checkout
|
|
128
126
|
* (`git rev-parse --git-dir` equals `--git-common-dir`). Pipeline workers and
|
|
129
127
|
* reviewers share that tree with the operator; mutating it races with them.
|
|
@@ -589,12 +587,12 @@ async function main() {
|
|
|
589
587
|
// forge.yaml), and `git -C <main> ...` from there is the case this rule exists for.
|
|
590
588
|
const mainCheckoutViolation = checkMainCheckoutMutation(command);
|
|
591
589
|
if (mainCheckoutViolation) {
|
|
592
|
-
if (process.env.FORGE_ENFORCE_MAIN_CHECKOUT_GIT
|
|
590
|
+
if (process.env.FORGE_ENFORCE_MAIN_CHECKOUT_GIT !== "0") {
|
|
593
591
|
process.stderr.write(mainCheckoutViolation);
|
|
594
592
|
process.exit(2);
|
|
595
593
|
return;
|
|
596
594
|
}
|
|
597
|
-
// Advisory
|
|
595
|
+
// Advisory only when the operator opts out with FORGE_ENFORCE_MAIN_CHECKOUT_GIT=0 (#3873).
|
|
598
596
|
process.stderr.write("WARNING (advisory, not blocked): " + mainCheckoutViolation.replace(/^BLOCKED: /, ""));
|
|
599
597
|
}
|
|
600
598
|
|
|
@@ -1304,9 +1302,11 @@ function mutatingGitCommand(tokens, depth = 0) {
|
|
|
1304
1302
|
const k = w === "eval" ? i + 1 : tokens.findIndex((t, n) => n > i && /^-[a-z]*c$/.test(t)) + 1;
|
|
1305
1303
|
if (k <= i || k >= tokens.length) return null;
|
|
1306
1304
|
const inner = w === "eval" ? tokens.slice(k).join(" ") : tokens[k];
|
|
1307
|
-
|
|
1308
|
-
|
|
1309
|
-
|
|
1305
|
+
// Every hit of the payload, not just the first (#3920): an allowed ref-only segment (`git branch -d other`)
|
|
1306
|
+
// must not mask a later mutating one. The caller decides per hit.
|
|
1307
|
+
const hits = [];
|
|
1308
|
+
for (const seg of extractCommandSegments(inner)) hits.push(...mutatingGitHits(seg, depth + 1));
|
|
1309
|
+
return hits.length ? { name: hits[0].name, dir: hits[0].dir, hits } : null;
|
|
1310
1310
|
}
|
|
1311
1311
|
break;
|
|
1312
1312
|
}
|
|
@@ -1338,12 +1338,27 @@ function mutatingGitCommand(tokens, depth = 0) {
|
|
|
1338
1338
|
return opts.some((t) => t === "-b") ? null : hit("git checkout");
|
|
1339
1339
|
case "switch":
|
|
1340
1340
|
return opts.some((t) => t === "-c" || t === "--create") ? null : hit("git switch");
|
|
1341
|
-
case "merge": case "pull": case "rebase": case "cherry-pick": case "revert": case "am":
|
|
1341
|
+
case "merge": case "pull": case "rebase": case "cherry-pick": case "revert": case "am":
|
|
1342
1342
|
return hit(`git ${sub}`);
|
|
1343
|
+
case "restore": {
|
|
1344
|
+
// `restore --staged` only rewrites the index (not the working tree); anything else (or --worktree/-W) does.
|
|
1345
|
+
const flag = (long, ch) => opts.some((t) => t === long || (/^-[a-zA-Z]+$/.test(t) && t.includes(ch)));
|
|
1346
|
+
return flag("--worktree", "W") || !flag("--staged", "S") ? hit("git restore") : null;
|
|
1347
|
+
}
|
|
1343
1348
|
case "clean":
|
|
1344
1349
|
return opts.some((t) => t === "-n" || t === "--dry-run") ? null : hit("git clean");
|
|
1345
|
-
case "branch":
|
|
1346
|
-
|
|
1350
|
+
case "branch": {
|
|
1351
|
+
// Ref-only verbs: a hit only when the delete/move/force target is the branch checked out at the
|
|
1352
|
+
// target dir (resolved by the caller). `-m <new>` with one positional renames the current branch.
|
|
1353
|
+
if (!opts.some((t) => /^(?:-[a-zA-Z]*[fFmMdD][a-zA-Z]*|--force|--move|--delete)$/.test(t))) return null;
|
|
1354
|
+
// Positionals after `--` count too (`git branch -m -- renamed` renames the checked-out branch, #3920).
|
|
1355
|
+
const pos = [...opts.filter((t) => !t.startsWith("-")), ...(dd >= 0 ? rest.slice(dd + 1) : [])];
|
|
1356
|
+
if (!pos.length) return null;
|
|
1357
|
+
const moves = opts.some((t) => t === "--move" || /^-[a-zA-Z]*[mM][a-zA-Z]*$/.test(t));
|
|
1358
|
+
const deletes = opts.some((t) => t === "--delete" || /^-[a-zA-Z]*[dD][a-zA-Z]*$/.test(t));
|
|
1359
|
+
const branches = moves && !deletes ? (pos.length === 1 ? ["@current"] : [pos[0]]) : deletes ? pos : [pos[0]];
|
|
1360
|
+
return { name: "git branch", dir, branches };
|
|
1361
|
+
}
|
|
1347
1362
|
case "reset":
|
|
1348
1363
|
return opts.some((t) => t === "--hard" || t === "--merge" || t === "--keep") ? hit("git reset") : null;
|
|
1349
1364
|
case "stash": {
|
|
@@ -1355,6 +1370,18 @@ function mutatingGitCommand(tokens, depth = 0) {
|
|
|
1355
1370
|
}
|
|
1356
1371
|
}
|
|
1357
1372
|
|
|
1373
|
+
/**
|
|
1374
|
+
* All main-checkout mutation hits in one command segment, flattening `bash -c` / `eval` payloads (#3920).
|
|
1375
|
+
* @param {string[]} tokens
|
|
1376
|
+
* @param {number} [depth]
|
|
1377
|
+
* @returns {Array<{name: string, dir: string|null, branches?: string[]}>}
|
|
1378
|
+
*/
|
|
1379
|
+
function mutatingGitHits(tokens, depth = 0) {
|
|
1380
|
+
const h = mutatingGitCommand(tokens, depth);
|
|
1381
|
+
if (!h) return [];
|
|
1382
|
+
return h.hits ? h.hits : [h];
|
|
1383
|
+
}
|
|
1384
|
+
|
|
1358
1385
|
/**
|
|
1359
1386
|
* Is `cwd` the main (non-linked-worktree) checkout of a git repo?
|
|
1360
1387
|
* Fails closed-to-allow: any error or non-git dir returns false.
|
|
@@ -1386,9 +1413,8 @@ function checkMainCheckoutMutation(command) {
|
|
|
1386
1413
|
if (process.env.FORGE_ALLOW_MAIN_CHECKOUT_GIT === "1") return null;
|
|
1387
1414
|
if (!/\b(?:git|gh)\b/.test(command)) return null;
|
|
1388
1415
|
try {
|
|
1389
|
-
let offender = null;
|
|
1390
1416
|
let baseDir = process.cwd();
|
|
1391
|
-
|
|
1417
|
+
// Decide per segment: an allowed ref-only verb (`git branch -d other`) must not mask a later mutating segment.
|
|
1392
1418
|
for (const tokens of extractCommandSegments(command)) {
|
|
1393
1419
|
const head = tokens.slice();
|
|
1394
1420
|
while (head.length && (/^[({]+$/.test(head[0]) || ["then", "do", "else", "!"].includes(head[0]))) head.shift();
|
|
@@ -1400,21 +1426,28 @@ function checkMainCheckoutMutation(command) {
|
|
|
1400
1426
|
baseDir = resolvePath(baseDir, t);
|
|
1401
1427
|
continue;
|
|
1402
1428
|
}
|
|
1403
|
-
const hit
|
|
1404
|
-
|
|
1429
|
+
for (const hit of mutatingGitHits(tokens)) {
|
|
1430
|
+
const targetDir = hit.dir === null ? baseDir : resolvePath(baseDir, hit.dir);
|
|
1431
|
+
if (!isMainCheckout(targetDir)) continue;
|
|
1432
|
+
if (hit.branches) {
|
|
1433
|
+
// Ref-only verb: only the checked-out branch matters; unresolvable (detached HEAD, error) = allow.
|
|
1434
|
+
const cur = spawnSync("git", ["-C", targetDir, "branch", "--show-current"], { encoding: "utf-8", timeout: 5000 });
|
|
1435
|
+
const current = cur.status === 0 ? String(cur.stdout).trim() : "";
|
|
1436
|
+
if (!current || !hit.branches.some((b) => b === "@current" || b === current)) continue;
|
|
1437
|
+
}
|
|
1438
|
+
// Hook is global: only protect ForgeDock-managed main checkouts (issue #1591 scoping), never unrelated repos.
|
|
1439
|
+
if (!existsSync(join(targetDir, "forge.yaml")) && !existsSync(join(targetDir, ".forgedock"))) continue;
|
|
1440
|
+
return (
|
|
1441
|
+
`BLOCKED: \`${hit.name}\` would mutate the main checkout (${targetDir}).\n` +
|
|
1442
|
+
"The main checkout is shared with the operator and other agents. Work in a git worktree " +
|
|
1443
|
+
"(`git worktree list`, `git worktree add`) and use `git -C <worktree> ...`, or read code with " +
|
|
1444
|
+
"`git show <sha>:<path>`, `gh pr diff`, or `gh api`.\n" +
|
|
1445
|
+
"Operator override: set FORGE_ALLOW_MAIN_CHECKOUT_GIT=1 in the shell before starting Claude Code " +
|
|
1446
|
+
"(set FORGE_ENFORCE_MAIN_CHECKOUT_GIT=0 to make this advisory only).\n"
|
|
1447
|
+
);
|
|
1448
|
+
}
|
|
1405
1449
|
}
|
|
1406
|
-
|
|
1407
|
-
if (!isMainCheckout(targetDir)) return null;
|
|
1408
|
-
// Hook is global: only protect ForgeDock-managed main checkouts (issue #1591 scoping), never unrelated repos.
|
|
1409
|
-
if (!existsSync(join(targetDir, "forge.yaml")) && !existsSync(join(targetDir, ".forgedock"))) return null;
|
|
1410
|
-
return (
|
|
1411
|
-
`BLOCKED: \`${offender}\` would mutate the main checkout (${targetDir}).\n` +
|
|
1412
|
-
"The main checkout is shared with the operator and other agents. Work in a git worktree " +
|
|
1413
|
-
"(`git worktree list`, `git worktree add`) and use `git -C <worktree> ...`, or read code with " +
|
|
1414
|
-
"`git show <sha>:<path>`, `gh pr diff`, or `gh api`.\n" +
|
|
1415
|
-
"Operator override: set FORGE_ALLOW_MAIN_CHECKOUT_GIT=1 in the shell before starting Claude Code " +
|
|
1416
|
-
"(enforcement itself is opt-in: FORGE_ENFORCE_MAIN_CHECKOUT_GIT=1).\n"
|
|
1417
|
-
);
|
|
1450
|
+
return null;
|
|
1418
1451
|
} catch {
|
|
1419
1452
|
return null; // fail open
|
|
1420
1453
|
}
|
package/bin/runner.mjs
CHANGED
|
@@ -74,6 +74,64 @@ export function withForgedockHome(env) {
|
|
|
74
74
|
return env;
|
|
75
75
|
}
|
|
76
76
|
|
|
77
|
+
/** Expand a leading `~` and require an absolute result; anything else -> null. */
|
|
78
|
+
function absoluteTmpRoot(value) {
|
|
79
|
+
const v = typeof value === "string" ? value.trim() : "";
|
|
80
|
+
if (!v) return null;
|
|
81
|
+
const expanded = v === "~" || v.startsWith("~/") ? join(os.homedir(), v.slice(1)) : v;
|
|
82
|
+
return isAbsolute(expanded) ? expanded : null;
|
|
83
|
+
}
|
|
84
|
+
|
|
85
|
+
/**
|
|
86
|
+
* Root of the per-run scratch dirs (forge#3572). Precedence: forge.yaml `engine.tmp_root`
|
|
87
|
+
* (absolute or `~`-prefixed), else null when the operator exported their own
|
|
88
|
+
* CLAUDE_CODE_TMPDIR/TMPDIR (their choice is respected and never cleaned), else `~/.forge/tmp`.
|
|
89
|
+
* A null return means "operator-managed: leave the child env untouched".
|
|
90
|
+
*
|
|
91
|
+
* @param {string} cwd
|
|
92
|
+
* @param {NodeJS.ProcessEnv} [env]
|
|
93
|
+
* @returns {string|null}
|
|
94
|
+
*/
|
|
95
|
+
export function resolveRunTmpRoot(cwd, env = process.env) {
|
|
96
|
+
try {
|
|
97
|
+
const forgeYamlPath = join(cwd, "forge.yaml");
|
|
98
|
+
if (existsSync(forgeYamlPath)) {
|
|
99
|
+
const configured = absoluteTmpRoot(parseForgeYaml(readFileSync(forgeYamlPath, "utf-8"))?.engine?.tmp_root);
|
|
100
|
+
if (configured) return configured;
|
|
101
|
+
}
|
|
102
|
+
} catch { /* unreadable config: fall through to the defaults */ }
|
|
103
|
+
if (env.CLAUDE_CODE_TMPDIR || env.TMPDIR) return null;
|
|
104
|
+
return join(os.homedir(), ".forge", "tmp");
|
|
105
|
+
}
|
|
106
|
+
|
|
107
|
+
/** `<root>/<issue>` for a numeric issue; null for an empty root or non-numeric issue (no path-escape, never the root). */
|
|
108
|
+
export function runTmpDir(root, issue) {
|
|
109
|
+
const id = String(issue ?? "").trim();
|
|
110
|
+
return root && /^[0-9]+$/.test(id) ? join(root, id) : null;
|
|
111
|
+
}
|
|
112
|
+
|
|
113
|
+
/**
|
|
114
|
+
* Point TMPDIR and CLAUDE_CODE_TMPDIR at a per-run, disk-backed dir so venvs and Claude session data
|
|
115
|
+
* stay off a size-limited /tmp. No-op when the issue is unknown or the operator owns the tmp root.
|
|
116
|
+
* Best-effort: a failed mkdir leaves the env unchanged.
|
|
117
|
+
*/
|
|
118
|
+
export function withRunTmpdir(env, { issue, cwd = process.cwd() } = {}) {
|
|
119
|
+
const dir = runTmpDir(resolveRunTmpRoot(cwd, env), issue);
|
|
120
|
+
if (!dir) return env;
|
|
121
|
+
try {
|
|
122
|
+
mkdirSync(dir, { recursive: true });
|
|
123
|
+
env.TMPDIR = dir;
|
|
124
|
+
env.CLAUDE_CODE_TMPDIR = dir;
|
|
125
|
+
} catch { /* scratch stays on the inherited tmp */ }
|
|
126
|
+
return env;
|
|
127
|
+
}
|
|
128
|
+
|
|
129
|
+
/** First positional arg when it is an issue number (the phase arg convention), else null. */
|
|
130
|
+
function issueFromArgs(args) {
|
|
131
|
+
const first = Array.isArray(args) ? args[0] : String(args ?? "").trim().split(/\s+/)[0];
|
|
132
|
+
return /^[0-9]+$/.test(String(first ?? "")) ? String(first) : null;
|
|
133
|
+
}
|
|
134
|
+
|
|
77
135
|
const DEFAULT_MODEL = "claude-sonnet-5";
|
|
78
136
|
const DEFAULT_MAX_ITERATIONS = 50;
|
|
79
137
|
const DEFAULT_MAX_TOKENS = 16384;
|
|
@@ -1410,7 +1468,7 @@ export function runCliBackend({
|
|
|
1410
1468
|
// Mirrors the existing scrub in the `run_bash` tool handler below.
|
|
1411
1469
|
// GH_TOKEN/GITHUB_TOKEN and all other env vars are intentionally left
|
|
1412
1470
|
// intact — only the Anthropic key is removed.
|
|
1413
|
-
const childEnv = withForgedockHome({ ...process.env });
|
|
1471
|
+
const childEnv = withRunTmpdir(withForgedockHome({ ...process.env }), { issue: issueFromArgs(args), cwd });
|
|
1414
1472
|
delete childEnv.ANTHROPIC_API_KEY;
|
|
1415
1473
|
|
|
1416
1474
|
// No `shell` option (defaults to false): argv is passed as discrete,
|
|
@@ -2186,9 +2244,10 @@ export function resolveBashShell() {
|
|
|
2186
2244
|
* the loop catches and reports the error back to the model as an error result.
|
|
2187
2245
|
*
|
|
2188
2246
|
* @param {string} cwd
|
|
2247
|
+
* @param {string|null} [runIssueNumber] - issue number scoping the per-run TMPDIR (forge#3572)
|
|
2189
2248
|
* @returns {Record<string, (input: object) => string>}
|
|
2190
2249
|
*/
|
|
2191
|
-
export function getToolHandlers(cwd) {
|
|
2250
|
+
export function getToolHandlers(cwd, runIssueNumber = null) {
|
|
2192
2251
|
return {
|
|
2193
2252
|
read_file: ({ path }) => {
|
|
2194
2253
|
if (!path) throw new Error("read_file requires a 'path'");
|
|
@@ -2208,7 +2267,7 @@ export function getToolHandlers(cwd) {
|
|
|
2208
2267
|
// into the model loop, so a prompt-injection payload could otherwise issue
|
|
2209
2268
|
// a run_bash command that exfiltrates the key. gh/git auth tokens
|
|
2210
2269
|
// (GH_TOKEN/GITHUB_TOKEN) are intentionally left intact.
|
|
2211
|
-
const childEnv = withForgedockHome({ ...process.env });
|
|
2270
|
+
const childEnv = withRunTmpdir(withForgedockHome({ ...process.env }), { issue: runIssueNumber, cwd });
|
|
2212
2271
|
delete childEnv.ANTHROPIC_API_KEY;
|
|
2213
2272
|
// Run under bash when available so bash-style commands the model emits
|
|
2214
2273
|
// (heredocs, scripts/*.sh, single-quote semantics) behave consistently
|
|
@@ -2648,7 +2707,7 @@ export async function runCommand(opts = {}) {
|
|
|
2648
2707
|
// is a known Linux limitation at the OS level; closing it would require
|
|
2649
2708
|
// a native module (e.g. prctl PR_SET_DUMPABLE) or process isolation.
|
|
2650
2709
|
// See: issue #1370 for tracking this known limitation.
|
|
2651
|
-
const handlers = getToolHandlers(cwd);
|
|
2710
|
+
const handlers = getToolHandlers(cwd, issueFromArgs(args));
|
|
2652
2711
|
const messages = [{ role: "user", content: userMessage }];
|
|
2653
2712
|
|
|
2654
2713
|
// Accumulate token usage across all messages.create() calls in this run.
|
|
@@ -5,7 +5,7 @@
|
|
|
5
5
|
* Reads labels and ONLY trusted `<!-- FORGE:TELEMETRY v1 -->` comments (same predicate as
|
|
6
6
|
* scripts/trusted-comments.sh: an author_association in FORGE_TRAIL_TRUSTED_ASSOCIATIONS, a Bot, or a login in
|
|
7
7
|
* FORGE_TRAIL_TRUSTED_LOGINS). Every `gh` call is argv-based (no shell). The `gh` runner is injectable for tests.
|
|
8
|
-
* Fetching is incremental: only items updated after the cached watermark are re-read.
|
|
8
|
+
* Fetching is incremental: only items updated after the cached watermark (minus a small overlap window) are re-read.
|
|
9
9
|
*/
|
|
10
10
|
import { execFileSync } from "node:child_process";
|
|
11
11
|
import { emptyCache, loadCache, saveCache } from "./cache.mjs";
|
|
@@ -58,8 +58,10 @@ export function parseTelemetryComments(comments, env = process.env) {
|
|
|
58
58
|
const num = (x) => { const v = Number(x); return x != null && x !== "" && Number.isFinite(v) ? v : null; };
|
|
59
59
|
|
|
60
60
|
/**
|
|
61
|
-
* Turn `phase_attempt` / `run_terminal` records into run summaries
|
|
62
|
-
* `
|
|
61
|
+
* Turn `phase_attempt` / `run_terminal` records into run summaries, finished only when a `run_terminal` exists.
|
|
62
|
+
* The run id is per engine process, so a `--retry` yields several; they collapse to ONE run per repo#issue, the latest
|
|
63
|
+
* by start timestamp (tie: greater run id), matching scripts/cost-report.sh (last RUN_START slice). Finished/usage
|
|
64
|
+
* filtering stays downstream, so an unfinished latest run still excludes the issue. Field names are the ones bin/engine/telemetry-seam.mjs writes (docs/TELEMETRY.md).
|
|
63
65
|
*/
|
|
64
66
|
export function runsFromRecords(records) {
|
|
65
67
|
const byRun = new Map();
|
|
@@ -86,13 +88,74 @@ export function runsFromRecords(records) {
|
|
|
86
88
|
if (num(r.cost_usd) != null) p.usd = (p.usd ?? 0) + num(r.cost_usd);
|
|
87
89
|
if (id === "remediate") run.remediated = true;
|
|
88
90
|
}
|
|
89
|
-
|
|
91
|
+
const latest = new Map();
|
|
92
|
+
for (const run of byRun.values()) {
|
|
93
|
+
const k = `${run.repo}#${run.issue}`;
|
|
94
|
+
const cur = latest.get(k);
|
|
95
|
+
const a = run._first ?? "", b = cur?._first ?? "";
|
|
96
|
+
if (!cur || a > b || (a === b && (run.run ?? "") > (cur.run ?? ""))) latest.set(k, run);
|
|
97
|
+
}
|
|
98
|
+
return [...latest.values()].map(({ _first, ...run }) => ({ ...run, startTs: _first }));
|
|
90
99
|
}
|
|
91
100
|
|
|
92
101
|
/** Gate/review records (everything that is not a run record) become aggregate events. */
|
|
93
102
|
export const gateEvents = (records) => records.filter((r) => r.event !== "phase_attempt" && r.event !== "run_terminal")
|
|
94
103
|
.map((r) => ({ ...r, version: r.forge_version ?? null }));
|
|
95
104
|
|
|
105
|
+
const CHECKPOINT_EVERY = 25;
|
|
106
|
+
const PAGE_SIZE = 100;
|
|
107
|
+
// The next incremental run re-reads this much before the persisted watermark (an overlap window), so a write that races a
|
|
108
|
+
// crawl or a small clock skew is re-listed. Re-reads are idempotent: items are keyed by issue number.
|
|
109
|
+
const WATERMARK_OVERLAP_MS = 5 * 60 * 1000;
|
|
110
|
+
const MAX_LIST_REQUESTS = 100000;
|
|
111
|
+
|
|
112
|
+
/** Shift an ISO-8601 timestamp by `ms`; an unparsable value is returned unchanged. */
|
|
113
|
+
export function shiftIso(ts, ms) {
|
|
114
|
+
const t = Date.parse(ts);
|
|
115
|
+
return Number.isNaN(t) ? ts : new Date(t + ms).toISOString().replace(/\.\d{3}Z$/, "Z");
|
|
116
|
+
}
|
|
117
|
+
|
|
118
|
+
const sinceParam = (ts) => encodeURIComponent(ts).replace(/%3A/g, ":");
|
|
119
|
+
|
|
120
|
+
/**
|
|
121
|
+
* Keyset crawl of the issue listing. The listing is sorted by a MUTABLE key (updated_at), so page-number pagination
|
|
122
|
+
* (`gh --paginate`) can skip an item updated mid-crawl: it moves to the end of the ordering and the items behind it shift
|
|
123
|
+
* one page earlier. Instead every request is page 1 from `since=CURSOR`, the cursor moves to the newest `updated_at` read,
|
|
124
|
+
* and already-seen (number, updated_at) pairs are dropped (the `since` boundary is inclusive). A full page that cannot move
|
|
125
|
+
* the cursor (more than PAGE_SIZE items share one timestamp) is drained with `page=N` for that timestamp only.
|
|
126
|
+
* Yields each page's unseen items sorted updated-asc.
|
|
127
|
+
*/
|
|
128
|
+
function* listIssuePages(gh, repo, startSince) {
|
|
129
|
+
const base = ["state=all", "sort=updated", "direction=asc", `per_page=${PAGE_SIZE}`];
|
|
130
|
+
const seen = new Set();
|
|
131
|
+
const key = (it) => `${it.number}@${it.updated_at}`;
|
|
132
|
+
const take = (page) => {
|
|
133
|
+
const fresh = page.filter((it) => Number.isInteger(it?.number) && !seen.has(key(it)));
|
|
134
|
+
for (const it of fresh) seen.add(key(it));
|
|
135
|
+
return fresh.sort((a, b) => String(a.updated_at).localeCompare(String(b.updated_at)));
|
|
136
|
+
};
|
|
137
|
+
const maxTs = (page) => page.reduce((m, it) => (it?.updated_at && (!m || it.updated_at > m) ? it.updated_at : m), null);
|
|
138
|
+
let cursor = startSince ?? null;
|
|
139
|
+
for (let n = 0; n < MAX_LIST_REQUESTS; n++) {
|
|
140
|
+
const get = (extra = []) => parsePages(gh(["api", `repos/${repo}/issues?${[...base, ...(cursor ? [`since=${sinceParam(cursor)}`] : []), ...extra].join("&")}`]));
|
|
141
|
+
const page = get();
|
|
142
|
+
const fresh = take(page);
|
|
143
|
+
if (fresh.length > 0) yield fresh;
|
|
144
|
+
if (page.length < PAGE_SIZE) return;
|
|
145
|
+
const next = maxTs(page);
|
|
146
|
+
if (next && next !== cursor) { cursor = next; continue; }
|
|
147
|
+
// No cursor progress on a full page: drain this timestamp by page number, then step past it.
|
|
148
|
+
for (let p = 2; p < MAX_LIST_REQUESTS; p++) {
|
|
149
|
+
const more = get([`page=${p}`]);
|
|
150
|
+
const freshMore = take(more);
|
|
151
|
+
if (freshMore.length > 0) yield freshMore;
|
|
152
|
+
if (more.length < PAGE_SIZE || freshMore.length === 0) break;
|
|
153
|
+
}
|
|
154
|
+
if (!cursor) return;
|
|
155
|
+
cursor = shiftIso(cursor, 1000);
|
|
156
|
+
}
|
|
157
|
+
}
|
|
158
|
+
|
|
96
159
|
const meta = (it) => ({ number: it.number, isPr: Boolean(it.pull_request), createdAt: it.created_at ?? null, closedAt: it.closed_at ?? null,
|
|
97
160
|
updatedAt: it.updated_at ?? null, labels: (it.labels ?? []).map((l) => (typeof l === "string" ? l : l.name)).filter(Boolean), authorType: it.user?.type ?? null });
|
|
98
161
|
|
|
@@ -103,25 +166,33 @@ const meta = (it) => ({ number: it.number, isPr: Boolean(it.pull_request), creat
|
|
|
103
166
|
export function fetchGithubRecords({ repo, refresh = false, useCache = true, gh = defaultGh, env = process.env } = {}) {
|
|
104
167
|
if (!isValidRepo(repo)) throw new Error(`invalid repo: ${repo}`);
|
|
105
168
|
const cache = useCache && !refresh ? loadCache(repo, env) : emptyCache();
|
|
106
|
-
const
|
|
107
|
-
const listed = parsePages(gh(["api", "--paginate", `repos/${repo}/issues?${q.join("&")}`]));
|
|
169
|
+
const startSince = cache.watermark ? shiftIso(cache.watermark, -WATERMARK_OVERLAP_MS) : null;
|
|
108
170
|
const items = { ...cache.items };
|
|
109
171
|
let watermark = cache.watermark;
|
|
110
172
|
let fetched = 0;
|
|
111
|
-
|
|
112
|
-
|
|
113
|
-
|
|
114
|
-
|
|
115
|
-
|
|
116
|
-
|
|
117
|
-
|
|
118
|
-
records =
|
|
119
|
-
|
|
173
|
+
// Checkpoint: the watermark is the newest updated_at actually processed, and the next run starts WATERMARK_OVERLAP_MS
|
|
174
|
+
// before it, so it is a safe resume point. A failure mid-crawl (rate limit, 5xx, a hook timeout) then keeps the
|
|
175
|
+
// progress instead of restarting at zero.
|
|
176
|
+
const checkpoint = () => { if (useCache) saveCache(repo, { ...emptyCache(), watermark, items }, env); };
|
|
177
|
+
try {
|
|
178
|
+
for (const page of listIssuePages(gh, repo, startSince)) for (const it of page) {
|
|
179
|
+
const m = meta(it);
|
|
180
|
+
let records = items[it.number]?.records ?? [];
|
|
181
|
+
// A telemetry comment is edited in place (which may not bump the issue's updated_at), so every listed item with
|
|
182
|
+
// comments is re-read rather than trusting a cached copy.
|
|
183
|
+
if ((it.comments ?? 0) > 0) {
|
|
184
|
+
records = parseTelemetryComments(parsePages(gh(["api", "--paginate", `repos/${repo}/issues/${it.number}/comments`])), env);
|
|
185
|
+
fetched++;
|
|
186
|
+
}
|
|
187
|
+
items[it.number] = { ...m, records };
|
|
188
|
+
if (m.updatedAt && (!watermark || m.updatedAt > watermark)) watermark = m.updatedAt;
|
|
189
|
+
if (fetched > 0 && fetched % CHECKPOINT_EVERY === 0 && (it.comments ?? 0) > 0) checkpoint();
|
|
120
190
|
}
|
|
121
|
-
|
|
122
|
-
|
|
191
|
+
} catch (err) {
|
|
192
|
+
checkpoint();
|
|
193
|
+
throw err;
|
|
123
194
|
}
|
|
124
|
-
|
|
195
|
+
checkpoint();
|
|
125
196
|
const seen = new Set();
|
|
126
197
|
const records = [];
|
|
127
198
|
for (const it of Object.values(items)) for (const r of it.records ?? []) {
|