qai-cli 3.0.0 → 3.0.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "qai-cli",
3
- "version": "3.0.0",
3
+ "version": "3.0.1",
4
4
  "description": "AI-powered QA engineer. Code review, testing, and bug detection from your terminal.",
5
5
  "main": "src/analyze.js",
6
6
  "types": "src/types.d.ts",
package/src/index.js CHANGED
@@ -3,6 +3,88 @@
3
3
  const fs = require('fs').promises;
4
4
  const { capturePage } = require('./capture');
5
5
  const { getProvider } = require('./providers');
6
+ const { reviewPR, formatReviewMarkdown } = require('./review');
7
+
8
+ // Route to the right command
9
+ const command = process.argv[2];
10
+
11
+ if (command === 'review') {
12
+ runReview().catch((err) => {
13
+ console.error('\nError:', err.message);
14
+ process.exit(1);
15
+ });
16
+ } else {
17
+ main();
18
+ }
19
+
20
+ /**
21
+ * Run PR review command
22
+ * Usage: qai review [PR_NUMBER] [--base main] [--focus security] [--json]
23
+ */
24
+ async function runReview() {
25
+ const args = process.argv.slice(3);
26
+ const options = {};
27
+
28
+ for (let i = 0; i < args.length; i++) {
29
+ if (args[i] === '--base' && args[i + 1]) {
30
+ options.base = args[++i];
31
+ } else if (args[i] === '--focus' && args[i + 1]) {
32
+ options.focus = args[++i];
33
+ } else if (args[i] === '--json') {
34
+ options.json = true;
35
+ } else if (/^\d+$/.test(args[i])) {
36
+ options.pr = parseInt(args[i], 10);
37
+ }
38
+ }
39
+
40
+ console.log('='.repeat(60));
41
+ console.log('qai review');
42
+ console.log('='.repeat(60));
43
+ if (options.pr) {
44
+ console.log(`PR: #${options.pr}`);
45
+ } else {
46
+ console.log(`Comparing: HEAD vs ${options.base || 'main'}`);
47
+ }
48
+ console.log(`Focus: ${options.focus || 'all'}`);
49
+ console.log('='.repeat(60));
50
+
51
+ const startTime = Date.now();
52
+ const report = await reviewPR(options);
53
+ const duration = ((Date.now() - startTime) / 1000).toFixed(1);
54
+
55
+ if (options.json) {
56
+ console.log(JSON.stringify(report, null, 2));
57
+ } else {
58
+ const markdown = formatReviewMarkdown(report);
59
+ await fs.writeFile('review-report.md', markdown);
60
+ console.log('\nSaved: review-report.md');
61
+
62
+ // Print summary
63
+ console.log('\n' + '='.repeat(60));
64
+ console.log('Review Summary');
65
+ console.log('='.repeat(60));
66
+ console.log(`Score: ${report.score !== null ? report.score + '/100' : 'N/A'}`);
67
+ console.log(`Issues: ${report.issues?.length || 0}`);
68
+ if (report.issues?.length > 0) {
69
+ const critical = report.issues.filter((i) => i.severity === 'critical').length;
70
+ const high = report.issues.filter((i) => i.severity === 'high').length;
71
+ const medium = report.issues.filter((i) => i.severity === 'medium').length;
72
+ const low = report.issues.filter((i) => i.severity === 'low').length;
73
+ if (critical) console.log(` Critical: ${critical}`);
74
+ if (high) console.log(` High: ${high}`);
75
+ if (medium) console.log(` Medium: ${medium}`);
76
+ if (low) console.log(` Low: ${low}`);
77
+ }
78
+ console.log(`Duration: ${duration}s`);
79
+ console.log('='.repeat(60));
80
+ }
81
+
82
+ // Exit with error if critical issues found
83
+ const criticals = report.issues?.filter((i) => i.severity === 'critical').length || 0;
84
+ if (criticals > 0) {
85
+ process.exit(1);
86
+ }
87
+ }
6
88
 
