Public: anyone can read this; the crew can change it.
Issues and pull requests are called issues and pull requests everywhere: retire "Told", "Changes", /tells and /changes #3304
Check · Review · Land · Live done — Landed and live in production.
mason/forge:mason/3241-issues-and-pull-requests-are-called-issu → main 4 commits fork 3 threads · 3 unresolved
51 files
changed +381
−451
README.md +1 −1
Diff not shown — a line 2,328 characters wide (limit 2,000). +1 −1
View filedocs/STUDIO.md +31 −44
| @@ -17,15 +17,15 @@ command palette from anywhere, a field included. The box takes its query to | ||
| 17 | 17 | `/search` (`searchPage`, `src/web/pages/search.ts`), the one search in |
| 18 | 18 | both faces, server-rendered with no script: every repository the viewer |
| 19 | 19 | can see, matched by name or description, an app saying whether it is live, |
| 20 | then every tell and change whose title mentions the word, each saying | |
| 21 | which app it's on and linked by the studio's noun — the same `search()` | |
| 20 | then every issue and pull request whose title mentions the word, each | |
| 21 | saying which app it's on and linked at `/issues/:n` or `/pulls/:n` — the same `search()` | |
| 22 | 22 | the API and the palette ask, so a signed-out reader sees only what a |
| 23 | 23 | signed-out reader would anywhere. The studio's face leaves code to dev's: |
| 24 | 24 | it says _Search the code_, a link to the same page with `?mode=dev`, which |
| 25 | 25 | keeps that face on every link and the form until the reader leaves. The palette starts from a short local |
| 26 | 26 | index — your own apps, then places and actions like Home, Apps, Map, New |
| 27 | 27 | app — and past two characters asks that very same search, folding its apps, |
| 28 | changes and tells in under their own headings (`rankPalette`, | |
| 28 | pull requests and issues in under their own headings (`rankPalette`, | |
| 29 | 29 | `src/web/palette.ts`), so what it turns up can never disagree with the page |
| 30 | 30 | behind it. |
| 31 | 31 | |
| @@ -75,10 +75,9 @@ with a door to make it one — `/settings/app`, the same "Run as an app" form | ||
| 75 | 75 | Settings already carries — open only to whoever could submit it (`canAdmin`); |
| 76 | 76 | a reader sees the sentence with no button that would just come back |
| 77 | 77 | refused. `?mode=dev` on the same URL is unaffected: the card is the studio's |
| 78 | own, never dev's. `/tells` and `/changes` on such a repository still send a | |
| 79 | studio viewer on to `/issues` and `/pulls`, as they do for any repository | |
| 80 | the studio does not apply to — the card names the one thing to do here, | |
| 81 | not a second front door onto lists that would be empty anyway. | |
| 78 | own, never dev's. `/issues` and `/pulls` on such a repository are the same | |
| 79 | lists they are everywhere — the card names the one thing to do here, not a | |
| 80 | second front door onto lists that would be empty anyway. | |
| 82 | 81 | |
| 83 | 82 | An app's home in the studio (`/:owner/:name`, when the repository is an |
| 84 | 83 | app) shows, top to bottom: what it serves and where; the box; what asks |
| @@ -152,7 +151,7 @@ it already answers only to people who can read it at all. | ||
| 152 | 151 | **Whose name a row carries** is one rule, in one place — `voiceFor` |
| 153 | 152 | (`src/web/studio/voice.ts`), which the app's page, a run's own page and the |
| 154 | 153 | board all sign their rows with. An agent's name is _not_ a secret: a |
| 155 | change's own page and `/changes` name the agent that opened it to anyone | |
| 154 | pull request's own page and `/pulls` name the agent that opened it to anyone | |
| 156 | 155 | who can read the repository. "The crew" is a voice, not a redaction, and it |
| 157 | 156 | is chosen by **what the actor is** first: a person is named to every viewer |
| 158 | 157 | who can see the row at all, so one visitor reads one name for one change on |
| @@ -202,34 +201,24 @@ presses instead of deciding. | ||
| 202 | 201 | ## The three lists |
| 203 | 202 | |
| 204 | 203 | `/:owner/:name/issues`, `/pulls` and `/runs` on an app, in the studio, are |
| 205 | _everything told_, _every change_ and _every run_ — dev's tabs with dev's | |
| 206 | issue numbers, labels, milestones and branch names replaced by the same | |
| 207 | five words the rest of the studio speaks. Each is a page of rows, newest | |
| 208 | first: who has it (the assignee, the author, or who ran it), when it last | |
| 209 | moved, its state as `wordBadge` draws it (`src/web/studio/words.ts`), and | |
| 210 | one link — the row itself — to the tell, the change or the transcript. | |
| 211 | `/:owner/:name/tells` and `/:owner/:name/changes` are the studio's own | |
| 212 | nouns for the first two lists, and `/:owner/:name/tells/:number` and | |
| 213 | `/:owner/:name/changes/:number` for one tell and one change — the same | |
| 214 | handler each, the same authorization, the same `?word=` filter and paging, | |
| 215 | not a second copy of any page; a viewer the studio does not apply to (dev | |
| 216 | mode, `?mode=dev`, or a repository that is not an app) is sent on to | |
| 217 | `/issues`, `/pulls`, `/issues/:number` or `/pulls/:number`, dev's own | |
| 218 | address, rather than the studio's nouns ever growing a second, | |
| 219 | dev-flavoured address of their own. It goes one way only: a dev URL asked | |
| 220 | for in studio mode answers the studio's page where it stands, never a | |
| 221 | redirect, so every link ever written to `/issues/7` keeps working and | |
| 222 | `?mode=dev` on it is still the one click to dev's own. In studio mode the | |
| 223 | studio's own links use the studio's nouns wherever a tell or a change is | |
| 224 | linked — the three lists' rows, the app page's _now_, | |
| 225 | a tell's dependencies and the change that closes it, a run's own tell, the | |
| 226 | search box and the command palette — so a person is never handed two | |
| 227 | addresses for one thing. The app page's own footer (`the map · everything | |
| 228 | told · every change`, and _Every run_ above **Now**) and the repository | |
| 229 | header's tabs (`Told`, `Changes`, in place of dev's `Issues`, `Pulls`) link | |
| 230 | to `/tells` and `/changes`; dev's `Commits`/`Branches` stay dev's alone. | |
| 231 | `/runs` has no second noun — the studio and dev share the one address | |
| 232 | there. What a page _posts_ to never moves with the noun: ship, close, | |
| 204 | the app's issues, pull requests and runs — dev's tabs with dev's labels, | |
| 205 | milestones and branch names replaced by the same five words the rest of | |
| 206 | the studio speaks. Each is a page of rows, newest first: who has it (the | |
| 207 | assignee, the author, or who ran it), when it last moved, its state as | |
| 208 | `wordBadge` draws it (`src/web/studio/words.ts`), and one link — the row | |
| 209 | itself — to the issue, the pull request or the transcript. Issues and pull | |
| 210 | requests are called that in every mode (#3241): the studio once had its own | |
| 211 | nouns and addresses for them, `/tells` and `/changes`, and those — | |
| 212 | `/:owner/:name/tells`, `/tells/new`, `/tells/:number`, `/changes` and | |
| 213 | `/changes/:number` — now answer 301 to `/issues`, `/issues/new`, | |
| 214 | `/issues/:number`, `/pulls` and `/pulls/:number`, query kept, in every mode, | |
| 215 | so a link ever written to them keeps working. A dev URL asked for in studio | |
| 216 | mode answers the studio's page where it stands, never a redirect, and | |
| 217 | `?mode=dev` on it is still the one click to dev's own. The app page's own | |
| 218 | footer (`the map · issues · pull requests`, and _Every run_ above **Now**) | |
| 219 | and the repository header's tabs (`Issues`, `Pulls`) link to `/issues` and | |
| 220 | `/pulls`; dev's `Commits`/`Branches` stay dev's alone. | |
| 221 | What a page _posts_ to is the same address: ship, close, | |
| 233 | 222 | comment, edit and decide keep posting under `/issues/:number` and |
| 234 | 223 | `/pulls/:number`, which is what `form-action 'self'` and the same-origin |
| 235 | 224 | checks are written against. |
| @@ -265,13 +254,13 @@ production has not caught up to yet, or a run with nothing to show — | ||
| 265 | 254 | still read as _working_: still moving, not one of the five a list links to |
| 266 | 255 | on its own. Paging is the underlying list's own cursor, unchanged; a word |
| 267 | 256 | filter narrows the page it lands on rather than inventing a second scheme. |
| 268 | Told and changes are the only lists of issues and pulls: every repository, | |
| 269 | app or not, and every viewer, whatever the mode, at `/issues` and `/tells`, | |
| 270 | `/pulls` and `/changes` alike (`?mode=dev` changes nothing there). Besides | |
| 257 | These are the only lists of issues and pulls: every repository, app or | |
| 258 | not, and every viewer, whatever the mode, at `/issues` and `/pulls` | |
| 259 | (`?mode=dev` changes nothing there). Besides | |
| 271 | 260 | the word they narrow by `?label=` (the repository's labels are chips under |
| 272 | 261 | the words), `?assignee=`, `?author=` and `?q=` (a piece of the title), each |
| 273 | 262 | shown as a chip that clears itself; an older `?state=closed|all` address |
| 274 | still answers with what it asked for. Told carries one verb besides opening | |
| 263 | still answers with what it asked for. Issues carries one verb besides opening | |
| 275 | 264 | a row — compose, for a signed-in person; a stranger on a public repository |
| 276 | 265 | gets "Report a problem" instead, and its writers see how many reports from |
| 277 | 266 | outside wait for them. A row wears its labels; a change's |
| @@ -280,8 +269,7 @@ list keeps `?mode=dev` to dev's runs until that list is contracted too. | ||
| 280 | 269 | |
| 281 | 270 | ## A tell, on its own page |
| 282 | 271 | |
| 283 | `/:owner/:name/issues/:number` — `/:owner/:name/tells/:number` in the | |
| 284 | studio's own noun, the same page — on an app, in the studio, is a tell — one | |
| 272 | `/:owner/:name/issues/:number` on an app, in the studio, is a tell — one | |
| 285 | 273 | sentence someone said to the crew — not dev's labels, milestones and |
| 286 | 274 | dependency form. It answers three questions: who has it (or that nobody |
| 287 | 275 | does yet), what it is waiting on and what waits on it (`dependenciesOf`, |
| @@ -319,8 +307,7 @@ mode, renders exactly what it always has. | ||
| 319 | 307 | |
| 320 | 308 | ## The look, on its own page |
| 321 | 309 | |
| 322 | `/:owner/:name/pulls/:n` — `/:owner/:name/changes/:n` in the studio's own | |
| 323 | noun, the same page — on an app, in the studio, is the change itself — | |
| 310 | `/:owner/:name/pulls/:n` on an app, in the studio, is the change itself — | |
| 324 | 311 | never its diff. The title and the opening post in the author's own words, |
| 325 | 312 | who made it and when, the check in the studio's five words (`pullWord`, |
| 326 | 313 | `src/web/studio/words.ts`) with its deploy one click away, the preview's |
src/actions/tell.ts +2 −2
| @@ -158,10 +158,10 @@ export const TOO_LONG_WORDS = "That is too long to tell in one go."; | ||
| 158 | 158 | /** What happened, in the sentence both doors say. */ |
| 159 | 159 | export function toldWords(told: Told): string { |
| 160 | 160 | if (told.routed === undefined) |
| 161 | return `Told as #${told.issue.number}. Nobody is on the crew to take it yet — add someone under Settings → Crew.`; | |
| 161 | return `Opened as issue #${told.issue.number}. Nobody is on the crew to take it yet — add someone under Settings → Crew.`; | |
| 162 | 162 | const r = told.routed; |
| 163 | 163 | const who = r.builder.user.username; |
| 164 | 164 | return told.assigned |
| 165 | 165 | ? `${who} is on it — ${r.size} ${r.kind}${r.by === "rule" ? "" : `, ${r.by} decided in ${r.ms} ms`}.` |
| 166 | : `Told as #${told.issue.number}, but ${who} could not take it.`; | |
| 166 | : `Opened as issue #${told.issue.number}, but ${who} could not take it.`; | |
| 167 | 167 | } |
src/feed/items.ts +7 −7
| @@ -340,7 +340,7 @@ function fromEvent( | ||
| 340 | 340 | ...(pull?.authorIsAgent ? [`by ${pull.authorName}`] : []), |
| 341 | 341 | ].join(", "); |
| 342 | 342 | const toldWords = toldParts === "" ? "" : ` (${toldParts})`; |
| 343 | const change = number === undefined ? base(repo) : `${base(repo)}/changes/${number}`; | |
| 343 | const change = number === undefined ? base(repo) : `${base(repo)}/pulls/${number}`; | |
| 344 | 344 | switch (e.kind) { |
| 345 | 345 | case "run.finished": { |
| 346 | 346 | const state = p["state"] === "failed" ? "failed" : "ok"; |
| @@ -364,11 +364,11 @@ function fromEvent( | ||
| 364 | 364 | }; |
| 365 | 365 | } |
| 366 | 366 | case "pull.opened": |
| 367 | return { ...common, kind: "pull", link: change, what: `a change was opened${title ? `: ${clip(title)}` : ""}${toldWords}` }; | |
| 367 | return { ...common, kind: "pull", link: change, what: `opened a pull request${title ? `: ${clip(title)}` : ""}${toldWords}` }; | |
| 368 | 368 | case "pull.merged": |
| 369 | return { ...common, kind: "pull", link: change, what: `a change shipped${title ? `: ${clip(title)}` : ""}${toldWords}` }; | |
| 369 | return { ...common, kind: "pull", link: change, what: `merged a pull request${title ? `: ${clip(title)}` : ""}${toldWords}` }; | |
| 370 | 370 | case "pull.reviewed": |
| 371 | return { ...common, kind: "pull", link: change, what: `a change was reviewed${title ? `: ${clip(title)}` : ""}` }; | |
| 371 | return { ...common, kind: "pull", link: change, what: `reviewed a pull request${title ? `: ${clip(title)}` : ""}` }; | |
| 372 | 372 | case "look.taken": |
| 373 | 373 | return { ...common, kind: "look", link: base(repo), what: "a new picture of it is ready" }; |
| 374 | 374 | case "app.serving": |
| @@ -380,8 +380,8 @@ function fromEvent( | ||
| 380 | 380 | return { |
| 381 | 381 | ...common, |
| 382 | 382 | kind: "tell", |
| 383 | link: `${base(repo)}/tells/${number}`, | |
| 384 | what: told === "" ? `told #${number}` : clip(told), | |
| 383 | link: `${base(repo)}/issues/${number}`, | |
| 384 | what: `opened an issue: ${told === "" ? `#${number}` : clip(told)}`, | |
| 385 | 385 | }; |
| 386 | 386 | } |
| 387 | 387 | case "issue.commented": |
| @@ -399,7 +399,7 @@ function fromEvent( | ||
| 399 | 399 | kind: "reply", |
| 400 | 400 | link: |
| 401 | 401 | e.kind === "issue.commented" |
| 402 | ? `${base(repo)}/tells/${number}` | |
| 402 | ? `${base(repo)}/issues/${number}` | |
| 403 | 403 | : change, |
| 404 | 404 | what: said === "" ? `replied on “${on}”` : `on “${on}”: ${clip(said)}`, |
| 405 | 405 | }; |
src/http/server.ts +23 −15
| @@ -222,7 +222,6 @@ import { | ||
| 222 | 222 | issueThreadFragment, |
| 223 | 223 | newIssuePage, |
| 224 | 224 | newIssueSubmit, |
| 225 | newTellPage, | |
| 226 | 225 | } from "../web/pages/issues.ts"; |
| 227 | 226 | import { tellPage } from "../web/studio/tell.ts"; |
| 228 | 227 | import { tellHerePage } from "../web/studio/tell_here.ts"; |
| @@ -331,7 +330,6 @@ import { | ||
| 331 | 330 | } from "../web/pages/crew.ts"; |
| 332 | 331 | import { newPullPage, newPullSubmit } from "../web/pages/pulls.ts"; |
| 333 | 332 | import { |
| 334 | changePage, | |
| 335 | 333 | pullPage, |
| 336 | 334 | pullStateFragment, |
| 337 | 335 | } from "../web/studio/change.ts"; |
| @@ -621,6 +619,21 @@ const gitChallenge = () => | ||
| 621 | 619 | |
| 622 | 620 | // ---- the public server ---- |
| 623 | 621 | |
| 622 | /** | |
| 623 | * A handler that answers 301 to the same repository's `/<noun>`, with the | |
| 624 | * rest of the old path (a literal, or `:number` for the matched number) and | |
| 625 | * the query kept — for addresses retired from a name, never from a page. | |
| 626 | */ | |
| 627 | function movedTo(noun: "issues" | "pulls", rest?: "new" | ":number"): Handler { | |
| 628 | return (ctx) => { | |
| 629 | const { owner = "", name = "", number = "" } = ctx.params; | |
| 630 | const tail = | |
| 631 | rest === undefined ? "" : `/${encodeURIComponent(rest === "new" ? "new" : number)}`; | |
| 632 | const location = `/${encodeURIComponent(owner)}/${encodeURIComponent(name)}/${noun}${tail}${ctx.url.search}`; | |
| 633 | return new Response(null, { status: 301, headers: { location } }); | |
| 634 | }; | |
| 635 | } | |
| 636 | ||
| 624 | 637 | export function buildRouter(): Router<Handler> { |
| 625 | 638 | const router = new Router<Handler>(); |
| 626 | 639 | |
| @@ -1057,15 +1070,12 @@ export function buildRouter(): Router<Handler> { | ||
| 1057 | 1070 | |
| 1058 | 1071 | // Issues (web). The literal /new is registered before /:number so it can |
| 1059 | 1072 | // never be captured as a number. |
| 1060 | // One list of issues: `/issues` and `/tells` are two addresses for the | |
| 1061 | // same page, for every repository and every viewer. | |
| 1062 | 1073 | router.add("GET", "/:owner/:name/issues", tellsList); |
| 1063 | // One issue page, at `/issues/:number` and `/tells/:number` alike, for | |
| 1064 | // every repository and every viewer. The literal /tells is registered | |
| 1065 | // before /tells/:number for the same reason /issues/new is. | |
| 1066 | router.add("GET", "/:owner/:name/tells", tellsList); | |
| 1067 | router.add("GET", "/:owner/:name/tells/new", newTellPage); | |
| 1068 | router.add("GET", "/:owner/:name/tells/:number", tellPage); | |
| 1074 | // Issues are called issues in every mode (#3241): the studio's old | |
| 1075 | // addresses for them answer 301, so a link written to one still lands. | |
| 1076 | router.add("GET", "/:owner/:name/tells", movedTo("issues")); | |
| 1077 | router.add("GET", "/:owner/:name/tells/new", movedTo("issues", "new")); | |
| 1078 | router.add("GET", "/:owner/:name/tells/:number", movedTo("issues", ":number")); | |
| 1069 | 1079 | router.add("GET", "/:owner/:name/report", reportPage); |
| 1070 | 1080 | router.add("POST", "/:owner/:name/report", reportSubmit); |
| 1071 | 1081 | router.add("GET", "/:owner/:name/reports", reportsPage); |
| @@ -1132,12 +1142,10 @@ export function buildRouter(): Router<Handler> { | ||
| 1132 | 1142 | ); |
| 1133 | 1143 | |
| 1134 | 1144 | // Pull requests. Same /new-before-/:number ordering discipline. |
| 1135 | // One list of pulls, at `/pulls` and `/changes` alike; one change under | |
| 1136 | // the studio's own noun. See the tells above for the rule; the forms that | |
| 1137 | // ship, close and comment keep posting under /pulls/:number. | |
| 1145 | // And pull requests are called pull requests: `/changes` answers 301. | |
| 1138 | 1146 | router.add("GET", "/:owner/:name/pulls", changesList); |
| 1139 | router.add("GET", "/:owner/:name/changes", changesList); | |
| 1140 | router.add("GET", "/:owner/:name/changes/:number", changePage); | |
| 1147 | router.add("GET", "/:owner/:name/changes", movedTo("pulls")); | |
| 1148 | router.add("GET", "/:owner/:name/changes/:number", movedTo("pulls", ":number")); | |
| 1141 | 1149 | router.add("GET", "/:owner/:name/pulls/new", newPullPage); |
| 1142 | 1150 | router.add("POST", "/:owner/:name/pulls/new", newPullSubmit); |
| 1143 | 1151 | router.add("GET", "/:owner/:name/pulls/:number", pullPage); |
src/tui/screens/answers.ts +1 −1
| @@ -68,7 +68,7 @@ export function answersScreen(f: Session, ctx: Ctx, repo: Repo): Screen { | ||
| 68 | 68 | ); |
| 69 | 69 | if (lines.length === 0) { |
| 70 | 70 | emptyState(s, body!, c, { |
| 71 | title: "No tells yet", | |
| 71 | title: "No issues yet", | |
| 72 | 72 | body: "What you tell the crew about this app shows up here, with its answer.", |
| 73 | 73 | }); |
| 74 | 74 | return; |
src/tui/screens/workspace.ts +1 −1
| @@ -1301,7 +1301,7 @@ export function workspaceScreen( | ||
| 1301 | 1301 | binding(["V"], "waves"), |
| 1302 | 1302 | ...(repo === undefined ? [] : [binding(["d"], "production deploys")]), |
| 1303 | 1303 | ...(repo !== undefined && canSeeAnswers(f.db, repo, f.user) |
| 1304 | ? [binding(["a"], "your tells and the crew's answers")] | |
| 1304 | ? [binding(["a"], "your issues and the crew's answers")] | |
| 1305 | 1305 | : []), |
| 1306 | 1306 | ...(repo !== undefined && admin() |
| 1307 | 1307 | ? [binding(["R"], `decisions by ${(getRepoById(f.db, repo.id) ?? repo).decisionsBy} (cycle)`)] |
src/web/chrome.ts +8 −12
| @@ -203,21 +203,17 @@ export function scopeHeader( | ||
| 203 | 203 | }, |
| 204 | 204 | ]), |
| 205 | 205 | { |
| 206 | // Studio names its own lists — told, not issues; a repository that is | |
| 207 | // not an app keeps git's words. | |
| 208 | label: studio ? "Told" : "Issues", | |
| 209 | href: studio ? tellsHref(base) : `${base}/issues`, | |
| 210 | icon: studio ? "clock" : "issue", | |
| 206 | // Issues and pull requests are called that in every mode (#3241). | |
| 207 | label: "Issues", | |
| 208 | href: tellsHref(base), | |
| 209 | icon: "issue", | |
| 211 | 210 | ...(facts.openIssues === null ? {} : { count: facts.openIssues }), |
| 212 | 211 | current: active === "issues", |
| 213 | 212 | shortcut: "g i", |
| 214 | 213 | }, |
| 215 | 214 | { |
| 216 | // "Look" is the five-word state a green pull is in, and the name of | |
| 217 | // the picture card on one — not the noun for this list, which is the | |
| 218 | // same one `/changes` and "Every change" already use. | |
| 219 | label: studio ? "Changes" : "Pulls", | |
| 220 | href: studio ? changesHref(base) : `${base}/pulls`, | |
| 215 | label: "Pulls", | |
| 216 | href: changesHref(base), | |
| 221 | 217 | icon: "pull", |
| 222 | 218 | ...(facts.openPulls === null ? {} : { count: facts.openPulls }), |
| 223 | 219 | current: active === "pulls", |
| @@ -383,8 +379,8 @@ export function repoPalette(ctx: Ctx, repo: Repo): PaletteItem[] { | ||
| 383 | 379 | return [ |
| 384 | 380 | { t: `${name} — Studio`, h: base, c: "This repo" }, |
| 385 | 381 | { t: `${name} — Files`, h: `${base}/files`, c: "This repo" }, |
| 386 | { t: `${name} — Told`, h: tellsHref(base), c: "This repo" }, | |
| 387 | { t: `${name} — Changes`, h: changesHref(base), c: "This repo" }, | |
| 382 | { t: `${name} — Issues`, h: tellsHref(base), c: "This repo" }, | |
| 383 | { t: `${name} — Pull requests`, h: changesHref(base), c: "This repo" }, | |
| 388 | 384 | // Telling the crew is for who may direct it (`mayDirectCrew`: a |
| 389 | 385 | // writer, or anyone signed in where the owner let outsiders in), so |
| 390 | 386 | // nobody else is offered a door that leads nowhere. |
src/web/client.ts +3 −6
| @@ -1590,15 +1590,12 @@ export const shellJs = /* js */ ` | ||
| 1590 | 1590 | d: r.originKind ? 'on ' + r.originKind : 'on an origin' } |
| 1591 | 1591 | : { t: r.owner + '/' + r.name, h: '/' + r.owner + '/' + r.name, g: 'Repositories' }); |
| 1592 | 1592 | }); |
| 1593 | // Which noun a tell or a change lives under is the mode's, and the index | |
| 1594 | // carries it — the studio's /tells/:n and /changes/:n, dev's /issues/:n | |
| 1595 | // and /pulls/:n, the same addresses the server just rendered into the | |
| 1596 | // page behind this box (\`tellHref\`/\`changeHref\`, src/web/studio/nouns.ts). | |
| 1597 | var studio = loadIndex().studio === true; | |
| 1593 | // An issue lives at /issues/:n and a pull request at /pulls/:n in every | |
| 1594 | // mode (\`tellHref\`/\`changeHref\`, src/web/studio/nouns.ts). | |
| 1598 | 1595 | (out.issues || []).slice(0, 5).forEach(function (i) { |
| 1599 | 1596 | if (i.source !== 'local') return; |
| 1600 | 1597 | var tell = i.kind === 'issue'; |
| 1601 | var noun = tell ? (studio ? 'tells' : 'issues') : (studio ? 'changes' : 'pulls'); | |
| 1598 | var noun = tell ? 'issues' : 'pulls'; | |
| 1602 | 1599 | rows.push({ |
| 1603 | 1600 | t: i.title, |
| 1604 | 1601 | h: '/' + i.repo.owner + '/' + i.repo.name + '/' + noun + '/' + i.number, |
src/web/pages/issues.ts +7 −21
| @@ -38,17 +38,15 @@ import { | ||
| 38 | 38 | import { |
| 39 | 39 | PAGE_TITLES, |
| 40 | 40 | pageTitle, |
| 41 | studioRecord, | |
| 42 | 41 | tellsHref, |
| 43 | 42 | } from "../studio/nouns.ts"; |
| 44 | 43 | |
| 45 | 44 | // ---- compose ---- |
| 46 | 45 | |
| 47 | 46 | /** |
| 48 | * The compose page, in the face the viewer has: the studio's own noun for | |
| 49 | * it (`PAGE_TITLES.compose`) on the tab, the heading and the button, and | |
| 50 | * Cancel back to the list that face calls home. The form posts to | |
| 51 | * `/issues/new` in both faces — the one door that changes something. | |
| 47 | * The compose page: `PAGE_TITLES.compose` on the tab, the heading and the | |
| 48 | * button, and Cancel back to the issues list. The form posts to | |
| 49 | * `/issues/new` — the one door that changes something. | |
| 52 | 50 | */ |
| 53 | 51 | function composePage( |
| 54 | 52 | ctx: Ctx, |
| @@ -56,13 +54,12 @@ function composePage( | ||
| 56 | 54 | values: { title: string; body: string; sealed?: boolean }, |
| 57 | 55 | error?: string, |
| 58 | 56 | ): Response { |
| 59 | const studio = studioRecord(ctx, repo) !== undefined; | |
| 60 | const heading = PAGE_TITLES.compose[studio ? "studio" : "dev"]; | |
| 57 | const heading = PAGE_TITLES.compose; | |
| 61 | 58 | const base = repoBase(repo); |
| 62 | 59 | return page( |
| 63 | 60 | { |
| 64 | 61 | ctx, |
| 65 | title: pageTitle("compose", repo, studio), | |
| 62 | title: pageTitle("compose", repo, false), | |
| 66 | 63 | crumbs: crumbsFor(repo), |
| 67 | 64 | palette: repoPalette(ctx, repo), |
| 68 | 65 | body: html`${repoHeader(ctx, repo, "issues", { |
| @@ -73,8 +70,8 @@ function composePage( | ||
| 73 | 70 | ${newIssueForm( |
| 74 | 71 | repo, |
| 75 | 72 | values, |
| 76 | studio ? "Tell" : "Create issue", | |
| 77 | studio ? tellsHref(base) : `${base}/issues`, | |
| 73 | "Create issue", | |
| 74 | tellsHref(base), | |
| 78 | 75 | error, |
| 79 | 76 | )}`, |
| 80 | 77 | }, |
| @@ -173,17 +170,6 @@ function accountOrReport(ctx: Ctx, repo: Repo): Response { | ||
| 173 | 170 | }); |
| 174 | 171 | } |
| 175 | 172 | |
| 176 | // GET /:owner/:name/tells/new — the studio's own door onto the compose page | |
| 177 | // `/issues/new` reaches, redirecting there when the studio does not apply, | |
| 178 | // the same rule `tellPage` follows for one tell. | |
| 179 | export function newTellPage(ctx: Ctx): Response { | |
| 180 | const repo = loadRepo(ctx); | |
| 181 | if (!repo) return notFoundPage(ctx); | |
| 182 | if (studioRecord(ctx, repo) === undefined) | |
| 183 | return redirect(`${repoBase(repo)}/issues/new${ctx.url.search}`); | |
| 184 | return newIssuePage(ctx); | |
| 185 | } | |
| 186 | ||
| 187 | 173 | // POST /:owner/:name/issues/new |
| 188 | 174 | export async function newIssueSubmit(ctx: Ctx): Promise<Response> { |
| 189 | 175 | const repo = loadRepo(ctx); |
src/web/pages/map.ts +4 −7
| @@ -1307,17 +1307,14 @@ const LIVE_KINDS = "run.queued,run.started,run.finished,issue.*,pull.*"; | ||
| 1307 | 1307 | /** The empty state in the studio's own words, pointing at the app page's |
| 1308 | 1308 | * own box for telling the crew (`#tell`, `src/web/studio/app.ts`). */ |
| 1309 | 1309 | const studioEmpty = (base: string): Html => |
| 1310 | html`<p>No tells to map yet. <a href="${base}#tell">Say one</a>, then link what it waits for.</p>`; | |
| 1310 | html`<p>No issues to map yet. <a href="${base}#tell">Say one</a>, then link what it waits for.</p>`; | |
| 1311 | 1311 | |
| 1312 | 1312 | /** GET /:owner/:name/map */ |
| 1313 | 1313 | export async function mapPage(ctx: Ctx): Promise<Response> { |
| 1314 | 1314 | const repo = loadRepo(ctx); |
| 1315 | 1315 | if (!repo) return notFoundPage(ctx); |
| 1316 | 1316 | const db = ctx.app.db; |
| 1317 | // In the studio a card is a tell and its link is `/tells/:number` — the | |
| 1318 | // same rule every other studio surface follows (`studioRecord`). | |
| 1319 | 1317 | const studio = studioRecord(ctx, repo) !== undefined; |
| 1320 | const issueHref = studio ? tellHref : devIssueHref; | |
| 1321 | 1318 | const graph = repoGraph(db, repo.id, { |
| 1322 | 1319 | ...sealedScope(db, repo, ctx.auth.user), |
| 1323 | 1320 | }); |
| @@ -1351,7 +1348,7 @@ export async function mapPage(ctx: Ctx): Promise<Response> { | ||
| 1351 | 1348 | ${drawMap(repo, graph, placed, emphasisFor(ready, critical, marks), { |
| 1352 | 1349 | id: "map-canvas", |
| 1353 | 1350 | cap: 1, |
| 1354 | issueHref, | |
| 1351 | issueHref: tellHref, | |
| 1355 | 1352 | ...(studio ? { emptyHtml: studioEmpty(repoBase(repo)) } : {}), |
| 1356 | 1353 | })} |
| 1357 | 1354 | ${placed.nodes.length === 0 ? null : countsLine(counts)} |
| @@ -1374,7 +1371,7 @@ export async function mapPage(ctx: Ctx): Promise<Response> { | ||
| 1374 | 1371 | ? "holds nothing up" |
| 1375 | 1372 | : `finishing it frees ${r.holdsUp}`, |
| 1376 | 1373 | "Nothing is ready: everything open waits for something.", |
| 1377 | issueHref, | |
| 1374 | tellHref, | |
| 1378 | 1375 | )} |
| 1379 | 1376 | ${rankedList( |
| 1380 | 1377 | repo, |
| @@ -1382,7 +1379,7 @@ export async function mapPage(ctx: Ctx): Promise<Response> { | ||
| 1382 | 1379 | blocked, |
| 1383 | 1380 | (r) => `holds up ${r.holdsUp}`, |
| 1384 | 1381 | "Nothing open holds anything up.", |
| 1385 | issueHref, | |
| 1382 | tellHref, | |
| 1386 | 1383 | )} |
| 1387 | 1384 | </aside> |
| 1388 | 1385 | </div>`; |
src/web/pages/search.ts +4 −4
| @@ -248,9 +248,9 @@ const WORDS: readonly Words[] = [ | ||
| 248 | 248 | ], |
| 249 | 249 | [ |
| 250 | 250 | "issues", |
| 251 | "Tells & changes", | |
| 252 | "Tells and changes", | |
| 253 | "No tell or change matches", | |
| 251 | "Issues & pull requests", | |
| 252 | "Issues and pull requests", | |
| 253 | "No issue or pull request matches", | |
| 254 | 254 | ], |
| 255 | 255 | ["code", "Code", "Code", "No line of code matches"], |
| 256 | 256 | ]; |
| @@ -357,7 +357,7 @@ export async function searchPage(ctx: Ctx): Promise<Response> { | ||
| 357 | 357 | type="search" |
| 358 | 358 | name="q" |
| 359 | 359 | value="${a.query}" |
| 360 | placeholder="Search apps, repositories, tells, changes and code" | |
| 360 | placeholder="Search apps, repositories, issues, pull requests and code" | |
| 361 | 361 | aria-label="Search" |
| 362 | 362 | autofocus |
| 363 | 363 | data-search-input |
src/web/studio/actions.ts +7 −7
| @@ -79,7 +79,7 @@ async function tellFrom( | ||
| 79 | 79 | const { routed } = told.value; |
| 80 | 80 | if (routed === undefined) |
| 81 | 81 | return flashOrJson(ctx, back, { |
| 82 | message: `Told. Nobody is on the crew to take it yet — add someone under Settings → Crew.`, | |
| 82 | message: `Opened as an issue. Nobody is on the crew to take it yet — add someone under Settings → Crew.`, | |
| 83 | 83 | }); |
| 84 | 84 | const builder = routed.builder; |
| 85 | 85 | return flashOrJson(ctx, back, { |
| @@ -135,11 +135,11 @@ async function tellFromPage(ctx: Ctx, repo: Repo, form: Form): Promise<Response> | ||
| 135 | 135 | const issue = `${repoBase(repo)}/issues/${told.value.issue.number}`; |
| 136 | 136 | return page({ |
| 137 | 137 | ctx, |
| 138 | title: `Told · ${repo.name}`, | |
| 138 | title: `Issue opened · ${repo.name}`, | |
| 139 | 139 | crumbs: crumbsFor(repo), |
| 140 | body: html`${pageHead("Told")} | |
| 140 | body: html`${pageHead("Issue opened")} | |
| 141 | 141 | <p>${toldWords(told.value)}</p> |
| 142 | <p><a href="${issue}" data-receipt>See your tell, #${String(told.value.issue.number)}</a></p>`, | |
| 142 | <p><a href="${issue}" data-receipt>See issue #${String(told.value.issue.number)}</a></p>`, | |
| 143 | 143 | }); |
| 144 | 144 | } |
| 145 | 145 | |
| @@ -235,9 +235,9 @@ function takeBackWords(error: RevertPullError): string { | ||
| 235 | 235 | case "unauthorized": |
| 236 | 236 | return "sign in first"; |
| 237 | 237 | case "forbidden": |
| 238 | return "only a person can take a change back"; | |
| 238 | return "only a person can take a pull request back"; | |
| 239 | 239 | case "not_found": |
| 240 | return "that change was not found"; | |
| 240 | return "that pull request was not found"; | |
| 241 | 241 | } |
| 242 | 242 | } |
| 243 | 243 | switch (error.kind) { |
| @@ -311,7 +311,7 @@ export async function saySubmit(ctx: Ctx): Promise<Response> { | ||
| 311 | 311 | const said = addComment(ctx.app, ctx.auth.user, repo, pull, text); |
| 312 | 312 | return flashOrJson(ctx, base, { |
| 313 | 313 | ...(said.ok |
| 314 | ? { message: `Told ${pull.authorName}. It picks the change back up.` } | |
| 314 | ? { message: `Told ${pull.authorName}. It picks the pull request back up.` } | |
| 315 | 315 | : { tone: "danger" as const, message: "That could not be said." }), |
| 316 | 316 | }); |
| 317 | 317 | } |
src/web/studio/app.ts +6 −6
| @@ -364,17 +364,17 @@ export async function studioApp( | ||
| 364 | 364 | const weekCard = card( |
| 365 | 365 | !hasHistory |
| 366 | 366 | ? html`<p class="muted small"> |
| 367 | Nothing to show yet — it fills in once a change | |
| 367 | Nothing to show yet — it fills in once a pull request | |
| 368 | 368 | ships${ownerFacts ? " or the crew takes a run" : ""}. |
| 369 | 369 | </p>` |
| 370 | 370 | : !ownerFacts |
| 371 | 371 | ? html`<dl> |
| 372 | 372 | <dt>Shipped</dt> |
| 373 | <dd>${shipped} change${shipped === 1 ? "" : "s"}</dd> | |
| 373 | <dd>${shipped} pull request${shipped === 1 ? "" : "s"}</dd> | |
| 374 | 374 | </dl>` |
| 375 | 375 | : html`<dl> |
| 376 | 376 | <dt>Shipped</dt> |
| 377 | <dd>${shipped} change${shipped === 1 ? "" : "s"}</dd> | |
| 377 | <dd>${shipped} pull request${shipped === 1 ? "" : "s"}</dd> | |
| 378 | 378 | <dt>Runs</dt> |
| 379 | 379 | <dd>${weekTotals.runs}</dd> |
| 380 | 380 | <dt>Preview</dt> |
| @@ -385,7 +385,7 @@ export async function studioApp( | ||
| 385 | 385 | <dd> |
| 386 | 386 | $${spent.toFixed(2)}${shipped > 0 |
| 387 | 387 | ? html` <span class="muted" |
| 388 | >· $${(spent / shipped).toFixed(2)} per shipped change</span | |
| 388 | >· $${(spent / shipped).toFixed(2)} per shipped pull request</span | |
| 389 | 389 | >` |
| 390 | 390 | : null} |
| 391 | 391 | </dd> |
| @@ -444,8 +444,8 @@ export async function studioApp( | ||
| 444 | 444 | // domains — is the admin's, on the app's settings page. |
| 445 | 445 | const more = html`<p class="muted small"> |
| 446 | 446 | <a href="${base}/map">The map</a> · |
| 447 | <a href="${tellsHref(base)}">Everything told</a> · | |
| 448 | <a href="${changesHref(base)}">Every change</a>${when( | |
| 447 | <a href="${tellsHref(base)}">Issues</a> · | |
| 448 | <a href="${changesHref(base)}">Pull requests</a>${when( | |
| 449 | 449 | canAdmin(db, repo, ctx.auth.user), |
| 450 | 450 | () => html` · <a href="${base}/settings/app">Environment settings</a>`, |
| 451 | 451 | )} |
src/web/studio/asks.ts +3 −3
| @@ -161,12 +161,12 @@ export interface AskWords { | ||
| 161 | 161 | /** The words for each kind of ask: every ask names its decision, its options and its default. */ |
| 162 | 162 | export const ASK_WORDS: Readonly<Record<DecisionKind, AskWords>> = { |
| 163 | 163 | look: { |
| 164 | decision: "Ship this change?", | |
| 164 | decision: "Ship this pull request?", | |
| 165 | 165 | options: ["Ship", "Say what is off"], |
| 166 | 166 | ifNothing: "it waits; nothing ships", |
| 167 | 167 | }, |
| 168 | 168 | review: { |
| 169 | decision: "Review this change: ship it, or say what is off?", | |
| 169 | decision: "Review this pull request: ship it, or say what is off?", | |
| 170 | 170 | options: ["Ship", "Say what is off"], |
| 171 | 171 | ifNothing: "it waits; an agent's change never ships unreviewed", |
| 172 | 172 | }, |
| @@ -1102,7 +1102,7 @@ export function askLinesFor( | ||
| 1102 | 1102 | ...about, |
| 1103 | 1103 | line, |
| 1104 | 1104 | number: only.pull!.number, |
| 1105 | link: `${base}/changes/${only.pull!.number}`, | |
| 1105 | link: `${base}/pulls/${only.pull!.number}`, | |
| 1106 | 1106 | }; |
| 1107 | 1107 | }); |
| 1108 | 1108 | } |
src/web/studio/branch_tell.ts +4 −4
| @@ -61,12 +61,12 @@ export function tellsAnswered( | ||
| 61 | 61 | return out; |
| 62 | 62 | } |
| 63 | 63 | |
| 64 | /** The line a change carries for its tell: a link in the studio's own noun, | |
| 65 | * and — when that tell is closed — what a person can act on. Never a verdict: | |
| 64 | /** The line a pull request carries for the issue it answers: a link to it, | |
| 65 | * and — when that issue is closed — what a person can act on. Never a verdict: | |
| 66 | 66 | * the change's word is judged elsewhere and is not touched by this. */ |
| 67 | 67 | export function tellLine(base: string, t: AnsweredTell): Html { |
| 68 | const link = html`<a href="${tellHref(base, t.number)}">tell #${t.number}</a>`; | |
| 68 | const link = html`<a href="${tellHref(base, t.number)}">issue #${t.number}</a>`; | |
| 69 | 69 | return t.closed |
| 70 | ? html`for ${link} — its tell is already done` | |
| 70 | ? html`for ${link} — its issue is already done` | |
| 71 | 71 | : html`for ${link}`; |
| 72 | 72 | } |
src/web/studio/change.ts +5 −12
| @@ -239,7 +239,7 @@ export function changeTabs( | ||
| 239 | 239 | return html`<div class="filters mt"> |
| 240 | 240 | ${seg([ |
| 241 | 241 | { |
| 242 | label: "Change", | |
| 242 | label: "Conversation", | |
| 243 | 243 | href: changeHref(base, pull.number), |
| 244 | 244 | current: current === "change", |
| 245 | 245 | }, |
| @@ -262,8 +262,7 @@ function conflictWords(baseBranch: string, files: readonly string[]): string { | ||
| 262 | 262 | } |
| 263 | 263 | |
| 264 | 264 | /** |
| 265 | * GET /:owner/:name/pulls/:number and /changes/:number: one page, two | |
| 266 | * addresses, for every repository and every viewer. An older `?rc=` link (an inline comment form | |
| 265 | * GET /:owner/:name/pulls/:number, for every repository and every viewer. An older `?rc=` link (an inline comment form | |
| 267 | 266 | * on the diff) is handed on to the Files step, where the diff now lives. |
| 268 | 267 | */ |
| 269 | 268 | export async function pullPage(ctx: Ctx): Promise<Response> { |
| @@ -277,10 +276,6 @@ export async function pullPage(ctx: Ctx): Promise<Response> { | ||
| 277 | 276 | return await changeConversation(ctx, repo, pull); |
| 278 | 277 | } |
| 279 | 278 | |
| 280 | /** GET /:owner/:name/changes/:number — the studio's own noun for the page | |
| 281 | * `/pulls/:number` reaches; see `pullPage`. */ | |
| 282 | export const changePage = pullPage; | |
| 283 | ||
| 284 | 279 | /** |
| 285 | 280 | * What a change's page says about where it stands, drawn once: the word and |
| 286 | 281 | * its note, the deploy worth linking, and the Preview card. The page's first |
| @@ -292,10 +287,8 @@ async function pullPanels(ctx: Ctx, repo: Repo, pull: Pull, spent: Spent = newSp | ||
| 292 | 287 | const record: AppRecord | undefined = getAppByRepo(ctx.app.db, repo.id); |
| 293 | 288 | const db = ctx.app.db; |
| 294 | 289 | const base = repoBase(repo); |
| 295 | // Dev's own address for this change, and the only one the page *posts* | |
| 296 | // to: ship, say and the escape to the diff all live under | |
| 297 | // `/pulls/:number`, whether a reader arrived there or at | |
| 298 | // `/changes/:number` (`changeHref`, nouns.ts). | |
| 290 | // The pull request's address, and the only one the page *posts* to: | |
| 291 | // ship, say and the escape to the diff all live under `/pulls/:number`. | |
| 299 | 292 | const thread = `${base}/pulls/${pull.number}`; |
| 300 | 293 | const wordCtx: WordCtx = { |
| 301 | 294 | db, |
| @@ -624,7 +617,7 @@ async function changeConversation( | ||
| 624 | 617 | : {}), |
| 625 | 618 | state: pull.state, |
| 626 | 619 | locked: pull.mergedAt !== null, |
| 627 | closeLabel: "Close change", | |
| 620 | closeLabel: "Close pull request", | |
| 628 | 621 | crewNote: threadNote(ctx.app.db, repo, pull), |
| 629 | 622 | })} |
| 630 | 623 | </div> |
src/web/studio/feed.ts +3 −3
| @@ -33,13 +33,13 @@ const RUN_LINK = /^\/[^/]+\/[^/]+\/runs\/[A-Za-z0-9_-]+$/; | ||
| 33 | 33 | const KIND_LABEL: Record<FeedItem["kind"], string> = { |
| 34 | 34 | run: "Run", |
| 35 | 35 | ask: "Needs you", |
| 36 | pull: "Change", | |
| 36 | pull: "Pull request", | |
| 37 | 37 | decision: "Decision", |
| 38 | 38 | map: "Map", |
| 39 | 39 | cost: "Cost", |
| 40 | 40 | milestone: "Watching", |
| 41 | 41 | choose: "Choose", |
| 42 | tell: "Told", | |
| 42 | tell: "Issue", | |
| 43 | 43 | reply: "Reply", |
| 44 | 44 | look: "Look", |
| 45 | 45 | release: "Release", |
| @@ -196,7 +196,7 @@ export function feedNote(): Html { | ||
| 196 | 196 | } |
| 197 | 197 | |
| 198 | 198 | const CHIP_LABEL: Record<KindChip, string> = { |
| 199 | reviews: "Changes", | |
| 199 | reviews: "Pull requests", | |
| 200 | 200 | decisions: "Decisions", |
| 201 | 201 | crew: "Crew words", |
| 202 | 202 | releases: "Releases", |
src/web/studio/hold.ts +1 −1
| @@ -61,7 +61,7 @@ export function holdCard( | ||
| 61 | 61 | ); |
| 62 | 62 | if (!may || !open) return null; |
| 63 | 63 | return html`<details class="mt mb" id="hold"> |
| 64 | <summary class="muted small">Hold this change</summary> | |
| 64 | <summary class="muted small">Hold this pull request</summary> | |
| 65 | 65 | <form class="bar mt" method="post" action="${thread}/hold"> |
| 66 | 66 | <input |
| 67 | 67 | class="input" |
src/web/studio/lists.ts +19 −17
| @@ -1,12 +1,12 @@ | ||
| 1 | 1 | /** |
| 2 | * The forge's three lists — everything told, every change, every run — and | |
| 2 | * The forge's three lists — issues, pull requests, runs — and | |
| 3 | 3 | * the one filter bar they share: five words, each a link carrying `?word=`, |
| 4 | 4 | * so what a person is looking at lives in the address and can be shared. A |
| 5 | 5 | * row is the same word, note and time the thing's own page shows, because |
| 6 | 6 | * every one of them is read through `tellWord`/`pullWord`/`runWord`. Each |
| 7 | * list is bounded to one page. Told and changes are the only lists of | |
| 8 | * issues and pulls there are: every repository, every viewer, at `/issues` | |
| 9 | * and `/tells`, `/pulls` and `/changes` alike. | |
| 7 | * list is bounded to one page. These are the only lists of issues and | |
| 8 | * pulls there are: every repository, every viewer, at `/issues` and | |
| 9 | * `/pulls`. | |
| 10 | 10 | */ |
| 11 | 11 | |
| 12 | 12 | import { sealedScope } from "../../core/sealed.ts"; |
| @@ -116,6 +116,7 @@ function emptyNote( | ||
| 116 | 116 | starter: string, |
| 117 | 117 | ): string { |
| 118 | 118 | if (filter === undefined) return starter; |
| 119 | if (filter === "told") return `${subject} is waiting to be picked up.`; | |
| 119 | 120 | return `${subject} is ${filter} yet.`; |
| 120 | 121 | } |
| 121 | 122 | |
| @@ -134,7 +135,9 @@ function wordFilterBar( | ||
| 134 | 135 | ${seg([ |
| 135 | 136 | { label: "Open", href: href(undefined), current: open }, |
| 136 | 137 | ...words.map((w) => ({ |
| 137 | label: w[0]!.toUpperCase() + w.slice(1), | |
| 138 | // `told` is the state of an issue nobody has picked up: the filter | |
| 139 | // says so in those words, never as a noun for the issue (#3241). | |
| 140 | label: w === "told" ? "Not started" : w[0]!.toUpperCase() + w.slice(1), | |
| 138 | 141 | href: href(w), |
| 139 | 142 | current: current === w, |
| 140 | 143 | })), |
| @@ -323,7 +326,7 @@ function listRead(ctx: Ctx, repo: Repo, f: ListFacets) { | ||
| 323 | 326 | } |
| 324 | 327 | |
| 325 | 328 | /** |
| 326 | * GET /:owner/:name/issues, and /tells: everything told, for every | |
| 329 | * GET /:owner/:name/issues: every issue, for every | |
| 327 | 330 | * repository and every viewer — one row per issue in its studio word, who |
| 328 | 331 | * has it, its labels, when it last moved, and the tell itself as the row's |
| 329 | 332 | * own link. `?word=` narrows it, and so do `?label=`, `?assignee=`, |
| @@ -366,7 +369,7 @@ export async function tellsList(ctx: Ctx): Promise<Response> { | ||
| 366 | 369 | title: "Nothing here", |
| 367 | 370 | note: emptyNote( |
| 368 | 371 | f.word, |
| 369 | record === undefined ? "No issue" : "Nothing told", | |
| 372 | "No issue", | |
| 370 | 373 | record === undefined |
| 371 | 374 | ? "Nothing open." |
| 372 | 375 | : "Nothing open. Tell the crew what you want and it shows up here.", |
| @@ -386,18 +389,17 @@ export async function tellsList(ctx: Ctx): Promise<Response> { | ||
| 386 | 389 | )}${issue.assigneeName |
| 387 | 390 | ? html`${issue.assigneeName} · ` |
| 388 | 391 | : null}${info.note} · ${time(issue.updatedAt)}`, |
| 389 | trail: wordBadge(info.word, undefined, record === undefined), | |
| 392 | trail: wordBadge(info.word), | |
| 390 | 393 | }), |
| 391 | 394 | ), |
| 392 | 395 | ); |
| 393 | 396 | const lastNumber = fetched[fetched.length - 1]?.number; |
| 394 | // Self-links follow whichever noun reached this page — `/tells` or | |
| 395 | // `/issues` — so paging never jumps a reader to the other address. | |
| 397 | // Self-links follow the address that reached this page. | |
| 396 | 398 | const listBase = ctx.url.pathname; |
| 397 | 399 | // The one verb besides opening a row: say what you want. It posts from |
| 398 | 400 | // `/issues/new`, the compose page, in the words the repository has. |
| 399 | 401 | const compose = when(ctx.auth.user, () => |
| 400 | btn(PAGE_TITLES.compose[record === undefined ? "dev" : "studio"], { | |
| 402 | btn(PAGE_TITLES.compose, { | |
| 401 | 403 | href: `${base}/issues/new`, |
| 402 | 404 | size: "sm", |
| 403 | 405 | variant: "outline", |
| @@ -464,7 +466,7 @@ export async function tellsList(ctx: Ctx): Promise<Response> { | ||
| 464 | 466 | } |
| 465 | 467 | |
| 466 | 468 | /** |
| 467 | * GET /:owner/:name/pulls, and /changes: every change, for every | |
| 469 | * GET /:owner/:name/pulls: every pull request, for every | |
| 468 | 470 | * repository and every viewer — one row per pull in its studio word, who |
| 469 | 471 | * made it, its labels, when it last moved, and the change itself as the |
| 470 | 472 | * row's own link. Where a change lives, its diff and its reviews are one |
| @@ -499,10 +501,10 @@ export async function changesList(ctx: Ctx): Promise<Response> { | ||
| 499 | 501 | title: "Nothing here", |
| 500 | 502 | note: emptyNote( |
| 501 | 503 | f.word, |
| 502 | "No change", | |
| 504 | "No pull request", | |
| 503 | 505 | record === undefined |
| 504 | 506 | ? "Nothing open." |
| 505 | : "Nothing open. A change is the crew's proposed edit to your app; the crew opens them as it works.", | |
| 507 | : "Nothing open. A pull request is the crew's proposed edit to your app; the crew opens them as it works.", | |
| 506 | 508 | ), |
| 507 | 509 | }) |
| 508 | 510 | : listCard( |
| @@ -517,12 +519,12 @@ export async function changesList(ctx: Ctx): Promise<Response> { | ||
| 517 | 519 | ? originTag(pull.originAuthor, originGlyph(repo)) |
| 518 | 520 | : pull.authorName} · ${info.note} · |
| 519 | 521 | ${when(answered.get(pull.number), (t) => html`${tellLine(base, t)} · `)}${time(pull.updatedAt)}`, |
| 520 | trail: wordBadge(info.word, undefined, record === undefined), | |
| 522 | trail: wordBadge(info.word), | |
| 521 | 523 | }), |
| 522 | 524 | ), |
| 523 | 525 | ); |
| 524 | 526 | const lastNumber = fetched[fetched.length - 1]?.number; |
| 525 | // See `tellsList`: `/changes` or `/pulls`, whichever reached this page. | |
| 527 | // See `tellsList`. | |
| 526 | 528 | const listBase = ctx.url.pathname; |
| 527 | 529 | |
| 528 | 530 | const body = html`${repoHeader(ctx, repo, "pulls")} |
| @@ -669,7 +671,7 @@ export async function runsListPage(ctx: Ctx, repo: Repo): Promise<Response> { | ||
| 669 | 671 | ${time(run.finishedAt ?? run.createdAt)}` |
| 670 | 672 | : html`${run.kind === "agent" ? html`${who} · ` : null}${info.note} |
| 671 | 673 | · ${time(run.finishedAt ?? run.createdAt)}`, |
| 672 | trail: wordBadge(info.word, undefined, record === undefined), | |
| 674 | trail: wordBadge(info.word), | |
| 673 | 675 | }); |
| 674 | 676 | }), |
| 675 | 677 | ); |
src/web/studio/nouns.ts +39 −37
| @@ -1,39 +1,35 @@ | ||
| 1 | 1 | /** |
| 2 | * The studio's nouns: where one tell and one change live, and the one rule | |
| 3 | * for whether a viewer gets the studio at all on a repository. Every studio | |
| 4 | * surface — the app page, the asks, the lists, a tell, a change, the search | |
| 5 | * box and the palette — builds its links through here, so the page a reader | |
| 6 | * came from and the address bar they land in can never disagree about where | |
| 7 | * a tell lives. | |
| 2 | * Where one issue and one pull request live on an app's repository, and the | |
| 3 | * one rule for whether a viewer gets the studio at all on a repository. | |
| 4 | * Every studio surface — the app page, the asks, the lists, an issue, a | |
| 5 | * pull request, the search box and the palette — builds its links through | |
| 6 | * here, so the page a reader came from and the address bar they land in can | |
| 7 | * never disagree. | |
| 8 | 8 | * |
| 9 | * Dev's own addresses are never closed: `/issues/:number` and | |
| 10 | * `/pulls/:number` answer exactly as they always have, in both modes, so | |
| 11 | * every link already written to one keeps working forever. This is a second | |
| 12 | * door, not a replacement — and the doors that *change* something are not | |
| 13 | * here at all. A form still posts where it has always posted | |
| 14 | * (`/pulls/:number/ship`, `/issues/:number/comments`), which is what the | |
| 15 | * same-origin checks and `form-action 'self'` are written against. | |
| 9 | * Issues and pull requests are called that in every mode (#3241): the | |
| 10 | * studio once had its own nouns ("told", "changes") and its own addresses | |
| 11 | * for them, `/tells` and `/changes`. Those addresses now answer 301 to | |
| 12 | * `/issues` and `/pulls` (`src/http/server.ts`) so every link already | |
| 13 | * written to one keeps working; nothing here builds one any more. | |
| 16 | 14 | */ |
| 17 | 15 | |
| 18 | 16 | import { getAppByRepo, type AppRecord } from "../../core/apps.ts"; |
| 19 | 17 | import type { Repo } from "../../core/repos.ts"; |
| 20 | 18 | import type { Ctx } from "../../http/context.ts"; |
| 21 | 19 | |
| 22 | /** Everything told — the studio's own door onto `/issues`. */ | |
| 23 | export const tellsHref = (base: string): string => `${base}/tells`; | |
| 20 | /** The issues list (`tellsList`, `src/web/studio/lists.ts`). */ | |
| 21 | export const tellsHref = (base: string): string => `${base}/issues`; | |
| 24 | 22 | |
| 25 | /** Every change — the studio's own door onto `/pulls`. */ | |
| 26 | export const changesHref = (base: string): string => `${base}/changes`; | |
| 23 | /** The pull requests list (`changesList`, `src/web/studio/lists.ts`). */ | |
| 24 | export const changesHref = (base: string): string => `${base}/pulls`; | |
| 27 | 25 | |
| 28 | /** One tell: the same page `/issues/:number` reaches, under the studio's | |
| 29 | * own noun (`tellPage`, `src/web/pages/issues.ts`). */ | |
| 26 | /** One issue (`tellPage`, `src/web/studio/tell.ts`). */ | |
| 30 | 27 | export const tellHref = (base: string, number: number): string => |
| 31 | `${base}/tells/${number}`; | |
| 28 | `${base}/issues/${number}`; | |
| 32 | 29 | |
| 33 | /** One change: the same page `/pulls/:number` reaches, under the studio's | |
| 34 | * own noun (`changePage`, `src/web/studio/change.ts`). */ | |
| 30 | /** One pull request (`pullPage`, `src/web/studio/change.ts`). */ | |
outdated
|
||
| 35 | 31 | export const changeHref = (base: string, number: number): string => |
| 36 | `${base}/changes/${number}`; | |
| 32 | `${base}/pulls/${number}`; | |
| 37 | 33 | |
| 38 | 34 | /** |
| 39 | 35 | * The one rule for whether a page speaks the studio's words on this repo: |
| @@ -47,28 +43,34 @@ export function studioRecord(ctx: Ctx, repo: Repo): AppRecord | undefined { | ||
| 47 | 43 | /** |
| 48 | 44 | * What each page a studio viewer can reach calls itself in the browser tab |
| 49 | 45 | * — the bookmark, the history entry, the first thing a screen reader says |
| 50 | * on arrival — in both faces, decided here beside the addresses so a page | |
| 51 | * never carries a second literal of its own. `studio` is the word when | |
| 52 | * `studioRecord` gives the page a studio; `dev` is today's word for dev and | |
| 53 | * for any repository that is not an app. A single tell, change or run | |
| 54 | * titles itself by its own title, and the app page by the app's name, so | |
| 55 | * none of those is here. | |
| 46 | * on arrival — decided here beside the addresses so a page never carries a | |
| 47 | * second literal of its own. Issues, pull requests, compose and settings | |
| 48 | * are one word in every mode (#3241); only the runs list still has two | |
| 49 | * faces: `studio` when `studioRecord` gives the page a studio, `dev` for | |
| 50 | * dev and any repository that is not an app. A single issue, pull request | |
| 51 | * or run titles itself by its own title, and the app page by the app's | |
| 52 | * name, so none of those is here. | |
| 56 | 53 | */ |
| 57 | 54 | export const PAGE_TITLES = { |
outdated
|
||
| 58 | tells: { studio: "Everything told", dev: "Issues" }, | |
| 59 | changes: { studio: "Every change", dev: "Pull requests" }, | |
| 55 | tells: "Issues", | |
| 56 | changes: "Pull requests", | |
| 60 | 57 | runs: { studio: "Every run", dev: "Runs" }, |
| 61 | compose: { studio: "Tell the crew", dev: "New issue" }, | |
| 62 | settings: { studio: "Settings", dev: "Settings" }, | |
| 58 | compose: "New issue", | |
| 59 | settings: "Settings", | |
| 63 | 60 | } as const; |
| 64 | 61 | |
| 65 | 62 | export type TitledPage = keyof typeof PAGE_TITLES; |
| 66 | 63 | |
| 67 | 64 | /** A page's `<title>` noun and the repository it is about, in the face |
| 68 | * the viewer has: `Everything told · notes`, or `Issues · notes`. */ | |
| 69 | export const pageTitle = ( | |
| 65 | * the viewer has: `Issues · notes`, or `Every run · notes`. `studio` | |
| 66 | * decides only for a page with two faces (the runs list). */ | |
| 67 | export function pageTitle( | |
| 70 | 68 | which: TitledPage, |
| 71 | 69 | repo: Repo, |
| 72 | 70 | studio: boolean, |
| 73 | ): string => | |
| 74 | `${PAGE_TITLES[which][studio ? "studio" : "dev"]} · ${repo.name}`; | |
| 71 | ): string { | |
| 72 | const title = PAGE_TITLES[which]; | |
| 73 | const word = | |
| 74 | typeof title === "string" ? title : title[studio ? "studio" : "dev"]; | |
| 75 | return `${word} · ${repo.name}`; | |
| 76 | } |
src/web/studio/now.ts +4 −4
| @@ -152,7 +152,7 @@ export async function nowCard( | ||
| 152 | 152 | .slice(0, 6); |
| 153 | 153 | // Who signs a "Now" row is the one rule for it, `voiceFor` |
| 154 | 154 | // (`studio/voice.ts`): a person is named to anyone who can see the row — |
| 155 | // the same name `/changes` and the change's own page give — and only the | |
| 155 | // the same name `/pulls` and the pull request's own page give — and only the | |
| 156 | 156 | // crew's own work reads as the app's own story for a viewer who cannot |
| 157 | 157 | // see the roster. The avatar takes the same string, so its initials can |
| 158 | 158 | // never disagree with the name beside them. |
| @@ -259,13 +259,13 @@ export async function nowCard( | ||
| 259 | 259 | nav: true, |
| 260 | 260 | lead: avatar(who), |
| 261 | 261 | title: i.title, |
| 262 | meta: html`${who} · told ${time(i.createdAt)}, not picked up yet`, | |
| 262 | meta: html`${who} · opened ${time(i.createdAt)}, not picked up yet`, | |
| 263 | 263 | trail: wordBadge("waiting"), |
| 264 | 264 | }); |
| 265 | 265 | }), |
| 266 | 266 | // Held by a person: `tellWord` — the one place a tell's word is |
| 267 | 267 | // decided (`studio/tell.ts`) — says so honestly, the same word and |
| 268 | // note the tell's own page and "Everything told" already show for it. | |
| 268 | // note the issue's own page and the issues list already show for it. | |
| 269 | 269 | // `personHeld` has already established the holder is a person, so |
| 270 | 270 | // `voiceFor` names them to every viewer who can see the row. |
| 271 | 271 | ...(await Promise.all( |
| @@ -300,7 +300,7 @@ export async function nowCard( | ||
| 300 | 300 | title: html`<a href="${tellHref(base, i.number)}">${i.title}</a>`, |
| 301 | 301 | // The invitation is a writer's (the shelf's rule, `ownerFacts`); the |
| 302 | 302 | // fact — nobody has taken it — stays for everyone. |
| 303 | meta: html`told ${time(i.createdAt)} · | |
| 303 | meta: html`opened ${time(i.createdAt)} · | |
| 304 | 304 | <span class="muted">nobody on the crew to take it</span>${ownerFacts |
| 305 | 305 | ? html` — <a href="${base}/settings">add someone</a>` |
| 306 | 306 | : null}`, |
src/web/studio/shelf.ts +1 −1
| @@ -277,7 +277,7 @@ export async function shelfRows( | ||
| 277 | 277 | word === undefined |
| 278 | 278 | ? stuck.has(repo.id) |
| 279 | 279 | ? html`<span class="muted" |
| 280 | >told — ${NOBODY_ON_CREW} to take it</span | |
| 280 | >open — ${NOBODY_ON_CREW} to take it</span | |
| 281 | 281 | >` |
| 282 | 282 | : canTell |
| 283 | 283 | ? html`<span class="muted">quiet — tell the crew something</span>` |
src/web/studio/tell.ts +4 −7
| @@ -240,8 +240,7 @@ async function tellClosingPull( | ||
| 240 | 240 | } |
| 241 | 241 | |
| 242 | 242 | /** |
| 243 | * GET /:owner/:name/issues/:number and /tells/:number: one page, two | |
| 244 | * addresses, for every repository and every viewer. Same `threadContext` at both, so a reader who | |
| 243 | * GET /:owner/:name/issues/:number, for every repository and every viewer. Same `threadContext` at both, so a reader who | |
| 245 | 244 | * may not see it gets the forge's not-found at either. |
| 246 | 245 | */ |
| 247 | 246 | export async function tellPage(ctx: Ctx): Promise<Response> { |
| @@ -279,9 +278,7 @@ async function studioTell( | ||
| 279 | 278 | }; |
| 280 | 279 | const base = repoBase(repo); |
| 281 | 280 | // The only address the page *posts* to: the reply, the close and every |
| 282 | // Details form live under `/issues/:number`, whether a reader arrived | |
| 283 | // there or at `/tells/:number` (`tellHref`, nouns.ts). The write surface | |
| 284 | // does not move with the noun. | |
| 281 | // Details form live under `/issues/:number`. | |
| 285 | 282 | const thread = `${base}/issues/${issue.number}`; |
| 286 | 283 | const direction = repo.directionIssueId === issue.id; |
| 287 | 284 | const closingPull = links(() => openPullsClosing(db, repo.id).get(issue.number)); |
| @@ -344,7 +341,7 @@ async function studioTell( | ||
| 344 | 341 | ordinary tell</span |
| 345 | 342 | >` |
| 346 | 343 | : wordInfo |
| 347 | ? html`${wordBadge(wordInfo.word, undefined, plain)} | |
| 344 | ? html`${wordBadge(wordInfo.word)} | |
| 348 | 345 | <span class="muted small">${wordInfo.note}</span>` |
| 349 | 346 | : stateBadge(issue.state)} |
| 350 | 347 | <span |
| @@ -419,7 +416,7 @@ async function studioTell( | ||
| 419 | 416 | action: `${thread}/comments`, |
| 420 | 417 | ...(mayClose ? { stateAction: `${thread}/state` } : {}), |
| 421 | 418 | state: issue.state, |
| 422 | closeLabel: plain ? "Close issue" : "Close tell", | |
| 419 | closeLabel: "Close issue", | |
| 423 | 420 | crewNote: threadNote(ctx.app.db, repo, issue), |
| 424 | 421 | reasons: true, |
| 425 | 422 | })} |
src/web/studio/voice.ts +2 −2
| @@ -4,7 +4,7 @@ | ||
| 4 | 4 | * of work to two different people. |
| 5 | 5 | * |
| 6 | 6 | * The decision, once: **an agent's name is not a secret.** A change's own |
| 7 | * page and `/changes` already name the agent that opened it to anyone who | |
| 7 | * page and `/pulls` already name the agent that opened it to anyone who | |
| 8 | 8 | * can read the repository, and nothing here could take that back. What |
| 9 | 9 | * "the crew" stands in for is narrower — who is on the crew is part of the |
| 10 | 10 | * roster, the ceiling and the ledger, the owner's own book-keeping |
| @@ -17,7 +17,7 @@ | ||
| 17 | 17 | * |
| 18 | 18 | * 1. Is the actor an agent? A person is named to every viewer that can see |
| 19 | 19 | * the row at all — their authorship is theirs, and the app page must |
| 20 | * say what `/changes` and the change's own page say. | |
| 20 | * say what `/pulls` and the pull request's own page say. | |
| 21 | 21 | * 2. Can this viewer see the owner's facts? A writer reads the real name. |
| 22 | 22 | * |
| 23 | 23 | * Nothing here decides *whether* a row is visible: a viewer who cannot see |
src/web/studio/words.ts +2 −2
| @@ -397,7 +397,7 @@ const WORD_ICON: Record<Word, Parameters<typeof icon>[0]> = { | ||
| 397 | 397 | * live script repaints as a run finishes (`studioRunJs`). `cancelled` draws |
| 398 | 398 | * the same quiet, secondary look as `waiting` and `planned` — a restart is |
| 399 | 399 | * never the danger red a real failure earns. */ |
| 400 | export function wordBadge(word: Word, id?: string, plain = false): Html { | |
| 400 | export function wordBadge(word: Word, id?: string): Html { | |
| 401 | 401 | const variant = |
| 402 | 402 | word === "look" || word === "shipped" || word === "done" |
| 403 | 403 | ? "success" |
| @@ -411,7 +411,7 @@ export function wordBadge(word: Word, id?: string, plain = false): Html { | ||
| 411 | 411 | data-variant="${variant}" |
| 412 | 412 | data-word="${word}" |
| 413 | 413 | ${id === undefined ? null : html`id="${id}"`} |
| 414 | >${word === "working" ? icon("loader") : null}${plain && word === "told" | |
| 414 | >${word === "working" ? icon("loader") : null}${word === "told" | |
| 415 | 415 | ? "open" |
| 416 | 416 | : word}</span |
| 417 | 417 | >`; |
test/asks-expiry.test.ts +1 −1
| @@ -190,7 +190,7 @@ describe("asks that expire, hold and collapse", () => { | ||
| 190 | 190 | const lines = askLinesFor(app.db, aliceUser); |
| 191 | 191 | expect(lines.map((l) => l.id)).toEqual([`look:${apples.id}`]); |
| 192 | 192 | expect(lines[0]!.link).toBe( |
| 193 | `/${repo.ownerName}/${repo.name}/changes/${apples.number}`, | |
| 193 | `/${repo.ownerName}/${repo.name}/pulls/${apples.number}`, | |
| 194 | 194 | ); |
| 195 | 195 | }); |
| 196 | 196 |
test/asks-rank.test.ts +2 −2
| @@ -226,7 +226,7 @@ describe("asks that earn their place", () => { | ||
| 226 | 226 | } |
| 227 | 227 | ).items; |
| 228 | 228 | const ask = items.find((i) => i.kind === "ask"); |
| 229 | expect(ask?.ask?.decision).toBe("Ship this change?"); | |
| 229 | expect(ask?.ask?.decision).toBe("Ship this pull request?"); | |
| 230 | 230 | expect( |
| 231 | 231 | items.some((i) => (i.ask as { why?: string } | undefined)?.why === "security"), |
| 232 | 232 | ).toBe(true); |
| @@ -334,7 +334,7 @@ describe("asks that earn their place", () => { | ||
| 334 | 334 | const fromMcp = await mcpAsks(); |
| 335 | 335 | expect(fromMcp.measure.raised).toBe(6); |
| 336 | 336 | expect(fromMcp.rows).toHaveLength(3); |
| 337 | expect(fromMcp.rows[0]!.words.decision).toBe("Ship this change?"); | |
| 337 | expect(fromMcp.rows[0]!.words.decision).toBe("Ship this pull request?"); | |
| 338 | 338 | // Nothing changed: a second pass raises nothing new. |
| 339 | 339 | recordAskLedger(app.db); |
| 340 | 340 | expect((await mcpAsks()).measure.raised).toBe(6); |
test/carried-deploy.test.ts +1 −1
| @@ -123,6 +123,6 @@ describe("a production deploy carries what it records", () => { | ||
| 123 | 123 | }) |
| 124 | 124 | ).text(); |
| 125 | 125 | expect(page).toContain('id="run-carries"'); |
| 126 | expect(page).toContain(`/changes/${two.number}`); | |
| 126 | expect(page).toContain(`/pulls/${two.number}`); | |
| 127 | 127 | }, 180_000); |
| 128 | 128 | }); |
test/feed-page.test.ts +1 −1
| @@ -95,7 +95,7 @@ describe("the feed on the home page", () => { | ||
| 95 | 95 | expect(text).toContain('href="/alice/open/runs/r1"'); |
| 96 | 96 | expect(text).toContain('action="/alice/open/runs/r1/steer"'); |
| 97 | 97 | const item = readFeed(app.db, alice).items[0]!; |
| 98 | const tell = feedCard({ ...item, kind: "pull", link: "/alice/open/changes/1" }).text; | |
| 98 | const tell = feedCard({ ...item, kind: "pull", link: "/alice/open/pulls/1" }).text; | |
| 99 | 99 | expect(tell).toContain('action="/alice/open/tell"'); |
| 100 | 100 | expect(tell).toContain('data-feed="dismissed"'); |
| 101 | 101 | expect(tell).toContain('data-feed="muted"'); |
test/first-login.test.ts +1 −1
| @@ -212,7 +212,7 @@ describe("the first login to a spawned forge", () => { | ||
| 212 | 212 | expect(home).toContain("Your crew has its direction:"); |
| 213 | 213 | expect(home).not.toContain("already at work"); |
| 214 | 214 | expect(home).toContain("atlas"); |
| 215 | expect(home).toMatch(/href="\/marias\/bakery\/tells\/\d+"[^>]*>[^<]*Run Maria/); | |
| 215 | expect(home).toMatch(/href="\/marias\/bakery\/issues\/\d+"[^>]*>[^<]*Run Maria/); | |
| 216 | 216 | // Each app with its way in. |
| 217 | 217 | expect(home).toContain('href="/marias/bakery"'); |
| 218 | 218 | }); |
test/here-manifest.test.ts +1 −1
| @@ -319,7 +319,7 @@ describe("the manifest: what you can do here, as who you are", () => { | ||
| 319 | 319 | headBranch: "mine", |
| 320 | 320 | }); |
| 321 | 321 | if (!own.ok) throw new Error(String(own.error)); |
| 322 | const ownBody = (await manifest(`/acme/game/changes/${own.value.number}`)).body; | |
| 322 | const ownBody = (await manifest(`/acme/game/pulls/${own.value.number}`)).body; | |
| 323 | 323 | expect(ownBody.here.pull).toEqual({ number: own.value.number, runs: true }); |
| 324 | 324 | }); |
| 325 | 325 | }); |
test/holds.test.ts +3 −3
| @@ -678,9 +678,9 @@ describe("a hold is set, shown and lifted", () => { | ||
| 678 | 678 | |
| 679 | 679 | test("the change page offers the hold, then says who holds it and why, with the way to let it go", async () => { |
| 680 | 680 | const cookie = await signIn("alice"); |
| 681 | const change = `/alice/garden/changes/${pull.number}`; | |
| 681 | const change = `/alice/garden/pulls/${pull.number}`; | |
| 682 | 682 | const before = await page(change, cookie); |
| 683 | expect(before).toContain("Hold this change"); | |
| 683 | expect(before).toContain("Hold this pull request"); | |
| 684 | 684 | expect(before).toContain( |
| 685 | 685 | `action="/alice/garden/pulls/${pull.number}/hold"`, |
| 686 | 686 | ); |
| @@ -707,7 +707,7 @@ describe("a hold is set, shown and lifted", () => { | ||
| 707 | 707 | expect(after).toContain( |
| 708 | 708 | `action="/alice/garden/pulls/${pull.number}/release"`, |
| 709 | 709 | ); |
| 710 | expect(after).not.toContain("Hold this change"); | |
| 710 | expect(after).not.toContain("Hold this pull request"); | |
| 711 | 711 | // The change's Files step, the one that reads the code, says it too |
| 712 | 712 | // (#628: one change page; `?mode=dev` answers the same page now). |
| 713 | 713 | const dev = await page(`/alice/garden/pulls/${pull.number}/files`, cookie); |
test/home-feed.test.ts +1 −1
| @@ -134,7 +134,7 @@ describe("home is the feed", () => { | ||
| 134 | 134 | // carries what was said. |
| 135 | 135 | expect(text).toContain("Water the tomatoes at dawn"); |
| 136 | 136 | expect(text).toMatch( |
| 137 | /href="\/alice\/garden\/tells\/1"[^>]*data-feed-open>Water the tomatoes at dawn</, | |
| 137 | /href="\/alice\/garden\/issues\/1"[^>]*data-feed-open>opened an issue: Water the tomatoes at dawn</, | |
| 138 | 138 | ); |
| 139 | 139 | // A reply names the tell it answers. |
| 140 | 140 | expect(text).toContain("on “Water the tomatoes at dawn”: Mulch them first"); |
test/home-walk.test.ts +2 −2
| @@ -75,7 +75,7 @@ test("composer, timeline, a tell, a crew member's run and pull, a share, the app | ||
| 75 | 75 | }); |
| 76 | 76 | expect(posted.status).toBe(303); |
| 77 | 77 | expect(await home(alice)).toMatch( |
| 78 | /href="\/alice\/garden\/tells\/1"[^>]*data-feed-open>Plant the beans</, | |
| 78 | /href="\/alice\/garden\/issues\/1"[^>]*data-feed-open>opened an issue: Plant the beans</, | |
| 79 | 79 | ); |
| 80 | 80 | |
| 81 | 81 | // The crew member's run and pull are posts under its name. |
| @@ -94,7 +94,7 @@ test("composer, timeline, a tell, a crew member's run and pull, a share, the app | ||
| 94 | 94 | const page = await home(alice); |
| 95 | 95 | expect(page).toContain("Sorted the seed list"); |
| 96 | 96 | expect(page).toMatch(/fcard-who">scribe<\/strong\s*>\s*<span[^>]*data-mark="crew"/); |
| 97 | expect(page).toContain("a change was opened"); | |
| 97 | expect(page).toContain("opened a pull request"); | |
| 98 | 98 | |
| 99 | 99 | // A share reaches another person as a post that says who shared it. |
| 100 | 100 | const shared = await share(app, alice, `event:${run.id}`, ["bob"]); |
test/issues.test.ts +1 −1
| @@ -117,7 +117,7 @@ describe("issue pages end to end", () => { | ||
| 117 | 117 | |
| 118 | 118 | const list = await (await fetch(`${base}/mel/proj/issues`)).text(); |
| 119 | 119 | expect(list).toContain("First bug"); |
| 120 | expect(list).toContain('href="/mel/proj/tells/1"'); | |
| 120 | expect(list).toContain('href="/mel/proj/issues/1"'); | |
| 121 | 121 | |
| 122 | 122 | const detail = await (await fetch(`${base}/mel/proj/issues/1`)).text(); |
| 123 | 123 | expect(detail).toContain("<strong>broken</strong>"); |
test/name-marks.test.ts +1 −1
| @@ -266,7 +266,7 @@ describe("a name always says who it is", () => { | ||
| 266 | 266 | app: "alice/garden", |
| 267 | 267 | actor, |
| 268 | 268 | at: new Date().toISOString(), |
| 269 | link: `/alice/garden/changes/${pull.number}`, | |
| 269 | link: `/alice/garden/pulls/${pull.number}`, | |
| 270 | 270 | what: "Beds", |
| 271 | 271 | reason: "", |
| 272 | 272 | score: 1, |
test/pull-checks-line-route.test.ts +1 −1
| @@ -156,7 +156,7 @@ test("once the check has passed, the line has no beat, no counts, and reads as p | ||
| 156 | 156 | |
| 157 | 157 | test("the change page the owner lands on draws the same live line, with the counts", async () => { |
| 158 | 158 | app.db.run("UPDATE run SET state = 'running', finished_at = NULL WHERE id = ?", [runId]); |
| 159 | const res = await fetch(`${servers.url}/alice/open/changes/${numbers["open"]}`, { | |
| 159 | const res = await fetch(`${servers.url}/alice/open/pulls/${numbers["open"]}`, { | |
| 160 | 160 | headers: { cookie: `forge_session=${createSession(app.db, alice.id).token}` }, |
| 161 | 161 | }); |
| 162 | 162 | expect(res.status).toBe(200); |
test/sealed-issues.test.ts −2
| @@ -118,9 +118,7 @@ const SINGLES = (n: number, p: number) => [ | ||
| 118 | 118 | `/api/v1/repos/alice/square/runs/${runId}`, |
| 119 | 119 | `/api/v1/repos/alice/square/runs/${runId}/log`, |
| 120 | 120 | `/alice/square/issues/${n}`, |
| 121 | `/alice/square/tells/${n}`, | |
| 122 | 121 | `/alice/square/pulls/${p}`, |
| 123 | `/alice/square/changes/${p}`, | |
| 124 | 122 | `/alice/square/runs/${runId}`, |
| 125 | 123 | ]; |
| 126 | 124 |
test/studio-changes.test.ts +54 −75
| @@ -371,7 +371,7 @@ describe("the studio, continued", () => { | ||
| 371 | 371 | expect(issues).not.toContain(`href="/alice/plainlists/issues?word=told"`); |
| 372 | 372 | expect(issues).not.toContain(`href="/alice/plainlists/issues?word=look"`); |
| 373 | 373 | expect(issues).toContain( |
| 374 | `href="/alice/plainlists/tells/${told.value.number}"`, | |
| 374 | `href="/alice/plainlists/issues/${told.value.number}"`, | |
| 375 | 375 | ); |
| 376 | 376 | expect(issues).not.toContain("See dev's list"); |
| 377 | 377 | |
| @@ -388,26 +388,21 @@ describe("the studio, continued", () => { | ||
| 388 | 388 | expect(runs).not.toContain("See dev's list"); |
| 389 | 389 | }); |
| 390 | 390 | |
| 391 | test("/tells and /changes are the studio's own doors onto the same lists /issues and /pulls reach — same words, same filter, same paging", async () => { | |
| 391 | test("the issues and pulls lists of an app filter by word, and their self-links stay on their own address", async () => { | |
| 392 | 392 | const told = openIssue(app, alice, repo, { |
| 393 | 393 | title: "Door test: a tell", |
| 394 | body: "reached through /tells too", | |
| 394 | body: "reached through /issues", | |
| 395 | 395 | }); |
| 396 | 396 | if (!told.ok) throw new Error(String(told.error)); |
| 397 | 397 | |
| 398 | 398 | const viaIssues = await (await get("/alice/notes/issues")).text(); |
| 399 | const viaTells = await (await get("/alice/notes/tells")).text(); | |
| 400 | expect(viaTells).toContain("Door test: a tell"); | |
| 401 | expect(viaTells).toContain('data-word="told"'); | |
| 402 | // Self-links (the word filter, paging) follow whichever door was used, | |
| 403 | // so a dev URL asked for in studio mode still answers exactly as it | |
| 404 | // does today. | |
| 405 | expect(viaTells).toContain(`href="/alice/notes/tells?word=told"`); | |
| 399 | expect(viaIssues).toContain("Door test: a tell"); | |
| 400 | expect(viaIssues).toContain('data-word="told"'); | |
| 406 | 401 | expect(viaIssues).toContain(`href="/alice/notes/issues?word=told"`); |
| 407 | 402 | |
| 408 | const filtered = await (await get("/alice/notes/tells?word=told")).text(); | |
| 403 | const filtered = await (await get("/alice/notes/issues?word=told")).text(); | |
| 409 | 404 | expect(filtered).toContain("Door test: a tell"); |
| 410 | const other = await (await get("/alice/notes/tells?word=shipped")).text(); | |
| 405 | const other = await (await get("/alice/notes/issues?word=shipped")).text(); | |
| 411 | 406 | expect(other).not.toContain("Door test: a tell"); |
| 412 | 407 | |
| 413 | 408 | const branch = await commitToBranch(app, alice, repo, { |
| @@ -437,18 +432,16 @@ describe("the studio, continued", () => { | ||
| 437 | 432 | finishRun(app.db, doorCheck.id, { state: "ok", summary: "check passed" }); |
| 438 | 433 | |
| 439 | 434 | const viaPulls = await (await get("/alice/notes/pulls")).text(); |
| 440 | const viaChanges = await (await get("/alice/notes/changes")).text(); | |
| 441 | expect(viaChanges).toContain("Door test: a change"); | |
| 442 | expect(viaChanges).toContain('data-word="look"'); | |
| 443 | expect(viaChanges).toContain(`href="/alice/notes/changes?word=look"`); | |
| 435 | expect(viaPulls).toContain("Door test: a change"); | |
| 436 | expect(viaPulls).toContain('data-word="look"'); | |
| 444 | 437 | expect(viaPulls).toContain(`href="/alice/notes/pulls?word=look"`); |
| 445 | 438 | |
| 446 | 439 | const changeFiltered = await ( |
| 447 | await get("/alice/notes/changes?word=look") | |
| 440 | await get("/alice/notes/pulls?word=look") | |
| 448 | 441 | ).text(); |
| 449 | 442 | expect(changeFiltered).toContain("Door test: a change"); |
| 450 | 443 | const changeOther = await ( |
| 451 | await get("/alice/notes/changes?word=shipped") | |
| 444 | await get("/alice/notes/pulls?word=shipped") | |
| 452 | 445 | ).text(); |
| 453 | 446 | expect(changeOther).not.toContain("Door test: a change"); |
| 454 | 447 | // A green pull left open now asks the front door too (#208) — close it |
| @@ -456,7 +449,7 @@ describe("the studio, continued", () => { | ||
| 456 | 449 | expect(setIssueState(app, alice, repo, doorPull, "closed").ok).toBe(true); |
| 457 | 450 | }); |
| 458 | 451 | |
| 459 | test("/tells and /changes answer a dev-mode viewer, ?mode=dev, and a repository that is not an app with the same list, never a bounce", async () => { | |
| 452 | test("/tells and /changes answer every viewer, app or not, with the same 301 to /issues and /pulls (#3241)", async () => { | |
| 460 | 453 | const plain = await createRepo(app, alice, { |
| 461 | 454 | name: "doorlists", |
| 462 | 455 | visibility: "private", |
| @@ -472,9 +465,10 @@ describe("the studio, continued", () => { | ||
| 472 | 465 | ["/alice/doorlists/changes", cookie], |
| 473 | 466 | ] as const) { |
| 474 | 467 | const res = await get(path, { cookie: viewer }); |
| 475 | expect(res.status).toBe(200); | |
| 476 | const text = await res.text(); | |
| 477 | expect(text).toContain(`href="${path.split("?")[0]}?word=`); | |
| 468 | expect(res.status).toBe(301); | |
| 469 | expect(res.headers.get("location")).toBe( | |
| 470 | path.replace(/\/tells$/, "/issues").replace(/\/changes$/, "/pulls"), | |
| 471 | ); | |
| 478 | 472 | } |
| 479 | 473 | }); |
| 480 | 474 | |
| @@ -650,44 +644,37 @@ describe("the studio, continued", () => { | ||
| 650 | 644 | expect(pulls).not.toContain('href="/alice/composelists/pulls/new"'); |
| 651 | 645 | }); |
| 652 | 646 | |
| 653 | test("a reader without access gets the forge's not-found on /tells and /changes, exactly as /issues and /pulls", async () => { | |
| 647 | test("a reader without access who follows /tells or /changes lands on the forge's not-found, exactly as /issues and /pulls", async () => { | |
| 654 | 648 | const anonTells = await fetch(`${base}/alice/notes/tells`, { |
| 655 | 649 | headers: { cookie: "forge_mode=studio" }, |
| 656 | redirect: "manual", | |
| 657 | 650 | }); |
| 658 | 651 | expect(anonTells.status).toBe(404); |
| 659 | 652 | |
| 660 | 653 | const anonChanges = await fetch(`${base}/alice/notes/changes`, { |
| 661 | 654 | headers: { cookie: "forge_mode=studio" }, |
| 662 | redirect: "manual", | |
| 663 | 655 | }); |
| 664 | 656 | expect(anonChanges.status).toBe(404); |
| 665 | 657 | }); |
| 666 | 658 | |
| 667 | test("/tells/:number and /changes/:number reach the same pages /issues/:number and /pulls/:number reach — one page, the same anchors, and forms that still post to dev's own address", async () => { | |
| 659 | test("/issues/:number and /pulls/:number are an app's issue and pull request pages, and every studio surface links there", async () => { | |
| 668 | 660 | const told = openIssue(app, alice, repo, { |
| 669 | 661 | title: "Door test: one tell", |
| 670 | body: "reached through /tells/:number too", | |
| 662 | body: "reached through /issues/:number", | |
| 671 | 663 | }); |
| 672 | 664 | if (!told.ok) throw new Error(String(told.error)); |
| 673 | 665 | const n = told.value.number; |
| 674 | 666 | |
| 675 | const viaTell = await get(`/alice/notes/tells/${n}`); | |
| 667 | const viaTell = await get(`/alice/notes/issues/${n}`); | |
| 676 | 668 | expect(viaTell.status).toBe(200); |
| 677 | 669 | const tellHtml = await viaTell.text(); |
| 678 | const viaIssue = await (await get(`/alice/notes/issues/${n}`)).text(); | |
| 679 | // The same page, never a second copy of the markup. | |
| 680 | 670 | expect(tellHtml).toContain("Door test: one tell"); |
| 681 | 671 | expect(tellHtml).toContain('data-word="told"'); |
| 682 | 672 | // Still waiting, nobody assigned: the one case the line is true (#214). |
| 683 | 673 | expect(tellHtml).toContain("nobody has it yet"); |
| 684 | expect(viaIssue).toContain('data-word="told"'); | |
| 685 | // The write surface does not move with the noun: the reply, the close | |
| 686 | // and every Details form still point at /issues/:number. | |
| 674 | // The reply, the close and every Details form post to /issues/:number. | |
| 687 | 675 | expect(tellHtml).toContain(`action="/alice/notes/issues/${n}/comments"`); |
| 688 | 676 | expect(tellHtml).toContain(`action="/alice/notes/issues/${n}/assignee"`); |
| 689 | // An anchor into the conversation still lands — the composer is on the | |
| 690 | // page either door was used. | |
| 677 | // An anchor into the conversation lands on the composer. | |
| 691 | 678 | expect(tellHtml).toContain('id="comment-body"'); |
| 692 | 679 | |
| 693 | 680 | const branch = await commitToBranch(app, alice, repo, { |
| @@ -706,7 +693,7 @@ describe("the studio, continued", () => { | ||
| 706 | 693 | if (!opened.ok) throw new Error(String(opened.error)); |
| 707 | 694 | const p = opened.value.number; |
| 708 | 695 | |
| 709 | const viaChange = await get(`/alice/notes/changes/${p}`); | |
| 696 | const viaChange = await get(`/alice/notes/pulls/${p}`); | |
| 710 | 697 | expect(viaChange.status).toBe(200); |
| 711 | 698 | const changeHtml = await viaChange.text(); |
| 712 | 699 | expect(changeHtml).toContain("Door test: one change"); |
| @@ -716,30 +703,22 @@ describe("the studio, continued", () => { | ||
| 716 | 703 | expect(changeHtml).toContain(`href="/alice/notes/pulls/${p}/files"`); |
| 717 | 704 | expect(changeHtml).toContain('id="comment-body"'); |
| 718 | 705 | |
| 719 | // Dev's own addresses answer the studio's pages where they stand — a | |
| 720 | // link already written to one is never bounced anywhere. | |
| 721 | const stillIssue = await get(`/alice/notes/issues/${n}`); | |
| 722 | expect(stillIssue.status).toBe(200); | |
| 723 | const stillPull = await get(`/alice/notes/pulls/${p}`); | |
| 724 | expect(stillPull.status).toBe(200); | |
| 725 | ||
| 726 | // The three lists, the app page and the search box all hand out the | |
| 727 | // studio's noun, so a person is never given two addresses for one thing. | |
| 728 | const tells = await (await get("/alice/notes/tells")).text(); | |
| 729 | expect(tells).toContain(`href="/alice/notes/tells/${n}"`); | |
| 730 | const changes = await (await get("/alice/notes/changes")).text(); | |
| 731 | expect(changes).toContain(`href="/alice/notes/changes/${p}"`); | |
| 706 | // The lists, the app page and the search box all hand out the one | |
| 707 | // address, so a person is never given two addresses for one thing. | |
| 708 | const tells = await (await get("/alice/notes/issues")).text(); | |
| 709 | expect(tells).toContain(`href="/alice/notes/issues/${n}"`); | |
| 710 | const changes = await (await get("/alice/notes/pulls")).text(); | |
| 711 | expect(changes).toContain(`href="/alice/notes/pulls/${p}"`); | |
| 732 | 712 | const appPage = await (await get("/alice/notes")).text(); |
| 733 | expect(appPage).toContain(`href="/alice/notes/changes/${p}"`); | |
| 713 | expect(appPage).toContain(`href="/alice/notes/pulls/${p}"`); | |
| 714 | expect(appPage).not.toMatch(/href="[^"]*\/(tells|changes)\//); | |
| 734 | 715 | const search = await (await get("/search?q=Door+test")).text(); |
| 735 | expect(search).toContain(`/alice/notes/tells/${n}`); | |
| 736 | expect(search).toContain(`/alice/notes/changes/${p}`); | |
| 737 | // The palette's remote rows are folded in the browser; the index tells | |
| 738 | // the script which mode it is in, so it folds the same nouns. | |
| 739 | expect(appPage).toContain('"studio":true'); | |
| 716 | expect(search).toContain(`/alice/notes/issues/${n}`); | |
| 717 | expect(search).toContain(`/alice/notes/pulls/${p}`); | |
| 718 | expect(search).not.toMatch(/\/alice\/notes\/(tells|changes)\//); | |
| 740 | 719 | }); |
| 741 | 720 | |
| 742 | test("/tells/:number answers a dev-mode viewer, ?mode=dev, and a repository that is not an app with the same page, never a bounce", async () => { | |
| 721 | test("/tells/:number answers every viewer, app or not, with the same 301 to /issues/:number (#3241)", async () => { | |
| 743 | 722 | const told = openIssue(app, alice, repo, { title: "Door test: dev's way" }); |
| 744 | 723 | if (!told.ok) throw new Error(String(told.error)); |
| 745 | 724 | const n = told.value.number; |
| @@ -750,8 +729,8 @@ describe("the studio, continued", () => { | ||
| 750 | 729 | }), |
| 751 | 730 | await get(`/alice/notes/tells/${n}`), |
| 752 | 731 | ]) { |
| 753 | expect(res.status).toBe(200); | |
| 754 | expect(await res.text()).toContain("Door test: dev"); | |
| 732 | expect(res.status).toBe(301); | |
| 733 | expect(res.headers.get("location")).toBe(`/alice/notes/issues/${n}`); | |
| 755 | 734 | } |
| 756 | 735 | |
| 757 | 736 | const plain = await createRepo(app, alice, { |
| @@ -767,18 +746,19 @@ describe("the studio, continued", () => { | ||
| 767 | 746 | const plainTell = await get( |
| 768 | 747 | `/alice/doorpages/tells/${plainTold.value.number}`, |
| 769 | 748 | ); |
| 770 | expect(plainTell.status).toBe(200); | |
| 771 | expect(await plainTell.text()).toContain("Not an app"); | |
| 749 | expect(plainTell.status).toBe(301); | |
| 750 | expect(plainTell.headers.get("location")).toBe( | |
| 751 | `/alice/doorpages/issues/${plainTold.value.number}`, | |
| 752 | ); | |
| 772 | 753 | }); |
| 773 | 754 | |
| 774 | test("a reader without access gets the forge's not-found on /tells/:number and /changes/:number, exactly as /issues/:number and /pulls/:number", async () => { | |
| 755 | test("a reader without access who follows /tells/:number or /changes/:number lands on the forge's not-found, exactly as /issues/:number and /pulls/:number", async () => { | |
| 775 | 756 | const told = openIssue(app, alice, repo, { title: "Door test: private" }); |
| 776 | 757 | if (!told.ok) throw new Error(String(told.error)); |
| 777 | 758 | const n = told.value.number; |
| 778 | 759 | |
| 779 | 760 | const anonTell = await fetch(`${base}/alice/notes/tells/${n}`, { |
| 780 | 761 | headers: { cookie: "forge_mode=studio" }, |
| 781 | redirect: "manual", | |
| 782 | 762 | }); |
| 783 | 763 | expect(anonTell.status).toBe(404); |
| 784 | 764 | const anonIssue = await fetch(`${base}/alice/notes/issues/${n}`, { |
| @@ -789,11 +769,10 @@ describe("the studio, continued", () => { | ||
| 789 | 769 | |
| 790 | 770 | // A number that is not a tell is not a tell at either address, and a |
| 791 | 771 | // change's number is not one under the tell's own noun. |
| 792 | const missing = await get("/alice/notes/tells/99999"); | |
| 772 | const missing = await get("/alice/notes/issues/99999"); | |
| 793 | 773 | expect(missing.status).toBe(404); |
| 794 | 774 | const anonChange = await fetch(`${base}/alice/notes/changes/1`, { |
| 795 | 775 | headers: { cookie: "forge_mode=studio" }, |
| 796 | redirect: "manual", | |
| 797 | 776 | }); |
| 798 | 777 | expect(anonChange.status).toBe(404); |
| 799 | 778 | }); |
| @@ -1313,28 +1292,28 @@ describe("the studio, continued", () => { | ||
| 1313 | 1292 | const stray = await open("scout/99999-nothing", "Answers nothing"); |
| 1314 | 1293 | const own = await open("revert-22", "A person's branch"); |
| 1315 | 1294 | |
| 1316 | const list = await (await get("/alice/notes/changes")).text(); | |
| 1295 | const list = await (await get("/alice/notes/pulls")).text(); | |
| 1317 | 1296 | const rowOf = (title: string) => { |
| 1318 | 1297 | const at = list.indexOf(title); |
| 1319 | 1298 | return list.slice(at, list.indexOf("</a>", at + 400) + 4); |
| 1320 | 1299 | }; |
| 1321 | expect(rowOf("Answers the tell")).toContain(`href="/alice/notes/tells/${n}"`); | |
| 1300 | expect(rowOf("Answers the tell")).toContain(`href="/alice/notes/issues/${n}"`); | |
| 1322 | 1301 | expect(rowOf("Answers the tell")).not.toContain("already done"); |
| 1323 | expect(rowOf("Answers nothing")).not.toContain("for tell"); | |
| 1324 | expect(rowOf("A person's branch")).not.toContain("for tell"); | |
| 1302 | expect(rowOf("Answers nothing")).not.toContain("for issue"); | |
| 1303 | expect(rowOf("A person's branch")).not.toContain("for issue"); | |
| 1325 | 1304 | |
| 1326 | 1305 | setIssueState(app, alice, repo, told.value, "closed"); |
| 1327 | const closedList = await (await get("/alice/notes/changes")).text(); | |
| 1328 | expect(closedList).toContain("its tell is already done"); | |
| 1306 | const closedList = await (await get("/alice/notes/pulls")).text(); | |
| 1307 | expect(closedList).toContain("its issue is already done"); | |
| 1329 | 1308 | const page = await ( |
| 1330 | await get(`/alice/notes/changes/${answering.number}`) | |
| 1309 | await get(`/alice/notes/pulls/${answering.number}`) | |
| 1331 | 1310 | ).text(); |
| 1332 | expect(page).toContain(`href="/alice/notes/tells/${n}"`); | |
| 1333 | expect(page).toContain("its tell is already done"); | |
| 1311 | expect(page).toContain(`href="/alice/notes/issues/${n}"`); | |
| 1312 | expect(page).toContain("its issue is already done"); | |
| 1334 | 1313 | for (const p of [stray, own]) { |
| 1335 | const res = await get(`/alice/notes/changes/${p.number}`); | |
| 1314 | const res = await get(`/alice/notes/pulls/${p.number}`); | |
| 1336 | 1315 | expect(res.status).toBe(200); |
| 1337 | expect(await res.text()).not.toContain("for tell"); | |
| 1316 | expect(await res.text()).not.toContain("for issue"); | |
| 1338 | 1317 | } |
| 1339 | 1318 | }); |
| 1340 | 1319 | }); |
test/studio-door.test.ts +1 −1
| @@ -492,7 +492,7 @@ describe("every open pull's preview on the studio page", () => { | ||
| 492 | 492 | expect(production).not.toContain(note); |
| 493 | 493 | |
| 494 | 494 | const changePage = await ( |
| 495 | await get(`/alice/widget/changes/${pull.number}`) | |
| 495 | await get(`/alice/widget/pulls/${pull.number}`) | |
| 496 | 496 | ).text(); |
| 497 | 497 | expect(changePage).toContain("<h2>Preview</h2>"); |
| 498 | 498 | expect(changePage).toContain(note); |
test/studio-runs.test.ts +1 −1
| @@ -510,7 +510,7 @@ describe("working: a run's own page in the studio", () => { | ||
| 510 | 510 | ).text(); |
| 511 | 511 | expect(page).toContain("Fix the flicker on load"); |
| 512 | 512 | // The tell it came from, in the studio's own noun (#158). |
| 513 | expect(page).toContain(`href="/alice/engine/tells/${tell.value.number}"`); | |
| 513 | expect(page).toContain(`href="/alice/engine/issues/${tell.value.number}"`); | |
| 514 | 514 | expect(page).toContain("$0.42 spent"); |
| 515 | 515 | expect(page).toContain('data-word="working"'); |
| 516 | 516 | // An agent's own run still speaks in the agent's name (#195). |
test/studio-search.test.ts +20 −20
| @@ -176,9 +176,9 @@ describe("finding things in the studio: /search", () => { | ||
| 176 | 176 | test("a change and a tell are found too, each saying which app they belong to", async () => { |
| 177 | 177 | const html = await (await asAlice("/search?q=chess")).text(); |
| 178 | 178 | expect(html).toContain("Add a chess clock timer"); |
| 179 | expect(html).toContain(`/alice/widgets/tells/${tellNumber}`); | |
| 179 | expect(html).toContain(`/alice/widgets/issues/${tellNumber}`); | |
| 180 | 180 | expect(html).toContain("Wire up the chess clock"); |
| 181 | expect(html).toContain(`/alice/widgets/changes/${changeNumber}`); | |
| 181 | expect(html).toContain(`/alice/widgets/pulls/${changeNumber}`); | |
| 182 | 182 | expect(html).toContain("alice/widgets"); |
| 183 | 183 | }); |
| 184 | 184 | |
| @@ -210,8 +210,8 @@ describe("finding things in the studio: /search", () => { | ||
| 210 | 210 | ).text(), |
| 211 | 211 | ]) { |
| 212 | 212 | expect(html).toContain("Apps and repositories"); |
| 213 | expect(html).toContain("Tells and changes"); | |
| 214 | expect(html).toContain(`/alice/widgets/tells/${tellNumber}`); | |
| 213 | expect(html).toContain("Issues and pull requests"); | |
| 214 | expect(html).toContain(`/alice/widgets/issues/${tellNumber}`); | |
| 215 | 215 | expect(mainOf(html)).not.toContain('name="mode"'); |
| 216 | 216 | } |
| 217 | 217 | }); |
| @@ -604,8 +604,8 @@ describe("every studio page's headings form one outline", () => { | ||
| 604 | 604 | test("the app page, the three lists, a tell, a change and a run each have one outline with no skipped level", async () => { |
| 605 | 605 | const pages: Record<string, string> = { |
| 606 | 606 | "the app page": "/alice/outline", |
| 607 | "everything told": "/alice/outline/tells", | |
| 608 | "every change": "/alice/outline/changes", | |
| 607 | "everything told": "/alice/outline/issues", | |
| 608 | "every change": "/alice/outline/pulls", | |
| 609 | 609 | "every run": "/alice/outline/runs", |
| 610 | 610 | "a tell": `/alice/outline/issues/${issueNumber}`, |
| 611 | 611 | "a change": `/alice/outline/pulls/${pullNumber}`, |
| @@ -812,10 +812,10 @@ describe("the studio's Stop and Redeploy come back to the page they were pressed | ||
| 812 | 812 | const stopped = await press( |
| 813 | 813 | "stop", |
| 814 | 814 | "studio", |
| 815 | `${base}/alice/notes/changes/1`, | |
| 815 | `${base}/alice/notes/pulls/1`, | |
| 816 | 816 | ); |
| 817 | 817 | expect(stopped.status).toBe(303); |
| 818 | expect(stopped.headers.get("location")).toBe("/alice/notes/changes/1"); | |
| 818 | expect(stopped.headers.get("location")).toBe("/alice/notes/pulls/1"); | |
| 819 | 819 | }); |
| 820 | 820 | |
| 821 | 821 | test("no referer, or one from somewhere else, falls back to the app's studio page — never the raw /app", async () => { |
| @@ -1104,11 +1104,11 @@ describe("the studio names a person on the app's page, and the crew only for the | ||
| 1104 | 1104 | |
| 1105 | 1105 | test("an anonymous visitor reads one author for one change on all three pages, avatar included", async () => { |
| 1106 | 1106 | const appPage = await anon("/ivy/pub"); |
| 1107 | const changes = await anon("/ivy/pub/changes"); | |
| 1108 | const change = await anon(`/ivy/pub/changes/${personPull.number}`); | |
| 1107 | const changes = await anon("/ivy/pub/pulls"); | |
| 1108 | const change = await anon(`/ivy/pub/pulls/${personPull.number}`); | |
| 1109 | 1109 | |
| 1110 | const onApp = rowWith(appPage, `/changes/${personPull.number}`); | |
| 1111 | const onList = rowWith(changes, `/changes/${personPull.number}`); | |
| 1110 | const onApp = rowWith(appPage, `/pulls/${personPull.number}`); | |
| 1111 | const onList = rowWith(changes, `/pulls/${personPull.number}`); | |
| 1112 | 1112 | expect(onApp).toContain("ivy"); |
| 1113 | 1113 | expect(onApp).not.toContain("the crew"); |
| 1114 | 1114 | // The same name the list gives, and the same initials beside it. |
| @@ -1117,7 +1117,7 @@ describe("the studio names a person on the app's page, and the crew only for the | ||
| 1117 | 1117 | expect(change).toContain("ivy"); |
| 1118 | 1118 | |
| 1119 | 1119 | // A tell she took herself is hers on the same panel, by the same rule. |
| 1120 | const held = rowWith(appPage, `/tells/${heldTell.number}`); | |
| 1120 | const held = rowWith(appPage, `/issues/${heldTell.number}`); | |
| 1121 | 1121 | expect(held).toContain("ivy"); |
| 1122 | 1122 | expect(held).not.toContain("the crew"); |
| 1123 | 1123 | expect(initialsIn(held)).toBe("i"); |
| @@ -1125,7 +1125,7 @@ describe("the studio names a person on the app's page, and the crew only for the | ||
| 1125 | 1125 | |
| 1126 | 1126 | test("the crew's own change and the crew's own run still read as the crew's to that visitor", async () => { |
| 1127 | 1127 | const appPage = await anon("/ivy/pub"); |
| 1128 | const onApp = rowWith(appPage, `/changes/${agentPull.number}`); | |
| 1128 | const onApp = rowWith(appPage, `/pulls/${agentPull.number}`); | |
| 1129 | 1129 | expect(onApp).toContain("the crew"); |
| 1130 | 1130 | expect(onApp).not.toContain("quill"); |
| 1131 | 1131 | expect(initialsIn(onApp)).toBe("tc"); |
| @@ -1134,7 +1134,7 @@ describe("the studio names a person on the app's page, and the crew only for the | ||
| 1134 | 1134 | expect(runRow).toContain("the crew"); |
| 1135 | 1135 | expect(runRow).not.toContain("quill"); |
| 1136 | 1136 | |
| 1137 | const tellRow = rowWith(appPage, `/tells/${waitingTell.number}`); | |
| 1137 | const tellRow = rowWith(appPage, `/issues/${waitingTell.number}`); | |
| 1138 | 1138 | expect(tellRow).toContain("the crew"); |
| 1139 | 1139 | expect(tellRow).not.toContain("quill"); |
| 1140 | 1140 | |
| @@ -1146,9 +1146,9 @@ describe("the studio names a person on the app's page, and the crew only for the | ||
| 1146 | 1146 | |
| 1147 | 1147 | test("the owner still reads the crew by name, and her own change as her own", async () => { |
| 1148 | 1148 | const appPage = await owner("/ivy/pub"); |
| 1149 | expect(rowWith(appPage, `/changes/${agentPull.number}`)).toContain("quill"); | |
| 1149 | expect(rowWith(appPage, `/pulls/${agentPull.number}`)).toContain("quill"); | |
| 1150 | 1150 | expect(rowWith(appPage, `/runs/${agentRun.id}`)).toContain("quill"); |
| 1151 | expect(rowWith(appPage, `/changes/${personPull.number}`)).toContain("ivy"); | |
| 1151 | expect(rowWith(appPage, `/pulls/${personPull.number}`)).toContain("ivy"); | |
| 1152 | 1152 | }); |
| 1153 | 1153 | }); |
| 1154 | 1154 | |
| @@ -1226,7 +1226,7 @@ describe("the studio's empty lists", () => { | ||
| 1226 | 1226 | await setup("blank"); |
| 1227 | 1227 | for (const [path, words] of [ |
| 1228 | 1228 | ["issues", "Tell the crew what you want"], |
| 1229 | ["pulls", "A change is the crew"], | |
| 1229 | ["pulls", "A pull request is the crew"], | |
| 1230 | 1230 | ["runs", "A run is one go"], |
| 1231 | 1231 | ] as const) { |
| 1232 | 1232 | const page = await get(`/alice/blank/${path}`); |
| @@ -1241,10 +1241,10 @@ describe("the studio's empty lists", () => { | ||
| 1241 | 1241 | const told = openIssue(app, alice, repo, { title: "Add a tab", body: "" }); |
| 1242 | 1242 | if (!told.ok) throw new Error(String(told.error)); |
| 1243 | 1243 | const issues = await get("/alice/busy/issues?word=shipped"); |
| 1244 | expect(issues).toContain("Nothing told is shipped yet."); | |
| 1244 | expect(issues).toContain("No issue is shipped yet."); | |
| 1245 | 1245 | expect(issues).not.toContain("Tell the crew what you want"); |
| 1246 | 1246 | const pulls = await get("/alice/busy/pulls?word=look"); |
| 1247 | expect(pulls).toContain("No change is look yet."); | |
| 1247 | expect(pulls).toContain("No pull request is look yet."); | |
| 1248 | 1248 | const runs = await get("/alice/busy/runs?word=stopped"); |
| 1249 | 1249 | expect(runs).toContain("No run is stopped yet."); |
| 1250 | 1250 | }); |
test/studio-shipping.test.ts +7 −7
| @@ -226,7 +226,7 @@ describe("a stranger sees the app, not the owner's books", () => { | ||
| 226 | 226 | // The app itself is still there: what it is, that it shipped, a way in. |
| 227 | 227 | expect(page).toContain("a light that stays on"); |
| 228 | 228 | expect(page).toContain(">Shipped<"); |
| 229 | expect(page).toContain("1 change"); | |
| 229 | expect(page).toContain("1 pull request"); | |
| 230 | 230 | expect(page).toContain("Open the app"); |
| 231 | 231 | // The direction's own number is the same owner's-books fact as spend |
| 232 | 232 | // and the ledger: a stranger and an anonymous visitor see neither. |
| @@ -2014,18 +2014,18 @@ describe("this week's shipping is counted by the database, not read off a page o | ||
| 2014 | 2014 | |
| 2015 | 2015 | test("the app page says 150 changes shipped, not the 100 a page of closed pulls holds", async () => { |
| 2016 | 2016 | const page = await (await get("/alice/busy")).text(); |
| 2017 | expect(page).toMatch(/Shipped<\/dt>\s*<dd>150 changes<\/dd>/); | |
| 2018 | expect(page).not.toContain("100 changes"); | |
| 2017 | expect(page).toMatch(/Shipped<\/dt>\s*<dd>150 pull requests<\/dd>/); | |
| 2018 | expect(page).not.toContain("100 pull requests"); | |
| 2019 | 2019 | // And the cost of a shipped change divides by all 150. |
| 2020 | 2020 | expect(page).toContain( |
| 2021 | `$${(SPENT / SHIPPED).toFixed(2)} per shipped change`, | |
| 2021 | `$${(SPENT / SHIPPED).toFixed(2)} per shipped pull request`, | |
| 2022 | 2022 | ); |
| 2023 | 2023 | }); |
| 2024 | 2024 | |
| 2025 | 2025 | test("an app with real history but nothing shipped this week reads 0 changes and no cost per change", async () => { |
| 2026 | 2026 | const page = await (await get("/alice/quiet")).text(); |
| 2027 | expect(page).toMatch(/Shipped<\/dt>\s*<dd>0 changes<\/dd>/); | |
| 2028 | expect(page).not.toContain("per shipped change"); | |
| 2027 | expect(page).toMatch(/Shipped<\/dt>\s*<dd>0 pull requests<\/dd>/); | |
| 2028 | expect(page).not.toContain("per shipped pull request"); | |
| 2029 | 2029 | }); |
| 2030 | 2030 | }); |
| 2031 | 2031 | |
| @@ -2130,6 +2130,6 @@ describe("a one-minute-old app's front door does not lead with a week of zeros ( | ||
| 2130 | 2130 | test("a stranger sees the same quiet line, not the owner's zeros", async () => { |
| 2131 | 2131 | const page = await (await fetch(`${base}/alice/newborn`)).text(); |
| 2132 | 2132 | expect(page).toContain("Nothing to show yet"); |
| 2133 | expect(page).not.toMatch(/Shipped<\/dt>\s*<dd>0 changes<\/dd>/); | |
| 2133 | expect(page).not.toMatch(/Shipped<\/dt>\s*<dd>0 pull requests<\/dd>/); | |
| 2134 | 2134 | }); |
| 2135 | 2135 | }); |
test/studio-words.test.ts +75 −87
| @@ -47,6 +47,30 @@ import { serve, type Servers } from "../src/http/server.ts"; | ||
| 47 | 47 | import { previewVerdict } from "../src/web/studio/previews.ts"; |
| 48 | 48 | import { testsClause } from "../src/web/studio/words.ts"; |
| 49 | 49 | |
| 50 | /** | |
| 51 | * The nouns the studio once had for issues and pull requests (#3241: "i dont | |
| 52 | * like the told and changes"). Issues and pull requests are called that in | |
| 53 | * every mode, so none of these is said as a noun for one — in a nav tab, a | |
| 54 | * heading, a title, a list's empty state or a feed badge. "Tell the crew" | |
| 55 | * stays: it is a verb, the box a person types into. Checked against the | |
| 56 | * whole page's visible words, the repo header's tabs included. | |
| 57 | */ | |
| 58 | const RETIRED_NOUNS: readonly RegExp[] = [ | |
| 59 | />\s*Told\s*</, | |
| 60 | />\s*Changes\s*</, | |
| 61 | /\bTells\b/, | |
| 62 | /\bEverything told\b/i, | |
| 63 | /\bNothing told\b/i, | |
| 64 | /\bEvery change\b/i, | |
| 65 | /\bClose (?:tell|change)\b/i, | |
| 66 | /\b(?:a|the|your|this|its|no) tells?\b/i, | |
| 67 | /\bTells? (?:&|and) changes\b/i, | |
| 68 | /<span class="badge"[^>]*>\s*(?:Change|Told)\s*<\/span>/, | |
| 69 | ]; | |
| 70 | ||
| 71 | /** The addresses the studio once gave them; none is linked to any more. */ | |
| 72 | const RETIRED_HREF = /href="\/[^"/]+\/[^"/]+\/(?:tells|changes)(?:[/?"])/; | |
| 73 | ||
| 50 | 74 | /** |
| 51 | 75 | * Dev's own words, each with the reason it must never reach a studio page. |
| 52 | 76 | * Checked with every `<script>` stripped first: the shared client script |
| @@ -88,18 +112,6 @@ const DEV_WORDS: readonly { pattern: RegExp; because: string }[] = [ | ||
| 88 | 112 | pattern: /\bmerge\b/i, |
| 89 | 113 | because: "the studio's own word for a merge is shipped", |
| 90 | 114 | }, |
| 91 | { | |
| 92 | pattern: /pull request/i, | |
| 93 | because: "the studio's own word for a pull is change", | |
| 94 | }, | |
| 95 | { | |
| 96 | // Bare "issue" also rides the topbar's own live-event wiring | |
| 97 | // (`data-live="run.*,pull.*,issue.*,check.*"`) on every page, which is | |
| 98 | // not a word a person reads — so only the composer's own button text is | |
| 99 | // checked here, the shape #171 actually leaked. | |
| 100 | pattern: /Close issue\b/, | |
| 101 | because: "the studio's own word for a tell is tell", | |
| 102 | }, | |
| 103 | 115 | { |
| 104 | 116 | pattern: /\bworkbench\b/i, |
| 105 | 117 | because: |
| @@ -484,7 +496,7 @@ describe("dev's vocabulary never reaches a studio page", () => { | ||
| 484 | 496 | const blankRepo = await createRepo(app, a.value, { |
| 485 | 497 | name: "blank", |
| 486 | 498 | visibility: "private", |
| 487 | description: "nothing told yet", | |
| 499 | description: "nothing asked yet", | |
| 488 | 500 | }); |
| 489 | 501 | if (!blankRepo.ok) throw new Error(String(blankRepo.error)); |
| 490 | 502 | const blankSeeded = await commitToBranch(app, a.value, blankRepo.value, { |
| @@ -521,7 +533,7 @@ describe("dev's vocabulary never reaches a studio page", () => { | ||
| 521 | 533 | word: "app", |
| 522 | 534 | path: repoPath, |
| 523 | 535 | title: "vocab", |
| 524 | shows: [`href="#i-pull"/></svg>Changes`], | |
| 536 | shows: [`href="#i-issue"/></svg>Issues`, `href="#i-pull"/></svg>Pulls`], | |
| 525 | 537 | hides: [`href="#i-eye"/></svg>Look`, `>Look<`, 'class="where"'], |
| 526 | 538 | // #630: the app page is the home in every mode; its code, with dev's |
| 527 | 539 | // where strip, is the Files step one tab over. |
| @@ -529,23 +541,11 @@ describe("dev's vocabulary never reaches a studio page", () => { | ||
| 529 | 541 | { |
| 530 | 542 | word: "tell", |
| 531 | 543 | path: `${repoPath}/issues/${tellNumber}`, |
| 532 | // #171: the composer's close button used to say "Close issue" here, | |
| 533 | // dev's own noun on the studio's own page. | |
| 534 | shows: ["Close tell"], | |
| 544 | // #3241: an issue is closed with "Close issue" in every mode. | |
| 545 | shows: ["Close issue"], | |
| 535 | 546 | // One issue page (#626): dev's controls are its closed Details. |
| 536 | 547 | title: `${tellTitle} · vocab`, |
| 537 | 548 | }, |
| 538 | // The studio's own nouns for the same two pages (#158): one handler | |
| 539 | // each, so what is walked here is the very page above under the | |
| 540 | // address the studio's own links hand out. No `dev` half — `?mode=dev` | |
| 541 | // at these addresses is a redirect to dev's own (`/issues/:number`, | |
| 542 | // `/pulls/:number`), which the row above already proves. | |
| 543 | { | |
| 544 | word: "tell (the studio's noun)", | |
| 545 | path: `${repoPath}/tells/${tellNumber}`, | |
| 546 | shows: [tellTitle, "Close tell"], | |
| 547 | title: `${tellTitle} · vocab`, | |
| 548 | }, | |
| 549 | 549 | { |
| 550 | 550 | word: "working", |
| 551 | 551 | path: `${repoPath}/runs/${runId}`, |
| @@ -554,20 +554,13 @@ describe("dev's vocabulary never reaches a studio page", () => { | ||
| 554 | 554 | { |
| 555 | 555 | word: "look", |
| 556 | 556 | path: `${repoPath}/pulls/${openPullNumber}`, |
| 557 | // #171: the composer's close button used to say "Close pull | |
| 558 | // request" here, dev's own noun on the studio's own page. | |
| 559 | // One change page (#628): the diff, the branch and the merge box are | |
| 557 | // #3241: a pull request is closed with "Close pull request". | |
| 558 | // One pull page (#628): the diff, the branch and the merge box are | |
| 560 | 559 | // its Files step, which speaks git on purpose, so it is not a row. |
| 561 | shows: ["Close change", `${repoPath}/pulls/${openPullNumber}/files`], | |
| 560 | shows: ["Close pull request", `${repoPath}/pulls/${openPullNumber}/files`], | |
| 562 | 561 | hides: ['class="where"'], |
| 563 | 562 | title: `${openPullTitle} · vocab`, |
| 564 | 563 | }, |
| 565 | { | |
| 566 | word: "look (the studio's noun)", | |
| 567 | path: `${repoPath}/changes/${openPullNumber}`, | |
| 568 | shows: [openPullTitle, "Close change"], | |
| 569 | title: `${openPullTitle} · vocab`, | |
| 570 | }, | |
| 571 | 564 | { |
| 572 | 565 | word: "shipped", |
| 573 | 566 | path: `${repoPath}/pulls/${mergedPullNumber}`, |
| @@ -581,10 +574,6 @@ describe("dev's vocabulary never reaches a studio page", () => { | ||
| 581 | 574 | shows: [ |
| 582 | 575 | tellTitle, |
| 583 | 576 | untouchedTitle, |
| 584 | `href="${repoPath}/tells/${tellNumber}"`, | |
| 585 | `href="${repoPath}/tells/${untouchedNumber}"`, | |
| 586 | ], | |
| 587 | hides: [ | |
| 588 | 577 | `href="${repoPath}/issues/${tellNumber}"`, |
| 589 | 578 | `href="${repoPath}/issues/${untouchedNumber}"`, |
| 590 | 579 | ], |
| @@ -593,7 +582,7 @@ describe("dev's vocabulary never reaches a studio page", () => { | ||
| 593 | 582 | // The empty case: what a new app's map says before anything is told. |
| 594 | 583 | word: "map (empty)", |
| 595 | 584 | path: "/alice/blank/map", |
| 596 | shows: ["No tells to map yet."], | |
| 585 | shows: ["No issues to map yet."], | |
| 597 | 586 | }, |
| 598 | 587 | // A word from the app's own description, not its name — the app's own |
| 599 | 588 | // row coming back for it proves the search reaches the description, |
| @@ -606,8 +595,8 @@ describe("dev's vocabulary never reaches a studio page", () => { | ||
| 606 | 595 | word: "tells", |
| 607 | 596 | path: `${repoPath}/issues`, |
| 608 | 597 | shows: [untouchedTitle, tellTitle], |
| 609 | // One list (#627): `?mode=dev` has no other page to show here. | |
| 610 | title: "Everything told · vocab", | |
| 598 | // One list (#627), one noun (#3241). | |
| 599 | title: "Issues · vocab", | |
| 611 | 600 | }, |
| 612 | 601 | { |
| 613 | 602 | word: "tells (told)", |
| @@ -622,39 +611,18 @@ describe("dev's vocabulary never reaches a studio page", () => { | ||
| 622 | 611 | hides: [tellTitle], |
| 623 | 612 | }, |
| 624 | 613 | { |
| 625 | word: "tells (the studio's noun)", | |
| 626 | path: `${repoPath}/tells`, | |
| 627 | shows: [untouchedTitle, tellTitle], | |
| 628 | title: "Everything told · vocab", | |
| 629 | }, | |
| 630 | { | |
| 631 | // #262: the one compose page, under the studio's own noun and dev's | |
| 632 | // own address alike — the tab, the heading and the button all say | |
| 633 | // the studio's word, and the form still posts where it always has. | |
| 634 | word: "tell the crew (the studio's noun)", | |
| 635 | path: `${repoPath}/tells/new`, | |
| 636 | shows: [`action="${repoPath}/issues/new"`, "<h1 class=\"mt\">Tell the crew</h1>"], | |
| 637 | title: "Tell the crew · vocab", | |
| 638 | }, | |
| 639 | { | |
| 640 | word: "tell the crew", | |
| 614 | // #262: the one compose page; #3241: called a new issue in every | |
| 615 | // mode, and the form still posts where it always has. | |
| 616 | word: "new issue", | |
| 641 | 617 | path: `${repoPath}/issues/new`, |
| 642 | shows: [`action="${repoPath}/issues/new"`, "<h1 class=\"mt\">Tell the crew</h1>"], | |
| 643 | title: "Tell the crew · vocab", | |
| 644 | dev: ["New issue", "Create issue"], | |
| 645 | devTitle: "New issue · vocab", | |
| 618 | shows: [`action="${repoPath}/issues/new"`, "<h1 class=\"mt\">New issue</h1>", "Create issue"], | |
| 619 | title: "New issue · vocab", | |
| 646 | 620 | }, |
| 647 | 621 | { |
| 648 | word: "changes (the studio's noun)", | |
| 649 | path: `${repoPath}/changes`, | |
| 650 | shows: [openPullTitle, secondChangeTitle], | |
| 651 | title: "Every change · vocab", | |
| 652 | }, | |
| 653 | { | |
| 654 | word: "changes", | |
| 622 | word: "pulls", | |
| 655 | 623 | path: `${repoPath}/pulls`, |
| 656 | 624 | shows: [openPullTitle, secondChangeTitle], |
| 657 | title: "Every change · vocab", | |
| 625 | title: "Pull requests · vocab", | |
| 658 | 626 | }, |
| 659 | 627 | { |
| 660 | 628 | word: "changes (look)", |
| @@ -748,6 +716,15 @@ describe("dev's vocabulary never reaches a studio page", () => { | ||
| 748 | 716 | `${p.word} (${p.path}) said dev's word ${pattern} — ${because}`, |
| 749 | 717 | ).not.toMatch(pattern); |
| 750 | 718 | } |
| 719 | for (const noun of RETIRED_NOUNS) { | |
| 720 | expect( | |
| 721 | visible, | |
| 722 | `${p.word} (${p.path}) called an issue or a pull request ${noun} (#3241)`, | |
| 723 | ).not.toMatch(noun); | |
| 724 | } | |
| 725 | expect(html, `${p.word} (${p.path}) linked a retired address`).not.toMatch( | |
| 726 | RETIRED_HREF, | |
| 727 | ); | |
| 751 | 728 | for (const branch of branches) { |
| 752 | 729 | expect( |
| 753 | 730 | visible, |
| @@ -787,7 +764,7 @@ describe("dev's vocabulary never reaches a studio page", () => { | ||
| 787 | 764 | expect(h1).not.toContain(runId); |
| 788 | 765 | }); |
| 789 | 766 | |
| 790 | test("the command palette speaks the studio's own nouns for an app (#246) — it rides a script tag the vocabulary walk above never opens", async () => { | |
| 767 | test("the command palette calls them issues and pull requests on an app too (#246, #3241) — it rides a script tag the vocabulary walk above never opens", async () => { | |
| 791 | 768 | const res = await fetch(`${base}${repoPath}`, { |
| 792 | 769 | headers: { cookie: `${cookie}; forge_mode=studio` }, |
| 793 | 770 | redirect: "manual", |
| @@ -806,18 +783,17 @@ describe("dev's vocabulary never reaches a studio page", () => { | ||
| 806 | 783 | "alice/vocab — Code", |
| 807 | 784 | "alice/vocab — Commits", |
| 808 | 785 | "alice/vocab — New issue", |
| 809 | "alice/vocab — Issues", | |
| 810 | "alice/vocab — Pull requests", | |
| 786 | "alice/vocab — Told", | |
| 787 | "alice/vocab — Changes", | |
| 811 | 788 | ]) { |
| 812 | 789 | expect(rows.has(t), `palette still offers "${t}" on a studio app page`).toBe( |
| 813 | 790 | false, |
| 814 | 791 | ); |
| 815 | 792 | } |
| 816 | 793 | |
| 817 | // The studio's own nouns, at the studio's own addresses. | |
| 818 | 794 | expect(rows.get("alice/vocab — Studio")).toBe(repoPath); |
| 819 | expect(rows.get("alice/vocab — Told")).toBe(`${repoPath}/tells`); | |
| 820 | expect(rows.get("alice/vocab — Changes")).toBe(`${repoPath}/changes`); | |
| 795 | expect(rows.get("alice/vocab — Issues")).toBe(`${repoPath}/issues`); | |
| 796 | expect(rows.get("alice/vocab — Pull requests")).toBe(`${repoPath}/pulls`); | |
| 821 | 797 | // The studio's first verb — telling the crew — reachable from the |
| 822 | 798 | // palette, landing on the app page where the tell box lives. |
| 823 | 799 | expect(rows.get("alice/vocab — Tell the crew")).toBe(`${repoPath}#tell`); |
| @@ -856,14 +832,26 @@ describe("dev's vocabulary never reaches a studio page", () => { | ||
| 856 | 832 | expect(res.status, path).toBe(200); |
| 857 | 833 | expect(titleOf(await res.text()), path).toBe(title); |
| 858 | 834 | } |
| 859 | // No studio, no studio door: the compose address under the studio's | |
| 860 | // noun hands a non-app to dev's own. | |
| 861 | const door = await fetch(`${base}/alice/plain/tells/new`, { | |
| 862 | headers: { cookie: `${cookie}; forge_mode=studio` }, | |
| 863 | redirect: "manual", | |
| 864 | }); | |
| 865 | expect(door.status).toBe(303); | |
| 866 | expect(door.headers.get("location")).toBe("/alice/plain/issues/new"); | |
| 835 | }); | |
| 836 | ||
| 837 | test("the retired addresses /tells and /changes answer 301 to the issues and pulls pages, query kept, app or not (#3241)", async () => { | |
| 838 | const moved = new Map([ | |
| 839 | [`${repoPath}/tells`, `${repoPath}/issues`], | |
| 840 | [`${repoPath}/tells?word=told`, `${repoPath}/issues?word=told`], | |
| 841 | [`${repoPath}/tells/new?title=hi`, `${repoPath}/issues/new?title=hi`], | |
| 842 | [`${repoPath}/tells/${tellNumber}`, `${repoPath}/issues/${tellNumber}`], | |
| 843 | [`${repoPath}/changes`, `${repoPath}/pulls`], | |
| 844 | [`${repoPath}/changes/${openPullNumber}`, `${repoPath}/pulls/${openPullNumber}`], | |
| 845 | ["/alice/plain/tells/new", "/alice/plain/issues/new"], | |
| 846 | ]); | |
| 847 | for (const [from, to] of moved) { | |
| 848 | const res = await fetch(`${base}${from}`, { | |
| 849 | headers: { cookie }, | |
| 850 | redirect: "manual", | |
| 851 | }); | |
| 852 | expect(res.status, from).toBe(301); | |
| 853 | expect(res.headers.get("location"), from).toBe(to); | |
| 854 | } | |
| 867 | 855 | }); |
| 868 | 856 | |
| 869 | 857 | test("reopening a merged change refuses in the reader's own noun — dev's word never rides the flash on a studio page (#171)", async () => { |
test/studio.test.ts +3 −3
| @@ -158,7 +158,7 @@ describe("the studio", () => { | ||
| 158 | 158 | // The depth row on every repository's header: Files (or Code), Changes |
| 159 | 159 | // (or Pulls) and Runs; Terminal only where SSH is on, which it is not here. |
| 160 | 160 | expect(withDev).toContain('href="/alice/notes/files"'); |
| 161 | expect(withDev).toContain('href="/alice/notes/changes"'); | |
| 161 | expect(withDev).toContain('href="/alice/notes/pulls"'); | |
| 162 | 162 | expect(withDev).toContain('href="/alice/notes/runs"'); |
| 163 | 163 | expect(withDev).not.toContain('href="/settings/keys#terminal"'); |
| 164 | 164 | // The toggle's POST is gone with it. |
| @@ -391,7 +391,7 @@ describe("the studio", () => { | ||
| 391 | 391 | ); |
| 392 | 392 | expect(lost.status).toBe(303); |
| 393 | 393 | expect(lost.headers.get("location")).toBe( |
| 394 | `/alice/notes/changes/${evil.number}`, | |
| 394 | `/alice/notes/pulls/${evil.number}`, | |
| 395 | 395 | ); |
| 396 | 396 | }); |
| 397 | 397 | |
| @@ -1076,7 +1076,7 @@ describe("the studio", () => { | ||
| 1076 | 1076 | expect(studio).toContain("Ground work"); |
| 1077 | 1077 | // What it waits on, in the studio's own noun (#158). |
| 1078 | 1078 | expect(studio).toContain( |
| 1079 | `href="/alice/notes/tells/${blocker.value.number}"`, | |
| 1079 | `href="/alice/notes/issues/${blocker.value.number}"`, | |
| 1080 | 1080 | ); |
| 1081 | 1081 | expect(studio).toContain('data-word="working"'); |
| 1082 | 1082 | expect(studio).toContain("$0.12"); |
test/tui-tell.test.ts +1 −1
| @@ -120,7 +120,7 @@ describe("tell the crew in the terminal", () => { | ||
| 120 | 120 | } as unknown as Told; |
| 121 | 121 | const answer = tellAnswer({ ok: true, value }); |
| 122 | 122 | expect(answer.text).toBe(toldWords(value)); |
| 123 | expect(answer.text).toContain("Told as #7, but mason could not take it."); | |
| 123 | expect(answer.text).toContain("Opened as issue #7, but mason could not take it."); | |
| 124 | 124 | expect(answer.text).not.toContain("is on it"); |
| 125 | 125 | expect(answer.tone).toBe("danger"); |
| 126 | 126 | }); |
test/unfurl.test.ts +7 −7
| @@ -166,13 +166,13 @@ describe("the pages", () => { | ||
| 166 | 166 | }); |
| 167 | 167 | |
| 168 | 168 | test("a tell opens on its state and first line", async () => { |
| 169 | const line = unfurl(await get(`/alice/open/tells/${numbers.tell}`)); | |
| 169 | const line = unfurl(await get(`/alice/open/issues/${numbers.tell}`)); | |
| 170 | 170 | expect(line).toContain("Say hello from open."); |
| 171 | 171 | expect(line).not.toContain("Second line"); |
| 172 | 172 | }); |
| 173 | 173 | |
| 174 | 174 | test("a change opens on its word and its own words", async () => { |
| 175 | const line = unfurl(await get(`/alice/open/changes/${numbers.change}`)); | |
| 175 | const line = unfurl(await get(`/alice/open/pulls/${numbers.change}`)); | |
| 176 | 176 | expect(line).toContain("Make open better."); |
| 177 | 177 | }); |
| 178 | 178 | |
| @@ -192,8 +192,8 @@ describe("the pages", () => { | ||
| 192 | 192 | |
| 193 | 193 | test("no sentence uses dev's vocabulary", async () => { |
| 194 | 194 | for (const path of [ |
| 195 | `/alice/open/tells/${numbers.tell}`, | |
| 196 | `/alice/open/changes/${numbers.change}`, | |
| 195 | `/alice/open/issues/${numbers.tell}`, | |
| 196 | `/alice/open/pulls/${numbers.change}`, | |
| 197 | 197 | `/alice/open/runs/${ids.run}`, |
| 198 | 198 | ]) |
| 199 | 199 | expect(unfurl(await get(path))).not.toMatch( |
| @@ -203,8 +203,8 @@ describe("the pages", () => { | ||
| 203 | 203 | |
| 204 | 204 | test("a private tell, change, run and app say 'Private.' and no more", async () => { |
| 205 | 205 | for (const path of [ |
| 206 | `/alice/hush/tells/${numbers.hushTell}`, | |
| 207 | `/alice/hush/changes/${numbers.hushChange}`, | |
| 206 | `/alice/hush/issues/${numbers.hushTell}`, | |
| 207 | `/alice/hush/pulls/${numbers.hushChange}`, | |
| 208 | 208 | `/alice/hush/runs/${ids.hushRun}`, |
| 209 | 209 | "/alice/hush/app", |
| 210 | 210 | ]) { |
| @@ -215,7 +215,7 @@ describe("the pages", () => { | ||
| 215 | 215 | }); |
| 216 | 216 | |
| 217 | 217 | test("anonymously a private page is not there at all", async () => { |
| 218 | const res = await fetch(`${servers.url}/alice/hush/tells/${numbers.hushTell}`); | |
| 218 | const res = await fetch(`${servers.url}/alice/hush/issues/${numbers.hushTell}`); | |
| 219 | 219 | expect(res.status).toBe(404); |
| 220 | 220 | }); |
| 221 | 221 | }); |
test/web-code.test.ts +2 −2
| @@ -446,7 +446,7 @@ describe("the code browser on the web", () => { | ||
| 446 | 446 | }); |
| 447 | 447 | expect(repo).toBeDefined(); |
| 448 | 448 | const html = await text("/alice/beacon/issues", alice); |
| 449 | expect(html).toContain('href="/alice/beacon/tells/1"'); | |
| 449 | expect(html).toContain('href="/alice/beacon/issues/1"'); | |
| 450 | 450 | expect(html).toContain("data-word="); |
| 451 | 451 | expect(await text("/alice/beacon/issues/1", alice)).toContain( |
| 452 | 452 | "still flickering", |
| @@ -458,7 +458,7 @@ describe("the code browser on the web", () => { | ||
| 458 | 458 | test("the search page renders repositories, conversations and code", async () => { |
| 459 | 459 | const html = await text("/search?q=lamp", alice); |
| 460 | 460 | expect(html).toContain("Apps and repositories"); |
| 461 | expect(html).toContain("Tells and changes"); | |
| 461 | expect(html).toContain("Issues and pull requests"); | |
| 462 | 462 | expect(html).toContain(">Code"); |
| 463 | 463 | // A code hit says where it is and shows the line with the query marked. |
| 464 | 464 | expect(html).toContain("src/lamp.ts"); |
test/web-workflows.test.ts +1 −1
| @@ -772,7 +772,7 @@ describe("verdicts, threads, and the gate they open", () => { | ||
| 772 | 772 | test("the pull list shows each pull in its word; where it stands on review is one step deeper, on the change", async () => { |
| 773 | 773 | // One list (#627): a row is the pull's word, never a review badge. |
| 774 | 774 | const list = await bodyOf("ada", "/acme/lib/pulls"); |
| 775 | expect(list).toContain(`href="/acme/lib/changes/${pullNumber}"`); | |
| 775 | expect(list).toContain(`href="/acme/lib/pulls/${pullNumber}"`); | |
| 776 | 776 | expect(list).toContain("data-word="); |
| 777 | 777 | expect(words(list)).not.toContain("Needs review"); |
| 778 | 778 |
new above
offline
Both sides of this ternary now return
/issues/:n. UsetellHrefdirectly and dropstudiohere if nothing else on this page reads it.