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, octokitFor } from "./Octokit.ts";
7import * as GitHub from "./Providers.ts";
8
9export interface CommentProps {
10 /**
11 * Repository owner (user or organization).
12 */
13 owner: string;
14
15 /**
16 * Repository name.
17 */
18 repository: string;
19
20 /**
21 * Issue or Pull Request number to comment on.
22 */
23 issueNumber: number;
24
25 /**
26 * Comment 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 * Whether to allow deletion of the comment when the resource is destroyed.
37 * By default, comments are never deleted to preserve discussion history.
38 * @default false
39 */
40 allowDelete?: boolean;
41
42 /**
43 * Override the GitHub host or API base URL for this resource only (e.g.
44 * `github.example.com` for GitHub Enterprise). Falls back to
45 * `GitHub.providers({ baseUrl })`, then to the host resolved by the auth
46 * provider. Changing it replaces the resource — the same name on a
47 * different GitHub instance is a different physical resource.
48 */
49 baseUrl?: string;
50}
51
52export interface Comment extends Resource<
53 "GitHub.Comment",
54 CommentProps,
55 {
56 /**
57 * The numeric ID of the comment in GitHub.
58 */
59 commentId: number;
60
61 /**
62 * URL to view the comment in a browser.
63 */
64 htmlUrl: string;
65
66 /**
67 * ISO-8601 timestamp of the last update.
68 */
69 updatedAt: string;
70 },
71 never,
72 GitHub.Providers
73> {}
74
75/**
76 * A GitHub issue or pull request comment.
77 *
78 * `Comment` manages the lifecycle of a single comment on an issue or pull
79 * request. Comments are created on the first deploy and updated in place on
80 * subsequent deploys when the `body` changes. By default, comments are never
81 * deleted to preserve discussion history — set `allowDelete: true` to opt in.
82 *
83 * Authentication is resolved in order: explicit `token` prop,
84 * `GITHUB_ACCESS_TOKEN` env var, `GITHUB_TOKEN` env var. The token needs
85 * `repo` scope for private repositories or `public_repo` for public ones.
86 * ### Creating Comments
87 * **Example:** Comment on an Issue
88 * ```typescript
89 * const comment = yield* GitHub.Comment("issue-comment", {
90 * owner: "my-org",
91 * repository: "my-repo",
92 * issueNumber: 123,
93 * body: "This is a comment created by Alchemy!",
94 * });
95 * ```
96 *
97 * **Example:** Comment on a Pull Request
98 * ```typescript
99 * const prComment = yield* GitHub.Comment("pr-comment", {
100 * owner: "my-org",
101 * repository: "my-repo",
102 * issueNumber: 456,
103 * body: "## Deployment Status\n\nSuccessfully deployed to staging!",
104 * });
105 * ```
106 *
107 * ### Updating Comments
108 * Deploy with the same logical ID and a different `body` to update the
109 * existing comment in place rather than creating a new one.
110 *
111 * **Example:** Update Comment Content
112 * ```typescript
113 * const comment = yield* GitHub.Comment("status-comment", {
114 * owner: "my-org",
115 * repository: "my-repo",
116 * issueNumber: 789,
117 * body: "Deployment completed successfully!",
118 * });
119 * ```
120 *
121 * ### Deleting Comments
122 * **Example:** Allow Comment Deletion
123 * ```typescript
124 * const comment = yield* GitHub.Comment("temp-comment", {
125 * owner: "my-org",
126 * repository: "my-repo",
127 * issueNumber: 123,
128 * body: "This comment can be deleted",
129 * allowDelete: true,
130 * });
131 * ```
132 *
133 * ### CI Preview Comments
134 * A common pattern is posting a preview-deployment URL on every pull request.
135 * The comment auto-updates on each push because the logical ID stays the same.
136 *
137 * **Example:** PR Preview Comment
138 * ```typescript
139 * if (process.env.PULL_REQUEST) {
140 * yield* GitHub.Comment("preview-comment", {
141 * owner: "my-org",
142 * repository: "my-repo",
143 * issueNumber: Number(process.env.PULL_REQUEST),
144 * body: Output.interpolate`
145 * ## Preview Deployed
146 *
147 * **URL:** ${website.url}
148 * `,
149 * });
150 * }
151 * ```
152 *
153 * @resource
154 * @product Issue
155 */
156export const Comment = Resource<Comment>("GitHub.Comment");
157
158export const CommentProvider = () =>
159 Provider.succeed(Comment, {
160 stables: ["commentId"],
161 // Non-listable: a Comment is identified entirely by its parent
162 // {owner, repository, issueNumber} plus the server-assigned commentId.
163 // GitHub only exposes comment enumeration *within* a specific issue or PR
164 // (`issues.listComments`); there is no account- or repo-wide API to
165 // enumerate every comment without first knowing the issue/PR. With no
166 // ambient scope to enumerate from, this collapses to the empty list.
167 list: () => Effect.succeed([]),
168
169 // A comment belongs to (host, owner, repository, issueNumber) — its
170 // server-assigned id is meaningless anywhere else, so moving it replaces
171 // the resource: a fresh comment is posted on the new issue, and the old
172 // one is deleted only when `allowDelete` is set (the provider's `delete`
173 // no-ops otherwise, preserving discussion history — the default).
174 diff: Effect.fn(function* ({ news, olds }) {
175 if (!isResolved(news)) return;
176 if (olds === undefined) return;
177 if (
178 news.owner !== olds.owner ||
179 news.repository !== olds.repository ||
180 news.issueNumber !== olds.issueNumber ||
181 (yield* gitHubBaseUrlChanged(olds, news))
182 ) {
183 return { action: "replace" };
184 }
185 }),
186
187 reconcile: Effect.fn(function* ({ news, output }) {
188 const octokit = yield* octokitFor(news.baseUrl);
189 const body = dedent(news.body);
190
191 // Observe — GitHub assigns `comment_id` server-side. Probe for live
192 // state via the cached id; a 404 (deleted out-of-band, or never
193 // created) collapses to "no observed comment" so we converge by
194 // posting a fresh one.
195 const observedId = output?.commentId
196 ? yield* Effect.tryPromise({
197 try: async () => {
198 try {
199 const { data } = await octokit.rest.issues.getComment({
200 owner: news.owner,
201 repo: news.repository,
202 comment_id: output.commentId,
203 });
204 return data.id;
205 } catch (error: any) {
206 if (error.status === 404) return undefined;
207 throw error;
208 }
209 },
210 catch: (e) => e as Error,
211 })
212 : undefined;
213
214 // Ensure — when no live comment exists, POST creates one.
215 if (observedId === undefined) {
216 const { data } = yield* Effect.tryPromise(() =>
217 octokit.rest.issues.createComment({
218 owner: news.owner,
219 repo: news.repository,
220 issue_number: news.issueNumber,
221 body,
222 }),
223 );
224 return {
225 commentId: data.id,
226 htmlUrl: data.html_url,
227 updatedAt: data.updated_at,
228 };
229 }
230
231 // Sync — PATCH the existing comment with the desired body. GitHub's
232 // updateComment is idempotent for identical bodies (returns same
233 // updatedAt), so we always issue the call rather than diffing.
234 const { data } = yield* Effect.tryPromise(() =>
235 octokit.rest.issues.updateComment({
236 owner: news.owner,
237 repo: news.repository,
238 comment_id: observedId,
239 body,
240 }),
241 );
242 return {
243 commentId: data.id,
244 htmlUrl: data.html_url,
245 updatedAt: data.updated_at,
246 };
247 }),
248
249 delete: Effect.fn(function* ({ olds, output }) {
250 if (!olds.allowDelete) {
251 return;
252 }
253
254 const octokit = yield* octokitFor(olds.baseUrl);
255
256 yield* Effect.tryPromise(async () => {
257 try {
258 await octokit.rest.issues.deleteComment({
259 owner: olds.owner,
260 repo: olds.repository,
261 comment_id: output.commentId,
262 });
263 } catch (error: any) {
264 if (error.status !== 404) {
265 throw error;
266 }
267 }
268 });
269 }),
270 });