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

Bugs get fixed or declined on a clock, and a share of each week goes to polish #8714

Merged opened this in answer to

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

Merged

scout/forge:scout/8709-bugs-get-fixed-or-declined-on-a-clock-an → main 8 commits fork 2 threads · 2 unresolved

  • Merged. scout/8709-bugs-get-fixed-or-declined-on-a-clock-an was merged into main as ac52291.

Review

Sign in to review this change.
15 files changed +372 −32
docs/features/bug-close.md added +23
@@ -0,0 +1,23 @@
1 # A bug closes with a reason
2
3 An issue labelled `bug` cannot be closed by hand with nothing behind it. Unless
4 a merged pull closed it, commits on main already name it, or it was split into
5 other issues, the close needs a `note`: one sentence, at most 300 characters. It
6 is posted as a comment (`Closed as won't fix: …` when the reason is `dropped`,
7 `Closed: …` otherwise). A missing note is `reason_required`; a note that is too
8 long or not text is `invalid_note`.
9
10 - Page: the close form has a "why" field beside the reason select; it is
11 optional for any issue that is not a bug.
12 - API: `PATCH …/issues/:number` with `{ state: "closed", note }`; without it on a bug, `422 reason_required`.
13 - MCP: `close` takes `note`; the same refusals.
14 - Terminal: `close <issue> "why"`. The reason is a note on every issue, bug or not.
15 The issue screen's `x` has no field for one, so it refuses a bug and points to the page.
16
17 Exempt, because they say why themselves: the crew's live triage (`triage`), its
18 stale sweeps (`stale`), and the forge's own closes (`system`: the sentinel, the
19 superseded-pull sweep, the gate review, an app note withdrawn). Each is tested in
20 `test/bug-close-doors.test.ts`.
21
22 On a mirrored repository the close is forwarded to the origin, but the note is
23 kept as a comment on this forge only: the origin does not see why.
docs/features/bugs.md +10 −21
@@ -1,23 +1,12 @@
1 # A bug closes with a reason
1 # Bugs on a clock
22
3 An issue labelled `bug` cannot be closed by hand with nothing behind it. Unless
4 a merged pull closed it, commits on main already name it, or it was split into
5 other issues, the close needs a `note`: one sentence, at most 300 characters. It
6 is posted as a comment (`Closed as won't fix: …` when the reason is `dropped`,
7 `Closed: …` otherwise). A missing note is `reason_required`; a note that is too
8 long or not text is `invalid_note`.
3 An open issue labelled `bug` is answered, fixed or closed as won't fix within a limit.
94
10 - Page: the close form has a "why" field beside the reason select; it is
11 optional for any issue that is not a bug.
12 - API: `PATCH …/issues/:number` with `{ state: "closed", note }`; without it on a bug, `422 reason_required`.
13 - MCP: `close` takes `note`; the same refusals.
14 - Terminal: `close <issue> "why"`. The reason is a note on every issue, bug or not.
15 The issue screen's `x` has no field for one, so it refuses a bug and points to the page.
16
17 Exempt, because they say why themselves: the crew's live triage (`triage`), its
18 stale sweeps (`stale`), and the forge's own closes (`system`: the sentinel, the
19 superseded-pull sweep, the gate review, an app note withdrawn). Each is tested in
20 `test/bug-close-doors.test.ts`.
21
22 On a mirrored repository the close is forwarded to the origin, but the note is
23 kept as a comment on this forge only: the origin does not see why.
5 - **Which labels count.** `bug` makes an issue a bug. Also `high`, `priority: high` or `priority:high` (any case) makes it high priority. Pull requests are not bugs.
6 - **Clocks.** 4 h from the issue's creation when high, 48 h otherwise (`src/flow/bugs.ts`).
7 - **Pickup order.** Queued agent runs about an open bug start before every other run (`startOrder` in `src/crew/dispatch.ts`). This is policy: bugs also outrank the team dials, which then order the rest. Within each side the queue keeps its order.
8 - **Alarm.** One `bug_clock` alarm names the bug that has gone longest past its clock, and how many more there are. It appears wherever alarms do (home, `/boards`, MCP and API telemetry). For a guest or an agent it counts public repositories only.
9 - **Tile.** Home's **Open bugs** tile (admins only) shows the open count and how many are past their clock; it links to `/boards`.
10 - **Terminal.** The **Bugs** palette entry shows the same count (admins who are people) and the alarm (cut to public for everyone else).
11 - **Closing.** Closing a bug needs a reason: see `bug-close.md`.
12 - **Not yet.** The polish share, shortcuts and hunters' findings of #8709 are not built.
src/crew/dispatch.ts +11 −1
@@ -42,6 +42,7 @@ import { accountActive } from "../core/accounts.ts";
4242 import { runningLimitSentence, runningRuns } from "../settings/quotas.ts";
4343 import { appendRunLog } from "../runs/log.ts";
4444 import { decideAndRecord } from "./ledger.ts";
45 import { bugsFirst } from "../flow/bugs.ts";
4546 import { byDials } from "./team_share.ts";
4647 import { gateQueue } from "../flow/valve_gate.ts";
4748 import { ARCHIVED_WHY, getRepoById, type Repo } from "../core/repos.ts";
@@ -1403,6 +1404,15 @@ export function sweepAssigned(app: App): void {
14031404 /** Per app, the runs already told why they wait, so the log says it once. */
14041405 const notedBy = new WeakMap<App, Set<string>>();
14051406
1407 /**
1408 * The order queued agent runs are started in: the team dials first, then bugs
1409 * ahead of everything (#8709), then the valves a person turned. A bug outranks the dials by policy: the clock
1410 * is a promise, the dials a share of the rest.
1411 */
1412 export function startOrder(app: App): ReturnType<typeof gateQueue> {
1413 return gateQueue(app.db, bugsFirst(app.db, byDials(app.db, listQueued(app.db, "agent", 50))));
1414 }
1415
14061416 /** Take queued runs, oldest first, up to the limit — one per issue at a time. */
14071417 function pump(
14081418 app: App,
@@ -1423,7 +1433,7 @@ function pump(
14231433 notedBy.set(app, noted);
14241434 // Builder starts go to teams in proportion to their dials (`team_share.ts`),
14251435 // then through the valves a person turned (`flow/valve_gate.ts`).
1426 const gated = gateQueue(app.db, byDials(app.db, listQueued(app.db, "agent", 50)));
1436 const gated = startOrder(app);
14271437 for (const shut of gated.held) {
14281438 if (noted.has(shut.id)) continue;
14291439 noted.add(shut.id);
src/flow/alarms.ts +4 −1
@@ -11,6 +11,7 @@ import { landingStopped, stoppedSentence } from "../checks/gate_stopped.ts";
1111 import { redHref, redMain } from "../checks/red_main.ts";
1212 import { getRepo } from "../core/repos.ts";
1313 import { ownRepoPath } from "../kernel/own_repo.ts";
14 import { bugClockAlarm } from "./bugs.ts";
1415 import { alarmIdleCrew } from "./alarm_idle_crew.ts";
1516 import { noReleaseAlarm } from "./alarm_no_release.ts";
1617 import { redMainAlarm } from "./alarm_red_main.ts";
@@ -19,7 +20,7 @@ import { alarmSilent } from "./alarm_silent.ts";
1920 import { alarmStuck } from "./alarm_stuck.ts";
2021
2122 export interface FiredAlarm {
22 readonly key: "landing_stopped" | "red_main" | "stuck" | "idle_crew" | "no_release" | "silent" | "repeat" | "unreachable";
23 readonly key: "landing_stopped" | "red_main" | "stuck" | "idle_crew" | "no_release" | "bug_clock" | "silent" | "repeat" | "unreachable";
2324 readonly says: string;
2425 readonly since: string;
2526 /** A same-origin path to the item the alarm names. */
@@ -59,5 +60,7 @@ export function firedAlarms(
5960 }
6061 const release = noReleaseAlarm(db, now);
6162 if (release.fired) out.push({ key: "no_release", says: release.says, since: release.since!, href: "/releases" });
63 const bug = bugClockAlarm(db, now, opts.publicOnly ? { publicOnly: true } : {});
64 if (bug.fired) out.push({ key: "bug_clock", says: bug.says, since: bug.since!, href: bug.href });
6265 return out;
6366 }
src/flow/bugs.ts added +122
@@ -0,0 +1,122 @@
1 /**

The terminal has no screen for this (add one in src/tui/ and docs/features/bugs.md), and the open-bug query is duplicated between openBugs and countBugs.

2 * Bugs on a clock (#8709): an open issue labelled `bug` is answered, fixed or
3 * closed as won't fix, within 4 hours when it is also labelled `high` and 48
4 * hours otherwise. Pure over the db and `now`. Dispatch starts bugs ahead of
5 * other work (`bugsFirst`), home counts them (`countBugs`), and one flow
6 * alarm names the oldest one past its clock (`bugClockAlarm`).
7 */
8
9 import type { Database } from "bun:sqlite";
10 import type { Run } from "../core/runs.ts";
11
12 const HOUR_MS = 3_600_000;
13 export const HIGH_BUG_MS = 4 * HOUR_MS;
14 export const LOW_BUG_MS = 48 * HOUR_MS;
15
16 const HIGH_NAMES = "('high', 'priority: high', 'priority:high')";
17
18 export interface OpenBug {
19 readonly repo: string;
20 readonly number: number;
21 readonly createdAt: string;
22 readonly high: boolean;
23 /** When its clock runs out (ISO). */
24 readonly dueAt: string;
25 }
26
27 export interface BugCount {
28 readonly open: number;
29 readonly overdue: number;
30 }
31
32 interface BugRow {
33 readonly owner: string | null;
34 readonly name: string | null;
35 readonly number: number;
36 readonly created_at: string;
37 readonly high: number;
38 }
39
40 /**
41 * The one open-bug query. `named` joins the repository and its owner (for the
42 * alarm); the count needs neither, and without them it reads only issue and labels.
43 */
44 function readBugRows(db: Database, named: boolean, publicOnly: boolean): BugRow[] {
45 const repo = named ? "JOIN repo r ON r.id = i.repo_id JOIN user u ON u.id = r.owner_id" : "";
46 const who = named ? "u.username AS owner, r.name AS name" : "NULL AS owner, NULL AS name";
47 return db
48 .query<BugRow, []>(
49 `SELECT ${who}, ${named ? "i.number" : "0 AS number"}, i.created_at,
50 EXISTS (SELECT 1 FROM issue_label h JOIN label hl ON hl.id = h.label_id
51 WHERE h.issue_id = i.id AND hl.name COLLATE NOCASE IN ${HIGH_NAMES}) AS high
52 FROM issue i ${repo}
53 JOIN issue_label il ON il.issue_id = i.id
54 JOIN label l ON l.id = il.label_id AND l.name = 'bug' COLLATE NOCASE
55 WHERE i.kind = 'issue' AND i.state = 'open' ${named && publicOnly ? "AND r.visibility = 'public'" : ""}
56 ORDER BY i.created_at`,
57 )
58 .all();
59 }
60
61 const dueMs = (r: BugRow): number => Date.parse(r.created_at) + (r.high === 1 ? HIGH_BUG_MS : LOW_BUG_MS);
62
63 /** Every open bug in a public or private repository alike; `publicOnly` leaves private ones out. */
64 export function openBugs(db: Database, opts: { readonly publicOnly?: boolean } = {}): OpenBug[] {
65 return readBugRows(db, true, opts.publicOnly === true).map((r) => ({
66 repo: `${r.owner}/${r.name}`,
67 number: r.number,
68 createdAt: r.created_at,
69 high: r.high === 1,
70 dueAt: new Date(dueMs(r)).toISOString(),
71 }));
72 }
73
74 export function countBugs(db: Database, now: Date): BugCount {
75 const rows = readBugRows(db, false, false);
76 return { open: rows.length, overdue: rows.filter((r) => dueMs(r) <= now.getTime()).length };
77 }
78
79 export interface BugClockAlarm {
80 readonly fired: boolean;
81 /** When the oldest overdue bug's clock ran out; only when fired. */
82 readonly since?: string;
83 readonly says: string;
84 readonly href: string;
85 }
86
87 /** One alarm for all the bugs past their clock, naming the one that has waited longest. */
88 export function bugClockAlarm(db: Database, now: Date, opts: { readonly publicOnly?: boolean } = {}): BugClockAlarm {
89 const late = openBugs(db, opts)
90 .filter((b) => Date.parse(b.dueAt) <= now.getTime())
91 .sort((a, b) => Date.parse(a.dueAt) - Date.parse(b.dueAt));
92 const first = late[0];
93 if (!first) return { fired: false, says: "No bug is past its clock.", href: "/boards" };
94 const hours = Math.floor((now.getTime() - Date.parse(first.dueAt)) / HOUR_MS);
95 const more = late.length > 1 ? ` and ${late.length - 1} more` : "";
96 return {
97 fired: true,
98 since: first.dueAt,
99 says: `Bug past its clock: ${first.repo}#${first.number}${more} still open, ${hours} h over its ${first.high ? "4 h" : "48 h"} limit. Fix it or close it as won't fix, with a reason.`,
100 href: `/${first.repo}/issues/${first.number}`,
101 };
102 }
103
104 /** Queued runs with those about an open bug first; each side keeps its order. */
105 export function bugsFirst(db: Database, queued: Run[]): Run[] {
106 const ids = queued.flatMap((r) => (r.issueId ? [r.issueId] : []));
107 if (ids.length === 0 || queued.length < 2) return queued;
108 const marks = ids.map(() => "?").join(",");
109 const bugs = new Set(
110 db
111 .query<{ id: string }, string[]>(
112 `SELECT i.id FROM issue i JOIN issue_label il ON il.issue_id = i.id
113 JOIN label l ON l.id = il.label_id AND l.name = 'bug' COLLATE NOCASE
114 WHERE i.id IN (${marks}) AND i.state = 'open'`,
115 )
116 .all(...ids)
117 .map((r) => r.id),
118 );
119 if (bugs.size === 0) return queued;
120 const isBug = (r: Run) => r.issueId !== null && bugs.has(r.issueId);
121 return [...queued.filter(isBug), ...queued.filter((r) => !isBug(r))];
122 }
src/flow/kpis.ts +7 −1
@@ -24,6 +24,7 @@ import { ceilingHitAt, ceilingReached } from "./ceiling.ts";
2424 import type { IdleReason } from "../crew/idle_reason.ts";
2525 import { lastOffBox } from "../snapshot/off_box.ts";
2626 import { readReach, type Reach } from "./reach.ts";
27 import { countBugs, type BugCount } from "./bugs.ts";
2728 import { HOURS, median } from "./throughput.ts";
2829
2930 const HOUR_MS = 3_600_000;
@@ -48,6 +49,8 @@ export interface Kpis {
4849 readonly spend: { readonly todayUsd: number; readonly ceilingUsd: number | null; /** When the run that crossed the cap was created (ISO); null while under it. */ readonly hitAt?: string | null };
4950 /** When a snapshot was last known off the machine (a bucket destination or the mirror, `off_box.ts`); null when never. */
5051 readonly backupAt: string | null;
52 /** Open bugs, and those past their clock (`bugs.ts`). */
53 readonly bugs?: BugCount;
5154 /** The verdict on what is in the bucket, read by this process (`snapshot/bucket.ts`); null when the forge is not watching one. */
5255 readonly backup?: BackupVerdict | null;
5356 /** Accounts, visitors and page speed (`reach.ts`). */
@@ -55,7 +58,7 @@ export interface Kpis {
5558 }
5659
5760 /** The parts `readKpis` names to its `time` callback, in the order it runs them. */
58 export const KPI_PARTS = ["kpis.landed", "kpis.live", "kpis.checks", "kpis.builders", "kpis.spend", "kpis.reach", "kpis.backup"] as const;
61 export const KPI_PARTS = ["kpis.landed", "kpis.live", "kpis.checks", "kpis.builders", "kpis.spend", "kpis.reach", "kpis.backup", "kpis.bugs"] as const;
5962 export type KpiPart = (typeof KPI_PARTS)[number];
6063 export type KpiTimer = <T>(name: KpiPart, fn: () => T) => T;
6164
@@ -173,7 +176,10 @@ export function readKpis(
173176 // Read from the bucket when it is watched; a job's own report only where nothing watches.
174177 const backupAt = backup !== null ? backup.newestAt : forge.dataDir === undefined ? null : lastOffBox(forge.dataDir);
175178
179 const bugs = time("kpis.bugs", () => countBugs(db, now));
180
176181 return {
182 bugs,
177183 backupAt,
178184 backup,
179185 builders,
src/tui/screens/bugs.ts added +56
@@ -0,0 +1,56 @@
1 /**
2 * Bugs on a clock in the terminal (#8709): the figure home's Open bugs tile
3 * shows and the bug_clock alarm, from `flow/bugs.ts`. Gated as the page is:
4 * the count is a person-admin's (the tile counts every repository), and any
5 * other reader, an agent or a guest included, sees only the alarm cut to
6 * public repositories (`publicOnly`). Read-only; Esc closes it.
7 */
8
9 import { bugClockAlarm, countBugs } from "../../flow/bugs.ts";
10 import type { Ctx, Screen } from "../app/app.ts";
11 import { style } from "../term/style.ts";
12 import { truncate, wrap } from "../term/width.ts";
13 import type { Session } from "../session.ts";
14 import { fill, len, split } from "../ui/layout.ts";
15 import type { PaletteEntry } from "./entries.ts";
16
17 /** What the screen says, one line each. */
18 export function bugLines(f: Session, now = new Date()): string[] {
19 const admin = f.user?.isAdmin === true && f.user.kind !== "agent";
20 const alarm = bugClockAlarm(f.db, now, admin ? {} : { publicOnly: true });
21 const count = admin ? countBugs(f.db, now) : undefined;
22 return [
23 ...(count === undefined
24 ? []
25 : [`Open bugs: ${count.open}, ${count.overdue > 0 ? `${count.overdue} past its clock` : "none past its clock"}`, ""]),
26 alarm.says,
27 "",
28 "Clocks: 4 h for a bug labelled high, 48 h otherwise.",
29 ];
30 }
31
32 export function bugsScreen(f: Session, ctx: Ctx): Screen {
33 return {
34 title: "Bugs",
35 keys: [],
36 render(area, s) {
37 const c = ctx.theme.c;
38 const [body] = split(area, "v", [fill(1), len(0)]);
39 const lines = bugLines(f).flatMap((l) => (l === "" ? [""] : wrap(l, Math.max(10, body!.w))));
40 lines.slice(0, body!.h).forEach((l, i) =>
41 s.text(body!.x, body!.y + i, truncate(l, body!.w), style(c.fg, c.bg), body!.x + body!.w),
42 );
43 },
44 key: () => false,
45 };
46 }
47
48 /** Offered to everyone: what a reader sees is cut inside, as the page cuts it. */
49 export const entry: PaletteEntry = {
50 id: "bugs",
51 title: "Bugs",
52 hint: "open bugs and the bug clock",
53 after: ["boards"],
54 offered: () => true,
55 run: (session, ctx) => ctx.push(bugsScreen(session, ctx)),
56 };
src/tui/screens/entries.ts +2
@@ -14,6 +14,7 @@ import { entry as archived } from "./archived.ts";
1414 import { entry as audit } from "./audit.ts";
1515 import { entry as bets } from "./bets.ts";
1616 import { entry as boards } from "./boards.ts";
17 import { entry as bugs } from "./bugs.ts";
1718 import { entry as bulk } from "./bulk.ts";
1819 import { entry as cloud } from "./cloud.ts";
1920 import { entry as connections } from "./connections.ts";
@@ -63,6 +64,7 @@ export interface PaletteEntry {
6364 export const ENTRIES: readonly PaletteEntry[] = [
6465 sentinel,
6566 boards,
67 bugs,
6668 homePins,
6769 system,
6870 telemetry,
src/web/studio/kpi_row.ts +10
@@ -149,6 +149,16 @@ export function kpiRowFor(k: Kpis, live: { sha: string; at: string } | null, now
149149 null,
150150 overdue ? "attn" : null,
151151 )}
152 ${k.bugs === undefined ? null : tile(
153 "Open bugs",
154 "/boards",
155 String(k.bugs.open),
156 `${k.bugs.open} open ${k.bugs.open === 1 ? "bug" : "bugs"}`,
157 k.bugs.overdue > 0 ? `${k.bugs.overdue} past its clock` : "none past its clock",
158 "Open bugs, and those past their clock: 4 h high, 48 h otherwise",
159 null,
160 k.bugs.overdue > 0 ? "attn" : null,
161 )}
152162 ${tile("Visitors", "/boards/reach", visitorsValue(k.reach), `${visitorsValue(k.reach)}, ${visitorsNote(k.reach)}`, visitorsNote(k.reach), "Distinct browsers per day, summed over 7 days", visitorsSpark(k.reach.visitorDays))}
153163 ${tile("Accounts", "/boards/reach", usersValue(k.reach), usersValue(k.reach), "signed in today", "Accounts signed in today (UTC), of all accounts")}
154164 ${tile(
test/flow-bugs.test.ts added +105
@@ -0,0 +1,105 @@
1 /** Bugs on a clock (#8709): 4 h high, 48 h otherwise; ahead of other work; counted on home. */
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 { createUser } from "../src/core/users.ts";
8 import { bugClockAlarm, bugsFirst, countBugs } from "../src/flow/bugs.ts";
9 import { firedAlarms } from "../src/flow/alarms.ts";
10 import { createRun, type Run } from "../src/core/runs.ts";
11 import { startOrder } from "../src/crew/dispatch.ts";
12 import { bugLines } from "../src/tui/screens/bugs.ts";
13 import type { Session } from "../src/tui/session.ts";
14
15 const NOW = new Date("2026-11-02T12:00:00.000Z");
16 const ago = (h: number): string => new Date(NOW.getTime() - h * 3_600_000).toISOString();
17
18 let dir: string;
19 let app: App;
20 let n = 0;
21
22 beforeAll(async () => {
23 dir = await mkdtemp(join(tmpdir(), "bugs-"));
24 app = createApp({ dataDir: dir, port: 0 });
25 const u = await createUser(app.db, { origin: "seed", trusted: true, username: "alice", email: "[email protected]", password: "a-good-passphrase", adminIfFirst: true });
26 if (!u.ok) throw new Error(u.error);
27 app.db.run("INSERT INTO repo (id, owner_id, name) VALUES ('r', ?1, 'r')", [u.value.id]);
28 for (const name of ["bug", "high"]) app.db.run("INSERT INTO label (id, repo_id, name) VALUES (?1, 'r', ?1)", [name]);
29 });
30 afterAll(async () => {
31 app.close?.();
32 await rm(dir, { recursive: true, force: true });
33 });
34
35 function issue(hoursOld: number, labels: string[], state = "open"): string {
36 n++;
37 const id = `i${n}`;
38 app.db.run("INSERT INTO issue (id, repo_id, number, kind, title, author_id, state, created_at) VALUES (?1, 'r', ?2, 'issue', 't', (SELECT id FROM user LIMIT 1), ?3, ?4)", [id, n, state, ago(hoursOld)]);
39 for (const l of labels) app.db.run("INSERT INTO issue_label (issue_id, label_id) VALUES (?1, ?2)", [id, l]);
40 return id;
41 }
42
43 describe("bug clock", () => {
44 test("nothing fires with no bug, or a bug inside its clock", () => {
45 expect(bugClockAlarm(app.db, NOW).fired).toBe(false);
46 issue(3, ["bug", "high"]);
47 issue(47, ["bug"]);
48 issue(100, []);
49 issue(100, ["bug"], "closed");
50 expect(bugClockAlarm(app.db, NOW).fired).toBe(false);
51 expect(countBugs(app.db, NOW)).toEqual({ open: 2, overdue: 0 });
52 });
53
54 test("a high bug past 4 h raises one alarm, counted and listed with the flow alarms", () => {
55 issue(5, ["bug", "high"]);
56 issue(49, ["bug"]);
57 const a = bugClockAlarm(app.db, NOW);
58 expect(a.fired).toBe(true);
59 expect(a.says).toContain("and 1 more");
60 expect(countBugs(app.db, NOW)).toEqual({ open: 4, overdue: 2 });
61 const fired = firedAlarms(app.db, NOW).filter((x) => x.key === "bug_clock");
62 expect(fired).toHaveLength(1);
63 expect(fired[0]!.href).toMatch(/^\/alice\/r\/issues\/\d+$/);
64 });
65
66 test("dispatch starts a queued bug run before a feature run that queued first", () => {
67 const feature = issue(1, []);
68 const bug = issue(1, ["bug"]);
69 createRun(app.db, { repoId: "r", kind: "agent", issueId: feature });
70 createRun(app.db, { repoId: "r", kind: "agent", issueId: bug });
71 const order = startOrder(app).order.map((r) => r.issueId);
72 expect(order.indexOf(bug)).toBeLessThan(order.indexOf(feature));
73 });
74
75 test("the terminal screen counts for an admin and cuts the alarm to public for a guest", () => {
76 issue(60, ["bug"]);
77 const admin = { db: app.db, user: { isAdmin: true, kind: "person" } } as unknown as Session;
78 const guest = { db: app.db, user: undefined } as unknown as Session;
79 expect(bugLines(admin, NOW).join("\n")).toContain("Open bugs:");
80 expect(bugLines(admin, NOW).join("\n")).toContain("Bug past its clock");
81 expect(bugLines(guest, NOW).join("\n")).not.toContain("Open bugs:");
82 app.db.run("UPDATE repo SET visibility = 'private' WHERE id = 'r'");
83 expect(bugLines(guest, NOW).join("\n")).toContain("No bug is past its clock");
84 expect(bugLines(admin, NOW).join("\n")).toContain("Bug past its clock");
85 app.db.run("UPDATE repo SET visibility = 'public' WHERE id = 'r'");
86 });
87
88 test("bugs start ahead of other work, each side in its own order", () => {
89 const feature = issue(1, []);
90 const bug = issue(1, ["bug"]);
91 const run = (issueId: string): Run => ({ issueId }) as unknown as Run;
92 const out = bugsFirst(app.db, [run(feature), run(bug)]);
93 expect(out.map((r) => r.issueId)).toEqual([bug, feature]);
94 });
95 });
96
97 describe("home's bug figure", () => {
98 test("the tile shows the open count and how many are past their clock", async () => {
99 const { kpiRowFor } = await import("../src/web/studio/kpi_row.ts");
100 const { NO_REACH } = await import("../src/flow/reach.ts");
101 const k = { landedHours: new Array<number>(24).fill(0), landed: 0, live: null, builders: { running: 0, slots: 0, idle: null }, spend: { todayUsd: 0, ceilingUsd: null }, medianCheckMs: null, backupAt: null, reach: NO_REACH };
102 expect(kpiRowFor({ ...k, bugs: { open: 4, overdue: 2 } }, null).toString()).toContain("2 past its clock");
103 expect(kpiRowFor(k, null).toString()).not.toContain("Open bugs");
104 });
105 });
test/helpers/reach-tables.ts +6
@@ -6,3 +6,9 @@ export function reachTables(db: Database): void {
66 db.run("CREATE TABLE user (id TEXT)");
77 db.run("CREATE TABLE page_vital (day TEXT, visitor TEXT, load REAL, inp REAL)");
88 }
9
10 /** The tables `countBugs` reads, bare, beside a small `issue` table of the test's own. */
11 export function bugTables(db: Database): void {
12 db.run("CREATE TABLE issue_label (issue_id TEXT, label_id TEXT)");
13 db.run("CREATE TABLE label (id TEXT, name TEXT)");
14 }
test/home-kpi-page.test.ts +2 −2
@@ -25,7 +25,7 @@ afterAll(async () => {
2525 await rm(dataDir, { recursive: true, force: true });
2626 });
2727
28 test("home renders the nine tiles; only an admin sees them", async () => {
28 test("home renders the ten tiles; only an admin sees them", async () => {
2929 const mk = async (username: string, first = false): Promise<User> => {
3030 const r = await createUser(app.db, {
3131 origin: "seed",
@@ -60,7 +60,7 @@ test("home renders the nine tiles; only an admin sees them", async () => {
6060 view(yesterday, "z", 400, 160);
6161 const a = await home(admin);
6262 expect(a).toContain('class="stats kpi-row mb"');
63 expect((a.match(/class="stat"/g) ?? []).length).toBe(9);
63 expect((a.match(/class="stat"/g) ?? []).length).toBe(10);
6464 for (const href of ["/board", "/releases", "/boards", "/settings/crew", "/settings/backups"]) expect(a).toContain(`class="stat" href="${href}"`);
6565 // The tiles are §5 stats (#3982): none of the page-local kpi classes is left on home.
6666 for (const name of ["kpis", "kpi", "kpi-value", "kpi-note", "kpi-spark"]) expect(a).not.toMatch(new RegExp(`class="[^"]*\\b${name}\\b(?!-)`));
test/home-kpi-row.test.ts +9 −5
@@ -1,6 +1,6 @@
11 /** Home's figures row (#2871): the tiles, their links, studio words only, a sparkline of 24 points and one of 30 days of visitors (#3186). */
22 import { Database } from "bun:sqlite";
3 import { reachTables } from "./helpers/reach-tables.ts";
3 import { bugTables, reachTables } from "./helpers/reach-tables.ts";
44 import { NO_REACH, readReach } from "../src/flow/reach.ts";
55 import { describe, expect, test } from "bun:test";
66 import { mkdtempSync, rmSync } from "node:fs";
@@ -125,7 +125,8 @@ describe("the builders figure from the database", () => {
125125 db.run("CREATE TABLE repo (autonomy TEXT, autonomy_slots INTEGER, default_branch TEXT, id TEXT)");
126126 db.run("CREATE TABLE run (id TEXT, kind TEXT, state TEXT, started_at TEXT, finished_at TEXT, serving_at TEXT, env TEXT, commit_oid TEXT, cost_usd REAL, created_at TEXT)");
127127 db.run("CREATE TABLE pull (carried INTEGER NOT NULL DEFAULT 0, merged_at TEXT, merged_oid TEXT, base_oid TEXT, base_branch TEXT, issue_id TEXT)");
128 db.run("CREATE TABLE issue (id TEXT, repo_id TEXT)");
128 db.run("CREATE TABLE issue (id TEXT, repo_id TEXT, kind TEXT, state TEXT, created_at TEXT)");
129 bugTables(db);
129130 reachTables(db);
130131 db.run("INSERT INTO repo VALUES ('autonomous', 3, 'main', 'a'), ('autonomous', 1, 'main', 'b'), ('off', 8, 'main', 'c')");
131132 db.run("INSERT INTO run (id, kind, state) VALUES ('1','agent','running'), ('2','agent','running'), ('3','agent','queued'), ('4','check','running')");
@@ -189,7 +190,8 @@ describe("the spend figure from the database", () => {
189190 db.run("CREATE TABLE repo (autonomy TEXT, autonomy_slots INTEGER, default_branch TEXT, id TEXT)");
190191 db.run("CREATE TABLE run (id TEXT, kind TEXT, state TEXT, started_at TEXT, finished_at TEXT, serving_at TEXT, env TEXT, commit_oid TEXT, cost_usd REAL, created_at TEXT)");
191192 db.run("CREATE TABLE pull (carried INTEGER NOT NULL DEFAULT 0, merged_at TEXT, merged_oid TEXT, base_oid TEXT, base_branch TEXT, issue_id TEXT)");
192 db.run("CREATE TABLE issue (id TEXT, repo_id TEXT)");
193 db.run("CREATE TABLE issue (id TEXT, repo_id TEXT, kind TEXT, state TEXT, created_at TEXT)");
194 bugTables(db);
193195 reachTables(db);
194196 db.run(
195197 `INSERT INTO run (id, kind, cost_usd, created_at) VALUES
@@ -241,7 +243,8 @@ describe("home kpi row: the last off-box backup", () => {
241243 db.run("CREATE TABLE repo (autonomy TEXT, autonomy_slots INTEGER, default_branch TEXT, id TEXT)");
242244 db.run("CREATE TABLE run (id TEXT, kind TEXT, state TEXT, started_at TEXT, finished_at TEXT, serving_at TEXT, env TEXT, commit_oid TEXT, cost_usd REAL, created_at TEXT)");
243245 db.run("CREATE TABLE pull (carried INTEGER NOT NULL DEFAULT 0, merged_at TEXT, merged_oid TEXT, base_oid TEXT, base_branch TEXT, issue_id TEXT)");
244 db.run("CREATE TABLE issue (id TEXT, repo_id TEXT)");
246 db.run("CREATE TABLE issue (id TEXT, repo_id TEXT, kind TEXT, state TEXT, created_at TEXT)");
247 bugTables(db);
245248 reachTables(db);
246249 expect(readKpis(db, NOW, { ceilingUsd: null, dataDir: dir }).backupAt).toBeNull();
247250 recordMirror(dir, { where: "b2", attemptAt: hoursAgo(1), reachable: true, newest: "s1", newestAt: hoursAgo(3) });
@@ -431,7 +434,8 @@ describe("the forge ceiling hit (#5798)", () => {
431434 db.run("CREATE TABLE repo (autonomy TEXT, autonomy_slots INTEGER, default_branch TEXT, id TEXT)");
432435 db.run("CREATE TABLE run (id TEXT, kind TEXT, state TEXT, started_at TEXT, finished_at TEXT, serving_at TEXT, env TEXT, commit_oid TEXT, cost_usd REAL, created_at TEXT)");
433436 db.run("CREATE TABLE pull (carried INTEGER NOT NULL DEFAULT 0, merged_at TEXT, merged_oid TEXT, base_oid TEXT, base_branch TEXT, issue_id TEXT)");
434 db.run("CREATE TABLE issue (id TEXT, repo_id TEXT)");
437 db.run("CREATE TABLE issue (id TEXT, repo_id TEXT, kind TEXT, state TEXT, created_at TEXT)");
438 bugTables(db);
435439 reachTables(db);
436440 db.run(
437441 `INSERT INTO run (id, kind, cost_usd, created_at) VALUES
test/home-timing.test.ts +1 −1
@@ -24,7 +24,7 @@ const names = (parts: string[]): string[] => parts.map((p) => p.split(";")[0]!);
2424
2525 test("a cold look at the owner's home names each part of the kpis read", async () => {
2626 const cold = names(await timing(f.ownerCookie));
27 expect(KPI_PARTS).toEqual(["kpis.landed", "kpis.live", "kpis.checks", "kpis.builders", "kpis.spend", "kpis.reach", "kpis.backup"]);
27 expect(KPI_PARTS).toEqual(["kpis.landed", "kpis.live", "kpis.checks", "kpis.builders", "kpis.spend", "kpis.reach", "kpis.backup", "kpis.bugs"]);
2828 expect(cold).toEqual(expect.arrayContaining(["kpis", ...KPI_PARTS]));
2929 });
3030
test/palette-roster.test.ts +4
@@ -74,6 +74,7 @@ test("a site-admin person is offered the whole roster", () => {
7474 ["signins", "Signed in", "your browsers and tokens; d signs a browser out"],
7575 ["sentinel", "Sentinel", "the forge's open faults"],
7676 ["boards", "Boards", "the forge's boards and their panels"],
77 ["bugs", "Bugs", "open bugs and the bug clock"],
7778 ["home-pins", "Pinned panels", "the panels pinned to home: reorder and unpin"],
7879 ["origin-apps", "Origin apps", "your OAuth apps for connecting origins; a adds, d removes"],
7980 ["system-memory", "System memory", "RSS beside the JS heap, hourly for 24 h"],
@@ -171,6 +172,7 @@ test("a site-admin agent is offered the roster without the person-only entries",
171172 ["forge-spend", "Forge spend", "today and the last 24 hours, headroom per subscription"],
172173 ["proposal-tokens", "Proposal tokens", "mint, list and revoke what a child proposes with"],
173174 ["can", "What I can do", "your role and what it allows here"],
175 ["bugs", "Bugs", "open bugs and the bug clock"],
174176 ["audit", "Who can do what", "each app's doors from its contract.json: yes, own or no"],
175177 ["archived", "Archived repositories", "put away, not gone: open one or wake it"],
176178 ["cloud", "Cloud accounts", "provider tokens, what they are used for, today's calls"],
@@ -227,6 +229,7 @@ test("a member person is offered the signed-in roster without the admin entries"
227229 ["proposal-tokens", "Proposal tokens", "mint, list and revoke what a child proposes with"],
228230 ["model", "Model budget", "what your crew may spend, and add your own subscription"],
229231 ["can", "What I can do", "your role and what it allows here"],
232 ["bugs", "Bugs", "open bugs and the bug clock"],
230233 ["audit", "Who can do what", "each app's doors from its contract.json: yes, own or no"],
231234 ["archived", "Archived repositories", "put away, not gone: open one or wake it"],
232235 ["bulk", "Bulk actions", "your last bulk actions; u undoes one inside its day"],
@@ -259,6 +262,7 @@ test("signed out, the palette offers sign-in and the public entries", () => {
259262 ["signin", "Sign in", undefined],
260263 ["api", "API", "the forge's endpoints; run a GET"],
261264 ["changelog", "Changelog", "the last 30 releases and their pulls"],
265 ["bugs", "Bugs", "open bugs and the bug clock"],
262266 ["audit", "Who can do what", "each app's doors from its contract.json: yes, own or no"],
263267 ["front", "Front page", "the apps a first visitor sees, ranked; an admin pins or hides"],
264268 ["family", "Family", "the forge that sent this one, and the forges it sent"],

↑↓ navigate ↵ open ⌘↵ its app esc close

Keyboard shortcuts

Docs