1import * as GitHub from "@/GitHub";
2import { GitHubCredentials } from "@/GitHub/Credentials.ts";
3import { Octokit } from "@/GitHub/Octokit.ts";
4import * as Output from "@/Output";
5import * as Provider from "@/Provider";
6import { destroy } from "@/RemovalPolicy";
7import * as Test from "@/Test/Alchemy";
8import { expect } from "alchemy-test";
9import * as Effect from "effect/Effect";
10
11const owner = process.env.GITHUB_TEST_OWNER ?? "alchemy-run-test";
12if (owner !== "alchemy-run-test" && owner !== "alchemy-run-test-2") {
13 throw new Error(
14 `Refusing GitHub Ruleset tests for unauthorized owner: ${owner}`,
15 );
16}
17
18const { test } = Test.make({
19 providers: GitHub.providers({ baseUrl: "github.com" }),
20});
21
22const fixtureNames = [
23 "alchemy-pr-1570-ruleset-lifecycle",
24 "alchemy-pr-1570-ruleset-list",
25 "alchemy-pr-1570-ruleset-replace-a",
26 "alchemy-pr-1570-ruleset-replace-b",
27];
28
29// Retain public fixtures: the gh token lacks delete_repo, and private rulesets are plan-gated.
30const repository = (name: string, id = "Repo") =>
31 GitHub.Repository(id, {
32 owner,
33 name,
34 visibility: "public",
35 autoInit: true,
36 });
37
38const repoName = (repo: GitHub.Repository) =>
39 Output.map(repo.fullName, (fullName) => fullName.split("/")[1]!);
40
41const getRuleset = (repo: string, rulesetId: number) =>
42 Effect.gen(function* () {
43 const octokit = yield* Octokit;
44 return yield* Effect.tryPromise({
45 try: () =>
46 octokit.rest.repos.getRepoRuleset({
47 owner,
48 repo,
49 ruleset_id: rulesetId,
50 }),
51 catch: (error) => error as Error & { status?: number },
52 }).pipe(
53 Effect.map(({ data }) => data),
54 Effect.catchIf(
55 (error) => error.status === 404,
56 () => Effect.succeed(undefined),
57 ),
58 );
59 });
60
61const getRulesets = (repo: string) =>
62 Effect.gen(function* () {
63 const octokit = yield* Octokit;
64 return yield* Effect.tryPromise({
65 try: () =>
66 octokit.paginate(octokit.rest.repos.getRepoRulesets, {
67 owner,
68 repo,
69 includes_parents: false,
70 per_page: 100,
71 }),
72 catch: (error) => error as Error,
73 });
74 });
75
76test.provider(
77 "create, update, clear, and delete a ruleset",
78 (stack) =>
79 Effect.gen(function* () {
80 yield* stack.destroy();
81 const repo = fixtureNames[0]!;
82 const deploy = (
83 props: Omit<GitHub.RulesetProps, "owner" | "repository">,
84 ) =>
85 stack.deploy(
86 Effect.gen(function* () {
87 const fixture = yield* repository(repo);
88 return yield* GitHub.Ruleset("MainProtection", {
89 owner,
90 repository: repoName(fixture),
91 ...props,
92 }).pipe(destroy());
93 }),
94 );
95
96 const created = yield* deploy({
97 name: "main protection",
98 conditions: { include: ["refs/heads/main"] },
99 bypassActors: [{ actorType: "RepositoryRole", actorId: 5 }],
100 rules: {
101 nonFastForward: true,
102 deletion: true,
103 update: true,
104 pullRequest: {},
105 requiredStatusChecks: { checks: [{ context: "ci/test" }] },
106 },
107 });
108 expect(created.rulesetId).toBeGreaterThan(0);
109 expect(created.name).toBe("main protection");
110 const fetched = yield* getRuleset(repo, created.rulesetId);
111 expect(fetched?.name).toBe("main protection");
112 expect(fetched?.enforcement).toBe("active");
113 expect(fetched?.rules?.map((rule) => rule.type).sort()).toEqual([
114 "deletion",
115 "non_fast_forward",
116 "pull_request",
117 "required_status_checks",
118 "update",
119 ]);
120 expect(fetched?.bypass_actors).toEqual([
121 { actor_type: "RepositoryRole", actor_id: 5, bypass_mode: "always" },
122 ]);
123 expect(created.createdAt).toBe(fetched?.created_at);
124
125 const updated = yield* deploy({
126 name: "updated main protection",
127 enforcement: "disabled",
128 conditions: { include: ["refs/heads/main", "refs/heads/release/*"] },
129 rules: {
130 nonFastForward: true,
131 requiredLinearHistory: true,
132 update: false,
133 },
134 });
135 expect(updated.rulesetId).toBe(created.rulesetId);
136 expect(updated.nodeId).toBe(created.nodeId);
137 expect(updated.name).toBe("updated main protection");
138 const afterUpdate = yield* getRuleset(repo, updated.rulesetId);
139 expect(afterUpdate?.name).toBe("updated main protection");
140 expect(afterUpdate?.enforcement).toBe("disabled");
141 expect(afterUpdate?.conditions?.ref_name?.include).toEqual([
142 "refs/heads/main",
143 "refs/heads/release/*",
144 ]);
145 expect(afterUpdate?.rules?.map((rule) => rule.type).sort()).toEqual([
146 "non_fast_forward",
147 "required_linear_history",
148 ]);
149 expect(afterUpdate?.bypass_actors ?? []).toEqual([]);
150
151 const cleared = yield* deploy({ name: "all branches" });
152 expect(cleared.rulesetId).toBe(created.rulesetId);
153 const afterClear = yield* getRuleset(repo, cleared.rulesetId);
154 expect(afterClear?.conditions?.ref_name).toEqual({
155 include: ["~ALL"],
156 exclude: [],
157 });
158 expect(afterClear?.rules ?? []).toEqual([]);
159 expect(afterClear?.enforcement).toBe("active");
160
161 // Delete the ruleset while its repository is still managed, then release the fixture.
162 yield* stack.deploy(repository(repo));
163 expect(yield* getRuleset(repo, created.rulesetId)).toBeUndefined();
164 expect(yield* getRulesets(repo)).toEqual([]);
165 yield* stack.destroy();
166 }),
167 {
168 tags: [
169 "provider:github",
170 "provider:github:repository",
171 "provider:github:ruleset",
172 "live",
173 ],
174 },
175);
176
177test.provider(
178 "list rulesets across test repositories",
179 (stack) =>
180 Effect.gen(function* () {
181 yield* stack.destroy();
182 const repo = fixtureNames[1]!;
183 const created = yield* stack.deploy(
184 Effect.gen(function* () {
185 const fixture = yield* repository(repo);
186 return yield* GitHub.Ruleset("ListedRuleset", {
187 owner,
188 repository: repoName(fixture),
189 name: "listed tag protection",
190 target: "tag",
191 rules: { deletion: true },
192 }).pipe(destroy());
193 }),
194 );
195 const credentials = yield* yield* GitHubCredentials;
196 const provider = yield* Provider.findProvider(GitHub.Ruleset);
197 // list() enumerates /user/repos; confine it to this suite's fixture.
198 const listed = yield* provider.list().pipe(
199 Effect.provideService(
200 GitHubCredentials,
201 Effect.succeed({
202 ...credentials,
203 octokit: (override) => {
204 const octokit = credentials.octokit(override);
205 octokit.hook.before("request", (options) => {
206 const url = new URL(options.url, "https://api.github.com");
207 if (url.pathname === "/user/repos") {
208 url.pathname = `/orgs/${owner}/repos`;
209 options.url = url.toString();
210 }
211 if (
212 url.origin !== "https://api.github.com" ||
213 (url.pathname !== `/orgs/${owner}/repos` &&
214 url.pathname !== `/repos/${owner}/${repo}/rulesets`)
215 ) {
216 throw new Error(`Unsafe Ruleset list request: ${url}`);
217 }
218 });
219 octokit.hook.after("request", (response, options) => {
220 const url = new URL(options.url, "https://api.github.com");
221 if (url.pathname === `/orgs/${owner}/repos`) {
222 response.data = (
223 response.data as Array<{ name: string }>
224 ).filter((repository) => repository.name === repo);
225 }
226 });
227 return octokit;
228 },
229 }),
230 ),
231 );
232 const found = listed.find(
233 (ruleset) => ruleset.rulesetId === created.rulesetId,
234 );
235 expect(found?.name).toBe("listed tag protection");
236 expect(found?.nodeId).toBe(created.nodeId);
237 expect((yield* getRuleset(repo, created.rulesetId))?.target).toBe("tag");
238
239 yield* stack.deploy(repository(repo));
240 expect(yield* getRuleset(repo, created.rulesetId)).toBeUndefined();
241 expect(yield* getRulesets(repo)).toEqual([]);
242 yield* stack.destroy();
243 }),
244 {
245 tags: [
246 "provider:github",
247 "provider:github:repository",
248 "provider:github:ruleset",
249 "live",
250 ],
251 },
252);
253
254test.provider(
255 "changing the repository replaces the ruleset",
256 (stack) =>
257 Effect.gen(function* () {
258 yield* stack.destroy();
259 const repoA = fixtureNames[2]!;
260 const repoB = fixtureNames[3]!;
261 const fixtures = Effect.gen(function* () {
262 const a = yield* repository(repoA, "RepoA");
263 const b = yield* repository(repoB, "RepoB");
264 return { a, b };
265 });
266 const deploy = (destination: "a" | "b") =>
267 stack.deploy(
268 Effect.gen(function* () {
269 const repos = yield* fixtures;
270 return yield* GitHub.Ruleset("Protection", {
271 owner,
272 repository: repoName(repos[destination]),
273 name: "replacement protection",
274 rules: { nonFastForward: true },
275 }).pipe(destroy());
276 }),
277 );
278 const created = yield* deploy("a");
279 expect((yield* getRuleset(repoA, created.rulesetId))?.name).toBe(
280 "replacement protection",
281 );
282 const replaced = yield* deploy("b");
283 expect(replaced.rulesetId).not.toBe(created.rulesetId);
284 expect(yield* getRuleset(repoA, created.rulesetId)).toBeUndefined();
285 expect((yield* getRuleset(repoB, replaced.rulesetId))?.name).toBe(
286 "replacement protection",
287 );
288
289 yield* stack.deploy(fixtures);
290 expect(yield* getRuleset(repoB, replaced.rulesetId)).toBeUndefined();
291 expect(yield* getRulesets(repoA)).toEqual([]);
292 expect(yield* getRulesets(repoB)).toEqual([]);
293 yield* stack.destroy();
294 }),
295 {
296 tags: [
297 "provider:github",
298 "provider:github:repository",
299 "provider:github:ruleset",
300 "live",
301 ],
302 },
303);