Public: anyone can read this; the crew can change it.
The terminal's issue screen says what an issue waits for, and `w` adds or removes a wait, as MCP `wait_for` does #7248
Check · Review · Land · Live done — Landed and live in production.
mason/forge:mason/7234-the-terminal-s-issue-screen-says-what-an → main 4 commits fork 2 threads · 2 unresolved
4 files
changed +424
−3
docs/OPERATING.md +2
| @@ -173,6 +173,8 @@ Crew teams (`src/core/teams.ts`, `src/actions/teams.ts`) — a named group of ag | ||
| 173 | 173 | |
| 174 | 174 | Cross-team work (`src/core/issue_teams.ts`, `src/actions/issue_teams.ts`) — an issue names up to two teams, and the pull whose body closes it (`Closes #N`) is sent to review once per named team. A writer names them on the issue page (Details → Teams), with `PUT /api/v1/repos/:owner/:name/issues/:number/teams` (`{"teams": ["design", "data-science"]}`; `GET` reads them back; a non-list is a 400, more than two or an unknown slug a 422, a mirror a 409), with the `issue_teams` MCP tool, or with `t` on an issue in the terminal — all through `nameTeams`, so the refusals and their sentences are the same. For each named team without a current verdict, a crew reviewer who is a member of it gets its own run (one reviewer on both teams gets one run, and their verdict answers both). The pull page and its Files step show each team's verdict, or that it is awaited; the terminal's pull screen shows a row per team in the Merge box (`Design: approved by quill`, `Data science: awaited`, with `(on an older head)` when the verdict is stale). A team nobody on the crew can ask is not a reason to skip review: when no named team has a reviewer here (teams start empty) the ordinary single review is sent, and when only some do, the rest wait and lapse to a person with the hour like any review. Not done: a team's approval is shown but not yet required by the merge gate. |
| 175 | 175 | |
| 176 | Waits between issues (`src/actions/dependencies.ts`, docs/PLANNING.md): a writer or a crew member links one issue to wait for another on the issue page (**Depends on**), with `POST`/`DELETE /api/v1/repos/:owner/:name/issues/:n/dependencies`, with the `wait_for` MCP tool, or with `w` on an issue in the terminal. A new number is linked, and one it already waits for asks a confirm, then is unlinked. All of these go through `linkIssue` and `unlinkIssue`, so they refuse the same links in the same `dependencyWords` sentences. The terminal's issue screen shows `Waits for #12 · Holds up #20` to every reader, and each number opens that issue. | |
| 177 | ||
| 176 | 178 | The forge setting `signup` (`src/settings/signup.ts`) is `open`, `invite` or `closed`, and `open` is what a forge with nothing stored reads as. A new forge — a fresh data directory or a spawned child — stores `invite` when it first boots; a forge that already has a stored value, or has run before without one, keeps what it had (no migration rewrites it). The seeding is done by `createApp` right after it makes the database, so a crash between those two steps would leave that forge `open`. **A new forge therefore refuses its first signup too**: make the first admin with `--first-admin` (a spawned forge's claim does it for the child), or set `FORGE_SIGNUP=open` before the first boot. A forge admin who is a person changes it live — **Signup** on `/admin/limits`, `PATCH /api/v1/admin/settings` (`{"signup":"closed"}`) or the `signup` MCP tool (dry by default, admin scope, never an agent) — with no restart; any other value is a 422. `FORGE_SIGNUP` sets it at boot, and a wrong value stops the boot with a plain message; while it stays set, every restart or deploy writes it again over a change made on the page (kept as a change by `boot`), so unset it to let the page decide. Every change is kept with who made it and when, and listed beside the form. While signup is `closed` (and `invite`, for anyone without an open invite link), `/signup` is one sentence with no form, the API and passkey signup answer 403 `signup_closed`, the terminal offers no Create account and refuses (with `invite` it asks for the token instead), and a first edge sign-in for a new name is 401. Existing accounts still sign in. A fresh forge's first account is held to the setting too; the operator's `--first-admin` link and a spawned forge's claim make the admin whatever it says. |
| 177 | 179 | |
| 178 | 180 | ### A cell's memory ceiling |
docs/PLANNING.md +8 −1
| @@ -16,7 +16,14 @@ where it is not for assigning (which spends money) or merging (a person's | ||
| 16 | 16 | call). |
| 17 | 17 | |
| 18 | 18 | On the issue page, **Depends on** says _Waits for 2, 1 still open_ and |
| 19 | _Holds up 3_, with each issue linked and a field to add one. Over the API: | |
| 19 | _Holds up 3_, with each issue linked and a field to add one. The terminal's | |
| 20 | issue screen shows the same line, `Waits for #12, #15 · Holds up #20`, when | |
| 21 | there is any, and a click on a number opens that issue. There a writer | |
| 22 | presses `w` and types a number: a new one is linked, and one it already | |
| 23 | waits for asks a confirm, then is unlinked. A refusal reads in | |
| 24 | `dependencyWords`' sentences, as MCP `wait_for` says it. The page, the | |
| 25 | terminal and `wait_for` all go through `linkIssue` and `unlinkIssue` | |
| 26 | (`src/actions/dependencies.ts`). Over the API: | |
| 20 | 27 | |
| 21 | 28 | |
| 22 | 29 | GET /api/v1/repos/:owner/:name/issues/:n/dependencies → { blockedBy, blocks } |
src/tui/screens/issue.ts +129 −2
| @@ -19,11 +19,18 @@ import { | ||
| 19 | 19 | setIssueLabelsThrough, |
| 20 | 20 | setIssueStateThrough, |
| 21 | 21 | } from "../../actions/issues.ts"; |
| 22 | import { | |
| 23 | dependencyWords, | |
| 24 | linkIssue, | |
| 25 | unlinkIssue, | |
| 26 | } from "../../actions/dependencies.ts"; | |
| 27 | import { THREAD_SAYS } from "../../actions/thread_words.ts"; | |
| 22 | 28 | import { ISSUE_TEAMS_SAY, nameTeams } from "../../actions/issue_teams.ts"; |
| 23 | 29 | import { teamsOfIssue, TEAMS_MAX } from "../../core/issue_teams.ts"; |
| 24 | 30 | import { listTeams } from "../../core/teams.ts"; |
| 25 | 31 | import { listRepoRuns } from "../../actions/runs.ts"; |
| 26 | 32 | import { privateRepoForSealed } from "../../actions/repos.ts"; |
| 33 | import { dependenciesOf } from "../../core/dependencies.ts"; | |
| 27 | 34 | import { alreadyDone } from "../../core/done_on_main.ts"; |
| 28 | 35 | import { hasPicture } from "../../core/note_pictures.ts"; |
| 29 | 36 | import { getIssue, type Issue, type Label } from "../../core/issues.ts"; |
| @@ -41,12 +48,15 @@ import { | ||
| 41 | 48 | ORIGIN_MARK, |
| 42 | 49 | Scroll, |
| 43 | 50 | ago, |
| 51 | confirmScreen, | |
| 44 | 52 | drawLines, |
| 45 | 53 | hitScroll, |
| 46 | 54 | errorText, |
| 55 | later, | |
| 47 | 56 | live, |
| 48 | 57 | pickerScreen, |
| 49 | 58 | plural, |
| 59 | promptScreen, | |
| 50 | 60 | runAsync, |
| 51 | 61 | } from "./common.ts"; |
| 52 | 62 | import { assignPicker, assignedText } from "./assign.ts"; |
| @@ -71,6 +81,29 @@ const PRIVATE_SAY: Record<string, string> = { | ||
| 71 | 81 | unverified: "Verify your email first: the private repository is for verified writers.", |
| 72 | 82 | }; |
| 73 | 83 | |
| 84 | /** What an issue waits for and holds up, by number, ascending. */ | |
| 85 | export interface Waits { | |
| 86 | readonly blockedBy: readonly number[]; | |
| 87 | readonly blocks: readonly number[]; | |
| 88 | } | |
| 89 | ||
| 90 | /** The waits line's parts: plain words, and the numbers a click opens. */ | |
| 91 | export function waitsParts(waits: Waits): { text: string; opens?: number }[] { | |
| 92 | const parts: { text: string; opens?: number }[] = []; | |
| 93 | const run = (words: string, numbers: readonly number[]): void => { | |
| 94 | if (numbers.length === 0) return; | |
| 95 | if (parts.length > 0) parts.push({ text: " · " }); | |
| 96 | parts.push({ text: `${words} ` }); | |
| 97 | numbers.forEach((n, i) => { | |
| 98 | if (i > 0) parts.push({ text: ", " }); | |
| 99 | parts.push({ text: `#${n}`, opens: n }); | |
| 100 | }); | |
| 101 | }; | |
| 102 | run("Waits for", waits.blockedBy); | |
| 103 | run("Holds up", waits.blocks); | |
| 104 | return parts; | |
| 105 | } | |
| 106 | ||
| 74 | 107 | /** The status-line tail for an issue the crew found done on main. */ |
| 75 | 108 | export function doneText(commits: readonly string[], canClose: boolean): string { |
| 76 | 109 | if (commits.length === 0) return ""; |
| @@ -96,6 +129,8 @@ export function issueScreen( | ||
| 96 | 129 | let issueRow: Issue | undefined; |
| 97 | 130 | /** The commits the crew found this issue done by on main; `x` closes it. */ |
| 98 | 131 | let doneBy: readonly string[] = []; |
| 132 | /** Which issues this one waits for and holds up; a line when any. */ | |
| 133 | let waits: Waits = { blockedBy: [], blocks: [] }; | |
| 99 | 134 | /** What the crew has done here, newest first; a card when there is any. */ |
| 100 | 135 | const crew = new RunList(f); |
| 101 | 136 | /** Which pane `j`/`k`/`enter` belong to; `tab` moves between them. */ |
| @@ -126,6 +161,11 @@ export function issueScreen( | ||
| 126 | 161 | issueRow?.state === "open" |
| 127 | 162 | ? (alreadyDone(f.db, [issueRow.id]).get(issueRow.id) ?? []) |
| 128 | 163 | : []; |
| 164 | const deps = issueRow === undefined ? undefined : dependenciesOf(f.db, issueRow.id); | |
| 165 | waits = { | |
| 166 | blockedBy: deps?.blockedBy.map((i) => i.number) ?? [], | |
| 167 | blocks: deps?.blocks.map((i) => i.number) ?? [], | |
| 168 | }; | |
| 129 | 169 | crew.set(runs !== undefined && runs.ok ? runs.value : []); |
| 130 | 170 | if (crew.runs.length === 0) focus = "thread"; |
| 131 | 171 | width = 0; |
| @@ -245,6 +285,69 @@ export function issueScreen( | ||
| 245 | 285 | }); |
| 246 | 286 | }; |
| 247 | 287 | |
| 288 | /** | |
| 289 | * `w` — a number to wait for. A new one links it; one it already waits | |
| 290 | * for asks first, then stops the wait. Refusals in `dependencyWords`. | |
| 291 | */ | |
| 292 | const waitFor = (): void => { | |
| 293 | const at = row(); | |
| 294 | if (at?.issue === undefined) return; | |
| 295 | const { repo: target, issue } = at; | |
| 296 | const refused = (code: string): string => | |
| 297 | dependencyWords[code] ?? (code === "forbidden" ? THREAD_SAYS.planForbidden : errorText(code)); | |
| 298 | const settled = (said: string): void => { | |
| 299 | ctx.toast(said, "success"); | |
| 300 | runAsync(ctx, reload); | |
| 301 | }; | |
| 302 | ctx.push( | |
| 303 | promptScreen(ctx, screen, { | |
| 304 | title: "Waits for", | |
| 305 | label: `#${number} waits for issue`, | |
| 306 | placeholder: "12", | |
| 307 | hint: | |
| 308 | waits.blockedBy.length === 0 | |
| 309 | ? "the number of the issue that must land first" | |
| 310 | : "a number it already waits for stops the wait", | |
| 311 | onSubmit: (value) => { | |
| 312 | const n = Number(value.replace(/^#/, "")); | |
| 313 | if (!Number.isInteger(n) || n <= 0) return "an issue number, like 12"; | |
| 314 | const blocker = getIssue(f.db, target.id, n); | |
| 315 | if (blocker === undefined) return `no #${n} here`; | |
| 316 | // Asked now, not from the last reload: another door may have | |
| 317 | // linked or unlinked since. | |
| 318 | const waiting = dependenciesOf(f.db, issue.id).blockedBy.some((i) => i.id === blocker.id); | |
| 319 | if (waiting) | |
| 320 | return later(ctx, () => | |
| 321 | confirmScreen(ctx, screen, { | |
| 322 | title: `Stop waiting for #${n}?`, | |
| 323 | body: [`#${number} no longer waits for #${n}.`], | |
| 324 | actions: [ | |
| 325 | { label: "Stop waiting", variant: "danger" }, | |
| 326 | { label: "Cancel", variant: "ghost" }, | |
| 327 | ], | |
| 328 | onChoose: (choice) => { | |
| 329 | if (choice !== 0) return; | |
| 330 | const done = unlinkIssue(f.app, f.user, target, issue, blocker); | |
| 331 | if (!done.ok) ctx.toast(refused(done.error), "danger"); | |
| 332 | else settled(`#${number} no longer waits for #${n}`); | |
| 333 | }, | |
| 334 | }), | |
| 335 | ); | |
| 336 | const done = linkIssue(f.app, f.user, target, issue, blocker); | |
| 337 | // A refusal another number could fix stays in the prompt; one | |
| 338 | // about who may plan here closes it and says so in full. | |
| 339 | if (!done.ok && done.error in dependencyWords) return refused(done.error); | |
| 340 | if (!done.ok) { | |
| 341 | ctx.toast(refused(done.error), "danger"); | |
| 342 | return undefined; | |
| 343 | } | |
| 344 | settled(`#${number} waits for #${n}`); | |
| 345 | return undefined; | |
| 346 | }, | |
| 347 | }), | |
| 348 | ); | |
| 349 | }; | |
| 350 | ||
| 248 | 351 | const screen: Screen = { |
| 249 | 352 | title: `#${number}`, |
| 250 | 353 | get keys() { |
| @@ -267,6 +370,7 @@ export function issueScreen( | ||
| 267 | 370 | }), |
| 268 | 371 | binding(["l"], "labels"), |
| 269 | 372 | binding(["t"], "teams"), |
| 373 | binding(["w"], "waits for"), | |
| 270 | 374 | ...(issueRow?.sealed === true |
| 271 | 375 | ? [binding(["p"], "private repo")] |
| 272 | 376 | : []), |
| @@ -291,12 +395,14 @@ export function issueScreen( | ||
| 291 | 395 | // Then the crew card, only when an agent has worked here. |
| 292 | 396 | const crewH = |
| 293 | 397 | crew.runs.length === 0 ? 0 : Math.min(crew.runs.length, CREW_ROWS) + 2; |
| 398 | const waitsLine = waitsParts(waits); | |
| 399 | const headH = waitsLine.length === 0 ? 3 : 4; | |
| 294 | 400 | const [strip, head, crewRow, body, box] = split(area, "v", [ |
| 295 | 401 | len(whereRows(src)), |
| 296 | len(3), | |
| 402 | len(headH), | |
| 297 | 403 | len(crewH), |
| 298 | 404 | fill(1), |
| 299 | len(Math.min(foot, Math.max(0, area.h - 7 - crewH - whereRows(src)))), | |
| 405 | len(Math.min(foot, Math.max(0, area.h - 4 - headH - crewH - whereRows(src)))), | |
| 300 | 406 | ]); |
| 301 | 407 | whereStrip(s, strip!, c, f, src); |
| 302 | 408 | const right = head!.x + head!.w; |
| @@ -349,6 +455,23 @@ export function issueScreen( | ||
| 349 | 455 | right, |
| 350 | 456 | ); |
| 351 | 457 | } |
| 458 | // What this waits for and holds up; each number opens that issue. | |
| 459 | let wx = head!.x; | |
| 460 | for (const part of waitsLine) { | |
| 461 | const start = wx; | |
| 462 | wx = s.text( | |
| 463 | wx, | |
| 464 | head!.y + 2, | |
| 465 | part.text, | |
| 466 | style(part.opens === undefined ? c.mutedFg : c.fg, c.bg), | |
| 467 | right, | |
| 468 | ); | |
| 469 | const opens = part.opens; | |
| 470 | if (opens !== undefined && wx > start) | |
| 471 | ctx.hit({ x: start, y: head!.y + 2, w: wx - start, h: 1 }, () => | |
| 472 | ctx.push(issueScreen(f, ctx, src, opens)), | |
| 473 | ); | |
| 474 | } | |
| 352 | 475 | if (crewH > 0) { |
| 353 | 476 | const inner = card(s, crewRow!, c, { |
| 354 | 477 | title: `Crew · ${plural(crew.runs.length, "run")}`, |
| @@ -475,6 +598,10 @@ export function issueScreen( | ||
| 475 | 598 | ); |
| 476 | 599 | return true; |
| 477 | 600 | } |
| 601 | if (e.key === "w" && mayWrite()) { | |
| 602 | waitFor(); | |
| 603 | return true; | |
| 604 | } | |
| 478 | 605 | if (e.key === "p" && mayWrite()) { |
| 479 | 606 | privateRepo(); |
| 480 | 607 | return true; |
test/tui-issue-waits.test.ts added +285
| @@ -0,0 +1,285 @@ | ||
| 1 | /** | |
| 2 | * The terminal's issue screen shows what an issue waits for and holds up, | |
| 3 | * each number opening that issue, and `w` adds or removes a wait through | |
| 4 | * the same `linkIssue` and `unlinkIssue` the page and MCP `wait_for` use. | |
| 5 | */ | |
| 6 | ||
| 7 | import { afterAll, beforeAll, describe, expect, test } from "bun:test"; | |
| 8 | import { mkdtemp, rm } from "node:fs/promises"; | |
| 9 | import { tmpdir } from "node:os"; | |
| 10 | import { join } from "node:path"; | |
| 11 | import { dependencyWords, linkIssue } from "../src/actions/dependencies.ts"; | |
| 12 | import { THREAD_SAYS } from "../src/actions/thread_words.ts"; | |
| 13 | import { openIssue } from "../src/actions/issues.ts"; | |
| 14 | import { openPull } from "../src/actions/pulls.ts"; | |
| 15 | import { commitToBranch, createRepo } from "../src/actions/repos.ts"; | |
| 16 | import { createApp, type App } from "../src/app.ts"; | |
| 17 | import { addDependency, dependenciesOf } from "../src/core/dependencies.ts"; | |
| 18 | import type { Issue } from "../src/core/issues.ts"; | |
| 19 | import { addTeamMember, createOrg, createTeam, removeTeamMember } from "../src/core/orgs.ts"; | |
| 20 | import { createRepo as createCoreRepo, getRepoById, type Repo } from "../src/core/repos.ts"; | |
| 21 | import { createUser, type User } from "../src/core/users.ts"; | |
| 22 | import { createForgeApp } from "../src/tui/forge.ts"; | |
| 23 | import { issueScreen } from "../src/tui/screens/issue.ts"; | |
| 24 | import { local } from "../src/tui/screens/where.ts"; | |
| 25 | import { headlessTerminal } from "../src/tui/term/terminal.ts"; | |
| 26 | import { quietLogs } from "./helpers/globals.ts"; | |
| 27 | import { settleApp } from "./helpers/tui.ts"; | |
| 28 | ||
| 29 | quietLogs(); | |
| 30 | ||
| 31 | describe("the terminal's issue screen and its waits", () => { | |
| 32 | let dataDir = ""; | |
| 33 | let app: App; | |
| 34 | let alice: User; | |
| 35 | let bob: User; | |
| 36 | let repo: Repo; | |
| 37 | const fresh = () => getRepoById(app.db, repo.id)!; | |
| 38 | const newIssue = (title: string): Issue => { | |
| 39 | const o = openIssue(app, alice, repo, { title }); | |
| 40 | if (!o.ok) throw new Error(o.error); | |
| 41 | return o.value; | |
| 42 | }; | |
| 43 | const waitsFor = (issue: Issue) => | |
| 44 | dependenciesOf(app.db, issue.id).blockedBy.map((i) => i.number); | |
| 45 | ||
| 46 | /** A terminal signed in as `user`, on the issue screen of `number`. */ | |
| 47 | const open = async (user: User, number: number, where: Repo = fresh(), rows = 40) => { | |
| 48 | const terminal = headlessTerminal(140, rows); | |
| 49 | const { app: tui, session } = createForgeApp( | |
| 50 | app, | |
| 51 | { sshUsername: "t", remoteAddress: "127.0.0.1", user }, | |
| 52 | { terminal, exitHooks: false }, | |
| 53 | ); | |
| 54 | tui.start(); | |
| 55 | await settleApp(tui); | |
| 56 | tui.push(issueScreen(session, tui.ctx, local(session, where), number)); | |
| 57 | await settleApp(tui); | |
| 58 | const press = async (...keys: string[]) => { | |
| 59 | for (const key of keys) | |
| 60 | tui.dispatch({ type: "key", key, ctrl: false, alt: false, shift: false }); | |
| 61 | await settleApp(tui); | |
| 62 | }; | |
| 63 | const type = (text: string) => press(...text); | |
| 64 | const text = () => tui.screen.toText(); | |
| 65 | /** Click the first cell of `needle` on the row that also holds `row`. */ | |
| 66 | const click = async (row: string, needle: string) => { | |
| 67 | const rows = text().split("\n"); | |
| 68 | const y = rows.findIndex((r) => r.includes(row) && r.includes(needle)); | |
| 69 | if (y < 0) throw new Error(`no row with ${row} and ${needle}:\n${text()}`); | |
| 70 | const x = rows[y]!.indexOf(needle, rows[y]!.indexOf(row)); | |
| 71 | tui.dispatch({ type: "mouse", kind: "press", button: 0, x, y, ctrl: false, alt: false, shift: false }); | |
| 72 | tui.dispatch({ type: "mouse", kind: "release", button: 0, x, y, ctrl: false, alt: false, shift: false }); | |
| 73 | await settleApp(tui); | |
| 74 | }; | |
| 75 | return { tui, press, type, text, click, stop: () => tui.stop() }; | |
| 76 | }; | |
| 77 | ||
| 78 | beforeAll(async () => { | |
| 79 | dataDir = await mkdtemp(join(tmpdir(), "forge-tui-issue-waits-")); | |
| 80 | app = createApp({ dataDir, port: 0 }); | |
| 81 | for (const username of ["alice", "bob"]) { | |
| 82 | const made = await createUser(app.db, { | |
| 83 | origin: "seed", | |
| 84 | trusted: true, | |
| 85 | username, | |
| 86 | email: `${username}@forge.local`, | |
| 87 | password: "a-good-passphrase", | |
| 88 | adminIfFirst: username === "alice", | |
| 89 | }); | |
| 90 | if (!made.ok) throw new Error(made.error); | |
| 91 | if (username === "alice") alice = made.value; | |
| 92 | else bob = made.value; | |
| 93 | } | |
| 94 | const r = await createRepo(app, alice, { name: "garden", visibility: "public" }); | |
| 95 | if (!r.ok) throw new Error(String(r.error)); | |
| 96 | repo = r.value; | |
| 97 | const seeded = await commitToBranch(app, alice, repo, { | |
| 98 | message: "seed", | |
| 99 | files: [{ path: "README.md", content: "# garden\n" }], | |
| 100 | }); | |
| 101 | if (!seeded.ok) throw new Error(JSON.stringify(seeded.error)); | |
| 102 | }, 30_000); | |
| 103 | ||
| 104 | afterAll(async () => { | |
| 105 | app.close(); | |
| 106 | await rm(dataDir, { recursive: true, force: true }); | |
| 107 | }); | |
| 108 | ||
| 109 | test("w links a new number, the line shows it, and the number opens that issue", async () => { | |
| 110 | const blocker = newIssue("Lay the soil"); | |
| 111 | const issue = newIssue("Plant the beds"); | |
| 112 | const t = await open(alice, issue.number); | |
| 113 | try { | |
| 114 | expect(t.text()).not.toContain("Waits for #"); | |
| 115 | await t.press("w"); | |
| 116 | expect(t.text()).toContain(`#${issue.number} waits for issue`); | |
| 117 | await t.type(String(blocker.number)); | |
| 118 | await t.press("enter"); | |
| 119 | expect(waitsFor(issue)).toEqual([blocker.number]); | |
| 120 | expect(t.text()).toContain(`Waits for #${blocker.number}`); | |
| 121 | await t.click("Waits for", `#${blocker.number}`); | |
| 122 | expect(t.text()).toContain("Lay the soil"); | |
| 123 | // The blocker's own screen says what it holds up. | |
| 124 | expect(t.text()).toContain(`Holds up #${issue.number}`); | |
| 125 | } finally { | |
| 126 | t.stop(); | |
| 127 | } | |
| 128 | }); | |
| 129 | ||
| 130 | test("w on a number it already waits for asks, then unlinks", async () => { | |
| 131 | const blocker = newIssue("Buy seeds"); | |
| 132 | const issue = newIssue("Sow"); | |
| 133 | const t = await open(alice, issue.number); | |
| 134 | try { | |
| 135 | await t.press("w"); | |
| 136 | await t.type(`#${blocker.number}`); | |
| 137 | await t.press("enter"); | |
| 138 | expect(waitsFor(issue)).toEqual([blocker.number]); | |
| 139 | await t.press("w"); | |
| 140 | await t.type(String(blocker.number)); | |
| 141 | await t.press("enter"); | |
| 142 | expect(t.text()).toContain(`Stop waiting for #${blocker.number}?`); | |
| 143 | // Cancel keeps the wait. | |
| 144 | await t.press("esc"); | |
| 145 | expect(waitsFor(issue)).toEqual([blocker.number]); | |
| 146 | await t.press("w"); | |
| 147 | await t.type(String(blocker.number)); | |
| 148 | await t.press("enter"); | |
| 149 | await t.press("enter"); | |
| 150 | expect(waitsFor(issue)).toEqual([]); | |
| 151 | expect(t.text()).not.toContain(`Waits for #${blocker.number}`); | |
| 152 | } finally { | |
| 153 | t.stop(); | |
| 154 | } | |
| 155 | }); | |
| 156 | ||
| 157 | test("a pull number and a loop are refused in dependencyWords' words", async () => { | |
| 158 | const c = await commitToBranch(app, alice, repo, { | |
| 159 | branch: "fence", | |
| 160 | base: "main", | |
| 161 | message: "fence", | |
| 162 | files: [{ path: "fence.md", content: "x\n" }], | |
| 163 | }); | |
| 164 | if (!c.ok) throw new Error(JSON.stringify(c.error)); | |
| 165 | const pull = await openPull(app, alice, repo, { | |
| 166 | title: "A fence", | |
| 167 | body: "", | |
| 168 | baseBranch: "main", | |
| 169 | headBranch: "fence", | |
| 170 | }); | |
| 171 | if (!pull.ok) throw new Error(String(pull.error)); | |
| 172 | const first = newIssue("Water"); | |
| 173 | const issue = newIssue("Harvest"); | |
| 174 | const t = await open(alice, issue.number); | |
| 175 | try { | |
| 176 | await t.press("w"); | |
| 177 | await t.type(String(pull.value.number)); | |
| 178 | await t.press("enter"); | |
| 179 | expect(t.text()).toContain(dependencyWords.not_an_issue!); | |
| 180 | expect(waitsFor(issue)).toEqual([]); | |
| 181 | await t.press("esc"); | |
| 182 | // #first waits for this one already; this waiting for #first loops. | |
| 183 | const back = await open(alice, first.number); | |
| 184 | await back.press("w"); | |
| 185 | await back.type(String(issue.number)); | |
| 186 | await back.press("enter"); | |
| 187 | back.stop(); | |
| 188 | await t.press("w"); | |
| 189 | await t.type(String(first.number)); | |
| 190 | await t.press("enter"); | |
| 191 | expect(t.text()).toContain(dependencyWords.cycle!); | |
| 192 | expect(waitsFor(issue)).toEqual([]); | |
| 193 | } finally { | |
| 194 | t.stop(); | |
| 195 | } | |
| 196 | }); | |
| 197 | ||
outdated
This test asserts that bob has no |
||
| 198 | test("a reader sees the line but has no w", async () => { | |
| 199 | const blocker = newIssue("Dig"); | |
| 200 | const issue = newIssue("Fill"); | |
| 201 | const t = await open(alice, issue.number); | |
| 202 | await t.press("w"); | |
| 203 | await t.type(String(blocker.number)); | |
| 204 | await t.press("enter"); | |
| 205 | t.stop(); | |
| 206 | const r = await open(bob, issue.number); | |
| 207 | try { | |
| 208 | expect(r.text()).toContain(`Waits for #${blocker.number}`); | |
| 209 | expect(r.text()).not.toContain("waits for issue"); | |
| 210 | await r.press("w"); | |
| 211 | expect(r.text()).not.toContain(`#${issue.number} waits for issue`); | |
| 212 | expect(waitsFor(issue)).toEqual([blocker.number]); | |
| 213 | } finally { | |
| 214 | r.stop(); | |
| 215 | } | |
| 216 | }); | |
| 217 | ||
| 218 | test("a refused link reads the planner words: write taken away while the screen is open", async () => { | |
| 219 | const org = createOrg(app.db, "acme", alice.id); | |
| 220 | if (!org.ok) throw new Error(org.error); | |
| 221 | const writers = createTeam(app.db, org.value.id, "Writers", "write"); | |
| 222 | if (!writers.ok) throw new Error(writers.error); | |
| 223 | addTeamMember(app.db, writers.value, bob.id); | |
| 224 | const made = await createCoreRepo(app.db, app.reposRoot, { | |
| 225 | ownerId: org.value.id, | |
| 226 | name: "orchard", | |
| 227 | visibility: "public", | |
| 228 | }); | |
| 229 | if (!made.ok) throw new Error(String(made.error)); | |
| 230 | const orchard = made.value; | |
| 231 | const opened = (title: string): Issue => { | |
| 232 | const o = openIssue(app, alice, orchard, { title }); | |
| 233 | if (!o.ok) throw new Error(o.error); | |
| 234 | return o.value; | |
| 235 | }; | |
| 236 | const blocker = opened("Graft"); | |
| 237 | const issue = opened("Prune"); | |
| 238 | const r = await open(bob, issue.number, orchard); | |
| 239 | try { | |
| 240 | // Bob could write when the screen opened, so `w` is there; by the | |
| 241 | // time he uses it he is off the team, and `linkIssue` says so. | |
| 242 | removeTeamMember(app.db, writers.value, bob.id); | |
| 243 | await r.press("w"); | |
| 244 | await r.type(String(blocker.number)); | |
| 245 | await r.press("enter"); | |
| 246 | // Said in full, and the prompt closed: no other number would do. | |
| 247 | expect(r.text()).toContain(THREAD_SAYS.planForbidden); | |
| 248 | expect(r.text()).not.toContain(`#${issue.number} waits for issue`); | |
| 249 | expect(waitsFor(issue)).toEqual([]); | |
| 250 | } finally { | |
| 251 | r.stop(); | |
| 252 | } | |
| 253 | }); | |
| 254 | ||
| 255 | test("w unlinks a wait written since the screen last loaded", async () => { | |
| 256 | const blocker = newIssue("Mulch"); | |
| 257 | const issue = newIssue("Weed"); | |
| 258 | const t = await open(alice, issue.number); | |
| 259 | try { | |
| 260 | // Written under the screen with no event, so nothing reloads it. | |
| 261 | expect(addDependency(app.db, issue, blocker, alice.id).ok).toBe(true); | |
| 262 | await t.press("w"); | |
| 263 | await t.type(String(blocker.number)); | |
| 264 | await t.press("enter"); | |
| 265 | expect(t.text()).toContain(`Stop waiting for #${blocker.number}?`); | |
| 266 | await t.press("enter"); | |
| 267 | expect(waitsFor(issue)).toEqual([]); | |
| 268 | } finally { | |
| 269 | t.stop(); | |
| 270 | } | |
| 271 | }); | |
| 272 | ||
| 273 | test("on a 24-row terminal the waits line still leaves the conversation room", async () => { | |
| 274 | const blocker = newIssue("Rake"); | |
| 275 | const issue = newIssue("Edge the lawn"); | |
| 276 | expect(linkIssue(app, alice, fresh(), issue, blocker).ok).toBe(true); | |
| 277 | const t = await open(alice, issue.number, fresh(), 24); | |
| 278 | try { | |
| 279 | expect(t.text()).toContain(`Waits for #${blocker.number}`); | |
| 280 | expect(t.text()).toContain("No description."); | |
| 281 | } finally { | |
| 282 | t.stop(); | |
| 283 | } | |
| 284 | }); | |
| 285 | }); |
new above
offline
The
forbiddentoTHREAD_SAYS.planForbiddenmapping is untested. Add a test that reaches it (an action refusal as a person who can see the issue but not link it), or drop the special case.