1import * as Effect from "effect/Effect";
2import { deepEqual, 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 RulesetProps {
9 /**
10 * Repository owner — a user or organization login.
11 */
12 owner: string;
13
14 /**
15 * Repository name.
16 */
17 repository: string;
18
19 /**
20 * Ruleset name. The name is displayed in the GitHub UI and can be updated.
21 */
22 name: string;
23
24 /**
25 * The enforcement level of the ruleset. Can be `disabled`, `active`, or
26 * `evaluate` (log-only mode).
27 * @default "active"
28 */
29 enforcement?: "disabled" | "active" | "evaluate";
30
31 /**
32 * Whether the ruleset targets branches or tags.
33 * @default "branch"
34 */
35 target?: "branch" | "tag";
36
37 /**
38 * Conditions for targeting refs. When omitted, applies to all refs of the
39 * target type.
40 */
41 conditions?: {
42 /**
43 * Ref patterns to include (e.g. `["refs/heads/main", "refs/heads/release/*"]`).
44 * Supports glob patterns.
45 */
46 include?: string[];
47
48 /**
49 * Ref patterns to exclude (e.g. `["refs/heads/dev/*"]`).
50 * Supports glob patterns.
51 */
52 exclude?: string[];
53 };
54
55 /**
56 * Actors allowed to bypass the ruleset. Omit to allow no bypasses.
57 */
58 bypassActors?: Array<{
59 /**
60 * The type of actor that can bypass the ruleset.
61 */
62 actorType: "RepositoryRole" | "Team" | "Integration" | "OrganizationAdmin";
63
64 /**
65 * The ID of the actor (role ID, team ID, or integration ID).
66 * RepositoryRole IDs are 2 for write, 4 for maintain, and 5 for admin.
67 */
68 actorId?: number;
69
70 /**
71 * Whether the actor can always bypass or only through pull requests.
72 * @default "always"
73 */
74 bypassMode?: "always" | "pull_request";
75 }>;
76
77 /**
78 * Rules to enforce on the targeted refs.
79 */
80 rules?: {
81 /**
82 * Require status checks to pass before merging.
83 */
84 requiredStatusChecks?: {
85 /**
86 * Status checks that must pass. Each entry is a context name or
87 * integration ID.
88 */
89 checks: Array<{
90 /**
91 * The status check context name.
92 */
93 context: string;
94
95 /**
96 * Optional integration ID.
97 */
98 integrationId?: number;
99 }>;
100
101 /**
102 * Whether to require branches to be up to date before merging.
103 * @default false
104 */
105 strictRequiredStatusChecksPolicy?: boolean;
106 };
107
108 /**
109 * Require commits to be signed.
110 */
111 requiredSignatures?: boolean;
112
113 /**
114 * Require pull request before merging.
115 */
116 pullRequest?: {
117 /**
118 * Number of required approving reviews.
119 */
120 requiredApprovingReviewCount?: number;
121
122 /**
123 * Dismiss stale reviews when new commits are pushed.
124 */
125 dismissStaleReviewsOnPush?: boolean;
126
127 /**
128 * Require review from code owners.
129 */
130 requireCodeOwnerReview?: boolean;
131
132 /**
133 * Require approval of the most recent reviewable push.
134 */
135 requireLastPushApproval?: boolean;
136
137 /**
138 * Required review thread resolution.
139 */
140 requiredReviewThreadResolution?: boolean;
141 };
142
143 /**
144 * Prevent creation of matching refs.
145 */
146 creation?: boolean;
147
148 /**
149 * Prevent updates to matching refs.
150 */
151 update?: boolean;
152
153 /**
154 * Prevent deletion of matching refs.
155 */
156 deletion?: boolean;
157
158 /**
159 * Require linear history.
160 */
161 requiredLinearHistory?: boolean;
162
163 /**
164 * Prevent force pushes.
165 */
166 nonFastForward?: boolean;
167 };
168
169 /**
170 * Override the GitHub host or API base URL for this resource only (e.g.
171 * `github.example.com` for GitHub Enterprise). Falls back to
172 * `GitHub.providers({ baseUrl })`, then to the host resolved by the auth
173 * provider. Changing it replaces the resource — the same name on a
174 * different GitHub instance is a different physical resource.
175 */
176 baseUrl?: string;
177}
178
179export interface Ruleset extends Resource<
180 "GitHub.Ruleset",
181 RulesetProps,
182 {
183 /**
184 * Numeric GitHub ruleset ID.
185 */
186 rulesetId: number;
187
188 /**
189 * GraphQL node ID of the ruleset, when returned by GitHub.
190 */
191 nodeId: string | undefined;
192
193 /**
194 * The ruleset name.
195 */
196 name: string;
197
198 /**
199 * ISO-8601 timestamp of when the ruleset was created.
200 */
201 createdAt: string | undefined;
202
203 /**
204 * ISO-8601 timestamp of the last update.
205 */
206 updatedAt: string | undefined;
207 },
208 never,
209 GitHub.Providers
210> {}
211
212/**
213 * A GitHub repository ruleset.
214 *
215 * `Ruleset` manages branch and tag protection rules at the repository level.
216 * Rulesets replace the legacy branch protection API with a more flexible
217 * system that can target multiple branches or tags with a single ruleset.
218 *
219 * Rulesets default to **retain** on removal — destroying the stack does NOT
220 * delete the ruleset on GitHub, protecting production branches from
221 * accidental removal. Opt in to actual deletion by wrapping the resource in
222 * {@link destroy}() from `alchemy/RemovalPolicy`.
223 *
224 * Authentication is resolved via the `GitHubCredentials` service supplied by
225 * `GitHub.providers()` (env, stored PAT, `gh` CLI, or OAuth). The token needs
226 * `repo` scope, or repository Administration write permission for a
227 * fine-grained token. Ruleset deletion does not require `delete_repo`.
228 *
229 * ### Creating a Ruleset
230 * **Example:** Protect Main Branch
231 * ```typescript
232 * yield* GitHub.Ruleset("main-protection", {
233 * owner: "my-org",
234 * repository: "my-repo",
235 * name: "main protection",
236 * target: "branch",
237 * conditions: {
238 * include: ["refs/heads/main"],
239 * },
240 * rules: {
241 * nonFastForward: true,
242 * deletion: true,
243 * requiredLinearHistory: true,
244 * },
245 * })
246 * ```
247 *
248 * **Example:** Require PR Reviews
249 * ```typescript
250 * yield* GitHub.Ruleset("pr-reviews", {
251 * owner: "my-org",
252 * repository: "my-repo",
253 * name: "require reviews",
254 * target: "branch",
255 * conditions: {
256 * include: ["refs/heads/main", "refs/heads/release/*"],
257 * },
258 * rules: {
259 * pullRequest: {
260 * requiredApprovingReviewCount: 2,
261 * requireCodeOwnerReview: true,
262 * dismissStaleReviewsOnPush: true,
263 * requiredReviewThreadResolution: true,
264 * },
265 * },
266 * })
267 * ```
268 *
269 * ### Status Checks
270 * **Example:** Require CI to Pass
271 * ```typescript
272 * yield* GitHub.Ruleset("ci-checks", {
273 * owner: "my-org",
274 * repository: "my-repo",
275 * name: "CI required",
276 * target: "branch",
277 * conditions: {
278 * include: ["refs/heads/main"],
279 * },
280 * rules: {
281 * requiredStatusChecks: {
282 * checks: [
283 * { context: "ci/test" },
284 * { context: "ci/lint" },
285 * ],
286 * strictRequiredStatusChecksPolicy: true,
287 * },
288 * },
289 * })
290 * ```
291 *
292 * ### Bypass Actors
293 * **Example:** Allow Admins to Bypass
294 * ```typescript
295 * yield* GitHub.Ruleset("protected-with-bypass", {
296 * owner: "my-org",
297 * repository: "my-repo",
298 * name: "protected with bypass",
299 * target: "branch",
300 * conditions: {
301 * include: ["refs/heads/main"],
302 * },
303 * bypassActors: [
304 * { actorType: "RepositoryRole", actorId: 5 },
305 * ],
306 * rules: {
307 * nonFastForward: true,
308 * },
309 * })
310 * ```
311 *
312 * @resource
313 * @product Repository
314 */
315export const Ruleset = Resource<Ruleset>("GitHub.Ruleset", {
316 defaultRemovalPolicy: "retain",
317});
318
319export const RulesetProvider = () =>
320 Provider.succeed(Ruleset, {
321 stables: ["rulesetId", "nodeId"],
322
323 diff: Effect.fn(function* ({ news, olds }) {
324 if (!isResolved(news)) return;
325 if (olds === undefined) return;
326 if (
327 news.owner !== olds.owner ||
328 news.repository !== olds.repository ||
329 (yield* gitHubBaseUrlChanged(olds, news))
330 ) {
331 return { action: "replace" };
332 }
333 }),
334
335 reconcile: Effect.fn(function* ({ news, output }) {
336 const octokit = yield* octokitFor(news.baseUrl);
337
338 let observed =
339 output === undefined
340 ? undefined
341 : yield* getRuleset(news, output.rulesetId);
342
343 const rules: NonNullable<
344 NonNullable<
345 Parameters<typeof octokit.rest.repos.createRepoRuleset>[0]
346 >["rules"]
347 > = [];
348
349 if (news.rules?.creation) {
350 rules.push({ type: "creation" });
351 }
352 if (news.rules?.update) {
353 rules.push({
354 type: "update",
355 parameters: { update_allows_fetch_and_merge: false },
356 });
357 }
358 if (news.rules?.deletion) {
359 rules.push({ type: "deletion" });
360 }
361 if (news.rules?.nonFastForward) {
362 rules.push({ type: "non_fast_forward" });
363 }
364 if (news.rules?.requiredLinearHistory) {
365 rules.push({ type: "required_linear_history" });
366 }
367 if (news.rules?.requiredSignatures) {
368 rules.push({ type: "required_signatures" });
369 }
370 if (news.rules?.pullRequest) {
371 rules.push({
372 type: "pull_request",
373 parameters: {
374 required_approving_review_count:
375 news.rules.pullRequest.requiredApprovingReviewCount ?? 0,
376 dismiss_stale_reviews_on_push:
377 news.rules.pullRequest.dismissStaleReviewsOnPush ?? false,
378 require_code_owner_review:
379 news.rules.pullRequest.requireCodeOwnerReview ?? false,
380 require_last_push_approval:
381 news.rules.pullRequest.requireLastPushApproval ?? false,
382 required_review_thread_resolution:
383 news.rules.pullRequest.requiredReviewThreadResolution ?? false,
384 },
385 });
386 }
387 if (news.rules?.requiredStatusChecks) {
388 rules.push({
389 type: "required_status_checks",
390 parameters: {
391 required_status_checks: news.rules.requiredStatusChecks.checks.map(
392 (check) => ({
393 context: check.context,
394 integration_id: check.integrationId,
395 }),
396 ),
397 strict_required_status_checks_policy:
398 news.rules.requiredStatusChecks
399 .strictRequiredStatusChecksPolicy ?? false,
400 },
401 });
402 }
403
404 const conditions = {
405 ref_name: {
406 include: news.conditions?.include ?? ["~ALL"],
407 exclude: news.conditions?.exclude ?? [],
408 },
409 };
410
411 // Build the bypass actors payload
412 const bypassActors = news.bypassActors?.map((actor) => ({
413 actor_type: actor.actorType,
414 actor_id: actor.actorId,
415 bypass_mode: actor.bypassMode ?? "always",
416 }));
417
418 const desired = {
419 name: news.name,
420 target: news.target ?? "branch",
421 enforcement: news.enforcement ?? "active",
422 bypass_actors: bypassActors ?? [],
423 conditions,
424 rules,
425 } satisfies Omit<
426 NonNullable<Parameters<typeof octokit.rest.repos.createRepoRuleset>[0]>,
427 "owner" | "repo"
428 >;
429
430 if (observed === undefined) {
431 observed = yield* Effect.tryPromise({
432 try: () =>
433 octokit.rest.repos.createRepoRuleset({
434 owner: news.owner,
435 repo: news.repository,
436 ...desired,
437 }),
438 catch: (e) => e as Error,
439 }).pipe(Effect.map(({ data }) => data));
440 }
441
442 if (
443 !deepEqual(
444 {
445 name: observed.name,
446 target: observed.target,
447 enforcement: observed.enforcement,
448 bypass_actors: observed.bypass_actors ?? [],
449 conditions: observed.conditions,
450 rules: observed.rules ?? [],
451 },
452 desired,
453 )
454 ) {
455 const rulesetId = observed.id;
456 observed = yield* Effect.tryPromise({
457 try: () =>
458 octokit.rest.repos.updateRepoRuleset({
459 owner: news.owner,
460 repo: news.repository,
461 ruleset_id: rulesetId,
462 ...desired,
463 }),
464 catch: (e) => e as Error,
465 }).pipe(Effect.map(({ data }) => data));
466 }
467
468 return attrsOf(observed);
469 }),
470
471 read: Effect.fn(function* ({ olds, output }) {
472 if (output === undefined) return undefined;
473 const observed = yield* getRuleset(olds, output.rulesetId);
474 return observed === undefined ? undefined : attrsOf(observed);
475 }),
476
477 list: Effect.fn(function* () {
478 const octokit = yield* Octokit;
479
480 const repos = yield* Effect.tryPromise({
481 try: () =>
482 octokit.paginate(octokit.rest.repos.listForAuthenticatedUser, {
483 per_page: 100,
484 }),
485 catch: (e) => e as Error,
486 });
487
488 const perRepo = yield* Effect.forEach(
489 repos,
490 (repo) =>
491 Effect.tryPromise({
492 try: () =>
493 octokit.paginate(octokit.rest.repos.getRepoRulesets, {
494 owner: repo.owner.login,
495 repo: repo.name,
496 includes_parents: false,
497 per_page: 100,
498 }),
499 catch: (e) => e as Error & { status?: number },
500 }).pipe(
501 Effect.map((rulesets) => rulesets.map(attrsOf)),
502 Effect.catchIf(
503 (error) => error.status === 403 || error.status === 404,
504 () => Effect.succeed([]),
505 ),
506 ),
507 { concurrency: 10 },
508 );
509
510 return perRepo.flat();
511 }),
512
513 delete: Effect.fn(function* ({ olds, output }) {
514 if (output?.rulesetId === undefined) return;
515
516 const octokit = yield* octokitFor(olds.baseUrl);
517
518 yield* Effect.tryPromise({
519 try: () =>
520 octokit.rest.repos.deleteRepoRuleset({
521 owner: olds.owner,
522 repo: olds.repository,
523 ruleset_id: output.rulesetId,
524 }),
525 catch: (e) => e as Error & { status?: number },
526 }).pipe(
527 Effect.catchIf(
528 (error) => error.status === 404,
529 () => Effect.void,
530 ),
531 );
532 }),
533 });
534
535const getRuleset = Effect.fn(function* (
536 props: Pick<RulesetProps, "owner" | "repository" | "baseUrl">,
537 rulesetId: number,
538) {
539 const octokit = yield* octokitFor(props.baseUrl);
540 return yield* Effect.tryPromise({
541 try: () =>
542 octokit.rest.repos.getRepoRuleset({
543 owner: props.owner,
544 repo: props.repository,
545 ruleset_id: rulesetId,
546 }),
547 catch: (e) => e as Error & { status?: number },
548 }).pipe(
549 Effect.map(({ data }) => data),
550 Effect.catchIf(
551 (error) => error.status === 404,
552 () => Effect.succeed(undefined),
553 ),
554 );
555});
556
557const attrsOf = (ruleset: {
558 id: number;
559 node_id?: string;
560 name: string;
561 created_at?: string;
562 updated_at?: string;
563}) => ({
564 rulesetId: ruleset.id,
565 nodeId: ruleset.node_id,
566 name: ruleset.name,
567 createdAt: ruleset.created_at,
568 updatedAt: ruleset.updated_at,
569});