@waron97/prbot 3.3.0 → 3.4.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.
@@ -18,7 +18,15 @@
18
18
  import slugify from 'slugify';
19
19
  import { Document, visit, parse as yamlParse, stringify as yamlStringify } from 'yaml';
20
20
  import { computeChecksum } from './checksum.js';
21
- import { buildProcess, compareDiagram, compareProcess, diff, parseProcess } from './pbModel.js';
21
+ import {
22
+ buildProcess,
23
+ compareDiagram,
24
+ compareProcess,
25
+ diagramGeometry,
26
+ diff,
27
+ normalizeProcessTree,
28
+ parseProcess,
29
+ } from './pbModel.js';
22
30
 
23
31
  const MANIFEST_FILE = '.agrippa-pb.json';
24
32
  const STRUCTURE_FILE = 'structure.yaml';
@@ -355,10 +363,69 @@ function stableStringify(value) {
355
363
  return JSON.stringify(value ?? null);
356
364
  }
357
365
 
366
+ // Canonical form of a recomposed payload used ONLY for change-detection
367
+ // checksums — never for the actual push payload (recompose() itself, and
368
+ // what gets sent on push, are untouched). Routes built_page through the same
369
+ // formatting/order-insensitive canonicalization already used by
370
+ // comparePayload()'s 0-loss round-trip verification:
371
+ // - normalizeProcessTree drops insignificant whitespace text nodes (incl.
372
+ // the extraDefs `#text` padding between <definitions>-root siblings) and
373
+ // sorts declarations by id, so cosmetic reformatting doesn't count;
374
+ // - diagramGeometry keeps only x/y/width/height/isExpanded/waypoints, so
375
+ // `format`-only artifacts like labelPos never enter the comparison.
376
+ // Without this, a push followed by an unmodified re-fetch could classify as
377
+ // a phantom `conflict` instead of `unchanged` — see
378
+ // ai_tasks/2026-07-16-lrp-clone/deferred_work.md items 1, 3, 4.
379
+ //
380
+ // `updated_date`/`modified_by` are server-managed bookkeeping, not process
381
+ // content — Odoo/Symple bump them on any touch of the record, including ones
382
+ // with no semantic effect (observed live: an integration service account
383
+ // re-saving the wizard moved `updated_date`/`modified_by` on both the
384
+ // top-level payload and a page wrapper with zero content diff otherwise, via
385
+ // comparePayload). They exist at two levels: the top-level payload scalars,
386
+ // and per-entry inside `payload.pages` (each page's audit wrapper, distinct
387
+ // from the page *content* under `.page`, which is left untouched). Left in,
388
+ // they permanently pin the object to `conflict` the moment anything server-
389
+ // side touches it, regardless of actual content — worse than the cosmetic
390
+ // built_page diffs above, since nothing local or an agrippa push causes them
391
+ // to resync.
392
+ const VOLATILE_AUDIT_FIELDS = ['updated_date', 'modified_by'];
393
+
394
+ function canonicalForChecksum(payload) {
395
+ const { process, decls } = normalizeProcessTree(payload.built_page);
396
+ const rest = omit(payload, ['built_page', 'pages', ...VOLATILE_AUDIT_FIELDS]);
397
+ // Page order is not a stable identity — locally it's whatever order the
398
+ // manifest happened to record at last decompose, while a live upstream
399
+ // fetch can return the same set of pages in a different order (observed
400
+ // live: a fresh fetch came back with pages 1-3 cyclically rotated versus
401
+ // the local manifest, same guids, zero content difference). Sort by the
402
+ // page's own guid — stable, unlike array position — before hashing, the
403
+ // same trick normalizeProcessTree already uses for extraDefs decls.
404
+ const pages = [...(payload.pages || [])]
405
+ .sort((a, b) => (a.guid ?? '').localeCompare(b.guid ?? ''))
406
+ .map((p) => omit(p, VOLATILE_AUDIT_FIELDS));
407
+ return {
408
+ ...rest,
409
+ pages,
410
+ built_page_process: process,
411
+ built_page_decls: decls,
412
+ built_page_diagram: diagramGeometry(payload.built_page),
413
+ };
414
+ }
415
+
416
+ // Checksum of a recomposed payload, canonicalized so cosmetic round-trip
417
+ // differences don't register as a change. Used for checksum_at_pull baselines
418
+ // (clone/pull) and for the push/pull classifier (localChecksum/
419
+ // remoteChecksumPb below) — keep every PB/LRP checksum call site on this one
420
+ // function so baselines and comparisons stay on the same normalization.
421
+ function checksumOfPayload(payload) {
422
+ return computeChecksum(stableStringify(canonicalForChecksum(payload)));
423
+ }
424
+
358
425
  // Checksum of the recomposed payload — stable across runs, changes only when the
