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

A change that cannot land says so, instead of reading green #127

Merged opened this

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

Merged

scout/forge:scout/86-a-change-that-cannot-land-says-so-instea → main 1 commit fork

  • Merged. scout/86-a-change-that-cannot-land-says-so-instea was merged into main as 09ae1ea.

Review

Sign in to review this change.
2 files changed +137 −4
src/web/studio/change.ts +42 −4
@@ -9,7 +9,7 @@
99
1010 import { appsRuntime } from "../../apps/runtime.ts";
1111 import type { AppRecord } from "../../core/apps.ts";
12 import type { Pull } from "../../core/pulls.ts";
12 import { pullStatus, type Pull } from "../../core/pulls.ts";
1313 import type { Repo } from "../../core/repos.ts";
1414 import { checkSummary } from "../../core/runs.ts";
1515 import { repoDir } from "../../git/repos.ts";
@@ -19,6 +19,7 @@ import { html, when } from "../html.ts";
1919 import { page } from "../layout.ts";
2020 import { liveClientJs, liveMount } from "../live.ts";
2121 import {
22 btn,
2223 card,
2324 icon,
2425 originTag,
@@ -34,6 +35,15 @@ import { decisionActions, decisionForm, decisionsFor } from "./asks.ts";
3435 import { studioCss } from "./style.ts";
3536 import { pullWord, wordBadge, type WordCtx } from "./words.ts";
3637
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
3747 /**
3848 * GET /:owner/:name/pulls/:number in studio mode: a change is judged by
3949 * what it does, not by its diff. The title and the author's own words (the
@@ -58,7 +68,20 @@ export async function studioPull(
5868 runtime: appsRuntime(ctx.app),
5969 record,
6070 };
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;
6285 // Before a merge the deploy worth linking is the preview's own check;
6386 // after, it is production's — the preview's run may already be torn
6487 // down, and the word above is judged by production regardless.
@@ -111,10 +134,25 @@ export async function studioPull(
111134 </p>`,
112135 );
113136
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.
114142 const decisionSection = when(decision, (d) =>
115143 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>`,
118156 {
119157 head: html`${icon("alert")}
120158 <h2>Decide</h2>`,
test/studio.test.ts +95
@@ -354,6 +354,101 @@ describe("the studio", () => {
354354 expect(merged.ok).toBe(true);
355355 });
356356
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
357452 test("a pull on a repository that is not an app renders exactly what it renders today, even in studio mode", async () => {
358453 const plain = await createRepo(app, alice, {
359454 name: "plainrepo",

↑↓ navigate ↵ open ⌘↵ its app esc close

Keyboard shortcuts

Docs