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 CollaboratorProps {
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 * GitHub username to grant access.
21 */
22 username: string;
23
24 /**
25 * Permission level to grant.
26 * - `pull` — read-only access
27 * - `push` — read and write access
28 * - `maintain` — read, write, and manage issues/PRs
29 * - `triage` — read and manage issues/PRs without write access
30 * - `admin` — full admin access
31 *
32 * @default "push"
33 */
34 permission?: "pull" | "push" | "maintain" | "triage" | "admin";
35
36 /**
37 * Override the GitHub host or API base URL for this resource only (e.g.
38 * `github.example.com` for GitHub Enterprise). Falls back to
39 * `GitHub.providers({ baseUrl })`, then to the host resolved by the auth
40 * provider. Changing it replaces the resource — the same name on a
41 * different GitHub instance is a different physical resource.
42 */
43 baseUrl?: string;
44}
45
46export interface Collaborator extends Resource<
47 "GitHub.Collaborator",
48 CollaboratorProps,
49 {
50 /**
51 * GitHub username.
52 */
53 username: string;
54
55 /**
56 * Permission level granted.
57 */
58 permission: string;
59 },
60 never,
61 GitHub.Providers
62> {}
63
64/**
65 * A GitHub repository collaborator.
66 *
67 * `Collaborator` grants a user direct access to a repository. For
68 * organization-owned repositories, prefer `GitHub.TeamAccess` to grant
69 * access through teams instead of individual users.
70 *
71 * Collaborators default to **retain** on removal — destroying the stack does
72 * NOT remove the collaborator, preventing accidental lockout. Opt in to
73 * actual removal by wrapping the resource in {@link destroy}() from
74 * `alchemy/RemovalPolicy`.
75 *
76 * Authentication is resolved via the `GitHubCredentials` service supplied by
77 * `GitHub.providers()` (env, stored PAT, `gh` CLI, or OAuth). The token needs
78 * `repo` scope (and `admin:repo` for removal when opted in via `destroy()`).
79 *
80 * ### Adding a Collaborator
81 * **Example:** Grant Push Access
82 * ```typescript
83 * yield* GitHub.Collaborator("collaborator", {
84 * owner: "my-org",
85 * repository: "my-repo",
86 * username: "contributor",
87 * permission: "push",
88 * })
89 * ```
90 *
91 * **Example:** Grant Admin Access
92 * ```typescript
93 * yield* GitHub.Collaborator("admin", {
94 * owner: "my-org",
95 * repository: "my-repo",
96 * username: "team-lead",
97 * permission: "admin",
98 * })
99 * ```
100 *
101 * **Example:** Grant Read-Only Access
102 * ```typescript
103 * yield* GitHub.Collaborator("readonly", {
104 * owner: "my-org",
105 * repository: "my-repo",
106 * username: "auditor",
107 * permission: "pull",
108 * })
109 * ```
110 *
111 * ### Removing a Collaborator
112 * **Example:** Allow Removal on Destroy
113 * ```typescript
114 * import { destroy } from "alchemy/RemovalPolicy"
115 *
116 * yield* GitHub.Collaborator("temp", {
117 * owner: "my-org",
118 * repository: "my-repo",
119 * username: "contractor",
120 * permission: "push",
121 * }).pipe(destroy())
122 * ```
123 *
124 * @resource
125 * @product Repository
126 */
127export const Collaborator = Resource<Collaborator>("GitHub.Collaborator", {
128 defaultRemovalPolicy: "retain",
129});
130
131export const CollaboratorProvider = () =>
132 Provider.succeed(Collaborator, {
133 diff: Effect.fn(function* ({ news, olds }) {
134 if (!isResolved(news)) return;
135 if (olds === undefined) return;
136 if (
137 news.owner !== olds.owner ||
138 news.repository !== olds.repository ||
139 news.username !== olds.username ||
140 (yield* gitHubBaseUrlChanged(olds, news))
141 ) {
142 return { action: "replace" };
143 }
144 }),
145
146 reconcile: Effect.fn(function* ({ news }) {
147 const octokit = yield* octokitFor(news.baseUrl);
148
149 // Ensure & Sync — PUT is idempotent; creates or updates permission
150 yield* Effect.tryPromise({
151 try: async () => {
152 await octokit.rest.repos.addCollaborator({
153 owner: news.owner,
154 repo: news.repository,
155 username: news.username,
156 permission: news.permission ?? "push",
157 });
158 },
159 catch: (e) => e as Error,
160 });
161
162 return {
163 username: news.username,
164 permission: news.permission ?? "push",
165 };
166 }),
167
168 list: Effect.fn(function* () {
169 const octokit = yield* Octokit;
170
171 const repos = yield* Effect.tryPromise({
172 try: () =>
173 octokit.paginate(octokit.rest.repos.listForAuthenticatedUser, {
174 per_page: 100,
175 }),
176 catch: (e) => e as Error,
177 });
178
179 const perRepo = yield* Effect.forEach(
180 repos,
181 (repo) =>
182 Effect.tryPromise({
183 try: async () => {
184 try {
185 const collaborators = await octokit.paginate(
186 octokit.rest.repos.listCollaborators,
187 {
188 owner: repo.owner.login,
189 repo: repo.name,
190 per_page: 100,
191 },
192 );
193 return collaborators.map((collab: any) => ({
194 username: collab.login,
195 permission: collab.permissions?.admin
196 ? "admin"
197 : collab.permissions?.maintain
198 ? "maintain"
199 : collab.permissions?.push
200 ? "push"
201 : collab.permissions?.triage
202 ? "triage"
203 : "pull",
204 }));
205 } catch (error: any) {
206 if (error.status === 403 || error.status === 404) {
207 return [];
208 }
209 throw error;
210 }
211 },
212 catch: (e) => e as Error,
213 }),
214 { concurrency: 10 },
215 );
216
217 return perRepo.flat();
218 }),
219
220 delete: Effect.fn(function* ({ olds }) {
221 const octokit = yield* octokitFor(olds.baseUrl);
222
223 yield* Effect.tryPromise({
224 try: async () => {
225 try {
226 await octokit.rest.repos.removeCollaborator({
227 owner: olds.owner,
228 repo: olds.repository,
229 username: olds.username,
230 });
231 } catch (error: any) {
232 if (error.status !== 404) {
233 throw error;
234 }
235 }
236 },
237 catch: (e) => e as Error,
238 });
239 }),
240 });