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
Check · Review · Land · Live done — Landed and live in production.
scout/forge:scout/7084-asked-says-what-became-of-each-ask-in-on → main 11 commits fork 3 threads · 3 unresolved
docs/MCP.md +1 −1
| @@ -42,7 +42,7 @@ for a tunnel and a forge that sleeps. | ||
| 42 | 42 | | `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 | |
| 43 | 43 | | `apps` | the apps you can see: live or not, where served, how each runs itself, what is working | | |
| 44 | 44 | | `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 | | | |
| 46 | 46 | | `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 | |
| 47 | 47 | | `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 | |
| 48 | 48 | | `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 @@ | ||
| 19 | 19 | */ |
| 20 | 20 | |
| 21 | 21 | 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"; | |
| 23 | 24 | import { getIssuesByIds, type Issue } from "./issues.ts"; |
| 24 | 25 | import { closesFromBody } from "./pulls.ts"; |
| 25 | 26 | import { getRepo, listReposWritableTo, type Repo } from "./repos.ts"; |
| @@ -36,8 +37,27 @@ export interface AskedPull { | ||
| 36 | 37 | readonly state: "open" | "merged" | "closed"; |
| 37 | 38 | /** When the pull was opened. */ |
| 38 | 39 | 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; | |
| 39 | 46 | } |
| 40 | 47 | |
| 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 | ||
| 41 | 61 | export interface Asked { |
| 42 | 62 | readonly source: "issue"; |
| 43 | 63 | readonly repo: Repo; |
| @@ -56,6 +76,7 @@ export interface Asked { | ||
| 56 | 76 | readonly lastCrewAt: string | null; |
| 57 | 77 | /** The commits the crew found it done by on the base, while it waits for the asker to close it; empty otherwise. */ |
| 58 | 78 | readonly doneBy: readonly string[]; |
| 79 | readonly outcome: AskedOutcome; | |
| 59 | 80 | } |
| 60 | 81 | |
| 61 | 82 | export type AskedShow = "all" | "unshipped" | "flagged"; |
| @@ -81,7 +102,7 @@ const DAY = 24 * 60 * 60 * 1000; | ||
| 81 | 102 | const SCAN_STEP = 500; |
| 82 | 103 | |
| 83 | 104 | /** 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">; | |
| 85 | 106 | |
| 86 | 107 | const shown = (a: Scanned, show: AskedShow): boolean => |
| 87 | 108 | show === "all" || |
| @@ -101,6 +122,8 @@ interface PullRow { | ||
| 101 | 122 | state: "open" | "closed"; |
| 102 | 123 | created_at: string; |
| 103 | 124 | merged_at: string | null; |
| 125 | parked: number; | |
| 126 | author: string; | |
| 104 | 127 | } |
| 105 | 128 | |
| 106 | 129 | const placeholders = (n: number, from = 1): string => |
| @@ -144,8 +167,9 @@ export function pullsByAsk( | ||
| 144 | 167 | const rows = db |
| 145 | 168 | .query<PullRow & { repo_id: string }, string[]>( |
| 146 | 169 | `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 | |
| 149 | 173 | WHERE issue.repo_id IN (${placeholders(repoIds.length)}) |
| 150 | 174 | ORDER BY issue.number`, |
| 151 | 175 | ) |
| @@ -165,6 +189,9 @@ export function pullsByAsk( | ||
| 165 | 189 | ? "open" |
| 166 | 190 | : "closed", |
| 167 | 191 | openedAt: row.created_at, |
| 192 | mergedAt: row.merged_at, | |
| 193 | parked: row.parked === 1, | |
| 194 | author: row.author, | |
| 168 | 195 | }); |
| 169 | 196 | pulls.set(key, list); |
| 170 | 197 | } |
| @@ -287,16 +314,148 @@ export function askedIn( | ||
| 287 | 314 | db, |
| 288 | 315 | page.filter((a) => a.issue.state === "open").map((a) => a.issue.id), |
| 289 | 316 | ); |
| 317 | const outcomes = outcomesOf(db, page); | |
| 290 | 318 | return page.map((a) => { |
| 291 | 319 | let at: string | null = last.get(a.issue.id) ?? null; |
| 292 | 320 | for (const p of a.pulls) { |
| 293 | 321 | const t = last.get(p.id); |
| 294 | 322 | if (t !== undefined && (at === null || t > at)) at = t; |
| 295 | 323 | } |
| 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 | }; | |
| 297 | 330 | }); |
| 298 | 331 | } |
| 299 | 332 | |
| 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 | ||
| 300 | 459 | export interface AskedCounts { |
| 301 | 460 | /** Every ask. */ |
| 302 | 461 | readonly asked: number; |
| @@ -420,6 +579,8 @@ export function askedJson(a: Asked) { | ||
| 420 | 579 | flagged: a.flagged, |
| 421 | 580 | lastCrewAt: a.lastCrewAt, |
| 422 | 581 | doneBy: a.doneBy, |
| 582 | outcome: a.outcome, | |
| 583 | outcomeText: outcomeText(a), | |
| 423 | 584 | pulls: a.pulls.map((p) => ({ |
| 424 | 585 | number: p.number, |
| 425 | 586 | 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: "; | ||
| 15 | 15 | export const DONE_LIKE = `${DONE_NOTE.replace(/[%_]/g, "")}%`; |
| 16 | 16 | |
| 17 | 17 | /** 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}%`; | |
| 19 | 20 | |
| 20 | 21 | /** What the comment on a close of such an issue starts with. */ |
| 21 | 22 | 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"; | ||
| 5 | 5 | |
| 6 | 6 | export const tool: Tool = { |
| 7 | 7 | 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", | |
| 9 | 9 | description: |
| 10 | 10 | "What people asked the crew for: unmerged by default, oldest first.", |
| 11 | 11 | inputSchema: { |
src/tui/screens/asked.ts +10 −5
| @@ -10,7 +10,7 @@ | ||
| 10 | 10 | |
| 11 | 11 | import type { Database } from "bun:sqlite"; |
| 12 | 12 | 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"; | |
| 14 | 14 | import { thoughtStream, type StreamThought } from "../../core/thought_stream.ts"; |
| 15 | 15 | import type { User } from "../../core/users.ts"; |
| 16 | 16 | import type { Ctx, Screen } from "../app/app.ts"; |
| @@ -60,13 +60,15 @@ function thoughtLine(t: StreamThought): AskedLine { | ||
| 60 | 60 | }; |
| 61 | 61 | } |
| 62 | 62 | |
| 63 | /** The words the page's badge uses, so the two doors agree. */ | |
| 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 ( |
||
| 65 | ||
| 63 | 66 | 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); | |
| 66 | 68 | return { |
| 67 | 69 | title: plain(a.issue.title), |
| 68 | 70 | ...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}`, | |
| 70 | 72 | meta: metaOf(word, relTime(a.askedAt)), |
| 71 | 73 | path: `/${a.repo.ownerName}/${a.repo.name}/issues/${a.issue.number}`, |
| 72 | 74 | }; |
| @@ -78,7 +80,10 @@ export function askedLines(db: Database, viewer: User | undefined): { thoughts: | ||
| 78 | 80 | const repos = askedRepos(db, viewer, null); |
| 79 | 81 | return { |
| 80 | 82 | 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), | |
| 82 | 87 | }; |
| 83 | 88 | } |
| 84 | 89 |
src/web/studio/asked_page.ts +72 −73
| @@ -1,23 +1,25 @@ | ||
| 1 | 1 | /** |
| 2 | 2 | * `/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. | |
| 8 | 10 | */ |
| 9 | 11 | |
| 10 | 12 | import { |
| 11 | 13 | askedCounts, |
| 12 | 14 | askedIn, |
| 13 | 15 | askedRepos, |
| 16 | askedTier, | |
| 14 | 17 | isAskedOrder, |
| 15 | 18 | isAskedShow, |
| 16 | STALE_DAYS, | |
| 19 | outcomeText, | |
| 17 | 20 | type Asked, |
| 18 | 21 | type AskedOrder, |
| 19 | 22 | type AskedShow, |
| 20 | type AskedState, | |
| 21 | 23 | } from "../../core/asked.ts"; |
| 22 | 24 | import { |
| 23 | 25 | flagDays, |
| @@ -38,18 +40,11 @@ import { | ||
| 38 | 40 | row, |
| 39 | 41 | seg, |
| 40 | 42 | time, |
| 41 | timeAgo, | |
| 43 | userTag, | |
| 42 | 44 | type Tone, |
| 43 | 45 | } from "../ui.ts"; |
| 44 | 46 | import { homeTabs } from "./home_tabs.ts"; |
| 45 | 47 | |
| 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 | ||
| 53 | 48 | const THOUGHT_WORD: Record<ThoughtState, { text: string; tone: Tone }> = { |
| 54 | 49 | unplaced: { text: "Heard", tone: "default" }, |
| 55 | 50 | placed: { text: "Placed", tone: "default" }, |
| @@ -103,58 +98,75 @@ function oldestFlaggedHref(stream: readonly StreamThought[]): string | null { | ||
| 103 | 98 | return null; |
| 104 | 99 | } |
| 105 | 100 | |
| 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 | }; | |
| 116 | 108 | |
| 117 | 109 | /** |
outdated
Non-blocking: |
||
| 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. | |
| 121 | 113 | */ |
| 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) | |
| 127 | 120 | 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" }, | |
| 130 | 123 | }; |
| 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 | } | |
| 132 | 153 | } |
| 133 | 154 | |
| 134 | 155 | function askedRow(a: Asked): Html { |
| 135 | const word = wordFor(a); | |
| 156 | const out = outcomeOf(a); | |
| 136 | 157 | 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 }), | |
| 152 | 162 | }); |
| 153 | 163 | } |
| 154 | 164 | |
| 155 | 165 | /** |
| 156 | 166 | * GET /asked — `?show=unshipped` (the default: nothing merged yet), |
| 157 | 167 | * `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. | |
| 158 | 170 | */ |
| 159 | 171 | export function askedPage(ctx: Ctx): Response { |
| 160 | 172 | notePhase(ctx.request, "session", takeSessionTime(ctx.request)); |
| @@ -180,6 +192,8 @@ export function askedPage(ctx: Ctx): Response { | ||
| 180 | 192 | const stream = timePhase(ctx.request, "thoughts", () => thoughtStream(ctx.app.db, user, { limit: 50 })); |
| 181 | 193 | const days = timePhase(ctx.request, "flags", () => flagDays(ctx.app.db, user.id)); |
| 182 | 194 | 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)); | |
| 183 | 197 | const next = oldestFlaggedHref(stream); |
| 184 | 198 | const flaggedThoughts = stream.filter((t) => t.flagged).length; |
| 185 | 199 | const thoughts = |
| @@ -202,7 +216,7 @@ export function askedPage(ctx: Ctx): Response { | ||
| 202 | 216 | const sentence = |
| 203 | 217 | asked === 0 |
| 204 | 218 | ? "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` : ""}`; | |
| 206 | 220 | |
| 207 | 221 | const body = html`${homeTabs("asked", unshipped)} |
| 208 | 222 | <h1 id="asked-sentence">What you asked for</h1> |
| @@ -210,31 +224,16 @@ export function askedPage(ctx: Ctx): Response { | ||
| 210 | 224 | ${thoughts} |
| 211 | 225 | <div class="bar mb mt"> |
| 212 | 226 | ${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 }, | |
| 231 | 230 | ])} |
| 232 | 231 | <span class="spacer"></span> |
| 233 | 232 | <a class="small muted" href="${href(show, order === "oldest" ? "newest" : "oldest")}" |
| 234 | 233 | >${order === "oldest" ? "Show newest first" : "Show oldest first"}</a |
| 235 | 234 | > |
| 236 | 235 | </div> |
| 237 | ${listed.length === 0 | |
| 236 | ${rows.length === 0 | |
| 238 | 237 | ? empty({ |
| 239 | 238 | icon: "issue", |
| 240 | 239 | title: asked === 0 ? "Nothing asked yet" : "Nothing here", |
| @@ -243,7 +242,7 @@ export function askedPage(ctx: Ctx): Response { | ||
| 243 | 242 | ? "What a person tells the crew shows up here, with what became of it." |
| 244 | 243 | : "Every ask in this view has something built for it.", |
| 245 | 244 | }) |
| 246 | : listCard(listed.map(askedRow))}`; | |
| 245 | : listCard(rows.map(askedRow))}`; | |
| 247 | 246 | const response = page({ ctx, title: "What you asked for", body }); |
| 248 | 247 | notePhase(ctx.request, "render", performance.now() - rendering); |
| 249 | 248 | 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", | ||
| 82 | 82 | const res = await get(); |
| 83 | 83 | const body = await res.text(); |
| 84 | 84 | expect(res.status).toBe(200); |
| 85 | expect(body).toContain("500 asked · 250 shipped"); | |
| 85 | expect(body).toContain("500 asked · 250 landed"); | |
| 86 | 86 | const parts = (res.headers.get("server-timing") ?? "").split(", "); |
| 87 | 87 | for (const name of ["app", "session", "repos", "counts", "asks", "thoughts", "flags", "render"]) |
| 88 | 88 | 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", () => { | ||
| 272 | 272 | expect(page).toContain("Not shipped"); |
| 273 | 273 | expect(page).toContain("Nothing built"); |
| 274 | 274 | expect(page).toContain("Show newest first"); |
| 275 | expect(page).toContain("nothing built in 3 days"); | |
| 276 | 275 | expect(page).not.toContain("Refactor the watering module"); |
| 277 | 276 | // The default view is what has nothing merged yet: the shipped ask waits behind "All". |
| 278 | 277 | expect(page).not.toContain("Prune the roses"); |
| 279 | expect(page).toContain("crew not on it yet"); | |
| 278 | expect(page).toContain("not placed yet"); | |
| 280 | 279 | expect(page.indexOf("Fix the gate")).toBeLessThan(page.indexOf("Plant tomatoes")); |
| 281 | 280 | // Reached from the board and the feed's header, not only the asks page. |
| 282 | 281 | expect(await (await fetch(`${servers.url}/board`, { headers: { cookie } })).text()).toContain('href="/asked"'); |
| @@ -343,7 +342,7 @@ describe("what you asked for", () => { | ||
| 343 | 342 | ( |
| 344 | 343 | page.match( |
| 345 | 344 | 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>`, | |
| 347 | 346 | ), |
| 348 | 347 | )?.[1] ?? "" |
| 349 | 348 | ) |
| @@ -351,10 +350,10 @@ describe("what you asked for", () => { | ||
| 351 | 350 | .replace(/\s+/g, " ") |
| 352 | 351 | .trim(); |
| 353 | 352 | expect(meta("Plant tomatoes")).toStartWith( |
| 354 | "crew not on it yet · no pull yet · alice/garden #", | |
| 353 | "not placed yet · alice/garden #", | |
| 355 | 354 | ); |
| 356 | 355 | expect(meta("A watering schedule")).toMatch( |
| 357 | /^crew .+ · pull #\d+ open · alice\/garden #\d+ · alice · /, | |
| 356 | /^aliceowner building in #\d+ · alice\/garden #\d+$/, | |
| 358 | 357 | ); |
| 359 | 358 | }); |
| 360 | 359 | |
| @@ -520,7 +519,7 @@ describe("an old ask says what exists for it", () => { | ||
| 520 | 519 | const row = |
| 521 | 520 | page.match( |
| 522 | 521 | 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>`, | |
| 524 | 523 | ), |
| 525 | 524 | )?.[1] ?? ""; |
| 526 | 525 | const tone = row.match(/data-variant="(\w+)"/)?.[1] ?? "default"; |
| @@ -531,10 +530,10 @@ describe("an old ask says what exists for it", () => { | ||
| 531 | 530 | }; |
| 532 | 531 | expect(badgeOf("Nothing at all")).toBe("danger: Nothing built"); |
| 533 | 532 | 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"); | |
| 535 | 534 | expect(badgeOf("The crew is on it")).toBe("attn: Building"); |
| 536 | 535 | 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"); | |
| 538 | 537 | }); |
| 539 | 538 | |
| 540 | 539 | test("the API and the MCP say stale and flagged apart, and when each pull was opened", async () => { |
Non-blocking: type this as
Record<AskedOutcome["kind"], string>. tsc then makes sure every kind has a word, and the?? a.outcome.kindfallback on the next line can go, since it can never run.