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
Check · Review · Land · Live done — Landed and live in production.
scout/forge:scout/1094-ship-it-and-watch-it-go-a-change-s-page → main 2 commits fork 5 threads · 5 unresolved
src/http/server.ts +6 −1
| @@ -245,7 +245,11 @@ import { | ||
| 245 | 245 | crewPage, |
| 246 | 246 | } from "../web/pages/crew.ts"; |
| 247 | 247 | 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"; | |
| 249 | 253 | import { |
| 250 | 254 | pullCommentSubmit, |
| 251 | 255 | pullFilesPage, |
| @@ -914,6 +918,7 @@ export function buildRouter(): Router<Handler> { | ||
| 914 | 918 | // box, one tab under the change itself. |
| 915 | 919 | router.add("GET", "/:owner/:name/pulls/:number/files", pullFilesPage); |
| 916 | 920 | router.add("GET", "/:owner/:name/pulls/:number/thread", pullThreadFragment); |
| 921 | router.add("GET", "/:owner/:name/pulls/:number/state", pullStateFragment); | |
| 917 | 922 | router.add("POST", "/:owner/:name/pulls/:number/comments", pullCommentSubmit); |
| 918 | 923 | router.add("POST", "/:owner/:name/pulls/:number/review", pullReviewSubmit); |
| 919 | 924 | // 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 { | ||
| 37 | 37 | number?: number; |
| 38 | 38 | /** Id of the last comment already on the page (for the ?after= fetch). */ |
| 39 | 39 | 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; | |
| 40 | 46 | /** |
| 41 | 47 | * The studio app page: a deploy run event for this repo fetches |
| 42 | 48 | * `<repo>/app/state` and swaps the head card and previews list in place, |
| @@ -55,6 +61,7 @@ export function liveMount(config: LiveConfig): Html { | ||
| 55 | 61 | data-thread="${config.thread ?? ""}" |
| 56 | 62 | data-number="${config.number ?? ""}" |
| 57 | 63 | data-last="${config.lastCommentId ?? ""}" |
| 64 | data-head="${config.head ?? ""}" | |
| 58 | 65 | data-app="${config.app ? "1" : ""}" |
| 59 | 66 | ></div> |
| 60 | 67 | <p |
| @@ -77,6 +84,7 @@ export const liveClientJs = /* js */ ` | ||
| 77 | 84 | var number = cfg.getAttribute('data-number'); |
| 78 | 85 | var lastId = cfg.getAttribute('data-last') || ''; |
| 79 | 86 | var isApp = cfg.getAttribute('data-app') === '1'; |
| 87 | var head = cfg.getAttribute('data-head') || ''; | |
| 80 | 88 | var pill = document.getElementById('live-pill'); |
| 81 | 89 | var status = document.getElementById('live-status'); |
| 82 | 90 | var conv = document.getElementById('conversation'); |
| @@ -227,9 +235,55 @@ export const liveClientJs = /* js */ ` | ||
| 227 | 235 | }); |
| 228 | 236 | } |
| 229 | 237 | |
| 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') { | |
| 248 | return data.payload.env === 'production' && !!head && data.payload.commitOid === head; | |
|
||
| 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 | ||
| 230 | 278 | function onEvent(e) { |
| 231 | 279 | var data; |
| 232 | 280 | 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 | } | |
| 233 | 287 | if (isDeploy(data) || isServing(data)) { refreshApp(); return; } |
| 234 | 288 | if (relevant(data) && conv && (data.kind === thread + '.commented')) { |
| 235 | 289 | appendNew(); |
src/web/studio/change.ts +90 −38
| @@ -39,7 +39,7 @@ import { | ||
| 39 | 39 | } from "../chrome.ts"; |
| 40 | 40 | import { html, when, type Html } from "../html.ts"; |
| 41 | 41 | import type { IconName as Glyph } from "../icons.ts"; |
| 42 | import { page, redirect } from "../layout.ts"; | |
| 42 | import { fragment, page, redirect } from "../layout.ts"; | |
| 43 | 43 | import { renderMarkdown } from "../markdown.ts"; |
| 44 | 44 | import { liveClientJs, liveMount } from "../live.ts"; |
| 45 | 45 | import { |
| @@ -262,17 +262,13 @@ export async function pullPage(ctx: Ctx): Promise<Response> { | ||
| 262 | 262 | export const changePage = pullPage; |
| 263 | 263 | |
| 264 | 264 | /** |
| 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. | |
| 270 | 270 | */ |
| 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) { | |
| 276 | 272 | const record: AppRecord | undefined = getAppByRepo(ctx.app.db, repo.id); |
| 277 | 273 | const db = ctx.app.db; |
| 278 | 274 | const base = repoBase(repo); |
| @@ -304,12 +300,6 @@ async function changeConversation( | ||
| 304 | 300 | requireReview: repo.requireReview, |
| 305 | 301 | }) |
| 306 | 302 | : 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); | |
| 313 | 303 | const conflicted = |
| 314 | 304 | status !== undefined && !status.mergeable && status.conflicts.length > 0; |
| 315 | 305 | // A reader's head runs nothing until a writer approves it (#777): say |
| @@ -338,6 +328,86 @@ async function changeConversation( | ||
| 338 | 328 | if (production?.runId) |
| 339 | 329 | deploy = { id: production.runId, commitOid: production.commitOid }; |
| 340 | 330 | } |
| 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, |
||
| 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); | |
| 341 | 411 | const crewDecision = decisionsFor(ctx, [repo], { evenDismissed: true }).find( |
| 342 | 412 | (d) => d.pull?.id === pull.id, |
| 343 | 413 | ); |
| @@ -397,25 +467,6 @@ async function changeConversation( | ||
| 397 | 467 | ? note |
| 398 | 468 | : undefined; |
| 399 | 469 | |
| 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 | ||
| 419 | 470 | // A conflicted change never offers Ship as though it would work — pressing |
| 420 | 471 | // it would only be refused. The button that helps posts to the very same |
| 421 | 472 | // route (`/ship`): `shipSubmit` already turns a conflict there into the |
| @@ -497,7 +548,7 @@ async function changeConversation( | ||
| 497 | 548 | ownTitle: true, |
| 498 | 549 | })} |
| 499 | 550 | ${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 })} | |
| 501 | 552 | ${when(wall, (w) => wallCard(w))} ${decisionSection} |
| 502 | 553 | ${takeBackSection} |
| 503 | 554 | ${when(record, (r) => lookCard(ctx, repo, r, pull, pull.headOid))} |
| @@ -518,7 +569,7 @@ async function changeConversation( | ||
| 518 | 569 | crewNote: threadNote(ctx.app.db, repo, pull), |
| 519 | 570 | })} |
| 520 | 571 | </div> |
| 521 | <aside>${when(record, () => previewCard(ctx, repo, pull))}</aside> | |
| 572 | <aside><div id="pull-preview">${preview}</div></aside> | |
| 522 | 573 | </div> |
| 523 | 574 | ${decision ? decisionForm(decision) : null} |
| 524 | 575 | ${liveMount({ |
| @@ -526,6 +577,7 @@ async function changeConversation( | ||
| 526 | 577 | thread: "pull", |
| 527 | 578 | number: pull.number, |
outdated
This is right only for a page drawn after the merge. Render the same value inside |
||
| 528 | 579 | lastCommentId: talk.lastCommentId, |
| 580 | head: pull.mergedOid ?? pull.headOid, | |
| 529 | 581 | })}`; |
| 530 | 582 | |
| 531 | 583 | return page({ |
test/studio.test.ts +55
| @@ -59,6 +59,7 @@ import { | ||
| 59 | 59 | STOPPED_SUMMARY, |
| 60 | 60 | type Run, |
| 61 | 61 | } from "../src/core/runs.ts"; |
| 62 | import { liveClientJs } from "../src/web/live.ts"; | |
| 62 | 63 | import { createUser, getUserByName, type User } from "../src/core/users.ts"; |
| 63 | 64 | import type { Decider } from "../src/crew/decide.ts"; |
| 64 | 65 | import { CHECK_FAILED_LIMIT } from "../src/crew/dispatch.ts"; |
| @@ -3465,6 +3466,60 @@ describe("the app's live fragment: /:owner/:name/app/state", () => { | ||
| 3465 | 3466 | expect(refusedPage.status).toBe(404); |
| 3466 | 3467 | }); |
| 3467 | 3468 | |
| 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 | ||
| 3468 | 3523 | 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 () => { |
| 3469 | 3524 | const page = await (await get("/alice/vault", owner)).text(); |
| 3470 | 3525 | expect(page).toContain('id="live-now"'); |
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.