Public: anyone can read this; the crew can change it.
The forge toolbar acts: who is working now, comment on this page, tell the crew, make it yours #2189
Check · Review · Land · Live done — Landed and live in production.
scout/forge:scout/810-the-forge-toolbar-acts-who-is-working-no → main 11 commits fork 5 threads · 5 unresolved
README.md +20
| @@ -511,6 +511,26 @@ file), and if you named an agent, the sentence became issue #1, assigned. | ||
| 511 | 511 | redeployed on each push and gone when the pull closes. Its data starts as |
| 512 | 512 | a copy of production's — copy-on-write where the filesystem can — and |
| 513 | 513 | never writes back. Merging moves code and touches no data. |
| 514 | - **The forge pill acts.** The pill the proxy adds to an app's pages | |
| 515 | (`"toolbar": false` in `forge.json` turns it off) links to a forge page, | |
| 516 | `/:owner/:name/toolbar?env=production|pull-N`, opened in a tab of its own: the | |
| 517 | pill posts nothing and reads nothing of the app's page, and every action is a | |
| 518 | same-origin form on the forge. Signed out, the page is where you are and Home. | |
| 519 | Signed in it shows the live agent run on this app or preview, linked to its | |
| 520 | run page (read once when the page opens; runs on work you may not see, such as | |
| 521 | a sealed report, are left out); *Report this page* / *Comment on this pull*, | |
| 522 | previewed first, then a receipt; *Tell the crew* for those who may direct it | |
| 523 | (the one `/:owner/:name/tell`); and *Make it yours* for everyone who cannot | |
| 524 | write the app. A production comment opens an issue carrying the page address; | |
| 525 | a preview comment goes on its pull. From a terminal or an agent the same | |
| 526 | actions are, in the SSH terminal, rows of the palette on a repository | |
| 527 | screen: *Working now* (while an agent run is live, left out when it is on work | |
| 528 | you may not see), *Tell the crew*, *Report a problem* (one text, an issue | |
| 529 | titled by its first line, through `openIssueThrough`) and *Make it yours* | |
| 530 | (the fork screen, for those who cannot write the repository); a pull is | |
| 531 | commented on from its own screen. Agents have the MCP `tell` and | |
| 532 | `make_it_yours` tools and the API's `POST …/issues`, | |
| 533 | `…/pulls/:n/comments` and `…/make-it-yours`. | |
| 514 | 534 | - **A run has a budget** (`src/kernel/budgets.ts`): a check past `gate` |
| 515 | 535 | (60 min) or a reviewer's run past `review` (10 min) is killed with |
| 516 | 536 | `budget: <what> took <n> min, limit <m>`, queued again once, and failed |
src/actions/page-report.ts added +13
| @@ -0,0 +1,13 @@ | ||
| 1 | /** | |
| 2 | * The words of a report made from a page (#810): the first line is the | |
| 3 | * title, the whole text is the body. One shape for the toolbar page and the | |
| 4 | * terminal's palette, so the issue they open reads the same from either door. | |
| 5 | */ | |
| 6 | ||
| 7 | const TITLE_MAX = 80; | |
| 8 | ||
| 9 | /** The issue title for `text`: its first line, kept to {@link TITLE_MAX}. */ | |
| 10 | export function reportTitle(text: string): string { | |
| 11 | const first = text.trim().split("\n")[0]!.trim(); | |
| 12 | return first.length <= TITLE_MAX ? first : `${first.slice(0, TITLE_MAX - 3).trimEnd()}…`; | |
| 13 | } |
src/apps/proxy.ts +10 −7
| @@ -353,17 +353,20 @@ export function pillWhere( | ||
| 353 | 353 | who: GrantWho | undefined, |
| 354 | 354 | ): PillWhere { |
| 355 | 355 | const where: PillWhere = { app: target.app.slug, env: target.env }; |
| 356 | const db = runtime.app.db; | |
| 357 | const repo = getRepoById(db, target.app.repoId); | |
| 358 | if (repo === undefined) return where; | |
| 359 | const viewer = who === undefined ? undefined : getUserById(db, who.id); | |
| 360 | const user = viewer !== undefined && isActive(viewer) ? viewer : undefined; | |
| 361 | // An app's access is not its repository's: the name goes only to a reader. | |
| 362 | const reads = canRead(db, repo, user); | |
| 363 | if (reads) where.repo = `${repo.ownerName}/${repo.name}`; | |
| 356 | 364 | const number = /^pull-(\d+)$/.exec(target.env)?.[1]; |
| 357 | 365 | if (number === undefined) return where; |
| 358 | 366 | where.pull = Number(number); |
| 359 | const db = runtime.app.db; | |
| 360 | const repo = getRepoById(db, target.app.repoId); | |
| 361 | 367 | const pull = getIssue(db, target.app.repoId, where.pull); |
| 362 | if (repo === undefined || pull === undefined) return where; | |
| 363 | const viewer = who === undefined ? undefined : getUserById(db, who.id); | |
| 364 | const user = viewer !== undefined && isActive(viewer) ? viewer : undefined; | |
| 365 | if (canRead(db, repo, user) && mayView(db, repo, pull, user)) | |
| 366 | where.title = pull.title; | |
| 368 | if (pull === undefined) return where; | |
| 369 | if (reads && mayView(db, repo, pull, user)) where.title = pull.title; | |
| 367 | 370 | return where; |
| 368 | 371 | } |
| 369 | 372 |
src/apps/toolbar.ts +20
| @@ -62,6 +62,23 @@ export const toolbarJs = `(function () { | ||
| 62 | 62 | crew.setAttribute("aria-live", "polite"); |
| 63 | 63 | panel.appendChild(crew); |
| 64 | 64 | panel.appendChild(link); |
| 65 | // The actions live on a forge page, opened in a tab of its own: the bar | |
| 66 | // posts nothing and reads nothing of this page beyond its address. | |
| 67 | if (d.repo && /^[\\w.-]+\\/[\\w.-]+$/.test(d.repo)) { | |
| 68 | var act = document.createElement("a"); | |
| 69 | act.textContent = "Comment, tell, make it yours"; | |
| 70 | act.target = "_blank"; | |
| 71 | act.rel = "noopener noreferrer"; | |
| 72 | act.style.cssText = "color:#8ab4ff;text-decoration:none;margin-top:4px"; | |
| 73 | try { | |
| 74 | var dest = new URL(home); | |
| 75 | dest.pathname = "/" + d.repo + "/toolbar"; | |
| 76 | dest.searchParams.set("env", d.env === "production" ? "production" : "pull-" + d.pull); | |
| 77 | dest.searchParams.set("page", location.origin + location.pathname); | |
| 78 | act.href = dest.href; | |
| 79 | panel.appendChild(act); | |
| 80 | } catch (e) {} | |
| 81 | } | |
| 65 | 82 | // Who of the crew is working here, asked once as the pill loads, of the |
| 66 | 83 | // app's own origin (same-origin, so its grant cookie goes along); nothing |
| 67 | 84 | // is shown idle. |
| @@ -155,6 +172,8 @@ export function rewritable(headers: Headers, status: number): boolean { | ||
| 155 | 172 | /** What the pill says about where it is; the title only for a reader of the pull. */ |
| 156 | 173 | export type PillWhere = { |
| 157 | 174 | app: string; |
| 175 | /** `owner/name`, where the forge page of the bar's actions lives. */ | |
| 176 | repo?: string; | |
| 158 | 177 | env: string; |
| 159 | 178 | pull?: number; |
| 160 | 179 | title?: string; |
| @@ -177,6 +196,7 @@ export function toolbarTag(forgeUrl: string, where?: PillWhere): string { | ||
| 177 | 196 | ? "" |
| 178 | 197 | : [ |
| 179 | 198 | ["app", where.app], |
| 199 | ["repo", where.repo], | |
| 180 | 200 | ["env", where.env], |
| 181 | 201 | ["pull", where.pull === undefined ? undefined : String(where.pull)], |
| 182 | 202 | ["title", where.title], |
src/core/runs.ts +6
| @@ -635,6 +635,8 @@ export interface ListRunsOptions { | ||
| 635 | 635 | issueId?: string | undefined; |
| 636 | 636 | /** Only runs for this environment. */ |
| 637 | 637 | env?: string | undefined; |
| 638 | /** Only runs in this state. */ | |
| 639 | state?: RunState | undefined; | |
| 638 | 640 | /** @default 50 */ |
| 639 | 641 | limit?: number | undefined; |
| 640 | 642 | /** Resume after this run id (exclusive) — lists run newest first. */ |
| @@ -660,6 +662,10 @@ export function listRuns( | ||
| 660 | 662 | params.push(options.env); |
| 661 | 663 | where += ` AND env = ?${params.length}`; |
| 662 | 664 | } |
| 665 | if (options.state !== undefined) { | |
| 666 | params.push(options.state); | |
| 667 | where += ` AND state = ?${params.length}`; | |
| 668 | } | |
| 663 | 669 | if (options.cursor !== undefined) { |
| 664 | 670 | params.push(options.cursor); |
| 665 | 671 | where += ` AND id < ?${params.length}`; |
src/http/server.ts +4
| @@ -6,6 +6,7 @@ | ||
| 6 | 6 | */ |
| 7 | 7 | |
| 8 | 8 | import { signupOptions, signupPasskey } from "../web/pages/signup-passkey.ts"; |
| 9 | import { toolbarComment, toolbarPage } from "../web/studio/toolbar.ts"; | |
| 9 | 10 | import { TOOLBAR_NAME, toolbarResponse } from "../apps/toolbar.ts"; |
| 10 | 11 | import { unsealReleased } from "../actions/sealed.ts"; |
| 11 | 12 | import { parentCallback, parentStart } from "./rp.ts"; |
| @@ -1225,6 +1226,9 @@ export function buildRouter(): Router<Handler> { | ||
| 1225 | 1226 | // Make it yours (#606): a fork with its app, planned, then made. |
| 1226 | 1227 | router.add("GET", "/:owner/:name/make-it-yours", makeItYoursPage); |
| 1227 | 1228 | router.add("POST", "/:owner/:name/make-it-yours", makeItYoursSubmit); |
| 1229 | // The toolbar's actions (#810): the bar links here, the forms post here. | |
| 1230 | router.add("GET", "/:owner/:name/toolbar", toolbarPage); | |
| 1231 | router.add("POST", "/:owner/:name/toolbar/comment", toolbarComment); | |
| 1228 | 1232 | // A fork takes what it follows: git's part by itself, the rest by the crew. |
| 1229 | 1233 | router.add("POST", "/:owner/:name/following", followingSubmit); |
| 1230 | 1234 | router.add("GET", "/:owner/:name/following", chainPage); |
src/tui/screens/report.ts added +46
| @@ -0,0 +1,46 @@ | ||
| 1 | /** | |
| 2 | * Report a problem, from a repository screen's `:` palette (#810): the | |
| 3 | * terminal's door to what the forge toolbar's "Report this page" does on the | |
| 4 | * web. One text becomes an issue through `openIssueThrough`, titled by its | |
| 5 | * first line. A pull is commented on from its own screen. | |
| 6 | */ | |
| 7 | ||
| 8 | import { openIssueThrough } from "../../actions/issues.ts"; | |
| 9 | import { reportTitle } from "../../actions/page-report.ts"; | |
| 10 | import { MAX_BODY } from "../../core/issues.ts"; | |
| 11 | import type { Repo } from "../../core/repos.ts"; | |
| 12 | import type { Ctx, Screen } from "../app/app.ts"; | |
| 13 | import type { Command } from "../app/palette.ts"; | |
| 14 | import type { Session } from "../session.ts"; | |
| 15 | import { promptScreen } from "./common.ts"; | |
| 16 | ||
| 17 | /** The palette row for `repo`: any signed-in person may report. */ | |
| 18 | export function reportCommands(f: Session, ctx: Ctx, parent: () => Screen, repo: Repo | undefined): Command[] { | |
| 19 | if (repo === undefined || f.user === undefined) return []; | |
| 20 | const name = `${repo.ownerName}/${repo.name}`; | |
| 21 | return [ | |
| 22 | { | |
| 23 | id: "report", | |
| 24 | title: `${name} — Report a problem`, | |
| 25 | hint: "opens an issue", | |
| 26 | run: () => | |
| 27 | ctx.push( | |
| 28 | promptScreen(ctx, parent(), { | |
| 29 | title: "Report a problem", | |
| 30 | label: name, | |
| 31 | hint: "enter sends", | |
| 32 | onSubmit: (said) => { | |
| 33 | const text = said.trim(); | |
| 34 | if (text === "") return "Say something first."; | |
| 35 | if (text.length > MAX_BODY) return "That is too long to send in one go."; | |
| 36 | return (async () => { | |
| 37 | const opened = await openIssueThrough(f.app, f.user, repo, { title: reportTitle(text), body: text }); | |
| 38 | if (opened.ok) ctx.toast(`Issue #${opened.value.number} opened.`, "success"); | |
| 39 | else ctx.toast("That could not be sent.", "danger"); | |
| 40 | })(); | |
| 41 | }, | |
| 42 | }), | |
| 43 | ), | |
| 44 | }, | |
| 45 | ]; | |
| 46 | } |
src/tui/screens/working.ts added +31
| @@ -0,0 +1,31 @@ | ||
| 1 | /** | |
| 2 | * Working now, from a repository screen's `:` palette (#810): the terminal's | |
| 3 | * door to the toolbar page's live line. The row is there while an agent run | |
| 4 | * is running on this repository and names it; a run on work the person may | |
| 5 | * not see (a sealed report) is left out, as on the page. Read when the | |
| 6 | * palette opens, which is as often as the page reads it. | |
| 7 | */ | |
| 8 | ||
| 9 | import type { Repo } from "../../core/repos.ts"; | |
| 10 | import { listRuns } from "../../core/runs.ts"; | |
| 11 | import { mayViewId } from "../../core/sealed.ts"; | |
| 12 | import type { Ctx } from "../app/app.ts"; | |
| 13 | import type { Command } from "../app/palette.ts"; | |
| 14 | import type { Session } from "../session.ts"; | |
| 15 | ||
| 16 | export function workingCommands(f: Session, ctx: Ctx, repo: Repo | undefined): Command[] { | |
| 17 | if (repo === undefined || f.user === undefined) return []; | |
| 18 | const live = listRuns(f.db, repo.id, { kind: "agent", state: "running", limit: 200 }).find((r) => | |
| 19 | mayViewId(f.db, repo, r.issueId, f.user), | |
| 20 | ); | |
| 21 | if (live === undefined) return []; | |
| 22 | const summary = live.summary || "the crew is on this"; | |
| 23 | return [ | |
| 24 | { | |
| 25 | id: "working", | |
| 26 | title: `${repo.ownerName}/${repo.name} — Working now: ${summary}`, | |
| 27 | hint: "the crew is running", | |
| 28 | run: () => ctx.toast(`Working now: ${summary}`, "success"), | |
Not blocking: on the page, Working now links to the run, but here it only toasts the summary again. Pushing the run's screen would make the two doors match. |
||
| 29 | }, | |
| 30 | ]; | |
| 31 | } |
src/tui/screens/workspace.ts +9 −1
| @@ -112,7 +112,10 @@ import { | ||
| 112 | 112 | slug, |
| 113 | 113 | openOnWeb, |
| 114 | 114 | } from "./common.ts"; |
| 115 | import { reportCommands } from "./report.ts"; | |
| 115 | 116 | import { tellCommands } from "./tell.ts"; |
| 117 | import { workingCommands } from "./working.ts"; | |
| 118 | import { yoursCommands } from "./yours.ts"; | |
| 116 | 119 | import { externalEditor, editorHint, hasExternalEditor } from "./external.ts"; |
| 117 | 120 | import { finderScreen } from "./finder.ts"; |
| 118 | 121 | import { issuesScreen } from "./issues.ts"; |
| @@ -1267,7 +1270,12 @@ export function workspaceScreen( | ||
| 1267 | 1270 | }; |
| 1268 | 1271 | |
| 1269 | 1272 | const screen: Screen = { |
| 1270 | commands: () => tellCommands(f, ctx, () => screen, repo), | |
| 1273 | commands: () => [ | |
| 1274 | ...workingCommands(f, ctx, repo), | |
| 1275 | ...tellCommands(f, ctx, () => screen, repo), | |
| 1276 | ...reportCommands(f, ctx, () => screen, repo), | |
| 1277 | ...yoursCommands(f, ctx, repo), | |
| 1278 | ], | |
| 1271 | 1279 | get title() { |
| 1272 | 1280 | return sourceName(src); |
| 1273 | 1281 | }, |
src/tui/screens/yours.ts added +23
| @@ -0,0 +1,23 @@ | ||
| 1 | /** | |
| 2 | * Make it yours, from a repository screen's `:` palette (#810): the | |
| 3 | * terminal's door to the toolbar's button, for whoever cannot write the | |
| 4 | * repository. It is the fork screen (#606) the `F` key opens. | |
| 5 | */ | |
| 6 | ||
| 7 | import { canWrite, type Repo } from "../../core/repos.ts"; | |
| 8 | import type { Ctx } from "../app/app.ts"; | |
| 9 | import type { Command } from "../app/palette.ts"; | |
| 10 | import type { Session } from "../session.ts"; | |
| 11 | import { forkScreen } from "./newrepo.ts"; | |
| 12 | ||
| 13 | export function yoursCommands(f: Session, ctx: Ctx, repo: Repo | undefined): Command[] { | |
| 14 | if (repo === undefined || f.user === undefined || canWrite(f.db, repo, f.user)) return []; | |
| 15 | return [ | |
| 16 | { | |
| 17 | id: "make-it-yours", | |
| 18 | title: `${repo.ownerName}/${repo.name} — Make it yours`, | |
| 19 | hint: "fork it", | |
| 20 | run: () => ctx.push(forkScreen(f, ctx, repo)), | |
| 21 | }, | |
| 22 | ]; | |
| 23 | } |
src/web/studio/toolbar.ts added +225
| @@ -0,0 +1,225 @@ | ||
| 1 | /** | |
| 2 | * The forge toolbar's actions (#810), as a forge page. The bar the app proxy | |
| 3 | * adds to an app's pages runs on the app's origin, so it never posts: it | |
| 4 | * opens this page on the forge's own origin, where every mutation is an | |
| 5 | * ordinary same-origin form with the forge's CSRF check (`readForm`). The | |
| 6 | * bar's script reads nothing of the app's page and nothing of the forge's | |
| 7 | * session; the forge reads the session here. | |
| 8 | * | |
| 9 | * GET /:owner/:name/toolbar?env=&page= where you are, who is working, | |
| 10 | * and the actions a person may take | |
| 11 | * POST /:owner/:name/toolbar/comment a preview of the comment, then | |
| 12 | * (step=post) the comment and a receipt | |
| 13 | * | |
| 14 | * Telling the crew posts to the one `/:owner/:name/tell`; making it yours | |
| 15 | * links to #606's page. Signed out, the page is where you are and home. | |
| 16 | */ | |
| 17 | ||
| 18 | import { addCommentThrough, openIssueThrough } from "../../actions/issues.ts"; | |
| 19 | import { reportTitle } from "../../actions/page-report.ts"; | |
| 20 | import { getAppByRepo } from "../../core/apps.ts"; | |
| 21 | import { getIssue, MAX_BODY } from "../../core/issues.ts"; | |
| 22 | import { canWrite } from "../../core/repos.ts"; | |
| 23 | import { listRuns } from "../../core/runs.ts"; | |
| 24 | import { mayView, mayViewId } from "../../core/sealed.ts"; | |
| 25 | import { mayDirectCrew } from "../../crew/direct.ts"; | |
| 26 | import type { Ctx } from "../../http/context.ts"; | |
| 27 | import { notFoundPage, pageHead, loadRepo, repoBase } from "../chrome.ts"; | |
| 28 | import { field, readForm } from "../form.ts"; | |
| 29 | import { html, when } from "../html.ts"; | |
| 30 | import { page } from "../layout.ts"; | |
| 31 | import { originError } from "../pages/thread.ts"; | |
| 32 | import { btn, card } from "../ui.ts"; | |
| 33 | ||
| 34 | /** The longest page address the bar may carry. */ | |
| 35 | const PAGE_MAX = 512; | |
| 36 | ||
| 37 | /** `raw` as a page address (http or https, no credentials), or "" for no. | |
| 38 | * Only origin and path survive: a query or fragment may carry a token, and | |
| 39 | * the address is published in an issue or comment. */ | |
| 40 | export function pageAddress(raw: string): string { | |
| 41 | if (raw.length === 0 || raw.length > PAGE_MAX) return ""; | |
outdated
High: this keeps |
||
| 42 | const url = URL.parse(raw); | |
| 43 | if (!url || (url.protocol !== "http:" && url.protocol !== "https:")) return ""; | |
| 44 | if (url.username !== "" || url.password !== "") return ""; | |
| 45 | url.search = ""; | |
| 46 | url.hash = ""; | |
| 47 | return url.href.length <= PAGE_MAX ? url.href : ""; | |
| 48 | } | |
| 49 | ||
| 50 | /** `env` as the bar names it: production, or a preview's pull number. */ | |
| 51 | function parseEnv(raw: string): { env: string; pull?: number } | undefined { | |
| 52 | if (raw === "production") return { env: raw }; | |
| 53 | const n = /^pull-([1-9]\d{0,8})$/.exec(raw)?.[1]; | |
| 54 | return n === undefined ? undefined : { env: raw, pull: Number(n) }; | |
| 55 | } | |
| 56 | ||
| 57 | /** The toolbar page's own path, for forms to come back to. */ | |
| 58 | const toolbarPath = (base: string, env: string, address: string): string => | |
| 59 | `${base}/toolbar?env=${env}${address === "" ? "" : `&page=${encodeURIComponent(address)}`}`; | |
| 60 | ||
| 61 | // GET /:owner/:name/toolbar | |
| 62 | export function toolbarPage(ctx: Ctx): Response { | |
| 63 | const repo = loadRepo(ctx); | |
| 64 | if (!repo) return notFoundPage(ctx); | |
| 65 | const where = parseEnv(ctx.url.searchParams.get("env") ?? "production"); | |
| 66 | if (where === undefined || getAppByRepo(ctx.app.db, repo.id) === undefined) | |
| 67 | return notFoundPage(ctx); | |
| 68 | const db = ctx.app.db; | |
| 69 | const user = ctx.auth.user; | |
| 70 | const pull = where.pull === undefined ? undefined : getIssue(db, repo.id, where.pull); | |
| 71 | if (where.pull !== undefined && (!pull || pull.kind !== "pull" || !mayView(db, repo, pull, user))) | |
| 72 | return notFoundPage(ctx); | |
| 73 | const address = pageAddress(ctx.url.searchParams.get("page") ?? ""); | |
| 74 | const base = repoBase(repo); | |
| 75 | const here = html`${repo.name}, ${pull ? html`preview of pull #${pull.number}` : "production"}`; | |
| 76 | const home = btn("Home", { href: "/", variant: "ghost" }); | |
| 77 | if (!user) { | |
| 78 | return page({ | |
| 79 | ctx, | |
| 80 | title: `${repo.name} · forge`, | |
outdated
High: no sealed scope here. On production, this picks any running agent run on the repo, including one on a sealed issue, and line 91 prints its summary to any reader. Filter with |
||
| 81 | body: html`<div class="narrow-wide">${pageHead(here)}<div class="form-actions">${home}</div></div>`, | |
| 82 | }); | |
| 83 | } | |
| 84 | // Read once, now, as the page opens: the live agent run on this app, or | |
| 85 | // on this preview's pull. | |
| 86 | // A run on work the viewer may not see (a sealed report) is not theirs to read. | |
| 87 | const live = listRuns(db, repo.id, { | |
| 88 | kind: "agent", | |
| 89 | ...(pull ? { issueId: pull.id } : {}), | |
| 90 | state: "running", | |
| 91 | limit: 200, | |
| 92 | }).find((r) => mayViewId(db, repo, r.issueId, user)); | |
| 93 | const comment = pull ? "Comment on this pull" : "Report this page"; | |
| 94 | const body = html`<div class="narrow-wide"> | |
| 95 | ${pageHead(here)} | |
| 96 | ${when( | |
| 97 | live !== undefined, | |
| 98 | () => html`<p class="mono" data-working> | |
| 99 | <a href="${base}/runs/${live!.id}">Working now: ${live!.summary || "the crew is on this"}</a> | |
| 100 | </p>`, | |
| 101 | )} | |
| 102 | ${card( | |
| 103 | html`<form method="post" action="${base}/toolbar/comment" class="stack"> | |
| 104 | <input type="hidden" name="env" value="${where.env}" /> | |
| 105 | <input type="hidden" name="page" value="${address}" /> | |
| 106 | <input type="hidden" name="step" value="preview" /> | |
| 107 | <label>${comment} | |
| 108 | <textarea name="body" rows="4" maxlength="${MAX_BODY}" required></textarea> | |
| 109 | </label> | |
| 110 | <div class="form-actions">${btn("Preview", { variant: "primary" })}</div> | |
| 111 | </form>`, | |
| 112 | )} | |
| 113 | ${when( | |
| 114 | mayDirectCrew(db, repo, user), | |
| 115 | () => | |
| 116 | card( | |
| 117 | html`<form method="post" action="${base}/tell" class="stack"> | |
| 118 | <input type="hidden" name="from" value="${toolbarPath(base, where.env, "")}" /> | |
| 119 | <label>Tell the crew | |
| 120 | <textarea name="text" rows="3" maxlength="${MAX_BODY}" required></textarea> | |
| 121 | </label> | |
| 122 | <div class="form-actions">${btn("Tell", { variant: "primary" })}</div> | |
| 123 | </form>`, | |
| 124 | ), | |
| 125 | )} | |
| 126 | <div class="form-actions"> | |
| 127 | ${when( | |
| 128 | !canWrite(db, repo, user), | |
| 129 | () => btn("Make it yours", { href: `${base}/make-it-yours`, icon: "fork" }), | |
| 130 | )} | |
| 131 | ${home} | |
| 132 | </div> | |
| 133 | </div>`; | |
| 134 | return page({ ctx, title: `${repo.name} · forge`, body }); | |
| 135 | } | |
| 136 | ||
| 137 | // POST /:owner/:name/toolbar/comment | |
| 138 | export async function toolbarComment(ctx: Ctx): Promise<Response> { | |
| 139 | const repo = loadRepo(ctx); | |
| 140 | if (!repo) return notFoundPage(ctx); | |
| 141 | const form = await readForm(ctx, "rw"); | |
| 142 | if (form instanceof Response) return form; | |
| 143 | const user = ctx.auth.user; | |
| 144 | if (!user) return new Response("forbidden", { status: 403 }); | |
| 145 | const where = parseEnv(field(form, "env")); | |
| 146 | if (where === undefined) return new Response("bad request", { status: 400 }); | |
| 147 | const db = ctx.app.db; | |
| 148 | const pull = where.pull === undefined ? undefined : getIssue(db, repo.id, where.pull); | |
| 149 | if (where.pull !== undefined && (!pull || pull.kind !== "pull" || !mayView(db, repo, pull, user))) | |
| 150 | return notFoundPage(ctx); | |
| 151 | const text = field(form, "body").trim(); | |
| 152 | const address = pageAddress(field(form, "page")); | |
| 153 | const base = repoBase(repo); | |
| 154 | const back = toolbarPath(base, where.env, address); | |
| 155 | const refuse = (message: string, status = 422): Response => | |
| 156 | page( | |
| 157 | { | |
| 158 | ctx, | |
| 159 | title: "Not sent", | |
| 160 | body: html`<div class="narrow-wide">${pageHead("Not sent", { sub: message })} | |
| 161 | <div class="form-actions">${btn("Back", { href: back, variant: "ghost" })}</div></div>`, | |
| 162 | }, | |
| 163 | status, | |
| 164 | ); | |
| 165 | if (text === "") return refuse("A comment needs something in it."); | |
| 166 | const said = address === "" ? text : `${text}\n\nPage: <${address}>`; | |
| 167 | if (said.length > MAX_BODY) return refuse("That is too long to send in one go."); | |
| 168 | const target = pull ? `pull #${pull.number}` : "an issue on this app"; | |
| 169 | ||
| 170 | if (field(form, "step") !== "post") { | |
| 171 | return page({ | |
| 172 | ctx, | |
| 173 | title: "Preview", | |
| 174 | body: html`<div class="narrow-wide"> | |
| 175 | ${pageHead("Preview", { sub: html`This will be sent as ${target}.` })} | |
| 176 | ${card(html`<form method="post" action="${base}/toolbar/comment" class="stack"> | |
| 177 | <input type="hidden" name="env" value="${where.env}" /> | |
| 178 | <input type="hidden" name="page" value="${address}" /> | |
| 179 | <input type="hidden" name="step" value="post" /> | |
| 180 | <input type="hidden" name="body" value="${text}" /> | |
| 181 | <pre class="mono">${said}</pre> | |
| 182 | <div class="form-actions"> | |
| 183 | ${btn("Send", { variant: "primary" })} | |
| 184 | ${btn("Edit", { href: back, variant: "ghost" })} | |
| 185 | </div> | |
| 186 | </form>`)} | |
| 187 | </div>`, | |
| 188 | }); | |
| 189 | } | |
| 190 | ||
| 191 | if (pull) { | |
| 192 | const added = await addCommentThrough(ctx.app, user, repo, pull, said); | |
| 193 | if (!added.ok) | |
| 194 | return refuse(originError(added.error) ?? "That comment could not be posted.", 403); | |
| 195 | return receipt(`${base}/pulls/${pull.number}`, "Comment posted"); | |
| 196 | } | |
| 197 | // The `…Through` twin, as every door that opens an issue: on a mirror the | |
| 198 | // origin creates it. | |
| 199 | const opened = await openIssueThrough(ctx.app, user, repo, { | |
| 200 | title: reportTitle(text), | |
| 201 | body: said, | |
| 202 | }); | |
| 203 | if (!opened.ok) { | |
| 204 | return refuse( | |
| 205 | originError(opened.error) ?? | |
| 206 | (opened.error === "body_too_long" | |
| 207 | ? "That is too long to send in one go." | |
| 208 | : "That could not be sent."), | |
| 209 | opened.error === "body_too_long" ? 422 : opened.error === "not_found" ? 404 : 403, | |
| 210 | ); | |
| 211 | } | |
| 212 | return receipt(`${base}/issues/${opened.value.number}`, "Issue opened"); | |
| 213 | ||
| 214 | function receipt(href: string, title: string): Response { | |
| 215 | return page({ | |
| 216 | ctx, | |
| 217 | title, | |
| 218 | body: html`<div class="narrow-wide"> | |
| 219 | ${pageHead(title)} | |
| 220 | <p><a href="${href}">See it</a></p> | |
| 221 | <div class="form-actions">${btn("Back to the page", { href: back, variant: "ghost" })}</div> | |
| 222 | </div>`, | |
| 223 | }); | |
| 224 | } | |
| 225 | } |
test/apps-pill-where.test.ts +22
| @@ -45,9 +45,13 @@ describe("the pill's data", () => { | ||
| 45 | 45 | for (const [name, visibility] of [ |
| 46 | 46 | ["open", "public"], |
| 47 | 47 | ["closed", "private"], |
| 48 | ["leaky", "private"], | |
| 48 | 49 | ] as const) { |
| 49 | 50 | const made = await newApp(app, a.value, { name, description: "x", visibility }); |
| 50 | 51 | if (!made.ok) throw new Error(String(made.error)); |
| 52 | // The app's access is its own: this one is open on a private repo. | |
| 53 | if (name === "leaky") | |
| 54 | app.db.run("UPDATE app SET access='public' WHERE repo_id=?1", [made.value.repo.id]); | |
| 51 | 55 | const record = getAppByRepo(app.db, made.value.repo.id)!; |
| 52 | 56 | targets[name] = { app: record, env: "production" } as AppTarget; |
| 53 | 57 | const pull = createIssue(app.db, { |
| @@ -72,6 +76,7 @@ describe("the pill's data", () => { | ||
| 72 | 76 | test("production names the app and the environment, no pull", () => { |
| 73 | 77 | expect(pillWhere(runtime, targets.open!, undefined)).toEqual({ |
| 74 | 78 | app: targets.open!.app.slug, |
| 79 | repo: "alice/open", | |
| 75 | 80 | env: "production", |
| 76 | 81 | }); |
| 77 | 82 | }); |
| @@ -91,6 +96,23 @@ describe("the pill's data", () => { | ||
| 91 | 96 | expect(owner.title).toContain("closed"); |
| 92 | 97 | }); |
| 93 | 98 | |
| 99 | test("a public app on a private repo names the repo only to a reader", async () => { | |
| 100 | expect(targets.leaky!.app.access).toBe("public"); | |
| 101 | expect(pillWhere(runtime, targets.leaky!, undefined).repo).toBeUndefined(); | |
| 102 | expect(pillWhere(runtime, preview("leaky"), undefined).title).toBeUndefined(); | |
| 103 | const bob = await createUser(app.db, { | |
| 104 | trusted: true, | |
| 105 | username: "bob", | |
| 106 | email: "[email protected]", | |
| 107 | password: "a-good-passphrase", | |
| 108 | }); | |
| 109 | if (!bob.ok) throw new Error(bob.error); | |
| 110 | const stranger = pillWhere(runtime, targets.leaky!, { id: bob.value.id } as never); | |
| 111 | expect(stranger.repo).toBeUndefined(); | |
| 112 | const owner = pillWhere(runtime, targets.leaky!, { id: aliceId } as never); | |
| 113 | expect(owner.repo).toBe("alice/leaky"); | |
| 114 | }); | |
| 115 | ||
| 94 | 116 | test("a title with markup is escaped inside the attribute", () => { |
| 95 | 117 | const tag = toolbarTag("http://f.test", { |
| 96 | 118 | app: "a", |
test/apps-toolbar.test.ts +1 −1
| @@ -128,7 +128,7 @@ describe("the forge pill", () => { | ||
| 128 | 128 | redirect: "manual", |
| 129 | 129 | }); |
| 130 | 130 | const tag = () => |
| 131 | `<script src="${servers.url}${TOOLBAR_PATH}" data-app="notes" data-env="production" defer></script>`; | |
| 131 | `<script src="${servers.url}${TOOLBAR_PATH}" data-app="notes" data-repo="alice/notes" data-env="production" defer></script>`; | |
| 132 | 132 | |
| 133 | 133 | test("HTML gets one script tag before </body>, with its length fixed", async () => { |
| 134 | 134 | toolbar = undefined; |
test/toolbar-actions.test.ts added +324
| @@ -0,0 +1,324 @@ | ||
| 1 | /** | |
| 2 | * The toolbar's actions (#810): the page the bar opens on the forge, the | |
| 3 | * comment (a pull's comment on a preview, an issue on production) with a | |
| 4 | * preview first and a receipt after, the tell through the one tell door, | |
| 5 | * make it yours hidden from writers, signed-out showing nothing to do, a | |
| 6 | * cross-origin POST refused, and the working-now line only while a run is live. | |
| 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 { createApp, type App } from "../src/app.ts"; | |
| 13 | import { newApp } from "../src/actions/apps.ts"; | |
| 14 | import { toolbarJs, toolbarTag } from "../src/apps/toolbar.ts"; | |
| 15 | import { createOrigin, linkRepoToOrigin } from "../src/core/origins.ts"; | |
| 16 | import { createIssue, getIssue, listComments, listIssues } from "../src/core/issues.ts"; | |
| 17 | import { claimRun, createRun, finishRun } from "../src/core/runs.ts"; | |
| 18 | import { createSession } from "../src/core/sessions.ts"; | |
| 19 | import { createUser, type User } from "../src/core/users.ts"; | |
| 20 | import { serve, type Servers } from "../src/http/server.ts"; | |
| 21 | import { quietLogs } from "./helpers/globals.ts"; | |
| 22 | ||
| 23 | quietLogs(); | |
| 24 | ||
| 25 | describe("toolbar actions", () => { | |
| 26 | let dataDir: string; | |
| 27 | let app: App; | |
| 28 | let servers: Servers; | |
| 29 | let alice: User; | |
| 30 | let bob: User; | |
| 31 | let repoId: string; | |
| 32 | let pullId: string; | |
| 33 | let pullNumber: number; | |
| 34 | let aliceCookie: string; | |
| 35 | let bobCookie: string; | |
| 36 | const PAGE = "https://notes.example.test/a?b=1"; | |
| 37 | ||
| 38 | const get = (path: string, cookie?: string) => | |
| 39 | fetch(`${servers.url}${path}`, { | |
| 40 | headers: cookie ? { cookie } : {}, | |
| 41 | redirect: "manual", | |
| 42 | }); | |
| 43 | const post = (path: string, cookie: string, fields: Record<string, string>, origin = servers.url) => | |
| 44 | fetch(`${servers.url}${path}`, { | |
| 45 | method: "POST", | |
| 46 | headers: { cookie, origin, "content-type": "application/x-www-form-urlencoded" }, | |
| 47 | body: new URLSearchParams(fields), | |
| 48 | redirect: "manual", | |
| 49 | }); | |
| 50 | ||
| 51 | beforeAll(async () => { | |
| 52 | dataDir = await mkdtemp(join(tmpdir(), "forge-toolbar-actions-")); | |
| 53 | app = createApp({ dataDir, port: 0 }); | |
| 54 | servers = await serve(app); | |
| 55 | const mk = async (username: string, first = false) => { | |
| 56 | const r = await createUser(app.db, { | |
| 57 | trusted: true, | |
| 58 | username, | |
| 59 | email: `${username}@forge.local`, | |
| 60 | password: "a-good-passphrase", | |
| 61 | adminIfFirst: first, | |
| 62 | }); | |
| 63 | if (!r.ok) throw new Error(r.error); | |
| 64 | return r.value; | |
| 65 | }; | |
| 66 | alice = await mk("alice", true); | |
| 67 | bob = await mk("bob"); | |
| 68 | const made = await newApp(app, alice, { name: "notes", description: "x", visibility: "public" }); | |
| 69 | if (!made.ok) throw new Error(String(made.error)); | |
| 70 | repoId = made.value.repo.id; | |
| 71 | const pull = createIssue(app.db, { repoId, authorId: alice.id, title: "A change", kind: "pull" }); | |
| 72 | if (!pull.ok) throw new Error(pull.error); | |
| 73 | pullId = pull.value.id; | |
| 74 | pullNumber = pull.value.number; | |
| 75 | aliceCookie = `forge_session=${createSession(app.db, alice.id).token}`; | |
| 76 | bobCookie = `forge_session=${createSession(app.db, bob.id).token}`; | |
| 77 | }, 60_000); | |
| 78 | ||
| 79 | afterAll(async () => { | |
| 80 | servers.stop(); | |
| 81 | app.close(); | |
| 82 | await rm(dataDir, { recursive: true, force: true }); | |
| 83 | }); | |
| 84 | ||
| 85 | const url = (env: string) => | |
| 86 | `/alice/notes/toolbar?env=${env}&page=${encodeURIComponent(PAGE)}`; | |
| 87 | ||
| 88 | test("the bar links to the forge page and never posts or reads the app", () => { | |
| 89 | expect(toolbarJs).toContain('"/toolbar"'); | |
| 90 | // Its one request is the crew line (#2233): a GET of the app's own origin. | |
| 91 | expect(toolbarJs.match(/fetch\(/g)?.length).toBe(1); | |
| 92 | expect(toolbarJs).toContain('fetch("/__forge/crew"'); | |
| 93 | expect(toolbarJs).not.toMatch(/method:|FormData|\.send\(/); | |
| 94 | expect(toolbarJs).not.toContain("XMLHttpRequest"); | |
| 95 | expect(toolbarJs).not.toContain("document.cookie"); | |
| 96 | expect(toolbarTag("https://forge.test", { app: "notes", repo: "alice/notes", env: "production" })).toContain( | |
| 97 | 'data-repo="alice/notes"', | |
| 98 | ); | |
| 99 | }); | |
| 100 | ||
| 101 | test("signed out: where you are and home only", async () => { | |
| 102 | const text = await (await get(url("production"))).text(); | |
| 103 | expect(text).toContain("production"); | |
| 104 | expect(text).toContain("Home"); | |
| 105 | expect(text).not.toContain("Tell the crew"); | |
| 106 | expect(text).not.toContain("Report this page"); | |
| 107 | expect(text).not.toContain("Make it yours"); | |
| 108 | }); | |
| 109 | ||
| 110 | test("an issue number is not a pull: pull-N for a plain issue is not found, page and comment", async () => { | |
| 111 | const plain = createIssue(app.db, { repoId, authorId: alice.id, title: "Just an issue" }); | |
| 112 | if (!plain.ok) throw new Error(plain.error); | |
| 113 | const env = `pull-${plain.value.number}`; | |
| 114 | expect((await get(url(env), aliceCookie)).status).toBe(404); | |
| 115 | const before = listComments(app.db, plain.value.id).length; | |
| 116 | const res = await post("/alice/notes/toolbar/comment", aliceCookie, { env, body: "hi", step: "post" }); | |
| 117 | expect(res.status).toBe(404); | |
| 118 | expect(listComments(app.db, plain.value.id).length).toBe(before); | |
| 119 | }); | |
| 120 | ||
| 121 | test("a bad env is not found", async () => { | |
| 122 | expect((await get(url("pull-abc"), aliceCookie)).status).toBe(404); | |
| 123 | expect((await get(url("pull-9999"), aliceCookie)).status).toBe(404); | |
| 124 | }); | |
| 125 | ||
| 126 | test("writers do not see make it yours; others do; tell is offered to who may", async () => { | |
| 127 | const mine = await (await get(url("production"), aliceCookie)).text(); | |
| 128 | expect(mine).not.toContain("make-it-yours"); | |
| 129 | expect(mine).toContain("Tell the crew"); | |
| 130 | const theirs = await (await get(url("production"), bobCookie)).text(); | |
| 131 | expect(theirs).toContain("/alice/notes/make-it-yours"); | |
| 132 | }); | |
| 133 | ||
| 134 | test("working now appears only while a run is live, linked to its page", async () => { | |
| 135 | expect(await (await get(url("production"), bobCookie)).text()).not.toContain("data-working"); | |
| 136 | const run = createRun(app.db, { repoId, kind: "agent", issueId: pullId, actorId: alice.id, summary: "wiring it" }); | |
| 137 | claimRun(app.db, run.id); | |
| 138 | const live = await (await get(url("production"), bobCookie)).text(); | |
| 139 | expect(live).toContain("data-working"); | |
| 140 | expect(live).toContain(`/alice/notes/runs/${run.id}`); | |
| 141 | expect(await (await get(url(`pull-${pullNumber}`), bobCookie)).text()).toContain(`/runs/${run.id}`); | |
| 142 | finishRun(app.db, run.id, { state: "ok", summary: "done" }); | |
| 143 | expect(await (await get(url("production"), bobCookie)).text()).not.toContain("data-working"); | |
| 144 | }); | |
| 145 | ||
| 146 | test("a preview comment: previewed first, then posted to the pull with a receipt", async () => { | |
| 147 | const fields = { env: `pull-${pullNumber}`, page: PAGE, body: "Looks <b>off</b>" }; | |
| 148 | const preview = await post("/alice/notes/toolbar/comment", bobCookie, { ...fields, step: "preview" }); | |
| 149 | expect(preview.status).toBe(200); | |
| 150 | const text = await preview.text(); | |
| 151 | expect(text).toContain("Looks <b>off</b>"); | |
| 152 | expect(listComments(app.db, pullId).length).toBe(0); | |
| 153 | const sent = await post("/alice/notes/toolbar/comment", bobCookie, { ...fields, step: "post" }); | |
| 154 | expect(sent.status).toBe(200); | |
| 155 | expect(await sent.text()).toContain(`/alice/notes/pulls/${pullNumber}#conversation`); | |
| 156 | const comments = listComments(app.db, pullId); | |
| 157 | expect(comments.length).toBe(1); | |
| 158 | expect(comments[0]!.body).toContain("<https://notes.example.test/a>"); | |
| 159 | }); | |
| 160 | ||
| 161 | test("a production comment opens an issue carrying the page address", async () => { | |
| 162 | const sent = await post("/alice/notes/toolbar/comment", bobCookie, { | |
| 163 | env: "production", | |
| 164 | page: PAGE, | |
| 165 | body: "The header overlaps", | |
| 166 | step: "post", | |
| 167 | }); | |
| 168 | expect(sent.status).toBe(200); | |
| 169 | const link = /\/alice\/notes\/issues\/(\d+)/.exec(await sent.text()); | |
| 170 | expect(link).not.toBeNull(); | |
| 171 | const issue = getIssue(app.db, repoId, Number(link![1])); | |
| 172 | expect(issue?.title).toBe("The header overlaps"); | |
| 173 | expect(issue?.body).toContain("<https://notes.example.test/a>"); | |
| 174 | }); | |
| 175 | ||
| 176 | test("a query or fragment on the page address never reaches the body", async () => { | |
| 177 | const sent = await post("/alice/notes/toolbar/comment", bobCookie, { | |
| 178 | env: "production", | |
| 179 | page: "https://notes.example.test/a?b=1#t=secret", | |
| 180 | body: "leaky?", | |
| 181 | step: "post", | |
| 182 | }); | |
| 183 | const n = /issues\/(\d+)/.exec(await sent.text())![1]!; | |
| 184 | const body = getIssue(app.db, repoId, Number(n))!.body; | |
| 185 | expect(body).toContain("<https://notes.example.test/a>"); | |
| 186 | expect(body).not.toContain("b=1"); | |
| 187 | expect(body).not.toContain("secret"); | |
| 188 | const pr = await post("/alice/notes/toolbar/comment", bobCookie, { | |
| 189 | env: `pull-${pullNumber}`, | |
| 190 | page: "https://notes.example.test/a?b=1#t=secret", | |
| 191 | body: "leaky?", | |
| 192 | step: "post", | |
| 193 | }); | |
| 194 | expect(pr.status).toBe(200); | |
| 195 | for (const c of listComments(app.db, pullId)) { | |
| 196 | expect(c.body).not.toContain("b=1"); | |
outdated
This assertion is fragile (it depends on issue numbering) and weak. The test also never uses the toolbar page's tell form. Take the form from the GET response and assert on the issue the tell created. |
||
| 197 | expect(c.body).not.toContain("secret"); | |
| 198 | } | |
| 199 | }); | |
| 200 | ||
| 201 | test("a body that only overflows with the page line is refused as too long", async () => { | |
| 202 | const res = await post("/alice/notes/toolbar/comment", bobCookie, { | |
| 203 | env: "production", | |
| 204 | page: PAGE, | |
| 205 | body: "x".repeat(65_536), | |
| 206 | step: "post", | |
| 207 | }); | |
| 208 | expect(res.status).toBe(422); | |
| 209 | expect(await res.text()).toContain("too long"); | |
| 210 | }); | |
| 211 | ||
| 212 | test("an address that is not http(s) is dropped, not carried", async () => { | |
| 213 | const sent = await post("/alice/notes/toolbar/comment", bobCookie, { | |
| 214 | env: "production", | |
| 215 | page: "javascript:alert(1)", | |
| 216 | body: "hello", | |
| 217 | step: "post", | |
| 218 | }); | |
| 219 | const n = /issues\/(\d+)/.exec(await sent.text())![1]!; | |
| 220 | expect(getIssue(app.db, repoId, Number(n))?.body).not.toContain("javascript"); | |
| 221 | }); | |
| 222 | ||
| 223 | test("a cross-origin POST is refused and nothing is made", async () => { | |
| 224 | const before = listComments(app.db, pullId).length; | |
| 225 | const res = await post( | |
| 226 | "/alice/notes/toolbar/comment", | |
| 227 | bobCookie, | |
| 228 | { env: `pull-${pullNumber}`, body: "evil", step: "post" }, | |
| 229 | "https://notes.example.test", | |
| 230 | ); | |
| 231 | expect(res.status).toBe(403); | |
| 232 | expect(listComments(app.db, pullId).length).toBe(before); | |
| 233 | }); | |
| 234 | ||
| 235 | test("signed-out comment is refused", async () => { | |
| 236 | const res = await post("/alice/notes/toolbar/comment", "", { env: "production", body: "hi", step: "post" }); | |
| 237 | expect(res.status).toBe(403); | |
| 238 | }); | |
| 239 | ||
| 240 | test("the page's tell form posts to the one tell door and the tell lands back on the page", async () => { | |
| 241 | const text = await (await get(url("production"), aliceCookie)).text(); | |
| 242 | const form = /<form method="post" action="([^"]+\/tell)"[^>]*>([\s\S]*?)<\/form>/.exec(text); | |
| 243 | expect(form).not.toBeNull(); | |
| 244 | const from = /name="from" value="([^"]+)"/.exec(form![2]!)![1]!.replaceAll("&", "&"); | |
| 245 | expect(form![1]).toBe("/alice/notes/tell"); | |
| 246 | const before = listIssues(app.db, repoId, {}).length; | |
| 247 | const res = await post(form![1]!, aliceCookie, { text: "Make the header smaller", from }); | |
| 248 | expect(res.status).toBe(303); | |
| 249 | expect(res.headers.get("location")).toBe(from); | |
| 250 | expect(from.startsWith("/alice/notes/toolbar?env=production")).toBe(true); | |
| 251 | const made = listIssues(app.db, repoId, {}).filter((i) => i.title === "Make the header smaller"); | |
| 252 | expect(made.length).toBe(1); | |
| 253 | expect(listIssues(app.db, repoId, {}).length).toBe(before + 1); | |
| 254 | expect(made[0]!.body).toContain("Told from"); | |
| 255 | }); | |
| 256 | ||
| 257 | test("a live run on a sealed issue is not shown to a reader who did not file it", async () => { | |
| 258 | const sealed = createIssue(app.db, { | |
| 259 | repoId, | |
| 260 | authorId: alice.id, | |
| 261 | title: "SEALEDTITLE hole", | |
| 262 | sealed: true, | |
| 263 | }); | |
| 264 | if (!sealed.ok) throw new Error(sealed.error); | |
| 265 | const run = createRun(app.db, { | |
| 266 | repoId, | |
| 267 | kind: "agent", | |
| 268 | issueId: sealed.value.id, | |
| 269 | actorId: alice.id, | |
| 270 | summary: "SEALEDSUMMARY fixing the hole", | |
| 271 | }); | |
| 272 | claimRun(app.db, run.id); | |
| 273 | const theirs = await (await get(url("production"), bobCookie)).text(); | |
| 274 | expect(theirs).not.toContain("data-working"); | |
| 275 | expect(theirs).not.toContain("SEALEDSUMMARY"); | |
| 276 | expect(theirs).not.toContain(run.id); | |
| 277 | const mine = await (await get(url("production"), aliceCookie)).text(); | |
| 278 | expect(mine).toContain("SEALEDSUMMARY"); | |
| 279 | finishRun(app.db, run.id, { state: "ok", summary: "done" }); | |
| 280 | }); | |
| 281 | ||
| 282 | test("reporting on a mirrored repository goes through the origin's rules, not around them", async () => { | |
| 283 | const made = await newApp(app, alice, { name: "mirrored", description: "x", visibility: "public" }); | |
| 284 | if (!made.ok) throw new Error(String(made.error)); | |
| 285 | const origin = createOrigin(app.db, dataDir, { | |
| 286 | userId: alice.id, | |
| 287 | kind: "forgejo", | |
| 288 | apiUrl: "https://origin.example.test/api/v1", | |
| 289 | webUrl: "https://origin.example.test", | |
| 290 | login: "alice", | |
| 291 | token: "t", | |
| 292 | }); | |
| 293 | if (!origin.ok) throw new Error(origin.error); | |
| 294 | linkRepoToOrigin(app.db, made.value.repo.id, { | |
| 295 | originId: origin.value.id, | |
| 296 | fullName: "alice/mirrored", | |
| 297 | remoteId: "1", | |
| 298 | htmlUrl: "https://origin.example.test/alice/mirrored", | |
| 299 | }); | |
| 300 | // Bob did not connect the origin: write-through refuses him, so no issue | |
| 301 | // is invented locally. | |
| 302 | const res = await post("/alice/mirrored/toolbar/comment", bobCookie, { | |
| 303 | env: "production", | |
| 304 | body: "hello", | |
| 305 | step: "post", | |
| 306 | }); | |
| 307 | expect(res.status).toBe(403); | |
| 308 | expect(await res.text()).toContain("Only the person who connected this origin"); | |
| 309 | expect(listIssues(app.db, made.value.repo.id, {}).filter((i) => i.title === "hello")).toEqual([]); | |
| 310 | }); | |
| 311 | ||
| 312 | test("a running run older than the twenty newest is still found", async () => { | |
| 313 | const old = createRun(app.db, { repoId, kind: "agent", issueId: pullId, actorId: alice.id, summary: "OLDRUNNING" }); | |
| 314 | claimRun(app.db, old.id); | |
| 315 | for (let i = 0; i < 25; i++) { | |
| 316 | const r = createRun(app.db, { repoId, kind: "agent", issueId: pullId, actorId: alice.id, summary: `n${i}` }); | |
| 317 | claimRun(app.db, r.id); | |
| 318 | finishRun(app.db, r.id, { state: "ok", summary: "done" }); | |
| 319 | } | |
| 320 | const text = await (await get(url("production"), aliceCookie)).text(); | |
| 321 | expect(text).toContain("OLDRUNNING"); | |
| 322 | finishRun(app.db, old.id, { state: "ok", summary: "done" }); | |
| 323 | }); | |
| 324 | }); |
test/tui-report.test.ts added +98
| @@ -0,0 +1,98 @@ | ||
| 1 | /** | |
| 2 | * Report a problem from a repository screen's palette (#810): the terminal's | |
| 3 | * door to the toolbar's "Report this page" — one text, one issue, titled by | |
| 4 | * its first line. | |
| 5 | */ | |
| 6 | import { afterAll, beforeAll, describe, expect, test } from "bun:test"; | |
| 7 | import { mkdtemp, rm } from "node:fs/promises"; | |
| 8 | import { tmpdir } from "node:os"; | |
| 9 | import { join } from "node:path"; | |
| 10 | import { createRepo } from "../src/actions/repos.ts"; | |
| 11 | import { reportTitle } from "../src/actions/page-report.ts"; | |
| 12 | import { createApp, type App } from "../src/app.ts"; | |
| 13 | import { listIssues } from "../src/core/issues.ts"; | |
| 14 | import type { Repo } from "../src/core/repos.ts"; | |
| 15 | import { createUser, type User } from "../src/core/users.ts"; | |
| 16 | import { createForgeApp } from "../src/tui/forge.ts"; | |
| 17 | import { repoScreen } from "../src/tui/screens/repo.ts"; | |
| 18 | import { local } from "../src/tui/screens/where.ts"; | |
| 19 | import { headlessTerminal } from "../src/tui/term/terminal.ts"; | |
| 20 | import { quietLogs } from "./helpers/globals.ts"; | |
| 21 | import { settleApp } from "./helpers/tui.ts"; | |
| 22 | import { proveAll } from "./helpers/verified.ts"; | |
| 23 | ||
| 24 | quietLogs(); | |
| 25 | ||
| 26 | let dir: string; | |
| 27 | let app: App; | |
| 28 | let alice: User; | |
| 29 | let bob: User; | |
| 30 | let repo: Repo; | |
| 31 | ||
| 32 | async function palette(user: User, keys: string[]): Promise<string> { | |
| 33 | const terminal = headlessTerminal(120, 40); | |
| 34 | const { app: tui, session } = createForgeApp( | |
| 35 | app, | |
| 36 | { sshUsername: user.username, remoteAddress: "127.0.0.1", fingerprint: "SHA256:test", user }, | |
| 37 | { terminal, exitHooks: false }, | |
| 38 | ); | |
| 39 | tui.start(); | |
| 40 | tui.ctx.push(repoScreen(session, tui.ctx, local(session, repo))); | |
| 41 | await settleApp(tui); | |
| 42 | for (const k of keys) { | |
| 43 | terminal.feed(k); | |
| 44 | await settleApp(tui); | |
| 45 | } | |
| 46 | const text = tui.screen.toText(); | |
| 47 | tui.stop?.(); | |
| 48 | return text; | |
| 49 | } | |
| 50 | ||
| 51 | beforeAll(async () => { | |
| 52 | dir = await mkdtemp(join(tmpdir(), "forge-tui-report-")); | |
| 53 | app = createApp({ dataDir: dir, port: 0 }); | |
| 54 | const mk = async (username: string, first = false) => { | |
| 55 | const r = await createUser(app.db, { | |
| 56 | trusted: true, username, email: `${username}@forge.local`, | |
| 57 | password: "a-good-passphrase", adminIfFirst: first, | |
| 58 | }); | |
| 59 | if (!r.ok) throw new Error(r.error); | |
| 60 | return r.value; | |
| 61 | }; | |
| 62 | alice = await mk("alice", true); | |
| 63 | bob = await mk("bob"); | |
| 64 | proveAll(app); | |
| 65 | const made = await createRepo(app, alice, { name: "lobby", visibility: "public" }); | |
| 66 | if (!made.ok) throw new Error(String(made.error)); | |
| 67 | repo = made.value; | |
| 68 | }); | |
| 69 | afterAll(async () => { | |
| 70 | app.db.close(); | |
| 71 | await rm(dir, { recursive: true, force: true }); | |
| 72 | }); | |
| 73 | ||
| 74 | describe("report a problem in the terminal", () => { | |
| 75 | test("the palette has the row", async () => { | |
| 76 | expect(await palette(bob, [":"])).toContain("alice/lobby — Report a problem"); | |
| 77 | }); | |
| 78 | ||
| 79 | test("a text opens an issue titled by its first line", async () => { | |
| 80 | const text = await palette(bob, [":", "report", "\r", ..."the door sticks".split(""), "\r"]); | |
| 81 | expect(text).toContain("opened"); | |
| 82 | const issue = listIssues(app.db, repo.id, {}).find((i) => i.title === "the door sticks"); | |
| 83 | expect(issue?.body).toBe("the door sticks"); | |
| 84 | }); | |
| 85 | ||
| 86 | test("an empty text says so and opens nothing", async () => { | |
| 87 | const before = listIssues(app.db, repo.id, {}).length; | |
| 88 | const text = await palette(bob, [":", "report", "\r", "\r"]); | |
| 89 | expect(text).toContain("Say something first."); | |
| 90 | expect(listIssues(app.db, repo.id, {}).length).toBe(before); | |
| 91 | }); | |
| 92 | ||
| 93 | test("a long first line is cut for the title", () => { | |
| 94 | const title = reportTitle(`${"x".repeat(200)}\nmore`); | |
| 95 | expect(title.length).toBe(78); | |
| 96 | expect(title.endsWith("…")).toBe(true); | |
| 97 | }); | |
| 98 | }); |
test/tui-working-yours.test.ts added +105
| @@ -0,0 +1,105 @@ | ||
| 1 | /** | |
| 2 | * The toolbar page's other two doors in the terminal (#810): "Working now" | |
| 3 | * while an agent run is live (a sealed issue's run hidden from who may not | |
| 4 | * see it), and "Make it yours" for whoever cannot write the repository. | |
| 5 | */ | |
| 6 | import { afterAll, beforeAll, describe, expect, test } from "bun:test"; | |
| 7 | import { mkdtemp, rm } from "node:fs/promises"; | |
| 8 | import { tmpdir } from "node:os"; | |
| 9 | import { join } from "node:path"; | |
| 10 | import { createRepo } from "../src/actions/repos.ts"; | |
| 11 | import { createApp, type App } from "../src/app.ts"; | |
| 12 | import type { Repo } from "../src/core/repos.ts"; | |
| 13 | import { createIssue } from "../src/core/issues.ts"; | |
| 14 | import { claimRun, createRun, finishRun } from "../src/core/runs.ts"; | |
| 15 | import { createUser, type User } from "../src/core/users.ts"; | |
| 16 | import { createForgeApp } from "../src/tui/forge.ts"; | |
| 17 | import { repoScreen } from "../src/tui/screens/repo.ts"; | |
| 18 | import { local } from "../src/tui/screens/where.ts"; | |
| 19 | import { headlessTerminal } from "../src/tui/term/terminal.ts"; | |
| 20 | import { quietLogs } from "./helpers/globals.ts"; | |
| 21 | import { settleApp } from "./helpers/tui.ts"; | |
| 22 | import { proveAll } from "./helpers/verified.ts"; | |
| 23 | ||
| 24 | quietLogs(); | |
| 25 | ||
| 26 | let dir: string; | |
| 27 | let app: App; | |
| 28 | let alice: User; | |
| 29 | let bob: User; | |
| 30 | let repo: Repo; | |
| 31 | ||
| 32 | async function palette(user: User, keys: string[] = [":"]): Promise<string> { | |
| 33 | const terminal = headlessTerminal(120, 40); | |
| 34 | const { app: tui, session } = createForgeApp( | |
| 35 | app, | |
| 36 | { sshUsername: user.username, remoteAddress: "127.0.0.1", fingerprint: "SHA256:test", user }, | |
| 37 | { terminal, exitHooks: false }, | |
| 38 | ); | |
| 39 | tui.start(); | |
| 40 | tui.ctx.push(repoScreen(session, tui.ctx, local(session, repo))); | |
| 41 | await settleApp(tui); | |
| 42 | for (const k of keys) { | |
| 43 | terminal.feed(k); | |
| 44 | await settleApp(tui); | |
| 45 | } | |
| 46 | const text = tui.screen.toText(); | |
| 47 | tui.stop?.(); | |
| 48 | return text; | |
| 49 | } | |
| 50 | ||
| 51 | beforeAll(async () => { | |
| 52 | dir = await mkdtemp(join(tmpdir(), "forge-tui-working-")); | |
| 53 | app = createApp({ dataDir: dir, port: 0 }); | |
| 54 | const mk = async (username: string, first = false) => { | |
| 55 | const r = await createUser(app.db, { | |
| 56 | trusted: true, username, email: `${username}@forge.local`, | |
| 57 | password: "a-good-passphrase", adminIfFirst: first, | |
| 58 | }); | |
| 59 | if (!r.ok) throw new Error(r.error); | |
| 60 | return r.value; | |
| 61 | }; | |
| 62 | alice = await mk("alice", true); | |
| 63 | bob = await mk("bob"); | |
| 64 | proveAll(app); | |
| 65 | const made = await createRepo(app, alice, { name: "lobby", visibility: "public" }); | |
| 66 | if (!made.ok) throw new Error(String(made.error)); | |
| 67 | repo = made.value; | |
| 68 | }); | |
| 69 | afterAll(async () => { | |
| 70 | app.db.close(); | |
| 71 | await rm(dir, { recursive: true, force: true }); | |
| 72 | }); | |
| 73 | ||
| 74 | describe("working now in the terminal", () => { | |
| 75 | test("shown while a run is live, gone once it finishes", async () => { | |
| 76 | expect(await palette(bob, [":", "Working"])).not.toContain("Working now"); | |
| 77 | const run = createRun(app.db, { repoId: repo.id, kind: "agent", actorId: alice.id, summary: "LIVEJOB sanding the door" }); | |
| 78 | claimRun(app.db, run.id); | |
| 79 | expect(await palette(bob, [":", "Working"])).toContain("Working now: LIVEJOB sanding the door"); | |
| 80 | finishRun(app.db, run.id, { state: "ok", summary: "done" }); | |
| 81 | expect(await palette(bob, [":", "Working"])).not.toContain("Working now"); | |
| 82 | }); | |
| 83 | ||
| 84 | test("a run on a sealed issue is hidden from bob and shown to its filer", async () => { | |
| 85 | const sealed = createIssue(app.db, { repoId: repo.id, authorId: alice.id, title: "hole", sealed: true }); | |
| 86 | if (!sealed.ok) throw new Error(sealed.error); | |
| 87 | const run = createRun(app.db, { repoId: repo.id, kind: "agent", issueId: sealed.value.id, actorId: alice.id, summary: "SEALEDJOB" }); | |
| 88 | claimRun(app.db, run.id); | |
| 89 | expect(await palette(bob, [":", "Working"])).not.toContain("SEALEDJOB"); | |
| 90 | expect(await palette(alice, [":", "Working"])).toContain("SEALEDJOB"); | |
| 91 | finishRun(app.db, run.id, { state: "ok", summary: "done" }); | |
| 92 | }); | |
| 93 | }); | |
| 94 | ||
| 95 | describe("make it yours in the terminal", () => { | |
| 96 | test("a writer does not see it; a reader who cannot write does", async () => { | |
| 97 | expect(await palette(alice, [":", "Make it"])).not.toContain("Make it yours"); | |
| 98 | expect(await palette(bob, [":", "Make it"])).toContain("alice/lobby — Make it yours"); | |
| 99 | }); | |
| 100 | ||
| 101 | test("it opens the fork screen", async () => { | |
| 102 | const text = await palette(bob, [":", "make it", "\r"]); | |
| 103 | expect(text).toContain("Fork"); | |
| 104 | }); | |
| 105 | }); |
High: this runs before any read check, so a public app (
access: "public") on a private repository publishesowner/nameto every visitor, anonymous ones included. Gate it astitleis gated below: resolve the viewer fromwho, and setrepoonly whencanRead(db, repo, user)holds. Add a test with a public app on a private repo, checking a stranger and no viewer.