Public: anyone can read this; the crew can change it.
Take a tell yourself and it disappears from the front door #242
Check · Review · Land · Live done — Landed and live in production.
scout/forge:scout/226-take-a-tell-yourself-and-it-disappears-f → main 3 commits fork
3 files
changed +146
−5
src/web/studio/app.ts +37 −2
| @@ -55,6 +55,7 @@ import { | ||
| 55 | 55 | import { LOG_TAIL_CAP, logBox } from "./log.ts"; |
| 56 | 56 | import { appPanels } from "./previews.ts"; |
| 57 | 57 | import { stopForm } from "./run.ts"; |
| 58 | import { tellWord } from "./tell.ts"; | |
| 58 | 59 | import { pullWord, runWord, wordBadge, type WordCtx } from "./words.ts"; |
| 59 | 60 | |
| 60 | 61 | /** |
| @@ -239,6 +240,18 @@ export async function studioApp( | ||
| 239 | 240 | const told = openTells.filter( |
| 240 | 241 | (i) => i.assigneeIsAgent && !openByIssue.has(i.number), |
| 241 | 242 | ); |
| 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 | ); | |
| 242 | 255 | // With nobody on the crew to build, `tellCrew` cannot route a tell to |
| 243 | 256 | // anyone — it stays unassigned forever, not merely unpicked-up like |
| 244 | 257 | // `waiting` below. Scoped to that one honest cause (never to a tell a |
| @@ -257,13 +270,14 @@ export async function studioApp( | ||
| 257 | 270 | : []; |
| 258 | 271 | const everRan = latestRunsForIssues( |
| 259 | 272 | db, |
| 260 | [...told, ...untaken].map((i) => i.id), | |
| 273 | [...told, ...personHeld, ...untaken].map((i) => i.id), | |
| 261 | 274 | ["agent"], |
| 262 | 275 | ); |
| 263 | 276 | 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); | |
| 264 | 278 | const stuck = untaken.filter((i) => !everRan.has(i.id)); |
| 265 | 279 | const issueNumberById = new Map( |
| 266 | [...told, ...untaken].map((i) => [i.id, i.number]), | |
| 280 | [...told, ...personHeld, ...untaken].map((i) => [i.id, i.number]), | |
| 267 | 281 | ); |
| 268 | 282 | const rowsNow = runs |
| 269 | 283 | .filter((r) => { |
| @@ -361,6 +375,27 @@ export async function studioApp( | ||
| 361 | 375 | trail: wordBadge("waiting"), |
| 362 | 376 | }); |
| 363 | 377 | }), |
| 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 | )), | |
| 364 | 399 | ...stuck.map((i) => { |
| 365 | 400 | return row({ |
| 366 | 401 | lead: avatar("crew"), |
src/web/studio/tell.ts +11 −3
| @@ -69,9 +69,11 @@ function shippedBy(db: Database, repo: Repo, issue: Issue): Pull | undefined { | ||
| 69 | 69 | * or closes it — judged by `pullWord`, the one place that fact is decided, |
| 70 | 70 | * so a merged tell reads *shipped* only once production is serving it, |
| 71 | 71 | * 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. | |
| 75 | 77 | */ |
| 76 | 78 | export async function tellWord( |
| 77 | 79 | wordCtx: WordCtx, |
| @@ -93,6 +95,12 @@ export async function tellWord( | ||
| 93 | 95 | limit: 1, |
| 94 | 96 | })[0]; |
| 95 | 97 | 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" }; | |
| 96 | 104 | return { word: "told", note: "not picked up yet" }; |
| 97 | 105 | } |
| 98 | 106 |
test/studio.test.ts +98
| @@ -4176,6 +4176,104 @@ describe("a tell nobody on the crew can take is not \"quiet\"", () => { | ||
| 4176 | 4176 | }); |
| 4177 | 4177 | }); |
| 4178 | 4178 | |
| 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 | ||
| 4179 | 4277 | /** |
| 4180 | 4278 | * Issue #191: `appShelf`/`shelfRows` used to derive a row from agent runs |
| 4181 | 4279 | * alone, so a change a person opened by hand — no run anywhere near it — |
new above
offline