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

Merged opened this

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

Merged

scout/forge:scout/6495-a-hold-set-until-green-releases-itself-w → main 9 commits fork 8 threads · 8 unresolved

  • Merged. scout/6495-a-hold-set-until-green-releases-itself-w was merged into main as 4867452.

Review

Sign in to review this change.
15 files changed +250 −16
README.md +2
@@ -40,6 +40,8 @@ when). The terminal's palette has the same as "Crew hand-overs", a read-only
4040 list of the same rows (repo#number, title, reasons; it does not open the
4141 issue — use the link on the page). Both are a forge admin's only.
4242
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
4345 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.
4446
4547 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.
8080 | `forges` | an admin's list of the forges sent from here: name, address, whom for, apps, claimed or not, last activity | |
8181 | `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 |
8282 | `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 |
8484 | `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 |
8585 | `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 |
8686 | `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";
1515 import {
1616 cleanWhy,
1717 getHold,
18 releaseGreenHold,
1819 releaseHold,
1920 setHold,
2021 type Hold,
2122 } 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";
2326 import { canRead, canWrite, type Repo } from "../core/repos.ts";
2427 import type { User } from "../core/users.ts";
2528 import { err, ok, type Result } from "../kernel/result.ts";
@@ -54,6 +57,7 @@ export function holdPull(
5457 pull: Pull,
5558 why: string,
5659 dry = false,
60 until?: "green",
5761 ): Result<Hold, HoldError> {
5862 const holder = requireHolder(app, actor, repo, pull);
5963 if (!holder.ok) return holder;
@@ -67,8 +71,9 @@ export function holdPull(
6771 heldByName: holder.value.username,
6872 why: reason.value,
6973 heldAt: getHold(app.db, pull.id)?.heldAt ?? new Date().toISOString(),
74 ...(until ? { until } : {}),
7075 });
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);
7277 app.bus.append({
7378 kind: "pull.held",
7479 repoId: repo.id,
@@ -102,3 +107,29 @@ export function releasePull(
102107 });
103108 return ok(released);
104109 }
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 {

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.

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. thread.ts EVENT_LOOK has no pull.released, so the pull timeline never shows it. With no actorId, the activity feed reads "someone released a pull request". Render it on the timeline as the forge's line, and test that it appears on the pull page.

132 },
133 });
134 return true;
135 }
src/checks/service.ts +2
@@ -16,6 +16,7 @@ import { existsSync, mkdirSync, readdirSync, rmSync } from "node:fs";
1616 import { tmpdir } from "node:os";
1717 import { join } from "node:path";
1818 import type { App } from "../app.ts";
19 import { releaseOnGreen } from "../actions/holds.ts";
1920 import { announceRun } from "../actions/runs.ts";
2021 import { announceShard } from "./shard_event.ts";
2122 import type { ForgeEvent } from "../core/events.ts";
@@ -669,6 +670,7 @@ export function startChecks(app: App, options: ChecksOptions): ChecksHandle {
669670 });
670671 write(outcome.ok ? `passed in ${seconds}s` : `failed: ${outcome.error}`);
671672 if (done) announceRun(app, done);
673 if (done && outcome.ok) releaseOnGreen(app, done);
672674 if (!outcome.ok && run.issueId === null && run.env === repo.defaultBranch)
673675 proposeRevert(app, repo.id).catch((error) =>
674676 log.error("revert proposal failed", { error: String(error) }),
src/contract/openapi/routes/putApiV1ReposOwnerNamePullsNumberHold.ts +3 −2
@@ -4,13 +4,14 @@ export const route: Route = {
44 method: "put",
55 path: `${REPO}/pulls/{number}/hold`,
66 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 }`",
88 auth: true,
9 body: { why: "string" },
9 body: { why: "string", until: "string" },
1010 refusals: {
1111 forbidden: "you are an agent, or you cannot write the repository (403)",
1212 not_open: "the pull is closed or merged (409)",
1313 empty_why: "`why` is empty (422)",
14 bad_until: "`until` is given and is not `green` (422)",
1415 why_too_long: "`why` is longer than 200 characters (422)",
1516 },
1617 };
src/core/holds.ts +26 −5
@@ -26,6 +26,8 @@ export interface Hold {
2626 /** Why, in one line. */
2727 readonly why: string;
2828 readonly heldAt: string;
29 /** "green": the forge lets it go when a check passes on the pull's head. */
30 readonly until?: "green";
2931 }
3032
3133 interface Row {
@@ -34,6 +36,7 @@ interface Row {
3436 username: string;
3537 why: string;
3638 held_at: string;
39 until_green: number;
3740 }
3841
3942 const fromRow = (row: Row): Hold => ({
@@ -42,10 +45,11 @@ const fromRow = (row: Row): Hold => ({
4245 heldByName: row.username,
4346 why: row.why,
4447 heldAt: row.held_at,
48 ...(row.until_green === 1 ? { until: "green" as const } : {}),
4549 });
4650
4751 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
4953 FROM pull_hold JOIN user ON user.id = pull_hold.held_by
5054 WHERE pull_hold.released_at IS NULL`;
5155
@@ -94,17 +98,19 @@ export function setHold(
9498 pullId: string,
9599 by: string,
96100 why: string,
101 until?: "green",
97102 ): Hold {
103 const green = until === "green" ? 1 : 0;
98104 const changed = db
99105 .query(
100 `UPDATE pull_hold SET held_by = ?2, why = ?3
106 `UPDATE pull_hold SET held_by = ?2, why = ?3, until_green = ?4
101107 WHERE issue_id = ?1 AND released_at IS NULL`,
102108 )
103 .run(pullId, by, why).changes;
109 .run(pullId, by, why, green).changes;
104110 if (changed === 0)
105111 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);
108114 return getHold(db, pullId)!;
109115 }
110116
@@ -121,6 +127,21 @@ export function releaseHold(db: Database, pullId: string, by: string): boolean {
121127 );
122128 }
123129
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
124145 /** When a hold on this pull was last released, if one ever was. */
125146 export function lastRelease(db: Database, pullId: string): string | undefined {
126147 return (
src/core/migration-pins.json +2 −1
@@ -121,5 +121,6 @@
121121 "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 hold-until-green pin.

122122 "20261019140000-route-timing": "0fd7cfa4e0a50c4b2b36162601f9ddc3318e7fa445559732364b2b52a06de011",
123123 "20261019150000-page-vitals": "63fe647d95733a5194a99ae955181f2456d5c2e8c9c38d37bbf1bad395b72bdf",
124 "20261020120000-app-vitals-five": "6c75f4137e5592bf728ffe05d42d33bc158c9dabab3c666c58bc7b6dceb57576"
124 "20261020120000-app-vitals-five": "6c75f4137e5592bf728ffe05d42d33bc158c9dabab3c666c58bc7b6dceb57576",
125 "20261026190000-hold-until-green": "2be50f3a7ee590b05ae1048f3adbe7040452501a9c684d981e2efbaf88256c58"
125126 }
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(
8989 if (actorName !== undefined) return actorName;
9090 return event.kind.startsWith("run.") ||
9191 event.kind.startsWith("app.") ||
92 event.kind.startsWith("prompt.")
92 event.kind.startsWith("prompt.") ||
93 (event.kind === "pull.released" && event.actorId === null)
9394 ? "the forge"
9495 : "someone";
9596 }
src/http/api.ts +5
@@ -1223,6 +1223,7 @@ const holdJson = (hold: Hold) => ({
12231223 by: hold.heldByName,
12241224 why: hold.why,
12251225 at: hold.heldAt,
1226 ...(hold.until ? { until: hold.until } : {}),
12261227 });
12271228
12281229 function loadPull(ctx: Ctx): Found<{ pull: Pull }> {
@@ -4290,12 +4291,16 @@ export function apiRoutes(router: Router<Handler>): void {
42904291 const found = loadPull(ctx);
outdated

Medium: only this JSON door takes until. The page's holdSubmit (studio/actions.ts) and the MCP hold tool call holdPull without it, and holdCard never shows that a hold is until-green. Wire both doors through and show it on the card.

42914292 if ("error" in found) return found.error;
42924293 const input = await body(ctx.request);
4294 if (input.until !== undefined && input.until !== "green")
4295 return respondAccess("bad_until", 422);
42934296 const held = holdPull(
42944297 ctx.app,
42954298 ctx.auth.user,
42964299 found.repo,
42974300 found.pull,
42984301 str(input.why),
4302 false,
4303 input.until === "green" ? "green" : undefined,
42994304 );
43004305 if (!held.ok)
43014306 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 {
4747
4848 export const tool: Tool = {
4949 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`",
5151 description:
5252 "Hold a pull (`why`) or `release` it; or `path`: hold new work on a file. Neither: list file holds.",
5353 inputSchema: {
@@ -60,6 +60,11 @@ export const tool: Tool = {
6060 type: "string",
6161 description: "Why, one line.",
6262 },
63 until: {
64 type: "string",
65 enum: ["green"],
66 description: "green: released when a check passes.",
67 },
6368 release: {
6469 type: "boolean",
6570 },
outdated

An until other than "green" is silently dropped, so the caller gets a plain hold that never releases. The API answers 422 bad_until for the same input. MCP does not enforce enum, so refuse it here before calling holdPull, and add a test.

@@ -100,7 +105,17 @@ export const tool: Tool = {
100105 dryRun: dry,
101106 });
102107 }
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 );
104119 if (!held.ok)
105120 return bad(
106121 held.error,
src/web/pages/thread.ts +16 −1
@@ -168,6 +168,11 @@ const EVENT_LOOK: Record<string, { icon: IconName; tone: Tone; verb: string }> =
168168 verb: "let this head run",
169169 },
170170 "pull.edited": { icon: "pencil", tone: "default", verb: "edited this" },
171 "pull.released": {
172 icon: "check",
173 tone: "success",
174 verb: "released the hold",
175 },
171176 };
172177
173178 /**
@@ -199,12 +204,22 @@ function stateEvents(ctx: Ctx, repo: Repo, issue: Issue): Entry[] {
199204 const look = EVENT_LOOK[event.kind]!;
200205 const actor =
201206 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;
202212 entries.push({
203213 at: event.createdAt,
204214 node: tlEvent({
205215 icon: look.icon,
206216 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
208223 ? userLink(actor.username, { mark: markOf(actor) })
209224 : html`<span>someone</span>`} <span>${look.verb}</span> ${time(
210225 event.createdAt,
src/web/studio/actions.ts +2
@@ -346,6 +346,8 @@ export async function holdSubmit(ctx: Ctx): Promise<Response> {
346346 repo,
347347 found.pull,
348348 field(form, "why"),
349 false,
350 field(form, "until") === "green" ? "green" : undefined,
349351 );
350352 if (!held.ok && (held.error === "not_found" || held.error === "forbidden"))
351353 return notFoundPage(ctx);
src/web/studio/hold.ts +8
@@ -41,6 +41,10 @@ export function holdCard(
4141 })}
4242 holds this, ${time(hold.heldAt)}: ${hold.why}
4343 </p>
44 ${when(
45 hold.until === "green",
46 () => html`<p class="muted small">Releases on green.</p>`,
47 )}
4448 <p class="muted small">
4549 No rule sends it to review or lands it until it is released. A person
4650 can still ship it.
@@ -71,6 +75,10 @@ export function holdCard(
7175 placeholder="Why, in one line"
7276 aria-label="Why it is held, in one line"
7377 />
78 <label class="check"
outdated

Hand-rolled checkbox. The design system's checkbox component is .check (§5 components). Use <label class="check"><input type="checkbox" name="until" value="green" /> <span>until green</span></label>, as settings.ts does.

79 ><input type="checkbox" name="until" value="green" />
80 <span>until green</span></label
81 >
7482 ${btn("Hold", { size: "sm" })}
7583 </form>
7684 <p class="muted small">
test/holds.test.ts +125 −1
@@ -9,7 +9,7 @@ import { mkdtemp, rm } from "node:fs/promises";
99 import { tmpdir } from "node:os";
1010 import { join } from "node:path";
1111 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";
1313 import { openIssue } from "../src/actions/issues.ts";
1414 import { mergePull, openPull } from "../src/actions/pulls.ts";
1515 import { commitToBranch, createRepo } from "../src/actions/repos.ts";
@@ -781,6 +781,56 @@ describe("a hold is set, shown and lifted", () => {
781781 // She said "not yet"; dana is still asked.
782782 expect([how(f.alice), how(dana)]).toEqual([["acted"], [null]]);
783783 });
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 });
784834 });
785835
786836 describe("a hold, as it is kept", () => {
@@ -851,3 +901,77 @@ describe("a hold, as it is kept", () => {
851901 expect(lastRelease(app.db, pull.id)).toBe(released);
852902 });
853903 });
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 });

↑↓ navigate ↵ open ⌘↵ its app esc close

Keyboard shortcuts

Docs