Public: anyone can read this; the crew can change it.
A hold set "until green" releases itself when a green check lands on the pull's head, with a note that says so #6508
Check · Review · Land · Live done — Landed and live in production.
scout/forge:scout/6495-a-hold-set-until-green-releases-itself-w → main 9 commits fork 8 threads · 8 unresolved
README.md +2
| @@ -40,6 +40,8 @@ when). The terminal's palette has the same as "Crew hand-overs", a read-only | ||
| 40 | 40 | list of the same rows (repo#number, title, reasons; it does not open the |
| 41 | 41 | issue — use the link on the page). Both are a forge admin's only. |
| 42 | 42 | |
| 43 | A hold can be set "until green" (a checkbox on the hold form, `until: "green"` on the hold API and the MCP `hold` tool): when a check passes on the pull's current head the forge releases it and the pull's timeline says `hold released: green on <short oid>`. A plain hold is never released by a check. | |
| 44 | ||
| 43 | 45 | A hand-over card on `/asks` (an agent's pull the reviewer stopped on after three rounds) has "Give back to the crew": one press assigns the pull, and the open issue it closes if a person holds it, to the agent that opened it, writes "Given back to the crew by @who", and the card leaves; the next tick starts its run. It is for a writer of the repository, and a pull a person opened, or by an agent gone from the crew, gets no button. In the terminal, `b` on a handed-over row of the On you pane does the same (`src/actions/give_back.ts`). The repository's planner can do it by comment: one that starts "Planner, one more round" on a handed-over crew pull emits the assignment to its builder, which starts the run (the pull is already the builder's, so nothing changes hands). It is the planner's alone; a reader's, the reviewer's or any other agent's does nothing. It is an agent's word, not a director's, so it does not end the hand-over: the card stays on `/asks` and the pull still counts as handed over during the round. |
| 44 | 46 | |
| 45 | 47 | Home opens with "Since you were here · 2 days": the pulls that landed in public repositories since you last loaded `/` (1 hour to 7 days back, a day on a first visit); the terminal's home shows the same lines, and MCP `feed` answers them as `digest`. See `docs/features/since-you-were-here.md`. |
docs/MCP.md +1 −1
| @@ -80,7 +80,7 @@ for a tunnel and a forge that sleeps. | ||
| 80 | 80 | | `forges` | an admin's list of the forges sent from here: name, address, whom for, apps, claimed or not, last activity | | |
| 81 | 81 | | `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 | |
| 82 | 82 | | `health` | how this forge is: the version and commit it runs, draining or not, the rollout and its incidents | | |
| 83 | | `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 | | |
| 83 | | `hold` | `{ app, pull | path, why?, until?, 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 | | |
| 84 | 84 | | `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 | |
| 85 | 85 | | `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 | |
| 86 | 86 | | `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; draws the `ui://forge/issue` card: title, state, assignee, labels and what it waits for, never the body or comments | | |
src/actions/holds.ts +33 −2
| @@ -15,11 +15,14 @@ import type { App } from "../app.ts"; | ||
| 15 | 15 | import { |
| 16 | 16 | cleanWhy, |
| 17 | 17 | getHold, |
| 18 | releaseGreenHold, | |
| 18 | 19 | releaseHold, |
| 19 | 20 | setHold, |
| 20 | 21 | type Hold, |
| 21 | 22 | } from "../core/holds.ts"; |
| 22 | import type { Pull } from "../core/pulls.ts"; | |
| 23 | import { getIssueById } from "../core/issues.ts"; | |
| 24 | import { getPull, type Pull } from "../core/pulls.ts"; | |
| 25 | import type { Run } from "../core/runs.ts"; | |
| 23 | 26 | import { canRead, canWrite, type Repo } from "../core/repos.ts"; |
| 24 | 27 | import type { User } from "../core/users.ts"; |
| 25 | 28 | import { err, ok, type Result } from "../kernel/result.ts"; |
| @@ -54,6 +57,7 @@ export function holdPull( | ||
| 54 | 57 | pull: Pull, |
| 55 | 58 | why: string, |
| 56 | 59 | dry = false, |
| 60 | until?: "green", | |
| 57 | 61 | ): Result<Hold, HoldError> { |
| 58 | 62 | const holder = requireHolder(app, actor, repo, pull); |
| 59 | 63 | if (!holder.ok) return holder; |
| @@ -67,8 +71,9 @@ export function holdPull( | ||
| 67 | 71 | heldByName: holder.value.username, |
| 68 | 72 | why: reason.value, |
| 69 | 73 | heldAt: getHold(app.db, pull.id)?.heldAt ?? new Date().toISOString(), |
| 74 | ...(until ? { until } : {}), | |
| 70 | 75 | }); |
| 71 | const hold = setHold(app.db, pull.id, holder.value.id, reason.value); | |
| 76 | const hold = setHold(app.db, pull.id, holder.value.id, reason.value, until); | |
| 72 | 77 | app.bus.append({ |
| 73 | 78 | kind: "pull.held", |
| 74 | 79 | repoId: repo.id, |
| @@ -102,3 +107,29 @@ export function releasePull( | ||
| 102 | 107 | }); |
| 103 | 108 | return ok(released); |
| 104 | 109 | } |
| 110 | ||
| 111 | /** | |
| 112 | * A check finished green: a hold set "until green" on that pull is let go | |
| 113 | * when the check was of the pull's current head. The pull's timeline gets | |
| 114 | * the one line. A plain hold, a red check and a check of an older head | |
| 115 | * change nothing. | |
| 116 | */ | |
| 117 | export function releaseOnGreen(app: App, run: Run): boolean { | |
| 118 | if (run.kind !== "check" || run.state !== "ok" || run.issueId === null) | |
| 119 | return false; | |
| 120 | const issue = getIssueById(app.db, run.issueId); | |
| 121 | if (!issue || issue.kind !== "pull") return false; | |
| 122 | const pull = getPull(app.db, issue.repoId, issue.number); | |
| 123 | if (!pull || pull.state !== "open" || pull.headOid !== run.commitOid) | |
| 124 | return false; | |
| 125 | if (!releaseGreenHold(app.db, pull.id)) return false; | |
| 126 | app.bus.append({ | |
| 127 | kind: "pull.released", | |
| 128 | repoId: pull.repoId, | |
| 129 | payload: { | |
| 130 | number: pull.number, | |
| 131 | note: `hold released: green on ${run.commitOid.slice(0, 7)}`, | |
High: this note lives only in the event payload. |
||
| 132 | }, | |
| 133 | }); | |
| 134 | return true; | |
| 135 | } |
src/checks/service.ts +2
| @@ -16,6 +16,7 @@ import { existsSync, mkdirSync, readdirSync, rmSync } from "node:fs"; | ||
| 16 | 16 | import { tmpdir } from "node:os"; |
| 17 | 17 | import { join } from "node:path"; |
| 18 | 18 | import type { App } from "../app.ts"; |
| 19 | import { releaseOnGreen } from "../actions/holds.ts"; | |
| 19 | 20 | import { announceRun } from "../actions/runs.ts"; |
| 20 | 21 | import { announceShard } from "./shard_event.ts"; |
| 21 | 22 | import type { ForgeEvent } from "../core/events.ts"; |
| @@ -669,6 +670,7 @@ export function startChecks(app: App, options: ChecksOptions): ChecksHandle { | ||
| 669 | 670 | }); |
| 670 | 671 | write(outcome.ok ? `passed in ${seconds}s` : `failed: ${outcome.error}`); |
| 671 | 672 | if (done) announceRun(app, done); |
| 673 | if (done && outcome.ok) releaseOnGreen(app, done); | |
| 672 | 674 | if (!outcome.ok && run.issueId === null && run.env === repo.defaultBranch) |
| 673 | 675 | proposeRevert(app, repo.id).catch((error) => |
| 674 | 676 | log.error("revert proposal failed", { error: String(error) }), |
src/contract/openapi/routes/putApiV1ReposOwnerNamePullsNumberHold.ts +3 −2
| @@ -4,13 +4,14 @@ export const route: Route = { | ||
| 4 | 4 | method: "put", |
| 5 | 5 | path: `${REPO}/pulls/{number}/hold`, |
| 6 | 6 | summary: |
| 7 | "Hold an open pull with one line that says why, so no rule reviews or lands it until it is released; a person who writes the repository holds, never an agent (rw scope); answers `{ held: true, hold }`", | |
| 7 | "Hold an open pull (with until green, the forge releases it when a check passes on the pull's head) with one line that says why, so no rule reviews or lands it until it is released; a person who writes the repository holds, never an agent (rw scope); answers `{ held: true, hold }`", | |
| 8 | 8 | auth: true, |
| 9 | body: { why: "string" }, | |
| 9 | body: { why: "string", until: "string" }, | |
| 10 | 10 | refusals: { |
| 11 | 11 | forbidden: "you are an agent, or you cannot write the repository (403)", |
| 12 | 12 | not_open: "the pull is closed or merged (409)", |
| 13 | 13 | empty_why: "`why` is empty (422)", |
| 14 | bad_until: "`until` is given and is not `green` (422)", | |
| 14 | 15 | why_too_long: "`why` is longer than 200 characters (422)", |
| 15 | 16 | }, |
| 16 | 17 | }; |
src/core/holds.ts +26 −5
| @@ -26,6 +26,8 @@ export interface Hold { | ||
| 26 | 26 | /** Why, in one line. */ |
| 27 | 27 | readonly why: string; |
| 28 | 28 | readonly heldAt: string; |
| 29 | /** "green": the forge lets it go when a check passes on the pull's head. */ | |
| 30 | readonly until?: "green"; | |
| 29 | 31 | } |
| 30 | 32 | |
| 31 | 33 | interface Row { |
| @@ -34,6 +36,7 @@ interface Row { | ||
| 34 | 36 | username: string; |
| 35 | 37 | why: string; |
| 36 | 38 | held_at: string; |
| 39 | until_green: number; | |
| 37 | 40 | } |
| 38 | 41 | |
| 39 | 42 | const fromRow = (row: Row): Hold => ({ |
| @@ -42,10 +45,11 @@ const fromRow = (row: Row): Hold => ({ | ||
| 42 | 45 | heldByName: row.username, |
| 43 | 46 | why: row.why, |
| 44 | 47 | heldAt: row.held_at, |
| 48 | ...(row.until_green === 1 ? { until: "green" as const } : {}), | |
| 45 | 49 | }); |
| 46 | 50 | |
| 47 | 51 | const STANDING = `SELECT pull_hold.issue_id, pull_hold.held_by, user.username, |
| 48 | pull_hold.why, pull_hold.held_at | |
| 52 | pull_hold.why, pull_hold.held_at, pull_hold.until_green | |
| 49 | 53 | FROM pull_hold JOIN user ON user.id = pull_hold.held_by |
| 50 | 54 | WHERE pull_hold.released_at IS NULL`; |
| 51 | 55 | |
| @@ -94,17 +98,19 @@ export function setHold( | ||
| 94 | 98 | pullId: string, |
| 95 | 99 | by: string, |
| 96 | 100 | why: string, |
| 101 | until?: "green", | |
| 97 | 102 | ): Hold { |
| 103 | const green = until === "green" ? 1 : 0; | |
| 98 | 104 | const changed = db |
| 99 | 105 | .query( |
| 100 | `UPDATE pull_hold SET held_by = ?2, why = ?3 | |
| 106 | `UPDATE pull_hold SET held_by = ?2, why = ?3, until_green = ?4 | |
| 101 | 107 | WHERE issue_id = ?1 AND released_at IS NULL`, |
| 102 | 108 | ) |
| 103 | .run(pullId, by, why).changes; | |
| 109 | .run(pullId, by, why, green).changes; | |
| 104 | 110 | if (changed === 0) |
| 105 | 111 | db.query( |
| 106 | "INSERT INTO pull_hold (id, issue_id, held_by, why) VALUES (?1, ?2, ?3, ?4)", | |
| 107 | ).run(id(), pullId, by, why); | |
| 112 | "INSERT INTO pull_hold (id, issue_id, held_by, why, until_green) VALUES (?1, ?2, ?3, ?4, ?5)", | |
| 113 | ).run(id(), pullId, by, why, green); | |
| 108 | 114 | return getHold(db, pullId)!; |
| 109 | 115 | } |
| 110 | 116 | |
| @@ -121,6 +127,21 @@ export function releaseHold(db: Database, pullId: string, by: string): boolean { | ||
| 121 | 127 | ); |
| 122 | 128 | } |
| 123 | 129 | |
| 130 | /** | |
| 131 | * The forge lets go of a hold set "until green". False when none stands or | |
| 132 | * the hold is a plain one: a green check never lifts those. | |
| 133 | */ | |
| 134 | export function releaseGreenHold(db: Database, pullId: string): boolean { | |
| 135 | return ( | |
| 136 | db | |
| 137 | .query( | |
| 138 | `UPDATE pull_hold SET released_at = strftime('%Y-%m-%dT%H:%M:%fZ','now') | |
| 139 | WHERE issue_id = ?1 AND released_at IS NULL AND until_green = 1`, | |
| 140 | ) | |
| 141 | .run(pullId).changes > 0 | |
| 142 | ); | |
| 143 | } | |
| 144 | ||
| 124 | 145 | /** When a hold on this pull was last released, if one ever was. */ |
| 125 | 146 | export function lastRelease(db: Database, pullId: string): string | undefined { |
| 126 | 147 | return ( |
src/core/migration-pins.json +2 −1
| @@ -121,5 +121,6 @@ | ||
| 121 | 121 | "20261019130000-run-model-prompt": "ce2e94f439649fc042c0a951f1af4a740b91f674dc94521e865ec517f68994f5", |
outdated
Low: these 26 pins are for migrations this pull doesn't touch, and they will conflict with any parallel pull that adds a migration. Keep only the |
||
| 122 | 122 | "20261019140000-route-timing": "0fd7cfa4e0a50c4b2b36162601f9ddc3318e7fa445559732364b2b52a06de011", |
| 123 | 123 | "20261019150000-page-vitals": "63fe647d95733a5194a99ae955181f2456d5c2e8c9c38d37bbf1bad395b72bdf", |
| 124 | "20261020120000-app-vitals-five": "6c75f4137e5592bf728ffe05d42d33bc158c9dabab3c666c58bc7b6dceb57576" | |
| 124 | "20261020120000-app-vitals-five": "6c75f4137e5592bf728ffe05d42d33bc158c9dabab3c666c58bc7b6dceb57576", | |
| 125 | "20261026190000-hold-until-green": "2be50f3a7ee590b05ae1048f3adbe7040452501a9c684d981e2efbaf88256c58" | |
| 125 | 126 | } |
src/core/migrations/20261026190000-hold-until-green.ts added +6
| @@ -0,0 +1,6 @@ | ||
| 1 | // A hold set "until green" (#6495, core/holds.ts): the forge lets it go when | |
| 2 | // a check passes on the pull's head. 0 is a plain hold, which is every hold | |
| 3 | // there is today. Additive. | |
| 4 | export default ` | |
| 5 | ALTER TABLE pull_hold ADD COLUMN until_green INTEGER NOT NULL DEFAULT 0; | |
| 6 | `; |
src/design/activity.ts +2 −1
| @@ -89,7 +89,8 @@ export function eventActor( | ||
| 89 | 89 | if (actorName !== undefined) return actorName; |
| 90 | 90 | return event.kind.startsWith("run.") || |
| 91 | 91 | event.kind.startsWith("app.") || |
| 92 | event.kind.startsWith("prompt.") | |
| 92 | event.kind.startsWith("prompt.") || | |
| 93 | (event.kind === "pull.released" && event.actorId === null) | |
| 93 | 94 | ? "the forge" |
| 94 | 95 | : "someone"; |
| 95 | 96 | } |
src/http/api.ts +5
| @@ -1223,6 +1223,7 @@ const holdJson = (hold: Hold) => ({ | ||
| 1223 | 1223 | by: hold.heldByName, |
| 1224 | 1224 | why: hold.why, |
| 1225 | 1225 | at: hold.heldAt, |
| 1226 | ...(hold.until ? { until: hold.until } : {}), | |
| 1226 | 1227 | }); |
| 1227 | 1228 | |
| 1228 | 1229 | function loadPull(ctx: Ctx): Found<{ pull: Pull }> { |
| @@ -4290,12 +4291,16 @@ export function apiRoutes(router: Router<Handler>): void { | ||
| 4290 | 4291 | const found = loadPull(ctx); |
outdated
Medium: only this JSON door takes |
||
| 4291 | 4292 | if ("error" in found) return found.error; |
| 4292 | 4293 | const input = await body(ctx.request); |
| 4294 | if (input.until !== undefined && input.until !== "green") | |
| 4295 | return respondAccess("bad_until", 422); | |
| 4293 | 4296 | const held = holdPull( |
| 4294 | 4297 | ctx.app, |
| 4295 | 4298 | ctx.auth.user, |
| 4296 | 4299 | found.repo, |
| 4297 | 4300 | found.pull, |
| 4298 | 4301 | str(input.why), |
| 4302 | false, | |
| 4303 | input.until === "green" ? "green" : undefined, | |
| 4299 | 4304 | ); |
| 4300 | 4305 | if (!held.ok) |
| 4301 | 4306 | return respondAccess(held.error, held.error === "not_open" ? 409 : 422); |
src/http/mcp/tools/hold.ts +17 −2
| @@ -47,7 +47,7 @@ function fileHold(ctx: Ctx, user: User, repo: Repo, args: Args): Answer { | ||
| 47 | 47 | |
| 48 | 48 | export const tool: Tool = { |
| 49 | 49 | name: "hold", |
| 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`", | |
| 50 | doc: "`{ app, pull | path, why?, until?, 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`", | |
| 51 | 51 | description: |
| 52 | 52 | "Hold a pull (`why`) or `release` it; or `path`: hold new work on a file. Neither: list file holds.", |
| 53 | 53 | inputSchema: { |
| @@ -60,6 +60,11 @@ export const tool: Tool = { | ||
| 60 | 60 | type: "string", |
| 61 | 61 | description: "Why, one line.", |
| 62 | 62 | }, |
| 63 | until: { | |
| 64 | type: "string", | |
| 65 | enum: ["green"], | |
| 66 | description: "green: released when a check passes.", | |
| 67 | }, | |
| 63 | 68 | release: { |
| 64 | 69 | type: "boolean", |
| 65 | 70 | }, |
outdated
An |
||
| @@ -100,7 +105,17 @@ export const tool: Tool = { | ||
| 100 | 105 | dryRun: dry, |
| 101 | 106 | }); |
| 102 | 107 | } |
| 103 | const held = holdPull(ctx.app, user, repo, pull, str(args["why"]), dry); | |
| 108 | if (args["until"] !== undefined && args["until"] !== "green") | |
| 109 | return bad("bad_until", 'until, when given, is "green"'); | |
| 110 | const held = holdPull( | |
| 111 | ctx.app, | |
| 112 | user, | |
| 113 | repo, | |
| 114 | pull, | |
| 115 | str(args["why"]), | |
| 116 | dry, | |
| 117 | args["until"] === "green" ? "green" : undefined, | |
| 118 | ); | |
| 104 | 119 | if (!held.ok) |
| 105 | 120 | return bad( |
| 106 | 121 | held.error, |
src/web/pages/thread.ts +16 −1
| @@ -168,6 +168,11 @@ const EVENT_LOOK: Record<string, { icon: IconName; tone: Tone; verb: string }> = | ||
| 168 | 168 | verb: "let this head run", |
| 169 | 169 | }, |
| 170 | 170 | "pull.edited": { icon: "pencil", tone: "default", verb: "edited this" }, |
| 171 | "pull.released": { | |
| 172 | icon: "check", | |
| 173 | tone: "success", | |
| 174 | verb: "released the hold", | |
| 175 | }, | |
| 171 | 176 | }; |
| 172 | 177 | |
| 173 | 178 | /** |
| @@ -199,12 +204,22 @@ function stateEvents(ctx: Ctx, repo: Repo, issue: Issue): Entry[] { | ||
| 199 | 204 | const look = EVENT_LOOK[event.kind]!; |
| 200 | 205 | const actor = |
| 201 | 206 | event.actorId !== null ? actors.get(event.actorId) : undefined; |
| 207 | // The forge's own release of an until-green hold carries its line and no actor. | |
| 208 | const note = | |
| 209 | event.kind === "pull.released" && typeof event.payload.note === "string" | |
| 210 | ? event.payload.note | |
| 211 | : undefined; | |
| 202 | 212 | entries.push({ |
| 203 | 213 | at: event.createdAt, |
| 204 | 214 | node: tlEvent({ |
| 205 | 215 | icon: look.icon, |
| 206 | 216 | tone: look.tone, |
| 207 | body: html`${actor | |
| 217 | body: | |
| 218 | note !== undefined && !actor | |
| 219 | ? html`<span>the forge</span> <span>${note}</span> ${time( | |
| 220 | event.createdAt, | |
| 221 | )}` | |
| 222 | : html`${actor | |
| 208 | 223 | ? userLink(actor.username, { mark: markOf(actor) }) |
| 209 | 224 | : html`<span>someone</span>`} <span>${look.verb}</span> ${time( |
| 210 | 225 | event.createdAt, |
src/web/studio/actions.ts +2
| @@ -346,6 +346,8 @@ export async function holdSubmit(ctx: Ctx): Promise<Response> { | ||
| 346 | 346 | repo, |
| 347 | 347 | found.pull, |
| 348 | 348 | field(form, "why"), |
| 349 | false, | |
| 350 | field(form, "until") === "green" ? "green" : undefined, | |
| 349 | 351 | ); |
| 350 | 352 | if (!held.ok && (held.error === "not_found" || held.error === "forbidden")) |
| 351 | 353 | return notFoundPage(ctx); |
src/web/studio/hold.ts +8
| @@ -41,6 +41,10 @@ export function holdCard( | ||
| 41 | 41 | })} |
| 42 | 42 | holds this, ${time(hold.heldAt)}: ${hold.why} |
| 43 | 43 | </p> |
| 44 | ${when( | |
| 45 | hold.until === "green", | |
| 46 | () => html`<p class="muted small">Releases on green.</p>`, | |
| 47 | )} | |
| 44 | 48 | <p class="muted small"> |
| 45 | 49 | No rule sends it to review or lands it until it is released. A person |
| 46 | 50 | can still ship it. |
| @@ -71,6 +75,10 @@ export function holdCard( | ||
| 71 | 75 | placeholder="Why, in one line" |
| 72 | 76 | aria-label="Why it is held, in one line" |
| 73 | 77 | /> |
| 78 | <label class="check" | |
outdated
Hand-rolled checkbox. The design system's checkbox component is |
||
| 79 | ><input type="checkbox" name="until" value="green" /> | |
| 80 | <span>until green</span></label | |
| 81 | > | |
| 74 | 82 | ${btn("Hold", { size: "sm" })} |
| 75 | 83 | </form> |
| 76 | 84 | <p class="muted small"> |
test/holds.test.ts +125 −1
| @@ -9,7 +9,7 @@ import { mkdtemp, rm } from "node:fs/promises"; | ||
| 9 | 9 | import { tmpdir } from "node:os"; |
| 10 | 10 | import { join } from "node:path"; |
| 11 | 11 | import { addToCrew, createAgent } from "../src/actions/crew.ts"; |
| 12 | import { holdPull, releasePull } from "../src/actions/holds.ts"; | |
| 12 | import { holdPull, releaseOnGreen, releasePull } from "../src/actions/holds.ts"; | |
| 13 | 13 | import { openIssue } from "../src/actions/issues.ts"; |
| 14 | 14 | import { mergePull, openPull } from "../src/actions/pulls.ts"; |
| 15 | 15 | import { commitToBranch, createRepo } from "../src/actions/repos.ts"; |
| @@ -781,6 +781,56 @@ describe("a hold is set, shown and lifted", () => { | ||
| 781 | 781 | // She said "not yet"; dana is still asked. |
| 782 | 782 | expect([how(f.alice), how(dana)]).toEqual([["acted"], [null]]); |
| 783 | 783 | }); |
| 784 | ||
| 785 | test("until green: the form sets it, the card says it, and the release shows on the pull page as the forge's line", async () => { | |
| 786 | const cookie = await signIn("alice"); | |
| 787 | const mine = await f.pullOn("until-green-page"); | |
| 788 | const change = `/alice/garden/pulls/${mine.number}`; | |
| 789 | expect(await page(change, cookie)).toContain('name="until"'); | |
| 790 | const sent = await form(`${change}/hold`, cookie, { | |
| 791 | why: "red alone", | |
| 792 | until: "green", | |
| 793 | }); | |
| 794 | expect(sent.status).toBe(303); | |
| 795 | expect(getHold(f.app.db, mine.id)?.until).toBe("green"); | |
| 796 | expect(await page(change, cookie)).toContain("Releases on green."); | |
| 797 | // The MCP door sets it too. | |
| 798 | releaseHold(f.app.db, mine.id, f.alice.id); | |
| 799 | const planned = await mcp("hold", { | |
| 800 | app: "alice/garden", | |
| 801 | pull: mine.number, | |
| 802 | why: "red alone", | |
| 803 | until: "green", | |
| 804 | dryRun: false, | |
| 805 | }); | |
| 806 | expect(planned.error).toBeUndefined(); | |
| 807 | expect(getHold(f.app.db, mine.id)?.until).toBe("green"); | |
| 808 | releaseHold(f.app.db, mine.id, f.alice.id); | |
| 809 | const typo = await mcp("hold", { | |
| 810 | app: "alice/garden", | |
| 811 | pull: mine.number, | |
| 812 | why: "red alone", | |
| 813 | until: "red", | |
| 814 | dryRun: false, | |
| 815 | }); | |
| 816 | expect(typo.error).toContain("bad_until"); | |
| 817 | expect(getHold(f.app.db, mine.id)).toBeUndefined(); | |
| 818 | holdPull(f.app, f.alice, f.repo, mine, "red alone", false, "green"); | |
| 819 | const check = createRun(f.app.db, { | |
| 820 | repoId: f.repo.id, | |
| 821 | kind: "check", | |
| 822 | issueId: mine.id, | |
| 823 | env: `pull-${mine.number}`, | |
| 824 | commitOid: mine.headOid, | |
| 825 | reason: "pull", | |
| 826 | }); | |
| 827 | claimRun(f.app.db, check.id); | |
| 828 | const done = finishRun(f.app.db, check.id, { state: "ok", summary: "ok" })!; | |
| 829 | expect(releaseOnGreen(f.app, done)).toBe(true); | |
| 830 | const text = await page(change, cookie); | |
| 831 | expect(text).toContain(`hold released: green on ${mine.headOid.slice(0, 7)}`); | |
| 832 | expect(text).toContain("the forge"); | |
| 833 | }); | |
| 784 | 834 | }); |
| 785 | 835 | |
| 786 | 836 | describe("a hold, as it is kept", () => { |
| @@ -851,3 +901,77 @@ describe("a hold, as it is kept", () => { | ||
| 851 | 901 | expect(lastRelease(app.db, pull.id)).toBe(released); |
| 852 | 902 | }); |
| 853 | 903 | }); |
| 904 | ||
| 905 | describe("a hold set until green", () => { | |
| 906 | let f: Awaited<ReturnType<typeof forge>>; | |
| 907 | let app: App; | |
| 908 | ||
| 909 | beforeAll(async () => { | |
| 910 | f = await forge("forge-holds-green-", "private"); | |
| 911 | app = f.app; | |
| 912 | }, 30_000); | |
| 913 | ||
| 914 | afterAll(() => f.close()); | |
| 915 | ||
| 916 | /** A finished check on the pull's head, as the verdict leaves it. */ | |
| 917 | const verdict = (pull: Pull, state: "ok" | "failed", oid = pull.headOid) => { | |
| 918 | const check = createRun(app.db, { | |
| 919 | repoId: f.repo.id, | |
| 920 | kind: "check", | |
| 921 | issueId: pull.id, | |
| 922 | env: `pull-${pull.number}`, | |
| 923 | commitOid: oid, | |
| 924 | reason: "pull", | |
| 925 | }); | |
| 926 | claimRun(app.db, check.id); | |
| 927 | return finishRun(app.db, check.id, { state, summary: state })!; | |
| 928 | }; | |
| 929 | const notes = (pull: Pull) => | |
| 930 | ( | |
| 931 | app.db | |
| 932 | .query("SELECT payload FROM event WHERE kind = 'pull.released'") | |
| 933 | .all() as { payload: string }[] | |
| 934 | ) | |
| 935 | .map((r) => JSON.parse(r.payload)) | |
| 936 | .filter((p) => p.number === pull.number) | |
| 937 | .map((p) => p.note); | |
| 938 | ||
| 939 | test("a green check on the head releases it, with one line saying so", async () => { | |
| 940 | const pull = await f.pullOn("green-one"); | |
| 941 | const held = setHold(app.db, pull.id, f.alice.id, "red alone", "green"); | |
| 942 | expect(held.until).toBe("green"); | |
| 943 | expect(releaseOnGreen(app, verdict(pull, "failed"))).toBe(false); | |
| 944 | expect(getHold(app.db, pull.id)).toBeDefined(); | |
| 945 | expect(releaseOnGreen(app, verdict(pull, "ok", "0".repeat(40)))).toBe(false); | |
| 946 | expect(getHold(app.db, pull.id)).toBeDefined(); | |
| 947 | expect(releaseOnGreen(app, verdict(pull, "ok"))).toBe(true); | |
| 948 | expect(getHold(app.db, pull.id)).toBeUndefined(); | |
| 949 | expect(lastRelease(app.db, pull.id)).toBeDefined(); | |
| 950 | expect(notes(pull)).toEqual([ | |
| 951 | `hold released: green on ${pull.headOid.slice(0, 7)}`, | |
| 952 | ]); | |
| 953 | }); | |
| 954 | ||
| 955 | test("a plain hold stays through a green check", async () => { | |
| 956 | const pull = await f.pullOn("green-two"); | |
| 957 | setHold(app.db, pull.id, f.alice.id, "waiting"); | |
| 958 | expect(getHold(app.db, pull.id)?.until).toBeUndefined(); | |
| 959 | expect(releaseOnGreen(app, verdict(pull, "ok"))).toBe(false); | |
| 960 | expect(getHold(app.db, pull.id)?.why).toBe("waiting"); | |
| 961 | expect(notes(pull)).toEqual([]); | |
| 962 | }); | |
| 963 | ||
| 964 | test("holding a held pull again sets whether it waits for green", async () => { | |
| 965 | const pull = await f.pullOn("green-three"); | |
| 966 | setHold(app.db, pull.id, f.alice.id, "waiting", "green"); | |
| 967 | expect(setHold(app.db, pull.id, f.alice.id, "waiting").until).toBeUndefined(); | |
| 968 | expect(releaseOnGreen(app, verdict(pull, "ok"))).toBe(false); | |
| 969 | }); | |
| 970 | ||
| 971 | test("holdPull takes until: green", async () => { | |
| 972 | const pull = await f.pullOn("green-four"); | |
| 973 | const held = holdPull(app, f.alice, f.repo, pull, "red alone", false, "green"); | |
| 974 | expect(held.ok && held.value.until).toBe("green"); | |
| 975 | expect(releaseOnGreen(app, verdict(pull, "ok"))).toBe(true); | |
| 976 | }); | |
| 977 | }); |
This only runs when a check finishes. A hold set until green on a head that already passed stands until the next push's check, while the card says "Releases on green." Tracked in #6529.