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>
This commit is contained in:
Vlad MocanuandClaude Opus 4.6 committed 2026-02-26 12:09:02 +02:00
1 parent ae326659b1
commit ac58fb6c14
15 files changed
+151 -266

No files matched your search

+3 -7
View File
@@ -8,7 +8,6 @@ import {
type CommentUpdateInput,
} from "../github/operations/comment-logic";
import {
updateForgejoCommentBody,
createForgejoPrCompareUrl,
getForgejoRunIndex,
} from "../forgejo/comment-logic";
@@ -19,7 +18,7 @@ import {
isEntityContext,
} from "../github/context";
import type { ParsedGitHubContext } from "../github/context";
import { GITHUB_SERVER_URL } from "../github/api/config";
import { getServerUrl } from "../github/api/config";
import { checkAndCommitOrDeleteBranch } from "../github/operations/branch-cleanup";
import { updateClaudeComment } from "../github/operations/comments/update-claude-comment";
@@ -54,7 +53,7 @@ export async function updateCommentLink(
const { owner, repo } = context.repository;
const platform = detectPlatform();
const serverUrl = GITHUB_SERVER_URL;
const serverUrl = getServerUrl();
const runIdentifier =
platform === "forgejo" ? getForgejoRunIndex() : process.env.GITHUB_RUN_ID;
const jobUrl = `${serverUrl}/${owner}/${repo}/actions/runs/${runIdentifier}`;
@@ -250,10 +249,7 @@ export async function updateCommentLink(
errorDetails,
};
const updatedBody =
platform === "forgejo"
? updateForgejoCommentBody(commentInput)
: updateCommentBody(commentInput);
const updatedBody = updateCommentBody(commentInput);
try {
await updateClaudeComment(octokit.rest, {
+7 -208
View File
@@ -1,18 +1,16 @@
/**
* 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:
* Contains Forgejo-specific helpers for:
* - 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
* - PR compare URL with `expand=1` instead of GitHub's `quick_pull=1`
* - Forgejo run index (GITHUB_RUN_NUMBER vs GITHUB_RUN_ID)
*
* The final comment body logic (updateCommentBody) is unified in
* src/github/operations/comment-logic.ts and works for both platforms.
*/
import { ensureProperlyEncodedUrl } from "../github/operations/comment-logic";
import type { CommentUpdateInput } from "../github/operations/comment-logic";
import { getServerUrl } from "../github/api/config";
// Re-export types so consumers don't need to import from two places
export type {
@@ -21,19 +19,6 @@ export type {
} 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";
}
// ---------------------------------------------------------------------------
// Run index helper
// ---------------------------------------------------------------------------
@@ -118,189 +103,3 @@ 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();
}
+10
View File
@@ -2,3 +2,13 @@ export const GITHUB_API_URL =
process.env.GITHUB_API_URL || "https://api.github.com";
export const GITHUB_SERVER_URL =
process.env.GITHUB_SERVER_URL || "https://github.com";
/**
* Read the server URL at call time (not module load time).
*
* Use this instead of GITHUB_SERVER_URL in code paths where tests may
* override process.env.GITHUB_SERVER_URL after module import.
*/
export function getServerUrl(): string {
return process.env.GITHUB_SERVER_URL || "https://github.com";
}
+40 -6
View File
@@ -1,4 +1,4 @@
import { GITHUB_SERVER_URL } from "../api/config";
import { getServerUrl } from "../api/config";
export type ExecutionDetails = {
total_cost_usd?: number;
@@ -66,6 +66,37 @@ export function ensureProperlyEncodedUrl(url: string): string | null {
}
}
/**
* Extract owner/repo from a URL using the configured server URL.
*
* 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 custom domains)
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;
}
export function updateCommentBody(input: CommentUpdateInput): string {
const originalBody = input.currentBody;
const {
@@ -84,6 +115,9 @@ export function updateCommentBody(input: CommentUpdateInput): string {
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 (safe no-op on GitHub)
bodyContent = bodyContent.replace(/⏳/g, "").trim();
// Check if there's a PR link in the content
let prLinkFromContent = "";
@@ -141,7 +175,7 @@ export function updateCommentBody(input: CommentUpdateInput): string {
if (branchLink) {
// Extract the branch URL from the link
const urlMatch = branchLink.match(/\((https:\/\/.*)\)/);
const urlMatch = branchLink.match(/\((https?:\/\/.*)\)/);
if (urlMatch && urlMatch[1]) {
branchUrl = urlMatch[1];
}
@@ -156,11 +190,11 @@ export function updateCommentBody(input: CommentUpdateInput): string {
}
// If we don't have a URL yet but have a branch name, construct it
// Uses dynamic URL extraction that works for both GitHub and Forgejo
if (!branchUrl && finalBranchName) {
// Extract owner/repo from jobUrl
const repoMatch = jobUrl.match(/github\.com\/([^\/]+)\/([^\/]+)\//);
if (repoMatch) {
branchUrl = `${GITHUB_SERVER_URL}/${repoMatch[1]}/${repoMatch[2]}/tree/${finalBranchName}`;
const extracted = extractOwnerRepoFromUrl(jobUrl);
if (extracted) {
branchUrl = `${getServerUrl()}/${extracted.owner}/${extracted.repo}/tree/${finalBranchName}`;
}
}
@@ -56,9 +56,14 @@ export async function createInitialComment(
const botNameMatch =
comment.user?.type === "Bot" &&
comment.user?.login.toLowerCase().includes("claude");
// On Forgejo, the bot is a regular user account identified by bot_name input
const forgejoBotMatch =
platform === "forgejo" &&
context.inputs.botName &&
comment.user?.login === context.inputs.botName;
const bodyMatch = comment.body === initialBody;
return idMatch || botNameMatch || bodyMatch;
return idMatch || botNameMatch || forgejoBotMatch || bodyMatch;
});
if (existingComment) {
response = await octokit.rest.issues.updateComment({
@@ -14,6 +14,7 @@ import {
createForgejoJobRunLink,
createForgejoBranchLink,
createForgejoCommentBody,
getForgejoRunIndex,
} from "../../../forgejo/comment-logic";
import { detectPlatform } from "../../../platform/detector";
import { type Octokits } from "../../api/client";
@@ -34,7 +35,7 @@ export async function updateTrackingComment(
const jobRunLink =
platform === "forgejo"
? createForgejoJobRunLink(owner, repo, context.runId)
? createForgejoJobRunLink(owner, repo, getForgejoRunIndex())
: createJobRunLink(owner, repo, context.runId);
// Add branch link for issues (not PRs)
+32 -9
View File
@@ -6,24 +6,47 @@
* 2. FORGEJO env var (set by Forgejo runner v7.0+)
* 3. GITHUB_SERVER_URL heuristic (non-github.com = Forgejo)
* 4. Default to GitHub
*
* The result is memoized after first detection — platform doesn't change
* at runtime and this avoids redundant env reads across 12+ call sites.
*/
export type Platform = "github" | "forgejo";
let _cachedPlatform: Platform | undefined;
export function detectPlatform(): Platform {
if (_cachedPlatform !== undefined) return _cachedPlatform;
let result: Platform = "github";
// 1. Explicit platform from action.yml detect step or user input
const platformEnv = process.env.PLATFORM;
if (platformEnv === "forgejo") return "forgejo";
if (platformEnv === "github") return "github";
if (platformEnv === "forgejo") {
result = "forgejo";
} else if (platformEnv === "github") {
result = "github";
}
// 2. FORGEJO env var (set by Forgejo runner v7.0+)
if (process.env.FORGEJO === "true") return "forgejo";
else if (process.env.FORGEJO === "true") {
result = "forgejo";
}
// 3. GITHUB_SERVER_URL heuristic — if not github.com, likely Forgejo/Gitea
const serverUrl = process.env.GITHUB_SERVER_URL || "";
if (serverUrl && !serverUrl.includes("github.com")) {
return "forgejo";
else {
const serverUrl = process.env.GITHUB_SERVER_URL || "";
if (serverUrl && !serverUrl.includes("github.com")) {
result = "forgejo";
}
}
return "github";
_cachedPlatform = result;
return result;
}
/**
* Reset the memoized platform detection result.
* Only needed in tests that change platform-related env vars between cases.
*/
export function _resetPlatformCache(): void {
_cachedPlatform = undefined;
}