1import * as Effect from "effect/Effect";
2import { isResolved } from "../Diff.ts";
3import * as Provider from "../Provider.ts";
4import { Resource } from "../Resource.ts";
5import { gitHubBaseUrlChanged, Octokit, octokitFor } from "./Octokit.ts";
6import type * as GitHub from "./Providers.ts";
7
8export interface BranchProtectionProps {
9 /**
10 * Repository owner (user or organization).
11 */
12 owner: string;
13
14 /**
15 * Repository name.
16 */
17 repository: string;
18
19 /**
20 * Name of the branch to protect (e.g. `main`). The branch is the rule's
21 * identity — changing it replaces the protection. The branch must exist.
22 */
23 branch: string;
24
25 /**
26 * Require status checks to pass before merging. Omit to disable.
27 */
28 requiredStatusChecks?: {
29 /**
30 * Require branches to be up to date with the base branch before merging.
31 * @default false
32 */
33 strict?: boolean;
34
35 /**
36 * Status check contexts that must pass. Prefer `checks` for control over
37 * which GitHub App must report the check.
38 */
39 contexts?: string[];
40
41 /**
42 * Status checks that must pass, optionally pinned to the GitHub App that
43 * must report them.
44 */
45 checks?: {
46 /**
47 * Name of the required check.
48 */
49 context: string;
50
51 /**
52 * ID of the GitHub App that must provide this check. Omit to accept the
53 * app that most recently reported it; pass `-1` to accept any app.
54 */
55 appId?: number;
56 }[];
57 };
58
59 /**
60 * Require pull request reviews before merging. Omit to disable.
61 */
62 requiredPullRequestReviews?: {
63 /**
64 * Dismiss approving reviews when a new commit is pushed.
65 * @default false
66 */
67 dismissStaleReviews?: boolean;
68
69 /**
70 * Block merging until code owners have reviewed.
71 * @default false
72 */
73 requireCodeOwnerReviews?: boolean;
74
75 /**
76 * Number of approving reviews required (0–6).
77 * @default 0
78 */
79 requiredApprovingReviewCount?: number;
80
81 /**
82 * Require the most recent push to be approved by someone other than the
83 * pusher.
84 * @default false
85 */
86 requireLastPushApproval?: boolean;
87
88 /**
89 * Who may dismiss pull request reviews. Only available on
90 * organization-owned repositories; omit for personal repositories.
91 */
92 dismissalRestrictions?: {
93 /**
94 * User logins with dismissal access.
95 */
96 users?: string[];
97
98 /**
99 * Team slugs with dismissal access.
100 */
101 teams?: string[];
102
103 /**
104 * App slugs with dismissal access.
105 */
106 apps?: string[];
107 };
108
109 /**
110 * Who may bypass pull request requirements.
111 */
112 bypassPullRequestAllowances?: {
113 /**
114 * User logins allowed to bypass.
115 */
116 users?: string[];
117
118 /**
119 * Team slugs allowed to bypass.
120 */
121 teams?: string[];
122
123 /**
124 * App slugs allowed to bypass.
125 */
126 apps?: string[];
127 };
128 };
129
130 /**
131 * Restrict who can push to the branch. Only available on
132 * organization-owned repositories. Omit to disable.
133 */
134 restrictions?: {
135 /**
136 * User logins with push access.
137 */
138 users?: string[];
139
140 /**
141 * Team slugs with push access.
142 */
143 teams?: string[];
144
145 /**
146 * App slugs with push access.
147 */
148 apps?: string[];
149 };
150
151 /**
152 * Enforce all configured restrictions for repository administrators.
153 * @default false
154 */
155 enforceAdmins?: boolean;
156
157 /**
158 * Require signed commits on the branch.
159 * @default false
160 */
161 requiredSignatures?: boolean;
162
163 /**
164 * Require a linear commit history (no merge commits). The repository must
165 * allow squash or rebase merging.
166 * @default false
167 */
168 requiredLinearHistory?: boolean;
169
170 /**
171 * Permit force pushes by anyone with write access.
172 * @default false
173 */
174 allowForcePushes?: boolean;
175
176 /**
177 * Permit deletion of the branch by anyone with write access.
178 * @default false
179 */
180 allowDeletions?: boolean;
181
182 /**
183 * When `restrictions` is set, also block pushes that create new branches
184 * unless the pusher is allowed by the restrictions.
185 * @default false
186 */
187 blockCreations?: boolean;
188
189 /**
190 * Require all pull request conversations to be resolved before merging.
191 * @default false
192 */
193 requiredConversationResolution?: boolean;
194
195 /**
196 * Make the branch read-only.
197 * @default false
198 */
199 lockBranch?: boolean;
200
201 /**
202 * Allow fork syncing (pulling upstream changes) while the branch is locked.
203 * @default false
204 */
205 allowForkSyncing?: boolean;
206
207 /**
208 * Override the GitHub host or API base URL for this resource only (e.g.
209 * `github.example.com` for GitHub Enterprise). Falls back to
210 * `GitHub.providers({ baseUrl })`, then to the host resolved by the auth
211 * provider. Changing it replaces the resource — the same branch on a
212 * different GitHub instance is a different physical resource.
213 */
214 baseUrl?: string;
215}
216
217export interface BranchProtection extends Resource<
218 "GitHub.BranchProtection",
219 BranchProtectionProps,
220 {
221 /**
222 * API URL of the branch protection rule.
223 */
224 url: string;
225
226 /**
227 * The protected branch name.
228 */
229 branch: string;
230
231 /**
232 * Whether restrictions are enforced for administrators.
233 */
234 enforceAdmins: boolean;
235
236 /**
237 * Whether signed commits are required.
238 */
239 requiredSignatures: boolean;
240
241 /**
242 * Whether a linear history is required.
243 */
244 requiredLinearHistory: boolean;
245
246 /**
247 * Whether force pushes are allowed.
248 */
249 allowForcePushes: boolean;
250
251 /**
252 * Whether branch deletion is allowed.
253 */
254 allowDeletions: boolean;
255
256 /**
257 * Whether conversation resolution is required before merging.
258 */
259 requiredConversationResolution: boolean;
260
261 /**
262 * Whether the branch is locked (read-only).
263 */
264 lockBranch: boolean;
265 },
266 never,
267 GitHub.Providers
268> {}
269
270/**
271 * A GitHub branch protection rule.
272 *
273 * `BranchProtection` manages the classic branch protection settings on a
274 * single branch: required status checks, required pull request reviews,
275 * push restrictions, admin enforcement, signed commits, linear history, and
276 * the force-push / deletion / lock toggles. Pair it with `GitHub.Repository`
277 * to protect the default branch of a repository provisioned in the same
278 * stack.
279 *
280 * Branch protection is available on public repositories on every plan;
281 * private repositories require GitHub Pro, Team, or Enterprise. Push
282 * restrictions and dismissal restrictions are only available on
283 * organization-owned repositories.
284 *
285 * Authentication is resolved via the `GitHubCredentials` service supplied
286 * by `GitHub.providers()` (env, stored PAT, `gh` CLI, or OAuth). The token
287 * needs `repo` scope and admin access to the repository.
288 *
289 * ### Protecting a Branch
290 * **Example:** Require Pull Request Reviews
291 * ```typescript
292 * yield* GitHub.BranchProtection("main", {
293 * owner: "my-org",
294 * repository: "my-repo",
295 * branch: "main",
296 * requiredPullRequestReviews: {
297 * requiredApprovingReviewCount: 1,
298 * dismissStaleReviews: true,
299 * },
300 * });
301 * ```
302 *
303 * **Example:** Require Status Checks and a Linear History
304 * ```typescript
305 * yield* GitHub.BranchProtection("main", {
306 * owner: "my-org",
307 * repository: "my-repo",
308 * branch: "main",
309 * requiredStatusChecks: {
310 * strict: true,
311 * checks: [{ context: "ci" }],
312 * },
313 * requiredLinearHistory: true,
314 * requiredConversationResolution: true,
315 * enforceAdmins: true,
316 * });
317 * ```
318 *
319 * ### Protecting a Repository's Default Branch
320 * **Example:** Protect the Default Branch of a New Repository
321 * ```typescript
322 * import * as Output from "alchemy/Output";
323 *
324 * const repo = yield* GitHub.Repository("repo", {
325 * owner: "my-org",
326 * name: "my-repo",
327 * autoInit: true,
328 * });
329 *
330 * yield* GitHub.BranchProtection("main", {
331 * owner: "my-org",
332 * repository: Output.map(repo.fullName, (fullName) => fullName.split("/")[1]!),
333 * branch: repo.defaultBranch,
334 * requiredPullRequestReviews: { requiredApprovingReviewCount: 1 },
335 * allowForcePushes: false,
336 * allowDeletions: false,
337 * });
338 * ```
339 *
340 * ### Restricting Pushes
341 * **Example:** Only a Team May Push
342 * ```typescript
343 * yield* GitHub.BranchProtection("release", {
344 * owner: "my-org",
345 * repository: "my-repo",
346 * branch: "release",
347 * restrictions: { teams: ["release-managers"] },
348 * blockCreations: true,
349 * requiredSignatures: true,
350 * });
351 * ```
352 *
353 * @resource
354 * @product Repository
355 */
356export const BranchProtection = Resource<BranchProtection>(
357 "GitHub.BranchProtection",
358);
359
360/**
361 * The `GET .../protection` payload. Both the GET and PUT responses share this
362 * shape for the fields we surface; every nested block is optional because
363 * GitHub omits disabled aspects entirely.
364 */
365interface ProtectionPayload {
366 url?: string;
367 enforce_admins?: { enabled?: boolean };
368 required_signatures?: { enabled?: boolean };
369 required_linear_history?: { enabled?: boolean };
370 allow_force_pushes?: { enabled?: boolean };
371 allow_deletions?: { enabled?: boolean };
372 required_conversation_resolution?: { enabled?: boolean };
373 lock_branch?: { enabled?: boolean };
374}
375
376const attrsOf = (
377 branch: string,
378 protection: ProtectionPayload,
379): BranchProtection["Attributes"] => ({
380 url: protection.url ?? "",
381 branch,
382 enforceAdmins: protection.enforce_admins?.enabled ?? false,
383 requiredSignatures: protection.required_signatures?.enabled ?? false,
384 requiredLinearHistory: protection.required_linear_history?.enabled ?? false,
385 allowForcePushes: protection.allow_force_pushes?.enabled ?? false,
386 allowDeletions: protection.allow_deletions?.enabled ?? false,
387 requiredConversationResolution:
388 protection.required_conversation_resolution?.enabled ?? false,
389 lockBranch: protection.lock_branch?.enabled ?? false,
390});
391
392export const BranchProtectionProvider = () =>
393 Provider.succeed(BranchProtection, {
394 stables: ["url", "branch"],
395
396 // {owner, repository, branch, host} is the rule's path identity — GitHub
397 // has no rename, so changing any of them replaces the resource.
398 diff: Effect.fn(function* ({ news, olds }) {
399 if (olds === undefined) return;
400 // Unresolved props may change the path identity and orphan the old rule.
401 if (!isResolved(news)) return { action: "replace" };
402 if (
403 news.owner !== olds.owner ||
404 news.repository !== olds.repository ||
405 news.branch !== olds.branch ||
406 (yield* gitHubBaseUrlChanged(olds, news))
407 ) {
408 return { action: "replace" };
409 }
410 }),
411
412 reconcile: Effect.fn(function* ({ news }) {
413 const octokit = yield* octokitFor(news.baseUrl);
414
415 // Ensure & Sync — the PUT is a full upsert of the rule. Send explicit
416 // values (or `null`) for every aspect so removed props converge back
417 // to their defaults rather than being left as-is.
418 const checks = news.requiredStatusChecks;
419 const reviews = news.requiredPullRequestReviews;
420 const restrictions = news.restrictions;
421
422 yield* Effect.tryPromise({
423 try: () =>
424 octokit.rest.repos.updateBranchProtection({
425 owner: news.owner,
426 repo: news.repository,
427 branch: news.branch,
428 required_status_checks:
429 checks === undefined
430 ? null
431 : {
432 strict: checks.strict ?? false,
433 contexts: checks.contexts ?? [],
434 ...(checks.checks === undefined
435 ? {}
436 : {
437 checks: checks.checks.map((check) => ({
438 context: check.context,
439 ...(check.appId === undefined
440 ? {}
441 : { app_id: check.appId }),
442 })),
443 }),
444 },
445 enforce_admins: news.enforceAdmins ?? false,
446 required_pull_request_reviews:
447 reviews === undefined
448 ? null
449 : {
450 dismiss_stale_reviews: reviews.dismissStaleReviews ?? false,
451 require_code_owner_reviews:
452 reviews.requireCodeOwnerReviews ?? false,
453 required_approving_review_count:
454 reviews.requiredApprovingReviewCount ?? 0,
455 require_last_push_approval:
456 reviews.requireLastPushApproval ?? false,
457 ...(reviews.dismissalRestrictions === undefined
458 ? {}
459 : {
460 dismissal_restrictions: {
461 users: reviews.dismissalRestrictions.users ?? [],
462 teams: reviews.dismissalRestrictions.teams ?? [],
463 apps: reviews.dismissalRestrictions.apps ?? [],
464 },
465 }),
466 ...(reviews.bypassPullRequestAllowances === undefined
467 ? {}
468 : {
469 bypass_pull_request_allowances: {
470 users:
471 reviews.bypassPullRequestAllowances.users ?? [],
472 teams:
473 reviews.bypassPullRequestAllowances.teams ?? [],
474 apps:
475 reviews.bypassPullRequestAllowances.apps ?? [],
476 },
477 }),
478 },
479 restrictions:
480 restrictions === undefined
481 ? null
482 : {
483 users: restrictions.users ?? [],
484 teams: restrictions.teams ?? [],
485 apps: restrictions.apps ?? [],
486 },
487 required_linear_history: news.requiredLinearHistory ?? false,
488 allow_force_pushes: news.allowForcePushes ?? false,
489 allow_deletions: news.allowDeletions ?? false,
490 block_creations: news.blockCreations ?? false,
491 required_conversation_resolution:
492 news.requiredConversationResolution ?? false,
493 lock_branch: news.lockBranch ?? false,
494 allow_fork_syncing: news.allowForkSyncing ?? false,
495 }),
496 catch: (e) => e as Error,
497 });
498
499 // Sync — required signatures live behind dedicated endpoints that the
500 // top-level PUT does not touch. Diff the observed flag against the
501 // desired one and only call the API on a real change.
502 const observed = yield* Effect.tryPromise({
503 try: async () => {
504 const { data } = await octokit.rest.repos.getBranchProtection({
505 owner: news.owner,
506 repo: news.repository,
507 branch: news.branch,
508 });
509 return data as ProtectionPayload;
510 },
511 catch: (e) => e as Error,
512 });
513
514 const desiredSignatures = news.requiredSignatures ?? false;
515 const observedSignatures = observed.required_signatures?.enabled ?? false;
516 if (desiredSignatures !== observedSignatures) {
517 yield* Effect.tryPromise({
518 try: async () => {
519 if (desiredSignatures) {
520 await octokit.rest.repos.createCommitSignatureProtection({
521 owner: news.owner,
522 repo: news.repository,
523 branch: news.branch,
524 });
525 } else {
526 await octokit.rest.repos.deleteCommitSignatureProtection({
527 owner: news.owner,
528 repo: news.repository,
529 branch: news.branch,
530 });
531 }
532 },
533 catch: (e) => e as Error,
534 });
535 }
536
537 return attrsOf(news.branch, {
538 ...observed,
539 required_signatures: { enabled: desiredSignatures },
540 });
541 }),
542
543 // Refresh from the live rule. A 404 means either the branch is no longer
544 // protected or the branch/repository is gone — both are "missing".
545 read: Effect.fn(function* ({ olds }) {
546 const octokit = yield* octokitFor(olds.baseUrl);
547
548 return yield* Effect.tryPromise({
549 try: async () => {
550 try {
551 const { data } = await octokit.rest.repos.getBranchProtection({
552 owner: olds.owner,
553 repo: olds.repository,
554 branch: olds.branch,
555 });
556 return attrsOf(olds.branch, data as ProtectionPayload);
557 } catch (error: any) {
558 if (error.status === 404) return undefined;
559 throw error;
560 }
561 },
562 catch: (e) => e as Error,
563 });
564 }),
565
566 // Enumerate every protected branch across the repositories the token can
567 // see — protection rules are keyed by {owner, repository, branch} with no
568 // account-wide list endpoint, so walk the repos like the Environment
569 // provider does and fetch the rule for each protected branch.
570 list: Effect.fn(function* () {
571 const octokit = yield* Octokit;
572
573 const repos = yield* Effect.tryPromise({
574 try: () =>
575 octokit.paginate(octokit.rest.repos.listForAuthenticatedUser, {
576 per_page: 100,
577 }),
578 catch: (e) => e as Error,
579 });
580
581 const perRepo = yield* Effect.forEach(
582 repos,
583 (repo) =>
584 Effect.tryPromise({
585 try: async () => {
586 try {
587 const branches = await octokit.paginate(
588 octokit.rest.repos.listBranches,
589 {
590 owner: repo.owner.login,
591 repo: repo.name,
592 protected: true,
593 per_page: 100,
594 },
595 );
596 const rules: BranchProtection["Attributes"][] = [];
597 for (const branch of branches) {
598 try {
599 const { data } =
600 await octokit.rest.repos.getBranchProtection({
601 owner: repo.owner.login,
602 repo: repo.name,
603 branch: branch.name,
604 });
605 rules.push(attrsOf(branch.name, data as ProtectionPayload));
606 } catch (error: any) {
607 // Protection may be removed between the list and the
608 // get, or the branch may be governed by a ruleset only.
609 if (error.status !== 404) throw error;
610 }
611 }
612 return rules;
613 } catch (error: any) {
614 // Repos without branch protection support (plan limits) or
615 // where the token lacks access reject with 403/404 — skip
616 // them rather than failing the whole enumeration.
617 if (error.status === 403 || error.status === 404) {
618 return [];
619 }
620 throw error;
621 }
622 },
623 catch: (e) => e as Error,
624 }),
625 { concurrency: 10 },
626 );
627
628 return perRepo.flat();
629 }),
630
631 delete: Effect.fn(function* ({ olds }) {
632 const octokit = yield* octokitFor(olds.baseUrl);
633
634 yield* Effect.tryPromise({
635 try: async () => {
636 try {
637 await octokit.rest.repos.deleteBranchProtection({
638 owner: olds.owner,
639 repo: olds.repository,
640 branch: olds.branch,
641 });
642 } catch (error: any) {
643 // Already unprotected, or the branch/repository is gone.
644 if (error.status !== 404) {
645 throw error;
646 }
647 }
648 },
649 catch: (e) => e as Error,
650 });
651 }),
652 });