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

A milestone's page shows what its limb cost and landed, with its costliest issues #1984

Merged opened this

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

Merged

scout/forge:scout/1596-a-milestone-s-page-shows-what-its-limb-c → main 3 commits fork

  • Merged. scout/1596-a-milestone-s-page-shows-what-its-limb-c was merged into main as 027b617.

Review

Sign in to review this change.
7 files changed +203 −6
README.md +6 −1
@@ -131,7 +131,12 @@ and of the top of the ready list first, then `enter` to apply (`esc` backs
131131 out), then `u` to undo the last move. It is the same fleet operation the web
132132 milestones page uses, and a move is heard by that page when it is open.
133133 Anyone else sees the order and no keys to change it; a guest sees only what
134 the web shows them.
134 the web shows them. Whoever may see owner facts also gets two lines under
135 the list for the limb under the cursor: **Cost and landed** (dollars spent,
136 changes landed) and its costliest three issues; a reader or guest sees none.
137 The web page has the same *Cost and landed* section on a milestone, and the
138 milestones API and MCP carry it as `spend` (`usd`, `landed`, `costliest`),
139 owner only.
135140
136141 Working on the UI itself? `bun run src/tui/main.ts --user alice` runs it over
137142 stdio against your local data directory. That `--user` flag is a development
docs/MCP.md +1 −1
@@ -52,7 +52,7 @@ for a tunnel and a forge that sleeps.
5252 | `make_it_yours` | `{ repo, name?, visibility?, dryRun? }`: a fork of an app you can see, as your own app, following the original; code and history copied, data and secrets not; private unless `visibility` is public (a public original only); at most 5 copies an account a day; plans unless `dryRun` is false | yes |
5353 | `front_page` | `{ pinned?, hidden?, weights?, dryRun? }`: the front page as a stranger sees it, ranked, with numbers; a site admin sets what is pinned (first public one featured), hidden, and what counts (weights of forks, ships, use, fresh, 0 to 10) | yes |
5454 | `map` | an app's plan: ready now, waiting on what, the critical path, where the crew is, parked twice and why (`app`) | |
55 | `milestones` | `{ app, issue? }`: how far each thing asked for is (and, with `issue`, whether you watch it: `watching`), from the dependency tree: the plan's limbs with their weights, or one milestone opened (who is building, what is blocked and on what, what merged in 7 days, an estimate) | |
55 | `milestones` | `{ app, issue? }`: how far each thing asked for is (and, with `issue`, whether you watch it: `watching`), from the dependency tree: the plan's limbs with their weights, or one milestone opened (who is building, what is blocked and on what, what merged in 7 days, an estimate, and for an owner `spend`: dollars, changes landed ever, the costliest three issues) | |
5656 | `limb` | `{ app, issue, action, reason?, pillar?, operation?, dryRun? }`: defer, resume or close a whole limb as not doing, or undo an operation; plans unless `dryRun` is false, and applying takes an admin token | yes |
5757 | `watch` | `{ app, issue, watch? }`: watch a milestone, or stop with `watch: false`: one item in your feed that follows it, and one push to your phone when it lands | yes |
5858 | `usage` | `{ repo, days? }`: what the crew's runs spent — tokens, cost, wall time — by subscription, agent, issue, and each account's 5-hour and weekly standing; a writer's | |
src/core/milestones.ts +53
@@ -40,6 +40,9 @@ import { getUsersByIds } from "./users.ts";
4040 /** A limb with this many open issues beneath it and nobody building is one to look at. */
4141 export const TRIM_OPEN = 8;
4242
43 /** How many issues a milestone's spend names. */
44 export const COSTLIEST = 3;
45
4346 /** How heavy a limb is: what is beneath it, and whether it moves. */
4447 export interface Weight {
4548 /** The work beneath it: what is open, and what closed as done. */
@@ -100,6 +103,17 @@ export interface Milestone extends Limb {
100103 readonly title: string;
101104 readonly at: string;
102105 }[];
106 /**
107 * What the limb cost and landed, ever: owner facts only, so nothing for a
108 * viewer who may not see them. `costliest` is at most `COSTLIEST` issues
109 * beneath it, dearest first; an issue that cost nothing is not listed.
110 */
111 readonly spend?: {
112 readonly usd: number;
113 /** Pulls merged, ever, for the issues beneath it. */
114 readonly landed: number;
115 readonly costliest: readonly { readonly issue: Issue; readonly usd: number }[];
116 };
103117 /** A plain sentence: when it lands at this week's pace, or why nobody can say. */
104118 readonly estimate: string;
105119 /** What it waits for directly that has a tree of its own. */
@@ -270,6 +284,18 @@ export function readPlan(
270284 }
271285 }
272286
287 // Every pull ever merged, by the issues it closes: what a limb landed.
288 // One query that parses each merged pull's body; owner reads only.
289 const everMerged: (readonly number[])[] = options.spend
290 ? db
291 .query<{ body: string }, [string]>(
292 `SELECT i.body FROM pull p JOIN issue i ON i.id = p.issue_id
293 WHERE i.repo_id = ?1 AND p.merged_at IS NOT NULL`,
294 )
295 .all(repo.id)
296 .map((row) => closesFromBody(row.body))
297 : [];
298
273299 const stateOf = (issue: Issue): LeafState => {
274300 if (issue.state === "closed") return counted(issue) ? "done" : "gone";
275301 if (building.has(issue.id)) return "building";
@@ -504,6 +530,21 @@ export function readPlan(
504530 merged: merged
505531 .filter((pull) => pull.closes.some((n) => numbers.has(n)))
506532 .map(({ number: n, title, at }) => ({ number: n, title, at })),
533 ...(options.spend
534 ? {
535 spend: {
536 usd: limb.weight.spentUsd ?? 0,
537 landed: everMerged.filter((closes) =>
538 closes.some((n) => numbers.has(n)),
539 ).length,
540 costliest: under
541 .filter((i) => (cost.get(i.id) ?? 0) > 0)
542 .map((i) => ({ issue: i, usd: cost.get(i.id)! }))
543 .sort((a, b) => b.usd - a.usd || a.issue.number - b.issue.number)
544 .slice(0, COSTLIEST),
545 },
546 }
547 : {}),
507548 estimate,
508549 limbs: arranged(issue.id, [...limbIds]).map(limbOf),
509550 leaves: children
@@ -576,6 +617,18 @@ export function milestoneJson(
576617 on: b.on.map(brief),
577618 })),
578619 merged: milestone.merged,
620 ...(milestone.spend === undefined
621 ? {}
622 : {
623 spend: {
624 usd: milestone.spend.usd,
625 landed: milestone.spend.landed,
626 costliest: milestone.spend.costliest.map((c) => ({
627 ...brief(c.issue),
628 usd: c.usd,
629 })),
630 },
631 }),
579632 limbs: milestone.limbs.map(limbJson),
580633 leaves: milestone.leaves.map((leaf) => ({
581634 ...brief(leaf.issue),
src/tui/screens/limbs.ts +31 −4
@@ -8,7 +8,9 @@
88 * verb `place`. What the screen decides is only what to draw and which keys
99 * exist; the action decides who may. A reader sees the order and no keys to
1010 * change it, an agent likewise, and a guest sees what `canRead` lets the web
11 * show — the plan is read without the owner's spend.
11 * show — the plan is read without the owner's spend. Whoever may see owner
12 * facts (`canSeeOwnerFacts`, as on the page) also gets, under the list, what
13 * the limb under the cursor cost and landed, and its costliest issues.
1214 *
1315 * `enter` on a limb that has limbs of its own opens them, so a milestone's
1416 * sub-limbs can be moved among themselves (the `under` of the action).
@@ -18,8 +20,8 @@
1820
1921 import { undoFleetOp } from "../../actions/fleet.ts";
2022 import { applyLimb, previewLimb, type LimbPreview } from "../../actions/limbs.ts";
21 import { canAdmin, canRead, type Repo } from "../../core/repos.ts";
22 import { readPlan, type Limb } from "../../core/milestones.ts";
23 import { canAdmin, canRead, canSeeOwnerFacts, type Repo } from "../../core/repos.ts";
24 import { readPlan, type Limb, type Milestone } from "../../core/milestones.ts";
2325 import type { Ctx, Screen } from "../app/app.ts";
2426 import { binding } from "../app/keys.ts";
2527 import { fill, len, split } from "../ui/layout.ts";
@@ -51,6 +53,15 @@ function limbsOf(f: Session, repo: Repo, group: string): Limb[] | undefined {
5153 return milestone === undefined ? undefined : [...milestone.limbs];
5254 }
5355
56 /** What a limb cost and landed, as the two lines under the list. */
57 export function spendLines(spend: NonNullable<Milestone["spend"]>): string[] {
58 const dear = spend.costliest.map((c) => `#${c.issue.number} $${c.usd.toFixed(2)}`);
59 return [
60 `Cost and landed: $${spend.usd.toFixed(2)} spent · ${plural(spend.landed, "change")} landed`,
61 dear.length === 0 ? "Nothing has cost anything yet." : `Costliest: ${dear.join(" · ")}`,
62 ];
63 }
64
5465 const line = (n: number, title: string): string => `#${n} ${title}`;
5566
5667 /** What a preview says before anything is written, as dialog lines. */
@@ -89,6 +100,8 @@ export function limbsScreen(
89100 const state = new ListState();
90101 let limbs: Limb[] = [];
91102 let loaded = false;
103 /** What each limb here cost and landed, by issue number; empty unless owner facts. */
104 let spends = new Map<number, NonNullable<Milestone["spend"]>>();
92105 /** The receipt of the last move made here, which `u` reverses once. */
93106 let last: string | undefined;
94107 // Read at use, not at open: a repository's admin can change under a session.
@@ -98,11 +111,20 @@ export function limbsScreen(
98111 const reload = (follow?: number): void => {
99112 if (!canRead(f.db, repo, f.user)) {
100113 limbs = [];
114 spends = new Map();
101115 loaded = true;
102116 ctx.invalidate();
103117 return;
104118 }
105119 limbs = limbsOf(f, repo, group) ?? [];
120 spends = new Map();
121 if (canSeeOwnerFacts(f.db, repo, f.user)) {
122 const plan = readPlan(f.db, repo, { spend: true });
123 for (const l of limbs) {
124 const spend = plan.milestone(l.issue.number)?.spend;
125 if (spend !== undefined) spends.set(l.issue.number, spend);
126 }
127 }
106128 loaded = true;
107129 if (follow !== undefined) {
108130 const at = limbs.findIndex((l) => l.issue.number === follow);
@@ -203,7 +225,8 @@ export function limbsScreen(
203225 },
204226 render(area, s) {
205227 const c = ctx.theme.c;
206 const [head, list] = split(area, "v", [len(1), fill(1)]);
228 const spend = current() === undefined ? undefined : spends.get(current()!.issue.number);
229 const [head, list, foot] = split(area, "v", [len(1), fill(1), len(spend ? 2 : 0)]);
207230 const note = !loaded
208231 ? "Loading…"
209232 : limbs.length === 0
@@ -211,6 +234,10 @@ export function limbsScreen(
211234 : `${plural(limbs.length, "limb")} in the order the crew takes ready work${may() ? "" : " · you can read this order, not change it"}`;
212235 s.text(head!.x, head!.y, truncate(note, head!.w), style(c.mutedFg, c.bg), head!.x + head!.w);
213236 drawList(s, list!, c, state, rows());
237 if (spend !== undefined)
238 spendLines(spend).forEach((text, n) =>
239 s.text(foot!.x, foot!.y + n, truncate(text, foot!.w), style(c.mutedFg, c.bg), foot!.x + foot!.w),
240 );
214241 hitList(ctx, list!, state, limbs.length, { open });
215242 },
216243 key(e) {
src/web/pages/milestones.ts +26
@@ -376,6 +376,31 @@ function milestoneBody(
376376 )}
377377 </section>`,
378378 )}
379 ${when(
380 m.spend,
381 (spend) =>
382 html`<section class="ms-section" id="ms-spend">
383 <h2>Cost and landed</h2>
384 <p class="ms-figures">
385 <span><b>${dollars(spend.usd)}</b> spent</span>
386 <span
387 ><b>${spend.landed}</b> change${spend.landed === 1 ? "" : "s"}
388 landed</span
389 >
390 </p>
391 ${spend.costliest.length === 0
392 ? html`<p class="muted small">Nothing has cost anything yet.</p>`
393 : html`<ol class="ms-rows">
394 ${spend.costliest.map(
395 (c) =>
396 html`<li>
397 ${issue(c.issue.number, c.issue.title)}
398 <span class="ms-state small">${dollars(c.usd)}</span>
399 </li>`,
400 )}
401 </ol>`}
402 </section>`,
403 )}
379404 <section class="ms-section" id="ms-merged">
380405 <h2>Merged in ${PILLAR_DAYS} days</h2>
381406 ${m.merged.length === 0
@@ -601,6 +626,7 @@ export const milestonesCss = /* css */ `
601626 .ms-section h2 { font-size: 13px; margin: 0 0 6px; }
602627 .ms-rows { list-style: none; margin: 0; padding: 0; display: grid; gap: 6px; font-size: 14px; }
603628 .ms-rows li > span { margin-left: 6px; }
629 .ms-figures { display: flex; flex-wrap: wrap; gap: 4px 20px; margin: 0 0 8px; font-variant-numeric: tabular-nums; }
604630 .ms-state { color: var(--muted-fg); }
605631 .ms-rows li[data-state="building"] .ms-state { color: var(--accent); }
606632 .ms-rows li[data-state="blocked"] .ms-state { color: var(--attn); }
test/milestones.test.ts +54
@@ -373,6 +373,14 @@ describe("milestones", () => {
373373 expect(one).toContain(`href="/alice/engine/issues/${network.number}"`);
374374 expect(one).toContain(`href="/alice/engine/pulls/${landed}"`);
375375 expect(one).toContain("$1.50");
376 // What it cost and landed, with its costliest: the shell, $1.50, one change.
377 const spend = one.slice(
378 one.indexOf('id="ms-spend"'),
379 one.indexOf("</section>", one.indexOf('id="ms-spend"')),
380 );
381 expect(spend).toMatch(/<b>\$1\.50<\/b> spent/);
382 expect(spend).toMatch(/<b>1<\/b> change\s+landed/);
383 expect(spend).toContain("Sandbox the shell");
376384 // Each issue once: the network is under Building now and the disk under
377385 // Blocked, so Issues lists neither again, only what closed.
378386 const issues = one.slice(
@@ -390,6 +398,7 @@ describe("milestones", () => {
390398 ).text();
391399 expect(theirs).toMatch(/<b>1<\/b> of 3 done/);
392400 expect(theirs).not.toContain("$1.50");
401 expect(theirs).not.toContain('id="ms-spend"');
393402 expect(theirs).not.toContain("mason");
394403 expect(theirs).toMatch(/the crew · since/);
395404
@@ -484,6 +493,12 @@ describe("milestones", () => {
484493 })
485494 ).json()) as Record<string, any>;
486495 expect("spentUsd" in theirs).toBe(false);
496 expect("spend" in theirs).toBe(false);
497 expect(one["spend"]).toMatchObject({
498 usd: 1.5,
499 landed: 1,
500 costliest: [{ number: shell.number, usd: 1.5 }],
501 });
487502 expect(theirs["buildingNow"][0].who).toBe("the crew");
488503 expect(theirs["done"]).toBe(1);
489504 });
@@ -515,4 +530,43 @@ describe("milestones", () => {
515530 expect(page).toMatch(/<b>0<\/b> of 2 done/);
516531 expect(page).toContain("A profit slider");
517532 });
533
534 test("a milestone's spend adds up: its dollars are its issues' dollars, the three dearest are named, and a reader sees none", () => {
535 const top = issue(plain, "Cost 1.0");
536 const parts = [0.5, 3, 1, 2.25].map((usd, n) => {
537 const part = issue(plain, `Costed ${n + 1}`);
538 link(plain, top, part);
539 const run = createRun(app.db, {
540 repoId: plain.id,
541 kind: "agent",
542 issueId: part.id,
543 actorId: mason.id,
544 reason: "assigned",
545 });
546 claimRun(app.db, run.id);
547 finishRun(app.db, run.id, { state: "ok", summary: "x", costUsd: usd });
548 return part;
549 });
550 const free = issue(plain, "Costed nothing");
551 link(plain, top, free);
552 const m = readPlan(app.db, fresh(plain), { spend: true }).milestone(
553 top.number,
554 )!;
555 expect(m.spend!.usd).toBe(6.75);
556 expect(m.spend!.usd).toBe(m.weight.spentUsd!);
557 expect(m.spend!.landed).toBe(0);
558 expect(m.spend!.costliest.map((c) => [c.issue.number, c.usd])).toEqual([
559 [parts[1]!.number, 3],
560 [parts[3]!.number, 2.25],
561 [parts[2]!.number, 1],
562 ]);
563 // The three named are never more than the whole.
564 expect(m.spend!.costliest.reduce((sum, c) => sum + c.usd, 0)).toBeLessThanOrEqual(
565 m.spend!.usd,
566 );
567 const reader = readPlan(app.db, fresh(plain), { spend: false }).milestone(
568 top.number,
569 )!;
570 expect(reader.spend).toBeUndefined();
571 });
518572 });
test/tui-limbs.test.ts +32
@@ -19,6 +19,7 @@ import { createApp, type App as Forge } from "../src/app.ts";
1919 import { listEvents } from "../src/core/events.ts";
2020 import { getIssueById, type Issue } from "../src/core/issues.ts";
2121 import { readPlan } from "../src/core/milestones.ts";
22 import { claimRun, createRun, finishRun } from "../src/core/runs.ts";
2223 import { getRepoById, type Repo } from "../src/core/repos.ts";
2324 import { createUser, getUserByName, type User } from "../src/core/users.ts";
2425 import { serve, type Servers } from "../src/http/server.ts";
@@ -117,6 +118,15 @@ describe("raise and lower a limb in the terminal", () => {
117118 if (r === repo) {
118119 trust = a;
119120 feed = b;
121 const run = createRun(forge.db, {
122 repoId: r.id,
123 kind: "agent",
124 issueId: leafA.id,
125 actorId: alice.id,
126 reason: "assigned",
127 });
128 claimRun(forge.db, run.id);
129 finishRun(forge.db, run.id, { state: "ok", summary: "x", costUsd: 2.5 });
120130 }
121131 }
122132 }, 60_000);
@@ -193,6 +203,28 @@ describe("raise and lower a limb in the terminal", () => {
193203 h.stop();
194204 });
195205
206 test("an owner sees what the limb under the cursor cost and landed; a reader and a guest see none", async () => {
207 const h = boot(alice);
208 await h.toMilestones("alice/order");
209 expect(h.text()).toContain("Cost and landed: $2.50 spent · 0 changes landed");
210 expect(h.text()).toContain("Costliest: #");
211 expect(h.text()).toContain("$2.50");
212 // The Feed limb cost nothing.
213 await h.press("j");
214 expect(h.text()).toContain("Cost and landed: $0.00 spent");
215 expect(h.text()).toContain("Nothing has cost anything yet.");
216 h.stop();
217
218 for (const who of [bob, undefined]) {
219 const r = boot(who);
220 await r.toMilestones("alice/order");
221 expect(r.text()).toContain("Trust");
222 expect(r.text()).not.toContain("Cost and landed");
223 expect(r.text()).not.toContain("$2.50");
224 r.stop();
225 }
226 });
227
196228 test("a guest sees a public order, never a private one, and moves nothing", async () => {
197229 const h = boot(undefined);
198230 await h.toMilestones("alice/vault");

↑↓ navigate ↵ open ⌘↵ its app esc close

Keyboard shortcuts

Docs