Compare commits

..

12 Commits

Author SHA1 Message Date
guro
7680c1d501 feat: add workflow_dispatch trigger with cloudflare tunnel token input
- Add manual trigger support for GitHub Actions UI
- Include cloudflare_tunnel_token and direct_prompt inputs
- Set agent mode for workflow_dispatch events
- Enable manual execution of Claude Code action
2025-08-07 13:58:04 -07:00
Guro
dee63efcf4 feat: add ttyd and cloudflared tunnel integration
Add support for exposing Claude's terminal interface via browser using ttyd and cloudflared tunnel.

- Add cloudflare_tunnel_token input parameter to both action.yml files
- Spawn ttyd process on port 7681 to serve Claude's CLI interface
- Start cloudflared tunnel process with provided token to expose via web
- Implement proper process cleanup in finally block
- Add 3-second startup delay for process initialization

When cloudflare_tunnel_token is provided, users can access Claude's interactive terminal through their browser via the cloudflared tunnel.

🤖 Generated with [Claude Code](https://claude.ai/code)

Co-Authored-By: Claude <noreply@anthropic.com>
2025-08-07 12:53:44 -07:00
Aner Cohen
7afc848186 fix: improve GitHub suggestion guidelines in review mode to prevent code duplication (#422)
* fix: prevent duplicate function signatures in review mode suggestions

This fixes a critical bug in the experimental review mode where GitHub
suggestions could create duplicate function signatures when applied.

The issue occurred because:
- GitHub suggestions REPLACE the entire selected line range
- Claude wasn't aware of this behavior and would include the function
  signature in multi-line suggestions, causing duplication

Changes:
- Added detailed instructions about GitHub's line replacement behavior
- Provided clear examples for single-line vs multi-line suggestions
- Added explicit warnings about common mistakes (duplicate signatures)
- Improved code readability by using a codeBlock variable instead of
  escaped backticks in template strings

This ensures Claude creates syntactically correct suggestions that
won't break code when applied through GitHub's suggestion feature.

* chore: format
2025-08-07 08:56:30 -07:00
Graham Campbell
6debac392b Go with Opus 4.1 (#420) 2025-08-06 21:22:15 -07:00
GitHub Actions
55fb6a96d0 chore: bump Claude Code version to 1.0.70 2025-08-06 19:59:40 +00:00
Ashwin Bhat
15db2b3c79 feat: add inline comment MCP server for experimental review mode (#414)
* feat: add inline comment MCP server for experimental review mode

- Create standalone inline PR comments without review workflow
- Support single-line and multi-line comments
- Auto-install server when in experimental review mode
- Uses octokit.rest.pulls.createReviewComment() directly

* docs: clarify GitHub code suggestion syntax in inline comment server

Add clear documentation that suggestion blocks replace the entire selected
line range and must be syntactically complete drop-in replacements.

🤖 Generated with [Claude Code](https://claude.ai/code)

Co-Authored-By: Claude <noreply@anthropic.com>

---------

Co-authored-by: Claude <noreply@anthropic.com>
2025-08-06 08:21:29 -07:00
Ashwin Bhat
188d526721 refactor: change git hook from pre-push to pre-commit (#401)
- Renamed scripts/pre-push to scripts/pre-commit
- Updated install-hooks.sh to install pre-commit hook
- Hook now runs formatting, type checking, and tests before commit
2025-08-05 17:02:34 -07:00
Ashwin Bhat
a519840051 fix: remove git config user.name and user.email from allowed tools (#410)
These git config commands are no longer needed as allowed tools since
Claude should not be modifying git configuration settings. Updated
the corresponding test to reflect this intentional change.

🤖 Generated with [Claude Code](https://claude.ai/code)

Co-authored-by: Claude <noreply@anthropic.com>
2025-08-05 11:32:46 -07:00
yoshikouki
85287e957d fix: restore prompt file creation in agent mode (#405)
- Restore prompt file creation logic that was accidentally removed in PR #374
- Agent mode now creates the prompt file directly in prepare() method
- Uses override_prompt or direct_prompt if available, falls back to minimal prompt
- Fixes 'Prompt file does not exist' error for workflow_dispatch and schedule events
- Add TODO comment to refactor this to use createPrompt in the future

Fixes #403
2025-08-05 11:14:28 -07:00
GitHub Actions
c6a07895d7 chore: bump Claude Code version to 1.0.69 2025-08-05 16:50:23 +00:00
atsushi-ishibashi
0c5d54472f feat: Add HTML img tag support to GitHub image downloader (#402)
* feat: support html img tag

* rm files

* refactor
2025-08-04 19:37:50 -07:00
GitHub Actions
2845685880 chore: bump Claude Code version to 1.0.68 2025-08-04 23:29:44 +00:00
35 changed files with 684 additions and 1870 deletions

View File

@@ -9,10 +9,21 @@ on:
types: [opened, assigned] types: [opened, assigned]
pull_request_review: pull_request_review:
types: [submitted] types: [submitted]
workflow_dispatch:
inputs:
cloudflare_tunnel_token:
description: 'Cloudflare tunnel token to expose Claude UI via browser'
required: false
type: string
direct_prompt:
description: 'Direct instruction for Claude'
required: false
type: string
jobs: jobs:
claude: claude:
if: | if: |
github.event_name == 'workflow_dispatch' ||
(github.event_name == 'issue_comment' && contains(github.event.comment.body, '@claude')) || (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_comment' && contains(github.event.comment.body, '@claude')) ||
(github.event_name == 'pull_request_review' && contains(github.event.review.body, '@claude')) || (github.event_name == 'pull_request_review' && contains(github.event.review.body, '@claude')) ||
@@ -36,4 +47,7 @@ jobs:
anthropic_api_key: ${{ secrets.ANTHROPIC_API_KEY }} anthropic_api_key: ${{ secrets.ANTHROPIC_API_KEY }}
allowed_tools: "Bash(bun install),Bash(bun test:*),Bash(bun run format),Bash(bun typecheck)" 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." 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-20250514" model: "claude-opus-4-1-20250805"
cloudflare_tunnel_token: ${{ github.event.inputs.cloudflare_tunnel_token }}
direct_prompt: ${{ github.event.inputs.direct_prompt }}
mode: ${{ github.event_name == 'workflow_dispatch' && 'agent' || 'tag' }}

2
.npmrc Normal file
View File

@@ -0,0 +1,2 @@
engine-strict=true
registry=https://registry.npmjs.org/

View File

@@ -114,6 +114,10 @@ inputs:
description: "Restrict network access to these domains only (newline-separated). If not set, no restrictions are applied. Provider domains are auto-detected." description: "Restrict network access to these domains only (newline-separated). If not set, no restrictions are applied. Provider domains are auto-detected."
required: false required: false
default: "" default: ""
cloudflare_tunnel_token:
description: "Cloudflare tunnel token to expose Claude UI via browser (optional)"
required: false
default: ""
outputs: outputs:
execution_file: execution_file:
@@ -162,10 +166,6 @@ runs:
ADDITIONAL_PERMISSIONS: ${{ inputs.additional_permissions }} ADDITIONAL_PERMISSIONS: ${{ inputs.additional_permissions }}
USE_COMMIT_SIGNING: ${{ inputs.use_commit_signing }} 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 - name: Install Base Action Dependencies
if: steps.prepare.outputs.contains_trigger == 'true' if: steps.prepare.outputs.contains_trigger == 'true'
shell: bash shell: bash
@@ -176,7 +176,7 @@ runs:
echo "Base-action dependencies installed" echo "Base-action dependencies installed"
cd - cd -
# Install Claude Code globally # Install Claude Code globally
bun install -g @anthropic-ai/claude-code bun install -g @anthropic-ai/claude-code@1.0.70
- name: Setup Network Restrictions - name: Setup Network Restrictions
if: steps.prepare.outputs.contains_trigger == 'true' && inputs.experimental_allowed_domains != '' if: steps.prepare.outputs.contains_trigger == 'true' && inputs.experimental_allowed_domains != ''
@@ -192,6 +192,7 @@ runs:
if: steps.prepare.outputs.contains_trigger == 'true' if: steps.prepare.outputs.contains_trigger == 'true'
shell: bash shell: bash
run: | run: |
# Run the base-action # Run the base-action
bun run ${GITHUB_ACTION_PATH}/base-action/src/index.ts bun run ${GITHUB_ACTION_PATH}/base-action/src/index.ts
env: env:
@@ -209,17 +210,17 @@ runs:
INPUT_CLAUDE_ENV: ${{ inputs.claude_env }} INPUT_CLAUDE_ENV: ${{ inputs.claude_env }}
INPUT_FALLBACK_MODEL: ${{ inputs.fallback_model }} INPUT_FALLBACK_MODEL: ${{ inputs.fallback_model }}
INPUT_EXPERIMENTAL_SLASH_COMMANDS_DIR: ${{ github.action_path }}/slash-commands INPUT_EXPERIMENTAL_SLASH_COMMANDS_DIR: ${{ github.action_path }}/slash-commands
INPUT_STREAM_CONFIG: ${{ steps.prepare.outputs.stream_config }} INPUT_CLOUDFLARE_TUNNEL_TOKEN: ${{ inputs.cloudflare_tunnel_token }}
# Model configuration # Model configuration
ANTHROPIC_MODEL: ${{ steps.prepare.outputs.anthropic_model || inputs.model || inputs.anthropic_model }} ANTHROPIC_MODEL: ${{ inputs.model || inputs.anthropic_model }}
GITHUB_TOKEN: ${{ steps.prepare.outputs.GITHUB_TOKEN }} GITHUB_TOKEN: ${{ steps.prepare.outputs.GITHUB_TOKEN }}
NODE_VERSION: ${{ env.NODE_VERSION }} NODE_VERSION: ${{ env.NODE_VERSION }}
DETAILED_PERMISSION_MESSAGES: "1" DETAILED_PERMISSION_MESSAGES: "1"
# Provider configuration # Provider configuration
ANTHROPIC_API_KEY: ${{ inputs.anthropic_api_key }} ANTHROPIC_API_KEY: ${{ inputs.anthropic_api_key }}
CLAUDE_CODE_OAUTH_TOKEN: ${{ steps.prepare.outputs.claude_code_oauth_token || inputs.claude_code_oauth_token }} CLAUDE_CODE_OAUTH_TOKEN: ${{ inputs.claude_code_oauth_token }}
ANTHROPIC_BASE_URL: ${{ env.ANTHROPIC_BASE_URL }} ANTHROPIC_BASE_URL: ${{ env.ANTHROPIC_BASE_URL }}
CLAUDE_CODE_USE_BEDROCK: ${{ inputs.use_bedrock == 'true' && '1' || '' }} CLAUDE_CODE_USE_BEDROCK: ${{ inputs.use_bedrock == 'true' && '1' || '' }}
CLAUDE_CODE_USE_VERTEX: ${{ inputs.use_vertex == 'true' && '1' || '' }} CLAUDE_CODE_USE_VERTEX: ${{ inputs.use_vertex == 'true' && '1' || '' }}
@@ -242,17 +243,6 @@ runs:
VERTEX_REGION_CLAUDE_3_5_SONNET: ${{ env.VERTEX_REGION_CLAUDE_3_5_SONNET }} 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 }} 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 - name: Update comment with job link
if: steps.prepare.outputs.contains_trigger == 'true' && steps.prepare.outputs.claude_comment_id && always() if: steps.prepare.outputs.contains_trigger == 'true' && steps.prepare.outputs.claude_comment_id && always()
shell: bash shell: bash

View File

@@ -69,7 +69,7 @@ Add the following to your workflow file:
uses: anthropics/claude-code-base-action@beta uses: anthropics/claude-code-base-action@beta
with: with:
prompt: "Review and fix TypeScript errors" prompt: "Review and fix TypeScript errors"
model: "claude-opus-4-20250514" model: "claude-opus-4-1-20250805"
fallback_model: "claude-sonnet-4-20250514" fallback_model: "claude-sonnet-4-20250514"
allowed_tools: "Bash(git:*),View,GlobTool,GrepTool,BatchTool" allowed_tools: "Bash(git:*),View,GlobTool,GrepTool,BatchTool"
anthropic_api_key: ${{ secrets.ANTHROPIC_API_KEY }} anthropic_api_key: ${{ secrets.ANTHROPIC_API_KEY }}
@@ -217,7 +217,7 @@ Provide the settings configuration directly as a JSON string:
prompt: "Your prompt here" prompt: "Your prompt here"
settings: | settings: |
{ {
"model": "claude-opus-4-20250514", "model": "claude-opus-4-1-20250805",
"env": { "env": {
"DEBUG": "true", "DEBUG": "true",
"API_URL": "https://api.example.com" "API_URL": "https://api.example.com"

View File

@@ -87,6 +87,10 @@ inputs:
description: "Whether to use Node.js dependency caching (set to true only for Node.js projects with lock files)" description: "Whether to use Node.js dependency caching (set to true only for Node.js projects with lock files)"
required: false required: false
default: "false" default: "false"
cloudflare_tunnel_token:
description: "Cloudflare tunnel token to expose Claude UI via browser (optional)"
required: false
default: ""
outputs: outputs:
conclusion: conclusion:
@@ -102,7 +106,7 @@ runs:
- name: Setup Node.js - name: Setup Node.js
uses: actions/setup-node@49933ea5288caeca8642d1e84afbd3f7d6820020 # https://github.com/actions/setup-node/releases/tag/v4.4.0 uses: actions/setup-node@49933ea5288caeca8642d1e84afbd3f7d6820020 # https://github.com/actions/setup-node/releases/tag/v4.4.0
with: with:
node-version: ${{ env.NODE_VERSION || '22.x' }} node-version: ${{ env.NODE_VERSION || '18.x' }}
cache: ${{ inputs.use_node_cache == 'true' && 'npm' || '' }} cache: ${{ inputs.use_node_cache == 'true' && 'npm' || '' }}
- name: Install Bun - name: Install Bun
@@ -118,9 +122,7 @@ runs:
- name: Install Claude Code - name: Install Claude Code
shell: bash shell: bash
run: | run: bun install -g @anthropic-ai/claude-code@1.0.70
# Install Claude Code
bun install -g @anthropic-ai/claude-code
- name: Run Claude Code Action - name: Run Claude Code Action
shell: bash shell: bash
@@ -149,6 +151,7 @@ runs:
INPUT_CLAUDE_ENV: ${{ inputs.claude_env }} INPUT_CLAUDE_ENV: ${{ inputs.claude_env }}
INPUT_FALLBACK_MODEL: ${{ inputs.fallback_model }} INPUT_FALLBACK_MODEL: ${{ inputs.fallback_model }}
INPUT_EXPERIMENTAL_SLASH_COMMANDS_DIR: ${{ inputs.experimental_slash_commands_dir }} INPUT_EXPERIMENTAL_SLASH_COMMANDS_DIR: ${{ inputs.experimental_slash_commands_dir }}
INPUT_CLOUDFLARE_TUNNEL_TOKEN: ${{ inputs.cloudflare_tunnel_token }}
# Provider configuration # Provider configuration
ANTHROPIC_API_KEY: ${{ inputs.anthropic_api_key }} ANTHROPIC_API_KEY: ${{ inputs.anthropic_api_key }}

View File

@@ -5,6 +5,7 @@ import { preparePrompt } from "./prepare-prompt";
import { runClaude } from "./run-claude"; import { runClaude } from "./run-claude";
import { setupClaudeCodeSettings } from "./setup-claude-code-settings"; import { setupClaudeCodeSettings } from "./setup-claude-code-settings";
import { validateEnvironmentVariables } from "./validate-env"; import { validateEnvironmentVariables } from "./validate-env";
import { spawn } from "child_process";
async function run() { async function run() {
try { try {
@@ -21,7 +22,40 @@ async function run() {
promptFile: process.env.INPUT_PROMPT_FILE || "", promptFile: process.env.INPUT_PROMPT_FILE || "",
}); });
await runClaude(promptConfig.path, { // Setup ttyd and cloudflared tunnel if token provided
let ttydProcess: any = null;
let cloudflaredProcess: any = null;
if (process.env.INPUT_CLOUDFLARE_TUNNEL_TOKEN) {
console.log("Setting up ttyd and cloudflared tunnel...");
// Start ttyd process in background
ttydProcess = spawn("ttyd", ["-p", "7681", "-i", "0.0.0.0", "claude"], {
stdio: "inherit",
detached: true,
});
ttydProcess.on("error", (error: Error) => {
console.warn(`ttyd process error: ${error.message}`);
});
// Start cloudflared tunnel
cloudflaredProcess = spawn("cloudflared", ["tunnel", "run", "--token", process.env.INPUT_CLOUDFLARE_TUNNEL_TOKEN], {
stdio: "inherit",
detached: true,
});
cloudflaredProcess.on("error", (error: Error) => {
console.warn(`cloudflared process error: ${error.message}`);
});
// Give processes time to start up
await new Promise(resolve => setTimeout(resolve, 3000));
console.log("ttyd and cloudflared tunnel started");
}
try {
await runClaude(promptConfig.path, {
allowedTools: process.env.INPUT_ALLOWED_TOOLS, allowedTools: process.env.INPUT_ALLOWED_TOOLS,
disallowedTools: process.env.INPUT_DISALLOWED_TOOLS, disallowedTools: process.env.INPUT_DISALLOWED_TOOLS,
maxTurns: process.env.INPUT_MAX_TURNS, maxTurns: process.env.INPUT_MAX_TURNS,
@@ -30,8 +64,25 @@ async function run() {
appendSystemPrompt: process.env.INPUT_APPEND_SYSTEM_PROMPT, appendSystemPrompt: process.env.INPUT_APPEND_SYSTEM_PROMPT,
claudeEnv: process.env.INPUT_CLAUDE_ENV, claudeEnv: process.env.INPUT_CLAUDE_ENV,
fallbackModel: process.env.INPUT_FALLBACK_MODEL, fallbackModel: process.env.INPUT_FALLBACK_MODEL,
streamConfig: process.env.INPUT_STREAM_CONFIG, model: process.env.ANTHROPIC_MODEL,
}); });
} finally {
// Clean up processes
if (ttydProcess) {
try {
ttydProcess.kill("SIGTERM");
} catch (e) {
console.warn("Failed to terminate ttyd process");
}
}
if (cloudflaredProcess) {
try {
cloudflaredProcess.kill("SIGTERM");
} catch (e) {
console.warn("Failed to terminate cloudflared process");
}
}
}
} catch (error) { } catch (error) {
core.setFailed(`Action failed with error: ${error}`); core.setFailed(`Action failed with error: ${error}`);
core.setOutput("conclusion", "failure"); core.setOutput("conclusion", "failure");

View File

@@ -4,7 +4,6 @@ import { promisify } from "util";
import { unlink, writeFile, stat } from "fs/promises"; import { unlink, writeFile, stat } from "fs/promises";
import { createWriteStream } from "fs"; import { createWriteStream } from "fs";
import { spawn } from "child_process"; import { spawn } from "child_process";
import { StreamHandler } from "./stream-handler";
const execAsync = promisify(exec); const execAsync = promisify(exec);
@@ -22,14 +21,7 @@ export type ClaudeOptions = {
claudeEnv?: string; claudeEnv?: string;
fallbackModel?: string; fallbackModel?: string;
timeoutMinutes?: string; timeoutMinutes?: string;
streamConfig?: string; model?: string;
};
export type StreamConfig = {
progress_endpoint?: string;
headers?: Record<string, string>;
resume_endpoint?: string;
session_id?: string;
}; };
type PreparedConfig = { type PreparedConfig = {
@@ -103,6 +95,9 @@ export function prepareRunConfig(
if (options.fallbackModel) { if (options.fallbackModel) {
claudeArgs.push("--fallback-model", options.fallbackModel); claudeArgs.push("--fallback-model", options.fallbackModel);
} }
if (options.model) {
claudeArgs.push("--model", options.model);
}
if (options.timeoutMinutes) { if (options.timeoutMinutes) {
const timeoutMinutesNum = parseInt(options.timeoutMinutes, 10); const timeoutMinutesNum = parseInt(options.timeoutMinutes, 10);
if (isNaN(timeoutMinutesNum) || timeoutMinutesNum <= 0) { if (isNaN(timeoutMinutesNum) || timeoutMinutesNum <= 0) {
@@ -111,22 +106,6 @@ 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 // Parse custom environment variables
const customEnv = parseCustomEnvVars(options.claudeEnv); const customEnv = parseCustomEnvVars(options.claudeEnv);
@@ -141,34 +120,6 @@ export function prepareRunConfig(
export async function runClaude(promptPath: string, options: ClaudeOptions) { export async function runClaude(promptPath: string, options: ClaudeOptions) {
const config = prepareRunConfig(promptPath, options); 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 // Create a named pipe
try { try {
await unlink(PIPE_PATH); await unlink(PIPE_PATH);
@@ -211,31 +162,12 @@ export async function runClaude(promptPath: string, options: ClaudeOptions) {
pipeStream.destroy(); 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, { const claudeProcess = spawn("claude", config.claudeArgs, {
stdio: ["pipe", "pipe", "inherit"], stdio: ["pipe", "pipe", "inherit"],
env: processEnv, env: {
...process.env,
...config.env,
},
}); });
// Handle Claude process errors // Handle Claude process errors
@@ -246,51 +178,32 @@ export async function runClaude(promptPath: string, options: ClaudeOptions) {
// Capture output for parsing execution metrics // Capture output for parsing execution metrics
let output = ""; let output = "";
let lineBuffer = ""; // Buffer for incomplete lines claudeProcess.stdout.on("data", (data) => {
claudeProcess.stdout.on("data", async (data) => {
const text = data.toString(); const text = data.toString();
output += text;
// Add new data to line buffer // Try to parse as JSON and pretty print if it's on a single line
lineBuffer += text; const lines = text.split("\n");
lines.forEach((line: string, index: number) => {
if (line.trim() === "") return;
// 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 { try {
// Check if this line is a JSON object // Check if this line is a JSON object
const parsed = JSON.parse(line); const parsed = JSON.parse(line);
const prettyJson = JSON.stringify(parsed, null, 2); const prettyJson = JSON.stringify(parsed, null, 2);
process.stdout.write(prettyJson); process.stdout.write(prettyJson);
process.stdout.write("\n"); if (index < lines.length - 1 || text.endsWith("\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) { } catch (e) {
// Not a JSON object, print as is // Not a JSON object, print as is
process.stdout.write(line); process.stdout.write(line);
process.stdout.write("\n"); if (index < lines.length - 1 || text.endsWith("\n")) {
// Don't send non-JSON lines to stream handler process.stdout.write("\n");
}
} }
} });
output += text;
}); });
// Handle stdout errors // Handle stdout errors
@@ -344,33 +257,8 @@ export async function runClaude(promptPath: string, options: ClaudeOptions) {
} }
}, timeoutMs); }, timeoutMs);
claudeProcess.on("close", async (code) => { claudeProcess.on("close", (code) => {
if (!resolved) { 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); clearTimeout(timeoutId);
resolved = true; resolved = true;
resolve(code || 0); resolve(code || 0);
@@ -387,15 +275,6 @@ 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 // Clean up processes
try { try {
catProcess.kill("SIGTERM"); catProcess.kill("SIGTERM");

View File

@@ -1,152 +0,0 @@
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;
}
}

View File

@@ -1,97 +0,0 @@
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
});
});

View File

@@ -134,7 +134,7 @@ describe("setupClaudeCodeSettings", () => {
// Then, add new settings // Then, add new settings
const newSettings = JSON.stringify({ const newSettings = JSON.stringify({
newKey: "newValue", newKey: "newValue",
model: "claude-opus-4-20250514", model: "claude-opus-4-1-20250805",
}); });
await setupClaudeCodeSettings(newSettings, testHomeDir); await setupClaudeCodeSettings(newSettings, testHomeDir);
@@ -145,7 +145,7 @@ describe("setupClaudeCodeSettings", () => {
expect(settings.enableAllProjectMcpServers).toBe(true); expect(settings.enableAllProjectMcpServers).toBe(true);
expect(settings.existingKey).toBe("existingValue"); expect(settings.existingKey).toBe("existingValue");
expect(settings.newKey).toBe("newValue"); expect(settings.newKey).toBe("newValue");
expect(settings.model).toBe("claude-opus-4-20250514"); expect(settings.model).toBe("claude-opus-4-1-20250805");
}); });
test("should copy slash commands to .claude directory when path provided", async () => { test("should copy slash commands to .claude directory when path provided", async () => {

View File

@@ -1,364 +0,0 @@
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"]);
});
});
});

View File

@@ -252,7 +252,7 @@ You can provide Claude Code settings to customize behavior such as model selecti
with: with:
settings: | settings: |
{ {
"model": "claude-opus-4-20250514", "model": "claude-opus-4-1-20250805",
"env": { "env": {
"DEBUG": "true", "DEBUG": "true",
"API_URL": "https://api.example.com" "API_URL": "https://api.example.com"

View File

@@ -6,8 +6,8 @@ echo "Installing git hooks..."
# Make sure hooks directory exists # Make sure hooks directory exists
mkdir -p .git/hooks mkdir -p .git/hooks
# Install pre-push hook # Install pre-commit hook
cp scripts/pre-push .git/hooks/pre-push cp scripts/pre-commit .git/hooks/pre-commit
chmod +x .git/hooks/pre-push chmod +x .git/hooks/pre-commit
echo "Git hooks installed successfully!" echo "Git hooks installed successfully!"

View File

@@ -60,8 +60,6 @@ export function buildAllowedToolsString(
"Bash(git diff:*)", "Bash(git diff:*)",
"Bash(git log:*)", "Bash(git log:*)",
"Bash(git rm:*)", "Bash(git rm:*)",
"Bash(git config user.name:*)",
"Bash(git config user.email:*)",
); );
} }

View File

@@ -12,6 +12,7 @@ import { createOctokit } from "../github/api/client";
import { parseGitHubContext, isEntityContext } from "../github/context"; import { parseGitHubContext, isEntityContext } from "../github/context";
import { getMode, isValidMode, DEFAULT_MODE } from "../modes/registry"; import { getMode, isValidMode, DEFAULT_MODE } from "../modes/registry";
import type { ModeName } from "../modes/types"; import type { ModeName } from "../modes/types";
import { prepare } from "../prepare";
async function run() { async function run() {
try { try {
@@ -59,19 +60,7 @@ async function run() {
} }
// Step 4: Get mode and check trigger conditions // Step 4: Get mode and check trigger conditions
let mode; const mode = getMode(validatedMode, context);
// 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); const containsTrigger = mode.shouldTrigger(context);
// Set output for action.yml to check // Set output for action.yml to check
@@ -83,9 +72,10 @@ async function run() {
} }
// Step 5: Use the new modular prepare function // Step 5: Use the new modular prepare function
const result = await mode.prepare({ const result = await prepare({
context, context,
octokit, octokit,
mode,
githubToken, githubToken,
}); });

View File

@@ -1,81 +0,0 @@
#!/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();
}

View File

@@ -6,7 +6,6 @@ import type {
PullRequestEvent, PullRequestEvent,
PullRequestReviewEvent, PullRequestReviewEvent,
PullRequestReviewCommentEvent, PullRequestReviewCommentEvent,
RepositoryDispatchEvent,
} from "@octokit/webhooks-types"; } from "@octokit/webhooks-types";
// Custom types for GitHub Actions events that aren't webhooks // Custom types for GitHub Actions events that aren't webhooks
export type WorkflowDispatchEvent = { export type WorkflowDispatchEvent = {
@@ -47,11 +46,7 @@ const ENTITY_EVENT_NAMES = [
"pull_request_review_comment", "pull_request_review_comment",
] as const; ] as const;
const AUTOMATION_EVENT_NAMES = [ const AUTOMATION_EVENT_NAMES = ["workflow_dispatch", "schedule"] as const;
"workflow_dispatch",
"schedule",
"repository_dispatch",
] as const;
// Derive types from constants for better maintainability // Derive types from constants for better maintainability
type EntityEventName = (typeof ENTITY_EVENT_NAMES)[number]; type EntityEventName = (typeof ENTITY_EVENT_NAMES)[number];
@@ -67,17 +62,6 @@ type BaseContext = {
full_name: string; full_name: string;
}; };
actor: string; actor: string;
payload:
| IssuesEvent
| IssueCommentEvent
| PullRequestEvent
| PullRequestReviewEvent
| PullRequestReviewCommentEvent
| RepositoryDispatchEvent
| WorkflowDispatchEvent
| ScheduleEvent;
entityNumber?: number;
isPR?: boolean;
inputs: { inputs: {
mode: ModeName; mode: ModeName;
triggerPhrase: string; triggerPhrase: string;
@@ -94,14 +78,6 @@ type BaseContext = {
additionalPermissions: Map<string, string>; additionalPermissions: Map<string, string>;
useCommitSigning: boolean; useCommitSigning: boolean;
}; };
progressTracking?: {
headers?: Record<string, string>;
resumeEndpoint?: string;
sessionId?: string;
progressEndpoint: string;
systemProgressEndpoint?: string;
oauthTokenEndpoint?: string;
};
}; };
// Context for entity-based events (issues, PRs, comments) // Context for entity-based events (issues, PRs, comments)
@@ -120,7 +96,7 @@ export type ParsedGitHubContext = BaseContext & {
// Context for automation events (workflow_dispatch, schedule) // Context for automation events (workflow_dispatch, schedule)
export type AutomationContext = BaseContext & { export type AutomationContext = BaseContext & {
eventName: AutomationEventName; eventName: AutomationEventName;
payload: WorkflowDispatchEvent | ScheduleEvent | RepositoryDispatchEvent; payload: WorkflowDispatchEvent | ScheduleEvent;
}; };
// Union type for all contexts // Union type for all contexts
@@ -214,66 +190,6 @@ export function parseGitHubContext(): GitHubContext {
isPR: true, 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": { case "workflow_dispatch": {
return { return {
...commonFields, ...commonFields,
@@ -371,9 +287,3 @@ export function isAutomationContext(
context.eventName as AutomationEventName, context.eventName as AutomationEventName,
); );
} }
export function isRepositoryDispatchEvent(
context: GitHubContext,
): context is GitHubContext & { payload: RepositoryDispatchEvent } {
return context.eventName === "repository_dispatch";
}

View File

@@ -8,7 +8,7 @@
import { $ } from "bun"; import { $ } from "bun";
import * as core from "@actions/core"; import * as core from "@actions/core";
import type { GitHubContext } from "../context"; import type { ParsedGitHubContext } from "../context";
import type { GitHubPullRequest } from "../types"; import type { GitHubPullRequest } from "../types";
import type { Octokits } from "../api/client"; import type { Octokits } from "../api/client";
import type { FetchDataResult } from "../data/fetcher"; import type { FetchDataResult } from "../data/fetcher";
@@ -21,15 +21,15 @@ export type BranchInfo = {
export async function setupBranch( export async function setupBranch(
octokits: Octokits, octokits: Octokits,
githubData: FetchDataResult | null, githubData: FetchDataResult,
context: GitHubContext, context: ParsedGitHubContext,
): Promise<BranchInfo> { ): Promise<BranchInfo> {
const { owner, repo } = context.repository; const { owner, repo } = context.repository;
const entityNumber = context.entityNumber; const entityNumber = context.entityNumber;
const { baseBranch, branchPrefix } = context.inputs; const { baseBranch, branchPrefix } = context.inputs;
const isPR = context.isPR; const isPR = context.isPR;
if (isPR && githubData) { if (isPR) {
const prData = githubData.contextData as GitHubPullRequest; const prData = githubData.contextData as GitHubPullRequest;
const prState = prData.state; const prState = prData.state;
@@ -84,27 +84,19 @@ export async function setupBranch(
sourceBranch = repoResponse.data.default_branch; sourceBranch = repoResponse.data.default_branch;
} }
// Generate branch name for either an issue, closed/merged PR, or repository_dispatch event // Generate branch name for either an issue or closed/merged PR
let branchName: string; const entityType = isPR ? "pr" : "issue";
if (context.eventName === "repository_dispatch") { // Create Kubernetes-compatible timestamp: lowercase, hyphens only, shorter format
// For repository_dispatch events, use run ID for uniqueness since there's no entity number const now = new Date();
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")}`;
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: // Ensure branch name is Kubernetes-compatible:
// - Lowercase only // - Lowercase only
// - Alphanumeric with hyphens // - Alphanumeric with hyphens
// - No underscores // - No underscores
// - Max 50 chars (to allow for prefixes) // - Max 50 chars (to allow for prefixes)
const branchName = `${branchPrefix}${entityType}-${entityNumber}-${timestamp}`;
const newBranch = branchName.toLowerCase().substring(0, 50); const newBranch = branchName.toLowerCase().substring(0, 50);
try { try {
@@ -140,18 +132,8 @@ export async function setupBranch(
} }
// For non-signing case, create and checkout the branch locally only // 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( console.log(
`Creating local branch ${newBranch} for ${entityType} ${entityId} from source branch: ${sourceBranch}...`, `Creating local branch ${newBranch} for ${entityType} #${entityNumber} from source branch: ${sourceBranch}...`,
); );
// Fetch and checkout the source branch first to ensure we branch from the correct base // Fetch and checkout the source branch first to ensure we branch from the correct base

View File

@@ -6,7 +6,7 @@
*/ */
import { $ } from "bun"; import { $ } from "bun";
import type { GitHubContext } from "../context"; import type { ParsedGitHubContext } from "../context";
import { GITHUB_SERVER_URL } from "../api/config"; import { GITHUB_SERVER_URL } from "../api/config";
type GitUser = { type GitUser = {
@@ -16,7 +16,7 @@ type GitUser = {
export async function configureGitAuth( export async function configureGitAuth(
githubToken: string, githubToken: string,
context: GitHubContext, context: ParsedGitHubContext,
user: GitUser | null, user: GitUser | null,
) { ) {
console.log("Configuring git authentication for non-signing mode"); console.log("Configuring git authentication for non-signing mode");

View File

@@ -3,11 +3,17 @@ import path from "path";
import type { Octokits } from "../api/client"; import type { Octokits } from "../api/client";
import { GITHUB_SERVER_URL } from "../api/config"; import { GITHUB_SERVER_URL } from "../api/config";
const escapedUrl = GITHUB_SERVER_URL.replace(/[.*+?^${}()|[\]\\]/g, "\\$&");
const IMAGE_REGEX = new RegExp( const IMAGE_REGEX = new RegExp(
`!\\[[^\\]]*\\]\\((${GITHUB_SERVER_URL.replace(/[.*+?^${}()|[\]\\]/g, "\\$&")}\\/user-attachments\\/assets\\/[^)]+)\\)`, `!\\[[^\\]]*\\]\\((${escapedUrl}\\/user-attachments\\/assets\\/[^)]+)\\)`,
"g", "g",
); );
const HTML_IMG_REGEX = new RegExp(
`<img[^>]+src=["']([^"']*${escapedUrl}\\/user-attachments\\/assets\\/[^"']+)["'][^>]*>`,
"gi",
);
type IssueComment = { type IssueComment = {
type: "issue_comment"; type: "issue_comment";
id: string; id: string;
@@ -63,8 +69,16 @@ export async function downloadCommentImages(
}> = []; }> = [];
for (const comment of comments) { for (const comment of comments) {
const imageMatches = [...comment.body.matchAll(IMAGE_REGEX)]; // Extract URLs from Markdown format
const urls = imageMatches.map((match) => match[1] as string); 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])];
if (urls.length > 0) { if (urls.length > 0) {
commentsWithImages.push({ comment, urls }); commentsWithImages.push({ comment, urls });

View File

@@ -0,0 +1,178 @@
#!/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";
// 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()
.optional()
.describe(
"Line number for single-line comments (required if startLine is not provided)",
),
startLine: z
.number()
.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;
// 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,
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);

View File

@@ -1,6 +1,6 @@
import * as core from "@actions/core"; import * as core from "@actions/core";
import { GITHUB_API_URL, GITHUB_SERVER_URL } from "../github/api/config"; import { GITHUB_API_URL, GITHUB_SERVER_URL } from "../github/api/config";
import type { GitHubContext } from "../github/context"; import type { ParsedGitHubContext } from "../github/context";
import { Octokit } from "@octokit/rest"; import { Octokit } from "@octokit/rest";
type PrepareConfigParams = { type PrepareConfigParams = {
@@ -12,7 +12,7 @@ type PrepareConfigParams = {
additionalMcpConfig?: string; additionalMcpConfig?: string;
claudeCommentId?: string; claudeCommentId?: string;
allowedTools: string[]; allowedTools: string[];
context: GitHubContext; context: ParsedGitHubContext;
}; };
async function checkActionsReadPermission( async function checkActionsReadPermission(
@@ -73,23 +73,21 @@ export async function prepareMcpConfig(
}; };
// Always include comment server for updating Claude comments // Always include comment server for updating Claude comments
if (context.inputs.mode === "tag") { baseMcpConfig.mcpServers.github_comment = {
baseMcpConfig.mcpServers.github_comment = { command: "bun",
command: "bun", args: [
args: [ "run",
"run", `${process.env.GITHUB_ACTION_PATH}/src/mcp/github-comment-server.ts`,
`${process.env.GITHUB_ACTION_PATH}/src/mcp/github-comment-server.ts`, ],
], env: {
env: { GITHUB_TOKEN: githubToken,
GITHUB_TOKEN: githubToken, REPO_OWNER: owner,
REPO_OWNER: owner, REPO_NAME: repo,
REPO_NAME: repo, ...(claudeCommentId && { CLAUDE_COMMENT_ID: claudeCommentId }),
...(claudeCommentId && { CLAUDE_COMMENT_ID: claudeCommentId }), GITHUB_EVENT_NAME: process.env.GITHUB_EVENT_NAME || "",
GITHUB_EVENT_NAME: process.env.GITHUB_EVENT_NAME || "", GITHUB_API_URL: GITHUB_API_URL,
GITHUB_API_URL: GITHUB_API_URL, },
}, };
};
}
// Include file ops server when commit signing is enabled // Include file ops server when commit signing is enabled
if (context.inputs.useCommitSigning) { if (context.inputs.useCommitSigning) {
@@ -113,6 +111,24 @@ 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 // Only add CI server if we have actions:read permission and we're in a PR context
const hasActionsReadPermission = const hasActionsReadPermission =
context.inputs.additionalPermissions.get("actions") === "read"; context.inputs.additionalPermissions.get("actions") === "read";

View File

@@ -1,4 +1,5 @@
import * as core from "@actions/core"; import * as core from "@actions/core";
import { mkdir, writeFile } from "fs/promises";
import type { Mode, ModeOptions, ModeResult } from "../types"; import type { Mode, ModeOptions, ModeResult } from "../types";
import { isAutomationContext } from "../../github/context"; import { isAutomationContext } from "../../github/context";
import type { PreparedContext } from "../../create-prompt/types"; import type { PreparedContext } from "../../create-prompt/types";
@@ -42,7 +43,23 @@ export const agentMode: Mode = {
async prepare({ context }: ModeOptions): Promise<ModeResult> { async prepare({ context }: ModeOptions): Promise<ModeResult> {
// Agent mode handles automation events (workflow_dispatch, schedule) only // Agent mode handles automation events (workflow_dispatch, schedule) only
// Agent mode doesn't need to create prompt files here - handled by createPrompt // 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,
);
// Export tool environment variables for agent mode // Export tool environment variables for agent mode
const baseTools = [ const baseTools = [

View File

@@ -16,15 +16,9 @@ import { agentMode } from "./agent";
import { reviewMode } from "./review"; import { reviewMode } from "./review";
import type { GitHubContext } from "../github/context"; import type { GitHubContext } from "../github/context";
import { isAutomationContext } from "../github/context"; import { isAutomationContext } from "../github/context";
import { remoteAgentMode } from "./remote-agent";
export const DEFAULT_MODE = "tag" as const; export const DEFAULT_MODE = "tag" as const;
export const VALID_MODES = [ export const VALID_MODES = ["tag", "agent", "experimental-review"] as const;
"tag",
"agent",
"remote-agent",
"experimental-review",
] as const;
/** /**
* All available modes. * All available modes.
@@ -34,7 +28,6 @@ const modes = {
tag: tagMode, tag: tagMode,
agent: agentMode, agent: agentMode,
"experimental-review": reviewMode, "experimental-review": reviewMode,
"remote-agent": remoteAgentMode,
} as const satisfies Record<ModeName, Mode>; } as const satisfies Record<ModeName, Mode>;
/** /**
@@ -56,13 +49,7 @@ export function getMode(name: ModeName, context: GitHubContext): Mode {
// Validate mode can handle the event type // Validate mode can handle the event type
if (name === "tag" && isAutomationContext(context)) { if (name === "tag" && isAutomationContext(context)) {
throw new Error( throw new Error(
`Tag mode cannot handle ${context.eventName} events. Use 'agent' mode for automation events or 'remote-agent' mode for repository_dispatch events.`, `Tag mode cannot handle ${context.eventName} events. Use 'agent' mode for automation 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.`,
); );
} }

View File

@@ -1,468 +0,0 @@
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`
: ""
}`;
}

View File

@@ -1,78 +0,0 @@
/**
* 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
};

View File

@@ -1,149 +0,0 @@
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);
}

View File

@@ -60,20 +60,8 @@ export const reviewMode: Mode = {
getAllowedTools() { getAllowedTools() {
return [ return [
// Context tools - to know who the current user is "Bash(gh issue comment:*)",
"mcp__github__get_me", "mcp__github_inline_comment__create_inline_comment",
// 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",
]; ];
}, },
@@ -115,6 +103,9 @@ export const reviewMode: Mode = {
? formatBody(contextData.body, imageUrlMap) ? formatBody(contextData.body, imageUrlMap)
: "No description provided"; : "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. 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> <formatted_context>
@@ -163,68 +154,50 @@ REVIEW MODE WORKFLOW:
1. First, understand the PR context: 1. First, understand the PR context:
- You are reviewing PR #${eventData.isPR && eventData.prNumber ? eventData.prNumber : "[PR number]"} in ${context.repository} - 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 - 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 - This provides the full context and latest state of the code
- Look at the changed_files section above to see which files were modified - Look at the changed_files section above to see which files were modified
2. Create a pending review: 2. Create review comments using GitHub MCP tools:
- Use mcp__github__create_pending_pull_request_review to start your review - Use Bash(gh issue comment:*) for general PR-level comments
- This allows you to batch comments before submitting - Use mcp__github_inline_comment__create_inline_comment for line-specific feedback (strongly preferred)
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
- When to use multi-line comments: 3. When creating inline comments with suggestions:
* When replacing multiple consecutive lines CRITICAL: GitHub's suggestion blocks REPLACE the ENTIRE line range you select
* When the fix requires changes across several lines - For single-line comments: Use 'line' parameter only
* Example: To replace lines 19-20, use startLine: 19, line: 20 - For multi-line comments: Use both 'startLine' and 'line' parameters
- The 'body' parameter should contain your comment and/or suggestion block
- For code suggestions, use this EXACT format in the body: How to write code suggestions correctly:
\`\`\`suggestion a) To remove a line (e.g., removing console.log on line 22):
corrected code here - Set line: 22
\`\`\` - Body: ${codeBlock}suggestion
${codeBlock}
(Empty suggestion block removes the line)
CRITICAL: GitHub suggestion blocks must ONLY contain the replacement for the specific line(s) being commented on: b) To modify a single line (e.g., fixing line 22):
- For single-line comments: Replace ONLY that line - Set line: 22
- For multi-line comments: Replace ONLY the lines in the range - Body: ${codeBlock}suggestion
- Do NOT include surrounding context or function signatures await this.emailInput.fill(email);
- Do NOT suggest changes that span beyond the commented lines ${codeBlock}
Example for line 19 \`var name = user.name;\`: c) To replace multiple lines (e.g., lines 21-23):
WRONG: - Set startLine: 21, line: 23
\\\`\\\`\\\`suggestion - Body must include ALL lines being replaced:
function processUser(user) { ${codeBlock}suggestion
if (!user) throw new Error('Invalid user'); async typeEmail(email: string): Promise<void> {
const name = user.name; await this.emailInput.fill(email);
\\\`\\\`\\\` }
${codeBlock}
CORRECT: COMMON MISTAKE TO AVOID:
\\\`\\\`\\\`suggestion Never duplicate code in suggestions. For example, DON'T do this:
const name = user.name; ${codeBlock}suggestion
\\\`\\\`\\\` async typeEmail(email: string): Promise<void> {
async typeEmail(email: string): Promise<void> { // WRONG: Duplicate signature!
For validation suggestions, comment on the function declaration line or create separate comments for each concern. await this.emailInput.fill(email);
}
4. Submit your review: ${codeBlock}
- 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: REVIEW GUIDELINES:
@@ -238,13 +211,11 @@ REVIEW GUIDELINES:
- Provide: - Provide:
* Specific, actionable feedback * Specific, actionable feedback
* Code suggestions when possible (following GitHub's format exactly) * Code suggestions using the exact format described above
* Clear explanations of issues * Clear explanations of issues found
* Constructive criticism * Constructive criticism with solutions
* Recognition of good practices * Recognition of good practices
* For complex changes that require multiple modifications: * For complex changes: Create separate inline comments for each logical change
- Create separate comments for each logical change
- Or explain the full solution in text without a suggestion block
- Communication: - Communication:
* All feedback goes through GitHub's review system * All feedback goes through GitHub's review system

View File

@@ -3,7 +3,7 @@ import type { PreparedContext } from "../create-prompt/types";
import type { FetchDataResult } from "../github/data/fetcher"; import type { FetchDataResult } from "../github/data/fetcher";
import type { Octokits } from "../github/api/client"; import type { Octokits } from "../github/api/client";
export type ModeName = "tag" | "agent" | "remote-agent" | "experimental-review"; export type ModeName = "tag" | "agent" | "experimental-review";
export type ModeContext = { export type ModeContext = {
mode: ModeName; mode: ModeName;

View File

@@ -1,19 +0,0 @@
/**
* 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>;
};

View File

@@ -1041,8 +1041,6 @@ describe("buildAllowedToolsString", () => {
expect(result).toContain("Bash(git diff:*)"); expect(result).toContain("Bash(git diff:*)");
expect(result).toContain("Bash(git log:*)"); expect(result).toContain("Bash(git log:*)");
expect(result).toContain("Bash(git rm:*)"); 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 // Comment tool from minimal server should be included
expect(result).toContain("mcp__github_comment__update_claude_comment"); expect(result).toContain("mcp__github_comment__update_claude_comment");

View File

@@ -662,4 +662,255 @@ describe("downloadCommentImages", () => {
); );
expect(result.get(imageUrl2)).toBeUndefined(); 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: ![test](${markdownUrl}) 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: ![test](${imageUrl}) 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",
);
});
}); });

View File

@@ -39,13 +39,13 @@ describe("Mode Registry", () => {
test("getMode throws error for tag mode with workflow_dispatch event", () => { test("getMode throws error for tag mode with workflow_dispatch event", () => {
expect(() => getMode("tag", mockWorkflowDispatchContext)).toThrow( expect(() => getMode("tag", mockWorkflowDispatchContext)).toThrow(
"Tag mode cannot handle workflow_dispatch events. Use 'agent' mode for automation events or 'remote-agent' mode for repository_dispatch events.", "Tag mode cannot handle workflow_dispatch events. Use 'agent' mode for automation events.",
); );
}); });
test("getMode throws error for tag mode with schedule event", () => { test("getMode throws error for tag mode with schedule event", () => {
expect(() => getMode("tag", mockScheduleContext)).toThrow( expect(() => getMode("tag", mockScheduleContext)).toThrow(
"Tag mode cannot handle schedule events. Use 'agent' mode for automation events or 'remote-agent' mode for repository_dispatch events.", "Tag mode cannot handle schedule events. Use 'agent' mode for automation events.",
); );
}); });
@@ -64,7 +64,7 @@ describe("Mode Registry", () => {
test("getMode throws error for invalid mode", () => { test("getMode throws error for invalid mode", () => {
const invalidMode = "invalid" as unknown as ModeName; const invalidMode = "invalid" as unknown as ModeName;
expect(() => getMode(invalidMode, mockContext)).toThrow( expect(() => getMode(invalidMode, mockContext)).toThrow(
"Invalid mode 'invalid'. Valid modes are: 'tag', 'agent', 'remote-agent', 'experimental-review'. Please check your workflow configuration.", "Invalid mode 'invalid'. Valid modes are: 'tag', 'agent', 'experimental-review'. Please check your workflow configuration.",
); );
}); });
@@ -72,7 +72,6 @@ describe("Mode Registry", () => {
expect(isValidMode("tag")).toBe(true); expect(isValidMode("tag")).toBe(true);
expect(isValidMode("agent")).toBe(true); expect(isValidMode("agent")).toBe(true);
expect(isValidMode("experimental-review")).toBe(true); expect(isValidMode("experimental-review")).toBe(true);
expect(isValidMode("remote-agent")).toBe(true);
}); });
test("isValidMode returns false for invalid mode", () => { test("isValidMode returns false for invalid mode", () => {

View File

@@ -1,28 +0,0 @@
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();
});
});