@waron97/prbot 3.5.0 → 3.7.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/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@waron97/prbot",
3
- "version": "3.5.0",
3
+ "version": "3.7.0",
4
4
  "description": "",
5
5
  "main": "index.js",
6
6
  "engines": {
@@ -1,5 +1,6 @@
1
1
  import { readFileSync } from 'fs';
2
2
  import fs from 'fs/promises';
3
+ import path from 'path';
3
4
  import search from '@inquirer/search';
4
5
  import inquirer from 'inquirer';
5
6
  import fetch from 'node-fetch';
@@ -7,6 +8,7 @@ import { resolveAddonsPath } from '../lib/addons.js';
7
8
  import { fuzzyMatch } from '../lib/fuzzy.js';
8
9
  import { execGit } from '../lib/git.js';
9
10
  import { log } from '../lib/logger.js';
11
+ import { tridentRpc } from '../lib/trident.js';
10
12
  import {
11
13
  appendPrToLine,
12
14
  appendRefsToLine,
@@ -27,42 +29,11 @@ function devopsHeaders() {
27
29
  }
28
30
 
29
31
  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),
32
+ const result = await tridentRpc('project.task', 'read', [[parseInt(taskId, 10)]], {
33
+ fields: ['name', 'x_subpackage_id', 'x_workflow', 'x_cluster_id', 'x_release_checklist'],
61
34
  });
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];
35
+ if (!result || !result[0]) throw new Error(`Task ${taskId} not found`);
36
+ return result[0];
66
37
  }
67
38
 
