chorus-cli 0.4.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 ADDED
@@ -0,0 +1,29 @@
1
+ {
2
+ "name": "chorus-cli",
3
+ "version": "0.4.0",
4
+ "description": "Automated ticket resolution with AI, Teams, and Slack integration",
5
+ "main": "index.js",
6
+ "bin": {
7
+ "chorus": "index.js"
8
+ },
9
+ "files": [
10
+ "index.js",
11
+ "providers/",
12
+ "tools/",
13
+ "scripts/postinstall.js"
14
+ ],
15
+ "scripts": {
16
+ "postinstall": "node scripts/postinstall.js",
17
+ "setup": "node index.js setup",
18
+ "start": "node index.js run"
19
+ },
20
+ "dependencies": {
21
+ "@anthropic-ai/sdk": "^0.73.0",
22
+ "@octokit/rest": "^20.0.2",
23
+ "dotenv": "^17.2.4",
24
+ "playwright": "^1.40.0"
25
+ },
26
+ "engines": {
27
+ "node": ">=18.0.0"
28
+ }
29
+ }
@@ -0,0 +1,202 @@
1
+ 'use strict';
2
+
3
+ const { exec } = require('child_process');
4
+ const util = require('util');
5
+ const fs = require('fs').promises;
6
+ const execPromise = util.promisify(exec);
7
+
8
+ /**
9
+ * Normalize an Azure DevOps work item into the shared issue shape.
10
+ */
11
+ function normalize(workItem) {
12
+ const fields = workItem.fields || {};
13
+ return {
14
+ number: workItem.id,
15
+ title: fields['System.Title'] || '(untitled)',
16
+ body: fields['System.Description'] || '',
17
+ labels: (fields['System.Tags'] || '')
18
+ .split(';')
19
+ .map(t => t.trim())
20
+ .filter(Boolean)
21
+ .map(name => ({ name })),
22
+ user: {
23
+ login: fields['System.CreatedBy']?.displayName
24
+ || fields['System.CreatedBy']?.uniqueName
25
+ || 'unknown',
26
+ },
27
+ };
28
+ }
29
+
30
+ function createAzureDevOpsProvider(config) {
31
+ const { org, project, repo, pat } = config;
32
+
33
+ const baseUrl = `https://dev.azure.com/${org}`;
34
+ const apiVersion = '7.1';
35
+
36
+ function authHeaders() {
37
+ const encoded = Buffer.from(`:${pat}`).toString('base64');
38
+ return {
39
+ Authorization: `Basic ${encoded}`,
40
+ 'Content-Type': 'application/json',
41
+ };
42
+ }
43
+
44
+ async function adoFetch(url, options = {}) {
45
+ const res = await fetch(url, {
46
+ ...options,
47
+ headers: { ...authHeaders(), ...options.headers },
48
+ });
49
+ if (!res.ok) {
50
+ const text = await res.text();
51
+ throw new Error(`Azure DevOps API error (${res.status}): ${text}`);
52
+ }
53
+ return res.json();
54
+ }
55
+
56
+ async function fetchLatestIssue() {
57
+ const wiql = {
58
+ query: `SELECT [System.Id] FROM WorkItems
59
+ WHERE [System.AssignedTo] = @me
60
+ AND [System.State] <> 'Closed'
61
+ AND [System.State] <> 'Removed'
62
+ ORDER BY [System.CreatedDate] DESC`,
63
+ };
64
+
65
+ const result = await adoFetch(
66
+ `${baseUrl}/${project}/_apis/wit/wiql?api-version=${apiVersion}`,
67
+ { method: 'POST', body: JSON.stringify(wiql) }
68
+ );
69
+
70
+ if (!result.workItems || result.workItems.length === 0) {
71
+ throw new Error('No open work items found assigned to you in Azure DevOps');
72
+ }
73
+
74
+ const topId = result.workItems[0].id;
75
+ return fetchIssueByNumber(topId);
76
+ }
77
+
78
+ async function fetchIssueByNumber(id) {
79
+ const data = await adoFetch(
80
+ `${baseUrl}/${project}/_apis/wit/workitems/${id}?$expand=all&api-version=${apiVersion}`
81
+ );
82
+ return normalize(data);
83
+ }
84
+
85
+ async function getUserDisplayName(identifier) {
86
+ // ADO work items already include the display name, so just pass it through
87
+ return identifier;
88
+ }
89
+
90
+ function parseIssueArg(arg) {
91
+ if (!arg) return null;
92
+
93
+ // Full ADO URL: https://dev.azure.com/{org}/{project}/_workitems/edit/{id}
94
+ const urlMatch = arg.match(
95
+ /dev\.azure\.com\/([^/]+)\/([^/]+)\/_workitems\/edit\/(\d+)/
96
+ );
97
+ if (urlMatch) {
98
+ return {
99
+ org: urlMatch[1],
100
+ project: urlMatch[2],
101
+ number: parseInt(urlMatch[3], 10),
102
+ };
103
+ }
104
+
105
+ // Bare work item number
106
+ if (/^\d+$/.test(arg)) {
107
+ return { org, project, number: parseInt(arg, 10) };
108
+ }
109
+
110
+ throw new Error(
111
+ `Invalid issue argument: "${arg}". Pass a number (456) or Azure DevOps URL.`
112
+ );
113
+ }
114
+
115
+ function getSolutionFiles(solution) {
116
+ return [...(solution.files_modified || []), ...(solution.files_created || [])];
117
+ }
118
+
119
+ async function gitAddSolutionFiles(solution) {
120
+ const files = getSolutionFiles(solution);
121
+ if (files.length > 0) {
122
+ const quoted = files.map(f => `"${f}"`).join(' ');
123
+ await execPromise(`git add ${quoted}`);
124
+ } else {
125
+ await execPromise('git add .');
126
+ }
127
+ }
128
+
129
+ async function createPR(issue, solution) {
130
+ const branchName = `fix/ab${issue.number}`;
131
+ const isCoder = config.codingTool === 'coder';
132
+
133
+ console.log('🌿 Creating PR...');
134
+
135
+ if (!isCoder) {
136
+ // Legacy: parse code blocks and write files
137
+ const fileRegex = /```(.+?)\n([\s\S]+?)```/g;
138
+ const raw = solution._raw || solution;
139
+ let match;
140
+ while ((match = fileRegex.exec(raw)) !== null) {
141
+ const [, filepath, content] = match;
142
+ const cleanPath = filepath.trim();
143
+ if (cleanPath.includes('/') || cleanPath.includes('.')) {
144
+ const dir = cleanPath.substring(0, cleanPath.lastIndexOf('/'));
145
+ if (dir) await execPromise(`mkdir -p ${dir}`).catch(() => {});
146
+ await fs.writeFile(cleanPath, content.trim());
147
+ }
148
+ }
149
+ }
150
+
151
+ // Clean up stale branch
152
+ await execPromise(`git branch -D ${branchName}`).catch(() => {});
153
+ await execPromise(`git push origin --delete ${branchName}`).catch(() => {});
154
+
155
+ await execPromise(`git checkout -b ${branchName}`);
156
+ await gitAddSolutionFiles(solution);
157
+
158
+ const { stdout: diffCheck } = await execPromise(`git diff --cached --stat`);
159
+ if (!diffCheck.trim()) {
160
+ await execPromise(`git checkout main`);
161
+ await execPromise(`git branch -D ${branchName}`).catch(() => {});
162
+ throw new Error('No file changes to commit — coder did not produce any code');
163
+ }
164
+
165
+ // AB#123 syntax links the commit to the Azure DevOps work item
166
+ await execPromise(`git commit -m "Fix: ${issue.title} AB#${issue.number}"`);
167
+ await execPromise(`git push origin ${branchName}`);
168
+
169
+ // Create PR via Azure DevOps REST API
170
+ const summary = isCoder ? solution.summary : (solution._raw || solution);
171
+ const filesChanged = isCoder
172
+ ? getSolutionFiles(solution).map(f => `- \`${f}\``).join('\n')
173
+ : '';
174
+
175
+ const prBody = {
176
+ sourceRefName: `refs/heads/${branchName}`,
177
+ targetRefName: 'refs/heads/main',
178
+ title: `Fix: ${issue.title}`,
179
+ description: `Resolves AB#${issue.number}\n\n## Summary\n${summary}${filesChanged ? `\n\n## Files Changed\n${filesChanged}` : ''}`,
180
+ };
181
+
182
+ const prResult = await adoFetch(
183
+ `${baseUrl}/${project}/_apis/git/repositories/${repo}/pullrequests?api-version=${apiVersion}`,
184
+ { method: 'POST', body: JSON.stringify(prBody) }
185
+ );
186
+
187
+ console.log('✅ PR created:', prResult.url || `PR #${prResult.pullRequestId}`);
188
+ }
189
+
190
+ return {
191
+ name: 'azuredevops',
192
+ fetchLatestIssue,
193
+ fetchIssueByNumber,
194
+ getUserDisplayName,
195
+ parseIssueArg,
196
+ createPR,
197
+ getSolutionFiles,
198
+ gitAddSolutionFiles,
199
+ };
200
+ }
201
+
202
+ module.exports = { createAzureDevOpsProvider };
@@ -0,0 +1,144 @@
1
+ 'use strict';
2
+
3
+ const { Octokit } = require('@octokit/rest');
4
+ const { exec } = require('child_process');
5
+ const util = require('util');
6
+ const fs = require('fs').promises;
7
+ const execPromise = util.promisify(exec);
8
+
9
+ function createGitHubProvider(config) {
10
+ const { owner, repo, token, codingTool } = config;
11
+
12
+ function getOctokit() {
13
+ return new Octokit({ auth: token });
14
+ }
15
+
16
+ async function fetchLatestIssue() {
17
+ const octokit = getOctokit();
18
+ const { data: issues } = await octokit.issues.listForRepo({
19
+ owner,
20
+ repo,
21
+ state: 'open',
22
+ assignee: 'james-baloyi',
23
+ sort: 'created',
24
+ direction: 'desc',
25
+ per_page: 10,
26
+ });
27
+
28
+ if (issues.length === 0) {
29
+ throw new Error(`No open issues found assigned to james-baloyi in ${owner}/${repo}`);
30
+ }
31
+
32
+ return issues[0];
33
+ }
34
+
35
+ async function fetchIssueByNumber(number, issueOwner, issueRepo) {
36
+ const octokit = getOctokit();
37
+ const { data: issue } = await octokit.issues.get({
38
+ owner: issueOwner || owner,
39
+ repo: issueRepo || repo,
40
+ issue_number: number,
41
+ });
42
+ return issue;
43
+ }
44
+
45
+ async function getUserDisplayName(username) {
46
+ const octokit = getOctokit();
47
+ const { data: user } = await octokit.users.getByUsername({ username });
48
+ return user;
49
+ }
50
+
51
+ function parseIssueArg(arg) {
52
+ if (!arg) return null;
53
+
54
+ // Full GitHub URL: https://github.com/owner/repo/issues/123
55
+ const urlMatch = arg.match(/github\.com\/([^/]+)\/([^/]+)\/issues\/(\d+)/);
56
+ if (urlMatch) {
57
+ return { owner: urlMatch[1], repo: urlMatch[2], number: parseInt(urlMatch[3], 10) };
58
+ }
59
+
60
+ // Bare issue number
61
+ if (/^\d+$/.test(arg)) {
62
+ return { owner, repo, number: parseInt(arg, 10) };
63
+ }
64
+
65
+ throw new Error(`Invalid issue argument: "${arg}". Pass a number (4464) or GitHub URL.`);
66
+ }
67
+
68
+ function getSolutionFiles(solution) {
69
+ return [...(solution.files_modified || []), ...(solution.files_created || [])];
70
+ }
71
+
72
+ async function gitAddSolutionFiles(solution) {
73
+ const files = getSolutionFiles(solution);
74
+ if (files.length > 0) {
75
+ const quoted = files.map(f => `"${f}"`).join(' ');
76
+ await execPromise(`git add ${quoted}`);
77
+ } else {
78
+ await execPromise('git add .');
79
+ }
80
+ }
81
+
82
+ async function createPR(issue, solution) {
83
+ const branchName = `fix/issue-${issue.number}`;
84
+ const isCoder = codingTool === 'coder';
85
+
86
+ console.log('🌿 Creating PR...');
87
+
88
+ if (!isCoder) {
89
+ await writeSolutionToFiles(solution._raw || solution);
90
+ }
91
+
92
+ // Clean up stale branch from previous runs
93
+ await execPromise(`git branch -D ${branchName}`).catch(() => {});
94
+ await execPromise(`git push origin --delete ${branchName}`).catch(() => {});
95
+
96
+ await execPromise(`git checkout -b ${branchName}`);
97
+ await gitAddSolutionFiles(solution);
98
+
99
+ const { stdout: diffCheck } = await execPromise(`git diff --cached --stat`);
100
+ if (!diffCheck.trim()) {
101
+ await execPromise(`git checkout main`);
102
+ await execPromise(`git branch -D ${branchName}`).catch(() => {});
103
+ throw new Error('No file changes to commit — coder did not produce any code');
104
+ }
105
+
106
+ await execPromise(`git commit -m "Fix: ${issue.title} (resolves #${issue.number})"`);
107
+ await execPromise(`git push origin ${branchName}`);
108
+
109
+ const summary = isCoder ? solution.summary : (solution._raw || solution);
110
+ const filesChanged = isCoder
111
+ ? getSolutionFiles(solution).map(f => `- \`${f}\``).join('\n')
112
+ : '';
113
+
114
+ const prBody = `Resolves #${issue.number}
115
+
116
+ ## Summary
117
+ ${summary}
118
+ ${filesChanged ? `\n## Files Changed\n${filesChanged}` : ''}`;
119
+
120
+ const tmpBody = `/tmp/pr-body-${Date.now()}.md`;
121
+ await fs.writeFile(tmpBody, prBody);
122
+
123
+ const escapedTitle = issue.title.replace(/"/g, '\\"');
124
+ const { stdout } = await execPromise(
125
+ `gh pr create --title "Fix: ${escapedTitle}" --body-file "${tmpBody}" --base main`
126
+ );
127
+ await fs.unlink(tmpBody).catch(() => {});
128
+
129
+ console.log('✅ PR created:', stdout);
130
+ }
131
+
132
+ return {
133
+ name: 'github',
134
+ fetchLatestIssue,
135
+ fetchIssueByNumber,
136
+ getUserDisplayName,
137
+ parseIssueArg,
138
+ createPR,
139
+ getSolutionFiles,
140
+ gitAddSolutionFiles,
141
+ };
142
+ }
143
+
144
+ module.exports = { createGitHubProvider };
@@ -0,0 +1,51 @@
1
+ 'use strict';
2
+
3
+ const { createGitHubProvider } = require('./github');
4
+ const { createAzureDevOpsProvider } = require('./azuredevops');
5
+
6
+ /**
7
+ * Detect provider from an issue argument URL.
8
+ * Returns 'github', 'azuredevops', or null if undetectable.
9
+ */
10
+ function detectProviderFromArg(issueArg) {
11
+ if (!issueArg || typeof issueArg !== 'string') return null;
12
+ if (issueArg.includes('github.com')) return 'github';
13
+ if (issueArg.includes('dev.azure.com')) return 'azuredevops';
14
+ return null;
15
+ }
16
+
17
+ /**
18
+ * Create a provider instance.
19
+ *
20
+ * Resolution order:
21
+ * 1. Explicit PROVIDER env var
22
+ * 2. URL detection from issueArg
23
+ * 3. Default to 'github'
24
+ */
25
+ function createProvider(config, issueArg) {
26
+ const explicit = process.env.PROVIDER;
27
+ const detected = detectProviderFromArg(issueArg);
28
+ const name = (explicit || detected || 'github').toLowerCase();
29
+
30
+ if (name === 'azuredevops' || name === 'azure' || name === 'ado') {
31
+ if (!config.azuredevops.org || !config.azuredevops.pat) {
32
+ throw new Error(
33
+ 'Azure DevOps provider requires AZDO_ORG, AZDO_PROJECT, AZDO_REPO, and AZDO_PAT environment variables. Run "chorus setup" to configure.'
34
+ );
35
+ }
36
+ return createAzureDevOpsProvider({
37
+ ...config.azuredevops,
38
+ codingTool: config.ai.codingTool,
39
+ });
40
+ }
41
+
42
+ // Default: GitHub
43
+ return createGitHubProvider({
44
+ owner: config.github.owner,
45
+ repo: config.github.repo,
46
+ token: config.github.token,
47
+ codingTool: config.ai.codingTool,
48
+ });
49
+ }
50
+
51
+ module.exports = { createProvider, detectProviderFromArg };
@@ -0,0 +1,125 @@
1
+ #!/usr/bin/env node
2
+ 'use strict';
3
+
4
+ const { execFileSync, execSync } = require('child_process');
5
+ const path = require('path');
6
+ const fs = require('fs');
7
+
8
+ const os = require('os');
9
+ const ROOT = path.resolve(__dirname, '..');
10
+ const VENV = path.join(os.homedir(), '.config', 'chorus', '.venv');
11
+ const REQUIREMENTS = path.join(ROOT, 'tools', 'requirements.txt');
12
+
13
+ // Skip in CI or when explicitly opted out
14
+ if (process.env.CHORUS_SKIP_POSTINSTALL === '1') {
15
+ console.log('CHORUS_SKIP_POSTINSTALL=1 — skipping Python setup');
16
+ process.exit(0);
17
+ }
18
+
19
+ // ---------------------------------------------------------------------------
20
+ // Helpers
21
+ // ---------------------------------------------------------------------------
22
+
23
+ function run(cmd, args, opts) {
24
+ try {
25
+ return execFileSync(cmd, args, { stdio: 'inherit', ...opts });
26
+ } catch (e) {
27
+ console.error(` Command failed: ${cmd} ${args.join(' ')}`);
28
+ if (e.message) console.error(` ${e.message}`);
29
+ return null;
30
+ }
31
+ }
32
+
33
+ function findPython() {
34
+ for (const candidate of ['python3', 'python']) {
35
+ try {
36
+ const version = execFileSync(candidate, ['--version'], {
37
+ encoding: 'utf8',
38
+ stdio: ['ignore', 'pipe', 'pipe'],
39
+ }).trim(); // e.g. "Python 3.11.4"
40
+
41
+ const match = version.match(/Python (\d+)\.(\d+)/);
42
+ if (match) {
43
+ const major = parseInt(match[1], 10);
44
+ const minor = parseInt(match[2], 10);
45
+ if (major === 3 && minor >= 8) {
46
+ console.log(`Found ${version} (${candidate})`);
47
+ return candidate;
48
+ }
49
+ }
50
+ } catch {
51
+ // candidate not found, try next
52
+ }
53
+ }
54
+ return null;
55
+ }
56
+
57
+ // ---------------------------------------------------------------------------
58
+ // Main
59
+ // ---------------------------------------------------------------------------
60
+
61
+ console.log('\nChorus postinstall — setting up Python environment\n');
62
+
63
+ const python = findPython();
64
+
65
+ if (!python) {
66
+ console.error(
67
+ '⚠ Python 3.8+ not found.\n' +
68
+ ' Chorus needs Python 3 to run its AI tools (coder, qa, mapper).\n' +
69
+ ' Install Python 3 and re-run: npm rebuild chorus\n'
70
+ );
71
+ // Exit 0 so npm install still succeeds
72
+ process.exit(0);
73
+ }
74
+
75
+ // 1. Create venv (skip if it already exists and looks valid)
76
+ const venvPython = process.platform === 'win32'
77
+ ? path.join(VENV, 'Scripts', 'python.exe')
78
+ : path.join(VENV, 'bin', 'python');
79
+
80
+ if (!fs.existsSync(venvPython)) {
81
+ console.log('Creating virtual environment...');
82
+ if (run(python, ['-m', 'venv', VENV]) === null) {
83
+ console.error('⚠ Failed to create virtual environment. Install the venv module and re-run: npm rebuild chorus');
84
+ process.exit(0);
85
+ }
86
+ } else {
87
+ console.log('Virtual environment already exists');
88
+ }
89
+
90
+ // 2. Install Python dependencies using python -m pip (more reliable than pip binary)
91
+ console.log('Installing Python dependencies...');
92
+ run(venvPython, ['-m', 'pip', 'install', '-r', REQUIREMENTS]);
93
+
94
+ // 3. Verify critical dependency installed
95
+ try {
96
+ execFileSync(venvPython, ['-c', 'import anthropic'], { stdio: 'ignore' });
97
+ console.log(' Dependencies installed ✓');
98
+ } catch {
99
+ console.error(
100
+ '⚠ "anthropic" module is missing after pip install.\n' +
101
+ ' Run manually: ' + venvPython + ' -m pip install -r ' + REQUIREMENTS
102
+ );
103
+ process.exit(0);
104
+ }
105
+
106
+ // 4. Verify slack_sdk installed
107
+ try {
108
+ execFileSync(venvPython, ['-c', 'import slack_sdk'], { stdio: 'ignore' });
109
+ console.log(' slack_sdk installed ✓');
110
+ } catch {
111
+ console.warn(
112
+ '⚠ "slack_sdk" module is missing after pip install.\n' +
113
+ ' Slack messenger will not work. Run manually:\n' +
114
+ ' ' + venvPython + ' -m pip install slack_sdk>=3.27.0'
115
+ );
116
+ // Non-blocking — Slack is optional
117
+ }
118
+
119
+ // 5. Install Playwright Firefox for the Python side
120
+ console.log('Installing Playwright Firefox...');
121
+ if (run(venvPython, ['-m', 'playwright', 'install', 'firefox']) === null) {
122
+ console.error('⚠ Playwright Firefox install failed. You can run it manually later:\n ' + venvPython + ' -m playwright install firefox');
123
+ }
124
+
125
+ console.log('\n✅ Chorus Python environment ready\n');