Public: anyone can read this; the crew can change it.
An admin can hold new work on one file: the picker starts no build whose issue names it, and the hold says who set it #6528
Check · Review · Land · Live done — Landed and live in production.
scout/forge:scout/6522-an-admin-can-hold-new-work-on-one-file-t → main 5 commits fork 7 threads · 7 unresolved
docs/MCP.md +1 −1
| @@ -77,7 +77,7 @@ for a tunnel and a forge that sleeps. | ||
| 77 | 77 | | `forges` | an admin's list of the forges sent from here: name, address, whom for, apps, claimed or not, last activity | | |
| 78 | 78 | | `front_page` | `{ pinned?, hidden?, weights?, dryRun? }`: the front page as a stranger sees it, ranked, with numbers; a site admin sets what is pinned (first public one featured), hidden, and what counts (weights of forks, ships, use, fresh, 0 to 10) | yes | |
| 79 | 79 | | `health` | how this forge is: the version and commit it runs, draining or not, the rollout and its incidents | | |
| 80 | | `hold` | `{ app, pull, why?, release?, dryRun? }`: hold a pull so no rule reviews or lands it, or let it go; a person who can write, never an agent; plans unless `dryRun: false` | yes | | |
| 80 | | `hold` | `{ app, pull | path, why?, release?, dryRun? }`: hold a pull so no rule reviews or lands it, or let it go; a person who can write, never an agent; or with `path` (no `pull`) hold new work on a file so the picker starts no build whose issue names it, or list the holds and who set each; an admin of the app; plans unless `dryRun: false` | yes | | |
| 81 | 81 | | `hooks` | `{ repo, action, url, secret, kinds, id }`: `list`, `add` (`secret`, 16–200 characters, is required) or `remove` the repository's webhooks, as `…/hooks`; an admin-scope token on a repository you administer, and a secret is never shown | yes | |
| 82 | 82 | | `invites` | `{ action, id? }`: `list` the outstanding invites (state and expiry, never a link) or `revoke` one by `id`; a person admin with an admin token, stricter than the API's list, which an `ro` token may read | yes | |
| 83 | 83 | | `issue` | `{ repo, number }`: one issue's title, state, closeReason, body, labels, assignee, comments (author, authorIsAgent, body, at) and blockedBy/blocks, as `GET …/issues/:number`; a sealed one you cannot see is not found | | |
docs/features/file-holds.md added +21
| @@ -0,0 +1,21 @@ | ||
| 1 | # Hold new work on a file | |
| 2 | ||
| 3 | An admin of a repository can hold new work on one path. While it is held, the | |
| 4 | picker starts no build whose issue names that path (the same `hot_file` hold a | |
| 5 | file in 5 open pulls gets), and the feed says `#12 waits: alice holds new work | |
| 6 | on src/http/mcp.ts.` Nothing already running is cut. Releasing the path frees | |
| 7 | the issues. | |
| 8 | ||
| 9 | - **Page:** the repository's Settings, "Held files" card (admins only): lists each | |
| 10 | hold with who set it and when, and the issues it keeps waiting; one form holds | |
| 11 | a path, a button releases it. | |
| 12 | - **MCP:** `hold { app, path, release?, dryRun? }` (no `pull`); plans unless | |
| 13 | `dryRun: false`; with no `path` it lists the holds. | |
| 14 | - **Terminal:** the "Held files" command. `owner/name path` holds, | |
| 15 | `release owner/name path` frees, `owner/name` lists. | |
| 16 | ||
| 17 | A path is relative, has no `..`, `.` or empty parts, and is under 300 | |
| 18 | characters. The hold names the signed-in admin; it is never taken from the request. | |
| 19 | ||
| 20 | An agent that administers the repository may hold a file, unlike a pull hold, | |
| 21 | which is people-only: the hold names its setter, so it is never silent. |
src/actions/file_holds.ts added +95
| @@ -0,0 +1,95 @@ | ||
| 1 | /** | |
| 2 | * Holding new work on a file and letting it go (`crew/file_holds.ts`, #6522). | |
| 3 | * | |
| 4 | * An admin of the repository decides, a person or an agent that is one; a | |
| 5 | * reader and a stranger get the one `not_found` every admin door gives. | |
| 6 | * `by` is the verified actor, never a value from the request. `dry` checks | |
| 7 | * everything and writes nothing. | |
| 8 | */ | |
| 9 | ||
| 10 | import type { App } from "../app.ts"; | |
| 11 | import { canAdmin, canRead, type Repo } from "../core/repos.ts"; | |
| 12 | import { getUserById, type User } from "../core/users.ts"; | |
| 13 | import { | |
| 14 | checkHoldPath, | |
| 15 | heldFiles, | |
| 16 | holdFile, | |
| 17 | releaseFile, | |
| 18 | type FileHold, | |
| 19 | } from "../crew/file_holds.ts"; | |
| 20 | import { err, ok, type Result } from "../kernel/result.ts"; | |
| 21 | ||
| 22 | export type FileHoldError = "unauthorized" | "not_found" | "invalid_path"; | |
| 23 | ||
| 24 | /** A hold with the name of the person who set it. */ | |
| 25 | export interface NamedHold extends FileHold { | |
| 26 | readonly byName: string; | |
| 27 | } | |
| 28 | ||
| 29 | function nameHold(app: App, hold: FileHold): NamedHold { | |
| 30 | return { ...hold, byName: getUserById(app.db, hold.by)?.username ?? "someone" }; | |
| 31 | } | |
| 32 | ||
| 33 | function requireAdmin(app: App, actor: User | undefined, repo: Repo): Result<User, FileHoldError> { | |
| 34 | if (!canRead(app.db, repo, actor)) return err("not_found"); | |
| 35 | if (!actor) return err("unauthorized"); | |
| 36 | return canAdmin(app.db, repo, actor) ? ok(actor) : err("not_found"); | |
| 37 | } | |
| 38 | ||
| 39 | /** Every file held in `repo`, for an admin of it; anyone else is told nothing. */ | |
| 40 | export function listFileHolds(app: App, actor: User | undefined, repo: Repo): Result<NamedHold[], FileHoldError> { | |
| 41 | const admin = requireAdmin(app, actor, repo); | |
| 42 | if (!admin.ok) return admin; | |
| 43 | return ok(heldFiles(app.db, repo.id).map((h) => nameHold(app, h))); | |
| 44 | } | |
| 45 | ||
| 46 | /** Hold new work on `path`; the hold names `actor`. */ | |
| 47 | export function holdFileAs( | |
| 48 | app: App, | |
| 49 | actor: User | undefined, | |
| 50 | repo: Repo, | |
| 51 | path: unknown, | |
| 52 | dry = false, | |
| 53 | ): Result<NamedHold, FileHoldError> { | |
| 54 | const admin = requireAdmin(app, actor, repo); | |
| 55 | if (!admin.ok) return admin; | |
| 56 | const checked = checkHoldPath(path); | |
| 57 | if (!checked.ok) return err("invalid_path"); | |
| 58 | if (dry) { | |
| 59 | const standing = heldFiles(app.db, repo.id).find((h) => h.path === checked.value); | |
| 60 | return ok(nameHold(app, standing ?? { repoId: repo.id, path: checked.value, by: admin.value.id, at: new Date().toISOString() })); | |
| 61 | } | |
| 62 | const held = holdFile(app.db, repo.id, checked.value, admin.value.id); | |
| 63 | if (!held.ok) return err("invalid_path"); | |
| 64 | app.bus.append({ | |
| 65 | kind: "repo.file_held", | |
| 66 | repoId: repo.id, | |
| 67 | actorId: admin.value.id, | |
| 68 | payload: { path: held.value.path }, | |
| 69 | }); | |
| 70 | return ok(nameHold(app, held.value)); | |
| 71 | } | |
| 72 | ||
| 73 | /** Lift the hold on `path`; whether there was one. */ | |
| 74 | export function releaseFileAs( | |
| 75 | app: App, | |
| 76 | actor: User | undefined, | |
| 77 | repo: Repo, | |
| 78 | path: unknown, | |
| 79 | dry = false, | |
| 80 | ): Result<boolean, FileHoldError> { | |
| 81 | const admin = requireAdmin(app, actor, repo); | |
| 82 | if (!admin.ok) return admin; | |
| 83 | const checked = checkHoldPath(path); | |
| 84 | if (!checked.ok) return err("invalid_path"); | |
| 85 | if (dry) return ok(heldFiles(app.db, repo.id).some((h) => h.path === checked.value)); | |
| 86 | const released = releaseFile(app.db, repo.id, checked.value); | |
| 87 | if (released) | |
| 88 | app.bus.append({ | |
| 89 | kind: "repo.file_released", | |
| 90 | repoId: repo.id, | |
| 91 | actorId: admin.value.id, | |
| 92 | payload: { path: checked.value }, | |
| 93 | }); | |
| 94 | return ok(released); | |
| 95 | } |
src/core/event_audience.ts +4 −1
| @@ -168,7 +168,10 @@ export function classifiedAudience(kind: string): EventAudience | undefined { | ||
| 168 | 168 | // address is often a private repository's. |
| 169 | 169 | kind === "repo.upstream_follow" || |
| 170 | 170 | kind === "repo.upstream_checked" || |
| 171 | kind === "repo.crew_outsiders" | |
| 171 | kind === "repo.crew_outsiders" || | |
| 172 | // A file held against new work is its admins' book-keeping (#6522). | |
| 173 | kind === "repo.file_held" || | |
| 174 | kind === "repo.file_released" | |
| 172 | 175 | ) |
| 173 | 176 | return "admins"; |
| 174 | 177 | // A fleet operation spans repositories; its receipt is its actor's. So is |
src/core/migrations/20261026180000-file-hold.ts added +12
| @@ -0,0 +1,12 @@ | ||
| 1 | // One row per file an admin holds new work on (#6522, crew/file_holds.ts): the | |
| 2 | // picker starts no build whose issue names the path, and the hold says who set | |
| 3 | // it and when. Additive. | |
| 4 | export default ` | |
| 5 | CREATE TABLE file_hold ( | |
| 6 | repo_id TEXT NOT NULL REFERENCES repo(id) ON DELETE CASCADE, | |
| 7 | path TEXT NOT NULL, | |
| 8 | by TEXT NOT NULL, | |
| 9 | at TEXT NOT NULL, | |
| 10 | PRIMARY KEY (repo_id, path) | |
| 11 | ); | |
| 12 | `; |
src/crew/autonomy.ts +8 −1
| @@ -381,7 +381,14 @@ export async function driveRepo( | ||
| 381 | 381 | const sorted = () => heldBy(app, repo, readyNow(repoGraph(app.db, repo.id, { sealed: true })), crew, active); |
| 382 | 382 | let held = sorted(); |
| 383 | 383 | if (freeParked(app, repo, by, held) > 0) held = sorted(); |
| 384 | holdHot(repo.id, held.by.hot_file, now); | |
| 384 | holdHot( | |
| 385 | repo.id, | |
| 386 | held.by.hot_file, | |
| 387 | now, | |
| 388 | new Map( | |
| 389 | [...held.pinned].map(([n, h]) => [n, { path: h.path, byName: getUserById(app.db, h.by)?.username ?? "someone" }]), | |
Minor: this repeats |
||
| 390 | ), | |
| 391 | ); | |
| 385 | 392 | const ready = held.startable; |
| 386 | 393 | if (slotted.length >= paced.slots) { |
| 387 | 394 | // The builders are busy; the planner can still refill the map, when |
src/crew/file_holds.ts added +68
| @@ -0,0 +1,68 @@ | ||
| 1 | /** | |
| 2 | * Hold new work on one file (#6522): an admin names a repository path and the | |
| 3 | * picker starts no build whose issue names it (`held.ts`, `hot_file`), until | |
| 4 | * the hold is released. A hold keeps who set it and when. | |
| 5 | */ | |
| 6 | ||
| 7 | import type { Database } from "bun:sqlite"; | |
| 8 | ||
| 9 | /** The longest path a hold takes. */ | |
| 10 | export const MAX_HOLD_PATH = 300; | |
| 11 | ||
| 12 | export interface FileHold { | |
| 13 | readonly repoId: string; | |
| 14 | readonly path: string; | |
| 15 | readonly by: string; | |
| 16 | readonly at: string; | |
| 17 | } | |
| 18 | ||
| 19 | export type HoldResult = { ok: true; value: FileHold } | { ok: false; error: string }; | |
| 20 | ||
| 21 | /** The path as held, or why it cannot be: relative, no `..`, under 300 characters. */ | |
| 22 | export function checkHoldPath(raw: unknown): { ok: true; value: string } | { ok: false; error: string } { | |
| 23 | if (typeof raw !== "string") return { ok: false, error: "path must be text" }; | |
| 24 | const path = raw.trim(); | |
| 25 | if (path === "") return { ok: false, error: "path is empty" }; | |
| 26 | if (path.length > MAX_HOLD_PATH) return { ok: false, error: `path is longer than ${MAX_HOLD_PATH} characters` }; | |
| 27 | if (/[\0-\x1f\x7f\\]/.test(path)) return { ok: false, error: "path has a control character or backslash" }; | |
| 28 | if (path.startsWith("/")) return { ok: false, error: "path must be relative" }; | |
| 29 | if (path.split("/").some((part) => part === ".." || part === "." || part === "")) | |
| 30 | return { ok: false, error: "path must not contain `..`, `.` or empty parts" }; | |
| 31 | return { ok: true, value: path }; | |
| 32 | } | |
| 33 | ||
| 34 | /** Hold new work on `path` in `repoId`, set by user `by`; holding it again keeps the first hold. */ | |
| 35 | export function holdFile(db: Database, repoId: string, path: unknown, by: string, now = new Date()): HoldResult { | |
| 36 | const checked = checkHoldPath(path); | |
| 37 | if (!checked.ok) return checked; | |
| 38 | const at = now.toISOString(); | |
| 39 | db.query(`INSERT OR IGNORE INTO file_hold (repo_id, path, by, at) VALUES (?1, ?2, ?3, ?4)`).run( | |
| 40 | repoId, | |
| 41 | checked.value, | |
| 42 | by, | |
Nothing outside the test calls this. It needs an admin-gated page, tty and MCP caller, which check who is asking and record |
||
| 43 | at, | |
| 44 | ); | |
| 45 | const row = db | |
| 46 | .query<{ repo_id: string; path: string; by: string; at: string }, [string, string]>( | |
| 47 | `SELECT repo_id, path, by, at FROM file_hold WHERE repo_id = ?1 AND path = ?2`, | |
| 48 | ) | |
| 49 | .get(repoId, checked.value)!; | |
| 50 | return { ok: true, value: { repoId: row.repo_id, path: row.path, by: row.by, at: row.at } }; | |
| 51 | } | |
| 52 | ||
| 53 | /** Lift the hold on `path`; whether there was one. */ | |
| 54 | export function releaseFile(db: Database, repoId: string, path: unknown): boolean { | |
| 55 | const checked = checkHoldPath(path); | |
| 56 | if (!checked.ok) return false; | |
| 57 | return db.query(`DELETE FROM file_hold WHERE repo_id = ?1 AND path = ?2`).run(repoId, checked.value).changes > 0; | |
| 58 | } | |
| 59 | ||
| 60 | /** Every file held in `repoId`, oldest hold first. */ | |
| 61 | export function heldFiles(db: Database, repoId: string): FileHold[] { | |
| 62 | return db | |
| 63 | .query<{ repo_id: string; path: string; by: string; at: string }, [string]>( | |
| 64 | `SELECT repo_id, path, by, at FROM file_hold WHERE repo_id = ?1 ORDER BY at, path`, | |
| 65 | ) | |
| 66 | .all(repoId) | |
| 67 | .map((r) => ({ repoId: r.repo_id, path: r.path, by: r.by, at: r.at })); | |
| 68 | } |
src/crew/held.ts +17 −3
| @@ -21,7 +21,8 @@ | ||
| 21 | 21 | * - `parked_twice`: the crew parked it twice; it is the planner's (`parks.ts`). |
| 22 | 22 | * - `red_fix`: a fix for a red main already covers it (`red_fix.ts`). |
| 23 | 23 | * - `hot_file`: a file it names is touched by `HOT_PULLS` or more open |
| 24 | * pulls; it starts when that pile drains under the limit (`hot_files.ts`). | |
| 24 | * pulls, or an admin holds it (`file_holds.ts`); it starts when that pile | |
| 25 | * drains under the limit or the hold is released (`hot_files.ts`). | |
| 25 | 26 | * |
| 26 | 27 | * The direction issue is the planner's standing issue and never a |
| 27 | 28 | * builder's; it is left out, not counted. |
| @@ -41,7 +42,8 @@ import { proposedWork, speaksForCrew } from "./direct.ts"; | ||
| 41 | 42 | import { capReached, runCapOf } from "./guard.ts"; |
| 42 | 43 | import { interruptionOf } from "./interrupted.ts"; |
| 43 | 44 | import { PARK_STRIKES, parkedCount } from "./parks.ts"; |
| 44 | import { hotFiles, hotOverlap } from "./hot_files.ts"; | |
| 45 | import { heldFiles, type FileHold } from "./file_holds.ts"; | |
| 46 | import { expectedFiles, hotFiles, hotOverlap } from "./hot_files.ts"; | |
| 45 | 47 | import { coveredByRedFix } from "./red_fix.ts"; |
| 46 | 48 | |
| 47 | 49 | export const HOLDS = [ |
| @@ -67,6 +69,8 @@ export interface Held { | ||
| 67 | 69 | readonly startable: Ranked[]; |
| 68 | 70 | /** Each held issue under the first filter that holds it. */ |
| 69 | 71 | readonly by: Readonly<Record<HoldName, readonly Issue[]>>; |
| 72 | /** Of the `hot_file` ones, those an admin's hold keeps: issue number to the hold that does. */ | |
| 73 | readonly pinned: ReadonlyMap<number, FileHold>; | |
| 70 | 74 | } |
| 71 | 75 | |
| 72 | 76 | /** |
| @@ -125,6 +129,11 @@ export function heldBy( | ||
| 125 | 129 | ); |
| 126 | 130 | const handed = withPlanner(app.db, repo); |
| 127 | 131 | const hot = hotFiles(repo.id); |
| 132 | const pins = new Map(heldFiles(app.db, repo.id).map((h) => [h.path, h])); | |
| 133 | const pinned = new Map<number, FileHold>(); | |
| 134 | // The hold an admin set on a file the issue names, if any: the first by hold order. | |
| 135 | const pinOf = (issue: Issue): FileHold | undefined => | |
| 136 | pins.size === 0 ? undefined : expectedFiles(issue).map((f) => pins.get(f)).find((h) => h !== undefined); | |
| 128 | 137 | const holdOf = (issue: Issue): HoldName | null => { |
| 129 | 138 | if (busyIssues.has(issue.id)) return "running"; |
| 130 | 139 | if (proposed.has(issue.number)) return "open_pull"; |
| @@ -135,6 +144,11 @@ export function heldBy( | ||
| 135 | 144 | if (atRunCap(app, repo, issue)) return "run_cap"; |
| 136 | 145 | if (parkedCount(app.db, repo, issue.id) >= PARK_STRIKES) return "parked_twice"; |
| 137 | 146 | if (coveredByRedFix(app.db, repo.id, issue)) return "red_fix"; |
| 147 | const pin = pinOf(issue); | |
| 148 | if (pin !== undefined) { | |
| 149 | pinned.set(issue.number, pin); | |
| 150 | return "hot_file"; | |
| 151 | } | |
| 138 | 152 | if (hotOverlap(issue, hot).length > 0) return "hot_file"; |
| 139 | 153 | return null; |
| 140 | 154 | }; |
| @@ -149,7 +163,7 @@ export function heldBy( | ||
| 149 | 163 | if (hold === null) startable.push(r); |
| 150 | 164 | else by[hold].push(r.issue); |
| 151 | 165 | } |
| 152 | return { startable, by }; | |
| 166 | return { startable, by, pinned }; | |
| 153 | 167 | } |
| 154 | 168 | |
| 155 | 169 | /** The count per filter, in `HOLDS` order. */ |
src/crew/hot_files.ts +13 −2
| @@ -174,6 +174,8 @@ export interface HotHold { | ||
| 174 | 174 | readonly number: number; |
| 175 | 175 | readonly title: string; |
| 176 | 176 | readonly files: readonly HotFile[]; |
| 177 | /** The admin's hold (`file_holds.ts`) that keeps this build, with the setter's name. */ | |
| 178 | readonly pin?: { readonly path: string; readonly byName: string }; | |
| 177 | 179 | } |
| 178 | 180 | |
| 179 | 181 | /** The pulls too wide to count as the picker last saw them, per repository, held builds or not. */ |
| @@ -188,7 +190,12 @@ export function standingWide(repoId: string): { at: string; wide: readonly WideP | ||
| 188 | 190 | const standing = new Map<string, { at: string; holds: readonly HotHold[]; wide: readonly WidePull[] }>(); |
| 189 | 191 | |
| 190 | 192 | /** Remember the builds the picker holds on hot files in `repoId` now; none clears it. */ |
| 191 | export function holdHot(repoId: string, issues: readonly Issue[], at = new Date()): void { | |
| 193 | export function holdHot( | |
| 194 | repoId: string, | |
| 195 | issues: readonly Issue[], | |
| 196 | at = new Date(), | |
| 197 | pins: ReadonlyMap<number, { path: string; byName: string }> = new Map(), | |
| 198 | ): void { | |
| 192 | 199 | const wide = widePulls(repoId); |
| 193 | 200 | if (wide.length === 0) standingWideBy.delete(repoId); |
| 194 | 201 | else standingWideBy.set(repoId, { at: at.toISOString(), wide }); |
| @@ -199,7 +206,10 @@ export function holdHot(repoId: string, issues: readonly Issue[], at = new Date( | ||
| 199 | 206 | const hot = hotFiles(repoId); |
| 200 | 207 | standing.set(repoId, { |
| 201 | 208 | at: at.toISOString(), |
| 202 | holds: issues.map((i) => ({ number: i.number, title: i.title, files: hotOverlap(i, hot) })), | |
| 209 | holds: issues.map((i) => { | |
| 210 | const pin = pins.get(i.number); | |
| 211 | return { number: i.number, title: i.title, files: hotOverlap(i, hot), ...(pin === undefined ? {} : { pin }) }; | |
| 212 | }), | |
| 203 | 213 | wide, |
| 204 | 214 | }); |
| 205 | 215 | } |
| @@ -221,6 +231,7 @@ export function hotWords(hot: ReadonlyMap<string, number>): string { | ||
| 221 | 231 | |
| 222 | 232 | /** One sentence for a held build: `#12 waits: src/http/mcp.ts is in 6 open pulls (hot at 5).` */ |
| 223 | 233 | export function hotSays(hold: HotHold): string { |
| 234 | if (hold.pin !== undefined) return `#${hold.number} waits: ${hold.pin.byName} holds new work on ${hold.pin.path}.`; | |
| 224 | 235 | const files = hold.files |
| 225 | 236 | .slice(0, 3) |
| 226 | 237 | .map((f) => `${f.path} is in ${f.pulls} open pulls`) |
src/http/mcp/tools/hold.ts +49 −4
| @@ -1,17 +1,61 @@ | ||
| 1 | import { holdFileAs, listFileHolds, releaseFileAs } from "../../../actions/file_holds.ts"; | |
| 2 | import { scopeAllows } from "../../../core/tokens.ts"; | |
| 1 | 3 | import { holdPull, releasePull } from "../../../actions/holds.ts"; |
| 2 | 4 | import { getPull } from "../../../core/pulls.ts"; |
| 3 | import { good, bad, type Tool, str, APP_ARG, planRepo, isAnswer } from "../shared.ts"; | |
| 5 | import { good, bad, type Answer, type Tool, str, APP_ARG, planRepo, isAnswer, type Args } from "../shared.ts"; | |
| 6 | import type { Ctx } from "../../context.ts"; | |
| 7 | import type { Repo } from "../../../core/repos.ts"; | |
| 8 | import type { User } from "../../../core/users.ts"; | |
| 9 | ||
| 10 | /** `hold` without a pull: new work on a file (#6522), an admin's. */ | |
| 11 | function fileHold(ctx: Ctx, user: User, repo: Repo, args: Args): Answer { | |
| 12 | const refused = bad("forbidden", "holding files is for an admin of this app"); | |
| 13 | const invalid = bad("invalid", "path is relative, without .., under 300 characters"); | |
outdated
Low: with no |
||
| 14 | if (args["path"] === undefined) { | |
| 15 | if (args["release"] !== undefined || args["why"] !== undefined) | |
| 16 | return bad("invalid", "release and why need a path; send path, or neither to list"); | |
| 17 | const listed = listFileHolds(ctx.app, user, repo); | |
| 18 | if (!listed.ok) return refused; | |
| 19 | return good({ | |
| 20 | says: listed.value.length === 0 ? "No file is held." : `${listed.value.length} file(s) held.`, | |
| 21 | holds: listed.value.map((h) => ({ path: h.path, by: h.byName, at: h.at })), | |
| 22 | }); | |
| 23 | } | |
| 24 | const path = str(args["path"]); | |
outdated
High: from here on, a real write needs only |
||
| 25 | const dry = args["dryRun"] !== false; | |
| 26 | const hint = "Nothing changed; send dryRun: false to do it."; | |
| 27 | if (!dry && !scopeAllows(ctx.auth.scope ?? "ro", "admin")) | |
| 28 | return bad("forbidden", "holding files needs an admin token"); | |
| 29 | if (args["release"] === true) { | |
| 30 | const released = releaseFileAs(ctx.app, user, repo, path, dry); | |
| 31 | if (!released.ok) return released.error === "invalid_path" ? invalid : refused; | |
| 32 | return good({ | |
| 33 | says: !released.value ? `${path} is not held.` : dry ? `Would release ${path}. ${hint}` : `Released ${path}.`, | |
| 34 | dryRun: dry, | |
| 35 | }); | |
| 36 | } | |
| 37 | const held = holdFileAs(ctx.app, user, repo, path, dry); | |
| 38 | if (!held.ok) return held.error === "invalid_path" ? invalid : refused; | |
| 39 | return good({ | |
| 40 | says: dry | |
| 41 | ? `Would hold new work on ${held.value.path}. ${hint}` | |
| 42 | : `Held new work on ${held.value.path}: no build starts on an issue that names it.`, | |
| 43 | hold: { path: held.value.path, by: held.value.byName, at: held.value.at }, | |
| 44 | dryRun: dry, | |
| 45 | }); | |
| 46 | } | |
| 4 | 47 | |
| 5 | 48 | export const tool: Tool = { |
| 6 | 49 | name: "hold", |
| 7 | doc: "`{ app, pull, why?, release?, dryRun? }`: hold a pull so no rule reviews or lands it, or let it go; a person who can write, never an agent; plans unless `dryRun: false`", | |
| 50 | doc: "`{ app, pull | path, why?, release?, dryRun? }`: hold a pull so no rule reviews or lands it, or let it go; a person who can write, never an agent; or with `path` (no `pull`) hold new work on a file so the picker starts no build whose issue names it, or list the holds and who set each; an admin of the app; plans unless `dryRun: false`", | |
| 8 | 51 | description: |
| 9 | "Hold a pull (`why`) or release it (`release: true`); held, none reviews or lands it. Writers.", | |
| 52 | "Hold a pull (`why`) or `release` it; or `path`: hold new work on a file. Neither: list file holds.", | |
| 10 | 53 | inputSchema: { |
| 11 | 54 | type: "object", |
| 12 | 55 | properties: { |
| 13 | 56 | app: APP_ARG, |
| 14 | 57 | pull: { type: "integer" }, |
| 58 | path: { type: "string", description: "A file: relative, no `..`." }, | |
| 15 | 59 | why: { |
| 16 | 60 | type: "string", |
| 17 | 61 | description: "Why, one line.", |
| @@ -21,13 +65,14 @@ export const tool: Tool = { | ||
| 21 | 65 | }, |
| 22 | 66 | dryRun: { type: "boolean" }, |
| 23 | 67 | }, |
| 24 | required: ["app", "pull"], | |
| 68 | required: ["app"], | |
| 25 | 69 | }, |
| 26 | 70 | writes: true, |
| 27 | 71 | dryByDefault: true, |
| 28 | 72 | run(ctx, user, args) { |
| 29 | 73 | const repo = planRepo(ctx, user, args); |
| 30 | 74 | if (isAnswer(repo)) return repo; |
| 75 | if (args["pull"] === undefined) return fileHold(ctx, user, repo, args); | |
| 31 | 76 | const n = args["pull"]; |
| 32 | 77 | const pull = |
| 33 | 78 | typeof n === "number" && Number.isInteger(n) && n > 0 |
src/http/server.ts +2
| @@ -339,6 +339,7 @@ import { | ||
| 339 | 339 | agentUpdate, |
| 340 | 340 | crewPage, |
| 341 | 341 | } from "../web/pages/crew.ts"; |
| 342 | import { fileHoldUpdate } from "../web/pages/file_holds.ts"; | |
| 342 | 343 | import { newPullPage, newPullSubmit } from "../web/pages/pulls.ts"; |
| 343 | 344 | import { |
| 344 | 345 | pullPage, |
| @@ -1441,6 +1442,7 @@ export function buildRouter(): Router<Handler> { | ||
| 1441 | 1442 | ); |
| 1442 | 1443 | router.add("GET", "/:owner/:name/settings/autonomy", autonomySettingsPage); |
| 1443 | 1444 | router.add("POST", "/:owner/:name/settings/autonomy", autonomySettingsUpdate); |
| 1445 | router.add("POST", "/:owner/:name/settings/holds", fileHoldUpdate); | |
| 1444 | 1446 | router.add("POST", "/:owner/:name/settings/upstream", followUpstreamUpdate); |
| 1445 | 1447 | router.add("POST", "/:owner/:name/settings/upstream/check", followUpstreamCheck); |
| 1446 | 1448 | router.add("GET", "/:owner/:name/settings/labels", labelsPage); |
src/tui/forge.ts +9
| @@ -74,6 +74,7 @@ import { queuesFor, systemFor } from "../system/telemetry.ts"; | ||
| 74 | 74 | import { live as liveMachine } from "../system/live.ts"; |
| 75 | 75 | import { releasesScreen } from "./screens/releases.ts"; |
| 76 | 76 | import { mayRead } from "../release/gate.ts"; |
| 77 | import { fileHoldsPrompt } from "./screens/fileholds.ts"; | |
| 77 | 78 | import { stalledScreen } from "./screens/stalled.ts"; |
| 78 | 79 | import { issuesScreen, newIssueScreen } from "./screens/issues.ts"; |
| 79 | 80 | import { limbsScreen } from "./screens/limbs.ts"; |
| @@ -360,6 +361,14 @@ export function forgeCommands(session: Session): (ctx: Ctx) => Command[] { | ||
| 360 | 361 | hint: "pulls whose check is red or was never asked for; enter re-runs", |
| 361 | 362 | run: () => ctx.push(stalledScreen(session, ctx)), |
| 362 | 363 | }); |
| 364 | // Hold new work on a file: the settings card's twin, admin-checked by the action itself. | |
| 365 | if (session.signedIn) | |
| 366 | commands.push({ | |
| 367 | id: "file-holds", | |
| 368 | title: "Held files", | |
| 369 | hint: "hold new work on a file, release it, or list the holds", | |
| 370 | run: () => ctx.push(fileHoldsPrompt(session, ctx, blank("Held files"))), | |
| 371 | }); | |
| 363 | 372 | // What outside agents tried, as Settings → Access says it: a site admin's (#1768). |
| 364 | 373 | if (session.user?.isAdmin === true && session.user.kind !== "agent") |
| 365 | 374 | commands.push({ |
src/tui/screens/fileholds.ts added +61
| @@ -0,0 +1,61 @@ | ||
| 1 | /** | |
| 2 | * Hold new work on a file, from the terminal (#6522): one line, the same | |
| 3 | * actions the settings page and the MCP tool call, so the admin check and | |
| 4 | * the hold's `by` are theirs. | |
| 5 | * | |
| 6 | * owner/name path hold new work on path | |
| 7 | * release owner/name path let it go | |
| 8 | * owner/name list the holds, who set them and when | |
| 9 | */ | |
| 10 | ||
| 11 | import { holdFileAs, listFileHolds, releaseFileAs } from "../../actions/file_holds.ts"; | |
| 12 | import { getRepo } from "../../core/repos.ts"; | |
| 13 | import type { Ctx, Screen } from "../app/app.ts"; | |
| 14 | import type { Session } from "../session.ts"; | |
| 15 | import { promptScreen } from "./common.ts"; | |
| 16 | ||
| 17 | const SAYS = { | |
| 18 | unauthorized: "Sign in first.", | |
| 19 | not_found: "No such repository, or you do not administer it.", | |
| 20 | invalid_path: "A path is relative, without .., under 300 characters.", | |
| 21 | }; | |
| 22 | ||
| 23 | /** Run one line of the prompt; the sentence to show, as a toast or an error. */ | |
| 24 | export function fileHoldLine(f: Session, text: string): { ok: boolean; says: string } { | |
| 25 | const words = text.trim().split(/\s+/).filter((w) => w !== ""); | |
| 26 | const release = words[0] === "release"; | |
| 27 | if (release) words.shift(); | |
| 28 | const [slug, path, ...extra] = words; | |
| 29 | const [owner, name, ...rest] = (slug ?? "").split("/"); | |
| 30 | const repo = owner && name && rest.length === 0 ? getRepo(f.db, owner, name) : undefined; | |
| 31 | if (repo === undefined || extra.length > 0 || (release && path === undefined)) | |
| 32 | return { ok: false, says: "Say: owner/name path, release owner/name path, or owner/name to list." }; | |
| 33 | if (path === undefined) { | |
| 34 | const listed = listFileHolds(f.app, f.user, repo); | |
| 35 | if (!listed.ok) return { ok: false, says: SAYS[listed.error] }; | |
| 36 | return { | |
| 37 | ok: true, | |
| 38 | says: listed.value.length === 0 ? "No file is held." : listed.value.map((h) => `${h.path} (${h.byName}, ${h.at.slice(0, 16)})`).join("; "), | |
| 39 | }; | |
| 40 | } | |
| 41 | if (release) { | |
| 42 | const done = releaseFileAs(f.app, f.user, repo, path); | |
| 43 | return done.ok ? { ok: true, says: done.value ? `${path} is free again.` : `${path} was not held.` } : { ok: false, says: SAYS[done.error] }; | |
| 44 | } | |
| 45 | const held = holdFileAs(f.app, f.user, repo, path); | |
| 46 | return held.ok ? { ok: true, says: `New work on ${held.value.path} is held.` } : { ok: false, says: SAYS[held.error] }; | |
| 47 | } | |
| 48 | ||
| 49 | export function fileHoldsPrompt(f: Session, ctx: Ctx, parent: Screen): Screen { | |
| 50 | return promptScreen(ctx, parent, { | |
| 51 | title: "Held files", | |
| 52 | label: "owner/name path · release owner/name path · owner/name", | |
| 53 | hint: "no build starts on an issue that names a held path", | |
| 54 | onSubmit: (text) => { | |
| 55 | const done = fileHoldLine(f, text); | |
| 56 | if (!done.ok) return done.says; | |
| 57 | ctx.toast(done.says); | |
| 58 | return undefined; | |
| 59 | }, | |
| 60 | }); | |
| 61 | } |
src/web/pages/file_holds.ts added +44
| @@ -0,0 +1,44 @@ | ||
| 1 | /** | |
| 2 | * POST /:owner/:name/settings/holds — hold new work on a file, or release it. | |
| 3 | * Through `adminForm`: an admin of the repository, same-origin, admin scope; | |
| 4 | * the hold names the signed-in admin (`actions/file_holds.ts`). | |
| 5 | */ | |
| 6 | ||
| 7 | import { holdFileAs, releaseFileAs } from "../../actions/file_holds.ts"; | |
| 8 | import type { Ctx } from "../../http/context.ts"; | |
| 9 | import { repoBase } from "../chrome.ts"; | |
| 10 | import { field } from "../form.ts"; | |
| 11 | import { flashTo } from "../layout.ts"; | |
| 12 | import { adminForm } from "./settings.ts"; | |
| 13 | ||
| 14 | export async function fileHoldUpdate(ctx: Ctx): Promise<Response> { | |
| 15 | const gate = await adminForm(ctx); | |
| 16 | if (gate instanceof Response) return gate; | |
| 17 | const { repo, form } = gate; | |
| 18 | const to = `${repoBase(repo)}/settings#file-holds`; | |
| 19 | const path = field(form, "path"); | |
| 20 | if (field(form, "verb") === "release") { | |
| 21 | const released = releaseFileAs(ctx.app, ctx.auth.user, repo, path); | |
| 22 | return flashTo( | |
| 23 | ctx, | |
| 24 | to, | |
| 25 | released.ok | |
| 26 | ? { message: released.value ? `${path} is free again.` : `${path} was not held.` } | |
| 27 | : { tone: "danger", message: "That path could not be released." }, | |
| 28 | ); | |
| 29 | } | |
| 30 | const held = holdFileAs(ctx.app, ctx.auth.user, repo, path); | |
| 31 | return flashTo( | |
| 32 | ctx, | |
| 33 | to, | |
| 34 | held.ok | |
| 35 | ? { message: `New work on ${held.value.path} is held.` } | |
| 36 | : { | |
| 37 | tone: "danger", | |
| 38 | message: | |
| 39 | held.error === "invalid_path" | |
| 40 | ? "A held path is relative, without .. or empty parts, and under 300 characters." | |
| 41 | : "That file could not be held.", | |
| 42 | }, | |
| 43 | ); | |
| 44 | } |
src/web/studio/crew.ts +2
| @@ -39,6 +39,7 @@ import { icon } from "../icons.ts"; | ||
| 39 | 39 | import { crewReadiness } from "../pages/crew.ts"; |
| 40 | 40 | import { grantsSection } from "../pages/subscriptions.ts"; |
| 41 | 41 | import { directionMarked } from "./direction.ts"; |
| 42 | import { fileHoldsCard } from "./file_holds.ts"; | |
| 42 | 43 | import { markOf } from "../../core/marks.ts"; |
| 43 | 44 | import { |
| 44 | 45 | btn, |
| @@ -401,6 +402,7 @@ export function settingsLead(ctx: Ctx, repo: Repo): Html { | ||
| 401 | 402 | ${aboutCard(repo, base, admin, isApp)} |
| 402 | 403 | ${crewCard(ctx, repo, base, admin)} |
| 403 | 404 | ${autonomyCard(ctx, repo, base, admin)} |
| 405 | ${admin ? fileHoldsCard(ctx, repo, base) : null} | |
| 404 | 406 | </div>`; |
| 405 | 407 | } |
| 406 | 408 |
src/web/studio/file_holds.ts added +68
| @@ -0,0 +1,68 @@ | ||
| 1 | /** | |
| 2 | * The hold-a-file card on a repository's settings, an admin's: each file | |
| 3 | * held, who set it and when, what it keeps waiting, a form to hold another | |
| 4 | * path and one to release. The forms post to `fileHoldUpdate` | |
| 5 | * (`pages/file_holds.ts`), which is where the admin and same-origin checks are. | |
| 6 | */ | |
| 7 | ||
| 8 | import { listFileHolds } from "../../actions/file_holds.ts"; | |
| 9 | import type { Repo } from "../../core/repos.ts"; | |
| 10 | import { heldNow } from "../../crew/held.ts"; | |
| 11 | import { MAX_HOLD_PATH } from "../../crew/file_holds.ts"; | |
| 12 | import type { Ctx } from "../../http/context.ts"; | |
| 13 | import { html, type Html } from "../html.ts"; | |
| 14 | import { icon } from "../icons.ts"; | |
| 15 | import { btn, card, field, row, time } from "../ui.ts"; | |
| 16 | ||
| 17 | /** Null unless the viewer is an admin of `repo`: the holds are the owner's book-keeping. */ | |
| 18 | export function fileHoldsCard(ctx: Ctx, repo: Repo, base: string): Html | null { | |
| 19 | const listed = listFileHolds(ctx.app, ctx.auth.user, repo); | |
| 20 | if (!listed.ok) return null; | |
| 21 | const waiting = listed.value.length === 0 ? undefined : heldNow(ctx.app, repo).pinned; | |
| 22 | const issuesOf = (path: string): string => | |
| 23 | [...(waiting ?? [])] | |
| 24 | .filter(([, h]) => h.path === path) | |
| 25 | .map(([n]) => `#${n}`) | |
| 26 | .join(" "); | |
| 27 | return card( | |
| 28 | html`<p class="muted small"> | |
| 29 | No build starts on an issue that names a held path. Nothing already | |
| 30 | running is cut. | |
| 31 | </p> | |
| 32 | ${listed.value.length === 0 | |
| 33 | ? html`<p class="muted" data-no-holds>No file is held.</p>` | |
| 34 | : html`<ul class="list setting-list"> | |
outdated
Hand-rolled list. Use |
||
| 35 | ${listed.value.map((h) => { | |
| 36 | const kept = issuesOf(h.path); | |
| 37 | return row({ | |
| 38 | title: html`<span class="mono">${h.path}</span>`, | |
| 39 | meta: html`held by <span data-by>${h.byName}</span> · ${time(h.at)}${kept === "" ? "" : html` · keeping ${kept} waiting`}`, | |
| 40 | data: { "file-hold": h.path }, | |
| 41 | trail: html`<form method="post" action="${base}/settings/holds"> | |
| 42 | <input type="hidden" name="path" value="${h.path}" /> | |
| 43 | <input type="hidden" name="verb" value="release" /> | |
| 44 | ${btn("Release", { variant: "ghost" })} | |
| 45 | </form>`, | |
| 46 | }); | |
| 47 | })} | |
| 48 | </ul>`} | |
| 49 | <form method="post" action="${base}/settings/holds"> | |
| 50 | <input type="hidden" name="verb" value="hold" /> | |
| 51 | ${field({ | |
| 52 | label: "Hold new work on", | |
| 53 | name: "path", | |
| 54 | placeholder: "src/http/mcp.ts", | |
| 55 | mono: true, | |
| 56 | required: true, | |
| 57 | spellcheck: false, | |
| 58 | hint: `A path in the repository, relative, at most ${MAX_HOLD_PATH} characters.`, | |
| 59 | })} | |
| 60 | <div class="form-actions">${btn("Hold file", { variant: "primary" })}</div> | |
| 61 | </form>`, | |
| 62 | { | |
| 63 | id: "file-holds", | |
| 64 | head: html`${icon("alert")} | |
| 65 | <h2>Held files</h2>`, | |
| 66 | }, | |
| 67 | ); | |
| 68 | } |
test/crew-file-holds.test.ts added +188
| @@ -0,0 +1,188 @@ | ||
| 1 | /** | |
| 2 | * An admin's hold on one file (#6522): the picker starts no build whose issue | |
| 3 | * names it, the hold says who set it, and it is reachable from the settings | |
| 4 | * page, the MCP and the terminal — each refusing a non-admin. | |
| 5 | */ | |
| 6 | import { afterAll, beforeAll, describe, expect, test } from "bun:test"; | |
| 7 | import { mkdtemp, rm } from "node:fs/promises"; | |
| 8 | import { tmpdir } from "node:os"; | |
| 9 | import { join } from "node:path"; | |
| 10 | import { createApp, type App } from "../src/app.ts"; | |
| 11 | import { holdFileAs, listFileHolds, releaseFileAs } from "../src/actions/file_holds.ts"; | |
| 12 | import { openIssue } from "../src/actions/issues.ts"; | |
| 13 | import { createRepo } from "../src/actions/repos.ts"; | |
| 14 | import type { Repo } from "../src/core/repos.ts"; | |
| 15 | import { createToken } from "../src/core/tokens.ts"; | |
| 16 | import { createUser, type User } from "../src/core/users.ts"; | |
| 17 | import { heldFiles, holdFile, releaseFile } from "../src/crew/file_holds.ts"; | |
| 18 | import { heldNow } from "../src/crew/held.ts"; | |
| 19 | import { holdHot, hotSays, standingHot } from "../src/crew/hot_files.ts"; | |
| 20 | import { serve, type Servers } from "../src/http/server.ts"; | |
| 21 | import { fileHoldLine } from "../src/tui/screens/fileholds.ts"; | |
| 22 | import { mcpTool } from "./helpers/mcp.ts"; | |
| 23 | ||
| 24 | describe("hold new work on a file", () => { | |
| 25 | let dir: string; | |
| 26 | let app: App; | |
| 27 | let servers: Servers; | |
| 28 | let alice: User; | |
| 29 | let bob: User; | |
| 30 | let repo: Repo; | |
| 31 | let named: number; | |
| 32 | let other: number; | |
| 33 | ||
| 34 | const person = async (username: string, first = false): Promise<User> => { | |
| 35 | const made = await createUser(app.db, { | |
| 36 | origin: "seed", | |
| 37 | trusted: true, | |
| 38 | username, | |
| 39 | email: `${username}@forge.local`, | |
| 40 | password: "a-good-passphrase", | |
| 41 | adminIfFirst: first, | |
| 42 | }); | |
| 43 | if (!made.ok) throw new Error(made.error); | |
| 44 | return made.value; | |
| 45 | }; | |
| 46 | const cookieOf = async (username: string): Promise<string> => | |
| 47 | ( | |
| 48 | ( | |
| 49 | await fetch(`${servers.url}/signin`, { | |
| 50 | method: "POST", | |
| 51 | body: new URLSearchParams({ username, password: "a-good-passphrase" }), | |
| 52 | redirect: "manual", | |
| 53 | }) | |
| 54 | ).headers.get("set-cookie") ?? "" | |
| 55 | ).split(";")[0]!; | |
| 56 | const post = (cookie: string, body: Record<string, string>, origin = servers.url) => | |
| 57 | fetch(`${servers.url}/alice/held-file/settings/holds`, { | |
| 58 | method: "POST", | |
| 59 | headers: { cookie, origin }, | |
| 60 | body: new URLSearchParams(body), | |
| 61 | redirect: "manual", | |
| 62 | }); | |
| 63 | const waiting = () => heldNow(app, repo).by.hot_file.map((i) => i.number); | |
| 64 | ||
| 65 | beforeAll(async () => { | |
| 66 | dir = await mkdtemp(join(tmpdir(), "file-holds-")); | |
| 67 | app = createApp({ dataDir: dir, port: 0 }); | |
| 68 | alice = await person("alice", true); | |
| 69 | bob = await person("bob"); | |
| 70 | servers = await serve(app); | |
| 71 | const made = await createRepo(app, alice, { name: "held-file", visibility: "public" }); | |
| 72 | if (!made.ok) throw new Error(String(made.error)); | |
| 73 | repo = made.value; | |
| 74 | const a = await openIssue(app, alice, repo, { title: "Change src/http/mcp.ts", body: "" }); | |
| 75 | const b = await openIssue(app, alice, repo, { title: "Change src/other/thing.ts", body: "" }); | |
| 76 | if (!a.ok || !b.ok) throw new Error("issues"); | |
| 77 | named = a.value.number; | |
| 78 | other = b.value.number; | |
| 79 | }); | |
| 80 | afterAll(async () => { | |
| 81 | servers.stop(); | |
| 82 | app.close(); | |
| 83 | await rm(dir, { recursive: true, force: true }); | |
| 84 | }); | |
| 85 | ||
| 86 | test("a held path holds an issue naming it, and releasing frees it", () => { | |
| 87 | expect(waiting()).toEqual([]); | |
| 88 | const held = holdFile(app.db, repo.id, "src/http/mcp.ts", alice.id); | |
| 89 | expect(held.ok && held.value.by).toBe(alice.id); | |
| 90 | expect(heldFiles(app.db, repo.id).map((h) => h.path)).toEqual(["src/http/mcp.ts"]); | |
| 91 | expect(waiting()).toEqual([named]); | |
| 92 | expect(heldNow(app, repo).startable.map((r) => r.issue.number)).toContain(other); | |
| 93 | expect(releaseFile(app.db, repo.id, "src/http/mcp.ts")).toBe(true); | |
| 94 | expect(waiting()).toEqual([]); | |
| 95 | expect(releaseFile(app.db, repo.id, "src/http/mcp.ts")).toBe(false); | |
| 96 | }); | |
| 97 | ||
| 98 | test("an invalid path is refused", () => { | |
| 99 | for (const bad of ["", "/etc/passwd", "../x.ts", "a/../b.ts", "a//b.ts", "x".repeat(301), 5, "a\\b"]) | |
| 100 | expect(holdFile(app.db, "nope", bad, "u").ok).toBe(false); | |
| 101 | expect(holdFileAs(app, alice, repo, "../x.ts")).toEqual({ ok: false, error: "invalid_path" }); | |
| 102 | }); | |
| 103 | ||
| 104 | test("the action refuses a non-admin and names the admin who holds", () => { | |
| 105 | expect(holdFileAs(app, bob, repo, "src/http/mcp.ts")).toEqual({ ok: false, error: "not_found" }); | |
| 106 | expect(holdFileAs(app, undefined, repo, "src/http/mcp.ts")).toEqual({ ok: false, error: "unauthorized" }); | |
| 107 | expect(releaseFileAs(app, bob, repo, "src/http/mcp.ts").ok).toBe(false); | |
| 108 | expect(listFileHolds(app, bob, repo).ok).toBe(false); | |
| 109 | expect(heldFiles(app.db, repo.id)).toEqual([]); | |
| 110 | ||
| 111 | const dry = holdFileAs(app, alice, repo, "src/http/mcp.ts", true); | |
| 112 | expect(dry.ok).toBe(true); | |
| 113 | expect(heldFiles(app.db, repo.id)).toEqual([]); | |
| 114 | ||
| 115 | const held = holdFileAs(app, alice, repo, "src/http/mcp.ts"); | |
| 116 | expect(held.ok && held.value.byName).toBe("alice"); | |
| 117 | expect(waiting()).toEqual([named]); | |
| 118 | const pin = heldNow(app, repo).pinned.get(named); | |
| 119 | expect(pin?.path).toBe("src/http/mcp.ts"); | |
| 120 | expect(pin?.by).toBe(alice.id); | |
| 121 | // The feed's sentence says who. | |
| 122 | holdHot(repo.id, heldNow(app, repo).by.hot_file, new Date(), new Map([[named, { path: pin!.path, byName: "alice" }]])); | |
| 123 | expect(hotSays(standingHot(repo.id)!.holds[0]!)).toBe(`#${named} waits: alice holds new work on src/http/mcp.ts.`); | |
| 124 | holdHot(repo.id, []); | |
| 125 | expect(releaseFileAs(app, alice, repo, "src/http/mcp.ts")).toEqual({ ok: true, value: true }); | |
| 126 | expect(waiting()).toEqual([]); | |
| 127 | }); | |
| 128 | ||
| 129 | test("the settings page: only an admin sees the card and posts; the hold reaches the picker", async () => { | |
| 130 | const adminCookie = await cookieOf("alice"); | |
| 131 | const bobCookie = await cookieOf("bob"); | |
| 132 | const seen = await (await fetch(`${servers.url}/alice/held-file/settings`, { headers: { cookie: adminCookie } })).text(); | |
| 133 | expect(seen).toContain('id="file-holds"'); | |
| 134 | const bobSees = await (await fetch(`${servers.url}/alice/held-file/settings`, { headers: { cookie: bobCookie } })).text(); | |
| 135 | expect(bobSees).not.toContain('id="file-holds"'); | |
| 136 | ||
| 137 | expect((await post(bobCookie, { verb: "hold", path: "src/http/mcp.ts" })).status).toBe(404); | |
| 138 | expect((await post(adminCookie, { verb: "hold", path: "src/http/mcp.ts" }, "https://evil.example")).status).toBeGreaterThanOrEqual(400); | |
| 139 | expect(heldFiles(app.db, repo.id)).toEqual([]); | |
| 140 | ||
| 141 | const done = await post(adminCookie, { verb: "hold", path: "src/http/mcp.ts" }); | |
| 142 | expect(done.status).toBe(303); | |
| 143 | expect(heldFiles(app.db, repo.id)[0]?.by).toBe(alice.id); | |
| 144 | expect(waiting()).toEqual([named]); | |
| 145 | const after = await (await fetch(`${servers.url}/alice/held-file/settings`, { headers: { cookie: adminCookie } })).text(); | |
| 146 | expect(after).toContain('data-file-hold="src/http/mcp.ts"'); | |
| 147 | expect(after).toContain("alice"); | |
| 148 | ||
| 149 | await post(bobCookie, { verb: "release", path: "src/http/mcp.ts" }); | |
| 150 | expect(heldFiles(app.db, repo.id).length).toBe(1); | |
| 151 | await post(adminCookie, { verb: "release", path: "src/http/mcp.ts" }); | |
| 152 | expect(waiting()).toEqual([]); | |
| 153 | }); | |
| 154 | ||
| 155 | test("the MCP tool: dry by default, admin only", async () => { | |
| 156 | const admin = createToken(app.db, alice.id, "t", "admin").token; | |
| 157 | const reader = createToken(app.db, bob.id, "t", "admin").token; | |
| 158 | const args = { app: "alice/held-file", path: "src/http/mcp.ts" }; | |
| 159 | expect((await mcpTool(servers.url, admin, "hold", args)).value.dryRun).toBe(true); | |
| 160 | expect(heldFiles(app.db, repo.id)).toEqual([]); | |
| 161 | expect((await mcpTool(servers.url, reader, "hold", { ...args, dryRun: false })).error).toBeDefined(); | |
| 162 | expect(heldFiles(app.db, repo.id)).toEqual([]); | |
| 163 | const rw = createToken(app.db, alice.id, "t", "rw").token; | |
| 164 | expect((await mcpTool(servers.url, rw, "hold", { ...args, dryRun: false })).error).toBeDefined(); | |
| 165 | expect(heldFiles(app.db, repo.id)).toEqual([]); | |
| 166 | const held = await mcpTool(servers.url, admin, "hold", { ...args, dryRun: false }); | |
| 167 | expect(held.value.hold.by).toBe("alice"); | |
| 168 | expect(waiting()).toEqual([named]); | |
| 169 | const listed = await mcpTool(servers.url, admin, "hold", { app: "alice/held-file" }); | |
| 170 | expect(listed.value.holds.map((h: { path: string }) => h.path)).toEqual(["src/http/mcp.ts"]); | |
| 171 | expect((await mcpTool(servers.url, admin, "hold", { ...args, release: true, dryRun: false })).value.says).toContain("Released"); | |
| 172 | expect(waiting()).toEqual([]); | |
| 173 | const forgot = await mcpTool(servers.url, admin, "hold", { app: "alice/held-file", release: true, dryRun: false }); | |
| 174 | expect(forgot.value?.holds).toBeUndefined(); | |
| 175 | expect(forgot.error).toBeDefined(); | |
| 176 | }); | |
| 177 | ||
| 178 | test("the terminal line holds, lists and releases, and refuses a non-admin", () => { | |
| 179 | const as = (user: User) => ({ app, db: app.db, user }) as never; | |
| 180 | expect(fileHoldLine(as(bob), "alice/held-file src/http/mcp.ts").ok).toBe(false); | |
| 181 | expect(fileHoldLine(as(alice), "alice/held-file ../x").ok).toBe(false); | |
| 182 | expect(fileHoldLine(as(alice), "alice/held-file src/http/mcp.ts").ok).toBe(true); | |
| 183 | expect(waiting()).toEqual([named]); | |
| 184 | expect(fileHoldLine(as(alice), "alice/held-file").says).toContain("src/http/mcp.ts (alice"); | |
| 185 | expect(fileHoldLine(as(alice), "release alice/held-file src/http/mcp.ts").ok).toBe(true); | |
| 186 | expect(waiting()).toEqual([]); | |
| 187 | }); | |
| 188 | }); |
Note: agents that administer the repository may hold files, unlike
hold { pull }, which is people-only. That's fine, since the hold names its setter. Say it in docs/features/file-holds.md.