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
4 changes: 4 additions & 0 deletions .env.example
Original file line number Diff line number Diff line change
Expand Up @@ -7,6 +7,10 @@ GITHUB_TOKEN=ghp_your_token_here
# GitLab Host (for self-hosted GitLab instances)
# GITLAB_HOST=gitlab.example.com

# Gitea/Forgejo API Token (required for Gitea/Forgejo PR reviews)
# GITEA_TOKEN=your_gitea_token_here
# FORGEJO_TOKEN=your_forgejo_token_here

# OpenHands LLM Configuration
# LLM_API_KEY - Primary API key for LLM provider (required)
# You can also use provider-specific keys for backward compatibility:
Expand Down
2 changes: 1 addition & 1 deletion Dockerfile
Original file line number Diff line number Diff line change
Expand Up @@ -75,7 +75,7 @@ RUN mkdir -p /workspace /tmp/hodor && \
chown -R bun:bun /app /workspace /tmp/hodor

LABEL org.opencontainers.image.title="Hodor" \
org.opencontainers.image.description="AI-powered code review agent for GitHub and GitLab" \
org.opencontainers.image.description="AI-powered code review agent for GitHub, GitLab, and Gitea/Forgejo" \
org.opencontainers.image.url="https://github.com/mr-karan/hodor" \
org.opencontainers.image.source="https://github.com/mr-karan/hodor" \
org.opencontainers.image.vendor="Karan Sharma" \
Expand Down
2 changes: 1 addition & 1 deletion package.json
Original file line number Diff line number Diff line change
Expand Up @@ -3,7 +3,7 @@
"publishConfig": {
"access": "public"
},
"version": "0.5.0",
"version": "0.6.0",
"description": "AI-powered code review agent that finds bugs, security issues, and logic errors in pull requests",
"type": "module",
"main": "dist/index.js",
Expand Down
44 changes: 42 additions & 2 deletions src/agent.ts
Original file line number Diff line number Diff line change
Expand Up @@ -11,6 +11,10 @@ import {
fetchGitlabMrInfo,
postGitlabMrComment,
} from "./gitlab.js";
import {
fetchGiteaPrInfo,
postGiteaPrComment,
} from "./gitea.js";
import { setupWorkspace, cleanupWorkspace } from "./workspace.js";
import { buildPrReviewPrompt } from "./prompt.js";
import { parseModelString, mapReasoningEffort, getApiKey } from "./model.js";
Expand Down Expand Up @@ -42,11 +46,15 @@ export function detectPlatform(prUrl: string): Platform {
if (prUrl.includes("/-/merge_requests/") || hostname.includes("gitlab")) {
return "gitlab";
}
// Gitea/Forgejo: /pulls/ (plural) — must check before GitHub since /pulls/ contains /pull/
if (prUrl.includes("/pulls/") || hostname.includes("gitea") || hostname.includes("forgejo") || hostname.includes("codeberg")) {
return "gitea";
}
if (prUrl.includes("/pull/") || hostname.includes("github")) {
return "github";
}
throw new Error(
`Cannot detect platform for URL: ${prUrl}. Expected a GitHub pull request (/pull/) or GitLab merge request (/-/merge_requests/) URL.`,
`Cannot detect platform for URL: ${prUrl}. Expected a GitHub (/pull/), GitLab (/-/merge_requests/), or Gitea/Forgejo (/pulls/) URL.`,
);
}

Expand All @@ -69,6 +77,20 @@ export function parsePrUrl(prUrl: string): ParsedPrUrl {
};
}

// Gitea/Forgejo format: /owner/repo/pulls/123
if (pathParts.length >= 4 && pathParts[2] === "pulls") {
const prNumber = parseInt(pathParts[3], 10);
if (!Number.isSafeInteger(prNumber) || prNumber <= 0) {
throw new Error(`Invalid PR number in URL: ${prUrl}. Expected a positive integer after /pulls/.`);
}
return {
owner: pathParts[0],
repo: pathParts[1],
prNumber,
host,
};
}

// GitLab format: /group/subgroup/repo/-/merge_requests/123
const mrIndex = pathParts.indexOf("merge_requests");
if (mrIndex >= 0) {
Expand All @@ -95,7 +117,7 @@ export function parsePrUrl(prUrl: string): ParsedPrUrl {
}

throw new Error(
`Invalid PR/MR URL format: ${prUrl}. Expected GitHub pull request or GitLab merge request URL.`,
`Invalid PR/MR URL format: ${prUrl}. Expected GitHub (/pull/), GitLab (/-/merge_requests/), or Gitea/Forgejo (/pulls/) URL.`,
);
}

