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

Merged opened this

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

Merged

torque/forge:torque/1389-cost-ledger-an-issue-sums-its-pulls-and → main 12 commits fork 3 threads · 3 unresolved

  • Merged. torque/1389-cost-ledger-an-issue-sums-its-pulls-and was merged into main as b0f6f7d.

Review

Sign in to review this change.
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";
3535 import { ISSUE_TEAMS_SAY, nameTeams } from "../../actions/issue_teams.ts";
3636 import { teamsOfIssue, TEAMS_MAX } from "../../core/issue_teams.ts";
3737 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";
3940 import { readPlan } from "../../actions/run_plan.ts";
4041 import type { IssuePlan } from "../../core/issue_plans.ts";
4142 import { shownStates } from "../../runs/state.ts";
@@ -167,6 +168,8 @@ export function issueScreen(
167168 /** Closed-as-dropped-or-split blockers nobody has said may be passed. */
168169 let held: Issue[] = [];
169170 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;
170173 let shown: ReturnType<typeof headline>;
171174 /** Which issues this one waits for and holds up; a line when any. */
172175 let waits: Waits = { blockedBy: [], blocks: [] };
@@ -216,6 +219,10 @@ export function issueScreen(
216219 blockedBy: deps?.blockedBy.map((i) => i.number) ?? [],
217220 blocks: deps?.blocks.map((i) => i.number) ?? [],
218221 };
222 cost =
223 repo === undefined || issueRow === undefined || !mayReadRuns(f.app, f.user, repo)
224 ? null
225 : issueCostFor(f.db, repo, issueRow, f.user);
219226 crew.set(runs !== undefined && runs.ok ? runs.value : []);
220227 plan =
221228 repo === undefined || issueRow === undefined ? undefined : readPlan(f.app, repo, f.user, issueRow);
@@ -590,14 +597,16 @@ export function issueScreen(
590597 const planH = planRows.length === 0 ? 0 : Math.min(planRows.length, CREW_ROWS + 2) + 2;
591598 const waitsLine = waitsParts(waits);
592599 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", [
594602 len(whereRows(src)),
595603 len(headH),
596604 len(held.length === 0 ? 0 : 1),
597605 len(crewH),
606 len(costH),
598607 len(planH),
599608 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)))),
601610 ]);
602611 whereStrip(s, strip!, c, f, src);
603612 const right = head!.x + head!.w;
@@ -689,6 +698,9 @@ export function issueScreen(
689698 });
690699 }
691700
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
692704 if (planH > 0 && plan !== undefined) {
693705 const inner = card(s, planRow!, c, { title: planTitle(plan, shown) });
694706 planRows.slice(0, inner.h).forEach((row, i) => {
src/web/pages/board.ts +5 −1
@@ -16,6 +16,7 @@
1616 * pointer. The old big view (`/big`, every address it had) lands here.
1717 */
1818
19 import { hoursSpan, readThroughput } from "../../flow/throughput.ts";
1920 import { usd } from "../../core/money.ts";
2021 import { NO_CLOUD_BODY } from "../../core/placement_policy.ts";
2122 import { CLOUD_BODY_CONFIGURED } from "../../spawn/placement.ts";
@@ -517,6 +518,7 @@ function returnsStrip(ctx: Ctx, scope: BoardScope): Html | null {
517518 until: new Date(now).toISOString(),
518519 });
519520 if (r.spent.runs === 0) return null;
521 const ask = readThroughput(ctx.app.db, [], new Date(now)).medianAskToLiveMs;

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.

520522 const href = `/board/returns${apps ? `?apps=${encodeURIComponent(apps)}` : ""}`;
521523 return html`<a class="muted small" id="returns-line" href="${href}"
522524 >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 {
524526 ? null
525527 : html`· <b>${usd(r.onLanded.perChange.median)}</b> a landed change`}
526528 · <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
528532 >`;
529533 }
530534
src/web/studio/tell.ts +19 −5
@@ -8,6 +8,7 @@
88 * one closed Details section.
99 */
1010
11 import { costRows, issueCostFor, type IssueCost } from "../../core/issue_cost.ts";
1112 import { notePhase, takeSessionTime } from "../../core/route_timing.ts";
1213 import { cardPath } from "../og/path.ts";
1314 import { mayEditIssue } from "../../actions/issues.ts";
@@ -337,7 +338,8 @@ async function tellRuns(
337338 issueId: issue.id,
338339 limit: 10,
339340 });
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;
341343 const base = repoBase(repo);
342344 const actors = getUsersByIds(
343345 wordCtx.db,
@@ -363,10 +365,22 @@ async function tellRuns(
363365 });
364366 }),
365367 );
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>`;
370384 }
371385
372386 /** 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", () => {
326326 ).toBe(422);
327327 });
328328
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
329352 test("spend reported from outside shows apart and is never added in", async () => {
330353 const post = (payload: unknown) =>
331354 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 if (pull) blocks should be one block.

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 });

↑↓ navigate ↵ open ⌘↵ its app esc close

Keyboard shortcuts

Docs