Public: anyone can read this; the crew can change it.
C opens a new issue from any page, prefilled with where it was pressed #8885
Check · Review · Land · Live done — Landed and live in production.
mason/forge:mason/8855-c-opens-a-new-issue-from-any-page-prefil → main 5 commits fork 4 threads · 4 unresolved
15 files
changed +390
−20
docs/features/new-issue-anywhere.md added +20
| @@ -0,0 +1,20 @@ | ||
| 1 | # A new issue from any page | |
| 2 | ||
| 3 | Pressing `c` (or Shift C) outside a text field opens a new issue from the page | |
| 4 | you are on. The same door is a "New issue" link in the user menu (and the | |
| 5 | guest menu), so touch, mouse and no-script readers have it too. | |
| 6 | ||
| 7 | - Page: `/new/issue?from=/path`. Signed out, it is the sign-in path, never a | |
| 8 | form. From a page inside a repository you can read, it lands on that | |
| 9 | repository's new-issue form; anywhere else it is a picker over the | |
| 10 | repositories you may write. The form keeps `from` in a hidden field and the | |
| 11 | stored body ends with ``Opened from `/path` ``. | |
| 12 | - Keys: a page with its own write box keeps `c` for it; Shift C is the door | |
| 13 | there. The help overlay (`?`) names `c / Shift C` or `Shift C` accordingly. | |
| 14 | - Terminal: the palette's "New issue" row asks which repository (the ones you | |
| 15 | may write), then opens its new-issue form. | |
| 16 | - Read-only origin browse pages show no link: there is nowhere to write. | |
| 17 | ||
| 18 | `from` is a path on this forge only, limited to letters, digits and `/ . _ ~ % -`; | |
| 19 | anything else is dropped at the boundary, since it ends up in a markdown body. | |
| 20 | Tests: `test/new-issue-anywhere.test.ts`, `test/tui-new-issue.test.ts`. |
src/http/server.ts +2
| @@ -194,6 +194,7 @@ import { | ||
| 194 | 194 | issueSealSubmit, |
| 195 | 195 | issueThreadFragment, |
| 196 | 196 | newIssuePage, |
| 197 | newIssueAnywhere, | |
| 197 | 198 | newIssueSubmit, |
| 198 | 199 | } from "../web/pages/issues.ts"; |
| 199 | 200 | import { noteModerate } from "../web/studio/notes.ts"; |
| @@ -763,6 +764,7 @@ export function buildRouter(): Router<Handler> { | ||
| 763 | 764 | // is registered before /:owner/:name can claim the shape. |
| 764 | 765 | router.add("GET", "/-/confirm", confirmPage); |
| 765 | 766 | router.add("GET", "/new", newRepoPage); |
| 767 | router.add("GET", "/new/issue", newIssueAnywhere); | |
| 766 | 768 | router.add("GET", "/repos", myReposPage); |
| 767 | 769 | router.add("GET", "/now/:stage", nowPage); |
| 768 | 770 | router.add("GET", "/audit", auditPage); |
src/tui/screens/entries.ts +2
| @@ -30,6 +30,7 @@ import { entry as limits } from "./limits.ts"; | ||
| 30 | 30 | import { modelCeilingEntry, modelGrantEntry } from "./model.ts"; |
| 31 | 31 | import { entry as orgGrants } from "./orggrants.ts"; |
| 32 | 32 | import { entry as orgTeams } from "./orgteams.ts"; |
| 33 | import { entry as newIssue } from "./newissue.ts"; | |
| 33 | 34 | import { entry as now } from "./nowlist.ts"; |
| 34 | 35 | import { entry as repos } from "./repolist.ts"; |
| 35 | 36 | import { entry as owner } from "./owner.ts"; |
| @@ -103,6 +104,7 @@ export const ENTRIES: readonly PaletteEntry[] = [ | ||
| 103 | 104 | profile, |
| 104 | 105 | now, |
| 105 | 106 | repos, |
| 107 | newIssue, | |
| 106 | 108 | ]; |
| 107 | 109 | |
| 108 | 110 | /** Put each entry the session is offered into `commands`, after the command it names or at the end. */ |
src/tui/screens/newissue.ts added +52
| @@ -0,0 +1,52 @@ | ||
| 1 | /** | |
| 2 | * A new issue from anywhere (`c` on the web): the terminal's door is a | |
| 3 | * palette entry that asks which repository, from the ones the viewer may | |
| 4 | * write, and opens that repository's new-issue form. Enter picks. | |
| 5 | */ | |
| 6 | ||
| 7 | import { listReposWritableTo, type Repo } from "../../core/repos.ts"; | |
| 8 | import type { Ctx, Screen } from "../app/app.ts"; | |
| 9 | import { binding } from "../app/keys.ts"; | |
| 10 | import { style } from "../term/style.ts"; | |
| 11 | import { truncate } from "../term/width.ts"; | |
| 12 | import type { Session } from "../session.ts"; | |
| 13 | import { fill, len, split } from "../ui/layout.ts"; | |
| 14 | import { ListState, drawList, type ListRow } from "./common.ts"; | |
| 15 | import type { PaletteEntry } from "./entries.ts"; | |
| 16 | import { newIssueScreen } from "./issues.ts"; | |
| 17 | ||
| 18 | export function newIssuePickScreen(f: Session, ctx: Ctx): Screen { | |
| 19 | const state = new ListState(); | |
| 20 | const read = (): Repo[] => listReposWritableTo(f.db, f.user, { limit: 200, withoutArchived: true }); | |
| 21 | return { | |
| 22 | title: "New issue: which repository?", | |
| 23 | keys: [binding(["j", "k"], "move cursor"), binding(["enter"], "new issue here")], | |
| 24 | render(area, s) { | |
| 25 | const c = ctx.theme.c; | |
| 26 | const repos = read(); | |
| 27 | const [head, list] = split(area, "v", [len(1), fill(1)]); | |
| 28 | const text = repos.length === 0 ? "No repository to open an issue in." : "Which repository?"; | |
| 29 | s.text(head!.x, head!.y, truncate(text, head!.w), style(c.mutedFg, c.bg), head!.x + head!.w); | |
| 30 | const rows: ListRow[] = repos.map((r) => ({ title: `${r.ownerName}/${r.name}` })); | |
| 31 | drawList(s, list!, c, state, rows); | |
| 32 | }, | |
| 33 | key(e) { | |
| 34 | const repos = read(); | |
| 35 | if (state.key(e, repos.length)) return true; | |
| 36 | const repo = repos[state.index]; | |
| 37 | if (e.ctrl || e.alt || e.key !== "enter" || repo === undefined) return false; | |
| 38 | ctx.replace(newIssueScreen(f, ctx, repo)); | |
| 39 | return true; | |
| 40 | }, | |
| 41 | }; | |
| 42 | } | |
| 43 | ||
| 44 | /** A new issue, as `/new/issue` offers it: anyone signed in. */ | |
| 45 | export const entry: PaletteEntry = { | |
| 46 | id: "new-issue-anywhere", | |
| 47 | title: "New issue", | |
| 48 | hint: "pick a repository", | |
| 49 | after: ["repos"], | |
| 50 | offered: (session) => session.user !== undefined, | |
| 51 | run: (session, ctx) => ctx.push(newIssuePickScreen(session, ctx)), | |
| 52 | }; |
src/web/client.ts +14 −1
| @@ -1454,6 +1454,13 @@ const shellCore = /* js */ ` | ||
| 1454 | 1454 | if (f.tagName === 'TEXTAREA') { var n = f.value.length; f.setSelectionRange(n, n); } |
| 1455 | 1455 | return true; |
| 1456 | 1456 | } |
| 1457 | // The "new issue from here" door: c on a page with no box of | |
| 1458 | // its own, Shift C on any page. | |
| 1459 | function openNewIssue(e) { | |
| 1460 | var p = location.pathname; | |
| 1461 | seq.length = 0; e.preventDefault(); | |
| 1462 | location.assign(p.indexOf('/new/issue') === 0 ? '/new/issue' : '/new/issue?from=' + encodeURIComponent(p)); | |
| 1463 | } | |
| 1457 | 1464 | doc.addEventListener('keydown', function (e) { |
| 1458 | 1465 | var t = e.target; |
| 1459 | 1466 | // Inside an open dialog the dialog owns the keys; none of these apply. |
| @@ -1493,7 +1500,9 @@ const shellCore = /* js */ ` | ||
| 1493 | 1500 | if (e.key === 'j') { seq.length = 0; e.preventDefault(); move(1); } |
| 1494 | 1501 | else if (e.key === 'k') { seq.length = 0; e.preventDefault(); move(-1); } |
| 1495 | 1502 | else if (e.key === 'o' || e.key === 'Enter') { seq.length = 0; openFocused(); } |
| 1496 | else if (e.key === 'c') { if (focusBox()) { seq.length = 0; e.preventDefault(); } } | |
| 1503 | else if (e.key === 'c') { | |
| 1504 | if (focusBox()) { seq.length = 0; e.preventDefault(); } else openNewIssue(e); | |
| 1505 | } else if (e.key === 'C') openNewIssue(e); | |
| 1497 | 1506 | else if (e.key === '/') { |
| 1498 | 1507 | var box = doc.querySelector('[data-search-input]'); |
| 1499 | 1508 | if (box) { seq.length = 0; e.preventDefault(); box.focus(); box.select(); } |
| @@ -1512,6 +1521,10 @@ const shellCore = /* js */ ` | ||
| 1512 | 1521 | function pageKeys() { |
| 1513 | 1522 | var out = []; |
| 1514 | 1523 | if (pageBox() && !doc.querySelector('[data-shortcut="c"]')) out.push(['c', doc.querySelector('[data-box]').getAttribute('data-box') || 'Write here']); |
| 1524 | // A page that keeps c for its own box shows Shift C alone; any other | |
outdated
The help row is untested, and the 'c / Shift C' label logic is hard to follow. Cover each of the three cases in a test. |
||
| 1525 | // page opens the new issue on either. | |
| 1526 | var ownC = pageBox() || doc.querySelector('[data-shortcut="c"]'); | |
| 1527 | out.push([ownC ? 'Shift C' : 'c / Shift C', 'New issue from this page']); | |
| 1515 | 1528 | if (doc.querySelector('form[data-submit-key]')) out.push(['Cmd/Ctrl Enter', 'Submit the box you are typing in']); |
| 1516 | 1529 | return out; |
| 1517 | 1530 | } |
src/web/issue_from.ts added +39
| @@ -0,0 +1,39 @@ | ||
| 1 | /** | |
| 2 | * Where a new issue was started from. The menu link and the `c` key carry | |
| 3 | * the page they were pressed on as `?from=/path`; the compose | |
| 4 | * form keeps it in a hidden field and the issue ends with one line naming it. | |
| 5 | * A path on this forge only: anything else is dropped at the boundary. | |
| 6 | */ | |
| 7 | ||
| 8 | const MAX_FROM = 200; | |
| 9 | const SAFE_PATH = /^\/[A-Za-z0-9/._~%-]*$/; | |
| 10 | ||
| 11 | /** The path when it is a plain same-origin path, else undefined. */ | |
| 12 | export function cleanFrom(raw: string | null | undefined): string | undefined { | |
| 13 | if (typeof raw !== "string" || raw.length === 0 || raw.length > MAX_FROM) return undefined; | |
| 14 | // Only the characters of a path: it is stored in a markdown body, so no | |
| 15 | // brackets, parentheses, backticks, angle brackets or `#` may get in. | |
| 16 | if (!SAFE_PATH.test(raw) || raw.startsWith("//")) return undefined; | |
| 17 | return raw; | |
| 18 | } | |
| 19 | ||
| 20 | /** The menu link and the `c` key: the picker/redirect door, naming the page it was pressed on. */ | |
outdated
newIssueHref has no caller outside the test. Remove it, or use it for the header link. |
||
| 21 | export function newIssueHref(pathname: string): string { | |
| 22 | const from = cleanFrom(pathname); | |
| 23 | return from === undefined || from.startsWith("/new/issue") | |
| 24 | ? "/new/issue" | |
| 25 | : `/new/issue?from=${encodeURIComponent(from)}`; | |
| 26 | } | |
| 27 | ||
| 28 | /** The body with the line naming the page it came from, when there is one. */ | |
outdated
This puts a client-chosen string into a markdown body. Constrain the charset or code-span it, rejecting backticks. See item 4 in the review body. |
||
| 29 | export function withFromLine(body: string, from: string | undefined): string { | |
| 30 | if (from === undefined) return body; | |
| 31 | const line = `Opened from \`${from}\``; | |
| 32 | return body.trim() === "" ? line : `${body.replace(/\s+$/, "")}\n\n${line}`; | |
| 33 | } | |
| 34 | ||
| 35 | /** The repository a path belongs to, as `[owner, name]`, when it has the shape of one. */ | |
| 36 | export function repoOfPath(from: string): [string, string] | undefined { | |
| 37 | const [owner, name] = from.split("?")[0]!.split("/").filter((s) => s !== ""); | |
| 38 | return owner !== undefined && name !== undefined ? [owner, name] : undefined; | |
| 39 | } |
src/web/layout.ts +15 −3
| @@ -7,6 +7,7 @@ | ||
| 7 | 7 | * `/search`), and every dialog (a link to a real page). |
| 8 | 8 | */ |
| 9 | 9 | |
| 10 | import { newIssueHref } from "./issue_from.ts"; | |
| 10 | 11 | import { OPENSEARCH_PATH, OPENSEARCH_TITLE, OPENSEARCH_TYPE } from "./opensearch.ts"; |
| 11 | 12 | import { protectionNotice } from "../kernel/protection.ts"; |
| 12 | 13 | import { administersAny } from "../core/admin_repos.ts"; |
| @@ -186,10 +187,19 @@ const helpItem = html`<button class="menu-item js-only hide-sm" type="button" da | ||
| 186 | 187 | </button>`; |
| 187 | 188 | |
| 188 | 189 | /** Someone not signed in has no user menu; theme and help share this one. */ |
| 189 | function guestMenu(theme: ThemeName | undefined): Html { | |
| 190 | /** | |
| 191 | * "New issue" from this page, with no script (`c` is the same door): a link | |
| 192 | * in the menus. Read-only origin browse has nowhere to write, so it has none. | |
| 193 | */ | |
| 194 | const newIssueItem = (ctx: Ctx): MenuItem[] => | |
| 195 | ctx.url.pathname.startsWith("/origins/") | |
| 196 | ? [] | |
| 197 | : [{ label: "New issue", href: newIssueHref(ctx.url.pathname) }]; | |
| 198 | ||
| 199 | function guestMenu(ctx: Ctx, theme: ThemeName | undefined): Html { | |
| 190 | 200 | return menu({ |
| 191 | 201 | trigger: themeGlyph, |
| 192 | items: [helpItem, themeForm(theme)], | |
| 202 | items: [...newIssueItem(ctx), helpItem, themeForm(theme)], | |
| 193 | 203 | label: "Appearance and help", |
| 194 | 204 | triggerOptions: { extra: "btn-icon" }, |
| 195 | 205 | }); |
| @@ -216,6 +226,7 @@ const forgeDoor = (ctx: Ctx): MenuItem[] => { | ||
| 216 | 226 | |
| 217 | 227 | function newMenu(ctx: Ctx): Html { |
| 218 | 228 | const items: MenuItem[] = [ |
| 229 | ...newIssueItem(ctx), | |
| 219 | 230 | { label: "New app", href: "/apps/new", icon: "rocket" }, |
| 220 | 231 | { label: "New repository", href: "/new", icon: "repo" }, |
| 221 | 232 | { label: "Migrate", href: "/migrate", icon: "download" }, |
| @@ -247,6 +258,7 @@ function userMenu( | ||
| 247 | 258 | { label: "Map", href: "/map", icon: "map" }, |
| 248 | 259 | { separator: true, label: "" }, |
| 249 | 260 | // The "+" menu shows on every width; these repeat it for the user menu. |
| 261 | ...newIssueItem(ctx), | |
| 250 | 262 | { label: "New app", href: "/apps/new", icon: "rocket" }, |
| 251 | 263 | { label: "New repository", href: "/new", icon: "repo" }, |
| 252 | 264 | { label: "Migrate", href: "/migrate", icon: "download" }, |
| @@ -587,7 +599,7 @@ function topbar( | ||
| 587 | 599 | } |
| 588 | 600 | : { label: "Sign out", post: "/logout", icon: "signOut" }, |
| 589 | 601 | )}` |
| 590 | : html`${guestMenu(theme)}${btn("Sign in", { | |
| 602 | : html`${guestMenu(ctx, theme)}${btn("Sign in", { | |
| 591 | 603 | href: signInHref(ctx), |
| 592 | 604 | })}`} |
| 593 | 605 | </span> |
src/web/pages/issues.ts +56 −8
| @@ -11,7 +11,7 @@ import { | ||
| 11 | 11 | setIssueSealed, |
| 12 | 12 | } from "../../actions/issues.ts"; |
| 13 | 13 | import type { Issue } from "../../core/issues.ts"; |
| 14 | import { canRead, type Repo } from "../../core/repos.ts"; | |
| 14 | import { canRead, getRepo, listReposWritableTo, type Repo } from "../../core/repos.ts"; | |
| 15 | 15 | import { privateRepoForSealed } from "../../actions/repos.ts"; |
| 16 | 16 | import { sealedWords, sealRefusedWords } from "../../actions/seal_issue_words.ts"; |
| 17 | 17 | import { BODY_TOO_LONG, TITLE_TOO_LONG } from "../../actions/thread_limits_words.ts"; |
| @@ -30,8 +30,9 @@ import { | ||
| 30 | 30 | } from "../chrome.ts"; |
| 31 | 31 | import { field, readForm } from "../form.ts"; |
| 32 | 32 | import { html, raw, type Html } from "../html.ts"; |
| 33 | import { flashTo, page } from "../layout.ts"; | |
| 34 | import { btn, empty, field as formField } from "../ui.ts"; | |
| 33 | import { flashTo, page, redirect } from "../layout.ts"; | |
| 34 | import { cleanFrom, repoOfPath, withFromLine } from "../issue_from.ts"; | |
| 35 | import { btn, empty, field as formField, listCard, row } from "../ui.ts"; | |
| 35 | 36 | import { |
| 36 | 37 | originError, |
| 37 | 38 | parseNumber, |
| @@ -54,7 +55,7 @@ import { | ||
| 54 | 55 | function composePage( |
| 55 | 56 | ctx: Ctx, |
| 56 | 57 | repo: Repo, |
| 57 | values: { title: string; body: string; sealed?: boolean }, | |
| 58 | values: { title: string; body: string; sealed?: boolean; from?: string | undefined }, | |
| 58 | 59 | error?: string, |
| 59 | 60 | ): Response { |
| 60 | 61 | const heading = PAGE_TITLES.compose; |
| @@ -84,13 +85,17 @@ function composePage( | ||
| 84 | 85 | |
| 85 | 86 | function newIssueForm( |
| 86 | 87 | repo: Repo, |
| 87 | values: { title: string; body: string; sealed?: boolean }, | |
| 88 | values: { title: string; body: string; sealed?: boolean; from?: string | undefined }, | |
| 88 | 89 | submit: string, |
| 89 | 90 | cancelHref: string, |
| 90 | 91 | error?: string, |
| 91 | 92 | ): Html { |
| 92 | 93 | return html`<div class="narrow-wide mt"> |
| 93 | 94 | <form method="post" action="${repoBase(repo)}/issues/new"> |
| 95 | ${values.from === undefined | |
| 96 | ? null | |
| 97 | : html`<input type="hidden" name="from" value="${values.from}" /> | |
| 98 | <p class="small muted">From <code>${values.from}</code></p>`} | |
| 94 | 99 | ${formField({ |
| 95 | 100 | label: "Title", |
| 96 | 101 | name: "title", |
| @@ -143,7 +148,7 @@ export function newIssuePage(ctx: Ctx): Response { | ||
| 143 | 148 | ); |
| 144 | 149 | } |
| 145 | 150 | return timePhase(request, "render", () => |
| 146 | composePage(ctx, repo, { title: "", body: "", sealed }), | |
| 151 | composePage(ctx, repo, { title: "", body: "", sealed, from: cleanFrom(ctx.url.searchParams.get("from")) }), | |
| 147 | 152 | ); |
| 148 | 153 | } |
| 149 | 154 | |
| @@ -188,6 +193,44 @@ function accountOrReport(ctx: Ctx, repo: Repo, sealed: boolean): Response { | ||
| 188 | 193 | }); |
| 189 | 194 | } |
| 190 | 195 | |
| 196 | // GET /new/issue[?from=/path] | |
| 197 | /** | |
| 198 | * The header's door: signed out, the sign-in path; from a page inside a | |
| 199 | * repository, that repository's form; anywhere else, a picker over the | |
| 200 | * repositories the viewer may write. Each carries `from` along. | |
| 201 | */ | |
| 202 | export function newIssueAnywhere(ctx: Ctx): Response { | |
| 203 | const user = ctx.auth.user; | |
| 204 | if (user === undefined) return signInFirst(ctx); | |
| 205 | notePhase(ctx.request, "session", takeSessionTime(ctx.request)); | |
| 206 | const from = cleanFrom(ctx.url.searchParams.get("from")); | |
| 207 | const query = from === undefined ? "" : `?from=${encodeURIComponent(from)}`; | |
| 208 | const named = from === undefined ? undefined : repoOfPath(from); | |
| 209 | const here = named && getRepo(ctx.app.db, named[0], named[1]); | |
| 210 | if (here && canRead(ctx.app.db, here, user)) | |
| 211 | return redirect(`${repoBase(here)}/issues/new${query}`); | |
| 212 | const repos = listReposWritableTo(ctx.app.db, user, { limit: 200, withoutArchived: true }); | |
| 213 | const rendering = performance.now(); | |
| 214 | const response = page({ | |
| 215 | ctx, | |
| 216 | title: "New issue", | |
| 217 | body: html`<div class="page-head"><h1>New issue</h1></div> | |
| 218 | ${repos.length === 0 | |
| 219 | ? empty({ icon: "issue", title: "No repository to open an issue in", note: "Open one from a repository you can see." }) | |
| 220 | : html`<p class="muted">Which repository?</p>${listCard( | |
| 221 | repos.map((r) => | |
| 222 | row({ | |
| 223 | href: `${repoBase(r)}/issues/new${query}`, | |
| 224 | nav: true, | |
| 225 | title: html`<span class="truncate">${r.ownerName}/${r.name}</span>`, | |
| 226 | }), | |
| 227 | ), | |
| 228 | )}`}`, | |
| 229 | }); | |
| 230 | notePhase(ctx.request, "render", performance.now() - rendering); | |
| 231 | return response; | |
| 232 | } | |
| 233 | ||
| 191 | 234 | // POST /:owner/:name/issues/new |
| 192 | 235 | export async function newIssueSubmit(ctx: Ctx): Promise<Response> { |
| 193 | 236 | const repo = loadRepo(ctx); |
| @@ -196,11 +239,16 @@ export async function newIssueSubmit(ctx: Ctx): Promise<Response> { | ||
| 196 | 239 | const form = await readForm(ctx, "rw"); |
| 197 | 240 | if (form instanceof Response) return form; |
| 198 | 241 | |
| 199 | const values = { | |
| 242 | const from = cleanFrom(field(form, "from")); | |
| 243 | const typed = { | |
| 200 | 244 | title: field(form, "title"), |
| 201 | 245 | body: field(form, "body"), |
| 202 | 246 | sealed: form.get("sealed") !== null, |
| 247 | ...(from === undefined ? {} : { from }), | |
| 203 | 248 | }; |
| 249 | // The line naming the page is added to what is stored, never to what is | |
| 250 | // sent back to the form on a refusal. | |
| 251 | const values = { ...typed, body: withFromLine(typed.body, from) }; | |
| 204 | 252 | // The `…Through` twin: on a mirror the origin creates the issue, so the |
| 205 | 253 | // number and the timestamps are the origin's rather than ones we invented. |
| 206 | 254 | const created = await openIssueThrough(ctx.app, ctx.auth.user, repo, values); |
| @@ -215,7 +263,7 @@ export async function newIssueSubmit(ctx: Ctx): Promise<Response> { | ||
| 215 | 263 | : created.error === "body_too_long" |
| 216 | 264 | ? BODY_TOO_LONG |
| 217 | 265 | : "You cannot open an issue here."); |
| 218 | return composePage(ctx, repo, values, message); | |
| 266 | return composePage(ctx, repo, typed, message); | |
| 219 | 267 | } |
| 220 | 268 | return flashTo(ctx, `${repoBase(repo)}/issues/${created.value.number}`, { |
| 221 | 269 | message: `Issue #${created.value.number} opened.`, |
test/design-audit.test.ts +1 −1
| @@ -15,7 +15,7 @@ const doc = readFileSync("docs/design/audit.md", "utf8"); | ||
| 15 | 15 | |
| 16 | 16 | /** Routes that answer a file, a fragment, a redirect hop or a callback. */ |
| 17 | 17 | const NOT_PAGES = |
| 18 | /^\/(assets\/|\.well-known\/|icons\/|og\/|live\/|__forge\/|go\/|events$|system\/stream$|talk\/stream$|settings\/teams\/stream$|sw\.js$|robots\.txt$|favicon\.ico$|apple-touch-icon(-precomposed)?\.png$|sitemap\.xml$|manifest\.webmanifest$|opensearch\.xml$|llms\.txt$|openapi\.json$|releases\.json$|changelog\.atom$|audit\.csv$|fleet\/graph\/live$|fleet\/graph\.csv$|settings\/origins\/callback$|home\/archived\/|settings\/backups\/live$|api\/v1\/)/; | |
| 18 | /^\/(assets\/|\.well-known\/|icons\/|og\/|live\/|__forge\/|go\/|new\/issue$|events$|system\/stream$|talk\/stream$|settings\/teams\/stream$|sw\.js$|robots\.txt$|favicon\.ico$|apple-touch-icon(-precomposed)?\.png$|sitemap\.xml$|manifest\.webmanifest$|opensearch\.xml$|llms\.txt$|openapi\.json$|releases\.json$|changelog\.atom$|audit\.csv$|fleet\/graph\/live$|fleet\/graph\.csv$|settings\/origins\/callback$|home\/archived\/|settings\/backups\/live$|api\/v1\/)/; | |
| 19 | 19 | |
| 20 | 20 | const getRoutes = [ |
| 21 | 21 | ...server.matchAll(/router\.add\(\s*"GET",\s*"([^"]+)"/g), |
test/new-issue-anywhere.test.ts added +109
| @@ -0,0 +1,109 @@ | ||
| 1 | import { afterAll, beforeAll, describe, expect, test } from "bun:test"; | |
| 2 | import { mkdtemp, rm } from "node:fs/promises"; | |
| 3 | import { tmpdir } from "node:os"; | |
| 4 | import { join } from "node:path"; | |
| 5 | import { createApp, type App } from "../src/app.ts"; | |
| 6 | import { serve, type Servers } from "../src/http/server.ts"; | |
| 7 | import { shellJs } from "../src/web/client.ts"; | |
| 8 | import { cleanFrom, newIssueHref, withFromLine } from "../src/web/issue_from.ts"; | |
| 9 | import { adminCookie } from "./helpers/admin.ts"; | |
| 10 | ||
| 11 | describe("a new issue from any page", () => { | |
| 12 | let dataDir: string; | |
| 13 | let app: App; | |
| 14 | let servers: Servers; | |
| 15 | let base: string; | |
| 16 | let cookie = ""; | |
| 17 | ||
| 18 | beforeAll(async () => { | |
| 19 | dataDir = await mkdtemp(join(tmpdir(), "forge-newissue-")); | |
| 20 | app = createApp({ dataDir, port: 0 }); | |
| 21 | servers = await serve(app); | |
| 22 | base = servers.url; | |
| 23 | cookie = await adminCookie(app, { username: "mel", email: "[email protected]", password: "issue-tracker-pass" }); | |
| 24 | await fetch(`${base}/new`, { | |
| 25 | method: "POST", | |
| 26 | headers: { cookie }, | |
| 27 | body: new URLSearchParams({ name: "proj", visibility: "public" }), | |
| 28 | redirect: "manual", | |
| 29 | }); | |
| 30 | }); | |
| 31 | ||
| 32 | afterAll(async () => { | |
| 33 | servers.stop(); | |
| 34 | app.close(); | |
| 35 | await rm(dataDir, { recursive: true, force: true }); | |
| 36 | }); | |
| 37 | ||
| 38 | test("the menu carries the link for a signed-in person and a guest, with no script", async () => { | |
| 39 | const signedIn = await (await fetch(`${base}/mel/proj`, { headers: { cookie } })).text(); | |
| 40 | expect(signedIn).toContain('href="/new/issue?from=%2Fmel%2Fproj"'); | |
| 41 | const guest = await (await fetch(`${base}/mel/proj`)).text(); | |
| 42 | expect(guest).toContain('href="/new/issue?from=%2Fmel%2Fproj"'); | |
| 43 | // The guest's link leads to sign in, never to a form (next test). | |
| 44 | // Read-only origin browse has no such link. | |
| 45 | const origin = await (await fetch(`${base}/origins/none/r/x/y`, { headers: { cookie } })).text(); | |
| 46 | expect(origin).not.toContain("/new/issue"); | |
| 47 | }); | |
| 48 | ||
| 49 | test("c and Shift C open the door from the page, and the help names each case", () => { | |
| 50 | expect(shellJs).toContain("e.key === 'C') openNewIssue(e)"); | |
| 51 | expect(shellJs).toContain("if (focusBox()) { seq.length = 0; e.preventDefault(); } else openNewIssue(e);"); | |
| 52 | expect(shellJs).toContain("'/new/issue?from=' + encodeURIComponent(p)"); | |
| 53 | // A page with its own box (or its own c) shows Shift C alone; any other, both keys. | |
| 54 | expect(shellJs).toContain("var ownC = pageBox() || doc.querySelector('[data-shortcut=\"c\"]');"); | |
| 55 | expect(shellJs).toContain("ownC ? 'Shift C' : 'c / Shift C', 'New issue from this page'"); | |
| 56 | expect(shellJs).toContain("out.push(['c', doc.querySelector('[data-box]')"); | |
| 57 | }); | |
| 58 | ||
| 59 | test("from a page in a repository the link lands on its form, prefilled", async () => { | |
| 60 | const door = await fetch(`${base}/new/issue?from=${encodeURIComponent("/mel/proj/pulls")}`, { headers: { cookie }, redirect: "manual" }); | |
| 61 | expect(door.status).toBe(303); | |
| 62 | const to = door.headers.get("location")!; | |
| 63 | expect(to.startsWith("/mel/proj/issues/new?from=")).toBe(true); | |
| 64 | const text = await (await fetch(`${base}${to}`, { headers: { cookie } })).text(); | |
| 65 | expect(text).toContain('<input type="hidden" name="from" value="/mel/proj/pulls"'); | |
| 66 | }); | |
| 67 | ||
| 68 | test("outside a repository it is a picker over the repositories the viewer may write", async () => { | |
| 69 | const res = await fetch(`${base}/new/issue?from=${encodeURIComponent("/explore")}`, { headers: { cookie } }); | |
| 70 | expect(res.status).toBe(200); | |
| 71 | const text = await res.text(); | |
| 72 | expect(text).toContain('href="/mel/proj/issues/new?from=%2Fexplore"'); | |
| 73 | }); | |
| 74 | ||
| 75 | test("the issue ends with the line naming the page; a foreign from is dropped", async () => { | |
| 76 | const post = (from: string) => | |
| 77 | fetch(`${base}/mel/proj/issues/new`, { | |
| 78 | method: "POST", | |
| 79 | headers: { cookie }, | |
| 80 | body: new URLSearchParams({ title: "Broken", body: "It breaks.", from }), | |
| 81 | redirect: "manual", | |
| 82 | }); | |
| 83 | const first = await post("/mel/proj/pulls"); | |
| 84 | expect(first.status).toBe(303); | |
| 85 | const issue = await (await fetch(`${base}/mel/proj/issues/1`)).text(); | |
| 86 | expect(issue).toContain("Opened from <code>/mel/proj/pulls</code>"); | |
| 87 | await post("https://evil.example/x"); | |
| 88 | const second = await (await fetch(`${base}/mel/proj/issues/2`)).text(); | |
| 89 | expect(second).not.toContain("Opened from"); | |
| 90 | expect(second).not.toContain("evil.example"); | |
| 91 | }); | |
| 92 | ||
| 93 | test("a guest gets the sign-in path, never a form", async () => { | |
| 94 | const res = await fetch(`${base}/new/issue?from=${encodeURIComponent("/mel/proj")}`, { redirect: "manual" }); | |
| 95 | expect(res.status).toBe(303); | |
| 96 | expect(res.headers.get("location")).toContain("/signin"); | |
| 97 | const repoForm = await (await fetch(`${base}/mel/proj/issues/new?from=%2Fmel%2Fproj`)).text(); | |
| 98 | expect(repoForm).not.toContain('action="/mel/proj/issues/new"'); | |
| 99 | }); | |
| 100 | ||
| 101 | test("from is a same-origin path only", () => { | |
| 102 | expect(cleanFrom("/a/b")).toBe("/a/b"); | |
| 103 | for (const bad of ["//evil.example", "https://x", "a/b", "/a b", "/a\\b", "", "/x".repeat(200), "/a)[x](http://e)", "/a`b", "/a<b", "/a#b", "/a", "/a?b=c"]) | |
| 104 | expect(cleanFrom(bad)).toBeUndefined(); | |
| 105 | expect(newIssueHref("/new/issue")).toBe("/new/issue"); | |
| 106 | expect(withFromLine("hi", "/x")).toBe("hi\n\nOpened from `/x`"); | |
| 107 | expect(withFromLine("", "/x")).toBe("Opened from `/x`"); | |
| 108 | }); | |
| 109 | }); |
test/palette-roster.test.ts +3
| @@ -49,6 +49,7 @@ test("a site-admin person is offered the whole roster", () => { | ||
| 49 | 49 | ["explore", "Explore", "on the web"], |
| 50 | 50 | ["profile", "Open a profile", "username or org"], |
| 51 | 51 | ["repos", "Repositories", "the repositories you may write"], |
| 52 | ["new-issue-anywhere", "New issue", "pick a repository"], | |
| 52 | 53 | ["repo", "Open a repository", "owner/name"], |
| 53 | 54 | ["issues", "Issues in a repository", "owner/name"], |
| 54 | 55 | ["held", "Held by the Later gate", "owner/name"], |
| @@ -140,6 +141,7 @@ test("a site-admin agent is offered the roster without the person-only entries", | ||
| 140 | 141 | ["explore", "Explore", "on the web"], |
| 141 | 142 | ["profile", "Open a profile", "username or org"], |
| 142 | 143 | ["repos", "Repositories", "the repositories you may write"], |
| 144 | ["new-issue-anywhere", "New issue", "pick a repository"], | |
| 143 | 145 | ["repo", "Open a repository", "owner/name"], |
| 144 | 146 | ["issues", "Issues in a repository", "owner/name"], |
| 145 | 147 | ["held", "Held by the Later gate", "owner/name"], |
| @@ -201,6 +203,7 @@ test("a member person is offered the signed-in roster without the admin entries" | ||
| 201 | 203 | ["explore", "Explore", "on the web"], |
| 202 | 204 | ["profile", "Open a profile", "username or org"], |
| 203 | 205 | ["repos", "Repositories", "the repositories you may write"], |
| 206 | ["new-issue-anywhere", "New issue", "pick a repository"], | |
| 204 | 207 | ["repo", "Open a repository", "owner/name"], |
| 205 | 208 | ["issues", "Issues in a repository", "owner/name"], |
| 206 | 209 | ["held", "Held by the Later gate", "owner/name"], |
test/repo-head-quiet.test.ts +4 −4
| @@ -14,11 +14,11 @@ afterAll(async () => { | ||
| 14 | 14 | }); |
| 15 | 15 | |
| 16 | 16 | // Bytes on the snappy fixture as the owner: [before the head folded its rare steps away, measured now]. |
| 17 | // The budget is the measurement plus a few bytes for port digits, not room. | |
| 17 | // The budget is the measurement plus a few bytes for port digits, not room, plus the menu's "New issue" link (#8855), about 320 bytes on each. | |
| 18 | 18 | const BYTES: Record<string, { before: number; budget: number }> = { |
| 19 | "": { before: 25_929, budget: 24_840 }, | |
| 20 | "/issues": { before: 51_705, budget: 50_590 }, | |
| 21 | "/pulls": { before: 25_151, budget: 24_030 }, | |
| 19 | "": { before: 25_929, budget: 25_190 }, | |
| 20 | "/issues": { before: 51_705, budget: 50_980 }, | |
| 21 | "/pulls": { before: 25_151, budget: 24_420 }, | |
| 22 | 22 | }; |
| 23 | 23 | |
| 24 | 24 | const get = async (path: string): Promise<string> => |
test/tui-new-issue.test.ts added +68
| @@ -0,0 +1,68 @@ | ||
| 1 | /** | |
| 2 | * A new issue from the terminal's palette: pick a repository you may write, | |
| 3 | * land on its new-issue form. A guest is not offered the row. | |
| 4 | */ | |
| 5 | import { afterAll, beforeAll, expect, test } from "bun:test"; | |
| 6 | import { rm } from "node:fs/promises"; | |
| 7 | import { createRepo } from "../src/actions/repos.ts"; | |
| 8 | import { createApp, type App } from "../src/app.ts"; | |
| 9 | import { createUser, type User } from "../src/core/users.ts"; | |
| 10 | import { createForgeApp } from "../src/tui/forge.ts"; | |
| 11 | import { headlessTerminal } from "../src/tui/term/terminal.ts"; | |
| 12 | import { quietLogs } from "./helpers/globals.ts"; | |
| 13 | import { scratchDir } from "./helpers/scratch.ts"; | |
| 14 | import { settleApp } from "./helpers/tui.ts"; | |
| 15 | import { proveAll } from "./helpers/verified.ts"; | |
| 16 | ||
| 17 | quietLogs(); | |
| 18 | ||
| 19 | let dir: string; | |
| 20 | let app: App; | |
| 21 | let alice: User; | |
| 22 | ||
| 23 | async function drive(user: User | undefined, keys: string[]): Promise<string> { | |
| 24 | const terminal = headlessTerminal(120, 40); | |
| 25 | const { app: tui } = createForgeApp( | |
| 26 | app, | |
| 27 | { sshUsername: user?.username ?? "guest", remoteAddress: "127.0.0.1", fingerprint: "SHA256:test", ...(user ? { user } : {}) }, | |
| 28 | { terminal, exitHooks: false }, | |
| 29 | ); | |
| 30 | tui.start(); | |
| 31 | await settleApp(tui); | |
| 32 | for (const k of keys) { | |
| 33 | terminal.feed(k); | |
| 34 | await settleApp(tui); | |
| 35 | } | |
| 36 | const text = tui.screen.toText(); | |
| 37 | tui.stop?.(); | |
| 38 | return text; | |
| 39 | } | |
| 40 | ||
| 41 | beforeAll(async () => { | |
| 42 | dir = scratchDir("forge-tui-new-issue-"); | |
| 43 | app = createApp({ dataDir: dir, port: 0 }); | |
| 44 | const r = await createUser(app.db, { | |
| 45 | origin: "seed", trusted: true, username: "alice", email: "[email protected]", | |
| 46 | password: "a-good-passphrase", adminIfFirst: true, | |
| 47 | }); | |
| 48 | if (!r.ok) throw new Error(r.error); | |
| 49 | alice = r.value; | |
| 50 | proveAll(app); | |
| 51 | const made = await createRepo(app, alice, { name: "lobby", visibility: "public" }); | |
| 52 | if (!made.ok) throw new Error(String(made.error)); | |
| 53 | }); | |
| 54 | afterAll(async () => { | |
| 55 | app.db.close(); | |
| 56 | await rm(dir, { recursive: true, force: true }); | |
| 57 | }); | |
| 58 | ||
| 59 | test("the palette row asks which repository, and enter opens its new-issue form", async () => { | |
| 60 | expect(await drive(alice, [":", "New issue", "\r"])).toContain("alice/lobby"); | |
| 61 | const form = await drive(alice, [":", "New issue", "\r", "\r"]); | |
| 62 | expect(form).toContain("Title"); | |
| 63 | expect(form).toContain("Description"); | |
| 64 | }); | |
| 65 | ||
| 66 | test("a guest is not offered the row", async () => { | |
| 67 | expect(await drive(undefined, [":", "New issue"])).not.toContain("pick a repository"); | |
| 68 | }); |
test/web-origins.test.ts +2 −1
| @@ -788,7 +788,8 @@ describe("origins on the web", () => { | ||
| 788 | 788 | text |
| 789 | 789 | .replaceAll(/nonce="[^"]*"/g, 'nonce="N"') |
| 790 | 790 | .replaceAll(/here\?path=[^"&\s]*/g, "here?path=") |
| 791 | .replaceAll(/next=[^"]*/g, "next="); | |
| 791 | .replaceAll(/next=[^"]*/g, "next=") | |
| 792 | .replaceAll(/\/new\/issue\?from=[^"]*/g, "/new/issue?from="); | |
| 792 | 793 | const staleText = await stale.text(); |
| 793 | 794 | const unknownText = await unknown.text(); |
| 794 | 795 | expect(sameBut(staleText)).toBe(sameBut(unknownText)); |
test/web-ui.test.ts +3 −2
| @@ -440,7 +440,8 @@ describe("web ui", () => { | ||
| 440 | 440 | t |
| 441 | 441 | .replaceAll(/nonce-?="?[\w-]+/g, "nonce") |
| 442 | 442 | .replaceAll(/here\?path=[^"&\s]*/g, "here?path=") |
| 443 | .replaceAll(/next=[^"]*/g, "next="); | |
| 443 | .replaceAll(/next=[^"]*/g, "next=") | |
| 444 | .replaceAll(/\/new\/issue\?from=[^"]*/g, "/new/issue?from="); | |
| 444 | 445 | expect(scrub(await hidden.text())).toBe(scrub(await absent.text())); |
| 445 | 446 | }); |
| 446 | 447 | |
| @@ -456,7 +457,7 @@ describe("web ui", () => { | ||
| 456 | 457 | const hidden = await (await fetch(`${base}/kim/secret`)).text(); |
| 457 | 458 | const absent = await (await fetch(`${base}/kim/nothing`)).text(); |
| 458 | 459 | const scrub = (t: string) => |
| 459 | t.replaceAll(/nonce-?="?[\w-]+/g, "nonce").replaceAll(/here\?path=[^"&\s]*/g, "here?path=").replaceAll(/next=[^"]*/g, "").replaceAll(/q=[^"]*"/g, 'q="').replaceAll(/Search for ‘[^’]*’/g, "Search for"); | |
| 460 | t.replaceAll(/nonce-?="?[\w-]+/g, "nonce").replaceAll(/here\?path=[^"&\s]*/g, "here?path=").replaceAll(/next=[^"]*/g, "").replaceAll(/\/new\/issue\?from=[^"]*/g, "/new/issue?from=").replaceAll(/q=[^"]*"/g, 'q="').replaceAll(/Search for ‘[^’]*’/g, "Search for"); | |
| 460 | 461 | expect(scrub(hidden)).toBe(scrub(absent)); |
| 461 | 462 | |
| 462 | 463 | const signedIn = await ( |
new above
offline
The key handling is untested. Add a test that fails without it.