import { describe, it, expect, beforeEach, afterEach } from "bun:test"; import { FORGEJO_SPINNER_HTML, getForgejoRunIndex, createForgejoJobRunLink, createForgejoBranchLink, createForgejoPrCompareUrl, createForgejoCommentBody, } from "../src/forgejo/comment-logic"; import { extractOwnerRepoFromUrl, updateCommentBody, type CommentUpdateInput, } from "../src/github/operations/comment-logic"; // --------------------------------------------------------------------------- // Test helpers // --------------------------------------------------------------------------- const FORGEJO_SERVER = "https://forgejo.example.com"; const GITHUB_SERVER = "https://github.com"; /** * Set GITHUB_SERVER_URL to a Forgejo instance URL. * The module reads it at call time via the config export. */ function setServerUrl(url: string) { process.env.GITHUB_SERVER_URL = url; } // --------------------------------------------------------------------------- // FORGEJO_SPINNER_HTML // --------------------------------------------------------------------------- describe("FORGEJO_SPINNER_HTML", () => { it("is a Unicode character, not a GitHub CDN URL", () => { expect(FORGEJO_SPINNER_HTML).not.toContain("github.com"); expect(FORGEJO_SPINNER_HTML).not.toContain(" { const origRunNumber = process.env.GITHUB_RUN_NUMBER; const origRunId = process.env.GITHUB_RUN_ID; afterEach(() => { if (origRunNumber !== undefined) { process.env.GITHUB_RUN_NUMBER = origRunNumber; } else { delete process.env.GITHUB_RUN_NUMBER; } if (origRunId !== undefined) { process.env.GITHUB_RUN_ID = origRunId; } else { delete process.env.GITHUB_RUN_ID; } }); it("prefers GITHUB_RUN_NUMBER over GITHUB_RUN_ID", () => { process.env.GITHUB_RUN_NUMBER = "661"; process.env.GITHUB_RUN_ID = "11943"; expect(getForgejoRunIndex()).toBe("661"); }); it("falls back to GITHUB_RUN_ID when GITHUB_RUN_NUMBER is not set", () => { delete process.env.GITHUB_RUN_NUMBER; process.env.GITHUB_RUN_ID = "11943"; expect(getForgejoRunIndex()).toBe("11943"); }); it("returns '0' when neither env var is set", () => { delete process.env.GITHUB_RUN_NUMBER; delete process.env.GITHUB_RUN_ID; expect(getForgejoRunIndex()).toBe("0"); }); }); // --------------------------------------------------------------------------- // createForgejoJobRunLink // --------------------------------------------------------------------------- describe("createForgejoJobRunLink", () => { beforeEach(() => setServerUrl(FORGEJO_SERVER)); afterEach(() => setServerUrl(GITHUB_SERVER)); it("creates a job run link with Forgejo server URL", () => { const result = createForgejoJobRunLink("myorg", "myrepo", "42"); expect(result).toBe( "[View job run](https://forgejo.example.com/myorg/myrepo/actions/runs/42)", ); }); it("uses /actions/runs/ path (same as GitHub)", () => { const result = createForgejoJobRunLink("org", "repo", "999"); expect(result).toContain("/actions/runs/999"); }); }); // --------------------------------------------------------------------------- // createForgejoBranchLink // --------------------------------------------------------------------------- describe("createForgejoBranchLink", () => { beforeEach(() => setServerUrl(FORGEJO_SERVER)); afterEach(() => setServerUrl(GITHUB_SERVER)); it("creates a branch link with /tree/ path", () => { const result = createForgejoBranchLink("myorg", "myrepo", "feature-branch"); expect(result).toBe( "\n[View branch](https://forgejo.example.com/myorg/myrepo/tree/feature-branch)", ); }); it("handles branch names with slashes", () => { const result = createForgejoBranchLink( "myorg", "myrepo", "claude/issue-5-20240101-1200", ); expect(result).toContain("/tree/claude/issue-5-20240101-1200"); }); }); // --------------------------------------------------------------------------- // createForgejoPrCompareUrl // --------------------------------------------------------------------------- describe("createForgejoPrCompareUrl", () => { it("builds compare URL without quick_pull=1", () => { const result = createForgejoPrCompareUrl( FORGEJO_SERVER, "myorg", "myrepo", "main", "feature-branch", "Fix bug", "Fixes issue #1", ); expect(result).toBe( `${FORGEJO_SERVER}/myorg/myrepo/compare/main...feature-branch?expand=1&title=Fix%20bug&body=Fixes%20issue%20%231`, ); expect(result).not.toContain("quick_pull"); }); it("uses expand=1 parameter for Forgejo", () => { const result = createForgejoPrCompareUrl( FORGEJO_SERVER, "myorg", "myrepo", "main", "feature", "Title", "Body", ); expect(result).toContain("expand=1"); }); it("URL-encodes title and body", () => { const result = createForgejoPrCompareUrl( FORGEJO_SERVER, "org", "repo", "main", "fix-branch", "fix: important bug fix", "Fixes #123\n\nGenerated with Claude Code", ); expect(result).toContain("title=fix%3A%20important%20bug%20fix"); expect(result).toContain( "body=Fixes%20%23123%0A%0AGenerated%20with%20Claude%20Code", ); }); it("uses three dots between branches", () => { const result = createForgejoPrCompareUrl( FORGEJO_SERVER, "o", "r", "main", "dev", "t", "b", ); expect(result).toContain("/compare/main...dev?"); }); }); // --------------------------------------------------------------------------- // createForgejoCommentBody // --------------------------------------------------------------------------- describe("createForgejoCommentBody", () => { beforeEach(() => setServerUrl(FORGEJO_SERVER)); afterEach(() => setServerUrl(GITHUB_SERVER)); it("uses Forgejo spinner instead of GitHub CDN image", () => { const result = createForgejoCommentBody( "[View job run](https://forgejo.example.com/myorg/myrepo/actions/runs/1)", ); expect(result).toContain(FORGEJO_SPINNER_HTML); expect(result).not.toContain("github.com/user-attachments"); expect(result).not.toContain(" { const jobLink = "[View job run](https://forgejo.example.com/myorg/myrepo/actions/runs/42)"; const result = createForgejoCommentBody(jobLink); expect(result).toContain("Claude Code is working…"); expect(result).toContain("I'll analyze this and get back to you."); expect(result).toContain(jobLink); }); it("includes branch link when provided", () => { const jobLink = "[View job run](https://forgejo.example.com/myorg/myrepo/actions/runs/42)"; const branchLink = "\n[View branch](https://forgejo.example.com/myorg/myrepo/tree/feature)"; const result = createForgejoCommentBody(jobLink, branchLink); expect(result).toContain(branchLink); }); }); // --------------------------------------------------------------------------- // extractOwnerRepoFromUrl // --------------------------------------------------------------------------- describe("extractOwnerRepoFromUrl", () => { it("extracts owner/repo from Forgejo job URL", () => { const result = extractOwnerRepoFromUrl( "https://forgejo.example.com/myorg/myrepo/actions/runs/42", FORGEJO_SERVER, ); expect(result).toEqual({ owner: "myorg", repo: "myrepo" }); }); it("extracts owner/repo from GitHub job URL", () => { const result = extractOwnerRepoFromUrl( "https://github.com/anthropics/claude-code-action/actions/runs/123", GITHUB_SERVER, ); expect(result).toEqual({ owner: "anthropics", repo: "claude-code-action" }); }); it("extracts owner/repo from branch URL", () => { const result = extractOwnerRepoFromUrl( "https://forgejo.example.com/myorg/myrepo/tree/feature-branch", FORGEJO_SERVER, ); expect(result).toEqual({ owner: "myorg", repo: "myrepo" }); }); it("returns null when hosts don't match", () => { const result = extractOwnerRepoFromUrl( "https://github.com/owner/repo/actions/runs/1", FORGEJO_SERVER, ); expect(result).toBeNull(); }); it("returns null for URLs with less than 2 path segments", () => { const result = extractOwnerRepoFromUrl( "https://forgejo.example.com/only-one-segment", FORGEJO_SERVER, ); expect(result).toBeNull(); }); it("returns null for invalid URLs", () => { const result = extractOwnerRepoFromUrl("not-a-url", FORGEJO_SERVER); expect(result).toBeNull(); }); it("handles server URLs with ports", () => { const result = extractOwnerRepoFromUrl( "https://forgejo.local:3000/org/repo/actions/runs/1", "https://forgejo.local:3000", ); expect(result).toEqual({ owner: "org", repo: "repo" }); }); it("handles http:// server URLs", () => { const result = extractOwnerRepoFromUrl( "http://forgejo.local/org/repo/tree/main", "http://forgejo.local", ); expect(result).toEqual({ owner: "org", repo: "repo" }); }); it("defaults serverUrl to GITHUB_SERVER_URL env var", () => { const originalUrl = process.env.GITHUB_SERVER_URL; process.env.GITHUB_SERVER_URL = FORGEJO_SERVER; try { const result = extractOwnerRepoFromUrl( "https://forgejo.example.com/myorg/myrepo/actions/runs/42", ); expect(result).toEqual({ owner: "myorg", repo: "myrepo" }); } finally { process.env.GITHUB_SERVER_URL = originalUrl; } }); }); // --------------------------------------------------------------------------- // updateCommentBody // --------------------------------------------------------------------------- describe("updateCommentBody", () => { const forgejoBaseInput: CommentUpdateInput = { currentBody: "Initial comment body", actionFailed: false, executionDetails: null, jobUrl: "https://forgejo.example.com/myorg/myrepo/actions/runs/42", branchName: undefined, triggerUsername: undefined, }; beforeEach(() => setServerUrl(FORGEJO_SERVER)); afterEach(() => setServerUrl(GITHUB_SERVER)); describe("working message replacement", () => { it("removes working message with GitHub spinner", () => { const input: CommentUpdateInput = { ...forgejoBaseInput, currentBody: 'Claude Code is working… ', triggerUsername: "testuser", }; const result = updateCommentBody(input); expect(result).toContain("**Claude finished @testuser's task**"); expect(result).not.toContain("Claude Code is working"); expect(result).not.toContain(" { const input: CommentUpdateInput = { ...forgejoBaseInput, currentBody: "Claude Code is working… ⏳", triggerUsername: "testuser", }; const result = updateCommentBody(input); expect(result).toContain("**Claude finished @testuser's task**"); expect(result).not.toContain("Claude Code is working"); expect(result).not.toContain("⏳"); }); it("includes success header with duration", () => { const input: CommentUpdateInput = { ...forgejoBaseInput, currentBody: "Claude Code is working…", executionDetails: { duration_ms: 74000 }, triggerUsername: "trigger-user", }; const result = updateCommentBody(input); expect(result).toContain( "**Claude finished @trigger-user's task in 1m 14s**", ); }); it("includes error header with duration", () => { const input: CommentUpdateInput = { ...forgejoBaseInput, currentBody: "Claude Code is working...", actionFailed: true, executionDetails: { duration_ms: 45000 }, }; const result = updateCommentBody(input); expect(result).toContain("**Claude encountered an error after 45s**"); }); it("includes error details when provided", () => { const input: CommentUpdateInput = { ...forgejoBaseInput, currentBody: "Claude Code is working...", actionFailed: true, executionDetails: { duration_ms: 45000 }, errorDetails: "Failed to fetch issue data", }; const result = updateCommentBody(input); expect(result).toContain("**Claude encountered an error after 45s**"); expect(result).toContain("[View job]"); expect(result).toContain("```\nFailed to fetch issue data\n```"); }); }); describe("job link", () => { it("includes Forgejo job link in header", () => { const input: CommentUpdateInput = { ...forgejoBaseInput, currentBody: "Some comment", }; const result = updateCommentBody(input); expect(result).toContain(`—— [View job](${forgejoBaseInput.jobUrl})`); }); }); describe("branch link — critical regex fix", () => { it("constructs branch URL from Forgejo job URL when branchName is provided without branchLink", () => { const input: CommentUpdateInput = { ...forgejoBaseInput, branchName: "claude/issue-5-20240101-1200", }; const result = updateCommentBody(input); // This is the critical fix: GitHub's version uses a hardcoded github.com regex // which fails for Forgejo URLs. Our version uses extractOwnerRepoFromUrl. expect(result).toContain( "• [`claude/issue-5-20240101-1200`](https://forgejo.example.com/myorg/myrepo/tree/claude/issue-5-20240101-1200)", ); }); it("falls back to plain branch name when URL extraction fails", () => { const input: CommentUpdateInput = { ...forgejoBaseInput, jobUrl: "invalid-url", branchName: "feature-branch", }; const result = updateCommentBody(input); expect(result).toContain("• `feature-branch`"); expect(result).not.toContain("[`feature-branch`]"); }); it("uses branchLink URL when provided", () => { const input: CommentUpdateInput = { ...forgejoBaseInput, branchName: "feature-branch", branchLink: "\n[View branch](https://forgejo.example.com/myorg/myrepo/tree/feature-branch)", }; const result = updateCommentBody(input); expect(result).toContain( "• [`feature-branch`](https://forgejo.example.com/myorg/myrepo/tree/feature-branch)", ); }); it("extracts branch name from branchLink when branchName not provided", () => { const input: CommentUpdateInput = { ...forgejoBaseInput, branchLink: "\n[View branch](https://forgejo.example.com/myorg/myrepo/tree/auto-detected-branch)", }; const result = updateCommentBody(input); expect(result).toContain( "• [`auto-detected-branch`](https://forgejo.example.com/myorg/myrepo/tree/auto-detected-branch)", ); }); }); describe("PR link", () => { it("adds PR link to header when provided", () => { const input: CommentUpdateInput = { ...forgejoBaseInput, prLink: "\n[Create a PR](https://forgejo.example.com/myorg/myrepo/compare/main...feature)", }; const result = updateCommentBody(input); expect(result).toContain( "• [Create PR ➔](https://forgejo.example.com/myorg/myrepo/compare/main...feature)", ); }); it("moves PR link from body to header", () => { const input: CommentUpdateInput = { ...forgejoBaseInput, currentBody: "Some comment with [Create a PR](https://forgejo.example.com/myorg/myrepo/compare/main...fix)", }; const result = updateCommentBody(input); expect(result).toContain( "• [Create PR ➔](https://forgejo.example.com/myorg/myrepo/compare/main...fix)", ); expect(result).not.toContain("[Create a PR]"); }); it("handles Forgejo PR URLs with expand=1 parameter", () => { const forgejoUrl = "https://forgejo.example.com/myorg/myrepo/compare/main...feature?expand=1&title=Fix%20bug&body=Desc"; const input: CommentUpdateInput = { ...forgejoBaseInput, currentBody: `Done!\n\n[Create a PR](${forgejoUrl})`, }; const result = updateCommentBody(input); expect(result).toContain(`• [Create PR ➔](${forgejoUrl})`); }); }); describe("execution details", () => { it("formats duration in minutes and seconds", () => { const input: CommentUpdateInput = { ...forgejoBaseInput, executionDetails: { duration_ms: 75000 }, triggerUsername: "testuser", }; const result = updateCommentBody(input); expect(result).toContain( "**Claude finished @testuser's task in 1m 15s**", ); }); it("formats seconds-only duration", () => { const input: CommentUpdateInput = { ...forgejoBaseInput, executionDetails: { duration_ms: 31000 }, triggerUsername: "testuser", }; const result = updateCommentBody(input); expect(result).toContain("**Claude finished @testuser's task in 31s**"); }); it("handles missing duration gracefully", () => { const input: CommentUpdateInput = { ...forgejoBaseInput, executionDetails: { total_cost_usd: 0.25 }, triggerUsername: "testuser", }; const result = updateCommentBody(input); expect(result).toContain("**Claude finished @testuser's task**"); expect(result).not.toContain(" in "); }); }); describe("combined updates", () => { it("combines all updates in correct order", () => { const input: CommentUpdateInput = { ...forgejoBaseInput, currentBody: "Claude Code is working… ⏳\n\n### Todo List:\n- [x] Read README.md\n- [x] Add disclaimer", actionFailed: false, branchName: "claude-branch-123", prLink: "\n[Create a PR](https://forgejo.example.com/myorg/myrepo/compare/main...claude-branch-123)", executionDetails: { duration_ms: 65000 }, triggerUsername: "trigger-user", }; const result = updateCommentBody(input); // Check header expect(result).toContain( "**Claude finished @trigger-user's task in 1m 5s**", ); expect(result).toContain("—— [View job]"); expect(result).toContain( "• [`claude-branch-123`](https://forgejo.example.com/myorg/myrepo/tree/claude-branch-123)", ); expect(result).toContain("• [Create PR ➔]"); // Check order const headerIndex = result.indexOf("**Claude finished"); const separatorIndex = result.indexOf("---"); const todoIndex = result.indexOf("### Todo List:"); expect(headerIndex).toBeLessThan(separatorIndex); expect(separatorIndex).toBeLessThan(todoIndex); // Check content preserved expect(result).toContain("### Todo List:"); expect(result).toContain("- [x] Read README.md"); expect(result).toContain("- [x] Add disclaimer"); // Spinner fully removed expect(result).not.toContain("⏳"); expect(result).not.toContain("Claude Code is working"); }); it("should not show branch name when branch doesn't exist remotely", () => { const input: CommentUpdateInput = { ...forgejoBaseInput, currentBody: "@claude can you help with this?", executionDetails: { duration_ms: 90000 }, branchLink: "", branchName: undefined, triggerUsername: "claude", prLink: "", }; const result = updateCommentBody(input); expect(result).toContain("Claude finished @claude's task in 1m 30s"); expect(result).toContain(`[View job](${forgejoBaseInput.jobUrl})`); expect(result).not.toContain("claude/issue-123"); expect(result).not.toContain("tree/claude/issue-123"); }); }); describe("comparison with GitHub version — behavior parity", () => { it("removes old View job run links from body", () => { const input: CommentUpdateInput = { ...forgejoBaseInput, currentBody: "Some work done\n[View job run](https://forgejo.example.com/myorg/myrepo/actions/runs/1)", triggerUsername: "user", }; const result = updateCommentBody(input); expect(result).not.toContain("[View job run]"); expect(result).toContain("[View job]"); }); it("removes old View branch links from body", () => { const input: CommentUpdateInput = { ...forgejoBaseInput, currentBody: "Some work done\n[View branch](https://forgejo.example.com/myorg/myrepo/tree/old-branch)", branchName: "new-branch", triggerUsername: "user", }; const result = updateCommentBody(input); // Old link in body removed expect(result).not.toContain("[View branch]"); // New branch in header expect(result).toContain("`new-branch`"); }); }); });