Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
19 changes: 13 additions & 6 deletions src/ai/claude.ts
Original file line number Diff line number Diff line change
@@ -1,5 +1,5 @@
import Anthropic from '@anthropic-ai/sdk';
import type { AIProvider } from './provider.js';
import type { AIProvider, AIResponse, AIRequestOptions } from './provider.js';

export class ClaudeProvider implements AIProvider {
private client: Anthropic;
Expand All @@ -14,17 +14,24 @@ export class ClaudeProvider implements AIProvider {
this.model = model;
}

async complete(prompt: string): Promise<string> {
async complete(prompt: string, options?: AIRequestOptions): Promise<AIResponse> {
const response = await this.client.messages.create({
model: this.model,
max_tokens: 1024,
max_tokens: options?.maxTokens ?? 1024,
messages: [{ role: 'user', content: prompt }],
});

const block = response.content[0];
if (block.type === 'text') {
return block.text;
if (block.type !== 'text') {
throw new Error('Unexpected response type from Claude API');
}
throw new Error('Unexpected response type from Claude API');

return {
text: block.text,
usage: {
inputTokens: response.usage.input_tokens,
outputTokens: response.usage.output_tokens,
},
};
}
}
2 changes: 1 addition & 1 deletion src/ai/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -4,7 +4,7 @@ import { ClaudeProvider } from './claude.js';
import { OpenAIProvider } from './openai.js';
import { OllamaProvider } from './ollama.js';

export type { AIProvider } from './provider.js';
export type { AIProvider, AIResponse, AIUsage, AIRequestOptions } from './provider.js';

export function createAIProvider(config: RepoKeeperConfig['ai']): AIProvider {
switch (config.provider) {
Expand Down
21 changes: 17 additions & 4 deletions src/ai/ollama.ts
Original file line number Diff line number Diff line change
@@ -1,4 +1,10 @@
import type { AIProvider } from './provider.js';
import type { AIProvider, AIResponse, AIRequestOptions } from './provider.js';

interface OllamaResponse {
response: string;
prompt_eval_count?: number;
eval_count?: number;
}

export class OllamaProvider implements AIProvider {
private url: string;
Expand All @@ -9,22 +15,29 @@ export class OllamaProvider implements AIProvider {
this.model = model;
}

async complete(prompt: string): Promise<string> {
async complete(prompt: string, options?: AIRequestOptions): Promise<AIResponse> {
const response = await fetch(`${this.url}/api/generate`, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({
model: this.model,
prompt,
stream: false,
...(options?.maxTokens ? { options: { num_predict: options.maxTokens } } : {}),
}),
});

if (!response.ok) {
throw new Error(`Ollama API error: ${response.status} ${response.statusText}`);
}

const data = (await response.json()) as { response: string };
return data.response;
const data = (await response.json()) as OllamaResponse;
return {
text: data.response,
usage: {
inputTokens: data.prompt_eval_count ?? 0,
outputTokens: data.eval_count ?? 0,
},
};
}
}
15 changes: 11 additions & 4 deletions src/ai/openai.ts
Original file line number Diff line number Diff line change
@@ -1,5 +1,5 @@
import OpenAI from 'openai';
import type { AIProvider } from './provider.js';
import type { AIProvider, AIResponse, AIRequestOptions } from './provider.js';

export class OpenAIProvider implements AIProvider {
private client: OpenAI;
Expand All @@ -15,17 +15,24 @@ export class OpenAIProvider implements AIProvider {
this.model = model;
}

async complete(prompt: string): Promise<string> {
async complete(prompt: string, options?: AIRequestOptions): Promise<AIResponse> {
const response = await this.client.chat.completions.create({
model: this.model,
messages: [{ role: 'user', content: prompt }],
max_tokens: 1024,
max_tokens: options?.maxTokens ?? 1024,
});

const content = response.choices[0]?.message?.content;
if (!content) {
throw new Error('Empty response from OpenAI API');
}
return content;

return {
text: content,
usage: {
inputTokens: response.usage?.prompt_tokens ?? 0,
outputTokens: response.usage?.completion_tokens ?? 0,
},
};
}
}
16 changes: 15 additions & 1 deletion src/ai/provider.ts
Original file line number Diff line number Diff line change
@@ -1,3 +1,17 @@
export interface AIUsage {
inputTokens: number;
outputTokens: number;
}

export interface AIResponse {
text: string;
usage: AIUsage;
}

export interface AIRequestOptions {
maxTokens?: number;
}

export interface AIProvider {
complete(prompt: string): Promise<string>;
complete(prompt: string, options?: AIRequestOptions): Promise<AIResponse>;
}
4 changes: 4 additions & 0 deletions src/config.ts
Original file line number Diff line number Diff line change
Expand Up @@ -41,6 +41,8 @@ export interface RepoKeeperConfig {
focus: string[];
maxContextFiles: number;
minDiffLines: number;
ignore?: string[];
commitStatus?: boolean;
};
port: number;
}
Expand Down Expand Up @@ -71,6 +73,8 @@ const defaults: RepoKeeperConfig = {
focus: ['security', 'performance', 'test-coverage', 'breaking-changes'],
maxContextFiles: 5,
minDiffLines: 10,
ignore: [],
commitStatus: false,
},
port: 3001,
};
Expand Down
17 changes: 17 additions & 0 deletions src/github/client.ts
Original file line number Diff line number Diff line change
Expand Up @@ -125,6 +125,23 @@ export class GitHubClient {
}));
}

