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 RepositoryProps {
9 /**
10 * Repository owner — a user or organization login.
11 *
12 * Changing the owner replaces the repository: a NEW, EMPTY repository is
13 * created under the new owner — history, issues, and pull requests are
14 * not carried over. The old repository is retained on GitHub under the
15 * default `retain` removal policy; it is only deleted when the resource
16 * opted into deletion via `destroy()`. To actually move a repository
17 * between owners with its history, transfer it in the GitHub UI/API
18 * first, then update `owner` here and deploy with `--adopt`.
19 */
20 owner: string;
21
22 /**
23 * Repository name. Renaming (deploying with the same logical ID and a
24 * different `name`) renames the existing repository in place rather than
25 * replacing it.
26 */
27 name: string;
28
29 /**
30 * Short description shown on the repository page.
31 */
32 description?: string;
33
34 /**
35 * Homepage URL shown on the repository page.
36 */
37 homepage?: string;
38
39 /**
40 * Repository visibility. `internal` is only valid for repositories owned by
41 * an organization on GitHub Enterprise. When omitted, GitHub's default
42 * applies (`public`).
43 * @default "public"
44 */
45 visibility?: "public" | "private" | "internal";
46
47 /**
48 * Whether the Issues tab is enabled.
49 * @default true
50 */
51 hasIssues?: boolean;
52
53 /**
54 * Whether the Projects tab is enabled.
55 * @default true
56 */
57 hasProjects?: boolean;
58
59 /**
60 * Whether the Wiki tab is enabled.
61 * @default true
62 */
63 hasWiki?: boolean;
64
65 /**
66 * Whether GitHub Discussions are enabled.
67 * @default false
68 */
69 hasDiscussions?: boolean;
70
71 /**
72 * Whether the repository is a template repository.
73 * @default false
74 */
75 isTemplate?: boolean;
76
77 /**
78 * Whether the repository is archived (read-only).
79 * @default false
80 */
81 archived?: boolean;
82
83 /**
84 * The default branch name. Only applied to repositories that already have
85 * at least one branch — setting it on an empty repository has no effect.
86 */
87 defaultBranch?: string;
88
89 /**
90 * Whether squash merges are allowed.
91 * @default true
92 */
93 allowSquashMerge?: boolean;
94
95 /**
96 * Whether merge commits are allowed.
97 * @default true
98 */
99 allowMergeCommit?: boolean;
100
101 /**
102 * Whether rebase merges are allowed.
103 * @default true
104 */
105 allowRebaseMerge?: boolean;
106
107 /**
108 * Whether auto-merge is enabled for pull requests.
109 * @default false
110 */
111 allowAutoMerge?: boolean;
112
113 /**
114 * Whether head branches are automatically deleted after a pull request is
115 * merged.
116 * @default false
117 */
118 deleteBranchOnMerge?: boolean;
119
120 /**
121 * Repository topics. The provided list fully replaces the existing topics.
122 */
123 topics?: string[];
124
125 /**
126 * Initialize the repository with an empty README on creation. Only used at
127 * create time — ignored on subsequent updates.
128 * @default false
129 */
130 autoInit?: boolean;
131
132 /**
133 * Name of the `.gitignore` template to apply on creation (e.g. `"Node"`).
134 * Only used at create time.
135 */
136 gitignoreTemplate?: string;
137
138 /**
139 * Keyword of the license template to apply on creation (e.g. `"mit"`).
140 * Only used at create time.
141 */
142 licenseTemplate?: string;
143
144 /**
145 * Override the GitHub host or API base URL for this resource only (e.g.
146 * `github.example.com` for GitHub Enterprise). Falls back to
147 * `GitHub.providers({ baseUrl })`, then to the host resolved by the auth
148 * provider. Changing it replaces the resource — the same name on a
149 * different GitHub instance is a different physical resource.
150 */
151 baseUrl?: string;
152}
153
154export interface Repository extends Resource<
155 "GitHub.Repository",
156 RepositoryProps,
157 {
158 /**
159 * Numeric GitHub repository ID.
160 */
161 repoId: number;
162
163 /**
164 * GraphQL node ID of the repository.
165 */
166 nodeId: string;
167
168 /**
169 * Full name in `owner/name` form.
170 */
171 fullName: string;
172
173 /**
174 * URL to view the repository in a browser.
175 */
176 htmlUrl: string;
177
178 /**
179 * Git protocol clone URL (`git://`).
180 */
181 gitUrl: string;
182
183 /**
184 * SSH clone URL (`git@github.com:owner/name.git`).
185 */
186 sshUrl: string;
187
188 /**
189 * HTTPS clone URL.
190 */
191 cloneUrl: string;
192
193 /**
194 * The resolved default branch name.
195 */
196 defaultBranch: string;
197
198 /**
199 * ISO-8601 timestamp of when the repository was created.
200 */
201 createdAt: string;
202
203 /**
204 * ISO-8601 timestamp of the last update.
205 */
206 updatedAt: string;
207 },
208 never,
209 GitHub.Providers
210> {}
211
212/**
213 * A GitHub repository.
214 *
215 * `Repository` manages the lifecycle of a repository owned by a user or
216 * organization. The repository is created on first deploy and its settings are
217 * converged on every subsequent deploy.
218 *
219 * Repositories default to **retain** on removal — destroying the stack does
220 * NOT delete the repository on GitHub, protecting its irreplaceable history
221 * (issues, pull requests, commits). Opt in to actual deletion by wrapping the
222 * resource (or the whole stack) in {@link destroy}() from
223 * `alchemy/RemovalPolicy`.
224 *
225 * Authentication is resolved via the `GitHubCredentials` service supplied by
226 * `GitHub.providers()` (env, stored PAT, `gh` CLI, or OAuth). The token needs
227 * `repo` scope (and `delete_repo` when deletion is opted in via `destroy()`).
228 * ### Creating a Repository
229 * **Example:** Basic Repository
230 * ```typescript
231 * const repo = yield* GitHub.Repository("api", {
232 * owner: "my-org",
233 * name: "api",
234 * description: "API service",
235 * autoInit: true,
236 * });
237 * ```
238 *
239 * **Example:** Private Repository with Settings
240 * ```typescript
241 * const repo = yield* GitHub.Repository("internal-tools", {
242 * owner: "my-org",
243 * name: "internal-tools",
244 * visibility: "private",
245 * hasWiki: false,
246 * hasProjects: false,
247 * deleteBranchOnMerge: true,
248 * });
249 * ```
250 *
251 * **Example:** Initialize from Templates
252 * The `autoInit`, `gitignoreTemplate`, and `licenseTemplate` props seed the
253 * first commit. They are only honored at create time — changing them on a
254 * later deploy has no effect on an existing repository.
255 * ```typescript
256 * const repo = yield* GitHub.Repository("service", {
257 * owner: "my-org",
258 * name: "service",
259 * autoInit: true,
260 * gitignoreTemplate: "Node",
261 * licenseTemplate: "mit",
262 * });
263 * ```
264 *
265 * ### Topics and Merge Configuration
266 * **Example:** Repository with Topics and Merge Policy
267 * ```typescript
268 * const repo = yield* GitHub.Repository("sdk", {
269 * owner: "my-org",
270 * name: "sdk",
271 * topics: ["typescript", "effect", "sdk"],
272 * allowMergeCommit: false,
273 * allowRebaseMerge: false,
274 * allowSquashMerge: true,
275 * allowAutoMerge: true,
276 * });
277 * ```
278 *
279 * ### Renaming a Repository
280 * **Example:** Rename in Place
281 * Keep the same logical ID and change `name` to rename the live repository
282 * instead of replacing it — the repository's history, issues, and pull
283 * requests are preserved. Only changing `owner` triggers a replacement.
284 * ```typescript
285 * // First deploy creates "api".
286 * const repo = yield* GitHub.Repository("api", {
287 * owner: "my-org",
288 * name: "api",
289 * });
290 *
291 * // A later deploy with the SAME logical ID ("api") renames it to "gateway".
292 * const repo = yield* GitHub.Repository("api", {
293 * owner: "my-org",
294 * name: "gateway",
295 * });
296 * ```
297 *
298 * ### Archiving a Repository
299 * **Example:** Make a Repository Read-Only
300 * Archiving sets the repository to read-only. Set `archived` back to `false`
301 * on a later deploy to un-archive it.
302 * ```typescript
303 * yield* GitHub.Repository("legacy", {
304 * owner: "my-org",
305 * name: "legacy-service",
306 * archived: true,
307 * });
308 * ```
309 *
310 * ### Wiring with Other Resources
311 * The repository's outputs can drive other GitHub resources so the whole
312 * repository configuration lives in one program.
313 *
314 * **Example:** Seed a Variable into the Repository
315 * ```typescript
316 * const repo = yield* GitHub.Repository("api", {
317 * owner: "my-org",
318 * name: "api",
319 * autoInit: true,
320 * });
321 *
322 * yield* GitHub.Variable("region", {
323 * owner: "my-org",
324 * repository: repo.name!,
325 * name: "AWS_REGION",
326 * value: "us-east-1",
327 * });
328 * ```
329 *
330 * **Example:** Store a Secret in the Repository
331 * ```typescript
332 * import * as Redacted from "effect/Redacted";
333 *
334 * const repo = yield* GitHub.Repository("api", {
335 * owner: "my-org",
336 * name: "api",
337 * autoInit: true,
338 * });
339 *
340 * yield* GitHub.Secret("deploy-token", {
341 * owner: "my-org",
342 * repository: repo.name!,
343 * name: "DEPLOY_TOKEN",
344 * value: Redacted.make("my-secret-value"),
345 * });
346 * ```
347 *
348 * ### Deleting a Repository
349 * **Example:** Allow Repository Deletion
350 * ```typescript
351 * import { destroy } from "alchemy/RemovalPolicy";
352 *
353 * yield* GitHub.Repository("ephemeral", {
354 * owner: "my-org",
355 * name: "ephemeral-preview",
356 * }).pipe(destroy());
357 * ```
358 *
359 * @resource
360 * @product Repository
361 */
362export const Repository = Resource<Repository>("GitHub.Repository", {
363 defaultRemovalPolicy: "retain",
364});
365
366export const RepositoryProvider = () =>
367 Provider.succeed(Repository, {
368 stables: ["repoId", "nodeId"],
369
370 // Structural changes are the owner (we deliberately do NOT call GitHub's
371 // transfer API — user-to-user transfers require out-of-band acceptance
372 // and cannot converge deterministically) and the host (the same repo
373 // name on a different GitHub instance is a different repository). A
374 // `name` change is a rename, handled in `reconcile`, not a replacement.
375 //
376 // Replacement is guarded by the resource's default `retain` removal
377 // policy: the engine creates the new repository and RETAINS the old one
378 // on GitHub (Apply honors `retain` for the replaced old generation), so
379 // history is never destroyed unless the user opted into `destroy()`.
380 diff: Effect.fn(function* ({ news, olds }) {
381 if (!isResolved(news)) return;
382 if (olds === undefined) return;
383 if (
384 news.owner !== olds.owner ||
385 (yield* gitHubBaseUrlChanged(olds, news))
386 ) {
387 return { action: "replace" };
388 }
389 }),
390
391 reconcile: Effect.fn(function* ({ news, olds }) {
392 const octokit = yield* octokitFor(news.baseUrl);
393
394 const getRepo = (repo: string) =>
395 Effect.tryPromise({
396 try: async () => {
397 try {
398 const { data } = await octokit.rest.repos.get({
399 owner: news.owner,
400 repo,
401 });
402 return data;
403 } catch (error: any) {
404 if (error.status === 404) return undefined;
405 throw error;
406 }
407 },
408 catch: (e) => e as Error,
409 });
410
411 // Observe — probe for the live repository under the desired name. On a
412 // rename (name changed since last deploy) the desired name 404s, so fall
413 // back to the prior name so we converge by renaming rather than creating
414 // a duplicate.
415 let observed = yield* getRepo(news.name);
416 if (
417 observed === undefined &&
418 olds?.name !== undefined &&
419 olds.name !== news.name
420 ) {
421 observed = yield* getRepo(olds.name);
422 }
423
424 // Ensure — create the repository when it does not exist. The owner may be
425 // a user or an organization; pick the matching create endpoint.
426 if (observed === undefined) {
427 const ownerType = yield* Effect.tryPromise({
428 try: async () => {
429 const { data } = await octokit.rest.users.getByUsername({
430 username: news.owner,
431 });
432 return data.type;
433 },
434 catch: (e) => e as Error,
435 });
436
437 const createInput = {
438 name: news.name,
439 description: news.description,
440 homepage: news.homepage,
441 has_issues: news.hasIssues,
442 has_projects: news.hasProjects,
443 has_wiki: news.hasWiki,
444 is_template: news.isTemplate,
445 auto_init: news.autoInit,
446 gitignore_template: news.gitignoreTemplate,
447 license_template: news.licenseTemplate,
448 allow_squash_merge: news.allowSquashMerge,
449 allow_merge_commit: news.allowMergeCommit,
450 allow_rebase_merge: news.allowRebaseMerge,
451 allow_auto_merge: news.allowAutoMerge,
452 delete_branch_on_merge: news.deleteBranchOnMerge,
453 };
454
455 observed = yield* Effect.tryPromise({
456 try: async () => {
457 try {
458 const { data } =
459 ownerType === "Organization"
460 ? // The org endpoint accepts `visibility` directly, which is
461 // the sole authority over public/private/internal. `internal`
462 // is only valid here.
463 await octokit.rest.repos.createInOrg({
464 org: news.owner,
465 ...createInput,
466 visibility: news.visibility,
467 } as Parameters<typeof octokit.rest.repos.createInOrg>[0])
468 : // A personal account is either a `User` or a `Bot`; both
469 // create through the authenticated-user endpoint, which only
470 // understands the boolean `private` flag.
471 await octokit.rest.repos.createForAuthenticatedUser({
472 ...createInput,
473 private: news.visibility
474 ? news.visibility !== "public"
475 : undefined,
476 } as Parameters<
477 typeof octokit.rest.repos.createForAuthenticatedUser
478 >[0]);
479 return data;
480 } catch (error: any) {
481 // A 422 means the name already exists — treat as a create race
482 // and re-observe so the sync step converges its settings.
483 if (error.status === 422) return undefined;
484 throw error;
485 }
486 },
487 catch: (e) => e as Error,
488 });
489
490 if (observed === undefined) {
491 observed = yield* getRepo(news.name);
492 }
493 if (observed === undefined) {
494 return yield* Effect.fail(
495 new Error(
496 `Failed to create or locate GitHub repository ${news.owner}/${news.name}`,
497 ),
498 );
499 }
500 }
501
502 // Sync — converge settings (and the name on a rename) against the live
503 // repository. GitHub's update is an idempotent PATCH, so we always issue
504 // it with the desired settings. `archived` is applied in a separate,
505 // later PATCH because an archived repository rejects any other settings
506 // change in the same call.
507 const repoName = observed.name;
508 const updateInput = {
509 owner: news.owner,
510 repo: repoName,
511 name: news.name,
512 description: news.description,
513 homepage: news.homepage,
514 private: news.visibility ? news.visibility !== "public" : undefined,
515 visibility: news.visibility,
516 has_issues: news.hasIssues,
517 has_projects: news.hasProjects,
518 has_wiki: news.hasWiki,
519 has_discussions: news.hasDiscussions,
520 is_template: news.isTemplate,
521 allow_squash_merge: news.allowSquashMerge,
522 allow_merge_commit: news.allowMergeCommit,
523 allow_rebase_merge: news.allowRebaseMerge,
524 allow_auto_merge: news.allowAutoMerge,
525 delete_branch_on_merge: news.deleteBranchOnMerge,
526 // Only set the default branch when it actually differs from the
527 // observed branch. GitHub returns 422 if the branch does not yet
528 // exist (e.g. an empty repo), so the 422 handler below strips it and
529 // retries rather than hard-failing — the branch may be created right
530 // after this deploy.
531 default_branch:
532 news.defaultBranch !== undefined &&
533 observed.default_branch !== news.defaultBranch
534 ? news.defaultBranch
535 : undefined,
536 };
537
538 const updated = yield* Effect.tryPromise({
539 // Octokit's typed params lag the REST API: `visibility: "internal"`
540 // and `has_discussions` are valid at runtime but missing from the
541 // generated types, so assert to the accepted parameter shape.
542 try: async () => {
543 try {
544 const { data } = await octokit.rest.repos.update(
545 updateInput as Parameters<typeof octokit.rest.repos.update>[0],
546 );
547 return data;
548 } catch (error: any) {
549 // A 422 on a default-branch update usually means the branch does
550 // not exist yet. Drop it and retry so the rest of the settings
551 // still converge.
552 if (error.status === 422 && updateInput.default_branch) {
553 const { default_branch, ...withoutBranch } = updateInput;
554 const { data } = await octokit.rest.repos.update(
555 withoutBranch as Parameters<
556 typeof octokit.rest.repos.update
557 >[0],
558 );
559 return data;
560 }
561 throw error;
562 }
563 },
564 catch: (e) => e as Error,
565 });
566
567 // Sync — apply `archived` in its own PATCH. Use the confirmed
568 // post-rename name from the first update so the call targets the right
569 // repo. Archiving is one-directional in this PATCH: only send it when
570 // explicitly provided.
571 if (news.archived !== undefined) {
572 yield* Effect.tryPromise({
573 try: () =>
574 octokit.rest.repos.update({
575 owner: news.owner,
576 repo: updated.name,
577 archived: news.archived,
578 } as Parameters<typeof octokit.rest.repos.update>[0]),
579 catch: (e) => e as Error,
580 });
581 }
582
583 // Sync — topics are managed via a dedicated endpoint. The provided list
584 // fully replaces existing topics, so removing the field (defined -> undefined)
585 // must clear them. Use the confirmed post-rename name from the first PATCH.
586 if (news.topics !== undefined || olds?.topics !== undefined) {
587 yield* Effect.tryPromise({
588 try: () =>
589 octokit.rest.repos.replaceAllTopics({
590 owner: news.owner,
591 repo: updated.name,
592 names: news.topics ?? [],
593 }),
594 catch: (e) => e as Error,
595 });
596 }
597
598 return attrsOf(updated);
599 }),
600
601 // Enumerate every repository the authenticated token can see. GitHub has no
602 // account/region scope resolved from env services — the ambient scope is the
603 // token itself, so we list the authenticated user's repositories (across all
604 // owners/orgs the token is a member of). `octokit.paginate` walks every page
605 // exhaustively. Each list item is a full repository object, so it hydrates
606 // directly into the same `Attributes` shape `read` returns.
607 list: Effect.fn(function* () {
608 const octokit = yield* Octokit;
609
610 const repos = yield* Effect.tryPromise({
611 try: () =>
612 octokit.paginate(octokit.rest.repos.listForAuthenticatedUser, {
613 per_page: 100,
614 }),
615 catch: (e) => e as Error,
616 });
617
618 return repos.map((repo) =>
619 attrsOf(repo as Parameters<typeof attrsOf>[0]),
620 );
621 }),
622
623 // Read by the numeric repository ID, which is stable across renames. This
624 // refreshes the output attributes (including the current name) so that a
625 // subsequent delete targets the live repository even when a prior rename's
626 // state persistence failed.
627 read: Effect.fn(function* ({ olds, output }) {
628 if (output === undefined) {
629 return undefined;
630 }
631
632 const octokit = yield* octokitFor(olds.baseUrl);
633
634 return yield* Effect.tryPromise({
635 try: async () => {
636 try {
637 const { data } = await octokit.request("GET /repositories/{id}", {
638 id: output.repoId,
639 });
640 return attrsOf(data as Parameters<typeof attrsOf>[0]);
641 } catch (error: any) {
642 if (error.status === 404) return undefined;
643 throw error;
644 }
645 },
646 catch: (e) => e as Error,
647 });
648 }),
649
650 delete: Effect.fn(function* ({ olds, output }) {
651 const octokit = yield* octokitFor(olds.baseUrl);
652
653 // Resolve the current repository name via the stable numeric ID. A rename
654 // whose state persistence failed leaves `olds.name` stale; deleting by
655 // the stale name 404s and silently leaves the repo behind. Looking it up
656 // by `repoId` gives us the live name.
657 let owner = olds.owner;
658 let repo = olds.name;
659 if (output?.repoId !== undefined) {
660 const current = yield* Effect.tryPromise({
661 try: async () => {
662 try {
663 const { data } = await octokit.request("GET /repositories/{id}", {
664 id: output.repoId,
665 });
666 return data;
667 } catch (error: any) {
668 if (error.status === 404) return undefined;
669 throw error;
670 }
671 },
672 catch: (e) => e as Error,
673 });
674 if (current !== undefined) {
675 owner = current.owner.login;
676 repo = current.name;
677 }
678 }
679
680 yield* Effect.tryPromise({
681 try: async () => {
682 try {
683 await octokit.rest.repos.delete({ owner, repo });
684 } catch (error: any) {
685 if (error.status !== 404) {
686 throw error;
687 }
688 }
689 },
690 catch: (e) => e as Error,
691 });
692 }),
693 });
694
695const attrsOf = (data: {
696 id: number;
697 node_id: string;
698 full_name: string;
699 html_url: string;
700 git_url: string;
701 ssh_url: string;
702 clone_url: string;
703 default_branch: string;
704 created_at: string | null;
705 updated_at: string | null;
706}) => ({
707 repoId: data.id,
708 nodeId: data.node_id,
709 fullName: data.full_name,
710 htmlUrl: data.html_url,
711 gitUrl: data.git_url,
712 sshUrl: data.ssh_url,
713 cloneUrl: data.clone_url,
714 defaultBranch: data.default_branch,
715 createdAt: data.created_at ?? new Date().toISOString(),
716 updatedAt: data.updated_at ?? new Date().toISOString(),
717});