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

Merged opened this

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

Merged

torque/forge:torque/7236-the-terminal-s-limbs-screen-watches-or-s → main 2 commits fork

  • Merged. torque/7236-the-terminal-s-limbs-screen-watches-or-s was merged into main as 9725501.

Review

Sign in to review this change.
3 files changed +216 −3
docs/PLANNING.md +3 −1
@@ -388,7 +388,9 @@ from the plan each time, as that person:
388388 A watch on a repository the person can no longer read says nothing, in the
389389 feed or on the phone. An agent does not watch. The same is `PUT` and
390390 `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.
392394
393395 ## A repository on its own
394396
src/tui/screens/limbs.ts +39 −2
@@ -17,6 +17,11 @@
1717 * `replanLimb` on confirm. The row reads "with the planner" until it
1818 * answers, and `p` then takes it back while that is still allowed.
1919 *
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 *
2025 * `enter` on a limb that has limbs of its own opens them, so a milestone's
2126 * sub-limbs can be moved among themselves (the `under` of the action).
2227 * Subscribed to the bus while on the stack, so a move made on the web, or in
@@ -26,9 +31,12 @@
2631 import { undoFleetOp } from "../../actions/fleet.ts";
2732 import { previewReplan, replanLimb, takeBackLimb } from "../../actions/limb_replan.ts";
2833 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";
2937 import { pendingReplans } from "../../core/limb_replan.ts";
3038 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";
3240 import { readPlan, type Limb, type Milestone, type Plan } from "../../core/milestones.ts";
3341 import type { Ctx, Screen } from "../app/app.ts";
3442 import { binding } from "../app/keys.ts";
@@ -115,6 +123,8 @@ export function limbsScreen(
115123 let last: string | undefined;
116124 /** The limbs with the planner, by issue id (#761). */
117125 let withPlanner = new Set<string>();
126 /** The milestones here the viewer watches, by issue id (#7236). */
127 let watching = new Set<string>();
118128 // Read at use, not at open: a repository's admin can change under a session.
119129 const may = (): boolean =>
120130 f.user !== undefined && f.user.kind === "user" && canAdmin(f.db, repo, f.user);
@@ -133,9 +143,12 @@ export function limbsScreen(
133143 withPlanner = new Set(pendingReplans(f.db, repo).map((r) => r.limbId));
134144 spends = new Map();
135145 paces = new Map();
146 watching = new Set();
136147 for (const l of limbs) {
137148 const m = plan.milestone(l.issue.number);
138149 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);
139152 if (m.spend !== undefined) spends.set(l.issue.number, m.spend);
140153 if (m.pace.line !== "") paces.set(l.issue.number, m.pace.line);
141154 }
@@ -240,6 +253,28 @@ export function limbsScreen(
240253 );
241254 };
242255
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
243278 const undo = (): void => {
244279 const id = last;
245280 if (id === undefined || !may()) return;
@@ -268,7 +303,7 @@ export function limbsScreen(
268303 limbs.map((l, n) => ({
269304 title: `${n + 1}. ${line(l.issue.number, l.name)}`,
270305 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`,
272307 }));
273308
274309 const screen: Screen = {
@@ -281,6 +316,7 @@ export function limbsScreen(
281316 binding(["r"], "raise", { disabled: !moves }),
282317 binding(["l"], "lower", { disabled: !moves }),
283318 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 }),
284320 binding(["u"], "undo", { disabled: !may() || last === undefined }),
285321 ];
286322 },
@@ -319,6 +355,7 @@ export function limbsScreen(
319355 else if (e.key === "r") move("raise");
320356 else if (e.key === "l") move("lower");
321357 else if (e.key === "p") replan();
358 else if (e.key === "w") watch();
322359 else if (e.key === "u") undo();
323360 else return false;
324361 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 });

↑↓ navigate ↵ open ⌘↵ its app esc close

Keyboard shortcuts

Docs