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 #2239
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 6 threads · 6 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"), | |
| 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 | |
outdated
The terminal gets tell and report here, but not the page's other two doors. There's no Working now line (the running agent run, gated by |
||
| 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 ""; | |
| 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`, | |
| 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 | |
outdated
Not blocking: a run that is still going but older than the 20 newest agent runs is never found. Filtering on running state in the query would be exact. |
||
| 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", | |
outdated
This checks |
||
| 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 | ||
outdated
Use |
||
| 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"); | |
| 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 for every viewer, guest included. A public app on a private repository now publishes the repository's
owner/nameindata-repo. Gate it the way the title below is gated: resolve the viewer first and setwhere.repoonly ifcanRead(db, owner, user). The bar already hides its action link whend.repois missing. Add a pill-where test for a public app on a private repo with a guest viewer.