forgedock 1.18.0 → 1.20.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/README.md +2 -0
- package/bin/engine/outcome-codes.mjs +13 -0
- package/bin/engine/phases.mjs +54 -4
- package/bin/engine/runlog.mjs +10 -2
- package/bin/engine/telemetry-seam.mjs +3 -0
- package/bin/engine/telemetry.test.mjs +3 -2
- package/bin/engine-cli.mjs +32 -2
- package/bin/engine.mjs +187 -10
- package/bin/runner.mjs +89 -13
- package/bin/stats/aggregate.mjs +7 -3
- package/bin/stats/cache.mjs +7 -3
- package/bin/stats/github-source.mjs +136 -28
- package/bin/stats/index.mjs +46 -9
- package/bin/stats/render.mjs +1 -1
- package/bin/stats/stats.test.mjs +201 -40
- package/commands/orchestrate/phase-4-execution.md +1 -1
- package/commands/review-pr-agents/protocols.md +19 -0
- package/commands/review-pr-staging.md +4 -4
- package/commands/review-pr.md +31 -20
- package/commands/work-on/build/architect.md +54 -1
- package/commands/work-on/build/implement.md +28 -1
- package/commands/work-on/build/validate.md +321 -29
- package/commands/work-on/build.md +184 -17
- package/commands/work-on/close.md +9 -4
- package/commands/work-on/investigate.md +7 -0
- package/commands/work-on/remediate.md +43 -17
- package/commands/work-on/review.md +43 -22
- package/commands/work-on.md +4 -4
- package/docs/CONFIG.md +93 -2
- package/docs/OUTCOME-CODES.md +5 -1
- package/docs/RELEASE-NOTES.md +15 -0
- package/docs/STATS.md +11 -4
- package/docs/TELEMETRY.md +5 -2
- package/docs/WORK-ON-RUNTIME.md +29 -3
- package/package.json +2 -2
- package/packages/protocol/src/types.js +1 -1
- package/scripts/acceptance-red-green.sh +90 -0
- package/scripts/acceptance-red-green.test.sh +74 -0
- package/scripts/architect-resume-match.test.sh +12 -3
- package/scripts/build-knowledge-index.mjs +8 -5
- package/scripts/builder-resume-match.test.sh +12 -3
- package/scripts/calibration.mjs +5 -2
- package/scripts/cap-findings.test.sh +2 -0
- package/scripts/check-contract-scope.test.sh +2 -0
- package/scripts/check-edge-cases.sh +143 -0
- package/scripts/check-edge-cases.test.sh +175 -0
- package/scripts/check-spec-markers.sh +4 -0
- package/scripts/check-test-tmp-cleanup.sh +56 -0
- package/scripts/check-test-tmp-cleanup.test.sh +69 -0
- package/scripts/ci-local/adapters/azure-pipelines.mjs +113 -0
- package/scripts/ci-local/adapters/bitbucket.mjs +64 -0
- package/scripts/ci-local/adapters/buildkite.mjs +58 -0
- package/scripts/ci-local/adapters/circleci.mjs +101 -0
- package/scripts/ci-local/adapters/common.mjs +115 -0
- package/scripts/ci-local/adapters/explicit.mjs +21 -0
- package/scripts/ci-local/adapters/github-actions.mjs +349 -0
- package/scripts/ci-local/adapters/gitlab-ci.mjs +184 -0
- package/scripts/ci-local/adapters/portability.mjs +104 -0
- package/scripts/ci-local/adapters/task-runners.mjs +67 -0
- package/scripts/ci-local/cli.mjs +50 -0
- package/scripts/ci-local/lib.mjs +379 -0
- package/scripts/ci-local/plan.mjs +186 -0
- package/scripts/ci-local/run.mjs +451 -0
- package/scripts/ci-local/scoping.mjs +531 -0
- package/scripts/ci-local.sh +98 -0
- package/scripts/ci-local.test.sh +1108 -0
- package/scripts/classify-finding.sh +33 -7
- package/scripts/classify-finding.test.sh +23 -0
- package/scripts/classify-test-run.sh +69 -0
- package/scripts/classify-test-run.test.sh +90 -0
- package/scripts/close-parent-c3.test.sh +2 -0
- package/scripts/consumer-sweep.sh +569 -0
- package/scripts/consumer-sweep.test.sh +357 -0
- package/scripts/detect-append-registries.sh +106 -0
- package/scripts/detect-append-registries.test.sh +64 -0
- package/scripts/file-deferred-followups.sh +91 -30
- package/scripts/file-deferred-followups.test.sh +101 -8
- package/scripts/fixtures/ci-local/azure/azure-pipelines.yml +11 -0
- package/scripts/fixtures/ci-local/bitbucket/bitbucket-pipelines.yml +12 -0
- package/scripts/fixtures/ci-local/buildkite/.buildkite/pipeline.yml +10 -0
- package/scripts/fixtures/ci-local/circleci/.circleci/config.yml +29 -0
- package/scripts/fixtures/ci-local/gha-basic/.github/workflows/ci.yml +59 -0
- package/scripts/fixtures/ci-local/gha-basic/.github/workflows/docs.yml +10 -0
- package/scripts/fixtures/ci-local/gha-basic/.github/workflows/nightly.yml +10 -0
- package/scripts/fixtures/ci-local/gha-basic/docs/index.md +1 -0
- package/scripts/fixtures/ci-local/gha-basic/src/greet.sh +2 -0
- package/scripts/fixtures/ci-local/gha-basic/tests/greet.test.sh +3 -0
- package/scripts/fixtures/ci-local/gha-dorny/.github/workflows/ci.yml +51 -0
- package/scripts/fixtures/ci-local/gha-dorny/backend/app.py +1 -0
- package/scripts/fixtures/ci-local/gha-dorny/docs/a.md +1 -0
- package/scripts/fixtures/ci-local/gha-dorny/frontend/app.js +1 -0
- package/scripts/fixtures/ci-local/gha-run/.github/workflows/ci.yml +39 -0
- package/scripts/fixtures/ci-local/gha-run/src/x.txt +1 -0
- package/scripts/fixtures/ci-local/gitlab/.gitlab-ci.yml +43 -0
- package/scripts/fixtures/ci-local/gitlab/docs/d.md +1 -0
- package/scripts/fixtures/ci-local/gitlab/src/a.txt +1 -0
- package/scripts/fixtures/ci-local/monorepo/.github/workflows/ci.yml +13 -0
- package/scripts/fixtures/ci-local/monorepo/package-lock.json +1 -0
- package/scripts/fixtures/ci-local/monorepo/packages/a/src/a.sh +2 -0
- package/scripts/fixtures/ci-local/monorepo/packages/a/tests/a.test.sh +3 -0
- package/scripts/fixtures/ci-local/monorepo/packages/b/src/b.sh +2 -0
- package/scripts/fixtures/ci-local/monorepo/packages/b/tests/b.test.sh +3 -0
- package/scripts/fixtures/ci-local/no-ci/Makefile +6 -0
- package/scripts/fixtures/ci-local/no-ci/package.json +1 -0
- package/scripts/fixtures/ci-local/no-ci/src/a.js +1 -0
- package/scripts/fixtures/ci-local/no-ci/src/b.js +1 -0
- package/scripts/fixtures/ci-local/no-ci/tests/a.test.js +2 -0
- package/scripts/fixtures/ci-local/no-ci/tests/b.test.js +2 -0
- package/scripts/forge-root.test.sh +1 -1
- package/scripts/lib/telemetry.sh +3 -0
- package/scripts/remediate-approved-head.test.sh +2 -0
- package/scripts/remediate-basesync.test.sh +1 -1
- package/scripts/review-acceptance-gate.sh +2 -2
- package/scripts/review-acceptance-gate.test.sh +20 -0
- package/scripts/review-delta.sh +8 -1
- package/scripts/review-delta.test.sh +19 -0
- package/scripts/review-finalize.sh +294 -53
- package/scripts/review-finalize.test.sh +347 -9
- package/scripts/review-pr-delta-wiring.test.sh +13 -0
- package/scripts/review-prepare.sh +5 -3
- package/scripts/review-prepare.test.sh +18 -2
- package/scripts/review-roster.sh +18 -5
- package/scripts/review-roster.test.sh +57 -1
- package/scripts/run-shell-tests.sh +5 -0
- package/scripts/selfreview-plan.sh +86 -0
- package/scripts/selfreview-plan.test.sh +90 -0
- package/scripts/spawn-depth-check.sh +5 -4
- package/scripts/spawn-depth-check.test.sh +3 -0
- package/scripts/telemetry.test.sh +10 -0
- package/scripts/transition-label.sh +26 -0
- package/scripts/trusted-comments.test.sh +2 -1
- package/scripts/verify-phase-trail.test.sh +14 -0
- package/scripts/wait-ci-green.sh +61 -9
- package/scripts/wait-ci-green.test.sh +41 -3
- package/scripts/with-run-tmpdir.sh +20 -0
package/bin/runner.mjs
CHANGED
|
@@ -47,8 +47,11 @@ import {
|
|
|
47
47
|
mkdtempSync,
|
|
48
48
|
rmSync,
|
|
49
49
|
realpathSync,
|
|
50
|
+
lstatSync,
|
|
51
|
+
chmodSync,
|
|
50
52
|
} from "fs";
|
|
51
|
-
import {
|
|
53
|
+
import { createHash } from "crypto";
|
|
54
|
+
import { join, dirname, basename, resolve, relative, isAbsolute, extname, win32 as win32Path } from "path";
|
|
52
55
|
import os from "os";
|
|
53
56
|
import { execSync, spawnSync } from "child_process";
|
|
54
57
|
import { createRequire } from "module";
|
|
@@ -104,28 +107,65 @@ export function resolveRunTmpRoot(cwd, env = process.env) {
|
|
|
104
107
|
return join(os.homedir(), ".forge", "tmp");
|
|
105
108
|
}
|
|
106
109
|
|
|
107
|
-
/**
|
|
108
|
-
|
|
110
|
+
/**
|
|
111
|
+
* Stable, path-safe identity of the repo checkout: `<sanitized basename>-<8 hex of sha1(realpath)>`.
|
|
112
|
+
* Local only (no network), identical across a `--retry` from the same checkout, distinct per checkout.
|
|
113
|
+
* Never empty, never `.`/`..`, never contains a path separator.
|
|
114
|
+
*/
|
|
115
|
+
export function repoKey(cwd = process.cwd()) {
|
|
116
|
+
let real;
|
|
117
|
+
try { real = realpathSync(cwd); } catch { real = resolve(String(cwd)); }
|
|
118
|
+
const name = basename(real).replace(/[^A-Za-z0-9._-]/g, "_").replace(/^\.+/, "") || "repo";
|
|
119
|
+
return `${name}-${createHash("sha1").update(real).digest("hex").slice(0, 8)}`;
|
|
120
|
+
}
|
|
121
|
+
|
|
122
|
+
/** `<root>/<repoKey>/<issue>` for a numeric issue; null for an empty root or non-numeric issue (no path-escape, never the root). */
|
|
123
|
+
export function runTmpDir(root, issue, cwd = process.cwd()) {
|
|
109
124
|
const id = String(issue ?? "").trim();
|
|
110
|
-
return root && /^[0-9]+$/.test(id) ? join(root, id) : null;
|
|
125
|
+
return root && /^[0-9]+$/.test(id) ? join(root, repoKey(cwd), id) : null;
|
|
126
|
+
}
|
|
127
|
+
|
|
128
|
+
/** True when `p` is a real directory (not a symlink) owned by the current uid (uid check skipped where unavailable). */
|
|
129
|
+
function isOwnedRealDir(p) {
|
|
130
|
+
const st = lstatSync(p);
|
|
131
|
+
if (!st.isDirectory() || st.isSymbolicLink()) return false;
|
|
132
|
+
return typeof process.getuid !== "function" || st.uid === process.getuid();
|
|
111
133
|
}
|
|
112
134
|
|
|
113
135
|
/**
|
|
114
136
|
* 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.
|
|
116
|
-
*
|
|
137
|
+
* stay off a size-limited /tmp. The dir is repo-scoped and created 0700; an existing dir that is a symlink
|
|
138
|
+
* or owned by another uid is not used. No-op when the issue is unknown or the operator owns the tmp root.
|
|
139
|
+
* Best-effort: any failure leaves the env unchanged.
|
|
117
140
|
*/
|
|
118
141
|
export function withRunTmpdir(env, { issue, cwd = process.cwd() } = {}) {
|
|
119
|
-
const dir = runTmpDir(resolveRunTmpRoot(cwd, env), issue);
|
|
142
|
+
const dir = runTmpDir(resolveRunTmpRoot(cwd, env), issue, cwd);
|
|
120
143
|
if (!dir) return env;
|
|
121
144
|
try {
|
|
122
|
-
mkdirSync(dir, { recursive: true });
|
|
145
|
+
mkdirSync(dir, { recursive: true, mode: 0o700 });
|
|
146
|
+
const parent = dirname(dir);
|
|
147
|
+
for (const d of [parent, dir]) {
|
|
148
|
+
if (!isOwnedRealDir(d)) return env;
|
|
149
|
+
chmodSync(d, 0o700);
|
|
150
|
+
}
|
|
123
151
|
env.TMPDIR = dir;
|
|
124
152
|
env.CLAUDE_CODE_TMPDIR = dir;
|
|
125
153
|
} catch { /* scratch stays on the inherited tmp */ }
|
|
126
154
|
return env;
|
|
127
155
|
}
|
|
128
156
|
|
|
157
|
+
const LEASE_HOLDER_ENV_RE = /^cli_[A-Za-z0-9._-]+_[0-9]+$/;
|
|
158
|
+
|
|
159
|
+
/**
|
|
160
|
+
* Stamp the phase child's env with `FORGE_LEASE_HOLDER=<engine lease id>` so the engine can tell, after a
|
|
161
|
+
* SIGKILL of the engine, whether an orphaned phase child is still running (env is inherited by grandchildren).
|
|
162
|
+
* An absent or malformed holder leaves the env untouched and never throws.
|
|
163
|
+
*/
|
|
164
|
+
export function withLeaseHolder(env, holder) {
|
|
165
|
+
if (typeof holder === "string" && LEASE_HOLDER_ENV_RE.test(holder)) env.FORGE_LEASE_HOLDER = holder;
|
|
166
|
+
return env;
|
|
167
|
+
}
|
|
168
|
+
|
|
129
169
|
/** First positional arg when it is an issue number (the phase arg convention), else null. */
|
|
130
170
|
function issueFromArgs(args) {
|
|
131
171
|
const first = Array.isArray(args) ? args[0] : String(args ?? "").trim().split(/\s+/)[0];
|
|
@@ -1402,6 +1442,7 @@ export function runCliBackend({
|
|
|
1402
1442
|
spawnFn = spawnSync,
|
|
1403
1443
|
model = null,
|
|
1404
1444
|
maxTurns = null,
|
|
1445
|
+
leaseHolder = null,
|
|
1405
1446
|
}) {
|
|
1406
1447
|
// forge#3589: opt-in flags, validated again here as defence in depth. Callers
|
|
1407
1448
|
// (runCommand) are responsible for the version gate. Unset -> argv unchanged.
|
|
@@ -1468,7 +1509,7 @@ export function runCliBackend({
|
|
|
1468
1509
|
// Mirrors the existing scrub in the `run_bash` tool handler below.
|
|
1469
1510
|
// GH_TOKEN/GITHUB_TOKEN and all other env vars are intentionally left
|
|
1470
1511
|
// intact — only the Anthropic key is removed.
|
|
1471
|
-
const childEnv = withRunTmpdir(withForgedockHome({ ...process.env }), { issue: issueFromArgs(args), cwd });
|
|
1512
|
+
const childEnv = withLeaseHolder(withRunTmpdir(withForgedockHome({ ...process.env }), { issue: issueFromArgs(args), cwd }), leaseHolder);
|
|
1472
1513
|
delete childEnv.ANTHROPIC_API_KEY;
|
|
1473
1514
|
|
|
1474
1515
|
// No `shell` option (defaults to false): argv is passed as discrete,
|
|
@@ -1493,6 +1534,9 @@ export function runCliBackend({
|
|
|
1493
1534
|
// intentionally omitted here since spawnSync's own `timeout` + ETIMEDOUT is
|
|
1494
1535
|
// reliable for this single, non-looped invocation.
|
|
1495
1536
|
const timedOut = result.status === null && result.error?.code === "ETIMEDOUT";
|
|
1537
|
+
// forge#4037: only a spawn-stage failure is transient infra. A wall-clock kill leaves evidence that the child ran
|
|
1538
|
+
// (a kill signal, a pid, or captured output); an ETIMEDOUT with none of those never started the child.
|
|
1539
|
+
const childRan = !!result.signal || (Number.isInteger(result.pid) && result.pid > 0) || (stdout + stderr).trim().length > 0;
|
|
1496
1540
|
if (timedOut) {
|
|
1497
1541
|
const timeoutSecs = Math.round(timeoutMs / 1000);
|
|
1498
1542
|
// forge#2360: spawnSync's `timeout` option kills the child outright on
|
|
@@ -1510,15 +1554,44 @@ export function runCliBackend({
|
|
|
1510
1554
|
// review findings — #2277, #2292, #2293, #2355 — for that defect
|
|
1511
1555
|
// class).
|
|
1512
1556
|
const partial = (stdout + stderr).trim();
|
|
1513
|
-
|
|
1557
|
+
const timeoutError = new Error(
|
|
1514
1558
|
`claude CLI invocation timed out after ${timeoutSecs}s and was killed. ` +
|
|
1515
1559
|
`Set FORGEDOCK_CLI_TIMEOUT_MS (ms) to adjust, or use --backend api.` +
|
|
1516
1560
|
(partial ? ` Partial output: ${sanitizeOutputExcerptForLog(partial)}` : ""),
|
|
1517
1561
|
);
|
|
1562
|
+
if (!childRan) {
|
|
1563
|
+
// forge#4021: stable transient-infra class; the engine retries it on a
|
|
1564
|
+
// separate bounded counter without consuming a phase attempt.
|
|
1565
|
+
timeoutError.code = "CLI_SPAWN_TRANSIENT";
|
|
1566
|
+
timeoutError.spawnErrno = "ETIMEDOUT";
|
|
1567
|
+
throw timeoutError;
|
|
1568
|
+
}
|
|
1569
|
+
// forge#4037: the child ran and was killed at the wall-clock limit: a normal, attempt-consuming failure
|
|
1570
|
+
// (`cli.timeout`), with usage attached when the CLI had already emitted a parseable envelope.
|
|
1571
|
+
timeoutError.code = "CLI_TIMEOUT";
|
|
1572
|
+
timeoutError.usage = null;
|
|
1573
|
+
try {
|
|
1574
|
+
const u = JSON.parse(stdout.trim())?.usage;
|
|
1575
|
+
if (u && typeof u === "object") {
|
|
1576
|
+
timeoutError.usage = {
|
|
1577
|
+
input_tokens: toFiniteUsageNumber(u.input_tokens),
|
|
1578
|
+
output_tokens: toFiniteUsageNumber(u.output_tokens),
|
|
1579
|
+
cache_creation_input_tokens: toFiniteUsageNumber(u.cache_creation_input_tokens),
|
|
1580
|
+
cache_read_input_tokens: toFiniteUsageNumber(u.cache_read_input_tokens),
|
|
1581
|
+
};
|
|
1582
|
+
}
|
|
1583
|
+
} catch {
|
|
1584
|
+
// No parseable envelope from a killed child: usage stays null (never fabricated).
|
|
1585
|
+
}
|
|
1586
|
+
throw timeoutError;
|
|
1518
1587
|
}
|
|
1519
1588
|
if (result.error) {
|
|
1520
1589
|
const error = new Error(`Failed to invoke claude CLI: ${result.error.message}`);
|
|
1521
1590
|
error.code = result.error.code;
|
|
1591
|
+
if (["EAGAIN", "ENOMEM", "EMFILE"].includes(result.error.code)) {
|
|
1592
|
+
error.spawnErrno = result.error.code;
|
|
1593
|
+
error.code = "CLI_SPAWN_TRANSIENT";
|
|
1594
|
+
}
|
|
1522
1595
|
throw error;
|
|
1523
1596
|
}
|
|
1524
1597
|
|
|
@@ -2245,9 +2318,10 @@ export function resolveBashShell() {
|
|
|
2245
2318
|
*
|
|
2246
2319
|
* @param {string} cwd
|
|
2247
2320
|
* @param {string|null} [runIssueNumber] - issue number scoping the per-run TMPDIR (forge#3572)
|
|
2321
|
+
* @param {string|null} [leaseHolder] - engine lease id stamped as FORGE_LEASE_HOLDER in the child env
|
|
2248
2322
|
* @returns {Record<string, (input: object) => string>}
|
|
2249
2323
|
*/
|
|
2250
|
-
export function getToolHandlers(cwd, runIssueNumber = null) {
|
|
2324
|
+
export function getToolHandlers(cwd, runIssueNumber = null, leaseHolder = null) {
|
|
2251
2325
|
return {
|
|
2252
2326
|
read_file: ({ path }) => {
|
|
2253
2327
|
if (!path) throw new Error("read_file requires a 'path'");
|
|
@@ -2267,7 +2341,7 @@ export function getToolHandlers(cwd, runIssueNumber = null) {
|
|
|
2267
2341
|
// into the model loop, so a prompt-injection payload could otherwise issue
|
|
2268
2342
|
// a run_bash command that exfiltrates the key. gh/git auth tokens
|
|
2269
2343
|
// (GH_TOKEN/GITHUB_TOKEN) are intentionally left intact.
|
|
2270
|
-
const childEnv = withRunTmpdir(withForgedockHome({ ...process.env }), { issue: runIssueNumber, cwd });
|
|
2344
|
+
const childEnv = withLeaseHolder(withRunTmpdir(withForgedockHome({ ...process.env }), { issue: runIssueNumber, cwd }), leaseHolder);
|
|
2271
2345
|
delete childEnv.ANTHROPIC_API_KEY;
|
|
2272
2346
|
// Run under bash when available so bash-style commands the model emits
|
|
2273
2347
|
// (heredocs, scripts/*.sh, single-quote semantics) behave consistently
|
|
@@ -2525,6 +2599,7 @@ export async function runCommand(opts = {}) {
|
|
|
2525
2599
|
backend = process.env.FORGEDOCK_BACKEND || "auto",
|
|
2526
2600
|
runtimeEnv = process.env,
|
|
2527
2601
|
logger = console,
|
|
2602
|
+
leaseHolder = null,
|
|
2528
2603
|
} = opts;
|
|
2529
2604
|
|
|
2530
2605
|
assertOpenCodeNativeRuntime({
|
|
@@ -2665,6 +2740,7 @@ export async function runCommand(opts = {}) {
|
|
|
2665
2740
|
logger,
|
|
2666
2741
|
model: cliModel,
|
|
2667
2742
|
maxTurns: cliMaxTurns,
|
|
2743
|
+
leaseHolder,
|
|
2668
2744
|
},
|
|
2669
2745
|
});
|
|
2670
2746
|
}
|
|
@@ -2707,7 +2783,7 @@ export async function runCommand(opts = {}) {
|
|
|
2707
2783
|
// is a known Linux limitation at the OS level; closing it would require
|
|
2708
2784
|
// a native module (e.g. prctl PR_SET_DUMPABLE) or process isolation.
|
|
2709
2785
|
// See: issue #1370 for tracking this known limitation.
|
|
2710
|
-
const handlers = getToolHandlers(cwd, issueFromArgs(args));
|
|
2786
|
+
const handlers = getToolHandlers(cwd, issueFromArgs(args), leaseHolder);
|
|
2711
2787
|
const messages = [{ role: "user", content: userMessage }];
|
|
2712
2788
|
|
|
2713
2789
|
// Accumulate token usage across all messages.create() calls in this run.
|
package/bin/stats/aggregate.mjs
CHANGED
|
@@ -127,8 +127,10 @@ function funnelSection(events, issues) {
|
|
|
127
127
|
for (const d of Object.values(bySeverity)) d.perReview = reviews.size ? d.total / reviews.size : null;
|
|
128
128
|
const dispositions = { ISSUE: 0, NOTE: 0, INPR_FIX: 0 };
|
|
129
129
|
for (const f of findings) if (f.disposition in dispositions) dispositions[f.disposition]++;
|
|
130
|
+
// Events written before the scope field existed carry none and are not counted.
|
|
131
|
+
const introduced = findings.filter((f) => f.scope === "introduced").length;
|
|
130
132
|
return {
|
|
131
|
-
reviews: reviews.size, findings: findings.length, perReview: ratio(findings.length, reviews.size),
|
|
133
|
+
reviews: reviews.size, findings: findings.length, introduced, perReview: ratio(findings.length, reviews.size),
|
|
132
134
|
bySeverity, dispositions,
|
|
133
135
|
validated: issues.filter((i) => i.labels.includes("validated")).length,
|
|
134
136
|
falsePositive: issues.filter((i) => i.labels.includes("false-positive")).length,
|
|
@@ -150,7 +152,7 @@ function gatesSection(events) {
|
|
|
150
152
|
};
|
|
151
153
|
}
|
|
152
154
|
|
|
153
|
-
function issueFlowSection(issues) {
|
|
155
|
+
function issueFlowSection(issues, introduced = 0) {
|
|
154
156
|
const real = issues.filter((i) => !i.isPr);
|
|
155
157
|
const created = {};
|
|
156
158
|
for (const i of real) { const s = issueSource(i); created[s] = (created[s] ?? 0) + 1; }
|
|
@@ -162,6 +164,7 @@ function issueFlowSection(issues) {
|
|
|
162
164
|
n: real.length, created, createdTotal: real.length, closed, closedTotal: closedIssues.length,
|
|
163
165
|
net: real.length ? real.length - closedIssues.length : null,
|
|
164
166
|
findingsPerMerge: ratio(created["review-finding"] ?? 0, merged),
|
|
167
|
+
introducedPerMerge: ratio(introduced, merged),
|
|
165
168
|
};
|
|
166
169
|
}
|
|
167
170
|
|
|
@@ -214,7 +217,8 @@ export function aggregate({ runs = [], events = [], issues = [] } = {}, opts = {
|
|
|
214
217
|
let groups = [...keys.values()].map(({ version, repo }) => {
|
|
215
218
|
const sel = (list) => list.filter((x) => x.version === version && x.repo === repo);
|
|
216
219
|
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:
|
|
220
|
+
funnel: funnelSection(sel(evs), sel(iss)), gates: gatesSection(sel(evs)), issueFlow: null };
|
|
221
|
+
g.issueFlow = issueFlowSection(sel(iss), g.funnel.introduced);
|
|
218
222
|
g.n = g.flow.n;
|
|
219
223
|
g.lowN = g.n < minN;
|
|
220
224
|
g.minN = minN;
|
package/bin/stats/cache.mjs
CHANGED
|
@@ -17,13 +17,17 @@ export function cacheDir(repo, env = process.env) {
|
|
|
17
17
|
return join(root, repo.replace("/", "_"));
|
|
18
18
|
}
|
|
19
19
|
|
|
20
|
-
/**
|
|
21
|
-
|
|
20
|
+
/**
|
|
21
|
+
* { watermark: ISO string | null, coveredFrom: ISO string | null, items: { "<number>": item } }
|
|
22
|
+
* The cache covers activity in [coveredFrom, watermark]; coveredFrom null means "from the beginning". A cache written
|
|
23
|
+
* before the field existed loads with coveredFrom undefined (floor unknown), never as full coverage.
|
|
24
|
+
*/
|
|
25
|
+
export const emptyCache = () => ({ v: CACHE_VERSION, watermark: null, coveredFrom: null, items: {} });
|
|
22
26
|
|
|
23
27
|
export function loadCache(repo, env = process.env) {
|
|
24
28
|
try {
|
|
25
29
|
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 };
|
|
30
|
+
if (c && c.v === CACHE_VERSION && c.items && typeof c.items === "object") return { ...emptyCache(), ...c, coveredFrom: "coveredFrom" in c ? c.coveredFrom : undefined };
|
|
27
31
|
} catch { /* missing or corrupt: refetch */ }
|
|
28
32
|
return emptyCache();
|
|
29
33
|
}
|
|
@@ -5,17 +5,24 @@
|
|
|
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
|
|
8
|
+
* Fetching is windowed and incremental: only items updated after the crawl start (the cached watermark minus a small
|
|
9
|
+
* overlap, or the requested `--since` bound) are listed, and comments come from the repo-wide `issues/comments?since=`
|
|
10
|
+
* endpoint (a few paginated calls) instead of one call per item.
|
|
9
11
|
*/
|
|
10
|
-
import {
|
|
12
|
+
import { execFile } from "node:child_process";
|
|
13
|
+
import { promisify } from "node:util";
|
|
11
14
|
import { emptyCache, loadCache, saveCache } from "./cache.mjs";
|
|
12
15
|
|
|
13
16
|
export const MARKER_RE = /^<!-- FORGE:TELEMETRY v1( part=(\d+))? -->/;
|
|
14
17
|
const OWNER_REPO_RE = /^[\w.-]+\/[\w.-]+$/;
|
|
15
18
|
export const isValidRepo = (r) => typeof r === "string" && OWNER_REPO_RE.test(r) && !r.split("/").some((p) => p === "." || p === "..");
|
|
16
19
|
|
|
17
|
-
|
|
18
|
-
|
|
20
|
+
const execFileAsync = promisify(execFile);
|
|
21
|
+
|
|
22
|
+
/** Async `gh` runner (concurrency needs it). A test stub may stay synchronous: every call site awaits the result. */
|
|
23
|
+
export async function defaultGh(args) {
|
|
24
|
+
const { stdout } = await execFileAsync("gh", args, { encoding: "utf8", timeout: 120000, maxBuffer: 256 * 1024 * 1024 });
|
|
25
|
+
return stdout;
|
|
19
26
|
}
|
|
20
27
|
|
|
21
28
|
/** `gh api --paginate` prints one JSON array per page; join them into one flat array. */
|
|
@@ -102,7 +109,6 @@ export function runsFromRecords(records) {
|
|
|
102
109
|
export const gateEvents = (records) => records.filter((r) => r.event !== "phase_attempt" && r.event !== "run_terminal")
|
|
103
110
|
.map((r) => ({ ...r, version: r.forge_version ?? null }));
|
|
104
111
|
|
|
105
|
-
const CHECKPOINT_EVERY = 25;
|
|
106
112
|
const PAGE_SIZE = 100;
|
|
107
113
|
// The next incremental run re-reads this much before the persisted watermark (an overlap window), so a write that races a
|
|
108
114
|
// crawl or a small clock skew is re-listed. Re-reads are idempotent: items are keyed by issue number.
|
|
@@ -125,7 +131,7 @@ const sinceParam = (ts) => encodeURIComponent(ts).replace(/%3A/g, ":");
|
|
|
125
131
|
* the cursor (more than PAGE_SIZE items share one timestamp) is drained with `page=N` for that timestamp only.
|
|
126
132
|
* Yields each page's unseen items sorted updated-asc.
|
|
127
133
|
*/
|
|
128
|
-
function* listIssuePages(gh, repo, startSince) {
|
|
134
|
+
async function* listIssuePages(gh, repo, startSince) {
|
|
129
135
|
const base = ["state=all", "sort=updated", "direction=asc", `per_page=${PAGE_SIZE}`];
|
|
130
136
|
const seen = new Set();
|
|
131
137
|
const key = (it) => `${it.number}@${it.updated_at}`;
|
|
@@ -137,8 +143,8 @@ function* listIssuePages(gh, repo, startSince) {
|
|
|
137
143
|
const maxTs = (page) => page.reduce((m, it) => (it?.updated_at && (!m || it.updated_at > m) ? it.updated_at : m), null);
|
|
138
144
|
let cursor = startSince ?? null;
|
|
139
145
|
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();
|
|
146
|
+
const get = async (extra = []) => parsePages(await gh(["api", `repos/${repo}/issues?${[...base, ...(cursor ? [`since=${sinceParam(cursor)}`] : []), ...extra].join("&")}`]));
|
|
147
|
+
const page = await get();
|
|
142
148
|
const fresh = take(page);
|
|
143
149
|
if (fresh.length > 0) yield fresh;
|
|
144
150
|
if (page.length < PAGE_SIZE) return;
|
|
@@ -146,7 +152,7 @@ function* listIssuePages(gh, repo, startSince) {
|
|
|
146
152
|
if (next && next !== cursor) { cursor = next; continue; }
|
|
147
153
|
// No cursor progress on a full page: drain this timestamp by page number, then step past it.
|
|
148
154
|
for (let p = 2; p < MAX_LIST_REQUESTS; p++) {
|
|
149
|
-
const more = get([`page=${p}`]);
|
|
155
|
+
const more = await get([`page=${p}`]);
|
|
150
156
|
const freshMore = take(more);
|
|
151
157
|
if (freshMore.length > 0) yield freshMore;
|
|
152
158
|
if (more.length < PAGE_SIZE || freshMore.length === 0) break;
|
|
@@ -159,35 +165,137 @@ function* listIssuePages(gh, repo, startSince) {
|
|
|
159
165
|
const meta = (it) => ({ number: it.number, isPr: Boolean(it.pull_request), createdAt: it.created_at ?? null, closedAt: it.closed_at ?? null,
|
|
160
166
|
updatedAt: it.updated_at ?? null, labels: (it.labels ?? []).map((l) => (typeof l === "string" ? l : l.name)).filter(Boolean), authorType: it.user?.type ?? null });
|
|
161
167
|
|
|
168
|
+
const CHECKPOINT_EVERY = 1000;
|
|
169
|
+
const RATE_CHECK_EVERY = 50;
|
|
170
|
+
const RATE_LOW = 200;
|
|
171
|
+
const MAX_RATE_SLEEP_MS = 60 * 60 * 1000;
|
|
172
|
+
const DEFAULT_CONCURRENCY = 6;
|
|
173
|
+
const isoNow = (ms) => new Date(ms).toISOString().replace(/\.\d{3}Z$/, "Z");
|
|
174
|
+
|
|
175
|
+
/** Run `fn` over `list` with at most `limit` in flight; the first failure stops new work and is rethrown. */
|
|
176
|
+
async function pool(list, limit, fn) {
|
|
177
|
+
let next = 0, failed = null;
|
|
178
|
+
const worker = async () => {
|
|
179
|
+
while (!failed && next < list.length) {
|
|
180
|
+
const item = list[next++];
|
|
181
|
+
try { await fn(item); } catch (err) { failed ??= err; }
|
|
182
|
+
}
|
|
183
|
+
};
|
|
184
|
+
await Promise.all(Array.from({ length: Math.min(Math.max(1, limit), list.length) }, worker));
|
|
185
|
+
if (failed) throw failed;
|
|
186
|
+
}
|
|
187
|
+
|
|
188
|
+
/** Best effort: when the core rate limit is nearly spent, sleep until it resets. Never throws. */
|
|
189
|
+
async function waitForRate(gh, sleep, now) {
|
|
190
|
+
try {
|
|
191
|
+
const core = JSON.parse(await gh(["api", "rate_limit"]))?.resources?.core;
|
|
192
|
+
if (core && Number.isFinite(core.remaining) && core.remaining < RATE_LOW && Number.isFinite(core.reset)) {
|
|
193
|
+
await sleep(Math.min(Math.max(core.reset * 1000 - now() + 1000, 0), MAX_RATE_SLEEP_MS));
|
|
194
|
+
}
|
|
195
|
+
} catch { /* the limit check is advisory */ }
|
|
196
|
+
}
|
|
197
|
+
|
|
162
198
|
/**
|
|
163
|
-
*
|
|
164
|
-
*
|
|
199
|
+
* Page the repo-wide comments endpoint (comments updated since `from`, id-ascending) and return the numbers of the issues
|
|
200
|
+
* and PRs that carry at least one trusted FORGE:TELEMETRY comment in that window. Items without one cost nothing more.
|
|
165
201
|
*/
|
|
166
|
-
|
|
202
|
+
async function scanWindowComments(call, repo, from, env, onPage) {
|
|
203
|
+
const base = ["per_page=100", ...(from ? [`since=${sinceParam(from)}`] : [])];
|
|
204
|
+
const numbers = new Set();
|
|
205
|
+
for (let p = 1; p < MAX_LIST_REQUESTS; p++) {
|
|
206
|
+
const page = parsePages(await call(["api", `repos/${repo}/issues/comments?${[...base, `page=${p}`].join("&")}`]));
|
|
207
|
+
for (const c of page) {
|
|
208
|
+
if (!MARKER_RE.test(c?.body ?? "") || !isTrusted(c, env)) continue;
|
|
209
|
+
const m = /\/issues\/(\d+)$/.exec(c.issue_url ?? "");
|
|
210
|
+
if (m) numbers.add(Number(m[1]));
|
|
211
|
+
}
|
|
212
|
+
onPage(p);
|
|
213
|
+
if (page.length < PAGE_SIZE) break;
|
|
214
|
+
}
|
|
215
|
+
return numbers;
|
|
216
|
+
}
|
|
217
|
+
|
|
218
|
+
/**
|
|
219
|
+
* Fetch (or incrementally refresh) the repo's issues, PRs and trusted telemetry records. Async; `gh` may be sync or async.
|
|
220
|
+
* `since` (ISO) bounds the crawl: only items and comments updated on/after it are read, plus whatever the cache already
|
|
221
|
+
* holds. The cache records the lowest window start it covers (`coveredFrom`); a request that starts earlier re-crawls from
|
|
222
|
+
* its own start and then lowers the floor, so a later wider window is never under-reported.
|
|
223
|
+
* Returns { items, records, fetched, fromCache, apiCalls, coveredFrom }. `refresh` ignores the cache; `useCache:false`
|
|
224
|
+
* never touches disk. `onProgress({phase, done, total})` is called as the crawl moves (total is null when unknown).
|
|
225
|
+
*/
|
|
226
|
+
export async function fetchGithubRecords({ repo, since = null, refresh = false, useCache = true, gh = defaultGh, env = process.env,
|
|
227
|
+
onProgress = () => {}, concurrency = DEFAULT_CONCURRENCY, sleep = (ms) => new Promise((r) => setTimeout(r, ms)), now = Date.now,
|
|
228
|
+
rateCheck = gh === defaultGh } = {}) {
|
|
167
229
|
if (!isValidRepo(repo)) throw new Error(`invalid repo: ${repo}`);
|
|
168
230
|
const cache = useCache && !refresh ? loadCache(repo, env) : emptyCache();
|
|
169
|
-
const
|
|
231
|
+
const start = since ?? null;
|
|
232
|
+
const hasCover = cache.watermark != null;
|
|
233
|
+
const floor = cache.coveredFrom;
|
|
234
|
+
// Does the cache already hold everything from `start` on? An unknown floor (a cache from before coveredFrom) is only
|
|
235
|
+
// trusted for a full-history request, never for a window.
|
|
236
|
+
const covers = hasCover && (floor === null || (floor === undefined ? start == null : start != null && Date.parse(start) >= Date.parse(floor)));
|
|
237
|
+
const crawlFrom = covers ? shiftIso(cache.watermark, -WATERMARK_OVERLAP_MS) : start;
|
|
238
|
+
const newFloor = covers ? (floor === undefined ? null : floor) : start;
|
|
239
|
+
const scanTs = isoNow(now());
|
|
240
|
+
let apiCalls = 0, guard = Promise.resolve();
|
|
241
|
+
const call = async (args) => {
|
|
242
|
+
if (rateCheck && apiCalls > 0 && apiCalls % RATE_CHECK_EVERY === 0) guard = guard.then(() => waitForRate(gh, sleep, now));
|
|
243
|
+
apiCalls++;
|
|
244
|
+
await guard;
|
|
245
|
+
return gh(args);
|
|
246
|
+
};
|
|
170
247
|
const items = { ...cache.items };
|
|
171
248
|
let watermark = cache.watermark;
|
|
172
249
|
let fetched = 0;
|
|
173
|
-
|
|
174
|
-
//
|
|
175
|
-
//
|
|
176
|
-
const
|
|
250
|
+
let gapDone = false;
|
|
251
|
+
// Never persist a watermark past the moment the comment scan started: a comment posted after it bumps its issue's
|
|
252
|
+
// updated_at, and the next run must still start before that.
|
|
253
|
+
const cap = (w) => (w && w > scanTs ? scanTs : w);
|
|
254
|
+
// Telemetry issues whose records are not yet in `items` (the records are re-read in memory first). While any remain the
|
|
255
|
+
// watermark must not move past where the comment scan began, or a resumed run would never re-scan their comments.
|
|
256
|
+
const pending = new Set();
|
|
257
|
+
const scanResume = () => (crawlFrom ? shiftIso(crawlFrom, WATERMARK_OVERLAP_MS) : null);
|
|
258
|
+
// Checkpoint: while the crawl extends coverage forward (fresh cache, or the covered range's tail) the watermark is the
|
|
259
|
+
// newest updated_at processed. A gap crawl (window starts before the floor) leaves watermark and floor untouched until
|
|
260
|
+
// it finishes, so a failure never claims coverage it does not have.
|
|
261
|
+
const checkpoint = () => {
|
|
262
|
+
if (!useCache) return;
|
|
263
|
+
const extend = !hasCover || covers || gapDone;
|
|
264
|
+
saveCache(repo, { ...emptyCache(), watermark: extend ? (pending.size > 0 ? scanResume() : cap(watermark)) : cache.watermark, coveredFrom: extend ? newFloor : cache.coveredFrom, items }, env);
|
|
265
|
+
};
|
|
177
266
|
try {
|
|
178
|
-
|
|
267
|
+
const telemetry = [...await scanWindowComments(call, repo, crawlFrom, env, (p) => onProgress({ phase: "comments", done: p, total: null }))];
|
|
268
|
+
// A telemetry run spans several comment parts and the window holds only the touched ones, so each such issue's full
|
|
269
|
+
// comment list is re-read once (bounded pool) and replaces its records.
|
|
270
|
+
const recordsOf = new Map();
|
|
271
|
+
for (const n of telemetry) pending.add(n);
|
|
272
|
+
let done = 0;
|
|
273
|
+
await pool(telemetry, concurrency, async (n) => {
|
|
274
|
+
recordsOf.set(n, parseTelemetryComments(parsePages(await call(["api", "--paginate", `repos/${repo}/issues/${n}/comments`])), env));
|
|
275
|
+
fetched++;
|
|
276
|
+
onProgress({ phase: "telemetry", done: ++done, total: telemetry.length });
|
|
277
|
+
});
|
|
278
|
+
const listed = new Set();
|
|
279
|
+
let processed = 0;
|
|
280
|
+
for await (const page of listIssuePages(call, repo, crawlFrom)) for (const it of page) {
|
|
179
281
|
const m = meta(it);
|
|
180
|
-
|
|
181
|
-
|
|
182
|
-
|
|
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 };
|
|
282
|
+
items[it.number] = { ...m, records: recordsOf.get(it.number) ?? items[it.number]?.records ?? [] };
|
|
283
|
+
listed.add(it.number);
|
|
284
|
+
pending.delete(it.number);
|
|
188
285
|
if (m.updatedAt && (!watermark || m.updatedAt > watermark)) watermark = m.updatedAt;
|
|
189
|
-
if (
|
|
286
|
+
if (++processed % CHECKPOINT_EVERY === 0) checkpoint();
|
|
287
|
+
if (processed % PAGE_SIZE === 0) onProgress({ phase: "issues", done: processed, total: null });
|
|
190
288
|
}
|
|
289
|
+
onProgress({ phase: "issues", done: processed, total: null });
|
|
290
|
+
// A telemetry comment edited in place does not bump its issue's updated_at, so such an issue can be missing from the
|
|
291
|
+
// listing: update its cached records, and read its metadata once if the cache has never seen it.
|
|
292
|
+
await pool(telemetry.filter((n) => !listed.has(n)), concurrency, async (n) => {
|
|
293
|
+
if (items[n]) { items[n] = { ...items[n], records: recordsOf.get(n) }; pending.delete(n); return; }
|
|
294
|
+
const [it] = parsePages(await call(["api", `repos/${repo}/issues/${n}`]));
|
|
295
|
+
if (it && Number.isInteger(it.number)) items[n] = { ...meta(it), records: recordsOf.get(n) };
|
|
296
|
+
pending.delete(n);
|
|
297
|
+
});
|
|
298
|
+
gapDone = true;
|
|
191
299
|
} catch (err) {
|
|
192
300
|
checkpoint();
|
|
193
301
|
throw err;
|
|
@@ -198,7 +306,7 @@ export function fetchGithubRecords({ repo, refresh = false, useCache = true, gh
|
|
|
198
306
|
for (const it of Object.values(items)) for (const r of it.records ?? []) {
|
|
199
307
|
if (!seen.has(r.eid)) { seen.add(r.eid); records.push(r); }
|
|
200
308
|
}
|
|
201
|
-
return { items: Object.values(items), records, fetched, fromCache:
|
|
309
|
+
return { items: Object.values(items), records, fetched, fromCache: hasCover, apiCalls, coveredFrom: newFloor };
|
|
202
310
|
}
|
|
203
311
|
|
|
204
312
|
/** Aggregate-model inputs for one repo from a fetch result. */
|
package/bin/stats/index.mjs
CHANGED
|
@@ -22,10 +22,12 @@ export const HELP = `Usage: forgedock stats [--since <version|date>] [--repo <ow
|
|
|
22
22
|
Version-over-version report: Cost, Time, Flow, Review funnel, Gates and Issue flow per (version, repo), with a
|
|
23
23
|
delta against the previous version and a low-n flag for samples under 10. Missing data prints n/a.
|
|
24
24
|
|
|
25
|
-
--since <v|date> only versions >= <v> (e.g. 1.16.0) or activity on/after <date> (YYYY-MM-DD)
|
|
25
|
+
--since <v|date> only versions >= <v> (e.g. 1.16.0) or activity on/after <date> (YYYY-MM-DD);
|
|
26
|
+
also bounds the GitHub crawl to that window
|
|
26
27
|
--repo <owner/repo> target repo (default: forge.yaml project, else the git origin remote)
|
|
27
28
|
--json machine-readable output
|
|
28
29
|
--refresh ignore the local cache and re-read everything from GitHub
|
|
30
|
+
(progress is printed to stderr while crawling)
|
|
29
31
|
--compact one-row-per-version table (embedded in the staging review summary)
|
|
30
32
|
--no-local ignore ~/.forge/runs (FORGE_RUNS_DIR)
|
|
31
33
|
--no-cache do not read or write ~/.forge/stats-cache
|
|
@@ -78,6 +80,38 @@ export function parseReleaseTimes(text) {
|
|
|
78
80
|
.filter((p) => p.length === 2 && /^\d+$/.test(p[1])).map((p) => ({ v: p[0].replace(/^v/, ""), t: Number(p[1]) }));
|
|
79
81
|
}
|
|
80
82
|
|
|
83
|
+
// A version --since becomes the tag's release time minus this margin: unversioned records are placed by release time, so
|
|
84
|
+
// the crawl must start a little before the tag.
|
|
85
|
+
const VERSION_SINCE_MARGIN_MS = 24 * 60 * 60 * 1000;
|
|
86
|
+
|
|
87
|
+
/**
|
|
88
|
+
* Lower-bound timestamp for the GitHub crawl from `--since`: a date as is, a version as its tag time minus a margin.
|
|
89
|
+
* Returns { since: ISO | null, note }: since null means a full crawl (no --since, or a version with no known tag time).
|
|
90
|
+
*/
|
|
91
|
+
export function sinceToTimestamp(since, releaseTimes = []) {
|
|
92
|
+
const p = parseSince(since);
|
|
93
|
+
const iso = (ms) => new Date(ms).toISOString().replace(/\.\d{3}Z$/, "Z");
|
|
94
|
+
if (p.sinceMs != null) return { since: iso(p.sinceMs), note: null };
|
|
95
|
+
if (p.sinceVersion) {
|
|
96
|
+
const hit = releaseTimes.find((r) => r.v === p.sinceVersion);
|
|
97
|
+
if (hit) return { since: iso(hit.t * 1000 - VERSION_SINCE_MARGIN_MS), note: null };
|
|
98
|
+
return { since: null, note: `no release time known for ${p.sinceVersion}; crawling the full history (pass --release-times or use a date to bound it)` };
|
|
99
|
+
}
|
|
100
|
+
return { since: null, note: null };
|
|
101
|
+
}
|
|
102
|
+
|
|
103
|
+
/** Throttled stderr progress printer (never stdout, so --json stays parseable). */
|
|
104
|
+
export function makeProgressPrinter(write = (t) => process.stderr.write(t), now = Date.now) {
|
|
105
|
+
let last = 0, lastPhase = null;
|
|
106
|
+
return ({ phase, done, total }) => {
|
|
107
|
+
const t = now();
|
|
108
|
+
const final = total != null && done >= total;
|
|
109
|
+
if (phase === lastPhase && !final && t - last < 2000) return;
|
|
110
|
+
last = t; lastPhase = phase;
|
|
111
|
+
write(`forgedock stats: ${phase} ${done}${total != null ? `/${total}` : ""}\n`);
|
|
112
|
+
};
|
|
113
|
+
}
|
|
114
|
+
|
|
81
115
|
function gitTagTimes() {
|
|
82
116
|
try {
|
|
83
117
|
const out = execFileSync("git", ["-C", join(here, "..", ".."), "for-each-ref", "refs/tags", "--format=%(refname:short) %(creatordate:unix)"],
|
|
@@ -107,21 +141,24 @@ export function resolveRepo(explicit, cwd = process.cwd()) {
|
|
|
107
141
|
return null;
|
|
108
142
|
}
|
|
109
143
|
|
|
110
|
-
/** Build the report object. deps: { gh, env, localRuns, now } for tests. Never touches the network when `gh` is stubbed. */
|
|
111
|
-
export function buildReport(opts, deps = {}) {
|
|
144
|
+
/** Build the report object. deps: { gh, env, localRuns, now, onProgress, warn, sleep } for tests. Never touches the network when `gh` is stubbed. */
|
|
145
|
+
export async function buildReport(opts, deps = {}) {
|
|
112
146
|
const env = deps.env ?? process.env;
|
|
113
147
|
const repo = opts.repo;
|
|
114
|
-
|
|
148
|
+
let releaseTimes = [];
|
|
149
|
+
if (opts.releaseTimes) releaseTimes = parseReleaseTimes(readFileSync(opts.releaseTimes, "utf8"));
|
|
150
|
+
else if (deps.releaseTimes) releaseTimes = deps.releaseTimes;
|
|
151
|
+
else releaseTimes = gitTagTimes();
|
|
152
|
+
const bound = sinceToTimestamp(opts.since, releaseTimes);
|
|
153
|
+
if (bound.note) (deps.warn ?? ((m) => process.stderr.write(`forgedock stats: ${m}\n`)))(bound.note);
|
|
154
|
+
const gh = await fetchGithubRecords({ repo, since: bound.since, refresh: opts.refresh, useCache: opts.cache, gh: deps.gh, env,
|
|
155
|
+
onProgress: deps.onProgress, sleep: deps.sleep, now: deps.now });
|
|
115
156
|
const model = toModel(repo, gh);
|
|
116
157
|
const ghKeys = new Set(model.runs.map((r) => `${r.repo}#${r.issue}`));
|
|
117
158
|
let local = [];
|
|
118
159
|
if (opts.local) {
|
|
119
160
|
local = (deps.localRuns ?? readLocalRuns(runsDir(env))).filter((r) => r.repo === repo && !ghKeys.has(`${r.repo}#${r.issue}`));
|
|
120
161
|
}
|
|
121
|
-
let releaseTimes = [];
|
|
122
|
-
if (opts.releaseTimes) releaseTimes = parseReleaseTimes(readFileSync(opts.releaseTimes, "utf8"));
|
|
123
|
-
else if (deps.releaseTimes) releaseTimes = deps.releaseTimes;
|
|
124
|
-
else releaseTimes = gitTagTimes();
|
|
125
162
|
const report = aggregate({ runs: [...model.runs, ...local], events: model.events, issues: model.issues },
|
|
126
163
|
{ minN: opts.minN, releaseTimes, ...parseSince(opts.since) });
|
|
127
164
|
return { repo, sources: { github: { items: gh.items.length, records: gh.records.length, cached: gh.fromCache }, local: { runs: local.length } }, ...report };
|
|
@@ -140,7 +177,7 @@ export async function runStats(argv, deps = {}) {
|
|
|
140
177
|
return 2;
|
|
141
178
|
}
|
|
142
179
|
try {
|
|
143
|
-
const report = buildReport(opts, deps);
|
|
180
|
+
const report = await buildReport(opts, { onProgress: makeProgressPrinter(), ...deps });
|
|
144
181
|
process.stdout.write(`${opts.json ? renderJson(report) : opts.compact ? renderCompact(report) : renderMarkdown(report)}\n`);
|
|
145
182
|
return 0;
|
|
146
183
|
} catch (err) {
|
package/bin/stats/render.mjs
CHANGED
|
@@ -55,7 +55,7 @@ function sections(g) {
|
|
|
55
55
|
const kv = (o) => Object.entries(o).map(([a, b]) => `${a} ${b}`).join(", ") || NA;
|
|
56
56
|
out.push("### Issue flow", ...table(["metric", "value"], [
|
|
57
57
|
["created by source", kv(i.created)], ["closed by kind", kv(i.closed)],
|
|
58
|
-
["net flow (created - closed)", isNum(i.net) ? signed(i.net, String) : NA], ["findings per merged issue", f1(i.findingsPerMerge)],
|
|
58
|
+
["net flow (created - closed)", isNum(i.net) ? signed(i.net, String) : NA], ["findings per merged issue", f1(i.findingsPerMerge)], ["introduced findings per merged issue", f1(i.introducedPerMerge)],
|
|
59
59
|
]), "");
|
|
60
60
|
if (g.delta) {
|
|
61
61
|
const d = g.delta;
|