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

An app's tabs are the studio's lists: everything told, every change, every run #95

Merged opened this

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

Merged

scout/forge:scout/56-an-app-s-tabs-are-the-studio-s-lists-eve → main 1 commit fork

  • Merged. scout/56-an-app-s-tabs-are-the-studio-s-lists-eve was merged into main as ca14ce1.

Review

Sign in to review this change.
7 files changed +533 −7
docs/STUDIO.md +28
@@ -102,6 +102,34 @@ A run in flight carries _Stop_ (running) or _Cancel_ (waiting) for anyone
102102 who can write here; its title opens the transcript, where the agent can be
103103 steered mid-run (see `docs/APPS.md`, _Runs_).
104104
105 ## The three lists
106
107 `/:owner/:name/issues`, `/pulls` and `/runs` on an app, in the studio, are
108 *everything told*, *every change* and *every run* — dev's tabs with dev's
109 issue numbers, labels, milestones and branch names replaced by the same
110 five words the rest of the studio speaks. Each is a page of rows, newest
111 first: who has it (the assignee, the author, or who ran it), when it last
112 moved, its state as `wordBadge` draws it (`src/web/pages/studio.ts`), and
113 one link — the row itself — to the tell, the change or the transcript. The
114 app page's own footer (`the map · everything told · every change`, and
115 *Every run* above **Now**) and the repository header (`Told`, `Look`, in
116 place of dev's `Issues`, `Pulls`) both land here; the header's own tabs
117 keep dev's names, and dev's `Commits`/`Branches` stay dev's alone.
118
119 A row's word comes from `tellWord`, `pullWord` or `runWord` — the one place
120 each is decided, never re-derived here. `?word=told|working|look|shipped|
121 stopped` is a link at the top of each list, never a form, so a filtered
122 view has its own address to share; with no `word` the list shows what is
123 open and moving — told, working and look, never shipped or stopped, the
124 same split "Open" draws elsewhere. `waiting` and `planned` — a merged pull
125 production has not caught up to yet, or a run with nothing to show —
126 still read as *working*: still moving, not one of the five a list links to
127 on its own. Paging is the underlying list's own cursor, unchanged; a word
128 filter narrows the page it lands on rather than inventing a second scheme.
129 `?mode=dev` on any of the three is dev's list, exactly as it always has
130 been; a repository that is not an app renders dev's list here regardless
131 of mode, since there is no studio to switch to.
132
105133 ## A tell, on its own page
106134
107135 `/:owner/:name/issues/:number` on an app, in the studio, is a tell — one
src/web/chrome.ts +6 −4
@@ -162,17 +162,19 @@ export function scopeHeader(
162162 shortcut: "g c",
163163 },
164164 {
165 label: "Issues",
165 // Studio names its own lists — told, not issues; dev's tabs are
166 // unchanged in dev mode and on a repository that is not an app.
167 label: studio ? "Told" : "Issues",
166168 href: `${base}/issues`,
167 icon: "issue",
169 icon: studio ? "clock" : "issue",
168170 ...(facts.openIssues === null ? {} : { count: facts.openIssues }),
169171 current: active === "issues",
170172 shortcut: "g i",
171173 },
172174 {
173 label: "Pulls",
175 label: studio ? "Look" : "Pulls",
174176 href: `${base}/pulls`,
175 icon: "pull",
177 icon: studio ? "eye" : "pull",
176178 ...(facts.openPulls === null ? {} : { count: facts.openPulls }),
177179 current: active === "pulls",
178180 shortcut: "g p",
src/web/pages/issues.ts +10 −1
@@ -63,7 +63,7 @@ import {
6363 threadFragment,
6464 } from "./thread.ts";
6565 import { crewActivityCard } from "./runs.ts";
66 import { studioTell } from "./studio.ts";
66 import { studioTell, studioTells } from "./studio.ts";
6767
6868 const PER_PAGE = 25;
6969
@@ -291,6 +291,15 @@ export function sourceQuery(
291291 export async function issuesList(ctx: Ctx): Promise<Response> {
292292 const repo = loadRepo(ctx);
293293 if (!repo) return notFoundPage(ctx);
294 // In the studio "everything told" is the issue list in the studio's own
295 // words — the same rule `issuePage` follows for one tell. `?mode=dev` is
296 // the one-off way to dev's list without switching the browser.
297 const studio =
298 ctx.mode === "studio" && ctx.url.searchParams.get("mode") !== "dev";
299 if (studio) {
300 const record = getAppByRepo(ctx.app.db, repo.id);
301 if (record !== undefined) return await studioTells(ctx, repo, record);
302 }
294303 const scope = localScope(ctx, repo);
295304 const base = scope.base;
296305 const query = readQuery(ctx);
src/web/pages/pulls.ts +11
@@ -6,6 +6,7 @@
66 */
77
88 import { openPull } from "../../actions/pulls.ts";
9 import { getAppByRepo } from "../../core/apps.ts";
910 import {
1011 commentCounts,
1112 issueCounts,
@@ -52,6 +53,7 @@ import {
5253 } from "../ui.ts";
5354 import { refSet } from "./refs.ts";
5455 import { threadRow } from "./issues.ts";
56 import { studioChanges } from "./studio.ts";
5557 import { originGlyph } from "./thread.ts";
5658
5759 const PER_PAGE = 25;
@@ -123,6 +125,15 @@ function verdictMark(summary: ReviewSummary | undefined): Html {
123125 export async function pullsList(ctx: Ctx): Promise<Response> {
124126 const repo = loadRepo(ctx);
125127 if (!repo) return notFoundPage(ctx);
128 // In the studio "every change" is the pull list in the studio's own
129 // words — the same rule `pullPage` follows for one change. `?mode=dev` is
130 // the one-off way to dev's list without switching the browser.
131 const studio =
132 ctx.mode === "studio" && ctx.url.searchParams.get("mode") !== "dev";
133 if (studio) {
134 const record = getAppByRepo(ctx.app.db, repo.id);
135 if (record !== undefined) return await studioChanges(ctx, repo, record);
136 }
126137 const base = repoBase(repo);
127138 const db = ctx.app.db;
128139 const params = ctx.url.searchParams;
src/web/pages/runs.ts +11 −2
@@ -37,7 +37,7 @@ import { field, readForm } from "../form.ts";
3737 import { html, when, type Html } from "../html.ts";
3838 import { flashTo, page, redirect } from "../layout.ts";
3939 import { liveClientJs, liveMount } from "../live.ts";
40 import { studioRun } from "./studio.ts";
40 import { studioRun, studioRuns } from "./studio.ts";
4141 import {
4242 btn,
4343 empty,
@@ -256,9 +256,18 @@ export function crewActivityCard(ctx: Ctx, repo: Repo, issue: Issue): Html {
256256 const PER_PAGE = 30;
257257
258258 // GET /:owner/:name/runs
259 export function runsList(ctx: Ctx): Response {
259 export async function runsList(ctx: Ctx): Promise<Response> {
260260 const repo = loadRepo(ctx);
261261 if (!repo) return notFoundPage(ctx);
262 // In the studio "every run" is the run list in the studio's own words —
263 // the same rule `runPage` follows for one run. `?mode=dev` is the
264 // one-off way to dev's list without switching the browser.
265 const studio =
266 ctx.mode === "studio" && ctx.url.searchParams.get("mode") !== "dev";
267 if (studio) {
268 const record = getAppByRepo(ctx.app.db, repo.id);
269 if (record !== undefined) return await studioRuns(ctx, repo, record);
270 }
262271 const base = repoBase(repo);
263272 const q = ctx.url.searchParams;
264273 const kindRaw = q.get("kind");
src/web/pages/studio.ts +331
@@ -106,8 +106,10 @@ import {
106106 icon,
107107 listCard,
108108 originTag,
109 pager,
109110 pill,
110111 row,
112 seg,
111113 sha,
112114 stateBadge,
113115 time,
@@ -121,11 +123,14 @@ import { onYouCard } from "./site.ts";
121123 import { previewCard } from "./apps.ts";
122124 import { pullState } from "./pulls.ts";
123125 import {
126 actorsFor,
124127 costText,
125128 durationText,
129 issuesFor,
126130 logLine,
127131 reasonWords,
128132 runTitle,
133 runTitleFrom,
129134 } from "./runs.ts";
130135 import { composer, conversation, originGlyph } from "./thread.ts";
131136
@@ -1853,6 +1858,332 @@ export async function studioTell(
18531858 });
18541859 }
18551860
1861 // ---- lists: everything told, every change, every run ----
1862
1863 /** The five words a studio list filters by. `waiting` and `planned` are
1864 * still moving, so they fold into `working` — the one place the seven-word
1865 * vocabulary above buckets down to the five a list links to. */
1866 type WordFilter = "told" | "working" | "look" | "shipped" | "stopped";
1867
1868 const WORD_FILTERS: readonly WordFilter[] = [
1869 "told",
1870 "working",
1871 "look",
1872 "shipped",
1873 "stopped",
1874 ];
1875
1876 function listBucket(word: Word): WordFilter {
1877 return word === "waiting" || word === "planned" ? "working" : word;
1878 }
1879
1880 /** The word named in `?word=`, when it is one of the five. */
1881 function readWordFilter(ctx: Ctx): WordFilter | undefined {
1882 const raw = ctx.url.searchParams.get("word") ?? "";
1883 return (WORD_FILTERS as readonly string[]).includes(raw)
1884 ? (raw as WordFilter)
1885 : undefined;
1886 }
1887
1888 /** Whether a row belongs on the page shown: the exact word the URL asked
1889 * for, else "open and moving" — told, working or look, never shipped or
1890 * stopped. */
1891 function inWordFilter(word: Word, filter: WordFilter | undefined): boolean {
1892 const bucket = listBucket(word);
1893 return filter === undefined
1894 ? bucket === "told" || bucket === "working" || bucket === "look"
1895 : bucket === filter;
1896 }
1897
1898 /** A filter bar of five words, each a link carrying `?word=` — never a
1899 * form, so the filter lives in the address and can be shared. No filter
1900 * reads as "Open": what is open and moving. */
1901 function wordFilterBar(base: string, current: WordFilter | undefined): Html {
1902 return html`<div class="filters mb">
1903 ${seg([
1904 { label: "Open", href: base, current: current === undefined },
1905 ...WORD_FILTERS.map((w) => ({
1906 label: w[0]!.toUpperCase() + w.slice(1),
1907 href: `${base}?word=${w}`,
1908 current: current === w,
1909 })),
1910 ])}
1911 </div>`;
1912 }
1913
1914 const LIST_PAGE = 25;
1915
1916 /** `?word=` carried onto a `cursor` link, so paging keeps the filter. */
1917 function pagedHref(base: string, filter: WordFilter | undefined, cursor: string): string {
1918 return `${base}?${filter ? `word=${filter}&` : ""}cursor=${cursor}`;
1919 }
1920
1921 /**
1922 * GET /:owner/:name/issues in the studio: everything told, one row per
1923 * issue in its studio word — who has it, when it last moved, and the tell
1924 * itself as the row's own link. No labels, no milestones. `?word=` narrows
1925 * it; `?mode=dev` reads dev's list, unchanged. Bounded to one page: `tellWord`
1926 * costs a few small reads per row, the trade the pull list already makes
1927 * for its own check summaries (`verdictsFor`, pulls.ts).
1928 */
1929 export async function studioTells(
1930 ctx: Ctx,
1931 repo: Repo,
1932 record: AppRecord,
1933 ): Promise<Response> {
1934 const db = ctx.app.db;
1935 const base = repoBase(repo);
1936 const filter = readWordFilter(ctx);
1937 const state = filter === "shipped" || filter === "stopped" ? "closed" : "open";
1938 const rawCursor = Number(ctx.url.searchParams.get("cursor"));
1939 const cursor =
1940 Number.isSafeInteger(rawCursor) && rawCursor > 0 ? rawCursor : undefined;
1941
1942 const fetched = listIssues(db, repo.id, {
1943 kind: "issue",
1944 state,
1945 limit: LIST_PAGE,
1946 ...(cursor !== undefined ? { cursor } : {}),
1947 }).filter((issue) => issue.id !== repo.directionIssueId);
1948 const wordCtx: WordCtx = {
1949 db,
1950 dir: repoDir(ctx.app.reposRoot, repo.ownerName, repo.name),
1951 runtime: appsRuntime(ctx.app),
1952 record,
1953 };
1954 const closingPulls = openPullsClosing(db, repo.id);
1955 const words = await Promise.all(
1956 fetched.map((issue) =>
1957 tellWord(wordCtx, repo, issue, closingPulls.get(issue.number)),
1958 ),
1959 );
1960 const rows = fetched
1961 .map((issue, i) => ({ issue, info: words[i] }))
1962 .filter(
1963 (r): r is { issue: Issue; info: { word: Word; note: string } } =>
1964 r.info !== undefined && inWordFilter(r.info.word, filter),
1965 );
1966
1967 const list =
1968 rows.length === 0
1969 ? empty({
1970 icon: "issue",
1971 title: "Nothing here",
1972 note: "Nothing told matches this filter yet.",
1973 })
1974 : listCard(
1975 rows.map(({ issue, info }) =>
1976 row({
1977 href: `${base}/issues/${issue.number}`,
1978 nav: true,
1979 lead: avatarOf(issue.assigneeName ?? "crew"),
1980 title: issue.title,
1981 meta: html`${issue.assigneeName
1982 ? html`${issue.assigneeName} · `
1983 : null}${info.note} · ${time(issue.updatedAt)}`,
1984 trail: wordBadge(info.word),
1985 }),
1986 ),
1987 );
1988 const lastNumber = fetched[fetched.length - 1]?.number;
1989
1990 const body = html`${repoHeader(ctx, repo, "issues")}
1991 <div class="mt">
1992 ${wordFilterBar(`${base}/issues`, filter)} ${list}
1993 ${pager(
1994 fetched.length === LIST_PAGE && lastNumber !== undefined
1995 ? { next: pagedHref(`${base}/issues`, filter, String(lastNumber)) }
1996 : {},
1997 )}
1998 </div>
1999 <p class="muted small mt">
2000 <a href="${base}/issues?mode=dev">${icon("code")}See dev's list</a> —
2001 labels, milestones and the dependency form, dev's own way.
2002 </p>
2003 ${liveMount({ repo: base })}`;
2004
2005 return page({
2006 ctx,
2007 title: `Everything told · ${repo.name}`,
2008 crumbs: crumbsFor(repo),
2009 palette: repoPalette(repo),
2010 body,
2011 styleCss: studioCss,
2012 scriptJs: liveClientJs,
2013 });
2014 }
2015
2016 /**
2017 * GET /:owner/:name/pulls in the studio: every change, one row per pull in
2018 * its studio word — who made it, when it last moved, and the change itself
2019 * as the row's own link. No branch names, no diff. `?word=` narrows it;
2020 * `?mode=dev` reads dev's list, unchanged.
2021 */
2022 export async function studioChanges(
2023 ctx: Ctx,
2024 repo: Repo,
2025 record: AppRecord,
2026 ): Promise<Response> {
2027 const db = ctx.app.db;
2028 const base = repoBase(repo);
2029 const filter = readWordFilter(ctx);
2030 const state = filter === "shipped" || filter === "stopped" ? "closed" : "open";
2031 const rawCursor = Number(ctx.url.searchParams.get("cursor"));
2032 const cursor =
2033 Number.isSafeInteger(rawCursor) && rawCursor > 0 ? rawCursor : undefined;
2034
2035 const fetched = listPulls(db, repo.id, {
2036 state,
2037 limit: LIST_PAGE,
2038 ...(cursor !== undefined ? { cursor } : {}),
2039 });
2040 const wordCtx: WordCtx = {
2041 db,
2042 dir: repoDir(ctx.app.reposRoot, repo.ownerName, repo.name),
2043 runtime: appsRuntime(ctx.app),
2044 record,
2045 };
2046 const words = await Promise.all(
2047 fetched.map((pull) => pullWord(wordCtx, pull)),
2048 );
2049 const rows = fetched
2050 .map((pull, i) => ({ pull, info: words[i]! }))
2051 .filter((r) => inWordFilter(r.info.word, filter));
2052
2053 const list =
2054 rows.length === 0
2055 ? empty({
2056 icon: "pull",
2057 title: "Nothing here",
2058 note: "Nothing matches this filter yet.",
2059 })
2060 : listCard(
2061 rows.map(({ pull, info }) =>
2062 row({
2063 href: `${base}/pulls/${pull.number}`,
2064 nav: true,
2065 lead: avatarOf(pull.authorName),
2066 title: pull.title,
2067 meta: html`${pull.authorName} · ${info.note} ·
2068 ${time(pull.updatedAt)}`,
2069 trail: wordBadge(info.word),
2070 }),
2071 ),
2072 );
2073 const lastNumber = fetched[fetched.length - 1]?.number;
2074
2075 const body = html`${repoHeader(ctx, repo, "pulls")}
2076 <div class="mt">
2077 ${wordFilterBar(`${base}/pulls`, filter)} ${list}
2078 ${pager(
2079 fetched.length === LIST_PAGE && lastNumber !== undefined
2080 ? { next: pagedHref(`${base}/pulls`, filter, String(lastNumber)) }
2081 : {},
2082 )}
2083 </div>
2084 <p class="muted small mt">
2085 <a href="${base}/pulls?mode=dev">${icon("code")}See dev's list</a> —
2086 branch names and the diff, dev's own way.
2087 </p>
2088 ${liveMount({ repo: base })}`;
2089
2090 return page({
2091 ctx,
2092 title: `Every change · ${repo.name}`,
2093 crumbs: crumbsFor(repo),
2094 palette: repoPalette(repo),
2095 body,
2096 styleCss: studioCss,
2097 scriptJs: liveClientJs,
2098 });
2099 }
2100
2101 /**
2102 * GET /:owner/:name/runs in the studio: every run, one row per run in its
2103 * studio word — who ran it, when it last moved, and the transcript as the
2104 * row's own link. `?word=` narrows it; `?mode=dev` reads dev's list,
2105 * unchanged.
2106 */
2107 export async function studioRuns(
2108 ctx: Ctx,
2109 repo: Repo,
2110 record: AppRecord,
2111 ): Promise<Response> {
2112 const db = ctx.app.db;
2113 const base = repoBase(repo);
2114 const filter = readWordFilter(ctx);
2115 const cursor = ctx.url.searchParams.get("cursor") ?? undefined;
2116
2117 const fetched = listRuns(db, repo.id, {
2118 kind: "agent",
2119 limit: LIST_PAGE,
2120 ...(cursor ? { cursor } : {}),
2121 });
2122 const wordCtx: WordCtx = {
2123 db,
2124 dir: repoDir(ctx.app.reposRoot, repo.ownerName, repo.name),
2125 runtime: appsRuntime(ctx.app),
2126 record,
2127 };
2128 const actors = actorsFor(ctx, fetched);
2129 const issues = issuesFor(ctx, fetched);
2130 const words = await Promise.all(fetched.map((run) => runWord(wordCtx, run)));
2131 const rows = fetched
2132 .map((run, i) => ({ run, info: words[i]! }))
2133 .filter((r) => inWordFilter(r.info.word, filter));
2134
2135 const list =
2136 rows.length === 0
2137 ? empty({
2138 icon: "bot",
2139 title: "Nothing here",
2140 note: "Nothing matches this filter yet.",
2141 })
2142 : listCard(
2143 rows.map(({ run, info }) => {
2144 const who =
2145 run.actorId === null
2146 ? "crew"
2147 : (actors.get(run.actorId)?.username ?? "crew");
2148 return row({
2149 href: `${base}/runs/${run.id}`,
2150 nav: true,
2151 lead: avatarOf(who),
2152 title: runTitleFrom(run, actors, issues),
2153 meta: html`${who} · ${info.note} ·
2154 ${time(run.finishedAt ?? run.createdAt)}`,
2155 trail: wordBadge(info.word),
2156 });
2157 }),
2158 );
2159 const last = fetched[fetched.length - 1];
2160
2161 const body = html`${repoHeader(ctx, repo, "app", { compact: true })}
2162 <div class="mt">
2163 ${wordFilterBar(`${base}/runs`, filter)} ${list}
2164 ${pager(
2165 fetched.length === LIST_PAGE && last !== undefined
2166 ? { next: pagedHref(`${base}/runs`, filter, last.id) }
2167 : {},
2168 )}
2169 </div>
2170 <p class="muted small mt">
2171 <a href="${base}/runs?mode=dev">${icon("code")}See dev's list</a> —
2172 deploys and checks alongside the crew's runs, dev's own way.
2173 </p>
2174 ${liveMount({ repo: base })}`;
2175
2176 return page({
2177 ctx,
2178 title: `Every run · ${repo.name}`,
2179 crumbs: crumbsFor(repo),
2180 palette: repoPalette(repo),
2181 body,
2182 styleCss: studioCss,
2183 scriptJs: liveClientJs,
2184 });
2185 }
2186
18562187 // ---- the studio home ----
18572188
18582189 /**
test/studio.test.ts +136
@@ -437,6 +437,142 @@ describe("the studio", () => {
437437 expect(studioCookie).toContain("<h2>Depends on</h2>");
438438 });
439439
440 test("everything told is the issue list in the studio's own words; the word filter narrows it, and ?mode=dev renders today's list", async () => {
441 const told = openIssue(app, alice, repo, {
442 title: "List test: not picked up yet",
443 body: "waits for the crew",
444 });
445 if (!told.ok) throw new Error(String(told.error));
446
447 const page = await (await get("/alice/notes/issues")).text();
448 expect(page).toContain("List test: not picked up yet");
449 expect(page).toContain('data-word="told"');
450 expect(page).not.toContain("Clear filters");
451 expect(page).not.toContain('name="label"');
452 expect(page).not.toContain(
453 `>#${told.value.number} opened`,
454 );
455 expect(page).toContain(`href="/alice/notes/issues?word=told"`);
456
457 const filtered = await (
458 await get("/alice/notes/issues?word=told")
459 ).text();
460 expect(filtered).toContain("List test: not picked up yet");
461 const other = await (
462 await get("/alice/notes/issues?word=shipped")
463 ).text();
464 expect(other).not.toContain("List test: not picked up yet");
465
466 const dev = await (
467 await get("/alice/notes/issues", { cookie: `${cookie}; forge_mode=dev` })
468 ).text();
469 expect(dev).toContain(`>#${told.value.number} opened`);
470 expect(dev).not.toContain("data-word=");
471 });
472
473 test("every change is the pull list in the studio's own words; the word filter narrows it, and ?mode=dev renders today's list", async () => {
474 const branch = await commitToBranch(app, alice, repo, {
475 branch: "feature/list-change",
476 base: "main",
477 message: "List change",
478 files: [{ path: "LIST_CHANGE.md", content: "a change\n" }],
479 });
480 if (!branch.ok) throw new Error(JSON.stringify(branch.error));
481 const opened = await openPull(app, alice, repo, {
482 title: "List test: a green change",
483 body: "",
484 baseBranch: "main",
485 headBranch: "feature/list-change",
486 });
487 if (!opened.ok) throw new Error(String(opened.error));
488 const pull = opened.value;
489 const check = createRun(app.db, {
490 repoId: repo.id,
491 kind: "check",
492 issueId: pull.id,
493 env: `pull-${pull.number}`,
494 commitOid: pull.headOid,
495 reason: "pull",
496 });
497 claimRun(app.db, check.id);
498 finishRun(app.db, check.id, { state: "ok", summary: "check passed" });
499
500 const page = await (await get("/alice/notes/pulls")).text();
501 expect(page).toContain("List test: a green change");
502 expect(page).toContain('data-word="look"');
503 expect(page).not.toContain(
504 `${pull.headBranch} &rarr; ${pull.baseBranch}`,
505 );
506 expect(page).not.toContain(`${pull.headBranch}`);
507
508 const filtered = await (
509 await get("/alice/notes/pulls?word=look")
510 ).text();
511 expect(filtered).toContain("List test: a green change");
512 const other = await (
513 await get("/alice/notes/pulls?word=shipped")
514 ).text();
515 expect(other).not.toContain("List test: a green change");
516
517 const dev = await (
518 await get("/alice/notes/pulls", { cookie: `${cookie}; forge_mode=dev` })
519 ).text();
520 expect(dev).toContain(pull.headBranch);
521 expect(dev).not.toContain("data-word=");
522 });
523
524 test("every run is the run list in the studio's own words; the word filter narrows it, and ?mode=dev renders today's list", async () => {
525 const scout = getUserByName(app.db, "scout")!;
526 const run = createRun(app.db, {
527 repoId: repo.id,
528 kind: "agent",
529 actorId: scout.id,
530 reason: "assigned",
531 });
532 claimRun(app.db, run.id);
533
534 const page = await (await get("/alice/notes/runs")).text();
535 expect(page).toContain('data-word="working"');
536 expect(page).toContain(`href="/alice/notes/runs/${run.id}"`);
537
538 const filtered = await (
539 await get("/alice/notes/runs?word=working")
540 ).text();
541 expect(filtered).toContain(`href="/alice/notes/runs/${run.id}"`);
542 const other = await (
543 await get("/alice/notes/runs?word=shipped")
544 ).text();
545 expect(other).not.toContain(`href="/alice/notes/runs/${run.id}"`);
546
547 finishRun(app.db, run.id, { state: "failed", summary: "boom" });
548 const dev = await (
549 await get("/alice/notes/runs", { cookie: `${cookie}; forge_mode=dev` })
550 ).text();
551 expect(dev).toContain("Failed");
552 expect(dev).not.toContain("data-word=");
553 });
554
555 test("issues, pulls and runs on a repository that is not an app render exactly what they render today, even in studio mode", async () => {
556 const plain = await createRepo(app, alice, {
557 name: "plainlists",
558 visibility: "private",
559 description: "no app here",
560 });
561 if (!plain.ok) throw new Error(String(plain.error));
562
563 const issues = await (await get("/alice/plainlists/issues")).text();
564 expect(issues).not.toContain(">Told<");
565 expect(issues).not.toContain("See dev's list");
566
567 const pulls = await (await get("/alice/plainlists/pulls")).text();
568 expect(pulls).not.toContain(">Look<");
569 expect(pulls).not.toContain("See dev's list");
570
571 const runs = await (await get("/alice/plainlists/runs")).text();
572 expect(runs).not.toContain(">Working<");
573 expect(runs).not.toContain("See dev's list");
574 });
575
440576 test("a reader without access gets the forge's not-found on a tell, exactly as dev", async () => {
441577 const told = openIssue(app, alice, repo, { title: "Private work" });
442578 if (!told.ok) throw new Error(String(told.error));

↑↓ navigate ↵ open ⌘↵ its app esc close

Keyboard shortcuts

Docs