1import * as ssm from "@distilled.cloud/gcp/securesourcemanager_v1";
2import * as Effect from "effect/Effect";
3import { Unowned } from "../../AdoptPolicy.ts";
4import { isResolved } from "../../Diff.ts";
5import * as Provider from "../../Provider.ts";
6import { Resource } from "../../Resource.ts";
7import { GcpEnvironment } from "../Environment.ts";
8import { hasAlchemyLabels } from "../Labels.ts";
9import type { Providers } from "../Providers.ts";
10import {
11 catchMissing,
12 collectPages,
13 expandName,
14 forEachRepository,
15 hasOwnershipMarker,
16 nameFromOperation,
17 normalizeLocation,
18 ownedByAlchemy,
19 PAGE_SIZE,
20 parseName,
21 parseOwnership,
22 replaceOnIdentity,
23 ResourceNotResolved,
24 retryConflict,
25 sameText,
26 waitForOperation,
27 waitUntilExists,
28 waitUntilGone,
29} from "./internal.ts";
30
31export type RepositoriesIssuesIssueCommentProps = {
32 /**
33 * Parent issue. Full name
34 * `projects/{project}/locations/{location}/repositories/{repository}/issues/{issue}`
35 * or the issue id (combined with `repository`). Immutable — changing
36 * it replaces the comment.
37 */
38 issue: string;
39 /**
40 * Parent repository used when `issue` is a bare id.
41 */
42 repository?: string;
43 /**
44 * Region used when `issue` or `repository` is a bare id.
45 * @default the stack's GCP region (`GCP.Region`, else the profile region, else `us-central1`)
46 */
47 location?: string;
48 /**
49 * Server-assigned comment id. Set after create. Immutable — changing
50 * it replaces the comment.
51 */
52 commentId?: string;
53 /**
54 * Comment body, posted as given. Comments have no labels, so Alchemy
55 * tracks the comment by its server-assigned id.
56 */
57 body: string;
58};
59
60export type RepositoriesIssuesIssueComment = Resource<
61 "GCP.SecureSourceManager.RepositoriesIssuesIssueComment",
62 RepositoriesIssuesIssueCommentProps,
63 {
64 /** Full resource name. */
65 name: string;
66 /** Server-assigned comment id. */
67 commentId: string;
68 /** Parent issue resource name. */
69 issue: string;
70 /** Project id. */
71 project: string;
72 /** Location id. */
73 location: string;
74 /** Comment body with the Alchemy ownership prefix stripped. */
75 body: string;
76 /** RFC3339 creation timestamp. */
77 createTime: string | undefined;
78 /** RFC3339 last-update timestamp. */
79 updateTime: string | undefined;
80 },
81 never,
82 Providers
83>;
84
85/**
86 * A comment on a Secure Source Manager issue.
87 *
88 * Comment ids are server-assigned. Changing `commentId` or `issue`
89 * replaces the comment. Body updates in place. Ownership for `list` /
90 * nuke is stamped into the body (comments have no labels field).
91 *
92 * ### Creating an Issue Comment
93 * **Example:** Reply on an issue
94 * ```typescript
95 * const comment = yield* GCP.SecureSourceManager.RepositoriesIssuesIssueComment(
96 * "Note",
97 * {
98 * issue: issue.name,
99 * body: "reproduced on main",
100 * },
101 * );
102 * ```
103 *
104 * ### Updating an Issue Comment
105 * Re-declare the same logical id with changed props; the engine keeps the
106 * physical resource and updates it in place.
107 *
108 * **Example:** Edit the body
109 * ```typescript
110 * const comment = yield* GCP.SecureSourceManager.RepositoriesIssuesIssueComment(
111 * "Note",
112 * {
113 * issue: issue.name,
114 * body: "reproduced on release",
115 * },
116 * );
117 * ```
118 *
119 * @resource
120 * @category SecureSourceManager
121 */
122export const RepositoriesIssuesIssueComment =
123 Resource<RepositoriesIssuesIssueComment>(
124 "GCP.SecureSourceManager.RepositoriesIssuesIssueComment",
125 );
126
127const resourceName = (issue: string, commentId: string) =>
128 `${issue}/issueComments/${commentId}`;
129
130const expandIssue = (
131 issue: string,
132 repository: string | undefined,
133 project: string,
134 location: string,
135) => {
136 const next = issue.replace(/\/+$/, "");
137 if (next.includes("/")) return next;
138 const repo = expandName(repository ?? "", project, location, "repositories");
139 return repo.length > 0 ? `${repo}/issues/${next}` : next;
140};
141
142const toAttrs = (item: ssm.IssueComment, project: string, region: string) => {
143 const name = item.name ?? "";
144 const parsed = parseName(name, "issueComments", region);
145 const body = parseOwnership(item.body);
146 return {
147 name,
148 commentId: parsed.id,
149 issue: parsed.parent,
150 project: parsed.project || project,
151 location: parsed.location,
152 body: body.text ?? "",
153 createTime: item.createTime,
154 updateTime: item.updateTime,
155 };
156};
157
158const getByName = (name: string) =>
159 name.length === 0
160 ? Effect.succeed(undefined)
161 : catchMissing(
162 ssm.getProjectsLocationsRepositoriesIssuesIssueComments({ name }),
163 );
164
165const listOnIssue = (issue: string) =>
166 collectPages(
167 ssm.listProjectsLocationsRepositoriesIssuesIssueComments.pages({
168 parent: issue,
169 pageSize: PAGE_SIZE,
170 }),
171 (page) => page.issueComments,
172 );
173
174const listOnRepository = (repository: string) =>
175 Effect.gen(function* () {
176 const issues = yield* collectPages(
177 ssm.listProjectsLocationsRepositoriesIssues.pages({
178 parent: repository,
179 pageSize: PAGE_SIZE,
180 }),
181 (page) => page.issues,
182 );
183 const pages = yield* Effect.forEach(
184 issues.filter((issue) => (issue.name ?? "").length > 0),
185 (issue) => listOnIssue(issue.name!),
186 { concurrency: 4 },
187 );
188 return pages.flat();
189 });
190
191/**
192 * A comment on the issue with this body (or one carrying a legacy
193 * ownership marker for `id`). Recovers a comment whose create succeeded but
194 * was never recorded.
195 */
196const findMatching = (issue: string, id: string, body: string) =>
197 Effect.gen(function* () {
198 if (issue.length === 0) return undefined;
199 const items = yield* listOnIssue(issue);
200 for (const item of items) {
201 if (yield* ownedByAlchemy(id, item.body)) return item;
202 if (sameText(item.body, body)) return item;
203 }
204 return undefined;
205 });
206
207const listOwned = (project: string, region: string) =>
208 forEachRepository(project, region, (repository) =>
209 listOnRepository(repository).pipe(
210 Effect.map((items) =>
211 items.filter((item) => hasOwnershipMarker(item.body)),
212 ),
213 ),
214 );
215
216export const RepositoriesIssuesIssueCommentProvider = () =>
217 Provider.succeed(RepositoriesIssuesIssueComment, {
218 stables: [
219 "name",
220 "commentId",
221 "issue",
222 "project",
223 "location",
224 "createTime",
225 ],
226
227 diff: Effect.fn(function* ({ news, olds, output }) {
228 if (!isResolved(news)) return undefined;
229 const env = yield* GcpEnvironment.current;
230 const location = normalizeLocation(
231 news.location ?? olds?.location ?? output?.location,
232 env.region,
233 );
234 return replaceOnIdentity({
235 previousId: olds?.commentId ?? output?.commentId,
236 nextId: news.commentId ?? olds?.commentId ?? output?.commentId,
237 previousLocation: normalizeLocation(
238 olds?.location ?? output?.location,
239 env.region,
240 ),
241 nextLocation: location,
242 previousParent: olds?.issue ?? output?.issue,
243 nextParent: expandIssue(
244 news.issue,
245 news.repository,
246 env.project,
247 location,
248 ),
249 });
250 }),
251
252 read: Effect.fn(function* ({ id, olds, output }) {
253 const env = yield* GcpEnvironment.current;
254 const location = normalizeLocation(
255 olds?.location ?? output?.location,
256 env.region,
257 );
258 const issue = expandIssue(
259 olds?.issue ?? output?.issue ?? "",
260 olds?.repository ??
261 (output === undefined
262 ? undefined
263 : parseName(output.issue, "issues", env.region).parent),
264 env.project,
265 location,
266 );
267 const commentId = olds?.commentId ?? output?.commentId;
268 const name =
269 output?.name ??
270 (commentId !== undefined && issue.length > 0
271 ? resourceName(issue, commentId)
272 : "");
273 let existing = yield* getByName(name);
274 const recorded = existing !== undefined && output !== undefined;
275 if (existing === undefined && olds !== undefined) {
276 existing = yield* findMatching(issue, id, olds.body);
277 }
278 if (existing === undefined) return undefined;
279 const attrs = toAttrs(existing, env.project, env.region);
280 const { labels } = parseOwnership(existing.body);
281 // A matching comment found without a recorded id could be anyone's.
282 return recorded || (yield* hasAlchemyLabels(id, labels))
283 ? attrs
284 : Unowned(attrs);
285 }),
286
287 list: () =>
288 Effect.gen(function* () {
289 const env = yield* GcpEnvironment.current;
290 const items = yield* listOwned(env.project, env.region);
291 return items.map((item: ssm.IssueComment) =>
292 toAttrs(item, env.project, env.region),
293 );
294 }),
295
296 reconcile: Effect.fn(function* ({ id, news, output }) {
297 const env = yield* GcpEnvironment.current;
298 const location = normalizeLocation(
299 news.location ?? output?.location,
300 env.region,
301 );
302 const issue = expandIssue(
303 news.issue,
304 news.repository,
305 env.project,
306 location,
307 );
308 const body = news.body;
309 const name =
310 output?.name ??
311 (news.commentId !== undefined
312 ? resourceName(issue, news.commentId)
313 : "");
314
315 let current = yield* getByName(name);
316 if (current === undefined) {
317 current = yield* findMatching(issue, id, body);
318 }
319
320 if (current === undefined) {
321 const created = yield* ssm
322 .createProjectsLocationsRepositoriesIssuesIssueComments({
323 parent: issue,
324 body: { body },
325 })
326 .pipe(Effect.catchTag("Conflict", () => Effect.succeed(undefined)));
327 if (created !== undefined) {
328 const operation = yield* waitForOperation(created);
329 const createdName = nameFromOperation(operation);
330 if (createdName !== undefined) {
331 current = yield* waitUntilExists(
332 getByName(createdName),
333 createdName,
334 );
335 }
336 }
337 if (current === undefined) {
338 current = yield* findMatching(issue, id, body);
339 }
340 }
341
342 if (current === undefined) {
343 return yield* new ResourceNotResolved({ name: name || issue });
344 }
345
346 const currentName = current.name ?? name;
347 // Raw body: also rewrites a legacy ownership marker away.
348 if (!sameText(current.body, body)) {
349 const operation =
350 yield* ssm.patchProjectsLocationsRepositoriesIssuesIssueComments({
351 name: currentName,
352 updateMask: "body",
353 body: { body },
354 });
355 yield* waitForOperation(operation);
356 current = yield* waitUntilExists(getByName(currentName), currentName);
357 }
358
359 return toAttrs(current, env.project, env.region);
360 }),
361
362 delete: Effect.fn(function* ({ output }) {
363 const operation = yield* retryConflict(
364 ssm
365 .deleteProjectsLocationsRepositoriesIssuesIssueComments({
366 name: output.name,
367 })
368 .pipe(Effect.catchTag("NotFound", () => Effect.succeed(undefined))),
369 );
370 if (operation !== undefined) {
371 yield* waitForOperation(operation, { notFoundOk: true });
372 }
373 yield* waitUntilGone(getByName(output.name), output.name);
374 }),
375 });