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`");
+ });
+ });
+});