Compare commits

...

83 Commits

Author SHA1 Message Date
David Blass c0f31415a3 try setting cwd 2025-10-23 15:43:50 -04:00
David Blass 706ce04895 bump version 2025-10-23 15:37:04 -04:00
David Blass 09be8e3068 try adding github token to env 2025-10-23 15:35:36 -04:00
David Blass c6c1210fa0 refactor tool implementation 2025-10-23 15:21:08 -04:00
David Blass 0368512b9e add pr and issue creation support 2025-10-23 10:24:32 -04:00
David Blass 9fb6135fd2 bump 2025-10-17 22:27:59 -04:00
David Blass bb78e5f94b update lockfile 2025-10-17 22:27:24 -04:00
David Blass c668578c6f refactor mcp and add instructions prefix 2025-10-17 22:26:24 -04:00
ssalbdivad 7f1566d9c2 update lockfile 2025-10-15 17:25:54 -04:00
ssalbdivad dd482566c2 bump version 2025-10-15 17:24:58 -04:00
ssalbdivad 57029c32a3 remove zod3 2025-10-15 17:24:50 -04:00
ssalbdivad 757d336475 switch to fastmcp 2025-10-15 17:24:29 -04:00
David Blass d03debab4b bump version 2025-10-14 15:56:27 -04:00
David Blass a05829f781 fix type errors 2025-10-14 14:58:46 -04:00
David Blass c8ba7940e3 fix installation token propagation 2025-10-13 17:21:14 -04:00
David Blass 710fdd0fa4 bump version 2025-10-13 17:09:19 -04:00
David Blass 4f5ee28b8a update publish to reflect no build 2025-10-13 17:08:59 -04:00
David Blass 806458b95a fix install loop 2025-10-13 17:06:34 -04:00
David Blass 2c856e3337 remove husky 2025-10-13 17:04:59 -04:00
David Blass a93c34e61b refactor action to use INPUTS_JSON object 2025-10-13 16:57:02 -04:00
David Blass cd20491d22 fix pnpm caching 2025-10-13 15:35:01 -04:00
David Blass 1a6ce6728c bump version 2025-10-13 15:30:48 -04:00
David Blass 3b39f2c8d8 move pnpm version specifier to actions 2025-10-13 15:30:41 -04:00
David Blass ec0eeb1d18 add packageManager to action package.json 2025-10-13 15:27:46 -04:00
David Blass 8ef805b9fc remove pnpm version from publish action 2025-10-13 15:27:05 -04:00
David Blass 6e93fd9a72 specify packageManager 2025-10-13 15:24:04 -04:00
David Blass 9567d84442 setup pnpm first 2025-10-13 15:21:09 -04:00
David Blass d79564db5e add pnpm setup 2025-10-13 15:14:51 -04:00
David Blass a7a0e87fd8 setup deps 2025-10-13 15:12:38 -04:00
David Blass 7050b8de75 switch to composite action 2025-10-13 15:03:06 -04:00
David Blass 2fc3ddee16 bump 2025-10-13 14:23:35 -04:00
David Blass 284d9733dd bump 2025-10-13 14:22:12 -04:00
David Blass 94e2b5f6e0 add terrible debugging 2025-10-13 14:19:47 -04:00
David Blass 03810d574e bump version 2025-10-13 14:14:52 -04:00
David Blass f52e94c612 27 2025-10-13 14:08:54 -04:00
David Blass 9444a0e208 iter 2025-10-13 14:04:46 -04:00
David Blass 2296060d04 await top-level runServer 2025-10-13 13:44:29 -04:00
David Blass 458bfe18a0 try different error handling 2025-10-13 13:35:10 -04:00
David Blass 4cfb9b5008 Revert "try adding more debug logging"
This reverts commit 06542e382a.
2025-10-13 13:28:35 -04:00
David Blass 06542e382a try adding more debug logging 2025-10-13 13:22:15 -04:00
David Blass bcdf6ab5fb add debug flag for mcp server 2025-10-13 13:02:04 -04:00
ssalbdivad 314f669f10 add debug logging 2025-10-09 19:28:00 -04:00
ssalbdivad a24275e21b bump version 2025-10-09 18:07:42 -04:00
ssalbdivad 872e620342 Revert "try to add debugging to mcp server"
This reverts commit 6d9c6fd2b1.
2025-10-09 18:07:28 -04:00
ssalbdivad 6d9c6fd2b1 try to add debugging to mcp server 2025-10-09 18:04:00 -04:00
ssalbdivad 008021df1c remove bad error handling 2025-10-09 17:53:22 -04:00
ssalbdivad d6bc0fdd64 iter 2025-10-09 17:45:38 -04:00
ssalbdivad 8fd0328109 propagate GITHUB_REPOSITORY 2025-10-09 17:26:01 -04:00
ssalbdivad a1f87ce118 unify installation token logic 2025-10-09 17:14:34 -04:00
ssalbdivad 3e7122611c use GITHUB_REPOSITORY for context 2025-10-09 17:04:03 -04:00
ssalbdivad 9459803aaa cleanup comments 2025-10-09 16:33:11 -04:00
ssalbdivad f74a75cfac generate installation token for each play run 2025-10-09 16:23:36 -04:00
David Blass 16e04e7152 bump version 2025-10-08 16:49:12 -04:00
David Blass 522779ef54 bump version 2025-10-08 16:47:03 -04:00
David Blass d3d2dad025 no-frozen-lockfile 2025-10-08 16:46:52 -04:00
David Blass 66bf86f081 bump version 2025-10-08 16:42:55 -04:00
David Blass 608322f026 use frozen lockfile in ci 2025-10-08 16:42:09 -04:00
David Blass e3a3d416fb bump version 2025-10-08 16:33:04 -04:00
David Blass f0e339f5c2 big 2025-10-08 16:21:10 -04:00
David Blass 87d32763e9 fix 2025-10-08 15:19:02 -04:00
ssalbdivad 671334f37d embarrassing 2025-10-08 14:07:28 -04:00
David Blass 267ed3686f bump version 2025-09-24 13:52:37 -04:00
David Blass ff1226a824 fix input type 2025-09-24 13:52:18 -04:00
ssalbdivad e13c5eed00 cleanup, add InstallationToken type 2025-09-23 12:48:35 -04:00
Colin McDonnell f2a1c3c1bb Update 2025-09-16 03:22:50 -07:00
Colin McDonnell e672deb934 Update 2025-09-16 03:22:12 -07:00
Colin McDonnell 70b365fca1 Clean up msgs 2025-09-10 15:01:45 -07:00
Colin McDonnell 5c8b03a427 Lock 2025-09-10 00:56:50 -07:00
Colin McDonnell 92225d30c5 No frozen lockfile 2025-09-10 00:36:03 -07:00
Colin McDonnell 3630ba6618 0.0.8 2025-09-10 00:32:54 -07:00
Colin McDonnell 6a03bb8e1b Update precommit 2025-09-10 00:31:21 -07:00
Colin McDonnell 3139f541e4 feat: integrate OIDC token exchange in GitHub Action
- Add setupGitHubInstallationToken utility for OIDC token generation
- Implement automatic token exchange with Pullfrog API endpoint
- Add support for multiple authentication methods (input, env, OIDC)
- Create setup utilities for test repository management
- Update action entry point to handle new token flow
- Add environment variable documentation for API key
- Remove large bundled dependencies and optimize build
- Support both development and production token workflows
2025-09-10 00:30:45 -07:00
Colin McDonnell c5b9c7cfc4 Tweak 2025-09-09 17:19:59 -07:00
Colin McDonnell 7a14716481 Disable npm publishing for now 2025-09-09 16:58:30 -07:00
Colin McDonnell 087709f4c7 Add lock 2025-09-09 16:55:45 -07:00
Colin McDonnell ff81db8bb7 Update 2025-09-09 16:54:36 -07:00
Colin McDonnell bfd948fd3c v0.0.7 2025-09-09 16:32:51 -07:00
Colin McDonnell 260b563913 Update workflow 2025-09-09 16:31:47 -07:00
Colin McDonnell 5fef548cee Drop lock 2025-09-09 16:26:18 -07:00
Colin McDonnell 7d633da1be refactor: complete action testing system overhaul
- Removed /scratch directory, now cloning pullfrogai/scratch as needed
- Implemented new play.ts testing system with local and Docker/act modes
- Added environment variable propagation to cloned test repositories
- Created minimal .act-dist approach to avoid pnpm symlink issues with Docker
- Migrated from dist/index.js to entry.cjs bundled output
- Added TypeScript fixture support with MainParams type safety
- Organized all test fixtures in fixtures/ directory
- Updated publish workflow to trigger on package.json changes
- Removed unnecessary INPUT_ANTHROPIC_API_KEY references
- Added comprehensive documentation for new testing system
- Fixed pre-commit hook to use entry.cjs instead of dist/
2025-09-09 16:20:00 -07:00
Colin McDonnell ede6cfdfbe Implement Claude Code basics 2025-08-29 00:41:05 -07:00
Colin McDonnell 7745a0befb Update Claude agent to use proper headless mode
- Replace --dangerously-skip-permissions with official headless mode flags
- Use -p (--print) for non-interactive mode
- Add --output-format json for structured responses
- Use --permission-mode acceptEdits for automation
- Parse JSON response and extract metadata (cost, duration, session_id)
- Handle both successful and error responses properly
- Reference: https://docs.anthropic.com/en/docs/claude-code/sdk/sdk-headless
2025-08-28 13:42:04 -07:00
Colin McDonnell 1abbd7ff41 Refactor action with agent interface system and make anthropic_api_key optional
- Created extensible agent interface with install() and execute() methods
- Moved Claude Code logic to agents/claude.ts implementing Agent interface
- Added utilities directory for reusable functions (exec, files)
- Refactored index.ts to be minimal (35 lines) using agent abstraction
- Made anthropic_api_key optional in action.yml
- Updated Node.js imports to use node: prefix convention
- Bumped version to 0.0.5
- Architecture now supports multiple agents (OpenAI, Gemini, etc.)
2025-08-28 13:37:20 -07:00
35 changed files with 3585 additions and 38625 deletions
+124
View File
@@ -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
-101
View File
@@ -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
View File
@@ -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
-6
View File
@@ -1,6 +0,0 @@
# Build the action before committing
echo "🔨 Building action..."
npm run build
# Add the built files to the commit
git add index.cjs dist/
+299
View File
@@ -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.
+78 -2
View File
@@ -1,4 +1,80 @@
# 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
npm run play -- --act # Run in Docker (simulates GitHub Actions)
```
## Testing with play.ts
The `play.ts` script provides two ways to test the action:
### Local Mode (Default)
```bash
npm run play # Uses fixtures/play.txt
npm run play fixtures/complex.txt # Custom prompt file
```
- Clones the scratch repository to `.temp`
- Runs Claude Code directly on your machine
- Fast iteration for development
### Docker Mode (--act flag)
```bash
npm run play -- --act # Uses fixtures/play.txt
npm run play fixtures/simple.txt -- --act # Custom prompt file
```
- Builds fresh bundles with esbuild
- Creates minimal distribution without node_modules
- Runs in Docker container via `act`
- Simulates GitHub Actions environment
### Prompt Files
Supports `.txt`, `.json`, and `.ts` files:
```bash
npm run play prompt.txt # Plain text prompt
npm run play config.json # JSON configuration
npm run play dynamic.ts # TypeScript with default export
```
## Building
```bash
pnpm build # Production build (bundles & removes node_modules)
pnpm build:dev # Development build (keeps node_modules)
pnpm dev # Watch mode
```
The action is bundled into `entry.cjs` with all dependencies included, eliminating runtime dependency on node_modules.
## Environment Variables
Create `.env` in `/action`:
```bash
ANTHROPIC_API_KEY=sk-ant-api03-xxxxxxxxxxxxxxxxxxxxxxxxxxxxxxx # Claude API key
```
## Architecture
- **entry.cjs**: Bundled action entry point (self-contained)
- **agents/**: Agent implementations (Claude, etc.)
- **utils/**: Utilities for subprocess, act, and formatting
- **fixtures/**: Test prompt files
## Why No node_modules?
pnpm uses symlinks that cause "invalid symlink" errors when `act` copies the action to Docker. Our solution:
1. Bundle everything into `entry.cjs`
2. Remove node_modules after building
3. Create minimal `.act-dist` for Docker testing
+36 -10
View File
@@ -1,17 +1,43 @@
name: 'Simple Message Action'
description: 'A simple GitHub Action that prints a message to the console'
author: 'Pullfrog'
name: "Pullfrog Claude Code Action"
description: "Execute Claude Code with a prompt using Anthropic API"
author: "Pullfrog"
inputs:
message:
description: 'Message to print to console'
prompt:
description: "Prompt to send to Claude Code"
required: true
default: 'Hello from Pullfrog Action!'
default: "Hello from Claude Code!"
anthropic_api_key:
description: "Anthropic API key for Claude Code authentication"
required: false
runs:
using: 'node20'
main: 'index.cjs'
using: "composite"
steps:
- name: Checkout code
uses: actions/checkout@v4
with:
fetch-depth: 1
- name: Setup pnpm
uses: pnpm/action-setup@v4
with:
version: latest
- name: Setup Node.js 24
uses: actions/setup-node@v4
with:
node-version: "24"
cache: "pnpm"
- name: Install dependencies
run: pnpm install
shell: bash
working-directory: ${{ github.action_path }}
- name: Run agent
run: node entry.ts
shell: bash
working-directory: ${{ github.action_path }}
env:
INPUTS_JSON: ${{ toJSON(inputs) }}
branding:
icon: 'message-circle'
color: 'blue'
icon: "code"
color: "green"
+321
View File
@@ -0,0 +1,321 @@
import { access, constants } from "node:fs/promises";
import * as core from "@actions/core";
import { createMcpConfig } from "../mcp/config.ts";
import { spawn } from "../utils/subprocess.ts";
import { boxString, tableString } from "../utils/table.ts";
import { instructions } from "./shared.ts";
import type { Agent, AgentConfig, AgentResult } from "./types.ts";
/**
* Claude Code agent implementation
*/
export class ClaudeAgent implements Agent {
private apiKey: string;
private githubInstallationToken: string;
public runStats = {
toolsUsed: 0,
turns: 0,
startTime: 0,
};
constructor(config: AgentConfig) {
this.apiKey = config.apiKey;
this.githubInstallationToken = config.githubInstallationToken;
}
/**
* Check if Claude Code CLI is already installed
*/
private async isClaudeInstalled(): Promise<boolean> {
try {
const claudePath = `${process.env.HOME}/.local/bin/claude`;
await access(claudePath, constants.F_OK | constants.X_OK);
return true;
} catch {
return false;
}
}
/**
* Install Claude Code CLI
*/
async install(): Promise<void> {
if (await this.isClaudeInstalled()) {
core.info("Claude Code is already installed, skipping installation");
return;
}
core.info("Installing Claude Code...");
try {
const result = await spawn({
cmd: "bash",
args: ["-c", "curl -fsSL https://claude.ai/install.sh | bash -s 1.0.93"],
env: { ANTHROPIC_API_KEY: this.apiKey },
timeout: 120000, // 2 minute timeout
onStdout: () => {},
onStderr: (chunk) => process.stderr.write(chunk),
});
if (result.exitCode !== 0) {
throw new Error(`Installation failed with exit code ${result.exitCode}: ${result.stderr}`);
}
core.info("Claude Code installed successfully");
} catch (error) {
throw new Error(`Failed to install Claude Code: ${error}`);
}
}
/**
* Execute Claude Code with the given prompt
*/
async execute(prompt: string): Promise<AgentResult> {
core.info("Running Claude Code...");
try {
const claudePath = `${process.env.HOME}/.local/bin/claude`;
const env = {
ANTHROPIC_API_KEY: this.apiKey,
GITHUB_TOKEN: this.githubInstallationToken,
};
console.log(boxString(prompt, { title: "Prompt" }));
const mcpConfig = createMcpConfig(this.githubInstallationToken);
console.log("📋 MCP Config:", mcpConfig);
const args = [
"--print",
"--output-format",
"stream-json",
"--verbose",
"--debug",
"--permission-mode",
"bypassPermissions",
"--mcp-config",
mcpConfig,
];
core.startGroup("🔄 Run details");
this.runStats = {
toolsUsed: 0,
turns: 0,
startTime: Date.now(),
};
const finalResult = "";
const totalCost = 0;
const result = await spawn({
cmd: claudePath,
args,
env,
input: `${instructions} ${prompt}`,
timeout: 10 * 60 * 1000, // 10 minutes
cwd: process.env.GITHUB_WORKSPACE || process.cwd(),
onStdout: (_chunk) => {
processJSONChunk(_chunk, this);
},
onStderr: (_chunk) => {
if (_chunk.trim()) {
processJSONChunk(_chunk, this);
}
},
});
if (result.exitCode !== 0) {
throw new Error(
`Command failed with exit code ${result.exitCode}\n\nStdout: ${result.stdout}\n\nStderr: ${result.stderr}`
);
}
const duration = Date.now() - this.runStats.startTime;
core.info(
`📊 Run Summary: ${this.runStats.toolsUsed} tools used, ${this.runStats.turns} turns, ${duration}ms duration`
);
core.info("✅ Task complete.");
core.endGroup(); // End the collapsible log group
return {
success: true,
output: finalResult,
metadata: {
promptLength: prompt.length,
exitCode: result.exitCode,
durationMs: result.durationMs,
totalCost,
},
};
} catch (error: any) {
try {
core.endGroup();
} catch {}
const errorMessage = error instanceof Error ? error.message : "Unknown error";
return {
success: false,
error: `Failed to execute Claude Code: ${errorMessage}`,
};
}
}
}
/**
* Pretty print a JSON chunk based on its type
*/
function processJSONChunk(chunk: string, agent?: ClaudeAgent): void {
try {
// Skip debug lines that start with [DEBUG] or [debug]
const trimmedChunk = chunk.trim();
if (trimmedChunk.startsWith("[DEBUG]") || trimmedChunk.startsWith("[debug]")) {
console.log(chunk);
return;
}
console.log(chunk);
const parsedChunk = JSON.parse(trimmedChunk);
switch (parsedChunk.type) {
case "system":
if (parsedChunk.subtype === "init") {
core.info(`🚀 Starting Claude Code session...`);
core.info(
tableString([
["model", parsedChunk.model],
["cwd", parsedChunk.cwd],
["permission_mode", parsedChunk.permissionMode],
["tools", parsedChunk.tools?.length ? `${parsedChunk.tools.length} tools` : "none"],
[
"mcp_servers",
parsedChunk.mcp_servers?.length
? `${parsedChunk.mcp_servers.length} servers`
: "none",
],
[
"slash_commands",
parsedChunk.slash_commands?.length
? `${parsedChunk.slash_commands.length} commands`
: "none",
],
])
);
}
break;
case "assistant":
if (parsedChunk.message?.content) {
if (agent) {
agent.runStats.turns++;
}
for (const content of parsedChunk.message.content) {
if (content.type === "text") {
if (content.text.trim()) {
core.info(boxString(content.text.trim(), { title: "Claude Code" }));
}
} else if (content.type === "tool_use") {
if (agent) {
agent.runStats.toolsUsed++;
}
const toolName = content.name;
core.info(`${toolName}`);
if (content.input) {
const input = content.input;
if (input.description) {
core.info(` └─ ${input.description}`);
}
if (input.command) {
core.info(` └─ command: ${input.command}`);
}
if (input.file_path) {
core.info(` └─ file: ${input.file_path}`);
}
if (input.content) {
const contentPreview =
input.content.length > 100
? `${input.content.substring(0, 100)}...`
: input.content;
core.info(` └─ content: ${contentPreview}`);
}
if (input.query) {
core.info(` └─ query: ${input.query}`);
}
if (input.pattern) {
core.info(` └─ pattern: ${input.pattern}`);
}
if (input.url) {
core.info(` └─ url: ${input.url}`);
}
if (input.edits && Array.isArray(input.edits)) {
core.info(` └─ edits: ${input.edits.length} changes`);
input.edits.forEach((edit: any, index: number) => {
if (edit.file_path) {
core.info(` ${index + 1}. ${edit.file_path}`);
}
});
}
if (input.task) {
core.info(` └─ task: ${input.task}`);
}
if (input.bash_command) {
core.info(` └─ bash_command: ${input.bash_command}`);
}
}
}
}
}
break;
case "user":
if (parsedChunk.message?.content) {
for (const content of parsedChunk.message.content) {
if (content.type === "tool_result") {
if (content.is_error) {
core.warning(`❌ Tool error: ${content.content}`);
} else {
}
}
}
}
break;
case "result":
if (parsedChunk.subtype === "success") {
core.info(
tableString([
["Cost", `$${parsedChunk.total_cost_usd?.toFixed(4) || "0.0000"}`],
["Input Tokens", parsedChunk.usage?.input_tokens || 0],
["Output Tokens", parsedChunk.usage?.output_tokens || 0],
["Duration", `${parsedChunk.duration_ms}ms`],
["Turns", parsedChunk.num_turns || 1],
])
);
} else {
core.error(`❌ Failed: ${parsedChunk.error || "Unknown error"}`);
}
break;
default:
core.debug(`📦 Unknown chunk type: ${parsedChunk.type}`);
break;
}
} catch (error) {
core.debug(`Failed to parse chunk: ${error}`);
core.debug(`Raw chunk: ${chunk.substring(0, 200)}...`);
}
}
+8
View File
@@ -0,0 +1,8 @@
import { mcpServerName } from "../mcp/config.ts";
export const instructions = `- use the ${mcpServerName} MCP server to interact with github
- if ${mcpServerName} is not available or doesn't include the functionality you need, describe why and bail
- do not under any circumstances use the gh cli
- if prompted by a comment to respond to create a new issue, pr or anything else, after succeeding,
also respond to the original comment with a very brief message containing a link to it
`;
+34
View File
@@ -0,0 +1,34 @@
/**
* 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;
githubInstallationToken: string;
}
-17731
View File
File diff suppressed because one or more lines are too long
+35
View File
@@ -0,0 +1,35 @@
#!/usr/bin/env node
/**
* Entry point for GitHub Action
*/
import * as core from "@actions/core";
import { type } from "arktype";
import { Inputs, main } from "./main.ts";
import packageJson from "./package.json" with { type: "json" };
async function run(): Promise<void> {
try {
console.log(`🐸 Running pullfrog/action@${packageJson.version}...`);
const inputsJson = process.env.INPUTS_JSON;
if (!inputsJson) {
throw new Error("INPUTS_JSON environment variable not found");
}
const parsed = type("string.json.parse").assert(inputsJson);
const inputs = Inputs.assert(parsed);
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();
-14
View File
@@ -1,14 +0,0 @@
import { build } from 'esbuild';
await build({
entryPoints: ['./index.ts'],
bundle: true,
outfile: './index.cjs',
format: 'cjs',
platform: 'node',
target: 'node20',
minify: false,
sourcemap: false,
});
console.log('✅ Build completed successfully!');
+9
View File
@@ -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;
+1
View File
@@ -0,0 +1 @@
create a PR implementing bogosort to https://github.com/pullfrogai/scratch/
-19829
View File
File diff suppressed because one or more lines are too long
+11 -16
View File
@@ -1,17 +1,12 @@
import * as core from "@actions/core";
/**
* Library entry point for npm package
* This exports the main function for programmatic usage
*/
try {
// Get the message input parameter, with a default fallback
const message = core.getInput("message") || "Hello from Pullfrog Action!";
// Print the message to console and GitHub Actions logs
console.log(`🐸 Pullfrog says: ${message}`);
core.info(`Action executed successfully with message: ${message}`);
// Set an output for potential use by other actions
core.setOutput("message", message);
} catch (error) {
// Handle any errors and fail the action
const errorMessage = error instanceof Error ? error.message : "Unknown error occurred";
core.setFailed(`Action failed: ${errorMessage}`);
}
export { ClaudeAgent } from "./agents/claude.ts";
export type { Agent, AgentConfig, AgentResult } from "./agents/types.ts";
export {
type Inputs as ExecutionInputs,
type MainResult,
main,
} from "./main.ts";
+54
View File
@@ -0,0 +1,54 @@
import * as core from "@actions/core";
import { type } from "arktype";
import { ClaudeAgent } from "./agents/claude.ts";
import { setupGitHubInstallationToken } from "./utils/github.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 async function main(inputs: Inputs): Promise<MainResult> {
try {
core.info(`→ Starting agent run with Claude Code`);
// Setup GitHub installation token
const githubInstallationToken = await setupGitHubInstallationToken();
const agent = new ClaudeAgent({
apiKey: inputs.anthropic_api_key!,
githubInstallationToken,
});
await agent.install();
const result = await agent.execute(inputs.prompt);
if (!result.success) {
return {
success: false,
error: result.error || "Agent execution failed",
output: result.output!,
};
}
return {
success: true,
output: result.output || "",
};
} catch (error) {
const errorMessage = error instanceof Error ? error.message : "Unknown error occurred";
return {
success: false,
error: errorMessage,
};
}
}
+28
View File
@@ -0,0 +1,28 @@
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 CommentTool = 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,
};
}),
});
+35
View File
@@ -0,0 +1,35 @@
/**
* Simple MCP configuration helper for adding our minimal GitHub comment server
*/
import { fromHere } from "@ark/fs";
const actionPath = fromHere("..");
export const mcpServerName = "gh-pullfrog";
export function createMcpConfig(githubInstallationToken: string) {
const githubRepository = process.env.GITHUB_REPOSITORY;
if (!githubRepository) {
throw new Error(
"GITHUB_REPOSITORY environment variable is required for MCP GitHub integration"
);
}
return JSON.stringify(
{
mcpServers: {
[mcpServerName]: {
command: "node",
args: [`${actionPath}/mcp/server.ts`],
env: {
GITHUB_INSTALLATION_TOKEN: githubInstallationToken,
GITHUB_REPOSITORY: githubRepository,
LOG_LEVEL: "debug",
},
},
},
},
null,
2
);
}
+42
View File
@@ -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),
};
}),
});
+42
View File
@@ -0,0 +1,42 @@
import { execSync } from "node:child_process";
import { type } from "arktype";
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();
console.log(`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,
};
}),
});
+16
View File
@@ -0,0 +1,16 @@
#!/usr/bin/env node
// Minimal GitHub Issue Comment MCP Server
import { FastMCP } from "fastmcp";
import { CommentTool } from "./comment.ts";
import { IssueTool } from "./issue.ts";
import { PullRequestTool } from "./pr.ts";
import { addTools } from "./shared.ts";
const server = new FastMCP({
name: "gh-pullfrog",
version: "0.0.1",
});
addTools(server, [CommentTool, IssueTool, PullRequestTool]);
server.start();
+78
View File
@@ -0,0 +1,78 @@
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;
}[];
error?: 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}`,
},
],
error: errorMessage,
isError: true,
};
};
-601
View File
@@ -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"
}
}
}
+48 -20
View File
@@ -1,36 +1,64 @@
{
"name": "action",
"version": "0.0.4",
"main": "index.js",
"directories": {
"example": "examples"
},
"name": "@pullfrog/action",
"version": "0.0.56",
"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",
"build": "node esbuild.config.js",
"dev": "node esbuild.config.js --watch",
"prepare": "husky"
"typecheck": "tsc --noEmit",
"play": "node play.ts",
"upDeps": "pnpm up --latest",
"lock": "pnpm --ignore-workspace install"
},
"dependencies": {
"@actions/core": "^1.10.1"
"@actions/core": "^1.11.1",
"@ark/fs": "0.50.0",
"@ark/util": "0.50.0",
"@octokit/rest": "^22.0.0",
"@octokit/webhooks-types": "^7.6.1",
"@standard-schema/spec": "1.0.0",
"arktype": "^2.1.23",
"dotenv": "^17.2.3",
"execa": "^9.6.0",
"fastmcp": "^3.20.0",
"table": "^6.9.0"
},
"devDependencies": {
"@types/node": "^20.10.0",
"esbuild": "^0.25.9",
"husky": "^9.0.0",
"typescript": "^5.3.0"
"@types/node": "^24.7.2",
"arg": "^5.0.2",
"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"
}
}
}
+160
View File
@@ -0,0 +1,160 @@
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 packageJson from "./package.json" with { type: "json" };
import { runAct } from "./utils/act.ts";
import { setupTestRepo } from "./utils/setup.ts";
config();
export async function run(
prompt: string,
options: { act?: boolean } = {}
): Promise<{ success: boolean; output?: string | undefined; error?: string | undefined }> {
try {
console.log(`🐸 Running pullfrog/action@${packageJson.version}...`);
if (options.act) {
console.log("🐳 Running with Docker/act...");
runAct(prompt);
return { success: true };
}
const tempDir = join(process.cwd(), ".temp");
setupTestRepo({ tempDir, forceClean: true });
const originalCwd = process.cwd();
process.chdir(tempDir);
console.log("🚀 Running action with prompt...");
console.log("─".repeat(50));
console.log("Prompt:");
console.log(prompt);
console.log("─".repeat(50));
const inputs: Inputs = {
prompt,
anthropic_api_key: process.env.ANTHROPIC_API_KEY,
};
const result = await main(inputs);
process.chdir(originalCwd);
if (result.success) {
console.log("✅ Action completed successfully");
if (result.output) {
console.log("Output:", result.output);
}
return { success: true, output: result.output || undefined, error: undefined };
} else {
console.error("❌ Action failed:", result.error);
return { success: false, error: result.error || undefined, output: undefined };
}
} catch (error) {
const errorMessage = (error as Error).message;
console.error("❌ Error:", errorMessage);
return { success: false, error: errorMessage, output: undefined };
}
}
if (import.meta.url === `file://${process.argv[1]}`) {
const args = arg({
"--help": Boolean,
"--act": Boolean,
"--raw": String,
"-h": "--help",
});
if (args["--help"]) {
console.log(`
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:
--act Use Docker/act to run the action instead of running directly
--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 --act fixtures/test.ts # Use TypeScript file with Docker/act
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, { act: args["--act"] || false });
if (!result.success) {
process.exit(1);
}
} catch (error) {
console.error("❌ Error:", (error as Error).message);
process.exit(1);
}
}
+1431 -279
View File
File diff suppressed because it is too large Load Diff
+20 -14
View File
@@ -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
}
}
+76
View File
@@ -0,0 +1,76 @@
import { execSync } from "node:child_process";
import { existsSync } from "node:fs";
import { dirname, join } from "node:path";
import { fileURLToPath } from "node:url";
import { config } from "dotenv";
import { buildAction, setupTestRepo } from "./setup.ts";
const __filename = fileURLToPath(import.meta.url);
const __dirname = dirname(__filename);
const tempDir = join(__dirname, "..", ".temp");
const actionPath = join(__dirname, "..");
const envPath = join(__dirname, "..", "..", ".env");
const ENV_VARS = ["ANTHROPIC_API_KEY", "GITHUB_INSTALLATION_TOKEN"];
export function runAct(prompt: string): void {
setupTestRepo({ tempDir });
config({ path: envPath });
buildAction(actionPath);
const workflowPath = join(tempDir, ".github", "workflows", "pullfrog.yml");
const distPath = join(actionPath, ".act-dist");
console.log("📦 Creating minimal distribution for act...");
execSync(`rm -rf "${distPath}" && mkdir -p "${distPath}"`, { shell: "/bin/bash" });
["action.yml", "entry.cjs", "index.cjs", "package.json"].forEach((file) => {
const src = join(actionPath, file);
if (existsSync(src)) {
execSync(`cp "${src}" "${distPath}"`);
}
});
try {
const escapedPrompt = prompt.replace(/'/g, "'\\''");
const actCommandParts = [
"act",
"workflow_dispatch",
"-W",
workflowPath,
"--input",
`prompt='${escapedPrompt}'`,
"--local-repository",
`pullfrog/action@v0=${distPath}`, // Use minimal dist without symlinks
];
ENV_VARS.forEach((key) => {
if (process.env[key]) {
actCommandParts.push("-s", key);
}
});
const actCommand = actCommandParts.join(" ");
console.log("🚀 Running act with prompt:");
console.log("─".repeat(50));
console.log(prompt);
console.log("─".repeat(50));
console.log("");
execSync(actCommand, {
stdio: "inherit",
cwd: join(__dirname, "..", ".."),
});
execSync(`rm -rf "${distPath}"`);
} catch (error) {
execSync(`rm -rf "${distPath}"`);
console.error("❌ Act execution failed:", (error as Error).message);
process.exit(1);
}
}
+13
View File
@@ -0,0 +1,13 @@
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;
}
+273
View File
@@ -0,0 +1,273 @@
import { createSign } from "node:crypto";
import * as core from "@actions/core";
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> {
core.info("Generating OIDC token...");
const oidcToken = await core.getIDToken("pullfrog-api");
core.info("OIDC token generated successfully");
const apiUrl = process.env.API_URL || "https://pullfrog.ai";
core.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;
core.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);
core.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 };
}
+49
View File
@@ -0,0 +1,49 @@
import { execSync } from "node:child_process";
import { existsSync, rmSync } from "node:fs";
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) {
console.log("🗑️ Removing existing .temp directory...");
rmSync(tempDir, { recursive: true, force: true });
console.log("📦 Cloning pullfrogai/scratch into .temp...");
execSync(`git clone ${repoUrl} ${tempDir}`, { stdio: "inherit" });
} else {
console.log("📦 Resetting existing .temp repository...");
execSync("git reset --hard HEAD && git clean -fd", {
cwd: tempDir,
stdio: "inherit",
});
}
} else {
console.log("📦 Cloning pullfrogai/scratch into .temp...");
execSync(`git clone ${repoUrl} ${tempDir}`, { stdio: "inherit" });
}
}
/**
* Build the action bundles
*/
export function buildAction(actionPath: string): void {
console.log("🔨 Building fresh bundles with esbuild...");
execSync("node esbuild.config.js", {
cwd: actionPath,
stdio: "inherit",
});
}
+110
View File
@@ -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();
}
});
}
+142
View File
@@ -0,0 +1,142 @@
import { table } from "table";
/**
* Print a formatted table with consistent styling
* @param rows - Array of string arrays representing table rows
* @param options - Optional table configuration
*/
export function tableString(
rows: string[][],
options?: {
title?: string;
indent?: string;
drawHorizontalLine?: (lineIndex: number, rowCount: number) => boolean;
}
): string {
const {
title,
indent = "",
drawHorizontalLine = (lineIndex: number, rowCount: number) => {
return lineIndex === 0 || (options?.title && lineIndex === 1) || lineIndex === rowCount;
},
} = options || {};
if (title) {
rows.unshift([title]);
}
const tableOutput = table(rows, {
drawHorizontalLine,
border: {
topBody: ``,
topJoin: ``,
topLeft: ``,
topRight: ``,
bottomBody: ``,
bottomJoin: ``,
bottomLeft: ``,
bottomRight: ``,
bodyLeft: ``,
bodyRight: ``,
bodyJoin: ``,
joinBody: ``,
joinLeft: ``,
joinRight: ``,
joinJoin: ``,
},
});
const indentedOutput = tableOutput.split("\n").join(`\n${indent}`).trim();
return `${indent}${indentedOutput}`;
}
/**
* Print a multi-line string in a formatted box with text wrapping
* @param text - The text to display
* @param options - Optional configuration for the box
*/
export function boxString(
text: string,
options?: {
title?: string;
maxWidth?: number;
indent?: string;
padding?: number;
}
): string {
const { title, maxWidth = 80, indent = "", padding = 1 } = options || {};
// Clean up the text and split into lines
const lines = text.trim().split("\n");
// Word wrap each line to fit within maxWidth
const wrappedLines: string[] = [];
for (const line of lines) {
if (line.length <= maxWidth - padding * 2) {
wrappedLines.push(line);
} else {
// Word wrap the line
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 {
// Word is too long, break it
wrappedLines.push(word.substring(0, maxWidth - padding * 2));
currentLine = word.substring(maxWidth - padding * 2);
}
}
}
if (currentLine) {
wrappedLines.push(currentLine);
}
}
}
// Find the maximum line length for box sizing
const maxLineLength = Math.max(...wrappedLines.map((line) => line.length));
const boxWidth = maxLineLength + padding * 2;
// Create the box
const topBorder = "┌" + "─".repeat(boxWidth) + "┐";
const bottomBorder = "└" + "─".repeat(boxWidth) + "┘";
const sideBorder = "│";
let result = "";
// Add title if provided
if (title) {
const titleLine = ` ${title} `;
const titlePadding = Math.max(0, boxWidth - titleLine.length);
result += `${indent}${titleLine}${"─".repeat(titlePadding)}\n`;
}
// Add top border (or title border)
if (!title) {
result += `${indent}${topBorder}\n`;
}
// Add content lines
for (const line of wrappedLines) {
const paddedLine = line.padEnd(maxLineLength);
result += `${indent}${sideBorder}${" ".repeat(padding)}${paddedLine}${" ".repeat(padding)}${sideBorder}\n`;
}
// Add bottom border
result += `${indent}${bottomBorder}`;
return result;
}