Public: anyone can read this; the crew can change it.
The top bar says how many things ask you, on every page #104
Check · Review · Land · Live done — Landed and live in production.
scout/forge:scout/74-the-top-bar-says-how-many-things-ask-you → main 1 commit fork
8 files
changed +408
−68
docs/STUDIO.md +9
| @@ -221,6 +221,15 @@ is the same flow as ever. `/?mode=dev` is the repositories list, and — like | ||
| 221 | 221 | the toggle in the top bar — it switches this browser to dev from then on; |
| 222 | 222 | `/?mode=studio` switches it back, the same way. |
| 223 | 223 | |
| 224 | The top bar carries the same count, on every studio page: a pill beside the | |
| 225 | wordmark, linking here, naming how many things ask the signed-in person — | |
| 226 | `askCountFor` (`src/web/studio/asks.ts`), the candidates `decisionsFor` | |
| 227 | itself builds its cards from, before either the words or a preview picture | |
| 228 | are read, so the two can never disagree. With nothing asking, the bar shows | |
| 229 | nothing rather than a zero. It is studio's own; dev's top bar never carries | |
| 230 | it. On a page that already streams (the home, an app, the map, the board) | |
| 231 | the bar's own live region rides that same stream — no second connection. | |
| 232 | ||
| 224 | 233 | The home keeps itself current. *Asks you*, *your apps*, *happening now* and |
| 225 | 234 | *on you* are each a live region (`data-live`, `src/web/client.ts`): a deploy |
| 226 | 235 | serving, a pull opening, closing or merging, a run starting or ending, or an |
src/crew/autonomy.ts +33
| @@ -119,6 +119,39 @@ export function spentToday( | ||
| 119 | 119 | ); |
| 120 | 120 | } |
| 121 | 121 | |
| 122 | /** | |
| 123 | * Agent spend since UTC midnight for many repositories at once, keyed by | |
| 124 | * repo id — one query, for a count that must not slow down as the apps | |
| 125 | * grow (the topbar's ask count is the reason this exists). | |
| 126 | */ | |
| 127 | export function spentTodayForRepos( | |
| 128 | db: App["db"], | |
| 129 | repoIds: readonly string[], | |
| 130 | now = new Date(), | |
| 131 | ): Map<string, number> { | |
| 132 | const out = new Map<string, number>(); | |
| 133 | const unique = [...new Set(repoIds)]; | |
| 134 | if (unique.length === 0) return out; | |
| 135 | const since = startOfTodayUtc(now).toISOString(); | |
| 136 | const CHUNK = 400; | |
| 137 | for (let i = 0; i < unique.length; i += CHUNK) { | |
| 138 | const batch = unique.slice(i, i + CHUNK); | |
| 139 | const rows = db | |
| 140 | .query< | |
| 141 | { repo_id: string; spent: number | null }, | |
| 142 | string[] | |
| 143 | >( | |
| 144 | `SELECT repo_id, SUM(cost_usd) AS spent FROM run | |
| 145 | WHERE kind = 'agent' AND created_at >= ?${batch.length + 1} | |
| 146 | AND repo_id IN (${batch.map((_, n) => `?${n + 1}`).join(",")}) | |
| 147 | GROUP BY repo_id`, | |
| 148 | ) | |
| 149 | .all(...batch, since); | |
| 150 | for (const row of rows) out.set(row.repo_id, row.spent ?? 0); | |
| 151 | } | |
| 152 | return out; | |
| 153 | } | |
| 154 | ||
| 122 | 155 | function activeRuns(app: App, repoId: string) { |
| 123 | 156 | return listActive(app.db, "agent").filter((r) => r.repoId === repoId); |
| 124 | 157 | } |
src/web/layout.ts +31 −1
| @@ -29,7 +29,8 @@ import { | ||
| 29 | 29 | import { prefersJson } from "./form.ts"; |
| 30 | 30 | import { html, raw, when, type Html } from "./html.ts"; |
| 31 | 31 | import { icon, themeGlyph } from "./icons.ts"; |
| 32 | import { btn, iconBtn, kbd, menu, type MenuItem } from "./ui.ts"; | |
| 32 | import { appsOf, askCountFor } from "./studio/asks.ts"; | |
| 33 | import { btn, iconBtn, kbd, menu, pill, type MenuItem } from "./ui.ts"; | |
| 33 | 34 | |
| 34 | 35 | /** One row of the command palette: title, href, category. */ |
| 35 | 36 | export interface PaletteItem { |
| @@ -142,6 +143,34 @@ function modeToggle(ctx: Ctx): Html { | ||
| 142 | 143 | </span>`; |
| 143 | 144 | } |
| 144 | 145 | |
| 146 | /** | |
| 147 | * What asks the signed-in person, across every app they can land — the | |
| 148 | * same set the home's own "Asks you" panel counts (`askCountFor`, the | |
| 149 | * cheap query beside `decisionsFor`), never built from the cards | |
| 150 | * themselves. Studio only; dev's top bar never carries it. With nothing | |
| 151 | * asking, the wrapper stays in the document — so a live swap on a page | |
| 152 | * that already streams (the home, an app, the map, the board) can still | |
| 153 | * find it by id and fill it in — but nothing renders inside it: a zero is | |
| 154 | * not a fact worth a badge. | |
| 155 | */ | |
| 156 | function asksBadge(ctx: Ctx): Html { | |
| 157 | const user = ctx.auth.user; | |
| 158 | if (!user || ctx.mode !== "studio") return html``; | |
| 159 | const count = askCountFor(ctx, appsOf(ctx, user).repos); | |
| 160 | return html`<span id="topbar-asks" data-live="run.*,pull.*,issue.*,check.*" | |
| 161 | >${when( | |
| 162 | count, | |
| 163 | (n) => | |
| 164 | html`<a | |
| 165 | class="topbar-asks" | |
| 166 | href="/" | |
| 167 | title="${n} thing${n === 1 ? "" : "s"} ask you" | |
| 168 | >${pill(`${n} asks you`, "accent")}</a | |
| 169 | >`, | |
| 170 | )}</span | |
| 171 | >`; | |
| 172 | } | |
| 173 | ||
| 145 | 174 | function newMenu(): Html { |
| 146 | 175 | const items: MenuItem[] = [ |
| 147 | 176 | { label: "New app", href: "/apps/new", icon: "rocket" }, |
| @@ -353,6 +382,7 @@ function topbar( | ||
| 353 | 382 | <a href="/" class="wordmark" data-shortcut="g h" |
| 354 | 383 | ><span class="mark"></span>forge</a |
| 355 | 384 | > |
| 385 | ${asksBadge(ctx)} | |
| 356 | 386 | ${when( |
| 357 | 387 | crumbs, |
| 358 | 388 | (items) => |
src/web/studio/actions.ts +1 −2
| @@ -23,8 +23,7 @@ import { | ||
| 23 | 23 | import { field, readForm } from "../form.ts"; |
| 24 | 24 | import { flashOrJson, flashTo, redirect } from "../layout.ts"; |
| 25 | 25 | import { isMode, modeCookie } from "../mode.ts"; |
| 26 | import { BULK, decisionsFor, isBulkAction, takes } from "./asks.ts"; | |
| 27 | import { appsOf } from "./home.ts"; | |
| 26 | import { appsOf, BULK, decisionsFor, isBulkAction, takes } from "./asks.ts"; | |
| 28 | 27 | |
| 29 | 28 | /** A merge refusal in a person's words. */ |
| 30 | 29 | function mergeWords(error: unknown): string { |
src/web/studio/asks.ts +131 −52
| @@ -8,6 +8,7 @@ | ||
| 8 | 8 | */ |
| 9 | 9 | |
| 10 | 10 | import { previewEnv } from "../../apps/data.ts"; |
| 11 | import { crewForRepos } from "../../core/agents.ts"; | |
| 11 | 12 | import { appsForRepos, getAppByRepo } from "../../core/apps.ts"; |
| 12 | 13 | import { latestDecision } from "../../core/decisions.ts"; |
| 13 | 14 | import { |
| @@ -17,14 +18,15 @@ import { | ||
| 17 | 18 | } from "../../core/issues.ts"; |
| 18 | 19 | import { looksForApps } from "../../core/looks.ts"; |
| 19 | 20 | import { joinMany, type Pull } from "../../core/pulls.ts"; |
| 20 | import { canSeeOwnerFacts, type Repo } from "../../core/repos.ts"; | |
| 21 | import { canSeeOwnerFacts, listReposVisibleTo, type Repo } from "../../core/repos.ts"; | |
| 21 | 22 | import { listReviews } from "../../core/reviews.ts"; |
| 22 | 23 | import { |
| 23 | 24 | activeRunFor, |
| 24 | 25 | automaticRunsSince, |
| 25 | 26 | latestRunsForIssues, |
| 26 | 27 | } from "../../core/runs.ts"; |
| 27 | import { autonomyStatus } from "../../crew/autonomy.ts"; | |
| 28 | import type { User } from "../../core/users.ts"; | |
| 29 | import { spentTodayForRepos } from "../../crew/autonomy.ts"; | |
| 28 | 30 | import { CHECK_FAILED_LIMIT } from "../../crew/dispatch.ts"; |
| 29 | 31 | import type { Ctx } from "../../http/context.ts"; |
| 30 | 32 | import { repoBase } from "../chrome.ts"; |
| @@ -33,6 +35,16 @@ import { btn, card, icon, row } from "../ui.ts"; | ||
| 33 | 35 | import { lookThumb, lookUrl } from "../pages/looks.ts"; |
| 34 | 36 | import { leanNote, leanVariant } from "./leans.ts"; |
| 35 | 37 | |
| 38 | /** The apps a person can see, as repositories, with their records. */ | |
| 39 | export function appsOf(ctx: Ctx, user: User) { | |
| 40 | const repos = listReposVisibleTo(ctx.app.db, user, { limit: 200 }); | |
| 41 | const records = appsForRepos( | |
| 42 | ctx.app.db, | |
| 43 | repos.map((r) => r.id), | |
| 44 | ); | |
| 45 | return { repos: repos.filter((r) => records.has(r.id)), records }; | |
| 46 | } | |
| 47 | ||
| 36 | 48 | |
| 37 | 49 | export type DecisionKind = "stopped" | "look" | "paused"; |
| 38 | 50 | |
| @@ -69,14 +81,37 @@ function lastHumanWord(ctx: Ctx, issue: Issue, agentName: string): string { | ||
| 69 | 81 | return latest; |
| 70 | 82 | } |
| 71 | 83 | |
| 84 | /** | |
| 85 | * A candidate ask, classified but not yet worded or pictured: the set | |
| 86 | * `decisionsFor` and `askCountFor` both build from, so a count can never | |
| 87 | * disagree with the list it is a count of. | |
| 88 | */ | |
| 89 | interface AskCandidate { | |
| 90 | readonly kind: DecisionKind; | |
| 91 | readonly repo: Repo; | |
| 92 | readonly pull?: Pull; | |
| 93 | readonly who: string; | |
| 94 | /** For "stopped": how many automatic tries, and whether the decider itself stopped it. */ | |
| 95 | readonly tries?: number; | |
| 96 | readonly stoppedByDecider?: boolean; | |
| 97 | } | |
| 98 | ||
| 72 | 99 | /** |
| 73 | 100 | * The crew's open pulls the viewer may land, sorted into what asks them: |
| 74 | 101 | * stopped (red past the crew's own limit since a person last spoke), or |
| 75 | 102 | * green and waiting for a look. Plus a repository paused at its ceiling. |
| 103 | * Every read here is bounded by the pulls a person can see or the | |
| 104 | * repositories handed in — never by a loop that queries once per app: the | |
| 105 | * ceiling check reads spend and crew for every repository in one query | |
| 106 | * each (`spentTodayForRepos`, `crewForRepos`), not `autonomyStatus` per | |
| 107 | * repository. | |
| 76 | 108 | */ |
| 77 | export function decisionsFor(ctx: Ctx, allRepos: readonly Repo[]): Decision[] { | |
| 109 | function askCandidatesFor( | |
| 110 | ctx: Ctx, | |
| 111 | allRepos: readonly Repo[], | |
| 112 | ): AskCandidate[] { | |
| 78 | 113 | const db = ctx.app.db; |
| 79 | const out: Decision[] = []; | |
| 114 | const out: AskCandidate[] = []; | |
| 80 | 115 | const user = ctx.auth.user; |
| 81 | 116 | if (!user) return out; |
| 82 | 117 | // Shipping, closing and the day's ceiling are the owner's own decisions — |
| @@ -92,25 +127,6 @@ export function decisionsFor(ctx: Ctx, allRepos: readonly Repo[]): Decision[] { | ||
| 92 | 127 | pulls.map((p) => p.id), |
| 93 | 128 | ); |
| 94 | 129 | const joined = new Map(joinMany(db, pulls).map((p) => [p.id, p])); |
| 95 | // The pictures, in one read: a green pull's row leads with its preview. | |
| 96 | const records = appsForRepos(db, [...byId.keys()]); | |
| 97 | const looks = looksForApps( | |
| 98 | db, | |
| 99 | [...records.values()].map((r) => r.id), | |
| 100 | ); | |
| 101 | const pictureOf = (repo: Repo, pull: Pull): string | undefined => { | |
| 102 | const record = records.get(repo.id); | |
| 103 | if (record === undefined) return undefined; | |
| 104 | const env = previewEnv(pull.number); | |
| 105 | const look = looks.find( | |
| 106 | (l) => | |
| 107 | l.appId === record.id && | |
| 108 | l.env === env && | |
| 109 | l.kind === "phone" && | |
| 110 | l.error === null, | |
| 111 | ); | |
| 112 | return look && lookUrl(repoBase(repo), env, look.commitOid, "phone"); | |
| 113 | }; | |
| 114 | 130 | for (const issue of pulls) { |
| 115 | 131 | const repo = byId.get(issue.repoId)!; |
| 116 | 132 | const pull = joined.get(issue.id); |
| @@ -131,48 +147,111 @@ export function decisionsFor(ctx: Ctx, allRepos: readonly Repo[]): Decision[] { | ||
| 131 | 147 | ) |
| 132 | 148 | out.push({ |
| 133 | 149 | kind: "stopped", |
| 134 | id: `stopped:${pull.id}`, | |
| 135 | 150 | repo, |
| 136 | 151 | pull, |
| 137 | 152 | who, |
| 138 | says: stopped | |
| 139 | ? `${who}'s check failed and the crew's decider stopped rather than spend another run. Say what to try, or close it.` | |
| 140 | : `${who} tried ${tries + 1} times and the check still fails. The crew stopped here. Say what to try, or close it.`, | |
| 141 | short: stopped | |
| 142 | ? `the check failed; the decider stopped for you` | |
| 143 | : `${who} tried ${tries + 1} times; the check still fails`, | |
| 153 | tries, | |
| 154 | stoppedByDecider: stopped, | |
| 144 | 155 | }); |
| 145 | 156 | } else if (check?.state === "ok" && repo.autonomy !== "autonomous") { |
| 146 | 157 | // Autonomous repositories look at their own green pulls: the decider, |
| 147 | 158 | // then the crew's reviewer. Everything else waits for a person. |
| 148 | const picture = pictureOf(repo, pull); | |
| 149 | out.push({ | |
| 150 | kind: "look", | |
| 151 | id: `look:${pull.id}`, | |
| 152 | repo, | |
| 153 | pull, | |
| 154 | who, | |
| 155 | says: `${who} finished "${pull.title}" and the check is green. Look at the preview, then ship it or say what is off.`, | |
| 156 | short: `${who} finished it; the check is green`, | |
| 157 | ...(picture === undefined ? {} : { picture }), | |
| 158 | }); | |
| 159 | out.push({ kind: "look", repo, pull, who }); | |
| 159 | 160 | } |
| 160 | 161 | } |
| 162 | const crew = crewForRepos( | |
| 163 | db, | |
| 164 | repos.map((r) => r.id), | |
| 165 | ); | |
| 166 | const spent = spentTodayForRepos( | |
| 167 | db, | |
| 168 | repos.map((r) => r.id), | |
| 169 | ); | |
| 161 | 170 | for (const repo of repos) { |
| 162 | if (repo.autonomy === "off") continue; | |
| 163 | const s = autonomyStatus(ctx.app, repo); | |
| 164 | if (s.paused === "ceiling") | |
| 165 | out.push({ | |
| 166 | kind: "paused", | |
| 167 | id: `paused:${repo.id}`, | |
| 168 | repo, | |
| 169 | who: "the crew", | |
| 170 | says: `${repo.name} reached its $${dollars(s.ceilingUsd)} for today. The crew picks nothing more until 00:00 UTC. Raise the ceiling in settings to go on now.`, | |
| 171 | short: `at its $${dollars(s.ceilingUsd)} ceiling until 00:00 UTC`, | |
| 172 | }); | |
| 171 | // Only a ceiling pause is an ask — "nobody" and "no crew" are settings | |
| 172 | // problems, not a decision, so neither needs its own grouped read. | |
| 173 | if (repo.autonomy === "off" || repo.autonomyBy === null) continue; | |
| 174 | if ((crew.get(repo.id)?.length ?? 0) === 0) continue; | |
| 175 | if ((spent.get(repo.id) ?? 0) >= repo.autonomyCeilingUsd) | |
| 176 | out.push({ kind: "paused", repo, who: "the crew" }); | |
| 173 | 177 | } |
| 174 | 178 | return out; |
| 175 | 179 | } |
| 180 | ||
| 181 | /** | |
| 182 | * What asks the viewer, across every app they can land — the count the | |
| 183 | * topbar carries. The same candidates `decisionsFor` builds its cards | |
| 184 | * from, with no picture read and no text built: cheap on every page, not | |
| 185 | * only the home's. | |
| 186 | */ | |
| 187 | export function askCountFor(ctx: Ctx, allRepos: readonly Repo[]): number { | |
| 188 | return askCandidatesFor(ctx, allRepos).length; | |
| 189 | } | |
| 190 | ||
| 191 | export function decisionsFor(ctx: Ctx, allRepos: readonly Repo[]): Decision[] { | |
| 192 | const db = ctx.app.db; | |
| 193 | const candidates = askCandidatesFor(ctx, allRepos); | |
| 194 | // The pictures, in one read: a green pull's row leads with its preview. | |
| 195 | const repoIds = [...new Set(candidates.map((c) => c.repo.id))]; | |
| 196 | const records = appsForRepos(db, repoIds); | |
| 197 | const looks = looksForApps( | |
| 198 | db, | |
| 199 | [...records.values()].map((r) => r.id), | |
| 200 | ); | |
| 201 | const pictureOf = (repo: Repo, pull: Pull): string | undefined => { | |
| 202 | const record = records.get(repo.id); | |
| 203 | if (record === undefined) return undefined; | |
| 204 | const env = previewEnv(pull.number); | |
| 205 | const look = looks.find( | |
| 206 | (l) => | |
| 207 | l.appId === record.id && | |
| 208 | l.env === env && | |
| 209 | l.kind === "phone" && | |
| 210 | l.error === null, | |
| 211 | ); | |
| 212 | return look && lookUrl(repoBase(repo), env, look.commitOid, "phone"); | |
| 213 | }; | |
| 214 | return candidates.map((c): Decision => { | |
| 215 | if (c.kind === "stopped" && c.pull) { | |
| 216 | const tries = c.tries ?? 0; | |
| 217 | const stopped = c.stoppedByDecider ?? false; | |
| 218 | return { | |
| 219 | kind: "stopped", | |
| 220 | id: `stopped:${c.pull.id}`, | |
| 221 | repo: c.repo, | |
| 222 | pull: c.pull, | |
| 223 | who: c.who, | |
| 224 | says: stopped | |
| 225 | ? `${c.who}'s check failed and the crew's decider stopped rather than spend another run. Say what to try, or close it.` | |
| 226 | : `${c.who} tried ${tries + 1} times and the check still fails. The crew stopped here. Say what to try, or close it.`, | |
| 227 | short: stopped | |
| 228 | ? `the check failed; the decider stopped for you` | |
| 229 | : `${c.who} tried ${tries + 1} times; the check still fails`, | |
| 230 | }; | |
| 231 | } | |
| 232 | if (c.kind === "look" && c.pull) { | |
| 233 | const picture = pictureOf(c.repo, c.pull); | |
| 234 | return { | |
| 235 | kind: "look", | |
| 236 | id: `look:${c.pull.id}`, | |
| 237 | repo: c.repo, | |
| 238 | pull: c.pull, | |
| 239 | who: c.who, | |
| 240 | says: `${c.who} finished "${c.pull.title}" and the check is green. Look at the preview, then ship it or say what is off.`, | |
| 241 | short: `${c.who} finished it; the check is green`, | |
| 242 | ...(picture === undefined ? {} : { picture }), | |
| 243 | }; | |
| 244 | } | |
| 245 | return { | |
| 246 | kind: "paused", | |
| 247 | id: `paused:${c.repo.id}`, | |
| 248 | repo: c.repo, | |
| 249 | who: "the crew", | |
| 250 | says: `${c.repo.name} reached its $${dollars(c.repo.autonomyCeilingUsd)} for today. The crew picks nothing more until 00:00 UTC. Raise the ceiling in settings to go on now.`, | |
| 251 | short: `at its $${dollars(c.repo.autonomyCeilingUsd)} ceiling until 00:00 UTC`, | |
| 252 | }; | |
| 253 | }); | |
| 254 | } | |
| 176 | 255 | // ---- deciding several at once ---- |
| 177 | 256 | |
| 178 | 257 | /** |
src/web/studio/home.ts +2 −13
| @@ -8,8 +8,7 @@ | ||
| 8 | 8 | */ |
| 9 | 9 | |
| 10 | 10 | import { appsRuntime } from "../../apps/runtime.ts"; |
| 11 | import { appsForRepos } from "../../core/apps.ts"; | |
| 12 | import { canSeeOwnerFacts, listReposVisibleTo } from "../../core/repos.ts"; | |
| 11 | import { canSeeOwnerFacts } from "../../core/repos.ts"; | |
| 13 | 12 | import { latestAgentRunsForRepos, listActiveRuns } from "../../core/runs.ts"; |
| 14 | 13 | import { getUsersByIds, type User } from "../../core/users.ts"; |
| 15 | 14 | import { repoDir } from "../../git/repos.ts"; |
| @@ -32,7 +31,7 @@ import { | ||
| 32 | 31 | type Tone, |
| 33 | 32 | } from "../ui.ts"; |
| 34 | 33 | import { onYouCard } from "../pages/site.ts"; |
| 35 | import { asksPanel, decisionsFor } from "./asks.ts"; | |
| 34 | import { appsOf, asksPanel, decisionsFor } from "./asks.ts"; | |
| 36 | 35 | import { withLikely } from "./leans.ts"; |
| 37 | 36 | import { stopForm } from "./run.ts"; |
| 38 | 37 | import { studioCss } from "./style.ts"; |
| @@ -235,13 +234,3 @@ export async function studioHome(ctx: Ctx, user: User): Promise<Response> { | ||
| 235 | 234 | scriptJs: `${liveClientJs}\n${liveRegionsJs}\n${asksJs}`, |
| 236 | 235 | }); |
| 237 | 236 | } |
| 238 | ||
| 239 | /** The apps a person can see, as repositories, with their records. */ | |
| 240 | export function appsOf(ctx: Ctx, user: User) { | |
| 241 | const repos = listReposVisibleTo(ctx.app.db, user, { limit: 200 }); | |
| 242 | const records = appsForRepos( | |
| 243 | ctx.app.db, | |
| 244 | repos.map((r) => r.id), | |
| 245 | ); | |
| 246 | return { repos: repos.filter((r) => records.has(r.id)), records }; | |
| 247 | } |
src/web/style/base.ts +2
| @@ -103,6 +103,8 @@ h3 { font-size: 14px; font-weight: 600; line-height: 1.4; } | ||
| 103 | 103 | } |
| 104 | 104 | .wordmark:hover { color: var(--fg); } |
| 105 | 105 | .wordmark:hover .mark { transform: rotate(135deg); } |
| 106 | .topbar-asks { display: inline-flex; flex: none; text-decoration: none; } | |
| 107 | .topbar-asks:hover .pill { background: color-mix(in srgb, var(--accent) 24%, transparent); } | |
| 106 | 108 | .topbar .spacer { flex: 1; } |
| 107 | 109 | .topbar-tools { display: flex; align-items: center; gap: 6px; flex: none; } |
| 108 | 110 |
test/studio.test.ts +199
| @@ -1274,6 +1274,205 @@ describe("the studio home keeps itself current", () => { | ||
| 1274 | 1274 | }); |
| 1275 | 1275 | }); |
| 1276 | 1276 | |
| 1277 | /** | |
| 1278 | * The top bar's own count of what asks the signed-in person — carried on | |
| 1279 | * every studio page, from `askCountFor` (`src/web/studio/asks.ts`), the | |
| 1280 | * cheap query beside `decisionsFor` the home itself uses. | |
| 1281 | */ | |
| 1282 | describe("the top bar carries the count of what asks you", () => { | |
| 1283 | let dataDir: string; | |
| 1284 | let app: App; | |
| 1285 | let servers: Servers; | |
| 1286 | let base: string; | |
| 1287 | let alice: User; | |
| 1288 | let repo: Repo; | |
| 1289 | let aliceCookie: string; | |
| 1290 | let bobCookie: string; | |
| 1291 | let carolCookie: string; | |
| 1292 | ||
| 1293 | const signIn = async (username: string): Promise<string> => | |
| 1294 | ( | |
| 1295 | ( | |
| 1296 | await fetch(`${base}/signin`, { | |
| 1297 | method: "POST", | |
| 1298 | body: new URLSearchParams({ | |
| 1299 | username, | |
| 1300 | password: "a-good-passphrase", | |
| 1301 | }), | |
| 1302 | redirect: "manual", | |
| 1303 | }) | |
| 1304 | ).headers.get("set-cookie") ?? "" | |
| 1305 | ).split(";")[0]!; | |
| 1306 | ||
| 1307 | const home = (cookie: string, mode: "studio" | "dev" = "studio") => | |
| 1308 | fetch(`${base}/`, { | |
| 1309 | headers: { cookie: `${cookie}; forge_mode=${mode}` }, | |
| 1310 | redirect: "manual", | |
| 1311 | }).then((r) => r.text()); | |
| 1312 | ||
| 1313 | const askPull = async ( | |
| 1314 | owner: User, | |
| 1315 | who: User, | |
| 1316 | repository: Repo, | |
| 1317 | name: string, | |
| 1318 | outcome: "ok" | "failed", | |
| 1319 | ): Promise<Pull> => { | |
| 1320 | const branch = await commitToBranch(app, owner, repository, { | |
| 1321 | branch: `ask/${name}`, | |
| 1322 | base: "main", | |
| 1323 | message: `Ask ${name}`, | |
| 1324 | files: [{ path: `${name}.md`, content: `${name}\n` }], | |
| 1325 | }); | |
| 1326 | if (!branch.ok) throw new Error(JSON.stringify(branch.error)); | |
| 1327 | const opened = await openPull(app, who, repository, { | |
| 1328 | title: `Ask ${name}`, | |
| 1329 | body: "", | |
| 1330 | baseBranch: "main", | |
| 1331 | headBranch: `ask/${name}`, | |
| 1332 | }); | |
| 1333 | if (!opened.ok) throw new Error(String(opened.error)); | |
| 1334 | const pull = opened.value; | |
| 1335 | const run = createRun(app.db, { | |
| 1336 | repoId: repository.id, | |
| 1337 | kind: "check", | |
| 1338 | issueId: pull.id, | |
| 1339 | env: `pull-${pull.number}`, | |
| 1340 | commitOid: pull.headOid, | |
| 1341 | reason: "pull", | |
| 1342 | }); | |
| 1343 | claimRun(app.db, run.id); | |
| 1344 | finishRun(app.db, run.id, { state: outcome, summary: `check ${outcome}` }); | |
| 1345 | if (outcome === "failed") { | |
| 1346 | await Bun.sleep(5); | |
| 1347 | for (let i = 0; i < CHECK_FAILED_LIMIT; i++) { | |
| 1348 | const tried = createRun(app.db, { | |
| 1349 | repoId: repository.id, | |
| 1350 | kind: "agent", | |
| 1351 | issueId: pull.id, | |
| 1352 | reason: "check_failed", | |
| 1353 | actorId: who.id, | |
| 1354 | }); | |
| 1355 | claimRun(app.db, tried.id); | |
| 1356 | finishRun(app.db, tried.id, { state: "failed", summary: "still red" }); | |
| 1357 | } | |
| 1358 | } | |
| 1359 | return pull; | |
| 1360 | }; | |
| 1361 | ||
| 1362 | beforeAll(async () => { | |
| 1363 | dataDir = await mkdtemp(join(tmpdir(), "forge-studio-topbar-asks-")); | |
| 1364 | app = createApp({ dataDir, port: 0 }); | |
| 1365 | servers = await serve(app); | |
| 1366 | base = servers.url; | |
| 1367 | const a = await createUser(app.db, { | |
| 1368 | username: "alice", | |
| 1369 | email: "[email protected]", | |
| 1370 | password: "a-good-passphrase", | |
| 1371 | adminIfFirst: true, | |
| 1372 | }); | |
| 1373 | if (!a.ok) throw new Error(a.error); | |
| 1374 | alice = a.value; | |
| 1375 | const b = await createUser(app.db, { | |
| 1376 | username: "bob", | |
| 1377 | email: "[email protected]", | |
| 1378 | password: "a-good-passphrase", | |
| 1379 | }); | |
| 1380 | if (!b.ok) throw new Error(b.error); | |
| 1381 | const bob = b.value; | |
| 1382 | const c = await createUser(app.db, { | |
| 1383 | username: "carol", | |
| 1384 | email: "[email protected]", | |
| 1385 | password: "a-good-passphrase", | |
| 1386 | }); | |
| 1387 | if (!c.ok) throw new Error(c.error); | |
| 1388 | aliceCookie = await signIn("alice"); | |
| 1389 | bobCookie = await signIn("bob"); | |
| 1390 | carolCookie = await signIn("carol"); | |
| 1391 | ||
| 1392 | const made = await createRepo(app, alice, { | |
| 1393 | name: "signals", | |
| 1394 | visibility: "private", | |
| 1395 | description: "", | |
| 1396 | }); | |
| 1397 | if (!made.ok) throw new Error(String(made.error)); | |
| 1398 | repo = made.value; | |
| 1399 | const seeded = await commitToBranch(app, alice, repo, { | |
| 1400 | message: "Start", | |
| 1401 | files: [{ path: "README.md", content: "s\n" }], | |
| 1402 | }); | |
| 1403 | if (!seeded.ok) throw new Error(JSON.stringify(seeded.error)); | |
| 1404 | const enabled = await enableApp(app, alice, repo, { slug: "signals" }); | |
| 1405 | if (!enabled.ok) throw new Error(String(enabled.error)); | |
| 1406 | const scout = await createAgent(app, alice, { username: "scout" }); | |
| 1407 | if (!scout.ok) throw new Error(String(scout.error)); | |
| 1408 | expect(addToCrew(app, alice, repo, "scout").ok).toBe(true); | |
| 1409 | const scoutUser = getUserByName(app.db, "scout")!; | |
| 1410 | ||
| 1411 | // Bob's own app: two asks of his — a green pull waiting for a look, | |
| 1412 | // and one stopped past the crew's own limit. | |
| 1413 | const otherMade = await createRepo(app, bob, { | |
| 1414 | name: "widget", | |
| 1415 | visibility: "public", | |
| 1416 | description: "", | |
| 1417 | }); | |
| 1418 | if (!otherMade.ok) throw new Error(String(otherMade.error)); | |
| 1419 | const other = otherMade.value; | |
| 1420 | const otherSeeded = await commitToBranch(app, bob, other, { | |
| 1421 | message: "Start", | |
| 1422 | files: [{ path: "README.md", content: "w\n" }], | |
| 1423 | }); | |
| 1424 | if (!otherSeeded.ok) throw new Error(JSON.stringify(otherSeeded.error)); | |
| 1425 | const otherEnabled = await enableApp(app, bob, other, { slug: "widget" }); | |
| 1426 | if (!otherEnabled.ok) throw new Error(String(otherEnabled.error)); | |
| 1427 | const otherAgent = await createAgent(app, alice, { username: "helper" }); | |
| 1428 | if (!otherAgent.ok) throw new Error(String(otherAgent.error)); | |
| 1429 | expect(addToCrew(app, bob, other, "helper").ok).toBe(true); | |
| 1430 | const helperUser = getUserByName(app.db, "helper")!; | |
| 1431 | await askPull(bob, helperUser, other, "widget-look", "ok"); | |
| 1432 | await askPull(bob, helperUser, other, "widget-stopped", "failed"); | |
| 1433 | ||
| 1434 | // A private app of alice's own, with a real ask on it too — never | |
| 1435 | // bob's to see, let alone count: it is private, and he is on no crew. | |
| 1436 | await askPull(alice, scoutUser, repo, "signals-look", "ok"); | |
| 1437 | }); | |
| 1438 | ||
| 1439 | afterAll(async () => { | |
| 1440 | servers.stop(); | |
| 1441 | app.close(); | |
| 1442 | await rm(dataDir, { recursive: true, force: true }); | |
| 1443 | }); | |
| 1444 | ||
| 1445 | test("in studio, the bar carries the count for a person with two asks, linking to the home, and it equals the home's own list length", async () => { | |
| 1446 | const page = await home(bobCookie); | |
| 1447 | const picks = page.match(/class="studio-ask-pick"/g)?.length ?? 0; | |
| 1448 | expect(picks).toBe(2); | |
| 1449 | expect(page).toContain('id="topbar-asks"'); | |
| 1450 | expect(page).toMatch( | |
| 1451 | /class="topbar-asks"[\s\S]{0,80}href="\/"[\s\S]{0,120}data-tone="accent"[\s\S]{0,20}>2 asks you</, | |
| 1452 | ); | |
| 1453 | }); | |
| 1454 | ||
| 1455 | test("the bar never counts an app a person cannot land: alice's own ask on her private app never reaches bob's count", async () => { | |
| 1456 | // Alice really does have an ask of her own; bob's stays at his own two. | |
| 1457 | const alicePage = await home(aliceCookie); | |
| 1458 | expect(alicePage).toMatch(/1 asks you|2 asks you/); | |
| 1459 | const bobPage = await home(bobCookie); | |
| 1460 | expect(bobPage).toContain("2 asks you"); | |
| 1461 | expect(bobPage).not.toContain("signals"); | |
| 1462 | }); | |
| 1463 | ||
| 1464 | test("carries nothing for a person with none: the bar shows nothing, not a zero", async () => { | |
| 1465 | const page = await home(carolCookie); | |
| 1466 | expect(page).not.toContain('class="topbar-asks"'); | |
| 1467 | expect(page).toMatch(/<span id="topbar-asks" data-live="[^"]*"\s*>\s*<\/span/); | |
| 1468 | }); | |
| 1469 | ||
| 1470 | test("dev mode's top bar is unchanged: no count, at all, even for a person with asks", async () => { | |
| 1471 | const page = await home(bobCookie, "dev"); | |
| 1472 | expect(page).not.toContain("topbar-asks"); | |
| 1473 | }); | |
| 1474 | }); | |
| 1475 | ||
| 1277 | 1476 | /** |
| 1278 | 1477 | * The studio head of an app that is not up: the same facts the App page |
| 1279 | 1478 | * shows — why, the deploy that says the rest, and the way to try again. |
new above
offline