Compare commits

...

4 Commits

Author SHA1 Message Date
Tosin Afolabi
cc19ae6bba fix: simplify matching to header-only, drop bot ID check
Legacy comments without headers will naturally age out. No need
for bot ID fallback logic that was broken anyway (wrong default ID).

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-02-13 10:17:12 -05:00
Tosin Afolabi
3128a645ab fix: don't require bot ID match when header is present
The bot_id default (41898282 = github-actions[bot]) doesn't match
claude[bot] (209825114) when using OIDC auth. This caused the sticky
comment matching to always fail, creating new comments each run.

Header-based matching is authoritative — if a comment has our specific
<!-- bot: {id} --> header, that's sufficient for identification. Bot ID
check is now only used for legacy comments without headers.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-02-13 10:15:19 -05:00
Tosin Afolabi
600162b09b refactor: align sticky comment isolation with PR #780 patterns
- Use `<!-- bot: {id} -->` header format instead of `<!-- sticky-job: -->`
- Add extractBotHeader() for cleaner header extraction
- Move header preservation to MCP server instead of modifying sanitizer
- Adopt regex-based matching with hasAnyHeader check and legacy fallback
- Use context.inputs.botId for dynamic bot ID matching
- Revert sanitizer.ts to keep security code untouched

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-02-13 09:54:52 -05:00
Tosin Afolabi
263b3b725a feat: isolate sticky comments by job ID to prevent workflow conflicts
When multiple workflows use `use_sticky_comment: true` on the same PR,
they previously co-opted each other's comments due to bot ID/name
matching. This adds a hidden `<!-- sticky-job: {jobId} -->` header
using `github.job` to isolate each workflow's sticky comment
automatically with no user configuration needed.

- Add sticky-job header to comments via createCommentBody()
- Match comments by job ID header instead of bot ID/name
- Preserve sticky-job headers through sanitizeContent() pipeline
- Preserve sticky-job headers through updateCommentBody() rebuilds
- Add pagination for comment search on busy PRs
- Fall back to bot ID/name matching when no job ID is available

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-02-13 08:23:15 -05:00
13 changed files with 158 additions and 17 deletions

View File

@ -199,6 +199,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 }}
DEFAULT_WORKFLOW_TOKEN: ${{ github.token }}
USE_COMMIT_SIGNING: ${{ inputs.use_commit_signing }}

View File

@ -90,6 +90,7 @@ type BaseContext = {
branchPrefix: string;
branchNameTemplate?: string;
useStickyComment: boolean;
jobId: string;
useCommitSigning: boolean;
sshSigningKey: string;
botId: string;
@ -150,6 +151,7 @@ export function parseGitHubContext(): GitHubContext {
branchPrefix: process.env.BRANCH_PREFIX ?? "claude/",
branchNameTemplate: process.env.BRANCH_NAME_TEMPLATE,
useStickyComment: process.env.USE_STICKY_COMMENT === "true",
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),

View File

@ -1,4 +1,5 @@
import { GITHUB_SERVER_URL } from "../api/config";
import { extractBotHeader } from "./comments/common";
export type ExecutionDetails = {
total_cost_usd?: number;
@ -79,11 +80,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 = "";
@ -199,5 +208,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();
}

View File

@ -24,10 +24,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*-->\n?)/);
return match?.[1] ?? "";
}

View File

@ -14,16 +14,21 @@ import {
} 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;
@ -33,20 +38,27 @@ export async function createInitialComment(
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;
// 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*-->`,
"i",
);
return headerPattern.test(comment.body || "");
});
if (existingComment) {
response = await octokit.rest.issues.updateComment({
owner,

View File

@ -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 {

View File

@ -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 { sanitizeContent } from "../github/utils/sanitizer";
@ -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 = sanitizeContent(body);
const bodyWithHeader = botHeader + sanitizedBody;
const result = await updateClaudeComment(octokit, {
owner,
repo,
commentId,
body: sanitizedBody,
body: bodyWithHeader,
isPullRequestReviewComment,
});

View File

@ -443,4 +443,79 @@ 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 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");
});
});
});

View File

@ -32,6 +32,7 @@ describe("prepareMcpConfig", () => {
labelTrigger: "",
branchPrefix: "",
useStickyComment: false,
jobId: "",
useCommitSigning: false,
sshSigningKey: "",
botId: String(CLAUDE_APP_BOT_ID),

View File

@ -19,6 +19,7 @@ const defaultInputs = {
labelTrigger: "",
branchPrefix: "claude/",
useStickyComment: false,
jobId: "",
useCommitSigning: false,
sshSigningKey: "",
botId: String(CLAUDE_APP_BOT_ID),

View File

@ -19,6 +19,7 @@ describe("detectMode with enhanced routing", () => {
labelTrigger: "",
branchPrefix: "claude/",
useStickyComment: false,
jobId: "",
useCommitSigning: false,
sshSigningKey: "",
botId: "123456",

View File

@ -67,6 +67,7 @@ describe("checkWritePermissions", () => {
labelTrigger: "",
branchPrefix: "claude/",
useStickyComment: false,
jobId: "",
useCommitSigning: false,
sshSigningKey: "",
botId: String(CLAUDE_APP_BOT_ID),

View File

@ -360,4 +360,8 @@ describe("stripHtmlComments (legacy)", () => {
"Hello World",
);
});
it("should strip all HTML comments including bot headers", () => {
expect(stripHtmlComments("<!-- bot: claude-review -->Text")).toBe("Text");
});
});