mirror of
https://github.com/anthropics/claude-code-action.git
synced 2026-01-23 15:04:13 +08:00
Compare commits
8 Commits
v0.0.27
...
ashwin/sti
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
7925dd1804 | ||
|
|
23fae74fdb | ||
|
|
3c739a8cf3 | ||
|
|
aa28d465c5 | ||
|
|
55b7205cd2 | ||
|
|
8fe405c45f | ||
|
|
73012199e4 | ||
|
|
459b56e54d |
2
.prettierignore
Normal file
2
.prettierignore
Normal file
@@ -0,0 +1,2 @@
|
|||||||
|
# Test fixtures should not be formatted to preserve exact output matching
|
||||||
|
test/fixtures/
|
||||||
76
README.md
76
README.md
@@ -74,12 +74,15 @@ jobs:
|
|||||||
# API_URL: https://api.example.com
|
# API_URL: https://api.example.com
|
||||||
# Optional: limit the number of conversation turns
|
# Optional: limit the number of conversation turns
|
||||||
# max_turns: "5"
|
# max_turns: "5"
|
||||||
|
# Optional: grant additional permissions (requires corresponding GitHub token permissions)
|
||||||
|
# additional_permissions: |
|
||||||
|
# actions: read
|
||||||
```
|
```
|
||||||
|
|
||||||
## Inputs
|
## Inputs
|
||||||
|
|
||||||
| Input | Description | Required | Default |
|
| Input | Description | Required | Default |
|
||||||
| --------------------- | -------------------------------------------------------------------------------------------------------------------- | -------- | --------- |
|
| ------------------------ | -------------------------------------------------------------------------------------------------------------------- | -------- | --------- |
|
||||||
| `anthropic_api_key` | Anthropic API key (required for direct API, not needed for Bedrock/Vertex) | No\* | - |
|
| `anthropic_api_key` | Anthropic API key (required for direct API, not needed for Bedrock/Vertex) | No\* | - |
|
||||||
| `direct_prompt` | Direct prompt for Claude to execute automatically without needing a trigger (for automated workflows) | No | - |
|
| `direct_prompt` | Direct prompt for Claude to execute automatically without needing a trigger (for automated workflows) | No | - |
|
||||||
| `base_branch` | The base branch to use for creating new branches (e.g., 'main', 'develop') | No | - |
|
| `base_branch` | The base branch to use for creating new branches (e.g., 'main', 'develop') | No | - |
|
||||||
@@ -88,6 +91,7 @@ jobs:
|
|||||||
| `use_sticky_comment` | Use just one comment to deliver PR comments (only applies for pull_request event workflows) | No | `false` |
|
| `use_sticky_comment` | Use just one comment to deliver PR comments (only applies for pull_request event workflows) | No | `false` |
|
||||||
| `github_token` | GitHub token for Claude to operate with. **Only include this if you're connecting a custom GitHub app of your own!** | No | - |
|
| `github_token` | GitHub token for Claude to operate with. **Only include this if you're connecting a custom GitHub app of your own!** | No | - |
|
||||||
| `model` | Model to use (provider-specific format required for Bedrock/Vertex) | No | - |
|
| `model` | Model to use (provider-specific format required for Bedrock/Vertex) | No | - |
|
||||||
|
| `fallback_model` | Enable automatic fallback to specified model when primary model is unavailable | No | - |
|
||||||
| `anthropic_model` | **DEPRECATED**: Use `model` instead. Kept for backward compatibility. | No | - |
|
| `anthropic_model` | **DEPRECATED**: Use `model` instead. Kept for backward compatibility. | No | - |
|
||||||
| `use_bedrock` | Use Amazon Bedrock with OIDC authentication instead of direct Anthropic API | No | `false` |
|
| `use_bedrock` | Use Amazon Bedrock with OIDC authentication instead of direct Anthropic API | No | `false` |
|
||||||
| `use_vertex` | Use Google Vertex AI with OIDC authentication instead of direct Anthropic API | No | `false` |
|
| `use_vertex` | Use Google Vertex AI with OIDC authentication instead of direct Anthropic API | No | `false` |
|
||||||
@@ -100,6 +104,7 @@ jobs:
|
|||||||
| `trigger_phrase` | The trigger phrase to look for in comments, issue/PR bodies, and issue titles | No | `@claude` |
|
| `trigger_phrase` | The trigger phrase to look for in comments, issue/PR bodies, and issue titles | No | `@claude` |
|
||||||
| `branch_prefix` | The prefix to use for Claude branches (defaults to 'claude/', use 'claude-' for dash format) | No | `claude/` |
|
| `branch_prefix` | The prefix to use for Claude branches (defaults to 'claude/', use 'claude-' for dash format) | No | `claude/` |
|
||||||
| `claude_env` | Custom environment variables to pass to Claude Code execution (YAML format) | No | "" |
|
| `claude_env` | Custom environment variables to pass to Claude Code execution (YAML format) | No | "" |
|
||||||
|
| `additional_permissions` | Additional permissions to enable. Currently supports 'actions: read' for viewing workflow results | No | "" |
|
||||||
|
|
||||||
\*Required when using direct Anthropic API (default and when not using Bedrock or Vertex)
|
\*Required when using direct Anthropic API (default and when not using Bedrock or Vertex)
|
||||||
|
|
||||||
@@ -338,6 +343,75 @@ This action is built on top of [`anthropics/claude-code-base-action`](https://gi
|
|||||||
|
|
||||||
## Advanced Configuration
|
## Advanced Configuration
|
||||||
|
|
||||||
|
### Additional Permissions for CI/CD Integration
|
||||||
|
|
||||||
|
The `additional_permissions` input allows Claude to access GitHub Actions workflow information when you grant the necessary permissions. This is particularly useful for analyzing CI/CD failures and debugging workflow issues.
|
||||||
|
|
||||||
|
#### Enabling GitHub Actions Access
|
||||||
|
|
||||||
|
To allow Claude to view workflow run results, job logs, and CI status:
|
||||||
|
|
||||||
|
1. **Grant the necessary permission to your GitHub token**:
|
||||||
|
|
||||||
|
- When using the default `GITHUB_TOKEN`, add the `actions: read` permission to your workflow:
|
||||||
|
|
||||||
|
```yaml
|
||||||
|
permissions:
|
||||||
|
contents: write
|
||||||
|
pull-requests: write
|
||||||
|
issues: write
|
||||||
|
actions: read # Add this line
|
||||||
|
```
|
||||||
|
|
||||||
|
2. **Configure the action with additional permissions**:
|
||||||
|
|
||||||
|
```yaml
|
||||||
|
- uses: anthropics/claude-code-action@beta
|
||||||
|
with:
|
||||||
|
anthropic_api_key: ${{ secrets.ANTHROPIC_API_KEY }}
|
||||||
|
additional_permissions: |
|
||||||
|
actions: read
|
||||||
|
# ... other inputs
|
||||||
|
```
|
||||||
|
|
||||||
|
3. **Claude will automatically get access to CI/CD tools**:
|
||||||
|
When you enable `actions: read`, Claude can use the following MCP tools:
|
||||||
|
- `mcp__github_ci__get_ci_status` - View workflow run statuses
|
||||||
|
- `mcp__github_ci__get_workflow_run_details` - Get detailed workflow information
|
||||||
|
- `mcp__github_ci__download_job_log` - Download and analyze job logs
|
||||||
|
|
||||||
|
#### Example: Debugging Failed CI Runs
|
||||||
|
|
||||||
|
```yaml
|
||||||
|
name: Claude CI Helper
|
||||||
|
on:
|
||||||
|
issue_comment:
|
||||||
|
types: [created]
|
||||||
|
|
||||||
|
permissions:
|
||||||
|
contents: write
|
||||||
|
pull-requests: write
|
||||||
|
issues: write
|
||||||
|
actions: read # Required for CI access
|
||||||
|
|
||||||
|
jobs:
|
||||||
|
claude-ci-helper:
|
||||||
|
runs-on: ubuntu-latest
|
||||||
|
steps:
|
||||||
|
- uses: anthropics/claude-code-action@beta
|
||||||
|
with:
|
||||||
|
anthropic_api_key: ${{ secrets.ANTHROPIC_API_KEY }}
|
||||||
|
additional_permissions: |
|
||||||
|
actions: read
|
||||||
|
# Now Claude can respond to "@claude why did the CI fail?"
|
||||||
|
```
|
||||||
|
|
||||||
|
**Important Notes**:
|
||||||
|
|
||||||
|
- The GitHub token must have the `actions: read` permission in your workflow
|
||||||
|
- If the permission is missing, Claude will warn you and suggest adding it
|
||||||
|
- Currently, only `actions: read` is supported, but the format allows for future extensions
|
||||||
|
|
||||||
### Custom Environment Variables
|
### Custom Environment Variables
|
||||||
|
|
||||||
You can pass custom environment variables to Claude Code execution using the `claude_env` input. This is useful for CI/test setups that require specific environment variables:
|
You can pass custom environment variables to Claude Code execution using the `claude_env` input. This is useful for CI/test setups that require specific environment variables:
|
||||||
|
|||||||
@@ -4,7 +4,7 @@ Thank you for trying out the beta of our GitHub Action! This document outlines o
|
|||||||
|
|
||||||
## Path to 1.0
|
## Path to 1.0
|
||||||
|
|
||||||
- **Ability to see GitHub Action CI results** - This will enable Claude to look at CI failures and make updates to PRs to fix test failures, lint errors, and the like.
|
- ~**Ability to see GitHub Action CI results** - This will enable Claude to look at CI failures and make updates to PRs to fix test failures, lint errors, and the like.~
|
||||||
- **Cross-repo support** - Enable Claude to work across multiple repositories in a single session
|
- **Cross-repo support** - Enable Claude to work across multiple repositories in a single session
|
||||||
- **Ability to modify workflow files** - Let Claude update GitHub Actions workflows and other CI configuration files
|
- **Ability to modify workflow files** - Let Claude update GitHub Actions workflows and other CI configuration files
|
||||||
- **Support for workflow_dispatch and repository_dispatch events** - Dispatch Claude on events triggered via API from other workflows or from other services
|
- **Support for workflow_dispatch and repository_dispatch events** - Dispatch Claude on events triggered via API from other workflows or from other services
|
||||||
|
|||||||
22
action.yml
22
action.yml
@@ -31,6 +31,9 @@ inputs:
|
|||||||
anthropic_model:
|
anthropic_model:
|
||||||
description: "DEPRECATED: Use 'model' instead. Model to use (provider-specific format required for Bedrock/Vertex)"
|
description: "DEPRECATED: Use 'model' instead. Model to use (provider-specific format required for Bedrock/Vertex)"
|
||||||
required: false
|
required: false
|
||||||
|
fallback_model:
|
||||||
|
description: "Enable automatic fallback to specified model when primary model is unavailable"
|
||||||
|
required: false
|
||||||
allowed_tools:
|
allowed_tools:
|
||||||
description: "Additional tools for Claude to use (the base GitHub tools will always be included)"
|
description: "Additional tools for Claude to use (the base GitHub tools will always be included)"
|
||||||
required: false
|
required: false
|
||||||
@@ -49,6 +52,10 @@ inputs:
|
|||||||
default: ""
|
default: ""
|
||||||
mcp_config:
|
mcp_config:
|
||||||
description: "Additional MCP configuration (JSON string) that merges with the built-in GitHub MCP servers"
|
description: "Additional MCP configuration (JSON string) that merges with the built-in GitHub MCP servers"
|
||||||
|
additional_permissions:
|
||||||
|
description: "Additional permissions to enable. Currently supports 'actions: read' for viewing workflow results"
|
||||||
|
required: false
|
||||||
|
default: ""
|
||||||
claude_env:
|
claude_env:
|
||||||
description: "Custom environment variables to pass to Claude Code execution (YAML format)"
|
description: "Custom environment variables to pass to Claude Code execution (YAML format)"
|
||||||
required: false
|
required: false
|
||||||
@@ -121,11 +128,13 @@ runs:
|
|||||||
OVERRIDE_GITHUB_TOKEN: ${{ inputs.github_token }}
|
OVERRIDE_GITHUB_TOKEN: ${{ inputs.github_token }}
|
||||||
GITHUB_RUN_ID: ${{ github.run_id }}
|
GITHUB_RUN_ID: ${{ github.run_id }}
|
||||||
USE_STICKY_COMMENT: ${{ inputs.use_sticky_comment }}
|
USE_STICKY_COMMENT: ${{ inputs.use_sticky_comment }}
|
||||||
|
ACTIONS_TOKEN: ${{ github.token }}
|
||||||
|
ADDITIONAL_PERMISSIONS: ${{ inputs.additional_permissions }}
|
||||||
|
|
||||||
- name: Run Claude Code
|
- name: Run Claude Code
|
||||||
id: claude-code
|
id: claude-code
|
||||||
if: steps.prepare.outputs.contains_trigger == 'true'
|
if: steps.prepare.outputs.contains_trigger == 'true'
|
||||||
uses: anthropics/claude-code-base-action@bdaad5f64e7ad7a8c0be290a3c49d0fa7e1bb442 # v0.0.29
|
uses: anthropics/claude-code-base-action@a835717b36becf75584224421f4094aae288cad7 # v0.0.31
|
||||||
with:
|
with:
|
||||||
prompt_file: ${{ runner.temp }}/claude-prompts/claude-prompt.txt
|
prompt_file: ${{ runner.temp }}/claude-prompts/claude-prompt.txt
|
||||||
allowed_tools: ${{ env.ALLOWED_TOOLS }}
|
allowed_tools: ${{ env.ALLOWED_TOOLS }}
|
||||||
@@ -133,6 +142,7 @@ runs:
|
|||||||
timeout_minutes: ${{ inputs.timeout_minutes }}
|
timeout_minutes: ${{ inputs.timeout_minutes }}
|
||||||
max_turns: ${{ inputs.max_turns }}
|
max_turns: ${{ inputs.max_turns }}
|
||||||
model: ${{ inputs.model || inputs.anthropic_model }}
|
model: ${{ inputs.model || inputs.anthropic_model }}
|
||||||
|
fallback_model: ${{ inputs.fallback_model }}
|
||||||
mcp_config: ${{ steps.prepare.outputs.mcp_config }}
|
mcp_config: ${{ steps.prepare.outputs.mcp_config }}
|
||||||
use_bedrock: ${{ inputs.use_bedrock }}
|
use_bedrock: ${{ inputs.use_bedrock }}
|
||||||
use_vertex: ${{ inputs.use_vertex }}
|
use_vertex: ${{ inputs.use_vertex }}
|
||||||
@@ -191,10 +201,18 @@ runs:
|
|||||||
if: steps.prepare.outputs.contains_trigger == 'true' && steps.claude-code.outputs.execution_file != ''
|
if: steps.prepare.outputs.contains_trigger == 'true' && steps.claude-code.outputs.execution_file != ''
|
||||||
shell: bash
|
shell: bash
|
||||||
run: |
|
run: |
|
||||||
echo "## Claude Code Report" >> $GITHUB_STEP_SUMMARY
|
# Try to format the turns, but if it fails, dump the raw JSON
|
||||||
|
if bun run ${{ github.action_path }}/src/entrypoints/format-turns.ts "${{ steps.claude-code.outputs.execution_file }}" >> $GITHUB_STEP_SUMMARY 2>/dev/null; then
|
||||||
|
echo "Successfully formatted Claude Code report"
|
||||||
|
else
|
||||||
|
echo "## Claude Code Report (Raw Output)" >> $GITHUB_STEP_SUMMARY
|
||||||
|
echo "" >> $GITHUB_STEP_SUMMARY
|
||||||
|
echo "Failed to format output (please report). Here's the raw JSON:" >> $GITHUB_STEP_SUMMARY
|
||||||
|
echo "" >> $GITHUB_STEP_SUMMARY
|
||||||
echo '```json' >> $GITHUB_STEP_SUMMARY
|
echo '```json' >> $GITHUB_STEP_SUMMARY
|
||||||
cat "${{ steps.claude-code.outputs.execution_file }}" >> $GITHUB_STEP_SUMMARY
|
cat "${{ steps.claude-code.outputs.execution_file }}" >> $GITHUB_STEP_SUMMARY
|
||||||
echo '```' >> $GITHUB_STEP_SUMMARY
|
echo '```' >> $GITHUB_STEP_SUMMARY
|
||||||
|
fi
|
||||||
|
|
||||||
- name: Revoke app token
|
- name: Revoke app token
|
||||||
if: always() && inputs.github_token == ''
|
if: always() && inputs.github_token == ''
|
||||||
|
|||||||
@@ -36,9 +36,21 @@ const BASE_ALLOWED_TOOLS = [
|
|||||||
];
|
];
|
||||||
const DISALLOWED_TOOLS = ["WebSearch", "WebFetch"];
|
const DISALLOWED_TOOLS = ["WebSearch", "WebFetch"];
|
||||||
|
|
||||||
export function buildAllowedToolsString(customAllowedTools?: string[]): string {
|
export function buildAllowedToolsString(
|
||||||
|
customAllowedTools?: string[],
|
||||||
|
includeActionsTools: boolean = false,
|
||||||
|
): string {
|
||||||
let baseTools = [...BASE_ALLOWED_TOOLS];
|
let baseTools = [...BASE_ALLOWED_TOOLS];
|
||||||
|
|
||||||
|
// Add GitHub Actions MCP tools if enabled
|
||||||
|
if (includeActionsTools) {
|
||||||
|
baseTools.push(
|
||||||
|
"mcp__github_ci__get_ci_status",
|
||||||
|
"mcp__github_ci__get_workflow_run_details",
|
||||||
|
"mcp__github_ci__download_job_log",
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
let allAllowedTools = baseTools.join(",");
|
let allAllowedTools = baseTools.join(",");
|
||||||
if (customAllowedTools && customAllowedTools.length > 0) {
|
if (customAllowedTools && customAllowedTools.length > 0) {
|
||||||
allAllowedTools = `${allAllowedTools},${customAllowedTools.join(",")}`;
|
allAllowedTools = `${allAllowedTools},${customAllowedTools.join(",")}`;
|
||||||
@@ -665,8 +677,12 @@ export async function createPrompt(
|
|||||||
);
|
);
|
||||||
|
|
||||||
// Set allowed tools
|
// Set allowed tools
|
||||||
|
const hasActionsReadPermission =
|
||||||
|
context.inputs.additionalPermissions.get("actions") === "read" &&
|
||||||
|
context.isPR;
|
||||||
const allAllowedTools = buildAllowedToolsString(
|
const allAllowedTools = buildAllowedToolsString(
|
||||||
context.inputs.allowedTools,
|
context.inputs.allowedTools,
|
||||||
|
hasActionsReadPermission,
|
||||||
);
|
);
|
||||||
const allDisallowedTools = buildDisallowedToolsString(
|
const allDisallowedTools = buildDisallowedToolsString(
|
||||||
context.inputs.disallowedTools,
|
context.inputs.disallowedTools,
|
||||||
|
|||||||
461
src/entrypoints/format-turns.ts
Executable file
461
src/entrypoints/format-turns.ts
Executable file
@@ -0,0 +1,461 @@
|
|||||||
|
#!/usr/bin/env bun
|
||||||
|
|
||||||
|
import { readFileSync, existsSync } from "fs";
|
||||||
|
import { exit } from "process";
|
||||||
|
|
||||||
|
export interface ToolUse {
|
||||||
|
type: string;
|
||||||
|
name?: string;
|
||||||
|
input?: Record<string, any>;
|
||||||
|
id?: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface ToolResult {
|
||||||
|
type: string;
|
||||||
|
tool_use_id?: string;
|
||||||
|
content?: any;
|
||||||
|
is_error?: boolean;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface ContentItem {
|
||||||
|
type: string;
|
||||||
|
text?: string;
|
||||||
|
tool_use_id?: string;
|
||||||
|
content?: any;
|
||||||
|
is_error?: boolean;
|
||||||
|
name?: string;
|
||||||
|
input?: Record<string, any>;
|
||||||
|
id?: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface Message {
|
||||||
|
content: ContentItem[];
|
||||||
|
usage?: {
|
||||||
|
input_tokens?: number;
|
||||||
|
output_tokens?: number;
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface Turn {
|
||||||
|
type: string;
|
||||||
|
subtype?: string;
|
||||||
|
message?: Message;
|
||||||
|
tools?: any[];
|
||||||
|
cost_usd?: number;
|
||||||
|
duration_ms?: number;
|
||||||
|
result?: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface GroupedContent {
|
||||||
|
type: string;
|
||||||
|
tools_count?: number;
|
||||||
|
data?: Turn;
|
||||||
|
text_parts?: string[];
|
||||||
|
tool_calls?: { tool_use: ToolUse; tool_result?: ToolResult }[];
|
||||||
|
usage?: Record<string, number>;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function detectContentType(content: any): string {
|
||||||
|
const contentStr = String(content).trim();
|
||||||
|
|
||||||
|
// Check for JSON
|
||||||
|
if (contentStr.startsWith("{") && contentStr.endsWith("}")) {
|
||||||
|
try {
|
||||||
|
JSON.parse(contentStr);
|
||||||
|
return "json";
|
||||||
|
} catch {
|
||||||
|
// Fall through
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (contentStr.startsWith("[") && contentStr.endsWith("]")) {
|
||||||
|
try {
|
||||||
|
JSON.parse(contentStr);
|
||||||
|
return "json";
|
||||||
|
} catch {
|
||||||
|
// Fall through
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Check for code-like content
|
||||||
|
const codeKeywords = [
|
||||||
|
"def ",
|
||||||
|
"class ",
|
||||||
|
"import ",
|
||||||
|
"from ",
|
||||||
|
"function ",
|
||||||
|
"const ",
|
||||||
|
"let ",
|
||||||
|
"var ",
|
||||||
|
];
|
||||||
|
if (codeKeywords.some((keyword) => contentStr.includes(keyword))) {
|
||||||
|
if (
|
||||||
|
contentStr.includes("def ") ||
|
||||||
|
contentStr.includes("import ") ||
|
||||||
|
contentStr.includes("from ")
|
||||||
|
) {
|
||||||
|
return "python";
|
||||||
|
} else if (
|
||||||
|
["function ", "const ", "let ", "var ", "=>"].some((js) =>
|
||||||
|
contentStr.includes(js),
|
||||||
|
)
|
||||||
|
) {
|
||||||
|
return "javascript";
|
||||||
|
} else {
|
||||||
|
return "python"; // default for code
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Check for shell/bash output
|
||||||
|
const shellIndicators = ["ls -", "cd ", "mkdir ", "rm ", "$ ", "# "];
|
||||||
|
if (
|
||||||
|
contentStr.startsWith("/") ||
|
||||||
|
contentStr.includes("Error:") ||
|
||||||
|
contentStr.startsWith("total ") ||
|
||||||
|
shellIndicators.some((indicator) => contentStr.includes(indicator))
|
||||||
|
) {
|
||||||
|
return "bash";
|
||||||
|
}
|
||||||
|
|
||||||
|
// Check for diff format
|
||||||
|
if (
|
||||||
|
contentStr.startsWith("@@") ||
|
||||||
|
contentStr.includes("+++ ") ||
|
||||||
|
contentStr.includes("--- ")
|
||||||
|
) {
|
||||||
|
return "diff";
|
||||||
|
}
|
||||||
|
|
||||||
|
// Check for HTML/XML
|
||||||
|
if (contentStr.startsWith("<") && contentStr.endsWith(">")) {
|
||||||
|
return "html";
|
||||||
|
}
|
||||||
|
|
||||||
|
// Check for markdown
|
||||||
|
const mdIndicators = ["# ", "## ", "### ", "- ", "* ", "```"];
|
||||||
|
if (mdIndicators.some((indicator) => contentStr.includes(indicator))) {
|
||||||
|
return "markdown";
|
||||||
|
}
|
||||||
|
|
||||||
|
// Default to plain text
|
||||||
|
return "text";
|
||||||
|
}
|
||||||
|
|
||||||
|
export function formatResultContent(content: any): string {
|
||||||
|
if (!content) {
|
||||||
|
return "*(No output)*\n\n";
|
||||||
|
}
|
||||||
|
|
||||||
|
let contentStr: string;
|
||||||
|
|
||||||
|
// Check if content is a list with "type": "text" structure
|
||||||
|
try {
|
||||||
|
let parsedContent: any;
|
||||||
|
if (typeof content === "string") {
|
||||||
|
parsedContent = JSON.parse(content);
|
||||||
|
} else {
|
||||||
|
parsedContent = content;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (
|
||||||
|
Array.isArray(parsedContent) &&
|
||||||
|
parsedContent.length > 0 &&
|
||||||
|
typeof parsedContent[0] === "object" &&
|
||||||
|
parsedContent[0]?.type === "text"
|
||||||
|
) {
|
||||||
|
// Extract the text field from the first item
|
||||||
|
contentStr = parsedContent[0]?.text || "";
|
||||||
|
} else {
|
||||||
|
contentStr = String(content).trim();
|
||||||
|
}
|
||||||
|
} catch {
|
||||||
|
contentStr = String(content).trim();
|
||||||
|
}
|
||||||
|
|
||||||
|
// Truncate very long results
|
||||||
|
if (contentStr.length > 3000) {
|
||||||
|
contentStr = contentStr.substring(0, 2997) + "...";
|
||||||
|
}
|
||||||
|
|
||||||
|
// Detect content type
|
||||||
|
const contentType = detectContentType(contentStr);
|
||||||
|
|
||||||
|
// Handle JSON content specially - pretty print it
|
||||||
|
if (contentType === "json") {
|
||||||
|
try {
|
||||||
|
// Try to parse and pretty print JSON
|
||||||
|
const parsed = JSON.parse(contentStr);
|
||||||
|
contentStr = JSON.stringify(parsed, null, 2);
|
||||||
|
} catch {
|
||||||
|
// Keep original if parsing fails
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Format with appropriate syntax highlighting
|
||||||
|
if (
|
||||||
|
contentType === "text" &&
|
||||||
|
contentStr.length < 100 &&
|
||||||
|
!contentStr.includes("\n")
|
||||||
|
) {
|
||||||
|
// Short text results don't need code blocks
|
||||||
|
return `**→** ${contentStr}\n\n`;
|
||||||
|
} else {
|
||||||
|
return `**Result:**\n\`\`\`${contentType}\n${contentStr}\n\`\`\`\n\n`;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export function formatToolWithResult(
|
||||||
|
toolUse: ToolUse,
|
||||||
|
toolResult?: ToolResult,
|
||||||
|
): string {
|
||||||
|
const toolName = toolUse.name || "unknown_tool";
|
||||||
|
const toolInput = toolUse.input || {};
|
||||||
|
|
||||||
|
let result = `### 🔧 \`${toolName}\`\n\n`;
|
||||||
|
|
||||||
|
// Add parameters if they exist and are not empty
|
||||||
|
if (Object.keys(toolInput).length > 0) {
|
||||||
|
result += "**Parameters:**\n```json\n";
|
||||||
|
result += JSON.stringify(toolInput, null, 2);
|
||||||
|
result += "\n```\n\n";
|
||||||
|
}
|
||||||
|
|
||||||
|
// Add result if available
|
||||||
|
if (toolResult) {
|
||||||
|
const content = toolResult.content || "";
|
||||||
|
const isError = toolResult.is_error || false;
|
||||||
|
|
||||||
|
if (isError) {
|
||||||
|
result += `❌ **Error:** \`${content}\`\n\n`;
|
||||||
|
} else {
|
||||||
|
result += formatResultContent(content);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return result;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function groupTurnsNaturally(data: Turn[]): GroupedContent[] {
|
||||||
|
const groupedContent: GroupedContent[] = [];
|
||||||
|
const toolResultsMap = new Map<string, ToolResult>();
|
||||||
|
|
||||||
|
// First pass: collect all tool results by tool_use_id
|
||||||
|
for (const turn of data) {
|
||||||
|
if (turn.type === "user") {
|
||||||
|
const content = turn.message?.content || [];
|
||||||
|
for (const item of content) {
|
||||||
|
if (item.type === "tool_result" && item.tool_use_id) {
|
||||||
|
toolResultsMap.set(item.tool_use_id, {
|
||||||
|
type: item.type,
|
||||||
|
tool_use_id: item.tool_use_id,
|
||||||
|
content: item.content,
|
||||||
|
is_error: item.is_error,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Second pass: process turns and group naturally
|
||||||
|
for (const turn of data) {
|
||||||
|
const turnType = turn.type || "unknown";
|
||||||
|
|
||||||
|
if (turnType === "system") {
|
||||||
|
const subtype = turn.subtype || "";
|
||||||
|
if (subtype === "init") {
|
||||||
|
const tools = turn.tools || [];
|
||||||
|
groupedContent.push({
|
||||||
|
type: "system_init",
|
||||||
|
tools_count: tools.length,
|
||||||
|
});
|
||||||
|
} else {
|
||||||
|
groupedContent.push({
|
||||||
|
type: "system_other",
|
||||||
|
data: turn,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
} else if (turnType === "assistant") {
|
||||||
|
const message = turn.message || { content: [] };
|
||||||
|
const content = message.content || [];
|
||||||
|
const usage = message.usage || {};
|
||||||
|
|
||||||
|
// Process content items
|
||||||
|
const textParts: string[] = [];
|
||||||
|
const toolCalls: { tool_use: ToolUse; tool_result?: ToolResult }[] = [];
|
||||||
|
|
||||||
|
for (const item of content) {
|
||||||
|
const itemType = item.type || "";
|
||||||
|
|
||||||
|
if (itemType === "text") {
|
||||||
|
textParts.push(item.text || "");
|
||||||
|
} else if (itemType === "tool_use") {
|
||||||
|
const toolUseId = item.id;
|
||||||
|
const toolResult = toolUseId
|
||||||
|
? toolResultsMap.get(toolUseId)
|
||||||
|
: undefined;
|
||||||
|
toolCalls.push({
|
||||||
|
tool_use: {
|
||||||
|
type: item.type,
|
||||||
|
name: item.name,
|
||||||
|
input: item.input,
|
||||||
|
id: item.id,
|
||||||
|
},
|
||||||
|
tool_result: toolResult,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (textParts.length > 0 || toolCalls.length > 0) {
|
||||||
|
groupedContent.push({
|
||||||
|
type: "assistant_action",
|
||||||
|
text_parts: textParts,
|
||||||
|
tool_calls: toolCalls,
|
||||||
|
usage: usage,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
} else if (turnType === "user") {
|
||||||
|
// Handle user messages that aren't tool results
|
||||||
|
const message = turn.message || { content: [] };
|
||||||
|
const content = message.content || [];
|
||||||
|
const textParts: string[] = [];
|
||||||
|
|
||||||
|
for (const item of content) {
|
||||||
|
if (item.type === "text") {
|
||||||
|
textParts.push(item.text || "");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (textParts.length > 0) {
|
||||||
|
groupedContent.push({
|
||||||
|
type: "user_message",
|
||||||
|
text_parts: textParts,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
} else if (turnType === "result") {
|
||||||
|
groupedContent.push({
|
||||||
|
type: "final_result",
|
||||||
|
data: turn,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return groupedContent;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function formatGroupedContent(groupedContent: GroupedContent[]): string {
|
||||||
|
let markdown = "## Claude Code Report\n\n";
|
||||||
|
|
||||||
|
for (const item of groupedContent) {
|
||||||
|
const itemType = item.type;
|
||||||
|
|
||||||
|
if (itemType === "system_init") {
|
||||||
|
markdown += `## 🚀 System Initialization\n\n**Available Tools:** ${item.tools_count} tools loaded\n\n---\n\n`;
|
||||||
|
} else if (itemType === "system_other") {
|
||||||
|
markdown += `## ⚙️ System Message\n\n${JSON.stringify(item.data, null, 2)}\n\n---\n\n`;
|
||||||
|
} else if (itemType === "assistant_action") {
|
||||||
|
// Add text content first (if any) - no header needed
|
||||||
|
for (const text of item.text_parts || []) {
|
||||||
|
if (text.trim()) {
|
||||||
|
markdown += `${text}\n\n`;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Add tool calls with their results
|
||||||
|
for (const toolCall of item.tool_calls || []) {
|
||||||
|
markdown += formatToolWithResult(
|
||||||
|
toolCall.tool_use,
|
||||||
|
toolCall.tool_result,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Add usage info if available
|
||||||
|
const usage = item.usage || {};
|
||||||
|
if (Object.keys(usage).length > 0) {
|
||||||
|
const inputTokens = usage.input_tokens || 0;
|
||||||
|
const outputTokens = usage.output_tokens || 0;
|
||||||
|
markdown += `*Token usage: ${inputTokens} input, ${outputTokens} output*\n\n`;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Only add separator if this section had content
|
||||||
|
if (
|
||||||
|
(item.text_parts && item.text_parts.length > 0) ||
|
||||||
|
(item.tool_calls && item.tool_calls.length > 0)
|
||||||
|
) {
|
||||||
|
markdown += "---\n\n";
|
||||||
|
}
|
||||||
|
} else if (itemType === "user_message") {
|
||||||
|
markdown += "## 👤 User\n\n";
|
||||||
|
for (const text of item.text_parts || []) {
|
||||||
|
if (text.trim()) {
|
||||||
|
markdown += `${text}\n\n`;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
markdown += "---\n\n";
|
||||||
|
} else if (itemType === "final_result") {
|
||||||
|
const data = item.data || {};
|
||||||
|
const cost = (data as any).cost_usd || 0;
|
||||||
|
const duration = (data as any).duration_ms || 0;
|
||||||
|
const resultText = (data as any).result || "";
|
||||||
|
|
||||||
|
markdown += "## ✅ Final Result\n\n";
|
||||||
|
if (resultText) {
|
||||||
|
markdown += `${resultText}\n\n`;
|
||||||
|
}
|
||||||
|
markdown += `**Cost:** $${cost.toFixed(4)} | **Duration:** ${(duration / 1000).toFixed(1)}s\n\n`;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return markdown;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function formatTurnsFromData(data: Turn[]): string {
|
||||||
|
// Group turns naturally
|
||||||
|
const groupedContent = groupTurnsNaturally(data);
|
||||||
|
|
||||||
|
// Generate markdown
|
||||||
|
const markdown = formatGroupedContent(groupedContent);
|
||||||
|
|
||||||
|
return markdown;
|
||||||
|
}
|
||||||
|
|
||||||
|
function main(): void {
|
||||||
|
// Get the JSON file path from command line arguments
|
||||||
|
const args = process.argv.slice(2);
|
||||||
|
if (args.length === 0) {
|
||||||
|
console.error("Usage: format-turns.ts <json-file>");
|
||||||
|
exit(1);
|
||||||
|
}
|
||||||
|
|
||||||
|
const jsonFile = args[0];
|
||||||
|
if (!jsonFile) {
|
||||||
|
console.error("Error: No JSON file provided");
|
||||||
|
exit(1);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!existsSync(jsonFile)) {
|
||||||
|
console.error(`Error: ${jsonFile} not found`);
|
||||||
|
exit(1);
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
// Read the JSON file
|
||||||
|
const fileContent = readFileSync(jsonFile, "utf-8");
|
||||||
|
const data: Turn[] = JSON.parse(fileContent);
|
||||||
|
|
||||||
|
// Group turns naturally
|
||||||
|
const groupedContent = groupTurnsNaturally(data);
|
||||||
|
|
||||||
|
// Generate markdown
|
||||||
|
const markdown = formatGroupedContent(groupedContent);
|
||||||
|
|
||||||
|
// Print to stdout (so it can be captured by shell)
|
||||||
|
console.log(markdown);
|
||||||
|
} catch (error) {
|
||||||
|
console.error(`Error processing file: ${error}`);
|
||||||
|
exit(1);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (import.meta.main) {
|
||||||
|
main();
|
||||||
|
}
|
||||||
@@ -94,6 +94,7 @@ async function run() {
|
|||||||
additionalMcpConfig,
|
additionalMcpConfig,
|
||||||
claudeCommentId: commentId.toString(),
|
claudeCommentId: commentId.toString(),
|
||||||
allowedTools: context.inputs.allowedTools,
|
allowedTools: context.inputs.allowedTools,
|
||||||
|
context,
|
||||||
});
|
});
|
||||||
core.setOutput("mcp_config", mcpConfig);
|
core.setOutput("mcp_config", mcpConfig);
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
|
|||||||
@@ -37,6 +37,7 @@ export type ParsedGitHubContext = {
|
|||||||
baseBranch?: string;
|
baseBranch?: string;
|
||||||
branchPrefix: string;
|
branchPrefix: string;
|
||||||
useStickyComment: boolean;
|
useStickyComment: boolean;
|
||||||
|
additionalPermissions: Map<string, string>;
|
||||||
};
|
};
|
||||||
};
|
};
|
||||||
|
|
||||||
@@ -63,7 +64,10 @@ export function parseGitHubContext(): ParsedGitHubContext {
|
|||||||
directPrompt: process.env.DIRECT_PROMPT ?? "",
|
directPrompt: process.env.DIRECT_PROMPT ?? "",
|
||||||
baseBranch: process.env.BASE_BRANCH,
|
baseBranch: process.env.BASE_BRANCH,
|
||||||
branchPrefix: process.env.BRANCH_PREFIX ?? "claude/",
|
branchPrefix: process.env.BRANCH_PREFIX ?? "claude/",
|
||||||
useStickyComment: process.env.STICKY_COMMENT === "true",
|
useStickyComment: process.env.USE_STICKY_COMMENT === "true",
|
||||||
|
additionalPermissions: parseAdditionalPermissions(
|
||||||
|
process.env.ADDITIONAL_PERMISSIONS ?? "",
|
||||||
|
),
|
||||||
},
|
},
|
||||||
};
|
};
|
||||||
|
|
||||||
@@ -125,6 +129,25 @@ export function parseMultilineInput(s: string): string[] {
|
|||||||
.filter((tool) => tool.length > 0);
|
.filter((tool) => tool.length > 0);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export function parseAdditionalPermissions(s: string): Map<string, string> {
|
||||||
|
const permissions = new Map<string, string>();
|
||||||
|
if (!s || !s.trim()) {
|
||||||
|
return permissions;
|
||||||
|
}
|
||||||
|
|
||||||
|
const lines = s.trim().split("\n");
|
||||||
|
for (const line of lines) {
|
||||||
|
const trimmedLine = line.trim();
|
||||||
|
if (trimmedLine) {
|
||||||
|
const [key, value] = trimmedLine.split(":").map((part) => part.trim());
|
||||||
|
if (key && value) {
|
||||||
|
permissions.set(key, value);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return permissions;
|
||||||
|
}
|
||||||
|
|
||||||
export function isIssuesEvent(
|
export function isIssuesEvent(
|
||||||
context: ParsedGitHubContext,
|
context: ParsedGitHubContext,
|
||||||
): context is ParsedGitHubContext & { payload: IssuesEvent } {
|
): context is ParsedGitHubContext & { payload: IssuesEvent } {
|
||||||
|
|||||||
@@ -29,13 +29,18 @@ export async function createInitialComment(
|
|||||||
if (
|
if (
|
||||||
context.inputs.useStickyComment &&
|
context.inputs.useStickyComment &&
|
||||||
context.isPR &&
|
context.isPR &&
|
||||||
!isPullRequestEvent(context)
|
isPullRequestEvent(context)
|
||||||
) {
|
) {
|
||||||
const comments = await octokit.rest.issues.listComments({
|
const comments = await octokit.rest.issues.listComments({
|
||||||
owner,
|
owner,
|
||||||
repo,
|
repo,
|
||||||
issue_number: context.entityNumber,
|
issue_number: context.entityNumber,
|
||||||
});
|
});
|
||||||
|
console.log("users");
|
||||||
|
comments.data.forEach((comment) => {
|
||||||
|
console.log(comment.user);
|
||||||
|
});
|
||||||
|
|
||||||
const existingComment = comments.data.find(
|
const existingComment = comments.data.find(
|
||||||
(comment) =>
|
(comment) =>
|
||||||
comment.user?.login.indexOf("claude[bot]") !== -1 ||
|
comment.user?.login.indexOf("claude[bot]") !== -1 ||
|
||||||
|
|||||||
@@ -1,47 +1,7 @@
|
|||||||
#!/usr/bin/env bun
|
#!/usr/bin/env bun
|
||||||
|
|
||||||
import * as core from "@actions/core";
|
import * as core from "@actions/core";
|
||||||
|
import { retryWithBackoff } from "../utils/retry";
|
||||||
type RetryOptions = {
|
|
||||||
maxAttempts?: number;
|
|
||||||
initialDelayMs?: number;
|
|
||||||
maxDelayMs?: number;
|
|
||||||
backoffFactor?: number;
|
|
||||||
};
|
|
||||||
|
|
||||||
async function retryWithBackoff<T>(
|
|
||||||
operation: () => Promise<T>,
|
|
||||||
options: RetryOptions = {},
|
|
||||||
): Promise<T> {
|
|
||||||
const {
|
|
||||||
maxAttempts = 3,
|
|
||||||
initialDelayMs = 5000,
|
|
||||||
maxDelayMs = 20000,
|
|
||||||
backoffFactor = 2,
|
|
||||||
} = options;
|
|
||||||
|
|
||||||
let delayMs = initialDelayMs;
|
|
||||||
let lastError: Error | undefined;
|
|
||||||
|
|
||||||
for (let attempt = 1; attempt <= maxAttempts; attempt++) {
|
|
||||||
try {
|
|
||||||
console.log(`Attempt ${attempt} of ${maxAttempts}...`);
|
|
||||||
return await operation();
|
|
||||||
} catch (error) {
|
|
||||||
lastError = error instanceof Error ? error : new Error(String(error));
|
|
||||||
console.error(`Attempt ${attempt} failed:`, lastError.message);
|
|
||||||
|
|
||||||
if (attempt < maxAttempts) {
|
|
||||||
console.log(`Retrying in ${delayMs / 1000} seconds...`);
|
|
||||||
await new Promise((resolve) => setTimeout(resolve, delayMs));
|
|
||||||
delayMs = Math.min(delayMs * backoffFactor, maxDelayMs);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
console.error(`Operation failed after ${maxAttempts} attempts`);
|
|
||||||
throw lastError;
|
|
||||||
}
|
|
||||||
|
|
||||||
async function getOidcToken(): Promise<string> {
|
async function getOidcToken(): Promise<string> {
|
||||||
try {
|
try {
|
||||||
|
|||||||
275
src/mcp/github-actions-server.ts
Normal file
275
src/mcp/github-actions-server.ts
Normal file
@@ -0,0 +1,275 @@
|
|||||||
|
#!/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 { mkdir, writeFile } from "fs/promises";
|
||||||
|
import { Octokit } from "@octokit/rest";
|
||||||
|
|
||||||
|
const REPO_OWNER = process.env.REPO_OWNER;
|
||||||
|
const REPO_NAME = process.env.REPO_NAME;
|
||||||
|
const PR_NUMBER = process.env.PR_NUMBER;
|
||||||
|
const GITHUB_TOKEN = process.env.GITHUB_TOKEN;
|
||||||
|
const RUNNER_TEMP = process.env.RUNNER_TEMP || "/tmp";
|
||||||
|
|
||||||
|
if (!REPO_OWNER || !REPO_NAME || !PR_NUMBER || !GITHUB_TOKEN) {
|
||||||
|
console.error(
|
||||||
|
"[GitHub CI Server] Error: REPO_OWNER, REPO_NAME, PR_NUMBER, and GITHUB_TOKEN environment variables are required",
|
||||||
|
);
|
||||||
|
process.exit(1);
|
||||||
|
}
|
||||||
|
|
||||||
|
const server = new McpServer({
|
||||||
|
name: "GitHub CI Server",
|
||||||
|
version: "0.0.1",
|
||||||
|
});
|
||||||
|
|
||||||
|
console.error("[GitHub CI Server] MCP Server instance created");
|
||||||
|
|
||||||
|
server.tool(
|
||||||
|
"get_ci_status",
|
||||||
|
"Get CI status summary for this PR",
|
||||||
|
{
|
||||||
|
status: z
|
||||||
|
.enum([
|
||||||
|
"completed",
|
||||||
|
"action_required",
|
||||||
|
"cancelled",
|
||||||
|
"failure",
|
||||||
|
"neutral",
|
||||||
|
"skipped",
|
||||||
|
"stale",
|
||||||
|
"success",
|
||||||
|
"timed_out",
|
||||||
|
"in_progress",
|
||||||
|
"queued",
|
||||||
|
"requested",
|
||||||
|
"waiting",
|
||||||
|
"pending",
|
||||||
|
])
|
||||||
|
.optional()
|
||||||
|
.describe("Filter workflow runs by status"),
|
||||||
|
},
|
||||||
|
async ({ status }) => {
|
||||||
|
try {
|
||||||
|
const client = new Octokit({
|
||||||
|
auth: GITHUB_TOKEN,
|
||||||
|
});
|
||||||
|
|
||||||
|
// Get the PR to find the head SHA
|
||||||
|
const { data: prData } = await client.pulls.get({
|
||||||
|
owner: REPO_OWNER!,
|
||||||
|
repo: REPO_NAME!,
|
||||||
|
pull_number: parseInt(PR_NUMBER!, 10),
|
||||||
|
});
|
||||||
|
const headSha = prData.head.sha;
|
||||||
|
|
||||||
|
const { data: runsData } = await client.actions.listWorkflowRunsForRepo({
|
||||||
|
owner: REPO_OWNER!,
|
||||||
|
repo: REPO_NAME!,
|
||||||
|
head_sha: headSha,
|
||||||
|
...(status && { status }),
|
||||||
|
});
|
||||||
|
|
||||||
|
// Process runs to create summary
|
||||||
|
const runs = runsData.workflow_runs || [];
|
||||||
|
const summary = {
|
||||||
|
total_runs: runs.length,
|
||||||
|
failed: 0,
|
||||||
|
passed: 0,
|
||||||
|
pending: 0,
|
||||||
|
};
|
||||||
|
|
||||||
|
const processedRuns = runs.map((run: any) => {
|
||||||
|
// Update summary counts
|
||||||
|
if (run.status === "completed") {
|
||||||
|
if (run.conclusion === "success") {
|
||||||
|
summary.passed++;
|
||||||
|
} else if (run.conclusion === "failure") {
|
||||||
|
summary.failed++;
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
summary.pending++;
|
||||||
|
}
|
||||||
|
|
||||||
|
return {
|
||||||
|
id: run.id,
|
||||||
|
name: run.name,
|
||||||
|
status: run.status,
|
||||||
|
conclusion: run.conclusion,
|
||||||
|
html_url: run.html_url,
|
||||||
|
created_at: run.created_at,
|
||||||
|
};
|
||||||
|
});
|
||||||
|
|
||||||
|
const result = {
|
||||||
|
summary,
|
||||||
|
runs: processedRuns,
|
||||||
|
};
|
||||||
|
|
||||||
|
return {
|
||||||
|
content: [
|
||||||
|
{
|
||||||
|
type: "text",
|
||||||
|
text: JSON.stringify(result, null, 2),
|
||||||
|
},
|
||||||
|
],
|
||||||
|
};
|
||||||
|
} catch (error) {
|
||||||
|
const errorMessage =
|
||||||
|
error instanceof Error ? error.message : String(error);
|
||||||
|
return {
|
||||||
|
content: [
|
||||||
|
{
|
||||||
|
type: "text",
|
||||||
|
text: `Error: ${errorMessage}`,
|
||||||
|
},
|
||||||
|
],
|
||||||
|
error: errorMessage,
|
||||||
|
isError: true,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
},
|
||||||
|
);
|
||||||
|
|
||||||
|
server.tool(
|
||||||
|
"get_workflow_run_details",
|
||||||
|
"Get job and step details for a workflow run",
|
||||||
|
{
|
||||||
|
run_id: z.number().describe("The workflow run ID"),
|
||||||
|
},
|
||||||
|
async ({ run_id }) => {
|
||||||
|
try {
|
||||||
|
const client = new Octokit({
|
||||||
|
auth: GITHUB_TOKEN,
|
||||||
|
});
|
||||||
|
|
||||||
|
// Get jobs for this workflow run
|
||||||
|
const { data: jobsData } = await client.actions.listJobsForWorkflowRun({
|
||||||
|
owner: REPO_OWNER!,
|
||||||
|
repo: REPO_NAME!,
|
||||||
|
run_id,
|
||||||
|
});
|
||||||
|
|
||||||
|
const processedJobs = jobsData.jobs.map((job: any) => {
|
||||||
|
// Extract failed steps
|
||||||
|
const failedSteps = (job.steps || [])
|
||||||
|
.filter((step: any) => step.conclusion === "failure")
|
||||||
|
.map((step: any) => ({
|
||||||
|
name: step.name,
|
||||||
|
number: step.number,
|
||||||
|
}));
|
||||||
|
|
||||||
|
return {
|
||||||
|
id: job.id,
|
||||||
|
name: job.name,
|
||||||
|
conclusion: job.conclusion,
|
||||||
|
html_url: job.html_url,
|
||||||
|
failed_steps: failedSteps,
|
||||||
|
};
|
||||||
|
});
|
||||||
|
|
||||||
|
const result = {
|
||||||
|
jobs: processedJobs,
|
||||||
|
};
|
||||||
|
|
||||||
|
return {
|
||||||
|
content: [
|
||||||
|
{
|
||||||
|
type: "text",
|
||||||
|
text: JSON.stringify(result, null, 2),
|
||||||
|
},
|
||||||
|
],
|
||||||
|
};
|
||||||
|
} catch (error) {
|
||||||
|
const errorMessage =
|
||||||
|
error instanceof Error ? error.message : String(error);
|
||||||
|
|
||||||
|
return {
|
||||||
|
content: [
|
||||||
|
{
|
||||||
|
type: "text",
|
||||||
|
text: `Error: ${errorMessage}`,
|
||||||
|
},
|
||||||
|
],
|
||||||
|
error: errorMessage,
|
||||||
|
isError: true,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
},
|
||||||
|
);
|
||||||
|
|
||||||
|
server.tool(
|
||||||
|
"download_job_log",
|
||||||
|
"Download job logs to disk",
|
||||||
|
{
|
||||||
|
job_id: z.number().describe("The job ID"),
|
||||||
|
},
|
||||||
|
async ({ job_id }) => {
|
||||||
|
try {
|
||||||
|
const client = new Octokit({
|
||||||
|
auth: GITHUB_TOKEN,
|
||||||
|
});
|
||||||
|
|
||||||
|
const response = await client.actions.downloadJobLogsForWorkflowRun({
|
||||||
|
owner: REPO_OWNER!,
|
||||||
|
repo: REPO_NAME!,
|
||||||
|
job_id,
|
||||||
|
});
|
||||||
|
|
||||||
|
const logsText = response.data as unknown as string;
|
||||||
|
|
||||||
|
const logsDir = `${RUNNER_TEMP}/github-ci-logs`;
|
||||||
|
await mkdir(logsDir, { recursive: true });
|
||||||
|
|
||||||
|
const logPath = `${logsDir}/job-${job_id}.log`;
|
||||||
|
await writeFile(logPath, logsText, "utf-8");
|
||||||
|
|
||||||
|
const result = {
|
||||||
|
path: logPath,
|
||||||
|
size_bytes: Buffer.byteLength(logsText, "utf-8"),
|
||||||
|
};
|
||||||
|
|
||||||
|
return {
|
||||||
|
content: [
|
||||||
|
{
|
||||||
|
type: "text",
|
||||||
|
text: JSON.stringify(result, null, 2),
|
||||||
|
},
|
||||||
|
],
|
||||||
|
};
|
||||||
|
} catch (error) {
|
||||||
|
const errorMessage =
|
||||||
|
error instanceof Error ? error.message : String(error);
|
||||||
|
|
||||||
|
return {
|
||||||
|
content: [
|
||||||
|
{
|
||||||
|
type: "text",
|
||||||
|
text: `Error: ${errorMessage}`,
|
||||||
|
},
|
||||||
|
],
|
||||||
|
error: errorMessage,
|
||||||
|
isError: true,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
},
|
||||||
|
);
|
||||||
|
|
||||||
|
async function runServer() {
|
||||||
|
try {
|
||||||
|
const transport = new StdioServerTransport();
|
||||||
|
|
||||||
|
await server.connect(transport);
|
||||||
|
|
||||||
|
process.on("exit", () => {
|
||||||
|
server.close();
|
||||||
|
});
|
||||||
|
} catch (error) {
|
||||||
|
throw error;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
runServer().catch(() => {
|
||||||
|
process.exit(1);
|
||||||
|
});
|
||||||
@@ -9,6 +9,7 @@ import fetch from "node-fetch";
|
|||||||
import { GITHUB_API_URL } from "../github/api/config";
|
import { GITHUB_API_URL } from "../github/api/config";
|
||||||
import { Octokit } from "@octokit/rest";
|
import { Octokit } from "@octokit/rest";
|
||||||
import { updateClaudeComment } from "../github/operations/comments/update-claude-comment";
|
import { updateClaudeComment } from "../github/operations/comments/update-claude-comment";
|
||||||
|
import { retryWithBackoff } from "../utils/retry";
|
||||||
|
|
||||||
type GitHubRef = {
|
type GitHubRef = {
|
||||||
object: {
|
object: {
|
||||||
@@ -233,6 +234,12 @@ server.tool(
|
|||||||
|
|
||||||
// 6. Update the reference to point to the new commit
|
// 6. Update the reference to point to the new commit
|
||||||
const updateRefUrl = `${GITHUB_API_URL}/repos/${owner}/${repo}/git/refs/heads/${branch}`;
|
const updateRefUrl = `${GITHUB_API_URL}/repos/${owner}/${repo}/git/refs/heads/${branch}`;
|
||||||
|
|
||||||
|
// We're seeing intermittent 403 "Resource not accessible by integration" errors
|
||||||
|
// on certain repos when updating git references. These appear to be transient
|
||||||
|
// GitHub API issues that succeed on retry.
|
||||||
|
await retryWithBackoff(
|
||||||
|
async () => {
|
||||||
const updateRefResponse = await fetch(updateRefUrl, {
|
const updateRefResponse = await fetch(updateRefUrl, {
|
||||||
method: "PATCH",
|
method: "PATCH",
|
||||||
headers: {
|
headers: {
|
||||||
@@ -249,11 +256,29 @@ server.tool(
|
|||||||
|
|
||||||
if (!updateRefResponse.ok) {
|
if (!updateRefResponse.ok) {
|
||||||
const errorText = await updateRefResponse.text();
|
const errorText = await updateRefResponse.text();
|
||||||
throw new Error(
|
const error = new Error(
|
||||||
`Failed to update reference: ${updateRefResponse.status} - ${errorText}`,
|
`Failed to update reference: ${updateRefResponse.status} - ${errorText}`,
|
||||||
);
|
);
|
||||||
|
|
||||||
|
// Only retry on 403 errors - these are the intermittent failures we're targeting
|
||||||
|
if (updateRefResponse.status === 403) {
|
||||||
|
console.log("Received 403 error, will retry...");
|
||||||
|
throw error;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// For non-403 errors, fail immediately without retry
|
||||||
|
console.error("Non-retryable error:", updateRefResponse.status);
|
||||||
|
throw error;
|
||||||
|
}
|
||||||
|
},
|
||||||
|
{
|
||||||
|
maxAttempts: 3,
|
||||||
|
initialDelayMs: 1000, // Start with 1 second delay
|
||||||
|
maxDelayMs: 5000, // Max 5 seconds delay
|
||||||
|
backoffFactor: 2, // Double the delay each time
|
||||||
|
},
|
||||||
|
);
|
||||||
|
|
||||||
const simplifiedResult = {
|
const simplifiedResult = {
|
||||||
commit: {
|
commit: {
|
||||||
sha: newCommitData.sha,
|
sha: newCommitData.sha,
|
||||||
@@ -427,6 +452,12 @@ server.tool(
|
|||||||
|
|
||||||
// 6. Update the reference to point to the new commit
|
// 6. Update the reference to point to the new commit
|
||||||
const updateRefUrl = `${GITHUB_API_URL}/repos/${owner}/${repo}/git/refs/heads/${branch}`;
|
const updateRefUrl = `${GITHUB_API_URL}/repos/${owner}/${repo}/git/refs/heads/${branch}`;
|
||||||
|
|
||||||
|
// We're seeing intermittent 403 "Resource not accessible by integration" errors
|
||||||
|
// on certain repos when updating git references. These appear to be transient
|
||||||
|
// GitHub API issues that succeed on retry.
|
||||||
|
await retryWithBackoff(
|
||||||
|
async () => {
|
||||||
const updateRefResponse = await fetch(updateRefUrl, {
|
const updateRefResponse = await fetch(updateRefUrl, {
|
||||||
method: "PATCH",
|
method: "PATCH",
|
||||||
headers: {
|
headers: {
|
||||||
@@ -443,11 +474,29 @@ server.tool(
|
|||||||
|
|
||||||
if (!updateRefResponse.ok) {
|
if (!updateRefResponse.ok) {
|
||||||
const errorText = await updateRefResponse.text();
|
const errorText = await updateRefResponse.text();
|
||||||
throw new Error(
|
const error = new Error(
|
||||||
`Failed to update reference: ${updateRefResponse.status} - ${errorText}`,
|
`Failed to update reference: ${updateRefResponse.status} - ${errorText}`,
|
||||||
);
|
);
|
||||||
|
|
||||||
|
// Only retry on 403 errors - these are the intermittent failures we're targeting
|
||||||
|
if (updateRefResponse.status === 403) {
|
||||||
|
console.log("Received 403 error, will retry...");
|
||||||
|
throw error;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// For non-403 errors, fail immediately without retry
|
||||||
|
console.error("Non-retryable error:", updateRefResponse.status);
|
||||||
|
throw error;
|
||||||
|
}
|
||||||
|
},
|
||||||
|
{
|
||||||
|
maxAttempts: 3,
|
||||||
|
initialDelayMs: 1000, // Start with 1 second delay
|
||||||
|
maxDelayMs: 5000, // Max 5 seconds delay
|
||||||
|
backoffFactor: 2, // Double the delay each time
|
||||||
|
},
|
||||||
|
);
|
||||||
|
|
||||||
const simplifiedResult = {
|
const simplifiedResult = {
|
||||||
commit: {
|
commit: {
|
||||||
sha: newCommitData.sha,
|
sha: newCommitData.sha,
|
||||||
|
|||||||
@@ -1,5 +1,7 @@
|
|||||||
import * as core from "@actions/core";
|
import * as core from "@actions/core";
|
||||||
import { GITHUB_API_URL } from "../github/api/config";
|
import { GITHUB_API_URL } from "../github/api/config";
|
||||||
|
import type { ParsedGitHubContext } from "../github/context";
|
||||||
|
import { Octokit } from "@octokit/rest";
|
||||||
|
|
||||||
type PrepareConfigParams = {
|
type PrepareConfigParams = {
|
||||||
githubToken: string;
|
githubToken: string;
|
||||||
@@ -9,8 +11,41 @@ type PrepareConfigParams = {
|
|||||||
additionalMcpConfig?: string;
|
additionalMcpConfig?: string;
|
||||||
claudeCommentId?: string;
|
claudeCommentId?: string;
|
||||||
allowedTools: string[];
|
allowedTools: string[];
|
||||||
|
context: ParsedGitHubContext;
|
||||||
};
|
};
|
||||||
|
|
||||||
|
async function checkActionsReadPermission(
|
||||||
|
token: string,
|
||||||
|
owner: string,
|
||||||
|
repo: string,
|
||||||
|
): Promise<boolean> {
|
||||||
|
try {
|
||||||
|
const client = new Octokit({ auth: token });
|
||||||
|
|
||||||
|
// Try to list workflow runs - this requires actions:read
|
||||||
|
// We use per_page=1 to minimize the response size
|
||||||
|
await client.actions.listWorkflowRunsForRepo({
|
||||||
|
owner,
|
||||||
|
repo,
|
||||||
|
per_page: 1,
|
||||||
|
});
|
||||||
|
|
||||||
|
return true;
|
||||||
|
} catch (error: any) {
|
||||||
|
// Check if it's a permission error
|
||||||
|
if (
|
||||||
|
error.status === 403 &&
|
||||||
|
error.message?.includes("Resource not accessible")
|
||||||
|
) {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
// For other errors (network issues, etc), log but don't fail
|
||||||
|
core.debug(`Failed to check actions permission: ${error.message}`);
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
export async function prepareMcpConfig(
|
export async function prepareMcpConfig(
|
||||||
params: PrepareConfigParams,
|
params: PrepareConfigParams,
|
||||||
): Promise<string> {
|
): Promise<string> {
|
||||||
@@ -22,6 +57,7 @@ export async function prepareMcpConfig(
|
|||||||
additionalMcpConfig,
|
additionalMcpConfig,
|
||||||
claudeCommentId,
|
claudeCommentId,
|
||||||
allowedTools,
|
allowedTools,
|
||||||
|
context,
|
||||||
} = params;
|
} = params;
|
||||||
try {
|
try {
|
||||||
const allowedToolsList = allowedTools || [];
|
const allowedToolsList = allowedTools || [];
|
||||||
@@ -53,6 +89,42 @@ export async function prepareMcpConfig(
|
|||||||
},
|
},
|
||||||
};
|
};
|
||||||
|
|
||||||
|
// Only add CI server if we have actions:read permission and we're in a PR context
|
||||||
|
const hasActionsReadPermission =
|
||||||
|
context.inputs.additionalPermissions.get("actions") === "read";
|
||||||
|
|
||||||
|
if (context.isPR && hasActionsReadPermission) {
|
||||||
|
// Verify the token actually has actions:read permission
|
||||||
|
const actuallyHasPermission = await checkActionsReadPermission(
|
||||||
|
process.env.ACTIONS_TOKEN || "",
|
||||||
|
owner,
|
||||||
|
repo,
|
||||||
|
);
|
||||||
|
|
||||||
|
if (!actuallyHasPermission) {
|
||||||
|
core.warning(
|
||||||
|
"The github_ci MCP server requires 'actions: read' permission. " +
|
||||||
|
"Please ensure your GitHub token has this permission. " +
|
||||||
|
"See: https://docs.github.com/en/actions/security-guides/automatic-token-authentication#permissions-for-the-github_token",
|
||||||
|
);
|
||||||
|
}
|
||||||
|
baseMcpConfig.mcpServers.github_ci = {
|
||||||
|
command: "bun",
|
||||||
|
args: [
|
||||||
|
"run",
|
||||||
|
`${process.env.GITHUB_ACTION_PATH}/src/mcp/github-actions-server.ts`,
|
||||||
|
],
|
||||||
|
env: {
|
||||||
|
// Use workflow github token, not app token
|
||||||
|
GITHUB_TOKEN: process.env.ACTIONS_TOKEN,
|
||||||
|
REPO_OWNER: owner,
|
||||||
|
REPO_NAME: repo,
|
||||||
|
PR_NUMBER: context.entityNumber.toString(),
|
||||||
|
RUNNER_TEMP: process.env.RUNNER_TEMP || "/tmp",
|
||||||
|
},
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
if (hasGitHubMcpTools) {
|
if (hasGitHubMcpTools) {
|
||||||
baseMcpConfig.mcpServers.github = {
|
baseMcpConfig.mcpServers.github = {
|
||||||
command: "docker",
|
command: "docker",
|
||||||
|
|||||||
40
src/utils/retry.ts
Normal file
40
src/utils/retry.ts
Normal file
@@ -0,0 +1,40 @@
|
|||||||
|
export type RetryOptions = {
|
||||||
|
maxAttempts?: number;
|
||||||
|
initialDelayMs?: number;
|
||||||
|
maxDelayMs?: number;
|
||||||
|
backoffFactor?: number;
|
||||||
|
};
|
||||||
|
|
||||||
|
export async function retryWithBackoff<T>(
|
||||||
|
operation: () => Promise<T>,
|
||||||
|
options: RetryOptions = {},
|
||||||
|
): Promise<T> {
|
||||||
|
const {
|
||||||
|
maxAttempts = 3,
|
||||||
|
initialDelayMs = 5000,
|
||||||
|
maxDelayMs = 20000,
|
||||||
|
backoffFactor = 2,
|
||||||
|
} = options;
|
||||||
|
|
||||||
|
let delayMs = initialDelayMs;
|
||||||
|
let lastError: Error | undefined;
|
||||||
|
|
||||||
|
for (let attempt = 1; attempt <= maxAttempts; attempt++) {
|
||||||
|
try {
|
||||||
|
console.log(`Attempt ${attempt} of ${maxAttempts}...`);
|
||||||
|
return await operation();
|
||||||
|
} catch (error) {
|
||||||
|
lastError = error instanceof Error ? error : new Error(String(error));
|
||||||
|
console.error(`Attempt ${attempt} failed:`, lastError.message);
|
||||||
|
|
||||||
|
if (attempt < maxAttempts) {
|
||||||
|
console.log(`Retrying in ${delayMs / 1000} seconds...`);
|
||||||
|
await new Promise((resolve) => setTimeout(resolve, delayMs));
|
||||||
|
delayMs = Math.min(delayMs * backoffFactor, maxDelayMs);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
console.error(`Operation failed after ${maxAttempts} attempts`);
|
||||||
|
throw lastError;
|
||||||
|
}
|
||||||
@@ -743,6 +743,36 @@ describe("buildAllowedToolsString", () => {
|
|||||||
expect(basePlusCustom).toContain("Tool2");
|
expect(basePlusCustom).toContain("Tool2");
|
||||||
expect(basePlusCustom).toContain("Tool3");
|
expect(basePlusCustom).toContain("Tool3");
|
||||||
});
|
});
|
||||||
|
|
||||||
|
test("should include GitHub Actions tools when includeActionsTools is true", () => {
|
||||||
|
const result = buildAllowedToolsString([], true);
|
||||||
|
|
||||||
|
// Base tools should be present
|
||||||
|
expect(result).toContain("Edit");
|
||||||
|
expect(result).toContain("Glob");
|
||||||
|
|
||||||
|
// GitHub Actions tools should be included
|
||||||
|
expect(result).toContain("mcp__github_ci__get_ci_status");
|
||||||
|
expect(result).toContain("mcp__github_ci__get_workflow_run_details");
|
||||||
|
expect(result).toContain("mcp__github_ci__download_job_log");
|
||||||
|
});
|
||||||
|
|
||||||
|
test("should include both custom and Actions tools when both provided", () => {
|
||||||
|
const customTools = ["Tool1", "Tool2"];
|
||||||
|
const result = buildAllowedToolsString(customTools, true);
|
||||||
|
|
||||||
|
// Base tools should be present
|
||||||
|
expect(result).toContain("Edit");
|
||||||
|
|
||||||
|
// Custom tools should be included
|
||||||
|
expect(result).toContain("Tool1");
|
||||||
|
expect(result).toContain("Tool2");
|
||||||
|
|
||||||
|
// GitHub Actions tools should be included
|
||||||
|
expect(result).toContain("mcp__github_ci__get_ci_status");
|
||||||
|
expect(result).toContain("mcp__github_ci__get_workflow_run_details");
|
||||||
|
expect(result).toContain("mcp__github_ci__download_job_log");
|
||||||
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
describe("buildDisallowedToolsString", () => {
|
describe("buildDisallowedToolsString", () => {
|
||||||
|
|||||||
95
test/fixtures/sample-turns-expected-output.md
vendored
Normal file
95
test/fixtures/sample-turns-expected-output.md
vendored
Normal file
@@ -0,0 +1,95 @@
|
|||||||
|
## Claude Code Report
|
||||||
|
|
||||||
|
## 🚀 System Initialization
|
||||||
|
|
||||||
|
**Available Tools:** 8 tools loaded
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
I'll help you with this task. Let me start by examining the file to understand what needs to be changed.
|
||||||
|
|
||||||
|
### 🔧 `Read`
|
||||||
|
|
||||||
|
**Parameters:**
|
||||||
|
```json
|
||||||
|
{
|
||||||
|
"file_path": "/path/to/sample/file.py"
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
**Result:**
|
||||||
|
```python
|
||||||
|
def example_function():
|
||||||
|
print("Debug message") # This should be removed
|
||||||
|
return "Hello World"
|
||||||
|
|
||||||
|
if __name__ == "__main__":
|
||||||
|
result = example_function()
|
||||||
|
print(result)
|
||||||
|
```
|
||||||
|
|
||||||
|
*Token usage: 100 input, 75 output*
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
I can see the debug print statement that needs to be removed. Let me fix this by editing the file.
|
||||||
|
|
||||||
|
### 🔧 `Edit`
|
||||||
|
|
||||||
|
**Parameters:**
|
||||||
|
```json
|
||||||
|
{
|
||||||
|
"file_path": "/path/to/sample/file.py",
|
||||||
|
"old_string": "def example_function():\n print(\"Debug message\") # This should be removed\n return \"Hello World\"",
|
||||||
|
"new_string": "def example_function():\n return \"Hello World\""
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
**→** File successfully edited. The debug print statement has been removed.
|
||||||
|
|
||||||
|
*Token usage: 200 input, 50 output*
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
Perfect! I've successfully removed the debug print statement from the function. Let me now create a pull request comment to document this change.
|
||||||
|
|
||||||
|
### 🔧 `mcp__github__add_pull_request_review_comment`
|
||||||
|
|
||||||
|
**Parameters:**
|
||||||
|
```json
|
||||||
|
{
|
||||||
|
"owner": "example-org",
|
||||||
|
"repo": "example-repo",
|
||||||
|
"pull_number": 123,
|
||||||
|
"body": "Removed debug print statement as requested.",
|
||||||
|
"commit_id": "abc123def456",
|
||||||
|
"path": "sample/file.py",
|
||||||
|
"line": 2
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
**→** Successfully posted review comment to PR #123
|
||||||
|
|
||||||
|
*Token usage: 150 input, 80 output*
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
Great! I've successfully completed the requested task:
|
||||||
|
|
||||||
|
1. ✅ Located the debug print statement in the file
|
||||||
|
2. ✅ Removed the print statement while preserving the function logic
|
||||||
|
3. ✅ Added a review comment documenting the change
|
||||||
|
|
||||||
|
The debug print statement has been removed as requested by the reviewers.
|
||||||
|
|
||||||
|
*Token usage: 180 input, 60 output*
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## ✅ Final Result
|
||||||
|
|
||||||
|
Successfully removed debug print statement from file and added review comment to document the change.
|
||||||
|
|
||||||
|
**Cost:** $0.0347 | **Duration:** 18.8s
|
||||||
|
|
||||||
|
|
||||||
196
test/fixtures/sample-turns.json
vendored
Normal file
196
test/fixtures/sample-turns.json
vendored
Normal file
@@ -0,0 +1,196 @@
|
|||||||
|
[
|
||||||
|
{
|
||||||
|
"type": "system",
|
||||||
|
"subtype": "init",
|
||||||
|
"session_id": "sample-session-id",
|
||||||
|
"tools": [
|
||||||
|
"Task",
|
||||||
|
"Bash",
|
||||||
|
"Read",
|
||||||
|
"Edit",
|
||||||
|
"Write",
|
||||||
|
"mcp__github__get_file_contents",
|
||||||
|
"mcp__github__create_or_update_file",
|
||||||
|
"mcp__github__add_pull_request_review_comment"
|
||||||
|
],
|
||||||
|
"mcp_servers": [
|
||||||
|
{
|
||||||
|
"name": "github",
|
||||||
|
"status": "connected"
|
||||||
|
}
|
||||||
|
]
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"type": "assistant",
|
||||||
|
"message": {
|
||||||
|
"id": "msg_sample123",
|
||||||
|
"type": "message",
|
||||||
|
"role": "assistant",
|
||||||
|
"model": "claude-test-model",
|
||||||
|
"content": [
|
||||||
|
{
|
||||||
|
"type": "text",
|
||||||
|
"text": "I'll help you with this task. Let me start by examining the file to understand what needs to be changed."
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"type": "tool_use",
|
||||||
|
"id": "tool_call_1",
|
||||||
|
"name": "Read",
|
||||||
|
"input": {
|
||||||
|
"file_path": "/path/to/sample/file.py"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"stop_reason": "tool_use",
|
||||||
|
"stop_sequence": null,
|
||||||
|
"usage": {
|
||||||
|
"input_tokens": 100,
|
||||||
|
"cache_creation_input_tokens": 0,
|
||||||
|
"cache_read_input_tokens": 50,
|
||||||
|
"output_tokens": 75
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"session_id": "sample-session-id"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"type": "user",
|
||||||
|
"message": {
|
||||||
|
"content": [
|
||||||
|
{
|
||||||
|
"type": "tool_result",
|
||||||
|
"tool_use_id": "tool_call_1",
|
||||||
|
"content": "def example_function():\n print(\"Debug message\") # This should be removed\n return \"Hello World\"\n\nif __name__ == \"__main__\":\n result = example_function()\n print(result)",
|
||||||
|
"is_error": false
|
||||||
|
}
|
||||||
|
]
|
||||||
|
}
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"type": "assistant",
|
||||||
|
"message": {
|
||||||
|
"id": "msg_sample124",
|
||||||
|
"type": "message",
|
||||||
|
"role": "assistant",
|
||||||
|
"model": "claude-test-model",
|
||||||
|
"content": [
|
||||||
|
{
|
||||||
|
"type": "text",
|
||||||
|
"text": "I can see the debug print statement that needs to be removed. Let me fix this by editing the file."
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"type": "tool_use",
|
||||||
|
"id": "tool_call_2",
|
||||||
|
"name": "Edit",
|
||||||
|
"input": {
|
||||||
|
"file_path": "/path/to/sample/file.py",
|
||||||
|
"old_string": "def example_function():\n print(\"Debug message\") # This should be removed\n return \"Hello World\"",
|
||||||
|
"new_string": "def example_function():\n return \"Hello World\""
|
||||||
|
}
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"stop_reason": "tool_use",
|
||||||
|
"stop_sequence": null,
|
||||||
|
"usage": {
|
||||||
|
"input_tokens": 200,
|
||||||
|
"cache_creation_input_tokens": 0,
|
||||||
|
"cache_read_input_tokens": 100,
|
||||||
|
"output_tokens": 50
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"session_id": "sample-session-id"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"type": "user",
|
||||||
|
"message": {
|
||||||
|
"content": [
|
||||||
|
{
|
||||||
|
"type": "tool_result",
|
||||||
|
"tool_use_id": "tool_call_2",
|
||||||
|
"content": "File successfully edited. The debug print statement has been removed.",
|
||||||
|
"is_error": false
|
||||||
|
}
|
||||||
|
]
|
||||||
|
}
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"type": "assistant",
|
||||||
|
"message": {
|
||||||
|
"id": "msg_sample125",
|
||||||
|
"type": "message",
|
||||||
|
"role": "assistant",
|
||||||
|
"model": "claude-test-model",
|
||||||
|
"content": [
|
||||||
|
{
|
||||||
|
"type": "text",
|
||||||
|
"text": "Perfect! I've successfully removed the debug print statement from the function. Let me now create a pull request comment to document this change."
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"type": "tool_use",
|
||||||
|
"id": "tool_call_3",
|
||||||
|
"name": "mcp__github__add_pull_request_review_comment",
|
||||||
|
"input": {
|
||||||
|
"owner": "example-org",
|
||||||
|
"repo": "example-repo",
|
||||||
|
"pull_number": 123,
|
||||||
|
"body": "Removed debug print statement as requested.",
|
||||||
|
"commit_id": "abc123def456",
|
||||||
|
"path": "sample/file.py",
|
||||||
|
"line": 2
|
||||||
|
}
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"stop_reason": "tool_use",
|
||||||
|
"stop_sequence": null,
|
||||||
|
"usage": {
|
||||||
|
"input_tokens": 150,
|
||||||
|
"cache_creation_input_tokens": 0,
|
||||||
|
"cache_read_input_tokens": 75,
|
||||||
|
"output_tokens": 80
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"session_id": "sample-session-id"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"type": "user",
|
||||||
|
"message": {
|
||||||
|
"content": [
|
||||||
|
{
|
||||||
|
"type": "tool_result",
|
||||||
|
"tool_use_id": "tool_call_3",
|
||||||
|
"content": "Successfully posted review comment to PR #123",
|
||||||
|
"is_error": false
|
||||||
|
}
|
||||||
|
]
|
||||||
|
}
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"type": "assistant",
|
||||||
|
"message": {
|
||||||
|
"id": "msg_sample126",
|
||||||
|
"type": "message",
|
||||||
|
"role": "assistant",
|
||||||
|
"model": "claude-test-model",
|
||||||
|
"content": [
|
||||||
|
{
|
||||||
|
"type": "text",
|
||||||
|
"text": "Great! I've successfully completed the requested task:\n\n1. ✅ Located the debug print statement in the file\n2. ✅ Removed the print statement while preserving the function logic\n3. ✅ Added a review comment documenting the change\n\nThe debug print statement has been removed as requested by the reviewers."
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"stop_reason": "end_turn",
|
||||||
|
"stop_sequence": null,
|
||||||
|
"usage": {
|
||||||
|
"input_tokens": 180,
|
||||||
|
"cache_creation_input_tokens": 0,
|
||||||
|
"cache_read_input_tokens": 90,
|
||||||
|
"output_tokens": 60
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"session_id": "sample-session-id"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"type": "result",
|
||||||
|
"cost_usd": 0.0347,
|
||||||
|
"duration_ms": 18750,
|
||||||
|
"result": "Successfully removed debug print statement from file and added review comment to document the change."
|
||||||
|
}
|
||||||
|
]
|
||||||
439
test/format-turns.test.ts
Normal file
439
test/format-turns.test.ts
Normal file
@@ -0,0 +1,439 @@
|
|||||||
|
import { expect, test, describe } from "bun:test";
|
||||||
|
import { readFileSync } from "fs";
|
||||||
|
import { join } from "path";
|
||||||
|
import {
|
||||||
|
formatTurnsFromData,
|
||||||
|
groupTurnsNaturally,
|
||||||
|
formatGroupedContent,
|
||||||
|
detectContentType,
|
||||||
|
formatResultContent,
|
||||||
|
formatToolWithResult,
|
||||||
|
type Turn,
|
||||||
|
type ToolUse,
|
||||||
|
type ToolResult,
|
||||||
|
} from "../src/entrypoints/format-turns";
|
||||||
|
|
||||||
|
describe("detectContentType", () => {
|
||||||
|
test("detects JSON objects", () => {
|
||||||
|
expect(detectContentType('{"key": "value"}')).toBe("json");
|
||||||
|
expect(detectContentType('{"number": 42}')).toBe("json");
|
||||||
|
});
|
||||||
|
|
||||||
|
test("detects JSON arrays", () => {
|
||||||
|
expect(detectContentType("[1, 2, 3]")).toBe("json");
|
||||||
|
expect(detectContentType('["a", "b"]')).toBe("json");
|
||||||
|
});
|
||||||
|
|
||||||
|
test("detects Python code", () => {
|
||||||
|
expect(detectContentType("def hello():\n pass")).toBe("python");
|
||||||
|
expect(detectContentType("import os")).toBe("python");
|
||||||
|
expect(detectContentType("from math import pi")).toBe("python");
|
||||||
|
});
|
||||||
|
|
||||||
|
test("detects JavaScript code", () => {
|
||||||
|
expect(detectContentType("function test() {}")).toBe("javascript");
|
||||||
|
expect(detectContentType("const x = 5")).toBe("javascript");
|
||||||
|
expect(detectContentType("let y = 10")).toBe("javascript");
|
||||||
|
expect(detectContentType("const fn = () => console.log()")).toBe(
|
||||||
|
"javascript",
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
test("detects bash/shell content", () => {
|
||||||
|
expect(detectContentType("/usr/bin/test")).toBe("bash");
|
||||||
|
expect(detectContentType("Error: command not found")).toBe("bash");
|
||||||
|
expect(detectContentType("ls -la")).toBe("bash");
|
||||||
|
expect(detectContentType("$ echo hello")).toBe("bash");
|
||||||
|
});
|
||||||
|
|
||||||
|
test("detects diff format", () => {
|
||||||
|
expect(detectContentType("@@ -1,3 +1,3 @@")).toBe("diff");
|
||||||
|
expect(detectContentType("+++ file.txt")).toBe("diff");
|
||||||
|
expect(detectContentType("--- file.txt")).toBe("diff");
|
||||||
|
});
|
||||||
|
|
||||||
|
test("detects HTML/XML", () => {
|
||||||
|
expect(detectContentType("<div>hello</div>")).toBe("html");
|
||||||
|
expect(detectContentType("<xml>content</xml>")).toBe("html");
|
||||||
|
});
|
||||||
|
|
||||||
|
test("detects markdown", () => {
|
||||||
|
expect(detectContentType("- List item")).toBe("markdown");
|
||||||
|
expect(detectContentType("* List item")).toBe("markdown");
|
||||||
|
expect(detectContentType("```code```")).toBe("markdown");
|
||||||
|
});
|
||||||
|
|
||||||
|
test("defaults to text", () => {
|
||||||
|
expect(detectContentType("plain text")).toBe("text");
|
||||||
|
expect(detectContentType("just some words")).toBe("text");
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe("formatResultContent", () => {
|
||||||
|
test("handles empty content", () => {
|
||||||
|
expect(formatResultContent("")).toBe("*(No output)*\n\n");
|
||||||
|
expect(formatResultContent(null)).toBe("*(No output)*\n\n");
|
||||||
|
expect(formatResultContent(undefined)).toBe("*(No output)*\n\n");
|
||||||
|
});
|
||||||
|
|
||||||
|
test("formats short text without code blocks", () => {
|
||||||
|
const result = formatResultContent("success");
|
||||||
|
expect(result).toBe("**→** success\n\n");
|
||||||
|
});
|
||||||
|
|
||||||
|
test("formats long text with code blocks", () => {
|
||||||
|
const longText =
|
||||||
|
"This is a longer piece of text that should be formatted in a code block because it exceeds the short text threshold";
|
||||||
|
const result = formatResultContent(longText);
|
||||||
|
expect(result).toContain("**Result:**");
|
||||||
|
expect(result).toContain("```text");
|
||||||
|
expect(result).toContain(longText);
|
||||||
|
});
|
||||||
|
|
||||||
|
test("pretty prints JSON content", () => {
|
||||||
|
const jsonContent = '{"key": "value", "number": 42}';
|
||||||
|
const result = formatResultContent(jsonContent);
|
||||||
|
expect(result).toContain("```json");
|
||||||
|
expect(result).toContain('"key": "value"');
|
||||||
|
expect(result).toContain('"number": 42');
|
||||||
|
});
|
||||||
|
|
||||||
|
test("truncates very long content", () => {
|
||||||
|
const veryLongContent = "A".repeat(4000);
|
||||||
|
const result = formatResultContent(veryLongContent);
|
||||||
|
expect(result).toContain("...");
|
||||||
|
// Should not contain the full long content
|
||||||
|
expect(result.length).toBeLessThan(veryLongContent.length);
|
||||||
|
});
|
||||||
|
|
||||||
|
test("handles type:text structure", () => {
|
||||||
|
const structuredContent = [{ type: "text", text: "Hello world" }];
|
||||||
|
const result = formatResultContent(JSON.stringify(structuredContent));
|
||||||
|
expect(result).toBe("**→** Hello world\n\n");
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe("formatToolWithResult", () => {
|
||||||
|
test("formats tool with parameters and result", () => {
|
||||||
|
const toolUse: ToolUse = {
|
||||||
|
type: "tool_use",
|
||||||
|
name: "read_file",
|
||||||
|
input: { file_path: "/path/to/file.txt" },
|
||||||
|
id: "tool_123",
|
||||||
|
};
|
||||||
|
|
||||||
|
const toolResult: ToolResult = {
|
||||||
|
type: "tool_result",
|
||||||
|
tool_use_id: "tool_123",
|
||||||
|
content: "File content here",
|
||||||
|
is_error: false,
|
||||||
|
};
|
||||||
|
|
||||||
|
const result = formatToolWithResult(toolUse, toolResult);
|
||||||
|
|
||||||
|
expect(result).toContain("### 🔧 `read_file`");
|
||||||
|
expect(result).toContain("**Parameters:**");
|
||||||
|
expect(result).toContain('"file_path": "/path/to/file.txt"');
|
||||||
|
expect(result).toContain("**→** File content here");
|
||||||
|
});
|
||||||
|
|
||||||
|
test("formats tool with error result", () => {
|
||||||
|
const toolUse: ToolUse = {
|
||||||
|
type: "tool_use",
|
||||||
|
name: "failing_tool",
|
||||||
|
input: { param: "value" },
|
||||||
|
};
|
||||||
|
|
||||||
|
const toolResult: ToolResult = {
|
||||||
|
type: "tool_result",
|
||||||
|
content: "Permission denied",
|
||||||
|
is_error: true,
|
||||||
|
};
|
||||||
|
|
||||||
|
const result = formatToolWithResult(toolUse, toolResult);
|
||||||
|
|
||||||
|
expect(result).toContain("### 🔧 `failing_tool`");
|
||||||
|
expect(result).toContain("❌ **Error:** `Permission denied`");
|
||||||
|
});
|
||||||
|
|
||||||
|
test("formats tool without parameters", () => {
|
||||||
|
const toolUse: ToolUse = {
|
||||||
|
type: "tool_use",
|
||||||
|
name: "simple_tool",
|
||||||
|
};
|
||||||
|
|
||||||
|
const result = formatToolWithResult(toolUse);
|
||||||
|
|
||||||
|
expect(result).toContain("### 🔧 `simple_tool`");
|
||||||
|
expect(result).not.toContain("**Parameters:**");
|
||||||
|
});
|
||||||
|
|
||||||
|
test("handles unknown tool name", () => {
|
||||||
|
const toolUse: ToolUse = {
|
||||||
|
type: "tool_use",
|
||||||
|
};
|
||||||
|
|
||||||
|
const result = formatToolWithResult(toolUse);
|
||||||
|
|
||||||
|
expect(result).toContain("### 🔧 `unknown_tool`");
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe("groupTurnsNaturally", () => {
|
||||||
|
test("groups system initialization", () => {
|
||||||
|
const data: Turn[] = [
|
||||||
|
{
|
||||||
|
type: "system",
|
||||||
|
subtype: "init",
|
||||||
|
tools: [{ name: "tool1" }, { name: "tool2" }],
|
||||||
|
},
|
||||||
|
];
|
||||||
|
|
||||||
|
const result = groupTurnsNaturally(data);
|
||||||
|
|
||||||
|
expect(result).toHaveLength(1);
|
||||||
|
expect(result[0]?.type).toBe("system_init");
|
||||||
|
expect(result[0]?.tools_count).toBe(2);
|
||||||
|
});
|
||||||
|
|
||||||
|
test("groups assistant actions with tool calls", () => {
|
||||||
|
const data: Turn[] = [
|
||||||
|
{
|
||||||
|
type: "assistant",
|
||||||
|
message: {
|
||||||
|
content: [
|
||||||
|
{ type: "text", text: "I'll help you" },
|
||||||
|
{
|
||||||
|
type: "tool_use",
|
||||||
|
id: "tool_123",
|
||||||
|
name: "read_file",
|
||||||
|
input: { file_path: "/test.txt" },
|
||||||
|
},
|
||||||
|
],
|
||||||
|
usage: { input_tokens: 100, output_tokens: 50 },
|
||||||
|
},
|
||||||
|
},
|
||||||
|
{
|
||||||
|
type: "user",
|
||||||
|
message: {
|
||||||
|
content: [
|
||||||
|
{
|
||||||
|
type: "tool_result",
|
||||||
|
tool_use_id: "tool_123",
|
||||||
|
content: "file content",
|
||||||
|
is_error: false,
|
||||||
|
},
|
||||||
|
],
|
||||||
|
},
|
||||||
|
},
|
||||||
|
];
|
||||||
|
|
||||||
|
const result = groupTurnsNaturally(data);
|
||||||
|
|
||||||
|
expect(result).toHaveLength(1);
|
||||||
|
expect(result[0]?.type).toBe("assistant_action");
|
||||||
|
expect(result[0]?.text_parts).toEqual(["I'll help you"]);
|
||||||
|
expect(result[0]?.tool_calls).toHaveLength(1);
|
||||||
|
expect(result[0]?.tool_calls?.[0]?.tool_use.name).toBe("read_file");
|
||||||
|
expect(result[0]?.tool_calls?.[0]?.tool_result?.content).toBe(
|
||||||
|
"file content",
|
||||||
|
);
|
||||||
|
expect(result[0]?.usage).toEqual({ input_tokens: 100, output_tokens: 50 });
|
||||||
|
});
|
||||||
|
|
||||||
|
test("groups user messages", () => {
|
||||||
|
const data: Turn[] = [
|
||||||
|
{
|
||||||
|
type: "user",
|
||||||
|
message: {
|
||||||
|
content: [{ type: "text", text: "Please help me" }],
|
||||||
|
},
|
||||||
|
},
|
||||||
|
];
|
||||||
|
|
||||||
|
const result = groupTurnsNaturally(data);
|
||||||
|
|
||||||
|
expect(result).toHaveLength(1);
|
||||||
|
expect(result[0]?.type).toBe("user_message");
|
||||||
|
expect(result[0]?.text_parts).toEqual(["Please help me"]);
|
||||||
|
});
|
||||||
|
|
||||||
|
test("groups final results", () => {
|
||||||
|
const data: Turn[] = [
|
||||||
|
{
|
||||||
|
type: "result",
|
||||||
|
cost_usd: 0.1234,
|
||||||
|
duration_ms: 5000,
|
||||||
|
result: "Task completed",
|
||||||
|
},
|
||||||
|
];
|
||||||
|
|
||||||
|
const result = groupTurnsNaturally(data);
|
||||||
|
|
||||||
|
expect(result).toHaveLength(1);
|
||||||
|
expect(result[0]?.type).toBe("final_result");
|
||||||
|
expect(result[0]?.data).toEqual(data[0]!);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe("formatGroupedContent", () => {
|
||||||
|
test("formats system initialization", () => {
|
||||||
|
const groupedContent = [
|
||||||
|
{
|
||||||
|
type: "system_init",
|
||||||
|
tools_count: 3,
|
||||||
|
},
|
||||||
|
];
|
||||||
|
|
||||||
|
const result = formatGroupedContent(groupedContent);
|
||||||
|
|
||||||
|
expect(result).toContain("## Claude Code Report");
|
||||||
|
expect(result).toContain("## 🚀 System Initialization");
|
||||||
|
expect(result).toContain("**Available Tools:** 3 tools loaded");
|
||||||
|
});
|
||||||
|
|
||||||
|
test("formats assistant actions", () => {
|
||||||
|
const groupedContent = [
|
||||||
|
{
|
||||||
|
type: "assistant_action",
|
||||||
|
text_parts: ["I'll help you with that"],
|
||||||
|
tool_calls: [
|
||||||
|
{
|
||||||
|
tool_use: {
|
||||||
|
type: "tool_use",
|
||||||
|
name: "test_tool",
|
||||||
|
input: { param: "value" },
|
||||||
|
},
|
||||||
|
tool_result: {
|
||||||
|
type: "tool_result",
|
||||||
|
content: "result",
|
||||||
|
is_error: false,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
],
|
||||||
|
usage: { input_tokens: 100, output_tokens: 50 },
|
||||||
|
},
|
||||||
|
];
|
||||||
|
|
||||||
|
const result = formatGroupedContent(groupedContent);
|
||||||
|
|
||||||
|
expect(result).toContain("I'll help you with that");
|
||||||
|
expect(result).toContain("### 🔧 `test_tool`");
|
||||||
|
expect(result).toContain("*Token usage: 100 input, 50 output*");
|
||||||
|
});
|
||||||
|
|
||||||
|
test("formats user messages", () => {
|
||||||
|
const groupedContent = [
|
||||||
|
{
|
||||||
|
type: "user_message",
|
||||||
|
text_parts: ["Help me please"],
|
||||||
|
},
|
||||||
|
];
|
||||||
|
|
||||||
|
const result = formatGroupedContent(groupedContent);
|
||||||
|
|
||||||
|
expect(result).toContain("## 👤 User");
|
||||||
|
expect(result).toContain("Help me please");
|
||||||
|
});
|
||||||
|
|
||||||
|
test("formats final results", () => {
|
||||||
|
const groupedContent = [
|
||||||
|
{
|
||||||
|
type: "final_result",
|
||||||
|
data: {
|
||||||
|
type: "result",
|
||||||
|
cost_usd: 0.1234,
|
||||||
|
duration_ms: 5678,
|
||||||
|
result: "Success!",
|
||||||
|
} as Turn,
|
||||||
|
},
|
||||||
|
];
|
||||||
|
|
||||||
|
const result = formatGroupedContent(groupedContent);
|
||||||
|
|
||||||
|
expect(result).toContain("## ✅ Final Result");
|
||||||
|
expect(result).toContain("Success!");
|
||||||
|
expect(result).toContain("**Cost:** $0.1234");
|
||||||
|
expect(result).toContain("**Duration:** 5.7s");
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe("formatTurnsFromData", () => {
|
||||||
|
test("handles empty data", () => {
|
||||||
|
const result = formatTurnsFromData([]);
|
||||||
|
expect(result).toBe("## Claude Code Report\n\n");
|
||||||
|
});
|
||||||
|
|
||||||
|
test("formats complete conversation", () => {
|
||||||
|
const data: Turn[] = [
|
||||||
|
{
|
||||||
|
type: "system",
|
||||||
|
subtype: "init",
|
||||||
|
tools: [{ name: "tool1" }],
|
||||||
|
},
|
||||||
|
{
|
||||||
|
type: "assistant",
|
||||||
|
message: {
|
||||||
|
content: [
|
||||||
|
{ type: "text", text: "I'll help you" },
|
||||||
|
{
|
||||||
|
type: "tool_use",
|
||||||
|
id: "tool_123",
|
||||||
|
name: "read_file",
|
||||||
|
input: { file_path: "/test.txt" },
|
||||||
|
},
|
||||||
|
],
|
||||||
|
},
|
||||||
|
},
|
||||||
|
{
|
||||||
|
type: "user",
|
||||||
|
message: {
|
||||||
|
content: [
|
||||||
|
{
|
||||||
|
type: "tool_result",
|
||||||
|
tool_use_id: "tool_123",
|
||||||
|
content: "file content",
|
||||||
|
is_error: false,
|
||||||
|
},
|
||||||
|
],
|
||||||
|
},
|
||||||
|
},
|
||||||
|
{
|
||||||
|
type: "result",
|
||||||
|
cost_usd: 0.05,
|
||||||
|
duration_ms: 2000,
|
||||||
|
result: "Done",
|
||||||
|
},
|
||||||
|
];
|
||||||
|
|
||||||
|
const result = formatTurnsFromData(data);
|
||||||
|
|
||||||
|
expect(result).toContain("## Claude Code Report");
|
||||||
|
expect(result).toContain("## 🚀 System Initialization");
|
||||||
|
expect(result).toContain("I'll help you");
|
||||||
|
expect(result).toContain("### 🔧 `read_file`");
|
||||||
|
expect(result).toContain("## ✅ Final Result");
|
||||||
|
expect(result).toContain("Done");
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe("integration tests", () => {
|
||||||
|
test("formats real conversation data correctly", () => {
|
||||||
|
// Load the sample JSON data
|
||||||
|
const jsonPath = join(__dirname, "fixtures", "sample-turns.json");
|
||||||
|
const expectedPath = join(
|
||||||
|
__dirname,
|
||||||
|
"fixtures",
|
||||||
|
"sample-turns-expected-output.md",
|
||||||
|
);
|
||||||
|
|
||||||
|
const jsonData = JSON.parse(readFileSync(jsonPath, "utf-8"));
|
||||||
|
const expectedOutput = readFileSync(expectedPath, "utf-8").trim();
|
||||||
|
|
||||||
|
// Format the data using our function
|
||||||
|
const actualOutput = formatTurnsFromData(jsonData).trim();
|
||||||
|
|
||||||
|
// Compare the outputs
|
||||||
|
expect(actualOutput).toBe(expectedOutput);
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -1,5 +1,8 @@
|
|||||||
import { describe, it, expect } from "bun:test";
|
import { describe, it, expect } from "bun:test";
|
||||||
import { parseMultilineInput } from "../../src/github/context";
|
import {
|
||||||
|
parseMultilineInput,
|
||||||
|
parseAdditionalPermissions,
|
||||||
|
} from "../../src/github/context";
|
||||||
|
|
||||||
describe("parseMultilineInput", () => {
|
describe("parseMultilineInput", () => {
|
||||||
it("should parse a comma-separated string", () => {
|
it("should parse a comma-separated string", () => {
|
||||||
@@ -55,3 +58,58 @@ Bash(bun typecheck)
|
|||||||
expect(result).toEqual([]);
|
expect(result).toEqual([]);
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
|
describe("parseAdditionalPermissions", () => {
|
||||||
|
it("should parse single permission", () => {
|
||||||
|
const input = "actions: read";
|
||||||
|
const result = parseAdditionalPermissions(input);
|
||||||
|
expect(result.get("actions")).toBe("read");
|
||||||
|
expect(result.size).toBe(1);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("should parse multiple permissions", () => {
|
||||||
|
const input = `actions: read
|
||||||
|
packages: write
|
||||||
|
contents: read`;
|
||||||
|
const result = parseAdditionalPermissions(input);
|
||||||
|
expect(result.get("actions")).toBe("read");
|
||||||
|
expect(result.get("packages")).toBe("write");
|
||||||
|
expect(result.get("contents")).toBe("read");
|
||||||
|
expect(result.size).toBe(3);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("should handle empty string", () => {
|
||||||
|
const input = "";
|
||||||
|
const result = parseAdditionalPermissions(input);
|
||||||
|
expect(result.size).toBe(0);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("should handle whitespace and empty lines", () => {
|
||||||
|
const input = `
|
||||||
|
actions: read
|
||||||
|
|
||||||
|
packages: write
|
||||||
|
`;
|
||||||
|
const result = parseAdditionalPermissions(input);
|
||||||
|
expect(result.get("actions")).toBe("read");
|
||||||
|
expect(result.get("packages")).toBe("write");
|
||||||
|
expect(result.size).toBe(2);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("should ignore lines without colon separator", () => {
|
||||||
|
const input = `actions: read
|
||||||
|
invalid line
|
||||||
|
packages: write`;
|
||||||
|
const result = parseAdditionalPermissions(input);
|
||||||
|
expect(result.get("actions")).toBe("read");
|
||||||
|
expect(result.get("packages")).toBe("write");
|
||||||
|
expect(result.size).toBe(2);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("should trim whitespace around keys and values", () => {
|
||||||
|
const input = " actions : read ";
|
||||||
|
const result = parseAdditionalPermissions(input);
|
||||||
|
expect(result.get("actions")).toBe("read");
|
||||||
|
expect(result.size).toBe(1);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|||||||
@@ -1,6 +1,7 @@
|
|||||||
import { describe, test, expect, beforeEach, afterEach, spyOn } from "bun:test";
|
import { describe, test, expect, beforeEach, afterEach, spyOn } from "bun:test";
|
||||||
import { prepareMcpConfig } from "../src/mcp/install-mcp-server";
|
import { prepareMcpConfig } from "../src/mcp/install-mcp-server";
|
||||||
import * as core from "@actions/core";
|
import * as core from "@actions/core";
|
||||||
|
import type { ParsedGitHubContext } from "../src/github/context";
|
||||||
|
|
||||||
describe("prepareMcpConfig", () => {
|
describe("prepareMcpConfig", () => {
|
||||||
let consoleInfoSpy: any;
|
let consoleInfoSpy: any;
|
||||||
@@ -8,6 +9,41 @@ describe("prepareMcpConfig", () => {
|
|||||||
let setFailedSpy: any;
|
let setFailedSpy: any;
|
||||||
let processExitSpy: any;
|
let processExitSpy: any;
|
||||||
|
|
||||||
|
// Create a mock context for tests
|
||||||
|
const mockContext: ParsedGitHubContext = {
|
||||||
|
runId: "test-run-id",
|
||||||
|
eventName: "issue_comment",
|
||||||
|
eventAction: "created",
|
||||||
|
repository: {
|
||||||
|
owner: "test-owner",
|
||||||
|
repo: "test-repo",
|
||||||
|
full_name: "test-owner/test-repo",
|
||||||
|
},
|
||||||
|
actor: "test-actor",
|
||||||
|
payload: {} as any,
|
||||||
|
entityNumber: 123,
|
||||||
|
isPR: false,
|
||||||
|
inputs: {
|
||||||
|
triggerPhrase: "@claude",
|
||||||
|
assigneeTrigger: "",
|
||||||
|
labelTrigger: "",
|
||||||
|
allowedTools: [],
|
||||||
|
disallowedTools: [],
|
||||||
|
customInstructions: "",
|
||||||
|
directPrompt: "",
|
||||||
|
branchPrefix: "",
|
||||||
|
useStickyComment: false,
|
||||||
|
additionalPermissions: new Map(),
|
||||||
|
},
|
||||||
|
};
|
||||||
|
|
||||||
|
const mockPRContext: ParsedGitHubContext = {
|
||||||
|
...mockContext,
|
||||||
|
eventName: "pull_request",
|
||||||
|
isPR: true,
|
||||||
|
entityNumber: 456,
|
||||||
|
};
|
||||||
|
|
||||||
beforeEach(() => {
|
beforeEach(() => {
|
||||||
consoleInfoSpy = spyOn(core, "info").mockImplementation(() => {});
|
consoleInfoSpy = spyOn(core, "info").mockImplementation(() => {});
|
||||||
consoleWarningSpy = spyOn(core, "warning").mockImplementation(() => {});
|
consoleWarningSpy = spyOn(core, "warning").mockImplementation(() => {});
|
||||||
@@ -15,6 +51,11 @@ describe("prepareMcpConfig", () => {
|
|||||||
processExitSpy = spyOn(process, "exit").mockImplementation(() => {
|
processExitSpy = spyOn(process, "exit").mockImplementation(() => {
|
||||||
throw new Error("Process exit");
|
throw new Error("Process exit");
|
||||||
});
|
});
|
||||||
|
|
||||||
|
// Set up required environment variables
|
||||||
|
if (!process.env.GITHUB_ACTION_PATH) {
|
||||||
|
process.env.GITHUB_ACTION_PATH = "/test/action/path";
|
||||||
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
afterEach(() => {
|
afterEach(() => {
|
||||||
@@ -31,6 +72,7 @@ describe("prepareMcpConfig", () => {
|
|||||||
repo: "test-repo",
|
repo: "test-repo",
|
||||||
branch: "test-branch",
|
branch: "test-branch",
|
||||||
allowedTools: [],
|
allowedTools: [],
|
||||||
|
context: mockContext,
|
||||||
});
|
});
|
||||||
|
|
||||||
const parsed = JSON.parse(result);
|
const parsed = JSON.parse(result);
|
||||||
@@ -57,6 +99,7 @@ describe("prepareMcpConfig", () => {
|
|||||||
"mcp__github__create_issue",
|
"mcp__github__create_issue",
|
||||||
"mcp__github_file_ops__commit_files",
|
"mcp__github_file_ops__commit_files",
|
||||||
],
|
],
|
||||||
|
context: mockContext,
|
||||||
});
|
});
|
||||||
|
|
||||||
const parsed = JSON.parse(result);
|
const parsed = JSON.parse(result);
|
||||||
@@ -78,6 +121,7 @@ describe("prepareMcpConfig", () => {
|
|||||||
"mcp__github_file_ops__commit_files",
|
"mcp__github_file_ops__commit_files",
|
||||||
"mcp__github_file_ops__update_claude_comment",
|
"mcp__github_file_ops__update_claude_comment",
|
||||||
],
|
],
|
||||||
|
context: mockContext,
|
||||||
});
|
});
|
||||||
|
|
||||||
const parsed = JSON.parse(result);
|
const parsed = JSON.parse(result);
|
||||||
@@ -93,6 +137,7 @@ describe("prepareMcpConfig", () => {
|
|||||||
repo: "test-repo",
|
repo: "test-repo",
|
||||||
branch: "test-branch",
|
branch: "test-branch",
|
||||||
allowedTools: ["Edit", "Read", "Write"],
|
allowedTools: ["Edit", "Read", "Write"],
|
||||||
|
context: mockContext,
|
||||||
});
|
});
|
||||||
|
|
||||||
const parsed = JSON.parse(result);
|
const parsed = JSON.parse(result);
|
||||||
@@ -109,6 +154,7 @@ describe("prepareMcpConfig", () => {
|
|||||||
branch: "test-branch",
|
branch: "test-branch",
|
||||||
additionalMcpConfig: "",
|
additionalMcpConfig: "",
|
||||||
allowedTools: [],
|
allowedTools: [],
|
||||||
|
context: mockContext,
|
||||||
});
|
});
|
||||||
|
|
||||||
const parsed = JSON.parse(result);
|
const parsed = JSON.parse(result);
|
||||||
@@ -126,6 +172,7 @@ describe("prepareMcpConfig", () => {
|
|||||||
branch: "test-branch",
|
branch: "test-branch",
|
||||||
additionalMcpConfig: " \n\t ",
|
additionalMcpConfig: " \n\t ",
|
||||||
allowedTools: [],
|
allowedTools: [],
|
||||||
|
context: mockContext,
|
||||||
});
|
});
|
||||||
|
|
||||||
const parsed = JSON.parse(result);
|
const parsed = JSON.parse(result);
|
||||||
@@ -158,6 +205,7 @@ describe("prepareMcpConfig", () => {
|
|||||||
"mcp__github__create_issue",
|
"mcp__github__create_issue",
|
||||||
"mcp__github_file_ops__commit_files",
|
"mcp__github_file_ops__commit_files",
|
||||||
],
|
],
|
||||||
|
context: mockContext,
|
||||||
});
|
});
|
||||||
|
|
||||||
const parsed = JSON.parse(result);
|
const parsed = JSON.parse(result);
|
||||||
@@ -195,6 +243,7 @@ describe("prepareMcpConfig", () => {
|
|||||||
"mcp__github__create_issue",
|
"mcp__github__create_issue",
|
||||||
"mcp__github_file_ops__commit_files",
|
"mcp__github_file_ops__commit_files",
|
||||||
],
|
],
|
||||||
|
context: mockContext,
|
||||||
});
|
});
|
||||||
|
|
||||||
const parsed = JSON.parse(result);
|
const parsed = JSON.parse(result);
|
||||||
@@ -232,6 +281,7 @@ describe("prepareMcpConfig", () => {
|
|||||||
branch: "test-branch",
|
branch: "test-branch",
|
||||||
additionalMcpConfig: additionalConfig,
|
additionalMcpConfig: additionalConfig,
|
||||||
allowedTools: [],
|
allowedTools: [],
|
||||||
|
context: mockContext,
|
||||||
});
|
});
|
||||||
|
|
||||||
const parsed = JSON.parse(result);
|
const parsed = JSON.parse(result);
|
||||||
@@ -251,6 +301,7 @@ describe("prepareMcpConfig", () => {
|
|||||||
branch: "test-branch",
|
branch: "test-branch",
|
||||||
additionalMcpConfig: invalidJson,
|
additionalMcpConfig: invalidJson,
|
||||||
allowedTools: [],
|
allowedTools: [],
|
||||||
|
context: mockContext,
|
||||||
});
|
});
|
||||||
|
|
||||||
const parsed = JSON.parse(result);
|
const parsed = JSON.parse(result);
|
||||||
@@ -271,6 +322,7 @@ describe("prepareMcpConfig", () => {
|
|||||||
branch: "test-branch",
|
branch: "test-branch",
|
||||||
additionalMcpConfig: nonObjectJson,
|
additionalMcpConfig: nonObjectJson,
|
||||||
allowedTools: [],
|
allowedTools: [],
|
||||||
|
context: mockContext,
|
||||||
});
|
});
|
||||||
|
|
||||||
const parsed = JSON.parse(result);
|
const parsed = JSON.parse(result);
|
||||||
@@ -294,6 +346,7 @@ describe("prepareMcpConfig", () => {
|
|||||||
branch: "test-branch",
|
branch: "test-branch",
|
||||||
additionalMcpConfig: nullJson,
|
additionalMcpConfig: nullJson,
|
||||||
allowedTools: [],
|
allowedTools: [],
|
||||||
|
context: mockContext,
|
||||||
});
|
});
|
||||||
|
|
||||||
const parsed = JSON.parse(result);
|
const parsed = JSON.parse(result);
|
||||||
@@ -317,6 +370,7 @@ describe("prepareMcpConfig", () => {
|
|||||||
branch: "test-branch",
|
branch: "test-branch",
|
||||||
additionalMcpConfig: arrayJson,
|
additionalMcpConfig: arrayJson,
|
||||||
allowedTools: [],
|
allowedTools: [],
|
||||||
|
context: mockContext,
|
||||||
});
|
});
|
||||||
|
|
||||||
const parsed = JSON.parse(result);
|
const parsed = JSON.parse(result);
|
||||||
@@ -363,6 +417,7 @@ describe("prepareMcpConfig", () => {
|
|||||||
branch: "test-branch",
|
branch: "test-branch",
|
||||||
additionalMcpConfig: additionalConfig,
|
additionalMcpConfig: additionalConfig,
|
||||||
allowedTools: [],
|
allowedTools: [],
|
||||||
|
context: mockContext,
|
||||||
});
|
});
|
||||||
|
|
||||||
const parsed = JSON.parse(result);
|
const parsed = JSON.parse(result);
|
||||||
@@ -384,6 +439,7 @@ describe("prepareMcpConfig", () => {
|
|||||||
repo: "test-repo",
|
repo: "test-repo",
|
||||||
branch: "test-branch",
|
branch: "test-branch",
|
||||||
allowedTools: [],
|
allowedTools: [],
|
||||||
|
context: mockContext,
|
||||||
});
|
});
|
||||||
|
|
||||||
const parsed = JSON.parse(result);
|
const parsed = JSON.parse(result);
|
||||||
@@ -404,6 +460,7 @@ describe("prepareMcpConfig", () => {
|
|||||||
repo: "test-repo",
|
repo: "test-repo",
|
||||||
branch: "test-branch",
|
branch: "test-branch",
|
||||||
allowedTools: [],
|
allowedTools: [],
|
||||||
|
context: mockContext,
|
||||||
});
|
});
|
||||||
|
|
||||||
const parsed = JSON.parse(result);
|
const parsed = JSON.parse(result);
|
||||||
@@ -411,4 +468,132 @@ describe("prepareMcpConfig", () => {
|
|||||||
|
|
||||||
process.env.GITHUB_WORKSPACE = oldEnv;
|
process.env.GITHUB_WORKSPACE = oldEnv;
|
||||||
});
|
});
|
||||||
|
|
||||||
|
test("should include github_ci server when context.isPR is true and actions:read permission is granted", async () => {
|
||||||
|
const oldEnv = process.env.ACTIONS_TOKEN;
|
||||||
|
process.env.ACTIONS_TOKEN = "workflow-token";
|
||||||
|
|
||||||
|
const contextWithPermissions = {
|
||||||
|
...mockPRContext,
|
||||||
|
inputs: {
|
||||||
|
...mockPRContext.inputs,
|
||||||
|
additionalPermissions: new Map([["actions", "read"]]),
|
||||||
|
},
|
||||||
|
};
|
||||||
|
|
||||||
|
const result = await prepareMcpConfig({
|
||||||
|
githubToken: "test-token",
|
||||||
|
owner: "test-owner",
|
||||||
|
repo: "test-repo",
|
||||||
|
branch: "test-branch",
|
||||||
|
allowedTools: [],
|
||||||
|
context: contextWithPermissions,
|
||||||
|
});
|
||||||
|
|
||||||
|
const parsed = JSON.parse(result);
|
||||||
|
expect(parsed.mcpServers.github_ci).toBeDefined();
|
||||||
|
expect(parsed.mcpServers.github_ci.env.GITHUB_TOKEN).toBe("workflow-token");
|
||||||
|
expect(parsed.mcpServers.github_ci.env.PR_NUMBER).toBe("456");
|
||||||
|
expect(parsed.mcpServers.github_file_ops).toBeDefined();
|
||||||
|
|
||||||
|
process.env.ACTIONS_TOKEN = oldEnv;
|
||||||
|
});
|
||||||
|
|
||||||
|
test("should not include github_ci server when context.isPR is false", async () => {
|
||||||
|
const result = await prepareMcpConfig({
|
||||||
|
githubToken: "test-token",
|
||||||
|
owner: "test-owner",
|
||||||
|
repo: "test-repo",
|
||||||
|
branch: "test-branch",
|
||||||
|
allowedTools: [],
|
||||||
|
context: mockContext,
|
||||||
|
});
|
||||||
|
|
||||||
|
const parsed = JSON.parse(result);
|
||||||
|
expect(parsed.mcpServers.github_ci).not.toBeDefined();
|
||||||
|
expect(parsed.mcpServers.github_file_ops).toBeDefined();
|
||||||
|
});
|
||||||
|
|
||||||
|
test("should not include github_ci server when actions:read permission is not granted", async () => {
|
||||||
|
const oldTokenEnv = process.env.ACTIONS_TOKEN;
|
||||||
|
process.env.ACTIONS_TOKEN = "workflow-token";
|
||||||
|
|
||||||
|
const result = await prepareMcpConfig({
|
||||||
|
githubToken: "test-token",
|
||||||
|
owner: "test-owner",
|
||||||
|
repo: "test-repo",
|
||||||
|
branch: "test-branch",
|
||||||
|
allowedTools: [],
|
||||||
|
context: mockPRContext,
|
||||||
|
});
|
||||||
|
|
||||||
|
const parsed = JSON.parse(result);
|
||||||
|
expect(parsed.mcpServers.github_ci).not.toBeDefined();
|
||||||
|
expect(parsed.mcpServers.github_file_ops).toBeDefined();
|
||||||
|
|
||||||
|
process.env.ACTIONS_TOKEN = oldTokenEnv;
|
||||||
|
});
|
||||||
|
|
||||||
|
test("should parse additional_permissions with multiple lines correctly", async () => {
|
||||||
|
const oldTokenEnv = process.env.ACTIONS_TOKEN;
|
||||||
|
process.env.ACTIONS_TOKEN = "workflow-token";
|
||||||
|
|
||||||
|
const contextWithPermissions = {
|
||||||
|
...mockPRContext,
|
||||||
|
inputs: {
|
||||||
|
...mockPRContext.inputs,
|
||||||
|
additionalPermissions: new Map([
|
||||||
|
["actions", "read"],
|
||||||
|
["future", "permission"],
|
||||||
|
]),
|
||||||
|
},
|
||||||
|
};
|
||||||
|
|
||||||
|
const result = await prepareMcpConfig({
|
||||||
|
githubToken: "test-token",
|
||||||
|
owner: "test-owner",
|
||||||
|
repo: "test-repo",
|
||||||
|
branch: "test-branch",
|
||||||
|
allowedTools: [],
|
||||||
|
context: contextWithPermissions,
|
||||||
|
});
|
||||||
|
|
||||||
|
const parsed = JSON.parse(result);
|
||||||
|
expect(parsed.mcpServers.github_ci).toBeDefined();
|
||||||
|
expect(parsed.mcpServers.github_ci.env.GITHUB_TOKEN).toBe("workflow-token");
|
||||||
|
|
||||||
|
process.env.ACTIONS_TOKEN = oldTokenEnv;
|
||||||
|
});
|
||||||
|
|
||||||
|
test("should warn when actions:read is requested but token lacks permission", async () => {
|
||||||
|
const oldTokenEnv = process.env.ACTIONS_TOKEN;
|
||||||
|
process.env.ACTIONS_TOKEN = "invalid-token";
|
||||||
|
|
||||||
|
const contextWithPermissions = {
|
||||||
|
...mockPRContext,
|
||||||
|
inputs: {
|
||||||
|
...mockPRContext.inputs,
|
||||||
|
additionalPermissions: new Map([["actions", "read"]]),
|
||||||
|
},
|
||||||
|
};
|
||||||
|
|
||||||
|
const result = await prepareMcpConfig({
|
||||||
|
githubToken: "test-token",
|
||||||
|
owner: "test-owner",
|
||||||
|
repo: "test-repo",
|
||||||
|
branch: "test-branch",
|
||||||
|
allowedTools: [],
|
||||||
|
context: contextWithPermissions,
|
||||||
|
});
|
||||||
|
|
||||||
|
const parsed = JSON.parse(result);
|
||||||
|
expect(parsed.mcpServers.github_ci).toBeDefined();
|
||||||
|
expect(consoleWarningSpy).toHaveBeenCalledWith(
|
||||||
|
expect.stringContaining(
|
||||||
|
"The github_ci MCP server requires 'actions: read' permission",
|
||||||
|
),
|
||||||
|
);
|
||||||
|
|
||||||
|
process.env.ACTIONS_TOKEN = oldTokenEnv;
|
||||||
|
});
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -21,6 +21,7 @@ const defaultInputs = {
|
|||||||
timeoutMinutes: 30,
|
timeoutMinutes: 30,
|
||||||
branchPrefix: "claude/",
|
branchPrefix: "claude/",
|
||||||
useStickyComment: false,
|
useStickyComment: false,
|
||||||
|
additionalPermissions: new Map<string, string>(),
|
||||||
};
|
};
|
||||||
|
|
||||||
const defaultRepository = {
|
const defaultRepository = {
|
||||||
|
|||||||
@@ -69,6 +69,7 @@ describe("checkWritePermissions", () => {
|
|||||||
directPrompt: "",
|
directPrompt: "",
|
||||||
branchPrefix: "claude/",
|
branchPrefix: "claude/",
|
||||||
useStickyComment: false,
|
useStickyComment: false,
|
||||||
|
additionalPermissions: new Map(),
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
|
|
||||||
|
|||||||
@@ -37,6 +37,7 @@ describe("checkContainsTrigger", () => {
|
|||||||
customInstructions: "",
|
customInstructions: "",
|
||||||
branchPrefix: "claude/",
|
branchPrefix: "claude/",
|
||||||
useStickyComment: false,
|
useStickyComment: false,
|
||||||
|
additionalPermissions: new Map(),
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
expect(checkContainsTrigger(context)).toBe(true);
|
expect(checkContainsTrigger(context)).toBe(true);
|
||||||
@@ -66,6 +67,7 @@ describe("checkContainsTrigger", () => {
|
|||||||
customInstructions: "",
|
customInstructions: "",
|
||||||
branchPrefix: "claude/",
|
branchPrefix: "claude/",
|
||||||
useStickyComment: false,
|
useStickyComment: false,
|
||||||
|
additionalPermissions: new Map(),
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
expect(checkContainsTrigger(context)).toBe(false);
|
expect(checkContainsTrigger(context)).toBe(false);
|
||||||
@@ -279,6 +281,7 @@ describe("checkContainsTrigger", () => {
|
|||||||
customInstructions: "",
|
customInstructions: "",
|
||||||
branchPrefix: "claude/",
|
branchPrefix: "claude/",
|
||||||
useStickyComment: false,
|
useStickyComment: false,
|
||||||
|
additionalPermissions: new Map(),
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
expect(checkContainsTrigger(context)).toBe(true);
|
expect(checkContainsTrigger(context)).toBe(true);
|
||||||
@@ -309,6 +312,7 @@ describe("checkContainsTrigger", () => {
|
|||||||
customInstructions: "",
|
customInstructions: "",
|
||||||
branchPrefix: "claude/",
|
branchPrefix: "claude/",
|
||||||
useStickyComment: false,
|
useStickyComment: false,
|
||||||
|
additionalPermissions: new Map(),
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
expect(checkContainsTrigger(context)).toBe(true);
|
expect(checkContainsTrigger(context)).toBe(true);
|
||||||
@@ -339,6 +343,7 @@ describe("checkContainsTrigger", () => {
|
|||||||
customInstructions: "",
|
customInstructions: "",
|
||||||
branchPrefix: "claude/",
|
branchPrefix: "claude/",
|
||||||
useStickyComment: false,
|
useStickyComment: false,
|
||||||
|
additionalPermissions: new Map(),
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
expect(checkContainsTrigger(context)).toBe(false);
|
expect(checkContainsTrigger(context)).toBe(false);
|
||||||
|
|||||||
Reference in New Issue
Block a user