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

Merged opened this

Check · Review · Land · Live done — Landed and live in production.

Merged

scout/forge:scout/2493-archive-part-2a-i-an-archived-repository → main 1 commit fork 1 thread · 1 unresolved

  • Merged. scout/2493-archive-part-2a-i-an-archived-repository was merged into main as a0a8937.

Review

Sign in to review this change.
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
313313 start a run."; the API answers 409 `archived`, and the terminal and MCP show
314314 the same line. The autonomy tick skips it. A run already going finishes, and
315315 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`).
317323
318324 ## Health
319325
src/actions/explore.ts +11 −2
@@ -42,6 +42,8 @@ export interface ExploreQuery {
4242 readonly sort: ExploreSort;
4343 /** Words a row's name, owner or description must contain. */
4444 readonly text: string;
45 /** `archived:yes` — put-away repositories are in the list; off, they are not. */
46 readonly archived: boolean;
4547 }
4648
4749 const KINDS: Record<string, ExploreKind> = {
@@ -73,6 +75,7 @@ const ROWS = 60;
7375 export function parseExploreQuery(line: string): ExploreQuery {
7476 let kind: ExploreKind | "all" = "all";
7577 let sort: ExploreSort = "score";
78 let archived = false;
7679 const words: string[] = [];
7780 for (const word of line
7881 .trim()
@@ -86,10 +89,12 @@ export function parseExploreQuery(line: string): ExploreQuery {
8689 (SORTS as readonly string[]).includes(value.toLowerCase())
8790 )
8891 sort = value.toLowerCase() as ExploreSort;
92 else if (k === "archived" && ["yes", "no"].includes(value.toLowerCase()))
93 archived = value.toLowerCase() === "yes";
8994 else if (k === "q" && value !== "") words.push(value);
9095 else words.push(word);
9196 }
92 return { kind, sort, text: words.join(" ").slice(0, 100) };
97 return { kind, sort, text: words.join(" ").slice(0, 100), archived };
9398 }
9499
95100 /** The line a query reads as, for links and the query box. */
@@ -97,6 +102,7 @@ export function exploreLine(query: ExploreQuery): string {
97102 return [
98103 query.kind === "all" ? "" : `kind:${query.kind}`,
99104 query.sort === "score" ? "" : `sort:${query.sort}`,
105 query.archived ? "archived:yes" : "",
100106 query.text,
101107 ]
102108 .filter((p) => p !== "")
@@ -137,7 +143,10 @@ export async function explore(
137143 now = Date.now(),
138144 ): Promise<ExploreRow[]> {
139145 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 });
141150 const records = appsForRepos(
142151 db,
143152 repos.map((r) => r.id),
src/core/asked.ts +4 −1
@@ -117,7 +117,10 @@ export function askedRepos(
117117 viewer: User | undefined,
118118 repoSpec: string | null | undefined,
119119 ): readonly Repo[] {
120 const writable = listReposWritableTo(db, viewer, { limit: 200 });
120 const writable = listReposWritableTo(db, viewer, {
121 limit: 200,
122 withoutArchived: true,
123 });
121124 if (repoSpec === null || repoSpec === undefined || repoSpec === "")
122125 return writable;
123126 const [owner, name, ...rest] = repoSpec.split("/");
src/core/repos.ts +4
@@ -649,6 +649,8 @@ export interface ListReposOptions {
649649 cursor?: string;
650650 /** Repositories that run an app first, each half newest first (not with `cursor`). */
651651 appsFirst?: boolean;
652 /** Leave archived repositories out. @default false */
653 withoutArchived?: boolean;
652654 }
653655
654656 // Visibility in SQL: public, owned, or owned by an org the viewer is on a
@@ -679,6 +681,7 @@ export function listReposVisibleTo(
679681 params.push(options.cursor);
680682 where += ` AND repo.id < ?${params.length}`;
681683 }
684 if (options.withoutArchived === true) where += " AND repo.archived_at IS NULL";
682685 params.push(pageLimit(options.limit));
683686 const rows = db
684687 .query<RepoRow, (string | number)[]>(
@@ -713,6 +716,7 @@ export function listReposWritableTo(
713716 params.push(options.cursor);
714717 where += ` AND repo.id < ?${params.length}`;
715718 }
719 if (options.withoutArchived === true) where += " AND repo.archived_at IS NULL";
716720 params.push(pageLimit(options.limit));
717721 const rows = db
718722 .query<RepoRow, (string | number)[]>(
src/flow/stalled.ts +4 −1
@@ -49,7 +49,10 @@ interface RunRow {
4949 }
5050
5151 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 });
5356 if (repos.length === 0) return [];
5457 const byId = new Map(repos.map((r) => [r.id, r]));
5558 const ids = [...byId.keys()];
src/web/studio/asks.ts +12 −8
@@ -90,7 +90,7 @@ function appsFiltered(
9090 export function appsOf(ctx: Ctx, user: User | undefined) {
9191 return appsFiltered(
9292 ctx,
93 listReposVisibleTo(ctx.app.db, user, { limit: 200 }),
93 listReposVisibleTo(ctx.app.db, user, { limit: 200, withoutArchived: true }),
9494 );
9595 }
9696
@@ -101,7 +101,10 @@ export function appsOf(ctx: Ctx, user: User | undefined) {
101101 export function writableAppsOf(ctx: Ctx, user: User | undefined) {
102102 return appsFiltered(
103103 ctx,
104 listReposWritableTo(ctx.app.db, user, { limit: 200 }),
104 listReposWritableTo(ctx.app.db, user, {
105 limit: 200,
106 withoutArchived: true,
107 }),
105108 );
106109 }
107110
@@ -512,7 +515,8 @@ function askCandidatesFor(
512515 );
513516 for (const issue of mine) {
514517 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;
516520 const pull = joinedMine.get(issue.id);
517521 if (issue.kind === "pull" && pull === undefined) continue;
518522 out.push({
@@ -766,7 +770,7 @@ function scoredFor(
766770
767771 /** The waves the viewer put aside with Later, oldest first, for the line that brings them back. */
768772 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 });
770774 const mine = ownerFactRepoIds(db, repos, user);
771775 return proposalsFor(db, repos.filter((r) => mine.has(r.id)).map((r) => r.id), "later");
772776 }
@@ -892,7 +896,7 @@ export function recordAskLedger(db: Database, now = new Date()): void {
892896 for (const { id } of people) {
893897 const user = getUserById(db, id);
894898 if (!user) continue;
895 const repos = listReposVisibleTo(db, user, { limit: 200 });
899 const repos = listReposVisibleTo(db, user, { limit: 200, withoutArchived: true });
896900 const all = scoredFor(db, user, repos, now.getTime());
897901 const shown = notDismissed(db, user, all);
898902 const brief = (a: Scored) => ({ id: a.id, repoId: a.repo.id, kind: a.kind });
@@ -992,7 +996,7 @@ export function askLinesFor(
992996 /** What the row waits for (the weightiest ask's), and on whom. */
993997 waitingFor: WaitingFor;
994998 }[] {
995 const repos = listReposVisibleTo(db, user, { limit: 200 });
999 const repos = listReposVisibleTo(db, user, { limit: 200, withoutArchived: true });
9961000 return askRows(waitingOn(db, user, repos, chips)).map((row) => {
9971001 const base = `/${row.repo.ownerName}/${row.repo.name}`;
9981002 const about = {
@@ -1353,7 +1357,7 @@ export function forgeNeedsFor(ctx: Ctx, user: User | undefined): Degraded[] {
13531357 */
13541358 export function askCountFor(ctx: Ctx, user: User | undefined): number {
13551359 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 });
13571361 return (
13581362 askRows(waitingOn(ctx.app.db, user, repos)).length +
13591363 forgeNeedsFor(ctx, user).length
@@ -1440,7 +1444,7 @@ export function askTargetsFor(
14401444 items: readonly string[],
14411445 ): { issueIds: string[]; refused: { item: string; reason: string }[] } {
14421446 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(
14441448 (a) => [a.id, a],
14451449 ),
14461450 );
src/web/studio/asks_page.ts +1 −1
@@ -387,7 +387,7 @@ export function needsYouSection(
387387 const picked = top?.picked ?? new Set<string>();
388388 const decisions = decisionsFor(
389389 ctx,
390 listReposVisibleTo(ctx.app.db, user, { limit: 200 }),
390 listReposVisibleTo(ctx.app.db, user, { limit: 200, withoutArchived: true }),
391391 );
392392 const needs = forgeNeedsFor(ctx, user);
393393 const rows = askRows(decisions);
src/web/studio/explore.ts +6
@@ -110,6 +110,12 @@ export async function explorePage(ctx: Ctx): Promise<Response> {
110110 current: query.sort === s.sort,
111111 })),
112112 )}
113 <a
114 class="btn btn-sm"
115 href="${href({ ...query, archived: !query.archived })}"
116 aria-pressed="${query.archived ? "true" : "false"}"

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.

117 >Show archived</a
118 >
113119 </div>
114120 ${rows.length === 0
115121 ? 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", () => {
3030 kind: "app",
3131 sort: "forks",
3232 text: "garden",
33 archived: false,
3334 });
3435 expect(parseExploreQuery("kind:people q:dungeon")).toEqual({
3536 kind: "person",
3637 sort: "score",
3738 text: "dungeon",
39 archived: false,
3840 });
3941 expect(parseExploreQuery("kind:nonsense sort:nonsense")).toEqual({
4042 kind: "all",
4143 sort: "score",
4244 text: "sort:nonsense",
45 archived: false,
4346 });
47 expect(parseExploreQuery("archived:yes").archived).toBe(true);
48 expect(exploreLine(parseExploreQuery("archived:yes kind:app"))).toBe(
49 "kind:app archived:yes",
50 );
4451 expect(exploreLine(parseExploreQuery(" sort:ships kind:app "))).toBe(
4552 "kind:app sort:ships",
4653 );

↑↓ navigate ↵ open ⌘↵ its app esc close

Keyboard shortcuts

Docs