Public: anyone can read this; the crew can change it.
The stream view: /asked shows the owner's thoughts newest first, each with where it went and its state #1751
Check · Review · Land · Live done — Landed and live in production.
scout/forge:scout/653-the-stream-view-asked-shows-the-owner-s → main 3 commits fork 3 threads · 3 unresolved
8 files
changed +469
−5
docs/STUDIO.md +9 −2
| @@ -366,8 +366,15 @@ whose pull is still open after three days says _Pull open_ and how long the | ||
| 366 | 366 | pull has been open. `?show=flagged|all` changes the view, `?order=newest` flips it, |
| 367 | 367 | `?repo=owner/name` keeps it to one repository. The rows are derived on read |
| 368 | 368 | in `src/core/asked.ts`, and `/api/v1/asked` and the MCP `asked` tool answer |
| 369 | the same rows. Each row names its `source` (`issue` today) so thoughts can | |
| 370 | join the same list. It is reached from the feed's header, the board, the asks page and the palette. | |
| 369 | the same rows. Above the asks, the same page | |
| 370 | shows the viewer's own thoughts, newest first and verbatim (`src/core/thought_stream.ts`): | |
| 371 | time, source, the issues the planner placed each on, and one word — _Heard_ | |
| 372 | (not yet placed), _Placed_, _Building_, _Shipped_ (a placed issue's pull | |
| 373 | merged) or _Superseded_ (a later thought marked what it placed superseded, | |
| 374 | and the row says which). A thought with something placed and nothing merged | |
| 375 | more than N days after placement (the `thoughtFlagDays` user setting, 3 by | |
| 376 | default) is _Nothing built_; the one primary button opens the oldest such | |
| 377 | thought's placed issue. `/api/v1/asked` and the MCP `asked` tool return them as `thoughts`. A placement in a repository the viewer cannot read shows no title, state or name. It is reached from the feed's header, the board, the asks page and the palette. | |
| 371 | 378 | |
| 372 | 379 | ## The home |
| 373 | 380 |
scripts/gate.ts +1
| @@ -161,6 +161,7 @@ export const GATE: readonly string[] = [ | ||
| 161 | 161 | "test/watches.test.ts", |
| 162 | 162 | "test/guard.test.ts", |
| 163 | 163 | "test/asked.test.ts", |
| 164 | "test/thought-stream.test.ts", | |
| 164 | 165 | "test/asks.test.ts", |
| 165 | 166 | "test/review-wait.test.ts", |
| 166 | 167 | "test/asks-bulk.test.ts", |
src/core/asked.ts +1 −1
| @@ -133,7 +133,7 @@ export function askedRepos( | ||
| 133 | 133 | * in these repositories, read once — the one join `scan` and `askedCounts` |
| 134 | 134 | * share, so a count can never disagree with the list. |
| 135 | 135 | */ |
| 136 | function pullsByAsk( | |
| 136 | export function pullsByAsk( | |
| 137 | 137 | db: Database, |
| 138 | 138 | repoIds: readonly string[], |
| 139 | 139 | ): Map<string, AskedPull[]> { |
src/core/thought_stream.ts added +179
| @@ -0,0 +1,179 @@ | ||
| 1 | /** | |
| 2 | * The thought stream (#653): every thought a person sent, newest first, each | |
| 3 | * with where the planner put it and one state word read from what became of | |
| 4 | * the issues it placed. `unplaced` until the planner reaches it; `placed` | |
| 5 | * once it has; `building` while a pull or a run is open on a placed issue; | |
| 6 | * `shipped` once a pull closing one merged; `superseded` when a later | |
| 7 | * thought marked what it placed superseded — and then it says which. | |
| 8 | * | |
| 9 | * Flagged: nothing merged N days after placement (after receipt, while still | |
| 10 | * unplaced). N is the person's `thoughtFlagDays` setting, three by default. | |
| 11 | * A thought that asked for nothing to be built (a vision, a decision, a | |
| 12 | * deferral) is never flagged. Everything is derived on read from the | |
| 13 | * thought, placement, issue, pull and run tables; a thought belongs to its | |
| 14 | * author, and a placement link to a repository the viewer cannot read is | |
| 15 | * not shown. | |
| 16 | */ | |
| 17 | ||
| 18 | import type { Database } from "bun:sqlite"; | |
| 19 | import { pullsByAsk } from "./asked.ts"; | |
| 20 | import { getIssue } from "./issues.ts"; | |
| 21 | import { placementsOf, type Kind, type Placed } from "./placements.ts"; | |
| 22 | import { canRead, getRepo, type Repo } from "./repos.ts"; | |
| 23 | import { listActiveRuns } from "./runs.ts"; | |
| 24 | import { getUserSetting } from "./settings.ts"; | |
| 25 | import { recentThoughts } from "./thoughts.ts"; | |
| 26 | import type { User } from "./users.ts"; | |
| 27 | ||
| 28 | export type ThoughtState = "unplaced" | "placed" | "building" | "shipped" | "superseded"; | |
| 29 | ||
| 30 | export const FLAG_DAYS = 3; | |
| 31 | export const FLAG_DAYS_KEY = "thoughtFlagDays"; | |
| 32 | export const THOUGHT_LIMIT = 200; | |
| 33 | ||
| 34 | const DAY = 24 * 60 * 60 * 1000; | |
| 35 | ||
| 36 | /** One issue a thought placed, with its title when the viewer may read it. */ | |
| 37 | export interface PlacedLink extends Placed { | |
| 38 | /** False when the viewer cannot read the repository: nothing about it is shown. */ | |
| 39 | readonly readable: boolean; | |
| 40 | readonly title: string | null; | |
| 41 | readonly issueState: "open" | "closed" | null; | |
| 42 | } | |
| 43 | ||
| 44 | export interface StreamThought { | |
| 45 | readonly id: string; | |
| 46 | readonly text: string; | |
| 47 | readonly source: string; | |
| 48 | readonly receivedAt: string; | |
| 49 | readonly kind: Kind | null; | |
| 50 | readonly reply: string | null; | |
| 51 | readonly placedAt: string | null; | |
| 52 | readonly placed: readonly PlacedLink[]; | |
| 53 | readonly state: ThoughtState; | |
| 54 | readonly flagged: boolean; | |
| 55 | /** The later thought that superseded this one. */ | |
| 56 | readonly supersededBy: { readonly id: string; readonly text: string; readonly receivedAt: string } | null; | |
| 57 | } | |
| 58 | ||
| 59 | /** A person's flag window in days: their setting when it is a whole number of 1–90, else three. */ | |
| 60 | export function flagDays(db: Database, userId: string): number { | |
| 61 | const n = Number(getUserSetting(db, userId, FLAG_DAYS_KEY)); | |
| 62 | return Number.isInteger(n) && n >= 1 && n <= 90 ? n : FLAG_DAYS; | |
| 63 | } | |
| 64 | ||
| 65 | const keyOf = (p: Placed) => `${p.repo}#${p.number}`; | |
| 66 | const BUILDS = new Set(["opened", "amended", "linked"]); | |
| 67 | ||
| 68 | export function thoughtStream( | |
| 69 | db: Database, | |
| 70 | viewer: User, | |
| 71 | options: { days?: number; now?: Date; limit?: number } = {}, | |
| 72 | ): StreamThought[] { | |
| 73 | const days = options.days ?? flagDays(db, viewer.id); | |
| 74 | const now = (options.now ?? new Date()).getTime(); | |
| 75 | const limit = Math.min(Math.max(options.limit ?? THOUGHT_LIMIT, 1), THOUGHT_LIMIT); | |
| 76 | const thoughts = recentThoughts(db, viewer.id, limit); | |
| 77 | const placements = placementsOf(db, thoughts.map((t) => t.id)); | |
| 78 | ||
| 79 | // Every repository the placements name, once. | |
| 80 | const repos = new Map<string, Repo | undefined>(); | |
| 81 | const repoOf = (slug: string): Repo | undefined => { | |
| 82 | if (!repos.has(slug)) { | |
| 83 | const [owner, name, ...rest] = slug.split("/"); | |
| 84 | const r = owner && name && rest.length === 0 ? getRepo(db, owner, name) : undefined; | |
| 85 | // A repository the viewer cannot read is a repository that is not there. | |
| 86 | repos.set(slug, r && canRead(db, r, viewer) ? r : undefined); | |
| 87 | } | |
| 88 | return repos.get(slug); | |
| 89 | }; | |
| 90 | const pulls = new Map<string, ReturnType<typeof pullsByAsk>>(); | |
| 91 | const pullsOf = (repo: Repo) => { | |
| 92 | let m = pulls.get(repo.id); | |
| 93 | if (m === undefined) pulls.set(repo.id, (m = pullsByAsk(db, [repo.id]))); | |
| 94 | return m; | |
| 95 | }; | |
| 96 | // Resolve every repository first, then read the crew's active runs once. | |
| 97 | for (const p of placements.values()) for (const x of p.placed) repoOf(x.repo); | |
| 98 | const activeRuns = new Set( | |
| 99 | listActiveRuns(db, [...repos.values()].flatMap((r) => (r ? [r.id] : []))).flatMap((r) => | |
| 100 | r.issueId === null ? [] : [r.issueId], | |
| 101 | ), | |
| 102 | ); | |
| 103 | ||
| 104 | // Who superseded what: the first later thought that marked an issue superseded. | |
| 105 | const supersedes = new Map<string, (typeof thoughts)[number]>(); | |
| 106 | for (const t of [...thoughts].reverse()) { | |
| 107 | for (const x of placements.get(t.id)?.placed ?? []) | |
| 108 | if (x.action === "superseded" && repoOf(x.repo) !== undefined && !supersedes.has(keyOf(x))) supersedes.set(keyOf(x), t); | |
| 109 | } | |
| 110 | ||
| 111 | return thoughts.map((t) => { | |
| 112 | const p = placements.get(t.id); | |
| 113 | const links: PlacedLink[] = (p?.placed ?? []).map((x) => { | |
| 114 | const repo = repoOf(x.repo); | |
| 115 | const issue = repo ? getIssue(db, repo.id, x.number) : undefined; | |
| 116 | return { ...x, readable: repo !== undefined, title: issue?.title ?? null, issueState: issue?.state ?? null }; | |
| 117 | }); | |
| 118 | let built = false; | |
| 119 | let shipped = false; | |
| 120 | let building = false; | |
| 121 | let by: (typeof thoughts)[number] | undefined; | |
| 122 | for (const x of p?.placed ?? []) { | |
| 123 | if (!BUILDS.has(x.action)) continue; | |
| 124 | built = true; | |
| 125 | const repo = repoOf(x.repo); | |
| 126 | const issue = repo ? getIssue(db, repo.id, x.number) : undefined; | |
| 127 | if (repo === undefined || issue === undefined) continue; | |
| 128 | const its = pullsOf(repo).get(`${repo.id}#${x.number}`) ?? []; | |
| 129 | if (its.some((q) => q.state === "merged")) shipped = true; | |
| 130 | else if (its.some((q) => q.state === "open") || activeRuns.has(issue.id)) building = true; | |
| 131 | const s = supersedes.get(keyOf(x)); | |
| 132 | if (s !== undefined && s.id !== t.id && s.receivedAt >= t.receivedAt) by = s; | |
| 133 | } | |
| 134 | const state: ThoughtState = | |
| 135 | p === undefined ? "unplaced" | |
| 136 | : by !== undefined && !shipped ? "superseded" | |
| 137 | : shipped ? "shipped" | |
| 138 | : building ? "building" | |
| 139 | : "placed"; | |
| 140 | const since = Date.parse(p?.placedAt ?? t.receivedAt); | |
| 141 | const flagged = | |
| 142 | (state === "unplaced" || (state === "placed" && built)) && now - since > days * DAY; | |
| 143 | return { | |
| 144 | id: t.id, | |
| 145 | text: t.text, | |
| 146 | source: t.source, | |
| 147 | receivedAt: t.receivedAt, | |
| 148 | kind: p?.kind ?? null, | |
| 149 | reply: p?.reply ?? null, | |
| 150 | placedAt: p?.placedAt ?? null, | |
| 151 | placed: links, | |
| 152 | state, | |
| 153 | flagged, | |
| 154 | supersededBy: | |
| 155 | state === "superseded" && by ? { id: by.id, text: by.text, receivedAt: by.receivedAt } : null, | |
| 156 | }; | |
| 157 | }); | |
| 158 | } | |
| 159 | ||
| 160 | /** The row as JSON, for the MCP: the text verbatim. */ | |
| 161 | export function streamJson(t: StreamThought) { | |
| 162 | return { | |
| 163 | id: t.id, | |
| 164 | text: t.text, | |
| 165 | source: t.source, | |
| 166 | receivedAt: t.receivedAt, | |
| 167 | kind: t.kind, | |
| 168 | reply: t.reply, | |
| 169 | placedAt: t.placedAt, | |
| 170 | placed: t.placed.map((x) => | |
| 171 | x.readable | |
| 172 | ? { action: x.action, repo: x.repo, number: x.number, title: x.title } | |
| 173 | : { action: x.action, repo: null, number: null, title: null }, | |
| 174 | ), | |
| 175 | state: t.state, | |
| 176 | flagged: t.flagged, | |
| 177 | supersededBy: t.supersededBy, | |
| 178 | }; | |
| 179 | } |
src/http/api.ts +2
| @@ -30,6 +30,7 @@ import { | ||
| 30 | 30 | isAskedOrder, |
| 31 | 31 | isAskedShow, |
| 32 | 32 | } from "../core/asked.ts"; |
| 33 | import { streamJson, thoughtStream } from "../core/thought_stream.ts"; | |
| 33 | 34 | import { |
| 34 | 35 | listAccounts, |
| 35 | 36 | removeAccount, |
| @@ -2718,6 +2719,7 @@ export function apiRoutes(router: Router<Handler>): void { | ||
| 2718 | 2719 | show: raw ?? "unshipped", |
| 2719 | 2720 | order: order ?? "oldest", |
| 2720 | 2721 | }).map(askedJson), |
| 2722 | thoughts: ctx.auth.user ? thoughtStream(ctx.app.db, ctx.auth.user).map(streamJson) : [], | |
| 2721 | 2723 | }); |
| 2722 | 2724 | }); |
| 2723 | 2725 | // Every repository the caller can see, one entry each: the global map. |
src/http/mcp.ts +6 −2
| @@ -119,6 +119,7 @@ import { | ||
| 119 | 119 | isAskedOrder, |
| 120 | 120 | isAskedShow, |
| 121 | 121 | } from "../core/asked.ts"; |
| 122 | import { streamJson, thoughtStream } from "../core/thought_stream.ts"; | |
| 122 | 123 | import { |
| 123 | 124 | readAppMembers, |
| 124 | 125 | setAppMembersThrough, |
| @@ -1126,7 +1127,7 @@ const TOOLS: readonly Tool[] = [ | ||
| 1126 | 1127 | { |
| 1127 | 1128 | name: "asked", |
| 1128 | 1129 | description: |
| 1129 | "What people asked the crew for, in the repositories you can write: by default what has nothing merged yet, oldest first. Each ask with who asked and when, the pulls that close it, one state (waiting, building, shipped, closed), the crew's last activity on it, whether it is stale (not shipped after three days), and whether it is flagged (stale with nothing built: no pull open or merged, and nobody on it). Each pull says when it was opened. `show` is unshipped (default), flagged or all; `order` is oldest (default) or newest; `repo` (owner/name) keeps it to one repository. The same data as the /asked page and /api/v1/asked.", | |
| 1130 | "What people asked the crew for, in the repositories you can write: by default what has nothing merged yet, oldest first. Each ask with who asked and when, the pulls that close it, one state (waiting, building, shipped, closed), the crew's last activity on it, whether it is stale (not shipped after three days), and whether it is flagged (stale with nothing built: no pull open or merged, and nobody on it). Each pull says when it was opened. `show` is unshipped (default), flagged or all; `order` is oldest (default) or newest; `repo` (owner/name) keeps it to one repository. Also `thoughts`: every thought you sent, newest first, verbatim, with time, source, where the planner put it, and one state (unplaced, placed, building, shipped, superseded — with the later thought that superseded it); `flagged` when nothing merged more than your `thoughtFlagDays` setting (three by default) after it was placed. Yours only. The same data as the /asked page and /api/v1/asked.", | |
| 1130 | 1131 | inputSchema: { |
| 1131 | 1132 | type: "object", |
| 1132 | 1133 | properties: { |
| @@ -1147,7 +1148,10 @@ const TOOLS: readonly Tool[] = [ | ||
| 1147 | 1148 | const repos = askedRepos(ctx.app.db, user, spec); |
| 1148 | 1149 | if (spec !== null && repos.length === 0) |
| 1149 | 1150 | return bad("not_found", "no such app or repository you can write (owner/name)"); |
| 1150 | return good({ asked: askedIn(ctx.app.db, repos, { show, order }).map(askedJson) }); | |
| 1151 | return good({ | |
| 1152 | asked: askedIn(ctx.app.db, repos, { show, order }).map(askedJson), | |
| 1153 | thoughts: thoughtStream(ctx.app.db, user).map(streamJson), | |
| 1154 | }); | |
| 1151 | 1155 | }, |
| 1152 | 1156 | }, |
| 1153 | 1157 | { |
src/web/studio/asked_page.ts +78
| @@ -19,6 +19,12 @@ import { | ||
| 19 | 19 | type AskedShow, |
| 20 | 20 | type AskedState, |
| 21 | 21 | } from "../../core/asked.ts"; |
| 22 | import { | |
| 23 | flagDays, | |
| 24 | thoughtStream, | |
| 25 | type StreamThought, | |
| 26 | type ThoughtState, | |
| 27 | } from "../../core/thought_stream.ts"; | |
| 22 | 28 | import type { Ctx } from "../../http/context.ts"; |
| 23 | 29 | import { repoBase, signInFirst } from "../chrome.ts"; |
| 24 | 30 | import { html, type Html } from "../html.ts"; |
| @@ -42,6 +48,57 @@ const WORD: Record<AskedState, { text: string; tone: Tone }> = { | ||
| 42 | 48 | closed: { text: "Closed", tone: "default" }, |
| 43 | 49 | }; |
| 44 | 50 | |
| 51 | const THOUGHT_WORD: Record<ThoughtState, { text: string; tone: Tone }> = { | |
| 52 | unplaced: { text: "Heard", tone: "default" }, | |
| 53 | placed: { text: "Placed", tone: "default" }, | |
| 54 | building: { text: "Building", tone: "attn" }, | |
| 55 | shipped: { text: "Shipped", tone: "success" }, | |
| 56 | superseded: { text: "Superseded", tone: "default" }, | |
| 57 | }; | |
| 58 | ||
| 59 | /** Where a thought went: each issue it touched, linked when the viewer may read it. */ | |
| 60 | function placedLinks(t: StreamThought): Html { | |
| 61 | const links = t.placed.map((x) => | |
| 62 | !x.readable | |
| 63 | ? html`${x.action} in a repository you cannot read` | |
outdated
For a repository the viewer can't read, this still prints its |
||
| 64 | : x.title === null | |
| 65 | ? html`${x.action} ${x.repo} #${x.number}` | |
| 66 | : html`${x.action} <a href="/${x.repo}/issues/${x.number}">${x.repo} #${x.number}</a>`, | |
| 67 | ); | |
| 68 | return links.length === 0 | |
| 69 | ? html`${t.reply ?? "the planner has not placed it yet"}` | |
| 70 | : html`${links.map((l, i) => html`${i > 0 ? " · " : ""}${l}`)}`; | |
| 71 | } | |
| 72 | ||
| 73 | function thoughtRow(t: StreamThought, days: number): Html { | |
| 74 | const word = t.flagged | |
| 75 | ? { text: "Nothing built", tone: "danger" as Tone } | |
| 76 | : THOUGHT_WORD[t.state]; | |
| 77 | return row({ | |
outdated
A flagged thought can be one that was never placed. This tooltip should say "Not placed N days after it was sent" in that case. |
||
| 78 | title: t.text, | |
| 79 | titleAttr: t.flagged | |
| 80 | ? t.placedAt === null | |
| 81 | ? `Not placed ${days} days after it was sent` | |
| 82 | : `Nothing merged ${days} days after it was placed` | |
| 83 | : t.text, | |
| 84 | meta: html`${time(t.receivedAt)} · ${t.source} · ${placedLinks(t)}${t.supersededBy | |
| 85 | ? html` · superseded by “${t.supersededBy.text.slice(0, 80)}” ${time(t.supersededBy.receivedAt)}` | |
| 86 | : null}`, | |
| 87 | trail: badge(word.text, { tone: word.tone }), | |
| 88 | }); | |
| 89 | } | |
| 90 | ||
| 91 | /** The first placed issue of the oldest flagged thought: the one next action. */ | |
| 92 | function oldestFlaggedHref(stream: readonly StreamThought[]): string | null { | |
| 93 | for (let i = stream.length - 1; i >= 0; i--) { | |
| 94 | const t = stream[i]!; | |
| 95 | if (!t.flagged) continue; | |
| 96 | const to = t.placed.find((x) => x.title !== null); | |
| 97 | if (to) return `/${to.repo}/issues/${to.number}`; | |
| 98 | } | |
| 99 | return null; | |
| 100 | } | |
| 101 | ||
| 45 | 102 | function pullNote(a: Asked): string { |
| 46 | 103 | // The row is one link, so the commits are named here and linked on the ask. |
| 47 | 104 | if (a.doneBy.length > 0) |
| @@ -114,6 +171,26 @@ export function askedPage(ctx: Ctx): Response { | ||
| 114 | 171 | ].filter(Boolean); |
| 115 | 172 | return `/asked${parts.length > 0 ? `?${parts.join("&")}` : ""}`; |
| 116 | 173 | }; |
| 174 | const stream = thoughtStream(ctx.app.db, ctx.auth.user, { limit: 50 }); | |
| 175 | const days = flagDays(ctx.app.db, ctx.auth.user.id); | |
| 176 | const next = oldestFlaggedHref(stream); | |
| 177 | const flaggedThoughts = stream.filter((t) => t.flagged).length; | |
| 178 | const thoughts = | |
| 179 | stream.length === 0 | |
| 180 | ? null | |
| 181 | : html`<section id="thoughts" aria-labelledby="thoughts-h"> | |
| 182 | <div class="bar mb"> | |
| 183 | <h2 id="thoughts-h">Your thoughts</h2> | |
| 184 | <span class="spacer"></span> | |
| 185 | ${next === null | |
| 186 | ? null | |
| 187 | : html`<a class="btn" data-variant="primary" href="${next}" | |
| 188 | >Open the oldest of ${flaggedThoughts} with nothing built</a | |
| 189 | >`} | |
| 190 | </div> | |
| 191 | ${listCard(stream.map((t) => thoughtRow(t, days)))} | |
| 192 | </section> | |
| 193 | <h2 class="mt">Asked in issues</h2>`; | |
| 117 | 194 | const { asked, shipped, unshipped, flagged } = counts; |
| 118 | 195 | const sentence = |
| 119 | 196 | asked === 0 |
| @@ -123,6 +200,7 @@ export function askedPage(ctx: Ctx): Response { | ||
| 123 | 200 | const body = html`${homeTabs("asked")} |
| 124 | 201 | <h1 id="asked-sentence">What you asked for</h1> |
| 125 | 202 | <p class="muted">${sentence}</p> |
| 203 | ${thoughts} | |
| 126 | 204 | <div class="filters bar mb mt"> |
| 127 | 205 | ${seg([ |
| 128 | 206 | { |
test/thought-stream.test.ts added +193
| @@ -0,0 +1,193 @@ | ||
| 1 | /** The thought stream on /asked (#653): states derive, flag after N days, superseded shown. */ | |
| 2 | ||
| 3 | import { afterAll, beforeAll, describe, expect, test } from "bun:test"; | |
| 4 | import { mkdtemp, rm } from "node:fs/promises"; | |
| 5 | import { tmpdir } from "node:os"; | |
| 6 | import { join } from "node:path"; | |
| 7 | import { createApp, type App } from "../src/app.ts"; | |
| 8 | import { openIssue } from "../src/actions/issues.ts"; | |
| 9 | import { openPull } from "../src/actions/pulls.ts"; | |
| 10 | import { commitToBranch, createRepo } from "../src/actions/repos.ts"; | |
| 11 | import { savePlacement, type Placed } from "../src/core/placements.ts"; | |
| 12 | import type { Repo } from "../src/core/repos.ts"; | |
| 13 | import { setUserSetting } from "../src/core/settings.ts"; | |
| 14 | import { flagDays, streamJson, thoughtStream } from "../src/core/thought_stream.ts"; | |
| 15 | import { serve } from "../src/http/server.ts"; | |
| 16 | import { createToken } from "../src/core/tokens.ts"; | |
| 17 | import { createUser, type User } from "../src/core/users.ts"; | |
| 18 | import { quietLogs } from "./helpers/globals.ts"; | |
| 19 | ||
| 20 | quietLogs(); | |
| 21 | const DAY = 24 * 60 * 60 * 1000; | |
| 22 | ||
| 23 | describe("thought stream", () => { | |
| 24 | let dir = ""; | |
| 25 | let app: App; | |
| 26 | let alice: User; | |
| 27 | let bob: User; | |
| 28 | let repo: Repo; | |
| 29 | let n = 0; | |
| 30 | ||
| 31 | beforeAll(async () => { | |
| 32 | dir = await mkdtemp(join(tmpdir(), "thoughts-")); | |
| 33 | app = createApp({ dataDir: dir, port: 0 }); | |
| 34 | const mk = async (username: string, first = false) => { | |
| 35 | const u = await createUser(app.db, { | |
| 36 | trusted: true, username, email: `${username}@forge.local`, | |
| 37 | password: "a-good-passphrase", adminIfFirst: first, | |
| 38 | }); | |
| 39 | if (!u.ok) throw new Error(u.error); | |
| 40 | return u.value; | |
| 41 | }; | |
| 42 | // The first account is the forge's admin, who reads everything: not the viewer. | |
| 43 | bob = await mk("bob", true); | |
| 44 | alice = await mk("alice"); | |
| 45 | const made = await createRepo(app, alice, { name: "game" }); | |
| 46 | if (!made.ok) throw new Error(String(made.error)); | |
| 47 | repo = made.value; | |
| 48 | await commitToBranch(app, alice, repo, { message: "seed", files: [{ path: "README.md", content: "# game\n" }] }); | |
| 49 | }); | |
| 50 | afterAll(async () => { | |
| 51 | app.db.close(); | |
| 52 | await rm(dir, { recursive: true, force: true }); | |
| 53 | }); | |
| 54 | ||
| 55 | const issue = () => { | |
| 56 | const made = openIssue(app, alice, repo, { title: `work ${++n}` }); | |
| 57 | if (!made.ok) throw new Error(String(made.error)); | |
| 58 | return made.value; | |
| 59 | }; | |
| 60 | const thought = (text: string, ago: number, who = alice) => { | |
| 61 | const id = `t${++n}`; | |
| 62 | app.db | |
| 63 | .query("INSERT INTO thought (id, author, text, source, received_at) VALUES (?1, ?2, ?3, 'composer', ?4)") | |
| 64 | .run(id, who.id, text, new Date(Date.now() - ago * DAY).toISOString()); | |
| 65 | return id; | |
| 66 | }; | |
| 67 | const place = (id: string, placed: Placed[], ago: number) => | |
| 68 | savePlacement(app.db, { | |
| 69 | thoughtId: id, kind: "work", placed, reply: "ok", asked: false, decidedBy: "rule", | |
| 70 | placedAt: new Date(Date.now() - ago * DAY).toISOString(), | |
| 71 | }); | |
| 72 | const slug = "alice/game"; | |
| 73 | const find = (id: string) => thoughtStream(app.db, alice).find((t) => t.id === id)!; | |
| 74 | ||
| 75 | test("newest first, verbatim, yours only; unplaced until placed", () => { | |
| 76 | const a = thought(" first, as typed\n", 0.2); | |
| 77 | const b = thought("second", 0.1); | |
| 78 | thought("bob's", 0.1, bob); | |
| 79 | const ids = thoughtStream(app.db, alice).map((t) => t.id); | |
| 80 | expect(ids.indexOf(b)).toBeLessThan(ids.indexOf(a)); | |
| 81 | expect(find(a).text).toBe(" first, as typed\n"); | |
| 82 | expect(find(a).state).toBe("unplaced"); | |
| 83 | expect(thoughtStream(app.db, alice).some((t) => t.text === "bob's")).toBe(false); | |
| 84 | }); | |
| 85 | ||
| 86 | test("placed, building, shipped derive from the placed issue and its pull", async () => { | |
| 87 | const i = issue(); | |
| 88 | const id = thought("build it", 0.5); | |
| 89 | place(id, [{ action: "opened", repo: slug, number: i.number }], 0.5); | |
| 90 | expect(find(id)).toMatchObject({ state: "placed", flagged: false }); | |
| 91 | expect(find(id).placed[0]!.title).toBe(i.title); | |
| 92 | ||
| 93 | const branch = `b${++n}`; | |
| 94 | await commitToBranch(app, alice, repo, { branch, base: "main", message: "m", files: [{ path: `${branch}.md`, content: "x\n" }] }); | |
| 95 | const pull = await openPull(app, alice, repo, { | |
| 96 | title: "p", body: `Closes #${i.number}`, baseBranch: "main", headBranch: branch, | |
| 97 | }); | |
| 98 | if (!pull.ok) throw new Error(String(pull.error)); | |
| 99 | expect(find(id).state).toBe("building"); | |
| 100 | app.db.query("UPDATE pull SET merged_at = ?1 WHERE issue_id = ?2").run(new Date().toISOString(), pull.value.id); | |
| 101 | app.db.query("UPDATE issue SET state = 'closed' WHERE id = ?1").run(pull.value.id); | |
| 102 | expect(find(id).state).toBe("shipped"); | |
| 103 | }); | |
| 104 | ||
| 105 | test("flagged only after N days with nothing merged; the setting moves N", () => { | |
| 106 | const i = issue(); | |
| 107 | const young = thought("young", 2); | |
| 108 | place(young, [{ action: "opened", repo: slug, number: i.number }], 2); | |
| 109 | const old = thought("old", 4); | |
| 110 | place(old, [{ action: "opened", repo: slug, number: i.number }], 4); | |
| 111 | const idle = thought("a vision", 9); | |
| 112 | place(idle, [], 9); | |
| 113 | expect(find(young).flagged).toBe(false); | |
| 114 | expect(find(old).flagged).toBe(true); | |
| 115 | expect(find(idle).flagged).toBe(false); | |
| 116 | expect(flagDays(app.db, alice.id)).toBe(3); | |
| 117 | setUserSetting(app.db, alice.id, "thoughtFlagDays", "1"); | |
| 118 | expect(find(young).flagged).toBe(true); | |
| 119 | setUserSetting(app.db, alice.id, "thoughtFlagDays", "nonsense"); | |
| 120 | expect(flagDays(app.db, alice.id)).toBe(3); | |
| 121 | expect(find(thought("unheard", 5)).flagged).toBe(true); | |
| 122 | }); | |
| 123 | ||
| 124 | test("a later thought that supersedes what an earlier one placed is shown", () => { | |
| 125 | const i = issue(); | |
| 126 | const first = thought("do it this way", 2); | |
| 127 | place(first, [{ action: "opened", repo: slug, number: i.number }], 2); | |
| 128 | const second = thought("no, not that way", 1); | |
| 129 | place(second, [{ action: "superseded", repo: slug, number: i.number }], 1); | |
| 130 | expect(find(first)).toMatchObject({ state: "superseded", flagged: false }); | |
| 131 | expect(find(first).supersededBy).toMatchObject({ id: second, text: "no, not that way" }); | |
| 132 | expect(find(second).state).toBe("placed"); | |
| 133 | }); | |
| 134 | ||
| 135 | test("a private repository's issue state and name never reach the viewer", async () => { | |
| 136 | const made = await createRepo(app, bob, { name: "secret", visibility: "private" }); | |
| 137 | if (!made.ok) throw new Error(String(made.error)); | |
| 138 | await commitToBranch(app, bob, made.value, { message: "seed", files: [{ path: "README.md", content: "# s\n" }] }); | |
| 139 | const hidden = openIssue(app, bob, made.value, { title: "hidden work" }); | |
| 140 | if (!hidden.ok) throw new Error(String(hidden.error)); | |
| 141 | await commitToBranch(app, bob, made.value, { branch: "fix", base: "main", message: "m", files: [{ path: "f.md", content: "x\n" }] }); | |
| 142 | const pull = await openPull(app, bob, made.value, { | |
| 143 | title: "p", body: `Closes #${hidden.value.number}`, baseBranch: "main", headBranch: "fix", | |
| 144 | }); | |
| 145 | if (!pull.ok) throw new Error(String(pull.error)); | |
| 146 | app.db.query("UPDATE pull SET merged_at = ?1 WHERE issue_id = ?2").run(new Date().toISOString(), pull.value.id); | |
| 147 | const id = thought("elsewhere", 0.1); | |
| 148 | place(id, [{ action: "opened", repo: "bob/secret", number: hidden.value.number }], 0.1); | |
| 149 | const mine = find(id); | |
| 150 | expect(mine.state).toBe("placed"); | |
| 151 | expect(mine.placed[0]).toMatchObject({ readable: false, title: null }); | |
| 152 | expect(JSON.stringify(streamJson(mine))).not.toContain("secret"); | |
| 153 | // A later thought superseding it changes nothing for someone who cannot read it. | |
| 154 | place(thought("never mind", 0.05), [{ action: "superseded", repo: "bob/secret", number: hidden.value.number }], 0.05); | |
| 155 | expect(find(id).state).toBe("placed"); | |
| 156 | }); | |
| 157 | ||
| 158 | test("/asked and the asked tool show the stream, with the one next action", async () => { | |
| 159 | const servers = await serve(app); | |
| 160 | try { | |
| 161 | const i = issue(); | |
| 162 | const id = thought("<b>stale</b> thought", 60); | |
| 163 | place(id, [{ action: "opened", repo: slug, number: i.number }], 60); | |
| 164 | const signin = await fetch(`${servers.url}/signin`, { | |
| 165 | method: "POST", | |
| 166 | body: new URLSearchParams({ username: "alice", password: "a-good-passphrase" }), | |
| 167 | redirect: "manual", | |
| 168 | }); | |
| 169 | const cookie = (signin.headers.get("set-cookie") ?? "").split(";")[0]!; | |
| 170 | const page = (await (await fetch(`${servers.url}/asked`, { headers: { cookie } })).text()).replace(/\s+/g, " "); | |
| 171 | expect(page).toContain("Your thoughts"); | |
| 172 | expect(page).toContain("<b>stale</b> thought"); | |
| 173 | expect(page).not.toContain("<b>stale</b>"); | |
| 174 | expect(page).toContain("Nothing built"); | |
| 175 | expect(page).not.toContain("secret"); | |
| 176 | expect(page).toContain("in a repository you cannot read"); | |
| 177 | // The one button goes to the oldest flagged thought's issue. | |
| 178 | expect(page).toContain(`data-variant="primary" href="/${slug}/issues/${i.number}"`); | |
| 179 | expect(page).toMatch(/Open the oldest of \d+ with nothing built/); | |
| 180 | const token = createToken(app.db, alice.id, "ro", "ro").token; | |
| 181 | const res = await fetch(`${servers.url}/mcp`, { | |
| 182 | method: "POST", | |
| 183 | headers: { authorization: `Bearer ${token}`, "content-type": "application/json" }, | |
| 184 | body: JSON.stringify({ jsonrpc: "2.0", id: 1, method: "tools/call", params: { name: "asked", arguments: {} } }), | |
| 185 | }); | |
| 186 | const got = JSON.parse(((await res.json()) as any).result.content[0].text); | |
| 187 | const mine = got.thoughts.find((t: any) => t.id === id); | |
| 188 | expect(mine).toMatchObject({ text: "<b>stale</b> thought", state: "placed", flagged: true }); | |
| 189 | } finally { | |
| 190 | servers.stop(); | |
| 191 | } | |
| 192 | }); | |
| 193 | }); |
new above
offline
High: there is no
canReadcheck here, unlike line 112. The state (building/shipped/superseded) of an issue in a repository the viewer can't read is computed and shown, which leaks the private issue's pull and run status. Skip unreadable placements in this loop and in thesupersedesloop.