359
426
  // local files change. clonePb stores this as checksum_at_pull; push recomputes it.
360
427
  function localChecksum(read) {
361
- return computeChecksum(stableStringify(recompose(read)));
428
+ return checksumOfPayload(recompose(read));
362
429
  }
363
430
 
364
431
  // Semantic checksum of a remote payload. Decompose → recompose normalises field
@@ -367,7 +434,7 @@ function localChecksum(read) {
367
434
  function remoteChecksumPb(payload) {
368
435
  const { files } = decompose(payload);
369
436
  const read = (p) => files[p] ?? '';
370
- return computeChecksum(stableStringify(recompose(read)));
437
+ return checksumOfPayload(recompose(read));
371
438
  }
372
439
 
373
440
  // Enumerate the wizard's pages from the pages/*.yml files (the authoritative
@@ -406,6 +473,7 @@ export {
406
473
  comparePayload,
407
474
  verifyRoundTrip,
408
475
  stableStringify,
476
+ checksumOfPayload,
409
477
  localChecksum,
410
478
  remoteChecksumPb,
411
479
  enumeratePages,
@@ -6,6 +6,7 @@ import fetch from 'node-fetch';
6
6
  import { resolveAddonsPath } from '../lib/addons.js';
7
7
  import { fuzzyMatch } from '../lib/fuzzy.js';
8
8
  import { execGit } from '../lib/git.js';
9
+ import { log } from '../lib/logger.js';
9
10
  import {
10
11
  appendPrToLine,
11
12
  appendRefsToLine,
@@ -120,7 +121,7 @@ async function createDevopsPR(branch, title, description) {
120
121
  return { id: data.pullRequestId, url: prUrl };
121
122
  }
122
123
 
123
- async function appendChecklistPrLink(taskId, currentChecklist, prUrl, prNumber) {
124
+ async function appendChecklistPrLink(taskId, currentChecklist, prUrl) {
124
125
  const link = `<a href="${prUrl}">${prUrl}</a><br/>`;
125
126
  const updated = currentChecklist ? `${currentChecklist}\n${link}` : link;
126
127
  const url = `${process.env.TRIDENT_URL}/jsonrpc`;
@@ -191,8 +192,8 @@ async function selectSection(sections, candidates) {
191
192
  const scored = candidates.length > 0 ? scoreSections(sections, candidates) : [];
192
193
 
193
194
  if (candidates.length > 0) {
194
- console.log(`\nTask fields: ${candidates.join(' | ')}`);
195
- if (scored.length === 0) console.log('No matching sections found.');
195
+ log(`\nTask fields: ${candidates.join(' | ')}`);
196
+ if (scored.length === 0) log('No matching sections found.');
196
197
  }
197
198
 
198
199
  const scoredHeadings = new Set(scored.map((s) => s.heading));
@@ -224,13 +225,13 @@ async function autoprAmend(options) {
224
225
  const pr = await fetchActivePr(branch);
225
226
  const prNumber = pr.pullRequestId;
226
227
  const prUrl = `https://dev.azure.com/${DEVOPS_ORG}/${DEVOPS_PROJECT}/_git/${DEVOPS_REPO}/pullrequest/${prNumber}`;
227
- console.log(`Found PR #${prNumber}: ${prUrl}`);
228
+ log(`Found PR #${prNumber}: ${prUrl}`);
228
229
 
229
230
  const ids = options.trident ?? [];
230
231
  const jiras = options.jira ?? [];
231
232
 
232
233
  const tasks = ids.length > 0 ? await Promise.all(ids.map(fetchTask)) : [];
233
- tasks.forEach((t) => console.log(`Task: ${t.name}`));
234
+ tasks.forEach((t) => log(`Task: ${t.name}`));
234
235
 
235
236
  const newLinks = [
236
237
  ...ids.map((id) => `${process.env.TRIDENT_URL}/odoo/my-tasks/${id}`),
@@ -241,20 +242,18 @@ async function autoprAmend(options) {
241
242
  ? `${pr.description}\n${newLinks.join('\n')}`
242
243
  : newLinks.join('\n');
243
244
  await patchDevopsPrDescription(prNumber, updatedDescription);
244
- console.log('PR description updated');
245
+ log('PR description updated');
245
246
  }
246
247
 
247
248
  for (let i = 0; i < ids.length; i++) {
248
- await appendChecklistPrLink(ids[i], tasks[i].x_release_checklist, prUrl, prNumber);
249
+ await appendChecklistPrLink(ids[i], tasks[i].x_release_checklist, prUrl);
249
250
  }
250
- if (ids.length > 0) console.log('Checklist updated');
251
+ if (ids.length > 0) log('Checklist updated');
251
252
 
252
253
  const content = readFileSync(changelogPath, 'utf-8');
253
254
  const existing = findLineByPrNumber(content, prNumber);
254
255
  if (!existing) {
255
- console.log(
256
- `Warning: no changelog line found for PR #${prNumber} — skipping changelog update`
257
- );
256
+ log(`Warning: no changelog line found for PR #${prNumber} — skipping changelog update`);
258
257
  return;
259
258
  }
260
259
  const lines = content.split('\n');
@@ -264,8 +263,8 @@ async function autoprAmend(options) {
264
263
  await execGit(['add', 'CHANGELOG.md'], ADDONS_PATH);
265
264
  await execGit(['commit', '-m', '[DOC][CHANGELOG] Changelog'], ADDONS_PATH);
266
265
  await execGit(['push'], ADDONS_PATH);
267
- console.log('Changelog updated and pushed');
268
- console.log('\nReminder: squash the two changelog commits before merging the PR.');
266
+ log('Changelog updated and pushed');
267
+ log('\nReminder: squash the two changelog commits before merging the PR.');
269
268
  }
270
269
 
271
270
  async function autopr(options) {
@@ -278,7 +277,7 @@ async function autopr(options) {
278
277
  const hasTridents = ids.length > 0;
279
278
 
280
279
  const tasks = hasTridents ? await Promise.all(ids.map((id) => fetchTask(id))) : [];
281
- tasks.forEach((t) => console.log(`Task: ${t.name}`));
280
+ tasks.forEach((t) => log(`Task: ${t.name}`));
282
281
 
283
282
  const content = readFileSync(changelogPath, 'utf-8');
284
283
 
@@ -286,7 +285,7 @@ async function autopr(options) {
286
285
 
287
286
  let appendMode = false;
288
287
  if (duplicate) {
289
- console.log(`\nExisting entry (line ${duplicate.lineNumber + 1}):\n ${duplicate.line}`);
288
+ log(`\nExisting entry (line ${duplicate.lineNumber + 1}):\n ${duplicate.line}`);
290
289
  const { confirm } = await inquirer.prompt([
291
290
  {
292
291
  type: 'confirm',
@@ -315,19 +314,19 @@ async function autopr(options) {
315
314
  }
316
315
 
317
316
  await execGit(['checkout', '-b', branch], ADDONS_PATH);
318
- console.log(`Branch created: ${branch}`);
317
+ log(`Branch created: ${branch}`);
319
318
  await execGit(['push', '-u', 'origin', branch], ADDONS_PATH);
320
319
 
321
320
  const prTitle = options.name ?? tasks[0]?.name ?? branch;
322
321
  const prDescription = buildPrDescription(ids, options.jira ?? []);
323
322
  const { id: prNumber, url: prUrl } = await createDevopsPR(branch, prTitle, prDescription);
324
- console.log(`PR opened: #${prNumber} — ${prUrl}`);
323
+ log(`PR opened: #${prNumber} — ${prUrl}`);
325
324
 
326
325
  if (hasTridents) {
327
326
  for (let i = 0; i < ids.length; i++) {
328
- await appendChecklistPrLink(ids[i], tasks[i].x_release_checklist, prUrl, prNumber);
327
+ await appendChecklistPrLink(ids[i], tasks[i].x_release_checklist, prUrl);
329
328
  }
330
- console.log('Checklist updated');
329
+ log('Checklist updated');
331
330
  }
332
331
 
333
332
  const lines = content.split('\n');
@@ -365,12 +364,12 @@ async function autopr(options) {
365
364
  }
366
365
 
367
366
  await fs.writeFile(changelogPath, lines.join('\n'));
368
- console.log('Changelog entry written');
367
+ log('Changelog entry written');
369
368
 
370
369
  await execGit(['add', 'CHANGELOG.md'], ADDONS_PATH);
371
370
  await execGit(['commit', '-m', '[DOC][CHANGELOG] Changelog'], ADDONS_PATH);
372
371
  await execGit(['push'], ADDONS_PATH);
373
- console.log('Changelog committed and pushed');
372
+ log('Changelog committed and pushed');
374
373
  }
375
374
 
376
375
  export { autopr };
@@ -4,6 +4,7 @@ import search from '@inquirer/search';
4
4
  import inquirer from 'inquirer';
5
5
  import { resolveAddonsPath } from '../lib/addons.js';
6
6
  import { fuzzyMatch } from '../lib/fuzzy.js';
7
+ import { log } from '../lib/logger.js';
7
8
 
8
9
  function buildRefString(tridents, jiras, prNumber) {
9
10
  const refs = [];
@@ -116,7 +117,7 @@ async function changelog(prNumber, options) {
116
117
  const duplicate = findDuplicateLine(content, tridents, jiras);
117
118
  let appendMode = false;
118
119
  if (duplicate) {
119
- console.log(`\nExisting entry (line ${duplicate.lineNumber + 1}):\n ${duplicate.line}`);
120
+ log(`\nExisting entry (line ${duplicate.lineNumber + 1}):\n ${duplicate.line}`);
120
121
  const { confirm } = await inquirer.prompt([
121
122
  {
122
123
  type: 'confirm',
@@ -132,7 +133,7 @@ async function changelog(prNumber, options) {
132
133
  const lines = content.split('\n');
133
134
  lines[duplicate.lineNumber] = appendPrToLine(lines[duplicate.lineNumber], prNumber);
134
135
  await fs.writeFile(changelogPath, lines.join('\n'));
135
- console.log('Updated existing line');
136
+ log('Updated existing line');
136
137
  return;
137
138
  }
138
139
 
@@ -176,7 +177,7 @@ async function changelog(prNumber, options) {
176
177
  lines.splice(endLine + 1, 0, newEntry);
177
178
 
178
179
  await fs.writeFile(changelogPath, lines.join('\n'));
179
- console.log('Changelog entry added');
180
+ log('Changelog entry added');
180
181
  }
181
182
 
182
183
  function findLineByPrNumber(content, prNumber) {
@@ -3,6 +3,7 @@ import inquirer from 'inquirer';
3
3
  import searchList from 'inquirer-search-list';
4
4
  import { resolveAddonsPath } from '../lib/addons.js';
5
5
  import { execGit } from '../lib/git.js';
6
+ import { log } from '../lib/logger.js';
6
7
 
7
8
  inquirer.registerPrompt('search-list', searchList);
8
9
 
@@ -50,7 +51,7 @@ function validateSameModule(files) {
50
51
  const allSameModule = modules.every((module) => `[${module}]` === currentModule);
51
52
 
52
53
  if (!allSameModule) {
53
- console.log(chalk.red('Selected files are not of the same module'));
54
+ log(chalk.red('Selected files are not of the same module'));
54
55
  return null;
55
56
  }
56
57
 
@@ -59,8 +60,8 @@ function validateSameModule(files) {
59
60
 
60
61
  async function getFilesToCommit(stagedChanges, unstagedChanges) {
61
62
  if (stagedChanges.trim()) {
62
- console.log(chalk.green('Staged changes:'));
63
- console.log(stagedChanges);
63
+ log(chalk.green('Staged changes:'));
64
+ log(stagedChanges);
64
65
 
65
66
  return {
66
67
  filesToCheck: stagedChanges.trim().split('\n'),
@@ -71,9 +72,9 @@ async function getFilesToCommit(stagedChanges, unstagedChanges) {
71
72
  const unstagedFiles = unstagedChanges.trim().split('\n');
72
73
 
73
74
  while (true) {
74
- console.log(chalk.yellow('No staged changes found.'));
75
- console.log(chalk.yellow('Unstaged changes:'));
76
- console.log(unstagedChanges);
75
+ log(chalk.yellow('No staged changes found.'));
76
+ log(chalk.yellow('Unstaged changes:'));
77
+ log(unstagedChanges);
77
78
 
78
79
  const answers = await inquirer.prompt([
79
80
  {
@@ -104,7 +105,7 @@ async function commit() {
104
105
  const stagedChanges = await execGit(['diff', '--cached', '--name-only'], ADDONS_PATH);
105
106
 
106
107
  if (!unstagedChanges.trim() && !stagedChanges.trim()) {
107
- console.log(chalk.red('No changes found to commit.'));
108
+ log(chalk.red('No changes found to commit.'));
108
109
  return;
109
110
  }
110
111
 
@@ -115,7 +116,7 @@ async function commit() {
115
116
  } = await getFilesToCommit(stagedChanges, unstagedChanges);
116
117
 
117
118
  if (filesToCheck.length === 0) {
118
- console.log(chalk.red('No files selected.'));
119
+ log(chalk.red('No files selected.'));
119
120
  return;
120
121
  }
121
122
 
@@ -167,13 +168,13 @@ async function commit() {
167
168
  }
168
169
 
169
170
  await execGit(['commit', '-m', commitMessage], ADDONS_PATH);
170
- console.log(chalk.green('Commit created successfully!'));
171
+ log(chalk.green('Commit created successfully!'));
171
172
 
172
173
  const remainingUnstaged = await execGit(['diff', '--name-only'], ADDONS_PATH);
173
174
  const remainingStaged = await execGit(['diff', '--cached', '--name-only'], ADDONS_PATH);
174
175
 
175
176
  if (!remainingUnstaged.trim() && !remainingStaged.trim()) {
176
- console.log(chalk.green('No more changes to commit.'));
177
+ log(chalk.green('No more changes to commit.'));
177
178
  break;
178
179
  }
179
180
 
@@ -1,3 +1,4 @@
1
+ import { log } from '../lib/logger.js';
1
2
  import { exportEmailTemplates } from './exportEmailTemplates.js';
2
3
  import { exportImperex } from './exportImperex.js';
3
4
  import { exportLrp } from './exportLrp.js';
@@ -5,7 +6,7 @@ import { exportPb } from './exportPb.js';
5
6
  import { exportWorkflow } from './exportWorkflow.js';
6
7
 
7
8
  function exportRip() {
8
- console.log('Not implemented yet.');
9
+ log('Not implemented yet.');
9
10
  }
10
11
 
11
12
  export { exportPb, exportRip, exportImperex, exportEmailTemplates, exportWorkflow, exportLrp };
@@ -36,13 +36,30 @@ async function initiateExport(guid, token) {
36
36
  if (!response.ok) throw new Error(await response.text());
37
37
  }
38
38
 
39
+ // No job/request id is returned by initiateExport, so the poll still
40
+ // correlates by GUID + a corrected date window over the last few results —
41
+ // a heuristic, not a stable identifier (see REL-ADP-PB-001; a real fix
42
+ // depends on the ImportExport API exposing a request id, EXT-002). What we
43
+ // can and do own locally is not hanging forever: an overall deadline.
44
+ const PB_EXPORT_POLL_INTERVAL_MS = 3_000;
45
+ const PB_EXPORT_POLL_TIMEOUT_MS = 120_000;
46
+
39
47
  async function pollExportResult(guid, requestTime, token) {
40
48
  const url = `${process.env.IMPORTEXPORT_URL}/export/info/processKey=ExportElement&subProcess=true&status=FAILED,COMPLETED&referenceId=process_builder`;
41
49
  // Server createDate is offset -1hr from system time; subtract 1hr+5s buffer
42
50
  const cutoff = requestTime - 3_605_000;
51
+ const deadline = requestTime + PB_EXPORT_POLL_TIMEOUT_MS;
43
52
 
44
53
  while (true) {
45
- await new Promise((r) => setTimeout(r, 3000));
54
+ if (Date.now() > deadline) {
55
+ throw new Error(
56
+ `REMOTE_TIMEOUT: Process Builder export for guid ${guid} did not complete within ${
57
+ PB_EXPORT_POLL_TIMEOUT_MS / 1000
58
+ }s`
59
+ );
60
+ }
61
+
62
+ await new Promise((r) => setTimeout(r, PB_EXPORT_POLL_INTERVAL_MS));
46
63
 
47
64
  const response = await fetch(url, {
48
65
  method: 'POST',
@@ -153,4 +170,4 @@ async function exportPb(opts) {
153
170
  }
154
171
  }
155
172
 
156
- export { exportPb };
173
+ export { exportPb, pollExportResult };
@@ -1,6 +1,7 @@
1
1
  import { existsSync, mkdirSync, readFileSync, writeFileSync } from 'fs';
2
2
  import inquirer from 'inquirer';
3
3
  import { CONFIG_DIR, CONFIG_FILE } from '../config.js';
4
+ import { log } from '../lib/logger.js';
4
5
 
5
6
  async function init() {
6
7
  if (!existsSync(CONFIG_DIR)) {
@@ -142,7 +143,7 @@ async function init() {
142
143
  .map(([k, v]) => `${k}=${v}`)
143
144
  .join('\n') + '\n'
144
145
  );
145
- console.log(`Config written to ${CONFIG_FILE}`);
146
+ log(`Config written to ${CONFIG_FILE}`);
146
147
  }
147
148
 
148
149
  export { init };
@@ -3,7 +3,7 @@ import path from 'path';
3
3
  import { select } from '@inquirer/prompts';
4
4
  import { parse } from 'yaml';
5
5
  import { CONFIG_DIR } from '../config.js';
6
- import { setSilent } from '../lib/logger.js';
6
+ import { log, setSilent } from '../lib/logger.js';
7
7
  import { exportEmailTemplates } from './exportEmailTemplates.js';
8
8
  import { exportImperex } from './exportImperex.js';
9
9
  import { exportLrp } from './exportLrp.js';
@@ -52,39 +52,50 @@ function isNothingToCommit(err) {
52
52
  }
53
53
 
54
54
  async function runRoutine(routine) {
55
- console.log(`Running Routine: ${routine.name}`);
55
+ log(`Running Routine: ${routine.name}`);
56
+
57
+ const failures = [];
56
58
 
57
59
  for (const step of routine.steps) {
58
60
  const label = `[${step.name}]`;
59
61
  const fn = COMMAND_MAP[step.command];
60
62
  if (!fn) {
61
- console.log(`${label} Unknown command: ${step.command}`);
63
+ log(`${label} Unknown command: ${step.command}`);
64
+ failures.push(`${step.name}: unknown command "${step.command}"`);
62
65
  continue;
63
66
  }
64
67
 
65
- console.log(`${label} Job started`);
68
+ log(`${label} Job started`);
66
69
  setSilent(true);
67
70
 
68
71
  try {
69
72
  await fn(stepOpts(step));
70
- console.log(`${label} Done (committed)`);
73
+ log(`${label} Done (committed)`);
71
74
  } catch (err) {
72
75
  if (isNothingToCommit(err)) {
73
- console.log(`${label} Done (nothing to commit)`);
76
+ log(`${label} Done (nothing to commit)`);
74
77
  } else {
75
- console.log(`${label} Failed: ${err.message}`);
78
+ log(`${label} Failed: ${err.message}`);
79
+ failures.push(`${step.name}: ${err.message}`);
76
80
  }
77
81
  } finally {
78
82
  setSilent(false);
79
83
  }
80
84
  }
85
+
86
+ if (failures.length) {
87
+ throw new Error(
88
+ `Routine "${routine.name}" completed with ${failures.length} failed step(s):\n` +
89
+ failures.map((f) => ` - ${f}`).join('\n')
90
+ );
91
+ }
81
92
  }
82
93
 
83
94
  async function routine() {
84
95
  const routines = loadRoutines();
85
96
 
86
97
  if (!routines.length) {
87
- console.log(
98
+ log(
88
99
  'No routines defined. Create ~/.config/prbot/routines.yaml or add routines: to agrippa.yaml.'
89
100
  );
90
101
  return;