@waron97/prbot 1.1.1 → 2.0.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/CLAUDE.md ADDED
File without changes
package/README.md CHANGED
@@ -1,6 +1,6 @@
1
1
  # prbot
2
2
 
3
- CLI tool for fetching Odoo workflow XML files from the RIP API, writing them into the addons repo, and committing the result.
3
+ CLI tool for managing PRs, changelogs, and Odoo workflow XML files in the addons repo.
4
4
 
5
5
  ## Install
6
6
 
@@ -33,6 +33,15 @@ source ~/.bashrc
33
33
  | `KC_ID` | Keycloak client ID |
34
34
  | `KC_SECRET` | Keycloak client secret |
35
35
  | `RIP_URL` | RIP API base URL |
36
+ | `TRIDENT_URL` | Trident (Odoo) instance URL |
37
+ | `TRIDENT_UID` | Trident user ID |
38
+ | `TRIDENT_TOKEN` | Trident API token |
39
+ | `TRIDENT_DB` | Trident database name |
40
+ | `DEVOPS_TOKEN` | Azure DevOps personal access token |
41
+ | `DEVOPS_ORG` | Azure DevOps organization |
42
+ | `DEVOPS_PROJECT` | Azure DevOps project |
43
+ | `DEVOPS_REPO` | Azure DevOps repository name |
44
+ | `AUTOPR_TARGET_BRANCH` | Target branch for auto-created PRs (default: `15.0-dev`) |
36
45
 
37
46
  ## Commands
38
47
 
@@ -62,6 +71,50 @@ Bumps the version in `__manifest__.py` for `<module>` and commits.
62
71
  prbot ver config_wf_contestazione --bump patch
