@waron97/prbot 3.4.0 → 3.6.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/README.md CHANGED
@@ -4,6 +4,8 @@ CLI tool for managing PRs, changelogs, and Odoo workflow XML files in the addons
4
4
 
5
5
  ## Install
6
6
 
7
+ Requires Node >= 20.
8
+
7
9
  ```bash
8
10
  npm install -g @waron97/prbot
9
11
  ```
@@ -129,9 +131,11 @@ prbot export workflow --no-commit
129
131
 
130
132
  Options:
131
133
 
132
- | Flag | Description |
133
- | ------------- | ------------------------ |
134
- | `--no-commit` | Skip the git commit step |
134
+ | Flag | Description |
135
+ | -------------- | ------------------------------------------------------------- |
136
+ | `--no-commit` | Skip the git commit step |
137
+ | `-q, --quiet` | Suppress informational output — errors still fail the command |
138
+ | `-s, --silent` | Deprecated alias of `--quiet`; no longer swallows errors |
135
139
 
136
140
  ### `prbot export pb`
137
141
 
@@ -144,9 +148,11 @@ prbot export pb --no-commit
144
148
 
145
149
  Options:
146
150
 
147
- | Flag | Description |
148
- | ------------- | ------------------------ |
149
- | `--no-commit` | Skip the git commit step |
151
+ | Flag | Description |
152
+ | -------------- | ------------------------------------------------------------- |
153
+ | `--no-commit` | Skip the git commit step |
154
+ | `-q, --quiet` | Suppress informational output — errors still fail the command |
155
+ | `-s, --silent` | Deprecated alias of `--quiet`; no longer swallows errors |
150
156
 
151
157
  ### `prbot export imperex`
152
158
 
@@ -159,9 +165,11 @@ prbot export imperex --no-commit
159
165
 
160
166
  Options:
161
167
 
162
- | Flag | Description |
163
- | ------------- | ------------------------ |
164
- | `--no-commit` | Skip the git commit step |
168
+ | Flag | Description |
169
+ | -------------- | ------------------------------------------------------------- |
170
+ | `--no-commit` | Skip the git commit step |
171
+ | `-q, --quiet` | Suppress informational output — errors still fail the command |
172
+ | `-s, --silent` | Deprecated alias of `--quiet`; no longer swallows errors |
165
173
 
166
174
  ### `prbot export email-templates`
167
175
 
@@ -174,9 +182,11 @@ prbot export email-templates --no-commit
174
182
 
175
183
  Options:
176
184
 
177
- | Flag | Description |
178
- | ------------- | ------------------------ |
179
- | `--no-commit` | Skip the git commit step |
185
+ | Flag | Description |
186
+ | -------------- | ------------------------------------------------------------- |
187
+ | `--no-commit` | Skip the git commit step |
188
+ | `-q, --quiet` | Suppress informational output — errors still fail the command |
189
+ | `-s, --silent` | Deprecated alias of `--quiet`; no longer swallows errors |
180
190
 
181
191
  ### `prbot init`
182
192
 
@@ -184,10 +194,11 @@ Interactive setup: writes `~/.config/prbot/config`.
184
194
 
185
195
  ### `prbot update`
186
196
 
187
- Reinstalls the latest published version from npm.
197
+ Reinstalls prbot from npm — latest by default, or a pinned version.
188
198
 
189
199
  ```bash
190
- prbot update
200
+ prbot update # latest
201
+ prbot update 3.4.0 # pin an exact version
191
202
  ```
192
203
 
193
204
  ---
@@ -225,26 +236,27 @@ agrippa clone --lrp --name B2WA_ml_IFS_passive_trigger --path my-lrp/
225
236
 
226
237
  Options:
227
238
 
228
- | Flag | Description |
229
- | ----------------- | --------------------------------------------------------------------------- |
230
- | `--phase` | Clone a phase (select a workflow) |
231
- | `--mfa` | Clone an MFA record |
232
- | `--pb` | Clone a process-builder wizard |
233
- | `--lrp` | Clone a long-running process |
234
- | `--id <id>` | Skip selection, clone by ID (phase/mfa) |
235
- | `--name <name>` | Skip selection: `document_id` (with `--pb`) or process name (with `--lrp`) |
236
- | `--path <path>` | Destination path (base dir for phases/wizard/lrp, file for MFA) |
239
+ | Flag | Description |
240
+ | --------------- | -------------------------------------------------------------------------- |
241
+ | `--phase` | Clone a phase (select a workflow) |
242
+ | `--mfa` | Clone an MFA record |
243
+ | `--pb` | Clone a process-builder wizard |
244
+ | `--lrp` | Clone a long-running process |
245
+ | `--id <id>` | Skip selection, clone by ID (phase/mfa) |
246
+ | `--name <name>` | Skip selection: `document_id` (with `--pb`) or process name (with `--lrp`) |
247
+ | `--path <path>` | Destination path (base dir for phases/wizard/lrp, file for MFA) |
237
248
 
238
249
  ### `agrippa pull`
239
250
 
