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 }} INCLUDE_COMMENTS_BY_ACTOR: ${{ inputs.include_comments_by_actor }}
EXCLUDE_COMMENTS_BY_ACTOR: ${{ inputs.exclude_comments_by_actor }} EXCLUDE_COMMENTS_BY_ACTOR: ${{ inputs.exclude_comments_by_actor }}
GITHUB_RUN_ID: ${{ github.run_id }} GITHUB_RUN_ID: ${{ github.run_id }}
GITHUB_JOB: ${{ github.job }}
USE_STICKY_COMMENT: ${{ inputs.use_sticky_comment }} USE_STICKY_COMMENT: ${{ inputs.use_sticky_comment }}
DEFAULT_WORKFLOW_TOKEN: ${{ github.token }} DEFAULT_WORKFLOW_TOKEN: ${{ github.token }}
USE_COMMIT_SIGNING: ${{ inputs.use_commit_signing }} USE_COMMIT_SIGNING: ${{ inputs.use_commit_signing }}

View File

@ -90,6 +90,7 @@ type BaseContext = {
branchPrefix: string; branchPrefix: string;
branchNameTemplate?: string; branchNameTemplate?: string;
useStickyComment: boolean; useStickyComment: boolean;
jobId: string;
useCommitSigning: boolean; useCommitSigning: boolean;
sshSigningKey: string; sshSigningKey: string;
botId: string; botId: string;
@ -150,6 +151,7 @@ export function parseGitHubContext(): GitHubContext {
branchPrefix: process.env.BRANCH_PREFIX ?? "claude/", branchPrefix: process.env.BRANCH_PREFIX ?? "claude/",
branchNameTemplate: process.env.BRANCH_NAME_TEMPLATE, branchNameTemplate: process.env.BRANCH_NAME_TEMPLATE,
useStickyComment: process.env.USE_STICKY_COMMENT === "true", useStickyComment: process.env.USE_STICKY_COMMENT === "true",
jobId: process.env.GITHUB_JOB || "",
useCommitSigning: process.env.USE_COMMIT_SIGNING === "true", useCommitSigning: process.env.USE_COMMIT_SIGNING === "true",
sshSigningKey: process.env.SSH_SIGNING_KEY || "", sshSigningKey: process.env.SSH_SIGNING_KEY || "",
botId: process.env.BOT_ID ?? String(CLAUDE_APP_BOT_ID), botId: process.env.BOT_ID ?? String(CLAUDE_APP_BOT_ID),

View File

@ -79,11 +79,22 @@ export function updateCommentBody(input: CommentUpdateInput): string {
errorDetails, errorDetails,
} = input; } = 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 // Extract content from the original comment body
// First, remove the "Claude Code is working…" or "Claude Code is working..." message // First, remove the "Claude Code is working…" or "Claude Code is working..." message
const workingPattern = /Claude Code is working[…\.]{1,3}(?:\s*<img[^>]*>)?/i; const workingPattern = /Claude Code is working[…\.]{1,3}(?:\s*<img[^>]*>)?/i;
let bodyContent = originalBody.replace(workingPattern, "").trim(); 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 // Check if there's a PR link in the content
let prLinkFromContent = ""; let prLinkFromContent = "";
@ -179,7 +190,7 @@ export function updateCommentBody(input: CommentUpdateInput): string {
} }
// Build the new body with blank line between header and separator // 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 // Add error details if available
if (actionFailed && errorDetails) { if (actionFailed && errorDetails) {

View File

@ -21,11 +21,21 @@ export function createBranchLink(
return `\n[View branch](${branchUrl})`; 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( export function createCommentBody(
jobRunLink: string, jobRunLink: string,
branchLink: string = "", branchLink: string = "",
jobId: string = "",
): 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. I'll analyze this and get back to you.

View File

@ -6,24 +6,28 @@
*/ */
import { appendFileSync } from "fs"; import { appendFileSync } from "fs";
import { createJobRunLink, createCommentBody } from "./common"; import {
createJobRunLink,
createCommentBody,
hasStickyJobHeader,
} from "./common";
import { import {
isPullRequestReviewCommentEvent, isPullRequestReviewCommentEvent,
isPullRequestEvent, isPullRequestEvent,
type ParsedGitHubContext, type ParsedGitHubContext,
} from "../../context"; } from "../../context";
import { CLAUDE_APP_BOT_ID } from "../../constants";
import type { Octokit } from "@octokit/rest"; import type { Octokit } from "@octokit/rest";
const CLAUDE_APP_BOT_ID = 209825114;
export async function createInitialComment( export async function createInitialComment(
octokit: Octokit, octokit: Octokit,
context: ParsedGitHubContext, context: ParsedGitHubContext,
) { ) {
const { owner, repo } = context.repository; const { owner, repo } = context.repository;
const jobId = context.inputs.jobId;
const jobRunLink = createJobRunLink(owner, repo, context.runId); const jobRunLink = createJobRunLink(owner, repo, context.runId);
const initialBody = createCommentBody(jobRunLink); const initialBody = createCommentBody(jobRunLink, "", jobId);
try { try {
let response; let response;
@ -33,20 +37,28 @@ export async function createInitialComment(
context.isPR && context.isPR &&
isPullRequestEvent(context) isPullRequestEvent(context)
) { ) {
const comments = await octokit.rest.issues.listComments({ const comments = await octokit.paginate(
owner, octokit.rest.issues.listComments,
repo, {
issue_number: context.entityNumber, owner,
}); repo,
const existingComment = comments.data.find((comment) => { 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 idMatch = comment.user?.id === CLAUDE_APP_BOT_ID;
const botNameMatch = const botNameMatch =
comment.user?.type === "Bot" && comment.user?.type === "Bot" &&
comment.user?.login.toLowerCase().includes("claude"); comment.user?.login.toLowerCase().includes("claude");
const bodyMatch = comment.body === initialBody; return idMatch || botNameMatch;
return idMatch || botNameMatch || bodyMatch;
}); });
if (existingComment) { if (existingComment) {
response = await octokit.rest.issues.updateComment({ response = await octokit.rest.issues.updateComment({
owner, owner,

View File

@ -33,7 +33,11 @@ export async function updateTrackingComment(
branchLink = createBranchLink(owner, repo, branch); 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 // Update the existing comment with the branch link
try { try {

View File

@ -97,4 +97,9 @@ export function redactGitHubTokens(content: string): string {
} }
export const stripHtmlComments = (content: 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"); 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: "", labelTrigger: "",
branchPrefix: "", branchPrefix: "",
useStickyComment: false, useStickyComment: false,
jobId: "",
useCommitSigning: false, useCommitSigning: false,
sshSigningKey: "", sshSigningKey: "",
botId: String(CLAUDE_APP_BOT_ID), botId: String(CLAUDE_APP_BOT_ID),

View File

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

View File

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

View File

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

View File

@ -360,4 +360,27 @@ describe("stripHtmlComments (legacy)", () => {
"Hello World", "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");
});
}); });