Compare commits
71 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| cacf9674c4 | |||
| f73260e3e6 | |||
| 3ddd6db7ca | |||
| 68499340e4 | |||
| acb06634be | |||
| 681e08557c | |||
| 15a7154aea | |||
| 434458a068 | |||
| 193954fdd7 | |||
| ab2d762658 | |||
| 876663cd1a | |||
| b2badf6d16 | |||
| 05fb2065b2 | |||
| 2042a5bf98 | |||
| 12da2b770c | |||
| a26ada9839 | |||
| 1328894afd | |||
| 85731f8360 | |||
| 1922352d86 | |||
| c0f31415a3 | |||
| 706ce04895 | |||
| 09be8e3068 | |||
| c6c1210fa0 | |||
| 0368512b9e | |||
| 9fb6135fd2 | |||
| bb78e5f94b | |||
| c668578c6f | |||
| 7f1566d9c2 | |||
| dd482566c2 | |||
| 57029c32a3 | |||
| 757d336475 | |||
| d03debab4b | |||
| a05829f781 | |||
| c8ba7940e3 | |||
| 710fdd0fa4 | |||
| 4f5ee28b8a | |||
| 806458b95a | |||
| 2c856e3337 | |||
| a93c34e61b | |||
| cd20491d22 | |||
| 1a6ce6728c | |||
| 3b39f2c8d8 | |||
| ec0eeb1d18 | |||
| 8ef805b9fc | |||
| 6e93fd9a72 | |||
| 9567d84442 | |||
| d79564db5e | |||
| a7a0e87fd8 | |||
| 7050b8de75 | |||
| 2fc3ddee16 | |||
| 284d9733dd | |||
| 94e2b5f6e0 | |||
| 03810d574e | |||
| f52e94c612 | |||
| 9444a0e208 | |||
| 2296060d04 | |||
| 458bfe18a0 | |||
| 4cfb9b5008 | |||
| 06542e382a | |||
| bcdf6ab5fb | |||
| 314f669f10 | |||
| a24275e21b | |||
| 872e620342 | |||
| 6d9c6fd2b1 | |||
| 008021df1c | |||
| d6bc0fdd64 | |||
| 8fd0328109 | |||
| a1f87ce118 | |||
| 3e7122611c | |||
| 9459803aaa | |||
| f74a75cfac |
@@ -29,12 +29,12 @@ jobs:
|
||||
- name: Setup Node.js
|
||||
uses: actions/setup-node@v4
|
||||
with:
|
||||
node-version: "20"
|
||||
node-version: "24"
|
||||
cache: "pnpm"
|
||||
registry-url: "https://registry.npmjs.org"
|
||||
|
||||
- name: Install dependencies
|
||||
run: pnpm install --no-frozen-lockfile
|
||||
run: pnpm install --frozen-lockfile
|
||||
|
||||
- name: Get package version
|
||||
id: version
|
||||
@@ -60,21 +60,6 @@ jobs:
|
||||
echo "✅ Tag ${{ steps.version.outputs.tag }} does not exist - will create release"
|
||||
fi
|
||||
|
||||
- name: Verify built files are up to date
|
||||
if: steps.check_tag.outputs.exists == 'false'
|
||||
run: |
|
||||
# Check if there are any uncommitted changes
|
||||
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: Build for npm with zshy
|
||||
if: steps.check_tag.outputs.exists == 'false'
|
||||
run: pnpm build:npm
|
||||
|
||||
- name: Create and push tags
|
||||
if: steps.check_tag.outputs.exists == 'false'
|
||||
run: |
|
||||
|
||||
@@ -1,6 +0,0 @@
|
||||
# Build the action before committing
|
||||
echo "🔨 Building action..."
|
||||
npm run build
|
||||
|
||||
# Add the built files to the commit
|
||||
git add entry.cjs
|
||||
@@ -12,69 +12,13 @@ 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
|
||||
pnpm play # Uses fixtures/play.txt
|
||||
```
|
||||
- Clones the scratch repository to `.temp`
|
||||
- Runs Claude Code directly on your machine
|
||||
- Fast iteration for development
|
||||
|
||||
### Docker Mode (--act flag)
|
||||
```bash
|
||||
npm run play -- --act # Uses fixtures/play.txt
|
||||
npm run play fixtures/simple.txt -- --act # Custom prompt file
|
||||
```
|
||||
- Builds fresh bundles with esbuild
|
||||
- Creates minimal distribution without node_modules
|
||||
- Runs in Docker container via `act`
|
||||
- Simulates GitHub Actions environment
|
||||
|
||||
### Prompt Files
|
||||
|
||||
Supports `.txt`, `.json`, and `.ts` files:
|
||||
```bash
|
||||
npm run play prompt.txt # Plain text prompt
|
||||
npm run play config.json # JSON configuration
|
||||
npm run play dynamic.ts # TypeScript with default export
|
||||
```
|
||||
|
||||
## Building
|
||||
|
||||
```bash
|
||||
pnpm build # Production build (bundles & removes node_modules)
|
||||
pnpm build:dev # Development build (keeps node_modules)
|
||||
pnpm dev # Watch mode
|
||||
```
|
||||
|
||||
The action is bundled into `entry.cjs` with all dependencies included, eliminating runtime dependency on node_modules.
|
||||
|
||||
## Environment Variables
|
||||
|
||||
Create `.env` in `/action`:
|
||||
|
||||
```bash
|
||||
ANTHROPIC_API_KEY=sk-ant-api03-xxxxxxxxxxxxxxxxxxxxxxxxxxxxxxx # Claude API key
|
||||
```
|
||||
|
||||
## Architecture
|
||||
|
||||
- **entry.cjs**: Bundled action entry point (self-contained)
|
||||
- **agents/**: Agent implementations (Claude, etc.)
|
||||
- **utils/**: Utilities for subprocess, act, and formatting
|
||||
- **fixtures/**: Test prompt files
|
||||
|
||||
## Why No node_modules?
|
||||
|
||||
pnpm uses symlinks that cause "invalid symlink" errors when `act` copies the action to Docker. Our solution:
|
||||
1. Bundle everything into `entry.cjs`
|
||||
2. Remove node_modules after building
|
||||
3. Create minimal `.act-dist` for Docker testing
|
||||
+24
-9
@@ -10,17 +10,32 @@ inputs:
|
||||
anthropic_api_key:
|
||||
description: "Anthropic API key for Claude Code authentication"
|
||||
required: false
|
||||
github_token:
|
||||
description: "GitHub token for repository access"
|
||||
required: false
|
||||
github_installation_token:
|
||||
description: "GitHub App installation token"
|
||||
required: false
|
||||
|
||||
runs:
|
||||
using: "node20"
|
||||
main: "entry.cjs"
|
||||
using: "composite"
|
||||
steps:
|
||||
- name: Checkout code
|
||||
uses: actions/checkout@v4
|
||||
with:
|
||||
fetch-depth: 1
|
||||
- name: Setup pnpm
|
||||
uses: pnpm/action-setup@v4
|
||||
- name: Setup Node.js 24
|
||||
uses: actions/setup-node@v4
|
||||
with:
|
||||
node-version: "24"
|
||||
- name: Install dependencies
|
||||
run: pnpm install
|
||||
shell: bash
|
||||
working-directory: ${{ github.action_path }}
|
||||
- name: Run agent
|
||||
run: |
|
||||
cd $GITHUB_WORKSPACE
|
||||
node ${{ github.action_path }}/entry.ts
|
||||
shell: bash
|
||||
env:
|
||||
INPUTS_JSON: ${{ toJSON(inputs) }}
|
||||
|
||||
branding:
|
||||
icon: "code"
|
||||
color: "orange"
|
||||
color: "green"
|
||||
|
||||
+95
-392
@@ -1,407 +1,110 @@
|
||||
import { access, constants } from "node:fs/promises";
|
||||
import * as core from "@actions/core";
|
||||
import { createMcpConfig } from "../mcp/config.ts";
|
||||
import { spawn } from "../utils/subprocess.ts";
|
||||
import { boxString, tableString } from "../utils/table.ts";
|
||||
import type { Agent, AgentConfig, AgentResult } from "./types.ts";
|
||||
import { query, type SDKMessage } from "@anthropic-ai/claude-agent-sdk";
|
||||
import { log } from "../utils/cli.ts";
|
||||
import { type Agent, instructions } from "./shared.ts";
|
||||
|
||||
/**
|
||||
* Claude Code agent implementation
|
||||
*/
|
||||
export class ClaudeAgent implements Agent {
|
||||
private apiKey: string;
|
||||
public runStats = {
|
||||
toolsUsed: 0,
|
||||
turns: 0,
|
||||
startTime: 0,
|
||||
};
|
||||
export const claude: Agent = {
|
||||
run: async ({ prompt, mcpServers, apiKey }) => {
|
||||
process.env.ANTHROPIC_API_KEY = apiKey;
|
||||
|
||||
// $: ExecaMethod;
|
||||
const queryInstance = query({
|
||||
prompt: `${instructions}\n\n${prompt}`,
|
||||
options: {
|
||||
permissionMode: "bypassPermissions",
|
||||
mcpServers,
|
||||
},
|
||||
});
|
||||
|
||||
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;
|
||||
// Stream the results
|
||||
for await (const message of queryInstance) {
|
||||
const handler = messageHandlers[message.type];
|
||||
await handler(message as never);
|
||||
}
|
||||
|
||||
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: () => {
|
||||
// no logs
|
||||
// process.stdout.write(chunk)
|
||||
},
|
||||
onStderr: (chunk) => process.stderr.write(chunk),
|
||||
});
|
||||
return {
|
||||
success: true,
|
||||
output: "",
|
||||
};
|
||||
},
|
||||
};
|
||||
|
||||
if (result.exitCode !== 0) {
|
||||
throw new Error(`Installation failed with exit code ${result.exitCode}: ${result.stderr}`);
|
||||
}
|
||||
type SDKMessageType = SDKMessage["type"];
|
||||
|
||||
core.info("Claude Code installed successfully");
|
||||
} catch (error) {
|
||||
throw new Error(`Failed to install Claude Code: ${error}`);
|
||||
}
|
||||
}
|
||||
type SDKMessageHandler<type extends SDKMessageType = SDKMessageType> = (
|
||||
data: Extract<SDKMessage, { type: type }>
|
||||
) => void | Promise<void>;
|
||||
|
||||
/**
|
||||
* Execute Claude Code with the given prompt
|
||||
*/
|
||||
async execute(prompt: string): Promise<AgentResult> {
|
||||
core.info("Running Claude Code...");
|
||||
// printTable([[prompt]]);
|
||||
type SDKMessageHandlers = {
|
||||
[type in SDKMessageType]: SDKMessageHandler<type>;
|
||||
};
|
||||
|
||||
try {
|
||||
// Execute Claude Code with the prompt directly using proper headless mode
|
||||
// core.info(`Executing Claude Code with prompt: ${prompt.substring(0, 100)}...`);
|
||||
const messageHandlers: SDKMessageHandlers = {
|
||||
assistant: (data) => {
|
||||
if (data.message?.content) {
|
||||
for (const content of data.message.content) {
|
||||
if (content.type === "text" && content.text?.trim()) {
|
||||
log.box(content.text.trim(), { title: "Claude" });
|
||||
} else if (content.type === "tool_use") {
|
||||
log.info(`→ ${content.name}`);
|
||||
|
||||
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",
|
||||
"bypassPermissions",
|
||||
];
|
||||
|
||||
// Add MCP configuration if GitHub credentials are available
|
||||
if (
|
||||
process.env.GITHUB_INSTALLATION_TOKEN &&
|
||||
process.env.REPO_OWNER &&
|
||||
process.env.REPO_NAME
|
||||
) {
|
||||
const mcpConfig = createMcpConfig(
|
||||
process.env.GITHUB_INSTALLATION_TOKEN,
|
||||
process.env.REPO_OWNER,
|
||||
process.env.REPO_NAME
|
||||
);
|
||||
console.log("📋 MCP Config:", mcpConfig);
|
||||
args.push("--mcp-config", mcpConfig);
|
||||
}
|
||||
|
||||
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}`);
|
||||
if (content.input) {
|
||||
const input = content.input as any;
|
||||
if (input.description) log.info(` └─ ${input.description}`);
|
||||
if (input.command) log.info(` └─ command: ${input.command}`);
|
||||
if (input.file_path) log.info(` └─ file: ${input.file_path}`);
|
||||
if (input.content) {
|
||||
const preview =
|
||||
input.content.length > 100
|
||||
? `${input.content.substring(0, 100)}...`
|
||||
: input.content;
|
||||
log.info(` └─ content: ${preview}`);
|
||||
}
|
||||
}
|
||||
}
|
||||
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.
|
||||
}
|
||||
if (input.query) log.info(` └─ query: ${input.query}`);
|
||||
if (input.pattern) log.info(` └─ pattern: ${input.pattern}`);
|
||||
if (input.url) log.info(` └─ url: ${input.url}`);
|
||||
if (input.edits && Array.isArray(input.edits)) {
|
||||
log.info(` └─ edits: ${input.edits.length} changes`);
|
||||
input.edits.forEach((edit: any, index: number) => {
|
||||
if (edit.file_path) log.info(` ${index + 1}. ${edit.file_path}`);
|
||||
});
|
||||
}
|
||||
if (input.task) log.info(` └─ task: ${input.task}`);
|
||||
if (input.bash_command) log.info(` └─ bash_command: ${input.bash_command}`);
|
||||
}
|
||||
}
|
||||
break;
|
||||
|
||||
case "result":
|
||||
if (parsedChunk.subtype === "success") {
|
||||
// Claude already prints something almost identical to this, so skip for now
|
||||
// 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)}...`);
|
||||
}
|
||||
}
|
||||
},
|
||||
user: (data) => {
|
||||
if (data.message?.content) {
|
||||
for (const content of data.message.content) {
|
||||
if (content.type === "tool_result" && content.is_error) {
|
||||
log.warning(`Tool error: ${content.content}`);
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
result: async (data) => {
|
||||
if (data.subtype === "success") {
|
||||
await log.summaryTable([
|
||||
[
|
||||
{ data: "Cost", header: true },
|
||||
{ data: "Input Tokens", header: true },
|
||||
{ data: "Output Tokens", header: true },
|
||||
],
|
||||
[
|
||||
`$${data.total_cost_usd?.toFixed(4) || "0.0000"}`,
|
||||
String(data.usage?.input_tokens || 0),
|
||||
String(data.usage?.output_tokens || 0),
|
||||
],
|
||||
]);
|
||||
} else if (data.subtype === "error_max_turns") {
|
||||
log.error(`Max turns reached: ${JSON.stringify(data)}`);
|
||||
} else if (data.subtype === "error_during_execution") {
|
||||
log.error(`Execution error: ${JSON.stringify(data)}`);
|
||||
} else {
|
||||
log.error(`Failed: ${JSON.stringify(data)}`);
|
||||
}
|
||||
},
|
||||
system: () => {},
|
||||
stream_event: () => {},
|
||||
};
|
||||
|
||||
@@ -0,0 +1,63 @@
|
||||
import type { McpServerConfig } from "@anthropic-ai/claude-agent-sdk";
|
||||
import { ghPullfrogMcpName } from "../mcp/config.ts";
|
||||
|
||||
/**
|
||||
* Result returned by agent execution
|
||||
*/
|
||||
export interface AgentResult {
|
||||
success: boolean;
|
||||
output?: string;
|
||||
error?: string;
|
||||
metadata?: Record<string, unknown>;
|
||||
}
|
||||
|
||||
/**
|
||||
* Configuration for agent creation
|
||||
*/
|
||||
export interface AgentConfig {
|
||||
apiKey: string;
|
||||
githubInstallationToken: string;
|
||||
prompt: string;
|
||||
mcpServers: Record<string, McpServerConfig>;
|
||||
}
|
||||
|
||||
export type Agent = {
|
||||
run: (config: AgentConfig) => Promise<AgentResult>;
|
||||
};
|
||||
|
||||
export const instructions = `- use the ${ghPullfrogMcpName} MCP server to interact with github
|
||||
- if ${ghPullfrogMcpName} is not available or doesn't include the functionality you need, describe why and bail
|
||||
- do not under any circumstances use the gh cli
|
||||
- if prompted by a comment to respond to create a new issue, pr or anything else, after succeeding,
|
||||
also respond to the original comment with a very brief message containing a link to it
|
||||
- mode selection: choose the appropriate mode based on the prompt payload:
|
||||
- choose "plan mode" if the prompt asks to:
|
||||
- create a plan, break down tasks, outline steps, or analyze requirements
|
||||
- understand the scope of work before implementation
|
||||
- provide a todo list or task breakdown
|
||||
- choose "implement" if the prompt asks to:
|
||||
- implement, build, create, or develop code changes
|
||||
- make specific changes to files or features
|
||||
- execute a plan that was previously created
|
||||
- the prompt includes specific implementation details or requirements
|
||||
- choose "review" if the prompt asks to:
|
||||
- review code, PR, or implementation
|
||||
- provide feedback, suggestions, or identify issues
|
||||
- check code quality, style, or correctness
|
||||
- once you've chosen a mode, follow its associated prompts carefully
|
||||
- when prompted directly (e.g., via issue comment or PR comment):
|
||||
(1) start by creating a single response comment using mcp__${ghPullfrogMcpName}__create_issue_comment
|
||||
- the initial comment should say something like "I'll do {summary of request}" where you summarize what was requested
|
||||
- save the commentId returned from this initial comment creation
|
||||
(2) use mcp__${ghPullfrogMcpName}__edit_issue_comment to progressively update that same comment as you make progress
|
||||
- update the comment with current status, completed tasks, and any relevant information
|
||||
- continue updating the same comment throughout the planning/implementation process
|
||||
(3) create_issue_comment should only be used once initially - all subsequent updates must use edit_issue_comment with the saved commentId
|
||||
- if prompted to review a PR:
|
||||
(1) get PR info with mcp__${ghPullfrogMcpName}__get_pull_request (this automatically prepares the repository by fetching and checking out the PR branch)
|
||||
(2) view diff: git diff origin/<base>...origin/<head> (use line numbers from this for inline comments)
|
||||
(3) read files from the checked-out PR branch to understand the implementation
|
||||
(4) when submitting review: use the 'comments' array for ALL specific code issues - include the file path and line position from the diff
|
||||
(5) only use the 'body' field for a brief summary (1-2 sentences) or for feedback that doesn't apply to a specific code location
|
||||
replace <base> and <head> with 'base' and 'head' from the PR info
|
||||
`;
|
||||
@@ -1,34 +0,0 @@
|
||||
/**
|
||||
* Standard interface for all Pullfrog agents
|
||||
*/
|
||||
export interface Agent {
|
||||
/**
|
||||
* Install the agent and any required dependencies
|
||||
*/
|
||||
install(): Promise<void>;
|
||||
|
||||
/**
|
||||
* Execute the agent with the given prompt
|
||||
* @param prompt The prompt to send to the agent
|
||||
* @param options Additional options specific to the agent
|
||||
*/
|
||||
execute(prompt: string, options?: Record<string, any>): Promise<AgentResult>;
|
||||
}
|
||||
|
||||
/**
|
||||
* Result returned by agent execution
|
||||
*/
|
||||
export interface AgentResult {
|
||||
success: boolean;
|
||||
output?: string;
|
||||
error?: string;
|
||||
metadata?: Record<string, any>;
|
||||
}
|
||||
|
||||
/**
|
||||
* Configuration for agent creation
|
||||
*/
|
||||
export interface AgentConfig {
|
||||
apiKey?: string;
|
||||
[key: string]: any;
|
||||
}
|
||||
@@ -2,52 +2,27 @@
|
||||
|
||||
/**
|
||||
* Entry point for GitHub Action
|
||||
* This file is bundled to entry.cjs and called directly by GitHub Actions
|
||||
*/
|
||||
|
||||
import * as core from "@actions/core";
|
||||
import { type ExecutionInputs, type MainParams, main } from "./main.ts";
|
||||
import { setupGitHubInstallationToken } from "./utils/github.ts";
|
||||
import { type } from "arktype";
|
||||
import { Inputs, main } from "./main.ts";
|
||||
import packageJson from "./package.json" with { type: "json" };
|
||||
import { log } from "./utils/cli.ts";
|
||||
|
||||
async function run(): Promise<void> {
|
||||
try {
|
||||
// Get inputs from GitHub Actions
|
||||
const prompt = core.getInput("prompt", { required: true });
|
||||
const anthropic_api_key = core.getInput("anthropic_api_key");
|
||||
log.info(`🐸 Running pullfrog/action@${packageJson.version}...`);
|
||||
|
||||
if (!prompt) {
|
||||
throw new Error("prompt is required");
|
||||
const inputsJson = process.env.INPUTS_JSON;
|
||||
if (!inputsJson) {
|
||||
throw new Error("INPUTS_JSON environment variable not found");
|
||||
}
|
||||
|
||||
// Create params object with new structure
|
||||
const inputs: ExecutionInputs = {
|
||||
prompt,
|
||||
anthropic_api_key,
|
||||
};
|
||||
const parsed = type("string.json.parse").assert(inputsJson);
|
||||
const inputs = Inputs.assert(parsed);
|
||||
|
||||
// Add optional properties only if they exist
|
||||
const githubToken = core.getInput("github_token") || process.env.GITHUB_TOKEN;
|
||||
if (githubToken) {
|
||||
inputs.github_token = githubToken;
|
||||
}
|
||||
|
||||
const githubInstallationToken =
|
||||
core.getInput("github_installation_token") || process.env.GITHUB_INSTALLATION_TOKEN;
|
||||
if (githubInstallationToken) {
|
||||
inputs.github_installation_token = githubInstallationToken;
|
||||
} else {
|
||||
await setupGitHubInstallationToken();
|
||||
}
|
||||
|
||||
const params: MainParams = {
|
||||
inputs,
|
||||
env: {},
|
||||
cwd: process.cwd(),
|
||||
};
|
||||
|
||||
const result = await main(params);
|
||||
|
||||
// TODO: Set outputs
|
||||
const result = await main(inputs);
|
||||
|
||||
if (!result.success) {
|
||||
throw new Error(result.error || "Agent execution failed");
|
||||
@@ -58,8 +33,4 @@ async function run(): Promise<void> {
|
||||
}
|
||||
}
|
||||
|
||||
// Run the action
|
||||
run().catch((error) => {
|
||||
console.error("Action failed:", error);
|
||||
process.exit(1);
|
||||
});
|
||||
await run();
|
||||
|
||||
@@ -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!");
|
||||
+5
-9
@@ -1,13 +1,9 @@
|
||||
import type { MainParams } from "../main.ts";
|
||||
import type { Inputs } from "../main.ts";
|
||||
|
||||
const testParams = {
|
||||
inputs: {
|
||||
prompt:
|
||||
"List all files in the current directory, then create a file called dynamic-test.txt with the content 'This was loaded from a TypeScript file!', then delete it.",
|
||||
anthropic_api_key: "sk-test-key",
|
||||
},
|
||||
env: {},
|
||||
cwd: process.cwd(),
|
||||
} satisfies MainParams;
|
||||
prompt:
|
||||
"List all files in the current directory, then create a file called dynamic-test.txt with the content 'This was loaded from a TypeScript file!', then delete it.",
|
||||
anthropic_api_key: "sk-test-key",
|
||||
} satisfies Inputs;
|
||||
|
||||
export default testParams;
|
||||
|
||||
+1
-3
@@ -1,3 +1 @@
|
||||
Use the MCP GitHub comment tool to add a comment containing your best frog joke to GitHub issue https://github.com/pullfrogai/scratch/issues/2.
|
||||
|
||||
Do not use the gh cli. If the mcp tool does not work, bail.
|
||||
add a github review for the following PR: https://github.com/pullfrogai/scratch/pull/16
|
||||
|
||||
@@ -3,11 +3,9 @@
|
||||
* This exports the main function for programmatic usage
|
||||
*/
|
||||
|
||||
export { ClaudeAgent } from "./agents/claude.ts";
|
||||
export type { Agent, AgentConfig, AgentResult } from "./agents/types.ts";
|
||||
export type { Agent, AgentConfig, AgentResult } from "./agents/shared.ts";
|
||||
export {
|
||||
type ExecutionInputs,
|
||||
type MainParams,
|
||||
type Inputs as ExecutionInputs,
|
||||
type MainResult,
|
||||
main,
|
||||
} from "./main.ts";
|
||||
|
||||
@@ -1,25 +1,16 @@
|
||||
import * as core from "@actions/core";
|
||||
import { ClaudeAgent } from "./agents/claude.ts";
|
||||
import { type } from "arktype";
|
||||
import { claude } from "./agents/claude.ts";
|
||||
import { createMcpConfigs } from "./mcp/config.ts";
|
||||
import { log } from "./utils/cli.ts";
|
||||
import { parseRepoContext, setupGitHubInstallationToken } from "./utils/github.ts";
|
||||
import { setupGitAuth, setupGitConfig } from "./utils/setup.ts";
|
||||
|
||||
// Expected environment variables that should be passed as inputs
|
||||
export const EXPECTED_INPUTS: string[] = [
|
||||
"ANTHROPIC_API_KEY",
|
||||
"GITHUB_TOKEN",
|
||||
"GITHUB_INSTALLATION_TOKEN",
|
||||
];
|
||||
export const Inputs = type({
|
||||
prompt: "string",
|
||||
"anthropic_api_key?": "string | undefined",
|
||||
});
|
||||
|
||||
export interface ExecutionInputs {
|
||||
prompt: string;
|
||||
anthropic_api_key: string;
|
||||
github_token?: string;
|
||||
github_installation_token?: string;
|
||||
}
|
||||
|
||||
export interface MainParams {
|
||||
inputs: ExecutionInputs;
|
||||
env: Record<string, string>;
|
||||
cwd: string;
|
||||
}
|
||||
export type Inputs = typeof Inputs.infer;
|
||||
|
||||
export interface MainResult {
|
||||
success: boolean;
|
||||
@@ -27,27 +18,30 @@ export interface MainResult {
|
||||
error?: string | undefined;
|
||||
}
|
||||
|
||||
export async function main(params: MainParams): Promise<MainResult> {
|
||||
export async function main(inputs: Inputs): Promise<MainResult> {
|
||||
try {
|
||||
// Extract inputs from params
|
||||
const { inputs, env, cwd } = params;
|
||||
log.info("Starting agent run...");
|
||||
|
||||
// Set working directory if different from current
|
||||
if (cwd !== process.cwd()) {
|
||||
process.chdir(cwd);
|
||||
}
|
||||
setupGitConfig();
|
||||
|
||||
// Set environment variables
|
||||
Object.assign(process.env, env);
|
||||
const githubInstallationToken = await setupGitHubInstallationToken();
|
||||
const repoContext = parseRepoContext();
|
||||
|
||||
core.info(`→ Starting agent run with Claude Code`);
|
||||
setupGitAuth(githubInstallationToken, repoContext);
|
||||
|
||||
// Create and install the Claude agent
|
||||
const agent = new ClaudeAgent({ apiKey: inputs.anthropic_api_key });
|
||||
await agent.install();
|
||||
const mcpServers = createMcpConfigs(githubInstallationToken);
|
||||
|
||||
// Execute the agent with the prompt
|
||||
const result = await agent.execute(inputs.prompt);
|
||||
log.debug(`📋 MCP Config: ${JSON.stringify(mcpServers, null, 2)}`);
|
||||
|
||||
log.info("Running Claude Agent SDK...");
|
||||
log.box(inputs.prompt, { title: "Prompt" });
|
||||
|
||||
const result = await claude.run({
|
||||
prompt: inputs.prompt,
|
||||
mcpServers,
|
||||
githubInstallationToken,
|
||||
apiKey: inputs.anthropic_api_key!,
|
||||
});
|
||||
|
||||
if (!result.success) {
|
||||
return {
|
||||
@@ -57,12 +51,17 @@ export async function main(params: MainParams): Promise<MainResult> {
|
||||
};
|
||||
}
|
||||
|
||||
log.success("Task complete.");
|
||||
await log.writeSummary();
|
||||
|
||||
return {
|
||||
success: true,
|
||||
output: result.output || "",
|
||||
};
|
||||
} catch (error) {
|
||||
const errorMessage = error instanceof Error ? error.message : "Unknown error occurred";
|
||||
log.error(errorMessage);
|
||||
await log.writeSummary();
|
||||
return {
|
||||
success: false,
|
||||
error: errorMessage,
|
||||
|
||||
@@ -0,0 +1,55 @@
|
||||
import { type } from "arktype";
|
||||
import { contextualize, tool } from "./shared.ts";
|
||||
|
||||
export const Comment = type({
|
||||
issueNumber: type.number.describe("the issue number to comment on"),
|
||||
body: type.string.describe("the comment body content"),
|
||||
});
|
||||
|
||||
export const CreateCommentTool = tool({
|
||||
name: "create_issue_comment",
|
||||
description: "Create a comment on a GitHub issue",
|
||||
parameters: Comment,
|
||||
execute: contextualize(async ({ issueNumber, body }, ctx) => {
|
||||
const result = await ctx.octokit.rest.issues.createComment({
|
||||
owner: ctx.owner,
|
||||
repo: ctx.name,
|
||||
issue_number: issueNumber,
|
||||
body: body,
|
||||
});
|
||||
|
||||
return {
|
||||
success: true,
|
||||
commentId: result.data.id,
|
||||
url: result.data.html_url,
|
||||
body: result.data.body,
|
||||
};
|
||||
}),
|
||||
});
|
||||
|
||||
export const EditComment = type({
|
||||
commentId: type.number.describe("the ID of the comment to edit"),
|
||||
body: type.string.describe("the new comment body content"),
|
||||
});
|
||||
|
||||
export const EditCommentTool = tool({
|
||||
name: "edit_issue_comment",
|
||||
description: "Edit a GitHub issue comment by its ID",
|
||||
parameters: EditComment,
|
||||
execute: contextualize(async ({ commentId, body }, ctx) => {
|
||||
const result = await ctx.octokit.rest.issues.updateComment({
|
||||
owner: ctx.owner,
|
||||
repo: ctx.name,
|
||||
comment_id: commentId,
|
||||
body: body,
|
||||
});
|
||||
|
||||
return {
|
||||
success: true,
|
||||
commentId: result.data.id,
|
||||
url: result.data.html_url,
|
||||
body: result.data.body,
|
||||
updatedAt: result.data.updated_at,
|
||||
};
|
||||
}),
|
||||
});
|
||||
+24
-21
@@ -1,28 +1,31 @@
|
||||
/**
|
||||
* Simple MCP configuration helper for adding our minimal GitHub comment server
|
||||
*/
|
||||
const actionPath = process.env.GITHUB_ACTION_PATH || process.cwd();
|
||||
|
||||
export function createMcpConfig(
|
||||
githubInstallationToken: string,
|
||||
repoOwner: string,
|
||||
repoName: string
|
||||
) {
|
||||
return JSON.stringify(
|
||||
{
|
||||
mcpServers: {
|
||||
minimal_github_comment: {
|
||||
command: "node",
|
||||
args: [`${actionPath}/mcp/server.ts`],
|
||||
env: {
|
||||
GITHUB_INSTALLATION_TOKEN: githubInstallationToken,
|
||||
REPO_OWNER: repoOwner,
|
||||
REPO_NAME: repoName,
|
||||
},
|
||||
},
|
||||
import type { McpServerConfig } from "@anthropic-ai/claude-agent-sdk";
|
||||
import { fromHere } from "@ark/fs";
|
||||
import { parseRepoContext } from "../utils/github.ts";
|
||||
|
||||
const actionPath = fromHere("..");
|
||||
|
||||
export const ghPullfrogMcpName = "gh-pullfrog";
|
||||
|
||||
export type McpName = typeof ghPullfrogMcpName;
|
||||
|
||||
export type McpConfigs = Record<McpName, McpServerConfig>;
|
||||
|
||||
export function createMcpConfigs(githubInstallationToken: string): McpConfigs {
|
||||
const repoContext = parseRepoContext();
|
||||
const githubRepository = `${repoContext.owner}/${repoContext.name}`;
|
||||
|
||||
return {
|
||||
[ghPullfrogMcpName]: {
|
||||
command: "node",
|
||||
args: [`${actionPath}/mcp/server.ts`],
|
||||
env: {
|
||||
GITHUB_INSTALLATION_TOKEN: githubInstallationToken,
|
||||
GITHUB_REPOSITORY: githubRepository,
|
||||
},
|
||||
},
|
||||
null,
|
||||
2
|
||||
);
|
||||
};
|
||||
}
|
||||
|
||||
@@ -0,0 +1,42 @@
|
||||
import { type } from "arktype";
|
||||
import { contextualize, tool } from "./shared.ts";
|
||||
|
||||
export const Issue = type({
|
||||
title: type.string.describe("the title of the issue"),
|
||||
body: type.string.describe("the body content of the issue"),
|
||||
labels: type.string
|
||||
.array()
|
||||
.describe("optional array of label names to apply to the issue")
|
||||
.optional(),
|
||||
assignees: type.string
|
||||
.array()
|
||||
.describe("optional array of usernames to assign to the issue")
|
||||
.optional(),
|
||||
});
|
||||
|
||||
export const IssueTool = tool({
|
||||
name: "create_issue",
|
||||
description: "Create a new GitHub issue",
|
||||
parameters: Issue,
|
||||
execute: contextualize(async ({ title, body, labels, assignees }, ctx) => {
|
||||
const result = await ctx.octokit.rest.issues.create({
|
||||
owner: ctx.owner,
|
||||
repo: ctx.name,
|
||||
title: title,
|
||||
body: body,
|
||||
labels: labels ?? [],
|
||||
assignees: assignees ?? [],
|
||||
});
|
||||
|
||||
return {
|
||||
success: true,
|
||||
issueId: result.data.id,
|
||||
number: result.data.number,
|
||||
url: result.data.html_url,
|
||||
title: result.data.title,
|
||||
state: result.data.state,
|
||||
labels: result.data.labels?.map((label) => (typeof label === "string" ? label : label.name)),
|
||||
assignees: result.data.assignees?.map((assignee) => assignee.login),
|
||||
};
|
||||
}),
|
||||
});
|
||||
@@ -0,0 +1,43 @@
|
||||
import { execSync } from "node:child_process";
|
||||
import { type } from "arktype";
|
||||
import { log } from "../utils/cli.ts";
|
||||
import { contextualize, tool } from "./shared.ts";
|
||||
|
||||
export const PullRequest = type({
|
||||
title: type.string.describe("the title of the pull request"),
|
||||
body: type.string.describe("the body content of the pull request"),
|
||||
base: type.string.describe("the base branch to merge into (e.g., 'main')"),
|
||||
});
|
||||
|
||||
export const PullRequestTool = tool({
|
||||
name: "create_pull_request",
|
||||
description: "Create a pull request from the current branch",
|
||||
parameters: PullRequest,
|
||||
execute: contextualize(async ({ title, body, base }, ctx) => {
|
||||
// Get the current branch name
|
||||
const currentBranch = execSync("git rev-parse --abbrev-ref HEAD", {
|
||||
encoding: "utf8",
|
||||
}).trim();
|
||||
|
||||
log.info(`Current branch: ${currentBranch}`);
|
||||
|
||||
const result = await ctx.octokit.rest.pulls.create({
|
||||
owner: ctx.owner,
|
||||
repo: ctx.name,
|
||||
title: title,
|
||||
body: body,
|
||||
head: currentBranch,
|
||||
base: base,
|
||||
});
|
||||
|
||||
return {
|
||||
success: true,
|
||||
pullRequestId: result.data.id,
|
||||
number: result.data.number,
|
||||
url: result.data.html_url,
|
||||
title: result.data.title,
|
||||
head: result.data.head.ref,
|
||||
base: result.data.base.ref,
|
||||
};
|
||||
}),
|
||||
});
|
||||
@@ -0,0 +1,52 @@
|
||||
import { execSync } from "node:child_process";
|
||||
import { type } from "arktype";
|
||||
import { log } from "../utils/cli.ts";
|
||||
import { contextualize, tool } from "./shared.ts";
|
||||
|
||||
export const PullRequestInfo = type({
|
||||
pull_number: type.number.describe("The pull request number to fetch"),
|
||||
});
|
||||
|
||||
export const PullRequestInfoTool = tool({
|
||||
name: "get_pull_request",
|
||||
description:
|
||||
"Retrieve PR information and automatically prepare the repository for review by fetching and checking out the PR branch.",
|
||||
parameters: PullRequestInfo,
|
||||
execute: contextualize(async ({ pull_number }, ctx) => {
|
||||
const pr = await ctx.octokit.rest.pulls.get({
|
||||
owner: ctx.owner,
|
||||
repo: ctx.name,
|
||||
pull_number,
|
||||
});
|
||||
|
||||
const data = pr.data;
|
||||
|
||||
const baseBranch = data.base.ref;
|
||||
const headBranch = data.head.ref;
|
||||
|
||||
if (!baseBranch) {
|
||||
throw new Error(`Base branch not found for PR #${pull_number}`);
|
||||
}
|
||||
|
||||
// Automatically fetch and checkout branches for review
|
||||
log.info(`Fetching base branch: origin/${baseBranch}`);
|
||||
execSync(`git fetch origin ${baseBranch} --depth=20`, { stdio: "inherit" });
|
||||
|
||||
log.info(`Fetching PR branch: origin/${headBranch}`);
|
||||
execSync(`git fetch origin ${headBranch}`, { stdio: "inherit" });
|
||||
|
||||
log.info(`Checking out PR branch: origin/${headBranch}`);
|
||||
execSync(`git checkout origin/${headBranch}`, { stdio: "inherit" });
|
||||
|
||||
return {
|
||||
number: data.number,
|
||||
url: data.html_url,
|
||||
title: data.title,
|
||||
state: data.state,
|
||||
draft: data.draft,
|
||||
merged: data.merged,
|
||||
base: baseBranch,
|
||||
head: headBranch,
|
||||
};
|
||||
}),
|
||||
});
|
||||
@@ -0,0 +1,94 @@
|
||||
import type { RestEndpointMethodTypes } from "@octokit/rest";
|
||||
import { type } from "arktype";
|
||||
import { contextualize, tool } from "./shared.ts";
|
||||
|
||||
export const Review = type({
|
||||
pull_number: type.number.describe("The pull request number to review"),
|
||||
event: type
|
||||
.enumerated("APPROVE", "REQUEST_CHANGES", "COMMENT")
|
||||
.describe("'APPROVE', 'REQUEST_CHANGES', or 'COMMENT' (the review action)"),
|
||||
body: type.string
|
||||
.describe(
|
||||
"Brief summary or general feedback that doesn't apply to specific code locations. Keep it concise - most feedback should be in the 'comments' array."
|
||||
)
|
||||
.optional(),
|
||||
commit_id: type.string
|
||||
.describe("Optional SHA of the commit being reviewed. Defaults to latest.")
|
||||
.optional(),
|
||||
comments: type({
|
||||
path: type.string.describe("The file path to comment on (relative to repo root)"),
|
||||
line: type.number.describe(
|
||||
"The line number in the file (use line numbers from the diff - usually the RIGHT side/new code)"
|
||||
),
|
||||
side: type
|
||||
.enumerated("LEFT", "RIGHT")
|
||||
.describe(
|
||||
"Side of the diff: LEFT (old code) or RIGHT (new code). Defaults to RIGHT if not provided."
|
||||
)
|
||||
.optional(),
|
||||
body: type.string.describe("The comment text for this specific line"),
|
||||
start_line: type.number
|
||||
.describe("Start line for multi-line comments (optional, for commenting on ranges)")
|
||||
.optional(),
|
||||
})
|
||||
.array()
|
||||
.describe(
|
||||
"REQUIRED: Array of inline comments for specific code issues. Use this for all location-specific feedback. Use 'git diff origin/<base>...origin/<head>' to find the correct line numbers (typically use the line numbers shown on the RIGHT side for new code, LEFT side for old code)."
|
||||
)
|
||||
.optional(),
|
||||
});
|
||||
|
||||
export const ReviewTool = tool({
|
||||
name: "submit_pull_request_review",
|
||||
description:
|
||||
"Submit a review (approve, request changes, or comment) for an existing pull request. " +
|
||||
"IMPORTANT: Use 'comments' array for ALL specific code issues at the line-level. " +
|
||||
"Only use 'body' for a brief summary or feedback that doesn't apply to a specific location.",
|
||||
parameters: Review,
|
||||
execute: contextualize(async ({ pull_number, event, body, commit_id, comments = [] }, ctx) => {
|
||||
// Get the PR to determine the head commit if commit_id not provided
|
||||
const pr = await ctx.octokit.rest.pulls.get({
|
||||
owner: ctx.owner,
|
||||
repo: ctx.name,
|
||||
pull_number,
|
||||
});
|
||||
|
||||
// Compose the request
|
||||
const params: RestEndpointMethodTypes["pulls"]["createReview"]["parameters"] = {
|
||||
owner: ctx.owner,
|
||||
repo: ctx.name,
|
||||
pull_number,
|
||||
event,
|
||||
};
|
||||
if (body) params.body = body;
|
||||
if (commit_id) {
|
||||
params.commit_id = commit_id;
|
||||
} else {
|
||||
params.commit_id = pr.data.head.sha;
|
||||
}
|
||||
if (comments.length > 0) {
|
||||
type ReviewComment = (typeof params.comments & {})[number];
|
||||
// Convert comments to the format expected by GitHub API
|
||||
params.comments = comments.map((comment) => {
|
||||
const reviewComment: ReviewComment = {
|
||||
...comment,
|
||||
};
|
||||
reviewComment.side = comment.side || "RIGHT";
|
||||
if (comment.start_line) {
|
||||
reviewComment.start_line = comment.start_line;
|
||||
reviewComment.start_side = comment.side || "RIGHT";
|
||||
}
|
||||
return reviewComment;
|
||||
});
|
||||
}
|
||||
const result = await ctx.octokit.rest.pulls.createReview(params);
|
||||
return {
|
||||
success: true,
|
||||
reviewId: result.data.id,
|
||||
html_url: result.data.html_url,
|
||||
state: result.data.state,
|
||||
user: result.data.user?.login,
|
||||
submitted_at: result.data.submitted_at,
|
||||
};
|
||||
}),
|
||||
});
|
||||
+18
-90
@@ -1,97 +1,25 @@
|
||||
#!/usr/bin/env node
|
||||
// Minimal GitHub Issue Comment MCP Server
|
||||
import { McpServer } from "@modelcontextprotocol/sdk/server/mcp.js";
|
||||
import { StdioServerTransport } from "@modelcontextprotocol/sdk/server/stdio.js";
|
||||
import { Octokit } from "@octokit/rest";
|
||||
import { type } from "arktype";
|
||||
import { z } from "zod";
|
||||
import { FastMCP } from "fastmcp";
|
||||
import { CreateCommentTool, EditCommentTool } from "./comment.ts";
|
||||
import { IssueTool } from "./issue.ts";
|
||||
import { PullRequestTool } from "./pr.ts";
|
||||
import { PullRequestInfoTool } from "./prInfo.ts";
|
||||
import { ReviewTool } from "./review.ts";
|
||||
import { addTools } from "./shared.ts";
|
||||
|
||||
// Get repository information from environment variables
|
||||
const REPO_OWNER = process.env.REPO_OWNER;
|
||||
const REPO_NAME = process.env.REPO_NAME;
|
||||
|
||||
if (!REPO_OWNER || !REPO_NAME) {
|
||||
console.error("Error: REPO_OWNER and REPO_NAME environment variables are required");
|
||||
process.exit(1);
|
||||
}
|
||||
|
||||
const server = new McpServer({
|
||||
name: "Minimal GitHub Issue Comment Server",
|
||||
const server = new FastMCP({
|
||||
name: "gh-pullfrog",
|
||||
version: "0.0.1",
|
||||
});
|
||||
|
||||
// Define the schema for creating issue comments
|
||||
const Comment = type({
|
||||
issueNumber: type.number.describe("the issue number to comment on"),
|
||||
body: type.string.describe("the comment body content"),
|
||||
});
|
||||
addTools(server, [
|
||||
CreateCommentTool,
|
||||
EditCommentTool,
|
||||
IssueTool,
|
||||
PullRequestTool,
|
||||
ReviewTool,
|
||||
PullRequestInfoTool,
|
||||
]);
|
||||
|
||||
server.tool(
|
||||
"create_issue_comment",
|
||||
"Create a comment on a GitHub issue",
|
||||
{
|
||||
issueNumber: z.number().describe("the issue number to comment on"),
|
||||
body: z.string().describe("the comment body content"),
|
||||
},
|
||||
async ({ issueNumber, body }) => {
|
||||
try {
|
||||
Comment.assert({ issueNumber, body });
|
||||
|
||||
const githubInstallationToken = process.env.GITHUB_INSTALLATION_TOKEN;
|
||||
if (!githubInstallationToken) {
|
||||
throw new Error("GITHUB_INSTALLATION_TOKEN environment variable is required");
|
||||
}
|
||||
|
||||
const octokit = new Octokit({
|
||||
auth: githubInstallationToken,
|
||||
});
|
||||
|
||||
const result = await octokit.rest.issues.createComment({
|
||||
owner: REPO_OWNER,
|
||||
repo: REPO_NAME,
|
||||
issue_number: issueNumber,
|
||||
body: body,
|
||||
});
|
||||
|
||||
return {
|
||||
content: [
|
||||
{
|
||||
type: "text",
|
||||
text: JSON.stringify(
|
||||
{
|
||||
success: true,
|
||||
commentId: result.data.id,
|
||||
url: result.data.html_url,
|
||||
body: result.data.body,
|
||||
},
|
||||
null,
|
||||
2
|
||||
),
|
||||
},
|
||||
],
|
||||
};
|
||||
} catch (error) {
|
||||
const errorMessage = error instanceof Error ? error.message : String(error);
|
||||
return {
|
||||
content: [
|
||||
{
|
||||
type: "text",
|
||||
text: `Error creating comment: ${errorMessage}`,
|
||||
},
|
||||
],
|
||||
error: errorMessage,
|
||||
isError: true,
|
||||
};
|
||||
}
|
||||
}
|
||||
);
|
||||
|
||||
async function runServer() {
|
||||
const transport = new StdioServerTransport();
|
||||
await server.connect(transport);
|
||||
process.on("exit", () => {
|
||||
server.close();
|
||||
});
|
||||
}
|
||||
|
||||
runServer().catch(console.error);
|
||||
server.start();
|
||||
|
||||
@@ -0,0 +1,76 @@
|
||||
import { cached } from "@ark/util";
|
||||
import { Octokit } from "@octokit/rest";
|
||||
import type { StandardSchemaV1 } from "@standard-schema/spec";
|
||||
import type { FastMCP, Tool } from "fastmcp";
|
||||
import { parseRepoContext, type RepoContext } from "../utils/github.ts";
|
||||
|
||||
export interface ToolResult {
|
||||
content: {
|
||||
type: "text";
|
||||
text: string;
|
||||
}[];
|
||||
isError?: boolean;
|
||||
}
|
||||
|
||||
export const getMcpContext = cached((): McpContext => {
|
||||
const githubInstallationToken = process.env.GITHUB_INSTALLATION_TOKEN;
|
||||
if (!githubInstallationToken) {
|
||||
throw new Error("GITHUB_INSTALLATION_TOKEN environment variable is required");
|
||||
}
|
||||
|
||||
return {
|
||||
...parseRepoContext(),
|
||||
octokit: new Octokit({
|
||||
auth: githubInstallationToken,
|
||||
}),
|
||||
};
|
||||
});
|
||||
|
||||
export interface McpContext extends RepoContext {
|
||||
octokit: Octokit;
|
||||
}
|
||||
|
||||
export const tool = <const params>(tool: Tool<any, StandardSchemaV1<params>>) => tool;
|
||||
|
||||
export const addTools = (server: FastMCP, tools: Tool<any, any>[]) => {
|
||||
for (const tool of tools) {
|
||||
server.addTool(tool);
|
||||
}
|
||||
return server;
|
||||
};
|
||||
|
||||
export const contextualize =
|
||||
<T>(executor: (params: T, ctx: McpContext) => Promise<Record<string, any>>) =>
|
||||
async (params: T): Promise<ToolResult> => {
|
||||
try {
|
||||
const ctx = getMcpContext();
|
||||
const result = await executor(params, ctx);
|
||||
return handleToolSuccess(result);
|
||||
} catch (error) {
|
||||
return handleToolError(error);
|
||||
}
|
||||
};
|
||||
|
||||
const handleToolSuccess = (data: Record<string, any>): ToolResult => {
|
||||
return {
|
||||
content: [
|
||||
{
|
||||
type: "text",
|
||||
text: JSON.stringify(data, null, 2),
|
||||
},
|
||||
],
|
||||
};
|
||||
};
|
||||
|
||||
const handleToolError = (error: unknown): ToolResult => {
|
||||
const errorMessage = error instanceof Error ? error.message : String(error);
|
||||
return {
|
||||
content: [
|
||||
{
|
||||
type: "text",
|
||||
text: `Error: ${errorMessage}`,
|
||||
},
|
||||
],
|
||||
isError: true,
|
||||
};
|
||||
};
|
||||
+13
-19
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "@pullfrog/action",
|
||||
"version": "0.0.13",
|
||||
"version": "0.0.70",
|
||||
"type": "module",
|
||||
"files": [
|
||||
"index.js",
|
||||
@@ -12,37 +12,31 @@
|
||||
"main.js",
|
||||
"main.d.ts"
|
||||
],
|
||||
"directories": {
|
||||
"example": "examples"
|
||||
},
|
||||
"scripts": {
|
||||
"test": "echo \"Error: no test specified\" && exit 1",
|
||||
"typecheck": "tsc --noEmit",
|
||||
"build": "node esbuild.config.js",
|
||||
"build:npm": "zshy",
|
||||
"build:dev": "node esbuild.config.js",
|
||||
"prepare": "husky",
|
||||
"play": "node play.ts",
|
||||
"upDeps": "pnpm up --latest"
|
||||
"upDeps": "pnpm up --latest",
|
||||
"lock": "pnpm --ignore-workspace install"
|
||||
},
|
||||
"dependencies": {
|
||||
"@actions/core": "^1.11.1",
|
||||
"@modelcontextprotocol/sdk": "^1.17.5",
|
||||
"@anthropic-ai/claude-agent-sdk": "^0.1.26",
|
||||
"@ark/fs": "0.53.0",
|
||||
"@ark/util": "0.53.0",
|
||||
"@octokit/rest": "^22.0.0",
|
||||
"@octokit/webhooks-types": "^7.6.1",
|
||||
"arktype": "^2.1.22",
|
||||
"dotenv": "^17.2.2",
|
||||
"@standard-schema/spec": "1.0.0",
|
||||
"arktype": "2.1.25",
|
||||
"dotenv": "^17.2.3",
|
||||
"execa": "^9.6.0",
|
||||
"fastmcp": "^3.20.0",
|
||||
"table": "^6.9.0"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@types/node": "^20.10.0",
|
||||
"@types/node": "^24.7.2",
|
||||
"arg": "^5.0.2",
|
||||
"esbuild": "^0.25.9",
|
||||
"husky": "^9.0.0",
|
||||
"typescript": "^5.3.0",
|
||||
"zshy": "^0.4.1",
|
||||
"zod": "^3.24.4"
|
||||
"typescript": "^5.9.3"
|
||||
},
|
||||
"repository": {
|
||||
"type": "git",
|
||||
@@ -50,7 +44,7 @@
|
||||
},
|
||||
"keywords": [],
|
||||
"author": "",
|
||||
"license": "ISC",
|
||||
"license": "MIT",
|
||||
"bugs": {
|
||||
"url": "https://github.com/pullfrog/action/issues"
|
||||
},
|
||||
|
||||
@@ -1,105 +1,65 @@
|
||||
import { existsSync, readFileSync } from "node:fs";
|
||||
import { dirname, extname, join, resolve } from "node:path";
|
||||
import { fileURLToPath, pathToFileURL } from "node:url";
|
||||
import { extname, join, resolve } from "node:path";
|
||||
import { pathToFileURL } from "node:url";
|
||||
import { fromHere } from "@ark/fs";
|
||||
import arg from "arg";
|
||||
import { config } from "dotenv";
|
||||
import { main } from "./main.ts";
|
||||
import { runAct } from "./utils/act.ts";
|
||||
import { type Inputs, main } from "./main.ts";
|
||||
import packageJson from "./package.json" with { type: "json" };
|
||||
import { log } from "./utils/cli.ts";
|
||||
import { setupTestRepo } from "./utils/setup.ts";
|
||||
|
||||
// Load environment variables from .env file
|
||||
config();
|
||||
|
||||
const __filename = fileURLToPath(import.meta.url);
|
||||
const __dirname = dirname(__filename);
|
||||
|
||||
export async function run(
|
||||
prompt: string,
|
||||
options: { act?: boolean } = {}
|
||||
prompt: string
|
||||
): Promise<{ success: boolean; output?: string | undefined; error?: string | undefined }> {
|
||||
try {
|
||||
if (options.act) {
|
||||
// Use Docker/act to run the action
|
||||
console.log("🐳 Running with Docker/act...");
|
||||
runAct(prompt);
|
||||
return { success: true };
|
||||
}
|
||||
log.info(`🐸 Running pullfrog/action@${packageJson.version}...`);
|
||||
|
||||
// Setup test repository and run directly
|
||||
const tempDir = join(process.cwd(), ".temp");
|
||||
setupTestRepo({ tempDir, forceClean: true });
|
||||
|
||||
// Change to the temp directory
|
||||
const originalCwd = process.cwd();
|
||||
process.chdir(tempDir);
|
||||
|
||||
console.log("🚀 Running action with prompt...");
|
||||
console.log("─".repeat(50));
|
||||
console.log("Prompt:");
|
||||
console.log(prompt);
|
||||
console.log("─".repeat(50));
|
||||
log.info("🚀 Running action with prompt...");
|
||||
log.separator();
|
||||
log.box(prompt, { title: "Prompt" });
|
||||
log.separator();
|
||||
|
||||
// Set environment variables from our .env for the action to use
|
||||
const { EXPECTED_INPUTS } = await import("./main.ts");
|
||||
EXPECTED_INPUTS.forEach((inputName) => {
|
||||
const value = process.env[inputName];
|
||||
if (value) {
|
||||
process.env[`INPUT_${inputName.toLowerCase()}`] = value;
|
||||
}
|
||||
});
|
||||
|
||||
// Run main with the new params structure
|
||||
const inputs: any = {
|
||||
const inputs: Inputs = {
|
||||
prompt,
|
||||
anthropic_api_key: process.env.ANTHROPIC_API_KEY || "",
|
||||
anthropic_api_key: process.env.ANTHROPIC_API_KEY,
|
||||
};
|
||||
|
||||
// Add optional properties only if they exist
|
||||
if (process.env.GITHUB_TOKEN) {
|
||||
inputs.github_token = process.env.GITHUB_TOKEN;
|
||||
}
|
||||
const result = await main(inputs);
|
||||
|
||||
if (process.env.GITHUB_INSTALLATION_TOKEN) {
|
||||
inputs.github_installation_token = process.env.GITHUB_INSTALLATION_TOKEN;
|
||||
}
|
||||
|
||||
const result = await main({
|
||||
inputs,
|
||||
env: process.env as Record<string, string>,
|
||||
cwd: process.cwd(),
|
||||
});
|
||||
|
||||
// Change back to original directory
|
||||
process.chdir(originalCwd);
|
||||
|
||||
if (result.success) {
|
||||
console.log("✅ Action completed successfully");
|
||||
if (result.output) {
|
||||
console.log("Output:", result.output);
|
||||
}
|
||||
log.success("Action completed successfully");
|
||||
return { success: true, output: result.output || undefined, error: undefined };
|
||||
} else {
|
||||
console.error("❌ Action failed:", result.error);
|
||||
log.error(`Action failed: ${result.error || "Unknown error"}`);
|
||||
return { success: false, error: result.error || undefined, output: undefined };
|
||||
}
|
||||
} catch (error) {
|
||||
const errorMessage = (error as Error).message;
|
||||
console.error("❌ Error:", errorMessage);
|
||||
} catch (err) {
|
||||
const errorMessage = (err as Error).message;
|
||||
log.error(`Error: ${errorMessage}`);
|
||||
return { success: false, error: errorMessage, output: undefined };
|
||||
}
|
||||
}
|
||||
|
||||
// CLI execution when run directly
|
||||
if (import.meta.url === `file://${process.argv[1]}`) {
|
||||
const args = arg({
|
||||
"--help": Boolean,
|
||||
"--act": Boolean,
|
||||
"--raw": String,
|
||||
"-h": "--help",
|
||||
});
|
||||
|
||||
if (args["--help"]) {
|
||||
console.log(`
|
||||
log.info(`
|
||||
Usage: tsx play.ts [file] [options]
|
||||
|
||||
Test the Pullfrog action with various prompts.
|
||||
@@ -108,7 +68,6 @@ Arguments:
|
||||
file Prompt file to use (.txt, .json, or .ts) [default: fixtures/basic.txt]
|
||||
|
||||
Options:
|
||||
--act Use Docker/act to run the action instead of running directly
|
||||
--raw [prompt] Use raw string as prompt instead of loading from file
|
||||
-h, --help Show this help message
|
||||
|
||||
@@ -116,7 +75,7 @@ Examples:
|
||||
tsx play.ts # Use default fixture
|
||||
tsx play.ts fixtures/basic.txt # Use specific text file
|
||||
tsx play.ts custom.json # Use JSON file
|
||||
tsx play.ts --act fixtures/test.ts # Use TypeScript file with Docker/act
|
||||
tsx play.ts fixtures/test.ts # Use TypeScript file
|
||||
tsx play.ts --raw "Hello world" # Use raw string as prompt
|
||||
`);
|
||||
process.exit(0);
|
||||
@@ -125,16 +84,13 @@ Examples:
|
||||
let prompt: string;
|
||||
|
||||
if (args["--raw"]) {
|
||||
// Use raw prompt string
|
||||
prompt = args["--raw"];
|
||||
} else {
|
||||
// Load prompt from file
|
||||
const filePath = args._[0] || "fixtures/basic.txt";
|
||||
const ext = extname(filePath).toLowerCase();
|
||||
let resolvedPath: string;
|
||||
|
||||
// First try as fixtures path
|
||||
const fixturesPath = join(__dirname, "fixtures", filePath);
|
||||
const fixturesPath = fromHere("fixtures", filePath);
|
||||
if (existsSync(fixturesPath)) {
|
||||
resolvedPath = fixturesPath;
|
||||
} else if (existsSync(filePath)) {
|
||||
@@ -145,13 +101,11 @@ Examples:
|
||||
|
||||
switch (ext) {
|
||||
case ".txt": {
|
||||
// Plain text - pass directly as prompt
|
||||
prompt = readFileSync(resolvedPath, "utf8").trim();
|
||||
break;
|
||||
}
|
||||
|
||||
case ".json": {
|
||||
// JSON - stringify and pass as prompt
|
||||
const content = readFileSync(resolvedPath, "utf8");
|
||||
const parsed = JSON.parse(content);
|
||||
prompt = JSON.stringify(parsed, null, 2);
|
||||
@@ -159,7 +113,6 @@ Examples:
|
||||
}
|
||||
|
||||
case ".ts": {
|
||||
// TypeScript - dynamic import and stringify default export
|
||||
const fileUrl = pathToFileURL(resolvedPath).href;
|
||||
const module = await import(fileUrl);
|
||||
|
||||
@@ -167,14 +120,11 @@ Examples:
|
||||
throw new Error(`TypeScript file ${filePath} must have a default export`);
|
||||
}
|
||||
|
||||
// If it's a string, use it directly
|
||||
if (typeof module.default === "string") {
|
||||
prompt = module.default;
|
||||
} else if (typeof module.default === "object" && module.default.prompt) {
|
||||
// If it's a MainParams object with a prompt field, extract the prompt
|
||||
prompt = module.default.prompt;
|
||||
} else {
|
||||
// Otherwise stringify it
|
||||
prompt = JSON.stringify(module.default, null, 2);
|
||||
}
|
||||
break;
|
||||
@@ -186,13 +136,13 @@ Examples:
|
||||
}
|
||||
|
||||
try {
|
||||
const result = await run(prompt, { act: args["--act"] || false });
|
||||
const result = await run(prompt);
|
||||
|
||||
if (!result.success) {
|
||||
process.exit(1);
|
||||
}
|
||||
} catch (error) {
|
||||
console.error("❌ Error:", (error as Error).message);
|
||||
} catch (err) {
|
||||
log.error((err as Error).message);
|
||||
process.exit(1);
|
||||
}
|
||||
}
|
||||
|
||||
Generated
+418
-451
File diff suppressed because it is too large
Load Diff
@@ -1,356 +0,0 @@
|
||||
#!/usr/bin/env tsx
|
||||
|
||||
/**
|
||||
* GitHub App Installation Token Generator
|
||||
*
|
||||
* Generates a temporary installation token for a GitHub App to access a specific repository.
|
||||
* Uses environment variables for configuration and supports multiple installations.
|
||||
*
|
||||
* Usage:
|
||||
* node scripts/generate-installation-token.ts [--repo owner/name] [--update-env]
|
||||
*
|
||||
* Environment variables required:
|
||||
* GITHUB_APP_ID - GitHub App ID
|
||||
* GITHUB_PRIVATE_KEY - GitHub App private key (PEM format)
|
||||
* REPO_OWNER - Target repository owner (default)
|
||||
* REPO_NAME - Target repository name (default)
|
||||
*/
|
||||
|
||||
import { createSign } from "node:crypto";
|
||||
import { readFileSync, writeFileSync } from "node:fs";
|
||||
import { join } from "node:path";
|
||||
import { config } from "dotenv";
|
||||
|
||||
// Load environment variables
|
||||
config();
|
||||
|
||||
interface GitHubAppConfig {
|
||||
appId: string;
|
||||
privateKey: string;
|
||||
repoOwner: string;
|
||||
repoName: string;
|
||||
}
|
||||
|
||||
interface Installation {
|
||||
id: number;
|
||||
account: {
|
||||
login: string;
|
||||
type: string;
|
||||
};
|
||||
}
|
||||
|
||||
interface Repository {
|
||||
owner: {
|
||||
login: string;
|
||||
};
|
||||
name: string;
|
||||
}
|
||||
|
||||
interface InstallationTokenResponse {
|
||||
token: string;
|
||||
expires_at: string;
|
||||
}
|
||||
|
||||
interface RepositoriesResponse {
|
||||
repositories: Repository[];
|
||||
}
|
||||
|
||||
class GitHubAppTokenGenerator {
|
||||
private config: GitHubAppConfig;
|
||||
|
||||
constructor(config: GitHubAppConfig) {
|
||||
// Process private key to handle escaped newlines
|
||||
config.privateKey = config.privateKey.replace(/\\n/g, "\n");
|
||||
this.config = config;
|
||||
this.validateConfig();
|
||||
}
|
||||
|
||||
private validateConfig(): void {
|
||||
const { appId, privateKey, repoOwner, repoName } = this.config;
|
||||
|
||||
if (!appId) {
|
||||
throw new Error("GITHUB_APP_ID environment variable is required");
|
||||
}
|
||||
|
||||
if (!privateKey) {
|
||||
throw new Error("GITHUB_PRIVATE_KEY environment variable is required");
|
||||
}
|
||||
|
||||
if (!repoOwner || !repoName) {
|
||||
throw new Error("REPO_OWNER and REPO_NAME environment variables are required");
|
||||
}
|
||||
|
||||
if (!privateKey.includes("BEGIN") || !privateKey.includes("END")) {
|
||||
throw new Error("GITHUB_PRIVATE_KEY must be in PEM format");
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Generates a JWT for GitHub App authentication
|
||||
*/
|
||||
private generateJWT(): string {
|
||||
const now = Math.floor(Date.now() / 1000);
|
||||
const payload = {
|
||||
iat: now - 60, // issued 1 minute ago to account for clock skew
|
||||
exp: now + 5 * 60, // expires in 5 minutes
|
||||
iss: this.config.appId,
|
||||
};
|
||||
|
||||
const header = {
|
||||
alg: "RS256",
|
||||
typ: "JWT",
|
||||
};
|
||||
|
||||
const encodedHeader = this.base64UrlEncode(JSON.stringify(header));
|
||||
const encodedPayload = this.base64UrlEncode(JSON.stringify(payload));
|
||||
const signaturePart = `${encodedHeader}.${encodedPayload}`;
|
||||
|
||||
const signature = createSign("RSA-SHA256")
|
||||
.update(signaturePart)
|
||||
.sign(this.config.privateKey, "base64")
|
||||
.replace(/\+/g, "-")
|
||||
.replace(/\//g, "_")
|
||||
.replace(/=/g, "");
|
||||
|
||||
return `${signaturePart}.${signature}`;
|
||||
}
|
||||
|
||||
private base64UrlEncode(str: string): string {
|
||||
return Buffer.from(str)
|
||||
.toString("base64")
|
||||
.replace(/\+/g, "-")
|
||||
.replace(/\//g, "_")
|
||||
.replace(/=/g, "");
|
||||
}
|
||||
|
||||
/**
|
||||
* Makes authenticated requests to GitHub API
|
||||
*/
|
||||
private async githubRequest<T>(
|
||||
path: string,
|
||||
options: {
|
||||
method?: string;
|
||||
headers?: Record<string, string>;
|
||||
body?: string;
|
||||
} = {}
|
||||
): Promise<T> {
|
||||
const { method = "GET", headers = {}, body } = options;
|
||||
|
||||
const url = `https://api.github.com${path}`;
|
||||
const requestHeaders = {
|
||||
Accept: "application/vnd.github.v3+json",
|
||||
"User-Agent": "Pullfrog-Installation-Token-Generator/1.0",
|
||||
...headers,
|
||||
};
|
||||
|
||||
const response = await fetch(url, {
|
||||
method,
|
||||
headers: requestHeaders,
|
||||
...(body && { body }),
|
||||
});
|
||||
|
||||
if (!response.ok) {
|
||||
const errorText = await response.text();
|
||||
throw new Error(
|
||||
`GitHub API request failed: ${response.status} ${response.statusText}\n${errorText}`
|
||||
);
|
||||
}
|
||||
|
||||
return response.json() as T;
|
||||
}
|
||||
|
||||
/**
|
||||
* Finds the installation ID for the target repository
|
||||
*/
|
||||
private async findInstallationId(jwt: string): Promise<number> {
|
||||
console.log("🔍 Finding GitHub App installation...");
|
||||
|
||||
const installations = await this.githubRequest<Installation[]>("/app/installations", {
|
||||
headers: { Authorization: `Bearer ${jwt}` },
|
||||
});
|
||||
|
||||
console.log(`📋 Found ${installations.length} installation(s)`);
|
||||
|
||||
// Check each installation for access to target repository
|
||||
for (const installation of installations) {
|
||||
console.log(`🔎 Checking installation ${installation.id} (${installation.account.login})`);
|
||||
|
||||
try {
|
||||
// Create a temporary token to check repository access
|
||||
const tempToken = await this.createInstallationToken(jwt, installation.id);
|
||||
const hasAccess = await this.checkRepositoryAccess(tempToken);
|
||||
|
||||
if (hasAccess) {
|
||||
console.log(
|
||||
`✅ Installation ${installation.id} has access to ${this.config.repoOwner}/${this.config.repoName}`
|
||||
);
|
||||
return installation.id;
|
||||
}
|
||||
} catch (error) {
|
||||
console.log(
|
||||
`❌ Installation ${installation.id} check failed:`,
|
||||
error instanceof Error ? error.message : String(error)
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
throw new Error(
|
||||
`No installation found with access to ${this.config.repoOwner}/${this.config.repoName}. ` +
|
||||
"Ensure the GitHub App is installed on the target repository."
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Checks if the installation token has access to the target repository
|
||||
*/
|
||||
private async checkRepositoryAccess(token: string): Promise<boolean> {
|
||||
try {
|
||||
const response = await this.githubRequest<RepositoriesResponse>(
|
||||
"/installation/repositories",
|
||||
{
|
||||
headers: { Authorization: `token ${token}` },
|
||||
}
|
||||
);
|
||||
|
||||
return response.repositories.some(
|
||||
(repo) => repo.owner.login === this.config.repoOwner && repo.name === this.config.repoName
|
||||
);
|
||||
} catch {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Creates an installation access token
|
||||
*/
|
||||
private async createInstallationToken(jwt: string, installationId: number): Promise<string> {
|
||||
const response = await this.githubRequest<InstallationTokenResponse>(
|
||||
`/app/installations/${installationId}/access_tokens`,
|
||||
{
|
||||
method: "POST",
|
||||
headers: { Authorization: `Bearer ${jwt}` },
|
||||
}
|
||||
);
|
||||
|
||||
return response.token;
|
||||
}
|
||||
|
||||
/**
|
||||
* Generates a new installation token for the configured repository
|
||||
*/
|
||||
async generateToken(): Promise<{
|
||||
token: string;
|
||||
installationId: number;
|
||||
expiresAt: string;
|
||||
}> {
|
||||
console.log(
|
||||
`🚀 Generating installation token for ${this.config.repoOwner}/${this.config.repoName}`
|
||||
);
|
||||
console.log(`📱 App ID: ${this.config.appId}`);
|
||||
|
||||
// Step 1: Generate JWT for app authentication
|
||||
const jwt = this.generateJWT();
|
||||
console.log("🔐 Generated JWT token");
|
||||
|
||||
// Step 2: Find installation with repository access
|
||||
const installationId = await this.findInstallationId(jwt);
|
||||
|
||||
// Step 3: Create installation access token
|
||||
console.log(`🎫 Creating installation token for installation ${installationId}...`);
|
||||
const token = await this.createInstallationToken(jwt, installationId);
|
||||
|
||||
// Calculate expiration (GitHub tokens expire after 1 hour)
|
||||
const expiresAt = new Date(Date.now() + 60 * 60 * 1000).toISOString();
|
||||
|
||||
console.log("✅ Installation token generated successfully!");
|
||||
console.log(`🎟️ Token: ${token.substring(0, 20)}...`);
|
||||
console.log(`📅 Expires: ${expiresAt}`);
|
||||
console.log(`🏢 Installation ID: ${installationId}`);
|
||||
|
||||
return { token, installationId, expiresAt };
|
||||
}
|
||||
|
||||
/**
|
||||
* Updates the .env file with the new installation token
|
||||
*/
|
||||
updateEnvFile(token: string): void {
|
||||
const envPath = join(process.cwd(), ".env");
|
||||
|
||||
try {
|
||||
let envContent = readFileSync(envPath, "utf8");
|
||||
|
||||
// Update or add the installation token
|
||||
const tokenLine = `GITHUB_INSTALLATION_TOKEN=${token}`;
|
||||
const tokenRegex = /^GITHUB_INSTALLATION_TOKEN=.*$/m;
|
||||
|
||||
if (tokenRegex.test(envContent)) {
|
||||
envContent = envContent.replace(tokenRegex, tokenLine);
|
||||
} else {
|
||||
envContent += `\n${tokenLine}\n`;
|
||||
}
|
||||
|
||||
writeFileSync(envPath, envContent);
|
||||
console.log(`📝 Updated ${envPath} with new installation token`);
|
||||
} catch (error) {
|
||||
console.error(
|
||||
"❌ Failed to update .env file:",
|
||||
error instanceof Error ? error.message : String(error)
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* CLI interface
|
||||
*/
|
||||
async function main(): Promise<void> {
|
||||
try {
|
||||
const args = process.argv.slice(2);
|
||||
const updateEnv = args.includes("--update-env");
|
||||
|
||||
// Parse repository from args if provided
|
||||
const repoArg = args.find((arg) => arg.startsWith("--repo="));
|
||||
let repoOwner = process.env.REPO_OWNER || "pullfrogai";
|
||||
let repoName = process.env.REPO_NAME || "scratch";
|
||||
|
||||
if (repoArg) {
|
||||
const [owner, name] = repoArg.split("=")[1].split("/");
|
||||
if (owner && name) {
|
||||
repoOwner = owner;
|
||||
repoName = name;
|
||||
} else {
|
||||
throw new Error("Invalid --repo format. Use: --repo=owner/name");
|
||||
}
|
||||
}
|
||||
|
||||
const config: GitHubAppConfig = {
|
||||
appId: process.env.GITHUB_APP_ID!,
|
||||
privateKey: process.env.GITHUB_PRIVATE_KEY!,
|
||||
repoOwner,
|
||||
repoName,
|
||||
};
|
||||
|
||||
const generator = new GitHubAppTokenGenerator(config);
|
||||
const result = await generator.generateToken();
|
||||
|
||||
if (updateEnv) {
|
||||
generator.updateEnvFile(result.token);
|
||||
}
|
||||
|
||||
console.log("\n🎉 Token generation complete!");
|
||||
|
||||
if (!updateEnv) {
|
||||
console.log("\n💡 To automatically update your .env file, run with --update-env flag");
|
||||
}
|
||||
} catch (error) {
|
||||
console.error("❌ Error:", error instanceof Error ? error.message : String(error));
|
||||
process.exit(1);
|
||||
}
|
||||
}
|
||||
|
||||
// Run if called directly
|
||||
if (import.meta.url === `file://${process.argv[1]}`) {
|
||||
main();
|
||||
}
|
||||
|
||||
export { GitHubAppTokenGenerator };
|
||||
@@ -0,0 +1,4 @@
|
||||
[] add modes to prompt
|
||||
[] progressively update comment
|
||||
[] don't allow rejecting prs
|
||||
[] fix pnpm caching
|
||||
+16
-15
@@ -2,21 +2,22 @@
|
||||
"compilerOptions": {
|
||||
"outDir": "./dist",
|
||||
"module": "NodeNext",
|
||||
"target": "ESNext",
|
||||
"moduleResolution": "NodeNext",
|
||||
"lib": ["ESNext"],
|
||||
"target": "ESNext",
|
||||
"moduleResolution": "NodeNext",
|
||||
"lib": ["ESNext"],
|
||||
"allowImportingTsExtensions": true,
|
||||
"rewriteRelativeImportExtensions": true,
|
||||
"skipLibCheck": true,
|
||||
"strict": true,
|
||||
"noUncheckedSideEffectImports": true,
|
||||
"declaration": true,
|
||||
"verbatimModuleSyntax": true,
|
||||
"esModuleInterop": true,
|
||||
"resolveJsonModule": true,
|
||||
"exactOptionalPropertyTypes": true,
|
||||
"forceConsistentCasingInFileNames": true,
|
||||
"stripInternal": true,
|
||||
"moduleDetection": "force"
|
||||
"rewriteRelativeImportExtensions": true,
|
||||
"skipLibCheck": true,
|
||||
"strict": true,
|
||||
"noUncheckedSideEffectImports": true,
|
||||
"declaration": true,
|
||||
"verbatimModuleSyntax": true,
|
||||
"esModuleInterop": true,
|
||||
"resolveJsonModule": true,
|
||||
"exactOptionalPropertyTypes": true,
|
||||
"forceConsistentCasingInFileNames": true,
|
||||
"stripInternal": true,
|
||||
"moduleDetection": "force",
|
||||
"useUnknownInCatchVariables": true
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,89 +0,0 @@
|
||||
import { execSync } from "node:child_process";
|
||||
import { existsSync } from "node:fs";
|
||||
import { dirname, join } from "node:path";
|
||||
import { fileURLToPath } from "node:url";
|
||||
import { config } from "dotenv";
|
||||
import { buildAction, setupTestRepo } from "./setup.ts";
|
||||
|
||||
const __filename = fileURLToPath(import.meta.url);
|
||||
const __dirname = dirname(__filename);
|
||||
|
||||
const tempDir = join(__dirname, "..", ".temp");
|
||||
const actionPath = join(__dirname, "..");
|
||||
const envPath = join(__dirname, "..", "..", ".env");
|
||||
|
||||
// Environment variables that should be passed as secrets to the workflow
|
||||
const ENV_VARS = ["ANTHROPIC_API_KEY", "GITHUB_INSTALLATION_TOKEN"];
|
||||
|
||||
export function runAct(prompt: string): void {
|
||||
// Setup test repository
|
||||
setupTestRepo({ tempDir });
|
||||
|
||||
// Load environment variables
|
||||
config({ path: envPath });
|
||||
|
||||
// Build action bundles
|
||||
buildAction(actionPath);
|
||||
|
||||
const workflowPath = join(tempDir, ".github", "workflows", "pullfrog.yml");
|
||||
|
||||
// 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: "/bin/bash" });
|
||||
|
||||
// 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/action@v0=${distPath}`, // Use minimal dist without symlinks
|
||||
];
|
||||
|
||||
// Add environment variables as secrets that will be available to the workflow
|
||||
ENV_VARS.forEach((key) => {
|
||||
if (process.env[key]) {
|
||||
actCommandParts.push("-s", key);
|
||||
}
|
||||
});
|
||||
|
||||
// We only need the specific ENV_VARS, no need to add other variables
|
||||
|
||||
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);
|
||||
}
|
||||
}
|
||||
+232
@@ -0,0 +1,232 @@
|
||||
/**
|
||||
* CLI output utilities that work well in both local and GitHub Actions environments
|
||||
*/
|
||||
|
||||
import * as core from "@actions/core";
|
||||
|
||||
const isGitHubActions = !!process.env.GITHUB_ACTIONS;
|
||||
const isDebugEnabled = process.env.LOG_LEVEL === "debug";
|
||||
|
||||
/**
|
||||
* Print a formatted box with text (for console output)
|
||||
*/
|
||||
function boxString(
|
||||
text: string,
|
||||
options?: {
|
||||
title?: string;
|
||||
maxWidth?: number;
|
||||
indent?: string;
|
||||
padding?: number;
|
||||
}
|
||||
): string {
|
||||
const { title, maxWidth = 80, indent = "", padding = 1 } = options || {};
|
||||
|
||||
const lines = text.trim().split("\n");
|
||||
const wrappedLines: string[] = [];
|
||||
|
||||
for (const line of lines) {
|
||||
if (line.length <= maxWidth - padding * 2) {
|
||||
wrappedLines.push(line);
|
||||
} else {
|
||||
const words = line.split(" ");
|
||||
let currentLine = "";
|
||||
|
||||
for (const word of words) {
|
||||
const testLine = currentLine ? `${currentLine} ${word}` : word;
|
||||
if (testLine.length <= maxWidth - padding * 2) {
|
||||
currentLine = testLine;
|
||||
} else {
|
||||
if (currentLine) {
|
||||
wrappedLines.push(currentLine);
|
||||
currentLine = word;
|
||||
} else {
|
||||
wrappedLines.push(word.substring(0, maxWidth - padding * 2));
|
||||
currentLine = word.substring(maxWidth - padding * 2);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if (currentLine) {
|
||||
wrappedLines.push(currentLine);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
const maxLineLength = Math.max(...wrappedLines.map((line) => line.length));
|
||||
const boxWidth = maxLineLength + padding * 2;
|
||||
|
||||
let result = "";
|
||||
|
||||
if (title) {
|
||||
const titleLine = ` ${title} `;
|
||||
const titlePadding = Math.max(0, boxWidth - titleLine.length);
|
||||
result += `${indent}┌${titleLine}${"─".repeat(titlePadding)}┐\n`;
|
||||
}
|
||||
|
||||
if (!title) {
|
||||
result += `${indent}┌${"─".repeat(boxWidth)}┐\n`;
|
||||
}
|
||||
|
||||
for (const line of wrappedLines) {
|
||||
const paddedLine = line.padEnd(maxLineLength);
|
||||
result += `${indent}│${" ".repeat(padding)}${paddedLine}${" ".repeat(padding)}│\n`;
|
||||
}
|
||||
|
||||
result += `${indent}└${"─".repeat(boxWidth)}┘`;
|
||||
|
||||
return result;
|
||||
}
|
||||
|
||||
/**
|
||||
* Print a formatted box with text
|
||||
* Works well in both local and GitHub Actions environments
|
||||
*/
|
||||
function box(
|
||||
text: string,
|
||||
options?: {
|
||||
title?: string;
|
||||
maxWidth?: number;
|
||||
}
|
||||
): void {
|
||||
const boxContent = boxString(text, options);
|
||||
core.info(boxContent);
|
||||
if (isGitHubActions) {
|
||||
// Add as markdown code block for summary (no headers)
|
||||
core.summary.addRaw(`\`\`\`\n${text}\n\`\`\`\n`);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Add a table to GitHub Actions job summary (rich formatting)
|
||||
* Also logs to console. Only use this once at the end of execution.
|
||||
*/
|
||||
async function summaryTable(
|
||||
rows: Array<Array<{ data: string; header?: boolean } | string>>,
|
||||
options?: {
|
||||
title?: string;
|
||||
}
|
||||
): Promise<void> {
|
||||
const { title } = options || {};
|
||||
|
||||
// Convert rows to format expected by Job Summaries API
|
||||
const formattedRows = rows.map((row) =>
|
||||
row.map((cell) => {
|
||||
if (typeof cell === "string") {
|
||||
return { data: cell };
|
||||
}
|
||||
return cell;
|
||||
})
|
||||
);
|
||||
|
||||
if (isGitHubActions) {
|
||||
const summary = core.summary;
|
||||
if (title) {
|
||||
summary.addRaw(`**${title}**\n\n`);
|
||||
}
|
||||
summary.addTable(formattedRows);
|
||||
// Note: Don't write immediately, let it accumulate with other summary content
|
||||
}
|
||||
|
||||
// Also log to console for visibility
|
||||
if (title) {
|
||||
core.info(`\n${title}`);
|
||||
}
|
||||
const tableText = formattedRows.map((row) => row.map((cell) => cell.data).join(" | ")).join("\n");
|
||||
core.info(`\n${tableText}\n`);
|
||||
}
|
||||
|
||||
/**
|
||||
* Print a separator line
|
||||
*/
|
||||
function separator(length: number = 50): void {
|
||||
const separatorText = "─".repeat(length);
|
||||
core.info(separatorText);
|
||||
if (isGitHubActions) {
|
||||
core.summary.addRaw(`---\n`);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Main logging utility object - import this once and access all utilities
|
||||
*/
|
||||
export const log = {
|
||||
/**
|
||||
* Print info message
|
||||
*/
|
||||
info: (message: string): void => {
|
||||
core.info(message);
|
||||
if (isGitHubActions) {
|
||||
core.summary.addRaw(`${message}\n`);
|
||||
}
|
||||
},
|
||||
|
||||
/**
|
||||
* Print warning message
|
||||
*/
|
||||
warning: (message: string): void => {
|
||||
core.warning(message);
|
||||
if (isGitHubActions) {
|
||||
core.summary.addRaw(`⚠️ ${message}\n`);
|
||||
}
|
||||
},
|
||||
|
||||
/**
|
||||
* Print error message
|
||||
*/
|
||||
error: (message: string): void => {
|
||||
core.error(message);
|
||||
if (isGitHubActions) {
|
||||
core.summary.addRaw(`❌ ${message}\n`);
|
||||
}
|
||||
},
|
||||
|
||||
/**
|
||||
* Print success message
|
||||
*/
|
||||
success: (message: string): void => {
|
||||
const successMessage = `✅ ${message}`;
|
||||
core.info(successMessage);
|
||||
if (isGitHubActions) {
|
||||
core.summary.addRaw(`${successMessage}\n`);
|
||||
}
|
||||
},
|
||||
|
||||
/**
|
||||
* Print debug message (only if LOG_LEVEL=debug)
|
||||
*/
|
||||
debug: (message: string): void => {
|
||||
if (isDebugEnabled) {
|
||||
if (isGitHubActions) {
|
||||
core.debug(message);
|
||||
} else {
|
||||
core.info(`[DEBUG] ${message}`);
|
||||
}
|
||||
}
|
||||
},
|
||||
|
||||
/**
|
||||
* Print a formatted box with text
|
||||
*/
|
||||
box,
|
||||
|
||||
/**
|
||||
* Add a table to GitHub Actions job summary (rich formatting)
|
||||
* Only use this once at the end of execution
|
||||
*/
|
||||
summaryTable,
|
||||
|
||||
/**
|
||||
* Print a separator line
|
||||
*/
|
||||
separator,
|
||||
|
||||
/**
|
||||
* Write all accumulated summary content to the job summary
|
||||
* Call this at the end of execution to finalize the summary
|
||||
*/
|
||||
writeSummary: async (): Promise<void> => {
|
||||
if (isGitHubActions) {
|
||||
await core.summary.write();
|
||||
}
|
||||
},
|
||||
};
|
||||
@@ -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;
|
||||
}
|
||||
+250
-47
@@ -1,4 +1,6 @@
|
||||
import { createSign } from "node:crypto";
|
||||
import * as core from "@actions/core";
|
||||
import { log } from "./cli.ts";
|
||||
|
||||
export interface InstallationToken {
|
||||
token: string;
|
||||
@@ -10,62 +12,263 @@ export interface InstallationToken {
|
||||
owner?: string;
|
||||
}
|
||||
|
||||
interface GitHubAppConfig {
|
||||
appId: string;
|
||||
privateKey: string;
|
||||
repoOwner: string;
|
||||
repoName: string;
|
||||
}
|
||||
|
||||
interface Installation {
|
||||
id: number;
|
||||
account: {
|
||||
login: string;
|
||||
type: string;
|
||||
};
|
||||
}
|
||||
|
||||
interface Repository {
|
||||
owner: {
|
||||
login: string;
|
||||
};
|
||||
name: string;
|
||||
}
|
||||
|
||||
interface InstallationTokenResponse {
|
||||
token: string;
|
||||
expires_at: string;
|
||||
}
|
||||
|
||||
interface RepositoriesResponse {
|
||||
repositories: Repository[];
|
||||
}
|
||||
|
||||
function checkExistingToken(): string | null {
|
||||
const inputToken = core.getInput("github_installation_token");
|
||||
const envToken = process.env.GITHUB_INSTALLATION_TOKEN;
|
||||
return inputToken || envToken || null;
|
||||
}
|
||||
|
||||
function isGitHubActionsEnvironment(): boolean {
|
||||
return Boolean(process.env.GITHUB_ACTIONS);
|
||||
}
|
||||
|
||||
async function acquireTokenViaOIDC(): Promise<string> {
|
||||
log.info("Generating OIDC token...");
|
||||
|
||||
const oidcToken = await core.getIDToken("pullfrog-api");
|
||||
log.info("OIDC token generated successfully");
|
||||
|
||||
const apiUrl = process.env.API_URL || "https://pullfrog.ai";
|
||||
|
||||
log.info("Exchanging OIDC token for installation token...");
|
||||
const tokenResponse = await fetch(`${apiUrl}/api/github/installation-token`, {
|
||||
method: "POST",
|
||||
headers: {
|
||||
Authorization: `Bearer ${oidcToken}`,
|
||||
"Content-Type": "application/json",
|
||||
},
|
||||
});
|
||||
|
||||
if (!tokenResponse.ok) {
|
||||
const errorText = await tokenResponse.text();
|
||||
throw new Error(
|
||||
`Token exchange failed: ${tokenResponse.status} ${tokenResponse.statusText} - ${errorText}`
|
||||
);
|
||||
}
|
||||
|
||||
const tokenData = (await tokenResponse.json()) as InstallationToken;
|
||||
log.info(`Installation token obtained for ${tokenData.repository || "all repositories"}`);
|
||||
|
||||
return tokenData.token;
|
||||
}
|
||||
|
||||
const base64UrlEncode = (str: string): string => {
|
||||
return Buffer.from(str)
|
||||
.toString("base64")
|
||||
.replace(/\+/g, "-")
|
||||
.replace(/\//g, "_")
|
||||
.replace(/=/g, "");
|
||||
};
|
||||
|
||||
const generateJWT = (appId: string, privateKey: string): string => {
|
||||
const now = Math.floor(Date.now() / 1000);
|
||||
const payload = {
|
||||
iat: now - 60,
|
||||
exp: now + 5 * 60,
|
||||
iss: appId,
|
||||
};
|
||||
|
||||
const header = {
|
||||
alg: "RS256",
|
||||
typ: "JWT",
|
||||
};
|
||||
|
||||
const encodedHeader = base64UrlEncode(JSON.stringify(header));
|
||||
const encodedPayload = base64UrlEncode(JSON.stringify(payload));
|
||||
const signaturePart = `${encodedHeader}.${encodedPayload}`;
|
||||
|
||||
const signature = createSign("RSA-SHA256")
|
||||
.update(signaturePart)
|
||||
.sign(privateKey, "base64")
|
||||
.replace(/\+/g, "-")
|
||||
.replace(/\//g, "_")
|
||||
.replace(/=/g, "");
|
||||
|
||||
return `${signaturePart}.${signature}`;
|
||||
};
|
||||
|
||||
const githubRequest = async <T>(
|
||||
path: string,
|
||||
options: {
|
||||
method?: string;
|
||||
headers?: Record<string, string>;
|
||||
body?: string;
|
||||
} = {}
|
||||
): Promise<T> => {
|
||||
const { method = "GET", headers = {}, body } = options;
|
||||
|
||||
const url = `https://api.github.com${path}`;
|
||||
const requestHeaders = {
|
||||
Accept: "application/vnd.github.v3+json",
|
||||
"User-Agent": "Pullfrog-Installation-Token-Generator/1.0",
|
||||
...headers,
|
||||
};
|
||||
|
||||
const response = await fetch(url, {
|
||||
method,
|
||||
headers: requestHeaders,
|
||||
...(body && { body }),
|
||||
});
|
||||
|
||||
if (!response.ok) {
|
||||
const errorText = await response.text();
|
||||
throw new Error(
|
||||
`GitHub API request failed: ${response.status} ${response.statusText}\n${errorText}`
|
||||
);
|
||||
}
|
||||
|
||||
return response.json() as T;
|
||||
};
|
||||
|
||||
const checkRepositoryAccess = async (
|
||||
token: string,
|
||||
repoOwner: string,
|
||||
repoName: string
|
||||
): Promise<boolean> => {
|
||||
try {
|
||||
const response = await githubRequest<RepositoriesResponse>("/installation/repositories", {
|
||||
headers: { Authorization: `token ${token}` },
|
||||
});
|
||||
|
||||
return response.repositories.some(
|
||||
(repo) => repo.owner.login === repoOwner && repo.name === repoName
|
||||
);
|
||||
} catch {
|
||||
return false;
|
||||
}
|
||||
};
|
||||
|
||||
const createInstallationToken = async (jwt: string, installationId: number): Promise<string> => {
|
||||
const response = await githubRequest<InstallationTokenResponse>(
|
||||
`/app/installations/${installationId}/access_tokens`,
|
||||
{
|
||||
method: "POST",
|
||||
headers: { Authorization: `Bearer ${jwt}` },
|
||||
}
|
||||
);
|
||||
|
||||
return response.token;
|
||||
};
|
||||
|
||||
const findInstallationId = async (
|
||||
jwt: string,
|
||||
repoOwner: string,
|
||||
repoName: string
|
||||
): Promise<number> => {
|
||||
const installations = await githubRequest<Installation[]>("/app/installations", {
|
||||
headers: { Authorization: `Bearer ${jwt}` },
|
||||
});
|
||||
|
||||
for (const installation of installations) {
|
||||
try {
|
||||
const tempToken = await createInstallationToken(jwt, installation.id);
|
||||
const hasAccess = await checkRepositoryAccess(tempToken, repoOwner, repoName);
|
||||
|
||||
if (hasAccess) {
|
||||
return installation.id;
|
||||
}
|
||||
} catch {}
|
||||
}
|
||||
|
||||
throw new Error(
|
||||
`No installation found with access to ${repoOwner}/${repoName}. ` +
|
||||
"Ensure the GitHub App is installed on the target repository."
|
||||
);
|
||||
};
|
||||
|
||||
async function acquireTokenViaGitHubApp(): Promise<string> {
|
||||
const repoContext = parseRepoContext();
|
||||
|
||||
const config: GitHubAppConfig = {
|
||||
appId: process.env.GITHUB_APP_ID!,
|
||||
privateKey: process.env.GITHUB_PRIVATE_KEY?.replace(/\\n/g, "\n")!,
|
||||
repoOwner: repoContext.owner,
|
||||
repoName: repoContext.name,
|
||||
};
|
||||
|
||||
const jwt = generateJWT(config.appId, config.privateKey);
|
||||
const installationId = await findInstallationId(jwt, config.repoOwner, config.repoName);
|
||||
const token = await createInstallationToken(jwt, installationId);
|
||||
|
||||
return token;
|
||||
}
|
||||
|
||||
async function acquireNewToken(): Promise<string> {
|
||||
if (isGitHubActionsEnvironment()) {
|
||||
return await acquireTokenViaOIDC();
|
||||
} else {
|
||||
return await acquireTokenViaGitHubApp();
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Setup GitHub installation token for the action
|
||||
*/
|
||||
export async function setupGitHubInstallationToken(): Promise<string> {
|
||||
// Check if we have an installation token from inputs or environment
|
||||
const inputToken = core.getInput("github_installation_token");
|
||||
const envToken = process.env.GITHUB_INSTALLATION_TOKEN;
|
||||
|
||||
const existingToken = inputToken || envToken;
|
||||
const existingToken = checkExistingToken();
|
||||
if (existingToken) {
|
||||
// Mask the existing token in logs for security
|
||||
core.setSecret(existingToken);
|
||||
core.info("Using provided GitHub installation token");
|
||||
log.info("Using provided GitHub installation token");
|
||||
return existingToken;
|
||||
}
|
||||
|
||||
core.info("Generating OIDC token...");
|
||||
const token = await acquireNewToken();
|
||||
|
||||
try {
|
||||
// Generate OIDC token for our API
|
||||
const oidcToken = await core.getIDToken("pullfrog-api");
|
||||
core.info("OIDC token generated successfully");
|
||||
core.setSecret(token);
|
||||
process.env.GITHUB_INSTALLATION_TOKEN = token;
|
||||
|
||||
// Exchange OIDC token for installation token
|
||||
const apiUrl = process.env.API_URL || "https://pullfrog.ai";
|
||||
|
||||
core.info("Exchanging OIDC token for installation token...");
|
||||
const tokenResponse = await fetch(`${apiUrl}/api/github/installation-token`, {
|
||||
method: "POST",
|
||||
headers: {
|
||||
Authorization: `Bearer ${oidcToken}`,
|
||||
"Content-Type": "application/json",
|
||||
},
|
||||
});
|
||||
|
||||
if (!tokenResponse.ok) {
|
||||
const errorText = await tokenResponse.text();
|
||||
throw new Error(
|
||||
`Token exchange failed: ${tokenResponse.status} ${tokenResponse.statusText} - ${errorText}`
|
||||
);
|
||||
}
|
||||
|
||||
// This type is enforced by us when the response is created
|
||||
const tokenData = (await tokenResponse.json()) as InstallationToken;
|
||||
core.info(`Installation token obtained for ${tokenData.repository || "all repositories"}`);
|
||||
|
||||
// Mask the token in logs for security
|
||||
core.setSecret(tokenData.token);
|
||||
|
||||
// Set the token as an environment variable for this run
|
||||
process.env.GITHUB_INSTALLATION_TOKEN = tokenData.token;
|
||||
|
||||
return tokenData.token;
|
||||
} catch (error) {
|
||||
throw new Error(
|
||||
`Failed to setup GitHub installation token: ${error instanceof Error ? error.message : "Unknown error"}`
|
||||
);
|
||||
}
|
||||
return token;
|
||||
}
|
||||
|
||||
export interface RepoContext {
|
||||
owner: string;
|
||||
name: string;
|
||||
}
|
||||
|
||||
/**
|
||||
* Parse repository context from GITHUB_REPOSITORY environment variable.
|
||||
*/
|
||||
export function parseRepoContext(): RepoContext {
|
||||
const githubRepo = process.env.GITHUB_REPOSITORY;
|
||||
if (!githubRepo) {
|
||||
throw new Error("GITHUB_REPOSITORY environment variable is required");
|
||||
}
|
||||
|
||||
const [owner, name] = githubRepo.split("/");
|
||||
if (!owner || !name) {
|
||||
throw new Error(`Invalid GITHUB_REPOSITORY format: ${githubRepo}. Expected 'owner/repo'`);
|
||||
}
|
||||
|
||||
return { owner, name };
|
||||
}
|
||||
|
||||
+31
-13
@@ -1,5 +1,7 @@
|
||||
import { execSync } from "node:child_process";
|
||||
import { existsSync, rmSync } from "node:fs";
|
||||
import { log } from "./cli.ts";
|
||||
import type { RepoContext } from "./github.ts";
|
||||
|
||||
export interface SetupOptions {
|
||||
tempDir: string;
|
||||
@@ -17,35 +19,51 @@ export function setupTestRepo(options: SetupOptions): void {
|
||||
forceClean = false,
|
||||
} = options;
|
||||
|
||||
// Handle existing temp directory
|
||||
if (existsSync(tempDir)) {
|
||||
if (forceClean) {
|
||||
console.log("🗑️ Removing existing .temp directory...");
|
||||
log.info("🗑️ Removing existing .temp directory...");
|
||||
rmSync(tempDir, { recursive: true, force: true });
|
||||
|
||||
// Clone the repository
|
||||
console.log("📦 Cloning pullfrogai/scratch into .temp...");
|
||||
log.info("📦 Cloning pullfrogai/scratch into .temp...");
|
||||
execSync(`git clone ${repoUrl} ${tempDir}`, { stdio: "inherit" });
|
||||
} else {
|
||||
console.log("📦 Resetting existing .temp repository...");
|
||||
log.info("📦 Resetting existing .temp repository...");
|
||||
execSync("git reset --hard HEAD && git clean -fd", {
|
||||
cwd: tempDir,
|
||||
stdio: "inherit",
|
||||
});
|
||||
}
|
||||
} else {
|
||||
console.log("📦 Cloning pullfrogai/scratch into .temp...");
|
||||
log.info("📦 Cloning pullfrogai/scratch into .temp...");
|
||||
execSync(`git clone ${repoUrl} ${tempDir}`, { stdio: "inherit" });
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Build the action bundles
|
||||
* Setup git configuration to avoid identity errors
|
||||
*/
|
||||
export function buildAction(actionPath: string): void {
|
||||
console.log("🔨 Building fresh bundles with esbuild...");
|
||||
execSync("node esbuild.config.js", {
|
||||
cwd: actionPath,
|
||||
stdio: "inherit",
|
||||
});
|
||||
export function setupGitConfig(): void {
|
||||
log.info("🔧 Setting up git configuration...");
|
||||
execSync('git config user.email "action@pullfrog.ai"', { stdio: "inherit" });
|
||||
execSync('git config user.name "Pullfrog Action"', { stdio: "inherit" });
|
||||
}
|
||||
|
||||
/**
|
||||
* Setup git authentication using GitHub token
|
||||
*/
|
||||
export function setupGitAuth(githubToken: string, repoContext: RepoContext): void {
|
||||
log.info("🔐 Setting up git authentication...");
|
||||
|
||||
// Remove existing git auth headers that actions/checkout might have set
|
||||
try {
|
||||
execSync("git config --unset-all http.https://github.com/.extraheader", { stdio: "inherit" });
|
||||
log.info("✓ Removed existing authentication headers");
|
||||
} catch {
|
||||
log.info("No existing authentication headers to remove");
|
||||
}
|
||||
|
||||
// Update remote URL to embed the token
|
||||
const remoteUrl = `https://x-access-token:${githubToken}@github.com/${repoContext.owner}/${repoContext.name}.git`;
|
||||
execSync(`git remote set-url origin "${remoteUrl}"`, { stdio: "inherit" });
|
||||
log.info("✓ Updated remote URL with authentication token");
|
||||
}
|
||||
|
||||
+4
-11
@@ -6,6 +6,7 @@ export interface SpawnOptions {
|
||||
env?: Record<string, string>;
|
||||
input?: string;
|
||||
timeout?: number;
|
||||
cwd?: string;
|
||||
onStdout?: (chunk: string) => void;
|
||||
onStderr?: (chunk: string) => void;
|
||||
}
|
||||
@@ -21,20 +22,19 @@ export interface SpawnResult {
|
||||
* 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 { cmd, args, env, input, timeout, cwd, 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"],
|
||||
cwd: cwd || process.cwd(),
|
||||
});
|
||||
|
||||
// Set up timeout if specified
|
||||
let timeoutId: NodeJS.Timeout | undefined;
|
||||
let isTimedOut = false;
|
||||
|
||||
@@ -43,7 +43,6 @@ export async function spawn(options: SpawnOptions): Promise<SpawnResult> {
|
||||
isTimedOut = true;
|
||||
child.kill("SIGTERM");
|
||||
|
||||
// If SIGTERM doesn't work, use SIGKILL after 5 seconds
|
||||
setTimeout(() => {
|
||||
if (!child.killed) {
|
||||
child.kill("SIGKILL");
|
||||
@@ -52,7 +51,6 @@ export async function spawn(options: SpawnOptions): Promise<SpawnResult> {
|
||||
}, timeout);
|
||||
}
|
||||
|
||||
// Handle stdout streaming
|
||||
if (child.stdout) {
|
||||
child.stdout.on("data", (data: Buffer) => {
|
||||
const chunk = data.toString();
|
||||
@@ -61,7 +59,6 @@ export async function spawn(options: SpawnOptions): Promise<SpawnResult> {
|
||||
});
|
||||
}
|
||||
|
||||
// Handle stderr streaming
|
||||
if (child.stderr) {
|
||||
child.stderr.on("data", (data: Buffer) => {
|
||||
const chunk = data.toString();
|
||||
@@ -70,7 +67,6 @@ export async function spawn(options: SpawnOptions): Promise<SpawnResult> {
|
||||
});
|
||||
}
|
||||
|
||||
// Handle process completion
|
||||
child.on("close", (exitCode) => {
|
||||
const durationMs = Date.now() - startTime;
|
||||
|
||||
@@ -91,15 +87,13 @@ export async function spawn(options: SpawnOptions): Promise<SpawnResult> {
|
||||
});
|
||||
});
|
||||
|
||||
// Handle process errors
|
||||
child.on("error", (error) => {
|
||||
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,
|
||||
@@ -108,7 +102,6 @@ export async function spawn(options: SpawnOptions): Promise<SpawnResult> {
|
||||
});
|
||||
});
|
||||
|
||||
// Send input if provided
|
||||
if (input && child.stdin) {
|
||||
child.stdin.write(input);
|
||||
child.stdin.end();
|
||||
|
||||
-172
@@ -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);
|
||||
// }
|
||||
Reference in New Issue
Block a user