Compare commits

..

5 Commits

Author SHA1 Message Date
wolfy eb7de776e4 idk 2026-06-01 20:47:26 -05:00
wolfy 108b8243a9 idk 2026-06-01 20:43:19 -05:00
wolfy 59f1e72dec idk 2026-06-01 20:41:34 -05:00
wolfy 46d95853ae idk 2026-06-01 20:34:00 -05:00
Adam Wolf e6374a952c idk 2026-06-01 20:07:02 -05:00
40 changed files with 1385 additions and 3802 deletions
-2
View File
@@ -1,2 +0,0 @@
!examples
-124
View File
@@ -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
View File
@@ -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
-299
View File
@@ -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.
-24
View File
@@ -1,24 +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
# Test with default prompt
npm run play # Run locally on your machine
```
## Testing with play.ts
```bash
pnpm play # Uses fixtures/play.txt
```
- Clones the scratch repository to `.temp`
- Runs Claude Code directly on your machine
- Fast iteration for development
+21 -36
View File
@@ -1,43 +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: "composite"
steps:
- name: Checkout code
uses: actions/checkout@v4
with:
fetch-depth: 1
- name: Setup pnpm
uses: pnpm/action-setup@v4
with:
version: "*"
- name: Setup Node.js 24
uses: actions/setup-node@v4
with:
node-version: "24"
- name: Install dependencies
run: pnpm install
shell: bash
working-directory: ${{ github.action_path }}
- name: Run agent
run: |
cd $GITHUB_WORKSPACE
node ${{ github.action_path }}/entry.ts
shell: bash
env:
INPUTS_JSON: ${{ toJSON(inputs) }}
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.
-110
View File
@@ -1,110 +0,0 @@
import { query, type SDKMessage } from "@anthropic-ai/claude-agent-sdk";
import { log } from "../utils/cli.ts";
import { type Agent, instructions } from "./shared.ts";
export const claude: Agent = {
run: async ({ prompt, mcpServers, apiKey }) => {
process.env.ANTHROPIC_API_KEY = apiKey;
const queryInstance = query({
prompt: `${instructions}\n\n${prompt}`,
options: {
permissionMode: "bypassPermissions",
mcpServers,
},
});
// 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>;
};
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}`);
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" && content.is_error) {
log.warning(`Tool error: ${content.content}`);
}
}
}
},
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: () => {},
};
-62
View File
@@ -1,62 +0,0 @@
import type { McpServerConfig } from "@anthropic-ai/claude-agent-sdk";
import { ghPullfrogMcpName } from "../mcp/config.ts";
/**
* Result returned by agent execution
*/
export interface AgentResult {
success: boolean;
output?: string;
error?: string;
metadata?: Record<string, unknown>;
}
/**
* Configuration for agent creation
*/
export interface AgentConfig {
apiKey: string;
githubInstallationToken: string;
prompt: string;
mcpServers: Record<string, McpServerConfig>;
}
export type Agent = {
run: (config: AgentConfig) => Promise<AgentResult>;
};
export const instructions = `- inspect ${ghPullfrogMcpName} MCP server to determine what tools are available
- do not under any circumstances use the github cli (\`gh\`). find the corresponding tool from ${ghPullfrogMcpName} instead.
- if prompted by a comment to respond to create a new issue, pr or anything else, after succeeding,
also respond to the original comment with a very brief message containing a link to it
- mode selection: choose the appropriate mode based on the prompt payload:
- choose "plan mode" if the prompt asks to:
- create a plan, break down tasks, outline steps, or analyze requirements
- understand the scope of work before implementation
- provide a todo list or task breakdown
- choose "implement" if the prompt asks to:
- implement, build, create, or develop code changes
- make specific changes to files or features
- execute a plan that was previously created
- the prompt includes specific implementation details or requirements
- choose "review" if the prompt asks to:
- review code, PR, or implementation
- provide feedback, suggestions, or identify issues
- check code quality, style, or correctness
- once you've chosen a mode, follow its associated prompts carefully
- when prompted directly (e.g., via issue comment or PR comment):
(1) start by creating a single response comment using mcp__${ghPullfrogMcpName}__create_issue_comment
- the initial comment should say something like "I'll do {summary of request}" where you summarize what was requested
- save the commentId returned from this initial comment creation
(2) use mcp__${ghPullfrogMcpName}__edit_issue_comment to progressively update that same comment as you make progress
- update the comment with current status, completed tasks, and any relevant information
- continue updating the same comment throughout the planning/implementation process
(3) create_issue_comment should only be used once initially - all subsequent updates must use edit_issue_comment with the saved commentId
- if prompted to review a PR:
(1) get PR info with mcp__${ghPullfrogMcpName}__get_pull_request (this automatically prepares the repository by fetching and checking out the PR branch)
(2) view diff: git diff origin/<base>...origin/<head> (use line numbers from this for inline comments)
(3) read files from the checked-out PR branch to understand the implementation
(4) when submitting review: use the 'comments' array for ALL specific code issues - include the file path and line position from the diff
(5) only use the 'body' field for a brief summary (1-2 sentences) or for feedback that doesn't apply to a specific code location
replace <base> and <head> with 'base' and 'head' from the PR info
`;
+18
View File
@@ -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");
+273
View File
@@ -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
View File
@@ -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");
}
+184
View File
@@ -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,
};
});
}
+116 -30
View File
@@ -1,36 +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
*/
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),
};
}
import * as core from "@actions/core";
import { type } from "arktype";
import { Inputs, main } from "./main.ts";
import packageJson from "./package.json" with { type: "json" };
import { log } from "./utils/cli.ts";
async function run(): Promise<void> {
function readEvent(): Record<string, unknown> {
const path = process.env.GITHUB_EVENT_PATH;
if (!path) return {};
try {
log.info(`🐸 Running pullfrog/action@${packageJson.version}...`);
const inputsJson = process.env.INPUTS_JSON;
if (!inputsJson) {
throw new Error("INPUTS_JSON environment variable not found");
}
const parsed = type("string.json.parse").assert(inputsJson);
const inputs = Inputs.assert(parsed);
const result = await main(inputs);
if (!result.success) {
throw new Error(result.error || "Agent execution failed");
}
} catch (error) {
const errorMessage = error instanceof Error ? error.message : "Unknown error occurred";
core.setFailed(`Action failed: ${errorMessage}`);
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();
-9
View File
@@ -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
View File
@@ -1 +0,0 @@
add a github review for the following PR: https://github.com/pullfrogai/scratch/pull/16
+163
View File
@@ -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
View File
@@ -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!");
-70
View File
@@ -1,70 +0,0 @@
import { type } from "arktype";
import { claude } from "./agents/claude.ts";
import { createMcpConfigs } from "./mcp/config.ts";
import { log } from "./utils/cli.ts";
import { parseRepoContext, setupGitHubInstallationToken } from "./utils/github.ts";
import { setupGitAuth, setupGitConfig } from "./utils/setup.ts";
export const Inputs = type({
prompt: "string",
"anthropic_api_key?": "string | undefined",
});
export type Inputs = typeof Inputs.infer;
export interface MainResult {
success: boolean;
output?: string | undefined;
error?: string | undefined;
}
export async function main(inputs: Inputs): Promise<MainResult> {
try {
log.info("Starting agent run...");
setupGitConfig();
const githubInstallationToken = await setupGitHubInstallationToken();
const repoContext = parseRepoContext();
setupGitAuth(githubInstallationToken, repoContext);
const mcpServers = createMcpConfigs(githubInstallationToken);
log.debug(`📋 MCP Config: ${JSON.stringify(mcpServers, null, 2)}`);
log.info("Running Claude Agent SDK...");
log.box(inputs.prompt, { title: "Prompt" });
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,
};
}
}
+218
View File
@@ -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);
}
-55
View File
@@ -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,
};
}),
});
-31
View File
@@ -1,31 +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";
const actionPath = fromHere("..");
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}`;
return {
[ghPullfrogMcpName]: {
command: "node",
args: [`${actionPath}/mcp/server.ts`],
env: {
GITHUB_INSTALLATION_TOKEN: githubInstallationToken,
GITHUB_REPOSITORY: githubRepository,
},
},
};
}
-42
View File
@@ -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),
};
}),
});
-43
View File
@@ -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,
};
}),
});
-52
View File
@@ -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,
};
}),
});
-94
View File
@@ -1,94 +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"),
event: type
.enumerated("APPROVE", "REQUEST_CHANGES", "COMMENT")
.describe("'APPROVE', 'REQUEST_CHANGES', or 'COMMENT' (the review action)"),
body: type.string
.describe(
"Brief summary or general feedback that doesn't apply to specific code locations. Keep it concise - most feedback should be in the 'comments' array."
)
.optional(),
commit_id: type.string
.describe("Optional SHA of the commit being reviewed. Defaults to latest.")
.optional(),
comments: type({
path: type.string.describe("The file path to comment on (relative to repo root)"),
line: type.number.describe(
"The line number in the file (use line numbers from the diff - usually the RIGHT side/new code)"
),
side: type
.enumerated("LEFT", "RIGHT")
.describe(
"Side of the diff: LEFT (old code) or RIGHT (new code). Defaults to RIGHT if not provided."
)
.optional(),
body: type.string.describe("The comment text for this specific line"),
start_line: type.number
.describe("Start line for multi-line comments (optional, for commenting on ranges)")
.optional(),
})
.array()
.describe(
"REQUIRED: Array of inline comments for specific code issues. Use this for all location-specific feedback. Use 'git diff origin/<base>...origin/<head>' to find the correct line numbers (typically use the line numbers shown on the RIGHT side for new code, LEFT side for old code)."
)
.optional(),
});
export const ReviewTool = tool({
name: "submit_pull_request_review",
description:
"Submit a review (approve, request changes, or comment) for an existing pull request. " +
"IMPORTANT: Use 'comments' array for ALL specific code issues at the line-level. " +
"Only use 'body' for a brief summary or feedback that doesn't apply to a specific location.",
parameters: Review,
execute: contextualize(async ({ pull_number, event, body, commit_id, comments = [] }, ctx) => {
// Get the PR to determine the head commit if commit_id not provided
const pr = await ctx.octokit.rest.pulls.get({
owner: ctx.owner,
repo: ctx.name,
pull_number,
});
// Compose the request
const params: RestEndpointMethodTypes["pulls"]["createReview"]["parameters"] = {
owner: ctx.owner,
repo: ctx.name,
pull_number,
event,
};
if (body) params.body = body;
if (commit_id) {
params.commit_id = commit_id;
} else {
params.commit_id = pr.data.head.sha;
}
if (comments.length > 0) {
type ReviewComment = (typeof params.comments & {})[number];
// Convert comments to the format expected by GitHub API
params.comments = comments.map((comment) => {
const reviewComment: ReviewComment = {
...comment,
};
reviewComment.side = comment.side || "RIGHT";
if (comment.start_line) {
reviewComment.start_line = comment.start_line;
reviewComment.start_side = comment.side || "RIGHT";
}
return reviewComment;
});
}
const result = await ctx.octokit.rest.pulls.createReview(params);
return {
success: true,
reviewId: result.data.id,
html_url: result.data.html_url,
state: result.data.state,
user: result.data.user?.login,
submitted_at: result.data.submitted_at,
};
}),
});
-25
View File
@@ -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();
-76
View File
@@ -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 -59
View File
@@ -1,65 +1,15 @@
{
"name": "@pullfrog/action",
"version": "0.0.72",
"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",
"play": "node play.ts",
"upDeps": "pnpm up --latest",
"lock": "pnpm --ignore-workspace install"
},
"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",
"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"
}
}
-148
View File
@@ -1,148 +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 packageJson from "./package.json" with { type: "json" };
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 {
log.info(`🐸 Running pullfrog/action@${packageJson.version}...`);
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);
}
}
-1648
View File
File diff suppressed because it is too large Load Diff
+167
View File
@@ -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 };
}
+22
View File
@@ -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"
}
}
}
+16
View File
@@ -0,0 +1,16 @@
{
"action": "opened",
"number": 4,
"pull_request": {
"number": 4,
"head": {
"sha": "94f4a680369ab07b076ba5679f05cc157e8c9a9c"
}
},
"repository": {
"name": "test-repo",
"owner": {
"login": "ShockVPN"
}
}
}
-4
View File
@@ -1,4 +0,0 @@
[] add modes to prompt
[] progressively update comment
[] don't allow rejecting prs
[] fix pnpm caching
+24 -18
View File
@@ -1,23 +1,29 @@
{
"compilerOptions": {
"outDir": "./dist",
"module": "NodeNext",
"target": "ESNext",
"moduleResolution": "NodeNext",
"lib": ["ESNext"],
"allowImportingTsExtensions": true,
"rewriteRelativeImportExtensions": true,
"skipLibCheck": true,
"strict": true,
"noUncheckedSideEffectImports": true,
"declaration": true,
"verbatimModuleSyntax": true,
"esModuleInterop": true,
"resolveJsonModule": true,
"exactOptionalPropertyTypes": true,
"forceConsistentCasingInFileNames": true,
"stripInternal": true,
// Environment setup & latest features
"lib": ["ESNext"],
"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
}
}
+57
View File
@@ -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[];
}
-232
View File
@@ -1,232 +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";
/**
* 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();
}
},
};
-274
View File
@@ -1,274 +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> {
log.info("Generating OIDC token...");
const oidcToken = await core.getIDToken("pullfrog-api");
log.info("OIDC token generated successfully");
const apiUrl = process.env.API_URL || "https://pullfrog.ai";
log.info("Exchanging OIDC token for installation token...");
const tokenResponse = await fetch(`${apiUrl}/api/github/installation-token`, {
method: "POST",
headers: {
Authorization: `Bearer ${oidcToken}`,
"Content-Type": "application/json",
},
});
if (!tokenResponse.ok) {
const errorText = await tokenResponse.text();
throw new Error(
`Token exchange failed: ${tokenResponse.status} ${tokenResponse.statusText} - ${errorText}`
);
}
const tokenData = (await tokenResponse.json()) as InstallationToken;
log.info(`Installation token obtained for ${tokenData.repository || "all repositories"}`);
return tokenData.token;
}
const base64UrlEncode = (str: string): string => {
return Buffer.from(str)
.toString("base64")
.replace(/\+/g, "-")
.replace(/\//g, "_")
.replace(/=/g, "");
};
const generateJWT = (appId: string, privateKey: string): string => {
const now = Math.floor(Date.now() / 1000);
const payload = {
iat: now - 60,
exp: now + 5 * 60,
iss: appId,
};
const header = {
alg: "RS256",
typ: "JWT",
};
const encodedHeader = base64UrlEncode(JSON.stringify(header));
const encodedPayload = base64UrlEncode(JSON.stringify(payload));
const signaturePart = `${encodedHeader}.${encodedPayload}`;
const signature = createSign("RSA-SHA256")
.update(signaturePart)
.sign(privateKey, "base64")
.replace(/\+/g, "-")
.replace(/\//g, "_")
.replace(/=/g, "");
return `${signaturePart}.${signature}`;
};
const githubRequest = async <T>(
path: string,
options: {
method?: string;
headers?: Record<string, string>;
body?: string;
} = {}
): Promise<T> => {
const { method = "GET", headers = {}, body } = options;
const url = `https://api.github.com${path}`;
const requestHeaders = {
Accept: "application/vnd.github.v3+json",
"User-Agent": "Pullfrog-Installation-Token-Generator/1.0",
...headers,
};
const response = await fetch(url, {
method,
headers: requestHeaders,
...(body && { body }),
});
if (!response.ok) {
const errorText = await response.text();
throw new Error(
`GitHub API request failed: ${response.status} ${response.statusText}\n${errorText}`
);
}
return response.json() as T;
};
const checkRepositoryAccess = async (
token: string,
repoOwner: string,
repoName: string
): Promise<boolean> => {
try {
const response = await githubRequest<RepositoriesResponse>("/installation/repositories", {
headers: { Authorization: `token ${token}` },
});
return response.repositories.some(
(repo) => repo.owner.login === repoOwner && repo.name === repoName
);
} catch {
return false;
}
};
const createInstallationToken = async (jwt: string, installationId: number): Promise<string> => {
const response = await githubRequest<InstallationTokenResponse>(
`/app/installations/${installationId}/access_tokens`,
{
method: "POST",
headers: { Authorization: `Bearer ${jwt}` },
}
);
return response.token;
};
const findInstallationId = async (
jwt: string,
repoOwner: string,
repoName: string
): Promise<number> => {
const installations = await githubRequest<Installation[]>("/app/installations", {
headers: { Authorization: `Bearer ${jwt}` },
});
for (const installation of installations) {
try {
const tempToken = await createInstallationToken(jwt, installation.id);
const hasAccess = await checkRepositoryAccess(tempToken, repoOwner, repoName);
if (hasAccess) {
return installation.id;
}
} catch {}
}
throw new Error(
`No installation found with access to ${repoOwner}/${repoName}. ` +
"Ensure the GitHub App is installed on the target repository."
);
};
async function acquireTokenViaGitHubApp(): Promise<string> {
const repoContext = parseRepoContext();
const config: GitHubAppConfig = {
appId: process.env.GITHUB_APP_ID!,
privateKey: process.env.GITHUB_PRIVATE_KEY?.replace(/\\n/g, "\n")!,
repoOwner: repoContext.owner,
repoName: repoContext.name,
};
const jwt = generateJWT(config.appId, config.privateKey);
const installationId = await findInstallationId(jwt, config.repoOwner, config.repoName);
const token = await createInstallationToken(jwt, installationId);
return token;
}
async function acquireNewToken(): Promise<string> {
if (isGitHubActionsEnvironment()) {
return await acquireTokenViaOIDC();
} else {
return await acquireTokenViaGitHubApp();
}
}
/**
* Setup GitHub installation token for the action
*/
export async function setupGitHubInstallationToken(): Promise<string> {
const existingToken = checkExistingToken();
if (existingToken) {
core.setSecret(existingToken);
log.info("Using provided GitHub installation token");
return existingToken;
}
const token = await acquireNewToken();
core.setSecret(token);
process.env.GITHUB_INSTALLATION_TOKEN = token;
return token;
}
export interface RepoContext {
owner: string;
name: string;
}
/**
* Parse repository context from GITHUB_REPOSITORY environment variable.
*/
export function parseRepoContext(): RepoContext {
const githubRepo = process.env.GITHUB_REPOSITORY;
if (!githubRepo) {
throw new Error("GITHUB_REPOSITORY environment variable is required");
}
const [owner, name] = githubRepo.split("/");
if (!owner || !name) {
throw new Error(`Invalid GITHUB_REPOSITORY format: ${githubRepo}. Expected 'owner/repo'`);
}
return { owner, name };
}
-69
View File
@@ -1,69 +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
*/
export function setupGitConfig(): void {
log.info("🔧 Setting up git configuration...");
execSync('git config user.email "action@pullfrog.ai"', { stdio: "inherit" });
execSync('git config user.name "Pullfrog Action"', { stdio: "inherit" });
}
/**
* Setup git authentication using GitHub token
*/
export function setupGitAuth(githubToken: string, repoContext: RepoContext): void {
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");
}
-110
View File
@@ -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();
}
});
}