forgedock 1.16.2 → 1.17.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 +92 -0
- package/bin/engine/phases.mjs +86 -26
- package/bin/engine/projector.mjs +48 -1
- package/bin/engine/runlog.mjs +13 -5
- package/bin/engine/telemetry-seam.mjs +62 -0
- package/bin/engine/telemetry-seam.test.mjs +60 -0
- package/bin/engine/telemetry.mjs +148 -0
- package/bin/engine/telemetry.test.mjs +120 -0
- package/bin/engine-cli.mjs +12 -1
- package/bin/engine.mjs +145 -40
- package/bin/forgedock.mjs +7 -1
- package/bin/hooks/interactive-engine.mjs +10 -0
- package/bin/hooks/pre-tool-use.mjs +187 -0
- package/bin/runner.mjs +7 -0
- package/bin/stats/aggregate.mjs +231 -0
- package/bin/stats/cache.mjs +40 -0
- package/bin/stats/github-source.mjs +140 -0
- package/bin/stats/index.mjs +154 -0
- package/bin/stats/local-source.mjs +79 -0
- package/bin/stats/package.json +6 -0
- package/bin/stats/render.mjs +82 -0
- package/bin/stats/stats.test.mjs +224 -0
- package/commands/cleanup.md +83 -2
- package/commands/deploy-info.md +8 -4
- package/commands/orchestrate/phase-1-resolve.md +4 -1
- package/commands/orchestrate/phase-4-execution.md +17 -2
- package/commands/orchestrate/phase-5-cleanup.md +18 -2
- package/commands/pipeline-health/phase-1-context.md +2 -0
- package/commands/pipeline-health/phase-2-metrics.md +12 -5
- package/commands/pipeline-status.md +21 -1
- package/commands/review-pr-agents/protocols.md +1 -0
- package/commands/review-pr-agents.md +10 -2
- package/commands/review-pr-staging.md +102 -25
- package/commands/review-pr.md +119 -103
- package/commands/work-on/build/implement.md +26 -5
- package/commands/work-on/build/validate.md +3 -2
- package/commands/work-on/build.md +95 -64
- package/commands/work-on/close.md +17 -6
- package/commands/work-on/decompose.md +45 -4
- package/commands/work-on/investigate.md +6 -2
- package/commands/work-on/remediate.md +22 -6
- package/commands/work-on/review.md +2 -2
- package/docs/CONFIG.md +33 -0
- package/docs/OUTCOME-CODES.md +48 -0
- package/docs/RELEASE-NOTES.md +12 -0
- package/docs/STATS.md +62 -0
- package/docs/TELEMETRY.md +65 -0
- package/docs/WORK-ON-RUNTIME.md +13 -0
- package/docs/site/command-reference.md +1 -1
- package/docs/spec/label-state-machine.md +1 -1
- package/package.json +3 -2
- package/scripts/builder-resume-match.test.sh +58 -0
- package/scripts/cap-findings.sh +110 -0
- package/scripts/cap-findings.test.sh +130 -0
- package/scripts/check-spec-markers.sh +2 -0
- package/scripts/classify-finding.sh +65 -16
- package/scripts/classify-finding.test.sh +43 -1
- package/scripts/classify-grep-exit.sh +20 -0
- package/scripts/classify-grep-exit.test.sh +46 -0
- package/scripts/close-parent-c3.test.sh +54 -0
- package/scripts/cost-report.sh +146 -0
- package/scripts/cost-report.test.sh +80 -0
- package/scripts/diff-size.sh +5 -0
- package/scripts/doctor-pipeline-state.sh +43 -0
- package/scripts/file-deferred-followups.sh +159 -0
- package/scripts/file-deferred-followups.test.sh +106 -0
- package/scripts/fixtures/cost-report/a-merged1.jsonl +6 -0
- package/scripts/fixtures/cost-report/a-merged2-remediated.jsonl +7 -0
- package/scripts/fixtures/cost-report/a-needs-human.jsonl +4 -0
- package/scripts/fixtures/cost-report/b-merged.jsonl +3 -0
- package/scripts/fixtures/cost-report/c-merged-attempt-usage.jsonl +8 -0
- package/scripts/fixtures/cost-report/hydrated-no-usage.jsonl +3 -0
- package/scripts/fixtures/cost-report/old-log-no-version.jsonl +3 -0
- package/scripts/fixtures/cost-report/unfinished-no-terminal.jsonl +2 -0
- package/scripts/fixtures/cost-report/unfinished-reopened.jsonl +5 -0
- package/scripts/forge-root.test.sh +8 -3
- package/scripts/lib/fence-bootstrap.sh +4 -3
- package/scripts/lib/pr-issue-number.sh +18 -0
- package/scripts/lib/review-template-resolve.sh +41 -0
- package/scripts/lib/telemetry.sh +168 -0
- package/scripts/remediate-liveness.sh +42 -6
- package/scripts/review-acceptance-gate.sh +16 -7
- package/scripts/review-acceptance-gate.test.sh +13 -0
- package/scripts/review-pr-domain-map.test.sh +8 -3
- package/scripts/review-pr-gate2.test.sh +15 -0
- package/scripts/review-pr-staging-finding-filter.test.sh +44 -0
- package/scripts/review-pr-staging-scope.test.sh +65 -5
- package/scripts/review-prepare.sh +517 -0
- package/scripts/review-prepare.test.sh +270 -0
- package/scripts/review-roster.sh +172 -0
- package/scripts/review-roster.test.sh +163 -0
- package/scripts/run-shell-tests.sh +3 -0
- package/scripts/telemetry.test.sh +106 -0
- package/scripts/wait-ci-green.sh +8 -1
- package/templates/forge.yaml.minimal +9 -0
|
@@ -1,6 +1,7 @@
|
|
|
1
1
|
#!/usr/bin/env node
|
|
2
2
|
import { createRequire } from "module";
|
|
3
3
|
import { existsSync } from "fs";
|
|
4
|
+
import { spawnSync } from "child_process";
|
|
4
5
|
import { dirname, join, resolve as resolvePath } from "path";
|
|
5
6
|
import { fileURLToPath, pathToFileURL } from "url";
|
|
6
7
|
const _require = createRequire(import.meta.url);
|
|
@@ -115,6 +116,23 @@ async function getInvariants() {
|
|
|
115
116
|
* starting Claude Code (operator-set only — same process.env semantics as
|
|
116
117
|
* the gist guard above).
|
|
117
118
|
*
|
|
119
|
+
* 9. Main-checkout mutation guard (issue #3650) — ADVISORY by default
|
|
120
|
+
* Enforced (exit 2) only when FORGE_ENFORCE_MAIN_CHECKOUT_GIT=1. Otherwise it
|
|
121
|
+
* writes a warning to stderr and allows the call: shipped specs (fix-ci,
|
|
122
|
+
* milestone, test-gate, cleanup, rollback, recover-orphans, spec-doctor,
|
|
123
|
+
* orchestrate phase-4) still run these verbs in the main checkout on purpose,
|
|
124
|
+
* and a denial blocks the whole Bash call (their `|| true` fallbacks never run).
|
|
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
|
|
127
|
+
* `gh pr checkout` when the shell's cwd is the main (non-worktree) checkout
|
|
128
|
+
* (`git rev-parse --git-dir` equals `--git-common-dir`). Pipeline workers and
|
|
129
|
+
* reviewers share that tree with the operator; mutating it races with them.
|
|
130
|
+
* Read-only git, `git checkout -b`, `git worktree ...`, and anything in a
|
|
131
|
+
* linked worktree stay allowed. Fails open when the repo cannot be resolved,
|
|
132
|
+
* and treats `git -C <dir>`, `--git-dir`, `--work-tree` and a leading `cd` as
|
|
133
|
+
* indeterminate (allowed). Override: FORGE_ALLOW_MAIN_CHECKOUT_GIT=1 in the
|
|
134
|
+
* shell environment (operator-set only).
|
|
135
|
+
*
|
|
118
136
|
* Rules 1-4 and 6 only apply inside a ForgeDock-managed directory (a directory
|
|
119
137
|
* with a `forge.yaml` or `.forgedock` marker) — see `isForgeDockManagedCwd()`
|
|
120
138
|
* (issue #1591). The hook installs into the user's global
|
|
@@ -566,6 +584,20 @@ async function main() {
|
|
|
566
584
|
return;
|
|
567
585
|
}
|
|
568
586
|
|
|
587
|
+
// --- Rule 9: Main-checkout mutation guard (issue #3650) ---
|
|
588
|
+
// Deliberately BEFORE the managed-cwd gate: pipeline workers run with cwd = a linked worktree (no
|
|
589
|
+
// forge.yaml), and `git -C <main> ...` from there is the case this rule exists for.
|
|
590
|
+
const mainCheckoutViolation = checkMainCheckoutMutation(command);
|
|
591
|
+
if (mainCheckoutViolation) {
|
|
592
|
+
if (process.env.FORGE_ENFORCE_MAIN_CHECKOUT_GIT === "1") {
|
|
593
|
+
process.stderr.write(mainCheckoutViolation);
|
|
594
|
+
process.exit(2);
|
|
595
|
+
return;
|
|
596
|
+
}
|
|
597
|
+
// Advisory (default): several shipped specs still mutate the main checkout on purpose (#3873).
|
|
598
|
+
process.stderr.write("WARNING (advisory, not blocked): " + mainCheckoutViolation.replace(/^BLOCKED: /, ""));
|
|
599
|
+
}
|
|
600
|
+
|
|
569
601
|
// Only enforce Rules 1-4 and 6 inside a ForgeDock-managed project — this hook
|
|
570
602
|
// installs globally (~/.claude/settings.json), so without this guard those
|
|
571
603
|
// rules would fire in every unrelated repo on the machine (issue #1591).
|
|
@@ -1233,6 +1265,161 @@ function checkTempPathStaging(command) {
|
|
|
1233
1265
|
return null;
|
|
1234
1266
|
}
|
|
1235
1267
|
|
|
1268
|
+
// ---------------------------------------------------------------------------
|
|
1269
|
+
// Rule 9: Main-checkout mutation guard (issue #3650)
|
|
1270
|
+
// ---------------------------------------------------------------------------
|
|
1271
|
+
|
|
1272
|
+
/**
|
|
1273
|
+
* Classify one command segment as a main-checkout mutation.
|
|
1274
|
+
*
|
|
1275
|
+
* The option tables live INSIDE the function: `main()` runs at module top via
|
|
1276
|
+
* `await`, before module-level `const`s declared further down are initialised,
|
|
1277
|
+
* so a module-level table throws (TDZ) and the hook fails open.
|
|
1278
|
+
*
|
|
1279
|
+
* @param {string[]} tokens
|
|
1280
|
+
* @param {number} [depth] wrapper-unwrap depth (bounded)
|
|
1281
|
+
* @returns {{name: string, dir: string|null}|null} offender and the `-C` dir it targets, or null
|
|
1282
|
+
*/
|
|
1283
|
+
function mutatingGitCommand(tokens, depth = 0) {
|
|
1284
|
+
// Strip grouping/compound prefixes: `(git x)`, `{ git x; }`, `then git x`, `do git x`, `! git x`.
|
|
1285
|
+
tokens = tokens.slice();
|
|
1286
|
+
for (;;) {
|
|
1287
|
+
if (/^[({]+$/.test(tokens[0] || "") || ["then", "do", "else", "elif", "!", "if", "while", "until"].includes(tokens[0])) tokens.shift();
|
|
1288
|
+
else if (/^[({]+[^({]/.test(tokens[0] || "")) tokens[0] = tokens[0].replace(/^[({]+/, "");
|
|
1289
|
+
else break;
|
|
1290
|
+
}
|
|
1291
|
+
const valueOpts = new Set(["-c", "--exec-path", "--namespace", "--super-prefix", "--config-env"]);
|
|
1292
|
+
let i = 0;
|
|
1293
|
+
// Skip leading env assignments (`FOO=1 git ...`) and transparent wrappers.
|
|
1294
|
+
for (;;) {
|
|
1295
|
+
while (i < tokens.length && /^[A-Za-z_][A-Za-z0-9_]*=/.test(tokens[i])) i++;
|
|
1296
|
+
const w = String(tokens[i] || "").replace(/^.*\//, "");
|
|
1297
|
+
if (["sudo", "env", "command", "exec", "nice", "nohup", "time", "xargs", "builtin"].includes(w)) {
|
|
1298
|
+
i++;
|
|
1299
|
+
while (i < tokens.length && (tokens[i].startsWith("-") || /^[A-Za-z_][A-Za-z0-9_]*=/.test(tokens[i]))) i++;
|
|
1300
|
+
continue;
|
|
1301
|
+
}
|
|
1302
|
+
if (["bash", "sh", "zsh", "dash"].includes(w) || w === "eval") {
|
|
1303
|
+
if (depth >= 3) return null;
|
|
1304
|
+
const k = w === "eval" ? i + 1 : tokens.findIndex((t, n) => n > i && /^-[a-z]*c$/.test(t)) + 1;
|
|
1305
|
+
if (k <= i || k >= tokens.length) return null;
|
|
1306
|
+
const inner = w === "eval" ? tokens.slice(k).join(" ") : tokens[k];
|
|
1307
|
+
let hit = null;
|
|
1308
|
+
for (const seg of extractCommandSegments(inner)) hit = hit || mutatingGitCommand(seg, depth + 1);
|
|
1309
|
+
return hit;
|
|
1310
|
+
}
|
|
1311
|
+
break;
|
|
1312
|
+
}
|
|
1313
|
+
const cmd = String(tokens[i] || "").replace(/^.*\//, "");
|
|
1314
|
+
if (cmd === "gh") {
|
|
1315
|
+
return tokens[i + 1] === "pr" && tokens[i + 2] === "checkout" ? { name: "gh pr checkout", dir: null } : null;
|
|
1316
|
+
}
|
|
1317
|
+
if (cmd !== "git") return null;
|
|
1318
|
+
i++;
|
|
1319
|
+
let dir = null;
|
|
1320
|
+
while (i < tokens.length && tokens[i].startsWith("-")) {
|
|
1321
|
+
if (tokens[i] === "-C") { dir = dir === null ? (tokens[i + 1] ?? ".") : resolvePath(dir, tokens[i + 1] ?? "."); i += 2; continue; }
|
|
1322
|
+
const wt = /^--work-tree(?:=(.*))?$/.exec(tokens[i]);
|
|
1323
|
+
if (wt) { const v = wt[1] ?? tokens[i + 1] ?? "."; dir = dir === null ? v : resolvePath(dir, v); i += wt[1] === undefined ? 2 : 1; continue; }
|
|
1324
|
+
if (/^--git-dir(?:=.*)?$/.test(tokens[i])) { i += tokens[i].includes("=") ? 1 : 2; continue; } // work tree stays the cwd/-C
|
|
1325
|
+
i += valueOpts.has(tokens[i]) ? 2 : 1;
|
|
1326
|
+
}
|
|
1327
|
+
const sub = tokens[i];
|
|
1328
|
+
// Arguments end at a shell comment; options end at `--`.
|
|
1329
|
+
let rest = tokens.slice(i + 1);
|
|
1330
|
+
const hash = rest.findIndex((t) => t.startsWith("#"));
|
|
1331
|
+
if (hash >= 0) rest = rest.slice(0, hash);
|
|
1332
|
+
const dd = rest.indexOf("--");
|
|
1333
|
+
const opts = dd >= 0 ? rest.slice(0, dd) : rest;
|
|
1334
|
+
const hit = (name) => ({ name, dir });
|
|
1335
|
+
switch (sub) {
|
|
1336
|
+
case "checkout":
|
|
1337
|
+
// Creating a new branch is the one sanctioned form (`git checkout -b`).
|
|
1338
|
+
return opts.some((t) => t === "-b") ? null : hit("git checkout");
|
|
1339
|
+
case "switch":
|
|
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": case "restore":
|
|
1342
|
+
return hit(`git ${sub}`);
|
|
1343
|
+
case "clean":
|
|
1344
|
+
return opts.some((t) => t === "-n" || t === "--dry-run") ? null : hit("git clean");
|
|
1345
|
+
case "branch":
|
|
1346
|
+
return opts.some((t) => /^(?:-[a-zA-Z]*[fFmMdD][a-zA-Z]*|--force|--move|--delete)$/.test(t)) ? hit("git branch") : null;
|
|
1347
|
+
case "reset":
|
|
1348
|
+
return opts.some((t) => t === "--hard" || t === "--merge" || t === "--keep") ? hit("git reset") : null;
|
|
1349
|
+
case "stash": {
|
|
1350
|
+
const action = opts.find((t) => !t.startsWith("-"));
|
|
1351
|
+
return action === "list" || action === "show" ? null : hit("git stash");
|
|
1352
|
+
}
|
|
1353
|
+
default:
|
|
1354
|
+
return null;
|
|
1355
|
+
}
|
|
1356
|
+
}
|
|
1357
|
+
|
|
1358
|
+
/**
|
|
1359
|
+
* Is `cwd` the main (non-linked-worktree) checkout of a git repo?
|
|
1360
|
+
* Fails closed-to-allow: any error or non-git dir returns false.
|
|
1361
|
+
* @param {string} cwd
|
|
1362
|
+
* @returns {boolean}
|
|
1363
|
+
*/
|
|
1364
|
+
function isMainCheckout(cwd) {
|
|
1365
|
+
try {
|
|
1366
|
+
const r = spawnSync(
|
|
1367
|
+
"git",
|
|
1368
|
+
["rev-parse", "--path-format=absolute", "--git-dir", "--git-common-dir"],
|
|
1369
|
+
{ cwd, encoding: "utf-8", timeout: 5000 },
|
|
1370
|
+
);
|
|
1371
|
+
if (r.status !== 0) return false;
|
|
1372
|
+
const [gitDir, commonDir] = String(r.stdout).trim().split(/\r?\n/);
|
|
1373
|
+
if (!gitDir || !commonDir) return false;
|
|
1374
|
+
return resolvePath(cwd, gitDir) === resolvePath(cwd, commonDir);
|
|
1375
|
+
} catch {
|
|
1376
|
+
return false;
|
|
1377
|
+
}
|
|
1378
|
+
}
|
|
1379
|
+
|
|
1380
|
+
/**
|
|
1381
|
+
* Rule 9: deny mutating git/gh commands aimed at the main checkout.
|
|
1382
|
+
* @param {string} command
|
|
1383
|
+
* @returns {string|null} stderr message when denied, else null
|
|
1384
|
+
*/
|
|
1385
|
+
function checkMainCheckoutMutation(command) {
|
|
1386
|
+
if (process.env.FORGE_ALLOW_MAIN_CHECKOUT_GIT === "1") return null;
|
|
1387
|
+
if (!/\b(?:git|gh)\b/.test(command)) return null;
|
|
1388
|
+
try {
|
|
1389
|
+
let offender = null;
|
|
1390
|
+
let baseDir = process.cwd();
|
|
1391
|
+
let targetDir = baseDir;
|
|
1392
|
+
for (const tokens of extractCommandSegments(command)) {
|
|
1393
|
+
const head = tokens.slice();
|
|
1394
|
+
while (head.length && (/^[({]+$/.test(head[0]) || ["then", "do", "else", "!"].includes(head[0]))) head.shift();
|
|
1395
|
+
if (head.length && /^[({]+[^({]/.test(head[0])) head[0] = head[0].replace(/^[({]+/, "");
|
|
1396
|
+
if (head[0] === "cd" || head[0] === "pushd") {
|
|
1397
|
+
// Follow a literal target; a variable/substitution/`-` makes later segments' cwd indeterminate: allow those.
|
|
1398
|
+
const t = head[1];
|
|
1399
|
+
if (!t || /[$`~]/.test(t) || t === "-") break;
|
|
1400
|
+
baseDir = resolvePath(baseDir, t);
|
|
1401
|
+
continue;
|
|
1402
|
+
}
|
|
1403
|
+
const hit = mutatingGitCommand(tokens);
|
|
1404
|
+
if (hit) { offender = hit.name; targetDir = hit.dir === null ? baseDir : resolvePath(baseDir, hit.dir); break; }
|
|
1405
|
+
}
|
|
1406
|
+
if (!offender) return null;
|
|
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
|
+
);
|
|
1418
|
+
} catch {
|
|
1419
|
+
return null; // fail open
|
|
1420
|
+
}
|
|
1421
|
+
}
|
|
1422
|
+
|
|
1236
1423
|
// ---------------------------------------------------------------------------
|
|
1237
1424
|
// Rule 8: Dangerous `rm` target guard (issue #2856)
|
|
1238
1425
|
//
|
package/bin/runner.mjs
CHANGED
|
@@ -1609,10 +1609,15 @@ export function runCliBackend({
|
|
|
1609
1609
|
let parsedResult = null;
|
|
1610
1610
|
let usage = null;
|
|
1611
1611
|
let turns = null;
|
|
1612
|
+
// forge#3696: envelope cost/duration per attempt (null when absent or non-finite).
|
|
1613
|
+
let costUsd = null;
|
|
1614
|
+
let durationMs = null;
|
|
1612
1615
|
try {
|
|
1613
1616
|
const parsed = JSON.parse(stdoutTrimmed);
|
|
1614
1617
|
if (parsed && typeof parsed === "object") {
|
|
1615
1618
|
if (Number.isFinite(parsed.num_turns)) turns = parsed.num_turns;
|
|
1619
|
+
if (Number.isFinite(parsed.total_cost_usd)) costUsd = parsed.total_cost_usd;
|
|
1620
|
+
if (Number.isFinite(parsed.duration_ms)) durationMs = parsed.duration_ms;
|
|
1616
1621
|
parsedResult = typeof parsed.result === "string" ? parsed.result : null;
|
|
1617
1622
|
if (parsed.usage && typeof parsed.usage === "object") {
|
|
1618
1623
|
usage = {
|
|
@@ -1697,6 +1702,8 @@ export function runCliBackend({
|
|
|
1697
1702
|
// and the envelope's turn count (null when absent), for runlog recording.
|
|
1698
1703
|
cliModel: modelFlag,
|
|
1699
1704
|
turns,
|
|
1705
|
+
costUsd,
|
|
1706
|
+
durationMs,
|
|
1700
1707
|
backend: "cli",
|
|
1701
1708
|
// forge#3521: the phase's final reply (the CLI envelope's `.result`), so
|
|
1702
1709
|
// callers can read its structured `*_RESULT` block. Additive; "" when absent.
|
|
@@ -0,0 +1,231 @@
|
|
|
1
|
+
// SPDX-FileCopyrightText: Copyright (c) RapierCraft Studios
|
|
2
|
+
// SPDX-License-Identifier: AGPL-3.0-or-later
|
|
3
|
+
/**
|
|
4
|
+
* Pure, source-agnostic aggregation for `forgedock stats` (forge#3835). No I/O, no clock, no model.
|
|
5
|
+
*
|
|
6
|
+
* Input record model (every source maps onto it; a missing field is null and degrades to "n/a"):
|
|
7
|
+
* run { source, repo, version, issue, run, finished, hydrated, usageEvents, reason, code, remediated,
|
|
8
|
+
* startTs, endTs, wallMs, phases: { <id>: { cr, out, usd, ms, attempts } } }
|
|
9
|
+
* event { repo, version, issue, pr, ts, event, ...payload } (FORGE:TELEMETRY gate/review events)
|
|
10
|
+
* issue { repo, number, isPr, createdAt, closedAt, labels: [name], authorType }
|
|
11
|
+
*
|
|
12
|
+
* Counting rules reproduce scripts/cost-report.sh so both reports agree on the same logs: only finished runs
|
|
13
|
+
* count, hydrated runs and runs with no usage events are excluded, tokens are cache-read tokens, and the
|
|
14
|
+
* per-phase mean divides by n (a run that skipped a phase counts 0).
|
|
15
|
+
*/
|
|
16
|
+
|
|
17
|
+
export const DEFAULT_MIN_N = 10;
|
|
18
|
+
export const UNKNOWN = "unknown";
|
|
19
|
+
|
|
20
|
+
const PHASES = ["investigate", "build", "review", "remediate", "close"];
|
|
21
|
+
const isNum = (x) => typeof x === "number" && Number.isFinite(x);
|
|
22
|
+
const sum = (a) => a.reduce((s, x) => s + x, 0);
|
|
23
|
+
const mean = (a) => (a.length ? sum(a) / a.length : null);
|
|
24
|
+
|
|
25
|
+
/** p=50 is the median (average of the middle pair, as cost-report.sh); any other p is nearest-rank. */
|
|
26
|
+
export function percentile(values, p) {
|
|
27
|
+
const v = values.filter(isNum).sort((a, b) => a - b);
|
|
28
|
+
if (!v.length) return null;
|
|
29
|
+
if (p === 50) return v.length % 2 ? v[(v.length - 1) / 2] : (v[v.length / 2 - 1] + v[v.length / 2]) / 2;
|
|
30
|
+
return v[Math.max(0, Math.ceil((v.length * p) / 100) - 1)];
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
/** Sort key for "1.16.1" / "1.16.1~": numeric parts, unparseable parts count as 0. */
|
|
34
|
+
export function versionKey(v) {
|
|
35
|
+
return String(v).replace(/~$/, "").split(".").map((x) => (/^\d+$/.test(x) ? Number(x) : 0));
|
|
36
|
+
}
|
|
37
|
+
export function compareVersions(a, b) {
|
|
38
|
+
const x = versionKey(a); const y = versionKey(b);
|
|
39
|
+
for (let i = 0; i < Math.max(x.length, y.length); i++) {
|
|
40
|
+
const d = (x[i] ?? 0) - (y[i] ?? 0);
|
|
41
|
+
if (d) return d;
|
|
42
|
+
}
|
|
43
|
+
return 0;
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
/** releaseTimes: [{ v, t }] with t in epoch seconds. Latest release at or before `ms`, marked "~" (estimated). */
|
|
47
|
+
export function estimateVersion(ms, releaseTimes) {
|
|
48
|
+
if (!isNum(ms)) return UNKNOWN;
|
|
49
|
+
let best = null;
|
|
50
|
+
for (const r of releaseTimes ?? []) if (r.t * 1000 <= ms && (!best || r.t > best.t)) best = r;
|
|
51
|
+
return best ? `${best.v}~` : UNKNOWN;
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
const toMs = (ts) => { const t = Date.parse(ts ?? ""); return Number.isNaN(t) ? null : t; };
|
|
55
|
+
const ratio = (num, den) => (den > 0 ? num / den : null);
|
|
56
|
+
const sub = (a, b) => (isNum(a) && isNum(b) ? a - b : null);
|
|
57
|
+
|
|
58
|
+
function issueSource(i) {
|
|
59
|
+
if (i.labels.includes("review-finding")) return "review-finding";
|
|
60
|
+
return i.authorType === "Bot" ? "pipeline" : "human";
|
|
61
|
+
}
|
|
62
|
+
function closedKind(i) {
|
|
63
|
+
if (i.labels.includes("workflow:merged")) return "merged";
|
|
64
|
+
if (i.labels.includes("workflow:invalid") || i.labels.includes("invalid")) return "invalid";
|
|
65
|
+
if (i.labels.includes("duplicate")) return "duplicate";
|
|
66
|
+
return "other";
|
|
67
|
+
}
|
|
68
|
+
|
|
69
|
+
function costSection(runs) {
|
|
70
|
+
const n = runs.length;
|
|
71
|
+
const cr = runs.map((r) => sum(Object.values(r.phases).map((p) => p.cr ?? 0)));
|
|
72
|
+
const merged = runs.filter((r) => r.reason === "merged").length;
|
|
73
|
+
const usd = runs.map((r) => {
|
|
74
|
+
const v = Object.values(r.phases).map((p) => p.usd).filter(isNum);
|
|
75
|
+
return v.length ? sum(v) : null;
|
|
76
|
+
}).filter(isNum);
|
|
77
|
+
const perPhase = {};
|
|
78
|
+
for (const id of [...PHASES, ...new Set(runs.flatMap((r) => Object.keys(r.phases)).filter((p) => !PHASES.includes(p)))]) {
|
|
79
|
+
perPhase[id] = n ? sum(runs.map((r) => r.phases[id]?.cr ?? 0)) / n : null;
|
|
80
|
+
}
|
|
81
|
+
return {
|
|
82
|
+
n, cacheReadMean: mean(cr), cacheReadMedian: percentile(cr, 50), perPhase,
|
|
83
|
+
outputMean: n ? sum(runs.map((r) => sum(Object.values(r.phases).map((p) => p.out ?? 0)))) / n : null,
|
|
84
|
+
perMergedIssue: merged ? sum(cr) / merged : null,
|
|
85
|
+
usdN: usd.length, usdMean: mean(usd), usdTotal: usd.length ? sum(usd) : null,
|
|
86
|
+
};
|
|
87
|
+
}
|
|
88
|
+
|
|
89
|
+
function timeSection(runs) {
|
|
90
|
+
const phases = {};
|
|
91
|
+
const ids = new Set(runs.flatMap((r) => Object.keys(r.phases)));
|
|
92
|
+
for (const id of ids) {
|
|
93
|
+
const ms = runs.map((r) => r.phases[id]?.ms).filter((x) => isNum(x) && x > 0);
|
|
94
|
+
if (ms.length) phases[id] = { n: ms.length, p50: percentile(ms, 50), p90: percentile(ms, 90) };
|
|
95
|
+
}
|
|
96
|
+
const wall = runs.map((r) => r.wallMs).filter((x) => isNum(x) && x >= 0);
|
|
97
|
+
return { phases, wall: { n: wall.length, p50: percentile(wall, 50), p90: percentile(wall, 90) } };
|
|
98
|
+
}
|
|
99
|
+
|
|
100
|
+
function flowSection(runs) {
|
|
101
|
+
const n = runs.length;
|
|
102
|
+
const codes = {};
|
|
103
|
+
let coded = 0;
|
|
104
|
+
for (const r of runs) if (r.code) { codes[r.code] = (codes[r.code] ?? 0) + 1; coded++; }
|
|
105
|
+
const top = Object.entries(codes).sort((a, b) => b[1] - a[1] || a[0].localeCompare(b[0]))
|
|
106
|
+
.map(([code, count]) => ({ code, count, pct: count / coded }));
|
|
107
|
+
return {
|
|
108
|
+
n,
|
|
109
|
+
mergedPct: ratio(runs.filter((r) => r.reason === "merged").length, n),
|
|
110
|
+
needsHumanPct: ratio(runs.filter((r) => r.reason === "needs-human").length, n),
|
|
111
|
+
remediationPct: ratio(runs.filter((r) => r.remediated).length, n),
|
|
112
|
+
codesN: coded, codes: top,
|
|
113
|
+
};
|
|
114
|
+
}
|
|
115
|
+
|
|
116
|
+
function funnelSection(events, issues) {
|
|
117
|
+
const findings = events.filter((e) => e.event === "finding_disposition");
|
|
118
|
+
const reviews = new Set([...events.filter((e) => e.event === "review_panel"), ...findings]
|
|
119
|
+
.map((e) => `${e.repo}#${e.pr ?? e.issue}`));
|
|
120
|
+
const bySeverity = {};
|
|
121
|
+
for (const f of findings) {
|
|
122
|
+
const s = String(f.severity ?? "unknown").toUpperCase();
|
|
123
|
+
const d = (bySeverity[s] ??= { total: 0, ISSUE: 0, NOTE: 0, INPR_FIX: 0, other: 0 });
|
|
124
|
+
d.total++;
|
|
125
|
+
if (f.disposition in d) d[f.disposition]++; else d.other++;
|
|
126
|
+
}
|
|
127
|
+
for (const d of Object.values(bySeverity)) d.perReview = reviews.size ? d.total / reviews.size : null;
|
|
128
|
+
const dispositions = { ISSUE: 0, NOTE: 0, INPR_FIX: 0 };
|
|
129
|
+
for (const f of findings) if (f.disposition in dispositions) dispositions[f.disposition]++;
|
|
130
|
+
return {
|
|
131
|
+
reviews: reviews.size, findings: findings.length, perReview: ratio(findings.length, reviews.size),
|
|
132
|
+
bySeverity, dispositions,
|
|
133
|
+
validated: issues.filter((i) => i.labels.includes("validated")).length,
|
|
134
|
+
falsePositive: issues.filter((i) => i.labels.includes("false-positive")).length,
|
|
135
|
+
};
|
|
136
|
+
}
|
|
137
|
+
|
|
138
|
+
function gatesSection(events) {
|
|
139
|
+
const of = (name) => events.filter((e) => e.event === name);
|
|
140
|
+
const acc = of("acceptance_gate"); const size = of("diff_size"); const ci = of("ci_gate"); const live = of("liveness");
|
|
141
|
+
const blocked = (list, fn) => ({ n: list.length, blocked: list.filter(fn).length, rate: ratio(list.filter(fn).length, list.length) });
|
|
142
|
+
const malformed = acc.filter((e) => Number(e.malformed) > 0);
|
|
143
|
+
const count = (list, key, val) => list.filter((e) => e[key] === val).length;
|
|
144
|
+
return {
|
|
145
|
+
acceptance: blocked(acc, (e) => Number(e.failed) > 0),
|
|
146
|
+
malformedRate: ratio(malformed.length, acc.length),
|
|
147
|
+
diffSize: { ...blocked(size, (e) => e.result === "OVER"), over: count(size, "result", "OVER"), cohesive: count(size, "result", "COHESIVE") },
|
|
148
|
+
ci: blocked(ci, (e) => e.result !== "PASS"),
|
|
149
|
+
liveness: blocked(live, (e) => e.decision === "DEFER" || e.decision === "EXHAUSTED"),
|
|
150
|
+
};
|
|
151
|
+
}
|
|
152
|
+
|
|
153
|
+
function issueFlowSection(issues) {
|
|
154
|
+
const real = issues.filter((i) => !i.isPr);
|
|
155
|
+
const created = {};
|
|
156
|
+
for (const i of real) { const s = issueSource(i); created[s] = (created[s] ?? 0) + 1; }
|
|
157
|
+
const closedIssues = real.filter((i) => i.closedAt);
|
|
158
|
+
const closed = {};
|
|
159
|
+
for (const i of closedIssues) { const k = closedKind(i); closed[k] = (closed[k] ?? 0) + 1; }
|
|
160
|
+
const merged = closed.merged ?? 0;
|
|
161
|
+
return {
|
|
162
|
+
n: real.length, created, createdTotal: real.length, closed, closedTotal: closedIssues.length,
|
|
163
|
+
net: real.length ? real.length - closedIssues.length : null,
|
|
164
|
+
findingsPerMerge: ratio(created["review-finding"] ?? 0, merged),
|
|
165
|
+
};
|
|
166
|
+
}
|
|
167
|
+
|
|
168
|
+
function deltaOf(cur, prev) {
|
|
169
|
+
if (!prev) return null;
|
|
170
|
+
return {
|
|
171
|
+
against: prev.version,
|
|
172
|
+
cacheReadMean: sub(cur.cost.cacheReadMean, prev.cost.cacheReadMean),
|
|
173
|
+
mergedPct: sub(cur.flow.mergedPct, prev.flow.mergedPct),
|
|
174
|
+
needsHumanPct: sub(cur.flow.needsHumanPct, prev.flow.needsHumanPct),
|
|
175
|
+
remediationPct: sub(cur.flow.remediationPct, prev.flow.remediationPct),
|
|
176
|
+
wallP50: sub(cur.time.wall.p50, prev.time.wall.p50),
|
|
177
|
+
};
|
|
178
|
+
}
|
|
179
|
+
|
|
180
|
+
/**
|
|
181
|
+
* Join runs, gate events and issues into per-(version, repo) groups with a delta against the previous version of
|
|
182
|
+
* the same repo and a low-n flag. opts: { minN, releaseTimes, sinceMs, sinceVersion }.
|
|
183
|
+
*/
|
|
184
|
+
export function aggregate({ runs = [], events = [], issues = [] } = {}, opts = {}) {
|
|
185
|
+
const minN = isNum(opts.minN) ? opts.minN : DEFAULT_MIN_N;
|
|
186
|
+
const rel = opts.releaseTimes ?? [];
|
|
187
|
+
const sinceMs = opts.sinceMs ?? null;
|
|
188
|
+
const inWindow = (...ts) => sinceMs == null || ts.some((t) => (toMs(t) ?? -Infinity) >= sinceMs);
|
|
189
|
+
|
|
190
|
+
const excluded = { unfinished: 0, noUsage: 0 };
|
|
191
|
+
const counted = [];
|
|
192
|
+
for (const r of runs) {
|
|
193
|
+
if (!r.finished) { excluded.unfinished++; continue; }
|
|
194
|
+
if (r.hydrated || !(r.usageEvents > 0)) { excluded.noUsage++; continue; }
|
|
195
|
+
if (!inWindow(r.endTs, r.startTs)) continue;
|
|
196
|
+
counted.push({ ...r, version: r.version ?? estimateVersion(toMs(r.endTs ?? r.startTs ?? r.mtimeTs), rel), repo: r.repo ?? UNKNOWN });
|
|
197
|
+
}
|
|
198
|
+
|
|
199
|
+
const versionOfIssue = new Map();
|
|
200
|
+
for (const r of counted) if (r.issue != null && r.version !== UNKNOWN) versionOfIssue.set(`${r.repo}#${r.issue}`, r.version);
|
|
201
|
+
const evs = events.filter((e) => inWindow(e.ts)).map((e) => ({
|
|
202
|
+
...e, repo: e.repo ?? UNKNOWN,
|
|
203
|
+
version: e.version ?? e.forge_version ?? estimateVersion(toMs(e.ts), rel),
|
|
204
|
+
}));
|
|
205
|
+
for (const e of evs) if (e.issue != null && e.version !== UNKNOWN && !versionOfIssue.has(`${e.repo}#${e.issue}`)) versionOfIssue.set(`${e.repo}#${e.issue}`, e.version);
|
|
206
|
+
const iss = issues.filter((i) => inWindow(i.createdAt, i.closedAt)).map((i) => ({
|
|
207
|
+
...i, repo: i.repo ?? UNKNOWN, labels: i.labels ?? [],
|
|
208
|
+
version: versionOfIssue.get(`${i.repo}#${i.number}`) ?? estimateVersion(toMs(i.createdAt), rel),
|
|
209
|
+
}));
|
|
210
|
+
|
|
211
|
+
const keys = new Map();
|
|
212
|
+
for (const x of [...counted, ...evs, ...iss]) keys.set(`${x.version}\u0000${x.repo}`, { version: x.version, repo: x.repo });
|
|
213
|
+
|
|
214
|
+
let groups = [...keys.values()].map(({ version, repo }) => {
|
|
215
|
+
const sel = (list) => list.filter((x) => x.version === version && x.repo === repo);
|
|
216
|
+
const g = { version, repo, cost: costSection(sel(counted)), time: timeSection(sel(counted)), flow: flowSection(sel(counted)),
|
|
217
|
+
funnel: funnelSection(sel(evs), sel(iss)), gates: gatesSection(sel(evs)), issueFlow: issueFlowSection(sel(iss)) };
|
|
218
|
+
g.n = g.flow.n;
|
|
219
|
+
g.lowN = g.n < minN;
|
|
220
|
+
g.minN = minN;
|
|
221
|
+
return g;
|
|
222
|
+
});
|
|
223
|
+
groups.sort((a, b) => a.repo.localeCompare(b.repo) || (a.version === UNKNOWN) - (b.version === UNKNOWN)
|
|
224
|
+
|| compareVersions(a.version, b.version));
|
|
225
|
+
for (let i = 0; i < groups.length; i++) {
|
|
226
|
+
const prev = [...groups.slice(0, i)].reverse().find((p) => p.repo === groups[i].repo && p.version !== UNKNOWN);
|
|
227
|
+
groups[i].delta = groups[i].version === UNKNOWN ? null : deltaOf(groups[i], prev);
|
|
228
|
+
}
|
|
229
|
+
if (opts.sinceVersion) groups = groups.filter((g) => g.version !== UNKNOWN && compareVersions(g.version, opts.sinceVersion) >= 0);
|
|
230
|
+
return { groups, excluded, minN };
|
|
231
|
+
}
|
|
@@ -0,0 +1,40 @@
|
|
|
1
|
+
// SPDX-FileCopyrightText: Copyright (c) RapierCraft Studios
|
|
2
|
+
// SPDX-License-Identifier: AGPL-3.0-or-later
|
|
3
|
+
/**
|
|
4
|
+
* Optional local cache for `forgedock stats` (forge#3835): ~/.forge/stats-cache/<owner>_<repo>/cache.json.
|
|
5
|
+
* GitHub is the source of truth; this only stores what was already fetched plus the `updated_at` watermark so a
|
|
6
|
+
* refresh is incremental. A missing, unreadable or wrong-version cache is treated as empty (a re-fetch), and
|
|
7
|
+
* deleting the directory costs nothing but time. Writes never throw.
|
|
8
|
+
*/
|
|
9
|
+
import { mkdirSync, readFileSync, renameSync, writeFileSync } from "node:fs";
|
|
10
|
+
import { homedir } from "node:os";
|
|
11
|
+
import { join } from "node:path";
|
|
12
|
+
|
|
13
|
+
export const CACHE_VERSION = 1;
|
|
14
|
+
|
|
15
|
+
export function cacheDir(repo, env = process.env) {
|
|
16
|
+
const root = env.FORGE_STATS_CACHE_DIR || join(homedir(), ".forge", "stats-cache");
|
|
17
|
+
return join(root, repo.replace("/", "_"));
|
|
18
|
+
}
|
|
19
|
+
|
|
20
|
+
/** { watermark: ISO string | null, items: { "<number>": item } } */
|
|
21
|
+
export const emptyCache = () => ({ v: CACHE_VERSION, watermark: null, items: {} });
|
|
22
|
+
|
|
23
|
+
export function loadCache(repo, env = process.env) {
|
|
24
|
+
try {
|
|
25
|
+
const c = JSON.parse(readFileSync(join(cacheDir(repo, env), "cache.json"), "utf8"));
|
|
26
|
+
if (c && c.v === CACHE_VERSION && c.items && typeof c.items === "object") return { ...emptyCache(), ...c };
|
|
27
|
+
} catch { /* missing or corrupt: refetch */ }
|
|
28
|
+
return emptyCache();
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
export function saveCache(repo, cache, env = process.env) {
|
|
32
|
+
try {
|
|
33
|
+
const dir = cacheDir(repo, env);
|
|
34
|
+
mkdirSync(dir, { recursive: true });
|
|
35
|
+
const tmp = join(dir, `cache.json.${process.pid}.tmp`);
|
|
36
|
+
writeFileSync(tmp, JSON.stringify(cache));
|
|
37
|
+
renameSync(tmp, join(dir, "cache.json"));
|
|
38
|
+
return true;
|
|
39
|
+
} catch { return false; }
|
|
40
|
+
}
|
|
@@ -0,0 +1,140 @@
|
|
|
1
|
+
// SPDX-FileCopyrightText: Copyright (c) RapierCraft Studios
|
|
2
|
+
// SPDX-License-Identifier: AGPL-3.0-or-later
|
|
3
|
+
/**
|
|
4
|
+
* Primary source for `forgedock stats` (forge#3835): the target repo's issues and PRs on GitHub.
|
|
5
|
+
* Reads labels and ONLY trusted `<!-- FORGE:TELEMETRY v1 -->` comments (same predicate as
|
|
6
|
+
* scripts/trusted-comments.sh: an author_association in FORGE_TRAIL_TRUSTED_ASSOCIATIONS, a Bot, or a login in
|
|
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.
|
|
9
|
+
*/
|
|
10
|
+
import { execFileSync } from "node:child_process";
|
|
11
|
+
import { emptyCache, loadCache, saveCache } from "./cache.mjs";
|
|
12
|
+
|
|
13
|
+
export const MARKER_RE = /^<!-- FORGE:TELEMETRY v1( part=(\d+))? -->/;
|
|
14
|
+
const OWNER_REPO_RE = /^[\w.-]+\/[\w.-]+$/;
|
|
15
|
+
export const isValidRepo = (r) => typeof r === "string" && OWNER_REPO_RE.test(r) && !r.split("/").some((p) => p === "." || p === "..");
|
|
16
|
+
|
|
17
|
+
export function defaultGh(args) {
|
|
18
|
+
return execFileSync("gh", args, { encoding: "utf8", stdio: ["ignore", "pipe", "pipe"], timeout: 120000, maxBuffer: 256 * 1024 * 1024 });
|
|
19
|
+
}
|
|
20
|
+
|
|
21
|
+
/** `gh api --paginate` prints one JSON array per page; join them into one flat array. */
|
|
22
|
+
export function parsePages(raw) {
|
|
23
|
+
const t = (raw ?? "").trim();
|
|
24
|
+
if (!t) return [];
|
|
25
|
+
return JSON.parse(`[${t.replace(/\]\s*\[/g, "],[")}]`).flat();
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
export function isTrusted(comment, env = process.env) {
|
|
29
|
+
const assoc = (env.FORGE_TRAIL_TRUSTED_ASSOCIATIONS ?? "OWNER,MEMBER,COLLABORATOR").split(",").map((s) => s.trim()).filter(Boolean);
|
|
30
|
+
const logins = (env.FORGE_TRAIL_TRUSTED_LOGINS ?? "").split(",").map((s) => s.trim()).filter(Boolean);
|
|
31
|
+
return assoc.includes(comment?.author_association ?? "") || (comment?.user?.type ?? "") === "Bot" || logins.includes(comment?.user?.login ?? "");
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
/**
|
|
35
|
+
* Trusted FORGE:TELEMETRY comments -> record objects. Parts are all read (order is irrelevant), the fenced `jsonl`
|
|
36
|
+
* block is parsed line by line (a torn or invalid line is skipped), and records are deduped on `eid` (never on the
|
|
37
|
+
* line text: identical same-second events carry different ids).
|
|
38
|
+
*/
|
|
39
|
+
export function parseTelemetryComments(comments, env = process.env) {
|
|
40
|
+
const seen = new Set();
|
|
41
|
+
const out = [];
|
|
42
|
+
for (const c of comments) {
|
|
43
|
+
if (!MARKER_RE.test(c?.body ?? "") || !isTrusted(c, env)) continue;
|
|
44
|
+
const block = /```jsonl\n([\s\S]*?)(?:\n```|$)/.exec(c.body);
|
|
45
|
+
if (!block) continue;
|
|
46
|
+
for (const line of block[1].split("\n")) {
|
|
47
|
+
if (!line.trim()) continue;
|
|
48
|
+
let rec;
|
|
49
|
+
try { rec = JSON.parse(line); } catch { continue; }
|
|
50
|
+
if (!rec || typeof rec !== "object" || typeof rec.event !== "string" || typeof rec.eid !== "string" || seen.has(rec.eid)) continue;
|
|
51
|
+
seen.add(rec.eid);
|
|
52
|
+
out.push(rec);
|
|
53
|
+
}
|
|
54
|
+
}
|
|
55
|
+
return out;
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
const num = (x) => { const v = Number(x); return x != null && x !== "" && Number.isFinite(v) ? v : null; };
|
|
59
|
+
|
|
60
|
+
/**
|
|
61
|
+
* Turn `phase_attempt` / `run_terminal` records into run summaries (one per repo#issue#run, finished only when a
|
|
62
|
+
* `run_terminal` exists). Field names are the ones bin/engine/telemetry-seam.mjs writes (docs/TELEMETRY.md).
|
|
63
|
+
*/
|
|
64
|
+
export function runsFromRecords(records) {
|
|
65
|
+
const byRun = new Map();
|
|
66
|
+
for (const r of records) {
|
|
67
|
+
if ((r.event !== "phase_attempt" && r.event !== "run_terminal") || r.issue == null) continue;
|
|
68
|
+
const key = `${r.repo}#${r.issue}#${r.run ?? ""}`;
|
|
69
|
+
const run = byRun.get(key) ?? { source: "github", repo: r.repo ?? null, version: r.forge_version ?? null, issue: r.issue, run: r.run ?? null,
|
|
70
|
+
finished: false, hydrated: false, usageEvents: 0, reason: null, code: null, remediated: false, startTs: null, endTs: null, wallMs: null,
|
|
71
|
+
phases: {}, _first: null };
|
|
72
|
+
byRun.set(key, run);
|
|
73
|
+
if (!run._first || r.ts < run._first) run._first = r.ts;
|
|
74
|
+
if (r.event === "run_terminal") {
|
|
75
|
+
run.finished = true; run.reason = r.reason ?? null; run.code = r.code ?? null; run.endTs = r.ts ?? null; run.wallMs = num(r.wall_ms);
|
|
76
|
+
continue;
|
|
77
|
+
}
|
|
78
|
+
const id = r.phase ?? "other";
|
|
79
|
+
const p = (run.phases[id] ??= { cr: 0, out: 0, usd: null, ms: null, attempts: 0 });
|
|
80
|
+
const hasUsage = [r.input_tokens, r.output_tokens, r.cache_read_tokens].some((x) => num(x) != null);
|
|
81
|
+
if (hasUsage) run.usageEvents++;
|
|
82
|
+
p.cr += num(r.cache_read_tokens) ?? 0;
|
|
83
|
+
p.out += num(r.output_tokens) ?? 0;
|
|
84
|
+
p.attempts++;
|
|
85
|
+
if (num(r.duration_ms) != null) p.ms = (p.ms ?? 0) + num(r.duration_ms);
|
|
86
|
+
if (num(r.cost_usd) != null) p.usd = (p.usd ?? 0) + num(r.cost_usd);
|
|
87
|
+
if (id === "remediate") run.remediated = true;
|
|
88
|
+
}
|
|
89
|
+
return [...byRun.values()].map(({ _first, ...run }) => ({ ...run, startTs: _first }));
|
|
90
|
+
}
|
|
91
|
+
|
|
92
|
+
/** Gate/review records (everything that is not a run record) become aggregate events. */
|
|
93
|
+
export const gateEvents = (records) => records.filter((r) => r.event !== "phase_attempt" && r.event !== "run_terminal")
|
|
94
|
+
.map((r) => ({ ...r, version: r.forge_version ?? null }));
|
|
95
|
+
|
|
96
|
+
const meta = (it) => ({ number: it.number, isPr: Boolean(it.pull_request), createdAt: it.created_at ?? null, closedAt: it.closed_at ?? null,
|
|
97
|
+
updatedAt: it.updated_at ?? null, labels: (it.labels ?? []).map((l) => (typeof l === "string" ? l : l.name)).filter(Boolean), authorType: it.user?.type ?? null });
|
|
98
|
+
|
|
99
|
+
/**
|
|
100
|
+
* Fetch (or incrementally refresh) the repo's issues, PRs and trusted telemetry records.
|
|
101
|
+
* Returns { items, records, fetched, fromCache }. `refresh` ignores the cache; `useCache:false` never touches disk.
|
|
102
|
+
*/
|
|
103
|
+
export function fetchGithubRecords({ repo, refresh = false, useCache = true, gh = defaultGh, env = process.env } = {}) {
|
|
104
|
+
if (!isValidRepo(repo)) throw new Error(`invalid repo: ${repo}`);
|
|
105
|
+
const cache = useCache && !refresh ? loadCache(repo, env) : emptyCache();
|
|
106
|
+
const q = ["state=all", "sort=updated", "direction=asc", "per_page=100", ...(cache.watermark ? [`since=${cache.watermark}`] : [])];
|
|
107
|
+
const listed = parsePages(gh(["api", "--paginate", `repos/${repo}/issues?${q.join("&")}`]));
|
|
108
|
+
const items = { ...cache.items };
|
|
109
|
+
let watermark = cache.watermark;
|
|
110
|
+
let fetched = 0;
|
|
111
|
+
for (const it of listed) {
|
|
112
|
+
if (!Number.isInteger(it?.number)) continue;
|
|
113
|
+
const m = meta(it);
|
|
114
|
+
let records = items[it.number]?.records ?? [];
|
|
115
|
+
// A telemetry comment is edited in place (which may not bump the issue's updated_at), so every listed item with
|
|
116
|
+
// comments is re-read rather than trusting a cached copy.
|
|
117
|
+
if ((it.comments ?? 0) > 0) {
|
|
118
|
+
records = parseTelemetryComments(parsePages(gh(["api", "--paginate", `repos/${repo}/issues/${it.number}/comments`])), env);
|
|
119
|
+
fetched++;
|
|
120
|
+
}
|
|
121
|
+
items[it.number] = { ...m, records };
|
|
122
|
+
if (m.updatedAt && (!watermark || m.updatedAt > watermark)) watermark = m.updatedAt;
|
|
123
|
+
}
|
|
124
|
+
if (useCache) saveCache(repo, { ...emptyCache(), watermark, items }, env);
|
|
125
|
+
const seen = new Set();
|
|
126
|
+
const records = [];
|
|
127
|
+
for (const it of Object.values(items)) for (const r of it.records ?? []) {
|
|
128
|
+
if (!seen.has(r.eid)) { seen.add(r.eid); records.push(r); }
|
|
129
|
+
}
|
|
130
|
+
return { items: Object.values(items), records, fetched, fromCache: cache.watermark != null };
|
|
131
|
+
}
|
|
132
|
+
|
|
133
|
+
/** Aggregate-model inputs for one repo from a fetch result. */
|
|
134
|
+
export function toModel(repo, result) {
|
|
135
|
+
return {
|
|
136
|
+
runs: runsFromRecords(result.records.map((r) => ({ ...r, repo: r.repo ?? repo }))),
|
|
137
|
+
events: gateEvents(result.records.map((r) => ({ ...r, repo: r.repo ?? repo }))),
|
|
138
|
+
issues: result.items.map((i) => ({ repo, number: i.number, isPr: i.isPr, createdAt: i.createdAt, closedAt: i.closedAt, labels: i.labels, authorType: i.authorType })),
|
|
139
|
+
};
|
|
140
|
+
}
|