7
89
  async function main() {
8
90
  const startTime = Date.now();
@@ -200,5 +282,3 @@ function generateMarkdownReport(report) {
200
282
 
201
283
  return lines.join('\n');
202
284
  }
203
-
204
- main();
@@ -54,6 +54,28 @@ class AnthropicProvider extends BaseProvider {
54
54
 
55
55
  return this.parseResponse(responseText);
56
56
  }
57
+
58
+ async reviewCode(diff, context, options = {}) {
59
+ const prompt = this.buildReviewPrompt(diff, context, options);
60
+
61
+ const response = await this.client.messages.create({
62
+ model: this.model,
63
+ max_tokens: 8192,
64
+ messages: [
65
+ {
66
+ role: 'user',
67
+ content: prompt,
68
+ },
69
+ ],
70
+ });
71
+
72
+ const responseText = response.content
73
+ .filter((block) => block.type === 'text')
74
+ .map((block) => block.text)
75
+ .join('\n');
76
+
77
+ return this.parseResponse(responseText);
78
+ }
57
79
  }
58
80
 
59
81
  module.exports = AnthropicProvider;
@@ -18,6 +18,18 @@ class BaseProvider {
18
18
  throw new Error('analyze() must be implemented by subclass');
19
19
  }
20
20
 
21
+ /**
22
+ * Review code changes (PR diff + context)
23
+ * @param {string} diff - Unified diff
24
+ * @param {Object} context - Codebase context (files, deps, dependents)
25
+ * @param {Object} options - Review options
26
+ * @returns {Promise<Object>} Review report
27
+ */
28
+ // eslint-disable-next-line no-unused-vars
29
+ async reviewCode(diff, context, options = {}) {
30
+ throw new Error('reviewCode() must be implemented by subclass');
31
+ }
32
+
21
33
  /**
22
34
  * Build the analysis prompt with focus-specific guidance
23
35
  */
