Public: anyone can read this; the crew can change it.
A change that cannot land says so, instead of reading green #127
Check · Review · Land · Live done — Landed and live in production.
scout/forge:scout/86-a-change-that-cannot-land-says-so-instea → main 1 commit fork
2 files
changed +137
−4
src/web/studio/change.ts +42 −4
| @@ -9,7 +9,7 @@ | ||
| 9 | 9 | |
| 10 | 10 | import { appsRuntime } from "../../apps/runtime.ts"; |
| 11 | 11 | import type { AppRecord } from "../../core/apps.ts"; |
| 12 | import type { Pull } from "../../core/pulls.ts"; | |
| 12 | import { pullStatus, type Pull } from "../../core/pulls.ts"; | |
| 13 | 13 | import type { Repo } from "../../core/repos.ts"; |
| 14 | 14 | import { checkSummary } from "../../core/runs.ts"; |
| 15 | 15 | import { repoDir } from "../../git/repos.ts"; |
| @@ -19,6 +19,7 @@ import { html, when } from "../html.ts"; | ||
| 19 | 19 | import { page } from "../layout.ts"; |
| 20 | 20 | import { liveClientJs, liveMount } from "../live.ts"; |
| 21 | 21 | import { |
| 22 | btn, | |
| 22 | 23 | card, |
| 23 | 24 | icon, |
| 24 | 25 | originTag, |
| @@ -34,6 +35,15 @@ import { decisionActions, decisionForm, decisionsFor } from "./asks.ts"; | ||
| 34 | 35 | import { studioCss } from "./style.ts"; |
| 35 | 36 | import { pullWord, wordBadge, type WordCtx } from "./words.ts"; |
| 36 | 37 | |
| 38 | /** What a conflicted change's own page says: the base it no longer merges | |
| 39 | * into, the first few files (bounded, so a huge conflict never floods the | |
| 40 | * page), and what fixes it. */ | |
| 41 | function conflictWords(baseBranch: string, files: readonly string[]): string { | |
| 42 | const shown = files.slice(0, 3).join(", "); | |
| 43 | const more = files.length > 3 ? `, and ${files.length - 3} more` : ""; | |
| 44 | return `it conflicts with ${baseBranch} in ${shown}${more} — bring the branch up to date and it can land`; | |
| 45 | } | |
| 46 | ||
| 37 | 47 | /** |
| 38 | 48 | * GET /:owner/:name/pulls/:number in studio mode: a change is judged by |
| 39 | 49 | * what it does, not by its diff. The title and the author's own words (the |
| @@ -58,7 +68,20 @@ export async function studioPull( | ||
| 58 | 68 | runtime: appsRuntime(ctx.app), |
| 59 | 69 | record, |
| 60 | 70 | }; |
| 61 | const { word, note } = await pullWord(wordCtx, pull); | |
| 71 | const { word, note: checkNote } = await pullWord(wordCtx, pull); | |
| 72 | // Whether this change still merges into its base is asked here, on its own | |
| 73 | // page, and nowhere a list of changes renders — a git call per open pull | |
| 74 | // would undo the home's own bound (`askCandidatesFor`). Only an open, | |
| 75 | // unmerged pull can conflict; a merged or closed one already has its word. | |
| 76 | const status = | |
| 77 | pull.mergedAt === null && pull.state !== "closed" | |
| 78 | ? await pullStatus(wordCtx.dir, pull) | |
| 79 | : undefined; | |
| 80 | const conflicted = | |
| 81 | status !== undefined && !status.mergeable && status.conflicts.length > 0; | |
| 82 | const note = conflicted | |
| 83 | ? conflictWords(pull.baseBranch, status.conflicts) | |
| 84 | : checkNote; | |
| 62 | 85 | // Before a merge the deploy worth linking is the preview's own check; |
| 63 | 86 | // after, it is production's — the preview's run may already be torn |
| 64 | 87 | // down, and the word above is judged by production regardless. |
| @@ -111,10 +134,25 @@ export async function studioPull( | ||
| 111 | 134 | </p>`, |
| 112 | 135 | ); |
| 113 | 136 | |
| 137 | // A conflicted change never offers Ship as though it would work — pressing | |
| 138 | // it would only be refused. The button that helps posts to the very same | |
| 139 | // route (`/ship`): `shipSubmit` already turns a conflict there into the | |
| 140 | // crew bringing the branch up to date, so nothing new is written, only said | |
| 141 | // sooner and in plain words. | |
| 114 | 142 | const decisionSection = when(decision, (d) => |
| 115 | 143 | card( |
| 116 | html`<p>${d.says}</p> | |
| 117 | <div class="bar mt">${decisionActions(ctx, d)}</div>`, | |
| 144 | conflicted && d.kind === "look" && d.pull | |
| 145 | ? html`<p>${d.who} finished "${d.pull.title}", but ${conflictWords( | |
| 146 | pull.baseBranch, | |
| 147 | status!.conflicts, | |
| 148 | )}.</p> | |
| 149 | <div class="bar mt"> | |
| 150 | <form method="post" action="${thread}/ship"> | |
| 151 | ${btn("Bring up to date", { size: "sm", variant: "primary" })} | |
| 152 | </form> | |
| 153 | </div>` | |
| 154 | : html`<p>${d.says}</p> | |
| 155 | <div class="bar mt">${decisionActions(ctx, d)}</div>`, | |
| 118 | 156 | { |
| 119 | 157 | head: html`${icon("alert")} |
| 120 | 158 | <h2>Decide</h2>`, |
test/studio.test.ts +95
| @@ -354,6 +354,101 @@ describe("the studio", () => { | ||
| 354 | 354 | expect(merged.ok).toBe(true); |
| 355 | 355 | }); |
| 356 | 356 | |
| 357 | test("a change that no longer merges into its base says so, and offers no Ship — a mergeable green pull is unchanged", async () => { | |
| 358 | const scout = getUserByName(app.db, "scout")!; | |
| 359 | const base1 = await commitToBranch(app, alice, repo, { | |
| 360 | message: "Add a file two branches will fight over", | |
| 361 | files: [{ path: "CONFLICT.md", content: "base\n" }], | |
| 362 | }); | |
| 363 | if (!base1.ok) throw new Error(JSON.stringify(base1.error)); | |
| 364 | const branch = await commitToBranch(app, alice, repo, { | |
| 365 | branch: "feature/conflicting", | |
| 366 | base: "main", | |
| 367 | message: "Scout's edit", | |
| 368 | files: [{ path: "CONFLICT.md", content: "base\nscout\n" }], | |
| 369 | }); | |
| 370 | if (!branch.ok) throw new Error(JSON.stringify(branch.error)); | |
| 371 | // Main moves the same line the other way underneath the open branch. | |
| 372 | const onMain = await commitToBranch(app, alice, repo, { | |
| 373 | message: "Main's edit", | |
| 374 | files: [{ path: "CONFLICT.md", content: "base\nmain\n" }], | |
| 375 | }); | |
| 376 | if (!onMain.ok) throw new Error(JSON.stringify(onMain.error)); | |
| 377 | ||
| 378 | const opened = await openPull(app, scout, repo, { | |
| 379 | title: "Conflicting change", | |
| 380 | body: "This will not merge cleanly.", | |
| 381 | baseBranch: "main", | |
| 382 | headBranch: "feature/conflicting", | |
| 383 | }); | |
| 384 | if (!opened.ok) throw new Error(String(opened.error)); | |
| 385 | const pull = opened.value; | |
| 386 | const run = createRun(app.db, { | |
| 387 | repoId: repo.id, | |
| 388 | kind: "check", | |
| 389 | issueId: pull.id, | |
| 390 | env: `pull-${pull.number}`, | |
| 391 | commitOid: pull.headOid, | |
| 392 | reason: "pull", | |
| 393 | }); | |
| 394 | claimRun(app.db, run.id); | |
| 395 | finishRun(app.db, run.id, { state: "ok", summary: "check passed in 1s" }); | |
| 396 | ||
| 397 | const studio = await ( | |
| 398 | await get(`/alice/notes/pulls/${pull.number}`) | |
| 399 | ).text(); | |
| 400 | // Green check alone never reads as ready: the page says it conflicts, | |
| 401 | // names the file, and says what fixes it — and offers no Ship. | |
| 402 | expect(studio).toContain("conflicts with main"); | |
| 403 | expect(studio).toContain("CONFLICT.md"); | |
| 404 | expect(studio).toContain("bring the branch up to date"); | |
| 405 | expect(studio).not.toContain("Ship"); | |
| 406 | expect(studio).toContain("Bring up to date"); | |
| 407 | // Still one of the five words — no sixth badge invented for this. | |
| 408 | expect(studio).toContain('data-word="look"'); | |
| 409 | ||
| 410 | setIssueState(app, alice, repo, pull, "closed"); | |
| 411 | ||
| 412 | // A mergeable green pull is unchanged by any of this. | |
| 413 | const clean = await commitToBranch(app, alice, repo, { | |
| 414 | branch: "feature/studio-clean", | |
| 415 | base: "main", | |
| 416 | message: "Clean change", | |
| 417 | files: [{ path: "CLEAN.md", content: "clean\n" }], | |
| 418 | }); | |
| 419 | if (!clean.ok) throw new Error(JSON.stringify(clean.error)); | |
| 420 | const openedClean = await openPull(app, scout, repo, { | |
| 421 | title: "Clean change", | |
| 422 | body: "Merges fine.", | |
| 423 | baseBranch: "main", | |
| 424 | headBranch: "feature/studio-clean", | |
| 425 | }); | |
| 426 | if (!openedClean.ok) throw new Error(String(openedClean.error)); | |
| 427 | const cleanPull = openedClean.value; | |
| 428 | const cleanRun = createRun(app.db, { | |
| 429 | repoId: repo.id, | |
| 430 | kind: "check", | |
| 431 | issueId: cleanPull.id, | |
| 432 | env: `pull-${cleanPull.number}`, | |
| 433 | commitOid: cleanPull.headOid, | |
| 434 | reason: "pull", | |
| 435 | }); | |
| 436 | claimRun(app.db, cleanRun.id); | |
| 437 | finishRun(app.db, cleanRun.id, { | |
| 438 | state: "ok", | |
| 439 | summary: "check passed in 1s", | |
| 440 | }); | |
| 441 | const cleanStudio = await ( | |
| 442 | await get(`/alice/notes/pulls/${cleanPull.number}`) | |
| 443 | ).text(); | |
| 444 | expect(cleanStudio).not.toContain("conflicts with main"); | |
| 445 | expect(cleanStudio).toContain("Ship"); | |
| 446 | expect(cleanStudio).toContain("green — ready for a look"); | |
| 447 | ||
| 448 | const merged = await mergePull(app, alice, repo, cleanPull, "squash"); | |
| 449 | expect(merged.ok).toBe(true); | |
| 450 | }); | |
| 451 | ||
| 357 | 452 | test("a pull on a repository that is not an app renders exactly what it renders today, even in studio mode", async () => { |
| 358 | 453 | const plain = await createRepo(app, alice, { |
| 359 | 454 | name: "plainrepo", |
new above
offline