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