prsmith 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.
package/src/ai.js ADDED
@@ -0,0 +1,118 @@
1
+ import chalk from 'chalk';
2
+
3
+ /**
4
+ * Polishes the provided text to be polite, constructive, and professional.
5
+ * Supports Gemini, OpenAI, and Groq.
6
+ *
7
+ * @param {string} text The raw text to polish (Problem or Suggested Fix)
8
+ * @param {string} fieldName Either "Problem Description" or "Suggested Fix" for prompt context
9
+ * @param {object} config Local user configuration from .prsmith.json
10
+ * @returns {Promise<string>} The polished text, or the original text on failure/missing keys.
11
+ */
12
+ export async function polishText(text, fieldName, config = {}) {
13
+ if (!text || text.trim() === '') return text;
14
+
15
+ // Retrieve credentials from config or environment variables
16
+ const provider = (
17
+ config.aiProvider ||
18
+ process.env.AI_PROVIDER ||
19
+ 'gemini'
20
+ ).toLowerCase();
21
+ let apiKey = config.aiApiKey || '';
22
+ let model = config.aiModel || '';
23
+
24
+ if (provider === 'gemini') {
25
+ apiKey = apiKey || process.env.GEMINI_API_KEY || '';
26
+ model = model || process.env.GEMINI_MODEL || 'gemini-1.5-flash';
27
+ } else if (provider === 'openai') {
28
+ apiKey = apiKey || process.env.OPENAI_API_KEY || '';
29
+ model = model || process.env.OPENAI_MODEL || 'gpt-4o-mini';
30
+ } else if (provider === 'groq') {
31
+ apiKey = apiKey || process.env.GROQ_API_KEY || '';
32
+ model = model || process.env.GROQ_MODEL || 'llama-3.3-70b-versatile';
33
+ } else {
34
+ console.warn(
35
+ chalk.yellow(
36
+ `\n⚠️ Unknown AI provider '${provider}'. Falling back to raw text.`
37
+ )
38
+ );
39
+ return text;
40
+ }
41
+
42
+ if (!apiKey) {
43
+ console.warn(
44
+ chalk.yellow(
45
+ `\n⚠️ No API key found for '${provider}'. Set 'aiApiKey' in .prsmith.json or the corresponding env variable (${provider.toUpperCase()}_API_KEY) to enable AI polishing.`
46
+ )
47
+ );
48
+ return text;
49
+ }
50
+
51
+ const prompt = `You are an expert senior software engineer and empathetic mentor.
52
+ Translate the following code review comment field (${fieldName}) into a highly polite, constructive, professional, and empathetic tone, while retaining all technical correctness and details.
53
+ Avoid sounding accusatory, patronizing, or overly verbose. Keep the polished output in standard markdown format, directly and only providing the polished content. Do not add introductory or concluding remarks.
54
+
55
+ Original content to polish:
56
+ ${text}`;
57
+
58
+ try {
59
+ if (provider === 'gemini') {
60
+ const url = `https://generativelanguage.googleapis.com/v1beta/models/${model}:generateContent?key=${apiKey}`;
61
+ const response = await fetch(url, {
62
+ method: 'POST',
63
+ headers: { 'Content-Type': 'application/json' },
64
+ body: JSON.stringify({
65
+ contents: [{ parts: [{ text: prompt }] }],
66
+ }),
67
+ });
68
+
69
+ if (!response.ok) {
70
+ const errorText = await response.text();
71
+ throw new Error(
72
+ `Gemini API error: ${response.statusText} (${errorText})`
73
+ );
74
+ }
75
+
76
+ const data = await response.json();
77
+ const resultText = data.candidates?.[0]?.content?.parts?.[0]?.text;
78
+ if (resultText) return resultText.trim();
79
+ } else if (provider === 'openai' || provider === 'groq') {
80
+ const url =
81
+ provider === 'openai'
82
+ ? 'https://api.openai.com/v1/chat/completions'
83
+ : 'https://api.groq.com/openai/v1/chat/completions';
84
+
85
+ const response = await fetch(url, {
86
+ method: 'POST',
87
+ headers: {
88
+ 'Content-Type': 'application/json',
89
+ Authorization: `Bearer ${apiKey}`,
90
+ },
91
+ body: JSON.stringify({
92
+ model: model,
93
+ messages: [{ role: 'user', content: prompt }],
94
+ temperature: 0.2,
95
+ }),
96
+ });
97
+
98
+ if (!response.ok) {
99
+ const errorText = await response.text();
100
+ throw new Error(
101
+ `${provider.toUpperCase()} API error: ${response.statusText} (${errorText})`
102
+ );
103
+ }
104
+
105
+ const data = await response.json();
106
+ const resultText = data.choices?.[0]?.message?.content;
107
+ if (resultText) return resultText.trim();
108
+ }
109
+ } catch (error) {
110
+ console.warn(
111
+ chalk.yellow(
112
+ `\n⚠️ AI Polishing failed: ${error.message}. Falling back to raw text.`
113
+ )
114
+ );
115
+ }
116
+
117
+ return text;
118
+ }
package/src/batch.js ADDED
@@ -0,0 +1,206 @@
1
+ import inquirer from 'inquirer';
2
+ import chalk from 'chalk';
3
+ import clipboardy from 'clipboardy';
4
+ import fs from 'fs';
5
+ import path from 'path';
6
+ import { getReviewData } from './prompts.js';
7
+ import { generateMarkdown } from './formatter.js';
8
+ import { polishText } from './ai.js';
9
+ import { detectGithubRepo, submitPRReview } from './github.js';
10
+
11
+ /**
12
+ * Runs the interactive Batch Review Mode.
13
+ *
14
+ * @param {object} options CLI parsed options
15
+ * @param {object} config Local configurations
16
+ */
17
+ export async function runBatchReview(config = {}) {
18
+ console.log(chalk.cyan('\n🚀 PRSmith - Starting Batch Review Mode\n'));
19
+
20
+ const comments = [];
21
+ let addMore = true;
22
+ let counter = 1;
23
+
24
+ while (addMore) {
25
+ console.log(chalk.blue.bold(`\n📝 Comment #${counter}:`));
26
+
27
+ // Get comment data interactively
28
+ // We pass empty options to ensure it prompts for everything
29
+ const data = await getReviewData({}, config);
30
+
31
+ // Apply AI polish if requested
32
+ if (data.ai) {
33
+ console.log(chalk.yellow('\n✨ Polishing with AI...'));
34
+ data.issue = await polishText(data.issue, 'Problem Description', config);
35
+ data.fix = await polishText(data.fix, 'Suggested Fix', config);
36
+ }
37
+
38
+ // Generate markdown for this individual comment
39
+ const commentMarkdown = generateMarkdown(data, config);
40
+
41
+ // Store in our review list
42
+ comments.push({
43
+ path: data.path || null,
44
+ line: data.line || null,
45
+ body: commentMarkdown,
46
+ title: data.title,
47
+ severity: data.severity,
48
+ });
49
+
50
+ console.log(chalk.green(`\n✅ Comment #${counter} added to batch!`));
51
+
52
+ const response = await inquirer.prompt([
53
+ {
54
+ type: 'confirm',
55
+ name: 'more',
56
+ message: 'Would you like to add another comment to this review batch?',
57
+ default: false,
58
+ },
59
+ ]);
60
+
61
+ addMore = response.more;
62
+ counter++;
63
+ }
64
+
65
+ // Generate unified review markdown
66
+ const unifiedMarkdown = generateUnifiedReport(comments);
67
+
68
+ console.log(chalk.cyan.bold('\n📦 Batch Review Compilation Successful!'));
69
+ console.log(chalk.gray(`Total Comments: ${comments.length}\n`));
70
+
71
+ // Options menu for export
72
+ let menuActive = true;
73
+ while (menuActive) {
74
+ const { action } = await inquirer.prompt([
75
+ {
76
+ type: 'select',
77
+ name: 'action',
78
+ message: 'What would you like to do with this batch review?',
79
+ choices: [
80
+ '📋 Copy entire review to clipboard',
81
+ '💾 Save review to a markdown file',
82
+ '🚀 Post review directly to a GitHub Pull Request',
83
+ '❌ Exit Batch Review Mode',
84
+ ],
85
+ },
86
+ ]);
87
+
88
+ if (action.includes('Copy')) {
89
+ try {
90
+ clipboardy.writeSync(unifiedMarkdown);
91
+ console.log(
92
+ chalk.green('\n✅ Full review successfully copied to clipboard!\n')
93
+ );
94
+ } catch (err) {
95
+ console.error(chalk.red(`Failed to copy to clipboard: ${err.message}`));
96
+ }
97
+ } else if (action.includes('Save')) {
98
+ const { outFile } = await inquirer.prompt([
99
+ {
100
+ type: 'input',
101
+ name: 'outFile',
102
+ message: 'Enter the file name to save (e.g. pr-review.md):',
103
+ default: 'pr-review.md',
104
+ },
105
+ ]);
106
+ try {
107
+ const outPath = path.resolve(process.cwd(), outFile);
108
+ fs.writeFileSync(outPath, unifiedMarkdown, 'utf8');
109
+ console.log(
110
+ chalk.green(`\n✅ Review successfully saved to ${outPath}\n`)
111
+ );
112
+ } catch (err) {
113
+ console.error(chalk.red(`Failed to write file: ${err.message}`));
114
+ }
115
+ } else if (action.includes('Post')) {
116
+ const repo = config.githubRepo || detectGithubRepo();
117
+ const defaultOwner = repo ? repo.owner : '';
118
+ const defaultRepo = repo ? repo.repo : '';
119
+
120
+ const prDetails = await inquirer.prompt([
121
+ {
122
+ type: 'input',
123
+ name: 'owner',
124
+ message: 'GitHub Repository Owner:',
125
+ default: defaultOwner,
126
+ validate: (val) => val.trim() !== '' || 'Owner is required.',
127
+ },
128
+ {
129
+ type: 'input',
130
+ name: 'repoName',
131
+ message: 'GitHub Repository Name:',
132
+ default: defaultRepo,
133
+ validate: (val) =>
134
+ val.trim() !== '' || 'Repository name is required.',
135
+ },
136
+ {
137
+ type: 'input',
138
+ name: 'prNumber',
139
+ message: 'Pull Request Number:',
140
+ validate: (val) =>
141
+ /^\d+$/.test(val.trim()) || 'PR Number must be a valid integer.',
142
+ },
143
+ ]);
144
+
145
+ console.log(chalk.yellow('\n🚀 Submitting review comments to GitHub...'));
146
+ try {
147
+ // Construct the review body listing general comments (those without path/line)
148
+ const generalComments = comments.filter((c) => !c.path || !c.line);
149
+ let reviewBody = '### 🛠️ PRSmith Unified Code Review Session\n\n';
150
+
151
+ if (generalComments.length > 0) {
152
+ reviewBody += '#### General Comments:\n\n';
153
+ generalComments.forEach((c) => {
154
+ reviewBody += `---\n\n${c.body}\n`;
155
+ });
156
+ } else {
157
+ reviewBody +=
158
+ 'All review comments have been applied inline to their respective lines. Check the files list below!';
159
+ }
160
+
161
+ const inlineComments = comments.filter((c) => c.path && c.line);
162
+
163
+ const url = await submitPRReview(
164
+ prDetails.owner,
165
+ prDetails.repoName,
166
+ prDetails.prNumber,
167
+ reviewBody,
168
+ inlineComments,
169
+ config
170
+ );
171
+
172
+ console.log(
173
+ chalk.green(`\n🎉 Native Pull Request Review successfully submitted!`)
174
+ );
175
+ console.log(chalk.green(`🔗 Review link: ${url}\n`));
176
+ } catch (err) {
177
+ console.error(
178
+ chalk.red(`\n❌ Failed to submit PR Review: ${err.message}\n`)
179
+ );
180
+ }
181
+ } else {
182
+ menuActive = false;
183
+ console.log(chalk.yellow('\nExiting Batch Review Mode. Bye!\n'));
184
+ }
185
+ }
186
+ }
187
+
188
+ /**
189
+ * Combines all individual review comments into a single unified markdown report.
190
+ *
191
+ * @param {Array<object>} comments The compiled list of comments
192
+ * @returns {string} The unified markdown text
193
+ */
194
+ function generateUnifiedReport(comments) {
195
+ let report = `# PRSmith Unified Code Review Report\n\n`;
196
+ report += `This review contains **${comments.length}** compiled review comment${comments.length > 1 ? 's' : ''}.\n\n`;
197
+
198
+ comments.forEach((c, idx) => {
199
+ report += `## Comment #${idx + 1}\n\n`;
200
+ report += c.body;
201
+ report += `\n---\n\n`;
202
+ });
203
+
204
+ report += `*Generated automatically with [PRSmith](https://github.com/tarunyaprogrammer/PRSmith).*`;
205
+ return report;
206
+ }
package/src/config.js CHANGED
@@ -5,7 +5,7 @@ import os from 'os';
5
5
  export function loadConfig() {
6
6
  const configPaths = [
7
7
  path.join(os.homedir(), '.prsmith.json'),
8
- path.join(process.cwd(), '.prsmith.json')
8
+ path.join(process.cwd(), '.prsmith.json'),
9
9
  ];
10
10
 
11
11
  let mergedConfig = { templates: {} };
@@ -16,10 +16,15 @@ export function loadConfig() {
16
16
  const fileContent = fs.readFileSync(configPath, 'utf-8');
17
17
  const parsed = JSON.parse(fileContent);
18
18
  if (parsed.templates) {
19
- mergedConfig.templates = { ...mergedConfig.templates, ...parsed.templates };
19
+ mergedConfig.templates = {
20
+ ...mergedConfig.templates,
21
+ ...parsed.templates,
22
+ };
20
23
  }
21
24
  } catch (err) {
22
- console.warn(`Warning: Could not parse config at ${configPath} - ${err.message}`);
25
+ console.warn(
26
+ `Warning: Could not parse config at ${configPath} - ${err.message}`
27
+ );
23
28
  }
24
29
  }
