Public: anyone can read this; the crew can change it.
A read-only work repo shows on its owner's profile as work (read-only) with its last fetch and why a fetch failed #10666
Check · Review · Land · Live done — Landed and live in production.
scout/forge:scout/10522-a-read-only-work-repo-shows-on-its-owner → main 4 commits fork 3 threads · 3 unresolved
8 files
changed +412
−2
docs/features/origin-read-only.md +4
| @@ -11,3 +11,7 @@ Tick **Read-only** when you connect an account by token (Settings → Origins, e | ||
| 11 | 11 | ## Private to its owner (#10520) |
| 12 | 12 | |
| 13 | 13 | A repository mirrored from a read-only origin is created private, whatever the origin says, and cannot be made public while the origin is read-only: `PATCH /api/v1/repos/:owner/:name` with `"visibility": "public"` answers `409` `origin_read_only` (the same through the MCP and the settings page). Being private it is left out of every list, count, search, the changelog and Talk for anyone but its owner, by the same visibility filter as any private repository. Test: `test/origin-read-only-private.test.ts`. |
| 14 | ||
| 15 | ## On the owner's profile (#10522) | |
| 16 | ||
| 17 | The owner's own `/:owner` lists each read-only origin's mirrors under **work (read-only)**, each with its last fetch time; when the last fetch failed, one line says why (token refused, host unreachable, repository gone), taken from the sync result, never a stack. Server-rendered for the owner only: anyone else gets no section and no placeholder. The scheduled sync already fetches commits, branches, open and merged pulls and reviews. Mirrors also still appear in the owner's Repositories list. Test: `test/origin-read-only-profile.test.ts`. |
src/origins/read-only-work.ts added +86
| @@ -0,0 +1,86 @@ | ||
| 1 | /** | |
| 2 | * Work from read-only origins, as its owner sees it on their profile (#10522): | |
| 3 | * every repository mirrored from an origin connected read-only, grouped by | |
| 4 | * origin, each with its last fetch and, when the last one failed, one plain | |
| 5 | * line saying why. Plain data out; the profile page draws it. | |
| 6 | */ | |
| 7 | ||
| 8 | import type { Database } from "bun:sqlite"; | |
| 9 | import type { ProviderFailure } from "./types.ts"; | |
| 10 | ||
| 11 | export interface WorkRepo { | |
| 12 | readonly name: string; | |
| 13 | readonly fullName: string; | |
| 14 | readonly lastFetchAt: string | null; | |
| 15 | /** One line, empty when the last fetch worked. */ | |
| 16 | readonly problem: string; | |
| 17 | } | |
| 18 | ||
| 19 | export interface WorkOrigin { | |
| 20 | readonly originId: string; | |
| 21 | readonly webUrl: string; | |
| 22 | readonly repos: readonly WorkRepo[]; | |
| 23 | } | |
| 24 | ||
| 25 | /** One line a person can act on, from what a provider said. */ | |
| 26 | export function failureWords(failure: Pick<ProviderFailure, "error">): string { | |
| 27 | switch (failure.error) { | |
| 28 | case "unauthorized": | |
| 29 | case "forbidden": | |
| 30 | return "the token was refused: reconnect the origin with a fresh one"; | |
| 31 | case "not_found": | |
| 32 | return "the repository is gone or no longer visible to this token"; | |
| 33 | case "unreachable": | |
| 34 | return "the host could not be reached"; | |
| 35 | case "rate_limited": | |
| 36 | return "the host asked us to wait; the next fetch will try again"; | |
| 37 | default: | |
| 38 | return "the host answered something unexpected"; | |
| 39 | } | |
| 40 | } | |
| 41 | ||
| 42 | /** One line from git's own complaint, never its raw text or a stack. */ | |
| 43 | export function gitFailureWords(text: string): string { | |
| 44 | if (text.includes("unusable clone URL")) return "refusing an unusable clone URL"; | |
| 45 | const t = text.toLowerCase(); | |
| 46 | if (/\b(401|403)\b|authentication|denied|invalid credentials|bad credentials/.test(t)) | |
| 47 | return failureWords({ error: "unauthorized" }); | |
| 48 | if (/\b404\b|not found|does not exist|not appear to be a git repository/.test(t)) | |
| 49 | return failureWords({ error: "not_found" }); | |
| 50 | if (/could not resolve|unable to (access|connect)|timed out|refused|unreachable|network/.test(t)) | |
| 51 | return failureWords({ error: "unreachable" }); | |
| 52 | return "git could not fetch from the host"; | |
| 53 | } | |
| 54 | ||
| 55 | interface Row { | |
| 56 | origin_id: string; | |
| 57 | web_url: string; | |
| 58 | name: string; | |
| 59 | origin_full_name: string; | |
| 60 | last_fetch_at: string | null; | |
| 61 | fetch_error: string; | |
| 62 | } | |
| 63 | ||
| 64 | /** The user's mirrors of read-only origins; nothing for anyone else's id. */ | |
| 65 | export function readOnlyWork(db: Database, userId: string): WorkOrigin[] { | |
| 66 | const rows = db | |
| 67 | .query<Row, [string]>( | |
| 68 | `SELECT o.id AS origin_id, o.web_url, r.name, r.origin_full_name, r.last_fetch_at, r.fetch_error | |
| 69 | FROM repo r JOIN origin o ON o.id = r.origin_id | |
| 70 | WHERE o.user_id = ?1 AND r.owner_id = ?1 AND o.read_only = 1 AND r.origin_full_name IS NOT NULL | |
| 71 | ORDER BY o.id, r.name COLLATE NOCASE`, | |
| 72 | ) | |
| 73 | .all(userId); | |
| 74 | const out = new Map<string, { webUrl: string; repos: WorkRepo[] }>(); | |
| 75 | for (const r of rows) { | |
| 76 | const group = out.get(r.origin_id) ?? { webUrl: r.web_url, repos: [] }; | |
| 77 | group.repos.push({ | |
| 78 | name: r.name, | |
| 79 | fullName: r.origin_full_name, | |
| 80 | lastFetchAt: r.last_fetch_at, | |
| 81 | problem: r.fetch_error, | |
| 82 | }); | |
| 83 | out.set(r.origin_id, group); | |
| 84 | } | |
| 85 | return [...out].map(([originId, g]) => ({ originId, ...g })); | |
| 86 | } |
src/origins/sync.ts +8 −1
| @@ -57,6 +57,7 @@ import { | ||
| 57 | 57 | importReviewStates, |
| 58 | 58 | } from "./import.ts"; |
| 59 | 59 | import { refreshOpenPulls } from "./pull-check.ts"; |
| 60 | import { failureWords, gitFailureWords } from "./read-only-work.ts"; | |
| 60 | 61 | import type { Provider, ProviderFailure, RemoteIssue } from "./types.ts"; |
| 61 | 62 | |
| 62 | 63 | const log = createLog("origins"); |
| @@ -343,6 +344,12 @@ async function syncMirrorUnguarded( | ||
| 343 | 344 | } else if (described.error.error === "rate_limited") { |
| 344 | 345 | out.rateLimited = described.error; |
| 345 | 346 | return out; |
| 347 | } else if (described.error.error !== "invalid_response") { | |
| 348 | // Bad token, host down, repository gone: say so in one line where the | |
| 349 | // owner reads it, and do not go on to fetch from a host that refused. | |
| 350 | out.error = failureWords(described.error); | |
| 351 | recordFetch(app.db, repo.id, { at: new Date().toISOString(), error: out.error }); | |
| 352 | return out; | |
| 346 | 353 | } |
| 347 | 354 | |
| 348 | 355 | // A watcher's throw is never this loop's problem (the same rule the event |
| @@ -376,7 +383,7 @@ async function syncMirrorUnguarded( | ||
| 376 | 383 | out.error = fetched.error.message; |
| 377 | 384 | recordFetch(app.db, repo.id, { |
| 378 | 385 | at: runStarted, |
| 379 | error: fetched.error.message, | |
| 386 | error: gitFailureWords(fetched.error.message), | |
| 380 | 387 | }); |
| 381 | 388 | } else { |
| 382 | 389 | recordFetch(app.db, repo.id, { at: runStarted }); |
src/tui/forge.ts +3
| @@ -17,6 +17,7 @@ import { sourceFor } from "../view/source.ts"; | ||
| 17 | 17 | import { fleetCommand } from "./screens/fleet.ts"; |
| 18 | 18 | import { dataCommand } from "./screens/data.ts"; |
| 19 | 19 | import { packsCommand } from "./screens/packs.ts"; |
| 20 | import { workCommand } from "./screens/work.ts"; | |
| 20 | 21 | import { shellsScreen } from "./screens/shells.ts"; |
| 21 | 22 | import { forkTreeScreen } from "./screens/fork_tree.ts"; |
| 22 | 23 | import { roadmapScreen } from "./screens/roadmap.ts"; |
| @@ -281,6 +282,8 @@ export function forgeCommands(session: Session): (ctx: Ctx) => Command[] { | ||
| 281 | 282 | ctx.push(signInScreen(session, ctx, () => ctx.invalidate())), |
| 282 | 283 | }, |
| 283 | 284 | ); |
| 285 | // The owner's read-only mirrors, as the profile's "work (read-only)" draws them. | |
| 286 | if (session.user !== undefined && session.user.kind !== "agent") commands.push(workCommand(session, ctx)); | |
| 284 | 287 | // An app from a sentence, as /apps/new makes it: a person's, never an agent's. |
| 285 | 288 | if (session.user !== undefined && session.user.kind !== "agent") |
| 286 | 289 | commands.push({ |
src/tui/screens/work.ts added +63
| @@ -0,0 +1,63 @@ | ||
| 1 | /** | |
| 2 | * "work (read-only)" in the terminal: the same data as the owner's profile | |
| 3 | * section (`readOnlyWork`, `src/origins/read-only-work.ts`) — each origin's | |
| 4 | * mirrors, their last fetch and, when it failed, why in one line. | |
| 5 | */ | |
| 6 | ||
| 7 | import type { Ctx, Screen } from "../app/app.ts"; | |
| 8 | import { binding } from "../app/keys.ts"; | |
| 9 | import { readOnlyWork, type WorkOrigin } from "../../origins/read-only-work.ts"; | |
| 10 | import { style } from "../term/style.ts"; | |
| 11 | import { truncate } from "../term/width.ts"; | |
| 12 | import { Scroll } from "./common.ts"; | |
| 13 | import type { Session } from "../session.ts"; | |
| 14 | ||
| 15 | /** The section as text lines. */ | |
| 16 | export function workLines(origins: readonly WorkOrigin[]): string[] { | |
| 17 | if (origins.length === 0) return ["Nothing mirrored from a read-only origin."]; | |
| 18 | return origins.flatMap((o, i) => [ | |
| 19 | ...(i > 0 ? [""] : []), | |
| 20 | URL.parse(o.webUrl)?.host ?? o.webUrl, | |
| 21 | ...o.repos.map((r) => | |
| 22 | ` ${r.fullName} ${ | |
| 23 | r.problem !== "" | |
| 24 | ? `Last fetch failed: ${r.problem}` | |
| 25 | : r.lastFetchAt === null | |
| 26 | ? "Not fetched yet" | |
| 27 | : `Last fetch ${r.lastFetchAt}` | |
| 28 | }`, | |
| 29 | ), | |
| 30 | ]); | |
| 31 | } | |
| 32 | ||
| 33 | export function workScreen(session: Session, ctx: Ctx): Screen { | |
| 34 | const scroll = new Scroll(); | |
| 35 | let lines: string[] = []; | |
| 36 | return { | |
| 37 | title: "work (read-only)", | |
| 38 | keys: [binding(["j", "k"], "scroll"), binding(["esc"], "back")], | |
| 39 | render(area, s) { | |
| 40 | const c = ctx.theme.c; | |
| 41 | lines = session.user === undefined ? [] : workLines(readOnlyWork(session.app.db, session.user.id)); | |
| 42 | const from = scroll.window(area.h, lines.length); | |
| 43 | for (let i = 0; i < area.h; i++) { | |
| 44 | const line = lines[from + i]; | |
| 45 | if (line !== undefined) s.text(area.x + 1, area.y + i, truncate(line, area.w - 2), style(c.fg, c.bg), area.x + area.w); | |
| 46 | } | |
| 47 | }, | |
| 48 | key(e) { | |
| 49 | if (e.key === "esc" || e.key === "q") { | |
| 50 | ctx.pop(); | |
| 51 | return true; | |
| 52 | } | |
| 53 | return scroll.key(e, lines.length); | |
| 54 | }, | |
| 55 | }; | |
| 56 | } | |
| 57 | ||
| 58 | export const workCommand = (session: Session, ctx: Ctx) => ({ | |
| 59 | id: "work", | |
| 60 | title: "work (read-only)", | |
| 61 | hint: "repositories mirrored from read-only origins, last fetch and why one failed", | |
| 62 | run: () => ctx.push(workScreen(session, ctx)), | |
| 63 | }); |
src/web/pages/org.ts +41 −1
| @@ -47,6 +47,7 @@ import { listReposByOwner, type Repo } from "../../core/repos.ts"; | ||
| 47 | 47 | import { getUserByName, hiddenFrom, type User } from "../../core/users.ts"; |
| 48 | 48 | import type { Ctx } from "../../http/context.ts"; |
| 49 | 49 | import { nameFrame, notePhase, takeSessionTime, timePhase, timePhaseAsync } from "../../core/route_timing.ts"; |
| 50 | import { readOnlyWork } from "../../origins/read-only-work.ts"; | |
| 50 | 51 | import { notFoundPage, pageHead, signInFirst } from "../chrome.ts"; |
| 51 | 52 | import { field, readForm, type Form } from "../form.ts"; |
| 52 | 53 | import { html, when, type Html } from "../html.ts"; |
| @@ -119,7 +120,11 @@ function repoSection( | ||
| 119 | 120 | ctx.app.db, |
| 120 | 121 | page.map((r) => r.id), |
| 121 | 122 | ); |
| 122 | const shown = page.filter((repo) => !apps.has(repo.id)); | |
| 123 | // The owner's read-only mirrors are drawn once, in "work (read-only)". | |
| 124 | const mirrored = new Set( | |
| 125 | isSelf ? readOnlyWork(ctx.app.db, owner.id).flatMap((o) => o.repos.map((r) => r.name)) : [], | |
| 126 | ); | |
| 127 | const shown = page.filter((repo) => !apps.has(repo.id) && !mirrored.has(repo.name)); | |
| 123 | 128 | |
| 124 | 129 | const lead = (repo: Repo) => |
| 125 | 130 | icon(repo.forkParentId !== null ? "fork" : "repo"); |
| @@ -186,6 +191,38 @@ function repoSection( | ||
| 186 | 191 | )}`; |
| 187 | 192 | } |
| 188 | 193 | |
| 194 | /** | |
| 195 | * "work (read-only)": what the owner mirrors from origins connected read-only, | |
| 196 | * with its last fetch and, when it failed, why in one line. Only ever drawn | |
| 197 | * for the owner's own view; nothing at all for anyone else. | |
| 198 | */ | |
| 199 | function workSection(ctx: Ctx, owner: User): Html { | |
| 200 | const origins = readOnlyWork(ctx.app.db, owner.id); | |
| 201 | if (origins.length === 0) return html``; | |
| 202 | return html`<section class="settings-block" id="work-read-only"> | |
| 203 | <h2>work (read-only)</h2> | |
| 204 | ${origins.map( | |
| 205 | (o) => html`<p class="small muted">${URL.parse(o.webUrl)?.host ?? o.webUrl}</p> | |
| 206 | ${plainList( | |
| 207 | o.repos.map((r) => | |
| 208 | row({ | |
| 209 | href: `/${owner.username}/${r.name}`, | |
| 210 | lead: icon("repo"), | |
| 211 | title: r.fullName, | |
| 212 | nav: true, | |
| 213 | meta: | |
| 214 | r.problem !== "" | |
| 215 | ? html`Last fetch failed: ${r.problem}` | |
| 216 | : r.lastFetchAt === null | |
| 217 | ? html`Not fetched yet` | |
| 218 | : html`Last fetch ${time(r.lastFetchAt)}`, | |
| 219 | }), | |
| 220 | ), | |
| 221 | )}`, | |
| 222 | )} | |
| 223 | </section>`; | |
| 224 | } | |
| 225 | ||
| 189 | 226 | /** |
| 190 | 227 | * The owner's apps, one shelf row each — the same row the home's *Your |
| 191 | 228 | * apps* card draws (`shelfRows`, `studio/shelf.ts`), never a second copy of |
| @@ -382,6 +419,9 @@ export async function ownerProfile(ctx: Ctx): Promise<Response> { | ||
| 382 | 419 | : null} |
| 383 | 420 | ${apps.html} |
| 384 | 421 | ${repos} |
| 422 | ${when(ctx.auth.user !== undefined && ctx.auth.user.id === owner.id && !isOrg, () => | |
| 423 | workSection(ctx, owner), | |
| 424 | )} | |
| 385 | 425 | ${when(isOrg && accessAllows(access, "read"), () => |
| 386 | 426 | teamsSection(ctx, owner, canManage), |
| 387 | 427 | )} |
test/origin-read-only-profile.test.ts added +205
| @@ -0,0 +1,205 @@ | ||
| 1 | /** | |
| 2 | * A read-only origin's mirrors show on their owner's profile as | |
| 3 | * "work (read-only)" with the last fetch and, on failure, one plain line | |
| 4 | * (#10522); and the scheduled sync lands commits, branches, open and merged | |
| 5 | * pulls and reviews from a fake remote. | |
| 6 | */ | |
| 7 | import { afterAll, beforeAll, describe, expect, setDefaultTimeout, 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 { createSession } from "../src/core/sessions.ts"; | |
| 13 | import { createUser } from "../src/core/users.ts"; | |
| 14 | import { getOriginById } from "../src/core/origins.ts"; | |
| 15 | import { serve, type Servers } from "../src/http/server.ts"; | |
| 16 | import { syncOrigin, syncsSettled } from "../src/origins/sync.ts"; | |
| 17 | import { workLines } from "../src/tui/screens/work.ts"; | |
| 18 | import { gitFailureWords, readOnlyWork } from "../src/origins/read-only-work.ts"; | |
| 19 | import { mirrorAndWait, newFakeState, serveFakeForge, type FakeForge } from "./fake-origin.ts"; | |
| 20 | import { seedAdmin } from "./helpers/admin.ts"; | |
| 21 | import { proveAll } from "./helpers/verified.ts"; | |
| 22 | ||
| 23 | setDefaultTimeout(60_000); | |
| 24 | ||
| 25 | const LOGIN = "octo"; | |
| 26 | const TOKEN = "octo-profile-secret-5c1d"; | |
| 27 | const PASSWORD = "alice-login-secret"; | |
| 28 | const NAME = "quillmoth"; | |
| 29 | const FULL = `octo/${NAME}`; | |
| 30 | const basic = (u: string, p: string) => `Basic ${Buffer.from(`${u}:${p}`).toString("base64")}`; | |
| 31 | ||
| 32 | describe("work (read-only) on the owner's profile", () => { | |
| 33 | const dirs: string[] = []; | |
| 34 | let app: App; | |
| 35 | let servers: Servers; | |
| 36 | let originApp: App; | |
| 37 | let originServers: Servers; | |
| 38 | let fake: FakeForge; | |
| 39 | let originId = ""; | |
| 40 | const cookies: Record<string, string> = {}; | |
| 41 | ||
| 42 | const api = (path: string, init?: RequestInit) => { | |
| 43 | const headers = new Headers(init?.headers); | |
| 44 | headers.set("content-type", "application/json"); | |
| 45 | headers.set("authorization", basic("alice", PASSWORD)); | |
| 46 | return fetch(`${servers.url}${path}`, { ...init, headers }); | |
| 47 | }; | |
| 48 | const as = (who: string, path: string) => | |
| 49 | fetch(`${servers.url}${path}`, { | |
| 50 | headers: cookies[who] === undefined ? {} : { cookie: cookies[who]! }, | |
| 51 | }); | |
| 52 | const sweep = async () => { | |
| 53 | const origin = getOriginById(app.db, originId)!; | |
| 54 | await syncOrigin(app, origin); | |
| 55 | await syncsSettled(); | |
| 56 | }; | |
| 57 | ||
| 58 | beforeAll(async () => { | |
| 59 | for (const p of ["forge-rop2-", "forge-rop2-up-"]) dirs.push(await mkdtemp(join(tmpdir(), p))); | |
| 60 | originApp = createApp({ dataDir: dirs[1]!, port: 0 }); | |
| 61 | originServers = await serve(originApp); | |
| 62 | await seedAdmin(originApp, { username: LOGIN, email: "[email protected]", password: TOKEN }); | |
| 63 | const oh = { "content-type": "application/json", authorization: basic(LOGIN, TOKEN) }; | |
| 64 | await fetch(`${originServers.url}/api/v1/repos`, { | |
| 65 | method: "POST", | |
| 66 | headers: oh, | |
| 67 | body: JSON.stringify({ name: NAME, visibility: "public" }), | |
| 68 | }); | |
| 69 | for (const [branch, message, path] of [ | |
| 70 | ["main", "zephyrmarker first commit", "a.txt"], | |
| 71 | ["main", "second upstream commit", "b.txt"], | |
| 72 | ["feature-x", "work on the feature", "c.txt"], | |
| 73 | ] as const) { | |
| 74 | const r = await fetch(`${originServers.url}/api/v1/repos/${FULL}/contents`, { | |
| 75 | method: "POST", | |
| 76 | headers: oh, | |
| 77 | body: JSON.stringify({ branch, message, files: [{ path, content: "one\n" }] }), | |
| 78 | }); | |
| 79 | expect(r.status, message).toBeLessThan(300); | |
| 80 | } | |
| 81 | const state = newFakeState("github", LOGIN, TOKEN); | |
| 82 | state.repos.push({ | |
| 83 | id: 9, | |
| 84 | fullName: FULL, | |
| 85 | cloneUrl: `${originServers.url}/${FULL}.git`, | |
| 86 | defaultBranch: "main", | |
| 87 | htmlUrl: `${originServers.url}/${FULL}`, | |
| 88 | push: true, | |
| 89 | }); | |
| 90 | const at = "2026-01-01T00:00:00.000Z"; | |
| 91 | state.issues.set(FULL, [ | |
| 92 | { id: 100, number: 1, title: "Open pull upstream", user: "mona", createdAt: at, updatedAt: at, pull: { base: "main", head: "feature-x", headRepo: FULL } }, | |
| 93 | { id: 101, number: 2, title: "Merged pull upstream", user: "hubot", state: "closed", createdAt: at, updatedAt: at, closedAt: at, pull: { base: "main", head: "feature-x", headRepo: FULL, mergedAt: at } }, | |
| 94 | ]); | |
| 95 | state.reviews.set(FULL, [ | |
| 96 | { id: 500, issueNumber: 1, body: "inline remark", user: "mona", path: "c.txt", diffHunk: "@@ -0,0 +1 @@", line: 1, side: "RIGHT" }, | |
| 97 | ]); | |
| 98 | state.reviewStates.set(FULL, [ | |
| 99 | { id: 600, issueNumber: 1, user: "hubot", state: "APPROVED", body: "ship it", submittedAt: at }, | |
| 100 | ]); | |
| 101 | fake = serveFakeForge(state); | |
| 102 | ||
| 103 | app = createApp({ dataDir: dirs[0]!, port: 0 }); | |
| 104 | servers = await serve(app); | |
| 105 | const made = await seedAdmin(app, { username: "alice", email: "[email protected]", password: PASSWORD }); | |
| 106 | if (!made.ok) throw new Error(made.error); | |
| 107 | const bob = await createUser(app.db, { username: "bob", email: "[email protected]", password: "a-good-passphrase", origin: "seed", trusted: true }); | |
| 108 | if (!bob.ok) throw new Error("users"); | |
| 109 | cookies["owner"] = `forge_session=${createSession(app.db, made.value.id).token}`; | |
| 110 | cookies["stranger"] = `forge_session=${createSession(app.db, bob.value.id).token}`; | |
| 111 | ||
| 112 | const connected = (await ( | |
| 113 | await api("/api/v1/user/origins", { | |
| 114 | method: "POST", | |
| 115 | body: JSON.stringify({ host: fake.url, kind: "github", token: TOKEN, readOnly: true }), | |
| 116 | }) | |
| 117 | ).json()) as { id: string }; | |
| 118 | originId = connected.id; | |
| 119 | await mirrorAndWait( | |
| 120 | await api(`/api/v1/user/origins/${originId}/mirror`, { method: "POST", body: JSON.stringify({ fullName: FULL }) }), | |
| 121 | ); | |
| 122 | await syncsSettled(); | |
| 123 | proveAll(app); | |
| 124 | }); | |
| 125 | ||
| 126 | afterAll(async () => { | |
| 127 | await syncsSettled(); | |
| 128 | fake.stop(); | |
| 129 | servers.stop(); | |
| 130 | originServers.stop(); | |
| 131 | app.close(); | |
outdated
This three-way OR passes even if the merged pull landed as anything closed. Assert the one state the API reports for a merged pull. |
||
| 132 | originApp.close(); | |
| 133 | for (const d of dirs) await rm(d, { recursive: true, force: true }); | |
| 134 | }); | |
| 135 | ||
| 136 | test("a scheduled sweep lands commits, branches, open and merged pulls and reviews", async () => { | |
| 137 | await sweep(); | |
| 138 | const commits = (await (await api(`/api/v1/repos/alice/${NAME}/commits?ref=main`)).json()) as { items?: { message: string }[] }; | |
| 139 | const text = JSON.stringify(commits); | |
| 140 | expect(text).toContain("second upstream commit"); | |
| 141 | expect(text).toContain("zephyrmarker"); | |
| 142 | const branches = await (await api(`/api/v1/repos/alice/${NAME}/branches`)).text(); | |
| 143 | expect(branches).toContain("feature-x"); | |
| 144 | const pulls = (await (await api(`/api/v1/repos/alice/${NAME}/pulls?state=all`)).json()) as { | |
| 145 | items: { number: number; state: string; mergedAt?: string }[]; | |
| 146 | }; | |
| 147 | const byNumber = new Map(pulls.items.map((p) => [p.number, p])); | |
| 148 | expect(byNumber.get(1)?.state).toBe("open"); | |
| 149 | expect(byNumber.get(2)).toMatchObject({ state: "closed", mergedAt: "2026-01-01T00:00:00.000Z" }); | |
| 150 | const comment = app.db.query("SELECT body FROM comment WHERE body = 'inline remark'").get(); | |
| 151 | expect(comment).not.toBeNull(); | |
| 152 | const review = app.db.query("SELECT state FROM review").all(); | |
| 153 | expect(review.length).toBe(1); | |
| 154 | }); | |
| 155 | ||
| 156 | test("the owner sees work (read-only) with its last fetch; nobody else sees the section", async () => { | |
| 157 | const mine = await (await as("owner", "/alice")).text(); | |
| 158 | expect(mine).toContain("work (read-only)"); | |
| 159 | expect(mine).toContain(FULL); | |
| 160 | expect(mine).toContain("Last fetch"); | |
| 161 | expect(mine).not.toContain("Last fetch failed"); | |
| 162 | for (const who of ["stranger", "anonymous"]) { | |
| 163 | const body = await (await as(who, "/alice")).text(); | |
| 164 | expect(body, who).not.toContain("work (read-only)"); | |
| 165 | expect(body, who).not.toContain(FULL); | |
| 166 | } | |
| 167 | expect(readOnlyWork(app.db, "someone-else")).toEqual([]); | |
| 168 | // Once: the mirror is not also listed under Repositories. | |
| 169 | expect(mine.split(`href="/alice/${NAME}"`).length - 1).toBe(1); | |
| 170 | }); | |
| 171 | ||
| 172 | test("the terminal screen carries the same lines", () => { | |
| 173 | const alice = app.db.query<{ id: string }, []>("SELECT id FROM user WHERE username = 'alice'").get()!; | |
| 174 | const text = workLines(readOnlyWork(app.db, alice.id)).join("\n"); | |
| 175 | expect(text).toContain(FULL); | |
| 176 | expect(text).toMatch(/Last fetch /); | |
| 177 | expect(workLines([])).toEqual(["Nothing mirrored from a read-only origin."]); | |
| 178 | }); | |
| 179 | ||
| 180 | test("a refused token is explained in one line, not a stack", async () => { | |
| 181 | fake.state.token = "rotated-away"; | |
| 182 | await sweep(); | |
| 183 | const mine = await (await as("owner", "/alice")).text(); | |
| 184 | expect(mine).toContain("Last fetch failed: the token was refused"); | |
| 185 | expect(mine).not.toMatch(/\bat \S+\.ts:\d+/); | |
| 186 | fake.state.token = TOKEN; | |
| 187 | }); | |
| 188 | ||
| 189 | test("a repository that is gone is explained", async () => { | |
| 190 | const kept = fake.state.repos.splice(0); | |
| 191 | await sweep(); | |
| 192 | const mine = await (await as("owner", "/alice")).text(); | |
| 193 | expect(mine).toContain("Last fetch failed: the repository is gone"); | |
| 194 | fake.state.repos.push(...kept); | |
| 195 | await sweep(); | |
| 196 | expect(await (await as("owner", "/alice")).text()).not.toContain("Last fetch failed"); | |
| 197 | }); | |
| 198 | ||
| 199 | test("git's own complaints become one line", () => { | |
| 200 | expect(gitFailureWords("fatal: Authentication failed for 'x'")).toContain("token was refused"); | |
| 201 | expect(gitFailureWords("fatal: repository 'x' not found")).toContain("gone"); | |
| 202 | expect(gitFailureWords("fatal: unable to access: Could not resolve host")).toContain("could not be reached"); | |
| 203 | expect(gitFailureWords("something\n at stack.ts:1")).not.toContain("\n"); | |
| 204 | }); | |
| 205 | }); |
test/palette-roster.test.ts +2
| @@ -73,6 +73,7 @@ test("a site-admin person is offered the whole roster", () => { | ||
| 73 | 73 | ["new-pull", "New pull request", "owner/name"], |
| 74 | 74 | ["settings", "Repository settings", "owner/name"], |
| 75 | 75 | ["new-repo", "New repository", undefined], |
| 76 | ["work", "work (read-only)", "repositories mirrored from read-only origins, last fetch and why one failed"], | |
| 76 | 77 | ["new-app", "New app", "say what it does in a sentence; the crew starts on it"], |
| 77 | 78 | ["new-app-template", "New app from a template", "copy a repository you can read, as the starting files"], |
| 78 | 79 | ["bring-app", "Bring an app", "paste a link from another forge; it runs here"], |
| @@ -271,6 +272,7 @@ test("a member person is offered the signed-in roster without the admin entries" | ||
| 271 | 272 | ["new-pull", "New pull request", "owner/name"], |
| 272 | 273 | ["settings", "Repository settings", "owner/name"], |
| 273 | 274 | ["new-repo", "New repository", undefined], |
| 275 | ["work", "work (read-only)", "repositories mirrored from read-only origins, last fetch and why one failed"], | |
| 274 | 276 | ["new-app", "New app", "say what it does in a sentence; the crew starts on it"], |
| 275 | 277 | ["new-app-template", "New app from a template", "copy a repository you can read, as the starting files"], |
| 276 | 278 | ["bring-app", "Bring an app", "paste a link from another forge; it runs here"], |
Principle 1: the heading already says read-only; this explanatory line says it again. Show just the host as a muted label, or drop it.