diff --git a/action.yml b/action.yml index e0ce364cb..319903758 100644 --- a/action.yml +++ b/action.yml @@ -188,6 +188,7 @@ runs: ADDITIONAL_PERMISSIONS: ${{ inputs.additional_permissions }} CLAUDE_ARGS: ${{ inputs.claude_args }} ALL_INPUTS: ${{ toJson(inputs) }} + GITHUB_JOB_ID: ${{ github.job }} - name: Install Base Action Dependencies if: steps.prepare.outputs.contains_trigger == 'true' diff --git a/src/github/context.ts b/src/github/context.ts index 90fba9d32..40e0cde18 100644 --- a/src/github/context.ts +++ b/src/github/context.ts @@ -92,6 +92,7 @@ type BaseContext = { useCommitSigning: boolean; botId: string; botName: string; + jobId: string; allowedBots: string; allowedNonWriteUsers: string; trackProgress: boolean; @@ -148,6 +149,7 @@ export function parseGitHubContext(): GitHubContext { useCommitSigning: process.env.USE_COMMIT_SIGNING === "true", botId: process.env.BOT_ID ?? String(CLAUDE_APP_BOT_ID), botName: process.env.BOT_NAME ?? CLAUDE_BOT_LOGIN, + jobId: process.env.GITHUB_JOB_ID || process.env.GITHUB_JOB || "", allowedBots: process.env.ALLOWED_BOTS ?? "", allowedNonWriteUsers: process.env.ALLOWED_NON_WRITE_USERS ?? "", trackProgress: process.env.TRACK_PROGRESS === "true", diff --git a/src/github/operations/comment-logic.ts b/src/github/operations/comment-logic.ts index 03b5d86ce..1211f927b 100644 --- a/src/github/operations/comment-logic.ts +++ b/src/github/operations/comment-logic.ts @@ -79,10 +79,20 @@ export function updateCommentBody(input: CommentUpdateInput): string { errorDetails, } = input; + // Extract and preserve sticky header for job isolation + const stickyHeaderPattern = /^()\n?/; + const stickyHeaderMatch = originalBody.match(stickyHeaderPattern); + const stickyHeader = stickyHeaderMatch ? stickyHeaderMatch[1] : ""; + + // Remove sticky header before processing content + const bodyWithoutHeader = stickyHeader + ? originalBody.replace(stickyHeaderPattern, "") + : originalBody; + // 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(); + let bodyContent = bodyWithoutHeader.replace(workingPattern, "").trim(); // Check if there's a PR link in the content let prLinkFromContent = ""; @@ -199,5 +209,10 @@ export function updateCommentBody(input: CommentUpdateInput): string { // Add the cleaned body content newBody += bodyContent; - return newBody.trim(); + // Prepend sticky header if it existed in the original comment + const finalBody = stickyHeader + ? `${stickyHeader}\n${newBody.trim()}` + : newBody.trim(); + + return finalBody; } diff --git a/src/github/operations/comments/common.ts b/src/github/operations/comments/common.ts index df24c0329..b5b045829 100644 --- a/src/github/operations/comments/common.ts +++ b/src/github/operations/comments/common.ts @@ -21,11 +21,17 @@ export function createBranchLink( return `\n[View branch](${branchUrl})`; } +export function createStickyCommentHeader(jobId: string): string { + return ``; +} + export function createCommentBody( jobRunLink: string, branchLink: string = "", + jobId: string = "", ): string { - return `Claude Code is working… ${SPINNER_HTML} + const header = jobId ? `${createStickyCommentHeader(jobId)}\n` : ""; + return `${header}Claude Code is working… ${SPINNER_HTML} I'll analyze this and get back to you. diff --git a/src/github/operations/comments/create-initial.ts b/src/github/operations/comments/create-initial.ts index 1243035b7..24baef4a4 100644 --- a/src/github/operations/comments/create-initial.ts +++ b/src/github/operations/comments/create-initial.ts @@ -6,7 +6,11 @@ */ import { appendFileSync } from "fs"; -import { createJobRunLink, createCommentBody } from "./common"; +import { + createJobRunLink, + createCommentBody, + createStickyCommentHeader, +} from "./common"; import { isPullRequestReviewCommentEvent, isPullRequestEvent, @@ -14,39 +18,42 @@ import { } from "../../context"; import type { Octokit } from "@octokit/rest"; -const CLAUDE_APP_BOT_ID = 209825114; - export async function createInitialComment( octokit: Octokit, context: ParsedGitHubContext, ) { const { owner, repo } = context.repository; + const { useStickyComment, jobId } = context.inputs; + + // Debug logging for sticky comment isolation + console.log(`📝 Sticky comment config: useStickyComment=${useStickyComment}, jobId="${jobId}"`); const jobRunLink = createJobRunLink(owner, repo, context.runId); - const initialBody = createCommentBody(jobRunLink); + // Include jobId in comment body when using sticky comments for job isolation + const initialBody = createCommentBody( + jobRunLink, + "", + useStickyComment ? jobId : "", + ); try { let response; - if ( - context.inputs.useStickyComment && - context.isPR && - isPullRequestEvent(context) - ) { + if (useStickyComment && context.isPR && isPullRequestEvent(context)) { const comments = await octokit.rest.issues.listComments({ owner, repo, issue_number: context.entityNumber, }); - const existingComment = comments.data.find((comment) => { - const idMatch = comment.user?.id === CLAUDE_APP_BOT_ID; - const botNameMatch = - comment.user?.type === "Bot" && - comment.user?.login.toLowerCase().includes("claude"); - const bodyMatch = comment.body === initialBody; - return idMatch || botNameMatch || bodyMatch; + // Find existing comment that matches this job's sticky header + const stickyHeader = createStickyCommentHeader(jobId); + const existingComment = comments.data.find((comment) => { + // Only match comments with OUR job's sticky header + // This ensures each job gets its own isolated comment + return comment.body?.includes(stickyHeader); }); + if (existingComment) { response = await octokit.rest.issues.updateComment({ owner, @@ -55,7 +62,7 @@ export async function createInitialComment( body: initialBody, }); } else { - // Create new comment if no existing one found + // Create new comment if no existing one found for this job response = await octokit.rest.issues.createComment({ owner, repo, diff --git a/src/github/operations/comments/update-with-branch.ts b/src/github/operations/comments/update-with-branch.ts index 838b15445..813eb1f55 100644 --- a/src/github/operations/comments/update-with-branch.ts +++ b/src/github/operations/comments/update-with-branch.ts @@ -25,6 +25,7 @@ export async function updateTrackingComment( ) { const { owner, repo } = context.repository; + const { useStickyComment, jobId } = context.inputs; const jobRunLink = createJobRunLink(owner, repo, context.runId); // Add branch link for issues (not PRs) @@ -33,7 +34,12 @@ export async function updateTrackingComment( branchLink = createBranchLink(owner, repo, branch); } - const updatedBody = createCommentBody(jobRunLink, branchLink); + // Preserve sticky header for job isolation + const updatedBody = createCommentBody( + jobRunLink, + branchLink, + useStickyComment ? jobId : "", + ); // Update the existing comment with the branch link try { diff --git a/src/github/utils/sanitizer.ts b/src/github/utils/sanitizer.ts index 83ee096ba..1ef93ff9d 100644 --- a/src/github/utils/sanitizer.ts +++ b/src/github/utils/sanitizer.ts @@ -97,4 +97,10 @@ export function redactGitHubTokens(content: string): string { } export const stripHtmlComments = (content: string) => - content.replace(//g, ""); + content.replace(//g, (match) => { + // Preserve sticky job headers used for comment isolation + if (match.startsWith("\n` : ""; + const finalBody = stickyHeader + sanitizedBody; const result = await updateClaudeComment(octokit, { owner, repo, commentId, - body: sanitizedBody, + body: finalBody, isPullRequestReviewComment, }); diff --git a/test/comment-logic.test.ts b/test/comment-logic.test.ts index d55c82d7b..7ebd5159f 100644 --- a/test/comment-logic.test.ts +++ b/test/comment-logic.test.ts @@ -443,4 +443,87 @@ describe("updateCommentBody", () => { expect(result).not.toContain("tree/claude/issue-123"); }); }); + + describe("sticky header preservation", () => { + it("preserves sticky header when updating comment", () => { + const input = { + ...baseInput, + currentBody: + "\nClaude Code is working…", + triggerUsername: "testuser", + }; + + const result = updateCommentBody(input); + expect(result).toContain(""); + expect(result).toContain("**Claude finished @testuser's task**"); + }); + + it("preserves sticky header with different job IDs", () => { + const input = { + ...baseInput, + currentBody: + "\nClaude Code is working…\n\nI'll analyze this.", + triggerUsername: "testuser", + }; + + const result = updateCommentBody(input); + expect(result).toContain(""); + expect(result).toContain("**Claude finished @testuser's task**"); + }); + + it("sticky header appears at the start of the final comment", () => { + const input = { + ...baseInput, + currentBody: + "\nClaude Code is working…", + triggerUsername: "testuser", + }; + + const result = updateCommentBody(input); + expect(result.startsWith("")).toBe(true); + }); + + it("does not add sticky header if none existed", () => { + const input = { + ...baseInput, + currentBody: "Claude Code is working…", + triggerUsername: "testuser", + }; + + const result = updateCommentBody(input); + expect(result).not.toContain("\nClaude Code is working…", + actionFailed: true, + executionDetails: { duration_ms: 30000 }, + }; + + const result = updateCommentBody(input); + expect(result).toContain(""); + expect(result).toContain("**Claude encountered an error after 30s**"); + }); + + it("preserves sticky header with branch and PR links", () => { + const input = { + ...baseInput, + currentBody: + "\nClaude Code is working…\n\n### Todo List:\n- [x] Done", + branchName: "feature-branch", + prLink: "\n[Create a PR](https://github.com/owner/repo/pr-url)", + triggerUsername: "testuser", + }; + + const result = updateCommentBody(input); + expect(result).toContain(""); + expect(result).toContain("`feature-branch`"); + expect(result).toContain("Create PR ➔"); + expect(result).toContain("### Todo List:"); + }); + }); }); diff --git a/test/sanitizer.test.ts b/test/sanitizer.test.ts index a89353b78..9d85021a6 100644 --- a/test/sanitizer.test.ts +++ b/test/sanitizer.test.ts @@ -241,6 +241,20 @@ describe("sanitizeContent", () => { expect(sanitized).not.toContain('title="'); expect(sanitized).toContain("
Test
"); }); + + it("should preserve sticky job headers while stripping other HTML comments", () => { + const content = ` + +Claude Code is working... +hidden text`; + + const sanitized = sanitizeContent(content); + + expect(sanitized).toContain(""); + expect(sanitized).not.toContain(""); + expect(sanitized).not.toContain("hidden text"); + expect(sanitized).toContain("Claude Code is working..."); + }); }); describe("redactGitHubTokens", () => { @@ -346,7 +360,7 @@ describe("sanitizeContent with token redaction", () => { }); }); -describe("stripHtmlComments (legacy)", () => { +describe("stripHtmlComments", () => { it("should remove HTML comments", () => { expect(stripHtmlComments("Hello World")).toBe( "Hello World", @@ -360,4 +374,24 @@ describe("stripHtmlComments (legacy)", () => { "Hello World", ); }); + + it("should preserve sticky job headers", () => { + expect( + stripHtmlComments("Content"), + ).toBe("Content"); + expect( + stripHtmlComments("\nClaude Code is working"), + ).toBe("\nClaude Code is working"); + }); + + it("should strip other comments but preserve sticky job headers", () => { + const content = "Content"; + expect(stripHtmlComments(content)).toBe("Content"); + }); + + it("should not preserve comments that look similar but are not sticky job headers", () => { + expect(stripHtmlComments("Content")).toBe("Content"); + expect(stripHtmlComments("Content")).toBe("Content"); + expect(stripHtmlComments("Content")).toBe("Content"); + }); }); diff --git a/test/sticky-comment-header.test.ts b/test/sticky-comment-header.test.ts new file mode 100644 index 000000000..54c53d855 --- /dev/null +++ b/test/sticky-comment-header.test.ts @@ -0,0 +1,95 @@ +import { describe, expect, test } from "bun:test"; +import { + createCommentBody, + createStickyCommentHeader, +} from "../src/github/operations/comments/common"; + +describe("Sticky Comment Header Logic", () => { + describe("createStickyCommentHeader", () => { + test("generates correct header format for job ID", () => { + const header = createStickyCommentHeader("claude-docs-review"); + expect(header).toBe(""); + }); + + test("generates correct header format for different job IDs", () => { + const header1 = createStickyCommentHeader("security-review"); + const header2 = createStickyCommentHeader("code-review"); + expect(header1).toBe(""); + expect(header2).toBe(""); + expect(header1).not.toBe(header2); + }); + }); + + describe("createCommentBody", () => { + test("includes sticky header when jobId is provided", () => { + const body = createCommentBody( + "http://example.com", + "", + "claude-security", + ); + expect(body).toContain(""); + expect(body).toContain("Claude Code is working"); + }); + + test("does not include header when jobId is empty", () => { + const body = createCommentBody("http://example.com"); + expect(body).not.toContain(""); + expect(body2).toContain(""); + }); + + test("header appears at the start of the comment body", () => { + const body = createCommentBody("http://example.com", "", "my-job"); + expect(body.startsWith("\n")).toBe(true); + }); + + test("includes branch link when provided", () => { + const body = createCommentBody( + "http://job.link", + "\n[View branch](http://branch.link)", + "my-job", + ); + expect(body).toContain(""); + expect(body).toContain("[View branch](http://branch.link)"); + expect(body).toContain("http://job.link"); + }); + }); + + describe("comment matching scenarios", () => { + test("different workflows with same bot produce different headers", () => { + // Simulating two different GitHub Actions jobs + const docsReviewHeader = createStickyCommentHeader("claude-docs-review"); + const securityReviewHeader = + createStickyCommentHeader("claude-security-review"); + + expect(docsReviewHeader).not.toBe(securityReviewHeader); + + // A comment from docs-review should not match security-review's header + const docsComment = `${docsReviewHeader}\nClaude Code is working...`; + expect(docsComment.includes(securityReviewHeader)).toBe(false); + expect(docsComment.includes(docsReviewHeader)).toBe(true); + }); + + test("same job ID produces matching headers", () => { + const header1 = createStickyCommentHeader("my-workflow-job"); + const header2 = createStickyCommentHeader("my-workflow-job"); + expect(header1).toBe(header2); + + const comment = `${header1}\nSome content`; + expect(comment.includes(header2)).toBe(true); + }); + }); +});