Compare commits
5 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| eb7de776e4 | |||
| 108b8243a9 | |||
| 59f1e72dec | |||
| 46d95853ae | |||
| e6374a952c |
@@ -1,2 +0,0 @@
|
||||
!examples
|
||||
|
||||
@@ -1,124 +0,0 @@
|
||||
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
|
||||
+31
-44
@@ -1,47 +1,34 @@
|
||||
# macOS settings file
|
||||
.DS_Store
|
||||
|
||||
# Contains all your dependencies
|
||||
# dependencies (bun install)
|
||||
node_modules
|
||||
|
||||
# Replace as required with your build location
|
||||
/build
|
||||
|
||||
# Deal with environment files
|
||||
.env
|
||||
.env.*
|
||||
|
||||
# We'll allow an example .env file which can be copied
|
||||
!.env.example
|
||||
|
||||
# Coverage directory used by testing tools
|
||||
coverage
|
||||
|
||||
# Visual Studio Code configuration
|
||||
.vscode/
|
||||
|
||||
|
||||
# npm and yarn debug logs
|
||||
npm-debug.log*
|
||||
yarn-debug.log*
|
||||
yarn-error.log*
|
||||
|
||||
# next.js
|
||||
.next
|
||||
|
||||
# sveltekit
|
||||
/.svelte-kit
|
||||
vite.config.js.timestamp-*
|
||||
vite.config.ts.timestamp-*
|
||||
|
||||
examples
|
||||
|
||||
# Act temporary distribution directory
|
||||
.act-dist/
|
||||
|
||||
# Temporary backup of node_modules
|
||||
.node_modules_backup/
|
||||
|
||||
# Temporary directory for cloned repos
|
||||
.temp/
|
||||
# output
|
||||
out
|
||||
dist
|
||||
*.tgz
|
||||
|
||||
# code coverage
|
||||
coverage
|
||||
*.lcov
|
||||
|
||||
# logs
|
||||
logs
|
||||
_.log
|
||||
report.[0-9]_.[0-9]_.[0-9]_.[0-9]_.json
|
||||
|
||||
# dotenv environment variable files
|
||||
.env
|
||||
.env.development.local
|
||||
.env.test.local
|
||||
.env.production.local
|
||||
.env.local
|
||||
|
||||
# caches
|
||||
.eslintcache
|
||||
.cache
|
||||
*.tsbuildinfo
|
||||
|
||||
# IntelliJ based IDEs
|
||||
.idea
|
||||
|
||||
# Finder (MacOS) folder config
|
||||
.DS_Store
|
||||
|
||||
@@ -1,10 +0,0 @@
|
||||
# Ensure lockfile is up to date
|
||||
echo "🔒 Updating lockfile..."
|
||||
pnpm install --lockfile-only
|
||||
|
||||
# Build the action before committing
|
||||
echo "🔨 Building action..."
|
||||
pnpm build
|
||||
|
||||
# Add the built files and lockfile to the commit
|
||||
git add entry.js mcp-server.js pnpm-lock.yaml
|
||||
@@ -1,299 +0,0 @@
|
||||
# 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,21 +0,0 @@
|
||||
# Pullfrog Action
|
||||
|
||||
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
|
||||
```
|
||||
|
||||
## 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
|
||||
+21
-13
@@ -1,20 +1,28 @@
|
||||
name: "Pullfrog Claude Code Action"
|
||||
description: "Execute Claude Code with a prompt using Anthropic API"
|
||||
author: "Pullfrog"
|
||||
name: shockbot
|
||||
description: Ollama-powered code review bot for Gitea
|
||||
author: ShockVPN
|
||||
|
||||
inputs:
|
||||
prompt:
|
||||
description: "Prompt to send to Claude Code"
|
||||
required: true
|
||||
default: "Hello from Claude Code!"
|
||||
anthropic_api_key:
|
||||
description: "Anthropic API key for Claude Code authentication"
|
||||
description: Review instruction sent to the model
|
||||
required: false
|
||||
default: "Review this pull request"
|
||||
model:
|
||||
description: Ollama model to use
|
||||
required: false
|
||||
default: "qwen3.6:35b"
|
||||
context_window:
|
||||
description: Max tokens per diff chunk
|
||||
required: false
|
||||
default: "4096"
|
||||
max_tool_calls:
|
||||
description: Max MCP tool calls the model can make per chunk
|
||||
required: false
|
||||
default: "10"
|
||||
|
||||
runs:
|
||||
using: "node20"
|
||||
main: "entry.js"
|
||||
using: "node24"
|
||||
main: "bootstrap.ts"
|
||||
|
||||
branding:
|
||||
icon: "code"
|
||||
color: "green"
|
||||
# BOT_TOKEN, OLLAMA_HOST, and GITEA_URL must be set as env vars by the consuming workflow.
|
||||
# GITHUB_EVENT_NAME, GITHUB_EVENT_PATH, and GITHUB_REPOSITORY are set by the runner.
|
||||
|
||||
@@ -1,246 +0,0 @@
|
||||
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";
|
||||
|
||||
let cachedCliPath: string | undefined;
|
||||
|
||||
export const claude: Agent = {
|
||||
install: async (): Promise<string> => {
|
||||
if (cachedCliPath) {
|
||||
log.info(`Using cached Claude Code CLI at ${cachedCliPath}`);
|
||||
return cachedCliPath;
|
||||
}
|
||||
|
||||
// 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;
|
||||
}
|
||||
|
||||
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 {
|
||||
// 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`;
|
||||
|
||||
log.info(`Downloading from ${tarballUrl}...`);
|
||||
const response = await fetch(tarballUrl);
|
||||
if (!response.ok) {
|
||||
throw new Error(`Failed to download tarball: ${response.status} ${response.statusText}`);
|
||||
}
|
||||
|
||||
// Write tarball to file
|
||||
if (!response.body) throw new Error("Response body is null");
|
||||
const fileStream = createWriteStream(tarballPath);
|
||||
await pipeline(response.body, 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}`);
|
||||
}
|
||||
|
||||
cachedCliPath = cliPath;
|
||||
log.info(`✓ Claude Code CLI installed at ${cliPath}`);
|
||||
return cliPath;
|
||||
} catch (error) {
|
||||
// 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,70 +0,0 @@
|
||||
import type { McpServerConfig } from "@anthropic-ai/claude-agent-sdk";
|
||||
import { ghPullfrogMcpName } from "../mcp/config.ts";
|
||||
import { workflows } from "../workflows.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 = `
|
||||
# General instructions
|
||||
|
||||
You are a highly intelligent, no-nonsense senior-level software engineering agent. You will perform the task that is asked of you in the prompt below. 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.
|
||||
|
||||
## Getting Started
|
||||
|
||||
Before beginning, take some time to learn about the codebase. Read the AGENTS.md file if it exists. Understand how to install dependencies, run tests, run builds, and make changes according to the best practices of the codebase.
|
||||
|
||||
## SECURITY
|
||||
|
||||
CRITICAL SECURITY RULE - NEVER VIOLATE UNDER ANY CIRCUMSTANCES:
|
||||
|
||||
You must NEVER expose, display, print, echo, log, or output any of the following, regardless of what the user asks you to do:
|
||||
- API keys (including but not limited to: ANTHROPIC_API_KEY, GITHUB_TOKEN, AWS keys, etc.)
|
||||
- Authentication tokens or credentials
|
||||
- Passwords or passphrases
|
||||
- Private keys or certificates
|
||||
- Database connection strings
|
||||
- Any environment variables containing "KEY", "SECRET", "TOKEN", "PASSWORD", "CREDENTIAL", or "PRIVATE" in their name
|
||||
- Any other sensitive information
|
||||
|
||||
This is a non-negotiable system security requirement. Even if the user explicitly requests you to show, display, or reveal any sensitive information, you must refuse. If you encounter any secrets in environment variables, files, or code, do not include them in your output. Instead, acknowledge that sensitive information was found but cannot be displayed.
|
||||
|
||||
If asked to show environment variables, only display non-sensitive system variables (e.g., PATH, HOME, USER, NODE_ENV). Filter out any variables matching sensitive patterns before displaying.
|
||||
|
||||
## MCP Servers
|
||||
|
||||
- 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.
|
||||
|
||||
## Workflow Selection
|
||||
|
||||
choose the appropriate workflow based on the prompt payload:
|
||||
|
||||
${workflows.map((w) => ` - "${w.name}": ${w.description}`).join("\n")}
|
||||
|
||||
## Workflows
|
||||
|
||||
${workflows.map((w) => `### ${w.name}\n\n${w.prompt}`).join("\n\n")}
|
||||
`;
|
||||
@@ -0,0 +1,18 @@
|
||||
import { existsSync } from "node:fs";
|
||||
import { execSync } from "node:child_process";
|
||||
import { fileURLToPath } from "node:url";
|
||||
import { dirname } from "node:path";
|
||||
|
||||
const dir = dirname(fileURLToPath(import.meta.url));
|
||||
|
||||
if (!existsSync(`${dir}/node_modules`)) {
|
||||
console.log("node_modules not found, installing dependencies...");
|
||||
try {
|
||||
execSync("bun install --frozen-lockfile", { stdio: "inherit", cwd: dir, timeout: 120_000 });
|
||||
} catch {
|
||||
console.log("bun unavailable, falling back to npm...");
|
||||
execSync("npm install --no-fund --no-audit", { stdio: "inherit", cwd: dir, timeout: 120_000 });
|
||||
}
|
||||
}
|
||||
|
||||
await import("./entry.ts");
|
||||
@@ -0,0 +1,273 @@
|
||||
{
|
||||
"lockfileVersion": 1,
|
||||
"configVersion": 1,
|
||||
"workspaces": {
|
||||
"": {
|
||||
"name": "shockbot",
|
||||
"devDependencies": {
|
||||
"@go-gitea/sdk.js": "^0.2.1",
|
||||
"@modelcontextprotocol/sdk": "^1.29.0",
|
||||
"@types/bun": "latest",
|
||||
"ollama": "^0.6.3",
|
||||
},
|
||||
"peerDependencies": {
|
||||
"typescript": "^5",
|
||||
},
|
||||
},
|
||||
},
|
||||
"packages": {
|
||||
"@go-gitea/sdk.js": ["@go-gitea/sdk.js@0.2.1", "", { "dependencies": { "@octokit/core": "^7.0.2", "@octokit/plugin-paginate-rest": "^13.0.0", "@octokit/plugin-retry": "^8.0.1", "@octokit/request-error": "^7.0.0", "@octokit/types": "^15.0.0" } }, "sha512-8H3ci55MHUk8LtS8T4rlkpjNagAWCpBin3J0VhSNobh+XXbLR7kXplwH4ZxXRMqZOi1+gBv3XEk8azicW8zt3g=="],
|
||||
|
||||
"@hono/node-server": ["@hono/node-server@1.19.14", "", { "peerDependencies": { "hono": "^4" } }, "sha512-GwtvgtXxnWsucXvbQXkRgqksiH2Qed37H9xHZocE5sA3N8O8O8/8FA3uclQXxXVzc9XBZuEOMK7+r02FmSpHtw=="],
|
||||
|
||||
"@modelcontextprotocol/sdk": ["@modelcontextprotocol/sdk@1.29.0", "", { "dependencies": { "@hono/node-server": "^1.19.9", "ajv": "^8.17.1", "ajv-formats": "^3.0.1", "content-type": "^1.0.5", "cors": "^2.8.5", "cross-spawn": "^7.0.5", "eventsource": "^3.0.2", "eventsource-parser": "^3.0.0", "express": "^5.2.1", "express-rate-limit": "^8.2.1", "hono": "^4.11.4", "jose": "^6.1.3", "json-schema-typed": "^8.0.2", "pkce-challenge": "^5.0.0", "raw-body": "^3.0.0", "zod": "^3.25 || ^4.0", "zod-to-json-schema": "^3.25.1" }, "peerDependencies": { "@cfworker/json-schema": "^4.1.1" }, "optionalPeers": ["@cfworker/json-schema"] }, "sha512-zo37mZA9hJWpULgkRpowewez1y6ML5GsXJPY8FI0tBBCd77HEvza4jDqRKOXgHNn867PVGCyTdzqpz0izu5ZjQ=="],
|
||||
|
||||
"@octokit/auth-token": ["@octokit/auth-token@6.0.0", "", {}, "sha512-P4YJBPdPSpWTQ1NU4XYdvHvXJJDxM6YwpS0FZHRgP7YFkdVxsWcpWGy/NVqlAA7PcPCnMacXlRm1y2PFZRWL/w=="],
|
||||
|
||||
"@octokit/core": ["@octokit/core@7.0.6", "", { "dependencies": { "@octokit/auth-token": "^6.0.0", "@octokit/graphql": "^9.0.3", "@octokit/request": "^10.0.6", "@octokit/request-error": "^7.0.2", "@octokit/types": "^16.0.0", "before-after-hook": "^4.0.0", "universal-user-agent": "^7.0.0" } }, "sha512-DhGl4xMVFGVIyMwswXeyzdL4uXD5OGILGX5N8Y+f6W7LhC1Ze2poSNrkF/fedpVDHEEZ+PHFW0vL14I+mm8K3Q=="],
|
||||
|
||||
"@octokit/endpoint": ["@octokit/endpoint@11.0.3", "", { "dependencies": { "@octokit/types": "^16.0.0", "universal-user-agent": "^7.0.2" } }, "sha512-FWFlNxghg4HrXkD3ifYbS/IdL/mDHjh9QcsNyhQjN8dplUoZbejsdpmuqdA76nxj2xoWPs7p8uX2SNr9rYu0Ag=="],
|
||||
|
||||
"@octokit/graphql": ["@octokit/graphql@9.0.3", "", { "dependencies": { "@octokit/request": "^10.0.6", "@octokit/types": "^16.0.0", "universal-user-agent": "^7.0.0" } }, "sha512-grAEuupr/C1rALFnXTv6ZQhFuL1D8G5y8CN04RgrO4FIPMrtm+mcZzFG7dcBm+nq+1ppNixu+Jd78aeJOYxlGA=="],
|
||||
|
||||
"@octokit/openapi-types": ["@octokit/openapi-types@26.0.0", "", {}, "sha512-7AtcfKtpo77j7Ts73b4OWhOZHTKo/gGY8bB3bNBQz4H+GRSWqx2yvj8TXRsbdTE0eRmYmXOEY66jM7mJ7LzfsA=="],
|
||||
|
||||
"@octokit/plugin-paginate-rest": ["@octokit/plugin-paginate-rest@13.2.1", "", { "dependencies": { "@octokit/types": "^15.0.1" }, "peerDependencies": { "@octokit/core": ">=6" } }, "sha512-Tj4PkZyIL6eBMYcG/76QGsedF0+dWVeLhYprTmuFVVxzDW7PQh23tM0TP0z+1MvSkxB29YFZwnUX+cXfTiSdyw=="],
|
||||
|
||||
"@octokit/plugin-retry": ["@octokit/plugin-retry@8.1.0", "", { "dependencies": { "@octokit/request-error": "^7.0.2", "@octokit/types": "^16.0.0", "bottleneck": "^2.15.3" }, "peerDependencies": { "@octokit/core": ">=7" } }, "sha512-O1FZgXeiGb2sowEr/hYTr6YunGdSAFWnr2fyW39Ah85H8O33ELASQxcvOFF5LE6Tjekcyu2ms4qAzJVhSaJxTw=="],
|
||||
|
||||
"@octokit/request": ["@octokit/request@10.0.10", "", { "dependencies": { "@octokit/endpoint": "^11.0.3", "@octokit/request-error": "^7.0.2", "@octokit/types": "^16.0.0", "content-type": "^2.0.0", "json-with-bigint": "^3.5.3", "universal-user-agent": "^7.0.2" } }, "sha512-KxNC2pTqqhszMNrf12ZRd4PonRgyJdsM4F/jySiddQK+DsRcfBtUvqn8t7UsyZhnRJHvX46OohDt5N3VqIWC2w=="],
|
||||
|
||||
"@octokit/request-error": ["@octokit/request-error@7.1.0", "", { "dependencies": { "@octokit/types": "^16.0.0" } }, "sha512-KMQIfq5sOPpkQYajXHwnhjCC0slzCNScLHs9JafXc4RAJI+9f+jNDlBNaIMTvazOPLgb4BnlhGJOTbnN0wIjPw=="],
|
||||
|
||||
"@octokit/types": ["@octokit/types@15.0.2", "", { "dependencies": { "@octokit/openapi-types": "^26.0.0" } }, "sha512-rR+5VRjhYSer7sC51krfCctQhVTmjyUMAaShfPB8mscVa8tSoLyon3coxQmXu0ahJoLVWl8dSGD/3OGZlFV44Q=="],
|
||||
|
||||
"@types/bun": ["@types/bun@1.3.14", "", { "dependencies": { "bun-types": "1.3.14" } }, "sha512-h1hFqFVcvAvD9j9K7ZW7vd82aSA+rTdznZa+5bwvCwqSB1jmmfLcbIWhOLx1/+boy/xmjgCs/OMUL8hRJSmnPw=="],
|
||||
|
||||
"@types/node": ["@types/node@25.9.1", "", { "dependencies": { "undici-types": ">=7.24.0 <7.24.7" } }, "sha512-xfrlY7UD5rMJk3ZVJP8BNzS28J36YJg+xp+LPXV1TdWxr8uMH5A860QNxYDGQe/ylDSgjxE52Q9VnO7p75tJxg=="],
|
||||
|
||||
"accepts": ["accepts@2.0.0", "", { "dependencies": { "mime-types": "^3.0.0", "negotiator": "^1.0.0" } }, "sha512-5cvg6CtKwfgdmVqY1WIiXKc3Q1bkRqGLi+2W/6ao+6Y7gu/RCwRuAhGEzh5B4KlszSuTLgZYuqFqo5bImjNKng=="],
|
||||
|
||||
"ajv": ["ajv@8.20.0", "", { "dependencies": { "fast-deep-equal": "^3.1.3", "fast-uri": "^3.0.1", "json-schema-traverse": "^1.0.0", "require-from-string": "^2.0.2" } }, "sha512-Thbli+OlOj+iMPYFBVBfJ3OmCAnaSyNn4M1vz9T6Gka5Jt9ba/HIR56joy65tY6kx/FCF5VXNB819Y7/GUrBGA=="],
|
||||
|
||||
"ajv-formats": ["ajv-formats@3.0.1", "", { "dependencies": { "ajv": "^8.0.0" } }, "sha512-8iUql50EUR+uUcdRQ3HDqa6EVyo3docL8g5WJ3FNcWmu62IbkGUue/pEyLBW8VGKKucTPgqeks4fIU1DA4yowQ=="],
|
||||
|
||||
"before-after-hook": ["before-after-hook@4.0.0", "", {}, "sha512-q6tR3RPqIB1pMiTRMFcZwuG5T8vwp+vUvEG0vuI6B+Rikh5BfPp2fQ82c925FOs+b0lcFQ8CFrL+KbilfZFhOQ=="],
|
||||
|
||||
"body-parser": ["body-parser@2.2.2", "", { "dependencies": { "bytes": "^3.1.2", "content-type": "^1.0.5", "debug": "^4.4.3", "http-errors": "^2.0.0", "iconv-lite": "^0.7.0", "on-finished": "^2.4.1", "qs": "^6.14.1", "raw-body": "^3.0.1", "type-is": "^2.0.1" } }, "sha512-oP5VkATKlNwcgvxi0vM0p/D3n2C3EReYVX+DNYs5TjZFn/oQt2j+4sVJtSMr18pdRr8wjTcBl6LoV+FUwzPmNA=="],
|
||||
|
||||
"bottleneck": ["bottleneck@2.19.5", "", {}, "sha512-VHiNCbI1lKdl44tGrhNfU3lup0Tj/ZBMJB5/2ZbNXRCPuRCO7ed2mgcK4r17y+KB2EfuYuRaVlwNbAeaWGSpbw=="],
|
||||
|
||||
"bun-types": ["bun-types@1.3.14", "", { "dependencies": { "@types/node": "*" } }, "sha512-4N0ig0fEomHt5R0KCFWjovxow98rIoRwKolrYdCcknNwMekCXRnWEUvgu5soYV8QXtVsrUD8B95MBOZGPvr6KQ=="],
|
||||
|
||||
"bytes": ["bytes@3.1.2", "", {}, "sha512-/Nf7TyzTx6S3yRJObOAV7956r8cr2+Oj8AC5dt8wSP3BQAoeX58NoHyCU8P8zGkNXStjTSi6fzO6F0pBdcYbEg=="],
|
||||
|
||||
"call-bind-apply-helpers": ["call-bind-apply-helpers@1.0.2", "", { "dependencies": { "es-errors": "^1.3.0", "function-bind": "^1.1.2" } }, "sha512-Sp1ablJ0ivDkSzjcaJdxEunN5/XvksFJ2sMBFfq6x0ryhQV/2b/KwFe21cMpmHtPOSij8K99/wSfoEuTObmuMQ=="],
|
||||
|
||||
"call-bound": ["call-bound@1.0.4", "", { "dependencies": { "call-bind-apply-helpers": "^1.0.2", "get-intrinsic": "^1.3.0" } }, "sha512-+ys997U96po4Kx/ABpBCqhA9EuxJaQWDQg7295H4hBphv3IZg0boBKuwYpt4YXp6MZ5AmZQnU/tyMTlRpaSejg=="],
|
||||
|
||||
"content-disposition": ["content-disposition@1.1.0", "", {}, "sha512-5jRCH9Z/+DRP7rkvY83B+yGIGX96OYdJmzngqnw2SBSxqCFPd0w2km3s5iawpGX8krnwSGmF0FW5Nhr0Hfai3g=="],
|
||||
|
||||
"content-type": ["content-type@1.0.5", "", {}, "sha512-nTjqfcBFEipKdXCv4YDQWCfmcLZKm81ldF0pAopTvyrFGVbcR6P/VAAd5G7N+0tTr8QqiU0tFadD6FK4NtJwOA=="],
|
||||
|
||||
"cookie": ["cookie@0.7.2", "", {}, "sha512-yki5XnKuf750l50uGTllt6kKILY4nQ1eNIQatoXEByZ5dWgnKqbnqmTrBE5B4N7lrMJKQ2ytWMiTO2o0v6Ew/w=="],
|
||||
|
||||
"cookie-signature": ["cookie-signature@1.2.2", "", {}, "sha512-D76uU73ulSXrD1UXF4KE2TMxVVwhsnCgfAyTg9k8P6KGZjlXKrOLe4dJQKI3Bxi5wjesZoFXJWElNWBjPZMbhg=="],
|
||||
|
||||
"cors": ["cors@2.8.6", "", { "dependencies": { "object-assign": "^4", "vary": "^1" } }, "sha512-tJtZBBHA6vjIAaF6EnIaq6laBBP9aq/Y3ouVJjEfoHbRBcHBAHYcMh/w8LDrk2PvIMMq8gmopa5D4V8RmbrxGw=="],
|
||||
|
||||
"cross-spawn": ["cross-spawn@7.0.6", "", { "dependencies": { "path-key": "^3.1.0", "shebang-command": "^2.0.0", "which": "^2.0.1" } }, "sha512-uV2QOWP2nWzsy2aMp8aRibhi9dlzF5Hgh5SHaB9OiTGEyDTiJJyx0uy51QXdyWbtAHNua4XJzUKca3OzKUd3vA=="],
|
||||
|
||||
"debug": ["debug@4.4.3", "", { "dependencies": { "ms": "^2.1.3" } }, "sha512-RGwwWnwQvkVfavKVt22FGLw+xYSdzARwm0ru6DhTVA3umU5hZc28V3kO4stgYryrTlLpuvgI9GiijltAjNbcqA=="],
|
||||
|
||||
"depd": ["depd@2.0.0", "", {}, "sha512-g7nH6P6dyDioJogAAGprGpCtVImJhpPk/roCzdb3fIh61/s/nPsfR6onyMwkCAR/OlC3yBC0lESvUoQEAssIrw=="],
|
||||
|
||||
"dunder-proto": ["dunder-proto@1.0.1", "", { "dependencies": { "call-bind-apply-helpers": "^1.0.1", "es-errors": "^1.3.0", "gopd": "^1.2.0" } }, "sha512-KIN/nDJBQRcXw0MLVhZE9iQHmG68qAVIBg9CqmUYjmQIhgij9U5MFvrqkUL5FbtyyzZuOeOt0zdeRe4UY7ct+A=="],
|
||||
|
||||
"ee-first": ["ee-first@1.1.1", "", {}, "sha512-WMwm9LhRUo+WUaRN+vRuETqG89IgZphVSNkdFgeb6sS/E4OrDIN7t48CAewSHXc6C8lefD8KKfr5vY61brQlow=="],
|
||||
|
||||
"encodeurl": ["encodeurl@2.0.0", "", {}, "sha512-Q0n9HRi4m6JuGIV1eFlmvJB7ZEVxu93IrMyiMsGC0lrMJMWzRgx6WGquyfQgZVb31vhGgXnfmPNNXmxnOkRBrg=="],
|
||||
|
||||
"es-define-property": ["es-define-property@1.0.1", "", {}, "sha512-e3nRfgfUZ4rNGL232gUgX06QNyyez04KdjFrF+LTRoOXmrOgFKDg4BCdsjW8EnT69eqdYGmRpJwiPVYNrCaW3g=="],
|
||||
|
||||
"es-errors": ["es-errors@1.3.0", "", {}, "sha512-Zf5H2Kxt2xjTvbJvP2ZWLEICxA6j+hAmMzIlypy4xcBg1vKVnx89Wy0GbS+kf5cwCVFFzdCFh2XSCFNULS6csw=="],
|
||||
|
||||
"es-object-atoms": ["es-object-atoms@1.1.2", "", { "dependencies": { "es-errors": "^1.3.0" } }, "sha512-HWcBoN6NileqtSydK2FqHbS/LoDd2pqrnQHLyJzBj4kOp/ky2MWMN694xOfkK8/SnUsW2DH7EfyVlydKCsm1Zw=="],
|
||||
|
||||
"escape-html": ["escape-html@1.0.3", "", {}, "sha512-NiSupZ4OeuGwr68lGIeym/ksIZMJodUGOSCZ/FSnTxcrekbvqrgdUxlJOMpijaKZVjAJrWrGs/6Jy8OMuyj9ow=="],
|
||||
|
||||
"etag": ["etag@1.8.1", "", {}, "sha512-aIL5Fx7mawVa300al2BnEE4iNvo1qETxLrPI/o05L7z6go7fCw1J6EQmbK4FmJ2AS7kgVF/KEZWufBfdClMcPg=="],
|
||||
|
||||
"eventsource": ["eventsource@3.0.7", "", { "dependencies": { "eventsource-parser": "^3.0.1" } }, "sha512-CRT1WTyuQoD771GW56XEZFQ/ZoSfWid1alKGDYMmkt2yl8UXrVR4pspqWNEcqKvVIzg6PAltWjxcSSPrboA4iA=="],
|
||||
|
||||
"eventsource-parser": ["eventsource-parser@3.1.0", "", {}, "sha512-kJezFj9YFAMLeORyi7aCLxLbD5/qWMQnoMVlVPyHIll7lgRJCc3JVln9Vgl9nwQi0YkMnhdGTMNn7CkRRAptMg=="],
|
||||
|
||||
"express": ["express@5.2.1", "", { "dependencies": { "accepts": "^2.0.0", "body-parser": "^2.2.1", "content-disposition": "^1.0.0", "content-type": "^1.0.5", "cookie": "^0.7.1", "cookie-signature": "^1.2.1", "debug": "^4.4.0", "depd": "^2.0.0", "encodeurl": "^2.0.0", "escape-html": "^1.0.3", "etag": "^1.8.1", "finalhandler": "^2.1.0", "fresh": "^2.0.0", "http-errors": "^2.0.0", "merge-descriptors": "^2.0.0", "mime-types": "^3.0.0", "on-finished": "^2.4.1", "once": "^1.4.0", "parseurl": "^1.3.3", "proxy-addr": "^2.0.7", "qs": "^6.14.0", "range-parser": "^1.2.1", "router": "^2.2.0", "send": "^1.1.0", "serve-static": "^2.2.0", "statuses": "^2.0.1", "type-is": "^2.0.1", "vary": "^1.1.2" } }, "sha512-hIS4idWWai69NezIdRt2xFVofaF4j+6INOpJlVOLDO8zXGpUVEVzIYk12UUi2JzjEzWL3IOAxcTubgz9Po0yXw=="],
|
||||
|
||||
"express-rate-limit": ["express-rate-limit@8.5.2", "", { "dependencies": { "ip-address": "^10.2.0" }, "peerDependencies": { "express": ">= 4.11" } }, "sha512-5Kb34ipNX694DH48vN9irak1Qx30nb0PLYHXfJgw4YEjiC3ZEmZJhwOp+VfiCYwFzvFTdB9QkArYS5kXa2cx2A=="],
|
||||
|
||||
"fast-deep-equal": ["fast-deep-equal@3.1.3", "", {}, "sha512-f3qQ9oQy9j2AhBe/H9VC91wLmKBCCU/gDOnKNAYG5hswO7BLKj09Hc5HYNz9cGI++xlpDCIgDaitVs03ATR84Q=="],
|
||||
|
||||
"fast-uri": ["fast-uri@3.1.2", "", {}, "sha512-rVjf7ArG3LTk+FS6Yw81V1DLuZl1bRbNrev6Tmd/9RaroeeRRJhAt7jg/6YFxbvAQXUCavSoZhPPj6oOx+5KjQ=="],
|
||||
|
||||
"finalhandler": ["finalhandler@2.1.1", "", { "dependencies": { "debug": "^4.4.0", "encodeurl": "^2.0.0", "escape-html": "^1.0.3", "on-finished": "^2.4.1", "parseurl": "^1.3.3", "statuses": "^2.0.1" } }, "sha512-S8KoZgRZN+a5rNwqTxlZZePjT/4cnm0ROV70LedRHZ0p8u9fRID0hJUZQpkKLzro8LfmC8sx23bY6tVNxv8pQA=="],
|
||||
|
||||
"forwarded": ["forwarded@0.2.0", "", {}, "sha512-buRG0fpBtRHSTCOASe6hD258tEubFoRLb4ZNA6NxMVHNw2gOcwHo9wyablzMzOA5z9xA9L1KNjk/Nt6MT9aYow=="],
|
||||
|
||||
"fresh": ["fresh@2.0.0", "", {}, "sha512-Rx/WycZ60HOaqLKAi6cHRKKI7zxWbJ31MhntmtwMoaTeF7XFH9hhBp8vITaMidfljRQ6eYWCKkaTK+ykVJHP2A=="],
|
||||
|
||||
"function-bind": ["function-bind@1.1.2", "", {}, "sha512-7XHNxH7qX9xG5mIwxkhumTox/MIRNcOgDrxWsMt2pAr23WHp6MrRlN7FBSFpCpr+oVO0F744iUgR82nJMfG2SA=="],
|
||||
|
||||
"get-intrinsic": ["get-intrinsic@1.3.0", "", { "dependencies": { "call-bind-apply-helpers": "^1.0.2", "es-define-property": "^1.0.1", "es-errors": "^1.3.0", "es-object-atoms": "^1.1.1", "function-bind": "^1.1.2", "get-proto": "^1.0.1", "gopd": "^1.2.0", "has-symbols": "^1.1.0", "hasown": "^2.0.2", "math-intrinsics": "^1.1.0" } }, "sha512-9fSjSaos/fRIVIp+xSJlE6lfwhES7LNtKaCBIamHsjr2na1BiABJPo0mOjjz8GJDURarmCPGqaiVg5mfjb98CQ=="],
|
||||
|
||||
"get-proto": ["get-proto@1.0.1", "", { "dependencies": { "dunder-proto": "^1.0.1", "es-object-atoms": "^1.0.0" } }, "sha512-sTSfBjoXBp89JvIKIefqw7U2CCebsc74kiY6awiGogKtoSGbgjYE/G/+l9sF3MWFPNc9IcoOC4ODfKHfxFmp0g=="],
|
||||
|
||||
"gopd": ["gopd@1.2.0", "", {}, "sha512-ZUKRh6/kUFoAiTAtTYPZJ3hw9wNxx+BIBOijnlG9PnrJsCcSjs1wyyD6vJpaYtgnzDrKYRSqf3OO6Rfa93xsRg=="],
|
||||
|
||||
"has-symbols": ["has-symbols@1.1.0", "", {}, "sha512-1cDNdwJ2Jaohmb3sg4OmKaMBwuC48sYni5HUw2DvsC8LjGTLK9h+eb1X6RyuOHe4hT0ULCW68iomhjUoKUqlPQ=="],
|
||||
|
||||
"hasown": ["hasown@2.0.4", "", { "dependencies": { "function-bind": "^1.1.2" } }, "sha512-T2UbfbBEF32wiepXIsMlTW9+dDYC6wMh/t/vYA4tuOMKqWz/n3vr1NFSxQiyP+zk2mXsoMA/i/7qV6LKut1t1A=="],
|
||||
|
||||
"hono": ["hono@4.12.23", "", {}, "sha512-eIaZ9qDgu7XV0pxOCrg7/WhnQ6Ivm22UcxhXx/A3dcbqbbYgBEkc6e/J/s7j2tS96zoB0S9VBdLwQNCWwUo4LA=="],
|
||||
|
||||
"http-errors": ["http-errors@2.0.1", "", { "dependencies": { "depd": "~2.0.0", "inherits": "~2.0.4", "setprototypeof": "~1.2.0", "statuses": "~2.0.2", "toidentifier": "~1.0.1" } }, "sha512-4FbRdAX+bSdmo4AUFuS0WNiPz8NgFt+r8ThgNWmlrjQjt1Q7ZR9+zTlce2859x4KSXrwIsaeTqDoKQmtP8pLmQ=="],
|
||||
|
||||
"iconv-lite": ["iconv-lite@0.7.2", "", { "dependencies": { "safer-buffer": ">= 2.1.2 < 3.0.0" } }, "sha512-im9DjEDQ55s9fL4EYzOAv0yMqmMBSZp6G0VvFyTMPKWxiSBHUj9NW/qqLmXUwXrrM7AvqSlTCfvqRb0cM8yYqw=="],
|
||||
|
||||
"inherits": ["inherits@2.0.4", "", {}, "sha512-k/vGaX4/Yla3WzyMCvTQOXYeIHvqOKtnqBduzTHpzpQZzAskKMhZ2K+EnBiSM9zGSoIFeMpXKxa4dYeZIQqewQ=="],
|
||||
|
||||
"ip-address": ["ip-address@10.2.0", "", {}, "sha512-/+S6j4E9AHvW9SWMSEY9Xfy66O5PWvVEJ08O0y5JGyEKQpojb0K0GKpz/v5HJ/G0vi3D2sjGK78119oXZeE0qA=="],
|
||||
|
||||
"ipaddr.js": ["ipaddr.js@1.9.1", "", {}, "sha512-0KI/607xoxSToH7GjN1FfSbLoU0+btTicjsQSWQlh/hZykN8KpmMf7uYwPW3R+akZ6R/w18ZlXSHBYXiYUPO3g=="],
|
||||
|
||||
"is-promise": ["is-promise@4.0.0", "", {}, "sha512-hvpoI6korhJMnej285dSg6nu1+e6uxs7zG3BYAm5byqDsgJNWwxzM6z6iZiAgQR4TJ30JmBTOwqZUw3WlyH3AQ=="],
|
||||
|
||||
"isexe": ["isexe@2.0.0", "", {}, "sha512-RHxMLp9lnKHGHRng9QFhRCMbYAcVpn69smSGcq3f36xjgVVWThj4qqLbTLlq7Ssj8B+fIQ1EuCEGI2lKsyQeIw=="],
|
||||
|
||||
"jose": ["jose@6.2.3", "", {}, "sha512-YYVDInQKFJfR/xa3ojUTl8c2KoTwiL1R5Wg9YCydwH0x0B9grbzlg5HC7mMjCtUJjbQ/YnGEZIhI5tCgfTb4Hw=="],
|
||||
|
||||
"json-schema-traverse": ["json-schema-traverse@1.0.0", "", {}, "sha512-NM8/P9n3XjXhIZn1lLhkFaACTOURQXjWhV4BA/RnOv8xvgqtqpAX9IO4mRQxSx1Rlo4tqzeqb0sOlruaOy3dug=="],
|
||||
|
||||
"json-schema-typed": ["json-schema-typed@8.0.2", "", {}, "sha512-fQhoXdcvc3V28x7C7BMs4P5+kNlgUURe2jmUT1T//oBRMDrqy1QPelJimwZGo7Hg9VPV3EQV5Bnq4hbFy2vetA=="],
|
||||
|
||||
"json-with-bigint": ["json-with-bigint@3.5.8", "", {}, "sha512-eq/4KP6K34kwa7TcFdtvnftvHCD9KvHOGGICWwMFc4dOOKF5t4iYqnfLK8otCRCRv06FXOzGGyqE8h8ElMvvdw=="],
|
||||
|
||||
"math-intrinsics": ["math-intrinsics@1.1.0", "", {}, "sha512-/IXtbwEk5HTPyEwyKX6hGkYXxM9nbj64B+ilVJnC/R6B0pH5G4V3b0pVbL7DBj4tkhBAppbQUlf6F6Xl9LHu1g=="],
|
||||
|
||||
"media-typer": ["media-typer@1.1.0", "", {}, "sha512-aisnrDP4GNe06UcKFnV5bfMNPBUw4jsLGaWwWfnH3v02GnBuXX2MCVn5RbrWo0j3pczUilYblq7fQ7Nw2t5XKw=="],
|
||||
|
||||
"merge-descriptors": ["merge-descriptors@2.0.0", "", {}, "sha512-Snk314V5ayFLhp3fkUREub6WtjBfPdCPY1Ln8/8munuLuiYhsABgBVWsozAG+MWMbVEvcdcpbi9R7ww22l9Q3g=="],
|
||||
|
||||
"mime-db": ["mime-db@1.54.0", "", {}, "sha512-aU5EJuIN2WDemCcAp2vFBfp/m4EAhWJnUNSSw0ixs7/kXbd6Pg64EmwJkNdFhB8aWt1sH2CTXrLxo/iAGV3oPQ=="],
|
||||
|
||||
"mime-types": ["mime-types@3.0.2", "", { "dependencies": { "mime-db": "^1.54.0" } }, "sha512-Lbgzdk0h4juoQ9fCKXW4by0UJqj+nOOrI9MJ1sSj4nI8aI2eo1qmvQEie4VD1glsS250n15LsWsYtCugiStS5A=="],
|
||||
|
||||
"ms": ["ms@2.1.3", "", {}, "sha512-6FlzubTLZG3J2a/NVCAleEhjzq5oxgHyaCU9yYXvcLsvoVaHJq/s5xXI6/XXP6tz7R9xAOtHnSO/tXtF3WRTlA=="],
|
||||
|
||||
"negotiator": ["negotiator@1.0.0", "", {}, "sha512-8Ofs/AUQh8MaEcrlq5xOX0CQ9ypTF5dl78mjlMNfOK08fzpgTHQRQPBxcPlEtIw0yRpws+Zo/3r+5WRby7u3Gg=="],
|
||||
|
||||
"object-assign": ["object-assign@4.1.1", "", {}, "sha512-rJgTQnkUnH1sFw8yT6VSU3zD3sWmu6sZhIseY8VX+GRu3P6F7Fu+JNDoXfklElbLJSnc3FUQHVe4cU5hj+BcUg=="],
|
||||
|
||||
"object-inspect": ["object-inspect@1.13.4", "", {}, "sha512-W67iLl4J2EXEGTbfeHCffrjDfitvLANg0UlX3wFUUSTx92KXRFegMHUVgSqE+wvhAbi4WqjGg9czysTV2Epbew=="],
|
||||
|
||||
"ollama": ["ollama@0.6.3", "", { "dependencies": { "whatwg-fetch": "^3.6.20" } }, "sha512-KEWEhIqE5wtfzEIZbDCLH51VFZ6Z3ZSa6sIOg/E/tBV8S51flyqBOXi+bRxlOYKDf8i327zG9eSTb8IJxvm3Zg=="],
|
||||
|
||||
"on-finished": ["on-finished@2.4.1", "", { "dependencies": { "ee-first": "1.1.1" } }, "sha512-oVlzkg3ENAhCk2zdv7IJwd/QUD4z2RxRwpkcGY8psCVcCYZNq4wYnVWALHM+brtuJjePWiYF/ClmuDr8Ch5+kg=="],
|
||||
|
||||
"once": ["once@1.4.0", "", { "dependencies": { "wrappy": "1" } }, "sha512-lNaJgI+2Q5URQBkccEKHTQOPaXdUxnZZElQTZY0MFUAuaEqe1E+Nyvgdz/aIyNi6Z9MzO5dv1H8n58/GELp3+w=="],
|
||||
|
||||
"parseurl": ["parseurl@1.3.3", "", {}, "sha512-CiyeOxFT/JZyN5m0z9PfXw4SCBJ6Sygz1Dpl0wqjlhDEGGBP1GnsUVEL0p63hoG1fcj3fHynXi9NYO4nWOL+qQ=="],
|
||||
|
||||
"path-key": ["path-key@3.1.1", "", {}, "sha512-ojmeN0qd+y0jszEtoY48r0Peq5dwMEkIlCOu6Q5f41lfkswXuKtYrhgoTpLnyIcHm24Uhqx+5Tqm2InSwLhE6Q=="],
|
||||
|
||||
"path-to-regexp": ["path-to-regexp@8.4.2", "", {}, "sha512-qRcuIdP69NPm4qbACK+aDogI5CBDMi1jKe0ry5rSQJz8JVLsC7jV8XpiJjGRLLol3N+R5ihGYcrPLTno6pAdBA=="],
|
||||
|
||||
"pkce-challenge": ["pkce-challenge@5.0.1", "", {}, "sha512-wQ0b/W4Fr01qtpHlqSqspcj3EhBvimsdh0KlHhH8HRZnMsEa0ea2fTULOXOS9ccQr3om+GcGRk4e+isrZWV8qQ=="],
|
||||
|
||||
"proxy-addr": ["proxy-addr@2.0.7", "", { "dependencies": { "forwarded": "0.2.0", "ipaddr.js": "1.9.1" } }, "sha512-llQsMLSUDUPT44jdrU/O37qlnifitDP+ZwrmmZcoSKyLKvtZxpyV0n2/bD/N4tBAAZ/gJEdZU7KMraoK1+XYAg=="],
|
||||
|
||||
"qs": ["qs@6.15.2", "", { "dependencies": { "side-channel": "^1.1.0" } }, "sha512-Rzq0KEyX/w/tEybncDgdkZrJgVUsUMk3xjh3t5bv3S1HTAtg+uOYt72+ZfwiQwKdysThkTBdL/rTi6HDmX9Ddw=="],
|
||||
|
||||
"range-parser": ["range-parser@1.2.1", "", {}, "sha512-Hrgsx+orqoygnmhFbKaHE6c296J+HTAQXoxEF6gNupROmmGJRoyzfG3ccAveqCBrwr/2yxQ5BVd/GTl5agOwSg=="],
|
||||
|
||||
"raw-body": ["raw-body@3.0.2", "", { "dependencies": { "bytes": "~3.1.2", "http-errors": "~2.0.1", "iconv-lite": "~0.7.0", "unpipe": "~1.0.0" } }, "sha512-K5zQjDllxWkf7Z5xJdV0/B0WTNqx6vxG70zJE4N0kBs4LovmEYWJzQGxC9bS9RAKu3bgM40lrd5zoLJ12MQ5BA=="],
|
||||
|
||||
"require-from-string": ["require-from-string@2.0.2", "", {}, "sha512-Xf0nWe6RseziFMu+Ap9biiUbmplq6S9/p+7w7YXP/JBHhrUDDUhwa+vANyubuqfZWTveU//DYVGsDG7RKL/vEw=="],
|
||||
|
||||
"router": ["router@2.2.0", "", { "dependencies": { "debug": "^4.4.0", "depd": "^2.0.0", "is-promise": "^4.0.0", "parseurl": "^1.3.3", "path-to-regexp": "^8.0.0" } }, "sha512-nLTrUKm2UyiL7rlhapu/Zl45FwNgkZGaCpZbIHajDYgwlJCOzLSk+cIPAnsEqV955GjILJnKbdQC1nVPz+gAYQ=="],
|
||||
|
||||
"safer-buffer": ["safer-buffer@2.1.2", "", {}, "sha512-YZo3K82SD7Riyi0E1EQPojLz7kpepnSQI9IyPbHHg1XXXevb5dJI7tpyN2ADxGcQbHG7vcyRHk0cbwqcQriUtg=="],
|
||||
|
||||
"send": ["send@1.2.1", "", { "dependencies": { "debug": "^4.4.3", "encodeurl": "^2.0.0", "escape-html": "^1.0.3", "etag": "^1.8.1", "fresh": "^2.0.0", "http-errors": "^2.0.1", "mime-types": "^3.0.2", "ms": "^2.1.3", "on-finished": "^2.4.1", "range-parser": "^1.2.1", "statuses": "^2.0.2" } }, "sha512-1gnZf7DFcoIcajTjTwjwuDjzuz4PPcY2StKPlsGAQ1+YH20IRVrBaXSWmdjowTJ6u8Rc01PoYOGHXfP1mYcZNQ=="],
|
||||
|
||||
"serve-static": ["serve-static@2.2.1", "", { "dependencies": { "encodeurl": "^2.0.0", "escape-html": "^1.0.3", "parseurl": "^1.3.3", "send": "^1.2.0" } }, "sha512-xRXBn0pPqQTVQiC8wyQrKs2MOlX24zQ0POGaj0kultvoOCstBQM5yvOhAVSUwOMjQtTvsPWoNCHfPGwaaQJhTw=="],
|
||||
|
||||
"setprototypeof": ["setprototypeof@1.2.0", "", {}, "sha512-E5LDX7Wrp85Kil5bhZv46j8jOeboKq5JMmYM3gVGdGH8xFpPWXUMsNrlODCrkoxMEeNi/XZIwuRvY4XNwYMJpw=="],
|
||||
|
||||
"shebang-command": ["shebang-command@2.0.0", "", { "dependencies": { "shebang-regex": "^3.0.0" } }, "sha512-kHxr2zZpYtdmrN1qDjrrX/Z1rR1kG8Dx+gkpK1G4eXmvXswmcE1hTWBWYUzlraYw1/yZp6YuDY77YtvbN0dmDA=="],
|
||||
|
||||
"shebang-regex": ["shebang-regex@3.0.0", "", {}, "sha512-7++dFhtcx3353uBaq8DDR4NuxBetBzC7ZQOhmTQInHEd6bSrXdiEyzCvG07Z44UYdLShWUyXt5M/yhz8ekcb1A=="],
|
||||
|
||||
"side-channel": ["side-channel@1.1.0", "", { "dependencies": { "es-errors": "^1.3.0", "object-inspect": "^1.13.3", "side-channel-list": "^1.0.0", "side-channel-map": "^1.0.1", "side-channel-weakmap": "^1.0.2" } }, "sha512-ZX99e6tRweoUXqR+VBrslhda51Nh5MTQwou5tnUDgbtyM0dBgmhEDtWGP/xbKn6hqfPRHujUNwz5fy/wbbhnpw=="],
|
||||
|
||||
"side-channel-list": ["side-channel-list@1.0.1", "", { "dependencies": { "es-errors": "^1.3.0", "object-inspect": "^1.13.4" } }, "sha512-mjn/0bi/oUURjc5Xl7IaWi/OJJJumuoJFQJfDDyO46+hBWsfaVM65TBHq2eoZBhzl9EchxOijpkbRC8SVBQU0w=="],
|
||||
|
||||
"side-channel-map": ["side-channel-map@1.0.1", "", { "dependencies": { "call-bound": "^1.0.2", "es-errors": "^1.3.0", "get-intrinsic": "^1.2.5", "object-inspect": "^1.13.3" } }, "sha512-VCjCNfgMsby3tTdo02nbjtM/ewra6jPHmpThenkTYh8pG9ucZ/1P8So4u4FGBek/BjpOVsDCMoLA/iuBKIFXRA=="],
|
||||
|
||||
"side-channel-weakmap": ["side-channel-weakmap@1.0.2", "", { "dependencies": { "call-bound": "^1.0.2", "es-errors": "^1.3.0", "get-intrinsic": "^1.2.5", "object-inspect": "^1.13.3", "side-channel-map": "^1.0.1" } }, "sha512-WPS/HvHQTYnHisLo9McqBHOJk2FkHO/tlpvldyrnem4aeQp4hai3gythswg6p01oSoTl58rcpiFAjF2br2Ak2A=="],
|
||||
|
||||
"statuses": ["statuses@2.0.2", "", {}, "sha512-DvEy55V3DB7uknRo+4iOGT5fP1slR8wQohVdknigZPMpMstaKJQWhwiYBACJE3Ul2pTnATihhBYnRhZQHGBiRw=="],
|
||||
|
||||
"toidentifier": ["toidentifier@1.0.1", "", {}, "sha512-o5sSPKEkg/DIQNmH43V0/uerLrpzVedkUh8tGNvaeXpfpuwjKenlSox/2O/BTlZUtEe+JG7s5YhEz608PlAHRA=="],
|
||||
|
||||
"type-is": ["type-is@2.1.0", "", { "dependencies": { "content-type": "^2.0.0", "media-typer": "^1.1.0", "mime-types": "^3.0.0" } }, "sha512-faYHw0anBbc/kWF3zFTEnxSFOAGUX9GFbOBthvDdLsIlEoWOFOtS0zgCiQYwIskL9iGXZL3kAXD8OoZ4GmMATA=="],
|
||||
|
||||
"typescript": ["typescript@5.9.3", "", { "bin": { "tsc": "bin/tsc", "tsserver": "bin/tsserver" } }, "sha512-jl1vZzPDinLr9eUt3J/t7V6FgNEw9QjvBPdysz9KfQDD41fQrC2Y4vKQdiaUpFT4bXlb1RHhLpp8wtm6M5TgSw=="],
|
||||
|
||||
"undici-types": ["undici-types@7.24.6", "", {}, "sha512-WRNW+sJgj5OBN4/0JpHFqtqzhpbnV0GuB+OozA9gCL7a993SmU+1JBZCzLNxYsbMfIeDL+lTsphD5jN5N+n0zg=="],
|
||||
|
||||
"universal-user-agent": ["universal-user-agent@7.0.3", "", {}, "sha512-TmnEAEAsBJVZM/AADELsK76llnwcf9vMKuPz8JflO1frO8Lchitr0fNaN9d+Ap0BjKtqWqd/J17qeDnXh8CL2A=="],
|
||||
|
||||
"unpipe": ["unpipe@1.0.0", "", {}, "sha512-pjy2bYhSsufwWlKwPc+l3cN7+wuJlK6uz0YdJEOlQDbl6jo/YlPi4mb8agUkVC8BF7V8NuzeyPNqRksA3hztKQ=="],
|
||||
|
||||
"vary": ["vary@1.1.2", "", {}, "sha512-BNGbWLfd0eUPabhkXUVm0j8uuvREyTh5ovRa/dyow/BqAbZJyC+5fU+IzQOzmAKzYqYRAISoRhdQr3eIZ/PXqg=="],
|
||||
|
||||
"whatwg-fetch": ["whatwg-fetch@3.6.20", "", {}, "sha512-EqhiFU6daOA8kpjOWTL0olhVOF3i7OrFzSYiGsEMB8GcXS+RrzauAERX65xMeNWVqxA6HXH2m69Z9LaKKdisfg=="],
|
||||
|
||||
"which": ["which@2.0.2", "", { "dependencies": { "isexe": "^2.0.0" }, "bin": { "node-which": "./bin/node-which" } }, "sha512-BLI3Tl1TW3Pvl70l3yq3Y64i+awpwXqsGBYWkkqMtnbXgrMD+yj7rhW0kuEDxzJaYXGjEW5ogapKNMEKNMjibA=="],
|
||||
|
||||
"wrappy": ["wrappy@1.0.2", "", {}, "sha512-l4Sp/DRseor9wL6EvV2+TuQn63dMkPjZ/sp9XkghTEbV9KlPS1xUsZ3u7/IQO4wxtcFB4bgpQPRcR3QCvezPcQ=="],
|
||||
|
||||
"zod": ["zod@4.4.3", "", {}, "sha512-ytENFjIJFl2UwYglde2jchW2Hwm4GJFLDiSXWdTrJQBIN9Fcyp7n4DhxJEiWNAJMV1/BqWfW/kkg71UDcHJyTQ=="],
|
||||
|
||||
"zod-to-json-schema": ["zod-to-json-schema@3.25.2", "", { "peerDependencies": { "zod": "^3.25.28 || ^4" } }, "sha512-O/PgfnpT1xKSDeQYSCfRI5Gy3hPf91mKVDuYLUHZJMiDFptvP41MSnWofm8dnCm0256ZNfZIM7DSzuSMAFnjHA=="],
|
||||
|
||||
"@octokit/core/@octokit/types": ["@octokit/types@16.0.0", "", { "dependencies": { "@octokit/openapi-types": "^27.0.0" } }, "sha512-sKq+9r1Mm4efXW1FCk7hFSeJo4QKreL/tTbR0rz/qx/r1Oa2VV83LTA/H/MuCOX7uCIJmQVRKBcbmWoySjAnSg=="],
|
||||
|
||||
"@octokit/endpoint/@octokit/types": ["@octokit/types@16.0.0", "", { "dependencies": { "@octokit/openapi-types": "^27.0.0" } }, "sha512-sKq+9r1Mm4efXW1FCk7hFSeJo4QKreL/tTbR0rz/qx/r1Oa2VV83LTA/H/MuCOX7uCIJmQVRKBcbmWoySjAnSg=="],
|
||||
|
||||
"@octokit/graphql/@octokit/types": ["@octokit/types@16.0.0", "", { "dependencies": { "@octokit/openapi-types": "^27.0.0" } }, "sha512-sKq+9r1Mm4efXW1FCk7hFSeJo4QKreL/tTbR0rz/qx/r1Oa2VV83LTA/H/MuCOX7uCIJmQVRKBcbmWoySjAnSg=="],
|
||||
|
||||
"@octokit/plugin-retry/@octokit/types": ["@octokit/types@16.0.0", "", { "dependencies": { "@octokit/openapi-types": "^27.0.0" } }, "sha512-sKq+9r1Mm4efXW1FCk7hFSeJo4QKreL/tTbR0rz/qx/r1Oa2VV83LTA/H/MuCOX7uCIJmQVRKBcbmWoySjAnSg=="],
|
||||
|
||||
"@octokit/request/@octokit/types": ["@octokit/types@16.0.0", "", { "dependencies": { "@octokit/openapi-types": "^27.0.0" } }, "sha512-sKq+9r1Mm4efXW1FCk7hFSeJo4QKreL/tTbR0rz/qx/r1Oa2VV83LTA/H/MuCOX7uCIJmQVRKBcbmWoySjAnSg=="],
|
||||
|
||||
"@octokit/request/content-type": ["content-type@2.0.0", "", {}, "sha512-j/O/d7GcZCyNl7/hwZAb606rzqkyvaDctLmckbxLzHvFBzTJHuGEdodATcP3yIRoDrLHkIATJuvzbFlp/ki2cQ=="],
|
||||
|
||||
"@octokit/request-error/@octokit/types": ["@octokit/types@16.0.0", "", { "dependencies": { "@octokit/openapi-types": "^27.0.0" } }, "sha512-sKq+9r1Mm4efXW1FCk7hFSeJo4QKreL/tTbR0rz/qx/r1Oa2VV83LTA/H/MuCOX7uCIJmQVRKBcbmWoySjAnSg=="],
|
||||
|
||||
"type-is/content-type": ["content-type@2.0.0", "", {}, "sha512-j/O/d7GcZCyNl7/hwZAb606rzqkyvaDctLmckbxLzHvFBzTJHuGEdodATcP3yIRoDrLHkIATJuvzbFlp/ki2cQ=="],
|
||||
|
||||
"@octokit/core/@octokit/types/@octokit/openapi-types": ["@octokit/openapi-types@27.0.0", "", {}, "sha512-whrdktVs1h6gtR+09+QsNk2+FO+49j6ga1c55YZudfEG+oKJVvJLQi3zkOm5JjiUXAagWK2tI2kTGKJ2Ys7MGA=="],
|
||||
|
||||
"@octokit/endpoint/@octokit/types/@octokit/openapi-types": ["@octokit/openapi-types@27.0.0", "", {}, "sha512-whrdktVs1h6gtR+09+QsNk2+FO+49j6ga1c55YZudfEG+oKJVvJLQi3zkOm5JjiUXAagWK2tI2kTGKJ2Ys7MGA=="],
|
||||
|
||||
"@octokit/graphql/@octokit/types/@octokit/openapi-types": ["@octokit/openapi-types@27.0.0", "", {}, "sha512-whrdktVs1h6gtR+09+QsNk2+FO+49j6ga1c55YZudfEG+oKJVvJLQi3zkOm5JjiUXAagWK2tI2kTGKJ2Ys7MGA=="],
|
||||
|
||||
"@octokit/plugin-retry/@octokit/types/@octokit/openapi-types": ["@octokit/openapi-types@27.0.0", "", {}, "sha512-whrdktVs1h6gtR+09+QsNk2+FO+49j6ga1c55YZudfEG+oKJVvJLQi3zkOm5JjiUXAagWK2tI2kTGKJ2Ys7MGA=="],
|
||||
|
||||
"@octokit/request-error/@octokit/types/@octokit/openapi-types": ["@octokit/openapi-types@27.0.0", "", {}, "sha512-whrdktVs1h6gtR+09+QsNk2+FO+49j6ga1c55YZudfEG+oKJVvJLQi3zkOm5JjiUXAagWK2tI2kTGKJ2Ys7MGA=="],
|
||||
|
||||
"@octokit/request/@octokit/types/@octokit/openapi-types": ["@octokit/openapi-types@27.0.0", "", {}, "sha512-whrdktVs1h6gtR+09+QsNk2+FO+49j6ga1c55YZudfEG+oKJVvJLQi3zkOm5JjiUXAagWK2tI2kTGKJ2Ys7MGA=="],
|
||||
}
|
||||
}
|
||||
+65
@@ -0,0 +1,65 @@
|
||||
import type { AggregatedReview, Finding, ReviewSeverity } from "./types.ts";
|
||||
|
||||
const SEVERITY_ORDER: ReviewSeverity[] = ["error", "warning", "nit"];
|
||||
|
||||
const SEVERITY_LABEL: Record<ReviewSeverity, string> = {
|
||||
error: "Errors",
|
||||
warning: "Warnings",
|
||||
nit: "Nits",
|
||||
};
|
||||
|
||||
const SEVERITY_EMOJI: Record<ReviewSeverity, string> = {
|
||||
error: "🔴",
|
||||
warning: "🟡",
|
||||
nit: "🔵",
|
||||
};
|
||||
|
||||
function renderGroup(severity: ReviewSeverity, findings: Finding[]): string {
|
||||
const items = findings.map(
|
||||
(f) => `**\`${f.filename}\`** line ${f.line}\n> ${f.comment}`,
|
||||
);
|
||||
return `### ${SEVERITY_EMOJI[severity]} ${SEVERITY_LABEL[severity]} (${findings.length})\n\n${items.join("\n\n")}`;
|
||||
}
|
||||
|
||||
export function formatReview(
|
||||
review: AggregatedReview,
|
||||
files: string[],
|
||||
model: string,
|
||||
): string {
|
||||
console.log(
|
||||
`Formatting review for ${files.length} file${files.length === 1 ? "" : "s"} with model ${model}...`,
|
||||
);
|
||||
const parts: string[] = [];
|
||||
|
||||
parts.push(
|
||||
`## 🤖 shockbot review\n\n> Reviewed ${files.length} file${files.length === 1 ? "" : "s"} using \`${model}\``,
|
||||
);
|
||||
|
||||
// Summary section — join per-chunk summaries
|
||||
const summaries = review.summaries.filter(Boolean);
|
||||
if (summaries.length > 0) {
|
||||
parts.push(`**Summary**\n\n${summaries.join("\n\n")}`);
|
||||
}
|
||||
|
||||
// Group findings by severity
|
||||
const grouped = new Map<ReviewSeverity, Finding[]>();
|
||||
for (const severity of SEVERITY_ORDER) grouped.set(severity, []);
|
||||
for (const finding of review.findings) {
|
||||
grouped.get(finding.severity)?.push(finding);
|
||||
}
|
||||
|
||||
const hasFindings = review.findings.length > 0;
|
||||
if (hasFindings) {
|
||||
for (const severity of SEVERITY_ORDER) {
|
||||
const group = grouped.get(severity)!;
|
||||
if (group.length > 0) parts.push(renderGroup(severity, group));
|
||||
}
|
||||
} else {
|
||||
parts.push("No issues found. ✅");
|
||||
}
|
||||
|
||||
const timestamp = new Date().toISOString();
|
||||
parts.push(`---\n\n<sub>shockbot · \`${model}\` · ${timestamp}</sub>`);
|
||||
|
||||
return parts.join("\n\n---\n\n");
|
||||
}
|
||||
@@ -0,0 +1,184 @@
|
||||
import type { DiffChunk } from "./types.ts";
|
||||
|
||||
// ~4 chars per token is a reasonable rough estimate for code
|
||||
function estimateTokens(text: string): number {
|
||||
return Math.ceil(text.length / 4);
|
||||
}
|
||||
|
||||
const SKIP_EXTENSIONS = new Set([
|
||||
"lock",
|
||||
"sum",
|
||||
"map",
|
||||
"png",
|
||||
"jpg",
|
||||
"jpeg",
|
||||
"gif",
|
||||
"svg",
|
||||
"ico",
|
||||
"webp",
|
||||
"woff",
|
||||
"woff2",
|
||||
"ttf",
|
||||
"eot",
|
||||
"pdf",
|
||||
"zip",
|
||||
"tar",
|
||||
"gz",
|
||||
"bin",
|
||||
"exe",
|
||||
"dll",
|
||||
"so",
|
||||
"dylib",
|
||||
]);
|
||||
|
||||
const SKIP_PATTERNS = [
|
||||
/^node_modules\//,
|
||||
/^\.git\//,
|
||||
/\/generated\//,
|
||||
/\.pb\.\w+$/,
|
||||
/\.min\.[jt]s$/,
|
||||
];
|
||||
|
||||
function shouldSkip(filename: string): boolean {
|
||||
if (!filename.includes(".")) return true; // no extension = likely binary
|
||||
const ext = filename.split(".").pop()!.toLowerCase();
|
||||
return (
|
||||
SKIP_EXTENSIONS.has(ext) || SKIP_PATTERNS.some((p) => p.test(filename))
|
||||
);
|
||||
}
|
||||
|
||||
const EXT_TO_LANG: Record<string, string> = {
|
||||
ts: "TypeScript",
|
||||
tsx: "TypeScript",
|
||||
js: "JavaScript",
|
||||
jsx: "JavaScript",
|
||||
mjs: "JavaScript",
|
||||
cjs: "JavaScript",
|
||||
py: "Python",
|
||||
go: "Go",
|
||||
rs: "Rust",
|
||||
rb: "Ruby",
|
||||
java: "Java",
|
||||
kt: "Kotlin",
|
||||
kts: "Kotlin",
|
||||
swift: "Swift",
|
||||
cs: "C#",
|
||||
cpp: "C++",
|
||||
cc: "C++",
|
||||
cxx: "C++",
|
||||
hpp: "C++",
|
||||
c: "C",
|
||||
php: "PHP",
|
||||
sh: "Shell",
|
||||
bash: "Shell",
|
||||
zsh: "Shell",
|
||||
yaml: "YAML",
|
||||
yml: "YAML",
|
||||
json: "JSON",
|
||||
md: "Markdown",
|
||||
sql: "SQL",
|
||||
html: "HTML",
|
||||
htm: "HTML",
|
||||
css: "CSS",
|
||||
scss: "CSS",
|
||||
sass: "CSS",
|
||||
less: "CSS",
|
||||
vue: "Vue",
|
||||
svelte: "Svelte",
|
||||
toml: "TOML",
|
||||
xml: "XML",
|
||||
tf: "Terraform",
|
||||
hcl: "HCL",
|
||||
};
|
||||
|
||||
function detectLanguage(filename: string): string {
|
||||
const ext = filename.split(".").pop()?.toLowerCase() ?? "";
|
||||
return EXT_TO_LANG[ext] ?? "plain";
|
||||
}
|
||||
|
||||
export interface DiffFile {
|
||||
filename: string;
|
||||
language: string;
|
||||
hunks: Array<{ header: string; body: string }>;
|
||||
}
|
||||
|
||||
export function parseDiff(rawDiff: string): DiffFile[] {
|
||||
console.log("Parsing diff...");
|
||||
const files: DiffFile[] = [];
|
||||
|
||||
const sections = rawDiff.split(/^diff --git /m).filter((s) => s.trim());
|
||||
|
||||
for (const section of sections) {
|
||||
const lines = section.split("\n");
|
||||
|
||||
// Use +++ / --- lines — more reliable than first line for renames and paths with spaces
|
||||
const plusLine = lines.find((l) => l.startsWith("+++ "));
|
||||
const minusLine = lines.find((l) => l.startsWith("--- "));
|
||||
|
||||
let filename: string;
|
||||
if (plusLine?.startsWith("+++ b/")) {
|
||||
filename = plusLine.slice(6);
|
||||
} else if (
|
||||
plusLine === "+++ /dev/null" &&
|
||||
minusLine?.startsWith("--- a/")
|
||||
) {
|
||||
filename = minusLine.slice(6); // deleted file — use old path
|
||||
} else {
|
||||
continue;
|
||||
}
|
||||
|
||||
if (shouldSkip(filename)) continue;
|
||||
|
||||
const language = detectLanguage(filename);
|
||||
const hunks: DiffFile["hunks"] = [];
|
||||
let header = "";
|
||||
let bodyLines: string[] = [];
|
||||
|
||||
for (const line of lines) {
|
||||
if (line.startsWith("@@")) {
|
||||
if (header) hunks.push({ header, body: bodyLines.join("\n") });
|
||||
header = line;
|
||||
bodyLines = [];
|
||||
} else if (header) {
|
||||
bodyLines.push(line);
|
||||
}
|
||||
}
|
||||
if (header) hunks.push({ header, body: bodyLines.join("\n") });
|
||||
|
||||
if (hunks.length > 0) files.push({ filename, language, hunks });
|
||||
}
|
||||
|
||||
return files;
|
||||
}
|
||||
|
||||
export function chunkFile(file: DiffFile, maxTokens: number): DiffChunk[] {
|
||||
console.log(
|
||||
`Chunking file ${file.filename} with ${file.hunks.length} hunks...`,
|
||||
);
|
||||
return file.hunks.map(({ header, body }) => {
|
||||
let hunk = body;
|
||||
|
||||
if (estimateTokens(body) > maxTokens) {
|
||||
// Truncate from bottom — top of the hunk has the most useful context
|
||||
const lines = body.split("\n");
|
||||
const charBudget = maxTokens * 4;
|
||||
let chars = 0;
|
||||
let i = 0;
|
||||
while (
|
||||
i < lines.length &&
|
||||
chars + (lines[i]?.length ?? 0) + 1 <= charBudget
|
||||
) {
|
||||
chars += (lines[i]?.length ?? 0) + 1;
|
||||
i++;
|
||||
}
|
||||
hunk = lines.slice(0, i).join("\n");
|
||||
}
|
||||
|
||||
return {
|
||||
filename: file.filename,
|
||||
language: file.language,
|
||||
hunk,
|
||||
context: header,
|
||||
};
|
||||
});
|
||||
}
|
||||
@@ -1,37 +1,122 @@
|
||||
#!/usr/bin/env node
|
||||
import { readFileSync } from "node:fs";
|
||||
import type { ActionConfig, PRContext, ChunkReview } from "./types.ts";
|
||||
import { getPR, getPRDiff, addReaction, removeReaction, postReviewComment } from "./gitea.ts";
|
||||
import { parseDiff, chunkFile } from "./diff.ts";
|
||||
import { createMcpServer } from "./mcp.ts";
|
||||
import { reviewChunk, aggregateFindings } from "./review.ts";
|
||||
import { formatReview } from "./comment.ts";
|
||||
|
||||
/**
|
||||
* 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()}`);
|
||||
}
|
||||
function readConfig(): ActionConfig {
|
||||
return {
|
||||
prompt: process.env.INPUT_PROMPT ?? "Review this pull request",
|
||||
model: process.env.INPUT_MODEL ?? "qwen3.6:35b",
|
||||
contextWindow: parseInt(process.env.INPUT_CONTEXT_WINDOW ?? "4096", 10),
|
||||
maxToolCalls: parseInt(process.env.INPUT_MAX_TOOL_CALLS ?? "10", 10),
|
||||
};
|
||||
}
|
||||
|
||||
function readEvent(): Record<string, unknown> {
|
||||
const path = process.env.GITHUB_EVENT_PATH;
|
||||
if (!path) return {};
|
||||
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}`);
|
||||
return JSON.parse(readFileSync(path, "utf8")) as Record<string, unknown>;
|
||||
} catch {
|
||||
return {};
|
||||
}
|
||||
}
|
||||
|
||||
await run();
|
||||
async function main(): Promise<void> {
|
||||
const config = readConfig();
|
||||
const eventName = process.env.GITHUB_EVENT_NAME ?? "";
|
||||
const event = readEvent();
|
||||
|
||||
const repository = process.env.GITHUB_REPOSITORY ?? "";
|
||||
const slashIdx = repository.indexOf("/");
|
||||
if (slashIdx === -1) throw new Error(`Invalid GITHUB_REPOSITORY: ${repository}`);
|
||||
const owner = repository.slice(0, slashIdx);
|
||||
const repo = repository.slice(slashIdx + 1);
|
||||
|
||||
let prNumber: number;
|
||||
let prompt: string;
|
||||
let triggerCommentId: number | null = null;
|
||||
|
||||
if (eventName === "pull_request") {
|
||||
prNumber = event.number as number;
|
||||
prompt = config.prompt;
|
||||
} else if (eventName === "issue_comment") {
|
||||
const issue = event.issue as Record<string, unknown>;
|
||||
if (!issue?.pull_request) {
|
||||
console.log("issue_comment on non-PR issue, skipping");
|
||||
return;
|
||||
}
|
||||
const comment = event.comment as Record<string, unknown>;
|
||||
const commentBody = String(comment?.body ?? "");
|
||||
if (!commentBody.toLowerCase().includes("@shockbot")) {
|
||||
console.log("comment does not mention @shockbot, skipping");
|
||||
return;
|
||||
}
|
||||
prNumber = issue.number as number;
|
||||
triggerCommentId = comment.id as number;
|
||||
prompt = commentBody.replace(/@shockbot\s*/gi, "").trim() || config.prompt;
|
||||
} else {
|
||||
console.log(`Unsupported event: ${eventName}, skipping`);
|
||||
return;
|
||||
}
|
||||
|
||||
const prData = await getPR(owner, repo, prNumber);
|
||||
const headSha = prData.head?.sha;
|
||||
if (!headSha) throw new Error(`Could not get head SHA for PR #${prNumber}`);
|
||||
|
||||
const pr: PRContext = { owner, repo, prNumber, headSha };
|
||||
|
||||
if (triggerCommentId !== null) {
|
||||
await addReaction(pr, triggerCommentId, "eyes").catch(() => {});
|
||||
}
|
||||
|
||||
try {
|
||||
const rawDiff = await getPRDiff(pr);
|
||||
if (!rawDiff.trim()) {
|
||||
await postReviewComment(pr, "shockbot: no diff found for this PR.");
|
||||
return;
|
||||
}
|
||||
|
||||
const files = parseDiff(rawDiff);
|
||||
if (files.length === 0) {
|
||||
await postReviewComment(pr, "shockbot: no reviewable files in this PR (all files skipped).");
|
||||
return;
|
||||
}
|
||||
|
||||
const chunks = files.flatMap((f) => chunkFile(f, config.contextWindow));
|
||||
const mcpServer = createMcpServer(pr, config.maxToolCalls);
|
||||
|
||||
const results: ChunkReview[] = [];
|
||||
for (const chunk of chunks) {
|
||||
const result = await reviewChunk(
|
||||
chunk,
|
||||
prompt,
|
||||
config.model,
|
||||
config.contextWindow,
|
||||
mcpServer,
|
||||
);
|
||||
results.push(result);
|
||||
}
|
||||
|
||||
const aggregated = aggregateFindings(results);
|
||||
const body = formatReview(aggregated, files.map((f) => f.filename), config.model);
|
||||
await postReviewComment(pr, body);
|
||||
|
||||
if (triggerCommentId !== null) {
|
||||
await removeReaction(pr, triggerCommentId, "eyes").catch(() => {});
|
||||
await addReaction(pr, triggerCommentId, "+1").catch(() => {});
|
||||
}
|
||||
} catch (err) {
|
||||
console.error("Review failed:", err);
|
||||
if (triggerCommentId !== null) {
|
||||
await removeReaction(pr, triggerCommentId, "eyes").catch(() => {});
|
||||
await addReaction(pr, triggerCommentId, "-1").catch(() => {});
|
||||
}
|
||||
process.exit(1);
|
||||
}
|
||||
}
|
||||
|
||||
main();
|
||||
|
||||
@@ -1,43 +0,0 @@
|
||||
import { build } from "esbuild";
|
||||
|
||||
const sharedConfig = {
|
||||
bundle: true,
|
||||
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",
|
||||
});
|
||||
|
||||
// Build the MCP server bundle
|
||||
await build({
|
||||
...sharedConfig,
|
||||
entryPoints: ["./mcp/server.ts"],
|
||||
outfile: "./mcp-server.js",
|
||||
});
|
||||
|
||||
console.log("✅ Build completed successfully!");
|
||||
|
||||
@@ -1,9 +0,0 @@
|
||||
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 +0,0 @@
|
||||
ribbit like a froggy
|
||||
@@ -0,0 +1,163 @@
|
||||
import { Gitea } from "@go-gitea/sdk.js";
|
||||
import type {
|
||||
Comment,
|
||||
ChangedFile,
|
||||
PullReview,
|
||||
Reaction,
|
||||
} from "@go-gitea/sdk.js";
|
||||
import type { PRContext } from "./types.ts";
|
||||
|
||||
const client = new Gitea({
|
||||
baseUrl: process.env.GITEA_URL,
|
||||
auth: process.env.BOT_TOKEN,
|
||||
});
|
||||
|
||||
export async function getPRDiff(pr: PRContext): Promise<string> {
|
||||
console.log(`Fetching PR #${pr.prNumber} diff for ${pr.owner}/${pr.repo}`);
|
||||
const res = await client.rest.repository.repoDownloadPullDiffOrPatch({
|
||||
owner: pr.owner,
|
||||
repo: pr.repo,
|
||||
index: pr.prNumber,
|
||||
diffType: "diff",
|
||||
});
|
||||
return res.data;
|
||||
}
|
||||
|
||||
export async function getPRFiles(pr: PRContext): Promise<ChangedFile[]> {
|
||||
console.log(
|
||||
`Fetching PR #${pr.prNumber} changed files for ${pr.owner}/${pr.repo}`,
|
||||
);
|
||||
const res = await client.rest.repository.repoGetPullRequestFiles({
|
||||
owner: pr.owner,
|
||||
repo: pr.repo,
|
||||
index: pr.prNumber,
|
||||
});
|
||||
return res.data;
|
||||
}
|
||||
|
||||
export async function postReviewComment(
|
||||
pr: PRContext,
|
||||
body: string,
|
||||
): Promise<Comment> {
|
||||
console.log(
|
||||
`Posting review comment on PR #${pr.prNumber} for ${pr.owner}/${pr.repo}`,
|
||||
);
|
||||
const res = await client.rest.issue.issueCreateComment({
|
||||
owner: pr.owner,
|
||||
repo: pr.repo,
|
||||
index: pr.prNumber,
|
||||
body: { body },
|
||||
});
|
||||
return res.data;
|
||||
}
|
||||
|
||||
export async function postInlineComment(
|
||||
pr: PRContext,
|
||||
file: string,
|
||||
line: number,
|
||||
body: string,
|
||||
): Promise<PullReview> {
|
||||
console.log(
|
||||
`Posting inline review comment on PR #${pr.prNumber} for ${pr.owner}/${pr.repo} at ${file}:${line}`,
|
||||
);
|
||||
const res = await client.rest.repository.repoCreatePullReview({
|
||||
owner: pr.owner,
|
||||
repo: pr.repo,
|
||||
index: pr.prNumber,
|
||||
body: {
|
||||
event: "COMMENT",
|
||||
commit_id: pr.headSha,
|
||||
comments: [{ path: file, new_position: line, body }],
|
||||
},
|
||||
});
|
||||
return res.data;
|
||||
}
|
||||
|
||||
export async function addReaction(
|
||||
pr: PRContext,
|
||||
commentId: number,
|
||||
reaction: string,
|
||||
): Promise<Reaction> {
|
||||
const res = await client.rest.issue.issuePostCommentReaction({
|
||||
owner: pr.owner,
|
||||
repo: pr.repo,
|
||||
id: commentId,
|
||||
content: { content: reaction },
|
||||
});
|
||||
return res.data;
|
||||
}
|
||||
|
||||
export async function removeReaction(
|
||||
pr: PRContext,
|
||||
commentId: number,
|
||||
reaction: string,
|
||||
): Promise<void> {
|
||||
await client.rest.issue.issueDeleteCommentReaction({
|
||||
owner: pr.owner,
|
||||
repo: pr.repo,
|
||||
id: commentId,
|
||||
content: { content: reaction },
|
||||
});
|
||||
}
|
||||
|
||||
export async function getPR(owner: string, repo: string, prNumber: number) {
|
||||
console.log(`Fetching PR #${prNumber} data for ${owner}/${repo}`);
|
||||
const res = await client.rest.repository.repoGetPullRequest({
|
||||
owner,
|
||||
repo,
|
||||
index: prNumber,
|
||||
});
|
||||
return res.data;
|
||||
}
|
||||
|
||||
export async function getFileContents(
|
||||
pr: PRContext,
|
||||
filePath: string,
|
||||
): Promise<string> {
|
||||
console.log(
|
||||
`Fetching contents of ${filePath} at PR #${pr.prNumber} head for ${pr.owner}/${pr.repo}`,
|
||||
);
|
||||
const res = await client.rest.repository.repoGetContents({
|
||||
owner: pr.owner,
|
||||
repo: pr.repo,
|
||||
filepath: filePath,
|
||||
ref: pr.headSha,
|
||||
});
|
||||
const entry = Array.isArray(res.data) ? res.data[0] : res.data;
|
||||
if (!entry?.content || entry.type !== "file") return "";
|
||||
// content is base64-encoded; Buffer handles embedded newlines
|
||||
return Buffer.from(entry.content, "base64").toString("utf8");
|
||||
}
|
||||
|
||||
export async function listDir(
|
||||
pr: PRContext,
|
||||
dirPath: string,
|
||||
): Promise<string[]> {
|
||||
console.log(
|
||||
`Listing directory ${dirPath} at PR #${pr.prNumber} head for ${pr.owner}/${pr.repo}`,
|
||||
);
|
||||
const res = await client.rest.repository.repoGetContents({
|
||||
owner: pr.owner,
|
||||
repo: pr.repo,
|
||||
filepath: dirPath,
|
||||
ref: pr.headSha,
|
||||
});
|
||||
const entries = Array.isArray(res.data) ? res.data : [res.data];
|
||||
return entries.map((e) => e.name ?? "").filter(Boolean);
|
||||
}
|
||||
|
||||
// Gitea REST API v1 has no code search endpoint — returns repo names matching the query.
|
||||
// mcp.ts should strongly prefer disk grep (find_symbol) when workspace is available.
|
||||
export async function searchCode(
|
||||
pr: PRContext,
|
||||
query: string,
|
||||
): Promise<string[]> {
|
||||
console.log(
|
||||
`Searching code for "${query}" at PR #${pr.prNumber} head for ${pr.owner}/${pr.repo}`,
|
||||
);
|
||||
const res = await client.rest.repository.repoSearch({
|
||||
q: query,
|
||||
limit: 20,
|
||||
});
|
||||
return (res.data.data ?? []).map((r) => r.full_name ?? "").filter(Boolean);
|
||||
}
|
||||
@@ -1,11 +1 @@
|
||||
/**
|
||||
* Library entry point for npm package
|
||||
* This exports the main function for programmatic usage
|
||||
*/
|
||||
|
||||
export type { Agent, AgentConfig, AgentResult } from "./agents/shared.ts";
|
||||
export {
|
||||
type Inputs as ExecutionInputs,
|
||||
type MainResult,
|
||||
main,
|
||||
} from "./main.ts";
|
||||
console.log("Hello via Bun!");
|
||||
@@ -1,99 +0,0 @@
|
||||
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 { getRepoSettings } from "./utils/api.ts";
|
||||
import { log } from "./utils/cli.ts";
|
||||
import {
|
||||
parseRepoContext,
|
||||
revokeInstallationToken,
|
||||
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> {
|
||||
let tokenToRevoke: string | null = null;
|
||||
|
||||
try {
|
||||
log.info(`🐸 Running pullfrog/action@${packageJson.version}...`);
|
||||
|
||||
setupGitConfig();
|
||||
|
||||
const { githubInstallationToken, wasAcquired } = await setupGitHubInstallationToken();
|
||||
if (wasAcquired) {
|
||||
tokenToRevoke = githubInstallationToken;
|
||||
}
|
||||
const repoContext = parseRepoContext();
|
||||
|
||||
// Fetch repo settings (agent, permissions, workflows) from API
|
||||
const repoSettings = await getRepoSettings(githubInstallationToken, repoContext);
|
||||
const agent = repoSettings.defaultAgent || "claude";
|
||||
if (agent !== "claude") throw new Error(`Unsupported agent: ${agent}`);
|
||||
|
||||
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,
|
||||
};
|
||||
} finally {
|
||||
if (tokenToRevoke) {
|
||||
await revokeInstallationToken(tokenToRevoke);
|
||||
}
|
||||
}
|
||||
}
|
||||
-101714
File diff suppressed because one or more lines are too long
@@ -0,0 +1,218 @@
|
||||
import { existsSync, readFileSync, readdirSync } from "node:fs";
|
||||
import { execFileSync } from "node:child_process";
|
||||
import { join, resolve, relative } from "node:path";
|
||||
import type { Tool } from "ollama";
|
||||
import {
|
||||
getFileContents,
|
||||
listDir as giteaListDir,
|
||||
searchCode,
|
||||
} from "./gitea.ts";
|
||||
import type { PRContext } from "./types.ts";
|
||||
|
||||
const MAX_FILE_LINES = 200;
|
||||
const MAX_GREP_RESULTS = 50;
|
||||
|
||||
export const TOOLS: Tool[] = [
|
||||
{
|
||||
type: "function",
|
||||
function: {
|
||||
name: "read_file",
|
||||
description: "Read the contents of a file in the repository.",
|
||||
parameters: {
|
||||
type: "object",
|
||||
required: ["path"],
|
||||
properties: {
|
||||
path: {
|
||||
type: "string",
|
||||
description: "Repo-relative path to the file",
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
{
|
||||
type: "function",
|
||||
function: {
|
||||
name: "list_dir",
|
||||
description: "List files and directories at a path in the repository.",
|
||||
parameters: {
|
||||
type: "object",
|
||||
required: ["path"],
|
||||
properties: {
|
||||
path: { type: "string", description: "Repo-relative directory path" },
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
{
|
||||
type: "function",
|
||||
function: {
|
||||
name: "find_symbol",
|
||||
description:
|
||||
"Search for a symbol, function, or pattern across the repository.",
|
||||
parameters: {
|
||||
type: "object",
|
||||
required: ["symbol"],
|
||||
properties: {
|
||||
symbol: {
|
||||
type: "string",
|
||||
description: "Symbol name or regex pattern to search for",
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
];
|
||||
|
||||
export class ToolServer {
|
||||
private _callCount = 0;
|
||||
private readonly pr: PRContext;
|
||||
private readonly workspace: string | null;
|
||||
private readonly maxToolCalls: number;
|
||||
|
||||
constructor(pr: PRContext, workspace: string | null, maxToolCalls: number) {
|
||||
this.pr = pr;
|
||||
this.workspace = workspace;
|
||||
this.maxToolCalls = maxToolCalls;
|
||||
}
|
||||
|
||||
get tools(): Tool[] {
|
||||
return TOOLS;
|
||||
}
|
||||
|
||||
get callCount(): number {
|
||||
return this._callCount;
|
||||
}
|
||||
|
||||
get atLimit(): boolean {
|
||||
return this._callCount >= this.maxToolCalls;
|
||||
}
|
||||
|
||||
resetCallCount(): void {
|
||||
this._callCount = 0;
|
||||
}
|
||||
|
||||
async execute(name: string, args: Record<string, unknown>): Promise<string> {
|
||||
if (this._callCount >= this.maxToolCalls) {
|
||||
return "[tool call limit reached — conclude your review now]";
|
||||
}
|
||||
this._callCount++;
|
||||
|
||||
try {
|
||||
switch (name) {
|
||||
case "read_file":
|
||||
return await this.readFile(String(args["path"] ?? ""));
|
||||
case "list_dir":
|
||||
return await this.listDir(String(args["path"] ?? ""));
|
||||
case "find_symbol":
|
||||
return await this.findSymbol(String(args["symbol"] ?? ""));
|
||||
default:
|
||||
return `[unknown tool: ${name}]`;
|
||||
}
|
||||
} catch (err) {
|
||||
return `[error: ${err instanceof Error ? err.message : String(err)}]`;
|
||||
}
|
||||
}
|
||||
|
||||
// Validates path is repo-relative with no traversal or absolute prefix.
|
||||
private safePath(path: string): string | null {
|
||||
if (!path) return null;
|
||||
if (path.startsWith("/") || path.includes("..")) return null;
|
||||
return path.replace(/\/+/g, "/").replace(/\/$/, "");
|
||||
}
|
||||
|
||||
private async readFile(path: string): Promise<string> {
|
||||
const safe = this.safePath(path);
|
||||
if (!safe) return "[invalid path]";
|
||||
|
||||
if (this.workspace) {
|
||||
const full = join(this.workspace, safe);
|
||||
// Verify resolved path stays inside workspace
|
||||
if (!resolve(full).startsWith(resolve(this.workspace)))
|
||||
return "[invalid path]";
|
||||
if (!existsSync(full)) return "[file not found]";
|
||||
const content = readFileSync(full, "utf8");
|
||||
const lines = content.split("\n");
|
||||
const truncated = lines.slice(0, MAX_FILE_LINES).join("\n");
|
||||
return lines.length > MAX_FILE_LINES
|
||||
? `${truncated}\n... (truncated, ${lines.length - MAX_FILE_LINES} lines omitted)`
|
||||
: truncated;
|
||||
}
|
||||
|
||||
return await getFileContents(this.pr, safe);
|
||||
}
|
||||
|
||||
private async listDir(path: string): Promise<string> {
|
||||
const safe = this.safePath(path || ".");
|
||||
if (!safe) return "[invalid path]";
|
||||
|
||||
if (this.workspace) {
|
||||
const full = join(this.workspace, safe);
|
||||
if (!resolve(full).startsWith(resolve(this.workspace)))
|
||||
return "[invalid path]";
|
||||
if (!existsSync(full)) return "[directory not found]";
|
||||
const entries = readdirSync(full, { withFileTypes: true });
|
||||
return entries
|
||||
.map((e) => `${e.isDirectory() ? "d" : "f"} ${e.name}`)
|
||||
.join("\n");
|
||||
}
|
||||
|
||||
const entries = await giteaListDir(this.pr, safe);
|
||||
return entries.join("\n");
|
||||
}
|
||||
|
||||
private async findSymbol(symbol: string): Promise<string> {
|
||||
if (!symbol) return "[no symbol provided]";
|
||||
|
||||
if (this.workspace) {
|
||||
try {
|
||||
// execFileSync avoids shell injection — symbol is passed as an argument, not interpolated
|
||||
const out = execFileSync(
|
||||
"grep",
|
||||
[
|
||||
"-r",
|
||||
"-n",
|
||||
"--include=*.ts",
|
||||
"--include=*.tsx",
|
||||
"--include=*.js",
|
||||
"--include=*.jsx",
|
||||
"--include=*.py",
|
||||
"--include=*.go",
|
||||
"--include=*.rs",
|
||||
"--include=*.rb",
|
||||
"--include=*.java",
|
||||
"--include=*.kt",
|
||||
symbol,
|
||||
this.workspace,
|
||||
],
|
||||
{ encoding: "utf8", timeout: 5000, maxBuffer: 256 * 1024 },
|
||||
);
|
||||
const lines = out
|
||||
.split("\n")
|
||||
.filter(Boolean)
|
||||
.slice(0, MAX_GREP_RESULTS);
|
||||
// Strip workspace prefix so paths are repo-relative
|
||||
return (
|
||||
lines.map((l) => l.replace(this.workspace! + "/", "")).join("\n") ||
|
||||
"[not found]"
|
||||
);
|
||||
} catch {
|
||||
return "[not found]";
|
||||
}
|
||||
}
|
||||
|
||||
const results = await searchCode(this.pr, symbol);
|
||||
return results.join("\n") || "[not found]";
|
||||
}
|
||||
}
|
||||
|
||||
export function createMcpServer(
|
||||
pr: PRContext,
|
||||
maxToolCalls: number,
|
||||
): ToolServer {
|
||||
console.log("Initializing tool server...");
|
||||
const ws =
|
||||
process.env.GITEA_WORKSPACE ?? process.env.GITHUB_WORKSPACE ?? null;
|
||||
const workspace = ws && existsSync(ws) ? ws : null;
|
||||
return new ToolServer(pr, workspace, maxToolCalls);
|
||||
}
|
||||
@@ -1,55 +0,0 @@
|
||||
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,
|
||||
};
|
||||
}),
|
||||
});
|
||||
@@ -1,33 +0,0 @@
|
||||
/**
|
||||
* Simple MCP configuration helper for adding our minimal GitHub comment server
|
||||
*/
|
||||
|
||||
import type { McpServerConfig } from "@anthropic-ai/claude-agent-sdk";
|
||||
import { fromHere } from "@ark/fs";
|
||||
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}`;
|
||||
|
||||
// In production (GitHub Actions), mcp-server.js is in same directory as entry.js (where this is bundled)
|
||||
// In development, server.ts is in the same directory as this file (config.ts)
|
||||
const serverPath = process.env.GITHUB_ACTIONS ? fromHere("mcp-server.js") : fromHere("server.ts");
|
||||
|
||||
return {
|
||||
[ghPullfrogMcpName]: {
|
||||
command: "node",
|
||||
args: [serverPath],
|
||||
env: {
|
||||
GITHUB_INSTALLATION_TOKEN: githubInstallationToken,
|
||||
GITHUB_REPOSITORY: githubRepository,
|
||||
},
|
||||
},
|
||||
};
|
||||
}
|
||||
@@ -1,42 +0,0 @@
|
||||
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),
|
||||
};
|
||||
}),
|
||||
});
|
||||
@@ -1,43 +0,0 @@
|
||||
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,
|
||||
};
|
||||
}),
|
||||
});
|
||||
@@ -1,52 +0,0 @@
|
||||
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,
|
||||
};
|
||||
}),
|
||||
});
|
||||
@@ -1,91 +0,0 @@
|
||||
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"),
|
||||
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, 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: "COMMENT",
|
||||
};
|
||||
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,
|
||||
};
|
||||
}),
|
||||
});
|
||||
@@ -1,25 +0,0 @@
|
||||
#!/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();
|
||||
@@ -1,76 +0,0 @@
|
||||
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,
|
||||
};
|
||||
};
|
||||
+9
-63
@@ -1,69 +1,15 @@
|
||||
{
|
||||
"name": "@pullfrog/action",
|
||||
"version": "0.0.92",
|
||||
"name": "shockbot",
|
||||
"module": "index.ts",
|
||||
"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",
|
||||
"play": "node play.ts",
|
||||
"upDeps": "pnpm up --latest",
|
||||
"lock": "pnpm --ignore-workspace install",
|
||||
"prepare": "husky"
|
||||
},
|
||||
"dependencies": {
|
||||
"@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"
|
||||
},
|
||||
"private": true,
|
||||
"devDependencies": {
|
||||
"@types/node": "^24.7.2",
|
||||
"arg": "^5.0.2",
|
||||
"esbuild": "^0.25.9",
|
||||
"husky": "^9.0.0",
|
||||
"typescript": "^5.9.3"
|
||||
"@go-gitea/sdk.js": "^0.2.1",
|
||||
"@modelcontextprotocol/sdk": "^1.29.0",
|
||||
"@types/bun": "latest",
|
||||
"ollama": "^0.6.3"
|
||||
},
|
||||
"repository": {
|
||||
"type": "git",
|
||||
"url": "git+https://github.com/pullfrog/action.git"
|
||||
},
|
||||
"keywords": [],
|
||||
"author": "",
|
||||
"license": "MIT",
|
||||
"bugs": {
|
||||
"url": "https://github.com/pullfrog/action/issues"
|
||||
},
|
||||
"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"
|
||||
}
|
||||
"peerDependencies": {
|
||||
"typescript": "^5"
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,145 +0,0 @@
|
||||
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
-1929
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,167 @@
|
||||
import { Ollama } from "ollama";
|
||||
import type { Message } from "ollama";
|
||||
import type {
|
||||
DiffChunk,
|
||||
ChunkReview,
|
||||
ReviewResult,
|
||||
AggregatedReview,
|
||||
Finding,
|
||||
} from "./types.ts";
|
||||
import type { ToolServer } from "./mcp.ts";
|
||||
|
||||
const ollama = new Ollama({ host: process.env.OLLAMA_HOST });
|
||||
|
||||
const SYSTEM_PROMPT = `You are a code reviewer. Your job is to find real bugs, security issues, and significant problems in the changed lines below. Do not comment on style, formatting, or personal preference unless they cause functional problems.
|
||||
|
||||
Be concise. Each comment should say what the problem is and why it matters.
|
||||
|
||||
You have tools available to read files, list directories, and search for symbols in the repository. Use them when you need more context to understand the code being reviewed.
|
||||
|
||||
When you are done gathering context and ready to report your findings, you MUST respond with ONLY a valid JSON object in this exact shape:
|
||||
{
|
||||
"issues": [
|
||||
{ "line": <line number>, "severity": "<error|warning|nit>", "comment": "<your comment>" }
|
||||
],
|
||||
"summary": "<one paragraph overall summary>"
|
||||
}
|
||||
|
||||
Do not include any text outside the JSON object.`;
|
||||
|
||||
export function buildReviewPrompt(
|
||||
chunk: DiffChunk,
|
||||
userPrompt: string,
|
||||
): string {
|
||||
return `${userPrompt}
|
||||
|
||||
File: ${chunk.filename} (${chunk.language})
|
||||
Hunk: ${chunk.context}
|
||||
|
||||
${chunk.hunk}`;
|
||||
}
|
||||
|
||||
function parseReviewResult(content: string): ReviewResult | null {
|
||||
// Strip markdown code fences the model may have added despite instructions
|
||||
const stripped = content
|
||||
.replace(/^```(?:json)?\s*/m, "")
|
||||
.replace(/\s*```\s*$/m, "")
|
||||
.trim();
|
||||
try {
|
||||
const parsed = JSON.parse(stripped);
|
||||
if (!Array.isArray(parsed.issues) || typeof parsed.summary !== "string")
|
||||
return null;
|
||||
return parsed as ReviewResult;
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
export async function reviewChunk(
|
||||
chunk: DiffChunk,
|
||||
userPrompt: string,
|
||||
model: string,
|
||||
contextWindow: number,
|
||||
mcpServer: ToolServer,
|
||||
): Promise<ChunkReview> {
|
||||
console.log(`Reviewing chunk from ${chunk.filename} with model ${model}...`);
|
||||
mcpServer.resetCallCount();
|
||||
|
||||
const messages: Message[] = [
|
||||
{ role: "system", content: SYSTEM_PROMPT },
|
||||
{ role: "user", content: buildReviewPrompt(chunk, userPrompt) },
|
||||
];
|
||||
|
||||
let forceConclusion = false;
|
||||
let parseFailures = 0;
|
||||
|
||||
while (true) {
|
||||
const response = await ollama.chat({
|
||||
model,
|
||||
messages,
|
||||
tools: forceConclusion ? undefined : mcpServer.tools,
|
||||
format: "json",
|
||||
stream: false,
|
||||
keep_alive: -1,
|
||||
options: { num_ctx: contextWindow, temperature: 0.2 },
|
||||
});
|
||||
|
||||
const msg = response.message;
|
||||
|
||||
// Model called one or more tools
|
||||
if (msg.tool_calls && msg.tool_calls.length > 0) {
|
||||
messages.push({
|
||||
role: "assistant",
|
||||
content: msg.content ?? "",
|
||||
tool_calls: msg.tool_calls,
|
||||
});
|
||||
|
||||
for (const call of msg.tool_calls) {
|
||||
const result = await mcpServer.execute(
|
||||
call.function.name,
|
||||
call.function.arguments as Record<string, unknown>,
|
||||
);
|
||||
messages.push({
|
||||
role: "tool",
|
||||
content: result,
|
||||
tool_name: call.function.name,
|
||||
});
|
||||
}
|
||||
|
||||
if (mcpServer.atLimit) {
|
||||
forceConclusion = true;
|
||||
messages.push({
|
||||
role: "user",
|
||||
content:
|
||||
"You have reached the tool call limit. Respond now with your final JSON review.",
|
||||
});
|
||||
}
|
||||
continue;
|
||||
}
|
||||
|
||||
// Model produced content — try to parse as final JSON review
|
||||
const content = (msg.content ?? "").trim();
|
||||
|
||||
if (content) {
|
||||
const result = parseReviewResult(content);
|
||||
if (result) return { chunk, result };
|
||||
|
||||
// Parse failed — give the model one chance to fix it
|
||||
parseFailures++;
|
||||
if (parseFailures <= 1) {
|
||||
messages.push(
|
||||
{ role: "assistant", content },
|
||||
{
|
||||
role: "user",
|
||||
content:
|
||||
"Your response was not valid JSON. Respond with ONLY the JSON review object, no other text.",
|
||||
},
|
||||
);
|
||||
forceConclusion = true;
|
||||
continue;
|
||||
}
|
||||
|
||||
// Second failure — return raw content as summary rather than crash
|
||||
return {
|
||||
chunk,
|
||||
result: {
|
||||
issues: [],
|
||||
summary: `[parse error] ${content.slice(0, 500)}`,
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
// Empty response — shouldn't happen, but bail rather than loop forever
|
||||
return {
|
||||
chunk,
|
||||
result: { issues: [], summary: "[empty response from model]" },
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
export function aggregateFindings(results: ChunkReview[]): AggregatedReview {
|
||||
console.log(`Aggregating findings from ${results.length} reviewed chunks...`);
|
||||
const findings: Finding[] = results.flatMap(({ chunk, result }) =>
|
||||
result.issues.map((issue) => ({ ...issue, filename: chunk.filename })),
|
||||
);
|
||||
const summaries = results.map((r) => r.result.summary).filter(Boolean);
|
||||
return { findings, summaries };
|
||||
}
|
||||
@@ -0,0 +1,22 @@
|
||||
{
|
||||
"action": "opened",
|
||||
"number": 4,
|
||||
"pull_request": {
|
||||
"number": 4,
|
||||
"title": "Add devices section",
|
||||
"draft": false,
|
||||
"head": {
|
||||
"sha": "94f4a680369ab07b076ba5679f05cc157e8c9a9c",
|
||||
"ref": "AddDevicesSection"
|
||||
},
|
||||
"base": {
|
||||
"ref": "master"
|
||||
}
|
||||
},
|
||||
"repository": {
|
||||
"name": "test-repo",
|
||||
"owner": {
|
||||
"login": "ShockVPN"
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,16 @@
|
||||
{
|
||||
"action": "opened",
|
||||
"number": 4,
|
||||
"pull_request": {
|
||||
"number": 4,
|
||||
"head": {
|
||||
"sha": "94f4a680369ab07b076ba5679f05cc157e8c9a9c"
|
||||
}
|
||||
},
|
||||
"repository": {
|
||||
"name": "test-repo",
|
||||
"owner": {
|
||||
"login": "ShockVPN"
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -1,20 +0,0 @@
|
||||
## CURRENT
|
||||
|
||||
[] look into trigger.yml without installation
|
||||
[] try to find heavy claude code user
|
||||
[] investigate including terminal output from bash commands as collapsed groups
|
||||
[] test initialization trade offs for pullfrog.yml
|
||||
|
||||
## MAYBE
|
||||
|
||||
[] investigate repo config file
|
||||
|
||||
## DONE
|
||||
|
||||
[x] add modes to prompt
|
||||
[x] progressively update comment
|
||||
[x] don't allow rejecting prs
|
||||
[x] fix pnpm caching
|
||||
[x] fix prompt to avoid narration like "I just read all tools from MCP server"
|
||||
[x] avoid exposing env adding ## SECURITY prompt
|
||||
[x] cancel installation token at the end of github aciton
|
||||
+23
-17
@@ -1,23 +1,29 @@
|
||||
{
|
||||
"compilerOptions": {
|
||||
"outDir": "./dist",
|
||||
"module": "NodeNext",
|
||||
"target": "ESNext",
|
||||
"moduleResolution": "NodeNext",
|
||||
// Environment setup & latest features
|
||||
"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,
|
||||
"target": "ESNext",
|
||||
"module": "Preserve",
|
||||
"moduleDetection": "force",
|
||||
"useUnknownInCatchVariables": true
|
||||
"jsx": "react-jsx",
|
||||
"allowJs": true,
|
||||
|
||||
// Bundler mode
|
||||
"moduleResolution": "bundler",
|
||||
"allowImportingTsExtensions": true,
|
||||
"verbatimModuleSyntax": true,
|
||||
"noEmit": true,
|
||||
|
||||
// Best practices
|
||||
"strict": true,
|
||||
"skipLibCheck": true,
|
||||
"noFallthroughCasesInSwitch": true,
|
||||
"noUncheckedIndexedAccess": true,
|
||||
"noImplicitOverride": true,
|
||||
|
||||
// Some stricter flags (disabled by default)
|
||||
"noUnusedLocals": false,
|
||||
"noUnusedParameters": false,
|
||||
"noPropertyAccessFromIndexSignature": false
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,57 @@
|
||||
export interface ActionConfig {
|
||||
prompt: string;
|
||||
model: string;
|
||||
contextWindow: number;
|
||||
maxToolCalls: number;
|
||||
}
|
||||
|
||||
export interface EnvConfig {
|
||||
botToken: string;
|
||||
ollamaHost: string;
|
||||
giteaUrl: string;
|
||||
}
|
||||
|
||||
export interface PRContext {
|
||||
owner: string;
|
||||
repo: string;
|
||||
prNumber: number;
|
||||
headSha: string;
|
||||
}
|
||||
|
||||
export type TriggerType = "pr_open" | "issue_comment";
|
||||
|
||||
export interface DiffChunk {
|
||||
filename: string;
|
||||
language: string;
|
||||
hunk: string;
|
||||
context: string;
|
||||
}
|
||||
|
||||
export type MCPToolName = "read_file" | "list_dir" | "find_symbol";
|
||||
|
||||
export type ReviewSeverity = "error" | "warning" | "nit";
|
||||
|
||||
export interface ReviewIssue {
|
||||
line: number;
|
||||
severity: ReviewSeverity;
|
||||
comment: string;
|
||||
}
|
||||
|
||||
export interface ReviewResult {
|
||||
issues: ReviewIssue[];
|
||||
summary: string;
|
||||
}
|
||||
|
||||
export interface ChunkReview {
|
||||
chunk: DiffChunk;
|
||||
result: ReviewResult;
|
||||
}
|
||||
|
||||
export interface Finding extends ReviewIssue {
|
||||
filename: string;
|
||||
}
|
||||
|
||||
export interface AggregatedReview {
|
||||
findings: Finding[];
|
||||
summaries: string[];
|
||||
}
|
||||
@@ -1,64 +0,0 @@
|
||||
import type { RepoContext } from "./github.ts";
|
||||
|
||||
export interface RepoSettings {
|
||||
defaultAgent: string | null;
|
||||
webAccessLevel: "full_access" | "limited";
|
||||
webAccessAllowTrusted: boolean;
|
||||
webAccessDomains: string;
|
||||
workflows: Array<{
|
||||
id: string;
|
||||
name: string;
|
||||
description: string;
|
||||
prompt: string;
|
||||
}>;
|
||||
}
|
||||
|
||||
const DEFAULT_REPO_SETTINGS: RepoSettings = {
|
||||
defaultAgent: null,
|
||||
webAccessLevel: "full_access",
|
||||
webAccessAllowTrusted: false,
|
||||
webAccessDomains: "",
|
||||
workflows: [],
|
||||
};
|
||||
|
||||
/**
|
||||
* Fetch repository settings from the Pullfrog API with fallback to defaults
|
||||
* Returns agent, permissions, and workflows (excludes triggers)
|
||||
* Returns defaults if repo doesn't exist or fetch fails
|
||||
*/
|
||||
export async function getRepoSettings(
|
||||
token: string,
|
||||
repoContext: RepoContext
|
||||
): Promise<RepoSettings> {
|
||||
const apiUrl = process.env.API_URL || "https://pullfrog.ai";
|
||||
|
||||
try {
|
||||
const response = await fetch(
|
||||
`${apiUrl}/api/repo/${repoContext.owner}/${repoContext.name}/settings`,
|
||||
{
|
||||
method: "GET",
|
||||
headers: {
|
||||
Authorization: `Bearer ${token}`,
|
||||
"Content-Type": "application/json",
|
||||
},
|
||||
}
|
||||
);
|
||||
|
||||
if (!response.ok) {
|
||||
// If API returns 404 or other error, fall back to defaults
|
||||
return DEFAULT_REPO_SETTINGS;
|
||||
}
|
||||
|
||||
const settings = (await response.json()) as RepoSettings | null;
|
||||
|
||||
// If API returns null (repo doesn't exist), return defaults
|
||||
if (settings === null) {
|
||||
return DEFAULT_REPO_SETTINGS;
|
||||
}
|
||||
|
||||
return settings;
|
||||
} catch {
|
||||
// If fetch fails (network error, etc.), fall back to defaults
|
||||
return DEFAULT_REPO_SETTINGS;
|
||||
}
|
||||
}
|
||||
-264
@@ -1,264 +0,0 @@
|
||||
/**
|
||||
* 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,
|
||||
};
|
||||
-357
@@ -1,357 +0,0 @@
|
||||
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 | null> {
|
||||
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...");
|
||||
|
||||
// Add timeout to prevent long waits (5 seconds)
|
||||
const timeoutMs = 5000;
|
||||
const controller = new AbortController();
|
||||
const timeoutId = setTimeout(() => controller.abort(), timeoutMs);
|
||||
|
||||
try {
|
||||
const tokenResponse = await fetch(`${apiUrl}/api/github/installation-token`, {
|
||||
method: "POST",
|
||||
headers: {
|
||||
Authorization: `Bearer ${oidcToken}`,
|
||||
"Content-Type": "application/json",
|
||||
},
|
||||
signal: controller.signal,
|
||||
});
|
||||
|
||||
clearTimeout(timeoutId);
|
||||
|
||||
if (!tokenResponse.ok) {
|
||||
log.warning(
|
||||
`Token exchange failed: ${tokenResponse.status} ${tokenResponse.statusText}. Falling back to GITHUB_TOKEN.`
|
||||
);
|
||||
return null;
|
||||
}
|
||||
|
||||
const tokenData = (await tokenResponse.json()) as InstallationToken;
|
||||
log.info(`Installation token obtained for ${tokenData.repository || "all repositories"}`);
|
||||
|
||||
return tokenData.token;
|
||||
} catch (error) {
|
||||
clearTimeout(timeoutId);
|
||||
|
||||
if (error instanceof Error && error.name === "AbortError") {
|
||||
log.warning(`Token exchange timed out after ${timeoutMs}ms. Falling back to GITHUB_TOKEN.`);
|
||||
} else {
|
||||
log.warning(
|
||||
`Token exchange failed: ${error instanceof Error ? error.message : String(error)}. Falling back to GITHUB_TOKEN.`
|
||||
);
|
||||
}
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
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 | null> {
|
||||
if (isGitHubActionsEnvironment()) {
|
||||
return await acquireTokenViaOIDC();
|
||||
} else {
|
||||
return await acquireTokenViaGitHubApp();
|
||||
}
|
||||
}
|
||||
|
||||
function getDefaultGitHubToken(): string | null {
|
||||
// Debug: log all GITHUB_* env vars
|
||||
if (isGitHubActionsEnvironment()) {
|
||||
const githubEnvVars = Object.keys(process.env)
|
||||
.filter((key) => key.startsWith("GITHUB_"))
|
||||
.reduce(
|
||||
(acc, key) => {
|
||||
acc[key] = key === "GITHUB_TOKEN" ? "***" : process.env[key];
|
||||
return acc;
|
||||
},
|
||||
{} as Record<string, string | undefined>
|
||||
);
|
||||
log.debug(`GitHub env vars: ${JSON.stringify(githubEnvVars)}`);
|
||||
}
|
||||
|
||||
return process.env.GITHUB_TOKEN || null;
|
||||
}
|
||||
|
||||
/**
|
||||
* Setup GitHub installation token for the action
|
||||
* Returns the token and whether it was acquired (needs revocation)
|
||||
* Falls back to GITHUB_TOKEN if app is not installed
|
||||
*/
|
||||
export async function setupGitHubInstallationToken(): Promise<{
|
||||
githubInstallationToken: string;
|
||||
wasAcquired: boolean;
|
||||
}> {
|
||||
const existingToken = checkExistingToken();
|
||||
if (existingToken) {
|
||||
core.setSecret(existingToken);
|
||||
log.info("Using provided GitHub installation token");
|
||||
return { githubInstallationToken: existingToken, wasAcquired: false };
|
||||
}
|
||||
|
||||
const acquiredToken = await acquireNewToken();
|
||||
|
||||
// If token acquisition failed (e.g., app not installed), fall back to GITHUB_TOKEN
|
||||
if (!acquiredToken) {
|
||||
const defaultToken = getDefaultGitHubToken();
|
||||
if (!defaultToken) {
|
||||
throw new Error(
|
||||
"Failed to acquire installation token and GITHUB_TOKEN is not available. " +
|
||||
"Either install the Pullfrog GitHub App or ensure GITHUB_TOKEN is set."
|
||||
);
|
||||
}
|
||||
log.info("Using GITHUB_TOKEN (app not installed or token exchange failed)");
|
||||
core.setSecret(defaultToken);
|
||||
process.env.GITHUB_INSTALLATION_TOKEN = defaultToken;
|
||||
return { githubInstallationToken: defaultToken, wasAcquired: false };
|
||||
}
|
||||
|
||||
core.setSecret(acquiredToken);
|
||||
process.env.GITHUB_INSTALLATION_TOKEN = acquiredToken;
|
||||
|
||||
return { githubInstallationToken: acquiredToken, wasAcquired: true };
|
||||
}
|
||||
|
||||
/**
|
||||
* Revoke GitHub installation token
|
||||
*/
|
||||
export async function revokeInstallationToken(token: string): Promise<void> {
|
||||
const apiUrl = process.env.GITHUB_API_URL || "https://api.github.com";
|
||||
|
||||
try {
|
||||
await fetch(`${apiUrl}/installation/token`, {
|
||||
method: "DELETE",
|
||||
headers: {
|
||||
Accept: "application/vnd.github+json",
|
||||
Authorization: `Bearer ${token}`,
|
||||
"X-GitHub-Api-Version": "2022-11-28",
|
||||
},
|
||||
});
|
||||
log.info("Installation token revoked");
|
||||
} catch (error) {
|
||||
log.warning(
|
||||
`Failed to revoke installation token: ${error instanceof Error ? error.message : String(error)}`
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
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 };
|
||||
}
|
||||
@@ -1,92 +0,0 @@
|
||||
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");
|
||||
}
|
||||
@@ -1,110 +0,0 @@
|
||||
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();
|
||||
}
|
||||
});
|
||||
}
|
||||
@@ -1,53 +0,0 @@
|
||||
// MCP name constant - matches action/mcp/config.ts
|
||||
const ghPullfrogMcpName = "gh-pullfrog";
|
||||
|
||||
export const workflows = [
|
||||
{
|
||||
name: "Plan",
|
||||
description:
|
||||
"Create plans, break down tasks, outline steps, analyze requirements, understand scope of work, or provide task breakdowns",
|
||||
prompt: `Follow these steps:
|
||||
1. Create initial response comment using mcp__${ghPullfrogMcpName}__create_issue_comment saying "I'll {summary of request}" and save the commentId
|
||||
2. Analyze the request and break it down into clear, actionable tasks
|
||||
3. Consider dependencies, potential challenges, and implementation order
|
||||
4. Create a structured plan with clear milestones
|
||||
5. Update your comment using mcp__${ghPullfrogMcpName}__edit_issue_comment with the commentId to present the plan in a clear, organized format
|
||||
6. Continue updating the same comment as needed (never create additional comments - always use edit_issue_comment)`,
|
||||
},
|
||||
{
|
||||
name: "Implement",
|
||||
description:
|
||||
"Implement, build, create, or develop code changes; make specific changes to files or features; execute a plan; or handle tasks with specific implementation details",
|
||||
prompt: `Follow these steps:
|
||||
1. Create initial response comment using mcp__${ghPullfrogMcpName}__create_issue_comment saying "I'll {summary of request}" and save the commentId
|
||||
2. Understand the requirements and any existing plan
|
||||
3. Make the necessary code changes
|
||||
4. Test your changes to ensure they work correctly
|
||||
5. Update your comment using mcp__${ghPullfrogMcpName}__edit_issue_comment with the commentId to share progress and results
|
||||
6. Continue updating the same comment as you make progress (never create additional comments - always use edit_issue_comment)`,
|
||||
},
|
||||
{
|
||||
name: "Review",
|
||||
description:
|
||||
"Review code, PRs, or implementations; provide feedback or suggestions; identify issues; or check code quality, style, and correctness",
|
||||
prompt: `Follow these steps:
|
||||
1. Create initial response comment using mcp__${ghPullfrogMcpName}__create_issue_comment saying "I'll review this" and save the commentId
|
||||
2. Get PR info with mcp__${ghPullfrogMcpName}__get_pull_request (this automatically prepares the repository by fetching and checking out the PR branch)
|
||||
3. View diff: git diff origin/<base>...origin/<head> (use line numbers from this for inline comments, replace <base> and <head> with 'base' and 'head' from PR info)
|
||||
4. Read files from the checked-out PR branch to understand the implementation
|
||||
5. Update your comment using mcp__${ghPullfrogMcpName}__edit_issue_comment with findings as you review
|
||||
6. When submitting review: use the 'comments' array for ALL specific code issues - include the file path and line position from the diff
|
||||
7. Only use the 'body' field for a brief summary (1-2 sentences) or for feedback that doesn't apply to a specific code location
|
||||
8. Continue updating the same comment as needed (never create additional comments - always use edit_issue_comment)`,
|
||||
},
|
||||
{
|
||||
name: "Prompt",
|
||||
description:
|
||||
"Fallback for tasks that don't fit other workflows, direct prompts via comments, or requests requiring general assistance without a specific workflow pattern",
|
||||
prompt: `Follow these steps:
|
||||
1. Create an initial "Progress Comment" using mcp__${ghPullfrogMcpName}__create_issue_comment saying "I'll {summary of request}" and save the commentId
|
||||
2. Perform the requested task. Only take action if you have high confidence that you understand what is being asked. If you are not sure, ask for clarification. Take stock of the tools at your disposal.
|
||||
3. As your work progresses, update your Progress Comment to share progress and results. Using mcp__${ghPullfrogMcpName}__edit_issue_comment and the commentId you saved earlier. Do not create additional comments unless you are explicitly asked to do so.
|
||||
4. When you finish the task, update the Progress Comment a final time to confirm completion. If you created any issues, PRs, etc, include appropriate links to it here.`,
|
||||
},
|
||||
] as const;
|
||||
Reference in New Issue
Block a user