forgedock 1.17.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 +37 -2
- package/bin/engine/phases.mjs +207 -106
- package/bin/engine/runlog.mjs +17 -2
- package/bin/engine/telemetry-seam.mjs +3 -0
- package/bin/engine/telemetry.test.mjs +3 -2
- package/bin/engine-cli.mjs +37 -2
- package/bin/engine.mjs +221 -15
- package/bin/forgedock.mjs +1 -1
- package/bin/hooks/pre-tool-use.mjs +65 -32
- package/bin/runner.mjs +125 -6
- package/bin/stats/aggregate.mjs +7 -3
- package/bin/stats/cache.mjs +7 -3
- package/bin/stats/github-source.mjs +203 -24
- package/bin/stats/index.mjs +46 -9
- package/bin/stats/render.mjs +1 -1
- package/bin/stats/stats.test.mjs +264 -15
- package/commands/cleanup.md +10 -3
- package/commands/deploy-pr.md +64 -0
- package/commands/fix-ci.md +20 -6
- package/commands/milestone.md +34 -17
- package/commands/orchestrate/phase-4-execution.md +11 -4
- package/commands/review-pr-staging.md +145 -522
- package/commands/review-pr.md +496 -3124
- package/commands/test-gate.md +25 -10
- package/commands/work-on/build/architect.md +66 -1
- package/commands/work-on/build/implement.md +21 -1
- package/commands/work-on/build/validate.md +322 -28
- package/commands/work-on/build.md +173 -95
- package/commands/work-on/close.md +50 -15
- package/commands/work-on/decompose.md +72 -34
- package/commands/work-on/investigate.md +16 -12
- package/commands/work-on/remediate.md +58 -25
- package/commands/work-on/review.md +36 -21
- package/commands/work-on.md +1 -2
- package/docs/CONFIG.md +132 -26
- package/docs/OUTCOME-CODES.md +12 -3
- package/docs/RELEASE-NOTES.md +12 -0
- package/docs/STATS.md +13 -5
- package/docs/TELEMETRY.md +7 -4
- package/docs/WORK-ON-RUNTIME.md +24 -0
- package/docs/spec/forge-protocol-v1.md +2 -2
- package/package.json +2 -2
- package/packages/protocol/src/phases.js +5 -5
- 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.sh +1 -1
- package/scripts/cap-findings.test.sh +3 -1
- 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-bash.sh +5 -1
- package/scripts/check-spec-bash.test.sh +20 -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 +71 -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/diff-size.sh +93 -4
- package/scripts/diff-size.test.sh +59 -2
- 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 +14 -12
- package/scripts/lib/telemetry.sh +1 -0
- package/scripts/publish-staging-sync.test.sh +48 -0
- package/scripts/remediate-approved-head.test.sh +2 -0
- package/scripts/remediate-basesync.test.sh +1 -1
- package/scripts/resolve-deploy-gate.sh +32 -6
- package/scripts/resolve-deploy-gate.test.sh +42 -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 +1579 -0
- package/scripts/review-finalize.test.sh +1040 -0
- package/scripts/review-pr-delta-wiring.test.sh +32 -72
- package/scripts/review-pr-domain-map.test.sh +18 -13
- package/scripts/review-pr-gate2.test.sh +9 -65
- package/scripts/review-pr-staging-scope.test.sh +14 -98
- package/scripts/review-prepare.sh +53 -15
- package/scripts/review-prepare.test.sh +37 -3
- package/scripts/review-roster.sh +16 -2
- package/scripts/review-roster.test.sh +22 -0
- package/scripts/run-shell-tests.sh +5 -0
- package/scripts/telemetry.test.sh +2 -0
- package/scripts/trusted-comments.test.sh +11 -3
- package/scripts/verify-phase-trail.test.sh +8 -0
- package/scripts/wait-ci-green.sh +61 -9
- package/scripts/wait-ci-green.test.sh +44 -6
- package/scripts/with-run-tmpdir.sh +20 -0
|
@@ -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. */
|
|
@@ -58,8 +65,10 @@ export function parseTelemetryComments(comments, env = process.env) {
|
|
|
58
65
|
const num = (x) => { const v = Number(x); return x != null && x !== "" && Number.isFinite(v) ? v : null; };
|
|
59
66
|
|
|
60
67
|
/**
|
|
61
|
-
* Turn `phase_attempt` / `run_terminal` records into run summaries
|
|
62
|
-
* `
|
|
68
|
+
* Turn `phase_attempt` / `run_terminal` records into run summaries, finished only when a `run_terminal` exists.
|
|
69
|
+
* The run id is per engine process, so a `--retry` yields several; they collapse to ONE run per repo#issue, the latest
|
|
70
|
+
* by start timestamp (tie: greater run id), matching scripts/cost-report.sh (last RUN_START slice). Finished/usage
|
|
71
|
+
* filtering stays downstream, so an unfinished latest run still excludes the issue. Field names are the ones bin/engine/telemetry-seam.mjs writes (docs/TELEMETRY.md).
|
|
63
72
|
*/
|
|
64
73
|
export function runsFromRecords(records) {
|
|
65
74
|
const byRun = new Map();
|
|
@@ -86,48 +95,218 @@ export function runsFromRecords(records) {
|
|
|
86
95
|
if (num(r.cost_usd) != null) p.usd = (p.usd ?? 0) + num(r.cost_usd);
|
|
87
96
|
if (id === "remediate") run.remediated = true;
|
|
88
97
|
}
|
|
89
|
-
|
|
98
|
+
const latest = new Map();
|
|
99
|
+
for (const run of byRun.values()) {
|
|
100
|
+
const k = `${run.repo}#${run.issue}`;
|
|
101
|
+
const cur = latest.get(k);
|
|
102
|
+
const a = run._first ?? "", b = cur?._first ?? "";
|
|
103
|
+
if (!cur || a > b || (a === b && (run.run ?? "") > (cur.run ?? ""))) latest.set(k, run);
|
|
104
|
+
}
|
|
105
|
+
return [...latest.values()].map(({ _first, ...run }) => ({ ...run, startTs: _first }));
|
|
90
106
|
}
|
|
91
107
|
|
|
92
108
|
/** Gate/review records (everything that is not a run record) become aggregate events. */
|
|
93
109
|
export const gateEvents = (records) => records.filter((r) => r.event !== "phase_attempt" && r.event !== "run_terminal")
|
|
94
110
|
.map((r) => ({ ...r, version: r.forge_version ?? null }));
|
|
95
111
|
|
|
112
|
+
const PAGE_SIZE = 100;
|
|
113
|
+
// The next incremental run re-reads this much before the persisted watermark (an overlap window), so a write that races a
|
|
114
|
+
// crawl or a small clock skew is re-listed. Re-reads are idempotent: items are keyed by issue number.
|
|
115
|
+
const WATERMARK_OVERLAP_MS = 5 * 60 * 1000;
|
|
116
|
+
const MAX_LIST_REQUESTS = 100000;
|
|
117
|
+
|
|
118
|
+
/** Shift an ISO-8601 timestamp by `ms`; an unparsable value is returned unchanged. */
|
|
119
|
+
export function shiftIso(ts, ms) {
|
|
120
|
+
const t = Date.parse(ts);
|
|
121
|
+
return Number.isNaN(t) ? ts : new Date(t + ms).toISOString().replace(/\.\d{3}Z$/, "Z");
|
|
122
|
+
}
|
|
123
|
+
|
|
124
|
+
const sinceParam = (ts) => encodeURIComponent(ts).replace(/%3A/g, ":");
|
|
125
|
+
|
|
126
|
+
/**
|
|
127
|
+
* Keyset crawl of the issue listing. The listing is sorted by a MUTABLE key (updated_at), so page-number pagination
|
|
128
|
+
* (`gh --paginate`) can skip an item updated mid-crawl: it moves to the end of the ordering and the items behind it shift
|
|
129
|
+
* one page earlier. Instead every request is page 1 from `since=CURSOR`, the cursor moves to the newest `updated_at` read,
|
|
130
|
+
* and already-seen (number, updated_at) pairs are dropped (the `since` boundary is inclusive). A full page that cannot move
|
|
131
|
+
* the cursor (more than PAGE_SIZE items share one timestamp) is drained with `page=N` for that timestamp only.
|
|
132
|
+
* Yields each page's unseen items sorted updated-asc.
|
|
133
|
+
*/
|
|
134
|
+
async function* listIssuePages(gh, repo, startSince) {
|
|
135
|
+
const base = ["state=all", "sort=updated", "direction=asc", `per_page=${PAGE_SIZE}`];
|
|
136
|
+
const seen = new Set();
|
|
137
|
+
const key = (it) => `${it.number}@${it.updated_at}`;
|
|
138
|
+
const take = (page) => {
|
|
139
|
+
const fresh = page.filter((it) => Number.isInteger(it?.number) && !seen.has(key(it)));
|
|
140
|
+
for (const it of fresh) seen.add(key(it));
|
|
141
|
+
return fresh.sort((a, b) => String(a.updated_at).localeCompare(String(b.updated_at)));
|
|
142
|
+
};
|
|
143
|
+
const maxTs = (page) => page.reduce((m, it) => (it?.updated_at && (!m || it.updated_at > m) ? it.updated_at : m), null);
|
|
144
|
+
let cursor = startSince ?? null;
|
|
145
|
+
for (let n = 0; n < MAX_LIST_REQUESTS; n++) {
|
|
146
|
+
const get = async (extra = []) => parsePages(await gh(["api", `repos/${repo}/issues?${[...base, ...(cursor ? [`since=${sinceParam(cursor)}`] : []), ...extra].join("&")}`]));
|
|
147
|
+
const page = await get();
|
|
148
|
+
const fresh = take(page);
|
|
149
|
+
if (fresh.length > 0) yield fresh;
|
|
150
|
+
if (page.length < PAGE_SIZE) return;
|
|
151
|
+
const next = maxTs(page);
|
|
152
|
+
if (next && next !== cursor) { cursor = next; continue; }
|
|
153
|
+
// No cursor progress on a full page: drain this timestamp by page number, then step past it.
|
|
154
|
+
for (let p = 2; p < MAX_LIST_REQUESTS; p++) {
|
|
155
|
+
const more = await get([`page=${p}`]);
|
|
156
|
+
const freshMore = take(more);
|
|
157
|
+
if (freshMore.length > 0) yield freshMore;
|
|
158
|
+
if (more.length < PAGE_SIZE || freshMore.length === 0) break;
|
|
159
|
+
}
|
|
160
|
+
if (!cursor) return;
|
|
161
|
+
cursor = shiftIso(cursor, 1000);
|
|
162
|
+
}
|
|
163
|
+
}
|
|
164
|
+
|
|
96
165
|
const meta = (it) => ({ number: it.number, isPr: Boolean(it.pull_request), createdAt: it.created_at ?? null, closedAt: it.closed_at ?? null,
|
|
97
166
|
updatedAt: it.updated_at ?? null, labels: (it.labels ?? []).map((l) => (typeof l === "string" ? l : l.name)).filter(Boolean), authorType: it.user?.type ?? null });
|
|
98
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
|
+
|
|
99
198
|
/**
|
|
100
|
-
*
|
|
101
|
-
*
|
|
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.
|
|
102
201
|
*/
|
|
103
|
-
|
|
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 } = {}) {
|
|
104
229
|
if (!isValidRepo(repo)) throw new Error(`invalid repo: ${repo}`);
|
|
105
230
|
const cache = useCache && !refresh ? loadCache(repo, env) : emptyCache();
|
|
106
|
-
const
|
|
107
|
-
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
|
+
};
|
|
108
247
|
const items = { ...cache.items };
|
|
109
248
|
let watermark = cache.watermark;
|
|
110
249
|
let fetched = 0;
|
|
111
|
-
|
|
112
|
-
|
|
113
|
-
|
|
114
|
-
|
|
115
|
-
|
|
116
|
-
|
|
117
|
-
|
|
118
|
-
|
|
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
|
+
};
|
|
266
|
+
try {
|
|
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));
|
|
119
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) {
|
|
281
|
+
const m = meta(it);
|
|
282
|
+
items[it.number] = { ...m, records: recordsOf.get(it.number) ?? items[it.number]?.records ?? [] };
|
|
283
|
+
listed.add(it.number);
|
|
284
|
+
pending.delete(it.number);
|
|
285
|
+
if (m.updatedAt && (!watermark || m.updatedAt > watermark)) watermark = m.updatedAt;
|
|
286
|
+
if (++processed % CHECKPOINT_EVERY === 0) checkpoint();
|
|
287
|
+
if (processed % PAGE_SIZE === 0) onProgress({ phase: "issues", done: processed, total: null });
|
|
120
288
|
}
|
|
121
|
-
|
|
122
|
-
|
|
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;
|
|
299
|
+
} catch (err) {
|
|
300
|
+
checkpoint();
|
|
301
|
+
throw err;
|
|
123
302
|
}
|
|
124
|
-
|
|
303
|
+
checkpoint();
|
|
125
304
|
const seen = new Set();
|
|
126
305
|
const records = [];
|
|
127
306
|
for (const it of Object.values(items)) for (const r of it.records ?? []) {
|
|
128
307
|
if (!seen.has(r.eid)) { seen.add(r.eid); records.push(r); }
|
|
129
308
|
}
|
|
130
|
-
return { items: Object.values(items), records, fetched, fromCache:
|
|
309
|
+
return { items: Object.values(items), records, fetched, fromCache: hasCover, apiCalls, coveredFrom: newFloor };
|
|
131
310
|
}
|
|
132
311
|
|
|
133
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;
|