Expand Down Expand Up @@ -142,6 +164,16 @@ export async function postReviewComment(opts: {
]);
logger.info(`Successfully posted review to GitHub PR #${parsed.prNumber}`);
return { success: true, platform: "github", prNumber: parsed.prNumber };
} else if (platform === "gitea") {
await postGiteaPrComment(
parsed.owner,
parsed.repo,
parsed.prNumber,
body,
parsed.host,
);
logger.info(`Successfully posted review to Gitea PR #${parsed.prNumber}`);
return { success: true, platform: "gitea", prNumber: parsed.prNumber };
} else {
await postGitlabMrComment(
parsed.owner,
Expand Down Expand Up @@ -347,6 +379,14 @@ export async function reviewPr(opts: {
} catch (err) {
logger.warn(`Failed to fetch GitHub metadata: ${err}`);
}
} else if (!localMode && platform === "gitea") {
try {
mrMetadata = await fetchGiteaPrInfo(owner, repo, prNumber, host, {
includeComments: true,
});
} catch (err) {
logger.warn(`Failed to fetch Gitea metadata: ${err}`);
}
}

// Detect previous hodor review SHA for incremental mode
Expand Down
16 changes: 11 additions & 5 deletions src/cli.ts
Original file line number Diff line number Diff line change
Expand Up @@ -15,13 +15,13 @@ const program = new Command();
program
.name("hodor")
.description(
"AI-powered code review agent for GitHub PRs, GitLab MRs, and local diffs.\n\n" +
"AI-powered code review agent for GitHub PRs, GitLab MRs, Gitea/Forgejo PRs, and local diffs.\n\n" +
"Hodor uses an AI agent that clones the repository, checks out the PR branch,\n" +
"and analyzes the code using tools (gh, git, glab) for metadata fetching and comment posting.\n\n" +
"For local reviews, use --local with --diff-against to review changes in your current git repository.",
)
.version("0.4.1")
.argument("[pr-url]", "URL of the GitHub PR or GitLab MR to review (optional with --local)")
.version("0.6.0")
.argument("[pr-url]", "URL of the GitHub PR, GitLab MR, or Gitea/Forgejo PR to review (optional with --local)")
.option(
"--model <model>",
"LLM model to use (e.g., anthropic/claude-sonnet-4-5-20250929, openai/gpt-5)",
Expand Down Expand Up @@ -93,7 +93,7 @@ program
}

// Auto-detect CI environment
const isCI = !!(process.env.CI || process.env.GITLAB_CI || process.env.GITHUB_ACTIONS);
const isCI = !!(process.env.CI || process.env.GITLAB_CI || process.env.GITHUB_ACTIONS || process.env.GITEA_ACTIONS || process.env.FORGEJO_ACTIONS);

if (verbose) setLogLevel("debug");
else if (isCI) setLogLevel("info");
Expand Down Expand Up @@ -206,6 +206,12 @@ program
} else if (platform === "gitlab" && !gitlabToken) {
console.error(chalk.yellow("Warning: No GitLab token detected. Set GITLAB_TOKEN (api scope)."));
console.error(chalk.dim(" Export GITLAB_TOKEN and optionally GITLAB_HOST.\n"));
} else if (platform === "gitea") {
const giteaToken = process.env.GITEA_TOKEN ?? process.env.FORGEJO_TOKEN;
if (!giteaToken) {
console.error(chalk.yellow("Warning: No Gitea/Forgejo token detected. Set GITEA_TOKEN for authentication."));
console.error(chalk.dim(" Export GITEA_TOKEN (or FORGEJO_TOKEN) for API access.\n"));
}
}
}

Expand Down Expand Up @@ -256,7 +262,7 @@ program

