feat(prd-1): implement M5 - Forgejo comment URL formatting
- Create src/forgejo/comment-logic.ts with Forgejo-specific URL builders: - Unicode spinner (replaces GitHub CDN image) - PR compare URL with expand=1 (no quick_pull=1) - Dynamic extractOwnerRepoFromUrl() fixing hardcoded github.com regex - updateForgejoCommentBody() with cross-platform URL extraction - 41 tests covering all URL patterns, spinner, and the critical regex fix - No upstream files modified (lean adapter approach) Progress: 56% complete (5/9) - next: M6 (MCP server platform routing) Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
1 parent
4b0737c54c
commit
0802ac18eb
3 files changed
+884
-1
No files matched your search
@@ -263,7 +263,7 @@ The upstream repo has 13 workflows in `.github/workflows/`. Strategy:
|
|||||||
- Depends on: M4a
|
- Depends on: M4a
|
||||||
- Success: Full parity with GitHub GraphQL data fetch. All comments, reviews, and inline comments included with proper TOCTOU filtering.
|
- 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)
|
- Files: `src/forgejo/comment-logic.ts` (NEW)
|
||||||
- Depends on: M1
|
- Depends on: M1
|
||||||
- Success: Comment formatting produces correct Forgejo URLs.
|
- Success: Comment formatting produces correct Forgejo URLs.
|
||||||
|
|||||||
@@ -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*<img[^>]*>)?/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();
|
||||||
|
}
|
||||||
@@ -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("<img");
|
||||||
|
expect(FORGEJO_SPINNER_HTML).toBe("⏳");
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
// 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;
|
||||||
|
}
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
// 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… <img src="https://github.com/user-attachments/assets/5ac382c7" width="14px" />',
|
||||||
|
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("<img");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("removes working message with Forgejo Unicode 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("⏳");
|
||||||
|
});
|
||||||
|
|
||||||
|
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`");
|
||||||
|
});
|
||||||
|
});
|
||||||
|
});
|
||||||
Reference in new issue
Block a user