Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions action.yml
Original file line number Diff line number Diff line change
Expand Up @@ -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'
Expand Down
2 changes: 2 additions & 0 deletions src/github/context.ts
Original file line number Diff line number Diff line change
Expand Up @@ -92,6 +92,7 @@ type BaseContext = {
useCommitSigning: boolean;
botId: string;
botName: string;
jobId: string;
allowedBots: string;
allowedNonWriteUsers: string;
trackProgress: boolean;
Expand Down Expand Up @@ -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",
Expand Down
19 changes: 17 additions & 2 deletions src/github/operations/comment-logic.ts
Original file line number Diff line number Diff line change
Expand Up @@ -79,10 +79,20 @@ export function updateCommentBody(input: CommentUpdateInput): string {
errorDetails,
} = input;

// Extract and preserve sticky header for job isolation
const stickyHeaderPattern = /^(<!-- sticky-job: [^>]+ -->)\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*<img[^>]*>)?/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 = "";
Expand Down Expand Up @@ -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;
}
8 changes: 7 additions & 1 deletion src/github/operations/comments/common.ts
Original file line number Diff line number Diff line change
Expand Up @@ -21,11 +21,17 @@ export function createBranchLink(
return `\n[View branch](${branchUrl})`;
}

export function createStickyCommentHeader(jobId: string): string {
return `<!-- sticky-job: ${jobId} -->`;
}

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.

Expand Down
41 changes: 24 additions & 17 deletions src/github/operations/comments/create-initial.ts
Original file line number Diff line number Diff line change
Expand Up @@ -6,47 +6,54 @@
*/

import { appendFileSync } from "fs";
import { createJobRunLink, createCommentBody } from "./common";
import {
createJobRunLink,
createCommentBody,
createStickyCommentHeader,
} from "./common";
import {
isPullRequestReviewCommentEvent,
isPullRequestEvent,
type ParsedGitHubContext,
} 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,
Expand All @@ -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,
Expand Down
8 changes: 7 additions & 1 deletion src/github/operations/comments/update-with-branch.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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)
Expand All @@ -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 {
Expand Down
8 changes: 7 additions & 1 deletion src/github/utils/sanitizer.ts
Original file line number Diff line number Diff line change
Expand Up @@ -97,4 +97,10 @@ export function redactGitHubTokens(content: string): string {
}

export const stripHtmlComments = (content: string) =>
content.replace(/<!--[\s\S]*?-->/g, "");
content.replace(/<!--[\s\S]*?-->/g, (match) => {
// Preserve sticky job headers used for comment isolation
if (match.startsWith("<!-- sticky-job:")) {
return match;
}
return "";
});
6 changes: 5 additions & 1 deletion src/mcp/github-comment-server.ts
Original file line number Diff line number Diff line change
Expand Up @@ -11,6 +11,7 @@ import { sanitizeContent } from "../github/utils/sanitizer";
// Get repository information from environment variables
const REPO_OWNER = process.env.REPO_OWNER;
const REPO_NAME = process.env.REPO_NAME;
const JOB_ID = process.env.GITHUB_JOB_ID || process.env.GITHUB_JOB || "";

if (!REPO_OWNER || !REPO_NAME) {
console.error(
Expand Down Expand Up @@ -56,12 +57,15 @@ server.tool(
eventName === "pull_request_review_comment";

const sanitizedBody = sanitizeContent(body);
// Prepend sticky header if job ID is available (for sticky comment isolation)
const stickyHeader = JOB_ID ? `<!-- sticky-job: ${JOB_ID} -->\n` : "";
const finalBody = stickyHeader + sanitizedBody;

const result = await updateClaudeComment(octokit, {
owner,
repo,
commentId,
body: sanitizedBody,
body: finalBody,
isPullRequestReviewComment,
});

Expand Down
83 changes: 83 additions & 0 deletions test/comment-logic.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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:
"<!-- sticky-job: claude-docs-review -->\nClaude Code is working…",
triggerUsername: "testuser",
};

const result = updateCommentBody(input);
expect(result).toContain("<!-- sticky-job: claude-docs-review -->");
expect(result).toContain("**Claude finished @testuser's task**");
});

it("preserves sticky header with different job IDs", () => {
const input = {
...baseInput,
currentBody:
"<!-- sticky-job: claude-security-review -->\nClaude Code is working…\n\nI'll analyze this.",
triggerUsername: "testuser",
};

const result = updateCommentBody(input);
expect(result).toContain("<!-- sticky-job: claude-security-review -->");
expect(result).toContain("**Claude finished @testuser's task**");
});

it("sticky header appears at the start of the final comment", () => {
const input = {
...baseInput,
currentBody:
"<!-- sticky-job: my-job -->\nClaude Code is working…",
triggerUsername: "testuser",
};

const result = updateCommentBody(input);
expect(result.startsWith("<!-- sticky-job: my-job -->")).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("<!-- sticky-job:");
expect(result).toContain("**Claude finished @testuser's task**");
});

it("preserves sticky header on error", () => {
const input = {
...baseInput,
currentBody:
"<!-- sticky-job: claude-docs-review -->\nClaude Code is working…",
actionFailed: true,
executionDetails: { duration_ms: 30000 },
};

const result = updateCommentBody(input);
expect(result).toContain("<!-- sticky-job: claude-docs-review -->");
expect(result).toContain("**Claude encountered an error after 30s**");
});

it("preserves sticky header with branch and PR links", () => {
const input = {
...baseInput,
currentBody:
"<!-- sticky-job: claude-docs-review -->\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("<!-- sticky-job: claude-docs-review -->");
expect(result).toContain("`feature-branch`");
expect(result).toContain("Create PR ➔");
expect(result).toContain("### Todo List:");
});
});
});
36 changes: 35 additions & 1 deletion test/sanitizer.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -241,6 +241,20 @@ describe("sanitizeContent", () => {
expect(sanitized).not.toContain('title="');
expect(sanitized).toContain("<div>Test</div>");
});