if (result.success) {
log(chalk.bold.green("Review posted successfully!"));
log(chalk.dim(` ${platform === "github" ? "PR" : "MR"}: ${prUrl}`));
log(chalk.dim(` ${platform === "gitlab" ? "MR" : "PR"}: ${prUrl}`));
} else {
log(chalk.bold.red(`Failed to post review: ${result.error}`));
log(chalk.yellow("\nReview output:\n"));
Expand Down
195 changes: 195 additions & 0 deletions src/gitea.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,195 @@
import { logger } from "./utils/logger.js";
import type { MrMetadata, NoteEntry } from "./types.js";

export class GiteaAPIError extends Error {
constructor(message: string) {
super(message);
this.name = "GiteaAPIError";
}
}

function normalizeBaseUrl(host?: string | null): string {
const candidate =
host ||
process.env.GITEA_HOST ||
process.env.FORGEJO_HOST;
if (!candidate) {
throw new GiteaAPIError(
"No Gitea/Forgejo host configured. Set GITEA_HOST or FORGEJO_HOST, " +
"or provide a full PR URL that includes the hostname.",
);
}
const trimmed = candidate.trim();
if (trimmed.startsWith("http://") || trimmed.startsWith("https://")) {
return trimmed.replace(/\/+$/, "");
}
return `https://${trimmed}`.replace(/\/+$/, "");
}

function giteaToken(): string | null {
return process.env.GITEA_TOKEN ?? process.env.FORGEJO_TOKEN ?? null;
}

function requireGiteaToken(): string {
const token = giteaToken();
if (!token) {
throw new GiteaAPIError(
"No Gitea/Forgejo token found. Set GITEA_TOKEN or FORGEJO_TOKEN environment variable.",
);
}
return token;
}

async function giteaFetch<T>(
host: string,
path: string,
options?: { method?: string; body?: unknown },
): Promise<T> {
const baseUrl = normalizeBaseUrl(host);
const url = `${baseUrl}/api/v1/${path}`;
const token = giteaToken();

const headers: Record<string, string> = {
Accept: "application/json",
"Content-Type": "application/json",
};
if (token) {
headers.Authorization = `token ${token}`;
}

const init: RequestInit = {
method: options?.method ?? "GET",
headers,
};

if (options?.body) {
init.body = JSON.stringify(options.body);
}

const response = await fetch(url, init);

if (!response.ok) {
const text = await response.text().catch(() => "");
if (response.status === 401 || response.status === 403) {
throw new GiteaAPIError(
`Authentication failed (${response.status}): check GITEA_TOKEN/FORGEJO_TOKEN. ${text}`,
);
}
if (response.status === 429) {
throw new GiteaAPIError(`Rate limited by Gitea API (429). ${text}`);
}
throw new GiteaAPIError(
`Gitea API error ${response.status} for ${options?.method ?? "GET"} ${path}: ${text}`,
);
}

return (await response.json()) as T;
}

/**
* Fetch pull request metadata from Gitea/Forgejo.
*/
export async function fetchGiteaPrInfo(
owner: string,
repo: string,
prNumber: number | string,
host?: string | null,
options?: { includeComments?: boolean },
): Promise<MrMetadata> {
let prData: Record<string, unknown>;
try {
prData = await giteaFetch<Record<string, unknown>>(
host ?? null,
`repos/${owner}/${repo}/pulls/${prNumber}`,
);
} catch (err) {
if (err instanceof GiteaAPIError) throw err;
const msg = err instanceof Error ? err.message : String(err);
throw new GiteaAPIError(`Failed to fetch PR #${prNumber}: ${msg}`);
}

const user = (prData.user as Record<string, string>) ?? {};
const head = (prData.head as Record<string, unknown>) ?? {};
const base = (prData.base as Record<string, unknown>) ?? {};
const labels = (prData.labels as Array<Record<string, string>>) ?? [];

const metadata: MrMetadata = {
title: prData.title as string | undefined,
description: (prData.body as string) ?? "",
source_branch: head.ref as string | undefined,
target_branch: base.ref as string | undefined,
changes_count: prData.changed_files as number | undefined,
labels: labels.map((lbl) => ({ name: lbl.name })),
author: {
username: user.login,
name: user.full_name || user.login,
},
state: prData.state as string | undefined,
};

if (options?.includeComments) {
try {
metadata.Notes = await fetchGiteaPrComments(owner, repo, prNumber, host);
} catch (err) {
logger.warn(`Failed to fetch PR comments: ${err instanceof Error ? err.message : err}`);
}
}

return metadata;
}

/**
* Fetch comments on a Gitea/Forgejo pull request.
* PRs are a superset of issues in Gitea, so comments use the issues endpoint.
* Note: This endpoint returns all comments in a single response (pagination params are ignored).
*/
export async function fetchGiteaPrComments(
owner: string,
repo: string,
prNumber: number | string,
host?: string | null,
): Promise<NoteEntry[]> {
const comments = await giteaFetch<Array<Record<string, unknown>>>(
host ?? null,
`repos/${owner}/${repo}/issues/${prNumber}/comments`,
);

return comments.map((c) => {
const user = (c.user as Record<string, string>) ?? {};
return {
body: (c.body as string) ?? "",
author: {
username: user.login,
name: user.full_name || user.login,
},
created_at: c.created_at as string | undefined,
system: false,
};
});
}

/**
* Post a comment on a Gitea/Forgejo pull request.
*/
export async function postGiteaPrComment(
owner: string,
repo: string,
prNumber: number | string,
body: string,
host?: string | null,
): Promise<void> {
// Posting requires authentication
requireGiteaToken();

try {
await giteaFetch<unknown>(
host ?? null,
`repos/${owner}/${repo}/issues/${prNumber}/comments`,
{ method: "POST", body: { body } },
);
} catch (err) {
if (err instanceof GiteaAPIError) throw err;
const msg = err instanceof Error ? err.message : String(err);
throw new GiteaAPIError(`Failed to post comment to PR #${prNumber}: ${msg}`);
}
}
2 changes: 1 addition & 1 deletion src/prompt.ts
Original file line number Diff line number Diff line change
Expand Up @@ -80,7 +80,7 @@ export function buildPrReviewPrompt(opts: {
// Plain two-arg diff includes uncommitted (staged + unstaged) changes
prDiffCmd = `git --no-pager diff ${targetBranch} --name-only`;
gitDiffCmd = `git --no-pager diff ${targetBranch}`;
} else if (platform === "github") {
} else if (platform === "github" || platform === "gitea") {
prDiffCmd = `git --no-pager diff origin/${targetBranch}...HEAD --name-only`;
gitDiffCmd = `git --no-pager diff origin/${targetBranch}...HEAD`;
} else {
Expand Down
2 changes: 1 addition & 1 deletion src/types.ts
Original file line number Diff line number Diff line change
@@ -1,4 +1,4 @@
export type Platform = "github" | "gitlab";
export type Platform = "github" | "gitlab" | "gitea";

export interface ParsedPrUrl {
owner: string;
Expand Down
Loading