25
30
  }
package/src/formatter.js CHANGED
@@ -1,21 +1,52 @@
1
- import { templates as defaultTemplates } from "./templates.js";
1
+ import { templates as defaultTemplates } from './templates.js';
2
+ import { detectGithubRepo } from './github.js';
2
3
 
3
4
  export function generateMarkdown(data, config = {}) {
4
5
  const mergedTemplates = { ...defaultTemplates, ...(config.templates || {}) };
5
6
  const intro =
6
7
  mergedTemplates[data.severity] ||
7
- "The current implementation requires attention.";
8
+ 'The current implementation requires attention.';
8
9
 
9
- return `### ${data.severity}: ${data.title}
10
+ let markdown = `### ${data.severity}: ${data.title}\n\n${intro}\n\n`;
10
11
 
11
- ${intro}
12
+ // 1. Format File & Line Context
13
+ if (data.path) {
14
+ const repo = config.githubRepo || detectGithubRepo();
15
+ const lineStr = data.line ? `:${data.line}` : '';
12
16
 
13
- **Problem**
17
+ if (repo && repo.owner && repo.repo) {
18
+ const branch = config.defaultBranch || 'main';
19
+ // Generate direct deep link to GitHub
20
+ const lineAnchor = data.line ? `#L${data.line.replace(/-.*/, '')}` : ''; // Take start of line range
21
+ const fileUrl = `https://github.com/${repo.owner}/${repo.repo}/blob/${branch}/${data.path}${lineAnchor}`;
22
+ markdown += `📁 **File:** [\`${data.path}${lineStr}\`](${fileUrl})\n\n`;
23
+ } else {
24
+ markdown += `📁 **File:** \`${data.path}${lineStr}\`\n\n`;
25
+ }
26
+ }
14
27
 
15
- ${data.issue}
28
+ // 2. Format Problem/Issue
29
+ markdown += `**Problem**\n\n${data.issue}\n\n`;
16
30
 
17
- **Suggested Fix**
31
+ // 3. Format Suggested Fix
32
+ markdown += `**Suggested Fix**\n\n${data.fix}\n\n`;
18
33
 
19
- ${data.fix}
20
- `;
34
+ // 4. Format Before / After Code Comparison
35
+ if (data.before || data.after) {
36
+ markdown += `<details>\n<summary>🔍 View Code Diff / Comparison</summary>\n\n`;
37
+
38
+ const lang = data.lang || 'javascript';
39
+
40
+ if (data.before && data.before.trim() !== '') {
41
+ markdown += `**Before:**\n\`\`\`${lang}\n${data.before.trim()}\n\`\`\`\n\n`;
42
+ }
43
+
44
+ if (data.after && data.after.trim() !== '') {
45
+ markdown += `**After:**\n\`\`\`${lang}\n${data.after.trim()}\n\`\`\`\n\n`;
46
+ }
47
+
48
+ markdown += `</details>\n`;
49
+ }
50
+
51
+ return markdown;
21
52
  }
