@probelabs/visor 0.1.17 → 0.1.18
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/README.md +78 -0
- package/defaults/.visor.yaml +53 -1
- package/dist/ai-review-service.js +2 -2
- package/dist/ai-review-service.js.map +1 -1
- package/dist/check-execution-engine.d.ts +32 -1
- package/dist/check-execution-engine.d.ts.map +1 -1
- package/dist/check-execution-engine.js +289 -19
- package/dist/check-execution-engine.js.map +1 -1
- package/dist/config.d.ts.map +1 -1
- package/dist/config.js +18 -3
- package/dist/config.js.map +1 -1
- package/dist/event-mapper.d.ts.map +1 -1
- package/dist/event-mapper.js +1 -1
- package/dist/event-mapper.js.map +1 -1
- package/dist/failure-condition-evaluator.d.ts +8 -0
- package/dist/failure-condition-evaluator.d.ts.map +1 -1
- package/dist/failure-condition-evaluator.js +25 -3
- package/dist/failure-condition-evaluator.js.map +1 -1
- package/dist/github-check-service.d.ts.map +1 -1
- package/dist/github-check-service.js +26 -39
- package/dist/github-check-service.js.map +1 -1
- package/dist/index.d.ts.map +1 -1
- package/dist/index.js +331 -690
- package/dist/index.js.map +1 -1
- package/dist/licenses.txt +2300 -0
- package/dist/output/code-review/schema.json +84 -0
- package/dist/output/code-review/template.liquid +32 -0
- package/dist/output/plain/schema.json +14 -0
- package/dist/output/plain/template.liquid +1 -0
- package/dist/output-formatters.d.ts.map +1 -1
- package/dist/output-formatters.js +14 -14
- package/dist/output-formatters.js.map +1 -1
- package/dist/pr-analyzer.d.ts +2 -1
- package/dist/pr-analyzer.d.ts.map +1 -1
- package/dist/pr-analyzer.js +2 -1
- package/dist/pr-analyzer.js.map +1 -1
- package/dist/providers/ai-check-provider.d.ts.map +1 -1
- package/dist/providers/ai-check-provider.js +4 -0
- package/dist/providers/ai-check-provider.js.map +1 -1
- package/dist/providers/check-provider-registry.js +2 -2
- package/dist/providers/check-provider-registry.js.map +1 -1
- package/dist/providers/check-provider.interface.d.ts +2 -0
- package/dist/providers/check-provider.interface.d.ts.map +1 -1
- package/dist/providers/check-provider.interface.js.map +1 -1
- package/dist/providers/index.d.ts +1 -1
- package/dist/providers/index.d.ts.map +1 -1
- package/dist/providers/index.js +3 -3
- package/dist/providers/index.js.map +1 -1
- package/dist/providers/noop-check-provider.d.ts +25 -0
- package/dist/providers/noop-check-provider.d.ts.map +1 -0
- package/dist/providers/noop-check-provider.js +55 -0
- package/dist/providers/noop-check-provider.js.map +1 -0
- package/dist/providers/tool-check-provider.d.ts +4 -1
- package/dist/providers/tool-check-provider.d.ts.map +1 -1
- package/dist/providers/tool-check-provider.js +64 -15
- package/dist/providers/tool-check-provider.js.map +1 -1
- package/dist/reviewer.d.ts +19 -37
- package/dist/reviewer.d.ts.map +1 -1
- package/dist/reviewer.js +83 -593
- package/dist/reviewer.js.map +1 -1
- package/dist/tiktoken_bg.wasm +0 -0
- package/dist/types/config.d.ts +52 -5
- package/dist/types/config.d.ts.map +1 -1
- package/package.json +3 -3
- package/dist/providers/script-check-provider.d.ts +0 -23
- package/dist/providers/script-check-provider.d.ts.map +0 -1
- package/dist/providers/script-check-provider.js +0 -163
- package/dist/providers/script-check-provider.js.map +0 -1
package/dist/reviewer.js
CHANGED
|
@@ -32,27 +32,55 @@ var __importStar = (this && this.__importStar) || (function () {
|
|
|
32
32
|
return result;
|
|
33
33
|
};
|
|
34
34
|
})();
|
|
35
|
-
var __importDefault = (this && this.__importDefault) || function (mod) {
|
|
36
|
-
return (mod && mod.__esModule) ? mod : { "default": mod };
|
|
37
|
-
};
|
|
38
35
|
Object.defineProperty(exports, "__esModule", { value: true });
|
|
39
36
|
exports.PRReviewer = void 0;
|
|
37
|
+
exports.convertReviewSummaryToGroupedResults = convertReviewSummaryToGroupedResults;
|
|
40
38
|
exports.calculateTotalIssues = calculateTotalIssues;
|
|
41
39
|
exports.calculateCriticalIssues = calculateCriticalIssues;
|
|
42
40
|
exports.convertIssuesToComments = convertIssuesToComments;
|
|
43
41
|
const github_comments_1 = require("./github-comments");
|
|
44
42
|
const ai_review_service_1 = require("./ai-review-service");
|
|
45
|
-
|
|
46
|
-
|
|
47
|
-
|
|
48
|
-
|
|
49
|
-
|
|
43
|
+
// Test utility function - Convert old ReviewSummary to new GroupedCheckResults format
|
|
44
|
+
// This is for backward compatibility with tests only
|
|
45
|
+
function convertReviewSummaryToGroupedResults(reviewSummary, checkName = 'test-check', groupName = 'default') {
|
|
46
|
+
// Create a simple content string from issues and suggestions
|
|
47
|
+
let content = '';
|
|
48
|
+
if (reviewSummary.issues && reviewSummary.issues.length > 0) {
|
|
49
|
+
content += `## Issues Found (${reviewSummary.issues.length})\n\n`;
|
|
50
|
+
reviewSummary.issues.forEach(issue => {
|
|
51
|
+
content += `- **${issue.severity.toUpperCase()}**: ${issue.message} (${issue.file}:${issue.line})\n`;
|
|
52
|
+
});
|
|
53
|
+
content += '\n';
|
|
54
|
+
}
|
|
55
|
+
if (reviewSummary.suggestions && reviewSummary.suggestions.length > 0) {
|
|
56
|
+
content += `## Suggestions\n\n`;
|
|
57
|
+
reviewSummary.suggestions.forEach(suggestion => {
|
|
58
|
+
content += `- ${suggestion}\n`;
|
|
59
|
+
});
|
|
60
|
+
}
|
|
61
|
+
if (!content) {
|
|
62
|
+
content = 'No issues found.';
|
|
63
|
+
}
|
|
64
|
+
const checkResult = {
|
|
65
|
+
checkName,
|
|
66
|
+
content: content.trim(),
|
|
67
|
+
group: groupName,
|
|
68
|
+
debug: reviewSummary.debug,
|
|
69
|
+
issues: reviewSummary.issues, // Include structured issues
|
|
70
|
+
};
|
|
71
|
+
const groupedResults = {};
|
|
72
|
+
groupedResults[groupName] = [checkResult];
|
|
73
|
+
return groupedResults;
|
|
74
|
+
}
|
|
75
|
+
// Helper functions for GitHub checks - ONLY for structured schemas that have issues
|
|
76
|
+
// These are the ONLY acceptable hardcoded schema dependencies, and only for GitHub integration
|
|
50
77
|
function calculateTotalIssues(issues) {
|
|
51
|
-
return issues.length;
|
|
78
|
+
return (issues || []).length;
|
|
52
79
|
}
|
|
53
80
|
function calculateCriticalIssues(issues) {
|
|
54
|
-
return issues.filter(i => i.severity === 'critical').length;
|
|
81
|
+
return (issues || []).filter(i => i.severity === 'critical').length;
|
|
55
82
|
}
|
|
83
|
+
// Legacy converter - ONLY for GitHub integration compatibility
|
|
56
84
|
function convertIssuesToComments(issues) {
|
|
57
85
|
return issues.map(issue => ({
|
|
58
86
|
file: issue.file,
|
|
@@ -62,7 +90,7 @@ function convertIssuesToComments(issues) {
|
|
|
62
90
|
category: issue.category,
|
|
63
91
|
suggestion: issue.suggestion,
|
|
64
92
|
replacement: issue.replacement,
|
|
65
|
-
ruleId: issue.ruleId,
|
|
93
|
+
ruleId: issue.ruleId,
|
|
66
94
|
}));
|
|
67
95
|
}
|
|
68
96
|
class PRReviewer {
|
|
@@ -76,276 +104,52 @@ class PRReviewer {
|
|
|
76
104
|
}
|
|
77
105
|
async reviewPR(owner, repo, prNumber, prInfo, options = {}) {
|
|
78
106
|
const { debug = false, config, checks } = options;
|
|
79
|
-
// If we have a config and checks, use CheckExecutionEngine
|
|
80
107
|
if (config && checks && checks.length > 0) {
|
|
81
|
-
// Import CheckExecutionEngine dynamically to avoid circular dependencies
|
|
82
108
|
const { CheckExecutionEngine } = await Promise.resolve().then(() => __importStar(require('./check-execution-engine')));
|
|
83
109
|
const engine = new CheckExecutionEngine();
|
|
84
|
-
|
|
85
|
-
|
|
86
|
-
// Return all issues - no filtering needed
|
|
87
|
-
return reviewSummary;
|
|
110
|
+
const groupedResults = await engine.executeGroupedChecks(prInfo, checks, undefined, config, undefined, debug);
|
|
111
|
+
return groupedResults;
|
|
88
112
|
}
|
|
89
|
-
// No config provided - require configuration
|
|
90
113
|
throw new Error('No configuration provided. Please create a .visor.yaml file with check definitions. ' +
|
|
91
114
|
'Built-in prompts have been removed - all checks must be explicitly configured.');
|
|
92
115
|
}
|
|
93
|
-
async postReviewComment(owner, repo, prNumber,
|
|
94
|
-
//
|
|
95
|
-
const
|
|
96
|
-
|
|
97
|
-
if (Object.keys(issuesByGroup).length <= 1) {
|
|
98
|
-
const comment = await this.formatReviewCommentWithVisorFormat(summary, options, {
|
|
116
|
+
async postReviewComment(owner, repo, prNumber, groupedResults, options = {}) {
|
|
117
|
+
// Post separate comments for each group
|
|
118
|
+
for (const [groupName, checkResults] of Object.entries(groupedResults)) {
|
|
119
|
+
const comment = await this.formatGroupComment(checkResults, options, {
|
|
99
120
|
owner,
|
|
100
121
|
repo,
|
|
101
122
|
prNumber,
|
|
102
123
|
commitSha: options.commitSha,
|
|
103
124
|
});
|
|
104
|
-
|
|
105
|
-
|
|
106
|
-
|
|
107
|
-
const consistentCommentId = `${baseCommentId}-${groupName}`;
|
|
125
|
+
const commentId = options.commentId
|
|
126
|
+
? `${options.commentId}-${groupName}`
|
|
127
|
+
: `visor-review-${groupName}`;
|
|
108
128
|
await this.commentManager.updateOrCreateComment(owner, repo, prNumber, comment, {
|
|
109
|
-
commentId
|
|
110
|
-
triggeredBy: options.triggeredBy || 'unknown',
|
|
111
|
-
allowConcurrentUpdates: false,
|
|
112
|
-
commitSha: options.commitSha,
|
|
113
|
-
});
|
|
114
|
-
return;
|
|
115
|
-
}
|
|
116
|
-
// Sort groups by the earliest timestamp of issues in each group
|
|
117
|
-
// This ensures comments are posted in the order checks completed
|
|
118
|
-
const sortedGroups = Object.entries(issuesByGroup).sort(([_aName, aIssues], [_bName, bIssues]) => {
|
|
119
|
-
// Find the earliest timestamp in each group
|
|
120
|
-
const aEarliest = Math.min(...aIssues.map(i => i.timestamp || Infinity));
|
|
121
|
-
const bEarliest = Math.min(...bIssues.map(i => i.timestamp || Infinity));
|
|
122
|
-
return aEarliest - bEarliest;
|
|
123
|
-
});
|
|
124
|
-
// Create separate comments for each group
|
|
125
|
-
for (const [groupName, groupIssues] of sortedGroups) {
|
|
126
|
-
const groupSummary = {
|
|
127
|
-
...summary,
|
|
128
|
-
issues: groupIssues,
|
|
129
|
-
};
|
|
130
|
-
// Use group name in comment ID to create separate comments
|
|
131
|
-
// Always include the group name suffix to ensure uniqueness
|
|
132
|
-
const baseCommentId = options.commentId || 'visor-review';
|
|
133
|
-
const groupCommentId = `${baseCommentId}-${groupName}`;
|
|
134
|
-
const comment = await this.formatReviewCommentWithVisorFormat(groupSummary, options, {
|
|
135
|
-
owner,
|
|
136
|
-
repo,
|
|
137
|
-
prNumber,
|
|
138
|
-
commitSha: options.commitSha,
|
|
139
|
-
});
|
|
140
|
-
await this.commentManager.updateOrCreateComment(owner, repo, prNumber, comment, {
|
|
141
|
-
commentId: groupCommentId,
|
|
129
|
+
commentId,
|
|
142
130
|
triggeredBy: options.triggeredBy || 'unknown',
|
|
143
131
|
allowConcurrentUpdates: false,
|
|
144
132
|
commitSha: options.commitSha,
|
|
145
133
|
});
|
|
146
134
|
}
|
|
147
135
|
}
|
|
148
|
-
async
|
|
149
|
-
// const totalIssues = calculateTotalIssues(summary.issues); // TODO: Use this for summary stats
|
|
136
|
+
async formatGroupComment(checkResults, _options, _githubContext) {
|
|
150
137
|
let comment = '';
|
|
151
|
-
// Simple header - let templates handle the content logic
|
|
152
138
|
comment += `## 🔍 Code Analysis Results\n\n`;
|
|
153
|
-
//
|
|
154
|
-
const
|
|
155
|
-
|
|
156
|
-
|
|
157
|
-
|
|
158
|
-
|
|
139
|
+
// Simple concatenation of all check outputs in this group
|
|
140
|
+
const checkContents = checkResults
|
|
141
|
+
.map(result => result.content)
|
|
142
|
+
.filter(content => content.trim());
|
|
143
|
+
comment += checkContents.join('\n\n');
|
|
144
|
+
// Add debug info if any check has it
|
|
145
|
+
const debugInfo = checkResults.find(result => result.debug)?.debug;
|
|
146
|
+
if (debugInfo) {
|
|
147
|
+
comment += '\n\n' + this.formatDebugSection(debugInfo);
|
|
159
148
|
comment += '\n\n';
|
|
160
149
|
}
|
|
161
|
-
// Simple footer
|
|
162
150
|
comment += `\n---\n*Powered by [Visor](https://probelabs.com/visor) from [Probelabs](https://probelabs.com)*`;
|
|
163
151
|
return comment;
|
|
164
152
|
}
|
|
165
|
-
async renderWithSchemaTemplate(summary, githubContext) {
|
|
166
|
-
try {
|
|
167
|
-
// If we have plain text suggestions (no schema/failed schema), render them directly
|
|
168
|
-
if (summary.issues.length === 0 && summary.suggestions.length > 0) {
|
|
169
|
-
return summary.suggestions.join('\n\n');
|
|
170
|
-
}
|
|
171
|
-
// If we have issues, use templates to render them
|
|
172
|
-
if (summary.issues.length > 0) {
|
|
173
|
-
const issuesByCheck = this.groupIssuesByCheck(summary.issues);
|
|
174
|
-
const renderedSections = [];
|
|
175
|
-
for (const [checkName, checkIssues] of Object.entries(issuesByCheck)) {
|
|
176
|
-
const checkSchema = checkIssues[0]?.schema || 'code-review';
|
|
177
|
-
const customTemplate = checkIssues[0]?.template;
|
|
178
|
-
const renderedSection = await this.renderSingleCheckTemplate(checkName, checkIssues, checkSchema, customTemplate, githubContext);
|
|
179
|
-
renderedSections.push(renderedSection);
|
|
180
|
-
}
|
|
181
|
-
return renderedSections.join('\n\n');
|
|
182
|
-
}
|
|
183
|
-
// No issues, no suggestions - let template handle this
|
|
184
|
-
return await this.renderSingleCheckTemplate('review', [], 'code-review', undefined, githubContext);
|
|
185
|
-
}
|
|
186
|
-
catch (error) {
|
|
187
|
-
console.warn('Failed to render with schema-template system, falling back to old system:', error);
|
|
188
|
-
// Fallback to old system if template fails
|
|
189
|
-
const comments = convertIssuesToComments(summary.issues);
|
|
190
|
-
return this.formatIssuesTable(comments);
|
|
191
|
-
}
|
|
192
|
-
}
|
|
193
|
-
generateGitHubDiffHash(filePath) {
|
|
194
|
-
// GitHub uses SHA256 hash of the file path for diff anchors
|
|
195
|
-
return crypto.createHash('sha256').update(filePath).digest('hex');
|
|
196
|
-
}
|
|
197
|
-
enhanceIssuesWithGitHubLinks(issues, githubContext) {
|
|
198
|
-
if (!githubContext) {
|
|
199
|
-
return issues;
|
|
200
|
-
}
|
|
201
|
-
// Use commit SHA for permalink format that auto-expands
|
|
202
|
-
// If no commit SHA provided, fall back to PR files view
|
|
203
|
-
const baseUrl = githubContext.commitSha
|
|
204
|
-
? `https://github.com/${githubContext.owner}/${githubContext.repo}/blob/${githubContext.commitSha}`
|
|
205
|
-
: `https://github.com/${githubContext.owner}/${githubContext.repo}/pull/${githubContext.prNumber}/files`;
|
|
206
|
-
return issues.map(issue => ({
|
|
207
|
-
...issue,
|
|
208
|
-
githubUrl: githubContext.commitSha && issue.line
|
|
209
|
-
? `${baseUrl}/${issue.file}#L${issue.line}${issue.endLine && issue.endLine !== issue.line ? `-L${issue.endLine}` : ''}`
|
|
210
|
-
: baseUrl,
|
|
211
|
-
fileHash: this.generateGitHubDiffHash(issue.file),
|
|
212
|
-
}));
|
|
213
|
-
}
|
|
214
|
-
async renderSingleCheckTemplate(checkName, issues, schema, customTemplate, githubContext) {
|
|
215
|
-
const liquid = new liquidjs_1.Liquid({
|
|
216
|
-
// Configure Liquid to handle whitespace better
|
|
217
|
-
trimTagLeft: false, // Don't auto-trim left side of tags
|
|
218
|
-
trimTagRight: false, // Don't auto-trim right side of tags
|
|
219
|
-
trimOutputLeft: false, // Don't auto-trim left side of output
|
|
220
|
-
trimOutputRight: false, // Don't auto-trim right side of output
|
|
221
|
-
greedy: false, // Don't be greedy with whitespace trimming
|
|
222
|
-
});
|
|
223
|
-
// Load template content based on configuration
|
|
224
|
-
let templateContent;
|
|
225
|
-
if (customTemplate) {
|
|
226
|
-
templateContent = await this.loadCustomTemplate(customTemplate);
|
|
227
|
-
}
|
|
228
|
-
else {
|
|
229
|
-
// Sanitize schema name to prevent path traversal attacks
|
|
230
|
-
const sanitizedSchema = schema.replace(/[^a-zA-Z0-9-]/g, '');
|
|
231
|
-
if (!sanitizedSchema) {
|
|
232
|
-
throw new Error('Invalid schema name');
|
|
233
|
-
}
|
|
234
|
-
// Load the appropriate template based on schema
|
|
235
|
-
const templatePath = path_1.default.join(__dirname, `../output/${sanitizedSchema}/template.liquid`);
|
|
236
|
-
templateContent = await promises_1.default.readFile(templatePath, 'utf-8');
|
|
237
|
-
}
|
|
238
|
-
// Enhance issues with GitHub links if context is available
|
|
239
|
-
const enhancedIssues = this.enhanceIssuesWithGitHubLinks(issues, githubContext);
|
|
240
|
-
// Pass enhanced issues with GitHub links
|
|
241
|
-
const templateData = {
|
|
242
|
-
issues: enhancedIssues,
|
|
243
|
-
checkName: checkName,
|
|
244
|
-
github: githubContext,
|
|
245
|
-
};
|
|
246
|
-
// Render with Liquid template and trim extra whitespace at the start/end
|
|
247
|
-
const rendered = await liquid.parseAndRender(templateContent, templateData);
|
|
248
|
-
return rendered.trim();
|
|
249
|
-
}
|
|
250
|
-
groupIssuesByCheck(issues) {
|
|
251
|
-
const grouped = {};
|
|
252
|
-
for (const issue of issues) {
|
|
253
|
-
const checkName = this.extractCheckNameFromRuleId(issue.ruleId || 'uncategorized');
|
|
254
|
-
if (!grouped[checkName]) {
|
|
255
|
-
grouped[checkName] = [];
|
|
256
|
-
}
|
|
257
|
-
grouped[checkName].push(issue);
|
|
258
|
-
}
|
|
259
|
-
return grouped;
|
|
260
|
-
}
|
|
261
|
-
extractCheckNameFromRuleId(ruleId) {
|
|
262
|
-
if (ruleId && ruleId.includes('/')) {
|
|
263
|
-
return ruleId.split('/')[0];
|
|
264
|
-
}
|
|
265
|
-
return 'uncategorized';
|
|
266
|
-
}
|
|
267
|
-
groupIssuesByGroup(issues) {
|
|
268
|
-
const grouped = {};
|
|
269
|
-
for (const issue of issues) {
|
|
270
|
-
const groupName = issue.group || 'default';
|
|
271
|
-
if (!grouped[groupName]) {
|
|
272
|
-
grouped[groupName] = [];
|
|
273
|
-
}
|
|
274
|
-
grouped[groupName].push(issue);
|
|
275
|
-
}
|
|
276
|
-
return grouped;
|
|
277
|
-
}
|
|
278
|
-
formatReviewComment(summary, options) {
|
|
279
|
-
const { format = 'table' } = options;
|
|
280
|
-
// Calculate metrics from issues
|
|
281
|
-
const totalIssues = calculateTotalIssues(summary.issues);
|
|
282
|
-
const criticalIssues = calculateCriticalIssues(summary.issues);
|
|
283
|
-
const comments = convertIssuesToComments(summary.issues);
|
|
284
|
-
let comment = `## 🤖 AI Code Review\n\n`;
|
|
285
|
-
comment += `**Issues Found:** ${totalIssues} (${criticalIssues} critical)\n\n`;
|
|
286
|
-
if (summary.suggestions.length > 0) {
|
|
287
|
-
comment += `### 💡 Suggestions\n`;
|
|
288
|
-
for (const suggestion of summary.suggestions) {
|
|
289
|
-
comment += `- ${suggestion}\n`;
|
|
290
|
-
}
|
|
291
|
-
comment += '\n';
|
|
292
|
-
}
|
|
293
|
-
if (comments.length > 0) {
|
|
294
|
-
comment += `### 🔍 Code Issues\n`;
|
|
295
|
-
for (const reviewComment of comments) {
|
|
296
|
-
const emoji = reviewComment.severity === 'error'
|
|
297
|
-
? '❌'
|
|
298
|
-
: reviewComment.severity === 'warning'
|
|
299
|
-
? '⚠️'
|
|
300
|
-
: 'ℹ️';
|
|
301
|
-
comment += `${emoji} **${reviewComment.file}:${reviewComment.line}** (${reviewComment.category})\n`;
|
|
302
|
-
comment += ` ${reviewComment.message}\n\n`;
|
|
303
|
-
}
|
|
304
|
-
}
|
|
305
|
-
if (format === 'table' && totalIssues > 5) {
|
|
306
|
-
comment += `*Showing top 5 issues. Use \`/review --format=markdown\` for complete analysis.*\n\n`;
|
|
307
|
-
}
|
|
308
|
-
// Add debug section if debug information is available
|
|
309
|
-
if (summary.debug) {
|
|
310
|
-
comment += '\n\n' + this.formatDebugSection(summary.debug);
|
|
311
|
-
comment += '\n\n';
|
|
312
|
-
}
|
|
313
|
-
comment += `\n---\n`;
|
|
314
|
-
comment += `*Powered by [Visor](https://probelabs.com/visor) from [Probelabs](https://probelabs.com)*`;
|
|
315
|
-
return comment;
|
|
316
|
-
}
|
|
317
|
-
groupCommentsByCategory(comments) {
|
|
318
|
-
const grouped = {
|
|
319
|
-
security: [],
|
|
320
|
-
performance: [],
|
|
321
|
-
style: [],
|
|
322
|
-
logic: [],
|
|
323
|
-
documentation: [],
|
|
324
|
-
};
|
|
325
|
-
for (const comment of comments) {
|
|
326
|
-
if (!grouped[comment.category]) {
|
|
327
|
-
grouped[comment.category] = [];
|
|
328
|
-
}
|
|
329
|
-
grouped[comment.category].push(comment);
|
|
330
|
-
}
|
|
331
|
-
return grouped;
|
|
332
|
-
}
|
|
333
|
-
groupCommentsByCheck(comments) {
|
|
334
|
-
const grouped = {};
|
|
335
|
-
for (const comment of comments) {
|
|
336
|
-
// Extract check name from ruleId prefix (e.g., "security/sql-injection" -> "security")
|
|
337
|
-
let checkName = 'uncategorized';
|
|
338
|
-
if (comment.ruleId && comment.ruleId.includes('/')) {
|
|
339
|
-
const parts = comment.ruleId.split('/');
|
|
340
|
-
checkName = parts[0];
|
|
341
|
-
}
|
|
342
|
-
if (!grouped[checkName]) {
|
|
343
|
-
grouped[checkName] = [];
|
|
344
|
-
}
|
|
345
|
-
grouped[checkName].push(comment);
|
|
346
|
-
}
|
|
347
|
-
return grouped;
|
|
348
|
-
}
|
|
349
153
|
formatDebugSection(debug) {
|
|
350
154
|
const formattedContent = [
|
|
351
155
|
`**Provider:** ${debug.provider}`,
|
|
@@ -363,7 +167,6 @@ class PRReviewer {
|
|
|
363
167
|
formattedContent.push(`- ${error}`);
|
|
364
168
|
});
|
|
365
169
|
}
|
|
366
|
-
// Check if debug content would be too large for GitHub comment
|
|
367
170
|
const fullDebugContent = [
|
|
368
171
|
...formattedContent,
|
|
369
172
|
'',
|
|
@@ -377,9 +180,7 @@ class PRReviewer {
|
|
|
377
180
|
debug.rawResponse,
|
|
378
181
|
'```',
|
|
379
182
|
].join('\n');
|
|
380
|
-
// GitHub comment limit is 65536 characters, leave some buffer
|
|
381
183
|
if (fullDebugContent.length > 60000) {
|
|
382
|
-
// Save debug info to artifact and provide link
|
|
383
184
|
const artifactPath = this.saveDebugArtifact(debug);
|
|
384
185
|
formattedContent.push('');
|
|
385
186
|
formattedContent.push('### Debug Details');
|
|
@@ -387,7 +188,6 @@ class PRReviewer {
|
|
|
387
188
|
if (artifactPath) {
|
|
388
189
|
formattedContent.push(`📁 **Full debug information saved to artifact:** \`${artifactPath}\``);
|
|
389
190
|
formattedContent.push('');
|
|
390
|
-
// Try to get GitHub context for artifact link
|
|
391
191
|
const runId = process.env.GITHUB_RUN_ID;
|
|
392
192
|
const repoUrl = process.env.GITHUB_SERVER_URL && process.env.GITHUB_REPOSITORY
|
|
393
193
|
? `${process.env.GITHUB_SERVER_URL}/${process.env.GITHUB_REPOSITORY}`
|
|
@@ -403,7 +203,6 @@ class PRReviewer {
|
|
|
403
203
|
}
|
|
404
204
|
}
|
|
405
205
|
else {
|
|
406
|
-
// Include full debug content if it fits
|
|
407
206
|
formattedContent.push('');
|
|
408
207
|
formattedContent.push('### AI Prompt');
|
|
409
208
|
formattedContent.push('```');
|
|
@@ -415,356 +214,47 @@ class PRReviewer {
|
|
|
415
214
|
formattedContent.push(debug.rawResponse);
|
|
416
215
|
formattedContent.push('```');
|
|
417
216
|
}
|
|
418
|
-
return this.commentManager.createCollapsibleSection('🐛 Debug Information', formattedContent.join('\n'), false
|
|
419
|
-
);
|
|
217
|
+
return this.commentManager.createCollapsibleSection('🐛 Debug Information', formattedContent.join('\n'), false);
|
|
420
218
|
}
|
|
421
219
|
saveDebugArtifact(debug) {
|
|
422
220
|
try {
|
|
423
221
|
const fs = require('fs');
|
|
424
222
|
const path = require('path');
|
|
425
|
-
// Create debug directory if it doesn't exist
|
|
426
223
|
const debugDir = path.join(process.cwd(), 'debug-artifacts');
|
|
427
224
|
if (!fs.existsSync(debugDir)) {
|
|
428
225
|
fs.mkdirSync(debugDir, { recursive: true });
|
|
429
226
|
}
|
|
430
|
-
// Create debug file with timestamp
|
|
431
227
|
const timestamp = new Date().toISOString().replace(/[:.]/g, '-');
|
|
432
228
|
const filename = `visor-debug-${timestamp}.md`;
|
|
433
|
-
const
|
|
434
|
-
|
|
435
|
-
|
|
436
|
-
|
|
229
|
+
const filepath = path.join(debugDir, filename);
|
|
230
|
+
const content = [
|
|
231
|
+
`# Visor Debug Information`,
|
|
232
|
+
``,
|
|
233
|
+
`**Timestamp:** ${debug.timestamp}`,
|
|
234
|
+
`**Provider:** ${debug.provider}`,
|
|
235
|
+
`**Model:** ${debug.model}`,
|
|
236
|
+
`**Processing Time:** ${debug.processingTime}ms`,
|
|
237
|
+
``,
|
|
238
|
+
`## AI Prompt`,
|
|
239
|
+
``,
|
|
240
|
+
'```',
|
|
241
|
+
debug.prompt,
|
|
242
|
+
'```',
|
|
243
|
+
``,
|
|
244
|
+
`## Raw AI Response`,
|
|
245
|
+
``,
|
|
246
|
+
'```json',
|
|
247
|
+
debug.rawResponse,
|
|
248
|
+
'```',
|
|
249
|
+
].join('\n');
|
|
250
|
+
fs.writeFileSync(filepath, content, 'utf8');
|
|
437
251
|
return filename;
|
|
438
252
|
}
|
|
439
253
|
catch (error) {
|
|
440
|
-
console.error(
|
|
254
|
+
console.error('Failed to save debug artifact:', error);
|
|
441
255
|
return null;
|
|
442
256
|
}
|
|
443
257
|
}
|
|
444
|
-
formatDebugAsMarkdown(debug) {
|
|
445
|
-
const lines = [
|
|
446
|
-
'# Visor AI Debug Information',
|
|
447
|
-
'',
|
|
448
|
-
`**Generated:** ${debug.timestamp}`,
|
|
449
|
-
`**Provider:** ${debug.provider}`,
|
|
450
|
-
`**Model:** ${debug.model}`,
|
|
451
|
-
`**API Key Source:** ${debug.apiKeySource}`,
|
|
452
|
-
`**Schema Type:** ${debug.schemaName || 'default (code-review)'}`,
|
|
453
|
-
`**Total Processing Time:** ${debug.processingTime}ms`,
|
|
454
|
-
`**Total Prompt Length:** ${debug.promptLength} characters`,
|
|
455
|
-
`**Total Response Length:** ${debug.responseLength} characters`,
|
|
456
|
-
`**JSON Parse Success:** ${debug.jsonParseSuccess ? '✅' : '❌'}`,
|
|
457
|
-
'',
|
|
458
|
-
];
|
|
459
|
-
if (debug.errors && debug.errors.length > 0) {
|
|
460
|
-
lines.push('## ❌ Errors');
|
|
461
|
-
debug.errors.forEach(error => {
|
|
462
|
-
lines.push(`- ${error}`);
|
|
463
|
-
});
|
|
464
|
-
lines.push('');
|
|
465
|
-
}
|
|
466
|
-
// Add schema information if available
|
|
467
|
-
if (debug.schema) {
|
|
468
|
-
lines.push('## 📋 Schema Options Passed to ProbeAgent');
|
|
469
|
-
lines.push('```json');
|
|
470
|
-
lines.push(debug.schema);
|
|
471
|
-
lines.push('```');
|
|
472
|
-
lines.push('');
|
|
473
|
-
}
|
|
474
|
-
// Parse combined prompt and response to extract individual checks
|
|
475
|
-
const promptSections = this.parseCheckSections(debug.prompt);
|
|
476
|
-
const responseSections = this.parseCheckSections(debug.rawResponse);
|
|
477
|
-
lines.push('## 📊 Check Results Summary');
|
|
478
|
-
lines.push('');
|
|
479
|
-
promptSections.forEach(section => {
|
|
480
|
-
const responseSection = responseSections.find(r => r.checkName === section.checkName);
|
|
481
|
-
lines.push(`- **${section.checkName}**: ${responseSection ? 'Success' : 'Failed'}`);
|
|
482
|
-
});
|
|
483
|
-
lines.push('');
|
|
484
|
-
// Add detailed information for each check
|
|
485
|
-
promptSections.forEach((promptSection, index) => {
|
|
486
|
-
const responseSection = responseSections.find(r => r.checkName === promptSection.checkName);
|
|
487
|
-
lines.push(`## ${index + 1}. ${promptSection.checkName.toUpperCase()} Check`);
|
|
488
|
-
lines.push('');
|
|
489
|
-
lines.push('### 📝 AI Prompt');
|
|
490
|
-
lines.push('');
|
|
491
|
-
lines.push('```');
|
|
492
|
-
lines.push(promptSection.content);
|
|
493
|
-
lines.push('```');
|
|
494
|
-
lines.push('');
|
|
495
|
-
lines.push('### 🤖 AI Response');
|
|
496
|
-
lines.push('');
|
|
497
|
-
if (responseSection) {
|
|
498
|
-
lines.push('```json');
|
|
499
|
-
lines.push(responseSection.content);
|
|
500
|
-
lines.push('```');
|
|
501
|
-
}
|
|
502
|
-
else {
|
|
503
|
-
lines.push('❌ No response available for this check');
|
|
504
|
-
}
|
|
505
|
-
lines.push('');
|
|
506
|
-
lines.push('---');
|
|
507
|
-
lines.push('');
|
|
508
|
-
});
|
|
509
|
-
// Add raw unprocessed prompt and response at the end for complete transparency
|
|
510
|
-
lines.push('## 📄 Raw Prompt (Complete)');
|
|
511
|
-
lines.push('');
|
|
512
|
-
lines.push('```');
|
|
513
|
-
lines.push(debug.prompt);
|
|
514
|
-
lines.push('```');
|
|
515
|
-
lines.push('');
|
|
516
|
-
lines.push('## 📄 Raw Response (Complete)');
|
|
517
|
-
lines.push('');
|
|
518
|
-
lines.push('```');
|
|
519
|
-
lines.push(debug.rawResponse);
|
|
520
|
-
lines.push('```');
|
|
521
|
-
lines.push('');
|
|
522
|
-
return lines.join('\n');
|
|
523
|
-
}
|
|
524
|
-
parseCheckSections(combinedText) {
|
|
525
|
-
const sections = [];
|
|
526
|
-
// Split by check sections like [security], [performance], etc.
|
|
527
|
-
const parts = combinedText.split(/\[(\w+)\]\s*\n/);
|
|
528
|
-
for (let i = 1; i < parts.length; i += 2) {
|
|
529
|
-
const checkName = parts[i];
|
|
530
|
-
const content = parts[i + 1]?.trim() || '';
|
|
531
|
-
if (checkName && content) {
|
|
532
|
-
sections.push({ checkName, content });
|
|
533
|
-
}
|
|
534
|
-
}
|
|
535
|
-
return sections;
|
|
536
|
-
}
|
|
537
|
-
formatIssuesTable(comments) {
|
|
538
|
-
let content = `## 🔍 Code Analysis Results\n\n`;
|
|
539
|
-
// Group comments by check (extracted from ruleId prefix)
|
|
540
|
-
const groupedComments = this.groupCommentsByCheck(comments);
|
|
541
|
-
// Create a table for each check that has issues
|
|
542
|
-
for (const [checkName, checkComments] of Object.entries(groupedComments)) {
|
|
543
|
-
if (checkComments.length === 0)
|
|
544
|
-
continue;
|
|
545
|
-
const checkTitle = checkName.charAt(0).toUpperCase() + checkName.slice(1);
|
|
546
|
-
// Check heading
|
|
547
|
-
content += `### ${checkTitle} Issues (${checkComments.length})\n\n`;
|
|
548
|
-
// Start HTML table for this category
|
|
549
|
-
content += `<table>\n`;
|
|
550
|
-
content += ` <thead>\n`;
|
|
551
|
-
content += ` <tr>\n`;
|
|
552
|
-
content += ` <th>Severity</th>\n`;
|
|
553
|
-
content += ` <th>File</th>\n`;
|
|
554
|
-
content += ` <th>Line</th>\n`;
|
|
555
|
-
content += ` <th>Issue</th>\n`;
|
|
556
|
-
content += ` </tr>\n`;
|
|
557
|
-
content += ` </thead>\n`;
|
|
558
|
-
content += ` <tbody>\n`;
|
|
559
|
-
// Sort comments within check by severity, then by file
|
|
560
|
-
const sortedCheckComments = checkComments.sort((a, b) => {
|
|
561
|
-
const severityOrder = { critical: 0, error: 1, warning: 2, info: 3 };
|
|
562
|
-
const severityDiff = (severityOrder[a.severity] || 4) - (severityOrder[b.severity] || 4);
|
|
563
|
-
if (severityDiff !== 0)
|
|
564
|
-
return severityDiff;
|
|
565
|
-
return a.file.localeCompare(b.file);
|
|
566
|
-
});
|
|
567
|
-
for (const comment of sortedCheckComments) {
|
|
568
|
-
const severityEmoji = comment.severity === 'critical'
|
|
569
|
-
? '🔴'
|
|
570
|
-
: comment.severity === 'error'
|
|
571
|
-
? '🔴'
|
|
572
|
-
: comment.severity === 'warning'
|
|
573
|
-
? '🟡'
|
|
574
|
-
: '🟢';
|
|
575
|
-
const severityText = comment.severity.charAt(0).toUpperCase() + comment.severity.slice(1);
|
|
576
|
-
// Build the issue description with suggestion/replacement if available
|
|
577
|
-
// Wrap content in a div for better table layout control
|
|
578
|
-
let issueContent = '';
|
|
579
|
-
// Pass the message as-is - Liquid template will handle escaping
|
|
580
|
-
issueContent += comment.message;
|
|
581
|
-
if (comment.suggestion) {
|
|
582
|
-
// Pass suggestion as-is - Liquid template will handle escaping
|
|
583
|
-
issueContent += `\n<details><summary>💡 <strong>Suggestion</strong></summary>${comment.suggestion}</details>`;
|
|
584
|
-
}
|
|
585
|
-
if (comment.replacement) {
|
|
586
|
-
// Extract language hint from file extension
|
|
587
|
-
const fileExt = comment.file.split('.').pop()?.toLowerCase() || 'text';
|
|
588
|
-
const languageHint = this.getLanguageHint(fileExt);
|
|
589
|
-
// Pass replacement as-is - Liquid template will handle escaping
|
|
590
|
-
issueContent += `\n<details><summary>🔧 <strong>Suggested Fix</strong></summary><pre><code class="language-${languageHint}">${comment.replacement}</code></pre></details>`;
|
|
591
|
-
}
|
|
592
|
-
// Wrap all content in a div for better table cell containment
|
|
593
|
-
const issueDescription = `<div>${issueContent}</div>`;
|
|
594
|
-
content += ` <tr>\n`;
|
|
595
|
-
content += ` <td>${severityEmoji} ${severityText}</td>\n`;
|
|
596
|
-
content += ` <td><code>${comment.file}</code></td>\n`;
|
|
597
|
-
content += ` <td>${comment.line}</td>\n`;
|
|
598
|
-
content += ` <td>${issueDescription}</td>\n`;
|
|
599
|
-
content += ` </tr>\n`;
|
|
600
|
-
}
|
|
601
|
-
// Close HTML table for this category
|
|
602
|
-
content += ` </tbody>\n`;
|
|
603
|
-
content += `</table>\n\n`;
|
|
604
|
-
// No hardcoded recommendations - all guidance comes from .visor.yaml prompts
|
|
605
|
-
}
|
|
606
|
-
return content;
|
|
607
|
-
}
|
|
608
|
-
getLanguageHint(fileExtension) {
|
|
609
|
-
const langMap = {
|
|
610
|
-
ts: 'typescript',
|
|
611
|
-
tsx: 'typescript',
|
|
612
|
-
js: 'javascript',
|
|
613
|
-
jsx: 'javascript',
|
|
614
|
-
py: 'python',
|
|
615
|
-
java: 'java',
|
|
616
|
-
kt: 'kotlin',
|
|
617
|
-
swift: 'swift',
|
|
618
|
-
go: 'go',
|
|
619
|
-
rs: 'rust',
|
|
620
|
-
cpp: 'cpp',
|
|
621
|
-
c: 'c',
|
|
622
|
-
cs: 'csharp',
|
|
623
|
-
php: 'php',
|
|
624
|
-
rb: 'ruby',
|
|
625
|
-
scala: 'scala',
|
|
626
|
-
sh: 'bash',
|
|
627
|
-
bash: 'bash',
|
|
628
|
-
zsh: 'bash',
|
|
629
|
-
sql: 'sql',
|
|
630
|
-
json: 'json',
|
|
631
|
-
yaml: 'yaml',
|
|
632
|
-
yml: 'yaml',
|
|
633
|
-
xml: 'xml',
|
|
634
|
-
html: 'html',
|
|
635
|
-
css: 'css',
|
|
636
|
-
scss: 'scss',
|
|
637
|
-
sass: 'sass',
|
|
638
|
-
md: 'markdown',
|
|
639
|
-
dockerfile: 'dockerfile',
|
|
640
|
-
tf: 'hcl',
|
|
641
|
-
};
|
|
642
|
-
return langMap[fileExtension] || fileExtension;
|
|
643
|
-
}
|
|
644
|
-
/**
|
|
645
|
-
* Load custom template content from file or raw content
|
|
646
|
-
*/
|
|
647
|
-
async loadCustomTemplate(config) {
|
|
648
|
-
if (config.content) {
|
|
649
|
-
// Auto-detect if content is actually a file path
|
|
650
|
-
if (await this.isFilePath(config.content)) {
|
|
651
|
-
return await this.loadTemplateFromFile(config.content);
|
|
652
|
-
}
|
|
653
|
-
else {
|
|
654
|
-
// Use raw template content directly
|
|
655
|
-
return config.content;
|
|
656
|
-
}
|
|
657
|
-
}
|
|
658
|
-
if (config.file) {
|
|
659
|
-
// Legacy explicit file property
|
|
660
|
-
return await this.loadTemplateFromFile(config.file);
|
|
661
|
-
}
|
|
662
|
-
throw new Error('Custom template configuration must specify either "file" or "content"');
|
|
663
|
-
}
|
|
664
|
-
/**
|
|
665
|
-
* Detect if a string is likely a file path and if the file exists
|
|
666
|
-
*/
|
|
667
|
-
async isFilePath(str) {
|
|
668
|
-
// Quick checks to exclude obvious non-file-path content
|
|
669
|
-
if (!str || str.trim() !== str || str.length > 512) {
|
|
670
|
-
return false;
|
|
671
|
-
}
|
|
672
|
-
// Exclude strings that are clearly content (contain common content indicators)
|
|
673
|
-
// But be more careful with paths that might contain common words as directory names
|
|
674
|
-
if (/\s{2,}/.test(str) || // Multiple consecutive spaces
|
|
675
|
-
/\n/.test(str) || // Contains newlines
|
|
676
|
-
/^(please|analyze|review|check|find|identify|look|search)/i.test(str.trim()) || // Starts with command words
|
|
677
|
-
str.split(' ').length > 8 // Too many words for a typical file path
|
|
678
|
-
) {
|
|
679
|
-
return false;
|
|
680
|
-
}
|
|
681
|
-
// For strings with path separators, be more lenient about common words
|
|
682
|
-
// as they might be legitimate directory names
|
|
683
|
-
if (!/[\/\\]/.test(str)) {
|
|
684
|
-
// Only apply strict English word filter to non-path strings
|
|
685
|
-
if (/\b(the|and|or|but|for|with|by|from|in|on|at|as)\b/i.test(str)) {
|
|
686
|
-
return false;
|
|
687
|
-
}
|
|
688
|
-
}
|
|
689
|
-
// Positive indicators for file paths
|
|
690
|
-
const hasFileExtension = /\.[a-zA-Z0-9]{1,10}$/i.test(str);
|
|
691
|
-
const hasPathSeparators = /[\/\\]/.test(str);
|
|
692
|
-
const isRelativePath = /^\.{1,2}\//.test(str);
|
|
693
|
-
const isAbsolutePath = path_1.default.isAbsolute(str);
|
|
694
|
-
const hasTypicalFileChars = /^[a-zA-Z0-9._\-\/\\:~]+$/.test(str);
|
|
695
|
-
// Must have at least one strong indicator
|
|
696
|
-
if (!(hasFileExtension || isRelativePath || isAbsolutePath || hasPathSeparators)) {
|
|
697
|
-
return false;
|
|
698
|
-
}
|
|
699
|
-
// Must contain only typical file path characters
|
|
700
|
-
if (!hasTypicalFileChars) {
|
|
701
|
-
return false;
|
|
702
|
-
}
|
|
703
|
-
// Additional validation for suspected file paths
|
|
704
|
-
try {
|
|
705
|
-
// Try to resolve and check if file exists
|
|
706
|
-
let resolvedPath;
|
|
707
|
-
if (path_1.default.isAbsolute(str)) {
|
|
708
|
-
resolvedPath = path_1.default.normalize(str);
|
|
709
|
-
}
|
|
710
|
-
else {
|
|
711
|
-
// Resolve relative to current working directory
|
|
712
|
-
resolvedPath = path_1.default.resolve(process.cwd(), str);
|
|
713
|
-
}
|
|
714
|
-
// Check if file exists
|
|
715
|
-
try {
|
|
716
|
-
const stat = await promises_1.default.stat(resolvedPath);
|
|
717
|
-
return stat.isFile();
|
|
718
|
-
}
|
|
719
|
-
catch {
|
|
720
|
-
// File doesn't exist, but might still be a valid file path format
|
|
721
|
-
// Return true if it has strong file path indicators
|
|
722
|
-
return hasFileExtension && (isRelativePath || isAbsolutePath || hasPathSeparators);
|
|
723
|
-
}
|
|
724
|
-
}
|
|
725
|
-
catch {
|
|
726
|
-
return false;
|
|
727
|
-
}
|
|
728
|
-
}
|
|
729
|
-
/**
|
|
730
|
-
* Safely load template from file with security checks
|
|
731
|
-
*/
|
|
732
|
-
async loadTemplateFromFile(templatePath) {
|
|
733
|
-
// Resolve the path (handles both relative and absolute paths)
|
|
734
|
-
let resolvedPath;
|
|
735
|
-
if (path_1.default.isAbsolute(templatePath)) {
|
|
736
|
-
// Absolute path - use as-is but validate it's not trying to escape expected directories
|
|
737
|
-
resolvedPath = path_1.default.normalize(templatePath);
|
|
738
|
-
}
|
|
739
|
-
else {
|
|
740
|
-
// Relative path - resolve relative to current working directory
|
|
741
|
-
resolvedPath = path_1.default.resolve(process.cwd(), templatePath);
|
|
742
|
-
}
|
|
743
|
-
// Security: Normalize and check for path traversal attempts
|
|
744
|
-
const normalizedPath = path_1.default.normalize(resolvedPath);
|
|
745
|
-
// Security: For relative paths, ensure they don't escape the current directory
|
|
746
|
-
if (!path_1.default.isAbsolute(templatePath)) {
|
|
747
|
-
const currentDir = path_1.default.resolve(process.cwd());
|
|
748
|
-
if (!normalizedPath.startsWith(currentDir)) {
|
|
749
|
-
throw new Error('Invalid template file path: path traversal detected');
|
|
750
|
-
}
|
|
751
|
-
}
|
|
752
|
-
// Security: Additional check for obvious path traversal patterns
|
|
753
|
-
if (templatePath.includes('../..')) {
|
|
754
|
-
throw new Error('Invalid template file path: path traversal detected');
|
|
755
|
-
}
|
|
756
|
-
// Security: Check file extension
|
|
757
|
-
if (!normalizedPath.endsWith('.liquid')) {
|
|
758
|
-
throw new Error('Invalid template file: must have .liquid extension');
|
|
759
|
-
}
|
|
760
|
-
try {
|
|
761
|
-
const templateContent = await promises_1.default.readFile(normalizedPath, 'utf-8');
|
|
762
|
-
return templateContent;
|
|
763
|
-
}
|
|
764
|
-
catch (error) {
|
|
765
|
-
throw new Error(`Failed to load custom template from ${normalizedPath}: ${error instanceof Error ? error.message : 'Unknown error'}`);
|
|
766
|
-
}
|
|
767
|
-
}
|
|
768
258
|
}
|
|
769
259
|
exports.PRReviewer = PRReviewer;
|
|
770
260
|
//# sourceMappingURL=reviewer.js.map
|