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 TeamAccessProps {
9 /**
10 * Repository owner — must be an organization login.
11 */
12 owner: string;
13
14 /**
15 * Repository name.
16 */
17 repository: string;
18
19 /**
20 * Team slug within the organization (e.g. `platform`, `frontend`).
21 */
22 teamSlug: 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 TeamAccess extends Resource<
47 "GitHub.TeamAccess",
48 TeamAccessProps,
49 {
50 /**
51 * Team slug.
52 */
53 teamSlug: string;
54
55 /**
56 * Permission level granted.
57 */
58 permission: string;
59 },
60 never,
61 GitHub.Providers
62> {}
63
64/**
65 * A GitHub team repository access grant.
66 *
67 * `TeamAccess` grants a team access to a repository within an organization.
68 * Teams provide a scalable way to manage repository permissions — add users
69 * to teams instead of granting individual collaborator access.
70 *
71 * Team access grants default to **retain** on removal — destroying the stack
72 * does NOT remove the team's access, preventing accidental lockout. Opt in
73 * to 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:org` for managing team access.
79 *
80 * ### Granting Team Access
81 * **Example:** Grant Push Access to a Team
82 * ```typescript
83 * yield* GitHub.TeamAccess("platform-access", {
84 * owner: "my-org",
85 * repository: "my-repo",
86 * teamSlug: "platform",
87 * permission: "push",
88 * })
89 * ```
90 *
91 * **Example:** Grant Admin Access to a Team
92 * ```typescript
93 * yield* GitHub.TeamAccess("admin-access", {
94 * owner: "my-org",
95 * repository: "my-repo",
96 * teamSlug: "admins",
97 * permission: "admin",
98 * })
99 * ```
100 *
101 * **Example:** Grant Read-Only Access
102 * ```typescript
103 * yield* GitHub.TeamAccess("readonly", {
104 * owner: "my-org",
105 * repository: "my-repo",
106 * teamSlug: "contractors",
107 * permission: "pull",
108 * })
109 * ```
110 *
111 * ### Multiple Teams
112 * **Example:** Grant Different Permissions to Multiple Teams
113 * ```typescript
114 * yield* GitHub.TeamAccess("platform-write", {
115 * owner: "my-org",
116 * repository: "api",
117 * teamSlug: "platform",
118 * permission: "push",
119 * })
120 *
121 * yield* GitHub.TeamAccess("security-read", {
122 * owner: "my-org",
123 * repository: "api",
124 * teamSlug: "security",
125 * permission: "pull",
126 * })
127 * ```
128 *
129 * ### Removing Team Access
130 * **Example:** Allow Removal on Destroy
131 * ```typescript
132 * import { destroy } from "alchemy/RemovalPolicy"
133 *
134 * yield* GitHub.TeamAccess("temp", {
135 * owner: "my-org",
136 * repository: "my-repo",
137 * teamSlug: "contractors",
138 * permission: "push",
139 * }).pipe(destroy())
140 * ```
141 *
142 * @resource
143 * @product Repository
144 */
145export const TeamAccess = Resource<TeamAccess>("GitHub.TeamAccess", {
146 defaultRemovalPolicy: "retain",
147});
148
149export const TeamAccessProvider = () =>
150 Provider.succeed(TeamAccess, {
151 diff: Effect.fn(function* ({ news, olds }) {
152 if (!isResolved(news)) return;
153 if (olds === undefined) return;
154 if (
155 news.owner !== olds.owner ||
156 news.repository !== olds.repository ||
157 news.teamSlug !== olds.teamSlug ||
158 (yield* gitHubBaseUrlChanged(olds, news))
159 ) {
160 return { action: "replace" };
161 }
162 }),
163
164 reconcile: Effect.fn(function* ({ news }) {
165 const octokit = yield* octokitFor(news.baseUrl);
166
167 // Ensure & Sync — PUT is idempotent; adds team or updates permission
168 yield* Effect.tryPromise({
169 try: async () => {
170 await octokit.rest.teams.addOrUpdateRepoPermissionsInOrg({
171 org: news.owner,
172 team_slug: news.teamSlug,
173 owner: news.owner,
174 repo: news.repository,
175 permission: news.permission ?? "push",
176 });
177 },
178 catch: (e) => e as Error,
179 });
180
181 return {
182 teamSlug: news.teamSlug,
183 permission: news.permission ?? "push",
184 };
185 }),
186
187 list: Effect.fn(function* () {
188 const octokit = yield* Octokit;
189
190 const repos = yield* Effect.tryPromise({
191 try: () =>
192 octokit.paginate(octokit.rest.repos.listForAuthenticatedUser, {
193 per_page: 100,
194 }),
195 catch: (e) => e as Error,
196 });
197
198 const perRepo = yield* Effect.forEach(
199 repos,
200 (repo) =>
201 Effect.tryPromise({
202 try: async () => {
203 try {
204 const teams = await octokit.paginate(
205 octokit.rest.repos.listTeams,
206 {
207 owner: repo.owner.login,
208 repo: repo.name,
209 per_page: 100,
210 },
211 );
212 return teams.map((team: any) => ({
213 teamSlug: team.slug,
214 permission: team.permission ?? "push",
215 }));
216 } catch (error: any) {
217 if (error.status === 403 || error.status === 404) {
218 return [];
219 }
220 throw error;
221 }
222 },
223 catch: (e) => e as Error,
224 }),
225 { concurrency: 10 },
226 );
227
228 return perRepo.flat();
229 }),
230
231 delete: Effect.fn(function* ({ olds }) {
232 const octokit = yield* octokitFor(olds.baseUrl);
233
234 yield* Effect.tryPromise({
235 try: async () => {
236 try {
237 await octokit.rest.teams.removeRepoInOrg({
238 org: olds.owner,
239 team_slug: olds.teamSlug,
240 owner: olds.owner,
241 repo: olds.repository,
242 });
243 } catch (error: any) {
244 if (error.status !== 404) {
245 throw error;
246 }
247 }
248 },
249 catch: (e) => e as Error,
250 });
251 }),
252 });