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

Ship it and watch it go: a change's page follows its own merge to production #1229

Merged opened this

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

Merged

scout/forge:scout/1094-ship-it-and-watch-it-go-a-change-s-page → main 2 commits fork 5 threads · 5 unresolved

  • Merged. scout/1094-ship-it-and-watch-it-go-a-change-s-page was merged into main as 7f1a252.

Review

Sign in to review this change.
4 files changed +205 −39
src/http/server.ts +6 −1
@@ -245,7 +245,11 @@ import {
245245 crewPage,
246246 } from "../web/pages/crew.ts";
247247 import { newPullPage, newPullSubmit } from "../web/pages/pulls.ts";
248 import { changePage, pullPage } from "../web/studio/change.ts";
248 import {
249 changePage,
250 pullPage,
251 pullStateFragment,
252 } from "../web/studio/change.ts";
249253 import {
250254 pullCommentSubmit,
251255 pullFilesPage,
@@ -914,6 +918,7 @@ export function buildRouter(): Router<Handler> {
914918 // box, one tab under the change itself.
915919 router.add("GET", "/:owner/:name/pulls/:number/files", pullFilesPage);
916920 router.add("GET", "/:owner/:name/pulls/:number/thread", pullThreadFragment);
921 router.add("GET", "/:owner/:name/pulls/:number/state", pullStateFragment);
917922 router.add("POST", "/:owner/:name/pulls/:number/comments", pullCommentSubmit);
918923 router.add("POST", "/:owner/:name/pulls/:number/review", pullReviewSubmit);
919924 // The verdict on the change as a whole, and the threads it settles.
src/web/live.ts +54
@@ -37,6 +37,12 @@ export interface LiveConfig {
3737 number?: number;
3838 /** Id of the last comment already on the page (for the ?after= fetch). */
3939 lastCommentId?: string;
40 /**
41 * The commit production serves once this change has shipped (its merge
42 * commit, else its head), for a pull page: an `app.serving` for production
43 * at exactly this commit swaps the check line and Preview card in place.
44 */
45 head?: string;
4046 /**
4147 * The studio app page: a deploy run event for this repo fetches
4248 * `<repo>/app/state` and swaps the head card and previews list in place,
@@ -55,6 +61,7 @@ export function liveMount(config: LiveConfig): Html {
5561 data-thread="${config.thread ?? ""}"
5662 data-number="${config.number ?? ""}"
5763 data-last="${config.lastCommentId ?? ""}"
64 data-head="${config.head ?? ""}"
5865 data-app="${config.app ? "1" : ""}"
5966 ></div>
6067 <p
@@ -77,6 +84,7 @@ export const liveClientJs = /* js */ `
7784 var number = cfg.getAttribute('data-number');
7885 var lastId = cfg.getAttribute('data-last') || '';
7986 var isApp = cfg.getAttribute('data-app') === '1';
87 var head = cfg.getAttribute('data-head') || '';
8088 var pill = document.getElementById('live-pill');
8189 var status = document.getElementById('live-status');
8290 var conv = document.getElementById('conversation');
@@ -227,9 +235,55 @@ export const liveClientJs = /* js */ `
227235 });
228236 }
229237
238 // A change's own page: its word, note, deploy link and Preview card follow
239 // the change without a reload. Only three things move them — this pull
240 // merging, a deploy run of its own preview, and production serving at its
241 // head — and a burst of them collapses to one fetch in flight.
242 var pullBusy = false, pullQueued = false;
243 function movesPull(data) {
244 if (thread !== 'pull' || !data || !data.payload) return false;
245 if (data.kind === 'pull.merged') return String(data.payload.number) === String(number);
246 if (data.kind.indexOf('run.') === 0) return data.payload.env === 'pull-' + number;
247 if (data.kind === 'app.serving') {

Edge case, not blocking: if another pull merges before production serves this one's merge commit, production serves a later commit and never === head. The page then keeps the in-flight word until a reload. A follow-up could match on 'production now serves a descendant of head' on the server side.

248 return data.payload.env === 'production' && !!head && data.payload.commitOid === head;

head is fixed at first paint. After a live pull.merged (squash, so mergedOid ≠ headOid), production's app.serving carries the merge commit and this never matches, so the page stays at shipping. Read the commit from the swapped-in fragment (for example data-head on #pull-check) and update head in refreshPull.

249 }
250 return false;
251 }
252 function refreshPull() {
253 if (pullBusy) { pullQueued = true; return; }
254 pullBusy = true;
255 fetch(repo + '/pulls/' + number + '/state', { headers: { accept: 'text/html' } })
256 .then(function (r) { return r.ok ? r.text() : ''; })
257 .then(function (text) {
258 if (!text) return;
259 var holder = document.createElement('div');
260 holder.innerHTML = text;
261 ['pull-check', 'pull-preview'].forEach(function (id) {
262 var mine = document.getElementById(id);
263 var theirs = holder.querySelector('#' + id);
264 if (mine && theirs && mine.innerHTML !== theirs.innerHTML) mine.replaceWith(theirs);
265 });
266 var mark = document.querySelector('#pull-check [data-head]');
267 if (mark && mark.getAttribute('data-head')) head = mark.getAttribute('data-head');
268 var word = textOf(document.querySelector('#pull-check .badge'));
269 if (status && word) status.textContent = word.charAt(0).toUpperCase() + word.slice(1);
270 })
271 .catch(function () {})
272 .then(function () {
273 pullBusy = false;
274 if (pullQueued) { pullQueued = false; refreshPull(); }
275 });
276 }
277
230278 function onEvent(e) {
231279 var data;
232280 try { data = JSON.parse(e.data); } catch (_) { return; }
281 if (movesPull(data)) {
282 refreshPull();
283 // A merge changes more than the word (the decision, the composer):
284 // it still offers the refresh. A run or a serving moves only the swap.
285 if (data.kind !== 'pull.merged') return;
286 }
233287 if (isDeploy(data) || isServing(data)) { refreshApp(); return; }
234288 if (relevant(data) && conv && (data.kind === thread + '.commented')) {
235289 appendNew();
src/web/studio/change.ts +90 −38
@@ -39,7 +39,7 @@ import {
3939 } from "../chrome.ts";
4040 import { html, when, type Html } from "../html.ts";
4141 import type { IconName as Glyph } from "../icons.ts";
42 import { page, redirect } from "../layout.ts";
42 import { fragment, page, redirect } from "../layout.ts";
4343 import { renderMarkdown } from "../markdown.ts";
4444 import { liveClientJs, liveMount } from "../live.ts";
4545 import {
@@ -262,17 +262,13 @@ export async function pullPage(ctx: Ctx): Promise<Response> {
262262 export const changePage = pullPage;
263263
264264 /**
265 * A change is judged by what it does, not by its diff. The title and the
266 * author's own words (the opening post), who made it and when, the check
267 * and the preview in the studio's five words, the look when one has been
268 * taken, the same decision the asks panel would offer, and the
269 * conversation — with the diff one tab deeper, at Files.
265 * What a change's page says about where it stands, drawn once: the word and
266 * its note, the deploy worth linking, and the Preview card. The page's first
267 * paint and `pullStateFragment` both come through here, so the two can never
268 * disagree. Bounded the way the page is: `pullWord` and one `pullStatus`, no
269 * diff, no query per comment.
270270 */
271 async function changeConversation(
272 ctx: Ctx,
273 repo: Repo,
274 pull: Pull,
275 ): Promise<Response> {
271 async function pullPanels(ctx: Ctx, repo: Repo, pull: Pull) {
276272 const record: AppRecord | undefined = getAppByRepo(ctx.app.db, repo.id);
277273 const db = ctx.app.db;
278274 const base = repoBase(repo);
@@ -304,12 +300,6 @@ async function changeConversation(
304300 requireReview: repo.requireReview,
305301 })
306302 : undefined;
307 // A change that loosens the policy is a person's call: said here, above
308 // the decision, as well as on the Files step beside the merge box.
309 const wall =
310 status === undefined
311 ? undefined
312 : await wallOf(wordCtx.dir, status.baseOid, status.headOid);
313303 const conflicted =
314304 status !== undefined && !status.mergeable && status.conflicts.length > 0;
315305 // A reader's head runs nothing until a writer approves it (#777): say
@@ -338,6 +328,86 @@ async function changeConversation(
338328 if (production?.runId)
339329 deploy = { id: production.runId, commitOid: production.commitOid };
340330 }
331
332 const checkLine = card(
333 html`<p
334 class="studio-pull-check"
335 data-head="${pull.mergedOid ?? pull.headOid}"
336 >
337 ${wordBadge(word)} <span class="muted small">${note}</span>
338 ${when(
339 tellsAnswered(db, repo.id, [pull]).get(pull.number),
340 (t) => html`<span class="where-sep" aria-hidden="true">·</span
341 >${tellLine(base, t)}`,
342 )}
343 ${when(
344 deploy,
345 (d) =>
346 html`<span class="where-sep" aria-hidden="true">·</span
347 ><a href="${base}/runs/${d.id}"
348 >deploy ${d.commitOid.slice(0, 7)}</a
349 >`,
350 )}
351 </p>`,
outdated

Nit: there's a double blank line here.

352 );
353
354 const preview = when(record, () => previewCard(ctx, repo, pull));
355 return {
356 record,
357 db,
358 base,
359 thread,
360 wordCtx,
361 word,
362 status,
363 conflicted,
364 note,
365 checkLine,
366 preview,
367 };
368 }
369
370 /**
371 * GET /:owner/:name/pulls/:number/state — the check line and the Preview
372 * card, bare. The page's live client fetches it when the change merges, when
373 * its preview deploys or production serves, and swaps the two in place.
374 * Authorized as the page is: a viewer who cannot read it gets the same 404.
375 */
376 export async function pullStateFragment(ctx: Ctx): Promise<Response> {
377 const found = pullContext(ctx);
378 if (found instanceof Response) return fragment(html``, { status: 404 });
379 const { checkLine, preview } = await pullPanels(
380 ctx,
381 found.repo,
382 found.pull,
383 );
384 return fragment(
385 html`<div id="pull-check">${checkLine}</div>
386 <div id="pull-preview">${preview}</div>`,
387 { request: ctx.request },
388 );
389 }
390
391 /**
392 * A change is judged by what it does, not by its diff. The title and the
393 * author's own words (the opening post), who made it and when, the check
394 * and the preview in the studio's five words, the look when one has been
395 * taken, the same decision the asks panel would offer, and the
396 * conversation — with the diff one tab deeper, at Files.
397 */
398 async function changeConversation(
399 ctx: Ctx,
400 repo: Repo,
401 pull: Pull,
402 ): Promise<Response> {
403 const { record, db, base, thread, wordCtx, word, status, conflicted, note, checkLine, preview } =

Nit: this destructuring is one 100+ column line that a formatter would wrap. Also, pullPanels returns eleven loose fields. A named return type, or passing back only what changeConversation doesn't already have, would keep the boundary readable.

404 await pullPanels(ctx, repo, pull);
405 // A change that loosens the policy is a person's call: said here, above
406 // the decision, as well as on the Files step beside the merge box.
407 const wall =
408 status === undefined
409 ? undefined
410 : await wallOf(wordCtx.dir, status.baseOid, status.headOid);
341411 const crewDecision = decisionsFor(ctx, [repo], { evenDismissed: true }).find(
342412 (d) => d.pull?.id === pull.id,
343413 );
@@ -397,25 +467,6 @@ async function changeConversation(
397467 ? note
398468 : undefined;
399469
400 const checkLine = card(
401 html`<p class="studio-pull-check">
402 ${wordBadge(word)} <span class="muted small">${note}</span>
403 ${when(
404 tellsAnswered(db, repo.id, [pull]).get(pull.number),
405 (t) => html`<span class="where-sep" aria-hidden="true">·</span
406 >${tellLine(base, t)}`,
407 )}
408 ${when(
409 deploy,
410 (d) =>
411 html`<span class="where-sep" aria-hidden="true">·</span
412 ><a href="${base}/runs/${d.id}"
413 >deploy ${d.commitOid.slice(0, 7)}</a
414 >`,
415 )}
416 </p>`,
417 );
418
419470 // A conflicted change never offers Ship as though it would work — pressing
420471 // it would only be refused. The button that helps posts to the very same
421472 // route (`/ship`): `shipSubmit` already turns a conflict there into the
@@ -497,7 +548,7 @@ async function changeConversation(
497548 ownTitle: true,
498549 })}
499550 ${changeHead(ctx, repo, pull)} ${changeTabs(repo, pull, "change")}
500 ${checkLine} ${holdCard(ctx, repo, pull, { act: true })}
551 <div id="pull-check">${checkLine}</div> ${holdCard(ctx, repo, pull, { act: true })}
501552 ${when(wall, (w) => wallCard(w))} ${decisionSection}
502553 ${takeBackSection}
503554 ${when(record, (r) => lookCard(ctx, repo, r, pull, pull.headOid))}
@@ -518,7 +569,7 @@ async function changeConversation(
518569 crewNote: threadNote(ctx.app.db, repo, pull),
519570 })}
520571 </div>
521 <aside>${when(record, () => previewCard(ctx, repo, pull))}</aside>
572 <aside><div id="pull-preview">${preview}</div></aside>
522573 </div>
523574 ${decision ? decisionForm(decision) : null}
524575 ${liveMount({
@@ -526,6 +577,7 @@ async function changeConversation(
526577 thread: "pull",
527578 number: pull.number,
outdated

This is right only for a page drawn after the merge. Render the same value inside pullPanels's check line so the fragment carries it too, and test that /pulls/:n/state names the squash merge commit after mergePull.

528579 lastCommentId: talk.lastCommentId,
580 head: pull.mergedOid ?? pull.headOid,
529581 })}`;
530582
531583 return page({
test/studio.test.ts +55
@@ -59,6 +59,7 @@ import {
5959 STOPPED_SUMMARY,
6060 type Run,
6161 } from "../src/core/runs.ts";
62 import { liveClientJs } from "../src/web/live.ts";
6263 import { createUser, getUserByName, type User } from "../src/core/users.ts";
6364 import type { Decider } from "../src/crew/decide.ts";
6465 import { CHECK_FAILED_LIMIT } from "../src/crew/dispatch.ts";
@@ -3465,6 +3466,60 @@ describe("the app's live fragment: /:owner/:name/app/state", () => {
34653466 expect(refusedPage.status).toBe(404);
34663467 });
34673468
3469 test("a change's page has a state fragment of its own: its current word, drawn by the page's own function, refused to whoever the page refuses, and named by the client script (#1094)", async () => {
3470 const branch = await commitToBranch(app, getUserByName(app.db, "alice")!, repo, {
3471 branch: "feature/ship-it",
3472 base: "main",
3473 message: "Ship it",
3474 files: [{ path: "SHIP.md", content: "ship\n" }],
3475 });
3476 if (!branch.ok) throw new Error(JSON.stringify(branch.error));
3477 const opened = await openPull(app, getUserByName(app.db, "alice")!, repo, {
3478 title: "Ship it",
3479 body: "",
3480 baseBranch: "main",
3481 headBranch: "feature/ship-it",
3482 });
3483 if (!opened.ok) throw new Error(String(opened.error));
3484 const n = opened.value.number;
3485
3486 const fragment = await get(`/alice/vault/pulls/${n}/state`, owner);
3487 expect(fragment.status).toBe(200);
3488 expect(fragment.headers.get("x-content-type-options")).toBe("nosniff");
3489 expect(fragment.headers.get("referrer-policy")).toBe("same-origin");
3490 const text = await fragment.text();
3491 expect(text).toContain('id="pull-check"');
3492 expect(text).toContain('id="pull-preview"');
3493 expect(text).toContain("studio-pull-check");
3494 // The page's first paint carries the very same check line.
3495 const pageText = await (await get(`/alice/vault/pulls/${n}`, owner)).text();
3496 const line = (t: string) =>
3497 t.match(/<p[^>]*studio-pull-check[^>]*>[\s\S]*?<\/p>/)?.[0];
3498 expect(line(text)).toBeDefined();
3499 expect(line(pageText)).toBe(line(text));
3500 expect(pageText).toContain('id="pull-check"');
3501 expect(pageText).toContain('data-head="');
3502
3503 expect((await get(`/alice/vault/pulls/${n}/state`, stranger)).status).toBe(404);
3504 expect((await get(`/alice/vault/pulls/999/state`, owner)).status).toBe(404);
3505
3506 const merged = await mergePull(app, getUserByName(app.db, "alice")!, repo, opened.value, "squash");
3507 if (!merged.ok) throw new Error(String(merged.error));
3508 const after = await (await get(`/alice/vault/pulls/${n}/state`, owner)).text();
3509 expect(line(after)).not.toBe(line(text));
3510 // The squash lands a new commit; the fragment names it, so a page that
3511 // saw the merge live can match production's serving against it.
3512 const headOf = (t: string) => t.match(/data-head="([0-9a-f]{40})"/)?.[1];
3513 expect(headOf(text)).toBeDefined();
3514 expect(headOf(after)).toBeDefined();
3515 expect(headOf(after)).not.toBe(headOf(text));
3516 expect(liveClientJs).toContain("#pull-check [data-head]");
3517
3518 expect(liveClientJs).toContain("'/pulls/' + number + '/state'");
3519 expect(liveClientJs).toContain("'pull.merged'");
3520 expect(liveClientJs).toContain("'app.serving'");
3521 });
3522
34683523 test("Now has an address of its own: the page's region names it, the fragment answers with the same region, and unchanged bytes come back 304", async () => {
34693524 const page = await (await get("/alice/vault", owner)).text();
34703525 expect(page).toContain('id="live-now"');

↑↓ navigate ↵ open ⌘↵ its app esc close

Keyboard shortcuts

Docs