mirror of
https://github.com/anthropics/claude-code-action.git
synced 2026-01-23 06:54:13 +08:00
Compare commits
9 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
4cdae8adfc | ||
|
|
e5d38c6b74 | ||
|
|
af398fcc95 | ||
|
|
aeda2d62c0 | ||
|
|
2ce0b1c9b2 | ||
|
|
fd041f9b80 | ||
|
|
544983d6bf | ||
|
|
4d3cbe2826 | ||
|
|
52c2f5881b |
2
.github/workflows/claude.yml
vendored
2
.github/workflows/claude.yml
vendored
@@ -36,4 +36,4 @@ jobs:
|
||||
anthropic_api_key: ${{ secrets.ANTHROPIC_API_KEY }}
|
||||
allowed_tools: "Bash(bun install),Bash(bun test:*),Bash(bun run format),Bash(bun typecheck)"
|
||||
custom_instructions: "You have also been granted tools for editing files and running bun commands (install, run, test, typecheck) for testing your changes: bun install, bun test, bun run format, bun typecheck."
|
||||
model: "claude-opus-4-1-20250805"
|
||||
model: "claude-opus-4-20250514"
|
||||
|
||||
13
README.md
13
README.md
@@ -14,19 +14,6 @@ A general-purpose [Claude Code](https://claude.ai/code) action for GitHub PRs an
|
||||
- 📋 **Progress Tracking**: Visual progress indicators with checkboxes that dynamically update as Claude completes tasks
|
||||
- 🏃 **Runs on Your Infrastructure**: The action executes entirely on your own GitHub runner (Anthropic API calls go to your chosen provider)
|
||||
|
||||
## ⚠️ **BREAKING CHANGES COMING IN v1.0** ⚠️
|
||||
|
||||
**We're planning a major update that will significantly change how this action works.** The new version will:
|
||||
|
||||
- ✨ Automatically select the appropriate mode (no more `mode` input)
|
||||
- 🔧 Simplify configuration with unified `prompt` and `claude_args`
|
||||
- 🚀 Align more closely with the Claude Code SDK capabilities
|
||||
- 💥 Remove multiple inputs like `direct_prompt`, `custom_instructions`, and others
|
||||
|
||||
**[→ Read the full v1.0 roadmap and provide feedback](https://github.com/anthropics/claude-code-action/discussions/428)**
|
||||
|
||||
---
|
||||
|
||||
## Quickstart
|
||||
|
||||
The easiest way to set up this action is through [Claude Code](https://claude.ai/code) in the terminal. Just open `claude` and run `/install-github-app`.
|
||||
|
||||
@@ -10,7 +10,7 @@ Thank you for trying out the beta of our GitHub Action! This document outlines o
|
||||
- **Support for workflow_dispatch and repository_dispatch events** - Dispatch Claude on events triggered via API from other workflows or from other services
|
||||
- **Ability to disable commit signing** - Option to turn off GPG signing for environments where it's not required. This will enable Claude to use normal `git` bash commands for committing. This will likely become the default behavior once added.
|
||||
- **Better code review behavior** - Support inline comments on specific lines, provide higher quality reviews with more actionable feedback
|
||||
- ~**Support triggering @claude from bot users** - Allow automation and bot accounts to invoke Claude~
|
||||
- **Support triggering @claude from bot users** - Allow automation and bot accounts to invoke Claude
|
||||
- **Customizable base prompts** - Full control over Claude's initial context with template variables like `$PR_COMMENTS`, `$PR_FILES`, etc. Users can replace our default prompt entirely while still accessing key contextual data
|
||||
|
||||
---
|
||||
|
||||
29
action.yml
29
action.yml
@@ -23,10 +23,6 @@ inputs:
|
||||
description: "The prefix to use for Claude branches (defaults to 'claude/', use 'claude-' for dash format)"
|
||||
required: false
|
||||
default: "claude/"
|
||||
allowed_bots:
|
||||
description: "Comma-separated list of allowed bot usernames, or '*' to allow all bots. Empty string (default) allows no bots."
|
||||
required: false
|
||||
default: ""
|
||||
|
||||
# Mode configuration
|
||||
mode:
|
||||
@@ -160,13 +156,16 @@ runs:
|
||||
OVERRIDE_PROMPT: ${{ inputs.override_prompt }}
|
||||
MCP_CONFIG: ${{ inputs.mcp_config }}
|
||||
OVERRIDE_GITHUB_TOKEN: ${{ inputs.github_token }}
|
||||
ALLOWED_BOTS: ${{ inputs.allowed_bots }}
|
||||
GITHUB_RUN_ID: ${{ github.run_id }}
|
||||
USE_STICKY_COMMENT: ${{ inputs.use_sticky_comment }}
|
||||
DEFAULT_WORKFLOW_TOKEN: ${{ github.token }}
|
||||
ADDITIONAL_PERMISSIONS: ${{ inputs.additional_permissions }}
|
||||
USE_COMMIT_SIGNING: ${{ inputs.use_commit_signing }}
|
||||
|
||||
# Authentication for remote-agent mode
|
||||
ANTHROPIC_API_KEY: ${{ inputs.anthropic_api_key }}
|
||||
CLAUDE_CODE_OAUTH_TOKEN: ${{ inputs.claude_code_oauth_token }}
|
||||
|
||||
- name: Install Base Action Dependencies
|
||||
if: steps.prepare.outputs.contains_trigger == 'true'
|
||||
shell: bash
|
||||
@@ -177,8 +176,7 @@ runs:
|
||||
echo "Base-action dependencies installed"
|
||||
cd -
|
||||
# Install Claude Code globally
|
||||
curl -fsSL https://claude.ai/install.sh | bash -s 1.0.83
|
||||
echo "$HOME/.local/bin" >> "$GITHUB_PATH"
|
||||
bun install -g @anthropic-ai/claude-code
|
||||
|
||||
- name: Setup Network Restrictions
|
||||
if: steps.prepare.outputs.contains_trigger == 'true' && inputs.experimental_allowed_domains != ''
|
||||
@@ -194,7 +192,6 @@ runs:
|
||||
if: steps.prepare.outputs.contains_trigger == 'true'
|
||||
shell: bash
|
||||
run: |
|
||||
|
||||
# Run the base-action
|
||||
bun run ${GITHUB_ACTION_PATH}/base-action/src/index.ts
|
||||
env:
|
||||
@@ -212,16 +209,17 @@ runs:
|
||||
INPUT_CLAUDE_ENV: ${{ inputs.claude_env }}
|
||||
INPUT_FALLBACK_MODEL: ${{ inputs.fallback_model }}
|
||||
INPUT_EXPERIMENTAL_SLASH_COMMANDS_DIR: ${{ github.action_path }}/slash-commands
|
||||
INPUT_STREAM_CONFIG: ${{ steps.prepare.outputs.stream_config }}
|
||||
|
||||
# Model configuration
|
||||
ANTHROPIC_MODEL: ${{ inputs.model || inputs.anthropic_model }}
|
||||
ANTHROPIC_MODEL: ${{ steps.prepare.outputs.anthropic_model || inputs.model || inputs.anthropic_model }}
|
||||
GITHUB_TOKEN: ${{ steps.prepare.outputs.GITHUB_TOKEN }}
|
||||
NODE_VERSION: ${{ env.NODE_VERSION }}
|
||||
DETAILED_PERMISSION_MESSAGES: "1"
|
||||
|
||||
# Provider configuration
|
||||
ANTHROPIC_API_KEY: ${{ inputs.anthropic_api_key }}
|
||||
CLAUDE_CODE_OAUTH_TOKEN: ${{ inputs.claude_code_oauth_token }}
|
||||
CLAUDE_CODE_OAUTH_TOKEN: ${{ steps.prepare.outputs.claude_code_oauth_token || inputs.claude_code_oauth_token }}
|
||||
ANTHROPIC_BASE_URL: ${{ env.ANTHROPIC_BASE_URL }}
|
||||
CLAUDE_CODE_USE_BEDROCK: ${{ inputs.use_bedrock == 'true' && '1' || '' }}
|
||||
CLAUDE_CODE_USE_VERTEX: ${{ inputs.use_vertex == 'true' && '1' || '' }}
|
||||
@@ -244,6 +242,17 @@ runs:
|
||||
VERTEX_REGION_CLAUDE_3_5_SONNET: ${{ env.VERTEX_REGION_CLAUDE_3_5_SONNET }}
|
||||
VERTEX_REGION_CLAUDE_3_7_SONNET: ${{ env.VERTEX_REGION_CLAUDE_3_7_SONNET }}
|
||||
|
||||
- name: Report Claude completion
|
||||
if: steps.prepare.outputs.contains_trigger == 'true' && always()
|
||||
shell: bash
|
||||
run: |
|
||||
bun run ${GITHUB_ACTION_PATH}/src/entrypoints/report-claude-complete.ts
|
||||
env:
|
||||
MODE: ${{ inputs.mode }}
|
||||
STREAM_CONFIG: ${{ steps.prepare.outputs.stream_config }}
|
||||
CLAUDE_CONCLUSION: ${{ steps.claude-code.outputs.conclusion }}
|
||||
CLAUDE_START_TIME: ${{ steps.prepare.outputs.claude_start_time }}
|
||||
|
||||
- name: Update comment with job link
|
||||
if: steps.prepare.outputs.contains_trigger == 'true' && steps.prepare.outputs.claude_comment_id && always()
|
||||
shell: bash
|
||||
|
||||
@@ -69,7 +69,7 @@ Add the following to your workflow file:
|
||||
uses: anthropics/claude-code-base-action@beta
|
||||
with:
|
||||
prompt: "Review and fix TypeScript errors"
|
||||
model: "claude-opus-4-1-20250805"
|
||||
model: "claude-opus-4-20250514"
|
||||
fallback_model: "claude-sonnet-4-20250514"
|
||||
allowed_tools: "Bash(git:*),View,GlobTool,GrepTool,BatchTool"
|
||||
anthropic_api_key: ${{ secrets.ANTHROPIC_API_KEY }}
|
||||
@@ -217,7 +217,7 @@ Provide the settings configuration directly as a JSON string:
|
||||
prompt: "Your prompt here"
|
||||
settings: |
|
||||
{
|
||||
"model": "claude-opus-4-1-20250805",
|
||||
"model": "claude-opus-4-20250514",
|
||||
"env": {
|
||||
"DEBUG": "true",
|
||||
"API_URL": "https://api.example.com"
|
||||
|
||||
@@ -102,7 +102,7 @@ runs:
|
||||
- name: Setup Node.js
|
||||
uses: actions/setup-node@49933ea5288caeca8642d1e84afbd3f7d6820020 # https://github.com/actions/setup-node/releases/tag/v4.4.0
|
||||
with:
|
||||
node-version: ${{ env.NODE_VERSION || '18.x' }}
|
||||
node-version: ${{ env.NODE_VERSION || '22.x' }}
|
||||
cache: ${{ inputs.use_node_cache == 'true' && 'npm' || '' }}
|
||||
|
||||
- name: Install Bun
|
||||
@@ -118,7 +118,9 @@ runs:
|
||||
|
||||
- name: Install Claude Code
|
||||
shell: bash
|
||||
run: curl -fsSL https://claude.ai/install.sh | bash -s 1.0.83
|
||||
run: |
|
||||
# Install Claude Code
|
||||
bun install -g @anthropic-ai/claude-code
|
||||
|
||||
- name: Run Claude Code Action
|
||||
shell: bash
|
||||
|
||||
@@ -30,7 +30,7 @@ async function run() {
|
||||
appendSystemPrompt: process.env.INPUT_APPEND_SYSTEM_PROMPT,
|
||||
claudeEnv: process.env.INPUT_CLAUDE_ENV,
|
||||
fallbackModel: process.env.INPUT_FALLBACK_MODEL,
|
||||
model: process.env.ANTHROPIC_MODEL,
|
||||
streamConfig: process.env.INPUT_STREAM_CONFIG,
|
||||
});
|
||||
} catch (error) {
|
||||
core.setFailed(`Action failed with error: ${error}`);
|
||||
|
||||
@@ -4,6 +4,7 @@ import { promisify } from "util";
|
||||
import { unlink, writeFile, stat } from "fs/promises";
|
||||
import { createWriteStream } from "fs";
|
||||
import { spawn } from "child_process";
|
||||
import { StreamHandler } from "./stream-handler";
|
||||
|
||||
const execAsync = promisify(exec);
|
||||
|
||||
@@ -21,7 +22,14 @@ export type ClaudeOptions = {
|
||||
claudeEnv?: string;
|
||||
fallbackModel?: string;
|
||||
timeoutMinutes?: string;
|
||||
model?: string;
|
||||
streamConfig?: string;
|
||||
};
|
||||
|
||||
export type StreamConfig = {
|
||||
progress_endpoint?: string;
|
||||
headers?: Record<string, string>;
|
||||
resume_endpoint?: string;
|
||||
session_id?: string;
|
||||
};
|
||||
|
||||
type PreparedConfig = {
|
||||
@@ -95,9 +103,6 @@ export function prepareRunConfig(
|
||||
if (options.fallbackModel) {
|
||||
claudeArgs.push("--fallback-model", options.fallbackModel);
|
||||
}
|
||||
if (options.model) {
|
||||
claudeArgs.push("--model", options.model);
|
||||
}
|
||||
if (options.timeoutMinutes) {
|
||||
const timeoutMinutesNum = parseInt(options.timeoutMinutes, 10);
|
||||
if (isNaN(timeoutMinutesNum) || timeoutMinutesNum <= 0) {
|
||||
@@ -106,6 +111,22 @@ export function prepareRunConfig(
|
||||
);
|
||||
}
|
||||
}
|
||||
// Parse stream config for session_id and resume_endpoint
|
||||
if (options.streamConfig) {
|
||||
try {
|
||||
const streamConfig: StreamConfig = JSON.parse(options.streamConfig);
|
||||
// Add --session-id if session_id is provided
|
||||
if (streamConfig.session_id) {
|
||||
claudeArgs.push("--session-id", streamConfig.session_id);
|
||||
}
|
||||
// Only add --teleport if we have both session_id AND resume_endpoint
|
||||
if (streamConfig.session_id && streamConfig.resume_endpoint) {
|
||||
claudeArgs.push("--teleport", streamConfig.session_id);
|
||||
}
|
||||
} catch (e) {
|
||||
console.error("Failed to parse stream_config JSON:", e);
|
||||
}
|
||||
}
|
||||
|
||||
// Parse custom environment variables
|
||||
const customEnv = parseCustomEnvVars(options.claudeEnv);
|
||||
@@ -120,6 +141,34 @@ export function prepareRunConfig(
|
||||
export async function runClaude(promptPath: string, options: ClaudeOptions) {
|
||||
const config = prepareRunConfig(promptPath, options);
|
||||
|
||||
// Set up streaming if endpoint is provided in stream config
|
||||
let streamHandler: StreamHandler | null = null;
|
||||
let streamConfig: StreamConfig | null = null;
|
||||
if (options.streamConfig) {
|
||||
try {
|
||||
streamConfig = JSON.parse(options.streamConfig);
|
||||
if (streamConfig?.progress_endpoint) {
|
||||
const customHeaders = streamConfig.headers || {};
|
||||
console.log("parsed headers", customHeaders);
|
||||
Object.keys(customHeaders).forEach((key) => {
|
||||
console.log(`Custom header: ${key} = ${customHeaders[key]}`);
|
||||
});
|
||||
streamHandler = new StreamHandler(
|
||||
streamConfig.progress_endpoint,
|
||||
customHeaders,
|
||||
);
|
||||
console.log(`Streaming output to: ${streamConfig.progress_endpoint}`);
|
||||
if (Object.keys(customHeaders).length > 0) {
|
||||
console.log(
|
||||
`Custom streaming headers: ${Object.keys(customHeaders).join(", ")}`,
|
||||
);
|
||||
}
|
||||
}
|
||||
} catch (e) {
|
||||
console.error("Failed to parse stream_config JSON:", e);
|
||||
}
|
||||
}
|
||||
|
||||
// Create a named pipe
|
||||
try {
|
||||
await unlink(PIPE_PATH);
|
||||
@@ -162,12 +211,31 @@ export async function runClaude(promptPath: string, options: ClaudeOptions) {
|
||||
pipeStream.destroy();
|
||||
});
|
||||
|
||||
// Prepare environment variables
|
||||
const processEnv = {
|
||||
...process.env,
|
||||
...config.env,
|
||||
};
|
||||
|
||||
// If both session_id and resume_endpoint are provided, set environment variables
|
||||
if (streamConfig?.session_id && streamConfig?.resume_endpoint) {
|
||||
processEnv.TELEPORT_RESUME_URL = streamConfig.resume_endpoint;
|
||||
console.log(
|
||||
`Setting TELEPORT_RESUME_URL to: ${streamConfig.resume_endpoint}`,
|
||||
);
|
||||
|
||||
if (streamConfig.headers && Object.keys(streamConfig.headers).length > 0) {
|
||||
processEnv.TELEPORT_HEADERS = JSON.stringify(streamConfig.headers);
|
||||
console.log(`Setting TELEPORT_HEADERS for resume endpoint`);
|
||||
}
|
||||
}
|
||||
|
||||
// Log the full Claude command being executed
|
||||
console.log(`Running Claude with args: ${config.claudeArgs.join(" ")}`);
|
||||
|
||||
const claudeProcess = spawn("claude", config.claudeArgs, {
|
||||
stdio: ["pipe", "pipe", "inherit"],
|
||||
env: {
|
||||
...process.env,
|
||||
...config.env,
|
||||
},
|
||||
env: processEnv,
|
||||
});
|
||||
|
||||
// Handle Claude process errors
|
||||
@@ -178,32 +246,51 @@ export async function runClaude(promptPath: string, options: ClaudeOptions) {
|
||||
|
||||
// Capture output for parsing execution metrics
|
||||
let output = "";
|
||||
claudeProcess.stdout.on("data", (data) => {
|
||||
let lineBuffer = ""; // Buffer for incomplete lines
|
||||
|
||||
claudeProcess.stdout.on("data", async (data) => {
|
||||
const text = data.toString();
|
||||
output += text;
|
||||
|
||||
// Try to parse as JSON and pretty print if it's on a single line
|
||||
const lines = text.split("\n");
|
||||
lines.forEach((line: string, index: number) => {
|
||||
if (line.trim() === "") return;
|
||||
// Add new data to line buffer
|
||||
lineBuffer += text;
|
||||
|
||||
// Split into lines - the last element might be incomplete
|
||||
const lines = lineBuffer.split("\n");
|
||||
|
||||
// The last element is either empty (if text ended with \n) or incomplete
|
||||
lineBuffer = lines.pop() || "";
|
||||
|
||||
// Process complete lines
|
||||
for (let index = 0; index < lines.length; index++) {
|
||||
const line = lines[index];
|
||||
if (!line || line.trim() === "") continue;
|
||||
|
||||
// Try to parse as JSON and pretty print if it's on a single line
|
||||
try {
|
||||
// Check if this line is a JSON object
|
||||
const parsed = JSON.parse(line);
|
||||
const prettyJson = JSON.stringify(parsed, null, 2);
|
||||
process.stdout.write(prettyJson);
|
||||
if (index < lines.length - 1 || text.endsWith("\n")) {
|
||||
process.stdout.write("\n");
|
||||
process.stdout.write("\n");
|
||||
|
||||
// Send valid JSON to stream handler if available
|
||||
if (streamHandler) {
|
||||
try {
|
||||
// Send the original line (which is valid JSON) with newline for proper splitting
|
||||
const dataToSend = line + "\n";
|
||||
await streamHandler.addOutput(dataToSend);
|
||||
} catch (error) {
|
||||
core.warning(`Failed to stream output: ${error}`);
|
||||
}
|
||||
}
|
||||
} catch (e) {
|
||||
// Not a JSON object, print as is
|
||||
process.stdout.write(line);
|
||||
if (index < lines.length - 1 || text.endsWith("\n")) {
|
||||
process.stdout.write("\n");
|
||||
}
|
||||
process.stdout.write("\n");
|
||||
// Don't send non-JSON lines to stream handler
|
||||
}
|
||||
});
|
||||
|
||||
output += text;
|
||||
}
|
||||
});
|
||||
|
||||
// Handle stdout errors
|
||||
@@ -257,8 +344,33 @@ export async function runClaude(promptPath: string, options: ClaudeOptions) {
|
||||
}
|
||||
}, timeoutMs);
|
||||
|
||||
claudeProcess.on("close", (code) => {
|
||||
claudeProcess.on("close", async (code) => {
|
||||
if (!resolved) {
|
||||
// Process any remaining data in the line buffer
|
||||
if (lineBuffer.trim()) {
|
||||
// Try to parse and print the remaining line
|
||||
try {
|
||||
const parsed = JSON.parse(lineBuffer);
|
||||
const prettyJson = JSON.stringify(parsed, null, 2);
|
||||
process.stdout.write(prettyJson);
|
||||
process.stdout.write("\n");
|
||||
|
||||
// Send valid JSON to stream handler if available
|
||||
if (streamHandler) {
|
||||
try {
|
||||
const dataToSend = lineBuffer + "\n";
|
||||
await streamHandler.addOutput(dataToSend);
|
||||
} catch (error) {
|
||||
core.warning(`Failed to stream final output: ${error}`);
|
||||
}
|
||||
}
|
||||
} catch (e) {
|
||||
process.stdout.write(lineBuffer);
|
||||
process.stdout.write("\n");
|
||||
// Don't send non-JSON lines to stream handler
|
||||
}
|
||||
}
|
||||
|
||||
clearTimeout(timeoutId);
|
||||
resolved = true;
|
||||
resolve(code || 0);
|
||||
@@ -275,6 +387,15 @@ export async function runClaude(promptPath: string, options: ClaudeOptions) {
|
||||
});
|
||||
});
|
||||
|
||||
// Clean up streaming
|
||||
if (streamHandler) {
|
||||
try {
|
||||
await streamHandler.close();
|
||||
} catch (error) {
|
||||
core.warning(`Failed to close stream handler: ${error}`);
|
||||
}
|
||||
}
|
||||
|
||||
// Clean up processes
|
||||
try {
|
||||
catProcess.kill("SIGTERM");
|
||||
|
||||
152
base-action/src/stream-handler.ts
Normal file
152
base-action/src/stream-handler.ts
Normal file
@@ -0,0 +1,152 @@
|
||||
import * as core from "@actions/core";
|
||||
|
||||
export function parseStreamHeaders(
|
||||
headersInput?: string,
|
||||
): Record<string, string> {
|
||||
if (!headersInput || headersInput.trim() === "") {
|
||||
return {};
|
||||
}
|
||||
|
||||
try {
|
||||
return JSON.parse(headersInput);
|
||||
} catch (e) {
|
||||
console.error("Failed to parse stream headers as JSON:", e);
|
||||
return {};
|
||||
}
|
||||
}
|
||||
|
||||
export type TokenGetter = (audience: string) => Promise<string>;
|
||||
|
||||
export class StreamHandler {
|
||||
private endpoint: string;
|
||||
private customHeaders: Record<string, string>;
|
||||
private tokenGetter: TokenGetter;
|
||||
private token: string | null = null;
|
||||
private tokenFetchTime: number = 0;
|
||||
private buffer: string[] = [];
|
||||
private flushTimer: NodeJS.Timeout | null = null;
|
||||
private isClosed = false;
|
||||
|
||||
private readonly TOKEN_LIFETIME_MS = 4 * 60 * 1000; // 4 minutes
|
||||
private readonly BATCH_SIZE = 10;
|
||||
private readonly BATCH_TIMEOUT_MS = 1000;
|
||||
private readonly REQUEST_TIMEOUT_MS = 5000;
|
||||
|
||||
constructor(
|
||||
endpoint: string,
|
||||
customHeaders: Record<string, string> = {},
|
||||
tokenGetter?: TokenGetter,
|
||||
) {
|
||||
this.endpoint = endpoint;
|
||||
this.customHeaders = customHeaders;
|
||||
this.tokenGetter = tokenGetter || ((audience) => core.getIDToken(audience));
|
||||
}
|
||||
|
||||
async addOutput(data: string): Promise<void> {
|
||||
if (this.isClosed) return;
|
||||
|
||||
// Split by newlines and add to buffer
|
||||
const lines = data.split("\n").filter((line) => line.length > 0);
|
||||
this.buffer.push(...lines);
|
||||
|
||||
// Check if we should flush
|
||||
if (this.buffer.length >= this.BATCH_SIZE) {
|
||||
await this.flush();
|
||||
} else {
|
||||
// Set or reset the timer
|
||||
this.resetFlushTimer();
|
||||
}
|
||||
}
|
||||
|
||||
private resetFlushTimer(): void {
|
||||
if (this.flushTimer) {
|
||||
clearTimeout(this.flushTimer);
|
||||
}
|
||||
this.flushTimer = setTimeout(() => {
|
||||
this.flush().catch((err) => {
|
||||
core.warning(`Failed to flush stream buffer: ${err}`);
|
||||
});
|
||||
}, this.BATCH_TIMEOUT_MS);
|
||||
}
|
||||
|
||||
private async getToken(): Promise<string> {
|
||||
const now = Date.now();
|
||||
|
||||
// Check if we need a new token
|
||||
if (!this.token || now - this.tokenFetchTime >= this.TOKEN_LIFETIME_MS) {
|
||||
try {
|
||||
this.token = await this.tokenGetter("claude-code-github-action");
|
||||
this.tokenFetchTime = now;
|
||||
core.debug("Fetched new OIDC token for streaming");
|
||||
} catch (error) {
|
||||
throw new Error(`Failed to get OIDC token: ${error}`);
|
||||
}
|
||||
}
|
||||
|
||||
return this.token;
|
||||
}
|
||||
|
||||
private async flush(): Promise<void> {
|
||||
if (this.buffer.length === 0) return;
|
||||
|
||||
// Clear the flush timer
|
||||
if (this.flushTimer) {
|
||||
clearTimeout(this.flushTimer);
|
||||
this.flushTimer = null;
|
||||
}
|
||||
|
||||
// Get the current buffer and clear it
|
||||
const output = [...this.buffer];
|
||||
this.buffer = [];
|
||||
|
||||
try {
|
||||
const token = await this.getToken();
|
||||
|
||||
const payload = {
|
||||
timestamp: new Date().toISOString(),
|
||||
output: output,
|
||||
};
|
||||
|
||||
// Create an AbortController for timeout
|
||||
const controller = new AbortController();
|
||||
const timeoutId = setTimeout(
|
||||
() => controller.abort(),
|
||||
this.REQUEST_TIMEOUT_MS,
|
||||
);
|
||||
|
||||
try {
|
||||
await fetch(this.endpoint, {
|
||||
method: "POST",
|
||||
headers: {
|
||||
"Content-Type": "application/json",
|
||||
Authorization: `Bearer ${token}`,
|
||||
...this.customHeaders,
|
||||
},
|
||||
body: JSON.stringify(payload),
|
||||
signal: controller.signal,
|
||||
});
|
||||
} finally {
|
||||
clearTimeout(timeoutId);
|
||||
}
|
||||
} catch (error) {
|
||||
// Log but don't throw - we don't want to interrupt Claude's execution
|
||||
core.warning(`Failed to stream output: ${error}`);
|
||||
}
|
||||
}
|
||||
|
||||
async close(): Promise<void> {
|
||||
// Clear any pending timer
|
||||
if (this.flushTimer) {
|
||||
clearTimeout(this.flushTimer);
|
||||
this.flushTimer = null;
|
||||
}
|
||||
|
||||
// Flush any remaining output
|
||||
if (this.buffer.length > 0) {
|
||||
await this.flush();
|
||||
}
|
||||
|
||||
// Mark as closed after flushing
|
||||
this.isClosed = true;
|
||||
}
|
||||
}
|
||||
97
base-action/test/resume-endpoint.test.ts
Normal file
97
base-action/test/resume-endpoint.test.ts
Normal file
@@ -0,0 +1,97 @@
|
||||
import { describe, it, expect } from "bun:test";
|
||||
import { prepareRunConfig } from "../src/run-claude";
|
||||
|
||||
describe("resume endpoint functionality", () => {
|
||||
it("should add --teleport flag when both session_id and resume_endpoint are provided", () => {
|
||||
const streamConfig = JSON.stringify({
|
||||
session_id: "12345",
|
||||
resume_endpoint: "https://example.com/resume/12345",
|
||||
});
|
||||
const config = prepareRunConfig("/path/to/prompt", {
|
||||
streamConfig,
|
||||
});
|
||||
|
||||
expect(config.claudeArgs).toContain("--teleport");
|
||||
expect(config.claudeArgs).toContain("12345");
|
||||
});
|
||||
|
||||
it("should not add --teleport flag when no streamConfig is provided", () => {
|
||||
const config = prepareRunConfig("/path/to/prompt", {
|
||||
allowedTools: "Edit",
|
||||
});
|
||||
|
||||
expect(config.claudeArgs).not.toContain("--teleport");
|
||||
});
|
||||
|
||||
it("should not add --teleport flag when only session_id is provided without resume_endpoint", () => {
|
||||
const streamConfig = JSON.stringify({
|
||||
session_id: "12345",
|
||||
// No resume_endpoint
|
||||
});
|
||||
const config = prepareRunConfig("/path/to/prompt", {
|
||||
streamConfig,
|
||||
});
|
||||
|
||||
expect(config.claudeArgs).not.toContain("--teleport");
|
||||
});
|
||||
|
||||
it("should not add --teleport flag when only resume_endpoint is provided without session_id", () => {
|
||||
const streamConfig = JSON.stringify({
|
||||
resume_endpoint: "https://example.com/resume/12345",
|
||||
// No session_id
|
||||
});
|
||||
const config = prepareRunConfig("/path/to/prompt", {
|
||||
streamConfig,
|
||||
});
|
||||
|
||||
expect(config.claudeArgs).not.toContain("--teleport");
|
||||
});
|
||||
|
||||
it("should maintain order of arguments with session_id", () => {
|
||||
const streamConfig = JSON.stringify({
|
||||
session_id: "12345",
|
||||
resume_endpoint: "https://example.com/resume/12345",
|
||||
});
|
||||
const config = prepareRunConfig("/path/to/prompt", {
|
||||
allowedTools: "Edit",
|
||||
streamConfig,
|
||||
maxTurns: "5",
|
||||
});
|
||||
|
||||
const teleportIndex = config.claudeArgs.indexOf("--teleport");
|
||||
const maxTurnsIndex = config.claudeArgs.indexOf("--max-turns");
|
||||
|
||||
expect(teleportIndex).toBeGreaterThan(-1);
|
||||
expect(maxTurnsIndex).toBeGreaterThan(-1);
|
||||
});
|
||||
|
||||
it("should handle progress_endpoint and headers in streamConfig", () => {
|
||||
const streamConfig = JSON.stringify({
|
||||
progress_endpoint: "https://example.com/progress",
|
||||
headers: { "X-Test": "value" },
|
||||
});
|
||||
const config = prepareRunConfig("/path/to/prompt", {
|
||||
streamConfig,
|
||||
});
|
||||
|
||||
// This test just verifies parsing doesn't fail - actual streaming logic
|
||||
// is tested elsewhere as it requires environment setup
|
||||
expect(config.claudeArgs).toBeDefined();
|
||||
});
|
||||
|
||||
it("should handle session_id with resume_endpoint and headers", () => {
|
||||
const streamConfig = JSON.stringify({
|
||||
session_id: "abc123",
|
||||
resume_endpoint: "https://example.com/resume/abc123",
|
||||
headers: { Authorization: "Bearer token" },
|
||||
progress_endpoint: "https://example.com/progress",
|
||||
});
|
||||
const config = prepareRunConfig("/path/to/prompt", {
|
||||
streamConfig,
|
||||
});
|
||||
|
||||
expect(config.claudeArgs).toContain("--teleport");
|
||||
expect(config.claudeArgs).toContain("abc123");
|
||||
// Note: Environment variable setup (TELEPORT_RESUME_URL, TELEPORT_HEADERS) is tested in integration tests
|
||||
});
|
||||
});
|
||||
@@ -134,7 +134,7 @@ describe("setupClaudeCodeSettings", () => {
|
||||
// Then, add new settings
|
||||
const newSettings = JSON.stringify({
|
||||
newKey: "newValue",
|
||||
model: "claude-opus-4-1-20250805",
|
||||
model: "claude-opus-4-20250514",
|
||||
});
|
||||
|
||||
await setupClaudeCodeSettings(newSettings, testHomeDir);
|
||||
@@ -145,7 +145,7 @@ describe("setupClaudeCodeSettings", () => {
|
||||
expect(settings.enableAllProjectMcpServers).toBe(true);
|
||||
expect(settings.existingKey).toBe("existingValue");
|
||||
expect(settings.newKey).toBe("newValue");
|
||||
expect(settings.model).toBe("claude-opus-4-1-20250805");
|
||||
expect(settings.model).toBe("claude-opus-4-20250514");
|
||||
});
|
||||
|
||||
test("should copy slash commands to .claude directory when path provided", async () => {
|
||||
|
||||
364
base-action/test/stream-handler.test.ts
Normal file
364
base-action/test/stream-handler.test.ts
Normal file
@@ -0,0 +1,364 @@
|
||||
import { describe, it, expect, beforeEach, mock } from "bun:test";
|
||||
import {
|
||||
StreamHandler,
|
||||
parseStreamHeaders,
|
||||
type TokenGetter,
|
||||
} from "../src/stream-handler";
|
||||
|
||||
describe("parseStreamHeaders", () => {
|
||||
it("should return empty object for empty input", () => {
|
||||
expect(parseStreamHeaders("")).toEqual({});
|
||||
expect(parseStreamHeaders(undefined)).toEqual({});
|
||||
expect(parseStreamHeaders(" ")).toEqual({});
|
||||
});
|
||||
|
||||
it("should parse single header", () => {
|
||||
const result = parseStreamHeaders('{"X-Correlation-Id": "12345"}');
|
||||
expect(result).toEqual({ "X-Correlation-Id": "12345" });
|
||||
});
|
||||
|
||||
it("should parse multiple headers", () => {
|
||||
const headers = JSON.stringify({
|
||||
"X-Correlation-Id": "12345",
|
||||
"X-Custom-Header": "custom-value",
|
||||
Authorization: "Bearer token123",
|
||||
});
|
||||
|
||||
const result = parseStreamHeaders(headers);
|
||||
expect(result).toEqual({
|
||||
"X-Correlation-Id": "12345",
|
||||
"X-Custom-Header": "custom-value",
|
||||
Authorization: "Bearer token123",
|
||||
});
|
||||
});
|
||||
|
||||
it("should handle headers with spaces", () => {
|
||||
const headers = JSON.stringify({
|
||||
"X-Header-One": "value with spaces",
|
||||
"X-Header-Two": "another value",
|
||||
});
|
||||
|
||||
const result = parseStreamHeaders(headers);
|
||||
expect(result).toEqual({
|
||||
"X-Header-One": "value with spaces",
|
||||
"X-Header-Two": "another value",
|
||||
});
|
||||
});
|
||||
|
||||
it("should skip empty lines and comments", () => {
|
||||
const headers = JSON.stringify({
|
||||
"X-Header-One": "value1",
|
||||
"X-Header-Two": "value2",
|
||||
"X-Header-Three": "value3",
|
||||
});
|
||||
|
||||
const result = parseStreamHeaders(headers);
|
||||
expect(result).toEqual({
|
||||
"X-Header-One": "value1",
|
||||
"X-Header-Two": "value2",
|
||||
"X-Header-Three": "value3",
|
||||
});
|
||||
});
|
||||
|
||||
it("should skip lines without colons", () => {
|
||||
const headers = JSON.stringify({
|
||||
"X-Header-One": "value1",
|
||||
"X-Header-Two": "value2",
|
||||
});
|
||||
|
||||
const result = parseStreamHeaders(headers);
|
||||
expect(result).toEqual({
|
||||
"X-Header-One": "value1",
|
||||
"X-Header-Two": "value2",
|
||||
});
|
||||
});
|
||||
|
||||
it("should handle headers with colons in values", () => {
|
||||
const headers = JSON.stringify({
|
||||
"X-URL": "https://example.com:8080/path",
|
||||
"X-Time": "10:30:45",
|
||||
});
|
||||
|
||||
const result = parseStreamHeaders(headers);
|
||||
expect(result).toEqual({
|
||||
"X-URL": "https://example.com:8080/path",
|
||||
"X-Time": "10:30:45",
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
describe("StreamHandler", () => {
|
||||
let handler: StreamHandler;
|
||||
let mockFetch: ReturnType<typeof mock>;
|
||||
let mockTokenGetter: TokenGetter;
|
||||
const mockEndpoint = "https://test.example.com/stream";
|
||||
const mockToken = "mock-oidc-token";
|
||||
|
||||
beforeEach(() => {
|
||||
// Mock fetch
|
||||
mockFetch = mock(() => Promise.resolve({ ok: true }));
|
||||
global.fetch = mockFetch as any;
|
||||
|
||||
// Mock token getter
|
||||
mockTokenGetter = mock(() => Promise.resolve(mockToken));
|
||||
});
|
||||
|
||||
describe("basic functionality", () => {
|
||||
it("should batch lines up to BATCH_SIZE", async () => {
|
||||
handler = new StreamHandler(mockEndpoint, {}, mockTokenGetter);
|
||||
|
||||
// Add 9 lines (less than batch size of 10)
|
||||
for (let i = 1; i <= 9; i++) {
|
||||
await handler.addOutput(`line ${i}\n`);
|
||||
}
|
||||
|
||||
// Should not have sent anything yet
|
||||
expect(mockFetch).not.toHaveBeenCalled();
|
||||
|
||||
// Add the 10th line to trigger flush
|
||||
await handler.addOutput("line 10\n");
|
||||
|
||||
// Should have sent the batch
|
||||
expect(mockFetch).toHaveBeenCalledTimes(1);
|
||||
expect(mockFetch).toHaveBeenCalledWith(mockEndpoint, {
|
||||
method: "POST",
|
||||
headers: {
|
||||
"Content-Type": "application/json",
|
||||
Authorization: `Bearer ${mockToken}`,
|
||||
},
|
||||
body: expect.stringContaining(
|
||||
'"output":["line 1","line 2","line 3","line 4","line 5","line 6","line 7","line 8","line 9","line 10"]',
|
||||
),
|
||||
signal: expect.any(AbortSignal),
|
||||
});
|
||||
});
|
||||
|
||||
it("should flush on timeout", async () => {
|
||||
handler = new StreamHandler(mockEndpoint, {}, mockTokenGetter);
|
||||
|
||||
// Add a few lines
|
||||
await handler.addOutput("line 1\n");
|
||||
await handler.addOutput("line 2\n");
|
||||
|
||||
// Should not have sent anything yet
|
||||
expect(mockFetch).not.toHaveBeenCalled();
|
||||
|
||||
// Wait for the timeout to trigger
|
||||
await new Promise((resolve) => setTimeout(resolve, 1100));
|
||||
|
||||
// Should have sent the batch
|
||||
expect(mockFetch).toHaveBeenCalledTimes(1);
|
||||
const call = mockFetch.mock.calls[0];
|
||||
expect(call).toBeDefined();
|
||||
const body = JSON.parse(call![1].body);
|
||||
expect(body.output).toEqual(["line 1", "line 2"]);
|
||||
});
|
||||
|
||||
it("should include custom headers", async () => {
|
||||
const customHeaders = {
|
||||
"X-Correlation-Id": "12345",
|
||||
"X-Custom": "value",
|
||||
};
|
||||
handler = new StreamHandler(mockEndpoint, customHeaders, mockTokenGetter);
|
||||
|
||||
// Trigger a batch
|
||||
for (let i = 1; i <= 10; i++) {
|
||||
await handler.addOutput(`line ${i}\n`);
|
||||
}
|
||||
|
||||
expect(mockFetch).toHaveBeenCalledWith(mockEndpoint, {
|
||||
method: "POST",
|
||||
headers: {
|
||||
"Content-Type": "application/json",
|
||||
Authorization: `Bearer ${mockToken}`,
|
||||
"X-Correlation-Id": "12345",
|
||||
"X-Custom": "value",
|
||||
},
|
||||
body: expect.any(String),
|
||||
signal: expect.any(AbortSignal),
|
||||
});
|
||||
});
|
||||
|
||||
it("should include timestamp in payload", async () => {
|
||||
handler = new StreamHandler(mockEndpoint, {}, mockTokenGetter);
|
||||
|
||||
const beforeTime = new Date().toISOString();
|
||||
|
||||
// Trigger a batch
|
||||
for (let i = 1; i <= 10; i++) {
|
||||
await handler.addOutput(`line ${i}\n`);
|
||||
}
|
||||
|
||||
const afterTime = new Date().toISOString();
|
||||
|
||||
const call = mockFetch.mock.calls[0];
|
||||
expect(call).toBeDefined();
|
||||
const body = JSON.parse(call![1].body);
|
||||
|
||||
expect(body).toHaveProperty("timestamp");
|
||||
expect(new Date(body.timestamp).toISOString()).toBe(body.timestamp);
|
||||
expect(body.timestamp >= beforeTime).toBe(true);
|
||||
expect(body.timestamp <= afterTime).toBe(true);
|
||||
});
|
||||
});
|
||||
|
||||
describe("token management", () => {
|
||||
it("should fetch token on first request", async () => {
|
||||
handler = new StreamHandler(mockEndpoint, {}, mockTokenGetter);
|
||||
|
||||
// Trigger a flush
|
||||
for (let i = 1; i <= 10; i++) {
|
||||
await handler.addOutput(`line ${i}\n`);
|
||||
}
|
||||
|
||||
expect(mockTokenGetter).toHaveBeenCalledWith("claude-code-github-action");
|
||||
expect(mockTokenGetter).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
|
||||
it("should reuse token within 4 minutes", async () => {
|
||||
handler = new StreamHandler(mockEndpoint, {}, mockTokenGetter);
|
||||
|
||||
// First batch
|
||||
for (let i = 1; i <= 10; i++) {
|
||||
await handler.addOutput(`line ${i}\n`);
|
||||
}
|
||||
|
||||
// Second batch immediately (within 4 minutes)
|
||||
for (let i = 11; i <= 20; i++) {
|
||||
await handler.addOutput(`line ${i}\n`);
|
||||
}
|
||||
|
||||
// Should have only fetched token once
|
||||
expect(mockTokenGetter).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
|
||||
it("should handle token fetch errors", async () => {
|
||||
const errorTokenGetter = mock(() =>
|
||||
Promise.reject(new Error("Token fetch failed")),
|
||||
);
|
||||
handler = new StreamHandler(mockEndpoint, {}, errorTokenGetter);
|
||||
|
||||
// Try to send data
|
||||
for (let i = 1; i <= 10; i++) {
|
||||
await handler.addOutput(`line ${i}\n`);
|
||||
}
|
||||
|
||||
// Should not have made fetch request
|
||||
expect(mockFetch).not.toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
|
||||
describe("error handling", () => {
|
||||
it("should handle fetch errors gracefully", async () => {
|
||||
mockFetch.mockImplementation(() =>
|
||||
Promise.reject(new Error("Network error")),
|
||||
);
|
||||
handler = new StreamHandler(mockEndpoint, {}, mockTokenGetter);
|
||||
|
||||
// Send data - should not throw
|
||||
for (let i = 1; i <= 10; i++) {
|
||||
await handler.addOutput(`line ${i}\n`);
|
||||
}
|
||||
|
||||
// Should have attempted to fetch
|
||||
expect(mockFetch).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
|
||||
it("should continue processing after errors", async () => {
|
||||
handler = new StreamHandler(mockEndpoint, {}, mockTokenGetter);
|
||||
|
||||
// First batch - make it fail
|
||||
let callCount = 0;
|
||||
mockFetch.mockImplementation(() => {
|
||||
callCount++;
|
||||
if (callCount === 1) {
|
||||
return Promise.reject(new Error("First batch failed"));
|
||||
}
|
||||
return Promise.resolve({ ok: true });
|
||||
});
|
||||
|
||||
for (let i = 1; i <= 10; i++) {
|
||||
await handler.addOutput(`line ${i}\n`);
|
||||
}
|
||||
|
||||
// Second batch - should work
|
||||
for (let i = 11; i <= 20; i++) {
|
||||
await handler.addOutput(`line ${i}\n`);
|
||||
}
|
||||
|
||||
// Should have attempted both batches
|
||||
expect(mockFetch).toHaveBeenCalledTimes(2);
|
||||
});
|
||||
});
|
||||
|
||||
describe("close functionality", () => {
|
||||
it("should flush remaining data on close", async () => {
|
||||
handler = new StreamHandler(mockEndpoint, {}, mockTokenGetter);
|
||||
|
||||
// Add some data but not enough to trigger batch
|
||||
await handler.addOutput("line 1\n");
|
||||
await handler.addOutput("line 2\n");
|
||||
|
||||
expect(mockFetch).not.toHaveBeenCalled();
|
||||
|
||||
// Close should flush
|
||||
await handler.close();
|
||||
|
||||
expect(mockFetch).toHaveBeenCalledTimes(1);
|
||||
const call = mockFetch.mock.calls[0];
|
||||
expect(call).toBeDefined();
|
||||
const body = JSON.parse(call![1].body);
|
||||
expect(body.output).toEqual(["line 1", "line 2"]);
|
||||
});
|
||||
|
||||
it("should not accept new data after close", async () => {
|
||||
handler = new StreamHandler(mockEndpoint, {}, mockTokenGetter);
|
||||
|
||||
await handler.close();
|
||||
|
||||
// Try to add data after close
|
||||
await handler.addOutput("should not be sent\n");
|
||||
|
||||
// Should not have sent anything
|
||||
expect(mockFetch).not.toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
|
||||
describe("data handling", () => {
|
||||
it("should filter out empty lines", async () => {
|
||||
handler = new StreamHandler(mockEndpoint, {}, mockTokenGetter);
|
||||
|
||||
await handler.addOutput("line 1\n\n\nline 2\n\n");
|
||||
await handler.close();
|
||||
|
||||
const call = mockFetch.mock.calls[0];
|
||||
expect(call).toBeDefined();
|
||||
const body = JSON.parse(call![1].body);
|
||||
expect(body.output).toEqual(["line 1", "line 2"]);
|
||||
});
|
||||
|
||||
it("should handle data without newlines", async () => {
|
||||
handler = new StreamHandler(mockEndpoint, {}, mockTokenGetter);
|
||||
|
||||
await handler.addOutput("single line");
|
||||
await handler.close();
|
||||
|
||||
const call = mockFetch.mock.calls[0];
|
||||
expect(call).toBeDefined();
|
||||
const body = JSON.parse(call![1].body);
|
||||
expect(body.output).toEqual(["single line"]);
|
||||
});
|
||||
|
||||
it("should handle multi-line input correctly", async () => {
|
||||
handler = new StreamHandler(mockEndpoint, {}, mockTokenGetter);
|
||||
|
||||
await handler.addOutput("line 1\nline 2\nline 3");
|
||||
await handler.close();
|
||||
|
||||
const call = mockFetch.mock.calls[0];
|
||||
expect(call).toBeDefined();
|
||||
const body = JSON.parse(call![1].body);
|
||||
expect(body.output).toEqual(["line 1", "line 2", "line 3"]);
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -207,8 +207,15 @@ Claude does **not** have access to execute arbitrary Bash commands by default. I
|
||||
```yaml
|
||||
- uses: anthropics/claude-code-action@beta
|
||||
with:
|
||||
allowed_tools: "Bash(npm install),Bash(npm run test),Edit,Replace,NotebookEditCell"
|
||||
disallowed_tools: "TaskOutput,KillTask"
|
||||
allowed_tools: |
|
||||
Bash(npm install)
|
||||
Bash(npm run test)
|
||||
Edit
|
||||
Replace
|
||||
NotebookEditCell
|
||||
disallowed_tools: |
|
||||
TaskOutput
|
||||
KillTask
|
||||
# ... other inputs
|
||||
```
|
||||
|
||||
@@ -245,7 +252,7 @@ You can provide Claude Code settings to customize behavior such as model selecti
|
||||
with:
|
||||
settings: |
|
||||
{
|
||||
"model": "claude-opus-4-1-20250805",
|
||||
"model": "claude-opus-4-20250514",
|
||||
"env": {
|
||||
"DEBUG": "true",
|
||||
"API_URL": "https://api.example.com"
|
||||
|
||||
@@ -3,7 +3,7 @@
|
||||
## Access Control
|
||||
|
||||
- **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
|
||||
- **No Bot Triggers**: GitHub Apps and bots cannot trigger this action
|
||||
- **Token Permissions**: The GitHub app receives only a short-lived token scoped specifically to the repository it's operating in
|
||||
- **No Cross-Repository Access**: Each action invocation is limited to the repository where it was triggered
|
||||
- **Limited Scope**: The token cannot access other repositories or perform actions beyond the configured permissions
|
||||
|
||||
@@ -42,8 +42,6 @@ jobs:
|
||||
# Optional: grant additional permissions (requires corresponding GitHub token permissions)
|
||||
# additional_permissions: |
|
||||
# actions: read
|
||||
# Optional: allow bot users to trigger the action
|
||||
# allowed_bots: "dependabot[bot],renovate[bot]"
|
||||
```
|
||||
|
||||
## Inputs
|
||||
@@ -78,7 +76,6 @@ jobs:
|
||||
| `additional_permissions` | Additional permissions to enable. Currently supports 'actions: read' for viewing workflow results | No | "" |
|
||||
| `experimental_allowed_domains` | Restrict network access to these domains only (newline-separated). | No | "" |
|
||||
| `use_commit_signing` | Enable commit signing using GitHub's commit signature verification. When false, Claude uses standard git commands | No | `false` |
|
||||
| `allowed_bots` | Comma-separated list of allowed bot usernames, or '\*' to allow all bots. Empty string (default) allows no bots | No | "" |
|
||||
|
||||
\*Required when using direct Anthropic API (default and when not using Bedrock or Vertex)
|
||||
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
name: Claude Code
|
||||
name: Claude PR Assistant
|
||||
|
||||
on:
|
||||
issue_comment:
|
||||
@@ -11,53 +11,38 @@ on:
|
||||
types: [submitted]
|
||||
|
||||
jobs:
|
||||
claude:
|
||||
claude-code-action:
|
||||
if: |
|
||||
(github.event_name == 'issue_comment' && contains(github.event.comment.body, '@claude')) ||
|
||||
(github.event_name == 'pull_request_review_comment' && contains(github.event.comment.body, '@claude')) ||
|
||||
(github.event_name == 'pull_request_review' && contains(github.event.review.body, '@claude')) ||
|
||||
(github.event_name == 'issues' && (contains(github.event.issue.body, '@claude') || contains(github.event.issue.title, '@claude')))
|
||||
(github.event_name == 'issues' && contains(github.event.issue.body, '@claude'))
|
||||
runs-on: ubuntu-latest
|
||||
permissions:
|
||||
contents: write
|
||||
pull-requests: write
|
||||
issues: write
|
||||
contents: read
|
||||
pull-requests: read
|
||||
issues: read
|
||||
id-token: write
|
||||
actions: read # Required for Claude to read CI results on PRs
|
||||
steps:
|
||||
- name: Checkout repository
|
||||
uses: actions/checkout@v4
|
||||
with:
|
||||
fetch-depth: 1
|
||||
|
||||
- name: Run Claude Code
|
||||
id: claude
|
||||
- name: Run Claude PR Action
|
||||
uses: anthropics/claude-code-action@beta
|
||||
with:
|
||||
anthropic_api_key: ${{ secrets.ANTHROPIC_API_KEY }}
|
||||
|
||||
# This is an optional setting that allows Claude to read CI results on PRs
|
||||
additional_permissions: |
|
||||
actions: read
|
||||
|
||||
# Optional: Specify model (defaults to Claude Sonnet 4, uncomment for Claude Opus 4.1)
|
||||
# model: "claude-opus-4-1-20250805"
|
||||
|
||||
# Optional: Customize the trigger phrase (default: @claude)
|
||||
# trigger_phrase: "/claude"
|
||||
|
||||
# Optional: Trigger when specific user is assigned to an issue
|
||||
# assignee_trigger: "claude-bot"
|
||||
|
||||
# Optional: Allow Claude to run specific commands
|
||||
# allowed_tools: "Bash(npm install),Bash(npm run build),Bash(npm run test:*),Bash(npm run lint:*)"
|
||||
|
||||
# Optional: Add custom instructions for Claude to customize its behavior for your project
|
||||
# custom_instructions: |
|
||||
# Follow our coding standards
|
||||
# Ensure all new code has tests
|
||||
# Use TypeScript for new files
|
||||
|
||||
# Optional: Custom environment variables for Claude
|
||||
# claude_env: |
|
||||
# NODE_ENV: test
|
||||
# Or use OAuth token instead:
|
||||
# claude_code_oauth_token: ${{ secrets.CLAUDE_CODE_OAUTH_TOKEN }}
|
||||
timeout_minutes: "60"
|
||||
# mode: tag # Default: responds to @claude mentions
|
||||
# Optional: Restrict network access to specific domains only
|
||||
# experimental_allowed_domains: |
|
||||
# .anthropic.com
|
||||
# .github.com
|
||||
# api.github.com
|
||||
# .githubusercontent.com
|
||||
# bun.sh
|
||||
# registry.npmjs.org
|
||||
# .blob.core.windows.net
|
||||
|
||||
@@ -6,8 +6,8 @@ echo "Installing git hooks..."
|
||||
# Make sure hooks directory exists
|
||||
mkdir -p .git/hooks
|
||||
|
||||
# Install pre-commit hook
|
||||
cp scripts/pre-commit .git/hooks/pre-commit
|
||||
chmod +x .git/hooks/pre-commit
|
||||
# Install pre-push hook
|
||||
cp scripts/pre-push .git/hooks/pre-push
|
||||
chmod +x .git/hooks/pre-push
|
||||
|
||||
echo "Git hooks installed successfully!"
|
||||
@@ -60,6 +60,8 @@ export function buildAllowedToolsString(
|
||||
"Bash(git diff:*)",
|
||||
"Bash(git log:*)",
|
||||
"Bash(git rm:*)",
|
||||
"Bash(git config user.name:*)",
|
||||
"Bash(git config user.email:*)",
|
||||
);
|
||||
}
|
||||
|
||||
|
||||
@@ -12,7 +12,6 @@ import { createOctokit } from "../github/api/client";
|
||||
import { parseGitHubContext, isEntityContext } from "../github/context";
|
||||
import { getMode, isValidMode, DEFAULT_MODE } from "../modes/registry";
|
||||
import type { ModeName } from "../modes/types";
|
||||
import { prepare } from "../prepare";
|
||||
|
||||
async function run() {
|
||||
try {
|
||||
@@ -60,7 +59,19 @@ async function run() {
|
||||
}
|
||||
|
||||
// Step 4: Get mode and check trigger conditions
|
||||
const mode = getMode(validatedMode, context);
|
||||
let mode;
|
||||
|
||||
// TEMPORARY HACK: Always use remote-agent mode for repository_dispatch events
|
||||
// This ensures backward compatibility while we transition
|
||||
if (context.eventName === "repository_dispatch") {
|
||||
console.log(
|
||||
"🔧 TEMPORARY HACK: Forcing remote-agent mode for repository_dispatch event",
|
||||
);
|
||||
mode = getMode("remote-agent", context);
|
||||
} else {
|
||||
mode = getMode(context.inputs.mode, context);
|
||||
}
|
||||
|
||||
const containsTrigger = mode.shouldTrigger(context);
|
||||
|
||||
// Set output for action.yml to check
|
||||
@@ -72,10 +83,9 @@ async function run() {
|
||||
}
|
||||
|
||||
// Step 5: Use the new modular prepare function
|
||||
const result = await prepare({
|
||||
const result = await mode.prepare({
|
||||
context,
|
||||
octokit,
|
||||
mode,
|
||||
githubToken,
|
||||
});
|
||||
|
||||
|
||||
81
src/entrypoints/report-claude-complete.ts
Normal file
81
src/entrypoints/report-claude-complete.ts
Normal file
@@ -0,0 +1,81 @@
|
||||
#!/usr/bin/env bun
|
||||
|
||||
import * as core from "@actions/core";
|
||||
import { reportClaudeComplete } from "../modes/remote-agent/system-progress-handler";
|
||||
import type { SystemProgressConfig } from "../modes/remote-agent/progress-types";
|
||||
import type { StreamConfig } from "../types/stream-config";
|
||||
|
||||
async function run() {
|
||||
try {
|
||||
// Only run if we're in remote-agent mode
|
||||
const mode = process.env.MODE;
|
||||
if (mode !== "remote-agent") {
|
||||
console.log(
|
||||
"Not in remote-agent mode, skipping Claude completion reporting",
|
||||
);
|
||||
return;
|
||||
}
|
||||
|
||||
// Check if we have stream config with system progress endpoint
|
||||
const streamConfigStr = process.env.STREAM_CONFIG;
|
||||
if (!streamConfigStr) {
|
||||
console.log(
|
||||
"No stream config available, skipping Claude completion reporting",
|
||||
);
|
||||
return;
|
||||
}
|
||||
|
||||
let streamConfig: StreamConfig;
|
||||
try {
|
||||
streamConfig = JSON.parse(streamConfigStr);
|
||||
} catch (e) {
|
||||
console.error("Failed to parse stream config:", e);
|
||||
return;
|
||||
}
|
||||
|
||||
if (!streamConfig.system_progress_endpoint) {
|
||||
console.log(
|
||||
"No system progress endpoint in stream config, skipping Claude completion reporting",
|
||||
);
|
||||
return;
|
||||
}
|
||||
|
||||
// Extract the system progress config
|
||||
const systemProgressConfig: SystemProgressConfig = {
|
||||
endpoint: streamConfig.system_progress_endpoint,
|
||||
headers: streamConfig.headers || {},
|
||||
};
|
||||
|
||||
// Get the OIDC token from Authorization header
|
||||
const authHeader = systemProgressConfig.headers?.["Authorization"];
|
||||
if (!authHeader || !authHeader.startsWith("Bearer ")) {
|
||||
console.error("No valid Authorization header in stream config");
|
||||
return;
|
||||
}
|
||||
const oidcToken = authHeader.substring(7); // Remove "Bearer " prefix
|
||||
|
||||
// Get Claude execution status
|
||||
const claudeConclusion = process.env.CLAUDE_CONCLUSION || "failure";
|
||||
const exitCode = claudeConclusion === "success" ? 0 : 1;
|
||||
|
||||
// Calculate duration if possible
|
||||
const startTime = process.env.CLAUDE_START_TIME;
|
||||
let durationMs = 0;
|
||||
if (startTime) {
|
||||
durationMs = Date.now() - parseInt(startTime, 10);
|
||||
}
|
||||
|
||||
// Report Claude completion
|
||||
console.log(
|
||||
`Reporting Claude completion: exitCode=${exitCode}, duration=${durationMs}ms`,
|
||||
);
|
||||
reportClaudeComplete(systemProgressConfig, oidcToken, exitCode, durationMs);
|
||||
} catch (error) {
|
||||
// Don't fail the action if reporting fails
|
||||
core.warning(`Failed to report Claude completion: ${error}`);
|
||||
}
|
||||
}
|
||||
|
||||
if (import.meta.main) {
|
||||
run();
|
||||
}
|
||||
@@ -6,6 +6,7 @@ import type {
|
||||
PullRequestEvent,
|
||||
PullRequestReviewEvent,
|
||||
PullRequestReviewCommentEvent,
|
||||
RepositoryDispatchEvent,
|
||||
} from "@octokit/webhooks-types";
|
||||
// Custom types for GitHub Actions events that aren't webhooks
|
||||
export type WorkflowDispatchEvent = {
|
||||
@@ -46,7 +47,11 @@ const ENTITY_EVENT_NAMES = [
|
||||
"pull_request_review_comment",
|
||||
] as const;
|
||||
|
||||
const AUTOMATION_EVENT_NAMES = ["workflow_dispatch", "schedule"] as const;
|
||||
const AUTOMATION_EVENT_NAMES = [
|
||||
"workflow_dispatch",
|
||||
"schedule",
|
||||
"repository_dispatch",
|
||||
] as const;
|
||||
|
||||
// Derive types from constants for better maintainability
|
||||
type EntityEventName = (typeof ENTITY_EVENT_NAMES)[number];
|
||||
@@ -62,6 +67,17 @@ type BaseContext = {
|
||||
full_name: string;
|
||||
};
|
||||
actor: string;
|
||||
payload:
|
||||
| IssuesEvent
|
||||
| IssueCommentEvent
|
||||
| PullRequestEvent
|
||||
| PullRequestReviewEvent
|
||||
| PullRequestReviewCommentEvent
|
||||
| RepositoryDispatchEvent
|
||||
| WorkflowDispatchEvent
|
||||
| ScheduleEvent;
|
||||
entityNumber?: number;
|
||||
isPR?: boolean;
|
||||
inputs: {
|
||||
mode: ModeName;
|
||||
triggerPhrase: string;
|
||||
@@ -77,7 +93,14 @@ type BaseContext = {
|
||||
useStickyComment: boolean;
|
||||
additionalPermissions: Map<string, string>;
|
||||
useCommitSigning: boolean;
|
||||
allowedBots: string;
|
||||
};
|
||||
progressTracking?: {
|
||||
headers?: Record<string, string>;
|
||||
resumeEndpoint?: string;
|
||||
sessionId?: string;
|
||||
progressEndpoint: string;
|
||||
systemProgressEndpoint?: string;
|
||||
oauthTokenEndpoint?: string;
|
||||
};
|
||||
};
|
||||
|
||||
@@ -97,7 +120,7 @@ export type ParsedGitHubContext = BaseContext & {
|
||||
// Context for automation events (workflow_dispatch, schedule)
|
||||
export type AutomationContext = BaseContext & {
|
||||
eventName: AutomationEventName;
|
||||
payload: WorkflowDispatchEvent | ScheduleEvent;
|
||||
payload: WorkflowDispatchEvent | ScheduleEvent | RepositoryDispatchEvent;
|
||||
};
|
||||
|
||||
// Union type for all contexts
|
||||
@@ -137,7 +160,6 @@ export function parseGitHubContext(): GitHubContext {
|
||||
process.env.ADDITIONAL_PERMISSIONS ?? "",
|
||||
),
|
||||
useCommitSigning: process.env.USE_COMMIT_SIGNING === "true",
|
||||
allowedBots: process.env.ALLOWED_BOTS ?? "",
|
||||
},
|
||||
};
|
||||
|
||||
@@ -192,6 +214,66 @@ export function parseGitHubContext(): GitHubContext {
|
||||
isPR: true,
|
||||
};
|
||||
}
|
||||
case "repository_dispatch": {
|
||||
const payload = context.payload as RepositoryDispatchEvent;
|
||||
// Extract task description from client_payload
|
||||
const clientPayload = payload.client_payload as {
|
||||
prompt?: string;
|
||||
stream_endpoint?: string;
|
||||
headers?: Record<string, string>;
|
||||
resume_endpoint?: string;
|
||||
session_id?: string;
|
||||
endpoints?: {
|
||||
resume?: string;
|
||||
progress?: string;
|
||||
system_progress?: string;
|
||||
oauth_endpoint?: string;
|
||||
};
|
||||
overrideInputs?: {
|
||||
model?: string;
|
||||
base_branch?: string;
|
||||
};
|
||||
};
|
||||
|
||||
// Override directPrompt with the prompt
|
||||
if (clientPayload.prompt) {
|
||||
commonFields.inputs.directPrompt = clientPayload.prompt;
|
||||
}
|
||||
|
||||
// Apply input overrides
|
||||
if (clientPayload.overrideInputs) {
|
||||
if (clientPayload.overrideInputs.base_branch) {
|
||||
commonFields.inputs.baseBranch =
|
||||
clientPayload.overrideInputs.base_branch;
|
||||
}
|
||||
}
|
||||
|
||||
// Set up progress tracking - prioritize endpoints object if available, fallback to individual fields
|
||||
let progressTracking: ParsedGitHubContext["progressTracking"] = undefined;
|
||||
|
||||
if (clientPayload.endpoints?.progress || clientPayload.stream_endpoint) {
|
||||
progressTracking = {
|
||||
progressEndpoint:
|
||||
clientPayload.endpoints?.progress ||
|
||||
clientPayload.stream_endpoint ||
|
||||
"",
|
||||
headers: clientPayload.headers,
|
||||
resumeEndpoint:
|
||||
// clientPayload.endpoints?.resume || clientPayload.resume_endpoint,
|
||||
clientPayload.resume_endpoint,
|
||||
sessionId: clientPayload.session_id,
|
||||
systemProgressEndpoint: clientPayload.endpoints?.system_progress,
|
||||
oauthTokenEndpoint: clientPayload.endpoints?.oauth_endpoint,
|
||||
};
|
||||
}
|
||||
|
||||
return {
|
||||
...commonFields,
|
||||
eventName: "repository_dispatch",
|
||||
payload: payload,
|
||||
progressTracking,
|
||||
};
|
||||
}
|
||||
case "workflow_dispatch": {
|
||||
return {
|
||||
...commonFields,
|
||||
@@ -289,3 +371,9 @@ export function isAutomationContext(
|
||||
context.eventName as AutomationEventName,
|
||||
);
|
||||
}
|
||||
|
||||
export function isRepositoryDispatchEvent(
|
||||
context: GitHubContext,
|
||||
): context is GitHubContext & { payload: RepositoryDispatchEvent } {
|
||||
return context.eventName === "repository_dispatch";
|
||||
}
|
||||
|
||||
@@ -8,7 +8,7 @@
|
||||
|
||||
import { $ } from "bun";
|
||||
import * as core from "@actions/core";
|
||||
import type { ParsedGitHubContext } from "../context";
|
||||
import type { GitHubContext } from "../context";
|
||||
import type { GitHubPullRequest } from "../types";
|
||||
import type { Octokits } from "../api/client";
|
||||
import type { FetchDataResult } from "../data/fetcher";
|
||||
@@ -21,15 +21,15 @@ export type BranchInfo = {
|
||||
|
||||
export async function setupBranch(
|
||||
octokits: Octokits,
|
||||
githubData: FetchDataResult,
|
||||
context: ParsedGitHubContext,
|
||||
githubData: FetchDataResult | null,
|
||||
context: GitHubContext,
|
||||
): Promise<BranchInfo> {
|
||||
const { owner, repo } = context.repository;
|
||||
const entityNumber = context.entityNumber;
|
||||
const { baseBranch, branchPrefix } = context.inputs;
|
||||
const isPR = context.isPR;
|
||||
|
||||
if (isPR) {
|
||||
if (isPR && githubData) {
|
||||
const prData = githubData.contextData as GitHubPullRequest;
|
||||
const prState = prData.state;
|
||||
|
||||
@@ -84,19 +84,27 @@ export async function setupBranch(
|
||||
sourceBranch = repoResponse.data.default_branch;
|
||||
}
|
||||
|
||||
// Generate branch name for either an issue or closed/merged PR
|
||||
const entityType = isPR ? "pr" : "issue";
|
||||
// Generate branch name for either an issue, closed/merged PR, or repository_dispatch event
|
||||
let branchName: string;
|
||||
|
||||
// Create Kubernetes-compatible timestamp: lowercase, hyphens only, shorter format
|
||||
const now = new Date();
|
||||
const 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")}`;
|
||||
if (context.eventName === "repository_dispatch") {
|
||||
// For repository_dispatch events, use run ID for uniqueness since there's no entity number
|
||||
const now = new Date();
|
||||
const 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")}`;
|
||||
branchName = `${branchPrefix}dispatch-${context.runId}-${timestamp}`;
|
||||
} else {
|
||||
// For issues and PRs, use the existing logic
|
||||
const entityType = isPR ? "pr" : "issue";
|
||||
const now = new Date();
|
||||
const 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")}`;
|
||||
branchName = `${branchPrefix}${entityType}-${entityNumber}-${timestamp}`;
|
||||
}
|
||||
|
||||
// Ensure branch name is Kubernetes-compatible:
|
||||
// - Lowercase only
|
||||
// - Alphanumeric with hyphens
|
||||
// - No underscores
|
||||
// - Max 50 chars (to allow for prefixes)
|
||||
const branchName = `${branchPrefix}${entityType}-${entityNumber}-${timestamp}`;
|
||||
const newBranch = branchName.toLowerCase().substring(0, 50);
|
||||
|
||||
try {
|
||||
@@ -132,8 +140,18 @@ export async function setupBranch(
|
||||
}
|
||||
|
||||
// For non-signing case, create and checkout the branch locally only
|
||||
const entityType =
|
||||
context.eventName === "repository_dispatch"
|
||||
? "dispatch"
|
||||
: isPR
|
||||
? "pr"
|
||||
: "issue";
|
||||
const entityId =
|
||||
context.eventName === "repository_dispatch"
|
||||
? context.runId
|
||||
: entityNumber!.toString();
|
||||
console.log(
|
||||
`Creating local branch ${newBranch} for ${entityType} #${entityNumber} from source branch: ${sourceBranch}...`,
|
||||
`Creating local branch ${newBranch} for ${entityType} ${entityId} from source branch: ${sourceBranch}...`,
|
||||
);
|
||||
|
||||
// Fetch and checkout the source branch first to ensure we branch from the correct base
|
||||
|
||||
@@ -6,7 +6,7 @@
|
||||
*/
|
||||
|
||||
import { $ } from "bun";
|
||||
import type { ParsedGitHubContext } from "../context";
|
||||
import type { GitHubContext } from "../context";
|
||||
import { GITHUB_SERVER_URL } from "../api/config";
|
||||
|
||||
type GitUser = {
|
||||
@@ -16,7 +16,7 @@ type GitUser = {
|
||||
|
||||
export async function configureGitAuth(
|
||||
githubToken: string,
|
||||
context: ParsedGitHubContext,
|
||||
context: GitHubContext,
|
||||
user: GitUser | null,
|
||||
) {
|
||||
console.log("Configuring git authentication for non-signing mode");
|
||||
|
||||
@@ -78,7 +78,7 @@ export async function setupGitHubToken(): Promise<string> {
|
||||
return appToken;
|
||||
} catch (error) {
|
||||
core.setFailed(
|
||||
`Failed to setup GitHub token: ${error}\n\nIf you instead wish to use this action with a custom GitHub token or custom GitHub app, provide a \`github_token\` in the \`uses\` section of the app in your workflow yml file.`,
|
||||
`Failed to setup GitHub token: ${error}.\n\nIf you instead wish to use this action with a custom GitHub token or custom GitHub app, provide a \`github_token\` in the \`uses\` section of the app in your workflow yml file.`,
|
||||
);
|
||||
process.exit(1);
|
||||
}
|
||||
|
||||
@@ -3,17 +3,11 @@ import path from "path";
|
||||
import type { Octokits } from "../api/client";
|
||||
import { GITHUB_SERVER_URL } from "../api/config";
|
||||
|
||||
const escapedUrl = GITHUB_SERVER_URL.replace(/[.*+?^${}()|[\]\\]/g, "\\$&");
|
||||
const IMAGE_REGEX = new RegExp(
|
||||
`!\\[[^\\]]*\\]\\((${escapedUrl}\\/user-attachments\\/assets\\/[^)]+)\\)`,
|
||||
`!\\[[^\\]]*\\]\\((${GITHUB_SERVER_URL.replace(/[.*+?^${}()|[\]\\]/g, "\\$&")}\\/user-attachments\\/assets\\/[^)]+)\\)`,
|
||||
"g",
|
||||
);
|
||||
|
||||
const HTML_IMG_REGEX = new RegExp(
|
||||
`<img[^>]+src=["']([^"']*${escapedUrl}\\/user-attachments\\/assets\\/[^"']+)["'][^>]*>`,
|
||||
"gi",
|
||||
);
|
||||
|
||||
type IssueComment = {
|
||||
type: "issue_comment";
|
||||
id: string;
|
||||
@@ -69,16 +63,8 @@ export async function downloadCommentImages(
|
||||
}> = [];
|
||||
|
||||
for (const comment of comments) {
|
||||
// Extract URLs from Markdown format
|
||||
const markdownMatches = [...comment.body.matchAll(IMAGE_REGEX)];
|
||||
const markdownUrls = markdownMatches.map((match) => match[1] as string);
|
||||
|
||||
// Extract URLs from HTML format
|
||||
const htmlMatches = [...comment.body.matchAll(HTML_IMG_REGEX)];
|
||||
const htmlUrls = htmlMatches.map((match) => match[1] as string);
|
||||
|
||||
// Combine and deduplicate URLs
|
||||
const urls = [...new Set([...markdownUrls, ...htmlUrls])];
|
||||
const imageMatches = [...comment.body.matchAll(IMAGE_REGEX)];
|
||||
const urls = imageMatches.map((match) => match[1] as string);
|
||||
|
||||
if (urls.length > 0) {
|
||||
commentsWithImages.push({ comment, urls });
|
||||
|
||||
@@ -58,41 +58,6 @@ export function sanitizeContent(content: string): string {
|
||||
content = stripMarkdownLinkTitles(content);
|
||||
content = stripHiddenAttributes(content);
|
||||
content = normalizeHtmlEntities(content);
|
||||
content = redactGitHubTokens(content);
|
||||
return content;
|
||||
}
|
||||
|
||||
export function redactGitHubTokens(content: string): string {
|
||||
// GitHub Personal Access Tokens (classic): ghp_XXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXX (40 chars)
|
||||
content = content.replace(
|
||||
/\bghp_[A-Za-z0-9]{36}\b/g,
|
||||
"[REDACTED_GITHUB_TOKEN]",
|
||||
);
|
||||
|
||||
// GitHub OAuth tokens: gho_XXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXX (40 chars)
|
||||
content = content.replace(
|
||||
/\bgho_[A-Za-z0-9]{36}\b/g,
|
||||
"[REDACTED_GITHUB_TOKEN]",
|
||||
);
|
||||
|
||||
// GitHub installation tokens: ghs_XXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXX (40 chars)
|
||||
content = content.replace(
|
||||
/\bghs_[A-Za-z0-9]{36}\b/g,
|
||||
"[REDACTED_GITHUB_TOKEN]",
|
||||
);
|
||||
|
||||
// GitHub refresh tokens: ghr_XXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXX (40 chars)
|
||||
content = content.replace(
|
||||
/\bghr_[A-Za-z0-9]{36}\b/g,
|
||||
"[REDACTED_GITHUB_TOKEN]",
|
||||
);
|
||||
|
||||
// GitHub fine-grained personal access tokens: github_pat_XXXXXXXXXX (up to 255 chars)
|
||||
content = content.replace(
|
||||
/\bgithub_pat_[A-Za-z0-9_]{11,221}\b/g,
|
||||
"[REDACTED_GITHUB_TOKEN]",
|
||||
);
|
||||
|
||||
return content;
|
||||
}
|
||||
|
||||
|
||||
@@ -21,42 +21,9 @@ export async function checkHumanActor(
|
||||
|
||||
console.log(`Actor type: ${actorType}`);
|
||||
|
||||
// Check bot permissions if actor is not a User
|
||||
if (actorType !== "User") {
|
||||
const allowedBots = githubContext.inputs.allowedBots;
|
||||
|
||||
// Check if all bots are allowed
|
||||
if (allowedBots.trim() === "*") {
|
||||
console.log(
|
||||
`All bots are allowed, skipping human actor check for: ${githubContext.actor}`,
|
||||
);
|
||||
return;
|
||||
}
|
||||
|
||||
// Parse allowed bots list
|
||||
const allowedBotsList = allowedBots
|
||||
.split(",")
|
||||
.map((bot) =>
|
||||
bot
|
||||
.trim()
|
||||
.toLowerCase()
|
||||
.replace(/\[bot\]$/, ""),
|
||||
)
|
||||
.filter((bot) => bot.length > 0);
|
||||
|
||||
const botName = githubContext.actor.toLowerCase().replace(/\[bot\]$/, "");
|
||||
|
||||
// Check if specific bot is allowed
|
||||
if (allowedBotsList.includes(botName)) {
|
||||
console.log(
|
||||
`Bot ${botName} is in allowed list, skipping human actor check`,
|
||||
);
|
||||
return;
|
||||
}
|
||||
|
||||
// Bot not allowed
|
||||
throw new Error(
|
||||
`Workflow initiated by non-human actor: ${botName} (type: ${actorType}). Add bot to allowed_bots list or use '*' to allow all bots.`,
|
||||
`Workflow initiated by non-human actor: ${githubContext.actor} (type: ${actorType}).`,
|
||||
);
|
||||
}
|
||||
|
||||
|
||||
@@ -17,12 +17,6 @@ export async function checkWritePermissions(
|
||||
try {
|
||||
core.info(`Checking permissions for actor: ${actor}`);
|
||||
|
||||
// Check if the actor is a GitHub App (bot user)
|
||||
if (actor.endsWith("[bot]")) {
|
||||
core.info(`Actor is a GitHub App: ${actor}`);
|
||||
return true;
|
||||
}
|
||||
|
||||
// Check permissions directly using the permission endpoint
|
||||
const response = await octokit.repos.getCollaboratorPermissionLevel({
|
||||
owner: repository.owner,
|
||||
|
||||
@@ -6,7 +6,6 @@ import { z } from "zod";
|
||||
import { GITHUB_API_URL } from "../github/api/config";
|
||||
import { Octokit } from "@octokit/rest";
|
||||
import { updateClaudeComment } from "../github/operations/comments/update-claude-comment";
|
||||
import { sanitizeContent } from "../github/utils/sanitizer";
|
||||
|
||||
// Get repository information from environment variables
|
||||
const REPO_OWNER = process.env.REPO_OWNER;
|
||||
@@ -55,13 +54,11 @@ server.tool(
|
||||
const isPullRequestReviewComment =
|
||||
eventName === "pull_request_review_comment";
|
||||
|
||||
const sanitizedBody = sanitizeContent(body);
|
||||
|
||||
const result = await updateClaudeComment(octokit, {
|
||||
owner,
|
||||
repo,
|
||||
commentId,
|
||||
body: sanitizedBody,
|
||||
body,
|
||||
isPullRequestReviewComment,
|
||||
});
|
||||
|
||||
|
||||
@@ -1,184 +0,0 @@
|
||||
#!/usr/bin/env node
|
||||
import { McpServer } from "@modelcontextprotocol/sdk/server/mcp.js";
|
||||
import { StdioServerTransport } from "@modelcontextprotocol/sdk/server/stdio.js";
|
||||
import { z } from "zod";
|
||||
import { createOctokit } from "../github/api/client";
|
||||
import { sanitizeContent } from "../github/utils/sanitizer";
|
||||
|
||||
// Get repository and PR information from environment variables
|
||||
const REPO_OWNER = process.env.REPO_OWNER;
|
||||
const REPO_NAME = process.env.REPO_NAME;
|
||||
const PR_NUMBER = process.env.PR_NUMBER;
|
||||
|
||||
if (!REPO_OWNER || !REPO_NAME || !PR_NUMBER) {
|
||||
console.error(
|
||||
"Error: REPO_OWNER, REPO_NAME, and PR_NUMBER environment variables are required",
|
||||
);
|
||||
process.exit(1);
|
||||
}
|
||||
|
||||
// GitHub Inline Comment MCP Server - Provides inline PR comment functionality
|
||||
// Provides an inline comment tool without exposing full PR review capabilities, so that
|
||||
// Claude can't accidentally approve a PR
|
||||
const server = new McpServer({
|
||||
name: "GitHub Inline Comment Server",
|
||||
version: "0.0.1",
|
||||
});
|
||||
|
||||
server.tool(
|
||||
"create_inline_comment",
|
||||
"Create an inline comment on a specific line or lines in a PR file",
|
||||
{
|
||||
path: z
|
||||
.string()
|
||||
.describe("The file path to comment on (e.g., 'src/index.js')"),
|
||||
body: z
|
||||
.string()
|
||||
.describe(
|
||||
"The comment text (supports markdown and GitHub code suggestion blocks). " +
|
||||
"For code suggestions, use: ```suggestion\\nreplacement code\\n```. " +
|
||||
"IMPORTANT: The suggestion block will REPLACE the ENTIRE line range (single line or startLine to line). " +
|
||||
"Ensure the replacement is syntactically complete and valid - it must work as a drop-in replacement for the selected lines.",
|
||||
),
|
||||
line: z
|
||||
.number()
|
||||
.nonnegative()
|
||||
.optional()
|
||||
.describe(
|
||||
"Line number for single-line comments (required if startLine is not provided)",
|
||||
),
|
||||
startLine: z
|
||||
.number()
|
||||
.nonnegative()
|
||||
.optional()
|
||||
.describe(
|
||||
"Start line for multi-line comments (use with line parameter for the end line)",
|
||||
),
|
||||
side: z
|
||||
.enum(["LEFT", "RIGHT"])
|
||||
.optional()
|
||||
.default("RIGHT")
|
||||
.describe(
|
||||
"Side of the diff to comment on: LEFT (old code) or RIGHT (new code)",
|
||||
),
|
||||
commit_id: z
|
||||
.string()
|
||||
.optional()
|
||||
.describe(
|
||||
"Specific commit SHA to comment on (defaults to latest commit)",
|
||||
),
|
||||
},
|
||||
async ({ path, body, line, startLine, side, commit_id }) => {
|
||||
try {
|
||||
const githubToken = process.env.GITHUB_TOKEN;
|
||||
|
||||
if (!githubToken) {
|
||||
throw new Error("GITHUB_TOKEN environment variable is required");
|
||||
}
|
||||
|
||||
const owner = REPO_OWNER;
|
||||
const repo = REPO_NAME;
|
||||
const pull_number = parseInt(PR_NUMBER, 10);
|
||||
|
||||
const octokit = createOctokit(githubToken).rest;
|
||||
|
||||
// Sanitize the comment body to remove any potential GitHub tokens
|
||||
const sanitizedBody = sanitizeContent(body);
|
||||
|
||||
// Validate that either line or both startLine and line are provided
|
||||
if (!line && !startLine) {
|
||||
throw new Error(
|
||||
"Either 'line' for single-line comments or both 'startLine' and 'line' for multi-line comments must be provided",
|
||||
);
|
||||
}
|
||||
|
||||
// If only line is provided, it's a single-line comment
|
||||
// If both startLine and line are provided, it's a multi-line comment
|
||||
const isSingleLine = !startLine;
|
||||
|
||||
const pr = await octokit.pulls.get({
|
||||
owner,
|
||||
repo,
|
||||
pull_number,
|
||||
});
|
||||
|
||||
const params: Parameters<
|
||||
typeof octokit.rest.pulls.createReviewComment
|
||||
>[0] = {
|
||||
owner,
|
||||
repo,
|
||||
pull_number,
|
||||
body: sanitizedBody,
|
||||
path,
|
||||
side: side || "RIGHT",
|
||||
commit_id: commit_id || pr.data.head.sha,
|
||||
};
|
||||
|
||||
if (isSingleLine) {
|
||||
// Single-line comment
|
||||
params.line = line;
|
||||
} else {
|
||||
// Multi-line comment
|
||||
params.start_line = startLine;
|
||||
params.start_side = side || "RIGHT";
|
||||
params.line = line;
|
||||
}
|
||||
|
||||
const result = await octokit.rest.pulls.createReviewComment(params);
|
||||
|
||||
return {
|
||||
content: [
|
||||
{
|
||||
type: "text",
|
||||
text: JSON.stringify(
|
||||
{
|
||||
success: true,
|
||||
comment_id: result.data.id,
|
||||
html_url: result.data.html_url,
|
||||
path: result.data.path,
|
||||
line: result.data.line || result.data.original_line,
|
||||
message: `Inline comment created successfully on ${path}${isSingleLine ? ` at line ${line}` : ` from line ${startLine} to ${line}`}`,
|
||||
},
|
||||
null,
|
||||
2,
|
||||
),
|
||||
},
|
||||
],
|
||||
};
|
||||
} catch (error) {
|
||||
const errorMessage =
|
||||
error instanceof Error ? error.message : String(error);
|
||||
|
||||
// Provide more helpful error messages for common issues
|
||||
let helpMessage = "";
|
||||
if (errorMessage.includes("Validation Failed")) {
|
||||
helpMessage =
|
||||
"\n\nThis usually means the line number doesn't exist in the diff or the file path is incorrect. Make sure you're commenting on lines that are part of the PR's changes.";
|
||||
} else if (errorMessage.includes("Not Found")) {
|
||||
helpMessage =
|
||||
"\n\nThis usually means the PR number, repository, or file path is incorrect.";
|
||||
}
|
||||
|
||||
return {
|
||||
content: [
|
||||
{
|
||||
type: "text",
|
||||
text: `Error creating inline comment: ${errorMessage}${helpMessage}`,
|
||||
},
|
||||
],
|
||||
error: errorMessage,
|
||||
isError: true,
|
||||
};
|
||||
}
|
||||
},
|
||||
);
|
||||
|
||||
async function runServer() {
|
||||
const transport = new StdioServerTransport();
|
||||
await server.connect(transport);
|
||||
process.on("exit", () => {
|
||||
server.close();
|
||||
});
|
||||
}
|
||||
|
||||
runServer().catch(console.error);
|
||||
@@ -1,6 +1,6 @@
|
||||
import * as core from "@actions/core";
|
||||
import { GITHUB_API_URL, GITHUB_SERVER_URL } from "../github/api/config";
|
||||
import type { ParsedGitHubContext } from "../github/context";
|
||||
import type { GitHubContext } from "../github/context";
|
||||
import { Octokit } from "@octokit/rest";
|
||||
|
||||
type PrepareConfigParams = {
|
||||
@@ -12,7 +12,7 @@ type PrepareConfigParams = {
|
||||
additionalMcpConfig?: string;
|
||||
claudeCommentId?: string;
|
||||
allowedTools: string[];
|
||||
context: ParsedGitHubContext;
|
||||
context: GitHubContext;
|
||||
};
|
||||
|
||||
async function checkActionsReadPermission(
|
||||
@@ -73,21 +73,23 @@ export async function prepareMcpConfig(
|
||||
};
|
||||
|
||||
// Always include comment server for updating Claude comments
|
||||
baseMcpConfig.mcpServers.github_comment = {
|
||||
command: "bun",
|
||||
args: [
|
||||
"run",
|
||||
`${process.env.GITHUB_ACTION_PATH}/src/mcp/github-comment-server.ts`,
|
||||
],
|
||||
env: {
|
||||
GITHUB_TOKEN: githubToken,
|
||||
REPO_OWNER: owner,
|
||||
REPO_NAME: repo,
|
||||
...(claudeCommentId && { CLAUDE_COMMENT_ID: claudeCommentId }),
|
||||
GITHUB_EVENT_NAME: process.env.GITHUB_EVENT_NAME || "",
|
||||
GITHUB_API_URL: GITHUB_API_URL,
|
||||
},
|
||||
};
|
||||
if (context.inputs.mode === "tag") {
|
||||
baseMcpConfig.mcpServers.github_comment = {
|
||||
command: "bun",
|
||||
args: [
|
||||
"run",
|
||||
`${process.env.GITHUB_ACTION_PATH}/src/mcp/github-comment-server.ts`,
|
||||
],
|
||||
env: {
|
||||
GITHUB_TOKEN: githubToken,
|
||||
REPO_OWNER: owner,
|
||||
REPO_NAME: repo,
|
||||
...(claudeCommentId && { CLAUDE_COMMENT_ID: claudeCommentId }),
|
||||
GITHUB_EVENT_NAME: process.env.GITHUB_EVENT_NAME || "",
|
||||
GITHUB_API_URL: GITHUB_API_URL,
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
// Include file ops server when commit signing is enabled
|
||||
if (context.inputs.useCommitSigning) {
|
||||
@@ -111,24 +113,6 @@ export async function prepareMcpConfig(
|
||||
};
|
||||
}
|
||||
|
||||
// Include inline comment server for experimental review mode
|
||||
if (context.inputs.mode === "experimental-review" && context.isPR) {
|
||||
baseMcpConfig.mcpServers.github_inline_comment = {
|
||||
command: "bun",
|
||||
args: [
|
||||
"run",
|
||||
`${process.env.GITHUB_ACTION_PATH}/src/mcp/github-inline-comment-server.ts`,
|
||||
],
|
||||
env: {
|
||||
GITHUB_TOKEN: githubToken,
|
||||
REPO_OWNER: owner,
|
||||
REPO_NAME: repo,
|
||||
PR_NUMBER: context.entityNumber?.toString() || "",
|
||||
GITHUB_API_URL: GITHUB_API_URL,
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
// Only add CI server if we have actions:read permission and we're in a PR context
|
||||
const hasActionsReadPermission =
|
||||
context.inputs.additionalPermissions.get("actions") === "read";
|
||||
|
||||
@@ -1,5 +1,4 @@
|
||||
import * as core from "@actions/core";
|
||||
import { mkdir, writeFile } from "fs/promises";
|
||||
import type { Mode, ModeOptions, ModeResult } from "../types";
|
||||
import { isAutomationContext } from "../../github/context";
|
||||
import type { PreparedContext } from "../../create-prompt/types";
|
||||
@@ -43,23 +42,7 @@ export const agentMode: Mode = {
|
||||
async prepare({ context }: ModeOptions): Promise<ModeResult> {
|
||||
// Agent mode handles automation events (workflow_dispatch, schedule) only
|
||||
|
||||
// TODO: handle by createPrompt (similar to tag and review modes)
|
||||
// Create prompt directory
|
||||
await mkdir(`${process.env.RUNNER_TEMP}/claude-prompts`, {
|
||||
recursive: true,
|
||||
});
|
||||
// Write the prompt file - the base action requires a prompt_file parameter,
|
||||
// so we must create this file even though agent mode typically uses
|
||||
// override_prompt or direct_prompt. If neither is provided, we write
|
||||
// a minimal prompt with just the repository information.
|
||||
const promptContent =
|
||||
context.inputs.overridePrompt ||
|
||||
context.inputs.directPrompt ||
|
||||
`Repository: ${context.repository.owner}/${context.repository.repo}`;
|
||||
await writeFile(
|
||||
`${process.env.RUNNER_TEMP}/claude-prompts/claude-prompt.txt`,
|
||||
promptContent,
|
||||
);
|
||||
// Agent mode doesn't need to create prompt files here - handled by createPrompt
|
||||
|
||||
// Export tool environment variables for agent mode
|
||||
const baseTools = [
|
||||
@@ -80,8 +63,9 @@ export const agentMode: Mode = {
|
||||
...context.inputs.disallowedTools,
|
||||
];
|
||||
|
||||
core.exportVariable("ALLOWED_TOOLS", allowedTools.join(","));
|
||||
core.exportVariable("DISALLOWED_TOOLS", disallowedTools.join(","));
|
||||
// Export as INPUT_ prefixed variables for the base action
|
||||
core.exportVariable("INPUT_ALLOWED_TOOLS", allowedTools.join(","));
|
||||
core.exportVariable("INPUT_DISALLOWED_TOOLS", disallowedTools.join(","));
|
||||
|
||||
// Agent mode uses a minimal MCP configuration
|
||||
// We don't need comment servers or PR-specific tools for automation
|
||||
|
||||
@@ -16,9 +16,15 @@ import { agentMode } from "./agent";
|
||||
import { reviewMode } from "./review";
|
||||
import type { GitHubContext } from "../github/context";
|
||||
import { isAutomationContext } from "../github/context";
|
||||
import { remoteAgentMode } from "./remote-agent";
|
||||
|
||||
export const DEFAULT_MODE = "tag" as const;
|
||||
export const VALID_MODES = ["tag", "agent", "experimental-review"] as const;
|
||||
export const VALID_MODES = [
|
||||
"tag",
|
||||
"agent",
|
||||
"remote-agent",
|
||||
"experimental-review",
|
||||
] as const;
|
||||
|
||||
/**
|
||||
* All available modes.
|
||||
@@ -28,6 +34,7 @@ const modes = {
|
||||
tag: tagMode,
|
||||
agent: agentMode,
|
||||
"experimental-review": reviewMode,
|
||||
"remote-agent": remoteAgentMode,
|
||||
} as const satisfies Record<ModeName, Mode>;
|
||||
|
||||
/**
|
||||
@@ -49,7 +56,13 @@ export function getMode(name: ModeName, context: GitHubContext): Mode {
|
||||
// Validate mode can handle the event type
|
||||
if (name === "tag" && isAutomationContext(context)) {
|
||||
throw new Error(
|
||||
`Tag mode cannot handle ${context.eventName} events. Use 'agent' mode for automation events.`,
|
||||
`Tag mode cannot handle ${context.eventName} events. Use 'agent' mode for automation events or 'remote-agent' mode for repository_dispatch events.`,
|
||||
);
|
||||
}
|
||||
|
||||
if (name === "remote-agent" && context.eventName !== "repository_dispatch") {
|
||||
throw new Error(
|
||||
`Remote agent mode can only handle repository_dispatch events. Use 'tag' mode for @claude mentions or 'agent' mode for other automation events.`,
|
||||
);
|
||||
}
|
||||
|
||||
|
||||
468
src/modes/remote-agent/index.ts
Normal file
468
src/modes/remote-agent/index.ts
Normal file
@@ -0,0 +1,468 @@
|
||||
import * as core from "@actions/core";
|
||||
import { mkdir, writeFile } from "fs/promises";
|
||||
import type { Mode, ModeOptions, ModeResult } from "../types";
|
||||
import { isRepositoryDispatchEvent } from "../../github/context";
|
||||
import type { GitHubContext } from "../../github/context";
|
||||
import { setupBranch } from "../../github/operations/branch";
|
||||
import { configureGitAuth } from "../../github/operations/git-config";
|
||||
import { prepareMcpConfig } from "../../mcp/install-mcp-server";
|
||||
import { GITHUB_SERVER_URL } from "../../github/api/config";
|
||||
import {
|
||||
buildAllowedToolsString,
|
||||
buildDisallowedToolsString,
|
||||
type PreparedContext,
|
||||
} from "../../create-prompt";
|
||||
import {
|
||||
reportWorkflowInitialized,
|
||||
reportClaudeStarting,
|
||||
reportWorkflowFailed,
|
||||
} from "./system-progress-handler";
|
||||
import type { SystemProgressConfig } from "./progress-types";
|
||||
import { fetchUserDisplayName } from "../../github/data/fetcher";
|
||||
import { createOctokit } from "../../github/api/client";
|
||||
import type { StreamConfig } from "../../types/stream-config";
|
||||
|
||||
/**
|
||||
* Fetches a Claude Code OAuth token from the specified endpoint using OIDC authentication
|
||||
*/
|
||||
async function fetchClaudeCodeOAuthToken(
|
||||
oauthTokenEndpoint: string,
|
||||
oidcToken?: string,
|
||||
sessionId?: string,
|
||||
): Promise<string> {
|
||||
console.log(`Fetching Claude Code OAuth token from: ${oauthTokenEndpoint}`);
|
||||
|
||||
try {
|
||||
if (!oidcToken) {
|
||||
throw new Error("OIDC token is required for OAuth authentication");
|
||||
}
|
||||
|
||||
// Make request to OAuth token endpoint
|
||||
const response = await fetch(oauthTokenEndpoint, {
|
||||
method: "POST",
|
||||
headers: {
|
||||
Authorization: `Bearer ${oidcToken}`,
|
||||
"Content-Type": "application/json",
|
||||
},
|
||||
body: JSON.stringify({
|
||||
...(sessionId && { session_id: sessionId }),
|
||||
}),
|
||||
});
|
||||
|
||||
if (!response.ok) {
|
||||
throw new Error(
|
||||
`OAuth token request failed: ${response.status} ${response.statusText}`,
|
||||
);
|
||||
}
|
||||
|
||||
const data = (await response.json()) as {
|
||||
oauth_token?: string;
|
||||
message?: string;
|
||||
};
|
||||
|
||||
if (!data.oauth_token) {
|
||||
const message = data.message || "Unknown error";
|
||||
throw new Error(`OAuth token request failed: ${message}`);
|
||||
}
|
||||
|
||||
console.log("Successfully fetched Claude Code OAuth token");
|
||||
return data.oauth_token;
|
||||
} catch (error) {
|
||||
console.error("Failed to fetch Claude Code OAuth token:", error);
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Remote Agent mode implementation.
|
||||
*
|
||||
* This mode is specifically designed for repository_dispatch events triggered by external APIs.
|
||||
* It bypasses the standard trigger checking, comment tracking, and GitHub data fetching used by tag mode,
|
||||
* making it ideal for automated tasks triggered via API calls with custom payloads.
|
||||
*/
|
||||
export const remoteAgentMode: Mode = {
|
||||
name: "remote-agent",
|
||||
description: "Remote automation mode for repository_dispatch events",
|
||||
|
||||
shouldTrigger(context) {
|
||||
// Only trigger for repository_dispatch events
|
||||
return isRepositoryDispatchEvent(context);
|
||||
},
|
||||
|
||||
prepareContext(context, data) {
|
||||
// Remote agent mode uses minimal context
|
||||
return {
|
||||
mode: "remote-agent",
|
||||
githubContext: context,
|
||||
baseBranch: data?.baseBranch,
|
||||
claudeBranch: data?.claudeBranch,
|
||||
};
|
||||
},
|
||||
|
||||
getAllowedTools() {
|
||||
return [];
|
||||
},
|
||||
|
||||
getDisallowedTools() {
|
||||
return [];
|
||||
},
|
||||
|
||||
shouldCreateTrackingComment() {
|
||||
return false;
|
||||
},
|
||||
|
||||
async prepare({
|
||||
context,
|
||||
octokit,
|
||||
githubToken,
|
||||
}: ModeOptions): Promise<ModeResult> {
|
||||
// Remote agent mode handles repository_dispatch events only
|
||||
|
||||
if (!isRepositoryDispatchEvent(context)) {
|
||||
throw new Error(
|
||||
"Remote agent mode can only handle repository_dispatch events",
|
||||
);
|
||||
}
|
||||
|
||||
// Extract task details from client_payload
|
||||
const payload = context.payload;
|
||||
const clientPayload = payload.client_payload as {
|
||||
prompt?: string;
|
||||
stream_endpoint?: string;
|
||||
headers?: Record<string, string>;
|
||||
resume_endpoint?: string;
|
||||
session_id?: string;
|
||||
endpoints?: {
|
||||
stream?: string;
|
||||
progress?: string;
|
||||
systemProgress?: string;
|
||||
oauthToken?: string;
|
||||
};
|
||||
overrideInputs?: {
|
||||
model?: string;
|
||||
base_branch?: string;
|
||||
};
|
||||
};
|
||||
|
||||
// Get OIDC token for streaming and potential OAuth token fetching
|
||||
let oidcToken: string;
|
||||
try {
|
||||
oidcToken = await core.getIDToken("claude-code-github-action");
|
||||
} catch (error) {
|
||||
console.error("Failed to get OIDC token:", error);
|
||||
throw new Error(
|
||||
`OIDC token required for remote-agent mode. Please add 'id-token: write' to your workflow permissions. Error: ${error}`,
|
||||
);
|
||||
}
|
||||
|
||||
// Set up system progress config if endpoint is provided
|
||||
let systemProgressConfig: SystemProgressConfig | null = null;
|
||||
if (context.progressTracking?.systemProgressEndpoint) {
|
||||
systemProgressConfig = {
|
||||
endpoint: context.progressTracking.systemProgressEndpoint,
|
||||
headers: context.progressTracking.headers,
|
||||
};
|
||||
}
|
||||
|
||||
// Handle authentication - fetch OAuth token if needed
|
||||
const anthropicApiKey = process.env.ANTHROPIC_API_KEY;
|
||||
const claudeCodeOAuthToken = process.env.CLAUDE_CODE_OAUTH_TOKEN;
|
||||
|
||||
if (!anthropicApiKey && !claudeCodeOAuthToken) {
|
||||
const oauthTokenEndpoint = context.progressTracking?.oauthTokenEndpoint;
|
||||
|
||||
if (oauthTokenEndpoint) {
|
||||
console.log(
|
||||
"No API key or OAuth token found, fetching OAuth token from endpoint",
|
||||
);
|
||||
try {
|
||||
const fetchedToken = await fetchClaudeCodeOAuthToken(
|
||||
oauthTokenEndpoint,
|
||||
oidcToken,
|
||||
context.progressTracking?.sessionId,
|
||||
);
|
||||
core.setOutput("claude_code_oauth_token", fetchedToken);
|
||||
console.log(
|
||||
"Successfully fetched and set OAuth token for Claude Code",
|
||||
);
|
||||
} catch (error) {
|
||||
console.error("Failed to fetch OAuth token:", error);
|
||||
throw new Error(
|
||||
`Authentication failed: No API key or OAuth token available, and OAuth token fetching failed: ${error}`,
|
||||
);
|
||||
}
|
||||
} else {
|
||||
throw new Error(
|
||||
"No authentication available: Missing ANTHROPIC_API_KEY, CLAUDE_CODE_OAUTH_TOKEN, and no OAuth token endpoint provided",
|
||||
);
|
||||
}
|
||||
} else {
|
||||
console.log("Using existing authentication (API key or OAuth token)");
|
||||
}
|
||||
|
||||
const taskDescription =
|
||||
clientPayload.prompt ||
|
||||
context.inputs.directPrompt ||
|
||||
"No task description provided";
|
||||
|
||||
// Setup branch for work isolation
|
||||
let branchInfo;
|
||||
try {
|
||||
branchInfo = await setupBranch(octokit, null, context);
|
||||
} catch (error) {
|
||||
// Report failure if we have system progress config
|
||||
if (systemProgressConfig) {
|
||||
reportWorkflowFailed(
|
||||
systemProgressConfig,
|
||||
oidcToken,
|
||||
"initialization",
|
||||
error as Error,
|
||||
"branch_setup_failed",
|
||||
);
|
||||
}
|
||||
throw error;
|
||||
}
|
||||
|
||||
// Configure git authentication if not using commit signing
|
||||
if (!context.inputs.useCommitSigning) {
|
||||
try {
|
||||
// Force Claude bot as git user
|
||||
await configureGitAuth(githubToken, context, {
|
||||
login: "claude[bot]",
|
||||
id: 209825114,
|
||||
});
|
||||
} catch (error) {
|
||||
console.error("Failed to configure git authentication:", error);
|
||||
// Report failure if we have system progress config
|
||||
if (systemProgressConfig) {
|
||||
reportWorkflowFailed(
|
||||
systemProgressConfig,
|
||||
oidcToken,
|
||||
"initialization",
|
||||
error as Error,
|
||||
"git_config_failed",
|
||||
);
|
||||
}
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
|
||||
// Report workflow initialized
|
||||
if (systemProgressConfig) {
|
||||
reportWorkflowInitialized(
|
||||
systemProgressConfig,
|
||||
oidcToken,
|
||||
branchInfo.claudeBranch || branchInfo.currentBranch,
|
||||
branchInfo.baseBranch,
|
||||
context.progressTracking?.sessionId,
|
||||
);
|
||||
}
|
||||
|
||||
// Create prompt directory
|
||||
await mkdir(`${process.env.RUNNER_TEMP}/claude-prompts`, {
|
||||
recursive: true,
|
||||
});
|
||||
|
||||
// Fetch trigger user display name from context.actor
|
||||
let triggerDisplayName: string | null | undefined;
|
||||
if (context.actor) {
|
||||
try {
|
||||
const octokits = createOctokit(githubToken);
|
||||
triggerDisplayName = await fetchUserDisplayName(
|
||||
octokits,
|
||||
context.actor,
|
||||
);
|
||||
} catch (error) {
|
||||
console.warn(
|
||||
`Failed to fetch user display name for ${context.actor}:`,
|
||||
error,
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
// Generate dispatch-specific prompt (just the task description)
|
||||
const promptContent = generateDispatchPrompt(taskDescription);
|
||||
|
||||
console.log("Writing prompt file...");
|
||||
console.log("Contents: ", promptContent);
|
||||
// Write the prompt file
|
||||
await writeFile(
|
||||
`${process.env.RUNNER_TEMP}/claude-prompts/claude-prompt.txt`,
|
||||
promptContent,
|
||||
);
|
||||
console.log(
|
||||
`Prompt file written successfully to ${process.env.RUNNER_TEMP}/claude-prompts/claude-prompt.txt`,
|
||||
);
|
||||
|
||||
// Set stream configuration for repository_dispatch events
|
||||
if (context.progressTracking) {
|
||||
const streamConfig: StreamConfig = {};
|
||||
|
||||
if (context.progressTracking.resumeEndpoint) {
|
||||
streamConfig.resume_endpoint = context.progressTracking.resumeEndpoint;
|
||||
}
|
||||
|
||||
if (context.progressTracking.sessionId) {
|
||||
streamConfig.session_id = context.progressTracking.sessionId;
|
||||
}
|
||||
|
||||
if (context.progressTracking.progressEndpoint) {
|
||||
streamConfig.progress_endpoint =
|
||||
context.progressTracking.progressEndpoint;
|
||||
}
|
||||
|
||||
if (context.progressTracking.systemProgressEndpoint) {
|
||||
streamConfig.system_progress_endpoint =
|
||||
context.progressTracking.systemProgressEndpoint;
|
||||
}
|
||||
|
||||
// Merge provided headers with OIDC token
|
||||
const headers: Record<string, string> = {
|
||||
...(context.progressTracking.headers || {}),
|
||||
};
|
||||
|
||||
// Use existing OIDC token for streaming
|
||||
headers["Authorization"] = `Bearer ${oidcToken}`;
|
||||
|
||||
if (Object.keys(headers).length > 0) {
|
||||
streamConfig.headers = headers;
|
||||
}
|
||||
|
||||
console.log("Setting stream config:", streamConfig);
|
||||
core.setOutput("stream_config", JSON.stringify(streamConfig));
|
||||
}
|
||||
|
||||
// Export tool environment variables for remote agent mode
|
||||
// Check if we have actions:read permission for CI tools
|
||||
const hasActionsReadPermission =
|
||||
context.inputs.additionalPermissions.get("actions") === "read";
|
||||
|
||||
const allowedToolsString = buildAllowedToolsString(
|
||||
context.inputs.allowedTools,
|
||||
hasActionsReadPermission,
|
||||
context.inputs.useCommitSigning,
|
||||
);
|
||||
const disallowedToolsString = buildDisallowedToolsString(
|
||||
context.inputs.disallowedTools,
|
||||
);
|
||||
|
||||
core.exportVariable("ALLOWED_TOOLS", allowedToolsString);
|
||||
core.exportVariable("DISALLOWED_TOOLS", disallowedToolsString);
|
||||
|
||||
// Handle model override from repository_dispatch payload
|
||||
if (clientPayload.overrideInputs?.model) {
|
||||
core.setOutput("anthropic_model", clientPayload.overrideInputs.model);
|
||||
}
|
||||
|
||||
// Get minimal MCP configuration for remote agent mode
|
||||
const additionalMcpConfig = process.env.MCP_CONFIG || "";
|
||||
const mcpConfig = await prepareMcpConfig({
|
||||
githubToken,
|
||||
owner: context.repository.owner,
|
||||
repo: context.repository.repo,
|
||||
branch: branchInfo.claudeBranch || branchInfo.currentBranch,
|
||||
baseBranch: branchInfo.baseBranch,
|
||||
additionalMcpConfig,
|
||||
claudeCommentId: "", // No comment ID for remote agent mode
|
||||
allowedTools: context.inputs.allowedTools,
|
||||
context,
|
||||
});
|
||||
|
||||
core.setOutput("mcp_config", mcpConfig);
|
||||
|
||||
// Report Claude is starting
|
||||
if (systemProgressConfig) {
|
||||
reportClaudeStarting(systemProgressConfig, oidcToken);
|
||||
}
|
||||
|
||||
// Track Claude start time for duration calculation
|
||||
core.setOutput("claude_start_time", Date.now().toString());
|
||||
|
||||
// Export system prompt for remote agent mode
|
||||
const systemPrompt = generateDispatchSystemPrompt(
|
||||
context,
|
||||
branchInfo.baseBranch,
|
||||
branchInfo.claudeBranch,
|
||||
context.actor,
|
||||
triggerDisplayName,
|
||||
);
|
||||
core.exportVariable("APPEND_SYSTEM_PROMPT", systemPrompt);
|
||||
|
||||
return {
|
||||
commentId: undefined, // No comment tracking for remote agent mode
|
||||
branchInfo,
|
||||
mcpConfig,
|
||||
};
|
||||
},
|
||||
|
||||
generatePrompt(context: PreparedContext): string {
|
||||
// TODO: update this to generate a more meaningful prompt
|
||||
return `Repository: ${context.repository}`;
|
||||
},
|
||||
};
|
||||
|
||||
/**
|
||||
* Generates a task-focused prompt for repository_dispatch events
|
||||
*/
|
||||
function generateDispatchPrompt(taskDescription: string): string {
|
||||
return taskDescription;
|
||||
}
|
||||
|
||||
/**
|
||||
* Generates the system prompt portion for repository_dispatch events
|
||||
*/
|
||||
function generateDispatchSystemPrompt(
|
||||
context: GitHubContext,
|
||||
baseBranch: string,
|
||||
claudeBranch: string | undefined,
|
||||
triggerUsername?: string,
|
||||
triggerDisplayName?: string | null,
|
||||
): string {
|
||||
const { repository } = context;
|
||||
|
||||
const coAuthorLine =
|
||||
triggerUsername && (triggerDisplayName || triggerUsername !== "Unknown")
|
||||
? `Co-authored-by: ${triggerDisplayName ?? triggerUsername} <${triggerUsername}@users.noreply.github.com>`
|
||||
: "";
|
||||
|
||||
let commitInstructions = "";
|
||||
if (context.inputs.useCommitSigning) {
|
||||
commitInstructions = `- Use mcp__github_file_ops__commit_files and mcp__github_file_ops__delete_files to commit and push changes`;
|
||||
if (coAuthorLine) {
|
||||
commitInstructions += `
|
||||
- When pushing changes, include a Co-authored-by trailer in the commit message
|
||||
- Use: "${coAuthorLine}"`;
|
||||
}
|
||||
} else {
|
||||
commitInstructions = `- Use git commands via the Bash tool to commit and push your changes:
|
||||
- Stage files: Bash(git add <files>)
|
||||
- Commit with a descriptive message: Bash(git commit -m "<message>")`;
|
||||
if (coAuthorLine) {
|
||||
commitInstructions += `
|
||||
- When committing, include a Co-authored-by trailer:
|
||||
Bash(git commit -m "<message>\\n\\n${coAuthorLine}")`;
|
||||
}
|
||||
commitInstructions += `
|
||||
- Be sure to follow your commit message guidelines
|
||||
- Push to the remote: Bash(git push origin HEAD)`;
|
||||
}
|
||||
|
||||
return `You are Claude, an AI assistant designed to help with GitHub issues and pull requests. Think carefully as you analyze the context and respond appropriately. Here's the context for your current task:
|
||||
|
||||
Your task is to complete the request described in the task description.
|
||||
|
||||
Instructions:
|
||||
1. For questions: Research the codebase and provide a detailed answer
|
||||
2. For implementations: Make the requested changes, commit, and push
|
||||
|
||||
Key points:
|
||||
- You're already on a new branch - NEVER create another branch (this is very important). ${claudeBranch} is the ONLY branch you should work on.
|
||||
${commitInstructions}
|
||||
${
|
||||
claudeBranch
|
||||
? `- After completing your work, provide a URL to create a PR in this format:
|
||||
|
||||
${GITHUB_SERVER_URL}/${repository.owner}/${repository.repo}/compare/${baseBranch}...${claudeBranch}?quick_pull=1`
|
||||
: ""
|
||||
}`;
|
||||
}
|
||||
78
src/modes/remote-agent/progress-types.ts
Normal file
78
src/modes/remote-agent/progress-types.ts
Normal file
@@ -0,0 +1,78 @@
|
||||
/**
|
||||
* System progress tracking types for remote agent mode
|
||||
*/
|
||||
|
||||
/**
|
||||
* Base event structure
|
||||
*/
|
||||
type BaseProgressEvent = {
|
||||
timestamp: string; // ISO 8601
|
||||
};
|
||||
|
||||
/**
|
||||
* Workflow initializing event
|
||||
*/
|
||||
export type WorkflowInitializingEvent = BaseProgressEvent & {
|
||||
event_type: "workflow_initializing";
|
||||
data: {
|
||||
branch: string;
|
||||
base_branch: string;
|
||||
session_id?: string;
|
||||
};
|
||||
};
|
||||
|
||||
/**
|
||||
* Claude starting event
|
||||
*/
|
||||
export type ClaudeStartingEvent = BaseProgressEvent & {
|
||||
event_type: "claude_starting";
|
||||
data: Record<string, never>; // No data needed
|
||||
};
|
||||
|
||||
/**
|
||||
* Claude complete event
|
||||
*/
|
||||
export type ClaudeCompleteEvent = BaseProgressEvent & {
|
||||
event_type: "claude_complete";
|
||||
data: {
|
||||
exit_code: number;
|
||||
duration_ms: number;
|
||||
};
|
||||
};
|
||||
|
||||
/**
|
||||
* Workflow failed event
|
||||
*/
|
||||
export type WorkflowFailedEvent = BaseProgressEvent & {
|
||||
event_type: "workflow_failed";
|
||||
data: {
|
||||
error: {
|
||||
phase: "initialization" | "claude_execution";
|
||||
message: string;
|
||||
code: string;
|
||||
};
|
||||
};
|
||||
};
|
||||
|
||||
/**
|
||||
* Discriminated union of all progress events
|
||||
*/
|
||||
export type ProgressEvent =
|
||||
| WorkflowInitializingEvent
|
||||
| ClaudeStartingEvent
|
||||
| ClaudeCompleteEvent
|
||||
| WorkflowFailedEvent;
|
||||
|
||||
/**
|
||||
* Payload sent to the system progress endpoint
|
||||
*/
|
||||
export type SystemProgressPayload = ProgressEvent;
|
||||
|
||||
/**
|
||||
* Configuration for system progress reporting
|
||||
*/
|
||||
export type SystemProgressConfig = {
|
||||
endpoint: string;
|
||||
headers?: Record<string, string>;
|
||||
timeout_ms?: number; // Default: 5000
|
||||
};
|
||||
149
src/modes/remote-agent/system-progress-handler.ts
Normal file
149
src/modes/remote-agent/system-progress-handler.ts
Normal file
@@ -0,0 +1,149 @@
|
||||
import * as core from "@actions/core";
|
||||
import type {
|
||||
ProgressEvent,
|
||||
SystemProgressPayload,
|
||||
SystemProgressConfig,
|
||||
WorkflowInitializingEvent,
|
||||
ClaudeStartingEvent,
|
||||
ClaudeCompleteEvent,
|
||||
WorkflowFailedEvent,
|
||||
} from "./progress-types";
|
||||
|
||||
/**
|
||||
* Send a progress event to the system progress endpoint (fire-and-forget)
|
||||
*/
|
||||
function sendProgressEvent(
|
||||
event: ProgressEvent,
|
||||
config: SystemProgressConfig,
|
||||
oidcToken: string,
|
||||
): void {
|
||||
const payload: SystemProgressPayload = event;
|
||||
|
||||
console.log(
|
||||
`Sending system progress event: ${event.event_type}`,
|
||||
JSON.stringify(payload, null, 2),
|
||||
);
|
||||
|
||||
// Fire and forget - don't await
|
||||
Promise.resolve().then(async () => {
|
||||
try {
|
||||
// Create an AbortController for timeout
|
||||
const controller = new AbortController();
|
||||
const timeoutId = setTimeout(
|
||||
() => controller.abort(),
|
||||
config.timeout_ms || 5000,
|
||||
);
|
||||
|
||||
try {
|
||||
const response = await fetch(config.endpoint, {
|
||||
method: "POST",
|
||||
headers: {
|
||||
"Content-Type": "application/json",
|
||||
Authorization: `Bearer ${oidcToken}`,
|
||||
...config.headers,
|
||||
},
|
||||
body: JSON.stringify(payload),
|
||||
signal: controller.signal,
|
||||
});
|
||||
|
||||
if (!response.ok) {
|
||||
console.error(
|
||||
`System progress endpoint returned ${response.status}: ${response.statusText}`,
|
||||
);
|
||||
}
|
||||
} finally {
|
||||
clearTimeout(timeoutId);
|
||||
}
|
||||
} catch (error) {
|
||||
// Log but don't throw - we don't want progress reporting to interrupt the workflow
|
||||
core.warning(`Failed to send system progress event: ${error}`);
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Report workflow initialization complete
|
||||
*/
|
||||
export function reportWorkflowInitialized(
|
||||
config: SystemProgressConfig,
|
||||
oidcToken: string,
|
||||
branch: string,
|
||||
baseBranch: string,
|
||||
sessionId?: string,
|
||||
): void {
|
||||
const event: WorkflowInitializingEvent = {
|
||||
timestamp: new Date().toISOString(),
|
||||
event_type: "workflow_initializing",
|
||||
data: {
|
||||
branch,
|
||||
base_branch: baseBranch,
|
||||
...(sessionId && { session_id: sessionId }),
|
||||
},
|
||||
};
|
||||
|
||||
sendProgressEvent(event, config, oidcToken);
|
||||
}
|
||||
|
||||
/**
|
||||
* Report Claude is starting
|
||||
*/
|
||||
export function reportClaudeStarting(
|
||||
config: SystemProgressConfig,
|
||||
oidcToken: string,
|
||||
): void {
|
||||
const event: ClaudeStartingEvent = {
|
||||
timestamp: new Date().toISOString(),
|
||||
event_type: "claude_starting",
|
||||
data: {},
|
||||
};
|
||||
|
||||
sendProgressEvent(event, config, oidcToken);
|
||||
}
|
||||
|
||||
/**
|
||||
* Report Claude completed
|
||||
*/
|
||||
export function reportClaudeComplete(
|
||||
config: SystemProgressConfig,
|
||||
oidcToken: string,
|
||||
exitCode: number,
|
||||
durationMs: number,
|
||||
): void {
|
||||
const event: ClaudeCompleteEvent = {
|
||||
timestamp: new Date().toISOString(),
|
||||
event_type: "claude_complete",
|
||||
data: {
|
||||
exit_code: exitCode,
|
||||
duration_ms: durationMs,
|
||||
},
|
||||
};
|
||||
|
||||
sendProgressEvent(event, config, oidcToken);
|
||||
}
|
||||
|
||||
/**
|
||||
* Report workflow failed
|
||||
*/
|
||||
export function reportWorkflowFailed(
|
||||
config: SystemProgressConfig,
|
||||
oidcToken: string,
|
||||
phase: "initialization" | "claude_execution",
|
||||
error: Error | string,
|
||||
code: string,
|
||||
): void {
|
||||
const errorMessage = error instanceof Error ? error.message : error;
|
||||
|
||||
const event: WorkflowFailedEvent = {
|
||||
timestamp: new Date().toISOString(),
|
||||
event_type: "workflow_failed",
|
||||
data: {
|
||||
error: {
|
||||
phase,
|
||||
message: errorMessage,
|
||||
code,
|
||||
},
|
||||
},
|
||||
};
|
||||
|
||||
sendProgressEvent(event, config, oidcToken);
|
||||
}
|
||||
@@ -60,8 +60,20 @@ export const reviewMode: Mode = {
|
||||
|
||||
getAllowedTools() {
|
||||
return [
|
||||
"Bash(gh issue comment:*)",
|
||||
"mcp__github_inline_comment__create_inline_comment",
|
||||
// Context tools - to know who the current user is
|
||||
"mcp__github__get_me",
|
||||
// Core review tools
|
||||
"mcp__github__create_pending_pull_request_review",
|
||||
"mcp__github__add_comment_to_pending_review",
|
||||
"mcp__github__submit_pending_pull_request_review",
|
||||
"mcp__github__delete_pending_pull_request_review",
|
||||
"mcp__github__create_and_submit_pull_request_review",
|
||||
// Comment tools
|
||||
"mcp__github__add_issue_comment",
|
||||
// PR information tools
|
||||
"mcp__github__get_pull_request",
|
||||
"mcp__github__get_pull_request_reviews",
|
||||
"mcp__github__get_pull_request_status",
|
||||
];
|
||||
},
|
||||
|
||||
@@ -103,9 +115,6 @@ export const reviewMode: Mode = {
|
||||
? formatBody(contextData.body, imageUrlMap)
|
||||
: "No description provided";
|
||||
|
||||
// Using a variable for code blocks to avoid escaping backticks in the template string
|
||||
const codeBlock = "```";
|
||||
|
||||
return `You are Claude, an AI assistant specialized in code reviews for GitHub pull requests. You are operating in REVIEW MODE, which means you should focus on providing thorough code review feedback using GitHub MCP tools for inline comments and suggestions.
|
||||
|
||||
<formatted_context>
|
||||
@@ -154,50 +163,68 @@ REVIEW MODE WORKFLOW:
|
||||
|
||||
1. First, understand the PR context:
|
||||
- You are reviewing PR #${eventData.isPR && eventData.prNumber ? eventData.prNumber : "[PR number]"} in ${context.repository}
|
||||
- Use mcp__github__get_pull_request to get PR metadata
|
||||
- Use the Read, Grep, and Glob tools to examine the modified files directly from disk
|
||||
- This provides the full context and latest state of the code
|
||||
- Look at the changed_files section above to see which files were modified
|
||||
|
||||
2. Create review comments using GitHub MCP tools:
|
||||
- Use Bash(gh issue comment:*) for general PR-level comments
|
||||
- Use mcp__github_inline_comment__create_inline_comment for line-specific feedback (strongly preferred)
|
||||
2. Create a pending review:
|
||||
- Use mcp__github__create_pending_pull_request_review to start your review
|
||||
- This allows you to batch comments before submitting
|
||||
|
||||
3. Add inline comments:
|
||||
- Use mcp__github__add_comment_to_pending_review for each issue or suggestion
|
||||
- Parameters:
|
||||
* path: The file path (e.g., "src/index.js")
|
||||
* line: Line number for single-line comments
|
||||
* startLine & line: For multi-line comments (startLine is the first line, line is the last)
|
||||
* side: "LEFT" (old code) or "RIGHT" (new code)
|
||||
* subjectType: "line" for line-level comments
|
||||
* body: Your comment text
|
||||
|
||||
3. When creating inline comments with suggestions:
|
||||
CRITICAL: GitHub's suggestion blocks REPLACE the ENTIRE line range you select
|
||||
- For single-line comments: Use 'line' parameter only
|
||||
- For multi-line comments: Use both 'startLine' and 'line' parameters
|
||||
- The 'body' parameter should contain your comment and/or suggestion block
|
||||
- When to use multi-line comments:
|
||||
* When replacing multiple consecutive lines
|
||||
* When the fix requires changes across several lines
|
||||
* Example: To replace lines 19-20, use startLine: 19, line: 20
|
||||
|
||||
How to write code suggestions correctly:
|
||||
a) To remove a line (e.g., removing console.log on line 22):
|
||||
- Set line: 22
|
||||
- Body: ${codeBlock}suggestion
|
||||
${codeBlock}
|
||||
(Empty suggestion block removes the line)
|
||||
- For code suggestions, use this EXACT format in the body:
|
||||
\`\`\`suggestion
|
||||
corrected code here
|
||||
\`\`\`
|
||||
|
||||
b) To modify a single line (e.g., fixing line 22):
|
||||
- Set line: 22
|
||||
- Body: ${codeBlock}suggestion
|
||||
await this.emailInput.fill(email);
|
||||
${codeBlock}
|
||||
CRITICAL: GitHub suggestion blocks must ONLY contain the replacement for the specific line(s) being commented on:
|
||||
- For single-line comments: Replace ONLY that line
|
||||
- For multi-line comments: Replace ONLY the lines in the range
|
||||
- Do NOT include surrounding context or function signatures
|
||||
- Do NOT suggest changes that span beyond the commented lines
|
||||
|
||||
c) To replace multiple lines (e.g., lines 21-23):
|
||||
- Set startLine: 21, line: 23
|
||||
- Body must include ALL lines being replaced:
|
||||
${codeBlock}suggestion
|
||||
async typeEmail(email: string): Promise<void> {
|
||||
await this.emailInput.fill(email);
|
||||
}
|
||||
${codeBlock}
|
||||
Example for line 19 \`var name = user.name;\`:
|
||||
WRONG:
|
||||
\\\`\\\`\\\`suggestion
|
||||
function processUser(user) {
|
||||
if (!user) throw new Error('Invalid user');
|
||||
const name = user.name;
|
||||
\\\`\\\`\\\`
|
||||
|
||||
COMMON MISTAKE TO AVOID:
|
||||
Never duplicate code in suggestions. For example, DON'T do this:
|
||||
${codeBlock}suggestion
|
||||
async typeEmail(email: string): Promise<void> {
|
||||
async typeEmail(email: string): Promise<void> { // WRONG: Duplicate signature!
|
||||
await this.emailInput.fill(email);
|
||||
}
|
||||
${codeBlock}
|
||||
CORRECT:
|
||||
\\\`\\\`\\\`suggestion
|
||||
const name = user.name;
|
||||
\\\`\\\`\\\`
|
||||
|
||||
For validation suggestions, comment on the function declaration line or create separate comments for each concern.
|
||||
|
||||
4. Submit your review:
|
||||
- Use mcp__github__submit_pending_pull_request_review
|
||||
- Parameters:
|
||||
* event: "COMMENT" (general feedback), "REQUEST_CHANGES" (issues found), or "APPROVE" (if appropriate)
|
||||
* body: Write a comprehensive review summary that includes:
|
||||
- Overview of what was reviewed (files, scope, focus areas)
|
||||
- Summary of all issues found (with counts by severity if applicable)
|
||||
- Key recommendations and action items
|
||||
- Highlights of good practices observed
|
||||
- Overall assessment and recommendation
|
||||
- The body should be detailed and informative since it's the main review content
|
||||
- Structure the body with clear sections using markdown headers
|
||||
|
||||
REVIEW GUIDELINES:
|
||||
|
||||
@@ -211,11 +238,13 @@ REVIEW GUIDELINES:
|
||||
|
||||
- Provide:
|
||||
* Specific, actionable feedback
|
||||
* Code suggestions using the exact format described above
|
||||
* Clear explanations of issues found
|
||||
* Constructive criticism with solutions
|
||||
* Code suggestions when possible (following GitHub's format exactly)
|
||||
* Clear explanations of issues
|
||||
* Constructive criticism
|
||||
* Recognition of good practices
|
||||
* For complex changes: Create separate inline comments for each logical change
|
||||
* For complex changes that require multiple modifications:
|
||||
- Create separate comments for each logical change
|
||||
- Or explain the full solution in text without a suggestion block
|
||||
|
||||
- Communication:
|
||||
* All feedback goes through GitHub's review system
|
||||
@@ -297,8 +326,9 @@ This ensures users get value from the review even before checking individual inl
|
||||
...context.inputs.disallowedTools,
|
||||
];
|
||||
|
||||
core.exportVariable("ALLOWED_TOOLS", allowedTools.join(","));
|
||||
core.exportVariable("DISALLOWED_TOOLS", disallowedTools.join(","));
|
||||
// Export as INPUT_ prefixed variables for the base action
|
||||
core.exportVariable("INPUT_ALLOWED_TOOLS", allowedTools.join(","));
|
||||
core.exportVariable("INPUT_DISALLOWED_TOOLS", disallowedTools.join(","));
|
||||
|
||||
const additionalMcpConfig = process.env.MCP_CONFIG || "";
|
||||
const mcpConfig = await prepareMcpConfig({
|
||||
|
||||
@@ -3,7 +3,7 @@ import type { PreparedContext } from "../create-prompt/types";
|
||||
import type { FetchDataResult } from "../github/data/fetcher";
|
||||
import type { Octokits } from "../github/api/client";
|
||||
|
||||
export type ModeName = "tag" | "agent" | "experimental-review";
|
||||
export type ModeName = "tag" | "agent" | "remote-agent" | "experimental-review";
|
||||
|
||||
export type ModeContext = {
|
||||
mode: ModeName;
|
||||
|
||||
19
src/types/stream-config.ts
Normal file
19
src/types/stream-config.ts
Normal file
@@ -0,0 +1,19 @@
|
||||
/**
|
||||
* Configuration for streaming and progress tracking
|
||||
*/
|
||||
export type StreamConfig = {
|
||||
/** Endpoint for streaming Claude execution progress */
|
||||
progress_endpoint?: string;
|
||||
|
||||
/** Endpoint for system-level progress reporting (workflow lifecycle events) */
|
||||
system_progress_endpoint?: string;
|
||||
|
||||
/** Resume endpoint for teleport functionality */
|
||||
resume_endpoint?: string;
|
||||
|
||||
/** Session ID for tracking */
|
||||
session_id?: string;
|
||||
|
||||
/** Headers to include with streaming requests (includes Authorization) */
|
||||
headers?: Record<string, string>;
|
||||
};
|
||||
@@ -1,96 +0,0 @@
|
||||
#!/usr/bin/env bun
|
||||
|
||||
import { describe, test, expect } from "bun:test";
|
||||
import { checkHumanActor } from "../src/github/validation/actor";
|
||||
import type { Octokit } from "@octokit/rest";
|
||||
import { createMockContext } from "./mockContext";
|
||||
|
||||
function createMockOctokit(userType: string): Octokit {
|
||||
return {
|
||||
users: {
|
||||
getByUsername: async () => ({
|
||||
data: {
|
||||
type: userType,
|
||||
},
|
||||
}),
|
||||
},
|
||||
} as unknown as Octokit;
|
||||
}
|
||||
|
||||
describe("checkHumanActor", () => {
|
||||
test("should pass for human actor", async () => {
|
||||
const mockOctokit = createMockOctokit("User");
|
||||
const context = createMockContext();
|
||||
context.actor = "human-user";
|
||||
|
||||
await expect(
|
||||
checkHumanActor(mockOctokit, context),
|
||||
).resolves.toBeUndefined();
|
||||
});
|
||||
|
||||
test("should throw error for bot actor when not allowed", async () => {
|
||||
const mockOctokit = createMockOctokit("Bot");
|
||||
const context = createMockContext();
|
||||
context.actor = "test-bot[bot]";
|
||||
context.inputs.allowedBots = "";
|
||||
|
||||
await expect(checkHumanActor(mockOctokit, context)).rejects.toThrow(
|
||||
"Workflow initiated by non-human actor: test-bot (type: Bot). Add bot to allowed_bots list or use '*' to allow all bots.",
|
||||
);
|
||||
});
|
||||
|
||||
test("should pass for bot actor when all bots allowed", async () => {
|
||||
const mockOctokit = createMockOctokit("Bot");
|
||||
const context = createMockContext();
|
||||
context.actor = "test-bot[bot]";
|
||||
context.inputs.allowedBots = "*";
|
||||
|
||||
await expect(
|
||||
checkHumanActor(mockOctokit, context),
|
||||
).resolves.toBeUndefined();
|
||||
});
|
||||
|
||||
test("should pass for specific bot when in allowed list", async () => {
|
||||
const mockOctokit = createMockOctokit("Bot");
|
||||
const context = createMockContext();
|
||||
context.actor = "dependabot[bot]";
|
||||
context.inputs.allowedBots = "dependabot[bot],renovate[bot]";
|
||||
|
||||
await expect(
|
||||
checkHumanActor(mockOctokit, context),
|
||||
).resolves.toBeUndefined();
|
||||
});
|
||||
|
||||
test("should pass for specific bot when in allowed list (without [bot])", async () => {
|
||||
const mockOctokit = createMockOctokit("Bot");
|
||||
const context = createMockContext();
|
||||
context.actor = "dependabot[bot]";
|
||||
context.inputs.allowedBots = "dependabot,renovate";
|
||||
|
||||
await expect(
|
||||
checkHumanActor(mockOctokit, context),
|
||||
).resolves.toBeUndefined();
|
||||
});
|
||||
|
||||
test("should throw error for bot not in allowed list", async () => {
|
||||
const mockOctokit = createMockOctokit("Bot");
|
||||
const context = createMockContext();
|
||||
context.actor = "other-bot[bot]";
|
||||
context.inputs.allowedBots = "dependabot[bot],renovate[bot]";
|
||||
|
||||
await expect(checkHumanActor(mockOctokit, context)).rejects.toThrow(
|
||||
"Workflow initiated by non-human actor: other-bot (type: Bot). Add bot to allowed_bots list or use '*' to allow all bots.",
|
||||
);
|
||||
});
|
||||
|
||||
test("should throw error for bot not in allowed list (without [bot])", async () => {
|
||||
const mockOctokit = createMockOctokit("Bot");
|
||||
const context = createMockContext();
|
||||
context.actor = "other-bot[bot]";
|
||||
context.inputs.allowedBots = "dependabot,renovate";
|
||||
|
||||
await expect(checkHumanActor(mockOctokit, context)).rejects.toThrow(
|
||||
"Workflow initiated by non-human actor: other-bot (type: Bot). Add bot to allowed_bots list or use '*' to allow all bots.",
|
||||
);
|
||||
});
|
||||
});
|
||||
@@ -1041,6 +1041,8 @@ describe("buildAllowedToolsString", () => {
|
||||
expect(result).toContain("Bash(git diff:*)");
|
||||
expect(result).toContain("Bash(git log:*)");
|
||||
expect(result).toContain("Bash(git rm:*)");
|
||||
expect(result).toContain("Bash(git config user.name:*)");
|
||||
expect(result).toContain("Bash(git config user.email:*)");
|
||||
|
||||
// Comment tool from minimal server should be included
|
||||
expect(result).toContain("mcp__github_comment__update_claude_comment");
|
||||
|
||||
@@ -662,255 +662,4 @@ describe("downloadCommentImages", () => {
|
||||
);
|
||||
expect(result.get(imageUrl2)).toBeUndefined();
|
||||
});
|
||||
|
||||
test("should detect and download images from HTML img tags", async () => {
|
||||
const mockOctokit = createMockOctokit();
|
||||
const imageUrl =
|
||||
"https://github.com/user-attachments/assets/html-image.png";
|
||||
const signedUrl =
|
||||
"https://private-user-images.githubusercontent.com/html.png?jwt=token";
|
||||
|
||||
// Mock octokit response
|
||||
// @ts-expect-error Mock implementation doesn't match full type signature
|
||||
mockOctokit.rest.issues.getComment = jest.fn().mockResolvedValue({
|
||||
data: {
|
||||
body_html: `<img src="${signedUrl}">`,
|
||||
},
|
||||
});
|
||||
|
||||
// Mock fetch for image download
|
||||
const mockArrayBuffer = new ArrayBuffer(8);
|
||||
fetchSpy = spyOn(global, "fetch").mockResolvedValue({
|
||||
ok: true,
|
||||
arrayBuffer: async () => mockArrayBuffer,
|
||||
} as Response);
|
||||
|
||||
const comments: CommentWithImages[] = [
|
||||
{
|
||||
type: "issue_comment",
|
||||
id: "777",
|
||||
body: `Here's an HTML image: <img src="${imageUrl}" alt="test">`,
|
||||
},
|
||||
];
|
||||
|
||||
const result = await downloadCommentImages(
|
||||
mockOctokit,
|
||||
"owner",
|
||||
"repo",
|
||||
comments,
|
||||
);
|
||||
|
||||
expect(mockOctokit.rest.issues.getComment).toHaveBeenCalledWith({
|
||||
owner: "owner",
|
||||
repo: "repo",
|
||||
comment_id: 777,
|
||||
mediaType: { format: "full+json" },
|
||||
});
|
||||
|
||||
expect(fetchSpy).toHaveBeenCalledWith(signedUrl);
|
||||
expect(fsWriteFileSpy).toHaveBeenCalledWith(
|
||||
"/tmp/github-images/image-1704067200000-0.png",
|
||||
Buffer.from(mockArrayBuffer),
|
||||
);
|
||||
|
||||
expect(result.size).toBe(1);
|
||||
expect(result.get(imageUrl)).toBe(
|
||||
"/tmp/github-images/image-1704067200000-0.png",
|
||||
);
|
||||
expect(consoleLogSpy).toHaveBeenCalledWith(
|
||||
"Found 1 image(s) in issue_comment 777",
|
||||
);
|
||||
expect(consoleLogSpy).toHaveBeenCalledWith(`Downloading ${imageUrl}...`);
|
||||
expect(consoleLogSpy).toHaveBeenCalledWith(
|
||||
"✓ Saved: /tmp/github-images/image-1704067200000-0.png",
|
||||
);
|
||||
});
|
||||
|
||||
test("should handle HTML img tags with different quote styles", async () => {
|
||||
const mockOctokit = createMockOctokit();
|
||||
const imageUrl1 =
|
||||
"https://github.com/user-attachments/assets/single-quote.jpg";
|
||||
const imageUrl2 =
|
||||
"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
|
||||
mockOctokit.rest.issues.getComment = jest.fn().mockResolvedValue({
|
||||
data: {
|
||||
body_html: `<img src="${signedUrl1}"><img src="${signedUrl2}">`,
|
||||
},
|
||||
});
|
||||
|
||||
fetchSpy = spyOn(global, "fetch").mockResolvedValue({
|
||||
ok: true,
|
||||
arrayBuffer: async () => new ArrayBuffer(8),
|
||||
} as Response);
|
||||
|
||||
const comments: CommentWithImages[] = [
|
||||
{
|
||||
type: "issue_comment",
|
||||
id: "888",
|
||||
body: `Single quote: <img src='${imageUrl1}' alt="test"> and double quote: <img src="${imageUrl2}" alt="test">`,
|
||||
},
|
||||
];
|
||||
|
||||
const result = await downloadCommentImages(
|
||||
mockOctokit,
|
||||
"owner",
|
||||
"repo",
|
||||
comments,
|
||||
);
|
||||
|
||||
expect(fetchSpy).toHaveBeenCalledTimes(2);
|
||||
expect(result.size).toBe(2);
|
||||
expect(result.get(imageUrl1)).toBe(
|
||||
"/tmp/github-images/image-1704067200000-0.jpg",
|
||||
);
|
||||
expect(result.get(imageUrl2)).toBe(
|
||||
"/tmp/github-images/image-1704067200000-1.png",
|
||||
);
|
||||
expect(consoleLogSpy).toHaveBeenCalledWith(
|
||||
"Found 2 image(s) in issue_comment 888",
|
||||
);
|
||||
});
|
||||
|
||||
test("should handle mixed Markdown and HTML images", async () => {
|
||||
const mockOctokit = createMockOctokit();
|
||||
const markdownUrl =
|
||||
"https://github.com/user-attachments/assets/markdown.png";
|
||||
const htmlUrl = "https://github.com/user-attachments/assets/html.jpg";
|
||||
const signedUrl1 =
|
||||
"https://private-user-images.githubusercontent.com/md.png?jwt=token1";
|
||||
const signedUrl2 =
|
||||
"https://private-user-images.githubusercontent.com/html.jpg?jwt=token2";
|
||||
|
||||
// @ts-expect-error Mock implementation doesn't match full type signature
|
||||
mockOctokit.rest.issues.getComment = jest.fn().mockResolvedValue({
|
||||
data: {
|
||||
body_html: `<img src="${signedUrl1}"><img src="${signedUrl2}">`,
|
||||
},
|
||||
});
|
||||
|
||||
fetchSpy = spyOn(global, "fetch").mockResolvedValue({
|
||||
ok: true,
|
||||
arrayBuffer: async () => new ArrayBuffer(8),
|
||||
} as Response);
|
||||
|
||||
const comments: CommentWithImages[] = [
|
||||
{
|
||||
type: "issue_comment",
|
||||
id: "999",
|
||||
body: `Markdown:  and HTML: <img src="${htmlUrl}" alt="test">`,
|
||||
},
|
||||
];
|
||||
|
||||
const result = await downloadCommentImages(
|
||||
mockOctokit,
|
||||
"owner",
|
||||
"repo",
|
||||
comments,
|
||||
);
|
||||
|
||||
expect(fetchSpy).toHaveBeenCalledTimes(2);
|
||||
expect(result.size).toBe(2);
|
||||
expect(result.get(markdownUrl)).toBe(
|
||||
"/tmp/github-images/image-1704067200000-0.png",
|
||||
);
|
||||
expect(result.get(htmlUrl)).toBe(
|
||||
"/tmp/github-images/image-1704067200000-1.jpg",
|
||||
);
|
||||
expect(consoleLogSpy).toHaveBeenCalledWith(
|
||||
"Found 2 image(s) in issue_comment 999",
|
||||
);
|
||||
});
|
||||
|
||||
test("should deduplicate identical URLs from Markdown and HTML", async () => {
|
||||
const mockOctokit = createMockOctokit();
|
||||
const imageUrl = "https://github.com/user-attachments/assets/duplicate.png";
|
||||
const signedUrl =
|
||||
"https://private-user-images.githubusercontent.com/dup.png?jwt=token";
|
||||
|
||||
// @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: "1000",
|
||||
body: `Same image twice:  and <img src="${imageUrl}" alt="test">`,
|
||||
},
|
||||
];
|
||||
|
||||
const result = await downloadCommentImages(
|
||||
mockOctokit,
|
||||
"owner",
|
||||
"repo",
|
||||
comments,
|
||||
);
|
||||
|
||||
expect(fetchSpy).toHaveBeenCalledTimes(1); // Only downloaded once
|
||||
expect(result.size).toBe(1);
|
||||
expect(result.get(imageUrl)).toBe(
|
||||
"/tmp/github-images/image-1704067200000-0.png",
|
||||
);
|
||||
expect(consoleLogSpy).toHaveBeenCalledWith(
|
||||
"Found 1 image(s) in issue_comment 1000",
|
||||
);
|
||||
});
|
||||
|
||||
test("should handle HTML img tags with additional attributes", async () => {
|
||||
const mockOctokit = createMockOctokit();
|
||||
const imageUrl =
|
||||
"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
|
||||
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: "1001",
|
||||
body: `Complex tag: <img class="image" src="${imageUrl}" alt="test image" width="100" height="200">`,
|
||||
},
|
||||
];
|
||||
|
||||
const result = await downloadCommentImages(
|
||||
mockOctokit,
|
||||
"owner",
|
||||
"repo",
|
||||
comments,
|
||||
);
|
||||
|
||||
expect(fetchSpy).toHaveBeenCalledTimes(1);
|
||||
expect(result.size).toBe(1);
|
||||
expect(result.get(imageUrl)).toBe(
|
||||
"/tmp/github-images/image-1704067200000-0.webp",
|
||||
);
|
||||
expect(consoleLogSpy).toHaveBeenCalledWith(
|
||||
"Found 1 image(s) in issue_comment 1001",
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -37,7 +37,6 @@ describe("prepareMcpConfig", () => {
|
||||
useStickyComment: false,
|
||||
additionalPermissions: new Map(),
|
||||
useCommitSigning: false,
|
||||
allowedBots: "",
|
||||
},
|
||||
};
|
||||
|
||||
|
||||
@@ -28,7 +28,6 @@ const defaultInputs = {
|
||||
useStickyComment: false,
|
||||
additionalPermissions: new Map<string, string>(),
|
||||
useCommitSigning: false,
|
||||
allowedBots: "",
|
||||
};
|
||||
|
||||
const defaultRepository = {
|
||||
|
||||
@@ -1,29 +1,15 @@
|
||||
import { describe, test, expect, beforeEach, afterEach, spyOn } from "bun:test";
|
||||
import { describe, test, expect, beforeEach } from "bun:test";
|
||||
import { agentMode } from "../../src/modes/agent";
|
||||
import type { GitHubContext } from "../../src/github/context";
|
||||
import { createMockContext, createMockAutomationContext } from "../mockContext";
|
||||
import * as core from "@actions/core";
|
||||
|
||||
describe("Agent Mode", () => {
|
||||
let mockContext: GitHubContext;
|
||||
let exportVariableSpy: any;
|
||||
let setOutputSpy: any;
|
||||
|
||||
beforeEach(() => {
|
||||
mockContext = createMockAutomationContext({
|
||||
eventName: "workflow_dispatch",
|
||||
});
|
||||
exportVariableSpy = spyOn(core, "exportVariable").mockImplementation(
|
||||
() => {},
|
||||
);
|
||||
setOutputSpy = spyOn(core, "setOutput").mockImplementation(() => {});
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
exportVariableSpy?.mockClear();
|
||||
setOutputSpy?.mockClear();
|
||||
exportVariableSpy?.mockRestore();
|
||||
setOutputSpy?.mockRestore();
|
||||
});
|
||||
|
||||
test("agent mode has correct properties", () => {
|
||||
@@ -70,67 +56,4 @@ describe("Agent Mode", () => {
|
||||
expect(agentMode.shouldTrigger(context)).toBe(false);
|
||||
});
|
||||
});
|
||||
|
||||
test("prepare method sets up tools environment variables correctly", async () => {
|
||||
// Clear any previous calls before this test
|
||||
exportVariableSpy.mockClear();
|
||||
setOutputSpy.mockClear();
|
||||
|
||||
const contextWithCustomTools = createMockAutomationContext({
|
||||
eventName: "workflow_dispatch",
|
||||
});
|
||||
contextWithCustomTools.inputs.allowedTools = ["CustomTool1", "CustomTool2"];
|
||||
contextWithCustomTools.inputs.disallowedTools = ["BadTool"];
|
||||
|
||||
const mockOctokit = {} as any;
|
||||
const result = await agentMode.prepare({
|
||||
context: contextWithCustomTools,
|
||||
octokit: mockOctokit,
|
||||
githubToken: "test-token",
|
||||
});
|
||||
|
||||
// Verify that both ALLOWED_TOOLS and DISALLOWED_TOOLS are set
|
||||
expect(exportVariableSpy).toHaveBeenCalledWith(
|
||||
"ALLOWED_TOOLS",
|
||||
"Edit,MultiEdit,Glob,Grep,LS,Read,Write,CustomTool1,CustomTool2",
|
||||
);
|
||||
expect(exportVariableSpy).toHaveBeenCalledWith(
|
||||
"DISALLOWED_TOOLS",
|
||||
"WebSearch,WebFetch,BadTool",
|
||||
);
|
||||
|
||||
// Verify MCP config is set
|
||||
expect(setOutputSpy).toHaveBeenCalledWith("mcp_config", expect.any(String));
|
||||
|
||||
// Verify return structure
|
||||
expect(result).toEqual({
|
||||
commentId: undefined,
|
||||
branchInfo: {
|
||||
baseBranch: "",
|
||||
currentBranch: "",
|
||||
claudeBranch: undefined,
|
||||
},
|
||||
mcpConfig: expect.any(String),
|
||||
});
|
||||
});
|
||||
|
||||
test("prepare method creates prompt file with correct content", async () => {
|
||||
const contextWithPrompts = createMockAutomationContext({
|
||||
eventName: "workflow_dispatch",
|
||||
});
|
||||
contextWithPrompts.inputs.overridePrompt = "Custom override prompt";
|
||||
contextWithPrompts.inputs.directPrompt =
|
||||
"Direct prompt (should be ignored)";
|
||||
|
||||
const mockOctokit = {} as any;
|
||||
await agentMode.prepare({
|
||||
context: contextWithPrompts,
|
||||
octokit: mockOctokit,
|
||||
githubToken: "test-token",
|
||||
});
|
||||
|
||||
// Note: We can't easily test file creation in this unit test,
|
||||
// but we can verify the method completes without errors
|
||||
expect(setOutputSpy).toHaveBeenCalledWith("mcp_config", expect.any(String));
|
||||
});
|
||||
});
|
||||
|
||||
@@ -39,13 +39,13 @@ describe("Mode Registry", () => {
|
||||
|
||||
test("getMode throws error for tag mode with workflow_dispatch event", () => {
|
||||
expect(() => getMode("tag", mockWorkflowDispatchContext)).toThrow(
|
||||
"Tag mode cannot handle workflow_dispatch events. Use 'agent' mode for automation events.",
|
||||
"Tag mode cannot handle workflow_dispatch events. Use 'agent' mode for automation events or 'remote-agent' mode for repository_dispatch events.",
|
||||
);
|
||||
});
|
||||
|
||||
test("getMode throws error for tag mode with schedule event", () => {
|
||||
expect(() => getMode("tag", mockScheduleContext)).toThrow(
|
||||
"Tag mode cannot handle schedule events. Use 'agent' mode for automation events.",
|
||||
"Tag mode cannot handle schedule events. Use 'agent' mode for automation events or 'remote-agent' mode for repository_dispatch events.",
|
||||
);
|
||||
});
|
||||
|
||||
@@ -64,7 +64,7 @@ describe("Mode Registry", () => {
|
||||
test("getMode throws error for invalid mode", () => {
|
||||
const invalidMode = "invalid" as unknown as ModeName;
|
||||
expect(() => getMode(invalidMode, mockContext)).toThrow(
|
||||
"Invalid mode 'invalid'. Valid modes are: 'tag', 'agent', 'experimental-review'. Please check your workflow configuration.",
|
||||
"Invalid mode 'invalid'. Valid modes are: 'tag', 'agent', 'remote-agent', 'experimental-review'. Please check your workflow configuration.",
|
||||
);
|
||||
});
|
||||
|
||||
@@ -72,6 +72,7 @@ describe("Mode Registry", () => {
|
||||
expect(isValidMode("tag")).toBe(true);
|
||||
expect(isValidMode("agent")).toBe(true);
|
||||
expect(isValidMode("experimental-review")).toBe(true);
|
||||
expect(isValidMode("remote-agent")).toBe(true);
|
||||
});
|
||||
|
||||
test("isValidMode returns false for invalid mode", () => {
|
||||
|
||||
@@ -73,7 +73,6 @@ describe("checkWritePermissions", () => {
|
||||
useStickyComment: false,
|
||||
additionalPermissions: new Map(),
|
||||
useCommitSigning: false,
|
||||
allowedBots: "",
|
||||
},
|
||||
});
|
||||
|
||||
@@ -127,16 +126,6 @@ describe("checkWritePermissions", () => {
|
||||
);
|
||||
});
|
||||
|
||||
test("should return true for bot user", async () => {
|
||||
const mockOctokit = createMockOctokit("none");
|
||||
const context = createContext();
|
||||
context.actor = "test-bot[bot]";
|
||||
|
||||
const result = await checkWritePermissions(mockOctokit, context);
|
||||
|
||||
expect(result).toBe(true);
|
||||
});
|
||||
|
||||
test("should throw error when permission check fails", async () => {
|
||||
const error = new Error("API error");
|
||||
const mockOctokit = {
|
||||
|
||||
28
test/report-claude-complete.test.ts
Normal file
28
test/report-claude-complete.test.ts
Normal file
@@ -0,0 +1,28 @@
|
||||
import { describe, test, expect } from "bun:test";
|
||||
import type { StreamConfig } from "../src/types/stream-config";
|
||||
|
||||
describe("report-claude-complete", () => {
|
||||
test("StreamConfig type should include system_progress_endpoint", () => {
|
||||
const config: StreamConfig = {
|
||||
progress_endpoint: "https://example.com/progress",
|
||||
system_progress_endpoint: "https://example.com/system-progress",
|
||||
resume_endpoint: "https://example.com/resume",
|
||||
session_id: "test-session",
|
||||
headers: {
|
||||
Authorization: "Bearer test-token",
|
||||
},
|
||||
};
|
||||
|
||||
expect(config.system_progress_endpoint).toBe(
|
||||
"https://example.com/system-progress",
|
||||
);
|
||||
});
|
||||
|
||||
test("StreamConfig type should allow optional fields", () => {
|
||||
const config: StreamConfig = {};
|
||||
|
||||
expect(config.system_progress_endpoint).toBeUndefined();
|
||||
expect(config.progress_endpoint).toBeUndefined();
|
||||
expect(config.headers).toBeUndefined();
|
||||
});
|
||||
});
|
||||
@@ -7,7 +7,6 @@ import {
|
||||
normalizeHtmlEntities,
|
||||
sanitizeContent,
|
||||
stripHtmlComments,
|
||||
redactGitHubTokens,
|
||||
} from "../src/github/utils/sanitizer";
|
||||
|
||||
describe("stripInvisibleCharacters", () => {
|
||||
@@ -243,109 +242,6 @@ describe("sanitizeContent", () => {
|
||||
});
|
||||
});
|
||||
|
||||
describe("redactGitHubTokens", () => {
|
||||
it("should redact personal access tokens (ghp_)", () => {
|
||||
const token = "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";
|
||||
expect(redactGitHubTokens(`Token: ${token}`)).toBe(
|
||||
"Token: [REDACTED_GITHUB_TOKEN]",
|
||||
);
|
||||
expect(redactGitHubTokens(`Here's a token: ${token} in text`)).toBe(
|
||||
"Here's a token: [REDACTED_GITHUB_TOKEN] in text",
|
||||
);
|
||||
});
|
||||
|
||||
it("should redact OAuth tokens (gho_)", () => {
|
||||
const token = "gho_16C7e42F292c6912E7710c838347Ae178B4a";
|
||||
expect(redactGitHubTokens(`OAuth: ${token}`)).toBe(
|
||||
"OAuth: [REDACTED_GITHUB_TOKEN]",
|
||||
);
|
||||
});
|
||||
|
||||
it("should redact installation tokens (ghs_)", () => {
|
||||
const token = "ghs_xz7yzju2SZjGPa0dUNMAx0SH4xDOCS31LXQW";
|
||||
expect(redactGitHubTokens(`Install token: ${token}`)).toBe(
|
||||
"Install token: [REDACTED_GITHUB_TOKEN]",
|
||||
);
|
||||
});
|
||||
|
||||
it("should redact refresh tokens (ghr_)", () => {
|
||||
const token = "ghr_1B4a2e77838347a253e56d7b5253e7d11667";
|
||||
expect(redactGitHubTokens(`Refresh: ${token}`)).toBe(
|
||||
"Refresh: [REDACTED_GITHUB_TOKEN]",
|
||||
);
|
||||
});
|
||||
|
||||
it("should redact fine-grained tokens (github_pat_)", () => {
|
||||
const token =
|
||||
"github_pat_11ABCDEFG0example5of9_2nVwvsylpmOLboQwTPTLewDcE621dQ0AAaBBCCDDEEFFHH";
|
||||
expect(redactGitHubTokens(`Fine-grained: ${token}`)).toBe(
|
||||
"Fine-grained: [REDACTED_GITHUB_TOKEN]",
|
||||
);
|
||||
});
|
||||
|
||||
it("should handle tokens in code blocks", () => {
|
||||
const content = `\`\`\`bash
|
||||
export GITHUB_TOKEN=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
|
||||
\`\`\``;
|
||||
const expected = `\`\`\`bash
|
||||
export GITHUB_TOKEN=[REDACTED_GITHUB_TOKEN]
|
||||
\`\`\``;
|
||||
expect(redactGitHubTokens(content)).toBe(expected);
|
||||
});
|
||||
|
||||
it("should handle multiple tokens in one text", () => {
|
||||
const content =
|
||||
"Token 1: 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 and token 2: gho_16C7e42F292c6912E7710c838347Ae178B4a";
|
||||
expect(redactGitHubTokens(content)).toBe(
|
||||
"Token 1: [REDACTED_GITHUB_TOKEN] and token 2: [REDACTED_GITHUB_TOKEN]",
|
||||
);
|
||||
});
|
||||
|
||||
it("should handle tokens in URLs", () => {
|
||||
const content =
|
||||
"https://api.github.com/user?access_token=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";
|
||||
expect(redactGitHubTokens(content)).toBe(
|
||||
"https://api.github.com/user?access_token=[REDACTED_GITHUB_TOKEN]",
|
||||
);
|
||||
});
|
||||
|
||||
it("should not redact partial matches or invalid tokens", () => {
|
||||
const content =
|
||||
"This is not a token: ghp_short or gho_toolong1234567890123456789012345678901234567890";
|
||||
expect(redactGitHubTokens(content)).toBe(content);
|
||||
});
|
||||
|
||||
it("should preserve normal text", () => {
|
||||
const content = "Normal text with no tokens";
|
||||
expect(redactGitHubTokens(content)).toBe(content);
|
||||
});
|
||||
|
||||
it("should handle edge cases", () => {
|
||||
expect(redactGitHubTokens("")).toBe("");
|
||||
expect(redactGitHubTokens("ghp_")).toBe("ghp_");
|
||||
expect(redactGitHubTokens("github_pat_short")).toBe("github_pat_short");
|
||||
});
|
||||
});
|
||||
|
||||
describe("sanitizeContent with token redaction", () => {
|
||||
it("should redact tokens as part of full sanitization", () => {
|
||||
const content = `
|
||||
<!-- Hidden comment with token: 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 -->
|
||||
Here's some text with a token: gho_16C7e42F292c6912E7710c838347Ae178B4a
|
||||
And invisible chars: test\u200Btoken
|
||||
`;
|
||||
|
||||
const sanitized = sanitizeContent(content);
|
||||
|
||||
expect(sanitized).not.toContain("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");
|
||||
expect(sanitized).not.toContain("gho_16C7e42F292c6912E7710c838347Ae178B4a");
|
||||
expect(sanitized).not.toContain("<!-- Hidden comment");
|
||||
expect(sanitized).not.toContain("\u200B");
|
||||
expect(sanitized).toContain("[REDACTED_GITHUB_TOKEN]");
|
||||
expect(sanitized).toContain("Here's some text with a token:");
|
||||
});
|
||||
});
|
||||
|
||||
describe("stripHtmlComments (legacy)", () => {
|
||||
it("should remove HTML comments", () => {
|
||||
expect(stripHtmlComments("Hello <!-- example -->World")).toBe(
|
||||
|
||||
@@ -41,7 +41,6 @@ describe("checkContainsTrigger", () => {
|
||||
useStickyComment: false,
|
||||
additionalPermissions: new Map(),
|
||||
useCommitSigning: false,
|
||||
allowedBots: "",
|
||||
},
|
||||
});
|
||||
expect(checkContainsTrigger(context)).toBe(true);
|
||||
@@ -75,7 +74,6 @@ describe("checkContainsTrigger", () => {
|
||||
useStickyComment: false,
|
||||
additionalPermissions: new Map(),
|
||||
useCommitSigning: false,
|
||||
allowedBots: "",
|
||||
},
|
||||
});
|
||||
expect(checkContainsTrigger(context)).toBe(false);
|
||||
@@ -293,7 +291,6 @@ describe("checkContainsTrigger", () => {
|
||||
useStickyComment: false,
|
||||
additionalPermissions: new Map(),
|
||||
useCommitSigning: false,
|
||||
allowedBots: "",
|
||||
},
|
||||
});
|
||||
expect(checkContainsTrigger(context)).toBe(true);
|
||||
@@ -328,7 +325,6 @@ describe("checkContainsTrigger", () => {
|
||||
useStickyComment: false,
|
||||
additionalPermissions: new Map(),
|
||||
useCommitSigning: false,
|
||||
allowedBots: "",
|
||||
},
|
||||
});
|
||||
expect(checkContainsTrigger(context)).toBe(true);
|
||||
@@ -363,7 +359,6 @@ describe("checkContainsTrigger", () => {
|
||||
useStickyComment: false,
|
||||
additionalPermissions: new Map(),
|
||||
useCommitSigning: false,
|
||||
allowedBots: "",
|
||||
},
|
||||
});
|
||||
expect(checkContainsTrigger(context)).toBe(false);
|
||||
|
||||
Reference in New Issue
Block a user