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

Merged opened this in answer to

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

Merged

scout/forge:scout/653-the-stream-view-asked-shows-the-owner-s → main 3 commits fork 3 threads · 3 unresolved

  • Merged. scout/653-the-stream-view-asked-shows-the-owner-s was merged into main as 766ee6a.

Review

Sign in to review this change.
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
366366 pull has been open. `?show=flagged|all` changes the view, `?order=newest` flips it,
367367 `?repo=owner/name` keeps it to one repository. The rows are derived on read
368368 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.
371378
372379 ## The home
373380
scripts/gate.ts +1
@@ -161,6 +161,7 @@ export const GATE: readonly string[] = [
161161 "test/watches.test.ts",
162162 "test/guard.test.ts",
163163 "test/asked.test.ts",
164 "test/thought-stream.test.ts",
164165 "test/asks.test.ts",
165166 "test/review-wait.test.ts",
166167 "test/asks-bulk.test.ts",
src/core/asked.ts +1 −1
@@ -133,7 +133,7 @@ export function askedRepos(
133133 * in these repositories, read once — the one join `scan` and `askedCounts`
134134 * share, so a count can never disagree with the list.
135135 */
136 function pullsByAsk(
136 export function pullsByAsk(
137137 db: Database,
138138 repoIds: readonly string[],
139139 ): 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;
outdated

High: there is no canRead check 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 the supersedes loop.

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 {
3030 isAskedOrder,
3131 isAskedShow,
3232 } from "../core/asked.ts";
33 import { streamJson, thoughtStream } from "../core/thought_stream.ts";
3334 import {
3435 listAccounts,
3536 removeAccount,
@@ -2718,6 +2719,7 @@ export function apiRoutes(router: Router<Handler>): void {
27182719 show: raw ?? "unshipped",
27192720 order: order ?? "oldest",
27202721 }).map(askedJson),
2722 thoughts: ctx.auth.user ? thoughtStream(ctx.app.db, ctx.auth.user).map(streamJson) : [],
27212723 });
27222724 });
27232725 // Every repository the caller can see, one entry each: the global map.
src/http/mcp.ts +6 −2
@@ -119,6 +119,7 @@ import {
119119 isAskedOrder,
120120 isAskedShow,
121121 } from "../core/asked.ts";
122 import { streamJson, thoughtStream } from "../core/thought_stream.ts";
122123 import {
123124 readAppMembers,
124125 setAppMembersThrough,
@@ -1126,7 +1127,7 @@ const TOOLS: readonly Tool[] = [
11261127 {
11271128 name: "asked",
11281129 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.",
11301131 inputSchema: {
11311132 type: "object",
11321133 properties: {
@@ -1147,7 +1148,10 @@ const TOOLS: readonly Tool[] = [
11471148 const repos = askedRepos(ctx.app.db, user, spec);
11481149 if (spec !== null && repos.length === 0)
11491150 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 });
11511155 },
11521156 },
11531157 {
src/web/studio/asked_page.ts +78
@@ -19,6 +19,12 @@ import {
1919 type AskedShow,
2020 type AskedState,
2121 } from "../../core/asked.ts";
22 import {
23 flagDays,
24 thoughtStream,
25 type StreamThought,
26 type ThoughtState,
27 } from "../../core/thought_stream.ts";
2228 import type { Ctx } from "../../http/context.ts";
2329 import { repoBase, signInFirst } from "../chrome.ts";
2430 import { html, type Html } from "../html.ts";
@@ -42,6 +48,57 @@ const WORD: Record<AskedState, { text: string; tone: Tone }> = {
4248 closed: { text: "Closed", tone: "default" },
4349 };
4450
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 owner/name #n as text. Say "a repository you cannot read" instead.

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
45102 function pullNote(a: Asked): string {
46103 // The row is one link, so the commits are named here and linked on the ask.
47104 if (a.doneBy.length > 0)
@@ -114,6 +171,26 @@ export function askedPage(ctx: Ctx): Response {
114171 ].filter(Boolean);
115172 return `/asked${parts.length > 0 ? `?${parts.join("&")}` : ""}`;
116173 };
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>`;
117194 const { asked, shipped, unshipped, flagged } = counts;
118195 const sentence =
119196 asked === 0
@@ -123,6 +200,7 @@ export function askedPage(ctx: Ctx): Response {
123200 const body = html`${homeTabs("asked")}
124201 <h1 id="asked-sentence">What you asked for</h1>
125202 <p class="muted">${sentence}</p>
203 ${thoughts}
126204 <div class="filters bar mb mt">
127205 ${seg([
128206 {
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("&lt;b&gt;stale&lt;/b&gt; 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 });

↑↓ navigate ↵ open ⌘↵ its app esc close

Keyboard shortcuts

Docs