chore: merge 7 upstream commits from anthropics/claude-code-action
Conflicts resolved: - docs/usage.md: kept fork's `platform` row, added upstream's new `classify_inline_comments` row, updated `allowed_bots` description with upstream's security warning - src/create-prompt/index.ts: kept fork's DISALLOWED_TOOLS export (consumed by run.ts) vs upstream's dead-code comment - src/modes/tag/index.ts: merged both fork's userAllowedTools and upstream's gitPushWrapper variable (both needed by shared code) Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
commit
ec394266fc
25 files changed
+449
-85
No files matched your search
+10
-14
@@ -26,19 +26,15 @@ import { extractUserRequest } from "../utils/extract-user-request";
|
||||
import { parseToolsInput } from "../utils/parse-tools-input";
|
||||
export type { CommonFields, PreparedContext } from "./types";
|
||||
|
||||
const GIT_PUSH_WRAPPER = `${process.env.GITHUB_ACTION_PATH}/scripts/git-push.sh`;
|
||||
|
||||
/** Filename for the user request file, read by the SDK runner */
|
||||
const USER_REQUEST_FILENAME = "claude-user-request.txt";
|
||||
|
||||
// Tag mode defaults - these tools are needed for tag mode to function
|
||||
const BASE_ALLOWED_TOOLS = [
|
||||
"Edit",
|
||||
"MultiEdit",
|
||||
"Glob",
|
||||
"Grep",
|
||||
"LS",
|
||||
"Read",
|
||||
"Write",
|
||||
];
|
||||
// Tag mode defaults - these tools are needed for tag mode to function.
|
||||
// Edit/MultiEdit/Write are intentionally omitted: acceptEdits permission mode
|
||||
// auto-allows file edits inside $GITHUB_WORKSPACE and denies writes outside it.
|
||||
const BASE_ALLOWED_TOOLS = ["Glob", "Grep", "LS", "Read"];
|
||||
|
||||
export function buildAllowedToolsString(
|
||||
customAllowedTools?: string[],
|
||||
@@ -62,7 +58,7 @@ export function buildAllowedToolsString(
|
||||
baseTools.push(
|
||||
"Bash(git add:*)",
|
||||
"Bash(git commit:*)",
|
||||
"Bash(git push:*)",
|
||||
`Bash(${GIT_PUSH_WRAPPER}:*)`,
|
||||
"Bash(git status:*)",
|
||||
"Bash(git diff:*)",
|
||||
"Bash(git log:*)",
|
||||
@@ -443,7 +439,7 @@ function getCommitInstructions(
|
||||
Bash(git commit -m "<message>\\n\\n${coAuthorLine}")`
|
||||
: ""
|
||||
}
|
||||
- Push to the remote: Bash(git push origin HEAD)`;
|
||||
- Push to the remote: Bash(${GIT_PUSH_WRAPPER} origin HEAD)`;
|
||||
} else {
|
||||
const branchName = eventData.claudeBranch || eventData.baseBranch;
|
||||
return `
|
||||
@@ -457,7 +453,7 @@ function getCommitInstructions(
|
||||
Bash(git commit -m "<message>\\n\\n${coAuthorLine}")`
|
||||
: ""
|
||||
}
|
||||
- Push to the remote: Bash(git push origin ${branchName})`;
|
||||
- Push to the remote: Bash(${GIT_PUSH_WRAPPER} origin ${branchName})`;
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -840,7 +836,7 @@ ${
|
||||
: `- Use git commands via the Bash tool for version control (remember that you have access to these git commands):
|
||||
- Stage files: Bash(git add <files>)
|
||||
- Commit changes: Bash(git commit -m "<message>")
|
||||
- Push to remote: Bash(git push origin <branch>) (NEVER force push)
|
||||
- Push to remote: Bash(${GIT_PUSH_WRAPPER} origin <branch>)
|
||||
- Delete files: Bash(git rm <files>) followed by commit and push
|
||||
- Check status: Bash(git status)
|
||||
- View diff: Bash(git diff)${eventData.isPR && eventData.baseBranch ? `\n - IMPORTANT: For PR diffs, use: Bash(git diff origin/${eventData.baseBranch}...HEAD)` : ""}`
|
||||
|
||||
@@ -23,6 +23,7 @@ export function collectActionInputsPresence(): string {
|
||||
github_token: "",
|
||||
max_turns: "",
|
||||
use_sticky_comment: "false",
|
||||
classify_inline_comments: "true",
|
||||
use_commit_signing: "false",
|
||||
ssh_signing_key: "",
|
||||
};
|
||||
|
||||
@@ -0,0 +1,233 @@
|
||||
#!/usr/bin/env bun
|
||||
/**
|
||||
* Reads buffered inline-comment calls from /tmp/inline-comments-buffer.jsonl,
|
||||
* classifies each as "real review" vs "test/probe" using Haiku, and posts
|
||||
* only the real ones. Calls with confirmed=false are never posted.
|
||||
*
|
||||
* If the Anthropic API is unavailable (Bedrock/Vertex users without a direct
|
||||
* key), falls back to posting everything with confirmed !== false. This
|
||||
* preserves backward compatibility — before this change, all unconfirmed
|
||||
* calls posted immediately.
|
||||
*/
|
||||
import { readFileSync } from "fs";
|
||||
import { createOctokit } from "../github/api/client";
|
||||
|
||||
const BUFFER_PATH = "/tmp/inline-comments-buffer.jsonl";
|
||||
|
||||
type BufferedComment = {
|
||||
ts: string;
|
||||
path: string;
|
||||
line?: number;
|
||||
startLine?: number;
|
||||
side?: "LEFT" | "RIGHT";
|
||||
commit_id?: string;
|
||||
body: string;
|
||||
confirmed?: boolean;
|
||||
};
|
||||
|
||||
const CLASSIFICATION_PROMPT = `You are classifying PR inline comments as either REAL code review feedback or TEST/PROBE calls.
|
||||
|
||||
A TEST/PROBE call is when an automated agent is checking whether a commenting tool works. These typically:
|
||||
- Start with phrases like "Test comment", "Testing if", "Can I", "Does this work", "Checking if"
|
||||
- Have generic/placeholder content not specific to any code
|
||||
- Exist to verify tool functionality, not to provide review feedback
|
||||
|
||||
A REAL review comment:
|
||||
- Discusses specific code, logic, bugs, or style
|
||||
- Provides actionable feedback for the PR author
|
||||
- References concrete aspects of the change
|
||||
|
||||
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.
|
||||
|
||||
Comments:
|
||||
`;
|
||||
|
||||
async function classifyComments(bodies: string[]): Promise<boolean[] | null> {
|
||||
const apiKey = process.env.ANTHROPIC_API_KEY;
|
||||
if (!apiKey) {
|
||||
console.log(
|
||||
"ANTHROPIC_API_KEY not set — skipping classification, posting all unconfirmed comments",
|
||||
);
|
||||
return null;
|
||||
}
|
||||
|
||||
const prompt =
|
||||
CLASSIFICATION_PROMPT +
|
||||
bodies.map((b, i) => `${i + 1}. ${JSON.stringify(b)}`).join("\n");
|
||||
|
||||
try {
|
||||
const res = await fetch("https://api.anthropic.com/v1/messages", {
|
||||
method: "POST",
|
||||
headers: {
|
||||
"content-type": "application/json",
|
||||
"x-api-key": apiKey,
|
||||
"anthropic-version": "2023-06-01",
|
||||
},
|
||||
body: JSON.stringify({
|
||||
model: "claude-haiku-4-5",
|
||||
max_tokens: 1024,
|
||||
messages: [{ role: "user", content: prompt }],
|
||||
}),
|
||||
});
|
||||
|
||||
if (!res.ok) {
|
||||
console.log(
|
||||
`Classification API returned ${res.status} — posting all unconfirmed comments`,
|
||||
);
|
||||
return null;
|
||||
}
|
||||
|
||||
const data = (await res.json()) as {
|
||||
content: { type: string; text: string }[];
|
||||
};
|
||||
const text = data.content.find((c) => c.type === "text")?.text ?? "";
|
||||
const match = text.match(/\[[\s\S]*\]/);
|
||||
if (!match) {
|
||||
console.log(
|
||||
"Could not parse classification response — posting all unconfirmed comments",
|
||||
);
|
||||
return null;
|
||||
}
|
||||
const parsed = JSON.parse(match[0]);
|
||||
if (
|
||||
!Array.isArray(parsed) ||
|
||||
parsed.length !== bodies.length ||
|
||||
!parsed.every((v) => typeof v === "boolean")
|
||||
) {
|
||||
console.log(
|
||||
"Classification response shape mismatch — posting all unconfirmed comments",
|
||||
);
|
||||
return null;
|
||||
}
|
||||
return parsed;
|
||||
} catch (e) {
|
||||
console.log(
|
||||
`Classification failed (${e instanceof Error ? e.message : String(e)}) — posting all unconfirmed comments`,
|
||||
);
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
async function postComment(
|
||||
octokit: ReturnType<typeof createOctokit>["rest"],
|
||||
owner: string,
|
||||
repo: string,
|
||||
pull_number: number,
|
||||
headSha: string,
|
||||
c: BufferedComment,
|
||||
): Promise<boolean> {
|
||||
const params: Parameters<typeof octokit.rest.pulls.createReviewComment>[0] = {
|
||||
owner,
|
||||
repo,
|
||||
pull_number,
|
||||
body: c.body,
|
||||
path: c.path,
|
||||
side: c.side || "RIGHT",
|
||||
commit_id: c.commit_id || headSha,
|
||||
};
|
||||
if (c.startLine) {
|
||||
params.start_line = c.startLine;
|
||||
params.start_side = c.side || "RIGHT";
|
||||
params.line = c.line;
|
||||
} else {
|
||||
params.line = c.line;
|
||||
}
|
||||
try {
|
||||
await octokit.rest.pulls.createReviewComment(params);
|
||||
return true;
|
||||
} catch (e) {
|
||||
console.log(
|
||||
` failed ${c.path}:${c.line}: ${e instanceof Error ? e.message : String(e)}`,
|
||||
);
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
async function main() {
|
||||
let raw: string;
|
||||
try {
|
||||
raw = readFileSync(BUFFER_PATH, "utf8");
|
||||
} catch {
|
||||
console.log("No buffered inline comments");
|
||||
return;
|
||||
}
|
||||
|
||||
const comments: BufferedComment[] = raw
|
||||
.split("\n")
|
||||
.filter(Boolean)
|
||||
.map((line) => JSON.parse(line));
|
||||
|
||||
if (comments.length === 0) {
|
||||
console.log("No buffered inline comments");
|
||||
return;
|
||||
}
|
||||
|
||||
console.log(`Found ${comments.length} buffered inline comment(s)`);
|
||||
|
||||
const githubToken = process.env.GITHUB_TOKEN;
|
||||
const owner = process.env.REPO_OWNER;
|
||||
const repo = process.env.REPO_NAME;
|
||||
const prNumber = process.env.PR_NUMBER;
|
||||
|
||||
if (!githubToken || !owner || !repo || !prNumber) {
|
||||
console.log(
|
||||
"::warning::Missing GITHUB_TOKEN/REPO_OWNER/REPO_NAME/PR_NUMBER — cannot post buffered comments",
|
||||
);
|
||||
return;
|
||||
}
|
||||
|
||||
// Partition: confirmed=false are never posted; the rest are candidates
|
||||
const neverPost = comments.filter((c) => c.confirmed === false);
|
||||
const candidates = comments.filter((c) => c.confirmed !== false);
|
||||
|
||||
if (neverPost.length > 0) {
|
||||
console.log(` ${neverPost.length} with confirmed=false — not posting`);
|
||||
}
|
||||
|
||||
if (candidates.length === 0) {
|
||||
return;
|
||||
}
|
||||
|
||||
// Classify candidates
|
||||
const verdicts = await classifyComments(candidates.map((c) => c.body));
|
||||
const toPost =
|
||||
verdicts === null
|
||||
? candidates
|
||||
: candidates.filter((_, i) => verdicts[i] === true);
|
||||
const filtered =
|
||||
verdicts === null ? [] : candidates.filter((_, i) => verdicts[i] === false);
|
||||
|
||||
if (filtered.length > 0) {
|
||||
console.log(
|
||||
`::warning::${filtered.length} buffered comment(s) classified as test/probe — NOT posted:`,
|
||||
);
|
||||
for (const c of filtered) {
|
||||
console.log(` [${c.path}:${c.line}] ${c.body.slice(0, 120)}`);
|
||||
}
|
||||
}
|
||||
|
||||
if (toPost.length === 0) {
|
||||
console.log("No real comments to post");
|
||||
return;
|
||||
}
|
||||
|
||||
const octokit = createOctokit(githubToken).rest;
|
||||
const pull_number = parseInt(prNumber, 10);
|
||||
const pr = await octokit.pulls.get({ owner, repo, pull_number });
|
||||
const headSha = pr.data.head.sha;
|
||||
|
||||
console.log(`Posting ${toPost.length} classified-as-real comment(s)`);
|
||||
let posted = 0;
|
||||
for (const c of toPost) {
|
||||
if (await postComment(octokit, owner, repo, pull_number, headSha, c)) {
|
||||
console.log(` posted ${c.path}:${c.line}`);
|
||||
posted++;
|
||||
}
|
||||
}
|
||||
console.log(`Posted ${posted}/${toPost.length}`);
|
||||
}
|
||||
|
||||
main().catch((e) => {
|
||||
console.error("post-buffered-inline-comments failed:", e);
|
||||
process.exit(1);
|
||||
});
|
||||
@@ -54,7 +54,7 @@ async function installClaudeCode(): Promise<void> {
|
||||
return;
|
||||
}
|
||||
|
||||
const claudeCodeVersion = "2.1.71";
|
||||
const claudeCodeVersion = "2.1.76";
|
||||
console.log(`Installing Claude Code v${claudeCodeVersion}...`);
|
||||
|
||||
// On musl/Alpine systems, the native binary may require a newer musl than
|
||||
|
||||
@@ -115,6 +115,7 @@ type BaseContext = {
|
||||
branchPrefix: string;
|
||||
branchNameTemplate?: string;
|
||||
useStickyComment: boolean;
|
||||
classifyInlineComments: boolean;
|
||||
useCommitSigning: boolean;
|
||||
sshSigningKey: string;
|
||||
botId: string;
|
||||
@@ -178,6 +179,7 @@ export function parseGitHubContext(): GitHubContext {
|
||||
branchPrefix: process.env.BRANCH_PREFIX ?? "claude/",
|
||||
branchNameTemplate: process.env.BRANCH_NAME_TEMPLATE,
|
||||
useStickyComment: process.env.USE_STICKY_COMMENT === "true",
|
||||
classifyInlineComments: process.env.CLASSIFY_INLINE_COMMENTS !== "false",
|
||||
useCommitSigning: process.env.USE_COMMIT_SIGNING === "true",
|
||||
sshSigningKey: process.env.SSH_SIGNING_KEY || "",
|
||||
botId: process.env.BOT_ID ?? String(CLAUDE_APP_BOT_ID),
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
#!/usr/bin/env node
|
||||
import { McpServer } from "@modelcontextprotocol/sdk/server/mcp.js";
|
||||
import { StdioServerTransport } from "@modelcontextprotocol/sdk/server/stdio.js";
|
||||
import { appendFileSync } from "fs";
|
||||
import { z } from "zod";
|
||||
import { createOctokit } from "../github/api/client";
|
||||
import { sanitizeContent } from "../github/utils/sanitizer";
|
||||
@@ -10,6 +11,13 @@ const REPO_OWNER = process.env.REPO_OWNER;
|
||||
const REPO_NAME = process.env.REPO_NAME;
|
||||
const PR_NUMBER = process.env.PR_NUMBER;
|
||||
|
||||
// Calls without confirmed=true are buffered here instead of posted. This
|
||||
// prevents subagents from posting test/probe comments when they inherit this
|
||||
// tool and probe it after hitting unrelated errors. The action's post-step
|
||||
// reports the buffer count for diagnostics.
|
||||
const BUFFER_PATH = "/tmp/inline-comments-buffer.jsonl";
|
||||
const CLASSIFY_ENABLED = process.env.CLASSIFY_INLINE_COMMENTS !== "false";
|
||||
|
||||
if (!REPO_OWNER || !REPO_NAME || !PR_NUMBER) {
|
||||
console.error(
|
||||
"Error: REPO_OWNER, REPO_NAME, and PR_NUMBER environment variables are required",
|
||||
@@ -67,8 +75,17 @@ server.tool(
|
||||
.describe(
|
||||
"Specific commit SHA to comment on (defaults to latest commit)",
|
||||
),
|
||||
confirmed: z
|
||||
.boolean()
|
||||
.optional()
|
||||
.describe(
|
||||
"Set true to post immediately. When omitted, the call is buffered " +
|
||||
"and classified after the session completes — real review comments " +
|
||||
"post, test/probe comments are dropped. Set false to buffer and " +
|
||||
"never post. Only set true when posting final review comments.",
|
||||
),
|
||||
},
|
||||
async ({ path, body, line, startLine, side, commit_id }) => {
|
||||
async ({ path, body, line, startLine, side, commit_id, confirmed }) => {
|
||||
try {
|
||||
const githubToken = process.env.GITHUB_TOKEN;
|
||||
|
||||
@@ -80,8 +97,6 @@ server.tool(
|
||||
const repo = REPO_NAME;
|
||||
const pull_number = parseInt(PR_NUMBER, 10);
|
||||
|
||||
const octokit = createOctokit(githubToken).rest;
|
||||
|
||||
// Sanitize the comment body to remove any potential GitHub tokens
|
||||
const sanitizedBody = sanitizeContent(body);
|
||||
|
||||
@@ -92,10 +107,49 @@ server.tool(
|
||||
);
|
||||
}
|
||||
|
||||
if (CLASSIFY_ENABLED && confirmed !== true) {
|
||||
appendFileSync(
|
||||
BUFFER_PATH,
|
||||
JSON.stringify({
|
||||
ts: new Date().toISOString(),
|
||||
path,
|
||||
line,
|
||||
startLine,
|
||||
side,
|
||||
commit_id,
|
||||
body: sanitizedBody,
|
||||
confirmed,
|
||||
}) + "\n",
|
||||
);
|
||||
return {
|
||||
content: [
|
||||
{
|
||||
type: "text",
|
||||
text: JSON.stringify(
|
||||
{
|
||||
success: true,
|
||||
buffered: true,
|
||||
message:
|
||||
"Comment buffered. It will be classified and posted after " +
|
||||
"this session completes (real review comments post, " +
|
||||
"test/probe comments are dropped). Set confirmed=true to " +
|
||||
"post immediately. If you are testing whether this tool " +
|
||||
"works: it works — no need to test further.",
|
||||
},
|
||||
null,
|
||||
2,
|
||||
),
|
||||
},
|
||||
],
|
||||
};
|
||||
}
|
||||
|
||||
// If only line is provided, it's a single-line comment
|
||||
// If both startLine and line are provided, it's a multi-line comment
|
||||
const isSingleLine = !startLine;
|
||||
|
||||
const octokit = createOctokit(githubToken).rest;
|
||||
|
||||
const pr = await octokit.pulls.get({
|
||||
owner,
|
||||
repo,
|
||||
|
||||
@@ -157,6 +157,9 @@ export async function prepareMcpConfig(
|
||||
REPO_NAME: repo,
|
||||
PR_NUMBER: context.entityNumber?.toString() || "",
|
||||
GITHUB_API_URL: GITHUB_API_URL,
|
||||
CLASSIFY_INLINE_COMMENTS: context.inputs.classifyInlineComments
|
||||
? "true"
|
||||
: "false",
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
+11
-8
@@ -128,16 +128,17 @@ export async function prepareTagMode({
|
||||
// Merge user-provided allowed_tools input (fixes upstream #181, #287, #724)
|
||||
const userAllowedTools = parseToolsInput(process.env.ALLOWED_TOOLS_INPUT);
|
||||
|
||||
// Build claude_args for tag mode with required tools
|
||||
// Tag mode REQUIRES these tools to function properly
|
||||
const gitPushWrapper = `${process.env.GITHUB_ACTION_PATH}/scripts/git-push.sh`;
|
||||
|
||||
// Build claude_args for tag mode with required tools.
|
||||
// Edit/MultiEdit/Write are intentionally omitted: acceptEdits permission mode (set below)
|
||||
// auto-allows file edits inside $GITHUB_WORKSPACE and denies writes outside (e.g. ~/.bashrc).
|
||||
// Listing them here would grant blanket write access to the whole runner (Asana 1213310082312048).
|
||||
const tagModeTools = [
|
||||
"Edit",
|
||||
"MultiEdit",
|
||||
"Glob",
|
||||
"Grep",
|
||||
"LS",
|
||||
"Read",
|
||||
"Write",
|
||||
"mcp__github_comment__update_claude_comment",
|
||||
"mcp__github_ci__get_ci_status",
|
||||
"mcp__github_ci__get_workflow_run_details",
|
||||
@@ -152,7 +153,7 @@ export async function prepareTagMode({
|
||||
tagModeTools.push(
|
||||
"Bash(git add:*)",
|
||||
"Bash(git commit:*)",
|
||||
"Bash(git push:*)",
|
||||
`Bash(${gitPushWrapper}:*)`,
|
||||
"Bash(git status:*)",
|
||||
"Bash(git diff:*)",
|
||||
"Bash(git log:*)",
|
||||
@@ -186,8 +187,10 @@ export async function prepareTagMode({
|
||||
const escapedOurConfig = ourMcpConfig.replace(/'/g, "'\\''");
|
||||
claudeArgs = `--mcp-config '${escapedOurConfig}'`;
|
||||
|
||||
// Add required tools for tag mode
|
||||
claudeArgs += ` --allowedTools "${tagModeTools.join(",")}"`;
|
||||
// Add required tools for tag mode.
|
||||
// acceptEdits: file edits auto-allowed inside cwd ($GITHUB_WORKSPACE), denied outside.
|
||||
// Headless SDK has no prompt handler, so anything that falls through to "ask" is denied.
|
||||
claudeArgs += ` --permission-mode acceptEdits --allowedTools "${tagModeTools.join(",")}"`;
|
||||
|
||||
// Append user's claude_args (which may have more --mcp-config flags)
|
||||
if (userClaudeArgs) {
|
||||
|
||||
Reference in new issue
Block a user