1import * as Effect from "effect/Effect";
2import { isResolved } from "../Diff.ts";
3import * as Provider from "../Provider.ts";
4import { Resource } from "../Resource.ts";
5import { dedent } from "../Util/dedent.ts";
6import { gitHubBaseUrlChanged, Octokit, octokitFor } from "./Octokit.ts";
7import type * as GitHub from "./Providers.ts";
8
9export interface IssueProps {
10 /**
11 * Repository owner (user or organization).
12 */
13 owner: string;
14
15 /**
16 * Repository name.
17 */
18 repository: string;
19
20 /**
21 * Issue title.
22 */
23 title: string;
24
25 /**
26 * Issue body (supports GitHub Markdown).
27 *
28 * The body is automatically dedented, so you can use indented template
29 * literals without worrying about leading whitespace. Accepts
30 * `Output<string>` at the call site via `Output.interpolate` to embed
31 * resource attributes that are not yet resolved.
32 */
33 body?: string;
34
35 /**
36 * State of the issue. Use "open" to reopen a closed issue or "closed" to
37 * close an open issue.
38 * @default "open"
39 */
40 state?: "open" | "closed";
41
42 /**
43 * Labels to attach to the issue. The provided list fully replaces any
44 * existing labels.
45 */
46 labels?: string[];
47
48 /**
49 * Assignees (user logins) to assign to the issue. The provided list fully
50 * replaces existing assignees.
51 */
52 assignees?: string[];
53
54 /**
55 * Milestone number to assign to the issue. Use `null` to remove milestone.
56 */
57 milestone?: number | null;
58
59 /**
60 * Override the GitHub host or API base URL for this resource only (e.g.
61 * `github.example.com` for GitHub Enterprise). Falls back to
62 * `GitHub.providers({ baseUrl })`, then to the host resolved by the auth
63 * provider. Changing it replaces the resource — the same name on a
64 * different GitHub instance is a different physical resource.
65 */
66 baseUrl?: string;
67}
68
69export interface Issue extends Resource<
70 "GitHub.Issue",
71 IssueProps,
72 {
73 /**
74 * The numeric ID of the issue in GitHub.
75 */
76 issueNumber: number;
77
78 /**
79 * GraphQL node ID of the issue.
80 */
81 nodeId: string;
82
83 /**
84 * URL to view the issue in a browser.
85 */
86 htmlUrl: string;
87
88 /**
89 * State of the issue (open or closed).
90 */
91 state: "open" | "closed";
92
93 /**
94 * ISO-8601 timestamp of when the issue was created.
95 */
96 createdAt: string;
97
98 /**
99 * ISO-8601 timestamp of the last update.
100 */
101 updatedAt: string;
102 },
103 never,
104 GitHub.Providers
105> {}
106
107/**
108 * A GitHub repository issue.
109 *
110 * `Issue` manages the lifecycle of a single issue in a repository. Issues are
111 * created on the first deploy and updated in place on subsequent deploys when
112 * properties change. By default, issues are retained on destruction to preserve
113 * discussion history. Pipe the resource through `destroy()` to close the issue
114 * on destruction instead; its discussion remains on GitHub.
115 *
116 * Authentication is resolved via the `GitHubCredentials` service supplied by
117 * `GitHub.providers()` (env, stored PAT, `gh` CLI, or OAuth). The token needs
118 * `repo` scope for private repositories or `public_repo` for public ones.
119 *
120 * ### Creating Issues
121 * **Example:** Create a Basic Issue
122 * ```typescript
123 * const issue = yield* GitHub.Issue("bug-report", {
124 * owner: "my-org",
125 * repository: "my-repo",
126 * title: "Bug: Application crashes on startup",
127 * body: "## Description\n\nThe application crashes when...",
128 * })
129 * ```
130 *
131 * **Example:** Issue with Labels and Assignees
132 * ```typescript
133 * const issue = yield* GitHub.Issue("feature-request", {
134 * owner: "my-org",
135 * repository: "my-repo",
136 * title: "Feature: Add dark mode",
137 * body: "Users have requested...",
138 * labels: ["enhancement", "ui"],
139 * assignees: ["developer1"],
140 * })
141 * ```
142 *
143 * ### Updating Issues
144 * Deploy with the same logical ID and different properties to update the
145 * existing issue in place rather than creating a new one.
146 *
147 * **Example:** Update Issue State
148 * ```typescript
149 * const issue = yield* GitHub.Issue("resolved-bug", {
150 * owner: "my-org",
151 * repository: "my-repo",
152 * title: "Bug: Application crashes on startup",
153 * body: "This has been resolved.",
154 * state: "closed",
155 * })
156 * ```
157 *
158 * ### Issue with Milestone
159 * **Example:** Assign to Milestone
160 * ```typescript
161 * const issue = yield* GitHub.Issue("v1-task", {
162 * owner: "my-org",
163 * repository: "my-repo",
164 * title: "Implement authentication",
165 * milestone: 1,
166 * })
167 * ```
168 *
169 * ### Tracking Infrastructure Changes
170 * A common pattern is creating issues to track infrastructure changes or
171 * deployment status.
172 *
173 * **Example:** Infrastructure Status Issue
174 * ```typescript
175 * yield* GitHub.Issue("infra-status", {
176 * owner: "my-org",
177 * repository: "my-repo",
178 * title: "Infrastructure Status",
179 * body: Output.interpolate`
180 * ## Current Status
181 *
182 * **Database:** ${database.endpoint}
183 * **Cache:** ${cache.endpoint}
184 * `,
185 * labels: ["infrastructure"],
186 * })
187 * ```
188 *
189 * @resource
190 * @product Issue
191 */
192export const Issue = Resource<Issue>("GitHub.Issue", {
193 defaultRemovalPolicy: "retain",
194});
195
196export const IssueProvider = () =>
197 Provider.succeed(Issue, {
198 stables: ["issueNumber", "nodeId"],
199
200 // An issue belongs to (host, owner, repository) — its server-assigned
201 // number is meaningless elsewhere, so moving it replaces the resource:
202 // a fresh issue is created on the new repository, and the old one is
203 // retained by default (matching the resource's `retain` removal policy).
204 diff: Effect.fn(function* ({ news, olds }) {
205 if (!isResolved(news)) return;
206 if (olds === undefined) return;
207 if (
208 news.owner !== olds.owner ||
209 news.repository !== olds.repository ||
210 (yield* gitHubBaseUrlChanged(olds, news))
211 ) {
212 return { action: "replace" };
213 }
214 }),
215
216 reconcile: Effect.fn(function* ({ news, output }) {
217 const octokit = yield* octokitFor(news.baseUrl);
218 const body = news.body ? dedent(news.body) : "";
219 const state = news.state ?? "open";
220 const labels = news.labels ?? [];
221 const assignees = news.assignees ?? [];
222 const milestone = news.milestone ?? null;
223
224 let data =
225 output?.issueNumber !== undefined
226 ? yield* Effect.tryPromise({
227 try: () =>
228 octokit.rest.issues.get({
229 owner: news.owner,
230 repo: news.repository,
231 issue_number: output.issueNumber,
232 }),
233 catch: (error) => error as Error & { status?: number },
234 }).pipe(
235 Effect.map((response) => response.data),
236 Effect.catchIf(
237 (error) => error.status === 404,
238 () => Effect.succeed(undefined),
239 ),
240 )
241 : undefined;
242
243 if (data === undefined) {
244 const created = yield* Effect.tryPromise(() =>
245 octokit.rest.issues.create({
246 owner: news.owner,
247 repo: news.repository,
248 title: news.title,
249 body,
250 labels,
251 assignees,
252 milestone: milestone ?? undefined,
253 }),
254 );
255 data = created.data;
256 }
257
258 const sameNames = (observed: string[], desired: string[]) =>
259 JSON.stringify(observed.map((name) => name.toLowerCase()).sort()) ===
260 JSON.stringify(desired.map((name) => name.toLowerCase()).sort());
261
262 // Creation always opens an issue; sync also applies the desired initial state.
263 if (
264 data.title !== news.title ||
265 (data.body ?? "") !== body ||
266 data.state !== state ||
267 !sameNames(
268 data.labels.map((label) =>
269 typeof label === "string" ? label : (label.name ?? ""),
270 ),
271 labels,
272 ) ||
273 !sameNames(
274 (data.assignees ?? []).map((assignee) => assignee.login),
275 assignees,
276 ) ||
277 (data.milestone?.number ?? null) !== milestone
278 ) {
279 const issueNumber = data.number;
280 const updated = yield* Effect.tryPromise(() =>
281 octokit.rest.issues.update({
282 owner: news.owner,
283 repo: news.repository,
284 issue_number: issueNumber,
285 title: news.title,
286 body,
287 state,
288 labels,
289 assignees,
290 milestone,
291 }),
292 );
293 data = updated.data;
294 }
295 return {
296 issueNumber: data.number,
297 nodeId: data.node_id,
298 htmlUrl: data.html_url,
299 state: data.state as "open" | "closed",
300 createdAt: data.created_at,
301 updatedAt: data.updated_at,
302 };
303 }),
304
305 // Enumerate every issue across the repositories the token can see.
306 list: Effect.fn(function* () {
307 const octokit = yield* Octokit;
308
309 const repos = yield* Effect.tryPromise({
310 try: () =>
311 octokit.paginate(octokit.rest.repos.listForAuthenticatedUser, {
312 per_page: 100,
313 }),
314 catch: (e) => e as Error,
315 });
316
317 const perRepo = yield* Effect.forEach(
318 repos,
319 (repo) =>
320 Effect.tryPromise({
321 try: async () => {
322 try {
323 const issues = await octokit.paginate(
324 octokit.rest.issues.listForRepo,
325 {
326 owner: repo.owner.login,
327 repo: repo.name,
328 state: "all",
329 per_page: 100,
330 },
331 );
332 // Filter out pull requests (they appear in issues API)
333 return issues
334 .filter((issue) => !issue.pull_request)
335 .map((issue) => ({
336 issueNumber: issue.number,
337 nodeId: issue.node_id,
338 htmlUrl: issue.html_url,
339 state: issue.state as "open" | "closed",
340 createdAt: issue.created_at,
341 updatedAt: issue.updated_at,
342 }));
343 } catch (error: any) {
344 if (error.status === 403 || error.status === 404) {
345 return [];
346 }
347 throw error;
348 }
349 },
350 catch: (e) => e as Error,
351 }),
352 { concurrency: 10 },
353 );
354
355 return perRepo.flat();
356 }),
357
358 delete: Effect.fn(function* ({ olds, output }) {
359 const octokit = yield* octokitFor(olds.baseUrl);
360
361 // Opted-in destruction closes the issue without deleting its discussion.
362 if (output?.issueNumber !== undefined) {
363 yield* Effect.tryPromise({
364 try: () =>
365 octokit.rest.issues.update({
366 owner: olds.owner,
367 repo: olds.repository,
368 issue_number: output.issueNumber,
369 state: "closed",
370 }),
371 catch: (error) => error as Error & { status?: number },
372 }).pipe(
373 Effect.catchIf(
374 (error) => error.status === 404,
375 () => Effect.void,
376 ),
377 );
378 }
379 }),
380 });