Skip to content

Commit 5d0cc74

Browse files
authored
feat(inline-comment): add confirmed param + probe-pattern safety net (#1048)
* feat(inline-comment): add confirmed param + probe-pattern safety net Subagents that inherit this tool sometimes probe it with test comments ('Test comment to see if I can create inline comments') after hitting unrelated errors elsewhere. Recurring issue across customer PRs. Adds two defenses: 1. confirmed param: set true to post (final review comments should pass this). When false, buffers to a JSONL file instead of posting. 2. Probe-pattern safety net: when confirmed is omitted (backward compat for existing prompts), the body is checked against obvious probe patterns ('test comment', 'can i', 'does this work', etc.). Matching calls are buffered instead of posted. A post-run step in action.yml reports the buffered call count and bodies as a workflow warning for diagnostics. Backward compatibility: - Existing single-agent prompts (no confirmed param) post normally unless the body happens to start with a probe phrase (unlikely for real review comments) - The code-review skill is being updated to pass confirmed: true in its final posting step - Subagent probes that would previously post now harmlessly buffer * refactor: replace probe-regex with Haiku classification in post-step The regex approach was narrow and could miss creative probe phrasings. Replaced with a batch Haiku classification that runs after the session completes. Flow: - MCP server: confirmed !== true -> buffer to JSONL (no classification in-band, no latency in the tool path) - Post-step (src/entrypoints/post-buffered-inline-comments.ts): reads buffer, sends all bodies to a single Haiku call, posts only those classified as real review comments - confirmed=false entries are never posted regardless of classification Fail-open: if ANTHROPIC_API_KEY is unavailable (Bedrock/Vertex users) or the classification call fails, posts all unconfirmed comments. This matches pre-PR behavior where all calls posted immediately. The post-step emits ::warning:: for each filtered comment so users can see what was dropped and why. * feat: add classify_inline_comments opt-out input New action input classify_inline_comments (default 'true'). Setting to 'false' restores pre-buffering behavior: all inline comment calls post immediately regardless of the confirmed param. Threads through: action input -> CLASSIFY_INLINE_COMMENTS env -> context.inputs.classifyInlineComments -> MCP server env -> CLASSIFY_ENABLED module const. Post-step is also gated on the input so it skips entirely when classification is disabled. * docs: document classify_inline_comments input and confirmed param - usage.md: add classify_inline_comments to inputs table - solutions.md: mention confirmed=true in the prompt example and explain buffering/classification in the tool permissions section
1 parent 567be3d commit 5d0cc74

13 files changed

+354
-34
lines changed

action.yml

Lines changed: 17 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -89,6 +89,10 @@ inputs:
8989
description: "Use just one comment to deliver issue/PR comments"
9090
required: false
9191
default: "false"
92+
classify_inline_comments:
93+
description: "Buffer inline comments without confirmed=true and classify them (real review vs test/probe) before posting after the session ends. Set to 'false' to post all inline comments immediately (pre-buffering behavior)."
94+
required: false
95+
default: "true"
9296
use_commit_signing:
9397
description: "Enable commit signing using GitHub's commit signature verification. When false, Claude uses standard git commands"
9498
required: false
@@ -204,6 +208,7 @@ runs:
204208
EXCLUDE_COMMENTS_BY_ACTOR: ${{ inputs.exclude_comments_by_actor }}
205209
GITHUB_RUN_ID: ${{ github.run_id }}
206210
USE_STICKY_COMMENT: ${{ inputs.use_sticky_comment }}
211+
CLASSIFY_INLINE_COMMENTS: ${{ inputs.classify_inline_comments }}
207212
DEFAULT_WORKFLOW_TOKEN: ${{ github.token }}
208213
USE_COMMIT_SIGNING: ${{ inputs.use_commit_signing }}
209214
SSH_SIGNING_KEY: ${{ inputs.ssh_signing_key }}
@@ -282,6 +287,18 @@ runs:
282287
run: |
283288
bun run ${GITHUB_ACTION_PATH}/src/entrypoints/cleanup-ssh-signing.ts
284289
290+
- name: Post buffered inline comments
291+
if: always() && inputs.classify_inline_comments != 'false'
292+
shell: bash
293+
env:
294+
GITHUB_TOKEN: ${{ steps.run.outputs.github_token || inputs.github_token || github.token }}
295+
REPO_OWNER: ${{ github.event.repository.owner.login }}
296+
REPO_NAME: ${{ github.event.repository.name }}
297+
PR_NUMBER: ${{ github.event.pull_request.number || github.event.issue.number }}
298+
ANTHROPIC_API_KEY: ${{ inputs.anthropic_api_key }}
299+
run: |
300+
bun run ${GITHUB_ACTION_PATH}/src/entrypoints/post-buffered-inline-comments.ts
301+
285302
- name: Revoke app token
286303
if: always() && inputs.github_token == '' && steps.run.outputs.skipped_due_to_workflow_validation_mismatch != 'true'
287304
shell: bash

docs/solutions.md

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -55,7 +55,7 @@ jobs:
5555
Note: The PR branch is already checked out in the current working directory.
5656
5757
Use `gh pr comment` for top-level feedback.
58-
Use `mcp__github_inline_comment__create_inline_comment` to highlight specific code issues.
58+
Use `mcp__github_inline_comment__create_inline_comment` (with `confirmed: true`) to highlight specific code issues.
5959
Only post GitHub comments - don't submit review text as messages.
6060
6161
claude_args: |
@@ -585,7 +585,7 @@ prompt: |
585585
### Common Tool Permissions
586586

587587
- **PR Comments**: `Bash(gh pr comment:*)`
588-
- **Inline Comments**: `mcp__github_inline_comment__create_inline_comment`
588+
- **Inline Comments**: `mcp__github_inline_comment__create_inline_comment` — pass `confirmed: true` to post immediately. When omitted, the comment is buffered and classified after the session ends (real review comments post, test/probe comments are filtered). This prevents subagent test comments from reaching PRs. To disable classification entirely, set `classify_inline_comments: 'false'` on the action.
589589
- **File Operations**: `Read,Write,Edit`
590590
- **Git Operations**: `Bash(git:*)`
591591

docs/usage.md

Lines changed: 30 additions & 29 deletions
Large diffs are not rendered by default.

src/entrypoints/collect-inputs.ts

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -23,6 +23,7 @@ export function collectActionInputsPresence(): string {
2323
github_token: "",
2424
max_turns: "",
2525
use_sticky_comment: "false",
26+
classify_inline_comments: "true",
2627
use_commit_signing: "false",
2728
ssh_signing_key: "",
2829
};
Lines changed: 233 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,233 @@
1+
#!/usr/bin/env bun
2+
/**
3+
* Reads buffered inline-comment calls from /tmp/inline-comments-buffer.jsonl,
4+
* classifies each as "real review" vs "test/probe" using Haiku, and posts
5+
* only the real ones. Calls with confirmed=false are never posted.
6+
*
7+
* If the Anthropic API is unavailable (Bedrock/Vertex users without a direct
8+
* key), falls back to posting everything with confirmed !== false. This
9+
* preserves backward compatibility — before this change, all unconfirmed
10+
* calls posted immediately.
11+
*/
12+
import { readFileSync } from "fs";
13+
import { createOctokit } from "../github/api/client";
14+
15+
const BUFFER_PATH = "/tmp/inline-comments-buffer.jsonl";
16+
17+
type BufferedComment = {
18+
ts: string;
19+
path: string;
20+
line?: number;
21+
startLine?: number;
22+
side?: "LEFT" | "RIGHT";
23+
commit_id?: string;
24+
body: string;
25+
confirmed?: boolean;
26+
};
27+
28+
const CLASSIFICATION_PROMPT = `You are classifying PR inline comments as either REAL code review feedback or TEST/PROBE calls.
29+
30+
A TEST/PROBE call is when an automated agent is checking whether a commenting tool works. These typically:
31+
- Start with phrases like "Test comment", "Testing if", "Can I", "Does this work", "Checking if"
32+
- Have generic/placeholder content not specific to any code
33+
- Exist to verify tool functionality, not to provide review feedback
34+
35+
A REAL review comment:
36+
- Discusses specific code, logic, bugs, or style
37+
- Provides actionable feedback for the PR author
38+
- References concrete aspects of the change
39+
40+
For each numbered comment body below, respond with ONLY a JSON array of booleans where true = REAL review comment, false = test/probe. No other text.
41+
42+
Comments:
43+
`;
44+
45+
async function classifyComments(bodies: string[]): Promise<boolean[] | null> {
46+
const apiKey = process.env.ANTHROPIC_API_KEY;
47+
if (!apiKey) {
48+
console.log(
49+
"ANTHROPIC_API_KEY not set — skipping classification, posting all unconfirmed comments",
50+
);
51+
return null;
52+
}
53+
54+
const prompt =
55+
CLASSIFICATION_PROMPT +
56+
bodies.map((b, i) => `${i + 1}. ${JSON.stringify(b)}`).join("\n");
57+
58+
try {
59+
const res = await fetch("https://api.anthropic.com/v1/messages", {
60+
method: "POST",
61+
headers: {
62+
"content-type": "application/json",
63+
"x-api-key": apiKey,
64+
"anthropic-version": "2023-06-01",
65+
},
66+
body: JSON.stringify({
67+
model: "claude-haiku-4-5",
68+
max_tokens: 1024,
69+
messages: [{ role: "user", content: prompt }],
70+
}),
71+
});
72+
73+
if (!res.ok) {
74+
console.log(
75+
`Classification API returned ${res.status} — posting all unconfirmed comments`,
76+
);
77+
return null;
78+
}
79+
80+
const data = (await res.json()) as {
81+
content: { type: string; text: string }[];
82+
};
83+
const text = data.content.find((c) => c.type === "text")?.text ?? "";
84+
const match = text.match(/\[[\s\S]*\]/);
85+
if (!match) {
86+
console.log(
87+
"Could not parse classification response — posting all unconfirmed comments",
88+
);
89+
return null;
90+
}
91+
const parsed = JSON.parse(match[0]);
92+
if (
93+
!Array.isArray(parsed) ||
94+
parsed.length !== bodies.length ||
95+
!parsed.every((v) => typeof v === "boolean")
96+
) {
97+
console.log(
98+
"Classification response shape mismatch — posting all unconfirmed comments",
99+
);
100+
return null;
101+
}
102+
return parsed;
103+
} catch (e) {
104+
console.log(
105+
`Classification failed (${e instanceof Error ? e.message : String(e)}) — posting all unconfirmed comments`,
106+
);
107+
return null;
108+
}
109+
}
110+
111+
async function postComment(
112+
octokit: ReturnType<typeof createOctokit>["rest"],
113+
owner: string,
114+
repo: string,
115+
pull_number: number,
116+
headSha: string,
117+
c: BufferedComment,
118+
): Promise<boolean> {
119+
const params: Parameters<typeof octokit.rest.pulls.createReviewComment>[0] = {
120+
owner,
121+
repo,
122+
pull_number,
123+
body: c.body,
124+
path: c.path,
125+
side: c.side || "RIGHT",
126+
commit_id: c.commit_id || headSha,
127+
};
128+
if (c.startLine) {
129+
params.start_line = c.startLine;
130+
params.start_side = c.side || "RIGHT";
131+
params.line = c.line;
132+
} else {
133+
params.line = c.line;
134+
}
135+
try {
136+
await octokit.rest.pulls.createReviewComment(params);
137+
return true;
138+
} catch (e) {
139+
console.log(
140+
` failed ${c.path}:${c.line}: ${e instanceof Error ? e.message : String(e)}`,
141+
);
142+
return false;
143+
}
144+
}
145+
146+
async function main() {
147+
let raw: string;
148+
try {
149+
raw = readFileSync(BUFFER_PATH, "utf8");
150+
} catch {
151+
console.log("No buffered inline comments");
152+
return;
153+
}
154+
155+
const comments: BufferedComment[] = raw
156+
.split("\n")
157+
.filter(Boolean)
158+
.map((line) => JSON.parse(line));
159+
160+
if (comments.length === 0) {
161+
console.log("No buffered inline comments");
162+
return;
163+
}
164+
165+
console.log(`Found ${comments.length} buffered inline comment(s)`);
166+
167+
const githubToken = process.env.GITHUB_TOKEN;
168+
const owner = process.env.REPO_OWNER;
169+
const repo = process.env.REPO_NAME;
170+
const prNumber = process.env.PR_NUMBER;
171+
172+
if (!githubToken || !owner || !repo || !prNumber) {
173+
console.log(
174+
"::warning::Missing GITHUB_TOKEN/REPO_OWNER/REPO_NAME/PR_NUMBER — cannot post buffered comments",
175+
);
176+
return;
177+
}
178+
179+
// Partition: confirmed=false are never posted; the rest are candidates
180+
const neverPost = comments.filter((c) => c.confirmed === false);
181+
const candidates = comments.filter((c) => c.confirmed !== false);
182+
183+
if (neverPost.length > 0) {
184+
console.log(` ${neverPost.length} with confirmed=false — not posting`);
185+
}
186+
187+
if (candidates.length === 0) {
188+
return;
189+
}
190+
191+
// Classify candidates
192+
const verdicts = await classifyComments(candidates.map((c) => c.body));
193+
const toPost =
194+
verdicts === null
195+
? candidates
196+
: candidates.filter((_, i) => verdicts[i] === true);
197+
const filtered =
198+
verdicts === null ? [] : candidates.filter((_, i) => verdicts[i] === false);
199+
200+
if (filtered.length > 0) {
201+
console.log(
202+
`::warning::${filtered.length} buffered comment(s) classified as test/probe — NOT posted:`,
203+
);
204+
for (const c of filtered) {
205+
console.log(` [${c.path}:${c.line}] ${c.body.slice(0, 120)}`);
206+
}
207+
}
208+
209+
if (toPost.length === 0) {
210+
console.log("No real comments to post");
211+
return;
212+
}
213+
214+
const octokit = createOctokit(githubToken).rest;
215+
const pull_number = parseInt(prNumber, 10);
216+
const pr = await octokit.pulls.get({ owner, repo, pull_number });
217+
const headSha = pr.data.head.sha;
218+
219+
console.log(`Posting ${toPost.length} classified-as-real comment(s)`);
220+
let posted = 0;
221+
for (const c of toPost) {
222+
if (await postComment(octokit, owner, repo, pull_number, headSha, c)) {
223+
console.log(` posted ${c.path}:${c.line}`);
224+
posted++;
225+
}
226+
}
227+
console.log(`Posted ${posted}/${toPost.length}`);
228+
}
229+
230+
main().catch((e) => {
231+
console.error("post-buffered-inline-comments failed:", e);
232+
process.exit(1);
233+
});

src/github/context.ts

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -90,6 +90,7 @@ type BaseContext = {
9090
branchPrefix: string;
9191
branchNameTemplate?: string;
9292
useStickyComment: boolean;
93+
classifyInlineComments: boolean;
9394
useCommitSigning: boolean;
9495
sshSigningKey: string;
9596
botId: string;
@@ -150,6 +151,7 @@ export function parseGitHubContext(): GitHubContext {
150151
branchPrefix: process.env.BRANCH_PREFIX ?? "claude/",
151152
branchNameTemplate: process.env.BRANCH_NAME_TEMPLATE,
152153
useStickyComment: process.env.USE_STICKY_COMMENT === "true",
154+
classifyInlineComments: process.env.CLASSIFY_INLINE_COMMENTS !== "false",
153155
useCommitSigning: process.env.USE_COMMIT_SIGNING === "true",
154156
sshSigningKey: process.env.SSH_SIGNING_KEY || "",
155157
botId: process.env.BOT_ID ?? String(CLAUDE_APP_BOT_ID),

0 commit comments

Comments
 (0)