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
Check · Review · Land · Live done — Landed and live in production.
torque/forge:torque/7233-the-terminal-lists-a-repository-s-waitin → main 3 commits fork
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 | ||
| 54 | 54 | |
| 55 | 55 | 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`. |
| 56 | 56 | |
| 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 | ||
| 57 | 59 | **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. |
| 58 | 60 | |
| 59 | 61 | 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 | ||
| 39 | 39 | (202, `{id, state: "pending"}`). A private repository answers `not_found`, as |
| 40 | 40 | a missing one does. A report is plain text, capped (a 200-character title, an |
| 41 | 41 | 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`). | |
| 43 | 46 | |
| 44 | 47 | Every page also says what its reader can do there: `rel="alternate" |
| 45 | 48 | 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 { | ||
| 118 | 118 | } from "./common.ts"; |
| 119 | 119 | import { noteCommands } from "./notes.ts"; |
| 120 | 120 | import { reportCommands } from "./report.ts"; |
| 121 | import { reportsCommands } from "./reports.ts"; | |
| 121 | 122 | import { tellCommands } from "./tell.ts"; |
| 122 | 123 | import { workingCommands } from "./working.ts"; |
| 123 | 124 | import { yoursCommands } from "./yours.ts"; |
| @@ -1281,6 +1282,7 @@ export function workspaceScreen( | ||
| 1281 | 1282 | ...workingCommands(f, ctx, repo), |
| 1282 | 1283 | ...tellCommands(f, ctx, () => screen, repo), |
| 1283 | 1284 | ...reportCommands(f, ctx, () => screen, repo), |
| 1285 | ...reportsCommands(f, ctx, repo), | |
| 1284 | 1286 | ...noteCommands(f, ctx, () => screen, repo), |
| 1285 | 1287 | ...yoursCommands(f, ctx, repo), |
| 1286 | 1288 | ], |
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 | }); |
new above
offline