From 3b78151fda6329b02baa91fae0efe1fb493fa4e2 Mon Sep 17 00:00:00 2001 From: Abhinav Raut Date: Fri, 27 Mar 2026 14:20:24 +0530 Subject: [PATCH 1/2] Feat: gitea support --- .env.example | 4 + Dockerfile | 2 +- package.json | 2 +- src/agent.ts | 44 ++++++++- src/cli.ts | 16 ++- src/gitea.ts | 195 +++++++++++++++++++++++++++++++++++++ src/prompt.ts | 2 +- src/types.ts | 2 +- src/workspace.ts | 116 ++++++++++++++++++++++ tests/agent.test.ts | 26 +++++ tests/gitea.test.ts | 232 ++++++++++++++++++++++++++++++++++++++++++++ 11 files changed, 630 insertions(+), 11 deletions(-) create mode 100644 src/gitea.ts create mode 100644 tests/gitea.test.ts diff --git a/.env.example b/.env.example index 3311542..206c41d 100644 --- a/.env.example +++ b/.env.example @@ -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: diff --git a/Dockerfile b/Dockerfile index 442f510..0de9b1e 100644 --- a/Dockerfile +++ b/Dockerfile @@ -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" \ diff --git a/package.json b/package.json index efb64dc..bcde33d 100644 --- a/package.json +++ b/package.json @@ -3,7 +3,7 @@ "publishConfig": { "access": "public" }, - "version": "0.5.0", + "version": "0.5.1", "description": "AI-powered code review agent that finds bugs, security issues, and logic errors in pull requests", "type": "module", "main": "dist/index.js", diff --git a/src/agent.ts b/src/agent.ts index 536e097..ffbf49f 100644 --- a/src/agent.ts +++ b/src/agent.ts @@ -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"; @@ -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.`, ); } @@ -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) { @@ -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.`, ); } @@ -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, @@ -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 diff --git a/src/cli.ts b/src/cli.ts index c26fa91..e443e6b 100644 --- a/src/cli.ts +++ b/src/cli.ts @@ -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.5.1") + .argument("[pr-url]", "URL of the GitHub PR, GitLab MR, or Gitea/Forgejo PR to review (optional with --local)") .option( "--model ", "LLM model to use (e.g., anthropic/claude-sonnet-4-5-20250929, openai/gpt-5)", @@ -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"); @@ -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")); + } } } @@ -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")); diff --git a/src/gitea.ts b/src/gitea.ts new file mode 100644 index 0000000..337274c --- /dev/null +++ b/src/gitea.ts @@ -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( + host: string, + path: string, + options?: { method?: string; body?: unknown }, +): Promise { + const baseUrl = normalizeBaseUrl(host); + const url = `${baseUrl}/api/v1/${path}`; + const token = giteaToken(); + + const headers: Record = { + 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 { + let prData: Record; + try { + prData = await giteaFetch>( + 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) ?? {}; + const head = (prData.head as Record) ?? {}; + const base = (prData.base as Record) ?? {}; + const labels = (prData.labels as Array>) ?? []; + + 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 { + const comments = await giteaFetch>>( + host ?? null, + `repos/${owner}/${repo}/issues/${prNumber}/comments`, + ); + + return comments.map((c) => { + const user = (c.user as Record) ?? {}; + 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 { + // Posting requires authentication + requireGiteaToken(); + + try { + await giteaFetch( + 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}`); + } +} diff --git a/src/prompt.ts b/src/prompt.ts index d1a156d..61ffd51 100644 --- a/src/prompt.ts +++ b/src/prompt.ts @@ -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 { diff --git a/src/types.ts b/src/types.ts index 694dae1..b55b682 100644 --- a/src/types.ts +++ b/src/types.ts @@ -1,4 +1,4 @@ -export type Platform = "github" | "gitlab"; +export type Platform = "github" | "gitlab" | "gitea"; export interface ParsedPrUrl { owner: string; diff --git a/src/workspace.ts b/src/workspace.ts index a44495f..c877a48 100644 --- a/src/workspace.ts +++ b/src/workspace.ts @@ -4,6 +4,7 @@ import { join } from "node:path"; import { exec, execJson } from "./utils/exec.js"; import { logger } from "./utils/logger.js"; import { fetchGitlabMrInfo } from "./gitlab.js"; +import { fetchGiteaPrInfo } from "./gitea.js"; import type { MrMetadata, Platform } from "./types.js"; export class WorkspaceError extends Error { @@ -47,6 +48,23 @@ function detectCiWorkspace(owner: string, repo: string): CiWorkspace { } } + // Gitea Actions / Forgejo Actions + // Must check BEFORE GITHUB_ACTIONS since Gitea/Forgejo Actions sets GITHUB_ACTIONS=true for compat + if (process.env.GITEA_ACTIONS === "true" || process.env.FORGEJO_ACTIONS === "true") { + const workspaceDir = process.env.GITHUB_WORKSPACE; + const repository = process.env.GITHUB_REPOSITORY; + const baseRef = process.env.GITHUB_BASE_REF ?? null; + + if (workspaceDir && repository) { + const expected = `${owner}/${repo}`; + if (repository === expected) { + const ciType = process.env.FORGEJO_ACTIONS ? "Forgejo" : "Gitea"; + logger.info(`Detected ${ciType} Actions environment (base: ${baseRef ?? "unknown"})`); + return { path: workspaceDir, targetBranch: baseRef, diffBaseSha: null }; + } + } + } + // GitHub Actions if (process.env.GITHUB_ACTIONS === "true") { const workspaceDir = process.env.GITHUB_WORKSPACE; @@ -263,6 +281,98 @@ async function cloneAndCheckoutGitlabMr( return targetBranch; } +// --------------------------------------------------------------------------- +// Gitea helpers +// --------------------------------------------------------------------------- + +async function getGiteaPrBranches( + owner: string, + repo: string, + prNumber: string, + host?: string, +): Promise<{ sourceBranch: string; targetBranch: string }> { + let prInfo: MrMetadata; + try { + prInfo = await fetchGiteaPrInfo(owner, repo, Number(prNumber), host); + } catch (err) { + const msg = err instanceof Error ? err.message : String(err); + throw new WorkspaceError(`Failed to fetch PR info for #${prNumber}: ${msg}`); + } + + const sourceBranch = prInfo.source_branch; + if (!sourceBranch) { + throw new WorkspaceError(`Could not determine source branch for PR #${prNumber}`); + } + + return { sourceBranch, targetBranch: prInfo.target_branch ?? "main" }; +} + +async function checkoutGiteaBranch(workspace: string, sourceBranch: string): Promise { + try { + await exec("git", ["checkout", "-b", sourceBranch, `origin/${sourceBranch}`], { + cwd: workspace, + }); + } catch { + try { + await exec("git", ["checkout", sourceBranch], { cwd: workspace }); + } catch (err) { + const msg = err instanceof Error ? err.message : String(err); + throw new WorkspaceError(`Failed to checkout PR branch '${sourceBranch}': ${msg}`); + } + } +} + +async function fetchAndCheckoutGiteaPr( + workspace: string, + owner: string, + repo: string, + prNumber: string, + host?: string, +): Promise { + logger.info(`Fetching and checking out PR #${prNumber} in existing workspace`); + await exec("git", ["fetch", "origin"], { cwd: workspace }); + + const { sourceBranch, targetBranch } = await getGiteaPrBranches(owner, repo, prNumber, host); + logger.info(`Source branch: ${sourceBranch}, Target branch: ${targetBranch}`); + await checkoutGiteaBranch(workspace, sourceBranch); + + return targetBranch; +} + +async function cloneAndCheckoutGiteaPr( + workspace: string, + owner: string, + repo: string, + prNumber: string, + host?: string, +): Promise { + const giteaHost = host || process.env.GITEA_HOST || process.env.FORGEJO_HOST; + if (!giteaHost) { + throw new WorkspaceError("No Gitea/Forgejo host configured. Set GITEA_HOST or FORGEJO_HOST."); + } + logger.info(`Setting up Gitea workspace for ${owner}/${repo}/pulls/${prNumber}`); + + const token = process.env.GITEA_TOKEN || process.env.FORGEJO_TOKEN; + const cloneUrl = `https://${giteaHost}/${owner}/${repo}.git`; + logger.info(`Cloning from ${cloneUrl}...`); + + try { + const cloneArgs = token + ? ["-c", `http.extraHeader=Authorization: token ${token}`, "clone", cloneUrl, workspace] + : ["clone", cloneUrl, workspace]; + await exec("git", cloneArgs); + } catch (err) { + const msg = err instanceof Error ? err.message : String(err); + throw new WorkspaceError(`Failed to clone ${owner}/${repo}: ${msg}`); + } + + const { sourceBranch, targetBranch } = await getGiteaPrBranches(owner, repo, prNumber, host); + logger.info(`Source branch: ${sourceBranch}, Target branch: ${targetBranch}`); + await checkoutGiteaBranch(workspace, sourceBranch); + + return targetBranch; +} + // --------------------------------------------------------------------------- // Main entry point // --------------------------------------------------------------------------- @@ -306,6 +416,9 @@ export async function setupWorkspace(opts: { } else if (platform === "gitlab") { const tb = await fetchAndCheckoutGitlabMr(workspace, owner, repo, prNumber, host); if (!detectedTargetBranch) detectedTargetBranch = tb; + } else if (platform === "gitea") { + const tb = await fetchAndCheckoutGiteaPr(workspace, owner, repo, prNumber, host); + if (!detectedTargetBranch) detectedTargetBranch = tb; } const finalTargetBranch = detectedTargetBranch ?? "main"; logger.info( @@ -323,6 +436,9 @@ export async function setupWorkspace(opts: { } else if (platform === "gitlab") { const tb = await cloneAndCheckoutGitlabMr(workspace, owner, repo, prNumber, host); if (!detectedTargetBranch) detectedTargetBranch = tb; + } else if (platform === "gitea") { + const tb = await cloneAndCheckoutGiteaPr(workspace, owner, repo, prNumber, host); + if (!detectedTargetBranch) detectedTargetBranch = tb; } else { throw new WorkspaceError(`Unsupported platform: ${platform}`); } diff --git a/tests/agent.test.ts b/tests/agent.test.ts index 7d7a6c6..207c952 100644 --- a/tests/agent.test.ts +++ b/tests/agent.test.ts @@ -18,6 +18,10 @@ describe("detectPlatform", () => { ["https://github.com/foo/bar/pull/42", "github"], ["https://gitlab.com/foo/bar/-/merge_requests/3", "gitlab"], ["https://gitlab.example.dev/group/repo/-/merge_requests/19", "gitlab"], + ["https://gitea.example.com/foo/bar/pulls/5", "gitea"], + ["https://codeberg.org/foo/bar/pulls/10", "gitea"], + ["https://forgejo.example.org/foo/bar/pulls/1", "gitea"], + ["https://code.mycompany.com/team/project/pulls/77", "gitea"], ] as const)("detects platform for %s", (url, expected) => { expect(detectPlatform(url)).toBe(expected); }); @@ -48,6 +52,22 @@ describe("parsePrUrl", () => { expect(result.host).toBe("gitlab.example.com"); }); + it("parses Gitea PR URL", () => { + const result = parsePrUrl("https://gitea.example.com/acme/widget/pulls/42"); + expect(result.owner).toBe("acme"); + expect(result.repo).toBe("widget"); + expect(result.prNumber).toBe(42); + expect(result.host).toBe("gitea.example.com"); + }); + + it("parses Codeberg PR URL", () => { + const result = parsePrUrl("https://codeberg.org/user/repo/pulls/7"); + expect(result.owner).toBe("user"); + expect(result.repo).toBe("repo"); + expect(result.prNumber).toBe(7); + expect(result.host).toBe("codeberg.org"); + }); + it("throws on invalid URL", () => { expect(() => parsePrUrl("https://gitlab.com/foo/bar/issues/1")).toThrow(); }); @@ -63,6 +83,12 @@ describe("parsePrUrl", () => { parsePrUrl("https://gitlab.com/foo/bar/-/merge_requests/xyz"), ).toThrow(/Invalid MR number/); }); + + it("throws on non-numeric Gitea PR number", () => { + expect(() => + parsePrUrl("https://gitea.example.com/foo/bar/pulls/abc"), + ).toThrow(/Invalid PR number/); + }); }); describe("formatMetricsMarkdown", () => { diff --git a/tests/gitea.test.ts b/tests/gitea.test.ts new file mode 100644 index 0000000..dba4177 --- /dev/null +++ b/tests/gitea.test.ts @@ -0,0 +1,232 @@ +import { describe, it, expect, vi, beforeEach, afterEach } from "vitest"; +import type { MrMetadata } from "../src/types.js"; + +// Sample Gitea API responses +const SAMPLE_PR = { + number: 42, + title: "Fix login redirect", + body: "Fixes the redirect loop on login page", + state: "open", + changed_files: 3, + user: { login: "alice", full_name: "Alice Smith" }, + head: { ref: "fix/login-redirect", label: "alice:fix/login-redirect" }, + base: { ref: "main", label: "acme:main" }, + labels: [{ name: "bug" }, { name: "auth" }], +}; + +const SAMPLE_COMMENTS = [ + { + body: "Looks like this needs a test for the edge case where session is expired", + user: { login: "bob", full_name: "Bob Jones" }, + created_at: "2025-03-20T10:30:00Z", + }, + { + body: "Good catch, I added a test in the latest commit", + user: { login: "alice", full_name: "Alice Smith" }, + created_at: "2025-03-20T11:00:00Z", + }, +]; + +describe("gitea module", () => { + let originalEnv: NodeJS.ProcessEnv; + let mockFetch: ReturnType; + + beforeEach(() => { + originalEnv = { ...process.env }; + process.env.GITEA_TOKEN = "test-token-123"; + mockFetch = vi.fn(); + vi.stubGlobal("fetch", mockFetch); + }); + + afterEach(() => { + process.env = originalEnv; + vi.restoreAllMocks(); + }); + + describe("fetchGiteaPrInfo", () => { + it("fetches and maps PR metadata correctly", async () => { + mockFetch.mockResolvedValueOnce({ + ok: true, + json: async () => SAMPLE_PR, + }); + + const { fetchGiteaPrInfo } = await import("../src/gitea.js"); + const result = await fetchGiteaPrInfo("acme", "widget", 42, "gitea.example.com"); + + expect(result.title).toBe("Fix login redirect"); + expect(result.description).toBe("Fixes the redirect loop on login page"); + expect(result.source_branch).toBe("fix/login-redirect"); + expect(result.target_branch).toBe("main"); + expect(result.changes_count).toBe(3); + expect(result.author?.username).toBe("alice"); + expect(result.author?.name).toBe("Alice Smith"); + expect(result.state).toBe("open"); + expect(result.labels).toEqual([{ name: "bug" }, { name: "auth" }]); + + // Verify correct URL was called + expect(mockFetch).toHaveBeenCalledWith( + "https://gitea.example.com/api/v1/repos/acme/widget/pulls/42", + expect.objectContaining({ + method: "GET", + headers: expect.objectContaining({ + Authorization: "token test-token-123", + }), + }), + ); + }); + + it("includes comments when requested", async () => { + mockFetch + .mockResolvedValueOnce({ + ok: true, + json: async () => SAMPLE_PR, + }) + .mockResolvedValueOnce({ + ok: true, + json: async () => SAMPLE_COMMENTS, + }); + + const { fetchGiteaPrInfo } = await import("../src/gitea.js"); + const result = await fetchGiteaPrInfo("acme", "widget", 42, "gitea.example.com", { + includeComments: true, + }); + + expect(result.Notes).toHaveLength(2); + expect(result.Notes![0].author?.username).toBe("bob"); + expect(result.Notes![0].body).toContain("edge case"); + expect(result.Notes![1].author?.username).toBe("alice"); + }); + }); + + describe("fetchGiteaPrComments", () => { + it("fetches and maps comments correctly", async () => { + mockFetch.mockResolvedValueOnce({ + ok: true, + json: async () => SAMPLE_COMMENTS, + }); + + const { fetchGiteaPrComments } = await import("../src/gitea.js"); + const notes = await fetchGiteaPrComments("acme", "widget", 42, "gitea.example.com"); + + expect(notes).toHaveLength(2); + expect(notes[0].body).toContain("edge case"); + expect(notes[0].author?.username).toBe("bob"); + expect(notes[0].created_at).toBe("2025-03-20T10:30:00Z"); + expect(notes[0].system).toBe(false); + }); + + it("returns all comments in single fetch (no pagination)", async () => { + const manyComments = Array.from({ length: 100 }, (_, i) => ({ + body: `Comment ${i}`, + user: { login: "user", full_name: "User" }, + created_at: "2025-03-20T10:00:00Z", + })); + + mockFetch.mockResolvedValueOnce({ ok: true, json: async () => manyComments }); + + const { fetchGiteaPrComments } = await import("../src/gitea.js"); + const notes = await fetchGiteaPrComments("acme", "widget", 42, "gitea.example.com"); + + expect(notes).toHaveLength(100); + expect(mockFetch).toHaveBeenCalledTimes(1); + }); + + it("handles empty comments", async () => { + mockFetch.mockResolvedValueOnce({ + ok: true, + json: async () => [], + }); + + const { fetchGiteaPrComments } = await import("../src/gitea.js"); + const notes = await fetchGiteaPrComments("acme", "widget", 42, "gitea.example.com"); + + expect(notes).toHaveLength(0); + expect(mockFetch).toHaveBeenCalledTimes(1); + }); + }); + + describe("postGiteaPrComment", () => { + it("posts a comment with correct body", async () => { + mockFetch.mockResolvedValueOnce({ + ok: true, + json: async () => ({ id: 1 }), + }); + + const { postGiteaPrComment } = await import("../src/gitea.js"); + await postGiteaPrComment("acme", "widget", 42, "Great PR!", "gitea.example.com"); + + expect(mockFetch).toHaveBeenCalledWith( + "https://gitea.example.com/api/v1/repos/acme/widget/issues/42/comments", + expect.objectContaining({ + method: "POST", + body: JSON.stringify({ body: "Great PR!" }), + }), + ); + }); + }); + + describe("error handling", () => { + it("throws GiteaAPIError on 401", async () => { + mockFetch.mockResolvedValueOnce({ + ok: false, + status: 401, + text: async () => "Unauthorized", + }); + + const { fetchGiteaPrInfo, GiteaAPIError } = await import("../src/gitea.js"); + + await expect( + fetchGiteaPrInfo("acme", "widget", 42, "gitea.example.com"), + ).rejects.toThrow(GiteaAPIError); + }); + + it("throws GiteaAPIError on 429 rate limit", async () => { + mockFetch.mockResolvedValueOnce({ + ok: false, + status: 429, + text: async () => "Too Many Requests", + }); + + const { fetchGiteaPrInfo, GiteaAPIError } = await import("../src/gitea.js"); + + await expect( + fetchGiteaPrInfo("acme", "widget", 42, "gitea.example.com"), + ).rejects.toThrow(/Rate limited/); + }); + + it("throws when no token is set for write operations", async () => { + delete process.env.GITEA_TOKEN; + delete process.env.FORGEJO_TOKEN; + + vi.resetModules(); + const { postGiteaPrComment } = await import("../src/gitea.js"); + + await expect( + postGiteaPrComment("acme", "widget", 42, "test comment", "gitea.example.com"), + ).rejects.toThrow(/GITEA_TOKEN/); + }); + + it("uses FORGEJO_TOKEN as fallback", async () => { + delete process.env.GITEA_TOKEN; + process.env.FORGEJO_TOKEN = "forgejo-token-456"; + + mockFetch.mockResolvedValueOnce({ + ok: true, + json: async () => SAMPLE_PR, + }); + + vi.resetModules(); + const { fetchGiteaPrInfo } = await import("../src/gitea.js"); + await fetchGiteaPrInfo("acme", "widget", 42, "forgejo.example.com"); + + expect(mockFetch).toHaveBeenCalledWith( + expect.any(String), + expect.objectContaining({ + headers: expect.objectContaining({ + Authorization: "token forgejo-token-456", + }), + }), + ); + }); + }); +}); From 46d80c75e9701ed132d6beaa0ee905554d4331c4 Mon Sep 17 00:00:00 2001 From: Abhinav Raut Date: Fri, 27 Mar 2026 14:29:15 +0530 Subject: [PATCH 2/2] Bump version --- package.json | 2 +- src/cli.ts | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/package.json b/package.json index bcde33d..e19c9e0 100644 --- a/package.json +++ b/package.json @@ -3,7 +3,7 @@ "publishConfig": { "access": "public" }, - "version": "0.5.1", + "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", diff --git a/src/cli.ts b/src/cli.ts index e443e6b..561e716 100644 --- a/src/cli.ts +++ b/src/cli.ts @@ -20,7 +20,7 @@ program "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.5.1") + .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 ",