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