Public: anyone can read this; the crew can change it.
A change opens in studio words, with the diff one click away #52
Check · Review · Land · Live done — Landed and live in production.
scout/forge:scout/43-a-change-opens-in-studio-words-with-the → main 1 commit fork
4 files
changed +300
−18
docs/STUDIO.md +16
| @@ -83,6 +83,22 @@ A run in flight carries _Stop_ (running) or _Cancel_ (waiting) for anyone | ||
| 83 | 83 | who can write here; its title opens the transcript, where the agent can be |
| 84 | 84 | steered mid-run (see `docs/APPS.md`, _Runs_). |
| 85 | 85 | |
| 86 | ## The look, on its own page | |
| 87 | ||
| 88 | `/:owner/:name/pulls/:n` on an app, in the studio, is the change itself — | |
| 89 | never its diff. The title and the opening post in the author's own words, | |
| 90 | who made it and when, the check in the studio's five words (`checkWord`, | |
| 91 | `src/web/pages/studio.ts`) with its deploy one click away, the preview's | |
| 92 | state with the link that opens it (`previewCard`, the same card the aside | |
| 93 | carries in dev), and the look at phone and desktop width when one has been | |
| 94 | taken (`lookCard`, docs/APPS.md). Below that the same decision an ask card | |
| 95 | offers — ship it, or say what is off — through the very same `mergePull` | |
| 96 | and comment actions, and the conversation: read it, add to it. *Read the | |
| 97 | code* is one link away, `?mode=dev` on the same URL, landing on the diff | |
| 98 | page exactly as it always has, deep links and all. Nothing here reads a | |
| 99 | diff or a blob: the page costs a handful of small queries whatever the | |
| 100 | change's size, unlike the page it links to. | |
| 101 | ||
| 86 | 102 | ## The home |
| 87 | 103 | |
| 88 | 104 | `/` in the studio: what asks you across every app you can land, what is on |
src/web/pages/pull.ts +10
| @@ -96,6 +96,7 @@ import { previewCard } from "./apps.ts"; | ||
| 96 | 96 | import { lookCard } from "./looks.ts"; |
| 97 | 97 | import { pullState } from "./pulls.ts"; |
| 98 | 98 | import { crewActivityCard } from "./runs.ts"; |
| 99 | import { studioPull } from "./studio.ts"; | |
| 99 | 100 | |
| 100 | 101 | /** The pull and its repo, or the response that refuses to say why not. */ |
| 101 | 102 | function pullContext(ctx: Ctx): { repo: Repo; pull: Pull } | Response { |
| @@ -666,6 +667,15 @@ export async function pullPage(ctx: Ctx): Promise<Response> { | ||
| 666 | 667 | const found = pullContext(ctx); |
| 667 | 668 | if (found instanceof Response) return found; |
| 668 | 669 | const { repo, pull } = found; |
| 670 | // In the studio a change is judged by what it does, not its diff — the | |
| 671 | // same rule `repoHome` follows for an app's own page. `?mode=dev` is the | |
| 672 | // one-off way to the diff without switching the browser, same as home. | |
| 673 | const studio = | |
| 674 | ctx.mode === "studio" && ctx.url.searchParams.get("mode") !== "dev"; | |
| 675 | if (studio) { | |
| 676 | const record = getAppByRepo(ctx.app.db, repo.id); | |
| 677 | if (record !== undefined) return await studioPull(ctx, repo, pull, record); | |
| 678 | } | |
| 669 | 679 | const dir = repoDir(ctx.app.reposRoot, repo.ownerName, repo.name); |
| 670 | 680 | const base = repoBase(repo); |
| 671 | 681 | const thread = `${base}/pulls/${pull.number}`; |
src/web/pages/studio.ts +173 −18
| @@ -42,15 +42,22 @@ import { | ||
| 42 | 42 | openPullsClosing, |
| 43 | 43 | type Pull, |
| 44 | 44 | } from "../../core/pulls.ts"; |
| 45 | import { canWrite, listReposVisibleTo, type Repo } from "../../core/repos.ts"; | |
| 45 | import { | |
| 46 | canWrite, | |
| 47 | getRepoById, | |
| 48 | listReposVisibleTo, | |
| 49 | type Repo, | |
| 50 | } from "../../core/repos.ts"; | |
| 46 | 51 | import { listReviews } from "../../core/reviews.ts"; |
| 47 | 52 | import { |
| 48 | 53 | activeRunFor, |
| 49 | 54 | automaticRunsSince, |
| 55 | checkSummary, | |
| 50 | 56 | latestAgentRunsForRepos, |
| 51 | 57 | latestRunsForIssues, |
| 52 | 58 | listActiveRuns, |
| 53 | 59 | listRuns, |
| 60 | type CheckSummary, | |
| 54 | 61 | type Run, |
| 55 | 62 | } from "../../core/runs.ts"; |
| 56 | 63 | import { getUserById, getUsersByIds, type User } from "../../core/users.ts"; |
| @@ -94,15 +101,22 @@ import { | ||
| 94 | 101 | envPhrase, |
| 95 | 102 | icon, |
| 96 | 103 | listCard, |
| 104 | originTag, | |
| 97 | 105 | pill, |
| 98 | 106 | row, |
| 107 | sha, | |
| 108 | stateBadge, | |
| 99 | 109 | time, |
| 110 | userLink, | |
| 100 | 111 | userTag, |
| 101 | 112 | type Tone, |
| 102 | 113 | } from "../ui.ts"; |
| 103 | 114 | import { looksForApps } from "../../core/looks.ts"; |
| 104 | import { lookNow, lookThumb, lookUrl } from "./looks.ts"; | |
| 115 | import { lookCard, lookNow, lookThumb, lookUrl } from "./looks.ts"; | |
| 105 | 116 | import { onYouCard } from "./site.ts"; |
| 117 | import { previewCard } from "./apps.ts"; | |
| 118 | import { pullState } from "./pulls.ts"; | |
| 119 | import { composer, conversation, originGlyph } from "./thread.ts"; | |
| 106 | 120 | |
| 107 | 121 | // ---- decisions: what asks a person ---- |
| 108 | 122 | |
| @@ -587,6 +601,37 @@ const WORD_ICON: Record<Word, Parameters<typeof icon>[0]> = { | ||
| 587 | 601 | planned: "map", |
| 588 | 602 | }; |
| 589 | 603 | |
| 604 | /** One of the five (or six) studio words, as a badge. */ | |
| 605 | function wordBadge(word: Word): Html { | |
| 606 | const variant = | |
| 607 | word === "look" || word === "shipped" | |
| 608 | ? "success" | |
| 609 | : word === "stopped" | |
| 610 | ? "danger" | |
| 611 | : word === "working" | |
| 612 | ? "accent" | |
| 613 | : "secondary"; | |
| 614 | return html`<span class="badge" data-variant="${variant}" data-word="${word}" | |
| 615 | >${word === "working" ? icon("loader") : null}${word}</span | |
| 616 | >`; | |
| 617 | } | |
| 618 | ||
| 619 | /** A single open pull's check, in the studio's own words — never dev's | |
| 620 | * "Checks passed/failed": the same five words as the rest of the page. */ | |
| 621 | function checkWord(check: CheckSummary): { word: Word; note: string } { | |
| 622 | if (check.state === "ok") | |
| 623 | return { word: "look", note: "green — ready for a look" }; | |
| 624 | if (check.state === "failed") | |
| 625 | return { word: "stopped", note: "the check failed" }; | |
| 626 | if (check.state === "running") | |
| 627 | return { word: "working", note: "checking now" }; | |
| 628 | if (check.state === "queued") | |
| 629 | return { word: "working", note: "queued to check" }; | |
| 630 | if (check.state === "cancelled") | |
| 631 | return { word: "stopped", note: "the check was cancelled" }; | |
| 632 | return { word: "waiting", note: "no deploy of this head yet" }; | |
| 633 | } | |
| 634 | ||
| 590 | 635 | // ---- previews: every open pull's preview, one click away ---- |
| 591 | 636 | |
| 592 | 637 | /** The three words the studio uses for a preview, whatever its check says. */ |
| @@ -811,22 +856,6 @@ export async function studioApp( | ||
| 811 | 856 | (r, idx, all) => all.findIndex((x) => x.issueId === r.issueId) === idx, |
| 812 | 857 | ) |
| 813 | 858 | .slice(0, 6); |
| 814 | const wordBadge = (word: Word): Html => { | |
| 815 | const variant = | |
| 816 | word === "look" || word === "shipped" | |
| 817 | ? "success" | |
| 818 | : word === "stopped" | |
| 819 | ? "danger" | |
| 820 | : word === "working" | |
| 821 | ? "accent" | |
| 822 | : "secondary"; | |
| 823 | return html`<span | |
| 824 | class="badge" | |
| 825 | data-variant="${variant}" | |
| 826 | data-word="${word}" | |
| 827 | >${word === "working" ? icon("loader") : null}${word}</span | |
| 828 | >`; | |
| 829 | }; | |
| 830 | 859 | const nowRows: Html[] = [ |
| 831 | 860 | ...openPulls.map((p) => { |
| 832 | 861 | const check = pullChecks.get(p.id); |
| @@ -1051,6 +1080,132 @@ function avatarOf(username: string): Html { | ||
| 1051 | 1080 | return avatar(username); |
| 1052 | 1081 | } |
| 1053 | 1082 | |
| 1083 | // ---- one pull, in studio words ---- | |
| 1084 | ||
| 1085 | /** | |
| 1086 | * GET /:owner/:name/pulls/:number in studio mode: a change is judged by | |
| 1087 | * what it does, not by its diff. The title and the author's own words (the | |
| 1088 | * opening post, same as dev), who made it and when, the check and the | |
| 1089 | * preview in the studio's five words, the look when one has been taken, the | |
| 1090 | * same decision the asks panel would offer, and the conversation — with the | |
| 1091 | * diff exactly one click away at `?mode=dev`. No diff is computed here, so | |
| 1092 | * this never gets slower as a change gets bigger. | |
| 1093 | */ | |
| 1094 | export async function studioPull( | |
| 1095 | ctx: Ctx, | |
| 1096 | repo: Repo, | |
| 1097 | pull: Pull, | |
| 1098 | record: AppRecord, | |
| 1099 | ): Promise<Response> { | |
| 1100 | const db = ctx.app.db; | |
| 1101 | const base = repoBase(repo); | |
| 1102 | const thread = `${base}/pulls/${pull.number}`; | |
| 1103 | const check = checkSummary(db, pull.id, pull.headOid); | |
| 1104 | const { word, note } = checkWord(check); | |
| 1105 | const decision = decisionsFor(ctx, [repo]).find( | |
| 1106 | (d) => d.pull?.id === pull.id, | |
| 1107 | ); | |
| 1108 | ||
| 1109 | const headRepo = pull.crossRepo | |
| 1110 | ? getRepoById(db, pull.headRepoId) | |
| 1111 | : undefined; | |
| 1112 | const headLabel = headRepo | |
| 1113 | ? `${headRepo.ownerName}/${headRepo.name}:${pull.headBranch}` | |
| 1114 | : pull.headBranch; | |
| 1115 | ||
| 1116 | const head = html`<div class="issue-head"> | |
| 1117 | <h1>${pull.title} <span class="number">#${pull.number}</span></h1> | |
| 1118 | <div class="issue-meta"> | |
| 1119 | ${stateBadge(pullState(pull))} | |
| 1120 | <span | |
| 1121 | >${pull.originAuthor | |
| 1122 | ? originTag(pull.originAuthor, originGlyph(repo)) | |
| 1123 | : userLink(pull.authorName)} | |
| 1124 | opened this ${time(pull.createdAt)}</span | |
| 1125 | > | |
| 1126 | ${when( | |
| 1127 | pull.originUrl, | |
| 1128 | (url) => | |
| 1129 | html`<a class="origin-link" href="${url}" rel="noreferrer noopener" | |
| 1130 | >${icon("external")}view on origin</a | |
| 1131 | >`, | |
| 1132 | )} | |
| 1133 | <span class="mono">${headLabel} → ${pull.baseBranch}</span> | |
| 1134 | </div> | |
| 1135 | </div>`; | |
| 1136 | ||
| 1137 | const checkLine = card( | |
| 1138 | html`<p class="studio-pull-check"> | |
| 1139 | ${wordBadge(word)} <span class="muted small">${note}</span> | |
| 1140 | ${when( | |
| 1141 | check.run, | |
| 1142 | (run) => | |
| 1143 | html`<span class="where-sep" aria-hidden="true">·</span | |
| 1144 | ><a href="${base}/runs/${run.id}" | |
| 1145 | >deploy ${run.commitOid.slice(0, 7)}</a | |
| 1146 | >`, | |
| 1147 | )} | |
| 1148 | </p>`, | |
| 1149 | ); | |
| 1150 | ||
| 1151 | const decisionSection = when(decision, (d) => | |
| 1152 | card( | |
| 1153 | html`<p>${d.says}</p> | |
| 1154 | <div class="bar mt">${decisionActions(ctx, d)}</div>`, | |
| 1155 | { | |
| 1156 | head: html`${icon("alert")} | |
| 1157 | <h2>Decide</h2>`, | |
| 1158 | }, | |
| 1159 | ), | |
| 1160 | ); | |
| 1161 | ||
| 1162 | const talk = conversation(ctx, repo, pull, { | |
| 1163 | openVerb: html`wants to merge <span class="mono">${headLabel}</span> into | |
| 1164 | <span class="mono">${pull.baseBranch}</span> ·`, | |
| 1165 | }); | |
| 1166 | ||
| 1167 | const body = html`${repoHeader(ctx, repo, "pulls", { | |
| 1168 | compact: true, | |
| 1169 | ownTitle: true, | |
| 1170 | })} | |
| 1171 | ${head} ${checkLine} ${decisionSection} | |
| 1172 | ${lookCard(ctx, repo, record, pull, pull.headOid)} | |
| 1173 | <div class="split"> | |
| 1174 | <div> | |
| 1175 | ${talk.node} | |
| 1176 | ${composer(ctx, { | |
| 1177 | action: `${thread}/comments`, | |
| 1178 | stateAction: `${thread}/state`, | |
| 1179 | state: pull.state, | |
| 1180 | locked: pull.mergedAt !== null, | |
| 1181 | kind: "pull", | |
| 1182 | })} | |
| 1183 | </div> | |
| 1184 | <aside>${previewCard(ctx, repo, pull)}</aside> | |
| 1185 | </div> | |
| 1186 | <p class="muted small mt"> | |
| 1187 | <a href="${thread}?mode=dev">${icon("code")}Read the code</a> — every | |
| 1188 | file it changed, line by line. | |
| 1189 | </p> | |
| 1190 | ${decision ? decisionForm(decision) : null} | |
| 1191 | ${liveMount({ | |
| 1192 | repo: base, | |
| 1193 | thread: "pull", | |
| 1194 | number: pull.number, | |
| 1195 | lastCommentId: talk.lastCommentId, | |
| 1196 | })}`; | |
| 1197 | ||
| 1198 | return page({ | |
| 1199 | ctx, | |
| 1200 | title: `${pull.title} · ${repo.name}`, | |
| 1201 | crumbs: crumbsFor(repo), | |
| 1202 | palette: repoPalette(repo), | |
| 1203 | body, | |
| 1204 | styleCss: studioCss, | |
| 1205 | scriptJs: liveClientJs, | |
| 1206 | }); | |
| 1207 | } | |
| 1208 | ||
| 1054 | 1209 | // ---- the studio home ---- |
| 1055 | 1210 | |
| 1056 | 1211 | /** |
test/studio.test.ts +101
| @@ -223,6 +223,107 @@ describe("the studio", () => { | ||
| 223 | 223 | expect(getUserByName(app.db, "scout")).toBeDefined(); |
| 224 | 224 | }); |
| 225 | 225 | |
| 226 | test("a pull on an app renders as a studio page — the preview, the look and the decision, no diff; ?mode=dev still renders the diff", async () => { | |
| 227 | const scout = getUserByName(app.db, "scout")!; | |
| 228 | const branch = await commitToBranch(app, alice, repo, { | |
| 229 | branch: "feature/studio-pull", | |
| 230 | base: "main", | |
| 231 | message: "Studio pull", | |
| 232 | files: [{ path: "STUDIO_PULL.md", content: "a change\n" }], | |
| 233 | }); | |
| 234 | if (!branch.ok) throw new Error(JSON.stringify(branch.error)); | |
| 235 | // Scout is an agent: this pull is the crew's, so a decision applies to | |
| 236 | // it once its check is green (`listOpenAgentPullsVisibleTo`). | |
| 237 | const opened = await openPull(app, scout, repo, { | |
| 238 | title: "Studio pull test", | |
| 239 | body: "Makes the thing faster.", | |
| 240 | baseBranch: "main", | |
| 241 | headBranch: "feature/studio-pull", | |
| 242 | }); | |
| 243 | if (!opened.ok) throw new Error(String(opened.error)); | |
| 244 | const pull = opened.value; | |
| 245 | const run = createRun(app.db, { | |
| 246 | repoId: repo.id, | |
| 247 | kind: "check", | |
| 248 | issueId: pull.id, | |
| 249 | env: `pull-${pull.number}`, | |
| 250 | commitOid: pull.headOid, | |
| 251 | reason: "pull", | |
| 252 | }); | |
| 253 | claimRun(app.db, run.id); | |
| 254 | finishRun(app.db, run.id, { state: "ok", summary: "check passed in 1s" }); | |
| 255 | ||
| 256 | const studio = await ( | |
| 257 | await get(`/alice/notes/pulls/${pull.number}`) | |
| 258 | ).text(); | |
| 259 | expect(studio).toContain("Studio pull test"); | |
| 260 | expect(studio).toContain("Makes the thing faster."); | |
| 261 | expect(studio).toContain(`href="/alice/notes/app/pull-${pull.number}/open"`); | |
| 262 | expect(studio).toContain("data-word=\"look\""); | |
| 263 | expect(studio).toContain("Ship"); | |
| 264 | expect(studio).toContain('id="comment-body"'); | |
| 265 | expect(studio).toContain( | |
| 266 | `href="/alice/notes/pulls/${pull.number}?mode=dev"`, | |
| 267 | ); | |
| 268 | expect(studio).not.toContain("diff-table"); | |
| 269 | ||
| 270 | // The dev page, either by cookie or by the one-off query, is untouched | |
| 271 | // and still carries the diff. | |
| 272 | const devCookie = await ( | |
| 273 | await get(`/alice/notes/pulls/${pull.number}`, { | |
| 274 | cookie: `${cookie}; forge_mode=dev`, | |
| 275 | }) | |
| 276 | ).text(); | |
| 277 | expect(devCookie).toContain("diff-table"); | |
| 278 | expect(devCookie).toContain("STUDIO_PULL.md"); | |
| 279 | ||
| 280 | const devQuery = await ( | |
| 281 | await get(`/alice/notes/pulls/${pull.number}?mode=dev`) | |
| 282 | ).text(); | |
| 283 | expect(devQuery).toContain("diff-table"); | |
| 284 | ||
| 285 | // Ship it, so it stops being an ask on the shared repo's home for the | |
| 286 | // tests that follow. | |
| 287 | const merged = await mergePull(app, alice, repo, pull, "squash"); | |
| 288 | expect(merged.ok).toBe(true); | |
| 289 | }); | |
| 290 | ||
| 291 | test("a pull on a repository that is not an app renders exactly what it renders today, even in studio mode", async () => { | |
| 292 | const plain = await createRepo(app, alice, { | |
| 293 | name: "plainrepo", | |
| 294 | visibility: "private", | |
| 295 | description: "no app here", | |
| 296 | }); | |
| 297 | if (!plain.ok) throw new Error(String(plain.error)); | |
| 298 | const seeded = await commitToBranch(app, alice, plain.value, { | |
| 299 | message: "Start", | |
| 300 | files: [{ path: "README.md", content: "hi\n" }], | |
| 301 | }); | |
| 302 | if (!seeded.ok) throw new Error(JSON.stringify(seeded.error)); | |
| 303 | const branch = await commitToBranch(app, alice, plain.value, { | |
| 304 | branch: "feature/plain", | |
| 305 | base: "main", | |
| 306 | message: "Plain change", | |
| 307 | files: [{ path: "PLAIN.md", content: "plain\n" }], | |
| 308 | }); | |
| 309 | if (!branch.ok) throw new Error(JSON.stringify(branch.error)); | |
| 310 | const opened = await openPull(app, alice, plain.value, { | |
| 311 | title: "Plain pull", | |
| 312 | body: "Nothing special.", | |
| 313 | baseBranch: "main", | |
| 314 | headBranch: "feature/plain", | |
| 315 | }); | |
| 316 | if (!opened.ok) throw new Error(String(opened.error)); | |
| 317 | const pull = opened.value; | |
| 318 | ||
| 319 | const studioCookie = await ( | |
| 320 | await get(`/alice/plainrepo/pulls/${pull.number}`) | |
| 321 | ).text(); | |
| 322 | expect(studioCookie).toContain("diff-table"); | |
| 323 | expect(studioCookie).toContain("PLAIN.md"); | |
| 324 | expect(studioCookie).toContain("Merge pull request"); | |
| 325 | }); | |
| 326 | ||
| 226 | 327 | test("several asks, one decision: a box per ask, one bar, one post; in place when asked for JSON", async () => { |
| 227 | 328 | const scout = getUserByName(app.db, "scout")!; |
| 228 | 329 | // The crew proposes: a pull by scout on its own branch. |
new above
offline