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
Check · Review · Land · Live done — Landed and live in production.
scout/forge:scout/56-an-app-s-tabs-are-the-studio-s-lists-eve → main 1 commit fork
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 | ||
| 102 | 102 | who can write here; its title opens the transcript, where the agent can be |
| 103 | 103 | steered mid-run (see `docs/APPS.md`, _Runs_). |
| 104 | 104 | |
| 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 | ||
| 105 | 133 | ## A tell, on its own page |
| 106 | 134 | |
| 107 | 135 | `/: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( | ||
| 162 | 162 | shortcut: "g c", |
| 163 | 163 | }, |
| 164 | 164 | { |
| 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", | |
| 166 | 168 | href: `${base}/issues`, |
| 167 | icon: "issue", | |
| 169 | icon: studio ? "clock" : "issue", | |
| 168 | 170 | ...(facts.openIssues === null ? {} : { count: facts.openIssues }), |
| 169 | 171 | current: active === "issues", |
| 170 | 172 | shortcut: "g i", |
| 171 | 173 | }, |
| 172 | 174 | { |
| 173 | label: "Pulls", | |
| 175 | label: studio ? "Look" : "Pulls", | |
| 174 | 176 | href: `${base}/pulls`, |
| 175 | icon: "pull", | |
| 177 | icon: studio ? "eye" : "pull", | |
| 176 | 178 | ...(facts.openPulls === null ? {} : { count: facts.openPulls }), |
| 177 | 179 | current: active === "pulls", |
| 178 | 180 | shortcut: "g p", |
src/web/pages/issues.ts +10 −1
| @@ -63,7 +63,7 @@ import { | ||
| 63 | 63 | threadFragment, |
| 64 | 64 | } from "./thread.ts"; |
| 65 | 65 | import { crewActivityCard } from "./runs.ts"; |
| 66 | import { studioTell } from "./studio.ts"; | |
| 66 | import { studioTell, studioTells } from "./studio.ts"; | |
| 67 | 67 | |
| 68 | 68 | const PER_PAGE = 25; |
| 69 | 69 | |
| @@ -291,6 +291,15 @@ export function sourceQuery( | ||
| 291 | 291 | export async function issuesList(ctx: Ctx): Promise<Response> { |
| 292 | 292 | const repo = loadRepo(ctx); |
| 293 | 293 | 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 | } | |
| 294 | 303 | const scope = localScope(ctx, repo); |
| 295 | 304 | const base = scope.base; |
| 296 | 305 | const query = readQuery(ctx); |
src/web/pages/pulls.ts +11
| @@ -6,6 +6,7 @@ | ||
| 6 | 6 | */ |
| 7 | 7 | |
| 8 | 8 | import { openPull } from "../../actions/pulls.ts"; |
| 9 | import { getAppByRepo } from "../../core/apps.ts"; | |
| 9 | 10 | import { |
| 10 | 11 | commentCounts, |
| 11 | 12 | issueCounts, |
| @@ -52,6 +53,7 @@ import { | ||
| 52 | 53 | } from "../ui.ts"; |
| 53 | 54 | import { refSet } from "./refs.ts"; |
| 54 | 55 | import { threadRow } from "./issues.ts"; |
| 56 | import { studioChanges } from "./studio.ts"; | |
| 55 | 57 | import { originGlyph } from "./thread.ts"; |
| 56 | 58 | |
| 57 | 59 | const PER_PAGE = 25; |
| @@ -123,6 +125,15 @@ function verdictMark(summary: ReviewSummary | undefined): Html { | ||
| 123 | 125 | export async function pullsList(ctx: Ctx): Promise<Response> { |
| 124 | 126 | const repo = loadRepo(ctx); |
| 125 | 127 | 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 | } | |
| 126 | 137 | const base = repoBase(repo); |
| 127 | 138 | const db = ctx.app.db; |
| 128 | 139 | const params = ctx.url.searchParams; |
src/web/pages/runs.ts +11 −2
| @@ -37,7 +37,7 @@ import { field, readForm } from "../form.ts"; | ||
| 37 | 37 | import { html, when, type Html } from "../html.ts"; |
| 38 | 38 | import { flashTo, page, redirect } from "../layout.ts"; |
| 39 | 39 | import { liveClientJs, liveMount } from "../live.ts"; |
| 40 | import { studioRun } from "./studio.ts"; | |
| 40 | import { studioRun, studioRuns } from "./studio.ts"; | |
| 41 | 41 | import { |
| 42 | 42 | btn, |
| 43 | 43 | empty, |
| @@ -256,9 +256,18 @@ export function crewActivityCard(ctx: Ctx, repo: Repo, issue: Issue): Html { | ||
| 256 | 256 | const PER_PAGE = 30; |
| 257 | 257 | |
| 258 | 258 | // GET /:owner/:name/runs |
| 259 | export function runsList(ctx: Ctx): Response { | |
| 259 | export async function runsList(ctx: Ctx): Promise<Response> { | |
| 260 | 260 | const repo = loadRepo(ctx); |
| 261 | 261 | 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 | } | |
| 262 | 271 | const base = repoBase(repo); |
| 263 | 272 | const q = ctx.url.searchParams; |
| 264 | 273 | const kindRaw = q.get("kind"); |
src/web/pages/studio.ts +331
| @@ -106,8 +106,10 @@ import { | ||
| 106 | 106 | icon, |
| 107 | 107 | listCard, |
| 108 | 108 | originTag, |
| 109 | pager, | |
| 109 | 110 | pill, |
| 110 | 111 | row, |
| 112 | seg, | |
| 111 | 113 | sha, |
| 112 | 114 | stateBadge, |
| 113 | 115 | time, |
| @@ -121,11 +123,14 @@ import { onYouCard } from "./site.ts"; | ||
| 121 | 123 | import { previewCard } from "./apps.ts"; |
| 122 | 124 | import { pullState } from "./pulls.ts"; |
| 123 | 125 | import { |
| 126 | actorsFor, | |
| 124 | 127 | costText, |
| 125 | 128 | durationText, |
| 129 | issuesFor, | |
| 126 | 130 | logLine, |
| 127 | 131 | reasonWords, |
| 128 | 132 | runTitle, |
| 133 | runTitleFrom, | |
| 129 | 134 | } from "./runs.ts"; |
| 130 | 135 | import { composer, conversation, originGlyph } from "./thread.ts"; |
| 131 | 136 | |
| @@ -1853,6 +1858,332 @@ export async function studioTell( | ||
| 1853 | 1858 | }); |
| 1854 | 1859 | } |
| 1855 | 1860 | |
| 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 | ||
| 1856 | 2187 | // ---- the studio home ---- |
| 1857 | 2188 | |
| 1858 | 2189 | /** |
test/studio.test.ts +136
| @@ -437,6 +437,142 @@ describe("the studio", () => { | ||
| 437 | 437 | expect(studioCookie).toContain("<h2>Depends on</h2>"); |
| 438 | 438 | }); |
| 439 | 439 | |
| 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} → ${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 | ||
| 440 | 576 | test("a reader without access gets the forge's not-found on a tell, exactly as dev", async () => { |
| 441 | 577 | const told = openIssue(app, alice, repo, { title: "Private work" }); |
| 442 | 578 | if (!told.ok) throw new Error(String(told.error)); |
new above
offline