1import { adopt } from "@/AdoptPolicy";
2import * as GitHub from "@/GitHub";
3import { Octokit } from "@/GitHub/Octokit.ts";
4import * as Provider from "@/Provider";
5import { destroy } from "@/RemovalPolicy";
6import * as Test from "@/Test/Alchemy";
7import { expect } from "alchemy-test";
8import * as Effect from "effect/Effect";
9import { MinimumLogLevel } from "effect/References";
10
11const { test } = Test.make({ providers: GitHub.providers() });
12
13const logLevel = Effect.provideService(
14 MinimumLogLevel,
15 process.env.DEBUG ? "Debug" : "Info",
16);
17
18// These tests create, mutate, and delete real repositories, so they run
19// against the dedicated test orgs (never a real one). Set GITHUB_TEST_OWNER=""
20// to skip the owner-scoped tests entirely. The second org hosts the target
21// side of owner-change (replacement) tests.
22const owner = process.env.GITHUB_TEST_OWNER ?? "alchemy-run-test";
23const owner2 = process.env.GITHUB_TEST_OWNER_2 ?? "alchemy-run-test-2";
24
25// Deleting a repository requires the `delete_repo` scope (fine-grained:
26// "Administration: write"). The default `gh` CLI token only carries
27// `gist, read:org, repo, workflow`, so every repo delete 403s with
28// "Must have admin rights to Repository." — the create/delete lifecycle
29// tests are gated behind an explicit opt-in. To run them:
30// gh auth refresh -h github.com -s delete_repo
31// GITHUB_TEST_DELETE_REPO=1 bun run test test/GitHub/Repository.test.ts --profile testing
32const canDeleteRepos = !!process.env.GITHUB_TEST_DELETE_REPO;
33
34const getRepo = (repo: string, repoOwner: string = owner) =>
35 Effect.gen(function* () {
36 const octokit = yield* Octokit;
37 return yield* Effect.tryPromise({
38 try: async () => {
39 try {
40 const { data } = await octokit.rest.repos.get({
41 owner: repoOwner,
42 repo,
43 });
44 return data;
45 } catch (error: any) {
46 if (error.status === 404) return undefined;
47 throw error;
48 }
49 },
50 catch: (e) => e as Error,
51 });
52 });
53
54test.provider.skipIf(!owner || !canDeleteRepos)(
55 "create, update, rename, and delete a repository",
56 (stack) =>
57 Effect.gen(function* () {
58 const name = "alchemy-effect-repo-test";
59 const renamed = "alchemy-effect-repo-test-renamed";
60
61 // Clean up any leftovers from a previous run before deploying.
62 yield* stack.destroy();
63
64 // Create
65 const created = yield* stack.deploy(
66 Effect.gen(function* () {
67 return yield* GitHub.Repository("Repo", {
68 owner,
69 name,
70 description: "alchemy-effect integration test",
71 visibility: "private",
72 autoInit: true,
73 // `hasIssues`, not `hasWiki`: wikis on PRIVATE repos are
74 // plan-gated for orgs — GitHub accepts `has_wiki: true` on the
75 // PATCH but silently keeps it false on a free-plan org, so a
76 // wiki toggle can never be asserted here.
77 hasIssues: false,
78 topics: ["alchemy", "test"],
79 }).pipe(destroy());
80 }),
81 );
82
83 expect(created.repoId).toBeGreaterThan(0);
84 expect(created.fullName).toEqual(`${owner}/${name}`);
85 expect(created.defaultBranch).toBeDefined();
86
87 const fetched = yield* getRepo(name);
88 expect(fetched?.id).toEqual(created.repoId);
89 expect(fetched?.description).toEqual("alchemy-effect integration test");
90 expect(fetched?.private).toEqual(true);
91 expect(fetched?.has_issues).toEqual(false);
92 expect(fetched?.topics).toEqual(
93 expect.arrayContaining(["alchemy", "test"]),
94 );
95
96 // Update — change settings and topics, same logical ID → same repoId.
97 const updated = yield* stack.deploy(
98 Effect.gen(function* () {
99 return yield* GitHub.Repository("Repo", {
100 owner,
101 name,
102 description: "updated description",
103 visibility: "private",
104 hasIssues: true,
105 topics: ["alchemy"],
106 }).pipe(destroy());
107 }),
108 );
109
110 expect(updated.repoId).toEqual(created.repoId);
111 const afterUpdate = yield* getRepo(name);
112 expect(afterUpdate?.description).toEqual("updated description");
113 expect(afterUpdate?.has_issues).toEqual(true);
114 expect(afterUpdate?.topics).toEqual(["alchemy"]);
115
116 // Rename — new `name`, same logical ID → in-place rename, same repoId.
117 const wasRenamed = yield* stack.deploy(
118 Effect.gen(function* () {
119 return yield* GitHub.Repository("Repo", {
120 owner,
121 name: renamed,
122 description: "updated description",
123 visibility: "private",
124 }).pipe(destroy());
125 }),
126 );
127
128 expect(wasRenamed.repoId).toEqual(created.repoId);
129 expect(wasRenamed.fullName).toEqual(`${owner}/${renamed}`);
130 const afterRename = yield* getRepo(renamed);
131 expect(afterRename?.id).toEqual(created.repoId);
132 // GitHub keeps a permanent redirect from a renamed repository's old
133 // name, and Octokit follows the 301 — so fetching the old name returns
134 // the SAME repository under its new name rather than a 404.
135 const oldName = yield* getRepo(name);
136 expect(oldName?.id).toEqual(created.repoId);
137 expect(oldName?.full_name).toEqual(`${owner}/${renamed}`);
138
139 // Delete
140 yield* stack.destroy();
141 const afterDestroy = yield* getRepo(renamed);
142 expect(afterDestroy).toBeUndefined();
143 }).pipe(logLevel),
144 {
145 tags: ["provider:github", "provider:github:repository", "live"],
146 timeout: 120_000,
147 },
148);
149
150test.provider.skipIf(!owner || !canDeleteRepos)(
151 "list enumerates the deployed repository",
152 (stack) =>
153 Effect.gen(function* () {
154 const name = "alchemy-effect-repo-list-test";
155
156 // Clean up any leftovers from a previous run before deploying.
157 yield* stack.destroy();
158
159 const deployed = yield* stack.deploy(
160 Effect.gen(function* () {
161 return yield* GitHub.Repository("ListRepo", {
162 owner,
163 name,
164 description: "alchemy-effect list integration test",
165 visibility: "private",
166 autoInit: true,
167 }).pipe(destroy());
168 }),
169 );
170
171 expect(deployed.repoId).toBeGreaterThan(0);
172
173 const provider = yield* Provider.findProvider(GitHub.Repository);
174 const all = yield* provider.list();
175
176 // The deployed repository must appear in the exhaustively-paginated result.
177 expect(all.some((r) => r.repoId === deployed.repoId)).toBe(true);
178 const found = all.find((r) => r.repoId === deployed.repoId);
179 expect(found?.fullName).toEqual(`${owner}/${name}`);
180
181 yield* stack.destroy();
182 const afterDestroy = yield* getRepo(name);
183 expect(afterDestroy).toBeUndefined();
184 }).pipe(logLevel),
185 {
186 tags: ["provider:github", "provider:github:repository", "live"],
187 timeout: 120_000,
188 },
189);
190
191// Owner changes are replacements, not moves (we never call GitHub's transfer
192// API). With deletion opted in via `destroy()`, the replaced old-generation
193// repo is deleted from the old org after the new one is created.
194test.provider.skipIf(!owner || !owner2 || !canDeleteRepos)(
195 "changing the owner replaces the repository (destroy-opted)",
196 (stack) =>
197 Effect.gen(function* () {
198 const name = "alchemy-effect-repo-owner-test";
199
200 // Clean up any leftovers from a previous run. Repos a crashed run left
201 // behind converge back under management when the deploy below re-takes
202 // the same deterministic name (reconcile's create-race path).
203 yield* stack.destroy();
204
205 const created = yield* stack.deploy(
206 Effect.gen(function* () {
207 return yield* GitHub.Repository("Repo", {
208 owner,
209 name,
210 description: "alchemy-effect owner-change test",
211 visibility: "private",
212 autoInit: true,
213 }).pipe(destroy());
214 }),
215 );
216 expect(created.fullName).toEqual(`${owner}/${name}`);
217
218 // Same logical ID, new owner → replacement: fresh repoId under the new
219 // org, and (because deletion is opted in) the old repo is cleaned up.
220 const replaced = yield* stack.deploy(
221 Effect.gen(function* () {
222 return yield* GitHub.Repository("Repo", {
223 owner: owner2,
224 name,
225 description: "alchemy-effect owner-change test",
226 visibility: "private",
227 autoInit: true,
228 }).pipe(destroy());
229 }),
230 );
231 expect(replaced.fullName).toEqual(`${owner2}/${name}`);
232 expect(replaced.repoId).not.toEqual(created.repoId);
233
234 const oldRepo = yield* getRepo(name, owner);
235 expect(oldRepo).toBeUndefined();
236 const newRepo = yield* getRepo(name, owner2);
237 expect(newRepo?.id).toEqual(replaced.repoId);
238
239 yield* stack.destroy();
240 const afterDestroy = yield* getRepo(name, owner2);
241 expect(afterDestroy).toBeUndefined();
242 }).pipe(logLevel),
243 {
244 tags: ["provider:github", "provider:github:repository", "live"],
245 timeout: 120_000,
246 },
247);
248
249// The safety property: under the DEFAULT `retain` removal policy, an owner
250// change creates the new repo but RETAINS the old one on GitHub — history is
251// never destroyed by a replacement unless deletion was explicitly opted in.
252test.provider.skipIf(!owner || !owner2 || !canDeleteRepos)(
253 "changing the owner retains the old repository by default",
254 (stack) =>
255 Effect.gen(function* () {
256 const name = "alchemy-effect-repo-retain-test";
257
258 // Clean up any leftovers from a previous run. Retained repos a prior
259 // run left behind converge back under management when the deploys
260 // below re-take the same deterministic names.
261 yield* stack.destroy();
262
263 // No `destroy()` pipe — the default `retain` policy applies.
264 const created = yield* stack.deploy(
265 Effect.gen(function* () {
266 return yield* GitHub.Repository("Repo", {
267 owner,
268 name,
269 description: "alchemy-effect retain-on-replace test",
270 visibility: "private",
271 autoInit: true,
272 });
273 }),
274 );
275
276 const replaced = yield* stack.deploy(
277 Effect.gen(function* () {
278 return yield* GitHub.Repository("Repo", {
279 owner: owner2,
280 name,
281 description: "alchemy-effect retain-on-replace test",
282 visibility: "private",
283 autoInit: true,
284 });
285 }),
286 );
287 expect(replaced.repoId).not.toEqual(created.repoId);
288 expect(replaced.fullName).toEqual(`${owner2}/${name}`);
289
290 // The replaced old generation must still exist in the old org.
291 const oldRepo = yield* getRepo(name, owner);
292 expect(oldRepo?.id).toEqual(created.repoId);
293
294 // Cleanup — all through the engine: adopt the retained repo back into
295 // state under a second logical ID and flip both resources to the
296 // `destroy` removal policy, so the final stack.destroy() deletes both.
297 const adopted = yield* stack.deploy(
298 Effect.gen(function* () {
299 const current = yield* GitHub.Repository("Repo", {
300 owner: owner2,
301 name,
302 description: "alchemy-effect retain-on-replace test",
303 visibility: "private",
304 }).pipe(destroy());
305
306 const old = yield* GitHub.Repository("OldRepo", {
307 owner,
308 name,
309 description: "alchemy-effect retain-on-replace test",
310 visibility: "private",
311 }).pipe(adopt(), destroy());
312
313 return { current, old };
314 }),
315 );
316
317 // The adopted resource converged onto the retained repo, not a new one.
318 expect(adopted.old.repoId).toEqual(created.repoId);
319 expect(adopted.current.repoId).toEqual(replaced.repoId);
320
321 yield* stack.destroy();
322 expect(yield* getRepo(name, owner)).toBeUndefined();
323 expect(yield* getRepo(name, owner2)).toBeUndefined();
324 }).pipe(logLevel),
325 {
326 tags: ["provider:github", "provider:github:repository", "live"],
327 timeout: 120_000,
328 },
329);
330
331// Read-only enumeration: `list()` needs no owner — it walks every repository the
332// authenticated token can see. Gated on a non-interactive token so CI never
333// stalls on the auth-method prompt; exercises the live, exhaustively-paginated
334// list path without creating a repo.
335const hasToken = !!(process.env.GITHUB_TOKEN ?? process.env.GH_TOKEN);
336test.provider.skipIf(!hasToken)(
337 "list returns the authenticated user's repositories",
338 (stack) =>
339 Effect.gen(function* () {
340 yield* stack.destroy();
341
342 const provider = yield* Provider.findProvider(GitHub.Repository);
343 const all = yield* provider.list();
344
345 expect(Array.isArray(all)).toBe(true);
346 for (const repo of all) {
347 expect(typeof repo.repoId).toBe("number");
348 expect(typeof repo.fullName).toBe("string");
349 expect(repo.fullName).toContain("/");
350 }
351
352 yield* stack.destroy();
353 }).pipe(logLevel),
354 {
355 tags: ["provider:github", "provider:github:repository", "live"],
356 timeout: 120_000,
357 },
358);