async createCommitStatus(
sha: string,
state: 'success' | 'failure' | 'pending',
description: string,
context: string = 'repokeeper/review',
): Promise<void> {
await this.octokit.repos.createCommitStatus({
owner: this.owner,
repo: this.repo,
sha,
state,
description,
context,
});
log('info', `Set commit status ${state} on ${sha.slice(0, 7)}: ${description}`);
}

private async ensureLabelsExist(labels: string[]): Promise<void> {
const existing = await this.octokit.paginate(this.octokit.issues.listLabelsForRepo, {
owner: this.owner,
Expand Down
4 changes: 2 additions & 2 deletions src/pr/summariser.ts
Original file line number Diff line number Diff line change
Expand Up @@ -80,7 +80,7 @@ export async function handlePullRequest(
.replace('{description}', body || '(no description)')
.replace('{diff}', truncatedDiff);

const summary = await ai.complete(prompt);
const { text: summary } = await ai.complete(prompt);

// Build the comment
const fileList = files
Expand Down Expand Up @@ -122,7 +122,7 @@ export async function handlePullRequestMerged(
.replace('{description}', body || '(no description)')
.replace('{fileSummary}', fileSummary);

const releaseNotes = await ai.complete(prompt);
const { text: releaseNotes } = await ai.complete(prompt);

const comment =
`## Release Notes\n\n${releaseNotes}\n\n` +
Expand Down
70 changes: 63 additions & 7 deletions src/review/reviewer.ts
Original file line number Diff line number Diff line change
Expand Up @@ -9,6 +9,48 @@ import { isAlreadyReviewed, markReviewed, parseDiffHunks, cleanupPR } from './hu
import { getAcceptedPatterns, formatAcceptedPatternsPrompt, learnFromMergedPR } from './memory.js';
import type { PRReviewPayload, ReviewResult, ReviewFinding, CodeReviewConfig, EnrichedFile } from './types.js';

export function matchesIgnorePattern(file: string, pattern: string): boolean {
// Escape regex metacharacters first (except glob chars * ? which we handle separately)
const regexStr = pattern
.replace(/[.+^${}()|[\]\\]/g, '\\$&')
.replace(/\*\*\//g, '{{GLOBSTAR_SLASH}}')
.replace(/\*\*/g, '{{GLOBSTAR}}')
.replace(/\*/g, '[^/]*')
.replace(/\?/g, '[^/]')
.replace(/{{GLOBSTAR_SLASH}}/g, '(?:.+/)?')
.replace(/{{GLOBSTAR}}/g, '.*');
return new RegExp(`^${regexStr}$`).test(file);
}

export function filterIgnoredFiles(files: string[], ignorePatterns: string[]): string[] {
if (!ignorePatterns || ignorePatterns.length === 0) return files;
return files.filter((file) => !ignorePatterns.some((pattern) => matchesIgnorePattern(file, pattern)));
}

function filterDiffByFiles(diff: string, allowedFiles: string[]): string {
const allowedSet = new Set(allowedFiles);
const sections = diff.split(/(?=^diff --git )/m);
return sections
.filter((section) => {
const match = section.match(/^diff --git a\/(.+?) b\//);
if (!match) return false;
return allowedSet.has(match[1]);
})
.join('');
}

export function buildCommitStatus(findings: ReviewFinding[]): { state: 'success' | 'failure'; description: string } {
const hasBlocking = findings.some((f) => f.severity === 'BLOCKING');
if (hasBlocking) {
const count = findings.filter((f) => f.severity === 'BLOCKING').length;
return { state: 'failure', description: `${count} blocking issue(s) found` };
}
if (findings.length > 0) {
return { state: 'success', description: `${findings.length} non-blocking finding(s)` };
}
return { state: 'success', description: 'No issues found' };
}

const DEFAULT_REVIEW_CONFIG: CodeReviewConfig = {
enabled: true,
focus: ['security', 'performance', 'test-coverage', 'breaking-changes'],
Expand Down Expand Up @@ -154,11 +196,18 @@ export async function handleCodeReview(
// Get changed file names from hunks
const changedFiles = [...new Set(hunks.map((h) => h.file))];

// Filter out ignored files
const reviewableFiles = filterIgnoredFiles(changedFiles, reviewConfig.ignore ?? []);
if (reviewableFiles.length === 0) {
log('info', `PR #${prNumber}: all changed files match ignore patterns, skipping review`);
return;
}

// Build codebase context
let enrichedFiles: EnrichedFile[] = [];
try {
enrichedFiles = await buildContext(
changedFiles,
reviewableFiles,
repository.clone_url,
owner,
repo,
Expand All @@ -174,21 +223,28 @@ export async function handleCodeReview(
// Get accepted patterns from memory
const acceptedPatterns = formatAcceptedPatternsPrompt(getAcceptedPatterns());

// Truncate diff for AI
// Filter diff to only include reviewable files and truncate for AI
const filteredDiff = filterDiffByFiles(diff, reviewableFiles);
const maxDiffChars = 30_000;
const truncatedDiff = diff.length > maxDiffChars
? diff.slice(0, maxDiffChars) + '\n\n... (diff truncated)'
: diff;
const truncatedDiff = filteredDiff.length > maxDiffChars
? filteredDiff.slice(0, maxDiffChars) + '\n\n... (diff truncated)'
: filteredDiff;

// Build prompt and call AI
const prompt = buildReviewPrompt(truncatedDiff, enrichedFiles, reviewConfig, acceptedPatterns);
const aiResponse = await ai.complete(prompt);
const result = parseAIResponse(aiResponse);
const { text: aiResponseText } = await ai.complete(prompt);
const result = parseAIResponse(aiResponseText);

// Post review via GitHub API
const octokit = new Octokit({ auth: config.github.token });
await postReview(octokit, owner, repo, prNumber, headSha, result);

// Post commit status if enabled
if (reviewConfig.commitStatus) {
const { state, description } = buildCommitStatus(result.findings);
await github.createCommitStatus(headSha, state, description);
}

// Mark this SHA as reviewed
markReviewed(owner, repo, prNumber, headSha);

Expand Down
2 changes: 2 additions & 0 deletions src/review/types.ts
Original file line number Diff line number Diff line change
Expand Up @@ -36,6 +36,8 @@ export interface CodeReviewConfig {
focus: string[];
maxContextFiles: number;
minDiffLines: number;
ignore?: string[];
commitStatus?: boolean;
}

export interface PRReviewPayload {
Expand Down
2 changes: 1 addition & 1 deletion src/triage/classifier.ts
Original file line number Diff line number Diff line change
Expand Up @@ -51,7 +51,7 @@ export async function classifyIssue(
}

const prompt = CLASSIFY_PROMPT.replace('{title}', title).replace('{body}', body || '(empty)');
const result = (await ai.complete(prompt)).trim().toLowerCase();
const result = (await ai.complete(prompt)).text.trim().toLowerCase();

const valid: IssueCategory[] = ['bug', 'feature', 'question', 'duplicate', 'docs', 'invalid'];
if (valid.includes(result as IssueCategory)) {
Expand Down
2 changes: 1 addition & 1 deletion src/triage/duplicate.ts
Original file line number Diff line number Diff line change
Expand Up @@ -87,7 +87,7 @@ async function findDuplicatesWithAI(
.replace('{existingTitle}', candidate.title)
.replace('{existingBody}', candidate.body || '(empty)');

const response = (await ai.complete(prompt)).trim();
const response = (await ai.complete(prompt)).text.trim();
const score = parseFloat(response);

if (!isNaN(score) && score >= 0 && score <= 1 && score >= threshold) {
Expand Down
2 changes: 1 addition & 1 deletion src/triage/responder.ts
Original file line number Diff line number Diff line change
Expand Up @@ -110,7 +110,7 @@ async function generateComment(
.replace('{category}', category);

try {
const response = (await ai.complete(prompt)).trim();
const response = (await ai.complete(prompt)).text.trim();
// Sanity check: if AI returns something too short or suspicious, use fallback
if (response.length < 20) {
return buildFallbackComment(category, title);
Expand Down
2 changes: 1 addition & 1 deletion tests/classifier.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -3,7 +3,7 @@ import { classifyIssue, categoryToLabel, isVagueIssue } from '../src/triage/clas
import type { AIProvider } from '../src/ai/provider.js';

function mockAI(response: string): AIProvider {
return { complete: async () => response };
return { complete: async () => ({ text: response, usage: { inputTokens: 0, outputTokens: 0 } }) };
}

describe('isVagueIssue', () => {
Expand Down
10 changes: 6 additions & 4 deletions tests/duplicate.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -14,17 +14,19 @@ const installIssues = [
{ number: 4, title: 'Add dark mode support', body: 'Please add dark mode theme to the settings page' },
];

const usage = { inputTokens: 0, outputTokens: 0 };

function mockAI(response: string): AIProvider {
return { complete: async () => response };
return { complete: async () => ({ text: response, usage }) };
}

function mockAIDynamic(responses: Map<string, string>): AIProvider {
return {
complete: async (prompt: string) => {
for (const [key, value] of responses) {
if (prompt.includes(key)) return value;
if (prompt.includes(key)) return { text: value, usage };
}
return '0.1';
return { text: '0.1', usage };
},
};
}
Expand Down Expand Up @@ -168,7 +170,7 @@ describe('findDuplicates with AI', () => {
const countingAI: AIProvider = {
complete: async () => {
aiCallCount++;
return '0.3';
return { text: '0.3', usage };
},
};

Expand Down
Loading
Loading