@@ -55,6 +67,8 @@ ${
55
67
  ## Screenshots Provided
56
68
  ${captureData.screenshots.map((s) => `- ${s.viewport}: ${s.width}x${s.height}`).join('\n')}
57
69
 
70
+ ${ariaSection}${domSection}
71
+
58
72
  ## Focus Area: ${focus}
59
73
  ${focusGuidance}
60
74
 
@@ -103,8 +117,112 @@ Respond with ONLY the JSON, no markdown code blocks.`;
103
117
  };
104
118
  }
105
119
  }
120
+
121
+ /**
122
+ * Build the code review prompt
123
+ */
124
+ buildReviewPrompt(diff, context, options = {}) {
125
+ const { focus = 'all' } = options;
126
+
127
+ const focusGuidance = REVIEW_FOCUS[focus] || REVIEW_FOCUS.all;
128
+
129
+ // Build context section
130
+ let contextSection = '';
131
+ if (context.summary) {
132
+ contextSection += `\n## Change Summary\n${context.summary}\n`;
133
+ }
134
+
135
+ // Include dependency info
136
+ if (Object.keys(context.dependencies).length > 0) {
137
+ contextSection += '\n## Dependencies\n';
138
+ for (const [file, deps] of Object.entries(context.dependencies)) {
139
+ contextSection += `- \`${file}\` imports: ${deps.map((d) => `\`${d}\``).join(', ')}\n`;
140
+ }
141
+ }
142
+
143
+ if (Object.keys(context.dependents).length > 0) {
144
+ contextSection += '\n## Dependents (files affected by these changes)\n';
145
+ for (const [file, deps] of Object.entries(context.dependents)) {
146
+ contextSection += `- \`${file}\` is used by: ${deps
147
+ .slice(0, 5)
148
+ .map((d) => `\`${d}\``)
149
+ .join(', ')}${deps.length > 5 ? ` (+${deps.length - 5} more)` : ''}\n`;
150
+ }
151
+ }
152
+
153
+ if (Object.keys(context.tests).length > 0) {
154
+ contextSection += '\n## Related Tests\n';
155
+ for (const [file, test] of Object.entries(context.tests)) {
156
+ contextSection += `- \`${file}\` has test: \`${test}\`\n`;
157
+ }
158
+ }
159
+
160
+ // Include relevant file contents (trimmed)
161
+ let fileContents = '';
162
+ const contextFiles = Object.entries(context.files || {});
163
+ if (contextFiles.length > 0) {
164
+ fileContents = '\n## Full File Contents (for context)\n';
165
+ for (const [filePath, content] of contextFiles) {
166
+ fileContents += `\n### \`${filePath}\`\n\`\`\`\n${content}\n\`\`\`\n`;
167
+ }
168
+ }
169
+
170
+ return `You are a senior software engineer doing a thorough code review. You have deep expertise in finding real bugs, security issues, and breaking changes. You are NOT a linter. Skip style nits.
171
+
172
+ ## Focus: ${focus}
173
+ ${focusGuidance}
174
+
175
+ ${contextSection}
176
+ ${fileContents}
177
+
178
+ ## Diff to Review
179
+ \`\`\`diff
180
+ ${diff}
181
+ \`\`\`
182
+
183
+ ## Instructions
184
+ - Focus on **real bugs**, security holes, breaking changes, edge cases, and logic errors
185
+ - Reference specific files and line numbers
186
+ - Skip style/formatting issues (that's what linters are for)
187
+ - If code looks good, say so. Don't invent problems.
188
+ - Be direct and specific. No filler.
189
+
190
+ Respond with ONLY this JSON (no code blocks):
191
+ {
192
+ "summary": "2-3 sentence overview of the changes and their quality",
193
+ "issues": [
194
+ {
195
+ "severity": "critical|high|medium|low",
196
+ "category": "bug|security|breaking-change|performance|error-handling|logic|race-condition|type-safety",
197
+ "title": "Short description",
198
+ "description": "What's wrong and why it matters",
199
+ "file": "path/to/file.js",
200
+ "line": 42,
201
+ "suggestion": "Code or explanation of how to fix"
202
+ }
203
+ ],
204
+ "score": 0-100,
205
+ "recommendations": ["General suggestions for improvement"]
206
+ }`;
207
+ }
106
208
  }
107
209
 
210
+ /**
211
+ * Review focus areas
212
+ */
213
+ const REVIEW_FOCUS = {
214
+ all: 'Review for bugs, security issues, breaking changes, performance problems, error handling gaps, and logic errors.',
215
+ security:
216
+ 'Focus on security vulnerabilities: injection, auth bypass, data exposure, SSRF, path traversal, ' +
217
+ 'insecure crypto, missing input validation, secrets in code.',
218
+ performance:
219
+ 'Focus on performance: N+1 queries, unnecessary re-renders, missing memoization, ' +
220
+ 'blocking operations, memory leaks, large bundle impact.',
221
+ bugs:
222
+ 'Focus on correctness: logic errors, off-by-one, null/undefined access, race conditions, ' +
223
+ 'unhandled promise rejections, incorrect error handling.',
224
+ };
225
+
108
226
  /**
109
227
  * Focus-specific prompt guidance
110
228
  */
@@ -35,6 +35,16 @@ class GeminiProvider extends BaseProvider {
35
35
  const response = await result.response;
36
36
  const responseText = response.text();
37
37
 
38
+ return this.parseResponse(responseText);
39
+ }
40
+ async reviewCode(diff, context, options = {}) {
41
+ const prompt = this.buildReviewPrompt(diff, context, options);
42
+ const model = this.genAI.getGenerativeModel({ model: this.model });
43
+
44
+ const result = await model.generateContent([{ text: prompt }]);
45
+ const response = await result.response;
46
+ const responseText = response.text();
47
+
38
48
  return this.parseResponse(responseText);
39
49
  }
40
50
  }
