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
Check · Review · Land · Live done — Landed and live in production.
scout/forge:scout/8827-every-action-on-a-pull-page-is-also-in-t → main 6 commits fork 4 threads · 4 unresolved
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 */ ` | ||
| 1756 | 1756 | if (i.m === 1) r.m = 1; |
| 1757 | 1757 | if (i.x) r.x = String(i.x); |
| 1758 | 1758 | 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; | |
| 1759 | 1760 | return r; |
| 1760 | 1761 | }); |
| 1761 | 1762 | var tellApp = (loadIndex().tell || {}).app; |
| @@ -1781,6 +1782,7 @@ const shellCore = /* js */ ` | ||
| 1781 | 1782 | a.setAttribute('aria-selected', 'false'); |
| 1782 | 1783 | // Same-origin paths only: the index is ours, and this keeps it so. |
| 1783 | 1784 | a.setAttribute('href', r.h.charAt(0) === '/' ? r.h : '/'); |
| 1785 | if (r.p) a.setAttribute('data-post', JSON.stringify(r.p)); | |
| 1784 | 1786 | var t = doc.createElement('span'); |
| 1785 | 1787 | t.className = 'grow'; |
| 1786 | 1788 | t.textContent = r.t; |
| @@ -1980,6 +1982,27 @@ const shellCore = /* js */ ` | ||
| 1980 | 1982 | tellFromHere(tellApp, a.getAttribute('data-tell')); |
| 1981 | 1983 | return; |
| 1982 | 1984 | } |
| 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 | } | |
| 1983 | 2006 | remember(shown[i]); |
| 1984 | 2007 | var h = a.getAttribute('href') || ''; |
| 1985 | 2008 | if (/^\\/[^\\/?#]+\\/[^\\/?#]+\\/tell\\?from=/.test(h) && !e.metaKey && !e.ctrlKey && !e.shiftKey) { |
src/web/layout.ts +2
| @@ -68,6 +68,8 @@ export interface PaletteItem { | ||
| 68 | 68 | x?: string; |
| 69 | 69 | o?: string; |
| 70 | 70 | m?: 1; |
| 71 | /** A mutation: the palette posts these fields to `h`, as the page's own form does. */ | |
| 72 | p?: Record<string, string>; | |
| 71 | 73 | } |
| 72 | 74 | |
| 73 | 75 | export interface PageOptions { |
src/web/pages/pull.ts +28 −2
| @@ -8,6 +8,7 @@ | ||
| 8 | 8 | * it needs. |
| 9 | 9 | */ |
| 10 | 10 | |
| 11 | import { pullRoster } from "../studio/pull_roster.ts"; | |
| 11 | 12 | import { askCheck } from "../../checks/again.ts"; |
| 12 | 13 | import { addCommentThrough } from "../../actions/issues.ts"; |
| 13 | 14 | import { COMMENT_EMPTY, COMMENT_NOT_POSTED } from "../../actions/comment_words.ts"; |
| @@ -477,7 +478,8 @@ function mergeBox( | ||
| 477 | 478 | icon("x"), |
| 478 | 479 | "Closed without merging.", |
| 479 | 480 | 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), | |
| 481 | 483 | ); |
| 482 | 484 | } |
| 483 | 485 | |
| @@ -1022,7 +1024,31 @@ export async function pullFilesPage(ctx: Ctx): Promise<Response> { | ||
| 1022 | 1024 | ctx, |
| 1023 | 1025 | title: `Files · ${pull.title} · ${repo.name}`, |
| 1024 | 1026 | crumbs: crumbsFor(repo), |
| 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 | ], | |
| 1026 | 1052 | body, |
| 1027 | 1053 | styleCss: labelCss([...allLabels, ...applied]), |
| 1028 | 1054 | 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 | ||
| 88 | 88 | import { changeHref } from "./nouns.ts"; |
| 89 | 89 | import { pullWord, servingHistory, stateDot, wordDot, type WordCtx } from "./words.ts"; |
| 90 | 90 | import { getHold } from "../../core/holds.ts"; |
| 91 | import { pullRoster } from "./pull_roster.ts"; | |
| 91 | 92 | |
| 92 | 93 | /** The pull and its repo, or the response that refuses to say why not. */ |
| 93 | 94 | export function pullContext(ctx: Ctx): { repo: Repo; pull: Pull } | Response { |
| @@ -207,41 +208,6 @@ function reviewEntry( | ||
| 207 | 208 | }; |
| 208 | 209 | } |
| 209 | 210 | |
| 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 | ||
| 245 | 211 | /** A change's head, the same on both of its tabs: the title, its state, |
| 246 | 212 | * who opened it and in answer to whom, where it came from, and what the |
| 247 | 213 | * crew spent on it. */ |
| @@ -675,17 +641,24 @@ async function changeConversation( | ||
| 675 | 641 | crumbs: crumbsFor(repo), |
| 676 | 642 | palette: [ |
| 677 | 643 | ...repoPalette(ctx, repo), |
| 678 | ...pullActions({ | |
| 644 | ...pullRoster({ | |
| 679 | 645 | base, |
| 680 | name: `${repo.ownerName}/${repo.name}`, | |
| 681 | 646 | 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, | |
| 683 | 650 | 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 | }, | |
| 689 | 662 | }), |
| 690 | 663 | ], |
| 691 | 664 | })); |
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 |
||
| 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"; | |
| 2 | 2 | import { mkdtemp, rm } from "node:fs/promises"; |
| 3 | 3 | import { tmpdir } from "node:os"; |
| 4 | 4 | import { join } from "node:path"; |
| @@ -278,3 +278,176 @@ test("signed out, the palette offers sign-in and the public entries", () => { | ||
| 278 | 278 | ["quit", "Quit", "disconnect"], |
| 279 | 279 | ]); |
| 280 | 280 | }); |
| 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"; | ||
| 3 | 3 | import { snappyFixture, type SnappyFixture } from "../bench/snappy.ts"; |
| 4 | 4 | import { mergePull, openPull } from "../src/actions/pulls.ts"; |
| 5 | 5 | 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"; | |
| 7 | 7 | |
| 8 | 8 | let f: SnappyFixture; |
| 9 | 9 | let base = ""; |
| @@ -76,20 +76,26 @@ test("every action is in the palette", async () => { | ||
| 76 | 76 | ]) |
| 77 | 77 | expect(text).toContain(`"h":"${h}"`); |
| 78 | 78 | 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":`); | |
| 80 | 80 | }); |
| 81 | 81 | |
| 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 }); | |
| 86 | 90 | 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); | |
| 93 | 99 | }); |
| 94 | 100 | |
| 95 | 101 | const getAs = async (cookie: string, path: string): Promise<string> => |
new above
offline
Roster is wired on the Files tab only; the Conversation tab uses a different list. One roster, both tabs.