Public: anyone can read this; the crew can change it.
The terminal's limbs screen watches or stops watching a milestone with `w`, as MCP `watch` does #7261
Check · Review · Land · Live done — Landed and live in production.
torque/forge:torque/7236-the-terminal-s-limbs-screen-watches-or-s → main 2 commits fork
3 files
changed +216
−3
docs/PLANNING.md +3 −1
| @@ -388,7 +388,9 @@ from the plan each time, as that person: | ||
| 388 | 388 | A watch on a repository the person can no longer read says nothing, in the |
| 389 | 389 | feed or on the phone. An agent does not watch. The same is `PUT` and |
| 390 | 390 | `DELETE /api/v1/repos/:owner/:name/milestones/:number/watch` with a write |
| 391 | token, `watching` on the milestone's read, and the MCP's `watch`. | |
| 391 | token, `watching` on the milestone's read, and the MCP's `watch`. On the | |
| 392 | terminal's Milestones screen, `w` on a row watches or stops; a watched row | |
| 393 | reads _watching_, and an agent is told only a person watches. | |
| 392 | 394 | |
| 393 | 395 | ## A repository on its own |
| 394 | 396 |
src/tui/screens/limbs.ts +39 −2
| @@ -17,6 +17,11 @@ | ||
| 17 | 17 | * `replanLimb` on confirm. The row reads "with the planner" until it |
| 18 | 18 | * answers, and `p` then takes it back while that is still allowed. |
| 19 | 19 | * |
| 20 | * `w` watches the milestone under the cursor, or stops (#7236): the same | |
| 21 | * `watchMilestone` the page and MCP `watch` reach, so its feed item and its | |
| 22 | * one push follow. A person only; an agent is told so. A watched row reads | |
| 23 | * "watching". | |
| 24 | * | |
| 20 | 25 | * `enter` on a limb that has limbs of its own opens them, so a milestone's |
| 21 | 26 | * sub-limbs can be moved among themselves (the `under` of the action). |
| 22 | 27 | * Subscribed to the bus while on the stack, so a move made on the web, or in |
| @@ -26,9 +31,12 @@ | ||
| 26 | 31 | import { undoFleetOp } from "../../actions/fleet.ts"; |
| 27 | 32 | import { previewReplan, replanLimb, takeBackLimb } from "../../actions/limb_replan.ts"; |
| 28 | 33 | import { IN_HAND } from "../../actions/plan_words.ts"; |
| 34 | import { MILESTONE_WATCH_PERSON_ONLY } from "../../actions/board_words.ts"; | |
| 35 | import { watchMilestone } from "../../actions/watches.ts"; | |
| 36 | import { isWatching } from "../../core/watches.ts"; | |
| 29 | 37 | import { pendingReplans } from "../../core/limb_replan.ts"; |
| 30 | 38 | import { applyLimb, previewLimb, type LimbPreview } from "../../actions/limbs.ts"; |
| 31 | import { canAdmin, canRead, canSeeOwnerFacts, type Repo } from "../../core/repos.ts"; | |
| 39 | import { canAdmin, canRead, canSeeOwnerFacts, getRepoById, type Repo } from "../../core/repos.ts"; | |
| 32 | 40 | import { readPlan, type Limb, type Milestone, type Plan } from "../../core/milestones.ts"; |
| 33 | 41 | import type { Ctx, Screen } from "../app/app.ts"; |
| 34 | 42 | import { binding } from "../app/keys.ts"; |
| @@ -115,6 +123,8 @@ export function limbsScreen( | ||
| 115 | 123 | let last: string | undefined; |
| 116 | 124 | /** The limbs with the planner, by issue id (#761). */ |
| 117 | 125 | let withPlanner = new Set<string>(); |
| 126 | /** The milestones here the viewer watches, by issue id (#7236). */ | |
| 127 | let watching = new Set<string>(); | |
| 118 | 128 | // Read at use, not at open: a repository's admin can change under a session. |
| 119 | 129 | const may = (): boolean => |
| 120 | 130 | f.user !== undefined && f.user.kind === "user" && canAdmin(f.db, repo, f.user); |
| @@ -133,9 +143,12 @@ export function limbsScreen( | ||
| 133 | 143 | withPlanner = new Set(pendingReplans(f.db, repo).map((r) => r.limbId)); |
| 134 | 144 | spends = new Map(); |
| 135 | 145 | paces = new Map(); |
| 146 | watching = new Set(); | |
| 136 | 147 | for (const l of limbs) { |
| 137 | 148 | const m = plan.milestone(l.issue.number); |
| 138 | 149 | if (m === undefined) continue; |
| 150 | if (f.user?.kind === "user" && isWatching(f.db, f.user.id, m.issue.id)) | |
| 151 | watching.add(m.issue.id); | |
| 139 | 152 | if (m.spend !== undefined) spends.set(l.issue.number, m.spend); |
| 140 | 153 | if (m.pace.line !== "") paces.set(l.issue.number, m.pace.line); |
| 141 | 154 | } |
| @@ -240,6 +253,28 @@ export function limbsScreen( | ||
| 240 | 253 | ); |
| 241 | 254 | }; |
| 242 | 255 | |
| 256 | /** Watch the milestone under the cursor, or stop: the action decides who may. */ | |
| 257 | const watch = (): void => { | |
| 258 | const limb = current(); | |
| 259 | if (limb === undefined) return; | |
| 260 | const on = !watching.has(limb.issue.id); | |
| 261 | // Read now, not at open: a repository made private under the screen is not watched. | |
| 262 | const now = getRepoById(f.db, repo.id); | |
| 263 | const done = | |
| 264 | now === undefined | |
| 265 | ? ({ ok: false, error: "not_found" } as const) | |
| 266 | : watchMilestone(f.app, f.user, now, limb.issue.number, on); | |
| 267 | if (!done.ok) { | |
| 268 | ctx.toast( | |
| 269 | done.error === "forbidden" ? MILESTONE_WATCH_PERSON_ONLY : "no milestone to watch here", | |
| 270 | "danger", | |
| 271 | ); | |
| 272 | return; | |
| 273 | } | |
| 274 | reload(limb.issue.number); | |
| 275 | ctx.toast(done.value.watching ? "watching — one item in your feed" : "not watching", "success"); | |
| 276 | }; | |
| 277 | ||
| 243 | 278 | const undo = (): void => { |
| 244 | 279 | const id = last; |
| 245 | 280 | if (id === undefined || !may()) return; |
| @@ -268,7 +303,7 @@ export function limbsScreen( | ||
| 268 | 303 | limbs.map((l, n) => ({ |
| 269 | 304 | title: `${n + 1}. ${line(l.issue.number, l.name)}`, |
| 270 | 305 | secondary: withPlanner.has(l.issue.id) ? "with the planner" : l.deferred ? "deferred" : "", |
| 271 | meta: `${l.weight.done}/${l.weight.total} done`, | |
| 306 | meta: `${watching.has(l.issue.id) ? "watching · " : ""}${l.weight.done}/${l.weight.total} done`, | |
| 272 | 307 | })); |
| 273 | 308 | |
| 274 | 309 | const screen: Screen = { |
| @@ -281,6 +316,7 @@ export function limbsScreen( | ||
| 281 | 316 | binding(["r"], "raise", { disabled: !moves }), |
| 282 | 317 | binding(["l"], "lower", { disabled: !moves }), |
| 283 | 318 | binding(["p"], withPlanner.has(current()?.issue.id ?? "") ? "take back" : "re-plan", { disabled: !may() || current() === undefined }), |
| 319 | binding(["w"], watching.has(current()?.issue.id ?? "") ? "unwatch" : "watch", { disabled: current() === undefined }), | |
| 284 | 320 | binding(["u"], "undo", { disabled: !may() || last === undefined }), |
| 285 | 321 | ]; |
| 286 | 322 | }, |
| @@ -319,6 +355,7 @@ export function limbsScreen( | ||
| 319 | 355 | else if (e.key === "r") move("raise"); |
| 320 | 356 | else if (e.key === "l") move("lower"); |
| 321 | 357 | else if (e.key === "p") replan(); |
| 358 | else if (e.key === "w") watch(); | |
| 322 | 359 | else if (e.key === "u") undo(); |
| 323 | 360 | else return false; |
| 324 | 361 | return true; |
test/tui-milestone-watch.test.ts added +174
| @@ -0,0 +1,174 @@ | ||
| 1 | /** | |
| 2 | * Watching a milestone from the terminal (#7236): `w` on the limbs screen is | |
| 3 | * the same `watchMilestone` the page and MCP `watch` reach. A watched row | |
| 4 | * reads "watching", the person's feed then follows the milestone, and `w` | |
| 5 | * again takes both away. An agent has no feed and is told so in the words | |
| 6 | * MCP `watch` uses. | |
| 7 | */ | |
| 8 | import { afterAll, beforeAll, describe, expect, test } from "bun:test"; | |
| 9 | import { mkdtemp, rm } from "node:fs/promises"; | |
| 10 | import { tmpdir } from "node:os"; | |
| 11 | import { join } from "node:path"; | |
| 12 | import { MILESTONE_WATCH_PERSON_ONLY } from "../src/actions/board_words.ts"; | |
| 13 | import { createAgent } from "../src/actions/crew.ts"; | |
| 14 | import { linkIssue } from "../src/actions/dependencies.ts"; | |
| 15 | import { openIssue, setIssueState } from "../src/actions/issues.ts"; | |
| 16 | import { createRepo, updateRepoSettings } from "../src/actions/repos.ts"; | |
| 17 | import { createApp, type App as Forge } from "../src/app.ts"; | |
| 18 | import { getIssueById, type Issue } from "../src/core/issues.ts"; | |
| 19 | import { getRepoById, type Repo } from "../src/core/repos.ts"; | |
| 20 | import { createUser, getUserByName, type User } from "../src/core/users.ts"; | |
| 21 | import { isWatching } from "../src/core/watches.ts"; | |
| 22 | import { readFeed } from "../src/feed/feed.ts"; | |
| 23 | import { createForgeApp } from "../src/tui/forge.ts"; | |
| 24 | import type { TuiIdentity } from "../src/tui/session.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("watch a milestone from the terminal", () => { | |
| 32 | let dataDir: string; | |
| 33 | let forge: Forge; | |
| 34 | let alice: User; | |
| 35 | let bob: User; | |
| 36 | let atlas: User; | |
| 37 | let repo: Repo; | |
| 38 | let release: Issue; | |
| 39 | let parts: Issue[]; | |
| 40 | ||
| 41 | const fresh = (): Repo => getRepoById(forge.db, repo.id)!; | |
| 42 | const open = (title: string): Issue => { | |
| 43 | const opened = openIssue(forge, alice, fresh(), { title }); | |
| 44 | if (!opened.ok) throw new Error(String(opened.error)); | |
| 45 | return opened.value; | |
| 46 | }; | |
| 47 | const inFeed = (who: User) => | |
| 48 | readFeed(forge.db, who, { limit: 50 }).items.filter((i) => i.kind === "milestone"); | |
| 49 | ||
| 50 | function boot(user: User) { | |
| 51 | const terminal = headlessTerminal(120, 32); | |
| 52 | const identity: TuiIdentity = { sshUsername: "t", remoteAddress: "127.0.0.1", user }; | |
| 53 | const { app } = createForgeApp(forge, identity, { terminal, exitHooks: false }); | |
| 54 | app.start(); | |
| 55 | const key = (k: string) => | |
| 56 | app.dispatch({ type: "key", key: k.startsWith("ctrl+") ? k.slice(5) : k, ctrl: k.startsWith("ctrl+"), alt: false, shift: false }); | |
| 57 | const press = async (...keys: string[]) => { | |
| 58 | for (const k of keys) key(k); | |
| 59 | await settleApp(app); | |
| 60 | }; | |
| 61 | const typeText = async (text: string) => { | |
| 62 | for (const ch of text) key(ch); | |
| 63 | await settleApp(app); | |
| 64 | }; | |
| 65 | const toMilestones = async (spec = "alice/release") => { | |
| 66 | await settleApp(app); | |
| 67 | await press("ctrl+k"); | |
| 68 | await typeText("Milestones in a repository"); | |
| 69 | await press("enter"); | |
| 70 | await typeText(spec); | |
| 71 | await press("enter"); | |
| 72 | }; | |
| 73 | return { text: () => app.screen.toText(), press, toMilestones, stop: () => app.stop() }; | |
| 74 | } | |
| 75 | ||
| 76 | beforeAll(async () => { | |
| 77 | dataDir = await mkdtemp(join(tmpdir(), "forge-tui-watch-")); | |
| 78 | forge = createApp({ dataDir, port: 0 }); | |
| 79 | for (const [username, adminIfFirst] of [["alice", true], ["bob", false]] as const) { | |
| 80 | const made = await createUser(forge.db, { | |
| 81 | origin: "seed", | |
| 82 | trusted: true, | |
| 83 | username, | |
| 84 | email: `${username}@forge.local`, | |
| 85 | password: "a-good-passphrase", | |
| 86 | adminIfFirst, | |
| 87 | }); | |
| 88 | if (!made.ok) throw new Error(made.error); | |
| 89 | } | |
| 90 | alice = getUserByName(forge.db, "alice")!; | |
| 91 | bob = getUserByName(forge.db, "bob")!; | |
| 92 | const agent = await createAgent(forge, alice, { username: "atlas" }); | |
| 93 | if (!agent.ok) throw new Error(String(agent.error)); | |
| 94 | atlas = getUserByName(forge.db, "atlas")!; | |
| 95 | const made = await createRepo(forge, alice, { name: "release", visibility: "public" }); | |
| 96 | if (!made.ok) throw new Error("repo"); | |
| 97 | repo = made.value; | |
| 98 | release = open("Release 1.0"); | |
| 99 | parts = [open("Part a"), open("Part b")]; | |
| 100 | for (const p of parts) { | |
| 101 | const now = (i: Issue) => getIssueById(forge.db, i.id)!; | |
| 102 | const linked = linkIssue(forge, alice, fresh(), now(release), now(p)); | |
| 103 | if (!linked.ok) throw new Error(String(linked.error)); | |
| 104 | } | |
| 105 | }, 60_000); | |
| 106 | ||
| 107 | afterAll(async () => { | |
| 108 | forge.close(); | |
| 109 | await rm(dataDir, { recursive: true, force: true }); | |
| 110 | }); | |
| 111 | ||
| 112 | test("w watches: the row shows it and the feed follows; w again takes both away", async () => { | |
| 113 | const h = boot(bob); | |
| 114 | await h.toMilestones(); | |
| 115 | expect(h.text()).toContain("Release 1.0"); | |
| 116 | expect(h.text()).not.toContain("watching"); | |
| 117 | expect(inFeed(bob)).toEqual([]); | |
| 118 | ||
| 119 | await h.press("w"); | |
| 120 | expect(isWatching(forge.db, bob.id, release.id)).toBe(true); | |
| 121 | expect(h.text()).toMatch(/Release 1\.0.*watching/); | |
| 122 | // The feed says a watched milestone once there is news: here, it lands. | |
| 123 | await Bun.sleep(5); | |
| 124 | for (const p of parts) { | |
| 125 | const closed = setIssueState(forge, alice, fresh(), getIssueById(forge.db, p.id)!, "closed"); | |
| 126 | if (!closed.ok) throw new Error(String(closed.error)); | |
| 127 | } | |
| 128 | const items = inFeed(bob); | |
| 129 | expect(items).toHaveLength(1); | |
| 130 | expect(items[0]!.id).toBe(`milestone:${release.id}:landed`); | |
| 131 | ||
| 132 | await h.press("w"); | |
| 133 | expect(isWatching(forge.db, bob.id, release.id)).toBe(false); | |
| 134 | expect(h.text()).not.toMatch(/Release 1\.0.*watching/); | |
| 135 | expect(inFeed(bob)).toEqual([]); | |
| 136 | h.stop(); | |
| 137 | }); | |
| 138 | ||
| 139 | test("an agent is refused in MCP watch's words, and nothing is written", async () => { | |
| 140 | const h = boot(atlas); | |
| 141 | await h.toMilestones(); | |
| 142 | expect(h.text()).toContain("Release 1.0"); | |
| 143 | await h.press("w"); | |
| 144 | expect(h.text()).toContain(MILESTONE_WATCH_PERSON_ONLY); | |
| 145 | expect(isWatching(forge.db, atlas.id, release.id)).toBe(false); | |
| 146 | expect(h.text()).not.toMatch(/Release 1\.0.*watching/); | |
| 147 | h.stop(); | |
| 148 | }); | |
| 149 | ||
| 150 | test("a repository made private under an open screen is not watched", async () => { | |
| 151 | const made = await createRepo(forge, alice, { name: "closing", visibility: "public" }); | |
| 152 | if (!made.ok) throw new Error("repo"); | |
| 153 | const r = made.value; | |
| 154 | const opened = (title: string): Issue => { | |
| 155 | const o = openIssue(forge, alice, getRepoById(forge.db, r.id)!, { title }); | |
| 156 | if (!o.ok) throw new Error(String(o.error)); | |
| 157 | return o.value; | |
| 158 | }; | |
| 159 | const gate = opened("Gate 2.0"); | |
| 160 | const leaf = opened("Leaf"); | |
| 161 | const linked = linkIssue(forge, alice, getRepoById(forge.db, r.id)!, gate, leaf); | |
| 162 | if (!linked.ok) throw new Error(String(linked.error)); | |
| 163 | ||
| 164 | const h = boot(bob); | |
| 165 | await h.toMilestones("alice/closing"); | |
| 166 | expect(h.text()).toContain("Gate 2.0"); | |
| 167 | const sealed = await updateRepoSettings(forge, alice, getRepoById(forge.db, r.id)!, { visibility: "private" }); | |
| 168 | if (!sealed.ok) throw new Error(String(sealed.error)); | |
| 169 | await h.press("w"); | |
| 170 | expect(h.text()).toContain("no milestone to watch here"); | |
| 171 | expect(isWatching(forge.db, bob.id, gate.id)).toBe(false); | |
| 172 | h.stop(); | |
| 173 | }); | |
| 174 | }); |
new above
offline