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 { tagRecord } from "../../Tags.ts";
8import { GcpEnvironment } from "../Environment.ts";
9import {
10 createInternalLabels,
11 diffLabels,
12 hasAlchemyLabels,
13} from "../Labels.ts";
14import type { Providers } from "../Providers.ts";
15import {
16 catchMissing,
17 collectPages,
18 desiredAnnotations,
19 expandName,
20 fieldMask,
21 fingerprint,
22 forEachRepository,
23 hasAlchemyLabelMap,
24 normalizeLocation,
25 PAGE_SIZE,
26 parseName,
27 replaceOnIdentity,
28 ResourceNotResolved,
29 retryConflict,
30 rfc1035,
31 sameText,
32 toPhysicalId,
33 userLabels,
34 waitForOperation,
35 waitUntilExists,
36 waitUntilGone,
37} from "./internal.ts";
38
39export type BranchRuleCheck = {
40 /** Required status-check context (GitHub-style check name). */
41 context?: string;
42};
43
44export type RepositoriesBranchRuleProps = {
45 /**
46 * Parent repository. Full name
47 * `projects/{project}/locations/{location}/repositories/{repository}`
48 * or the repository id (combined with `location`). Immutable —
49 * changing it replaces the rule.
50 */
51 repository: string;
52 /**
53 * Region used when `repository` is a bare id.
54 * @default the stack's GCP region (`GCP.Region`, else the profile region, else `us-central1`)
55 */
56 location?: string;
57 /**
58 * Branch rule id (the `{branchRule}` segment). If omitted, a unique
59 * RFC1035 name is generated. Immutable — changing it replaces the rule.
60 */
61 branchRuleId?: string;
62 /**
63 * Regex of branches this rule matches. `.*` matches every branch.
64 * @default ".*"
65 */
66 includePattern?: string;
67 /**
68 * When true, the rule is disabled.
69 * @default false
70 */
71 disabled?: boolean;
72 /**
73 * When true, matching branches require a pull request.
74 * @default false
75 */
76 requirePullRequest?: boolean;
77 /**
78 * Minimum number of approvals required before merge.
79 */
80 minimumApprovalsCount?: number;
81 /**
82 * Minimum number of reviews required before merge.
83 */
84 minimumReviewsCount?: number;
85 /**
86 * When true, reviews may be stale relative to the latest commit.
87 * @default false
88 */
89 allowStaleReviews?: boolean;
90 /**
91 * When true, require linear history before merge.
92 * @default false
93 */
94 requireLinearHistory?: boolean;
95 /**
96 * When true, require all comments resolved before merge.
97 * @default false
98 */
99 requireCommentsResolved?: boolean;
100 /**
101 * When true, require a code-owner approval before merge.
102 * @default false
103 */
104 requireCodeOwnerApproval?: boolean;
105 /**
106 * Required status checks before merging.
107 */
108 requiredStatusChecks?: BranchRuleCheck[];
109 /**
110 * User annotations. Alchemy ownership keys (`alchemy-stack`,
111 * `alchemy-stage`, `alchemy-id`) are merged in automatically. Branch
112 * rules have no labels field.
113 */
114 annotations?: Record<string, string>;
115};
116
117export type RepositoriesBranchRule = Resource<
118 "GCP.SecureSourceManager.RepositoriesBranchRule",
119 RepositoriesBranchRuleProps,
120 {
121 /** Full resource name. */
122 name: string;
123 /** Branch rule id (last path segment). */
124 branchRuleId: string;
125 /** Parent repository resource name. */
126 repository: string;
127 /** Project id. */
128 project: string;
129 /** Location id. */
130 location: string;
131 /** Branch-name regex this rule matches. */
132 includePattern: string | undefined;
133 /** Whether the rule is disabled. */
134 disabled: boolean;
135 /** Whether a pull request is required. */
136 requirePullRequest: boolean;
137 /** Minimum approvals required. */
138 minimumApprovalsCount: number | undefined;
139 /** Minimum reviews required. */
140 minimumReviewsCount: number | undefined;
141 /** Whether stale reviews are allowed. */
142 allowStaleReviews: boolean;
143 /** Whether linear history is required. */
144 requireLinearHistory: boolean;
145 /** Whether comments must be resolved. */
146 requireCommentsResolved: boolean;
147 /** Whether code-owner approval is required. */
148 requireCodeOwnerApproval: boolean;
149 /** Required status checks. */
150 requiredStatusChecks: BranchRuleCheck[];
151 /** User annotations (Alchemy ownership keys stripped). */
152 annotations: Record<string, string>;
153 /** Server-generated resource uid. */
154 uid: string | undefined;
155 /** RFC3339 creation timestamp. */
156 createTime: string | undefined;
157 /** RFC3339 last-update timestamp. */
158 updateTime: string | undefined;
159 /** Server etag. */
160 etag: string | undefined;
161 },
162 never,
163 Providers
164>;
165
166/**
167 * A Secure Source Manager branch protection rule.
168 *
169 * Changing `branchRuleId`, `repository`, or `location` replaces the rule.
170 * Pattern, flags, checks, and annotations update in place. Ownership for
171 * `list` / nuke is stamped into `annotations`.
172 *
173 * ### Creating a Branch Rule
174 * **Example:** Protect the default branch
175 * ```typescript
176 * const rule = yield* GCP.SecureSourceManager.RepositoriesBranchRule("Main", {
177 * repository: repo.name,
178 * includePattern: "main",
179 * requirePullRequest: true,
180 * minimumApprovalsCount: 1,
181 * });
182 * ```
183 *
184 * **Example:** Named rule with annotations
185 * ```typescript
186 * const rule = yield* GCP.SecureSourceManager.RepositoriesBranchRule("Main", {
187 * repository: repo.name,
188 * branchRuleId: "protect-main",
189 * includePattern: "main",
190 * requireCommentsResolved: true,
191 * annotations: { env: "prod" },
192 * });
193 * ```
194 *
195 * ### Updating a Branch Rule
196 * Re-declare the same logical id with changed props; the engine keeps the
197 * physical resource and updates it in place.
198 *
199 * **Example:** Raise the approval floor
200 * ```typescript
201 * const rule = yield* GCP.SecureSourceManager.RepositoriesBranchRule("Main", {
202 * repository: repo.name,
203 * includePattern: "main",
204 * requirePullRequest: true,
205 * minimumApprovalsCount: 2,
206 * annotations: { env: "prod", team: "platform" },
207 * });
208 * ```
209 *
210 * @resource
211 * @category SecureSourceManager
212 */
213export const RepositoriesBranchRule = Resource<RepositoriesBranchRule>(
214 "GCP.SecureSourceManager.RepositoriesBranchRule",
215);
216
217const resourceName = (repository: string, branchRuleId: string) =>
218 `${repository}/branchRules/${branchRuleId}`;
219
220const toChecks = (
221 checks: readonly ssm.Check[] | readonly BranchRuleCheck[] | undefined,
222): BranchRuleCheck[] =>
223 (checks ?? [])
224 .map((check) => ({ context: check.context }))
225 .sort((left, right) =>
226 (left.context ?? "").localeCompare(right.context ?? ""),
227 );
228
229const toAttrs = (item: ssm.BranchRule, project: string, region: string) => {
230 const name = item.name ?? "";
231 const parsed = parseName(name, "branchRules", region);
232 return {
233 name,
234 branchRuleId: parsed.id,
235 repository: parsed.parent,
236 project: parsed.project || project,
237 location: parsed.location,
238 includePattern: item.includePattern,
239 disabled: item.disabled === true,
240 requirePullRequest: item.requirePullRequest === true,
241 minimumApprovalsCount: item.minimumApprovalsCount,
242 minimumReviewsCount: item.minimumReviewsCount,
243 allowStaleReviews: item.allowStaleReviews === true,
244 requireLinearHistory: item.requireLinearHistory === true,
245 requireCommentsResolved: item.requireCommentsResolved === true,
246 requireCodeOwnerApproval: item.requireCodeOwnerApproval === true,
247 requiredStatusChecks: toChecks(item.requiredStatusChecks),
248 annotations: userLabels(item.annotations),
249 uid: item.uid,
250 createTime: item.createTime,
251 updateTime: item.updateTime,
252 etag: item.etag,
253 };
254};
255
256const getByName = (name: string) =>
257 name.length === 0
258 ? Effect.succeed(undefined)
259 : catchMissing(ssm.getProjectsLocationsRepositoriesBranchRules({ name }));
260
261const listOnRepository = (repository: string) =>
262 collectPages(
263 ssm.listProjectsLocationsRepositoriesBranchRules.pages({
264 parent: repository,
265 pageSize: PAGE_SIZE,
266 }),
267 (page) => page.branchRules,
268 );
269
270const listOwned = (project: string, region: string) =>
271 forEachRepository(project, region, (repository) =>
272 listOnRepository(repository).pipe(
273 Effect.map((items) =>
274 items.filter((item) => hasAlchemyLabelMap(item.annotations)),
275 ),
276 ),
277 );
278
279export const RepositoriesBranchRuleProvider = () =>
280 Provider.succeed(RepositoriesBranchRule, {
281 stables: [
282 "name",
283 "branchRuleId",
284 "repository",
285 "project",
286 "location",
287 "uid",
288 "createTime",
289 ],
290
291 diff: Effect.fn(function* ({ news, olds, output }) {
292 if (!isResolved(news)) return undefined;
293 const env = yield* GcpEnvironment.current;
294 const location = normalizeLocation(
295 news.location ?? olds?.location ?? output?.location,
296 env.region,
297 );
298 return replaceOnIdentity({
299 previousId: olds?.branchRuleId ?? output?.branchRuleId,
300 nextId: news.branchRuleId
301 ? rfc1035(news.branchRuleId, "branchrule")
302 : (olds?.branchRuleId ?? output?.branchRuleId),
303 previousLocation: normalizeLocation(
304 olds?.location ?? output?.location,
305 env.region,
306 ),
307 nextLocation: location,
308 previousParent: olds?.repository ?? output?.repository,
309 nextParent: expandName(
310 news.repository,
311 env.project,
312 location,
313 "repositories",
314 ),
315 });
316 }),
317
318 read: Effect.fn(function* ({ id, olds, output }) {
319 const env = yield* GcpEnvironment.current;
320 const branchRuleId = yield* toPhysicalId(
321 id,
322 olds?.branchRuleId,
323 output?.branchRuleId,
324 "branchrule",
325 );
326 const location = normalizeLocation(
327 olds?.location ?? output?.location,
328 env.region,
329 );
330 const repository = expandName(
331 olds?.repository ?? output?.repository ?? "",
332 env.project,
333 location,
334 "repositories",
335 );
336 const name =
337 output?.name ??
338 (repository.length > 0 ? resourceName(repository, branchRuleId) : "");
339 const existing = yield* getByName(name);
340 if (existing === undefined) return undefined;
341 const attrs = toAttrs(existing, env.project, env.region);
342 return (yield* hasAlchemyLabels(id, tagRecord(existing.annotations)))
343 ? attrs
344 : Unowned(attrs);
345 }),
346
347 list: () =>
348 Effect.gen(function* () {
349 const env = yield* GcpEnvironment.current;
350 const items = yield* listOwned(env.project, env.region);
351 return items.map((item: ssm.BranchRule) =>
352 toAttrs(item, env.project, env.region),
353 );
354 }),
355
356 reconcile: Effect.fn(function* ({ id, news, output }) {
357 const env = yield* GcpEnvironment.current;
358 const branchRuleId = yield* toPhysicalId(
359 id,
360 news.branchRuleId,
361 output?.branchRuleId,
362 "branchrule",
363 );
364 const location = normalizeLocation(
365 news.location ?? output?.location,
366 env.region,
367 );
368 const repository = expandName(
369 news.repository,
370 env.project,
371 location,
372 "repositories",
373 );
374 const name = resourceName(repository, branchRuleId);
375 const ownership = yield* createInternalLabels(id);
376 const annotations = desiredAnnotations(news.annotations, ownership);
377 const includePattern = news.includePattern ?? ".*";
378 const requiredStatusChecks = toChecks(news.requiredStatusChecks);
379
380 let current = yield* getByName(output?.name ?? name);
381
382 if (current === undefined) {
383 const created = yield* ssm
384 .createProjectsLocationsRepositoriesBranchRules({
385 parent: repository,
386 branchRuleId,
387 body: {
388 includePattern,
389 disabled: news.disabled === true ? true : undefined,
390 requirePullRequest:
391 news.requirePullRequest === true ? true : undefined,
392 minimumApprovalsCount: news.minimumApprovalsCount,
393 minimumReviewsCount: news.minimumReviewsCount,
394 allowStaleReviews:
395 news.allowStaleReviews === true ? true : undefined,
396 requireLinearHistory:
397 news.requireLinearHistory === true ? true : undefined,
398 requireCommentsResolved:
399 news.requireCommentsResolved === true ? true : undefined,
400 requireCodeOwnerApproval:
401 news.requireCodeOwnerApproval === true ? true : undefined,
402 requiredStatusChecks:
403 requiredStatusChecks.length > 0
404 ? requiredStatusChecks
405 : undefined,
406 annotations,
407 },
408 })
409 .pipe(Effect.catchTag("Conflict", () => Effect.succeed(undefined)));
410 if (created !== undefined) {
411 yield* waitForOperation(created);
412 }
413 current = yield* waitUntilExists(getByName(name), name);
414 }
415
416 if (current === undefined) {
417 return yield* new ResourceNotResolved({ name });
418 }
419
420 const observed = tagRecord(current.annotations);
421 const { upsert, removed } = diffLabels(observed, annotations);
422 const mask = fieldMask([
423 (upsert.length > 0 || removed.length > 0) && "annotations",
424 !sameText(current.includePattern, includePattern) && "includePattern",
425 (current.disabled === true) !== (news.disabled === true) && "disabled",
426 (current.requirePullRequest === true) !==
427 (news.requirePullRequest === true) && "requirePullRequest",
428 current.minimumApprovalsCount !== news.minimumApprovalsCount &&
429 "minimumApprovalsCount",
430 current.minimumReviewsCount !== news.minimumReviewsCount &&
431 "minimumReviewsCount",
432 (current.allowStaleReviews === true) !==
433 (news.allowStaleReviews === true) && "allowStaleReviews",
434 (current.requireLinearHistory === true) !==
435 (news.requireLinearHistory === true) && "requireLinearHistory",
436 (current.requireCommentsResolved === true) !==
437 (news.requireCommentsResolved === true) && "requireCommentsResolved",
438 (current.requireCodeOwnerApproval === true) !==
439 (news.requireCodeOwnerApproval === true) &&
440 "requireCodeOwnerApproval",
441 fingerprint(toChecks(current.requiredStatusChecks)) !==
442 fingerprint(requiredStatusChecks) && "requiredStatusChecks",
443 ]);
444
445 if (mask.length > 0) {
446 const operation =
447 yield* ssm.patchProjectsLocationsRepositoriesBranchRules({
448 name: current.name ?? name,
449 updateMask: mask,
450 body: {
451 etag: current.etag,
452 includePattern,
453 disabled: news.disabled === true,
454 requirePullRequest: news.requirePullRequest === true,
455 minimumApprovalsCount: news.minimumApprovalsCount,
456 minimumReviewsCount: news.minimumReviewsCount,
457 allowStaleReviews: news.allowStaleReviews === true,
458 requireLinearHistory: news.requireLinearHistory === true,
459 requireCommentsResolved: news.requireCommentsResolved === true,
460 requireCodeOwnerApproval: news.requireCodeOwnerApproval === true,
461 requiredStatusChecks,
462 annotations,
463 },
464 });
465 yield* waitForOperation(operation);
466 current = yield* waitUntilExists(
467 getByName(current.name ?? name),
468 current.name ?? name,
469 );
470 }
471
472 return toAttrs(current, env.project, env.region);
473 }),
474
475 delete: Effect.fn(function* ({ output }) {
476 const operation = yield* retryConflict(
477 ssm
478 .deleteProjectsLocationsRepositoriesBranchRules({
479 name: output.name,
480 allowMissing: true,
481 })
482 .pipe(Effect.catchTag("NotFound", () => Effect.succeed(undefined))),
483 );
484 if (operation !== undefined) {
485 yield* waitForOperation(operation, { notFoundOk: true });
486 }
487 yield* waitUntilGone(getByName(output.name), output.name);
488 }),
489 });