Public: anyone can read this; the crew can change it.
Cost ledger: an issue sums its pulls, and the board shows the median ask→live #10280
Check · Review · Land · Live done — Landed and live in production.
torque/forge:torque/1389-cost-ledger-an-issue-sums-its-pulls-and → main 12 commits fork 3 threads · 3 unresolved
8 files
changed +507
−9
docs/features/issue-cost.md added +34
| @@ -0,0 +1,34 @@ | ||
| 1 | # Issue cost: what an issue and its pulls spent | |
| 2 | ||
| 3 | An issue's page and its terminal screen say what the issue cost: the dollars | |
| 4 | its own agent runs spent plus the dollars spent on every pull whose | |
| 5 | description closes it. It is for people who write to the repository and want | |
| 6 | to know what a piece of work cost before they look at the whole ledger. | |
| 7 | ||
| 8 | - **Web** (`/<owner>/<repo>/issues/<n>`): under the Runs card, a list of rows. | |
| 9 | `Cost` is the total. When a closing pull spent anything, `This issue` | |
| 10 | shows the issue's own runs and one `Pull` row per pull follows, with the | |
| 11 | pull number linking to the pull. With no runs of its own but a closing pull | |
| 12 | that spent something, the Runs card is left out and the cost rows stay. | |
| 13 | - **Terminal** (open the issue): one line under the Crew card, for example | |
| 14 | `Cost $0.75: this issue $0.50, #12 $0.25`. | |
| 15 | ||
| 16 | ## Rules | |
| 17 | ||
| 18 | - A pull counts when its description closes the issue, read by | |
| 19 | `closesFromBody` (`src/core/pulls.ts`), in any state: `Closes #7` counts for | |
| 20 | #7 and not for #70. Runs are summed from `run.cost_usd`, in US dollars as | |
| 21 | recorded, rounded to a ten-thousandth. | |
| 22 | - A pull that closes two issues counts in both, so the issues' totals can add | |
| 23 | up to more than the repository spent. The repository's total is the usage | |
| 24 | ledger's. | |
| 25 | - Nothing is shown when nothing was spent. | |
| 26 | - The same gate applies on both doors (`issueCostFor`, | |
| 27 | `src/core/issue_cost.ts`): the viewer must read the repository's runs, see | |
| 28 | the owner's facts (as the runs list hides cost from other readers), and be | |
| 29 | allowed to see the issue. A sealed pull the viewer may not see is left out | |
| 30 | of the sum. A guest sees nothing. | |
| 31 | ||
| 32 | ## Not done | |
| 33 | ||
| 34 | There is no API or MCP field for the cost yet. |
src/core/issue_cost.ts added +112
| @@ -0,0 +1,112 @@ | ||
| 1 | /** | |
| 2 | * What an issue cost (#1389): its own agent runs plus the runs of the pulls | |
| 3 | * whose description closes it (`closesFromBody`, the forge's one reading of | |
| 4 | * "closes"). US dollars, the provider's list price as the run recorded it | |
| 5 | * (`run.cost_usd`). | |
| 6 | * | |
| 7 | * total = Σ cost of runs on the issue + Σ over closing pulls (Σ cost of runs on the pull) | |
| 8 | * | |
| 9 | * A pull that closes two issues counts in both, so the issues' totals may | |
| 10 | * add up to more than the forge spent; the repository's own total is the | |
| 11 | * usage ledger's. Sums are taken in 1/10 000 of a dollar to keep float | |
| 12 | * dust out of the line, as `core/efficiency.ts` does. | |
| 13 | * | |
| 14 | * Pure data: no authz. The caller has already decided the viewer may see | |
| 15 | * the issue. | |
| 16 | */ | |
| 17 | ||
| 18 | import type { Database } from "bun:sqlite"; | |
| 19 | import { closesFromBody } from "./pulls.ts"; | |
| 20 | import type { Repo } from "./repos.ts"; | |
| 21 | import { canSeeOwnerFacts } from "./repos.ts"; | |
| 22 | import { mayViewId } from "./sealed.ts"; | |
| 23 | import type { User } from "./users.ts"; | |
| 24 | ||
| 25 | export interface PullCost { | |
| 26 | readonly id: string; | |
| 27 | readonly number: number; | |
| 28 | readonly usd: number; | |
| 29 | } | |
| 30 | ||
| 31 | export interface IssueCost { | |
| 32 | /** Runs whose `issue_id` is the issue itself. */ | |
| 33 | readonly ownUsd: number; | |
| 34 | /** Closing pulls (any state), oldest first, each with its own runs' cost. */ | |
| 35 | readonly pulls: readonly PullCost[]; | |
| 36 | /** `ownUsd` plus every pull. */ | |
| 37 | readonly totalUsd: number; | |
| 38 | } | |
| 39 | ||
| 40 | const round4 = (n: number): number => Math.round(n * 1e4) / 1e4; | |
| 41 | ||
| 42 | export function issueCost(db: Database, repoId: string, issueId: string, number: number): IssueCost { | |
| 43 | const sum = db.query<{ usd: number }, [string]>( | |
| 44 | "SELECT COALESCE(SUM(cost_usd), 0) AS usd FROM run WHERE issue_id = ?1", | |
| 45 | ); | |
| 46 | const ownUsd = round4(sum.get(issueId)?.usd ?? 0); | |
| 47 | // The LIKE only narrows; `closesFromBody` decides. | |
| 48 | const candidates = db | |
| 49 | .query<{ id: string; number: number; body: string }, [string, string, string]>( | |
| 50 | `SELECT issue.id AS id, issue.number AS number, issue.body AS body | |
| 51 | FROM pull JOIN issue ON issue.id = pull.issue_id | |
| 52 | WHERE issue.repo_id = ?1 AND issue.id != ?2 AND issue.body LIKE ?3 | |
| 53 | ORDER BY issue.number`, | |
| 54 | ) | |
| 55 | .all(repoId, issueId, `%#${number}%`); | |
| 56 | const pulls: PullCost[] = []; | |
| 57 | for (const c of candidates) { | |
| 58 | if (!closesFromBody(c.body).includes(number)) continue; | |
| 59 | pulls.push({ id: c.id, number: c.number, usd: round4(sum.get(c.id)?.usd ?? 0) }); | |
| 60 | } | |
| 61 | return { ownUsd, pulls, totalUsd: round4(ownUsd + pulls.reduce((s, p) => s + p.usd, 0)) }; | |
| 62 | } | |
| 63 | ||
| 64 | /** | |
| 65 | * What this viewer may be told an issue cost: null when the cost is not | |
| 66 | * theirs to see (the issue is sealed from them, or they do not see the | |
| 67 | * owner's facts, as `runForReader` hides a run's cost from them) or when | |
| 68 | * nothing was spent. A closing pull sealed from them is left out of the sum. | |
| 69 | * The caller has already checked the viewer may read this repository's runs. | |
| 70 | */ | |
| 71 | export function issueCostFor( | |
| 72 | db: Database, | |
| 73 | repo: Repo, | |
| 74 | issue: { readonly id: string; readonly number: number }, | |
| 75 | viewer: User | undefined, | |
| 76 | ): IssueCost | null { | |
| 77 | if (!canSeeOwnerFacts(db, repo, viewer) || !mayViewId(db, repo, issue.id, viewer)) return null; | |
| 78 | const all = issueCost(db, repo.id, issue.id, issue.number); | |
| 79 | const pulls = all.pulls.filter((p) => mayViewId(db, repo, p.id, viewer)); | |
| 80 | const totalUsd = round4(all.ownUsd + pulls.reduce((s, p) => s + p.usd, 0)); | |
| 81 | return totalUsd === 0 ? null : { ownUsd: all.ownUsd, pulls, totalUsd }; | |
| 82 | } | |
| 83 | ||
| 84 | const usd2 = (n: number): string => `$${n.toFixed(2)}`; | |
| 85 | ||
| 86 | /** The cost in one plain line, for the terminal: "Cost $0.75: this issue $0.50, #12 $0.25". */ | |
| 87 | export function costLine(cost: IssueCost): string { | |
| 88 | const spent = cost.pulls.filter((p) => p.usd > 0); | |
| 89 | if (spent.length === 0) return `Cost ${usd2(cost.totalUsd)}`; | |
| 90 | const parts = [`this issue ${usd2(cost.ownUsd)}`, ...spent.map((p) => `#${p.number} ${usd2(p.usd)}`)]; | |
| 91 | return `Cost ${usd2(cost.totalUsd)}: ${parts.join(", ")}`; | |
| 92 | } | |
| 93 | ||
| 94 | /** One row of the cost: a label, the dollars, and the pull it is when it is one. */ | |
| 95 | export interface CostRow { | |
| 96 | readonly label: string; | |
| 97 | readonly usd: number; | |
| 98 | readonly pull?: number; | |
| 99 | } | |
| 100 | ||
| 101 | /** | |
| 102 | * The cost as rows both doors show: the total, then, when a pull spent | |
| 103 | * anything, this issue's own runs and each pull that did. | |
| 104 | */ | |
| 105 | export function costRows(cost: IssueCost): CostRow[] { | |
| 106 | const rows: CostRow[] = [{ label: "Cost", usd: cost.totalUsd }]; | |
| 107 | const spent = cost.pulls.filter((p) => p.usd > 0); | |
| 108 | if (spent.length === 0) return rows; | |
| 109 | rows.push({ label: "This issue", usd: cost.ownUsd }); | |
| 110 | for (const p of spent) rows.push({ label: "Pull", usd: p.usd, pull: p.number }); | |
| 111 | return rows; | |
| 112 | } |
src/tui/screens/issue.ts +15 −3
| @@ -35,7 +35,8 @@ import { THREAD_SAYS } from "../../actions/thread_words.ts"; | ||
| 35 | 35 | import { ISSUE_TEAMS_SAY, nameTeams } from "../../actions/issue_teams.ts"; |
| 36 | 36 | import { teamsOfIssue, TEAMS_MAX } from "../../core/issue_teams.ts"; |
| 37 | 37 | import { listTeams } from "../../core/teams.ts"; |
| 38 | import { listRepoRuns } from "../../actions/runs.ts"; | |
| 38 | import { costLine, issueCostFor, type IssueCost } from "../../core/issue_cost.ts"; | |
| 39 | import { listRepoRuns, mayReadRuns } from "../../actions/runs.ts"; | |
| 39 | 40 | import { readPlan } from "../../actions/run_plan.ts"; |
| 40 | 41 | import type { IssuePlan } from "../../core/issue_plans.ts"; |
| 41 | 42 | import { shownStates } from "../../runs/state.ts"; |
| @@ -167,6 +168,8 @@ export function issueScreen( | ||
| 167 | 168 | /** Closed-as-dropped-or-split blockers nobody has said may be passed. */ |
| 168 | 169 | let held: Issue[] = []; |
| 169 | 170 | let plan: IssuePlan | undefined; |
| 171 | /** What the issue and its closing pulls cost, when this viewer may see it. */ | |
| 172 | let cost: IssueCost | null = null; | |
| 170 | 173 | let shown: ReturnType<typeof headline>; |
| 171 | 174 | /** Which issues this one waits for and holds up; a line when any. */ |
| 172 | 175 | let waits: Waits = { blockedBy: [], blocks: [] }; |
| @@ -216,6 +219,10 @@ export function issueScreen( | ||
| 216 | 219 | blockedBy: deps?.blockedBy.map((i) => i.number) ?? [], |
| 217 | 220 | blocks: deps?.blocks.map((i) => i.number) ?? [], |
| 218 | 221 | }; |
| 222 | cost = | |
| 223 | repo === undefined || issueRow === undefined || !mayReadRuns(f.app, f.user, repo) | |
| 224 | ? null | |
| 225 | : issueCostFor(f.db, repo, issueRow, f.user); | |
| 219 | 226 | crew.set(runs !== undefined && runs.ok ? runs.value : []); |
| 220 | 227 | plan = |
| 221 | 228 | repo === undefined || issueRow === undefined ? undefined : readPlan(f.app, repo, f.user, issueRow); |
| @@ -590,14 +597,16 @@ export function issueScreen( | ||
| 590 | 597 | const planH = planRows.length === 0 ? 0 : Math.min(planRows.length, CREW_ROWS + 2) + 2; |
| 591 | 598 | const waitsLine = waitsParts(waits); |
| 592 | 599 | const headH = waitsLine.length === 0 ? 3 : 4; |
| 593 | const [strip, head, waitRow, crewRow, planRow, body, box] = split(area, "v", [ | |
| 600 | const costH = cost === null ? 0 : 1; | |
| 601 | const [strip, head, waitRow, crewRow, costRow, planRow, body, box] = split(area, "v", [ | |
| 594 | 602 | len(whereRows(src)), |
| 595 | 603 | len(headH), |
| 596 | 604 | len(held.length === 0 ? 0 : 1), |
| 597 | 605 | len(crewH), |
| 606 | len(costH), | |
| 598 | 607 | len(planH), |
| 599 | 608 | fill(1), |
| 600 | len(Math.min(foot, Math.max(0, area.h - 4 - headH - crewH - planH - whereRows(src) - (held.length === 0 ? 0 : 1)))), | |
| 609 | len(Math.min(foot, Math.max(0, area.h - 4 - headH - crewH - costH - planH - whereRows(src) - (held.length === 0 ? 0 : 1)))), | |
| 601 | 610 | ]); |
| 602 | 611 | whereStrip(s, strip!, c, f, src); |
| 603 | 612 | const right = head!.x + head!.w; |
| @@ -689,6 +698,9 @@ export function issueScreen( | ||
| 689 | 698 | }); |
| 690 | 699 | } |
| 691 | 700 | |
| 701 | if (cost !== null && costRow!.h > 0) | |
| 702 | s.text(costRow!.x, costRow!.y, truncate(costLine(cost), costRow!.w), style(c.fg, c.bg), costRow!.x + costRow!.w); | |
| 703 | ||
| 692 | 704 | if (planH > 0 && plan !== undefined) { |
| 693 | 705 | const inner = card(s, planRow!, c, { title: planTitle(plan, shown) }); |
| 694 | 706 | planRows.slice(0, inner.h).forEach((row, i) => { |
src/web/pages/board.ts +5 −1
| @@ -16,6 +16,7 @@ | ||
| 16 | 16 | * pointer. The old big view (`/big`, every address it had) lands here. |
| 17 | 17 | */ |
| 18 | 18 | |
| 19 | import { hoursSpan, readThroughput } from "../../flow/throughput.ts"; | |
| 19 | 20 | import { usd } from "../../core/money.ts"; |
| 20 | 21 | import { NO_CLOUD_BODY } from "../../core/placement_policy.ts"; |
| 21 | 22 | import { CLOUD_BODY_CONFIGURED } from "../../spawn/placement.ts"; |
| @@ -517,6 +518,7 @@ function returnsStrip(ctx: Ctx, scope: BoardScope): Html | null { | ||
| 517 | 518 | until: new Date(now).toISOString(), |
| 518 | 519 | }); |
| 519 | 520 | if (r.spent.runs === 0) return null; |
| 521 | const ask = readThroughput(ctx.app.db, [], new Date(now)).medianAskToLiveMs; | |
| 520 | 522 | const href = `/board/returns${apps ? `?apps=${encodeURIComponent(apps)}` : ""}`; |
| 521 | 523 | return html`<a class="muted small" id="returns-line" href="${href}" |
| 522 | 524 | >Tokens, ${ASKS_WINDOW_DAYS} days: <b>${usd(r.spent.usd)}</b> → <b>${r.landed}</b> landed |
| @@ -524,7 +526,9 @@ function returnsStrip(ctx: Ctx, scope: BoardScope): Html | null { | ||
| 524 | 526 | ? null |
| 525 | 527 | : html`· <b>${usd(r.onLanded.perChange.median)}</b> a landed change`} |
| 526 | 528 | · <b>${usd(r.landedNothing.closed.usd)}</b> closed with nothing |
| 527 | · <b>${usd(r.landedNothing.open.usd)}</b> still open</a | |
| 529 | · <b>${usd(r.landedNothing.open.usd)}</b> still open${ | |
| 530 | ask === null ? null : html` · median ask→live <b>${hoursSpan(ask)}</b> (24 h)` | |
Non-blocking: the line says Tokens, 7 days, then the median says (24 h). Two windows in one sentence read as one; say the window in plain words, e.g. "median ask→live today". |
||
| 531 | }</a | |
| 528 | 532 | >`; |
| 529 | 533 | } |
| 530 | 534 |
src/web/studio/tell.ts +19 −5
| @@ -8,6 +8,7 @@ | ||
| 8 | 8 | * one closed Details section. |
| 9 | 9 | */ |
| 10 | 10 | |
| 11 | import { costRows, issueCostFor, type IssueCost } from "../../core/issue_cost.ts"; | |
| 11 | 12 | import { notePhase, takeSessionTime } from "../../core/route_timing.ts"; |
| 12 | 13 | import { cardPath } from "../og/path.ts"; |
| 13 | 14 | import { mayEditIssue } from "../../actions/issues.ts"; |
| @@ -337,7 +338,8 @@ async function tellRuns( | ||
| 337 | 338 | issueId: issue.id, |
| 338 | 339 | limit: 10, |
| 339 | 340 | }); |
| 340 | if (runs.length === 0) return null; | |
| 341 | const cost = issueCostFor(wordCtx.db, repo, issue, wordCtx.viewer); | |
| 342 | if (runs.length === 0 && cost === null) return null; | |
| 341 | 343 | const base = repoBase(repo); |
| 342 | 344 | const actors = getUsersByIds( |
| 343 | 345 | wordCtx.db, |
| @@ -363,10 +365,22 @@ async function tellRuns( | ||
| 363 | 365 | }); |
| 364 | 366 | }), |
| 365 | 367 | ); |
| 366 | return listCard(rows, { | |
| 367 | head: html`${icon("bot")} | |
| 368 | <h2>Runs</h2>`, | |
| 369 | }); | |
| 368 | return html`${runs.length === 0 | |
| 369 | ? null | |
| 370 | : listCard(rows, { | |
| 371 | head: html`${icon("bot")} | |
| 372 | <h2>Runs</h2>`, | |
| 373 | })}${costBlock(cost, base)}`; | |
| 374 | } | |
| 375 | ||
| 376 | /** What the issue and its closing pulls cost, as `dl` rows; nothing when there is no cost to show. */ | |
| 377 | function costBlock(cost: IssueCost | null, base: string): Html | null { | |
| 378 | if (cost === null) return null; | |
| 379 | return html`<dl class="status-rows" data-issue-cost>${costRows(cost).map( | |
| 380 | (r) => html`<div class="status-row"><dt>${r.label}</dt><dd class="num">${ | |
| 381 | r.pull === undefined ? null : html`<a href="${base}/pulls/${r.pull}">#${r.pull}</a> ` | |
| 382 | }${costText(r.usd)}</dd></div>`, | |
| 383 | )}</dl>`; | |
| 370 | 384 | } |
| 371 | 385 | |
| 372 | 386 | /** The pull that closes this tell, with its check and a way into its |
test/issue-cost.test.ts added +165
| @@ -0,0 +1,165 @@ | ||
| 1 | /** | |
| 2 | * An issue sums its pulls (#1389): its cost is its own runs' dollars plus | |
| 3 | * those of every pull whose description closes it — worked by hand below: | |
| 4 | * $0.40 own + $1.25 on #2's pull + $0.35 on the second pull = $2.00; a pull | |
| 5 | * that names another issue adds nothing. | |
| 6 | */ | |
| 7 | ||
| 8 | import { afterAll, beforeAll, describe, expect, test } from "bun:test"; | |
| 9 | import { mkdtemp, rm } from "node:fs/promises"; | |
| 10 | import { tmpdir } from "node:os"; | |
| 11 | import { join } from "node:path"; | |
| 12 | import { openIssue } from "../src/actions/issues.ts"; | |
| 13 | import { createRepo } from "../src/actions/repos.ts"; | |
| 14 | import { createApp, type App } from "../src/app.ts"; | |
| 15 | import { costLine, costRows, issueCost, issueCostFor } from "../src/core/issue_cost.ts"; | |
| 16 | import type { Repo } from "../src/core/repos.ts"; | |
| 17 | import { createRun } from "../src/core/runs.ts"; | |
| 18 | import { createUser, type User } from "../src/core/users.ts"; | |
| 19 | import { serve, type Servers } from "../src/http/server.ts"; | |
| 20 | ||
| 21 | describe("issue cost roll-up", () => { | |
| 22 | let dataDir: string; | |
| 23 | let app: App; | |
| 24 | let servers: Servers; | |
| 25 | let alice: User; | |
| 26 | let repo: Repo; | |
| 27 | ||
| 28 | const open = (title: string, body = "") => { | |
| 29 | const r = openIssue(app, alice, repo, { title, body }); | |
| 30 | if (!r.ok) throw new Error(String(r.error)); | |
| 31 | return r.value; | |
| 32 | }; | |
| 33 | const asPull = (id: string) => { | |
| 34 | app.db.query("UPDATE issue SET kind = 'pull' WHERE id = ?1").run(id); | |
| 35 | app.db | |
| 36 | .query(`INSERT INTO pull (issue_id, base_branch, head_branch, base_oid, head_oid) VALUES (?1, ?2, 'x', '', '')`) | |
| 37 | .run(id, repo.defaultBranch); | |
| 38 | }; | |
| 39 | const spend = (issueId: string, usd: number) => { | |
| 40 | const r = createRun(app.db, { repoId: repo.id, kind: "agent", issueId }); | |
| 41 | app.db.query("UPDATE run SET cost_usd = ?1 WHERE id = ?2").run(usd, r.id); | |
| 42 | }; | |
| 43 | ||
| 44 | beforeAll(async () => { | |
| 45 | dataDir = await mkdtemp(join(tmpdir(), "forge-issue-cost-")); | |
| 46 | app = createApp({ dataDir, port: 0 }); | |
| 47 | servers = await serve(app); | |
| 48 | const a = await createUser(app.db, { | |
| 49 | origin: "seed", | |
| 50 | trusted: true, | |
| 51 | username: "alice", | |
| 52 | email: "[email protected]", | |
| 53 | password: "a-good-passphrase", | |
| 54 | adminIfFirst: true, | |
| 55 | }); | |
| 56 | if (!a.ok) throw new Error(a.error); | |
| 57 | alice = a.value; | |
| 58 | const made = await createRepo(app, alice, { name: "shop" }); | |
| 59 | if (!made.ok) throw new Error(String(made.error)); | |
| 60 | repo = made.value; | |
| 61 | }); | |
| 62 | afterAll(async () => { | |
| 63 | servers.stop(); | |
| 64 | await rm(dataDir, { recursive: true, force: true }); | |
| 65 | }); | |
| 66 | ||
| 67 | test("own runs plus the pulls that close it, and no others", () => { | |
| 68 | const one = open("One"); | |
| 69 | const two = open("Two"); | |
| 70 | spend(one.id, 0.1); | |
| 71 | spend(one.id, 0.3); | |
| 72 | const p1 = open("Fix two", `Closes #${two.number}`); | |
| 73 | asPull(p1.id); | |
| 74 | spend(p1.id, 1.25); | |
| 75 | const p2 = open("Fix two again", `fixes #${two.number}, and #${one.number} is only mentioned`); | |
| 76 | asPull(p2.id); | |
| 77 | spend(p2.id, 0.35); | |
| 78 | const other = open("Fix one", `Closes #9${one.number}9`); | |
| 79 | asPull(other.id); | |
| 80 | spend(other.id, 9); | |
| 81 | spend(two.id, 0.4); | |
| 82 | ||
| 83 | const cost = issueCost(app.db, repo.id, two.id, two.number); | |
| 84 | expect(cost.ownUsd).toBe(0.4); | |
| 85 | expect(cost.pulls).toEqual([ | |
| 86 | { id: p1.id, number: p1.number, usd: 1.25 }, | |
| 87 | { id: p2.id, number: p2.number, usd: 0.35 }, | |
| 88 | ]); | |
| 89 | expect(cost.totalUsd).toBe(2); | |
| 90 | // One's own is its runs only: no pull closes it ("#1" is not "#191"). | |
| 91 | expect(issueCost(app.db, repo.id, one.id, one.number)).toEqual({ ownUsd: 0.4, pulls: [], totalUsd: 0.4 }); | |
| 92 | }); | |
| 93 | ||
| 94 | const signin = async (username: string, password: string) => { | |
| 95 | const login = await fetch(`${servers.url}/signin`, { | |
| 96 | method: "POST", | |
| 97 | redirect: "manual", | |
| 98 | headers: { "content-type": "application/x-www-form-urlencoded", origin: servers.url }, | |
| 99 | body: new URLSearchParams({ username, password }), | |
| 100 | }); | |
| 101 | return (login.headers.get("set-cookie") ?? "").split(";")[0] ?? ""; | |
| 102 | }; | |
| 103 | const costDl = (html: string) => /<dl class="status-rows" data-issue-cost>([\s\S]*?)<\/dl>/.exec(html)?.[1]; | |
| 104 | const page = async (n: number, cookie = "") => | |
| 105 | (await fetch(`${servers.url}/alice/shop/issues/${n}`, { headers: { cookie } })).text(); | |
| 106 | ||
| 107 | test("the tell page shows the cost as rows, each pull a link", async () => { | |
| 108 | const t = open("Costly"); | |
| 109 | spend(t.id, 0.5); | |
| 110 | const p = open("Fix costly", `Closes #${t.number}`); | |
| 111 | asPull(p.id); | |
| 112 | spend(p.id, 0.25); | |
| 113 | const text = await page(t.number, await signin("alice", "a-good-passphrase")); | |
| 114 | const rows = [...(costDl(text) ?? "").matchAll(/<dt>(.*?)<\/dt><dd class="num">([\s\S]*?)<\/dd>/g)].map((m) => [ | |
| 115 | m[1], | |
| 116 | m[2]!.replace(/<a href="([^"]+)">(#\d+)<\/a>/g, "[$2 $1]").trim(), | |
| 117 | ]); | |
| 118 | expect(rows).toEqual([ | |
| 119 | ["Cost", "$0.75"], | |
| 120 | ["This issue", "$0.50"], | |
| 121 | ["Pull", `[#${p.number} /alice/shop/pulls/${p.number}] $0.25`], | |
| 122 | ]); | |
| 123 | expect(text).not.toContain("this tell"); | |
| 124 | }); | |
| 125 | ||
| 126 | test("with no runs of its own but a closing pull, the Runs card goes and the cost stays", async () => { | |
| 127 | const t = open("Quiet"); | |
| 128 | const p = open("Fix quiet", `Closes #${t.number}`); | |
| 129 | asPull(p.id); | |
| 130 | spend(p.id, 0.2); | |
| 131 | const text = await page(t.number, await signin("alice", "a-good-passphrase")); | |
| 132 | expect(text).not.toContain("<h2>Runs</h2>"); | |
| 133 | expect(costDl(text)).toContain("$0.20"); | |
| 134 | }); | |
| 135 | ||
| 136 | test("a guest and a reader who cannot write are shown no cost", async () => { | |
| 137 | const t = open("Private money"); | |
| 138 | spend(t.id, 0.9); | |
| 139 | const b = await createUser(app.db, { | |
| 140 | origin: "seed", | |
| 141 | trusted: true, | |
| 142 | username: "bob", | |
| 143 | email: "[email protected]", | |
| 144 | password: "a-good-passphrase", | |
| 145 | }); | |
| 146 | if (!b.ok) throw new Error(b.error); | |
| 147 | expect(costDl(await page(t.number))).toBeUndefined(); | |
| 148 | expect(costDl(await page(t.number, await signin("bob", "a-good-passphrase")))).toBeUndefined(); | |
| 149 | expect(issueCostFor(app.db, repo, t, undefined)).toBeNull(); | |
| 150 | expect(issueCostFor(app.db, repo, t, b.value)).toBeNull(); | |
| 151 | const mine = issueCostFor(app.db, repo, t, alice); | |
| 152 | expect(mine && costLine(mine)).toBe("Cost $0.90"); | |
| 153 | }); | |
| 154 | ||
| 155 | test("the terminal's line is the plain sentence", () => { | |
| 156 | const t = open("Words"); | |
| 157 | spend(t.id, 0.5); | |
| 158 | const p = open("Fix words", `Closes #${t.number}`); | |
| 159 | asPull(p.id); | |
| 160 | spend(p.id, 0.25); | |
| 161 | const c = issueCostFor(app.db, repo, t, alice)!; | |
| 162 | expect(costLine(c)).toBe(`Cost $0.75: this issue $0.50, #${p.number} $0.25`); | |
| 163 | expect(costRows(c).map((r) => r.label)).toEqual(["Cost", "This issue", "Pull"]); | |
| 164 | }); | |
| 165 | }); |
test/returns.test.ts +23
| @@ -326,6 +326,29 @@ describe("what the tokens bought", () => { | ||
| 326 | 326 | ).toBe(422); |
| 327 | 327 | }); |
| 328 | 328 | |
| 329 | test("the line carries the day's median ask→live beside the dollars once a production deploy served a landing", async () => { | |
| 330 | const before = await (await get("/board", { cookie: aliceCookie })).text(); | |
| 331 | expect(before).not.toContain("median ask→live"); | |
| 332 | const merged = app.db | |
| 333 | .query<{ merged_at: string }, []>("SELECT merged_at FROM pull WHERE merged_at IS NOT NULL ORDER BY merged_at DESC LIMIT 1") | |
| 334 | .get(); | |
| 335 | expect(merged).not.toBeNull(); | |
| 336 | // Served the instant it merged; the ask was exactly three hours before: 3.0 h. | |
| 337 | const at = merged!.merged_at; | |
| 338 | app.db | |
| 339 | .query( | |
| 340 | `UPDATE issue SET created_at = ?1 WHERE kind = 'issue' AND repo_id = ?3 AND number IN ( | |
| 341 | SELECT CAST(substr(i.body, 9) AS INTEGER) FROM pull p JOIN issue i ON i.id = p.issue_id WHERE p.merged_at = ?2)`, | |
| 342 | ) | |
| 343 | .run(new Date(Date.parse(at) - 3 * 3_600_000).toISOString(), at, repo.id); | |
| 344 | const dep = createRun(app.db, { repoId: repo.id, kind: "deploy", env: "production" }); | |
| 345 | app.db.query("UPDATE run SET state = 'ok', created_at = ?1, serving_at = ?1 WHERE id = ?2").run(at, dep.id); | |
| 346 | const board = await (await get("/board", { cookie: aliceCookie })).text(); | |
| 347 | const line = /<a class="muted small" id="returns-line"[^>]*>([\s\S]*?)<\/a>/.exec(board)?.[1]?.replace(/\s+/g, " "); | |
| 348 | expect(line).toContain("median ask→live <b>3.0 h</b> (24 h)"); | |
| 349 | expect(line).toContain("<b>$4.00</b> → <b>1</b> landed"); | |
| 350 | }); | |
| 351 | ||
| 329 | 352 | test("spend reported from outside shows apart and is never added in", async () => { |
| 330 | 353 | const post = (payload: unknown) => |
| 331 | 354 | fetch(`${servers.url}/api/v1/repos/alice/mill/spend`, { |
test/tui-issue-cost.test.ts added +134
| @@ -0,0 +1,134 @@ | ||
| 1 | /** | |
| 2 | * The terminal's issue screen shows what the issue and its closing pulls cost | |
| 3 | * (the same `issueCostFor` gate as the web page), and shows a viewer who may | |
| 4 | * not see it nothing. | |
| 5 | */ | |
| 6 | ||
| 7 | import { afterAll, beforeAll, describe, expect, test } from "bun:test"; | |
| 8 | import { mkdtemp, rm } from "node:fs/promises"; | |
| 9 | import { tmpdir } from "node:os"; | |
| 10 | import { join } from "node:path"; | |
| 11 | import { openIssue } from "../src/actions/issues.ts"; | |
| 12 | import { commitToBranch, createRepo } from "../src/actions/repos.ts"; | |
| 13 | import { createApp, type App } from "../src/app.ts"; | |
| 14 | import type { Issue } from "../src/core/issues.ts"; | |
| 15 | import { createRun } from "../src/core/runs.ts"; | |
| 16 | import { getRepoById, type Repo } from "../src/core/repos.ts"; | |
| 17 | import { createUser, type User } from "../src/core/users.ts"; | |
| 18 | import { createForgeApp } from "../src/tui/forge.ts"; | |
| 19 | import { issueScreen } from "../src/tui/screens/issue.ts"; | |
| 20 | import { local } from "../src/tui/screens/where.ts"; | |
| 21 | import { headlessTerminal } from "../src/tui/term/terminal.ts"; | |
| 22 | import { quietLogs } from "./helpers/globals.ts"; | |
| 23 | import { settleApp } from "./helpers/tui.ts"; | |
| 24 | ||
| 25 | quietLogs(); | |
| 26 | ||
| 27 | describe("the terminal's issue screen and its cost", () => { | |
| 28 | let dataDir = ""; | |
| 29 | let app: App; | |
| 30 | let alice: User; | |
| 31 | let bob: User; | |
| 32 | let repo: Repo; | |
| 33 | const fresh = () => getRepoById(app.db, repo.id)!; | |
| 34 | const newIssue = (title: string): Issue => { | |
| 35 | const o = openIssue(app, alice, repo, { title }); | |
| 36 | if (!o.ok) throw new Error(o.error); | |
| 37 | return o.value; | |
| 38 | }; | |
| 39 | ||
| 40 | /** A terminal signed in as `user`, on the issue screen of `number`. */ | |
| 41 | const open = async (user: User, number: number, where: Repo = fresh(), rows = 40) => { | |
| 42 | const terminal = headlessTerminal(140, rows); | |
| 43 | const { app: tui, session } = createForgeApp( | |
| 44 | app, | |
| 45 | { sshUsername: "t", remoteAddress: "127.0.0.1", user }, | |
| 46 | { terminal, exitHooks: false }, | |
| 47 | ); | |
| 48 | tui.start(); | |
| 49 | await settleApp(tui); | |
| 50 | tui.push(issueScreen(session, tui.ctx, local(session, where), number)); | |
| 51 | await settleApp(tui); | |
| 52 | const press = async (...keys: string[]) => { | |
| 53 | for (const key of keys) | |
| 54 | tui.dispatch({ type: "key", key, ctrl: false, alt: false, shift: false }); | |
| 55 | await settleApp(tui); | |
| 56 | }; | |
| 57 | const type = (text: string) => press(...text); | |
| 58 | const text = () => tui.screen.toText(); | |
| 59 | /** Click the first cell of `needle` on the row that also holds `row`. */ | |
| 60 | const click = async (row: string, needle: string) => { | |
| 61 | const rows = text().split("\n"); | |
| 62 | const y = rows.findIndex((r) => r.includes(row) && r.includes(needle)); | |
| 63 | if (y < 0) throw new Error(`no row with ${row} and ${needle}:\n${text()}`); | |
| 64 | const x = rows[y]!.indexOf(needle, rows[y]!.indexOf(row)); | |
| 65 | tui.dispatch({ type: "mouse", kind: "press", button: 0, x, y, ctrl: false, alt: false, shift: false }); | |
| 66 | tui.dispatch({ type: "mouse", kind: "release", button: 0, x, y, ctrl: false, alt: false, shift: false }); | |
| 67 | await settleApp(tui); | |
| 68 | }; | |
| 69 | return { tui, press, type, text, click, stop: () => tui.stop() }; | |
| 70 | }; | |
| 71 | ||
| 72 | beforeAll(async () => { | |
| 73 | dataDir = await mkdtemp(join(tmpdir(), "forge-tui-issue-cost-")); | |
| 74 | app = createApp({ dataDir, port: 0 }); | |
| 75 | for (const username of ["alice", "bob"]) { | |
| 76 | const made = await createUser(app.db, { | |
| 77 | origin: "seed", | |
| 78 | trusted: true, | |
| 79 | username, | |
| 80 | email: `${username}@forge.local`, | |
| 81 | password: "a-good-passphrase", | |
| 82 | adminIfFirst: username === "alice", | |
| 83 | }); | |
| 84 | if (!made.ok) throw new Error(made.error); | |
| 85 | if (username === "alice") alice = made.value; | |
| 86 | else bob = made.value; | |
| 87 | } | |
| 88 | const r = await createRepo(app, alice, { name: "garden", visibility: "public" }); | |
| 89 | if (!r.ok) throw new Error(String(r.error)); | |
| 90 | repo = r.value; | |
| 91 | const seeded = await commitToBranch(app, alice, repo, { | |
| 92 | message: "seed", | |
| 93 | files: [{ path: "README.md", content: "# garden\n" }], | |
| 94 | }); | |
| 95 | if (!seeded.ok) throw new Error(JSON.stringify(seeded.error)); | |
| 96 | }, 30_000); | |
| 97 | ||
| 98 | afterAll(async () => { | |
| 99 | app.close(); | |
| 100 | await rm(dataDir, { recursive: true, force: true }); | |
| 101 | }); | |
| 102 | ||
| 103 | const spend = (issue: Issue, usd: number, pull = false) => { | |
| 104 | if (pull) | |
Non-blocking: the two consecutive |
||
| 105 | app.db.query("UPDATE issue SET kind = 'pull' WHERE id = ?1").run(issue.id); | |
| 106 | if (pull) | |
| 107 | app.db | |
| 108 | .query(`INSERT INTO pull (issue_id, base_branch, head_branch, base_oid, head_oid) VALUES (?1, 'main', 'x', '', '')`) | |
| 109 | .run(issue.id); | |
| 110 | const r = createRun(app.db, { repoId: repo.id, kind: "agent", issueId: issue.id }); | |
| 111 | app.db.query("UPDATE run SET cost_usd = ?1 WHERE id = ?2").run(usd, r.id); | |
| 112 | }; | |
| 113 | ||
| 114 | test("the owner sees the sentence, a reader who cannot write sees nothing", async () => { | |
| 115 | const issue = newIssue("Prune"); | |
| 116 | spend(issue, 0.5); | |
| 117 | const pull = newIssue("Fix prune"); | |
| 118 | app.db.query("UPDATE issue SET body = ?2 WHERE id = ?1").run(pull.id, `Closes #${issue.number}`); | |
| 119 | spend(pull, 0.25, true); | |
| 120 | const t = await open(alice, issue.number); | |
| 121 | try { | |
| 122 | expect(t.text()).toContain(`Cost $0.75: this issue $0.50, #${pull.number} $0.25`); | |
| 123 | } finally { | |
| 124 | t.stop(); | |
| 125 | } | |
| 126 | const r = await open(bob, issue.number); | |
| 127 | try { | |
| 128 | expect(r.text()).toContain("Prune"); | |
| 129 | expect(r.text()).not.toContain("Cost $"); | |
| 130 | } finally { | |
| 131 | r.stop(); | |
| 132 | } | |
| 133 | }); | |
| 134 | }); |
new above
offline
Non-blocking: readThroughput is called with [] apps, so the median is forge-wide while the dollars on this line follow the ?apps= scope. Pass the same scope, or label it as forge-wide.