diff --git a/prds/1-forgejo-platform-support.md b/prds/1-forgejo-platform-support.md index 6839af2..3c48fc0 100644 --- a/prds/1-forgejo-platform-support.md +++ b/prds/1-forgejo-platform-support.md @@ -263,7 +263,7 @@ The upstream repo has 13 workflows in `.github/workflows/`. Strategy: - Depends on: M4a - Success: Full parity with GitHub GraphQL data fetch. All comments, reviews, and inline comments included with proper TOCTOU filtering. -- [ ] **M5: Forgejo comment URL formatting** — Create `src/forgejo/comment-logic.ts` with Forgejo-specific URL patterns for job links, branch links, and PR creation links (used in tracking comment updates). +- [x] **M5: Forgejo comment URL formatting** — Create `src/forgejo/comment-logic.ts` with Forgejo-specific URL patterns for job links, branch links, and PR creation links (used in tracking comment updates). - Files: `src/forgejo/comment-logic.ts` (NEW) - Depends on: M1 - Success: Comment formatting produces correct Forgejo URLs. diff --git a/src/forgejo/comment-logic.ts b/src/forgejo/comment-logic.ts new file mode 100644 index 0000000..9b83b9a --- /dev/null +++ b/src/forgejo/comment-logic.ts @@ -0,0 +1,288 @@ +/** + * Forgejo-specific comment URL formatting and body construction. + * + * Mirrors the GitHub versions in: + * - src/github/operations/comments/common.ts (spinner, link builders, initial comment) + * - src/github/operations/comment-logic.ts (updateCommentBody for final comment) + * + * Differences from GitHub: + * - Unicode spinner instead of GitHub CDN image (CDN URLs don't resolve on Forgejo) + * - PR compare URL omits `quick_pull=1` (GitHub-specific parameter) + * - Dynamic server URL extraction instead of hardcoded github.com regex + */ + +import { ensureProperlyEncodedUrl } from "../github/operations/comment-logic"; +import type { CommentUpdateInput } from "../github/operations/comment-logic"; + +// Re-export types so consumers don't need to import from two places +export type { + CommentUpdateInput, + ExecutionDetails, +} from "../github/operations/comment-logic"; +export { ensureProperlyEncodedUrl } from "../github/operations/comment-logic"; + +/** + * Read the server URL at call time (not module load time). + * + * The GitHub module uses a top-level constant from config.ts, which is fine + * when GITHUB_SERVER_URL doesn't change. For Forgejo we read from env at + * call time so that: + * 1. Tests can override the env var and see the effect immediately. + * 2. The same module works regardless of import order. + */ +function getServerUrl(): string { + return process.env.GITHUB_SERVER_URL || "https://github.com"; +} + +// --------------------------------------------------------------------------- +// Spinner +// --------------------------------------------------------------------------- + +/** + * Unicode spinner for Forgejo comments. + * The GitHub version uses a CDN-hosted animated GIF that won't load on Forgejo. + */ +export const FORGEJO_SPINNER_HTML = "⏳"; + +// --------------------------------------------------------------------------- +// URL Builders +// --------------------------------------------------------------------------- + +export function createForgejoJobRunLink( + owner: string, + repo: string, + runId: string, +): string { + const jobRunUrl = `${getServerUrl()}/${owner}/${repo}/actions/runs/${runId}`; + return `[View job run](${jobRunUrl})`; +} + +export function createForgejoBranchLink( + owner: string, + repo: string, + branchName: string, +): string { + const branchUrl = `${getServerUrl()}/${owner}/${repo}/tree/${branchName}`; + return `\n[View branch](${branchUrl})`; +} + +/** + * Build a Forgejo PR compare URL for "Create a PR" links. + * + * Forgejo's compare page pre-fills title/body from query params but does not + * support GitHub's `quick_pull=1` parameter. + */ +export function createForgejoPrCompareUrl( + serverUrl: string, + owner: string, + repo: string, + baseBranch: string, + headBranch: string, + title: string, + body: string, +): string { + const prTitle = encodeURIComponent(title); + const prBody = encodeURIComponent(body); + return `${serverUrl}/${owner}/${repo}/compare/${baseBranch}...${headBranch}?expand=1&title=${prTitle}&body=${prBody}`; +} + +// --------------------------------------------------------------------------- +// Initial comment body (shown while Claude is working) +// --------------------------------------------------------------------------- + +export function createForgejoCommentBody( + jobRunLink: string, + branchLink: string = "", +): string { + return `Claude Code is working… ${FORGEJO_SPINNER_HTML} + +I'll analyze this and get back to you. + +${jobRunLink}${branchLink}`; +} + +// --------------------------------------------------------------------------- +// Owner/repo extraction helper +// --------------------------------------------------------------------------- + +/** + * Extract owner/repo from a URL using the configured server URL. + * + * Unlike the GitHub version (which hardcodes `github.com`), this dynamically + * matches against any server URL — works for both GitHub and Forgejo. + */ +export function extractOwnerRepoFromUrl( + url: string, + serverUrl: string = getServerUrl(), +): { owner: string; repo: string } | null { + try { + const parsedServer = new URL(serverUrl); + const parsedUrl = new URL(url); + + // Host must match (handles both github.com and git.wxs.ro etc.) + if (parsedUrl.host !== parsedServer.host) { + return null; + } + + // Extract first two path segments as owner/repo + const segments = parsedUrl.pathname.split("/").filter(Boolean); + const owner = segments[0]; + const repo = segments[1]; + if (owner && repo) { + return { owner, repo }; + } + } catch { + // Invalid URLs — fall through to null + } + return null; +} + +// --------------------------------------------------------------------------- +// Final comment body (shown after Claude finishes) +// --------------------------------------------------------------------------- + +/** + * Forgejo version of updateCommentBody. + * + * Identical logic to src/github/operations/comment-logic.ts:updateCommentBody + * with one critical fix: uses extractOwnerRepoFromUrl() for dynamic server URL + * extraction instead of the hardcoded `github.com` regex. + */ +export function updateForgejoCommentBody(input: CommentUpdateInput): string { + const originalBody = input.currentBody; + const { + executionDetails, + jobUrl, + branchLink, + prLink, + actionFailed, + branchName, + triggerUsername, + errorDetails, + } = input; + + // Extract content from the original comment body + // First, remove the "Claude Code is working…" or "Claude Code is working..." message + const workingPattern = /Claude Code is working[…\.]{1,3}(?:\s*]*>)?/i; + let bodyContent = originalBody.replace(workingPattern, "").trim(); + + // Also remove the Unicode spinner used on Forgejo + bodyContent = bodyContent.replace(/⏳/g, "").trim(); + + // Check if there's a PR link in the content + let prLinkFromContent = ""; + + // Match the entire markdown link structure + const prLinkPattern = /\[Create .* PR\]\((.*)\)$/m; + const prLinkMatch = bodyContent.match(prLinkPattern); + + if (prLinkMatch && prLinkMatch[1]) { + const encodedUrl = ensureProperlyEncodedUrl(prLinkMatch[1]); + if (encodedUrl) { + prLinkFromContent = encodedUrl; + // Remove the PR link from the content + bodyContent = bodyContent.replace(prLinkMatch[0], "").trim(); + } + } + + // Calculate duration string if available + let durationStr = ""; + if (executionDetails?.duration_ms !== undefined) { + const totalSeconds = Math.round(executionDetails.duration_ms / 1000); + const minutes = Math.floor(totalSeconds / 60); + const seconds = totalSeconds % 60; + durationStr = minutes > 0 ? `${minutes}m ${seconds}s` : `${seconds}s`; + } + + // Build the header + let header = ""; + + if (actionFailed) { + header = "**Claude encountered an error"; + if (durationStr) { + header += ` after ${durationStr}`; + } + header += "**"; + } else { + // Get the username from triggerUsername or extract from content + const usernameMatch = bodyContent.match(/@([a-zA-Z0-9-]+)/); + const username = + triggerUsername || (usernameMatch ? usernameMatch[1] : "user"); + + header = `**Claude finished @${username}'s task`; + if (durationStr) { + header += ` in ${durationStr}`; + } + header += "**"; + } + + // Add links section + let links = ` —— [View job](${jobUrl})`; + + // Add branch name with link + if (branchName || branchLink) { + let finalBranchName = branchName; + let branchUrl = ""; + + if (branchLink) { + // Extract the branch URL from the link + const urlMatch = branchLink.match(/\((https?:\/\/.*)\)/); + if (urlMatch && urlMatch[1]) { + branchUrl = urlMatch[1]; + } + + // Extract branch name from link if not provided + if (!finalBranchName) { + const branchNameMatch = branchLink.match(/tree\/([^"'\)]+)/); + if (branchNameMatch) { + finalBranchName = branchNameMatch[1]; + } + } + } + + // If we don't have a URL yet but have a branch name, construct it + // FIX: Uses extractOwnerRepoFromUrl instead of hardcoded github.com regex + if (!branchUrl && finalBranchName) { + const extracted = extractOwnerRepoFromUrl(jobUrl); + if (extracted) { + branchUrl = `${getServerUrl()}/${extracted.owner}/${extracted.repo}/tree/${finalBranchName}`; + } + } + + if (finalBranchName && branchUrl) { + links += ` • [\`${finalBranchName}\`](${branchUrl})`; + } else if (finalBranchName) { + links += ` • \`${finalBranchName}\``; + } + } + + // Add PR link (either from content or provided) + const prUrl = + prLinkFromContent || (prLink ? prLink.match(/\(([^)]+)\)/)?.[1] : ""); + if (prUrl) { + links += ` • [Create PR ➔](${prUrl})`; + } + + // Build the new body with blank line between header and separator + let newBody = `${header}${links}`; + + // Add error details if available + if (actionFailed && errorDetails) { + newBody += `\n\n\`\`\`\n${errorDetails}\n\`\`\``; + } + + newBody += `\n\n---\n`; + + // Clean up the body content + // Remove any existing View job run, branch links from the bottom + bodyContent = bodyContent.replace(/\n?\[View job run\]\([^\)]+\)/g, ""); + bodyContent = bodyContent.replace(/\n?\[View branch\]\([^\)]+\)/g, ""); + + // Remove any existing duration info at the bottom + bodyContent = bodyContent.replace(/\n*---\n*Duration: [0-9]+m? [0-9]+s/g, ""); + + // Add the cleaned body content + newBody += bodyContent; + + return newBody.trim(); +} diff --git a/test/forgejo-comment-logic.test.ts b/test/forgejo-comment-logic.test.ts new file mode 100644 index 0000000..e77b8dc --- /dev/null +++ b/test/forgejo-comment-logic.test.ts @@ -0,0 +1,595 @@ +import { describe, it, expect, beforeEach, afterEach } from "bun:test"; +import { + FORGEJO_SPINNER_HTML, + createForgejoJobRunLink, + createForgejoBranchLink, + createForgejoPrCompareUrl, + createForgejoCommentBody, + extractOwnerRepoFromUrl, + updateForgejoCommentBody, + type CommentUpdateInput, +} from "../src/forgejo/comment-logic"; + +// --------------------------------------------------------------------------- +// Test helpers +// --------------------------------------------------------------------------- + +const FORGEJO_SERVER = "https://git.wxs.ro"; +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(" { + beforeEach(() => setServerUrl(FORGEJO_SERVER)); + afterEach(() => setServerUrl(GITHUB_SERVER)); + + it("creates a job run link with Forgejo server URL", () => { + const result = createForgejoJobRunLink("wxs", "myrepo", "42"); + expect(result).toBe( + "[View job run](https://git.wxs.ro/wxs/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("wxs", "myrepo", "feature-branch"); + expect(result).toBe( + "\n[View branch](https://git.wxs.ro/wxs/myrepo/tree/feature-branch)", + ); + }); + + it("handles branch names with slashes", () => { + const result = createForgejoBranchLink( + "wxs", + "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, + "wxs", + "myrepo", + "main", + "feature-branch", + "Fix bug", + "Fixes issue #1", + ); + + expect(result).toBe( + `${FORGEJO_SERVER}/wxs/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, + "wxs", + "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://git.wxs.ro/wxs/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://git.wxs.ro/wxs/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://git.wxs.ro/wxs/myrepo/actions/runs/42)"; + const branchLink = + "\n[View branch](https://git.wxs.ro/wxs/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://git.wxs.ro/wxs/myrepo/actions/runs/42", + FORGEJO_SERVER, + ); + expect(result).toEqual({ owner: "wxs", 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://git.wxs.ro/wxs/myrepo/tree/feature-branch", + FORGEJO_SERVER, + ); + expect(result).toEqual({ owner: "wxs", 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://git.wxs.ro/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://git.wxs.ro/wxs/myrepo/actions/runs/42", + ); + expect(result).toEqual({ owner: "wxs", repo: "myrepo" }); + } finally { + process.env.GITHUB_SERVER_URL = originalUrl; + } + }); +}); + +// --------------------------------------------------------------------------- +// updateForgejoCommentBody +// --------------------------------------------------------------------------- + +describe("updateForgejoCommentBody", () => { + const forgejoBaseInput: CommentUpdateInput = { + currentBody: "Initial comment body", + actionFailed: false, + executionDetails: null, + jobUrl: "https://git.wxs.ro/wxs/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 = updateForgejoCommentBody(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 = updateForgejoCommentBody(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 = updateForgejoCommentBody(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 = updateForgejoCommentBody(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 = updateForgejoCommentBody(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 = updateForgejoCommentBody(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 = updateForgejoCommentBody(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://git.wxs.ro/wxs/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 = updateForgejoCommentBody(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://git.wxs.ro/wxs/myrepo/tree/feature-branch)", + }; + + const result = updateForgejoCommentBody(input); + expect(result).toContain( + "• [`feature-branch`](https://git.wxs.ro/wxs/myrepo/tree/feature-branch)", + ); + }); + + it("extracts branch name from branchLink when branchName not provided", () => { + const input: CommentUpdateInput = { + ...forgejoBaseInput, + branchLink: + "\n[View branch](https://git.wxs.ro/wxs/myrepo/tree/auto-detected-branch)", + }; + + const result = updateForgejoCommentBody(input); + expect(result).toContain( + "• [`auto-detected-branch`](https://git.wxs.ro/wxs/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://git.wxs.ro/wxs/myrepo/compare/main...feature)", + }; + + const result = updateForgejoCommentBody(input); + expect(result).toContain( + "• [Create PR ➔](https://git.wxs.ro/wxs/myrepo/compare/main...feature)", + ); + }); + + it("moves PR link from body to header", () => { + const input: CommentUpdateInput = { + ...forgejoBaseInput, + currentBody: + "Some comment with [Create a PR](https://git.wxs.ro/wxs/myrepo/compare/main...fix)", + }; + + const result = updateForgejoCommentBody(input); + expect(result).toContain( + "• [Create PR ➔](https://git.wxs.ro/wxs/myrepo/compare/main...fix)", + ); + expect(result).not.toContain("[Create a PR]"); + }); + + it("handles Forgejo PR URLs with expand=1 parameter", () => { + const forgejoUrl = + "https://git.wxs.ro/wxs/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 = updateForgejoCommentBody(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 = updateForgejoCommentBody(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 = updateForgejoCommentBody(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 = updateForgejoCommentBody(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://git.wxs.ro/wxs/myrepo/compare/main...claude-branch-123)", + executionDetails: { duration_ms: 65000 }, + triggerUsername: "trigger-user", + }; + + const result = updateForgejoCommentBody(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://git.wxs.ro/wxs/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 = updateForgejoCommentBody(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://git.wxs.ro/wxs/myrepo/actions/runs/1)", + triggerUsername: "user", + }; + + const result = updateForgejoCommentBody(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://git.wxs.ro/wxs/myrepo/tree/old-branch)", + branchName: "new-branch", + triggerUsername: "user", + }; + + const result = updateForgejoCommentBody(input); + // Old link in body removed + expect(result).not.toContain("[View branch]"); + // New branch in header + expect(result).toContain("`new-branch`"); + }); + }); +});