1import * as GitHub from "@/GitHub";
2import { GitHubCredentials } from "@/GitHub/Credentials.ts";
3import { Octokit } from "@/GitHub/Octokit.ts";
4import * as Output from "@/Output";
5import * as Provider from "@/Provider";
6import { destroy } from "@/RemovalPolicy";
7import * as Test from "@/Test/Alchemy";
8import { expect } from "alchemy-test";
9import * as Effect from "effect/Effect";
10import * as Schedule from "effect/Schedule";
11
12const owner = process.env.GITHUB_TEST_OWNER ?? "alchemy-run-test";
13if (owner !== "alchemy-run-test" && owner !== "alchemy-run-test-2") {
14 throw new Error(`Unsafe GITHUB_TEST_OWNER: ${owner}`);
15}
16
17const { test } = Test.make({
18 providers: GitHub.providers({ baseUrl: "github.com" }),
19});
20
21const repository = (id: string, name: string) =>
22 GitHub.Repository(id, {
23 owner,
24 name,
25 description: "Alchemy PR 1568 Issue integration fixture (retained)",
26 visibility: "private",
27 hasIssues: true,
28 autoInit: true,
29 });
30
31const repoName = (repo: GitHub.Repository) =>
32 Output.map(repo.fullName, (fullName) => fullName.split("/")[1]!);
33
34const getIssue = (repo: string, issueNumber: number) =>
35 Effect.gen(function* () {
36 const octokit = yield* Octokit;
37 const { data } = yield* Effect.tryPromise(() =>
38 octokit.rest.issues.get({ owner, repo, issue_number: issueNumber }),
39 );
40 return data;
41 });
42
43test.provider(
44 "create closed, update, list, and close an issue on destroy",
45 (stack) =>
46 Effect.gen(function* () {
47 yield* stack.destroy();
48 const name = "alchemy-pr-1568-issue-lifecycle";
49 const deployIssue = (
50 props: Omit<GitHub.IssueProps, "owner" | "repository">,
51 ) =>
52 stack.deploy(
53 Effect.gen(function* () {
54 // Repository deletion needs delete_repo; reuse this retained fixture.
55 const repo = yield* repository("Repo", name);
56 return yield* GitHub.Issue("Issue", {
57 owner,
58 repository: repoName(repo),
59 ...props,
60 }).pipe(destroy());
61 }),
62 );
63
64 const created = yield* deployIssue({
65 title: "PR 1568: initially closed",
66 body: "\n Initial body\n Second line\n",
67 labels: ["bug"],
68 state: "closed",
69 });
70 expect(created.issueNumber).toBeGreaterThan(0);
71 expect(created.nodeId).toBeTruthy();
72 expect(created.htmlUrl).toBe(
73 `https://github.com/${owner}/${name}/issues/${created.issueNumber}`,
74 );
75 expect(created.state).toBe("closed");
76 const initial = yield* getIssue(name, created.issueNumber);
77 expect(initial.state).toBe("closed");
78 expect(initial.body).toBe("Initial body\nSecond line");
79 expect(
80 initial.labels.map((label) =>
81 typeof label === "string" ? label : label.name,
82 ),
83 ).toEqual(["bug"]);
84
85 const updated = yield* deployIssue({ title: "PR 1568: reopened" });
86 expect(updated.issueNumber).toBe(created.issueNumber);
87 expect(updated.nodeId).toBe(created.nodeId);
88 expect(updated.state).toBe("open");
89 const fetched = yield* getIssue(name, updated.issueNumber);
90 expect(fetched.title).toBe("PR 1568: reopened");
91 expect(fetched.body ?? "").toBe("");
92 expect(fetched.labels).toEqual([]);
93 expect(fetched.assignees).toEqual([]);
94 expect(fetched.milestone).toBeNull();
95
96 const credentials = yield* yield* GitHubCredentials;
97 const provider = yield* Provider.findProvider(GitHub.Issue);
98 const listed = yield* provider.list().pipe(
99 Effect.provideService(
100 GitHubCredentials,
101 Effect.succeed({
102 ...credentials,
103 octokit: (override) => {
104 const octokit = credentials.octokit(override);
105 octokit.hook.before("request", (options) => {
106 const url = new URL(options.url, "https://api.github.com");
107 if (url.pathname === "/user/repos") {
108 url.pathname = `/orgs/${owner}/repos`;
109 options.url = url.toString();
110 }
111 if (
112 url.origin !== "https://api.github.com" ||
113 (url.pathname !== `/orgs/${owner}/repos` &&
114 url.pathname !== `/repos/${owner}/${name}/issues`)
115 ) {
116 throw new Error(`Unsafe Issue list request: ${url}`);
117 }
118 });
119 octokit.hook.after("request", (response, options) => {
120 const url = new URL(options.url, "https://api.github.com");
121 if (url.pathname === `/orgs/${owner}/repos`) {
122 response.data = (
123 response.data as Array<{ name: string }>
124 ).filter((repo) => repo.name === name);
125 }
126 });
127 return octokit;
128 },
129 }),
130 ),
131 Effect.repeat({
132 schedule: Schedule.spaced("2 seconds"),
133 times: 10,
134 until: (issues) =>
135 issues.some((issue) => issue.nodeId === created.nodeId),
136 }),
137 );
138 expect(listed.map((issue) => issue.nodeId)).toContain(created.nodeId);
139
140 yield* stack.destroy();
141 expect((yield* getIssue(name, created.issueNumber)).state).toBe("closed");
142 yield* stack.destroy();
143 }),
144 {
145 tags: [
146 "provider:github",
147 "provider:github:issue",
148 "provider:github:repository",
149 "live",
150 ],
151 timeout: 120_000,
152 },
153);
154
155test.provider(
156 "repository changes replace the issue and close the old generation",
157 (stack) =>
158 Effect.gen(function* () {
159 yield* stack.destroy();
160 const firstName = "alchemy-pr-1568-issue-replace-a";
161 const secondName = "alchemy-pr-1568-issue-replace-b";
162 const deployIssue = (target: "first" | "second") =>
163 stack.deploy(
164 Effect.gen(function* () {
165 // Keep both dependencies across replacement; retain the fixture repos.
166 const first = yield* repository("FirstRepo", firstName);
167 const second = yield* repository("SecondRepo", secondName);
168 return yield* GitHub.Issue("Issue", {
169 owner,
170 repository: repoName(target === "first" ? first : second),
171 title: "PR 1568: repository replacement",
172 }).pipe(destroy());
173 }),
174 );
175
176 const created = yield* deployIssue("first");
177 expect((yield* getIssue(firstName, created.issueNumber)).state).toBe(
178 "open",
179 );
180 const replaced = yield* deployIssue("second");
181 expect(replaced.nodeId).not.toBe(created.nodeId);
182 expect(replaced.htmlUrl).toContain(`/${secondName}/issues/`);
183 expect((yield* getIssue(firstName, created.issueNumber)).state).toBe(
184 "closed",
185 );
186 expect((yield* getIssue(secondName, replaced.issueNumber)).state).toBe(
187 "open",
188 );
189
190 yield* stack.destroy();
191 expect((yield* getIssue(secondName, replaced.issueNumber)).state).toBe(
192 "closed",
193 );
194 yield* stack.destroy();
195 }),
196 {
197 tags: [
198 "provider:github",
199 "provider:github:issue",
200 "provider:github:repository",
201 "live",
202 ],
203 timeout: 120_000,
204 },
205);