Public: anyone can read this; the crew can change it.
issue_teams.ts says which named teams still hold a pull: pure, tested, not yet used by merge #10089
Check · Review · Land · Live done — Landed and live in production.
mason/forge:mason/10078-issue-teams-ts-says-which-named-teams-st → main 1 commit fork
2 files
changed +172
−0
src/core/issue_teams.ts +23
| @@ -6,6 +6,7 @@ | ||
| 6 | 6 | |
| 7 | 7 | import type { Database } from "bun:sqlite"; |
| 8 | 8 | import { err, ok, type Result } from "../kernel/result.ts"; |
| 9 | import { crewWithRole } from "./agents.ts"; | |
| 9 | 10 | import { getIssue } from "./issues.ts"; |
| 10 | 11 | import { closesFromBody } from "./pulls.ts"; |
| 11 | 12 | import { listReviews, type Review, type ReviewState } from "./reviews.ts"; |
| @@ -96,3 +97,25 @@ export function teamVerdicts( | ||
| 96 | 97 | /** Whether every named team has a current verdict on the head (vacuously true with no teams). */ |
| 97 | 98 | export const teamsAnswered = (verdicts: readonly TeamVerdict[]): boolean => |
| 98 | 99 | verdicts.every((v) => v.state !== undefined && !v.stale); |
| 100 | ||
| 101 | /** | |
| 102 | * The named teams that still hold the pull: each has a crew reviewer on the | |
| 103 | * pull's repository and no current approval on its head. A stale approval, | |
| 104 | * or a newer verdict that is not an approval, holds; a team with nobody to | |
| 105 | * ask does not, matching the review's lapse rule (docs/OPERATING.md). | |
| 106 | * Nothing gates on it yet (#9930). | |
| 107 | */ | |
| 108 | export function teamsHolding( | |
| 109 | db: Database, | |
| 110 | pull: { id: string; repoId: string; body: string; headOid: string }, | |
| 111 | ): TeamVerdict[] { | |
| 112 | const verdicts = teamVerdicts(db, pull); | |
| 113 | if (verdicts.length === 0) return []; | |
| 114 | const reviewers = new Set( | |
| 115 | (crewWithRole(db, [pull.repoId], "reviewer").get(pull.repoId) ?? []).map((a) => a.user.id), | |
| 116 | ); | |
| 117 | return verdicts.filter((v) => { | |
| 118 | const askable = getTeam(db, v.team)!.members.some((m) => reviewers.has(m)); | |
| 119 | return askable && !(v.state === "approved" && !v.stale); | |
| 120 | }); | |
| 121 | } |
test/issue-teams-hold.test.ts added +149
| @@ -0,0 +1,149 @@ | ||
| 1 | /** | |
| 2 | * Which named teams still hold a pull (#10078, the first piece of #9930's | |
| 3 | * merge gate): a team with a crew reviewer and no current approval on the | |
| 4 | * head holds; a team with nobody to ask does not. Nothing calls it yet. | |
| 5 | */ | |
| 6 | ||
| 7 | import { seatAs } from "./helpers/seat.ts"; | |
| 8 | import { afterAll, beforeAll, describe, expect, test } from "bun:test"; | |
| 9 | import { mkdtemp, rm } from "node:fs/promises"; | |
| 10 | import { tmpdir } from "node:os"; | |
| 11 | import { join } from "node:path"; | |
| 12 | import { addToCrew, createAgent } from "../src/actions/crew.ts"; | |
| 13 | import { nameTeams } from "../src/actions/issue_teams.ts"; | |
| 14 | import { openIssue } from "../src/actions/issues.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 { teamsHolding } from "../src/core/issue_teams.ts"; | |
| 19 | import type { Pull } from "../src/core/pulls.ts"; | |
| 20 | import type { Repo } from "../src/core/repos.ts"; | |
| 21 | import { submitReview } from "../src/core/reviews.ts"; | |
| 22 | import { saveTeam } from "../src/core/teams.ts"; | |
| 23 | import { createUser, getUserByName, type User } from "../src/core/users.ts"; | |
| 24 | import { quietLogs } from "./helpers/globals.ts"; | |
| 25 | ||
| 26 | quietLogs(); | |
| 27 | ||
| 28 | describe("teams holding a pull", () => { | |
| 29 | let dataDir = ""; | |
| 30 | let app: App; | |
| 31 | let alice: User; | |
| 32 | let repo: Repo; | |
| 33 | let n = 0; | |
| 34 | const id = (name: string) => getUserByName(app.db, name)!.id; | |
| 35 | const held = (pull: Pull) => teamsHolding(app.db, pull).map((v) => v.team); | |
| 36 | ||
| 37 | /** A pull whose body closes a fresh issue naming `teams`. */ | |
| 38 | const pullNaming = async (teams: string[]): Promise<Pull> => { | |
| 39 | const issue = openIssue(app, alice, repo, { title: "A chart" }); | |
| 40 | if (!issue.ok) throw new Error(issue.error); | |
| 41 | if (teams.length > 0) { | |
| 42 | const set = nameTeams(app, alice, repo, issue.value, teams); | |
| 43 | if (!set.ok) throw new Error(set.error); | |
| 44 | } | |
| 45 | const branch = `h${++n}`; | |
| 46 | const c = await commitToBranch(app, alice, repo, { | |
| 47 | branch, | |
| 48 | base: "main", | |
| 49 | message: branch, | |
| 50 | files: [{ path: `${branch}.md`, content: "x\n" }], | |
| 51 | }); | |
| 52 | if (!c.ok) throw new Error(JSON.stringify(c.error)); | |
| 53 | const o = await openPull(app, alice, repo, { | |
| 54 | title: branch, | |
| 55 | body: `Closes #${issue.value.number}`, | |
| 56 | baseBranch: "main", | |
| 57 | headBranch: branch, | |
| 58 | }); | |
| 59 | if (!o.ok) throw new Error(String(o.error)); | |
| 60 | return o.value; | |
| 61 | }; | |
| 62 | const approve = (pull: Pull, by: string, commitOid = pull.headOid) => | |
| 63 | submitReview(app.db, { issueId: pull.id, authorId: id(by), state: "approved", commitOid }); | |
| 64 | ||
| 65 | beforeAll(async () => { | |
| 66 | dataDir = await mkdtemp(join(tmpdir(), "forge-issue-teams-hold-")); | |
| 67 | app = createApp({ dataDir, port: 0 }); | |
| 68 | const made = await createUser(app.db, { | |
| 69 | origin: "seed", | |
| 70 | trusted: true, | |
| 71 | username: "alice", | |
| 72 | email: "[email protected]", | |
| 73 | password: "a-good-passphrase", | |
| 74 | adminIfFirst: true, | |
| 75 | }); | |
| 76 | if (!made.ok) throw new Error(made.error); | |
| 77 | alice = made.value; | |
| 78 | const r = await createRepo(app, alice, { name: "garden", visibility: "private" }); | |
| 79 | if (!r.ok) throw new Error(String(r.error)); | |
| 80 | repo = r.value; | |
| 81 | const seeded = await commitToBranch(app, alice, repo, { | |
| 82 | message: "seed", | |
| 83 | files: [{ path: "README.md", content: "# garden\n" }], | |
| 84 | }); | |
| 85 | if (!seeded.ok) throw new Error(JSON.stringify(seeded.error)); | |
| 86 | for (const name of ["quill", "pike", "moss"]) { | |
| 87 | const agent = await createAgent(app, alice, { username: name }); | |
| 88 | if (!agent.ok) throw new Error(String(agent.error)); | |
| 89 | addToCrew(app, alice, repo, name); | |
| 90 | } | |
| 91 | // quill and pike review here; moss is on the crew but not as a reviewer. | |
| 92 | seatAs(app.db, repo.id, id("quill"), "reviewer"); | |
| 93 | seatAs(app.db, repo.id, id("pike"), "reviewer"); | |
| 94 | saveTeam(app.db, "design", { charter: "", dial: 0, members: [id("quill")] }); | |
| 95 | saveTeam(app.db, "data-science", { charter: "", dial: 0, members: [id("pike")] }); | |
| 96 | saveTeam(app.db, "security", { charter: "", dial: 0, members: [id("moss")] }); | |
| 97 | }, 120_000); | |
| 98 | ||
| 99 | afterAll(async () => { | |
| 100 | for (let i = 0; i < 20; i++) await new Promise((done) => setImmediate(done)); | |
| 101 | await Bun.sleep(100); | |
| 102 | app?.close(); | |
| 103 | if (dataDir !== "") await rm(dataDir, { recursive: true, force: true, maxRetries: 5, retryDelay: 50 }); | |
| 104 | }, 30_000); | |
| 105 | ||
| 106 | test("an awaited team holds, with its name", async () => { | |
| 107 | const pull = await pullNaming(["design", "data-science"]); | |
| 108 | expect(teamsHolding(app.db, pull).map((v) => [v.team, v.name])).toEqual([ | |
| 109 | ["design", "Design"], | |
| 110 | ["data-science", "Data science"], | |
| 111 | ]); | |
| 112 | }); | |
| 113 | ||
| 114 | test("an approval on the head clears that team, and only that team", async () => { | |
| 115 | const pull = await pullNaming(["design", "data-science"]); | |
| 116 | approve(pull, "quill"); | |
| 117 | expect(held(pull)).toEqual(["data-science"]); | |
| 118 | approve(pull, "pike"); | |
| 119 | expect(held(pull)).toEqual([]); | |
| 120 | }); | |
| 121 | ||
| 122 | test("changes requested on the head holds", async () => { | |
| 123 | const pull = await pullNaming(["design"]); | |
| 124 | submitReview(app.db, { issueId: pull.id, authorId: id("quill"), state: "changes_requested", commitOid: pull.headOid }); | |
| 125 | expect(held(pull)).toEqual(["design"]); | |
| 126 | }); | |
| 127 | ||
| 128 | test("an approval on an older head holds", async () => { | |
| 129 | const pull = await pullNaming(["design"]); | |
| 130 | approve(pull, "quill"); | |
| 131 | expect(held(pull)).toEqual([]); | |
| 132 | // A push moves the head past the approval. | |
| 133 | const moved = { ...pull, headOid: "f".repeat(40) }; | |
| 134 | expect(held(moved)).toEqual(["design"]); | |
| 135 | expect(teamsHolding(app.db, moved)[0]!.stale).toBe(true); | |
| 136 | }); | |
| 137 | ||
| 138 | test("a team with no crew reviewer here does not hold", async () => { | |
| 139 | const pull = await pullNaming(["security"]); | |
| 140 | expect(held(pull)).toEqual([]); | |
| 141 | const both = await pullNaming(["security", "design"]); | |
| 142 | expect(held(both)).toEqual(["design"]); | |
| 143 | }); | |
| 144 | ||
| 145 | test("an issue that names no teams holds nothing", async () => { | |
| 146 | const pull = await pullNaming([]); | |
| 147 | expect(held(pull)).toEqual([]); | |
| 148 | }); | |
| 149 | }); |