1import { GitHubCredentials } from "@/GitHub/Credentials.ts";
2import * as GitHub from "@/GitHub/index.ts";
3import { Octokit } from "@/GitHub/Octokit.ts";
4import * as Output from "@/Output.ts";
5import * as Provider from "@/Provider.ts";
6import { destroy } from "@/RemovalPolicy.ts";
7import * as Test from "@/Test/Alchemy.ts";
8import { expect } from "alchemy-test";
9import * as Effect from "effect/Effect";
10
11const owner = process.env.GITHUB_TEST_OWNER ?? "alchemy-run-test";
12if (!["alchemy-run-test", "alchemy-run-test-2"].includes(owner)) {
13 throw new Error(
14 "GITHUB_TEST_OWNER must be alchemy-run-test or alchemy-run-test-2",
15 );
16}
17
18const { test } = Test.make({
19 providers: GitHub.providers({ baseUrl: "github.com" }),
20});
21
22const repositoryName = (fixture: string) =>
23 `alchemy-pr-1566-milestone-${fixture}`;
24
25// Repositories are retained because the test token does not have delete_repo.
26const repository = (name: string) =>
27 GitHub.Repository("Repo", {
28 owner,
29 name: repositoryName(name),
30 visibility: "public",
31 autoInit: true,
32 });
33
34const fixture = (
35 name: string,
36 props: Omit<GitHub.MilestoneProps, "owner" | "repository">,
37) =>
38 Effect.gen(function* () {
39 const repo = yield* repository(name);
40 return yield* GitHub.Milestone("Milestone", {
41 ...props,
42 owner,
43 repository: Output.map(
44 repo.fullName,
45 (fullName) => fullName.split("/")[1]!,
46 ),
47 }).pipe(destroy());
48 });
49
50const listMilestones = (fixture: string) =>
51 Effect.gen(function* () {
52 const octokit = yield* Octokit;
53 return yield* Effect.tryPromise({
54 try: () =>
55 octokit.paginate(octokit.rest.issues.listMilestones, {
56 owner,
57 repo: repositoryName(fixture),
58 state: "all",
59 per_page: 100,
60 }),
61 catch: (error) => error as Error,
62 });
63 });
64
65const verifyDeleted = (fixture: string) =>
66 Effect.gen(function* () {
67 expect(yield* listMilestones(fixture)).toEqual([]);
68 });
69
70test.provider(
71 "create and update milestone",
72 (stack) =>
73 Effect.gen(function* () {
74 yield* stack.destroy();
75 const created = yield* stack.deploy(
76 fixture("update", {
77 title: "v1.0.0",
78 description: "First release",
79 }),
80 );
81 expect(created.title).toBe("v1.0.0");
82 expect(created.state).toBe("open");
83 expect(created.description).toBe("First release");
84 const updated = yield* stack.deploy(
85 fixture("update", {
86 title: "v1.0.0",
87 description: "Updated: Bug fixes and improvements",
88 dueOn: "2027-12-31",
89 }),
90 );
91 expect(updated.milestoneNumber).toBe(created.milestoneNumber);
92 expect(updated.description).toBe("Updated: Bug fixes and improvements");
93 expect(updated.dueOn).toBe("2027-12-31T00:00:00Z");
94 const observed = yield* listMilestones("update");
95 expect(observed).toHaveLength(1);
96 expect(observed[0]?.description).toBe(updated.description);
97 expect(observed[0]?.due_on).toBe(updated.dueOn);
98 yield* stack.deploy(repository("update"));
99 yield* verifyDeleted("update");
100 yield* stack.destroy();
101 }),
102 {
103 tags: [
104 "provider:github",
105 "provider:github:milestone",
106 "provider:github:repository",
107 "live",
108 ],
109 timeout: 120_000,
110 },
111);
112
113test.provider(
114 "close and reopen milestone",
115 (stack) =>
116 Effect.gen(function* () {
117 yield* stack.destroy();
118 const created = yield* stack.deploy(
119 fixture("state", { title: "Release v2.0", state: "open" }),
120 );
121 expect(created.state).toBe("open");
122 expect(created.closedAt).toBe(null);
123 const closed = yield* stack.deploy(
124 fixture("state", { title: "Release v2.0", state: "closed" }),
125 );
126 expect(closed.milestoneNumber).toBe(created.milestoneNumber);
127 expect(closed.state).toBe("closed");
128 expect(closed.closedAt).not.toBe(null);
129 expect((yield* listMilestones("state"))[0]?.state).toBe("closed");
130 const reopened = yield* stack.deploy(
131 fixture("state", { title: "Release v2.0", state: "open" }),
132 );
133 expect(reopened.milestoneNumber).toBe(created.milestoneNumber);
134 expect(reopened.state).toBe("open");
135 expect(reopened.closedAt).toBe(null);
136 expect((yield* listMilestones("state"))[0]?.state).toBe("open");
137 yield* stack.deploy(repository("state"));
138 yield* verifyDeleted("state");
139 yield* stack.destroy();
140 }),
141 {
142 tags: [
143 "provider:github",
144 "provider:github:milestone",
145 "provider:github:repository",
146 "live",
147 ],
148 timeout: 120_000,
149 },
150);
151
152test.provider(
153 "replace milestone when title changes",
154 (stack) =>
155 Effect.gen(function* () {
156 yield* stack.destroy();
157 const created = yield* stack.deploy(
158 fixture("replace", {
159 title: "Q1 2026",
160 description: "First quarter goals",
161 }),
162 );
163 expect(created.title).toBe("Q1 2026");
164 const replaced = yield* stack.deploy(
165 fixture("replace", {
166 title: "Q1 2027",
167 description: "Updated quarter goals",
168 }),
169 );
170 expect(replaced.title).toBe("Q1 2027");
171 expect(replaced.milestoneNumber).not.toBe(created.milestoneNumber);
172 const observed = yield* listMilestones("replace");
173 expect(observed.map((milestone) => milestone.number)).toEqual([
174 replaced.milestoneNumber,
175 ]);
176 yield* stack.deploy(repository("replace"));
177 yield* verifyDeleted("replace");
178 yield* stack.destroy();
179 }),
180 {
181 tags: [
182 "provider:github",
183 "provider:github:milestone",
184 "provider:github:repository",
185 "live",
186 ],
187 timeout: 120_000,
188 },
189);
190
191test.provider(
192 "list enumeration includes deployed milestone",
193 (stack) =>
194 Effect.gen(function* () {
195 yield* stack.destroy();
196 const created = yield* stack.deploy(
197 fixture("list", {
198 title: "PR 1566 list milestone",
199 description: "For list test",
200 }),
201 );
202 const credentials = yield* yield* GitHubCredentials;
203 const client = credentials.octokit({ baseUrl: undefined });
204 client.hook.before("request", (options) => {
205 const url = new URL(options.url, "https://api.github.com");
206 if (url.pathname === "/user/repos") {
207 url.pathname = `/orgs/${owner}/repos`;
208 options.url = url.toString();
209 }
210 });
211 const provider = yield* Provider.findProvider(GitHub.Milestone);
212 const allMilestones = yield* provider
213 .list()
214 .pipe(
215 Effect.provideService(
216 GitHubCredentials,
217 Effect.succeed({ ...credentials, octokit: () => client }),
218 ),
219 );
220 const found = allMilestones.find(
221 (milestone) => milestone.nodeId === created.nodeId,
222 );
223 expect(found).toBeDefined();
224 expect(found?.title).toBe(created.title);
225 expect(found?.htmlUrl).toBe(created.htmlUrl);
226 yield* stack.deploy(repository("list"));
227 yield* verifyDeleted("list");
228 yield* stack.destroy();
229 }),
230 {
231 tags: [
232 "provider:github",
233 "provider:github:milestone",
234 "provider:github:repository",
235 "live",
236 ],
237 timeout: 120_000,
238 },
239);
240
241test.provider(
242 "milestone with due date",
243 (stack) =>
244 Effect.gen(function* () {
245 yield* stack.destroy();
246 const created = yield* stack.deploy(
247 fixture("duedate", {
248 title: "Sprint 1",
249 description: "Complete authentication",
250 dueOn: "2026-12-31",
251 }),
252 );
253 expect(created.title).toBe("Sprint 1");
254 expect(created.dueOn).toBe("2026-12-31T00:00:00Z");
255 expect((yield* listMilestones("duedate"))[0]?.due_on).toBe(created.dueOn);
256 yield* stack.deploy(repository("duedate"));
257 yield* verifyDeleted("duedate");
258 yield* stack.destroy();
259 }),
260 {
261 tags: [
262 "provider:github",
263 "provider:github:milestone",
264 "provider:github:repository",
265 "live",
266 ],
267 timeout: 120_000,
268 },
269);
270
271test.provider(
272 "removing optional properties restores defaults",
273 (stack) =>
274 Effect.gen(function* () {
275 yield* stack.destroy();
276 const created = yield* stack.deploy(
277 fixture("defaults", {
278 title: "Defaults",
279 state: "closed",
280 description: "Remove me",
281 dueOn: "2027-12-31",
282 }),
283 );
284 const updated = yield* stack.deploy(
285 fixture("defaults", { title: "Defaults" }),
286 );
287 expect(updated.milestoneNumber).toBe(created.milestoneNumber);
288 expect(updated.state).toBe("open");
289 expect(updated.description ?? "").toBe("");
290 expect(updated.dueOn).toBe(null);
291 const observed = (yield* listMilestones("defaults"))[0];
292 expect(observed?.state).toBe("open");
293 expect(observed?.description ?? "").toBe("");
294 expect(observed?.due_on).toBe(null);
295 yield* stack.deploy(repository("defaults"));
296 yield* verifyDeleted("defaults");
297 yield* stack.destroy();
298 }),
299 {
300 tags: [
301 "provider:github",
302 "provider:github:milestone",
303 "provider:github:repository",
304 "live",
305 ],
306 timeout: 120_000,
307 },
308);