@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 +1 -1
- package/src/commands/autopr.js +9 -59
- package/src/commands/doc.js +93 -0
- package/src/index.js +8 -0
- package/src/lib/html.js +117 -0
- package/src/lib/trident.js +34 -0
package/package.json
CHANGED
package/src/commands/autopr.js
CHANGED
|
@@ -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
|
|
31
|
-
|
|
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
|
-
|
|
63
|
-
|
|
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
|
-
|
|
128
|
-
|
|
129
|
-
|
|
130
|
-
|
|
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(
|
package/src/lib/html.js
ADDED
|
@@ -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(/ /gi, ' ')
|
|
11
|
+
.replace(/&/gi, '&')
|
|
12
|
+
.replace(/</gi, '<')
|
|
13
|
+
.replace(/>/gi, '>')
|
|
14
|
+
.replace(/"/gi, '"')
|
|
15
|
+
.replace(/'/g, "'")
|
|
16
|
+
.replace(/'/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 += ``;
|
|
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 };
|