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
Check · Review · Land · Live done — Landed and live in production.
scout/forge:scout/1838-archive-part-1-an-admin-archives-or-wake → main 16 commits fork 9 threads · 9 unresolved
README.md +10
| @@ -212,6 +212,16 @@ taking a release is not done here. This forge publishes its own | ||
| 212 | 212 | `/releases.json` (the version names, each linking to that document), so any |
| 213 | 213 | forge can be a parent. The terminal shows the count, not the release names. |
| 214 | 214 | |
| 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 | ||
| 215 | 225 | ## Health |
| 216 | 226 | |
| 217 | 227 | `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 = { | ||
| 27 | 27 | }; |
| 28 | 28 | |
| 29 | 29 | export const GATE: readonly string[] = [ |
| 30 | "test/archive.test.ts", | |
| 31 | "test/tui-archive.test.ts", | |
| 32 | "test/archive-agent.test.ts", | |
| 30 | 33 | "test/waves-page.test.ts", |
| 31 | 34 | "test/waves-order.test.ts", |
| 32 | 35 | "test/domain-drill.test.ts", |
src/actions/repos.ts +8 −4
| @@ -245,12 +245,14 @@ export async function updateRepoSettings( | ||
| 245 | 245 | ): Promise<Result<Repo, UpdateSettingsError>> { |
| 246 | 246 | const admin = requireRepoAdmin(app, actor, repo); |
| 247 | 247 | 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`). | |
| 250 | 251 | if ( |
| 251 | 252 | (settings.crewOutsiders !== undefined || |
| 252 | 253 | settings.decisionsBy !== undefined || |
| 253 | settings.plansNeedNod !== undefined) && | |
| 254 | settings.plansNeedNod !== undefined || | |
| 255 | settings.archived !== undefined) && | |
| 254 | 256 | admin.value.kind === "agent" |
| 255 | 257 | ) |
| 256 | 258 | return err("not_found"); |
| @@ -267,7 +269,7 @@ export async function updateRepoSettings( | ||
| 267 | 269 | if (!moved.ok) return err("unknown_branch"); |
| 268 | 270 | } |
| 269 | 271 | const updated = writeSettings(app.db, repo, settings); |
| 270 | const changed = ( | |
| 272 | const changed: string[] = ( | |
| 271 | 273 | [ |
| 272 | 274 | "visibility", |
| 273 | 275 | "description", |
| @@ -280,6 +282,8 @@ export async function updateRepoSettings( | ||
| 280 | 282 | "plansNeedNod", |
| 281 | 283 | ] as const |
| 282 | 284 | ).filter((k) => settings[k] !== undefined && settings[k] !== repo[k]); |
| 285 | if (settings.archived !== undefined && settings.archived !== (repo.archivedAt !== null)) | |
| 286 | changed.push("archived"); | |
| 283 | 287 | app.bus.append({ |
| 284 | 288 | kind: "repo.settings_updated", |
| 285 | 289 | 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 { | ||
| 130 | 130 | * read only by the base's writers and crew. Null for every other one. |
| 131 | 131 | */ |
| 132 | 132 | readonly sealedFor: string | null; |
| 133 | /** When an admin archived it (ISO time), or null: put away, not deleted. */ | |
| 134 | readonly archivedAt: string | null; | |
| 133 | 135 | /** The origin this mirrors, or null for a native repo. */ |
| 134 | 136 | readonly origin: RepoOrigin | null; |
| 135 | 137 | /** |
| @@ -169,6 +171,7 @@ interface RepoRow { | ||
| 169 | 171 | direction_issue_id: string | null; |
| 170 | 172 | fork_parent_id: string | null; |
| 171 | 173 | sealed_for: string | null; |
| 174 | archived_at: string | null; | |
| 172 | 175 | origin_id: string | null; |
| 173 | 176 | origin_kind: "github" | "forgejo" | "gitea" | null; |
| 174 | 177 | origin_full_name: string | null; |
| @@ -208,6 +211,7 @@ function toRepo(row: RepoRow): Repo { | ||
| 208 | 211 | directionIssueId: row.direction_issue_id, |
| 209 | 212 | forkParentId: row.fork_parent_id, |
| 210 | 213 | sealedFor: row.sealed_for, |
| 214 | archivedAt: row.archived_at, | |
| 211 | 215 | origin: |
| 212 | 216 | row.origin_id !== null && row.origin_kind !== null |
| 213 | 217 | ? { |
| @@ -1040,6 +1044,8 @@ export interface RepoSettings { | ||
| 1040 | 1044 | /** Whether someone who cannot write here may start or steer a run. */ |
| 1041 | 1045 | crewOutsiders?: boolean; |
| 1042 | 1046 | decisionsBy?: DecisionsBy; |
| 1047 | /** Archive (true) or wake (false); the time is stamped here. */ | |
| 1048 | archived?: boolean; | |
| 1043 | 1049 | plansNeedNod?: boolean; |
| 1044 | 1050 | } |
| 1045 | 1051 | |
| @@ -1054,7 +1060,7 @@ export function updateRepoSettings( | ||
| 1054 | 1060 | default_branch = ?4, require_review = ?5, require_checks = ?7, |
| 1055 | 1061 | autonomy = ?8, autonomy_direction = ?9, autonomy_ceiling_usd = ?10, |
| 1056 | 1062 | 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 | |
| 1058 | 1064 | WHERE id = ?6`, |
| 1059 | 1065 | ).run( |
| 1060 | 1066 | settings.visibility ?? repo.visibility, |
| @@ -1071,6 +1077,11 @@ export function updateRepoSettings( | ||
| 1071 | 1077 | settings.autonomyBy === undefined ? repo.autonomyBy : settings.autonomyBy, |
| 1072 | 1078 | (settings.crewOutsiders ?? repo.crewOutsiders) ? 1 : 0, |
| 1073 | 1079 | settings.decisionsBy ?? repo.decisionsBy, |
| 1080 | settings.archived === undefined | |
| 1081 | ? repo.archivedAt | |
| 1082 | : settings.archived | |
| 1083 | ? (repo.archivedAt ?? new Date().toISOString()) | |
| 1084 | : null, | |
| 1074 | 1085 | (settings.plansNeedNod ?? repo.plansNeedNod) ? 1 : 0, |
| 1075 | 1086 | ); |
| 1076 | 1087 | return getRepoById(db, repo.id)!; |
src/http/api.ts +9
| @@ -1817,6 +1817,11 @@ export function apiRoutes(router: Router<Handler>): void { | ||
| 1817 | 1817 | const repo = loadRepo(ctx); |
| 1818 | 1818 | if (!repo) return notFound(); |
| 1819 | 1819 | 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"); | |
| 1820 | 1825 | if (input.decisionsBy !== undefined && !isDecisionsBy(input.decisionsBy)) |
| 1821 | 1826 | return apiError( |
| 1822 | 1827 | "bad_decisions_by", |
| @@ -1852,6 +1857,10 @@ export function apiRoutes(router: Router<Handler>): void { | ||
| 1852 | 1857 | ...(isDecisionsBy(input.decisionsBy) |
| 1853 | 1858 | ? { decisionsBy: input.decisionsBy } |
| 1854 | 1859 | : {}), |
| 1860 | // Archive or wake (#1838): any admin's call, and only a real boolean. | |
| 1861 | ...(typeof input.archived === "boolean" | |
| 1862 | ? { archived: input.archived } | |
| 1863 | : {}), | |
| 1855 | 1864 | // Whether a plan waits for a nod: an admin's, never an agent's. |
| 1856 | 1865 | ...(typeof input.plansNeedNod === "boolean" |
| 1857 | 1866 | ? { plansNeedNod: input.plansNeedNod } |
src/http/server.ts +2
| @@ -338,6 +338,7 @@ import { | ||
| 338 | 338 | settingsPage, |
| 339 | 339 | settingsUpdate, |
| 340 | 340 | waveCut, |
| 341 | archiveSubmit, | |
| 341 | 342 | webhookCreate, |
| 342 | 343 | webhookDelete, |
| 343 | 344 | } from "../web/pages/settings.ts"; |
| @@ -1175,6 +1176,7 @@ export function buildRouter(): Router<Handler> { | ||
| 1175 | 1176 | router.add("GET", "/:owner/:name/settings", settingsPage); |
| 1176 | 1177 | router.add("POST", "/:owner/:name/settings", settingsUpdate); |
| 1177 | 1178 | router.add("POST", "/:owner/:name/settings/wave", waveCut); |
| 1179 | router.add("POST", "/:owner/:name/settings/archive", archiveSubmit); | |
| 1178 | 1180 | router.add("GET", "/:owner/:name/settings/app", appSettingsPage); |
| 1179 | 1181 | router.add("POST", "/:owner/:name/settings/app", appSettingsUpdate); |
| 1180 | 1182 | router.add("POST", "/:owner/:name/settings/app/disable", appDisable); |
src/tui/screens/about.ts +46 −7
| @@ -18,7 +18,8 @@ | ||
| 18 | 18 | import { isPinned } from "../../core/pins.ts"; |
| 19 | 19 | import { parentReleases, releasesSentence } from "../../core/parent_releases.ts"; |
| 20 | 20 | 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"; | |
| 22 | 23 | import type { RepoFacts, RepoSource } from "../../view/source.ts"; |
| 23 | 24 | import type { Ctx, Screen } from "../app/app.ts"; |
| 24 | 25 | import { binding } from "../app/keys.ts"; |
| @@ -43,15 +44,33 @@ export function aboutScreen( | ||
| 43 | 44 | facts: RepoFacts | undefined, |
| 44 | 45 | ref: string, |
| 45 | 46 | ): 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
|
||
| 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
outdated
Only the terminal refuses an agent admin here; PATCH and the Settings form accept one. Refuse agents once, in |
||
| 54 | ]; | |
| 55 | let rows = factRows(); | |
| 48 | 56 | const cloneUrl = rows.find(([label]) => label.startsWith("clone"))?.[1] ?? ""; |
| 49 | 57 | return { |
| 50 | 58 | 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 |
||
| 65 | binding( | |
| 66 | ["a"], | |
| 67 | repo?.archivedAt === null ? "archive" : "wake", | |
| 68 | ), | |
| 69 | ] | |
| 70 | : []), | |
| 71 | binding(["esc"], "close"), | |
| 72 | ]; | |
| 73 | }, | |
| 55 | 74 | render(area, s) { |
| 56 | 75 | parent.render(area, s); |
| 57 | 76 | const c = ctx.theme.c; |
| @@ -128,6 +147,20 @@ export function aboutScreen( | ||
| 128 | 147 | ctx.toast("copied the clone URL", "success"); |
| 129 | 148 | return true; |
| 130 | 149 | } |
| 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 |
||
| 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 | } | |
| 131 | 164 | if (e.key === "esc" || e.key === "q" || e.key === "I") { |
| 132 | 165 | ctx.pop(); |
| 133 | 166 | return true; |
| @@ -137,6 +170,12 @@ export function aboutScreen( | ||
| 137 | 170 | }; |
| 138 | 171 | } |
| 139 | 172 | |
| 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 | ||
| 140 | 179 | /** |
| 141 | 180 | * The web following card's releases sentence, for a fork on a forge that |
| 142 | 181 | * 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> { | ||
| 920 | 920 | return await codeHome(ctx, repo); |
| 921 | 921 | } |
| 922 | 922 | |
| 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 |
||
| 943 | } | |
| 944 | ||
| 923 | 945 | /** The code at a ref: the tree, the README and the About rail — with, in |
| 924 | 946 | * the studio, the offer to make a repository that is not an app one. */ |
| 925 | 947 | async function codeHome(ctx: Ctx, repo: Repo): Promise<Response> { |
| @@ -930,7 +952,7 @@ async function codeHome(ctx: Ctx, repo: Repo): Promise<Response> { | ||
| 930 | 952 | const refs = await refSetFrom(scope.source); |
| 931 | 953 | |
| 932 | 954 | 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} | |
| 934 | 956 | <div class="mt"> |
| 935 | 957 | ${empty({ |
| 936 | 958 | icon: "repo", |
| @@ -989,7 +1011,7 @@ async function codeHome(ctx: Ctx, repo: Repo): Promise<Response> { | ||
| 989 | 1011 | const following = await followingOf(ctx, repo); |
| 990 | 1012 | const body = html`${scopeHeader(ctx, scope, "code", { |
| 991 | 1013 | branchCount: refs.branches.length, |
| 992 | })}${noApp} | |
| 1014 | })}${archivedLine(ctx, repo)}${noApp} | |
| 993 | 1015 | ${frozenBlock(ctx, repo)} ${appStrip(ctx, repo)} |
| 994 | 1016 | ${following === undefined |
| 995 | 1017 | ? forkBar(ctx, repo) |
src/web/pages/settings.ts +45 −1
| @@ -321,12 +321,28 @@ export async function settingsPage(ctx: Ctx): Promise<Response> { | ||
| 321 | 321 | </form>`, |
| 322 | 322 | { head: html`${icon("rocket")}<h2>Cut a wave</h2>` }, |
| 323 | 323 | ); |
| 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
|
||
| 338 | { head: html`${icon("folder")}<h2>Archive</h2>` }, | |
| 339 | ); | |
| 324 | 340 | return settingsPageResponse( |
| 325 | 341 | ctx, |
| 326 | 342 | repo, |
| 327 | 343 | "general", |
| 328 | 344 | "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 | |
| 330 | 346 | ? html`<div class="mt">${secrets}</div>` |
| 331 | 347 | : null}`, |
| 332 | 348 | ); |
| @@ -382,6 +398,34 @@ export async function settingsUpdate(ctx: Ctx): Promise<Response> { | ||
| 382 | 398 | return flashTo(ctx, `${base}/settings`, flash); |
| 383 | 399 | } |
| 384 | 400 | |
| 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 | ||
| 385 | 429 | /** POST /:owner/:name/settings/wave — open the draft of the next wave. */ |
| 386 | 430 | export async function waveCut(ctx: Ctx): Promise<Response> { |
| 387 | 431 | 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 | }); |
Add
settings.archived !== undefinedto 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.