Public: anyone can read this; the crew can change it.

Ship and close from the inbox and the feed land back on the same page with a one-line flash #1766

Merged opened this

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

Merged

scout/forge:scout/1567-ship-and-close-from-the-inbox-and-the-fe → main 2 commits fork 1 thread · 1 unresolved

  • Merged. scout/1567-ship-and-close-from-the-inbox-and-the-fe was merged into main as b1ca5b5.

Review

Sign in to review this change.
8 files changed +135 −11
src/web/flash.ts +20
@@ -19,6 +19,8 @@ export interface Flash {
1919 message: string;
2020 /** A one-time value rendered in a copyable monospace block. */
2121 code?: string;
22 /** One same-origin link after the message ("see it live"). */
23 link?: { href: string; label: string };
2224 }
2325
2426 // Cookies cap out around 4KB; a flash is a sentence, not a payload.
@@ -63,5 +65,23 @@ export function readFlash(request: Request): Flash | undefined {
6365 ...(typeof value.code === "string" && value.code !== ""
6466 ? { code: value.code.slice(0, 512) }
6567 : {}),
68 ...linkOf(value.link),
6669 };
6770 }
71
72 /** A flash link read back: a same-origin path and a short label, or nothing. */
73 function linkOf(value: unknown): { link?: NonNullable<Flash["link"]> } {
74 if (typeof value !== "object" || value === null) return {};
75 const { href, label } = value as Record<string, unknown>;
76 if (typeof href !== "string" || typeof label !== "string") return {};
77 if (!isLocalPath(href) || label === "" || label.length > 80) return {};
78 return { link: { href, label } };
79 }
80
81 /** A path on this origin: one leading slash, no scheme, no control bytes. */
82 export function isLocalPath(path: string): boolean {
83 return (
84 path.length <= 512 &&
85 /^\/(?![/\\])[^\u0000-\u001f\u007f\\]*$/.test(path)
86 );
87 }
src/web/form.ts +11
@@ -10,6 +10,7 @@
1010
1111 import { BODY_MAX, readBounded, STRANGER_MAX } from "../http/limits.ts";
1212 import { scopeAllows, type TokenScope } from "../core/tokens.ts";
13 import { isLocalPath } from "./flash.ts";
1314 import { crossOrigin, type Ctx } from "../http/context.ts";
1415
1516 export type Form = Awaited<ReturnType<Request["formData"]>>;
@@ -58,6 +59,16 @@ export function field(form: Form, name: string): string {
5859 return typeof value === "string" ? value : "";
5960 }
6061
62 /**
63 * Where a form asks to land back: its `back` field when that is a path on
64 * this origin, otherwise `fallback`. Never a URL — a caller writes this
65 * field, so anything with a scheme or host is dropped.
66 */
67 export function backTo(form: Form, fallback: string): string {
68 const back = field(form, "back");
69 return isLocalPath(back) ? back : fallback;
70 }
71
6172 /** Whether the client asked for JSON — a script keeping the page in place —
6273 * rather than the redirect a plain form gets. */
6374 export function prefersJson(ctx: Ctx): boolean {
src/web/layout.ts +4 −1
@@ -394,7 +394,10 @@ function flashToast(flash: Flash): Html {
394394 return html`<div class="toast" data-tone="${tone}" role="status">
395395 ${icon(glyph)}
396396 <div class="toast-body">
397 ${flash.message}
397 ${flash.message}${when(
398 flash.link,
399 (link) => html` · <a href="${link.href}">${link.label}</a>`,
400 )}
398401 ${when(
399402 flash.code,
400403 (code) =>
src/web/pages/thread.ts +5 −2
@@ -49,7 +49,7 @@ import {
4949 } from "../../core/users.ts";
5050 import type { Ctx } from "../../http/context.ts";
5151 import { commentCard } from "../comments.ts";
52 import { field, readForm } from "../form.ts";
52 import { backTo, field, readForm } from "../form.ts";
5353 import { html, raw, when, type Html } from "../html.ts";
5454 import { flashOrJson, flashTo } from "../layout.ts";
5555 import {
@@ -724,7 +724,10 @@ export async function stateSubmit(
724724 if (form instanceof Response) return form;
725725
726726 const wanted = field(form, "state") === "closed" ? "closed" : "open";
727 const dest = `${repoBase(found.repo)}/${kind === "pull" ? "pulls" : "issues"}/${found.issue.number}`;
727 const dest = backTo(
728 form,
729 `${repoBase(found.repo)}/${kind === "pull" ? "pulls" : "issues"}/${found.issue.number}`,
730 );
728731 const why = field(form, "reason");
729732 const intoText = field(form, "into").trim();
730733 const into = intoText
src/web/studio/actions.ts +9 −4
@@ -21,7 +21,7 @@ import { canRead, getRepo, type Repo } from "../../core/repos.ts";
2121 import { bringUpToDate } from "../../crew/autonomy.ts";
2222 import type { Ctx } from "../../http/context.ts";
2323 import { loadRepo, notFoundPage, repoBase, signInFirst } from "../chrome.ts";
24 import { field, readForm, type Form } from "../form.ts";
24 import { backTo, field, readForm, type Form } from "../form.ts";
2525 import { flashOrJson, flashTo } from "../layout.ts";
2626 import { FORGE_TARGET } from "./feed.ts";
2727 import { changeHref } from "./nouns.ts";
@@ -133,6 +133,8 @@ export async function shipSubmit(ctx: Ctx): Promise<Response> {
133133 : undefined;
134134 if (!pull || !mayView(ctx.app.db, repo, pull, ctx.auth.user))
135135 return notFoundPage(ctx);
136 // The inbox and the feed say where they were; the pull page does not.
137 const back = backTo(form, changeHref(base, pull.number));
136138 const merged = await mergePull(ctx.app, ctx.auth.user, repo, pull, "squash");
137139 // A conflict is never the person's to fix: the pull's agent brings the
138140 // branch up to date, and the pull comes back green, or lands on its own.
@@ -142,7 +144,7 @@ export async function shipSubmit(ctx: Ctx): Promise<Response> {
142144 merged.error.kind === "conflict"
143145 ) {
144146 const who = bringUpToDate(ctx.app, repo, pull, merged.error.files);
145 return flashOrJson(ctx, base, {
147 return flashOrJson(ctx, back, {
146148 tone: "danger" as const,
147149 message:
148150 who === undefined
@@ -150,9 +152,12 @@ export async function shipSubmit(ctx: Ctx): Promise<Response> {
150152 : `It conflicts with main (${merged.error.files.join(", ")}); ${who} is bringing it up to date${repo.autonomy === "autonomous" ? ", then it lands on its own" : ", then it comes back to you"}.`,
151153 });
152154 }
153 return flashOrJson(ctx, base, {
155 return flashOrJson(ctx, back, {
154156 ...(merged.ok
155 ? { message: `Shipped "${pull.title}". Production is redeploying.` }
157 ? {
158 message: `Shipped #${pull.number}`,
159 link: { href: `${base}/app/production/open`, label: "see it live" },
160 }
156161 : {
157162 tone: "danger" as const,
158163 message: `That could not ship: ${mergeWords(merged.error)}.`,
src/web/studio/asks.ts +4 −3
@@ -900,7 +900,7 @@ const askFormId = (d: Decision): string => `ask-${d.id}`;
900900 * It sits after the panel's form, because a form inside a form is not
901901 * HTML; the button reaches it by id.
902902 */
903 export function decisionForm(d: Decision): Html | null {
903 export function decisionForm(d: Decision, back?: string): Html | null {
904904 const pull = d.pull;
905905 const own = html`id="${askFormId(d)}" method="post" hidden`;
906906 if (d.kind === "parked")
@@ -909,13 +909,14 @@ export function decisionForm(d: Decision): Html | null {
909909 </form>`;
910910 if (pull === undefined) return null;
911911 const base = repoBase(d.repo);
912 const where = back === undefined ? null : html`<input type="hidden" name="back" value="${back}" />`;
912913 if (d.kind === "stopped")
913914 return html`<form ${own} action="${base}/pulls/${pull.number}/state">
914 <input type="hidden" name="state" value="closed" />
915 <input type="hidden" name="state" value="closed" />${where}
915916 </form>`;
916917 if (d.kind === "look" || d.kind === "review")
917918 return html`<form ${own} action="${base}/pulls/${pull.number}/ship">
918 <input type="hidden" name="pull" value="${pull.number}" />
919 <input type="hidden" name="pull" value="${pull.number}" />${where}
919920 </form>`;
920921 return null;
921922 }
src/web/studio/asks_page.ts +1 −1
@@ -420,7 +420,7 @@ export function needsYouSection(
420420 ${reasonInput(top?.reason)} ${btn("Preview", { size: "sm" })}
421421 <span class="muted small">on the selected asks</span>
422422 </form>`}
423 ${decisions.map((d) => decisionForm(d))}
423 ${decisions.map((d) => decisionForm(d, ctx.url.pathname))}

Only the pathname is sent, so a filtered inbox comes back unfiltered after Ship or Close. Send pathname plus search, still validated by isLocalPath.

424424 </section>`;
425425 return { body, scriptJs: SELECT_JS };
426426 }
test/studio.test.ts +81
@@ -400,6 +400,87 @@ describe("the studio", () => {
400400 expect(getUserByName(app.db, "scout")).toBeDefined();
401401 });
402402
403 test("ship from the home returns to the home with a flash and one link; a foreign back lands on the pull page", async () => {
404 const green = async (name: string) => {
405 const branch = await commitToBranch(app, alice, repo, {
406 branch: `feature/${name}`,
407 base: "main",
408 message: name,
409 files: [{ path: `${name.toUpperCase()}.md`, content: `${name}\n` }],
410 });
411 if (!branch.ok) throw new Error(JSON.stringify(branch.error));
412 const opened = await openPull(app, alice, repo, {
413 title: name,
414 body: "",
415 baseBranch: "main",
416 headBranch: `feature/${name}`,
417 });
418 if (!opened.ok) throw new Error(String(opened.error));
419 const check = createRun(app.db, {
420 repoId: repo.id,
421 kind: "check",
422 issueId: opened.value.id,
423 env: `pull-${opened.value.number}`,
424 commitOid: opened.value.headOid,
425 reason: "pull",
426 });
427 claimRun(app.db, check.id);
428 finishRun(app.db, check.id, { state: "ok", summary: "check passed in 1s" });
429 return opened.value;
430 };
431 const home = await green("fromhome");
432 const shipped = await post(
433 `/alice/notes/pulls/${home.number}/ship`,
434 { back: "/" },
435 "; forge_mode=studio",
436 );
437 expect(shipped.status).toBe(303);
438 expect(shipped.headers.get("location")).toBe("/");
439 const flash = decodeURIComponent(
440 (shipped.headers.get("set-cookie") ?? "").split("forge_flash=")[1] ?? "",
441 );
442 expect(flash).toContain(`Shipped #${home.number}`);
443 expect(flash).toContain("see it live");
444 expect(getPull(app.db, repo.id, home.number)?.mergedAt).not.toBeNull();
445
446 const evil = await green("fromevil");
447 const lost = await post(
448 `/alice/notes/pulls/${evil.number}/ship`,
449 { back: "https://evil.example/" },
450 "; forge_mode=studio",
451 );
452 expect(lost.status).toBe(303);
453 expect(lost.headers.get("location")).toBe(
454 `/alice/notes/changes/${evil.number}`,
455 );
456 });
457
458 test("close from the home returns to the home", async () => {
459 const branch = await commitToBranch(app, alice, repo, {
460 branch: "feature/closeme",
461 base: "main",
462 message: "Close me",
463 files: [{ path: "CLOSEME.md", content: "x\n" }],
464 });
465 if (!branch.ok) throw new Error(JSON.stringify(branch.error));
466 const opened = await openPull(app, alice, repo, {
467 title: "Close me",
468 body: "",
469 baseBranch: "main",
470 headBranch: "feature/closeme",
471 });
472 if (!opened.ok) throw new Error(String(opened.error));
473 const n = opened.value.number;
474 const closed = await post(
475 `/alice/notes/pulls/${n}/state`,
476 { state: "closed", back: "/" },
477 "; forge_mode=studio",
478 );
479 expect(closed.status).toBe(303);
480 expect(closed.headers.get("location")).toBe("/");
481 expect(getPull(app.db, repo.id, n)?.state).toBe("closed");
482 });
483
403484 test("a tell shipped long ago still reads shipped once it is buried under 250 later events", async () => {
404485 const told = openIssue(app, alice, repo, {
405486 title: "Buried tell",

↑↓ navigate ↵ open ⌘↵ its app esc close

Keyboard shortcuts

Docs