@waron97/prbot 3.5.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/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@waron97/prbot",
3
- "version": "3.5.0",
3
+ "version": "3.6.0",
4
4
  "description": "",
5
5
  "main": "index.js",
6
6
  "engines": {
@@ -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 };