diff --git a/action.yml b/action.yml index 5249664..915d9e0 100644 --- a/action.yml +++ b/action.yml @@ -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 }} diff --git a/src/github/context.ts b/src/github/context.ts index 936e70e..003fd40 100644 --- a/src/github/context.ts +++ b/src/github/context.ts @@ -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), diff --git a/src/github/operations/comment-logic.ts b/src/github/operations/comment-logic.ts index 03b5d86..b4f5cbf 100644 --- a/src/github/operations/comment-logic.ts +++ b/src/github/operations/comment-logic.ts @@ -79,11 +79,22 @@ export function updateCommentBody(input: CommentUpdateInput): string { errorDetails, } = input; + // Preserve sticky-job header if present + const stickyHeaderMatch = originalBody.match( + /^()\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*]*>)?/i; let bodyContent = originalBody.replace(workingPattern, "").trim(); + // Remove sticky-job header from body content (it's re-prepended separately) + bodyContent = bodyContent + .replace(/^\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) { diff --git a/src/github/operations/comments/common.ts b/src/github/operations/comments/common.ts index df24c03..5b9d39c 100644 --- a/src/github/operations/comments/common.ts +++ b/src/github/operations/comments/common.ts @@ -21,11 +21,21 @@ export function createBranchLink( return `\n[View branch](${branchUrl})`; } +export function createStickyJobHeader(jobId: string): string { + return ``; +} + +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. diff --git a/src/github/operations/comments/create-initial.ts b/src/github/operations/comments/create-initial.ts index 1243035..67efdca 100644 --- a/src/github/operations/comments/create-initial.ts +++ b/src/github/operations/comments/create-initial.ts @@ -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, diff --git a/src/github/operations/comments/update-with-branch.ts b/src/github/operations/comments/update-with-branch.ts index 838b154..0df40c9 100644 --- a/src/github/operations/comments/update-with-branch.ts +++ b/src/github/operations/comments/update-with-branch.ts @@ -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 { diff --git a/src/github/utils/sanitizer.ts b/src/github/utils/sanitizer.ts index 83ee096..fd5d8a6 100644 --- a/src/github/utils/sanitizer.ts +++ b/src/github/utils/sanitizer.ts @@ -97,4 +97,9 @@ export function redactGitHubTokens(content: string): string { } export const stripHtmlComments = (content: string) => - content.replace(//g, ""); + content.replace(//g, (match) => { + if (match.startsWith("\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("\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"); + }); + }); }); diff --git a/test/install-mcp-server.test.ts b/test/install-mcp-server.test.ts index 152d2be..6e666d4 100644 --- a/test/install-mcp-server.test.ts +++ b/test/install-mcp-server.test.ts @@ -32,6 +32,7 @@ describe("prepareMcpConfig", () => { labelTrigger: "", branchPrefix: "", useStickyComment: false, + jobId: "", useCommitSigning: false, sshSigningKey: "", botId: String(CLAUDE_APP_BOT_ID), diff --git a/test/mockContext.ts b/test/mockContext.ts index 19ab044..a669ddc 100644 --- a/test/mockContext.ts +++ b/test/mockContext.ts @@ -19,6 +19,7 @@ const defaultInputs = { labelTrigger: "", branchPrefix: "claude/", useStickyComment: false, + jobId: "", useCommitSigning: false, sshSigningKey: "", botId: String(CLAUDE_APP_BOT_ID), diff --git a/test/modes/detector.test.ts b/test/modes/detector.test.ts index c8a6c75..c504048 100644 --- a/test/modes/detector.test.ts +++ b/test/modes/detector.test.ts @@ -19,6 +19,7 @@ describe("detectMode with enhanced routing", () => { labelTrigger: "", branchPrefix: "claude/", useStickyComment: false, + jobId: "", useCommitSigning: false, sshSigningKey: "", botId: "123456", diff --git a/test/permissions.test.ts b/test/permissions.test.ts index cf2efdb..1bfb92d 100644 --- a/test/permissions.test.ts +++ b/test/permissions.test.ts @@ -67,6 +67,7 @@ describe("checkWritePermissions", () => { labelTrigger: "", branchPrefix: "claude/", useStickyComment: false, + jobId: "", useCommitSigning: false, sshSigningKey: "", botId: String(CLAUDE_APP_BOT_ID), diff --git a/test/sanitizer.test.ts b/test/sanitizer.test.ts index a89353b..b97c374 100644 --- a/test/sanitizer.test.ts +++ b/test/sanitizer.test.ts @@ -360,4 +360,27 @@ describe("stripHtmlComments (legacy)", () => { "Hello World", ); }); + + it("should preserve sticky-job headers", () => { + expect(stripHtmlComments("Text")).toBe( + "Text", + ); + }); + + it("should preserve sticky-job headers while stripping other comments", () => { + const input = + "\nHello World"; + expect(stripHtmlComments(input)).toBe( + "\nHello World", + ); + }); +}); + +describe("sanitizeContent with sticky-job headers", () => { + it("should preserve sticky-job headers through full sanitization", () => { + const content = "\nSome response text"; + const sanitized = sanitizeContent(content); + expect(sanitized).toContain(""); + expect(sanitized).toContain("Some response text"); + }); });