@get-dx/cli 0.9.0 → 0.11.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/dist/commandHelpers.js +10 -0
- package/dist/commands/catalog/entities.js +1 -11
- package/dist/commands/catalog/relation-edges-blank-template.yaml +19 -0
- package/dist/commands/catalog/relationEdges.js +285 -0
- package/dist/commands/catalog/relationEdgesRendering.js +45 -0
- package/dist/commands/catalog/relations.js +231 -0
- package/dist/commands/catalog/relationsRendering.js +38 -0
- package/dist/commands/catalog.js +4 -0
- package/dist/commands/scorecard-blank-template.yaml +5 -0
- package/dist/commands/scorecards.js +2 -0
- package/dist/commands/snapshots.js +202 -8
- package/dist/commands/studio/reports.js +8 -0
- package/dist/commands/users/update.js +207 -0
- package/dist/commands/users.js +2 -0
- package/dist/commands/workflow-blank-template.yaml +6 -0
- package/dist/commands/workflowsRendering.js +3 -0
- package/package.json +1 -1
package/dist/commandHelpers.js
CHANGED
|
@@ -125,6 +125,16 @@ export function parsePositiveIntOption(value, flag) {
|
|
|
125
125
|
}
|
|
126
126
|
return n;
|
|
127
127
|
}
|
|
128
|
+
export function parseCommaSeparatedValues(value) {
|
|
129
|
+
if (value === undefined) {
|
|
130
|
+
return undefined;
|
|
131
|
+
}
|
|
132
|
+
const parsed = value
|
|
133
|
+
.split(",")
|
|
134
|
+
.map((entry) => entry.trim())
|
|
135
|
+
.filter((entry) => entry.length > 0);
|
|
136
|
+
return parsed.length > 0 ? parsed : undefined;
|
|
137
|
+
}
|
|
128
138
|
export function createExampleText(examples) {
|
|
129
139
|
const lines = [];
|
|
130
140
|
lines.push(""); // separate from the rest of the help text
|
|
@@ -1,5 +1,5 @@
|
|
|
1
1
|
import { Command } from "commander";
|
|
2
|
-
import { createExampleText, getContext, parsePositiveIntOption, wrapAction, } from "../../commandHelpers.js";
|
|
2
|
+
import { createExampleText, getContext, parseCommaSeparatedValues, parsePositiveIntOption, wrapAction, } from "../../commandHelpers.js";
|
|
3
3
|
import { CliError, EXIT_CODES, HttpError } from "../../errors.js";
|
|
4
4
|
import { request } from "../../http.js";
|
|
5
5
|
import { renderJson } from "../../renderers.js";
|
|
@@ -655,16 +655,6 @@ function getEntityMutationOptionValues(options) {
|
|
|
655
655
|
owner_user_ids: parseCommaSeparatedValues(options.ownerUserIds),
|
|
656
656
|
};
|
|
657
657
|
}
|
|
658
|
-
function parseCommaSeparatedValues(value) {
|
|
659
|
-
if (value === undefined) {
|
|
660
|
-
return undefined;
|
|
661
|
-
}
|
|
662
|
-
const parsed = value
|
|
663
|
-
.split(",")
|
|
664
|
-
.map((entry) => entry.trim())
|
|
665
|
-
.filter((entry) => entry.length > 0);
|
|
666
|
-
return parsed.length > 0 ? parsed : undefined;
|
|
667
|
-
}
|
|
668
658
|
function buildEntityMutationBody(identifier, params) {
|
|
669
659
|
const body = { identifier };
|
|
670
660
|
if ("type" in params && params.type !== undefined) {
|
|
@@ -0,0 +1,19 @@
|
|
|
1
|
+
# Relation edges for bulk upsert
|
|
2
|
+
#
|
|
3
|
+
# Edit this file, then run:
|
|
4
|
+
#
|
|
5
|
+
# ```
|
|
6
|
+
# dx catalog relationEdges bulkUpsert --relation <relation-identifier> --from-file <path>
|
|
7
|
+
# ```
|
|
8
|
+
#
|
|
9
|
+
# Each key is a source entity identifier, and each value is a list of target
|
|
10
|
+
# entity identifiers.
|
|
11
|
+
#
|
|
12
|
+
# Bulk upsert only adds edges. Existing edges that are not listed here are kept;
|
|
13
|
+
# use `dx catalog relationEdges remove` to delete an edge.
|
|
14
|
+
|
|
15
|
+
source-entity-identifier-1:
|
|
16
|
+
- target-entity-identifier-a
|
|
17
|
+
- target-entity-identifier-b
|
|
18
|
+
source-entity-identifier-2:
|
|
19
|
+
- target-entity-identifier-c
|
|
@@ -0,0 +1,285 @@
|
|
|
1
|
+
import fs from "fs";
|
|
2
|
+
import { Command } from "commander";
|
|
3
|
+
import { parse as parseYaml } from "yaml";
|
|
4
|
+
import { createExampleText, getContext, parsePositiveIntOption, wrapAction, } from "../../commandHelpers.js";
|
|
5
|
+
import { CliError, EXIT_CODES } from "../../errors.js";
|
|
6
|
+
import { request } from "../../http.js";
|
|
7
|
+
import { renderJson } from "../../renderers.js";
|
|
8
|
+
import { buildRuntime } from "../../runtime.js";
|
|
9
|
+
import { renderRelationEdgeAdded, renderRelationEdgeList, renderRelationEdgeRemoved, renderRelationEdgesTemplateWritten, renderRelationEdgesUpserted, } from "./relationEdgesRendering.js";
|
|
10
|
+
const RELATION_EDGES_BLANK_TEMPLATE_YAML = fs.readFileSync(new URL("./relation-edges-blank-template.yaml", import.meta.url), "utf8");
|
|
11
|
+
export function relationEdgesCommand() {
|
|
12
|
+
const relationEdges = new Command()
|
|
13
|
+
.name("relationEdges")
|
|
14
|
+
.description("Manage relation edges between entities");
|
|
15
|
+
relationEdges
|
|
16
|
+
.command("add")
|
|
17
|
+
.description("Add a relation edge between two entities")
|
|
18
|
+
.requiredOption("--relation <identifier>", "Relation identifier (required)")
|
|
19
|
+
.requiredOption("--source <identifier>", "Source entity identifier (required)")
|
|
20
|
+
.requiredOption("--target <identifier>", "Target entity identifier (required)")
|
|
21
|
+
.addHelpText("afterAll", createExampleText([
|
|
22
|
+
{
|
|
23
|
+
label: "Record that `login-frontend` depends on `authn-backend`",
|
|
24
|
+
command: "dx catalog relationEdges add --relation service-depends-on-service --source login-frontend --target authn-backend",
|
|
25
|
+
},
|
|
26
|
+
{
|
|
27
|
+
label: "Add an edge and return JSON",
|
|
28
|
+
command: "dx catalog relationEdges add --relation service-depends-on-service --source login-frontend --target authn-backend --json",
|
|
29
|
+
},
|
|
30
|
+
]))
|
|
31
|
+
.action(wrapAction(async (options, command) => {
|
|
32
|
+
const edge = {
|
|
33
|
+
relation: options.relation,
|
|
34
|
+
source: options.source,
|
|
35
|
+
target: options.target,
|
|
36
|
+
};
|
|
37
|
+
const runtime = await buildRuntime(getContext(command));
|
|
38
|
+
const response = await addRelationEdge(runtime, edge);
|
|
39
|
+
if (runtime.context.json) {
|
|
40
|
+
renderJson(response);
|
|
41
|
+
}
|
|
42
|
+
else {
|
|
43
|
+
renderRelationEdgeAdded(edge);
|
|
44
|
+
}
|
|
45
|
+
}));
|
|
46
|
+
relationEdges
|
|
47
|
+
.command("bulkUpsert")
|
|
48
|
+
.description("Create or update many relation edges for one relation from a YAML or JSON file or stdin. The file maps each source entity identifier to a list of target entity identifiers.")
|
|
49
|
+
.requiredOption("--relation <identifier>", "Relation identifier (required)")
|
|
50
|
+
.option("--from-file <path>", "Path to a YAML or JSON file of edges. Use `initBulkUpsert` to generate a template.")
|
|
51
|
+
.option("--from-stdin", "Read YAML or JSON edges from stdin")
|
|
52
|
+
.addHelpText("afterAll", createExampleText([
|
|
53
|
+
{
|
|
54
|
+
label: "Generate a template first",
|
|
55
|
+
command: "dx catalog relationEdges initBulkUpsert ./edges.yaml",
|
|
56
|
+
},
|
|
57
|
+
{
|
|
58
|
+
label: "Upsert edges from a YAML file",
|
|
59
|
+
command: "dx catalog relationEdges bulkUpsert --relation service-depends-on-service --from-file ./edges.yaml",
|
|
60
|
+
},
|
|
61
|
+
{
|
|
62
|
+
label: "Upsert edges from stdin",
|
|
63
|
+
command: 'echo \'{"login-frontend": ["authn-backend", "feature-flag-backend"]}\' | dx catalog relationEdges bulkUpsert --relation service-depends-on-service --from-stdin',
|
|
64
|
+
},
|
|
65
|
+
]))
|
|
66
|
+
.action(wrapAction(async (options, command) => {
|
|
67
|
+
if (!options.fromFile && !options.fromStdin) {
|
|
68
|
+
throw new CliError("One of --from-file or --from-stdin is required", EXIT_CODES.ARGUMENT_ERROR);
|
|
69
|
+
}
|
|
70
|
+
if (options.fromFile && options.fromStdin) {
|
|
71
|
+
throw new CliError("--from-file and --from-stdin are mutually exclusive", EXIT_CODES.ARGUMENT_ERROR);
|
|
72
|
+
}
|
|
73
|
+
const raw = options.fromFile
|
|
74
|
+
? readYamlFile(options.fromFile)
|
|
75
|
+
: await readYamlStdin();
|
|
76
|
+
const edges = parseEdgesMap(raw, options.fromFile ? options.fromFile : "stdin");
|
|
77
|
+
const runtime = await buildRuntime(getContext(command));
|
|
78
|
+
const response = await bulkUpsertRelationEdges(runtime, options.relation, edges);
|
|
79
|
+
if (runtime.context.json) {
|
|
80
|
+
renderJson(response);
|
|
81
|
+
}
|
|
82
|
+
else {
|
|
83
|
+
renderRelationEdgesUpserted(options.relation, edges);
|
|
84
|
+
}
|
|
85
|
+
}));
|
|
86
|
+
relationEdges
|
|
87
|
+
.command("initBulkUpsert")
|
|
88
|
+
.description("Write a blank YAML template of relation edges to use with `bulkUpsert`")
|
|
89
|
+
.argument("<path>", "File path to write the YAML to")
|
|
90
|
+
.addHelpText("afterAll", createExampleText([
|
|
91
|
+
{
|
|
92
|
+
label: "Write a blank relation edges template",
|
|
93
|
+
command: "dx catalog relationEdges initBulkUpsert ./edges.yaml",
|
|
94
|
+
},
|
|
95
|
+
{
|
|
96
|
+
label: "Upsert edges from the template",
|
|
97
|
+
command: "dx catalog relationEdges bulkUpsert --relation service-depends-on-service --from-file ./edges.yaml",
|
|
98
|
+
},
|
|
99
|
+
]))
|
|
100
|
+
.action(wrapAction(async (path, _options, command) => {
|
|
101
|
+
const runtime = await buildRuntime(getContext(command));
|
|
102
|
+
fs.writeFileSync(path, RELATION_EDGES_BLANK_TEMPLATE_YAML, "utf8");
|
|
103
|
+
if (runtime.context.json) {
|
|
104
|
+
renderJson({ ok: true, path });
|
|
105
|
+
}
|
|
106
|
+
else {
|
|
107
|
+
renderRelationEdgesTemplateWritten(path);
|
|
108
|
+
}
|
|
109
|
+
}));
|
|
110
|
+
relationEdges
|
|
111
|
+
.command("list")
|
|
112
|
+
.description("List the entities connected to an entity through a relation")
|
|
113
|
+
.requiredOption("--entity <identifier>", "Entity identifier (required)")
|
|
114
|
+
.requiredOption("--relation <identifier>", "Relation identifier (required)")
|
|
115
|
+
.option("--direction <direction>", "Treat the entity as the source (`outgoing`) or target (`incoming`) of the relation (default is outgoing)")
|
|
116
|
+
.option("--cursor <cursor>", "Cursor for the next page of results")
|
|
117
|
+
.option("--limit <n>", "Max edges per page, up to 50 (default is 25)", (value) => parsePositiveIntOption(value, "--limit"))
|
|
118
|
+
.addHelpText("afterAll", createExampleText([
|
|
119
|
+
{
|
|
120
|
+
label: "List the APIs that `payments-service` consumes",
|
|
121
|
+
command: "dx catalog relationEdges list --entity payments-service --relation service-consumes-api",
|
|
122
|
+
},
|
|
123
|
+
{
|
|
124
|
+
label: "List incoming edges and return JSON",
|
|
125
|
+
command: "dx catalog relationEdges list --entity core-api --relation service-consumes-api --direction incoming --json",
|
|
126
|
+
},
|
|
127
|
+
{
|
|
128
|
+
label: "Fetch the next page using a cursor from the prior response",
|
|
129
|
+
command: "dx catalog relationEdges list --entity payments-service --relation service-consumes-api --cursor d0d93f40-e534-51ac-0a0a-c839dfa2ff35",
|
|
130
|
+
},
|
|
131
|
+
]))
|
|
132
|
+
.action(wrapAction(async (options, command) => {
|
|
133
|
+
const runtime = await buildRuntime(getContext(command));
|
|
134
|
+
const response = await listRelationEdges(runtime, {
|
|
135
|
+
entity_identifier: options.entity,
|
|
136
|
+
relation_identifier: options.relation,
|
|
137
|
+
direction: options.direction,
|
|
138
|
+
cursor: options.cursor,
|
|
139
|
+
limit: options.limit,
|
|
140
|
+
});
|
|
141
|
+
if (runtime.context.json) {
|
|
142
|
+
renderJson(response);
|
|
143
|
+
}
|
|
144
|
+
else {
|
|
145
|
+
renderRelationEdgeList(response.entity_relations, {
|
|
146
|
+
entity: options.entity,
|
|
147
|
+
relation: options.relation,
|
|
148
|
+
direction: options.direction ?? "outgoing",
|
|
149
|
+
}, response.response_metadata?.next_cursor ?? null);
|
|
150
|
+
}
|
|
151
|
+
}));
|
|
152
|
+
relationEdges
|
|
153
|
+
.command("remove")
|
|
154
|
+
.description("Remove a relation edge between two entities")
|
|
155
|
+
.requiredOption("--relation <identifier>", "Relation identifier (required)")
|
|
156
|
+
.requiredOption("--source <identifier>", "Source entity identifier (required)")
|
|
157
|
+
.requiredOption("--target <identifier>", "Target entity identifier (required)")
|
|
158
|
+
.addHelpText("afterAll", createExampleText([
|
|
159
|
+
{
|
|
160
|
+
label: "Remove the edge from `login-frontend` to `authn-backend`",
|
|
161
|
+
command: "dx catalog relationEdges remove --relation service-depends-on-service --source login-frontend --target authn-backend",
|
|
162
|
+
},
|
|
163
|
+
{
|
|
164
|
+
label: "Remove an edge and return JSON",
|
|
165
|
+
command: "dx catalog relationEdges remove --relation service-depends-on-service --source login-frontend --target authn-backend --json",
|
|
166
|
+
},
|
|
167
|
+
]))
|
|
168
|
+
.action(wrapAction(async (options, command) => {
|
|
169
|
+
const edge = {
|
|
170
|
+
relation: options.relation,
|
|
171
|
+
source: options.source,
|
|
172
|
+
target: options.target,
|
|
173
|
+
};
|
|
174
|
+
const runtime = await buildRuntime(getContext(command));
|
|
175
|
+
const response = await removeRelationEdge(runtime, edge);
|
|
176
|
+
if (runtime.context.json) {
|
|
177
|
+
renderJson(response);
|
|
178
|
+
}
|
|
179
|
+
else {
|
|
180
|
+
renderRelationEdgeRemoved(edge);
|
|
181
|
+
}
|
|
182
|
+
}));
|
|
183
|
+
return relationEdges;
|
|
184
|
+
}
|
|
185
|
+
async function addRelationEdge(runtime, edge) {
|
|
186
|
+
const response = await request(runtime, "/catalog.relationEdges.add", {
|
|
187
|
+
method: "POST",
|
|
188
|
+
body: edgeBody(edge),
|
|
189
|
+
});
|
|
190
|
+
return response.body;
|
|
191
|
+
}
|
|
192
|
+
async function bulkUpsertRelationEdges(runtime, relation, edges) {
|
|
193
|
+
const response = await request(runtime, "/catalog.relationEdges.bulkUpsert", {
|
|
194
|
+
method: "POST",
|
|
195
|
+
body: { relation_identifier: relation, edges },
|
|
196
|
+
});
|
|
197
|
+
return response.body;
|
|
198
|
+
}
|
|
199
|
+
async function listRelationEdges(runtime, params) {
|
|
200
|
+
const query = {
|
|
201
|
+
entity_identifier: params.entity_identifier,
|
|
202
|
+
relation_identifier: params.relation_identifier,
|
|
203
|
+
};
|
|
204
|
+
if (params.direction !== undefined)
|
|
205
|
+
query.direction = params.direction;
|
|
206
|
+
if (params.cursor !== undefined)
|
|
207
|
+
query.cursor = params.cursor;
|
|
208
|
+
if (params.limit !== undefined)
|
|
209
|
+
query.limit = params.limit;
|
|
210
|
+
const response = await request(runtime, "/catalog.relationEdges.list", {
|
|
211
|
+
method: "GET",
|
|
212
|
+
query,
|
|
213
|
+
});
|
|
214
|
+
return response.body;
|
|
215
|
+
}
|
|
216
|
+
async function removeRelationEdge(runtime, edge) {
|
|
217
|
+
const response = await request(runtime, "/catalog.relationEdges.remove", {
|
|
218
|
+
method: "POST",
|
|
219
|
+
body: edgeBody(edge),
|
|
220
|
+
});
|
|
221
|
+
return response.body;
|
|
222
|
+
}
|
|
223
|
+
function edgeBody(edge) {
|
|
224
|
+
return {
|
|
225
|
+
relation_identifier: edge.relation,
|
|
226
|
+
source_entity_identifier: edge.source,
|
|
227
|
+
target_entity_identifier: edge.target,
|
|
228
|
+
};
|
|
229
|
+
}
|
|
230
|
+
// --- Input helpers ---
|
|
231
|
+
function parseEdgesMap(raw, sourceLabel) {
|
|
232
|
+
if (raw === null || raw === undefined) {
|
|
233
|
+
throw noEdgesError(sourceLabel);
|
|
234
|
+
}
|
|
235
|
+
if (typeof raw !== "object" || Array.isArray(raw)) {
|
|
236
|
+
throw new CliError("Edges must be an object mapping each source entity identifier to a list of target entity identifiers", EXIT_CODES.ARGUMENT_ERROR);
|
|
237
|
+
}
|
|
238
|
+
for (const [source, targets] of Object.entries(raw)) {
|
|
239
|
+
if (!Array.isArray(targets) ||
|
|
240
|
+
!targets.every((target) => typeof target === "string")) {
|
|
241
|
+
throw new CliError(`Targets for source "${source}" must be a list of entity identifiers`, EXIT_CODES.ARGUMENT_ERROR);
|
|
242
|
+
}
|
|
243
|
+
}
|
|
244
|
+
const edges = raw;
|
|
245
|
+
if (Object.values(edges).every((targets) => targets.length === 0)) {
|
|
246
|
+
throw noEdgesError(sourceLabel);
|
|
247
|
+
}
|
|
248
|
+
return edges;
|
|
249
|
+
}
|
|
250
|
+
function noEdgesError(sourceLabel) {
|
|
251
|
+
return new CliError(`No edges found in ${sourceLabel}`, EXIT_CODES.ARGUMENT_ERROR);
|
|
252
|
+
}
|
|
253
|
+
function readYamlFile(filePath) {
|
|
254
|
+
let content;
|
|
255
|
+
try {
|
|
256
|
+
content = fs.readFileSync(filePath, "utf8");
|
|
257
|
+
}
|
|
258
|
+
catch (err) {
|
|
259
|
+
throw new CliError(`Could not read file "${filePath}": ${err.message}`, EXIT_CODES.ARGUMENT_ERROR);
|
|
260
|
+
}
|
|
261
|
+
try {
|
|
262
|
+
return parseYaml(content);
|
|
263
|
+
}
|
|
264
|
+
catch (err) {
|
|
265
|
+
throw new CliError(`Failed to parse YAML from "${filePath}": ${err.message}`, EXIT_CODES.ARGUMENT_ERROR);
|
|
266
|
+
}
|
|
267
|
+
}
|
|
268
|
+
async function readYamlStdin() {
|
|
269
|
+
return new Promise((resolve, reject) => {
|
|
270
|
+
const chunks = [];
|
|
271
|
+
process.stdin.on("data", (chunk) => chunks.push(chunk));
|
|
272
|
+
process.stdin.on("end", () => {
|
|
273
|
+
const content = Buffer.concat(chunks).toString("utf8");
|
|
274
|
+
try {
|
|
275
|
+
resolve(parseYaml(content));
|
|
276
|
+
}
|
|
277
|
+
catch (err) {
|
|
278
|
+
reject(new CliError(`Failed to parse YAML from stdin: ${err.message}`, EXIT_CODES.ARGUMENT_ERROR));
|
|
279
|
+
}
|
|
280
|
+
});
|
|
281
|
+
process.stdin.on("error", (err) => {
|
|
282
|
+
reject(new CliError(`Failed to read from stdin: ${err.message}`, EXIT_CODES.ARGUMENT_ERROR));
|
|
283
|
+
});
|
|
284
|
+
});
|
|
285
|
+
}
|
|
@@ -0,0 +1,45 @@
|
|
|
1
|
+
import { renderRichText } from "../../renderers.js";
|
|
2
|
+
import * as ui from "../../ui.js";
|
|
3
|
+
export function renderRelationEdgeAdded(edge) {
|
|
4
|
+
renderRichText([
|
|
5
|
+
ui.p(`${ui.success(ui.GLYPHS.CHECK)} Added edge ${edgeSummary(edge)}`),
|
|
6
|
+
]);
|
|
7
|
+
}
|
|
8
|
+
export function renderRelationEdgeRemoved(edge) {
|
|
9
|
+
renderRichText([
|
|
10
|
+
ui.p(`${ui.success(ui.GLYPHS.CHECK)} Removed edge ${edgeSummary(edge)}`),
|
|
11
|
+
]);
|
|
12
|
+
}
|
|
13
|
+
export function renderRelationEdgesUpserted(relation, edges) {
|
|
14
|
+
const count = Object.values(edges).reduce((sum, targets) => sum + targets.length, 0);
|
|
15
|
+
renderRichText([
|
|
16
|
+
ui.p(`${ui.success(ui.GLYPHS.CHECK)} Upserted ${ui.bold(count.toString())} ${count === 1 ? "edge" : "edges"} for ${ui.code(relation)}`),
|
|
17
|
+
]);
|
|
18
|
+
}
|
|
19
|
+
export function renderRelationEdgesTemplateWritten(path) {
|
|
20
|
+
renderRichText([
|
|
21
|
+
ui.p(`${ui.success(ui.GLYPHS.CHECK)} Blank template written to ${ui.code(path)}.`),
|
|
22
|
+
ui.p(`Edit the file, then run: ${ui.code(`dx catalog relationEdges bulkUpsert --relation <relation-identifier> --from-file ${path}`)}`),
|
|
23
|
+
]);
|
|
24
|
+
}
|
|
25
|
+
export function renderRelationEdgeList(entities, query, nextCursor) {
|
|
26
|
+
const blocks = [ui.h1("Relation Edges")];
|
|
27
|
+
blocks.push(ui.dl([
|
|
28
|
+
ui.dli("Entity", ui.code(query.entity)),
|
|
29
|
+
ui.dli("Relation", ui.code(query.relation)),
|
|
30
|
+
ui.dli("Direction", query.direction),
|
|
31
|
+
], { termWidth: 11 }));
|
|
32
|
+
blocks.push(ui.p(`Displaying ${ui.bold(entities.length.toString())} related entities.`));
|
|
33
|
+
if (nextCursor) {
|
|
34
|
+
blocks.push(ui.p(`Next cursor: ${ui.code(nextCursor)}`));
|
|
35
|
+
}
|
|
36
|
+
if (entities.length > 0) {
|
|
37
|
+
blocks.push(ui.ul(entities.map((entity) => ui.li(entity.name
|
|
38
|
+
? `${entity.name} (${ui.code(entity.identifier)})`
|
|
39
|
+
: ui.code(entity.identifier)))));
|
|
40
|
+
}
|
|
41
|
+
renderRichText(blocks);
|
|
42
|
+
}
|
|
43
|
+
function edgeSummary(edge) {
|
|
44
|
+
return `${ui.code(edge.source)} -> ${ui.code(edge.target)} (${ui.code(edge.relation)})`;
|
|
45
|
+
}
|
|
@@ -0,0 +1,231 @@
|
|
|
1
|
+
import { Command } from "commander";
|
|
2
|
+
import { createExampleText, getContext, parsePositiveIntOption, wrapAction, } from "../../commandHelpers.js";
|
|
3
|
+
import { CliError, EXIT_CODES } from "../../errors.js";
|
|
4
|
+
import { request } from "../../http.js";
|
|
5
|
+
import { renderJson } from "../../renderers.js";
|
|
6
|
+
import { buildRuntime } from "../../runtime.js";
|
|
7
|
+
import * as ui from "../../ui.js";
|
|
8
|
+
import { renderRelation, renderRelationDeleted, renderRelationList, } from "./relationsRendering.js";
|
|
9
|
+
const RELATION_TYPES = [
|
|
10
|
+
"consumes",
|
|
11
|
+
"depends on",
|
|
12
|
+
"parent of",
|
|
13
|
+
"part of",
|
|
14
|
+
"provides",
|
|
15
|
+
"manages",
|
|
16
|
+
];
|
|
17
|
+
const RELATION_CARDINALITIES = [
|
|
18
|
+
"one_to_one",
|
|
19
|
+
"one_to_many",
|
|
20
|
+
"many_to_one",
|
|
21
|
+
"many_to_many",
|
|
22
|
+
];
|
|
23
|
+
const RELATION_TYPE_HELP = `Relation type. One of: ${RELATION_TYPES.map((t) => `"${t}"`).join(", ")}`;
|
|
24
|
+
export function relationsCommand() {
|
|
25
|
+
const relations = new Command()
|
|
26
|
+
.name("relations")
|
|
27
|
+
.description("Manage relation definitions between entity types");
|
|
28
|
+
relations
|
|
29
|
+
.command("create")
|
|
30
|
+
.description("Create a new relation definition between two entity types in your software catalog")
|
|
31
|
+
.requiredOption("--identifier <identifier>", "Unique identifier for the new relation (required)")
|
|
32
|
+
.requiredOption("--source-type <type>", "Entity type identifier for the source side of the relation (required)")
|
|
33
|
+
.requiredOption("--target-type <type>", "Entity type identifier for the target side of the relation (required)")
|
|
34
|
+
.requiredOption("--type <type>", `${RELATION_TYPE_HELP} (required)`)
|
|
35
|
+
.requiredOption("--cardinality <cardinality>", `Relation cardinality. One of: ${RELATION_CARDINALITIES.join(", ")} (required)`)
|
|
36
|
+
.option("--description <desc>", "Description of the relation")
|
|
37
|
+
.addHelpText("afterAll", createExampleText([
|
|
38
|
+
{
|
|
39
|
+
label: "Create a relation between services",
|
|
40
|
+
command: 'dx catalog relations create --identifier service-depends-on-service --source-type service --target-type service --type "depends on" --cardinality many_to_many',
|
|
41
|
+
},
|
|
42
|
+
{
|
|
43
|
+
label: "Create with a description and return JSON",
|
|
44
|
+
command: 'dx catalog relations create --identifier service-provides-package --source-type service --target-type package --type provides --cardinality one_to_many --description "Service provides a client SDK" --json',
|
|
45
|
+
},
|
|
46
|
+
]))
|
|
47
|
+
.action(wrapAction(async (options, command) => {
|
|
48
|
+
const runtime = await buildRuntime(getContext(command));
|
|
49
|
+
const response = await createRelation(runtime, {
|
|
50
|
+
identifier: options.identifier,
|
|
51
|
+
source_entity_type_identifier: options.sourceType,
|
|
52
|
+
target_entity_type_identifier: options.targetType,
|
|
53
|
+
type: options.type,
|
|
54
|
+
cardinality: options.cardinality,
|
|
55
|
+
description: options.description,
|
|
56
|
+
});
|
|
57
|
+
if (runtime.context.json) {
|
|
58
|
+
renderJson(response);
|
|
59
|
+
}
|
|
60
|
+
else {
|
|
61
|
+
renderRelation(response.relation, `${ui.success(ui.GLYPHS.CHECK)} Relation created`);
|
|
62
|
+
}
|
|
63
|
+
}));
|
|
64
|
+
relations
|
|
65
|
+
.command("delete")
|
|
66
|
+
.description("Delete a relation definition from your software catalog")
|
|
67
|
+
.argument("<identifier>", "Relation identifier")
|
|
68
|
+
.addHelpText("afterAll", createExampleText([
|
|
69
|
+
{
|
|
70
|
+
label: "Delete the `service-depends-on-service` relation",
|
|
71
|
+
command: "dx catalog relations delete service-depends-on-service",
|
|
72
|
+
},
|
|
73
|
+
{
|
|
74
|
+
label: "Delete a relation and return JSON",
|
|
75
|
+
command: "dx catalog relations delete service-depends-on-service --json",
|
|
76
|
+
},
|
|
77
|
+
]))
|
|
78
|
+
.action(wrapAction(async (identifier, _options, command) => {
|
|
79
|
+
const runtime = await buildRuntime(getContext(command));
|
|
80
|
+
const response = await deleteRelation(runtime, identifier);
|
|
81
|
+
if (runtime.context.json) {
|
|
82
|
+
renderJson(response);
|
|
83
|
+
}
|
|
84
|
+
else {
|
|
85
|
+
renderRelationDeleted(response.relation);
|
|
86
|
+
}
|
|
87
|
+
}));
|
|
88
|
+
relations
|
|
89
|
+
.command("info")
|
|
90
|
+
.description("Get information about a relation definition")
|
|
91
|
+
.argument("<identifier>", "Relation identifier")
|
|
92
|
+
.addHelpText("afterAll", createExampleText([
|
|
93
|
+
{
|
|
94
|
+
label: "Fetch info for the `service-depends-on-service` relation",
|
|
95
|
+
command: "dx catalog relations info service-depends-on-service",
|
|
96
|
+
},
|
|
97
|
+
{
|
|
98
|
+
label: "Fetch info and return as JSON",
|
|
99
|
+
command: "dx catalog relations info service-depends-on-service --json",
|
|
100
|
+
},
|
|
101
|
+
]))
|
|
102
|
+
.action(wrapAction(async (identifier, _options, command) => {
|
|
103
|
+
const runtime = await buildRuntime(getContext(command));
|
|
104
|
+
const response = await getRelation(runtime, identifier);
|
|
105
|
+
if (runtime.context.json) {
|
|
106
|
+
renderJson(response);
|
|
107
|
+
}
|
|
108
|
+
else {
|
|
109
|
+
renderRelation(response.relation);
|
|
110
|
+
}
|
|
111
|
+
}));
|
|
112
|
+
relations
|
|
113
|
+
.command("list")
|
|
114
|
+
.description("List relation definitions in your software catalog")
|
|
115
|
+
.option("--cursor <cursor>", "Cursor for the next page of results")
|
|
116
|
+
.option("--limit <n>", "Max relations per page (default is 25)", (value) => parsePositiveIntOption(value, "--limit"))
|
|
117
|
+
.addHelpText("afterAll", createExampleText([
|
|
118
|
+
{
|
|
119
|
+
label: "List the first page of relations",
|
|
120
|
+
command: "dx catalog relations list",
|
|
121
|
+
},
|
|
122
|
+
{
|
|
123
|
+
label: "List with a limit and return JSON",
|
|
124
|
+
command: "dx catalog relations list --limit 10 --json",
|
|
125
|
+
},
|
|
126
|
+
{
|
|
127
|
+
label: "Fetch the next page using a cursor from the prior response",
|
|
128
|
+
command: "dx catalog relations list --cursor avsgf30ccan3",
|
|
129
|
+
},
|
|
130
|
+
]))
|
|
131
|
+
.action(wrapAction(async (options, command) => {
|
|
132
|
+
const runtime = await buildRuntime(getContext(command));
|
|
133
|
+
const response = await listRelations(runtime, {
|
|
134
|
+
cursor: options.cursor,
|
|
135
|
+
limit: options.limit,
|
|
136
|
+
});
|
|
137
|
+
if (runtime.context.json) {
|
|
138
|
+
renderJson(response);
|
|
139
|
+
}
|
|
140
|
+
else {
|
|
141
|
+
renderRelationList(response.relations, response.response_metadata?.next_cursor ?? null);
|
|
142
|
+
}
|
|
143
|
+
}));
|
|
144
|
+
relations
|
|
145
|
+
.command("update")
|
|
146
|
+
.description("Update a relation definition. The identifier, source and target entity types, and cardinality cannot be changed.")
|
|
147
|
+
.argument("<identifier>", "Relation identifier")
|
|
148
|
+
.option("--type <type>", RELATION_TYPE_HELP)
|
|
149
|
+
.option("--description <desc>", "Description of the relation")
|
|
150
|
+
.addHelpText("afterAll", createExampleText([
|
|
151
|
+
{
|
|
152
|
+
label: "Update a relation's description",
|
|
153
|
+
command: 'dx catalog relations update service-depends-on-service --description "Runtime dependencies between services"',
|
|
154
|
+
},
|
|
155
|
+
{
|
|
156
|
+
label: "Change the relation type and return JSON",
|
|
157
|
+
command: "dx catalog relations update service-provides-package --type provides --json",
|
|
158
|
+
},
|
|
159
|
+
]))
|
|
160
|
+
.action(wrapAction(async (identifier, options, command) => {
|
|
161
|
+
if (options.type === undefined && options.description === undefined) {
|
|
162
|
+
throw new CliError("Provide at least one of --type or --description to update", EXIT_CODES.ARGUMENT_ERROR);
|
|
163
|
+
}
|
|
164
|
+
const runtime = await buildRuntime(getContext(command));
|
|
165
|
+
const response = await updateRelation(runtime, identifier, {
|
|
166
|
+
type: options.type,
|
|
167
|
+
description: options.description,
|
|
168
|
+
});
|
|
169
|
+
if (runtime.context.json) {
|
|
170
|
+
renderJson(response);
|
|
171
|
+
}
|
|
172
|
+
else {
|
|
173
|
+
renderRelation(response.relation, `${ui.success(ui.GLYPHS.CHECK)} Relation updated`);
|
|
174
|
+
}
|
|
175
|
+
}));
|
|
176
|
+
return relations;
|
|
177
|
+
}
|
|
178
|
+
async function createRelation(runtime, params) {
|
|
179
|
+
const body = {
|
|
180
|
+
identifier: params.identifier,
|
|
181
|
+
source_entity_type_identifier: params.source_entity_type_identifier,
|
|
182
|
+
target_entity_type_identifier: params.target_entity_type_identifier,
|
|
183
|
+
type: params.type,
|
|
184
|
+
cardinality: params.cardinality,
|
|
185
|
+
};
|
|
186
|
+
if (params.description !== undefined)
|
|
187
|
+
body.description = params.description;
|
|
188
|
+
const response = await request(runtime, "/catalog.relations.create", {
|
|
189
|
+
method: "POST",
|
|
190
|
+
body,
|
|
191
|
+
});
|
|
192
|
+
return response.body;
|
|
193
|
+
}
|
|
194
|
+
async function deleteRelation(runtime, identifier) {
|
|
195
|
+
const response = await request(runtime, "/catalog.relations.delete", {
|
|
196
|
+
method: "POST",
|
|
197
|
+
query: { identifier },
|
|
198
|
+
});
|
|
199
|
+
return response.body;
|
|
200
|
+
}
|
|
201
|
+
async function getRelation(runtime, identifier) {
|
|
202
|
+
const response = await request(runtime, "/catalog.relations.info", {
|
|
203
|
+
method: "GET",
|
|
204
|
+
query: { identifier },
|
|
205
|
+
});
|
|
206
|
+
return response.body;
|
|
207
|
+
}
|
|
208
|
+
async function listRelations(runtime, params) {
|
|
209
|
+
const query = {};
|
|
210
|
+
if (params.cursor !== undefined)
|
|
211
|
+
query.cursor = params.cursor;
|
|
212
|
+
if (params.limit !== undefined)
|
|
213
|
+
query.limit = params.limit;
|
|
214
|
+
const response = await request(runtime, "/catalog.relations.list", {
|
|
215
|
+
method: "GET",
|
|
216
|
+
query,
|
|
217
|
+
});
|
|
218
|
+
return response.body;
|
|
219
|
+
}
|
|
220
|
+
async function updateRelation(runtime, identifier, params) {
|
|
221
|
+
const body = { identifier };
|
|
222
|
+
if (params.type !== undefined)
|
|
223
|
+
body.type = params.type;
|
|
224
|
+
if (params.description !== undefined)
|
|
225
|
+
body.description = params.description;
|
|
226
|
+
const response = await request(runtime, "/catalog.relations.update", {
|
|
227
|
+
method: "POST",
|
|
228
|
+
body,
|
|
229
|
+
});
|
|
230
|
+
return response.body;
|
|
231
|
+
}
|
|
@@ -0,0 +1,38 @@
|
|
|
1
|
+
import { renderRichText } from "../../renderers.js";
|
|
2
|
+
import * as ui from "../../ui.js";
|
|
3
|
+
export function renderRelationList(relations, nextCursor) {
|
|
4
|
+
const blocks = [ui.h1("Relations")];
|
|
5
|
+
blocks.push(ui.p(`Displaying ${ui.bold(relations.length.toString())} relations.`));
|
|
6
|
+
if (nextCursor) {
|
|
7
|
+
blocks.push(ui.p(`Next cursor: ${ui.code(nextCursor)}`));
|
|
8
|
+
}
|
|
9
|
+
for (const relation of relations) {
|
|
10
|
+
blocks.push(ui.h2(ui.code(relation.identifier)));
|
|
11
|
+
blocks.push(...relationContent(relation));
|
|
12
|
+
}
|
|
13
|
+
renderRichText(blocks);
|
|
14
|
+
}
|
|
15
|
+
export function renderRelation(relation, title = "Relation Information") {
|
|
16
|
+
renderRichText([ui.h1(title), ...relationContent(relation)]);
|
|
17
|
+
}
|
|
18
|
+
export function renderRelationDeleted(relation) {
|
|
19
|
+
renderRichText([
|
|
20
|
+
ui.h1(`${ui.success(ui.GLYPHS.CHECK)} Relation deleted`),
|
|
21
|
+
ui.p(`The relation ${ui.code(relation.identifier)} has been deleted.`),
|
|
22
|
+
]);
|
|
23
|
+
}
|
|
24
|
+
function relationContent(relation) {
|
|
25
|
+
return [
|
|
26
|
+
ui.dl([
|
|
27
|
+
ui.dli("Public ID", ui.code(relation.public_id)),
|
|
28
|
+
ui.dli("Identifier", ui.code(relation.identifier)),
|
|
29
|
+
ui.dli("Relation", `${ui.code(relation.source_entity_type_identifier)} ${relation.type} ${ui.code(relation.target_entity_type_identifier)}`),
|
|
30
|
+
ui.dli("Type", relation.type),
|
|
31
|
+
ui.dli("Inverse type", relation.inverse_type),
|
|
32
|
+
ui.dli("Cardinality", ui.code(relation.cardinality)),
|
|
33
|
+
ui.dli("Description", relation.description || ui.dim("(None)")),
|
|
34
|
+
ui.dli("Created", ui.timestampSummary(relation.created_at)),
|
|
35
|
+
ui.dli("Last updated", ui.timestampSummary(relation.updated_at)),
|
|
36
|
+
], { termWidth: 15 }),
|
|
37
|
+
];
|
|
38
|
+
}
|
package/dist/commands/catalog.js
CHANGED
|
@@ -1,11 +1,15 @@
|
|
|
1
1
|
import { Command } from "commander";
|
|
2
2
|
import { entitiesCommand } from "./catalog/entities.js";
|
|
3
3
|
import { entityTypesCommand } from "./catalog/entityTypes.js";
|
|
4
|
+
import { relationEdgesCommand } from "./catalog/relationEdges.js";
|
|
5
|
+
import { relationsCommand } from "./catalog/relations.js";
|
|
4
6
|
export function catalogCommand() {
|
|
5
7
|
const catalog = new Command()
|
|
6
8
|
.name("catalog")
|
|
7
9
|
.description("Manage the Software Catalog");
|
|
8
10
|
catalog.addCommand(entitiesCommand());
|
|
9
11
|
catalog.addCommand(entityTypesCommand());
|
|
12
|
+
catalog.addCommand(relationEdgesCommand());
|
|
13
|
+
catalog.addCommand(relationsCommand());
|
|
10
14
|
return catalog;
|
|
11
15
|
}
|
|
@@ -28,6 +28,11 @@ entity_filter_type_identifiers:
|
|
|
28
28
|
# SQL filter expression (when entity_filter_type is "sql")
|
|
29
29
|
entity_filter_sql: ""
|
|
30
30
|
|
|
31
|
+
# Optional: default view when looking at the scorecard's results. Set to null to clear.
|
|
32
|
+
# default_display_entity_type_identifier: service
|
|
33
|
+
# default_display_grouping: owner_team # entity, owner_team, owner_leaf, or relation:<relation_id>
|
|
34
|
+
# (relation groupings require default_display_entity_type_identifier)
|
|
35
|
+
|
|
31
36
|
# User IDs of editors
|
|
32
37
|
editors: []
|
|
33
38
|
|
|
@@ -1,5 +1,5 @@
|
|
|
1
1
|
import { Command } from "commander";
|
|
2
|
-
import { createExampleText, getContext, parsePositiveIntOption, wrapAction, } from "../commandHelpers.js";
|
|
2
|
+
import { createExampleText, getContext, parseCommaSeparatedValues, parsePositiveIntOption, wrapAction, } from "../commandHelpers.js";
|
|
3
3
|
import { CliError, EXIT_CODES } from "../errors.js";
|
|
4
4
|
import { request } from "../http.js";
|
|
5
5
|
import { renderJson, renderRichText } from "../renderers.js";
|
|
@@ -165,6 +165,60 @@ export function snapshotsCommand() {
|
|
|
165
165
|
}
|
|
166
166
|
}));
|
|
167
167
|
snapshots.addCommand(snapshotTeams);
|
|
168
|
+
const workflowScores = new Command()
|
|
169
|
+
.name("workflowScores")
|
|
170
|
+
.description("Work with snapshot workflow scores");
|
|
171
|
+
workflowScores
|
|
172
|
+
.command("list")
|
|
173
|
+
.description("List workflow scores and details for a snapshot")
|
|
174
|
+
.option("--id <id>", "The unique ID of the snapshot")
|
|
175
|
+
.option("--team-ids <ids>", "Comma-separated team IDs")
|
|
176
|
+
.option("--snapshot-team-ids <ids>", "Comma-separated snapshot team IDs")
|
|
177
|
+
.option("--group-ids <ids>", "Comma-separated group IDs")
|
|
178
|
+
.option("--branch-ids <ids>", "Comma-separated branch IDs")
|
|
179
|
+
.option("--attribute-ids <ids>", "Comma-separated attribute IDs")
|
|
180
|
+
.addHelpText("afterAll", createExampleText([
|
|
181
|
+
{
|
|
182
|
+
label: "List workflow scores for a snapshot",
|
|
183
|
+
command: "dx snapshots workflowScores list --id MjUyNbaY",
|
|
184
|
+
},
|
|
185
|
+
{
|
|
186
|
+
label: "List snapshot workflow scores as JSON",
|
|
187
|
+
command: "dx --json snapshots workflowScores list --id MjUyNbaY",
|
|
188
|
+
},
|
|
189
|
+
{
|
|
190
|
+
label: "Filter workflow scores by team IDs",
|
|
191
|
+
command: "dx snapshots workflowScores list --id MjUyNbaY --team-ids MTExNDg5,MTExNDg4",
|
|
192
|
+
},
|
|
193
|
+
{
|
|
194
|
+
label: "Filter workflow scores by snapshot team IDs",
|
|
195
|
+
command: "dx snapshots workflowScores list --id MjUyNbaY --snapshot-team-ids NTcxMDM",
|
|
196
|
+
},
|
|
197
|
+
{
|
|
198
|
+
label: "Filter workflow scores by group IDs",
|
|
199
|
+
command: "dx snapshots workflowScores list --id MjUyNbaY --group-ids MzYyMjM",
|
|
200
|
+
},
|
|
201
|
+
{
|
|
202
|
+
label: "Filter workflow scores by attribute IDs",
|
|
203
|
+
command: "dx snapshots workflowScores list --id MjUyNbaY --attribute-ids NzI4OA",
|
|
204
|
+
},
|
|
205
|
+
{
|
|
206
|
+
label: "Filter workflow scores by branch IDs",
|
|
207
|
+
command: "dx snapshots workflowScores list --id MjUyNbaY --branch-ids MQ",
|
|
208
|
+
},
|
|
209
|
+
]))
|
|
210
|
+
.action(wrapAction(async (options, command) => {
|
|
211
|
+
const snapshotId = parseRequiredTextOption(options.id, "--id");
|
|
212
|
+
const runtime = await buildRuntime(getContext(command));
|
|
213
|
+
const response = await listSnapshotWorkflowScores(runtime, snapshotId, options);
|
|
214
|
+
if (runtime.context.json) {
|
|
215
|
+
renderJson(response);
|
|
216
|
+
}
|
|
217
|
+
else {
|
|
218
|
+
renderSnapshotWorkflowScores(response, snapshotId, options, runtime);
|
|
219
|
+
}
|
|
220
|
+
}));
|
|
221
|
+
snapshots.addCommand(workflowScores);
|
|
168
222
|
return snapshots;
|
|
169
223
|
}
|
|
170
224
|
async function listSnapshotDriverComments(runtime, options) {
|
|
@@ -196,6 +250,57 @@ async function listSnapshotTeams(runtime, snapshotId) {
|
|
|
196
250
|
});
|
|
197
251
|
return response.body;
|
|
198
252
|
}
|
|
253
|
+
async function listSnapshotWorkflowScores(runtime, snapshotId, options) {
|
|
254
|
+
const response = await request(runtime, "/snapshots.workflowScores.list", {
|
|
255
|
+
method: "GET",
|
|
256
|
+
query: {
|
|
257
|
+
snapshot_id: snapshotId,
|
|
258
|
+
...snapshotFilterQuery(options, "apiKey"),
|
|
259
|
+
},
|
|
260
|
+
});
|
|
261
|
+
return response.body;
|
|
262
|
+
}
|
|
263
|
+
// The web app names some filter params differently from the API. Attributes
|
|
264
|
+
// have no webKey because the web app only accepts decoded tag IDs.
|
|
265
|
+
const SNAPSHOT_FILTER_KEYS = [
|
|
266
|
+
{ option: "teamIds", apiKey: "team_ids", webKey: "team_ids" },
|
|
267
|
+
{
|
|
268
|
+
option: "snapshotTeamIds",
|
|
269
|
+
apiKey: "snapshot_team_ids",
|
|
270
|
+
webKey: "snapshot_team_ids",
|
|
271
|
+
},
|
|
272
|
+
{ option: "groupIds", apiKey: "group_ids", webKey: "group_ids" },
|
|
273
|
+
{ option: "branchIds", apiKey: "branch_ids", webKey: "branch" },
|
|
274
|
+
{ option: "attributeIds", apiKey: "attribute_ids" },
|
|
275
|
+
];
|
|
276
|
+
// The web app can't combine groups with snapshot teams (selecting a group
|
|
277
|
+
// clears snapshot teams) and only selects one branch at a time, so drop those
|
|
278
|
+
// filters from the link and report which ones were left out
|
|
279
|
+
function snapshotWebFilters(options) {
|
|
280
|
+
const webOptions = { ...options };
|
|
281
|
+
const omittedFlags = [];
|
|
282
|
+
if (parseCommaSeparatedValues(options.groupIds) &&
|
|
283
|
+
parseCommaSeparatedValues(options.snapshotTeamIds)) {
|
|
284
|
+
webOptions.snapshotTeamIds = undefined;
|
|
285
|
+
omittedFlags.push("--snapshot-team-ids");
|
|
286
|
+
}
|
|
287
|
+
if ((parseCommaSeparatedValues(options.branchIds)?.length ?? 0) > 1) {
|
|
288
|
+
webOptions.branchIds = undefined;
|
|
289
|
+
omittedFlags.push("--branch-ids");
|
|
290
|
+
}
|
|
291
|
+
if (parseCommaSeparatedValues(options.attributeIds)) {
|
|
292
|
+
omittedFlags.push("--attribute-ids");
|
|
293
|
+
}
|
|
294
|
+
return { query: snapshotFilterQuery(webOptions, "webKey"), omittedFlags };
|
|
295
|
+
}
|
|
296
|
+
function snapshotFilterQuery(options, target) {
|
|
297
|
+
return Object.fromEntries(SNAPSHOT_FILTER_KEYS.flatMap((keys) => {
|
|
298
|
+
const key = keys[target];
|
|
299
|
+
return key
|
|
300
|
+
? [[key, parseCommaSeparatedValues(options[keys.option])?.join(",")]]
|
|
301
|
+
: [];
|
|
302
|
+
}));
|
|
303
|
+
}
|
|
199
304
|
async function getSnapshotInfo(runtime, snapshotId) {
|
|
200
305
|
const response = await request(runtime, "/snapshots.info", {
|
|
201
306
|
method: "GET",
|
|
@@ -314,6 +419,70 @@ function renderSnapshotInfo(snapshot, snapshotId, runtime) {
|
|
|
314
419
|
}
|
|
315
420
|
renderRichText(blocks);
|
|
316
421
|
}
|
|
422
|
+
function renderSnapshotWorkflowScores(response, snapshotId, filterOptions, runtime) {
|
|
423
|
+
const { results } = response;
|
|
424
|
+
const filterDetails = workflowScoresFilterDetails(response.filters);
|
|
425
|
+
const webFilters = snapshotWebFilters(filterOptions);
|
|
426
|
+
const blocks = [ui.h1("Snapshot Workflow Scores")];
|
|
427
|
+
if (filterDetails.length > 0) {
|
|
428
|
+
blocks.push(ui.dl(filterDetails, { termWidth: 17 }));
|
|
429
|
+
}
|
|
430
|
+
blocks.push(ui.p(`Displaying ${ui.bold(results.length.toString())} ${results.length === 1 ? "workflow" : "workflows"}.`), ui.p(ui.link(
|
|
431
|
+
// Sort the web view by workflow name, ascending (sb = sort by, sr = sort reversed)
|
|
432
|
+
snapshotWebLink(snapshotId, "workflow", runtime, {
|
|
433
|
+
sb: "name",
|
|
434
|
+
sr: "false",
|
|
435
|
+
...webFilters.query,
|
|
436
|
+
}))));
|
|
437
|
+
if (webFilters.omittedFlags.length > 0) {
|
|
438
|
+
blocks.push(ui.p(ui.dim(`Web link omits ${webFilters.omittedFlags.join(" and ")}; the web app can't apply those filters.`)));
|
|
439
|
+
}
|
|
440
|
+
if (results.length === 0) {
|
|
441
|
+
blocks.push(ui.p(ui.dim("(None)")));
|
|
442
|
+
renderRichText(blocks);
|
|
443
|
+
return;
|
|
444
|
+
}
|
|
445
|
+
for (const result of results) {
|
|
446
|
+
blocks.push(ui.h2(result.workflow.name));
|
|
447
|
+
blocks.push(ui.p(result.workflow.prompt));
|
|
448
|
+
if (result.hidden) {
|
|
449
|
+
blocks.push(ui.p(ui.dim("(Hidden)")));
|
|
450
|
+
continue;
|
|
451
|
+
}
|
|
452
|
+
const benchmark = result.workflow.benchmark;
|
|
453
|
+
const benchmarkValue = benchmark
|
|
454
|
+
? `${benchmark.name} (${benchmark.year}) p50=${benchmark.p_50}, p75=${benchmark.p_75}, p90=${benchmark.p_90}`
|
|
455
|
+
: ui.dim("(None)");
|
|
456
|
+
blocks.push(ui.dl([
|
|
457
|
+
ui.dli("Workflow ID", result.workflow.id),
|
|
458
|
+
ui.dli("Score", formatNumber(result.score)),
|
|
459
|
+
optionalDetail("Respondents", formatNumber(result.total_respondents), result.total_respondents),
|
|
460
|
+
optionalDetail("Cohort size", formatNumber(result.total_cohort), result.total_cohort),
|
|
461
|
+
ui.dli("Unit", formatText(result.workflow.unit)),
|
|
462
|
+
ui.dli("Lower is better", result.workflow.lower_is_better ? "Yes" : "No"),
|
|
463
|
+
ui.dli("Benchmark", benchmarkValue),
|
|
464
|
+
].filter((detail) => detail !== null), { termWidth: 17 }));
|
|
465
|
+
blocks.push(ui.h3("Distribution"));
|
|
466
|
+
blocks.push(ui.p(formatWorkflowDistributionTable(result.distribution)));
|
|
467
|
+
}
|
|
468
|
+
renderRichText(blocks);
|
|
469
|
+
}
|
|
470
|
+
function workflowScoresFilterDetails(filters) {
|
|
471
|
+
const names = (items) => items.map((item) => item.name).join(", ");
|
|
472
|
+
const attributes = filters.attribute_groups.flatMap((group) => group.attributes.map((attribute) => ({
|
|
473
|
+
id: attribute.id,
|
|
474
|
+
name: `${group.name}: ${attribute.name}`,
|
|
475
|
+
})));
|
|
476
|
+
return [
|
|
477
|
+
["Teams", filters.teams],
|
|
478
|
+
["Snapshot teams", filters.snapshot_teams],
|
|
479
|
+
["Groups", filters.groups],
|
|
480
|
+
["Branches", filters.branches],
|
|
481
|
+
["Attributes", attributes],
|
|
482
|
+
]
|
|
483
|
+
.filter(([, items]) => items.length > 0)
|
|
484
|
+
.map(([term, items]) => ui.dli(term, names(items)));
|
|
485
|
+
}
|
|
317
486
|
function renderSnapshots(snapshots, runtime) {
|
|
318
487
|
const blocks = [ui.h1("Snapshots")];
|
|
319
488
|
blocks.push(ui.p(`Displaying ${ui.bold(snapshots.length.toString())} snapshots.`));
|
|
@@ -466,19 +635,44 @@ function formatBenchmarkTable(teamScore) {
|
|
|
466
635
|
["vs_90th", formatBenchmarkNumber(teamScore.vs_90th)],
|
|
467
636
|
];
|
|
468
637
|
const columns = ["Benchmark", "Value"];
|
|
469
|
-
|
|
470
|
-
const border = `+${widths.map((width) => "-".repeat(width + 2)).join("+")}+`;
|
|
471
|
-
const header = `| ${columns.map((column, index) => column.padEnd(widths[index])).join(" | ")} |`;
|
|
472
|
-
const body = rows.map((row) => `| ${row.map((cell, index) => cell.padEnd(widths[index])).join(" | ")} |`);
|
|
473
|
-
return [border, header, border, ...body, border].join("\n");
|
|
638
|
+
return buildTable(columns, rows);
|
|
474
639
|
}
|
|
475
640
|
function formatBenchmarkNumber(value) {
|
|
476
641
|
return typeof value === "number" && Number.isFinite(value)
|
|
477
642
|
? value.toString()
|
|
478
643
|
: "(None)";
|
|
479
644
|
}
|
|
480
|
-
function
|
|
481
|
-
|
|
645
|
+
function formatWorkflowDistributionTable(distribution) {
|
|
646
|
+
if (distribution.length === 0) {
|
|
647
|
+
return ui.dim("(None)");
|
|
648
|
+
}
|
|
649
|
+
const rows = distribution.map((item) => [
|
|
650
|
+
item.label.trim(),
|
|
651
|
+
item.value.toString(),
|
|
652
|
+
item.count.toString(),
|
|
653
|
+
]);
|
|
654
|
+
const columns = ["Label", "Value", "Count"];
|
|
655
|
+
return buildTable(columns, rows);
|
|
656
|
+
}
|
|
657
|
+
function buildTable(columns, rows) {
|
|
658
|
+
const widths = columns.map((column, index) => Math.max(column.length, ...rows.map((row) => row[index].length)));
|
|
659
|
+
const border = `+${widths.map((width) => "-".repeat(width + 2)).join("+")}+`;
|
|
660
|
+
const header = `| ${columns.map((column, index) => column.padEnd(widths[index])).join(" | ")} |`;
|
|
661
|
+
const body = rows.map((row) => `| ${row.map((cell, index) => cell.padEnd(widths[index])).join(" | ")} |`);
|
|
662
|
+
return [border, header, border, ...body, border].join("\n");
|
|
663
|
+
}
|
|
664
|
+
function snapshotWebLink(snapshotId, section, runtime, queryParams = {}) {
|
|
665
|
+
let urlPath = `/snapshots/${snapshotId}/${section}`;
|
|
666
|
+
const params = new URLSearchParams();
|
|
667
|
+
for (const [key, value] of Object.entries(queryParams)) {
|
|
668
|
+
if (value !== undefined) {
|
|
669
|
+
params.set(key, value);
|
|
670
|
+
}
|
|
671
|
+
}
|
|
672
|
+
if (params.size > 0) {
|
|
673
|
+
urlPath += `?${params.toString()}`;
|
|
674
|
+
}
|
|
675
|
+
return ui.webLink(urlPath, runtime);
|
|
482
676
|
}
|
|
483
677
|
function parseRequiredTextOption(value, flag) {
|
|
484
678
|
if (typeof value !== "string" || value.trim().length === 0) {
|
|
@@ -370,12 +370,20 @@ function studioReportToYaml(report) {
|
|
|
370
370
|
viewer_emails: report.viewer_emails ?? [],
|
|
371
371
|
edit_access_type: report.edit_access_type,
|
|
372
372
|
editor_emails: report.editor_emails ?? [],
|
|
373
|
+
sections: report.sections?.map((section) => ({
|
|
374
|
+
id: section.id,
|
|
375
|
+
name: section.name,
|
|
376
|
+
description: section.description,
|
|
377
|
+
})),
|
|
373
378
|
tiles: report.tiles.map((tile) => ({
|
|
374
379
|
id: tile.id,
|
|
375
380
|
title: tile.title,
|
|
376
381
|
sql: tile.sql,
|
|
377
382
|
chart_type: tile.chart_type,
|
|
378
383
|
chart_config: tile.chart_config,
|
|
384
|
+
section_id: tile.section_id,
|
|
385
|
+
width: tile.width,
|
|
386
|
+
height: tile.height,
|
|
379
387
|
})),
|
|
380
388
|
};
|
|
381
389
|
return stringifyYaml(payload, { blockQuote: "literal" });
|
|
@@ -0,0 +1,207 @@
|
|
|
1
|
+
import { Command, Option } from "commander";
|
|
2
|
+
import { createExampleText, getContext, parseCommaSeparatedValues, wrapAction, } from "../../commandHelpers.js";
|
|
3
|
+
import { CliError, EXIT_CODES } from "../../errors.js";
|
|
4
|
+
import { requestWithMessages } from "../../http.js";
|
|
5
|
+
import { renderJson, renderRichText } from "../../renderers.js";
|
|
6
|
+
import { buildRuntime } from "../../runtime.js";
|
|
7
|
+
import * as ui from "../../ui.js";
|
|
8
|
+
const PROPERTIES = [
|
|
9
|
+
{
|
|
10
|
+
key: "start_date",
|
|
11
|
+
label: "Start date",
|
|
12
|
+
valueName: "date",
|
|
13
|
+
description: "Start date as YYYY-MM-DD, or null to clear it",
|
|
14
|
+
parse: parseDate,
|
|
15
|
+
},
|
|
16
|
+
{
|
|
17
|
+
key: "github_username",
|
|
18
|
+
label: "GitHub username",
|
|
19
|
+
valueName: "username",
|
|
20
|
+
description: "GitHub username, or null to clear it",
|
|
21
|
+
parse: parseText,
|
|
22
|
+
},
|
|
23
|
+
{
|
|
24
|
+
key: "additional_github_username",
|
|
25
|
+
label: "Additional GitHub username",
|
|
26
|
+
valueName: "username",
|
|
27
|
+
description: "Additional GitHub username, or null to clear it",
|
|
28
|
+
parse: parseText,
|
|
29
|
+
},
|
|
30
|
+
{
|
|
31
|
+
key: "gitlab_username",
|
|
32
|
+
label: "GitLab username",
|
|
33
|
+
valueName: "username",
|
|
34
|
+
description: "GitLab username, or null to clear it",
|
|
35
|
+
parse: parseText,
|
|
36
|
+
},
|
|
37
|
+
{
|
|
38
|
+
key: "ai_light_adoption_date",
|
|
39
|
+
label: "AI light adoption date",
|
|
40
|
+
valueName: "date",
|
|
41
|
+
description: "Date the user became a light AI adopter as YYYY-MM-DD, or null to clear it",
|
|
42
|
+
parse: parseDate,
|
|
43
|
+
},
|
|
44
|
+
{
|
|
45
|
+
key: "ai_moderate_adoption_date",
|
|
46
|
+
label: "AI moderate adoption date",
|
|
47
|
+
valueName: "date",
|
|
48
|
+
description: "Date the user became a moderate AI adopter as YYYY-MM-DD, or null to clear it",
|
|
49
|
+
parse: parseDate,
|
|
50
|
+
},
|
|
51
|
+
{
|
|
52
|
+
key: "ai_heavy_adoption_date",
|
|
53
|
+
label: "AI heavy adoption date",
|
|
54
|
+
valueName: "date",
|
|
55
|
+
description: "Date the user became a heavy AI adopter as YYYY-MM-DD, or null to clear it",
|
|
56
|
+
parse: parseDate,
|
|
57
|
+
},
|
|
58
|
+
{
|
|
59
|
+
key: "sso_email",
|
|
60
|
+
label: "SSO email",
|
|
61
|
+
valueName: "email",
|
|
62
|
+
description: "Alternate email used to authenticate through SAML SSO, or null to clear it",
|
|
63
|
+
parse: parseText,
|
|
64
|
+
},
|
|
65
|
+
{
|
|
66
|
+
key: "additional_emails",
|
|
67
|
+
label: "Additional emails",
|
|
68
|
+
valueName: "emails",
|
|
69
|
+
description: "Comma-separated additional email addresses. Replaces the existing list; null clears it",
|
|
70
|
+
parse: parseEmails,
|
|
71
|
+
},
|
|
72
|
+
{
|
|
73
|
+
key: "protected",
|
|
74
|
+
label: "Protected",
|
|
75
|
+
valueName: "true|false",
|
|
76
|
+
description: "Whether the user's IC metrics are de-identified",
|
|
77
|
+
parse: (value, flag) => parseBoolean(value, flag, false),
|
|
78
|
+
},
|
|
79
|
+
{
|
|
80
|
+
key: "exclude_from_snapshots",
|
|
81
|
+
label: "Exclude from snapshots",
|
|
82
|
+
valueName: "true|false",
|
|
83
|
+
description: "Whether the user is left out of snapshots",
|
|
84
|
+
parse: (value, flag) => parseBoolean(value, flag, false),
|
|
85
|
+
},
|
|
86
|
+
{
|
|
87
|
+
key: "developer",
|
|
88
|
+
label: "Developer",
|
|
89
|
+
valueName: "true|false|null",
|
|
90
|
+
description: "Whether the user is a developer: true, false, or null to clear it. Clearing only works for accounts that manage developer classification manually",
|
|
91
|
+
parse: (value, flag) => parseBoolean(value, flag, true),
|
|
92
|
+
},
|
|
93
|
+
];
|
|
94
|
+
const ERROR_MESSAGES = {
|
|
95
|
+
"User not found": "No active DX user has that email address. Check the spelling.",
|
|
96
|
+
"Developer must be true or false": "Only accounts that manage developer classification manually can clear the developer flag. Pass true or false instead.",
|
|
97
|
+
};
|
|
98
|
+
export function updateCommand() {
|
|
99
|
+
const update = new Command()
|
|
100
|
+
.name("update")
|
|
101
|
+
.description("Update the properties of a DX user")
|
|
102
|
+
.argument("<email>", "Email address of the user to update");
|
|
103
|
+
for (const property of PROPERTIES) {
|
|
104
|
+
update.addOption(new Option(`${flagFor(property)} <${property.valueName}>`, property.description));
|
|
105
|
+
}
|
|
106
|
+
return update
|
|
107
|
+
.addHelpText("afterAll", createExampleText([
|
|
108
|
+
{
|
|
109
|
+
label: "Set a user's start date and GitHub username",
|
|
110
|
+
command: "dx users update ada@example.com --start-date 2024-10-07 --github-username ada-lovelace",
|
|
111
|
+
},
|
|
112
|
+
{
|
|
113
|
+
label: "Clear a user's GitLab username",
|
|
114
|
+
command: "dx users update ada@example.com --gitlab-username null",
|
|
115
|
+
},
|
|
116
|
+
{
|
|
117
|
+
label: "Mark a user as not a developer and return JSON",
|
|
118
|
+
command: "dx users update ada@example.com --developer false --json",
|
|
119
|
+
},
|
|
120
|
+
]))
|
|
121
|
+
.action(wrapAction(async (email, options, command) => {
|
|
122
|
+
const params = { email };
|
|
123
|
+
for (const property of PROPERTIES) {
|
|
124
|
+
const value = options[attributeNameFor(property)];
|
|
125
|
+
if (value !== undefined) {
|
|
126
|
+
params[property.key] = property.parse(value, flagFor(property));
|
|
127
|
+
}
|
|
128
|
+
}
|
|
129
|
+
if (Object.keys(params).length === 1) {
|
|
130
|
+
throw new CliError("Nothing to update. Pass at least one property option, such as --start-date or --developer. See --help for the full list.", EXIT_CODES.ARGUMENT_ERROR);
|
|
131
|
+
}
|
|
132
|
+
const runtime = await buildRuntime(getContext(command));
|
|
133
|
+
const response = await updateUser(runtime, params);
|
|
134
|
+
if (runtime.context.json) {
|
|
135
|
+
renderJson(response);
|
|
136
|
+
}
|
|
137
|
+
else {
|
|
138
|
+
renderRichText([
|
|
139
|
+
ui.p(`${ui.success(ui.GLYPHS.CHECK)} Updated user ${ui.code(response.user.email)}.`),
|
|
140
|
+
ui.dl(
|
|
141
|
+
// The API only returns protected when the account has protected_user_ic_metrics_disabled on.
|
|
142
|
+
PROPERTIES.filter(({ key }) => key !== "protected" ||
|
|
143
|
+
response.user.protected !== undefined).map(({ key, label }) => ui.dli(label, formatValue(response.user[key]))), {
|
|
144
|
+
termWidth: Math.max(...PROPERTIES.map(({ label }) => label.length)) +
|
|
145
|
+
2,
|
|
146
|
+
}),
|
|
147
|
+
]);
|
|
148
|
+
}
|
|
149
|
+
}));
|
|
150
|
+
}
|
|
151
|
+
function flagFor(property) {
|
|
152
|
+
return `--${property.key.replace(/_/g, "-")}`;
|
|
153
|
+
}
|
|
154
|
+
function attributeNameFor(property) {
|
|
155
|
+
return new Option(flagFor(property)).attributeName();
|
|
156
|
+
}
|
|
157
|
+
function formatValue(value) {
|
|
158
|
+
if (Array.isArray(value)) {
|
|
159
|
+
return value.length > 0 ? value.join(", ") : "unset";
|
|
160
|
+
}
|
|
161
|
+
return String(value ?? "unset");
|
|
162
|
+
}
|
|
163
|
+
function parseText(value) {
|
|
164
|
+
return value === "null" ? null : value;
|
|
165
|
+
}
|
|
166
|
+
function parseDate(value, flag) {
|
|
167
|
+
if (value === "null") {
|
|
168
|
+
return null;
|
|
169
|
+
}
|
|
170
|
+
// JS rolls 2024-02-30 over to March 1, so compare the round trip.
|
|
171
|
+
const date = new Date(`${value}T00:00:00Z`);
|
|
172
|
+
if (!/^\d{4}-\d{2}-\d{2}$/.test(value) ||
|
|
173
|
+
Number.isNaN(date.getTime()) ||
|
|
174
|
+
!date.toISOString().startsWith(value)) {
|
|
175
|
+
throw new CliError(`${flag} must be a date formatted as YYYY-MM-DD, or null`, EXIT_CODES.ARGUMENT_ERROR);
|
|
176
|
+
}
|
|
177
|
+
return value;
|
|
178
|
+
}
|
|
179
|
+
function parseEmails(value, flag) {
|
|
180
|
+
if (value === "null") {
|
|
181
|
+
return null;
|
|
182
|
+
}
|
|
183
|
+
const emails = parseCommaSeparatedValues(value);
|
|
184
|
+
if (emails === undefined) {
|
|
185
|
+
throw new CliError(`${flag} needs at least one email address, or null to clear the list`, EXIT_CODES.ARGUMENT_ERROR);
|
|
186
|
+
}
|
|
187
|
+
return emails;
|
|
188
|
+
}
|
|
189
|
+
function parseBoolean(value, flag, allowNull) {
|
|
190
|
+
const normalized = value.trim().toLowerCase();
|
|
191
|
+
if (normalized === "true") {
|
|
192
|
+
return true;
|
|
193
|
+
}
|
|
194
|
+
if (normalized === "false") {
|
|
195
|
+
return false;
|
|
196
|
+
}
|
|
197
|
+
if (allowNull && normalized === "null") {
|
|
198
|
+
return null;
|
|
199
|
+
}
|
|
200
|
+
throw new CliError(`${flag} must be ${allowNull ? "true, false, or null" : "true or false"}`, EXIT_CODES.ARGUMENT_ERROR);
|
|
201
|
+
}
|
|
202
|
+
async function updateUser(runtime, params) {
|
|
203
|
+
return requestWithMessages(runtime, "/users.update", {
|
|
204
|
+
method: "POST",
|
|
205
|
+
body: params,
|
|
206
|
+
}, ERROR_MESSAGES);
|
|
207
|
+
}
|
package/dist/commands/users.js
CHANGED
|
@@ -1,9 +1,11 @@
|
|
|
1
1
|
import { Command } from "commander";
|
|
2
2
|
import { linkableAccountsCommand } from "./users/linkableAccounts.js";
|
|
3
3
|
import { linksCommand } from "./users/links.js";
|
|
4
|
+
import { updateCommand } from "./users/update.js";
|
|
4
5
|
export function usersCommand() {
|
|
5
6
|
const users = new Command().name("users").description("Manage DX users");
|
|
6
7
|
users.addCommand(linkableAccountsCommand());
|
|
7
8
|
users.addCommand(linksCommand());
|
|
9
|
+
users.addCommand(updateCommand());
|
|
8
10
|
return users;
|
|
9
11
|
}
|
|
@@ -31,6 +31,12 @@ trigger_type: ANY
|
|
|
31
31
|
# - squad_id: "<team id>" # user_id or squad_id
|
|
32
32
|
# squad_linkage_id: null # optional
|
|
33
33
|
|
|
34
|
+
# timeout: how long a run can stay in progress before it times out. Only
|
|
35
|
+
# applies when execution_type is EVENT_DRIVEN (ignored for SIMPLE). One of
|
|
36
|
+
# ONE_HOUR, SIX_HOURS, TWELVE_HOURS, ONE_DAY, THREE_DAYS, SEVEN_DAYS,
|
|
37
|
+
# FOURTEEN_DAYS or THIRTY_DAYS. Optional; if not set, a default of 1 hour is used.
|
|
38
|
+
# timeout: ONE_DAY
|
|
39
|
+
|
|
34
40
|
# Which entities the workflow can run against (only used when scope is ENTITY).
|
|
35
41
|
# entity_filter_type: "ENTITY_TYPES" or "SQL"
|
|
36
42
|
entity_filter_type: ENTITY_TYPES
|
|
@@ -72,6 +72,9 @@ function coreContent(workflow) {
|
|
|
72
72
|
ui.dli("Scope", workflowScopeLabel(workflow.scope)),
|
|
73
73
|
ui.dli("Execution type", workflowExecutionTypeLabel(workflow.execution_type)),
|
|
74
74
|
ui.dli("Who can run", `${workflowTriggerTypeLabel(workflow.trigger_type)} ${ui.dim(workflowTriggerTypeDescription(workflow.trigger_type))}`),
|
|
75
|
+
...(workflow.timeout
|
|
76
|
+
? [ui.dli("Timeout", ui.code(workflow.timeout))]
|
|
77
|
+
: []),
|
|
75
78
|
], { termWidth: 16 }));
|
|
76
79
|
return blocks;
|
|
77
80
|
}
|