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

Merged opened this

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

Merged

mason/forge:mason/10078-issue-teams-ts-says-which-named-teams-st → main 1 commit fork

  • Merged. mason/10078-issue-teams-ts-says-which-named-teams-st was merged into main as 9b12ece.

Review

Sign in to review this change.
2 files changed +172 −0
src/core/issue_teams.ts +23
@@ -6,6 +6,7 @@
66
77 import type { Database } from "bun:sqlite";
88 import { err, ok, type Result } from "../kernel/result.ts";
9 import { crewWithRole } from "./agents.ts";
910 import { getIssue } from "./issues.ts";
1011 import { closesFromBody } from "./pulls.ts";
1112 import { listReviews, type Review, type ReviewState } from "./reviews.ts";
@@ -96,3 +97,25 @@ export function teamVerdicts(
9697 /** Whether every named team has a current verdict on the head (vacuously true with no teams). */
9798 export const teamsAnswered = (verdicts: readonly TeamVerdict[]): boolean =>
9899 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 });

↑↓ navigate ↵ open ⌘↵ its app esc close

Keyboard shortcuts

Docs