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 @@ -303,6 +303,7 @@ runs:
INCLUDE_COMMENTS_BY_ACTOR: ${{ inputs.include_comments_by_actor }}
EXCLUDE_COMMENTS_BY_ACTOR: ${{ inputs.exclude_comments_by_actor }}
GITHUB_RUN_ID: ${{ github.run_id }}
GITHUB_JOB: ${{ github.job }}
USE_STICKY_COMMENT: ${{ inputs.use_sticky_comment }}
CLASSIFY_INLINE_COMMENTS: ${{ inputs.classify_inline_comments }}
DEFAULT_WORKFLOW_TOKEN: ${{ github.token }}
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 = {
branchNameTemplate?: string;
useStickyComment: boolean;
classifyInlineComments: boolean;
jobId: string;
useCommitSigning: boolean;
sshSigningKey: string;
botId: string;
Expand Down Expand Up @@ -154,6 +155,7 @@ export function parseGitHubContext(): GitHubContext {
branchNameTemplate: process.env.BRANCH_NAME_TEMPLATE,
useStickyComment: process.env.USE_STICKY_COMMENT === "true",
classifyInlineComments: process.env.CLASSIFY_INLINE_COMMENTS !== "false",
jobId: process.env.GITHUB_JOB || "",
useCommitSigning: process.env.USE_COMMIT_SIGNING === "true",
sshSigningKey: process.env.SSH_SIGNING_KEY || "",
botId: process.env.BOT_ID ?? String(CLAUDE_APP_BOT_ID),
Expand Down
15 changes: 14 additions & 1 deletion src/github/operations/comment-logic.ts
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
import { GITHUB_SERVER_URL } from "../api/config";
import { redactSecrets } from "../utils/sanitizer";
import { encodeBranchNameForUrl } from "./comments/common";
import { encodeBranchNameForUrl, extractBotHeader } from "./comments/common";

export type ExecutionDetails = {
total_cost_usd?: number;
Expand Down Expand Up @@ -81,11 +81,19 @@ export function updateCommentBody(input: CommentUpdateInput): string {
errorDetails,
} = input;

// Extract and preserve bot header for sticky comment identification
const botHeader = extractBotHeader(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();

// Remove bot header from body content since we'll prepend it at the end
if (botHeader) {
bodyContent = bodyContent.replace(/^<!--\s*bot:\s*\S+\s*-->\n?/, "").trim();
}

// Check if there's a PR link in the content
let prLinkFromContent = "";

Expand Down Expand Up @@ -203,5 +211,10 @@ export function updateCommentBody(input: CommentUpdateInput): string {
// Add the cleaned body content
newBody += bodyContent;

// Prepend bot header if it existed in the original comment
if (botHeader) {
return (botHeader + newBody).trim();
}

return newBody.trim();
}
9 changes: 8 additions & 1 deletion src/github/operations/comments/common.ts
Original file line number Diff line number Diff line change
Expand Up @@ -29,10 +29,17 @@ export function createBranchLink(
export function createCommentBody(
jobRunLink: string,
branchLink: string = "",
botName: string = "",
): string {
return `Claude Code is working… ${SPINNER_HTML}
const header = botName ? `<!-- bot: ${botName} -->\n` : "";
return `${header}Claude Code is working… ${SPINNER_HTML}

I'll analyze this and get back to you.

${jobRunLink}${branchLink}`;
}

export function extractBotHeader(body: string): string {
const match = body.match(/^<!--\s*bot:\s*\S+\s*-->/);
return match ? `${match[0]}\n` : "";
}
46 changes: 26 additions & 20 deletions src/github/operations/comments/create-initial.ts
Original file line number Diff line number Diff line change
Expand Up @@ -9,44 +9,50 @@ import { appendFileSync } from "fs";
import { createJobRunLink, createCommentBody } from "./common";
import {
isPullRequestReviewCommentEvent,
isPullRequestEvent,
type ParsedGitHubContext,
} from "../../context";
import type { Octokit } from "@octokit/rest";

const CLAUDE_APP_BOT_ID = 209825114;
function escapeRegex(str: string): string {
return str.replace(/[.*+?^${}()|[\]\\]/g, "\\$&");
}

export async function createInitialComment(
octokit: Octokit,
context: ParsedGitHubContext,
) {
const { owner, repo } = context.repository;
const botIdentifier = context.inputs.useStickyComment
? context.inputs.jobId
: "";

const jobRunLink = createJobRunLink(owner, repo, context.runId);
const initialBody = createCommentBody(jobRunLink);
const initialBody = createCommentBody(jobRunLink, "", botIdentifier);

try {
let response;

if (
context.inputs.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;
if (context.inputs.useStickyComment && context.isPR) {
// Use pagination to fetch all comments (handles PRs with 30+ comments)
const comments = await octokit.paginate(
octokit.rest.issues.listComments,
{
owner,
repo,
issue_number: context.entityNumber,
per_page: 100,
},
);

return idMatch || botNameMatch || bodyMatch;
const existingComment = comments.find((comment) => {
if (!botIdentifier) return false;

const headerPattern = new RegExp(
`<!--\\s*bot:\\s*${escapeRegex(botIdentifier)}\\s*-->`,
);
return headerPattern.test(comment.body || "");
});

if (existingComment) {
response = await octokit.rest.issues.updateComment({
owner,
Expand Down
5 changes: 4 additions & 1 deletion src/github/operations/comments/update-with-branch.ts
Original file line number Diff line number Diff line change
Expand Up @@ -33,7 +33,10 @@ export async function updateTrackingComment(
branchLink = createBranchLink(owner, repo, branch);
}

const updatedBody = createCommentBody(jobRunLink, branchLink);
const botIdentifier = context.inputs.useStickyComment
? context.inputs.jobId
: "";
const updatedBody = createCommentBody(jobRunLink, branchLink, botIdentifier);

// Update the existing comment with the branch link
try {
Expand Down
21 changes: 20 additions & 1 deletion src/mcp/github-comment-server.ts
Original file line number Diff line number Diff line change
Expand Up @@ -5,6 +5,7 @@ import { StdioServerTransport } from "@modelcontextprotocol/sdk/server/stdio.js"
import { z } from "zod";
import { GITHUB_API_URL } from "../github/api/config";
import { Octokit } from "@octokit/rest";
import { extractBotHeader } from "../github/operations/comments/common";
import { updateClaudeComment } from "../github/operations/comments/update-claude-comment";
import { redactSecrets, sanitizeContent } from "../github/utils/sanitizer";

Expand Down Expand Up @@ -55,13 +56,31 @@ server.tool(
const isPullRequestReviewComment =
eventName === "pull_request_review_comment";

// Fetch current comment to preserve the bot header for sticky comment functionality
const currentComment = isPullRequestReviewComment
? await octokit.rest.pulls.getReviewComment({
owner,
repo,
comment_id: commentId,
})
: await octokit.rest.issues.getComment({
owner,
repo,
comment_id: commentId,
});

// Extract bot header if present (e.g., "<!-- bot: claude-code-review -->")
const botHeader = extractBotHeader(currentComment.data.body || "");

// Sanitize then prepend header to preserve sticky comment identification
const sanitizedBody = redactSecrets(sanitizeContent(body));
const bodyWithHeader = botHeader + sanitizedBody;

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

Expand Down
90 changes: 90 additions & 0 deletions test/comment-logic.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -443,4 +443,94 @@ describe("updateCommentBody", () => {
expect(result).not.toContain("tree/claude/issue-123");
});
});

describe("bot header preservation", () => {
it("should preserve bot header through comment update", () => {
const input: CommentUpdateInput = {
currentBody:
"<!-- bot: claude-review -->\nClaude Code is working…\n\nI'll analyze this and get back to you.\n\n[View job run](https://github.com/owner/repo/actions/runs/123)",
actionFailed: false,
executionDetails: { duration_ms: 30000 },
jobUrl: "https://github.com/owner/repo/actions/runs/123",
triggerUsername: "test-user",
};

const result = updateCommentBody(input);
expect(result).toStartWith("<!-- bot: claude-review -->\n");
expect(result).toContain("Claude finished @test-user's task");
});

it("should preserve bot header with different bot names", () => {
const input: CommentUpdateInput = {
currentBody:
"<!-- bot: claude-docs-review -->\nClaude Code is working...",
actionFailed: false,
executionDetails: { duration_ms: 30000 },
jobUrl: "https://github.com/owner/repo/actions/runs/123",
triggerUsername: "user",
};

const result = updateCommentBody(input);
expect(result).toStartWith("<!-- bot: claude-docs-review -->");
});

it("should work without bot header", () => {
const input: CommentUpdateInput = {
currentBody:
"Claude Code is working…\n\nI'll analyze this and get back to you.",
actionFailed: false,
executionDetails: { duration_ms: 30000 },
jobUrl: "https://github.com/owner/repo/actions/runs/123",
triggerUsername: "test-user",
};

const result = updateCommentBody(input);
expect(result).not.toContain("<!-- bot:");
expect(result).toContain("Claude finished @test-user's task");
});

it("should preserve bot header when action fails", () => {
const input: CommentUpdateInput = {
currentBody: "<!-- bot: claude-review -->\nClaude Code is working…",
actionFailed: true,
executionDetails: { duration_ms: 10000 },
jobUrl: "https://github.com/owner/repo/actions/runs/123",
errorDetails: "Something went wrong",
};

const result = updateCommentBody(input);
expect(result).toStartWith("<!-- bot: claude-review -->");
expect(result).toContain("**Claude encountered an error after 10s**");
});

it("should add a line break after bot header even when original lacks one", () => {
const input: CommentUpdateInput = {
currentBody:
"<!-- bot: claude-review -->Claude Code is working…\n\n[View job run](https://github.com/owner/repo/actions/runs/123)",
actionFailed: false,
executionDetails: { duration_ms: 5000 },
jobUrl: "https://github.com/owner/repo/actions/runs/123",
triggerUsername: "test-user",
};

const result = updateCommentBody(input);
expect(result).toStartWith("<!-- bot: claude-review -->\n");
expect(result).not.toContain("<!-- bot: claude-review -->**");
});

it("should not preserve bot header if not at start of comment", () => {
const input: CommentUpdateInput = {
currentBody:
"Some text before\n<!-- bot: claude-review -->\nClaude Code is working…",
actionFailed: false,
executionDetails: { duration_ms: 5000 },
jobUrl: "https://github.com/owner/repo/actions/runs/123",
triggerUsername: "testuser",
};

const result = updateCommentBody(input);
expect(result.startsWith("<!-- bot:")).toBe(false);
expect(result).toContain("**Claude finished");
});
});
});
1 change: 1 addition & 0 deletions test/install-mcp-server.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -33,6 +33,7 @@ describe("prepareMcpConfig", () => {
branchPrefix: "",
useStickyComment: false,
classifyInlineComments: true,
jobId: "",
useCommitSigning: false,
sshSigningKey: "",
botId: String(CLAUDE_APP_BOT_ID),
Expand Down
1 change: 1 addition & 0 deletions test/mockContext.ts
Original file line number Diff line number Diff line change
Expand Up @@ -20,6 +20,7 @@ const defaultInputs = {
branchPrefix: "claude/",
useStickyComment: false,
classifyInlineComments: true,
jobId: "",
useCommitSigning: false,
sshSigningKey: "",
botId: String(CLAUDE_APP_BOT_ID),
Expand Down
1 change: 1 addition & 0 deletions test/modes/detector.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -20,6 +20,7 @@ describe("detectMode with enhanced routing", () => {
branchPrefix: "claude/",
useStickyComment: false,
classifyInlineComments: true,
jobId: "",
useCommitSigning: false,
sshSigningKey: "",
botId: "123456",
Expand Down
1 change: 1 addition & 0 deletions test/permissions.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -69,6 +69,7 @@ describe("checkWritePermissions", () => {
branchPrefix: "claude/",
useStickyComment: false,
classifyInlineComments: true,
jobId: "",
useCommitSigning: false,
sshSigningKey: "",
botId: String(CLAUDE_APP_BOT_ID),
Expand Down
4 changes: 4 additions & 0 deletions test/sanitizer.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -517,6 +517,10 @@ describe("stripHtmlComments (legacy)", () => {
"Hello World",
);
});

it("should strip all HTML comments including bot headers", () => {
expect(stripHtmlComments("<!-- bot: claude-review -->Text")).toBe("Text");
});
});

describe("outbound comment sanitization and redaction", () => {
Expand Down