Compare commits
124 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| 3937c3bdba | |||
| bac3f3e9c6 | |||
| 5ea1d95b70 | |||
| 6d0c21f0f5 | |||
| c31824144b | |||
| 0a63f3da9d | |||
| 42b023cc86 | |||
| 854e3d5e4d | |||
| 5bb1b779a8 | |||
| 599264694e | |||
| b9c15e9f38 | |||
| 7ef44eb254 | |||
| 5a21d40d27 | |||
| 175f92542e | |||
| b448787f24 | |||
| 65e3da81e9 | |||
| f31e3a026e | |||
| 220652f27b | |||
| 349af82bfc | |||
| 15732d126d | |||
| 36b006108b | |||
| 029ae0d280 | |||
| 92b435eb80 | |||
| cacf9674c4 | |||
| f73260e3e6 | |||
| 3ddd6db7ca | |||
| 68499340e4 | |||
| acb06634be | |||
| 681e08557c | |||
| 15a7154aea | |||
| 434458a068 | |||
| 193954fdd7 | |||
| ab2d762658 | |||
| 876663cd1a | |||
| b2badf6d16 | |||
| 05fb2065b2 | |||
| 2042a5bf98 | |||
| 12da2b770c | |||
| a26ada9839 | |||
| 1328894afd | |||
| 85731f8360 | |||
| 1922352d86 | |||
| c0f31415a3 | |||
| 706ce04895 | |||
| 09be8e3068 | |||
| c6c1210fa0 | |||
| 0368512b9e | |||
| 9fb6135fd2 | |||
| bb78e5f94b | |||
| c668578c6f | |||
| 7f1566d9c2 | |||
| dd482566c2 | |||
| 57029c32a3 | |||
| 757d336475 | |||
| d03debab4b | |||
| a05829f781 | |||
| c8ba7940e3 | |||
| 710fdd0fa4 | |||
| 4f5ee28b8a | |||
| 806458b95a | |||
| 2c856e3337 | |||
| a93c34e61b | |||
| cd20491d22 | |||
| 1a6ce6728c | |||
| 3b39f2c8d8 | |||
| ec0eeb1d18 | |||
| 8ef805b9fc | |||
| 6e93fd9a72 | |||
| 9567d84442 | |||
| d79564db5e | |||
| a7a0e87fd8 | |||
| 7050b8de75 | |||
| 2fc3ddee16 | |||
| 284d9733dd | |||
| 94e2b5f6e0 | |||
| 03810d574e | |||
| f52e94c612 | |||
| 9444a0e208 | |||
| 2296060d04 | |||
| 458bfe18a0 | |||
| 4cfb9b5008 | |||
| 06542e382a | |||
| bcdf6ab5fb | |||
| 314f669f10 | |||
| a24275e21b | |||
| 872e620342 | |||
| 6d9c6fd2b1 | |||
| 008021df1c | |||
| d6bc0fdd64 | |||
| 8fd0328109 | |||
| a1f87ce118 | |||
| 3e7122611c | |||
| 9459803aaa | |||
| f74a75cfac | |||
| 16e04e7152 | |||
| 522779ef54 | |||
| d3d2dad025 | |||
| 66bf86f081 | |||
| 608322f026 | |||
| e3a3d416fb | |||
| f0e339f5c2 | |||
| 87d32763e9 | |||
| 671334f37d | |||
| 267ed3686f | |||
| ff1226a824 | |||
| e13c5eed00 | |||
| f2a1c3c1bb | |||
| e672deb934 | |||
| 70b365fca1 | |||
| 5c8b03a427 | |||
| 92225d30c5 | |||
| 3630ba6618 | |||
| 6a03bb8e1b | |||
| 3139f541e4 | |||
| c5b9c7cfc4 | |||
| 7a14716481 | |||
| 087709f4c7 | |||
| ff81db8bb7 | |||
| bfd948fd3c | |||
| 260b563913 | |||
| 5fef548cee | |||
| 7d633da1be | |||
| ede6cfdfbe | |||
| 7745a0befb |
@@ -0,0 +1,124 @@
|
||||
name: Publish & Release
|
||||
|
||||
on:
|
||||
push:
|
||||
branches:
|
||||
- main
|
||||
paths:
|
||||
- "package.json"
|
||||
workflow_dispatch:
|
||||
|
||||
permissions:
|
||||
contents: write
|
||||
id-token: write
|
||||
|
||||
jobs:
|
||||
publish:
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- name: Checkout code
|
||||
uses: actions/checkout@v4
|
||||
with:
|
||||
fetch-depth: 0
|
||||
|
||||
- name: Setup pnpm
|
||||
uses: pnpm/action-setup@v4
|
||||
with:
|
||||
version: latest
|
||||
|
||||
- name: Setup Node.js
|
||||
uses: actions/setup-node@v4
|
||||
with:
|
||||
node-version: "24"
|
||||
cache: "pnpm"
|
||||
registry-url: "https://registry.npmjs.org"
|
||||
|
||||
- name: Install dependencies
|
||||
run: pnpm install --frozen-lockfile
|
||||
|
||||
- name: Get package version
|
||||
id: version
|
||||
run: |
|
||||
VERSION=$(npm pkg get version | tr -d '"')
|
||||
echo "version=$VERSION" >> $GITHUB_OUTPUT
|
||||
echo "tag=v$VERSION" >> $GITHUB_OUTPUT
|
||||
|
||||
# Extract major version (e.g., "0" from "0.0.1")
|
||||
MAJOR_VERSION=$(echo $VERSION | cut -d. -f1)
|
||||
echo "major_tag=v$MAJOR_VERSION" >> $GITHUB_OUTPUT
|
||||
|
||||
echo "📦 Package version: $VERSION"
|
||||
|
||||
- name: Check if tag already exists
|
||||
id: check_tag
|
||||
run: |
|
||||
if git rev-parse "refs/tags/${{ steps.version.outputs.tag }}" >/dev/null 2>&1; then
|
||||
echo "exists=true" >> $GITHUB_OUTPUT
|
||||
echo "⚠️ Tag ${{ steps.version.outputs.tag }} already exists - skipping release"
|
||||
else
|
||||
echo "exists=false" >> $GITHUB_OUTPUT
|
||||
echo "✅ Tag ${{ steps.version.outputs.tag }} does not exist - will create release"
|
||||
fi
|
||||
|
||||
- name: Create and push tags
|
||||
if: steps.check_tag.outputs.exists == 'false'
|
||||
run: |
|
||||
# Create specific version tag
|
||||
git tag ${{ steps.version.outputs.tag }}
|
||||
git push origin ${{ steps.version.outputs.tag }}
|
||||
|
||||
# Create/update major version tag (moving tag)
|
||||
git tag -f ${{ steps.version.outputs.major_tag }}
|
||||
git push origin ${{ steps.version.outputs.major_tag }} --force
|
||||
|
||||
echo "🏷️ Created tags: ${{ steps.version.outputs.tag }} and ${{ steps.version.outputs.major_tag }}"
|
||||
|
||||
- name: Create GitHub Release
|
||||
if: steps.check_tag.outputs.exists == 'false'
|
||||
uses: actions/create-release@v1
|
||||
env:
|
||||
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
|
||||
with:
|
||||
tag_name: ${{ steps.version.outputs.tag }}
|
||||
release_name: "${{ steps.version.outputs.tag }}"
|
||||
body: |
|
||||
## 📦 @pullfrog/action ${{ steps.version.outputs.version }}
|
||||
|
||||
### Usage in GitHub Actions
|
||||
|
||||
```yaml
|
||||
- uses: pullfrog/action@${{ steps.version.outputs.major_tag }}
|
||||
```
|
||||
|
||||
### Installation via npm
|
||||
|
||||
```bash
|
||||
npm install @pullfrog/action@${{ steps.version.outputs.version }}
|
||||
```
|
||||
draft: false
|
||||
prerelease: false
|
||||
|
||||
# - name: Publish to npm
|
||||
# if: steps.check_tag.outputs.exists == 'false'
|
||||
# run: npm publish --access public
|
||||
# env:
|
||||
# NODE_AUTH_TOKEN: ${{ secrets.NPM_TOKEN }}
|
||||
|
||||
- name: Summary
|
||||
if: always()
|
||||
run: |
|
||||
echo "## 📊 Publish Summary" >> $GITHUB_STEP_SUMMARY
|
||||
echo "" >> $GITHUB_STEP_SUMMARY
|
||||
if [[ "${{ steps.check_tag.outputs.exists }}" == "true" ]]; then
|
||||
echo "⚠️ Version ${{ steps.version.outputs.version }} already exists - no action taken" >> $GITHUB_STEP_SUMMARY
|
||||
else
|
||||
echo "✅ Successfully published version ${{ steps.version.outputs.version }}" >> $GITHUB_STEP_SUMMARY
|
||||
echo "" >> $GITHUB_STEP_SUMMARY
|
||||
echo "### 🏷️ Tags Created" >> $GITHUB_STEP_SUMMARY
|
||||
echo "- \`${{ steps.version.outputs.tag }}\` (specific version)" >> $GITHUB_STEP_SUMMARY
|
||||
echo "- \`${{ steps.version.outputs.major_tag }}\` (major version, auto-updating)" >> $GITHUB_STEP_SUMMARY
|
||||
echo "" >> $GITHUB_STEP_SUMMARY
|
||||
echo "### 📦 Published to" >> $GITHUB_STEP_SUMMARY
|
||||
echo "- GitHub Release: [View Release](https://github.com/${{ github.repository }}/releases/tag/${{ steps.version.outputs.tag }})" >> $GITHUB_STEP_SUMMARY
|
||||
echo "- npm Registry: [@pullfrog/action@${{ steps.version.outputs.version }}](https://www.npmjs.com/package/@pullfrog/action/v/${{ steps.version.outputs.version }})" >> $GITHUB_STEP_SUMMARY
|
||||
fi
|
||||
@@ -1,101 +0,0 @@
|
||||
name: Auto-tag Action Release
|
||||
|
||||
on:
|
||||
push:
|
||||
branches: [main]
|
||||
paths:
|
||||
- 'package.json'
|
||||
|
||||
permissions:
|
||||
contents: write
|
||||
|
||||
jobs:
|
||||
auto-tag:
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- name: Checkout code
|
||||
uses: actions/checkout@v4
|
||||
with:
|
||||
fetch-depth: 0
|
||||
|
||||
- name: Setup Node.js
|
||||
uses: actions/setup-node@v4
|
||||
with:
|
||||
node-version: '20'
|
||||
|
||||
- name: Setup pnpm
|
||||
uses: pnpm/action-setup@v4
|
||||
with:
|
||||
version: latest
|
||||
|
||||
- name: Get package version
|
||||
id: version
|
||||
run: |
|
||||
VERSION=$(node -p "require('./package.json').version")
|
||||
echo "version=$VERSION" >> $GITHUB_OUTPUT
|
||||
echo "tag=v$VERSION" >> $GITHUB_OUTPUT
|
||||
|
||||
# Extract major version (e.g., "0" from "0.0.1")
|
||||
MAJOR_VERSION=$(echo $VERSION | cut -d. -f1)
|
||||
echo "major_tag=v$MAJOR_VERSION" >> $GITHUB_OUTPUT
|
||||
|
||||
- name: Check if tag already exists
|
||||
id: check_tag
|
||||
run: |
|
||||
if git rev-parse "refs/tags/${{ steps.version.outputs.tag }}" >/dev/null 2>&1; then
|
||||
echo "exists=true" >> $GITHUB_OUTPUT
|
||||
echo "Tag ${{ steps.version.outputs.tag }} already exists"
|
||||
else
|
||||
echo "exists=false" >> $GITHUB_OUTPUT
|
||||
echo "Tag ${{ steps.version.outputs.tag }} does not exist"
|
||||
fi
|
||||
|
||||
- name: Verify built files are up to date
|
||||
if: steps.check_tag.outputs.exists == 'false'
|
||||
run: |
|
||||
# Check if there are any uncommitted changes (built files should already be committed via pre-commit hook)
|
||||
if [[ -n $(git status --porcelain) ]]; then
|
||||
echo "❌ Error: There are uncommitted changes. Built files should be committed via pre-commit hook."
|
||||
git status
|
||||
exit 1
|
||||
fi
|
||||
echo "✅ All built files are up to date"
|
||||
|
||||
- name: Create and push tags
|
||||
if: steps.check_tag.outputs.exists == 'false'
|
||||
run: |
|
||||
# Create specific version tag
|
||||
git tag ${{ steps.version.outputs.tag }}
|
||||
git push origin ${{ steps.version.outputs.tag }}
|
||||
|
||||
# Create/update major version tag (moving tag)
|
||||
git tag -f ${{ steps.version.outputs.major_tag }}
|
||||
git push origin ${{ steps.version.outputs.major_tag }} --force
|
||||
|
||||
- name: Create GitHub Release
|
||||
if: steps.check_tag.outputs.exists == 'false'
|
||||
uses: actions/create-release@v1
|
||||
env:
|
||||
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
|
||||
with:
|
||||
tag_name: ${{ steps.version.outputs.tag }}
|
||||
release_name: ${{ steps.version.outputs.tag }}
|
||||
body: |
|
||||
Automated release for action version ${{ steps.version.outputs.version }}
|
||||
|
||||
## Usage
|
||||
|
||||
```yaml
|
||||
- uses: pullfrog/pullfrog@${{ steps.version.outputs.major_tag }}
|
||||
with:
|
||||
message: "Your message here"
|
||||
```
|
||||
|
||||
Or use the specific version:
|
||||
```yaml
|
||||
- uses: pullfrog/pullfrog@${{ steps.version.outputs.tag }}
|
||||
with:
|
||||
message: "Your message here"
|
||||
```
|
||||
draft: false
|
||||
prerelease: false
|
||||
+12
-2
@@ -1,4 +1,4 @@
|
||||
# macOS settings file
|
||||
# macOS settings file
|
||||
.DS_Store
|
||||
|
||||
# Contains all your dependencies
|
||||
@@ -34,4 +34,14 @@ yarn-error.log*
|
||||
vite.config.js.timestamp-*
|
||||
vite.config.ts.timestamp-*
|
||||
|
||||
examples
|
||||
examples
|
||||
|
||||
# Act temporary distribution directory
|
||||
.act-dist/
|
||||
|
||||
# Temporary backup of node_modules
|
||||
.node_modules_backup/
|
||||
|
||||
# Temporary directory for cloned repos
|
||||
.temp/
|
||||
dist
|
||||
|
||||
+7
-3
@@ -1,6 +1,10 @@
|
||||
# Ensure lockfile is up to date
|
||||
echo "🔒 Updating lockfile..."
|
||||
pnpm install --lockfile-only
|
||||
|
||||
# Build the action before committing
|
||||
echo "🔨 Building action..."
|
||||
npm run build
|
||||
pnpm build
|
||||
|
||||
# Add the built files to the commit
|
||||
git add index.cjs dist/
|
||||
# Add the built files and lockfile to the commit
|
||||
git add entry.js mcp-server.js pnpm-lock.yaml
|
||||
|
||||
@@ -0,0 +1,299 @@
|
||||
# Claude Code Action Architecture & Flow
|
||||
|
||||
This document provides a comprehensive overview of how the official (Anthropic) Claude Code Action works, from token exchange through post-run cleanup.
|
||||
|
||||
## Overview
|
||||
|
||||
The Claude Code Action is a sophisticated GitHub automation platform that enables Claude to interact with GitHub repositories through secure token exchange, intelligent mode detection, and comprehensive GitHub API integration.
|
||||
|
||||
## High-Level Architecture
|
||||
|
||||
```mermaid
|
||||
graph TD
|
||||
Start([GitHub Action Triggered]) --> Setup[Setup Environment<br/>- Install Bun<br/>- Install Dependencies]
|
||||
|
||||
Setup --> ParseContext[Parse GitHub Context<br/>- Extract event data<br/>- Parse inputs]
|
||||
|
||||
ParseContext --> ModeDetection{Mode Detection}
|
||||
|
||||
ModeDetection -->|Has explicit prompt| AgentMode[AGENT MODE<br/>Direct automation]
|
||||
ModeDetection -->|@claude mention/assignment/label| TagMode[TAG MODE<br/>Interactive response]
|
||||
ModeDetection -->|No trigger| DefaultAgent[Default to Agent<br/>(won't trigger)]
|
||||
|
||||
%% Token Exchange Branch
|
||||
AgentMode --> TokenExchange[Token Exchange Process]
|
||||
TagMode --> TokenExchange
|
||||
TokenExchange --> TokenMethod{Token Method}
|
||||
|
||||
TokenMethod -->|Custom token provided| UseCustom[Use Custom GitHub Token]
|
||||
TokenMethod -->|No custom token| OIDC[Generate OIDC Token<br/>core.getIDToken()]
|
||||
|
||||
OIDC --> Exchange[Exchange OIDC for App Token<br/>api.anthropic.com/api/github/github-app-token-exchange]
|
||||
Exchange --> CreateOctokit[Create Authenticated Octokit Client<br/>REST + GraphQL]
|
||||
UseCustom --> CreateOctokit
|
||||
|
||||
%% Permission Checks
|
||||
CreateOctokit --> PermCheck[Check Write Permissions<br/>Only for entity contexts]
|
||||
PermCheck -->|No permissions| PermFail[❌ Exit: No write access]
|
||||
PermCheck -->|Has permissions| TriggerCheck{Check Trigger Conditions}
|
||||
|
||||
%% Trigger Validation
|
||||
TriggerCheck -->|Agent Mode| AgentTrigger{Has explicit prompt?}
|
||||
TriggerCheck -->|Tag Mode| TagTrigger{Contains @claude mention<br/>or assignment/label?}
|
||||
|
||||
AgentTrigger -->|No prompt| NoTrigger[❌ Skip: No trigger found]
|
||||
AgentTrigger -->|Has prompt| PrepareAgent[Prepare Agent Mode]
|
||||
TagTrigger -->|No mention| NoTrigger
|
||||
TagTrigger -->|Has mention| PrepareTag[Prepare Tag Mode]
|
||||
|
||||
%% Mode-Specific Preparation
|
||||
PrepareAgent --> AgentPrep[Agent Mode Preparation<br/>- Create prompt file<br/>- Setup MCP servers<br/>- No tracking comment]
|
||||
PrepareTag --> TagPrep[Tag Mode Preparation<br/>- Create tracking comment<br/>- Setup branches<br/>- Fetch GitHub data<br/>- Setup MCP servers]
|
||||
|
||||
%% Data Fetching (Tag Mode)
|
||||
TagPrep --> DataFetch[Fetch GitHub Data<br/>GraphQL + REST API]
|
||||
DataFetch --> FetchWhat{What to fetch?}
|
||||
|
||||
FetchWhat -->|Pull Request| PRData[PR Data:<br/>- Comments & reviews<br/>- Changed files + SHAs<br/>- Commit history<br/>- Author info]
|
||||
FetchWhat -->|Issue| IssueData[Issue Data:<br/>- Comments<br/>- Issue details<br/>- Author info]
|
||||
|
||||
PRData --> ProcessImages[Process Images<br/>Download & convert to base64]
|
||||
IssueData --> ProcessImages
|
||||
ProcessImages --> SetupBranch[Setup Branch<br/>- Create Claude branch<br/>- Configure git auth]
|
||||
|
||||
%% MCP Server Setup
|
||||
AgentPrep --> MCPSetup[Setup MCP Servers]
|
||||
SetupBranch --> MCPSetup
|
||||
|
||||
MCPSetup --> MCPServers{MCP Servers}
|
||||
MCPServers --> GitHubActions[GitHub Actions Server<br/>- Workflow data<br/>- CI results]
|
||||
MCPServers --> GitHubComments[GitHub Comment Server<br/>- Comment operations]
|
||||
MCPServers --> GitHubFiles[GitHub File Ops Server<br/>- File operations<br/>- Branch management]
|
||||
MCPServers --> GitHubInline[GitHub Inline Comment Server<br/>- PR review comments]
|
||||
|
||||
GitHubActions --> PromptGen[Generate Prompt]
|
||||
GitHubComments --> PromptGen
|
||||
GitHubFiles --> PromptGen
|
||||
GitHubInline --> PromptGen
|
||||
|
||||
%% Prompt Generation
|
||||
PromptGen --> PromptType{Prompt Type}
|
||||
PromptType -->|Agent Mode| AgentPrompt[Agent Prompt:<br/>- Direct user prompt<br/>- Minimal context]
|
||||
PromptType -->|Tag Mode| TagPrompt[Tag Prompt:<br/>- Rich GitHub context<br/>- PR/Issue details<br/>- Changed files<br/>- Comments & reviews<br/>- Commit instructions]
|
||||
|
||||
AgentPrompt --> ClaudeRun[Run Claude Code]
|
||||
TagPrompt --> ClaudeRun
|
||||
|
||||
%% Claude Execution
|
||||
ClaudeRun --> ClaudeExec[Claude Code Execution<br/>base-action/src/index.ts]
|
||||
ClaudeExec --> ClaudeArgs[Prepare Claude Args<br/>- Prompt file path<br/>- Custom claude_args<br/>- Output format: stream-json]
|
||||
|
||||
ClaudeArgs --> ClaudeProvider{Provider}
|
||||
ClaudeProvider -->|Default| AnthropicAPI[Anthropic API<br/>ANTHROPIC_API_KEY]
|
||||
ClaudeProvider -->|Bedrock| AWSBedrock[AWS Bedrock<br/>OIDC + AWS credentials]
|
||||
ClaudeProvider -->|Vertex| GCPVertex[GCP Vertex AI<br/>OIDC + GCP credentials]
|
||||
|
||||
AnthropicAPI --> ClaudeProcess[Spawn Claude Process<br/>- Named pipe for input<br/>- Stream JSON output]
|
||||
AWSBedrock --> ClaudeProcess
|
||||
GCPVertex --> ClaudeProcess
|
||||
|
||||
ClaudeProcess --> ClaudeTools[Claude Tool Usage<br/>- MCP tools<br/>- File operations<br/>- GitHub API calls<br/>- Bash commands]
|
||||
|
||||
ClaudeTools --> ClaudeOutput[Claude Output Processing<br/>- Capture execution log<br/>- Parse JSON stream<br/>- Extract metrics]
|
||||
|
||||
%% Post-Run Actions
|
||||
ClaudeOutput --> PostRun{Post-Run Actions}
|
||||
|
||||
PostRun -->|Success| Success[✅ Success Path]
|
||||
PostRun -->|Failure| Failure[❌ Failure Path]
|
||||
|
||||
Success --> UpdateComment[Update Tracking Comment<br/>- Job run link<br/>- Branch link<br/>- PR link (if created)<br/>- Execution metrics]
|
||||
Failure --> UpdateComment
|
||||
|
||||
UpdateComment --> BranchCleanup[Branch Cleanup<br/>- Check for changes<br/>- Delete empty branches<br/>- Keep branches with commits]
|
||||
|
||||
BranchCleanup --> FormatReport[Format Execution Report<br/>- Parse conversation turns<br/>- Format tool usage<br/>- Add to GitHub step summary]
|
||||
|
||||
FormatReport --> RevokeToken[Revoke App Token<br/>DELETE /installation/token]
|
||||
|
||||
RevokeToken --> End([Action Complete])
|
||||
```
|
||||
|
||||
## Key Components
|
||||
|
||||
### 1. Token Exchange Process
|
||||
|
||||
The action uses a secure OIDC token exchange system:
|
||||
|
||||
1. **OIDC Token Generation**: `core.getIDToken("claude-code-github-action")`
|
||||
2. **Token Exchange**: POST to `https://api.anthropic.com/api/github/github-app-token-exchange`
|
||||
3. **Authentication**: Creates authenticated Octokit clients for GitHub API access
|
||||
|
||||
**Security Benefits:**
|
||||
- Repository-scoped access
|
||||
- Time-limited tokens
|
||||
- Permission-limited (only configured GitHub App permissions)
|
||||
- Automatic token masking in logs
|
||||
|
||||
### 2. Mode Detection
|
||||
|
||||
The action automatically detects the appropriate execution mode:
|
||||
|
||||
#### **Agent Mode**
|
||||
- **Trigger**: Explicit `prompt` input provided
|
||||
- **Use Case**: Direct automation, custom workflows
|
||||
- **Behavior**: Minimal context, direct execution
|
||||
- **Tracking**: No tracking comments
|
||||
|
||||
#### **Tag Mode**
|
||||
- **Trigger**: @claude mentions, issue assignments, or labels
|
||||
- **Use Case**: Interactive GitHub responses
|
||||
- **Behavior**: Rich context, comprehensive GitHub data
|
||||
- **Tracking**: Creates and updates tracking comments
|
||||
|
||||
### 3. Data Fetching (Tag Mode)
|
||||
|
||||
When in Tag Mode, the action fetches comprehensive GitHub context:
|
||||
|
||||
#### **Pull Request Data:**
|
||||
- Comments and reviews (including inline comments)
|
||||
- Changed files with SHAs
|
||||
- Commit history and metadata
|
||||
- Author information
|
||||
- File diff data
|
||||
|
||||
#### **Issue Data:**
|
||||
- Issue details and metadata
|
||||
- All comments
|
||||
- Author information
|
||||
- Labels and assignments
|
||||
|
||||
#### **Image Processing:**
|
||||
- Downloads images from GitHub
|
||||
- Converts to base64 for Claude
|
||||
- Maps original URLs to processed content
|
||||
|
||||
### 4. MCP Server Integration
|
||||
|
||||
The action sets up multiple MCP (Model Context Protocol) servers to provide Claude with GitHub capabilities:
|
||||
|
||||
#### **GitHub Actions Server**
|
||||
- Access to workflow runs and CI data
|
||||
- Build status and test results
|
||||
- Artifact information
|
||||
|
||||
#### **GitHub Comment Server**
|
||||
- Comment creation and updates
|
||||
- Issue and PR comment management
|
||||
|
||||
#### **GitHub File Operations Server**
|
||||
- File reading and writing
|
||||
- Branch creation and management
|
||||
- Commit operations
|
||||
|
||||
#### **GitHub Inline Comment Server**
|
||||
- PR review comment operations
|
||||
- Line-specific feedback
|
||||
|
||||
### 5. Prompt Generation
|
||||
|
||||
The action generates context-rich prompts based on the detected mode:
|
||||
|
||||
#### **Agent Mode Prompts:**
|
||||
- Direct user prompt
|
||||
- Minimal GitHub context
|
||||
- Focused on specific task
|
||||
|
||||
#### **Tag Mode Prompts:**
|
||||
- Comprehensive GitHub context
|
||||
- PR/Issue details and history
|
||||
- Changed files and diffs
|
||||
- Comment threads and reviews
|
||||
- Commit instructions and guidelines
|
||||
|
||||
### 6. Claude Execution
|
||||
|
||||
The action runs Claude Code through multiple provider options:
|
||||
|
||||
#### **Provider Support:**
|
||||
- **Anthropic API** (default): Direct API access with API key
|
||||
- **AWS Bedrock**: OIDC authentication with AWS credentials
|
||||
- **GCP Vertex AI**: OIDC authentication with GCP credentials
|
||||
|
||||
#### **Execution Process:**
|
||||
1. **Named Pipe Setup**: Creates pipe for prompt input
|
||||
2. **Process Spawning**: Spawns Claude Code process
|
||||
3. **Stream Processing**: Captures JSON stream output
|
||||
4. **Tool Integration**: Enables MCP tools and GitHub operations
|
||||
|
||||
### 7. Post-Run Actions
|
||||
|
||||
After Claude execution, the action performs comprehensive cleanup and reporting:
|
||||
|
||||
#### **Comment Updates:**
|
||||
- Updates tracking comments with results
|
||||
- Adds job run links and execution metrics
|
||||
- Includes branch and PR links when created
|
||||
|
||||
#### **Branch Management:**
|
||||
- Checks for actual changes in Claude branches
|
||||
- Deletes empty branches to avoid clutter
|
||||
- Preserves branches with meaningful commits
|
||||
|
||||
#### **Report Generation:**
|
||||
- Parses execution logs and conversation turns
|
||||
- Formats tool usage and results
|
||||
- Adds formatted report to GitHub step summary
|
||||
|
||||
#### **Security Cleanup:**
|
||||
- Revokes GitHub App installation token
|
||||
- Cleans up temporary files and processes
|
||||
|
||||
## Security Considerations
|
||||
|
||||
### **Access Control:**
|
||||
- Repository-scoped permissions only
|
||||
- Write access validation for actors
|
||||
- Bot user controls and allowlists
|
||||
|
||||
### **Token Management:**
|
||||
- Short-lived installation tokens
|
||||
- Automatic token revocation after use
|
||||
- Secure OIDC-based exchange
|
||||
|
||||
### **Permission Boundaries:**
|
||||
- Limited to configured GitHub App permissions
|
||||
- No cross-repository access
|
||||
- Scoped to specific repository operations
|
||||
|
||||
## Integration Points
|
||||
|
||||
### **With Pullfrog:**
|
||||
The Claude Code Action can be integrated with Pullfrog's workflow system, providing:
|
||||
- Standardized agent interaction patterns
|
||||
- Consistent GitHub integration
|
||||
- Reusable authentication flows
|
||||
- Common MCP server infrastructure
|
||||
|
||||
### **With GitHub:**
|
||||
- Native GitHub Actions integration
|
||||
- Comprehensive API coverage (REST + GraphQL)
|
||||
- Proper webhook handling
|
||||
- Standard GitHub UI integration
|
||||
|
||||
## Development Notes
|
||||
|
||||
### **Key Files:**
|
||||
- `src/entrypoints/prepare.ts`: Main preparation logic
|
||||
- `src/modes/`: Mode detection and handling
|
||||
- `src/github/token.ts`: OIDC token exchange
|
||||
- `src/mcp/`: MCP server implementations
|
||||
- `base-action/`: Core Claude Code execution
|
||||
|
||||
### **Testing:**
|
||||
- Unit tests for individual components
|
||||
- Integration tests for full workflows
|
||||
- Local testing with `act` tool
|
||||
- Comprehensive fixture support
|
||||
|
||||
This architecture provides a robust, secure, and extensible foundation for Claude-GitHub integration while maintaining clear separation of concerns and comprehensive error handling.
|
||||
@@ -1,4 +1,24 @@
|
||||
# Pullfrog
|
||||
# Pullfrog Action
|
||||
|
||||
A simple GitHub Action that prints a customizable message to the console.
|
||||
GitHub Action for running Claude Code and other agents via Pullfrog.
|
||||
|
||||
> **📖 Claude Code Action Architecture**: For a detailed technical overview of how the Claude Code Action works (token exchange, modes, data fetching, execution flow), see [CLAUDE-ACTION.md](./CLAUDE-ACTION.md).
|
||||
|
||||
## Quick Start
|
||||
|
||||
```bash
|
||||
# Install dependencies
|
||||
pnpm install
|
||||
|
||||
# Test with default prompt
|
||||
npm run play # Run locally on your machine
|
||||
```
|
||||
|
||||
## Testing with play.ts
|
||||
|
||||
```bash
|
||||
pnpm play # Uses fixtures/play.txt
|
||||
```
|
||||
- Clones the scratch repository to `.temp`
|
||||
- Runs Claude Code directly on your machine
|
||||
- Fast iteration for development
|
||||
|
||||
+10
-10
@@ -1,20 +1,20 @@
|
||||
name: 'Pullfrog Claude Code Action'
|
||||
description: 'Execute Claude Code with a prompt using Anthropic API'
|
||||
author: 'Pullfrog'
|
||||
name: "Pullfrog Claude Code Action"
|
||||
description: "Execute Claude Code with a prompt using Anthropic API"
|
||||
author: "Pullfrog"
|
||||
|
||||
inputs:
|
||||
prompt:
|
||||
description: 'Prompt to send to Claude Code'
|
||||
description: "Prompt to send to Claude Code"
|
||||
required: true
|
||||
default: 'Hello from Claude Code!'
|
||||
default: "Hello from Claude Code!"
|
||||
anthropic_api_key:
|
||||
description: 'Anthropic API key for Claude Code authentication'
|
||||
description: "Anthropic API key for Claude Code authentication"
|
||||
required: false
|
||||
|
||||
runs:
|
||||
using: 'node20'
|
||||
main: 'index.cjs'
|
||||
using: "node20"
|
||||
main: "entry.js"
|
||||
|
||||
branding:
|
||||
icon: 'code'
|
||||
color: 'orange'
|
||||
icon: "code"
|
||||
color: "green"
|
||||
|
||||
+229
-63
@@ -1,78 +1,244 @@
|
||||
import * as core from "@actions/core";
|
||||
import { executeCommand } from "../utils/exec";
|
||||
import { createTempFile } from "../utils/files";
|
||||
import type { Agent, AgentConfig, AgentResult } from "./types";
|
||||
import { execSync } from "node:child_process";
|
||||
import { createWriteStream, existsSync, rmSync } from "node:fs";
|
||||
import { mkdtemp } from "node:fs/promises";
|
||||
import { tmpdir } from "node:os";
|
||||
import { join } from "node:path";
|
||||
import { pipeline } from "node:stream/promises";
|
||||
import { query, type SDKMessage } from "@anthropic-ai/claude-agent-sdk";
|
||||
import packageJson from "../package.json" with { type: "json" };
|
||||
import { log } from "../utils/cli.ts";
|
||||
import { type Agent, instructions } from "./shared.ts";
|
||||
|
||||
/**
|
||||
* Claude Code agent implementation
|
||||
*/
|
||||
export class ClaudeAgent implements Agent {
|
||||
private apiKey: string;
|
||||
let cachedCliPath: string | undefined;
|
||||
|
||||
constructor(config: AgentConfig) {
|
||||
if (!config.apiKey) {
|
||||
throw new Error("Claude agent requires an API key");
|
||||
export const claude: Agent = {
|
||||
install: async (): Promise<string> => {
|
||||
if (cachedCliPath) {
|
||||
log.info(`Using cached Claude Code CLI at ${cachedCliPath}`);
|
||||
return cachedCliPath;
|
||||
}
|
||||
this.apiKey = config.apiKey;
|
||||
}
|
||||
|
||||
/**
|
||||
* Install Claude Code CLI
|
||||
*/
|
||||
async install(): Promise<void> {
|
||||
core.info("Installing Claude Code...");
|
||||
try {
|
||||
await executeCommand("curl -fsSL https://claude.ai/install.sh | bash -s 1.0.93");
|
||||
core.info("Claude Code installed successfully");
|
||||
} catch (error) {
|
||||
throw new Error(`Failed to install Claude Code: ${error}`);
|
||||
// Get the SDK version from package.json and resolve to actual version
|
||||
const versionRange = packageJson.dependencies["@anthropic-ai/claude-agent-sdk"] || "latest";
|
||||
let sdkVersion: string;
|
||||
|
||||
// If it's a range (starts with ^ or ~), query npm registry for the latest matching version
|
||||
if (versionRange.startsWith("^") || versionRange.startsWith("~")) {
|
||||
const npmRegistry = process.env.NPM_REGISTRY || "https://registry.npmjs.org";
|
||||
log.info(`Resolving version for range ${versionRange}...`);
|
||||
try {
|
||||
const registryResponse = await fetch(`${npmRegistry}/@anthropic-ai/claude-agent-sdk`);
|
||||
if (!registryResponse.ok) {
|
||||
throw new Error(`Failed to query registry: ${registryResponse.status}`);
|
||||
}
|
||||
const registryData = (await registryResponse.json()) as {
|
||||
"dist-tags": { latest: string };
|
||||
versions: Record<string, unknown>;
|
||||
};
|
||||
// Get the latest version that matches the range (simplified: just use latest)
|
||||
sdkVersion = registryData["dist-tags"].latest;
|
||||
log.info(`Resolved to version ${sdkVersion}`);
|
||||
} catch (error) {
|
||||
log.warning(
|
||||
`Failed to resolve version from registry, using latest: ${error instanceof Error ? error.message : String(error)}`
|
||||
);
|
||||
sdkVersion = "latest";
|
||||
}
|
||||
} else {
|
||||
sdkVersion = versionRange;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Execute Claude Code with the given prompt
|
||||
*/
|
||||
async execute(prompt: string): Promise<AgentResult> {
|
||||
core.info("Executing Claude Code...");
|
||||
log.info(`📦 Installing Claude Code CLI from @anthropic-ai/claude-agent-sdk@${sdkVersion}...`);
|
||||
|
||||
// Create temp directory
|
||||
const tempDir = await mkdtemp(join(tmpdir(), "claude-cli-"));
|
||||
const tarballPath = join(tempDir, "package.tgz");
|
||||
|
||||
try {
|
||||
// Create a temporary file for the prompt
|
||||
const promptFile = createTempFile(prompt, "prompt.txt");
|
||||
// Download tarball from npm
|
||||
const npmRegistry = process.env.NPM_REGISTRY || "https://registry.npmjs.org";
|
||||
const tarballUrl = `${npmRegistry}/@anthropic-ai/claude-agent-sdk/-/claude-agent-sdk-${sdkVersion}.tgz`;
|
||||
|
||||
// Execute Claude Code with the prompt
|
||||
const command = `$HOME/.local/bin/claude --dangerously-skip-permissions "${promptFile}"`;
|
||||
core.info(`Executing: ${command}`);
|
||||
|
||||
const { stdout, stderr } = await executeCommand(command, {
|
||||
ANTHROPIC_API_KEY: this.apiKey,
|
||||
});
|
||||
|
||||
if (stderr) {
|
||||
core.warning(`Claude Code stderr: ${stderr}`);
|
||||
log.info(`Downloading from ${tarballUrl}...`);
|
||||
const response = await fetch(tarballUrl);
|
||||
if (!response.ok) {
|
||||
throw new Error(`Failed to download tarball: ${response.status} ${response.statusText}`);
|
||||
}
|
||||
|
||||
if (stdout) {
|
||||
core.info("Claude Code output:");
|
||||
console.log(stdout);
|
||||
// Write tarball to file
|
||||
const fileStream = createWriteStream(tarballPath);
|
||||
await pipeline(response.body as any, fileStream);
|
||||
log.info(`Downloaded tarball to ${tarballPath}`);
|
||||
|
||||
// Extract tarball
|
||||
log.info(`Extracting tarball...`);
|
||||
execSync(`tar -xzf "${tarballPath}" -C "${tempDir}"`, { stdio: "pipe" });
|
||||
|
||||
// Find cli.js in the extracted package
|
||||
const extractedDir = join(tempDir, "package");
|
||||
const cliPath = join(extractedDir, "cli.js");
|
||||
|
||||
if (!existsSync(cliPath)) {
|
||||
throw new Error(`cli.js not found in extracted package at ${cliPath}`);
|
||||
}
|
||||
|
||||
core.info("Claude Code executed successfully");
|
||||
|
||||
return {
|
||||
success: true,
|
||||
output: stdout,
|
||||
error: stderr || undefined,
|
||||
metadata: {
|
||||
promptFile,
|
||||
command,
|
||||
},
|
||||
};
|
||||
cachedCliPath = cliPath;
|
||||
log.info(`✓ Claude Code CLI installed at ${cliPath}`);
|
||||
return cliPath;
|
||||
} catch (error) {
|
||||
const errorMessage = error instanceof Error ? error.message : "Unknown error";
|
||||
return {
|
||||
success: false,
|
||||
error: `Failed to execute Claude Code: ${errorMessage}`,
|
||||
};
|
||||
// Cleanup on error
|
||||
try {
|
||||
rmSync(tempDir, { recursive: true, force: true });
|
||||
} catch {
|
||||
// Ignore cleanup errors
|
||||
}
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
run: async ({ prompt, mcpServers, apiKey }) => {
|
||||
process.env.ANTHROPIC_API_KEY = apiKey;
|
||||
|
||||
if (!cachedCliPath) {
|
||||
throw new Error("Claude CLI not installed. Call install() before run().");
|
||||
}
|
||||
|
||||
const queryInstance = query({
|
||||
prompt: `${instructions}\n\n****** USER PROMPT ******\n${prompt}`,
|
||||
options: {
|
||||
permissionMode: "bypassPermissions",
|
||||
mcpServers,
|
||||
pathToClaudeCodeExecutable: cachedCliPath,
|
||||
},
|
||||
});
|
||||
|
||||
// Stream the results
|
||||
for await (const message of queryInstance) {
|
||||
const handler = messageHandlers[message.type];
|
||||
await handler(message as never);
|
||||
}
|
||||
|
||||
return {
|
||||
success: true,
|
||||
output: "",
|
||||
};
|
||||
},
|
||||
};
|
||||
|
||||
type SDKMessageType = SDKMessage["type"];
|
||||
|
||||
type SDKMessageHandler<type extends SDKMessageType = SDKMessageType> = (
|
||||
data: Extract<SDKMessage, { type: type }>
|
||||
) => void | Promise<void>;
|
||||
|
||||
type SDKMessageHandlers = {
|
||||
[type in SDKMessageType]: SDKMessageHandler<type>;
|
||||
};
|
||||
|
||||
// Track bash tool IDs to identify when bash tool results come back
|
||||
const bashToolIds = new Set<string>();
|
||||
|
||||
const messageHandlers: SDKMessageHandlers = {
|
||||
assistant: (data) => {
|
||||
if (data.message?.content) {
|
||||
for (const content of data.message.content) {
|
||||
if (content.type === "text" && content.text?.trim()) {
|
||||
log.box(content.text.trim(), { title: "Claude" });
|
||||
} else if (content.type === "tool_use") {
|
||||
log.info(`→ ${content.name}`);
|
||||
|
||||
// Track bash tool IDs
|
||||
if (content.name === "bash" && content.id) {
|
||||
bashToolIds.add(content.id);
|
||||
}
|
||||
|
||||
if (content.input) {
|
||||
const input = content.input as any;
|
||||
if (input.description) log.info(` └─ ${input.description}`);
|
||||
if (input.command) log.info(` └─ command: ${input.command}`);
|
||||
if (input.file_path) log.info(` └─ file: ${input.file_path}`);
|
||||
if (input.content) {
|
||||
const preview =
|
||||
input.content.length > 100
|
||||
? `${input.content.substring(0, 100)}...`
|
||||
: input.content;
|
||||
log.info(` └─ content: ${preview}`);
|
||||
}
|
||||
if (input.query) log.info(` └─ query: ${input.query}`);
|
||||
if (input.pattern) log.info(` └─ pattern: ${input.pattern}`);
|
||||
if (input.url) log.info(` └─ url: ${input.url}`);
|
||||
if (input.edits && Array.isArray(input.edits)) {
|
||||
log.info(` └─ edits: ${input.edits.length} changes`);
|
||||
input.edits.forEach((edit: any, index: number) => {
|
||||
if (edit.file_path) log.info(` ${index + 1}. ${edit.file_path}`);
|
||||
});
|
||||
}
|
||||
if (input.task) log.info(` └─ task: ${input.task}`);
|
||||
if (input.bash_command) log.info(` └─ bash_command: ${input.bash_command}`);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
user: (data) => {
|
||||
if (data.message?.content) {
|
||||
for (const content of data.message.content) {
|
||||
if (content.type === "tool_result") {
|
||||
const toolUseId = (content as any).tool_use_id;
|
||||
const isBashTool = toolUseId && bashToolIds.has(toolUseId);
|
||||
|
||||
if (isBashTool) {
|
||||
// Log bash output in a collapsed group
|
||||
const outputContent =
|
||||
typeof content.content === "string"
|
||||
? content.content
|
||||
: Array.isArray(content.content)
|
||||
? content.content
|
||||
.map((c: any) => (typeof c === "string" ? c : c.text || JSON.stringify(c)))
|
||||
.join("\n")
|
||||
: String(content.content);
|
||||
|
||||
log.startGroup(`bash output`);
|
||||
if (content.is_error) {
|
||||
log.warning(outputContent);
|
||||
} else {
|
||||
log.info(outputContent);
|
||||
}
|
||||
log.endGroup();
|
||||
// Clean up the tracked ID
|
||||
bashToolIds.delete(toolUseId);
|
||||
} else if (content.is_error) {
|
||||
const errorContent =
|
||||
typeof content.content === "string" ? content.content : String(content.content);
|
||||
log.warning(`Tool error: ${errorContent}`);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
result: async (data) => {
|
||||
if (data.subtype === "success") {
|
||||
await log.summaryTable([
|
||||
[
|
||||
{ data: "Cost", header: true },
|
||||
{ data: "Input Tokens", header: true },
|
||||
{ data: "Output Tokens", header: true },
|
||||
],
|
||||
[
|
||||
`$${data.total_cost_usd?.toFixed(4) || "0.0000"}`,
|
||||
String(data.usage?.input_tokens || 0),
|
||||
String(data.usage?.output_tokens || 0),
|
||||
],
|
||||
]);
|
||||
} else if (data.subtype === "error_max_turns") {
|
||||
log.error(`Max turns reached: ${JSON.stringify(data)}`);
|
||||
} else if (data.subtype === "error_during_execution") {
|
||||
log.error(`Execution error: ${JSON.stringify(data)}`);
|
||||
} else {
|
||||
log.error(`Failed: ${JSON.stringify(data)}`);
|
||||
}
|
||||
},
|
||||
system: () => {},
|
||||
stream_event: () => {},
|
||||
tool_progress: () => {},
|
||||
auth_status: () => {},
|
||||
};
|
||||
|
||||
@@ -1,16 +0,0 @@
|
||||
import { ClaudeAgent } from "./claude";
|
||||
import type { Agent, AgentConfig } from "./types";
|
||||
|
||||
export type AgentType = "claude";
|
||||
|
||||
/**
|
||||
* Factory for creating agent instances
|
||||
*/
|
||||
export function createAgent(type: AgentType, config: AgentConfig): Agent {
|
||||
switch (type) {
|
||||
case "claude":
|
||||
return new ClaudeAgent(config);
|
||||
default:
|
||||
throw new Error(`Unsupported agent type: ${type}`);
|
||||
}
|
||||
}
|
||||
@@ -1,3 +0,0 @@
|
||||
export * from "./claude";
|
||||
export * from "./factory";
|
||||
export * from "./types";
|
||||
@@ -0,0 +1,64 @@
|
||||
import type { McpServerConfig } from "@anthropic-ai/claude-agent-sdk";
|
||||
import { ghPullfrogMcpName } from "../mcp/config.ts";
|
||||
|
||||
/**
|
||||
* Result returned by agent execution
|
||||
*/
|
||||
export interface AgentResult {
|
||||
success: boolean;
|
||||
output?: string;
|
||||
error?: string;
|
||||
metadata?: Record<string, unknown>;
|
||||
}
|
||||
|
||||
/**
|
||||
* Configuration for agent creation
|
||||
*/
|
||||
export interface AgentConfig {
|
||||
apiKey: string;
|
||||
githubInstallationToken: string;
|
||||
prompt: string;
|
||||
mcpServers: Record<string, McpServerConfig>;
|
||||
}
|
||||
|
||||
export type Agent = {
|
||||
install: () => Promise<string>;
|
||||
run: (config: AgentConfig) => Promise<AgentResult>;
|
||||
};
|
||||
|
||||
export const instructions = `
|
||||
You are a highly intelligent, no-nonsense senior-level software engineering agent. You are careful, to-the-point, and kind. You only say things you know to be true. Your code is focused, minimal, and production-ready. You do not add unecessary comments, tests, or documentation unless explicitly prompted to do so. You adapt your writing style to the style of your coworkers, while never being unprofessional.
|
||||
|
||||
- eagerly inspect your MCP servers to determine what tools are available to you, especially ${ghPullfrogMcpName}
|
||||
- do not under any circumstances use the github cli (\`gh\`). find the corresponding tool from ${ghPullfrogMcpName} instead.
|
||||
- mode selection: choose the appropriate mode based on the prompt payload:
|
||||
- choose "plan mode" if the prompt asks to:
|
||||
- create a plan, break down tasks, outline steps, or analyze requirements
|
||||
- understand the scope of work before implementation
|
||||
- provide a todo list or task breakdown
|
||||
- choose "implement" if the prompt asks to:
|
||||
- implement, build, create, or develop code changes
|
||||
- make specific changes to files or features
|
||||
- execute a plan that was previously created
|
||||
- the prompt includes specific implementation details or requirements
|
||||
- choose "review" if the prompt asks to:
|
||||
- review code, PR, or implementation
|
||||
- provide feedback, suggestions, or identify issues
|
||||
- check code quality, style, or correctness
|
||||
- once you've chosen a mode, follow its associated prompts carefully
|
||||
- when prompted directly (e.g., via issue comment or PR comment):
|
||||
(1) start by creating a single response comment using mcp__${ghPullfrogMcpName}__create_issue_comment
|
||||
- the initial comment should say something like "I'll do {summary of request}" where you summarize what was requested
|
||||
- save the commentId returned from this initial comment creation
|
||||
(2) use mcp__${ghPullfrogMcpName}__edit_issue_comment to progressively update that same comment as you make progress
|
||||
- update the comment with current status, completed tasks, and any relevant information
|
||||
- continue updating the same comment throughout the planning/implementation process
|
||||
(3) create_issue_comment should only be used once initially - all subsequent updates must use edit_issue_comment with the saved commentId
|
||||
- if prompted to review a PR:
|
||||
(1) get PR info with mcp__${ghPullfrogMcpName}__get_pull_request (this automatically prepares the repository by fetching and checking out the PR branch)
|
||||
(2) view diff: git diff origin/<base>...origin/<head> (use line numbers from this for inline comments)
|
||||
(3) read files from the checked-out PR branch to understand the implementation
|
||||
(4) when submitting review: use the 'comments' array for ALL specific code issues - include the file path and line position from the diff
|
||||
(5) only use the 'body' field for a brief summary (1-2 sentences) or for feedback that doesn't apply to a specific code location
|
||||
replace <base> and <head> with 'base' and 'head' from the PR info
|
||||
`;
|
||||
@@ -1,34 +0,0 @@
|
||||
/**
|
||||
* Standard interface for all Pullfrog agents
|
||||
*/
|
||||
export interface Agent {
|
||||
/**
|
||||
* Install the agent and any required dependencies
|
||||
*/
|
||||
install(): Promise<void>;
|
||||
|
||||
/**
|
||||
* Execute the agent with the given prompt
|
||||
* @param prompt The prompt to send to the agent
|
||||
* @param options Additional options specific to the agent
|
||||
*/
|
||||
execute(prompt: string, options?: Record<string, any>): Promise<AgentResult>;
|
||||
}
|
||||
|
||||
/**
|
||||
* Result returned by agent execution
|
||||
*/
|
||||
export interface AgentResult {
|
||||
success: boolean;
|
||||
output?: string;
|
||||
error?: string;
|
||||
metadata?: Record<string, any>;
|
||||
}
|
||||
|
||||
/**
|
||||
* Configuration for agent creation
|
||||
*/
|
||||
export interface AgentConfig {
|
||||
apiKey?: string;
|
||||
[key: string]: any;
|
||||
}
|
||||
Vendored
-17731
File diff suppressed because one or more lines are too long
@@ -0,0 +1,37 @@
|
||||
#!/usr/bin/env node
|
||||
|
||||
/**
|
||||
* Entry point for GitHub Action
|
||||
*/
|
||||
|
||||
import * as core from "@actions/core";
|
||||
import { type Inputs, main } from "./main.ts";
|
||||
import { log } from "./utils/cli.ts";
|
||||
|
||||
async function run(): Promise<void> {
|
||||
// Change to GITHUB_WORKSPACE if set (this is where actions/checkout puts the repo)
|
||||
// JavaScript actions run from the action's directory, not the checked-out repo
|
||||
if (process.env.GITHUB_WORKSPACE && process.cwd() !== process.env.GITHUB_WORKSPACE) {
|
||||
log.debug(`Changing to GITHUB_WORKSPACE: ${process.env.GITHUB_WORKSPACE}`);
|
||||
process.chdir(process.env.GITHUB_WORKSPACE);
|
||||
log.debug(`New working directory: ${process.cwd()}`);
|
||||
}
|
||||
|
||||
try {
|
||||
const inputs: Inputs = {
|
||||
prompt: core.getInput("prompt", { required: true }),
|
||||
anthropic_api_key: core.getInput("anthropic_api_key") || undefined,
|
||||
};
|
||||
|
||||
const result = await main(inputs);
|
||||
|
||||
if (!result.success) {
|
||||
throw new Error(result.error || "Agent execution failed");
|
||||
}
|
||||
} catch (error) {
|
||||
const errorMessage = error instanceof Error ? error.message : "Unknown error occurred";
|
||||
core.setFailed(`Action failed: ${errorMessage}`);
|
||||
}
|
||||
}
|
||||
|
||||
await run();
|
||||
+37
-8
@@ -1,14 +1,43 @@
|
||||
import { build } from 'esbuild';
|
||||
import { build } from "esbuild";
|
||||
|
||||
await build({
|
||||
entryPoints: ['./index.ts'],
|
||||
const sharedConfig = {
|
||||
bundle: true,
|
||||
outfile: './index.cjs',
|
||||
format: 'cjs',
|
||||
platform: 'node',
|
||||
target: 'node20',
|
||||
format: "esm",
|
||||
platform: "node",
|
||||
target: "node20",
|
||||
minify: false,
|
||||
sourcemap: false,
|
||||
// Bundle all dependencies - GitHub Actions doesn't have node_modules
|
||||
// Only mark optional peer dependencies as external
|
||||
external: [
|
||||
"@valibot/to-json-schema",
|
||||
"effect",
|
||||
"sury",
|
||||
],
|
||||
// Provide a proper require shim for CommonJS modules bundled into ESM
|
||||
// We use a unique variable name to avoid conflicts with bundled imports
|
||||
banner: {
|
||||
js: `import { createRequire as __createRequire } from 'module'; import { fileURLToPath as __fileURLToPath } from 'url'; import { dirname as __dirnameFn } from 'path'; const require = __createRequire(import.meta.url); const __filename = __fileURLToPath(import.meta.url); const __dirname = __dirnameFn(__filename);`,
|
||||
},
|
||||
// Enable tree-shaking to remove unused code
|
||||
treeShaking: true,
|
||||
// Drop console statements in production (but keep for debugging)
|
||||
drop: [],
|
||||
};
|
||||
|
||||
// Build the main entry bundle (without MCP)
|
||||
await build({
|
||||
...sharedConfig,
|
||||
entryPoints: ["./entry.ts"],
|
||||
outfile: "./entry.js",
|
||||
});
|
||||
|
||||
console.log('✅ Build completed successfully!');
|
||||
// Build the MCP server bundle
|
||||
await build({
|
||||
...sharedConfig,
|
||||
entryPoints: ["./mcp/server.ts"],
|
||||
outfile: "./mcp-server.js",
|
||||
});
|
||||
|
||||
console.log("✅ Build completed successfully!");
|
||||
|
||||
|
||||
@@ -0,0 +1,9 @@
|
||||
import type { Inputs } from "../main.ts";
|
||||
|
||||
const testParams = {
|
||||
prompt:
|
||||
"List all files in the current directory, then create a file called dynamic-test.txt with the content 'This was loaded from a TypeScript file!', then delete it.",
|
||||
anthropic_api_key: "sk-test-key",
|
||||
} satisfies Inputs;
|
||||
|
||||
export default testParams;
|
||||
@@ -0,0 +1 @@
|
||||
run npx cowsay "don't eat me"
|
||||
@@ -1,39 +1,11 @@
|
||||
import * as core from "@actions/core";
|
||||
import { createAgent } from "./agents/factory";
|
||||
/**
|
||||
* Library entry point for npm package
|
||||
* This exports the main function for programmatic usage
|
||||
*/
|
||||
|
||||
async function main(): Promise<void> {
|
||||
try {
|
||||
// Get inputs
|
||||
const prompt = core.getInput("prompt", { required: true });
|
||||
const anthropicApiKey = core.getInput("anthropic_api_key", { required: true });
|
||||
|
||||
if (!anthropicApiKey) {
|
||||
throw new Error("anthropic_api_key is required");
|
||||
}
|
||||
|
||||
core.info(`🐸 Pullfrog Claude Code Action starting...`);
|
||||
core.info(`Prompt: ${prompt}`);
|
||||
|
||||
// Create and install the Claude agent
|
||||
const agent = createAgent("claude", { apiKey: anthropicApiKey });
|
||||
await agent.install();
|
||||
|
||||
// Execute the agent with the prompt
|
||||
const result = await agent.execute(prompt);
|
||||
|
||||
if (!result.success) {
|
||||
throw new Error(result.error || "Agent execution failed");
|
||||
}
|
||||
|
||||
// Set outputs
|
||||
core.setOutput("status", "success");
|
||||
core.setOutput("prompt", prompt);
|
||||
core.setOutput("output", result.output || "");
|
||||
} catch (error) {
|
||||
const errorMessage = error instanceof Error ? error.message : "Unknown error occurred";
|
||||
core.setFailed(`Action failed: ${errorMessage}`);
|
||||
}
|
||||
}
|
||||
|
||||
// Execute main function
|
||||
main();
|
||||
export type { Agent, AgentConfig, AgentResult } from "./agents/shared.ts";
|
||||
export {
|
||||
type Inputs as ExecutionInputs,
|
||||
type MainResult,
|
||||
main,
|
||||
} from "./main.ts";
|
||||
|
||||
@@ -0,0 +1,80 @@
|
||||
import { type } from "arktype";
|
||||
import { claude } from "./agents/claude.ts";
|
||||
import { createMcpConfigs } from "./mcp/config.ts";
|
||||
import packageJson from "./package.json" with { type: "json" };
|
||||
import { log } from "./utils/cli.ts";
|
||||
import { parseRepoContext, setupGitHubInstallationToken } from "./utils/github.ts";
|
||||
import { setupGitAuth, setupGitConfig } from "./utils/setup.ts";
|
||||
|
||||
export const Inputs = type({
|
||||
prompt: "string",
|
||||
"anthropic_api_key?": "string | undefined",
|
||||
});
|
||||
|
||||
export type Inputs = typeof Inputs.infer;
|
||||
|
||||
export interface MainResult {
|
||||
success: boolean;
|
||||
output?: string | undefined;
|
||||
error?: string | undefined;
|
||||
}
|
||||
|
||||
export type PromptJSON = {};
|
||||
|
||||
export async function main(inputs: Inputs): Promise<MainResult> {
|
||||
try {
|
||||
log.info(`🐸 Running pullfrog/action@${packageJson.version}...`);
|
||||
|
||||
setupGitConfig();
|
||||
|
||||
const githubInstallationToken = await setupGitHubInstallationToken();
|
||||
const repoContext = parseRepoContext();
|
||||
|
||||
setupGitAuth(githubInstallationToken, repoContext);
|
||||
|
||||
const mcpServers = createMcpConfigs(githubInstallationToken);
|
||||
|
||||
log.debug(`📋 MCP Config: ${JSON.stringify(mcpServers, null, 2)}`);
|
||||
|
||||
// Install Claude CLI before running
|
||||
await claude.install();
|
||||
|
||||
log.info("Running Claude Agent SDK...");
|
||||
log.box(inputs.prompt, { title: "Prompt" });
|
||||
|
||||
// TODO: check if `inputs.prompts` is JSON
|
||||
// if yes, check if it's a webhook payload or toJSON(github.event)
|
||||
// for webhook payloads, check the specified `agent` field
|
||||
|
||||
const result = await claude.run({
|
||||
prompt: inputs.prompt,
|
||||
mcpServers,
|
||||
githubInstallationToken,
|
||||
apiKey: inputs.anthropic_api_key!,
|
||||
});
|
||||
|
||||
if (!result.success) {
|
||||
return {
|
||||
success: false,
|
||||
error: result.error || "Agent execution failed",
|
||||
output: result.output!,
|
||||
};
|
||||
}
|
||||
|
||||
log.success("Task complete.");
|
||||
await log.writeSummary();
|
||||
|
||||
return {
|
||||
success: true,
|
||||
output: result.output || "",
|
||||
};
|
||||
} catch (error) {
|
||||
const errorMessage = error instanceof Error ? error.message : "Unknown error occurred";
|
||||
log.error(errorMessage);
|
||||
await log.writeSummary();
|
||||
return {
|
||||
success: false,
|
||||
error: errorMessage,
|
||||
};
|
||||
}
|
||||
}
|
||||
Executable
+101715
File diff suppressed because one or more lines are too long
@@ -0,0 +1,55 @@
|
||||
import { type } from "arktype";
|
||||
import { contextualize, tool } from "./shared.ts";
|
||||
|
||||
export const Comment = type({
|
||||
issueNumber: type.number.describe("the issue number to comment on"),
|
||||
body: type.string.describe("the comment body content"),
|
||||
});
|
||||
|
||||
export const CreateCommentTool = tool({
|
||||
name: "create_issue_comment",
|
||||
description: "Create a comment on a GitHub issue",
|
||||
parameters: Comment,
|
||||
execute: contextualize(async ({ issueNumber, body }, ctx) => {
|
||||
const result = await ctx.octokit.rest.issues.createComment({
|
||||
owner: ctx.owner,
|
||||
repo: ctx.name,
|
||||
issue_number: issueNumber,
|
||||
body: body,
|
||||
});
|
||||
|
||||
return {
|
||||
success: true,
|
||||
commentId: result.data.id,
|
||||
url: result.data.html_url,
|
||||
body: result.data.body,
|
||||
};
|
||||
}),
|
||||
});
|
||||
|
||||
export const EditComment = type({
|
||||
commentId: type.number.describe("the ID of the comment to edit"),
|
||||
body: type.string.describe("the new comment body content"),
|
||||
});
|
||||
|
||||
export const EditCommentTool = tool({
|
||||
name: "edit_issue_comment",
|
||||
description: "Edit a GitHub issue comment by its ID",
|
||||
parameters: EditComment,
|
||||
execute: contextualize(async ({ commentId, body }, ctx) => {
|
||||
const result = await ctx.octokit.rest.issues.updateComment({
|
||||
owner: ctx.owner,
|
||||
repo: ctx.name,
|
||||
comment_id: commentId,
|
||||
body: body,
|
||||
});
|
||||
|
||||
return {
|
||||
success: true,
|
||||
commentId: result.data.id,
|
||||
url: result.data.html_url,
|
||||
body: result.data.body,
|
||||
updatedAt: result.data.updated_at,
|
||||
};
|
||||
}),
|
||||
});
|
||||
@@ -0,0 +1,54 @@
|
||||
/**
|
||||
* Simple MCP configuration helper for adding our minimal GitHub comment server
|
||||
*/
|
||||
|
||||
import { existsSync, readdirSync } from "node:fs";
|
||||
import { dirname } from "node:path";
|
||||
import type { McpServerConfig } from "@anthropic-ai/claude-agent-sdk";
|
||||
import { fromHere } from "@ark/fs";
|
||||
import { log } from "../utils/cli.ts";
|
||||
import { parseRepoContext } from "../utils/github.ts";
|
||||
|
||||
export const ghPullfrogMcpName = "gh-pullfrog";
|
||||
|
||||
export type McpName = typeof ghPullfrogMcpName;
|
||||
|
||||
export type McpConfigs = Record<McpName, McpServerConfig>;
|
||||
|
||||
export function createMcpConfigs(githubInstallationToken: string): McpConfigs {
|
||||
const repoContext = parseRepoContext();
|
||||
const githubRepository = `${repoContext.owner}/${repoContext.name}`;
|
||||
|
||||
const serverPath = process.env.GITHUB_ACTION_PATH
|
||||
? `${process.env.GITHUB_ACTION_PATH}/mcp-server.js`
|
||||
: fromHere("server.ts");
|
||||
|
||||
// Debug: Log server path and check if it exists
|
||||
log.info(`MCP Server Path: ${serverPath}`);
|
||||
const pathExists = existsSync(serverPath);
|
||||
log.info(`MCP Server Path exists: ${pathExists}`);
|
||||
|
||||
if (!pathExists) {
|
||||
const dir = dirname(serverPath);
|
||||
log.info(`Directory: ${dir}`);
|
||||
try {
|
||||
const files = readdirSync(dir);
|
||||
log.info(`Files in directory: ${files.join(", ")}`);
|
||||
} catch (error) {
|
||||
log.warning(
|
||||
`Failed to read directory: ${error instanceof Error ? error.message : String(error)}`
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
return {
|
||||
[ghPullfrogMcpName]: {
|
||||
command: "node",
|
||||
args: [serverPath],
|
||||
env: {
|
||||
GITHUB_INSTALLATION_TOKEN: githubInstallationToken,
|
||||
GITHUB_REPOSITORY: githubRepository,
|
||||
},
|
||||
},
|
||||
};
|
||||
}
|
||||
@@ -0,0 +1,42 @@
|
||||
import { type } from "arktype";
|
||||
import { contextualize, tool } from "./shared.ts";
|
||||
|
||||
export const Issue = type({
|
||||
title: type.string.describe("the title of the issue"),
|
||||
body: type.string.describe("the body content of the issue"),
|
||||
labels: type.string
|
||||
.array()
|
||||
.describe("optional array of label names to apply to the issue")
|
||||
.optional(),
|
||||
assignees: type.string
|
||||
.array()
|
||||
.describe("optional array of usernames to assign to the issue")
|
||||
.optional(),
|
||||
});
|
||||
|
||||
export const IssueTool = tool({
|
||||
name: "create_issue",
|
||||
description: "Create a new GitHub issue",
|
||||
parameters: Issue,
|
||||
execute: contextualize(async ({ title, body, labels, assignees }, ctx) => {
|
||||
const result = await ctx.octokit.rest.issues.create({
|
||||
owner: ctx.owner,
|
||||
repo: ctx.name,
|
||||
title: title,
|
||||
body: body,
|
||||
labels: labels ?? [],
|
||||
assignees: assignees ?? [],
|
||||
});
|
||||
|
||||
return {
|
||||
success: true,
|
||||
issueId: result.data.id,
|
||||
number: result.data.number,
|
||||
url: result.data.html_url,
|
||||
title: result.data.title,
|
||||
state: result.data.state,
|
||||
labels: result.data.labels?.map((label) => (typeof label === "string" ? label : label.name)),
|
||||
assignees: result.data.assignees?.map((assignee) => assignee.login),
|
||||
};
|
||||
}),
|
||||
});
|
||||
@@ -0,0 +1,43 @@
|
||||
import { execSync } from "node:child_process";
|
||||
import { type } from "arktype";
|
||||
import { log } from "../utils/cli.ts";
|
||||
import { contextualize, tool } from "./shared.ts";
|
||||
|
||||
export const PullRequest = type({
|
||||
title: type.string.describe("the title of the pull request"),
|
||||
body: type.string.describe("the body content of the pull request"),
|
||||
base: type.string.describe("the base branch to merge into (e.g., 'main')"),
|
||||
});
|
||||
|
||||
export const PullRequestTool = tool({
|
||||
name: "create_pull_request",
|
||||
description: "Create a pull request from the current branch",
|
||||
parameters: PullRequest,
|
||||
execute: contextualize(async ({ title, body, base }, ctx) => {
|
||||
// Get the current branch name
|
||||
const currentBranch = execSync("git rev-parse --abbrev-ref HEAD", {
|
||||
encoding: "utf8",
|
||||
}).trim();
|
||||
|
||||
log.info(`Current branch: ${currentBranch}`);
|
||||
|
||||
const result = await ctx.octokit.rest.pulls.create({
|
||||
owner: ctx.owner,
|
||||
repo: ctx.name,
|
||||
title: title,
|
||||
body: body,
|
||||
head: currentBranch,
|
||||
base: base,
|
||||
});
|
||||
|
||||
return {
|
||||
success: true,
|
||||
pullRequestId: result.data.id,
|
||||
number: result.data.number,
|
||||
url: result.data.html_url,
|
||||
title: result.data.title,
|
||||
head: result.data.head.ref,
|
||||
base: result.data.base.ref,
|
||||
};
|
||||
}),
|
||||
});
|
||||
@@ -0,0 +1,52 @@
|
||||
import { execSync } from "node:child_process";
|
||||
import { type } from "arktype";
|
||||
import { log } from "../utils/cli.ts";
|
||||
import { contextualize, tool } from "./shared.ts";
|
||||
|
||||
export const PullRequestInfo = type({
|
||||
pull_number: type.number.describe("The pull request number to fetch"),
|
||||
});
|
||||
|
||||
export const PullRequestInfoTool = tool({
|
||||
name: "get_pull_request",
|
||||
description:
|
||||
"Retrieve PR information and automatically prepare the repository for review by fetching and checking out the PR branch.",
|
||||
parameters: PullRequestInfo,
|
||||
execute: contextualize(async ({ pull_number }, ctx) => {
|
||||
const pr = await ctx.octokit.rest.pulls.get({
|
||||
owner: ctx.owner,
|
||||
repo: ctx.name,
|
||||
pull_number,
|
||||
});
|
||||
|
||||
const data = pr.data;
|
||||
|
||||
const baseBranch = data.base.ref;
|
||||
const headBranch = data.head.ref;
|
||||
|
||||
if (!baseBranch) {
|
||||
throw new Error(`Base branch not found for PR #${pull_number}`);
|
||||
}
|
||||
|
||||
// Automatically fetch and checkout branches for review
|
||||
log.info(`Fetching base branch: origin/${baseBranch}`);
|
||||
execSync(`git fetch origin ${baseBranch} --depth=20`, { stdio: "inherit" });
|
||||
|
||||
log.info(`Fetching PR branch: origin/${headBranch}`);
|
||||
execSync(`git fetch origin ${headBranch}`, { stdio: "inherit" });
|
||||
|
||||
log.info(`Checking out PR branch: origin/${headBranch}`);
|
||||
execSync(`git checkout origin/${headBranch}`, { stdio: "inherit" });
|
||||
|
||||
return {
|
||||
number: data.number,
|
||||
url: data.html_url,
|
||||
title: data.title,
|
||||
state: data.state,
|
||||
draft: data.draft,
|
||||
merged: data.merged,
|
||||
base: baseBranch,
|
||||
head: headBranch,
|
||||
};
|
||||
}),
|
||||
});
|
||||
@@ -0,0 +1,94 @@
|
||||
import type { RestEndpointMethodTypes } from "@octokit/rest";
|
||||
import { type } from "arktype";
|
||||
import { contextualize, tool } from "./shared.ts";
|
||||
|
||||
export const Review = type({
|
||||
pull_number: type.number.describe("The pull request number to review"),
|
||||
event: type
|
||||
.enumerated("APPROVE", "REQUEST_CHANGES", "COMMENT")
|
||||
.describe("'APPROVE', 'REQUEST_CHANGES', or 'COMMENT' (the review action)"),
|
||||
body: type.string
|
||||
.describe(
|
||||
"Brief summary or general feedback that doesn't apply to specific code locations. Keep it concise - most feedback should be in the 'comments' array."
|
||||
)
|
||||
.optional(),
|
||||
commit_id: type.string
|
||||
.describe("Optional SHA of the commit being reviewed. Defaults to latest.")
|
||||
.optional(),
|
||||
comments: type({
|
||||
path: type.string.describe("The file path to comment on (relative to repo root)"),
|
||||
line: type.number.describe(
|
||||
"The line number in the file (use line numbers from the diff - usually the RIGHT side/new code)"
|
||||
),
|
||||
side: type
|
||||
.enumerated("LEFT", "RIGHT")
|
||||
.describe(
|
||||
"Side of the diff: LEFT (old code) or RIGHT (new code). Defaults to RIGHT if not provided."
|
||||
)
|
||||
.optional(),
|
||||
body: type.string.describe("The comment text for this specific line"),
|
||||
start_line: type.number
|
||||
.describe("Start line for multi-line comments (optional, for commenting on ranges)")
|
||||
.optional(),
|
||||
})
|
||||
.array()
|
||||
.describe(
|
||||
"REQUIRED: Array of inline comments for specific code issues. Use this for all location-specific feedback. Use 'git diff origin/<base>...origin/<head>' to find the correct line numbers (typically use the line numbers shown on the RIGHT side for new code, LEFT side for old code)."
|
||||
)
|
||||
.optional(),
|
||||
});
|
||||
|
||||
export const ReviewTool = tool({
|
||||
name: "submit_pull_request_review",
|
||||
description:
|
||||
"Submit a review (approve, request changes, or comment) for an existing pull request. " +
|
||||
"IMPORTANT: Use 'comments' array for ALL specific code issues at the line-level. " +
|
||||
"Only use 'body' for a brief summary or feedback that doesn't apply to a specific location.",
|
||||
parameters: Review,
|
||||
execute: contextualize(async ({ pull_number, event, body, commit_id, comments = [] }, ctx) => {
|
||||
// Get the PR to determine the head commit if commit_id not provided
|
||||
const pr = await ctx.octokit.rest.pulls.get({
|
||||
owner: ctx.owner,
|
||||
repo: ctx.name,
|
||||
pull_number,
|
||||
});
|
||||
|
||||
// Compose the request
|
||||
const params: RestEndpointMethodTypes["pulls"]["createReview"]["parameters"] = {
|
||||
owner: ctx.owner,
|
||||
repo: ctx.name,
|
||||
pull_number,
|
||||
event,
|
||||
};
|
||||
if (body) params.body = body;
|
||||
if (commit_id) {
|
||||
params.commit_id = commit_id;
|
||||
} else {
|
||||
params.commit_id = pr.data.head.sha;
|
||||
}
|
||||
if (comments.length > 0) {
|
||||
type ReviewComment = (typeof params.comments & {})[number];
|
||||
// Convert comments to the format expected by GitHub API
|
||||
params.comments = comments.map((comment) => {
|
||||
const reviewComment: ReviewComment = {
|
||||
...comment,
|
||||
};
|
||||
reviewComment.side = comment.side || "RIGHT";
|
||||
if (comment.start_line) {
|
||||
reviewComment.start_line = comment.start_line;
|
||||
reviewComment.start_side = comment.side || "RIGHT";
|
||||
}
|
||||
return reviewComment;
|
||||
});
|
||||
}
|
||||
const result = await ctx.octokit.rest.pulls.createReview(params);
|
||||
return {
|
||||
success: true,
|
||||
reviewId: result.data.id,
|
||||
html_url: result.data.html_url,
|
||||
state: result.data.state,
|
||||
user: result.data.user?.login,
|
||||
submitted_at: result.data.submitted_at,
|
||||
};
|
||||
}),
|
||||
});
|
||||
@@ -0,0 +1,25 @@
|
||||
#!/usr/bin/env node
|
||||
// Minimal GitHub Issue Comment MCP Server
|
||||
import { FastMCP } from "fastmcp";
|
||||
import { CreateCommentTool, EditCommentTool } from "./comment.ts";
|
||||
import { IssueTool } from "./issue.ts";
|
||||
import { PullRequestTool } from "./pr.ts";
|
||||
import { PullRequestInfoTool } from "./prInfo.ts";
|
||||
import { ReviewTool } from "./review.ts";
|
||||
import { addTools } from "./shared.ts";
|
||||
|
||||
const server = new FastMCP({
|
||||
name: "gh-pullfrog",
|
||||
version: "0.0.1",
|
||||
});
|
||||
|
||||
addTools(server, [
|
||||
CreateCommentTool,
|
||||
EditCommentTool,
|
||||
IssueTool,
|
||||
PullRequestTool,
|
||||
ReviewTool,
|
||||
PullRequestInfoTool,
|
||||
]);
|
||||
|
||||
server.start();
|
||||
@@ -0,0 +1,76 @@
|
||||
import { cached } from "@ark/util";
|
||||
import { Octokit } from "@octokit/rest";
|
||||
import type { StandardSchemaV1 } from "@standard-schema/spec";
|
||||
import type { FastMCP, Tool } from "fastmcp";
|
||||
import { parseRepoContext, type RepoContext } from "../utils/github.ts";
|
||||
|
||||
export interface ToolResult {
|
||||
content: {
|
||||
type: "text";
|
||||
text: string;
|
||||
}[];
|
||||
isError?: boolean;
|
||||
}
|
||||
|
||||
export const getMcpContext = cached((): McpContext => {
|
||||
const githubInstallationToken = process.env.GITHUB_INSTALLATION_TOKEN;
|
||||
if (!githubInstallationToken) {
|
||||
throw new Error("GITHUB_INSTALLATION_TOKEN environment variable is required");
|
||||
}
|
||||
|
||||
return {
|
||||
...parseRepoContext(),
|
||||
octokit: new Octokit({
|
||||
auth: githubInstallationToken,
|
||||
}),
|
||||
};
|
||||
});
|
||||
|
||||
export interface McpContext extends RepoContext {
|
||||
octokit: Octokit;
|
||||
}
|
||||
|
||||
export const tool = <const params>(tool: Tool<any, StandardSchemaV1<params>>) => tool;
|
||||
|
||||
export const addTools = (server: FastMCP, tools: Tool<any, any>[]) => {
|
||||
for (const tool of tools) {
|
||||
server.addTool(tool);
|
||||
}
|
||||
return server;
|
||||
};
|
||||
|
||||
export const contextualize =
|
||||
<T>(executor: (params: T, ctx: McpContext) => Promise<Record<string, any>>) =>
|
||||
async (params: T): Promise<ToolResult> => {
|
||||
try {
|
||||
const ctx = getMcpContext();
|
||||
const result = await executor(params, ctx);
|
||||
return handleToolSuccess(result);
|
||||
} catch (error) {
|
||||
return handleToolError(error);
|
||||
}
|
||||
};
|
||||
|
||||
const handleToolSuccess = (data: Record<string, any>): ToolResult => {
|
||||
return {
|
||||
content: [
|
||||
{
|
||||
type: "text",
|
||||
text: JSON.stringify(data, null, 2),
|
||||
},
|
||||
],
|
||||
};
|
||||
};
|
||||
|
||||
const handleToolError = (error: unknown): ToolResult => {
|
||||
const errorMessage = error instanceof Error ? error.message : String(error);
|
||||
return {
|
||||
content: [
|
||||
{
|
||||
type: "text",
|
||||
text: `Error: ${errorMessage}`,
|
||||
},
|
||||
],
|
||||
isError: true,
|
||||
};
|
||||
};
|
||||
Generated
-601
@@ -1,601 +0,0 @@
|
||||
{
|
||||
"name": "action",
|
||||
"version": "0.0.2",
|
||||
"lockfileVersion": 3,
|
||||
"requires": true,
|
||||
"packages": {
|
||||
"": {
|
||||
"name": "action",
|
||||
"version": "0.0.2",
|
||||
"license": "ISC",
|
||||
"dependencies": {
|
||||
"@actions/core": "^1.10.1"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@types/node": "^20.10.0",
|
||||
"esbuild": "^0.25.9",
|
||||
"typescript": "^5.3.0"
|
||||
}
|
||||
},
|
||||
"node_modules/@actions/core": {
|
||||
"version": "1.11.1",
|
||||
"resolved": "https://registry.npmjs.org/@actions/core/-/core-1.11.1.tgz",
|
||||
"integrity": "sha512-hXJCSrkwfA46Vd9Z3q4cpEpHB1rL5NG04+/rbqW9d3+CSvtB1tYe8UTpAlixa1vj0m/ULglfEK2UKxMGxCxv5A==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"@actions/exec": "^1.1.1",
|
||||
"@actions/http-client": "^2.0.1"
|
||||
}
|
||||
},
|
||||
"node_modules/@actions/exec": {
|
||||
"version": "1.1.1",
|
||||
"resolved": "https://registry.npmjs.org/@actions/exec/-/exec-1.1.1.tgz",
|
||||
"integrity": "sha512-+sCcHHbVdk93a0XT19ECtO/gIXoxvdsgQLzb2fE2/5sIZmWQuluYyjPQtrtTHdU1YzTZ7bAPN4sITq2xi1679w==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"@actions/io": "^1.0.1"
|
||||
}
|
||||
},
|
||||
"node_modules/@actions/http-client": {
|
||||
"version": "2.2.3",
|
||||
"resolved": "https://registry.npmjs.org/@actions/http-client/-/http-client-2.2.3.tgz",
|
||||
"integrity": "sha512-mx8hyJi/hjFvbPokCg4uRd4ZX78t+YyRPtnKWwIl+RzNaVuFpQHfmlGVfsKEJN8LwTCvL+DfVgAM04XaHkm6bA==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"tunnel": "^0.0.6",
|
||||
"undici": "^5.25.4"
|
||||
}
|
||||
},
|
||||
"node_modules/@actions/io": {
|
||||
"version": "1.1.3",
|
||||
"resolved": "https://registry.npmjs.org/@actions/io/-/io-1.1.3.tgz",
|
||||
"integrity": "sha512-wi9JjgKLYS7U/z8PPbco+PvTb/nRWjeoFlJ1Qer83k/3C5PHQi28hiVdeE2kHXmIL99mQFawx8qt/JPjZilJ8Q==",
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/@esbuild/aix-ppc64": {
|
||||
"version": "0.25.9",
|
||||
"resolved": "https://registry.npmjs.org/@esbuild/aix-ppc64/-/aix-ppc64-0.25.9.tgz",
|
||||
"integrity": "sha512-OaGtL73Jck6pBKjNIe24BnFE6agGl+6KxDtTfHhy1HmhthfKouEcOhqpSL64K4/0WCtbKFLOdzD/44cJ4k9opA==",
|
||||
"cpu": [
|
||||
"ppc64"
|
||||
],
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"optional": true,
|
||||
"os": [
|
||||
"aix"
|
||||
],
|
||||
"engines": {
|
||||
"node": ">=18"
|
||||
}
|
||||
},
|
||||
"node_modules/@esbuild/android-arm": {
|
||||
"version": "0.25.9",
|
||||
"resolved": "https://registry.npmjs.org/@esbuild/android-arm/-/android-arm-0.25.9.tgz",
|
||||
"integrity": "sha512-5WNI1DaMtxQ7t7B6xa572XMXpHAaI/9Hnhk8lcxF4zVN4xstUgTlvuGDorBguKEnZO70qwEcLpfifMLoxiPqHQ==",
|
||||
"cpu": [
|
||||
"arm"
|
||||
],
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"optional": true,
|
||||
"os": [
|
||||
"android"
|
||||
],
|
||||
"engines": {
|
||||
"node": ">=18"
|
||||
}
|
||||
},
|
||||
"node_modules/@esbuild/android-arm64": {
|
||||
"version": "0.25.9",
|
||||
"resolved": "https://registry.npmjs.org/@esbuild/android-arm64/-/android-arm64-0.25.9.tgz",
|
||||
"integrity": "sha512-IDrddSmpSv51ftWslJMvl3Q2ZT98fUSL2/rlUXuVqRXHCs5EUF1/f+jbjF5+NG9UffUDMCiTyh8iec7u8RlTLg==",
|
||||
"cpu": [
|
||||
"arm64"
|
||||
],
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"optional": true,
|
||||
"os": [
|
||||
"android"
|
||||
],
|
||||
"engines": {
|
||||
"node": ">=18"
|
||||
}
|
||||
},
|
||||
"node_modules/@esbuild/android-x64": {
|
||||
"version": "0.25.9",
|
||||
"resolved": "https://registry.npmjs.org/@esbuild/android-x64/-/android-x64-0.25.9.tgz",
|
||||
"integrity": "sha512-I853iMZ1hWZdNllhVZKm34f4wErd4lMyeV7BLzEExGEIZYsOzqDWDf+y082izYUE8gtJnYHdeDpN/6tUdwvfiw==",
|
||||
"cpu": [
|
||||
"x64"
|
||||
],
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"optional": true,
|
||||
"os": [
|
||||
"android"
|
||||
],
|
||||
"engines": {
|
||||
"node": ">=18"
|
||||
}
|
||||
},
|
||||
"node_modules/@esbuild/darwin-arm64": {
|
||||
"version": "0.25.9",
|
||||
"resolved": "https://registry.npmjs.org/@esbuild/darwin-arm64/-/darwin-arm64-0.25.9.tgz",
|
||||
"integrity": "sha512-XIpIDMAjOELi/9PB30vEbVMs3GV1v2zkkPnuyRRURbhqjyzIINwj+nbQATh4H9GxUgH1kFsEyQMxwiLFKUS6Rg==",
|
||||
"cpu": [
|
||||
"arm64"
|
||||
],
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"optional": true,
|
||||
"os": [
|
||||
"darwin"
|
||||
],
|
||||
"engines": {
|
||||
"node": ">=18"
|
||||
}
|
||||
},
|
||||
"node_modules/@esbuild/darwin-x64": {
|
||||
"version": "0.25.9",
|
||||
"resolved": "https://registry.npmjs.org/@esbuild/darwin-x64/-/darwin-x64-0.25.9.tgz",
|
||||
"integrity": "sha512-jhHfBzjYTA1IQu8VyrjCX4ApJDnH+ez+IYVEoJHeqJm9VhG9Dh2BYaJritkYK3vMaXrf7Ogr/0MQ8/MeIefsPQ==",
|
||||
"cpu": [
|
||||
"x64"
|
||||
],
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"optional": true,
|
||||
"os": [
|
||||
"darwin"
|
||||
],
|
||||
"engines": {
|
||||
"node": ">=18"
|
||||
}
|
||||
},
|
||||
"node_modules/@esbuild/freebsd-arm64": {
|
||||
"version": "0.25.9",
|
||||
"resolved": "https://registry.npmjs.org/@esbuild/freebsd-arm64/-/freebsd-arm64-0.25.9.tgz",
|
||||
"integrity": "sha512-z93DmbnY6fX9+KdD4Ue/H6sYs+bhFQJNCPZsi4XWJoYblUqT06MQUdBCpcSfuiN72AbqeBFu5LVQTjfXDE2A6Q==",
|
||||
"cpu": [
|
||||
"arm64"
|
||||
],
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"optional": true,
|
||||
"os": [
|
||||
"freebsd"
|
||||
],
|
||||
"engines": {
|
||||
"node": ">=18"
|
||||
}
|
||||
},
|
||||
"node_modules/@esbuild/freebsd-x64": {
|
||||
"version": "0.25.9",
|
||||
"resolved": "https://registry.npmjs.org/@esbuild/freebsd-x64/-/freebsd-x64-0.25.9.tgz",
|
||||
"integrity": "sha512-mrKX6H/vOyo5v71YfXWJxLVxgy1kyt1MQaD8wZJgJfG4gq4DpQGpgTB74e5yBeQdyMTbgxp0YtNj7NuHN0PoZg==",
|
||||
"cpu": [
|
||||
"x64"
|
||||
],
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"optional": true,
|
||||
"os": [
|
||||
"freebsd"
|
||||
],
|
||||
"engines": {
|
||||
"node": ">=18"
|
||||
}
|
||||
},
|
||||
"node_modules/@esbuild/linux-arm": {
|
||||
"version": "0.25.9",
|
||||
"resolved": "https://registry.npmjs.org/@esbuild/linux-arm/-/linux-arm-0.25.9.tgz",
|
||||
"integrity": "sha512-HBU2Xv78SMgaydBmdor38lg8YDnFKSARg1Q6AT0/y2ezUAKiZvc211RDFHlEZRFNRVhcMamiToo7bDx3VEOYQw==",
|
||||
"cpu": [
|
||||
"arm"
|
||||
],
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"optional": true,
|
||||
"os": [
|
||||
"linux"
|
||||
],
|
||||
"engines": {
|
||||
"node": ">=18"
|
||||
}
|
||||
},
|
||||
"node_modules/@esbuild/linux-arm64": {
|
||||
"version": "0.25.9",
|
||||
"resolved": "https://registry.npmjs.org/@esbuild/linux-arm64/-/linux-arm64-0.25.9.tgz",
|
||||
"integrity": "sha512-BlB7bIcLT3G26urh5Dmse7fiLmLXnRlopw4s8DalgZ8ef79Jj4aUcYbk90g8iCa2467HX8SAIidbL7gsqXHdRw==",
|
||||
"cpu": [
|
||||
"arm64"
|
||||
],
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"optional": true,
|
||||
"os": [
|
||||
"linux"
|
||||
],
|
||||
"engines": {
|
||||
"node": ">=18"
|
||||
}
|
||||
},
|
||||
"node_modules/@esbuild/linux-ia32": {
|
||||
"version": "0.25.9",
|
||||
"resolved": "https://registry.npmjs.org/@esbuild/linux-ia32/-/linux-ia32-0.25.9.tgz",
|
||||
"integrity": "sha512-e7S3MOJPZGp2QW6AK6+Ly81rC7oOSerQ+P8L0ta4FhVi+/j/v2yZzx5CqqDaWjtPFfYz21Vi1S0auHrap3Ma3A==",
|
||||
"cpu": [
|
||||
"ia32"
|
||||
],
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"optional": true,
|
||||
"os": [
|
||||
"linux"
|
||||
],
|
||||
"engines": {
|
||||
"node": ">=18"
|
||||
}
|
||||
},
|
||||
"node_modules/@esbuild/linux-loong64": {
|
||||
"version": "0.25.9",
|
||||
"resolved": "https://registry.npmjs.org/@esbuild/linux-loong64/-/linux-loong64-0.25.9.tgz",
|
||||
"integrity": "sha512-Sbe10Bnn0oUAB2AalYztvGcK+o6YFFA/9829PhOCUS9vkJElXGdphz0A3DbMdP8gmKkqPmPcMJmJOrI3VYB1JQ==",
|
||||
"cpu": [
|
||||
"loong64"
|
||||
],
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"optional": true,
|
||||
"os": [
|
||||
"linux"
|
||||
],
|
||||
"engines": {
|
||||
"node": ">=18"
|
||||
}
|
||||
},
|
||||
"node_modules/@esbuild/linux-mips64el": {
|
||||
"version": "0.25.9",
|
||||
"resolved": "https://registry.npmjs.org/@esbuild/linux-mips64el/-/linux-mips64el-0.25.9.tgz",
|
||||
"integrity": "sha512-YcM5br0mVyZw2jcQeLIkhWtKPeVfAerES5PvOzaDxVtIyZ2NUBZKNLjC5z3/fUlDgT6w89VsxP2qzNipOaaDyA==",
|
||||
"cpu": [
|
||||
"mips64el"
|
||||
],
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"optional": true,
|
||||
"os": [
|
||||
"linux"
|
||||
],
|
||||
"engines": {
|
||||
"node": ">=18"
|
||||
}
|
||||
},
|
||||
"node_modules/@esbuild/linux-ppc64": {
|
||||
"version": "0.25.9",
|
||||
"resolved": "https://registry.npmjs.org/@esbuild/linux-ppc64/-/linux-ppc64-0.25.9.tgz",
|
||||
"integrity": "sha512-++0HQvasdo20JytyDpFvQtNrEsAgNG2CY1CLMwGXfFTKGBGQT3bOeLSYE2l1fYdvML5KUuwn9Z8L1EWe2tzs1w==",
|
||||
"cpu": [
|
||||
"ppc64"
|
||||
],
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"optional": true,
|
||||
"os": [
|
||||
"linux"
|
||||
],
|
||||
"engines": {
|
||||
"node": ">=18"
|
||||
}
|
||||
},
|
||||
"node_modules/@esbuild/linux-riscv64": {
|
||||
"version": "0.25.9",
|
||||
"resolved": "https://registry.npmjs.org/@esbuild/linux-riscv64/-/linux-riscv64-0.25.9.tgz",
|
||||
"integrity": "sha512-uNIBa279Y3fkjV+2cUjx36xkx7eSjb8IvnL01eXUKXez/CBHNRw5ekCGMPM0BcmqBxBcdgUWuUXmVWwm4CH9kg==",
|
||||
"cpu": [
|
||||
"riscv64"
|
||||
],
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"optional": true,
|
||||
"os": [
|
||||
"linux"
|
||||
],
|
||||
"engines": {
|
||||
"node": ">=18"
|
||||
}
|
||||
},
|
||||
"node_modules/@esbuild/linux-s390x": {
|
||||
"version": "0.25.9",
|
||||
"resolved": "https://registry.npmjs.org/@esbuild/linux-s390x/-/linux-s390x-0.25.9.tgz",
|
||||
"integrity": "sha512-Mfiphvp3MjC/lctb+7D287Xw1DGzqJPb/J2aHHcHxflUo+8tmN/6d4k6I2yFR7BVo5/g7x2Monq4+Yew0EHRIA==",
|
||||
"cpu": [
|
||||
"s390x"
|
||||
],
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"optional": true,
|
||||
"os": [
|
||||
"linux"
|
||||
],
|
||||
"engines": {
|
||||
"node": ">=18"
|
||||
}
|
||||
},
|
||||
"node_modules/@esbuild/linux-x64": {
|
||||
"version": "0.25.9",
|
||||
"resolved": "https://registry.npmjs.org/@esbuild/linux-x64/-/linux-x64-0.25.9.tgz",
|
||||
"integrity": "sha512-iSwByxzRe48YVkmpbgoxVzn76BXjlYFXC7NvLYq+b+kDjyyk30J0JY47DIn8z1MO3K0oSl9fZoRmZPQI4Hklzg==",
|
||||
"cpu": [
|
||||
"x64"
|
||||
],
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"optional": true,
|
||||
"os": [
|
||||
"linux"
|
||||
],
|
||||
"engines": {
|
||||
"node": ">=18"
|
||||
}
|
||||
},
|
||||
"node_modules/@esbuild/netbsd-arm64": {
|
||||
"version": "0.25.9",
|
||||
"resolved": "https://registry.npmjs.org/@esbuild/netbsd-arm64/-/netbsd-arm64-0.25.9.tgz",
|
||||
"integrity": "sha512-9jNJl6FqaUG+COdQMjSCGW4QiMHH88xWbvZ+kRVblZsWrkXlABuGdFJ1E9L7HK+T0Yqd4akKNa/lO0+jDxQD4Q==",
|
||||
"cpu": [
|
||||
"arm64"
|
||||
],
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"optional": true,
|
||||
"os": [
|
||||
"netbsd"
|
||||
],
|
||||
"engines": {
|
||||
"node": ">=18"
|
||||
}
|
||||
},
|
||||
"node_modules/@esbuild/netbsd-x64": {
|
||||
"version": "0.25.9",
|
||||
"resolved": "https://registry.npmjs.org/@esbuild/netbsd-x64/-/netbsd-x64-0.25.9.tgz",
|
||||
"integrity": "sha512-RLLdkflmqRG8KanPGOU7Rpg829ZHu8nFy5Pqdi9U01VYtG9Y0zOG6Vr2z4/S+/3zIyOxiK6cCeYNWOFR9QP87g==",
|
||||
"cpu": [
|
||||
"x64"
|
||||
],
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"optional": true,
|
||||
"os": [
|
||||
"netbsd"
|
||||
],
|
||||
"engines": {
|
||||
"node": ">=18"
|
||||
}
|
||||
},
|
||||
"node_modules/@esbuild/openbsd-arm64": {
|
||||
"version": "0.25.9",
|
||||
"resolved": "https://registry.npmjs.org/@esbuild/openbsd-arm64/-/openbsd-arm64-0.25.9.tgz",
|
||||
"integrity": "sha512-YaFBlPGeDasft5IIM+CQAhJAqS3St3nJzDEgsgFixcfZeyGPCd6eJBWzke5piZuZ7CtL656eOSYKk4Ls2C0FRQ==",
|
||||
"cpu": [
|
||||
"arm64"
|
||||
],
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"optional": true,
|
||||
"os": [
|
||||
"openbsd"
|
||||
],
|
||||
"engines": {
|
||||
"node": ">=18"
|
||||
}
|
||||
},
|
||||
"node_modules/@esbuild/openbsd-x64": {
|
||||
"version": "0.25.9",
|
||||
"resolved": "https://registry.npmjs.org/@esbuild/openbsd-x64/-/openbsd-x64-0.25.9.tgz",
|
||||
"integrity": "sha512-1MkgTCuvMGWuqVtAvkpkXFmtL8XhWy+j4jaSO2wxfJtilVCi0ZE37b8uOdMItIHz4I6z1bWWtEX4CJwcKYLcuA==",
|
||||
"cpu": [
|
||||
"x64"
|
||||
],
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"optional": true,
|
||||
"os": [
|
||||
"openbsd"
|
||||
],
|
||||
"engines": {
|
||||
"node": ">=18"
|
||||
}
|
||||
},
|
||||
"node_modules/@esbuild/openharmony-arm64": {
|
||||
"version": "0.25.9",
|
||||
"resolved": "https://registry.npmjs.org/@esbuild/openharmony-arm64/-/openharmony-arm64-0.25.9.tgz",
|
||||
"integrity": "sha512-4Xd0xNiMVXKh6Fa7HEJQbrpP3m3DDn43jKxMjxLLRjWnRsfxjORYJlXPO4JNcXtOyfajXorRKY9NkOpTHptErg==",
|
||||
"cpu": [
|
||||
"arm64"
|
||||
],
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"optional": true,
|
||||
"os": [
|
||||
"openharmony"
|
||||
],
|
||||
"engines": {
|
||||
"node": ">=18"
|
||||
}
|
||||
},
|
||||
"node_modules/@esbuild/sunos-x64": {
|
||||
"version": "0.25.9",
|
||||
"resolved": "https://registry.npmjs.org/@esbuild/sunos-x64/-/sunos-x64-0.25.9.tgz",
|
||||
"integrity": "sha512-WjH4s6hzo00nNezhp3wFIAfmGZ8U7KtrJNlFMRKxiI9mxEK1scOMAaa9i4crUtu+tBr+0IN6JCuAcSBJZfnphw==",
|
||||
"cpu": [
|
||||
"x64"
|
||||
],
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"optional": true,
|
||||
"os": [
|
||||
"sunos"
|
||||
],
|
||||
"engines": {
|
||||
"node": ">=18"
|
||||
}
|
||||
},
|
||||
"node_modules/@esbuild/win32-arm64": {
|
||||
"version": "0.25.9",
|
||||
"resolved": "https://registry.npmjs.org/@esbuild/win32-arm64/-/win32-arm64-0.25.9.tgz",
|
||||
"integrity": "sha512-mGFrVJHmZiRqmP8xFOc6b84/7xa5y5YvR1x8djzXpJBSv/UsNK6aqec+6JDjConTgvvQefdGhFDAs2DLAds6gQ==",
|
||||
"cpu": [
|
||||
"arm64"
|
||||
],
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"optional": true,
|
||||
"os": [
|
||||
"win32"
|
||||
],
|
||||
"engines": {
|
||||
"node": ">=18"
|
||||
}
|
||||
},
|
||||
"node_modules/@esbuild/win32-ia32": {
|
||||
"version": "0.25.9",
|
||||
"resolved": "https://registry.npmjs.org/@esbuild/win32-ia32/-/win32-ia32-0.25.9.tgz",
|
||||
"integrity": "sha512-b33gLVU2k11nVx1OhX3C8QQP6UHQK4ZtN56oFWvVXvz2VkDoe6fbG8TOgHFxEvqeqohmRnIHe5A1+HADk4OQww==",
|
||||
"cpu": [
|
||||
"ia32"
|
||||
],
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"optional": true,
|
||||
"os": [
|
||||
"win32"
|
||||
],
|
||||
"engines": {
|
||||
"node": ">=18"
|
||||
}
|
||||
},
|
||||
"node_modules/@esbuild/win32-x64": {
|
||||
"version": "0.25.9",
|
||||
"resolved": "https://registry.npmjs.org/@esbuild/win32-x64/-/win32-x64-0.25.9.tgz",
|
||||
"integrity": "sha512-PPOl1mi6lpLNQxnGoyAfschAodRFYXJ+9fs6WHXz7CSWKbOqiMZsubC+BQsVKuul+3vKLuwTHsS2c2y9EoKwxQ==",
|
||||
"cpu": [
|
||||
"x64"
|
||||
],
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"optional": true,
|
||||
"os": [
|
||||
"win32"
|
||||
],
|
||||
"engines": {
|
||||
"node": ">=18"
|
||||
}
|
||||
},
|
||||
"node_modules/@fastify/busboy": {
|
||||
"version": "2.1.1",
|
||||
"resolved": "https://registry.npmjs.org/@fastify/busboy/-/busboy-2.1.1.tgz",
|
||||
"integrity": "sha512-vBZP4NlzfOlerQTnba4aqZoMhE/a9HY7HRqoOPaETQcSQuWEIyZMHGfVu6w9wGtGK5fED5qRs2DteVCjOH60sA==",
|
||||
"license": "MIT",
|
||||
"engines": {
|
||||
"node": ">=14"
|
||||
}
|
||||
},
|
||||
"node_modules/@types/node": {
|
||||
"version": "20.19.11",
|
||||
"resolved": "https://registry.npmjs.org/@types/node/-/node-20.19.11.tgz",
|
||||
"integrity": "sha512-uug3FEEGv0r+jrecvUUpbY8lLisvIjg6AAic6a2bSP5OEOLeJsDSnvhCDov7ipFFMXS3orMpzlmi0ZcuGkBbow==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"undici-types": "~6.21.0"
|
||||
}
|
||||
},
|
||||
"node_modules/esbuild": {
|
||||
"version": "0.25.9",
|
||||
"resolved": "https://registry.npmjs.org/esbuild/-/esbuild-0.25.9.tgz",
|
||||
"integrity": "sha512-CRbODhYyQx3qp7ZEwzxOk4JBqmD/seJrzPa/cGjY1VtIn5E09Oi9/dB4JwctnfZ8Q8iT7rioVv5k/FNT/uf54g==",
|
||||
"dev": true,
|
||||
"hasInstallScript": true,
|
||||
"license": "MIT",
|
||||
"bin": {
|
||||
"esbuild": "bin/esbuild"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">=18"
|
||||
},
|
||||
"optionalDependencies": {
|
||||
"@esbuild/aix-ppc64": "0.25.9",
|
||||
"@esbuild/android-arm": "0.25.9",
|
||||
"@esbuild/android-arm64": "0.25.9",
|
||||
"@esbuild/android-x64": "0.25.9",
|
||||
"@esbuild/darwin-arm64": "0.25.9",
|
||||
"@esbuild/darwin-x64": "0.25.9",
|
||||
"@esbuild/freebsd-arm64": "0.25.9",
|
||||
"@esbuild/freebsd-x64": "0.25.9",
|
||||
"@esbuild/linux-arm": "0.25.9",
|
||||
"@esbuild/linux-arm64": "0.25.9",
|
||||
"@esbuild/linux-ia32": "0.25.9",
|
||||
"@esbuild/linux-loong64": "0.25.9",
|
||||
"@esbuild/linux-mips64el": "0.25.9",
|
||||
"@esbuild/linux-ppc64": "0.25.9",
|
||||
"@esbuild/linux-riscv64": "0.25.9",
|
||||
"@esbuild/linux-s390x": "0.25.9",
|
||||
"@esbuild/linux-x64": "0.25.9",
|
||||
"@esbuild/netbsd-arm64": "0.25.9",
|
||||
"@esbuild/netbsd-x64": "0.25.9",
|
||||
"@esbuild/openbsd-arm64": "0.25.9",
|
||||
"@esbuild/openbsd-x64": "0.25.9",
|
||||
"@esbuild/openharmony-arm64": "0.25.9",
|
||||
"@esbuild/sunos-x64": "0.25.9",
|
||||
"@esbuild/win32-arm64": "0.25.9",
|
||||
"@esbuild/win32-ia32": "0.25.9",
|
||||
"@esbuild/win32-x64": "0.25.9"
|
||||
}
|
||||
},
|
||||
"node_modules/tunnel": {
|
||||
"version": "0.0.6",
|
||||
"resolved": "https://registry.npmjs.org/tunnel/-/tunnel-0.0.6.tgz",
|
||||
"integrity": "sha512-1h/Lnq9yajKY2PEbBadPXj3VxsDDu844OnaAo52UVmIzIvwwtBPIuNvkjuzBlTWpfJyUbG3ez0KSBibQkj4ojg==",
|
||||
"license": "MIT",
|
||||
"engines": {
|
||||
"node": ">=0.6.11 <=0.7.0 || >=0.7.3"
|
||||
}
|
||||
},
|
||||
"node_modules/typescript": {
|
||||
"version": "5.9.2",
|
||||
"resolved": "https://registry.npmjs.org/typescript/-/typescript-5.9.2.tgz",
|
||||
"integrity": "sha512-CWBzXQrc/qOkhidw1OzBTQuYRbfyxDXJMVJ1XNwUHGROVmuaeiEm3OslpZ1RV96d7SKKjZKrSJu3+t/xlw3R9A==",
|
||||
"dev": true,
|
||||
"license": "Apache-2.0",
|
||||
"bin": {
|
||||
"tsc": "bin/tsc",
|
||||
"tsserver": "bin/tsserver"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">=14.17"
|
||||
}
|
||||
},
|
||||
"node_modules/undici": {
|
||||
"version": "5.29.0",
|
||||
"resolved": "https://registry.npmjs.org/undici/-/undici-5.29.0.tgz",
|
||||
"integrity": "sha512-raqeBD6NQK4SkWhQzeYKd1KmIG6dllBOTt55Rmkt4HtI9mwdWtJljnrXjAFUBLTSN67HWrOIZ3EPF4kjUw80Bg==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"@fastify/busboy": "^2.0.0"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">=14.0"
|
||||
}
|
||||
},
|
||||
"node_modules/undici-types": {
|
||||
"version": "6.21.0",
|
||||
"resolved": "https://registry.npmjs.org/undici-types/-/undici-types-6.21.0.tgz",
|
||||
"integrity": "sha512-iwDZqg0QAGrg9Rav5H4n0M64c3mkR59cJ6wQp+7C4nI0gsmExaedaYLNO44eT4AtBBwjbTiGPMlt2Md0T9H9JQ==",
|
||||
"dev": true,
|
||||
"license": "MIT"
|
||||
}
|
||||
}
|
||||
}
|
||||
+49
-16
@@ -1,36 +1,69 @@
|
||||
{
|
||||
"name": "action",
|
||||
"version": "0.0.5",
|
||||
"main": "index.js",
|
||||
"directories": {
|
||||
"example": "examples"
|
||||
},
|
||||
"name": "@pullfrog/action",
|
||||
"version": "0.0.86",
|
||||
"type": "module",
|
||||
"files": [
|
||||
"index.js",
|
||||
"index.cjs",
|
||||
"index.d.ts",
|
||||
"index.d.cts",
|
||||
"agents",
|
||||
"utils",
|
||||
"main.js",
|
||||
"main.d.ts"
|
||||
],
|
||||
"scripts": {
|
||||
"test": "echo \"Error: no test specified\" && exit 1",
|
||||
|
||||
"typecheck": "tsc --noEmit",
|
||||
"build": "node esbuild.config.js",
|
||||
"dev": "node esbuild.config.js --watch",
|
||||
"play": "node play.ts",
|
||||
"upDeps": "pnpm up --latest",
|
||||
"lock": "pnpm --ignore-workspace install",
|
||||
"prepare": "husky"
|
||||
},
|
||||
"dependencies": {
|
||||
"@actions/core": "^1.10.1"
|
||||
"@actions/core": "^1.11.1",
|
||||
"@anthropic-ai/claude-agent-sdk": "^0.1.26",
|
||||
"@ark/fs": "0.53.0",
|
||||
"@ark/util": "0.53.0",
|
||||
"@octokit/rest": "^22.0.0",
|
||||
"@octokit/webhooks-types": "^7.6.1",
|
||||
"@standard-schema/spec": "1.0.0",
|
||||
"arktype": "2.1.25",
|
||||
"dotenv": "^17.2.3",
|
||||
"execa": "^9.6.0",
|
||||
"fastmcp": "^3.20.0",
|
||||
"table": "^6.9.0"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@types/node": "^20.10.0",
|
||||
"@types/node": "^24.7.2",
|
||||
"arg": "^5.0.2",
|
||||
"esbuild": "^0.25.9",
|
||||
"husky": "^9.0.0",
|
||||
"typescript": "^5.3.0"
|
||||
"typescript": "^5.9.3"
|
||||
},
|
||||
"repository": {
|
||||
"type": "git",
|
||||
"url": "git+https://github.com/pullfrog/pullfrog.git"
|
||||
"url": "git+https://github.com/pullfrog/action.git"
|
||||
},
|
||||
"keywords": [],
|
||||
"author": "",
|
||||
"license": "ISC",
|
||||
"type": "module",
|
||||
"license": "MIT",
|
||||
"bugs": {
|
||||
"url": "https://github.com/pullfrog/pullfrog/issues"
|
||||
"url": "https://github.com/pullfrog/action/issues"
|
||||
},
|
||||
"homepage": "https://github.com/pullfrog/pullfrog#readme"
|
||||
"homepage": "https://github.com/pullfrog/action#readme",
|
||||
"zshy": {
|
||||
"exports": "./index.ts"
|
||||
},
|
||||
"main": "./dist/index.cjs",
|
||||
"module": "./dist/index.js",
|
||||
"types": "./dist/index.d.cts",
|
||||
"exports": {
|
||||
".": {
|
||||
"types": "./dist/index.d.cts",
|
||||
"import": "./dist/index.js",
|
||||
"require": "./dist/index.cjs"
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,145 @@
|
||||
import { existsSync, readFileSync } from "node:fs";
|
||||
import { extname, join, resolve } from "node:path";
|
||||
import { pathToFileURL } from "node:url";
|
||||
import { fromHere } from "@ark/fs";
|
||||
import arg from "arg";
|
||||
import { config } from "dotenv";
|
||||
import { type Inputs, main } from "./main.ts";
|
||||
import { log } from "./utils/cli.ts";
|
||||
import { setupTestRepo } from "./utils/setup.ts";
|
||||
|
||||
config();
|
||||
|
||||
export async function run(
|
||||
prompt: string
|
||||
): Promise<{ success: boolean; output?: string | undefined; error?: string | undefined }> {
|
||||
try {
|
||||
const tempDir = join(process.cwd(), ".temp");
|
||||
setupTestRepo({ tempDir, forceClean: true });
|
||||
|
||||
const originalCwd = process.cwd();
|
||||
process.chdir(tempDir);
|
||||
|
||||
log.info("🚀 Running action with prompt...");
|
||||
log.separator();
|
||||
log.box(prompt, { title: "Prompt" });
|
||||
log.separator();
|
||||
|
||||
const inputs: Inputs = {
|
||||
prompt,
|
||||
anthropic_api_key: process.env.ANTHROPIC_API_KEY,
|
||||
};
|
||||
|
||||
const result = await main(inputs);
|
||||
|
||||
process.chdir(originalCwd);
|
||||
|
||||
if (result.success) {
|
||||
log.success("Action completed successfully");
|
||||
return { success: true, output: result.output || undefined, error: undefined };
|
||||
} else {
|
||||
log.error(`Action failed: ${result.error || "Unknown error"}`);
|
||||
return { success: false, error: result.error || undefined, output: undefined };
|
||||
}
|
||||
} catch (err) {
|
||||
const errorMessage = (err as Error).message;
|
||||
log.error(`Error: ${errorMessage}`);
|
||||
return { success: false, error: errorMessage, output: undefined };
|
||||
}
|
||||
}
|
||||
|
||||
if (import.meta.url === `file://${process.argv[1]}`) {
|
||||
const args = arg({
|
||||
"--help": Boolean,
|
||||
"--raw": String,
|
||||
"-h": "--help",
|
||||
});
|
||||
|
||||
if (args["--help"]) {
|
||||
log.info(`
|
||||
Usage: tsx play.ts [file] [options]
|
||||
|
||||
Test the Pullfrog action with various prompts.
|
||||
|
||||
Arguments:
|
||||
file Prompt file to use (.txt, .json, or .ts) [default: fixtures/basic.txt]
|
||||
|
||||
Options:
|
||||
--raw [prompt] Use raw string as prompt instead of loading from file
|
||||
-h, --help Show this help message
|
||||
|
||||
Examples:
|
||||
tsx play.ts # Use default fixture
|
||||
tsx play.ts fixtures/basic.txt # Use specific text file
|
||||
tsx play.ts custom.json # Use JSON file
|
||||
tsx play.ts fixtures/test.ts # Use TypeScript file
|
||||
tsx play.ts --raw "Hello world" # Use raw string as prompt
|
||||
`);
|
||||
process.exit(0);
|
||||
}
|
||||
|
||||
let prompt: string;
|
||||
|
||||
if (args["--raw"]) {
|
||||
prompt = args["--raw"];
|
||||
} else {
|
||||
const filePath = args._[0] || "fixtures/basic.txt";
|
||||
const ext = extname(filePath).toLowerCase();
|
||||
let resolvedPath: string;
|
||||
|
||||
const fixturesPath = fromHere("fixtures", filePath);
|
||||
if (existsSync(fixturesPath)) {
|
||||
resolvedPath = fixturesPath;
|
||||
} else if (existsSync(filePath)) {
|
||||
resolvedPath = resolve(filePath);
|
||||
} else {
|
||||
throw new Error(`File not found: ${filePath}`);
|
||||
}
|
||||
|
||||
switch (ext) {
|
||||
case ".txt": {
|
||||
prompt = readFileSync(resolvedPath, "utf8").trim();
|
||||
break;
|
||||
}
|
||||
|
||||
case ".json": {
|
||||
const content = readFileSync(resolvedPath, "utf8");
|
||||
const parsed = JSON.parse(content);
|
||||
prompt = JSON.stringify(parsed, null, 2);
|
||||
break;
|
||||
}
|
||||
|
||||
case ".ts": {
|
||||
const fileUrl = pathToFileURL(resolvedPath).href;
|
||||
const module = await import(fileUrl);
|
||||
|
||||
if (!module.default) {
|
||||
throw new Error(`TypeScript file ${filePath} must have a default export`);
|
||||
}
|
||||
|
||||
if (typeof module.default === "string") {
|
||||
prompt = module.default;
|
||||
} else if (typeof module.default === "object" && module.default.prompt) {
|
||||
prompt = module.default.prompt;
|
||||
} else {
|
||||
prompt = JSON.stringify(module.default, null, 2);
|
||||
}
|
||||
break;
|
||||
}
|
||||
|
||||
default:
|
||||
throw new Error(`Unsupported file type: ${ext}. Supported types: .txt, .json, .ts`);
|
||||
}
|
||||
}
|
||||
|
||||
try {
|
||||
const result = await run(prompt);
|
||||
|
||||
if (!result.success) {
|
||||
process.exit(1);
|
||||
}
|
||||
} catch (err) {
|
||||
log.error((err as Error).message);
|
||||
process.exit(1);
|
||||
}
|
||||
}
|
||||
Generated
+1682
-125
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,8 @@
|
||||
[x] add modes to prompt
|
||||
[x] progressively update comment
|
||||
[] don't allow rejecting prs
|
||||
[] fix pnpm caching
|
||||
[] try to avoid claude narrating the initial comment
|
||||
[] fix prompt to avoid narration like "I just read all tools from MCP server"
|
||||
[] investigate including terminal output from bash commands as collapsed groups
|
||||
[] avoid exposing env
|
||||
+20
-14
@@ -1,17 +1,23 @@
|
||||
{
|
||||
"compilerOptions": {
|
||||
"target": "ES2020",
|
||||
"module": "commonjs",
|
||||
"lib": ["ES2020"],
|
||||
"outDir": "./",
|
||||
"rootDir": "./",
|
||||
"strict": true,
|
||||
"esModuleInterop": true,
|
||||
"skipLibCheck": true,
|
||||
"forceConsistentCasingInFileNames": true,
|
||||
"declaration": false,
|
||||
"sourceMap": false
|
||||
},
|
||||
"include": ["*.ts"],
|
||||
"exclude": ["node_modules", "**/*.test.ts"]
|
||||
"outDir": "./dist",
|
||||
"module": "NodeNext",
|
||||
"target": "ESNext",
|
||||
"moduleResolution": "NodeNext",
|
||||
"lib": ["ESNext"],
|
||||
"allowImportingTsExtensions": true,
|
||||
"rewriteRelativeImportExtensions": true,
|
||||
"skipLibCheck": true,
|
||||
"strict": true,
|
||||
"noUncheckedSideEffectImports": true,
|
||||
"declaration": true,
|
||||
"verbatimModuleSyntax": true,
|
||||
"esModuleInterop": true,
|
||||
"resolveJsonModule": true,
|
||||
"exactOptionalPropertyTypes": true,
|
||||
"forceConsistentCasingInFileNames": true,
|
||||
"stripInternal": true,
|
||||
"moduleDetection": "force",
|
||||
"useUnknownInCatchVariables": true
|
||||
}
|
||||
}
|
||||
|
||||
+264
@@ -0,0 +1,264 @@
|
||||
/**
|
||||
* CLI output utilities that work well in both local and GitHub Actions environments
|
||||
*/
|
||||
|
||||
import * as core from "@actions/core";
|
||||
|
||||
const isGitHubActions = !!process.env.GITHUB_ACTIONS;
|
||||
const isDebugEnabled = process.env.LOG_LEVEL === "debug";
|
||||
|
||||
/**
|
||||
* Start a collapsed group (GitHub Actions) or regular group (local)
|
||||
*/
|
||||
function startGroup(name: string): void {
|
||||
if (isGitHubActions) {
|
||||
core.startGroup(name);
|
||||
} else {
|
||||
console.group(name);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* End a collapsed group
|
||||
*/
|
||||
function endGroup(): void {
|
||||
if (isGitHubActions) {
|
||||
core.endGroup();
|
||||
} else {
|
||||
console.groupEnd();
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Print a formatted box with text (for console output)
|
||||
*/
|
||||
function boxString(
|
||||
text: string,
|
||||
options?: {
|
||||
title?: string;
|
||||
maxWidth?: number;
|
||||
indent?: string;
|
||||
padding?: number;
|
||||
}
|
||||
): string {
|
||||
const { title, maxWidth = 80, indent = "", padding = 1 } = options || {};
|
||||
|
||||
const lines = text.trim().split("\n");
|
||||
const wrappedLines: string[] = [];
|
||||
|
||||
for (const line of lines) {
|
||||
if (line.length <= maxWidth - padding * 2) {
|
||||
wrappedLines.push(line);
|
||||
} else {
|
||||
const words = line.split(" ");
|
||||
let currentLine = "";
|
||||
|
||||
for (const word of words) {
|
||||
const testLine = currentLine ? `${currentLine} ${word}` : word;
|
||||
if (testLine.length <= maxWidth - padding * 2) {
|
||||
currentLine = testLine;
|
||||
} else {
|
||||
if (currentLine) {
|
||||
wrappedLines.push(currentLine);
|
||||
currentLine = word;
|
||||
} else {
|
||||
wrappedLines.push(word.substring(0, maxWidth - padding * 2));
|
||||
currentLine = word.substring(maxWidth - padding * 2);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if (currentLine) {
|
||||
wrappedLines.push(currentLine);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
const maxLineLength = Math.max(...wrappedLines.map((line) => line.length));
|
||||
const boxWidth = maxLineLength + padding * 2;
|
||||
|
||||
let result = "";
|
||||
|
||||
if (title) {
|
||||
const titleLine = ` ${title} `;
|
||||
const titlePadding = Math.max(0, boxWidth - titleLine.length);
|
||||
result += `${indent}┌${titleLine}${"─".repeat(titlePadding)}┐\n`;
|
||||
}
|
||||
|
||||
if (!title) {
|
||||
result += `${indent}┌${"─".repeat(boxWidth)}┐\n`;
|
||||
}
|
||||
|
||||
for (const line of wrappedLines) {
|
||||
const paddedLine = line.padEnd(maxLineLength);
|
||||
result += `${indent}│${" ".repeat(padding)}${paddedLine}${" ".repeat(padding)}│\n`;
|
||||
}
|
||||
|
||||
result += `${indent}└${"─".repeat(boxWidth)}┘`;
|
||||
|
||||
return result;
|
||||
}
|
||||
|
||||
/**
|
||||
* Print a formatted box with text
|
||||
* Works well in both local and GitHub Actions environments
|
||||
*/
|
||||
function box(
|
||||
text: string,
|
||||
options?: {
|
||||
title?: string;
|
||||
maxWidth?: number;
|
||||
}
|
||||
): void {
|
||||
const boxContent = boxString(text, options);
|
||||
core.info(boxContent);
|
||||
if (isGitHubActions) {
|
||||
// Add as markdown code block for summary (no headers)
|
||||
core.summary.addRaw(`\`\`\`\n${text}\n\`\`\`\n`);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Add a table to GitHub Actions job summary (rich formatting)
|
||||
* Also logs to console. Only use this once at the end of execution.
|
||||
*/
|
||||
async function summaryTable(
|
||||
rows: Array<Array<{ data: string; header?: boolean } | string>>,
|
||||
options?: {
|
||||
title?: string;
|
||||
}
|
||||
): Promise<void> {
|
||||
const { title } = options || {};
|
||||
|
||||
// Convert rows to format expected by Job Summaries API
|
||||
const formattedRows = rows.map((row) =>
|
||||
row.map((cell) => {
|
||||
if (typeof cell === "string") {
|
||||
return { data: cell };
|
||||
}
|
||||
return cell;
|
||||
})
|
||||
);
|
||||
|
||||
if (isGitHubActions) {
|
||||
const summary = core.summary;
|
||||
if (title) {
|
||||
summary.addRaw(`**${title}**\n\n`);
|
||||
}
|
||||
summary.addTable(formattedRows);
|
||||
// Note: Don't write immediately, let it accumulate with other summary content
|
||||
}
|
||||
|
||||
// Also log to console for visibility
|
||||
if (title) {
|
||||
core.info(`\n${title}`);
|
||||
}
|
||||
const tableText = formattedRows.map((row) => row.map((cell) => cell.data).join(" | ")).join("\n");
|
||||
core.info(`\n${tableText}\n`);
|
||||
}
|
||||
|
||||
/**
|
||||
* Print a separator line
|
||||
*/
|
||||
function separator(length: number = 50): void {
|
||||
const separatorText = "─".repeat(length);
|
||||
core.info(separatorText);
|
||||
if (isGitHubActions) {
|
||||
core.summary.addRaw(`---\n`);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Main logging utility object - import this once and access all utilities
|
||||
*/
|
||||
export const log = {
|
||||
/**
|
||||
* Print info message
|
||||
*/
|
||||
info: (message: string): void => {
|
||||
core.info(message);
|
||||
if (isGitHubActions) {
|
||||
core.summary.addRaw(`${message}\n`);
|
||||
}
|
||||
},
|
||||
|
||||
/**
|
||||
* Print warning message
|
||||
*/
|
||||
warning: (message: string): void => {
|
||||
core.warning(message);
|
||||
if (isGitHubActions) {
|
||||
core.summary.addRaw(`⚠️ ${message}\n`);
|
||||
}
|
||||
},
|
||||
|
||||
/**
|
||||
* Print error message
|
||||
*/
|
||||
error: (message: string): void => {
|
||||
core.error(message);
|
||||
if (isGitHubActions) {
|
||||
core.summary.addRaw(`❌ ${message}\n`);
|
||||
}
|
||||
},
|
||||
|
||||
/**
|
||||
* Print success message
|
||||
*/
|
||||
success: (message: string): void => {
|
||||
const successMessage = `✅ ${message}`;
|
||||
core.info(successMessage);
|
||||
if (isGitHubActions) {
|
||||
core.summary.addRaw(`${successMessage}\n`);
|
||||
}
|
||||
},
|
||||
|
||||
/**
|
||||
* Print debug message (only if LOG_LEVEL=debug)
|
||||
*/
|
||||
debug: (message: string): void => {
|
||||
if (isDebugEnabled) {
|
||||
if (isGitHubActions) {
|
||||
core.debug(message);
|
||||
} else {
|
||||
core.info(`[DEBUG] ${message}`);
|
||||
}
|
||||
}
|
||||
},
|
||||
|
||||
/**
|
||||
* Print a formatted box with text
|
||||
*/
|
||||
box,
|
||||
|
||||
/**
|
||||
* Add a table to GitHub Actions job summary (rich formatting)
|
||||
* Only use this once at the end of execution
|
||||
*/
|
||||
summaryTable,
|
||||
|
||||
/**
|
||||
* Print a separator line
|
||||
*/
|
||||
separator,
|
||||
|
||||
/**
|
||||
* Write all accumulated summary content to the job summary
|
||||
* Call this at the end of execution to finalize the summary
|
||||
*/
|
||||
writeSummary: async (): Promise<void> => {
|
||||
if (isGitHubActions) {
|
||||
await core.summary.write();
|
||||
}
|
||||
},
|
||||
|
||||
/**
|
||||
* Start a collapsed group (GitHub Actions) or regular group (local)
|
||||
*/
|
||||
startGroup,
|
||||
|
||||
/**
|
||||
* End a collapsed group
|
||||
*/
|
||||
endGroup,
|
||||
};
|
||||
@@ -1,15 +0,0 @@
|
||||
import { exec } from "node:child_process";
|
||||
import { promisify } from "node:util";
|
||||
|
||||
export const execAsync = promisify(exec);
|
||||
|
||||
/**
|
||||
* Execute a shell command with optional environment variables
|
||||
*/
|
||||
export async function executeCommand(
|
||||
command: string,
|
||||
env?: Record<string, string>
|
||||
): Promise<{ stdout: string; stderr: string }> {
|
||||
const execEnv = env ? { ...process.env, ...env } : process.env;
|
||||
return execAsync(command, { env: execEnv });
|
||||
}
|
||||
@@ -1,13 +0,0 @@
|
||||
import { mkdtempSync, writeFileSync } from "node:fs";
|
||||
import { tmpdir } from "node:os";
|
||||
import { join } from "node:path";
|
||||
|
||||
/**
|
||||
* Create a temporary file with the given content
|
||||
*/
|
||||
export function createTempFile(content: string, filename = "temp.txt"): string {
|
||||
const tempDir = mkdtempSync(join(tmpdir(), "pullfrog-"));
|
||||
const filePath = join(tempDir, filename);
|
||||
writeFileSync(filePath, content);
|
||||
return filePath;
|
||||
}
|
||||
+274
@@ -0,0 +1,274 @@
|
||||
import { createSign } from "node:crypto";
|
||||
import * as core from "@actions/core";
|
||||
import { log } from "./cli.ts";
|
||||
|
||||
export interface InstallationToken {
|
||||
token: string;
|
||||
expires_at: string;
|
||||
installation_id: number;
|
||||
repository: string;
|
||||
ref: string;
|
||||
runner_environment: string;
|
||||
owner?: string;
|
||||
}
|
||||
|
||||
interface GitHubAppConfig {
|
||||
appId: string;
|
||||
privateKey: string;
|
||||
repoOwner: string;
|
||||
repoName: string;
|
||||
}
|
||||
|
||||
interface Installation {
|
||||
id: number;
|
||||
account: {
|
||||
login: string;
|
||||
type: string;
|
||||
};
|
||||
}
|
||||
|
||||
interface Repository {
|
||||
owner: {
|
||||
login: string;
|
||||
};
|
||||
name: string;
|
||||
}
|
||||
|
||||
interface InstallationTokenResponse {
|
||||
token: string;
|
||||
expires_at: string;
|
||||
}
|
||||
|
||||
interface RepositoriesResponse {
|
||||
repositories: Repository[];
|
||||
}
|
||||
|
||||
function checkExistingToken(): string | null {
|
||||
const inputToken = core.getInput("github_installation_token");
|
||||
const envToken = process.env.GITHUB_INSTALLATION_TOKEN;
|
||||
return inputToken || envToken || null;
|
||||
}
|
||||
|
||||
function isGitHubActionsEnvironment(): boolean {
|
||||
return Boolean(process.env.GITHUB_ACTIONS);
|
||||
}
|
||||
|
||||
async function acquireTokenViaOIDC(): Promise<string> {
|
||||
log.info("Generating OIDC token...");
|
||||
|
||||
const oidcToken = await core.getIDToken("pullfrog-api");
|
||||
log.info("OIDC token generated successfully");
|
||||
|
||||
const apiUrl = process.env.API_URL || "https://pullfrog.ai";
|
||||
|
||||
log.info("Exchanging OIDC token for installation token...");
|
||||
const tokenResponse = await fetch(`${apiUrl}/api/github/installation-token`, {
|
||||
method: "POST",
|
||||
headers: {
|
||||
Authorization: `Bearer ${oidcToken}`,
|
||||
"Content-Type": "application/json",
|
||||
},
|
||||
});
|
||||
|
||||
if (!tokenResponse.ok) {
|
||||
const errorText = await tokenResponse.text();
|
||||
throw new Error(
|
||||
`Token exchange failed: ${tokenResponse.status} ${tokenResponse.statusText} - ${errorText}`
|
||||
);
|
||||
}
|
||||
|
||||
const tokenData = (await tokenResponse.json()) as InstallationToken;
|
||||
log.info(`Installation token obtained for ${tokenData.repository || "all repositories"}`);
|
||||
|
||||
return tokenData.token;
|
||||
}
|
||||
|
||||
const base64UrlEncode = (str: string): string => {
|
||||
return Buffer.from(str)
|
||||
.toString("base64")
|
||||
.replace(/\+/g, "-")
|
||||
.replace(/\//g, "_")
|
||||
.replace(/=/g, "");
|
||||
};
|
||||
|
||||
const generateJWT = (appId: string, privateKey: string): string => {
|
||||
const now = Math.floor(Date.now() / 1000);
|
||||
const payload = {
|
||||
iat: now - 60,
|
||||
exp: now + 5 * 60,
|
||||
iss: appId,
|
||||
};
|
||||
|
||||
const header = {
|
||||
alg: "RS256",
|
||||
typ: "JWT",
|
||||
};
|
||||
|
||||
const encodedHeader = base64UrlEncode(JSON.stringify(header));
|
||||
const encodedPayload = base64UrlEncode(JSON.stringify(payload));
|
||||
const signaturePart = `${encodedHeader}.${encodedPayload}`;
|
||||
|
||||
const signature = createSign("RSA-SHA256")
|
||||
.update(signaturePart)
|
||||
.sign(privateKey, "base64")
|
||||
.replace(/\+/g, "-")
|
||||
.replace(/\//g, "_")
|
||||
.replace(/=/g, "");
|
||||
|
||||
return `${signaturePart}.${signature}`;
|
||||
};
|
||||
|
||||
const githubRequest = async <T>(
|
||||
path: string,
|
||||
options: {
|
||||
method?: string;
|
||||
headers?: Record<string, string>;
|
||||
body?: string;
|
||||
} = {}
|
||||
): Promise<T> => {
|
||||
const { method = "GET", headers = {}, body } = options;
|
||||
|
||||
const url = `https://api.github.com${path}`;
|
||||
const requestHeaders = {
|
||||
Accept: "application/vnd.github.v3+json",
|
||||
"User-Agent": "Pullfrog-Installation-Token-Generator/1.0",
|
||||
...headers,
|
||||
};
|
||||
|
||||
const response = await fetch(url, {
|
||||
method,
|
||||
headers: requestHeaders,
|
||||
...(body && { body }),
|
||||
});
|
||||
|
||||
if (!response.ok) {
|
||||
const errorText = await response.text();
|
||||
throw new Error(
|
||||
`GitHub API request failed: ${response.status} ${response.statusText}\n${errorText}`
|
||||
);
|
||||
}
|
||||
|
||||
return response.json() as T;
|
||||
};
|
||||
|
||||
const checkRepositoryAccess = async (
|
||||
token: string,
|
||||
repoOwner: string,
|
||||
repoName: string
|
||||
): Promise<boolean> => {
|
||||
try {
|
||||
const response = await githubRequest<RepositoriesResponse>("/installation/repositories", {
|
||||
headers: { Authorization: `token ${token}` },
|
||||
});
|
||||
|
||||
return response.repositories.some(
|
||||
(repo) => repo.owner.login === repoOwner && repo.name === repoName
|
||||
);
|
||||
} catch {
|
||||
return false;
|
||||
}
|
||||
};
|
||||
|
||||
const createInstallationToken = async (jwt: string, installationId: number): Promise<string> => {
|
||||
const response = await githubRequest<InstallationTokenResponse>(
|
||||
`/app/installations/${installationId}/access_tokens`,
|
||||
{
|
||||
method: "POST",
|
||||
headers: { Authorization: `Bearer ${jwt}` },
|
||||
}
|
||||
);
|
||||
|
||||
return response.token;
|
||||
};
|
||||
|
||||
const findInstallationId = async (
|
||||
jwt: string,
|
||||
repoOwner: string,
|
||||
repoName: string
|
||||
): Promise<number> => {
|
||||
const installations = await githubRequest<Installation[]>("/app/installations", {
|
||||
headers: { Authorization: `Bearer ${jwt}` },
|
||||
});
|
||||
|
||||
for (const installation of installations) {
|
||||
try {
|
||||
const tempToken = await createInstallationToken(jwt, installation.id);
|
||||
const hasAccess = await checkRepositoryAccess(tempToken, repoOwner, repoName);
|
||||
|
||||
if (hasAccess) {
|
||||
return installation.id;
|
||||
}
|
||||
} catch {}
|
||||
}
|
||||
|
||||
throw new Error(
|
||||
`No installation found with access to ${repoOwner}/${repoName}. ` +
|
||||
"Ensure the GitHub App is installed on the target repository."
|
||||
);
|
||||
};
|
||||
|
||||
async function acquireTokenViaGitHubApp(): Promise<string> {
|
||||
const repoContext = parseRepoContext();
|
||||
|
||||
const config: GitHubAppConfig = {
|
||||
appId: process.env.GITHUB_APP_ID!,
|
||||
privateKey: process.env.GITHUB_PRIVATE_KEY?.replace(/\\n/g, "\n")!,
|
||||
repoOwner: repoContext.owner,
|
||||
repoName: repoContext.name,
|
||||
};
|
||||
|
||||
const jwt = generateJWT(config.appId, config.privateKey);
|
||||
const installationId = await findInstallationId(jwt, config.repoOwner, config.repoName);
|
||||
const token = await createInstallationToken(jwt, installationId);
|
||||
|
||||
return token;
|
||||
}
|
||||
|
||||
async function acquireNewToken(): Promise<string> {
|
||||
if (isGitHubActionsEnvironment()) {
|
||||
return await acquireTokenViaOIDC();
|
||||
} else {
|
||||
return await acquireTokenViaGitHubApp();
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Setup GitHub installation token for the action
|
||||
*/
|
||||
export async function setupGitHubInstallationToken(): Promise<string> {
|
||||
const existingToken = checkExistingToken();
|
||||
if (existingToken) {
|
||||
core.setSecret(existingToken);
|
||||
log.info("Using provided GitHub installation token");
|
||||
return existingToken;
|
||||
}
|
||||
|
||||
const token = await acquireNewToken();
|
||||
|
||||
core.setSecret(token);
|
||||
process.env.GITHUB_INSTALLATION_TOKEN = token;
|
||||
|
||||
return token;
|
||||
}
|
||||
|
||||
export interface RepoContext {
|
||||
owner: string;
|
||||
name: string;
|
||||
}
|
||||
|
||||
/**
|
||||
* Parse repository context from GITHUB_REPOSITORY environment variable.
|
||||
*/
|
||||
export function parseRepoContext(): RepoContext {
|
||||
const githubRepo = process.env.GITHUB_REPOSITORY;
|
||||
if (!githubRepo) {
|
||||
throw new Error("GITHUB_REPOSITORY environment variable is required");
|
||||
}
|
||||
|
||||
const [owner, name] = githubRepo.split("/");
|
||||
if (!owner || !name) {
|
||||
throw new Error(`Invalid GITHUB_REPOSITORY format: ${githubRepo}. Expected 'owner/repo'`);
|
||||
}
|
||||
|
||||
return { owner, name };
|
||||
}
|
||||
@@ -0,0 +1,92 @@
|
||||
import { execSync } from "node:child_process";
|
||||
import { existsSync, rmSync } from "node:fs";
|
||||
import { log } from "./cli.ts";
|
||||
import type { RepoContext } from "./github.ts";
|
||||
|
||||
export interface SetupOptions {
|
||||
tempDir: string;
|
||||
repoUrl?: string;
|
||||
forceClean?: boolean;
|
||||
}
|
||||
|
||||
/**
|
||||
* Setup the test repository for running actions
|
||||
*/
|
||||
export function setupTestRepo(options: SetupOptions): void {
|
||||
const {
|
||||
tempDir,
|
||||
repoUrl = "git@github.com:pullfrogai/scratch.git",
|
||||
forceClean = false,
|
||||
} = options;
|
||||
|
||||
if (existsSync(tempDir)) {
|
||||
if (forceClean) {
|
||||
log.info("🗑️ Removing existing .temp directory...");
|
||||
rmSync(tempDir, { recursive: true, force: true });
|
||||
|
||||
log.info("📦 Cloning pullfrogai/scratch into .temp...");
|
||||
execSync(`git clone ${repoUrl} ${tempDir}`, { stdio: "inherit" });
|
||||
} else {
|
||||
log.info("📦 Resetting existing .temp repository...");
|
||||
execSync("git reset --hard HEAD && git clean -fd", {
|
||||
cwd: tempDir,
|
||||
stdio: "inherit",
|
||||
});
|
||||
}
|
||||
} else {
|
||||
log.info("📦 Cloning pullfrogai/scratch into .temp...");
|
||||
execSync(`git clone ${repoUrl} ${tempDir}`, { stdio: "inherit" });
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Setup git configuration to avoid identity errors
|
||||
* Only runs in GitHub Actions environment to avoid overwriting local git config
|
||||
*/
|
||||
export function setupGitConfig(): void {
|
||||
// Only set up git config in GitHub Actions environment
|
||||
// In local development, use the user's existing git config
|
||||
if (!process.env.GITHUB_ACTIONS) {
|
||||
return;
|
||||
}
|
||||
|
||||
log.info("🔧 Setting up git configuration...");
|
||||
try {
|
||||
execSync('git config user.email "action@pullfrog.ai"', { stdio: "pipe" });
|
||||
execSync('git config user.name "Pullfrog Action"', { stdio: "pipe" });
|
||||
log.debug("setupGitConfig: ✓ Git configuration set successfully");
|
||||
} catch (error) {
|
||||
// If git config fails, log warning but don't fail the action
|
||||
// This can happen if we're not in a git repo or git isn't available
|
||||
log.warning(
|
||||
`Failed to set git config: ${error instanceof Error ? error.message : String(error)}`
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Setup git authentication using GitHub token
|
||||
* Only runs in GitHub Actions environment to avoid breaking local git remotes
|
||||
*/
|
||||
export function setupGitAuth(githubToken: string, repoContext: RepoContext): void {
|
||||
// Only set up git auth in GitHub Actions environment
|
||||
// In local testing, this would overwrite the real git remote with fake credentials
|
||||
if (!process.env.GITHUB_ACTIONS) {
|
||||
return;
|
||||
}
|
||||
|
||||
log.info("🔐 Setting up git authentication...");
|
||||
|
||||
// Remove existing git auth headers that actions/checkout might have set
|
||||
try {
|
||||
execSync("git config --unset-all http.https://github.com/.extraheader", { stdio: "inherit" });
|
||||
log.info("✓ Removed existing authentication headers");
|
||||
} catch {
|
||||
log.info("No existing authentication headers to remove");
|
||||
}
|
||||
|
||||
// Update remote URL to embed the token
|
||||
const remoteUrl = `https://x-access-token:${githubToken}@github.com/${repoContext.owner}/${repoContext.name}.git`;
|
||||
execSync(`git remote set-url origin "${remoteUrl}"`, { stdio: "inherit" });
|
||||
log.info("✓ Updated remote URL with authentication token");
|
||||
}
|
||||
@@ -0,0 +1,110 @@
|
||||
import { spawn as nodeSpawn } from "node:child_process";
|
||||
|
||||
export interface SpawnOptions {
|
||||
cmd: string;
|
||||
args: string[];
|
||||
env?: Record<string, string>;
|
||||
input?: string;
|
||||
timeout?: number;
|
||||
cwd?: string;
|
||||
onStdout?: (chunk: string) => void;
|
||||
onStderr?: (chunk: string) => void;
|
||||
}
|
||||
|
||||
export interface SpawnResult {
|
||||
stdout: string;
|
||||
stderr: string;
|
||||
exitCode: number;
|
||||
durationMs: number;
|
||||
}
|
||||
|
||||
/**
|
||||
* Spawn a subprocess with streaming callbacks and buffered results
|
||||
*/
|
||||
export async function spawn(options: SpawnOptions): Promise<SpawnResult> {
|
||||
const { cmd, args, env, input, timeout, cwd, onStdout, onStderr } = options;
|
||||
|
||||
const startTime = Date.now();
|
||||
let stdoutBuffer = "";
|
||||
let stderrBuffer = "";
|
||||
|
||||
return new Promise((resolve, reject) => {
|
||||
const child = nodeSpawn(cmd, args, {
|
||||
env: env ? { ...process.env, ...env } : process.env,
|
||||
stdio: ["pipe", "pipe", "pipe"],
|
||||
cwd: cwd || process.cwd(),
|
||||
});
|
||||
|
||||
let timeoutId: NodeJS.Timeout | undefined;
|
||||
let isTimedOut = false;
|
||||
|
||||
if (timeout) {
|
||||
timeoutId = setTimeout(() => {
|
||||
isTimedOut = true;
|
||||
child.kill("SIGTERM");
|
||||
|
||||
setTimeout(() => {
|
||||
if (!child.killed) {
|
||||
child.kill("SIGKILL");
|
||||
}
|
||||
}, 5000);
|
||||
}, timeout);
|
||||
}
|
||||
|
||||
if (child.stdout) {
|
||||
child.stdout.on("data", (data: Buffer) => {
|
||||
const chunk = data.toString();
|
||||
stdoutBuffer += chunk;
|
||||
onStdout?.(chunk);
|
||||
});
|
||||
}
|
||||
|
||||
if (child.stderr) {
|
||||
child.stderr.on("data", (data: Buffer) => {
|
||||
const chunk = data.toString();
|
||||
stderrBuffer += chunk;
|
||||
onStderr?.(chunk);
|
||||
});
|
||||
}
|
||||
|
||||
child.on("close", (exitCode) => {
|
||||
const durationMs = Date.now() - startTime;
|
||||
|
||||
if (timeoutId) {
|
||||
clearTimeout(timeoutId);
|
||||
}
|
||||
|
||||
if (isTimedOut) {
|
||||
reject(new Error(`Process timed out after ${timeout}ms`));
|
||||
return;
|
||||
}
|
||||
|
||||
resolve({
|
||||
stdout: stdoutBuffer,
|
||||
stderr: stderrBuffer,
|
||||
exitCode: exitCode || 0,
|
||||
durationMs,
|
||||
});
|
||||
});
|
||||
|
||||
child.on("error", (_error) => {
|
||||
const durationMs = Date.now() - startTime;
|
||||
|
||||
if (timeoutId) {
|
||||
clearTimeout(timeoutId);
|
||||
}
|
||||
|
||||
resolve({
|
||||
stdout: stdoutBuffer,
|
||||
stderr: stderrBuffer,
|
||||
exitCode: 1,
|
||||
durationMs,
|
||||
});
|
||||
});
|
||||
|
||||
if (input && child.stdin) {
|
||||
child.stdin.write(input);
|
||||
child.stdin.end();
|
||||
}
|
||||
});
|
||||
}
|
||||
Reference in New Issue
Block a user