240
- Fetches remote code for all tracked entries and shows what changed. Classifies each as `fast-forward` (safe overwrite) or `conflict` (local edits would be lost). Lets you select which to pull.
251
+ Fetches remote code for all tracked entries and shows what changed. Classifies each as `fast-forward` (safe overwrite) or `conflict` (local edits would be lost). `conflict` entries are shown but **not** preselected — you opt in explicitly. Lets you select which to pull.
241
252
 
242
253
  After pulling, also checks tracked workflows for newly added `from_code` phases and auto-clones any not yet present locally.
243
254
 
244
- Tracked **process-builder wizards** and **long-running processes** are also refreshed from upstream: the local project is re-decomposed from the latest payload (orphan script files pruned; PB wizards also prune orphan pages), with the same `fast-forward`/`conflict` classification (PB: upstream `updated_date`; LRP: re-resolved-by-name payload checksum vs. the last pulled state LRPs have no stable id, so every pull re-resolves the current id/tenantId by name first). The current local state is backed up to `.backup/<timestamp>/<path>/local.json` first.
255
+ Tracked **process-builder wizards** and **long-running processes** are also refreshed from upstream: the local project is re-decomposed from the latest payload (orphan script files pruned; PB wizards also prune orphan pages), with the same `fast-forward`/`conflict` classification — a semantic checksum of the recomposed payload (not raw `updated_date`), canonicalized so cosmetic noise never triggers a false conflict: whitespace-only BPMN text nodes, `format`-only `labelPos`, page ordering, and the `updated_date`/`modified_by` audit fields Odoo/Symple bump on any server touch are all excluded from the comparison. The current local state is backed up to `.backup/<timestamp>/<path>/local.json` first.
245
256
 
246
257
  ```bash
247
258
  agrippa pull
259
+ agrippa pull --non-interactive # no prompts: auto-select fast-forward, fail if any conflict
248
260
  ```
249
261
 
250
262
  ### `agrippa push`
@@ -257,18 +269,21 @@ Pushing a wizard saves it as a **draft**; publish it so live consumers see the c
257
269
  agrippa push # prompts whether to publish/deploy each pushed wizard/LRP
258
270
  agrippa push --publish # auto-publish wizards, auto-deploy LRPs
259
271
  agrippa push --skip-publish # never publish/deploy (no prompt)
272
+ agrippa push --non-interactive # no prompts: auto-select fast-forward, fail if any conflict;
273
+ # publish/deploy defaults to skip unless --publish is also passed
260
274
  ```
261
275
 
262
- ### `agrippa diff [path]`
276
+ ### `agrippa diff [target]`
263
277
 
264
- Shows a diff between local files and remote code. Optionally filter to a specific file path.
278
+ Shows a diff between local files and remote code. `[target]` optionally narrows it to a single file, a workflow folder, a cloned project directory, or a `document_id`/name; omit it for the whole workspace.
265
279
 
266
- For process-builder wizards, diffs the whole project tree against what the workspace would look like if the wizard were decomposed fresh from upstream right now (`.backup/` and `preview.svg` are excluded as local-only artifacts).
280
+ For process-builder wizards and long-running processes, diffs the whole project tree against what the workspace would look like if the project were decomposed fresh from upstream right now (`.backup/` and `preview.svg` are excluded as local-only artifacts).
267
281
 
268
282
  ```bash
269
283
  agrippa diff
270
284
  agrippa diff my-workflow/some-phase.py
271
285
  agrippa diff ml_review_billing # a cloned wizard's directory
286
+ agrippa diff B2WA_M2C_passthrough # a cloned LRP, by directory or name
272
287
  ```
273
288
 
274
289
  ### `agrippa init-phase`
@@ -295,15 +310,16 @@ These commands exist mainly so an **AI agent** can add/remove/connect blocks wit
295
310
 
296
311
  Newly added blocks get placeholder geometry. `pb format` re-lays-out the **whole** diagram (discarding any hand-tuned layout), so running it is a deliberate **human decision** — the alternative is positioning the new blocks by hand in the UI. Agents are expected to make structural edits but **not** to run `format` or to `pull`/`push`; a human reviews and syncs.
297
312
 
298
- | Command | Purpose |
299
- | --------------- | ------------------------------------------------------------------------------------ |
300
- | `pb format` | Re-lay-out the **entire** diagram (elkjs, left→right); overwrites existing layout |
301
- | `pb add` | Add a node (`--type`, `--name`, `--parent`); scaffolds script/page files |
302
- | `pb rm` | Remove a node (`--id`), its edges, and its script/page files |
303
- | `pb connect` | Add a flow (`--from`, `--to`, `--condition`, `--default`); enforces the gateway rule |
304
- | `pb disconnect` | Remove a flow (`--id`, or `--from`/`--to`) |
305
- | `pb ls` | List nodes and edges with their ids (discover targets without reading the YAML) |
306
- | `pb preview` | Render the diagram to an SVG (`--out`) for a quick visual check |
313
+ | Command | Purpose |
314
+ | --------------- | -------------------------------------------------------------------------------------------------------------------------------------------------- |
315
+ | `pb format` | Re-lay-out the **entire** diagram (elkjs, left→right); overwrites existing layout |
316
+ | `pb add` | Add a node (`--type`, `--name`, `--parent`); scaffolds script/page files |
317
+ | `pb rm` | Remove a node (`--id`), its edges, and its script/page files |
318
+ | `pb connect` | Add a flow (`--from`, `--to`, `--condition`, `--default`); enforces the gateway rule |
319
+ | `pb disconnect` | Remove a flow (`--id`, or `--from`/`--to`); clears the source gateway's `default` if it pointed at that flow |
320
+ | `pb lint` | Check the structural rules: gateway defaults/conditions, branch names, incoming-flow limits, and `default`s pointing at flows that no longer exist |
321
+ | `pb ls` | List nodes and edges with their ids (discover targets without reading the YAML) |
322
+ | `pb preview` | Render the diagram to an SVG (`--out`) for a quick visual check |
307
323
 
308
324
  ```bash