@@ -41,6 +41,31 @@ class OllamaProvider extends BaseProvider {
41
41
  throw new Error(`Ollama request failed: ${response.status} ${response.statusText}`);
42
42
  }
43
43
 
44
+ const data = await response.json();
45
+ return this.parseResponse(data.response || '');
46
+ }
47
+ async reviewCode(diff, context, options = {}) {
48
+ const prompt = this.buildReviewPrompt(diff, context, options);
49
+
50
+ const response = await fetch(`${this.baseUrl}/api/generate`, {
51
+ method: 'POST',
52
+ headers: {
53
+ 'Content-Type': 'application/json',
54
+ },
55
+ body: JSON.stringify({
56
+ model: this.model,
57
+ prompt,
58
+ stream: false,
59
+ options: {
60
+ temperature: 0.1,
61
+ },
62
+ }),
63
+ });
64
+
65
+ if (!response.ok) {
66
+ throw new Error(`Ollama request failed: ${response.status} ${response.statusText}`);
67
+ }
68
+
44
69
  const data = await response.json();
45
70
  return this.parseResponse(data.response || '');
46
71
  }
@@ -46,6 +46,23 @@ class OpenAIProvider extends BaseProvider {
46
46
  ],
47
47
  });
48
48
 
49
+ const responseText = response.choices[0]?.message?.content || '';
50
+ return this.parseResponse(responseText);
51
+ }
52
+ async reviewCode(diff, context, options = {}) {
53
+ const prompt = this.buildReviewPrompt(diff, context, options);
54
+
55
+ const response = await this.client.chat.completions.create({
56
+ model: this.model,
57
+ max_tokens: 8192,
58
+ messages: [
59
+ {
60
+ role: 'user',
61
+ content: prompt,
62
+ },
63
+ ],
64
+ });
65
+
49
66
  const responseText = response.choices[0]?.message?.content || '';
50
67
  return this.parseResponse(responseText);
51
68
  }
