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 fingerprint,
16 forEachRepository,
17 hasTargetUriOwnership,
18 normalizeLocation,
19 PAGE_SIZE,
20 parseName,
21 parseTargetUriOwnership,
22 replaceOnIdentity,
23 ResourceNotResolved,
24 retryConflict,
25 rfc1035,
26 sameText,
27 stripAlchemyQuery,
28 toPhysicalId,
29 waitForOperation,
30 waitUntilExists,
31 waitUntilGone,
32} from "./internal.ts";
33
34export type HookEvent = ssm.HookEventsItemEnum | (string & {});
35
36export type RepositoriesHookProps = {
37 /**
38 * Parent repository. Full name
39 * `projects/{project}/locations/{location}/repositories/{repository}`
40 * or the repository id (combined with `location`). Immutable —
41 * changing it replaces the hook.
42 */
43 repository: string;
44 /**
45 * Region used when `repository` is a bare id.
46 * @default the stack's GCP region (`GCP.Region`, else the profile region, else `us-central1`)
47 */
48 location?: string;
49 /**
50 * Hook id (the `{hook}` segment). If omitted, a unique RFC1035 name is
51 * generated. Immutable — changing it replaces the hook.
52 */
53 hookId?: string;
54 /**
55 * Target URI that receives webhook payloads, sent as given. Hooks have
56 * no labels, so ownership rests on the hook id (generated ids embed the
57 * stack, stage, and logical id).
58 */
59 targetUri: string;
60 /**
61 * Events that fire the hook (`PUSH`, `PULL_REQUEST`).
62 */
63 events?: HookEvent[];
64 /**
65 * When true, the hook exists but does not send traffic.
66 * @default false
67 */
68 disabled?: boolean;
69 /**
70 * Sensitive query string appended to `targetUri` by the service.
71 */
72 sensitiveQueryString?: string;
73 /**
74 * Push-event filter. Empty or `*` reports every branch.
75 */
76 pushOption?: {
77 /** Glob of branches that trigger the hook. */
78 branchFilter?: string;
79 };
80};
81
82export type RepositoriesHook = Resource<
83 "GCP.SecureSourceManager.RepositoriesHook",
84 RepositoriesHookProps,
85 {
86 /** Full resource name. */
87 name: string;
88 /** Hook id (last path segment). */
89 hookId: string;
90 /** Parent repository resource name. */
91 repository: string;
92 /** Project id. */
93 project: string;
94 /** Location id. */
95 location: string;
96 /** Target URI with Alchemy ownership query parameters stripped. */
97 targetUri: string;
98 /** Events that fire the hook. */
99 events: HookEvent[];
100 /** Whether the hook is disabled. */
101 disabled: boolean;
102 /** Push-event filter. */
103 pushOption:
104 | {
105 branchFilter: string | undefined;
106 }
107 | undefined;
108 /** Server-generated resource uid. */
109 uid: string | undefined;
110 /** RFC3339 creation timestamp. */
111 createTime: string | undefined;
112 /** RFC3339 last-update timestamp. */
113 updateTime: string | undefined;
114 },
115 never,
116 Providers
117>;
118
119/**
120 * A Secure Source Manager repository webhook.
121 *
122 * Changing `hookId`, `repository`, or `location` replaces the hook.
123 * Target URI, events, disabled flag, and push options update in place.
124 * Ownership for `list` / nuke is stamped into `targetUri` query
125 * parameters (hooks have no labels field).
126 *
127 * ### Creating a Hook
128 * **Example:** Push webhook
129 * ```typescript
130 * const hook = yield* GCP.SecureSourceManager.RepositoriesHook("Notify", {
131 * repository: repo.name,
132 * targetUri: "https://example.com/hooks/ssm",
133 * events: ["PUSH"],
134 * });
135 * ```
136 *
137 * **Example:** Named hook with a branch filter
138 * ```typescript
139 * const hook = yield* GCP.SecureSourceManager.RepositoriesHook("Notify", {
140 * repository: repo.name,
141 * hookId: "prod-push",
142 * targetUri: "https://example.com/hooks/ssm",
143 * events: ["PUSH", "PULL_REQUEST"],
144 * pushOption: { branchFilter: "main" },
145 * });
146 * ```
147 *
148 * ### Updating a Hook
149 * Re-declare the same logical id with changed props; the engine keeps the
150 * physical resource and updates it in place.
151 *
152 * **Example:** Disable and retarget
153 * ```typescript
154 * const hook = yield* GCP.SecureSourceManager.RepositoriesHook("Notify", {
155 * repository: repo.name,
156 * targetUri: "https://example.com/hooks/ssm-v2",
157 * events: ["PUSH"],
158 * disabled: true,
159 * });
160 * ```
161 *
162 * @resource
163 * @category SecureSourceManager
164 */
165export const RepositoriesHook = Resource<RepositoriesHook>(
166 "GCP.SecureSourceManager.RepositoriesHook",
167);
168
169const resourceName = (repository: string, hookId: string) =>
170 `${repository}/hooks/${hookId}`;
171
172const toEvents = (
173 events: readonly (ssm.HookEventsItemEnum | (string & {}))[] | undefined,
174): HookEvent[] => [...(events ?? [])].slice().sort();
175
176const toAttrs = (item: ssm.Hook, project: string, region: string) => {
177 const name = item.name ?? "";
178 const parsed = parseName(name, "hooks", region);
179 return {
180 name,
181 hookId: parsed.id,
182 repository: parsed.parent,
183 project: parsed.project || project,
184 location: parsed.location,
185 targetUri: stripAlchemyQuery(item.targetUri ?? ""),
186 events: toEvents(item.events),
187 disabled: item.disabled === true,
188 pushOption:
189 item.pushOption === undefined
190 ? undefined
191 : { branchFilter: item.pushOption.branchFilter },
192 uid: item.uid,
193 createTime: item.createTime,
194 updateTime: item.updateTime,
195 };
196};
197
198const getByName = (name: string) =>
199 name.length === 0
200 ? Effect.succeed(undefined)
201 : catchMissing(ssm.getProjectsLocationsRepositoriesHooks({ name }));
202
203const listOnRepository = (repository: string) =>
204 collectPages(
205 ssm.listProjectsLocationsRepositoriesHooks.pages({
206 parent: repository,
207 pageSize: PAGE_SIZE,
208 }),
209 (page) => page.hooks,
210 );
211
212const listOwned = (project: string, region: string) =>
213 forEachRepository(project, region, (repository) =>
214 listOnRepository(repository).pipe(
215 Effect.map((items) =>
216 items.filter((item) => hasTargetUriOwnership(item.targetUri)),
217 ),
218 ),
219 );
220
221export const RepositoriesHookProvider = () =>
222 Provider.succeed(RepositoriesHook, {
223 stables: [
224 "name",
225 "hookId",
226 "repository",
227 "project",
228 "location",
229 "uid",
230 "createTime",
231 ],
232
233 diff: Effect.fn(function* ({ news, olds, output }) {
234 if (!isResolved(news)) return undefined;
235 const env = yield* GcpEnvironment.current;
236 const location = normalizeLocation(
237 news.location ?? olds?.location ?? output?.location,
238 env.region,
239 );
240 return replaceOnIdentity({
241 previousId: olds?.hookId ?? output?.hookId,
242 nextId: news.hookId
243 ? rfc1035(news.hookId, "hook")
244 : (olds?.hookId ?? output?.hookId),
245 previousLocation: normalizeLocation(
246 olds?.location ?? output?.location,
247 env.region,
248 ),
249 nextLocation: location,
250 previousParent: olds?.repository ?? output?.repository,
251 nextParent: expandName(
252 news.repository,
253 env.project,
254 location,
255 "repositories",
256 ),
257 });
258 }),
259
260 read: Effect.fn(function* ({ id, olds, output }) {
261 const env = yield* GcpEnvironment.current;
262 const hookId = yield* toPhysicalId(
263 id,
264 olds?.hookId,
265 output?.hookId,
266 "hook",
267 );
268 const location = normalizeLocation(
269 olds?.location ?? output?.location,
270 env.region,
271 );
272 const repository = expandName(
273 olds?.repository ?? output?.repository ?? "",
274 env.project,
275 location,
276 "repositories",
277 );
278 const name =
279 output?.name ??
280 (repository.length > 0 ? resourceName(repository, hookId) : "");
281 const existing = yield* getByName(name);
282 if (existing === undefined) return undefined;
283 const attrs = toAttrs(existing, env.project, env.region);
284 // A generated id embeds stack/stage/logical id, so finding it means we
285 // created it; an explicit id could name anyone's hook. Hooks created
286 // before ownership moved off the URI still carry query markers.
287 const owned =
288 output !== undefined ||
289 olds?.hookId === undefined ||
290 (yield* hasAlchemyLabels(
291 id,
292 parseTargetUriOwnership(existing.targetUri),
293 ));
294 return owned ? attrs : Unowned(attrs);
295 }),
296
297 list: () =>
298 Effect.gen(function* () {
299 const env = yield* GcpEnvironment.current;
300 const items = yield* listOwned(env.project, env.region);
301 return items.map((item: ssm.Hook) =>
302 toAttrs(item, env.project, env.region),
303 );
304 }),
305
306 reconcile: Effect.fn(function* ({ id, news, output }) {
307 const env = yield* GcpEnvironment.current;
308 const hookId = yield* toPhysicalId(
309 id,
310 news.hookId,
311 output?.hookId,
312 "hook",
313 );
314 const location = normalizeLocation(
315 news.location ?? output?.location,
316 env.region,
317 );
318 const repository = expandName(
319 news.repository,
320 env.project,
321 location,
322 "repositories",
323 );
324 const name = resourceName(repository, hookId);
325 const targetUri = news.targetUri;
326 const events = toEvents(news.events);
327
328 let current = yield* getByName(output?.name ?? name);
329
330 if (current === undefined) {
331 const created = yield* ssm
332 .createProjectsLocationsRepositoriesHooks({
333 parent: repository,
334 hookId,
335 body: {
336 targetUri,
337 events: events.length > 0 ? events : undefined,
338 disabled: news.disabled === true ? true : undefined,
339 sensitiveQueryString: news.sensitiveQueryString,
340 pushOption: news.pushOption,
341 },
342 })
343 .pipe(Effect.catchTag("Conflict", () => Effect.succeed(undefined)));
344 if (created !== undefined) {
345 yield* waitForOperation(created);
346 }
347 current = yield* waitUntilExists(getByName(name), name);
348 }
349
350 if (current === undefined) {
351 return yield* new ResourceNotResolved({ name });
352 }
353
354 const mask = fieldMask([
355 !sameText(current.targetUri, targetUri) && "targetUri",
356 fingerprint(toEvents(current.events)) !== fingerprint(events) &&
357 "events",
358 (current.disabled === true) !== (news.disabled === true) && "disabled",
359 !sameText(current.sensitiveQueryString, news.sensitiveQueryString) &&
360 "sensitiveQueryString",
361 fingerprint(current.pushOption) !== fingerprint(news.pushOption) &&
362 "pushOption",
363 ]);
364
365 if (mask.length > 0) {
366 const operation = yield* ssm.patchProjectsLocationsRepositoriesHooks({
367 name: current.name ?? name,
368 updateMask: mask,
369 body: {
370 targetUri,
371 events,
372 disabled: news.disabled === true,
373 sensitiveQueryString: news.sensitiveQueryString,
374 pushOption: news.pushOption,
375 },
376 });
377 yield* waitForOperation(operation);
378 current = yield* waitUntilExists(
379 getByName(current.name ?? name),
380 current.name ?? name,
381 );
382 }
383
384 return toAttrs(current, env.project, env.region);
385 }),
386
387 delete: Effect.fn(function* ({ output }) {
388 const operation = yield* retryConflict(
389 ssm
390 .deleteProjectsLocationsRepositoriesHooks({ name: output.name })
391 .pipe(Effect.catchTag("NotFound", () => Effect.succeed(undefined))),
392 );
393 if (operation !== undefined) {
394 yield* waitForOperation(operation, { notFoundOk: true });
395 }
396 yield* waitUntilGone(getByName(output.name), output.name);
397 }),
398 });