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
38 changed files with 1384 additions and 55395 deletions
-2
View File
@@ -1,2 +0,0 @@
!examples
-64
View File
@@ -1,64 +0,0 @@
name: Publish
on:
push:
branches:
- main
paths:
- "package.json"
workflow_dispatch:
jobs:
publish:
runs-on: ubuntu-latest
permissions:
contents: write
id-token: write
steps:
- uses: actions/checkout@v4
with:
fetch-depth: 0
- uses: pnpm/action-setup@v4
with:
version: 9
- uses: actions/setup-node@v4
with:
node-version: "20"
cache: "pnpm"
registry-url: "https://registry.npmjs.org"
- name: Install dependencies
run: pnpm install --frozen-lockfile
- name: Build for npm with zshy
run: |
cd action
pnpm build:npm
- name: Get version from package.json
id: get_version
run: |
cd action
VERSION=$(node -p "require('./package.json').version")
echo "version=$VERSION" >> $GITHUB_OUTPUT
echo "Publishing version: $VERSION"
- name: Create GitHub Release
uses: actions/create-release@v1
env:
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
with:
tag_name: v${{ steps.get_version.outputs.version }}
release_name: "@pullfrog/action v${{ steps.get_version.outputs.version }}"
draft: false
prerelease: false
- name: Publish to npm
run: |
cd action
npm publish --access public
env:
NODE_AUTH_TOKEN: ${{ secrets.NPM_TOKEN }}
-101
View File
@@ -1,101 +0,0 @@
name: Auto-tag Action Release
on:
push:
branches: [main]
paths:
- 'package.json'
permissions:
contents: write
jobs:
auto-tag:
runs-on: ubuntu-latest
steps:
- name: Checkout code
uses: actions/checkout@v4
with:
fetch-depth: 0
- name: Setup Node.js
uses: actions/setup-node@v4
with:
node-version: '20'
- name: Setup pnpm
uses: pnpm/action-setup@v4
with:
version: latest
- name: Get package version
id: version
run: |
VERSION=$(node -p "require('./package.json').version")
echo "version=$VERSION" >> $GITHUB_OUTPUT
echo "tag=v$VERSION" >> $GITHUB_OUTPUT
# Extract major version (e.g., "0" from "0.0.1")
MAJOR_VERSION=$(echo $VERSION | cut -d. -f1)
echo "major_tag=v$MAJOR_VERSION" >> $GITHUB_OUTPUT
- name: Check if tag already exists
id: check_tag
run: |
if git rev-parse "refs/tags/${{ steps.version.outputs.tag }}" >/dev/null 2>&1; then
echo "exists=true" >> $GITHUB_OUTPUT
echo "Tag ${{ steps.version.outputs.tag }} already exists"
else
echo "exists=false" >> $GITHUB_OUTPUT
echo "Tag ${{ steps.version.outputs.tag }} does not exist"
fi
- name: Verify built files are up to date
if: steps.check_tag.outputs.exists == 'false'
run: |
# Check if there are any uncommitted changes (built files should already be committed via pre-commit hook)
if [[ -n $(git status --porcelain) ]]; then
echo "❌ Error: There are uncommitted changes. Built files should be committed via pre-commit hook."
git status
exit 1
fi
echo "✅ All built files are up to date"
- name: Create and push tags
if: steps.check_tag.outputs.exists == 'false'
run: |
# Create specific version tag
git tag ${{ steps.version.outputs.tag }}
git push origin ${{ steps.version.outputs.tag }}
# Create/update major version tag (moving tag)
git tag -f ${{ steps.version.outputs.major_tag }}
git push origin ${{ steps.version.outputs.major_tag }} --force
- name: Create GitHub Release
if: steps.check_tag.outputs.exists == 'false'
uses: actions/create-release@v1
env:
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
with:
tag_name: ${{ steps.version.outputs.tag }}
release_name: ${{ steps.version.outputs.tag }}
body: |
Automated release for action version ${{ steps.version.outputs.version }}
## Usage
```yaml
- uses: pullfrog/pullfrog@${{ steps.version.outputs.major_tag }}
with:
message: "Your message here"
```
Or use the specific version:
```yaml
- uses: pullfrog/pullfrog@${{ steps.version.outputs.tag }}
with:
message: "Your message here"
```
draft: false
prerelease: false
+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
-6
View File
@@ -1,6 +0,0 @@
# Build the action before committing
echo "🔨 Building action..."
npm run build
# Add the built files to the commit
git add index.cjs entry.cjs
-70
View File
@@ -1,70 +0,0 @@
# Pullfrog Action
GitHub Action for running Claude Code and other agents via Pullfrog.
## Quick Start
```bash
# Install dependencies
pnpm install
# Test with default prompt
npm run play # Run locally on your machine
npm run play -- --act # Run in Docker (simulates GitHub Actions)
```
## Testing with play.ts
The `play.ts` script provides two ways to test the action:
### Local Mode (Default)
```bash
npm run play # Uses fixtures/play.txt
npm run play fixtures/complex.txt # Custom prompt file
```
- Clones the scratch repository to `.temp`
- Runs Claude Code directly on your machine
- Fast iteration for development
### Docker Mode (--act flag)
```bash
npm run play -- --act # Uses fixtures/play.txt
npm run play fixtures/simple.txt -- --act # Custom prompt file
```
- Builds fresh bundles with esbuild
- Creates minimal distribution without node_modules
- Runs in Docker container via `act`
- Simulates GitHub Actions environment
### Prompt Files
Supports `.txt`, `.json`, and `.ts` files:
```bash
npm run play prompt.txt # Plain text prompt
npm run play config.json # JSON configuration
npm run play dynamic.ts # TypeScript with default export
```
## Building
```bash
pnpm build # Production build (bundles & removes node_modules)
pnpm build:dev # Development build (keeps node_modules)
pnpm dev # Watch mode
```
The action is bundled into `entry.cjs` with all dependencies included, eliminating runtime dependency on node_modules.
## Architecture
- **entry.cjs**: Bundled action entry point (self-contained)
- **agents/**: Agent implementations (Claude, etc.)
- **utils/**: Utilities for subprocess, act, and formatting
- **fixtures/**: Test prompt files
## Why No node_modules?
pnpm uses symlinks that cause "invalid symlink" errors when `act` copies the action to Docker. Our solution:
1. Bundle everything into `entry.cjs`
2. Remove node_modules after building
3. Create minimal `.act-dist` for Docker testing
+21 -13
View File
@@ -1,20 +1,28 @@
name: "Pullfrog Claude Code Action"
description: "Execute Claude Code with a prompt using Anthropic API"
author: "Pullfrog"
name: shockbot
description: Ollama-powered code review bot for Gitea
author: ShockVPN
inputs:
prompt:
description: "Prompt to send to Claude Code"
required: true
default: "Hello from Claude Code!"
anthropic_api_key:
description: "Anthropic API key for Claude Code authentication"
description: Review instruction sent to the model
required: false
default: "Review this pull request"
model:
description: Ollama model to use
required: false
default: "qwen3.6:35b"
context_window:
description: Max tokens per diff chunk
required: false
default: "4096"
max_tool_calls:
description: Max MCP tool calls the model can make per chunk
required: false
default: "10"
runs:
using: "node20"
main: "entry.cjs"
using: "node24"
main: "bootstrap.ts"
branding:
icon: "code"
color: "orange"
# 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.
-402
View File
@@ -1,402 +0,0 @@
import { access, constants } from "node:fs/promises";
import * as core from "@actions/core";
import { boxString, tableString } from "../utils";
import { spawn } from "../utils/subprocess";
import type { Agent, AgentConfig, AgentResult } from "./types";
/**
* Claude Code agent implementation
*/
export class ClaudeAgent implements Agent {
private apiKey: string;
public runStats = {
toolsUsed: 0,
turns: 0,
startTime: 0,
};
// $: ExecaMethod;
constructor(config: AgentConfig) {
if (!config.apiKey) {
throw new Error("Claude agent requires an API key");
}
this.apiKey = config.apiKey;
// Removed execa dependency - using spawn utility instead
}
/**
* Check if Claude Code CLI is already installed
*/
private async isClaudeInstalled(): Promise<boolean> {
try {
const claudePath = `${process.env.HOME}/.local/bin/claude`;
await access(claudePath, constants.F_OK | constants.X_OK);
return true;
} catch {
return false;
}
}
/**
* Install Claude Code CLI
*/
async install(): Promise<void> {
// Check if Claude Code is already installed
if (await this.isClaudeInstalled()) {
core.info("Claude Code is already installed, skipping installation");
return;
}
core.info("Installing Claude Code...");
try {
// Use shell execution to properly handle the pipe
const result = await spawn({
cmd: "bash",
args: [
"-c",
"curl -fsSL https://claude.ai/install.sh | bash -s 1.0.93",
],
env: { ANTHROPIC_API_KEY: this.apiKey },
timeout: 120000, // 2 minute timeout
onStdout: (chunk) => process.stdout.write(chunk),
onStderr: (chunk) => process.stderr.write(chunk),
});
if (result.exitCode !== 0) {
throw new Error(
`Installation failed with exit code ${result.exitCode}: ${result.stderr}`,
);
}
core.info("Claude Code installed successfully");
} catch (error) {
throw new Error(`Failed to install Claude Code: ${error}`);
}
}
/**
* Execute Claude Code with the given prompt
*/
async execute(prompt: string): Promise<AgentResult> {
core.info("Running Claude Code...");
// printTable([[prompt]]);
try {
// Execute Claude Code with the prompt directly using proper headless mode
// core.info(`Executing Claude Code with prompt: ${prompt.substring(0, 100)}...`);
const claudePath = `${process.env.HOME}/.local/bin/claude`;
// console.log("Using Claude Code from:", claudePath);
console.log(boxString(prompt, { title: "Prompt" }));
const args = [
"--print",
"--output-format",
"stream-json",
"--verbose",
"--permission-mode",
"acceptEdits",
];
const env = {
ANTHROPIC_API_KEY: this.apiKey,
};
// Start a collapsible log group for streaming output
core.startGroup("🔄 Run details");
// Initialize run statistics
this.runStats = {
toolsUsed: 0,
turns: 0,
startTime: Date.now(),
};
const finalResult = "";
const totalCost = 0;
// run Claude Code with the prompt
const result = await spawn({
cmd: claudePath,
args,
env,
input: prompt,
timeout: 10 * 60 * 1000, // 10 minutes
onStdout: (_chunk) => {
// console.log(chunk);
processJSONChunk(_chunk, this);
},
onStderr: (_chunk) => {
if (_chunk.trim()) {
// core.warning(`[warn] ${chunk}`);
processJSONChunk(_chunk, this);
}
},
});
// throw on non-zero exit code
if (result.exitCode !== 0) {
throw new Error(
`Command failed with exit code ${result.exitCode}\n\nStdout: ${result.stdout}\n\nStderr: ${result.stderr}`,
);
}
// Process the complete buffered stdout to extract final results
// if (result.stdout.trim()) {
// const lines = result.stdout.trim().split("\n");
// for (const line of lines) {
// if (line.trim()) {
// const chunkResult = processJsonChunk(line);
// if (chunkResult.finalResult) finalResult = chunkResult.finalResult;
// if (chunkResult.totalCost) totalCost = chunkResult.totalCost;
// }
// }
// }
// Log run summary
const duration = Date.now() - this.runStats.startTime;
core.info(
`📊 Run Summary: ${this.runStats.toolsUsed} tools used, ${this.runStats.turns} turns, ${duration}ms duration`,
);
core.info("✅ Task complete.");
core.endGroup(); // End the collapsible log group
return {
success: true,
output: finalResult,
metadata: {
promptLength: prompt.length,
exitCode: result.exitCode,
durationMs: result.durationMs,
totalCost,
},
};
} catch (error: any) {
// Ensure group is closed even if error occurs before group is started
try {
core.endGroup();
} catch {
// Group might not have been started, ignore
}
const errorMessage =
error instanceof Error ? error.message : "Unknown error";
return {
success: false,
error: `Failed to execute Claude Code: ${errorMessage}`,
};
}
}
}
/**
* Process a JSON chunk line and extract result data
*/
// function processJsonChunk(line: string): { finalResult?: string; totalCost?: number } {
// try {
// const chunk = JSON.parse(line.trim());
// processJSONChunk(chunk);
// // Collect final result and cost data
// if (chunk.type === "result" && chunk.result) {
// return {
// finalResult: chunk.result,
// totalCost: chunk.total_cost_usd || 0,
// };
// }
// return {};
// } catch {
// core.debug(`Failed to parse JSON line: ${line}`);
// return {};
// }
// }
/**
* Pretty print a JSON chunk based on its type
*/
function processJSONChunk(chunk: string, agent?: ClaudeAgent): void {
try {
// Parse the JSON string first
console.log(chunk);
const parsedChunk = JSON.parse(chunk.trim());
switch (parsedChunk.type) {
case "system":
if (parsedChunk.subtype === "init") {
core.info(`🚀 Starting Claude Code session...`);
// core.info(`📁 Working directory: ${parsedChunk.cwd}`);
// core.info(`🔑 Permission mode: ${parsedChunk.permissionMode}`);
core.info(
tableString([
["model", parsedChunk.model],
["cwd", parsedChunk.cwd],
["permission_mode", parsedChunk.permissionMode],
[
"tools",
parsedChunk.tools?.length
? `${parsedChunk.tools.length} tools`
: "none",
],
[
"mcp_servers",
parsedChunk.mcp_servers?.length
? `${parsedChunk.mcp_servers.length} servers`
: "none",
],
[
"slash_commands",
parsedChunk.slash_commands?.length
? `${parsedChunk.slash_commands.length} commands`
: "none",
],
]),
);
}
break;
case "assistant":
if (parsedChunk.message?.content) {
// Track turns
if (agent) {
agent.runStats.turns++;
}
for (const content of parsedChunk.message.content) {
if (content.type === "text") {
// Skip empty text content
if (content.text.trim()) {
core.info(
boxString(content.text.trim(), { title: "Claude Code" }),
);
}
} else if (content.type === "tool_use") {
// Track tools used
if (agent) {
agent.runStats.toolsUsed++;
}
// Enhanced tool usage logging
const toolName = content.name;
// const toolId = content.id;
core.info(`${toolName}`);
// Log tool-specific details based on tool type
if (content.input) {
const input = content.input;
// Common tool input fields
if (input.description) {
core.info(` └─ ${input.description}`);
}
// Tool-specific input fields
if (input.command) {
core.info(` └─ command: ${input.command}`);
}
if (input.file_path) {
core.info(` └─ file: ${input.file_path}`);
}
if (input.content) {
const contentPreview =
input.content.length > 100
? `${input.content.substring(0, 100)}...`
: input.content;
core.info(` └─ content: ${contentPreview}`);
}
if (input.query) {
core.info(` └─ query: ${input.query}`);
}
if (input.pattern) {
core.info(` └─ pattern: ${input.pattern}`);
}
if (input.url) {
core.info(` └─ url: ${input.url}`);
}
// For multi-edit or complex operations
if (input.edits && Array.isArray(input.edits)) {
core.info(` └─ edits: ${input.edits.length} changes`);
input.edits.forEach((edit: any, index: number) => {
if (edit.file_path) {
core.info(` ${index + 1}. ${edit.file_path}`);
}
});
}
// For task operations
if (input.task) {
core.info(` └─ task: ${input.task}`);
}
// For bash operations with specific details
if (input.bash_command) {
core.info(` └─ bash_command: ${input.bash_command}`);
}
}
// Log tool ID for debugging
// core.debug(` 🔗 Tool ID: ${toolId}`);
}
}
}
break;
case "user":
if (parsedChunk.message?.content) {
for (const content of parsedChunk.message.content) {
if (content.type === "tool_result") {
if (content.is_error) {
core.warning(`❌ Tool error: ${content.content}`);
} else {
// Enhanced tool result logging
const _resultContent = content.content.trim();
// do nothing for now. usually useless in headless more.
}
}
}
}
break;
case "result":
if (parsedChunk.subtype === "success") {
if (parsedChunk.result) {
core.info(
boxString(parsedChunk.result.trim(), {
title: "🤖 Claude Code",
maxWidth: 70,
}),
);
}
core.info(
tableString([
[
"Cost",
`$${parsedChunk.total_cost_usd?.toFixed(4) || "0.0000"}`,
],
["Input Tokens", parsedChunk.usage?.input_tokens || 0],
["Output Tokens", parsedChunk.usage?.output_tokens || 0],
["Duration", `${parsedChunk.duration_ms}ms`],
["Turns", parsedChunk.num_turns || 1],
]),
);
} else {
core.error(`❌ Failed: ${parsedChunk.error || "Unknown error"}`);
}
break;
default:
// Log unknown chunk types for debugging
core.debug(`📦 Unknown chunk type: ${parsedChunk.type}`);
break;
}
} catch (error) {
core.debug(`Failed to parse chunk: ${error}`);
core.debug(`Raw chunk: ${chunk.substring(0, 200)}...`);
}
}
-2
View File
@@ -1,2 +0,0 @@
export * from "./claude";
export * from "./types";
-34
View File
@@ -1,34 +0,0 @@
/**
* Standard interface for all Pullfrog agents
*/
export interface Agent {
/**
* Install the agent and any required dependencies
*/
install(): Promise<void>;
/**
* Execute the agent with the given prompt
* @param prompt The prompt to send to the agent
* @param options Additional options specific to the agent
*/
execute(prompt: string, options?: Record<string, any>): Promise<AgentResult>;
}
/**
* Result returned by agent execution
*/
export interface AgentResult {
success: boolean;
output?: string;
error?: string;
metadata?: Record<string, any>;
}
/**
* Configuration for agent creation
*/
export interface AgentConfig {
apiKey?: string;
[key: string]: any;
}
+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,
};
});
}
-26021
View File
File diff suppressed because one or more lines are too long
+116 -49
View File
@@ -1,55 +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
* This file is bundled to entry.cjs and called directly by GitHub Actions
*/
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 { main } from "./main";
async function run(): Promise<void> {
function readEvent(): Record<string, unknown> {
const path = process.env.GITHUB_EVENT_PATH;
if (!path) return {};
try {
// Get inputs from GitHub Actions
const prompt = core.getInput("prompt", { required: true });
const anthropicApiKey = core.getInput("anthropic_api_key", {
required: true,
});
if (!anthropicApiKey) {
throw new Error("anthropic_api_key is required");
}
if (!prompt) {
throw new Error("prompt is required");
}
// Create params object
const params = {
prompt,
anthropicApiKey,
};
// Run the main function
const result = await main(params);
// Set outputs
core.setOutput("status", result.success ? "success" : "failed");
core.setOutput("prompt", prompt);
core.setOutput("output", result.output || "");
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 {};
}
}
// Run the action
run().catch((error) => {
console.error("Action failed:", error);
process.exit(1);
});
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();
-16
View File
@@ -1,16 +0,0 @@
import { build } from "esbuild";
// Build the GitHub Action bundle only
// For npm package builds, use zshy (pnpm build:npm)
await build({
entryPoints: ["./entry.ts"],
bundle: true,
outfile: "./entry.cjs",
format: "cjs",
platform: "node",
target: "node20",
minify: false,
sourcemap: false,
});
console.log("✅ Build completed successfully!");
-7
View File
@@ -1,7 +0,0 @@
import type { MainParams } from "../main";
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."
} satisfies MainParams;
export default testParams;
-1
View File
@@ -1 +0,0 @@
Print the list of tools available. Then create a new file called test.txt with the content "Hello from Pullfrog!".
+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);
}
-25978
View File
File diff suppressed because one or more lines are too long
+1 -8
View File
@@ -1,8 +1 @@
/**
* Library entry point for npm package
* This exports the main function for programmatic usage
*/
export { main } from "./main";
export { ClaudeAgent } from "./agents";
export type { Agent, AgentConfig, AgentResult } from "./agents/types";
console.log("Hello via Bun!");
-54
View File
@@ -1,54 +0,0 @@
import * as core from "@actions/core";
import { ClaudeAgent } from "./agents";
export interface MainParams {
prompt: string;
anthropicApiKey?: string;
}
export interface MainResult {
success: boolean;
output?: string;
error?: string;
}
export async function main(params: MainParams): Promise<MainResult> {
try {
// Use provided API key or fall back to environment variable
const anthropicApiKey =
params.anthropicApiKey || process.env.ANTHROPIC_API_KEY;
if (!anthropicApiKey) {
throw new Error("anthropic_api_key is required");
}
core.info(`→ Starting agent run with Claude Code`);
// Create and install the Claude agent
const agent = new ClaudeAgent({ apiKey: anthropicApiKey });
await agent.install();
// Execute the agent with the prompt
const result = await agent.execute(params.prompt);
if (!result.success) {
return {
success: false,
error: result.error || "Agent execution failed",
output: result.output,
};
}
return {
success: true,
output: result.output || "",
};
} catch (error) {
const errorMessage =
error instanceof Error ? error.message : "Unknown error occurred";
return {
success: false,
error: errorMessage,
};
}
}
+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);
}
-1460
View File
File diff suppressed because it is too large Load Diff
+9 -48
View File
@@ -1,54 +1,15 @@
{
"name": "@pullfrog/action",
"version": "0.0.6",
"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"
],
"directories": {
"example": "examples"
},
"scripts": {
"test": "echo \"Error: no test specified\" && exit 1",
"build": "pnpm install && node esbuild.config.js && rm -rf node_modules",
"build:npm": "zshy",
"build:dev": "node esbuild.config.js",
"prepare": "husky",
"play": "tsx --env-file=../.env play.ts"
},
"dependencies": {
"@actions/core": "^1.10.1",
"execa": "^9.6.0",
"table": "^6.9.0"
},
"private": true,
"devDependencies": {
"@types/node": "^20.10.0",
"commander": "^14.0.0",
"dotenv": "^17.2.2",
"esbuild": "^0.25.9",
"husky": "^9.0.0",
"typescript": "^5.3.0",
"zshy": "^0.4.1"
"@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/pullfrog.git"
},
"keywords": [],
"author": "",
"license": "ISC",
"bugs": {
"url": "https://github.com/pullfrog/pullfrog/issues"
},
"homepage": "https://github.com/pullfrog/pullfrog#readme",
"zshy": {
"exports": "./index.ts"
"peerDependencies": {
"typescript": "^5"
}
}
-191
View File
@@ -1,191 +0,0 @@
#!/usr/bin/env tsx
import { execSync } from "node:child_process";
import { existsSync, readFileSync, rmSync, writeFileSync } from "node:fs";
import { dirname, join, resolve, extname } from "node:path";
import { fileURLToPath, pathToFileURL } from "node:url";
import { Command } from "commander";
import { main } from "./main";
import { runAct } from "./utils/act";
const __filename = fileURLToPath(import.meta.url);
const __dirname = dirname(__filename);
async function loadPrompt(filePath: string): Promise<string> {
const ext = extname(filePath).toLowerCase();
// Try to resolve the file path
let resolvedPath: string;
// First try as fixtures path
const fixturesPath = join(__dirname, "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": {
// Plain text - pass directly as prompt
return readFileSync(resolvedPath, "utf8").trim();
}
case ".json": {
// JSON - stringify and pass as prompt
const content = readFileSync(resolvedPath, "utf8");
const parsed = JSON.parse(content);
return JSON.stringify(parsed, null, 2);
}
case ".ts": {
// TypeScript - dynamic import and stringify default export
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 it's a string, use it directly
if (typeof module.default === "string") {
return module.default;
}
// If it's a MainParams object with a prompt field, extract the prompt
if (typeof module.default === "object" && module.default.prompt) {
return module.default.prompt;
}
// Otherwise stringify it
return JSON.stringify(module.default, null, 2);
}
default:
throw new Error(
`Unsupported file type: ${ext}. Supported types: .txt, .json, .ts`,
);
}
}
async function runPlay(
filePath: string,
options: { act?: boolean },
): Promise<void> {
try {
// Load the prompt from the specified file
const prompt = await loadPrompt(filePath);
if (options.act) {
// Use Docker/act to run the action
console.log("🐳 Running with Docker/act...");
runAct(prompt);
} else {
// Clone the test repository and run directly
const tempDir = join(process.cwd(), ".temp");
const repoUrl = "git@github.com:pullfrogai/scratch.git";
// Remove existing temp directory if it exists
if (existsSync(tempDir)) {
console.log("🗑️ Removing existing .temp directory...");
rmSync(tempDir, { recursive: true, force: true });
}
// Clone the repository
console.log("📦 Cloning pullfrogai/scratch into .temp...");
execSync(`git clone ${repoUrl} ${tempDir}`, { stdio: "inherit" });
// List of environment variables to copy to .temp
const envVarsToCopy = [
"ANTHROPIC_API_KEY",
"GITHUB_TOKEN",
// Add more environment variables here as needed
];
// Build .env content from the list
const envLines = envVarsToCopy
.map((varName) => `${varName}=${process.env[varName] || ""}`)
.join("\n");
const envPath = join(tempDir, ".env");
writeFileSync(envPath, envLines + "\n");
console.log("📝 Created .env file in .temp directory with:");
let hasRequiredVars = true;
envVarsToCopy.forEach((varName) => {
const hasValue = !!process.env[varName];
console.log(` - ${varName}: ${hasValue ? "✓" : "✗ (missing)"}`);
// Check for required variables
if (varName === "ANTHROPIC_API_KEY" && !hasValue) {
hasRequiredVars = false;
}
});
if (!hasRequiredVars) {
console.warn("\n⚠️ Warning: ANTHROPIC_API_KEY is not set or empty.");
console.warn(
" Please ensure you have a valid API key in your .env file.",
);
console.warn(
" Get your API key from: https://console.anthropic.com/api-keys\n",
);
}
// Change to the temp directory
process.chdir(tempDir);
console.log("🚀 Running test in .temp directory...");
console.log("─".repeat(50));
console.log(`Prompt from ${filePath}:`);
console.log(prompt);
console.log("─".repeat(50));
// Run main with the params object
const result = await main({ prompt });
if (result.success) {
console.log("✅ Test completed successfully");
if (result.output) {
console.log("Output:", result.output);
}
} else {
console.error("❌ Test failed:", result.error);
process.exit(1);
}
}
} catch (error) {
console.error("❌ Error:", (error as Error).message);
process.exit(1);
}
}
// Set up CLI
const program = new Command();
program
.name("play")
.description("Test the Pullfrog action with various prompts")
.version("1.0.0")
.argument(
"[file]",
"Prompt file to use (.txt, .json, or .ts)",
"fixtures/play.txt",
)
.option(
"--act",
"Use Docker/act to run the action instead of running directly",
)
.action(async (file: string, options: { act?: boolean }) => {
await runPlay(file, options);
});
// Parse arguments and run
program.parseAsync(process.argv).catch((error) => {
console.error("Fatal error:", error);
process.exit(1);
});
-372
View File
@@ -1,372 +0,0 @@
lockfileVersion: '9.0'
settings:
autoInstallPeers: true
excludeLinksFromLockfile: false
importers:
.:
dependencies:
'@actions/core':
specifier: ^1.10.1
version: 1.11.1
devDependencies:
'@types/node':
specifier: ^20.10.0
version: 20.19.11
esbuild:
specifier: ^0.25.9
version: 0.25.9
husky:
specifier: ^9.0.0
version: 9.1.7
typescript:
specifier: ^5.3.0
version: 5.9.2
packages:
'@actions/core@1.11.1':
resolution: {integrity: sha512-hXJCSrkwfA46Vd9Z3q4cpEpHB1rL5NG04+/rbqW9d3+CSvtB1tYe8UTpAlixa1vj0m/ULglfEK2UKxMGxCxv5A==}
'@actions/exec@1.1.1':
resolution: {integrity: sha512-+sCcHHbVdk93a0XT19ECtO/gIXoxvdsgQLzb2fE2/5sIZmWQuluYyjPQtrtTHdU1YzTZ7bAPN4sITq2xi1679w==}
'@actions/http-client@2.2.3':
resolution: {integrity: sha512-mx8hyJi/hjFvbPokCg4uRd4ZX78t+YyRPtnKWwIl+RzNaVuFpQHfmlGVfsKEJN8LwTCvL+DfVgAM04XaHkm6bA==}
'@actions/io@1.1.3':
resolution: {integrity: sha512-wi9JjgKLYS7U/z8PPbco+PvTb/nRWjeoFlJ1Qer83k/3C5PHQi28hiVdeE2kHXmIL99mQFawx8qt/JPjZilJ8Q==}
'@esbuild/aix-ppc64@0.25.9':
resolution: {integrity: sha512-OaGtL73Jck6pBKjNIe24BnFE6agGl+6KxDtTfHhy1HmhthfKouEcOhqpSL64K4/0WCtbKFLOdzD/44cJ4k9opA==}
engines: {node: '>=18'}
cpu: [ppc64]
os: [aix]
'@esbuild/android-arm64@0.25.9':
resolution: {integrity: sha512-IDrddSmpSv51ftWslJMvl3Q2ZT98fUSL2/rlUXuVqRXHCs5EUF1/f+jbjF5+NG9UffUDMCiTyh8iec7u8RlTLg==}
engines: {node: '>=18'}
cpu: [arm64]
os: [android]
'@esbuild/android-arm@0.25.9':
resolution: {integrity: sha512-5WNI1DaMtxQ7t7B6xa572XMXpHAaI/9Hnhk8lcxF4zVN4xstUgTlvuGDorBguKEnZO70qwEcLpfifMLoxiPqHQ==}
engines: {node: '>=18'}
cpu: [arm]
os: [android]
'@esbuild/android-x64@0.25.9':
resolution: {integrity: sha512-I853iMZ1hWZdNllhVZKm34f4wErd4lMyeV7BLzEExGEIZYsOzqDWDf+y082izYUE8gtJnYHdeDpN/6tUdwvfiw==}
engines: {node: '>=18'}
cpu: [x64]
os: [android]
'@esbuild/darwin-arm64@0.25.9':
resolution: {integrity: sha512-XIpIDMAjOELi/9PB30vEbVMs3GV1v2zkkPnuyRRURbhqjyzIINwj+nbQATh4H9GxUgH1kFsEyQMxwiLFKUS6Rg==}
engines: {node: '>=18'}
cpu: [arm64]
os: [darwin]
'@esbuild/darwin-x64@0.25.9':
resolution: {integrity: sha512-jhHfBzjYTA1IQu8VyrjCX4ApJDnH+ez+IYVEoJHeqJm9VhG9Dh2BYaJritkYK3vMaXrf7Ogr/0MQ8/MeIefsPQ==}
engines: {node: '>=18'}
cpu: [x64]
os: [darwin]
'@esbuild/freebsd-arm64@0.25.9':
resolution: {integrity: sha512-z93DmbnY6fX9+KdD4Ue/H6sYs+bhFQJNCPZsi4XWJoYblUqT06MQUdBCpcSfuiN72AbqeBFu5LVQTjfXDE2A6Q==}
engines: {node: '>=18'}
cpu: [arm64]
os: [freebsd]
'@esbuild/freebsd-x64@0.25.9':
resolution: {integrity: sha512-mrKX6H/vOyo5v71YfXWJxLVxgy1kyt1MQaD8wZJgJfG4gq4DpQGpgTB74e5yBeQdyMTbgxp0YtNj7NuHN0PoZg==}
engines: {node: '>=18'}
cpu: [x64]
os: [freebsd]
'@esbuild/linux-arm64@0.25.9':
resolution: {integrity: sha512-BlB7bIcLT3G26urh5Dmse7fiLmLXnRlopw4s8DalgZ8ef79Jj4aUcYbk90g8iCa2467HX8SAIidbL7gsqXHdRw==}
engines: {node: '>=18'}
cpu: [arm64]
os: [linux]
'@esbuild/linux-arm@0.25.9':
resolution: {integrity: sha512-HBU2Xv78SMgaydBmdor38lg8YDnFKSARg1Q6AT0/y2ezUAKiZvc211RDFHlEZRFNRVhcMamiToo7bDx3VEOYQw==}
engines: {node: '>=18'}
cpu: [arm]
os: [linux]
'@esbuild/linux-ia32@0.25.9':
resolution: {integrity: sha512-e7S3MOJPZGp2QW6AK6+Ly81rC7oOSerQ+P8L0ta4FhVi+/j/v2yZzx5CqqDaWjtPFfYz21Vi1S0auHrap3Ma3A==}
engines: {node: '>=18'}
cpu: [ia32]
os: [linux]
'@esbuild/linux-loong64@0.25.9':
resolution: {integrity: sha512-Sbe10Bnn0oUAB2AalYztvGcK+o6YFFA/9829PhOCUS9vkJElXGdphz0A3DbMdP8gmKkqPmPcMJmJOrI3VYB1JQ==}
engines: {node: '>=18'}
cpu: [loong64]
os: [linux]
'@esbuild/linux-mips64el@0.25.9':
resolution: {integrity: sha512-YcM5br0mVyZw2jcQeLIkhWtKPeVfAerES5PvOzaDxVtIyZ2NUBZKNLjC5z3/fUlDgT6w89VsxP2qzNipOaaDyA==}
engines: {node: '>=18'}
cpu: [mips64el]
os: [linux]
'@esbuild/linux-ppc64@0.25.9':
resolution: {integrity: sha512-++0HQvasdo20JytyDpFvQtNrEsAgNG2CY1CLMwGXfFTKGBGQT3bOeLSYE2l1fYdvML5KUuwn9Z8L1EWe2tzs1w==}
engines: {node: '>=18'}
cpu: [ppc64]
os: [linux]
'@esbuild/linux-riscv64@0.25.9':
resolution: {integrity: sha512-uNIBa279Y3fkjV+2cUjx36xkx7eSjb8IvnL01eXUKXez/CBHNRw5ekCGMPM0BcmqBxBcdgUWuUXmVWwm4CH9kg==}
engines: {node: '>=18'}
cpu: [riscv64]
os: [linux]
'@esbuild/linux-s390x@0.25.9':
resolution: {integrity: sha512-Mfiphvp3MjC/lctb+7D287Xw1DGzqJPb/J2aHHcHxflUo+8tmN/6d4k6I2yFR7BVo5/g7x2Monq4+Yew0EHRIA==}
engines: {node: '>=18'}
cpu: [s390x]
os: [linux]
'@esbuild/linux-x64@0.25.9':
resolution: {integrity: sha512-iSwByxzRe48YVkmpbgoxVzn76BXjlYFXC7NvLYq+b+kDjyyk30J0JY47DIn8z1MO3K0oSl9fZoRmZPQI4Hklzg==}
engines: {node: '>=18'}
cpu: [x64]
os: [linux]
'@esbuild/netbsd-arm64@0.25.9':
resolution: {integrity: sha512-9jNJl6FqaUG+COdQMjSCGW4QiMHH88xWbvZ+kRVblZsWrkXlABuGdFJ1E9L7HK+T0Yqd4akKNa/lO0+jDxQD4Q==}
engines: {node: '>=18'}
cpu: [arm64]
os: [netbsd]
'@esbuild/netbsd-x64@0.25.9':
resolution: {integrity: sha512-RLLdkflmqRG8KanPGOU7Rpg829ZHu8nFy5Pqdi9U01VYtG9Y0zOG6Vr2z4/S+/3zIyOxiK6cCeYNWOFR9QP87g==}
engines: {node: '>=18'}
cpu: [x64]
os: [netbsd]
'@esbuild/openbsd-arm64@0.25.9':
resolution: {integrity: sha512-YaFBlPGeDasft5IIM+CQAhJAqS3St3nJzDEgsgFixcfZeyGPCd6eJBWzke5piZuZ7CtL656eOSYKk4Ls2C0FRQ==}
engines: {node: '>=18'}
cpu: [arm64]
os: [openbsd]
'@esbuild/openbsd-x64@0.25.9':
resolution: {integrity: sha512-1MkgTCuvMGWuqVtAvkpkXFmtL8XhWy+j4jaSO2wxfJtilVCi0ZE37b8uOdMItIHz4I6z1bWWtEX4CJwcKYLcuA==}
engines: {node: '>=18'}
cpu: [x64]
os: [openbsd]
'@esbuild/openharmony-arm64@0.25.9':
resolution: {integrity: sha512-4Xd0xNiMVXKh6Fa7HEJQbrpP3m3DDn43jKxMjxLLRjWnRsfxjORYJlXPO4JNcXtOyfajXorRKY9NkOpTHptErg==}
engines: {node: '>=18'}
cpu: [arm64]
os: [openharmony]
'@esbuild/sunos-x64@0.25.9':
resolution: {integrity: sha512-WjH4s6hzo00nNezhp3wFIAfmGZ8U7KtrJNlFMRKxiI9mxEK1scOMAaa9i4crUtu+tBr+0IN6JCuAcSBJZfnphw==}
engines: {node: '>=18'}
cpu: [x64]
os: [sunos]
'@esbuild/win32-arm64@0.25.9':
resolution: {integrity: sha512-mGFrVJHmZiRqmP8xFOc6b84/7xa5y5YvR1x8djzXpJBSv/UsNK6aqec+6JDjConTgvvQefdGhFDAs2DLAds6gQ==}
engines: {node: '>=18'}
cpu: [arm64]
os: [win32]
'@esbuild/win32-ia32@0.25.9':
resolution: {integrity: sha512-b33gLVU2k11nVx1OhX3C8QQP6UHQK4ZtN56oFWvVXvz2VkDoe6fbG8TOgHFxEvqeqohmRnIHe5A1+HADk4OQww==}
engines: {node: '>=18'}
cpu: [ia32]
os: [win32]
'@esbuild/win32-x64@0.25.9':
resolution: {integrity: sha512-PPOl1mi6lpLNQxnGoyAfschAodRFYXJ+9fs6WHXz7CSWKbOqiMZsubC+BQsVKuul+3vKLuwTHsS2c2y9EoKwxQ==}
engines: {node: '>=18'}
cpu: [x64]
os: [win32]
'@fastify/busboy@2.1.1':
resolution: {integrity: sha512-vBZP4NlzfOlerQTnba4aqZoMhE/a9HY7HRqoOPaETQcSQuWEIyZMHGfVu6w9wGtGK5fED5qRs2DteVCjOH60sA==}
engines: {node: '>=14'}
'@types/node@20.19.11':
resolution: {integrity: sha512-uug3FEEGv0r+jrecvUUpbY8lLisvIjg6AAic6a2bSP5OEOLeJsDSnvhCDov7ipFFMXS3orMpzlmi0ZcuGkBbow==}
esbuild@0.25.9:
resolution: {integrity: sha512-CRbODhYyQx3qp7ZEwzxOk4JBqmD/seJrzPa/cGjY1VtIn5E09Oi9/dB4JwctnfZ8Q8iT7rioVv5k/FNT/uf54g==}
engines: {node: '>=18'}
hasBin: true
husky@9.1.7:
resolution: {integrity: sha512-5gs5ytaNjBrh5Ow3zrvdUUY+0VxIuWVL4i9irt6friV+BqdCfmV11CQTWMiBYWHbXhco+J1kHfTOUkePhCDvMA==}
engines: {node: '>=18'}
hasBin: true
tunnel@0.0.6:
resolution: {integrity: sha512-1h/Lnq9yajKY2PEbBadPXj3VxsDDu844OnaAo52UVmIzIvwwtBPIuNvkjuzBlTWpfJyUbG3ez0KSBibQkj4ojg==}
engines: {node: '>=0.6.11 <=0.7.0 || >=0.7.3'}
typescript@5.9.2:
resolution: {integrity: sha512-CWBzXQrc/qOkhidw1OzBTQuYRbfyxDXJMVJ1XNwUHGROVmuaeiEm3OslpZ1RV96d7SKKjZKrSJu3+t/xlw3R9A==}
engines: {node: '>=14.17'}
hasBin: true
undici-types@6.21.0:
resolution: {integrity: sha512-iwDZqg0QAGrg9Rav5H4n0M64c3mkR59cJ6wQp+7C4nI0gsmExaedaYLNO44eT4AtBBwjbTiGPMlt2Md0T9H9JQ==}
undici@5.29.0:
resolution: {integrity: sha512-raqeBD6NQK4SkWhQzeYKd1KmIG6dllBOTt55Rmkt4HtI9mwdWtJljnrXjAFUBLTSN67HWrOIZ3EPF4kjUw80Bg==}
engines: {node: '>=14.0'}
snapshots:
'@actions/core@1.11.1':
dependencies:
'@actions/exec': 1.1.1
'@actions/http-client': 2.2.3
'@actions/exec@1.1.1':
dependencies:
'@actions/io': 1.1.3
'@actions/http-client@2.2.3':
dependencies:
tunnel: 0.0.6
undici: 5.29.0
'@actions/io@1.1.3': {}
'@esbuild/aix-ppc64@0.25.9':
optional: true
'@esbuild/android-arm64@0.25.9':
optional: true
'@esbuild/android-arm@0.25.9':
optional: true
'@esbuild/android-x64@0.25.9':
optional: true
'@esbuild/darwin-arm64@0.25.9':
optional: true
'@esbuild/darwin-x64@0.25.9':
optional: true
'@esbuild/freebsd-arm64@0.25.9':
optional: true
'@esbuild/freebsd-x64@0.25.9':
optional: true
'@esbuild/linux-arm64@0.25.9':
optional: true
'@esbuild/linux-arm@0.25.9':
optional: true
'@esbuild/linux-ia32@0.25.9':
optional: true
'@esbuild/linux-loong64@0.25.9':
optional: true
'@esbuild/linux-mips64el@0.25.9':
optional: true
'@esbuild/linux-ppc64@0.25.9':
optional: true
'@esbuild/linux-riscv64@0.25.9':
optional: true
'@esbuild/linux-s390x@0.25.9':
optional: true
'@esbuild/linux-x64@0.25.9':
optional: true
'@esbuild/netbsd-arm64@0.25.9':
optional: true
'@esbuild/netbsd-x64@0.25.9':
optional: true
'@esbuild/openbsd-arm64@0.25.9':
optional: true
'@esbuild/openbsd-x64@0.25.9':
optional: true
'@esbuild/openharmony-arm64@0.25.9':
optional: true
'@esbuild/sunos-x64@0.25.9':
optional: true
'@esbuild/win32-arm64@0.25.9':
optional: true
'@esbuild/win32-ia32@0.25.9':
optional: true
'@esbuild/win32-x64@0.25.9':
optional: true
'@fastify/busboy@2.1.1': {}
'@types/node@20.19.11':
dependencies:
undici-types: 6.21.0
esbuild@0.25.9:
optionalDependencies:
'@esbuild/aix-ppc64': 0.25.9
'@esbuild/android-arm': 0.25.9
'@esbuild/android-arm64': 0.25.9
'@esbuild/android-x64': 0.25.9
'@esbuild/darwin-arm64': 0.25.9
'@esbuild/darwin-x64': 0.25.9
'@esbuild/freebsd-arm64': 0.25.9
'@esbuild/freebsd-x64': 0.25.9
'@esbuild/linux-arm': 0.25.9
'@esbuild/linux-arm64': 0.25.9
'@esbuild/linux-ia32': 0.25.9
'@esbuild/linux-loong64': 0.25.9
'@esbuild/linux-mips64el': 0.25.9
'@esbuild/linux-ppc64': 0.25.9
'@esbuild/linux-riscv64': 0.25.9
'@esbuild/linux-s390x': 0.25.9
'@esbuild/linux-x64': 0.25.9
'@esbuild/netbsd-arm64': 0.25.9
'@esbuild/netbsd-x64': 0.25.9
'@esbuild/openbsd-arm64': 0.25.9
'@esbuild/openbsd-x64': 0.25.9
'@esbuild/openharmony-arm64': 0.25.9
'@esbuild/sunos-x64': 0.25.9
'@esbuild/win32-arm64': 0.25.9
'@esbuild/win32-ia32': 0.25.9
'@esbuild/win32-x64': 0.25.9
husky@9.1.7: {}
tunnel@0.0.6: {}
typescript@5.9.2: {}
undici-types@6.21.0: {}
undici@5.29.0:
dependencies:
'@fastify/busboy': 2.1.1
+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"
}
}
}
+23 -39
View File
@@ -1,45 +1,29 @@
{
// Visit https://aka.ms/tsconfig to read more about this file
"compilerOptions": {
// File Layout
// "rootDir": "./src",
// "outDir": "./dist",
// Environment Settings
// See also https://aka.ms/tsconfig/module
"module": "esnext",
"moduleResolution": "bundler",
"target": "esnext",
"types": [],
// For nodejs:
// "lib": ["esnext"],
// "types": ["node"],
// and npm install -D @types/node
// Other Outputs
"sourceMap": true,
"declaration": true,
"declarationMap": true,
// Stricter Typechecking Options
"noUncheckedIndexedAccess": true,
"exactOptionalPropertyTypes": true,
// Style Options
// "noImplicitReturns": true,
// "noImplicitOverride": true,
// "noUnusedLocals": true,
// "noUnusedParameters": true,
// "noFallthroughCasesInSwitch": true,
// "noPropertyAccessFromIndexSignature": true,
// Recommended Options
"strict": true,
"jsx": "react-jsx",
"verbatimModuleSyntax": true,
"isolatedModules": true,
"noUncheckedSideEffectImports": true,
// Environment setup & latest features
"lib": ["ESNext"],
"target": "ESNext",
"module": "Preserve",
"moduleDetection": "force",
"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[];
}
-108
View File
@@ -1,108 +0,0 @@
import { execSync } from "node:child_process";
import { existsSync, readFileSync } from "node:fs";
import { dirname, join } from "node:path";
import { fileURLToPath } from "node:url";
import { config, parse } from "dotenv";
const __filename = fileURLToPath(import.meta.url);
const __dirname = dirname(__filename);
export function runAct(prompt: string): void {
// First, ensure the scratch repo is cloned
const tempDir = join(__dirname, "..", ".temp");
// Check if .temp exists and either reset it or clone it
if (existsSync(tempDir)) {
console.log("📦 Resetting existing .temp repository...");
execSync("git reset --hard HEAD && git clean -fd", {
cwd: tempDir,
stdio: "inherit",
});
} else {
console.log("📦 Cloning pullfrogai/scratch into .temp...");
const repoUrl = "git@github.com:pullfrogai/scratch.git";
execSync(`git clone ${repoUrl} ${tempDir}`, { stdio: "inherit" });
}
const workflowPath = join(tempDir, ".github", "workflows", "pullfrog.yml");
const envPath = join(__dirname, "..", "..", ".env");
// Load environment variables into process
config({ path: envPath });
// Parse environment variables from .env file to get keys
let envVars: string[] = [];
try {
const content = readFileSync(envPath, "utf8");
const parsed = parse(content);
envVars = Object.keys(parsed);
} catch (error) {
console.warn(
`Warning: Could not read .env file: ${(error as Error).message}`,
);
}
// Build fresh bundles with esbuild
const actionPath = join(__dirname, "..");
console.log("🔨 Building fresh bundles with esbuild...");
execSync("node esbuild.config.js", {
cwd: actionPath,
stdio: "inherit",
});
// Create minimal dist for act (avoids pnpm symlink issues)
const distPath = join(actionPath, ".act-dist");
console.log("📦 Creating minimal distribution for act...");
execSync(`rm -rf "${distPath}" && mkdir -p "${distPath}"`, { shell: true });
// Copy only necessary files (bundled, no node_modules needed)
["action.yml", "entry.cjs", "index.cjs", "package.json"].forEach((file) => {
const src = join(actionPath, file);
if (existsSync(src)) {
execSync(`cp "${src}" "${distPath}"`);
}
});
try {
// Build the act command with input directly
// Properly escape the prompt for shell
const escapedPrompt = prompt.replace(/'/g, "'\\''");
const actCommandParts = [
"act",
"workflow_dispatch",
"-W",
workflowPath,
"--input",
`prompt='${escapedPrompt}'`,
"--local-repository",
`pullfrog/pullfrog@v0=${distPath}`, // Use minimal dist without symlinks
];
// Add all environment variables as secrets (without values)
envVars.forEach((key) => {
actCommandParts.push("-s", key);
});
const actCommand = actCommandParts.join(" ");
console.log("🚀 Running act with prompt:");
console.log("─".repeat(50));
console.log(prompt);
console.log("─".repeat(50));
console.log("");
// Execute act
execSync(actCommand, {
stdio: "inherit",
cwd: join(__dirname, "..", ".."),
});
// Clean up
execSync(`rm -rf "${distPath}"`);
} catch (error) {
// Clean up on error
execSync(`rm -rf "${distPath}"`);
console.error("❌ Act execution failed:", (error as Error).message);
process.exit(1);
}
}
-13
View File
@@ -1,13 +0,0 @@
import { mkdtempSync, writeFileSync } from "node:fs";
import { tmpdir } from "node:os";
import { join } from "node:path";
/**
* Create a temporary file with the given content
*/
export function createTempFile(content: string, filename = "temp.txt"): string {
const tempDir = mkdtempSync(join(tmpdir(), "pullfrog-"));
const filePath = join(tempDir, filename);
writeFileSync(filePath, content);
return filePath;
}
-3
View File
@@ -1,3 +0,0 @@
export * from "./files";
export * from "./subprocess";
export * from "./table";
-117
View File
@@ -1,117 +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;
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, onStdout, onStderr } = options;
const startTime = Date.now();
let stdoutBuffer = "";
let stderrBuffer = "";
return new Promise((resolve, reject) => {
// Spawn the child process
const child = nodeSpawn(cmd, args, {
env: env ? { ...process.env, ...env } : process.env,
stdio: ["pipe", "pipe", "pipe"],
});
// Set up timeout if specified
let timeoutId: NodeJS.Timeout | undefined;
let isTimedOut = false;
if (timeout) {
timeoutId = setTimeout(() => {
isTimedOut = true;
child.kill("SIGTERM");
// If SIGTERM doesn't work, use SIGKILL after 5 seconds
setTimeout(() => {
if (!child.killed) {
child.kill("SIGKILL");
}
}, 5000);
}, timeout);
}
// Handle stdout streaming
if (child.stdout) {
child.stdout.on("data", (data: Buffer) => {
const chunk = data.toString();
stdoutBuffer += chunk;
onStdout?.(chunk);
});
}
// Handle stderr streaming
if (child.stderr) {
child.stderr.on("data", (data: Buffer) => {
const chunk = data.toString();
stderrBuffer += chunk;
onStderr?.(chunk);
});
}
// Handle process completion
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,
});
});
// Handle process errors
child.on("error", (error) => {
const durationMs = Date.now() - startTime;
if (timeoutId) {
clearTimeout(timeoutId);
}
// Still return buffered output even on error
resolve({
stdout: stdoutBuffer,
stderr: stderrBuffer,
exitCode: 1,
durationMs,
});
});
// Send input if provided
if (input && child.stdin) {
child.stdin.write(input);
child.stdin.end();
}
});
}
-172
View File
@@ -1,172 +0,0 @@
import { table } from "table";
/**
* Print a formatted table with consistent styling
* @param rows - Array of string arrays representing table rows
* @param options - Optional table configuration
*/
export function tableString(
rows: string[][],
options?: {
title?: string;
indent?: string;
drawHorizontalLine?: (lineIndex: number, rowCount: number) => boolean;
}
): string {
const {
title,
indent = "",
drawHorizontalLine = (lineIndex: number, rowCount: number) => {
return lineIndex === 0 || (options?.title && lineIndex === 1) || lineIndex === rowCount;
},
} = options || {};
if (options?.title) {
rows.unshift([options.title]);
}
const tableOutput = table(rows, {
drawHorizontalLine,
border: {
topBody: ``,
topJoin: ``,
topLeft: ``,
topRight: ``,
bottomBody: ``,
bottomJoin: ``,
bottomLeft: ``,
bottomRight: ``,
bodyLeft: ``,
bodyRight: ``,
bodyJoin: ``,
joinBody: ``,
joinLeft: ``,
joinRight: ``,
joinJoin: ``,
},
});
const indentedOutput = tableOutput.split("\n").join(`\n${indent}`).trim();
return `${indent}${indentedOutput}`;
}
/**
* Print a multi-line string in a formatted box with text wrapping
* @param text - The text to display
* @param options - Optional configuration for the box
*/
export function boxString(
text: string,
options?: {
title?: string;
maxWidth?: number;
indent?: string;
padding?: number;
}
): string {
const { title, maxWidth = 80, indent = "", padding = 1 } = options || {};
// Clean up the text and split into lines
const lines = text.trim().split("\n");
// Word wrap each line to fit within maxWidth
const wrappedLines: string[] = [];
for (const line of lines) {
if (line.length <= maxWidth - padding * 2) {
wrappedLines.push(line);
} else {
// Word wrap the line
const words = line.split(" ");
let currentLine = "";
for (const word of words) {
const testLine = currentLine ? `${currentLine} ${word}` : word;
if (testLine.length <= maxWidth - padding * 2) {
currentLine = testLine;
} else {
if (currentLine) {
wrappedLines.push(currentLine);
currentLine = word;
} else {
// Word is too long, break it
wrappedLines.push(word.substring(0, maxWidth - padding * 2));
currentLine = word.substring(maxWidth - padding * 2);
}
}
}
if (currentLine) {
wrappedLines.push(currentLine);
}
}
}
// Find the maximum line length for box sizing
const maxLineLength = Math.max(...wrappedLines.map((line) => line.length));
const boxWidth = maxLineLength + padding * 2;
// Create the box
const topBorder = "┌" + "─".repeat(boxWidth) + "┐";
const bottomBorder = "└" + "─".repeat(boxWidth) + "┘";
const sideBorder = "│";
let result = "";
// Add title if provided
if (title) {
const titleLine = ` ${title} `;
const titlePadding = Math.max(0, boxWidth - titleLine.length);
result += `${indent}${titleLine}${"─".repeat(titlePadding)}\n`;
}
// Add top border (or title border)
if (!title) {
result += `${indent}${topBorder}\n`;
}
// Add content lines
for (const line of wrappedLines) {
const paddedLine = line.padEnd(maxLineLength);
result += `${indent}${sideBorder}${" ".repeat(padding)}${paddedLine}${" ".repeat(padding)}${sideBorder}\n`;
}
// Add bottom border
result += `${indent}${bottomBorder}`;
return result;
}
// /**
// * Create a simple two-column table for displaying key-value pairs
// * @param data - Array of [key, value] pairs
// * @param title - Optional table title
// * @param indent - Optional indentation string
// */
// export function printKeyValueTable(
// data: [string, string][],
// title?: string,
// indent?: string
// ): void {
// const rows: string[][] = [["Key", "Value"], ...data];
// const options: Parameters<typeof printTable>[1] = {};
// if (title !== undefined) options.title = title;
// if (indent !== undefined) options.indent = indent;
// printTable(rows, options);
// }
// /**
// * Create a path resolution table (specific use case)
// * @param pathData - Array of [location, resolvedPath] pairs
// * @param indent - Optional indentation string
// */
// export function printPathTable(pathData: [string, string][], indent?: string): void {
// const rows: string[][] = [["Location", "Resolved path"], ...pathData];
// const options: Parameters<typeof printTable>[1] = {};
// if (indent !== undefined) options.indent = indent;
// printTable(rows, options);
// }