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

Every open pull's preview is one click from the app's studio page #44

Merged opened this

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

Merged

scout/forge:scout/25-every-open-pull-s-preview-is-one-click-f → main 4 commits fork

  • Merged. scout/25-every-open-pull-s-preview-is-one-click-f was merged into main as 0100235.

Review

Sign in to review this change.
2 files changed +205 −5
src/web/pages/studio.ts +90 −3
@@ -19,7 +19,13 @@ import {
1919 } from "../../actions/issues.ts";
2020 import { mergePull } from "../../actions/pulls.ts";
2121 import { tellCrew } from "../../actions/crew.ts";
22 import { appsRuntime, type EnvStatus } from "../../apps/runtime.ts";
22 import { envPull, previewEnv } from "../../apps/data.ts";
23 import {
24 appsRuntime,
25 type AppsRuntime,
26 type EnvState,
27 type EnvStatus,
28 } from "../../apps/runtime.ts";
2329 import { listCrewRoles, type Agent } from "../../core/agents.ts";
2430 import { appsForRepos, getAppByRepo, type AppRecord } from "../../core/apps.ts";
2531 import {
@@ -95,7 +101,6 @@ import {
95101 type Tone,
96102 } from "../ui.ts";
97103 import { looksForApps } from "../../core/looks.ts";
98 import { previewEnv } from "../../apps/data.ts";
99104 import { lookNow, lookThumb, lookUrl } from "./looks.ts";
100105 import { onYouCard } from "./site.ts";
101106
@@ -582,6 +587,87 @@ const WORD_ICON: Record<Word, Parameters<typeof icon>[0]> = {
582587 planned: "map",
583588 };
584589
590 // ---- previews: every open pull's preview, one click away ----
591
592 /** The three words the studio uses for a preview, whatever its check says. */
593 function previewBadge(state: EnvState | undefined): Html {
594 if (state === "running")
595 return badge("live", { tone: "success", icon: "play" });
596 if (state === "deploying")
597 return badge("starting", { tone: "attn", icon: "loader" });
598 return badge("not up", {
599 ...(state === "failed" ? { tone: "danger" as const } : {}),
600 icon: "square",
601 });
602 }
603
604 /**
605 * One open pull's preview, as one row: its host is the click, its state is
606 * the badge, and a check still running or a check that failed says so right
607 * there — the row never hides a preview that is serving.
608 */
609 function previewRow(
610 base: string,
611 runtime: AppsRuntime | undefined,
612 record: AppRecord,
613 pull: Pull,
614 env: EnvStatus | undefined,
615 ): Html {
616 const href =
617 runtime === undefined
618 ? undefined
619 : `${base}/app/${previewEnv(pull.number)}/open`;
620 const host =
621 runtime === undefined
622 ? undefined
623 : runtime.urlFor(record, previewEnv(pull.number)).replace(/^https?:\/\//, "");
624 const bits: Html[] = [html`#${pull.number} ${pull.title}`];
625 if (env?.commitOid) bits.push(html`${env.commitOid.slice(0, 7)}`);
626 if (env?.since) bits.push(html`up ${time(env.since)}`);
627 const say = env?.error ?? env?.note;
628 if (say) bits.push(html`${say}`);
629 return row({
630 ...(href === undefined ? {} : { href, nav: true }),
631 lead: icon("rocket"),
632 title:
633 host === undefined
634 ? `#${pull.number} ${pull.title}`
635 : html`<span class="mono">${host}</span>`,
636 meta: bits.reduce((a, b) => html`${a} · ${b}`),
637 trail: previewBadge(env?.state),
638 });
639 }
640
641 /**
642 * Every open pull's preview, one row each, from the runtime's own `status()`
643 * — no per-pull query, so the list costs nothing extra as pulls pile up. A
644 * pull with no environment yet still gets a row, so the list matches the
645 * open pulls exactly.
646 */
647 function previewsPanel(
648 base: string,
649 runtime: AppsRuntime | undefined,
650 record: AppRecord,
651 openPulls: readonly Pull[],
652 envs: readonly EnvStatus[],
653 ): Html | null {
654 if (openPulls.length === 0) return null;
655 const byPull = new Map(
656 envs
657 .filter((e) => e.env !== "production")
658 .map((e) => [envPull(e.env), e] as const),
659 );
660 return listCard(
661 openPulls.map((p) =>
662 previewRow(base, runtime, record, p, byPull.get(p.number)),
663 ),
664 {
665 head: html`${icon("rocket")}
666 <h2>Previews</h2>`,
667 },
668 );
669 }
670
585671 // ---- the app in the studio ----
586672
587673 export async function studioApp(
@@ -629,6 +715,7 @@ export async function studioApp(
629715 // ---- the app itself ----
630716 const runtime = appsRuntime(ctx.app);
631717 const envs: EnvStatus[] = runtime === undefined ? [] : runtime.status(record);
718 const previews = previewsPanel(base, runtime, record, openPulls, envs);
632719 const production = envs.find((e) => e.env === "production");
633720 const url = runtime?.urlFor(record, "production");
634721 const writer = canWrite(db, repo, ctx.auth.user);
@@ -926,7 +1013,7 @@ export async function studioApp(
9261013 })}
9271014 ${head}
9281015 <div class="dash">
929 <div class="dash-main stack">${tell} ${asks} ${now}</div>
1016 <div class="dash-main stack">${tell} ${asks} ${previews} ${now}</div>
9301017 <aside class="dash-rail stack">
9311018 ${crewCard} ${weekCard} ${ownCard} ${more}
9321019 </aside>
test/studio.test.ts +115 −2
@@ -13,9 +13,8 @@ import type { EnvName } from "../src/apps/data.ts";
1313 import { AppsRuntime } from "../src/apps/runtime.ts";
1414 import { getAppByRepo, type AppRecord } from "../src/core/apps.ts";
1515 import { addToCrew, createAgent } from "../src/actions/crew.ts";
16 import { openPull } from "../src/actions/pulls.ts";
16 import { mergePull, openPull } from "../src/actions/pulls.ts";
1717 import { commitToBranch, createRepo } from "../src/actions/repos.ts";
18 import { getAppByRepo } from "../src/core/apps.ts";
1918 import { getIssue, listIssues } from "../src/core/issues.ts";
2019 import { recordLook } from "../src/core/looks.ts";
2120 import { getPull, type Pull } from "../src/core/pulls.ts";
@@ -493,6 +492,120 @@ describe("the studio", () => {
493492 });
494493 });
495494
495 describe("every open pull's preview on the studio page", () => {
496 let dataDir: string;
497 let app: App;
498 let servers: Servers;
499 let runtime: AppsRuntime;
500 let base: string;
501 let cookie: string;
502 let repo: Repo;
503
504 const get = (path: string) =>
505 fetch(`${base}${path}`, {
506 headers: { cookie: `${cookie}; forge_mode=studio` },
507 redirect: "manual",
508 });
509
510 beforeAll(async () => {
511 dataDir = await mkdtemp(join(tmpdir(), "forge-studio-previews-"));
512 app = createApp({ dataDir, port: 0 });
513 servers = await serve(app);
514 base = servers.url;
515 runtime = new AppsRuntime(app, {
516 domain: "studio-previews.test",
517 scheme: "http",
518 publicPort: servers.port,
519 forgeUrl: servers.url,
520 concurrency: 1,
521 });
522 await runtime.start();
523 const a = await createUser(app.db, {
524 username: "alice",
525 email: "[email protected]",
526 password: "a-good-passphrase",
527 adminIfFirst: true,
528 });
529 if (!a.ok) throw new Error(a.error);
530 cookie = (
531 (
532 await fetch(`${base}/signin`, {
533 method: "POST",
534 body: new URLSearchParams({
535 username: "alice",
536 password: "a-good-passphrase",
537 }),
538 redirect: "manual",
539 })
540 ).headers.get("set-cookie") ?? ""
541 ).split(";")[0]!;
542 const made = await createRepo(app, a.value, {
543 name: "widget",
544 visibility: "public",
545 description: "",
546 });
547 if (!made.ok) throw new Error(String(made.error));
548 repo = made.value;
549 const seeded = await commitToBranch(app, a.value, repo, {
550 message: "Start",
551 files: [
552 {
553 path: "package.json",
554 content: '{"name":"widget","scripts":{"start":"bun run server.ts"}}\n',
555 },
556 {
557 path: "server.ts",
558 content:
559 "Bun.serve({ port: Number(process.env.PORT), fetch: () => new Response('ok') });\n",
560 },
561 ],
562 });
563 if (!seeded.ok) throw new Error(JSON.stringify(seeded.error));
564 const enabled = await enableApp(app, a.value, repo, { slug: "widget" });
565 if (!enabled.ok) throw new Error(String(enabled.error));
566 }, 30_000);
567
568 afterAll(async () => {
569 await runtime.stop();
570 servers.stop();
571 app.close();
572 await rm(dataDir, { recursive: true, force: true });
573 });
574
575 test("an open pull's preview host and open link render; none do once nothing is open", async () => {
576 const alice = getUserByName(app.db, "alice")!;
577 const before = await (await get("/alice/widget")).text();
578 expect(before).not.toContain("<h2>Previews</h2>");
579
580 const branch = await commitToBranch(app, alice, repo, {
581 branch: "feature/x",
582 base: repo.defaultBranch,
583 message: "Add X",
584 files: [{ path: "X.md", content: "x\n" }],
585 });
586 if (!branch.ok) throw new Error(JSON.stringify(branch.error));
587 const opened = await openPull(app, alice, repo, {
588 title: "Add X",
589 body: "",
590 baseBranch: repo.defaultBranch,
591 headBranch: "feature/x",
592 });
593 if (!opened.ok) throw new Error(String(opened.error));
594 const pull = opened.value;
595
596 const page = await (await get("/alice/widget")).text();
597 expect(page).toContain("<h2>Previews</h2>");
598 expect(page).toContain(`href="/alice/widget/app/pull-${pull.number}/open"`);
599 expect(page).toContain(`#${pull.number} Add X`);
600
601 const merged = await mergePull(app, alice, repo, pull, "squash");
602 expect(merged.ok).toBe(true);
603 const after = await (await get("/alice/widget")).text();
604 expect(after).not.toContain("<h2>Previews</h2>");
605 expect(after).not.toContain(`pull-${pull.number}/open`);
606 }, 30_000);
607 });
608
496609 describe("the studio home does not wait on the decider", () => {
497610 const DECIDER_MS = 800;
498611 let dataDir: string;

↑↓ navigate ↵ open ⌘↵ its app esc close

Keyboard shortcuts

Docs