68
39
  function buildPrDescription(taskIds, jiras) {
@@ -124,30 +95,10 @@ async function createDevopsPR(branch, title, description) {
124
95
  async function appendChecklistPrLink(taskId, currentChecklist, prUrl) {
125
96
  const link = `<a href="${prUrl}">${prUrl}</a><br/>`;
126
97
  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)}`);
98
+ await tridentRpc('project.task', 'write', [
99
+ [parseInt(taskId, 10)],
100
+ { x_release_checklist: updated },
101
+ ]);
151
102
  }
152
103
 
153
104
  function scoreSections(sections, candidates) {
@@ -268,6 +219,11 @@ async function autoprAmend(options) {
268
219
  }
269
220
 
270
221
  async function autopr(options) {
222
+ if (options.worktree && options.amend) {
223
+ throw new Error(
224
+ '--worktree cannot be combined with --amend (amend operates on the already-checked-out branch)'
225
+ );
226
+ }
271
227
  if (options.amend) return autoprAmend(options);
272
228
 
273
229
  const ADDONS_PATH = resolveAddonsPath(process.env.ADDONS_PATH);
@@ -313,9 +269,20 @@ async function autopr(options) {
313
269
  }
314
270
  }
315
271
 
316
- await execGit(['checkout', '-b', branch], ADDONS_PATH);
317
- log(`Branch created: ${branch}`);
318
- await execGit(['push', '-u', 'origin', branch], ADDONS_PATH);
272
+ let repoRoot = ADDONS_PATH;
273
+ if (options.worktree) {
274
+ const worktreePath =
275
+ typeof options.worktree === 'string'
276
+ ? path.resolve(options.worktree)
277
+ : path.join(path.dirname(ADDONS_PATH), `${path.basename(ADDONS_PATH)}-${branch}`);
278
+ await execGit(['worktree', 'add', worktreePath, '-b', branch], ADDONS_PATH);
279
+ log(`Worktree created: ${worktreePath}`);
280
+ repoRoot = worktreePath;
281
+ } else {
282
+ await execGit(['checkout', '-b', branch], ADDONS_PATH);
283
+ log(`Branch created: ${branch}`);
284
+ }
285
+ await execGit(['push', '-u', 'origin', branch], repoRoot);
319
286
 
320
287
  const prTitle = options.name ?? tasks[0]?.name ?? branch;
321
288
  const prDescription = buildPrDescription(ids, options.jira ?? []);
@@ -363,13 +330,17 @@ async function autopr(options) {
363
330
  lines.splice(endLine + 1, 0, newEntry);
364
331
  }
365
332
 
366
- await fs.writeFile(changelogPath, lines.join('\n'));
333
+ await fs.writeFile(`${repoRoot}/CHANGELOG.md`, lines.join('\n'));
367
334
  log('Changelog entry written');
368
335
 
369
- await execGit(['add', 'CHANGELOG.md'], ADDONS_PATH);
370
- await execGit(['commit', '-m', '[DOC][CHANGELOG] Changelog'], ADDONS_PATH);
371
- await execGit(['push'], ADDONS_PATH);
336
+ await execGit(['add', 'CHANGELOG.md'], repoRoot);
337
+ await execGit(['commit', '-m', '[DOC][CHANGELOG] Changelog'], repoRoot);
338
+ await execGit(['push'], repoRoot);
372
339
  log('Changelog committed and pushed');
340
+
341
+ if (repoRoot !== ADDONS_PATH) {
342
+ log(`\nContinue working in: ${repoRoot}`);
343
+ }
373
344
  }
374
345
 
375
346
  export { autopr };
@@ -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 };
@@ -73,6 +73,7 @@ function generateXml(templates) {
73
73
  <field name="subject">${escapeXml(t.subject)}</field>
74
74
  <field name="email_from">${escapeXml(t.email_from)}</field>
75
75
  <field name="email_to">${escapeXml(t.email_to)}</field>
76
+ <field name="lang">${escapeXml(t.lang)}</field>
76
77
  <field name="body_html" type="html">
77
78
  ${sanitizeBodyHtml(t.body_html)}
78
79
  </field>
@@ -23,7 +23,8 @@ async function init() {
23
23
  {
24
24
  type: 'input',
25
25
  name: 'ADDONS_PATH',
26
- message: 'Addons path:',
26
+ message:
27
+ "Addons path ('.' or blank = use the repo in the current directory, e.g. a worktree):",
27
28
  default: existing.ADDONS_PATH ?? '~/codebase/sorgenia/addons',
28
29
  },
29
30
  {
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,
@@ -135,6 +136,10 @@ program
135
136
  .option('-b, --branch <name>', 'Branch name (default: autopr_<taskId>)')
136
137
  .option('-n, --name <text>', 'PR title (default: task name from Odoo)')
137
138
  .option('--amend', 'Amend existing PR on current branch with new trident/jira refs')
139
+ .option(
140
+ '--worktree [path]',
141
+ 'Create the branch in a new git worktree instead of switching the current checkout; optional path overrides the default sibling directory'
142
+ )
138
143
  .action(async (opts) => {
139
144
  await autopr(opts);
140
145
  });
@@ -143,6 +148,13 @@ program.command('commit').action(async (opts) => {
143
148
  await commit(opts);
144
149
  });
145
150
 
151
+ program
152
+ .command('doc')
153
+ .option('-t, --trident <id>', 'Trident task IDs (repeatable)', collect)
154
+ .action(async (opts) => {
155
+ await doc(opts);
156
+ });
157
+
146
158
  const exportCmd = program.command('export');
147
159
 
148
160
  withQuiet(
package/src/lib/addons.js CHANGED
@@ -1,8 +1,15 @@
1
- function resolveAddonsPath(addonsPath) {
2
- if (addonsPath.startsWith('~')) {
3
- return addonsPath.replace('~', process.env.HOME);
1
+ function resolveAddonsPath(addonsPath = process.env.ADDONS_PATH) {
2
+ const value = (addonsPath ?? '').trim();
3
+ // Unset, empty, or the explicit "." sentinel => operate on the repo in
4
+ // the current working directory (e.g. a git worktree), instead of a
5
+ // fixed global checkout.
6
+ if (value === '' || value === '.') {
7
+ return process.cwd();
4
8
  }
5
- return addonsPath;
9
+ if (value.startsWith('~')) {
10
+ return value.replace('~', process.env.HOME);
11
+ }
12
+ return value;
6
13
  }
7
14
 
8
15
  export { resolveAddonsPath };
@@ -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 };