diff --git a/README.md b/README.md index 87e0d93..8eabbef 100644 --- a/README.md +++ b/README.md @@ -85,6 +85,7 @@ jobs: | `base_branch` | The base branch to use for creating new branches (e.g., 'main', 'develop') | No | - | | `max_turns` | Maximum number of conversation turns Claude can take (limits back-and-forth exchanges) | No | - | | `timeout_minutes` | Timeout in minutes for execution | No | `30` | +| `use_sticky_comment` | Use just one comment to deliver PR comments (only applies for pull_request event workflows) | No | `false` | | `github_token` | GitHub token for Claude to operate with. **Only include this if you're connecting a custom GitHub app of your own!** | No | - | | `model` | Model to use (provider-specific format required for Bedrock/Vertex) | No | - | | `anthropic_model` | **DEPRECATED**: Use `model` instead. Kept for backward compatibility. | No | - | diff --git a/action.yml b/action.yml index d7b0092..50cfc88 100644 --- a/action.yml +++ b/action.yml @@ -78,6 +78,10 @@ inputs: description: "Timeout in minutes for execution" required: false default: "30" + use_sticky_comment: + description: "Use just one comment to deliver issue/PR comments" + required: false + default: "false" outputs: execution_file: @@ -116,6 +120,7 @@ runs: MCP_CONFIG: ${{ inputs.mcp_config }} OVERRIDE_GITHUB_TOKEN: ${{ inputs.github_token }} GITHUB_RUN_ID: ${{ github.run_id }} + USE_STICKY_COMMENT: ${{ inputs.use_sticky_comment }} - name: Run Claude Code id: claude-code @@ -180,6 +185,7 @@ runs: TRIGGER_USERNAME: ${{ github.event.comment.user.login || github.event.issue.user.login || github.event.pull_request.user.login || github.event.sender.login || github.triggering_actor || github.actor || '' }} PREPARE_SUCCESS: ${{ steps.prepare.outcome == 'success' }} PREPARE_ERROR: ${{ steps.prepare.outputs.prepare_error || '' }} + USE_STICKY_COMMENT: ${{ inputs.use_sticky_comment }} - name: Display Claude Code Report if: steps.prepare.outputs.contains_trigger == 'true' && steps.claude-code.outputs.execution_file != '' diff --git a/src/github/context.ts b/src/github/context.ts index c81ef49..e45f019 100644 --- a/src/github/context.ts +++ b/src/github/context.ts @@ -36,6 +36,7 @@ export type ParsedGitHubContext = { directPrompt: string; baseBranch?: string; branchPrefix: string; + useStickyComment: boolean; }; }; @@ -62,6 +63,7 @@ export function parseGitHubContext(): ParsedGitHubContext { directPrompt: process.env.DIRECT_PROMPT ?? "", baseBranch: process.env.BASE_BRANCH, branchPrefix: process.env.BRANCH_PREFIX ?? "claude/", + useStickyComment: process.env.STICKY_COMMENT === "true", }, }; diff --git a/src/github/operations/comments/create-initial.ts b/src/github/operations/comments/create-initial.ts index c4c0449..3d6d896 100644 --- a/src/github/operations/comments/create-initial.ts +++ b/src/github/operations/comments/create-initial.ts @@ -9,6 +9,7 @@ import { appendFileSync } from "fs"; import { createJobRunLink, createCommentBody } from "./common"; import { isPullRequestReviewCommentEvent, + isPullRequestEvent, type ParsedGitHubContext, } from "../../context"; import type { Octokit } from "@octokit/rest"; @@ -25,8 +26,39 @@ export async function createInitialComment( try { let response; - // Only use createReplyForReviewComment if it's a PR review comment AND we have a comment_id - if (isPullRequestReviewCommentEvent(context)) { + if ( + context.inputs.useStickyComment && + context.isPR && + !isPullRequestEvent(context) + ) { + const comments = await octokit.rest.issues.listComments({ + owner, + repo, + issue_number: context.entityNumber, + }); + const existingComment = comments.data.find( + (comment) => + comment.user?.login.indexOf("claude[bot]") !== -1 || + comment.body === initialBody, + ); + if (existingComment) { + response = await octokit.rest.issues.updateComment({ + owner, + repo, + comment_id: existingComment.id, + body: initialBody, + }); + } else { + // Create new comment if no existing one found + response = await octokit.rest.issues.createComment({ + owner, + repo, + issue_number: context.entityNumber, + body: initialBody, + }); + } + } else if (isPullRequestReviewCommentEvent(context)) { + // Only use createReplyForReviewComment if it's a PR review comment AND we have a comment_id response = await octokit.rest.pulls.createReplyForReviewComment({ owner, repo, diff --git a/test/mockContext.ts b/test/mockContext.ts index 8afaba3..a60a80a 100644 --- a/test/mockContext.ts +++ b/test/mockContext.ts @@ -20,6 +20,7 @@ const defaultInputs = { useVertex: false, timeoutMinutes: 30, branchPrefix: "claude/", + useStickyComment: false, }; const defaultRepository = { diff --git a/test/permissions.test.ts b/test/permissions.test.ts index 7a7a0c7..9343e98 100644 --- a/test/permissions.test.ts +++ b/test/permissions.test.ts @@ -68,6 +68,7 @@ describe("checkWritePermissions", () => { customInstructions: "", directPrompt: "", branchPrefix: "claude/", + useStickyComment: false, }, }); diff --git a/test/trigger-validation.test.ts b/test/trigger-validation.test.ts index cbb3796..0d16d6d 100644 --- a/test/trigger-validation.test.ts +++ b/test/trigger-validation.test.ts @@ -36,6 +36,7 @@ describe("checkContainsTrigger", () => { disallowedTools: [], customInstructions: "", branchPrefix: "claude/", + useStickyComment: false, }, }); expect(checkContainsTrigger(context)).toBe(true); @@ -64,6 +65,7 @@ describe("checkContainsTrigger", () => { disallowedTools: [], customInstructions: "", branchPrefix: "claude/", + useStickyComment: false, }, }); expect(checkContainsTrigger(context)).toBe(false); @@ -276,6 +278,7 @@ describe("checkContainsTrigger", () => { disallowedTools: [], customInstructions: "", branchPrefix: "claude/", + useStickyComment: false, }, }); expect(checkContainsTrigger(context)).toBe(true); @@ -305,6 +308,7 @@ describe("checkContainsTrigger", () => { disallowedTools: [], customInstructions: "", branchPrefix: "claude/", + useStickyComment: false, }, }); expect(checkContainsTrigger(context)).toBe(true); @@ -334,6 +338,7 @@ describe("checkContainsTrigger", () => { disallowedTools: [], customInstructions: "", branchPrefix: "claude/", + useStickyComment: false, }, }); expect(checkContainsTrigger(context)).toBe(false);