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

Merged opened this in answer to

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

Merged

mason/forge:mason/3241-issues-and-pull-requests-are-called-issu → main 4 commits fork 3 threads · 3 unresolved

  • Merged. mason/3241-issues-and-pull-requests-are-called-issu was merged into main as d903258.

Review

Sign in to review this change.
51 files changed +381 −451
README.md +1 −1

Diff not shown — a line 2,328 characters wide (limit 2,000). +1 −1

View file
docs/STUDIO.md +31 −44
@@ -17,15 +17,15 @@ command palette from anywhere, a field included. The box takes its query to
1717 `/search` (`searchPage`, `src/web/pages/search.ts`), the one search in
1818 both faces, server-rendered with no script: every repository the viewer
1919 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()`
2222 the API and the palette ask, so a signed-out reader sees only what a
2323 signed-out reader would anywhere. The studio's face leaves code to dev's:
2424 it says _Search the code_, a link to the same page with `?mode=dev`, which
2525 keeps that face on every link and the form until the reader leaves. The palette starts from a short local
2626 index — your own apps, then places and actions like Home, Apps, Map, New
2727 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`,
2929 `src/web/palette.ts`), so what it turns up can never disagree with the page
3030 behind it.
3131
@@ -75,10 +75,9 @@ with a door to make it one — `/settings/app`, the same "Run as an app" form
7575 Settings already carries — open only to whoever could submit it (`canAdmin`);
7676 a reader sees the sentence with no button that would just come back
7777 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.
8281
8382 An app's home in the studio (`/:owner/:name`, when the repository is an
8483 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.
152151 **Whose name a row carries** is one rule, in one place — `voiceFor`
153152 (`src/web/studio/voice.ts`), which the app's page, a run's own page and the
154153 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
156155 who can read the repository. "The crew" is a voice, not a redaction, and it
157156 is chosen by **what the actor is** first: a person is named to every viewer
158157 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.
202201 ## The three lists
203202
204203 `/: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,
233222 comment, edit and decide keep posting under `/issues/:number` and
234223 `/pulls/:number`, which is what `form-action 'self'` and the same-origin
235224 checks are written against.
@@ -265,13 +254,13 @@ production has not caught up to yet, or a run with nothing to show —
265254 still read as _working_: still moving, not one of the five a list links to
266255 on its own. Paging is the underlying list's own cursor, unchanged; a word
267256 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
271260 the word they narrow by `?label=` (the repository's labels are chips under
272261 the words), `?assignee=`, `?author=` and `?q=` (a piece of the title), each
273262 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
275264 a row — compose, for a signed-in person; a stranger on a public repository
276265 gets "Report a problem" instead, and its writers see how many reports from
277266 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.
280269
281270 ## A tell, on its own page
282271
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
285273 sentence someone said to the crew — not dev's labels, milestones and
286274 dependency form. It answers three questions: who has it (or that nobody
287275 does yet), what it is waiting on and what waits on it (`dependenciesOf`,
@@ -319,8 +307,7 @@ mode, renders exactly what it always has.
319307
320308 ## The look, on its own page
321309
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 —
324311 never its diff. The title and the opening post in the author's own words,
325312 who made it and when, the check in the studio's five words (`pullWord`,
326313 `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.";
158158 /** What happened, in the sentence both doors say. */
159159 export function toldWords(told: Told): string {
160160 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.`;
162162 const r = told.routed;
163163 const who = r.builder.user.username;
164164 return told.assigned
165165 ? `${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.`;
167167 }
src/feed/items.ts +7 −7
@@ -340,7 +340,7 @@ function fromEvent(
340340 ...(pull?.authorIsAgent ? [`by ${pull.authorName}`] : []),
341341 ].join(", ");
342342 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}`;
344344 switch (e.kind) {
345345 case "run.finished": {
346346 const state = p["state"] === "failed" ? "failed" : "ok";
@@ -364,11 +364,11 @@ function fromEvent(
364364 };
365365 }
366366 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}` };
368368 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}` };
370370 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)}` : ""}` };
372372 case "look.taken":
373373 return { ...common, kind: "look", link: base(repo), what: "a new picture of it is ready" };
374374 case "app.serving":
@@ -380,8 +380,8 @@ function fromEvent(
380380 return {
381381 ...common,
382382 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)}`,
385385 };
386386 }
387387 case "issue.commented":
@@ -399,7 +399,7 @@ function fromEvent(
399399 kind: "reply",
400400 link:
401401 e.kind === "issue.commented"
402 ? `${base(repo)}/tells/${number}`
402 ? `${base(repo)}/issues/${number}`
403403 : change,
404404 what: said === "" ? `replied on “${on}”` : `on “${on}”: ${clip(said)}`,
405405 };
src/feed/share.ts +1 −1
@@ -193,7 +193,7 @@ async function steer(
193193 item: Candidate,
194194 ): Promise<Outcome> {
195195 const text = `Shared by ${sender.username} with ${agent.username}: ${words(item)} (${item.link})`;
196 const number = /\/changes\/(\d+)$/.exec(item.link)?.[1];
196 const number = /\/pulls\/(\d+)$/.exec(item.link)?.[1];
197197 const issue =
198198 number === undefined ? undefined : getIssue(app.db, repo.id, Number(number));
199199 if (issue !== undefined) {
src/http/server.ts +23 −15
@@ -222,7 +222,6 @@ import {
222222 issueThreadFragment,
223223 newIssuePage,
224224 newIssueSubmit,
225 newTellPage,
226225 } from "../web/pages/issues.ts";
227226 import { tellPage } from "../web/studio/tell.ts";
228227 import { tellHerePage } from "../web/studio/tell_here.ts";
@@ -331,7 +330,6 @@ import {
331330 } from "../web/pages/crew.ts";
332331 import { newPullPage, newPullSubmit } from "../web/pages/pulls.ts";
333332 import {
334 changePage,
335333 pullPage,
336334 pullStateFragment,
337335 } from "../web/studio/change.ts";
@@ -621,6 +619,21 @@ const gitChallenge = () =>
621619
622620 // ---- the public server ----
623621
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
624637 export function buildRouter(): Router<Handler> {
625638 const router = new Router<Handler>();
626639
@@ -1057,15 +1070,12 @@ export function buildRouter(): Router<Handler> {
10571070
10581071 // Issues (web). The literal /new is registered before /:number so it can
10591072 // 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.
10621073 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"));
10691079 router.add("GET", "/:owner/:name/report", reportPage);
10701080 router.add("POST", "/:owner/:name/report", reportSubmit);
10711081 router.add("GET", "/:owner/:name/reports", reportsPage);
@@ -1132,12 +1142,10 @@ export function buildRouter(): Router<Handler> {
11321142 );
11331143
11341144 // 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.
11381146 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"));
11411149 router.add("GET", "/:owner/:name/pulls/new", newPullPage);
11421150 router.add("POST", "/:owner/:name/pulls/new", newPullSubmit);
11431151 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 {
6868 );
6969 if (lines.length === 0) {
7070 emptyState(s, body!, c, {
71 title: "No tells yet",
71 title: "No issues yet",
7272 body: "What you tell the crew about this app shows up here, with its answer.",
7373 });
7474 return;
src/tui/screens/workspace.ts +1 −1
@@ -1301,7 +1301,7 @@ export function workspaceScreen(
13011301 binding(["V"], "waves"),
13021302 ...(repo === undefined ? [] : [binding(["d"], "production deploys")]),
13031303 ...(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")]
13051305 : []),
13061306 ...(repo !== undefined && admin()
13071307 ? [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(
203203 },
204204 ]),
205205 {
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",
211210 ...(facts.openIssues === null ? {} : { count: facts.openIssues }),
212211 current: active === "issues",
213212 shortcut: "g i",
214213 },
215214 {
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),
221217 icon: "pull",
222218 ...(facts.openPulls === null ? {} : { count: facts.openPulls }),
223219 current: active === "pulls",
@@ -383,8 +379,8 @@ export function repoPalette(ctx: Ctx, repo: Repo): PaletteItem[] {
383379 return [
384380 { t: `${name} — Studio`, h: base, c: "This repo" },
385381 { 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" },
388384 // Telling the crew is for who may direct it (`mayDirectCrew`: a
389385 // writer, or anyone signed in where the owner let outsiders in), so
390386 // nobody else is offered a door that leads nowhere.
src/web/client.ts +3 −6
@@ -1590,15 +1590,12 @@ export const shellJs = /* js */ `
15901590 d: r.originKind ? 'on ' + r.originKind : 'on an origin' }
15911591 : { t: r.owner + '/' + r.name, h: '/' + r.owner + '/' + r.name, g: 'Repositories' });
15921592 });
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).
15981595 (out.issues || []).slice(0, 5).forEach(function (i) {
15991596 if (i.source !== 'local') return;
16001597 var tell = i.kind === 'issue';
1601 var noun = tell ? (studio ? 'tells' : 'issues') : (studio ? 'changes' : 'pulls');
1598 var noun = tell ? 'issues' : 'pulls';
16021599 rows.push({
16031600 t: i.title,
16041601 h: '/' + i.repo.owner + '/' + i.repo.name + '/' + noun + '/' + i.number,
src/web/pages/issues.ts +7 −21
@@ -38,17 +38,15 @@ import {
3838 import {
3939 PAGE_TITLES,
4040 pageTitle,
41 studioRecord,
4241 tellsHref,
4342 } from "../studio/nouns.ts";
4443
4544 // ---- compose ----
4645
4746 /**
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.
5250 */
5351 function composePage(
5452 ctx: Ctx,
@@ -56,13 +54,12 @@ function composePage(
5654 values: { title: string; body: string; sealed?: boolean },
5755 error?: string,
5856 ): Response {
59 const studio = studioRecord(ctx, repo) !== undefined;
60 const heading = PAGE_TITLES.compose[studio ? "studio" : "dev"];
57 const heading = PAGE_TITLES.compose;
6158 const base = repoBase(repo);
6259 return page(
6360 {
6461 ctx,
65 title: pageTitle("compose", repo, studio),
62 title: pageTitle("compose", repo, false),
6663 crumbs: crumbsFor(repo),
6764 palette: repoPalette(ctx, repo),
6865 body: html`${repoHeader(ctx, repo, "issues", {
@@ -73,8 +70,8 @@ function composePage(
7370 ${newIssueForm(
7471 repo,
7572 values,
76 studio ? "Tell" : "Create issue",
77 studio ? tellsHref(base) : `${base}/issues`,
73 "Create issue",
74 tellsHref(base),
7875 error,
7976 )}`,
8077 },
@@ -173,17 +170,6 @@ function accountOrReport(ctx: Ctx, repo: Repo): Response {
173170 });
174171 }
175172
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
187173 // POST /:owner/:name/issues/new
188174 export async function newIssueSubmit(ctx: Ctx): Promise<Response> {
189175 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.*";
13071307 /** The empty state in the studio's own words, pointing at the app page's
13081308 * own box for telling the crew (`#tell`, `src/web/studio/app.ts`). */
13091309 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>`;
13111311
13121312 /** GET /:owner/:name/map */
13131313 export async function mapPage(ctx: Ctx): Promise<Response> {
13141314 const repo = loadRepo(ctx);
13151315 if (!repo) return notFoundPage(ctx);
13161316 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`).
13191317 const studio = studioRecord(ctx, repo) !== undefined;
1320 const issueHref = studio ? tellHref : devIssueHref;
13211318 const graph = repoGraph(db, repo.id, {
13221319 ...sealedScope(db, repo, ctx.auth.user),
outdated

Both sides of this ternary now return /issues/:n. Use tellHref directly and drop studio here if nothing else on this page reads it.

13231320 });
@@ -1351,7 +1348,7 @@ export async function mapPage(ctx: Ctx): Promise<Response> {
13511348 ${drawMap(repo, graph, placed, emphasisFor(ready, critical, marks), {
13521349 id: "map-canvas",
13531350 cap: 1,
1354 issueHref,
1351 issueHref: tellHref,
13551352 ...(studio ? { emptyHtml: studioEmpty(repoBase(repo)) } : {}),
13561353 })}
13571354 ${placed.nodes.length === 0 ? null : countsLine(counts)}
@@ -1374,7 +1371,7 @@ export async function mapPage(ctx: Ctx): Promise<Response> {
13741371 ? "holds nothing up"
13751372 : `finishing it frees ${r.holdsUp}`,
13761373 "Nothing is ready: everything open waits for something.",
1377 issueHref,
1374 tellHref,
13781375 )}
13791376 ${rankedList(
13801377 repo,
@@ -1382,7 +1379,7 @@ export async function mapPage(ctx: Ctx): Promise<Response> {
13821379 blocked,
13831380 (r) => `holds up ${r.holdsUp}`,
13841381 "Nothing open holds anything up.",
1385 issueHref,
1382 tellHref,
13861383 )}
13871384 </aside>
13881385 </div>`;
src/web/pages/search.ts +4 −4
@@ -248,9 +248,9 @@ const WORDS: readonly Words[] = [
248248 ],
249249 [
250250 "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",
254254 ],
255255 ["code", "Code", "Code", "No line of code matches"],
256256 ];
@@ -357,7 +357,7 @@ export async function searchPage(ctx: Ctx): Promise<Response> {
357357 type="search"
358358 name="q"
359359 value="${a.query}"
360 placeholder="Search apps, repositories, tells, changes and code"
360 placeholder="Search apps, repositories, issues, pull requests and code"
361361 aria-label="Search"
362362 autofocus
363363 data-search-input
src/web/studio/actions.ts +7 −7
@@ -79,7 +79,7 @@ async function tellFrom(
7979 const { routed } = told.value;
8080 if (routed === undefined)
8181 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.`,
8383 });
8484 const builder = routed.builder;
8585 return flashOrJson(ctx, back, {
@@ -135,11 +135,11 @@ async function tellFromPage(ctx: Ctx, repo: Repo, form: Form): Promise<Response>
135135 const issue = `${repoBase(repo)}/issues/${told.value.issue.number}`;
136136 return page({
137137 ctx,
138 title: `Told · ${repo.name}`,
138 title: `Issue opened · ${repo.name}`,
139139 crumbs: crumbsFor(repo),
140 body: html`${pageHead("Told")}
140 body: html`${pageHead("Issue opened")}
141141 <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>`,
143143 });
144144 }
145145
@@ -235,9 +235,9 @@ function takeBackWords(error: RevertPullError): string {
235235 case "unauthorized":
236236 return "sign in first";
237237 case "forbidden":
238 return "only a person can take a change back";
238 return "only a person can take a pull request back";
239239 case "not_found":
240 return "that change was not found";
240 return "that pull request was not found";
241241 }
242242 }
243243 switch (error.kind) {
@@ -311,7 +311,7 @@ export async function saySubmit(ctx: Ctx): Promise<Response> {
311311 const said = addComment(ctx.app, ctx.auth.user, repo, pull, text);
312312 return flashOrJson(ctx, base, {
313313 ...(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.` }
315315 : { tone: "danger" as const, message: "That could not be said." }),
316316 });
317317 }
src/web/studio/app.ts +6 −6
@@ -364,17 +364,17 @@ export async function studioApp(
364364 const weekCard = card(
365365 !hasHistory
366366 ? 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
368368 ships${ownerFacts ? " or the crew takes a run" : ""}.
369369 </p>`
370370 : !ownerFacts
371371 ? html`<dl>
372372 <dt>Shipped</dt>
373 <dd>${shipped} change${shipped === 1 ? "" : "s"}</dd>
373 <dd>${shipped} pull request${shipped === 1 ? "" : "s"}</dd>
374374 </dl>`
375375 : html`<dl>
376376 <dt>Shipped</dt>
377 <dd>${shipped} change${shipped === 1 ? "" : "s"}</dd>
377 <dd>${shipped} pull request${shipped === 1 ? "" : "s"}</dd>
378378 <dt>Runs</dt>
379379 <dd>${weekTotals.runs}</dd>
380380 <dt>Preview</dt>
@@ -385,7 +385,7 @@ export async function studioApp(
385385 <dd>
386386 $${spent.toFixed(2)}${shipped > 0
387387 ? html` <span class="muted"
388 >· $${(spent / shipped).toFixed(2)} per shipped change</span
388 >· $${(spent / shipped).toFixed(2)} per shipped pull request</span
389389 >`
390390 : null}
391391 </dd>
@@ -444,8 +444,8 @@ export async function studioApp(
444444 // domains — is the admin's, on the app's settings page.
445445 const more = html`<p class="muted small">
446446 <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(
449449 canAdmin(db, repo, ctx.auth.user),
450450 () => html` · <a href="${base}/settings/app">Environment settings</a>`,
451451 )}
src/web/studio/asks.ts +3 −3
@@ -161,12 +161,12 @@ export interface AskWords {
161161 /** The words for each kind of ask: every ask names its decision, its options and its default. */
162162 export const ASK_WORDS: Readonly<Record<DecisionKind, AskWords>> = {
163163 look: {
164 decision: "Ship this change?",
164 decision: "Ship this pull request?",
165165 options: ["Ship", "Say what is off"],
166166 ifNothing: "it waits; nothing ships",
167167 },
168168 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?",
170170 options: ["Ship", "Say what is off"],
171171 ifNothing: "it waits; an agent's change never ships unreviewed",
172172 },
@@ -1102,7 +1102,7 @@ export function askLinesFor(
11021102 ...about,
11031103 line,
11041104 number: only.pull!.number,
1105 link: `${base}/changes/${only.pull!.number}`,
1105 link: `${base}/pulls/${only.pull!.number}`,
11061106 };
11071107 });
11081108 }
src/web/studio/branch_tell.ts +4 −4
@@ -61,12 +61,12 @@ export function tellsAnswered(
6161 return out;
6262 }
6363
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:
6666 * the change's word is judged elsewhere and is not touched by this. */
6767 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>`;
6969 return t.closed
70 ? html`for ${link} — its tell is already done`
70 ? html`for ${link} — its issue is already done`
7171 : html`for ${link}`;
7272 }
src/web/studio/change.ts +5 −12
@@ -239,7 +239,7 @@ export function changeTabs(
239239 return html`<div class="filters mt">
240240 ${seg([
241241 {
242 label: "Change",
242 label: "Conversation",
243243 href: changeHref(base, pull.number),
244244 current: current === "change",
245245 },
@@ -262,8 +262,7 @@ function conflictWords(baseBranch: string, files: readonly string[]): string {
262262 }
263263
264264 /**
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
267266 * on the diff) is handed on to the Files step, where the diff now lives.
268267 */
269268 export async function pullPage(ctx: Ctx): Promise<Response> {
@@ -277,10 +276,6 @@ export async function pullPage(ctx: Ctx): Promise<Response> {
277276 return await changeConversation(ctx, repo, pull);
278277 }
279278
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
284279 /**
285280 * What a change's page says about where it stands, drawn once: the word and
286281 * 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
292287 const record: AppRecord | undefined = getAppByRepo(ctx.app.db, repo.id);
293288 const db = ctx.app.db;
294289 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`.
299292 const thread = `${base}/pulls/${pull.number}`;
300293 const wordCtx: WordCtx = {
301294 db,
@@ -624,7 +617,7 @@ async function changeConversation(
624617 : {}),
625618 state: pull.state,
626619 locked: pull.mergedAt !== null,
627 closeLabel: "Close change",
620 closeLabel: "Close pull request",
628621 crewNote: threadNote(ctx.app.db, repo, pull),
629622 })}
630623 </div>
src/web/studio/feed.ts +3 −3
@@ -33,13 +33,13 @@ const RUN_LINK = /^\/[^/]+\/[^/]+\/runs\/[A-Za-z0-9_-]+$/;
3333 const KIND_LABEL: Record<FeedItem["kind"], string> = {
3434 run: "Run",
3535 ask: "Needs you",
36 pull: "Change",
36 pull: "Pull request",
3737 decision: "Decision",
3838 map: "Map",
3939 cost: "Cost",
4040 milestone: "Watching",
4141 choose: "Choose",
42 tell: "Told",
42 tell: "Issue",
4343 reply: "Reply",
4444 look: "Look",
4545 release: "Release",
@@ -196,7 +196,7 @@ export function feedNote(): Html {
196196 }
197197
198198 const CHIP_LABEL: Record<KindChip, string> = {
199 reviews: "Changes",
199 reviews: "Pull requests",
200200 decisions: "Decisions",
201201 crew: "Crew words",
202202 releases: "Releases",
src/web/studio/hold.ts +1 −1
@@ -61,7 +61,7 @@ export function holdCard(
6161 );
6262 if (!may || !open) return null;
6363 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>
6565 <form class="bar mt" method="post" action="${thread}/hold">
6666 <input
6767 class="input"
src/web/studio/lists.ts +19 −17
@@ -1,12 +1,12 @@
11 /**
2 * The forge's three lists — everything told, every change, every run — and
2 * The forge's three lists — issues, pull requests, runs — and
33 * the one filter bar they share: five words, each a link carrying `?word=`,
44 * so what a person is looking at lives in the address and can be shared. A
55 * row is the same word, note and time the thing's own page shows, because
66 * 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`.
1010 */
1111
1212 import { sealedScope } from "../../core/sealed.ts";
@@ -116,6 +116,7 @@ function emptyNote(
116116 starter: string,
117117 ): string {
118118 if (filter === undefined) return starter;
119 if (filter === "told") return `${subject} is waiting to be picked up.`;
119120 return `${subject} is ${filter} yet.`;
120121 }
121122
@@ -134,7 +135,9 @@ function wordFilterBar(
134135 ${seg([
135136 { label: "Open", href: href(undefined), current: open },
136137 ...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),
138141 href: href(w),
139142 current: current === w,
140143 })),
@@ -323,7 +326,7 @@ function listRead(ctx: Ctx, repo: Repo, f: ListFacets) {
323326 }
324327
325328 /**
326 * GET /:owner/:name/issues, and /tells: everything told, for every
329 * GET /:owner/:name/issues: every issue, for every
327330 * repository and every viewer — one row per issue in its studio word, who
328331 * has it, its labels, when it last moved, and the tell itself as the row's
329332 * own link. `?word=` narrows it, and so do `?label=`, `?assignee=`,
@@ -366,7 +369,7 @@ export async function tellsList(ctx: Ctx): Promise<Response> {
366369 title: "Nothing here",
367370 note: emptyNote(
368371 f.word,
369 record === undefined ? "No issue" : "Nothing told",
372 "No issue",
370373 record === undefined
371374 ? "Nothing open."
372375 : "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> {
386389 )}${issue.assigneeName
387390 ? html`${issue.assigneeName} · `
388391 : null}${info.note} · ${time(issue.updatedAt)}`,
389 trail: wordBadge(info.word, undefined, record === undefined),
392 trail: wordBadge(info.word),
390393 }),
391394 ),
392395 );
393396 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.
396398 const listBase = ctx.url.pathname;
397399 // The one verb besides opening a row: say what you want. It posts from
398400 // `/issues/new`, the compose page, in the words the repository has.
399401 const compose = when(ctx.auth.user, () =>
400 btn(PAGE_TITLES.compose[record === undefined ? "dev" : "studio"], {
402 btn(PAGE_TITLES.compose, {
401403 href: `${base}/issues/new`,
402404 size: "sm",
403405 variant: "outline",
@@ -464,7 +466,7 @@ export async function tellsList(ctx: Ctx): Promise<Response> {
464466 }
465467
466468 /**
467 * GET /:owner/:name/pulls, and /changes: every change, for every
469 * GET /:owner/:name/pulls: every pull request, for every
468470 * repository and every viewer — one row per pull in its studio word, who
469471 * made it, its labels, when it last moved, and the change itself as the
470472 * 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> {
499501 title: "Nothing here",
500502 note: emptyNote(
501503 f.word,
502 "No change",
504 "No pull request",
503505 record === undefined
504506 ? "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.",
506508 ),
507509 })
508510 : listCard(
@@ -517,12 +519,12 @@ export async function changesList(ctx: Ctx): Promise<Response> {
517519 ? originTag(pull.originAuthor, originGlyph(repo))
518520 : pull.authorName} · ${info.note} ·
519521 ${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),
521523 }),
522524 ),
523525 );
524526 const lastNumber = fetched[fetched.length - 1]?.number;
525 // See `tellsList`: `/changes` or `/pulls`, whichever reached this page.
527 // See `tellsList`.
526528 const listBase = ctx.url.pathname;
527529
528530 const body = html`${repoHeader(ctx, repo, "pulls")}
@@ -669,7 +671,7 @@ export async function runsListPage(ctx: Ctx, repo: Repo): Promise<Response> {
669671 ${time(run.finishedAt ?? run.createdAt)}`
670672 : html`${run.kind === "agent" ? html`${who} · ` : null}${info.note}
671673 · ${time(run.finishedAt ?? run.createdAt)}`,
672 trail: wordBadge(info.word, undefined, record === undefined),
674 trail: wordBadge(info.word),
673675 });
674676 }),
675677 );
src/web/studio/nouns.ts +39 −37
@@ -1,39 +1,35 @@
11 /**
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.
88 *
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.
1614 */
1715
1816 import { getAppByRepo, type AppRecord } from "../../core/apps.ts";
1917 import type { Repo } from "../../core/repos.ts";
2018 import type { Ctx } from "../../http/context.ts";
2119
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`;
2422
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`;
2725
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`). */
3027 export const tellHref = (base: string, number: number): string =>
31 `${base}/tells/${number}`;
28 `${base}/issues/${number}`;
3229
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

changePage no longer exists after this pull. The page is pullPage in src/web/studio/change.ts.

3531 export const changeHref = (base: string, number: number): string =>
36 `${base}/changes/${number}`;
32 `${base}/pulls/${number}`;
3733
3834 /**
3935 * 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 {
4743 /**
4844 * What each page a studio viewer can reach calls itself in the browser tab
4945 * — 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.
5653 */
5754 export const PAGE_TITLES = {
outdated

studio and dev are now the same string for tells, changes and compose. Collapse them so pageTitle's studio argument decides something real (only runs still differs).

58 tells: { studio: "Everything told", dev: "Issues" },
59 changes: { studio: "Every change", dev: "Pull requests" },
55 tells: "Issues",
56 changes: "Pull requests",
6057 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",
6360 } as const;
6461
6562 export type TitledPage = keyof typeof PAGE_TITLES;
6663
6764 /** 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(
7068 which: TitledPage,
7169 repo: Repo,
7270 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(
152152 .slice(0, 6);
153153 // Who signs a "Now" row is the one rule for it, `voiceFor`
154154 // (`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
156156 // crew's own work reads as the app's own story for a viewer who cannot
157157 // see the roster. The avatar takes the same string, so its initials can
158158 // never disagree with the name beside them.
@@ -259,13 +259,13 @@ export async function nowCard(
259259 nav: true,
260260 lead: avatar(who),
261261 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`,
263263 trail: wordBadge("waiting"),
264264 });
265265 }),
266266 // Held by a person: `tellWord` — the one place a tell's word is
267267 // 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.
269269 // `personHeld` has already established the holder is a person, so
270270 // `voiceFor` names them to every viewer who can see the row.
271271 ...(await Promise.all(
@@ -300,7 +300,7 @@ export async function nowCard(
300300 title: html`<a href="${tellHref(base, i.number)}">${i.title}</a>`,
301301 // The invitation is a writer's (the shelf's rule, `ownerFacts`); the
302302 // fact — nobody has taken it — stays for everyone.
303 meta: html`told ${time(i.createdAt)} ·
303 meta: html`opened ${time(i.createdAt)} ·
304304 <span class="muted">nobody on the crew to take it</span>${ownerFacts
305305 ? html` — <a href="${base}/settings">add someone</a>`
306306 : null}`,
src/web/studio/shelf.ts +1 −1
@@ -277,7 +277,7 @@ export async function shelfRows(
277277 word === undefined
278278 ? stuck.has(repo.id)
279279 ? html`<span class="muted"
280 >told — ${NOBODY_ON_CREW} to take it</span
280 >open — ${NOBODY_ON_CREW} to take it</span
281281 >`
282282 : canTell
283283 ? html`<span class="muted">quiet — tell the crew something</span>`
src/web/studio/tell.ts +4 −7
@@ -240,8 +240,7 @@ async function tellClosingPull(
240240 }
241241
242242 /**
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
245244 * may not see it gets the forge's not-found at either.
246245 */
247246 export async function tellPage(ctx: Ctx): Promise<Response> {
@@ -279,9 +278,7 @@ async function studioTell(
279278 };
280279 const base = repoBase(repo);
281280 // 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`.
285282 const thread = `${base}/issues/${issue.number}`;
286283 const direction = repo.directionIssueId === issue.id;
287284 const closingPull = links(() => openPullsClosing(db, repo.id).get(issue.number));
@@ -344,7 +341,7 @@ async function studioTell(
344341 ordinary tell</span
345342 >`
346343 : wordInfo
347 ? html`${wordBadge(wordInfo.word, undefined, plain)}
344 ? html`${wordBadge(wordInfo.word)}
348345 <span class="muted small">${wordInfo.note}</span>`
349346 : stateBadge(issue.state)}
350347 <span
@@ -419,7 +416,7 @@ async function studioTell(
419416 action: `${thread}/comments`,
420417 ...(mayClose ? { stateAction: `${thread}/state` } : {}),
421418 state: issue.state,
422 closeLabel: plain ? "Close issue" : "Close tell",
419 closeLabel: "Close issue",
423420 crewNote: threadNote(ctx.app.db, repo, issue),
424421 reasons: true,
425422 })}
src/web/studio/voice.ts +2 −2
@@ -4,7 +4,7 @@
44 * of work to two different people.
55 *
66 * 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
88 * can read the repository, and nothing here could take that back. What
99 * "the crew" stands in for is narrower — who is on the crew is part of the
1010 * roster, the ceiling and the ledger, the owner's own book-keeping
@@ -17,7 +17,7 @@
1717 *
1818 * 1. Is the actor an agent? A person is named to every viewer that can see
1919 * 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.
2121 * 2. Can this viewer see the owner's facts? A writer reads the real name.
2222 *
2323 * 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]> = {
397397 * live script repaints as a run finishes (`studioRunJs`). `cancelled` draws
398398 * the same quiet, secondary look as `waiting` and `planned` — a restart is
399399 * 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 {
401401 const variant =
402402 word === "look" || word === "shipped" || word === "done"
403403 ? "success"
@@ -411,7 +411,7 @@ export function wordBadge(word: Word, id?: string, plain = false): Html {
411411 data-variant="${variant}"
412412 data-word="${word}"
413413 ${id === undefined ? null : html`id="${id}"`}
414 >${word === "working" ? icon("loader") : null}${plain && word === "told"
414 >${word === "working" ? icon("loader") : null}${word === "told"
415415 ? "open"
416416 : word}</span
417417 >`;
test/asks-expiry.test.ts +1 −1
@@ -190,7 +190,7 @@ describe("asks that expire, hold and collapse", () => {
190190 const lines = askLinesFor(app.db, aliceUser);
191191 expect(lines.map((l) => l.id)).toEqual([`look:${apples.id}`]);
192192 expect(lines[0]!.link).toBe(
193 `/${repo.ownerName}/${repo.name}/changes/${apples.number}`,
193 `/${repo.ownerName}/${repo.name}/pulls/${apples.number}`,
194194 );
195195 });
196196
test/asks-rank.test.ts +2 −2
@@ -226,7 +226,7 @@ describe("asks that earn their place", () => {
226226 }
227227 ).items;
228228 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?");
230230 expect(
231231 items.some((i) => (i.ask as { why?: string } | undefined)?.why === "security"),
232232 ).toBe(true);
@@ -334,7 +334,7 @@ describe("asks that earn their place", () => {
334334 const fromMcp = await mcpAsks();
335335 expect(fromMcp.measure.raised).toBe(6);
336336 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?");
338338 // Nothing changed: a second pass raises nothing new.
339339 recordAskLedger(app.db);
340340 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", () => {
123123 })
124124 ).text();
125125 expect(page).toContain('id="run-carries"');
126 expect(page).toContain(`/changes/${two.number}`);
126 expect(page).toContain(`/pulls/${two.number}`);
127127 }, 180_000);
128128 });
test/feed-page.test.ts +1 −1
@@ -95,7 +95,7 @@ describe("the feed on the home page", () => {
9595 expect(text).toContain('href="/alice/open/runs/r1"');
9696 expect(text).toContain('action="/alice/open/runs/r1/steer"');
9797 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;
9999 expect(tell).toContain('action="/alice/open/tell"');
100100 expect(tell).toContain('data-feed="dismissed"');
101101 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", () => {
212212 expect(home).toContain("Your crew has its direction:");
213213 expect(home).not.toContain("already at work");
214214 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/);
216216 // Each app with its way in.
217217 expect(home).toContain('href="/marias/bakery"');
218218 });
test/here-manifest.test.ts +1 −1
@@ -319,7 +319,7 @@ describe("the manifest: what you can do here, as who you are", () => {
319319 headBranch: "mine",
320320 });
321321 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;
323323 expect(ownBody.here.pull).toEqual({ number: own.value.number, runs: true });
324324 });
325325 });
test/holds.test.ts +3 −3
@@ -678,9 +678,9 @@ describe("a hold is set, shown and lifted", () => {
678678
679679 test("the change page offers the hold, then says who holds it and why, with the way to let it go", async () => {
680680 const cookie = await signIn("alice");
681 const change = `/alice/garden/changes/${pull.number}`;
681 const change = `/alice/garden/pulls/${pull.number}`;
682682 const before = await page(change, cookie);
683 expect(before).toContain("Hold this change");
683 expect(before).toContain("Hold this pull request");
684684 expect(before).toContain(
685685 `action="/alice/garden/pulls/${pull.number}/hold"`,
686686 );
@@ -707,7 +707,7 @@ describe("a hold is set, shown and lifted", () => {
707707 expect(after).toContain(
708708 `action="/alice/garden/pulls/${pull.number}/release"`,
709709 );
710 expect(after).not.toContain("Hold this change");
710 expect(after).not.toContain("Hold this pull request");
711711 // The change's Files step, the one that reads the code, says it too
712712 // (#628: one change page; `?mode=dev` answers the same page now).
713713 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", () => {
134134 // carries what was said.
135135 expect(text).toContain("Water the tomatoes at dawn");
136136 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</,
138138 );
139139 // A reply names the tell it answers.
140140 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
7575 });
7676 expect(posted.status).toBe(303);
7777 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</,
7979 );
8080
8181 // 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
9494 const page = await home(alice);
9595 expect(page).toContain("Sorted the seed list");
9696 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");
9898
9999 // A share reaches another person as a post that says who shared it.
100100 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", () => {
117117
118118 const list = await (await fetch(`${base}/mel/proj/issues`)).text();
119119 expect(list).toContain("First bug");
120 expect(list).toContain('href="/mel/proj/tells/1"');
120 expect(list).toContain('href="/mel/proj/issues/1"');
121121
122122 const detail = await (await fetch(`${base}/mel/proj/issues/1`)).text();
123123 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", () => {
266266 app: "alice/garden",
267267 actor,
268268 at: new Date().toISOString(),
269 link: `/alice/garden/changes/${pull.number}`,
269 link: `/alice/garden/pulls/${pull.number}`,
270270 what: "Beds",
271271 reason: "",
272272 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
156156
157157 test("the change page the owner lands on draws the same live line, with the counts", async () => {
158158 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"]}`, {
160160 headers: { cookie: `forge_session=${createSession(app.db, alice.id).token}` },
161161 });
162162 expect(res.status).toBe(200);
test/sealed-issues.test.ts −2
@@ -118,9 +118,7 @@ const SINGLES = (n: number, p: number) => [
118118 `/api/v1/repos/alice/square/runs/${runId}`,
119119 `/api/v1/repos/alice/square/runs/${runId}/log`,
120120 `/alice/square/issues/${n}`,
121 `/alice/square/tells/${n}`,
122121 `/alice/square/pulls/${p}`,
123 `/alice/square/changes/${p}`,
124122 `/alice/square/runs/${runId}`,
125123 ];
126124
test/studio-changes.test.ts +54 −75
@@ -371,7 +371,7 @@ describe("the studio, continued", () => {
371371 expect(issues).not.toContain(`href="/alice/plainlists/issues?word=told"`);
372372 expect(issues).not.toContain(`href="/alice/plainlists/issues?word=look"`);
373373 expect(issues).toContain(
374 `href="/alice/plainlists/tells/${told.value.number}"`,
374 `href="/alice/plainlists/issues/${told.value.number}"`,
375375 );
376376 expect(issues).not.toContain("See dev's list");
377377
@@ -388,26 +388,21 @@ describe("the studio, continued", () => {
388388 expect(runs).not.toContain("See dev's list");
389389 });
390390
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 () => {
392392 const told = openIssue(app, alice, repo, {
393393 title: "Door test: a tell",
394 body: "reached through /tells too",
394 body: "reached through /issues",
395395 });
396396 if (!told.ok) throw new Error(String(told.error));
397397
398398 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"');
406401 expect(viaIssues).toContain(`href="/alice/notes/issues?word=told"`);
407402
408 const filtered = await (await get("/alice/notes/tells?word=told")).text();
403 const filtered = await (await get("/alice/notes/issues?word=told")).text();
409404 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();
411406 expect(other).not.toContain("Door test: a tell");
412407
413408 const branch = await commitToBranch(app, alice, repo, {
@@ -437,18 +432,16 @@ describe("the studio, continued", () => {
437432 finishRun(app.db, doorCheck.id, { state: "ok", summary: "check passed" });
438433
439434 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"');
444437 expect(viaPulls).toContain(`href="/alice/notes/pulls?word=look"`);
445438
446439 const changeFiltered = await (
447 await get("/alice/notes/changes?word=look")
440 await get("/alice/notes/pulls?word=look")
448441 ).text();
449442 expect(changeFiltered).toContain("Door test: a change");
450443 const changeOther = await (
451 await get("/alice/notes/changes?word=shipped")
444 await get("/alice/notes/pulls?word=shipped")
452445 ).text();
453446 expect(changeOther).not.toContain("Door test: a change");
454447 // A green pull left open now asks the front door too (#208) — close it
@@ -456,7 +449,7 @@ describe("the studio, continued", () => {
456449 expect(setIssueState(app, alice, repo, doorPull, "closed").ok).toBe(true);
457450 });
458451
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 () => {
460453 const plain = await createRepo(app, alice, {
461454 name: "doorlists",
462455 visibility: "private",
@@ -472,9 +465,10 @@ describe("the studio, continued", () => {
472465 ["/alice/doorlists/changes", cookie],
473466 ] as const) {
474467 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 );
478472 }
479473 });
480474
@@ -650,44 +644,37 @@ describe("the studio, continued", () => {
650644 expect(pulls).not.toContain('href="/alice/composelists/pulls/new"');
651645 });
652646
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 () => {
654648 const anonTells = await fetch(`${base}/alice/notes/tells`, {
655649 headers: { cookie: "forge_mode=studio" },
656 redirect: "manual",
657650 });
658651 expect(anonTells.status).toBe(404);
659652
660653 const anonChanges = await fetch(`${base}/alice/notes/changes`, {
661654 headers: { cookie: "forge_mode=studio" },
662 redirect: "manual",
663655 });
664656 expect(anonChanges.status).toBe(404);
665657 });
666658
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 () => {
668660 const told = openIssue(app, alice, repo, {
669661 title: "Door test: one tell",
670 body: "reached through /tells/:number too",
662 body: "reached through /issues/:number",
671663 });
672664 if (!told.ok) throw new Error(String(told.error));
673665 const n = told.value.number;
674666
675 const viaTell = await get(`/alice/notes/tells/${n}`);
667 const viaTell = await get(`/alice/notes/issues/${n}`);
676668 expect(viaTell.status).toBe(200);
677669 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.
680670 expect(tellHtml).toContain("Door test: one tell");
681671 expect(tellHtml).toContain('data-word="told"');
682672 // Still waiting, nobody assigned: the one case the line is true (#214).
683673 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.
687675 expect(tellHtml).toContain(`action="/alice/notes/issues/${n}/comments"`);
688676 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.
691678 expect(tellHtml).toContain('id="comment-body"');
692679
693680 const branch = await commitToBranch(app, alice, repo, {
@@ -706,7 +693,7 @@ describe("the studio, continued", () => {
706693 if (!opened.ok) throw new Error(String(opened.error));
707694 const p = opened.value.number;
708695
709 const viaChange = await get(`/alice/notes/changes/${p}`);
696 const viaChange = await get(`/alice/notes/pulls/${p}`);
710697 expect(viaChange.status).toBe(200);
711698 const changeHtml = await viaChange.text();
712699 expect(changeHtml).toContain("Door test: one change");
@@ -716,30 +703,22 @@ describe("the studio, continued", () => {
716703 expect(changeHtml).toContain(`href="/alice/notes/pulls/${p}/files"`);
717704 expect(changeHtml).toContain('id="comment-body"');
718705
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}"`);
732712 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)\//);
734715 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)\//);
740719 });
741720
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 () => {
743722 const told = openIssue(app, alice, repo, { title: "Door test: dev's way" });
744723 if (!told.ok) throw new Error(String(told.error));
745724 const n = told.value.number;
@@ -750,8 +729,8 @@ describe("the studio, continued", () => {
750729 }),
751730 await get(`/alice/notes/tells/${n}`),
752731 ]) {
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}`);
755734 }
756735
757736 const plain = await createRepo(app, alice, {
@@ -767,18 +746,19 @@ describe("the studio, continued", () => {
767746 const plainTell = await get(
768747 `/alice/doorpages/tells/${plainTold.value.number}`,
769748 );
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 );
772753 });
773754
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 () => {
775756 const told = openIssue(app, alice, repo, { title: "Door test: private" });
776757 if (!told.ok) throw new Error(String(told.error));
777758 const n = told.value.number;
778759
779760 const anonTell = await fetch(`${base}/alice/notes/tells/${n}`, {
780761 headers: { cookie: "forge_mode=studio" },
781 redirect: "manual",
782762 });
783763 expect(anonTell.status).toBe(404);
784764 const anonIssue = await fetch(`${base}/alice/notes/issues/${n}`, {
@@ -789,11 +769,10 @@ describe("the studio, continued", () => {
789769
790770 // A number that is not a tell is not a tell at either address, and a
791771 // 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");
793773 expect(missing.status).toBe(404);
794774 const anonChange = await fetch(`${base}/alice/notes/changes/1`, {
795775 headers: { cookie: "forge_mode=studio" },
796 redirect: "manual",
797776 });
798777 expect(anonChange.status).toBe(404);
799778 });
@@ -1313,28 +1292,28 @@ describe("the studio, continued", () => {
13131292 const stray = await open("scout/99999-nothing", "Answers nothing");
13141293 const own = await open("revert-22", "A person's branch");
13151294
1316 const list = await (await get("/alice/notes/changes")).text();
1295 const list = await (await get("/alice/notes/pulls")).text();
13171296 const rowOf = (title: string) => {
13181297 const at = list.indexOf(title);
13191298 return list.slice(at, list.indexOf("</a>", at + 400) + 4);
13201299 };
1321 expect(rowOf("Answers the tell")).toContain(`href="/alice/notes/tells/${n}"`);
1300 expect(rowOf("Answers the tell")).toContain(`href="/alice/notes/issues/${n}"`);
13221301 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");
13251304
13261305 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");
13291308 const page = await (
1330 await get(`/alice/notes/changes/${answering.number}`)
1309 await get(`/alice/notes/pulls/${answering.number}`)
13311310 ).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");
13341313 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}`);
13361315 expect(res.status).toBe(200);
1337 expect(await res.text()).not.toContain("for tell");
1316 expect(await res.text()).not.toContain("for issue");
13381317 }
13391318 });
13401319 });
test/studio-door.test.ts +1 −1
@@ -492,7 +492,7 @@ describe("every open pull's preview on the studio page", () => {
492492 expect(production).not.toContain(note);
493493
494494 const changePage = await (
495 await get(`/alice/widget/changes/${pull.number}`)
495 await get(`/alice/widget/pulls/${pull.number}`)
496496 ).text();
497497 expect(changePage).toContain("<h2>Preview</h2>");
498498 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", () => {
510510 ).text();
511511 expect(page).toContain("Fix the flicker on load");
512512 // 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}"`);
514514 expect(page).toContain("$0.42 spent");
515515 expect(page).toContain('data-word="working"');
516516 // 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", () => {
176176 test("a change and a tell are found too, each saying which app they belong to", async () => {
177177 const html = await (await asAlice("/search?q=chess")).text();
178178 expect(html).toContain("Add a chess clock timer");
179 expect(html).toContain(`/alice/widgets/tells/${tellNumber}`);
179 expect(html).toContain(`/alice/widgets/issues/${tellNumber}`);
180180 expect(html).toContain("Wire up the chess clock");
181 expect(html).toContain(`/alice/widgets/changes/${changeNumber}`);
181 expect(html).toContain(`/alice/widgets/pulls/${changeNumber}`);
182182 expect(html).toContain("alice/widgets");
183183 });
184184
@@ -210,8 +210,8 @@ describe("finding things in the studio: /search", () => {
210210 ).text(),
211211 ]) {
212212 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}`);
215215 expect(mainOf(html)).not.toContain('name="mode"');
216216 }
217217 });
@@ -604,8 +604,8 @@ describe("every studio page's headings form one outline", () => {
604604 test("the app page, the three lists, a tell, a change and a run each have one outline with no skipped level", async () => {
605605 const pages: Record<string, string> = {
606606 "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",
609609 "every run": "/alice/outline/runs",
610610 "a tell": `/alice/outline/issues/${issueNumber}`,
611611 "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
812812 const stopped = await press(
813813 "stop",
814814 "studio",
815 `${base}/alice/notes/changes/1`,
815 `${base}/alice/notes/pulls/1`,
816816 );
817817 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");
819819 });
820820
821821 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
11041104
11051105 test("an anonymous visitor reads one author for one change on all three pages, avatar included", async () => {
11061106 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}`);
11091109
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}`);
11121112 expect(onApp).toContain("ivy");
11131113 expect(onApp).not.toContain("the crew");
11141114 // 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
11171117 expect(change).toContain("ivy");
11181118
11191119 // 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}`);
11211121 expect(held).toContain("ivy");
11221122 expect(held).not.toContain("the crew");
11231123 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
11251125
11261126 test("the crew's own change and the crew's own run still read as the crew's to that visitor", async () => {
11271127 const appPage = await anon("/ivy/pub");
1128 const onApp = rowWith(appPage, `/changes/${agentPull.number}`);
1128 const onApp = rowWith(appPage, `/pulls/${agentPull.number}`);
11291129 expect(onApp).toContain("the crew");
11301130 expect(onApp).not.toContain("quill");
11311131 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
11341134 expect(runRow).toContain("the crew");
11351135 expect(runRow).not.toContain("quill");
11361136
1137 const tellRow = rowWith(appPage, `/tells/${waitingTell.number}`);
1137 const tellRow = rowWith(appPage, `/issues/${waitingTell.number}`);
11381138 expect(tellRow).toContain("the crew");
11391139 expect(tellRow).not.toContain("quill");
11401140
@@ -1146,9 +1146,9 @@ describe("the studio names a person on the app's page, and the crew only for the
11461146
11471147 test("the owner still reads the crew by name, and her own change as her own", async () => {
11481148 const appPage = await owner("/ivy/pub");
1149 expect(rowWith(appPage, `/changes/${agentPull.number}`)).toContain("quill");
1149 expect(rowWith(appPage, `/pulls/${agentPull.number}`)).toContain("quill");
11501150 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");
11521152 });
11531153 });
11541154
@@ -1226,7 +1226,7 @@ describe("the studio's empty lists", () => {
12261226 await setup("blank");
12271227 for (const [path, words] of [
12281228 ["issues", "Tell the crew what you want"],
1229 ["pulls", "A change is the crew"],
1229 ["pulls", "A pull request is the crew"],
12301230 ["runs", "A run is one go"],
12311231 ] as const) {
12321232 const page = await get(`/alice/blank/${path}`);
@@ -1241,10 +1241,10 @@ describe("the studio's empty lists", () => {
12411241 const told = openIssue(app, alice, repo, { title: "Add a tab", body: "" });
12421242 if (!told.ok) throw new Error(String(told.error));
12431243 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.");
12451245 expect(issues).not.toContain("Tell the crew what you want");
12461246 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.");
12481248 const runs = await get("/alice/busy/runs?word=stopped");
12491249 expect(runs).toContain("No run is stopped yet.");
12501250 });
test/studio-shipping.test.ts +7 −7
@@ -226,7 +226,7 @@ describe("a stranger sees the app, not the owner's books", () => {
226226 // The app itself is still there: what it is, that it shipped, a way in.
227227 expect(page).toContain("a light that stays on");
228228 expect(page).toContain(">Shipped<");
229 expect(page).toContain("1 change");
229 expect(page).toContain("1 pull request");
230230 expect(page).toContain("Open the app");
231231 // The direction's own number is the same owner's-books fact as spend
232232 // 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
20142014
20152015 test("the app page says 150 changes shipped, not the 100 a page of closed pulls holds", async () => {
20162016 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");
20192019 // And the cost of a shipped change divides by all 150.
20202020 expect(page).toContain(
2021 `$${(SPENT / SHIPPED).toFixed(2)} per shipped change`,
2021 `$${(SPENT / SHIPPED).toFixed(2)} per shipped pull request`,
20222022 );
20232023 });
20242024
20252025 test("an app with real history but nothing shipped this week reads 0 changes and no cost per change", async () => {
20262026 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");
20292029 });
20302030 });
20312031
@@ -2130,6 +2130,6 @@ describe("a one-minute-old app's front door does not lead with a week of zeros (
21302130 test("a stranger sees the same quiet line, not the owner's zeros", async () => {
21312131 const page = await (await fetch(`${base}/alice/newborn`)).text();
21322132 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>/);
21342134 });
21352135 });
test/studio-words.test.ts +75 −87
@@ -47,6 +47,30 @@ import { serve, type Servers } from "../src/http/server.ts";
4747 import { previewVerdict } from "../src/web/studio/previews.ts";
4848 import { testsClause } from "../src/web/studio/words.ts";
4949
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
5074 /**
5175 * Dev's own words, each with the reason it must never reach a studio page.
5276 * Checked with every `<script>` stripped first: the shared client script
@@ -88,18 +112,6 @@ const DEV_WORDS: readonly { pattern: RegExp; because: string }[] = [
88112 pattern: /\bmerge\b/i,
89113 because: "the studio's own word for a merge is shipped",
90114 },
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 },
103115 {
104116 pattern: /\bworkbench\b/i,
105117 because:
@@ -484,7 +496,7 @@ describe("dev's vocabulary never reaches a studio page", () => {
484496 const blankRepo = await createRepo(app, a.value, {
485497 name: "blank",
486498 visibility: "private",
487 description: "nothing told yet",
499 description: "nothing asked yet",
488500 });
489501 if (!blankRepo.ok) throw new Error(String(blankRepo.error));
490502 const blankSeeded = await commitToBranch(app, a.value, blankRepo.value, {
@@ -521,7 +533,7 @@ describe("dev's vocabulary never reaches a studio page", () => {
521533 word: "app",
522534 path: repoPath,
523535 title: "vocab",
524 shows: [`href="#i-pull"/></svg>Changes`],
536 shows: [`href="#i-issue"/></svg>Issues`, `href="#i-pull"/></svg>Pulls`],
525537 hides: [`href="#i-eye"/></svg>Look`, `>Look<`, 'class="where"'],
526538 // #630: the app page is the home in every mode; its code, with dev's
527539 // where strip, is the Files step one tab over.
@@ -529,23 +541,11 @@ describe("dev's vocabulary never reaches a studio page", () => {
529541 {
530542 word: "tell",
531543 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"],
535546 // One issue page (#626): dev's controls are its closed Details.
536547 title: `${tellTitle} · vocab`,
537548 },
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 },
549549 {
550550 word: "working",
551551 path: `${repoPath}/runs/${runId}`,
@@ -554,20 +554,13 @@ describe("dev's vocabulary never reaches a studio page", () => {
554554 {
555555 word: "look",
556556 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
560559 // 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`],
562561 hides: ['class="where"'],
563562 title: `${openPullTitle} · vocab`,
564563 },
565 {
566 word: "look (the studio's noun)",
567 path: `${repoPath}/changes/${openPullNumber}`,
568 shows: [openPullTitle, "Close change"],
569 title: `${openPullTitle} · vocab`,
570 },
571564 {
572565 word: "shipped",
573566 path: `${repoPath}/pulls/${mergedPullNumber}`,
@@ -581,10 +574,6 @@ describe("dev's vocabulary never reaches a studio page", () => {
581574 shows: [
582575 tellTitle,
583576 untouchedTitle,
584 `href="${repoPath}/tells/${tellNumber}"`,
585 `href="${repoPath}/tells/${untouchedNumber}"`,
586 ],
587 hides: [
588577 `href="${repoPath}/issues/${tellNumber}"`,
589578 `href="${repoPath}/issues/${untouchedNumber}"`,
590579 ],
@@ -593,7 +582,7 @@ describe("dev's vocabulary never reaches a studio page", () => {
593582 // The empty case: what a new app's map says before anything is told.
594583 word: "map (empty)",
595584 path: "/alice/blank/map",
596 shows: ["No tells to map yet."],
585 shows: ["No issues to map yet."],
597586 },
598587 // A word from the app's own description, not its name — the app's own
599588 // row coming back for it proves the search reaches the description,
@@ -606,8 +595,8 @@ describe("dev's vocabulary never reaches a studio page", () => {
606595 word: "tells",
607596 path: `${repoPath}/issues`,
608597 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",
611600 },
612601 {
613602 word: "tells (told)",
@@ -622,39 +611,18 @@ describe("dev's vocabulary never reaches a studio page", () => {
622611 hides: [tellTitle],
623612 },
624613 {
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",
641617 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",
646620 },
647621 {
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",
655623 path: `${repoPath}/pulls`,
656624 shows: [openPullTitle, secondChangeTitle],
657 title: "Every change · vocab",
625 title: "Pull requests · vocab",
658626 },
659627 {
660628 word: "changes (look)",
@@ -748,6 +716,15 @@ describe("dev's vocabulary never reaches a studio page", () => {
748716 `${p.word} (${p.path}) said dev's word ${pattern} — ${because}`,
749717 ).not.toMatch(pattern);
750718 }
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 );
751728 for (const branch of branches) {
752729 expect(
753730 visible,
@@ -787,7 +764,7 @@ describe("dev's vocabulary never reaches a studio page", () => {
787764 expect(h1).not.toContain(runId);
788765 });
789766
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 () => {
791768 const res = await fetch(`${base}${repoPath}`, {
792769 headers: { cookie: `${cookie}; forge_mode=studio` },
793770 redirect: "manual",
@@ -806,18 +783,17 @@ describe("dev's vocabulary never reaches a studio page", () => {
806783 "alice/vocab — Code",
807784 "alice/vocab — Commits",
808785 "alice/vocab — New issue",
809 "alice/vocab — Issues",
810 "alice/vocab — Pull requests",
786 "alice/vocab — Told",
787 "alice/vocab — Changes",
811788 ]) {
812789 expect(rows.has(t), `palette still offers "${t}" on a studio app page`).toBe(
813790 false,
814791 );
815792 }
816793
817 // The studio's own nouns, at the studio's own addresses.
818794 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`);
821797 // The studio's first verb — telling the crew — reachable from the
822798 // palette, landing on the app page where the tell box lives.
823799 expect(rows.get("alice/vocab — Tell the crew")).toBe(`${repoPath}#tell`);
@@ -856,14 +832,26 @@ describe("dev's vocabulary never reaches a studio page", () => {
856832 expect(res.status, path).toBe(200);
857833 expect(titleOf(await res.text()), path).toBe(title);
858834 }
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 }
867855 });
868856
869857 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", () => {
158158 // The depth row on every repository's header: Files (or Code), Changes
159159 // (or Pulls) and Runs; Terminal only where SSH is on, which it is not here.
160160 expect(withDev).toContain('href="/alice/notes/files"');
161 expect(withDev).toContain('href="/alice/notes/changes"');
161 expect(withDev).toContain('href="/alice/notes/pulls"');
162162 expect(withDev).toContain('href="/alice/notes/runs"');
163163 expect(withDev).not.toContain('href="/settings/keys#terminal"');
164164 // The toggle's POST is gone with it.
@@ -391,7 +391,7 @@ describe("the studio", () => {
391391 );
392392 expect(lost.status).toBe(303);
393393 expect(lost.headers.get("location")).toBe(
394 `/alice/notes/changes/${evil.number}`,
394 `/alice/notes/pulls/${evil.number}`,
395395 );
396396 });
397397
@@ -1076,7 +1076,7 @@ describe("the studio", () => {
10761076 expect(studio).toContain("Ground work");
10771077 // What it waits on, in the studio's own noun (#158).
10781078 expect(studio).toContain(
1079 `href="/alice/notes/tells/${blocker.value.number}"`,
1079 `href="/alice/notes/issues/${blocker.value.number}"`,
10801080 );
10811081 expect(studio).toContain('data-word="working"');
10821082 expect(studio).toContain("$0.12");
test/tui-tell.test.ts +1 −1
@@ -120,7 +120,7 @@ describe("tell the crew in the terminal", () => {
120120 } as unknown as Told;
121121 const answer = tellAnswer({ ok: true, value });
122122 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.");
124124 expect(answer.text).not.toContain("is on it");
125125 expect(answer.tone).toBe("danger");
126126 });
test/unfurl.test.ts +7 −7
@@ -166,13 +166,13 @@ describe("the pages", () => {
166166 });
167167
168168 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}`));
170170 expect(line).toContain("Say hello from open.");
171171 expect(line).not.toContain("Second line");
172172 });
173173
174174 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}`));
176176 expect(line).toContain("Make open better.");
177177 });
178178
@@ -192,8 +192,8 @@ describe("the pages", () => {
192192
193193 test("no sentence uses dev's vocabulary", async () => {
194194 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}`,
197197 `/alice/open/runs/${ids.run}`,
198198 ])
199199 expect(unfurl(await get(path))).not.toMatch(
@@ -203,8 +203,8 @@ describe("the pages", () => {
203203
204204 test("a private tell, change, run and app say 'Private.' and no more", async () => {
205205 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}`,
208208 `/alice/hush/runs/${ids.hushRun}`,
209209 "/alice/hush/app",
210210 ]) {
@@ -215,7 +215,7 @@ describe("the pages", () => {
215215 });
216216
217217 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}`);
219219 expect(res.status).toBe(404);
220220 });
221221 });
test/web-code.test.ts +2 −2
@@ -446,7 +446,7 @@ describe("the code browser on the web", () => {
446446 });
447447 expect(repo).toBeDefined();
448448 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"');
450450 expect(html).toContain("data-word=");
451451 expect(await text("/alice/beacon/issues/1", alice)).toContain(
452452 "still flickering",
@@ -458,7 +458,7 @@ describe("the code browser on the web", () => {
458458 test("the search page renders repositories, conversations and code", async () => {
459459 const html = await text("/search?q=lamp", alice);
460460 expect(html).toContain("Apps and repositories");
461 expect(html).toContain("Tells and changes");
461 expect(html).toContain("Issues and pull requests");
462462 expect(html).toContain(">Code");
463463 // A code hit says where it is and shows the line with the query marked.
464464 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", () => {
772772 test("the pull list shows each pull in its word; where it stands on review is one step deeper, on the change", async () => {
773773 // One list (#627): a row is the pull's word, never a review badge.
774774 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}"`);
776776 expect(list).toContain("data-word=");
777777 expect(words(list)).not.toContain("Needs review");
778778

↑↓ navigate ↵ open ⌘↵ its app esc close

Keyboard shortcuts

Docs