@checkstack/automation-frontend 0.2.0 → 0.3.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/CHANGELOG.md +352 -0
- package/package.json +13 -9
- package/src/components/AutomationGroupCombobox.tsx +133 -0
- package/src/editor/ActionEditor.tsx +180 -90
- package/src/editor/ActionListEditor.tsx +27 -1
- package/src/editor/AddActionDialog.tsx +15 -45
- package/src/editor/AddConditionDialog.tsx +86 -0
- package/src/editor/AddTriggerDialog.tsx +97 -0
- package/src/editor/AutomationDefinitionEditor.tsx +41 -2
- package/src/editor/ConditionEditor.tsx +359 -70
- package/src/editor/ConditionsEditor.tsx +113 -44
- package/src/editor/ItemSheet.tsx +51 -0
- package/src/editor/RunReplayPicker.tsx +97 -0
- package/src/editor/ScriptServicesBooter.tsx +53 -0
- package/src/editor/ScriptTestRenderer.tsx +150 -0
- package/src/editor/SystemEntityPicker.test.ts +37 -0
- package/src/editor/SystemEntityPicker.tsx +109 -0
- package/src/editor/TriggersEditor.tsx +345 -137
- package/src/editor/action-helpers.test.ts +107 -0
- package/src/editor/action-helpers.ts +72 -0
- package/src/editor/action-leaf-cards.tsx +98 -1
- package/src/editor/condition-kind.test.ts +126 -0
- package/src/editor/condition-kind.ts +130 -0
- package/src/editor/item-summary.test.ts +171 -0
- package/src/editor/item-summary.ts +210 -0
- package/src/editor/picker-dialog.tsx +156 -0
- package/src/editor/registry-context.tsx +9 -2
- package/src/editor/script-actions.test.ts +184 -0
- package/src/editor/script-actions.ts +146 -0
- package/src/editor/system-entity-picker.logic.ts +23 -0
- package/src/editor/template-completion.test.ts +22 -3
- package/src/editor/template-completion.ts +16 -8
- package/src/editor/template-helpers.ts +4 -0
- package/src/editor/trigger-helpers.test.ts +28 -0
- package/src/editor/trigger-helpers.ts +17 -0
- package/src/editor/useScriptDiagnostics.ts +108 -0
- package/src/index.tsx +2 -0
- package/src/pages/AutomationEditPage.tsx +95 -47
- package/src/pages/AutomationListPage.tsx +172 -123
- package/src/pages/automation-grouping.test.ts +86 -0
- package/src/pages/automation-grouping.ts +65 -0
- package/src/script-context.test.ts +142 -1
- package/src/script-context.ts +115 -0
- package/tsconfig.json +12 -0
|
@@ -0,0 +1,65 @@
|
|
|
1
|
+
import type { Automation } from "@checkstack/automation-common";
|
|
2
|
+
|
|
3
|
+
/**
|
|
4
|
+
* Label for the implicit bucket holding automations with no `group`.
|
|
5
|
+
* Always sorted LAST so it sits at the bottom of the list.
|
|
6
|
+
*/
|
|
7
|
+
export const UNGROUPED_LABEL = "Ungrouped";
|
|
8
|
+
|
|
9
|
+
export interface AutomationGroup {
|
|
10
|
+
/** Display label (the group value, or {@link UNGROUPED_LABEL}). */
|
|
11
|
+
label: string;
|
|
12
|
+
/** Stable key for React + accordion state (the label is unique already). */
|
|
13
|
+
key: string;
|
|
14
|
+
/** Whether this is the implicit ungrouped bucket. */
|
|
15
|
+
ungrouped: boolean;
|
|
16
|
+
items: Automation[];
|
|
17
|
+
}
|
|
18
|
+
|
|
19
|
+
/**
|
|
20
|
+
* Partition a flat automation list into named groups for the collapsible
|
|
21
|
+
* list view. Pure so it is unit-testable without a DOM render.
|
|
22
|
+
*
|
|
23
|
+
* - Automations with a non-empty `group` land in that group.
|
|
24
|
+
* - Automations with an absent / blank `group` land in {@link UNGROUPED_LABEL}.
|
|
25
|
+
* - Named groups are sorted alphabetically (case-insensitive, locale-aware);
|
|
26
|
+
* the ungrouped bucket is always appended LAST.
|
|
27
|
+
* - Item order within a group is preserved from the input.
|
|
28
|
+
* - Empty groups are never produced.
|
|
29
|
+
*/
|
|
30
|
+
export function groupAutomations({
|
|
31
|
+
automations,
|
|
32
|
+
}: {
|
|
33
|
+
automations: Automation[];
|
|
34
|
+
}): AutomationGroup[] {
|
|
35
|
+
const named = new Map<string, Automation[]>();
|
|
36
|
+
const ungrouped: Automation[] = [];
|
|
37
|
+
|
|
38
|
+
for (const automation of automations) {
|
|
39
|
+
const group = automation.group?.trim();
|
|
40
|
+
if (group) {
|
|
41
|
+
const bucket = named.get(group);
|
|
42
|
+
if (bucket) bucket.push(automation);
|
|
43
|
+
else named.set(group, [automation]);
|
|
44
|
+
} else {
|
|
45
|
+
ungrouped.push(automation);
|
|
46
|
+
}
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
const groups: AutomationGroup[] = [...named.entries()]
|
|
50
|
+
.toSorted(([a], [b]) =>
|
|
51
|
+
a.localeCompare(b, undefined, { sensitivity: "base" }),
|
|
52
|
+
)
|
|
53
|
+
.map(([label, items]) => ({ label, key: label, ungrouped: false, items }));
|
|
54
|
+
|
|
55
|
+
if (ungrouped.length > 0) {
|
|
56
|
+
groups.push({
|
|
57
|
+
label: UNGROUPED_LABEL,
|
|
58
|
+
key: UNGROUPED_LABEL,
|
|
59
|
+
ungrouped: true,
|
|
60
|
+
items: ungrouped,
|
|
61
|
+
});
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
return groups;
|
|
65
|
+
}
|
|
@@ -13,7 +13,11 @@ import type {
|
|
|
13
13
|
AutomationDefinition,
|
|
14
14
|
TriggerInfo,
|
|
15
15
|
} from "@checkstack/automation-common";
|
|
16
|
-
import {
|
|
16
|
+
import {
|
|
17
|
+
generateAutomationContextTypes,
|
|
18
|
+
generateSecretEnvTypes,
|
|
19
|
+
secretEnvEnvNames,
|
|
20
|
+
} from "./script-context";
|
|
17
21
|
|
|
18
22
|
const incidentCreated: TriggerInfo = {
|
|
19
23
|
qualifiedId: "incident.created",
|
|
@@ -69,6 +73,7 @@ function baseDef(
|
|
|
69
73
|
conditions: [],
|
|
70
74
|
actions: [],
|
|
71
75
|
mode: "single",
|
|
76
|
+
concurrency_scope: "automation",
|
|
72
77
|
max_runs: 1,
|
|
73
78
|
...overrides,
|
|
74
79
|
};
|
|
@@ -245,3 +250,139 @@ describe("generateAutomationContextTypes", () => {
|
|
|
245
250
|
expect(scope.entries.some((e) => e.path === "trigger")).toBe(true);
|
|
246
251
|
});
|
|
247
252
|
});
|
|
253
|
+
|
|
254
|
+
describe("generateSecretEnvTypes", () => {
|
|
255
|
+
it("emits a ProcessEnv augmentation typing each declared key as string", () => {
|
|
256
|
+
const out = generateSecretEnvTypes({ envNames: ["API_TOKEN", "DB_PASS"] });
|
|
257
|
+
expect(out).toContain("declare namespace NodeJS");
|
|
258
|
+
expect(out).toContain("interface ProcessEnv");
|
|
259
|
+
expect(out).toContain("readonly API_TOKEN: string;");
|
|
260
|
+
expect(out).toContain("readonly DB_PASS: string;");
|
|
261
|
+
});
|
|
262
|
+
|
|
263
|
+
it("stays a global script - no top-level export/import that would modularize the merged extra-lib", () => {
|
|
264
|
+
// A single top-level `export`/`import` would turn the concatenated
|
|
265
|
+
// `context + secretEnv` extra-lib into a module, demoting the
|
|
266
|
+
// `declare const context` global to a module-local binding and breaking
|
|
267
|
+
// `context.*` IntelliSense. The augmentation must use an ambient
|
|
268
|
+
// `declare namespace`, never `declare global { … } export {};`.
|
|
269
|
+
const out = generateSecretEnvTypes({ envNames: ["API_TOKEN"] });
|
|
270
|
+
expect(out).not.toMatch(/^\s*export\b/m);
|
|
271
|
+
expect(out).not.toMatch(/^\s*import\b/m);
|
|
272
|
+
expect(out).not.toContain("declare global");
|
|
273
|
+
});
|
|
274
|
+
|
|
275
|
+
it("emits an empty string for empty input so the caller drops it", () => {
|
|
276
|
+
expect(generateSecretEnvTypes({ envNames: [] })).toBe("");
|
|
277
|
+
});
|
|
278
|
+
|
|
279
|
+
it("skips invalid identifiers but keeps the valid ones", () => {
|
|
280
|
+
const out = generateSecretEnvTypes({
|
|
281
|
+
envNames: ["GOOD", "BAD-NAME", "123start", "with space", "_ok$"],
|
|
282
|
+
});
|
|
283
|
+
expect(out).toContain("readonly GOOD: string;");
|
|
284
|
+
expect(out).toContain("readonly _ok$: string;");
|
|
285
|
+
expect(out).not.toContain("BAD-NAME");
|
|
286
|
+
expect(out).not.toContain("123start");
|
|
287
|
+
expect(out).not.toContain("with space");
|
|
288
|
+
});
|
|
289
|
+
|
|
290
|
+
it("emits an empty string when every name is invalid", () => {
|
|
291
|
+
expect(generateSecretEnvTypes({ envNames: ["BAD-NAME", "1x"] })).toBe("");
|
|
292
|
+
});
|
|
293
|
+
|
|
294
|
+
it("de-duplicates repeated keys", () => {
|
|
295
|
+
const out = generateSecretEnvTypes({ envNames: ["TOKEN", "TOKEN"] });
|
|
296
|
+
const occurrences = out.split("readonly TOKEN: string;").length - 1;
|
|
297
|
+
expect(occurrences).toBe(1);
|
|
298
|
+
});
|
|
299
|
+
|
|
300
|
+
it("merged context + secretEnv stays a global script (regression guard)", () => {
|
|
301
|
+
// Reproduces the action-leaf-cards merge: the `context` global concatenated
|
|
302
|
+
// with the secretEnv augmentation must NOT contain a top-level
|
|
303
|
+
// export/import, otherwise `declare const context` stops being global and
|
|
304
|
+
// its IntelliSense silently disappears in the automation script editor.
|
|
305
|
+
const { typeDefinitions } = generateAutomationContextTypes({
|
|
306
|
+
definition: baseDef(),
|
|
307
|
+
triggers: [],
|
|
308
|
+
path: [{ slot: "root", index: 0 }],
|
|
309
|
+
});
|
|
310
|
+
const secretEnvLib = generateSecretEnvTypes({ envNames: ["API_TOKEN"] });
|
|
311
|
+
const merged = [typeDefinitions, secretEnvLib].filter(Boolean).join("\n\n");
|
|
312
|
+
expect(merged).toContain("declare const context");
|
|
313
|
+
expect(merged).toContain("declare namespace NodeJS");
|
|
314
|
+
expect(merged).not.toMatch(/^\s*export\b/m);
|
|
315
|
+
expect(merged).not.toMatch(/^\s*import\b/m);
|
|
316
|
+
});
|
|
317
|
+
});
|
|
318
|
+
|
|
319
|
+
describe("secretEnvEnvNames", () => {
|
|
320
|
+
const configSchema = {
|
|
321
|
+
type: "object",
|
|
322
|
+
properties: {
|
|
323
|
+
script: { type: "string", "x-editor-types": ["typescript"] },
|
|
324
|
+
secretEnv: {
|
|
325
|
+
type: "object",
|
|
326
|
+
additionalProperties: { type: "string" },
|
|
327
|
+
"x-secret-env": true,
|
|
328
|
+
},
|
|
329
|
+
},
|
|
330
|
+
};
|
|
331
|
+
|
|
332
|
+
it("returns the env-var keys from the x-secret-env mapping value", () => {
|
|
333
|
+
expect(
|
|
334
|
+
secretEnvEnvNames({
|
|
335
|
+
configSchema,
|
|
336
|
+
config: {
|
|
337
|
+
script: "export default async () => {}",
|
|
338
|
+
secretEnv: {
|
|
339
|
+
API_TOKEN: "${{ secrets.jira_token }}",
|
|
340
|
+
DB: "${{ secrets.db }}",
|
|
341
|
+
},
|
|
342
|
+
},
|
|
343
|
+
}),
|
|
344
|
+
).toEqual(["API_TOKEN", "DB"]);
|
|
345
|
+
});
|
|
346
|
+
|
|
347
|
+
it("locates the field by annotation, not by name", () => {
|
|
348
|
+
const renamed = {
|
|
349
|
+
type: "object",
|
|
350
|
+
properties: {
|
|
351
|
+
mySecrets: {
|
|
352
|
+
type: "object",
|
|
353
|
+
additionalProperties: { type: "string" },
|
|
354
|
+
"x-secret-env": true,
|
|
355
|
+
},
|
|
356
|
+
},
|
|
357
|
+
};
|
|
358
|
+
expect(
|
|
359
|
+
secretEnvEnvNames({
|
|
360
|
+
configSchema: renamed,
|
|
361
|
+
config: { mySecrets: { TOKEN: "${{ secrets.t }}" } },
|
|
362
|
+
}),
|
|
363
|
+
).toEqual(["TOKEN"]);
|
|
364
|
+
});
|
|
365
|
+
|
|
366
|
+
it("returns [] when there is no x-secret-env field", () => {
|
|
367
|
+
expect(
|
|
368
|
+
secretEnvEnvNames({
|
|
369
|
+
configSchema: { type: "object", properties: { script: { type: "string" } } },
|
|
370
|
+
config: { script: "x" },
|
|
371
|
+
}),
|
|
372
|
+
).toEqual([]);
|
|
373
|
+
});
|
|
374
|
+
|
|
375
|
+
it("returns [] when the mapping is missing or not a record", () => {
|
|
376
|
+
expect(secretEnvEnvNames({ configSchema, config: {} })).toEqual([]);
|
|
377
|
+
expect(
|
|
378
|
+
secretEnvEnvNames({ configSchema, config: { secretEnv: "nope" } }),
|
|
379
|
+
).toEqual([]);
|
|
380
|
+
});
|
|
381
|
+
|
|
382
|
+
it("returns [] for malformed schema / config input", () => {
|
|
383
|
+
expect(secretEnvEnvNames({ configSchema: null, config: null })).toEqual([]);
|
|
384
|
+
expect(
|
|
385
|
+
secretEnvEnvNames({ configSchema: "not-a-schema", config: 42 }),
|
|
386
|
+
).toEqual([]);
|
|
387
|
+
});
|
|
388
|
+
});
|
package/src/script-context.ts
CHANGED
|
@@ -216,3 +216,118 @@ function indent(input: string, pad: string): string {
|
|
|
216
216
|
.map((line, i) => (i === 0 ? line : `${pad}${line}`))
|
|
217
217
|
.join("\n");
|
|
218
218
|
}
|
|
219
|
+
|
|
220
|
+
/** A TypeScript identifier safe to emit as an unquoted interface key. */
|
|
221
|
+
const TS_IDENTIFIER_RE = /^[A-Za-z_$][A-Za-z0-9_$]*$/;
|
|
222
|
+
|
|
223
|
+
/**
|
|
224
|
+
* Emit an ambient `NodeJS.ProcessEnv` augmentation that types the declared
|
|
225
|
+
* `secretEnv` environment-variable NAMES as `string`, so they autocomplete
|
|
226
|
+
* under `process.env.` in the inline-script Monaco editor and are typed.
|
|
227
|
+
*
|
|
228
|
+
* This is a type AUGMENTATION, not a replacement: it adds the known keys as
|
|
229
|
+
* non-optional `string` members and coexists with `@types/node`'s existing
|
|
230
|
+
* `ProcessEnv` index signature (`[key: string]: string | undefined`), so
|
|
231
|
+
* unknown keys still resolve to `string | undefined` while the declared ones
|
|
232
|
+
* narrow to `string`.
|
|
233
|
+
*
|
|
234
|
+
* IMPORTANT: the output is a **global script**, NOT a module. It uses a
|
|
235
|
+
* top-level ambient `declare namespace NodeJS` (which declaration-merges into
|
|
236
|
+
* the global `NodeJS.ProcessEnv`) rather than the module-form
|
|
237
|
+
* `declare global { … } export {};`. The reason is the consumer
|
|
238
|
+
* (`action-leaf-cards`) CONCATENATES this into the same extra-lib string as the
|
|
239
|
+
* `declare const context` global. A single top-level `export {};` would turn
|
|
240
|
+
* the whole concatenated `.d.ts` into a module, which silently demotes
|
|
241
|
+
* `declare const context` from a global ambient to a module-local binding -
|
|
242
|
+
* breaking `context.*` IntelliSense entirely. Staying a global script keeps the
|
|
243
|
+
* merged file ambient. Empty / all-invalid input emits `""` (dropped by the
|
|
244
|
+
* caller's `.filter(Boolean)`) so merging is always safe.
|
|
245
|
+
*
|
|
246
|
+
* Only valid TS identifiers are emitted as ambient keys (an env var like
|
|
247
|
+
* `MY-VAR` is not a legal `process.env.MY-VAR` member access, so it is
|
|
248
|
+
* skipped — it remains reachable via the index signature as
|
|
249
|
+
* `process.env["MY-VAR"]`).
|
|
250
|
+
*/
|
|
251
|
+
export function generateSecretEnvTypes({
|
|
252
|
+
envNames,
|
|
253
|
+
}: {
|
|
254
|
+
envNames: string[];
|
|
255
|
+
}): string {
|
|
256
|
+
// De-dupe, keep only legal identifiers, preserve first-seen order.
|
|
257
|
+
const seen = new Set<string>();
|
|
258
|
+
const valid: string[] = [];
|
|
259
|
+
for (const name of envNames) {
|
|
260
|
+
const trimmed = name.trim();
|
|
261
|
+
if (trimmed === "" || seen.has(trimmed)) continue;
|
|
262
|
+
seen.add(trimmed);
|
|
263
|
+
if (TS_IDENTIFIER_RE.test(trimmed)) valid.push(trimmed);
|
|
264
|
+
}
|
|
265
|
+
|
|
266
|
+
if (valid.length === 0) {
|
|
267
|
+
// Nothing to declare — emit an empty string so the caller's
|
|
268
|
+
// `.filter(Boolean)` drops it and the merged extra-lib stays untouched
|
|
269
|
+
// (a global script). Emitting `export {};` here would modularize the
|
|
270
|
+
// merged file and break the `context` global (see the doc comment).
|
|
271
|
+
return "";
|
|
272
|
+
}
|
|
273
|
+
|
|
274
|
+
const members = valid
|
|
275
|
+
.map((name) => ` readonly ${name}: string;`)
|
|
276
|
+
.join("\n");
|
|
277
|
+
return [
|
|
278
|
+
"/**",
|
|
279
|
+
" * Auto-generated: the `secretEnv` mapping declared on this action injects",
|
|
280
|
+
" * these names as `process.env.<NAME>` for the run. Typed `string` so they",
|
|
281
|
+
" * autocomplete; unknown keys still resolve via @types/node's index signature.",
|
|
282
|
+
" */",
|
|
283
|
+
"declare namespace NodeJS {",
|
|
284
|
+
" interface ProcessEnv {",
|
|
285
|
+
members,
|
|
286
|
+
" }",
|
|
287
|
+
"}",
|
|
288
|
+
].join("\n");
|
|
289
|
+
}
|
|
290
|
+
|
|
291
|
+
/** Narrow an `unknown` to a plain (non-array) object record. */
|
|
292
|
+
function asRecord(value: unknown): Record<string, unknown> | undefined {
|
|
293
|
+
if (value && typeof value === "object" && !Array.isArray(value)) {
|
|
294
|
+
return value as Record<string, unknown>;
|
|
295
|
+
}
|
|
296
|
+
return undefined;
|
|
297
|
+
}
|
|
298
|
+
|
|
299
|
+
/**
|
|
300
|
+
* Locate the action config's secret→env mapping by the `x-secret-env`
|
|
301
|
+
* schema annotation (NOT a hard-coded field name) and return the declared
|
|
302
|
+
* environment-variable NAMES (the mapping's keys). These feed
|
|
303
|
+
* {@link generateSecretEnvTypes} so the script editor types `process.env.*`.
|
|
304
|
+
*
|
|
305
|
+
* Both inputs are loosely typed (`configSchema` is a JSON-schema record;
|
|
306
|
+
* `config` is the action's live config object), so they're narrowed at
|
|
307
|
+
* runtime via {@link asRecord}. The lone `as` casts the already-narrowed
|
|
308
|
+
* record to its inferred shape — unavoidable because `asRecord` returns the
|
|
309
|
+
* generic record. Returns `[]` when no `x-secret-env` field exists or its
|
|
310
|
+
* mapping value isn't a record.
|
|
311
|
+
*/
|
|
312
|
+
export function secretEnvEnvNames({
|
|
313
|
+
configSchema,
|
|
314
|
+
config,
|
|
315
|
+
}: {
|
|
316
|
+
configSchema: unknown;
|
|
317
|
+
config: unknown;
|
|
318
|
+
}): string[] {
|
|
319
|
+
const schemaRecord = asRecord(configSchema);
|
|
320
|
+
const properties = asRecord(schemaRecord?.properties);
|
|
321
|
+
if (!properties) return [];
|
|
322
|
+
|
|
323
|
+
const fieldKey = Object.entries(properties).find(([, prop]) => {
|
|
324
|
+
const propRecord = asRecord(prop);
|
|
325
|
+
return propRecord?.["x-secret-env"] === true;
|
|
326
|
+
})?.[0];
|
|
327
|
+
if (!fieldKey) return [];
|
|
328
|
+
|
|
329
|
+
const configRecord = asRecord(config);
|
|
330
|
+
const mapping = asRecord(configRecord?.[fieldKey]);
|
|
331
|
+
if (!mapping) return [];
|
|
332
|
+
return Object.keys(mapping);
|
|
333
|
+
}
|
package/tsconfig.json
CHANGED
|
@@ -7,15 +7,27 @@
|
|
|
7
7
|
{
|
|
8
8
|
"path": "../automation-common"
|
|
9
9
|
},
|
|
10
|
+
{
|
|
11
|
+
"path": "../catalog-common"
|
|
12
|
+
},
|
|
10
13
|
{
|
|
11
14
|
"path": "../common"
|
|
12
15
|
},
|
|
13
16
|
{
|
|
14
17
|
"path": "../frontend-api"
|
|
15
18
|
},
|
|
19
|
+
{
|
|
20
|
+
"path": "../gitops-frontend"
|
|
21
|
+
},
|
|
16
22
|
{
|
|
17
23
|
"path": "../integration-common"
|
|
18
24
|
},
|
|
25
|
+
{
|
|
26
|
+
"path": "../script-packages-frontend"
|
|
27
|
+
},
|
|
28
|
+
{
|
|
29
|
+
"path": "../secrets-frontend"
|
|
30
|
+
},
|
|
19
31
|
{
|
|
20
32
|
"path": "../signal-frontend"
|
|
21
33
|
},
|