Public: anyone can read this; the crew can change it.
A fix the owner or steward writes lands through the queue as is: an in-repo pull is gated and landed, not closed for being off a fork #7575
Check · Review · Land · Live done — Landed and live in production.
torque/forge:torque/7572-a-fix-the-owner-or-steward-writes-lands → main 2 commits fork
10 files
changed +267
−6
docs/APPS.md +6
| @@ -308,6 +308,12 @@ around it. None is advisory. | ||
| 308 | 308 | create a repository (it owns nothing it was not given; it may fork to |
| 309 | 309 | contribute). A reviewer on the crew may still pass a verdict — request |
| 310 | 310 | changes, approve — since a verdict gates nothing an agent could do. |
| 311 | A person's pull from a branch of the repository itself — the owner's | |
| 312 | or a steward's fix — is a normal pull: its pushes move its head and run | |
| 313 | its check, review and gate again, and it lands through the queue. No | |
| 314 | builder is sent to it, since none can push that branch; changes asked | |
| 315 | for on it go back to its author, and the crew never parks it | |
| 316 | (`crew/authors_pull.ts`, #7572). | |
| 311 | 317 | 2. **A preview lives exactly as long as its pull is open.** Opened: deployed. |
| 312 | 318 | Pushed: redeployed at the new head. Closed or merged: stopped, and its |
| 313 | 319 | data deleted. There is no orphan state; the sweep on boot rebuilds the |
src/crew/authors_pull.ts added +22
| @@ -0,0 +1,22 @@ | ||
| 1 | /** | |
| 2 | * A person's pull from a branch of the repository itself (#7572): the | |
| 3 | * owner's or a steward's fix, opened by hand or with an admin token. It is | |
| 4 | * a normal pull — a push to its branch moves its head (`trackPullHead`) and | |
| 5 | * runs its check, review and gate again, and green it lands through the | |
| 6 | * queue — but no agent can push its branch: the crew works from forks. | |
| 7 | * | |
| 8 | * So the crew never sends a builder to it and never parks it. Changes asked | |
| 9 | * for on it go back to its author (`requested.ts`), and closing it is only | |
| 10 | * theirs: a closed pull no longer follows its branch, and every later push | |
| 11 | * would be lost to it. | |
| 12 | */ | |
| 13 | ||
| 14 | import type { Pull } from "../core/pulls.ts"; | |
| 15 | ||
| 16 | /** Whether the pull is a person's, from a branch of the repository it proposes to. */ | |
| 17 | export function authorsInRepoPull(pull: Pick<Pull, "authorIsAgent" | "crossRepo">): boolean { | |
| 18 | return !pull.authorIsAgent && !pull.crossRepo; | |
| 19 | } | |
| 20 | ||
| 21 | /** What the changes-requested pass says of one, once it is back with its author. */ | |
| 22 | export const BACK_WITH_AUTHOR = "back with its author, a person: the branch lives in the repository"; |
src/crew/autonomy.ts +6 −1
| @@ -13,6 +13,7 @@ | ||
| 13 | 13 | */ |
| 14 | 14 | |
| 15 | 15 | import { closedToday, closeStale } from "./stale_pulls.ts"; |
| 16 | import { authorsInRepoPull } from "./authors_pull.ts"; | |
| 16 | 17 | import type { Database } from "bun:sqlite"; |
| 17 | 18 | import type { App } from "../app.ts"; |
| 18 | 19 | import { ceilingReached, forgeCeiling } from "../flow/ceiling.ts"; |
| @@ -1082,7 +1083,10 @@ function answeredWithoutChange( | ||
| 1082 | 1083 | * planner's to split or drop on its next pass, and one parked twice is |
| 1083 | 1084 | * handed to the planner with every reason (`strikeNote`). A pull a person |
| 1084 | 1085 | * holds is not parked: closing it would hand its issue back to the crew, to |
| 1085 | * build the same change again beside the one that was told to wait. | |
| 1086 | * build the same change again beside the one that was told to wait. A | |
| 1087 | * person's pull from a branch of the repository itself is not parked | |
| 1088 | * either: closing it is its author's, and closed it would stop following | |
| 1089 | * the pushes that answer its review (#7572). | |
| 1086 | 1090 | */ |
| 1087 | 1091 | export function parkPull( |
| 1088 | 1092 | app: App, |
| @@ -1092,6 +1096,7 @@ export function parkPull( | ||
| 1092 | 1096 | ): boolean { |
| 1093 | 1097 | if (repo.autonomy !== "autonomous" || repo.autonomyBy === null) return false; |
| 1094 | 1098 | if (getHold(app.db, pull.id) !== undefined) return false; |
| 1099 | if (authorsInRepoPull(pull)) return false; | |
| 1095 | 1100 | const by = getUserById(app.db, repo.autonomyBy); |
| 1096 | 1101 | if (!by) return false; |
| 1097 | 1102 | const issue = getIssueById(app.db, pull.id); |
src/crew/requested.ts +19
| @@ -32,6 +32,7 @@ import { reviewSummary } from "../core/reviews.ts"; | ||
| 32 | 32 | import { activeRunFor } from "../core/runs.ts"; |
| 33 | 33 | import { getUserById } from "../core/users.ts"; |
| 34 | 34 | import { createLog } from "../kernel/log.ts"; |
| 35 | import { authorsInRepoPull, BACK_WITH_AUTHOR } from "./authors_pull.ts"; | |
| 35 | 36 | import { queueAgentRun } from "./dispatch.ts"; |
| 36 | 37 | import { flowOnlyHolds } from "./flow_only.ts"; |
| 37 | 38 | import { HANDOVER_NOTE, handoverOf } from "./guard.ts"; |
| @@ -207,6 +208,8 @@ function followUp( | ||
| 207 | 208 | // Past the rounds the verdict is advice, and the pull lands as it is. |
| 208 | 209 | if (reviewSummary(app.db, pull.id, pull.headOid).changesRequested === 0) |
| 209 | 210 | return notMoved(requested, "past the review rounds; the verdict is advice"); |
| 211 | // No builder can push a branch of the repository itself (#7572). | |
| 212 | if (authorsInRepoPull(pull)) return handBack(app, repo, requested, issue); | |
| 210 | 213 | if (activeRunFor(app.db, "agent", pull.id) !== undefined) |
| 211 | 214 | return notMoved(requested, "a run is in flight"); |
| 212 | 215 | // A push ends the verdict's turn (the reviewer reads the new head), so every |
| @@ -247,6 +250,22 @@ function followUp( | ||
| 247 | 250 | }; |
| 248 | 251 | } |
| 249 | 252 | |
| 253 | /** | |
| 254 | * A person's in-repo pull with changes asked for is theirs to answer: it is | |
| 255 | * assigned back to its author, once, and no builder is sent — its run could | |
| 256 | * only fail, and the failure would park the pull. | |
| 257 | */ | |
| 258 | function handBack(app: App, repo: Repo, requested: RequestedPull, issue: Issue): RequestedMove { | |
| 259 | if (issue.assigneeId !== issue.authorId) { | |
| 260 | const author = getUserById(app.db, issue.authorId); | |
| 261 | const by = (repo.autonomyBy === null ? undefined : getUserById(app.db, repo.autonomyBy)) ?? author; | |
| 262 | if (author === undefined || by === undefined) return notMoved(requested, "nobody to hand it back to"); | |
| 263 | const done = assignIssue(app, by, repo, issue, author); | |
| 264 | if (!done.ok) return notMoved(requested, `could not hand it back to ${author.username}: ${done.error}`); | |
| 265 | } | |
| 266 | return notMoved(requested, BACK_WITH_AUTHOR); | |
| 267 | } | |
| 268 | ||
| 250 | 269 | /** How many runs other than a review started on the pull after the verdict. */ |
| 251 | 270 | function startedSince(app: App, pullId: string, at: string): number { |
| 252 | 271 | return app.db |
src/crew/runner.ts +5 −1
| @@ -33,6 +33,7 @@ import { Stopwatch } from "../apps/timing.ts"; | ||
| 33 | 33 | import { previewEnv } from "../apps/data.ts"; |
| 34 | 34 | import { appsRuntime } from "../apps/runtime.ts"; |
| 35 | 35 | import { ownApp, ownAppUrls } from "./app_hosts.ts"; |
| 36 | import { authorsInRepoPull } from "./authors_pull.ts"; | |
| 36 | 37 | import { crewRole, getAgent, type Agent } from "../core/agents.ts"; |
| 37 | 38 | import { getAppByRepo, type AppRecord } from "../core/apps.ts"; |
| 38 | 39 | import { |
| @@ -887,10 +888,13 @@ async function work( | ||
| 887 | 888 | // brief, its clone is its own fork at a scratch branch, and it pushes |
| 888 | 889 | // nothing. The pull's branch is not its to move, and it never tries. |
| 889 | 890 | const reviewing = reason === "review"; |
| 891 | // A person's such pull is theirs to push, and stays open (#7572). | |
| 890 | 892 | if (pull !== undefined && !reviewing && pull.headRepoId === repo.id) |
| 891 | 893 | return { |
| 892 | 894 | state: "failed", |
| 893 | summary: `pull #${pull.number}'s branch lives in the repository itself; the crew works from forks now — close it and assign the issue again`, | |
| 895 | summary: authorsInRepoPull(pull) | |
| 896 | ? `pull #${pull.number}'s branch lives in the repository itself and only its author, a person, can push it; the crew leaves it with them` | |
| 897 | : `pull #${pull.number}'s branch lives in the repository itself; the crew works from forks now — close it and assign the issue again`, | |
| 894 | 898 | }; |
| 895 | 899 | // A pull whose branch lives in someone else's fork is not the agent's to |
| 896 | 900 | // push to, and the run no longer fails for it: like a merged pull's |
test/authors-pull.test.ts added +203
| @@ -0,0 +1,203 @@ | ||
| 1 | /** | |
| 2 | * A fix the owner or a steward writes lands through the queue as it is | |
| 3 | * (#7572): a person's pull from a branch of the repository itself is a | |
| 4 | * normal pull. A push to its branch moves its head and starts its check; | |
| 5 | * changes asked for on it go back to its author, never to a builder who | |
| 6 | * cannot push that branch; and the crew never parks it, so it never stops | |
| 7 | * following its branch. | |
| 8 | */ | |
| 9 | ||
| 10 | import { afterAll, beforeAll, describe, expect, test } from "bun:test"; | |
| 11 | import { mkdtemp, rm } from "node:fs/promises"; | |
| 12 | import { tmpdir } from "node:os"; | |
| 13 | import { join } from "node:path"; | |
| 14 | import { addToCrew, createAgent } from "../src/actions/crew.ts"; | |
| 15 | import { openPull } from "../src/actions/pulls.ts"; | |
| 16 | import { commitToBranch, createRepo } from "../src/actions/repos.ts"; | |
| 17 | import { createApp, type App } from "../src/app.ts"; | |
| 18 | import { startChecks, type ChecksHandle } from "../src/checks/service.ts"; | |
| 19 | import { setCrewRole } from "../src/core/agents.ts"; | |
| 20 | import { getIssueById } from "../src/core/issues.ts"; | |
| 21 | import { getPull, type Pull } from "../src/core/pulls.ts"; | |
| 22 | import { getRepoById, type Repo } from "../src/core/repos.ts"; | |
| 23 | import { submitReview } from "../src/core/reviews.ts"; | |
| 24 | import { listRuns } from "../src/core/runs.ts"; | |
| 25 | import { createUser, getUserByName, type User } from "../src/core/users.ts"; | |
| 26 | import { authorsInRepoPull, BACK_WITH_AUTHOR } from "../src/crew/authors_pull.ts"; | |
| 27 | import { parkPull, settleAgentRun } from "../src/crew/autonomy.ts"; | |
| 28 | import { finishApproved } from "../src/crew/finishing.ts"; | |
| 29 | import { moveRequested } from "../src/crew/requested.ts"; | |
| 30 | import { serve, type Servers } from "../src/http/server.ts"; | |
| 31 | import { quietLogs } from "./helpers/globals.ts"; | |
| 32 | ||
| 33 | quietLogs(); | |
| 34 | ||
| 35 | describe("a person's in-repo pull is a normal pull the crew leaves to its author", () => { | |
| 36 | let dataDir = ""; | |
| 37 | let app: App; | |
| 38 | let servers: Servers; | |
| 39 | let checks: ChecksHandle; | |
| 40 | let alice: User; | |
| 41 | let builder: User; | |
| 42 | let reviewer: User; | |
| 43 | let repo: Repo; | |
| 44 | let n = 0; | |
| 45 | ||
| 46 | const fresh = () => getRepoById(app.db, repo.id)!; | |
| 47 | const reload = (pull: Pull) => getPull(app.db, repo.id, pull.number)!; | |
| 48 | const push = async (by: User, branch: string, base?: string): Promise<string> => { | |
| 49 | const c = await commitToBranch(app, by, repo, { | |
| 50 | branch, | |
| 51 | ...(base === undefined ? {} : { base }), | |
| 52 | message: `${branch} ${++n}`, | |
| 53 | files: [{ path: `${branch.replace("/", "-")}.md`, content: `${n}\n` }], | |
| 54 | }); | |
| 55 | if (!c.ok) throw new Error(JSON.stringify(c.error)); | |
| 56 | return c.value.commitOid; | |
| 57 | }; | |
| 58 | /** A pull `by` opens on a branch of the repository, pushed by alice: an agent writes only its fork. */ | |
| 59 | const pullBy = async (by: User, branch: string): Promise<Pull> => { | |
| 60 | await push(alice, branch, "main"); | |
| 61 | const o = await openPull(app, by, repo, { title: branch, body: "", baseBranch: "main", headBranch: branch }); | |
| 62 | if (!o.ok) throw new Error(String(o.error)); | |
| 63 | return o.value; | |
| 64 | }; | |
| 65 | const changesRequested = (pull: Pull) => { | |
| 66 | const r = submitReview(app.db, { | |
| 67 | issueId: pull.id, | |
| 68 | authorId: reviewer.id, | |
| 69 | state: "changes_requested", | |
| 70 | body: "Fix the one thing.", | |
| 71 | commitOid: pull.headOid, | |
| 72 | }); | |
| 73 | if (!r.ok) throw new Error(r.error); | |
| 74 | }; | |
| 75 | const agentRuns = (pull: Pull) => | |
| 76 | listRuns(app.db, repo.id, { issueId: pull.id }).filter((r) => r.kind === "agent" && r.reason !== "review"); | |
| 77 | ||
| 78 | beforeAll(async () => { | |
| 79 | dataDir = await mkdtemp(join(tmpdir(), "forge-authors-pull-")); | |
| 80 | app = createApp({ dataDir, port: 0 }); | |
| 81 | servers = await serve(app); | |
| 82 | checks = startChecks(app, { forgeUrl: servers.url, concurrency: 1 }); | |
| 83 | const made = await createUser(app.db, { | |
| 84 | origin: "seed", | |
| 85 | trusted: true, | |
| 86 | username: "alice", | |
| 87 | email: "[email protected]", | |
| 88 | password: "a-good-passphrase", | |
| 89 | adminIfFirst: true, | |
| 90 | }); | |
| 91 | if (!made.ok) throw new Error(made.error); | |
| 92 | alice = made.value; | |
| 93 | const r = await createRepo(app, alice, { name: "garden", visibility: "private" }); | |
| 94 | if (!r.ok) throw new Error(String(r.error)); | |
| 95 | repo = r.value; | |
| 96 | const gate = await commitToBranch(app, alice, repo, { | |
| 97 | message: "The check", | |
| 98 | files: [{ path: "forge.json", content: `{"check": {"run": "true", "install": ""}}\n` }], | |
| 99 | }); | |
| 100 | if (!gate.ok) throw new Error(JSON.stringify(gate.error)); | |
| 101 | for (const name of ["mason", "quill"]) { | |
| 102 | const agent = await createAgent(app, alice, { username: name }); | |
| 103 | if (!agent.ok) throw new Error(String(agent.error)); | |
| 104 | expect(addToCrew(app, alice, repo, name).ok).toBe(true); | |
| 105 | } | |
| 106 | builder = getUserByName(app.db, "mason")!; | |
| 107 | reviewer = getUserByName(app.db, "quill")!; | |
| 108 | setCrewRole(app.db, repo.id, reviewer.id, "reviewer"); | |
| 109 | app.db | |
| 110 | .query("UPDATE repo SET autonomy = 'autonomous', autonomy_by = ?2, autonomy_slots = 3 WHERE id = ?1") | |
| 111 | .run(repo.id, alice.id); | |
| 112 | }, 30_000); | |
| 113 | ||
| 114 | afterAll(async () => { | |
| 115 | await checks.stop(); | |
| 116 | servers.stop(); | |
| 117 | app.close(); | |
| 118 | await rm(dataDir, { recursive: true, force: true }); | |
| 119 | }); | |
| 120 | ||
| 121 | test("only a person's pull from a branch of the repository itself is one", () => { | |
| 122 | expect(authorsInRepoPull({ authorIsAgent: false, crossRepo: false })).toBe(true); | |
| 123 | expect(authorsInRepoPull({ authorIsAgent: true, crossRepo: false })).toBe(false); | |
| 124 | expect(authorsInRepoPull({ authorIsAgent: false, crossRepo: true })).toBe(false); | |
| 125 | }); | |
| 126 | ||
| 127 | test("a push to its branch moves the pull's head and starts a check of the new head", async () => { | |
| 128 | const pull = await pullBy(alice, "steward/one"); | |
| 129 | const tip = await push(alice, pull.headBranch); | |
| 130 | expect(reload(pull).headOid).toBe(tip); | |
| 131 | const deadline = Date.now() + 10_000; | |
| 132 | let checked = false; | |
| 133 | while (!checked && Date.now() < deadline) { | |
| 134 | checked = listRuns(app.db, repo.id, { kind: "check", limit: 100 }).some( | |
| 135 | (r) => r.issueId === pull.id && r.commitOid === tip && r.env === `pull-${pull.number}`, | |
| 136 | ); | |
| 137 | if (!checked) await Bun.sleep(50); | |
| 138 | } | |
| 139 | expect(checked).toBe(true); | |
| 140 | }); | |
| 141 | ||
| 142 | test("changes asked for on it go back to its author: no builder is sent, even one already on it", async () => { | |
| 143 | const pull = await pullBy(alice, "steward/two"); | |
| 144 | // A builder's name on it, as #7436 had. | |
| 145 | app.db.query("UPDATE issue SET assignee_id = ?2 WHERE id = ?1").run(pull.id, builder.id); | |
| 146 | changesRequested(pull); | |
| 147 | const moves = await moveRequested(app, fresh(), 3); | |
| 148 | expect(moves.find((m) => m.pull === pull.number)).toEqual({ | |
| 149 | pull: pull.number, | |
| 150 | did: "not moved", | |
| 151 | why: BACK_WITH_AUTHOR, | |
| 152 | }); | |
| 153 | expect(getIssueById(app.db, pull.id)!.assigneeId).toBe(alice.id); | |
| 154 | expect(agentRuns(pull)).toHaveLength(0); | |
| 155 | // Said again on a later tick, and still nothing queued. | |
| 156 | await moveRequested(app, fresh(), 3); | |
| 157 | expect(agentRuns(pull)).toHaveLength(0); | |
| 158 | }); | |
| 159 | ||
| 160 | test("a builder's run on it that pushed nothing does not park it, and its next push still moves its head", async () => { | |
| 161 | const pull = await pullBy(alice, "steward/three"); | |
| 162 | changesRequested(pull); | |
| 163 | const parked = settleAgentRun(app, fresh(), { | |
| 164 | issueId: pull.id, | |
| 165 | state: "failed", | |
| 166 | summary: `pull #${pull.number}'s branch lives in the repository itself`, | |
| 167 | reason: "feedback", | |
| 168 | actorId: builder.id, | |
| 169 | }); | |
| 170 | expect(parked).toBe(false); | |
| 171 | expect(parkPull(app, fresh(), pull, "the check stayed red")).toBe(false); | |
| 172 | expect(getIssueById(app.db, pull.id)!.state).toBe("open"); | |
| 173 | const tip = await push(alice, pull.headBranch); | |
| 174 | expect(reload(pull).headOid).toBe(tip); | |
| 175 | }); | |
| 176 | ||
| 177 | test("a builder's own in-repo pull is still parked as before", async () => { | |
| 178 | const pull = await pullBy(builder, "b-legacy"); | |
| 179 | expect(parkPull(app, fresh(), pull, "the check stayed red")).toBe(true); | |
| 180 | expect(getIssueById(app.db, pull.id)!.state).toBe("closed"); | |
| 181 | }); | |
| 182 | ||
| 183 | test("the finishing pass leaves it alone: not a candidate, and still open", async () => { | |
| 184 | const pull = await pullBy(alice, "steward/four"); | |
| 185 | const r = submitReview(app.db, { | |
| 186 | issueId: pull.id, | |
| 187 | authorId: reviewer.id, | |
| 188 | state: "approved", | |
| 189 | body: "", | |
| 190 | commitOid: pull.headOid, | |
| 191 | }); | |
| 192 | if (!r.ok) throw new Error(r.error); | |
| 193 | // Main moves under it on the same file: approved and conflicted. | |
| 194 | await commitToBranch(app, alice, repo, { | |
| 195 | message: "main moves", | |
| 196 | files: [{ path: "steward-four.md", content: "main\n" }], | |
| 197 | }); | |
| 198 | const pass = await finishApproved(app, fresh(), 3); | |
| 199 | expect(pass.moves.find((m) => m.pull === pull.number)).toBeUndefined(); | |
| 200 | expect(agentRuns(pull)).toHaveLength(0); | |
| 201 | expect(getIssueById(app.db, pull.id)!.state).toBe("open"); | |
| 202 | }); | |
| 203 | }); |
test/checks-disk-red.test.ts +1 −1
| @@ -54,7 +54,7 @@ describe("a red on a full check machine", () => { | ||
| 54 | 54 | files: [{ path: `${branch}.md`, content: "x\n" }], |
| 55 | 55 | }); |
| 56 | 56 | if (!c.ok) throw new Error(JSON.stringify(c.error)); |
| 57 | const o = await openPull(app, alice, repo, { title: branch, baseBranch: "main", headBranch: branch }); | |
| 57 | const o = await openPull(app, bot, repo, { title: branch, baseBranch: "main", headBranch: branch }); | |
| 58 | 58 | if (!o.ok) throw new Error(String(o.error)); |
| 59 | 59 | app.db.run("UPDATE issue SET assignee_id = ?1 WHERE id = ?2", [bot.id, o.value.id]); |
| 60 | 60 | for (let i = 0; i < CHECK_FAILED_LIMIT; i++) { |
test/crew-failed-while-building.test.ts +1 −1
| @@ -43,7 +43,7 @@ describe("a failure during the builder's run", () => { | ||
| 43 | 43 | files: [{ path: `${branch}.md`, content: "x\n" }], |
| 44 | 44 | }); |
| 45 | 45 | if (!c.ok) throw new Error(JSON.stringify(c.error)); |
| 46 | const o = await openPull(app, alice, repo, { title: branch, baseBranch: "main", headBranch: branch, ...(body === undefined ? {} : { body }) }); | |
| 46 | const o = await openPull(app, bot, repo, { title: branch, baseBranch: "main", headBranch: branch, ...(body === undefined ? {} : { body }) }); | |
| 47 | 47 | if (!o.ok) throw new Error(String(o.error)); |
| 48 | 48 | app.db.run("UPDATE issue SET assignee_id = ?1 WHERE id = ?2", [bot.id, o.value.id]); |
| 49 | 49 | for (let i = 0; i < CHECK_FAILED_LIMIT; i++) { |
test/holds.test.ts +3 −1
| @@ -404,7 +404,9 @@ describe("a held pull and the rules", () => { | ||
| 404 | 404 | f.app.db |
| 405 | 405 | .query("UPDATE issue SET author_id = ?2 WHERE id = ?1") |
| 406 | 406 | .run(pull.id, author.id); |
| 407 | return f.passCheck(pull, minutesAgo); | |
| 407 | f.passCheck(pull, minutesAgo); | |
| 408 | // Read again: the author decides whose pull it is (#7572). | |
| 409 | return getPull(f.app.db, f.repo.id, pull.number)!; | |
| 408 | 410 | }; |
| 409 | 411 | const approve = (pull: Pull, by: User) => |
| 410 | 412 | submitReview(f.app.db, { |
new above
offline