Public: anyone can read this; the crew can change it.
Estimates are recorded against an issue, start from the forge's own history,… #11964
Check · Review · Land · Live done — Landed and live in production.
torque/forge:torque/11955-estimates-are-recorded-against-an-issue → main 12 commits fork 2 threads · 2 unresolved
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 | ||
| 161 | 161 | `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. |
| 162 | 162 | |
| 163 | 163 | 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`; | ||
| 98 | 98 | | `edit` | — | Retitle or rewrite an issue or pull: yours, or where you write; an agent only its own | 310 | |
| 99 | 99 | | `egress_grants` | — | Private destinations and grants: list, grant or revoke (site admin) | 289 | |
| 100 | 100 | | `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 | | |
| 101 | 102 | | `events` | — | Repository events | 259 | |
| 102 | 103 | | `explore` | — | The apps, people (and, for an admin, forges) you can see, ranked | 627 | |
| 103 | 104 | | `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 @@ | ||
| 6 | 6 | */ |
| 7 | 7 | import type { App } from "../app.ts"; |
| 8 | 8 | import { addDependency, repoGraph } from "../core/dependencies.ts"; |
| 9 | import { voidOpen } from "../core/estimates.ts"; | |
| 9 | 10 | import { getIssue, type Issue } from "../core/issues.ts"; |
| 10 | 11 | import { isStructural, pillarsOf } from "../core/pillars.ts"; |
| 11 | 12 | import { clearRank, lift, MAX_RANKED, ranksInRepo, writeOrder } from "../core/rank.ts"; |
| @@ -79,6 +80,8 @@ export function rankIssue( | ||
| 79 | 80 | const linked = addDependency(app.db, issue, set.gate!, actor.id); |
| 80 | 81 | if (!linked.ok) return invalid(NEXT_INVALID); |
| 81 | 82 | clearRank(app.db, issue.id); |
| 83 | // Not now: what it promised is void, not scored (#11955). | |
| 84 | voidOpen(app.db, issue.id); | |
| 82 | 85 | return ok(undefined); |
| 83 | 86 | } |
| 84 | 87 | lift(app.db, repo, issue.id); |
src/app.ts +4
| @@ -4,6 +4,7 @@ | ||
| 4 | 4 | * side-effect-contained, so tests boot real forges by the dozen. |
| 5 | 5 | */ |
| 6 | 6 | |
| 7 | import { settleEstimates } from "./actions/estimates_settle.ts"; | |
| 7 | 8 | import { ownHost, setOwnHost } from "./core/trusted_sources.ts"; |
| 8 | 9 | import { commitIdentity } from "./core/commit_identity.ts"; |
| 9 | 10 | import { getUserById } from "./core/users.ts"; |
| @@ -258,6 +259,9 @@ export function createApp(config: ForgeConfig): App { | ||
| 258 | 259 | // sends them. Listener throws are swallowed by the bus, so this can never |
| 259 | 260 | // break the action that produced the event. |
| 260 | 261 | 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)); | |
| 261 | 265 | // A push, a merge, a repository change or a build event may move pins: the kept listings go (`git/pins_kept.ts`). |
| 262 | 266 | bus.subscribe((event) => { |
| 263 | 267 | 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 | ||
| 159 | 159 | export { route as getApiV1ReposOwnerNameIssuesNumber } from "./getApiV1ReposOwnerNameIssuesNumber.ts"; |
| 160 | 160 | export { route as getApiV1ReposOwnerNameIssuesNumberComments } from "./getApiV1ReposOwnerNameIssuesNumberComments.ts"; |
| 161 | 161 | export { route as getApiV1ReposOwnerNameIssuesNumberDependencies } from "./getApiV1ReposOwnerNameIssuesNumberDependencies.ts"; |
| 162 | export { route as getApiV1ReposOwnerNameIssuesNumberEstimates } from "./getApiV1ReposOwnerNameIssuesNumberEstimates.ts"; | |
| 162 | 163 | export { route as getApiV1ReposOwnerNameIssuesNumberPlan } from "./getApiV1ReposOwnerNameIssuesNumberPlan.ts"; |
| 163 | 164 | export { route as getApiV1ReposOwnerNameIssuesNumberTeams } from "./getApiV1ReposOwnerNameIssuesNumberTeams.ts"; |
| 164 | 165 | export { route as getApiV1ReposOwnerNameIterations } from "./getApiV1ReposOwnerNameIterations.ts"; |
| @@ -346,6 +347,7 @@ export { route as postApiV1ReposOwnerNameIssues } from "./postApiV1ReposOwnerNam | ||
| 346 | 347 | export { route as postApiV1ReposOwnerNameIssuesNumberComments } from "./postApiV1ReposOwnerNameIssuesNumberComments.ts"; |
| 347 | 348 | export { route as postApiV1ReposOwnerNameIssuesNumberDependencies } from "./postApiV1ReposOwnerNameIssuesNumberDependencies.ts"; |
| 348 | 349 | export { route as postApiV1ReposOwnerNameIssuesNumberDependenciesBlockerConfirm } from "./postApiV1ReposOwnerNameIssuesNumberDependenciesBlockerConfirm.ts"; |
| 350 | export { route as postApiV1ReposOwnerNameIssuesNumberEstimates } from "./postApiV1ReposOwnerNameIssuesNumberEstimates.ts"; | |
| 349 | 351 | export { route as postApiV1ReposOwnerNameIssuesNumberPrivate } from "./postApiV1ReposOwnerNameIssuesNumberPrivate.ts"; |
| 350 | 352 | export { route as postApiV1ReposOwnerNameJobsIdAction } from "./postApiV1ReposOwnerNameJobsIdAction.ts"; |
| 351 | 353 | 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([ | ||
| 56 | 56 | // the admins' (`repo.upstream_follow`). |
| 57 | 57 | "repo.following", |
| 58 | 58 | "issue.labeled", |
| 59 | "issue.estimated", | |
| 59 | 60 | "issue.first", |
| 60 | 61 | "issue.ranked", |
| 61 | 62 | "week.retro", |
src/core/issues.ts +6 −1
| @@ -66,6 +66,8 @@ export interface Issue { | ||
| 66 | 66 | * (`core/sealed.ts`). |
| 67 | 67 | */ |
| 68 | 68 | readonly sealed: boolean; |
| 69 | /** Whether any estimate was ever recorded against it (#11955); none, no read. */ | |
| 70 | readonly estimated: boolean; | |
| 69 | 71 | } |
| 70 | 72 | |
| 71 | 73 | interface IssueRow { |
| @@ -90,11 +92,13 @@ interface IssueRow { | ||
| 90 | 92 | assignee_is_agent: number | null; |
| 91 | 93 | author_is_agent: number; |
| 92 | 94 | sealed: number; |
| 95 | estimated: number; | |
| 93 | 96 | } |
| 94 | 97 | |
| 95 | 98 | const SELECT = ` |
| 96 | 99 | 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 | |
| 98 | 102 | FROM issue JOIN user ON user.id = issue.author_id |
| 99 | 103 | LEFT JOIN user AS assignee ON assignee.id = issue.assignee_id`; |
| 100 | 104 | |
| @@ -128,6 +132,7 @@ function toIssue(row: IssueRow): Issue { | ||
| 128 | 132 | assigneeIsAgent: row.assignee_is_agent === 1, |
| 129 | 133 | authorIsAgent: row.author_is_agent === 1, |
| 130 | 134 | sealed: row.sealed === 1, |
| 135 | estimated: row.estimated === 1, | |
| 131 | 136 | }; |
| 132 | 137 | } |
| 133 | 138 |
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 { | ||
| 369 | 369 | const MCP_SIZE_RULE = |
| 370 | 370 | "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."; |
| 371 | 371 | |
| 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 | ||
| 372 | 379 | /** A plan on the issue is how a person sees where a run is without reading its log; only the forge serves the tool. */ |
| 373 | 380 | const PLAN_RULE = |
| 374 | 381 | "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 { | ||
| 475 | 482 | if (input.pull === undefined && isForge(input.repo) && namesMcp(issue.title, issue.body)) |
| 476 | 483 | out.push("", MCP_SIZE_RULE); |
| 477 | 484 | if (input.pull === undefined && isForge(input.repo)) out.push("", PLAN_RULE); |
| 485 | if (input.pull === undefined && isForge(input.repo)) out.push("", ESTIMATE_RULE); | |
| 478 | 486 | out.push( |
| 479 | 487 | "", |
| 480 | 488 | `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 @@ | ||
| 7 | 7 | |
| 8 | 8 | import { holds, weakEtag } from "../etag.ts"; |
| 9 | 9 | 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"; | |
| 10 | 12 | import { confirmIssue, dependencyWords, linkIssue, unlinkIssue } from "../../actions/dependencies.ts"; |
| 11 | 13 | import { |
| 12 | 14 | addCommentThrough, |
| @@ -327,6 +329,27 @@ export function issueRoutes(router: Router<Handler>): void { | ||
| 327 | 329 | return json({ repo: there, clone: `${ctx.url.origin}/${there}.git` }, 201); |
| 328 | 330 | }); |
| 329 | 331 | |
| 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 | }); | |
| 330 | 353 | router.add( |
| 331 | 354 | "GET", |
| 332 | 355 | "/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"; | ||
| 57 | 57 | export { tool as edit } from "./edit.ts"; |
| 58 | 58 | export { tool as egress_grants } from "./egress_grants.ts"; |
| 59 | 59 | export { tool as email_verify } from "./email_verify.ts"; |
| 60 | export { tool as estimate } from "./estimate.ts"; | |
| 60 | 61 | export { tool as events } from "./events.ts"; |
| 61 | 62 | export { tool as explore } from "./explore.ts"; |
| 62 | 63 | export { tool as family } from "./family.ts"; |
src/tui/forge.ts +7
| @@ -101,6 +101,7 @@ import { stalledScreen } from "./screens/stalled.ts"; | ||
| 101 | 101 | import { issuesScreen, newIssueScreen } from "./screens/issues.ts"; |
| 102 | 102 | import { limbsScreen } from "./screens/limbs.ts"; |
| 103 | 103 | import { nextScreen } from "./screens/next.ts"; |
| 104 | import { estimatesScreen } from "./screens/estimates.ts"; | |
| 104 | 105 | import { findingsScreen } from "./screens/findings.ts"; |
| 105 | 106 | import { flowsScreen } from "./screens/flows.ts"; |
| 106 | 107 | import { weekScreen } from "./screens/week.ts"; |
| @@ -177,6 +178,12 @@ const REPO_VERBS: readonly { | ||
| 177 | 178 | title: "Next up in a repository", |
| 178 | 179 | open: (f, ctx, repo) => nextScreen(f, ctx, repo), |
| 179 | 180 | }, |
| 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 | }, | |
| 180 | 187 | { |
| 181 | 188 | // The retrospective the forge wrote at the last weekly reset, and its proposals (#8704). |
| 182 | 189 | 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> | |
| 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"; | ||
| 24 | 24 | import { readDrifts } from "../../flow/alarm_drift.ts"; |
| 25 | 25 | import { readHandoverLists } from "../../flow/handover_read.ts"; |
| 26 | 26 | import { readWaiting, waitingSection } from "./flow-waiting.ts"; |
| 27 | import { estimatesSection } from "./flow-estimates.ts"; | |
| 27 | 28 | import { clogSection, readClogView } from "./flow-clog.ts"; |
| 28 | 29 | import { cpuStopsSection } from "./flow-cpu.ts"; |
| 29 | 30 | import { cpuClosedPulls } from "../../flow/cpu_closed.ts"; |
| @@ -108,6 +109,7 @@ export async function flowPage(ctx: Ctx): Promise<Response> { | ||
| 108 | 109 | ${clogSection(readClogView(ctx.app.db))} |
| 109 | 110 | ${cpuStopsSection(cpuStops(ctx.app.db).stopped, cpuClosedPulls(ctx.app.db, new Date()))} |
| 110 | 111 | ${waitingSection(await readWaiting(ctx.app.db, ctx.app.reposRoot, new Date()))} |
| 112 | ${estimatesSection(ctx.app.db, ctx.auth.user)} | |
| 111 | 113 | ${pipesSvg(p)} |
| 112 | 114 | <p class="muted small">${money(p.usd)} spent in the last 7 days; ${plural(p.shipped, "pull")} shipped.</p> |
| 113 | 115 | ${handoverSection(readHandoverLists(ctx.app.db, await readDrifts(ctx.app), Date.now()))} |
src/web/pages/next.ts +8 −2
| @@ -7,6 +7,8 @@ | ||
| 7 | 7 | * beside each action (`j`/`k` choose the row), which post the same form. |
| 8 | 8 | */ |
| 9 | 9 | 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"; | |
| 10 | 12 | import { nextUp, TOP, type NextRow } from "../../core/next_up.ts"; |
| 11 | 13 | import { openPullStages } from "../../flow/read.ts"; |
| 12 | 14 | import { stateWord, type StateFacts } from "./next_state.ts"; |
| @@ -157,6 +159,7 @@ function line( | ||
| 157 | 159 | position: number | null = null, |
| 158 | 160 | state: string | null = null, |
| 159 | 161 | mine = false, |
| 162 | promise: string | undefined = undefined, | |
| 160 | 163 | ): Html { |
| 161 | 164 | const r = rows[at]!; |
| 162 | 165 | const n = r.issue.number; |
| @@ -175,7 +178,7 @@ function line( | ||
| 175 | 178 | title: r.issue.title, |
| 176 | 179 | titleAttr: r.issue.title, |
| 177 | 180 | 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}`}`, | |
| 179 | 182 | nav: true, |
| 180 | 183 | data: { number: String(n), ...(cost === undefined ? {} : { cost: String(cost) }) }, |
| 181 | 184 | ...(controls === null ? {} : { after: controls }), |
| @@ -209,6 +212,9 @@ export function nextPage(ctx: Ctx): Response { | ||
| 209 | 212 | const { priced, cut, capacity, sentence } = weekView(ctx.app.db, repo.id, rows, new Date()); |
| 210 | 213 | const costs = rowCosts(priced); |
| 211 | 214 | 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); | |
| 212 | 218 | const facts = (r: NextRow): StateFacts => ({ stage: stages.get(r.issue.number) ?? "issue", waits: r.waits, held: r.state === "held", onIt: r.onIt !== null }); |
| 213 | 219 | // One numbered order, folded: the first TOP rows open, what comes after, then Later. |
| 214 | 220 | // A filter hides rows but keeps each one's place in the whole ranking. |
| @@ -220,7 +226,7 @@ export function nextPage(ctx: Ctx): Response { | ||
| 220 | 226 | <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}> |
| 221 | 227 | ${list.flatMap((at, i) => [ |
| 222 | 228 | ...(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]!)), | |
| 224 | 230 | ])} |
| 225 | 231 | </ul></details>`; |
| 226 | 232 | }; |
src/web/studio/tell.ts +14
| @@ -1,3 +1,4 @@ | ||
| 1 | import type { Database } from "bun:sqlite"; | |
| 1 | 2 | /** |
| 2 | 3 | * One tell in the studio: a tell is one sentence someone said, and the page |
| 3 | 4 | * answers three questions — who has it, what it is waiting on, and what the |
| @@ -50,6 +51,8 @@ import { repoDir } from "../../git/repos.ts"; | ||
| 50 | 51 | import type { Ctx } from "../../http/context.ts"; |
| 51 | 52 | import { crumbsFor, repoBase, repoHeader, repoPalette } from "../chrome.ts"; |
| 52 | 53 | import { html, when, type Html } from "../html.ts"; |
| 54 | import { currentOf, estimatesOf } from "../../core/estimates.ts"; | |
| 55 | import { estimateLine } from "../../flow/estimate_words.ts"; | |
| 53 | 56 | import { noteLine } from "./note.ts"; |
| 54 | 57 | import { threadLine } from "../describe.ts"; |
| 55 | 58 | import { page } from "../layout.ts"; |
| @@ -522,6 +525,7 @@ async function studioTell( | ||
| 522 | 525 | badge("Sealed security report", { tone: "danger", icon: "lock" }), |
| 523 | 526 | )} |
| 524 | 527 | ${workSpent(ctx, repo, issue.id)} |
| 528 | ${promiseLine(db, issue)} | |
| 525 | 529 | ${!direction && issue.state === "open" && canWrite(db, repo, user) |
| 526 | 530 | ? flowButton( |
| 527 | 531 | `${thread}/flow`, |
| @@ -687,3 +691,13 @@ function sealCard(ctx: Ctx, repo: Repo, issue: Issue, action: string): Html | nu | ||
| 687 | 691 | )} |
| 688 | 692 | </form>`; |
| 689 | 693 | } |
| 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", () => { | ||
| 122 | 122 | const NOT_ASKED = new Set([ |
| 123 | 123 | "access", "account_requests", "account_verify", "app_members", "app_notes", "asks_act", "asks_decide", "asks_proposal", "asks_undecide", "atlas", |
| 124 | 124 | "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", | |
| 126 | 126 | "follow_mode", "fork", "front_page", "hold", "issue_teams", "keep_plan", "knobs", "labels", "lift", "limb", "maintenance", "make_it_yours", "map", "merge", "new_app", |
| 127 | 127 | "next_up", "week", "notify", "open_pull", "org_create", "origin_apps", "pins", "plan", "profile", "propose_to_parent_forge", "repo_labels", "reports", |
| 128 | 128 | "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 { | ||
| 60 | 60 | assigneeIsAgent: false, |
| 61 | 61 | authorIsAgent: false, |
| 62 | 62 | sealed: false, |
| 63 | estimated: false, | |
| 63 | 64 | }; |
| 64 | 65 | } |
| 65 | 66 |
test/mcp.test.ts +1
| @@ -382,6 +382,7 @@ describe("the forge over MCP", () => { | ||
| 382 | 382 | "ssh_keys", |
| 383 | 383 | "steer", |
| 384 | 384 | "email_verify", |
| 385 | "estimate", | |
| 385 | 386 | "parent_unlink", |
| 386 | 387 | "vision", |
| 387 | 388 | "boards", |
test/pages-budget.test.ts +1
| @@ -56,6 +56,7 @@ const NOT_PAGES = new Set([ | ||
| 56 | 56 | /** Patterns with an id in them, and the path the fixture fills them with. */ |
| 57 | 57 | const FIXTURE: Record<string, string> = { |
| 58 | 58 | "/: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", | |
| 59 | 60 | "/:owner/:name/pulls/:number": "/alice/forge/pulls/2", |
| 60 | 61 | "/:owner/:name/display": "/alice/forge/display", |
| 61 | 62 | "/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", () => { | ||
| 70 | 70 | ["pulls", "Pull requests in a repository", "owner/name"], |
| 71 | 71 | ["milestones", "Milestones in a repository", "owner/name"], |
| 72 | 72 | ["next-up", "Next up in a repository", "owner/name"], |
| 73 | ["estimates", "Estimates in a repository", "owner/name"], | |
| 73 | 74 | ["week", "The week in a repository", "owner/name"], |
| 74 | 75 | ["flows", "Flows of a repository", "owner/name"], |
| 75 | 76 | ["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", | ||
| 232 | 233 | ["pulls", "Pull requests in a repository", "owner/name"], |
| 233 | 234 | ["milestones", "Milestones in a repository", "owner/name"], |
| 234 | 235 | ["next-up", "Next up in a repository", "owner/name"], |
| 236 | ["estimates", "Estimates in a repository", "owner/name"], | |
| 235 | 237 | ["week", "The week in a repository", "owner/name"], |
| 236 | 238 | ["flows", "Flows of a repository", "owner/name"], |
| 237 | 239 | ["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" | ||
| 320 | 322 | ["pulls", "Pull requests in a repository", "owner/name"], |
| 321 | 323 | ["milestones", "Milestones in a repository", "owner/name"], |
| 322 | 324 | ["next-up", "Next up in a repository", "owner/name"], |
| 325 | ["estimates", "Estimates in a repository", "owner/name"], | |
| 323 | 326 | ["week", "The week in a repository", "owner/name"], |
| 324 | 327 | ["flows", "Flows of a repository", "owner/name"], |
| 325 | 328 | ["findings", "Findings in a repository", "owner/name"], |
| @@ -403,6 +406,7 @@ test("signed out, the palette offers sign-in and the public entries", () => { | ||
| 403 | 406 | ["pulls", "Pull requests in a repository", "owner/name"], |
| 404 | 407 | ["milestones", "Milestones in a repository", "owner/name"], |
| 405 | 408 | ["next-up", "Next up in a repository", "owner/name"], |
| 409 | ["estimates", "Estimates in a repository", "owner/name"], | |
| 406 | 410 | ["week", "The week in a repository", "owner/name"], |
| 407 | 411 | ["flows", "Flows of a repository", "owner/name"], |
| 408 | 412 | ["findings", "Findings in a repository", "owner/name"], |
test/route-timing-coverage.test.ts +1
| @@ -21,6 +21,7 @@ import { seedAdmin } from "./helpers/admin.ts"; | ||
| 21 | 21 | const UNTIMED: Record<string, string> = { |
| 22 | 22 | "/api/v1/conversations": "a person's conversation list, one indexed query; no page to phase", |
| 23 | 23 | "/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", | |
| 24 | 25 | "/metrics": "Prometheus text exposition, scraped by token; not a page read", |
| 25 | 26 | }; |
| 26 | 27 |
new above
offline
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.