Skip to content

Commit 3f5fa6d

Browse files
committed
feat: add --local mode for reviewing local git diffs
Support reviewing local git diffs without requiring a PR URL. Useful for Bitbucket PRs or pre-push local reviews. Usage: hodor --local # diff against origin/main hodor --local --diff-against HEAD~1 # diff against specific ref In local mode: - Uses current directory (or --workspace) as workspace - Skips PR metadata fetching and workspace cloning - --post is disabled (no remote to post to) - Diff embedding and compaction work as normal Closes #8
1 parent 1ef9936 commit 3f5fa6d

3 files changed

Lines changed: 130 additions & 73 deletions

File tree

src/agent.ts

Lines changed: 63 additions & 25 deletions
Original file line numberDiff line numberDiff line change
@@ -167,7 +167,7 @@ export async function postReviewComment(opts: {
167167
}
168168

169169
export async function reviewPr(opts: {
170-
prUrl: string;
170+
prUrl?: string;
171171
model?: string;
172172
reasoningEffort?: string;
173173
customPrompt?: string | null;
@@ -177,6 +177,8 @@ export async function reviewPr(opts: {
177177
includeMetricsFooter?: boolean;
178178
onEvent?: (event: AgentProgressEvent) => void;
179179
bedrockTags?: Record<string, string> | null;
180+
localMode?: boolean;
181+
diffAgainst?: string;
180182
}): Promise<{ review: ReviewOutput; metricsFooter: string | null; headSha: string | null; metrics: ReviewMetrics }> {
181183
const {
182184
prUrl,
@@ -189,14 +191,25 @@ export async function reviewPr(opts: {
189191
includeMetricsFooter = false,
190192
onEvent,
191193
bedrockTags,
194+
localMode = false,
195+
diffAgainst,
192196
} = opts;
193197

194-
logger.info(`Starting PR review for: ${prUrl}`);
198+
logger.info(`Starting PR review for: ${localMode ? "local diff" : prUrl}`);
195199

196-
// Parse PR URL
197-
const { owner, repo, prNumber, host } = parsePrUrl(prUrl);
198-
const platform = detectPlatform(prUrl);
199-
logger.info(`Platform: ${platform}, Repo: ${owner}/${repo}, PR: ${prNumber}, Host: ${host}`);
200+
let owner = "", repo = "", host = "";
201+
let prNumber = 0;
202+
let platform: Platform = "github";
203+
204+
if (!localMode && prUrl) {
205+
const urlParsed = parsePrUrl(prUrl);
206+
owner = urlParsed.owner;
207+
repo = urlParsed.repo;
208+
prNumber = urlParsed.prNumber;
209+
host = urlParsed.host;
210+
platform = detectPlatform(prUrl);
211+
logger.info(`Platform: ${platform}, Repo: ${owner}/${repo}, PR: ${prNumber}, Host: ${host}`);
212+
}
200213

201214
// --- Preflight: validate model + credentials before any expensive I/O ---
202215
const parsed = parseModelString(model);
@@ -285,30 +298,49 @@ export async function reviewPr(opts: {
285298
// --- End preflight ---
286299

287300
// Setup workspace
288-
const { workspace, targetBranch, diffBaseSha, isTemporary } = await setupWorkspace({
289-
platform,
290-
owner,
291-
repo,
292-
prNumber: String(prNumber),
293-
host,
294-
workingDir: workspaceDir ?? undefined,
295-
reuse: workspaceDir != null,
296-
});
297-
298-
const workspacePath = workspace;
301+
let workspacePath: string;
302+
let targetBranch: string;
303+
let diffBaseSha: string | null = null;
304+
let isTemporary = false;
305+
306+
if (localMode) {
307+
// Resolve to git repo root so paths from git diff match tool expectations
308+
const cwd = workspaceDir ?? process.cwd();
309+
try {
310+
const { stdout: toplevel } = await exec("git", ["rev-parse", "--show-toplevel"], { cwd });
311+
workspacePath = toplevel.trim();
312+
} catch {
313+
workspacePath = cwd; // fallback if not in a git repo
314+
}
315+
targetBranch = diffAgainst ?? "origin/main";
316+
logger.info(`Local mode: workspace=${workspacePath}, diffAgainst=${targetBranch}`);
317+
} else {
318+
const wsResult = await setupWorkspace({
319+
platform,
320+
owner,
321+
repo,
322+
prNumber: String(prNumber),
323+
host,
324+
workingDir: workspaceDir ?? undefined,
325+
reuse: workspaceDir != null,
326+
});
327+
workspacePath = wsResult.workspace;
328+
targetBranch = wsResult.targetBranch;
329+
diffBaseSha = wsResult.diffBaseSha;
330+
isTemporary = wsResult.isTemporary;
331+
}
299332

300333
try {
301-
// Fetch PR metadata
302334
let mrMetadata: MrMetadata | null = null;
303-
if (platform === "gitlab") {
335+
if (!localMode && platform === "gitlab") {
304336
try {
305337
mrMetadata = await fetchGitlabMrInfo(owner, repo, prNumber, host, {
306338
includeComments: true,
307339
});
308340
} catch (err) {
309341
logger.warn(`Failed to fetch GitLab metadata: ${err}`);
310342
}
311-
} else if (platform === "github") {
343+
} else if (!localMode && platform === "github") {
312344
try {
313345
const githubRaw = await fetchGithubPrInfo(owner, repo, prNumber);
314346
mrMetadata = normalizeGithubMetadata(githubRaw);
@@ -340,9 +372,12 @@ export async function reviewPr(opts: {
340372
}
341373
}
342374

343-
// Get HEAD SHA for embedding in posted comments
344-
const { stdout: headShaRaw } = await exec("git", ["rev-parse", "HEAD"], { cwd: workspacePath });
345-
const headSha = headShaRaw.trim();
375+
// Get HEAD SHA for embedding in posted comments (skip in local mode — no posting)
376+
let headSha: string | null = null;
377+
if (!localMode) {
378+
const { stdout: headShaRaw } = await exec("git", ["rev-parse", "HEAD"], { cwd: workspacePath });
379+
headSha = headShaRaw.trim();
380+
}
346381

347382
// Pre-fetch diff for embedding in prompt (avoids per-file tool calls)
348383
const MAX_EMBED_BYTES = 200 * 1024; // 200KB
@@ -352,7 +387,9 @@ export async function reviewPr(opts: {
352387
? ["--no-pager", "diff", `${previousReviewSha}...HEAD`]
353388
: diffBaseSha
354389
? ["--no-pager", "diff", diffBaseSha, "HEAD"]
355-
: ["--no-pager", "diff", `origin/${targetBranch}...HEAD`];
390+
: localMode
391+
? ["--no-pager", "diff", targetBranch] // includes uncommitted changes
392+
: ["--no-pager", "diff", `origin/${targetBranch}...HEAD`];
356393
const { stdout: rawDiff } = await exec("git", diffArgs, { cwd: workspacePath });
357394
if (Buffer.byteLength(rawDiff, "utf-8") <= MAX_EMBED_BYTES) {
358395
embeddedDiff = rawDiff;
@@ -366,7 +403,7 @@ export async function reviewPr(opts: {
366403

367404
// Build prompt (always uses JSON template; rendered to markdown post-hoc)
368405
const prompt = buildPrReviewPrompt({
369-
prUrl,
406+
prUrl: prUrl ?? `local diff (against ${targetBranch})`,
370407
platform,
371408
targetBranch,
372409
diffBaseSha,
@@ -375,6 +412,7 @@ export async function reviewPr(opts: {
375412
customPromptFile: promptFile,
376413
embeddedDiff,
377414
previousReviewSha,
415+
localMode,
378416
});
379417

380418
const startTime = Date.now();

src/cli.ts

Lines changed: 61 additions & 48 deletions
Original file line numberDiff line numberDiff line change
@@ -15,12 +15,13 @@ const program = new Command();
1515
program
1616
.name("hodor")
1717
.description(
18-
"AI-powered code review agent for GitHub PRs and GitLab MRs.\n\n" +
18+
"AI-powered code review agent for GitHub PRs, GitLab MRs, and local diffs.\n\n" +
1919
"Hodor uses an AI agent that clones the repository, checks out the PR branch,\n" +
20-
"and analyzes the code using tools (gh, git, glab) for metadata fetching and comment posting.",
20+
"and analyzes the code using tools (gh, git, glab) for metadata fetching and comment posting.\n\n" +
21+
"For local reviews, use --local with --diff-against to review changes in your current git repository.",
2122
)
22-
.version("0.3.4")
23-
.argument("<pr-url>", "URL of the GitHub PR or GitLab MR to review")
23+
.version("0.4.1")
24+
.argument("[pr-url]", "URL of the GitHub PR or GitLab MR to review (optional with --local)")
2425
.option(
2526
"--model <model>",
2627
"LLM model to use (e.g., anthropic/claude-sonnet-4-5-20250929, openai/gpt-5)",
@@ -58,7 +59,17 @@ program
5859
"--prometheus-push <url>",
5960
"Push review metrics to a Prometheus Pushgateway URL",
6061
)
61-
.action(async (prUrl: string, cmdOpts: Record<string, unknown>) => {
62+
.option(
63+
"--local",
64+
"Review local changes in the current directory (no PR URL required)",
65+
false,
66+
)
67+
.option(
68+
"--diff-against <ref>",
69+
"Git ref to diff against in local mode (e.g., origin/main, HEAD~1)",
70+
"origin/main",
71+
)
72+
.action(async (prUrl: string | undefined, cmdOpts: Record<string, unknown>) => {
6273
const verbose = cmdOpts.verbose as boolean;
6374
const post = cmdOpts.post as boolean;
6475
const model = cmdOpts.model as string;
@@ -69,6 +80,17 @@ program
6980
const ultrathink = cmdOpts.ultrathink as boolean;
7081
const bedrockTagsRaw = cmdOpts.bedrockTags as string | undefined;
7182
const prometheusPush = cmdOpts.prometheusPush as string | undefined;
83+
const localMode = cmdOpts.local as boolean;
84+
const diffAgainst = cmdOpts.diffAgainst as string;
85+
86+
if (!localMode && !prUrl) {
87+
console.error(chalk.red("Error: pr-url is required unless --local is specified"));
88+
process.exit(1);
89+
}
90+
if (localMode && post) {
91+
console.error(chalk.red("Error: --post is not supported in --local mode (no remote to post to)"));
92+
process.exit(1);
93+
}
7294

7395
// Auto-detect CI environment
7496
const isCI = !!(process.env.CI || process.env.GITLAB_CI || process.env.GITHUB_ACTIONS);
@@ -168,41 +190,34 @@ program
168190
}
169191

170192
try {
171-
// Validate URL and detect platform (inside try so errors are caught)
172-
const platform = detectPlatform(prUrl);
173-
const githubToken = process.env.GITHUB_TOKEN;
174-
const gitlabToken =
175-
process.env.GITLAB_TOKEN ??
176-
process.env.GITLAB_PRIVATE_TOKEN ??
177-
process.env.CI_JOB_TOKEN;
193+
// Detect platform and warn about missing tokens
194+
let platform: string = "local";
195+
if (!localMode && prUrl) {
196+
platform = detectPlatform(prUrl);
197+
const githubToken = process.env.GITHUB_TOKEN;
198+
const gitlabToken =
199+
process.env.GITLAB_TOKEN ??
200+
process.env.GITLAB_PRIVATE_TOKEN ??
201+
process.env.CI_JOB_TOKEN;
178202

179-
if (platform === "github" && !githubToken) {
180-
console.error(
181-
chalk.yellow(
182-
"Warning: GITHUB_TOKEN not set. You may encounter rate limits or authentication issues.",
183-
),
184-
);
185-
console.error(
186-
chalk.dim(" Set GITHUB_TOKEN environment variable or run: gh auth login\n"),
187-
);
188-
} else if (platform === "gitlab" && !gitlabToken) {
189-
console.error(
190-
chalk.yellow(
191-
"Warning: No GitLab token detected. Set GITLAB_TOKEN (api scope) for authentication.",
192-
),
193-
);
194-
console.error(
195-
chalk.dim(
196-
" Export GITLAB_TOKEN and optionally GITLAB_HOST for self-hosted instances.\n",
197-
),
198-
);
203+
if (platform === "github" && !githubToken) {
204+
console.error(chalk.yellow("Warning: GITHUB_TOKEN not set. You may encounter rate limits."));
205+
console.error(chalk.dim(" Set GITHUB_TOKEN or run: gh auth login\n"));
206+
} else if (platform === "gitlab" && !gitlabToken) {
207+
console.error(chalk.yellow("Warning: No GitLab token detected. Set GITLAB_TOKEN (api scope)."));
208+
console.error(chalk.dim(" Export GITLAB_TOKEN and optionally GITLAB_HOST.\n"));
209+
}
199210
}
200211

201-
log(
202-
`\n${chalk.bold.cyan("Hodor - AI Code Review Agent")}`,
203-
);
204-
log(chalk.dim(`Platform: ${platform.toUpperCase()}`));
205-
log(chalk.dim(`PR URL: ${prUrl}`));
212+
log(`\n${chalk.bold.cyan("Hodor - AI Code Review Agent")}`);
213+
if (localMode) {
214+
log(chalk.dim(`Mode: Local diff review`));
215+
log(chalk.dim(`Diff against: ${diffAgainst}`));
216+
log(chalk.dim(`Workspace: ${workspace ?? process.cwd()}`));
217+
} else {
218+
log(chalk.dim(`Platform: ${platform.toUpperCase()}`));
219+
log(chalk.dim(`PR URL: ${prUrl}`));
220+
}
206221
log(chalk.dim(`Model: ${model}`));
207222
if (reasoningEffort) {
208223
log(chalk.dim(`Reasoning Effort: ${reasoningEffort}`));
@@ -211,22 +226,24 @@ program
211226

212227
streamLog(chalk.dim("▶ Setting up workspace..."));
213228
const { review, metricsFooter, headSha, metrics } = await reviewPr({
214-
prUrl,
229+
prUrl: localMode ? undefined : prUrl,
215230
model,
216231
reasoningEffort,
217232
customPrompt: prompt,
218233
promptFile,
219234
cleanup: !workspace,
220235
workspaceDir: workspace,
221-
includeMetricsFooter: post,
236+
includeMetricsFooter: post && !localMode,
222237
onEvent: handleEvent,
223238
bedrockTags,
239+
localMode,
240+
diffAgainst,
224241
});
225242
const reviewText = renderMarkdown(review);
226243

227244
streamLog(chalk.green("✔ Review complete!"));
228245

229-
if (post) {
246+
if (post && prUrl) {
230247
log(chalk.cyan("\nPosting review to PR/MR..."));
231248

232249
const result = await postReviewComment({
@@ -241,20 +258,16 @@ program
241258
log(chalk.bold.green("Review posted successfully!"));
242259
log(chalk.dim(` ${platform === "github" ? "PR" : "MR"}: ${prUrl}`));
243260
} else {
244-
log(
245-
chalk.bold.red(`Failed to post review: ${result.error}`),
246-
);
261+
log(chalk.bold.red(`Failed to post review: ${result.error}`));
247262
log(chalk.yellow("\nReview output:\n"));
248263
console.log(reviewText);
249264
}
250265
} else {
251266
log(chalk.bold.green("Review Complete\n"));
252267
console.log(reviewText);
253-
log(
254-
chalk.dim(
255-
"\nTip: Use --post to automatically post this review to the PR/MR",
256-
),
257-
);
268+
if (!localMode) {
269+
log(chalk.dim("\nTip: Use --post to automatically post this review to the PR/MR"));
270+
}
258271
}
259272

260273
// Push metrics to Prometheus Pushgateway (best-effort, never fails the run)

src/prompt.ts

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -21,6 +21,7 @@ export function buildPrReviewPrompt(opts: {
2121
customPromptFile?: string | null;
2222
embeddedDiff?: string | null;
2323
previousReviewSha?: string | null;
24+
localMode?: boolean;
2425
}): string {
2526
const {
2627
prUrl,
@@ -32,6 +33,7 @@ export function buildPrReviewPrompt(opts: {
3233
customPromptFile,
3334
embeddedDiff,
3435
previousReviewSha,
36+
localMode = false,
3537
} = opts;
3638

3739
// Step 1: Determine template (always tool submission; rendered to markdown post-hoc)
@@ -74,6 +76,10 @@ export function buildPrReviewPrompt(opts: {
7476
prDiffCmd = `git --no-pager diff ${previousReviewSha}...HEAD --name-only`;
7577
gitDiffCmd = `git --no-pager diff ${previousReviewSha}...HEAD`;
7678
logger.info(`Incremental review: diffing from ${previousReviewSha.slice(0, 8)} to HEAD`);
79+
} else if (localMode) {
80+
// Plain two-arg diff includes uncommitted (staged + unstaged) changes
81+
prDiffCmd = `git --no-pager diff ${targetBranch} --name-only`;
82+
gitDiffCmd = `git --no-pager diff ${targetBranch}`;
7783
} else if (platform === "github") {
7884
prDiffCmd = `git --no-pager diff origin/${targetBranch}...HEAD --name-only`;
7985
gitDiffCmd = `git --no-pager diff origin/${targetBranch}...HEAD`;

0 commit comments

Comments
 (0)