Public: anyone can read this; the crew can change it.
One screen answers “is everything fine?”: the asks inbox, reborn as all fine / these need you #357
Check · Review · Land · Live done — Landed and live in production.
scout/forge:scout/346-one-screen-answers-is-everything-fine-th → main 1 commit fork
9 files
changed +322
−54
docs/STUDIO.md +13
| @@ -280,6 +280,19 @@ page exactly as it always has, deep links and all. Nothing here reads a | ||
| 280 | 280 | diff or a blob: the page costs a handful of small queries whatever the |
| 281 | 281 | change's size, unlike the page it links to. |
| 282 | 282 | |
| 283 | ## The asks | |
| 284 | ||
| 285 | `/asks` (`asksPage`, `src/web/studio/asks_page.ts`) is the one screen for | |
| 286 | "is everything fine?". It leads with one sentence — _All fine_ when nothing | |
| 287 | asks the viewer, otherwise _N things need you_ — then the decision cards | |
| 288 | (a pull the crew stopped on, a green pull waiting for a look, an app parked | |
| 289 | at its ceiling), and, for a site admin only, one card per sentence of the | |
| 290 | forge's health (`needs` in `/api/v1/health`). What counts as an ask is | |
| 291 | defined once, in `src/web/studio/asks.ts`: the header's **Asks** count, the | |
| 292 | page, the dashboard's _Needs you_ link and the phone notifier all read it, | |
| 293 | so the numbers agree. It is server-rendered and every button is a form post, | |
| 294 | so it works without JavaScript. | |
| 295 | ||
| 283 | 296 | ## The home |
| 284 | 297 | |
| 285 | 298 | `/` in the studio: what is on you (issues and pulls a person assigned to |
forge.json +1 −1
| @@ -6,7 +6,7 @@ | ||
| 6 | 6 | "ready": 90 |
| 7 | 7 | }, |
| 8 | 8 | "check": { |
| 9 | "run": "bun x tsc --noEmit && bun scripts/test-shards.ts --timeout 60000 test/crew.test.ts test/crew-wrapup.test.ts test/crew-limits.test.ts test/crew-sandbox.test.ts test/steer.test.ts test/landing.test.ts test/decide.test.ts test/ledger.test.ts test/checks.test.ts test/dependencies.test.ts test/autonomy.test.ts test/studio.test.ts test/studio-words.test.ts test/forks.test.ts test/fork-flow.test.ts test/cross-repo-pull.test.ts test/pulls.test.ts test/apps.test.ts test/apps-adversarial.test.ts test/apps-busy.test.ts test/web.test.ts test/git-trust.test.ts test/helpers.test.ts test/shards.test.ts test/migrate.test.ts test/vitals.test.ts", | |
| 9 | "run": "bun x tsc --noEmit && bun scripts/test-shards.ts --timeout 60000 test/crew.test.ts test/crew-wrapup.test.ts test/crew-limits.test.ts test/crew-sandbox.test.ts test/steer.test.ts test/landing.test.ts test/decide.test.ts test/ledger.test.ts test/checks.test.ts test/dependencies.test.ts test/autonomy.test.ts test/studio.test.ts test/studio-words.test.ts test/forks.test.ts test/fork-flow.test.ts test/cross-repo-pull.test.ts test/pulls.test.ts test/apps.test.ts test/apps-adversarial.test.ts test/apps-busy.test.ts test/web.test.ts test/git-trust.test.ts test/helpers.test.ts test/shards.test.ts test/migrate.test.ts test/vitals.test.ts test/asks.test.ts", | |
| 10 | 10 | "timeout": 1800 |
| 11 | 11 | } |
| 12 | 12 | } |
src/http/server.ts +3
| @@ -104,6 +104,7 @@ import { | ||
| 104 | 104 | takeBackSubmit, |
| 105 | 105 | tellSubmit, |
| 106 | 106 | } from "../web/studio/actions.ts"; |
| 107 | import { asksPage } from "../web/studio/asks_page.ts"; | |
| 107 | 108 | import { liveAppsFragment, liveOnYouFragment } from "../web/studio/home.ts"; |
| 108 | 109 | import { appNowFragment } from "../web/studio/now.ts"; |
| 109 | 110 | import { appStateFragment } from "../web/studio/previews.ts"; |
| @@ -411,6 +412,8 @@ export function buildRouter(): Router<Handler> { | ||
| 411 | 412 | router.add("POST", "/mode", modeSubmit); |
| 412 | 413 | // The board: a live wall of the crews at work, for a second monitor. |
| 413 | 414 | router.add("GET", "/board", boardPage); |
| 415 | // One screen for "is everything fine?": what asks the viewer (docs/STUDIO.md). | |
| 416 | router.add("GET", "/asks", asksPage); | |
| 414 | 417 | // The big view: the whole forge as one keyboard-first frame. |
| 415 | 418 | router.add("GET", "/big", bigPage); |
| 416 | 419 | router.add("GET", "/search", searchPage); |
src/web/layout.ts +20 −4
| @@ -7,7 +7,7 @@ | ||
| 7 | 7 | * `/search`), and every dialog (a link to a real page). |
| 8 | 8 | */ |
| 9 | 9 | |
| 10 | import { appsOf } from "./studio/asks.ts"; | |
| 10 | import { appsOf, askCountFor } from "./studio/asks.ts"; | |
| 11 | 11 | import { listReposVisibleTo } from "../core/repos.ts"; |
| 12 | 12 | import type { User } from "../core/users.ts"; |
| 13 | 13 | import type { Ctx } from "../http/context.ts"; |
| @@ -165,6 +165,22 @@ function modeToggle(ctx: Ctx): Html { | ||
| 165 | 165 | </span>`; |
| 166 | 166 | } |
| 167 | 167 | |
| 168 | /** | |
| 169 | * Studio only: the way to `/asks`, with how many things ask the viewer — the | |
| 170 | * very count the page leads with (`askCountFor`). Quiet when nothing does. | |
| 171 | */ | |
| 172 | function asksLink(ctx: Ctx): Html { | |
| 173 | if (!ctx.auth.user || ctx.mode !== "studio") return html``; | |
| 174 | const count = askCountFor(ctx, ctx.auth.user); | |
| 175 | return html`<a | |
| 176 | class="btn" | |
| 177 | data-size="sm" | |
| 178 | href="/asks" | |
| 179 | title="${count === 0 ? "All fine" : `${count} need you`}" | |
| 180 | >Asks${count === 0 ? null : pill(count, "accent")}</a | |
| 181 | >`; | |
| 182 | } | |
| 183 | ||
| 168 | 184 | function newMenu(): Html { |
| 169 | 185 | const items: MenuItem[] = [ |
| 170 | 186 | { label: "New app", href: "/apps/new", icon: "rocket" }, |
| @@ -273,13 +289,13 @@ const STATIC_PALETTE_DEV: PaletteItem[] = [ | ||
| 273 | 289 | /** |
| 274 | 290 | * The studio's own verbs: no "New repository" or "New organization" — dev's |
| 275 | 291 | * own way of making one, which would land a studio reader on dev's own form |
| 276 | * unannounced. "Asks you" goes to the home, where the asks panel lives. | |
| 292 | * unannounced. "Asks you" goes to `/asks`, the one screen of what needs the viewer. | |
| 277 | 293 | */ |
| 278 | 294 | const STATIC_PALETTE_STUDIO: PaletteItem[] = [ |
| 279 | 295 | { t: "Home", h: "/", c: "Go to" }, |
| 280 | 296 | { t: "Apps", h: "/apps", c: "Go to" }, |
| 281 | 297 | { t: "Map", h: "/map", c: "Go to" }, |
| 282 | { t: "Asks you", h: "/", c: "Go to" }, | |
| 298 | { t: "Asks you", h: "/asks", c: "Go to" }, | |
| 283 | 299 | { t: "New app", h: "/apps/new", c: "Create" }, |
| 284 | 300 | { t: "Migrate a repository", h: "/migrate", c: "Create" }, |
| 285 | 301 | { t: "Search", h: "/search", c: "Go to" }, |
| @@ -430,7 +446,7 @@ function topbar( | ||
| 430 | 446 | )} |
| 431 | 447 | <span class="spacer"></span> |
| 432 | 448 | <span class="topbar-tools"> |
| 433 | ${searchBar()}<span class="hide-sm">${helpButton()}</span>${modeToggle( | |
| 449 | ${searchBar()}<span class="hide-sm">${helpButton()}</span>${asksLink(ctx)}${modeToggle( | |
| 434 | 450 | ctx, |
| 435 | 451 | )}${themeMenu(theme)} |
| 436 | 452 | ${user |
src/web/pages/site.ts +9 −45
| @@ -39,6 +39,7 @@ import { | ||
| 39 | 39 | } from "../../core/users.ts"; |
| 40 | 40 | import { isThemeName } from "../../design/themes.ts"; |
| 41 | 41 | import { crossOrigin, type Ctx } from "../../http/context.ts"; |
| 42 | import { askCountFor, askSentence } from "../studio/asks.ts"; | |
| 42 | 43 | import { studioHome } from "../studio/home.ts"; |
| 43 | 44 | import { edgeLoginUrl } from "../../http/edge.ts"; |
| 44 | 45 | import { sshCommand } from "../../ssh/command.ts"; |
| @@ -397,56 +398,19 @@ const threadPath = (repo: Repo, issue: Issue): string => | ||
| 397 | 398 | `/${repo.ownerName}/${repo.name}/${issue.kind === "pull" ? "pulls" : "issues"}/${issue.number}`; |
| 398 | 399 | |
| 399 | 400 | /** |
| 400 | * Open pulls the crew opened, in repositories the viewer can write — the | |
| 401 | * ones waiting on a review only a person can give. Visibility is in the | |
| 402 | * query; write access is checked once per distinct repository here. The | |
| 403 | * check beside each is the latest deploy of that pull, one grouped read. | |
| 401 | * Needs you: one line that links to `/asks`, where the single list of what | |
| 402 | * asks the viewer lives (`studio/asks.ts` decides what counts). Absent when | |
| 403 | * nothing asks — a card saying "nothing" is a zero drawn. | |
| 404 | 404 | */ |
| 405 | 405 | function needsYouCard(ctx: Ctx, user: User): Html | undefined { |
| 406 | const db = ctx.app.db; | |
| 407 | const pulls = listOpenAgentPullsVisibleTo(db, user, 25); | |
| 408 | if (pulls.length === 0) return undefined; | |
| 409 | const repos = getReposByIds( | |
| 410 | db, | |
| 411 | pulls.map((pull) => pull.repoId), | |
| 412 | ); | |
| 413 | // Write access for every repository at once — one query, like visibility. | |
| 414 | const mayWrite = writableRepoIds(db, user, [...repos.keys()]); | |
| 415 | const writable = new Map<string, Repo>(); | |
| 416 | for (const repo of repos.values()) | |
| 417 | if (mayWrite.has(repo.id)) writable.set(repo.id, repo); | |
| 418 | const mine = pulls.filter((pull) => writable.has(pull.repoId)).slice(0, 10); | |
| 419 | if (mine.length === 0) return undefined; | |
| 420 | const checks = latestRunsForIssues( | |
| 421 | db, | |
| 422 | mine.map((pull) => pull.id), | |
| 423 | ); | |
| 424 | return listCard( | |
| 425 | mine.map((pull) => { | |
| 426 | const repo = writable.get(pull.repoId)!; | |
| 427 | const check = checks.get(pull.id); | |
| 428 | return row({ | |
| 429 | href: threadPath(repo, pull), | |
| 430 | nav: true, | |
| 431 | state: "open", | |
| 432 | lead: icon("pull"), | |
| 433 | title: pull.title, | |
| 434 | titleAttr: pull.title, | |
| 435 | meta: html`${repo.ownerName}/${repo.name} #${pull.number} · | |
| 436 | ${userTag(pull.authorName)} opened ${time(pull.createdAt)}`, | |
| 437 | trail: html`${check === undefined | |
| 438 | ? html`<span class="badge verdict-stale" | |
| 439 | >${icon("clock")}No checks</span | |
| 440 | >` | |
| 441 | : runStateBadge(check.state, check.kind)}`, | |
| 442 | }); | |
| 443 | }), | |
| 406 | const count = askCountFor(ctx, user); | |
| 407 | if (count === 0) return undefined; | |
| 408 | return card( | |
| 409 | html`<a href="/asks">${askSentence(count)}</a>`, | |
| 444 | 410 | { |
| 445 | 411 | head: html`${icon("eye")} |
| 446 | 412 | <h2>Needs you</h2> |
| 447 | <span class="muted small" | |
| 448 | >pull requests from the crew, waiting on a review</span | |
| 449 | >`, | |
| 413 | <span class="muted small">decisions, and the forge's own health</span>`, | |
| 450 | 414 | }, |
| 451 | 415 | ); |
| 452 | 416 | } |
src/web/studio/asks.ts +43 −1
| @@ -10,7 +10,8 @@ | ||
| 10 | 10 | import type { Database } from "bun:sqlite"; |
| 11 | 11 | import { previewEnv } from "../../apps/data.ts"; |
| 12 | 12 | import { crewForRepos } from "../../core/agents.ts"; |
| 13 | import { appsForRepos, getAppByRepo } from "../../core/apps.ts"; | |
| 13 | import { appsRuntime } from "../../apps/runtime.ts"; | |
| 14 | import { appsForRepos, getAppByRepo, listApps } from "../../core/apps.ts"; | |
| 14 | 15 | import { latestDecision } from "../../core/decisions.ts"; |
| 15 | 16 | import { |
| 16 | 17 | listComments, |
| @@ -35,6 +36,7 @@ import type { User } from "../../core/users.ts"; | ||
| 35 | 36 | import { isPausedAtCeiling, spentTodayForRepos } from "../../crew/autonomy.ts"; |
| 36 | 37 | import { CHECK_FAILED_LIMIT } from "../../crew/dispatch.ts"; |
| 37 | 38 | import type { Ctx } from "../../http/context.ts"; |
| 39 | import { vitals } from "../../kernel/vitals.ts"; | |
| 38 | 40 | import { repoBase } from "../chrome.ts"; |
| 39 | 41 | import { html, when, type Html } from "../html.ts"; |
| 40 | 42 | import { btn, card, icon, row } from "../ui.ts"; |
| @@ -389,3 +391,43 @@ export function decisionActions(ctx: Ctx, d: Decision): Html { | ||
| 389 | 391 | size: "sm", |
| 390 | 392 | })}`; |
| 391 | 393 | } |
| 394 | ||
| 395 | /** | |
| 396 | * What the forge itself needs, for a site admin: each sentence of the | |
| 397 | * health answer (`/api/v1/health`) as one ask. Anyone else gets none — the | |
| 398 | * forge's standing is the admin's to read and nobody else's to know about. | |
| 399 | */ | |
| 400 | export function forgeNeedsFor(ctx: Ctx, user: User | undefined): string[] { | |
| 401 | if (!user?.isAdmin) return []; | |
| 402 | const runtime = appsRuntime(ctx.app); | |
| 403 | const envs = runtime | |
| 404 | ? listApps(ctx.app.db).flatMap((record) => runtime.status(record)) | |
| 405 | : []; | |
| 406 | return vitals({ | |
| 407 | db: ctx.app.db, | |
| 408 | dataDir: ctx.app.config.dataDir, | |
| 409 | envs, | |
| 410 | }).needs; | |
| 411 | } | |
| 412 | ||
| 413 | /** | |
| 414 | * How many things ask the viewer: the one number the studio header and the | |
| 415 | * asks page both show. It counts the same candidates the cards are made | |
| 416 | * from (`askCandidatesFor`) plus the forge's own needs, so the two cannot | |
| 417 | * disagree. | |
| 418 | */ | |
| 419 | export function askCountFor(ctx: Ctx, user: User | undefined): number { | |
| 420 | if (!user) return 0; | |
| 421 | const repos = listReposVisibleTo(ctx.app.db, user, { limit: 200 }); | |
| 422 | return ( | |
| 423 | askCandidatesFor(ctx.app.db, user, repos).length + | |
| 424 | forgeNeedsFor(ctx, user).length | |
| 425 | ); | |
| 426 | } | |
| 427 | ||
| 428 | /** The single sentence the asks page leads with, and the dashboard links with. */ | |
| 429 | export function askSentence(count: number): string { | |
| 430 | return count === 0 | |
| 431 | ? "All fine" | |
| 432 | : `${count} ${count === 1 ? "thing needs" : "things need"} you`; | |
| 433 | } |
src/web/studio/asks_page.ts added +66
| @@ -0,0 +1,66 @@ | ||
| 1 | /** | |
| 2 | * `/asks`: the one screen that answers "is everything fine?". A sentence — | |
| 3 | * *All fine*, or *N things need you* — and under it the decision cards | |
| 4 | * (`asks.ts`), then, for a site admin, one card per thing the forge itself | |
| 5 | * needs. First paint is the whole page; every button is a plain form post. | |
| 6 | */ | |
| 7 | ||
| 8 | import type { Ctx } from "../../http/context.ts"; | |
| 9 | import { repoBase, signInFirst } from "../chrome.ts"; | |
| 10 | import { listReposVisibleTo } from "../../core/repos.ts"; | |
| 11 | import { html, type Html } from "../html.ts"; | |
| 12 | import { page } from "../layout.ts"; | |
| 13 | import { btn, card, icon } from "../ui.ts"; | |
| 14 | import { | |
| 15 | askSentence, | |
| 16 | decisionActions, | |
| 17 | decisionForm, | |
| 18 | decisionsFor, | |
| 19 | forgeNeedsFor, | |
| 20 | type Decision, | |
| 21 | } from "./asks.ts"; | |
| 22 | import { changeHref } from "./nouns.ts"; | |
| 23 | ||
| 24 | function decisionCard(ctx: Ctx, d: Decision): Html { | |
| 25 | const base = repoBase(d.repo); | |
| 26 | const title = d.pull | |
| 27 | ? html`<a href="${changeHref(base, d.pull.number)}">${d.pull.title}</a>` | |
| 28 | : html`<a href="${base}/settings/autonomy">Paused</a>`; | |
| 29 | return card( | |
| 30 | html`<p>${d.says}</p> | |
| 31 | <div class="bar mt">${decisionActions(ctx, d)}</div>`, | |
| 32 | { | |
| 33 | id: d.id.replace(/[^a-z0-9-]/g, "-"), | |
| 34 | head: html`${icon(d.kind === "look" ? "eye" : "pull")} | |
| 35 | <h2>${title}</h2> | |
| 36 | <span class="muted small">${d.repo.ownerName}/${d.repo.name}</span>`, | |
| 37 | }, | |
| 38 | ); | |
| 39 | } | |
| 40 | ||
| 41 | const forgeCard = (sentence: string): Html => | |
| 42 | card(html`<p>${sentence}</p>`, { | |
| 43 | tone: "danger", | |
| 44 | head: html`${icon("shield")} | |
| 45 | <h2>The forge</h2> | |
| 46 | <span class="spacer"></span> | |
| 47 | ${btn("Health", { href: "/api/v1/health", size: "sm" })}`, | |
| 48 | }); | |
| 49 | ||
| 50 | export function asksPage(ctx: Ctx): Response { | |
| 51 | const user = ctx.auth.user; | |
| 52 | if (!user) return signInFirst(ctx); | |
| 53 | const decisions = decisionsFor( | |
| 54 | ctx, | |
| 55 | listReposVisibleTo(ctx.app.db, user, { limit: 200 }), | |
| 56 | ); | |
| 57 | const needs = forgeNeedsFor(ctx, user); | |
| 58 | const count = decisions.length + needs.length; | |
| 59 | const body = html`<h1 id="asks-sentence">${askSentence(count)}</h1> | |
| 60 | <div class="stack"> | |
| 61 | ${decisions.map((d) => decisionCard(ctx, d))} | |
| 62 | ${needs.map(forgeCard)} | |
| 63 | </div> | |
| 64 | ${decisions.map((d) => decisionForm(d))}`; | |
| 65 | return page({ ctx, title: askSentence(count), body }); | |
| 66 | } |
test/asks.test.ts added +164
| @@ -0,0 +1,164 @@ | ||
| 1 | /** | |
| 2 | * `/asks`, the one screen for "is everything fine?": the sentence, the | |
| 3 | * decision cards, the forge's own needs for an admin — and the same number | |
| 4 | * in the header. | |
| 5 | */ | |
| 6 | ||
| 7 | import { afterAll, beforeAll, describe, expect, test } from "bun:test"; | |
| 8 | import { mkdtemp, rm } from "node:fs/promises"; | |
| 9 | import { tmpdir } from "node:os"; | |
| 10 | import { join } from "node:path"; | |
| 11 | import { createApp, type App } from "../src/app.ts"; | |
| 12 | import { addToCrew, createAgent } from "../src/actions/crew.ts"; | |
| 13 | import { openPull } from "../src/actions/pulls.ts"; | |
| 14 | import { commitToBranch, createRepo } from "../src/actions/repos.ts"; | |
| 15 | import { recordDecision } from "../src/core/decisions.ts"; | |
| 16 | import type { Repo } from "../src/core/repos.ts"; | |
| 17 | import { claimRun, createRun, finishRun } from "../src/core/runs.ts"; | |
| 18 | import { getUserByName } from "../src/core/users.ts"; | |
| 19 | import { serve, type Servers } from "../src/http/server.ts"; | |
| 20 | import { quietLogs } from "./helpers/globals.ts"; | |
| 21 | ||
| 22 | quietLogs(); | |
| 23 | ||
| 24 | const PASSWORD = "a-long-asks-passphrase"; | |
| 25 | ||
| 26 | describe("the asks page", () => { | |
| 27 | let dataDir = ""; | |
| 28 | let app: App; | |
| 29 | let servers: Servers; | |
| 30 | let alice = ""; | |
| 31 | let bob = ""; | |
| 32 | let repo: Repo; | |
| 33 | ||
| 34 | const get = async (cookie: string, path: string): Promise<string> => { | |
| 35 | const response = await fetch(`${servers.url}${path}`, { | |
| 36 | headers: { cookie }, | |
| 37 | redirect: "manual", | |
| 38 | }); | |
| 39 | expect(response.status).toBe(200); | |
| 40 | return (await response.text()).replace(/\s+/g, " "); | |
| 41 | }; | |
| 42 | ||
| 43 | const signUp = async (username: string): Promise<string> => { | |
| 44 | const response = await fetch(`${servers.url}/signup`, { | |
| 45 | method: "POST", | |
| 46 | body: new URLSearchParams({ | |
| 47 | username, | |
| 48 | email: `${username}@forge.test`, | |
| 49 | password: PASSWORD, | |
| 50 | }), | |
| 51 | redirect: "manual", | |
| 52 | }); | |
| 53 | return `${(response.headers.get("set-cookie") ?? "").split(";")[0]!}; forge_mode=studio`; | |
| 54 | }; | |
| 55 | ||
| 56 | /** The number the header's link carries, or 0 when it carries none. */ | |
| 57 | const headerCount = (page: string): number => | |
| 58 | Number(/>Asks<span class="pill"[^>]*>(\d+)</.exec(page)?.[1] ?? 0); | |
| 59 | ||
| 60 | const sentence = (page: string): string => | |
| 61 | /<h1 id="asks-sentence">([^<]*)<\/h1>/.exec(page)?.[1] ?? ""; | |
| 62 | ||
| 63 | beforeAll(async () => { | |
| 64 | dataDir = await mkdtemp(join(tmpdir(), "forge-asks-")); | |
| 65 | app = createApp({ dataDir, port: 0 }); | |
| 66 | servers = await serve(app); | |
| 67 | alice = await signUp("alice"); // the first account: the site admin | |
| 68 | bob = await signUp("bob"); | |
| 69 | const aliceUser = getUserByName(app.db, "alice")!; | |
| 70 | const made = await createRepo(app, aliceUser, { | |
| 71 | name: "garden", | |
| 72 | visibility: "public", | |
| 73 | }); | |
| 74 | if (!made.ok) throw new Error(String(made.error)); | |
| 75 | repo = made.value; | |
| 76 | await commitToBranch(app, aliceUser, repo, { | |
| 77 | message: "seed", | |
| 78 | files: [{ path: "README.md", content: "# garden\n" }], | |
| 79 | }); | |
| 80 | await createAgent(app, aliceUser, { username: "bot" }); | |
| 81 | addToCrew(app, aliceUser, repo, "bot"); | |
| 82 | }, 30_000); | |
| 83 | ||
| 84 | afterAll(async () => { | |
| 85 | await servers.stop(); | |
| 86 | await rm(dataDir, { recursive: true, force: true }); | |
| 87 | }); | |
| 88 | ||
| 89 | test("when nothing asks, it says All fine, and so does the header", async () => { | |
| 90 | const page = await get(alice, "/asks"); | |
| 91 | expect(sentence(page)).toBe("All fine"); | |
| 92 | expect(headerCount(page)).toBe(0); | |
| 93 | expect(await get(alice, "/")).not.toContain("<h2>Needs you</h2>"); | |
| 94 | }); | |
| 95 | ||
| 96 | test("a signed-out reader is sent to sign in", async () => { | |
| 97 | const response = await fetch(`${servers.url}/asks`, { redirect: "manual" }); | |
| 98 | expect(response.status).toBe(303); | |
| 99 | expect(response.headers.get("location")).toContain("/signin"); | |
| 100 | }); | |
| 101 | ||
| 102 | test("a stopped pull is a card, and the header and the page agree", async () => { | |
| 103 | const bot = getUserByName(app.db, "bot")!; | |
| 104 | const branch = await commitToBranch(app, getUserByName(app.db, "alice")!, repo, { | |
| 105 | branch: "bot/1-thing", | |
| 106 | base: "main", | |
| 107 | message: "Bot work", | |
| 108 | files: [{ path: "BOT.md", content: "bot\n" }], | |
| 109 | }); | |
| 110 | expect(branch.ok).toBe(true); | |
| 111 | const opened = await openPull(app, bot, repo, { | |
| 112 | title: "Bot did a thing", | |
| 113 | body: "", | |
| 114 | baseBranch: "main", | |
| 115 | headBranch: "bot/1-thing", | |
| 116 | }); | |
| 117 | if (!opened.ok) throw new Error(String(opened.error)); | |
| 118 | const run = createRun(app.db, { | |
| 119 | repoId: repo.id, | |
| 120 | kind: "check", | |
| 121 | issueId: opened.value.id, | |
| 122 | env: `pull-${opened.value.number}`, | |
| 123 | commitOid: opened.value.headOid, | |
| 124 | reason: "pull", | |
| 125 | }); | |
| 126 | claimRun(app.db, run.id); | |
| 127 | finishRun(app.db, run.id, { state: "failed", summary: "red" }); | |
| 128 | // Failed but the crew is still trying: that is working, not an ask. | |
| 129 | expect(sentence(await get(alice, "/asks"))).toBe("All fine"); | |
| 130 | recordDecision(app.db, { | |
| 131 | kind: "retry", | |
| 132 | repoId: repo.id, | |
| 133 | issueId: opened.value.id, | |
| 134 | state: {}, | |
| 135 | answer: "stop", | |
| 136 | p: 1, | |
| 137 | by: "rule", | |
| 138 | ms: 1, | |
| 139 | }); | |
| 140 | const page = await get(alice, "/asks"); | |
| 141 | expect(sentence(page)).toBe("1 thing needs you"); | |
| 142 | expect(page).toContain("Bot did a thing"); | |
| 143 | expect(headerCount(page)).toBe(1); | |
| 144 | // The dashboard no longer lists it: it links here. | |
| 145 | const dash = await get(`${alice.split(";")[0]!}; forge_mode=dev`, "/"); | |
| 146 | expect(dash).toContain('<a href="/asks">1 thing needs you</a>'); | |
| 147 | // Bob can read the public repository but cannot write it: not his ask. | |
| 148 | expect(sentence(await get(bob, "/asks"))).toBe("All fine"); | |
| 149 | }); | |
| 150 | ||
| 151 | test("a forge health problem is a card for an admin and for no one else", async () => { | |
| 152 | createRun(app.db, { repoId: repo.id, kind: "check", env: "main" }); | |
| 153 | app.db | |
| 154 | .query("UPDATE run SET created_at = ?1 WHERE state = 'queued'") | |
| 155 | .run(new Date(Date.now() - 3 * 3_600_000).toISOString()); | |
| 156 | const admin = await get(alice, "/asks"); | |
| 157 | expect(admin).toContain("queue has waited over"); | |
| 158 | expect(sentence(admin)).toBe("2 things need you"); | |
| 159 | expect(headerCount(admin)).toBe(2); | |
| 160 | const other = await get(bob, "/asks"); | |
| 161 | expect(other).not.toContain("queue has waited"); | |
| 162 | expect(sentence(other)).toBe("All fine"); | |
| 163 | }); | |
| 164 | }); |
test/web-apps.test.ts +3 −3
| @@ -761,9 +761,9 @@ describe("apps, runs and the crew on the web", () => { | ||
| 761 | 761 | headBranch: "bot/1-do-a-thing", |
| 762 | 762 | }); |
| 763 | 763 | expect(proposed.ok).toBe(true); |
| 764 | const inbox = await bodyOf(alice, "/"); | |
| 765 | expect(inbox).toContain("<h2>Needs you</h2>"); | |
| 766 | expect(inbox).toContain("Bot did a thing"); | |
| 764 | // Whether this pull asks depends on its check, which the runtime is | |
| 765 | // racing to finish here: when it does, the dashboard links to /asks | |
| 766 | // rather than listing it (test/asks.test.ts holds that page to account). | |
| 767 | 767 | // Bob can read the public repo but not write it: not his inbox. |
| 768 | 768 | expect(await bodyOf(bob, "/")).not.toContain("<h2>Needs you</h2>"); |
| 769 | 769 | }); |
new above
offline