mirror of
https://github.com/anthropics/claude-code-action.git
synced 2026-08-05 02:38:30 +08:00
Compare commits
No commits in common. "main" and "v1.0.170" have entirely different histories.
@ -145,7 +145,7 @@ runs:
|
|||||||
PATH_TO_CLAUDE_CODE_EXECUTABLE: ${{ inputs.path_to_claude_code_executable }}
|
PATH_TO_CLAUDE_CODE_EXECUTABLE: ${{ inputs.path_to_claude_code_executable }}
|
||||||
run: |
|
run: |
|
||||||
if [ -z "$PATH_TO_CLAUDE_CODE_EXECUTABLE" ]; then
|
if [ -z "$PATH_TO_CLAUDE_CODE_EXECUTABLE" ]; then
|
||||||
CLAUDE_CODE_VERSION="2.1.221"
|
CLAUDE_CODE_VERSION="2.1.206"
|
||||||
echo "Installing Claude Code v${CLAUDE_CODE_VERSION}..."
|
echo "Installing Claude Code v${CLAUDE_CODE_VERSION}..."
|
||||||
for attempt in 1 2 3; do
|
for attempt in 1 2 3; do
|
||||||
echo "Installation attempt $attempt..."
|
echo "Installation attempt $attempt..."
|
||||||
|
|||||||
@ -6,7 +6,7 @@
|
|||||||
"name": "@anthropic-ai/claude-code-base-action",
|
"name": "@anthropic-ai/claude-code-base-action",
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"@actions/core": "^1.10.1",
|
"@actions/core": "^1.10.1",
|
||||||
"@anthropic-ai/claude-agent-sdk": "^0.3.221",
|
"@anthropic-ai/claude-agent-sdk": "^0.3.206",
|
||||||
"shell-quote": "^1.8.3",
|
"shell-quote": "^1.8.3",
|
||||||
},
|
},
|
||||||
"devDependencies": {
|
"devDependencies": {
|
||||||
@ -27,23 +27,23 @@
|
|||||||
|
|
||||||
"@actions/io": ["@actions/io@1.1.3", "", {}, "sha512-wi9JjgKLYS7U/z8PPbco+PvTb/nRWjeoFlJ1Qer83k/3C5PHQi28hiVdeE2kHXmIL99mQFawx8qt/JPjZilJ8Q=="],
|
"@actions/io": ["@actions/io@1.1.3", "", {}, "sha512-wi9JjgKLYS7U/z8PPbco+PvTb/nRWjeoFlJ1Qer83k/3C5PHQi28hiVdeE2kHXmIL99mQFawx8qt/JPjZilJ8Q=="],
|
||||||
|
|
||||||
"@anthropic-ai/claude-agent-sdk": ["@anthropic-ai/claude-agent-sdk@0.3.221", "", { "optionalDependencies": { "@anthropic-ai/claude-agent-sdk-darwin-arm64": "0.3.221", "@anthropic-ai/claude-agent-sdk-darwin-x64": "0.3.221", "@anthropic-ai/claude-agent-sdk-linux-arm64": "0.3.221", "@anthropic-ai/claude-agent-sdk-linux-arm64-musl": "0.3.221", "@anthropic-ai/claude-agent-sdk-linux-x64": "0.3.221", "@anthropic-ai/claude-agent-sdk-linux-x64-musl": "0.3.221", "@anthropic-ai/claude-agent-sdk-win32-arm64": "0.3.221", "@anthropic-ai/claude-agent-sdk-win32-x64": "0.3.221" }, "peerDependencies": { "@anthropic-ai/sdk": ">=0.93.0", "@modelcontextprotocol/sdk": "^1.29.0", "zod": "^4.0.0" } }, "sha512-qOXebuNlK5hKkYmXSdSNJHDw4ulAJYQ7hdGuOmv5pXtaRCvfkIRlNGKoiFjGDq55n8aneAp3/BIzAVcxSQln+g=="],
|
"@anthropic-ai/claude-agent-sdk": ["@anthropic-ai/claude-agent-sdk@0.3.206", "", { "optionalDependencies": { "@anthropic-ai/claude-agent-sdk-darwin-arm64": "0.3.206", "@anthropic-ai/claude-agent-sdk-darwin-x64": "0.3.206", "@anthropic-ai/claude-agent-sdk-linux-arm64": "0.3.206", "@anthropic-ai/claude-agent-sdk-linux-arm64-musl": "0.3.206", "@anthropic-ai/claude-agent-sdk-linux-x64": "0.3.206", "@anthropic-ai/claude-agent-sdk-linux-x64-musl": "0.3.206", "@anthropic-ai/claude-agent-sdk-win32-arm64": "0.3.206", "@anthropic-ai/claude-agent-sdk-win32-x64": "0.3.206" }, "peerDependencies": { "@anthropic-ai/sdk": ">=0.93.0", "@modelcontextprotocol/sdk": "^1.29.0", "zod": "^4.0.0" } }, "sha512-KljDh9Pg4YCYpoXS8dnWoVSsOHtU4yLCW268K2iOruSxFXE8/Tay6DPvmJzYuqjP5YLNYfj05ZGykwZSUn6GXA=="],
|
||||||
|
|
||||||
"@anthropic-ai/claude-agent-sdk-darwin-arm64": ["@anthropic-ai/claude-agent-sdk-darwin-arm64@0.3.221", "", { "os": "darwin", "cpu": "arm64" }, "sha512-/J4MnpoOqJqrIjH6c6GQAB6Tlcp+kvTEWSM9+5yCkGF2cUmXgaQk7eJlu05ldp+GuSS5AVKq5goIjhYFgY3+Sg=="],
|
"@anthropic-ai/claude-agent-sdk-darwin-arm64": ["@anthropic-ai/claude-agent-sdk-darwin-arm64@0.3.206", "", { "os": "darwin", "cpu": "arm64" }, "sha512-FL2+NKcMMN47vcnCW2Fkt3AOgeRRlQxrisbPNaxrxqPJFzhUKs17x5j0XzLefd0xRbDAr74hd0PK/tnp6PHM6w=="],
|
||||||
|
|
||||||
"@anthropic-ai/claude-agent-sdk-darwin-x64": ["@anthropic-ai/claude-agent-sdk-darwin-x64@0.3.221", "", { "os": "darwin", "cpu": "x64" }, "sha512-je33rwPBGEHDoW/kMzoMw/Pg4mcRfvzirkhEx/rykAK4/w0t3Czwt5DeF0HXVQzW+M8bXPN1486wMxONNFLQMw=="],
|
"@anthropic-ai/claude-agent-sdk-darwin-x64": ["@anthropic-ai/claude-agent-sdk-darwin-x64@0.3.206", "", { "os": "darwin", "cpu": "x64" }, "sha512-mRW8PPMfQN15EunLwpdmcVzk3XuM4DXQUM8DOzaeA1Hr1yxYPaVVBLr9hkdBKOqr8XTl3ueoTR6RZAy6a/n7OA=="],
|
||||||
|
|
||||||
"@anthropic-ai/claude-agent-sdk-linux-arm64": ["@anthropic-ai/claude-agent-sdk-linux-arm64@0.3.221", "", { "os": "linux", "cpu": "arm64" }, "sha512-6HhD/5poDNEpITda9NyTmqwrxaFi4dn5nROLCVC1BG4IhEHfeBIVocr1TAgTy7RjoScr3RHl9SA9OFpUMzuifQ=="],
|
"@anthropic-ai/claude-agent-sdk-linux-arm64": ["@anthropic-ai/claude-agent-sdk-linux-arm64@0.3.206", "", { "os": "linux", "cpu": "arm64" }, "sha512-Id6H8l6EsGb7849EAZDOB4Ic+FQpbzt4D5HGOwp59CTW3o/1c4etjQA/Kl1K+DSEWn3FGo6D5UMVgc/rwhBj8g=="],
|
||||||
|
|
||||||
"@anthropic-ai/claude-agent-sdk-linux-arm64-musl": ["@anthropic-ai/claude-agent-sdk-linux-arm64-musl@0.3.221", "", { "os": "linux", "cpu": "arm64" }, "sha512-Gi84q2ULzWhduMoP69NGwMpo9AibXcO+PtbF0eHCUJAQ/qUu0PxcrTg6S73WotQ2qAP8LhtN6a8x+8EszU64MA=="],
|
"@anthropic-ai/claude-agent-sdk-linux-arm64-musl": ["@anthropic-ai/claude-agent-sdk-linux-arm64-musl@0.3.206", "", { "os": "linux", "cpu": "arm64" }, "sha512-aMZe1Kl+kYv5QlA15W9Ae25MAzBsA9FA40f5TOtJebA+M/xliF0r2LLb2NdyBviiZCDllcE31l0zgYE0rQqFQw=="],
|
||||||
|
|
||||||
"@anthropic-ai/claude-agent-sdk-linux-x64": ["@anthropic-ai/claude-agent-sdk-linux-x64@0.3.221", "", { "os": "linux", "cpu": "x64" }, "sha512-pPrpl84Pe563tLci6ndBuBK6IY85RKhcAAJMU73/VPB240yiSnnPaxqKTEgXnw7qbAFYTQ1TOasum/awUs4BdQ=="],
|
"@anthropic-ai/claude-agent-sdk-linux-x64": ["@anthropic-ai/claude-agent-sdk-linux-x64@0.3.206", "", { "os": "linux", "cpu": "x64" }, "sha512-egZhOC1RlEVhZyq6Oa1b04AF7hh4hO+8oCsJCZ3gOifJQuHih1oW3lZ8fOUxU85jlT2ytAnt5kRp4uQr6PJgbg=="],
|
||||||
|
|
||||||
"@anthropic-ai/claude-agent-sdk-linux-x64-musl": ["@anthropic-ai/claude-agent-sdk-linux-x64-musl@0.3.221", "", { "os": "linux", "cpu": "x64" }, "sha512-/tm6HdYV1EnnZJpN9l1yxZGRaE5K+zj0OLycBBHc0CBXtAQ1we/7aMpqX39iJ01c7J5TG9coEJOI/G9K2FhQoQ=="],
|
"@anthropic-ai/claude-agent-sdk-linux-x64-musl": ["@anthropic-ai/claude-agent-sdk-linux-x64-musl@0.3.206", "", { "os": "linux", "cpu": "x64" }, "sha512-xQBOBhlcmTNc7YeYT5qLZOikpSq3WHlsJ/t6i7kJqUWrcXlOPaAoSMdKp5xO/V0CjAdzdJoWB88I55UAh8mclQ=="],
|
||||||
|
|
||||||
"@anthropic-ai/claude-agent-sdk-win32-arm64": ["@anthropic-ai/claude-agent-sdk-win32-arm64@0.3.221", "", { "os": "win32", "cpu": "arm64" }, "sha512-IK2aOH58U7iMM4nAc45B8pcHZhFCUAPml6N8WFBHNr41+hRelI++xEVJpQQk55SyrRQ8HVeCoMeTlp858i9KgQ=="],
|
"@anthropic-ai/claude-agent-sdk-win32-arm64": ["@anthropic-ai/claude-agent-sdk-win32-arm64@0.3.206", "", { "os": "win32", "cpu": "arm64" }, "sha512-oRQk23bFXSz4QRhOxqnnvlLWq/KiF2PtSBYXrMg1AQrCOHJd2k66aOAS7AJ4ZSzejiyV4N6sS6UThfmF6ipHBQ=="],
|
||||||
|
|
||||||
"@anthropic-ai/claude-agent-sdk-win32-x64": ["@anthropic-ai/claude-agent-sdk-win32-x64@0.3.221", "", { "os": "win32", "cpu": "x64" }, "sha512-vqa6DOMfqSr+JoXqc7p6mjqwuTOYQLdXVBVCOwg7HEc/O0JgrFLeC4lCQs8hIkLrO0r/pBwv8KbfmYz1DwIeew=="],
|
"@anthropic-ai/claude-agent-sdk-win32-x64": ["@anthropic-ai/claude-agent-sdk-win32-x64@0.3.206", "", { "os": "win32", "cpu": "x64" }, "sha512-BdjKmDojZjc5RjN+8Q6K7Yqf1WYhel6I3DkMXc9zdxS/xIuN42sx7zgQpdMGY73pm7ROPLqo3LieOeMhVH161w=="],
|
||||||
|
|
||||||
"@anthropic-ai/sdk": ["@anthropic-ai/sdk@0.93.0", "", { "dependencies": { "json-schema-to-ts": "^3.1.1" }, "peerDependencies": { "zod": "^3.25.0 || ^4.0.0" }, "optionalPeers": ["zod"], "bin": { "anthropic-ai-sdk": "bin/cli" } }, "sha512-q9vaSZQVFx6B/gPxetGYfLXSJD5v0sOmh0OpZDq7yCrTSA+Rscvrtyol7JJTW40wEpQB4U1B4JXzxQitbQ3CAA=="],
|
"@anthropic-ai/sdk": ["@anthropic-ai/sdk@0.93.0", "", { "dependencies": { "json-schema-to-ts": "^3.1.1" }, "peerDependencies": { "zod": "^3.25.0 || ^4.0.0" }, "optionalPeers": ["zod"], "bin": { "anthropic-ai-sdk": "bin/cli" } }, "sha512-q9vaSZQVFx6B/gPxetGYfLXSJD5v0sOmh0OpZDq7yCrTSA+Rscvrtyol7JJTW40wEpQB4U1B4JXzxQitbQ3CAA=="],
|
||||||
|
|
||||||
|
|||||||
@ -11,7 +11,7 @@
|
|||||||
},
|
},
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"@actions/core": "^1.10.1",
|
"@actions/core": "^1.10.1",
|
||||||
"@anthropic-ai/claude-agent-sdk": "^0.3.221",
|
"@anthropic-ai/claude-agent-sdk": "^0.3.206",
|
||||||
"shell-quote": "^1.8.3"
|
"shell-quote": "^1.8.3"
|
||||||
},
|
},
|
||||||
"devDependencies": {
|
"devDependencies": {
|
||||||
|
|||||||
@ -75,8 +75,7 @@ async function run() {
|
|||||||
core.setOutput("conclusion", "failure");
|
core.setOutput("conclusion", "failure");
|
||||||
process.exit(1);
|
process.exit(1);
|
||||||
} finally {
|
} finally {
|
||||||
// Stop refreshing the workload identity token file (so the process can
|
// Stop refreshing the workload identity token file so the process can exit
|
||||||
// exit) and delete the token material so it doesn't outlive this step
|
|
||||||
workloadIdentity?.stop();
|
workloadIdentity?.stop();
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@ -201,9 +201,6 @@ export function parseSdkOptions(options: ClaudeOptions): ParsedSdkOptions {
|
|||||||
// Detect if --json-schema is present (for hasJsonSchema flag)
|
// Detect if --json-schema is present (for hasJsonSchema flag)
|
||||||
const hasJsonSchema = "json-schema" in extraArgs;
|
const hasJsonSchema = "json-schema" in extraArgs;
|
||||||
|
|
||||||
const modelFromClaudeArgs = extraArgs["model"] || undefined;
|
|
||||||
delete extraArgs["model"];
|
|
||||||
|
|
||||||
const additionalDirectories = extraArgs["add-dir"]
|
const additionalDirectories = extraArgs["add-dir"]
|
||||||
? extraArgs["add-dir"]
|
? extraArgs["add-dir"]
|
||||||
.split(ACCUMULATE_DELIMITER)
|
.split(ACCUMULATE_DELIMITER)
|
||||||
@ -307,7 +304,7 @@ export function parseSdkOptions(options: ClaudeOptions): ParsedSdkOptions {
|
|||||||
// Build SDK options - use merged tools from both direct options and claudeArgs
|
// Build SDK options - use merged tools from both direct options and claudeArgs
|
||||||
const sdkOptions: SdkOptions = {
|
const sdkOptions: SdkOptions = {
|
||||||
// Direct options from ClaudeOptions inputs
|
// Direct options from ClaudeOptions inputs
|
||||||
model: options.model || modelFromClaudeArgs,
|
model: options.model,
|
||||||
maxTurns: options.maxTurns ? parseInt(options.maxTurns, 10) : undefined,
|
maxTurns: options.maxTurns ? parseInt(options.maxTurns, 10) : undefined,
|
||||||
allowedTools:
|
allowedTools:
|
||||||
mergedAllowedTools.length > 0 ? mergedAllowedTools : undefined,
|
mergedAllowedTools.length > 0 ? mergedAllowedTools : undefined,
|
||||||
|
|||||||
@ -208,10 +208,7 @@ export async function runClaudeWithSdk(
|
|||||||
throw new Error("No result message received from Claude");
|
throw new Error("No result message received from Claude");
|
||||||
}
|
}
|
||||||
|
|
||||||
// subtype "success" with is_error:true means the run errored without producing
|
const isSuccess = resultMessage.subtype === "success";
|
||||||
// a real result — treat it as failure so CI does not show a misleading green check.
|
|
||||||
const isSuccess =
|
|
||||||
resultMessage.subtype === "success" && !resultMessage.is_error;
|
|
||||||
result.conclusion = isSuccess ? "success" : "failure";
|
result.conclusion = isSuccess ? "success" : "failure";
|
||||||
|
|
||||||
// Handle structured output
|
// Handle structured output
|
||||||
@ -237,21 +234,14 @@ export async function runClaudeWithSdk(
|
|||||||
}
|
}
|
||||||
|
|
||||||
if (!isSuccess) {
|
if (!isSuccess) {
|
||||||
if (resultMessage.subtype === "success" && resultMessage.is_error) {
|
|
||||||
core.error(
|
|
||||||
"Claude result reported subtype success with is_error:true (run did not complete successfully)",
|
|
||||||
);
|
|
||||||
}
|
|
||||||
if ("errors" in resultMessage && resultMessage.errors) {
|
if ("errors" in resultMessage && resultMessage.errors) {
|
||||||
core.error(`Execution failed: ${resultMessage.errors.join(", ")}`);
|
core.error(`Execution failed: ${resultMessage.errors.join(", ")}`);
|
||||||
}
|
}
|
||||||
throw new Error(
|
throw new Error(
|
||||||
`Claude execution failed: ${
|
`Claude execution failed: ${
|
||||||
resultMessage.subtype === "success" && resultMessage.is_error
|
"errors" in resultMessage && resultMessage.errors
|
||||||
? "result is_error:true"
|
? resultMessage.errors.join(", ")
|
||||||
: "errors" in resultMessage && resultMessage.errors
|
: "unknown error"
|
||||||
? resultMessage.errors.join(", ")
|
|
||||||
: "unknown error"
|
|
||||||
}`,
|
}`,
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@ -15,8 +15,7 @@
|
|||||||
*/
|
*/
|
||||||
|
|
||||||
import * as core from "@actions/core";
|
import * as core from "@actions/core";
|
||||||
import { createHash } from "crypto";
|
import { mkdirSync, writeFileSync } from "fs";
|
||||||
import { mkdirSync, rmSync, writeFileSync } from "fs";
|
|
||||||
import { join } from "path";
|
import { join } from "path";
|
||||||
import { retryWithBackoff } from "./retry";
|
import { retryWithBackoff } from "./retry";
|
||||||
|
|
||||||
@ -51,63 +50,6 @@ async function fetchIdentityToken(audience: string) {
|
|||||||
return retryWithBackoff(() => core.getIDToken(audience));
|
return retryWithBackoff(() => core.getIDToken(audience));
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
|
||||||
* Writes a profile config that switches federation resolution to the
|
|
||||||
* file-backed path. Resolving federation through a profile (rather than bare
|
|
||||||
* env vars) enables the SDK's on-disk credentials cache, so the several
|
|
||||||
* `claude` processes the action spawns (plugin installs, main query) share
|
|
||||||
* one exchanged access token instead of each re-exchanging the single-use
|
|
||||||
* GitHub OIDC token, which fails with 401 (`jti_reused`).
|
|
||||||
*
|
|
||||||
* The profile is intentionally minimal: the SDK gap-fills the federation
|
|
||||||
* fields (rule, organization, identity-token file, service account, base URL)
|
|
||||||
* from the ANTHROPIC_* env vars the action already exports, so the file only
|
|
||||||
* needs to exist to turn the cache on.
|
|
||||||
*
|
|
||||||
* The config dir name embeds a fingerprint of the federation inputs. The
|
|
||||||
* SDK's cache reuses a token on `expires_at` alone, with no record of the
|
|
||||||
* config that minted it, and the token's scope is bound at mint time — so a
|
|
||||||
* later action step in the same job (RUNNER_TEMP is per-job) with different
|
|
||||||
* federation inputs must land in a different dir or it would silently reuse
|
|
||||||
* the first step's token.
|
|
||||||
*
|
|
||||||
* Sharing the cache is only safe while the action spawns its `claude`
|
|
||||||
* subprocesses sequentially: the SDK cache is not cross-process serialized,
|
|
||||||
* and concurrent cache misses would each re-exchange the same single-use
|
|
||||||
* identity token. Parallelizing the plugin installs would reintroduce the
|
|
||||||
* `jti_reused` failures.
|
|
||||||
*/
|
|
||||||
function writeFederationProfile(baseDir: string): string {
|
|
||||||
// Every input that changes which credential the exchange mints must be in
|
|
||||||
// here; service_account_id and scope are sent in the exchange request body.
|
|
||||||
const fingerprint = createHash("sha256")
|
|
||||||
.update(
|
|
||||||
JSON.stringify([
|
|
||||||
process.env.ANTHROPIC_FEDERATION_RULE_ID?.trim() ?? "",
|
|
||||||
process.env.ANTHROPIC_ORGANIZATION_ID?.trim() ?? "",
|
|
||||||
process.env.ANTHROPIC_SERVICE_ACCOUNT_ID?.trim() ?? "",
|
|
||||||
process.env.ANTHROPIC_WORKSPACE_ID?.trim() ?? "",
|
|
||||||
process.env.ANTHROPIC_BASE_URL?.trim() ?? "",
|
|
||||||
process.env.ANTHROPIC_SCOPE?.trim() ?? "",
|
|
||||||
]),
|
|
||||||
)
|
|
||||||
.digest("hex")
|
|
||||||
.slice(0, 16);
|
|
||||||
const configDir = join(baseDir, `config-${fingerprint}`);
|
|
||||||
|
|
||||||
mkdirSync(join(configDir, "configs"), { recursive: true, mode: 0o700 });
|
|
||||||
writeFileSync(
|
|
||||||
join(configDir, "configs", "default.json"),
|
|
||||||
JSON.stringify(
|
|
||||||
{ version: "1.0", authentication: { type: "oidc_federation" } },
|
|
||||||
null,
|
|
||||||
2,
|
|
||||||
),
|
|
||||||
{ mode: 0o600 },
|
|
||||||
);
|
|
||||||
return configDir;
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Fetches a GitHub Actions OIDC token, writes it to a file in RUNNER_TEMP,
|
* Fetches a GitHub Actions OIDC token, writes it to a file in RUNNER_TEMP,
|
||||||
* exports ANTHROPIC_IDENTITY_TOKEN_FILE, and starts a background refresh so
|
* exports ANTHROPIC_IDENTITY_TOKEN_FILE, and starts a background refresh so
|
||||||
@ -115,8 +57,7 @@ function writeFederationProfile(baseDir: string): string {
|
|||||||
*
|
*
|
||||||
* Returns undefined when federation is not configured or is shadowed by a
|
* Returns undefined when federation is not configured or is shadowed by a
|
||||||
* higher-precedence credential. Callers must invoke stop() when execution
|
* higher-precedence credential. Callers must invoke stop() when execution
|
||||||
* finishes; it also deletes the identity token and any cached exchanged
|
* finishes.
|
||||||
* credential.
|
|
||||||
*/
|
*/
|
||||||
export async function setupWorkloadIdentity(): Promise<
|
export async function setupWorkloadIdentity(): Promise<
|
||||||
WorkloadIdentityHandle | undefined
|
WorkloadIdentityHandle | undefined
|
||||||
@ -160,17 +101,6 @@ export async function setupWorkloadIdentity(): Promise<
|
|||||||
}
|
}
|
||||||
|
|
||||||
process.env.ANTHROPIC_IDENTITY_TOKEN_FILE = tokenFile;
|
process.env.ANTHROPIC_IDENTITY_TOKEN_FILE = tokenFile;
|
||||||
if (
|
|
||||||
process.env.ANTHROPIC_CONFIG_DIR?.trim() ||
|
|
||||||
process.env.ANTHROPIC_PROFILE?.trim()
|
|
||||||
) {
|
|
||||||
core.warning(
|
|
||||||
"ANTHROPIC_CONFIG_DIR or ANTHROPIC_PROFILE is already set, so the action will not write its own federation profile. Credential caching across the spawned Claude processes follows the existing profile configuration.",
|
|
||||||
);
|
|
||||||
} else {
|
|
||||||
process.env.ANTHROPIC_CONFIG_DIR = writeFederationProfile(tokenDir);
|
|
||||||
process.env.ANTHROPIC_PROFILE = "default";
|
|
||||||
}
|
|
||||||
console.log(
|
console.log(
|
||||||
`Workload identity federation configured (rule: ${process.env.ANTHROPIC_FEDERATION_RULE_ID}, identity token file: ${tokenFile})`,
|
`Workload identity federation configured (rule: ${process.env.ANTHROPIC_FEDERATION_RULE_ID}, identity token file: ${tokenFile})`,
|
||||||
);
|
);
|
||||||
@ -185,12 +115,6 @@ export async function setupWorkloadIdentity(): Promise<
|
|||||||
|
|
||||||
return {
|
return {
|
||||||
tokenFile,
|
tokenFile,
|
||||||
stop: () => {
|
stop: () => clearInterval(refreshInterval),
|
||||||
clearInterval(refreshInterval);
|
|
||||||
// RUNNER_TEMP is per-job, not per-step: remove the identity token, the
|
|
||||||
// profile, and the cached exchanged credential so they don't outlive
|
|
||||||
// this step.
|
|
||||||
rmSync(tokenDir, { recursive: true, force: true });
|
|
||||||
},
|
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|||||||
@ -106,8 +106,7 @@ describe("parseSdkOptions", () => {
|
|||||||
const result = parseSdkOptions(options);
|
const result = parseSdkOptions(options);
|
||||||
|
|
||||||
expect(result.sdkOptions.extraArgs?.["allowedTools"]).toBeUndefined();
|
expect(result.sdkOptions.extraArgs?.["allowedTools"]).toBeUndefined();
|
||||||
expect(result.sdkOptions.extraArgs?.["model"]).toBeUndefined();
|
expect(result.sdkOptions.extraArgs?.["model"]).toBe("claude-3-5-sonnet");
|
||||||
expect(result.sdkOptions.model).toBe("claude-3-5-sonnet");
|
|
||||||
});
|
});
|
||||||
|
|
||||||
test("should handle hyphenated --allowed-tools flag", () => {
|
test("should handle hyphenated --allowed-tools flag", () => {
|
||||||
@ -367,8 +366,7 @@ describe("parseSdkOptions", () => {
|
|||||||
);
|
);
|
||||||
expect(mcpConfig.mcpServers).toHaveProperty("server1");
|
expect(mcpConfig.mcpServers).toHaveProperty("server1");
|
||||||
expect(mcpConfig.mcpServers).toHaveProperty("server2");
|
expect(mcpConfig.mcpServers).toHaveProperty("server2");
|
||||||
expect(result.sdkOptions.extraArgs?.["model"]).toBeUndefined();
|
expect(result.sdkOptions.extraArgs?.["model"]).toBe("claude-3-5-sonnet");
|
||||||
expect(result.sdkOptions.model).toBe("claude-3-5-sonnet");
|
|
||||||
});
|
});
|
||||||
|
|
||||||
test("should handle real-world scenario: action config + user config", () => {
|
test("should handle real-world scenario: action config + user config", () => {
|
||||||
@ -438,8 +436,7 @@ describe("parseSdkOptions", () => {
|
|||||||
const result = parseSdkOptions(options);
|
const result = parseSdkOptions(options);
|
||||||
|
|
||||||
expect(result.sdkOptions.additionalDirectories).toEqual(["/path/to/dir"]);
|
expect(result.sdkOptions.additionalDirectories).toEqual(["/path/to/dir"]);
|
||||||
expect(result.sdkOptions.extraArgs?.["model"]).toBeUndefined();
|
expect(result.sdkOptions.extraArgs?.["model"]).toBe("claude-3-5-sonnet");
|
||||||
expect(result.sdkOptions.model).toBe("claude-3-5-sonnet");
|
|
||||||
expect(result.sdkOptions.extraArgs?.["add-dir"]).toBeUndefined();
|
expect(result.sdkOptions.extraArgs?.["add-dir"]).toBeUndefined();
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
@ -467,8 +464,7 @@ describe("parseSdkOptions", () => {
|
|||||||
|
|
||||||
const result = parseSdkOptions(options);
|
const result = parseSdkOptions(options);
|
||||||
|
|
||||||
expect(result.sdkOptions.extraArgs?.["model"]).toBeUndefined();
|
expect(result.sdkOptions.extraArgs?.["model"]).toBe("claude-haiku");
|
||||||
expect(result.sdkOptions.model).toBe("claude-haiku");
|
|
||||||
expect(result.sdkOptions.allowedTools).toEqual(["Edit"]);
|
expect(result.sdkOptions.allowedTools).toEqual(["Edit"]);
|
||||||
});
|
});
|
||||||
|
|
||||||
@ -479,8 +475,7 @@ describe("parseSdkOptions", () => {
|
|||||||
|
|
||||||
const result = parseSdkOptions(options);
|
const result = parseSdkOptions(options);
|
||||||
|
|
||||||
expect(result.sdkOptions.extraArgs?.["model"]).toBeUndefined();
|
expect(result.sdkOptions.extraArgs?.["model"]).toBe("claude-haiku");
|
||||||
expect(result.sdkOptions.model).toBe("claude-haiku");
|
|
||||||
});
|
});
|
||||||
|
|
||||||
test("should not strip inline # that appears inside a quoted value", () => {
|
test("should not strip inline # that appears inside a quoted value", () => {
|
||||||
@ -490,37 +485,11 @@ describe("parseSdkOptions", () => {
|
|||||||
|
|
||||||
const result = parseSdkOptions(options);
|
const result = parseSdkOptions(options);
|
||||||
|
|
||||||
expect(result.sdkOptions.extraArgs?.["model"]).toBeUndefined();
|
expect(result.sdkOptions.extraArgs?.["model"]).toBe("claude-haiku");
|
||||||
expect(result.sdkOptions.model).toBe("claude-haiku");
|
|
||||||
expect(result.sdkOptions.extraArgs?.["prompt"]).toBe("use color #ff0000");
|
expect(result.sdkOptions.extraArgs?.["prompt"]).toBe("use color #ff0000");
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
describe("model handling", () => {
|
|
||||||
test("should map --model from claudeArgs to sdkOptions.model", () => {
|
|
||||||
const options: ClaudeOptions = {
|
|
||||||
claudeArgs: "--model claude-haiku-4-5-20251001",
|
|
||||||
};
|
|
||||||
|
|
||||||
const result = parseSdkOptions(options);
|
|
||||||
|
|
||||||
expect(result.sdkOptions.model).toBe("claude-haiku-4-5-20251001");
|
|
||||||
expect(result.sdkOptions.extraArgs?.["model"]).toBeUndefined();
|
|
||||||
});
|
|
||||||
|
|
||||||
test("should prefer direct model option over --model from claudeArgs", () => {
|
|
||||||
const options: ClaudeOptions = {
|
|
||||||
model: "claude-sonnet-4-6",
|
|
||||||
claudeArgs: "--model claude-haiku-4-5-20251001",
|
|
||||||
};
|
|
||||||
|
|
||||||
const result = parseSdkOptions(options);
|
|
||||||
|
|
||||||
expect(result.sdkOptions.model).toBe("claude-sonnet-4-6");
|
|
||||||
expect(result.sdkOptions.extraArgs?.["model"]).toBeUndefined();
|
|
||||||
});
|
|
||||||
});
|
|
||||||
|
|
||||||
describe("environment variables passthrough", () => {
|
describe("environment variables passthrough", () => {
|
||||||
test("should include OTEL environment variables in sdkOptions.env", () => {
|
test("should include OTEL environment variables in sdkOptions.env", () => {
|
||||||
// Set up test environment variables
|
// Set up test environment variables
|
||||||
|
|||||||
@ -63,69 +63,4 @@ describe("runClaudeWithSdk", () => {
|
|||||||
consoleLogSpy.mockRestore();
|
consoleLogSpy.mockRestore();
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
test("fails when result subtype is success but is_error is true", async () => {
|
|
||||||
const consoleErrorSpy = spyOn(console, "error").mockImplementation(
|
|
||||||
() => {},
|
|
||||||
);
|
|
||||||
const consoleLogSpy = spyOn(console, "log").mockImplementation(() => {});
|
|
||||||
const coreErrorSpy = spyOn(
|
|
||||||
await import("@actions/core"),
|
|
||||||
"error",
|
|
||||||
).mockImplementation(() => {});
|
|
||||||
|
|
||||||
tempDir = await mkdtemp(join(tmpdir(), "claude-sdk-"));
|
|
||||||
process.env.RUNNER_TEMP = tempDir;
|
|
||||||
|
|
||||||
const promptPath = join(tempDir, "prompt.txt");
|
|
||||||
await writeFile(promptPath, "test prompt");
|
|
||||||
|
|
||||||
const initMessage = {
|
|
||||||
type: "system",
|
|
||||||
subtype: "init",
|
|
||||||
session_id: "session-123",
|
|
||||||
model: "claude-sonnet-5",
|
|
||||||
};
|
|
||||||
|
|
||||||
const errorResultMessage = {
|
|
||||||
type: "result",
|
|
||||||
subtype: "success",
|
|
||||||
is_error: true,
|
|
||||||
duration_ms: 434,
|
|
||||||
num_turns: 1,
|
|
||||||
total_cost_usd: 0,
|
|
||||||
permission_denials: [],
|
|
||||||
};
|
|
||||||
|
|
||||||
mock.module("@anthropic-ai/claude-agent-sdk", () => ({
|
|
||||||
query: async function* () {
|
|
||||||
yield initMessage;
|
|
||||||
yield errorResultMessage;
|
|
||||||
},
|
|
||||||
}));
|
|
||||||
|
|
||||||
try {
|
|
||||||
const { runClaudeWithSdk } = await import("../src/run-claude-sdk");
|
|
||||||
|
|
||||||
await expect(
|
|
||||||
runClaudeWithSdk(promptPath, {
|
|
||||||
sdkOptions: {},
|
|
||||||
showFullOutput: false,
|
|
||||||
hasJsonSchema: false,
|
|
||||||
}),
|
|
||||||
).rejects.toThrow("result is_error:true");
|
|
||||||
|
|
||||||
const executionFile = join(tempDir, "claude-execution-output.json");
|
|
||||||
await expect(readFile(executionFile, "utf-8")).resolves.toBe(
|
|
||||||
JSON.stringify([initMessage, errorResultMessage], null, 2),
|
|
||||||
);
|
|
||||||
expect(coreErrorSpy).toHaveBeenCalledWith(
|
|
||||||
"Claude result reported subtype success with is_error:true (run did not complete successfully)",
|
|
||||||
);
|
|
||||||
} finally {
|
|
||||||
consoleErrorSpy.mockRestore();
|
|
||||||
consoleLogSpy.mockRestore();
|
|
||||||
coreErrorSpy.mockRestore();
|
|
||||||
}
|
|
||||||
});
|
|
||||||
});
|
});
|
||||||
|
|||||||
@ -2,14 +2,7 @@
|
|||||||
|
|
||||||
import { describe, test, expect, beforeEach, afterEach, spyOn } from "bun:test";
|
import { describe, test, expect, beforeEach, afterEach, spyOn } from "bun:test";
|
||||||
import * as core from "@actions/core";
|
import * as core from "@actions/core";
|
||||||
import {
|
import { existsSync, mkdtempSync, readFileSync, rmSync, statSync } from "fs";
|
||||||
existsSync,
|
|
||||||
mkdtempSync,
|
|
||||||
readdirSync,
|
|
||||||
readFileSync,
|
|
||||||
rmSync,
|
|
||||||
statSync,
|
|
||||||
} from "fs";
|
|
||||||
import { tmpdir } from "os";
|
import { tmpdir } from "os";
|
||||||
import { join } from "path";
|
import { join } from "path";
|
||||||
import {
|
import {
|
||||||
@ -34,12 +27,6 @@ describe("workload identity federation", () => {
|
|||||||
delete process.env.ANTHROPIC_ORGANIZATION_ID;
|
delete process.env.ANTHROPIC_ORGANIZATION_ID;
|
||||||
delete process.env.ANTHROPIC_OIDC_AUDIENCE;
|
delete process.env.ANTHROPIC_OIDC_AUDIENCE;
|
||||||
delete process.env.ANTHROPIC_IDENTITY_TOKEN_FILE;
|
delete process.env.ANTHROPIC_IDENTITY_TOKEN_FILE;
|
||||||
delete process.env.ANTHROPIC_SERVICE_ACCOUNT_ID;
|
|
||||||
delete process.env.ANTHROPIC_WORKSPACE_ID;
|
|
||||||
delete process.env.ANTHROPIC_BASE_URL;
|
|
||||||
delete process.env.ANTHROPIC_SCOPE;
|
|
||||||
delete process.env.ANTHROPIC_CONFIG_DIR;
|
|
||||||
delete process.env.ANTHROPIC_PROFILE;
|
|
||||||
|
|
||||||
getIDTokenSpy = spyOn(core, "getIDToken").mockResolvedValue(
|
getIDTokenSpy = spyOn(core, "getIDToken").mockResolvedValue(
|
||||||
"test-identity-token",
|
"test-identity-token",
|
||||||
@ -136,123 +123,5 @@ describe("workload identity federation", () => {
|
|||||||
handle?.stop();
|
handle?.stop();
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
test("writes a minimal federation profile and selects it", async () => {
|
|
||||||
process.env.ANTHROPIC_FEDERATION_RULE_ID = "fdrl_test";
|
|
||||||
process.env.ANTHROPIC_ORGANIZATION_ID =
|
|
||||||
"00000000-0000-0000-0000-000000000000";
|
|
||||||
process.env.ANTHROPIC_SERVICE_ACCOUNT_ID = "svac_test";
|
|
||||||
process.env.ANTHROPIC_WORKSPACE_ID = "wrkspc_test";
|
|
||||||
|
|
||||||
const handle = await setupWorkloadIdentity();
|
|
||||||
try {
|
|
||||||
const configDir = process.env.ANTHROPIC_CONFIG_DIR;
|
|
||||||
expect(configDir).toBeDefined();
|
|
||||||
expect(
|
|
||||||
configDir!.startsWith(
|
|
||||||
join(tempDir, "claude-workload-identity", "config-"),
|
|
||||||
),
|
|
||||||
).toBe(true);
|
|
||||||
expect(process.env.ANTHROPIC_PROFILE).toBe("default");
|
|
||||||
|
|
||||||
const profilePath = join(configDir!, "configs", "default.json");
|
|
||||||
expect(statSync(profilePath).mode & 0o777).toBe(0o600);
|
|
||||||
// Minimal on purpose: the SDK gap-fills the federation fields from
|
|
||||||
// the ANTHROPIC_* env vars the action exports.
|
|
||||||
expect(JSON.parse(readFileSync(profilePath, "utf-8"))).toEqual({
|
|
||||||
version: "1.0",
|
|
||||||
authentication: { type: "oidc_federation" },
|
|
||||||
});
|
|
||||||
} finally {
|
|
||||||
handle?.stop();
|
|
||||||
}
|
|
||||||
});
|
|
||||||
|
|
||||||
test("derives the config dir from the federation inputs", async () => {
|
|
||||||
process.env.ANTHROPIC_FEDERATION_RULE_ID = "fdrl_test";
|
|
||||||
process.env.ANTHROPIC_ORGANIZATION_ID =
|
|
||||||
"00000000-0000-0000-0000-000000000000";
|
|
||||||
process.env.ANTHROPIC_WORKSPACE_ID = "wrkspc_a";
|
|
||||||
|
|
||||||
(await setupWorkloadIdentity())?.stop();
|
|
||||||
const firstConfigDir = process.env.ANTHROPIC_CONFIG_DIR;
|
|
||||||
expect(firstConfigDir).toBeDefined();
|
|
||||||
|
|
||||||
// A later step in the same job with a different workspace must not
|
|
||||||
// share the first step's credentials cache.
|
|
||||||
delete process.env.ANTHROPIC_CONFIG_DIR;
|
|
||||||
delete process.env.ANTHROPIC_PROFILE;
|
|
||||||
process.env.ANTHROPIC_WORKSPACE_ID = "wrkspc_b";
|
|
||||||
|
|
||||||
(await setupWorkloadIdentity())?.stop();
|
|
||||||
const secondConfigDir = process.env.ANTHROPIC_CONFIG_DIR;
|
|
||||||
expect(secondConfigDir).toBeDefined();
|
|
||||||
expect(secondConfigDir).not.toBe(firstConfigDir);
|
|
||||||
|
|
||||||
// Same inputs land in the same dir, so an unchanged config can still
|
|
||||||
// reuse a cached token.
|
|
||||||
delete process.env.ANTHROPIC_CONFIG_DIR;
|
|
||||||
delete process.env.ANTHROPIC_PROFILE;
|
|
||||||
|
|
||||||
(await setupWorkloadIdentity())?.stop();
|
|
||||||
expect(process.env.ANTHROPIC_CONFIG_DIR).toBe(secondConfigDir!);
|
|
||||||
});
|
|
||||||
|
|
||||||
test("does not overwrite an operator-set ANTHROPIC_PROFILE", async () => {
|
|
||||||
process.env.ANTHROPIC_FEDERATION_RULE_ID = "fdrl_test";
|
|
||||||
process.env.ANTHROPIC_ORGANIZATION_ID =
|
|
||||||
"00000000-0000-0000-0000-000000000000";
|
|
||||||
process.env.ANTHROPIC_PROFILE = "operator";
|
|
||||||
|
|
||||||
const handle = await setupWorkloadIdentity();
|
|
||||||
try {
|
|
||||||
expect(process.env.ANTHROPIC_PROFILE).toBe("operator");
|
|
||||||
expect(process.env.ANTHROPIC_CONFIG_DIR).toBeUndefined();
|
|
||||||
expect(warningSpy).toHaveBeenCalled();
|
|
||||||
|
|
||||||
const entries = readdirSync(join(tempDir, "claude-workload-identity"));
|
|
||||||
expect(entries.filter((e) => e.startsWith("config-"))).toEqual([]);
|
|
||||||
|
|
||||||
// The identity token file is still provisioned for the operator's
|
|
||||||
// profile (or the env-var fallback) to consume.
|
|
||||||
expect(process.env.ANTHROPIC_IDENTITY_TOKEN_FILE).toBe(
|
|
||||||
handle!.tokenFile,
|
|
||||||
);
|
|
||||||
} finally {
|
|
||||||
handle?.stop();
|
|
||||||
}
|
|
||||||
});
|
|
||||||
|
|
||||||
test("does not overwrite an operator-set ANTHROPIC_CONFIG_DIR", async () => {
|
|
||||||
process.env.ANTHROPIC_FEDERATION_RULE_ID = "fdrl_test";
|
|
||||||
process.env.ANTHROPIC_ORGANIZATION_ID =
|
|
||||||
"00000000-0000-0000-0000-000000000000";
|
|
||||||
const operatorConfigDir = join(tempDir, "operator-config");
|
|
||||||
process.env.ANTHROPIC_CONFIG_DIR = operatorConfigDir;
|
|
||||||
|
|
||||||
const handle = await setupWorkloadIdentity();
|
|
||||||
try {
|
|
||||||
expect(process.env.ANTHROPIC_CONFIG_DIR).toBe(operatorConfigDir);
|
|
||||||
expect(process.env.ANTHROPIC_PROFILE).toBeUndefined();
|
|
||||||
expect(warningSpy).toHaveBeenCalled();
|
|
||||||
} finally {
|
|
||||||
handle?.stop();
|
|
||||||
}
|
|
||||||
});
|
|
||||||
|
|
||||||
test("stop removes the identity token and credential cache", async () => {
|
|
||||||
process.env.ANTHROPIC_FEDERATION_RULE_ID = "fdrl_test";
|
|
||||||
process.env.ANTHROPIC_ORGANIZATION_ID =
|
|
||||||
"00000000-0000-0000-0000-000000000000";
|
|
||||||
|
|
||||||
const handle = await setupWorkloadIdentity();
|
|
||||||
const tokenDir = join(tempDir, "claude-workload-identity");
|
|
||||||
expect(existsSync(handle!.tokenFile)).toBe(true);
|
|
||||||
expect(existsSync(process.env.ANTHROPIC_CONFIG_DIR!)).toBe(true);
|
|
||||||
|
|
||||||
handle!.stop();
|
|
||||||
|
|
||||||
expect(existsSync(tokenDir)).toBe(false);
|
|
||||||
});
|
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|||||||
20
bun.lock
20
bun.lock
@ -7,7 +7,7 @@
|
|||||||
"dependencies": {
|
"dependencies": {
|
||||||
"@actions/core": "^1.10.1",
|
"@actions/core": "^1.10.1",
|
||||||
"@actions/github": "^6.0.1",
|
"@actions/github": "^6.0.1",
|
||||||
"@anthropic-ai/claude-agent-sdk": "^0.3.221",
|
"@anthropic-ai/claude-agent-sdk": "^0.3.206",
|
||||||
"@modelcontextprotocol/sdk": "^1.11.0",
|
"@modelcontextprotocol/sdk": "^1.11.0",
|
||||||
"@octokit/graphql": "^8.2.2",
|
"@octokit/graphql": "^8.2.2",
|
||||||
"@octokit/rest": "^21.1.1",
|
"@octokit/rest": "^21.1.1",
|
||||||
@ -37,23 +37,23 @@
|
|||||||
|
|
||||||
"@actions/io": ["@actions/io@1.1.3", "", {}, "sha512-wi9JjgKLYS7U/z8PPbco+PvTb/nRWjeoFlJ1Qer83k/3C5PHQi28hiVdeE2kHXmIL99mQFawx8qt/JPjZilJ8Q=="],
|
"@actions/io": ["@actions/io@1.1.3", "", {}, "sha512-wi9JjgKLYS7U/z8PPbco+PvTb/nRWjeoFlJ1Qer83k/3C5PHQi28hiVdeE2kHXmIL99mQFawx8qt/JPjZilJ8Q=="],
|
||||||
|
|
||||||
"@anthropic-ai/claude-agent-sdk": ["@anthropic-ai/claude-agent-sdk@0.3.221", "", { "optionalDependencies": { "@anthropic-ai/claude-agent-sdk-darwin-arm64": "0.3.221", "@anthropic-ai/claude-agent-sdk-darwin-x64": "0.3.221", "@anthropic-ai/claude-agent-sdk-linux-arm64": "0.3.221", "@anthropic-ai/claude-agent-sdk-linux-arm64-musl": "0.3.221", "@anthropic-ai/claude-agent-sdk-linux-x64": "0.3.221", "@anthropic-ai/claude-agent-sdk-linux-x64-musl": "0.3.221", "@anthropic-ai/claude-agent-sdk-win32-arm64": "0.3.221", "@anthropic-ai/claude-agent-sdk-win32-x64": "0.3.221" }, "peerDependencies": { "@anthropic-ai/sdk": ">=0.93.0", "@modelcontextprotocol/sdk": "^1.29.0", "zod": "^4.0.0" } }, "sha512-qOXebuNlK5hKkYmXSdSNJHDw4ulAJYQ7hdGuOmv5pXtaRCvfkIRlNGKoiFjGDq55n8aneAp3/BIzAVcxSQln+g=="],
|
"@anthropic-ai/claude-agent-sdk": ["@anthropic-ai/claude-agent-sdk@0.3.206", "", { "optionalDependencies": { "@anthropic-ai/claude-agent-sdk-darwin-arm64": "0.3.206", "@anthropic-ai/claude-agent-sdk-darwin-x64": "0.3.206", "@anthropic-ai/claude-agent-sdk-linux-arm64": "0.3.206", "@anthropic-ai/claude-agent-sdk-linux-arm64-musl": "0.3.206", "@anthropic-ai/claude-agent-sdk-linux-x64": "0.3.206", "@anthropic-ai/claude-agent-sdk-linux-x64-musl": "0.3.206", "@anthropic-ai/claude-agent-sdk-win32-arm64": "0.3.206", "@anthropic-ai/claude-agent-sdk-win32-x64": "0.3.206" }, "peerDependencies": { "@anthropic-ai/sdk": ">=0.93.0", "@modelcontextprotocol/sdk": "^1.29.0", "zod": "^4.0.0" } }, "sha512-KljDh9Pg4YCYpoXS8dnWoVSsOHtU4yLCW268K2iOruSxFXE8/Tay6DPvmJzYuqjP5YLNYfj05ZGykwZSUn6GXA=="],
|
||||||
|
|
||||||
"@anthropic-ai/claude-agent-sdk-darwin-arm64": ["@anthropic-ai/claude-agent-sdk-darwin-arm64@0.3.221", "", { "os": "darwin", "cpu": "arm64" }, "sha512-/J4MnpoOqJqrIjH6c6GQAB6Tlcp+kvTEWSM9+5yCkGF2cUmXgaQk7eJlu05ldp+GuSS5AVKq5goIjhYFgY3+Sg=="],
|
"@anthropic-ai/claude-agent-sdk-darwin-arm64": ["@anthropic-ai/claude-agent-sdk-darwin-arm64@0.3.206", "", { "os": "darwin", "cpu": "arm64" }, "sha512-FL2+NKcMMN47vcnCW2Fkt3AOgeRRlQxrisbPNaxrxqPJFzhUKs17x5j0XzLefd0xRbDAr74hd0PK/tnp6PHM6w=="],
|
||||||
|
|
||||||
"@anthropic-ai/claude-agent-sdk-darwin-x64": ["@anthropic-ai/claude-agent-sdk-darwin-x64@0.3.221", "", { "os": "darwin", "cpu": "x64" }, "sha512-je33rwPBGEHDoW/kMzoMw/Pg4mcRfvzirkhEx/rykAK4/w0t3Czwt5DeF0HXVQzW+M8bXPN1486wMxONNFLQMw=="],
|
"@anthropic-ai/claude-agent-sdk-darwin-x64": ["@anthropic-ai/claude-agent-sdk-darwin-x64@0.3.206", "", { "os": "darwin", "cpu": "x64" }, "sha512-mRW8PPMfQN15EunLwpdmcVzk3XuM4DXQUM8DOzaeA1Hr1yxYPaVVBLr9hkdBKOqr8XTl3ueoTR6RZAy6a/n7OA=="],
|
||||||
|
|
||||||
"@anthropic-ai/claude-agent-sdk-linux-arm64": ["@anthropic-ai/claude-agent-sdk-linux-arm64@0.3.221", "", { "os": "linux", "cpu": "arm64" }, "sha512-6HhD/5poDNEpITda9NyTmqwrxaFi4dn5nROLCVC1BG4IhEHfeBIVocr1TAgTy7RjoScr3RHl9SA9OFpUMzuifQ=="],
|
"@anthropic-ai/claude-agent-sdk-linux-arm64": ["@anthropic-ai/claude-agent-sdk-linux-arm64@0.3.206", "", { "os": "linux", "cpu": "arm64" }, "sha512-Id6H8l6EsGb7849EAZDOB4Ic+FQpbzt4D5HGOwp59CTW3o/1c4etjQA/Kl1K+DSEWn3FGo6D5UMVgc/rwhBj8g=="],
|
||||||
|
|
||||||
"@anthropic-ai/claude-agent-sdk-linux-arm64-musl": ["@anthropic-ai/claude-agent-sdk-linux-arm64-musl@0.3.221", "", { "os": "linux", "cpu": "arm64" }, "sha512-Gi84q2ULzWhduMoP69NGwMpo9AibXcO+PtbF0eHCUJAQ/qUu0PxcrTg6S73WotQ2qAP8LhtN6a8x+8EszU64MA=="],
|
"@anthropic-ai/claude-agent-sdk-linux-arm64-musl": ["@anthropic-ai/claude-agent-sdk-linux-arm64-musl@0.3.206", "", { "os": "linux", "cpu": "arm64" }, "sha512-aMZe1Kl+kYv5QlA15W9Ae25MAzBsA9FA40f5TOtJebA+M/xliF0r2LLb2NdyBviiZCDllcE31l0zgYE0rQqFQw=="],
|
||||||
|
|
||||||
"@anthropic-ai/claude-agent-sdk-linux-x64": ["@anthropic-ai/claude-agent-sdk-linux-x64@0.3.221", "", { "os": "linux", "cpu": "x64" }, "sha512-pPrpl84Pe563tLci6ndBuBK6IY85RKhcAAJMU73/VPB240yiSnnPaxqKTEgXnw7qbAFYTQ1TOasum/awUs4BdQ=="],
|
"@anthropic-ai/claude-agent-sdk-linux-x64": ["@anthropic-ai/claude-agent-sdk-linux-x64@0.3.206", "", { "os": "linux", "cpu": "x64" }, "sha512-egZhOC1RlEVhZyq6Oa1b04AF7hh4hO+8oCsJCZ3gOifJQuHih1oW3lZ8fOUxU85jlT2ytAnt5kRp4uQr6PJgbg=="],
|
||||||
|
|
||||||
"@anthropic-ai/claude-agent-sdk-linux-x64-musl": ["@anthropic-ai/claude-agent-sdk-linux-x64-musl@0.3.221", "", { "os": "linux", "cpu": "x64" }, "sha512-/tm6HdYV1EnnZJpN9l1yxZGRaE5K+zj0OLycBBHc0CBXtAQ1we/7aMpqX39iJ01c7J5TG9coEJOI/G9K2FhQoQ=="],
|
"@anthropic-ai/claude-agent-sdk-linux-x64-musl": ["@anthropic-ai/claude-agent-sdk-linux-x64-musl@0.3.206", "", { "os": "linux", "cpu": "x64" }, "sha512-xQBOBhlcmTNc7YeYT5qLZOikpSq3WHlsJ/t6i7kJqUWrcXlOPaAoSMdKp5xO/V0CjAdzdJoWB88I55UAh8mclQ=="],
|
||||||
|
|
||||||
"@anthropic-ai/claude-agent-sdk-win32-arm64": ["@anthropic-ai/claude-agent-sdk-win32-arm64@0.3.221", "", { "os": "win32", "cpu": "arm64" }, "sha512-IK2aOH58U7iMM4nAc45B8pcHZhFCUAPml6N8WFBHNr41+hRelI++xEVJpQQk55SyrRQ8HVeCoMeTlp858i9KgQ=="],
|
"@anthropic-ai/claude-agent-sdk-win32-arm64": ["@anthropic-ai/claude-agent-sdk-win32-arm64@0.3.206", "", { "os": "win32", "cpu": "arm64" }, "sha512-oRQk23bFXSz4QRhOxqnnvlLWq/KiF2PtSBYXrMg1AQrCOHJd2k66aOAS7AJ4ZSzejiyV4N6sS6UThfmF6ipHBQ=="],
|
||||||
|
|
||||||
"@anthropic-ai/claude-agent-sdk-win32-x64": ["@anthropic-ai/claude-agent-sdk-win32-x64@0.3.221", "", { "os": "win32", "cpu": "x64" }, "sha512-vqa6DOMfqSr+JoXqc7p6mjqwuTOYQLdXVBVCOwg7HEc/O0JgrFLeC4lCQs8hIkLrO0r/pBwv8KbfmYz1DwIeew=="],
|
"@anthropic-ai/claude-agent-sdk-win32-x64": ["@anthropic-ai/claude-agent-sdk-win32-x64@0.3.206", "", { "os": "win32", "cpu": "x64" }, "sha512-BdjKmDojZjc5RjN+8Q6K7Yqf1WYhel6I3DkMXc9zdxS/xIuN42sx7zgQpdMGY73pm7ROPLqo3LieOeMhVH161w=="],
|
||||||
|
|
||||||
"@anthropic-ai/sdk": ["@anthropic-ai/sdk@0.93.0", "", { "dependencies": { "json-schema-to-ts": "^3.1.1" }, "peerDependencies": { "zod": "^3.25.0 || ^4.0.0" }, "optionalPeers": ["zod"], "bin": { "anthropic-ai-sdk": "bin/cli" } }, "sha512-q9vaSZQVFx6B/gPxetGYfLXSJD5v0sOmh0OpZDq7yCrTSA+Rscvrtyol7JJTW40wEpQB4U1B4JXzxQitbQ3CAA=="],
|
"@anthropic-ai/sdk": ["@anthropic-ai/sdk@0.93.0", "", { "dependencies": { "json-schema-to-ts": "^3.1.1" }, "peerDependencies": { "zod": "^3.25.0 || ^4.0.0" }, "optionalPeers": ["zod"], "bin": { "anthropic-ai-sdk": "bin/cli" } }, "sha512-q9vaSZQVFx6B/gPxetGYfLXSJD5v0sOmh0OpZDq7yCrTSA+Rscvrtyol7JJTW40wEpQB4U1B4JXzxQitbQ3CAA=="],
|
||||||
|
|
||||||
|
|||||||
@ -337,17 +337,17 @@ For a complete list of available settings and their descriptions, see the [Claud
|
|||||||
|
|
||||||
Many individual input parameters have been consolidated into `claude_args` or `settings`. Here's how to migrate:
|
Many individual input parameters have been consolidated into `claude_args` or `settings`. Here's how to migrate:
|
||||||
|
|
||||||
| Old Input | New Approach |
|
| Old Input | New Approach |
|
||||||
| --------------------- | --------------------------------------------------------------- |
|
| --------------------- | -------------------------------------------------------- |
|
||||||
| `allowed_tools` | Use `claude_args: "--allowedTools Tool1,Tool2"` |
|
| `allowed_tools` | Use `claude_args: "--allowedTools Tool1,Tool2"` |
|
||||||
| `disallowed_tools` | Use `claude_args: "--disallowedTools Tool1,Tool2"` |
|
| `disallowed_tools` | Use `claude_args: "--disallowedTools Tool1,Tool2"` |
|
||||||
| `max_turns` | Use `claude_args: "--max-turns 10"` |
|
| `max_turns` | Use `claude_args: "--max-turns 10"` |
|
||||||
| `model` | Use `claude_args: "--model claude-4-0-sonnet-20250805"` |
|
| `model` | Use `claude_args: "--model claude-4-0-sonnet-20250805"` |
|
||||||
| `claude_env` | Use `settings` with `"env"` object |
|
| `claude_env` | Use `settings` with `"env"` object |
|
||||||
| `custom_instructions` | Use `claude_args: "--append-system-prompt 'Your instructions'"` |
|
| `custom_instructions` | Use `claude_args: "--system-prompt 'Your instructions'"` |
|
||||||
| `mcp_config` | Use `claude_args: "--mcp-config '{...}'"` |
|
| `mcp_config` | Use `claude_args: "--mcp-config '{...}'"` |
|
||||||
| `direct_prompt` | Use `prompt` input instead |
|
| `direct_prompt` | Use `prompt` input instead |
|
||||||
| `override_prompt` | Use `prompt` with GitHub context variables |
|
| `override_prompt` | Use `prompt` with GitHub context variables |
|
||||||
|
|
||||||
## Custom Executables for Specialized Environments
|
## Custom Executables for Specialized Environments
|
||||||
|
|
||||||
|
|||||||
@ -26,7 +26,7 @@ This action supports the following GitHub events ([learn more GitHub event trigg
|
|||||||
|
|
||||||
## Automated Documentation Updates
|
## Automated Documentation Updates
|
||||||
|
|
||||||
Automatically update documentation when specific files change (see [`examples/pr-review-filtered-paths.yml`](../examples/pr-review-filtered-paths.yml)):
|
Automatically update documentation when specific files change (see [`examples/claude-pr-path-specific.yml`](../examples/claude-pr-path-specific.yml)):
|
||||||
|
|
||||||
```yaml
|
```yaml
|
||||||
on:
|
on:
|
||||||
@ -47,7 +47,7 @@ When API files are modified, the action automatically detects that a `prompt` is
|
|||||||
|
|
||||||
## Author-Specific Code Reviews
|
## Author-Specific Code Reviews
|
||||||
|
|
||||||
Automatically review PRs from specific authors or external contributors (see [`examples/pr-review-filtered-authors.yml`](../examples/pr-review-filtered-authors.yml)):
|
Automatically review PRs from specific authors or external contributors (see [`examples/claude-review-from-author.yml`](../examples/claude-review-from-author.yml)):
|
||||||
|
|
||||||
```yaml
|
```yaml
|
||||||
on:
|
on:
|
||||||
|
|||||||
@ -153,7 +153,7 @@ prompt: "Review this PR for security vulnerabilities"
|
|||||||
**These inputs are deprecated in v1.0:**
|
**These inputs are deprecated in v1.0:**
|
||||||
|
|
||||||
- **`direct_prompt`** → Use `prompt` instead
|
- **`direct_prompt`** → Use `prompt` instead
|
||||||
- **`custom_instructions`** → Use `claude_args` with `--append-system-prompt` (appends to the default system prompt, matching v0 behavior; `--system-prompt` replaces it entirely)
|
- **`custom_instructions`** → Use `claude_args` with `--system-prompt`
|
||||||
|
|
||||||
Migration examples:
|
Migration examples:
|
||||||
|
|
||||||
@ -165,7 +165,7 @@ custom_instructions: "Focus on security"
|
|||||||
# New (v1.0)
|
# New (v1.0)
|
||||||
prompt: "Review this PR"
|
prompt: "Review this PR"
|
||||||
claude_args: |
|
claude_args: |
|
||||||
--append-system-prompt "Focus on security"
|
--system-prompt "Focus on security"
|
||||||
```
|
```
|
||||||
|
|
||||||
### Why doesn't Claude execute my bash commands?
|
### Why doesn't Claude execute my bash commands?
|
||||||
|
|||||||
@ -14,19 +14,19 @@ This guide helps you migrate from Claude Code Action v0.x to v1.0. The new versi
|
|||||||
|
|
||||||
The following inputs have been deprecated and replaced:
|
The following inputs have been deprecated and replaced:
|
||||||
|
|
||||||
| Deprecated Input | Replacement | Notes |
|
| Deprecated Input | Replacement | Notes |
|
||||||
| --------------------- | ------------------------------------- | ----------------------------------------------------------------------------------- |
|
| --------------------- | ------------------------------------ | --------------------------------------------- |
|
||||||
| `mode` | Auto-detected | Action automatically chooses based on context |
|
| `mode` | Auto-detected | Action automatically chooses based on context |
|
||||||
| `direct_prompt` | `prompt` | Direct drop-in replacement |
|
| `direct_prompt` | `prompt` | Direct drop-in replacement |
|
||||||
| `override_prompt` | `prompt` | Use GitHub context variables instead |
|
| `override_prompt` | `prompt` | Use GitHub context variables instead |
|
||||||
| `custom_instructions` | `claude_args: --append-system-prompt` | Appends to the default prompt (v0 behavior); `--system-prompt` replaces it entirely |
|
| `custom_instructions` | `claude_args: --system-prompt` | Move to CLI arguments |
|
||||||
| `max_turns` | `claude_args: --max-turns` | Use CLI format |
|
| `max_turns` | `claude_args: --max-turns` | Use CLI format |
|
||||||
| `model` | `claude_args: --model` | Specify via CLI |
|
| `model` | `claude_args: --model` | Specify via CLI |
|
||||||
| `allowed_tools` | `claude_args: --allowedTools` | Use CLI format |
|
| `allowed_tools` | `claude_args: --allowedTools` | Use CLI format |
|
||||||
| `disallowed_tools` | `claude_args: --disallowedTools` | Use CLI format |
|
| `disallowed_tools` | `claude_args: --disallowedTools` | Use CLI format |
|
||||||
| `claude_env` | `settings` with env object | Use settings JSON |
|
| `claude_env` | `settings` with env object | Use settings JSON |
|
||||||
| `mcp_config` | `claude_args: --mcp-config` | Pass MCP config via CLI arguments |
|
| `mcp_config` | `claude_args: --mcp-config` | Pass MCP config via CLI arguments |
|
||||||
| `timeout_minutes` | Use GitHub Actions `timeout-minutes` | Configure at job level instead of input level |
|
| `timeout_minutes` | Use GitHub Actions `timeout-minutes` | Configure at job level instead of input level |
|
||||||
|
|
||||||
## Migration Examples
|
## Migration Examples
|
||||||
|
|
||||||
@ -52,7 +52,7 @@ The following inputs have been deprecated and replaced:
|
|||||||
anthropic_api_key: ${{ secrets.ANTHROPIC_API_KEY }}
|
anthropic_api_key: ${{ secrets.ANTHROPIC_API_KEY }}
|
||||||
claude_args: |
|
claude_args: |
|
||||||
--max-turns 10
|
--max-turns 10
|
||||||
--append-system-prompt "Follow our coding standards"
|
--system-prompt "Follow our coding standards"
|
||||||
--allowedTools Edit,Read,Write
|
--allowedTools Edit,Read,Write
|
||||||
```
|
```
|
||||||
|
|
||||||
@ -255,15 +255,14 @@ claude_args: |
|
|||||||
|
|
||||||
### Common claude_args Options
|
### Common claude_args Options
|
||||||
|
|
||||||
| Option | Description | Example |
|
| Option | Description | Example |
|
||||||
| ------------------------ | ------------------------------------------------------------------------- | ------------------------------------------------------ |
|
| ------------------- | ------------------------ | -------------------------------------- |
|
||||||
| `--max-turns` | Limit conversation turns | `--max-turns 10` |
|
| `--max-turns` | Limit conversation turns | `--max-turns 10` |
|
||||||
| `--model` | Specify Claude model | `--model claude-4-0-sonnet-20250805` |
|
| `--model` | Specify Claude model | `--model claude-4-0-sonnet-20250805` |
|
||||||
| `--allowedTools` | Enable specific tools | `--allowedTools Edit,Read,Write` |
|
| `--allowedTools` | Enable specific tools | `--allowedTools Edit,Read,Write` |
|
||||||
| `--disallowedTools` | Disable specific tools | `--disallowedTools WebSearch` |
|
| `--disallowedTools` | Disable specific tools | `--disallowedTools WebSearch` |
|
||||||
| `--system-prompt` | Replace the entire default system prompt | `--system-prompt "Focus on security"` |
|
| `--system-prompt` | Add system instructions | `--system-prompt "Focus on security"` |
|
||||||
| `--append-system-prompt` | Append to the default system prompt (keeps Claude Code's built-in prompt) | `--append-system-prompt "Follow our coding standards"` |
|
| `--mcp-config` | Add MCP server config | `--mcp-config '{"mcpServers": {...}}'` |
|
||||||
| `--mcp-config` | Add MCP server config | `--mcp-config '{"mcpServers": {...}}'` |
|
|
||||||
|
|
||||||
## Provider-Specific Updates
|
## Provider-Specific Updates
|
||||||
|
|
||||||
@ -331,7 +330,7 @@ You can also pass MCP configuration from a file:
|
|||||||
- [ ] Remove `mode` input (auto-detected now)
|
- [ ] Remove `mode` input (auto-detected now)
|
||||||
- [ ] Replace `direct_prompt` with `prompt`
|
- [ ] Replace `direct_prompt` with `prompt`
|
||||||
- [ ] Replace `override_prompt` with `prompt` using GitHub context
|
- [ ] Replace `override_prompt` with `prompt` using GitHub context
|
||||||
- [ ] Move `custom_instructions` to `claude_args` with `--append-system-prompt`
|
- [ ] Move `custom_instructions` to `claude_args` with `--system-prompt`
|
||||||
- [ ] Convert `max_turns` to `claude_args` with `--max-turns`
|
- [ ] Convert `max_turns` to `claude_args` with `--max-turns`
|
||||||
- [ ] Convert `model` to `claude_args` with `--model`
|
- [ ] Convert `model` to `claude_args` with `--model`
|
||||||
- [ ] Convert `allowed_tools` to `claude_args` with `--allowedTools`
|
- [ ] Convert `allowed_tools` to `claude_args` with `--allowedTools`
|
||||||
|
|||||||
@ -2,7 +2,7 @@
|
|||||||
|
|
||||||
## Access Control
|
## Access Control
|
||||||
|
|
||||||
- **Repository Access**: The action can only be triggered by users with write access to the repository. This is checked for issue, pull request, comment, and review events, and for `workflow_run` events, where both the workflow actor and the actor that started the upstream run are checked. `workflow_dispatch`, `repository_dispatch`, and `schedule` events are not checked separately — GitHub itself requires write access to dispatch a workflow, and scheduled runs have no external actor.
|
- **Repository Access**: The action can only be triggered by users with write access to the repository
|
||||||
- **Bot User Control**: By default, GitHub Apps and bots cannot trigger this action for security reasons. Use the `allowed_bots` parameter to enable specific bots or all bots
|
- **Bot User Control**: By default, GitHub Apps and bots cannot trigger this action for security reasons. Use the `allowed_bots` parameter to enable specific bots or all bots
|
||||||
- **⚠️ Allowed bots are not checked for repository permissions.** A bot that matches an entry does **not** need to be installed on your repository or have write access. On a **public repository**, external parties — including GitHub Apps created by anyone — may be able to trigger workflow events such as opening issues, commenting, or reviewing pull requests. If your workflow listens on those events and `allowed_bots` is set to `'*'`, any such App can invoke this action with a prompt it controls.
|
- **⚠️ Allowed bots are not checked for repository permissions.** A bot that matches an entry does **not** need to be installed on your repository or have write access. On a **public repository**, external parties — including GitHub Apps created by anyone — may be able to trigger workflow events such as opening issues, commenting, or reviewing pull requests. If your workflow listens on those events and `allowed_bots` is set to `'*'`, any such App can invoke this action with a prompt it controls.
|
||||||
- Prefer an explicit list over `'*'`
|
- Prefer an explicit list over `'*'`
|
||||||
@ -22,8 +22,6 @@
|
|||||||
|
|
||||||
## Using this action with `pull_request_target` or `workflow_run`
|
## Using this action with `pull_request_target` or `workflow_run`
|
||||||
|
|
||||||
For `workflow_run` events, the action checks the repository access of the actor that started the upstream run (for example, the author of the fork pull request that triggered your CI workflow) in addition to the workflow actor. If that actor does not have write access, the action stops before running Claude. To run on `workflow_run` events downstream of pull requests from contributors without write access, add those users to `allowed_non_write_users` and pass `github_token: ${{ secrets.GITHUB_TOKEN }}` — see the notes on that input above and keep the workflow's permissions minimal.
|
|
||||||
|
|
||||||
`pull_request_target` and `workflow_run` execute with the **base repository's secrets**. If your workflow checks out the PR head (`ref: ${{ github.event.pull_request.head.sha }}` for `pull_request_target`, `ref: ${{ github.event.workflow_run.head_sha }}` for `workflow_run`) into `$GITHUB_WORKSPACE` before this action, the action and Claude run with that checkout as the working directory.
|
`pull_request_target` and `workflow_run` execute with the **base repository's secrets**. If your workflow checks out the PR head (`ref: ${{ github.event.pull_request.head.sha }}` for `pull_request_target`, `ref: ${{ github.event.workflow_run.head_sha }}` for `workflow_run`) into `$GITHUB_WORKSPACE` before this action, the action and Claude run with that checkout as the working directory.
|
||||||
|
|
||||||
**Do not check out an untrusted ref into the workspace root before this action.** Use one of these patterns instead:
|
**Do not check out an untrusted ref into the workspace root before this action.** Use one of these patterns instead:
|
||||||
|
|||||||
@ -99,7 +99,7 @@ These inputs are deprecated and will be removed in a future version:
|
|||||||
| `mode` | **DEPRECATED**: Mode is now automatically detected based on workflow context | Remove this input; the action auto-detects the correct mode |
|
| `mode` | **DEPRECATED**: Mode is now automatically detected based on workflow context | Remove this input; the action auto-detects the correct mode |
|
||||||
| `direct_prompt` | **DEPRECATED**: Use `prompt` instead | Replace with `prompt` |
|
| `direct_prompt` | **DEPRECATED**: Use `prompt` instead | Replace with `prompt` |
|
||||||
| `override_prompt` | **DEPRECATED**: Use `prompt` with template variables or `claude_args` with `--system-prompt` | Use `prompt` for templates or `claude_args` for system prompts |
|
| `override_prompt` | **DEPRECATED**: Use `prompt` with template variables or `claude_args` with `--system-prompt` | Use `prompt` for templates or `claude_args` for system prompts |
|
||||||
| `custom_instructions` | **DEPRECATED**: Use `claude_args` with `--append-system-prompt` or include in `prompt` | Move instructions to `prompt` or use `claude_args` |
|
| `custom_instructions` | **DEPRECATED**: Use `claude_args` with `--system-prompt` or include in `prompt` | Move instructions to `prompt` or use `claude_args` |
|
||||||
| `max_turns` | **DEPRECATED**: Use `claude_args` with `--max-turns` instead | Use `claude_args: "--max-turns 5"` |
|
| `max_turns` | **DEPRECATED**: Use `claude_args` with `--max-turns` instead | Use `claude_args: "--max-turns 5"` |
|
||||||
| `model` | **DEPRECATED**: Use `claude_args` with `--model` instead | Use `claude_args: "--model claude-4-0-sonnet-20250805"` |
|
| `model` | **DEPRECATED**: Use `claude_args` with `--model` instead | Use `claude_args: "--model claude-4-0-sonnet-20250805"` |
|
||||||
| `fallback_model` | **DEPRECATED**: Use `claude_args` with fallback configuration | Configure fallback in `claude_args` or `settings` |
|
| `fallback_model` | **DEPRECATED**: Use `claude_args` with fallback configuration | Configure fallback in `claude_args` or `settings` |
|
||||||
@ -139,7 +139,7 @@ For a comprehensive guide on migrating from v0.x to v1.0, including step-by-step
|
|||||||
anthropic_api_key: ${{ secrets.ANTHROPIC_API_KEY }}
|
anthropic_api_key: ${{ secrets.ANTHROPIC_API_KEY }}
|
||||||
claude_args: |
|
claude_args: |
|
||||||
--max-turns 10
|
--max-turns 10
|
||||||
--append-system-prompt "Focus on security"
|
--system-prompt "Focus on security"
|
||||||
```
|
```
|
||||||
|
|
||||||
#### Automation Workflows
|
#### Automation Workflows
|
||||||
|
|||||||
@ -12,7 +12,7 @@
|
|||||||
"dependencies": {
|
"dependencies": {
|
||||||
"@actions/core": "^1.10.1",
|
"@actions/core": "^1.10.1",
|
||||||
"@actions/github": "^6.0.1",
|
"@actions/github": "^6.0.1",
|
||||||
"@anthropic-ai/claude-agent-sdk": "^0.3.221",
|
"@anthropic-ai/claude-agent-sdk": "^0.3.206",
|
||||||
"@modelcontextprotocol/sdk": "^1.11.0",
|
"@modelcontextprotocol/sdk": "^1.11.0",
|
||||||
"@octokit/graphql": "^8.2.2",
|
"@octokit/graphql": "^8.2.2",
|
||||||
"@octokit/rest": "^21.1.1",
|
"@octokit/rest": "^21.1.1",
|
||||||
|
|||||||
3
src/entrypoints/format-turns.ts
Normal file → Executable file
3
src/entrypoints/format-turns.ts
Normal file → Executable file
@ -268,8 +268,7 @@ export function groupTurnsNaturally(data: Turn[]): GroupedContent[] {
|
|||||||
type: "system_init",
|
type: "system_init",
|
||||||
tools_count: tools.length,
|
tools_count: tools.length,
|
||||||
});
|
});
|
||||||
} else if (subtype !== "thinking_tokens") {
|
} else {
|
||||||
// Skip thinking_tokens - internal progress events not meant for summary
|
|
||||||
groupedContent.push({
|
groupedContent.push({
|
||||||
type: "system_other",
|
type: "system_other",
|
||||||
data: turn,
|
data: turn,
|
||||||
|
|||||||
@ -9,11 +9,7 @@ import * as core from "@actions/core";
|
|||||||
import { setupGitHubToken } from "../github/token";
|
import { setupGitHubToken } from "../github/token";
|
||||||
import { checkWritePermissions } from "../github/validation/permissions";
|
import { checkWritePermissions } from "../github/validation/permissions";
|
||||||
import { createOctokit } from "../github/api/client";
|
import { createOctokit } from "../github/api/client";
|
||||||
import {
|
import { parseGitHubContext, isEntityContext } from "../github/context";
|
||||||
parseGitHubContext,
|
|
||||||
isEntityContext,
|
|
||||||
isWorkflowRunEvent,
|
|
||||||
} from "../github/context";
|
|
||||||
import { detectMode } from "../modes/detector";
|
import { detectMode } from "../modes/detector";
|
||||||
import { prepareTagMode } from "../modes/tag";
|
import { prepareTagMode } from "../modes/tag";
|
||||||
import { prepareAgentMode } from "../modes/agent";
|
import { prepareAgentMode } from "../modes/agent";
|
||||||
@ -37,8 +33,8 @@ async function run() {
|
|||||||
const githubToken = await setupGitHubToken();
|
const githubToken = await setupGitHubToken();
|
||||||
const octokit = createOctokit(githubToken);
|
const octokit = createOctokit(githubToken);
|
||||||
|
|
||||||
// Step 3: Check write permissions (entity contexts and workflow_run)
|
// Step 3: Check write permissions (only for entity contexts)
|
||||||
if (isEntityContext(context) || isWorkflowRunEvent(context)) {
|
if (isEntityContext(context)) {
|
||||||
// Check if github_token was provided as input (not from app)
|
// Check if github_token was provided as input (not from app)
|
||||||
const githubTokenProvided = !!process.env.OVERRIDE_GITHUB_TOKEN;
|
const githubTokenProvided = !!process.env.OVERRIDE_GITHUB_TOKEN;
|
||||||
const hasWritePermissions = await checkWritePermissions(
|
const hasWritePermissions = await checkWritePermissions(
|
||||||
|
|||||||
@ -21,7 +21,6 @@ import {
|
|||||||
isPullRequestEvent,
|
isPullRequestEvent,
|
||||||
isPullRequestReviewEvent,
|
isPullRequestReviewEvent,
|
||||||
isPullRequestReviewCommentEvent,
|
isPullRequestReviewCommentEvent,
|
||||||
isWorkflowRunEvent,
|
|
||||||
} from "../github/context";
|
} from "../github/context";
|
||||||
import type { GitHubContext } from "../github/context";
|
import type { GitHubContext } from "../github/context";
|
||||||
import { detectMode } from "../modes/detector";
|
import { detectMode } from "../modes/detector";
|
||||||
@ -76,7 +75,7 @@ async function installClaudeCode(): Promise<string> {
|
|||||||
return customExecutable;
|
return customExecutable;
|
||||||
}
|
}
|
||||||
|
|
||||||
const claudeCodeVersion = "2.1.221";
|
const claudeCodeVersion = "2.1.206";
|
||||||
console.log(`Installing Claude Code v${claudeCodeVersion}...`);
|
console.log(`Installing Claude Code v${claudeCodeVersion}...`);
|
||||||
|
|
||||||
for (let attempt = 1; attempt <= 3; attempt++) {
|
for (let attempt = 1; attempt <= 3; attempt++) {
|
||||||
@ -186,10 +185,8 @@ async function run() {
|
|||||||
process.env.GITHUB_TOKEN = githubToken;
|
process.env.GITHUB_TOKEN = githubToken;
|
||||||
process.env.GH_TOKEN = githubToken;
|
process.env.GH_TOKEN = githubToken;
|
||||||
|
|
||||||
// Check write permissions for entity contexts, and for workflow_run
|
// Check write permissions (only for entity contexts)
|
||||||
// events, whose upstream run may have been started by an actor without
|
if (isEntityContext(context)) {
|
||||||
// write access (e.g. the author of a fork pull request)
|
|
||||||
if (isEntityContext(context) || isWorkflowRunEvent(context)) {
|
|
||||||
const hasWritePermissions = await checkWritePermissions(
|
const hasWritePermissions = await checkWritePermissions(
|
||||||
octokit.rest,
|
octokit.rest,
|
||||||
context,
|
context,
|
||||||
@ -321,8 +318,7 @@ async function run() {
|
|||||||
} finally {
|
} finally {
|
||||||
// Phase 4: Cleanup (always runs)
|
// Phase 4: Cleanup (always runs)
|
||||||
|
|
||||||
// Stop refreshing the workload identity token file and delete the token
|
// Stop refreshing the workload identity token file
|
||||||
// material so it doesn't outlive this step
|
|
||||||
workloadIdentity?.stop();
|
workloadIdentity?.stop();
|
||||||
|
|
||||||
// Update tracking comment
|
// Update tracking comment
|
||||||
|
|||||||
@ -282,12 +282,6 @@ export function isPullRequestReviewCommentEvent(
|
|||||||
return context.eventName === "pull_request_review_comment";
|
return context.eventName === "pull_request_review_comment";
|
||||||
}
|
}
|
||||||
|
|
||||||
export function isWorkflowRunEvent(
|
|
||||||
context: GitHubContext,
|
|
||||||
): context is AutomationContext & { payload: WorkflowRunEvent } {
|
|
||||||
return context.eventName === "workflow_run";
|
|
||||||
}
|
|
||||||
|
|
||||||
export function isIssuesAssignedEvent(
|
export function isIssuesAssignedEvent(
|
||||||
context: GitHubContext,
|
context: GitHubContext,
|
||||||
): context is ParsedGitHubContext & { payload: IssuesAssignedEvent } {
|
): context is ParsedGitHubContext & { payload: IssuesAssignedEvent } {
|
||||||
|
|||||||
@ -1,5 +1,4 @@
|
|||||||
import { execFileSync } from "child_process";
|
import { execFileSync } from "child_process";
|
||||||
import type { IssuesEvent } from "@octokit/webhooks-types";
|
|
||||||
import type { Octokits } from "../api/client";
|
import type { Octokits } from "../api/client";
|
||||||
import { ISSUE_QUERY, PR_QUERY, USER_QUERY } from "../api/queries/github";
|
import { ISSUE_QUERY, PR_QUERY, USER_QUERY } from "../api/queries/github";
|
||||||
import {
|
import {
|
||||||
@ -30,12 +29,6 @@ import {
|
|||||||
* Extracts the trigger timestamp from the GitHub webhook payload.
|
* Extracts the trigger timestamp from the GitHub webhook payload.
|
||||||
* This timestamp represents when the triggering comment/review/event was created.
|
* This timestamp represents when the triggering comment/review/event was created.
|
||||||
*
|
*
|
||||||
* For `issues` and `pull_request` events there is no dedicated trigger
|
|
||||||
* object in the payload, so the issue/PR's own timestamps from the webhook
|
|
||||||
* snapshot are used: `created_at` for opened events, otherwise `updated_at`
|
|
||||||
* (falling back to `created_at`). For issues labeled/assigned events,
|
|
||||||
* prefer resolveTriggerTimestamp() which looks up the exact event time.
|
|
||||||
*
|
|
||||||
* @param context - Parsed GitHub context from webhook
|
* @param context - Parsed GitHub context from webhook
|
||||||
* @returns ISO timestamp string or undefined if not available
|
* @returns ISO timestamp string or undefined if not available
|
||||||
*/
|
*/
|
||||||
@ -48,138 +41,11 @@ export function extractTriggerTimestamp(
|
|||||||
return context.payload.review.submitted_at || undefined;
|
return context.payload.review.submitted_at || undefined;
|
||||||
} else if (isPullRequestReviewCommentEvent(context)) {
|
} else if (isPullRequestReviewCommentEvent(context)) {
|
||||||
return context.payload.comment.created_at || undefined;
|
return context.payload.comment.created_at || undefined;
|
||||||
} else if (isIssuesEvent(context)) {
|
|
||||||
const issue = context.payload.issue;
|
|
||||||
if (context.eventAction === "opened") {
|
|
||||||
return issue?.created_at || issue?.updated_at || undefined;
|
|
||||||
}
|
|
||||||
// updated_at reflects the last comment or edit on the issue, so the
|
|
||||||
// newest pre-existing comment can share this timestamp and be excluded
|
|
||||||
// along with anything newer.
|
|
||||||
return issue?.updated_at || issue?.created_at || undefined;
|
|
||||||
} else if (isPullRequestEvent(context)) {
|
|
||||||
const pullRequest = context.payload.pull_request;
|
|
||||||
if (context.eventAction === "opened") {
|
|
||||||
return pullRequest?.created_at || pullRequest?.updated_at || undefined;
|
|
||||||
}
|
|
||||||
return pullRequest?.updated_at || pullRequest?.created_at || undefined;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
return undefined;
|
return undefined;
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
|
||||||
* Resolves the trigger timestamp for the event, consulting the GitHub API
|
|
||||||
* where the webhook payload does not carry an exact time for the triggering
|
|
||||||
* action.
|
|
||||||
*
|
|
||||||
* For issues labeled/assigned events the label/assignment carries no
|
|
||||||
* timestamp of its own in the payload, so the matching entry in the issue's
|
|
||||||
* event history is looked up and its `created_at` is used. If the lookup
|
|
||||||
* fails, this falls back to extractTriggerTimestamp().
|
|
||||||
*
|
|
||||||
* @param context - Parsed GitHub context from webhook
|
|
||||||
* @param octokits - GitHub API clients
|
|
||||||
* @returns ISO timestamp string or undefined if not available
|
|
||||||
*/
|
|
||||||
export async function resolveTriggerTimestamp(
|
|
||||||
context: ParsedGitHubContext,
|
|
||||||
octokits: Octokits,
|
|
||||||
): Promise<string | undefined> {
|
|
||||||
if (
|
|
||||||
isIssuesEvent(context) &&
|
|
||||||
(context.eventAction === "labeled" || context.eventAction === "assigned")
|
|
||||||
) {
|
|
||||||
const eventTime = await findIssueEventTime(context, octokits);
|
|
||||||
if (eventTime) {
|
|
||||||
return eventTime;
|
|
||||||
}
|
|
||||||
console.warn(
|
|
||||||
`Could not resolve the ${context.eventAction} event time for issue #${context.entityNumber}; falling back to the webhook payload timestamps`,
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
return extractTriggerTimestamp(context);
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Looks up the most recent labeled/assigned event on the issue that matches
|
|
||||||
* the label or assignee in the webhook payload, returning its created_at.
|
|
||||||
*/
|
|
||||||
async function findIssueEventTime(
|
|
||||||
context: ParsedGitHubContext & { payload: IssuesEvent },
|
|
||||||
octokits: Octokits,
|
|
||||||
): Promise<string | undefined> {
|
|
||||||
const payload = context.payload;
|
|
||||||
let matches: (event: {
|
|
||||||
event: string;
|
|
||||||
label?: { name?: string | null };
|
|
||||||
assignee?: { login?: string } | null;
|
|
||||||
}) => boolean;
|
|
||||||
|
|
||||||
if (payload.action === "labeled") {
|
|
||||||
const labelName = payload.label?.name;
|
|
||||||
if (!labelName) return undefined;
|
|
||||||
matches = (event) =>
|
|
||||||
event.event === "labeled" && event.label?.name === labelName;
|
|
||||||
} else if (payload.action === "assigned") {
|
|
||||||
const assigneeLogin = payload.assignee?.login;
|
|
||||||
if (!assigneeLogin) return undefined;
|
|
||||||
matches = (event) =>
|
|
||||||
event.event === "assigned" && event.assignee?.login === assigneeLogin;
|
|
||||||
} else {
|
|
||||||
return undefined;
|
|
||||||
}
|
|
||||||
|
|
||||||
try {
|
|
||||||
const events = await octokits.rest.paginate(
|
|
||||||
octokits.rest.issues.listEvents,
|
|
||||||
{
|
|
||||||
owner: context.repository.owner,
|
|
||||||
repo: context.repository.repo,
|
|
||||||
issue_number: context.entityNumber,
|
|
||||||
per_page: 100,
|
|
||||||
},
|
|
||||||
);
|
|
||||||
|
|
||||||
let latest: (typeof events)[number] | undefined;
|
|
||||||
for (const event of events.filter(matches)) {
|
|
||||||
if (
|
|
||||||
!latest ||
|
|
||||||
new Date(event.created_at).getTime() >
|
|
||||||
new Date(latest.created_at).getTime()
|
|
||||||
) {
|
|
||||||
latest = event;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// Labeling/assignment does not bump the issue's updated_at, so the event
|
|
||||||
// that fired this webhook cannot predate the payload snapshot's
|
|
||||||
// updated_at. An older match means the current event is not visible in
|
|
||||||
// the events API yet; ignore it rather than adopt a stale boundary.
|
|
||||||
const snapshotUpdatedAt = payload.issue?.updated_at;
|
|
||||||
if (
|
|
||||||
latest &&
|
|
||||||
snapshotUpdatedAt &&
|
|
||||||
new Date(latest.created_at).getTime() <
|
|
||||||
new Date(snapshotUpdatedAt).getTime()
|
|
||||||
) {
|
|
||||||
console.warn(
|
|
||||||
`Latest matching ${payload.action} event on issue #${context.entityNumber} predates the issue's updated_at; treating it as stale`,
|
|
||||||
);
|
|
||||||
return undefined;
|
|
||||||
}
|
|
||||||
|
|
||||||
return latest?.created_at || undefined;
|
|
||||||
} catch (error) {
|
|
||||||
console.warn(
|
|
||||||
`Failed to fetch events for issue #${context.entityNumber}:`,
|
|
||||||
error,
|
|
||||||
);
|
|
||||||
return undefined;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Extracts the original title from the GitHub webhook payload.
|
* Extracts the original title from the GitHub webhook payload.
|
||||||
* This is the title as it existed when the trigger event occurred.
|
* This is the title as it existed when the trigger event occurred.
|
||||||
@ -338,9 +204,11 @@ export function isBodySafeToUse(
|
|||||||
* @param excludeActors - Comma-separated actors to exclude
|
* @param excludeActors - Comma-separated actors to exclude
|
||||||
* @returns Filtered array of comments
|
* @returns Filtered array of comments
|
||||||
*/
|
*/
|
||||||
export function filterCommentsByActor<
|
export function filterCommentsByActor<T extends { author: { login: string } }>(
|
||||||
T extends { author: { login: string } | null },
|
comments: T[],
|
||||||
>(comments: T[], includeActors: string = "", excludeActors: string = ""): T[] {
|
includeActors: string = "",
|
||||||
|
excludeActors: string = "",
|
||||||
|
): T[] {
|
||||||
const includeParsed = parseActorFilter(includeActors);
|
const includeParsed = parseActorFilter(includeActors);
|
||||||
const excludeParsed = parseActorFilter(excludeActors);
|
const excludeParsed = parseActorFilter(excludeActors);
|
||||||
|
|
||||||
@ -351,9 +219,7 @@ export function filterCommentsByActor<
|
|||||||
|
|
||||||
return comments.filter((comment) =>
|
return comments.filter((comment) =>
|
||||||
shouldIncludeCommentByActor(
|
shouldIncludeCommentByActor(
|
||||||
// author is null for comments from deleted ("ghost") accounts; treat them
|
comment.author.login,
|
||||||
// as the "ghost" login so filtering never dereferences null and crashes.
|
|
||||||
comment.author?.login ?? "ghost",
|
|
||||||
includeParsed,
|
includeParsed,
|
||||||
excludeParsed,
|
excludeParsed,
|
||||||
),
|
),
|
||||||
|
|||||||
@ -21,7 +21,7 @@ export function formatContext(
|
|||||||
const prData = contextData as GitHubPullRequest;
|
const prData = contextData as GitHubPullRequest;
|
||||||
const sanitizedTitle = sanitizeContent(prData.title);
|
const sanitizedTitle = sanitizeContent(prData.title);
|
||||||
return `PR Title: ${sanitizedTitle}
|
return `PR Title: ${sanitizedTitle}
|
||||||
PR Author: ${prData.author?.login ?? "ghost"}
|
PR Author: ${prData.author.login}
|
||||||
PR Branch: ${prData.headRefName} -> ${prData.baseRefName}
|
PR Branch: ${prData.headRefName} -> ${prData.baseRefName}
|
||||||
PR State: ${prData.state}
|
PR State: ${prData.state}
|
||||||
PR Labels: ${formatLabels(prData.labels.nodes)}
|
PR Labels: ${formatLabels(prData.labels.nodes)}
|
||||||
@ -33,7 +33,7 @@ Changed Files: ${prData.files.nodes.length} files`;
|
|||||||
const issueData = contextData as GitHubIssue;
|
const issueData = contextData as GitHubIssue;
|
||||||
const sanitizedTitle = sanitizeContent(issueData.title);
|
const sanitizedTitle = sanitizeContent(issueData.title);
|
||||||
return `Issue Title: ${sanitizedTitle}
|
return `Issue Title: ${sanitizedTitle}
|
||||||
Issue Author: ${issueData.author?.login ?? "ghost"}
|
Issue Author: ${issueData.author.login}
|
||||||
Issue State: ${issueData.state}
|
Issue State: ${issueData.state}
|
||||||
Issue Labels: ${formatLabels(issueData.labels.nodes)}`;
|
Issue Labels: ${formatLabels(issueData.labels.nodes)}`;
|
||||||
}
|
}
|
||||||
@ -71,7 +71,7 @@ export function formatComments(
|
|||||||
|
|
||||||
body = sanitizeContent(body);
|
body = sanitizeContent(body);
|
||||||
|
|
||||||
return `[${comment.author?.login ?? "ghost"} at ${comment.createdAt}]: ${body}`;
|
return `[${comment.author.login} at ${comment.createdAt}]: ${body}`;
|
||||||
})
|
})
|
||||||
.join("\n\n");
|
.join("\n\n");
|
||||||
}
|
}
|
||||||
@ -85,7 +85,7 @@ export function formatReviewComments(
|
|||||||
}
|
}
|
||||||
|
|
||||||
const formattedReviews = reviewData.nodes.map((review) => {
|
const formattedReviews = reviewData.nodes.map((review) => {
|
||||||
let reviewOutput = `[Review by ${review.author?.login ?? "ghost"} at ${review.submittedAt}]: ${review.state}`;
|
let reviewOutput = `[Review by ${review.author.login} at ${review.submittedAt}]: ${review.state}`;
|
||||||
|
|
||||||
if (review.body && review.body.trim()) {
|
if (review.body && review.body.trim()) {
|
||||||
let body = review.body;
|
let body = review.body;
|
||||||
|
|||||||
@ -27,7 +27,7 @@ function extractFirstLabel(githubData: FetchDataResult): string | undefined {
|
|||||||
* This prevents command injection by ensuring only safe characters are used.
|
* This prevents command injection by ensuring only safe characters are used.
|
||||||
*
|
*
|
||||||
* Valid branch names:
|
* Valid branch names:
|
||||||
* - Start with alphanumeric character, underscore, or @ (not dash, to prevent option injection)
|
* - Start with alphanumeric character or @ (not dash, to prevent option injection)
|
||||||
* - Contain only alphanumeric, forward slash, hyphen, underscore, period, hash (#), plus (+), comma (,), or at sign (@)
|
* - Contain only alphanumeric, forward slash, hyphen, underscore, period, hash (#), plus (+), comma (,), or at sign (@)
|
||||||
* - Do not start or end with a period
|
* - Do not start or end with a period
|
||||||
* - Do not end with a slash
|
* - Do not end with a slash
|
||||||
@ -68,15 +68,12 @@ export function validateBranchName(branchName: string): void {
|
|||||||
// @ is valid per git-check-ref-format anywhere in a ref name, including the first character
|
// @ is valid per git-check-ref-format anywhere in a ref name, including the first character
|
||||||
// (e.g. ticket conventions like "TICKET-123@add-feature" or prefixes like "@hotfix/...");
|
// (e.g. ticket conventions like "TICKET-123@add-feature" or prefixes like "@hotfix/...");
|
||||||
// the bare name "@" (HEAD shorthand) and the "@{" sequence (reflog syntax) are rejected below.
|
// the bare name "@" (HEAD shorthand) and the "@{" sequence (reflog syntax) are rejected below.
|
||||||
// _ is valid per git-check-ref-format anywhere in a ref name, including the first character;
|
|
||||||
// leading underscores are a common convention for release/internal branches (e.g.
|
|
||||||
// "_release/v1.2.3"), which previously failed validation as a PR's base branch.
|
|
||||||
// All git calls use execFileSync (not shell interpolation), so none of these characters carry injection risk.
|
// All git calls use execFileSync (not shell interpolation), so none of these characters carry injection risk.
|
||||||
const validPattern = /^[a-zA-Z0-9@_][a-zA-Z0-9/_.#+,@-]*$/;
|
const validPattern = /^[a-zA-Z0-9@][a-zA-Z0-9/_.#+,@-]*$/;
|
||||||
|
|
||||||
if (!validPattern.test(branchName)) {
|
if (!validPattern.test(branchName)) {
|
||||||
throw new Error(
|
throw new Error(
|
||||||
`Invalid branch name: "${branchName}". Branch names must start with an alphanumeric character, underscore, or '@' and contain only alphanumeric characters, forward slashes, hyphens, underscores, periods, hashes (#), plus signs (+), commas (,), or at signs (@).`,
|
`Invalid branch name: "${branchName}". Branch names must start with an alphanumeric character or '@' and contain only alphanumeric characters, forward slashes, hyphens, underscores, periods, hashes (#), plus signs (+), commas (,), or at signs (@).`,
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@ -1,8 +1,4 @@
|
|||||||
// Types for GitHub GraphQL query responses
|
// Types for GitHub GraphQL query responses
|
||||||
|
|
||||||
// GitHub's GraphQL `author`/`actor` fields resolve to null when the underlying
|
|
||||||
// account has been deleted (the "ghost" user). Any field typed as
|
|
||||||
// `GitHubAuthor | null` can therefore be null at runtime and must be guarded.
|
|
||||||
export type GitHubAuthor = {
|
export type GitHubAuthor = {
|
||||||
login: string;
|
login: string;
|
||||||
name?: string;
|
name?: string;
|
||||||
@ -12,7 +8,7 @@ export type GitHubComment = {
|
|||||||
id: string;
|
id: string;
|
||||||
databaseId: string;
|
databaseId: string;
|
||||||
body: string;
|
body: string;
|
||||||
author: GitHubAuthor | null;
|
author: GitHubAuthor;
|
||||||
createdAt: string;
|
createdAt: string;
|
||||||
updatedAt?: string;
|
updatedAt?: string;
|
||||||
lastEditedAt?: string;
|
lastEditedAt?: string;
|
||||||
@ -43,7 +39,7 @@ export type GitHubFile = {
|
|||||||
export type GitHubReview = {
|
export type GitHubReview = {
|
||||||
id: string;
|
id: string;
|
||||||
databaseId: string;
|
databaseId: string;
|
||||||
author: GitHubAuthor | null;
|
author: GitHubAuthor;
|
||||||
body: string;
|
body: string;
|
||||||
state: string;
|
state: string;
|
||||||
submittedAt: string;
|
submittedAt: string;
|
||||||
@ -57,7 +53,7 @@ export type GitHubReview = {
|
|||||||
export type GitHubPullRequest = {
|
export type GitHubPullRequest = {
|
||||||
title: string;
|
title: string;
|
||||||
body: string;
|
body: string;
|
||||||
author: GitHubAuthor | null;
|
author: GitHubAuthor;
|
||||||
baseRefName: string;
|
baseRefName: string;
|
||||||
headRefName: string;
|
headRefName: string;
|
||||||
headRefOid: string;
|
headRefOid: string;
|
||||||
@ -99,7 +95,7 @@ export type GitHubPullRequest = {
|
|||||||
export type GitHubIssue = {
|
export type GitHubIssue = {
|
||||||
title: string;
|
title: string;
|
||||||
body: string;
|
body: string;
|
||||||
author: GitHubAuthor | null;
|
author: GitHubAuthor;
|
||||||
createdAt: string;
|
createdAt: string;
|
||||||
updatedAt?: string;
|
updatedAt?: string;
|
||||||
lastEditedAt?: string;
|
lastEditedAt?: string;
|
||||||
|
|||||||
@ -14,39 +14,6 @@ const HTML_IMG_REGEX = new RegExp(
|
|||||||
"gi",
|
"gi",
|
||||||
);
|
);
|
||||||
|
|
||||||
const SIGNED_URL_REGEX =
|
|
||||||
/https:\/\/private-user-images\.githubusercontent\.com\/[^"]+\?jwt=[^"]+/g;
|
|
||||||
|
|
||||||
// GitHub identifies an uploaded asset by a GUID that appears both in the
|
|
||||||
// user-attachment URL and in the signed download URL rendered in body_html.
|
|
||||||
const ASSET_GUID_REGEX =
|
|
||||||
/[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}/i;
|
|
||||||
|
|
||||||
function extractAssetGuid(url: string): string | undefined {
|
|
||||||
return url.match(ASSET_GUID_REGEX)?.[0]?.toLowerCase();
|
|
||||||
}
|
|
||||||
|
|
||||||
const SIGNED_URL_HOST = "private-user-images.githubusercontent.com";
|
|
||||||
|
|
||||||
// Signed download URLs have the shape /<owner-id>/<asset-id>-<guid>.<ext>.
|
|
||||||
// The GUID must come from the resolved filename, not from anywhere in the raw
|
|
||||||
// string, so text that merely embeds a GUID cannot claim another asset.
|
|
||||||
const SIGNED_URL_PATH_REGEX =
|
|
||||||
/^\/[^/]+\/[^/]*-([0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12})(?:\.[a-z0-9]+)?$/i;
|
|
||||||
|
|
||||||
function extractSignedUrlAssetGuid(signedUrl: string): string | undefined {
|
|
||||||
let parsed: URL;
|
|
||||||
try {
|
|
||||||
parsed = new URL(signedUrl);
|
|
||||||
} catch {
|
|
||||||
return undefined;
|
|
||||||
}
|
|
||||||
if (parsed.host !== SIGNED_URL_HOST) {
|
|
||||||
return undefined;
|
|
||||||
}
|
|
||||||
return parsed.pathname.match(SIGNED_URL_PATH_REGEX)?.[1]?.toLowerCase();
|
|
||||||
}
|
|
||||||
|
|
||||||
type IssueComment = {
|
type IssueComment = {
|
||||||
type: "issue_comment";
|
type: "issue_comment";
|
||||||
id: string;
|
id: string;
|
||||||
@ -207,34 +174,21 @@ export async function downloadCommentImages(
|
|||||||
}
|
}
|
||||||
|
|
||||||
// Extract signed URLs from HTML
|
// Extract signed URLs from HTML
|
||||||
const signedUrls = bodyHtml.match(SIGNED_URL_REGEX) || [];
|
const signedUrlRegex =
|
||||||
|
/https:\/\/private-user-images\.githubusercontent\.com\/[^"]+\?jwt=[^"]+/g;
|
||||||
// Index the signed URLs by the asset GUID they reference. The signed
|
const signedUrls = bodyHtml.match(signedUrlRegex) || [];
|
||||||
// URLs come from a separate render of the body, so their order and
|
|
||||||
// count are not guaranteed to line up with the URLs extracted from the
|
|
||||||
// markdown; pairing by asset identifier keeps each download tied to the
|
|
||||||
// URL it actually belongs to.
|
|
||||||
const signedUrlByGuid = new Map<string, string>();
|
|
||||||
for (const signedUrl of signedUrls) {
|
|
||||||
const guid = extractSignedUrlAssetGuid(signedUrl);
|
|
||||||
if (guid && !signedUrlByGuid.has(guid)) {
|
|
||||||
signedUrlByGuid.set(guid, signedUrl);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// Download each image
|
// Download each image
|
||||||
for (const [i, originalUrl] of urls.entries()) {
|
for (let i = 0; i < Math.min(signedUrls.length, urls.length); i++) {
|
||||||
// Check if we've already downloaded this URL
|
const signedUrl = signedUrls[i];
|
||||||
if (urlToPathMap.has(originalUrl)) {
|
const originalUrl = urls[i];
|
||||||
|
|
||||||
|
if (!signedUrl || !originalUrl) {
|
||||||
continue;
|
continue;
|
||||||
}
|
}
|
||||||
|
|
||||||
const guid = extractAssetGuid(originalUrl);
|
// Check if we've already downloaded this URL
|
||||||
const signedUrl = guid ? signedUrlByGuid.get(guid) : undefined;
|
if (urlToPathMap.has(originalUrl)) {
|
||||||
if (!signedUrl) {
|
|
||||||
console.warn(
|
|
||||||
`No matching signed URL found for ${originalUrl}, skipping`,
|
|
||||||
);
|
|
||||||
continue;
|
continue;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@ -10,13 +10,7 @@ export function stripInvisibleCharacters(content: string): string {
|
|||||||
}
|
}
|
||||||
|
|
||||||
export function stripMarkdownImageAltText(content: string): string {
|
export function stripMarkdownImageAltText(content: string): string {
|
||||||
// Inline images:  -> 
|
return content.replace(/!\[[^\]]*\]\(/g, ";
|
||||||
content = content.replace(/!\[[^\]]*\]\(/g, ";
|
|
||||||
// Reference-style images: ![alt][ref] -> ![][ref] (keep the label, drop the
|
|
||||||
// alt text, which is otherwise a hidden-instruction channel just like the
|
|
||||||
// inline form above).
|
|
||||||
content = content.replace(/!\[[^\]]*\](\[[^\]]*\])/g, "![]$1");
|
|
||||||
return content;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
export function stripMarkdownLinkTitles(content: string): string {
|
export function stripMarkdownLinkTitles(content: string): string {
|
||||||
@ -89,12 +83,6 @@ export function redactGitHubTokens(content: string): string {
|
|||||||
"[REDACTED_GITHUB_TOKEN]",
|
"[REDACTED_GITHUB_TOKEN]",
|
||||||
);
|
);
|
||||||
|
|
||||||
// GitHub user-to-server tokens: ghu_XXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXX (40 chars)
|
|
||||||
content = content.replace(
|
|
||||||
/\bghu_[A-Za-z0-9]{36}\b/g,
|
|
||||||
"[REDACTED_GITHUB_TOKEN]",
|
|
||||||
);
|
|
||||||
|
|
||||||
// GitHub installation tokens: ghs_XXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXX (40 chars)
|
// GitHub installation tokens: ghs_XXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXX (40 chars)
|
||||||
content = content.replace(
|
content = content.replace(
|
||||||
/\bghs_[A-Za-z0-9]{36}\b/g,
|
/\bghs_[A-Za-z0-9]{36}\b/g,
|
||||||
|
|||||||
@ -1,5 +1,5 @@
|
|||||||
import * as core from "@actions/core";
|
import * as core from "@actions/core";
|
||||||
import { isWorkflowRunEvent, type GitHubContext } from "../context";
|
import type { ParsedGitHubContext } from "../context";
|
||||||
import type { Octokit } from "@octokit/rest";
|
import type { Octokit } from "@octokit/rest";
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@ -24,28 +24,6 @@ function isAllowedBot(actor: string, allowedBots: string): boolean {
|
|||||||
return allowedList.includes(normalizedActor);
|
return allowedList.includes(normalizedActor);
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
|
||||||
* Collect the actors whose repository access should be checked. This is
|
|
||||||
* normally just the workflow actor (GITHUB_ACTOR). For workflow_run events
|
|
||||||
* the actor that started the upstream run is checked as well when it
|
|
||||||
* differs, since that is the account the run originates from.
|
|
||||||
*/
|
|
||||||
function getActorsToCheck(context: GitHubContext): string[] {
|
|
||||||
const actors = [context.actor];
|
|
||||||
|
|
||||||
if (isWorkflowRunEvent(context)) {
|
|
||||||
const runActor = context.payload.workflow_run?.actor?.login;
|
|
||||||
if (runActor && !actors.includes(runActor)) {
|
|
||||||
core.info(
|
|
||||||
`workflow_run was started by ${runActor}; checking permissions for that actor as well`,
|
|
||||||
);
|
|
||||||
actors.push(runActor);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
return actors;
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Check if the actor has write permissions to the repository
|
* Check if the actor has write permissions to the repository
|
||||||
* @param octokit - The Octokit REST client
|
* @param octokit - The Octokit REST client
|
||||||
@ -56,31 +34,11 @@ function getActorsToCheck(context: GitHubContext): string[] {
|
|||||||
*/
|
*/
|
||||||
export async function checkWritePermissions(
|
export async function checkWritePermissions(
|
||||||
octokit: Octokit,
|
octokit: Octokit,
|
||||||
context: GitHubContext,
|
context: ParsedGitHubContext,
|
||||||
allowedNonWriteUsers?: string,
|
allowedNonWriteUsers?: string,
|
||||||
githubTokenProvided?: boolean,
|
githubTokenProvided?: boolean,
|
||||||
): Promise<boolean> {
|
): Promise<boolean> {
|
||||||
for (const actor of getActorsToCheck(context)) {
|
const { repository, actor } = context;
|
||||||
const allowed = await checkActorWritePermissions(
|
|
||||||
octokit,
|
|
||||||
context,
|
|
||||||
actor,
|
|
||||||
allowedNonWriteUsers,
|
|
||||||
githubTokenProvided,
|
|
||||||
);
|
|
||||||
if (!allowed) return false;
|
|
||||||
}
|
|
||||||
return true;
|
|
||||||
}
|
|
||||||
|
|
||||||
async function checkActorWritePermissions(
|
|
||||||
octokit: Octokit,
|
|
||||||
context: GitHubContext,
|
|
||||||
actor: string,
|
|
||||||
allowedNonWriteUsers?: string,
|
|
||||||
githubTokenProvided?: boolean,
|
|
||||||
): Promise<boolean> {
|
|
||||||
const { repository } = context;
|
|
||||||
const allowedBots = context.inputs.allowedBots ?? "";
|
const allowedBots = context.inputs.allowedBots ?? "";
|
||||||
|
|
||||||
try {
|
try {
|
||||||
|
|||||||
@ -17,20 +17,6 @@ type PrepareConfigParams = {
|
|||||||
context: GitHubContext;
|
context: GitHubContext;
|
||||||
};
|
};
|
||||||
|
|
||||||
// Build the bun invocation for one of the action's own MCP servers. The
|
|
||||||
// flags mirror the entrypoint invocation in action.yml so the server process
|
|
||||||
// reads its runtime config from the action directory rather than from the
|
|
||||||
// process working directory.
|
|
||||||
function bunServerArgs(scriptPath: string): string[] {
|
|
||||||
const actionPath = process.env.GITHUB_ACTION_PATH;
|
|
||||||
return [
|
|
||||||
"--no-env-file",
|
|
||||||
`--config=${actionPath}/bunfig.toml`,
|
|
||||||
"run",
|
|
||||||
`${actionPath}/${scriptPath}`,
|
|
||||||
];
|
|
||||||
}
|
|
||||||
|
|
||||||
async function checkActionsReadPermission(
|
async function checkActionsReadPermission(
|
||||||
token: string,
|
token: string,
|
||||||
owner: string,
|
owner: string,
|
||||||
@ -111,7 +97,10 @@ export async function prepareMcpConfig(
|
|||||||
if (shouldIncludeCommentServer) {
|
if (shouldIncludeCommentServer) {
|
||||||
baseMcpConfig.mcpServers.github_comment = {
|
baseMcpConfig.mcpServers.github_comment = {
|
||||||
command: "bun",
|
command: "bun",
|
||||||
args: bunServerArgs("src/mcp/github-comment-server.ts"),
|
args: [
|
||||||
|
"run",
|
||||||
|
`${process.env.GITHUB_ACTION_PATH}/src/mcp/github-comment-server.ts`,
|
||||||
|
],
|
||||||
env: {
|
env: {
|
||||||
GITHUB_TOKEN: githubToken,
|
GITHUB_TOKEN: githubToken,
|
||||||
REPO_OWNER: owner,
|
REPO_OWNER: owner,
|
||||||
@ -127,7 +116,10 @@ export async function prepareMcpConfig(
|
|||||||
if (context.inputs.useCommitSigning) {
|
if (context.inputs.useCommitSigning) {
|
||||||
baseMcpConfig.mcpServers.github_file_ops = {
|
baseMcpConfig.mcpServers.github_file_ops = {
|
||||||
command: "bun",
|
command: "bun",
|
||||||
args: bunServerArgs("src/mcp/github-file-ops-server.ts"),
|
args: [
|
||||||
|
"run",
|
||||||
|
`${process.env.GITHUB_ACTION_PATH}/src/mcp/github-file-ops-server.ts`,
|
||||||
|
],
|
||||||
env: {
|
env: {
|
||||||
GITHUB_TOKEN: githubToken,
|
GITHUB_TOKEN: githubToken,
|
||||||
REPO_OWNER: owner,
|
REPO_OWNER: owner,
|
||||||
@ -150,7 +142,10 @@ export async function prepareMcpConfig(
|
|||||||
) {
|
) {
|
||||||
baseMcpConfig.mcpServers.github_inline_comment = {
|
baseMcpConfig.mcpServers.github_inline_comment = {
|
||||||
command: "bun",
|
command: "bun",
|
||||||
args: bunServerArgs("src/mcp/github-inline-comment-server.ts"),
|
args: [
|
||||||
|
"run",
|
||||||
|
`${process.env.GITHUB_ACTION_PATH}/src/mcp/github-inline-comment-server.ts`,
|
||||||
|
],
|
||||||
env: {
|
env: {
|
||||||
GITHUB_TOKEN: githubToken,
|
GITHUB_TOKEN: githubToken,
|
||||||
REPO_OWNER: owner,
|
REPO_OWNER: owner,
|
||||||
@ -192,7 +187,10 @@ export async function prepareMcpConfig(
|
|||||||
} else {
|
} else {
|
||||||
baseMcpConfig.mcpServers.github_ci = {
|
baseMcpConfig.mcpServers.github_ci = {
|
||||||
command: "bun",
|
command: "bun",
|
||||||
args: bunServerArgs("src/mcp/github-actions-server.ts"),
|
args: [
|
||||||
|
"run",
|
||||||
|
`${process.env.GITHUB_ACTION_PATH}/src/mcp/github-actions-server.ts`,
|
||||||
|
],
|
||||||
env: {
|
env: {
|
||||||
// Use workflow github token, not app token
|
// Use workflow github token, not app token
|
||||||
GITHUB_TOKEN: process.env.DEFAULT_WORKFLOW_TOKEN,
|
GITHUB_TOKEN: process.env.DEFAULT_WORKFLOW_TOKEN,
|
||||||
|
|||||||
@ -8,7 +8,7 @@ import {
|
|||||||
import { prepareMcpConfig } from "../../mcp/install-mcp-server";
|
import { prepareMcpConfig } from "../../mcp/install-mcp-server";
|
||||||
import {
|
import {
|
||||||
fetchGitHubData,
|
fetchGitHubData,
|
||||||
resolveTriggerTimestamp,
|
extractTriggerTimestamp,
|
||||||
extractOriginalTitle,
|
extractOriginalTitle,
|
||||||
extractOriginalBody,
|
extractOriginalBody,
|
||||||
} from "../../github/data/fetcher";
|
} from "../../github/data/fetcher";
|
||||||
@ -45,7 +45,7 @@ export async function prepareTagMode({
|
|||||||
const commentData = await createInitialComment(octokit.rest, context);
|
const commentData = await createInitialComment(octokit.rest, context);
|
||||||
const commentId = commentData.id;
|
const commentId = commentData.id;
|
||||||
|
|
||||||
const triggerTime = await resolveTriggerTimestamp(context, octokit);
|
const triggerTime = extractTriggerTimestamp(context);
|
||||||
const originalTitle = extractOriginalTitle(context);
|
const originalTitle = extractOriginalTitle(context);
|
||||||
const originalBody = extractOriginalBody(context);
|
const originalBody = extractOriginalBody(context);
|
||||||
|
|
||||||
|
|||||||
@ -28,20 +28,6 @@ function extractDescription(
|
|||||||
.replace(/^-|-$/g, ""); // Remove leading/trailing hyphens
|
.replace(/^-|-$/g, ""); // Remove leading/trailing hyphens
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
|
||||||
* Sanitizes a label into a git-safe branch segment. Labels are free-form and
|
|
||||||
* often scoped (e.g. "area:permissions"), so characters that are invalid in a
|
|
||||||
* branch name (":", "/", spaces, ...) are replaced with a hyphen rather than
|
|
||||||
* dropped, keeping the label readable. Returns "" if nothing usable remains.
|
|
||||||
*/
|
|
||||||
function sanitizeLabel(label: string): string {
|
|
||||||
return label
|
|
||||||
.toLowerCase()
|
|
||||||
.replace(/[^a-z0-9-]+/g, "-") // Replace runs of invalid chars with a hyphen
|
|
||||||
.replace(/-+/g, "-") // Collapse multiple hyphens
|
|
||||||
.replace(/^-|-$/g, ""); // Remove leading/trailing hyphens
|
|
||||||
}
|
|
||||||
|
|
||||||
export interface BranchTemplateVariables {
|
export interface BranchTemplateVariables {
|
||||||
prefix: string;
|
prefix: string;
|
||||||
entityType: string;
|
entityType: string;
|
||||||
@ -92,7 +78,7 @@ export function generateBranchName(
|
|||||||
entityNumber,
|
entityNumber,
|
||||||
timestamp: `${now.getFullYear()}${String(now.getMonth() + 1).padStart(2, "0")}${String(now.getDate()).padStart(2, "0")}-${String(now.getHours()).padStart(2, "0")}${String(now.getMinutes()).padStart(2, "0")}`,
|
timestamp: `${now.getFullYear()}${String(now.getMonth() + 1).padStart(2, "0")}${String(now.getDate()).padStart(2, "0")}-${String(now.getHours()).padStart(2, "0")}${String(now.getMinutes()).padStart(2, "0")}`,
|
||||||
sha: sha?.substring(0, 8), // First 8 characters of SHA
|
sha: sha?.substring(0, 8), // First 8 characters of SHA
|
||||||
label: (label && sanitizeLabel(label)) || entityType, // Sanitize; fall back to entityType if empty/no label
|
label: label || entityType, // Fall back to entityType if no label
|
||||||
description: title ? extractDescription(title) : undefined,
|
description: title ? extractDescription(title) : undefined,
|
||||||
};
|
};
|
||||||
|
|
||||||
|
|||||||
@ -5,7 +5,6 @@ import {
|
|||||||
applyBranchTemplate,
|
applyBranchTemplate,
|
||||||
generateBranchName,
|
generateBranchName,
|
||||||
} from "../src/utils/branch-template";
|
} from "../src/utils/branch-template";
|
||||||
import { validateBranchName } from "../src/github/operations/branch";
|
|
||||||
|
|
||||||
describe("branch template utilities", () => {
|
describe("branch template utilities", () => {
|
||||||
describe("applyBranchTemplate", () => {
|
describe("applyBranchTemplate", () => {
|
||||||
@ -145,53 +144,6 @@ describe("branch template utilities", () => {
|
|||||||
expect(result).toBe("dev/enhancement-issue_789");
|
expect(result).toBe("dev/enhancement-issue_789");
|
||||||
});
|
});
|
||||||
|
|
||||||
it("should sanitize scoped labels that contain invalid git characters", () => {
|
|
||||||
const template = "{{prefix}}{{label}}/{{entityNumber}}";
|
|
||||||
const result = generateBranchName(
|
|
||||||
template,
|
|
||||||
"claude/",
|
|
||||||
"issue",
|
|
||||||
123,
|
|
||||||
undefined,
|
|
||||||
"area:permissions",
|
|
||||||
);
|
|
||||||
|
|
||||||
expect(result).toBe("claude/area-permissions/123");
|
|
||||||
// Regression: an unsanitized ":" here previously failed validateBranchName
|
|
||||||
// and crashed the run via process.exit(1).
|
|
||||||
expect(() => validateBranchName(result)).not.toThrow();
|
|
||||||
});
|
|
||||||
|
|
||||||
it("should replace spaces in labels with hyphens", () => {
|
|
||||||
const template = "{{prefix}}{{label}}-{{entityNumber}}";
|
|
||||||
const result = generateBranchName(
|
|
||||||
template,
|
|
||||||
"fix/",
|
|
||||||
"issue",
|
|
||||||
456,
|
|
||||||
undefined,
|
|
||||||
"needs review",
|
|
||||||
);
|
|
||||||
|
|
||||||
expect(result).toBe("fix/needs-review-456");
|
|
||||||
expect(() => validateBranchName(result)).not.toThrow();
|
|
||||||
});
|
|
||||||
|
|
||||||
it("should fall back to entityType when a label sanitizes to empty", () => {
|
|
||||||
const template = "{{prefix}}{{label}}-{{entityNumber}}";
|
|
||||||
const result = generateBranchName(
|
|
||||||
template,
|
|
||||||
"fix/",
|
|
||||||
"pr",
|
|
||||||
789,
|
|
||||||
undefined,
|
|
||||||
"🎉",
|
|
||||||
);
|
|
||||||
|
|
||||||
expect(result).toBe("fix/pr-789");
|
|
||||||
expect(() => validateBranchName(result)).not.toThrow();
|
|
||||||
});
|
|
||||||
|
|
||||||
it("should use description in template when provided", () => {
|
it("should use description in template when provided", () => {
|
||||||
const template = "{{prefix}}{{description}}/{{entityNumber}}";
|
const template = "{{prefix}}{{description}}/{{entityNumber}}";
|
||||||
const result = generateBranchName(
|
const result = generateBranchName(
|
||||||
|
|||||||
@ -1,74 +0,0 @@
|
|||||||
import { describe, test, expect } from "bun:test";
|
|
||||||
import {
|
|
||||||
SPINNER_HTML,
|
|
||||||
createJobRunLink,
|
|
||||||
createBranchLink,
|
|
||||||
createCommentBody,
|
|
||||||
} from "../src/github/operations/comments/common";
|
|
||||||
import { GITHUB_SERVER_URL } from "../src/github/api/config";
|
|
||||||
|
|
||||||
describe("comments/common", () => {
|
|
||||||
describe("createJobRunLink", () => {
|
|
||||||
test("builds a markdown link to the workflow run", () => {
|
|
||||||
const result = createJobRunLink("anthropics", "claude-code-action", "42");
|
|
||||||
expect(result).toBe(
|
|
||||||
`[View job run](${GITHUB_SERVER_URL}/anthropics/claude-code-action/actions/runs/42)`,
|
|
||||||
);
|
|
||||||
});
|
|
||||||
|
|
||||||
test("honors GITHUB_SERVER_URL (GHES) rather than hardcoding github.com", () => {
|
|
||||||
// The link is built from the configured server URL, so it must point at
|
|
||||||
// whatever GITHUB_SERVER_URL resolves to (github.com by default, a GHES
|
|
||||||
// host in enterprise setups).
|
|
||||||
expect(createJobRunLink("o", "r", "1")).toContain(GITHUB_SERVER_URL);
|
|
||||||
});
|
|
||||||
});
|
|
||||||
|
|
||||||
describe("createBranchLink", () => {
|
|
||||||
test("builds a leading-newline markdown link to the branch tree", () => {
|
|
||||||
const result = createBranchLink(
|
|
||||||
"anthropics",
|
|
||||||
"claude-code-action",
|
|
||||||
"feature/x",
|
|
||||||
);
|
|
||||||
expect(result).toBe(
|
|
||||||
`\n[View branch](${GITHUB_SERVER_URL}/anthropics/claude-code-action/tree/feature/x)`,
|
|
||||||
);
|
|
||||||
});
|
|
||||||
|
|
||||||
test("prefixes the link with a newline so it renders on its own line", () => {
|
|
||||||
expect(createBranchLink("o", "r", "main").startsWith("\n")).toBe(true);
|
|
||||||
});
|
|
||||||
});
|
|
||||||
|
|
||||||
describe("createCommentBody", () => {
|
|
||||||
test("includes the spinner, the working message, and the job run link", () => {
|
|
||||||
const jobRunLink = createJobRunLink("o", "r", "7");
|
|
||||||
const body = createCommentBody(jobRunLink);
|
|
||||||
|
|
||||||
expect(body).toContain(SPINNER_HTML);
|
|
||||||
expect(body).toContain("Claude Code is working…");
|
|
||||||
expect(body).toContain(jobRunLink);
|
|
||||||
});
|
|
||||||
|
|
||||||
test("omits the branch link when none is provided (defaults to empty)", () => {
|
|
||||||
const body = createCommentBody(createJobRunLink("o", "r", "7"));
|
|
||||||
expect(body).not.toContain("View branch");
|
|
||||||
// No trailing branch content: body ends with the job run link.
|
|
||||||
expect(body.endsWith(")")).toBe(true);
|
|
||||||
});
|
|
||||||
|
|
||||||
test("appends the branch link when provided", () => {
|
|
||||||
const jobRunLink = createJobRunLink("o", "r", "7");
|
|
||||||
const branchLink = createBranchLink("o", "r", "feature/x");
|
|
||||||
const body = createCommentBody(jobRunLink, branchLink);
|
|
||||||
|
|
||||||
expect(body).toContain(jobRunLink);
|
|
||||||
expect(body).toContain(branchLink);
|
|
||||||
// The branch link (with its leading newline) comes after the job run link.
|
|
||||||
expect(body.indexOf(branchLink)).toBeGreaterThan(
|
|
||||||
body.indexOf(jobRunLink),
|
|
||||||
);
|
|
||||||
});
|
|
||||||
});
|
|
||||||
});
|
|
||||||
@ -6,10 +6,8 @@ import {
|
|||||||
getEventTypeAndContext,
|
getEventTypeAndContext,
|
||||||
buildAllowedToolsString,
|
buildAllowedToolsString,
|
||||||
buildDisallowedToolsString,
|
buildDisallowedToolsString,
|
||||||
prepareContext,
|
|
||||||
} from "../src/create-prompt";
|
} from "../src/create-prompt";
|
||||||
import type { PreparedContext } from "../src/create-prompt";
|
import type { PreparedContext } from "../src/create-prompt";
|
||||||
import { createMockContext } from "./mockContext";
|
|
||||||
|
|
||||||
beforeAll(() => {
|
beforeAll(() => {
|
||||||
process.env.GITHUB_ACTION_PATH = "/test/action/path";
|
process.env.GITHUB_ACTION_PATH = "/test/action/path";
|
||||||
@ -1272,83 +1270,3 @@ describe("buildDisallowedToolsString", () => {
|
|||||||
expect(result).toBe("BadTool1,BadTool2");
|
expect(result).toBe("BadTool1,BadTool2");
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
describe("prepareContext validation errors", () => {
|
|
||||||
const commentId = "12345";
|
|
||||||
|
|
||||||
test("throws on an unsupported event type", () => {
|
|
||||||
const context = createMockContext({
|
|
||||||
eventName: "deployment_status" as any,
|
|
||||||
});
|
|
||||||
|
|
||||||
expect(() => prepareContext(context, commentId)).toThrow(
|
|
||||||
"Unsupported event type: deployment_status",
|
|
||||||
);
|
|
||||||
});
|
|
||||||
|
|
||||||
test("pull_request event requires a PR number (isPR must be true)", () => {
|
|
||||||
const context = createMockContext({
|
|
||||||
eventName: "pull_request",
|
|
||||||
eventAction: "opened",
|
|
||||||
isPR: false,
|
|
||||||
});
|
|
||||||
|
|
||||||
expect(() => prepareContext(context, commentId)).toThrow(
|
|
||||||
"PR_NUMBER is required for pull_request event",
|
|
||||||
);
|
|
||||||
});
|
|
||||||
|
|
||||||
test("pull_request_review event requires a PR number", () => {
|
|
||||||
const context = createMockContext({
|
|
||||||
eventName: "pull_request_review",
|
|
||||||
isPR: false,
|
|
||||||
payload: {
|
|
||||||
review: { body: "please fix", user: { login: "user1" } },
|
|
||||||
} as any,
|
|
||||||
});
|
|
||||||
|
|
||||||
expect(() => prepareContext(context, commentId)).toThrow(
|
|
||||||
"PR_NUMBER is required for pull_request_review event",
|
|
||||||
);
|
|
||||||
});
|
|
||||||
|
|
||||||
test("issues event requires an event action", () => {
|
|
||||||
const context = createMockContext({
|
|
||||||
eventName: "issues",
|
|
||||||
eventAction: "",
|
|
||||||
isPR: false,
|
|
||||||
payload: { issue: { user: { login: "user1" } } } as any,
|
|
||||||
});
|
|
||||||
|
|
||||||
expect(() => prepareContext(context, commentId)).toThrow(
|
|
||||||
"GITHUB_EVENT_ACTION is required for issues event",
|
|
||||||
);
|
|
||||||
});
|
|
||||||
|
|
||||||
test("issues event rejects an unsupported action", () => {
|
|
||||||
const context = createMockContext({
|
|
||||||
eventName: "issues",
|
|
||||||
eventAction: "deleted",
|
|
||||||
isPR: false,
|
|
||||||
payload: { issue: { user: { login: "user1" } } } as any,
|
|
||||||
});
|
|
||||||
|
|
||||||
expect(() =>
|
|
||||||
prepareContext(context, commentId, "main", "claude/issue-1"),
|
|
||||||
).toThrow("Unsupported issue action: deleted");
|
|
||||||
});
|
|
||||||
|
|
||||||
test("issue_comment on an issue requires a claude branch", () => {
|
|
||||||
const context = createMockContext({
|
|
||||||
eventName: "issue_comment",
|
|
||||||
isPR: false,
|
|
||||||
payload: {
|
|
||||||
comment: { id: 999, body: "@claude help", user: { login: "user1" } },
|
|
||||||
} as any,
|
|
||||||
});
|
|
||||||
|
|
||||||
expect(() => prepareContext(context, commentId)).toThrow(
|
|
||||||
"CLAUDE_BRANCH is required for issue_comment event",
|
|
||||||
);
|
|
||||||
});
|
|
||||||
});
|
|
||||||
|
|||||||
@ -1,7 +1,6 @@
|
|||||||
import { describe, expect, it, jest, test } from "bun:test";
|
import { describe, expect, it, jest, test } from "bun:test";
|
||||||
import {
|
import {
|
||||||
extractTriggerTimestamp,
|
extractTriggerTimestamp,
|
||||||
resolveTriggerTimestamp,
|
|
||||||
extractOriginalTitle,
|
extractOriginalTitle,
|
||||||
extractOriginalBody,
|
extractOriginalBody,
|
||||||
fetchGitHubData,
|
fetchGitHubData,
|
||||||
@ -9,7 +8,6 @@ import {
|
|||||||
filterReviewsToTriggerTime,
|
filterReviewsToTriggerTime,
|
||||||
isBodySafeToUse,
|
isBodySafeToUse,
|
||||||
} from "../src/github/data/fetcher";
|
} from "../src/github/data/fetcher";
|
||||||
import type { ParsedGitHubContext } from "../src/github/context";
|
|
||||||
import {
|
import {
|
||||||
createMockContext,
|
createMockContext,
|
||||||
mockIssueCommentContext,
|
mockIssueCommentContext,
|
||||||
@ -18,8 +16,6 @@ import {
|
|||||||
mockPullRequestReviewCommentContext,
|
mockPullRequestReviewCommentContext,
|
||||||
mockPullRequestOpenedContext,
|
mockPullRequestOpenedContext,
|
||||||
mockIssueOpenedContext,
|
mockIssueOpenedContext,
|
||||||
mockIssueAssignedContext,
|
|
||||||
mockIssueLabeledContext,
|
|
||||||
} from "./mockContext";
|
} from "./mockContext";
|
||||||
import type { GitHubComment, GitHubReview } from "../src/github/types";
|
import type { GitHubComment, GitHubReview } from "../src/github/types";
|
||||||
|
|
||||||
@ -42,112 +38,15 @@ describe("extractTriggerTimestamp", () => {
|
|||||||
expect(timestamp).toBe("2024-01-15T16:45:00Z");
|
expect(timestamp).toBe("2024-01-15T16:45:00Z");
|
||||||
});
|
});
|
||||||
|
|
||||||
it("should extract created_at timestamp from pull_request opened event", () => {
|
it("should return undefined for pull_request event", () => {
|
||||||
const context = mockPullRequestOpenedContext;
|
const context = mockPullRequestOpenedContext;
|
||||||
const timestamp = extractTriggerTimestamp(context);
|
const timestamp = extractTriggerTimestamp(context);
|
||||||
expect(timestamp).toBe("2024-01-15T14:00:00Z");
|
expect(timestamp).toBeUndefined();
|
||||||
});
|
});
|
||||||
|
|
||||||
it("should extract updated_at timestamp from pull_request synchronize event", () => {
|
it("should return undefined for issues event", () => {
|
||||||
const context: ParsedGitHubContext = {
|
|
||||||
...mockPullRequestOpenedContext,
|
|
||||||
eventAction: "synchronize",
|
|
||||||
payload: {
|
|
||||||
...(mockPullRequestOpenedContext.payload as any),
|
|
||||||
action: "synchronize",
|
|
||||||
},
|
|
||||||
};
|
|
||||||
const timestamp = extractTriggerTimestamp(context);
|
|
||||||
expect(timestamp).toBe("2024-01-15T14:05:00Z");
|
|
||||||
});
|
|
||||||
|
|
||||||
it("should extract updated_at timestamp from pull_request edited event", () => {
|
|
||||||
const context: ParsedGitHubContext = {
|
|
||||||
...mockPullRequestOpenedContext,
|
|
||||||
eventAction: "edited",
|
|
||||||
payload: {
|
|
||||||
...(mockPullRequestOpenedContext.payload as any),
|
|
||||||
action: "edited",
|
|
||||||
},
|
|
||||||
};
|
|
||||||
const timestamp = extractTriggerTimestamp(context);
|
|
||||||
expect(timestamp).toBe("2024-01-15T14:05:00Z");
|
|
||||||
});
|
|
||||||
|
|
||||||
it("should extract created_at timestamp from issues opened event", () => {
|
|
||||||
const context = mockIssueOpenedContext;
|
const context = mockIssueOpenedContext;
|
||||||
const timestamp = extractTriggerTimestamp(context);
|
const timestamp = extractTriggerTimestamp(context);
|
||||||
expect(timestamp).toBe("2024-01-15T10:30:00Z");
|
|
||||||
});
|
|
||||||
|
|
||||||
it("should fall back to updated_at for issues labeled event", () => {
|
|
||||||
const context = mockIssueLabeledContext;
|
|
||||||
const timestamp = extractTriggerTimestamp(context);
|
|
||||||
expect(timestamp).toBe("2024-01-15T11:30:00Z");
|
|
||||||
});
|
|
||||||
|
|
||||||
it("should fall back to updated_at for issues assigned event", () => {
|
|
||||||
const context = mockIssueAssignedContext;
|
|
||||||
const timestamp = extractTriggerTimestamp(context);
|
|
||||||
expect(timestamp).toBe("2024-01-15T11:00:00Z");
|
|
||||||
});
|
|
||||||
|
|
||||||
it("should fall back to created_at for issues labeled event without updated_at", () => {
|
|
||||||
const context = createMockContext({
|
|
||||||
eventName: "issues",
|
|
||||||
eventAction: "labeled",
|
|
||||||
payload: {
|
|
||||||
action: "labeled",
|
|
||||||
issue: {
|
|
||||||
number: 1,
|
|
||||||
title: "test",
|
|
||||||
body: "test",
|
|
||||||
created_at: "2024-01-15T08:00:00Z",
|
|
||||||
},
|
|
||||||
} as any,
|
|
||||||
entityNumber: 1,
|
|
||||||
isPR: false,
|
|
||||||
});
|
|
||||||
const timestamp = extractTriggerTimestamp(context);
|
|
||||||
expect(timestamp).toBe("2024-01-15T08:00:00Z");
|
|
||||||
});
|
|
||||||
|
|
||||||
it("should fall back to created_at for pull_request synchronize event without updated_at", () => {
|
|
||||||
const context = createMockContext({
|
|
||||||
eventName: "pull_request",
|
|
||||||
eventAction: "synchronize",
|
|
||||||
payload: {
|
|
||||||
action: "synchronize",
|
|
||||||
pull_request: {
|
|
||||||
number: 1,
|
|
||||||
title: "test",
|
|
||||||
body: "test",
|
|
||||||
created_at: "2024-01-15T08:30:00Z",
|
|
||||||
},
|
|
||||||
} as any,
|
|
||||||
entityNumber: 1,
|
|
||||||
isPR: true,
|
|
||||||
});
|
|
||||||
const timestamp = extractTriggerTimestamp(context);
|
|
||||||
expect(timestamp).toBe("2024-01-15T08:30:00Z");
|
|
||||||
});
|
|
||||||
|
|
||||||
it("should return undefined for issues event without timestamps", () => {
|
|
||||||
const context = createMockContext({
|
|
||||||
eventName: "issues",
|
|
||||||
eventAction: "labeled",
|
|
||||||
payload: {
|
|
||||||
action: "labeled",
|
|
||||||
issue: {
|
|
||||||
number: 1,
|
|
||||||
title: "test",
|
|
||||||
body: "test",
|
|
||||||
},
|
|
||||||
} as any,
|
|
||||||
entityNumber: 1,
|
|
||||||
isPR: false,
|
|
||||||
});
|
|
||||||
const timestamp = extractTriggerTimestamp(context);
|
|
||||||
expect(timestamp).toBeUndefined();
|
expect(timestamp).toBeUndefined();
|
||||||
});
|
});
|
||||||
|
|
||||||
@ -167,195 +66,6 @@ describe("extractTriggerTimestamp", () => {
|
|||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
describe("resolveTriggerTimestamp", () => {
|
|
||||||
const createEventsOctokits = (events: any[]) => {
|
|
||||||
const paginate = jest.fn().mockResolvedValue(events);
|
|
||||||
return {
|
|
||||||
octokits: {
|
|
||||||
rest: {
|
|
||||||
paginate,
|
|
||||||
issues: { listEvents: jest.fn() },
|
|
||||||
},
|
|
||||||
graphql: jest.fn(),
|
|
||||||
} as any,
|
|
||||||
paginate,
|
|
||||||
};
|
|
||||||
};
|
|
||||||
|
|
||||||
it("should use the labeled event time for issues labeled event", async () => {
|
|
||||||
const { octokits, paginate } = createEventsOctokits([
|
|
||||||
{
|
|
||||||
event: "labeled",
|
|
||||||
label: { name: "other-label" },
|
|
||||||
created_at: "2024-01-15T10:45:00Z",
|
|
||||||
},
|
|
||||||
{
|
|
||||||
event: "labeled",
|
|
||||||
label: { name: "claude-task" },
|
|
||||||
created_at: "2024-01-15T10:50:00Z",
|
|
||||||
},
|
|
||||||
{
|
|
||||||
event: "labeled",
|
|
||||||
label: { name: "claude-task" },
|
|
||||||
created_at: "2024-01-15T11:45:00Z",
|
|
||||||
},
|
|
||||||
{
|
|
||||||
event: "assigned",
|
|
||||||
assignee: { login: "claude-bot" },
|
|
||||||
created_at: "2024-01-15T11:50:00Z",
|
|
||||||
},
|
|
||||||
]);
|
|
||||||
|
|
||||||
const timestamp = await resolveTriggerTimestamp(
|
|
||||||
mockIssueLabeledContext,
|
|
||||||
octokits,
|
|
||||||
);
|
|
||||||
|
|
||||||
expect(timestamp).toBe("2024-01-15T11:45:00Z");
|
|
||||||
expect(paginate).toHaveBeenCalledWith(octokits.rest.issues.listEvents, {
|
|
||||||
owner: "test-owner",
|
|
||||||
repo: "test-repo",
|
|
||||||
issue_number: 1234,
|
|
||||||
per_page: 100,
|
|
||||||
});
|
|
||||||
});
|
|
||||||
|
|
||||||
it("should use the assigned event time for issues assigned event", async () => {
|
|
||||||
const { octokits } = createEventsOctokits([
|
|
||||||
{
|
|
||||||
event: "assigned",
|
|
||||||
assignee: { login: "someone-else" },
|
|
||||||
created_at: "2024-01-15T10:40:00Z",
|
|
||||||
},
|
|
||||||
{
|
|
||||||
event: "assigned",
|
|
||||||
assignee: { login: "claude-bot" },
|
|
||||||
created_at: "2024-01-15T11:05:00Z",
|
|
||||||
},
|
|
||||||
]);
|
|
||||||
|
|
||||||
const timestamp = await resolveTriggerTimestamp(
|
|
||||||
mockIssueAssignedContext,
|
|
||||||
octokits,
|
|
||||||
);
|
|
||||||
|
|
||||||
expect(timestamp).toBe("2024-01-15T11:05:00Z");
|
|
||||||
});
|
|
||||||
|
|
||||||
it("should ignore a matching event that predates the issue's updated_at", async () => {
|
|
||||||
// A match older than the payload's updated_at is a previous
|
|
||||||
// labeling, not the one that fired this webhook.
|
|
||||||
const { octokits } = createEventsOctokits([
|
|
||||||
{
|
|
||||||
event: "labeled",
|
|
||||||
label: { name: "claude-task" },
|
|
||||||
created_at: "2024-01-15T10:50:00Z",
|
|
||||||
},
|
|
||||||
]);
|
|
||||||
|
|
||||||
const timestamp = await resolveTriggerTimestamp(
|
|
||||||
mockIssueLabeledContext,
|
|
||||||
octokits,
|
|
||||||
);
|
|
||||||
|
|
||||||
expect(timestamp).toBe("2024-01-15T11:30:00Z");
|
|
||||||
});
|
|
||||||
|
|
||||||
it("should fall back to updated_at when no matching labeled event exists", async () => {
|
|
||||||
const { octokits } = createEventsOctokits([
|
|
||||||
{
|
|
||||||
event: "labeled",
|
|
||||||
label: { name: "unrelated" },
|
|
||||||
created_at: "2024-01-15T10:45:00Z",
|
|
||||||
},
|
|
||||||
]);
|
|
||||||
|
|
||||||
const timestamp = await resolveTriggerTimestamp(
|
|
||||||
mockIssueLabeledContext,
|
|
||||||
octokits,
|
|
||||||
);
|
|
||||||
|
|
||||||
expect(timestamp).toBe("2024-01-15T11:30:00Z");
|
|
||||||
});
|
|
||||||
|
|
||||||
it("should fall back to updated_at when the events lookup fails", async () => {
|
|
||||||
const octokits = {
|
|
||||||
rest: {
|
|
||||||
paginate: jest.fn().mockRejectedValue(new Error("API failure")),
|
|
||||||
issues: { listEvents: jest.fn() },
|
|
||||||
},
|
|
||||||
graphql: jest.fn(),
|
|
||||||
} as any;
|
|
||||||
|
|
||||||
const timestamp = await resolveTriggerTimestamp(
|
|
||||||
mockIssueAssignedContext,
|
|
||||||
octokits,
|
|
||||||
);
|
|
||||||
|
|
||||||
expect(timestamp).toBe("2024-01-15T11:00:00Z");
|
|
||||||
});
|
|
||||||
|
|
||||||
it("should fall back to created_at when updated_at is also missing", async () => {
|
|
||||||
const context = createMockContext({
|
|
||||||
eventName: "issues",
|
|
||||||
eventAction: "labeled",
|
|
||||||
payload: {
|
|
||||||
action: "labeled",
|
|
||||||
label: { name: "claude-task" },
|
|
||||||
issue: {
|
|
||||||
number: 1,
|
|
||||||
title: "test",
|
|
||||||
body: "test",
|
|
||||||
created_at: "2024-01-15T08:00:00Z",
|
|
||||||
},
|
|
||||||
} as any,
|
|
||||||
entityNumber: 1,
|
|
||||||
isPR: false,
|
|
||||||
});
|
|
||||||
const { octokits } = createEventsOctokits([]);
|
|
||||||
|
|
||||||
const timestamp = await resolveTriggerTimestamp(context, octokits);
|
|
||||||
|
|
||||||
expect(timestamp).toBe("2024-01-15T08:00:00Z");
|
|
||||||
});
|
|
||||||
|
|
||||||
it("should use created_at for issues opened event without an API call", async () => {
|
|
||||||
const { octokits, paginate } = createEventsOctokits([]);
|
|
||||||
|
|
||||||
const timestamp = await resolveTriggerTimestamp(
|
|
||||||
mockIssueOpenedContext,
|
|
||||||
octokits,
|
|
||||||
);
|
|
||||||
|
|
||||||
expect(timestamp).toBe("2024-01-15T10:30:00Z");
|
|
||||||
expect(paginate).not.toHaveBeenCalled();
|
|
||||||
});
|
|
||||||
|
|
||||||
it("should use created_at for pull_request opened event without an API call", async () => {
|
|
||||||
const { octokits, paginate } = createEventsOctokits([]);
|
|
||||||
|
|
||||||
const timestamp = await resolveTriggerTimestamp(
|
|
||||||
mockPullRequestOpenedContext,
|
|
||||||
octokits,
|
|
||||||
);
|
|
||||||
|
|
||||||
expect(timestamp).toBe("2024-01-15T14:00:00Z");
|
|
||||||
expect(paginate).not.toHaveBeenCalled();
|
|
||||||
});
|
|
||||||
|
|
||||||
it("should use the existing comment timestamp for issue_comment events", async () => {
|
|
||||||
const { octokits, paginate } = createEventsOctokits([]);
|
|
||||||
|
|
||||||
const timestamp = await resolveTriggerTimestamp(
|
|
||||||
mockIssueCommentContext,
|
|
||||||
octokits,
|
|
||||||
);
|
|
||||||
|
|
||||||
expect(timestamp).toBe("2024-01-15T12:30:00Z");
|
|
||||||
expect(paginate).not.toHaveBeenCalled();
|
|
||||||
});
|
|
||||||
});
|
|
||||||
|
|
||||||
describe("extractOriginalTitle", () => {
|
describe("extractOriginalTitle", () => {
|
||||||
it("should extract title from IssueCommentEvent on PR", () => {
|
it("should extract title from IssueCommentEvent on PR", () => {
|
||||||
const title = extractOriginalTitle(mockPullRequestCommentContext);
|
const title = extractOriginalTitle(mockPullRequestCommentContext);
|
||||||
@ -949,90 +659,6 @@ describe("fetchGitHubData integration with time filtering", () => {
|
|||||||
expect(result.comments[0]?.body).toBe("Comment before trigger");
|
expect(result.comments[0]?.body).toBe("Comment before trigger");
|
||||||
});
|
});
|
||||||
|
|
||||||
it("should filter comments using the resolved issues labeled event time", async () => {
|
|
||||||
const mockOctokits = {
|
|
||||||
graphql: jest.fn().mockResolvedValue({
|
|
||||||
repository: {
|
|
||||||
issue: {
|
|
||||||
number: 1234,
|
|
||||||
title: "Test Issue",
|
|
||||||
body: "Issue body",
|
|
||||||
author: { login: "author" },
|
|
||||||
comments: {
|
|
||||||
nodes: [
|
|
||||||
{
|
|
||||||
id: "1",
|
|
||||||
databaseId: "1",
|
|
||||||
body: "Comment before label",
|
|
||||||
author: { login: "user1" },
|
|
||||||
createdAt: "2024-01-15T10:00:00Z",
|
|
||||||
updatedAt: "2024-01-15T10:00:00Z",
|
|
||||||
},
|
|
||||||
{
|
|
||||||
id: "2",
|
|
||||||
databaseId: "2",
|
|
||||||
body: "Comment created after label",
|
|
||||||
author: { login: "user2" },
|
|
||||||
createdAt: "2024-01-15T12:00:00Z",
|
|
||||||
updatedAt: "2024-01-15T12:00:00Z",
|
|
||||||
},
|
|
||||||
{
|
|
||||||
id: "3",
|
|
||||||
databaseId: "3",
|
|
||||||
body: "Comment edited after label",
|
|
||||||
author: { login: "user3" },
|
|
||||||
createdAt: "2024-01-15T10:00:00Z",
|
|
||||||
updatedAt: "2024-01-15T12:00:00Z",
|
|
||||||
lastEditedAt: "2024-01-15T12:00:00Z",
|
|
||||||
},
|
|
||||||
{
|
|
||||||
id: "4",
|
|
||||||
databaseId: "4",
|
|
||||||
body: "Latest comment before label",
|
|
||||||
author: { login: "user4" },
|
|
||||||
createdAt: "2024-01-15T11:30:00Z",
|
|
||||||
updatedAt: "2024-01-15T11:30:00Z",
|
|
||||||
},
|
|
||||||
],
|
|
||||||
},
|
|
||||||
},
|
|
||||||
},
|
|
||||||
user: { login: "trigger-user" },
|
|
||||||
}),
|
|
||||||
rest: {
|
|
||||||
paginate: jest.fn().mockResolvedValue([
|
|
||||||
{
|
|
||||||
event: "labeled",
|
|
||||||
label: { name: "claude-task" },
|
|
||||||
created_at: "2024-01-15T11:45:00Z",
|
|
||||||
},
|
|
||||||
]),
|
|
||||||
issues: { listEvents: jest.fn() },
|
|
||||||
},
|
|
||||||
};
|
|
||||||
|
|
||||||
// The issues (labeled) webhook has no trigger comment; the boundary is
|
|
||||||
// the labeled event's own timestamp from the issue event history.
|
|
||||||
const triggerTime = await resolveTriggerTimestamp(
|
|
||||||
mockIssueLabeledContext,
|
|
||||||
mockOctokits as any,
|
|
||||||
);
|
|
||||||
expect(triggerTime).toBe("2024-01-15T11:45:00Z");
|
|
||||||
|
|
||||||
const result = await fetchGitHubData({
|
|
||||||
octokits: mockOctokits as any,
|
|
||||||
repository: "test-owner/test-repo",
|
|
||||||
prNumber: "1234",
|
|
||||||
isPR: false,
|
|
||||||
triggerUsername: "trigger-user",
|
|
||||||
triggerTime,
|
|
||||||
});
|
|
||||||
|
|
||||||
// Comments created before the label are kept (including the most
|
|
||||||
// recent one); comments created or edited after it are excluded.
|
|
||||||
expect(result.comments.map((c) => c.id)).toEqual(["1", "4"]);
|
|
||||||
});
|
|
||||||
|
|
||||||
it("should filter PR reviews based on trigger time", async () => {
|
it("should filter PR reviews based on trigger time", async () => {
|
||||||
const mockOctokits = {
|
const mockOctokits = {
|
||||||
graphql: jest.fn().mockResolvedValue({
|
graphql: jest.fn().mockResolvedValue({
|
||||||
@ -1873,42 +1499,4 @@ describe("filterCommentsByActor", () => {
|
|||||||
const filtered = filterCommentsByActor(comments, "user1", "");
|
const filtered = filterCommentsByActor(comments, "user1", "");
|
||||||
expect(filtered).toHaveLength(0);
|
expect(filtered).toHaveLength(0);
|
||||||
});
|
});
|
||||||
|
|
||||||
test("does not crash on comments from deleted (null-author) accounts", () => {
|
|
||||||
// GitHub's GraphQL returns author: null for comments whose account was
|
|
||||||
// deleted. With an exclude filter set (the exact `*[bot]` config we
|
|
||||||
// recommend), the null author must not throw when dereferenced.
|
|
||||||
const comments = [
|
|
||||||
{ author: { login: "user1" }, body: "comment1" },
|
|
||||||
{ author: null, body: "from a deleted account" },
|
|
||||||
{ author: { login: "bot[bot]" }, body: "comment3" },
|
|
||||||
];
|
|
||||||
|
|
||||||
const { filterCommentsByActor } = require("../src/github/data/fetcher");
|
|
||||||
const filtered = filterCommentsByActor(comments, "", "*[bot]");
|
|
||||||
// ghost comment is retained (it matches no exclude pattern); the bot is dropped.
|
|
||||||
expect(filtered).toHaveLength(2);
|
|
||||||
expect(filtered.map((c: any) => c.body)).toEqual([
|
|
||||||
"comment1",
|
|
||||||
"from a deleted account",
|
|
||||||
]);
|
|
||||||
});
|
|
||||||
|
|
||||||
test("treats null author as the 'ghost' login for include/exclude", () => {
|
|
||||||
const comments = [
|
|
||||||
{ author: null, body: "from a deleted account" },
|
|
||||||
{ author: { login: "user1" }, body: "comment2" },
|
|
||||||
];
|
|
||||||
|
|
||||||
const { filterCommentsByActor } = require("../src/github/data/fetcher");
|
|
||||||
// Excluding "ghost" removes the deleted-account comment.
|
|
||||||
expect(filterCommentsByActor(comments, "", "ghost")).toHaveLength(1);
|
|
||||||
expect(filterCommentsByActor(comments, "", "ghost")[0].body).toBe(
|
|
||||||
"comment2",
|
|
||||||
);
|
|
||||||
// Including only "ghost" keeps just the deleted-account comment.
|
|
||||||
const onlyGhost = filterCommentsByActor(comments, "ghost", "");
|
|
||||||
expect(onlyGhost).toHaveLength(1);
|
|
||||||
expect(onlyGhost[0].body).toBe("from a deleted account");
|
|
||||||
});
|
|
||||||
});
|
});
|
||||||
|
|||||||
@ -159,21 +159,6 @@ Issue State: OPEN
|
|||||||
Issue Labels: architecture, agent-sdk, drift:functional`,
|
Issue Labels: architecture, agent-sdk, drift:functional`,
|
||||||
);
|
);
|
||||||
});
|
});
|
||||||
|
|
||||||
test("renders a deleted (null-author) issue author as 'ghost'", () => {
|
|
||||||
const issueData: GitHubIssue = {
|
|
||||||
title: "Test Issue",
|
|
||||||
body: "Issue body",
|
|
||||||
author: null,
|
|
||||||
createdAt: "2023-01-01T00:00:00Z",
|
|
||||||
state: "OPEN",
|
|
||||||
labels: { nodes: [] },
|
|
||||||
comments: { nodes: [] },
|
|
||||||
};
|
|
||||||
|
|
||||||
const result = formatContext(issueData, false);
|
|
||||||
expect(result).toContain("Issue Author: ghost");
|
|
||||||
});
|
|
||||||
});
|
});
|
||||||
|
|
||||||
describe("formatBody", () => {
|
describe("formatBody", () => {
|
||||||
@ -267,24 +252,6 @@ describe("formatComments", () => {
|
|||||||
);
|
);
|
||||||
});
|
});
|
||||||
|
|
||||||
test("renders deleted (null-author) comments as 'ghost'", () => {
|
|
||||||
// GitHub returns author: null for comments from deleted accounts.
|
|
||||||
const comments: GitHubComment[] = [
|
|
||||||
{
|
|
||||||
id: "1",
|
|
||||||
databaseId: "100001",
|
|
||||||
body: "From a deleted account",
|
|
||||||
author: null,
|
|
||||||
createdAt: "2023-01-01T00:00:00Z",
|
|
||||||
},
|
|
||||||
];
|
|
||||||
|
|
||||||
const result = formatComments(comments);
|
|
||||||
expect(result).toBe(
|
|
||||||
"[ghost at 2023-01-01T00:00:00Z]: From a deleted account",
|
|
||||||
);
|
|
||||||
});
|
|
||||||
|
|
||||||
test("returns empty string for empty comments array", () => {
|
test("returns empty string for empty comments array", () => {
|
||||||
const result = formatComments([]);
|
const result = formatComments([]);
|
||||||
expect(result).toBe("");
|
expect(result).toBe("");
|
||||||
@ -527,29 +494,6 @@ describe("formatReviewComments", () => {
|
|||||||
);
|
);
|
||||||
});
|
});
|
||||||
|
|
||||||
test("renders deleted (null-author) reviews as 'ghost'", () => {
|
|
||||||
const reviewData = {
|
|
||||||
nodes: [
|
|
||||||
{
|
|
||||||
id: "review1",
|
|
||||||
databaseId: "300099",
|
|
||||||
author: null,
|
|
||||||
body: "Left before deleting the account",
|
|
||||||
state: "COMMENTED",
|
|
||||||
submittedAt: "2023-01-01T00:00:00Z",
|
|
||||||
comments: {
|
|
||||||
nodes: [],
|
|
||||||
},
|
|
||||||
},
|
|
||||||
],
|
|
||||||
};
|
|
||||||
|
|
||||||
const result = formatReviewComments(reviewData);
|
|
||||||
expect(result).toBe(
|
|
||||||
`[Review by ghost at 2023-01-01T00:00:00Z]: COMMENTED\nLeft before deleting the account`,
|
|
||||||
);
|
|
||||||
});
|
|
||||||
|
|
||||||
test("formats multiple reviews correctly", () => {
|
test("formats multiple reviews correctly", () => {
|
||||||
const reviewData = {
|
const reviewData = {
|
||||||
nodes: [
|
nodes: [
|
||||||
|
|||||||
@ -484,34 +484,4 @@ describe("system_other handling", () => {
|
|||||||
]);
|
]);
|
||||||
expect(markdown).toContain("## ⚙️ System Message");
|
expect(markdown).toContain("## ⚙️ System Message");
|
||||||
});
|
});
|
||||||
|
|
||||||
test("filters out thinking_tokens system messages", () => {
|
|
||||||
const data: Turn[] = [
|
|
||||||
{ type: "system", subtype: "init", tools: [{ name: "tool1" }] },
|
|
||||||
{ type: "system", subtype: "thinking_tokens" },
|
|
||||||
{ type: "system", subtype: "thinking_tokens" },
|
|
||||||
{ type: "system", subtype: "other_subtype" },
|
|
||||||
];
|
|
||||||
|
|
||||||
const grouped = groupTurnsNaturally(data);
|
|
||||||
|
|
||||||
// Should have init and other_subtype, but not thinking_tokens
|
|
||||||
expect(grouped).toHaveLength(2);
|
|
||||||
expect(grouped[0]?.type).toBe("system_init");
|
|
||||||
expect(grouped[1]?.type).toBe("system_other");
|
|
||||||
expect(grouped[1]?.data?.subtype).toBe("other_subtype");
|
|
||||||
});
|
|
||||||
|
|
||||||
test("thinking_tokens does not appear in formatted output", () => {
|
|
||||||
const data: Turn[] = [
|
|
||||||
{ type: "system", subtype: "init", tools: [] },
|
|
||||||
{ type: "system", subtype: "thinking_tokens" },
|
|
||||||
{ type: "system", subtype: "thinking_tokens" },
|
|
||||||
];
|
|
||||||
|
|
||||||
const result = formatTurnsFromData(data);
|
|
||||||
|
|
||||||
expect(result).not.toContain("thinking_tokens");
|
|
||||||
expect(result).toContain("## 🚀 System Initialization");
|
|
||||||
});
|
|
||||||
});
|
});
|
||||||
|
|||||||
@ -33,7 +33,6 @@ import {
|
|||||||
isIssuesAssignedEvent,
|
isIssuesAssignedEvent,
|
||||||
isEntityContext,
|
isEntityContext,
|
||||||
isAutomationContext,
|
isAutomationContext,
|
||||||
isWorkflowRunEvent,
|
|
||||||
} from "../src/github/context";
|
} from "../src/github/context";
|
||||||
import { CLAUDE_APP_BOT_ID, CLAUDE_BOT_LOGIN } from "../src/github/constants";
|
import { CLAUDE_APP_BOT_ID, CLAUDE_BOT_LOGIN } from "../src/github/constants";
|
||||||
import { createMockContext, createMockAutomationContext } from "./mockContext";
|
import { createMockContext, createMockAutomationContext } from "./mockContext";
|
||||||
@ -518,14 +517,4 @@ describe("type guards", () => {
|
|||||||
).toBe(true);
|
).toBe(true);
|
||||||
expect(isAutomationContext(issuesContext)).toBe(false);
|
expect(isAutomationContext(issuesContext)).toBe(false);
|
||||||
});
|
});
|
||||||
|
|
||||||
test("isWorkflowRunEvent accepts only workflow_run", () => {
|
|
||||||
expect(
|
|
||||||
isWorkflowRunEvent(
|
|
||||||
createMockAutomationContext({ eventName: "workflow_run" }),
|
|
||||||
),
|
|
||||||
).toBe(true);
|
|
||||||
expect(isWorkflowRunEvent(workflowDispatchContext)).toBe(false);
|
|
||||||
expect(isWorkflowRunEvent(issuesContext)).toBe(false);
|
|
||||||
});
|
|
||||||
});
|
});
|
||||||
|
|||||||
@ -13,17 +13,6 @@ import { downloadCommentImages } from "../src/github/utils/image-downloader";
|
|||||||
import type { CommentWithImages } from "../src/github/utils/image-downloader";
|
import type { CommentWithImages } from "../src/github/utils/image-downloader";
|
||||||
import type { Octokits } from "../src/github/api/client";
|
import type { Octokits } from "../src/github/api/client";
|
||||||
|
|
||||||
// Asset URLs and their signed download URLs share the asset's GUID.
|
|
||||||
const GUID_1 = "f871c23e-a84d-4f1f-b9a0-86626c63f161";
|
|
||||||
const GUID_2 = "0b0c9d33-4e6a-4f4e-8a1a-2f9e5c6d7e8f";
|
|
||||||
const GUID_3 = "a1b2c3d4-e5f6-4789-abcd-ef0123456789";
|
|
||||||
|
|
||||||
const assetUrl = (guid: string, suffix = "") =>
|
|
||||||
`https://github.com/user-attachments/assets/${guid}${suffix}`;
|
|
||||||
|
|
||||||
const signedUrlFor = (guid: string, ext: string, token = "token") =>
|
|
||||||
`https://private-user-images.githubusercontent.com/12345/98765432-${guid}${ext}?jwt=${token}`;
|
|
||||||
|
|
||||||
describe("downloadCommentImages", () => {
|
describe("downloadCommentImages", () => {
|
||||||
let consoleLogSpy: any;
|
let consoleLogSpy: any;
|
||||||
let consoleWarnSpy: any;
|
let consoleWarnSpy: any;
|
||||||
@ -108,8 +97,10 @@ describe("downloadCommentImages", () => {
|
|||||||
|
|
||||||
test("should detect and download images from issue comments", async () => {
|
test("should detect and download images from issue comments", async () => {
|
||||||
const mockOctokit = createMockOctokit();
|
const mockOctokit = createMockOctokit();
|
||||||
const imageUrl = assetUrl(GUID_1);
|
const imageUrl =
|
||||||
const signedUrl = signedUrlFor(GUID_1, ".png");
|
"https://github.com/user-attachments/assets/test-image.png";
|
||||||
|
const signedUrl =
|
||||||
|
"https://private-user-images.githubusercontent.com/test.png?jwt=token";
|
||||||
|
|
||||||
// Mock octokit response
|
// Mock octokit response
|
||||||
// @ts-expect-error Mock implementation doesn't match full type signature
|
// @ts-expect-error Mock implementation doesn't match full type signature
|
||||||
@ -173,8 +164,10 @@ describe("downloadCommentImages", () => {
|
|||||||
// the URL-based guess used to default to ".png" while the bytes are JPEG —
|
// the URL-based guess used to default to ".png" while the bytes are JPEG —
|
||||||
// producing a mislabeled file that the Anthropic API rejected with a 400.
|
// producing a mislabeled file that the Anthropic API rejected with a 400.
|
||||||
const mockOctokit = createMockOctokit();
|
const mockOctokit = createMockOctokit();
|
||||||
const imageUrl = assetUrl(GUID_1);
|
const imageUrl =
|
||||||
const signedUrl = signedUrlFor(GUID_1, ".jpg");
|
"https://github.com/user-attachments/assets/f871c23e-a84d-4f1f-b9a0-86626c63f161";
|
||||||
|
const signedUrl =
|
||||||
|
"https://private-user-images.githubusercontent.com/screenshot?jwt=token";
|
||||||
|
|
||||||
// @ts-expect-error Mock implementation doesn't match full type signature
|
// @ts-expect-error Mock implementation doesn't match full type signature
|
||||||
mockOctokit.rest.issues.get = jest.fn().mockResolvedValue({
|
mockOctokit.rest.issues.get = jest.fn().mockResolvedValue({
|
||||||
@ -216,8 +209,10 @@ describe("downloadCommentImages", () => {
|
|||||||
|
|
||||||
test("should handle review comments", async () => {
|
test("should handle review comments", async () => {
|
||||||
const mockOctokit = createMockOctokit();
|
const mockOctokit = createMockOctokit();
|
||||||
const imageUrl = assetUrl(GUID_1, ".jpg");
|
const imageUrl =
|
||||||
const signedUrl = signedUrlFor(GUID_1, ".jpg");
|
"https://github.com/user-attachments/assets/review-image.jpg";
|
||||||
|
const signedUrl =
|
||||||
|
"https://private-user-images.githubusercontent.com/review.jpg?jwt=token";
|
||||||
|
|
||||||
// @ts-expect-error Mock implementation doesn't match full type signature
|
// @ts-expect-error Mock implementation doesn't match full type signature
|
||||||
mockOctokit.rest.pulls.getReviewComment = jest.fn().mockResolvedValue({
|
mockOctokit.rest.pulls.getReviewComment = jest.fn().mockResolvedValue({
|
||||||
@ -260,8 +255,10 @@ describe("downloadCommentImages", () => {
|
|||||||
|
|
||||||
test("should handle review bodies", async () => {
|
test("should handle review bodies", async () => {
|
||||||
const mockOctokit = createMockOctokit();
|
const mockOctokit = createMockOctokit();
|
||||||
const imageUrl = assetUrl(GUID_1);
|
const imageUrl =
|
||||||
const signedUrl = signedUrlFor(GUID_1, ".png");
|
"https://github.com/user-attachments/assets/review-body.png";
|
||||||
|
const signedUrl =
|
||||||
|
"https://private-user-images.githubusercontent.com/body.png?jwt=token";
|
||||||
|
|
||||||
// @ts-expect-error Mock implementation doesn't match full type signature
|
// @ts-expect-error Mock implementation doesn't match full type signature
|
||||||
mockOctokit.rest.pulls.getReview = jest.fn().mockResolvedValue({
|
mockOctokit.rest.pulls.getReview = jest.fn().mockResolvedValue({
|
||||||
@ -306,8 +303,10 @@ describe("downloadCommentImages", () => {
|
|||||||
|
|
||||||
test("should handle issue bodies", async () => {
|
test("should handle issue bodies", async () => {
|
||||||
const mockOctokit = createMockOctokit();
|
const mockOctokit = createMockOctokit();
|
||||||
const imageUrl = assetUrl(GUID_1, ".gif");
|
const imageUrl =
|
||||||
const signedUrl = signedUrlFor(GUID_1, ".gif");
|
"https://github.com/user-attachments/assets/issue-body.gif";
|
||||||
|
const signedUrl =
|
||||||
|
"https://private-user-images.githubusercontent.com/issue.gif?jwt=token";
|
||||||
|
|
||||||
// @ts-expect-error Mock implementation doesn't match full type signature
|
// @ts-expect-error Mock implementation doesn't match full type signature
|
||||||
mockOctokit.rest.issues.get = jest.fn().mockResolvedValue({
|
mockOctokit.rest.issues.get = jest.fn().mockResolvedValue({
|
||||||
@ -353,8 +352,9 @@ describe("downloadCommentImages", () => {
|
|||||||
|
|
||||||
test("should handle PR bodies", async () => {
|
test("should handle PR bodies", async () => {
|
||||||
const mockOctokit = createMockOctokit();
|
const mockOctokit = createMockOctokit();
|
||||||
const imageUrl = assetUrl(GUID_1, ".webp");
|
const imageUrl = "https://github.com/user-attachments/assets/pr-body.webp";
|
||||||
const signedUrl = signedUrlFor(GUID_1, ".webp");
|
const signedUrl =
|
||||||
|
"https://private-user-images.githubusercontent.com/pr.webp?jwt=token";
|
||||||
|
|
||||||
// @ts-expect-error Mock implementation doesn't match full type signature
|
// @ts-expect-error Mock implementation doesn't match full type signature
|
||||||
mockOctokit.rest.pulls.get = jest.fn().mockResolvedValue({
|
mockOctokit.rest.pulls.get = jest.fn().mockResolvedValue({
|
||||||
@ -400,10 +400,12 @@ describe("downloadCommentImages", () => {
|
|||||||
|
|
||||||
test("should handle multiple images in a single comment", async () => {
|
test("should handle multiple images in a single comment", async () => {
|
||||||
const mockOctokit = createMockOctokit();
|
const mockOctokit = createMockOctokit();
|
||||||
const imageUrl1 = assetUrl(GUID_1);
|
const imageUrl1 = "https://github.com/user-attachments/assets/image1.png";
|
||||||
const imageUrl2 = assetUrl(GUID_2, ".jpg");
|
const imageUrl2 = "https://github.com/user-attachments/assets/image2.jpg";
|
||||||
const signedUrl1 = signedUrlFor(GUID_1, ".png", "token1");
|
const signedUrl1 =
|
||||||
const signedUrl2 = signedUrlFor(GUID_2, ".jpg", "token2");
|
"https://private-user-images.githubusercontent.com/1.png?jwt=token1";
|
||||||
|
const signedUrl2 =
|
||||||
|
"https://private-user-images.githubusercontent.com/2.jpg?jwt=token2";
|
||||||
|
|
||||||
// @ts-expect-error Mock implementation doesn't match full type signature
|
// @ts-expect-error Mock implementation doesn't match full type signature
|
||||||
mockOctokit.rest.issues.getComment = jest.fn().mockResolvedValue({
|
mockOctokit.rest.issues.getComment = jest.fn().mockResolvedValue({
|
||||||
@ -445,215 +447,11 @@ describe("downloadCommentImages", () => {
|
|||||||
);
|
);
|
||||||
});
|
});
|
||||||
|
|
||||||
test("should pair images by asset identifier even when the HTML order differs", async () => {
|
|
||||||
const mockOctokit = createMockOctokit();
|
|
||||||
const imageUrl1 = assetUrl(GUID_1);
|
|
||||||
const imageUrl2 = assetUrl(GUID_2);
|
|
||||||
const signedUrl1 = signedUrlFor(GUID_1, ".png", "token1");
|
|
||||||
const signedUrl2 = signedUrlFor(GUID_2, ".png", "token2");
|
|
||||||
|
|
||||||
// The rendered HTML lists the second asset first.
|
|
||||||
// @ts-expect-error Mock implementation doesn't match full type signature
|
|
||||||
mockOctokit.rest.issues.getComment = jest.fn().mockResolvedValue({
|
|
||||||
data: {
|
|
||||||
body_html: `<img src="${signedUrl2}"><img src="${signedUrl1}">`,
|
|
||||||
},
|
|
||||||
});
|
|
||||||
|
|
||||||
fetchSpy = spyOn(global, "fetch").mockResolvedValue({
|
|
||||||
ok: true,
|
|
||||||
arrayBuffer: async () => new ArrayBuffer(8),
|
|
||||||
} as Response);
|
|
||||||
|
|
||||||
const comments: CommentWithImages[] = [
|
|
||||||
{
|
|
||||||
type: "issue_comment",
|
|
||||||
id: "999",
|
|
||||||
body: `Two images:  and `,
|
|
||||||
},
|
|
||||||
];
|
|
||||||
|
|
||||||
const result = await downloadCommentImages(
|
|
||||||
mockOctokit,
|
|
||||||
"owner",
|
|
||||||
"repo",
|
|
||||||
comments,
|
|
||||||
);
|
|
||||||
|
|
||||||
expect(fetchSpy).toHaveBeenCalledTimes(2);
|
|
||||||
expect(fetchSpy).toHaveBeenNthCalledWith(1, signedUrl1);
|
|
||||||
expect(fetchSpy).toHaveBeenNthCalledWith(2, signedUrl2);
|
|
||||||
expect(result.get(imageUrl1)).toBe(
|
|
||||||
"/tmp/github-images/image-1704067200000-0.png",
|
|
||||||
);
|
|
||||||
expect(result.get(imageUrl2)).toBe(
|
|
||||||
"/tmp/github-images/image-1704067200000-1.png",
|
|
||||||
);
|
|
||||||
});
|
|
||||||
|
|
||||||
test("should match asset identifiers case-insensitively", async () => {
|
|
||||||
const mockOctokit = createMockOctokit();
|
|
||||||
const imageUrl = assetUrl(GUID_1.toUpperCase());
|
|
||||||
const signedUrl = signedUrlFor(GUID_1, ".png");
|
|
||||||
|
|
||||||
// @ts-expect-error Mock implementation doesn't match full type signature
|
|
||||||
mockOctokit.rest.issues.getComment = jest.fn().mockResolvedValue({
|
|
||||||
data: {
|
|
||||||
body_html: `<img src="${signedUrl}">`,
|
|
||||||
},
|
|
||||||
});
|
|
||||||
|
|
||||||
fetchSpy = spyOn(global, "fetch").mockResolvedValue({
|
|
||||||
ok: true,
|
|
||||||
arrayBuffer: async () => new ArrayBuffer(8),
|
|
||||||
} as Response);
|
|
||||||
|
|
||||||
const comments: CommentWithImages[] = [
|
|
||||||
{
|
|
||||||
type: "issue_comment",
|
|
||||||
id: "1002",
|
|
||||||
body: `Uppercase: `,
|
|
||||||
},
|
|
||||||
];
|
|
||||||
|
|
||||||
const result = await downloadCommentImages(
|
|
||||||
mockOctokit,
|
|
||||||
"owner",
|
|
||||||
"repo",
|
|
||||||
comments,
|
|
||||||
);
|
|
||||||
|
|
||||||
expect(fetchSpy).toHaveBeenCalledWith(signedUrl);
|
|
||||||
expect(result.get(imageUrl)).toBe(
|
|
||||||
"/tmp/github-images/image-1704067200000-0.png",
|
|
||||||
);
|
|
||||||
});
|
|
||||||
|
|
||||||
test("should skip an image whose signed URL refers to a different asset", async () => {
|
|
||||||
const mockOctokit = createMockOctokit();
|
|
||||||
const imageUrl = assetUrl(GUID_1);
|
|
||||||
// The rendered HTML only contains a signed URL for a different asset.
|
|
||||||
const signedUrl = signedUrlFor(GUID_2, ".png");
|
|
||||||
|
|
||||||
// @ts-expect-error Mock implementation doesn't match full type signature
|
|
||||||
mockOctokit.rest.issues.getComment = jest.fn().mockResolvedValue({
|
|
||||||
data: {
|
|
||||||
body_html: `<img src="${signedUrl}">`,
|
|
||||||
},
|
|
||||||
});
|
|
||||||
|
|
||||||
fetchSpy = spyOn(global, "fetch").mockResolvedValue({
|
|
||||||
ok: true,
|
|
||||||
arrayBuffer: async () => new ArrayBuffer(8),
|
|
||||||
} as Response);
|
|
||||||
|
|
||||||
const comments: CommentWithImages[] = [
|
|
||||||
{
|
|
||||||
type: "issue_comment",
|
|
||||||
id: "1003",
|
|
||||||
body: `Original image: `,
|
|
||||||
},
|
|
||||||
];
|
|
||||||
|
|
||||||
const result = await downloadCommentImages(
|
|
||||||
mockOctokit,
|
|
||||||
"owner",
|
|
||||||
"repo",
|
|
||||||
comments,
|
|
||||||
);
|
|
||||||
|
|
||||||
expect(fetchSpy).not.toHaveBeenCalled();
|
|
||||||
expect(result.size).toBe(0);
|
|
||||||
expect(consoleWarnSpy).toHaveBeenCalledWith(
|
|
||||||
`No matching signed URL found for ${imageUrl}, skipping`,
|
|
||||||
);
|
|
||||||
});
|
|
||||||
|
|
||||||
test("should not pair a signed URL that only names the asset in a leading path segment", async () => {
|
|
||||||
const mockOctokit = createMockOctokit();
|
|
||||||
const imageUrl = assetUrl(GUID_1);
|
|
||||||
// The path segment mentions the requested asset, but the URL resolves to a
|
|
||||||
// different asset's filename once ".." is applied.
|
|
||||||
const signedUrl = `https://private-user-images.githubusercontent.com/${GUID_1}/../12345/98765432-${GUID_2}.png?jwt=token`;
|
|
||||||
|
|
||||||
// @ts-expect-error Mock implementation doesn't match full type signature
|
|
||||||
mockOctokit.rest.issues.getComment = jest.fn().mockResolvedValue({
|
|
||||||
data: {
|
|
||||||
body_html: `<a href="${signedUrl}">${signedUrl}</a>`,
|
|
||||||
},
|
|
||||||
});
|
|
||||||
|
|
||||||
fetchSpy = spyOn(global, "fetch").mockResolvedValue({
|
|
||||||
ok: true,
|
|
||||||
arrayBuffer: async () => new ArrayBuffer(8),
|
|
||||||
} as Response);
|
|
||||||
|
|
||||||
const comments: CommentWithImages[] = [
|
|
||||||
{
|
|
||||||
type: "issue_comment",
|
|
||||||
id: "1005",
|
|
||||||
body: `Original image: `,
|
|
||||||
},
|
|
||||||
];
|
|
||||||
|
|
||||||
const result = await downloadCommentImages(
|
|
||||||
mockOctokit,
|
|
||||||
"owner",
|
|
||||||
"repo",
|
|
||||||
comments,
|
|
||||||
);
|
|
||||||
|
|
||||||
expect(fetchSpy).not.toHaveBeenCalled();
|
|
||||||
expect(result.size).toBe(0);
|
|
||||||
expect(consoleWarnSpy).toHaveBeenCalledWith(
|
|
||||||
`No matching signed URL found for ${imageUrl}, skipping`,
|
|
||||||
);
|
|
||||||
});
|
|
||||||
|
|
||||||
test("should skip an image URL without an asset identifier", async () => {
|
|
||||||
const mockOctokit = createMockOctokit();
|
|
||||||
const imageUrl =
|
|
||||||
"https://github.com/user-attachments/assets/test-image.png";
|
|
||||||
const signedUrl = signedUrlFor(GUID_1, ".png");
|
|
||||||
|
|
||||||
// @ts-expect-error Mock implementation doesn't match full type signature
|
|
||||||
mockOctokit.rest.issues.getComment = jest.fn().mockResolvedValue({
|
|
||||||
data: {
|
|
||||||
body_html: `<img src="${signedUrl}">`,
|
|
||||||
},
|
|
||||||
});
|
|
||||||
|
|
||||||
fetchSpy = spyOn(global, "fetch").mockResolvedValue({
|
|
||||||
ok: true,
|
|
||||||
arrayBuffer: async () => new ArrayBuffer(8),
|
|
||||||
} as Response);
|
|
||||||
|
|
||||||
const comments: CommentWithImages[] = [
|
|
||||||
{
|
|
||||||
type: "issue_comment",
|
|
||||||
id: "1004",
|
|
||||||
body: `No identifier: `,
|
|
||||||
},
|
|
||||||
];
|
|
||||||
|
|
||||||
const result = await downloadCommentImages(
|
|
||||||
mockOctokit,
|
|
||||||
"owner",
|
|
||||||
"repo",
|
|
||||||
comments,
|
|
||||||
);
|
|
||||||
|
|
||||||
expect(fetchSpy).not.toHaveBeenCalled();
|
|
||||||
expect(result.size).toBe(0);
|
|
||||||
expect(consoleWarnSpy).toHaveBeenCalledWith(
|
|
||||||
`No matching signed URL found for ${imageUrl}, skipping`,
|
|
||||||
);
|
|
||||||
});
|
|
||||||
|
|
||||||
test("should skip already downloaded images", async () => {
|
test("should skip already downloaded images", async () => {
|
||||||
const mockOctokit = createMockOctokit();
|
const mockOctokit = createMockOctokit();
|
||||||
const imageUrl = assetUrl(GUID_1);
|
const imageUrl = "https://github.com/user-attachments/assets/duplicate.png";
|
||||||
const signedUrl = signedUrlFor(GUID_1, ".png");
|
const signedUrl =
|
||||||
|
"https://private-user-images.githubusercontent.com/dup.png?jwt=token";
|
||||||
|
|
||||||
// @ts-expect-error Mock implementation doesn't match full type signature
|
// @ts-expect-error Mock implementation doesn't match full type signature
|
||||||
mockOctokit.rest.issues.getComment = jest.fn().mockResolvedValue({
|
mockOctokit.rest.issues.getComment = jest.fn().mockResolvedValue({
|
||||||
@ -696,7 +494,7 @@ describe("downloadCommentImages", () => {
|
|||||||
|
|
||||||
test("should handle missing HTML body", async () => {
|
test("should handle missing HTML body", async () => {
|
||||||
const mockOctokit = createMockOctokit();
|
const mockOctokit = createMockOctokit();
|
||||||
const imageUrl = assetUrl(GUID_1);
|
const imageUrl = "https://github.com/user-attachments/assets/missing.png";
|
||||||
|
|
||||||
// @ts-expect-error Mock implementation doesn't match full type signature
|
// @ts-expect-error Mock implementation doesn't match full type signature
|
||||||
mockOctokit.rest.issues.getComment = jest.fn().mockResolvedValue({
|
mockOctokit.rest.issues.getComment = jest.fn().mockResolvedValue({
|
||||||
@ -728,8 +526,9 @@ describe("downloadCommentImages", () => {
|
|||||||
|
|
||||||
test("should handle fetch errors", async () => {
|
test("should handle fetch errors", async () => {
|
||||||
const mockOctokit = createMockOctokit();
|
const mockOctokit = createMockOctokit();
|
||||||
const imageUrl = assetUrl(GUID_1);
|
const imageUrl = "https://github.com/user-attachments/assets/error.png";
|
||||||
const signedUrl = signedUrlFor(GUID_1, ".png");
|
const signedUrl =
|
||||||
|
"https://private-user-images.githubusercontent.com/error.png?jwt=token";
|
||||||
|
|
||||||
// @ts-expect-error Mock implementation doesn't match full type signature
|
// @ts-expect-error Mock implementation doesn't match full type signature
|
||||||
mockOctokit.rest.issues.getComment = jest.fn().mockResolvedValue({
|
mockOctokit.rest.issues.getComment = jest.fn().mockResolvedValue({
|
||||||
@ -768,7 +567,7 @@ describe("downloadCommentImages", () => {
|
|||||||
|
|
||||||
test("should handle API errors gracefully", async () => {
|
test("should handle API errors gracefully", async () => {
|
||||||
const mockOctokit = createMockOctokit();
|
const mockOctokit = createMockOctokit();
|
||||||
const imageUrl = assetUrl(GUID_1);
|
const imageUrl = "https://github.com/user-attachments/assets/api-error.png";
|
||||||
|
|
||||||
// @ts-expect-error Mock implementation doesn't match full type signature
|
// @ts-expect-error Mock implementation doesn't match full type signature
|
||||||
mockOctokit.rest.issues.getComment = jest
|
mockOctokit.rest.issues.getComment = jest
|
||||||
@ -800,21 +599,42 @@ describe("downloadCommentImages", () => {
|
|||||||
test("should extract correct file extensions", async () => {
|
test("should extract correct file extensions", async () => {
|
||||||
const mockOctokit = createMockOctokit();
|
const mockOctokit = createMockOctokit();
|
||||||
const extensions = [
|
const extensions = [
|
||||||
{ url: assetUrl(GUID_1, ".png"), ext: ".png" },
|
{
|
||||||
{ url: assetUrl(GUID_1, ".jpg"), ext: ".jpg" },
|
url: "https://github.com/user-attachments/assets/test.png",
|
||||||
{ url: assetUrl(GUID_1, ".jpeg"), ext: ".jpeg" },
|
ext: ".png",
|
||||||
{ url: assetUrl(GUID_1, ".gif"), ext: ".gif" },
|
},
|
||||||
{ url: assetUrl(GUID_1, ".webp"), ext: ".webp" },
|
{
|
||||||
{ url: assetUrl(GUID_1, ".svg"), ext: ".svg" },
|
url: "https://github.com/user-attachments/assets/test.jpg",
|
||||||
// default
|
ext: ".jpg",
|
||||||
{ url: assetUrl(GUID_1), ext: ".png" },
|
},
|
||||||
|
{
|
||||||
|
url: "https://github.com/user-attachments/assets/test.jpeg",
|
||||||
|
ext: ".jpeg",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
url: "https://github.com/user-attachments/assets/test.gif",
|
||||||
|
ext: ".gif",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
url: "https://github.com/user-attachments/assets/test.webp",
|
||||||
|
ext: ".webp",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
url: "https://github.com/user-attachments/assets/test.svg",
|
||||||
|
ext: ".svg",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
// default
|
||||||
|
url: "https://github.com/user-attachments/assets/no-extension",
|
||||||
|
ext: ".png",
|
||||||
|
},
|
||||||
];
|
];
|
||||||
|
|
||||||
let callIndex = 0;
|
let callIndex = 0;
|
||||||
// @ts-expect-error Mock implementation doesn't match full type signature
|
// @ts-expect-error Mock implementation doesn't match full type signature
|
||||||
mockOctokit.rest.issues.getComment = jest.fn().mockResolvedValue({
|
mockOctokit.rest.issues.getComment = jest.fn().mockResolvedValue({
|
||||||
data: {
|
data: {
|
||||||
body_html: `<img src="${signedUrlFor(GUID_1, "")}">`,
|
body_html: `<img src="https://private-user-images.githubusercontent.com/test?jwt=token">`,
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
|
|
||||||
@ -849,11 +669,12 @@ describe("downloadCommentImages", () => {
|
|||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
test("should handle a signed URL missing for one of several images", async () => {
|
test("should handle mismatched signed URL count", async () => {
|
||||||
const mockOctokit = createMockOctokit();
|
const mockOctokit = createMockOctokit();
|
||||||
const imageUrl1 = assetUrl(GUID_1);
|
const imageUrl1 = "https://github.com/user-attachments/assets/img1.png";
|
||||||
const imageUrl2 = assetUrl(GUID_2);
|
const imageUrl2 = "https://github.com/user-attachments/assets/img2.png";
|
||||||
const signedUrl1 = signedUrlFor(GUID_1, ".png");
|
const signedUrl1 =
|
||||||
|
"https://private-user-images.githubusercontent.com/1.png?jwt=token";
|
||||||
|
|
||||||
// Only one signed URL for two images
|
// Only one signed URL for two images
|
||||||
// @ts-expect-error Mock implementation doesn't match full type signature
|
// @ts-expect-error Mock implementation doesn't match full type signature
|
||||||
@ -889,15 +710,14 @@ describe("downloadCommentImages", () => {
|
|||||||
"/tmp/github-images/image-1704067200000-0.png",
|
"/tmp/github-images/image-1704067200000-0.png",
|
||||||
);
|
);
|
||||||
expect(result.get(imageUrl2)).toBeUndefined();
|
expect(result.get(imageUrl2)).toBeUndefined();
|
||||||
expect(consoleWarnSpy).toHaveBeenCalledWith(
|
|
||||||
`No matching signed URL found for ${imageUrl2}, skipping`,
|
|
||||||
);
|
|
||||||
});
|
});
|
||||||
|
|
||||||
test("should detect and download images from HTML img tags", async () => {
|
test("should detect and download images from HTML img tags", async () => {
|
||||||
const mockOctokit = createMockOctokit();
|
const mockOctokit = createMockOctokit();
|
||||||
const imageUrl = assetUrl(GUID_1);
|
const imageUrl =
|
||||||
const signedUrl = signedUrlFor(GUID_1, ".png");
|
"https://github.com/user-attachments/assets/html-image.png";
|
||||||
|
const signedUrl =
|
||||||
|
"https://private-user-images.githubusercontent.com/html.png?jwt=token";
|
||||||
|
|
||||||
// Mock octokit response
|
// Mock octokit response
|
||||||
// @ts-expect-error Mock implementation doesn't match full type signature
|
// @ts-expect-error Mock implementation doesn't match full type signature
|
||||||
@ -957,10 +777,14 @@ describe("downloadCommentImages", () => {
|
|||||||
|
|
||||||
test("should handle HTML img tags with different quote styles", async () => {
|
test("should handle HTML img tags with different quote styles", async () => {
|
||||||
const mockOctokit = createMockOctokit();
|
const mockOctokit = createMockOctokit();
|
||||||
const imageUrl1 = assetUrl(GUID_1, ".jpg");
|
const imageUrl1 =
|
||||||
const imageUrl2 = assetUrl(GUID_2, ".png");
|
"https://github.com/user-attachments/assets/single-quote.jpg";
|
||||||
const signedUrl1 = signedUrlFor(GUID_1, ".jpg", "token1");
|
const imageUrl2 =
|
||||||
const signedUrl2 = signedUrlFor(GUID_2, ".png", "token2");
|
"https://github.com/user-attachments/assets/double-quote.png";
|
||||||
|
const signedUrl1 =
|
||||||
|
"https://private-user-images.githubusercontent.com/single.jpg?jwt=token1";
|
||||||
|
const signedUrl2 =
|
||||||
|
"https://private-user-images.githubusercontent.com/double.png?jwt=token2";
|
||||||
|
|
||||||
// @ts-expect-error Mock implementation doesn't match full type signature
|
// @ts-expect-error Mock implementation doesn't match full type signature
|
||||||
mockOctokit.rest.issues.getComment = jest.fn().mockResolvedValue({
|
mockOctokit.rest.issues.getComment = jest.fn().mockResolvedValue({
|
||||||
@ -1004,17 +828,18 @@ describe("downloadCommentImages", () => {
|
|||||||
|
|
||||||
test("should handle mixed Markdown and HTML images", async () => {
|
test("should handle mixed Markdown and HTML images", async () => {
|
||||||
const mockOctokit = createMockOctokit();
|
const mockOctokit = createMockOctokit();
|
||||||
const markdownUrl = assetUrl(GUID_1);
|
const markdownUrl =
|
||||||
const htmlUrl = assetUrl(GUID_2, ".jpg");
|
"https://github.com/user-attachments/assets/markdown.png";
|
||||||
const signedUrl1 = signedUrlFor(GUID_1, ".png", "token1");
|
const htmlUrl = "https://github.com/user-attachments/assets/html.jpg";
|
||||||
const signedUrl2 = signedUrlFor(GUID_2, ".jpg", "token2");
|
const signedUrl1 =
|
||||||
|
"https://private-user-images.githubusercontent.com/md.png?jwt=token1";
|
||||||
|
const signedUrl2 =
|
||||||
|
"https://private-user-images.githubusercontent.com/html.jpg?jwt=token2";
|
||||||
|
|
||||||
// The rendered HTML has the images in document order (HTML tag first),
|
|
||||||
// which is the reverse of the order in which the URLs are extracted.
|
|
||||||
// @ts-expect-error Mock implementation doesn't match full type signature
|
// @ts-expect-error Mock implementation doesn't match full type signature
|
||||||
mockOctokit.rest.issues.getComment = jest.fn().mockResolvedValue({
|
mockOctokit.rest.issues.getComment = jest.fn().mockResolvedValue({
|
||||||
data: {
|
data: {
|
||||||
body_html: `<img src="${signedUrl2}"><img src="${signedUrl1}">`,
|
body_html: `<img src="${signedUrl1}"><img src="${signedUrl2}">`,
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
|
|
||||||
@ -1027,7 +852,7 @@ describe("downloadCommentImages", () => {
|
|||||||
{
|
{
|
||||||
type: "issue_comment",
|
type: "issue_comment",
|
||||||
id: "999",
|
id: "999",
|
||||||
body: `HTML: <img src="${htmlUrl}" alt="test"> and Markdown: `,
|
body: `Markdown:  and HTML: <img src="${htmlUrl}" alt="test">`,
|
||||||
},
|
},
|
||||||
];
|
];
|
||||||
|
|
||||||
@ -1053,8 +878,9 @@ describe("downloadCommentImages", () => {
|
|||||||
|
|
||||||
test("should deduplicate identical URLs from Markdown and HTML", async () => {
|
test("should deduplicate identical URLs from Markdown and HTML", async () => {
|
||||||
const mockOctokit = createMockOctokit();
|
const mockOctokit = createMockOctokit();
|
||||||
const imageUrl = assetUrl(GUID_1);
|
const imageUrl = "https://github.com/user-attachments/assets/duplicate.png";
|
||||||
const signedUrl = signedUrlFor(GUID_1, ".png");
|
const signedUrl =
|
||||||
|
"https://private-user-images.githubusercontent.com/dup.png?jwt=token";
|
||||||
|
|
||||||
// @ts-expect-error Mock implementation doesn't match full type signature
|
// @ts-expect-error Mock implementation doesn't match full type signature
|
||||||
mockOctokit.rest.issues.getComment = jest.fn().mockResolvedValue({
|
mockOctokit.rest.issues.getComment = jest.fn().mockResolvedValue({
|
||||||
@ -1095,8 +921,10 @@ describe("downloadCommentImages", () => {
|
|||||||
|
|
||||||
test("should handle HTML img tags with additional attributes", async () => {
|
test("should handle HTML img tags with additional attributes", async () => {
|
||||||
const mockOctokit = createMockOctokit();
|
const mockOctokit = createMockOctokit();
|
||||||
const imageUrl = assetUrl(GUID_3, ".webp");
|
const imageUrl =
|
||||||
const signedUrl = signedUrlFor(GUID_3, ".webp");
|
"https://github.com/user-attachments/assets/complex-tag.webp";
|
||||||
|
const signedUrl =
|
||||||
|
"https://private-user-images.githubusercontent.com/complex.webp?jwt=token";
|
||||||
|
|
||||||
// @ts-expect-error Mock implementation doesn't match full type signature
|
// @ts-expect-error Mock implementation doesn't match full type signature
|
||||||
mockOctokit.rest.issues.getComment = jest.fn().mockResolvedValue({
|
mockOctokit.rest.issues.getComment = jest.fn().mockResolvedValue({
|
||||||
|
|||||||
@ -214,46 +214,6 @@ describe("prepareMcpConfig", () => {
|
|||||||
);
|
);
|
||||||
});
|
});
|
||||||
|
|
||||||
test("should pin bun config flags before run for every bun server", async () => {
|
|
||||||
process.env.GITHUB_ACTION_PATH = "/test/action/path";
|
|
||||||
process.env.DEFAULT_WORKFLOW_TOKEN = "workflow-token";
|
|
||||||
|
|
||||||
const result = await prepareMcpConfig({
|
|
||||||
githubToken: "test-token",
|
|
||||||
owner: "test-owner",
|
|
||||||
repo: "test-repo",
|
|
||||||
branch: "test-branch",
|
|
||||||
baseBranch: "main",
|
|
||||||
allowedTools: ["mcp__github_inline_comment__create_inline_comment"],
|
|
||||||
mode: "tag",
|
|
||||||
context: {
|
|
||||||
...mockPRContext,
|
|
||||||
inputs: { ...mockPRContext.inputs, useCommitSigning: true },
|
|
||||||
},
|
|
||||||
});
|
|
||||||
|
|
||||||
const parsed = JSON.parse(result);
|
|
||||||
const servers: Record<string, string> = {
|
|
||||||
github_comment: "src/mcp/github-comment-server.ts",
|
|
||||||
github_file_ops: "src/mcp/github-file-ops-server.ts",
|
|
||||||
github_inline_comment: "src/mcp/github-inline-comment-server.ts",
|
|
||||||
github_ci: "src/mcp/github-actions-server.ts",
|
|
||||||
};
|
|
||||||
|
|
||||||
for (const [name, script] of Object.entries(servers)) {
|
|
||||||
expect(parsed.mcpServers[name]).toBeDefined();
|
|
||||||
expect(parsed.mcpServers[name].command).toBe("bun");
|
|
||||||
expect(parsed.mcpServers[name].args).toEqual([
|
|
||||||
"--no-env-file",
|
|
||||||
"--config=/test/action/path/bunfig.toml",
|
|
||||||
"run",
|
|
||||||
`/test/action/path/${script}`,
|
|
||||||
]);
|
|
||||||
}
|
|
||||||
|
|
||||||
delete process.env.DEFAULT_WORKFLOW_TOKEN;
|
|
||||||
});
|
|
||||||
|
|
||||||
test("should use current working directory when GITHUB_WORKSPACE is not set", async () => {
|
test("should use current working directory when GITHUB_WORKSPACE is not set", async () => {
|
||||||
delete process.env.GITHUB_WORKSPACE;
|
delete process.env.GITHUB_WORKSPACE;
|
||||||
|
|
||||||
|
|||||||
@ -140,7 +140,7 @@ export const mockIssueOpenedContext: ParsedGitHubContext = {
|
|||||||
body: "## Description\n\nThe application crashes immediately after launching.\n\n## Steps to reproduce\n\n1. Install the app\n2. Launch it\n3. See crash\n\n/claude please help me fix this",
|
body: "## Description\n\nThe application crashes immediately after launching.\n\n## Steps to reproduce\n\n1. Install the app\n2. Launch it\n3. See crash\n\n/claude please help me fix this",
|
||||||
assignee: null,
|
assignee: null,
|
||||||
created_at: "2024-01-15T10:30:00Z",
|
created_at: "2024-01-15T10:30:00Z",
|
||||||
updated_at: "2024-01-15T10:35:00Z",
|
updated_at: "2024-01-15T10:30:00Z",
|
||||||
html_url: "https://github.com/test-owner/test-repo/issues/42",
|
html_url: "https://github.com/test-owner/test-repo/issues/42",
|
||||||
user: {
|
user: {
|
||||||
login: "john-doe",
|
login: "john-doe",
|
||||||
@ -191,8 +191,6 @@ export const mockIssueAssignedContext: ParsedGitHubContext = {
|
|||||||
avatar_url: "https://avatars.githubusercontent.com/u/11111",
|
avatar_url: "https://avatars.githubusercontent.com/u/11111",
|
||||||
html_url: "https://github.com/claude-bot",
|
html_url: "https://github.com/claude-bot",
|
||||||
},
|
},
|
||||||
created_at: "2024-01-15T09:00:00Z",
|
|
||||||
updated_at: "2024-01-15T11:00:00Z",
|
|
||||||
},
|
},
|
||||||
repository: {
|
repository: {
|
||||||
name: "test-repo",
|
name: "test-repo",
|
||||||
@ -227,8 +225,6 @@ export const mockIssueLabeledContext: ParsedGitHubContext = {
|
|||||||
html_url: "https://github.com/alice-wonder",
|
html_url: "https://github.com/alice-wonder",
|
||||||
},
|
},
|
||||||
assignee: null,
|
assignee: null,
|
||||||
created_at: "2024-01-15T09:30:00Z",
|
|
||||||
updated_at: "2024-01-15T11:30:00Z",
|
|
||||||
},
|
},
|
||||||
label: {
|
label: {
|
||||||
id: 987654321,
|
id: 987654321,
|
||||||
@ -359,8 +355,6 @@ export const mockPullRequestOpenedContext: ParsedGitHubContext = {
|
|||||||
avatar_url: "https://avatars.githubusercontent.com/u/55555",
|
avatar_url: "https://avatars.githubusercontent.com/u/55555",
|
||||||
html_url: "https://github.com/feature-developer",
|
html_url: "https://github.com/feature-developer",
|
||||||
},
|
},
|
||||||
created_at: "2024-01-15T14:00:00Z",
|
|
||||||
updated_at: "2024-01-15T14:05:00Z",
|
|
||||||
},
|
},
|
||||||
repository: {
|
repository: {
|
||||||
name: "test-repo",
|
name: "test-repo",
|
||||||
|
|||||||
@ -3,7 +3,6 @@ import * as core from "@actions/core";
|
|||||||
import { checkWritePermissions } from "../src/github/validation/permissions";
|
import { checkWritePermissions } from "../src/github/validation/permissions";
|
||||||
import type { ParsedGitHubContext } from "../src/github/context";
|
import type { ParsedGitHubContext } from "../src/github/context";
|
||||||
import { CLAUDE_APP_BOT_ID, CLAUDE_BOT_LOGIN } from "../src/github/constants";
|
import { CLAUDE_APP_BOT_ID, CLAUDE_BOT_LOGIN } from "../src/github/constants";
|
||||||
import { createMockAutomationContext } from "./mockContext";
|
|
||||||
|
|
||||||
describe("checkWritePermissions", () => {
|
describe("checkWritePermissions", () => {
|
||||||
let coreInfoSpy: any;
|
let coreInfoSpy: any;
|
||||||
@ -456,159 +455,4 @@ describe("checkWritePermissions", () => {
|
|||||||
expect(result).toBe(true);
|
expect(result).toBe(true);
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
describe("workflow_run contexts", () => {
|
|
||||||
const createWorkflowRunContext = (
|
|
||||||
actor: string,
|
|
||||||
runActor: string = actor,
|
|
||||||
) =>
|
|
||||||
createMockAutomationContext({
|
|
||||||
eventName: "workflow_run",
|
|
||||||
eventAction: "completed",
|
|
||||||
actor,
|
|
||||||
payload: {
|
|
||||||
action: "completed",
|
|
||||||
workflow_run: {
|
|
||||||
id: 123,
|
|
||||||
event: "pull_request",
|
|
||||||
actor: { login: runActor },
|
|
||||||
head_repository: { full_name: "fork-owner/test-repo" },
|
|
||||||
},
|
|
||||||
} as any,
|
|
||||||
});
|
|
||||||
|
|
||||||
const createMockOctokitWithLevels = (levels: Record<string, string>) =>
|
|
||||||
({
|
|
||||||
repos: {
|
|
||||||
getCollaboratorPermissionLevel: async (params: {
|
|
||||||
username: string;
|
|
||||||
}) => ({
|
|
||||||
data: { permission: levels[params.username] ?? "none" },
|
|
||||||
}),
|
|
||||||
},
|
|
||||||
}) as any;
|
|
||||||
|
|
||||||
test("should return false when the run actor lacks write access", async () => {
|
|
||||||
const mockOctokit = createMockOctokit("read");
|
|
||||||
const context = createWorkflowRunContext("fork-contributor");
|
|
||||||
|
|
||||||
const result = await checkWritePermissions(mockOctokit, context);
|
|
||||||
|
|
||||||
expect(result).toBe(false);
|
|
||||||
expect(coreWarningSpy).toHaveBeenCalledWith(
|
|
||||||
"Actor has insufficient permissions: read",
|
|
||||||
);
|
|
||||||
});
|
|
||||||
|
|
||||||
test("should return true when the run actor has write access", async () => {
|
|
||||||
const mockOctokit = createMockOctokit("write");
|
|
||||||
const context = createWorkflowRunContext("maintainer");
|
|
||||||
|
|
||||||
const result = await checkWritePermissions(mockOctokit, context);
|
|
||||||
|
|
||||||
expect(result).toBe(true);
|
|
||||||
});
|
|
||||||
|
|
||||||
test("should return true when the run actor has admin access", async () => {
|
|
||||||
const mockOctokit = createMockOctokit("admin");
|
|
||||||
const context = createWorkflowRunContext("maintainer");
|
|
||||||
|
|
||||||
const result = await checkWritePermissions(mockOctokit, context);
|
|
||||||
|
|
||||||
expect(result).toBe(true);
|
|
||||||
});
|
|
||||||
|
|
||||||
test("should also check the payload run actor when it differs from the workflow actor", async () => {
|
|
||||||
const mockOctokit = createMockOctokitWithLevels({
|
|
||||||
maintainer: "write",
|
|
||||||
"fork-contributor": "read",
|
|
||||||
});
|
|
||||||
const context = createWorkflowRunContext(
|
|
||||||
"maintainer",
|
|
||||||
"fork-contributor",
|
|
||||||
);
|
|
||||||
|
|
||||||
const result = await checkWritePermissions(mockOctokit, context);
|
|
||||||
|
|
||||||
expect(result).toBe(false);
|
|
||||||
expect(coreInfoSpy).toHaveBeenCalledWith(
|
|
||||||
"workflow_run was started by fork-contributor; checking permissions for that actor as well",
|
|
||||||
);
|
|
||||||
});
|
|
||||||
|
|
||||||
test("should return true when both the workflow actor and run actor have write access", async () => {
|
|
||||||
const mockOctokit = createMockOctokitWithLevels({
|
|
||||||
maintainer: "write",
|
|
||||||
"other-maintainer": "admin",
|
|
||||||
});
|
|
||||||
const context = createWorkflowRunContext(
|
|
||||||
"maintainer",
|
|
||||||
"other-maintainer",
|
|
||||||
);
|
|
||||||
|
|
||||||
const result = await checkWritePermissions(mockOctokit, context);
|
|
||||||
|
|
||||||
expect(result).toBe(true);
|
|
||||||
});
|
|
||||||
|
|
||||||
test("should allow a run actor listed in allowed_non_write_users when github_token is provided", async () => {
|
|
||||||
const mockOctokit = createMockOctokit("read");
|
|
||||||
const context = createWorkflowRunContext("fork-contributor");
|
|
||||||
|
|
||||||
const result = await checkWritePermissions(
|
|
||||||
mockOctokit,
|
|
||||||
context,
|
|
||||||
"fork-contributor,other-user",
|
|
||||||
true,
|
|
||||||
);
|
|
||||||
|
|
||||||
expect(result).toBe(true);
|
|
||||||
expect(coreWarningSpy).toHaveBeenCalledWith(
|
|
||||||
"⚠️ SECURITY WARNING: Bypassing write permission check for fork-contributor due to allowed_non_write_users configuration. This should only be used for workflows with very limited permissions.",
|
|
||||||
);
|
|
||||||
});
|
|
||||||
|
|
||||||
test("should NOT bypass for a run actor in allowed_non_write_users when github_token is not provided", async () => {
|
|
||||||
const mockOctokit = createMockOctokit("read");
|
|
||||||
const context = createWorkflowRunContext("fork-contributor");
|
|
||||||
|
|
||||||
const result = await checkWritePermissions(
|
|
||||||
mockOctokit,
|
|
||||||
context,
|
|
||||||
"fork-contributor",
|
|
||||||
false,
|
|
||||||
);
|
|
||||||
|
|
||||||
expect(result).toBe(false);
|
|
||||||
expect(coreWarningSpy).toHaveBeenCalledWith(
|
|
||||||
"Actor has insufficient permissions: read",
|
|
||||||
);
|
|
||||||
});
|
|
||||||
|
|
||||||
test("should require the payload run actor to also be in allowed_non_write_users", async () => {
|
|
||||||
const mockOctokit = createMockOctokit("read");
|
|
||||||
const context = createWorkflowRunContext(
|
|
||||||
"maintainer",
|
|
||||||
"fork-contributor",
|
|
||||||
);
|
|
||||||
|
|
||||||
const result = await checkWritePermissions(
|
|
||||||
mockOctokit,
|
|
||||||
context,
|
|
||||||
"maintainer",
|
|
||||||
true,
|
|
||||||
);
|
|
||||||
|
|
||||||
expect(result).toBe(false);
|
|
||||||
});
|
|
||||||
|
|
||||||
test("should return true for [bot] run actors", async () => {
|
|
||||||
const mockOctokit = createMockOctokit("none");
|
|
||||||
const context = createWorkflowRunContext("dependabot[bot]");
|
|
||||||
|
|
||||||
const result = await checkWritePermissions(mockOctokit, context);
|
|
||||||
|
|
||||||
expect(result).toBe(true);
|
|
||||||
});
|
|
||||||
});
|
|
||||||
});
|
});
|
||||||
|
|||||||
@ -59,22 +59,6 @@ describe("stripMarkdownImageAltText", () => {
|
|||||||
it("should handle empty alt text", () => {
|
it("should handle empty alt text", () => {
|
||||||
expect(stripMarkdownImageAltText("")).toBe("");
|
expect(stripMarkdownImageAltText("")).toBe("");
|
||||||
});
|
});
|
||||||
|
|
||||||
it("should remove alt text from reference-style images", () => {
|
|
||||||
expect(stripMarkdownImageAltText("![example alt text][img1]")).toBe(
|
|
||||||
"![][img1]",
|
|
||||||
);
|
|
||||||
expect(
|
|
||||||
stripMarkdownImageAltText("Text ![description][ref] more text"),
|
|
||||||
).toBe("Text ![][ref] more text");
|
|
||||||
});
|
|
||||||
|
|
||||||
it("should preserve the reference label of a reference-style image", () => {
|
|
||||||
// the [ref] label must survive so the image definition still resolves;
|
|
||||||
// only the alt text (the injection channel) is removed
|
|
||||||
expect(stripMarkdownImageAltText("![alt][my-ref]")).toBe("![][my-ref]");
|
|
||||||
expect(stripMarkdownImageAltText("![][keep]")).toBe("![][keep]");
|
|
||||||
});
|
|
||||||
});
|
});
|
||||||
|
|
||||||
describe("stripMarkdownLinkTitles", () => {
|
describe("stripMarkdownLinkTitles", () => {
|
||||||
@ -292,16 +276,6 @@ describe("redactGitHubTokens", () => {
|
|||||||
);
|
);
|
||||||
});
|
});
|
||||||
|
|
||||||
it("should redact user-to-server tokens (ghu_)", () => {
|
|
||||||
const token = "ghu_16C7e42F292c6912E7710c838347Ae178B4a";
|
|
||||||
expect(redactGitHubTokens(`User token: ${token}`)).toBe(
|
|
||||||
"User token: [REDACTED_GITHUB_TOKEN]",
|
|
||||||
);
|
|
||||||
expect(
|
|
||||||
redactGitHubTokens(`In a URL: x-access-token:${token}@github.com`),
|
|
||||||
).toBe("In a URL: x-access-token:[REDACTED_GITHUB_TOKEN]@github.com");
|
|
||||||
});
|
|
||||||
|
|
||||||
it("should redact installation tokens (ghs_)", () => {
|
it("should redact installation tokens (ghs_)", () => {
|
||||||
const token = "ghs_xz7yzju2SZjGPa0dUNMAx0SH4xDOCS31LXQW";
|
const token = "ghs_xz7yzju2SZjGPa0dUNMAx0SH4xDOCS31LXQW";
|
||||||
expect(redactGitHubTokens(`Install token: ${token}`)).toBe(
|
expect(redactGitHubTokens(`Install token: ${token}`)).toBe(
|
||||||
|
|||||||
@ -74,16 +74,6 @@ describe("validateBranchName", () => {
|
|||||||
expect(() => validateBranchName("@hotfix/login-timeout")).not.toThrow();
|
expect(() => validateBranchName("@hotfix/login-timeout")).not.toThrow();
|
||||||
expect(() => validateBranchName("agent/task@abc123")).not.toThrow();
|
expect(() => validateBranchName("agent/task@abc123")).not.toThrow();
|
||||||
});
|
});
|
||||||
|
|
||||||
it("should accept branch names starting with underscore (git-valid, common for release branches)", () => {
|
|
||||||
// Leading underscores are valid per git check-ref-format and a common
|
|
||||||
// convention for release/internal branches. Rejecting them broke the
|
|
||||||
// action on any open PR whose base branch was e.g. "_release/v1.2.3",
|
|
||||||
// since setupBranch validates the PR's baseRefName after checkout.
|
|
||||||
expect(() => validateBranchName("_release/v1.2.3")).not.toThrow();
|
|
||||||
expect(() => validateBranchName("_internal")).not.toThrow();
|
|
||||||
expect(() => validateBranchName("_wip/feature-x")).not.toThrow();
|
|
||||||
});
|
|
||||||
});
|
});
|
||||||
|
|
||||||
describe("command injection attempts", () => {
|
describe("command injection attempts", () => {
|
||||||
|
|||||||
Loading…
x
Reference in New Issue
Block a user