nayan-ai 1.0.0-beta.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.
@@ -0,0 +1,77 @@
1
+ import type { CodeIssue, ReviewComment } from './types.js';
2
+
3
+ export function issuesToReviewComments(issues: CodeIssue[]): ReviewComment[] {
4
+ return issues.map((issue) => {
5
+ const severityIcon = issue.severity === 'error' ? 'šŸ”“' : issue.severity === 'warning' ? '🟔' : 'šŸ”µ';
6
+ const categoryIcon = issue.category === 'functionality' ? 'šŸ›' : issue.category === 'readability' ? 'šŸ“–' : '⚔';
7
+
8
+ let body = `${severityIcon} **${categoryIcon} ${capitalize(issue.category)}**\n\n${issue.message}`;
9
+ if (issue.suggestion) {
10
+ body += `\n\nšŸ’” **Suggestion:** ${issue.suggestion}`;
11
+ }
12
+
13
+ return {
14
+ path: issue.filename,
15
+ line: issue.line,
16
+ side: 'RIGHT' as const,
17
+ body,
18
+ };
19
+ });
20
+ }
21
+
22
+ export function generateSummary(issues: CodeIssue[]): string {
23
+ if (issues.length === 0) {
24
+ return '## āœ… AI Code Review Complete\n\nNo issues found. The code looks good!';
25
+ }
26
+
27
+ const bySeverity = {
28
+ error: issues.filter((i) => i.severity === 'error'),
29
+ warning: issues.filter((i) => i.severity === 'warning'),
30
+ info: issues.filter((i) => i.severity === 'info'),
31
+ };
32
+
33
+ let summary = `## šŸ¤– AI Code Review Complete\n\nFound **${issues.length}** issue(s) in this PR.\n\n`;
34
+
35
+ // Group issues by severity and list them
36
+ if (bySeverity.error.length > 0) {
37
+ summary += `### šŸ”“ Errors (${bySeverity.error.length})\n\n`;
38
+ bySeverity.error.forEach((issue) => {
39
+ const icon = issue.category === 'functionality' ? 'šŸ›' : issue.category === 'readability' ? 'šŸ“–' : '⚔';
40
+ summary += `- ${icon} **\`${issue.filename}:${issue.line}\`**\n ${issue.message}`;
41
+ if (issue.suggestion) {
42
+ summary += `\n šŸ’” *${issue.suggestion}*`;
43
+ }
44
+ summary += '\n\n';
45
+ });
46
+ }
47
+
48
+ if (bySeverity.warning.length > 0) {
49
+ summary += `### 🟔 Warnings (${bySeverity.warning.length})\n\n`;
50
+ bySeverity.warning.forEach((issue) => {
51
+ const icon = issue.category === 'functionality' ? 'šŸ›' : issue.category === 'readability' ? 'šŸ“–' : '⚔';
52
+ summary += `- ${icon} **\`${issue.filename}:${issue.line}\`**\n ${issue.message}`;
53
+ if (issue.suggestion) {
54
+ summary += `\n šŸ’” *${issue.suggestion}*`;
55
+ }
56
+ summary += '\n\n';
57
+ });
58
+ }
59
+
60
+ if (bySeverity.info.length > 0) {
61
+ summary += `### šŸ”µ Info (${bySeverity.info.length})\n\n`;
62
+ bySeverity.info.forEach((issue) => {
63
+ const icon = issue.category === 'functionality' ? 'šŸ›' : issue.category === 'readability' ? 'šŸ“–' : '⚔';
64
+ summary += `- ${icon} **\`${issue.filename}:${issue.line}\`**\n ${issue.message}`;
65
+ if (issue.suggestion) {
66
+ summary += `\n šŸ’” *${issue.suggestion}*`;
67
+ }
68
+ summary += '\n\n';
69
+ });
70
+ }
71
+
72
+ return summary.trim();
73
+ }
74
+
75
+ function capitalize(str: string): string {
76
+ return str.charAt(0).toUpperCase() + str.slice(1);
77
+ }
package/src/claude.ts ADDED
@@ -0,0 +1,147 @@
1
+ import { spawn } from 'child_process';
2
+ import type { CodeIssue } from './types.js';
3
+ import chalk from 'chalk';
4
+ import { getReviewPrompt } from './prompt.js';
5
+
6
+ export interface ClaudeOptions {
7
+ verbose?: boolean;
8
+ }
9
+
10
+ export async function analyzeWithClaude(
11
+ repoPath: string,
12
+ baseBranch: string = 'origin/main',
13
+ options: ClaudeOptions
14
+ ): Promise<CodeIssue[]> {
15
+ const response = await runClaudeExec(repoPath, baseBranch, options);
16
+ return parseClaudeResponse(response);
17
+ }
18
+
19
+ async function runClaudeExec(
20
+ repoPath: string,
21
+ baseBranch: string,
22
+ options: ClaudeOptions
23
+ ): Promise<string> {
24
+ const prompt = getReviewPrompt(baseBranch);
25
+
26
+ return new Promise((resolve, reject) => {
27
+ // Use claude with -p for print mode and --output-format json
28
+ const args = [
29
+ '-p',
30
+ '--output-format', 'json',
31
+ '--dangerously-skip-permissions',
32
+ prompt
33
+ ];
34
+
35
+ if (options.verbose) {
36
+ console.log(`\n[Claude] Running: claude ${args.slice(0, 3).join(' ')} "<prompt>"`);
37
+ console.log(`[Claude] Working directory: ${repoPath}`);
38
+ }
39
+
40
+ const startTime = Date.now();
41
+
42
+ const child = spawn('claude', args, {
43
+ cwd: repoPath,
44
+ stdio: ['pipe', 'pipe', 'pipe'],
45
+ });
46
+
47
+ let stdout = '';
48
+ let stderr = '';
49
+
50
+ child.stdout.on('data', (data) => {
51
+ const chunk = data.toString();
52
+ stdout += chunk;
53
+
54
+ if (options.verbose) {
55
+ process.stdout.write(chunk);
56
+ } else {
57
+ // Show progress dots
58
+ process.stdout.write(chalk.gray('.'));
59
+ }
60
+ });
61
+
62
+ child.stderr.on('data', (data) => {
63
+ const chunk = data.toString();
64
+ stderr += chunk;
65
+ if (options.verbose) {
66
+ process.stderr.write(chunk);
67
+ }
68
+ });
69
+
70
+ child.on('close', (code) => {
71
+ const elapsed = Date.now() - startTime;
72
+
73
+ if (!options.verbose) {
74
+ console.log(); // New line after progress dots
75
+ }
76
+ console.log(`\n[Claude] Completed in ${(elapsed / 1000).toFixed(1)}s`);
77
+
78
+ if (code !== 0) {
79
+ const errorMsg = stderr || stdout || 'Unknown error';
80
+ reject(new Error(`Claude review failed (exit ${code}): ${errorMsg.slice(0, 500)}`));
81
+ return;
82
+ }
83
+
84
+ resolve(stdout);
85
+ });
86
+
87
+ child.on('error', (err) => {
88
+ if ((err as NodeJS.ErrnoException).code === 'ENOENT') {
89
+ reject(new Error('claude CLI not found. Install Claude Code CLI first: https://code.claude.com'));
90
+ return;
91
+ }
92
+ reject(err);
93
+ });
94
+ });
95
+ }
96
+
97
+ function parseClaudeResponse(response: string): CodeIssue[] {
98
+ // Claude with --output-format json outputs a JSON object with result field
99
+ try {
100
+ const parsed = JSON.parse(response);
101
+
102
+ // Claude JSON output has a 'result' field with the text response
103
+ const text = parsed.result || parsed.text || response;
104
+
105
+ // Try to extract JSON from the text
106
+ const jsonMatch = text.match(/\{\s*"issues"\s*:\s*\[[\s\S]*?\]\s*\}/);
107
+ if (jsonMatch) {
108
+ const issuesJson = JSON.parse(jsonMatch[0]);
109
+ if (issuesJson.issues && Array.isArray(issuesJson.issues)) {
110
+ return issuesJson.issues
111
+ .filter((item: any) => item.message)
112
+ .map((item: any) => ({
113
+ filename: item.filename || 'unknown',
114
+ line: item.line || 0,
115
+ category: item.category || 'functionality',
116
+ severity: item.severity || 'info',
117
+ message: item.message,
118
+ suggestion: item.suggestion,
119
+ }));
120
+ }
121
+ }
122
+ } catch {
123
+ // Not valid JSON wrapper, try direct extraction
124
+ }
125
+
126
+ // Fallback: try to find raw JSON in the response
127
+ const jsonMatch = response.match(/\{\s*"issues"\s*:\s*\[[\s\S]*?\]\s*\}/);
128
+ if (jsonMatch) {
129
+ try {
130
+ const parsed = JSON.parse(jsonMatch[0]);
131
+ return (parsed.issues || [])
132
+ .filter((item: any) => item.message)
133
+ .map((item: any) => ({
134
+ filename: item.filename || 'unknown',
135
+ line: item.line || 0,
136
+ category: item.category || 'functionality',
137
+ severity: item.severity || 'info',
138
+ message: item.message,
139
+ suggestion: item.suggestion,
140
+ }));
141
+ } catch {
142
+ // ignore
143
+ }
144
+ }
145
+
146
+ return [];
147
+ }
package/src/cli.ts ADDED
@@ -0,0 +1,172 @@
1
+ #!/usr/bin/env node
2
+
3
+ import { program } from 'commander';
4
+ import chalk from 'chalk';
5
+ import ora from 'ora';
6
+ import { GitHubClient, parseFiles, parsePRReference } from './github.js';
7
+ import { issuesToReviewComments, generateSummary } from './analyzer.js';
8
+ import { analyzeWithCodex, type CodexOptions } from './codex.js';
9
+ import { analyzeWithClaude, type ClaudeOptions } from './claude.js';
10
+ import { cloneRepo } from './repo.js';
11
+ import { createRequire } from 'module';
12
+ import type { CLIOptions, CodeIssue } from './types.js';
13
+
14
+ const require = createRequire(import.meta.url);
15
+ const { version } = require('../package.json');
16
+
17
+ program
18
+ .name('nayan-ai')
19
+ .description('AI-powered PR code reviewer using Codex CLI')
20
+ .version(version)
21
+ .argument('<pr-url>', 'GitHub PR URL (e.g., https://github.com/owner/repo/pull/123)')
22
+ .requiredOption('-t, --token <token>', 'GitHub personal access token')
23
+ .option('-v, --verbose', 'Show real-time output from Codex CLI', false)
24
+ .option('-d, --dry', 'Only analyze, don\'t post comments', false)
25
+ .option('-i, --inline', 'Post inline comments instead of summary', false)
26
+ .option('-l, --llm <provider>', 'LLM provider: codex (default) or claude', 'codex')
27
+ .option('--format <format>', 'Output format: text, json', 'text')
28
+ .action(run);
29
+
30
+ program.parse();
31
+
32
+ async function run(prUrl: string, options: CLIOptions) {
33
+ try {
34
+ const prInfo = parsePRReference(prUrl);
35
+ const githubUrl = prInfo.githubUrl;
36
+
37
+ console.log(chalk.bold.blue('\nšŸ¤– Nayan AI - PR Reviewer'));
38
+ console.log('━'.repeat(40));
39
+ console.log(` Repository: ${chalk.cyan(`${prInfo.owner}/${prInfo.repo}`)}`);
40
+ console.log(` PR Number: ${chalk.cyan(`#${prInfo.number}`)}`);
41
+ if (githubUrl) {
42
+ console.log(` GitHub: ${chalk.cyan(githubUrl)}`);
43
+ }
44
+ if (options.dry) {
45
+ console.log(` Mode: ${chalk.yellow('Dry Run (no comments will be posted)')}`);
46
+ }
47
+ console.log('━'.repeat(40) + '\n');
48
+
49
+ const github = new GitHubClient(options.token, githubUrl);
50
+
51
+ // Fetch PR details
52
+ let spinner = ora('Fetching PR details...').start();
53
+ const pr = await github.getPullRequest(prInfo);
54
+ spinner.succeed(`PR: ${pr.title}`);
55
+
56
+ // Fetch changed files
57
+ spinner = ora('Fetching changed files...').start();
58
+ const files = await github.getPullRequestFiles(prInfo);
59
+ const fileChanges = parseFiles(files);
60
+ spinner.succeed(`Found ${fileChanges.length} files with changes`);
61
+
62
+ // Clone and analyze with Codex CLI
63
+ spinner = ora('Cloning repository...').start();
64
+ const repo = await cloneRepo(prInfo, options.token, githubUrl);
65
+
66
+ let issues: CodeIssue[];
67
+ try {
68
+ spinner.succeed('Repository cloned');
69
+
70
+ const llmName = options.llm === 'claude' ? 'Claude Code' : 'Codex';
71
+ console.log(chalk.cyan(`Running code review with ${llmName} CLI...\n`));
72
+
73
+ if (options.llm === 'claude') {
74
+ const claudeOpts: ClaudeOptions = { verbose: options.verbose };
75
+ issues = await analyzeWithClaude(repo.path, 'origin/main', claudeOpts);
76
+ } else {
77
+ const codexOpts: CodexOptions = { verbose: options.verbose };
78
+ issues = await analyzeWithCodex(repo.path, 'origin/main', codexOpts);
79
+ }
80
+ console.log(chalk.green(`\nāœ” Analysis complete: ${issues.length} issues found`));
81
+ } finally {
82
+ await repo.cleanup();
83
+ }
84
+
85
+ // Display results
86
+ console.log(chalk.bold('\nšŸ“‹ Review Summary'));
87
+ console.log('─'.repeat(41));
88
+
89
+ if (options.format === 'json') {
90
+ console.log(JSON.stringify(issues, null, 2));
91
+ } else {
92
+ printIssuesSummary(issues);
93
+ }
94
+
95
+ // Post to GitHub
96
+ if (!options.dry) {
97
+ spinner = ora('Posting review to GitHub...').start();
98
+
99
+ if (options.inline) {
100
+ // Post inline comments on specific lines
101
+ const reviewComments = issuesToReviewComments(issues);
102
+ const summary = generateSummary(issues);
103
+ await github.postReview(prInfo, pr.head.sha, summary, reviewComments);
104
+ spinner.succeed('Inline review posted to GitHub');
105
+ } else {
106
+ // Post summary as a PR-level comment (default)
107
+ const summary = generateSummary(issues);
108
+ await github.postComment(prInfo, summary);
109
+ spinner.succeed('Review summary posted to GitHub');
110
+ }
111
+ } else {
112
+ console.log(chalk.yellow('\nDry run mode - no comments posted to GitHub'));
113
+ }
114
+
115
+ console.log(chalk.green('\nāœ… Review complete!\n'));
116
+ } catch (error) {
117
+ console.error(chalk.red('\nError:'), error instanceof Error ? error.message : error);
118
+ process.exit(1);
119
+ }
120
+ }
121
+
122
+ function printIssuesSummary(issues: CodeIssue[]) {
123
+ if (issues.length === 0) {
124
+ console.log(chalk.green(' No issues found! The code looks good.'));
125
+ return;
126
+ }
127
+
128
+ const bySeverity = {
129
+ error: issues.filter((i) => i.severity === 'error'),
130
+ warning: issues.filter((i) => i.severity === 'warning'),
131
+ info: issues.filter((i) => i.severity === 'info'),
132
+ };
133
+
134
+ // Print errors
135
+ if (bySeverity.error.length > 0) {
136
+ console.log(chalk.red.bold(`\n šŸ”“ Errors (${bySeverity.error.length}):`));
137
+ for (const issue of bySeverity.error) {
138
+ const icon = issue.category === 'functionality' ? 'šŸ›' : issue.category === 'readability' ? 'šŸ“–' : '⚔';
139
+ console.log(chalk.red(` ${icon} ${issue.filename}:${issue.line}`));
140
+ console.log(` ${issue.message}`);
141
+ if (issue.suggestion) {
142
+ console.log(chalk.dim(` šŸ’” ${issue.suggestion}`));
143
+ }
144
+ }
145
+ }
146
+
147
+ // Print warnings
148
+ if (bySeverity.warning.length > 0) {
149
+ console.log(chalk.yellow.bold(`\n 🟔 Warnings (${bySeverity.warning.length}):`));
150
+ for (const issue of bySeverity.warning) {
151
+ const icon = issue.category === 'functionality' ? 'šŸ›' : issue.category === 'readability' ? 'šŸ“–' : '⚔';
152
+ console.log(chalk.yellow(` ${icon} ${issue.filename}:${issue.line}`));
153
+ console.log(` ${issue.message}`);
154
+ if (issue.suggestion) {
155
+ console.log(chalk.dim(` šŸ’” ${issue.suggestion}`));
156
+ }
157
+ }
158
+ }
159
+
160
+ // Print info
161
+ if (bySeverity.info.length > 0) {
162
+ console.log(chalk.blue.bold(`\n šŸ”µ Info (${bySeverity.info.length}):`));
163
+ for (const issue of bySeverity.info) {
164
+ const icon = issue.category === 'functionality' ? 'šŸ›' : issue.category === 'readability' ? 'šŸ“–' : '⚔';
165
+ console.log(chalk.blue(` ${icon} ${issue.filename}:${issue.line}`));
166
+ console.log(` ${issue.message}`);
167
+ if (issue.suggestion) {
168
+ console.log(chalk.dim(` šŸ’” ${issue.suggestion}`));
169
+ }
170
+ }
171
+ }
172
+ }
package/src/codex.ts ADDED
@@ -0,0 +1,158 @@
1
+ import { spawn } from 'child_process';
2
+ import type { CodeIssue } from './types.js';
3
+ import { logProcessor, type CodexEvent } from './logs.js';
4
+ import { getReviewPrompt } from './prompt.js';
5
+
6
+ export interface CodexOptions {
7
+ verbose?: boolean;
8
+ }
9
+
10
+ export async function analyzeWithCodex(
11
+ repoPath: string,
12
+ baseBranch: string = 'origin/main',
13
+ options: CodexOptions
14
+ ): Promise<CodeIssue[]> {
15
+ const response = await runCodexExec(repoPath, baseBranch, options);
16
+ return parseCodexResponse(response);
17
+ }
18
+
19
+ async function runCodexExec(
20
+ repoPath: string,
21
+ baseBranch: string,
22
+ options: CodexOptions
23
+ ): Promise<string> {
24
+ const prompt = getReviewPrompt(baseBranch);
25
+
26
+ return new Promise((resolve, reject) => {
27
+ // Use codex exec with --json for structured output
28
+ const args = ['@openai/codex', 'exec', '--json', '--full-auto', prompt];
29
+
30
+ if (options.verbose) {
31
+ console.log(`\n[Codex] Running: npx ${args.join(' ')}`);
32
+ console.log(`[Codex] Working directory: ${repoPath}`);
33
+ }
34
+
35
+ const startTime = Date.now();
36
+
37
+ const child = spawn('npx', args, {
38
+ cwd: repoPath,
39
+ stdio: ['pipe', 'pipe', 'pipe'],
40
+ });
41
+
42
+ let stdout = '';
43
+ let stderr = '';
44
+
45
+ // Reset log processor for new analysis
46
+ logProcessor.reset();
47
+
48
+ child.stdout.on('data', (data) => {
49
+ const chunk = data.toString();
50
+ stdout += chunk;
51
+
52
+ // Parse JSONL and show progress
53
+ for (const line of chunk.split('\n')) {
54
+ if (!line.trim()) continue;
55
+ try {
56
+ const event: CodexEvent = JSON.parse(line);
57
+
58
+ if (options.verbose) {
59
+ console.log(JSON.stringify(event, null, 2));
60
+ } else {
61
+ // Use log processor for nice interactive output
62
+ logProcessor.processEvent(event);
63
+ }
64
+ } catch {
65
+ // Not JSON
66
+ }
67
+ }
68
+ });
69
+
70
+ child.stderr.on('data', (data) => {
71
+ const chunk = data.toString();
72
+ stderr += chunk;
73
+ if (options.verbose) {
74
+ process.stderr.write(chunk);
75
+ }
76
+ });
77
+
78
+ child.on('close', (code) => {
79
+ const elapsed = Date.now() - startTime;
80
+
81
+ console.log(`\n[Codex] Completed in ${(elapsed / 1000).toFixed(1)}s`);
82
+
83
+ if (code !== 0) {
84
+ const errorMsg = stderr || stdout || 'Unknown error';
85
+ reject(new Error(`Codex review failed (exit ${code}): ${errorMsg.slice(0, 500)}`));
86
+ return;
87
+ }
88
+
89
+ resolve(stdout);
90
+ });
91
+
92
+ child.on('error', (err) => {
93
+ if ((err as NodeJS.ErrnoException).code === 'ENOENT') {
94
+ reject(new Error('npx not found. Install Node.js/npm (Node 18+) to run nayan-ai.'));
95
+ return;
96
+ }
97
+ reject(err);
98
+ });
99
+ });
100
+ }
101
+
102
+ function parseCodexResponse(response: string): CodeIssue[] {
103
+ // codex exec --json outputs JSONL - parse each line looking for agent_message with issues
104
+ const lines = response.split('\n');
105
+
106
+ for (const line of lines) {
107
+ if (!line.trim()) continue;
108
+
109
+ try {
110
+ const event = JSON.parse(line);
111
+
112
+ // Look for agent_message in item.completed events
113
+ if (event.type === 'item.completed' && event.item?.type === 'agent_message') {
114
+ const text = event.item.text;
115
+ if (text) {
116
+ // Parse the JSON from the agent's message
117
+ const issuesJson = JSON.parse(text);
118
+ if (issuesJson.issues && Array.isArray(issuesJson.issues)) {
119
+ return issuesJson.issues
120
+ .filter((item: any) => item.message)
121
+ .map((item: any) => ({
122
+ filename: item.filename || 'unknown',
123
+ line: item.line || 0,
124
+ category: item.category || 'functionality',
125
+ severity: item.severity || 'info',
126
+ message: item.message,
127
+ suggestion: item.suggestion,
128
+ }));
129
+ }
130
+ }
131
+ }
132
+ } catch {
133
+ // Not valid JSON, skip
134
+ }
135
+ }
136
+
137
+ // Fallback: try to find raw JSON in the response
138
+ const jsonMatch = response.match(/\{\s*"issues"\s*:\s*\[[\s\S]*?\]\s*\}/);
139
+ if (jsonMatch) {
140
+ try {
141
+ const parsed = JSON.parse(jsonMatch[0]);
142
+ return (parsed.issues || [])
143
+ .filter((item: any) => item.message)
144
+ .map((item: any) => ({
145
+ filename: item.filename || 'unknown',
146
+ line: item.line || 0,
147
+ category: item.category || 'functionality',
148
+ severity: item.severity || 'info',
149
+ message: item.message,
150
+ suggestion: item.suggestion,
151
+ }));
152
+ } catch {
153
+ // ignore
154
+ }
155
+ }
156
+
157
+ return [];
158
+ }
package/src/github.ts ADDED
@@ -0,0 +1,108 @@
1
+ import type { PRInfo, PullRequest, PullRequestFile, FileChange, ReviewComment } from './types.js';
2
+
3
+ export class GitHubClient {
4
+ private token: string;
5
+ private apiBase: string;
6
+
7
+ constructor(token: string, githubUrl?: string) {
8
+ this.token = token;
9
+ this.apiBase = githubUrl
10
+ ? `${githubUrl.replace(/\/$/, '')}/api/v3`
11
+ : 'https://api.github.com';
12
+ }
13
+
14
+ private async fetch<T>(endpoint: string, options: RequestInit = {}): Promise<T> {
15
+ const url = `${this.apiBase}${endpoint}`;
16
+ const response = await fetch(url, {
17
+ ...options,
18
+ headers: {
19
+ 'Accept': 'application/vnd.github.v3+json',
20
+ 'Authorization': `Bearer ${this.token}`,
21
+ 'User-Agent': 'nayan-ai',
22
+ ...options.headers,
23
+ },
24
+ });
25
+
26
+ if (!response.ok) {
27
+ const body = await response.text();
28
+ throw new Error(`GitHub API error (${response.status}): ${body}`);
29
+ }
30
+
31
+ return response.json() as Promise<T>;
32
+ }
33
+
34
+ async getPullRequest(pr: PRInfo): Promise<PullRequest> {
35
+ return this.fetch<PullRequest>(`/repos/${pr.owner}/${pr.repo}/pulls/${pr.number}`);
36
+ }
37
+
38
+ async getPullRequestFiles(pr: PRInfo): Promise<PullRequestFile[]> {
39
+ return this.fetch<PullRequestFile[]>(`/repos/${pr.owner}/${pr.repo}/pulls/${pr.number}/files?per_page=100`);
40
+ }
41
+
42
+ async postReview(
43
+ pr: PRInfo,
44
+ commitId: string,
45
+ body: string,
46
+ comments: ReviewComment[]
47
+ ): Promise<void> {
48
+ await this.fetch(`/repos/${pr.owner}/${pr.repo}/pulls/${pr.number}/reviews`, {
49
+ method: 'POST',
50
+ headers: { 'Content-Type': 'application/json' },
51
+ body: JSON.stringify({
52
+ commit_id: commitId,
53
+ body,
54
+ event: 'COMMENT',
55
+ comments,
56
+ }),
57
+ });
58
+ }
59
+
60
+ async postComment(pr: PRInfo, body: string): Promise<void> {
61
+ await this.fetch(`/repos/${pr.owner}/${pr.repo}/issues/${pr.number}/comments`, {
62
+ method: 'POST',
63
+ headers: { 'Content-Type': 'application/json' },
64
+ body: JSON.stringify({ body }),
65
+ });
66
+ }
67
+ }
68
+
69
+ export function parseFiles(files: PullRequestFile[]): FileChange[] {
70
+ return files
71
+ .filter((f): f is PullRequestFile & { patch: string } => !!f.patch)
72
+ .map((f) => ({
73
+ filename: f.filename,
74
+ patch: f.patch,
75
+ }));
76
+ }
77
+
78
+ export function parsePRReference(input: string): PRInfo & { githubUrl?: string } {
79
+ // Full URL: https://github.com/owner/repo/pull/123 or https://enterprise.example.com/owner/repo/pull/123
80
+ const urlMatch = input.match(/^(https?:\/\/[^/]+)\/([^/]+)\/([^/]+)\/pull\/(\d+)/);
81
+ if (urlMatch) {
82
+ const baseUrl = urlMatch[1];
83
+ // Only github.com (exactly) is public GitHub, everything else is enterprise
84
+ const isEnterprise = !baseUrl.match(/^https?:\/\/(www\.)?github\.com$/i);
85
+ return {
86
+ owner: urlMatch[2],
87
+ repo: urlMatch[3],
88
+ number: parseInt(urlMatch[4], 10),
89
+ githubUrl: isEnterprise ? baseUrl : undefined,
90
+ };
91
+ }
92
+
93
+ // Short reference: owner/repo#123
94
+ const shortMatch = input.match(/^([^/]+)\/([^#]+)#(\d+)$/);
95
+ if (shortMatch) {
96
+ return {
97
+ owner: shortMatch[1],
98
+ repo: shortMatch[2],
99
+ number: parseInt(shortMatch[3], 10),
100
+ };
101
+ }
102
+
103
+ throw new Error(
104
+ 'Invalid PR reference. Use:\n' +
105
+ ' - https://github.com/owner/repo/pull/123\n' +
106
+ ' - owner/repo#123'
107
+ );
108
+ }
package/src/index.ts ADDED
@@ -0,0 +1,6 @@
1
+ export { GitHubClient, parseFiles, parsePRReference } from './github.js';
2
+ export { analyzeWithCodex } from './codex.js';
3
+ export { analyzeWithClaude } from './claude.js';
4
+ export { issuesToReviewComments, generateSummary } from './analyzer.js';
5
+ export { cloneRepo, getGitDiff, getChangedFiles } from './repo.js';
6
+ export type * from './types.js';