v1.91.333.0 fix(scorecard): --now bounds every windowed line; recommended-harness week is 83 PRs (GST-135) (#3635)

* fix(scorecard): --now bounds every windowed line; recommended-harness week is 83 PRs (GST-135)

Co-authored-by: capy-ai[bot] <230910855+capy-ai[bot]@users.noreply.github.com>

* fix(scorecard): page-capped reads report unavailable, releases page to the window, one instant comparison for runs

Pre-landing review fixes for GST-135: readMergedPrs, readGateRuns and readReleases throw past MAX_PAGES
instead of returning a partial count; readReleases pages until it reaches the window start;
listWorkflowRuns compares both bounds with Date.parse and skips unparsable timestamps; tests for a
sub-second window, releases behind a full page and the PR page cap; doc wording on the red-train row
and the unverified 142-PR baseline removed.

Co-authored-by: capy-ai[bot] <230910855+capy-ai[bot]@users.noreply.github.com>

* docs(harness): document the scorecard's --now window end

Co-authored-by: capy-ai[bot] <230910855+capy-ai[bot]@users.noreply.github.com>

* test(scorecard): a numeric timestamp in the inWindow control is a parsed date, not a bare epoch literal

Co-authored-by: capy-ai[bot] <230910855+capy-ai[bot]@users.noreply.github.com>

* fix(scorecard): releases read every page; flaky row counts records across cases

GitHub lists releases by creation, so stopping on a page's oldest
publication could miss a draft published inside the window. Read every
page (the page cap still marks the line unavailable). Pin the gate-run
and release page caps with a test. The harness doc's flaky row now says
17 paid flake records across 11 cases instead of 17 cases.

Co-authored-by: capy-ai[bot] <230910855+capy-ai[bot]@users.noreply.github.com>

* v1.91.333.0 stamp

Co-authored-by: capy-ai[bot] <230910855+capy-ai[bot]@users.noreply.github.com>

---------

Co-authored-by: capy-ai[bot] <230910855+capy-ai[bot]@users.noreply.github.com>
This commit is contained in:
Garry Tanandcapy-ai[bot] authored and GitHub committed 2026-10-30 12:16:21 +00:00
1 parent 39378ca6d1
commit 11c17d466a
10 files changed
+200 -55

No files matched your search

+12
View File
@@ -1,5 +1,17 @@
# Changelog
## [1.91.333.0] - 2026-10-30
**`gstack-factory-scorecard --now` now ends the window, so a past week no longer counts what happened after it.** Every line counts only items whose timestamps fall in `[now - since-days, now)`. Before, `--now` moved only the start, so `--now 2026-10-15T00:00:00Z --since-days 14` counted 290 merged PRs (every merge from 2026-10-01 through the day of the run) where the window holds 65, and the gate-run and release lines also counted through the day of the run. Rerun with the fix, the two weeks before 2026-10-15 show 65 merged PRs and the two weeks before 2026-10-29 show 197, matching GitHub search for both windows.
### Fixed
- **`lib/factory-scorecard.ts`**: one window (`scoreWindow`, `inWindow`) bounds merged PRs and their lead times, gate runs and their cost, the red-train rate, flaky events, free-suite retries, issues and releases. The workflow-runs query asks GitHub for the window's days (`created=<start>..<end>`), and the issue searches use a closed time range instead of `>=start`. Releases are read to the last page, because GitHub orders them by creation and a draft can be published long after it was created, and a PR, gate-run or release read that runs past 20 pages now marks its line unavailable instead of printing a partial count.
- **`scripts/lib/ci-history.ts`**: `listWorkflowRuns` takes an optional `until` (exclusive), compares both ends of the window as instants and skips a run whose `created_at` does not parse; `createdFilter` builds the runs API's `created` query for both callers.
- **`docs/recommended-harness.md`**: "Where gstack's own numbers stand" is remeasured for the exact week of 2026-10-19 through 2026-10-25 UTC: 83 merged PRs (not 103), median lead time 6.5h, 54 gate runs (52 green, 2 red), $1,361.94 of known gate cost, 17 paid flake records across 11 cases, 0 releases. The earlier figures came from a run before this fix, so they counted past the end of the week.
### Tests
- `test/factory-scorecard.test.ts`: a PR merged exactly at `--now`, and a gate run, free run and release after it, are left out; the runs and issue queries carry the window's end. A sub-second `--now` keeps the issue search range inside the window, releases behind a full page of newer ones are found, a release published late is found on a later page, and a PR, gate-run or release read past the page cap is unavailable.
## [1.91.332.0] - 2026-10-30
**gstack can remember which model you want your agents on, and every Capy session now reads a one-page summary of how crews work.** Set `agent_model` once (`opus`, another family, or an exact `provider/model`) and `gstack-models resolve --role agent` returns it whenever an agent picks a model for a lane, subagent or reviewer. Unset, every agent keeps its own model, and gstack takes no position for or against any model. A cross-family outside voice and a model under test in an eval ignore the preference on purpose. On Capy the preference lives in `preferences.yaml` beside the gstack skill in your Drive, so it survives fresh machines without being copied anywhere.
+1 -1
View File
@@ -1 +1 @@
1.91.332.0
1.91.333.0
+1 -1
View File
@@ -1,4 +1,4 @@
# gstack digest v1.91.332.0 — regenerate/re-copy after upgrading gstack
# gstack digest v1.91.333.0 — regenerate/re-copy after upgrading gstack
Behavioral rules from gstack (https://github.com/garrytan/gstack), compressed
for agent hosts without a full skill install. The full skills add workflows,
+2
View File
@@ -15,6 +15,8 @@
* [--out-dir <dir>] [--csv <file>] [--json] [--cache-dir <dir>]
* [--fixtures <recorded gh responses.json>] [--now <iso>]
*
* The window is [now - since-days, now): --now (default: the current time) is
* its end, and every line counts only items inside it.
* --repo defaults to the GitHub owner/name of the origin remote; without one,
* pass it. Every run writes this window's scorecard.{csv,md} and appends its
* rows to trend.csv in --out-dir, so the weeks line up.
+20 -16
View File
@@ -257,7 +257,7 @@ line whose input could not be read says `unavailable (<reason>)` rather than 0.
gstack-factory-scorecard --repo owner/name --since-days 7 \
[--crew login,login] [--gate-workflow evals.yml] [--free-workflow free-tests.yml] \
[--train-prefix train/] [--ready-marker 'READY #\d+|SHIP_RECEIPT:'] [--pass-marker '"passes": (\d+)|passes=(\d+)'] \
[--pass-cap 3] [--out-dir .context/factory-scorecard]
[--pass-cap 3] [--out-dir .context/factory-scorecard] [--now <iso>]
```
`--repo` defaults to the origin remote's owner/name on github.com (or
@@ -265,6 +265,10 @@ gstack-factory-scorecard --repo owner/name --since-days 7 \
to `--out-dir` and appends the same rows to `trend.csv` there, so a weekly
retro accumulates its own trend without renaming files.
The window is `[now - since-days, now)`: `--now` (default: the current time)
sets its end, and every line counts only items inside it, so a past week
measured later does not count anything that happened after that week.
| Line | How it is counted | What one of the best software factories would show |
|---|---|---|
| Merged PRs | `merged_at` in the window, base = default branch; crew by `--crew` logins, else author association OWNER/MEMBER/COLLABORATOR | several merges a day, every day; outside contributions land in the same week |
@@ -284,29 +288,29 @@ the gap next to the number.
### Where gstack's own numbers stand
Measured on gstack's repository with this tool for 2026-10-19 to 2026-10-26
(the first full week of trains and `/ship`-before-READY; 103 merged PRs, 77
gate runs, artifacts read for the newest 60):
Measured on gstack's repository with this tool for the week of 2026-10-19
through 2026-10-25, UTC (`--now 2026-10-26T00:00:00Z --since-days 7`; the first
full week of trains and `/ship`-before-READY; 83 merged PRs, 54 gate runs,
artifacts read for all 54):
| Line | gstack, that week | Against the bar |
|---|---|---|
| Merged PRs | 103 (crew 100, outside 3) | volume is there; outside contributions are few and are ported by the crew rather than merged from forks |
| Lead time, created → merged | median 7.9h, p90 23.7h | inside the bar on both |
| Lead time, READY → merged | no number: 0 of 103 PRs had a READY comment | the crew reports READY in-thread; step 6 above (post it on the PR) is what makes this measurable |
| Review passes per PR | no number: 0 of 103 PRs carried a pass count | the receipt's `passes` field lands with /ship's convergence rule; until then crews record the count in the READY block, and the marker reads it from a PR comment |
| Gate runs | 73 green / 4 red / 0 cancelled; known cost $1,628 on 59 of 77 runs | green rate 95%; cost unknown on 18 runs (screens that planned no cases, and runs older than the artifact cap) |
| Known gate cost per merged PR | $15.81 (a floor) | about $24 per train of up to 5 PRs, plus screens; one gate per PR would have been about $33 each |
| Red-train rate | 4% (1 red of 28 trains; 0 red backstop, 3 red screens) | inside the bar; the three red screens were one judge reading a moved file |
| Flaky events | 21 paid cases passed on their one rerun (rerun spend $10.72); 17 free-suite retries in 17 of 103 runs | above the bar on both: 0.27 flaky cases per gate against 0.1, and 17% of free runs retried against 1%. One family (`ship-docsync-*`) repeated three times in two days |
| Issues opened / closed | 50 / 56 | inside the bar |
| Releases cut | 1 (the first weekly release) | on schedule, with the starvation caveat above |
| Merged PRs | 83 (crew 80, outside 3) | volume is there; the 3 outside PRs are bot PRs (2 from github-actions, 1 from dependabot), and outside contributions are ported by the crew rather than merged from forks |
| Lead time, created to merged | median 6.5h, p90 20.0h | inside the bar on both |
| Lead time, READY to merged | no number: 0 of 83 PRs had a READY comment | the crew reports READY in-thread; step 6 above (post it on the PR) is what makes this measurable |
| Review passes per PR | no number: 0 of 83 PRs carried a pass count | the receipt's `passes` field lands with /ship's convergence rule; until then crews record the count in the READY block, and the marker reads it from a PR comment |
| Gate runs | 52 green / 2 red / 0 cancelled; known cost $1,361.94 on 52 of 54 runs | green rate 96%; cost unknown only on the 2 red runs (the red train and the red screen) |
| Known gate cost per merged PR | $16.41 (a floor) | trains averaged $32.48 each for up to 5 PRs (21 trains with a known cost), screens $21.18 each (29 with a known cost) |
| Red-train rate | 5% (1 red of 22 trains; 0 red backstop, 1 red screen) | the tool rounds 1 of 22 (4.5%) to 5%, so this is just under the 5% bar; the 1 red screen is counted separately from trains |
| Flaky events | 17 paid flake records across 11 cases, each passing its one rerun (rerun spend $7.20); 17 free-suite retries in 17 of 83 runs | above the bar on all three: 0.31 flake records per gate against 0.1, 4 cases that flaked more than once against 0, and 20% of free runs retried against 1%. One case (`ship-docsync-current`) did it four times in three days |
| Issues opened / closed | 28 / 51 | inside the bar |
| Releases cut | 0 | none inside the week: the first weekly release, v1.91.242.0, was published at 12:01 UTC on 2026-10-26, just after the window closed (see the starvation caveat above) |
Read the gap honestly: lead time and throughput are where they should be, the
gate is green and its cost is mostly known, and flakiness is the line that is
not. The daily pass exists for exactly that line; the scorecard's job is to
show next week whether it moved. The inventory lane's per-PR baseline CSV in
the Captain's Drive (142 merged PRs over the eleven days before this window
closed) holds the longer history behind these numbers; from here on
the Captain's Drive holds the longer history behind these numbers; from here on
`trend.csv` in `--out-dir` carries the weeks.
## What is Capy-specific and what is not
+58 -24
View File
@@ -5,6 +5,13 @@
* counting rule next to the number, and a line whose input cannot be read says
* so ("unavailable: <reason>") instead of printing 0.
*
* The window is [now - since-days, now): every input below counts an item only
* when its timestamp falls inside it (inWindow), so a past window does not
* count anything that happened after it. Attributes are read as they are now
* (a run's latest conclusion, an issue's latest closed_at, artifacts that have
* not expired). A merged-PR, gate-run or release read that runs past MAX_PAGES
* marks its line unavailable instead of printing a partial count.
*
* Inputs, all REST reads (no write, no paid work):
* merged PRs pulls?state=closed&base=<base>, merged_at inside the window;
* crew = --crew logins, else author_association OWNER/MEMBER/COLLABORATOR
@@ -29,7 +36,7 @@ import * as fs from 'node:fs';
import * as os from 'node:os';
import * as path from 'node:path';
import {
GH_CLIENT, downloadRunArtifacts, filesUnder, historyFetcher, listWorkflowRuns, readFlakeLedgerDir, unavailableReason,
GH_CLIENT, createdFilter, downloadRunArtifacts, filesUnder, historyFetcher, listWorkflowRuns, readFlakeLedgerDir, unavailableReason,
type GhClient,
} from '../scripts/lib/ci-history';
@@ -68,6 +75,7 @@ export const DEFAULTS = {
export const CREW_ASSOCIATIONS = new Set(['OWNER', 'MEMBER', 'COLLABORATOR']);
const PAGE = 100;
const MAX_PAGES = 20;
const pageCap = (what: string) => new Error(`${what} ran past ${MAX_PAGES} pages of ${PAGE}; a partial count is not reported`);
const COST_LINE = /\*\*\$(\d+(?:\.\d+)?)\*\* rule\/judge cost|\bcost=\$(\d+(?:\.\d+)?)/g;
export interface MergedPr {
@@ -92,6 +100,24 @@ export interface Scorecard {
}
export interface ScorecardRow { id: string; metric: string; value: string; benchmark: string; rule: string }
/** The scorecard's one window, [start, end) as ISO instants; every windowed read filters through inWindow. */
export interface ScoreWindow { start: string; end: string }
export function scoreWindow(o: Pick<ScorecardOptions, 'now' | 'sinceDays'>): ScoreWindow {
return { start: new Date(o.now - o.sinceDays * 86_400_000).toISOString(), end: new Date(o.now).toISOString() };
}
export function inWindow(at: unknown, w: ScoreWindow): boolean {
const t = typeof at === 'string' ? Date.parse(at) : NaN;
return t >= Date.parse(w.start) && t < Date.parse(w.end);
}
/** GitHub search's inclusive, whole-second range for the window: start rounded up, end one second before the next whole second at or after it. */
export function searchRange(w: ScoreWindow): string {
const second = (ms: number) => new Date(Math.ceil(ms / 1000) * 1000).toISOString().replace(/\.\d{3}Z$/, 'Z');
return `${second(Date.parse(w.start))}..${second(Math.ceil(Date.parse(w.end) / 1000) * 1000 - 1000)}`;
}
// --- Sources ---
export function ghSource(repo: string, cacheDir: string, client: GhClient = GH_CLIENT): ScorecardSource {
@@ -140,14 +166,15 @@ function pullsPage(source: ScorecardSource, o: ScorecardOptions, page: number):
}
/** Every PR merged into base inside the window; pages stop once a page's oldest update predates the window. */
export function readMergedPrs(source: ScorecardSource, o: ScorecardOptions, since: string): MergedPr[] {
export function readMergedPrs(source: ScorecardSource, o: ScorecardOptions, w: ScoreWindow): MergedPr[] {
const out: MergedPr[] = [];
const crewLogins = o.crew ? new Set(o.crew.map(l => l.toLowerCase())) : null;
for (let page = 1; page <= MAX_PAGES; page++) {
for (let page = 1; ; page++) {
if (page > MAX_PAGES) throw pageCap(`PRs updated since ${w.start}`);
const pulls = pullsPage(source, o, page);
for (const pr of pulls) {
const mergedAt = typeof pr.merged_at === 'string' ? pr.merged_at : null;
if (!mergedAt || mergedAt < since) continue;
if (!mergedAt || !inWindow(mergedAt, w)) continue;
const author = String(pr.user?.login ?? '');
const association = String(pr.author_association ?? 'NONE');
const comments = readComments(source, o.repo, Number(pr.number), { readyMarker: o.readyMarker, passMarker: o.passMarker, mergedAt });
@@ -158,7 +185,7 @@ export function readMergedPrs(source: ScorecardSource, o: ScorecardOptions, sinc
passes: bodyPasses === null ? comments.passes : Math.max(bodyPasses, comments.passes ?? 0) });
}
const oldest = pulls[pulls.length - 1]?.updated_at;
if (pulls.length < PAGE || (typeof oldest === 'string' && oldest < since)) break;
if (pulls.length < PAGE || (typeof oldest === 'string' && oldest < w.start)) break;
}
return out.sort((a, b) => a.mergedAt.localeCompare(b.mergedAt));
}
@@ -240,15 +267,16 @@ export function flakeRecords(dirs: string[]): { flakes: number; rerunUsd: number
}
/** Gate runs in the window with kind, outcome, cost and flake records; artifacts are read for the newest maxArtifactRuns only. */
export function readGateRuns(source: ScorecardSource, o: ScorecardOptions, since: string, prCost: Map<number, number>): { gates: GateRun[]; artifactRunsRead: number; artifactErrors: number } {
const query = [`status=completed`, `per_page=${PAGE}`, `created=${encodeURIComponent(`>=${since.slice(0, 10)}`)}`,
export function readGateRuns(source: ScorecardSource, o: ScorecardOptions, w: ScoreWindow, prCost: Map<number, number>): { gates: GateRun[]; artifactRunsRead: number; artifactErrors: number } {
const query = [`status=completed`, `per_page=${PAGE}`, createdFilter(w.start, w.end),
...(o.gateEvent ? [`event=${encodeURIComponent(o.gateEvent)}`] : [])].join('&');
const gates: GateRun[] = [];
for (let page = 1; page <= MAX_PAGES; page++) {
for (let page = 1; ; page++) {
if (page > MAX_PAGES) throw pageCap('gate runs in the window');
const body = source.getJson(`repos/${o.repo}/actions/workflows/${o.gateWorkflow}/runs?${query}&page=${page}`) as { workflow_runs?: any[] };
const runs = Array.isArray(body?.workflow_runs) ? body.workflow_runs : [];
for (const run of runs) {
if (String(run.created_at) < since || (o.gateEvent && run.event !== o.gateEvent)) continue;
if (!inWindow(run.created_at, w) || (o.gateEvent && run.event !== o.gateEvent)) continue;
const title = String(run.display_title ?? run.name ?? '');
const branch = String(run.head_branch ?? '');
const pr = Number(/#(\d+)/.exec(title)?.[1] ?? run.pull_requests?.[0]?.number ?? NaN);
@@ -281,9 +309,9 @@ export function readGateRuns(source: ScorecardSource, o: ScorecardOptions, since
}
/** Free-suite runs on base in the window and the flaky-pass entries their flake-ledger-* artifacts hold. */
export function readFreeRetries(source: ScorecardSource, o: ScorecardOptions, since: string): FreeRetryRun[] {
export function readFreeRetries(source: ScorecardSource, o: ScorecardOptions, w: ScoreWindow): FreeRetryRun[] {
const client: GhClient = { getJson: p => source.getJson(p), downloadZip: () => { throw new Error('not used'); } };
const runs = listWorkflowRuns(client, o.repo, o.freeWorkflow, { since, branch: o.base });
const runs = listWorkflowRuns(client, o.repo, o.freeWorkflow, { since: w.start, until: w.end, branch: o.base });
const retries = (runId: number): number | null => {
try { return [...source.artifactDirs(runId, name => name.startsWith('flake-ledger-')).values()].flatMap(readFlakeLedgerDir).length; } catch { return null; }
};
@@ -295,30 +323,36 @@ function searchCount(source: ScorecardSource, q: string): number | null {
return typeof body?.total_count === 'number' ? body.total_count : null;
}
export function readReleases(source: ScorecardSource, repo: string, since: string): Array<{ tag: string; publishedAt: string }> {
const body = source.getJson(`repos/${repo}/releases?per_page=${PAGE}&page=1`);
return (Array.isArray(body) ? body : []).filter(r => !r.draft && typeof r.published_at === 'string' && r.published_at >= since)
.map(r => ({ tag: String(r.tag_name ?? ''), publishedAt: String(r.published_at) }));
/** Releases published inside the window, drafts excluded; every page is read, because GitHub orders releases by creation and a draft can be published long after it was created. */
export function readReleases(source: ScorecardSource, repo: string, w: ScoreWindow): Array<{ tag: string; publishedAt: string }> {
const out: Array<{ tag: string; publishedAt: string }> = [];
for (let page = 1; ; page++) {
if (page > MAX_PAGES) throw pageCap(`releases published since ${w.start}`);
const body = source.getJson(`repos/${repo}/releases?per_page=${PAGE}&page=${page}`);
const releases = Array.isArray(body) ? body : [];
out.push(...releases.filter(r => !r.draft && inWindow(r.published_at, w)).map(r => ({ tag: String(r.tag_name ?? ''), publishedAt: String(r.published_at) })));
if (releases.length < PAGE) break;
}
return out;
}
/** Every read, each guarded: one unreadable input marks its lines unavailable and the rest still print. */
export function collectScorecard(source: ScorecardSource, o: ScorecardOptions): Scorecard {
const since = new Date(o.now - o.sinceDays * 86_400_000).toISOString();
const card: Scorecard = { repo: o.repo, base: o.base, windowStart: since, windowEnd: new Date(o.now).toISOString(),
const w = scoreWindow(o);
const card: Scorecard = { repo: o.repo, base: o.base, windowStart: w.start, windowEnd: w.end,
prs: [], gates: [], freeRuns: [], artifactRunsRead: 0, passCap: o.passCap, artifactErrors: 0, issuesOpened: null, issuesClosed: null, releases: [], unavailable: [] };
const guard = (what: string, fn: () => void) => { try { fn(); } catch (e) { card.unavailable.push(`${what}: ${unavailableReason(e)}`); } };
guard('merged PRs', () => { card.prs = readMergedPrs(source, o, since); });
guard('merged PRs', () => { card.prs = readMergedPrs(source, o, w); });
guard('gate runs', () => {
const prCost = new Map(card.prs.filter(p => p.commentCostUsd !== null).map(p => [p.number, p.commentCostUsd!] as const));
Object.assign(card, readGateRuns(source, o, since, prCost));
Object.assign(card, readGateRuns(source, o, w, prCost));
});
guard('free retries', () => { card.freeRuns = readFreeRetries(source, o, since); });
const instant = since.replace(/\.\d{3}Z$/, 'Z');
guard('free retries', () => { card.freeRuns = readFreeRetries(source, o, w); });
guard('issues', () => {
card.issuesOpened = searchCount(source, `repo:${o.repo} is:issue created:>=${instant}`);
card.issuesClosed = searchCount(source, `repo:${o.repo} is:issue closed:>=${instant}`);
card.issuesOpened = searchCount(source, `repo:${o.repo} is:issue created:${searchRange(w)}`);
card.issuesClosed = searchCount(source, `repo:${o.repo} is:issue closed:${searchRange(w)}`);
});
guard('releases', () => { card.releases = readReleases(source, o.repo, since); });
guard('releases', () => { card.releases = readReleases(source, o.repo, w); });
return card;
}
+1 -1
View File
@@ -1,6 +1,6 @@
{
"name": "gstack",
"version": "1.91.332",
"version": "1.91.333",
"description": "Garry's Stack — Claude Code skills + fast headless browser. One repo, one install, entire AI engineering workflow.",
"license": "MIT",
"type": "module",
+14 -6
View File
@@ -95,21 +95,29 @@ export interface CiJob { id: number; name: string; conclusion: string | null; st
const PAGE = 100;
/** The runs API's `created` filter, by UTC day (both ends inclusive): `>=since`, or `since..until` when the window has an end. */
export function createdFilter(since: string, until?: string): string {
return `created=${encodeURIComponent(until ? `${since.slice(0, 10)}..${until.slice(0, 10)}` : `>=${since.slice(0, 10)}`)}`;
}
/**
* Every completed run of `workflow` created at or after `since`, newest first,
* paging until a page is short or older than `since`. A failure on any page
* throws: a partial history is never reported as a complete one.
* Every completed run of `workflow` created at or after `since` (and before
* `until` when given), newest first, paging until a page is short (the
* `created` filter bounds the range server-side). A run whose created_at does
* not parse is skipped. A failure on any page throws: a partial history is
* never reported as a complete one.
*/
export function listWorkflowRuns(client: GhClient, repo: string, workflow: string,
opts: { since: string; branch?: string; event?: string; maxPages?: number }): CiRun[] {
opts: { since: string; until?: string; branch?: string; event?: string; maxPages?: number }): CiRun[] {
const out: CiRun[] = [];
const query = [`status=completed`, `per_page=${PAGE}`, `created=${encodeURIComponent(`>=${opts.since.slice(0, 10)}`)}`,
const query = [`status=completed`, `per_page=${PAGE}`, createdFilter(opts.since, opts.until),
...(opts.branch ? [`branch=${encodeURIComponent(opts.branch)}`] : []), ...(opts.event ? [`event=${encodeURIComponent(opts.event)}`] : [])];
for (let page = 1; page <= (opts.maxPages ?? 20); page++) {
const body = client.getJson(`repos/${repo}/actions/workflows/${workflow}/runs?${query.join('&')}&page=${page}`) as { workflow_runs?: any[] };
const runs = Array.isArray(body?.workflow_runs) ? body.workflow_runs : [];
for (const run of runs) {
if (String(run.created_at) < opts.since) continue;
const created = Date.parse(String(run.created_at));
if (!(created >= Date.parse(opts.since)) || (opts.until && created >= Date.parse(opts.until))) continue;
if ((opts.branch && run.head_branch !== opts.branch) || (opts.event && run.event !== opts.event)) continue;
out.push({ id: Number(run.id), attempt: Number(run.run_attempt ?? 1), sha: String(run.head_sha ?? ''), branch: String(run.head_branch ?? ''),
event: String(run.event ?? ''), status: String(run.status ?? ''), conclusion: run.conclusion ?? null,
+87 -2
View File
@@ -11,8 +11,8 @@ import * as fs from 'node:fs';
import * as os from 'node:os';
import * as path from 'node:path';
import {
BENCHMARKS, DEFAULTS, classifyGate, collectScorecard, fixtureSource, flakeRecords, fmtHours, gateOutcome, passCount, percentile, readComments, renderCsv,
renderMarkdown, scorecardRows, verdictCost, type FixtureFile, type ScorecardOptions,
BENCHMARKS, DEFAULTS, classifyGate, collectScorecard, fixtureSource, flakeRecords, fmtHours, gateOutcome, inWindow, passCount, percentile, readComments, renderCsv,
renderMarkdown, scorecardRows, searchRange, verdictCost, type FixtureFile, type ScorecardOptions,
} from '../lib/factory-scorecard';
const ROOT = path.resolve(import.meta.dir, '..');
@@ -124,6 +124,82 @@ describe('factory-scorecard: recorded window', () => {
expect(values.merged).toBe('3 (crew 2, outside 1)');
});
test('--now closes the window: a PR, gate run, free run or release at or after now is left out, and the runs and issue queries carry the end', () => {
// Value: protects=every windowed line counts [now - since-days, now) only, so a past window counts nothing that happened after it; fails_when=a read filters on the window start alone or a query drops the end bound; why_new=GST-135: --now 2026-10-15 counted 290 merged PRs (every merge through the 2026-10-30 run) against 65 in the window, and the gate and release lines ignored --now; seam=none; no_claim=GitHub's server-side date filters, which the reads re-check per item
const fx = fixture();
const key = (part: string) => Object.keys(fx.responses).find(k => k.includes(part))!;
(fx.responses[key('/pulls?')] as any[]).unshift({ number: 106, title: 'merged at the window end', user: { login: 'garry' }, author_association: 'OWNER',
created_at: '2026-10-26T10:00:00Z', merged_at: '2026-10-26T12:00:00Z', updated_at: '2026-10-26T13:00:00Z', base: { ref: 'main' } });
fx.responses['repos/acme/widgets/issues/106/comments?per_page=100&page=1'] = [];
const gateRuns = fx.responses[key('/evals.yml/runs?')] as { workflow_runs: any[] };
gateRuns.workflow_runs.unshift({ ...gateRuns.workflow_runs.find(r => r.id === 9002), id: 9007, conclusion: 'success', created_at: '2026-10-26T12:30:00Z' });
const freeRuns = fx.responses[key('/free-tests.yml/runs?')] as { workflow_runs: any[] };
freeRuns.workflow_runs.unshift({ ...freeRuns.workflow_runs[0], id: 8004, created_at: '2026-10-26T13:00:00Z' });
(fx.responses[key('/releases?')] as any[]).unshift({ tag_name: 'v1.2.0', draft: false, prerelease: false, published_at: '2026-10-26T12:00:00Z' });
const source = fixtureSource(fx, scratch());
const card = collectScorecard(source, options());
expect(card.unavailable).toEqual([]);
expect(card.prs.map(p => p.number)).toEqual([101, 103, 102]);
expect(card.gates.map(g => g.id)).toEqual([9004, 9003, 9002, 9001]);
expect(card.freeRuns.map(r => r.id)).toEqual([8003, 8002, 8001]);
expect(card.releases.map(r => r.tag)).toEqual(['v1.0.0']);
const values = byId(scorecardRows(card));
expect(values.merged).toBe('3 (crew 2, outside 1)');
expect(values.lead_time).toBe('median 8.0h, p90 5.0d');
expect(values.gates).toBe('2 / 1 / 1; known cost $43.00 on 2 of 4 runs');
expect(values.releases).toBe('1 (v1.0.0)');
for (const workflow of ['/evals.yml/runs?', '/free-tests.yml/runs?']) {
expect(decodeURIComponent(source.calls.find(c => c.includes(workflow))!)).toContain('created=2026-10-19..2026-10-26');
}
const searches = source.calls.filter(c => c.startsWith('search/issues')).map(decodeURIComponent);
expect(searches).toEqual([
'search/issues?q=repo:acme/widgets is:issue created:2026-10-19T12:00:00Z..2026-10-26T11:59:59Z&per_page=1',
'search/issues?q=repo:acme/widgets is:issue closed:2026-10-19T12:00:00Z..2026-10-26T11:59:59Z&per_page=1',
]);
});
test('a past window pages past newer releases, and a read that runs past the page cap is unavailable instead of partial', () => {
// Value: protects=a past window still finds its releases behind a full page of newer ones, and a PR read that never reaches the window start reports unavailable rather than a short count; fails_when=releases read page 1 only again or the PR loop exits at MAX_PAGES without throwing; why_new=adversarial review of GST-135 found both truncations silent for past windows; seam=none; no_claim=GitHub's ordering of releases beyond newest-first
const fx = fixture();
const releasesKey = (page: number) => `repos/acme/widgets/releases?per_page=100&page=${page}`;
const recorded = fx.responses[releasesKey(1)] as any[];
fx.responses[releasesKey(1)] = Array.from({ length: 100 }, (_, i) => ({ tag_name: `v2.0.${i}`, draft: false, prerelease: false, published_at: '2026-10-27T00:00:00Z' }));
fx.responses[releasesKey(2)] = recorded;
expect(collectScorecard(fixtureSource(fx, scratch()), options()).releases.map(r => r.tag)).toEqual(['v1.0.0']);
const pullsKey = (page: number) => `repos/acme/widgets/pulls?state=closed&base=main&sort=updated&direction=desc&per_page=100&page=${page}`;
const capped = fixture();
for (let page = 1; page <= 20; page++) {
capped.responses[pullsKey(page)] = Array.from({ length: 100 }, (_, i) => ({ number: page * 1000 + i, title: 'closed without merge', user: { login: 'garry' },
author_association: 'OWNER', created_at: '2026-10-25T00:00:00Z', merged_at: null, updated_at: '2026-10-25T00:00:00Z' }));
}
const card = collectScorecard(fixtureSource(capped, scratch()), options());
expect(card.unavailable).toHaveLength(1);
expect(card.unavailable[0]).toStartWith('merged PRs: PRs updated since 2026-10-19T12:00:00.000Z ran past 20 pages of 100');
expect(byId(scorecardRows(card)).merged).toStartWith('unavailable (');
});
test('gate-run and release reads past the page cap are unavailable, and a release published late is found on a later page', () => {
// Value: protects=the gate, cost and red-train lines and the releases line report unavailable instead of a short count, and a draft published inside the window counts wherever it sits in GitHub's creation order; fails_when=either loop exits at MAX_PAGES without throwing, or release paging stops on a page's oldest publication again; why_new=review of GST-135 found only the merged-PR cap tested and the release stop relying on publication order; seam=none; no_claim=GitHub's 1,000-result limit on filtered workflow-run lists
const fx = fixture();
const releasesKey = (page: number) => `repos/acme/widgets/releases?per_page=100&page=${page}`;
fx.responses[releasesKey(1)] = Array.from({ length: 100 }, (_, i) => ({ tag_name: `v0.9.${i}`, draft: false, created_at: '2026-10-01T00:00:00Z', published_at: '2026-10-02T00:00:00Z' }));
fx.responses[releasesKey(2)] = [{ tag_name: 'v0.1.0', draft: false, created_at: '2026-09-01T00:00:00Z', published_at: '2026-10-20T00:00:00Z' }];
expect(collectScorecard(fixtureSource(fx, scratch()), options()).releases.map(r => r.tag)).toEqual(['v0.1.0']);
const capped = fixture();
const runsKey = Object.keys(capped.responses).find(k => k.includes('/evals.yml/runs?'))!.replace(/page=1$/, 'page=');
const run = (capped.responses[runsKey + '1'] as any).workflow_runs[0];
for (let page = 1; page <= 20; page++) {
capped.responses[runsKey + page] = { workflow_runs: Array.from({ length: 100 }, (_, i) => ({ ...run, id: page * 1000 + i })) };
capped.responses[releasesKey(page)] = Array.from({ length: 100 }, (_, i) => ({ tag_name: `v${page}.${i}`, draft: false, published_at: '2026-10-27T00:00:00Z' }));
}
const card = collectScorecard(fixtureSource(capped, scratch()), options());
expect(card.unavailable.some(u => u.startsWith('gate runs: gate runs in the window ran past 20 pages of 100'))).toBe(true);
expect(card.unavailable.some(u => u.startsWith('releases: releases published since 2026-10-19T12:00:00.000Z ran past 20 pages of 100'))).toBe(true);
const values = byId(scorecardRows(card));
expect(values.gates).toStartWith('unavailable (');
expect(values.releases).toStartWith('unavailable (');
});
test('--max-artifact-runs caps artifact reads: later runs count without cost or flake data', () => {
const card = collectScorecard(fixtureSource(fixture(), scratch()), options({ maxArtifactRuns: 2 }));
expect(card.artifactRunsRead).toBe(2);
@@ -266,6 +342,15 @@ describe('factory-scorecard: pieces', () => {
expect(flakeRecords([path.join(dir, 'missing')])).toEqual({ flakes: 0, rerunUsd: 0 });
});
test('the window is end-exclusive at millisecond precision; the issue search range stays inside it at whole seconds', () => {
// Value: protects=a sub-second --now (the default, Date.now()) neither drops an issue at the end second nor counts one before the start; fails_when=either rounding in searchRange flips or inWindow stops rejecting unparsable timestamps; why_new=the --now test uses a whole-second now only; seam=none; no_claim=GitHub search's inclusive-range semantics
const w = { start: '2026-10-19T12:00:00.500Z', end: '2026-10-26T12:00:00.500Z' };
expect(searchRange(w)).toBe('2026-10-19T12:00:01Z..2026-10-26T12:00:00Z');
expect(searchRange({ start: '2026-10-19T12:00:00.000Z', end: '2026-10-26T12:00:00.000Z' })).toBe('2026-10-19T12:00:00Z..2026-10-26T11:59:59Z');
expect(['2026-10-19T12:00:00Z', '2026-10-19T12:00:01Z', '2026-10-26T12:00:00Z', '2026-10-26T12:00:01Z'].map(at => inWindow(at, w))).toEqual([false, true, true, false]);
expect([undefined, null, '', 'soon', Date.parse('2026-10-20T00:00:00Z')].map(at => inWindow(at, w))).toEqual([false, false, false, false, false]);
});
test('percentile is nearest-rank: p50 and p90 of small samples, null when empty', () => {
expect(percentile([], 0.5)).toBeNull();
expect(percentile([4, 8, 120], 0.5)).toBe(8);
+4 -4
View File
@@ -133,7 +133,7 @@
"body": "ship receipt follows\nSHIP_RECEIPT: pr=103 head=bbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbb passes=3"
}
],
"repos/acme/widgets/actions/workflows/evals.yml/runs?status=completed&per_page=100&created=%3E%3D2026-10-19&event=workflow_dispatch&page=1": {
"repos/acme/widgets/actions/workflows/evals.yml/runs?status=completed&per_page=100&created=2026-10-19..2026-10-26&event=workflow_dispatch&page=1": {
"total_count": 6,
"workflow_runs": [
{
@@ -222,7 +222,7 @@
}
]
},
"repos/acme/widgets/actions/workflows/free-tests.yml/runs?status=completed&per_page=100&created=%3E%3D2026-10-19&branch=main&page=1": {
"repos/acme/widgets/actions/workflows/free-tests.yml/runs?status=completed&per_page=100&created=2026-10-19..2026-10-26&branch=main&page=1": {
"total_count": 3,
"workflow_runs": [
{
@@ -289,11 +289,11 @@
"published_at": "2026-10-01T07:00:00Z"
}
],
"search/issues?q=repo%3Aacme%2Fwidgets%20is%3Aissue%20created%3A%3E%3D2026-10-19T12%3A00%3A00Z&per_page=1": {
"search/issues?q=repo%3Aacme%2Fwidgets%20is%3Aissue%20created%3A2026-10-19T12%3A00%3A00Z..2026-10-26T11%3A59%3A59Z&per_page=1": {
"total_count": 5,
"items": []
},
"search/issues?q=repo%3Aacme%2Fwidgets%20is%3Aissue%20closed%3A%3E%3D2026-10-19T12%3A00%3A00Z&per_page=1": {
"search/issues?q=repo%3Aacme%2Fwidgets%20is%3Aissue%20closed%3A2026-10-19T12%3A00%3A00Z..2026-10-26T11%3A59%3A59Z&per_page=1": {
"total_count": 7,
"items": []
}