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

/asked says what became of each ask in one line, finished from pull #5825's branch with only the crew's parking note trusted #7094

Merged opened this

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

Merged

scout/forge:scout/7084-asked-says-what-became-of-each-ask-in-on → main 11 commits fork 3 threads · 3 unresolved

  • Merged. scout/7084-asked-says-what-became-of-each-ask-in-on was merged into main as d7bf0b5.

Review

Sign in to review this change.
9 files changed +478 −95
docs/MCP.md +1 −1
@@ -42,7 +42,7 @@ for a tunnel and a forge that sleeps.
4242 | `app_members` | `{ repo, members?, dryRun? }`: the people a private app names, who may enter it and its previews and nothing else; a writer (never an agent) changes the list with an admin-scope token | yes |
4343 | `apps` | the apps you can see: live or not, where served, how each runs itself, what is working | |
4444 | `archive` | `{ repo | repos, archived?, dryRun? }`: archive (true) or wake (false) a repository, by name; planned first (`dryRun` is true unless `false`); only the repository's admin with an admin token, never an agent. `repos` (at most 50) runs the fleet `archive` operation over them, answers each one's outcome and the `operation` id; `limb` with `action: "undo"`, that `operation` and any one of the repositories as `app` reverts it for a day | yes |
45 | `asked` | `{ repo?, show?, order? }`: what people asked the crew for, by default nothing merged yet and oldest first: who and when, the pulls that close it, the crew's last activity, waiting/building/shipped/closed, stale when not shipped in three days and flagged when stale with nothing built; repositories you can write | |
45 | `asked` | `{ repo?, show?, order? }`: what people asked the crew for, by default nothing merged yet and oldest first: who and when, the pulls that close it, the crew's last activity, waiting/building/shipped/closed, the outcome (landed in a pull, who is building, parked and why, failed) and its sentence, stale when not shipped in three days and flagged when stale with nothing built; repositories you can write | |
4646 | `asks_decide` | `{ item, give_back? }`: let the decider answer a parked hand-over (`parked:<id>`); with `give_back`, give a hand-over back to the crew instead (`handover:<id>`, or `assigned:<id>` for a pull a pass gave up on and assigned to you, whose ask carries the pass's reason): it is assigned to the builder that last worked on it and a comment says so; the same as the card's **Give back to the crew** and `POST /api/v1/asks/give-back` | yes |
4747 | `asks_proposal` | `{ id, verb, words? }`: act on a wave proposal (`id` is the proposal's own id, not the ask row's `proposal:` id): `verb` is `approve` (opens its issues), `redirect` (with `words`), `later` or `again`; the owner only; answers what the verb did, as `POST /api/v1/asks/proposal/:id/:verb` | yes |
4848 | `asks_undecide` | `{ id }`: put a set-aside card back on your board within a day (`id` from `asks_decide`); too late or unknown answers a refusal, as `POST /api/v1/asks/decide/:id/undo` | yes |
src/core/asked.ts +166 −5
@@ -19,7 +19,8 @@
1919 */
2020
2121 import type { Database } from "bun:sqlite";
22 import { alreadyDone } from "./done_on_main.ts";
22 import { marksByName, type Mark } from "./marks.ts";
23 import { alreadyDone, BY_CREW, PARK_LEAD, PARK_LIKE } from "./done_on_main.ts";
2324 import { getIssuesByIds, type Issue } from "./issues.ts";
2425 import { closesFromBody } from "./pulls.ts";
2526 import { getRepo, listReposWritableTo, type Repo } from "./repos.ts";
@@ -36,8 +37,27 @@ export interface AskedPull {
3637 readonly state: "open" | "merged" | "closed";
3738 /** When the pull was opened. */
3839 readonly openedAt: string;
40 /** When it merged; null otherwise. */
41 readonly mergedAt: string | null;
42 /** Closed by the crew because it could not finish it, not by a person. */
43 readonly parked: boolean;
44 /** Who opened it. */
45 readonly author: string;
3946 }
4047
48 /**
49 * What became of an ask, read from its issue, pulls and runs alone:
50 * `parked` and `failed` need the owner; `building` and `unplaced` are in
51 * progress; `landed` is merged; `closed` is closed with nothing merged.
52 */
53 export type AskedOutcome =
54 | { readonly kind: "landed"; readonly pull: number; readonly at: string }
55 | { readonly kind: "building"; readonly by: string; readonly byMark?: Mark; readonly pull: number | null }
56 | { readonly kind: "parked"; readonly pull: number; readonly note: string }
57 | { readonly kind: "failed"; readonly by: string; readonly byMark?: Mark; readonly at: string }
58 | { readonly kind: "unplaced" }
59 | { readonly kind: "closed" };
60
4161 export interface Asked {
4262 readonly source: "issue";
4363 readonly repo: Repo;
@@ -56,6 +76,7 @@ export interface Asked {
5676 readonly lastCrewAt: string | null;
5777 /** The commits the crew found it done by on the base, while it waits for the asker to close it; empty otherwise. */
5878 readonly doneBy: readonly string[];
79 readonly outcome: AskedOutcome;
5980 }
6081
6182 export type AskedShow = "all" | "unshipped" | "flagged";
@@ -81,7 +102,7 @@ const DAY = 24 * 60 * 60 * 1000;
81102 const SCAN_STEP = 500;
82103
83104 /** An ask with everything but the crew's last activity, which is read per page. */
84 type Scanned = Omit<Asked, "lastCrewAt" | "doneBy">;
105 type Scanned = Omit<Asked, "lastCrewAt" | "doneBy" | "outcome">;
85106
86107 const shown = (a: Scanned, show: AskedShow): boolean =>
87108 show === "all" ||
@@ -101,6 +122,8 @@ interface PullRow {
101122 state: "open" | "closed";
102123 created_at: string;
103124 merged_at: string | null;
125 parked: number;
126 author: string;
104127 }
105128
106129 const placeholders = (n: number, from = 1): string =>
@@ -144,8 +167,9 @@ export function pullsByAsk(
144167 const rows = db
145168 .query<PullRow & { repo_id: string }, string[]>(
146169 `SELECT issue.id, issue.repo_id, issue.number, issue.title, issue.body, issue.state,
147 issue.created_at, pull.merged_at
148 FROM issue JOIN pull ON pull.issue_id = issue.id
170 issue.created_at, issue.parked, pull.merged_at,
171 user.username AS author
172 FROM issue JOIN pull ON pull.issue_id = issue.id JOIN user ON user.id = issue.author_id
149173 WHERE issue.repo_id IN (${placeholders(repoIds.length)})
150174 ORDER BY issue.number`,
151175 )
@@ -165,6 +189,9 @@ export function pullsByAsk(
165189 ? "open"
166190 : "closed",
167191 openedAt: row.created_at,
192 mergedAt: row.merged_at,
193 parked: row.parked === 1,
194 author: row.author,
168195 });
169196 pulls.set(key, list);
170197 }
@@ -287,16 +314,148 @@ export function askedIn(
287314 db,
288315 page.filter((a) => a.issue.state === "open").map((a) => a.issue.id),
289316 );
317 const outcomes = outcomesOf(db, page);
290318 return page.map((a) => {
291319 let at: string | null = last.get(a.issue.id) ?? null;
292320 for (const p of a.pulls) {
293321 const t = last.get(p.id);
294322 if (t !== undefined && (at === null || t > at)) at = t;
295323 }
296 return { ...a, lastCrewAt: at, doneBy: done.get(a.issue.id) ?? [] };
324 return {
325 ...a,
326 lastCrewAt: at,
327 doneBy: done.get(a.issue.id) ?? [],
328 outcome: outcomes.get(a.issue.id)!,
329 };
297330 });
298331 }
299332
333 /** The first sentence of a parking note, without the crew's fixed lead-in. */
334 export function firstSentence(text: string): string {
335 const lead = text.startsWith(PARK_LEAD) ? text.slice(PARK_LEAD.length).replace(/^:\s*/, "") : text;
336 const plain = lead.replace(/\s+/g, " ").trim();
337 const end = plain.search(/[.!?](\s|$)/);
338 return end === -1 ? plain : plain.slice(0, end + 1);
339 }
340
341 type Run = { state: string; by: string; at: string };
342
343 /** The newest agent run of each issue or pull in `ids`, in grouped reads. */
344 function lastRuns(db: Database, ids: readonly string[]): Map<string, Run> {
345 const out = new Map<string, Run>();
346 for (let i = 0; i < ids.length; i += 400) {
347 const batch = ids.slice(i, i + 400);
348 for (const r of db
349 .query<{ issue_id: string; state: string; by: string | null; at: string }, string[]>(
350 `SELECT run.issue_id, run.state, user.username AS by, run.created_at AS at
351 FROM run LEFT JOIN user ON user.id = run.actor_id
352 WHERE run.kind = 'agent' AND run.issue_id IN (${placeholders(batch.length)})
353 ORDER BY run.created_at, run.id`,
354 )
355 .all(...batch))
356 out.set(r.issue_id, { state: r.state, by: r.by ?? "the crew", at: r.at });
357 }
358 return out;
359 }
360
361 /** The newest parking note on each of the pulls `ids`, in grouped reads. */
362 function parkingNotes(db: Database, ids: readonly string[]): Map<string, string> {
363 const out = new Map<string, string>();
364 for (let i = 0; i < ids.length; i += 400) {
365 const batch = ids.slice(i, i + 400);
366 for (const c of db
367 .query<{ issue_id: string; body: string }, string[]>(
368 `SELECT n.issue_id, n.body FROM comment n
369 WHERE n.body LIKE '${PARK_LIKE}' AND ${BY_CREW} AND n.issue_id IN (${placeholders(batch.length)})
370 ORDER BY n.created_at, n.id`,
371 )
372 .all(...batch))
373 out.set(c.issue_id, c.body);
374 }
375 return out;
376 }
377
378 const newest = <T>(items: readonly T[], keep: (item: T) => boolean): T | undefined => {
379 for (let i = items.length - 1; i >= 0; i--) if (keep(items[i]!)) return items[i];
380 return undefined;
381 };
382
383 /** The outcome of each ask: its pulls first, then the issue, then the newest agent run. */
384 function outcomesOf(db: Database, page: readonly Scanned[]): Map<string, AskedOutcome> {
385 const runs = lastRuns(db, page.flatMap((a) => [a.issue.id, ...a.pulls.map((p) => p.id)]));
386 const notes = parkingNotes(db, page.flatMap((a) => a.pulls.filter((p) => p.parked).map((p) => p.id)));
387 const out = new Map<string, AskedOutcome>();
388 for (const a of page) {
389 const merged = newest(a.pulls, (p) => p.state === "merged");
390 const open = newest(a.pulls, (p) => p.state === "open");
391 const parked = newest(a.pulls, (p) => p.parked);
392 let run: Run | undefined;
393 for (const id of [a.issue.id, ...a.pulls.map((p) => p.id)]) {
394 const r = runs.get(id);
395 if (r !== undefined && (run === undefined || r.at > run.at)) run = r;
396 }
397 let outcome: AskedOutcome;
398 if (merged !== undefined)
399 outcome = { kind: "landed", pull: merged.number, at: merged.mergedAt ?? merged.openedAt };
400 else if (open !== undefined) outcome = { kind: "building", by: open.author, pull: open.number };
401 else if (a.state === "building") outcome = { kind: "building", by: run?.by ?? "the crew", pull: null };
402 else if (a.state === "closed") outcome = { kind: "closed" };
403 else if (parked !== undefined)
404 outcome = { kind: "parked", pull: parked.number, note: firstSentence(notes.get(parked.id) ?? "") };
405 else if (run?.state === "failed") outcome = { kind: "failed", by: run.by, at: run.at };
406 else outcome = { kind: "unplaced" };
407 out.set(a.issue.id, outcome);
408 }
409 // A name says who it is: the mark comes from the account, never from the name.
410 const marks = marksByName(db, [...out.values()].flatMap((o) => ("by" in o ? [o.by] : [])));
411 for (const [id, o] of out) {
412 const byMark = "by" in o ? marks.get(o.by) : undefined;
413 if (byMark !== undefined && "by" in o) out.set(id, { ...o, byMark });
414 }
415 return out;
416 }
417
418 /** 0 needs the owner, 1 is in progress, 2 landed, 3 closed with nothing merged. */
419 export function askedTier(a: Asked): 0 | 1 | 2 | 3 {
420 if (a.doneBy.length > 0 && a.outcome.kind !== "closed") return 0;
421 switch (a.outcome.kind) {
422 case "parked":
423 case "failed":
424 return 0;
425 case "building":
426 case "unplaced":
427 return 1;
428 case "landed":
429 return 2;
430 case "closed":
431 return 3;
432 }
433 }
434
435 /** A name with its mark word, as the terminal and the API print it. */
436 const named = (by: string, mark: Mark | undefined): string => (mark === undefined ? by : `${by} (${mark})`);
437
438 /** What became of the ask as plain words, for the terminal; the page links the same words. */
439 export function outcomeText(a: Asked): string {
440 if (a.doneBy.length > 0)
441 return `on main in ${a.doneBy.map((o) => o.slice(0, 7)).join(", ")}, waiting for you to close it`;
442 const o = a.outcome;
443 switch (o.kind) {
444 case "landed":
445 return `landed in #${o.pull}`;
446 case "building":
447 return o.pull === null ? `${named(o.by, o.byMark)} building` : `${named(o.by, o.byMark)} building in #${o.pull}`;
448 case "parked":
449 return `parked: ${o.note === "" ? "no note left" : o.note}`;
450 case "failed":
451 return `${named(o.by, o.byMark)} failed`;
452 case "unplaced":
453 return "not placed yet";
454 case "closed":
455 return "closed, nothing merged";
456 }
457 }
458
300459 export interface AskedCounts {
301460 /** Every ask. */
302461 readonly asked: number;
@@ -420,6 +579,8 @@ export function askedJson(a: Asked) {
420579 flagged: a.flagged,
421580 lastCrewAt: a.lastCrewAt,
422581 doneBy: a.doneBy,
582 outcome: a.outcome,
583 outcomeText: outcomeText(a),
423584 pulls: a.pulls.map((p) => ({
424585 number: p.number,
425586 title: p.title,
src/core/done_on_main.ts +2 −1
@@ -15,7 +15,8 @@ export const DONE_NOTE = "Done on main already, waiting for you to close it: ";
1515 export const DONE_LIKE = `${DONE_NOTE.replace(/[%_]/g, "")}%`;
1616
1717 /** What every park note starts with, for `LIKE`. */
18 export const PARK_LIKE = "Parked by the crew%";
18 export const PARK_LEAD = "Parked by the crew";
19 export const PARK_LIKE = `${PARK_LEAD}%`;
1920
2021 /** What the comment on a close of such an issue starts with. */
2122 export const CLOSED_NOTE = "Closed: already done on main in ";
src/http/mcp/tools/asked.ts +1 −1
@@ -5,7 +5,7 @@ import { ORDER_WORDS } from "../../../core/arg_words.ts";
55
66 export const tool: Tool = {
77 name: "asked",
8 doc: "`{ repo?, show?, order? }`: what people asked the crew for, by default nothing merged yet and oldest first: who and when, the pulls that close it, the crew's last activity, waiting/building/shipped/closed, stale when not shipped in three days and flagged when stale with nothing built; repositories you can write",
8 doc: "`{ repo?, show?, order? }`: what people asked the crew for, by default nothing merged yet and oldest first: who and when, the pulls that close it, the crew's last activity, waiting/building/shipped/closed, the outcome (landed in a pull, who is building, parked and why, failed) and its sentence, stale when not shipped in three days and flagged when stale with nothing built; repositories you can write",
99 description:
1010 "What people asked the crew for: unmerged by default, oldest first.",
1111 inputSchema: {
src/tui/screens/asked.ts +10 −5
@@ -10,7 +10,7 @@
1010
1111 import type { Database } from "bun:sqlite";
1212 import { NOT_PLACED_YET } from "../../actions/ask_state_words.ts";
13 import { askedIn, askedRepos, type Asked } from "../../core/asked.ts";
13 import { askedIn, askedRepos, askedTier, outcomeText, type Asked } from "../../core/asked.ts";
1414 import { thoughtStream, type StreamThought } from "../../core/thought_stream.ts";
1515 import type { User } from "../../core/users.ts";
1616 import type { Ctx, Screen } from "../app/app.ts";
@@ -60,13 +60,15 @@ function thoughtLine(t: StreamThought): AskedLine {
6060 };
6161 }
6262
63 /** The words the page's badge uses, so the two doors agree. */

Non-blocking: type this as Record<AskedOutcome["kind"], string>. tsc then makes sure every kind has a word, and the ?? a.outcome.kind fallback on the next line can go, since it can never run.

64 const OUTCOME_WORD: Record<string, string> = { unplaced: "waiting", landed: "landed", failed: "failed", parked: "parked", building: "building", closed: "closed" };
outdated

Non-blocking: in the terminal the meta word is now the raw enum (unplaced, landed, failed), while the page's badge says Waiting, Landed or Failed. Use the same words the page's badge uses, so the two doors agree.

65
6366 function askLine(a: Asked): AskedLine {
64 const word =
65 a.doneBy.length > 0 ? "done on main" : a.flagged ? "nothing built" : a.state;
67 const word = a.doneBy.length > 0 ? "done on main" : a.flagged ? "nothing built" : (OUTCOME_WORD[a.outcome.kind] ?? a.outcome.kind);
6668 return {
6769 title: plain(a.issue.title),
6870 ...toneOf(a.flagged, a.state),
69 secondary: `${a.repo.ownerName}/${a.repo.name} #${a.issue.number} · ${plain(a.askedBy)}`,
71 secondary: `${plain(outcomeText(a))} · ${a.repo.ownerName}/${a.repo.name} #${a.issue.number}`,
7072 meta: metaOf(word, relTime(a.askedAt)),
7173 path: `/${a.repo.ownerName}/${a.repo.name}/issues/${a.issue.number}`,
7274 };
@@ -78,7 +80,10 @@ export function askedLines(db: Database, viewer: User | undefined): { thoughts:
7880 const repos = askedRepos(db, viewer, null);
7981 return {
8082 thoughts: thoughtStream(db, viewer, { limit: 50 }).map(thoughtLine),
81 asks: askedIn(db, repos, { show: "unshipped", order: "oldest" }).map(askLine),
83 // Needs the owner first, then in progress; the sort is stable, so each keeps oldest first.
84 asks: askedIn(db, repos, { show: "unshipped", order: "oldest" })
85 .sort((x, y) => askedTier(x) - askedTier(y))
86 .map(askLine),
8287 };
8388 }
8489
src/web/studio/asked_page.ts +72 −73
@@ -1,23 +1,25 @@
11 /**
22 * `/asked`: everything a person asked for, oldest unmet first, and what
3 * became of it (`core/asked.ts`). The path: the owner wonders whether something he
4 * asked for was heard, opens this from the asks page or the board, and
5 * opens the ask. One row per ask, one word per row; a row with nothing built
6 * after the stale window says so. `?show=` narrows it, `?repo=owner/name`
7 * keeps it to one repository.
3 * became of it (`core/asked.ts`). The path: the owner wonders whether something
4 * they asked for was heard, opens this from the asks page or the board, and
5 * opens the ask. One row per ask, one sentence per row: what was asked, then
6 * what became of it, each linked to its evidence. `?show=` and `?order=`
7 * choose the asks; within them, those that need the owner come first, then
8 * those in progress, then what landed, then what was closed with nothing
9 * merged. `?repo=owner/name` keeps it to one repository.
810 */
911
1012 import {
1113 askedCounts,
1214 askedIn,
1315 askedRepos,
16 askedTier,
1417 isAskedOrder,
1518 isAskedShow,
16 STALE_DAYS,
19 outcomeText,
1720 type Asked,
1821 type AskedOrder,
1922 type AskedShow,
20 type AskedState,
2123 } from "../../core/asked.ts";
2224 import {
2325 flagDays,
@@ -38,18 +40,11 @@ import {
3840 row,
3941 seg,
4042 time,
41 timeAgo,
43 userTag,
4244 type Tone,
4345 } from "../ui.ts";
4446 import { homeTabs } from "./home_tabs.ts";
4547
46 const WORD: Record<AskedState, { text: string; tone: Tone }> = {
47 waiting: { text: "Waiting", tone: "default" },
48 building: { text: "Building", tone: "attn" },
49 shipped: { text: "Shipped", tone: "success" },
50 closed: { text: "Closed", tone: "default" },
51 };
52
5348 const THOUGHT_WORD: Record<ThoughtState, { text: string; tone: Tone }> = {
5449 unplaced: { text: "Heard", tone: "default" },
5550 placed: { text: "Placed", tone: "default" },
@@ -103,58 +98,75 @@ function oldestFlaggedHref(stream: readonly StreamThought[]): string | null {
10398 return null;
10499 }
105100
106 function pullNote(a: Asked): string {
107 // The row is one link, so the commits are named here and linked on the ask.
108 if (a.doneBy.length > 0)
109 return `on main in ${a.doneBy.map((o) => o.slice(0, 7)).join(", ")}, waiting for you to close it`;
110 const merged = a.pulls.find((p) => p.state === "merged");
111 if (merged) return `pull #${merged.number} merged`;
112 const open = a.pulls.find((p) => p.state === "open");
113 if (open) return `pull #${open.number} open`;
114 return a.pulls.length > 0 ? "no pull merged" : "no pull yet";
115 }
101 /** The ask, cut at the last space before 80 characters (a hard cut when it has none). */
102 const asking = (a: Asked): string => {
103 const text = a.issue.title.replace(/\s+/g, " ").trim();
104 if (text.length <= 80) return text;
105 const space = text.lastIndexOf(" ", 79);
106 return `${text.slice(0, space > 0 ? space : 79)}…`;
107 };
116108
117109 /**
outdated

Non-blocking: NOT_PLACED_YET_SHORT repeats the "not placed yet" that outcomeText already returns, and NOT_PLACED_YET lives in actions/ask_state_words.ts. Wrap outcomeText(a) here as the parked and closed cases do, and drop the extra constant.

118 * The one badge a row wears. Red is for an old ask with nothing to show.
119 * An old ask whose pull is open says that, and how long the pull has been
120 * open, which is the next thing a person asks.
110 * What became of the ask, as the end of its sentence, linked to the pull or
111 * issue that says so. Only an ask with nothing built wears red; the rest are
112 * green for landed, amber for under way, and plain otherwise.
121113 */
122 function wordFor(a: Asked): { text: string; tone: Tone } {
123 if (a.doneBy.length > 0) return { text: "Done on main", tone: "success" };
124 if (a.flagged) return { text: "Nothing built", tone: "danger" };
125 const open = a.pulls.find((p) => p.state === "open");
126 if (a.stale && open !== undefined)
114 function outcomeOf(a: Asked): { text: Html; badge: { text: string; tone: Tone } } {
115 const base = repoBase(a.repo);
116 const issue = `${base}/issues/${a.issue.number}`;
117 const pull = (n: number) => html`<a href="${base}/pulls/${n}">#${n}</a>`;
118 const flagged: Tone = a.flagged ? "danger" : "default";
119 if (a.doneBy.length > 0)
127120 return {
128 text: `Pull open ${timeAgo(open.openedAt).replace(/ ago$/, "")}`,
129 tone: "attn",
121 text: html`<a href="${issue}">${outcomeText(a)}</a>`,
122 badge: { text: "Done on main", tone: "success" },
130123 };
131 return WORD[a.state];
124 const o = a.outcome;
125 switch (o.kind) {
126 case "landed":
127 return { text: html`landed in ${pull(o.pull)} ${time(o.at)}`, badge: { text: "Landed", tone: "success" } };
128 case "building":
129 return {
130 text: o.pull === null
131 ? html`${userTag(o.by, { avatar: false, mark: o.byMark })} <a href="${issue}">building</a>`
132 : html`${userTag(o.by, { avatar: false, mark: o.byMark })} building in ${pull(o.pull)}`,
133 badge: { text: "Building", tone: "attn" },
134 };
135 case "parked":
136 return {
137 text: html`<a href="${base}/pulls/${o.pull}">${outcomeText(a)}</a>`,
138 badge: { text: a.flagged ? "Nothing built" : "Parked", tone: flagged },
139 };
140 case "failed":
141 return {
142 text: html`${userTag(o.by, { avatar: false, mark: o.byMark })} <a href="${issue}">failed</a> ${time(o.at)}`,
143 badge: { text: a.flagged ? "Nothing built" : "Failed", tone: flagged },
144 };
145 case "closed":
146 return { text: html`<a href="${issue}">${outcomeText(a)}</a>`, badge: { text: "Closed", tone: "default" } };
147 default:
148 return {
149 text: html`<a href="${issue}">${outcomeText(a)}</a>`,
150 badge: { text: a.flagged ? "Nothing built" : "Waiting", tone: flagged },
151 };
152 }
132153 }
133154
134155 function askedRow(a: Asked): Html {
135 const word = wordFor(a);
156 const out = outcomeOf(a);
136157 return row({
137 href: `${repoBase(a.repo)}/issues/${a.issue.number}`,
138 nav: true,
139 title: a.issue.title,
140 // What became of it comes first: the line is cut at a phone's width, and
141 // what is cut is where and who, which the ask's own page says.
142 meta: html`${a.lastCrewAt === null
143 ? "crew not on it yet"
144 : html`crew ${time(a.lastCrewAt)}`}
145 · ${pullNote(a)} · ${a.repo.ownerName}/${a.repo.name} #${a.issue.number} ·
146 ${a.askedBy} · ${time(a.askedAt)}`,
147 // One badge per row, so a phone keeps the title.
148 trail: badge(word.text, { tone: word.tone }),
149 titleAttr: a.flagged
150 ? `${WORD[a.state].text}, and nothing built in ${STALE_DAYS} days`
151 : a.issue.title,
158 title: html`<a href="${repoBase(a.repo)}/issues/${a.issue.number}">${asking(a)}</a>`,
159 titleAttr: a.issue.title,
160 meta: html`${out.text} · ${a.repo.ownerName}/${a.repo.name} #${a.issue.number}`,
161 trail: badge(out.badge.text, { tone: out.badge.tone }),
152162 });
153163 }
154164
155165 /**
156166 * GET /asked — `?show=unshipped` (the default: nothing merged yet),
157167 * `flagged` or `all`; oldest first unless `?order=newest`; `?repo=` for one.
168 * Within the order, asks that need the owner come before those in progress,
169 * and those before what landed.
158170 */
159171 export function askedPage(ctx: Ctx): Response {
160172 notePhase(ctx.request, "session", takeSessionTime(ctx.request));
@@ -180,6 +192,8 @@ export function askedPage(ctx: Ctx): Response {
180192 const stream = timePhase(ctx.request, "thoughts", () => thoughtStream(ctx.app.db, user, { limit: 50 }));
181193 const days = timePhase(ctx.request, "flags", () => flagDays(ctx.app.db, user.id));
182194 const rendering = performance.now();
195 // The sort is stable, so each group keeps the order asked for.
196 const rows = [...listed].sort((x, y) => askedTier(x) - askedTier(y));
183197 const next = oldestFlaggedHref(stream);
184198 const flaggedThoughts = stream.filter((t) => t.flagged).length;
185199 const thoughts =
@@ -202,7 +216,7 @@ export function askedPage(ctx: Ctx): Response {
202216 const sentence =
203217 asked === 0
204218 ? "Nothing asked yet"
205 : `${asked} asked · ${shipped} shipped${flagged > 0 ? ` · ${flagged} with nothing built` : ""}`;
219 : `${asked} asked · ${shipped} landed${flagged > 0 ? ` · ${flagged} with nothing built` : ""}`;
206220
207221 const body = html`${homeTabs("asked", unshipped)}
208222 <h1 id="asked-sentence">What you asked for</h1>
@@ -210,31 +224,16 @@ export function askedPage(ctx: Ctx): Response {
210224 ${thoughts}
211225 <div class="bar mb mt">
212226 ${seg([
213 {
214 label: "Not shipped",
215 href: href("unshipped"),
216 current: show === "unshipped",
217 count: unshipped,
218 },
219 {
220 label: "Nothing built",
221 href: href("flagged"),
222 current: show === "flagged",
223 count: flagged,
224 },
225 {
226 label: "All",
227 href: href("all"),
228 current: show === "all",
229 count: asked,
230 },
227 { label: "Not shipped", href: href("unshipped"), current: show === "unshipped", count: unshipped },
228 { label: "Nothing built", href: href("flagged"), current: show === "flagged", count: flagged },
229 { label: "All", href: href("all"), current: show === "all", count: asked },
231230 ])}
232231 <span class="spacer"></span>
233232 <a class="small muted" href="${href(show, order === "oldest" ? "newest" : "oldest")}"
234233 >${order === "oldest" ? "Show newest first" : "Show oldest first"}</a
235234 >
236235 </div>
237 ${listed.length === 0
236 ${rows.length === 0
238237 ? empty({
239238 icon: "issue",
240239 title: asked === 0 ? "Nothing asked yet" : "Nothing here",
@@ -243,7 +242,7 @@ export function askedPage(ctx: Ctx): Response {
243242 ? "What a person tells the crew shows up here, with what became of it."
244243 : "Every ask in this view has something built for it.",
245244 })
246 : listCard(listed.map(askedRow))}`;
245 : listCard(rows.map(askedRow))}`;
247246 const response = page({ ctx, title: "What you asked for", body });
248247 notePhase(ctx.request, "render", performance.now() - rendering);
249248 return response;
test/asked-outcomes.test.ts added +218
@@ -0,0 +1,218 @@
1 /** `/asked` reads as one sentence per ask: what was asked, then what became of it, linked to its evidence, needing-the-owner first (#5802). */
2 import { afterAll, beforeAll, expect, test } from "bun:test";
3 import { mkdtempSync, rmSync } from "node:fs";
4 import { tmpdir } from "node:os";
5 import { join } from "node:path";
6 import { createAgent } from "../src/actions/crew.ts";
7 import { createRepo } from "../src/actions/repos.ts";
8 import { createApp, type App } from "../src/app.ts";
9 import { firstSentence } from "../src/core/asked.ts";
10 import { createIssue, addComment } from "../src/core/issues.ts";
11 import { createRun } from "../src/core/runs.ts";
12 import { createToken } from "../src/core/tokens.ts";
13 import { askedLines } from "../src/tui/screens/asked.ts";
14 import { createUser, getUserByName, type User } from "../src/core/users.ts";
15 import { serve, type Servers } from "../src/http/server.ts";
16 import { proveAll } from "./helpers/verified.ts";
17 import { quietLogs } from "./helpers/globals.ts";
18
19 quietLogs();
20
21 const DAY = 24 * 60 * 60 * 1000;
22 let root = "";
23 let app: App;
24 let servers: Servers;
25 let cookie = "";
26 let alice: User;
27 let bot: User;
28 let repoId = "";
29 const numbers: Record<string, number> = {};
30
31 const ago = (days: number) => new Date(Date.now() - days * DAY).toISOString();
32
33 const ask = (key: string, title: string) => {
34 const made = createIssue(app.db, { repoId, authorId: alice.id, title });
35 if (!made.ok) throw new Error(made.error);
36 numbers[key] = made.value.number;
37 return made.value;
38 };
39
40 /** A pull by `author` whose body closes `issue`. */
41 const pullFor = (key: string, issue: { number: number }, author: User, extra: string) => {
42 const made = createIssue(app.db, {
43 repoId,
44 authorId: author.id,
45 title: `Answers ${issue.number}`,
46 body: `Closes #${issue.number}`,
47 kind: "pull",
48 });
49 if (!made.ok) throw new Error(made.error);
50 numbers[key] = made.value.number;
51 app.db
52 .query(`INSERT INTO pull (issue_id, base_branch, head_branch, base_oid, head_oid, merged_at)
53 VALUES (?1, 'main', ?2, 'a', 'b', ?3)`)
54 .run(made.value.id, key, extra === "" ? null : extra);
55 return made.value;
56 };
57
58 beforeAll(async () => {
59 root = mkdtempSync(join(tmpdir(), "forge-asked-outcomes-"));
60 app = createApp({ dataDir: root, port: 0, forgeRepo: "alice/forge" });
61 const u = await createUser(app.db, {
62 origin: "seed", trusted: true, username: "alice", email: "[email protected]",
63 password: "a-good-passphrase", adminIfFirst: true,
64 });
65 if (!u.ok) throw new Error(u.error);
66 alice = u.value;
67 const b = await createAgent(app, alice, { username: "bot" });
68 if (!b.ok) throw new Error(String(b.error));
69 bot = getUserByName(app.db, "bot")!;
70 proveAll(app);
71 const made = await createRepo(app, alice, { name: "garden" });
72 if (!made.ok) throw new Error(String(made.error));
73 repoId = made.value.id;
74
75 app.db.query("INSERT INTO repo_crew (repo_id, user_id) VALUES (?1, ?2)").run(repoId, bot.id);
76 const landed = ask("landed", "Prune the roses");
77 const merged = pullFor("landedPull", landed, bot, ago(1));
78 app.db.query("UPDATE issue SET state = 'closed' WHERE id IN (?1, ?2)").run(landed.id, merged.id);
79 const old = ask("old", "Paint the shed");
80 const oldPull = pullFor("oldPull", old, bot, ago(20));
81 app.db.query("UPDATE issue SET state = 'closed' WHERE id IN (?1, ?2)").run(old.id, oldPull.id);
82
83 const building = ask("building", "A watering schedule");
84 pullFor("buildingPull", building, bot, "");
85
86 const parked = ask("parked", "Plant tomatoes in the north bed");
87 const parkedPull = pullFor("parkedPull", parked, bot, "");
88 app.db.query("UPDATE issue SET state = 'closed', parked = 1 WHERE id = ?1").run(parkedPull.id);
89 addComment(app.db, {
90 issueId: parkedPull.id,
91 authorId: bot.id,
92 body: "Parked by the crew: the check failed twice on the same test. Run it again by hand.",
93 });
94 addComment(app.db, { issueId: parkedPull.id, authorId: alice.id, body: "Parked by the crew: spoofed" });
95
96 const failed = ask("failed", "Fix the gate");
97 const run = createRun(app.db, { repoId, kind: "agent", issueId: failed.id, actorId: bot.id });
98 app.db.query("UPDATE run SET state = 'failed' WHERE id = ?1").run(run.id);
99
100 ask("unplaced", "x".repeat(100));
101 ask("spaced", `${"word ".repeat(15)}tails and more tail`);
102
103 const dropped = ask("dropped", "Dropped idea");
104 app.db.query("UPDATE issue SET state = 'closed' WHERE id = ?1").run(dropped.id);
105
106 servers = await serve(app);
107 const signin = await fetch(`${servers.url}/signin`, {
108 method: "POST",
109 headers: { origin: servers.url },
110 body: new URLSearchParams({ username: "alice", password: "a-good-passphrase" }),
111 redirect: "manual",
112 });
113 cookie = (signin.headers.get("set-cookie") ?? "").split(";")[0]!;
114 }, 30_000);
115
116 afterAll(() => {
117 servers.stop();
118 app.close();
119 rmSync(root, { recursive: true, force: true });
120 });
121
122 const read = async () => {
123 const res = await fetch(`${servers.url}/asked?show=all`, { headers: { cookie } });
124 expect(res.status).toBe(200);
125 return (await res.text()).replace(/\s+/g, " ");
126 };
127 /** The row holding this ask's title, as text and as hrefs. */
128 const rowOf = (page: string, title: string) => {
129 const at = page.indexOf(`>${title}</a>`);
130 expect(at).toBeGreaterThan(-1);
131 const start = page.lastIndexOf("<li", at);
132 const end = page.indexOf("</li>", at);
133 const li = page.slice(start, end);
134 return {
135 text: li.replace(/<[^>]+>/g, "").replace(/\s+/g, " ").trim(),
136 hrefs: [...li.matchAll(/href="([^"]+)"/g)].map((m) => m[1]!),
137 };
138 };
139
140 test("each state reads as one sentence with a link to its evidence", async () => {
141 const page = await read();
142 const n = numbers;
143 const landed = rowOf(page, "Prune the roses");
144 expect(landed.text).toContain(`landed in #${n.landedPull}`);
145 expect(landed.hrefs).toContain(`/alice/garden/pulls/${n.landedPull}`);
146 expect(landed.hrefs).toContain(`/alice/garden/issues/${n.landed}`);
147
148 const building = rowOf(page, "A watering schedule");
149 expect(building.text).toContain(`botcrew building in #${n.buildingPull}`);
150 expect(building.hrefs).toContain(`/alice/garden/pulls/${n.buildingPull}`);
151
152 const parked = rowOf(page, "Plant tomatoes in the north bed");
153 expect(parked.text).toContain("parked: the check failed twice on the same test.");
154 expect(parked.text).not.toContain("Run it again");
155 expect(parked.hrefs).toContain(`/alice/garden/pulls/${n.parkedPull}`);
156
157 const failed = rowOf(page, "Fix the gate");
158 expect(failed.text).toContain("botcrew failed");
159 expect(failed.hrefs).toContain(`/alice/garden/issues/${n.failed}`);
160
161 const unplaced = rowOf(page, `${"x".repeat(79)}…`);
162 expect(unplaced.text).toContain("not placed yet");
163 expect(unplaced.hrefs).toContain(`/alice/garden/issues/${n.unplaced}`);
164 });
165
166 test("what needs the owner comes first, then progress, then what landed", async () => {
167 const page = await read();
168 const at = (t: string) => page.indexOf(`>${t}</a>`);
169 const order = ["Plant tomatoes in the north bed", "Fix the gate", "A watering schedule", "Prune the roses"].map(at);
170 expect(order.every((i) => i > -1)).toBe(true);
171 expect(order[0]!).toBeLessThan(order[2]!);
172 expect(order[1]!).toBeLessThan(order[2]!);
173 expect(order[2]!).toBeLessThan(order[3]!);
174 expect(page.indexOf(`>${"x".repeat(79)}…</a>`)).toBeLessThan(order[3]!);
175 expect(at("Paint the shed")).toBeGreaterThan(order[2]!);
176 });
177
178 test("the API, the MCP and the terminal carry the same outcome and sentence", async () => {
179 const res = await fetch(`${servers.url}/api/v1/asked?show=all`, { headers: { authorization: `Bearer ${createToken(app.db, alice.id, "ro", "ro").token}` } });
180 const rows = ((await res.json()) as any).asked as any[];
181 const by = (t: string) => rows.find((r) => r.title === t);
182 expect(by("Prune the roses").outcome.kind).toBe("landed");
183 expect(by("Prune the roses").outcomeText).toBe(`landed in #${numbers.landedPull}`);
184 expect(by("Plant tomatoes in the north bed").outcomeText).toBe("parked: the check failed twice on the same test.");
185 expect(by("Fix the gate").outcome).toMatchObject({ kind: "failed", by: "bot" });
186 const lines = askedLines(app.db, getUserByName(app.db, "alice")!).asks;
187 expect(lines.map((l) => l.title)[0]).toBe("Plant tomatoes in the north bed");
188 expect(lines[0]!.secondary).toStartWith("parked: the check failed twice");
189 });
190
191 test("All lists every ask it counts, closed ones last", async () => {
192 const page = await read();
193 const counted = Number(page.match(/>All<span class="pill" >(\d+)</)?.[1]);
194 expect(counted).toBeGreaterThan(0);
195 expect(page.split('<div class="row"').length - 1).toBe(counted);
196 const dropped = rowOf(page, "Dropped idea");
197 expect(dropped.text).toContain("closed, nothing merged");
198 expect(page.indexOf(">Dropped idea</a>")).toBeGreaterThan(page.indexOf(">Prune the roses</a>"));
199 });
200
201 test("a name in the sentence wears its mark, outside the link", async () => {
202 const page = await read();
203 const li = page.slice(page.lastIndexOf("<li", page.indexOf(">A watering schedule</a>")), page.indexOf("</li>", page.indexOf(">A watering schedule</a>")));
204 expect(li).toContain('data-mark="crew"');
205 expect(li).not.toMatch(/<a [^>]*>[^<]*bot/);
206 const lines = askedLines(app.db, alice).asks.map((l) => l.secondary);
207 expect(lines.some((t) => t?.startsWith("bot (crew) building in #"))).toBe(true);
208 });
209
210 test("a parking note is cut at its first sentence", () => {
211 expect(firstSentence("the check failed. Then more.")).toBe("the check failed.");
212 expect(firstSentence("no full stop")).toBe("no full stop");
213 });
214
215 test("a long ask is cut at the last space before 80 characters", async () => {
216 const page = await read();
217 expect(page).toContain(`>${"word ".repeat(15).trim()}…</a>`);
218 });
test/asked-timing.test.ts +1 −1
@@ -82,7 +82,7 @@ test("GET /asked names its phases and answers within its budget with 500 asks",
8282 const res = await get();
8383 const body = await res.text();
8484 expect(res.status).toBe(200);
85 expect(body).toContain("500 asked · 250 shipped");
85 expect(body).toContain("500 asked · 250 landed");
8686 const parts = (res.headers.get("server-timing") ?? "").split(", ");
8787 for (const name of ["app", "session", "repos", "counts", "asks", "thoughts", "flags", "render"])
8888 expect(parts.find((p) => p.startsWith(`${name};`))).toMatch(/^[a-z]+;dur=\d+\.\d$/);
test/asked.test.ts +7 −8
@@ -272,11 +272,10 @@ describe("what you asked for", () => {
272272 expect(page).toContain("Not shipped");
273273 expect(page).toContain("Nothing built");
274274 expect(page).toContain("Show newest first");
275 expect(page).toContain("nothing built in 3 days");
276275 expect(page).not.toContain("Refactor the watering module");
277276 // The default view is what has nothing merged yet: the shipped ask waits behind "All".
278277 expect(page).not.toContain("Prune the roses");
279 expect(page).toContain("crew not on it yet");
278 expect(page).toContain("not placed yet");
280279 expect(page.indexOf("Fix the gate")).toBeLessThan(page.indexOf("Plant tomatoes"));
281280 // Reached from the board and the feed's header, not only the asks page.
282281 expect(await (await fetch(`${servers.url}/board`, { headers: { cookie } })).text()).toContain('href="/asked"');
@@ -343,7 +342,7 @@ describe("what you asked for", () => {
343342 (
344343 page.match(
345344 new RegExp(
346 `${title}</span\\s*>\\s*<span class="row-meta">([\\s\\S]*?)</span>`,
345 `${title}</a></span\\s*>\\s*<span class="row-meta">([\\s\\S]*?)</span>`,
347346 ),
348347 )?.[1] ?? ""
349348 )
@@ -351,10 +350,10 @@ describe("what you asked for", () => {
351350 .replace(/\s+/g, " ")
352351 .trim();
353352 expect(meta("Plant tomatoes")).toStartWith(
354 "crew not on it yet · no pull yet · alice/garden #",
353 "not placed yet · alice/garden #",
355354 );
356355 expect(meta("A watering schedule")).toMatch(
357 /^crew .+ · pull #\d+ open · alice\/garden #\d+ · alice · /,
356 /^aliceowner building in #\d+ · alice\/garden #\d+$/,
358357 );
359358 });
360359
@@ -520,7 +519,7 @@ describe("an old ask says what exists for it", () => {
520519 const row =
521520 page.match(
522521 new RegExp(
523 `${title}</span[\\s\\S]*?<span class="row-trail">([\\s\\S]*?)</a>`,
522 `${title}</a></span[\\s\\S]*?<span class="row-trail">([\\s\\S]*?)</li>`,
524523 ),
525524 )?.[1] ?? "";
526525 const tone = row.match(/data-variant="(\w+)"/)?.[1] ?? "default";
@@ -531,10 +530,10 @@ describe("an old ask says what exists for it", () => {
531530 };
532531 expect(badgeOf("Nothing at all")).toBe("danger: Nothing built");
533532 expect(badgeOf("Its only pull closed")).toBe("danger: Nothing built");
534 expect(badgeOf("A pull is open")).toBe("attn: Pull open 2d");
533 expect(badgeOf("A pull is open")).toBe("attn: Building");
535534 expect(badgeOf("The crew is on it")).toBe("attn: Building");
536535 expect(badgeOf("Asked today")).toBe("attn: Building");
537 expect(page).toContain("5 asked · 0 shipped · 2 with nothing built");
536 expect(page).toContain("5 asked · 0 landed · 2 with nothing built");
538537 });
539538
540539 test("the API and the MCP say stale and flagged apart, and when each pull was opened", async () => {

↑↓ navigate ↵ open ⌘↵ its app esc close

Keyboard shortcuts

Docs