1import * as GitHub from "@/GitHub";
2import { Octokit } from "@/GitHub/Octokit.ts";
3import * as Output from "@/Output";
4import * as Provider from "@/Provider";
5import { destroy } from "@/RemovalPolicy";
6import * as Test from "@/Test/Alchemy";
7import { expect } from "alchemy-test";
8import * as Effect from "effect/Effect";
9import { MinimumLogLevel } from "effect/References";
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 mutations outside the test organizations: ${owner}`,
15 );
16}
17
18const { test } = Test.make({
19 providers: GitHub.providers({ baseUrl: "github.com" }),
20});
21
22const logLevel = Effect.provideService(
23 MinimumLogLevel,
24 process.env.DEBUG ? "Debug" : "Info",
25);
26
27// Public fixtures avoid plan gates and are retained because gh lacks delete_repo.
28const repo = "alchemy-effect-pr-1514-branch-protection";
29const replacementRepo = "alchemy-effect-pr-1514-branch-protection-replacement";
30
31// Derive the repository name from the `fullName` output — referencing an
32// output (rather than the `repo` constant) makes the engine order dependent
33// resources after the repository exists.
34const repoName = (repository: GitHub.Repository) =>
35 Output.map(repository.fullName, (fullName) => fullName.split("/")[1]!);
36
37const getProtection = (branch: string, repository: string = repo) =>
38 Effect.gen(function* () {
39 const octokit = yield* Octokit;
40 return yield* Effect.tryPromise({
41 try: async () => {
42 try {
43 const { data } = await octokit.rest.repos.getBranchProtection({
44 owner,
45 repo: repository,
46 branch,
47 });
48 return data;
49 } catch (error: any) {
50 if (error.status === 404) return undefined;
51 throw error;
52 }
53 },
54 catch: (e) => e as Error,
55 });
56 });
57
58const hostRepository = GitHub.Repository("Repo", {
59 owner,
60 name: repo,
61 description: "alchemy-effect branch protection test",
62 visibility: "public",
63 autoInit: true,
64});
65
66test.provider(
67 "create, update, replace, and delete a branch protection rule",
68 (stack) =>
69 Effect.gen(function* () {
70 // Clean up any leftovers from a previous run before deploying.
71 yield* stack.destroy();
72
73 // Create — protect the default branch with reviews, status checks, and
74 // the strict toggles. `Repository` defaults to `retain`, so the host
75 // repo is created once and reused across runs (reconcile is
76 // idempotent); `autoInit` guarantees the default branch exists.
77 const created = yield* stack.deploy(
78 Effect.gen(function* () {
79 const repository = yield* hostRepository;
80
81 return yield* GitHub.BranchProtection("Protection", {
82 owner,
83 repository: repoName(repository),
84 branch: repository.defaultBranch,
85 requiredStatusChecks: {
86 strict: true,
87 contexts: ["ci"],
88 },
89 requiredPullRequestReviews: {
90 requiredApprovingReviewCount: 1,
91 dismissStaleReviews: true,
92 },
93 enforceAdmins: true,
94 requiredLinearHistory: true,
95 requiredConversationResolution: true,
96 }).pipe(destroy());
97 }),
98 );
99
100 expect(created.url).toContain(`/repos/${owner}/${repo}/branches/`);
101 expect(created.branch.length).toBeGreaterThan(0);
102 expect(created.enforceAdmins).toBe(true);
103 expect(created.requiredLinearHistory).toBe(true);
104 expect(created.requiredConversationResolution).toBe(true);
105 expect(created.requiredSignatures).toBe(false);
106 expect(created.allowForcePushes).toBe(false);
107
108 const fetched = yield* getProtection(created.branch);
109 expect(fetched?.required_status_checks?.strict).toBe(true);
110 expect(fetched?.required_status_checks?.contexts).toEqual(["ci"]);
111 expect(
112 fetched?.required_pull_request_reviews?.required_approving_review_count,
113 ).toEqual(1);
114 expect(
115 fetched?.required_pull_request_reviews?.dismiss_stale_reviews,
116 ).toBe(true);
117 expect(fetched?.enforce_admins?.enabled).toBe(true);
118 expect(fetched?.required_linear_history?.enabled).toBe(true);
119 expect(fetched?.required_conversation_resolution?.enabled).toBe(true);
120
121 const provider = yield* Provider.findProvider(GitHub.BranchProtection);
122 const recovered = yield* provider.read!({
123 id: "Protection",
124 fqn: "Protection",
125 instanceId: "pr-1514",
126 olds: { owner, repository: repo, branch: created.branch },
127 output: undefined,
128 });
129 expect(recovered?.url).toEqual(created.url);
130 expect(recovered?.enforceAdmins).toBe(true);
131
132 // Update — drop reviews and status checks, relax admin enforcement,
133 // allow force pushes, and require signed commits (the aspect that lives
134 // behind its own endpoint). Same logical ID → same rule URL (update in
135 // place, not replace).
136 const updated = yield* stack.deploy(
137 Effect.gen(function* () {
138 const repository = yield* hostRepository;
139
140 return yield* GitHub.BranchProtection("Protection", {
141 owner,
142 repository: repoName(repository),
143 branch: repository.defaultBranch,
144 allowForcePushes: true,
145 requiredSignatures: true,
146 requiredLinearHistory: true,
147 }).pipe(destroy());
148 }),
149 );
150
151 expect(updated.url).toEqual(created.url);
152 expect(updated.enforceAdmins).toBe(false);
153 expect(updated.allowForcePushes).toBe(true);
154 expect(updated.requiredSignatures).toBe(true);
155 expect(updated.requiredConversationResolution).toBe(false);
156
157 const afterUpdate = yield* getProtection(created.branch);
158 expect(afterUpdate?.required_status_checks).toBeUndefined();
159 expect(afterUpdate?.required_pull_request_reviews).toBeUndefined();
160 expect(afterUpdate?.enforce_admins?.enabled).toBe(false);
161 expect(afterUpdate?.allow_force_pushes?.enabled).toBe(true);
162 expect(afterUpdate?.required_signatures?.enabled).toBe(true);
163 expect(afterUpdate?.required_linear_history?.enabled).toBe(true);
164
165 // Re-deploying the same props is a no-op that still converges.
166 const unchanged = yield* stack.deploy(
167 Effect.gen(function* () {
168 const repository = yield* hostRepository;
169
170 return yield* GitHub.BranchProtection("Protection", {
171 owner,
172 repository: repoName(repository),
173 branch: repository.defaultBranch,
174 allowForcePushes: true,
175 requiredSignatures: true,
176 requiredLinearHistory: true,
177 }).pipe(destroy());
178 }),
179 );
180 expect(unchanged.url).toEqual(created.url);
181 expect(unchanged.requiredSignatures).toBe(true);
182
183 const reset = yield* stack.deploy(
184 Effect.gen(function* () {
185 const repository = yield* hostRepository;
186 return yield* GitHub.BranchProtection("Protection", {
187 owner,
188 repository: repoName(repository),
189 branch: repository.defaultBranch,
190 }).pipe(destroy());
191 }),
192 );
193 expect(reset.requiredSignatures).toBe(false);
194 expect(reset.allowForcePushes).toBe(false);
195 expect(reset.requiredLinearHistory).toBe(false);
196 const afterReset = yield* getProtection(created.branch);
197 expect(afterReset?.required_signatures?.enabled ?? false).toBe(false);
198 expect(afterReset?.allow_force_pushes?.enabled).toBe(false);
199 expect(afterReset?.required_linear_history?.enabled).toBe(false);
200
201 // Keep the old dependency deployed until its protection is replaced.
202 const replaced = yield* stack.deploy(
203 Effect.gen(function* () {
204 yield* hostRepository;
205 const repository = yield* GitHub.Repository("ReplacementRepo", {
206 owner,
207 name: replacementRepo,
208 description: "alchemy-effect PR 1514 replacement fixture",
209 visibility: "public",
210 autoInit: true,
211 });
212 return yield* GitHub.BranchProtection("Protection", {
213 owner,
214 repository: repoName(repository),
215 branch: repository.defaultBranch,
216 enforceAdmins: true,
217 }).pipe(destroy());
218 }),
219 );
220 expect(replaced.url).not.toEqual(created.url);
221 expect(replaced.url).toContain(
222 `/repos/${owner}/${replacementRepo}/branches/`,
223 );
224 expect(yield* getProtection(created.branch)).toBeUndefined();
225 expect(
226 (yield* getProtection(replaced.branch, replacementRepo))?.enforce_admins
227 ?.enabled,
228 ).toBe(true);
229
230 // Both rules must be gone independently of the retained repositories.
231 yield* stack.destroy();
232 expect(yield* getProtection(created.branch)).toBeUndefined();
233 expect(
234 yield* getProtection(replaced.branch, replacementRepo),
235 ).toBeUndefined();
236 }).pipe(logLevel),
237 {
238 tags: [
239 "provider:github",
240 "provider:github:branchprotection",
241 "provider:github:repository",
242 "live",
243 ],
244 timeout: 120_000,
245 },
246);