package/src/github.js ADDED
@@ -0,0 +1,153 @@
1
+ import fs from 'fs';
2
+ import path from 'path';
3
+
4
+ /**
5
+ * Attempts to automatically detect the GitHub owner and repository name
6
+ * from the local .git/config file.
7
+ *
8
+ * @returns {{ owner: string, repo: string } | null} The detected repository or null
9
+ */
10
+ export function detectGithubRepo() {
11
+ try {
12
+ const gitConfigPath = path.join(process.cwd(), '.git', 'config');
13
+ if (!fs.existsSync(gitConfigPath)) {
14
+ return null;
15
+ }
16
+
17
+ const content = fs.readFileSync(gitConfigPath, 'utf8');
18
+ // Use RegExp constructor to avoid escaping forward slashes which triggers ESLint warnings
19
+ const regex = new RegExp(
20
+ 'url\\s*=\\s*(?:https://github\\.com/|git@github\\.com:)([^/\\s]+)/([^./\\s]+)(?:\\.git)?',
21
+ 'i'
22
+ );
23
+ const match = content.match(regex);
24
+
25
+ if (match) {
26
+ return {
27
+ owner: match[1].trim(),
28
+ repo: match[2].trim(),
29
+ };
30
+ }
31
+ } catch {
32
+ // Fail silently, return null
33
+ }
34
+ return null;
35
+ }
36
+
37
+ /**
38
+ * Posts a markdown comment directly to a GitHub Pull Request discussion thread.
39
+ *
40
+ * @param {string} owner The repo owner
41
+ * @param {string} repo The repo name
42
+ * @param {number|string} prNumber The pull request number
43
+ * @param {string} markdown The markdown body to post
44
+ * @param {object} config Local configurations for tokens
45
+ * @returns {Promise<string>} The API response comment URL on success
46
+ */
47
+ export async function postPRComment(
48
+ owner,
49
+ repo,
50
+ prNumber,
51
+ markdown,
52
+ config = {}
53
+ ) {
54
+ const token = config.githubToken || process.env.GITHUB_TOKEN;
55
+ if (!token) {
56
+ throw new Error(
57
+ "GitHub Personal Access Token not found. Set 'githubToken' in .prsmith.json or the GITHUB_TOKEN environment variable."
58
+ );
59
+ }
60
+
61
+ const url = `https://api.github.com/repos/${owner}/${repo}/issues/${prNumber}/comments`;
62
+
63
+ const response = await fetch(url, {
64
+ method: 'POST',
65
+ headers: {
66
+ Authorization: `Bearer ${token}`,
67
+ 'Content-Type': 'application/json',
68
+ Accept: 'application/vnd.github+json',
69
+ 'X-GitHub-Api-Version': '2022-11-28',
70
+ 'User-Agent': 'PRSmith-CLI',
71
+ },
72
+ body: JSON.stringify({ body: markdown }),
73
+ });
74
+
75
+ if (!response.ok) {
76
+ const errorText = await response.text();
77
+ throw new Error(`GitHub API error: ${response.statusText} (${errorText})`);
78
+ }
79
+
80
+ const data = await response.json();
81
+ return data.html_url;
82
+ }
83
+
84
+ /**
85
+ * Submits a native GitHub PR Review session containing multiple inline and/or general comments.
86
+ *
87
+ * @param {string} owner The repo owner
88
+ * @param {string} repo The repo name
89
+ * @param {number|string} prNumber The pull request number
90
+ * @param {string} body The main review description/summary
91
+ * @param {Array<{path: string, line: number, body: string}>} comments Inline comments list
92
+ * @param {object} config Local configurations for tokens
93
+ * @returns {Promise<string>} The API response review URL or confirmation message
94
+ */
95
+ export async function submitPRReview(
96
+ owner,
97
+ repo,
98
+ prNumber,
99
+ body,
100
+ comments = [],
101
+ config = {}
102
+ ) {
103
+ const token = config.githubToken || process.env.GITHUB_TOKEN;
104
+ if (!token) {
105
+ throw new Error(
106
+ "GitHub Personal Access Token not found. Set 'githubToken' in .prsmith.json or the GITHUB_TOKEN environment variable."
107
+ );
108
+ }
109
+
110
+ const url = `https://api.github.com/repos/${owner}/${repo}/pulls/${prNumber}/reviews`;
111
+
112
+ // Format individual inline comments for the reviews API
113
+ // Filter out any comments that lack file path/line
114
+ const apiComments = comments
115
+ .filter((c) => c.path && c.line)
116
+ .map((c) => ({
117
+ path: c.path,
118
+ line: parseInt(c.line, 10),
119
+ body: c.body,
120
+ side: 'RIGHT', // Reviewing the changes in the incoming PR
121
+ }));
122
+
123
+ const payload = {
124
+ body: body,
125
+ event: 'COMMENT',
126
+ };
127
+
128
+ if (apiComments.length > 0) {
129
+ payload.comments = apiComments;
130
+ }
131
+
132
+ const response = await fetch(url, {
133
+ method: 'POST',
134
+ headers: {
135
+ Authorization: `Bearer ${token}`,
136
+ 'Content-Type': 'application/json',
137
+ Accept: 'application/vnd.github+json',
138
+ 'X-GitHub-Api-Version': '2022-11-28',
139
+ 'User-Agent': 'PRSmith-CLI',
140
+ },
141
+ body: JSON.stringify(payload),
142
+ });
143
+
144
+ if (!response.ok) {
145
+ const errorText = await response.text();
146
+ throw new Error(`GitHub API error: ${response.statusText} (${errorText})`);
147
+ }
148
+
149
+ const data = await response.json();
150
+ return (
151
+ data.html_url || `https://github.com/${owner}/${repo}/pull/${prNumber}`
152
+ );
153
+ }