@waron97/prbot 2.8.0 → 3.0.1

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,38 @@
1
+ """Shared Recordset base class for all Odoo models."""
2
+
3
+ from typing import Any, Dict, List, Optional, Union, Iterator
4
+ import datetime as _dt
5
+
6
+ class Recordset:
7
+ """Base class for all Odoo model record objects."""
8
+
9
+ id: int
10
+ ids: List[int]
11
+ create_date: _dt.date
12
+ write_date: _dt.date
13
+ create_uid: "Recordset"
14
+ write_uid: "Recordset"
15
+ def __getattr__(self, name: str) -> Any: ...
16
+ def browse(self, ids: Union[int, List[int]]) -> "Recordset": ...
17
+ def write(self, vals: Dict[str, Any]) -> bool: ...
18
+ def search(
19
+ self,
20
+ domain: List[Any],
21
+ limit: Optional[int] = None,
22
+ order: Optional[str] = None,
23
+ offset: int = 0,
24
+ ) -> "Recordset": ...
25
+ def create(self, vals: Dict[str, Any]) -> "Recordset": ...
26
+ def sudo(self) -> "Recordset": ...
27
+ def with_context(self, ctx: Dict[str, Any] = ..., **kwargs: Any) -> "Recordset": ...
28
+ def filtered(self, func: Any) -> "Recordset": ...
29
+ def mapped(self, field: str) -> Any: ...
30
+ def sorted(self, key: Any = None, reverse: bool = False) -> "Recordset": ...
31
+ def exists(self) -> "Recordset": ...
32
+ def unlink(self) -> bool: ...
33
+ def get_payload(self) -> Any: ...
34
+ def __getitem__(self, key: Union[str, int]) -> Any: ...
35
+ def __setitem__(self, key: str, value: Any) -> None: ...
36
+ def __bool__(self) -> bool: ...
37
+ def __iter__(self) -> Iterator["Recordset"]: ...
38
+ def __len__(self) -> int: ...
package/package.json CHANGED
@@ -1,13 +1,14 @@
1
1
  {
2
2
  "name": "@waron97/prbot",
3
- "version": "2.8.0",
3
+ "version": "3.0.1",
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": "src/index.js"
10
+ "prbot": "src/index.js",
11
+ "agrippa": "src/agrippa/index.js"
11
12
  },
12
13
  "dependencies": {
13
14
  "@inquirer/search": "^2.0.1",
@@ -18,7 +19,9 @@
18
19
  "inquirer-search-list": "^1.2.6",
19
20
  "node-fetch": "^3.3.2",
20
21
  "omelette": "^0.4.17",
21
- "prettier": "^3.5.3"
22
+ "prettier": "^3.5.3",
23
+ "slugify": "^1.6.9",
24
+ "yaml": "^2.9.0"
22
25
  },
23
26
  "devDependencies": {
24
27
  "@eslint/js": "^9.21.0",
@@ -28,6 +31,10 @@
28
31
  "typescript": "^5.8.2",
29
32
  "typescript-eslint": "^8.26.0"
30
33
  },
34
+ "files": [
35
+ "src/",
36
+ "agrippa_typings/"
37
+ ],
31
38
  "keywords": [],
32
39
  "author": "",
33
40
  "license": "ISC",