309
325
  agrippa pb ls --pb ml_review_billing
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@waron97/prbot",
3
- "version": "3.4.0",
3
+ "version": "3.6.0",
4
4
  "description": "",
5
5
  "main": "index.js",
6
6
  "engines": {
@@ -15,6 +15,7 @@ import { getToken } from '../../lib/auth.js';
15
15
  import { error, log } from '../../lib/logger.js';
16
16
  import { computeChecksum } from '../lib/checksum.js';
17
17
  import { loadEffectiveEnv, readConfig } from '../lib/config.js';
18
+ import { fetchUpstream } from '../lib/lrpApi.js';
18
19
  import { getProcess } from '../lib/pbApi.js';
19
20
  import { decompose, localChecksum } from '../lib/pbProject.js';
20
21
  import { projectReader, writeProject } from '../lib/pbWorkspace.js';
@@ -36,13 +37,24 @@ async function diff(targetArg) {
36
37
  return;
37
38
  }
38
39
 
39
- const pbEntries = entries.filter((e) => e.object_type === 'process_builder');
40
- const codeEntries = entries.filter((e) => e.object_type !== 'process_builder');
40
+ // Decomposed projects (pb + lrp) diff as whole directory trees; phases and
41
+ // MFAs are single code files. An LRP left in the code bucket used to reach
42
+ // readCodeFile() with a directory path and blow up with EISDIR, so the
43
+ // split is by "is this a project" rather than "is this a wizard".
44
+ const projectEntries = entries.filter((e) => PROJECT_TYPES.has(e.object_type));
45
+ const codeEntries = entries.filter((e) => !PROJECT_TYPES.has(e.object_type));
41
46
 
42
47
  if (codeEntries.length && !process.env.RIP_URL)
43
48
  throw new Error('RIP_URL is not configured. Run `prbot init` or set it in agrippa.yaml.');
44
- if (pbEntries.length && !process.env.PB_URL)
49
+ if (projectEntries.some((e) => e.object_type === 'process_builder') && !process.env.PB_URL)
45
50
  throw new Error('PB_URL is not configured. Run `prbot init` or set it in agrippa.yaml.');
51
+ if (
52
+ projectEntries.some((e) => e.object_type === 'long_running_process') &&
53
+ !process.env.IMPORTEXPORT_URL
54
+ )
55
+ throw new Error(
56
+ 'IMPORTEXPORT_URL is not configured. Run `prbot init` or set it in agrippa.yaml.'
57
+ );
46
58
 
47
59
  log('Fetching remote code...');
48
60
  const token = await getToken();
@@ -57,8 +69,8 @@ async function diff(targetArg) {
57
69
  chunks.push(...cs);
58
70
  }
59
71
 
60
- for (const entry of pbEntries) {
61
- const { hasDiff, chunk } = await diffPbEntry(token, entry);
72
+ for (const entry of projectEntries) {
73
+ const { hasDiff, chunk } = await diffProjectEntry(token, entry);
62
74
  if (hasDiff) {
63
75
  diffCount++;
64
76
  chunks.push(chunk);
@@ -124,14 +136,33 @@ function diffCodeEntries(entries, remoteCodeMap) {
124
136
  return { diffCount, chunks };
125
137
  }
126
138
 
127
- // Process-builder wizard: decompose the upstream payload into a throwaway
128
- // project tree (exactly what re-cloning now would produce) and diff it against
129
- // a copy of the local project, recursively. `.backup/` and `preview.svg` are
130
- // local-only artifacts (push backups, dev preview render) with no upstream
131
- // counterpart, so they're stripped from the local copy before diffing.
132
- async function diffPbEntry(token, entry) {
139
+ // Decomposed project (process-builder wizard or long-running process): fetch
140
+ // the upstream payload, decompose it into a throwaway project tree (exactly
141
+ // what re-cloning now would produce) and diff it against a copy of the local
142
+ // project, recursively. `.backup/` and `preview.svg` are local-only artifacts
143
+ // (push backups, dev preview render) with no upstream counterpart, so they're
144
+ // stripped from the local copy before diffing.
145
+ //
146
+ // The two types differ only in how upstream is reached: a wizard by guid, an
147
+ // LRP by name (its tabulator id changes on every save — never a stable key,
148
+ // see pullLrpEntry). Everything downstream is the shared decompose pipeline.
149
+ const PROJECT_TYPES = new Set(['process_builder', 'long_running_process']);
150
+
151
+ async function fetchProjectUpstream(token, entry) {
152
+ if (entry.object_type === 'long_running_process') {
153
+ const res = await fetchUpstream(token, entry.name);
154
+ if (!res?.payload) throw new Error(`could not fetch upstream LRP "${entry.name}"`);
155
+ return res.payload;
156
+ }
133
157
  const upstream = await getProcess(token, entry.guid);
134
158
  if (!upstream) throw new Error(`could not fetch upstream wizard ${entry.guid}`);
159
+ return upstream;
160
+ }
161
+
162
+ async function diffProjectEntry(token, entry) {
163
+ const upstream = await fetchProjectUpstream(token, entry);
164
+ const label =
165
+ entry.object_type === 'long_running_process' ? 'long-running process' : 'process-builder';
135
166
 
136
167
  const tmpRoot = mkdtempSync(join(tmpdir(), 'agrippa-pb-diff-'));
137
168
  try {
@@ -159,7 +190,7 @@ async function diffPbEntry(token, entry) {
159
190
  if (result.status !== null && result.status > 1) {
160
191
  error(`git diff failed for ${entry.path}`);
161
192
  }
162
- const header = Buffer.from(`\n=== ${entry.path} [${entry.name}] (process-builder) ===\n`);
193
+ const header = Buffer.from(`\n=== ${entry.path} [${entry.name}] (${label}) ===\n`);
163
194
  const chunk = Buffer.concat([header, result.stdout ?? Buffer.alloc(0)]);
164
195
  return { hasDiff: true, chunk };
165
196
  } finally {
@@ -173,14 +204,21 @@ function filterEntries(workspace, targetArg) {
173
204
  // Normalise: strip trailing slash
174
205
  const target = targetArg.replace(/\/$/, '');
175
206
 
176
- // If it exists on disk and is a directory, filter by prefix
207
+ // If it exists on disk and is a directory, take both the entries *under* it
208
+ // (a workflow folder holding one file per phase) and any entry that *is* it
209
+ // — a pb/lrp project is tracked as the directory itself, so prefix matching
210
+ // alone silently returned nothing for the most common target.
177
211
  if (existsSync(target) && statSync(target).isDirectory()) {
178
- const prefix = target.endsWith('/') ? target : target + '/';
179
- return workspace.filter((e) => e.path.startsWith(prefix));
212
+ const prefix = target + '/';
213
+ const matches = workspace.filter((e) => e.path === target || e.path.startsWith(prefix));
214
+ if (matches.length) return matches;
180
215
  }
181
216
 
182
- // Otherwise treat as exact file path (whether it exists yet or not)
183
- return workspace.filter((e) => e.path === target);
217
+ // Exact file path (whether it exists yet or not), else fall back to the
218
+ // identifiers the other commands accept — document_id (`--pb`) and name.
219
+ const byPath = workspace.filter((e) => e.path === target);
220
+ if (byPath.length) return byPath;
221
+ return workspace.filter((e) => e.document_id === target || e.name === target);
184
222
  }
185
223
 
186
224
  export { diff };
@@ -218,6 +218,8 @@ async function pbRemove(opts) {
218
218
  `Removed ${result.removed.length} node(s) [${result.removed.join(', ')}], ` +
219
219
  `${result.removedEdges} dangling edge(s), ${deletes.length} file(s).`
220
220
  );
221
+ for (const c of result.clearedDefaults || [])
222
+ log(` cleared dangling default ${c.edge} on ${c.node}.`);
221
223
  }
222
224
 
223
225
  async function pbConnect(opts) {
@@ -250,6 +252,8 @@ async function pbDisconnect(opts) {
250
252
  saveStructure(dir, structure);
251
253
  validate(dir);
252
254
  log(`Removed ${result.removed} edge(s)${result.id ? ` (${result.id})` : ''}.`);
255
+ for (const c of result.clearedDefaults || [])
256
+ log(` cleared dangling default ${c.edge} on ${c.node}.`);
253
257
  }
254
258
 
255
259
  async function pbSetDefault(opts) {
@@ -91,8 +91,11 @@ program
91
91
  );
92
92
 
93
93
  program
94
- .command('diff [path]')
95
- .description('Show differences between local files and remote code')
94
+ .command('diff [target]')
95
+ .description(
96
+ 'Show differences between local files and remote code. [target] = file, folder, ' +
97
+ 'project dir, document_id or name; omit for the whole workspace'
98
+ )
96
99
  .action((path) =>
97
100
  diff(path).catch((err) => {
98
101
  error(`Error: ${err.message}`);
@@ -317,7 +317,13 @@ function removeNode(structure, manifest, { id }) {
317
317
  );
318
318
  }
319
319
 
320
- return { writes: {}, deletes, result: { removed: [...victimIds], removedEdges } };
320
+ const clearedDefaults = clearDanglingDefaults(structure);
321
+
322
+ return {
323
+ writes: {},
324
+ deletes,
325
+ result: { removed: [...victimIds], removedEdges, clearedDefaults },
326
+ };
321
327
  }
322
328
 
323
329
  // ---------- connect / disconnect ----------
@@ -492,15 +498,55 @@ function lintIncomingEdges(structure) {
492
498
  return issues;
493
499
  }
494
500
 
501
+ // Flag `attrs.default` values that don't name an outgoing edge of their own
502
+ // node. lintGateways only covers the inverse (a gateway that needs a default
503
+ // and hasn't got a valid one), so a node left with a single outgoing flow and a
504
+ // default pointing at a deleted one used to pass clean — and then fail at
505
+ // publish with Activiti's null-reference NPE. The edits that delete edges now
506
+ // clear these themselves (clearDanglingDefaults); this rule is the net for
507
+ // hand-edited structure.yaml and for projects predating that fix.
508
+ function lintDanglingDefaults(structure) {
509
+ const issues = [];
510
+ eachNode(structure.nodes, null, (n) => {
511
+ const def = n.attrs?.default;
512
+ if (!def) return;
513
+ if ((n.edges || []).some((e) => e.id === def)) return;
514
+ issues.push(
515
+ `${n.id} (${n.name || n.type}): default flow ${def} does not exist on this node — ` +
516
+ `Activiti fails the deploy on the dangling reference.`
517
+ );
518
+ });
519
+ return issues;
520
+ }
521
+
495
522
  // Run all lint rules and return combined issues.
496
523
  function lintAll(structure) {
497
524
  return [
498
525
  ...lintGateways(structure),
526
+ ...lintDanglingDefaults(structure),
499
527
  ...lintEdgeNames(structure),
500
528
  ...lintIncomingEdges(structure),
501
529
  ];
502
530
  }
503
531
 
532
+ // Drop every `attrs.default` that no longer names an outgoing edge of its own
533
+ // node. Must run after any edit that deletes edges: a default referencing a
534
+ // removed flow recomposes fine and passes the other lint rules, but Activiti
535
+ // resolves the reference at deploy time, gets null, and the publish fails with
536
+ // an unattributed `Could not deploy the XML: null. Error: null`. Returns the
537
+ // cleared [nodeId, edgeId] pairs so callers can report them.
538
+ function clearDanglingDefaults(structure) {
539
+ const cleared = [];
540
+ eachNode(structure.nodes, null, (n) => {
541
+ const def = n.attrs?.default;
542
+ if (!def) return;
543
+ if ((n.edges || []).some((e) => e.id === def)) return;
544
+ delete n.attrs.default;
545
+ cleared.push({ node: n.id, edge: def });
546
+ });
547
+ return cleared;
548
+ }
549
+
504
550
  // Remove an edge by id, or by --from/--to pair.
505
551
  function disconnect(structure, { id, from, to }) {
506
552
  let removed = 0;
@@ -516,7 +562,8 @@ function disconnect(structure, { id, from, to }) {
516
562
  removed += before - n.edges.length;
517
563
  });
518
564
  if (!removed) throw new Error(id ? `edge not found: ${id}` : `no edge from ${from} to ${to}`);
519
- return { writes: {}, deletes: [], result: { removed, id: removedId } };
565
+ const clearedDefaults = clearDanglingDefaults(structure);
566
+ return { writes: {}, deletes: [], result: { removed, id: removedId, clearedDefaults } };
520
567
  }
521
568
 
522
569
  // ---------- list ----------
@@ -391,9 +391,29 @@ function stableStringify(value) {
391
391
  // to resync.
392
392
  const VOLATILE_AUDIT_FIELDS = ['updated_date', 'modified_by'];
393
393
 
394
+ // `status`/`version` are the server's publication lifecycle, not content —
395
+ // excluded for the same reason as the audit fields above, but only at the top
396
+ // level (page wrappers carry neither). They surface in process.yaml via
397
+ // EDITABLE_SCALARS and are still sent on push; they just don't count as a
398
+ // change. Every PATCH flips the wizard server-side to draft/modified while
399
+ // process.yaml keeps whatever status was recorded at the last clone/pull, and
400
+ // push stores the *local* checksum as the new baseline — so a push not
401
+ // followed by a publish guaranteed a phantom `conflict` on the next push,
402
+ // with zero content difference on either side (observed live 2026-07-21 on
403
+ // ml_voltura_fibra_data_input: upstream came back `modified` against a
404
+ // `published` process.yaml, shifting the remote checksum off the baseline on
405
+ // its own). `version` is worse still: a publish that bumps it server-side
406
+ // pins the object to `conflict` permanently, since nothing local resyncs it.
407
+ const VOLATILE_LIFECYCLE_FIELDS = ['status', 'version'];
408
+
394
409
  function canonicalForChecksum(payload) {
395
410
  const { process, decls } = normalizeProcessTree(payload.built_page);
396
- const rest = omit(payload, ['built_page', 'pages', ...VOLATILE_AUDIT_FIELDS]);
411
+ const rest = omit(payload, [
412
+ 'built_page',
413
+ 'pages',
414
+ ...VOLATILE_AUDIT_FIELDS,
415
+ ...VOLATILE_LIFECYCLE_FIELDS,
416
+ ]);
397
417
  // Page order is not a stable identity — locally it's whatever order the
398
418
  // manifest happened to record at last decompose, while a live upstream
399
419
  // fetch can return the same set of pages in a different order (observed
@@ -7,6 +7,7 @@ import { resolveAddonsPath } from '../lib/addons.js';
7
7
  import { fuzzyMatch } from '../lib/fuzzy.js';
8
8
  import { execGit } from '../lib/git.js';
9
9
  import { log } from '../lib/logger.js';
10
+ import { tridentRpc } from '../lib/trident.js';
10
11
  import {
11
12
  appendPrToLine,
12
13
  appendRefsToLine,
@@ -27,42 +28,11 @@ function devopsHeaders() {
27
28
  }
28
29
 
29
30
  async function fetchTask(taskId) {
30
- const url = `${process.env.TRIDENT_URL}/jsonrpc`;
31
- const body = {
32
- jsonrpc: '2.0',
33
- method: 'call',
34
- id: 1,
35
- params: {
36
- service: 'object',
37
- method: 'execute_kw',
38
- args: [
39
- process.env.TRIDENT_DB,
40
- parseInt(process.env.TRIDENT_UID, 10),
41
- process.env.TRIDENT_TOKEN,
42
- 'project.task',
43
- 'read',
44
- [[parseInt(taskId, 10)]],
45
- {
46
- fields: [
47
- 'name',
48
- 'x_subpackage_id',
49
- 'x_workflow',
50
- 'x_cluster_id',
51
- 'x_release_checklist',
52
- ],
53
- },
54
- ],
55
- },
56
- };
57
- const res = await fetch(url, {
58
- method: 'POST',
59
- headers: { 'Content-Type': 'application/json' },
60
- body: JSON.stringify(body),
31
+ const result = await tridentRpc('project.task', 'read', [[parseInt(taskId, 10)]], {
32
+ fields: ['name', 'x_subpackage_id', 'x_workflow', 'x_cluster_id', 'x_release_checklist'],
61
33
  });
62
- const data = await res.json();
63
- if (data.error) throw new Error(`Trident error: ${JSON.stringify(data.error)}`);
64
- if (!data.result || !data.result[0]) throw new Error(`Task ${taskId} not found`);
65
- return data.result[0];
34
+ if (!result || !result[0]) throw new Error(`Task ${taskId} not found`);
35
+ return result[0];
66
36
  }
67
37
 
68
38
  function buildPrDescription(taskIds, jiras) {
@@ -124,30 +94,10 @@ async function createDevopsPR(branch, title, description) {
124
94
  async function appendChecklistPrLink(taskId, currentChecklist, prUrl) {
125
95
  const link = `<a href="${prUrl}">${prUrl}</a><br/>`;
126
96
  const updated = currentChecklist ? `${currentChecklist}\n${link}` : link;
127
- const url = `${process.env.TRIDENT_URL}/jsonrpc`;
128
- const res = await fetch(url, {
129
- method: 'POST',
130
- headers: { 'Content-Type': 'application/json' },
131
- body: JSON.stringify({
132
- jsonrpc: '2.0',
133
- method: 'call',
134
- id: 2,
135
- params: {
136
- service: 'object',
137
- method: 'execute_kw',
138
- args: [
139
- process.env.TRIDENT_DB,
140
- parseInt(process.env.TRIDENT_UID, 10),
141
- process.env.TRIDENT_TOKEN,
142
- 'project.task',
143
- 'write',
144
- [[parseInt(taskId, 10)], { x_release_checklist: updated }],
145
- ],
146
- },
147
- }),
148
- });
149
- const data = await res.json();
150
- if (data.error) throw new Error(`Trident write error: ${JSON.stringify(data.error)}`);
97
+ await tridentRpc('project.task', 'write', [
98
+ [parseInt(taskId, 10)],
99
+ { x_release_checklist: updated },
100
+ ]);
151
101
  }
152
102
 
153
103
  function scoreSections(sections, candidates) {
@@ -0,0 +1,93 @@
1
+ import fs from 'fs/promises';
2
+ import path from 'path';
3
+ import { htmlToMarkdown } from '../lib/html.js';
4
+ import { log, warn } from '../lib/logger.js';
5
+ import { tridentRpc } from '../lib/trident.js';
6
+
7
+ async function fetchTaskDoc(taskId) {
8
+ const id = parseInt(taskId, 10);
9
+ const [task] = await tridentRpc('project.task', 'read', [[id]], {
10
+ fields: ['name', 'description'],
11
+ });
12
+ if (!task) throw new Error(`Task ${taskId} not found`);
13
+
14
+ const attachments = await tridentRpc(
15
+ 'ir.attachment',
16
+ 'search_read',
17
+ [
18
+ [
19
+ ['res_model', '=', 'project.task'],
20
+ ['res_id', '=', id],
21
+ ],
22
+ ],
23
+ { fields: ['name', 'datas', 'mimetype'] }
24
+ );
25
+
26
+ return { task, attachments };
27
+ }
28
+
29
+ // Strips path separators from an Odoo attachment name and de-dupes against
30
+ // names already written into the same folder.
31
+ function safeFilename(name, usedNames) {
32
+ const cleaned = (name || 'attachment').replace(/[/\\]/g, '_').trim() || 'attachment';
33
+ if (!usedNames.has(cleaned)) {
34
+ usedNames.add(cleaned);
35
+ return cleaned;
36
+ }
37
+ const ext = path.extname(cleaned);
38
+ const base = cleaned.slice(0, cleaned.length - ext.length);
39
+ let i = 2;
40
+ let candidate = `${base} (${i})${ext}`;
41
+ while (usedNames.has(candidate)) {
42
+ i += 1;
43
+ candidate = `${base} (${i})${ext}`;
44
+ }
45
+ usedNames.add(candidate);
46
+ return candidate;
47
+ }
48
+
49
+ async function writeTaskDoc(taskId, { task, attachments }) {
50
+ const dirName = `trident_${taskId}`;
51
+ const dir = path.join(process.cwd(), dirName);
52
+ await fs.mkdir(dir, { recursive: true });
53
+
54
+ const usedNames = new Set();
55
+ const writtenFiles = [];
56
+ for (const att of attachments) {
57
+ if (!att.datas) {
58
+ warn(`Warning: skipping attachment "${att.name}" (no downloadable content)`);
59
+ continue;
60
+ }
61
+ const filename = safeFilename(att.name, usedNames);
62
+ await fs.writeFile(path.join(dir, filename), Buffer.from(att.datas, 'base64'));
63
+ writtenFiles.push(filename);
64
+ }
65
+
66
+ const taskUrl = `${process.env.TRIDENT_URL}/odoo/my-tasks/${taskId}`;
67
+ const lines = [`# ${task.name}`, '', taskUrl, ''];
68
+ const body = htmlToMarkdown(task.description);
69
+ if (body) lines.push(body, '');
70
+
71
+ lines.push('## Attachments');
72
+ if (writtenFiles.length === 0) {
73
+ lines.push('', '_None_');
74
+ } else {
75
+ lines.push('', ...writtenFiles.map((f) => `- [${f}](./${f})`));
76
+ }
77
+
78
+ await fs.writeFile(path.join(dir, `${dirName}.md`), lines.join('\n') + '\n');
79
+ return dir;
80
+ }
81
+
82
+ async function doc(opts) {
83
+ const ids = opts.trident ?? [];
84
+ if (ids.length === 0) throw new Error('No Trident id: provide -t <id>');
85
+
86
+ for (const id of ids) {
87
+ const data = await fetchTaskDoc(id);
88
+ const dir = await writeTaskDoc(id, data);
89
+ log(`Wrote ${dir}`);
90
+ }
91
+ }
92
+
93
+ export { doc };
package/src/index.js CHANGED
@@ -5,6 +5,7 @@ import { configDotenv } from 'dotenv';
5
5
  import { autopr } from './commands/autopr.js';
6
6
  import { changelog } from './commands/changelog.js';
7
7
  import { commit } from './commands/commit.js';
8
+ import { doc } from './commands/doc.js';
8
9
  import {
9
10
  exportEmailTemplates,
10
11
  exportImperex,
@@ -143,6 +144,13 @@ program.command('commit').action(async (opts) => {
143
144
  await commit(opts);
144
145
  });
145
146
 
147
+ program
148
+ .command('doc')
149
+ .option('-t, --trident <id>', 'Trident task IDs (repeatable)', collect)
150
+ .action(async (opts) => {
151
+ await doc(opts);
152
+ });
153
+
146
154
  const exportCmd = program.command('export');
147
155
 
148
156
  withQuiet(
@@ -0,0 +1,117 @@
1
+ // Small, dependency-free HTML→markdown converter. Not a general-purpose HTML
2
+ // parser — it's sized to the markup Odoo actually produces for `project.task`
3
+ // description fields (paragraphs, line breaks, bold/italic, links, lists,
4
+ // inline images), which is enough to render usefully in a .md file. Anything
5
+ // outside that (tables, nested lists, styled spans, ...) is stripped down to
6
+ // its plain text rather than mis-rendered.
7
+
8
+ function decodeEntities(str) {
9
+ return str
10
+ .replace(/&nbsp;/gi, ' ')
11
+ .replace(/&amp;/gi, '&')
12
+ .replace(/&lt;/gi, '<')
13
+ .replace(/&gt;/gi, '>')
14
+ .replace(/&quot;/gi, '"')
15
+ .replace(/&#39;/g, "'")
16
+ .replace(/&apos;/gi, "'");
17
+ }
18
+
19
+ function htmlToMarkdown(html) {
20
+ if (!html) return '';
21
+
22
+ let out = '';
23
+ let lastIndex = 0;
24
+ let match;
25
+ const listStack = []; // { type: 'ul' | 'ol', index: number }
26
+ const linkStack = []; // pending href for the currently-open <a>
27
+
28
+ // Raw text between tags: collapse the source's own whitespace/indentation
29
+ // into single spaces. Paragraph/line breaks are produced by the tag
30
+ // handling below, not by whitespace in the source.
31
+ function appendText(text) {
32
+ const cleaned = text.replace(/\s+/g, ' ');
33
+ if (cleaned.trim() === '') return;
34
+ out += cleaned;
35
+ }
36
+
37
+ const tagRe = /<(\/?)([a-zA-Z0-9]+)([^>]*)>/g;
38
+ while ((match = tagRe.exec(html)) !== null) {
39
+ const [, closing, rawTag, attrs] = match;
40
+ const tag = rawTag.toLowerCase();
41
+ appendText(html.slice(lastIndex, match.index));
42
+ lastIndex = tagRe.lastIndex;
43
+ const isClosing = closing === '/';
44
+
45
+ switch (tag) {
46
+ case 'br':
47
+ out += '\n';
48
+ break;
49
+ case 'p':
50
+ case 'div':
51
+ // Odoo commonly wraps list-item text in a <p> (<li><p>...</p></li>);
52
+ // inside a list, let <li> alone own the line breaks.
53
+ if (listStack.length === 0) out += '\n\n';
54
+ break;
55
+ case 'h1':
56
+ case 'h2':
57
+ case 'h3':
58
+ case 'h4':
59
+ case 'h5':
60
+ case 'h6':
61
+ out += isClosing ? '\n\n' : `\n\n${'#'.repeat(Number(tag[1]))} `;
62
+ break;
63
+ case 'strong':
64
+ case 'b':
65
+ out += '**';
66
+ break;
67
+ case 'em':
68
+ case 'i':
69
+ out += '_';
70
+ break;
71
+ case 'a':
72
+ if (!isClosing) {
73
+ const hrefMatch = attrs.match(/href=["']([^"']*)["']/i);
74
+ linkStack.push(hrefMatch ? hrefMatch[1] : '');
75
+ out += '[';
76
+ } else {
77
+ const href = linkStack.pop() ?? '';
78
+ out += `](${href})`;
79
+ }
80
+ break;
81
+ case 'img': {
82
+ const srcMatch = attrs.match(/src=["']([^"']*)["']/i);
83
+ out += `![](${srcMatch ? srcMatch[1] : ''})`;
84
+ break;
85
+ }
86
+ case 'ul':
87
+ case 'ol':
88
+ if (!isClosing) listStack.push({ type: tag, index: 0 });
89
+ else listStack.pop();
90
+ out += '\n';
91
+ break;
92
+ case 'li':
93
+ if (!isClosing) {
94
+ const ctx = listStack[listStack.length - 1];
95
+ if (ctx && ctx.type === 'ol') {
96
+ ctx.index += 1;
97
+ out += `\n${ctx.index}. `;
98
+ } else {
99
+ out += '\n- ';
100
+ }
101
+ }
102
+ break;
103
+ default:
104
+ // Unknown/unsupported tag (span, table, etc.): drop the tag,
105
+ // keep its text content via the surrounding appendText calls.
106
+ break;
107
+ }
108
+ }
109
+ appendText(html.slice(lastIndex));
110
+
111
+ return decodeEntities(out)
112
+ .replace(/[ \t]+\n/g, '\n')
113
+ .replace(/\n{3,}/g, '\n\n')
114
+ .trim();
115
+ }
116
+
117
+ export { htmlToMarkdown };
@@ -0,0 +1,34 @@
1
+ import fetch from 'node-fetch';
2
+
3
+ // Generic Odoo execute_kw call against Trident. Throws on transport or Odoo error.
4
+ async function tridentRpc(model, method, args, kwargs = {}) {
5
+ const url = `${process.env.TRIDENT_URL}/jsonrpc`;
6
+ const body = {
7
+ jsonrpc: '2.0',
8
+ method: 'call',
9
+ id: 1,
10
+ params: {
11
+ service: 'object',
12
+ method: 'execute_kw',
13
+ args: [
14
+ process.env.TRIDENT_DB,
15
+ parseInt(process.env.TRIDENT_UID, 10),
16
+ process.env.TRIDENT_TOKEN,
17
+ model,
18
+ method,
19
+ args,
20
+ kwargs,
21
+ ],
22
+ },
23
+ };
24
+ const res = await fetch(url, {
25
+ method: 'POST',
26
+ headers: { 'Content-Type': 'application/json' },
27
+ body: JSON.stringify(body),
28
+ });
29
+ const data = await res.json();
30
+ if (data.error) throw new Error(`Trident error: ${JSON.stringify(data.error)}`);
31
+ return data.result;
32
+ }
33
+
34
+ export { tridentRpc };