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

A change opens in studio words, with the diff one click away #52

Merged opened this

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

Merged

scout/forge:scout/43-a-change-opens-in-studio-words-with-the → main 1 commit fork

  • Merged. scout/43-a-change-opens-in-studio-words-with-the was merged into main as 333eda6.

Review

Sign in to review this change.
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
8383 who can write here; its title opens the transcript, where the agent can be
8484 steered mid-run (see `docs/APPS.md`, _Runs_).
8585
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
86102 ## The home
87103
88104 `/` 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";
9696 import { lookCard } from "./looks.ts";
9797 import { pullState } from "./pulls.ts";
9898 import { crewActivityCard } from "./runs.ts";
99 import { studioPull } from "./studio.ts";
99100
100101 /** The pull and its repo, or the response that refuses to say why not. */
101102 function pullContext(ctx: Ctx): { repo: Repo; pull: Pull } | Response {
@@ -666,6 +667,15 @@ export async function pullPage(ctx: Ctx): Promise<Response> {
666667 const found = pullContext(ctx);
667668 if (found instanceof Response) return found;
668669 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 }
669679 const dir = repoDir(ctx.app.reposRoot, repo.ownerName, repo.name);
670680 const base = repoBase(repo);
671681 const thread = `${base}/pulls/${pull.number}`;
src/web/pages/studio.ts +173 −18
@@ -42,15 +42,22 @@ import {
4242 openPullsClosing,
4343 type Pull,
4444 } 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";
4651 import { listReviews } from "../../core/reviews.ts";
4752 import {
4853 activeRunFor,
4954 automaticRunsSince,
55 checkSummary,
5056 latestAgentRunsForRepos,
5157 latestRunsForIssues,
5258 listActiveRuns,
5359 listRuns,
60 type CheckSummary,
5461 type Run,
5562 } from "../../core/runs.ts";
5663 import { getUserById, getUsersByIds, type User } from "../../core/users.ts";
@@ -94,15 +101,22 @@ import {
94101 envPhrase,
95102 icon,
96103 listCard,
104 originTag,
97105 pill,
98106 row,
107 sha,
108 stateBadge,
99109 time,
110 userLink,
100111 userTag,
101112 type Tone,
102113 } from "../ui.ts";
103114 import { looksForApps } from "../../core/looks.ts";
104 import { lookNow, lookThumb, lookUrl } from "./looks.ts";
115 import { lookCard, lookNow, lookThumb, lookUrl } from "./looks.ts";
105116 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";
106120
107121 // ---- decisions: what asks a person ----
108122
@@ -587,6 +601,37 @@ const WORD_ICON: Record<Word, Parameters<typeof icon>[0]> = {
587601 planned: "map",
588602 };
589603
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
590635 // ---- previews: every open pull's preview, one click away ----
591636
592637 /** The three words the studio uses for a preview, whatever its check says. */
@@ -811,22 +856,6 @@ export async function studioApp(
811856 (r, idx, all) => all.findIndex((x) => x.issueId === r.issueId) === idx,
812857 )
813858 .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 };
830859 const nowRows: Html[] = [
831860 ...openPulls.map((p) => {
832861 const check = pullChecks.get(p.id);
@@ -1051,6 +1080,132 @@ function avatarOf(username: string): Html {
10511080 return avatar(username);
10521081 }
10531082
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
10541209 // ---- the studio home ----
10551210
10561211 /**
test/studio.test.ts +101
@@ -223,6 +223,107 @@ describe("the studio", () => {
223223 expect(getUserByName(app.db, "scout")).toBeDefined();
224224 });
225225
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
226327 test("several asks, one decision: a box per ask, one bar, one post; in place when asked for JSON", async () => {
227328 const scout = getUserByName(app.db, "scout")!;
228329 // The crew proposes: a pull by scout on its own branch.

↑↓ navigate ↵ open ⌘↵ its app esc close

Keyboard shortcuts

Docs