@waron97/prbot 1.1.0 → 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.
@@ -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 };