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 fingerprint,
15 forEachRepository,
16 hasOwnershipMarker,
17 nameFromOperation,
18 normalizeLocation,
19 ownedByAlchemy,
20 PAGE_SIZE,
21 parseName,
22 parseOwnership,
23 replaceOnIdentity,
24 ResourceNotResolved,
25 retryConflict,
26 sameText,
27 waitForOperation,
28 waitUntilExists,
29 waitUntilGone,
30} from "./internal.ts";
31
32export type PullRequestCommentReview = {
33 /**
34 * Review action (`COMMENT`, `CHANGE_REQUESTED`, `APPROVED`).
35 */
36 actionType?: ssm.ReviewActionTypeEnum | (string & {});
37 /** Optional review summary body. */
38 body?: string;
39};
40
41export type PullRequestCommentCode = {
42 /** Comment body. */
43 body?: string;
44 /** Parent code-comment name this comment replies to. */
45 reply?: string;
46 /** File path of the comment. */
47 position?: {
48 /** Path of the file. */
49 path?: string;
50 /** Line number (positive = new side, negative = old side). */
51 line?: string;
52 };
53};
54
55export type RepositoriesPullRequestsPullRequestCommentProps = {
56 /**
57 * Parent pull request. Full name
58 * `projects/{project}/locations/{location}/repositories/{repository}/pullRequests/{pullRequest}`
59 * or the pull-request id (combined with `repository`). Immutable —
60 * changing it replaces the comment.
61 */
62 pullRequest: string;
63 /**
64 * Parent repository used when `pullRequest` is a bare id.
65 */
66 repository?: string;
67 /**
68 * Region used when `pullRequest` or `repository` is a bare id.
69 * @default the stack's GCP region (`GCP.Region`, else the profile region, else `us-central1`)
70 */
71 location?: string;
72 /**
73 * Server-assigned comment id. Set after create. Immutable — changing
74 * it replaces the comment.
75 */
76 commentId?: string;
77 /**
78 * General pull-request comment. Mutually exclusive with `review` and
79 * `code`. Posted as given; comments have no labels, so Alchemy tracks
80 * the comment by its server-assigned id.
81 */
82 comment?: { body: string };
83 /**
84 * Review summary comment. Mutually exclusive with `comment` and `code`.
85 */
86 review?: PullRequestCommentReview;
87 /**
88 * Line-level code comment. Mutually exclusive with `comment` and
89 * `review`. Creating a root code comment requires
90 * `BatchCreatePullRequestComments`; this resource creates a general
91 * comment or a code reply.
92 */
93 code?: PullRequestCommentCode;
94};
95
96export type RepositoriesPullRequestsPullRequestComment = Resource<
97 "GCP.SecureSourceManager.RepositoriesPullRequestsPullRequestComment",
98 RepositoriesPullRequestsPullRequestCommentProps,
99 {
100 /** Full resource name. */
101 name: string;
102 /** Server-assigned comment id. */
103 commentId: string;
104 /** Parent pull-request resource name. */
105 pullRequest: string;
106 /** Project id. */
107 project: string;
108 /** Location id. */
109 location: string;
110 /** General comment body with ownership prefix stripped. */
111 comment: { body: string } | undefined;
112 /** Review summary with ownership prefix stripped from `body`. */
113 review: PullRequestCommentReview | undefined;
114 /** Code comment with ownership prefix stripped from `body`. */
115 code:
116 | {
117 body: string | undefined;
118 reply: string | undefined;
119 resolved: boolean | undefined;
120 position:
121 | {
122 path: string | undefined;
123 line: string | undefined;
124 }
125 | undefined;
126 }
127 | undefined;
128 /** RFC3339 creation timestamp. */
129 createTime: string | undefined;
130 /** RFC3339 last-update timestamp. */
131 updateTime: string | undefined;
132 },
133 never,
134 Providers
135>;
136
137/**
138 * A comment on a Secure Source Manager pull request.
139 *
140 * Comment ids are server-assigned. Changing `commentId` or `pullRequest`
141 * replaces the comment. Body updates in place. Ownership for `list` /
142 * nuke is stamped into the comment body (comments have no labels field).
143 * Create a general `comment`, a `review`, or a `code` reply — batch
144 * review-plus-code threads use the distilled batch-create API.
145 *
146 * ### Creating a Pull Request Comment
147 * **Example:** General comment
148 * ```typescript
149 * const comment =
150 * yield* GCP.SecureSourceManager.RepositoriesPullRequestsPullRequestComment(
151 * "Note",
152 * {
153 * pullRequest: pullRequest.name,
154 * comment: { body: "looks good" },
155 * },
156 * );
157 * ```
158 *
159 * ### Updating a Pull Request Comment
160 * Re-declare the same logical id with changed props; the engine keeps the
161 * physical resource and updates it in place.
162 *
163 * **Example:** Edit the body
164 * ```typescript
165 * const comment =
166 * yield* GCP.SecureSourceManager.RepositoriesPullRequestsPullRequestComment(
167 * "Note",
168 * {
169 * pullRequest: pullRequest.name,
170 * comment: { body: "looks good after the rebase" },
171 * },
172 * );
173 * ```
174 *
175 * @resource
176 * @category SecureSourceManager
177 */
178export const RepositoriesPullRequestsPullRequestComment =
179 Resource<RepositoriesPullRequestsPullRequestComment>(
180 "GCP.SecureSourceManager.RepositoriesPullRequestsPullRequestComment",
181 );
182
183const resourceName = (pullRequest: string, commentId: string) =>
184 `${pullRequest}/pullRequestComments/${commentId}`;
185
186const expandPullRequest = (
187 pullRequest: string,
188 repository: string | undefined,
189 project: string,
190 location: string,
191) => {
192 const next = pullRequest.replace(/\/+$/, "");
193 if (next.includes("/")) return next;
194 const repo = expandName(repository ?? "", project, location, "repositories");
195 return repo.length > 0 ? `${repo}/pullRequests/${next}` : next;
196};
197
198const ownershipBodyOf = (item: ssm.PullRequestComment) =>
199 item.comment?.body ?? item.review?.body ?? item.code?.body;
200
201const toAttrs = (
202 item: ssm.PullRequestComment,
203 project: string,
204 region: string,
205) => {
206 const name = item.name ?? "";
207 const parsed = parseName(name, "pullRequestComments", region);
208 const commentBody = parseOwnership(item.comment?.body);
209 const reviewBody = parseOwnership(item.review?.body);
210 const codeBody = parseOwnership(item.code?.body);
211 return {
212 name,
213 commentId: parsed.id,
214 pullRequest: parsed.parent,
215 project: parsed.project || project,
216 location: parsed.location,
217 comment:
218 item.comment === undefined ? undefined : { body: commentBody.text ?? "" },
219 review:
220 item.review === undefined
221 ? undefined
222 : {
223 actionType: item.review.actionType,
224 body: reviewBody.text,
225 },
226 code:
227 item.code === undefined
228 ? undefined
229 : {
230 body: codeBody.text,
231 reply: item.code.reply,
232 resolved: item.code.resolved,
233 position:
234 item.code.position === undefined
235 ? undefined
236 : {
237 path: item.code.position.path,
238 line: item.code.position.line,
239 },
240 },
241 createTime: item.createTime,
242 updateTime: item.updateTime,
243 };
244};
245
246const encodeBodies = (
247 news: RepositoriesPullRequestsPullRequestCommentProps,
248): ssm.PullRequestComment => {
249 if (news.review !== undefined) {
250 return {
251 review: {
252 actionType: news.review.actionType,
253 body: news.review.body,
254 },
255 };
256 }
257 if (news.code !== undefined) {
258 return {
259 code: {
260 body: news.code.body,
261 reply: news.code.reply,
262 position: news.code.position,
263 },
264 };
265 }
266 return {
267 comment: {
268 body: news.comment?.body,
269 },
270 };
271};
272
273const desiredBody = (
274 news: RepositoriesPullRequestsPullRequestCommentProps,
275): string | undefined =>
276 news.review?.body ?? news.code?.body ?? news.comment?.body;
277
278const getByName = (name: string) =>
279 name.length === 0
280 ? Effect.succeed(undefined)
281 : catchMissing(
282 ssm.getProjectsLocationsRepositoriesPullRequestsPullRequestComments({
283 name,
284 }),
285 );
286
287const listOnPullRequest = (pullRequest: string) =>
288 collectPages(
289 ssm.listProjectsLocationsRepositoriesPullRequestsPullRequestComments.pages({
290 parent: pullRequest,
291 pageSize: PAGE_SIZE,
292 }),
293 (page) => page.pullRequestComments,
294 );
295
296const listOnRepository = (repository: string) =>
297 Effect.gen(function* () {
298 const pullRequests = yield* collectPages(
299 ssm.listProjectsLocationsRepositoriesPullRequests.pages({
300 parent: repository,
301 pageSize: PAGE_SIZE,
302 }),
303 (page) => page.pullRequests,
304 );
305 const pages = yield* Effect.forEach(
306 pullRequests.filter((item) => (item.name ?? "").length > 0),
307 (item) => listOnPullRequest(item.name!),
308 { concurrency: 4 },
309 );
310 return pages.flat();
311 });
312
313/**
314 * A comment on the pull request with this body (or one carrying a legacy
315 * ownership marker for `id`). Recovers a comment whose create succeeded but
316 * was never recorded.
317 */
318const findMatching = (
319 pullRequest: string,
320 id: string,
321 body: string | undefined,
322) =>
323 Effect.gen(function* () {
324 if (pullRequest.length === 0) return undefined;
325 const items = yield* listOnPullRequest(pullRequest);
326 for (const item of items) {
327 if (yield* ownedByAlchemy(id, ownershipBodyOf(item))) return item;
328 if (sameText(ownershipBodyOf(item), body)) return item;
329 }
330 return undefined;
331 });
332
333const listOwned = (project: string, region: string) =>
334 forEachRepository(project, region, (repository) =>
335 listOnRepository(repository).pipe(
336 Effect.map((items) =>
337 items.filter((item) => hasOwnershipMarker(ownershipBodyOf(item))),
338 ),
339 ),
340 );
341
342export const RepositoriesPullRequestsPullRequestCommentProvider = () =>
343 Provider.succeed(RepositoriesPullRequestsPullRequestComment, {
344 stables: [
345 "name",
346 "commentId",
347 "pullRequest",
348 "project",
349 "location",
350 "createTime",
351 ],
352
353 diff: Effect.fn(function* ({ news, olds, output }) {
354 if (!isResolved(news)) return undefined;
355 const env = yield* GcpEnvironment.current;
356 const location = normalizeLocation(
357 news.location ?? olds?.location ?? output?.location,
358 env.region,
359 );
360 const nextKind =
361 news.review !== undefined
362 ? "review"
363 : news.code !== undefined
364 ? "code"
365 : "comment";
366 const previousKind =
367 olds?.review !== undefined || output?.review !== undefined
368 ? "review"
369 : olds?.code !== undefined || output?.code !== undefined
370 ? "code"
371 : olds?.comment !== undefined || output?.comment !== undefined
372 ? "comment"
373 : nextKind;
374 return replaceOnIdentity({
375 previousId: olds?.commentId ?? output?.commentId,
376 nextId: news.commentId ?? olds?.commentId ?? output?.commentId,
377 previousLocation: normalizeLocation(
378 olds?.location ?? output?.location,
379 env.region,
380 ),
381 nextLocation: location,
382 previousParent: olds?.pullRequest ?? output?.pullRequest,
383 nextParent: expandPullRequest(
384 news.pullRequest,
385 news.repository,
386 env.project,
387 location,
388 ),
389 extra:
390 previousKind !== nextKind ||
391 fingerprint(news.code?.position) !==
392 fingerprint(olds?.code?.position ?? output?.code?.position) ||
393 !sameText(
394 news.code?.reply,
395 olds?.code?.reply ?? output?.code?.reply,
396 ) ||
397 !sameText(
398 news.review?.actionType,
399 olds?.review?.actionType ?? output?.review?.actionType,
400 ),
401 });
402 }),
403
404 read: Effect.fn(function* ({ id, olds, output }) {
405 const env = yield* GcpEnvironment.current;
406 const location = normalizeLocation(
407 olds?.location ?? output?.location,
408 env.region,
409 );
410 const pullRequest = expandPullRequest(
411 olds?.pullRequest ?? output?.pullRequest ?? "",
412 olds?.repository,
413 env.project,
414 location,
415 );
416 const commentId = olds?.commentId ?? output?.commentId;
417 const name =
418 output?.name ??
419 (commentId !== undefined && pullRequest.length > 0
420 ? resourceName(pullRequest, commentId)
421 : "");
422 let existing = yield* getByName(name);
423 const recorded = existing !== undefined && output !== undefined;
424 if (existing === undefined && olds !== undefined) {
425 existing = yield* findMatching(pullRequest, id, desiredBody(olds));
426 }
427 if (existing === undefined) return undefined;
428 const attrs = toAttrs(existing, env.project, env.region);
429 const { labels } = parseOwnership(ownershipBodyOf(existing));
430 // A matching comment found without a recorded id could be anyone's.
431 return recorded || (yield* hasAlchemyLabels(id, labels))
432 ? attrs
433 : Unowned(attrs);
434 }),
435
436 list: () =>
437 Effect.gen(function* () {
438 const env = yield* GcpEnvironment.current;
439 const items = yield* listOwned(env.project, env.region);
440 return items.map((item: ssm.PullRequestComment) =>
441 toAttrs(item, env.project, env.region),
442 );
443 }),
444
445 reconcile: Effect.fn(function* ({ id, news, output }) {
446 const env = yield* GcpEnvironment.current;
447 const location = normalizeLocation(
448 news.location ?? output?.location,
449 env.region,
450 );
451 const pullRequest = expandPullRequest(
452 news.pullRequest,
453 news.repository,
454 env.project,
455 location,
456 );
457 const encoded = encodeBodies(news);
458 const name =
459 output?.name ??
460 (news.commentId !== undefined
461 ? resourceName(pullRequest, news.commentId)
462 : "");
463
464 let current = yield* getByName(name);
465 if (current === undefined) {
466 current = yield* findMatching(pullRequest, id, desiredBody(news));
467 }
468
469 if (current === undefined) {
470 const created = yield* ssm
471 .createProjectsLocationsRepositoriesPullRequestsPullRequestComments({
472 parent: pullRequest,
473 body: encoded,
474 })
475 .pipe(Effect.catchTag("Conflict", () => Effect.succeed(undefined)));
476 if (created !== undefined) {
477 const operation = yield* waitForOperation(created);
478 const createdName = nameFromOperation(operation);
479 if (createdName !== undefined) {
480 current = yield* waitUntilExists(
481 getByName(createdName),
482 createdName,
483 );
484 }
485 }
486 if (current === undefined) {
487 current = yield* findMatching(pullRequest, id, desiredBody(news));
488 }
489 }
490
491 if (current === undefined) {
492 return yield* new ResourceNotResolved({
493 name: name || pullRequest,
494 });
495 }
496
497 const currentName = current.name ?? name;
498 // Raw body: also rewrites a legacy ownership marker away.
499 if (!sameText(ownershipBodyOf(current), desiredBody(news))) {
500 const operation =
501 yield* ssm.patchProjectsLocationsRepositoriesPullRequestsPullRequestComments(
502 {
503 name: currentName,
504 updateMask: "body",
505 body: encoded,
506 },
507 );
508 yield* waitForOperation(operation);
509 current = yield* waitUntilExists(getByName(currentName), currentName);
510 }
511
512 return toAttrs(current, env.project, env.region);
513 }),
514
515 delete: Effect.fn(function* ({ output }) {
516 const operation = yield* retryConflict(
517 ssm
518 .deleteProjectsLocationsRepositoriesPullRequestsPullRequestComments({
519 name: output.name,
520 })
521 .pipe(Effect.catchTag("NotFound", () => Effect.succeed(undefined))),
522 );
523 if (operation !== undefined) {
524 yield* waitForOperation(operation, { notFoundOk: true });
525 }
526 yield* waitUntilGone(getByName(output.name), output.name);
527 }),
528 });