prsmith 1.1.0 → 2.1.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/CHANGELOG.md +15 -0
- package/README.md +124 -43
- package/bin/cli.js +196 -31
- package/eslint.config.js +8 -6
- package/package.json +1 -1
- package/src/ai.js +118 -0
- package/src/batch.js +219 -0
- package/src/config.js +32 -3
- package/src/formatter.js +40 -9
- package/src/github.js +153 -0
- package/src/prompts.js +118 -21
- package/src/templates.js +5 -9
- package/tests/features.test.js +253 -0
- package/tests/formatter.test.js +32 -28
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,219 @@
|
|
|
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 token = config.githubToken || process.env.GITHUB_TOKEN;
|
|
117
|
+
if (!token) {
|
|
118
|
+
console.log(
|
|
119
|
+
chalk.red('\n❌ Error: GitHub Personal Access Token not found!')
|
|
120
|
+
);
|
|
121
|
+
console.log(
|
|
122
|
+
chalk.yellow(
|
|
123
|
+
"Please configure 'githubToken' in your local ~/.prsmith.json file or set the GITHUB_TOKEN environment variable.\n"
|
|
124
|
+
)
|
|
125
|
+
);
|
|
126
|
+
continue;
|
|
127
|
+
}
|
|
128
|
+
|
|
129
|
+
const repo = config.githubRepo || detectGithubRepo();
|
|
130
|
+
const defaultOwner = repo ? repo.owner : '';
|
|
131
|
+
const defaultRepo = repo ? repo.repo : '';
|
|
132
|
+
|
|
133
|
+
const prDetails = await inquirer.prompt([
|
|
134
|
+
{
|
|
135
|
+
type: 'input',
|
|
136
|
+
name: 'owner',
|
|
137
|
+
message: 'GitHub Repository Owner:',
|
|
138
|
+
default: defaultOwner,
|
|
139
|
+
validate: (val) => val.trim() !== '' || 'Owner is required.',
|
|
140
|
+
},
|
|
141
|
+
{
|
|
142
|
+
type: 'input',
|
|
143
|
+
name: 'repoName',
|
|
144
|
+
message: 'GitHub Repository Name:',
|
|
145
|
+
default: defaultRepo,
|
|
146
|
+
validate: (val) =>
|
|
147
|
+
val.trim() !== '' || 'Repository name is required.',
|
|
148
|
+
},
|
|
149
|
+
{
|
|
150
|
+
type: 'input',
|
|
151
|
+
name: 'prNumber',
|
|
152
|
+
message: 'Pull Request Number:',
|
|
153
|
+
validate: (val) =>
|
|
154
|
+
/^\d+$/.test(val.trim()) || 'PR Number must be a valid integer.',
|
|
155
|
+
},
|
|
156
|
+
]);
|
|
157
|
+
|
|
158
|
+
console.log(chalk.yellow('\n🚀 Submitting review comments to GitHub...'));
|
|
159
|
+
try {
|
|
160
|
+
// Construct the review body listing general comments (those without path/line)
|
|
161
|
+
const generalComments = comments.filter((c) => !c.path || !c.line);
|
|
162
|
+
let reviewBody = '### 🛠️ PRSmith Unified Code Review Session\n\n';
|
|
163
|
+
|
|
164
|
+
if (generalComments.length > 0) {
|
|
165
|
+
reviewBody += '#### General Comments:\n\n';
|
|
166
|
+
generalComments.forEach((c) => {
|
|
167
|
+
reviewBody += `---\n\n${c.body}\n`;
|
|
168
|
+
});
|
|
169
|
+
} else {
|
|
170
|
+
reviewBody +=
|
|
171
|
+
'All review comments have been applied inline to their respective lines. Check the files list below!';
|
|
172
|
+
}
|
|
173
|
+
|
|
174
|
+
const inlineComments = comments.filter((c) => c.path && c.line);
|
|
175
|
+
|
|
176
|
+
const url = await submitPRReview(
|
|
177
|
+
prDetails.owner,
|
|
178
|
+
prDetails.repoName,
|
|
179
|
+
prDetails.prNumber,
|
|
180
|
+
reviewBody,
|
|
181
|
+
inlineComments,
|
|
182
|
+
config
|
|
183
|
+
);
|
|
184
|
+
|
|
185
|
+
console.log(
|
|
186
|
+
chalk.green(`\n🎉 Native Pull Request Review successfully submitted!`)
|
|
187
|
+
);
|
|
188
|
+
console.log(chalk.green(`🔗 Review link: ${url}\n`));
|
|
189
|
+
} catch (err) {
|
|
190
|
+
console.error(
|
|
191
|
+
chalk.red(`\n❌ Failed to submit PR Review: ${err.message}\n`)
|
|
192
|
+
);
|
|
193
|
+
}
|
|
194
|
+
} else {
|
|
195
|
+
menuActive = false;
|
|
196
|
+
console.log(chalk.yellow('\nExiting Batch Review Mode. Bye!\n'));
|
|
197
|
+
}
|
|
198
|
+
}
|
|
199
|
+
}
|
|
200
|
+
|
|
201
|
+
/**
|
|
202
|
+
* Combines all individual review comments into a single unified markdown report.
|
|
203
|
+
*
|
|
204
|
+
* @param {Array<object>} comments The compiled list of comments
|
|
205
|
+
* @returns {string} The unified markdown text
|
|
206
|
+
*/
|
|
207
|
+
function generateUnifiedReport(comments) {
|
|
208
|
+
let report = `# PRSmith Unified Code Review Report\n\n`;
|
|
209
|
+
report += `This review contains **${comments.length}** compiled review comment${comments.length > 1 ? 's' : ''}.\n\n`;
|
|
210
|
+
|
|
211
|
+
comments.forEach((c, idx) => {
|
|
212
|
+
report += `## Comment #${idx + 1}\n\n`;
|
|
213
|
+
report += c.body;
|
|
214
|
+
report += `\n---\n\n`;
|
|
215
|
+
});
|
|
216
|
+
|
|
217
|
+
report += `*Generated automatically with [PRSmith](https://github.com/tarunyaprogrammer/PRSmith).*`;
|
|
218
|
+
return report;
|
|
219
|
+
}
|
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: {} };
|
|
@@ -15,14 +15,43 @@ export function loadConfig() {
|
|
|
15
15
|
try {
|
|
16
16
|
const fileContent = fs.readFileSync(configPath, 'utf-8');
|
|
17
17
|
const parsed = JSON.parse(fileContent);
|
|
18
|
+
|
|
18
19
|
if (parsed.templates) {
|
|
19
|
-
mergedConfig.templates = {
|
|
20
|
+
mergedConfig.templates = {
|
|
21
|
+
...mergedConfig.templates,
|
|
22
|
+
...parsed.templates,
|
|
23
|
+
};
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
if (parsed.editor) {
|
|
27
|
+
mergedConfig.editor = parsed.editor;
|
|
20
28
|
}
|
|
29
|
+
|
|
30
|
+
// Also merge any other config properties (tokens, providers, etc.)
|
|
31
|
+
Object.keys(parsed).forEach((key) => {
|
|
32
|
+
if (key !== 'templates') {
|
|
33
|
+
mergedConfig[key] = parsed[key];
|
|
34
|
+
}
|
|
35
|
+
});
|
|
21
36
|
} catch (err) {
|
|
22
|
-
console.warn(
|
|
37
|
+
console.warn(
|
|
38
|
+
`Warning: Could not parse config at ${configPath} - ${err.message}`
|
|
39
|
+
);
|
|
23
40
|
}
|
|
24
41
|
}
|
|
25
42
|
}
|
|
26
43
|
|
|
44
|
+
// Senior Guardrail: Prevent the general public from getting stuck in Vim (no instructions)
|
|
45
|
+
// If the user has not set EDITOR/VISUAL globally, and did not set 'editor' in .prsmith.json,
|
|
46
|
+
// we default the spawned editor in our process to 'nano' on macOS/Linux.
|
|
47
|
+
// 'nano' has friendly, visible exit instructions at the bottom of the terminal by default!
|
|
48
|
+
if (mergedConfig.editor) {
|
|
49
|
+
process.env.EDITOR = mergedConfig.editor;
|
|
50
|
+
} else if (!process.env.EDITOR && !process.env.VISUAL) {
|
|
51
|
+
if (process.platform !== 'win32') {
|
|
52
|
+
process.env.EDITOR = 'nano';
|
|
53
|
+
}
|
|
54
|
+
}
|
|
55
|
+
|
|
27
56
|
return mergedConfig;
|
|
28
57
|
}
|
package/src/formatter.js
CHANGED
|
@@ -1,21 +1,52 @@
|
|
|
1
|
-
import { templates as defaultTemplates } from
|
|
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
|
-
|
|
8
|
+
'The current implementation requires attention.';
|
|
8
9
|
|
|
9
|
-
|
|
10
|
+
let markdown = `### ${data.severity}: ${data.title}\n\n${intro}\n\n`;
|
|
10
11
|
|
|
11
|
-
|
|
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
|
-
|
|
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
|
-
|
|
28
|
+
// 2. Format Problem/Issue
|
|
29
|
+
markdown += `**Problem**\n\n${data.issue}\n\n`;
|
|
16
30
|
|
|
17
|
-
|
|
31
|
+
// 3. Format Suggested Fix
|
|
32
|
+
markdown += `**Suggested Fix**\n\n${data.fix}\n\n`;
|
|
18
33
|
|
|
19
|
-
|
|
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
|
+
}
|