Public: anyone can read this; the crew can change it.

Archive, part 1: an admin archives or wakes one repository from Settings and the API, and its page says "archived on <date>" with a Wake button #1940

Merged opened this

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

Merged

scout/forge:scout/1838-archive-part-1-an-admin-archives-or-wake → main 16 commits fork 9 threads · 9 unresolved

  • Merged. scout/1838-archive-part-1-an-admin-archives-or-wake was merged into main as 1bc0db5.

Review

Sign in to review this change.
13 files changed +500 −15
README.md +10
@@ -212,6 +212,16 @@ taking a release is not done here. This forge publishes its own
212212 `/releases.json` (the version names, each linking to that document), so any
213213 forge can be a parent. The terminal shows the count, not the release names.
214214
215 ## Archive
216
217 An admin puts a repository away from **Settings → Archive**, with
218 `PATCH /api/v1/repos/:owner/:name {"archived":true|false}` (admins only; anyone
219 else gets 403), or in the terminal with `a` on the About card (`I`). The
220 repository page and the card then say when it was archived, and an admin gets
221 a Wake button or the same key to undo it. Archiving twice keeps the first date.
222 It does not yet block anything: clone, push and links keep working, and the
223 repository stays in every list.
224
215225 ## Health
216226
217227 `GET /api/v1/health` (and the MCP `health` tool) answer a site admin with
scripts/gate.ts +3
@@ -27,6 +27,9 @@ export type Exclusion = {
2727 };
2828
2929 export const GATE: readonly string[] = [
30 "test/archive.test.ts",
31 "test/tui-archive.test.ts",
32 "test/archive-agent.test.ts",
3033 "test/waves-page.test.ts",
3134 "test/waves-order.test.ts",
3235 "test/domain-drill.test.ts",
src/actions/repos.ts +8 −4
@@ -245,12 +245,14 @@ export async function updateRepoSettings(
245245 ): Promise<Result<Repo, UpdateSettingsError>> {
246246 const admin = requireRepoAdmin(app, actor, repo);
247247 if (!admin.ok) return admin;
248 // Who the crew obeys is a person's call: an agent never widens it, even
249 // one on a team that administers the repository (`crew/direct.ts`).
248 // Who the crew obeys, and whether the repository is archived, are a
249 // person's call: an agent never changes them, even one on a team that
250 // administers the repository (`crew/direct.ts`).
250251 if (
251252 (settings.crewOutsiders !== undefined ||
252253 settings.decisionsBy !== undefined ||
253 settings.plansNeedNod !== undefined) &&
254 settings.plansNeedNod !== undefined ||
255 settings.archived !== undefined) &&
254256 admin.value.kind === "agent"
255257 )
256258 return err("not_found");
@@ -267,7 +269,7 @@ export async function updateRepoSettings(
267269 if (!moved.ok) return err("unknown_branch");
268270 }
269271 const updated = writeSettings(app.db, repo, settings);
270 const changed = (
272 const changed: string[] = (
271273 [
272274 "visibility",
273275 "description",
@@ -280,6 +282,8 @@ export async function updateRepoSettings(
280282 "plansNeedNod",
281283 ] as const
outdated

Add settings.archived !== undefined to the agent refusal above (crewOutsiders, decisionsBy, plansNeedNod): archiving is a person's call, and it should be decided here once for the API, the form and the terminal. Add a test where an admin agent is refused.

282284 ).filter((k) => settings[k] !== undefined && settings[k] !== repo[k]);
285 if (settings.archived !== undefined && settings.archived !== (repo.archivedAt !== null))
286 changed.push("archived");
283287 app.bus.append({
284288 kind: "repo.settings_updated",
285289 repoId: repo.id,
src/core/migrations/20261015090000-a-repository-put-away.ts added +5
@@ -0,0 +1,5 @@
1 // Archive (#1838): when an admin put a repository away. Null is the
2 // ordinary state; nothing is deleted and clone and push go on working.
3 export default `
4 ALTER TABLE repo ADD COLUMN archived_at TEXT;
5 `;
src/core/repos.ts +12 −1
@@ -130,6 +130,8 @@ export interface Repo {
130130 * read only by the base's writers and crew. Null for every other one.
131131 */
132132 readonly sealedFor: string | null;
133 /** When an admin archived it (ISO time), or null: put away, not deleted. */
134 readonly archivedAt: string | null;
133135 /** The origin this mirrors, or null for a native repo. */
134136 readonly origin: RepoOrigin | null;
135137 /**
@@ -169,6 +171,7 @@ interface RepoRow {
169171 direction_issue_id: string | null;
170172 fork_parent_id: string | null;
171173 sealed_for: string | null;
174 archived_at: string | null;
172175 origin_id: string | null;
173176 origin_kind: "github" | "forgejo" | "gitea" | null;
174177 origin_full_name: string | null;
@@ -208,6 +211,7 @@ function toRepo(row: RepoRow): Repo {
208211 directionIssueId: row.direction_issue_id,
209212 forkParentId: row.fork_parent_id,
210213 sealedFor: row.sealed_for,
214 archivedAt: row.archived_at,
211215 origin:
212216 row.origin_id !== null && row.origin_kind !== null
213217 ? {
@@ -1040,6 +1044,8 @@ export interface RepoSettings {
10401044 /** Whether someone who cannot write here may start or steer a run. */
10411045 crewOutsiders?: boolean;
10421046 decisionsBy?: DecisionsBy;
1047 /** Archive (true) or wake (false); the time is stamped here. */
1048 archived?: boolean;
10431049 plansNeedNod?: boolean;
10441050 }
10451051
@@ -1054,7 +1060,7 @@ export function updateRepoSettings(
10541060 default_branch = ?4, require_review = ?5, require_checks = ?7,
10551061 autonomy = ?8, autonomy_direction = ?9, autonomy_ceiling_usd = ?10,
10561062 autonomy_slots = ?11, autonomy_by = ?12, crew_outsiders = ?13, decisions_by = ?14,
1057 plans_need_nod = ?15
1063 archived_at = ?15, plans_need_nod = ?16
10581064 WHERE id = ?6`,
10591065 ).run(
10601066 settings.visibility ?? repo.visibility,
@@ -1071,6 +1077,11 @@ export function updateRepoSettings(
10711077 settings.autonomyBy === undefined ? repo.autonomyBy : settings.autonomyBy,
10721078 (settings.crewOutsiders ?? repo.crewOutsiders) ? 1 : 0,
10731079 settings.decisionsBy ?? repo.decisionsBy,
1080 settings.archived === undefined
1081 ? repo.archivedAt
1082 : settings.archived
1083 ? (repo.archivedAt ?? new Date().toISOString())
1084 : null,
10741085 (settings.plansNeedNod ?? repo.plansNeedNod) ? 1 : 0,
10751086 );
10761087 return getRepoById(db, repo.id)!;
src/http/api.ts +9
@@ -1817,6 +1817,11 @@ export function apiRoutes(router: Router<Handler>): void {
18171817 const repo = loadRepo(ctx);
18181818 if (!repo) return notFound();
18191819 const input = await body(ctx.request);
1820 // The caller can already read this repository, so saying no is no leak.
1821 if (input.archived !== undefined && !canAdmin(ctx.app.db, repo, ctx.auth.user))
1822 return apiError("forbidden", 403, "only an admin archives or wakes");
1823 if (input.archived !== undefined && typeof input.archived !== "boolean")
1824 return apiError("bad_archived", 422, "archived is true or false");
18201825 if (input.decisionsBy !== undefined && !isDecisionsBy(input.decisionsBy))
18211826 return apiError(
18221827 "bad_decisions_by",
@@ -1852,6 +1857,10 @@ export function apiRoutes(router: Router<Handler>): void {
18521857 ...(isDecisionsBy(input.decisionsBy)
18531858 ? { decisionsBy: input.decisionsBy }
18541859 : {}),
1860 // Archive or wake (#1838): any admin's call, and only a real boolean.
1861 ...(typeof input.archived === "boolean"
1862 ? { archived: input.archived }
1863 : {}),
18551864 // Whether a plan waits for a nod: an admin's, never an agent's.
18561865 ...(typeof input.plansNeedNod === "boolean"
18571866 ? { plansNeedNod: input.plansNeedNod }
src/http/server.ts +2
@@ -338,6 +338,7 @@ import {
338338 settingsPage,
339339 settingsUpdate,
340340 waveCut,
341 archiveSubmit,
341342 webhookCreate,
342343 webhookDelete,
343344 } from "../web/pages/settings.ts";
@@ -1175,6 +1176,7 @@ export function buildRouter(): Router<Handler> {
11751176 router.add("GET", "/:owner/:name/settings", settingsPage);
11761177 router.add("POST", "/:owner/:name/settings", settingsUpdate);
11771178 router.add("POST", "/:owner/:name/settings/wave", waveCut);
1179 router.add("POST", "/:owner/:name/settings/archive", archiveSubmit);
11781180 router.add("GET", "/:owner/:name/settings/app", appSettingsPage);
11791181 router.add("POST", "/:owner/:name/settings/app", appSettingsUpdate);
11801182 router.add("POST", "/:owner/:name/settings/app/disable", appDisable);
src/tui/screens/about.ts +46 −7
@@ -18,7 +18,8 @@
1818 import { isPinned } from "../../core/pins.ts";
1919 import { parentReleases, releasesSentence } from "../../core/parent_releases.ts";
2020 import { running } from "../../kernel/version.ts";
21 import type { Repo } from "../../core/repos.ts";
21 import { updateRepoSettings } from "../../actions/repos.ts";
22 import { canAdmin, getRepoById, type Repo } from "../../core/repos.ts";
2223 import type { RepoFacts, RepoSource } from "../../view/source.ts";
2324 import type { Ctx, Screen } from "../app/app.ts";
2425 import { binding } from "../app/keys.ts";
@@ -43,15 +44,33 @@ export function aboutScreen(
4344 facts: RepoFacts | undefined,
4445 ref: string,
4546 ): Screen {
46 const repo = localRepo(src);
47 const rows = aboutFacts(f, src, facts, ref);
47 // Archiving changes the row under this card, so read it at use.
outdated

held is never read again; write let repo = localRepo(src);.

48 let repo = localRepo(src);
49 const may = (): boolean =>
50 repo !== undefined && f.user !== undefined && canAdmin(f.db, repo, f.user);
51 const factRows = (): [string, string][] => [
52 ...archivedRow(repo),
53 ...aboutFacts(f, src, facts, ref),
outdated

Here only a person may archive, but PATCH lets an admin agent do it, since updateRepoSettings refuses agents only for crewOutsiders/decisionsBy. That is fine for a marker. Settle one rule before archiving starts to hide or spin anything down.

outdated

Only the terminal refuses an agent admin here; PATCH and the Settings form accept one. Refuse agents once, in actions/repos.ts next to plansNeedNod, and make may() just canAdmin, so every door gates the same way.

54 ];
55 let rows = factRows();
4856 const cloneUrl = rows.find(([label]) => label.startsWith("clone"))?.[1] ?? "";
4957 return {
5058 title: "About",
51 keys: [
52 binding(["y"], "copy the clone URL", { disabled: cloneUrl === "" }),
53 binding(["esc"], "close"),
54 ],
59 get keys() {
60 return [
61 binding(["y"], "copy the clone URL", { disabled: cloneUrl === "" }),
62 // Only an admin is offered the key, and only an admin's press counts.
63 ...(may()
64 ? [
outdated

Nit: the body of the get keys() getter lost its indentation. Run the formatter over this block.

65 binding(
66 ["a"],
67 repo?.archivedAt === null ? "archive" : "wake",
68 ),
69 ]
70 : []),
71 binding(["esc"], "close"),
72 ];
73 },
5574 render(area, s) {
5675 parent.render(area, s);
5776 const c = ctx.theme.c;
@@ -128,6 +147,20 @@ export function aboutScreen(
128147 ctx.toast("copied the clone URL", "success");
129148 return true;
130149 }
150 if (e.key === "a" && repo !== undefined && may()) {
151 const waking = repo.archivedAt !== null;
152 void updateRepoSettings(f.app, f.user, repo, {

Minor, not blocking: this promise has no rejection handler. If updateRepoSettings throws, for example on a DB error, there's no toast and the rejection goes unhandled. A .catch(() => ctx.toast("that could not be changed", "danger")) would cover it.

153 archived: !waking,
154 }).then((done) => {
155 if (done.ok) {
156 repo = getRepoById(f.db, done.value.id) ?? done.value;
157 rows = factRows();
158 ctx.toast(waking ? "awake again" : "archived", "success");
159 } else ctx.toast("that could not be changed", "danger");
160 ctx.invalidate();
161 });
162 return true;
163 }
131164 if (e.key === "esc" || e.key === "q" || e.key === "I") {
132165 ctx.pop();
133166 return true;
@@ -137,6 +170,12 @@ export function aboutScreen(
137170 };
138171 }
139172
173 /** One line when an admin has put the repository away. */
174 function archivedRow(repo: Repo | undefined): [string, string][] {
175 if (repo === undefined || repo.archivedAt === null) return [];
176 return [["archived", repo.archivedAt.slice(0, 10)]];
177 }
178
140179 /**
141180 * The web following card's releases sentence, for a fork on a forge that
142181 * names a parent forge: the same words, from the same cache.
src/web/pages/repo.ts +24 −2
@@ -920,6 +920,28 @@ export async function repoFiles(ctx: Ctx): Promise<Response> {
920920 return await codeHome(ctx, repo);
921921 }
922922
923 /**
924 * "Archived on <date>" with, for an admin, the Wake button. A repository put
925 * away still clones, pushes and links as before; this only says so.
926 */
927 function archivedLine(ctx: Ctx, repo: Repo): Html | null {
928 if (repo.archivedAt === null) return null;
929 return html`<div class="mt archived">
930 <p>
931 Archived on
932 <time datetime="${repo.archivedAt}">${repo.archivedAt.slice(0, 10)}</time>
933 </p>
934 ${when(
935 canAdmin(ctx.app.db, repo, ctx.auth.user),
936 () => html`<form method="post" action="${repoBase(repo)}/settings/archive">
937 <input type="hidden" name="archived" value="0" />
938 <input type="hidden" name="to" value="repo" />
939 ${btn("Wake", { size: "sm" })}
940 </form>`,
941 )}
942 </div>`;
outdated

Nit: missing ; after the template literal.

943 }
944
923945 /** The code at a ref: the tree, the README and the About rail — with, in
924946 * the studio, the offer to make a repository that is not an app one. */
925947 async function codeHome(ctx: Ctx, repo: Repo): Promise<Response> {
@@ -930,7 +952,7 @@ async function codeHome(ctx: Ctx, repo: Repo): Promise<Response> {
930952 const refs = await refSetFrom(scope.source);
931953
932954 if (refs.branches.length === 0) {
933 const body = html`${scopeHeader(ctx, scope, "code")}${noApp}
955 const body = html`${scopeHeader(ctx, scope, "code")}${archivedLine(ctx, repo)}${noApp}
934956 <div class="mt">
935957 ${empty({
936958 icon: "repo",
@@ -989,7 +1011,7 @@ async function codeHome(ctx: Ctx, repo: Repo): Promise<Response> {
9891011 const following = await followingOf(ctx, repo);
9901012 const body = html`${scopeHeader(ctx, scope, "code", {
9911013 branchCount: refs.branches.length,
992 })}${noApp}
1014 })}${archivedLine(ctx, repo)}${noApp}
9931015 ${frozenBlock(ctx, repo)} ${appStrip(ctx, repo)}
9941016 ${following === undefined
9951017 ? forkBar(ctx, repo)
src/web/pages/settings.ts +45 −1
@@ -321,12 +321,28 @@ export async function settingsPage(ctx: Ctx): Promise<Response> {
321321 </form>`,
322322 { head: html`${icon("rocket")}<h2>Cut a wave</h2>` },
323323 );
324 const archive = card(
325 html`<form method="post" action="${base}/settings/archive">
326 <input type="hidden" name="archived" value="${repo.archivedAt === null ? "1" : "0"}" />
327 <p class="muted">
328 ${repo.archivedAt === null
329 ? "Put a finished repository away without deleting it. Its page says when it was archived; links, clone and push keep working."
330 : html`Archived on ${repo.archivedAt.slice(0, 10)}. Wake it to take the label off.`}
331 </p>
332 <div class="form-actions">
333 ${btn(repo.archivedAt === null ? "Archive" : "Wake", {
334 variant: "primary",
335 })}
336 </div>
337 </form>`,
outdated

alert is the danger zone's icon. Archiving is reversible and calm, so use a neutral icon (no red noise).

338 { head: html`${icon("folder")}<h2>Archive</h2>` },
339 );
324340 return settingsPageResponse(
325341 ctx,
326342 repo,
327343 "general",
328344 "Settings",
329 html`${lead}<div class="mt">${body}</div><div class="mt">${wave}</div>${secrets
345 html`${lead}<div class="mt">${body}</div><div class="mt">${wave}</div><div class="mt">${archive}</div>${secrets
330346 ? html`<div class="mt">${secrets}</div>`
331347 : null}`,
332348 );
@@ -382,6 +398,34 @@ export async function settingsUpdate(ctx: Ctx): Promise<Response> {
382398 return flashTo(ctx, `${base}/settings`, flash);
383399 }
384400
401 /**
402 * POST /:owner/:name/settings/archive — archive or wake. The state asked
403 * for travels in the form, so a double submit is idempotent.
404 */
405 export async function archiveSubmit(ctx: Ctx): Promise<Response> {
406 const gate = await adminForm(ctx);
407 if (gate instanceof Response) return gate;
408 const { repo, form } = gate;
409 const asked = field(form, "archived");
410 if (asked !== "0" && asked !== "1")
411 return flashTo(ctx, `${repoBase(repo)}/settings`, {
412 tone: "danger",
413 message: "That could not be changed.",
414 });
415 const archived = asked === "1";
416 const updated = await updateRepoSettings(ctx.app, ctx.auth.user, repo, {
417 archived,
418 });
419 const back = field(form, "to") === "repo" ? repoBase(repo) : `${repoBase(repo)}/settings`;
420 return flashTo(
421 ctx,
422 back,
423 updated.ok
424 ? { message: archived ? "Archived." : "Awake again." }
425 : { tone: "danger", message: "That could not be changed." },
426 );
427 }
428
385429 /** POST /:owner/:name/settings/wave — open the draft of the next wave. */
386430 export async function waveCut(ctx: Ctx): Promise<Response> {
387431 const gate = await adminForm(ctx);
test/archive-agent.test.ts added +74
@@ -0,0 +1,74 @@
1 /** Archiving is a person's call: an admin agent's PATCH is refused, and nothing changes. */
2 import { afterAll, beforeAll, expect, test } from "bun:test";
3 import { mkdtemp, rm } from "node:fs/promises";
4 import { tmpdir } from "node:os";
5 import { join } from "node:path";
6 import { createApp, type App } from "../src/app.ts";
7 import { addToCrew, createAgent } from "../src/actions/crew.ts";
8 import { addTeamMember, createOrg, listTeams, OWNERS_TEAM } from "../src/core/orgs.ts";
9 import { createRepo, getRepoById, type Repo } from "../src/core/repos.ts";
10 import { createToken } from "../src/core/tokens.ts";
11 import { createUser } from "../src/core/users.ts";
12 import { serve, type Servers } from "../src/http/server.ts";
13 import { quietLogs } from "./helpers/globals.ts";
14
15 quietLogs();
16
17 let dataDir: string;
18 let app: App;
19 let servers: Servers;
20 let repo: Repo;
21 let botToken = "";
22 let founderToken = "";
23
24 const ok = <T>(r: { ok: true; value: T } | { ok: false; error: unknown }): T => {
25 if (!r.ok) throw new Error(JSON.stringify(r.error));
26 return r.value;
27 };
28 const patch = (token: string, archived: boolean) =>
29 fetch(`${servers.url}/api/v1/repos/acme/svc`, {
30 method: "PATCH",
31 headers: { authorization: `Bearer ${token}`, "content-type": "application/json" },
32 body: JSON.stringify({ archived }),
33 });
34
35 beforeAll(async () => {
36 dataDir = await mkdtemp(join(tmpdir(), "forge-archive-agent-"));
37 app = createApp({ dataDir, port: 0 });
38 servers = await serve(app);
39 const founder = ok(
40 await createUser(app.db, {
41 trusted: true,
42 username: "founder",
43 email: "[email protected]",
44 password: "a-good-passphrase",
45 adminIfFirst: true,
46 }),
47 );
48 const org = ok(createOrg(app.db, "acme", founder.id));
49 repo = ok(
50 await createRepo(app.db, app.reposRoot, { ownerId: org.id, name: "svc", visibility: "public" }),
51 );
52 const bot = ok(await createAgent(app, founder, { username: "bot" }));
53 expect(addToCrew(app, founder, repo, "bot").ok).toBe(true);
54 const owners = listTeams(app.db, org.id).find((t) => t.name === OWNERS_TEAM)!;
55 addTeamMember(app.db, owners, bot.user.id);
56 botToken = createToken(app.db, bot.user.id, "admin", "admin").token;
57 founderToken = createToken(app.db, founder.id, "admin", "admin").token;
58 }, 30_000);
59
60 afterAll(async () => {
61 servers?.stop();
62 app?.close();
63 await rm(dataDir, { recursive: true, force: true });
64 });
65
66 test("an admin agent cannot archive, a person can", async () => {
67 const refused = await patch(botToken, true);
68 expect(refused.status).toBeGreaterThanOrEqual(400);
69 expect(getRepoById(app.db, repo.id)!.archivedAt).toBeNull();
70 expect((await patch(founderToken, true)).status).toBe(200);
71 expect(getRepoById(app.db, repo.id)!.archivedAt).not.toBeNull();
72 expect((await patch(botToken, false)).status).toBeGreaterThanOrEqual(400);
73 expect(getRepoById(app.db, repo.id)!.archivedAt).not.toBeNull();
74 });
test/archive.test.ts added +148
@@ -0,0 +1,148 @@
1 import { afterAll, beforeAll, describe, expect, test } from "bun:test";
2 import { mkdtemp, rm } from "node:fs/promises";
3 import { tmpdir } from "node:os";
4 import { join } from "node:path";
5 import { createApp, type App } from "../src/app.ts";
6 import { getRepo } from "../src/core/repos.ts";
7 import { serve, type Servers } from "../src/http/server.ts";
8
9 type Row = { archivedAt: string | null };
10
11 describe("archive and wake", () => {
12 let dataDir: string;
13 let app: App;
14 let servers: Servers;
15 let base: string;
16 let owner = "";
17 let stranger = "";
18
19 const form = (fields: Record<string, string>) => new URLSearchParams(fields);
20
21 async function signup(username: string): Promise<string> {
22 const res = await fetch(`${base}/signup`, {
23 method: "POST",
24 body: form({
25 username,
26 email: `${username}@f.dev`,
27 password: "archive-password",
28 }),
29 redirect: "manual",
30 });
31 return (res.headers.get("set-cookie") ?? "").split(";")[0]!;
32 }
33
34 const patch = (cookie: string, archived: boolean) =>
35 fetch(`${base}/api/v1/repos/ada/old`, {
36 method: "PATCH",
37 headers: { cookie, "content-type": "application/json", origin: base },
38 body: JSON.stringify({ archived }),
39 });
40
41 beforeAll(async () => {
42 dataDir = await mkdtemp(join(tmpdir(), "forge-archive-"));
43 app = createApp({ dataDir, port: 0 });
44 servers = await serve(app);
45 base = servers.url;
46 owner = await signup("ada");
47 stranger = await signup("bob");
48 await fetch(`${base}/new`, {
49 method: "POST",
50 headers: { cookie: owner },
51 body: form({ name: "old", visibility: "public", description: "done" }),
52 redirect: "manual",
53 });
54 });
55
56 afterAll(async () => {
57 servers.stop();
58 app.close();
59 await rm(dataDir, { recursive: true, force: true });
60 });
61
62 test("a repository starts unarchived, and the JSON says so", async () => {
63 const res = await fetch(`${base}/api/v1/repos/ada/old`, {
64 headers: { cookie: owner },
65 });
66 expect(((await res.json()) as Row).archivedAt).toBeNull();
67 });
68
69 test("a non-boolean archived is refused with 422 and changes nothing", async () => {
70 for (const archived of ["yes", 1, null]) {
71 const res = await fetch(`${base}/api/v1/repos/ada/old`, {
72 method: "PATCH",
73 headers: { cookie: owner, "content-type": "application/json", origin: base },
74 body: JSON.stringify({ archived }),
75 });
76 expect(res.status).toBe(422);
77 expect(((await res.json()) as { error: string }).error).toBe("bad_archived");
78 }
79 const res = await fetch(`${base}/api/v1/repos/ada/old`, {
80 headers: { cookie: owner },
81 });
82 expect(((await res.json()) as Row).archivedAt).toBeNull();
83 });
84
85 test("the API archives and wakes it", async () => {
86 const archived = await patch(owner, true);
87 expect(archived.status).toBe(200);
88 const stamp = ((await archived.json()) as Row).archivedAt;
89 expect(Date.parse(stamp ?? "")).not.toBeNaN();
90 expect(getRepo(app.db, "ada", "old")?.archivedAt).toBe(stamp);
91
92 // Archiving again keeps the first date.
93 expect(((await (await patch(owner, true)).json()) as Row).archivedAt).toBe(stamp);
94
95 const woke = await patch(owner, false);
96 expect(((await woke.json()) as Row).archivedAt).toBeNull();
97 expect(getRepo(app.db, "ada", "old")?.archivedAt).toBeNull();
98 });
99
100 test("a non-admin is refused with 403 and nothing changes", async () => {
101 const res = await patch(stranger, true);
102 expect(res.status).toBe(403);
103 expect(getRepo(app.db, "ada", "old")?.archivedAt).toBeNull();
104 });
105
106 test("the Settings form archives, the page says so, and Wake clears it", async () => {
107 const post = (cookie: string, fields: Record<string, string>, origin = base) =>
108 fetch(`${base}/ada/old/settings/archive`, {
109 method: "POST",
110 headers: { cookie, origin },
111 body: form(fields),
112 redirect: "manual",
113 });
114
115 // Cross-origin: refused, unchanged.
116 const cross = await post(owner, { archived: "1" }, "http://evil.example");
117 expect(cross.status).toBe(403);
118 expect(getRepo(app.db, "ada", "old")?.archivedAt).toBeNull();
119
120 // A stranger's form is not an admin's.
121 expect((await post(stranger, { archived: "1" })).status).toBeGreaterThanOrEqual(400);
122 expect(getRepo(app.db, "ada", "old")?.archivedAt).toBeNull();
123
124 expect((await post(owner, { archived: "1" })).status).toBe(303);
125 const at = getRepo(app.db, "ada", "old")!.archivedAt!;
126 const day = at.slice(0, 10);
127
128 const asOwner = await (
129 await fetch(`${base}/ada/old`, { headers: { cookie: owner } })
130 ).text();
131 expect(asOwner).toContain(`Archived on`);
132 expect(asOwner).toContain(day);
133 expect(asOwner).toContain("Wake");
134
135 const asStranger = await (
136 await fetch(`${base}/ada/old`, { headers: { cookie: stranger } })
137 ).text();
138 expect(asStranger).toContain("Archived on");
139 expect(asStranger).not.toContain("/settings/archive");
140
141 expect((await post(owner, { archived: "0", to: "repo" })).status).toBe(303);
142 expect(getRepo(app.db, "ada", "old")?.archivedAt).toBeNull();
143 const woken = await (
144 await fetch(`${base}/ada/old`, { headers: { cookie: owner } })
145 ).text();
146 expect(woken).not.toContain("Archived on");
147 });
148 });
test/tui-archive.test.ts added +114
@@ -0,0 +1,114 @@
1 /** Archive in the terminal (#1838): About shows it, an admin's `a` flips it. */
2
3 import { afterAll, beforeAll, describe, expect, test } from "bun:test";
4 import { mkdtemp, rm } from "node:fs/promises";
5 import { tmpdir } from "node:os";
6 import { join } from "node:path";
7 import { updateRepoSettings } from "../src/actions/repos.ts";
8 import { createRepo } from "../src/actions/repos.ts";
9 import { signUp } from "../src/actions/users.ts";
10 import { createApp, type App as Forge } from "../src/app.ts";
11 import { getRepo, type Repo } from "../src/core/repos.ts";
12 import type { User } from "../src/core/users.ts";
13 import { createForgeApp } from "../src/tui/forge.ts";
14 import type { TuiIdentity } from "../src/tui/session.ts";
15 import { headlessTerminal } from "../src/tui/term/terminal.ts";
16 import { quietLogs } from "./helpers/globals.ts";
17 import { settleApp, settleUntil } from "./helpers/tui.ts";
18
19 quietLogs();
20
21 describe("archive in the terminal", () => {
22 let dataDir: string;
23 let forge: Forge;
24 let alice: User;
25 let repo: Repo;
26
27 function boot(identity: TuiIdentity) {
28 const terminal = headlessTerminal(120, 40);
29 const { app } = createForgeApp(forge, identity, {
30 terminal,
31 exitHooks: false,
32 });
33 app.start();
34 const press = async (...keys: string[]): Promise<void> => {
35 for (const key of keys)
36 app.dispatch({ type: "key", key, ctrl: false, alt: false, shift: false });
37 await settleApp(app);
38 };
39 return { app, press, text: () => app.screen.toText() };
40 }
41
42 async function openAbout(h: ReturnType<typeof boot>): Promise<void> {
43 await settleApp(h.app);
44 await h.press("/");
45 for (const ch of "beacon") await h.press(ch);
46 await h.press("enter");
47 await h.press("enter");
48 await settleUntil(h.app, "alice/beacon");
49 await h.press("I");
50 await settleUntil(h.app, "clone (http)");
51 }
52
53 beforeAll(async () => {
54 dataDir = await mkdtemp(join(tmpdir(), "forge-tui-archive-"));
55 forge = createApp({ dataDir, port: 0 });
56 const a = await signUp(forge, {
57 username: "alice",
58 email: "[email protected]",
59 password: "a-long-passphrase",
60 });
61 if (!a.ok) throw new Error("signup failed");
62 alice = a.value;
63 const made = await createRepo(forge, alice, {
64 name: "beacon",
65 visibility: "public",
66 });
67 if (!made.ok) throw new Error("repo failed");
68 repo = made.value;
69 });
70
71 afterAll(async () => {
72 await rm(dataDir, { recursive: true, force: true });
73 });
74
75 test("an admin archives and wakes from About with `a`", async () => {
76 const h = boot({
77 sshUsername: "alice",
78 remoteAddress: "127.0.0.1",
79 fingerprint: "SHA256:test",
80 user: alice,
81 });
82 try {
83 await openAbout(h);
84 expect(h.text()).not.toContain("archived");
85 await h.press("a");
86 expect(getRepo(forge.db, "alice", "beacon")?.archivedAt).not.toBeNull();
87 expect(h.text()).toMatch(/archived +20\d\d-/);
88 await h.press("a");
89 expect(getRepo(forge.db, "alice", "beacon")?.archivedAt).toBeNull();
90 expect(h.text()).not.toMatch(/archived +20\d\d-/);
91 } finally {
92 h.app.stop();
93 }
94 });
95
96 test("a guest sees the archived line but `a` does nothing", async () => {
97 const done = await updateRepoSettings(forge, alice, repo, { archived: true });
98 expect(done.ok).toBe(true);
99 const h = boot({
100 sshUsername: "git",
101 remoteAddress: "127.0.0.1",
102 fingerprint: "SHA256:unknown",
103 });
104 try {
105 await openAbout(h);
106 expect(h.text()).toMatch(/archived +20\d\d-/);
107 expect(h.text()).not.toMatch(/\ba +(wake|archive)\b/);
108 await h.press("a");
109 expect(getRepo(forge.db, "alice", "beacon")?.archivedAt).not.toBeNull();
110 } finally {
111 h.app.stop();
112 }
113 });
114 });

↑↓ navigate ↵ open ⌘↵ its app esc close

Keyboard shortcuts

Docs