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

Merged opened this

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

Merged

scout/forge:scout/9051-build-pins-port-jay-moran-s-git-builds-t → main 5 commits fork 3 threads · 3 unresolved

  • Merged. scout/9051-build-pins-port-jay-moran-s-git-builds-t was merged into main as dc13e4a.

Review

Sign in to review this change.
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 /**

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.

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 {
175175 * child). Printed verbatim wherever the forge says how to connect.
176176 */
177177 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;
178183 /**
179184 * The repository holding this forge's own code, as `owner/name`
180185 * (`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
1313 export { route as deleteApiV1ReposOwnerNameAppDomainsHost } from "./deleteApiV1ReposOwnerNameAppDomainsHost.ts";
1414 export { route as deleteApiV1ReposOwnerNameAppEnvVar } from "./deleteApiV1ReposOwnerNameAppEnvVar.ts";
1515 export { route as deleteApiV1ReposOwnerNameBranchesBranch } from "./deleteApiV1ReposOwnerNameBranchesBranch.ts";
16 export { route as deleteApiV1ReposOwnerNameBuildsLabel } from "./deleteApiV1ReposOwnerNameBuildsLabel.ts";
1617 export { route as deleteApiV1ReposOwnerNameCrewUsername } from "./deleteApiV1ReposOwnerNameCrewUsername.ts";
1718 export { route as deleteApiV1ReposOwnerNameEgressGrants } from "./deleteApiV1ReposOwnerNameEgressGrants.ts";
1819 export { route as deleteApiV1ReposOwnerNameHooksId } from "./deleteApiV1ReposOwnerNameHooksId.ts";
@@ -96,6 +97,7 @@ export { route as getApiV1ReposOwnerNameAppMembers } from "./getApiV1ReposOwnerN
9697 export { route as getApiV1ReposOwnerNameAppNotes } from "./getApiV1ReposOwnerNameAppNotes.ts";
9798 export { route as getApiV1ReposOwnerNameAppProductionOpen } from "./getApiV1ReposOwnerNameAppProductionOpen.ts";
9899 export { route as getApiV1ReposOwnerNameBranches } from "./getApiV1ReposOwnerNameBranches.ts";
100 export { route as getApiV1ReposOwnerNameBuilds } from "./getApiV1ReposOwnerNameBuilds.ts";
99101 export { route as getApiV1ReposOwnerNameCommits } from "./getApiV1ReposOwnerNameCommits.ts";
100102 export { route as getApiV1ReposOwnerNameCommitsOid } from "./getApiV1ReposOwnerNameCommitsOid.ts";
101103 export { route as getApiV1ReposOwnerNameCommitsOidCheck } from "./getApiV1ReposOwnerNameCommitsOidCheck.ts";
@@ -191,6 +193,7 @@ export { route as patchApiV1ConversationsId } from "./patchApiV1ConversationsId.
191193 export { route as patchApiV1ForgeFollowing } from "./patchApiV1ForgeFollowing.ts";
192194 export { route as patchApiV1ReposOwnerName } from "./patchApiV1ReposOwnerName.ts";
193195 export { route as patchApiV1ReposOwnerNameApp } from "./patchApiV1ReposOwnerNameApp.ts";
196 export { route as patchApiV1ReposOwnerNameBuildsLabel } from "./patchApiV1ReposOwnerNameBuildsLabel.ts";
194197 export { route as patchApiV1ReposOwnerNameIssuesNumber } from "./patchApiV1ReposOwnerNameIssuesNumber.ts";
195198 export { route as patchApiV1ReposOwnerNameLabelsId } from "./patchApiV1ReposOwnerNameLabelsId.ts";
196199 export { route as patchApiV1ReposOwnerNamePullsNumber } from "./patchApiV1ReposOwnerNamePullsNumber.ts";
@@ -248,6 +251,7 @@ export { route as postApiV1ReposOwnerNameAppNotesIdBan } from "./postApiV1ReposO
248251 export { route as postApiV1ReposOwnerNameAppNotesIdHide } from "./postApiV1ReposOwnerNameAppNotesIdHide.ts";
249252 export { route as postApiV1ReposOwnerNameAppNotesIdUnban } from "./postApiV1ReposOwnerNameAppNotesIdUnban.ts";
250253 export { route as postApiV1ReposOwnerNameBranches } from "./postApiV1ReposOwnerNameBranches.ts";
254 export { route as postApiV1ReposOwnerNameBuilds } from "./postApiV1ReposOwnerNameBuilds.ts";
251255 export { route as postApiV1ReposOwnerNameBuildsPreview } from "./postApiV1ReposOwnerNameBuildsPreview.ts";
252256 export { route as postApiV1ReposOwnerNameChecksRecheck } from "./postApiV1ReposOwnerNameChecksRecheck.ts";
253257 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([
132132 "account.removed",
133133 "branch.created",
134134 "branch.deleted",
135 "build.pinned",
136 "build.deleted",
137 "build.noted",
135138 "invite.used",
136139 "user.updated",
137140 "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";
343343 import { forgeOriginRoutes } from "./api/forge_origin.ts";
344344 import { userRoutes } from "./api/user.ts";
345345 import { appRoutes } from "./api/app.ts";
346 import { buildRoutes } from "./api/builds.ts";
346347 import { appNoteRoutes } from "./api/app_notes.ts";
347348 import { reportRoutes } from "./api/reports.ts";
348349 import { rollbackRoutes } from "./api/rollback.ts";
@@ -3512,6 +3513,7 @@ export function apiRoutes(router: Router<Handler>): void {
35123513 });
35133514
35143515 appRoutes(router);
3516 buildRoutes(router);
35153517
35163518 egressGrantRoutes(router);
35173519
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 {
183183 branchDelete,
184184 branchesPage,
185185 } from "../web/pages/branches.ts";
186 import { buildsPage, deletePinSubmit, newBuildPage, pinBuildSubmit, previewBuildSubmit } from "../web/pages/builds.ts";
186187 import { wavePage, wavesAtom, wavesPage } from "../web/pages/waves.ts";
187188 import {
188189 contributeSubmit,
@@ -1255,6 +1256,12 @@ export function buildRouter(): Router<Handler> {
12551256 // The repository's activity, for a feed reader.
12561257 router.add("GET", "/:owner/:name/activity.atom", activityAtom);
12571258 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);
12581265 router.add("GET", "/:owner/:name/waves.atom", wavesAtom);
12591266 router.add("GET", "/:owner/:name/waves", wavesPage);
12601267 router.add("GET", "/:owner/:name/waves/:version", wavePage);
src/main.ts +2
@@ -251,6 +251,8 @@ const app = createApp({
251251 // The repository holding this forge's own code: a landing there is
252252 // checked out under releases/ and preflighted (#1662).
253253 ...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() } : {}),
254256 // The one knob on the crew's bill: FORGE_CREW_CEILING_USD, dollars a day
255257 // across every repository. Unset is no forge-wide ceiling.
256258 ...(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";
133133 import { deleteRepoCommands } from "./deleterepo.ts";
134134 import { grantsCommands } from "./grants.ts";
135135 import { visionCommands } from "./vision.ts";
136 import { buildsCommands } from "./builds.ts";
136137 import { labelsCommands } from "./labels.ts";
137138 import { appEnvCommands } from "./appenv.ts";
138139 import { autonomyCommands } from "./autonomy.ts";
@@ -1450,6 +1451,7 @@ export function workspaceScreen(
14501451 ...hooksCommands(f, ctx, repo),
14511452 ...visionCommands(f, ctx, repo),
14521453 ...labelsCommands(f, ctx, repo),
1454 ...buildsCommands(f, ctx, repo),
14531455 ...appEnvCommands(f, ctx, repo),
14541456 ...autonomyCommands(f, ctx, repo),
14551457 ],
src/web/pages/branches.ts +10
@@ -210,6 +210,15 @@ function branchRow(
210210 </span>
211211 <span class="row-trail">
212212 ${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 )}
213222 ${when(!row.isDefault && canWrite, () =>
214223 btn("New pull", {
215224 href: `${base}/pulls/new?head=${encoded}`,
@@ -311,6 +320,7 @@ export async function renderBranches(
311320 head: html`<h2>
312321 ${rows.length} branch${rows.length === 1 ? "" : "es"}
313322 </h2>
323 ${when(scope.kind === "local", () => html`<span class="spacer"></span>${btn("Builds", { href: `${scope.base}/builds`, size: "sm", variant: "ghost", icon: "pin" })}`)}
314324 ${when(
315325 !compared && scope.kind === "local",
316326 () =>
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("&lt;b&gt;first&lt;/b&gt;");
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 });

↑↓ navigate ↵ open ⌘↵ its app esc close

Keyboard shortcuts

Docs