1import * as cloudbuild from "@distilled.cloud/gcp/cloudbuild_v2";
2import * as Data from "effect/Data";
3import type { GcpOpContext } from "@distilled.cloud/gcp/Protocol";
4import * as Effect from "effect/Effect";
5import * as Schedule from "effect/Schedule";
6import * as Stream from "effect/Stream";
7import { Unowned } from "../../AdoptPolicy.ts";
8import { isResolved } from "../../Diff.ts";
9import { createPhysicalName } from "../../PhysicalName.ts";
10import * as Provider from "../../Provider.ts";
11import { Resource } from "../../Resource.ts";
12import { tagRecord } from "../../Tags.ts";
13import { GcpEnvironment } from "../Environment.ts";
14import {
15 createInternalLabels,
16 hasAlchemyLabels,
17 stripInternalLabels,
18 toLabels,
19} from "../Labels.ts";
20import type { Providers } from "../Providers.ts";
21import { waitForOperation as waitForLongRunningOperation } from "../Operation.ts";
22
23const MAX_NAME_LENGTH = 63;
24
25export type RepositoryProps = {
26 /**
27 * Parent Cloud Build connection. Full name
28 * `projects/{project}/locations/{location}/connections/{connection}` or
29 * the connection id (combined with `location`). Immutable — changing it
30 * replaces the repository.
31 */
32 connection: string;
33 /**
34 * Cloud Build location (`us-central1`, `us-east1`, …). Used when
35 * `connection` is a bare id. Immutable — changing it replaces the
36 * repository. `US-CENTRAL1` is accepted and normalized to `us-central1`.
37 * @default the stack's GCP region (`GCP.Region`, else the profile region, else `us-central1`)
38 */
39 location?: string;
40 /**
41 * Repository id (the `{repository}` segment of
42 * `projects/{project}/locations/{location}/connections/{connection}/repositories/{repository}`).
43 * If omitted, a unique name is generated from the stack, stage, and
44 * logical id. Unique within the connection. Immutable — changing it
45 * replaces the repository.
46 */
47 repositoryId?: string;
48 /**
49 * Git clone HTTPS URI (e.g. `https://github.com/{owner}/{repo}.git`).
50 * Immutable — changing it replaces the repository.
51 */
52 remoteUri: string;
53 /**
54 * User annotations. Cloud Build repositories have no labels, so Alchemy
55 * ownership (`alchemy-stack` / `alchemy-stage` / `alchemy-id`) is stored
56 * here for `list` / nuke. The API has no update method — changing
57 * annotations replaces the repository.
58 */
59 annotations?: Record<string, string>;
60};
61
62export type Repository = Resource<
63 "GCP.CloudBuild.Repository",
64 RepositoryProps,
65 {
66 /** Full resource name `projects/{project}/locations/{location}/connections/{connection}/repositories/{repository}`. */
67 name: string;
68 /** Repository id (last path segment). */
69 repositoryId: string;
70 /** Parent connection resource name. */
71 connection: string;
72 /** Connection id (last path segment of the parent). */
73 connectionId: string;
74 /** Project id. */
75 project: string;
76 /** Location id (`us-central1`, …). */
77 location: string;
78 /** Git clone HTTPS URI. */
79 remoteUri: string;
80 /** User annotations (Alchemy ownership keys stripped). */
81 annotations: Record<string, string>;
82 /** External id of the webhook created for the repository. */
83 webhookId: string | undefined;
84 /** Server checksum of the resource. */
85 etag: string | undefined;
86 /** RFC3339 creation timestamp. */
87 createTime: string | undefined;
88 /** RFC3339 last-update timestamp. */
89 updateTime: string | undefined;
90 },
91 never,
92 Providers
93>;
94
95/**
96 * A Cloud Build v2 repository — links a remote Git repository to a
97 * {@link Connection}.
98 *
99 * Changing `repositoryId`, `connection`, `location`, `remoteUri`, or
100 * `annotations` replaces the repository. There is no update API; every
101 * user-facing field is immutable.
102 *
103 * Cloud Build repositories have no labels. Alchemy stores ownership in
104 * annotations (`alchemy-stack` / `alchemy-stage` / `alchemy-id`) so
105 * `list` / `pnpm nuke:gcp` can find them.
106 *
107 * ### Creating a Repository
108 * **Example:** Generated name
109 * ```typescript
110 * const github = yield* GCP.CloudBuild.Connection("Github", {
111 * githubConfig: {},
112 * });
113 * const source = yield* GCP.CloudBuild.Repository("Source", {
114 * connection: github.name,
115 * remoteUri: "https://github.com/{owner}/{repo}.git",
116 * });
117 * ```
118 *
119 * **Example:** Named repository with annotations
120 * ```typescript
121 * const source = yield* GCP.CloudBuild.Repository("Source", {
122 * connection: github.name,
123 * repositoryId: "app-source",
124 * remoteUri: "https://github.com/{owner}/{repo}.git",
125 * annotations: { env: "prod" },
126 * });
127 * ```
128 *
129 * ### Fetching Git Refs
130 * **Example:** List branches
131 * ```typescript
132 * const fetchGitRefs = yield* GCP.CloudBuild.FetchGitRefs(source);
133 * const { refNames } = yield* fetchGitRefs({ refType: "BRANCH" });
134 * ```
135 *
136 * ### Cloning with a Token
137 * **Example:** Fetch a read token
138 * ```typescript
139 * const accessReadToken = yield* GCP.CloudBuild.AccessReadToken(source);
140 * const { token } = yield* accessReadToken();
141 * ```
142 *
143 * @resource
144 * @category CloudBuild
145 */
146export const Repository = Resource<Repository>("GCP.CloudBuild.Repository");
147
148export class RepositoryNotResolved extends Data.TaggedError(
149 "GCP.CloudBuild.RepositoryNotResolved",
150)<{
151 name: string;
152}> {}
153
154export class RepositoryStillExists extends Data.TaggedError(
155 "GCP.CloudBuild.RepositoryStillExists",
156)<{
157 name: string;
158}> {}
159
160const lastSegment = (value: string) => {
161 const trimmed = value.replace(/\/+$/, "");
162 const parts = trimmed.split("/");
163 return parts[parts.length - 1] || trimmed;
164};
165
166const normalizeLocation = (location: string | undefined, fallback: string) =>
167 lastSegment(location ?? fallback).toLowerCase();
168
169const parseName = (name: string) => {
170 const parts = name.split("/").filter((part) => part.length > 0);
171 const repositoriesAt = parts.lastIndexOf("repositories");
172 const connectionsAt = parts.lastIndexOf("connections");
173 const locationsAt = parts.lastIndexOf("locations");
174 const projectsAt = parts.lastIndexOf("projects");
175 const connection =
176 connectionsAt >= 0 ? parts.slice(0, connectionsAt + 2).join("/") : "";
177 return {
178 project:
179 projectsAt >= 0 && parts[projectsAt + 1] ? parts[projectsAt + 1]! : "",
180 location:
181 locationsAt >= 0 && parts[locationsAt + 1] ? parts[locationsAt + 1]! : "",
182 connection,
183 connectionId:
184 connectionsAt >= 0 && parts[connectionsAt + 1]
185 ? parts[connectionsAt + 1]!
186 : "",
187 repositoryId:
188 repositoriesAt >= 0 && parts[repositoriesAt + 1]
189 ? parts[repositoriesAt + 1]!
190 : lastSegment(name),
191 };
192};
193
194const resolveParent = (
195 project: string,
196 connection: string,
197 location: string | undefined,
198 defaultLocation: string,
199) => {
200 if (connection.includes("/")) {
201 const parsed = parseName(
202 connection.includes("/repositories/")
203 ? connection
204 : `${connection.replace(/\/+$/, "")}/repositories/_`,
205 );
206 return {
207 parent: parsed.connection,
208 location: parsed.location,
209 project: parsed.project || project,
210 connectionId: parsed.connectionId,
211 };
212 }
213 const loc = normalizeLocation(location, defaultLocation);
214 return {
215 parent: `projects/${project}/locations/${loc}/connections/${connection}`,
216 location: loc,
217 project,
218 connectionId: connection,
219 };
220};
221
222const parentKey = (
223 connection: string | undefined,
224 location: string | undefined,
225 defaultLocation: string,
226) => {
227 if (connection === undefined || connection === "") return undefined;
228 const parsed = resolveParent("", connection, location, defaultLocation);
229 return `${parsed.location}/${parsed.connectionId}`;
230};
231
232const resourceName = (parent: string, repositoryId: string) =>
233 `${parent}/repositories/${repositoryId}`;
234
235const userAnnotations = (
236 annotations: Record<string, string | undefined> | null | undefined,
237): Record<string, string> => stripInternalLabels(tagRecord(annotations));
238
239const toId = (
240 id: string,
241 repositoryId: string | undefined,
242 existing?: string,
243) =>
244 Effect.gen(function* () {
245 return (
246 repositoryId ??
247 existing ??
248 (yield* createPhysicalName({
249 id,
250 maxLength: MAX_NAME_LENGTH,
251 lowercase: true,
252 }))
253 );
254 });
255
256const annotationsEqual = (
257 left: Record<string, string>,
258 right: Record<string, string>,
259) => {
260 const leftKeys = Object.keys(left).sort();
261 const rightKeys = Object.keys(right).sort();
262 return (
263 leftKeys.length === rightKeys.length &&
264 leftKeys.every(
265 (key, index) => key === rightKeys[index] && left[key] === right[key],
266 )
267 );
268};
269
270const toAttrs = (repository: cloudbuild.Repository, project: string) => {
271 const name = repository.name ?? "";
272 const parsed = parseName(name);
273 return {
274 name,
275 repositoryId: parsed.repositoryId,
276 connection: parsed.connection,
277 connectionId: parsed.connectionId,
278 project: parsed.project || project,
279 location: parsed.location,
280 remoteUri: repository.remoteUri ?? "",
281 annotations: userAnnotations(repository.annotations),
282 webhookId: repository.webhookId,
283 etag: repository.etag,
284 createTime: repository.createTime,
285 updateTime: repository.updateTime,
286 };
287};
288
289const getByName = (name: string) =>
290 cloudbuild
291 .getProjectsLocationsConnectionsRepositories({ name })
292 .pipe(Effect.catchTag("NotFound", () => Effect.succeed(undefined)));
293
294/**
295 * Wait on a Cloud Build repositories long-running operation (seconds to a minute).
296 * `ALREADY_EXISTS` (a create race) counts as success; so does `NOT_FOUND`
297 * when `notFoundOk` (deletes). Returns the final operation.
298 */
299const waitForOperation = (
300 operation: cloudbuild.Operation,
301 options?: { notFoundOk?: boolean },
302) =>
303 Effect.suspend(() => {
304 let latest = operation;
305 return waitForLongRunningOperation(
306 operation,
307 (name) => {
308 const get = cloudbuild.getProjectsLocationsOperations({ name }).pipe(
309 Effect.tap((current) =>
310 Effect.sync(() => {
311 latest = current;
312 }),
313 ),
314 );
315 const observe: Effect.Effect<
316 cloudbuild.Operation,
317 cloudbuild.GetProjectsLocationsOperationsError,
318 GcpOpContext
319 > =
320 options?.notFoundOk === true
321 ? get.pipe(
322 Effect.catchTag("NotFound", () =>
323 Effect.succeed<cloudbuild.Operation>({ name, done: true }),
324 ),
325 )
326 : get.pipe(
327 // A just-returned operation can briefly 404 on read.
328 Effect.retry({
329 while: (error) => error._tag === "NotFound",
330 times: 5,
331 schedule: Schedule.exponential("250 millis"),
332 }),
333 );
334 return observe;
335 },
336 { budget: "5 minutes" },
337 ).pipe(
338 Effect.map(() => latest),
339 // google.rpc.Code ALREADY_EXISTS = 6, NOT_FOUND = 5.
340 Effect.catchTag("GCP.OperationFailed", (error) =>
341 error.code === 6 || (options?.notFoundOk === true && error.code === 5)
342 ? Effect.succeed(latest)
343 : Effect.fail(error),
344 ),
345 );
346 });
347
348const waitUntilExists = (name: string) =>
349 getByName(name).pipe(
350 Effect.flatMap((repository) =>
351 repository
352 ? Effect.succeed(repository)
353 : Effect.fail(new RepositoryNotResolved({ name })),
354 ),
355 Effect.retry({
356 while: (error) => error._tag === "GCP.CloudBuild.RepositoryNotResolved",
357 times: 8,
358 schedule: Schedule.spaced("1 second"),
359 }),
360 );
361
362const waitUntilGone = (name: string) =>
363 getByName(name).pipe(
364 Effect.flatMap((repository) =>
365 repository === undefined
366 ? Effect.void
367 : Effect.fail(new RepositoryStillExists({ name })),
368 ),
369 Effect.retry({
370 while: (error) => error._tag === "GCP.CloudBuild.RepositoryStillExists",
371 times: 10,
372 schedule: Schedule.spaced("1 second"),
373 }),
374 );
375
376const listRepositoriesAt = (parent: string, project: string) =>
377 cloudbuild.listProjectsLocationsConnectionsRepositories
378 .pages({
379 parent,
380 pageSize: 1000,
381 returnPartialSuccess: true,
382 })
383 .pipe(
384 Stream.flatMap((page) => Stream.fromIterable(page.repositories ?? [])),
385 Stream.filter((repository) =>
386 Object.keys(repository.annotations ?? {}).some((key) =>
387 key.startsWith("alchemy-"),
388 ),
389 ),
390 Stream.map((repository) => toAttrs(repository, project)),
391 Stream.runCollect,
392 Effect.map((chunk) => Array.from(chunk)),
393 Effect.catchTag("NotFound", () => Effect.succeed([])),
394 );
395
396const listOwnedRepositories = (project: string) =>
397 Effect.gen(function* () {
398 const connections = yield* cloudbuild.listProjectsLocationsConnections
399 .pages({
400 parent: `projects/${project}/locations/-`,
401 pageSize: 1000,
402 returnPartialSuccess: true,
403 })
404 .pipe(
405 Stream.flatMap((page) => Stream.fromIterable(page.connections ?? [])),
406 Stream.runCollect,
407 Effect.map((chunk) => Array.from(chunk)),
408 Effect.catchTag("NotFound", () =>
409 Effect.succeed([] as cloudbuild.Connection[]),
410 ),
411 );
412
413 const pages = yield* Effect.forEach(
414 connections,
415 (connection) =>
416 connection.name
417 ? listRepositoriesAt(connection.name, project)
418 : Effect.succeed([] as ReturnType<typeof toAttrs>[]),
419 { concurrency: 4 },
420 );
421 return pages.flat();
422 });
423
424export const RepositoryProvider = () =>
425 Provider.succeed(Repository, {
426 stables: [
427 "name",
428 "repositoryId",
429 "connection",
430 "connectionId",
431 "project",
432 "location",
433 "remoteUri",
434 "webhookId",
435 "createTime",
436 ],
437
438 diff: Effect.fn(function* ({ news, olds, output }) {
439 const env = yield* GcpEnvironment.current;
440 if (!isResolved(news)) return undefined;
441 if (output === undefined && olds === undefined) return undefined;
442
443 const previousId = olds?.repositoryId ?? output?.repositoryId;
444 const nextId = news.repositoryId ?? previousId;
445 const previousLocation = normalizeLocation(
446 olds?.location ?? output?.location,
447 env.region,
448 );
449 const nextLocation = normalizeLocation(
450 news.location ?? olds?.location ?? output?.location,
451 env.region,
452 );
453 const previousParent = parentKey(
454 olds?.connection ?? output?.connection,
455 olds?.location ?? output?.location,
456 env.region,
457 );
458 const nextParent =
459 news.connection !== undefined
460 ? parentKey(
461 news.connection,
462 news.location ?? olds?.location ?? output?.location,
463 env.region,
464 )
465 : previousParent;
466 const previousUri = olds?.remoteUri ?? output?.remoteUri ?? "";
467 const nextUri = news.remoteUri ?? previousUri;
468 const previousAnnotations = toLabels(
469 olds?.annotations ?? output?.annotations,
470 );
471 const nextAnnotations = toLabels(news.annotations);
472
473 const replace =
474 (previousId !== undefined &&
475 nextId !== undefined &&
476 nextId !== previousId) ||
477 previousLocation !== nextLocation ||
478 (previousParent !== undefined &&
479 nextParent !== undefined &&
480 previousParent !== nextParent) ||
481 (previousUri !== "" && nextUri !== previousUri) ||
482 !annotationsEqual(previousAnnotations, nextAnnotations);
483
484 if (!replace) return undefined;
485 return {
486 action: "replace" as const,
487 deleteFirst:
488 previousParent === nextParent &&
489 previousLocation === nextLocation &&
490 previousId !== undefined &&
491 nextId === previousId,
492 };
493 }),
494
495 read: Effect.fn(function* ({ id, olds, output }) {
496 const env = yield* GcpEnvironment.current;
497 let name = output?.name;
498 if (name === undefined || name.length === 0) {
499 const connectionRef = olds?.connection ?? output?.connection;
500 if (typeof connectionRef !== "string" || connectionRef.length === 0) {
501 return undefined;
502 }
503 const repositoryId = yield* toId(
504 id,
505 olds?.repositoryId,
506 output?.repositoryId,
507 );
508 const parent = resolveParent(
509 env.project,
510 connectionRef,
511 olds?.location ?? output?.location,
512 env.region,
513 );
514 name = resourceName(parent.parent, repositoryId);
515 }
516 const existing = yield* getByName(name);
517 if (existing === undefined) return undefined;
518 const attrs = toAttrs(existing, env.project);
519 return (yield* hasAlchemyLabels(id, tagRecord(existing.annotations)))
520 ? attrs
521 : Unowned(attrs);
522 }),
523
524 list: () =>
525 Effect.gen(function* () {
526 const env = yield* GcpEnvironment.current;
527 return yield* listOwnedRepositories(env.project);
528 }),
529
530 reconcile: Effect.fn(function* ({ id, news, output }) {
531 const env = yield* GcpEnvironment.current;
532 const repositoryId = yield* toId(
533 id,
534 news.repositoryId,
535 output?.repositoryId,
536 );
537 const parent = resolveParent(
538 env.project,
539 news.connection ?? output?.connection ?? "",
540 news.location ?? output?.location,
541 env.region,
542 );
543 const name = resourceName(parent.parent, repositoryId);
544 const desiredAnnotations = {
545 ...toLabels(news.annotations),
546 ...(yield* createInternalLabels(id)),
547 };
548
549 let current = yield* getByName(name);
550
551 if (current === undefined) {
552 const created = yield* cloudbuild
553 .createProjectsLocationsConnectionsRepositories({
554 parent: parent.parent,
555 repositoryId,
556 body: {
557 remoteUri: news.remoteUri,
558 annotations: desiredAnnotations,
559 },
560 })
561 .pipe(Effect.catchTag("Conflict", () => Effect.succeed(undefined)));
562 if (created !== undefined) {
563 yield* waitForOperation(created);
564 }
565 current = yield* waitUntilExists(name);
566 }
567
568 if (current === undefined) {
569 return yield* new RepositoryNotResolved({ name });
570 }
571
572 return toAttrs(current, env.project);
573 }),
574
575 delete: Effect.fn(function* ({ output }) {
576 const name = output.name;
577 if (name === undefined || name.length === 0) {
578 return;
579 }
580 const operation = yield* cloudbuild
581 .deleteProjectsLocationsConnectionsRepositories({ name })
582 .pipe(Effect.catchTag("NotFound", () => Effect.succeed(undefined)));
583 if (operation !== undefined) {
584 yield* waitForOperation(operation, { notFoundOk: true });
585 }
586 yield* waitUntilGone(name);
587 }),
588 });