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

Merged opened this

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

Merged

mason/forge:mason/8855-c-opens-a-new-issue-from-any-page-prefil → main 5 commits fork 4 threads · 4 unresolved

  • Merged. mason/8855-c-opens-a-new-issue-from-any-page-prefil was merged into main as d99c72d.

Review

Sign in to review this change.
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 {
194194 issueSealSubmit,
195195 issueThreadFragment,
196196 newIssuePage,
197 newIssueAnywhere,
197198 newIssueSubmit,
198199 } from "../web/pages/issues.ts";
199200 import { noteModerate } from "../web/studio/notes.ts";
@@ -763,6 +764,7 @@ export function buildRouter(): Router<Handler> {
763764 // is registered before /:owner/:name can claim the shape.
764765 router.add("GET", "/-/confirm", confirmPage);
765766 router.add("GET", "/new", newRepoPage);
767 router.add("GET", "/new/issue", newIssueAnywhere);
766768 router.add("GET", "/repos", myReposPage);
767769 router.add("GET", "/now/:stage", nowPage);
768770 router.add("GET", "/audit", auditPage);
src/tui/screens/entries.ts +2
@@ -30,6 +30,7 @@ import { entry as limits } from "./limits.ts";
3030 import { modelCeilingEntry, modelGrantEntry } from "./model.ts";
3131 import { entry as orgGrants } from "./orggrants.ts";
3232 import { entry as orgTeams } from "./orgteams.ts";
33 import { entry as newIssue } from "./newissue.ts";
3334 import { entry as now } from "./nowlist.ts";
3435 import { entry as repos } from "./repolist.ts";
3536 import { entry as owner } from "./owner.ts";
@@ -103,6 +104,7 @@ export const ENTRIES: readonly PaletteEntry[] = [
103104 profile,
104105 now,
105106 repos,
107 newIssue,
106108 ];
107109
108110 /** 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 */ `
14541454 if (f.tagName === 'TEXTAREA') { var n = f.value.length; f.setSelectionRange(n, n); }
14551455 return true;
14561456 }
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 }
14571464 doc.addEventListener('keydown', function (e) {
14581465 var t = e.target;
14591466 // Inside an open dialog the dialog owns the keys; none of these apply.
@@ -1493,7 +1500,9 @@ const shellCore = /* js */ `
14931500 if (e.key === 'j') { seq.length = 0; e.preventDefault(); move(1); }
14941501 else if (e.key === 'k') { seq.length = 0; e.preventDefault(); move(-1); }
14951502 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);

The key handling is untested. Add a test that fails without it.

