1import * as codebuild from "@distilled.cloud/aws/codebuild";
2import * as Effect from "effect/Effect";
3import * as Stream from "effect/Stream";
4import { Unowned } from "../../AdoptPolicy.ts";
5import { isResolved } from "../../Diff.ts";
6import { createPhysicalName } from "../../PhysicalName.ts";
7import * as Provider from "../../Provider.ts";
8import { Resource } from "../../Resource.ts";
9import { createInternalTags, hasAlchemyTags } from "../../Tags.ts";
10import { AWSEnvironment } from "../Environment.ts";
11
12/**
13 * Where CodeBuild exports the raw report data.
14 */
15export interface ReportGroupExportConfig {
16 /**
17 * `NO_EXPORT` keeps report data only in CodeBuild (30-day retention);
18 * `S3` additionally exports the raw data to a bucket.
19 * @default "NO_EXPORT"
20 */
21 exportConfigType?: "NO_EXPORT" | "S3";
22 /**
23 * S3 destination for exported report data (for `exportConfigType: "S3"`).
24 */
25 s3Destination?: {
26 /** Name of the destination bucket. */
27 bucket?: string;
28 /** AWS account that owns the bucket, when not the report's account. */
29 bucketOwner?: string;
30 /** Path prefix inside the bucket. */
31 path?: string;
32 /**
33 * Whether the exported data is zipped.
34 * @default "NONE"
35 */
36 packaging?: "ZIP" | "NONE";
37 /** KMS key used to encrypt the exported data. */
38 encryptionKey?: string;
39 /** Disable encryption of the exported data. */
40 encryptionDisabled?: boolean;
41 };
42}
43
44export interface ReportGroupProps {
45 /**
46 * Name of the report group (2-128 chars). Builds address it from the
47 * buildspec `reports:` section as `<project-name>-<group-name>` or by this
48 * exact name. If omitted a deterministic physical name is generated.
49 * Changing the name replaces the report group.
50 */
51 reportGroupName?: string;
52 /**
53 * What the group's reports contain: test results or code coverage.
54 * Changing the type replaces the report group.
55 */
56 type: "TEST" | "CODE_COVERAGE";
57 /**
58 * Where raw report data is exported.
59 * @default { exportConfigType: "NO_EXPORT" }
60 */
61 exportConfig?: ReportGroupExportConfig;
62 /**
63 * Also delete any reports in the group when the group is destroyed.
64 * @default true
65 */
66 deleteReports?: boolean;
67 /**
68 * User-defined tags.
69 */
70 tags?: Record<string, string>;
71}
72
73export interface ReportGroup extends Resource<
74 "AWS.CodeBuild.ReportGroup",
75 ReportGroupProps,
76 {
77 /** Physical name of the report group. */
78 reportGroupName: string;
79 /** ARN of the report group. */
80 reportGroupArn: string;
81 }
82> {}
83
84/**
85 * An AWS CodeBuild report group — a named collection of test or
86 * code-coverage reports produced by builds. A build's buildspec `reports:`
87 * section uploads its test results into a report group, and the report-read
88 * bindings ({@link BatchGetReports}, {@link DescribeTestCases}, …) let
89 * runtime code query them.
90 *
91 * ### Creating a Report Group
92 * **Example:** Test Report Group
93 * ```typescript
94 * const reports = yield* CodeBuild.ReportGroup("UnitTests", {
95 * type: "TEST",
96 * });
97 * ```
98 *
99 * **Example:** Coverage Group Exported to S3
100 * ```typescript
101 * const coverage = yield* CodeBuild.ReportGroup("Coverage", {
102 * type: "CODE_COVERAGE",
103 * exportConfig: {
104 * exportConfigType: "S3",
105 * s3Destination: { bucket: bucket.bucketName, packaging: "ZIP" },
106 * },
107 * });
108 * ```
109 *
110 * @resource
111 */
112export const ReportGroup = Resource<ReportGroup>("AWS.CodeBuild.ReportGroup");
113
114/** Convert a CodeBuild wire tag list into a plain record. */
115const toTagRecord = (
116 tags: ReadonlyArray<{ key?: string; value?: string }> | undefined,
117): Record<string, string> =>
118 Object.fromEntries(
119 (tags ?? [])
120 .filter(
121 (tag): tag is { key: string; value: string } =>
122 typeof tag.key === "string" && typeof tag.value === "string",
123 )
124 .map((tag) => [tag.key, tag.value]),
125 );
126
127const toWireTags = (tags: Record<string, string>): codebuild.Tag[] =>
128 Object.entries(tags).map(([key, value]) => ({ key, value }));
129
130const toWireExportConfig = (
131 exportConfig: ReportGroupExportConfig | undefined,
132): codebuild.ReportExportConfig => ({
133 exportConfigType: exportConfig?.exportConfigType ?? "NO_EXPORT",
134 s3Destination: exportConfig?.s3Destination,
135});
136
137/** Builds a report group's ARN from its name in the ambient account/region. */
138const reportGroupArn = Effect.fn(function* (name: string) {
139 const { accountId, region } = yield* AWSEnvironment.current;
140 return `arn:aws:codebuild:${region}:${accountId}:report-group/${name}`;
141});
142
143/** Structural comparison for the export-config sync no-op check. */
144const sameExportConfig = (
145 observed: codebuild.ReportExportConfig | undefined,
146 desired: codebuild.ReportExportConfig,
147): boolean =>
148 (observed?.exportConfigType ?? "NO_EXPORT") === desired.exportConfigType &&
149 JSON.stringify(observed?.s3Destination ?? null) ===
150 JSON.stringify(desired.s3Destination ?? null);
151
152const sameTags = (
153 observed: Record<string, string>,
154 desired: Record<string, string>,
155): boolean =>
156 Object.keys(observed).length === Object.keys(desired).length &&
157 Object.entries(desired).every(([k, v]) => observed[k] === v);
158
159export const ReportGroupProvider = () =>
160 Provider.effect(
161 ReportGroup,
162 Effect.gen(function* () {
163 const toName = (id: string, props: Partial<ReportGroupProps>) =>
164 props.reportGroupName
165 ? Effect.succeed(props.reportGroupName)
166 : createPhysicalName({ id, maxLength: 128 });
167
168 /** Read a report group by ARN; missing or DELETING reads as absent. */
169 const getReportGroup = Effect.fn(function* (arn: string) {
170 const response = yield* codebuild.batchGetReportGroups({
171 reportGroupArns: [arn],
172 });
173 const group = response.reportGroups?.[0];
174 return group?.status === "DELETING" ? undefined : group;
175 });
176
177 return {
178 stables: ["reportGroupName", "reportGroupArn"],
179
180 diff: Effect.fn(function* ({ id, olds, news }) {
181 if (!isResolved(news)) return undefined;
182 if (
183 (yield* toName(id, olds ?? {})) !==
184 (yield* toName(id, news ?? {})) ||
185 olds?.type !== news.type
186 ) {
187 return { action: "replace" } as const;
188 }
189 }),
190
191 read: Effect.fn(function* ({ id, olds, output }) {
192 const name =
193 output?.reportGroupName ?? (yield* toName(id, olds ?? {}));
194 const arn = output?.reportGroupArn ?? (yield* reportGroupArn(name));
195 const group = yield* getReportGroup(arn);
196 if (group === undefined || group.arn === undefined) {
197 return undefined;
198 }
199 const attrs = {
200 reportGroupName: group.name ?? name,
201 reportGroupArn: group.arn,
202 };
203 const tags = toTagRecord(group.tags);
204 return (yield* hasAlchemyTags(id, tags)) ? attrs : Unowned(attrs);
205 }),
206
207 reconcile: Effect.fn(function* ({ id, news, output, session }) {
208 const name = output?.reportGroupName ?? (yield* toName(id, news));
209 const arn = output?.reportGroupArn ?? (yield* reportGroupArn(name));
210 const internalTags = yield* createInternalTags(id);
211 const desiredTags = { ...internalTags, ...news.tags };
212 const desiredExport = toWireExportConfig(news.exportConfig);
213
214 // 1. Observe — cloud state is authoritative.
215 let observed = yield* getReportGroup(arn);
216
217 // 3. Sync — diff observed export config + tags against desired
218 // and skip the API entirely on a no-op. updateReportGroup is a
219 // full upsert of both mutable aspects. A delete→redeploy race can
220 // leave batchGetReportGroups returning the just-deleted group;
221 // updateReportGroup then reports the truth with a typed
222 // ResourceNotFoundException — treat it as missing.
223 if (
224 observed !== undefined &&
225 (!sameExportConfig(observed.exportConfig, desiredExport) ||
226 !sameTags(toTagRecord(observed.tags), desiredTags))
227 ) {
228 observed = yield* codebuild
229 .updateReportGroup({
230 arn,
231 exportConfig: desiredExport,
232 tags: toWireTags(desiredTags),
233 })
234 .pipe(
235 Effect.map((res) => res.reportGroup),
236 Effect.catchTag("ResourceNotFoundException", () =>
237 Effect.succeed(undefined),
238 ),
239 );
240 }
241
242 // 2. Ensure — create if missing; tolerate the create/create race.
243 if (observed === undefined) {
244 observed = yield* codebuild
245 .createReportGroup({
246 name,
247 type: news.type,
248 exportConfig: desiredExport,
249 tags: toWireTags(desiredTags),
250 })
251 .pipe(
252 Effect.map((res) => res.reportGroup),
253 Effect.catchTag("ResourceAlreadyExistsException", () =>
254 getReportGroup(arn),
255 ),
256 );
257 }
258
259 if (observed === undefined || observed.arn === undefined) {
260 return yield* Effect.fail(
261 new Error(
262 `CodeBuild report group '${name}' disappeared while reconciling`,
263 ),
264 );
265 }
266
267 yield* session.note(name);
268 return {
269 reportGroupName: observed.name ?? name,
270 reportGroupArn: observed.arn,
271 };
272 }),
273
274 delete: Effect.fn(function* ({ olds, output }) {
275 // deleteReportGroup is idempotent — deleting a missing group
276 // returns success. `deleteReports` clears contained reports first
277 // (a non-empty group cannot be deleted otherwise).
278 yield* codebuild.deleteReportGroup({
279 arn: output.reportGroupArn,
280 deleteReports: olds.deleteReports ?? true,
281 });
282 }),
283
284 list: () =>
285 codebuild.listReportGroups.pages({}).pipe(
286 Stream.runCollect,
287 Effect.map((chunk) =>
288 Array.from(chunk).flatMap((page) => page.reportGroups ?? []),
289 ),
290 Effect.flatMap((arns) =>
291 arns.length === 0
292 ? Effect.succeed(
293 [] as { reportGroupName: string; reportGroupArn: string }[],
294 )
295 : Effect.forEach(
296 // batchGetReportGroups accepts up to 100 ARNs per call.
297 chunkArns(arns, 100),
298 (batch) =>
299 codebuild
300 .batchGetReportGroups({ reportGroupArns: batch })
301 .pipe(Effect.map((res) => res.reportGroups ?? [])),
302 { concurrency: 2 },
303 ).pipe(
304 Effect.map((results) =>
305 results.flat().flatMap((g) =>
306 g.name !== undefined && g.arn !== undefined
307 ? [
308 {
309 reportGroupName: g.name,
310 reportGroupArn: g.arn,
311 },
312 ]
313 : [],
314 ),
315 ),
316 ),
317 ),
318 ),
319 };
320 }),
321 );
322
323/** Split a list into fixed-size chunks. */
324const chunkArns = (arns: string[], size: number): string[][] => {
325 const chunks: string[][] = [];
326 for (let i = 0; i < arns.length; i += size) {
327 chunks.push(arns.slice(i, i + size));
328 }
329 return chunks;
330};