Public: anyone can read this; the crew can change it.

Estimates are recorded against an issue, start from the forge's own history,… #11964

Merged opened this

Check · Review · Land · Live done — Landed and live in production.

Merged

torque/forge:torque/11955-estimates-are-recorded-against-an-issue → main 12 commits fork 2 threads · 2 unresolved

  • Merged. torque/11955-estimates-are-recorded-against-an-issue was merged into main as d0663ea.

Review

Sign in to review this change.
35 files changed +1668 −4
docs/FLOW.md +4
@@ -161,3 +161,7 @@ toward no file's heat and is named on the feed (`too wide to count: #4840
161161 `src/crew/stale_reds.ts` `sweepStaleReds(app, repo, now, room)` runs each autonomy tick and reads only the database, so a restart or roll loses nothing. An open crew pull whose latest check on its head failed, with no run in 30 minutes and no person holding or assigned to it, is re-checked (reason `sweep`); still red, its builder is queued with the failing test named, within the free slots and 3 a tick. Past its tries it is parked and its issue is ready again. Red for over 24 h and conflicting with main, or with its issue closed, it is closed with one line. The flow report's `stale_reds` line (`src/flow/stale_reds.ts`) counts the ones not yet handled, and the keeper names `stale_reds` as a cause from 3.
162162
163163 How a person sees stale reds: `bun scripts/flow-report.ts` prints the `stale_reds:` line (count and oldest hours), and the keeper names `stale_reds` as the cause when they are what holds the flow; the terminal Flow dialog draws the keeper's line (`keeperLine`), so it shows there when the keeper picks that cause. It has no line of its own on the dialog.
164
165 ## Estimates (#11955)
166
167 Estimates are recorded against an issue, asked for from the forge's own history and scored: see `docs/features/estimates.md`.
docs/MCP.md +1
@@ -98,6 +98,7 @@ bytes in `tools/list`. After changing a tool run `bun scripts/mcp-table.ts`;
9898 | `edit` | — | Retitle or rewrite an issue or pull: yours, or where you write; an agent only its own | 310 |
9999 | `egress_grants` | — | Private destinations and grants: list, grant or revoke (site admin) | 289 |
100100 | `email_verify` | — | Send yourself a new verification link | 126 |
101 | `estimate` | — | Ask an issue's base rate, or record `event` with `p50`, `p90` (instants) and `basis` | 399 |
101102 | `events` | — | Repository events | 259 |
102103 | `explore` | — | The apps, people (and, for an admin, forges) you can see, ranked | 627 |
103104 | `family` | — | The forge that sent this one and, for a site admin, those it sent, with state, version | 205 |
docs/features/estimates.md added +17
@@ -0,0 +1,17 @@
1 # Estimates (#11955)
2
3 An estimate is a promise about when one issue's work happens, kept against the issue and scored. Rows are never edited: a new one supersedes and the old stays as data.
4
5 1. **Ask** the base rate for an issue: MCP `estimate` with `{ repo, issue }`, `GET /api/v1/repos/:owner/:name/issues/:n/estimates`, or in the terminal open the repository, choose **Estimates in a repository** from the palette and press `a` on the issue. It comes from the forge's own history (`src/flow/estimate_base.ts`): merged pulls in the last 30 days of the same repository, size and kind (and builder when assigned), measured from the first agent run on the issue (else its creation) to the pull opening, the merge and the release that carried the merge going live. Quantiles are nearest-rank, `sorted[round((n - 1) * q)]`. Under five samples it says so and widens: builder, kind, size, then 90 days. It adds what history does not know (queue position, runs going, budget left, why a release is held) and your own scorecard. Anyone who may read the issue may ask.
6 2. **Record** one: the same tool with `event` (`pull_opened`, `merged`, `live`), `p50`, `p90` (future instants, p50 ≤ p90) and `basis`; `POST` to the same URL; or `r` on the terminal screen, typing `event p50 p90 basis…` (for example `merged 2026-11-14T07:15:00Z 2026-11-14T11:00:00Z a small change`). Only someone with write access, or a crew member on the repository, records, and only on an open issue.
7 3. **See** it: the issue page and `/next` carry the line ("live by Fri 07:15 (90%: Fri 11:00) · steward · history says Fri 09:40"); `/flow` carries the scorecard.
8
9 ## What the forge does
10
11 Nobody settles an estimate by hand. `src/actions/estimates_settle.ts` is the one place that hears of the events on the bus: a pull that says `Closes #N` opening or merging sets the actual instant of `pull_opened` or `merged`; `release.verified_live` resolves each open `live` estimate at the first release cut after its merge that served. An issue closed as **dropped** or **split**, or moved to **Not now** (`src/actions/next.ts`), voids what it left open: the rows stay, marked void, never scored. Closing as done voids nothing. `live` resolves only for the forge's own releases, not an app's production deploy.
12
13 ## Scores
14
15 `src/flow/estimate_score.ts`: ratio = actual / p50 duration, both from the moment the estimate was made; the median ratio is the bias; the p90 hit rate should be about 90%. Only an estimator's first estimate per issue and event counts, so revising late cannot improve a score. The base rate shown at the time is scored as the estimator `history`. The scorecards read the newest 5,000 estimates in repositories the viewer may read, so a private repository's estimators, sizes and counts show only to those who can open it.
16
17 Tests: `test/estimates.test.ts`.
src/actions/estimates.ts added +198
@@ -0,0 +1,198 @@
1 /**
2 * The `estimate` act (#11955), one tool for two uses: ask the base rate for an
3 * issue, or record an estimate against it. The page, the API, MCP and the
4 * terminal call these and say the same words. Validation is here, at the
5 * boundary; rows and maths are `core/estimates.ts` and `flow/estimate_*`.
6 */
7 import type { App } from "../app.ts";
8 import { addEstimate, currentOf, estimatesOf, estimatesVisibleTo, isEvent, type EstimateEvent, type EstimateRow } from "../core/estimates.ts";
9 import { forgeSource } from "../settings/forge_source.ts";
10 import { getIssue, type Issue } from "../core/issues.ts";
11 import { mergedPullClosing, openPullsClosing } from "../core/pulls.ts";
12 import { nextUp } from "../core/next_up.ts";
13 import { canWrite, type Repo } from "../core/repos.ts";
14 import { listActiveRuns } from "../core/runs.ts";
15 import type { User } from "../core/users.ts";
16 import { budgetLines } from "../crew/forge_budget.ts";
17 import { baseRate, historySamples, type BaseRate } from "../flow/estimate_base.ts";
18 import { kindOf, labelsOf, sizeOf } from "../flow/estimate.ts";
19 import { lastLine } from "../flow/estimate_score.ts";
20 import { estimateLine } from "../flow/estimate_words.ts";
21 import { blocking, standingHolds } from "../keeper/hold.ts";
22 import { err, ok, type Result } from "../kernel/result.ts";
23
24 export const ESTIMATE_FORBIDDEN = "Only someone with write access, or a crew member on this repository, records an estimate.";
25 export const ESTIMATE_NOT_OPEN = "Only an open issue is estimated.";
26 export const ESTIMATE_SHAPE =
27 "`event` is pull_opened, merged or live; `p50` and `p90` are instants like 2026-11-14T07:15:00Z, both in the future with p50 no later than p90; `basis` is one line, at most 300 characters.";
28 export const ESTIMATE_PASSED = "That event has already happened, so it is not estimated.";
29 const BASIS_MAX = 300;
30 const INSTANT = /^\d{4}-\d{2}-\d{2}T\d{2}:\d{2}(?::\d{2}(?:\.\d{1,3})?)?(?:Z|[+-]\d{2}:\d{2})$/;
31
32 export type EstimateError = { kind: "not_found" | "forbidden" | "invalid"; message: string };
33
34 /** What is true now that history does not know. */
35 export interface NowFacts {
36 /** Place on /next, 1 at the top; null when the issue is not on the list. */
37 readonly queuePosition: number | null;
38 readonly listed: number;
39 /** Agent runs going on this repository now. */
40 readonly buildersBusy: number;
41 /** USD left of the forge's day and month budgets. */
42 readonly budgetLeftUsd: { readonly day: number; readonly month: number };
43 /** Why the keeper will not cut a release now; null when nothing holds one. */
44 readonly releasesHeld: string | null;
45 }
46
47 export interface Asked {
48 readonly repo: string;
49 readonly number: number;
50 readonly size: string;
51 readonly kind: string;
52 readonly now: string;
53 readonly base: BaseRate;
54 readonly facts: NowFacts;
55 readonly current: readonly EstimateRow[];
56 readonly scorecard: string;
57 readonly line: string | undefined;
58 }
59
60 function startedAt(app: App, issue: Issue): number | undefined {
61 const row = app.db
62 .query<{ at: string | null }, [string]>("SELECT MIN(created_at) AS at FROM run WHERE issue_id = ?1 AND kind = 'agent'")
63 .get(issue.id);
64 const ms = row?.at == null ? NaN : Date.parse(row.at);
65 return Number.isFinite(ms) ? ms : undefined;
66 }
67
68 /**
69 * The base rate for this issue at `now`: one read of the history, the same answer for the same
70 * history.
71 */
72 export function baseFor(app: App, repo: Repo, issue: Issue, now: Date): BaseRate {
73 const labels = labelsOf(app.db, [issue.id]).get(issue.id) ?? [];
74 const started = startedAt(app, issue);
75 const samples = historySamples(app.db, repo.id, now.getTime() - 90 * 86_400_000, now.getTime());
76 return baseRate(
77 samples,
78 { size: sizeOf(issue), kind: kindOf(labels), builder: issue.assigneeName ?? null, elapsedMs: started === undefined ? 0 : Math.max(0, now.getTime() - started) },
79 `${repo.ownerName}/${repo.name}`,
80 now.getTime(),
81 );
82 }
83
84 function factsOf(app: App, repo: Repo, issue: Issue, now: Date): NowFacts {
85 const rows = nextUp(app.db, repo).rows;
86 const at = rows.findIndex((r) => r.issue.id === issue.id);
87 const [, day, month] = budgetLines(app.db, now);
88 const own = forgeSource(app.db, app.config.forgeRepo);
89 const forge = own !== undefined && own.toLowerCase() === `${repo.ownerName}/${repo.name}`.toLowerCase();
90 return {
91 queuePosition: at < 0 ? null : at + 1,
92 listed: rows.length,
93 buildersBusy: listActiveRuns(app.db, [repo.id]).length,
94 budgetLeftUsd: {
95 day: Math.max(0, Math.round((day!.limitUsd - (day!.spentUsd ?? 0)) * 100) / 100),
96 month: Math.max(0, Math.round((month!.limitUsd - (month!.spentUsd ?? 0)) * 100) / 100),
97 },
98 releasesHeld: forge ? blocking(standingHolds(app.db)) ?? null : null,
99 };
100 }
101
102 /**
103 * The issue as the act sees it: an issue, not a pull. Whether it is open is `recordEstimate`'s
104 * say; asking is for any issue the caller may see.
105 */
106 export function estimateIssue(app: App, repo: Repo, number: number): Issue | undefined {
107 const issue = Number.isInteger(number) && number > 0 ? getIssue(app.db, repo.id, number) : undefined;
108 return issue !== undefined && issue.kind === "issue" ? issue : undefined;
109 }
110
111 /** Ask: the base rate for this issue and what is true now, with the asker's own scorecard. */
112 export function askEstimate(app: App, actor: User | undefined, repo: Repo, issue: Issue, now: Date = new Date()): Asked {
113 const rows = estimatesOf(app.db, issue.id);
114 const labels = labelsOf(app.db, [issue.id]).get(issue.id) ?? [];
115 return {
116 repo: `${repo.ownerName}/${repo.name}`,
117 number: issue.number,
118 size: sizeOf(issue),
119 kind: kindOf(labels),
120 now: now.toISOString(),
121 base: baseFor(app, repo, issue, now),
122 facts: factsOf(app, repo, issue, now),
123 current: currentOf(rows),
124 scorecard: actor === undefined ? "" : lastLine(estimatesVisibleTo(app.db, actor, actor.id), actor.username, true),
125 line: estimateLine(currentOf(rows), now.getTime()),
126 };
127 }
128
129 export interface EstimateInput {
130 readonly event?: unknown;
131 readonly p50?: unknown;
132 readonly p90?: unknown;
133 readonly basis?: unknown;
134 }
135
136 const instant = (v: unknown): number | undefined => {
137 if (typeof v !== "string" || !INSTANT.test(v)) return undefined;
138 const ms = Date.parse(v);
139 return Number.isFinite(ms) ? ms : undefined;
140 };
141
142 /**
143 * Record: validated, stamped with who and when, and the base rate shown at that moment. A new one
144 * supersedes; the old rows stay.
145 */
146 export function recordEstimate(
147 app: App,
148 actor: User,
149 repo: Repo,
150 issue: Issue,
151 input: EstimateInput,
152 now: Date = new Date(),
153 ): Result<{ estimate: EstimateRow; base: BaseRate; scorecard: string }, EstimateError> {
154 const invalid = (message: string) => err<EstimateError>({ kind: "invalid", message });
155 if (!canWrite(app.db, repo, actor)) return err({ kind: "forbidden", message: ESTIMATE_FORBIDDEN });
156 if (issue.state !== "open") return invalid(ESTIMATE_NOT_OPEN);
157 if (!isEvent(input.event)) return invalid(ESTIMATE_SHAPE);
158 const event: EstimateEvent = input.event;
159 const p50 = instant(input.p50);
160 const p90 = instant(input.p90);
161 if (p50 === undefined || p90 === undefined) return invalid(ESTIMATE_SHAPE);
162 if (p50 <= now.getTime() || p90 <= now.getTime()) return invalid("Both times are in the future: " + ESTIMATE_SHAPE);
163 if (p50 > p90) return invalid("p50 is no later than p90.");
164 const basis = input.basis === undefined ? "" : input.basis;
165 // eslint-disable-next-line no-control-regex
166 if (typeof basis !== "string" || basis.length > BASIS_MAX || /[\u0000-\u001f\u007f]/.test(basis)) return invalid(ESTIMATE_SHAPE);
167 const happened =
168 event === "pull_opened"
169 ? openPullsClosing(app.db, repo.id).has(issue.number) || mergedPullClosing(app.db, repo.id, issue.number) !== undefined
170 : event === "merged"
171 ? mergedPullClosing(app.db, repo.id, issue.number) !== undefined
172 : false;
173 if (happened) return invalid(ESTIMATE_PASSED);
174 const base = baseFor(app, repo, issue, now);
175 const labels = labelsOf(app.db, [issue.id]).get(issue.id) ?? [];
176 const id = addEstimate(app.db, {
177 repoId: repo.id,
178 issueId: issue.id,
179 event,
180 p50At: new Date(p50).toISOString(),
181 p90At: new Date(p90).toISOString(),
182 madeAt: now.toISOString(),
183 byId: actor.id,
184 byKind: actor.kind === "agent" ? "agent" : "person",
185 basis: basis.trim(),
186 baseJson: JSON.stringify(base),
187 size: sizeOf(issue),
188 kind: kindOf(labels),
189 });
190 app.bus.append({
191 kind: "issue.estimated",
192 repoId: repo.id,
193 actorId: actor.id,
194 payload: { number: issue.number, event, estimate: id },
195 });
196 const estimate = estimatesOf(app.db, issue.id).find((e) => e.id === id)!;
197 return ok({ estimate, base, scorecard: lastLine(estimatesVisibleTo(app.db, actor, actor.id), actor.username, true) });
198 }
src/actions/estimates_settle.ts added +75
@@ -0,0 +1,75 @@
1 /**
2 * Where an estimate is settled (#11955), the one place: the forge hears of the event on the bus and
3 * the actual instant is set, or what an abandoned issue promised is voided. Nothing else in the
4 * core, the pulls or the keeper knows estimates exist. Only reads and writes `issue_estimate`,
5 * `core/estimates.ts` does the rows.
6 *
7 * - `pull.opened`: the issues the pull's description closes had a pull opened (its creation time).
8 * - `pull.merged`: the same issues merged (the pull's merge time).
9 * - `release.verified_live`: each open `live` estimate resolves at the first release, cut after
10 * its merge, that served.
11 * - `issue.closed` as dropped or split: what it left open is void. (Moving to Not now voids in
12 * `actions/next.ts`, the act that does it.)
13 */
14 import type { Database } from "bun:sqlite";
15 import { settleEvent, settleLive, voidOpen, type ServedRelease } from "../core/estimates.ts";
16 import type { ForgeEvent } from "../core/events.ts";
17 import { getIssue } from "../core/issues.ts";
18 import { closesFromBody, mergedPullClosing } from "../core/pulls.ts";
19
20 /** The most releases one settling reads: the newest that served. */
21 const RELEASES_MAX = 200;
22
23 const numberOf = (e: ForgeEvent): number | undefined => {
24 const n = e.payload["number"];
25 return typeof n === "number" && Number.isInteger(n) ? n : undefined;
26 };
27
28 /** The pull's description and its two instants. */
29 function pullFacts(db: Database, repoId: string, number: number): { body: string; openedAt: string; mergedAt: string | null } | undefined {
30 const row = db
31 .query<{ body: string; created_at: string; merged_at: string | null }, [string, number]>(
32 `SELECT i.body, i.created_at, p.merged_at FROM issue i JOIN pull p ON p.issue_id = i.id WHERE i.repo_id = ?1 AND i.number = ?2`,
33 )
34 .get(repoId, number);
35 return row === null ? undefined : { body: row.body, openedAt: row.created_at, mergedAt: row.merged_at };
36 }
37
38 /** Releases that served and were not rolled back, oldest first. */
39 function servedReleases(db: Database): ServedRelease[] {
40 return db
41 .query<{ cut_at: string; live_at: string }, [number]>(
42 `SELECT cut_at, live_at FROM keeper_release WHERE live_at IS NOT NULL AND stage != 'rolled_back' ORDER BY cut_at DESC LIMIT ?1`,
43 )
44 .all(RELEASES_MAX)
45 .reverse()
46 .map((r) => ({ cutAt: r.cut_at, liveAt: r.live_at }));
47 }
48
49 /** Settle what this event settles. Subscribed once, in `app.ts`. */
50 export function settleEstimates(db: Database, e: ForgeEvent): void {
51 if (e.repoId === null) return;
52 const number = numberOf(e);
53 switch (e.kind) {
54 case "pull.opened": {
55 const pull = number === undefined ? undefined : pullFacts(db, e.repoId, number);
56 if (pull !== undefined) settleEvent(db, e.repoId, closesFromBody(pull.body), "pull_opened", pull.openedAt);
57 return;
58 }
59 case "pull.merged": {
60 const pull = number === undefined ? undefined : pullFacts(db, e.repoId, number);
61 if (pull !== undefined) settleEvent(db, e.repoId, closesFromBody(pull.body), "merged", pull.mergedAt ?? e.createdAt);
62 return;
63 }
64 case "issue.closed": {
65 const reason = e.payload["reason"];
66 const issue = number === undefined ? undefined : getIssue(db, e.repoId, number);
67 if (issue !== undefined && (reason === "dropped" || reason === "split")) voidOpen(db, issue.id, e.createdAt);
68 return;
69 }
70 case "release.verified_live":
71 settleLive(db, servedReleases(db), (repoId, n) => mergedPullClosing(db, repoId, n)?.mergedAt ?? undefined);
72 return;
73 }
74 }
75
src/actions/next.ts +3
@@ -6,6 +6,7 @@
66 */
77 import type { App } from "../app.ts";
88 import { addDependency, repoGraph } from "../core/dependencies.ts";
9 import { voidOpen } from "../core/estimates.ts";
910 import { getIssue, type Issue } from "../core/issues.ts";
1011 import { isStructural, pillarsOf } from "../core/pillars.ts";
1112 import { clearRank, lift, MAX_RANKED, ranksInRepo, writeOrder } from "../core/rank.ts";
@@ -79,6 +80,8 @@ export function rankIssue(
7980 const linked = addDependency(app.db, issue, set.gate!, actor.id);
8081 if (!linked.ok) return invalid(NEXT_INVALID);
8182 clearRank(app.db, issue.id);
83 // Not now: what it promised is void, not scored (#11955).
84 voidOpen(app.db, issue.id);
8285 return ok(undefined);
8386 }
8487 lift(app.db, repo, issue.id);
src/app.ts +4
@@ -4,6 +4,7 @@
44 * side-effect-contained, so tests boot real forges by the dozen.
55 */
66
7 import { settleEstimates } from "./actions/estimates_settle.ts";
78 import { ownHost, setOwnHost } from "./core/trusted_sources.ts";
89 import { commitIdentity } from "./core/commit_identity.ts";
910 import { getUserById } from "./core/users.ts";
@@ -258,6 +259,9 @@ export function createApp(config: ForgeConfig): App {
258259 // sends them. Listener throws are swallowed by the bus, so this can never
259260 // break the action that produced the event.
260261 bus.subscribe((event) => enqueueForEvent(db, event));
262 // A pull opened or merged, a release served, an issue dropped or split:
263 // estimates settle (#11955).
264 bus.subscribe((event) => settleEstimates(db, event));
261265 // A push, a merge, a repository change or a build event may move pins: the kept listings go (`git/pins_kept.ts`).
262266 bus.subscribe((event) => {
263267 const k = event.kind;
src/contract/openapi/routes/getApiV1ReposOwnerNameIssuesNumberEstimates.ts added +9
@@ -0,0 +1,9 @@
1 import { REPO, type Route } from "../shared.ts";
2
3 export const route: Route = {
4 method: "get",
5 path: `${REPO}/issues/{number}/estimates`,
6 summary: "Read an issue's base rate from the forge's history (p50/p90 per event), the estimates recorded against it and the caller's scorecard",
7 auth: true,
8 role: "member",
9 };
src/contract/openapi/routes/index.ts +2
@@ -159,6 +159,7 @@ export { route as getApiV1ReposOwnerNameIssues } from "./getApiV1ReposOwnerNameI
159159 export { route as getApiV1ReposOwnerNameIssuesNumber } from "./getApiV1ReposOwnerNameIssuesNumber.ts";
160160 export { route as getApiV1ReposOwnerNameIssuesNumberComments } from "./getApiV1ReposOwnerNameIssuesNumberComments.ts";
161161 export { route as getApiV1ReposOwnerNameIssuesNumberDependencies } from "./getApiV1ReposOwnerNameIssuesNumberDependencies.ts";
162 export { route as getApiV1ReposOwnerNameIssuesNumberEstimates } from "./getApiV1ReposOwnerNameIssuesNumberEstimates.ts";
162163 export { route as getApiV1ReposOwnerNameIssuesNumberPlan } from "./getApiV1ReposOwnerNameIssuesNumberPlan.ts";
163164 export { route as getApiV1ReposOwnerNameIssuesNumberTeams } from "./getApiV1ReposOwnerNameIssuesNumberTeams.ts";
164165 export { route as getApiV1ReposOwnerNameIterations } from "./getApiV1ReposOwnerNameIterations.ts";
@@ -346,6 +347,7 @@ export { route as postApiV1ReposOwnerNameIssues } from "./postApiV1ReposOwnerNam
346347 export { route as postApiV1ReposOwnerNameIssuesNumberComments } from "./postApiV1ReposOwnerNameIssuesNumberComments.ts";
347348 export { route as postApiV1ReposOwnerNameIssuesNumberDependencies } from "./postApiV1ReposOwnerNameIssuesNumberDependencies.ts";
348349 export { route as postApiV1ReposOwnerNameIssuesNumberDependenciesBlockerConfirm } from "./postApiV1ReposOwnerNameIssuesNumberDependenciesBlockerConfirm.ts";
350 export { route as postApiV1ReposOwnerNameIssuesNumberEstimates } from "./postApiV1ReposOwnerNameIssuesNumberEstimates.ts";
349351 export { route as postApiV1ReposOwnerNameIssuesNumberPrivate } from "./postApiV1ReposOwnerNameIssuesNumberPrivate.ts";
350352 export { route as postApiV1ReposOwnerNameJobsIdAction } from "./postApiV1ReposOwnerNameJobsIdAction.ts";
351353 export { route as postApiV1ReposOwnerNameLabels } from "./postApiV1ReposOwnerNameLabels.ts";
src/contract/openapi/routes/postApiV1ReposOwnerNameIssuesNumberEstimates.ts added +10
@@ -0,0 +1,10 @@
1 import { REPO, type Route } from "../shared.ts";
2
3 export const route: Route = {
4 method: "post",
5 path: `${REPO}/issues/{number}/estimates`,
6 summary: "Record an estimate against an issue: event (pull_opened, merged, live), p50 and p90 instants, a one-line basis; the forge resolves and scores it",
7 auth: true,
8 role: "member",
9 body: { event: "string", p50: "string", p90: "string", basis: "string" },
10 };
src/core/estimates.ts added +226
@@ -0,0 +1,226 @@
1 /**
2 * Estimates against an issue (#11955): rows kept, never edited. The forge sets
3 * the actual instant when the event happens (`settleEvent`, `settleLive`) and
4 * voids what an abandoned issue leaves open (`voidOpen`); nobody does it by hand.
5 * This module reads no pulls and no releases: `actions/estimates_settle.ts` is the one place
6 * that hears of the events and calls these.
7 * Plain data in and out; the base rate and the scores are `flow/`.
8 */
9 import type { Database } from "bun:sqlite";
10 import type { User } from "./users.ts";
11 import { repoVisible } from "./visibility.ts";
12
13 export const EVENTS = ["pull_opened", "merged", "live"] as const;
14 export type EstimateEvent = (typeof EVENTS)[number];
15 export const isEvent = (v: unknown): v is EstimateEvent => typeof v === "string" && (EVENTS as readonly string[]).includes(v);
16
17 export type EstimateState = "open" | "resolved" | "void";
18
19 export interface EstimateRow {
20 readonly id: number;
21 readonly repoId: string;
22 readonly issueId: string;
23 readonly issueNumber: number;
24 readonly event: EstimateEvent;
25 readonly p50At: string;
26 readonly p90At: string;
27 readonly madeAt: string;
28 readonly byId: string;
29 readonly byName: string;
30 readonly byKind: "person" | "agent";
31 readonly basis: string;
32 /** The base rate shown when it was made: the raw JSON; `flow/estimate_base.ts` reads it. */
33 readonly baseJson: string;
34 readonly size: string;
35 readonly kind: string;
36 readonly actualAt: string | null;
37 readonly voidedAt: string | null;
38 readonly state: EstimateState;
39 }
40
41 interface Raw {
42 id: number;
43 repo_id: string;
44 issue_id: string;
45 number: number;
46 event: EstimateEvent;
47 p50_at: string;
48 p90_at: string;
49 made_at: string;
50 by_id: string;
51 username: string;
52 by_kind: "person" | "agent";
53 basis: string;
54 base_json: string;
55 size: string;
56 kind: string;
57 actual_at: string | null;
58 voided_at: string | null;
59 }
60
61 const SELECT = `SELECT e.id, e.repo_id, e.issue_id, i.number, e.event, e.p50_at, e.p90_at, e.made_at, e.by_id,
62 u.username, e.by_kind, e.basis, e.base_json, e.size, e.kind, e.actual_at, e.voided_at
63 FROM issue_estimate e JOIN issue i ON i.id = e.issue_id JOIN user u ON u.id = e.by_id`;
64
65 const toRow = (r: Raw): EstimateRow => ({
66 id: r.id,
67 repoId: r.repo_id,
68 issueId: r.issue_id,
69 issueNumber: r.number,
70 event: r.event,
71 p50At: r.p50_at,
72 p90At: r.p90_at,
73 madeAt: r.made_at,
74 byId: r.by_id,
75 byName: r.username,
76 byKind: r.by_kind,
77 basis: r.basis,
78 baseJson: r.base_json,
79 size: r.size,
80 kind: r.kind,
81 actualAt: r.actual_at,
82 voidedAt: r.voided_at,
83 state: r.voided_at !== null ? "void" : r.actual_at !== null ? "resolved" : "open",
84 });
85
86 export interface NewEstimate {
87 readonly repoId: string;
88 readonly issueId: string;
89 readonly event: EstimateEvent;
90 readonly p50At: string;
91 readonly p90At: string;
92 readonly madeAt: string;
93 readonly byId: string;
94 readonly byKind: "person" | "agent";
95 readonly basis: string;
96 readonly baseJson: string;
97 readonly size: string;
98 readonly kind: string;
99 }
100
101 /**
102 * Keep one estimate. Rows are only added: the older one for the same event is superseded by being
103 * older.
104 */
105 export function addEstimate(db: Database, e: NewEstimate): number {
106 return Number(
107 db
108 .query(
109 `INSERT INTO issue_estimate (repo_id, issue_id, event, p50_at, p90_at, made_at, by_id, by_kind, basis, base_json, size, kind)
110 VALUES (?1, ?2, ?3, ?4, ?5, ?6, ?7, ?8, ?9, ?10, ?11, ?12)`,
111 )
112 .run(e.repoId, e.issueId, e.event, e.p50At, e.p90At, e.madeAt, e.byId, e.byKind, e.basis, e.baseJson, e.size, e.kind)
113 .lastInsertRowid,
114 );
115 }
116
117 /** Every estimate on an issue, oldest first: the revisions are data. */
118 export function estimatesOf(db: Database, issueId: string): EstimateRow[] {
119 return db.query<Raw, [string]>(`${SELECT} WHERE e.issue_id = ?1 ORDER BY e.id`).all(issueId).map(toRow);
120 }
121
122 /** What each event stands at now: the newest estimate per event (events in their order). */
123 export function currentOf(rows: readonly EstimateRow[]): EstimateRow[] {
124 const latest = new Map<EstimateEvent, EstimateRow>();
125 for (const r of rows) latest.set(r.event, r);
126 return EVENTS.flatMap((ev) => latest.get(ev) ?? []);
127 }
128
129 /** The most issue numbers one list read names. */
130 export const NUMBERS_MAX = 500;
131 /** The most rows a scorecard reads: the newest. */
132 export const SCORE_ROWS = 5000;
133
134 /**
135 * The newest estimate per event for each issue number of a repository, for a list's rows (one
136 * read of those issues' rows only).
137 */
138 export function currentByNumber(db: Database, repoId: string, numbers: readonly number[]): Map<number, EstimateRow[]> {
139 const out = new Map<number, EstimateRow[]>();
140 if (numbers.length === 0) return out;
141 const byIssue = new Map<number, EstimateRow[]>();
142 for (const r of db
143 .query<Raw, [string, string]>(
144 `${SELECT} WHERE e.repo_id = ?1 AND i.number IN (SELECT value FROM json_each(?2)) ORDER BY e.id`,
145 )
146 .all(repoId, JSON.stringify(numbers.slice(0, NUMBERS_MAX)))
147 .map(toRow))
148 byIssue.set(r.issueNumber, [...(byIssue.get(r.issueNumber) ?? []), r]);
149 for (const [n, rows] of byIssue) out.set(n, currentOf(rows));
150 return out;
151 }
152
153 /**
154 * The newest {@link SCORE_ROWS} estimates in repositories `viewer` may read (a signed-out viewer:
155 * the public ones), oldest first, for the scorecards; `byId` keeps one estimator's.
156 */
157 export function estimatesVisibleTo(db: Database, viewer: User | undefined, byId?: string): EstimateRow[] {
158 const rows = db
159 .query<Raw, [string, string, number]>(
160 `${SELECT} JOIN repo ON repo.id = e.repo_id
161 WHERE ${repoVisible(viewer !== undefined)} AND (?2 = '' OR e.by_id = ?2)
162 ORDER BY e.id DESC LIMIT ?3`,
163 )
164 .all(viewer?.id ?? "", byId ?? "", SCORE_ROWS);
165 return rows.reverse().map(toRow);
166 }
167
168 /**
169 * The event happened for these issue numbers: their open estimates for it get the actual instant.
170 * Returns how many.
171 */
172 export function settleEvent(db: Database, repoId: string, numbers: readonly number[], event: EstimateEvent, at: string): number {
173 let n = 0;
174 for (const number of numbers)
175 n += db
176 .query(
177 `UPDATE issue_estimate SET actual_at = ?4
178 WHERE event = ?3 AND actual_at IS NULL AND voided_at IS NULL
179 AND issue_id = (SELECT id FROM issue WHERE repo_id = ?1 AND number = ?2)`,
180 )
181 .run(repoId, number, event, at).changes;
182 return n;
183 }
184
185 /** A release that served: when it was cut and when it went live. */
186 export interface ServedRelease {
187 readonly cutAt: string;
188 readonly liveAt: string;
189 }
190
191 /**
192 * Releases went live: every open `live` estimate whose issue merged at or before a release was cut
193 * resolves at the first such release's `liveAt`. `mergedAt` says when the pull that closed an issue
194 * merged (the caller reads pulls; this module reads none). `releases` are oldest first.
195 */
196 export function settleLive(
197 db: Database,
198 releases: readonly ServedRelease[],
199 mergedAt: (repoId: string, number: number) => string | undefined,
200 ): number {
201 if (releases.length === 0) return 0;
202 const open = db
203 .query<{ id: number; repo_id: string; number: number }, []>(
204 `SELECT e.id, e.repo_id, i.number FROM issue_estimate e JOIN issue i ON i.id = e.issue_id
205 WHERE e.event = 'live' AND e.actual_at IS NULL AND e.voided_at IS NULL`,
206 )
207 .all();
208 let n = 0;
209 for (const r of open) {
210 const merged = mergedAt(r.repo_id, r.number);
211 const release = merged === undefined ? undefined : releases.find((x) => x.cutAt >= merged);
212 if (release === undefined) continue;
213 n += db.query(`UPDATE issue_estimate SET actual_at = ?2 WHERE id = ?1 AND actual_at IS NULL AND voided_at IS NULL`).run(r.id, release.liveAt).changes;
214 }
215 return n;
216 }
217
218 /**
219 * An issue closed as dropped or split, or moved to Not now: what it left open is kept, marked
220 * void, never scored.
221 */
222 export function voidOpen(db: Database, issueId: string, at: string = new Date().toISOString()): number {
223 return db
224 .query(`UPDATE issue_estimate SET voided_at = ?2 WHERE issue_id = ?1 AND actual_at IS NULL AND voided_at IS NULL`)
225 .run(issueId, at).changes;
226 }
src/core/event_audience.ts +1
@@ -56,6 +56,7 @@ const READER_KINDS: ReadonlySet<string> = new Set([
5656 // the admins' (`repo.upstream_follow`).
5757 "repo.following",
5858 "issue.labeled",
59 "issue.estimated",
5960 "issue.first",
6061 "issue.ranked",
6162 "week.retro",
src/core/issues.ts +6 −1
@@ -66,6 +66,8 @@ export interface Issue {
6666 * (`core/sealed.ts`).
6767 */
6868 readonly sealed: boolean;
69 /** Whether any estimate was ever recorded against it (#11955); none, no read. */
70 readonly estimated: boolean;
6971 }
7072
7173 interface IssueRow {
@@ -90,11 +92,13 @@ interface IssueRow {
9092 assignee_is_agent: number | null;
9193 author_is_agent: number;
9294 sealed: number;
95 estimated: number;
9396 }
9497
9598 const SELECT = `
9699 SELECT issue.*, user.username AS author_name, user.is_agent AS author_is_agent,
97 assignee.username AS assignee_name, assignee.is_agent AS assignee_is_agent
100 assignee.username AS assignee_name, assignee.is_agent AS assignee_is_agent,
101 EXISTS (SELECT 1 FROM issue_estimate WHERE issue_estimate.issue_id = issue.id) AS estimated
98102 FROM issue JOIN user ON user.id = issue.author_id
99103 LEFT JOIN user AS assignee ON assignee.id = issue.assignee_id`;
100104
@@ -128,6 +132,7 @@ function toIssue(row: IssueRow): Issue {
128132 assigneeIsAgent: row.assignee_is_agent === 1,
129133 authorIsAgent: row.author_is_agent === 1,
130134 sealed: row.sealed === 1,
135 estimated: row.estimated === 1,
131136 };
132137 }
133138
src/core/migration-pins/20261113200000-issue-estimates.pin added +1
@@ -0,0 +1,1 @@
1 dfe23c0041426dfe2c241a8ef21756e483583eaffd04ccd8e3cfa04e2afa21db
src/core/migrations/20261113200000-issue-estimates.ts added +27
@@ -0,0 +1,27 @@
1 // Estimates of when an issue's work happens (#11955): one row per estimate, never edited.
2 // A new estimate for the same issue and event supersedes the old one on the page; the old
3 // rows stay as data. The forge alone sets `actual_at` (the event happened) or `voided_at`
4 // (the issue closed as dropped or split, or moved to Not now). `base_json` is the base rate shown
5 // when the estimate was made, so the history itself can be scored. Additive: one table.
6 export default `
7 CREATE TABLE issue_estimate (
8 id INTEGER PRIMARY KEY AUTOINCREMENT,
9 repo_id TEXT NOT NULL REFERENCES repo(id) ON DELETE CASCADE,
10 issue_id TEXT NOT NULL REFERENCES issue(id) ON DELETE CASCADE,
11 event TEXT NOT NULL CHECK (event IN ('pull_opened','merged','live')),
12 p50_at TEXT NOT NULL,
13 p90_at TEXT NOT NULL,
14 made_at TEXT NOT NULL,
15 by_id TEXT NOT NULL REFERENCES user(id),
16 by_kind TEXT NOT NULL CHECK (by_kind IN ('person','agent')),
17 basis TEXT NOT NULL DEFAULT '',
18 base_json TEXT NOT NULL DEFAULT 'null',
19 size TEXT NOT NULL,
20 kind TEXT NOT NULL DEFAULT '',
21 actual_at TEXT,
22 voided_at TEXT
23 );
24 CREATE INDEX issue_estimate_issue ON issue_estimate(issue_id, event, id);
25 CREATE INDEX issue_estimate_open ON issue_estimate(event, repo_id) WHERE actual_at IS NULL AND voided_at IS NULL;
26 CREATE INDEX issue_estimate_repo ON issue_estimate(repo_id, made_at);
27 `;
src/crew/brief.ts +8
@@ -369,6 +369,13 @@ export function taskPrompt(input: TaskInput): string {
369369 const MCP_SIZE_RULE =
370370 "This issue touches the MCP. Run `bun test test/mcp-size.test.ts` before pushing; it names the five costliest tools when it fails. Prefer an `action` or `kind` on an existing tool (docs/MCP.md, \"Room for a new tool\") over a new tool, and never raise the ceilings.";
371371
372 /**
373 * A time promised to the owner is a record first (#11955); the task prompt carries it,
374 * not the system brief, to keep that short.
375 */
376 export const ESTIMATE_RULE =
377 'A "by <time>" you say to the owner is recorded first with the `estimate` tool (ask it for the issue\'s base rate, then record `event` pull_opened, merged or live with `p50` and `p90` instants and a one-line `basis`), or it is not said. The forge resolves it when the event happens and scores it.';
378
372379 /** A plan on the issue is how a person sees where a run is without reading its log; only the forge serves the tool. */
373380 const PLAN_RULE =
374381 "Keep a short plan on this issue with the `keep_plan` tool (up to 12 items, replaced whole each time): call it once you have read the code and again as items finish, so a person sees where you are without a log.";
@@ -475,6 +482,7 @@ function taskBody(input: TaskInput): string {
475482 if (input.pull === undefined && isForge(input.repo) && namesMcp(issue.title, issue.body))
476483 out.push("", MCP_SIZE_RULE);
477484 if (input.pull === undefined && isForge(input.repo)) out.push("", PLAN_RULE);
485 if (input.pull === undefined && isForge(input.repo)) out.push("", ESTIMATE_RULE);
478486 out.push(
479487 "",
480488 `Work it on the branch \`${input.branch}\` and push when done.`,
src/flow/estimate_base.ts added +179
@@ -0,0 +1,179 @@
1 /**
2 * The base rate for an issue (#11955): how long past issues like it took from the
3 * start of work to each event, from the forge's own history. Measured, never
4 * guessed; the same history gives the same answer. Plain data in and out;
5 * `historySamples` is the one read.
6 *
7 * Start of work is the first agent run on the issue, else its creation. Time
8 * waiting in the queue is therefore not in the rates: it is said beside them
9 * (`flow/estimate_now.ts`). Quantiles are nearest-rank over the sorted
10 * durations, `sorted[round((n - 1) * q)]`, so a test can do it by hand.
11 */
12 import type { Database } from "bun:sqlite";
13 import type { EstimateEvent } from "../core/estimates.ts";
14 import { closesFromBody } from "../core/pulls.ts";
15 import { kindOf, labelsOf, sizeOf, type Size } from "./estimate.ts";
16 import { durationWords } from "./estimate_words.ts";
17
18 /** Samples a group needs before it speaks plainly. */
19 export const MIN_BASE = 5;
20 const DAY = 86_400_000;
21 /** The most merged pulls one read walks. */
22 const PULLS_MAX = 600;
23
24 export interface HistorySample {
25 readonly number: number;
26 readonly size: Size;
27 readonly kind: string;
28 readonly builder: string | null;
29 /**
30 * Milliseconds from the start of work to each event; live is absent when no release since the
31 * merge has served.
32 */
33 readonly pull: number;
34 readonly merged: number;
35 readonly live: number | undefined;
36 readonly mergedAtMs: number;
37 }
38
39 export interface EventRate {
40 readonly n: number;
41 /** From now, in ms. */
42 readonly p50Ms: number;
43 readonly p90Ms: number;
44 readonly p50At: string;
45 readonly p90At: string;
46 }
47
48 export interface BaseRate {
49 readonly n: number;
50 /** True when fewer than {@link MIN_BASE} samples were found even after widening. */
51 readonly thin: boolean;
52 /** What was widened, in order, e.g. ["dropped the builder", "dropped the kind"]. */
53 readonly widened: readonly string[];
54 readonly basis: string;
55 readonly events: Record<EstimateEvent, EventRate | null>;
56 }
57
58 /** Nearest-rank quantile of an ascending array. */
59 export const quantile = (sorted: readonly number[], q: number): number => sorted[Math.round((sorted.length - 1) * q)]!;
60 const ascending = (xs: readonly number[]): number[] => [...xs].sort((a, b) => a - b);
61
62 /**
63 * Past issues finished in the window: merged pulls that closed them, with when work started and
64 * when each release went live.
65 */
66 export function historySamples(db: Database, repoId: string, sinceMs: number, nowMs: number): HistorySample[] {
67 const since = new Date(sinceMs).toISOString();
68 const pulls = db
69 .query<{ body: string; opened: string; merged: string }, [string, string, number]>(
70 `SELECT i.body, i.created_at AS opened, p.merged_at AS merged FROM pull p JOIN issue i ON i.id = p.issue_id
71 WHERE i.repo_id = ?1 AND p.merged_at IS NOT NULL AND p.merged_at >= ?2 ORDER BY p.merged_at DESC LIMIT ?3`,
72 )
73 .all(repoId, since, PULLS_MAX);
74 const closed = new Map<number, { opened: string; merged: string }>();
75 for (const p of pulls) for (const n of closesFromBody(p.body)) if (!closed.has(n)) closed.set(n, p);
76 if (closed.size === 0) return [];
77 const numbers = JSON.stringify([...closed.keys()].slice(0, PULLS_MAX * 4));
78 const issues = db
79 .query<{ id: string; number: number; title: string; body: string; created_at: string; assignee: string | null; started: string | null }, [string, string]>(
80 `SELECT i.id, i.number, i.title, i.body, i.created_at, a.username AS assignee,
81 (SELECT MIN(r.created_at) FROM run r WHERE r.issue_id = i.id AND r.kind = 'agent') AS started
82 FROM issue i LEFT JOIN user a ON a.id = i.assignee_id
83 WHERE i.repo_id = ?1 AND i.kind = 'issue' AND i.state = 'closed' AND i.close_reason = 'done'
84 AND i.number IN (SELECT value FROM json_each(?2))`,
85 )
86 .all(repoId, numbers)
87 .filter((i) => closed.has(i.number));
88 const labels = labelsOf(db, issues.map((i) => i.id));
89 const releases = db
90 .query<{ cut_at: string; live_at: string }, [string]>(
91 `SELECT cut_at, live_at FROM keeper_release WHERE live_at IS NOT NULL AND stage != 'rolled_back' AND cut_at >= ?1 ORDER BY cut_at`,
92 )
93 .all(since);
94 const out: HistorySample[] = [];
95 for (const i of issues) {
96 const c = closed.get(i.number)!;
97 const start = Date.parse(i.started ?? i.created_at);
98 const merged = Date.parse(c.merged);
99 const opened = Date.parse(c.opened);
100 if (![start, merged, opened].every(Number.isFinite) || merged < start || merged > nowMs) continue;
101 const release = releases.find((r) => r.cut_at >= c.merged);
102 const live = release === undefined ? undefined : Date.parse(release.live_at);
103 out.push({
104 number: i.number,
105 size: sizeOf(i),
106 kind: kindOf(labels.get(i.id) ?? []),
107 builder: i.assignee,
108 pull: Math.max(0, Math.min(opened, merged) - start),
109 merged: merged - start,
110 live: live === undefined || !Number.isFinite(live) || live < merged ? undefined : live - start,
111 mergedAtMs: merged,
112 });
113 }
114 return out;
115 }
116
117 export interface Target {
118 readonly size: Size;
119 readonly kind: string;
120 readonly builder: string | null;
121 /** Milliseconds since work started on it; 0 when it has not. */
122 readonly elapsedMs: number;
123 }
124
125 /**
126 * From now: those still running at `elapsed` count what is left; if fewer than {@link MIN_BASE}
127 * still run, all count in full.
128 */
129 function fromNow(durations: readonly number[], elapsedMs: number, nowMs: number): EventRate | null {
130 if (durations.length === 0) return null;
131 const running = durations.filter((d) => d > elapsedMs).map((d) => d - elapsedMs);
132 const left = ascending(elapsedMs > 0 && running.length >= MIN_BASE ? running : durations);
133 const p50Ms = quantile(left, 0.5);
134 const p90Ms = quantile(left, 0.9);
135 return { n: durations.length, p50Ms, p90Ms, p50At: new Date(nowMs + p50Ms).toISOString(), p90At: new Date(nowMs + p90Ms).toISOString() };
136 }
137
138 /**
139 * The base rate of `target` among `samples` (all within 90 days), widening until {@link MIN_BASE}
140 * are found.
141 */
142 export function baseRate(samples: readonly HistorySample[], target: Target, where: string, nowMs: number): BaseRate {
143 const inDays = (days: number) => samples.filter((s) => s.mergedAtMs >= nowMs - days * DAY);
144 const levels: { widen: string | null; pick: () => HistorySample[]; who: string }[] = [
145 ...(target.builder === null
146 ? []
147 : [{ widen: null, who: `${target.size} ${target.kind === "" ? "" : `${target.kind} `}issues by ${target.builder}, last 30 days`, pick: () => inDays(30).filter((s) => s.size === target.size && s.kind === target.kind && s.builder === target.builder) }]),
148 { widen: target.builder === null ? null : "dropped the builder", who: `${target.size} ${target.kind === "" ? "" : `${target.kind} `}issues, last 30 days`, pick: () => inDays(30).filter((s) => s.size === target.size && s.kind === target.kind) },
149 { widen: "dropped the kind", who: `${target.size} issues, last 30 days`, pick: () => inDays(30).filter((s) => s.size === target.size) },
150 { widen: "dropped the size", who: "issues of any size, last 30 days", pick: () => inDays(30) },
151 { widen: "widened to 90 days", who: "issues of any size, last 90 days", pick: () => inDays(90) },
152 ];
153 const widened: string[] = [];
154 let used = levels[levels.length - 1]!;
155 let group: HistorySample[] = [];
156 for (const level of levels) {
157 if (level.widen !== null) widened.push(level.widen);
158 group = level.pick();
159 used = level;
160 if (group.length >= MIN_BASE) break;
161 }
162 const thin = group.length < MIN_BASE;
163 const events: Record<EstimateEvent, EventRate | null> = {
164 pull_opened: fromNow(group.map((s) => s.pull), target.elapsedMs, nowMs),
165 merged: fromNow(group.map((s) => s.merged), target.elapsedMs, nowMs),
166 live: fromNow(group.flatMap((s) => (s.live === undefined ? [] : [s.live])), target.elapsedMs, nowMs),
167 };
168 const merged = events.merged;
169 const live = events.live;
170 const stage = group.flatMap((s) => (s.live === undefined ? [] : [s.live - s.merged])).sort((a, b) => a - b);
171 const parts = [
172 merged === null ? "nothing merged yet" : `${target.elapsedMs > 0 ? "now→merged" : "work start→merged"} p50 ${durationWords(merged.p50Ms)}, p90 ${durationWords(merged.p90Ms)}`,
173 stage.length === 0 || live === null ? "no release has gone live since" : `merged→live p50 ${durationWords(quantile(stage, 0.5))}, p90 ${durationWords(quantile(stage, 0.9))}`,
174 ];
175 const basis = thin
176 ? `only ${group.length} ${used.who} in ${where} (fewer than ${MIN_BASE}; ${widened.length === 0 ? "nothing wider to try" : widened.join(", ")}): ${parts.join("; ")}`
177 : `${group.length} ${used.who} in ${where}${widened.length === 0 ? "" : ` (${widened.join(", ")})`}: ${parts.join("; ")}`;
178 return { n: group.length, thin, widened, basis, events };
179 }
src/flow/estimate_score.ts added +138
@@ -0,0 +1,138 @@
1 /**
2 * Scoring estimates (#11955). For each estimate resolved: ratio = actual duration /
3 * p50 duration, both from the moment it was made. The median ratio is the bias
4 * (2.4 means it runs 2.4× late: optimistic); the p90 hit rate is the share whose
5 * actual instant was at or before the p90 (should be about 90%).
6 *
7 * One estimate counts per estimator, issue and event: the first one, so a revision
8 * after the fact cannot improve a score (later ones are kept as data and shown, not scored).
9 * The base rate shown at that moment is scored the same way as an estimator named
10 * `history`. Void estimates are counted, never scored. Plain data in and out.
11 */
12 import type { EstimateEvent, EstimateRow, EstimateState } from "../core/estimates.ts";
13 import { ratioOf, timesWords } from "./estimate_words.ts";
14
15 export const HISTORY = "history";
16 /** How many of an estimator's most recent resolved estimates the headline reads. */
17 export const LAST = 20;
18
19 export interface Scored {
20 readonly who: string;
21 readonly issueNumber: number;
22 readonly event: EstimateEvent;
23 readonly size: string;
24 readonly kind: string;
25 readonly madeAt: string;
26 readonly p50At: string;
27 readonly p90At: string;
28 readonly actualAt: string | null;
29 readonly state: EstimateState;
30 }
31
32 export interface Score {
33 readonly who: string;
34 /** "" for all of an estimator's work, else "small bug": a size and kind. */
35 readonly group: string;
36 readonly resolved: number;
37 readonly void: number;
38 readonly open: number;
39 /** Median of actual/p50 over the resolved; null with none. */
40 readonly medianRatio: number | null;
41 /** Share of the resolved whose actual was at or before the p90, 0..1; null with none. */
42 readonly p90Hit: number | null;
43 }
44
45 /** Median: the middle one, or the mean of the middle two. */
46 export function median(xs: readonly number[]): number | null {
47 if (xs.length === 0) return null;
48 const s = [...xs].sort((a, b) => a - b);
49 const mid = s.length >> 1;
50 return s.length % 2 === 1 ? s[mid]! : (s[mid - 1]! + s[mid]!) / 2;
51 }
52
53 /**
54 * The estimates that count: each estimator's first per issue and event, plus the history's first
55 * per issue and event.
56 */
57 export function scoredEstimates(rows: readonly EstimateRow[]): Scored[] {
58 const seen = new Set<string>();
59 const firstOfIssueEvent = new Set<string>();
60 const out: Scored[] = [];
61 const base = (r: EstimateRow, who: string, p50At: string, p90At: string): Scored => ({
62 who, issueNumber: r.issueNumber, event: r.event, size: r.size, kind: r.kind, madeAt: r.madeAt, p50At, p90At, actualAt: r.actualAt, state: r.state,
63 });
64 for (const r of rows) {
65 const key = `${r.issueId}/${r.event}`;
66 if (!firstOfIssueEvent.has(key)) {
67 firstOfIssueEvent.add(key);
68 try {
69 const b = JSON.parse(r.baseJson) as { events?: Record<string, { p50At?: string; p90At?: string } | null> } | null;
70 const h = b?.events?.[r.event];
71 if (typeof h?.p50At === "string" && typeof h.p90At === "string") out.push(base(r, HISTORY, h.p50At, h.p90At));
72 } catch {
73 // A base that does not read is not scored; the estimate still is.
74 }
75 }
76 const mine = `${key}/${r.byName}`;
77 if (seen.has(mine)) continue;
78 seen.add(mine);
79 out.push(base(r, r.byName, r.p50At, r.p90At));
80 }
81 return out;
82 }
83
84 const groupOf = (s: Pick<Scored, "size" | "kind">): string => (s.kind === "" ? s.size : `${s.size} ${s.kind}`);
85
86 function tally(who: string, group: string, rows: readonly Scored[], last?: number): Score {
87 const resolved = rows
88 .filter((s) => s.state === "resolved" && s.actualAt !== null)
89 .sort((a, b) => (a.actualAt! < b.actualAt! ? -1 : a.actualAt! > b.actualAt! ? 1 : 0));
90 const read = last === undefined ? resolved : resolved.slice(-last);
91 const ratios = read.flatMap((s) => ratioOf(s) ?? []);
92 const held = read.filter((s) => Date.parse(s.actualAt!) <= Date.parse(s.p90At)).length;
93 return {
94 who,
95 group,
96 resolved: resolved.length,
97 void: rows.filter((s) => s.state === "void").length,
98 open: rows.filter((s) => s.state === "open").length,
99 medianRatio: median(ratios),
100 p90Hit: read.length === 0 ? null : held / read.length,
101 };
102 }
103
104 /**
105 * One score per estimator over all its estimates, then one per size and kind, the estimators in
106 * name order and `history` last.
107 */
108 export function scoreboard(rows: readonly EstimateRow[], last?: number): Score[] {
109 const scored = scoredEstimates(rows);
110 const who = [...new Set(scored.map((s) => s.who))].sort((a, b) => (a === HISTORY ? 1 : b === HISTORY ? -1 : a < b ? -1 : 1));
111 return who.flatMap((w) => {
112 const mine = scored.filter((s) => s.who === w);
113 const groups = [...new Set(mine.map(groupOf))].sort();
114 return [tally(w, "", mine, last), ...groups.map((g) => tally(w, g, mine.filter((s) => groupOf(s) === g), last))];
115 });
116 }
117
118 /**
119 * "median 2.4× late, p90 held 55%": the bias and the p90 in words; undefined with nothing resolved.
120 */
121 export function biasWords(s: Pick<Score, "medianRatio" | "p90Hit">): string | undefined {
122 if (s.medianRatio === null || s.p90Hit === null) return undefined;
123 const r = s.medianRatio;
124 const bias = r >= 1.05 ? `${timesWords(r)} late` : r > 0 && r <= 1 / 1.05 ? `${timesWords(1 / r)} early` : "on the mark";
125 return `median ${bias}, p90 held ${Math.round(s.p90Hit * 100)}%`;
126 }
127
128 /**
129 * "your last 20: median 2.4× late, p90 held 55%" for one estimator; says so when nothing has
130 * resolved yet.
131 */
132 export function lastLine(rows: readonly EstimateRow[], who: string, you = false): string {
133 const s = scoreboard(rows, LAST).find((x) => x.who === who && x.group === "");
134 const words = s === undefined ? undefined : biasWords(s);
135 const count = s === undefined ? 0 : Math.min(s.resolved, LAST);
136 const head = `${you ? "your" : `${who}'s`} last ${count}`;
137 return words === undefined ? `${you ? "you have" : `${who} has`} no resolved estimates yet` : `${head}: ${words}`;
138 }
src/flow/estimate_words.ts added +78
@@ -0,0 +1,78 @@
1 /**
2 * Estimates in words (#11955): durations as "3 h 10 m", instants as "Fri 07:15"
3 * in UTC (the page says UTC), the one line on an issue and on a /next row.
4 * Plain data in, plain strings out; no clock read except what is passed.
5 */
6 import { EVENTS, type EstimateEvent, type EstimateRow } from "../core/estimates.ts";
7
8 const DAYS = ["Sun", "Mon", "Tue", "Wed", "Thu", "Fri", "Sat"] as const;
9 const MONTHS = ["Jan", "Feb", "Mar", "Apr", "May", "Jun", "Jul", "Aug", "Sep", "Oct", "Nov", "Dec"] as const;
10 const two = (n: number): string => String(n).padStart(2, "0");
11
12 /** "25 m", "3 h 10 m", "9 h", "2 d 4 h": whole minutes below a day, hours above. */
13 export function durationWords(ms: number): string {
14 const minutes = Math.max(0, Math.round(ms / 60_000));
15 if (minutes < 60) return `${minutes} m`;
16 const hours = Math.floor(minutes / 60);
17 if (hours < 48) return minutes % 60 === 0 ? `${hours} h` : `${hours} h ${minutes % 60} m`;
18 const days = Math.floor(hours / 24);
19 return hours % 24 === 0 ? `${days} d` : `${days} d ${hours % 24} h`;
20 }
21
22 /** "Fri 07:15" UTC; with the date when it is a week or more from `now`. */
23 export function instantWords(iso: string, now: number): string {
24 const d = new Date(iso);
25 const clock = `${two(d.getUTCHours())}:${two(d.getUTCMinutes())}`;
26 const near = Math.abs(d.getTime() - now) < 6 * 86_400_000;
27 return near ? `${DAYS[d.getUTCDay()]} ${clock}` : `${DAYS[d.getUTCDay()]} ${d.getUTCDate()} ${MONTHS[d.getUTCMonth()]} ${clock}`;
28 }
29
30 export const EVENT_WORDS: Record<EstimateEvent, string> = {
31 pull_opened: "pull opened",
32 merged: "merged",
33 live: "live",
34 };
35 const WENT: Record<EstimateEvent, string> = { pull_opened: "pull opened", merged: "merged", live: "went live" };
36
37 /**
38 * What an estimate was: actual over the p50 duration, both from the moment it was made; undefined
39 * when not resolved or degenerate.
40 */
41 export function ratioOf(r: Pick<EstimateRow, "madeAt" | "p50At" | "actualAt">): number | undefined {
42 if (r.actualAt === null) return undefined;
43 const planned = Date.parse(r.p50At) - Date.parse(r.madeAt);
44 const took = Date.parse(r.actualAt) - Date.parse(r.madeAt);
45 return planned > 0 && took >= 0 ? took / planned : undefined;
46 }
47
48 export const timesWords = (ratio: number): string => `${(Math.round(ratio * 10) / 10).toFixed(1)}×`;
49
50 /**
51 * The base rate's p50 instant for an event, as it stood when the estimate was made, or undefined.
52 */
53 export function historySays(r: Pick<EstimateRow, "baseJson">, event: EstimateEvent): string | undefined {
54 try {
55 const base = JSON.parse(r.baseJson) as { events?: Record<string, { p50At?: string } | null> } | null;
56 return base?.events?.[event]?.p50At;
57 } catch {
58 return undefined;
59 }
60 }
61
62 /**
63 * The one line a promise carries. Open: "live by Fri 07:15 (90%: Fri 11:00) · steward ·
64 * history says Fri 09:40". Resolved: "went live Fri 10:02, 1.6× the estimate". Void: said so.
65 * The event shown is the furthest one estimated; undefined when there is none.
66 */
67 export function estimateLine(rows: readonly EstimateRow[], now: number): string | undefined {
68 const mine = [...rows].reverse();
69 const row = EVENTS.slice().reverse().map((ev) => mine.find((r) => r.event === ev)).find((r) => r !== undefined);
70 if (row === undefined) return undefined;
71 if (row.state === "resolved" && row.actualAt !== null) {
72 const ratio = ratioOf(row);
73 return `${WENT[row.event]} ${instantWords(row.actualAt, now)}${ratio === undefined ? "" : `, ${timesWords(ratio)} the estimate`}`;
74 }
75 if (row.state === "void") return `estimate void: the issue closed without the work`;
76 const says = historySays(row, row.event);
77 return `${EVENT_WORDS[row.event]} by ${instantWords(row.p50At, now)} (90%: ${instantWords(row.p90At, now)}) · ${row.byName}${says === undefined ? "" : ` · history says ${instantWords(says, now)}`}`;
78 }
src/http/api/issues.ts +23
@@ -7,6 +7,8 @@
77
88 import { holds, weakEtag } from "../etag.ts";
99 import { ISSUE_TEAMS_SAY, nameTeams } from "../../actions/issue_teams.ts";
10 import { askEstimate, recordEstimate } from "../../actions/estimates.ts";
11 import { estimatesOf } from "../../core/estimates.ts";
1012 import { confirmIssue, dependencyWords, linkIssue, unlinkIssue } from "../../actions/dependencies.ts";
1113 import {
1214 addCommentThrough,
@@ -327,6 +329,27 @@ export function issueRoutes(router: Router<Handler>): void {
327329 return json({ repo: there, clone: `${ctx.url.origin}/${there}.git` }, 201);
328330 });
329331
332 // Estimates (#11955): GET asks the base rate; POST records one.
333 router.add("GET", "/api/v1/repos/:owner/:name/issues/:number/estimates", (ctx) => {
334 const found = loadIssue(ctx);
335 if ("error" in found) return found.error;
336 return json({
337 ...askEstimate(ctx.app, ctx.auth.user, found.repo, found.issue),
338 revisions: estimatesOf(ctx.app.db, found.issue.id),
339 });
340 });
341 router.add("POST", "/api/v1/repos/:owner/:name/issues/:number/estimates", async (ctx) => {
342 const denied = requireScope(ctx, "rw");
343 if (denied) return denied;
344 const found = loadIssue(ctx);
345 if ("error" in found) return found.error;
346 if (ctx.auth.user === undefined) return apiError("unauthorized", 401, "sign in to record an estimate");
347 const input = await body(ctx.request);
348 const made = recordEstimate(ctx.app, ctx.auth.user, found.repo, found.issue, input);
349 return made.ok
350 ? json(made.value, 201)
351 : apiError(made.error.kind, made.error.kind === "forbidden" ? 403 : 422, made.error.message);
352 });
330353 router.add(
331354 "GET",
332355 "/api/v1/repos/:owner/:name/issues/:number/dependencies",
src/http/mcp/tools/estimate.ts added +38
@@ -0,0 +1,38 @@
1 import { askEstimate, estimateIssue, recordEstimate } from "../../../actions/estimates.ts";
2 import { bad, good, type Tool, REPO_ARG, repoArg, isAnswer } from "../shared.ts";
3 import { mayView } from "../../../core/sealed.ts";
4
5 export const tool: Tool = {
6 name: "estimate",
7 doc: "`{ repo, issue, event?, p50?, p90?, basis? }`: with no times, the base rate for that issue from the forge's own history (p50/p90 instants and durations per event, sample size, one line of basis, what is true now, and your scorecard); with `event` (`pull_opened`, `merged`, `live`), `p50` and `p90` (future instants, p50 ≤ p90) it records an estimate, which the forge resolves when the event happens and a new one supersedes",
8 description: "Ask an issue's base rate, or record `event` with `p50`, `p90` (instants) and `basis`.",
9 inputSchema: {
10 type: "object",
11 properties: {
12 repo: REPO_ARG,
13 issue: { type: "integer", minimum: 1 },
14 event: { type: "string", enum: ["pull_opened", "merged", "live"] },
15 p50: { type: "string", maxLength: 40 },
16 p90: { type: "string", maxLength: 40 },
17 basis: { type: "string", maxLength: 300 },
18 },
19 required: ["repo", "issue"],
20 },
21 writes: true,
22 readsWithout: "event",
23 run(ctx, user, args) {
24 const repo = repoArg(ctx, user, args);
25 if (isAnswer(repo)) return repo;
26 const issue = estimateIssue(ctx.app, repo, args["issue"] as number);
27 if (issue === undefined || !mayView(ctx.app.db, repo, issue, ctx.auth.user))
28 return bad("no_such_issue", `no issue #${String(args["issue"])}`);
29 if (args["event"] === undefined) return good(askEstimate(ctx.app, user, repo, issue));
30 const made = recordEstimate(ctx.app, user, repo, issue, {
31 event: args["event"],
32 p50: args["p50"],
33 p90: args["p90"],
34 basis: args["basis"],
35 });
36 return made.ok ? good({ says: `Recorded: ${made.value.base.basis}`, ...made.value }) : bad(made.error.kind, made.error.message);
37 },
38 };
src/http/mcp/tools/index.ts +1
@@ -57,6 +57,7 @@ export { tool as economics } from "./economics.ts";
5757 export { tool as edit } from "./edit.ts";
5858 export { tool as egress_grants } from "./egress_grants.ts";
5959 export { tool as email_verify } from "./email_verify.ts";
60 export { tool as estimate } from "./estimate.ts";
6061 export { tool as events } from "./events.ts";
6162 export { tool as explore } from "./explore.ts";
6263 export { tool as family } from "./family.ts";
src/tui/forge.ts +7
@@ -101,6 +101,7 @@ import { stalledScreen } from "./screens/stalled.ts";
101101 import { issuesScreen, newIssueScreen } from "./screens/issues.ts";
102102 import { limbsScreen } from "./screens/limbs.ts";
103103 import { nextScreen } from "./screens/next.ts";
104 import { estimatesScreen } from "./screens/estimates.ts";
104105 import { findingsScreen } from "./screens/findings.ts";
105106 import { flowsScreen } from "./screens/flows.ts";
106107 import { weekScreen } from "./screens/week.ts";
@@ -177,6 +178,12 @@ const REPO_VERBS: readonly {
177178 title: "Next up in a repository",
178179 open: (f, ctx, repo) => nextScreen(f, ctx, repo),
179180 },
181 {
182 // What is promised for each issue, the base rate to ask, and recording one (#11955).
183 id: "estimates",
184 title: "Estimates in a repository",
185 open: (f, ctx, repo) => estimatesScreen(f, ctx, repo),
186 },
180187 {
181188 // The retrospective the forge wrote at the last weekly reset, and its proposals (#8704).
182189 id: "week",
src/tui/screens/estimates.ts added +135
@@ -0,0 +1,135 @@
1 /**
2 * A repository's estimates, in the terminal (#11955): the issues the crew builds from next, each
3 * with what is promised for it, and the same two acts the page, the API and MCP `estimate` reach.
4 * Gated as they are: a reader sees the list (sealed issues left to `sealedScope`) and asks an
5 * issue's base rate with `a`; recording with `r` is `recordEstimate`, so only a writer or a crew
6 * member on the repository can. The line typed is `event p50 p90 basis…`, e.g.
7 * `merged 2026-11-14T07:15:00Z 2026-11-14T11:00:00Z a small change`.
8 */
9
10 import { askEstimate, estimateIssue, recordEstimate, type Asked } from "../../actions/estimates.ts";
11 import { currentByNumber, type EstimateRow } from "../../core/estimates.ts";
12 import { nextUp, type NextRow } from "../../core/next_up.ts";
13 import { canRead, canWrite, type Repo } from "../../core/repos.ts";
14 import { mayView, sealedScope } from "../../core/sealed.ts";
15 import { estimateLine } from "../../flow/estimate_words.ts";
16 import type { Ctx, Screen } from "../app/app.ts";
17 import { binding } from "../app/keys.ts";
18 import { fill, len, split } from "../ui/layout.ts";
19 import { style } from "../term/style.ts";
20 import { truncate } from "../term/width.ts";
21 import type { Session } from "../session.ts";
22 import { ListState, drawList, live, promptScreen, type ListRow } from "./common.ts";
23
24 /** The rows shown: the top of the list, as the page's. */
25 const SHOWN = 100;
26 const LIVE_KINDS = ["issue.estimated", "issue.ranked", "issue.opened", "issue.closed", "issue.reopened"];
27
28 /** `event p50 p90 basis…` as the act's input. */
29 export function parseEstimate(line: string): { event: string; p50: string; p90: string; basis: string } | undefined {
30 const m = /^\s*(\S+)\s+(\S+)\s+(\S+)(?:\s+(.*))?$/s.exec(line);
31 return m === null ? undefined : { event: m[1]!, p50: m[2]!, p90: m[3]!, basis: (m[4] ?? "").trim() };
32 }
33
34 export function estimatesScreen(f: Session, ctx: Ctx, repo: Repo): Screen {
35 const state = new ListState();
36 let rows: readonly NextRow[] = [];
37 let promised = new Map<number, EstimateRow[]>();
38 let asked: Asked | undefined;
39 let loaded = false;
40 // Read at use, not at open: access can change under a session.
41 const may = (): boolean => f.user !== undefined && canWrite(f.db, repo, f.user);
42
43 const reload = (): void => {
44 if (!canRead(f.db, repo, f.user)) {
45 rows = [];
46 promised = new Map();
47 } else {
48 const scope = sealedScope(f.db, repo, f.user);
49 rows = nextUp(f.db, repo, scope).rows.slice(0, SHOWN);
50 promised = currentByNumber(f.db, repo.id, rows.filter((r) => r.issue.estimated).map((r) => r.issue.number));
51 }
52 loaded = true;
53 state.clamp(rows.length);
54 ctx.invalidate();
55 };
56
57 const current = (): NextRow | undefined => rows[state.index];
58 const target = () => {
59 const row = current();
60 // The act's own gate for one issue: a kind of issue the viewer may see.
61 const issue = row === undefined ? undefined : estimateIssue(f.app, repo, row.issue.number);
62 return issue !== undefined && mayView(f.db, repo, issue, f.user) ? issue : undefined;
63 };
64
65 const ask = (): void => {
66 const issue = target();
67 if (issue === undefined || !canRead(f.db, repo, f.user)) return;
68 asked = askEstimate(f.app, f.user, repo, issue);
69 ctx.invalidate();
70 };
71
72 const record = (): void => {
73 const issue = target();
74 if (issue === undefined || !may()) return;
75 ctx.push(
76 promptScreen(ctx, screen, {
77 title: `Estimate #${issue.number}`,
78 label: "event p50 p90 basis: event is pull_opened, merged or live; times like 2026-11-14T07:15:00Z",
79 onSubmit: (raw) => {
80 const input = parseEstimate(raw);
81 if (input === undefined) return "event, p50 and p90 first, then the basis";
82 const made = recordEstimate(f.app, f.user!, repo, issue, input);
83 if (!made.ok) return made.error.message;
84 ctx.toast(`Recorded: ${made.value.base.basis}`, "success");
85 reload();
86 return undefined;
87 },
88 }),
89 );
90 };
91
92 const lines = (): ListRow[] =>
93 rows.map((r) => {
94 const words = estimateLine(promised.get(r.issue.number) ?? [], Date.now());
95 return { title: `#${r.issue.number} ${r.issue.title}`, meta: words ?? "no estimate" };
96 });
97
98 const screen: Screen = {
99 title: `${repo.ownerName}/${repo.name} — Estimates`,
100 get keys() {
101 return [
102 binding(["j", "k"], "move cursor"),
103 binding(["a"], "ask the base rate", { disabled: current() === undefined }),
104 binding(["r"], "record an estimate", { disabled: !may() || current() === undefined }),
105 ];
106 },
107 load(signal) {
108 live(f, signal, { repoId: repo.id, kinds: LIVE_KINDS }, () => reload());
109 reload();
110 return Promise.resolve();
111 },
112 render(area, s) {
113 const c = ctx.theme.c;
114 const [head, list] = split(area, "v", [len(2), fill(1)]);
115 const note = !loaded
116 ? "Loading…"
117 : rows.length === 0
118 ? "Nothing is ready to estimate."
119 : `a asks the base rate for the issue under the cursor${may() ? ", r records an estimate" : "; you can read estimates, not record them"}`;
120 s.text(head!.x, head!.y, truncate(note, head!.w), style(c.mutedFg, c.bg), head!.x + head!.w);
121 const said = asked === undefined ? "" : `#${asked.number}: ${asked.base.basis}`;
122 s.text(head!.x, head!.y + 1, truncate(said, head!.w), style(c.fg, c.bg), head!.x + head!.w);
123 drawList(s, list!, c, state, lines());
124 },
125 key(e) {
126 if (state.key(e, rows.length)) return true;
127 if (e.ctrl || e.alt) return false;
128 if (e.key === "a") ask();
129 else if (e.key === "r") record();
130 else return false;
131 return true;
132 },
133 };
134 return screen;
135 }
src/web/pages/flow-estimates.ts added +27
@@ -0,0 +1,27 @@
1 /**
2 * The estimate scorecard on /flow (#11955), over the repositories the viewer may read: per
3 * estimator, then per size and kind, how late (or early) the median ran and how often the p90 held.
4 */
5 import { estimatesVisibleTo } from "../../core/estimates.ts";
6 import type { User } from "../../core/users.ts";
7 import type { Database } from "bun:sqlite";
8 import { biasWords, scoreboard, type Score } from "../../flow/estimate_score.ts";
9 import { html, type Html } from "../html.ts";
10 import { icon, row } from "../ui.ts";
11
12 const counts = (s: Score): string => `${String(s.resolved)} resolved · ${String(s.void)} void · ${String(s.open)} open`;
13
14 /** One row per score: "steward · small bug: median 2.4× late, p90 held 55% · 6 resolved …". */
15 export function estimatesSection(db: Database, viewer: User | undefined): Html {
16 const scores = scoreboard(estimatesVisibleTo(db, viewer));
17 if (scores.length === 0) return html``;
18 return html`<h2>Estimates</h2>
19 <p class="muted small">The p90 should hold about 90% of the time.</p>
outdated

Principle 1 and 8: this paragraph explains the page twice. One plain sentence at most. Also scores here cover all repos, including private ones: scope to what the viewer can see.

20 <ul class="list mb" id="estimate-scores">${scores.map((s) =>
21 row({
22 lead: icon("activity"),
23 title: html`${s.who}${s.group === "" ? "" : ` · ${s.group}`}`,
24 meta: html`${biasWords(s) ?? "nothing resolved yet"} · ${counts(s)}`,
25 }),
26 )}</ul>`;
27 }
src/web/pages/flow.ts +2
@@ -24,6 +24,7 @@ import { handoverSection } from "./flow-handover.ts";
2424 import { readDrifts } from "../../flow/alarm_drift.ts";
2525 import { readHandoverLists } from "../../flow/handover_read.ts";
2626 import { readWaiting, waitingSection } from "./flow-waiting.ts";
27 import { estimatesSection } from "./flow-estimates.ts";
2728 import { clogSection, readClogView } from "./flow-clog.ts";
2829 import { cpuStopsSection } from "./flow-cpu.ts";
2930 import { cpuClosedPulls } from "../../flow/cpu_closed.ts";
@@ -108,6 +109,7 @@ export async function flowPage(ctx: Ctx): Promise<Response> {
108109 ${clogSection(readClogView(ctx.app.db))}
109110 ${cpuStopsSection(cpuStops(ctx.app.db).stopped, cpuClosedPulls(ctx.app.db, new Date()))}
110111 ${waitingSection(await readWaiting(ctx.app.db, ctx.app.reposRoot, new Date()))}
112 ${estimatesSection(ctx.app.db, ctx.auth.user)}
111113 ${pipesSvg(p)}
112114 <p class="muted small">${money(p.usd)} spent in the last 7 days; ${plural(p.shipped, "pull")} shipped.</p>
113115 ${handoverSection(readHandoverLists(ctx.app.db, await readDrifts(ctx.app), Date.now()))}
src/web/pages/next.ts +8 −2
@@ -7,6 +7,8 @@
77 * beside each action (`j`/`k` choose the row), which post the same form.
88 */
99 import { placeOf, rankIssue, rankedWords, stepTo, type Place } from "../../actions/next.ts";
10 import { currentByNumber } from "../../core/estimates.ts";
11 import { estimateLine } from "../../flow/estimate_words.ts";
1012 import { nextUp, TOP, type NextRow } from "../../core/next_up.ts";
1113 import { openPullStages } from "../../flow/read.ts";
1214 import { stateWord, type StateFacts } from "./next_state.ts";
@@ -157,6 +159,7 @@ function line(
157159 position: number | null = null,
158160 state: string | null = null,
159161 mine = false,
162 promise: string | undefined = undefined,
160163 ): Html {
161164 const r = rows[at]!;
162165 const n = r.issue.number;
@@ -175,7 +178,7 @@ function line(
175178 title: r.issue.title,
176179 titleAttr: r.issue.title,
177180 lead: html`${position === null ? "·" : String(position)}`,
178 meta: html`${mine ? html`<span class="mine">Owner's pick</span> · ` : null}${r.role === "blocker" ? "Blocker" : "Enabler"} · #${n}${state === null ? "" : ` · ${state} · ${r.onIt ?? "next"}`} · ${r.roleWhy}${away ? null : html` · <span class="estimate">${rangeWords(priced.find((p) => p.number === n)?.estimate ?? { known: false, samples: 0, basis: "no estimate" })}</span>`}${state !== null || r.onIt === null ? "" : ` · on it: ${r.onIt}`}${r.why === null ? "" : ` · ${r.why}`}`,
181 meta: html`${mine ? html`<span class="mine">Owner's pick</span> · ` : null}${r.role === "blocker" ? "Blocker" : "Enabler"} · #${n}${state === null ? "" : ` · ${state} · ${r.onIt ?? "next"}`} · ${r.roleWhy}${away ? null : html` · <span class="estimate">${rangeWords(priced.find((p) => p.number === n)?.estimate ?? { known: false, samples: 0, basis: "no estimate" })}</span>`}${state !== null || r.onIt === null ? "" : ` · on it: ${r.onIt}`}${r.why === null ? "" : ` · ${r.why}`}${promise === undefined ? "" : ` · ${promise}`}`,
179182 nav: true,
180183 data: { number: String(n), ...(cost === undefined ? {} : { cost: String(cost) }) },
181184 ...(controls === null ? {} : { after: controls }),
@@ -209,6 +212,9 @@ export function nextPage(ctx: Ctx): Response {
209212 const { priced, cut, capacity, sentence } = weekView(ctx.app.db, repo.id, rows, new Date());
210213 const costs = rowCosts(priced);
211214 const stages = openPullStages(ctx.app.db, repo, scope, new Date(), new Set(rows.map((r) => r.issue.number)));
215 const promised = currentByNumber(ctx.app.db, repo.id, rows.filter((r) => r.issue.estimated).map((r) => r.issue.number));
216 const nowMs = Date.now();
217 const promise = (r: NextRow): string | undefined => estimateLine(promised.get(r.issue.number) ?? [], nowMs);
212218 const facts = (r: NextRow): StateFacts => ({ stage: stages.get(r.issue.number) ?? "issue", waits: r.waits, held: r.state === "held", onIt: r.onIt !== null });
213219 // One numbered order, folded: the first TOP rows open, what comes after, then Later.
214220 // A filter hides rows but keeps each one's place in the whole ranking.
@@ -220,7 +226,7 @@ export function nextPage(ctx: Ctx): Response {
220226 <ul class="list setting-list"${id === undefined ? null : html` id="${id}"`}${id !== undefined && !isFiltered && costs !== null && capacity !== null && cut.unsure === null ? html` data-capacity="${String(capacity.usd)}"` : null}>
221227 ${list.flatMap((at, i) => [
222228 ...(i === mark && cut.unsure === null && id !== undefined ? [cutMark(cut, false)] : []),
223 line(base, rows, at, writer, back, false, priced, costs?.[at], at + 1, stateWord(rows[at]!, facts(rows[at]!)), rows[at]!.byOwner),
229 line(base, rows, at, writer, back, false, priced, costs?.[at], at + 1, stateWord(rows[at]!, facts(rows[at]!)), rows[at]!.byOwner, promise(rows[at]!)),
224230 ])}
225231 </ul></details>`;
226232 };
src/web/studio/tell.ts +14
@@ -1,3 +1,4 @@
1 import type { Database } from "bun:sqlite";
12 /**
23 * One tell in the studio: a tell is one sentence someone said, and the page
34 * answers three questions — who has it, what it is waiting on, and what the
@@ -50,6 +51,8 @@ import { repoDir } from "../../git/repos.ts";
5051 import type { Ctx } from "../../http/context.ts";
5152 import { crumbsFor, repoBase, repoHeader, repoPalette } from "../chrome.ts";
5253 import { html, when, type Html } from "../html.ts";
54 import { currentOf, estimatesOf } from "../../core/estimates.ts";
55 import { estimateLine } from "../../flow/estimate_words.ts";
5356 import { noteLine } from "./note.ts";
5457 import { threadLine } from "../describe.ts";
5558 import { page } from "../layout.ts";
@@ -522,6 +525,7 @@ async function studioTell(
522525 badge("Sealed security report", { tone: "danger", icon: "lock" }),
523526 )}
524527 ${workSpent(ctx, repo, issue.id)}
528 ${promiseLine(db, issue)}
525529 ${!direction && issue.state === "open" && canWrite(db, repo, user)
526530 ? flowButton(
527531 `${thread}/flow`,
@@ -687,3 +691,13 @@ function sealCard(ctx: Ctx, repo: Repo, issue: Issue, action: string): Html | nu
687691 )}
688692 </form>`;
689693 }
694
695 /**
696 * The promise on an issue, in one line: "live by Fri 07:15 (90%: Fri 11:00) · steward · history
697 * says Fri 09:40" (#11955).
698 */
699 function promiseLine(db: Database, issue: Issue): Html | null {
700 if (!issue.estimated) return null;
701 const words = estimateLine(currentOf(estimatesOf(db, issue.id)), Date.now());
702 return words === undefined ? null : html`<span class="estimate" id="estimate-line">${words}</span>`;
703 }
test/conversation-confirm.test.ts +1 −1
@@ -122,7 +122,7 @@ describe("the one list of what asks first", () => {
122122 const NOT_ASKED = new Set([
123123 "access", "account_requests", "account_verify", "app_members", "app_notes", "asks_act", "asks_decide", "asks_proposal", "asks_undecide", "atlas",
124124 "asks", "assign", "attach_look", "bet", "board_pins", "board_write", "branches", "capacity", "valves", "cloud_accounts", "close", "confirm_wait",
125 "crew", "decisions_by", "detach", "economics", "edit", "email_verify", "feed_chips", "feed_dismiss", "feed_like", "flag_set", "flag_winner",
125 "crew", "decisions_by", "detach", "economics", "edit", "email_verify", "estimate", "feed_chips", "feed_dismiss", "feed_like", "flag_set", "flag_winner",
126126 "follow_mode", "fork", "front_page", "hold", "issue_teams", "keep_plan", "knobs", "labels", "lift", "limb", "maintenance", "make_it_yours", "map", "merge", "new_app",
127127 "next_up", "week", "notify", "open_pull", "org_create", "origin_apps", "pins", "plan", "profile", "propose_to_parent_forge", "repo_labels", "reports",
128128 "repo", "restore_drill", "review", "say", "signup", "snapshot_now", "spend", "steer", "sync", "take_back", "take_updates",
test/estimates.test.ts added +417
@@ -0,0 +1,417 @@
1 /** Estimates against an issue (#11955): ask, record, resolve, void, score. */
2 import { afterAll, beforeAll, describe, expect, test } from "bun:test";
3 import { mkdtemp, rm } from "node:fs/promises";
4 import { tmpdir } from "node:os";
5 import { join } from "node:path";
6 import { createApp, type App } from "../src/app.ts";
7 import { askEstimate, recordEstimate } from "../src/actions/estimates.ts";
8 import { openIssue, setIssueStateThrough } from "../src/actions/issues.ts";
9 import { mergePull, openPull } from "../src/actions/pulls.ts";
10 import { commitToBranch, createRepo } from "../src/actions/repos.ts";
11 import { currentByNumber, currentOf, estimatesOf, estimatesVisibleTo } from "../src/core/estimates.ts";
12 import { createIssue, getIssue } from "../src/core/issues.ts";
13 import type { Repo } from "../src/core/repos.ts";
14 import { createToken } from "../src/core/tokens.ts";
15 import { createUser, type User } from "../src/core/users.ts";
16 import { baseRate, quantile, type HistorySample } from "../src/flow/estimate_base.ts";
17 import { biasWords, lastLine, median, scoreboard } from "../src/flow/estimate_score.ts";
18 import { durationWords, estimateLine, ratioOf } from "../src/flow/estimate_words.ts";
19 import { markLive, recordCut } from "../src/keeper/record.ts";
20 import { serve, type Servers } from "../src/http/server.ts";
21 import { estimatesScreen, parseEstimate } from "../src/tui/screens/estimates.ts";
22 import type { Ctx as TuiCtx } from "../src/tui/app/app.ts";
23 import { keyFor } from "../src/tui/app/keys.ts";
24 import type { Session } from "../src/tui/session.ts";
25
26 const H = 3_600_000;
27 const NOW = new Date("2026-11-13T12:00:00.000Z");
28 const at = (ms: number) => new Date(NOW.getTime() + ms).toISOString();
29
30 const sample = (n: number, mergedH: number, extra: Partial<HistorySample> = {}): HistorySample => ({
31 number: n,
32 size: "small",
33 kind: "bug",
34 builder: null,
35 pull: mergedH * H * 0.5,
36 merged: mergedH * H,
37 live: mergedH * H + 25 * 60_000,
38 mergedAtMs: NOW.getTime() - 86_400_000,
39 ...extra,
40 });
41
42 describe("the base rate", () => {
43 test("six samples: nearest-rank p50 and p90 from now, with the stage after the merge", () => {
44 const samples = [1, 2, 3, 4, 5, 6].map((h, i) => sample(i + 1, h));
45 const r = baseRate(samples, { size: "small", kind: "bug", builder: null, elapsedMs: 0 }, "trevato/forge", NOW.getTime());
46 expect(r.thin).toBe(false);
47 expect(r.n).toBe(6);
48 // sorted[round(5 * .5)] = sorted[3] = 4 h; sorted[round(5 * .9)] = sorted[5] = 6 h
49 expect(r.events.merged!.p50Ms).toBe(4 * H);
50 expect(r.events.merged!.p90Ms).toBe(6 * H);
51 expect(r.events.merged!.p50At).toBe(at(4 * H));
52 expect(r.basis).toContain("6 small bug issues, last 30 days in trevato/forge");
53 expect(r.basis).toContain("work start→merged p50 4 h, p90 6 h");
54 expect(r.basis).toContain("merged→live p50 25 m, p90 25 m");
55 });
56
57 test("fewer than five: it says so and widens, dropping the builder, then the kind", () => {
58 const samples = [
59 sample(1, 1, { builder: "torque" }),
60 sample(2, 2, { builder: "torque" }),
61 sample(3, 3, { kind: "" }),
62 sample(4, 4, { kind: "" }),
63 sample(5, 5, { kind: "" }),
64 sample(6, 6, { kind: "" }),
65 ];
66 const r = baseRate(samples, { size: "small", kind: "bug", builder: "torque", elapsedMs: 0 }, "x/y", NOW.getTime());
67 expect(r.widened).toEqual(["dropped the builder", "dropped the kind"]);
68 expect(r.n).toBe(6);
69 expect(r.thin).toBe(false);
70 const few = baseRate(samples.slice(0, 2), { size: "small", kind: "bug", builder: null, elapsedMs: 0 }, "x/y", NOW.getTime());
71 expect(few.thin).toBe(true);
72 expect(few.basis).toContain("fewer than 5");
73 });
74
75 test("work already under way counts only what is left of the slower ones", () => {
76 const samples = [1, 2, 3, 4, 5, 6, 7, 8].map((h, i) => sample(i + 1, h));
77 const r = baseRate(samples, { size: "small", kind: "bug", builder: null, elapsedMs: 2 * H }, "x/y", NOW.getTime());
78 // 3..8 h remain 1..6 h: the same nearest ranks as above
79 expect(r.events.merged!.p50Ms).toBe(4 * H);
80 });
81
82 test("durations read in words", () => {
83 expect([durationWords(25 * 60_000), durationWords(190 * 60_000), durationWords(9 * H), durationWords(52 * H)]).toEqual(["25 m", "3 h 10 m", "9 h", "2 d 4 h"]);
84 expect(quantile([1, 2, 3], 0.5)).toBe(2);
85 });
86 });
87
88 describe("the scorecard maths", () => {
89 const row = (id: number, who: string, p50H: number, actualH: number | null, p90H = p50H * 2, extra: object = {}) => ({
90 id, repoId: "r", issueId: `i${id}`, issueNumber: id, event: "merged" as const,
91 p50At: at(p50H * H), p90At: at(p90H * H), madeAt: at(0), byId: who, byName: who, byKind: "agent" as const,
92 basis: "", baseJson: "null", size: "small", kind: "bug",
93 actualAt: actualH === null ? null : at(actualH * H), voidedAt: null,
94 state: actualH === null ? ("open" as const) : ("resolved" as const), ...extra,
95 });
96
97 test("ratio is actual over p50 from the moment it was made; the median is the bias", () => {
98 expect(ratioOf(row(1, "a", 2, 5))).toBe(2.5);
99 expect(median([1, 3, 2])).toBe(2);
100 expect(median([1, 2, 3, 4])).toBe(2.5);
101 const rows = [row(1, "steward", 2, 5), row(2, "steward", 2, 4.8), row(3, "steward", 1, 2.4, 3), row(4, "steward", 2, null)];
102 const all = scoreboard(rows).find((s) => s.who === "steward" && s.group === "")!;
103 // ratios 2.5, 2.4, 2.4: median 2.4; p90 at 4, 4, 3: the first two missed, the third held
104 expect(all.medianRatio).toBeCloseTo(2.4, 10);
105 expect(all.p90Hit).toBeCloseTo(1 / 3, 10);
106 expect([all.resolved, all.open, all.void]).toEqual([3, 1, 0]);
107 expect(biasWords(all)).toBe("median 2.4× late, p90 held 33%");
108 expect(lastLine(rows, "steward", true)).toBe("your last 3: median 2.4× late, p90 held 33%");
109 expect(scoreboard(rows).some((s) => s.group === "small bug")).toBe(true);
110 });
111
112 test("void is counted, never scored; a revision after the fact does not improve the score", () => {
113 const rows = [
114 row(1, "steward", 2, 6),
115 row(2, "steward", 2, 6, 4),
116 { ...row(2, "steward", 5, 6, 8), id: 3, issueId: "i2" }, // revised later: kept, not scored
117 row(4, "steward", 2, null, 4, { voidedAt: at(H), state: "void" }),
118 ];
119 const all = scoreboard(rows).find((s) => s.who === "steward" && s.group === "")!;
120 expect(all.resolved).toBe(2);
121 expect(all.void).toBe(1);
122 expect(all.medianRatio).toBe(3);
123 });
124
125 test("the base rate shown at the time is scored as `history`", () => {
126 const baseJson = JSON.stringify({ events: { merged: { p50At: at(3 * H), p90At: at(9 * H) } } });
127 const rows = [row(1, "steward", 2, 3, 4, { baseJson })];
128 const history = scoreboard(rows).find((s) => s.who === "history" && s.group === "")!;
129 expect(history.medianRatio).toBe(1);
130 expect(history.p90Hit).toBe(1);
131 });
132
133 test("the line: open, then resolved with its ratio", () => {
134 const open = row(1, "steward", 2, null, 6, { baseJson: JSON.stringify({ events: { merged: { p50At: at(3 * H) } } }) });
135 expect(estimateLine([open], NOW.getTime())).toBe("merged by Fri 14:00 (90%: Fri 18:00) · steward · history says Fri 15:00");
136 expect(estimateLine([row(1, "steward", 2, 3)], NOW.getTime())).toBe("merged Fri 15:00, 1.5× the estimate");
137 });
138 });
139
140 describe("recording and resolving on the forge", () => {
141 let dataDir = "";
142 let app: App;
143 let servers: Servers;
144 let alice: User;
145 let stranger: User;
146 let repo: Repo;
147 let rw = "";
148
149 const issue = (title: string, body = "") => {
150 const o = openIssue(app, alice, repo, { title, body });
151 if (!o.ok) throw new Error(String(o.error));
152 return o.value;
153 };
154 const getI = (n: number) => getIssue(app.db, repo.id, n)!;
155 const say = (n: number, event: unknown, p50: string, p90: string, now = NOW, who = alice) =>
156 recordEstimate(app, who, repo, getI(n), { event, p50, p90, basis: "by hand" }, now);
157
158 beforeAll(async () => {
159 dataDir = await mkdtemp(join(tmpdir(), "forge-estimates-"));
160 app = createApp({ dataDir, port: 0 });
161 servers = await serve(app);
162 const made = await createUser(app.db, { origin: "seed", trusted: true, username: "alice", email: "[email protected]", password: "a-good-passphrase", adminIfFirst: true });
163 if (!made.ok) throw new Error(made.error);
164 alice = made.value;
165 const other = await createUser(app.db, { origin: "seed", trusted: true, username: "mallory", email: "[email protected]", password: "a-good-passphrase" });
166 if (!other.ok) throw new Error(other.error);
167 stranger = other.value;
168 const r = await createRepo(app, alice, { name: "forge", visibility: "public" });
169 if (!r.ok) throw new Error(String(r.error));
170 repo = r.value;
171 await commitToBranch(app, alice, repo, { branch: "main", message: "base", files: [{ path: "a.txt", content: "a\n" }] });
172 rw = createToken(app.db, alice.id, "rw", "rw").token;
173 }, 30_000);
174
175 afterAll(async () => {
176 await servers.stop();
177 app.close();
178 await rm(dataDir, { recursive: true, force: true, maxRetries: 20, retryDelay: 100 });
179 });
180
181 test("ask: the base rate comes from seeded history", () => {
182 for (let i = 0; i < 6; i++) {
183 const done = createIssue(app.db, { repoId: repo.id, authorId: alice.id, title: `done ${i}`, body: "" });
184 if (!done.ok) throw new Error("issue");
185 const pr = createIssue(app.db, { repoId: repo.id, authorId: alice.id, title: `pr ${i}`, body: `Closes #${done.value.number}`, kind: "pull" });
186 if (!pr.ok) throw new Error("pull");
187 const created = new Date(NOW.getTime() - (i + 2) * 86_400_000).toISOString();
188 const merged = new Date(NOW.getTime() - (i + 2) * 86_400_000 + (i + 1) * H).toISOString();
189 app.db.query(`UPDATE issue SET state='closed', close_reason='done', created_at=?2 WHERE id=?1`).run(done.value.id, created);
190 app.db.query(`INSERT INTO pull (issue_id, base_branch, head_branch, base_oid, head_oid, merged_at) VALUES (?1,'main','b','a','b',?2)`).run(pr.value.id, merged);
191 }
192 const target = issue("a small thing");
193 const asked = askEstimate(app, alice, repo, target, NOW);
194 expect(asked.base.n).toBe(6);
195 expect(asked.base.thin).toBe(false);
196 expect(asked.base.events.merged!.p50Ms).toBe(4 * H);
197 expect(asked.base.events.merged!.p90Ms).toBe(6 * H);
198 expect(asked.facts.queuePosition).not.toBeNull();
199 expect(asked.facts.budgetLeftUsd.day).toBeGreaterThanOrEqual(0);
200 expect(asked.scorecard).toBe("you have no resolved estimates yet");
201 });
202
203 test("record: validated, stamped, and a new one supersedes while the old stays", () => {
204 const n = issue("record me").number;
205 expect(say(n, "nonsense", at(H), at(2 * H)).ok).toBe(false);
206 expect(say(n, "merged", at(-H), at(2 * H)).ok).toBe(false);
207 expect(say(n, "merged", at(3 * H), at(2 * H)).ok).toBe(false);
208 expect(say(n, "merged", "tomorrow", at(2 * H)).ok).toBe(false);
209 const nobody = say(n, "merged", at(H), at(2 * H), NOW, stranger);
210 expect(nobody.ok).toBe(false);
211 if (!nobody.ok) expect(nobody.error.kind).toBe("forbidden");
212 expect(estimatesOf(app.db, getI(n).id)).toHaveLength(0);
213
214 const first = say(n, "merged", at(H), at(2 * H));
215 expect(first.ok).toBe(true);
216 if (!first.ok) return;
217 expect(first.value.estimate).toMatchObject({ byName: "alice", byKind: "person", madeAt: NOW.toISOString(), event: "merged", state: "open" });
218 expect(JSON.parse(first.value.estimate.baseJson).events).toBeDefined();
219 const second = say(n, "merged", at(4 * H), at(5 * H));
220 expect(second.ok).toBe(true);
221 const rows = estimatesOf(app.db, getI(n).id);
222 expect(rows).toHaveLength(2);
223 expect(currentOf(rows)[0]!.p50At).toBe(at(4 * H));
224 expect(rows[0]!.p50At).toBe(at(H));
225 });
226
227 test("resolved by the forge: pull opened, merged, and live; never by hand", async () => {
228 const t = issue("ship me");
229 const real = new Date();
230 const soon = (h: number) => new Date(real.getTime() + h * H).toISOString();
231 for (const ev of ["pull_opened", "merged", "live"] as const) {
232 const made = recordEstimate(app, alice, repo, t, { event: ev, p50: soon(1), p90: soon(2) }, real);
233 expect(made.ok).toBe(true);
234 }
235 await commitToBranch(app, alice, repo, { branch: "ship", base: "main", message: "ship", files: [{ path: "s.txt", content: "s\n" }] });
236 const opened = await openPull(app, alice, repo, { title: "Ship it", body: `Closes #${t.number}`, baseBranch: "main", headBranch: "ship" });
237 if (!opened.ok) throw new Error(JSON.stringify(opened.error));
238 const state = () => Object.fromEntries(estimatesOf(app.db, t.id).map((e) => [e.event, e.state]));
239 expect(state()).toEqual({ pull_opened: "resolved", merged: "open", live: "open" });
240 // Asking for an event that already happened is refused.
241 expect(recordEstimate(app, alice, repo, t, { event: "pull_opened", p50: soon(1), p90: soon(2) }, real).ok).toBe(false);
242 const merged = await mergePull(app, alice, repo, opened.value, "squash");
243 if (!merged.ok) throw new Error(JSON.stringify(merged.error));
244 expect(state()).toEqual({ pull_opened: "resolved", merged: "resolved", live: "open" });
245 // A release cut before the merge does not carry it; one cut after does, and its going live
246 // resolves.
247 const before = recordCut(app.db, { version: "v-old", sha: "a", knownGood: { version: "k", image: "i" }, at: real.getTime() - 3_600_000 });
248 markLive(app.db, before.id, real.getTime() + 1000);
249 app.bus.append({ kind: "release.verified_live", repoId: repo.id, payload: { version: "v-old" } });
250 expect(state().live).toBe("open");
251 const cut = recordCut(app.db, { version: "v-new", sha: "b", knownGood: { version: "k", image: "i" }, at: real.getTime() + 2000 });
252 expect(markLive(app.db, cut.id, real.getTime() + 60_000)).toBe(true);
253 // Settled when the release is verified, not when it merely answers.
254 expect(state().live).toBe("open");
255 app.bus.append({ kind: "release.verified_live", repoId: repo.id, payload: { version: "v-new" } });
256 expect(state()).toEqual({ pull_opened: "resolved", merged: "resolved", live: "resolved" });
257 const live = estimatesOf(app.db, t.id).find((e) => e.event === "live")!;
258 expect(live.actualAt).toBe(new Date(real.getTime() + 60_000).toISOString());
259 expect(estimateLine([live], real.getTime())).toContain("went live");
260 }, 30_000);
261
262 test("an issue closed as split or dropped voids what it left open; done does not", async () => {
263 const split = issue("too big");
264 const dropped = issue("not wanted");
265 for (const t of [split, dropped]) expect(say(t.number, "merged", at(H), at(2 * H)).ok).toBe(true);
266 const a = await setIssueStateThrough(app, alice, repo, split, "closed", { reason: "split", into: [issue("half").number] });
267 expect(a.ok).toBe(true);
268 const b = await setIssueStateThrough(app, alice, repo, dropped, "closed", { reason: "dropped" });
269 expect(b.ok).toBe(true);
270 expect(estimatesOf(app.db, split.id)[0]).toMatchObject({ state: "void" });
271 expect(estimatesOf(app.db, dropped.id)[0]!.voidedAt).not.toBeNull();
272 // Closed as done, the promise is kept to be resolved by the merge; nothing voids it.
273 const finished = issue("finished by hand");
274 expect(say(finished.number, "merged", at(H), at(2 * H)).ok).toBe(true);
275 const c = await setIssueStateThrough(app, alice, repo, finished, "closed", { reason: "done" });
276 expect(c.ok).toBe(true);
277 expect(estimatesOf(app.db, finished.id)[0]).toMatchObject({ state: "open", voidedAt: null });
278 const voidRow = scoreboard(estimatesVisibleTo(app.db, alice)).find((s) => s.who === "alice" && s.group === "")!;
279 expect(voidRow.void).toBeGreaterThanOrEqual(2);
280 });
281
282 test("the API asks and records, and the issue page and /next carry the line", async () => {
283 const t = issue("api and page");
284 const url = `${servers.url}/api/v1/repos/alice/forge/issues/${t.number}/estimates`;
285 const asked = await fetch(url, { headers: { authorization: `Bearer ${rw}` } });
286 expect(asked.status).toBe(200);
287 expect(((await asked.json()) as any).base.basis).toBeString();
288 const soon = (h: number) => new Date(Date.now() + h * H).toISOString();
289 const bad = await fetch(url, { method: "POST", headers: { authorization: `Bearer ${rw}`, "content-type": "application/json" }, body: JSON.stringify({ event: "live", p50: soon(3), p90: soon(1) }) });
290 expect(bad.status).toBe(422);
291 const ok = await fetch(url, { method: "POST", headers: { authorization: `Bearer ${rw}`, "content-type": "application/json" }, body: JSON.stringify({ event: "live", p50: soon(3), p90: soon(5), basis: "api" }) });
292 expect(ok.status).toBe(201);
293 const page = await (await fetch(`${servers.url}/alice/forge/issues/${t.number}`)).text();
294 expect(page).toContain("live by");
295 expect(page).toContain("· alice");
296 const next = await (await fetch(`${servers.url}/alice/forge/next`)).text();
297 expect(next).toContain("live by");
298 });
299
300 test("the MCP tool asks with no times and records with them", async () => {
301 const t = issue("mcp");
302 const call = async (args: unknown) => {
303 const res = await fetch(`${servers.url}/mcp`, {
304 method: "POST",
305 headers: { "content-type": "application/json", accept: "application/json, text/event-stream", authorization: `Bearer ${rw}` },
306 body: JSON.stringify({ jsonrpc: "2.0", id: 1, method: "tools/call", params: { name: "estimate", arguments: args } }),
307 });
308 const body = (await res.json()) as any;
309 return { error: body.result.isError === true, value: body.result.isError === true ? body.result.content[0].text : JSON.parse(body.result.content[0].text) };
310 };
311 const asked = await call({ repo: "alice/forge", issue: t.number });
312 expect(asked.error).toBe(false);
313 expect(asked.value.base).toBeDefined();
314 const soon = (h: number) => new Date(Date.now() + h * H).toISOString();
315 const made = await call({ repo: "alice/forge", issue: t.number, event: "merged", p50: soon(1), p90: soon(2), basis: "mcp" });
316 expect(made.error).toBe(false);
317 const refused = await call({ repo: "alice/forge", issue: t.number, event: "merged", p50: soon(2), p90: soon(1) });
318 expect(refused.error).toBe(true);
319 });
320 });
321
322 describe("who sees what, and how much is read", () => {
323 let dir: string;
324 let a: App;
325 let owner: User;
326 let outsider: User;
327 let hidden: Repo;
328 let shown: Repo;
329
330 beforeAll(async () => {
331 dir = await mkdtemp(join(tmpdir(), "forge-estimates-scope-"));
332 a = createApp({ dataDir: dir, port: 0 });
333 const o = await createUser(a.db, { origin: "seed", trusted: true, username: "olive", email: "[email protected]", password: "a-good-passphrase", adminIfFirst: true });
334 const x = await createUser(a.db, { origin: "seed", trusted: true, username: "xavier", email: "[email protected]", password: "a-good-passphrase" });
335 if (!o.ok || !x.ok) throw new Error("users");
336 owner = o.value;
337 outsider = x.value;
338 const priv = await createRepo(a, owner, { name: "vault", visibility: "private" });
339 const pub = await createRepo(a, owner, { name: "open", visibility: "public" });
340 if (!priv.ok || !pub.ok) throw new Error("repos");
341 hidden = priv.value;
342 shown = pub.value;
343 }, 30_000);
344 afterAll(async () => {
345 a.close();
346 await rm(dir, { recursive: true, force: true, maxRetries: 20, retryDelay: 100 });
347 });
348
349 const promise = (repo: Repo, title: string) => {
350 const made = createIssue(a.db, { repoId: repo.id, authorId: owner.id, title, body: "" });
351 if (!made.ok) throw new Error("issue");
352 const soon = (h: number) => new Date(Date.now() + h * H).toISOString();
353 const r = recordEstimate(a, owner, repo, made.value, { event: "merged", p50: soon(1), p90: soon(2) });
354 if (!r.ok) throw new Error(r.error.message);
355 return made.value;
356 };
357
358 test("the scorecards leave out repositories the viewer cannot read", () => {
359 promise(hidden, "secret plan");
360 promise(shown, "public plan");
361 const sees = (u: User | undefined) => estimatesVisibleTo(a.db, u).map((e) => e.repoId);
362 expect(sees(undefined)).toEqual([shown.id]);
363 expect(sees(outsider)).toEqual([shown.id]);
364 expect(new Set(sees(owner))).toEqual(new Set([hidden.id, shown.id]));
365 // The asker's own card reads the same scope: no private estimate of
366 // someone else's is in it.
367 expect(estimatesVisibleTo(a.db, outsider, owner.id).map((e) => e.repoId)).toEqual([shown.id]);
368 });
369
370 test("a list names its issues and reads only theirs", () => {
371 const one = promise(shown, "listed");
372 promise(shown, "not listed");
373 const got = currentByNumber(a.db, shown.id, [one.number]);
374 expect([...got.keys()]).toEqual([one.number]);
375 expect(currentByNumber(a.db, shown.id, [])).toEqual(new Map());
376 });
377
378 test("moving an issue to Not now voids its open estimates; a duplicate is a drop", async () => {
379 const gate = openIssue(a, owner, shown, { title: "Later gate: not now" });
380 expect(gate.ok).toBe(true);
381 const t = promise(shown, "to defer");
382 const { rankIssue } = await import("../src/actions/next.ts");
383 const moved = rankIssue(a, owner, shown, t.number, { to: "not_now" });
384 expect(moved.ok).toBe(true);
385 expect(estimatesOf(a.db, t.id)[0]).toMatchObject({ state: "void" });
386 const dup = promise(shown, "a duplicate");
387 const closed = await setIssueStateThrough(a, owner, shown, dup, "closed", { reason: "dropped", note: "duplicate of another" });
388 expect(closed.ok).toBe(true);
389 expect(estimatesOf(a.db, dup.id)[0]).toMatchObject({ state: "void" });
390 });
391
392 test("the terminal door: a reader asks, only a writer records, a private repository shows nothing", async () => {
393 const t = promise(shown, "on the screen");
394 const session = (user: User | undefined) => ({ user, db: a.db, app: a }) as unknown as Session;
395 const run = async (user: User | undefined, repo: Repo, key: string) => {
396 const pushed: unknown[] = [];
397 let drawn = 0;
398 const ctx = { toast() {}, invalidate: () => drawn++, push: (x: unknown) => pushed.push(x), theme: {} } as unknown as TuiCtx;
399 const screen = estimatesScreen(session(user), ctx, repo);
400 await screen.load?.(new AbortController().signal);
401 const before = drawn;
402 screen.key?.(keyFor(key)!);
403 return { pushed: pushed.length, drew: drawn - before };
404 };
405 expect(t.number).toBeGreaterThan(0);
406 // Asking redraws with the answer for anyone who can read; recording
407 // opens the prompt for a writer only.
408 expect((await run(outsider, shown, "a")).drew).toBe(1);
409 expect((await run(outsider, shown, "r")).pushed).toBe(0);
410 expect((await run(owner, shown, "r")).pushed).toBe(1);
411 // The private repository lists nothing to an outsider: no ask, no record.
412 expect(await run(outsider, hidden, "a")).toEqual({ pushed: 0, drew: 0 });
413 expect(await run(outsider, hidden, "r")).toEqual({ pushed: 0, drew: 0 });
414 expect(parseEstimate("merged 2026-11-14T07:15:00Z 2026-11-14T11:00:00Z a small change")).toEqual({ event: "merged", p50: "2026-11-14T07:15:00Z", p90: "2026-11-14T11:00:00Z", basis: "a small change" });
415 expect(parseEstimate("merged only")).toBeUndefined();
416 });
417 });
test/map.test.ts +1
@@ -60,6 +60,7 @@ function paperIssue(n: number, state: "open" | "closed" = "open"): Issue {
6060 assigneeIsAgent: false,
6161 authorIsAgent: false,
6262 sealed: false,
63 estimated: false,
6364 };
6465 }
6566
test/mcp.test.ts +1
@@ -382,6 +382,7 @@ describe("the forge over MCP", () => {
382382 "ssh_keys",
383383 "steer",
384384 "email_verify",
385 "estimate",
385386 "parent_unlink",
386387 "vision",
387388 "boards",
test/pages-budget.test.ts +1
@@ -56,6 +56,7 @@ const NOT_PAGES = new Set([
5656 /** Patterns with an id in them, and the path the fixture fills them with. */
5757 const FIXTURE: Record<string, string> = {
5858 "/:owner/:name/issues/:number": "/alice/forge/issues/1",
59 "/api/v1/repos/:owner/:name/issues/:number/estimates": "/api/v1/repos/alice/forge/issues/1/estimates",
5960 "/:owner/:name/pulls/:number": "/alice/forge/pulls/2",
6061 "/:owner/:name/display": "/alice/forge/display",
6162 "/api/v1/admin/machines/:machine/disks": "/api/v1/admin/machines/rack1/disks",
test/palette-roster.test.ts +4
@@ -70,6 +70,7 @@ test("a site-admin person is offered the whole roster", () => {
7070 ["pulls", "Pull requests in a repository", "owner/name"],
7171 ["milestones", "Milestones in a repository", "owner/name"],
7272 ["next-up", "Next up in a repository", "owner/name"],
73 ["estimates", "Estimates in a repository", "owner/name"],
7374 ["week", "The week in a repository", "owner/name"],
7475 ["flows", "Flows of a repository", "owner/name"],
7576 ["findings", "Findings in a repository", "owner/name"],
@@ -232,6 +233,7 @@ test("a site-admin agent is offered the roster without the person-only entries",
232233 ["pulls", "Pull requests in a repository", "owner/name"],
233234 ["milestones", "Milestones in a repository", "owner/name"],
234235 ["next-up", "Next up in a repository", "owner/name"],
236 ["estimates", "Estimates in a repository", "owner/name"],
235237 ["week", "The week in a repository", "owner/name"],
236238 ["flows", "Flows of a repository", "owner/name"],
237239 ["findings", "Findings in a repository", "owner/name"],
@@ -320,6 +322,7 @@ test("a member person is offered the signed-in roster without the admin entries"
320322 ["pulls", "Pull requests in a repository", "owner/name"],
321323 ["milestones", "Milestones in a repository", "owner/name"],
322324 ["next-up", "Next up in a repository", "owner/name"],
325 ["estimates", "Estimates in a repository", "owner/name"],
323326 ["week", "The week in a repository", "owner/name"],
324327 ["flows", "Flows of a repository", "owner/name"],
325328 ["findings", "Findings in a repository", "owner/name"],
@@ -403,6 +406,7 @@ test("signed out, the palette offers sign-in and the public entries", () => {
403406 ["pulls", "Pull requests in a repository", "owner/name"],
404407 ["milestones", "Milestones in a repository", "owner/name"],
405408 ["next-up", "Next up in a repository", "owner/name"],
409 ["estimates", "Estimates in a repository", "owner/name"],
406410 ["week", "The week in a repository", "owner/name"],
407411 ["flows", "Flows of a repository", "owner/name"],
408412 ["findings", "Findings in a repository", "owner/name"],
test/route-timing-coverage.test.ts +1
@@ -21,6 +21,7 @@ import { seedAdmin } from "./helpers/admin.ts";
2121 const UNTIMED: Record<string, string> = {
2222 "/api/v1/conversations": "a person's conversation list, one indexed query; no page to phase",
2323 "/api/v1/admin/machines/:machine/disks": "an owner's per-machine disk summary, one indexed read; no page to phase",
24 "/api/v1/repos/:owner/:name/issues/:number/estimates": "an issue's base rate from one indexed read of its history; no page to phase",
2425 "/metrics": "Prometheus text exposition, scraped by token; not a page read",
2526 };
2627

↑↓ navigate ↵ open ⌘↵ its app esc close

Keyboard shortcuts

Docs