@waron97/prbot 3.1.3 → 3.2.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.
Files changed (39) hide show
  1. package/README.md +50 -11
  2. package/agrippa-pb.md +350 -0
  3. package/package.json +7 -3
  4. package/src/agrippa/commands/clone.js +29 -13
  5. package/src/agrippa/commands/clonePb.js +107 -0
  6. package/src/agrippa/commands/diff.js +94 -18
  7. package/src/agrippa/commands/init.js +66 -12
  8. package/src/agrippa/commands/initPhase.js +16 -17
  9. package/src/agrippa/commands/pb.js +279 -0
  10. package/src/agrippa/commands/pull.js +119 -13
  11. package/src/agrippa/commands/pullPb.js +54 -0
  12. package/src/agrippa/commands/push.js +112 -47
  13. package/src/agrippa/commands/pushPb.js +87 -0
  14. package/src/agrippa/commands/repair.js +3 -1
  15. package/src/agrippa/index.js +138 -14
  16. package/src/agrippa/lib/api.js +17 -3
  17. package/src/agrippa/lib/checksum.js +3 -1
  18. package/src/agrippa/lib/config.js +2 -2
  19. package/src/agrippa/lib/pbApi.js +71 -0
  20. package/src/agrippa/lib/pbEdit.js +467 -0
  21. package/src/agrippa/lib/pbLayout.js +283 -0
  22. package/src/agrippa/lib/pbModel.js +698 -0
  23. package/src/agrippa/lib/pbPreview.js +151 -0
  24. package/src/agrippa/lib/pbProject.js +390 -0
  25. package/src/agrippa/lib/pbWorkspace.js +30 -0
  26. package/src/agrippa/lib/workspace.js +23 -3
  27. package/src/commands/autopr.js +5 -2
  28. package/src/commands/changelog.js +7 -2
  29. package/src/commands/export.js +3 -3
  30. package/src/commands/exportEmailTemplates.js +25 -15
  31. package/src/commands/exportImperex.js +4 -4
  32. package/src/commands/exportLrp.js +10 -7
  33. package/src/commands/exportPb.js +4 -5
  34. package/src/commands/exportWorkflow.js +27 -14
  35. package/src/commands/init.js +7 -0
  36. package/src/commands/routine.js +7 -5
  37. package/src/index.js +24 -7
  38. package/src/lib/premigrate.js +3 -3
  39. package/src/lib/updateCheck.js +5 -2