package/src/review.js ADDED
@@ -0,0 +1,370 @@
1
+ /**
2
+ * PR Code Review Engine
3
+ *
4
+ * Fetches PR diffs, gathers codebase context, and sends to LLM for deep review.
5
+ *
6
+ * Usage:
7
+ * const { reviewPR } = require('./review');
8
+ * const report = await reviewPR({ pr: 42, base: 'main' });
9
+ */
10
+
11
+ const { execSync } = require('child_process');
12
+ const fs = require('fs');
13
+ const path = require('path');
14
+ const { getProvider } = require('./providers');
15
+
16
+ /**
17
+ * Review a PR or branch diff
18
+ *
19
+ * @param {Object} options
20
+ * @param {number} [options.pr] - PR number to review
21
+ * @param {string} [options.base] - Base branch for diff (default: main)
22
+ * @param {string} [options.cwd] - Working directory (default: process.cwd())
23
+ * @param {string} [options.focus] - Focus area (security, performance, bugs, all)
24
+ * @param {boolean} [options.json] - Output JSON instead of markdown
25
+ * @returns {Promise<ReviewReport>}
26
+ */
27
+ async function reviewPR(options = {}) {
28
+ const { pr, base = 'main', cwd = process.cwd(), focus = 'all' } = options;
29
+
30
+ // Step 1: Get the diff
31
+ console.log('[1/4] Fetching diff...');
32
+ const diff = getDiff({ pr, base, cwd });
33
+
34
+ if (!diff.trim()) {
35
+ return {
36
+ summary: 'No changes found.',
37
+ issues: [],
38
+ score: 100,
39
+ recommendations: [],
40
+ };
41
+ }
42
+
43
+ // Step 2: Parse changed files
44
+ console.log('[2/4] Analyzing changed files...');
45
+ const changedFiles = parseChangedFiles(diff);
46
+ console.log(` ${changedFiles.length} files changed`);
47
+
48
+ // Step 3: Gather context for each changed file
49
+ console.log('[3/4] Gathering codebase context...');
50
+ const context = gatherContext(changedFiles, cwd);
51
+
52
+ // Step 4: Send to LLM for review
53
+ console.log('[4/4] Reviewing with AI...');
54
+ const provider = getProvider();
55
+ const report = await provider.reviewCode(diff, context, { focus });
56
+
57
+ return report;
58
+ }
59
+
60
+ /**
61
+ * Get diff from PR number or branch comparison
62
+ */
63
+ function getDiff({ pr, base, cwd }) {
64
+ try {
65
+ if (pr) {
66
+ // Fetch PR diff via gh CLI
67
+ return execSync(`gh pr diff ${pr} --color=never`, {
68
+ cwd,
69
+ encoding: 'utf-8',
70
+ maxBuffer: 10 * 1024 * 1024,
71
+ });
72
+ } else {
73
+ // Diff current branch against base
74
+ return execSync(`git diff ${base}...HEAD`, {
75
+ cwd,
76
+ encoding: 'utf-8',
77
+ maxBuffer: 10 * 1024 * 1024,
78
+ });
79
+ }
80
+ } catch (error) {
81
+ throw new Error(`Failed to get diff: ${error.message}`);
82
+ }
83
+ }
84
+
85
+ /**
86
+ * Parse a unified diff into structured file changes
87
+ */
88
+ function parseChangedFiles(diff) {
89
+ const files = [];
90
+ const fileDiffs = diff.split(/^diff --git /m).filter(Boolean);
91
+
92
+ for (const fileDiff of fileDiffs) {
93
+ const headerMatch = fileDiff.match(/a\/(.+?) b\/(.+)/);
94
+ if (!headerMatch) continue;
95
+
96
+ const filePath = headerMatch[2];
97
+ const isNew = fileDiff.includes('new file mode');
98
+ const isDeleted = fileDiff.includes('deleted file mode');
99
+
100
+ // Extract hunks
101
+ const hunks = [];
102
+ const hunkRegex = /^@@ -(\d+)(?:,\d+)? \+(\d+)(?:,\d+)? @@(.*)/gm;
103
+ let match;
104
+ while ((match = hunkRegex.exec(fileDiff)) !== null) {
105
+ hunks.push({
106
+ oldStart: parseInt(match[1], 10),
107
+ newStart: parseInt(match[2], 10),
108
+ header: match[3].trim(),
109
+ });
110
+ }
111
+
112
+ // Count additions and deletions
113
+ const lines = fileDiff.split('\n');
114
+ const additions = lines.filter((l) => l.startsWith('+') && !l.startsWith('+++')).length;
115
+ const deletions = lines.filter((l) => l.startsWith('-') && !l.startsWith('---')).length;
116
+
117
+ files.push({
118
+ path: filePath,
119
+ isNew,
120
+ isDeleted,
121
+ hunks,
122
+ additions,
123
+ deletions,
124
+ diff: fileDiff,
125
+ });
126
+ }
127
+
128
+ return files;
129
+ }
130
+
131
+ /**
132
+ * Gather relevant context for changed files
133
+ *
134
+ * For each changed file, we collect:
135
+ * - The full current file content (for understanding structure)
136
+ * - Import/require dependencies
137
+ * - Files that import/require the changed file
138
+ * - Related test files
139
+ */
140
+ function gatherContext(changedFiles, cwd) {
141
+ const context = {
142
+ files: {},
143
+ dependencies: {},
144
+ dependents: {},
145
+ tests: {},
146
+ summary: '',
147
+ };
148
+
149
+ const MAX_CONTEXT_CHARS = 200000; // ~50K tokens
150
+ let totalChars = 0;
151
+
152
+ for (const file of changedFiles) {
153
+ if (file.isDeleted) continue;
154
+ if (totalChars > MAX_CONTEXT_CHARS) break;
155
+
156
+ const fullPath = path.join(cwd, file.path);
157
+
158
+ // Read full file content
159
+ try {
160
+ const content = fs.readFileSync(fullPath, 'utf-8');
161
+ // Skip huge files
162
+ if (content.length > 50000) {
163
+ context.files[file.path] =
164
+ `[File too large: ${content.length} chars, showing first 5000]\n${content.slice(0, 5000)}`;
165
+ } else {
166
+ context.files[file.path] = content;
167
+ }
168
+ totalChars += Math.min(content.length, 50000);
169
+ } catch {
170
+ // File might not exist locally (renamed, etc.)
171
+ }
172
+
173
+ // Find imports in this file
174
+ const deps = findImports(fullPath, cwd);
175
+ if (deps.length > 0) {
176
+ context.dependencies[file.path] = deps;
177
+ }
178
+
179
+ // Find files that depend on this file
180
+ const dependents = findDependents(file.path, cwd);
181
+ if (dependents.length > 0) {
182
+ context.dependents[file.path] = dependents;
183
+ // Include first few dependent file contents for context
184
+ for (const dep of dependents.slice(0, 3)) {
185
+ if (totalChars > MAX_CONTEXT_CHARS) break;
186
+ try {
187
+ const depPath = path.join(cwd, dep);
188
+ const depContent = fs.readFileSync(depPath, 'utf-8');
189
+ if (!context.files[dep] && depContent.length < 20000) {
190
+ context.files[dep] = depContent;
191
+ totalChars += depContent.length;
192
+ }
193
+ } catch {
194
+ // Skip unreadable files
195
+ }
196
+ }
197
+ }
198
+
199
+ // Find related test files
200
+ const testFile = findTestFile(file.path, cwd);
201
+ if (testFile) {
202
+ context.tests[file.path] = testFile;
203
+ }
204
+ }
205
+
206
+ // Build summary
207
+ const fileList = changedFiles.map((f) => {
208
+ const status = f.isNew ? '(new)' : f.isDeleted ? '(deleted)' : '';
209
+ return ` ${f.path} ${status} +${f.additions} -${f.deletions}`;
210
+ });
211
+ context.summary = `${changedFiles.length} files changed:\n${fileList.join('\n')}`;
212
+
213
+ return context;
214
+ }
215
+
216
+ /**
217
+ * Find imports/requires in a file
218
+ */
219
+ function findImports(filePath, _cwd) {
220
+ try {
221
+ const content = fs.readFileSync(filePath, 'utf-8');
222
+ const imports = [];
223
+
224
+ // ES imports
225
+ const esImportRegex = /import\s+.*?\s+from\s+['"](.+?)['"]/g;
226
+ let match;
227
+ while ((match = esImportRegex.exec(content)) !== null) {
228
+ imports.push(match[1]);
229
+ }
230
+
231
+ // CommonJS requires
232
+ const cjsRegex = /require\(['"](.+?)['"]\)/g;
233
+ while ((match = cjsRegex.exec(content)) !== null) {
234
+ imports.push(match[1]);
235
+ }
236
+
237
+ // Filter to local imports only (starting with . or /)
238
+ return imports.filter((i) => i.startsWith('.') || i.startsWith('/'));
239
+ } catch {
240
+ return [];
241
+ }
242
+ }
243
+
244
+ /**
245
+ * Find files that import/require a given file
246
+ */
247
+ function findDependents(filePath, cwd) {
248
+ const basename = path.basename(filePath, path.extname(filePath));
249
+
250
+ try {
251
+ // Use grep to find files that reference this module
252
+ const result = execSync(
253
+ 'grep -rl --include="*.js" --include="*.ts" --include="*.jsx" --include="*.tsx" ' +
254
+ '--exclude-dir=node_modules --exclude-dir=.next --exclude-dir=.git --exclude-dir=dist ' +
255
+ `"${basename}" . 2>/dev/null | head -20`,
256
+ { cwd, encoding: 'utf-8', timeout: 10000 },
257
+ );
258
+
259
+ return result
260
+ .trim()
261
+ .split('\n')
262
+ .filter(Boolean)
263
+ .map((f) => f.replace(/^\.\//, ''))
264
+ .filter((f) => f !== filePath); // Exclude self
265
+ } catch {
266
+ return [];
267
+ }
268
+ }
269
+
270
+ /**
271
+ * Find the test file for a given source file
272
+ */
273
+ function findTestFile(filePath, cwd) {
274
+ const ext = path.extname(filePath);
275
+ const base = path.basename(filePath, ext);
276
+ const dir = path.dirname(filePath);
277
+
278
+ // Common test file patterns
279
+ const patterns = [
280
+ path.join(dir, `${base}.test${ext}`),
281
+ path.join(dir, `${base}.spec${ext}`),
282
+ path.join(dir, '__tests__', `${base}${ext}`),
283
+ path.join(dir, '__tests__', `${base}.test${ext}`),
284
+ path.join('test', `${base}.test${ext}`),
285
+ path.join('tests', `${base}.test${ext}`),
286
+ path.join('test', `${base}.spec${ext}`),
287
+ path.join('tests', `${base}.spec${ext}`),
288
+ ];
289
+
290
+ for (const pattern of patterns) {
291
+ const fullPath = path.join(cwd, pattern);
292
+ if (fs.existsSync(fullPath)) {
293
+ return pattern;
294
+ }
295
+ }
296
+
297
+ return null;
298
+ }
299
+
300
+ /**
301
+ * Format review report as markdown
302
+ */
303
+ function formatReviewMarkdown(report) {
304
+ const lines = [];
305
+
306
+ lines.push('# Code Review Report');
307
+ lines.push('');
308
+ lines.push(`**Score:** ${report.score}/100`);
309
+ lines.push('');
310
+ lines.push('## Summary');
311
+ lines.push('');
312
+ lines.push(report.summary || 'No summary provided.');
313
+ lines.push('');
314
+
315
+ if (report.issues && report.issues.length > 0) {
316
+ lines.push('## Issues');
317
+ lines.push('');
318
+
319
+ for (const issue of report.issues) {
320
+ const emoji =
321
+ { critical: '\u{1F534}', high: '\u{1F7E0}', medium: '\u{1F7E1}', low: '\u{1F7E2}' }[
322
+ issue.severity
323
+ ] || '\u26AA';
324
+
325
+ lines.push(`### ${emoji} ${issue.title}`);
326
+ lines.push('');
327
+ lines.push(
328
+ `**Severity:** ${issue.severity} | **File:** \`${issue.file || 'N/A'}\`${issue.line ? ` | **Line:** ${issue.line}` : ''}`,
329
+ );
330
+ lines.push('');
331
+ lines.push(issue.description);
332
+ lines.push('');
333
+ if (issue.suggestion) {
334
+ lines.push('**Suggestion:**');
335
+ lines.push(issue.suggestion);
336
+ lines.push('');
337
+ }
338
+ }
339
+ } else {
340
+ lines.push('## Issues');
341
+ lines.push('');
342
+ lines.push('No issues found. Code looks good!');
343
+ lines.push('');
344
+ }
345
+
346
+ if (report.recommendations && report.recommendations.length > 0) {
347
+ lines.push('## Recommendations');
348
+ lines.push('');
349
+ for (const rec of report.recommendations) {
350
+ lines.push(`- ${rec}`);
351
+ }
352
+ lines.push('');
353
+ }
354
+
355
+ lines.push('---');
356
+ lines.push('*Generated by [qai](https://github.com/tyler-james-bridges/qaie)*');
357
+
358
+ return lines.join('\n');
359
+ }
360
+
361
+ module.exports = {
362
+ reviewPR,
363
+ getDiff,
364
+ parseChangedFiles,
365
+ gatherContext,
366
+ findImports,
367
+ findDependents,
368
+ findTestFile,
369
+ formatReviewMarkdown,
370
+ };