diff-hound 1.0.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/README.md ADDED
@@ -0,0 +1,221 @@
1
+ # Diff Hound
2
+
3
+ Diff Hound is an automated AI-powered code review tool that posts intelligent, contextual comments directly on pull requests across supported platforms.
4
+
5
+ Supports GitHub today. GitLab and Bitbucket support are planned.
6
+
7
+ ---
8
+
9
+ ## ✨ Features
10
+
11
+ - 🧠 Automated code review using OpenAI (Upcoming: Claude, DeepSeek, CodeLlama)
12
+ - 💬 Posts inline or summary comments on pull requests
13
+ - 🔌 Plug-and-play architecture for models and platforms
14
+ - ⚙️ Configurable with JSON/YAML config files and CLI overrides
15
+ - 🛠️ Designed for CI/CD pipelines and local runs
16
+ - 🧐 Tracks last reviewed commit to avoid duplicate reviews
17
+
18
+ ---
19
+
20
+ ## 🛠️ Installation
21
+
22
+ ### Option 1: Install via npm
23
+
24
+ ```bash
25
+ npm install -g diff-hound
26
+ ```
27
+
28
+ ### Option 2: Install from source
29
+
30
+ ```bash
31
+ git clone https://github.com/runtimebug/diff-hound.git
32
+ cd diff-hound
33
+ npm install
34
+ npm run build
35
+ npm link
36
+ ```
37
+
38
+ ---
39
+
40
+ ## 🚀 How to Use
41
+
42
+ ### Step 1: Setup Environment Variables
43
+
44
+ Copy the provided `.env.example` to `.env` and fill in your credentials:
45
+
46
+ ```bash
47
+ cp .env.example .env
48
+ ```
49
+
50
+ Then modify with your keys / tokens:
51
+
52
+ ```env
53
+ # Platform tokens
54
+ GITHUB_TOKEN=your_github_token # Requires 'repo' scope
55
+
56
+ # AI Model API keys
57
+ OPENAI_API_KEY=your_openai_key
58
+ ```
59
+
60
+ > 🔐 `GITHUB_TOKEN` is used to fetch PRs and post comments – [get it here](https://github.com/settings/personal-access-tokens)
61
+ > 🔐 `OPENAI_API_KEY` is used to generate code reviews via GPT – [get it here](https://platform.openai.com/api-keys)
62
+
63
+ ---
64
+
65
+ ### Step 2: Create a Config File
66
+
67
+ You can define your config in `.aicodeconfig.json` or `.aicode.yml`:
68
+
69
+ #### JSON Example (`.aicodeconfig.json`)
70
+
71
+ ```json
72
+ {
73
+ "provider": "openai",
74
+ "model": "gpt-4o", // Or any other openai model
75
+ "endpoint": "", // Optional: custom endpoint
76
+ "gitProvider": "github",
77
+ "repo": "your-username/your-repo",
78
+ "dryRun": false,
79
+ "verbose": false,
80
+ "rules": [
81
+ "Prefer const over let when variables are not reassigned",
82
+ "Avoid reassigning const variables",
83
+ "Add descriptive comments for complex logic",
84
+ "Remove unnecessary comments",
85
+ "Follow the DRY (Don't Repeat Yourself) principle",
86
+ "Use descriptive variable and function names",
87
+ "Handle errors appropriately",
88
+ "Add type annotations where necessary"
89
+ ],
90
+ "ignoreFiles": ["*.md", "package-lock.json", "yarn.lock", "LICENSE", "*.log"],
91
+ "commentStyle": "inline",
92
+ "severity": "suggestion"
93
+ }
94
+ ```
95
+
96
+ #### YAML Example (`.aicode.yml`)
97
+
98
+ ```yaml
99
+ provider: openai
100
+ model: gpt-4o # Or any other openai model
101
+ endpoint: "" # Optional: custom endpoint
102
+ gitProvider: github
103
+ repo: your-username/your-repo
104
+ dryRun: false
105
+ verbose: false
106
+ commentStyle: inline
107
+ severity: suggestion
108
+ ignoreFiles:
109
+ - "*.md"
110
+ - package-lock.json
111
+ - yarn.lock
112
+ - LICENSE
113
+ - "*.log"
114
+ rules:
115
+ - Prefer const over let when variables are not reassigned
116
+ - Avoid reassigning const variables
117
+ - Add descriptive comments for complex logic
118
+ - Remove unnecessary comments
119
+ - Follow the DRY (Don't Repeat Yourself) principle
120
+ - Use descriptive variable and function names
121
+ - Handle errors appropriately
122
+ - Add type annotations where necessary
123
+ ```
124
+
125
+ ---
126
+
127
+ ### Step 3: Run It
128
+
129
+ ```bash
130
+ diff-hound
131
+ ```
132
+
133
+ Or override config values via CLI:
134
+
135
+ ```bash
136
+ diff-hound --repo=owner/repo --provider=openai --model=gpt-4o --dry-run
137
+ ```
138
+
139
+ > Add `--dry-run` to **print comments to console** instead of posting them.
140
+
141
+ ---
142
+
143
+ ### Output Example (Dry Run)
144
+
145
+ ```bash
146
+ == Comments for PR #42: Fix input validation ==
147
+
148
+ src/index.ts:17 —
149
+ Prefer `const` over `let` since `userId` is not reassigned.
150
+
151
+ src/utils/parse.ts:45 —
152
+ Consider refactoring to reduce nesting.
153
+ ```
154
+
155
+ ---
156
+
157
+ ### Optional CLI Flags
158
+
159
+ | Flag | Short | Description |
160
+ | ------------------ | ----- | --------------------------------------- |
161
+ | `--provider` | `-p` | AI model provider (e.g. `openai`) |
162
+ | `--model` | `-m` | AI model (e.g. `gpt-4o`, `gpt-4`, etc.) |
163
+ | `--model-endpoint` | `-e` | Custom API endpoint for the model |
164
+ | `--git-provider` | `-g` | Repo platform (default: `github`) |
165
+ | `--repo` | `-r` | GitHub repo in format `owner/repo` |
166
+ | `--comment-style` | `-s` | `inline` or `summary` |
167
+ | `--dry-run` | `-d` | Don’t post comments, only print |
168
+ | `--verbose` | `-v` | Enable debug logs |
169
+ | `--config-path` | `-c` | Custom config file path |
170
+
171
+ ---
172
+
173
+ ## 🛠️ Development
174
+
175
+ ### Project Structure
176
+
177
+ ```
178
+ diff-hound/
179
+ ├── bin/ # CLI entrypoint
180
+ ├── src/
181
+ │ ├── cli/ # CLI argument parsing
182
+ │ ├── config/ # JSON/YAML config handling
183
+ │ ├── core/ # Diff parsing, formatting
184
+ │ ├── models/ # AI model adapters
185
+ │ ├── platforms/ # GitHub, GitLab, etc.
186
+ │ └── types/ # TypeScript types
187
+ ├── .env
188
+ ├── README.md
189
+ ```
190
+
191
+ ---
192
+
193
+ ### Add Support for New AI Models
194
+
195
+ Create a new class in `src/models/` that implements the `CodeReviewModel` interface.
196
+
197
+ ---
198
+
199
+ ### Add Support for New Platforms
200
+
201
+ Create a new class in `src/platforms/` that implements the `CodeReviewPlatform` interface.
202
+
203
+ ---
204
+
205
+ ## ✅ Next Steps
206
+
207
+ 🔧 Add Winston for production-grade logging
208
+ 🌐 Implement GitLab and Bitbucket platform adapters
209
+ 🌍 Add support for other AI model providers (e.g. Anthropic, DeepSeek...)
210
+ 💻 Add support for running local models (e.g. Ollama, Llama.cpp, Hugging Face transformers)
211
+ 📤 Add support for webhook triggers (e.g., GitHub Actions, GitLab CI)
212
+ 🧪 Add unit and integration test suites (Jest or Vitest)
213
+ 📦 Publish Docker image for CI/CD use
214
+ 🧩 Enable plugin hooks for custom rule logic
215
+ 🗂 Add support for reviewing diffs from local branches or patch files
216
+
217
+ ---
218
+
219
+ ## 📜 License
220
+
221
+ MIT – Use freely, contribute openly.
@@ -0,0 +1,4 @@
1
+ #!/usr/bin/env node
2
+
3
+ // This is a simple wrapper to run the compiled TypeScript code
4
+ require("../dist/index.js");
@@ -0,0 +1,9 @@
1
+ import { ReviewConfig } from "../types";
2
+ /**
3
+ * Parse and validate CLI arguments
4
+ */
5
+ export declare function parseCli(): Partial<ReviewConfig>;
6
+ /**
7
+ * Log message if verbose mode is enabled
8
+ */
9
+ export declare function verboseLog(options: Partial<ReviewConfig>, message: string): void;
@@ -0,0 +1,56 @@
1
+ "use strict";
2
+ var __importDefault = (this && this.__importDefault) || function (mod) {
3
+ return (mod && mod.__esModule) ? mod : { "default": mod };
4
+ };
5
+ Object.defineProperty(exports, "__esModule", { value: true });
6
+ exports.parseCli = parseCli;
7
+ exports.verboseLog = verboseLog;
8
+ const commander_1 = require("commander");
9
+ const dotenv_1 = __importDefault(require("dotenv"));
10
+ const package_json_1 = require("../../package.json");
11
+ const config_1 = require("../config");
12
+ // Load environment variables
13
+ dotenv_1.default.config();
14
+ /**
15
+ * Parse and validate CLI arguments
16
+ */
17
+ function parseCli() {
18
+ const program = new commander_1.Command();
19
+ program
20
+ .name("diff-hound")
21
+ .description("AI-powered code review for GitHub, GitLab, and Bitbucket")
22
+ .version(package_json_1.version)
23
+ .option("-p, --provider <provider>", "The provider of the AI model (openai, anthropic, deepseek, groq, gemini)", config_1.DEFAULT_CONFIG.provider)
24
+ .option("-m, --model <model>", "The AI model (gpt-4o, claude-3-5-sonnet, deepseek, llama3, gemini-2.0-flash)", config_1.DEFAULT_CONFIG.model)
25
+ .option("-e, --model-endpoint <endpoint>", "The endpoint for the AI model")
26
+ .option("-g, --git-platform <platform>", "Platform to use (github, gitlab, bitbucket)", config_1.DEFAULT_CONFIG.gitPlatform)
27
+ .option("-r, --repo <owner/repo>", "Repository to review")
28
+ .option("-s, --comment-style <commentStyle>", "Comment style (inline, summary)", config_1.DEFAULT_CONFIG.commentStyle)
29
+ .option("-d, --dry-run", "Do not post comments, just print them", config_1.DEFAULT_CONFIG.dryRun)
30
+ .option("-v, --verbose", "Enable verbose logging", config_1.DEFAULT_CONFIG.verbose)
31
+ .option("-c, --config-path <path>", "Path to config file (default: .aicodeconfig.json or .aicode.yml)")
32
+ .parse(process.argv);
33
+ const options = program.opts();
34
+ return sanitizeCliOptions({
35
+ provider: options.provider,
36
+ model: options.model,
37
+ gitPlatform: options.gitPlatform,
38
+ repo: options.repo,
39
+ commentStyle: options.commentStyle,
40
+ dryRun: options.dryRun,
41
+ verbose: options.verbose,
42
+ endpoint: options.modelEndpoint,
43
+ configPath: options.configPath,
44
+ });
45
+ }
46
+ /**
47
+ * Log message if verbose mode is enabled
48
+ */
49
+ function verboseLog(options, message) {
50
+ if (options.verbose) {
51
+ console.log(`[DEBUG] ${message}`);
52
+ }
53
+ }
54
+ function sanitizeCliOptions(cli) {
55
+ return Object.fromEntries(Object.entries(cli).filter(([_, v]) => v !== undefined));
56
+ }
@@ -0,0 +1,18 @@
1
+ import { ReviewConfig } from "../types";
2
+ /**
3
+ * Default configuration
4
+ */
5
+ export declare const DEFAULT_CONFIG: ReviewConfig;
6
+ /**
7
+ * Load configuration from file
8
+ * @param configPath Optional path to config file
9
+ * @returns Review configuration
10
+ */
11
+ export declare function loadConfig(configPath?: string): Promise<ReviewConfig>;
12
+ /**
13
+ * Validates the configuration : CLI options && config file
14
+ * @param cliOptions CLI options from command line
15
+ * @param config Configuration from file
16
+ * @returns Updated configuration
17
+ */
18
+ export declare function validateConfig(cliOptions: Partial<ReviewConfig>, config: ReviewConfig): ReviewConfig;
@@ -0,0 +1,102 @@
1
+ "use strict";
2
+ var __importDefault = (this && this.__importDefault) || function (mod) {
3
+ return (mod && mod.__esModule) ? mod : { "default": mod };
4
+ };
5
+ Object.defineProperty(exports, "__esModule", { value: true });
6
+ exports.DEFAULT_CONFIG = void 0;
7
+ exports.loadConfig = loadConfig;
8
+ exports.validateConfig = validateConfig;
9
+ const fs_1 = __importDefault(require("fs"));
10
+ const path_1 = __importDefault(require("path"));
11
+ const js_yaml_1 = __importDefault(require("js-yaml"));
12
+ /**
13
+ * Default configuration
14
+ */
15
+ exports.DEFAULT_CONFIG = {
16
+ provider: "openai",
17
+ model: "gpt-4o",
18
+ gitPlatform: "github",
19
+ commentStyle: "inline",
20
+ dryRun: false,
21
+ verbose: false,
22
+ severity: "suggestion",
23
+ ignoreFiles: [],
24
+ };
25
+ /**
26
+ * Load configuration from file
27
+ * @param configPath Optional path to config file
28
+ * @returns Review configuration
29
+ */
30
+ async function loadConfig(configPath) {
31
+ // If config path is specified, use it
32
+ if (configPath && fs_1.default.existsSync(configPath)) {
33
+ return loadConfigFromFile(configPath);
34
+ }
35
+ // Otherwise, look for default config files
36
+ const jsonConfigPath = path_1.default.resolve(process.cwd(), ".aicodeconfig.json");
37
+ const yamlConfigPath = path_1.default.resolve(process.cwd(), ".aicode.yml");
38
+ if (fs_1.default.existsSync(jsonConfigPath)) {
39
+ return loadConfigFromFile(jsonConfigPath);
40
+ }
41
+ if (fs_1.default.existsSync(yamlConfigPath)) {
42
+ return loadConfigFromFile(yamlConfigPath);
43
+ }
44
+ // Return default config if no config file is found
45
+ return { ...exports.DEFAULT_CONFIG };
46
+ }
47
+ /**
48
+ * Load configuration from a specific file
49
+ * @param filePath Path to config file
50
+ * @returns Review configuration
51
+ */
52
+ function loadConfigFromFile(filePath) {
53
+ try {
54
+ const fileContent = fs_1.default.readFileSync(filePath, "utf8");
55
+ let config;
56
+ if (filePath.endsWith(".json")) {
57
+ config = JSON.parse(fileContent);
58
+ }
59
+ else if (filePath.endsWith(".yml") || filePath.endsWith(".yaml")) {
60
+ config = js_yaml_1.default.load(fileContent);
61
+ }
62
+ else {
63
+ throw new Error(`Unsupported config file format: ${filePath}`);
64
+ }
65
+ // Merge with default config
66
+ return { ...exports.DEFAULT_CONFIG, ...config };
67
+ }
68
+ catch (error) {
69
+ console.error(`Error loading config from ${filePath}: ${error}`);
70
+ return { ...exports.DEFAULT_CONFIG };
71
+ }
72
+ }
73
+ /**
74
+ * Validates the configuration : CLI options && config file
75
+ * @param cliOptions CLI options from command line
76
+ * @param config Configuration from file
77
+ * @returns Updated configuration
78
+ */
79
+ function validateConfig(cliOptions, config) {
80
+ let finalConfig = { ...config, ...cliOptions };
81
+ // Validate provider
82
+ // Todo: Add more providers as needed ("anthropic", "deepseek", "groq", "gemini")
83
+ const validProviders = ["openai"];
84
+ if (!validProviders.includes(finalConfig.provider)) {
85
+ console.error(`Error: Invalid provider '${finalConfig.provider}'. Using default: ${exports.DEFAULT_CONFIG.provider}`);
86
+ finalConfig.provider = exports.DEFAULT_CONFIG.provider;
87
+ }
88
+ // Validate platform
89
+ // Todo: Add more platforms as needed ("gitlab", "bitbucket")
90
+ const validPlatforms = ["github"];
91
+ if (!validPlatforms.includes(finalConfig.gitPlatform)) {
92
+ console.error(`Error: Invalid platform '${finalConfig.gitPlatform}'. Using default: ${exports.DEFAULT_CONFIG.gitPlatform}`);
93
+ finalConfig.gitPlatform = exports.DEFAULT_CONFIG.gitPlatform;
94
+ }
95
+ // Validate severity
96
+ if (finalConfig.severity &&
97
+ !["suggestion", "warning", "error"].includes(finalConfig.severity)) {
98
+ console.warn(`Warning: Invalid severity '${finalConfig.severity}' in config file. Using default: ${exports.DEFAULT_CONFIG.severity}`);
99
+ finalConfig.severity = exports.DEFAULT_CONFIG.severity;
100
+ }
101
+ return finalConfig;
102
+ }
@@ -0,0 +1,2 @@
1
+ import { FileChange } from "../types";
2
+ export declare function parseUnifiedDiff(files: FileChange[]): FileChange[];
@@ -0,0 +1,38 @@
1
+ "use strict";
2
+ Object.defineProperty(exports, "__esModule", { value: true });
3
+ exports.parseUnifiedDiff = parseUnifiedDiff;
4
+ function parseUnifiedDiff(files) {
5
+ return files.map((file) => {
6
+ if (!file.patch || file.status === "deleted")
7
+ return file;
8
+ const lines = file.patch.split("\n");
9
+ const updatedLines = [];
10
+ let newLineNum = 0;
11
+ let lineOffset = 0;
12
+ for (const line of lines) {
13
+ const hunkMatch = line.match(/^@@ -\d+(?:,\d+)? \+(\d+)(?:,\d+)? @@/);
14
+ if (hunkMatch) {
15
+ newLineNum = parseInt(hunkMatch[1], 10);
16
+ lineOffset = 0;
17
+ updatedLines.push(line); // keep hunk line
18
+ continue;
19
+ }
20
+ if (line.startsWith("+") && !line.startsWith("+++")) {
21
+ const actualLineNumber = newLineNum + lineOffset;
22
+ updatedLines.push(`${line} // LINE_NUMBER: ${actualLineNumber}`);
23
+ lineOffset++;
24
+ }
25
+ else if (line.startsWith("-") && !line.startsWith("---")) {
26
+ updatedLines.push(line); // deleted line
27
+ }
28
+ else {
29
+ updatedLines.push(line); // context line
30
+ lineOffset++;
31
+ }
32
+ }
33
+ return {
34
+ ...file,
35
+ patch: updatedLines.join("\n"),
36
+ };
37
+ });
38
+ }
@@ -0,0 +1 @@
1
+ export {};
package/dist/index.js ADDED
@@ -0,0 +1,110 @@
1
+ "use strict";
2
+ Object.defineProperty(exports, "__esModule", { value: true });
3
+ const cli_1 = require("./cli");
4
+ const config_1 = require("./config");
5
+ const platforms_1 = require("./platforms");
6
+ const models_1 = require("./models");
7
+ const parseUnifiedDiff_1 = require("./core/parseUnifiedDiff");
8
+ async function main() {
9
+ try {
10
+ // Parse CLI options
11
+ const cliOptions = (0, cli_1.parseCli)();
12
+ (0, cli_1.verboseLog)(cliOptions, "CLI options parsed");
13
+ // Load configuration
14
+ const fileConfig = await (0, config_1.loadConfig)(cliOptions.configPath);
15
+ (0, cli_1.verboseLog)(cliOptions, `Configuration loaded from ${cliOptions.configPath || "default"}`);
16
+ // Merge CLI options with config
17
+ const config = (0, config_1.validateConfig)(cliOptions, fileConfig);
18
+ (0, cli_1.verboseLog)(config, `Bot configuration: ${JSON.stringify(config, null, 2)}`);
19
+ // Get platform adapter
20
+ const platform = await (0, platforms_1.getPlatform)(config.gitPlatform);
21
+ (0, cli_1.verboseLog)(config, `Using platform: ${config.gitPlatform}`);
22
+ // Get model adapter
23
+ const model = (0, models_1.getModel)(config.provider, config.model, config.endpoint);
24
+ (0, cli_1.verboseLog)(config, `Using model: ${config.model}`);
25
+ // Ensure repository is specified
26
+ if (!config.repo) {
27
+ console.error("Error: Repository is not specified. Please add it to the config file or use the --repo CLI option.");
28
+ process.exit(1);
29
+ }
30
+ // Get pull requests that need review
31
+ const pullRequests = await platform.getPullRequests(config.repo);
32
+ (0, cli_1.verboseLog)(config, `Found ${pullRequests.length} PRs`);
33
+ if (pullRequests.length === 0) {
34
+ console.log("No pull requests found that need review");
35
+ return;
36
+ }
37
+ // Process each pull request
38
+ const results = [];
39
+ for (const pr of pullRequests) {
40
+ (0, cli_1.verboseLog)(config, `Processing PR #${pr.number}: ${pr.title}`);
41
+ // Check if AI has already commented since the last update
42
+ const hasCommented = await platform.hasAICommented(config.repo, pr.id);
43
+ if (hasCommented) {
44
+ (0, cli_1.verboseLog)(config, `Skipping PR #${pr.number} - already reviewed since last update`);
45
+ results.push({
46
+ prId: pr.id,
47
+ commentsPosted: 0,
48
+ status: "skipped",
49
+ });
50
+ continue;
51
+ }
52
+ try {
53
+ // Get PR diff
54
+ const diff = await platform.getPullRequestDiff(config.repo, pr.id);
55
+ (0, cli_1.verboseLog)(config, `Got diff for PR #${pr.number} with ${diff.length} changed files`);
56
+ const parsedDiff = (0, parseUnifiedDiff_1.parseUnifiedDiff)(diff);
57
+ (0, cli_1.verboseLog)(config, `Parsed diff for PR #${pr.number} with ${diff.length} changed files`);
58
+ // Get AI review
59
+ const comments = await model.review(parsedDiff, config);
60
+ (0, cli_1.verboseLog)(config, `Generated ${comments.length} comments for PR #${pr.number}`);
61
+ if (cliOptions.dryRun) {
62
+ // Just print comments in dry run mode
63
+ console.log(`\n== Comments for PR #${pr.number}: ${pr.title} ==`);
64
+ comments.forEach((comment) => {
65
+ console.log(`\n${comment.type === "inline"
66
+ ? `${comment.path}:${comment.line}`
67
+ : "Summary comment"}:`);
68
+ console.log(comment.content);
69
+ });
70
+ }
71
+ else {
72
+ // Post comments to PR
73
+ for (const comment of comments) {
74
+ await platform.postComment(config.repo, pr.id, comment);
75
+ }
76
+ console.log(`Posted ${comments.length} comments to PR #${pr.number}`);
77
+ }
78
+ results.push({
79
+ prId: pr.id,
80
+ commentsPosted: comments.length,
81
+ status: "success",
82
+ });
83
+ }
84
+ catch (error) {
85
+ console.error(`Error processing PR #${pr.number}: ${error}`);
86
+ results.push({
87
+ prId: pr.id,
88
+ commentsPosted: 0,
89
+ status: "failure",
90
+ error: error instanceof Error ? error.message : String(error),
91
+ });
92
+ }
93
+ }
94
+ // Print summary
95
+ console.log("\n== Review Summary ==");
96
+ console.log(`Total PRs: ${pullRequests.length}`);
97
+ console.log(`Reviewed: ${results.filter((r) => r.status === "success").length}`);
98
+ console.log(`Skipped: ${results.filter((r) => r.status === "skipped").length}`);
99
+ console.log(`Failed: ${results.filter((r) => r.status === "failure").length}`);
100
+ }
101
+ catch (error) {
102
+ console.error(`Error: ${error}`);
103
+ process.exit(1);
104
+ }
105
+ }
106
+ // Run the application
107
+ main().catch((error) => {
108
+ console.error(`Unhandled error: ${error}`);
109
+ process.exit(1);
110
+ });
@@ -0,0 +1,9 @@
1
+ import { CodeReviewModel } from "../types";
2
+ /**
3
+ * Get model adapter for the specified AI model
4
+ * @param provider The provider of the AI model (e.g., openai, anthropic, deepseek)
5
+ * @param model The specific model to use (e.g., gpt-3.5-turbo, gpt-4)
6
+ * @param endpoint Optional custom endpoint URL
7
+ * @returns Model adapter instance
8
+ */
9
+ export declare function getModel(provider: string, model: string, endpoint?: string): CodeReviewModel;
@@ -0,0 +1,19 @@
1
+ "use strict";
2
+ Object.defineProperty(exports, "__esModule", { value: true });
3
+ exports.getModel = getModel;
4
+ const openai_1 = require("./openai");
5
+ /**
6
+ * Get model adapter for the specified AI model
7
+ * @param provider The provider of the AI model (e.g., openai, anthropic, deepseek)
8
+ * @param model The specific model to use (e.g., gpt-3.5-turbo, gpt-4)
9
+ * @param endpoint Optional custom endpoint URL
10
+ * @returns Model adapter instance
11
+ */
12
+ function getModel(provider, model, endpoint) {
13
+ switch (provider) {
14
+ case "openai":
15
+ return new openai_1.OpenAIModel(model, endpoint);
16
+ default:
17
+ throw new Error(`Unsupported provider: ${provider}`);
18
+ }
19
+ }
@@ -0,0 +1,30 @@
1
+ import { CodeReviewModel, FileChange, AIComment, ReviewConfig } from "../types";
2
+ /**
3
+ * OpenAI model adapter for code review
4
+ */
5
+ export declare class OpenAIModel implements CodeReviewModel {
6
+ private client;
7
+ private model;
8
+ constructor(model: string, endpoint?: string);
9
+ /**
10
+ * Generate a code review prompt for the given diff
11
+ * @param diff File changes to review
12
+ * @param config Review configuration
13
+ * @returns Prompt for the AI
14
+ */
15
+ private generatePrompt;
16
+ /**
17
+ * Parse the AI response into comments
18
+ * @param response AI generated response
19
+ * @param config Review configuration
20
+ * @returns List of comments
21
+ */
22
+ private parseResponse;
23
+ /**
24
+ * Review code changes and generate comments
25
+ * @param diff File changes to review
26
+ * @param config Review configuration
27
+ * @returns List of AI comments
28
+ */
29
+ review(diff: FileChange[], config: ReviewConfig): Promise<AIComment[]>;
30
+ }
@@ -0,0 +1,162 @@
1
+ "use strict";
2
+ var __importDefault = (this && this.__importDefault) || function (mod) {
3
+ return (mod && mod.__esModule) ? mod : { "default": mod };
4
+ };
5
+ Object.defineProperty(exports, "__esModule", { value: true });
6
+ exports.OpenAIModel = void 0;
7
+ const openai_1 = __importDefault(require("openai"));
8
+ /**
9
+ * OpenAI model adapter for code review
10
+ */
11
+ class OpenAIModel {
12
+ constructor(model, endpoint) {
13
+ const apiKey = process.env.OPENAI_API_KEY;
14
+ if (!apiKey) {
15
+ throw new Error("OPENAI_API_KEY environment variable is required");
16
+ }
17
+ if (!model) {
18
+ throw new Error("Model is required");
19
+ }
20
+ this.model = model;
21
+ this.client = new openai_1.default({
22
+ apiKey,
23
+ baseURL: endpoint,
24
+ });
25
+ }
26
+ /**
27
+ * Generate a code review prompt for the given diff
28
+ * @param diff File changes to review
29
+ * @param config Review configuration
30
+ * @returns Prompt for the AI
31
+ */
32
+ generatePrompt(diff, config) {
33
+ const rules = config.rules && config.rules.length > 0
34
+ ? `\nApply these specific rules:\n${config.rules
35
+ .map((rule) => `- ${rule}`)
36
+ .join("\n")}`
37
+ : "";
38
+ const diffText = diff
39
+ .map((file) => {
40
+ return `File: ${file.filename} (${file.status})
41
+ ${file.patch || "No changes"}
42
+ `;
43
+ })
44
+ .join("\n\n");
45
+ return `
46
+ Only provide brief, actionable, file-specific feedback related to the actual code diff.
47
+ Do not include general advice, documentation-style summaries, or best practices unless they directly relate to the diff.
48
+ Use a direct tone. No greetings. No summaries. No repeated advice.
49
+
50
+ Important formatting rules:
51
+ - Do not comment on lines that are unchanged or just context unless it's directly impacted by a change.
52
+ ${config.commentStyle === "inline"
53
+ ? "- Output only inline comments, Use this format: 'filename.py:<line number in the new file> — comment'"
54
+ : "- Provide a single short summary of your review."}
55
+
56
+ ${rules}
57
+
58
+ Here are the changes to review:
59
+
60
+ ${diffText}
61
+
62
+ ${config.customPrompt || ""}`;
63
+ }
64
+ /**
65
+ * Parse the AI response into comments
66
+ * @param response AI generated response
67
+ * @param config Review configuration
68
+ * @returns List of comments
69
+ */
70
+ parseResponse(response, config) {
71
+ const comments = [];
72
+ if (config.commentStyle === "summary") {
73
+ // Generate a single summary comment
74
+ comments.push({
75
+ type: "summary",
76
+ content: response.trim(),
77
+ severity: config.severity,
78
+ });
79
+ return comments;
80
+ }
81
+ // Look for patterns like "filename.ext:123 — comment text"
82
+ const inlineCommentRegex = /([\w/.-]+):(\d+)\s*[—–-]\s*(.*?)(?=\s+[\w/.-]+:\d+\s*[—–-]|$)/gs;
83
+ let match;
84
+ while ((match = inlineCommentRegex.exec(response + "\n\n")) !== null) {
85
+ const [, path, lineStr, content] = match;
86
+ const line = parseInt(lineStr, 10);
87
+ comments.push({
88
+ type: "inline",
89
+ path,
90
+ line,
91
+ content: content.trim(),
92
+ severity: config.severity,
93
+ });
94
+ }
95
+ // If no inline comments were parsed, create a summary comment
96
+ if (comments.length === 0) {
97
+ comments.push({
98
+ type: "summary",
99
+ content: response.trim(),
100
+ severity: config.severity,
101
+ });
102
+ }
103
+ return comments;
104
+ }
105
+ /**
106
+ * Review code changes and generate comments
107
+ * @param diff File changes to review
108
+ * @param config Review configuration
109
+ * @returns List of AI comments
110
+ */
111
+ async review(diff, config) {
112
+ // Filter out ignored files
113
+ if (config.ignoreFiles && config.ignoreFiles.length > 0) {
114
+ diff = diff.filter((file) => {
115
+ return !config.ignoreFiles?.some((pattern) => {
116
+ // Basic glob pattern matching for *.ext
117
+ if (pattern.startsWith("*") && pattern.indexOf(".") > 0) {
118
+ const ext = pattern.substring(1);
119
+ return file.filename.endsWith(ext);
120
+ }
121
+ return file.filename === pattern;
122
+ });
123
+ });
124
+ }
125
+ // Skip if no files to review
126
+ if (diff.length === 0) {
127
+ return [
128
+ {
129
+ type: "summary",
130
+ content: "No files to review after applying ignore patterns.",
131
+ severity: "suggestion",
132
+ },
133
+ ];
134
+ }
135
+ const prompt = this.generatePrompt(diff, config);
136
+ try {
137
+ const response = await this.client.chat.completions.create({
138
+ model: this.model,
139
+ messages: [
140
+ {
141
+ role: "system",
142
+ content: "You are a senior software engineer doing a peer code review. Your job is to spot all logic, syntax, and semantic issues in a code diff.",
143
+ },
144
+ { role: "user", content: prompt },
145
+ ],
146
+ temperature: 0.1,
147
+ max_tokens: 4000,
148
+ store: true,
149
+ });
150
+ const content = response.choices[0]?.message.content;
151
+ if (!content) {
152
+ throw new Error("No response from OpenAI");
153
+ }
154
+ return this.parseResponse(content, config);
155
+ }
156
+ catch (error) {
157
+ console.error("Error generating review with OpenAI:", error);
158
+ throw error;
159
+ }
160
+ }
161
+ }
162
+ exports.OpenAIModel = OpenAIModel;
@@ -0,0 +1,43 @@
1
+ import { CodeReviewPlatform, PullRequest, FileChange, AIComment } from "../types";
2
+ /**
3
+ * GitHub platform adapter
4
+ */
5
+ export declare class GithubPlatform implements CodeReviewPlatform {
6
+ private client;
7
+ private commentSignature;
8
+ private constructor();
9
+ static init(): Promise<GithubPlatform>;
10
+ /**
11
+ * Extract owner and repo from repo string
12
+ * @param repo Repository in format "owner/repo"
13
+ * @returns Object with owner and repo properties
14
+ */
15
+ private parseRepo;
16
+ /**
17
+ * Get all open pull requests for a repository
18
+ * @param repo Repository in format "owner/repo"
19
+ * @returns List of pull requests
20
+ */
21
+ getPullRequests(repo: string): Promise<PullRequest[]>;
22
+ /**
23
+ * Get the diff for a pull request
24
+ * @param repo Repository in format "owner/repo"
25
+ * @param prId Pull request ID
26
+ * @returns List of file changes
27
+ */
28
+ getPullRequestDiff(repo: string, prId: string | number): Promise<FileChange[]>;
29
+ /**
30
+ * Post a comment on a pull request
31
+ * @param repo Repository in format "owner/repo"
32
+ * @param prId Pull request ID
33
+ * @param comment Comment to post
34
+ */
35
+ postComment(repo: string, prId: string | number, comment: AIComment): Promise<void>;
36
+ /**
37
+ * Checks if the AI has already commented on the latest commit of a pull request
38
+ * @param repo Repository in format "owner/repo"
39
+ * @param prId Pull request ID
40
+ * @returns True if the AI has commented, false otherwise
41
+ */
42
+ hasAICommented(repo: string, prId: string | number): Promise<boolean>;
43
+ }
@@ -0,0 +1,157 @@
1
+ "use strict";
2
+ Object.defineProperty(exports, "__esModule", { value: true });
3
+ exports.GithubPlatform = void 0;
4
+ /**
5
+ * GitHub platform adapter
6
+ */
7
+ class GithubPlatform {
8
+ constructor(client) {
9
+ this.commentSignature = "<!-- DIFF-HOUND-BOT -->";
10
+ this.client = client;
11
+ }
12
+ static async init() {
13
+ const { Octokit } = await import("@octokit/rest");
14
+ const token = process.env.GITHUB_TOKEN;
15
+ if (!token) {
16
+ throw new Error("GITHUB_TOKEN environment variable is required");
17
+ }
18
+ const client = new Octokit({ auth: token });
19
+ return new GithubPlatform(client);
20
+ }
21
+ /**
22
+ * Extract owner and repo from repo string
23
+ * @param repo Repository in format "owner/repo"
24
+ * @returns Object with owner and repo properties
25
+ */
26
+ parseRepo(repo) {
27
+ const [owner, repoName] = repo.split("/");
28
+ if (!owner || !repoName) {
29
+ throw new Error(`Invalid repository format: ${repo}. Expected format: owner/repo`);
30
+ }
31
+ return { owner, repo: repoName };
32
+ }
33
+ /**
34
+ * Get all open pull requests for a repository
35
+ * @param repo Repository in format "owner/repo"
36
+ * @returns List of pull requests
37
+ */
38
+ async getPullRequests(repo) {
39
+ const { owner, repo: repoName } = this.parseRepo(repo);
40
+ const { data: pulls } = await this.client.pulls.list({
41
+ owner,
42
+ repo: repoName,
43
+ state: "open",
44
+ sort: "updated",
45
+ direction: "desc",
46
+ });
47
+ return pulls.map((pull) => ({
48
+ id: pull.number,
49
+ number: pull.number,
50
+ title: pull.title,
51
+ description: pull.body || undefined,
52
+ author: pull.user?.login || "unknown",
53
+ branch: pull.head.ref,
54
+ baseBranch: pull.base.ref,
55
+ updatedAt: new Date(pull.updated_at),
56
+ url: pull.html_url,
57
+ }));
58
+ }
59
+ /**
60
+ * Get the diff for a pull request
61
+ * @param repo Repository in format "owner/repo"
62
+ * @param prId Pull request ID
63
+ * @returns List of file changes
64
+ */
65
+ async getPullRequestDiff(repo, prId) {
66
+ const { owner, repo: repoName } = this.parseRepo(repo);
67
+ const { data: files } = await this.client.pulls.listFiles({
68
+ owner,
69
+ repo: repoName,
70
+ pull_number: Number(prId),
71
+ });
72
+ return files.map((file) => ({
73
+ filename: file.filename,
74
+ status: file.status,
75
+ additions: file.additions,
76
+ deletions: file.deletions,
77
+ patch: file.patch || undefined,
78
+ previousFilename: file.previous_filename,
79
+ }));
80
+ }
81
+ /**
82
+ * Post a comment on a pull request
83
+ * @param repo Repository in format "owner/repo"
84
+ * @param prId Pull request ID
85
+ * @param comment Comment to post
86
+ */
87
+ async postComment(repo, prId, comment) {
88
+ const { owner, repo: repoName } = this.parseRepo(repo);
89
+ const pullNumber = Number(prId);
90
+ const { data: pull } = await this.client.pulls.get({
91
+ owner,
92
+ repo: repoName,
93
+ pull_number: pullNumber,
94
+ });
95
+ if (comment.type === "inline" && comment.path && comment.line) {
96
+ // Post inline comment
97
+ await this.client.pulls.createReviewComment({
98
+ owner,
99
+ repo: repoName,
100
+ pull_number: pullNumber,
101
+ body: `${comment.content}\n\n${this.commentSignature}\n<!-- SHA: ${pull.head.sha} -->`,
102
+ commit_id: pull.head.sha,
103
+ path: comment.path,
104
+ line: comment.line,
105
+ });
106
+ }
107
+ else {
108
+ // Post PR comment
109
+ await this.client.issues.createComment({
110
+ owner,
111
+ repo: repoName,
112
+ issue_number: pullNumber,
113
+ body: `${comment.content}\n\n${this.commentSignature}\n<!-- SHA: ${pull.head.sha} -->`,
114
+ });
115
+ }
116
+ }
117
+ /**
118
+ * Checks if the AI has already commented on the latest commit of a pull request
119
+ * @param repo Repository in format "owner/repo"
120
+ * @param prId Pull request ID
121
+ * @returns True if the AI has commented, false otherwise
122
+ */
123
+ async hasAICommented(repo, prId) {
124
+ const { owner, repo: repoName } = this.parseRepo(repo);
125
+ const pullNumber = Number(prId);
126
+ const { data: pull } = await this.client.pulls.get({
127
+ owner,
128
+ repo: repoName,
129
+ pull_number: pullNumber,
130
+ });
131
+ const latestCommitSha = pull.head.sha;
132
+ // --- Get issue (summary) comments
133
+ const issueComments = (await this.client.paginate(this.client.issues.listComments.endpoint.merge({
134
+ owner,
135
+ repo: repoName,
136
+ issue_number: pullNumber,
137
+ per_page: 100,
138
+ })));
139
+ // --- Get review (inline) comments
140
+ const reviewComments = (await this.client.paginate(this.client.pulls.listReviewComments.endpoint.merge({
141
+ owner,
142
+ repo: repoName,
143
+ pull_number: pullNumber,
144
+ per_page: 100,
145
+ })));
146
+ const allComments = [...issueComments, ...reviewComments];
147
+ const reviewedShas = allComments
148
+ .filter((c) => c.body?.includes(this.commentSignature))
149
+ .map((c) => {
150
+ const match = c.body?.match(/<!-- SHA: (.+?) -->/);
151
+ return match?.[1];
152
+ })
153
+ .filter(Boolean);
154
+ return reviewedShas.includes(latestCommitSha);
155
+ }
156
+ }
157
+ exports.GithubPlatform = GithubPlatform;
@@ -0,0 +1,7 @@
1
+ import { Platform, CodeReviewPlatform } from "../types";
2
+ /**
3
+ * Get platform adapter for the specified platform
4
+ * @param platform Platform to use
5
+ * @returns Platform adapter instance
6
+ */
7
+ export declare function getPlatform(platform: Platform): Promise<CodeReviewPlatform>;
@@ -0,0 +1,17 @@
1
+ "use strict";
2
+ Object.defineProperty(exports, "__esModule", { value: true });
3
+ exports.getPlatform = getPlatform;
4
+ const github_1 = require("./github");
5
+ /**
6
+ * Get platform adapter for the specified platform
7
+ * @param platform Platform to use
8
+ * @returns Platform adapter instance
9
+ */
10
+ async function getPlatform(platform) {
11
+ switch (platform) {
12
+ case "github":
13
+ return await github_1.GithubPlatform.init();
14
+ default:
15
+ throw new Error(`Unsupported platform: ${platform}`);
16
+ }
17
+ }
@@ -0,0 +1,64 @@
1
+ /**
2
+ * Core types for the AI Code Reviewer
3
+ */
4
+ export type Platform = "github";
5
+ export type Provider = "openai";
6
+ export interface ReviewConfig {
7
+ provider: Provider;
8
+ model: string;
9
+ gitPlatform: Platform;
10
+ repo?: string;
11
+ commentStyle?: "inline" | "summary";
12
+ dryRun?: boolean;
13
+ verbose?: boolean;
14
+ endpoint?: string;
15
+ configPath?: string;
16
+ severity?: "suggestion" | "warning" | "error";
17
+ ignoreFiles?: string[];
18
+ rules?: string[];
19
+ customPrompt?: string;
20
+ }
21
+ export interface PullRequest {
22
+ id: string | number;
23
+ number?: number;
24
+ title: string;
25
+ description?: string;
26
+ author: string;
27
+ branch: string;
28
+ baseBranch: string;
29
+ commits?: string[];
30
+ updatedAt: Date;
31
+ url?: string;
32
+ }
33
+ export interface FileChange {
34
+ filename: string;
35
+ status: "added" | "modified" | "deleted" | "renamed";
36
+ additions: number;
37
+ deletions: number;
38
+ patch?: string;
39
+ previousFilename?: string;
40
+ }
41
+ export interface AIComment {
42
+ type: "inline" | "summary";
43
+ path?: string;
44
+ line?: number;
45
+ content: string;
46
+ suggestions?: string[];
47
+ severity?: "suggestion" | "warning" | "error";
48
+ created_at?: string;
49
+ }
50
+ export interface CodeReviewPlatform {
51
+ getPullRequests(repo: string): Promise<PullRequest[]>;
52
+ getPullRequestDiff(repo: string, prId: string | number): Promise<FileChange[]>;
53
+ postComment(repo: string, prId: string | number, comment: AIComment): Promise<void>;
54
+ hasAICommented(repo: string, prId: string | number): Promise<boolean>;
55
+ }
56
+ export interface CodeReviewModel {
57
+ review(diff: FileChange[], config: ReviewConfig): Promise<AIComment[]>;
58
+ }
59
+ export interface ReviewResult {
60
+ prId: string | number;
61
+ commentsPosted: number;
62
+ status: "success" | "failure" | "skipped";
63
+ error?: string;
64
+ }
@@ -0,0 +1,5 @@
1
+ "use strict";
2
+ /**
3
+ * Core types for the AI Code Reviewer
4
+ */
5
+ Object.defineProperty(exports, "__esModule", { value: true });
package/package.json ADDED
@@ -0,0 +1,56 @@
1
+ {
2
+ "name": "diff-hound",
3
+ "version": "1.0.0",
4
+ "description": "AI-powered code review bot for GitHub, GitLab, and Bitbucket",
5
+ "main": "./bin/index.js",
6
+ "bin": {
7
+ "diff-hound": "bin/diff-hound.js"
8
+ },
9
+ "files": [
10
+ "bin",
11
+ "dist",
12
+ "README.md",
13
+ "LICENSE"
14
+ ],
15
+ "scripts": {
16
+ "build": "tsc",
17
+ "start": "node dist/index.js",
18
+ "dev": "ts-node src/index.ts",
19
+ "lint": "eslint src/**/*.ts"
20
+ },
21
+ "keywords": [
22
+ "code-review",
23
+ "ai",
24
+ "github",
25
+ "gitlab",
26
+ "bitbucket",
27
+ "openai",
28
+ "claude",
29
+ "llm"
30
+ ],
31
+ "author": "runtimebug",
32
+ "license": "MIT",
33
+ "engines": {
34
+ "node": ">=18.0.0"
35
+ },
36
+ "repository": {
37
+ "type": "git",
38
+ "url": "git+https://github.com/runtimebug/diff-hound.git"
39
+ },
40
+ "dependencies": {
41
+ "@octokit/rest": "^21.1.1",
42
+ "commander": "^11.0.0",
43
+ "dotenv": "^16.3.1",
44
+ "js-yaml": "^4.1.0",
45
+ "openai": "^4.10.0"
46
+ },
47
+ "devDependencies": {
48
+ "@types/js-yaml": "^4.0.5",
49
+ "@types/node": "^20.6.0",
50
+ "@typescript-eslint/eslint-plugin": "^6.7.0",
51
+ "@typescript-eslint/parser": "^6.7.0",
52
+ "eslint": "^8.49.0",
53
+ "ts-node": "^10.9.1",
54
+ "typescript": "^5.2.2"
55
+ }
56
+ }