1505 } else if (e.key === 'C') openNewIssue(e);
14971506 else if (e.key === '/') {
14981507 var box = doc.querySelector('[data-search-input]');
14991508 if (box) { seq.length = 0; e.preventDefault(); box.focus(); box.select(); }
@@ -1512,6 +1521,10 @@ const shellCore = /* js */ `
15121521 function pageKeys() {
15131522 var out = [];
15141523 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']);
15151528 if (doc.querySelector('form[data-submit-key]')) out.push(['Cmd/Ctrl Enter', 'Submit the box you are typing in']);
15161529 return out;
15171530 }
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 @@
77 * `/search`), and every dialog (a link to a real page).
88 */
99
10 import { newIssueHref } from "./issue_from.ts";
1011 import { OPENSEARCH_PATH, OPENSEARCH_TITLE, OPENSEARCH_TYPE } from "./opensearch.ts";
1112 import { protectionNotice } from "../kernel/protection.ts";
1213 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
186187 </button>`;
187188
188189 /** 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 {
190200 return menu({
191201 trigger: themeGlyph,
192 items: [helpItem, themeForm(theme)],
202 items: [...newIssueItem(ctx), helpItem, themeForm(theme)],
193203 label: "Appearance and help",
194204 triggerOptions: { extra: "btn-icon" },
195205 });
@@ -216,6 +226,7 @@ const forgeDoor = (ctx: Ctx): MenuItem[] => {
216226
217227 function newMenu(ctx: Ctx): Html {
218228 const items: MenuItem[] = [
229 ...newIssueItem(ctx),
219230 { label: "New app", href: "/apps/new", icon: "rocket" },
220231 { label: "New repository", href: "/new", icon: "repo" },
221232 { label: "Migrate", href: "/migrate", icon: "download" },
@@ -247,6 +258,7 @@ function userMenu(
247258 { label: "Map", href: "/map", icon: "map" },
248259 { separator: true, label: "" },
249260 // The "+" menu shows on every width; these repeat it for the user menu.
261 ...newIssueItem(ctx),
250262 { label: "New app", href: "/apps/new", icon: "rocket" },
251263 { label: "New repository", href: "/new", icon: "repo" },
252264 { label: "Migrate", href: "/migrate", icon: "download" },
@@ -587,7 +599,7 @@ function topbar(
587599 }
588600 : { label: "Sign out", post: "/logout", icon: "signOut" },
589601 )}`
590 : html`${guestMenu(theme)}${btn("Sign in", {
602 : html`${guestMenu(ctx, theme)}${btn("Sign in", {
591603 href: signInHref(ctx),
592604 })}`}
593605 </span>
src/web/pages/issues.ts +56 −8
@@ -11,7 +11,7 @@ import {
1111 setIssueSealed,
1212 } from "../../actions/issues.ts";
1313 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";
1515 import { privateRepoForSealed } from "../../actions/repos.ts";
1616 import { sealedWords, sealRefusedWords } from "../../actions/seal_issue_words.ts";
1717 import { BODY_TOO_LONG, TITLE_TOO_LONG } from "../../actions/thread_limits_words.ts";
@@ -30,8 +30,9 @@ import {
3030 } from "../chrome.ts";
3131 import { field, readForm } from "../form.ts";
3232 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";
3536 import {
3637 originError,
3738 parseNumber,
@@ -54,7 +55,7 @@ import {
5455 function composePage(
5556 ctx: Ctx,
5657 repo: Repo,
57 values: { title: string; body: string; sealed?: boolean },
58 values: { title: string; body: string; sealed?: boolean; from?: string | undefined },
5859 error?: string,
5960 ): Response {
6061 const heading = PAGE_TITLES.compose;
@@ -84,13 +85,17 @@ function composePage(
8485
8586 function newIssueForm(
8687 repo: Repo,
87 values: { title: string; body: string; sealed?: boolean },
88 values: { title: string; body: string; sealed?: boolean; from?: string | undefined },
8889 submit: string,
8990 cancelHref: string,
9091 error?: string,
9192 ): Html {
9293 return html`<div class="narrow-wide mt">
9394 <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>`}
9499 ${formField({
95100 label: "Title",
96101 name: "title",
@@ -143,7 +148,7 @@ export function newIssuePage(ctx: Ctx): Response {
143148 );
144149 }
145150 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")) }),
147152 );
148153 }
149154
@@ -188,6 +193,44 @@ function accountOrReport(ctx: Ctx, repo: Repo, sealed: boolean): Response {
188193 });
189194 }
190195
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
191234 // POST /:owner/:name/issues/new
192235 export async function newIssueSubmit(ctx: Ctx): Promise<Response> {
193236 const repo = loadRepo(ctx);
@@ -196,11 +239,16 @@ export async function newIssueSubmit(ctx: Ctx): Promise<Response> {
196239 const form = await readForm(ctx, "rw");
197240 if (form instanceof Response) return form;
198241
199 const values = {
242 const from = cleanFrom(field(form, "from"));
243 const typed = {
200244 title: field(form, "title"),
201245 body: field(form, "body"),
202246 sealed: form.get("sealed") !== null,
247 ...(from === undefined ? {} : { from }),
203248 };
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) };
204252 // The `…Through` twin: on a mirror the origin creates the issue, so the
205253 // number and the timestamps are the origin's rather than ones we invented.
206254 const created = await openIssueThrough(ctx.app, ctx.auth.user, repo, values);
@@ -215,7 +263,7 @@ export async function newIssueSubmit(ctx: Ctx): Promise<Response> {
215263 : created.error === "body_too_long"
216264 ? BODY_TOO_LONG
217265 : "You cannot open an issue here.");
218 return composePage(ctx, repo, values, message);
266 return composePage(ctx, repo, typed, message);
219267 }
220268 return flashTo(ctx, `${repoBase(repo)}/issues/${created.value.number}`, {
221269 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");
1515
1616 /** Routes that answer a file, a fragment, a redirect hop or a callback. */
1717 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\/)/;
1919
2020 const getRoutes = [
2121 ...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![i](u)", "/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", () => {
4949 ["explore", "Explore", "on the web"],
5050 ["profile", "Open a profile", "username or org"],
5151 ["repos", "Repositories", "the repositories you may write"],
52 ["new-issue-anywhere", "New issue", "pick a repository"],
5253 ["repo", "Open a repository", "owner/name"],
5354 ["issues", "Issues in a repository", "owner/name"],
5455 ["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",
140141 ["explore", "Explore", "on the web"],
141142 ["profile", "Open a profile", "username or org"],
142143 ["repos", "Repositories", "the repositories you may write"],
144 ["new-issue-anywhere", "New issue", "pick a repository"],
143145 ["repo", "Open a repository", "owner/name"],
144146 ["issues", "Issues in a repository", "owner/name"],
145147 ["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"
201203 ["explore", "Explore", "on the web"],
202204 ["profile", "Open a profile", "username or org"],
203205 ["repos", "Repositories", "the repositories you may write"],
206 ["new-issue-anywhere", "New issue", "pick a repository"],
204207 ["repo", "Open a repository", "owner/name"],
205208 ["issues", "Issues in a repository", "owner/name"],
206209 ["held", "Held by the Later gate", "owner/name"],
test/repo-head-quiet.test.ts +4 −4
@@ -14,11 +14,11 @@ afterAll(async () => {
1414 });
1515
1616 // 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.
1818 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 },
2222 };
2323
2424 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", () => {
788788 text
789789 .replaceAll(/nonce="[^"]*"/g, 'nonce="N"')
790790 .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=");
792793 const staleText = await stale.text();
793794 const unknownText = await unknown.text();
794795 expect(sameBut(staleText)).toBe(sameBut(unknownText));
test/web-ui.test.ts +3 −2
@@ -440,7 +440,8 @@ describe("web ui", () => {
440440 t
441441 .replaceAll(/nonce-?="?[\w-]+/g, "nonce")
442442 .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=");
444445 expect(scrub(await hidden.text())).toBe(scrub(await absent.text()));
445446 });
446447
@@ -456,7 +457,7 @@ describe("web ui", () => {
456457 const hidden = await (await fetch(`${base}/kim/secret`)).text();
457458 const absent = await (await fetch(`${base}/kim/nothing`)).text();
458459 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");
460461 expect(scrub(hidden)).toBe(scrub(absent));
461462
462463 const signedIn = await (

↑↓ navigate ↵ open ⌘↵ its app esc close

Keyboard shortcuts

Docs