forgedock 1.18.0 → 1.19.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 +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 +139 -7
- package/bin/runner.mjs +69 -9
- 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/review-pr-staging.md +4 -4
- package/commands/review-pr.md +25 -15
- package/commands/work-on/build/architect.md +53 -0
- package/commands/work-on/build/implement.md +19 -0
- package/commands/work-on/build/validate.md +320 -28
- package/commands/work-on/build.md +66 -9
- package/commands/work-on/investigate.md +7 -0
- package/commands/work-on/remediate.md +32 -13
- package/commands/work-on/review.md +32 -19
- package/docs/CONFIG.md +89 -2
- package/docs/OUTCOME-CODES.md +5 -1
- package/docs/STATS.md +11 -4
- package/docs/TELEMETRY.md +5 -2
- package/docs/WORK-ON-RUNTIME.md +18 -0
- 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/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 +2 -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 +371 -0
- package/scripts/ci-local/plan.mjs +186 -0
- package/scripts/ci-local/run.mjs +345 -0
- package/scripts/ci-local/scoping.mjs +508 -0
- package/scripts/ci-local.sh +97 -0
- package/scripts/ci-local.test.sh +919 -0
- package/scripts/classify-finding.sh +32 -6
- 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/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 +1 -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 +193 -41
- package/scripts/review-finalize.test.sh +211 -4
- 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/run-shell-tests.sh +5 -0
- package/scripts/telemetry.test.sh +2 -0
- package/scripts/trusted-comments.test.sh +2 -1
- package/scripts/verify-phase-trail.test.sh +8 -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,22 +107,47 @@ 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 */ }
|
|
@@ -1493,6 +1521,9 @@ export function runCliBackend({
|
|
|
1493
1521
|
// intentionally omitted here since spawnSync's own `timeout` + ETIMEDOUT is
|
|
1494
1522
|
// reliable for this single, non-looped invocation.
|
|
1495
1523
|
const timedOut = result.status === null && result.error?.code === "ETIMEDOUT";
|
|
1524
|
+
// forge#4037: only a spawn-stage failure is transient infra. A wall-clock kill leaves evidence that the child ran
|
|
1525
|
+
// (a kill signal, a pid, or captured output); an ETIMEDOUT with none of those never started the child.
|
|
1526
|
+
const childRan = !!result.signal || (Number.isInteger(result.pid) && result.pid > 0) || (stdout + stderr).trim().length > 0;
|
|
1496
1527
|
if (timedOut) {
|
|
1497
1528
|
const timeoutSecs = Math.round(timeoutMs / 1000);
|
|
1498
1529
|
// forge#2360: spawnSync's `timeout` option kills the child outright on
|
|
@@ -1510,15 +1541,44 @@ export function runCliBackend({
|
|
|
1510
1541
|
// review findings — #2277, #2292, #2293, #2355 — for that defect
|
|
1511
1542
|
// class).
|
|
1512
1543
|
const partial = (stdout + stderr).trim();
|
|
1513
|
-
|
|
1544
|
+
const timeoutError = new Error(
|
|
1514
1545
|
`claude CLI invocation timed out after ${timeoutSecs}s and was killed. ` +
|
|
1515
1546
|
`Set FORGEDOCK_CLI_TIMEOUT_MS (ms) to adjust, or use --backend api.` +
|
|
1516
1547
|
(partial ? ` Partial output: ${sanitizeOutputExcerptForLog(partial)}` : ""),
|
|
1517
1548
|
);
|
|
1549
|
+
if (!childRan) {
|
|
1550
|
+
// forge#4021: stable transient-infra class; the engine retries it on a
|
|
1551
|
+
// separate bounded counter without consuming a phase attempt.
|
|
1552
|
+
timeoutError.code = "CLI_SPAWN_TRANSIENT";
|
|
1553
|
+
timeoutError.spawnErrno = "ETIMEDOUT";
|
|
1554
|
+
throw timeoutError;
|
|
1555
|
+
}
|
|
1556
|
+
// forge#4037: the child ran and was killed at the wall-clock limit: a normal, attempt-consuming failure
|
|
1557
|
+
// (`cli.timeout`), with usage attached when the CLI had already emitted a parseable envelope.
|
|
1558
|
+
timeoutError.code = "CLI_TIMEOUT";
|
|
1559
|
+
timeoutError.usage = null;
|
|
1560
|
+
try {
|
|
1561
|
+
const u = JSON.parse(stdout.trim())?.usage;
|
|
1562
|
+
if (u && typeof u === "object") {
|
|
1563
|
+
timeoutError.usage = {
|
|
1564
|
+
input_tokens: toFiniteUsageNumber(u.input_tokens),
|
|
1565
|
+
output_tokens: toFiniteUsageNumber(u.output_tokens),
|
|
1566
|
+
cache_creation_input_tokens: toFiniteUsageNumber(u.cache_creation_input_tokens),
|
|
1567
|
+
cache_read_input_tokens: toFiniteUsageNumber(u.cache_read_input_tokens),
|
|
1568
|
+
};
|
|
1569
|
+
}
|
|
1570
|
+
} catch {
|
|
1571
|
+
// No parseable envelope from a killed child: usage stays null (never fabricated).
|
|
1572
|
+
}
|
|
1573
|
+
throw timeoutError;
|
|
1518
1574
|
}
|
|
1519
1575
|
if (result.error) {
|
|
1520
1576
|
const error = new Error(`Failed to invoke claude CLI: ${result.error.message}`);
|
|
1521
1577
|
error.code = result.error.code;
|
|
1578
|
+
if (["EAGAIN", "ENOMEM", "EMFILE"].includes(result.error.code)) {
|
|
1579
|
+
error.spawnErrno = result.error.code;
|
|
1580
|
+
error.code = "CLI_SPAWN_TRANSIENT";
|
|
1581
|
+
}
|
|
1522
1582
|
throw error;
|
|
1523
1583
|
}
|
|
1524
1584
|
|
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;
|