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

The terminal lists a repository's waiting reports from outside and accepts or dismisses each, as the page does #7265

Merged opened this

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

Merged

torque/forge:torque/7233-the-terminal-lists-a-repository-s-waitin → main 3 commits fork

  • Merged. torque/7233-the-terminal-lists-a-repository-s-waitin was merged into main as 4596e03.

Review

Sign in to review this change.
5 files changed +302 −1
README.md +2
@@ -54,6 +54,8 @@ A pull the merge queue holds as red alone (red on its own merge at its current h
5454
5555 On a sealed issue, a writer gets the private repository to fix it in: `POST /api/v1/repos/:owner/:name/issues/:number/private`, the issue page's button, and `p` on the issue's terminal screen, which shows the repository and its clone URL (built from the forge's origin). All three refuse alike: not found for anyone who may not see sealed reports, `not_sealed`, and `unverified`.
5656
57 Reports from outside (sent with no account) are decided in the terminal too: open the repository as a person who writes it, press `:` and pick "Reports (N)"; beside each title is when it was sent, the page it came from and its words. `a` accepts the one under the cursor as an issue and says its number, `d` dismisses it after a confirm, and one someone else already decided says "this report was already decided". The row shows only while reports wait, and only to a person — not an agent — who can write the repository (`src/tui/screens/reports.ts`).
58
5759 **Taking a shipped pull back**: `POST /api/v1/repos/:owner/:name/pulls/:number/take-back` (a person who can write here, `rw` token scope; a session needs a same-origin request) opens a new pull, `Revert "<title>"` from the branch `revert-<n>` into the default branch, that undoes exactly what the shipped pull merged. Nothing lands until a person merges it. It takes no body and answers 201 with the new pull, as `POST …/pulls` answers one. Refusals: an agent or a reader gets 403 `forbidden`; a read-only token gets 403; a pull that is unknown or that you may not see gets 404 `not_found`; 409 `not_shipped` (it never merged), 409 `already_reverted`, 409 `mirrored` (the origin owns the history); 409 `conflict` when the undo no longer applies cleanly, with the conflicting files in `message`, and nothing is left behind. A failure of the forge's own git is 500 `git_failed` with a fixed message; git's words go to the server log. The pull page's **Take back** is the same verb (`revertPull` in `src/actions/revert.ts`). The terminal's pull screen does not offer it yet.
5860
5961 Under the agents, each team that has a pull has one pooled row (its members'
docs/STANDARDS.md +4 −1
@@ -39,7 +39,10 @@ wrong: the form at `/:owner/:name/report`, or
3939 (202, `{id, state: "pending"}`). A private repository answers `not_found`, as
4040 a missing one does. A report is plain text, capped (a 200-character title, an
4141 8,000-character body), limited per source, per repository and for the whole
42 forge, and a repository holds at most 100 waiting.
42 forge, and a repository holds at most 100 waiting. A person who writes the
43 repository decides them on the web, through the API, MCP `reports`, or the
44 terminal: a repository's `:` palette shows "Reports (N)" while any wait, `a`
45 accepts one as an issue and `d` dismisses one (`src/tui/screens/reports.ts`).
4346
4447 Every page also says what its reader can do there: `rel="alternate"
4548 type="application/json"` (tag and `Link` header) points at
src/tui/screens/reports.ts added +118
@@ -0,0 +1,118 @@
1 /**
2 * Reports from outside, from a repository screen's `:` palette (#7233): the
3 * terminal's door to the waiting list the web, the API and MCP `reports`
4 * already show. The row is "Reports (N)" and appears only while N > 0 for a
5 * person who may decide them — `waitingCount` is 0 to everyone else. `a`
6 * accepts the report under the cursor as an issue (its own title, behind the
7 * "From outside" marker) and says the number; `d` dismisses after a confirm.
8 * Every rule is the action's (`actions/reports.ts`).
9 */
10
11 import { REPORT_ALREADY_DECIDED } from "../../actions/origin_app_words.ts";
12 import { acceptReport, dismissReport, waitingCount, waitingReports } from "../../actions/reports.ts";
13 import type { Report } from "../../core/reports.ts";
14 import type { Repo } from "../../core/repos.ts";
15 import type { Ctx, Screen } from "../app/app.ts";
16 import { binding } from "../app/keys.ts";
17 import type { Command } from "../app/palette.ts";
18 import { style } from "../term/style.ts";
19 import { truncate, wrap } from "../term/width.ts";
20 import type { Session } from "../session.ts";
21 import { beside, fill, len, pct, split } from "../ui/layout.ts";
22 import { ListState, ago, confirmScreen, drawList, errorText, runAsync, type ListRow } from "./common.ts";
23
24 /** The palette row for `repo`: only while reports wait, only to a decider. */
25 export function reportsCommands(f: Session, ctx: Ctx, repo: Repo | undefined): Command[] {
26 if (repo === undefined || f.user === undefined) return [];
27 const n = waitingCount(f.app, f.user, repo);
28 if (n === 0) return [];
29 return [
30 {
31 id: "reports",
32 title: `${repo.ownerName}/${repo.name} — Reports (${n})`,
33 hint: "accept or dismiss what outsiders sent",
34 run: () => ctx.push(reportsScreen(f, ctx, repo)),
35 },
36 ];
37 }
38
39 /** A refusal from the action, in the words every other door uses. */
40 function refusal(code: string): string {
41 return code === "decided" ? REPORT_ALREADY_DECIDED : errorText(code);
42 }
43
44 export function reportsScreen(f: Session, ctx: Ctx, repo: Repo): Screen {
45 const state = new ListState();
46 let note = "";
47 // What the person last saw: a key acts on that row, so a report someone
48 // else decided meanwhile is refused in words rather than silently skipped.
49 let shown: Report[] = [];
50 const load = (): Report[] => {
51 const r = waitingReports(f.app, f.user, repo);
52 shown = r.ok ? r.value : [];
53 return shown;
54 };
55
56 const rows = (list: readonly Report[]): ListRow[] =>
57 list.map((r) => ({ title: r.title, secondary: r.page ?? "", meta: ago(r.createdAt) }));
58
59 const screen: Screen = {
60 title: `Reports: ${repo.ownerName}/${repo.name}`,
61 keys: [binding(["j", "k"], "move cursor"), binding(["a"], "accept as an issue"), binding(["d"], "dismiss")],
62 render(area, s) {
63 const c = ctx.theme.c;
64 const list = load();
65 const [head, body] = split(area, "v", [len(1), fill(1)]);
66 const status = list.length === 0 ? "Nothing waits." : note === "" ? "Sent from outside, unchecked. a accepts as an issue, d dismisses." : "";
67 const text = [note, status].filter((t) => t !== "").join(" ");
68 s.text(head!.x, head!.y, truncate(text, head!.w), style(c.mutedFg, c.bg), head!.x + head!.w);
69 const { main, aside } = beside(body!, ctx.twoPane, { main: pct(45), aside: fill(1) });
70 drawList(s, main, c, state, rows(list));
71 const picked = list[state.index];
72 if (aside === undefined || picked === undefined) return;
73 const x = aside.x + 1;
74 const w = Math.max(0, aside.w - 2);
75 const lines = [
76 `Sent ${ago(picked.createdAt)}${picked.page === null ? "" : ` from ${picked.page}`}`,
77 "",
78 ...wrap(picked.body === "" ? "(no words)" : picked.body, w),
79 ];
80 lines.slice(0, aside.h).forEach((l, i) =>
81 s.text(x, aside.y + i, truncate(l, w), style(i === 0 ? c.mutedFg : c.fg, c.bg), x + w),
82 );
83 },
84 key(e) {
85 if (state.key(e, shown.length)) return true;
86 if (e.ctrl || e.alt) return false;
87 const picked = shown[state.index];
88 if (e.key === "a") {
89 if (picked === undefined) return false;
90 runAsync(ctx, async () => {
91 const done = await acceptReport(f.app, f.user, repo, picked.id);
92 note = done.ok ? `Accepted as issue #${done.value.number}.` : refusal(done.error);
93 ctx.invalidate();
94 });
95 return true;
96 }
97 if (e.key === "d") {
98 if (picked === undefined) return false;
99 ctx.push(
100 confirmScreen(ctx, screen, {
101 title: "Dismiss this report",
102 body: [picked.title, "No issue is opened."],
103 actions: [{ label: "Keep waiting", variant: "ghost" }, { label: "Dismiss" }],
104 onChoose: (i) => {
105 if (i < 1) return;
106 const done = dismissReport(f.app, f.user, repo, picked.id);
107 note = done.ok ? "Dismissed." : refusal(done.error);
108 ctx.invalidate();
109 },
110 }),
111 );
112 return true;
113 }
114 return false;
115 },
116 };
117 return screen;
118 }
src/tui/screens/workspace.ts +2
@@ -118,6 +118,7 @@ import {
118118 } from "./common.ts";
119119 import { noteCommands } from "./notes.ts";
120120 import { reportCommands } from "./report.ts";
121 import { reportsCommands } from "./reports.ts";
121122 import { tellCommands } from "./tell.ts";
122123 import { workingCommands } from "./working.ts";
123124 import { yoursCommands } from "./yours.ts";
@@ -1281,6 +1282,7 @@ export function workspaceScreen(
12811282 ...workingCommands(f, ctx, repo),
12821283 ...tellCommands(f, ctx, () => screen, repo),
12831284 ...reportCommands(f, ctx, () => screen, repo),
1285 ...reportsCommands(f, ctx, repo),
12841286 ...noteCommands(f, ctx, () => screen, repo),
12851287 ...yoursCommands(f, ctx, repo),
12861288 ],
test/tui-reports.test.ts added +176
@@ -0,0 +1,176 @@
1 /**
2 * Reports from outside in the terminal (#7233): two reports filed through the
3 * API wait under "Reports (2)" in the owner's palette; `a` accepts one as an
4 * issue and says its number, `d` dismisses the other after a confirm, and the
5 * row is gone once nothing waits. A reader has no row; a report someone else
6 * decided meanwhile is refused in REPORT_ALREADY_DECIDED's words.
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 { REPORT_ALREADY_DECIDED } from "../src/actions/origin_app_words.ts";
13 import { dismissReport, waitingReports } from "../src/actions/reports.ts";
14 import { createRepo } from "../src/actions/repos.ts";
15 import { createApp, type App } from "../src/app.ts";
16 import { getIssue } from "../src/core/issues.ts";
17 import { insertReport } from "../src/core/reports.ts";
18 import type { Repo } from "../src/core/repos.ts";
19 import { createUser, getUserByName, type User } from "../src/core/users.ts";
20 import type { Ctx } from "../src/tui/app/app.ts";
21 import { reportsCommands } from "../src/tui/screens/reports.ts";
22 import type { Session } from "../src/tui/session.ts";
23 import { serve, type Servers } from "../src/http/server.ts";
24 import { createForgeApp } from "../src/tui/forge.ts";
25 import { repoScreen } from "../src/tui/screens/repo.ts";
26 import { local } from "../src/tui/screens/where.ts";
27 import { headlessTerminal } from "../src/tui/term/terminal.ts";
28 import { quietLogs } from "./helpers/globals.ts";
29 import { settleApp } from "./helpers/tui.ts";
30
31 quietLogs();
32
33 let dir: string;
34 let app: App;
35 let servers: Servers;
36 let alice: User;
37 let bob: User;
38 let repo: Repo;
39
40 /** A repository screen for `user`; `run` feeds keys and reads the screen. */
41 async function open(user: User) {
42 const terminal = headlessTerminal(140, 36);
43 const { app: tui, session } = createForgeApp(
44 app,
45 { sshUsername: user.username, remoteAddress: "127.0.0.1", fingerprint: "SHA256:test", user },
46 { terminal, exitHooks: false },
47 );
48 tui.start();
49 tui.ctx.push(repoScreen(session, tui.ctx, local(session, repo)));
50 await settleApp(tui);
51 return {
52 run: async (...keys: string[]) => {
53 for (const k of keys) {
54 terminal.feed(k);
55 await settleApp(tui);
56 }
57 return tui.screen.toText();
58 },
59 stop: () => tui.stop?.(),
60 };
61 }
62
63 async function file(title: string, body: string, page: string): Promise<void> {
64 const res = await fetch(`${servers.url}/api/v1/repos/alice/lib/reports`, {
65 method: "POST",
66 headers: { "content-type": "application/json" },
67 body: JSON.stringify({ title, body, page }),
68 });
69 expect(res.status).toBe(202);
70 }
71
72 beforeAll(async () => {
73 dir = await mkdtemp(join(tmpdir(), "forge-tui-reports-"));
74 app = createApp({ dataDir: dir, port: 0, host: "forge.test" });
75 servers = await serve(app);
76 const mk = async (username: string, first = false) => {
77 const r = await createUser(app.db, {
78 origin: "seed", trusted: true, username, email: `${username}@forge.local`,
79 password: "a-good-passphrase", adminIfFirst: first,
80 });
81 if (!r.ok) throw new Error(r.error);
82 return r.value;
83 };
84 alice = await mk("alice", true);
85 bob = await mk("bob");
86 const made = await createRepo(app, alice, { name: "lib", visibility: "public" });
87 if (!made.ok) throw new Error(String(made.error));
88 repo = made.value;
89 });
90
91 afterAll(async () => {
92 await servers.stop();
93 await rm(dir, { recursive: true, force: true });
94 });
95
96 const waiting = () => {
97 const r = waitingReports(app, alice, repo);
98 if (!r.ok) throw new Error(r.error);
99 return r.value;
100 };
101
102 describe("reports from outside, in the terminal", () => {
103 test("no row while nothing waits", async () => {
104 const h = await open(alice);
105 expect(await h.run(":", "Reports (")).not.toContain("lib — Reports (");
106 h.stop();
107 });
108
109 test("the owner accepts one and dismisses one; a reader has no row", async () => {
110 await file("The save button does nothing", "Clicked save twice, nothing happened.", "https://lib.example/edit");
111 await file("Typo on the front page", "It says teh.", "https://lib.example/");
112 expect(waiting()).toHaveLength(2);
113
114 const reader = await open(bob);
115 expect(await reader.run(":", "Reports (")).not.toContain("lib — Reports (");
116 reader.stop();
117
118 const h = await open(alice);
119 let text = await h.run(":", "Reports (");
120 expect(text).toContain("alice/lib — Reports (2)");
121 text = await h.run("\r");
122 expect(text).toContain("The save button does nothing");
123 expect(text).toContain("Typo on the front page");
124 expect(text).toContain("https://lib.example/edit");
125 expect(text).toContain("Clicked save twice, nothing happened.");
126
127 text = await h.run("a");
128 const number = Number(/Accepted as issue #(\d+)\./.exec(text)?.[1]);
129 expect(number).toBeGreaterThan(0);
130 const issue = getIssue(app.db, repo.id, number)!;
131 expect(issue.title).toContain("The save button does nothing");
132 expect(issue.body).toContain("Clicked save twice, nothing happened.");
133 expect(waiting().map((r) => r.title)).toEqual(["Typo on the front page"]);
134
135 // The confirm's first button keeps it waiting; the second dismisses.
136 await h.run("d", "\r");
137 expect(waiting()).toHaveLength(1);
138 text = await h.run("d", "\x1b[C", "\r");
139 expect(text).toContain("Dismissed.");
140 expect(text).toContain("Nothing waits.");
141 expect(waiting()).toHaveLength(0);
142 h.stop();
143
144 const after = await open(alice);
145 expect(await after.run(":", "Reports (")).not.toContain("lib — Reports (");
146 after.stop();
147 });
148
149 test("a report decided by someone else meanwhile says so", async () => {
150 await file("Login loops", "Back to the login page every time.", "https://lib.example/login");
151 const h = await open(alice);
152 await h.run(":", "Reports (", "\r");
153 const [report] = waiting();
154 expect(dismissReport(app, alice, repo, report!.id).ok).toBe(true);
155 expect(await h.run("a")).toContain(REPORT_ALREADY_DECIDED);
156 h.stop();
157 });
158 });
159
160 describe("no row for an agent or a guest", () => {
161 test("the owner, turned agent, with a report waiting, has no row; neither has a guest", async () => {
162 // Straight to the table: the API has already taken this source's share for the window.
163 insertReport(app.db, repo.id, { title: "Broken link", body: "", page: null });
164 // As a person the owner sees "Reports (1)"; the same account as an agent sees nothing.
165 expect(reportsCommands({ app, user: alice } as Session, {} as Ctx, repo)).toHaveLength(1);
166 app.db.query("UPDATE user SET is_agent = 1 WHERE id = ?1").run(alice.id);
167 try {
168 const agent = getUserByName(app.db, "alice")!;
169 expect(agent.kind).toBe("agent");
170 expect(reportsCommands({ app, user: agent } as Session, {} as Ctx, repo)).toEqual([]);
171 } finally {
172 app.db.query("UPDATE user SET is_agent = 0 WHERE id = ?1").run(alice.id);
173 }
174 expect(reportsCommands({ app, user: undefined } as Session, {} as Ctx, repo)).toEqual([]);
175 });
176 });

↑↓ navigate ↵ open ⌘↵ its app esc close

Keyboard shortcuts

Docs