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>
This commit is contained in:
Tosin Afolabi 2026-02-13 08:23:15 -05:00
parent c22f7c3f9d
commit 263b3b725a
13 changed files with 121 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

@ -79,11 +79,22 @@ export function updateCommentBody(input: CommentUpdateInput): string {
errorDetails,
} = input;
// Preserve sticky-job header if present
const stickyHeaderMatch = originalBody.match(
/^(<!-- sticky-job: [^\n]+ -->)\n?/,
);
const stickyHeader = stickyHeaderMatch ? stickyHeaderMatch[1] + "\n" : "";
// 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 sticky-job header from body content (it's re-prepended separately)
bodyContent = bodyContent
.replace(/^<!-- sticky-job: [^\n]+ -->\n?/, "")
.trim();
// Check if there's a PR link in the content
let prLinkFromContent = "";
@ -179,7 +190,7 @@ export function updateCommentBody(input: CommentUpdateInput): string {
}
// Build the new body with blank line between header and separator
let newBody = `${header}${links}`;
let newBody = `${stickyHeader}${header}${links}`;
// Add error details if available
if (actionFailed && errorDetails) {

View File

@ -21,11 +21,21 @@ export function createBranchLink(
return `\n[View branch](${branchUrl})`;
}
export function createStickyJobHeader(jobId: string): string {
return `<!-- sticky-job: ${jobId} -->`;
}
export function hasStickyJobHeader(body: string, jobId: string): boolean {
return body.includes(createStickyJobHeader(jobId));
}
export function createCommentBody(
jobRunLink: string,
branchLink: string = "",
jobId: string = "",
): string {
return `Claude Code is working… ${SPINNER_HTML}
const header = jobId ? `${createStickyJobHeader(jobId)}\n` : "";
return `${header}Claude Code is working… ${SPINNER_HTML}
I'll analyze this and get back to you.

View File

@ -6,24 +6,28 @@
*/
import { appendFileSync } from "fs";
import { createJobRunLink, createCommentBody } from "./common";
import {
createJobRunLink,
createCommentBody,
hasStickyJobHeader,
} from "./common";
import {
isPullRequestReviewCommentEvent,
isPullRequestEvent,
type ParsedGitHubContext,
} from "../../context";
import { CLAUDE_APP_BOT_ID } from "../../constants";
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 jobId = context.inputs.jobId;
const jobRunLink = createJobRunLink(owner, repo, context.runId);
const initialBody = createCommentBody(jobRunLink);
const initialBody = createCommentBody(jobRunLink, "", jobId);
try {
let response;
@ -33,20 +37,28 @@ 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 comments = await octokit.paginate(
octokit.rest.issues.listComments,
{
owner,
repo,
issue_number: context.entityNumber,
per_page: 100,
},
);
const existingComment = comments.find((comment) => {
if (jobId) {
return hasStickyJobHeader(comment.body ?? "", jobId);
}
// Fallback for backward compat when no jobId is available
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;
return idMatch || botNameMatch;
});
if (existingComment) {
response = await octokit.rest.issues.updateComment({
owner,

View File

@ -33,7 +33,11 @@ export async function updateTrackingComment(
branchLink = createBranchLink(owner, repo, branch);
}
const updatedBody = createCommentBody(jobRunLink, branchLink);
const updatedBody = createCommentBody(
jobRunLink,
branchLink,
context.inputs.jobId,
);
// Update the existing comment with the branch link
try {

View File

@ -97,4 +97,9 @@ export function redactGitHubTokens(content: string): string {
}
export const stripHtmlComments = (content: string) =>
content.replace(/<!--[\s\S]*?-->/g, "");
content.replace(/<!--[\s\S]*?-->/g, (match) => {
if (match.startsWith("<!-- sticky-job:")) {
return match;
}
return "";
});

View File

@ -443,4 +443,36 @@ describe("updateCommentBody", () => {
expect(result).not.toContain("tree/claude/issue-123");
});
});
describe("sticky-job header preservation", () => {
it("should preserve sticky-job header through comment update", () => {
const input: CommentUpdateInput = {
currentBody:
"<!-- sticky-job: 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("<!-- sticky-job: claude-review -->\n");
expect(result).toContain("Claude finished @test-user's task");
});
it("should work without sticky-job 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("sticky-job");
expect(result).toContain("Claude finished @test-user's task");
});
});
});

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,27 @@ describe("stripHtmlComments (legacy)", () => {
"Hello World",
);
});
it("should preserve sticky-job headers", () => {
expect(stripHtmlComments("<!-- sticky-job: claude-review -->Text")).toBe(
"<!-- sticky-job: claude-review -->Text",
);
});
it("should preserve sticky-job headers while stripping other comments", () => {
const input =
"<!-- sticky-job: my-job -->\n<!-- hidden prompt -->Hello World";
expect(stripHtmlComments(input)).toBe(
"<!-- sticky-job: my-job -->\nHello World",
);
});
});
describe("sanitizeContent with sticky-job headers", () => {
it("should preserve sticky-job headers through full sanitization", () => {
const content = "<!-- sticky-job: claude-review -->\nSome response text";
const sanitized = sanitizeContent(content);
expect(sanitized).toContain("<!-- sticky-job: claude-review -->");
expect(sanitized).toContain("Some response text");
});
});