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
Check · Review · Land · Live done — Landed and live in production.
scout/forge:scout/25-every-open-pull-s-preview-is-one-click-f → main 4 commits fork
2 files
changed +205
−5
src/web/pages/studio.ts +90 −3
| @@ -19,7 +19,13 @@ import { | ||
| 19 | 19 | } from "../../actions/issues.ts"; |
| 20 | 20 | import { mergePull } from "../../actions/pulls.ts"; |
| 21 | 21 | 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"; | |
| 23 | 29 | import { listCrewRoles, type Agent } from "../../core/agents.ts"; |
| 24 | 30 | import { appsForRepos, getAppByRepo, type AppRecord } from "../../core/apps.ts"; |
| 25 | 31 | import { |
| @@ -95,7 +101,6 @@ import { | ||
| 95 | 101 | type Tone, |
| 96 | 102 | } from "../ui.ts"; |
| 97 | 103 | import { looksForApps } from "../../core/looks.ts"; |
| 98 | import { previewEnv } from "../../apps/data.ts"; | |
| 99 | 104 | import { lookNow, lookThumb, lookUrl } from "./looks.ts"; |
| 100 | 105 | import { onYouCard } from "./site.ts"; |
| 101 | 106 | |
| @@ -582,6 +587,87 @@ const WORD_ICON: Record<Word, Parameters<typeof icon>[0]> = { | ||
| 582 | 587 | planned: "map", |
| 583 | 588 | }; |
| 584 | 589 | |
| 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 | ||
| 585 | 671 | // ---- the app in the studio ---- |
| 586 | 672 | |
| 587 | 673 | export async function studioApp( |
| @@ -629,6 +715,7 @@ export async function studioApp( | ||
| 629 | 715 | // ---- the app itself ---- |
| 630 | 716 | const runtime = appsRuntime(ctx.app); |
| 631 | 717 | const envs: EnvStatus[] = runtime === undefined ? [] : runtime.status(record); |
| 718 | const previews = previewsPanel(base, runtime, record, openPulls, envs); | |
| 632 | 719 | const production = envs.find((e) => e.env === "production"); |
| 633 | 720 | const url = runtime?.urlFor(record, "production"); |
| 634 | 721 | const writer = canWrite(db, repo, ctx.auth.user); |
| @@ -926,7 +1013,7 @@ export async function studioApp( | ||
| 926 | 1013 | })} |
| 927 | 1014 | ${head} |
| 928 | 1015 | <div class="dash"> |
| 929 | <div class="dash-main stack">${tell} ${asks} ${now}</div> | |
| 1016 | <div class="dash-main stack">${tell} ${asks} ${previews} ${now}</div> | |
| 930 | 1017 | <aside class="dash-rail stack"> |
| 931 | 1018 | ${crewCard} ${weekCard} ${ownCard} ${more} |
| 932 | 1019 | </aside> |
test/studio.test.ts +115 −2
| @@ -13,9 +13,8 @@ import type { EnvName } from "../src/apps/data.ts"; | ||
| 13 | 13 | import { AppsRuntime } from "../src/apps/runtime.ts"; |
| 14 | 14 | import { getAppByRepo, type AppRecord } from "../src/core/apps.ts"; |
| 15 | 15 | 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"; | |
| 17 | 17 | import { commitToBranch, createRepo } from "../src/actions/repos.ts"; |
| 18 | import { getAppByRepo } from "../src/core/apps.ts"; | |
| 19 | 18 | import { getIssue, listIssues } from "../src/core/issues.ts"; |
| 20 | 19 | import { recordLook } from "../src/core/looks.ts"; |
| 21 | 20 | import { getPull, type Pull } from "../src/core/pulls.ts"; |
| @@ -493,6 +492,120 @@ describe("the studio", () => { | ||
| 493 | 492 | }); |
| 494 | 493 | }); |
| 495 | 494 | |
| 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 | ||
| 496 | 609 | describe("the studio home does not wait on the decider", () => { |
| 497 | 610 | const DECIDER_MS = 800; |
| 498 | 611 | let dataDir: string; |
new above
offline