@@ -0,0 +1,134 @@
1
+ import inquirer from 'inquirer';
2
+ import select from '@inquirer/select';
3
+ import search from '@inquirer/search';
4
+ import { readConfig, writeConfig, loadEffectiveEnv } from '../lib/config.js';
5
+ import { listWorkflows, getPhasesByWorkflow, listMfas } from '../lib/api.js';
6
+ import { getToken } from '../../lib/auth.js';
7
+ import { computeChecksum } from '../lib/checksum.js';
8
+ import { toSlug, defaultMfaPath, writeCodeFile } from '../lib/workspace.js';
9
+ import { fuzzyMatch } from '../../lib/fuzzy.js';
10
+
11
+ async function clone(opts) {
12
+ const config = readConfig();
13
+ loadEffectiveEnv(config);
14
+
15
+ const ripUrl = process.env.RIP_URL;
16
+ if (!ripUrl) throw new Error('RIP_URL is not configured. Run `prbot init` or set it in agrippa.yaml.');
17
+
18
+ // Determine object type
19
+ let objectType = opts.mfa ? 'mfa' : opts.phase ? 'phase' : null;
20
+ if (!objectType) {
21
+ objectType = await select({
22
+ message: 'What do you want to clone?',
23
+ choices: [
24
+ { name: 'MFA', value: 'mfa' },
25
+ { name: 'Phase', value: 'phase' },
26
+ ],
27
+ });
28
+ }
29
+
30
+ console.log('Fetching records...');
31
+ const token = await getToken();
32
+
33
+ let records;
34
+ if (objectType === 'phase') {
35
+ records = await listWorkflows(token, ripUrl);
36
+ } else {
37
+ records = await listMfas(token, ripUrl);
38
+ }
39
+
40
+ if (!records.length) {
41
+ console.log(`No ${objectType} records found.`);
42
+ return;
43
+ }
44
+
45
+ // Determine record
46
+ let record;
47
+ if (opts.id) {
48
+ const id = parseInt(opts.id, 10);
49
+ record = records.find((r) => r.id === id);
50
+ if (!record) throw new Error(`No ${objectType} found with id ${opts.id}`);
51
+ } else {
52
+ record = await search({
53
+ message: `Select a ${objectType}:`,
54
+ source: (input) => {
55
+ const filtered = input
56
+ ? records.filter((r) => fuzzyMatch(r.name, input))
57
+ : records;
58
+ return filtered.map((r) => ({
59
+ name: objectType === 'mfa' ? `${r.model_name} / ${r.name}` : r.name,
60
+ value: r,
61
+ }));
62
+ },
63
+ });
64
+ }
65
+
66
+ // Determine path
67
+ let basePath = opts.path ?? null;
68
+
69
+ if (objectType === 'phase') {
70
+ if (!basePath) {
71
+ const { inputPath } = await inquirer.prompt([
72
+ {
73
+ type: 'input',
74
+ name: 'inputPath',
75
+ message: 'Base directory for phases:',
76
+ default: toSlug(record.name),
77
+ },
78
+ ]);
79
+ basePath = inputPath;
80
+ }
81
+
82
+ console.log(`Fetching phases for "${record.name}"...`);
83
+ const phases = await getPhasesByWorkflow(token, ripUrl, record.id, { fromCode: true });
84
+
85
+ if (!phases.length) {
86
+ console.log('No phases found.');
87
+ return;
88
+ }
89
+
90
+ for (const phase of phases) {
91
+ const filePath = `${basePath}/${toSlug(phase.name)}.py`;
92
+ writeCodeFile(filePath, phase.code);
93
+ config.workspace.push({
94
+ path: filePath,
95
+ id: phase.id,
96
+ object_type: 'phase',
97
+ workflow_id: record.id,
98
+ workflow_name: record.name,
99
+ checksum_at_pull: computeChecksum(phase.code),
100
+ name: `${record.name} / ${phase.name}`,
101
+ });
102
+ console.log(` wrote ${filePath}`);
103
+ }
104
+
105
+ console.log(`Cloned ${phases.length} phase(s) to ${basePath}/`);
106
+ } else {
107
+ const defaultPath = defaultMfaPath(record.model_name, record.name);
108
+ if (!basePath) {
109
+ const { inputPath } = await inquirer.prompt([
110
+ {
111
+ type: 'input',
112
+ name: 'inputPath',
113
+ message: 'Path for MFA file:',
114
+ default: defaultPath,
115
+ },
116
+ ]);
117
+ basePath = inputPath;
118
+ }
119
+
120
+ writeCodeFile(basePath, record.code);
121
+ config.workspace.push({
122
+ path: basePath,
123
+ id: record.id,
124
+ object_type: 'mfa',
125
+ checksum_at_pull: computeChecksum(record.code),
126
+ name: `${record.model_name} / ${record.name}`,
127
+ });
128
+ console.log(`Cloned MFA to ${basePath}`);
129
+ }
130
+
131
+ writeConfig(config);
132
+ }
133
+
134
+ export { clone };
@@ -0,0 +1,96 @@
1
+ import { existsSync, statSync, writeFileSync, unlinkSync } from 'fs';
2
+ import { join } from 'path';
3
+ import { tmpdir } from 'os';
4
+ import { spawnSync } from 'child_process';
5
+ import { readConfig, loadEffectiveEnv } from '../lib/config.js';
6
+ import { fetchRemoteCode } from './pull.js';
7
+ import { getToken } from '../../lib/auth.js';
8
+ import { readCodeFile, fileExists } from '../lib/workspace.js';
9
+ import { computeChecksum } from '../lib/checksum.js';
10
+
11
+ async function diff(targetArg) {
12
+ const config = readConfig();
13
+ loadEffectiveEnv(config);
14
+
15
+ const ripUrl = process.env.RIP_URL;
16
+ if (!ripUrl) throw new Error('RIP_URL is not configured. Run `prbot init` or set it in agrippa.yaml.');
17
+
18
+ if (!config.workspace.length) {
19
+ console.log('No tracked resources. Run `agrippa clone` first.');
20
+ return;
21
+ }
22
+
23
+ const entries = filterEntries(config.workspace, targetArg);
24
+ if (!entries.length) {
25
+ console.log('No tracked files match the given path.');
26
+ return;
27
+ }
28
+
29
+ console.log('Fetching remote code...');
30
+ const token = await getToken();
31
+ const remoteCodeMap = await fetchRemoteCode(token, ripUrl, entries);
32
+
33
+ let diffCount = 0;
34
+ const tmpFiles = [];
35
+
36
+ try {
37
+ for (const entry of entries) {
38
+ const key = `${entry.object_type}:${entry.id}`;
39
+ const remoteCode = remoteCodeMap.get(key) ?? '';
40
+ const localCode = readCodeFile(entry.path) ?? '';
41
+
42
+ if (computeChecksum(localCode) === computeChecksum(remoteCode)) continue;
43
+
44
+ if (!fileExists(entry.path)) {
45
+ console.log(`\n--- ${entry.path} (local file missing)`);
46
+ continue;
47
+ }
48
+
49
+ // Write remote content to a temp file so git diff --no-index can compare
50
+ const tmpPath = join(tmpdir(), `agrippa-remote-${entry.object_type}-${entry.id}.py`);
51
+ writeFileSync(tmpPath, (remoteCode ?? '').trim() + '\n', 'utf-8');
52
+ tmpFiles.push(tmpPath);
53
+
54
+ console.log(`\n=== ${entry.path} [${entry.name}] ===`);
55
+
56
+ const result = spawnSync(
57
+ 'git',
58
+ ['diff', '--no-index', '--color=always', tmpPath, entry.path],
59
+ { stdio: ['ignore', 'inherit', 'inherit'] },
60
+ );
61
+ // exit code 1 means differences found (normal), 0 means identical, >1 means error
62
+ if (result.status !== null && result.status > 1) {
63
+ console.error(`git diff failed for ${entry.path}`);
64
+ }
65
+ diffCount++;
66
+ }
67
+ } finally {
68
+ for (const f of tmpFiles) {
69
+ try { unlinkSync(f); } catch { /* ignore */ }
70
+ }
71
+ }
72
+
73
+ if (diffCount === 0) {
74
+ console.log('No differences found — all tracked files match the remote.');
75
+ } else {
76
+ console.log(`\n${diffCount} file(s) differ from remote.`);
77
+ }
78
+ }
79
+
80
+ function filterEntries(workspace, targetArg) {
81
+ if (!targetArg) return workspace;
82
+
83
+ // Normalise: strip trailing slash
84
+ const target = targetArg.replace(/\/$/, '');
85
+
86
+ // If it exists on disk and is a directory, filter by prefix
87
+ if (existsSync(target) && statSync(target).isDirectory()) {
88
+ const prefix = target.endsWith('/') ? target : target + '/';
89
+ return workspace.filter((e) => e.path.startsWith(prefix));
90
+ }
91
+
92
+ // Otherwise treat as exact file path (whether it exists yet or not)
93
+ return workspace.filter((e) => e.path === target);
94
+ }
95
+
96
+ export { diff };
@@ -0,0 +1,116 @@
1
+ import { existsSync, writeFileSync, mkdirSync, copyFileSync } from 'fs';
2
+ import { fileURLToPath } from 'url';
3
+ import { join } from 'path';
4
+ import inquirer from 'inquirer';
5
+ import { WORKSPACE_FILE } from '../lib/config.js';
6
+
7
+ const TEMPLATE = `# agrippa workspace configuration
8
+ # Keycloak and RIP credentials are taken from the global prbot config by default.
9
+ # Uncomment and set values here only to override them for this workspace.
10
+ agrippa: {}
11
+ # KC_URL: ""
12
+ # KC_USER: ""
13
+ # KC_PASSWORD: ""
14
+ # KC_ID: ""
15
+ # KC_SECRET: ""
16
+ # RIP_URL: ""
17
+
18
+ workspace: []
19
+ `;
20
+
21
+ const PYPROJECT_TOML = `[tool.ruff]
22
+ builtins = [
23
+ "case_id",
24
+ "request",
25
+ "format_exc",
26
+ "env",
27
+ "log",
28
+ "json_dumps",
29
+ "ValidationError",
30
+ "datetime",
31
+ "date",
32
+ "dateutil",
33
+ "time",
34
+ "pytz",
35
+ "body",
36
+ "make_response",
37
+ "logger",
38
+ "first",
39
+ "args",
40
+ "model",
41
+ "json_loads",
42
+ "record",
43
+ "records"
44
+ ]
45
+ `;
46
+
47
+ const PYRIGHTCONFIG = JSON.stringify(
48
+ {
49
+ pythonVersion: '3.10',
50
+ stubPath: 'typings',
51
+ typeCheckingMode: 'standard',
52
+ reportArgumentType: 'none',
53
+ },
54
+ null,
55
+ 2,
56
+ ) + '\n';
57
+
58
+ const TYPING_FILES = [
59
+ '__builtins__.pyi',
60
+ 'odoo_environment.pyi',
61
+ 'odoo_records.pyi',
62
+ 'recordset.pyi',
63
+ 'b2w_entities.pyi',
64
+ ];
65
+
66
+ async function init() {
67
+ if (existsSync(WORKSPACE_FILE)) {
68
+ const { overwrite } = await inquirer.prompt([
69
+ {
70
+ type: 'confirm',
71
+ name: 'overwrite',
72
+ message: `${WORKSPACE_FILE} already exists. Overwrite?`,
73
+ default: false,
74
+ },
75
+ ]);
76
+ if (!overwrite) {
77
+ console.log('Aborted.');
78
+ return;
79
+ }
80
+ }
81
+
82
+ writeFileSync(WORKSPACE_FILE, TEMPLATE, 'utf-8');
83
+ console.log(`Created ${WORKSPACE_FILE}`);
84
+ console.log(`Run 'agrippa clone' to add resources to this workspace.`);
85
+ console.log(`Add ${WORKSPACE_FILE} to .gitignore if it contains credentials.`);
86
+
87
+ if (!existsSync('pyproject.toml')) {
88
+ writeFileSync('pyproject.toml', PYPROJECT_TOML, 'utf-8');
89
+ console.log('Created pyproject.toml (ruff builtins)');
90
+ }
91
+
92
+ const { importTypings } = await inquirer.prompt([
93
+ {
94
+ type: 'confirm',
95
+ name: 'importTypings',
96
+ message: 'Import pyright config and type stubs?',
97
+ default: true,
98
+ },
99
+ ]);
100
+
101
+ if (importTypings) {
102
+ if (!existsSync('pyrightconfig.json')) {
103
+ writeFileSync('pyrightconfig.json', PYRIGHTCONFIG, 'utf-8');
104
+ console.log('Created pyrightconfig.json');
105
+ }
106
+
107
+ const typingsDir = fileURLToPath(new URL('../../../agrippa_typings', import.meta.url));
108
+ mkdirSync('typings', { recursive: true });
109
+ for (const file of TYPING_FILES) {
110
+ copyFileSync(join(typingsDir, file), join('typings', file));
111
+ }
112
+ console.log(`Copied ${TYPING_FILES.length} type stubs to typings/`);
113
+ }
114
+ }
115
+
116
+ export { init };
@@ -0,0 +1,162 @@
1
+ import inquirer from 'inquirer';
2
+ import search from '@inquirer/search';
3
+ import { readConfig, loadEffectiveEnv } from '../lib/config.js';
4
+ import {
5
+ listWorkflows,
6
+ getPhasesByWorkflow,
7
+ getPhaseResults,
8
+ initPhaseRemote,
9
+ getPhaseConfigurators,
10
+ deleteConfigurator,
11
+ createConfigurator,
12
+ } from '../lib/api.js';
13
+ import { getToken } from '../../lib/auth.js';
14
+ import { fuzzyMatch } from '../../lib/fuzzy.js';
15
+
16
+ const CODE_TEMPLATE = `logs = []
17
+ debug_logs = []
18
+ error_occurred = False
19
+
20
+
21
+ def make_log(message, data=None, always=False):
22
+ log_entry = {"message": message}
23
+ if data is not None:
24
+ log_entry["data"] = data
25
+
26
+ if always:
27
+ logs.append(log_entry)
28
+ else:
29
+ debug_logs.append(log_entry)
30
+
31
+
32
+ # --------------------------------------
33
+
34
+
35
+ def main():
36
+ pass
37
+
38
+
39
+ try:
40
+ result = main()
41
+ except Exception as e:
42
+ error_occurred = True
43
+ make_log(
44
+ "Error occurred in phase",
45
+ {"error": str(e), "traceback": format_exc()},
46
+ always=True,
47
+ )
48
+ case_id.write({"error_message": str(e)})
49
+
50
+
51
+ if error_occurred and debug_logs:
52
+ logs.append({"message": "debug_logs", "logs": debug_logs})
53
+
54
+ if logs:
55
+ log(json_dumps(logs, indent=2))`;
56
+
57
+ function generateCode(results) {
58
+ if (!results || results.length === 0) return CODE_TEMPLATE;
59
+
60
+ const lines = [];
61
+ const vars = results.map((_, i) => `RES${i + 1}`);
62
+ results.forEach((r, i) => lines.push(`# ${vars[i]} = ${r.name}`));
63
+ lines.push('');
64
+ lines.push(`${vars.join(', ')} = (${vars.map((v) => `"${v}"`).join(', ')})`);
65
+ lines.push('');
66
+ lines.push(CODE_TEMPLATE);
67
+ return lines.join('\n');
68
+ }
69
+
70
+ async function initPhase() {
71
+ const config = readConfig();
72
+ loadEffectiveEnv(config);
73
+
74
+ const ripUrl = process.env.RIP_URL;
75
+ if (!ripUrl) throw new Error('RIP_URL is not configured. Run `prbot init` or set it in agrippa.yaml.');
76
+
77
+ console.log('Fetching workflows...');
78
+ const token = await getToken();
79
+ const workflows = await listWorkflows(token, ripUrl);
80
+
81
+ if (!workflows.length) {
82
+ console.log('No workflows found.');
83
+ return;
84
+ }
85
+
86
+ const workflow = await search({
87
+ message: 'Select a workflow:',
88
+ source: (input) => {
89
+ const filtered = input
90
+ ? workflows.filter((w) => fuzzyMatch(w.name, input))
91
+ : workflows;
92
+ return filtered.map((w) => ({ name: w.name, value: w }));
93
+ },
94
+ });
95
+
96
+ console.log(`Fetching phases for "${workflow.name}"...`);
97
+ const phases = await getPhasesByWorkflow(token, ripUrl, workflow.id);
98
+
99
+ if (!phases.length) {
100
+ console.log('No phases found for this workflow.');
101
+ return;
102
+ }
103
+
104
+ const phase = await search({
105
+ message: 'Select a phase:',
106
+ source: (input) => {
107
+ const filtered = input
108
+ ? phases.filter((p) => fuzzyMatch(p.name, input))
109
+ : phases;
110
+ return filtered.map((p) => ({ name: p.name, value: p }));
111
+ },
112
+ });
113
+
114
+ // Resolve result objects — API may return IDs or full objects
115
+ let results = phase.allowed_phase_result_ids || [];
116
+ if (results.length > 0 && typeof results[0] === 'number') {
117
+ results = await getPhaseResults(token, ripUrl, phase.id);
118
+ }
119
+
120
+ const code = generateCode(results);
121
+
122
+ const { confirm } = await inquirer.prompt([
123
+ {
124
+ type: 'confirm',
125
+ name: 'confirm',
126
+ message: `Initialize phase "${phase.name}"? This will overwrite existing code.`,
127
+ default: true,
128
+ },
129
+ ]);
130
+
131
+ if (!confirm) {
132
+ console.log('Aborted.');
133
+ return;
134
+ }
135
+
136
+ // Delete existing configurator records for this phase
137
+ const existing = await getPhaseConfigurators(token, ripUrl, phase.id);
138
+ for (const cfg of existing) {
139
+ await deleteConfigurator(token, ripUrl, cfg.id);
140
+ }
141
+
142
+ // Update phase code + set_result_automatically
143
+ await initPhaseRemote(token, ripUrl, phase.id, code);
144
+
145
+ // Create new configurator records for each result
146
+ const vars = results.map((_, i) => `RES${i + 1}`);
147
+ for (let i = 0; i < results.length; i++) {
148
+ await createConfigurator(token, ripUrl, {
149
+ result_value: vars[i],
150
+ code_phase_id: phase.id,
151
+ triplet_phase_result_id: results[i].id,
152
+ });
153
+ }
154
+
155
+ console.log(`Initialized phase "${phase.name}".`);
156
+ if (results.length > 0) {
157
+ console.log(` ${results.length} configurator(s) created: ${vars.join(', ')}`);
158
+ }
159
+ console.log(`Run 'agrippa pull' in workspaces that track this workflow to fetch the updated code.`);
160
+ }
161
+
162
+ export { initPhase };