1import * as developerconnect from "@distilled.cloud/gcp/developerconnect_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 { tagRecord } from "../../Tags.ts";
8import { GcpEnvironment } from "../Environment.ts";
9import { createInternalLabels, hasAlchemyLabels, toLabels } from "../Labels.ts";
10import type { Providers } from "../Providers.ts";
11import {
12 collectPages,
13 expandParent,
14 fingerprint,
15 listAtNested,
16 listLabeledPages,
17 normalizeLocation,
18 parseName,
19 replaceOnIdentity,
20 ResourceNotResolved,
21 toPhysicalId,
22 userAnnotations,
23 userLabels,
24 waitForOperation,
25 waitUntilExists,
26 waitUntilGone,
27 listAtLocation,
28} from "./internal.ts";
29
30export type ConnectionsGitRepositoryLinkProps = {
31 /**
32 * Parent Developer Connect connection. Full name
33 * `projects/{project}/locations/{location}/connections/{connection}`
34 * or the connection id (combined with `location`). Immutable —
35 * changing it replaces the link.
36 */
37 connection: string;
38 /**
39 * Region used when `connection` is a bare id. Immutable — changing
40 * it replaces the link. `US-CENTRAL1` is accepted and normalized to
41 * `us-central1`.
42 * @default the stack's GCP region (`GCP.Region`, else the profile
43 * region, else `us-central1`)
44 */
45 location?: string;
46 /**
47 * Git repository link id (the `{gitRepositoryLink}` segment of
48 * `{connection}/gitRepositoryLinks/{gitRepositoryLink}`). If omitted,
49 * a unique RFC1035 name is generated. Unique within the connection.
50 * Immutable — changing it replaces the link.
51 */
52 gitRepositoryLinkId?: string;
53 /**
54 * Git clone HTTPS URI (e.g. `https://github.com/{owner}/{repo}.git`).
55 * Immutable — changing it replaces the link.
56 */
57 cloneUri: string;
58 /**
59 * User labels. Alchemy ownership labels are merged in automatically.
60 * The API has no patch method — changing labels replaces the link.
61 */
62 labels?: Record<string, string>;
63 /**
64 * User annotations (AIP-148). The API has no patch method — changing
65 * annotations replaces the link.
66 */
67 annotations?: Record<string, string>;
68};
69
70export type ConnectionsGitRepositoryLink = Resource<
71 "GCP.DeveloperConnect.ConnectionsGitRepositoryLink",
72 ConnectionsGitRepositoryLinkProps,
73 {
74 /** Full resource name. */
75 name: string;
76 /** Git repository link id (last path segment). */
77 gitRepositoryLinkId: string;
78 /** Parent connection resource name. */
79 connection: string;
80 /** Connection id (last path segment of the parent). */
81 connectionId: string;
82 /** Project id. */
83 project: string;
84 /** Location id. */
85 location: string;
86 /** Git clone HTTPS URI. */
87 cloneUri: string;
88 /** User labels (Alchemy ownership labels stripped). */
89 labels: Record<string, string>;
90 /** User annotations. */
91 annotations: Record<string, string>;
92 /** External id of the webhook created for the repository. */
93 webhookId: string | undefined;
94 /** Git proxy URI when the parent connection has git proxy enabled. */
95 gitProxyUri: string | undefined;
96 /** True while Developer Connect is applying the link. */
97 reconciling: boolean;
98 /** Server checksum of the resource. */
99 etag: string | undefined;
100 /** Server-generated resource uid. */
101 uid: string | undefined;
102 /** RFC3339 creation timestamp. */
103 createTime: string | undefined;
104 /** RFC3339 last-update timestamp. */
105 updateTime: string | undefined;
106 },
107 never,
108 Providers
109>;
110
111/**
112 * A Developer Connect git repository link — associates a remote Git
113 * repository with a {@link Connection} so webhooks and clone tokens
114 * can be issued.
115 *
116 * Changing `gitRepositoryLinkId`, `connection`, `location`, `cloneUri`,
117 * labels, or annotations replaces the link. There is no update API;
118 * every user-facing field is immutable.
119 *
120 * ### Creating a Git Repository Link
121 * **Example:** Generated name
122 * ```typescript
123 * const github = yield* GCP.DeveloperConnect.Connection("Github", {
124 * githubConfig: { githubApp: "DEVELOPER_CONNECT" },
125 * });
126 * const source = yield* GCP.DeveloperConnect.ConnectionsGitRepositoryLink(
127 * "Source",
128 * {
129 * connection: github.name,
130 * cloneUri: "https://github.com/{owner}/{repo}.git",
131 * },
132 * );
133 * ```
134 *
135 * **Example:** Named link with labels
136 * ```typescript
137 * const source = yield* GCP.DeveloperConnect.ConnectionsGitRepositoryLink(
138 * "Source",
139 * {
140 * connection: github.name,
141 * gitRepositoryLinkId: "app-source",
142 * cloneUri: "https://github.com/{owner}/{repo}.git",
143 * labels: { env: "prod" },
144 * },
145 * );
146 * ```
147 *
148 * ### Fetching Git Refs
149 * **Example:** List branches
150 * ```typescript
151 * const fetchGitRefs = yield* GCP.DeveloperConnect.FetchGitRefs(source);
152 * const { refNames } = yield* fetchGitRefs({ refType: "BRANCH" });
153 * ```
154 *
155 * ### Cloning with a Token
156 * **Example:** Fetch a read token
157 * ```typescript
158 * const fetchReadToken = yield* GCP.DeveloperConnect.FetchReadToken(source);
159 * const { token } = yield* fetchReadToken();
160 * ```
161 *
162 * @resource
163 * @category DeveloperConnect
164 */
165export const ConnectionsGitRepositoryLink =
166 Resource<ConnectionsGitRepositoryLink>(
167 "GCP.DeveloperConnect.ConnectionsGitRepositoryLink",
168 );
169
170const parseLinkName = (name: string, region: string) => {
171 const parsed = parseName(name, "gitRepositoryLinks", region);
172 const connectionParsed = parseName(parsed.parent, "connections", region);
173 return {
174 project: parsed.project,
175 location: parsed.location,
176 gitRepositoryLinkId: parsed.id,
177 connection: parsed.parent,
178 connectionId: connectionParsed.id,
179 };
180};
181
182const resolveParent = (
183 project: string,
184 connection: string,
185 location: string | undefined,
186 region: string,
187) => {
188 if (connection.includes("/")) {
189 const parsed = parseLinkName(
190 connection.includes("/gitRepositoryLinks/")
191 ? connection
192 : `${connection.replace(/\/+$/, "")}/gitRepositoryLinks/_`,
193 region,
194 );
195 return {
196 parent: parsed.connection,
197 location: parsed.location,
198 project: parsed.project || project,
199 connectionId: parsed.connectionId,
200 };
201 }
202 const loc = normalizeLocation(location, region);
203 return {
204 parent: expandParent(connection, project, loc, "connections"),
205 location: loc,
206 project,
207 connectionId: connection,
208 };
209};
210
211const parentKey = (
212 connection: string | undefined,
213 location: string | undefined,
214 region: string,
215) => {
216 if (connection === undefined || connection === "") return undefined;
217 const parsed = resolveParent("", connection, location, region);
218 return `${parsed.location}/${parsed.connectionId}`;
219};
220
221const resourceName = (parent: string, gitRepositoryLinkId: string) =>
222 `${parent}/gitRepositoryLinks/${gitRepositoryLinkId}`;
223
224const toAttrs = (
225 item: developerconnect.GitRepositoryLink,
226 project: string,
227 region: string,
228) => {
229 const name = item.name ?? "";
230 const parsed = parseLinkName(name, region);
231 return {
232 name,
233 gitRepositoryLinkId: parsed.gitRepositoryLinkId,
234 connection: parsed.connection,
235 connectionId: parsed.connectionId,
236 project: parsed.project || project,
237 location: parsed.location,
238 cloneUri: item.cloneUri ?? "",
239 labels: userLabels(item.labels),
240 annotations: userAnnotations(item.annotations),
241 webhookId: item.webhookId,
242 gitProxyUri: item.gitProxyUri,
243 reconciling: item.reconciling === true,
244 etag: item.etag,
245 uid: item.uid,
246 createTime: item.createTime,
247 updateTime: item.updateTime,
248 };
249};
250
251const getByName = (name: string) =>
252 developerconnect
253 .getProjectsLocationsConnectionsGitRepositoryLinks({ name })
254 .pipe(Effect.catchTag("NotFound", () => Effect.succeed(undefined)));
255
256const listLinksAt = (parent: string) =>
257 listLabeledPages(
258 developerconnect.listProjectsLocationsConnectionsGitRepositoryLinks.pages({
259 parent,
260 pageSize: 1000,
261 }),
262 (page) => page.gitRepositoryLinks,
263 (item) => item.labels,
264 );
265
266const listOwned = (project: string, region: string) =>
267 Effect.gen(function* () {
268 const wildcard = yield* listAtNested(
269 project,
270 "connections/-",
271 region,
272 (parent) => listLinksAt(parent),
273 );
274 if (wildcard.length > 0) return wildcard;
275 const connections = yield* listAtLocation(project, region, (parent) =>
276 collectPages(
277 developerconnect.listProjectsLocationsConnections.pages({
278 parent,
279 pageSize: 1000,
280 }),
281 (page) => page.connections,
282 ),
283 );
284 const pages = yield* Effect.forEach(
285 connections,
286 (connection) =>
287 connection.name
288 ? listLinksAt(connection.name)
289 : Effect.succeed([] as developerconnect.GitRepositoryLink[]),
290 { concurrency: 4 },
291 );
292 return pages.flat();
293 });
294
295export const ConnectionsGitRepositoryLinkProvider = () =>
296 Provider.succeed(ConnectionsGitRepositoryLink, {
297 stables: [
298 "name",
299 "gitRepositoryLinkId",
300 "connection",
301 "connectionId",
302 "project",
303 "location",
304 "cloneUri",
305 "uid",
306 "createTime",
307 ],
308
309 diff: Effect.fn(function* ({ news, olds, output }) {
310 if (!isResolved(news)) return undefined;
311 const env = yield* GcpEnvironment.current;
312 if (output === undefined && olds === undefined) return undefined;
313 const previousUri = olds?.cloneUri ?? output?.cloneUri ?? "";
314 const nextUri = news.cloneUri ?? previousUri;
315 return replaceOnIdentity({
316 previousId: olds?.gitRepositoryLinkId ?? output?.gitRepositoryLinkId,
317 nextId:
318 news.gitRepositoryLinkId ??
319 olds?.gitRepositoryLinkId ??
320 output?.gitRepositoryLinkId,
321 previousLocation: normalizeLocation(
322 olds?.location ?? output?.location,
323 env.region,
324 ),
325 nextLocation: normalizeLocation(
326 news.location ?? olds?.location ?? output?.location,
327 env.region,
328 ),
329 previousParent: parentKey(
330 olds?.connection ?? output?.connection,
331 olds?.location ?? output?.location,
332 env.region,
333 ),
334 nextParent:
335 news.connection !== undefined
336 ? parentKey(
337 news.connection,
338 news.location ?? olds?.location ?? output?.location,
339 env.region,
340 )
341 : parentKey(
342 olds?.connection ?? output?.connection,
343 news.location ?? olds?.location ?? output?.location,
344 env.region,
345 ),
346 extra:
347 (previousUri !== "" && nextUri !== previousUri) ||
348 fingerprint(toLabels(olds?.labels ?? output?.labels)) !==
349 fingerprint(toLabels(news.labels)) ||
350 fingerprint(olds?.annotations ?? output?.annotations) !==
351 fingerprint(news.annotations),
352 });
353 }),
354
355 read: Effect.fn(function* ({ id, olds, output }) {
356 const env = yield* GcpEnvironment.current;
357 let name = output?.name;
358 if (name === undefined || name.length === 0) {
359 const connectionRef = olds?.connection ?? output?.connection;
360 if (typeof connectionRef !== "string" || connectionRef.length === 0) {
361 return undefined;
362 }
363 const gitRepositoryLinkId = yield* toPhysicalId(
364 id,
365 olds?.gitRepositoryLinkId,
366 output?.gitRepositoryLinkId,
367 "gitrepositorylink",
368 );
369 const parent = resolveParent(
370 env.project,
371 connectionRef,
372 olds?.location ?? output?.location,
373 env.region,
374 );
375 name = resourceName(parent.parent, gitRepositoryLinkId);
376 }
377 const existing = yield* getByName(name);
378 if (existing === undefined) return undefined;
379 const attrs = toAttrs(existing, env.project, env.region);
380 return (yield* hasAlchemyLabels(id, tagRecord(existing.labels)))
381 ? attrs
382 : Unowned(attrs);
383 }),
384
385 list: () =>
386 Effect.gen(function* () {
387 const env = yield* GcpEnvironment.current;
388 const items = yield* listOwned(env.project, env.region);
389 return items.map((item) => toAttrs(item, env.project, env.region));
390 }),
391
392 reconcile: Effect.fn(function* ({ id, news, output }) {
393 const env = yield* GcpEnvironment.current;
394 const gitRepositoryLinkId = yield* toPhysicalId(
395 id,
396 news.gitRepositoryLinkId,
397 output?.gitRepositoryLinkId,
398 "gitrepositorylink",
399 );
400 const parent = resolveParent(
401 env.project,
402 news.connection ?? output?.connection ?? "",
403 news.location ?? output?.location,
404 env.region,
405 );
406 const name = resourceName(parent.parent, gitRepositoryLinkId);
407 const desiredLabels = {
408 ...toLabels(news.labels),
409 ...(yield* createInternalLabels(id)),
410 };
411
412 let current = yield* getByName(output?.name ?? name);
413
414 if (current === undefined) {
415 const created = yield* developerconnect
416 .createProjectsLocationsConnectionsGitRepositoryLinks({
417 parent: parent.parent,
418 gitRepositoryLinkId,
419 body: {
420 cloneUri: news.cloneUri,
421 labels: desiredLabels,
422 annotations: news.annotations,
423 },
424 })
425 .pipe(Effect.catchTag("Conflict", () => Effect.succeed(undefined)));
426 if (created !== undefined) {
427 yield* waitForOperation(created);
428 }
429 current = yield* waitUntilExists(getByName(name), name);
430 }
431
432 if (current === undefined) {
433 return yield* new ResourceNotResolved({ name });
434 }
435
436 return toAttrs(current, env.project, env.region);
437 }),
438
439 delete: Effect.fn(function* ({ output }) {
440 const name = output.name;
441 if (name === undefined || name.length === 0) return;
442 const operation = yield* developerconnect
443 .deleteProjectsLocationsConnectionsGitRepositoryLinks({ name })
444 .pipe(Effect.catchTag("NotFound", () => Effect.succeed(undefined)));
445 if (operation !== undefined) {
446 yield* waitForOperation(operation, { notFoundOk: true });
447 }
448 yield* waitUntilGone(getByName(name), name);
449 }),
450 });