it("should preserve sticky job headers while stripping other HTML comments", () => {
const content = `<!-- sticky-job: my-workflow-job -->
<!-- malicious hidden comment -->
Claude Code is working...
<img alt="hidden text" src="spinner.gif">`;

const sanitized = sanitizeContent(content);

expect(sanitized).toContain("<!-- sticky-job: my-workflow-job -->");
expect(sanitized).not.toContain("<!-- malicious hidden comment -->");
expect(sanitized).not.toContain("hidden text");
expect(sanitized).toContain("Claude Code is working...");
});
});

describe("redactGitHubTokens", () => {
Expand Down Expand Up @@ -346,7 +360,7 @@ describe("sanitizeContent with token redaction", () => {
});
});

describe("stripHtmlComments (legacy)", () => {
describe("stripHtmlComments", () => {
it("should remove HTML comments", () => {
expect(stripHtmlComments("Hello <!-- example -->World")).toBe(
"Hello World",
Expand All @@ -360,4 +374,24 @@ describe("stripHtmlComments (legacy)", () => {
"Hello World",
);
});

it("should preserve sticky job headers", () => {
expect(
stripHtmlComments("<!-- sticky-job: my-job-id -->Content"),
).toBe("<!-- sticky-job: my-job-id -->Content");
expect(
stripHtmlComments("<!-- sticky-job: claude-docs-review -->\nClaude Code is working"),
).toBe("<!-- sticky-job: claude-docs-review -->\nClaude Code is working");
});

it("should strip other comments but preserve sticky job headers", () => {
const content = "<!-- sticky-job: test-job --><!-- malicious comment -->Content";
expect(stripHtmlComments(content)).toBe("<!-- sticky-job: test-job -->Content");
});

it("should not preserve comments that look similar but are not sticky job headers", () => {
expect(stripHtmlComments("<!--sticky-job: no-space -->Content")).toBe("Content");
expect(stripHtmlComments("<!-- sticky-jobs: plural -->Content")).toBe("Content");
expect(stripHtmlComments("<!-- sticky-job-fake: fake -->Content")).toBe("Content");
});
});
Loading