@@ -0,0 +1,467 @@
1
+ // Structural graph helpers for a decomposed process-builder project.
2
+ //
3
+ // These operate on the parsed structure.yaml object (the nested graph: nodes
4
+ // hold their own outgoing `edges` and, for containers, nested `nodes`). They
5
+ // cover the ops where hand-editing the multi-thousand-line YAML traps an agent:
6
+ // BPMN id generation, dangling-edge cleanup on remove, subProcess nesting, and
7
+ // multi-file scaffolding (script/page/manifest). Content edits (rename, change a
8
+ // condition, edit a script body) stay raw-YAML — a helper there adds nothing.
9
+ //
10
+ // Functions mutate `structure`/`manifest` in place and return any file
11
+ // side-effects (`writes`/`deletes`) plus a `result` for the CLI to report.
12
+ // Geometry is intentionally stubbed (correct size, placeholder position) — the
13
+ // agent runs `pb format` to finalize layout. No IO happens here.
14
+
15
+ import { stringify as yamlStringify } from 'yaml';
16
+ import { pad, toSlug } from './pbProject.js';
17
+
18
+ // Fixed element sizes (measured across all fixtures). Containers are sized by
19
+ // the layout engine; we seed a small default so a stubbed clone stays valid.
20
+ const SIZE = {
21
+ startEvent: [36, 36],
22
+ endEvent: [36, 36],
23
+ boundaryEvent: [36, 36],
24
+ exclusiveGateway: [50, 50],
25
+ scriptTask: [84, 84],
26
+ serviceTask: [84, 84],
27
+ userTask: [84, 84],
28
+ subProcess: [350, 200],
29
+ transaction: [350, 200],
30
+ };
31
+
32
+ // BPMN id prefixes by node type (mirrors the upstream naming convention).
33
+ const PREFIX = {
34
+ startEvent: 'StartEvent',
35
+ endEvent: 'EndEvent',
36
+ boundaryEvent: 'BoundaryEvent',
37
+ exclusiveGateway: 'ExclusiveGateway',
38
+ scriptTask: 'ScriptTask',
39
+ serviceTask: 'ServiceTask',
40
+ userTask: 'UserTask',
41
+ subProcess: 'SubProcess',
42
+ transaction: 'Transaction',
43
+ };
44
+
45
+ const CONTAINER = new Set(['subProcess', 'transaction']);
46
+
47
+ // Depth-first visit of every node in the nested graph, with its parent node.
48
+ function eachNode(nodes, parent, fn) {
49
+ for (const n of nodes || []) {
50
+ fn(n, parent);
51
+ if (n.nodes) eachNode(n.nodes, n, fn);
52
+ }
53
+ }
54
+
55
+ // All ids in use (nodes, edges, annotations, associations) — for collision-free gen.
56
+ function collectIds(structure) {
57
+ const ids = new Set();
58
+ eachNode(structure.nodes, null, (n) => {
59
+ ids.add(n.id);
60
+ for (const e of n.edges || []) ids.add(e.id);
61
+ });
62
+ for (const a of structure.annotations || []) ids.add(a.id);
63
+ for (const a of structure.associations || []) ids.add(a.id);
64
+ return ids;
65
+ }
66
+
67
+ function rand() {
68
+ return Math.random().toString(36).slice(2, 9).padEnd(7, '0');
69
+ }
70
+
71
+ function genId(structure, prefix) {
72
+ const ids = collectIds(structure);
73
+ let id;
74
+ do {
75
+ id = `${prefix}_${rand()}`;
76
+ } while (ids.has(id));
77
+ return id;
78
+ }
79
+
80
+ // Locate a node anywhere in the tree → { node, list (its containing array), parent }.
81
+ function findNode(structure, id) {
82
+ let found = null;
83
+ const search = (nodes, parent) => {
84
+ for (const n of nodes || []) {
85
+ if (n.id === id) {
86
+ found = { node: n, list: nodes, parent };
87
+ return true;
88
+ }
89
+ if (n.nodes && search(n.nodes, n)) return true;
90
+ }
91
+ return false;
92
+ };
93
+ search(structure.nodes, null);
94
+ return found;
95
+ }
96
+
97
+ // Center point of a node from its (possibly stub) layout — for placeholder waypoints.
98
+ function centerOf(n) {
99
+ const l = n.layout || { x: 0, y: 0, width: 0, height: 0 };
100
+ return [Math.round(l.x + (l.width || 0) / 2), Math.round(l.y + (l.height || 0) / 2)];
101
+ }
102
+
103
+ // Next script sequence number (max existing ×10 step, default 10).
104
+ function nextScriptSeq(existingScriptFiles) {
105
+ let max = 0;
106
+ for (const f of existingScriptFiles || []) {
107
+ const m = /(?:^|\/)(\d{4})_/.exec(f);
108
+ if (m) max = Math.max(max, parseInt(m[1], 10));
109
+ }
110
+ return max + 10;
111
+ }
112
+
113
+ // ---------- add ----------
114
+
115
+ // Add a node. For scriptTask, scaffolds an empty script file and refs it. For
116
+ // userTask, scaffolds a minimal page file + a manifest page entry (guid=null →
117
+ // push will create it upstream). Container nodes start empty + expanded.
118
+ function addNode(structure, manifest, opts, ctx = {}) {
119
+ const { type, name, parentId } = opts;
120
+ if (!PREFIX[type]) throw new Error(`Unknown node type: ${type}`);
121
+
122
+ const id = genId(structure, PREFIX[type]);
123
+ const [w, h] = SIZE[type];
124
+ const node = { id, type };
125
+ if (name !== undefined) node.name = name;
126
+ node.layout = { x: 0, y: 0, width: w, height: h };
127
+
128
+ const writes = {};
129
+
130
+ if (type === 'scriptTask') {
131
+ const seq = nextScriptSeq(ctx.existingScripts);
132
+ const file = `scripts/${pad(seq)}_${toSlug(name || id)}.js`;
133
+ writes[file] = '';
134
+ node.script = file;
135
+ } else if (type === 'userTask') {
136
+ const formKey = toSlug(name || id);
137
+ node.formKey = formKey;
138
+ const file = `pages/${formKey}.yml`;
139
+ const pageObj = {
140
+ _id: { stepkey: id, processkey: ctx.documentId ?? manifest?.document_id ?? null },
141
+ columns: 1,
142
+ entities: [],
143
+ language: '',
144
+ page_name: '',
145
+ page_builder: [],
146
+ };
147
+ writes[file] = yamlStringify(pageObj, { lineWidth: 0 });
148
+ manifest.pages = manifest.pages || {};
149
+ manifest.pages[id] = {
150
+ file,
151
+ wrapper: {
152
+ name: id,
153
+ guid: null,
154
+ process_guid: manifest.guid ?? null,
155
+ system_record: false,
156
+ owner_group: null,
157
+ },
158
+ };
159
+ } else if (CONTAINER.has(type)) {
160
+ node.expanded = 'true';
161
+ node.nodes = [];
162
+ }
163
+
164
+ if (parentId) {
165
+ const f = findNode(structure, parentId);
166
+ if (!f) throw new Error(`parent not found: ${parentId}`);
167
+ if (!CONTAINER.has(f.node.type))
168
+ throw new Error(`parent ${parentId} is not a container (subProcess/transaction)`);
169
+ f.node.nodes = f.node.nodes || [];
170
+ f.node.nodes.push(node);
171
+ } else {
172
+ structure.nodes.push(node);
173
+ }
174
+
175
+ return { writes, deletes: [], result: { id, type, file: writes && Object.keys(writes)[0] } };
176
+ }
177
+
178
+ // Add a node spliced between two already-connected nodes: there must be exactly
179
+ // one sequenceFlow `from` → `to` already (none, or more than one, is an error —
180
+ // ambiguous or nothing to splice). That edge (id, name, condition, default-flag
181
+ // reference — all keyed by edge id) is kept and retargeted onto the new node; a
182
+ // second plain edge runs new-node → `to`. `from`/`to` must share the same
183
+ // container — boundary-crossing flows aren't auto-spliced.
184
+ function addNodeBetween(structure, manifest, opts, ctx = {}) {
185
+ const { from, to, type, name } = opts;
186
+ if (!PREFIX[type]) throw new Error(`Unknown node type: ${type}`);
187
+
188
+ const sf = findNode(structure, from);
189
+ if (!sf) throw new Error(`source node not found: ${from}`);
190
+ const st = findNode(structure, to);
191
+ if (!st) throw new Error(`target node not found: ${to}`);
192
+
193
+ const sfParentId = sf.parent ? sf.parent.id : null;
194
+ const stParentId = st.parent ? st.parent.id : null;
195
+ if (sfParentId !== stParentId) {
196
+ throw new Error(
197
+ `${from} and ${to} are in different containers (boundary-crossing flow) — ` +
198
+ 'insert not supported automatically; use `pb add` + `pb connect`/`pb disconnect` manually.'
199
+ );
200
+ }
201
+
202
+ const candidates = (sf.node.edges || []).filter((e) => e.target === to);
203
+ if (candidates.length !== 1) {
204
+ throw new Error(
205
+ candidates.length === 0
206
+ ? `no edge ${from} → ${to}; nothing to insert between.`
207
+ : `${candidates.length} edges ${from} → ${to} (${candidates.map((e) => e.id).join(', ')}); ` +
208
+ 'must be exactly one to insert between.'
209
+ );
210
+ }
211
+ const edge = candidates[0];
212
+
213
+ const { writes, result } = addNode(
214
+ structure,
215
+ manifest,
216
+ { type, name, parentId: sfParentId || undefined },
217
+ ctx
218
+ );
219
+ const newNode = findNode(structure, result.id).node;
220
+
221
+ edge.target = result.id;
222
+ edge.waypoints = [centerOf(sf.node), centerOf(newNode)];
223
+
224
+ const id2 = genId(structure, 'SequenceFlow');
225
+ newNode.edges = newNode.edges || [];
226
+ newNode.edges.push({ id: id2, target: to, waypoints: [centerOf(newNode), centerOf(st.node)] });
227
+
228
+ const warnings =
229
+ sf.node.type === 'exclusiveGateway'
230
+ ? lintGateways(structure).filter((w) => w.startsWith(from))
231
+ : [];
232
+
233
+ return {
234
+ writes,
235
+ deletes: [],
236
+ result: {
237
+ id: result.id,
238
+ type,
239
+ file: result.file,
240
+ edgeId: edge.id,
241
+ newEdgeId: id2,
242
+ warnings,
243
+ },
244
+ };
245
+ }
246
+
247
+ // ---------- remove ----------
248
+
249
+ // Remove a node (and, recursively, its container children). Drops the node's own
250
+ // outgoing edges (they nest under it) AND every edge elsewhere targeting it or a
251
+ // descendant, plus any associations referencing them. Deletes the script/page
252
+ // files and manifest page entries for the removed userTask/scriptTask nodes.
253
+ function removeNode(structure, manifest, { id }) {
254
+ const f = findNode(structure, id);
255
+ if (!f) throw new Error(`node not found: ${id}`);
256
+
257
+ const victims = [];
258
+ const collect = (n) => {
259
+ victims.push(n);
260
+ for (const c of n.nodes || []) collect(c);
261
+ };
262
+ collect(f.node);
263
+ const victimIds = new Set(victims.map((n) => n.id));
264
+
265
+ const deletes = [];
266
+ for (const n of victims) {
267
+ if (n.type === 'scriptTask' && n.script) deletes.push(n.script);
268
+ if (n.type === 'userTask') {
269
+ const info = manifest.pages?.[n.id];
270
+ if (info?.file) deletes.push(info.file);
271
+ if (manifest.pages) delete manifest.pages[n.id];
272
+ }
273
+ }
274
+
275
+ // unlink the node from its containing list
276
+ f.list.splice(f.list.indexOf(f.node), 1);
277
+
278
+ // drop dangling edges (inbound from outside the removed subtree)
279
+ let removedEdges = 0;
280
+ eachNode(structure.nodes, null, (n) => {
281
+ if (!n.edges) return;
282
+ const before = n.edges.length;
283
+ n.edges = n.edges.filter((e) => !victimIds.has(e.target));
284
+ removedEdges += before - n.edges.length;
285
+ });
286
+ if (structure.associations) {
287
+ structure.associations = structure.associations.filter(
288
+ (a) => !victimIds.has(a.sourceRef) && !victimIds.has(a.targetRef)
289
+ );
290
+ }
291
+
292
+ return { writes: {}, deletes, result: { removed: [...victimIds], removedEdges } };
293
+ }
294
+
295
+ // ---------- connect / disconnect ----------
296
+
297
+ // Add a sequenceFlow from `from` to `to`. Stub straight-line waypoints (center→
298
+ // center) keep the project recompose-valid until `pb format` reroutes.
299
+ //
300
+ // exclusiveGateway rule (enforced by Activiti): when a gateway has >1 outgoing
301
+ // flow, exactly one must be the `default` and every other must carry a condition
302
+ // expression. `--default` sets the source gateway's default to the new edge;
303
+ // otherwise a non-default flow should be given a `--condition`. Violations are
304
+ // returned as `result.warnings` (non-blocking) so the caller can surface them.
305
+ function connect(structure, { from, to, name, condition, conditionType, makeDefault }) {
306
+ const sf = findNode(structure, from);
307
+ if (!sf) throw new Error(`source node not found: ${from}`);
308
+ const st = findNode(structure, to);
309
+ if (!st) throw new Error(`target node not found: ${to}`);
310
+
311
+ const id = genId(structure, 'SequenceFlow');
312
+ const edge = { id, target: to };
313
+ if (name !== undefined) edge.name = name;
314
+ if (condition !== undefined) {
315
+ edge.condition = condition;
316
+ edge.conditionType = conditionType || 'tFormalExpression';
317
+ }
318
+ edge.waypoints = [centerOf(sf.node), centerOf(st.node)];
319
+
320
+ sf.node.edges = sf.node.edges || [];
321
+ sf.node.edges.push(edge);
322
+
323
+ const warnings = [];
324
+ if (makeDefault) {
325
+ if (sf.node.type !== 'exclusiveGateway') {
326
+ warnings.push(
327
+ `--default only applies to exclusiveGateway; ${from} is ${sf.node.type}.`
328
+ );
329
+ } else {
330
+ const prev = sf.node.attrs?.default;
331
+ sf.node.attrs = sf.node.attrs || {};
332
+ sf.node.attrs.default = id;
333
+ if (prev && prev !== id)
334
+ warnings.push(`replaced previous default flow ${prev} on ${from}.`);
335
+ }
336
+ }
337
+ // Re-check the gateway's invariant after the edit.
338
+ if (sf.node.type === 'exclusiveGateway') {
339
+ warnings.push(...lintGateways(structure).filter((w) => w.startsWith(from)));
340
+ }
341
+
342
+ return { writes: {}, deletes: [], result: { id, from, to, warnings } };
343
+ }
344
+
345
+ // Mark an existing outgoing flow as the source gateway's default — by edge id,
346
+ // or by --from/--to pair. The flow must already exist (use `pb connect` to add a
347
+ // new one); this only flips the default flag, so the agent no longer has to
348
+ // delete and re-add a connection just to change which one is default. The owner
349
+ // must be an exclusiveGateway. Returns the previous default (if any) and a fresh
350
+ // gateway lint as non-blocking warnings.
351
+ function setDefault(structure, { id, from, to }) {
352
+ let owner = null;
353
+ let edge = null;
354
+ eachNode(structure.nodes, null, (n) => {
355
+ if (owner || !n.edges) return;
356
+ const e = id
357
+ ? n.edges.find((x) => x.id === id)
358
+ : n.id === from && n.edges.find((x) => x.target === to);
359
+ if (e) {
360
+ owner = n;
361
+ edge = e;
362
+ }
363
+ });
364
+ if (!edge) throw new Error(id ? `edge not found: ${id}` : `no edge from ${from} to ${to}`);
365
+ if (owner.type !== 'exclusiveGateway')
366
+ throw new Error(
367
+ `--default only applies to exclusiveGateway; ${owner.id} is ${owner.type}.`
368
+ );
369
+
370
+ const prev = owner.attrs?.default;
371
+ owner.attrs = owner.attrs || {};
372
+ owner.attrs.default = edge.id;
373
+
374
+ const warnings = lintGateways(structure).filter((w) => w.startsWith(owner.id));
375
+ return {
376
+ writes: {},
377
+ deletes: [],
378
+ result: { id: edge.id, from: owner.id, to: edge.target, prev, warnings },
379
+ };
380
+ }
381
+
382
+ // Flag exclusiveGateways that violate the default/condition rule: a gateway with
383
+ // >1 outgoing flow needs exactly one default, and every non-default flow needs a
384
+ // condition. Returns human-readable issue strings (empty = all good).
385
+ function lintGateways(structure) {
386
+ const issues = [];
387
+ eachNode(structure.nodes, null, (n) => {
388
+ if (n.type !== 'exclusiveGateway') return;
389
+ const outs = n.edges || [];
390
+ if (outs.length < 2) return;
391
+ const def = n.attrs?.default;
392
+ const hasDefault = def && outs.some((e) => e.id === def);
393
+ if (!hasDefault) {
394
+ issues.push(
395
+ `${n.id} (${n.name || 'gateway'}): ${outs.length} outgoing flows but no default — mark one with --default.`
396
+ );
397
+ }
398
+ for (const e of outs) {
399
+ if (e.id === def) continue;
400
+ if (e.condition === undefined) {
401
+ issues.push(
402
+ `${n.id} → ${e.target} (${e.id}): non-default flow without a condition expression.`
403
+ );
404
+ }
405
+ }
406
+ });
407
+ return issues;
408
+ }
409
+
410
+ // Remove an edge by id, or by --from/--to pair.
411
+ function disconnect(structure, { id, from, to }) {
412
+ let removed = 0;
413
+ let removedId = null;
414
+ eachNode(structure.nodes, null, (n) => {
415
+ if (!n.edges) return;
416
+ const before = n.edges.length;
417
+ n.edges = n.edges.filter((e) => {
418
+ const match = id ? e.id === id : n.id === from && e.target === to;
419
+ if (match) removedId = e.id;
420
+ return !match;
421
+ });
422
+ removed += before - n.edges.length;
423
+ });
424
+ if (!removed) throw new Error(id ? `edge not found: ${id}` : `no edge from ${from} to ${to}`);
425
+ return { writes: {}, deletes: [], result: { removed, id: removedId } };
426
+ }
427
+
428
+ // ---------- list ----------
429
+
430
+ // Flat rows for `pb ls` so an agent can discover ids/targets without reading YAML.
431
+ function listGraph(structure) {
432
+ const rows = [];
433
+ eachNode(structure.nodes, null, (n, parent) => {
434
+ const def = n.attrs?.default;
435
+ rows.push({
436
+ id: n.id,
437
+ type: n.type,
438
+ name: n.name ?? '',
439
+ parent: parent ? parent.id : null,
440
+ edges: (n.edges || []).map((e) => ({
441
+ id: e.id,
442
+ target: e.target,
443
+ name: e.name,
444
+ condition: e.condition,
445
+ isDefault: def === e.id,
446
+ })),
447
+ });
448
+ });
449
+ return rows;
450
+ }
451
+
452
+ export {
453
+ SIZE,
454
+ PREFIX,
455
+ CONTAINER,
456
+ eachNode,
457
+ findNode,
458
+ genId,
459
+ addNode,
460
+ addNodeBetween,
461
+ removeNode,
462
+ connect,
463
+ disconnect,
464
+ setDefault,
465
+ listGraph,
466
+ lintGateways,
467
+ };