Public: anyone can read this; the crew can change it.
Build pins: port Jay Moran's git/builds.ts, notesIntent and the pin pages onto main #9062
Check · Review · Land · Live done — Landed and live in production.
scout/forge:scout/9051-build-pins-port-jay-moran-s-git-builds-t → main 5 commits fork 3 threads · 3 unresolved
21 files
changed +1621
−0
docs/features/build-pins.md added +13
| @@ -0,0 +1,13 @@ | ||
| 1 | # Build pins | |
| 2 | ||
| 3 | A writer marks a commit as a build worth keeping: an annotated tag at | |
| 4 | `refs/builds/<label>` holding the commit, who pinned it, a note and an | |
| 5 | optional `Parent:`/`Source:`. Git holds all of it; pinning runs no check and | |
| 6 | wakes no agent. Ported from Jay Moran's developer-forge work ; `git/builds.ts` | |
| 7 | is trimmed to what main calls). | |
| 8 | ||
| 9 | - Page: `/<owner>/<name>/builds` lists pins; `/builds/new` is the form (Branches has a Pin button per branch and a Builds link); Preview serves a pinned commit (`build_preview`); Delete is behind `/-/confirm`. | |
| 10 | - API: `GET/POST /api/v1/repos/:owner/:name/builds`, `PATCH`/`DELETE …/builds/:label` (rw scope, a person who writes; agents are refused). | |
| 11 | - Terminal: on a repository, the palette's `<owner>/<name> — Builds` (`src/tui/screens/builds.ts`) lists the pins to anyone who may read it; a person who writes (never an agent) presses `p` to pin (prefilled with the next label and the default branch's tip), `n` to note, `d` to delete. | |
| 12 | - Notes are an append-only list on `refs/forge/notes`. `FORGE_NOTES_INTENT=<file>` copies each person's note once into that file under `## Iterator notes`. | |
| 13 | - Left out: demos and displays, screenshots, the Iterations graph, the lineage on Branches. |
src/actions/builds.ts added +141
| @@ -0,0 +1,141 @@ | ||
| 1 | /** | |
| 2 | * Pin a build: a writer marks one commit as a build worth keeping, under a | |
| 3 | * short label, with a note and where it came from. The pin is a git ref | |
| 4 | * (`git/builds.ts`), so it costs no check run and wakes no agent; the event | |
| 5 | * is what the feed and webhooks read. | |
| 6 | */ | |
| 7 | ||
| 8 | import type { App } from "../app.ts"; | |
| 9 | import { commitIdentity } from "../core/commit_identity.ts"; | |
| 10 | import { copyNoteToIntent } from "../core/intent_notes.ts"; | |
| 11 | import type { Repo } from "../core/repos.ts"; | |
| 12 | import type { User } from "../core/users.ts"; | |
| 13 | import { | |
| 14 | deletePin as gitDeletePin, | |
| 15 | pinBuild as gitPinBuild, | |
| 16 | noteFingerprint, | |
| 17 | notePinBuild, | |
| 18 | type BuildNote, | |
| 19 | type BuildPin, | |
| 20 | type DeletePinError, | |
| 21 | type PinBuildError, | |
| 22 | type SetNoteError, | |
| 23 | } from "../git/builds.ts"; | |
| 24 | import { repoDir } from "../git/repos.ts"; | |
| 25 | import { err, ok, type Result } from "../kernel/result.ts"; | |
| 26 | import { requireWriter, type AccessError } from "./access.ts"; | |
| 27 | import { isMirror } from "../origins/write-through.ts"; | |
| 28 | ||
| 29 | export interface PinBuildInput { | |
| 30 | readonly label: string; | |
| 31 | readonly oid: string; | |
| 32 | readonly note?: string; | |
| 33 | readonly parent?: string | undefined; | |
| 34 | /** `pull #12`, `branch feat/x` or `commit`. */ | |
| 35 | readonly source?: string; | |
| 36 | } | |
| 37 | ||
| 38 | export type PinBuildActionError = AccessError | PinBuildError | "mirrored"; | |
| 39 | ||
| 40 | /** A person who writes the repository, never an agent, and not on a mirror: who may pin, note or delete. */ | |
| 41 | function writerOf(app: App, actor: User | undefined, repo: Repo): Result<User, AccessError | "mirrored"> { | |
| 42 | const writer = requireWriter(app, actor, repo); | |
| 43 | if (!writer.ok) return writer; | |
| 44 | if (writer.value.kind === "agent") return err("forbidden"); | |
| 45 | if (isMirror(app, repo)) return err("mirrored"); | |
| 46 | return writer; | |
| 47 | } | |
| 48 | ||
| 49 | export async function pinBuild( | |
| 50 | app: App, | |
| 51 | actor: User | undefined, | |
| 52 | repo: Repo, | |
| 53 | input: PinBuildInput, | |
| 54 | ): Promise<Result<BuildPin, PinBuildActionError>> { | |
| 55 | // A person who writes, not an agent; a mirror's refs are its origin's. | |
| 56 | const writer = writerOf(app, actor, repo); | |
| 57 | if (!writer.ok) return writer; | |
| 58 | const who = commitIdentity(app.config, writer.value); | |
| 59 | const pinned = await gitPinBuild(repoDir(app.reposRoot, repo.ownerName, repo.name), { | |
| 60 | label: input.label.trim(), | |
| 61 | oid: input.oid.trim(), | |
| 62 | note: input.note ?? "", | |
| 63 | parent: input.parent === undefined || input.parent.trim() === "" ? undefined : input.parent.trim(), | |
| 64 | source: input.source ?? "commit", | |
| 65 | tagger: who, | |
| 66 | }); | |
| 67 | if (!pinned.ok) return pinned; | |
| 68 | app.bus.append({ | |
| 69 | kind: "build.pinned", | |
| 70 | repoId: repo.id, | |
| 71 | actorId: writer.value.id, | |
| 72 | payload: { | |
| 73 | label: pinned.value.label, | |
| 74 | oid: pinned.value.oid, | |
| 75 | parent: pinned.value.parent ?? null, | |
| 76 | source: pinned.value.source, | |
| 77 | }, | |
| 78 | }); | |
| 79 | return ok(pinned.value); | |
| 80 | } | |
| 81 | ||
| 82 | /** | |
| 83 | * Delete a pin: `refs/builds/<label>` with its cover and screenshots. A | |
| 84 | * person who writes the repository, never an agent. The commit and the | |
| 85 | * pin's notes stay. | |
| 86 | */ | |
| 87 | export async function deletePin( | |
| 88 | app: App, | |
| 89 | actor: User | undefined, | |
| 90 | repo: Repo, | |
| 91 | label: string, | |
| 92 | ): Promise<Result<{ readonly label: string; readonly oid: string }, AccessError | DeletePinError | "mirrored">> { | |
| 93 | const writer = writerOf(app, actor, repo); | |
| 94 | if (!writer.ok) return writer; | |
| 95 | const dir = repoDir(app.reposRoot, repo.ownerName, repo.name); | |
| 96 | const gone = await gitDeletePin(dir, label); | |
| 97 | if (!gone.ok) return gone; | |
| 98 | // Its notes stay: they are append-only (`git/builds.ts`). | |
| 99 | app.bus.append({ | |
| 100 | kind: "build.deleted", | |
| 101 | repoId: repo.id, | |
| 102 | actorId: writer.value.id, | |
| 103 | payload: { label, oid: gone.value.oid, tag: gone.value.tag }, | |
| 104 | }); | |
| 105 | return ok({ label, oid: gone.value.oid }); | |
| 106 | } | |
| 107 | ||
| 108 | /** | |
| 109 | * Add a note to a build: appended to `notes.json` on `refs/forge/notes`, | |
| 110 | * one commit by its author (`git/builds.ts`). `notes` is the note's text; | |
| 111 | * the older `note` and `body` join into it. A person who writes the | |
| 112 | * repository, never an agent (as the API contract says). The note is also copied, once, into the rolling intent doc when | |
| 113 | * the forge names one (`FORGE_NOTES_INTENT`, `core/intent_notes.ts`). | |
| 114 | */ | |
| 115 | export async function notePin( | |
| 116 | app: App, | |
| 117 | actor: User | undefined, | |
| 118 | repo: Repo, | |
| 119 | label: string, | |
| 120 | change: { readonly notes?: string | undefined; readonly note?: string | undefined; readonly body?: string | undefined }, | |
| 121 | ): Promise<Result<{ readonly pin: BuildPin; readonly note: BuildNote }, AccessError | SetNoteError | "mirrored">> { | |
| 122 | const writer = writerOf(app, actor, repo); | |
| 123 | if (!writer.ok) return writer; | |
| 124 | const text = change.notes ?? [change.note, change.body].filter((s) => s !== undefined && s.trim() !== "").join("\n\n"); | |
| 125 | const who = { name: writer.value.username, kind: "human" as const }; | |
| 126 | const dir = repoDir(app.reposRoot, repo.ownerName, repo.name); | |
| 127 | const noted = await notePinBuild(dir, label, text, who, commitIdentity(app.config, writer.value)); | |
| 128 | if (!noted.ok) return noted; | |
| 129 | const { pin, note, commit } = noted.value; | |
| 130 | app.bus.append({ | |
| 131 | kind: "build.noted", | |
| 132 | repoId: repo.id, | |
| 133 | actorId: writer.value.id, | |
| 134 | payload: { label, oid: pin.oid, commit, note: noteFingerprint(note), by: note.kind }, | |
| 135 | }); | |
| 136 | if (note.kind === "human" && app.config.notesIntent !== undefined) { | |
| 137 | const copied = copyNoteToIntent(app.config.notesIntent, `${repo.ownerName}/${repo.name}`, note); | |
| 138 | if (!copied.ok) app.log.warn("note not copied to the intent doc", { file: app.config.notesIntent, error: copied.error }); | |
| 139 | } | |
| 140 | return ok({ pin, note }); | |
| 141 | } |
src/app.ts +5
| @@ -175,6 +175,11 @@ export interface ForgeConfig { | ||
| 175 | 175 | * child). Printed verbatim wherever the forge says how to connect. |
| 176 | 176 | */ |
| 177 | 177 | appSsh?: string; |
| 178 | /** | |
| 179 | * The rolling intent doc a person's build note is copied into, once, | |
| 180 | * under "## Iterator notes" (`core/intent_notes.ts`). Unset copies none. | |
| 181 | */ | |
| 182 | notesIntent?: string; | |
| 178 | 183 | /** |
| 179 | 184 | * The repository holding this forge's own code, as `owner/name` |
| 180 | 185 | * (`FORGE_REPO`, e.g. `trevato-apps/forge`). A spawned child forks it, |
src/contract/openapi/routes/deleteApiV1ReposOwnerNameBuildsLabel.ts added +16
| @@ -0,0 +1,16 @@ | ||
| 1 | import { REPO, type Route } from "../shared.ts"; | |
| 2 | ||
| 3 | export const route: Route = { | |
| 4 | method: "delete", | |
| 5 | path: `${REPO}/builds/{label}`, | |
| 6 | summary: "Delete a pin: refs/builds/{label} with its cover and screenshots; the commit stays (rw scope, a person who writes); answers 204", | |
| 7 | auth: true, | |
| 8 | status: 204, | |
| 9 | refusals: { | |
| 10 | forbidden: "403: not a person who writes the repository (an agent is refused)", | |
| 11 | not_found: "404: no such repository or pin, or the caller cannot read it", | |
| 12 | mirrored: "409: the repository mirrors an origin", | |
| 13 | moved: "409: the pin changed while it was being deleted", | |
| 14 | git_failed: "422: git refused the change", | |
| 15 | }, | |
| 16 | }; |
src/contract/openapi/routes/getApiV1ReposOwnerNameBuilds.ts added +3
| @@ -0,0 +1,3 @@ | ||
| 1 | import { REPO, type Route } from "../shared.ts"; | |
| 2 | ||
| 3 | export const route: Route = { method: "get", path: `${REPO}/builds`, summary: "List a repository's build pins (refs/builds/<label>), newest first; answers { items, next_cursor }", auth: false }; |
src/contract/openapi/routes/index.ts +4
| @@ -13,6 +13,7 @@ export { route as deleteApiV1ReposOwnerNameApp } from "./deleteApiV1ReposOwnerNa | ||
| 13 | 13 | export { route as deleteApiV1ReposOwnerNameAppDomainsHost } from "./deleteApiV1ReposOwnerNameAppDomainsHost.ts"; |
| 14 | 14 | export { route as deleteApiV1ReposOwnerNameAppEnvVar } from "./deleteApiV1ReposOwnerNameAppEnvVar.ts"; |
| 15 | 15 | export { route as deleteApiV1ReposOwnerNameBranchesBranch } from "./deleteApiV1ReposOwnerNameBranchesBranch.ts"; |
| 16 | export { route as deleteApiV1ReposOwnerNameBuildsLabel } from "./deleteApiV1ReposOwnerNameBuildsLabel.ts"; | |
| 16 | 17 | export { route as deleteApiV1ReposOwnerNameCrewUsername } from "./deleteApiV1ReposOwnerNameCrewUsername.ts"; |
| 17 | 18 | export { route as deleteApiV1ReposOwnerNameEgressGrants } from "./deleteApiV1ReposOwnerNameEgressGrants.ts"; |
| 18 | 19 | export { route as deleteApiV1ReposOwnerNameHooksId } from "./deleteApiV1ReposOwnerNameHooksId.ts"; |
| @@ -96,6 +97,7 @@ export { route as getApiV1ReposOwnerNameAppMembers } from "./getApiV1ReposOwnerN | ||
| 96 | 97 | export { route as getApiV1ReposOwnerNameAppNotes } from "./getApiV1ReposOwnerNameAppNotes.ts"; |
| 97 | 98 | export { route as getApiV1ReposOwnerNameAppProductionOpen } from "./getApiV1ReposOwnerNameAppProductionOpen.ts"; |
| 98 | 99 | export { route as getApiV1ReposOwnerNameBranches } from "./getApiV1ReposOwnerNameBranches.ts"; |
| 100 | export { route as getApiV1ReposOwnerNameBuilds } from "./getApiV1ReposOwnerNameBuilds.ts"; | |
| 99 | 101 | export { route as getApiV1ReposOwnerNameCommits } from "./getApiV1ReposOwnerNameCommits.ts"; |
| 100 | 102 | export { route as getApiV1ReposOwnerNameCommitsOid } from "./getApiV1ReposOwnerNameCommitsOid.ts"; |
| 101 | 103 | export { route as getApiV1ReposOwnerNameCommitsOidCheck } from "./getApiV1ReposOwnerNameCommitsOidCheck.ts"; |
| @@ -191,6 +193,7 @@ export { route as patchApiV1ConversationsId } from "./patchApiV1ConversationsId. | ||
| 191 | 193 | export { route as patchApiV1ForgeFollowing } from "./patchApiV1ForgeFollowing.ts"; |
| 192 | 194 | export { route as patchApiV1ReposOwnerName } from "./patchApiV1ReposOwnerName.ts"; |
| 193 | 195 | export { route as patchApiV1ReposOwnerNameApp } from "./patchApiV1ReposOwnerNameApp.ts"; |
| 196 | export { route as patchApiV1ReposOwnerNameBuildsLabel } from "./patchApiV1ReposOwnerNameBuildsLabel.ts"; | |
| 194 | 197 | export { route as patchApiV1ReposOwnerNameIssuesNumber } from "./patchApiV1ReposOwnerNameIssuesNumber.ts"; |
| 195 | 198 | export { route as patchApiV1ReposOwnerNameLabelsId } from "./patchApiV1ReposOwnerNameLabelsId.ts"; |
| 196 | 199 | export { route as patchApiV1ReposOwnerNamePullsNumber } from "./patchApiV1ReposOwnerNamePullsNumber.ts"; |
| @@ -248,6 +251,7 @@ export { route as postApiV1ReposOwnerNameAppNotesIdBan } from "./postApiV1ReposO | ||
| 248 | 251 | export { route as postApiV1ReposOwnerNameAppNotesIdHide } from "./postApiV1ReposOwnerNameAppNotesIdHide.ts"; |
| 249 | 252 | export { route as postApiV1ReposOwnerNameAppNotesIdUnban } from "./postApiV1ReposOwnerNameAppNotesIdUnban.ts"; |
| 250 | 253 | export { route as postApiV1ReposOwnerNameBranches } from "./postApiV1ReposOwnerNameBranches.ts"; |
| 254 | export { route as postApiV1ReposOwnerNameBuilds } from "./postApiV1ReposOwnerNameBuilds.ts"; | |
| 251 | 255 | export { route as postApiV1ReposOwnerNameBuildsPreview } from "./postApiV1ReposOwnerNameBuildsPreview.ts"; |
| 252 | 256 | export { route as postApiV1ReposOwnerNameChecksRecheck } from "./postApiV1ReposOwnerNameChecksRecheck.ts"; |
| 253 | 257 | export { route as postApiV1ReposOwnerNameContents } from "./postApiV1ReposOwnerNameContents.ts"; |
src/contract/openapi/routes/patchApiV1ReposOwnerNameBuildsLabel.ts added +17
| @@ -0,0 +1,17 @@ | ||
| 1 | import { REPO, type Route } from "../shared.ts"; | |
| 2 | ||
| 3 | export const route: Route = { | |
| 4 | method: "patch", | |
| 5 | path: `${REPO}/builds/{label}`, | |
| 6 | summary: "Edit a build's notes: `notes` is the whole text (markdown, at most 10 KB; its first line is the summary), written as `<label>.md` in one commit on `refs/forge/notes` by the editor; the tag is not rewritten. `note` (the first line) and `body` (the rest) are still taken for one release and joined into the file, a field left out staying (rw scope, a person who writes); answers the pin", | |
| 7 | auth: true, | |
| 8 | body: { notes: "string", note: "string", body: "string" }, | |
| 9 | refusals: { | |
| 10 | forbidden: "403: not a person who writes the repository (an agent is refused)", | |
| 11 | not_found: "404: no such repository or pin, or the caller cannot read it", | |
| 12 | invalid_note: "422: the notes pass 10 KB, or `note` is not one line of at most 200 characters", | |
| 13 | mirrored: "409: the repository mirrors an origin", | |
| 14 | moved: "409: the pin changed while it was being edited", | |
| 15 | git_failed: "422: git refused the change", | |
| 16 | }, | |
| 17 | }; |
src/contract/openapi/routes/postApiV1ReposOwnerNameBuilds.ts added +11
| @@ -0,0 +1,11 @@ | ||
| 1 | import { REPO, type Route } from "../shared.ts"; | |
| 2 | ||
| 3 | export const route: Route = { | |
| 4 | method: "post", | |
| 5 | path: `${REPO}/builds`, | |
| 6 | summary: "Pin a commit as a build (rw scope, a writer's) at refs/builds/<label>; answers 201 with the pin", | |
| 7 | auth: true, | |
| 8 | body: { label: "string", sha: "string", note: "string", parent: "string", source: "string" }, | |
| 9 | status: 201, | |
| 10 | refusals: { exists: "409: a pin with that label already exists" }, | |
| 11 | }; |
src/core/event_audience.ts +3
| @@ -132,6 +132,9 @@ const READER_KINDS: ReadonlySet<string> = new Set([ | ||
| 132 | 132 | "account.removed", |
| 133 | 133 | "branch.created", |
| 134 | 134 | "branch.deleted", |
| 135 | "build.pinned", | |
| 136 | "build.deleted", | |
| 137 | "build.noted", | |
| 135 | 138 | "invite.used", |
| 136 | 139 | "user.updated", |
| 137 | 140 | "user.key_added", |
src/core/intent_notes.ts added +58
| @@ -0,0 +1,58 @@ | ||
| 1 | /** | |
The first line of a note is written unindented into the shared doc: a leading '#' or '-->' injects a heading or breaks the fingerprint comment. Neutralise it and test it. |
||
| 2 | * A person's build note, copied verbatim and once into a rolling intent doc, | |
| 3 | * the way ui-iterator copies a round's notes (records/ui-iterator/notes.mjs, | |
| 4 | * `syncIntent`): one list line under "## Iterator notes", carrying a hidden | |
| 5 | * fingerprint so it is never copied twice. Agent notes are never copied: | |
| 6 | * intent holds people's words only. The doc must exist; none is created. | |
| 7 | */ | |
| 8 | ||
| 9 | import { existsSync, readFileSync, renameSync, writeFileSync } from "node:fs"; | |
| 10 | import { noteFingerprint, type BuildNote } from "../git/builds.ts"; | |
| 11 | import { err, ok, type Result } from "../kernel/result.ts"; | |
| 12 | ||
| 13 | const HEAD = "## Iterator notes"; | |
| 14 | ||
| 15 | /** A line of note text made inert in the doc: no comment can open or close, no heading start. */ | |
| 16 | export const inert = (line: string): string => | |
| 17 | line.replace(/<!--/g, "<!- -").replace(/-->/g, "-- >").replace(/^(\s*)#/, "$1\\#"); | |
| 18 | ||
| 19 | /** The line `note` is copied as: `- <source> <iteration>, <by>, <date>: <first line> <!-- ui-iterator <fp> -->`, the rest indented. */ | |
| 20 | export function intentLine(source: string, note: BuildNote): string { | |
| 21 | const [first = "", ...rest] = note.text.split("\n").map(inert); | |
| 22 | const more = rest.map((l) => ` ${l}`).join("\n"); | |
| 23 | return `- ${source} ${note.iteration}, ${note.by}, ${note.at.slice(0, 10)}: ${first} <!-- ui-iterator ${noteFingerprint(note)} -->${more ? `\n${more}` : ""}`; | |
| 24 | } | |
| 25 | ||
| 26 | /** `doc` with `line` appended at the end of its "## Iterator notes" section, made when missing. */ | |
| 27 | export function withIntentLine(doc: string, line: string): string { | |
| 28 | let text = doc; | |
| 29 | let at = text.indexOf(HEAD); | |
| 30 | if (at < 0) { | |
| 31 | text = text.replace(/\n*$/, `\n\n${HEAD}\n`); | |
| 32 | at = text.indexOf(HEAD); | |
| 33 | } | |
| 34 | // The section ends before the next heading, or at the end of the file. | |
| 35 | const next = text.slice(at + HEAD.length).search(/\n#{1,2} /); | |
| 36 | const cut = next < 0 ? text.length : at + HEAD.length + next; | |
| 37 | // Continue an existing list without a gap; start a new one after a blank line. | |
| 38 | const kept = text.slice(0, cut).replace(/\n*$/, ""); | |
| 39 | const last = kept.slice(kept.lastIndexOf("\n") + 1); | |
| 40 | const before = kept + (last.startsWith("- ") || last.startsWith(" ") ? "\n" : "\n\n"); | |
| 41 | return `${before}${line}\n${next < 0 ? "" : `\n${text.slice(cut).replace(/^\n*/, "")}`}`; | |
| 42 | } | |
| 43 | ||
| 44 | /** Copy `note` into the intent doc at `file`; answers whether it was written. */ | |
| 45 | export function copyNoteToIntent(file: string, source: string, note: BuildNote): Result<boolean, "no_doc" | "write_failed"> { | |
| 46 | if (note.kind !== "human") return ok(false); | |
| 47 | if (!existsSync(file)) return err("no_doc"); | |
| 48 | try { | |
| 49 | const doc = readFileSync(file, "utf8"); | |
| 50 | if (doc.includes(`<!-- ui-iterator ${noteFingerprint(note)} -->`)) return ok(false); | |
| 51 | const tmp = `${file}.forge-tmp`; | |
| 52 | writeFileSync(tmp, withIntentLine(doc, intentLine(source, note))); | |
| 53 | renameSync(tmp, file); | |
| 54 | return ok(true); | |
| 55 | } catch { | |
| 56 | return err("write_failed"); | |
| 57 | } | |
| 58 | } |
src/git/builds.ts added +472
| @@ -0,0 +1,472 @@ | ||
| 1 | /** | |
Much of this module has no caller (demos, covers, lineage helpers, readAllPinMessages citing a non-existent script). Trim to what is reachable. |
||
| 2 | * Build pins: a person marks a commit as a build worth keeping. A pin is an | |
| 3 | * annotated tag object under `refs/builds/<label>` — the commit, who pinned | |
| 4 | * it and when, a note, and two trailers: `Parent:` (a label, when the | |
| 5 | * person named one) and `Source:` (the pull, branch or commit it came | |
| 6 | * from). Git holds all of it, so nothing here lives only in the database, | |
| 7 | * and pinning runs no check and wakes no agent. Notes live on | |
| 8 | * `refs/forge/notes` (see below). | |
| 9 | */ | |
| 10 | ||
| 11 | import { createHash } from "node:crypto"; | |
| 12 | import { readFileSync, statSync } from "node:fs"; | |
| 13 | import { join } from "node:path"; | |
| 14 | import { err, ok, type Result } from "../kernel/result.ts"; | |
| 15 | import { runGit, runGitCapture, runGitRaw } from "./env.ts"; | |
| 16 | import { isSafeOid, isSafeRef } from "./guard.ts"; | |
| 17 | ||
| 18 | export const BUILDS_PREFIX = "refs/builds/"; | |
| 19 | ||
| 20 | /** How many pins a lineage reads. A build list is for people to scan. */ | |
| 21 | export const PIN_LIMIT = 100; | |
| 22 | ||
| 23 | export interface BuildPin { | |
| 24 | readonly label: string; | |
| 25 | /** The pinned commit. */ | |
| 26 | readonly oid: string; | |
| 27 | readonly note: string; | |
| 28 | /** The pin the person named as this one's parent: `label` here, or | |
| 29 | * `owner/name:label` for a concept edge to another repository. */ | |
| 30 | readonly parent: string | undefined; | |
| 31 | /** Where it was pinned from: `pull #12`, `branch feat/x` or `commit`. */ | |
| 32 | readonly source: string; | |
| 33 | readonly pinnedBy: string; | |
| 34 | /** ISO time it was pinned. */ | |
| 35 | readonly pinnedAt: string; | |
| 36 | /** Its notes, ranked, when it has any (`withNotes`); `note` is then the first one's text. */ | |
| 37 | readonly notes?: readonly BuildNote[] | undefined; | |
| 38 | /** The tag message's note, when `note` holds the first of its notes instead. */ | |
| 39 | readonly tagNote?: string | undefined; | |
| 40 | } | |
| 41 | ||
| 42 | /** A label is short and plain: letters, digits, `.`, `_` and `-`. */ | |
| 43 | export function isPinLabel(label: string): boolean { | |
| 44 | return ( | |
| 45 | /^[A-Za-z0-9][A-Za-z0-9._-]{0,39}$/.test(label) && | |
| 46 | !label.endsWith(".lock") && | |
| 47 | isSafeRef(`${BUILDS_PREFIX}${label}`) | |
| 48 | ); | |
| 49 | } | |
| 50 | ||
| 51 | /** | |
| 52 | * What a pin may name as its parent: a label here, or `owner/name:label` in | |
| 53 | * another repository — a concept edge, for builds that share an idea but no | |
| 54 | * history. | |
| 55 | */ | |
| 56 | export function isPinParent(parent: string): boolean { | |
| 57 | const at = parent.lastIndexOf(":"); | |
| 58 | if (at === -1) return isPinLabel(parent); | |
| 59 | const repo = parent.slice(0, at); | |
| 60 | return /^[A-Za-z0-9][A-Za-z0-9._-]*\/[A-Za-z0-9][A-Za-z0-9._-]*$/.test(repo) && isPinLabel(parent.slice(at + 1)); | |
| 61 | } | |
| 62 | ||
| 63 | export type PinBuildError = | |
| 64 | | "invalid_label" | |
| 65 | | "invalid_parent" | |
| 66 | | "not_a_commit" | |
| 67 | | "exists" | |
| 68 | | "git_failed"; | |
| 69 | ||
| 70 | export interface PinInput { | |
| 71 | readonly label: string; | |
| 72 | readonly oid: string; | |
| 73 | readonly note: string; | |
| 74 | readonly parent?: string | undefined; | |
| 75 | readonly source: string; | |
| 76 | readonly tagger: { readonly name: string; readonly email: string }; | |
| 77 | /** Seconds since the epoch; tests pass one to order pins. */ | |
| 78 | readonly at?: number; | |
| 79 | } | |
| 80 | ||
| 81 | /** One line, no control bytes: what a trailer or tagger field may hold. */ | |
| 82 | const oneLine = (s: string): string => | |
| 83 | s.replace(/[\x00-\x1f\x7f<>]/g, " ").trim(); | |
| 84 | ||
| 85 | /** Write the tag object and create the ref; an existing label is refused. */ | |
| 86 | export async function pinBuild( | |
| 87 | dir: string, | |
| 88 | input: PinInput, | |
| 89 | ): Promise<Result<BuildPin, PinBuildError>> { | |
| 90 | if (!isPinLabel(input.label)) return err("invalid_label"); | |
| 91 | if (input.parent !== undefined && !isPinParent(input.parent)) | |
| 92 | return err("invalid_parent"); | |
| 93 | if (!isSafeOid(input.oid)) return err("not_a_commit"); | |
| 94 | const kind = await runGitCapture(["cat-file", "-t", input.oid], { cwd: dir }); | |
| 95 | if (kind.code !== 0 || kind.stdout.trim() !== "commit") | |
| 96 | return err("not_a_commit"); | |
| 97 | ||
| 98 | const at = input.at ?? Math.floor(Date.now() / 1000); | |
| 99 | const note = input.note.replace(/\r/g, "").trim(); | |
| 100 | const message = pinMessage(note === "" ? input.label : note, input.parent, oneLine(input.source) || "commit"); | |
| 101 | const tag = | |
| 102 | `object ${input.oid}\ntype commit\ntag ${input.label}\n` + | |
| 103 | `tagger ${oneLine(input.tagger.name) || "forge"} <${oneLine(input.tagger.email)}> ${at} +0000\n\n` + | |
| 104 | message; | |
| 105 | const made = await runGit(["mktag"], { cwd: dir, stdin: tag }); | |
| 106 | if (!made.ok) return err("git_failed"); | |
| 107 | const created = await runGitCapture( | |
| 108 | ["update-ref", `${BUILDS_PREFIX}${input.label}`, made.value.trim(), ""], | |
| 109 | { cwd: dir }, | |
| 110 | ); | |
| 111 | if (created.code !== 0) | |
| 112 | return err(/already exists|reference already/i.test(created.stderr) ? "exists" : "git_failed"); | |
| 113 | return ok({ | |
| 114 | label: input.label, | |
| 115 | oid: input.oid, | |
| 116 | note, | |
| 117 | parent: input.parent, | |
| 118 | source: oneLine(input.source) || "commit", | |
| 119 | pinnedBy: oneLine(input.tagger.name) || "forge", | |
| 120 | pinnedAt: new Date(at * 1000).toISOString(), | |
| 121 | }); | |
| 122 | } | |
| 123 | ||
| 124 | /** A pin's message: its note, then its trailers. */ | |
| 125 | function pinMessage(note: string, parent: string | undefined, source: string): string { | |
| 126 | const trailers = [...(parent === undefined ? [] : [`Parent: ${parent}`]), `Source: ${source}`]; | |
| 127 | return `${note}\n\n${trailers.join("\n")}\n`; | |
| 128 | } | |
| 129 | ||
| 130 | const TRAILER = /^(Parent|Source): (.*)$/; | |
| 131 | ||
| 132 | /** The trailers block at the end of a pin's message, and the note above it. */ | |
| 133 | function parsePinMessage(text: string): { note: string; parent: string | undefined; source: string } { | |
| 134 | const lines = text.replace(/\n+$/, "").split("\n"); | |
| 135 | let parent: string | undefined; | |
| 136 | let source = "commit"; | |
| 137 | let end = lines.length; | |
| 138 | while (end > 0) { | |
| 139 | const m = TRAILER.exec(lines[end - 1]!); | |
| 140 | if (m === null) break; | |
| 141 | if (m[1] === "Parent" && isPinParent(m[2]!)) parent = m[2]!; | |
| 142 | else if (m[1] === "Source") source = m[2]!; | |
| 143 | end--; | |
| 144 | } | |
| 145 | return { note: lines.slice(0, end).join("\n").trim(), parent, source }; | |
| 146 | } | |
| 147 | ||
| 148 | /** The most bytes of one note's text. */ | |
| 149 | export const NOTES_MAX = 10 * 1024; | |
| 150 | ||
| 151 | /** One note's text: plain text, not blank, that may break lines and tab. */ | |
| 152 | export const isNotes = (text: string): boolean => | |
| 153 | text.trim() !== "" && new TextEncoder().encode(text).length <= NOTES_MAX && !/[\x00-\x08\x0b-\x1f\x7f]/.test(text); | |
| 154 | ||
| 155 | export type SetNoteError = "invalid_note" | "not_found" | "moved" | "git_failed" | "notes_lost"; | |
| 156 | ||
| 157 | /** | |
| 158 | * A build's notes live on `refs/forge/notes` in its repository, in the | |
| 159 | * format ui-iterator keeps beside a round: `notes.json`, `{ "notes": [...] }`, | |
| 160 | * one entry per note. `iteration` is the pin's label (or a fork's or a | |
| 161 | * family's name, for a note on the whole line), `by` the author's name, | |
| 162 | * `kind` `human` or `agent`, `at` an ISO time, `text` plain text. | |
| 163 | * | |
| 164 | * Notes are append-only: a correction is a new note. `notes.lock` holds | |
| 165 | * every note's fingerprint, and an append refuses a `notes.json` that lost | |
| 166 | * one. Each append is one commit by its author. People's notes rank first. | |
| 167 | * | |
| 168 | */ | |
| 169 | export const NOTES_REF = "refs/forge/notes"; | |
| 170 | const NOTES_FILE = "notes.json"; | |
| 171 | const NOTES_LOCK = "notes.lock"; | |
| 172 | ||
| 173 | export const NOTE_KINDS = ["human", "agent"] as const; | |
| 174 | export type NoteKind = (typeof NOTE_KINDS)[number]; | |
| 175 | ||
| 176 | /** One note, as `notes.json` holds it. */ | |
| 177 | export interface BuildNote { | |
| 178 | readonly iteration: string; | |
| 179 | readonly by: string; | |
| 180 | readonly kind: NoteKind; | |
| 181 | readonly at: string; | |
| 182 | readonly text: string; | |
| 183 | } | |
| 184 | ||
| 185 | /** A note's fingerprint, as ui-iterator's `notes.lock` keeps it: sha1 over its five fields, first 12 hex. */ | |
| 186 | export const noteFingerprint = (n: BuildNote): string => | |
| 187 | createHash("sha1").update([n.iteration, n.by, n.kind, n.at, n.text].join("\u0000")).digest("hex").slice(0, 12); | |
| 188 | ||
| 189 | /** People's notes first, then agents'; each oldest first. */ | |
| 190 | export const rankNotes = (notes: readonly BuildNote[]): BuildNote[] => | |
| 191 | [...notes].sort((a, b) => (a.kind === b.kind ? Date.parse(a.at) - Date.parse(b.at) : a.kind === "human" ? -1 : 1)); | |
| 192 | ||
| 193 | function isBuildNote(n: unknown): n is BuildNote { | |
| 194 | if (typeof n !== "object" || n === null) return false; | |
| 195 | const o = n as Record<string, unknown>; | |
| 196 | return ( | |
| 197 | typeof o["iteration"] === "string" && | |
| 198 | typeof o["by"] === "string" && | |
| 199 | (o["kind"] === "human" || o["kind"] === "agent") && | |
| 200 | typeof o["at"] === "string" && | |
| 201 | !Number.isNaN(Date.parse(o["at"])) && | |
| 202 | typeof o["text"] === "string" | |
| 203 | ); | |
| 204 | } | |
| 205 | ||
| 206 | /** `notes.json` as its notes, in file order; an entry that is not a note is skipped. */ | |
| 207 | function parseNotesJson(text: string): BuildNote[] { | |
| 208 | try { | |
| 209 | const data: unknown = JSON.parse(text); | |
| 210 | const list = Array.isArray(data) ? data : (data as { notes?: unknown } | null)?.notes; | |
| 211 | return Array.isArray(list) | |
| 212 | ? list.filter(isBuildNote).map((n) => ({ iteration: n.iteration, by: n.by, kind: n.kind, at: n.at, text: n.text })) | |
| 213 | : []; | |
| 214 | } catch { | |
| 215 | return []; | |
| 216 | } | |
| 217 | } | |
| 218 | ||
| 219 | /** `notes.lock` as its fingerprints. */ | |
| 220 | function parseLock(text: string): string[] { | |
| 221 | try { | |
| 222 | const data: unknown = JSON.parse(text); | |
| 223 | return Array.isArray(data) ? data.filter((h): h is string => typeof h === "string") : []; | |
| 224 | } catch { | |
| 225 | return []; | |
| 226 | } | |
| 227 | } | |
| 228 | ||
| 229 | /** Whether `dir` holds a notes ref, read without spawning git. */ | |
| 230 | export function holdsNotes(dir: string): boolean { | |
| 231 | try { | |
| 232 | if (statSync(join(dir, NOTES_REF)).isFile()) return true; | |
| 233 | } catch { | |
| 234 | // No loose ref. | |
| 235 | } | |
| 236 | try { | |
| 237 | return readFileSync(join(dir, "packed-refs"), "utf8").includes(` ${NOTES_REF}\n`); | |
| 238 | } catch { | |
| 239 | return false; | |
| 240 | } | |
| 241 | } | |
| 242 | ||
| 243 | /** The notes tree's entries at `tip`, raw (`ls-tree -z` lines), by file name. */ | |
| 244 | async function notesEntries(dir: string, tip: string): Promise<Map<string, { readonly line: string; readonly oid: string }> | undefined> { | |
| 245 | const listed = await runGitCapture(["ls-tree", "-z", tip], { cwd: dir }); | |
| 246 | if (listed.code !== 0) return undefined; | |
| 247 | const out = new Map<string, { line: string; oid: string }>(); | |
| 248 | for (const line of listed.stdout.split("\0")) { | |
| 249 | const m = /^(\d+) (\w+) ([0-9a-f]+)\t(.+)$/s.exec(line); | |
| 250 | if (m !== null) out.set(m[4]!, { line, oid: m[3]! }); | |
| 251 | } | |
| 252 | return out; | |
| 253 | } | |
| 254 | ||
| 255 | /** Blobs' text by id, in one `cat-file --batch`. */ | |
| 256 | async function readBlobs(dir: string, oids: readonly string[]): Promise<Map<string, string>> { | |
| 257 | const out = new Map<string, string>(); | |
| 258 | const safe = oids.filter(isSafeOid); | |
| 259 | if (safe.length === 0) return out; | |
| 260 | const read = await runGitRaw(["cat-file", "--batch"], { cwd: dir, stdin: `${safe.join("\n")}\n` }); | |
| 261 | if (!read.ok) return out; | |
| 262 | const bytes = read.value; | |
| 263 | const text = new TextDecoder(); | |
| 264 | let at = 0; | |
| 265 | while (at < bytes.length) { | |
| 266 | const nl = bytes.indexOf(0x0a, at); | |
| 267 | if (nl === -1) break; | |
| 268 | const [oid, kind, size] = text.decode(bytes.subarray(at, nl)).split(" "); | |
| 269 | if (kind !== "blob" || oid === undefined) { | |
| 270 | at = nl + 1; | |
| 271 | continue; | |
| 272 | } | |
| 273 | const n = Number(size); | |
| 274 | out.set(oid, text.decode(bytes.subarray(nl + 1, nl + 1 + n))); | |
| 275 | at = nl + 1 + n + 1; | |
| 276 | } | |
| 277 | return out; | |
| 278 | } | |
| 279 | ||
| 280 | /** The current tip of the notes ref, or undefined when there is none. */ | |
| 281 | async function notesTip(dir: string): Promise<string | undefined> { | |
| 282 | const at = await runGitCapture(["rev-parse", "--verify", "--quiet", `${NOTES_REF}^{commit}`], { cwd: dir }); | |
| 283 | const oid = at.stdout.trim(); | |
| 284 | return at.code === 0 && isSafeOid(oid) ? oid : undefined; | |
| 285 | } | |
| 286 | ||
| 287 | /** What the notes ref holds at `tip`: its tree, its notes and its lock. */ | |
| 288 | interface NotesState { | |
| 289 | readonly entries: Map<string, { readonly line: string; readonly oid: string }>; | |
| 290 | readonly notes: readonly BuildNote[]; | |
| 291 | readonly lock: readonly string[]; | |
| 292 | } | |
| 293 | ||
| 294 | async function readNotesState(dir: string, tip: string | undefined): Promise<NotesState | undefined> { | |
| 295 | if (tip === undefined) return { entries: new Map(), notes: [], lock: [] }; | |
| 296 | const entries = await notesEntries(dir, tip); | |
| 297 | if (entries === undefined) return undefined; | |
| 298 | const file = entries.get(NOTES_FILE), lock = entries.get(NOTES_LOCK); | |
| 299 | const blobs = await readBlobs(dir, [ | |
| 300 | ...(file === undefined ? [] : [file.oid]), | |
| 301 | ...(lock === undefined ? [] : [lock.oid]), | |
| 302 | ]); | |
| 303 | const notes = file === undefined ? [] : parseNotesJson(blobs.get(file.oid) ?? ""); | |
| 304 | return { entries, notes, lock: lock === undefined ? [] : parseLock(blobs.get(lock.oid) ?? "") }; | |
| 305 | } | |
| 306 | ||
| 307 | /** Each listed label's notes, ranked; a label with none is left out. */ | |
| 308 | export async function readBuildNotes(dir: string, labels: readonly string[]): Promise<Map<string, BuildNote[]>> { | |
| 309 | const out = new Map<string, BuildNote[]>(); | |
| 310 | if (labels.length === 0 || !holdsNotes(dir)) return out; | |
| 311 | const tip = await notesTip(dir); | |
| 312 | if (tip === undefined) return out; | |
| 313 | const state = await readNotesState(dir, tip); | |
| 314 | if (state === undefined) return out; | |
| 315 | const wanted = new Set(labels); | |
| 316 | for (const n of state.notes) { | |
| 317 | if (!wanted.has(n.iteration)) continue; | |
| 318 | const list = out.get(n.iteration) ?? []; | |
| 319 | list.push(n); | |
| 320 | out.set(n.iteration, list); | |
| 321 | } | |
| 322 | for (const [label, list] of out) out.set(label, rankNotes(list)); | |
| 323 | return out; | |
| 324 | } | |
| 325 | ||
| 326 | /** A pin with its notes: `note` becomes the first note's text, and the tag's own note moves to `tagNote`. */ | |
| 327 | export const withNotes = (pin: BuildPin, notes: readonly BuildNote[] | undefined): BuildPin => | |
| 328 | notes === undefined || notes.length === 0 ? pin : { ...pin, note: notes[0]!.text, tagNote: pin.note, notes }; | |
| 329 | ||
| 330 | /** How many times an append retries when another write moved the notes ref first. */ | |
| 331 | const NOTES_TRIES = 8; | |
| 332 | ||
| 333 | /** | |
| 334 | * Append `note` to `notes.json` on `refs/forge/notes`, with its fingerprint | |
| 335 | * in `notes.lock`: one commit by `author`, through plumbing, landed with a | |
| 336 | * lease on the tip it read. A `notes.json` missing a locked note is refused. | |
| 337 | */ | |
| 338 | export async function appendNote( | |
| 339 | dir: string, | |
| 340 | note: BuildNote, | |
| 341 | author: { readonly name: string; readonly email: string }, | |
| 342 | message: string, | |
| 343 | ): Promise<Result<string, "moved" | "git_failed" | "notes_lost">> { | |
| 344 | const env = { | |
| 345 | GIT_AUTHOR_NAME: oneLine(author.name) || "forge", | |
| 346 | GIT_AUTHOR_EMAIL: oneLine(author.email), | |
| 347 | GIT_COMMITTER_NAME: oneLine(author.name) || "forge", | |
| 348 | GIT_COMMITTER_EMAIL: oneLine(author.email), | |
| 349 | }; | |
| 350 | for (let attempt = 0; attempt < NOTES_TRIES; attempt++) { | |
| 351 | const tip = await notesTip(dir); | |
| 352 | const state = await readNotesState(dir, tip); | |
| 353 | if (state === undefined) return err("git_failed"); | |
| 354 | const have = new Set(state.notes.map(noteFingerprint)); | |
| 355 | if (state.lock.some((h) => !have.has(h))) return err("notes_lost"); | |
| 356 | const notes = [...state.notes, note]; | |
| 357 | const json = await runGit(["hash-object", "-w", "--stdin"], { cwd: dir, stdin: `${JSON.stringify({ notes }, null, 2)}\n` }); | |
| 358 | const lock = await runGit(["hash-object", "-w", "--stdin"], { | |
| 359 | cwd: dir, | |
| 360 | stdin: `${JSON.stringify([...new Set(notes.map(noteFingerprint))].sort(), null, 1)}\n`, | |
| 361 | }); | |
| 362 | if (!json.ok || !lock.ok) return err("git_failed"); | |
| 363 | const drop = new Set([NOTES_FILE, NOTES_LOCK]); | |
| 364 | const lines = [...state.entries].filter(([name]) => !drop.has(name)).map(([, e]) => e.line); | |
| 365 | lines.push(`100644 blob ${json.value.trim()}\t${NOTES_FILE}`, `100644 blob ${lock.value.trim()}\t${NOTES_LOCK}`); | |
| 366 | const tree = await runGit(["mktree", "-z"], { cwd: dir, stdin: lines.map((l) => `${l}\0`).join("") }); | |
| 367 | if (!tree.ok) return err("git_failed"); | |
| 368 | const commit = await runGit(["commit-tree", tree.value.trim(), ...(tip === undefined ? [] : ["-p", tip]), "-m", message], { cwd: dir, env }); | |
| 369 | if (!commit.ok) return err("git_failed"); | |
| 370 | const oid = commit.value.trim(); | |
| 371 | const moved = await runGitCapture(["update-ref", NOTES_REF, oid, tip ?? ""], { cwd: dir }); | |
| 372 | if (moved.code === 0) return ok(oid); | |
| 373 | } | |
| 374 | return err("moved"); | |
| 375 | } | |
| 376 | ||
| 377 | /** | |
| 378 | * Add a note to the pin `label`: `text` by `who`, now, appended to its | |
| 379 | * notes (`appendNote`). Answers the pin with its notes, the new note and | |
| 380 | * the commit. The tag stays. | |
| 381 | */ | |
| 382 | export async function notePinBuild( | |
| 383 | dir: string, | |
| 384 | label: string, | |
| 385 | text: string, | |
| 386 | who: { readonly name: string; readonly kind: NoteKind }, | |
| 387 | author: { readonly name: string; readonly email: string }, | |
| 388 | ): Promise<Result<{ readonly pin: BuildPin; readonly note: BuildNote; readonly commit: string }, SetNoteError>> { | |
| 389 | if (!isPinLabel(label)) return err("not_found"); | |
| 390 | const clean = text.replace(/\r/g, "").trim(); | |
| 391 | if (!isNotes(clean)) return err("invalid_note"); | |
| 392 | const pin = await readTagPin(dir, label); | |
| 393 | if (pin === undefined) return err("not_found"); | |
| 394 | const note: BuildNote = { iteration: label, by: who.name, kind: who.kind, at: new Date().toISOString(), text: clean }; | |
| 395 | const written = await appendNote(dir, note, author, `Note on ${label}`); | |
| 396 | if (!written.ok) return written; | |
| 397 | const notes = (await readBuildNotes(dir, [label])).get(label) ?? [note]; | |
| 398 | return ok({ pin: withNotes(pin, notes), note, commit: written.value }); | |
| 399 | } | |
| 400 | ||
| 401 | export type DeletePinError = "not_found" | "moved" | "git_failed"; | |
| 402 | ||
| 403 | /** | |
| 404 | * Delete the pin `label`: `refs/builds/<label>`, only from the tag read | |
| 405 | * here, so a pin changed meanwhile is not deleted. The commit stays. | |
| 406 | */ | |
| 407 | export async function deletePin(dir: string, label: string): Promise<Result<{ readonly oid: string; readonly tag: string }, DeletePinError>> { | |
| 408 | if (!isPinLabel(label)) return err("not_found"); | |
| 409 | const read = async (ref: string): Promise<string | undefined> => { | |
| 410 | const at = await runGitCapture(["rev-parse", "--verify", "--quiet", ref], { cwd: dir }); | |
| 411 | const oid = at.stdout.trim(); | |
| 412 | return at.code === 0 && isSafeOid(oid) ? oid : undefined; | |
| 413 | }; | |
| 414 | const ref = `${BUILDS_PREFIX}${label}`; | |
| 415 | const tag = await read(ref); | |
| 416 | if (tag === undefined) return err("not_found"); | |
| 417 | const commit = await runGitCapture(["rev-parse", "--verify", "--quiet", `${tag}^{commit}`], { cwd: dir }); | |
| 418 | const lines = [`delete ${ref} ${tag}`]; | |
| 419 | const done = await runGitCapture(["update-ref", "--stdin"], { cwd: dir, stdin: `start\n${lines.join("\n")}\nprepare\ncommit\n` }); | |
| 420 | if (done.code !== 0) return err(/cannot lock|is at|expected/i.test(done.stderr) ? "moved" : "git_failed"); | |
| 421 | return ok({ oid: commit.stdout.trim(), tag }); | |
| 422 | } | |
| 423 | ||
| 424 | /** One pin by label with its tag message's note, its notes file not read. */ | |
| 425 | async function readTagPin(dir: string, label: string): Promise<BuildPin | undefined> { | |
| 426 | if (!isPinLabel(label)) return undefined; | |
| 427 | return (await listTagPins(dir, label))[0]; | |
| 428 | } | |
| 429 | ||
| 430 | /** | |
| 431 | * Every pin, newest first, read in one `for-each-ref`; or the one `label`. | |
| 432 | * A pin's note is its notes file on `refs/forge/notes` when it has one, | |
| 433 | * else its tag message's. | |
| 434 | */ | |
| 435 | export async function listBuildPins(dir: string, label?: string): Promise<BuildPin[]> { | |
| 436 | const pins = await listTagPins(dir, label); | |
| 437 | const notes = await readBuildNotes(dir, pins.map((p) => p.label)); | |
| 438 | if (notes.size === 0) return pins; | |
| 439 | return pins.map((p) => withNotes(p, notes.get(p.label))); | |
| 440 | } | |
| 441 | ||
| 442 | /** `listBuildPins` with each note as its tag message holds it. */ | |
| 443 | async function listTagPins(dir: string, label?: string): Promise<BuildPin[]> { | |
| 444 | const listed = await runGit( | |
| 445 | [ | |
| 446 | "for-each-ref", | |
| 447 | "--sort=-taggerdate", | |
| 448 | `--count=${PIN_LIMIT}`, | |
| 449 | "--format=%(refname:strip=2)%00%(*objectname)%00%(*objecttype)%00%(taggername)%00%(taggerdate:iso-strict)%00%(contents)%00", | |
| 450 | label === undefined ? BUILDS_PREFIX.slice(0, -1) : `${BUILDS_PREFIX}${label}`, | |
| 451 | ], | |
| 452 | { cwd: dir }, | |
| 453 | ); | |
| 454 | if (!listed.ok || listed.value === "") return []; | |
| 455 | const fields = listed.value.split("\0"); | |
| 456 | const pins: BuildPin[] = []; | |
| 457 | for (let i = 0; i + 5 < fields.length; i += 6) { | |
| 458 | const label = fields[i]!.replace(/^\n/, ""); | |
| 459 | if (fields[i + 2] !== "commit" || !isPinLabel(label)) continue; | |
| 460 | const message = parsePinMessage(fields[i + 5]!); | |
| 461 | pins.push({ | |
| 462 | label, | |
| 463 | oid: fields[i + 1]!, | |
| 464 | note: message.note, | |
| 465 | parent: message.parent, | |
| 466 | source: message.source, | |
| 467 | pinnedBy: fields[i + 3]!, | |
| 468 | pinnedAt: new Date(fields[i + 4]!).toISOString(), | |
| 469 | }); | |
| 470 | } | |
| 471 | return pins; | |
| 472 | } |
src/http/api.ts +2
| @@ -343,6 +343,7 @@ import { FORGE_NOT_CARRIED } from "../origins/words.ts"; | ||
| 343 | 343 | import { forgeOriginRoutes } from "./api/forge_origin.ts"; |
| 344 | 344 | import { userRoutes } from "./api/user.ts"; |
| 345 | 345 | import { appRoutes } from "./api/app.ts"; |
| 346 | import { buildRoutes } from "./api/builds.ts"; | |
| 346 | 347 | import { appNoteRoutes } from "./api/app_notes.ts"; |
| 347 | 348 | import { reportRoutes } from "./api/reports.ts"; |
| 348 | 349 | import { rollbackRoutes } from "./api/rollback.ts"; |
| @@ -3512,6 +3513,7 @@ export function apiRoutes(router: Router<Handler>): void { | ||
| 3512 | 3513 | }); |
| 3513 | 3514 | |
| 3514 | 3515 | appRoutes(router); |
| 3516 | buildRoutes(router); | |
| 3515 | 3517 | |
| 3516 | 3518 | egressGrantRoutes(router); |
| 3517 | 3519 |
src/http/api/builds.ts added +64
| @@ -0,0 +1,64 @@ | ||
| 1 | /** | |
| 2 | * Build pins over the API: list, pin, add a note, delete | |
| 3 | * (`actions/builds.ts`, `git/builds.ts`). Reading is the repository's | |
| 4 | * readers'; every write needs the `rw` scope and a person who writes. | |
| 5 | */ | |
| 6 | ||
| 7 | import { deletePin, notePin, pinBuild } from "../../actions/builds.ts"; | |
| 8 | import { notePhase, takeSessionTime } from "../../core/route_timing.ts"; | |
| 9 | import { listBuildPins } from "../../git/builds.ts"; | |
| 10 | import { repoDir } from "../../git/repos.ts"; | |
| 11 | import { loadRepo } from "../api.ts"; | |
| 12 | import type { Handler } from "../context.ts"; | |
| 13 | import { body, json, notFound, requireScope, respondAccess, str } from "../respond.ts"; | |
| 14 | import type { Router } from "../router.ts"; | |
| 15 | ||
| 16 | const STATUS: Record<string, number> = { exists: 409, mirrored: 409, moved: 409 }; | |
| 17 | ||
| 18 | export function buildRoutes(router: Router<Handler>): void { | |
| 19 | router.add("GET", "/api/v1/repos/:owner/:name/builds", async (ctx) => { | |
| 20 | notePhase(ctx.request, "session", takeSessionTime(ctx.request)); | |
| 21 | const repo = loadRepo(ctx); | |
| 22 | if (!repo) return notFound(); | |
| 23 | return json({ items: await listBuildPins(repoDir(ctx.app.reposRoot, repo.ownerName, repo.name)), next_cursor: null }); | |
| 24 | }); | |
| 25 | ||
| 26 | router.add("POST", "/api/v1/repos/:owner/:name/builds", async (ctx) => { | |
| 27 | const denied = requireScope(ctx, "rw"); | |
| 28 | if (denied) return denied; | |
| 29 | const repo = loadRepo(ctx); | |
| 30 | if (!repo) return notFound(); | |
| 31 | const input = await body(ctx.request); | |
| 32 | const pinned = await pinBuild(ctx.app, ctx.auth.user, repo, { | |
| 33 | label: str(input.label), | |
| 34 | oid: str(input.sha), | |
| 35 | note: str(input.note), | |
| 36 | parent: str(input.parent), | |
| 37 | source: str(input.source) || "api", | |
| 38 | }); | |
| 39 | return pinned.ok ? json(pinned.value, 201) : respondAccess(pinned.error, STATUS[pinned.error] ?? 422); | |
| 40 | }); | |
| 41 | ||
| 42 | router.add("DELETE", "/api/v1/repos/:owner/:name/builds/:label", async (ctx) => { | |
| 43 | const denied = requireScope(ctx, "rw"); | |
| 44 | if (denied) return denied; | |
| 45 | const repo = loadRepo(ctx); | |
| 46 | if (!repo) return notFound(); | |
| 47 | const done = await deletePin(ctx.app, ctx.auth.user, repo, ctx.params.label ?? ""); | |
| 48 | return done.ok ? new Response(null, { status: 204 }) : respondAccess(done.error, STATUS[done.error] ?? 422); | |
| 49 | }); | |
| 50 | ||
| 51 | router.add("PATCH", "/api/v1/repos/:owner/:name/builds/:label", async (ctx) => { | |
| 52 | const denied = requireScope(ctx, "rw"); | |
| 53 | if (denied) return denied; | |
| 54 | const repo = loadRepo(ctx); | |
| 55 | if (!repo) return notFound(); | |
| 56 | const input = await body(ctx.request); | |
| 57 | const done = await notePin(ctx.app, ctx.auth.user, repo, ctx.params.label ?? "", { | |
| 58 | ...(typeof input.notes === "string" ? { notes: input.notes } : {}), | |
| 59 | ...(typeof input.note === "string" ? { note: input.note } : {}), | |
| 60 | ...(typeof input.body === "string" ? { body: input.body } : {}), | |
| 61 | }); | |
| 62 | return done.ok ? json(done.value.pin) : respondAccess(done.error, STATUS[done.error] ?? 422); | |
| 63 | }); | |
| 64 | } |
src/http/server.ts +7
| @@ -183,6 +183,7 @@ import { | ||
| 183 | 183 | branchDelete, |
| 184 | 184 | branchesPage, |
| 185 | 185 | } from "../web/pages/branches.ts"; |
| 186 | import { buildsPage, deletePinSubmit, newBuildPage, pinBuildSubmit, previewBuildSubmit } from "../web/pages/builds.ts"; | |
| 186 | 187 | import { wavePage, wavesAtom, wavesPage } from "../web/pages/waves.ts"; |
| 187 | 188 | import { |
| 188 | 189 | contributeSubmit, |
| @@ -1255,6 +1256,12 @@ export function buildRouter(): Router<Handler> { | ||
| 1255 | 1256 | // The repository's activity, for a feed reader. |
| 1256 | 1257 | router.add("GET", "/:owner/:name/activity.atom", activityAtom); |
| 1257 | 1258 | router.add("GET", "/:owner/:name/branches", branchesPage); |
| 1259 | // Build pins (`git/builds.ts`): the list, the form, and the writes behind them. | |
| 1260 | router.add("GET", "/:owner/:name/builds", buildsPage); | |
| 1261 | router.add("GET", "/:owner/:name/builds/new", newBuildPage); | |
| 1262 | router.add("POST", "/:owner/:name/builds", pinBuildSubmit); | |
| 1263 | router.add("POST", "/:owner/:name/builds/preview", previewBuildSubmit); | |
| 1264 | router.add("POST", "/:owner/:name/builds/:label/delete", deletePinSubmit); | |
| 1258 | 1265 | router.add("GET", "/:owner/:name/waves.atom", wavesAtom); |
| 1259 | 1266 | router.add("GET", "/:owner/:name/waves", wavesPage); |
| 1260 | 1267 | router.add("GET", "/:owner/:name/waves/:version", wavePage); |
src/main.ts +2
| @@ -251,6 +251,8 @@ const app = createApp({ | ||
| 251 | 251 | // The repository holding this forge's own code: a landing there is |
| 252 | 252 | // checked out under releases/ and preflighted (#1662). |
| 253 | 253 | ...releaseEnv(process.env), |
| 254 | // FORGE_NOTES_INTENT: the intent doc people's build notes are copied into. | |
| 255 | ...((process.env.FORGE_NOTES_INTENT ?? "").trim() !== "" ? { notesIntent: (process.env.FORGE_NOTES_INTENT ?? "").trim() } : {}), | |
| 254 | 256 | // The one knob on the crew's bill: FORGE_CREW_CEILING_USD, dollars a day |
| 255 | 257 | // across every repository. Unset is no forge-wide ceiling. |
| 256 | 258 | ...(process.env.FORGE_MIGRATE_PRIVATE_HOSTS === "1" |
src/tui/screens/builds.ts added +175
| @@ -0,0 +1,175 @@ | ||
| 1 | /** | |
| 2 | * The Builds command, a repository's: its pinned builds, newest first, as | |
| 3 | * the builds page lists them (`listBuildPins`) — label, short commit, who | |
| 4 | * pinned it, when, and the first line of its note. Offered to, and read | |
| 5 | * for, only someone who may read the repository. A person who writes it | |
| 6 | * (never an agent) is also offered `p` pin, `n` note and `d` delete, each | |
| 7 | * through the page's own actions (`pinBuild`, `notePin`, `deletePin`), | |
| 8 | * which check again. | |
| 9 | */ | |
| 10 | ||
| 11 | import { deletePin, notePin, pinBuild } from "../../actions/builds.ts"; | |
| 12 | import { canRead, canWrite, type Repo } from "../../core/repos.ts"; | |
| 13 | import { listBuildPins, type BuildPin } from "../../git/builds.ts"; | |
| 14 | import { refTip } from "../../git/read.ts"; | |
| 15 | import { repoDir } from "../../git/repos.ts"; | |
| 16 | import { nextLabel } from "../../web/pages/builds.ts"; | |
| 17 | import type { Ctx, Screen } from "../app/app.ts"; | |
| 18 | import { binding } from "../app/keys.ts"; | |
| 19 | import type { Command } from "../app/palette.ts"; | |
| 20 | import type { Session } from "../session.ts"; | |
| 21 | import { style } from "../term/style.ts"; | |
| 22 | import { truncate } from "../term/width.ts"; | |
| 23 | import { ListState, ago, confirmScreen, metaOf, promptScreen } from "./common.ts"; | |
| 24 | ||
| 25 | export const NOT_FOUND_LINE = "No such repository"; | |
| 26 | export const NO_BUILDS_LINE = "No builds pinned yet."; | |
| 27 | export const DELETED_LINE = "Deleted that pin; its commit and notes stay."; | |
| 28 | ||
| 29 | /** What a refused or failed action reads as. */ | |
| 30 | const WORDS: Record<string, string> = { | |
| 31 | invalid_label: "A label is letters, digits, . _ and -, at most 40.", | |
| 32 | invalid_parent: "That parent is not a pin label.", | |
| 33 | not_a_commit: "That is not a commit in this repository.", | |
| 34 | exists: "That label is already pinned.", | |
| 35 | invalid_note: "A note is plain text, not blank, at most 10 KB.", | |
| 36 | not_found: "That pin is gone.", | |
| 37 | moved: "That pin changed meanwhile; try again.", | |
| 38 | mirrored: "A mirror's builds are its origin's.", | |
| 39 | notes_lost: "The notes ref lost a note; nothing was written.", | |
| 40 | }; | |
| 41 | const say = (error: string): string => WORDS[error] ?? "Only a person who writes this repository can do that."; | |
| 42 | ||
| 43 | /** Whether `f`'s person may pin, note or delete here: a writer who is not an agent. */ | |
| 44 | export const mayPin = (f: Session, repo: Repo): boolean => | |
| 45 | f.user !== undefined && f.user.kind !== "agent" && canWrite(f.db, repo, f.user); | |
| 46 | ||
| 47 | /** "label sha note…": the label, then the commit, then the rest as the note. */ | |
| 48 | export function parsePin(entry: string): { label: string; oid: string; note: string } | undefined { | |
| 49 | const [label = "", oid = "", ...rest] = entry.trim().split(/\s+/); | |
| 50 | return label === "" || oid === "" ? undefined : { label, oid, note: rest.join(" ") }; | |
| 51 | } | |
| 52 | ||
| 53 | export function buildsScreen(f: Session, ctx: Ctx, repo: Repo): Screen { | |
| 54 | const dir = repoDir(f.app.reposRoot, repo.ownerName, repo.name); | |
| 55 | let pins: BuildPin[] | undefined; | |
| 56 | let hidden = false; | |
| 57 | let note = ""; | |
| 58 | const state = new ListState(); | |
| 59 | const reload = async (): Promise<void> => { | |
| 60 | pins = await listBuildPins(dir); | |
| 61 | state.clamp(pins.length); | |
| 62 | ctx.invalidate(); | |
| 63 | }; | |
| 64 | const done = async (line: string): Promise<void> => { | |
| 65 | note = line; | |
| 66 | await reload(); | |
| 67 | }; | |
| 68 | ||
| 69 | const pin = async (): Promise<void> => { | |
| 70 | const tip = await refTip(dir, `refs/heads/${repo.defaultBranch}`); | |
| 71 | ctx.push( | |
| 72 | promptScreen(ctx, screen, { | |
| 73 | title: "Pin a build", | |
| 74 | label: "label, commit, then a note", | |
| 75 | value: `${nextLabel(pins ?? [])} ${tip ?? ""} `, | |
| 76 | onSubmit: (entry) => { | |
| 77 | const got = parsePin(entry); | |
| 78 | if (got === undefined) return "Give a label and a commit."; | |
| 79 | return ctx.track( | |
| 80 | pinBuild(f.app, f.user, repo, { ...got, source: "terminal" }).then((r) => done(r.ok ? `Pinned ${r.value.label}.` : say(r.error))), | |
| 81 | ); | |
| 82 | }, | |
| 83 | }), | |
| 84 | ); | |
| 85 | }; | |
| 86 | const addNote = (picked: BuildPin): void => { | |
| 87 | ctx.push( | |
| 88 | promptScreen(ctx, screen, { | |
| 89 | title: `Note on ${picked.label}`, | |
| 90 | label: "what you saw", | |
| 91 | onSubmit: (text) => | |
| 92 | ctx.track(notePin(f.app, f.user, repo, picked.label, { notes: text }).then((r) => done(r.ok ? `Noted ${picked.label}.` : say(r.error)))), | |
| 93 | }), | |
| 94 | ); | |
| 95 | }; | |
| 96 | const ask = (picked: BuildPin): void => { | |
| 97 | ctx.push( | |
| 98 | confirmScreen(ctx, screen, { | |
| 99 | title: "Delete a pin", | |
| 100 | body: [`Delete the pin ${picked.label}? Its commit and notes stay.`], | |
| 101 | actions: [{ label: "Keep", variant: "ghost" }, { label: "Delete", variant: "danger" }], | |
| 102 | onChoose: (i) => { | |
| 103 | if (i < 1) return; | |
| 104 | void ctx.track(deletePin(f.app, f.user, repo, picked.label).then((r) => done(r.ok ? DELETED_LINE : say(r.error)))); | |
| 105 | }, | |
| 106 | }), | |
| 107 | ); | |
| 108 | }; | |
| 109 | ||
| 110 | const screen: Screen = { | |
| 111 | title: "Builds", | |
| 112 | // Read each time: someone who loses write access stops being offered the writes. | |
| 113 | get keys() { | |
| 114 | return [ | |
| 115 | binding(["j", "k"], "move"), | |
| 116 | ...(mayPin(f, repo) ? [binding(["p"], "pin"), binding(["n"], "note"), binding(["d"], "delete")] : []), | |
| 117 | binding(["esc", "q"], "back"), | |
| 118 | ]; | |
| 119 | }, | |
| 120 | async load() { | |
| 121 | // Gated here as well as on the command: the screen is exported. | |
| 122 | hidden = !canRead(f.db, repo, f.user); | |
| 123 | if (hidden) pins = []; | |
| 124 | else await reload(); | |
| 125 | ctx.invalidate(); | |
| 126 | }, | |
| 127 | render(area, s) { | |
| 128 | const c = ctx.theme.c; | |
| 129 | const right = area.x + area.w; | |
| 130 | const line = (y: number, text: string, fg = c.fg) => s.text(area.x + 1, area.y + y, truncate(text, area.w - 1), style(fg, c.bg), right); | |
| 131 | if (hidden) return void line(0, NOT_FOUND_LINE); | |
| 132 | if (pins === undefined) return void line(0, "Reading…"); | |
| 133 | const rows = note === "" ? area.h : Math.max(1, area.h - 1); | |
| 134 | if (note !== "") line(area.h - 1, note, c.muted); | |
| 135 | if (pins.length === 0) return void line(0, NO_BUILDS_LINE); | |
| 136 | const from = state.window(rows, pins.length); | |
| 137 | for (let i = 0; i < rows; i++) { | |
| 138 | const p = pins[from + i]; | |
| 139 | if (p === undefined) break; | |
| 140 | const picked = from + i === state.index; | |
| 141 | s.text(area.x, area.y + i, picked ? ">" : " ", style(c.accent, c.bg), right); | |
| 142 | const first = p.note.split("\n")[0] ?? ""; | |
| 143 | line(i, `${p.label} ${metaOf(p.oid.slice(0, 7), p.pinnedBy, ago(p.pinnedAt), first === p.label ? "" : first)}`, picked ? c.fg : c.muted); | |
| 144 | } | |
| 145 | }, | |
| 146 | key(e) { | |
| 147 | note = ""; | |
| 148 | const picked = pins?.[state.index]; | |
| 149 | const write = !e.ctrl && !e.alt && !hidden && mayPin(f, repo); | |
| 150 | if (e.key === "j" || e.key === "down") state.move(1, pins?.length ?? 0); | |
| 151 | else if (e.key === "k" || e.key === "up") state.move(-1, pins?.length ?? 0); | |
| 152 | else if (e.key === "esc" || e.key === "q") ctx.pop(); | |
| 153 | else if (e.key === "p" && write) void ctx.track(pin()); | |
| 154 | else if (e.key === "n" && write && picked !== undefined) addNote(picked); | |
| 155 | else if (e.key === "d" && write && picked !== undefined) ask(picked); | |
| 156 | else return false; | |
| 157 | ctx.invalidate(); | |
| 158 | return true; | |
| 159 | }, | |
| 160 | }; | |
| 161 | return screen; | |
| 162 | } | |
| 163 | ||
| 164 | /** The palette's door on a repository screen, for whoever may read it. */ | |
| 165 | export function buildsCommands(f: Session, ctx: Ctx, repo: Repo | undefined): Command[] { | |
| 166 | if (repo === undefined || !canRead(f.db, repo, f.user)) return []; | |
| 167 | return [ | |
| 168 | { | |
| 169 | id: "builds", | |
| 170 | title: `${repo.ownerName}/${repo.name} — Builds`, | |
| 171 | hint: "its pinned builds; a writer pins, notes and deletes", | |
| 172 | run: () => ctx.push(buildsScreen(f, ctx, repo)), | |
| 173 | }, | |
| 174 | ]; | |
| 175 | } |
src/tui/screens/workspace.ts +2
| @@ -133,6 +133,7 @@ import { makeItYoursScreen, yoursCommands } from "./yours.ts"; | ||
| 133 | 133 | import { deleteRepoCommands } from "./deleterepo.ts"; |
| 134 | 134 | import { grantsCommands } from "./grants.ts"; |
| 135 | 135 | import { visionCommands } from "./vision.ts"; |
| 136 | import { buildsCommands } from "./builds.ts"; | |
| 136 | 137 | import { labelsCommands } from "./labels.ts"; |
| 137 | 138 | import { appEnvCommands } from "./appenv.ts"; |
| 138 | 139 | import { autonomyCommands } from "./autonomy.ts"; |
| @@ -1450,6 +1451,7 @@ export function workspaceScreen( | ||
| 1450 | 1451 | ...hooksCommands(f, ctx, repo), |
| 1451 | 1452 | ...visionCommands(f, ctx, repo), |
| 1452 | 1453 | ...labelsCommands(f, ctx, repo), |
| 1454 | ...buildsCommands(f, ctx, repo), | |
| 1453 | 1455 | ...appEnvCommands(f, ctx, repo), |
| 1454 | 1456 | ...autonomyCommands(f, ctx, repo), |
| 1455 | 1457 | ], |
src/web/pages/branches.ts +10
| @@ -210,6 +210,15 @@ function branchRow( | ||
| 210 | 210 | </span> |
| 211 | 211 | <span class="row-trail"> |
| 212 | 212 | ${divergence(row)} |
| 213 | ${when(canWrite, () => | |
| 214 | btn("Pin", { | |
| 215 | href: `${base}/builds/new?sha=${row.oid}&source=${encodeURIComponent(`branch ${row.name}`)}`, | |
| 216 | size: "sm", | |
| 217 | variant: "outline", | |
| 218 | icon: "pin", | |
| 219 | label: `Pin a build of ${row.name}`, | |
| 220 | }), | |
| 221 | )} | |
| 213 | 222 | ${when(!row.isDefault && canWrite, () => |
| 214 | 223 | btn("New pull", { |
| 215 | 224 | href: `${base}/pulls/new?head=${encoded}`, |
| @@ -311,6 +320,7 @@ export async function renderBranches( | ||
| 311 | 320 | head: html`<h2> |
| 312 | 321 | ${rows.length} branch${rows.length === 1 ? "" : "es"} |
| 313 | 322 | </h2> |
| 323 | ${when(scope.kind === "local", () => html`<span class="spacer"></span>${btn("Builds", { href: `${scope.base}/builds`, size: "sm", variant: "ghost", icon: "pin" })}`)} | |
| 314 | 324 | ${when( |
| 315 | 325 | !compared && scope.kind === "local", |
| 316 | 326 | () => |
src/web/pages/builds.ts added +227
| @@ -0,0 +1,227 @@ | ||
| 1 | /** | |
| 2 | * Builds: the pins people made (`git/builds.ts`), a short form to make one, | |
| 3 | * and "preview now" for any commit. A pin is a git ref, so pinning runs no | |
| 4 | * check and wakes no agent. | |
| 5 | */ | |
| 6 | ||
| 7 | import { notePhase, takeSessionTime } from "../../core/route_timing.ts"; | |
| 8 | import { previewBuild } from "../../actions/build_preview.ts"; | |
| 9 | import { openEnvUrl } from "../../actions/apps.ts"; | |
| 10 | import { deletePin, pinBuild } from "../../actions/builds.ts"; | |
| 11 | import { canWrite } from "../../core/repos.ts"; | |
| 12 | import { exactRefOid } from "../../git/branches.ts"; | |
| 13 | import { listBuildPins, type BuildPin } from "../../git/builds.ts"; | |
| 14 | import { isSafeOid } from "../../git/guard.ts"; | |
| 15 | import { repoDir } from "../../git/repos.ts"; | |
| 16 | import type { Ctx } from "../../http/context.ts"; | |
| 17 | import { crumbsFor, loadRepo, notFoundPage, repoBase, repoHeader, repoPalette, signInFirst } from "../chrome.ts"; | |
| 18 | import { field as formValue, readForm } from "../form.ts"; | |
| 19 | import { html, when } from "../html.ts"; | |
| 20 | import { flashTo, page } from "../layout.ts"; | |
| 21 | import { btn, card, field, icon, sha, time } from "../ui.ts"; | |
| 22 | ||
| 23 | /** The newest label stepped on by one. */ | |
| 24 | export function nextLabel(pins: readonly BuildPin[]): string { | |
| 25 | const last = pins[0]?.label; | |
| 26 | if (last === undefined) return "v1"; | |
| 27 | const letter = /^(.*\d)([a-y])$/.exec(last); | |
| 28 | if (letter) return `${letter[1]}${String.fromCharCode(letter[2]!.charCodeAt(0) + 1)}`; | |
| 29 | const num = /^(.*?)(\d+)$/.exec(last); | |
| 30 | if (num) return `${num[1]}${Number(num[2]) + 1}`; | |
| 31 | return `${last}-2`; | |
| 32 | } | |
| 33 | ||
| 34 | /** Renders a page and names `session` and `render` in Server-Timing. */ | |
| 35 | function timedPage(ctx: Ctx, opts: Parameters<typeof page>[0]): Response { | |
| 36 | notePhase(ctx.request, "session", takeSessionTime(ctx.request)); | |
| 37 | const started = performance.now(); | |
| 38 | const response = page(opts); | |
| 39 | notePhase(ctx.request, "render", performance.now() - started); | |
| 40 | return response; | |
| 41 | } | |
| 42 | ||
| 43 | // GET /:owner/:name/builds — the pins, newest first. | |
| 44 | export async function buildsPage(ctx: Ctx): Promise<Response> { | |
| 45 | const repo = loadRepo(ctx); | |
| 46 | if (!repo) return notFoundPage(ctx); | |
| 47 | const base = repoBase(repo); | |
| 48 | const pins = await listBuildPins(repoDir(ctx.app.reposRoot, repo.ownerName, repo.name)); | |
| 49 | const writer = ctx.auth.user !== undefined && canWrite(ctx.app.db, repo, ctx.auth.user); | |
| 50 | const body = html`${repoHeader(ctx, repo, "branches", { compact: true })} | |
| 51 | <div class="mt"> | |
| 52 | ${card( | |
| 53 | pins.length === 0 | |
| 54 | ? html`<p class="muted builds-none">No builds pinned yet.</p>` | |
| 55 | : html`<ul class="list"> | |
| 56 | ${pins.map( | |
| 57 | (p) => html`<li> | |
| 58 | <div class="row build-row"> | |
| 59 | <span class="row-lead">${icon("pin")}</span> | |
| 60 | <span class="row-main"> | |
| 61 | <span class="row-title mono">${p.label}</span> | |
| 62 | <span class="row-meta" | |
| 63 | >${sha(p.oid, `${base}/commit/${p.oid}`)} · ${p.source} · pinned ${time(p.pinnedAt)} by | |
| 64 | ${p.pinnedBy}${when(p.parent, (parent) => html` · under ${parent}`)}${when( | |
| 65 | p.note, | |
| 66 | (note) => html` · ${note}`, | |
| 67 | )}</span | |
| 68 | > | |
| 69 | </span> | |
| 70 | <span class="row-trail"> | |
| 71 | ${when(writer, () => | |
| 72 | html`<form method="post" action="${base}/builds/preview" class="build-preview"> | |
| 73 | <input type="hidden" name="sha" value="${p.oid}" /> | |
| 74 | ${btn("Preview", { type: "submit", size: "sm", variant: "outline", icon: "eye" })} | |
| 75 | </form>`, | |
| 76 | )} | |
| 77 | ${when(writer, () => | |
| 78 | btn("Delete", { | |
| 79 | href: `/-/confirm?to=${encodeURIComponent(`${base}/builds/${encodeURIComponent(p.label)}/delete`)}`, | |
| 80 | size: "sm", | |
| 81 | variant: "danger", | |
| 82 | label: `Delete pin ${p.label}`, | |
| 83 | }), | |
| 84 | )} | |
| 85 | </span> | |
| 86 | </div> | |
| 87 | </li>`, | |
| 88 | )} | |
| 89 | </ul>`, | |
| 90 | { | |
| 91 | head: html`${icon("pin")} | |
| 92 | <h2>Builds</h2> | |
| 93 | ${when(writer, () => btn("Pin a build", { href: `${base}/builds/new`, size: "sm", variant: "primary" }))}`, | |
| 94 | }, | |
| 95 | )} | |
| 96 | </div>`; | |
| 97 | return timedPage(ctx, { ctx, title: `Builds · ${repo.name}`, crumbs: crumbsFor(repo), palette: repoPalette(ctx, repo), body }); | |
| 98 | } | |
| 99 | ||
| 100 | // GET /:owner/:name/builds/new?sha=&source= — the pin form. | |
| 101 | export async function newBuildPage(ctx: Ctx): Promise<Response> { | |
| 102 | const repo = loadRepo(ctx); | |
| 103 | if (!repo) return notFoundPage(ctx); | |
| 104 | if (!ctx.auth.user) return signInFirst(ctx); | |
| 105 | if (!canWrite(ctx.app.db, repo, ctx.auth.user)) return notFoundPage(ctx); | |
| 106 | const base = repoBase(repo); | |
| 107 | const dir = repoDir(ctx.app.reposRoot, repo.ownerName, repo.name); | |
| 108 | const pins = await listBuildPins(dir); | |
| 109 | const asked = ctx.url.searchParams.get("sha") ?? ""; | |
| 110 | const want = isSafeOid(asked) ? asked : ((await exactRefOid(dir, `refs/heads/${repo.defaultBranch}`)) ?? ""); | |
| 111 | const source = ctx.url.searchParams.get("source") ?? (isSafeOid(asked) ? "" : `branch ${repo.defaultBranch}`); | |
| 112 | const body = html`${repoHeader(ctx, repo, "branches", { compact: true })} | |
| 113 | <div class="mt"> | |
| 114 | ${card( | |
| 115 | html`<form method="post" action="${base}/builds" class="stack"> | |
| 116 | ${field({ label: "Commit", name: "sha", value: isSafeOid(want) ? want : "", required: true, mono: true, hint: "Full commit SHA." })} | |
| 117 | <input type="hidden" name="source" value="${source}" /> | |
| 118 | ${field({ label: "Label", name: "label", value: nextLabel(pins), required: true, mono: true, hint: "Letters, digits, dot, dash or underscore. Example: v3." })} | |
| 119 | ${field({ label: "Note", name: "note", rows: 3, hint: "Optional." })} | |
| 120 | <div class="bar"> | |
| 121 | ${btn("Pin build", { variant: "primary", icon: "pin" })} | |
| 122 | ${btn("Cancel", { href: `${base}/builds`, variant: "ghost" })} | |
| 123 | </div> | |
| 124 | </form>`, | |
| 125 | { head: html`${icon("pin")}<h2>Pin a build</h2>` }, | |
| 126 | )} | |
| 127 | </div>`; | |
| 128 | return timedPage(ctx, { ctx, title: `Pin a build · ${repo.name}`, crumbs: crumbsFor(repo), palette: repoPalette(ctx, repo), body }); | |
| 129 | } | |
| 130 | ||
| 131 | const PIN_ERRORS: Record<string, string> = { | |
| 132 | invalid_label: "Invalid label. Use letters, digits, dot, dash or underscore, up to 40 characters.", | |
| 133 | invalid_parent: "Parent is not a pin in this repository.", | |
| 134 | not_a_commit: "Commit not found in this repository.", | |
| 135 | exists: "A pin with that label already exists.", | |
| 136 | mirrored: "This repository is a mirror. Pin builds in a fork.", | |
| 137 | git_failed: "Pin failed: git refused the ref.", | |
| 138 | }; | |
| 139 | ||
| 140 | const hidden = (error: string): boolean => error === "unauthorized" || error === "not_found" || error === "forbidden"; | |
| 141 | ||
| 142 | // POST /:owner/:name/builds — pin it. | |
| 143 | export async function pinBuildSubmit(ctx: Ctx): Promise<Response> { | |
| 144 | const repo = loadRepo(ctx); | |
| 145 | if (!repo) return notFoundPage(ctx); | |
| 146 | if (!ctx.auth.user) return signInFirst(ctx); | |
| 147 | const form = await readForm(ctx, "rw"); | |
| 148 | if (form instanceof Response) return form; | |
| 149 | const base = repoBase(repo); | |
| 150 | const pinned = await pinBuild(ctx.app, ctx.auth.user, repo, { | |
| 151 | label: formValue(form, "label"), | |
| 152 | oid: formValue(form, "sha"), | |
| 153 | note: formValue(form, "note"), | |
| 154 | parent: formValue(form, "parent"), | |
| 155 | source: formValue(form, "source") || "commit", | |
| 156 | }); | |
| 157 | if (!pinned.ok) { | |
| 158 | if (hidden(pinned.error)) return notFoundPage(ctx); | |
| 159 | return flashTo(ctx, `${base}/builds/new?sha=${encodeURIComponent(formValue(form, "sha"))}`, { | |
| 160 | tone: "danger", | |
| 161 | message: PIN_ERRORS[pinned.error] ?? "Pin failed.", | |
| 162 | }); | |
| 163 | } | |
| 164 | return flashTo(ctx, `${base}/builds`, { | |
| 165 | message: `Pinned ${pinned.value.label} at ${pinned.value.oid.slice(0, 7)}.`, | |
| 166 | }); | |
| 167 | } | |
| 168 | ||
| 169 | // POST /:owner/:name/builds/:label/delete — behind /-/confirm. | |
| 170 | export async function deletePinSubmit(ctx: Ctx): Promise<Response> { | |
| 171 | const repo = loadRepo(ctx); | |
| 172 | if (!repo) return notFoundPage(ctx); | |
| 173 | if (!ctx.auth.user) return signInFirst(ctx); | |
| 174 | const form = await readForm(ctx, "rw"); | |
| 175 | if (form instanceof Response) return form; | |
| 176 | const gone = await deletePin(ctx.app, ctx.auth.user, repo, ctx.params.label ?? ""); | |
| 177 | if (!gone.ok && hidden(gone.error)) return notFoundPage(ctx); | |
| 178 | return flashTo( | |
| 179 | ctx, | |
| 180 | `${repoBase(repo)}/builds`, | |
| 181 | gone.ok ? { message: `Deleted pin ${gone.value.label}.` } : { tone: "danger", message: "Delete failed." }, | |
| 182 | ); | |
| 183 | } | |
| 184 | ||
| 185 | const PREVIEW_ERRORS: Record<string, string> = { | |
| 186 | unverified: "Verify your email before previewing a build.", | |
| 187 | mirrored: "This repository is a mirror. Preview builds in a fork.", | |
| 188 | not_a_commit: "Commit not found in this repository.", | |
| 189 | no_runtime: "Apps are disabled on this forge.", | |
| 190 | }; | |
| 191 | ||
| 192 | // POST /:owner/:name/builds/preview — serve one commit for a while, then open it. | |
| 193 | export async function previewBuildSubmit(ctx: Ctx): Promise<Response> { | |
| 194 | const repo = loadRepo(ctx); | |
| 195 | if (!repo) return notFoundPage(ctx); | |
| 196 | if (!ctx.auth.user) return signInFirst(ctx); | |
| 197 | const form = await readForm(ctx, "rw"); | |
| 198 | if (form instanceof Response) return form; | |
| 199 | const oid = formValue(form, "sha"); | |
| 200 | const preview = await previewBuild(ctx.app, ctx.auth.user, repo, oid); | |
| 201 | if (!preview.ok) { | |
| 202 | if (hidden(preview.error)) return notFoundPage(ctx); | |
| 203 | return flashTo(ctx, `${repoBase(repo)}/builds`, { | |
| 204 | tone: "danger", | |
| 205 | message: PREVIEW_ERRORS[preview.error] ?? "Preview failed.", | |
| 206 | }); | |
| 207 | } | |
| 208 | // Through the forge's own opener, so a private app's preview gets its grant. | |
| 209 | const opened = openEnvUrl(ctx.app, ctx.auth.user, repo, preview.value.env, "/"); | |
| 210 | const url = opened.ok ? opened.value : preview.value.url; | |
| 211 | // Not a redirect: a form's redirect to another origin is held to the | |
| 212 | // page's CSP form-action. A script navigation is not; the proxy there | |
| 213 | // shows a page that refreshes itself until the build is up. | |
| 214 | return page({ | |
| 215 | ctx, | |
| 216 | title: `Opening ${oid.slice(0, 7)} · ${repo.name}`, | |
| 217 | crumbs: crumbsFor(repo), | |
| 218 | scriptJs: `location.replace(${JSON.stringify(url).replace(/</g, "\\u003c")});`, | |
| 219 | body: html`<div class="mt"> | |
| 220 | ${card( | |
| 221 | html`<p>Starting build <span class="mono">${oid.slice(0, 7)}</span>.</p> | |
| 222 | <p><a class="btn" data-variant="primary" href="${url}">Open ${url}</a></p>`, | |
| 223 | { head: html`${icon("eye")}<h2>Opening preview</h2>` }, | |
| 224 | )} | |
| 225 | </div>`, | |
| 226 | }); | |
| 227 | } |
test/build-pins.test.ts added +266
| @@ -0,0 +1,266 @@ | ||
| 1 | /** | |
| 2 | * Build pins: a writer marks a commit under refs/builds/<label>; the | |
| 3 | * builds page lists them. | |
| 4 | */ | |
| 5 | import { afterAll, beforeAll, describe, expect, test } from "bun:test"; | |
| 6 | import { mkdtemp, rm } from "node:fs/promises"; | |
| 7 | import { tmpdir } from "node:os"; | |
| 8 | import { join } from "node:path"; | |
| 9 | import { createApp, type App } from "../src/app.ts"; | |
| 10 | import { notePin, pinBuild } from "../src/actions/builds.ts"; | |
| 11 | import { commitToBranch, createRepo } from "../src/actions/repos.ts"; | |
| 12 | import { getRepo } from "../src/core/repos.ts"; | |
| 13 | import { createUser, type User } from "../src/core/users.ts"; | |
| 14 | import { promote } from "./helpers/members.ts"; | |
| 15 | import { listBuildPins } from "../src/git/builds.ts"; | |
| 16 | import { repoDir } from "../src/git/repos.ts"; | |
| 17 | import { serve, type Servers } from "../src/http/server.ts"; | |
| 18 | import { nextLabel } from "../src/web/pages/builds.ts"; | |
| 19 | ||
| 20 | describe("build pins", () => { | |
| 21 | let dataDir: string; | |
| 22 | let app: App; | |
| 23 | let servers: Servers; | |
| 24 | let base: string; | |
| 25 | let alice: User; | |
| 26 | let bob: User; | |
| 27 | let aliceCookie: string; | |
| 28 | let bobCookie: string; | |
| 29 | const oids: Record<string, string> = {}; | |
| 30 | ||
| 31 | const signIn = async (username: string) => | |
| 32 | ( | |
| 33 | ( | |
| 34 | await fetch(`${base}/signin`, { | |
| 35 | method: "POST", | |
| 36 | body: new URLSearchParams({ username, password: "a-good-passphrase" }), | |
| 37 | redirect: "manual", | |
| 38 | }) | |
| 39 | ).headers.get("set-cookie") ?? "" | |
| 40 | ).split(";")[0]!; | |
| 41 | const post = (path: string, cookie: string, body: Record<string, string>) => | |
| 42 | fetch(`${base}${path}`, { | |
| 43 | method: "POST", | |
| 44 | headers: { cookie, origin: base }, | |
| 45 | body: new URLSearchParams(body), | |
| 46 | redirect: "manual", | |
| 47 | }); | |
| 48 | const fresh = () => getRepo(app.db, "alice", "game")!; | |
| 49 | const pins = () => listBuildPins(repoDir(app.reposRoot, "alice", "game")); | |
| 50 | ||
| 51 | beforeAll(async () => { | |
| 52 | dataDir = await mkdtemp(join(tmpdir(), "forge-pins-")); | |
| 53 | app = createApp({ dataDir, port: 0 }); | |
| 54 | servers = await serve(app); | |
| 55 | base = servers.url; | |
| 56 | for (const name of ["alice", "bob"]) { | |
| 57 | const made = await createUser(app.db, { origin: "seed", | |
| 58 | trusted: true, | |
| 59 | username: name, | |
| 60 | email: `${name}@forge.local`, | |
| 61 | password: "a-good-passphrase", | |
| 62 | adminIfFirst: true, | |
| 63 | }); | |
| 64 | if (!made.ok) throw new Error(made.error); | |
| 65 | if (name === "alice") alice = made.value; | |
| 66 | else bob = made.value; | |
| 67 | } | |
| 68 | promote(app.db, bob); | |
| 69 | aliceCookie = await signIn("alice"); | |
| 70 | bobCookie = await signIn("bob"); | |
| 71 | const made = await createRepo(app, alice, { name: "game", visibility: "public" }); | |
| 72 | if (!made.ok) throw new Error(String(made.error)); | |
| 73 | for (const [branch, message] of [ | |
| 74 | ["main", "one"], | |
| 75 | ["main", "two"], | |
| 76 | ["idea", "idea a"], | |
| 77 | ["idea", "idea b"], | |
| 78 | ] as const) { | |
| 79 | const c = await commitToBranch(app, alice, fresh(), { | |
| 80 | branch, | |
| 81 | message, | |
| 82 | files: [{ path: `${message}.txt`, content: `${message}\n` }], | |
| 83 | }); | |
| 84 | if (!c.ok) throw new Error(JSON.stringify(c.error)); | |
| 85 | oids[message] = c.value.commitOid; | |
| 86 | } | |
| 87 | }); | |
| 88 | ||
| 89 | afterAll(async () => { | |
| 90 | servers.stop(); | |
| 91 | app.close(); | |
| 92 | await rm(dataDir, { recursive: true, force: true }); | |
| 93 | }); | |
| 94 | ||
| 95 | test("a writer pins a commit from the form; it is a ref with its note and source", async () => { | |
| 96 | const res = await post("/alice/game/builds", aliceCookie, { | |
| 97 | sha: oids.one!, | |
| 98 | label: "v1", | |
| 99 | note: "first playable", | |
| 100 | source: "branch main", | |
| 101 | }); | |
| 102 | expect(res.status).toBe(303); | |
| 103 | expect(res.headers.get("location")).toBe("/alice/game/builds"); | |
| 104 | const [pin] = await pins(); | |
| 105 | expect(pin?.label).toBe("v1"); | |
| 106 | expect(pin?.oid).toBe(oids.one!); | |
| 107 | expect(pin?.note).toBe("first playable"); | |
| 108 | expect(pin?.source).toBe("branch main"); | |
| 109 | expect(pin?.pinnedBy).toBe("alice"); | |
| 110 | }); | |
| 111 | ||
| 112 | test("a reader cannot pin, and a stranger's form is refused", async () => { | |
| 113 | const res = await post("/alice/game/builds", bobCookie, { sha: oids.two!, label: "evil" }); | |
| 114 | expect(res.status).toBe(404); | |
| 115 | const forged = await fetch(`${base}/alice/game/builds`, { | |
| 116 | method: "POST", | |
| 117 | headers: { cookie: aliceCookie, origin: "https://evil.test" }, | |
| 118 | body: new URLSearchParams({ sha: oids.two!, label: "evil" }), | |
| 119 | redirect: "manual", | |
| 120 | }); | |
| 121 | expect(forged.status).toBe(403); | |
| 122 | expect((await pins()).map((p) => p.label)).not.toContain("evil"); | |
| 123 | }); | |
| 124 | ||
| 125 | test("bad labels, unknown commits and taken labels are refused", async () => { | |
| 126 | for (const label of ["-x", "a/b", "a b", "x".repeat(41), "refs..x"]) | |
| 127 | expect( | |
| 128 | (await pinBuild(app, alice, fresh(), { label, oid: oids.two! })).ok, | |
| 129 | ).toBe(false); | |
| 130 | const missing = await pinBuild(app, alice, fresh(), { label: "ghost", oid: "f".repeat(40) }); | |
| 131 | expect(missing.ok ? "" : missing.error).toBe("not_a_commit"); | |
| 132 | const taken = await pinBuild(app, alice, fresh(), { label: "v1", oid: oids.two! }); | |
| 133 | expect(taken.ok ? "" : taken.error).toBe("exists"); | |
| 134 | const flag = await pinBuild(app, alice, fresh(), { label: "flag", oid: "--all" }); | |
| 135 | expect(flag.ok).toBe(false); | |
| 136 | }); | |
| 137 | ||
| 138 | test("the API lists the pins to a reader and refuses a write without a token", async () => { | |
| 139 | const listed = ((await (await fetch(`${base}/api/v1/repos/alice/game/builds`)).json()) as { | |
| 140 | items: { label: string }[]; | |
| 141 | }).items; | |
| 142 | expect(listed.map((p) => p.label)).toContain("v1"); | |
| 143 | const anon = await fetch(`${base}/api/v1/repos/alice/game/builds`, { | |
| 144 | method: "POST", | |
| 145 | headers: { "content-type": "application/json" }, | |
| 146 | body: JSON.stringify({ label: "api1", sha: oids.two! }), | |
| 147 | }); | |
| 148 | expect(anon.status).toBe(401); | |
| 149 | expect((await pins()).map((p) => p.label)).not.toContain("api1"); | |
| 150 | }); | |
| 151 | ||
| 152 | test("the offered label steps on from the newest", () => { | |
| 153 | const pin = (label: string) => | |
| 154 | ({ label, oid: "", note: "", parent: undefined, source: "", pinnedBy: "", pinnedAt: "" }) as const; | |
| 155 | expect(nextLabel([])).toBe("v1"); | |
| 156 | expect(nextLabel([pin("2a")])).toBe("2b"); | |
| 157 | expect(nextLabel([pin("v3")])).toBe("v4"); | |
| 158 | expect(nextLabel([pin("demo")])).toBe("demo-2"); | |
| 159 | }); | |
| 160 | }); | |
| 161 | ||
| 162 | describe("build pins pages and notes", () => { | |
| 163 | let dataDir: string; | |
| 164 | let app: App; | |
| 165 | let servers: Servers; | |
| 166 | let alice: User; | |
| 167 | let cookie: string; | |
| 168 | let oid: string; | |
| 169 | const post = (path: string, body: Record<string, string>, origin = servers.url) => | |
| 170 | fetch(`${servers.url}${path}`, { method: "POST", headers: { cookie, origin }, body: new URLSearchParams(body), redirect: "manual" }); | |
| 171 | ||
| 172 | beforeAll(async () => { | |
| 173 | dataDir = await mkdtemp(join(tmpdir(), "forge-pins2-")); | |
| 174 | app = createApp({ dataDir, port: 0 }); | |
| 175 | servers = await serve(app); | |
| 176 | const made = await createUser(app.db, { origin: "seed", trusted: true, username: "alice", email: "[email protected]", password: "a-good-passphrase", adminIfFirst: true }); | |
| 177 | if (!made.ok) throw new Error(made.error); | |
| 178 | alice = made.value; | |
| 179 | const res = await fetch(`${servers.url}/signin`, { method: "POST", body: new URLSearchParams({ username: "alice", password: "a-good-passphrase" }), redirect: "manual" }); | |
| 180 | cookie = (res.headers.get("set-cookie") ?? "").split(";")[0]!; | |
| 181 | const repo = await createRepo(app, alice, { name: "game", visibility: "public" }); | |
| 182 | if (!repo.ok) throw new Error(String(repo.error)); | |
| 183 | const c = await commitToBranch(app, alice, getRepo(app.db, "alice", "game")!, { branch: "main", message: "one", files: [{ path: "a.txt", content: "a\n" }] }); | |
| 184 | if (!c.ok) throw new Error(JSON.stringify(c.error)); | |
| 185 | oid = c.value.commitOid; | |
| 186 | }); | |
| 187 | ||
| 188 | afterAll(async () => { | |
| 189 | servers.stop(); | |
| 190 | app.close(); | |
| 191 | await rm(dataDir, { recursive: true, force: true }); | |
| 192 | }); | |
| 193 | ||
| 194 | test("the form is offered to a writer, the list shows the pin, the branches page links both", async () => { | |
| 195 | const form = await (await fetch(`${servers.url}/alice/game/builds/new?sha=${oid}`, { headers: { cookie } })).text(); | |
| 196 | expect(form).toContain('name="label"'); | |
| 197 | expect(form).toContain(`value="${oid}"`); | |
| 198 | expect(await (await fetch(`${servers.url}/alice/game/builds/new`)).text()).not.toContain('name="label"'); | |
| 199 | expect((await post("/alice/game/builds", { sha: oid, label: "v1", note: "<b>first</b>" })).status).toBe(303); | |
| 200 | const list = await (await fetch(`${servers.url}/alice/game/builds`, { headers: { cookie } })).text(); | |
| 201 | expect(list).toContain(">v1<"); | |
| 202 | expect(list).toContain("<b>first</b>"); | |
| 203 | expect(list).not.toContain("<b>first</b>"); | |
| 204 | const branches = await (await fetch(`${servers.url}/alice/game/branches`, { headers: { cookie } })).text(); | |
| 205 | expect(branches).toContain("/alice/game/builds/new?sha="); | |
| 206 | expect(branches).toContain("/alice/game/builds"); | |
| 207 | }); | |
| 208 | ||
| 209 | test("a taken label is flashed back, not pinned twice", async () => { | |
| 210 | const again = await post("/alice/game/builds", { sha: oid, label: "v1" }); | |
| 211 | expect(again.status).toBe(303); | |
| 212 | expect(again.headers.get("location")).toContain("/builds/new"); | |
| 213 | expect((await listBuildPins(repoDir(app.reposRoot, "alice", "game"))).length).toBe(1); | |
| 214 | }); | |
| 215 | ||
| 216 | test("a note is appended through the API, and copied once into the intent doc", async () => { | |
| 217 | const intent = join(dataDir, "intent.md"); | |
| 218 | await Bun.write(intent, "# Intent\n"); | |
| 219 | const noted = await notePin( | |
| 220 | { ...app, config: { ...app.config, notesIntent: intent } } as App, | |
| 221 | alice, | |
| 222 | getRepo(app.db, "alice", "game")!, | |
| 223 | "v1", | |
| 224 | { notes: "It plays." }, | |
| 225 | ); | |
| 226 | expect(noted.ok).toBe(true); | |
| 227 | const doc = await Bun.file(intent).text(); | |
| 228 | expect(doc).toContain("## Iterator notes"); | |
| 229 | expect(doc).toContain("It plays."); | |
| 230 | const pin = (await listBuildPins(repoDir(app.reposRoot, "alice", "game")))[0]; | |
| 231 | expect(pin?.label).toBe("v1"); | |
| 232 | }); | |
| 233 | ||
| 234 | test("an agent cannot pin or note, even holding write access", async () => { | |
| 235 | const repo = getRepo(app.db, "alice", "game")!; | |
| 236 | const pins = () => listBuildPins(repoDir(app.reposRoot, "alice", "game")); | |
| 237 | const agent = { ...alice, kind: "agent" } as User; | |
| 238 | const pinned = await pinBuild(app, agent, repo, { label: "by-agent", oid: (await pins())[0]!.oid }); | |
| 239 | expect(pinned).toEqual({ ok: false, error: "forbidden" }); | |
| 240 | const noted = await notePin(app, agent, repo, (await pins())[0]!.label, { notes: "hi" }); | |
| 241 | expect(noted).toEqual({ ok: false, error: "forbidden" }); | |
| 242 | }); | |
| 243 | ||
| 244 | test("a note cannot open a comment or a heading in the intent doc", async () => { | |
| 245 | const intent = join(dataDir, "intent-inject.md"); | |
| 246 | await Bun.write(intent, "# Intent\n"); | |
| 247 | const repo = getRepo(app.db, "alice", "game")!; | |
| 248 | const label = (await listBuildPins(repoDir(app.reposRoot, "alice", "game")))[0]!.label; | |
| 249 | const noted = await notePin({ ...app, config: { ...app.config, notesIntent: intent } } as App, alice, repo, label, { | |
| 250 | notes: "--> # Heading <!-- x\n# second\nmore", | |
| 251 | }); | |
| 252 | expect(noted.ok).toBe(true); | |
| 253 | const doc = await Bun.file(intent).text(); | |
| 254 | const line = doc.split("\n").find((l) => l.startsWith("- "))!; | |
| 255 | expect(line.match(/-->/g)?.length).toBe(1); | |
| 256 | expect(line.match(/<!--/g)?.length).toBe(1); | |
| 257 | expect(doc).not.toMatch(/^\s*#(?! Intent)(?!# Iterator notes)/m); | |
| 258 | }); | |
| 259 | ||
| 260 | test("deleting a pin removes the ref and the list forgets it; a stranger's form is refused", async () => { | |
| 261 | expect((await post("/alice/game/builds/v1/delete", {}, "https://evil.test")).status).toBe(403); | |
| 262 | expect((await listBuildPins(repoDir(app.reposRoot, "alice", "game"))).length).toBe(1); | |
| 263 | expect((await post("/alice/game/builds/v1/delete", {})).status).toBe(303); | |
| 264 | expect(await listBuildPins(repoDir(app.reposRoot, "alice", "game"))).toEqual([]); | |
| 265 | }); | |
| 266 | }); |
test/tui-builds.test.ts added +123
| @@ -0,0 +1,123 @@ | ||
| 1 | /** The terminal's Builds screen: a repository's pins for its readers; pin, note and delete for a writer who is not an agent. */ | |
| 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 { pinBuild } from "../src/actions/builds.ts"; | |
| 7 | import { commitToBranch, createRepo } from "../src/actions/repos.ts"; | |
| 8 | import { createApp, type App } from "../src/app.ts"; | |
| 9 | import type { Repo } from "../src/core/repos.ts"; | |
| 10 | import { createUser, type User } from "../src/core/users.ts"; | |
| 11 | import { listBuildPins, readBuildNotes } from "../src/git/builds.ts"; | |
| 12 | import { refTip } from "../src/git/read.ts"; | |
| 13 | import { repoDir } from "../src/git/repos.ts"; | |
| 14 | import type { Ctx } from "../src/tui/app/app.ts"; | |
| 15 | import { createForgeApp, Session } from "../src/tui/forge.ts"; | |
| 16 | import { buildsCommands, buildsScreen, DELETED_LINE, NOT_FOUND_LINE, NO_BUILDS_LINE, parsePin } from "../src/tui/screens/builds.ts"; | |
| 17 | import { headlessTerminal } from "../src/tui/term/terminal.ts"; | |
| 18 | import { quietLogs } from "./helpers/globals.ts"; | |
| 19 | import { settleApp } from "./helpers/tui.ts"; | |
| 20 | ||
| 21 | quietLogs(); | |
| 22 | ||
| 23 | let dir: string; | |
| 24 | let app: App; | |
| 25 | let open: Repo; | |
| 26 | let secret: Repo; | |
| 27 | let alice: User; | |
| 28 | let oid: string; | |
| 29 | const guest = { sshUsername: "git", remoteAddress: "127.0.0.1", fingerprint: "SHA256:test" }; | |
| 30 | const pins = (r: Repo) => listBuildPins(repoDir(app.reposRoot, r.ownerName, r.name)); | |
| 31 | ||
| 32 | beforeAll(async () => { | |
| 33 | dir = await mkdtemp(join(tmpdir(), "forge-tui-builds-")); | |
| 34 | app = createApp({ dataDir: dir, port: 0 }); | |
| 35 | const made = await createUser(app.db, { origin: "seed", trusted: true, username: "alice", email: "[email protected]", password: "a-good-passphrase", adminIfFirst: true }); | |
| 36 | if (!made.ok) throw new Error(made.error); | |
| 37 | alice = made.value; | |
| 38 | const mk = async (name: string, visibility: "public" | "private") => { | |
| 39 | const repo = await createRepo(app, alice, { name, visibility }); | |
| 40 | if (!repo.ok) throw new Error(JSON.stringify(repo.error)); | |
| 41 | const c = await commitToBranch(app, alice, repo.value, { branch: repo.value.defaultBranch, message: "start", files: [{ path: "README.md", content: "hi\n" }] }); | |
| 42 | if (!c.ok) throw new Error(JSON.stringify(c.error)); | |
| 43 | return repo.value; | |
| 44 | }; | |
| 45 | open = await mk("game", "public"); | |
| 46 | secret = await mk("secret", "private"); | |
| 47 | oid = (await refTip(repoDir(app.reposRoot, "alice", "game"), `refs/heads/${open.defaultBranch}`))!; | |
| 48 | const s = await pinBuild(app, alice, secret, { label: "hidden-pin", oid: (await refTip(repoDir(app.reposRoot, "alice", "secret"), `refs/heads/${secret.defaultBranch}`))! }); | |
| 49 | if (!s.ok) throw new Error(s.error); | |
| 50 | }, 60_000); | |
| 51 | ||
| 52 | afterAll(async () => { | |
| 53 | app.close(); | |
| 54 | await rm(dir, { recursive: true, force: true }); | |
| 55 | }); | |
| 56 | ||
| 57 | const key = (key: string) => ({ type: "key" as const, key, ctrl: false, alt: false, shift: false }); | |
| 58 | ||
| 59 | async function drive(repo: Repo, user: User | undefined, steps: (string | string[])[] = []): Promise<string> { | |
| 60 | const terminal = headlessTerminal(100, 30); | |
| 61 | const { app: tui, session } = createForgeApp(app, user ? { ...guest, user } : guest, { terminal, exitHooks: false }); | |
| 62 | tui.start(); | |
| 63 | tui.ctx.push(buildsScreen(session, tui.ctx, repo)); | |
| 64 | await settleApp(tui); | |
| 65 | for (const step of steps) { | |
| 66 | for (const k of Array.isArray(step) ? step : [step]) tui.dispatch(key(k)); | |
| 67 | await settleApp(tui); | |
| 68 | } | |
| 69 | const text = tui.screen.toText(); | |
| 70 | tui.stop?.(); | |
| 71 | return text; | |
| 72 | } | |
| 73 | ||
| 74 | test("parsePin reads label, commit, then the note", () => { | |
| 75 | expect(parsePin(" v2 abc it plays ")).toEqual({ label: "v2", oid: "abc", note: "it plays" }); | |
| 76 | expect(parsePin("v2")).toBeUndefined(); | |
| 77 | }); | |
| 78 | ||
| 79 | test("a reader with no pins sees so, and a guest is offered no writes", async () => { | |
| 80 | const text = await drive(open, undefined, ["p"]); | |
| 81 | expect(text).toContain(NO_BUILDS_LINE); | |
| 82 | expect(text).not.toContain("Pin a build"); | |
| 83 | }); | |
| 84 | ||
| 85 | test("a writer pins from the prefilled prompt, notes it, and deletes it", async () => { | |
| 86 | // The prompt is prefilled with the next label and the default branch's tip; type a note and submit. | |
| 87 | let text = await drive(open, alice, ["p", [..."looks good"], "enter"]); | |
| 88 | expect(text).toContain("Pinned v1."); | |
| 89 | const [made] = await pins(open); | |
| 90 | expect(made).toMatchObject({ label: "v1", oid, source: "terminal", note: "looks good" }); | |
| 91 | ||
| 92 | text = await drive(open, alice, ["n", [..."it plays"], "enter"]); | |
| 93 | expect(text).toContain("Noted v1."); | |
| 94 | const notes = await readBuildNotes(repoDir(app.reposRoot, "alice", "game"), ["v1"]); | |
| 95 | expect(notes.get("v1")?.[0]).toMatchObject({ by: "alice", kind: "human", text: "it plays" }); | |
| 96 | ||
| 97 | text = await drive(open, alice, ["d", "right", "enter"]); | |
| 98 | expect(await pins(open)).toEqual([]); | |
| 99 | expect(text).toContain(DELETED_LINE); | |
| 100 | }); | |
| 101 | ||
| 102 | test("an agent holding write access is offered no writes, and its keys do nothing", async () => { | |
| 103 | const p = await pinBuild(app, alice, open, { label: "keep", oid }); | |
| 104 | expect(p.ok).toBe(true); | |
| 105 | const agent = { ...alice, kind: "agent" } as User; | |
| 106 | await drive(open, agent, ["d", "right", "enter"]); | |
| 107 | await drive(open, agent, ["p", "enter"]); | |
| 108 | expect((await pins(open)).map((x) => x.label)).toEqual(["keep"]); | |
| 109 | }); | |
| 110 | ||
| 111 | test("a stranger opening a private repository's screen gets No such repository and no pin", async () => { | |
| 112 | const text = await drive(secret, undefined); | |
| 113 | expect(text).toContain(NOT_FOUND_LINE); | |
| 114 | expect(text).not.toContain("hidden-pin"); | |
| 115 | }); | |
| 116 | ||
| 117 | test("the command is offered to a reader and not for a repository the viewer cannot read", () => { | |
| 118 | const ctx = {} as Ctx; | |
| 119 | const stranger = new Session(app, guest); | |
| 120 | expect(buildsCommands(stranger, ctx, open).map((c) => c.id)).toEqual(["builds"]); | |
| 121 | expect(buildsCommands(stranger, ctx, secret)).toEqual([]); | |
| 122 | expect(buildsCommands(stranger, ctx, undefined)).toEqual([]); | |
| 123 | }); |
new above
offline
pinBuild lets agents pin while the contract says a person who writes; notePin lets crew agents note while PATCH says agents are refused. Align code, contract and tests.