Files
claude-code-action/test/forgejo-comment-logic.test.ts
T
Vlad MocanuandClaude Opus 4.6 ac58fb6c14 fix(prd-7): M1 architecture review — consolidate duplication, fix bugs, memoize detection
- F1: Consolidated updateForgejoCommentBody into unified updateCommentBody (~150 lines removed)
- F2: Fixed wrong run identifier in update-with-branch.ts (context.runId → getForgejoRunIndex())
- F3: Memoized detectPlatform() with _resetPlatformCache() for tests
- F4: Added getServerUrl() runtime helper to resolve constant-vs-function inconsistency
- F5: Fixed sticky comment bot matching on Forgejo via context.inputs.botName
- F6: Documented GHES heuristic misidentification in docs/forgejo.md
- Updated PRD M1 checkbox to complete

Progress: 17% complete — M1 done, M2 (error handling review) next

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-02-26 12:09:02 +02:00

639 lines
21 KiB
TypeScript

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://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("<img");
expect(FORGEJO_SPINNER_HTML).toBe("⏳");
});
});
// ---------------------------------------------------------------------------
// getForgejoRunIndex
// ---------------------------------------------------------------------------
describe("getForgejoRunIndex", () => {
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("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("<img");
});
it("includes working message and job run link", () => {
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;
}
});
});
// ---------------------------------------------------------------------------
// updateCommentBody
// ---------------------------------------------------------------------------
describe("updateCommentBody", () => {
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… <img src="https://github.com/user-attachments/assets/5ac382c7" width="14px" />',
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("<img");
});
it("removes working message with Forgejo Unicode 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("⏳");
});
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://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 = 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://git.wxs.ro/wxs/myrepo/tree/feature-branch)",
};
const result = updateCommentBody(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 = updateCommentBody(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 = updateCommentBody(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 = updateCommentBody(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 = 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://git.wxs.ro/wxs/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://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 = 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://git.wxs.ro/wxs/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://git.wxs.ro/wxs/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`");
});
});
});