Public: anyone can read this; the crew can change it.
Archive, part 2a-i: an archived repository leaves the web home's apps, the explorer's default view and the web inbox, assigned work included #2495
Check · Review · Land · Live done — Landed and live in production.
scout/forge:scout/2493-archive-part-2a-i-an-archived-repository → main 1 commit fork 1 thread · 1 unresolved
10 files
changed +163
−14
README.md +7 −1
| @@ -313,7 +313,13 @@ wave and converging a fork each answer "This repository is archived; wake it to | ||
| 313 | 313 | start a run."; the API answers 409 `archived`, and the terminal and MCP show |
| 314 | 314 | the same line. The autonomy tick skips it. A run already going finishes, and |
| 315 | 315 | Wake undoes all of it. Clone, push and links keep working, and the repository |
| 316 | stays in every list. | |
| 316 | page still answers. | |
| 317 | ||
| 318 | An archived repository leaves the web home's apps, the inbox (work assigned to | |
| 319 | you there included, and the terminal's and MCP's asks), the stalled-steps | |
| 320 | reader and the explorer's default view; `/explore` shows it with `archived:yes` | |
| 321 | (the **Show archived** toggle). It stays in `/apps`, search, the palette and | |
| 322 | the repository API (`GET /api/v1/repos`). | |
| 317 | 323 | |
| 318 | 324 | ## Health |
| 319 | 325 |
src/actions/explore.ts +11 −2
| @@ -42,6 +42,8 @@ export interface ExploreQuery { | ||
| 42 | 42 | readonly sort: ExploreSort; |
| 43 | 43 | /** Words a row's name, owner or description must contain. */ |
| 44 | 44 | readonly text: string; |
| 45 | /** `archived:yes` — put-away repositories are in the list; off, they are not. */ | |
| 46 | readonly archived: boolean; | |
| 45 | 47 | } |
| 46 | 48 | |
| 47 | 49 | const KINDS: Record<string, ExploreKind> = { |
| @@ -73,6 +75,7 @@ const ROWS = 60; | ||
| 73 | 75 | export function parseExploreQuery(line: string): ExploreQuery { |
| 74 | 76 | let kind: ExploreKind | "all" = "all"; |
| 75 | 77 | let sort: ExploreSort = "score"; |
| 78 | let archived = false; | |
| 76 | 79 | const words: string[] = []; |
| 77 | 80 | for (const word of line |
| 78 | 81 | .trim() |
| @@ -86,10 +89,12 @@ export function parseExploreQuery(line: string): ExploreQuery { | ||
| 86 | 89 | (SORTS as readonly string[]).includes(value.toLowerCase()) |
| 87 | 90 | ) |
| 88 | 91 | sort = value.toLowerCase() as ExploreSort; |
| 92 | else if (k === "archived" && ["yes", "no"].includes(value.toLowerCase())) | |
| 93 | archived = value.toLowerCase() === "yes"; | |
| 89 | 94 | else if (k === "q" && value !== "") words.push(value); |
| 90 | 95 | else words.push(word); |
| 91 | 96 | } |
| 92 | return { kind, sort, text: words.join(" ").slice(0, 100) }; | |
| 97 | return { kind, sort, text: words.join(" ").slice(0, 100), archived }; | |
| 93 | 98 | } |
| 94 | 99 | |
| 95 | 100 | /** The line a query reads as, for links and the query box. */ |
| @@ -97,6 +102,7 @@ export function exploreLine(query: ExploreQuery): string { | ||
| 97 | 102 | return [ |
| 98 | 103 | query.kind === "all" ? "" : `kind:${query.kind}`, |
| 99 | 104 | query.sort === "score" ? "" : `sort:${query.sort}`, |
| 105 | query.archived ? "archived:yes" : "", | |
| 100 | 106 | query.text, |
| 101 | 107 | ] |
| 102 | 108 | .filter((p) => p !== "") |
| @@ -137,7 +143,10 @@ export async function explore( | ||
| 137 | 143 | now = Date.now(), |
| 138 | 144 | ): Promise<ExploreRow[]> { |
| 139 | 145 | const db = app.db; |
| 140 | const repos = listReposVisibleTo(db, viewer, { limit: READ }); | |
| 146 | const repos = listReposVisibleTo(db, viewer, { | |
| 147 | limit: READ, | |
| 148 | withoutArchived: !query.archived, | |
| 149 | }); | |
| 141 | 150 | const records = appsForRepos( |
| 142 | 151 | db, |
| 143 | 152 | repos.map((r) => r.id), |
src/core/asked.ts +4 −1
| @@ -117,7 +117,10 @@ export function askedRepos( | ||
| 117 | 117 | viewer: User | undefined, |
| 118 | 118 | repoSpec: string | null | undefined, |
| 119 | 119 | ): readonly Repo[] { |
| 120 | const writable = listReposWritableTo(db, viewer, { limit: 200 }); | |
| 120 | const writable = listReposWritableTo(db, viewer, { | |
| 121 | limit: 200, | |
| 122 | withoutArchived: true, | |
| 123 | }); | |
| 121 | 124 | if (repoSpec === null || repoSpec === undefined || repoSpec === "") |
| 122 | 125 | return writable; |
| 123 | 126 | const [owner, name, ...rest] = repoSpec.split("/"); |
src/core/repos.ts +4
| @@ -649,6 +649,8 @@ export interface ListReposOptions { | ||
| 649 | 649 | cursor?: string; |
| 650 | 650 | /** Repositories that run an app first, each half newest first (not with `cursor`). */ |
| 651 | 651 | appsFirst?: boolean; |
| 652 | /** Leave archived repositories out. @default false */ | |
| 653 | withoutArchived?: boolean; | |
| 652 | 654 | } |
| 653 | 655 | |
| 654 | 656 | // Visibility in SQL: public, owned, or owned by an org the viewer is on a |
| @@ -679,6 +681,7 @@ export function listReposVisibleTo( | ||
| 679 | 681 | params.push(options.cursor); |
| 680 | 682 | where += ` AND repo.id < ?${params.length}`; |
| 681 | 683 | } |
| 684 | if (options.withoutArchived === true) where += " AND repo.archived_at IS NULL"; | |
| 682 | 685 | params.push(pageLimit(options.limit)); |
| 683 | 686 | const rows = db |
| 684 | 687 | .query<RepoRow, (string | number)[]>( |
| @@ -713,6 +716,7 @@ export function listReposWritableTo( | ||
| 713 | 716 | params.push(options.cursor); |
| 714 | 717 | where += ` AND repo.id < ?${params.length}`; |
| 715 | 718 | } |
| 719 | if (options.withoutArchived === true) where += " AND repo.archived_at IS NULL"; | |
| 716 | 720 | params.push(pageLimit(options.limit)); |
| 717 | 721 | const rows = db |
| 718 | 722 | .query<RepoRow, (string | number)[]>( |
src/flow/stalled.ts +4 −1
| @@ -49,7 +49,10 @@ interface RunRow { | ||
| 49 | 49 | } |
| 50 | 50 | |
| 51 | 51 | export function readStalled(db: Database, viewer: User, now: Date): StalledStep[] { |
| 52 | const repos = listReposWritableTo(db, viewer, { limit: REPOS_MAX }); | |
| 52 | const repos = listReposWritableTo(db, viewer, { | |
| 53 | limit: REPOS_MAX, | |
| 54 | withoutArchived: true, | |
| 55 | }); | |
| 53 | 56 | if (repos.length === 0) return []; |
| 54 | 57 | const byId = new Map(repos.map((r) => [r.id, r])); |
| 55 | 58 | const ids = [...byId.keys()]; |
src/web/studio/asks.ts +12 −8
| @@ -90,7 +90,7 @@ function appsFiltered( | ||
| 90 | 90 | export function appsOf(ctx: Ctx, user: User | undefined) { |
| 91 | 91 | return appsFiltered( |
| 92 | 92 | ctx, |
| 93 | listReposVisibleTo(ctx.app.db, user, { limit: 200 }), | |
| 93 | listReposVisibleTo(ctx.app.db, user, { limit: 200, withoutArchived: true }), | |
| 94 | 94 | ); |
| 95 | 95 | } |
| 96 | 96 | |
| @@ -101,7 +101,10 @@ export function appsOf(ctx: Ctx, user: User | undefined) { | ||
| 101 | 101 | export function writableAppsOf(ctx: Ctx, user: User | undefined) { |
| 102 | 102 | return appsFiltered( |
| 103 | 103 | ctx, |
| 104 | listReposWritableTo(ctx.app.db, user, { limit: 200 }), | |
| 104 | listReposWritableTo(ctx.app.db, user, { | |
| 105 | limit: 200, | |
| 106 | withoutArchived: true, | |
| 107 | }), | |
| 105 | 108 | ); |
| 106 | 109 | } |
| 107 | 110 | |
| @@ -512,7 +515,8 @@ function askCandidatesFor( | ||
| 512 | 515 | ); |
| 513 | 516 | for (const issue of mine) { |
| 514 | 517 | const repo = visible.get(issue.repoId) ?? elsewhere.get(issue.repoId); |
| 515 | if (!readable(repo) || !mayView(db, repo, issue, user)) continue; | |
| 518 | if (!readable(repo) || repo.archivedAt !== null) continue; | |
| 519 | if (!mayView(db, repo, issue, user)) continue; | |
| 516 | 520 | const pull = joinedMine.get(issue.id); |
| 517 | 521 | if (issue.kind === "pull" && pull === undefined) continue; |
| 518 | 522 | out.push({ |
| @@ -766,7 +770,7 @@ function scoredFor( | ||
| 766 | 770 | |
| 767 | 771 | /** The waves the viewer put aside with Later, oldest first, for the line that brings them back. */ |
| 768 | 772 | export function putAsideWaves(db: Database, user: User): Proposal[] { |
| 769 | const repos = listReposVisibleTo(db, user, { limit: 200 }); | |
| 773 | const repos = listReposVisibleTo(db, user, { limit: 200, withoutArchived: true }); | |
| 770 | 774 | const mine = ownerFactRepoIds(db, repos, user); |
| 771 | 775 | return proposalsFor(db, repos.filter((r) => mine.has(r.id)).map((r) => r.id), "later"); |
| 772 | 776 | } |
| @@ -892,7 +896,7 @@ export function recordAskLedger(db: Database, now = new Date()): void { | ||
| 892 | 896 | for (const { id } of people) { |
| 893 | 897 | const user = getUserById(db, id); |
| 894 | 898 | if (!user) continue; |
| 895 | const repos = listReposVisibleTo(db, user, { limit: 200 }); | |
| 899 | const repos = listReposVisibleTo(db, user, { limit: 200, withoutArchived: true }); | |
| 896 | 900 | const all = scoredFor(db, user, repos, now.getTime()); |
| 897 | 901 | const shown = notDismissed(db, user, all); |
| 898 | 902 | const brief = (a: Scored) => ({ id: a.id, repoId: a.repo.id, kind: a.kind }); |
| @@ -992,7 +996,7 @@ export function askLinesFor( | ||
| 992 | 996 | /** What the row waits for (the weightiest ask's), and on whom. */ |
| 993 | 997 | waitingFor: WaitingFor; |
| 994 | 998 | }[] { |
| 995 | const repos = listReposVisibleTo(db, user, { limit: 200 }); | |
| 999 | const repos = listReposVisibleTo(db, user, { limit: 200, withoutArchived: true }); | |
| 996 | 1000 | return askRows(waitingOn(db, user, repos, chips)).map((row) => { |
| 997 | 1001 | const base = `/${row.repo.ownerName}/${row.repo.name}`; |
| 998 | 1002 | const about = { |
| @@ -1353,7 +1357,7 @@ export function forgeNeedsFor(ctx: Ctx, user: User | undefined): Degraded[] { | ||
| 1353 | 1357 | */ |
| 1354 | 1358 | export function askCountFor(ctx: Ctx, user: User | undefined): number { |
| 1355 | 1359 | if (!user) return 0; |
| 1356 | const repos = listReposVisibleTo(ctx.app.db, user, { limit: 200 }); | |
| 1360 | const repos = listReposVisibleTo(ctx.app.db, user, { limit: 200, withoutArchived: true }); | |
| 1357 | 1361 | return ( |
| 1358 | 1362 | askRows(waitingOn(ctx.app.db, user, repos)).length + |
| 1359 | 1363 | forgeNeedsFor(ctx, user).length |
| @@ -1440,7 +1444,7 @@ export function askTargetsFor( | ||
| 1440 | 1444 | items: readonly string[], |
| 1441 | 1445 | ): { issueIds: string[]; refused: { item: string; reason: string }[] } { |
| 1442 | 1446 | const asks = new Map( |
| 1443 | askCandidatesFor(db, user, listReposVisibleTo(db, user, { limit: 200 })).map( | |
| 1447 | askCandidatesFor(db, user, listReposVisibleTo(db, user, { limit: 200, withoutArchived: true })).map( | |
| 1444 | 1448 | (a) => [a.id, a], |
| 1445 | 1449 | ), |
| 1446 | 1450 | ); |
src/web/studio/asks_page.ts +1 −1
| @@ -387,7 +387,7 @@ export function needsYouSection( | ||
| 387 | 387 | const picked = top?.picked ?? new Set<string>(); |
| 388 | 388 | const decisions = decisionsFor( |
| 389 | 389 | ctx, |
| 390 | listReposVisibleTo(ctx.app.db, user, { limit: 200 }), | |
| 390 | listReposVisibleTo(ctx.app.db, user, { limit: 200, withoutArchived: true }), | |
| 391 | 391 | ); |
| 392 | 392 | const needs = forgeNeedsFor(ctx, user); |
| 393 | 393 | const rows = askRows(decisions); |
src/web/studio/explore.ts +6
| @@ -110,6 +110,12 @@ export async function explorePage(ctx: Ctx): Promise<Response> { | ||
| 110 | 110 | current: query.sort === s.sort, |
| 111 | 111 | })), |
| 112 | 112 | )} |
| 113 | <a | |
| 114 | class="btn btn-sm" | |
| 115 | href="${href({ ...query, archived: !query.archived })}" | |
| 116 | aria-pressed="${query.archived ? "true" : "false"}" | |
| 117 | >Show archived</a | |
| 118 | > | |
| 113 | 119 | </div> |
| 114 | 120 | ${rows.length === 0 |
| 115 | 121 | ? empty({ |
test/archive-web.test.ts added +107
| @@ -0,0 +1,107 @@ | ||
| 1 | /** | |
| 2 | * An archived repository leaves the web home's apps, the explorer's default | |
| 3 | * view and the inbox (assigned work included); `archived:yes` and its own | |
| 4 | * page still show it. | |
| 5 | */ | |
| 6 | import { afterAll, beforeAll, describe, expect, test } from "bun:test"; | |
| 7 | import { mkdtemp, rm } from "node:fs/promises"; | |
| 8 | import { tmpdir } from "node:os"; | |
| 9 | import { join } from "node:path"; | |
| 10 | import { createApp, type App } from "../src/app.ts"; | |
| 11 | import { explore, parseExploreQuery } from "../src/actions/explore.ts"; | |
| 12 | import { openIssue } from "../src/actions/issues.ts"; | |
| 13 | import { createRepo } from "../src/actions/repos.ts"; | |
| 14 | import { createApp as insertApp } from "../src/core/apps.ts"; | |
| 15 | import { setAssignee } from "../src/core/issues.ts"; | |
| 16 | import { | |
| 17 | listReposVisibleTo, | |
| 18 | listReposWritableTo, | |
| 19 | updateRepoSettings, | |
| 20 | type Repo, | |
| 21 | } from "../src/core/repos.ts"; | |
| 22 | import { createUser, type User } from "../src/core/users.ts"; | |
| 23 | import { serve, type Servers } from "../src/http/server.ts"; | |
| 24 | import { askLinesFor } from "../src/web/studio/asks.ts"; | |
| 25 | import { quietLogs } from "./helpers/globals.ts"; | |
| 26 | ||
| 27 | quietLogs(); | |
| 28 | ||
| 29 | describe("an archived repository on the web", () => { | |
| 30 | let dataDir = ""; | |
| 31 | let app: App; | |
| 32 | let servers: Servers; | |
| 33 | let alice: User; | |
| 34 | let repo: Repo; | |
| 35 | let cookie = ""; | |
| 36 | let assigned = ""; | |
| 37 | ||
| 38 | beforeAll(async () => { | |
| 39 | dataDir = await mkdtemp(join(tmpdir(), "forge-archive-web-")); | |
| 40 | app = createApp({ dataDir, port: 0 }); | |
| 41 | servers = await serve(app); | |
| 42 | const made = await createUser(app.db, { | |
| 43 | trusted: true, | |
| 44 | username: "alice", | |
| 45 | email: "[email protected]", | |
| 46 | password: "a-long-archive-passphrase", | |
| 47 | adminIfFirst: true, | |
| 48 | }); | |
| 49 | if (!made.ok) throw new Error(made.error); | |
| 50 | alice = made.value; | |
| 51 | const res = await fetch(`${servers.url}/signin`, { | |
| 52 | method: "POST", | |
| 53 | body: new URLSearchParams({ username: "alice", password: "a-long-archive-passphrase" }), | |
| 54 | redirect: "manual", | |
| 55 | }); | |
| 56 | cookie = `${(res.headers.get("set-cookie") ?? "").split(";")[0]!}; forge_mode=studio`; | |
| 57 | const r = await createRepo(app, alice, { name: "oldie", visibility: "public" }); | |
| 58 | if (!r.ok) throw new Error(String(r.error)); | |
| 59 | repo = r.value; | |
| 60 | const rec = insertApp(app.db, { repoId: repo.id, slug: "oldie", access: "public" }); | |
| 61 | if (!rec.ok) throw new Error(rec.error); | |
| 62 | const issue = openIssue(app, alice, repo, { title: "Sweep the shed", body: "" }); | |
| 63 | if (!issue.ok) throw new Error(String(issue.error)); | |
| 64 | assigned = issue.value.id; | |
| 65 | setAssignee(app.db, assigned, alice.id); | |
| 66 | }, 30_000); | |
| 67 | ||
| 68 | afterAll(async () => { | |
| 69 | await servers.stop(); | |
| 70 | await rm(dataDir, { recursive: true, force: true }); | |
| 71 | }); | |
| 72 | ||
| 73 | const get = async (path: string) => | |
| 74 | (await fetch(`${servers.url}${path}`, { headers: { cookie } })).text(); | |
| 75 | const names = async (line: string) => | |
| 76 | (await explore(app, alice, parseExploreQuery(line))).map((r) => r.name); | |
| 77 | ||
| 78 | test("present while live: home, explorer, inbox", async () => { | |
| 79 | expect(await get("/live/on-you")).toContain("Sweep the shed"); | |
| 80 | expect(await get("/live/apps")).toContain("alice/oldie"); | |
| 81 | expect(await names("kind:app")).toContain("alice/oldie"); | |
| 82 | expect(askLinesFor(app.db, alice).some((l) => l.id === `assigned:${assigned}`)).toBe(true); | |
| 83 | }); | |
| 84 | ||
| 85 | test("absent once archived, the assigned issue included", async () => { | |
| 86 | repo = updateRepoSettings(app.db, repo, { archived: true }); | |
| 87 | expect(await get("/live/apps")).not.toContain("alice/oldie"); | |
| 88 | expect(await get("/live/on-you")).not.toContain("Sweep the shed"); | |
| 89 | expect(askLinesFor(app.db, alice).some((l) => l.id === `assigned:${assigned}`)).toBe(false); | |
| 90 | expect(await names("kind:app")).not.toContain("alice/oldie"); | |
| 91 | expect(listReposWritableTo(app.db, alice, { withoutArchived: true })).toHaveLength(0); | |
| 92 | expect(listReposVisibleTo(app.db, alice, { withoutArchived: true })).toHaveLength(0); | |
| 93 | // Off by default: the repository API's list still has it. | |
| 94 | expect(listReposVisibleTo(app.db, alice)).toHaveLength(1); | |
| 95 | }); | |
| 96 | ||
| 97 | test("archived:yes brings it back, the toggle says its state, its page answers 200", async () => { | |
| 98 | expect(await names("kind:app archived:yes")).toContain("alice/oldie"); | |
| 99 | const off = await get("/explore"); | |
| 100 | expect(off).toContain("Show archived"); | |
| 101 | expect(off).toContain('aria-pressed="false"'); | |
| 102 | const on = await get(`/explore?q=${encodeURIComponent("archived:yes")}`); | |
| 103 | expect(on).toContain('aria-pressed="true"'); | |
| 104 | expect(on).toContain("alice/oldie"); | |
| 105 | expect((await fetch(`${servers.url}/alice/oldie`, { headers: { cookie } })).status).toBe(200); | |
| 106 | }); | |
| 107 | }); |
test/explore.test.ts +7
| @@ -30,17 +30,24 @@ describe("the one line", () => { | ||
| 30 | 30 | kind: "app", |
| 31 | 31 | sort: "forks", |
| 32 | 32 | text: "garden", |
| 33 | archived: false, | |
| 33 | 34 | }); |
| 34 | 35 | expect(parseExploreQuery("kind:people q:dungeon")).toEqual({ |
| 35 | 36 | kind: "person", |
| 36 | 37 | sort: "score", |
| 37 | 38 | text: "dungeon", |
| 39 | archived: false, | |
| 38 | 40 | }); |
| 39 | 41 | expect(parseExploreQuery("kind:nonsense sort:nonsense")).toEqual({ |
| 40 | 42 | kind: "all", |
| 41 | 43 | sort: "score", |
| 42 | 44 | text: "sort:nonsense", |
| 45 | archived: false, | |
| 43 | 46 | }); |
| 47 | expect(parseExploreQuery("archived:yes").archived).toBe(true); | |
| 48 | expect(exploreLine(parseExploreQuery("archived:yes kind:app"))).toBe( | |
| 49 | "kind:app archived:yes", | |
| 50 | ); | |
| 44 | 51 | expect(exploreLine(parseExploreQuery(" sort:ships kind:app "))).toBe( |
| 45 | 52 | "kind:app sort:ships", |
| 46 | 53 | ); |
new above
offline
aria-pressed is not valid on a link role, so screen readers will not announce the state. Use aria-current="true" with visible text such as 'Archived shown', or make it a button in a form.