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

Take a tell yourself and it disappears from the front door #242

Merged opened this

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

Merged

scout/forge:scout/226-take-a-tell-yourself-and-it-disappears-f → main 3 commits fork

  • Merged. scout/226-take-a-tell-yourself-and-it-disappears-f was merged into main as 354b3f5.

Review

Sign in to review this change.
3 files changed +146 −5
src/web/studio/app.ts +37 −2
@@ -55,6 +55,7 @@ import {
5555 import { LOG_TAIL_CAP, logBox } from "./log.ts";
5656 import { appPanels } from "./previews.ts";
5757 import { stopForm } from "./run.ts";
58 import { tellWord } from "./tell.ts";
5859 import { pullWord, runWord, wordBadge, type WordCtx } from "./words.ts";
5960
6061 /**
@@ -239,6 +240,18 @@ export async function studioApp(
239240 const told = openTells.filter(
240241 (i) => i.assigneeIsAgent && !openByIssue.has(i.number),
241242 );
243 // A person can hold a tell exactly as an agent can — #224's own rule for
244 // a change applies here too: the front door counts work by what it is,
245 // not by who is doing it. Scoped to `assigneeId`, never widened to every
246 // open tell, so an ordinary unassigned backlog still stays off this
247 // panel.
248 const personHeld = openTells.filter(
249 (i) =>
250 i.assigneeId !== null &&
251 !i.assigneeIsAgent &&
252 i.id !== repo.directionIssueId &&
253 !openByIssue.has(i.number),
254 );
242255 // With nobody on the crew to build, `tellCrew` cannot route a tell to
243256 // anyone — it stays unassigned forever, not merely unpicked-up like
244257 // `waiting` below. Scoped to that one honest cause (never to a tell a
@@ -257,13 +270,14 @@ export async function studioApp(
257270 : [];
258271 const everRan = latestRunsForIssues(
259272 db,
260 [...told, ...untaken].map((i) => i.id),
273 [...told, ...personHeld, ...untaken].map((i) => i.id),
261274 ["agent"],
262275 );
263276 const waiting = told.filter((i) => !everRan.has(i.id)).slice(0, 5);
277 const held = personHeld.filter((i) => !everRan.has(i.id)).slice(0, 5);
264278 const stuck = untaken.filter((i) => !everRan.has(i.id));
265279 const issueNumberById = new Map(
266 [...told, ...untaken].map((i) => [i.id, i.number]),
280 [...told, ...personHeld, ...untaken].map((i) => [i.id, i.number]),
267281 );
268282 const rowsNow = runs
269283 .filter((r) => {
@@ -361,6 +375,27 @@ export async function studioApp(
361375 trail: wordBadge("waiting"),
362376 });
363377 }),
378 // Held by a person: `tellWord` — the one place a tell's word is
379 // decided (`studio/tell.ts`) — says so honestly, the same word and
380 // note the tell's own page and "Everything told" already show for it.
381 ...(await Promise.all(
382 held.map(async (i) => {
383 const { word, note } =
384 (await tellWord(wordCtx, repo, i, undefined)) ?? {
385 word: "told" as const,
386 note: "held",
387 };
388 const who = nameFor(i.assigneeName ?? "someone");
389 return row({
390 href: tellHref(base, i.number),
391 nav: true,
392 lead: avatar(who),
393 title: i.title,
394 meta: html`${who} · ${note} · ${time(i.createdAt)}`,
395 trail: wordBadge(word),
396 });
397 }),
398 )),
364399 ...stuck.map((i) => {
365400 return row({
366401 lead: avatar("crew"),
src/web/studio/tell.ts +11 −3
@@ -69,9 +69,11 @@ function shippedBy(db: Database, repo: Repo, issue: Issue): Pull | undefined {
6969 * or closes it — judged by `pullWord`, the one place that fact is decided,
7070 * so a merged tell reads *shipped* only once production is serving it,
7171 * same as everywhere else — else a run in flight, else its last run's own
72 * word, else *told*: said, and nobody has picked it up yet. Undefined for
73 * a closed issue nothing merged shut, so the caller falls back to the
74 * plain state badge dev uses.
72 * word, else *told*: said, and nobody has picked it up yet — or, when a
73 * person holds it rather than an agent, simply *held*: nothing is queued
74 * for the crew, so it is never worded as if it were. Undefined for a
75 * closed issue nothing merged shut, so the caller falls back to the plain
76 * state badge dev uses.
7577 */
7678 export async function tellWord(
7779 wordCtx: WordCtx,
@@ -93,6 +95,12 @@ export async function tellWord(
9395 limit: 1,
9496 })[0];
9597 if (last !== undefined) return await runWord(wordCtx, last);
98 // A person can hold a tell the same as an agent can — the crew has never
99 // touched it, but nobody is waiting on the crew to either, so it is
100 // honest to say who has it rather than that it is unpicked, as if it
101 // were still sitting in a queue.
102 if (issue.assigneeId !== null && !issue.assigneeIsAgent)
103 return { word: "told", note: "held" };
96104 return { word: "told", note: "not picked up yet" };
97105 }
98106
test/studio.test.ts +98
@@ -4176,6 +4176,104 @@ describe("a tell nobody on the crew can take is not \"quiet\"", () => {
41764176 });
41774177 });
41784178
4179 /**
4180 * Issue #226: taking a tell yourself — assigning it to a person, not an
4181 * agent — used to drop it clean off "Now": the panel's filter only ever
4182 * let an agent-held or unassigned tell through, so a person holding it
4183 * counted as nobody. `tellWord`'s own fallback said "not picked up yet"
4184 * regardless, so the tell's own page agreed with nothing true either.
4185 * Both failed before this change.
4186 */
4187 describe("a tell held by a person still shows on the front door (#226)", () => {
4188 let dataDir: string;
4189 let app: App;
4190 let servers: Servers;
4191 let base: string;
4192 let repo: Repo;
4193 let alice: User;
4194 let cookie: string;
4195 let tell: Issue;
4196
4197 const get = (path: string) =>
4198 fetch(`${base}${path}`, {
4199 headers: { cookie: `${cookie}; forge_mode=studio` },
4200 redirect: "manual",
4201 });
4202
4203 beforeAll(async () => {
4204 dataDir = await mkdtemp(join(tmpdir(), "forge-studio-person-held-"));
4205 app = createApp({ dataDir, port: 0 });
4206 servers = await serve(app);
4207 base = servers.url;
4208 const a = await createUser(app.db, {
4209 username: "alice",
4210 email: "[email protected]",
4211 password: "a-good-passphrase",
4212 adminIfFirst: true,
4213 });
4214 if (!a.ok) throw new Error(a.error);
4215 alice = a.value;
4216 cookie = (
4217 (
4218 await fetch(`${base}/signin`, {
4219 method: "POST",
4220 body: new URLSearchParams({
4221 username: "alice",
4222 password: "a-good-passphrase",
4223 }),
4224 redirect: "manual",
4225 })
4226 ).headers.get("set-cookie") ?? ""
4227 ).split(";")[0]!;
4228 const made = await createRepo(app, alice, {
4229 name: "pad",
4230 visibility: "private",
4231 description: "a pad for notes",
4232 });
4233 if (!made.ok) throw new Error(String(made.error));
4234 repo = made.value;
4235 const seeded = await commitToBranch(app, alice, repo, {
4236 message: "Start",
4237 files: [{ path: "README.md", content: "pad\n" }],
4238 });
4239 if (!seeded.ok) throw new Error(JSON.stringify(seeded.error));
4240 const enabled = await enableApp(app, alice, repo, { slug: "pad" });
4241 if (!enabled.ok) throw new Error(String(enabled.error));
4242 const scout = await createAgent(app, alice, { username: "scout" });
4243 if (!scout.ok) throw new Error(String(scout.error));
4244 expect(addToCrew(app, alice, repo, "scout").ok).toBe(true);
4245
4246 const told = openIssue(app, alice, repo, {
4247 title: "Give the header a subtitle",
4248 });
4249 if (!told.ok) throw new Error(String(told.error));
4250 tell = told.value;
4251 // Taken by a person — the owner herself — not routed to the crew.
4252 expect(assignIssue(app, alice, repo, tell, alice).ok).toBe(true);
4253 });
4254
4255 afterAll(async () => {
4256 servers.stop();
4257 app.close();
4258 await rm(dataDir, { recursive: true, force: true });
4259 });
4260
4261 test('"Now" names the person who holds it, not "Nothing yet"', async () => {
4262 const page = await (await get("/alice/pad")).text();
4263 expect(page).not.toContain("Nothing yet. Tell the crew something.");
4264 expect(page).toContain("Give the header a subtitle");
4265 expect(page).toContain("alice");
4266 expect(page).not.toContain("not picked up yet");
4267 });
4268
4269 test("the tell's own page agrees with the panel: held, not unpicked", async () => {
4270 const page = await (await get(`/alice/pad/issues/${tell.number}`)).text();
4271 expect(page).not.toContain("not picked up yet");
4272 expect(page).toContain(">alice</a>");
4273 expect(page).toContain("has it");
4274 });
4275 });
4276
41794277 /**
41804278 * Issue #191: `appShelf`/`shelfRows` used to derive a row from agent runs
41814279 * alone, so a change a person opened by hand — no run anywhere near it —

↑↓ navigate ↵ open ⌘↵ its app esc close

Keyboard shortcuts

Docs