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:
parent
c22f7c3f9d
commit
263b3b725a
@ -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 }}
|
||||||
|
|||||||
@ -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),
|
||||||
|
|||||||
@ -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) {
|
||||||
|
|||||||
@ -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.
|
||||||
|
|
||||||
|
|||||||
@ -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,
|
||||||
|
|||||||
@ -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 {
|
||||||
|
|||||||
@ -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 "";
|
||||||
|
});
|
||||||
|
|||||||
@ -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");
|
||||||
|
});
|
||||||
|
});
|
||||||
});
|
});
|
||||||
|
|||||||
@ -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),
|
||||||
|
|||||||
@ -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),
|
||||||
|
|||||||
@ -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",
|
||||||
|
|||||||
@ -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),
|
||||||
|
|||||||
@ -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");
|
||||||
|
});
|
||||||
});
|
});
|
||||||
|
|||||||
Loading…
x
Reference in New Issue
Block a user