63
72
  ```
64
73
 
74
+ ### `prbot changelog <pr>`
75
+
76
+ Writes a changelog entry into `CHANGELOG.md` for a given PR number. Prompts to select the target section, detects existing indentation, and appends the entry with refs.
77
+
78
+ ```bash
79
+ prbot changelog 42 -m "Fix invoice state race condition" -t 1234 -t 5678 -j TESTML-1 -j TESTML-2
80
+ ```
81
+
82
+ Options:
83
+
84
+ | Flag | Description |
85
+ |------|-------------|
86
+ | `-m, --message <text>` | Changelog entry message (prompted if omitted) |
87
+ | `-t, --trident <code>` | Trident issue code (repeatable) |
88
+ | `-j, --jira <code>` | JIRA issue code (repeatable) |
89
+
90
+ ### `prbot autopr`
91
+
92
+ End-to-end PR automation: creates a branch, pushes it, opens a draft PR on Azure DevOps, appends the PR link to each Trident task's release checklist, writes a changelog entry, commits, and pushes.
93
+
94
+ ```bash
95
+ # Single Trident task
96
+ prbot autopr -t 1234
97
+
98
+ # Multiple Trident tasks (all get PR link; first with work package drives section matching)
99
+ prbot autopr -t 1234 -t 5678
100
+
101
+ # Multiple Trident + multiple JIRA
102
+ prbot autopr -t 1234 -t 5678 -j TESTML-1 -j TESTML-2
103
+
104
+ # JIRA only — skips all Trident fetch/write operations
105
+ prbot autopr -j JIRA-99 --branch my-branch
106
+ ```
107
+
108
+ Options:
109
+
110
+ | Flag | Description |
111
+ |------|-------------|
112
+ | `-t, --trident <id>` | Trident task ID (repeatable) |
113
+ | `-j, --jira <code>` | JIRA issue code (repeatable) |
114
+ | `-m, --message <text>` | Changelog entry message (prompted if omitted) |
115
+ | `-b, --branch <name>` | Branch name (default: `autopr_<first-task-id>` or `autopr_<first-jira>`) |
116
+ | `-n, --name <text>` | PR title (default: Trident task name) |
117
+
65
118
  ### `prbot init`
66
119
 
67
120
  Interactive setup: writes `~/.config/prbot/config` and installs shell completion.
package/package.json CHANGED
@@ -1,16 +1,16 @@
1
1
  {
2
2
  "name": "@waron97/prbot",
3
- "version": "1.1.1",
3
+ "version": "2.0.0",
4
4
  "description": "",
5
5
  "main": "index.js",
6
6
  "scripts": {
7
7
  "test": "echo \"Error: no test specified\" && exit 1"
8
8
  },
9
9
  "bin": {
10
- "prbot": "index.js"
10
+ "prbot": "src/index.js"
11
11
  },
12
12
  "dependencies": {
13
- "@waron97/prbot": "^1.0.0",
13
+ "@inquirer/search": "^2.0.1",
14
14
  "commander": "^14.0.2",
15
15
  "dotenv": "^17.2.3",
16
16
  "eslint-plugin-es5": "^1.5.0",
@@ -0,0 +1,309 @@
1
+ import fs from "fs/promises";
2
+ import { readFileSync } from "fs";
3
+ import fetch from "node-fetch";
4
+ import inquirer from "inquirer";
5
+ import search from "@inquirer/search";
6
+ import { resolveAddonsPath } from "../lib/addons.js";
7
+ import { execGit } from "../lib/git.js";
8
+ import {
9
+ extractSections,
10
+ findSectionEndLine,
11
+ detectIndentation,
12
+ buildRefString,
13
+ findDuplicateLine,
14
+ appendPrToLine,
15
+ } from "./changelog.js";
16
+
17
+ function devopsHeaders() {
18
+ const token = Buffer.from(`:${process.env.DEVOPS_TOKEN}`).toString("base64");
19
+ return {
20
+ Authorization: `Basic ${token}`,
21
+ "Content-Type": "application/json",
22
+ };
23
+ }
24
+
25
+ async function fetchTask(taskId) {
26
+ const url = `${process.env.TRIDENT_URL}/jsonrpc`;
27
+ const body = {
28
+ jsonrpc: "2.0",
29
+ method: "call",
30
+ id: 1,
31
+ params: {
32
+ service: "object",
33
+ method: "execute_kw",
34
+ args: [
35
+ process.env.TRIDENT_DB,
36
+ parseInt(process.env.TRIDENT_UID, 10),
37
+ process.env.TRIDENT_TOKEN,
38
+ "project.task",
39
+ "read",
40
+ [[parseInt(taskId, 10)]],
41
+ {
42
+ fields: [
43
+ "name",
44
+ "x_subpackage_id",
45
+ "x_workflow",
46
+ "x_release_checklist",
47
+ ],
48
+ },
49
+ ],
50
+ },
51
+ };
52
+ const res = await fetch(url, {
53
+ method: "POST",
54
+ headers: { "Content-Type": "application/json" },
55
+ body: JSON.stringify(body),
56
+ });
57
+ const data = await res.json();
58
+ if (data.error)
59
+ throw new Error(`Trident error: ${JSON.stringify(data.error)}`);
60
+ if (!data.result || !data.result[0])
61
+ throw new Error(`Task ${taskId} not found`);
62
+ return data.result[0];
63
+ }
64
+
65
+ function buildPrDescription(taskIds, jiras) {
66
+ const lines = taskIds.map(
67
+ (id) => `${process.env.TRIDENT_URL}/odoo/my-tasks/${id}`,
68
+ );
69
+ for (const jira of jiras ?? []) {
70
+ lines.push(`https://sorgenia.atlassian.net/browse/${jira}`);
71
+ }
72
+ return lines.join("\n");
73
+ }
74
+
75
+ async function createDevopsPR(branch, title, description) {
76
+ const { DEVOPS_ORG, DEVOPS_PROJECT, DEVOPS_REPO, AUTOPR_TARGET_BRANCH } =
77
+ process.env;
78
+ const apiUrl = `https://dev.azure.com/${DEVOPS_ORG}/${DEVOPS_PROJECT}/_apis/git/repositories/${DEVOPS_REPO}/pullrequests?api-version=7.0`;
79
+ const res = await fetch(apiUrl, {
80
+ method: "POST",
81
+ headers: devopsHeaders(),
82
+ body: JSON.stringify({
83
+ title,
84
+ description,
85
+ isDraft: true,
86
+ sourceRefName: `refs/heads/${branch}`,
87
+ targetRefName: `refs/heads/${AUTOPR_TARGET_BRANCH}`,
88
+ }),
89
+ });
90
+ const data = await res.json();
91
+ if (!res.ok)
92
+ throw new Error(`DevOps PR creation failed: ${JSON.stringify(data)}`);
93
+ const prUrl = `https://dev.azure.com/${DEVOPS_ORG}/${DEVOPS_PROJECT}/_git/${DEVOPS_REPO}/pullrequest/${data.pullRequestId}`;
94
+ return { id: data.pullRequestId, url: prUrl };
95
+ }
96
+
97
+ async function appendChecklistPrLink(taskId, currentChecklist, prUrl, prNumber) {
98
+ const link = `<a href="${prUrl}">${prUrl}</a><br/>`;
99
+ const updated = currentChecklist ? `${currentChecklist}\n${link}` : link;
100
+ const url = `${process.env.TRIDENT_URL}/jsonrpc`;
101
+ const res = await fetch(url, {
102
+ method: "POST",
103
+ headers: { "Content-Type": "application/json" },
104
+ body: JSON.stringify({
105
+ jsonrpc: "2.0",
106
+ method: "call",
107
+ id: 2,
108
+ params: {
109
+ service: "object",
110
+ method: "execute_kw",
111
+ args: [
112
+ process.env.TRIDENT_DB,
113
+ parseInt(process.env.TRIDENT_UID, 10),
114
+ process.env.TRIDENT_TOKEN,
115
+ "project.task",
116
+ "write",
117
+ [[parseInt(taskId, 10)], { x_release_checklist: updated }],
118
+ ],
119
+ },
120
+ }),
121
+ });
122
+ const data = await res.json();
123
+ if (data.error)
124
+ throw new Error(`Trident write error: ${JSON.stringify(data.error)}`);
125
+ }
126
+
127
+ function scoreSections(sections, candidates) {
128
+ const results = [];
129
+ for (const section of sections) {
130
+ const heading = section.heading.replace(/^### /, "").toLowerCase().trim();
131
+ let score = 0;
132
+ for (const candidate of candidates) {
133
+ const lc = candidate.toLowerCase().trim();
134
+ if (heading === lc) {
135
+ score += 100;
136
+ continue;
137
+ }
138
+ if (heading.includes(lc) || lc.includes(heading)) {
139
+ score += 50;
140
+ continue;
141
+ }
142
+ // Match by first pipe-segment (e.g. "CROSS_31.1 - ML")
143
+ const candidatePrefix = lc.split("|")[0].trim();
144
+ const headingPrefix = heading.split("|")[0].trim();
145
+ if (
146
+ headingPrefix.includes(candidatePrefix) ||
147
+ candidatePrefix.includes(headingPrefix)
148
+ ) {
149
+ score += 20;
150
+ continue;
151
+ }
152
+ // Token fallback
153
+ const tokens = lc
154
+ .split(/[\s|_\-.]+/)
155
+ .filter((t) => t.length > 2);
156
+ for (const token of tokens) {
157
+ if (heading.includes(token)) score++;
158
+ }
159
+ }
160
+ if (score > 0) results.push({ ...section, score });
161
+ }
162
+ return results.sort((a, b) => b.score - a.score).slice(0, 3);
163
+ }
164
+
165
+ async function selectSection(sections, candidates) {
166
+ const scored = candidates.length > 0 ? scoreSections(sections, candidates) : [];
167
+
168
+ if (candidates.length > 0) {
169
+ console.log(`\nTask fields: ${candidates.join(" | ")}`);
170
+ if (scored.length === 0) console.log("No matching sections found.");
171
+ }
172
+
173
+ const scoredHeadings = new Set(scored.map((s) => s.heading));
174
+ const topChoices = scored.map((s) => ({
175
+ name: `${s.heading.replace(/^### /, "")} ✓`,
176
+ value: s.heading,
177
+ }));
178
+ const restChoices = sections
179
+ .filter((s) => !scoredHeadings.has(s.heading))
180
+ .map((s) => ({ name: s.heading.replace(/^### /, ""), value: s.heading }));
181
+ const allChoices = [...topChoices, ...restChoices];
182
+
183
+ const selected = await search({
184
+ message: "Select changelog section:",
185
+ source: async (input) => {
186
+ if (!input) return allChoices;
187
+ return allChoices.filter((c) =>
188
+ c.value.toLowerCase().includes(input.toLowerCase()),
189
+ );
190
+ },
191
+ });
192
+ return sections.find((s) => s.heading === selected);
193
+ }
194
+
195
+ async function autopr(options) {
196
+ const ADDONS_PATH = resolveAddonsPath(process.env.ADDONS_PATH);
197
+ const changelogPath = `${ADDONS_PATH}/CHANGELOG.md`;
198
+
199
+ const ids = options.trident ?? [];
200
+ const hasTridents = ids.length > 0;
201
+
202
+ const tasks = hasTridents
203
+ ? await Promise.all(ids.map((id) => fetchTask(id)))
204
+ : [];
205
+ tasks.forEach((t) => console.log(`Task: ${t.name}`));
206
+
207
+ const content = readFileSync(changelogPath, "utf-8");
208
+
209
+ const duplicate = hasTridents
210
+ ? findDuplicateLine(content, ids, [])
211
+ : null;
212
+
213
+ let appendMode = false;
214
+ if (duplicate) {
215
+ console.log(`\nExisting entry (line ${duplicate.lineNumber + 1}):\n ${duplicate.line}`);
216
+ const { confirm } = await inquirer.prompt([
217
+ {
218
+ type: "confirm",
219
+ name: "confirm",
220
+ message: "Append PR ref to existing entry?",
221
+ default: true,
222
+ },
223
+ ]);
224
+ appendMode = confirm;
225
+ }
226
+
227
+ const branch =
228
+ options.branch ??
229
+ (ids[0]
230
+ ? `autopr_${ids[0]}`
231
+ : options.jira?.[0]
232
+ ? `autopr_${options.jira[0]}`
233
+ : null);
234
+ if (!branch)
235
+ throw new Error(
236
+ "No branch name: provide --branch, a task ID, or --jira",
237
+ );
238
+
239
+ let message = null;
240
+ if (!appendMode) {
241
+ message = options.message;
242
+ if (!message) {
243
+ const answer = await inquirer.prompt([
244
+ { type: "input", name: "message", message: "Changelog entry message:" },
245
+ ]);
246
+ message = answer.message;
247
+ }
248
+ }
249
+
250
+ await execGit(["checkout", "-b", branch], ADDONS_PATH);
251
+ console.log(`Branch created: ${branch}`);
252
+ await execGit(["push", "-u", "origin", branch], ADDONS_PATH);
253
+
254
+ const prTitle = options.name ?? (tasks[0]?.name ?? branch);
255
+ const prDescription = buildPrDescription(ids, options.jira ?? []);
256
+ const { id: prNumber, url: prUrl } = await createDevopsPR(branch, prTitle, prDescription);
257
+ console.log(`PR opened: #${prNumber} — ${prUrl}`);
258
+
259
+ if (hasTridents) {
260
+ for (let i = 0; i < ids.length; i++) {
261
+ await appendChecklistPrLink(ids[i], tasks[i].x_release_checklist, prUrl, prNumber);
262
+ }
263
+ console.log("Checklist updated");
264
+ }
265
+
266
+ const lines = content.split("\n");
267
+
268
+ if (appendMode) {
269
+ lines[duplicate.lineNumber] = appendPrToLine(lines[duplicate.lineNumber], prNumber);
270
+ } else {
271
+ const sections = extractSections(content);
272
+ const candidates = [];
273
+ if (hasTridents) {
274
+ const anchor = tasks.find(
275
+ (t) =>
276
+ (Array.isArray(t.x_subpackage_id) && t.x_subpackage_id[1]) ||
277
+ (Array.isArray(t.x_workflow) && t.x_workflow[1]),
278
+ );
279
+ if (anchor) {
280
+ if (Array.isArray(anchor.x_subpackage_id) && anchor.x_subpackage_id[1])
281
+ candidates.push(anchor.x_subpackage_id[1]);
282
+ if (Array.isArray(anchor.x_workflow) && anchor.x_workflow[1])
283
+ candidates.push(anchor.x_workflow[1]);
284
+ }
285
+ }
286
+
287
+ const selectedSection = await selectSection(sections, candidates);
288
+ const endLine = findSectionEndLine(lines, selectedSection.startLine);
289
+ const indent = detectIndentation(lines, selectedSection.startLine, endLine);
290
+
291
+ const jiras = options.jira ?? [];
292
+ const refString = buildRefString(ids, jiras, prNumber);
293
+ const newEntry = `${indent}- ${message}${refString ? " " + refString : ""}`;
294
+ lines.splice(endLine + 1, 0, newEntry);
295
+ }
296
+
297
+ await fs.writeFile(changelogPath, lines.join("\n"));
298
+ console.log("Changelog entry written");
299
+
300
+ await execGit(["add", "CHANGELOG.md"], ADDONS_PATH);
301
+ await execGit(
302
+ ["commit", "-m", "[DOC][CHANGELOG] Changelog"],
303
+ ADDONS_PATH,
304
+ );
305
+ await execGit(["push"], ADDONS_PATH);
306
+ console.log("Changelog committed and pushed");
307
+ }
308
+
309
+ export { autopr };
@@ -0,0 +1,189 @@
1
+ import fs from "fs/promises";
2
+ import { readFileSync } from "fs";
3
+ import inquirer from "inquirer";
4
+ import search from "@inquirer/search";
5
+ import { resolveAddonsPath } from "../lib/addons.js";
6
+
7
+ function buildRefString(tridents, jiras, prNumber) {
8
+ const refs = [];
9
+
10
+ if (tridents && tridents.length > 0) {
11
+ refs.push(`Trident ${tridents.map((t) => `#${t}`).join(", #")}`);
12
+ }
13
+
14
+ if (jiras && jiras.length > 0) {
15
+ refs.push(`JIRA ${jiras.join(", ")}`);
16
+ }
17
+
18
+ if (prNumber) {
19
+ refs.push(`PR sorgenia_addons #${prNumber}ADO`);
20
+ }
21
+
22
+ return refs.length > 0 ? `(${refs.join(", ")})` : "";
23
+ }
24
+
25
+ function appendPrToLine(line, prNumber) {
26
+ const parenMatch = line.match(/\(([^)]*)\)\s*$/);
27
+ if (parenMatch) {
28
+ const inner = parenMatch[1];
29
+ const suffix = inner.includes("PR sorgenia_addons")
30
+ ? `, #${prNumber}ADO`
31
+ : `, PR sorgenia_addons #${prNumber}ADO`;
32
+ return line.replace(/\(([^)]*)\)\s*$/, `(${inner}${suffix})`);
33
+ }
34
+ return `${line.trimEnd()} (PR sorgenia_addons #${prNumber}ADO)`;
35
+ }
36
+
37
+ function findDuplicateLine(content, tridents, jiras) {
38
+ const lines = content.split("\n");
39
+
40
+ for (let i = 0; i < lines.length; i++) {
41
+ const line = lines[i];
42
+
43
+ if (tridents && tridents.length > 0) {
44
+ for (const t of tridents) {
45
+ if (line.includes(`Trident #${t}`)) {
46
+ return { lineNumber: i, line };
47
+ }
48
+ }
49
+ }
50
+
51
+ if (jiras && jiras.length > 0) {
52
+ for (const j of jiras) {
53
+ if (line.includes(j)) {
54
+ return { lineNumber: i, line };
55
+ }
56
+ }
57
+ }
58
+ }
59
+
60
+ return null;
61
+ }
62
+
63
+ function extractSections(content) {
64
+ const lines = content.split("\n");
65
+ const sections = [];
66
+
67
+ for (let i = 0; i < lines.length; i++) {
68
+ if (lines[i].startsWith("### ")) {
69
+ sections.push({
70
+ heading: lines[i],
71
+ startLine: i,
72
+ });
73
+ }
74
+ }
75
+
76
+ return sections;
77
+ }
78
+
79
+ function findSectionEndLine(lines, sectionStartLine) {
80
+ const nextSectionLine = lines.findIndex(
81
+ (l, i) => i > sectionStartLine && l.startsWith("### "),
82
+ );
83
+
84
+ if (nextSectionLine === -1) {
85
+ return lines.length - 1;
86
+ }
87
+
88
+ let endLine = nextSectionLine - 1;
89
+ while (endLine > sectionStartLine && lines[endLine].trim() === "") {
90
+ endLine--;
91
+ }
92
+
93
+ return endLine;
94
+ }
95
+
96
+ function detectIndentation(lines, sectionStartLine, sectionEndLine) {
97
+ for (let i = sectionStartLine + 1; i <= sectionEndLine; i++) {
98
+ const line = lines[i];
99
+ const match = line.match(/^(\s*)-\s/);
100
+ if (match) {
101
+ return match[1];
102
+ }
103
+ }
104
+ return " ";
105
+ }
106
+
107
+ async function changelog(prNumber, options) {
108
+ const tridents = options.trident || [];
109
+ const jiras = options.jira || [];
110
+ let message = options.message;
111
+
112
+ let ADDONS_PATH = resolveAddonsPath(process.env.ADDONS_PATH);
113
+ const changelogPath = `${ADDONS_PATH}/CHANGELOG.md`;
114
+
115
+ const content = readFileSync(changelogPath, "utf-8");
116
+
117
+ const duplicate = findDuplicateLine(content, tridents, jiras);
118
+ let appendMode = false;
119
+ if (duplicate) {
120
+ console.log(`\nExisting entry (line ${duplicate.lineNumber + 1}):\n ${duplicate.line}`);
121
+ const { confirm } = await inquirer.prompt([
122
+ {
123
+ type: "confirm",
124
+ name: "confirm",
125
+ message: "Append PR ref to existing entry?",
126
+ default: true,
127
+ },
128
+ ]);
129
+ appendMode = confirm;
130
+ }
131
+
132
+ if (appendMode) {
133
+ const lines = content.split("\n");
134
+ lines[duplicate.lineNumber] = appendPrToLine(lines[duplicate.lineNumber], prNumber);
135
+ await fs.writeFile(changelogPath, lines.join("\n"));
136
+ console.log("Updated existing line");
137
+ return;
138
+ }
139
+
140
+ if (!message) {
141
+ const answer = await inquirer.prompt([
142
+ {
143
+ type: "input",
144
+ name: "message",
145
+ message: "Changelog entry message:",
146
+ },
147
+ ]);
148
+ message = answer.message;
149
+ }
150
+
151
+ const sections = extractSections(content);
152
+ const sectionChoices = sections.map((s) => ({
153
+ name: s.heading.replace(/^## /, ""),
154
+ value: s.heading,
155
+ }));
156
+
157
+ const selectedSection = await search({
158
+ message: "Select section to add entry:",
159
+ source: async (input) => {
160
+ if (!input) {
161
+ return sectionChoices;
162
+ }
163
+
164
+ const filtered = sectionChoices.filter((choice) =>
165
+ choice.name.toLowerCase().includes(input.toLowerCase()),
166
+ );
167
+
168
+ return filtered;
169
+ },
170
+ });
171
+
172
+ const selectedSectionObj = sections.find(
173
+ (s) => s.heading === selectedSection,
174
+ );
175
+
176
+ const lines = content.split("\n");
177
+ const endLine = findSectionEndLine(lines, selectedSectionObj.startLine);
178
+ const indent = detectIndentation(lines, selectedSectionObj.startLine, endLine);
179
+
180
+ const refString = buildRefString(tridents, jiras, prNumber);
181
+ const newEntry = `${indent}- ${message}${refString ? " " + refString : ""}`;
182
+
183
+ lines.splice(endLine + 1, 0, newEntry);
184
+
185
+ await fs.writeFile(changelogPath, lines.join("\n"));
186
+ console.log("Changelog entry added");
187
+ }
188
+
189
+ export { changelog, extractSections, findSectionEndLine, detectIndentation, buildRefString, findDuplicateLine, appendPrToLine };
@@ -0,0 +1,144 @@
1
+ import { readFileSync, existsSync, mkdirSync, writeFileSync, appendFileSync } from "fs";
2
+ import path from "path";
3
+ import inquirer from "inquirer";
4
+ import { CONFIG_DIR, CONFIG_FILE, COMPLETION_SCRIPT } from "../config.js";
5
+
6
+ async function init(completion) {
7
+ if (!existsSync(CONFIG_DIR)) {
8
+ mkdirSync(CONFIG_DIR, { recursive: true });
9
+ }
10
+
11
+ const existing = existsSync(CONFIG_FILE)
12
+ ? Object.fromEntries(
13
+ readFileSync(CONFIG_FILE, "utf-8")
14
+ .split("\n")
15
+ .flatMap((line) => {
16
+ const m = line.match(/^([A-Z_]+)=(.*)$/);
17
+ return m ? [[m[1], m[2]]] : [];
18
+ }),
19
+ )
20
+ : {};
21
+
22
+ const answers = await inquirer.prompt([
23
+ {
24
+ type: "input",
25
+ name: "ADDONS_PATH",
26
+ message: "Addons path:",
27
+ default: existing.ADDONS_PATH ?? "~/codebase/sorgenia/addons",
28
+ },
29
+ {
30
+ type: "input",
31
+ name: "KC_URL",
32
+ message: "Keycloak URL:",
33
+ default: existing.KC_URL ?? "",
34
+ },
35
+ {
36
+ type: "input",
37
+ name: "KC_USER",
38
+ message: "Keycloak user:",
39
+ default: existing.KC_USER ?? "",
40
+ },
41
+ {
42
+ type: "password",
43
+ name: "KC_PASSWORD",
44
+ message: "Keycloak password:",
45
+ default: existing.KC_PASSWORD ?? "",
46
+ mask: "*",
47
+ },
48
+ {
49
+ type: "input",
50
+ name: "KC_ID",
51
+ message: "Keycloak client ID:",
52
+ default: existing.KC_ID ?? "",
53
+ },
54
+ {
55
+ type: "input",
56
+ name: "KC_SECRET",
57
+ message: "Keycloak client secret:",
58
+ default: existing.KC_SECRET ?? "",
59
+ },
60
+ {
61
+ type: "input",
62
+ name: "RIP_URL",
63
+ message: "RIP URL:",
64
+ default: existing.RIP_URL ?? "",
65
+ },
66
+ {
67
+ type: "input",
68
+ name: "TRIDENT_URL",
69
+ message: "Trident URL:",
70
+ default: existing.TRIDENT_URL ?? "",
71
+ },
72
+ {
73
+ type: "input",
74
+ name: "TRIDENT_UID",
75
+ message: "Trident UID:",
76
+ default: existing.TRIDENT_UID ?? "",
77
+ },
78
+ {
79
+ type: "input",
80
+ name: "TRIDENT_TOKEN",
81
+ message: "Trident token:",
82
+ default: existing.TRIDENT_TOKEN ?? "",
83
+ },
84
+ {
85
+ type: "input",
86
+ name: "DEVOPS_TOKEN",
87
+ message: "DevOps token:",
88
+ default: existing.DEVOPS_TOKEN ?? "",
89
+ },
90
+ {
91
+ type: "input",
92
+ name: "DEVOPS_ORG",
93
+ message: "DevOps org:",
94
+ default: existing.DEVOPS_ORG ?? "",
95
+ },
96
+ {
97
+ type: "input",
98
+ name: "DEVOPS_PROJECT",
99
+ message: "DevOps project:",
100
+ default: existing.DEVOPS_PROJECT ?? "",
101
+ },
102
+ {
103
+ type: "input",
104
+ name: "DEVOPS_REPO",
105
+ message: "DevOps repo:",
106
+ default: existing.DEVOPS_REPO ?? "",
107
+ },
108
+ {
109
+ type: "input",
110
+ name: "TRIDENT_DB",
111
+ message: "Trident DB name:",
112
+ default: existing.TRIDENT_DB ?? "",
113
+ },
114
+ {
115
+ type: "input",
116
+ name: "AUTOPR_TARGET_BRANCH",
117
+ message: "AutoPR target branch:",
118
+ default: existing.AUTOPR_TARGET_BRANCH ?? "15.0-dev",
119
+ },
120
+ ]);
121
+
122
+ writeFileSync(
123
+ CONFIG_FILE,
124
+ Object.entries(answers)
125
+ .map(([k, v]) => `${k}=${v}`)
126
+ .join("\n") + "\n",
127
+ );
128
+ console.log(`Config written to ${CONFIG_FILE}`);
129
+
130
+ writeFileSync(COMPLETION_SCRIPT, completion.generateCompletionCode());
131
+ console.log(`Completion script written to ${COMPLETION_SCRIPT}`);
132
+
133
+ const rcFile = path.join(process.env.HOME || "", ".bashrc");
134
+ const sourceLine = `source ${COMPLETION_SCRIPT}`;
135
+ const rcContent = existsSync(rcFile) ? readFileSync(rcFile, "utf-8") : "";
136
+ if (!rcContent.includes(sourceLine)) {
137
+ appendFileSync(rcFile, `\n# prbot completion\n${sourceLine}\n`);
138
+ console.log(`Registered completion in ${rcFile} — run: source ~/.bashrc`);
139
+ } else {
140
+ console.log("Completion already registered in ~/.bashrc");
141
+ }
142
+ }
143
+
144
+ export { init };
@@ -0,0 +1,124 @@
1
+ import fetch from "node-fetch";
2
+ import fs from "fs/promises";
3
+ import path from "path";
4
+ import { execFile } from "child_process";
5
+ import { resolveAddonsPath } from "../lib/addons.js";
6
+
7
+ async function getToken() {
8
+ const url = process.env.KC_URL;
9
+ const payload = new URLSearchParams();
10
+
11
+ payload.append("username", process.env.KC_USER);
12
+ payload.append("password", process.env.KC_PASSWORD);
13
+ payload.append("client_id", process.env.KC_ID);
14
+ payload.append("client_secret", process.env.KC_SECRET);
15
+ payload.append("grant_type", "password");
16
+
17
+ const response = await fetch(url, {
18
+ method: "POST",
19
+ headers: {
20
+ "Content-Type": "application/x-www-form-urlencoded",
21
+ },
22
+ body: payload.toString(),
23
+ });
24
+
25
+ const json = await response.json();
26
+ return json.access_token;
27
+ }
28
+
29
+ async function getFiles(module_name, token) {
30
+ const url = `${process.env.RIP_URL}/ir.model/xml_prbot`;
31
+ const body = JSON.stringify({ module_name });
32
+ const headers = {
33
+ Authorization: `Bearer ${token}`,
34
+ "Content-Type": "application/json",
35
+ };
36
+
37
+ const response = await fetch(url, { method: "POST", body, headers });
38
+ if (!response.ok) {
39
+ throw new Error(await response.text());
40
+ }
41
+ return await response.json();
42
+ }
43
+
44
+ async function main(module_name) {
45
+ const token = await getToken();
46
+ const files = await getFiles(module_name, token);
47
+
48
+ let ADDONS_PATH = resolveAddonsPath(process.env.ADDONS_PATH);
49
+
50
+ for (const file of files) {
51
+ const buffer = Buffer.from(file.data, "base64");
52
+ let content = buffer.toString();
53
+ const lines = content.split("\n");
54
+
55
+ const odooCloseIndex = lines.findIndex((l) => l.trim() === "</odoo>");
56
+ if (odooCloseIndex !== -1 && odooCloseIndex < lines.length - 1) {
57
+ const footer = lines.slice(odooCloseIndex + 1).join("\n");
58
+ const skippedMatch = footer.match(/Skipped records:\s*(\d+)/);
59
+ if (skippedMatch && parseInt(skippedMatch[1]) > 0) {
60
+ throw new Error(
61
+ `[${file.name}] Export contains skipped records:\n${footer.trim()}`,
62
+ );
63
+ }
64
+ const duplicatedMatch = footer.match(/Duplicated records:\s*(\d+)/);
65
+ if (duplicatedMatch && parseInt(duplicatedMatch[1]) > 0) {
66
+ throw new Error(
67
+ `[${file.name}] Export contains duplicated records:\n${footer.trim()}`,
68
+ );
69
+ }
70
+ }
71
+
72
+ if (lines.length > 2) {
73
+ lines.splice(-2);
74
+ }
75
+ content = lines.join("\n");
76
+ content = content.replace(
77
+ /<field name="bpmn_diagram"><!\[CDATA\[[\s\S]*?\]\]><\/field>/g,
78
+ "",
79
+ );
80
+
81
+ let destPath;
82
+ if (file.name.includes("Relazioni mancanti")) {
83
+ destPath = `${ADDONS_PATH}/config/${module_name}/data/workflow_missing_relations.xml`;
84
+ } else {
85
+ destPath = `${ADDONS_PATH}/config/${module_name}/data/workflow_configuration.xml`;
86
+ }
87
+
88
+ await fs.mkdir(path.dirname(destPath), { recursive: true });
89
+ await fs.writeFile(destPath, content);
90
+ console.log(`Processed: ${file.name} -> ${destPath}`);
91
+ }
92
+
93
+ const workflowDir = path.join(ADDONS_PATH, "config", module_name, "data");
94
+ const filesToAdd = [
95
+ path.join(workflowDir, "workflow_missing_relations.xml"),
96
+ path.join(workflowDir, "workflow_configuration.xml"),
97
+ ];
98
+
99
+ for (const filePath of filesToAdd) {
100
+ await new Promise((resolve, reject) => {
101
+ execFile("git", ["add", filePath], { cwd: ADDONS_PATH }, (error) => {
102
+ if (error) reject(error);
103
+ else resolve();
104
+ });
105
+ });
106
+ }
107
+
108
+ const commitMessage = `[IMP][${module_name}] Update workflow`;
109
+ await new Promise((resolve, reject) => {
110
+ execFile(
111
+ "git",
112
+ ["commit", "-m", commitMessage],
113
+ { cwd: ADDONS_PATH },
114
+ (error) => {
115
+ if (error) reject(error);
116
+ else resolve();
117
+ },
118
+ );
119
+ });
120
+
121
+ console.log(`Committed with message: ${commitMessage}`);
122
+ }
123
+
124
+ export { main };
@@ -0,0 +1,83 @@
1
+ import fs from "fs/promises";
2
+ import path from "path";
3
+ import { execFile } from "child_process";
4
+ import { resolveAddonsPath } from "../lib/addons.js";
5
+
6
+ async function verbot(module_name, level) {
7
+ if (!["major", "minor", "patch"].includes(level)) {
8
+ throw new Error("Level must be one of major, minor, patch");
9
+ }
10
+
11
+ let ADDONS_PATH = resolveAddonsPath(process.env.ADDONS_PATH);
12
+
13
+ let manifestPath = path.join(ADDONS_PATH, module_name, "__manifest__.py");
14
+ try {
15
+ await fs.access(manifestPath);
16
+ } catch {
17
+ manifestPath = path.join(
18
+ ADDONS_PATH,
19
+ "config",
20
+ module_name,
21
+ "__manifest__.py",
22
+ );
23
+ try {
24
+ await fs.access(manifestPath);
25
+ } catch {
26
+ throw new Error(`__manifest__.py not found for module ${module_name}`);
27
+ }
28
+ }
29
+
30
+ const content = await fs.readFile(manifestPath, "utf-8");
31
+ const versionMatch = content.match(/"version":\s*"(15\.0\.\d+\.\d+\.\d+)"/);
32
+ if (!versionMatch) {
33
+ throw new Error("Version not found in manifest");
34
+ }
35
+
36
+ const currentVersion = versionMatch[1];
37
+ const parts = currentVersion.split(".");
38
+ const base = `${parts[0]}.${parts[1]}`;
39
+ const major = parseInt(parts[2]);
40
+ const minor = parseInt(parts[3]);
41
+ const patch = parseInt(parts[4]);
42
+
43
+ let newVersion;
44
+ if (level === "patch") {
45
+ newVersion = `${base}.${major}.${minor}.${patch + 1}`;
46
+ } else if (level === "minor") {
47
+ newVersion = `${base}.${major}.${minor + 1}.0`;
48
+ } else if (level === "major") {
49
+ newVersion = `${base}.${major + 1}.0.0`;
50
+ }
51
+
52
+ const newContent = content.replace(
53
+ `"version": "${currentVersion}"`,
54
+ `"version": "${newVersion}"`,
55
+ );
56
+
57
+ await fs.writeFile(manifestPath, newContent);
58
+ console.log(`Updated version: ${currentVersion} -> ${newVersion}`);
59
+
60
+ await new Promise((resolve, reject) => {
61
+ execFile("git", ["add", manifestPath], { cwd: ADDONS_PATH }, (error) => {
62
+ if (error) reject(error);
63
+ else resolve();
64
+ });
65
+ });
66
+
67
+ const commitMessage = `[VER][${module_name}] Bump`;
68
+ await new Promise((resolve, reject) => {
69
+ execFile(
70
+ "git",
71
+ ["commit", "-m", commitMessage],
72
+ { cwd: ADDONS_PATH },
73
+ (error) => {
74
+ if (error) reject(error);
75
+ else resolve();
76
+ },
77
+ );
78
+ });
79
+
80
+ console.log(`Committed with message: ${commitMessage}`);
81
+ }
82
+
83
+ export { verbot };
package/src/config.js ADDED
@@ -0,0 +1,7 @@
1
+ import path from "path";
2
+
3
+ const CONFIG_DIR = path.join(process.env.HOME || "", ".config", "prbot");
4
+ const CONFIG_FILE = path.join(CONFIG_DIR, "config");
5
+ const COMPLETION_SCRIPT = path.join(CONFIG_DIR, "completion.sh");
6
+
7
+ export { CONFIG_DIR, CONFIG_FILE, COMPLETION_SCRIPT };
package/src/index.js ADDED
@@ -0,0 +1,106 @@
1
+ #!/usr/bin/env node
2
+
3
+ import { configDotenv } from "dotenv";
4
+ import { readdirSync, readFileSync } from "fs";
5
+ import path from "path";
6
+ import { program } from "commander";
7
+ import omelette from "omelette";
8
+ import { CONFIG_FILE, COMPLETION_SCRIPT } from "./config.js";
9
+ import { main as prMain } from "./commands/pr.js";
10
+ import { verbot } from "./commands/ver.js";
11
+ import { init } from "./commands/init.js";
12
+ import { changelog } from "./commands/changelog.js";
13
+ import { autopr } from "./commands/autopr.js";
14
+
15
+ const completion = omelette("prbot <command> <module>");
16
+ completion.on("command", ({ reply }) => {
17
+ reply(["pr", "ver", "init", "changelog", "autopr"]);
18
+ });
19
+
20
+ completion.on("module", ({ before, reply }) => {
21
+ if (["init", "changelog", "autopr"].includes(before)) {
22
+ reply([]);
23
+ return;
24
+ }
25
+ try {
26
+ const raw = readFileSync(CONFIG_FILE, "utf-8");
27
+ const match = raw.match(/^ADDONS_PATH=(.+)$/m);
28
+ if (!match) {
29
+ reply([]);
30
+ return;
31
+ }
32
+ const addonsPath = match[1].trim().replace(/^~/, process.env.HOME || "");
33
+ reply(readdirSync(path.join(addonsPath, "config")));
34
+ } catch {
35
+ reply([]);
36
+ }
37
+ });
38
+
39
+ completion.init();
40
+
41
+ const isCompletionMode =
42
+ process.argv.includes("--compbash") || process.argv.includes("--compzsh");
43
+
44
+ if (!isCompletionMode) {
45
+ configDotenv({ path: CONFIG_FILE });
46
+ }
47
+
48
+ program
49
+ .command("pr <module>")
50
+ .option("-b, --bump <level>")
51
+ .action((module, opts) => {
52
+ prMain(module)
53
+ .then(() => {
54
+ if (opts.bump) {
55
+ return verbot(module, opts.bump);
56
+ }
57
+ })
58
+ .catch((err) => {
59
+ throw err;
60
+ });
61
+ });
62
+
63
+ program
64
+ .command("ver <module>")
65
+ .option("-b, --bump <level>")
66
+ .action((module, opts) => {
67
+ if (!opts.bump) {
68
+ throw new Error("No bump level specified");
69
+ }
70
+ verbot(module, opts.bump);
71
+ });
72
+
73
+ program
74
+ .command("init")
75
+ .description("Create config file and install shell completion")
76
+ .action(() => {
77
+ init(completion);
78
+ });
79
+
80
+ const collect = (val, prev) => [...(prev ?? []), val];
81
+
82
+ program
83
+ .command("changelog <pr>")
84
+ .option("-t, --trident <code>", "Trident issue codes (repeatable)", collect)
85
+ .option("-j, --jira <code>", "JIRA issue codes (repeatable)", collect)
86
+ .option("-m, --message <text>", "Changelog entry message")
87
+ .action((prNumber, opts) => {
88
+ changelog(prNumber, opts).catch((err) => {
89
+ throw err;
90
+ });
91
+ });
92
+
93
+ program
94
+ .command("autopr")
95
+ .option("-t, --trident <id>", "Trident task IDs (repeatable)", collect)
96
+ .option("-j, --jira <code>", "JIRA issue codes (repeatable)", collect)
97
+ .option("-m, --message <text>", "Changelog entry message")
98
+ .option("-b, --branch <name>", "Branch name (default: autopr_<taskId>)")
99
+ .option("-n, --name <text>", "PR title (default: task name from Odoo)")
100
+ .action((opts) => {
101
+ autopr(opts).catch((err) => {
102
+ throw err;
103
+ });
104
+ });
105
+
106
+ program.parse();
@@ -0,0 +1,8 @@
1
+ function resolveAddonsPath(addonsPath) {
2
+ if (addonsPath.startsWith("~")) {
3
+ return addonsPath.replace("~", process.env.HOME);
4
+ }
5
+ return addonsPath;
6
+ }
7
+
8
+ export { resolveAddonsPath };
package/src/lib/git.js ADDED
@@ -0,0 +1,12 @@
1
+ import { execFile } from "child_process";
2
+
3
+ function execGit(args, cwd) {
4
+ return new Promise((resolve, reject) => {
5
+ execFile("git", args, { cwd }, (error, stdout) => {
6
+ if (error) reject(error);
7
+ else resolve(stdout);
8
+ });
9
+ });
10
+ }
11
+
12
+ export { execGit };