1import type * as apigee from "@distilled.cloud/gcp/apigee_v1";
2import {
3 alchemyLabelKeys,
4 createInternalLabels,
5 hasAlchemyLabels,
6} from "../Labels.ts";
7
8export { createInternalLabels, hasAlchemyLabels };
9
10export const lastSegment = (value: string) => {
11 const trimmed = value.replace(/\/+$/, "");
12 const parts = trimmed.split("/");
13 return parts[parts.length - 1] || trimmed;
14};
15
16export const orgIdOf = (value: string | undefined, project?: string) =>
17 lastSegment(value ?? project ?? "");
18
19export const orgParent = (org: string) =>
20 org.startsWith("organizations/") ? org : `organizations/${org}`;
21
22export const xmlEscape = (value: string) =>
23 value
24 .replaceAll("&", "&")
25 .replaceAll("<", "&lt;")
26 .replaceAll(">", "&gt;")
27 .replaceAll('"', "&quot;")
28 .replaceAll("'", "&apos;");
29
30export const xmlUnescape = (value: string) =>
31 value
32 .replaceAll("&lt;", "<")
33 .replaceAll("&gt;", ">")
34 .replaceAll("&quot;", '"')
35 .replaceAll("&apos;", "'")
36 .replaceAll("&amp;", "&");
37
38export const encodeOwnership = (
39 labels: Record<string, string>,
40 userText: string | undefined,
41 extra?: Record<string, string>,
42): string => {
43 const pairs = [
44 `${alchemyLabelKeys.stack}=${labels[alchemyLabelKeys.stack]}`,
45 `${alchemyLabelKeys.stage}=${labels[alchemyLabelKeys.stage]}`,
46 `${alchemyLabelKeys.id}=${labels[alchemyLabelKeys.id]}`,
47 ...Object.entries(extra ?? {}).map(([key, value]) => `${key}=${value}`),
48 ];
49 const marker = `[alchemy ${pairs.join(" ")}]`;
50 return userText ? `${marker}\n${userText}` : marker;
51};
52
53export const parseOwnership = (
54 value: string | undefined,
55): {
56 labels: Record<string, string>;
57 text: string | undefined;
58} => {
59 if (!value?.startsWith("[alchemy ")) {
60 return { labels: {}, text: value };
61 }
62 const end = value.indexOf("]");
63 if (end < 0) return { labels: {}, text: value };
64 const labels: Record<string, string> = {};
65 for (const part of value.slice("[alchemy ".length, end).split(/\s+/)) {
66 const eq = part.indexOf("=");
67 if (eq > 0) {
68 labels[part.slice(0, eq)] = part.slice(eq + 1);
69 }
70 }
71 const rest = value.slice(end + 1).replace(/^\s+/, "");
72 return { labels, text: rest.length > 0 ? rest : undefined };
73};
74
75export const hasOwnershipMarker = (value: string | undefined) =>
76 Object.keys(parseOwnership(value).labels).some((key) =>
77 key.startsWith("alchemy-"),
78 );
79
80export const encodeDescription = (
81 labels: Record<string, string>,
82 description: string | undefined,
83 maxLength?: number,
84): string => {
85 const encoded = encodeOwnership(labels, description);
86 return maxLength !== undefined ? encoded.slice(0, maxLength) : encoded;
87};
88
89export const parseDescription = (
90 description: string | undefined,
91): {
92 labels: Record<string, string>;
93 description: string | undefined;
94} => {
95 const parsed = parseOwnership(description);
96 return { labels: parsed.labels, description: parsed.text };
97};
98
99export const encodeComments = (
100 labels: Record<string, string>,
101 comments: readonly string[] | undefined,
102): string[] => {
103 const marker = encodeOwnership(labels, undefined);
104 const rest = (comments ?? []).filter(
105 (comment) => !comment.startsWith("[alchemy "),
106 );
107 return [marker, ...rest];
108};
109
110export const parseComments = (
111 comments: readonly string[] | undefined,
112): {
113 labels: Record<string, string>;
114 comments: string[];
115} => {
116 const list = [...(comments ?? [])];
117 const marked = list.find((comment) => comment.startsWith("[alchemy "));
118 const parsed = parseOwnership(marked);
119 return {
120 labels: parsed.labels,
121 comments: list.filter((comment) => !comment.startsWith("[alchemy ")),
122 };
123};
124
125export const commentsHaveOwnership = (
126 comments: readonly string[] | undefined,
127) =>
128 hasOwnershipMarker(
129 comments?.find((comment) => comment.startsWith("[alchemy ")),
130 );
131
132export type Attribute = {
133 name?: string;
134 value?: string;
135};
136
137const isAttributeObject = (
138 value: readonly Attribute[] | { attribute?: readonly Attribute[] },
139): value is { attribute?: readonly Attribute[] } => !Array.isArray(value);
140
141const attributeList = (
142 value:
143 | readonly Attribute[]
144 | { attribute?: readonly Attribute[] }
145 | undefined,
146): Attribute[] => {
147 if (value === undefined) return [];
148 if (isAttributeObject(value)) return [...(value.attribute ?? [])];
149 return [...value];
150};
151
152export const fromAttributes = (
153 value:
154 | readonly Attribute[]
155 | { attribute?: readonly Attribute[] }
156 | undefined,
157): {
158 labels: Record<string, string>;
159 attributes: Record<string, string>;
160} => {
161 const labels: Record<string, string> = {};
162 const attributes: Record<string, string> = {};
163 for (const item of attributeList(value)) {
164 if (item.name === undefined) continue;
165 const next = item.value ?? "";
166 if (item.name.startsWith("alchemy-")) labels[item.name] = next;
167 else attributes[item.name] = next;
168 }
169 return { labels, attributes };
170};
171
172const isAttributeArray = (
173 value: Record<string, string> | readonly Attribute[],
174): value is readonly Attribute[] => Array.isArray(value);
175
176export const toAttributes = (
177 ownership: Record<string, string>,
178 user?: Record<string, string> | readonly Attribute[],
179): Attribute[] => {
180 const record =
181 user === undefined
182 ? {}
183 : isAttributeArray(user)
184 ? fromAttributes(user).attributes
185 : user;
186 return Object.entries({ ...record, ...ownership }).map(([name, value]) => ({
187 name,
188 value,
189 }));
190};
191
192export const userAttributeList = (
193 value:
194 | readonly Attribute[]
195 | { attribute?: readonly Attribute[] }
196 | undefined,
197): Attribute[] =>
198 Object.entries(fromAttributes(value).attributes).map(([name, value]) => ({
199 name,
200 value,
201 }));
202
203const OWNERSHIP_EXTENSION = "alchown";
204
205const sanitizeHostname = (value: string) =>
206 value.replace(/[^a-zA-Z0-9._-]/g, "-").replace(/^-+|-+$/g, "") || "x";
207
208export const withOwnershipExtension = (
209 labels: Record<string, string>,
210 extensions:
211 | readonly apigee.GoogleCloudApigeeV1ApimServiceExtensionExtension[]
212 | undefined,
213): apigee.GoogleCloudApigeeV1ApimServiceExtensionExtension[] => [
214 {
215 name: OWNERSHIP_EXTENSION,
216 hostname: [
217 sanitizeHostname(labels[alchemyLabelKeys.stack] ?? "stack"),
218 sanitizeHostname(labels[alchemyLabelKeys.stage] ?? "stage"),
219 sanitizeHostname(labels[alchemyLabelKeys.id] ?? "id"),
220 ].join("."),
221 matchCondition: "false",
222 },
223 ...(extensions ?? []).filter(
224 (extension) => extension.name !== OWNERSHIP_EXTENSION,
225 ),
226];
227
228export const parseOwnershipExtension = (
229 extensions:
230 | readonly apigee.GoogleCloudApigeeV1ApimServiceExtensionExtension[]
231 | undefined,
232): Record<string, string> => {
233 const found = (extensions ?? []).find(
234 (extension) => extension.name === OWNERSHIP_EXTENSION,
235 );
236 if (found?.hostname === undefined) return {};
237 const [stack, stage, ...idParts] = found.hostname.split(".");
238 if (stack === undefined || stage === undefined || idParts.length === 0) {
239 return {};
240 }
241 return {
242 [alchemyLabelKeys.stack]: stack,
243 [alchemyLabelKeys.stage]: stage,
244 [alchemyLabelKeys.id]: idParts.join("."),
245 };
246};
247
248export const hasOwnershipExtension = (
249 extensions:
250 | readonly apigee.GoogleCloudApigeeV1ApimServiceExtensionExtension[]
251 | undefined,
252) =>
253 (extensions ?? []).some(
254 (extension) => extension.name === OWNERSHIP_EXTENSION,
255 );
256
257export const stripOwnershipExtension = (
258 extensions:
259 | readonly apigee.GoogleCloudApigeeV1ApimServiceExtensionExtension[]
260 | undefined,
261) =>
262 (extensions ?? []).filter(
263 (extension) => extension.name !== OWNERSHIP_EXTENSION,
264 );