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

Every action on a pull page is also in the command palette (Cmd-K) #8832

Merged opened this

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

Merged

scout/forge:scout/8827-every-action-on-a-pull-page-is-also-in-t → main 6 commits fork 4 threads · 4 unresolved

  • Merged. scout/8827-every-action-on-a-pull-page-is-also-in-t was merged into main as c0e96a1.

Review

Sign in to review this change.
8 files changed +365 −58
docs/features/cmd-k-pull-actions.md added +22
@@ -0,0 +1,22 @@
1 # Cmd-K: a pull's actions
2
3 On a pull page, either tab, press Cmd-K (Ctrl-K) and the palette lists what the page lets you do, under "This pull". Type a verb ("close", "check", "reopen") and press Enter. Every row is something the page itself offers you; a reader is never shown a row the server would refuse.
4
5 Both tabs build their rows from one function, `pullRoster` (`src/web/studio/pull_roster.ts`), from the values the page already draws its controls by.
6
7 **On both tabs**
8 - Conversation, Files, and for a pull from this repository Branch and Commits.
9 - Comment, for anyone signed in, until it is merged.
10 - Reopen pull request, on a closed, unmerged pull, for its author or a writer. It posts the page's own Reopen form (`state=open` to `…/state`). The page draws its Reopen button by the same rule.
11
12 **On the Conversation tab** the page folds its actions away, so these rows are links that land on them: Ship (read "Bring up to date" on a conflict), Check again, Hold, Close pull request, Edit and Take it back, each shown by the rule the page shows its control by.
13
14 **On the Files tab**
15 - Review, for anyone signed in, while it is open.
16 - Assign, and Labels when the repository has labels, for a writer.
17 - Check again, for a writer while it is open: posts to `…/checks`.
18 - Merge pull request: posts a merge commit to `…/merge`. It is shown only when the merge box's button is enabled (a writer, git, review and checks agree). There is no Squash row; squash from the merge box.
19
20 **How a row posts.** A row that changes something carries the fields to post; the palette builds a hidden same-origin form with them and submits it, exactly as the page's button would, so the server's same-origin and permission checks apply unchanged. It is never followed as a GET, and it is not kept in Recent, where replaying it would be one.
21
22 Not in the palette: land-past, resolving a review thread and per-line review comments. They need typed input or a place in the diff, so they stay on the page.
src/web/client.ts +23
@@ -1756,6 +1756,7 @@ const shellCore = /* js */ `
17561756 if (i.m === 1) r.m = 1;
17571757 if (i.x) r.x = String(i.x);
17581758 if (i.o && String(i.o).charAt(0) === '/') r.o = String(i.o);
1759 if (i.p && typeof i.p === 'object' && r.h.charAt(0) === '/') r.p = i.p;
17591760 return r;
17601761 });
17611762 var tellApp = (loadIndex().tell || {}).app;
@@ -1781,6 +1782,7 @@ const shellCore = /* js */ `
17811782 a.setAttribute('aria-selected', 'false');
17821783 // Same-origin paths only: the index is ours, and this keeps it so.
17831784 a.setAttribute('href', r.h.charAt(0) === '/' ? r.h : '/');
1785 if (r.p) a.setAttribute('data-post', JSON.stringify(r.p));
17841786 var t = doc.createElement('span');
17851787 t.className = 'grow';
17861788 t.textContent = r.t;
@@ -1980,6 +1982,27 @@ const shellCore = /* js */ `
19801982 tellFromHere(tellApp, a.getAttribute('data-tell'));
19811983 return;
19821984 }
1985 if (a.hasAttribute('data-post')) {
1986 // A mutation posts the page's own form; it is never followed as a GET.
1987 e.preventDefault();
1988 close();
1989 var pf = doc.createElement('form');
1990 pf.method = 'post'; pf.action = a.getAttribute('href') || '/';
1991 var fields = {};
1992 try { fields = JSON.parse(a.getAttribute('data-post') || '{}'); } catch (_) {}
1993 Object.keys(fields).forEach(function (k) {
1994 var hid = doc.createElement('input');
1995 hid.type = 'hidden'; hid.name = k; hid.value = String(fields[k]);
1996 pf.appendChild(hid);
1997 });
1998 var go = doc.createElement('button');
1999 go.type = 'submit';
2000 pf.appendChild(go);
2001 pf.hidden = true;
2002 doc.body.appendChild(pf);
2003 go.click();
2004 return;
2005 }
19832006 remember(shown[i]);
19842007 var h = a.getAttribute('href') || '';
19852008 if (/^\\/[^\\/?#]+\\/[^\\/?#]+\\/tell\\?from=/.test(h) && !e.metaKey && !e.ctrlKey && !e.shiftKey) {
src/web/layout.ts +2
@@ -68,6 +68,8 @@ export interface PaletteItem {
6868 x?: string;
6969 o?: string;
7070 m?: 1;
71 /** A mutation: the palette posts these fields to `h`, as the page's own form does. */
72 p?: Record<string, string>;
7173 }
7274
7375 export interface PageOptions {
src/web/pages/pull.ts +28 −2
@@ -8,6 +8,7 @@
88 * it needs.
99 */
1010
11 import { pullRoster } from "../studio/pull_roster.ts";
1112 import { askCheck } from "../../checks/again.ts";
1213 import { addCommentThrough } from "../../actions/issues.ts";
1314 import { COMMENT_EMPTY, COMMENT_NOT_POSTED } from "../../actions/comment_words.ts";
@@ -477,7 +478,8 @@ function mergeBox(
477478 icon("x"),
478479 "Closed without merging.",
479480 html`Nothing was applied to <span class="mono">${pull.baseBranch}</span>.`,
480 when(ctx.auth.user !== undefined, () => reopen),
481 // Reopening is its author's or a writer's (`setIssueState`), as in the palette.
482 when(ctx.auth.user !== undefined && (ctx.auth.user.id === pull.authorId || mayMerge), () => reopen),
481483 );
482484 }
483485
@@ -1022,7 +1024,31 @@ export async function pullFilesPage(ctx: Ctx): Promise<Response> {
10221024 ctx,
10231025 title: `Files · ${pull.title} · ${repo.name}`,
10241026 crumbs: crumbsFor(repo),
outdated

Roster is wired on the Files tab only; the Conversation tab uses a different list. One roster, both tabs.

1025 palette: repoPalette(ctx, repo),
1027 palette: [
1028 ...repoPalette(ctx, repo),
1029 ...pullRoster({
1030 base,
1031 number: pull.number,
1032 headBranch: pull.headBranch,
1033 crossRepo: pull.crossRepo,
1034 signedIn: ctx.auth.user !== undefined,
1035 writer: mayMerge,
1036 state: pull.state === "closed" ? "closed" : "open",
1037 merged: pull.mergedAt !== null,
1038 // The merge box's enabled button, by the same gates in the same order.
1039 labels: allLabels.length > 0,
1040 mayState: ctx.auth.user !== undefined && (ctx.auth.user.id === pull.authorId || mayMerge),
1041 mergeReady:
1042 mayMerge &&
1043 pull.state === "open" &&
1044 pull.mergedAt === null &&
1045 repo.origin === null &&
1046 pull.originHeadRepo === null &&
1047 status.mergeable &&
1048 !(status.reviews !== null && !status.reviews.mergeable) &&
1049 !checksHold(repo.requireChecks, check.state, stale),
1050 }),
1051 ],
10261052 body,
10271053 styleCss: labelCss([...allLabels, ...applied]),
10281054 scriptJs: `${liveClientJs}\n${liveRegionsJs}`,
src/web/studio/change.ts +16 −43
@@ -88,6 +88,7 @@ import { STEP_MEANS, STEPS, stepOfPull, stepWord, type PathStep } from "../../fl
8888 import { changeHref } from "./nouns.ts";
8989 import { pullWord, servingHistory, stateDot, wordDot, type WordCtx } from "./words.ts";
9090 import { getHold } from "../../core/holds.ts";
91 import { pullRoster } from "./pull_roster.ts";
9192
9293 /** The pull and its repo, or the response that refuses to say why not. */
9394 export function pullContext(ctx: Ctx): { repo: Repo; pull: Pull } | Response {
@@ -207,41 +208,6 @@ function reviewEntry(
207208 };
208209 }
209210
210 /**
211 * Everything the page does, as palette rows (#8773): the page shows its one
212 * primary action and folds the rest away, so each of them is a keystroke
213 * from Cmd-K. Only what this viewer may do is offered.
214 */
215 export function pullActions(o: {
216 base: string;
217 name: string;
218 number: number;
219 open: boolean;
220 writer: boolean;
221 mayClose: boolean;
222 shippable: boolean;
223 /** Shipping would only be refused: the verb is the one that fixes it. */
224 conflicted?: boolean;
225 takeBack: boolean;
226 mayEdit: boolean;
227 }): { t: string; h: string; c: string }[] {
228 const c = "This pull request";
229 const at = `${o.base}/pulls/${o.number}`;
230 const row = (verb: string, h: string) => ({ t: `${o.name} #${o.number} — ${verb}`, h, c });
231 return [
232 row("Conversation", at),
233 row("Files", `${at}/files`),
234 // The where strip rests on the repository's home; one step from here.
235 { t: `${o.name} — Where it is read from`, h: o.base, c },
236 ...(o.shippable ? [row(o.conflicted ? "Bring up to date" : "Ship", `${at}#decide`)] : []),
237 ...(o.open && o.writer ? [row("Check again", `${at}#live-checks`), row("Hold", `${at}?more=1#hold`)] : []),
238 ...(o.open ? [row("Comment", `${at}#comment-body`)] : []),
239 ...(o.open && o.mayClose ? [row("Close pull request", `${at}#comment-body`)] : []),
240 ...(o.mayEdit ? [row("Edit", `${at}#edit`)] : []),
241 ...(o.takeBack ? [row("Take it back", `${at}?more=1#take-back`)] : []),
242 ];
243 }
244
245211 /** A change's head, the same on both of its tabs: the title, its state,
246212 * who opened it and in answer to whom, where it came from, and what the
247213 * crew spent on it. */
@@ -675,17 +641,24 @@ async function changeConversation(
675641 crumbs: crumbsFor(repo),
676642 palette: [
677643 ...repoPalette(ctx, repo),
678 ...pullActions({
644 ...pullRoster({
679645 base,
680 name: `${repo.ownerName}/${repo.name}`,
681646 number: pull.number,
682 open: pull.state === "open" && pull.mergedAt === null,
647 headBranch: pull.headBranch,
648 crossRepo: pull.crossRepo,
649 signedIn: ctx.auth.user !== undefined,
683650 writer,
684 mayClose: ctx.auth.user !== undefined && (ctx.auth.user.id === pull.authorId || writer),
685 shippable: decision !== undefined,
686 conflicted,
687 takeBack: mayTakeBack,
688 mayEdit: mayEditIssue(ctx.app, repo, pull, ctx.auth.user).ok && !mirrored,
651 state: pull.state === "closed" ? "closed" : "open",
652 merged: pull.mergedAt !== null,
653 labels: false,
654 mayState: ctx.auth.user !== undefined && (ctx.auth.user.id === pull.authorId || writer),
655 mergeReady: false,
656 change: {
657 shippable: decision !== undefined,
658 conflicted: conflicted === true,
659 mayEdit: mayEditIssue(ctx.app, repo, pull, ctx.auth.user).ok && !mirrored,
660 takeBack: mayTakeBack,
661 },
689662 }),
690663 ],
691664 }));
src/web/studio/pull_roster.ts added +82
@@ -0,0 +1,82 @@
1 /**
2 * What a pull lets this reader do, as palette rows, on both of its tabs: one list, plain
3 * data in, plain rows out. A row with `p` is a mutation — the palette posts
4 * it as the page's own form does (same action, same fields), never a GET;
5 * a row without it is a link to where the page does the thing.
6 *
7 * Each rule below is the rule the page draws its control by. A reader who
8 * may not do an action is never handed its row.
9 */
10 import type { PaletteItem } from "../layout.ts";
11
12 export interface PullRosterInput {
13 /** `/owner/name`. */
14 readonly base: string;
15 readonly number: number;
16 readonly headBranch: string;
17 readonly crossRepo: boolean;
18 readonly signedIn: boolean;
19 /** May write to the repository. */
20 readonly writer: boolean;
21 readonly state: "open" | "closed";
22 readonly merged: boolean;
23 /** May close or reopen it: its author or a writer, as `setIssueState` allows. */
24 readonly mayState: boolean;
25 /** The repository has labels to apply: the page draws its form only then. */
26 readonly labels: boolean;
27 /** The merge box would offer an enabled Merge (git, review and checks agree). */
28 readonly mergeReady: boolean;
29 /** The Conversation tab folds its actions away, so its rows are links to them. */
30 readonly change?: {
31 readonly shippable: boolean;
32 /** Shipping would only be refused: the verb is the one that fixes it. */
33 readonly conflicted: boolean;
34 readonly mayEdit: boolean;
35 readonly takeBack: boolean;
36 };
37 }
38
39 const GROUP = "This pull";
40
41 export function pullRoster(i: PullRosterInput): PaletteItem[] {
42 const thread = `${i.base}/pulls/${i.number}`;
outdated

'Diff' and 'Files' point at the same page; duplicate row.

43 const row = (t: string, h: string, p?: Record<string, string>): PaletteItem => ({
44 t,
45 h,
46 c: GROUP,
47 ...(p ? { p } : {}),
48 });
49 const open = i.state === "open" && !i.merged;
50 const out: PaletteItem[] = [
51 row("Conversation", thread),
52 row("Files", `${thread}/files`),
53 ];
54 if (!i.crossRepo) {
55 const ref = encodeURIComponent(i.headBranch);
56 out.push(row("Branch", `${i.base}/tree/${ref}`), row("Commits", `${i.base}/commits?ref=${ref}`));
57 }
58 const c = i.change;
59 if (c?.shippable) out.push(row(c.conflicted ? "Bring up to date" : "Ship", `${thread}#decide`));
60 if (i.signedIn && !i.merged) out.push(row("Comment", `${thread}#comment-body`));
61 if (!c && open && i.signedIn) out.push(row("Review", `${thread}/files#review-body`));
62 if (open && i.writer) {
63 if (c) {
64 out.push(row("Check again", `${thread}#live-checks`), row("Hold", `${thread}?more=1#hold`));
65 } else {
66 out.push(row("Check again", `${thread}/checks`, {}));
67 }
68 }
69 // The Files tab's assignee and labels cards are a writer's whatever the pull's state.
70 if (!c && i.writer) {
71 out.push(row("Assign", `${thread}/files#assignee-now`));
72 if (i.labels) out.push(row("Labels", `${thread}/files#labels-now`));
73 }
74 if (c && open && i.mayState) out.push(row("Close pull request", `${thread}#comment-body`));
outdated

Add a Reopen row here for a closed, unmerged pull. Post {state:'open'} (or whatever the page's /state form sends) to ${thread}/state. Gate it by the same rule the page draws its button by.

75 // A closed, unmerged pull: the page's Reopen form posts just this.
76 if (i.state === "closed" && !i.merged && i.signedIn && i.mayState)
77 out.push(row("Reopen pull request", `${thread}/state`, { state: "open" }));
78 if (c?.mayEdit) out.push(row("Edit", `${thread}#edit`));
79 if (c?.takeBack) out.push(row("Take it back", `${thread}?more=1#take-back`));
80 if (i.mergeReady) out.push(row("Merge pull request", `${thread}/merge`, { strategy: "merge" }));
81 return out;
82 }
test/palette-roster.test.ts +174 −1
@@ -1,4 +1,4 @@
1 import { afterAll, beforeAll, expect, test } from "bun:test";
1 import { afterAll, beforeAll, describe, expect, test } from "bun:test";
22 import { mkdtemp, rm } from "node:fs/promises";
33 import { tmpdir } from "node:os";
44 import { join } from "node:path";
@@ -278,3 +278,176 @@ test("signed out, the palette offers sign-in and the public entries", () => {
278278 ["quit", "Quit", "disconnect"],
279279 ]);
280280 });
281
282 // ---- the pull page's palette: the page's actions and the palette's rows are one set ----
283
284 import { openPull } from "../src/actions/pulls.ts";
285 import { commitToBranch, createRepo } from "../src/actions/repos.ts";
286 import { serve, type Servers } from "../src/http/server.ts";
287 import { pullRoster, type PullRosterInput } from "../src/web/studio/pull_roster.ts";
288
289 describe("on a pull's Files step the palette lists what the page offers this reader", () => {
290 let pdir: string;
291 let papp: App;
292 let servers: Servers;
293 let writerCookie = "";
294 let readerCookie = "";
295 const thread = "/wendy/lib/pulls/1";
296
297 const signIn = async (username: string) => {
298 const res = await fetch(`${servers.url}/signin`, {
299 method: "POST",
300 body: new URLSearchParams({ username, password: "a-good-passphrase" }),
301 redirect: "manual",
302 });
303 return (res.headers.get("set-cookie") ?? "").split(";")[0]!;
304 };
305
306 beforeAll(async () => {
307 pdir = await mkdtemp(join(tmpdir(), "forge-palette-pull-"));
308 papp = createApp({ dataDir: pdir, port: 0, host: "forge.test" });
309 servers = await serve(papp);
310 const mk = async (username: string, adminIfFirst: boolean) => {
311 const r = await createUser(papp.db, {
312 origin: "seed", trusted: true, username, email: `${username}@forge.local`,
313 password: "a-good-passphrase", adminIfFirst,
314 });
315 if (!r.ok) throw new Error(r.error);
316 return r.value;
317 };
318 const wendy = await mk("wendy", true);
319 await mk("rhea", false);
320 const r = await createRepo(papp, wendy, { name: "lib", visibility: "public" });
321 if (!r.ok) throw new Error(String(r.error));
322 for (const [branch, content] of [["main", "a\n"], ["feat", "a\nb\n"]] as const) {
323 const done = await commitToBranch(papp, wendy, r.value, {
324 branch, message: branch, files: [{ path: "f.txt", content }],
325 });
326 if (!done.ok) throw new Error(JSON.stringify(done.error));
327 }
328 const opened = await openPull(papp, wendy, r.value, { title: "Feat", baseBranch: "main", headBranch: "feat" });
329 if (!opened.ok) throw new Error(String(opened.error));
330 // A second pull, closed without merging: its page offers Reopen.
331 const second = await openPull(papp, wendy, r.value, { title: "Gone", baseBranch: "main", headBranch: "feat" });
332 if (!second.ok) throw new Error(String(second.error));
333 writerCookie = await signIn("wendy");
334 readerCookie = await signIn("rhea");
335 }, 120_000);
336
337 afterAll(async () => {
338 await servers.stop();
339 papp.close();
340 await rm(pdir, { recursive: true, force: true });
outdated

Only mutation forms are compared; links are checked with arrayContaining. Assert the full set equality of page actions vs palette rows.

341 });
342
343 // The page's form actions, by the palette row that stands for each.
344 const STANDS_FOR: Record<string, string> = {
345 checks: "Check again", merge: "Merge pull request", "review-state": "Review", assignee: "Assign",
346 labels: "Labels", ship: "Ship", hold: "Hold", state: "Close pull request", edit: "Edit", comments: "Comment",
347 };
348 const READING = ["Conversation", "Files", "Branch", "Commits"];
349
350 const read = async (path: string, cookie: string, at = thread) => {
351 const html = await (await fetch(`${servers.url}${path}`, { headers: { cookie } })).text();
352 const index = JSON.parse(/id="palette-index">([^<]*)</.exec(html)![1]!) as {
353 items: { t: string; h: string; c?: string; p?: Record<string, string> }[];
354 };
355 const forms = [...html.matchAll(/<form\s[^>]*action="([^"]+)"/g)]
356 .map((m) => m[1]!)
357 .filter((a) => a.startsWith(`${at}/`))
358 .map((a) => {
359 const verb = STANDS_FOR[a.slice(at.length + 1)];
360 if (verb === undefined) throw new Error(`the page has a form the palette does not know: ${a}`);
361 return verb;
362 });
363 return { rows: index.items.filter((i) => i.c === "This pull"), forms };
364 };
365
366 for (const [who, kind] of [["writer", () => writerCookie], ["reader", () => readerCookie]] as const) {
367 for (const [tab, path] of [["Conversation", thread], ["Files", `${thread}/files`]] as const) {
368 test(`on the ${tab} tab a ${who}'s action rows are exactly the page's forms`, async () => {
369 const { rows, forms } = await read(path, kind());
370 // The Files tab has no comment form of its own: its Comment row links to the Conversation's.
371 const acts = rows.map((r) => r.t).filter((t) => !READING.includes(t) && !(tab === "Files" && t === "Comment"));
372 const same = (t: string) => (t === "Bring up to date" ? "Ship" : t);
373 expect(acts.map(same).sort()).toEqual([...new Set(forms)].sort());
374 // A row that posts goes to a form action the page has, with a path of its own.
375 for (const r of rows.filter((x) => x.p)) expect(r.h.startsWith(`${thread}/`)).toBe(true);
376 // No title twice.
377 expect(new Set(rows.map((r) => r.t)).size).toBe(rows.length);
378 });
379 }
380 }
381
382 test("on a closed pull, Reopen is on the page and in the palette for its author or a writer alone", async () => {
383 const at = "/wendy/lib/pulls/2";
384 const shut = await fetch(`${servers.url}${at}/state`, {
385 method: "POST",
386 headers: { cookie: writerCookie, origin: servers.url, "content-type": "application/x-www-form-urlencoded" },
387 body: "state=closed",
388 redirect: "manual",
389 });
390 expect(shut.status).toBeLessThan(400);
391 const same = (t: string) => (t === "Close pull request" ? "Reopen pull request" : t);
392 for (const [cookie, may] of [[writerCookie, true], [readerCookie, false]] as const) {
393 for (const path of [at, `${at}/files`]) {
394 const { rows, forms } = await read(path, cookie, at);
395 const acts = rows
396 .map((r) => r.t)
397 .filter((t) => !READING.includes(t) && !(path.endsWith("/files") && t === "Comment"));
398 expect(acts.sort()).toEqual([...new Set(forms.map(same))].sort());
399 const reopen = rows.find((r) => r.t === "Reopen pull request");
400 if (may) expect(reopen).toMatchObject({ h: `${at}/state`, p: { state: "open" } });
401 else expect(reopen).toBeUndefined();
402 }
403 }
404 });
405
406 test("both tabs list the same reading rows, and no row points at the Files page twice", async () => {
407 for (const path of [thread, `${thread}/files`]) {
408 const { rows } = await read(path, writerCookie);
409 expect(rows.filter((r) => READING.includes(r.t)).map((r) => r.t)).toEqual(READING);
410 expect(rows.filter((r) => r.h === `${thread}/files`).length).toBe(1);
411 }
412 });
413
414 test("a reader may review and comment on the Files tab, but not assign, check or merge", async () => {
415 const titles = (await read(`${thread}/files`, readerCookie)).rows.map((r) => r.t);
416 expect(titles).toEqual(expect.arrayContaining(["Conversation", "Files", "Branch", "Comment", "Review"]));
417 for (const t of ["Assign", "Labels", "Check again", "Merge pull request"]) expect(titles).not.toContain(t);
418 });
419
420 test("a writer is offered every action the page has", async () => {
421 const titles = (await read(`${thread}/files`, writerCookie)).rows.map((r) => r.t);
422 expect(titles).toEqual(
423 expect.arrayContaining(["Conversation", "Files", "Branch", "Commits", "Comment", "Review", "Assign", "Check again", "Merge pull request"]),
424 );
425 });
426
427 test("a signed-out visitor is offered only the reading rows", async () => {
428 const titles = (await read(`${thread}/files`, "")).rows.map((r) => r.t);
429 expect(titles).toEqual(["Conversation", "Files", "Branch", "Commits"]);
430 });
431
432 test("the rules, one by one", () => {
433 const base: PullRosterInput = {
434 base: "/o/r", number: 3, headBranch: "a b", crossRepo: false, signedIn: true, writer: true,
435 state: "open", merged: false, mergeReady: true, labels: true, mayState: true,
436 };
437 const titles = (o: Partial<PullRosterInput>) => pullRoster({ ...base, ...o }).map((r) => r.t);
438 expect(pullRoster(base).find((r) => r.t === "Merge pull request")).toMatchObject({ h: "/o/r/pulls/3/merge", p: { strategy: "merge" } });
439 expect(titles({ mergeReady: false })).not.toContain("Merge pull request");
440 expect(pullRoster(base).find((r) => r.t === "Branch")!.h).toBe("/o/r/tree/a%20b");
441 expect(titles({ crossRepo: true })).not.toContain("Branch");
442 expect(titles({ merged: true })).not.toContain("Comment");
443 expect(titles({ state: "closed" })).not.toContain("Check again");
444 expect(titles({ labels: false })).not.toContain("Labels");
445 expect(titles({ labels: true })).toContain("Labels");
446 expect(titles({})).not.toContain("Diff");
447 expect(titles({})).not.toContain("Reopen pull request");
448 expect(titles({ state: "closed" })).toContain("Reopen pull request");
449 expect(titles({ state: "closed", mayState: false })).not.toContain("Reopen pull request");
450 expect(titles({ state: "closed", merged: true })).not.toContain("Reopen pull request");
451 expect(pullRoster(base).filter((r) => r.p).every((r) => r.h.startsWith("/o/r/pulls/3/"))).toBe(true);
452 });
453 });
test/pull-actions.test.ts +18 −12
@@ -3,7 +3,7 @@ import { afterAll, beforeAll, expect, test } from "bun:test";
33 import { snappyFixture, type SnappyFixture } from "../bench/snappy.ts";
44 import { mergePull, openPull } from "../src/actions/pulls.ts";
55 import { commitToBranch } from "../src/actions/repos.ts";
6 import { pullActions } from "../src/web/studio/change.ts";
6 import { pullRoster, type PullRosterInput } from "../src/web/studio/pull_roster.ts";
77
88 let f: SnappyFixture;
99 let base = "";
@@ -76,20 +76,26 @@ test("every action is in the palette", async () => {
7676 ])
7777 expect(text).toContain(`"h":"${h}"`);
7878 for (const t of ["Ship", "Check again", "Hold", "Comment", "Close pull request", "Edit"])
79 expect(text).toContain(`#${number} — ${t}`);
79 expect(text).toContain(`"t":"${t}","h":`);
8080 });
8181
82 test("pullActions offers what the viewer may do and nothing else", () => {
83 const o = { base: "/a/b", name: "a/b", number: 7 };
84 const labels = (x: Parameters<typeof pullActions>[0]) => pullActions(x).map((i) => i.t);
85 const all = labels({ ...o, open: true, writer: true, mayClose: true, shippable: true, takeBack: false, mayEdit: true });
82 test("the roster offers what the viewer may do and nothing else", () => {
83 const o: PullRosterInput = {
84 base: "/a/b", number: 7, headBranch: "t", crossRepo: false, signedIn: true, writer: true,
85 state: "open", merged: false, mergeReady: false, labels: false, mayState: true,
86 };
87 const labels = (x: Partial<PullRosterInput>) => pullRoster({ ...o, ...x }).map((i) => i.t);
88 const change = { shippable: true, conflicted: false, mayEdit: true, takeBack: false };
89 const all = labels({ change });
8690 for (const w of ["Conversation", "Files", "Ship", "Check again", "Hold", "Comment", "Close pull request", "Edit"])
87 expect(all.some((t) => t.includes(w))).toBe(true);
88 const merged = labels({ ...o, open: false, writer: true, mayClose: false, shippable: false, takeBack: true, mayEdit: false });
89 expect(merged.some((t) => t.includes("Take it back"))).toBe(true);
90 expect(merged.some((t) => t.includes("Ship"))).toBe(false);
91 const reader = labels({ ...o, open: true, writer: false, mayClose: false, shippable: false, takeBack: false, mayEdit: false });
92 expect(reader.length).toBeLessThanOrEqual(4);
91 expect(all).toContain(w);
92 expect(all).not.toContain("Diff");
93 expect(labels({ change: { ...change, conflicted: true } })).toContain("Bring up to date");
94 const merged = labels({ state: "closed", merged: true, mayState: false, change: { ...change, shippable: false, mayEdit: false, takeBack: true } });
95 expect(merged).toContain("Take it back");
96 expect(merged).not.toContain("Ship");
97 const reader = labels({ writer: false, mayState: false, change: { ...change, shippable: false, mayEdit: false } });
98 expect(reader.length).toBeLessThanOrEqual(5);
9399 });
94100
95101 const getAs = async (cookie: string, path: string): Promise<string> =>

↑↓ navigate ↵ open ⌘↵ its app esc close

Keyboard shortcuts

Docs