Compare commits

...

9 Commits

Author SHA1 Message Date
David Blass 1922352d86 fix git push auth 2025-10-23 16:12:15 -04:00
David Blass c0f31415a3 try setting cwd 2025-10-23 15:43:50 -04:00
David Blass 706ce04895 bump version 2025-10-23 15:37:04 -04:00
David Blass 09be8e3068 try adding github token to env 2025-10-23 15:35:36 -04:00
David Blass c6c1210fa0 refactor tool implementation 2025-10-23 15:21:08 -04:00
David Blass 0368512b9e add pr and issue creation support 2025-10-23 10:24:32 -04:00
David Blass 9fb6135fd2 bump 2025-10-17 22:27:59 -04:00
David Blass bb78e5f94b update lockfile 2025-10-17 22:27:24 -04:00
David Blass c668578c6f refactor mcp and add instructions prefix 2025-10-17 22:26:24 -04:00
18 changed files with 291 additions and 158 deletions
+28 -42
View File
@@ -3,6 +3,7 @@ import * as core from "@actions/core";
import { createMcpConfig } from "../mcp/config.ts"; import { createMcpConfig } from "../mcp/config.ts";
import { spawn } from "../utils/subprocess.ts"; import { spawn } from "../utils/subprocess.ts";
import { boxString, tableString } from "../utils/table.ts"; import { boxString, tableString } from "../utils/table.ts";
import { instructions } from "./shared.ts";
import type { Agent, AgentConfig, AgentResult } from "./types.ts"; import type { Agent, AgentConfig, AgentResult } from "./types.ts";
/** /**
@@ -10,7 +11,7 @@ import type { Agent, AgentConfig, AgentResult } from "./types.ts";
*/ */
export class ClaudeAgent implements Agent { export class ClaudeAgent implements Agent {
private apiKey: string; private apiKey: string;
private githubInstallationToken?: string; private githubInstallationToken: string;
public runStats = { public runStats = {
toolsUsed: 0, toolsUsed: 0,
turns: 0, turns: 0,
@@ -18,9 +19,6 @@ export class ClaudeAgent implements Agent {
}; };
constructor(config: AgentConfig) { constructor(config: AgentConfig) {
if (!config.apiKey) {
throw new Error("Claude agent requires an API key");
}
this.apiKey = config.apiKey; this.apiKey = config.apiKey;
this.githubInstallationToken = config.githubInstallationToken; this.githubInstallationToken = config.githubInstallationToken;
} }
@@ -51,10 +49,7 @@ export class ClaudeAgent implements Agent {
try { try {
const result = await spawn({ const result = await spawn({
cmd: "bash", cmd: "bash",
args: [ args: ["-c", "curl -fsSL https://claude.ai/install.sh | bash -s 1.0.93"],
"-c",
"curl -fsSL https://claude.ai/install.sh | bash -s 1.0.93",
],
env: { ANTHROPIC_API_KEY: this.apiKey }, env: { ANTHROPIC_API_KEY: this.apiKey },
timeout: 120000, // 2 minute timeout timeout: 120000, // 2 minute timeout
onStdout: () => {}, onStdout: () => {},
@@ -62,9 +57,7 @@ export class ClaudeAgent implements Agent {
}); });
if (result.exitCode !== 0) { if (result.exitCode !== 0) {
throw new Error( throw new Error(`Installation failed with exit code ${result.exitCode}: ${result.stderr}`);
`Installation failed with exit code ${result.exitCode}: ${result.stderr}`
);
} }
core.info("Claude Code installed successfully"); core.info("Claude Code installed successfully");
@@ -81,7 +74,16 @@ export class ClaudeAgent implements Agent {
try { try {
const claudePath = `${process.env.HOME}/.local/bin/claude`; const claudePath = `${process.env.HOME}/.local/bin/claude`;
const env = {
ANTHROPIC_API_KEY: this.apiKey,
};
console.log(boxString(prompt, { title: "Prompt" })); console.log(boxString(prompt, { title: "Prompt" }));
const mcpConfig = createMcpConfig(this.githubInstallationToken);
console.log("📋 MCP Config:", mcpConfig);
const args = [ const args = [
"--print", "--print",
"--output-format", "--output-format",
@@ -90,22 +92,10 @@ export class ClaudeAgent implements Agent {
"--debug", "--debug",
"--permission-mode", "--permission-mode",
"bypassPermissions", "bypassPermissions",
"--mcp-config",
mcpConfig,
]; ];
if (!this.githubInstallationToken) {
throw new Error(
"GITHUB_INSTALLATION_TOKEN is required for GitHub integration"
);
}
const mcpConfig = createMcpConfig(this.githubInstallationToken);
console.log("📋 MCP Config:", mcpConfig);
args.push("--mcp-config", mcpConfig);
const env = {
ANTHROPIC_API_KEY: this.apiKey,
};
core.startGroup("🔄 Run details"); core.startGroup("🔄 Run details");
this.runStats = { this.runStats = {
@@ -121,7 +111,7 @@ export class ClaudeAgent implements Agent {
cmd: claudePath, cmd: claudePath,
args, args,
env, env,
input: prompt, input: `${instructions} ${prompt}`,
timeout: 10 * 60 * 1000, // 10 minutes timeout: 10 * 60 * 1000, // 10 minutes
onStdout: (_chunk) => { onStdout: (_chunk) => {
processJSONChunk(_chunk, this); processJSONChunk(_chunk, this);
@@ -161,8 +151,7 @@ export class ClaudeAgent implements Agent {
try { try {
core.endGroup(); core.endGroup();
} catch {} } catch {}
const errorMessage = const errorMessage = error instanceof Error ? error.message : "Unknown error";
error instanceof Error ? error.message : "Unknown error";
return { return {
success: false, success: false,
error: `Failed to execute Claude Code: ${errorMessage}`, error: `Failed to execute Claude Code: ${errorMessage}`,
@@ -176,8 +165,15 @@ export class ClaudeAgent implements Agent {
*/ */
function processJSONChunk(chunk: string, agent?: ClaudeAgent): void { function processJSONChunk(chunk: string, agent?: ClaudeAgent): void {
try { try {
// Skip debug lines that start with [DEBUG] or [debug]
const trimmedChunk = chunk.trim();
if (trimmedChunk.startsWith("[DEBUG]") || trimmedChunk.startsWith("[debug]")) {
console.log(chunk); console.log(chunk);
const parsedChunk = JSON.parse(chunk.trim()); return;
}
console.log(chunk);
const parsedChunk = JSON.parse(trimmedChunk);
switch (parsedChunk.type) { switch (parsedChunk.type) {
case "system": case "system":
@@ -188,12 +184,7 @@ function processJSONChunk(chunk: string, agent?: ClaudeAgent): void {
["model", parsedChunk.model], ["model", parsedChunk.model],
["cwd", parsedChunk.cwd], ["cwd", parsedChunk.cwd],
["permission_mode", parsedChunk.permissionMode], ["permission_mode", parsedChunk.permissionMode],
[ ["tools", parsedChunk.tools?.length ? `${parsedChunk.tools.length} tools` : "none"],
"tools",
parsedChunk.tools?.length
? `${parsedChunk.tools.length} tools`
: "none",
],
[ [
"mcp_servers", "mcp_servers",
parsedChunk.mcp_servers?.length parsedChunk.mcp_servers?.length
@@ -220,9 +211,7 @@ function processJSONChunk(chunk: string, agent?: ClaudeAgent): void {
for (const content of parsedChunk.message.content) { for (const content of parsedChunk.message.content) {
if (content.type === "text") { if (content.type === "text") {
if (content.text.trim()) { if (content.text.trim()) {
core.info( core.info(boxString(content.text.trim(), { title: "Claude Code" }));
boxString(content.text.trim(), { title: "Claude Code" })
);
} }
} else if (content.type === "tool_use") { } else if (content.type === "tool_use") {
if (agent) { if (agent) {
@@ -307,10 +296,7 @@ function processJSONChunk(chunk: string, agent?: ClaudeAgent): void {
if (parsedChunk.subtype === "success") { if (parsedChunk.subtype === "success") {
core.info( core.info(
tableString([ tableString([
[ ["Cost", `$${parsedChunk.total_cost_usd?.toFixed(4) || "0.0000"}`],
"Cost",
`$${parsedChunk.total_cost_usd?.toFixed(4) || "0.0000"}`,
],
["Input Tokens", parsedChunk.usage?.input_tokens || 0], ["Input Tokens", parsedChunk.usage?.input_tokens || 0],
["Output Tokens", parsedChunk.usage?.output_tokens || 0], ["Output Tokens", parsedChunk.usage?.output_tokens || 0],
["Duration", `${parsedChunk.duration_ms}ms`], ["Duration", `${parsedChunk.duration_ms}ms`],
+8
View File
@@ -0,0 +1,8 @@
import { mcpServerName } from "../mcp/config.ts";
export const instructions = `- use the ${mcpServerName} MCP server to interact with github
- if ${mcpServerName} is not available or doesn't include the functionality you need, describe why and bail
- do not under any circumstances use the gh cli
- if prompted by a comment to respond to create a new issue, pr or anything else, after succeeding,
also respond to the original comment with a very brief message containing a link to it
`;
+2 -2
View File
@@ -29,6 +29,6 @@ export interface AgentResult {
* Configuration for agent creation * Configuration for agent creation
*/ */
export interface AgentConfig { export interface AgentConfig {
apiKey?: string; apiKey: string;
[key: string]: any; githubInstallationToken: string;
} }
+1 -3
View File
@@ -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. create a PR implementing bogosort to https://github.com/pullfrogai/scratch/
Do not use the gh cli. If the mcp tool does not work, bail.
+8 -2
View File
@@ -1,7 +1,8 @@
import * as core from "@actions/core"; import * as core from "@actions/core";
import { type } from "arktype"; import { type } from "arktype";
import { ClaudeAgent } from "./agents/claude.ts"; import { ClaudeAgent } from "./agents/claude.ts";
import { setupGitHubInstallationToken } from "./utils/github.ts"; import { setupGitHubInstallationToken, parseRepoContext } from "./utils/github.ts";
import { setupGitConfig, setupGitAuth } from "./utils/setup.ts";
export const Inputs = type({ export const Inputs = type({
prompt: "string", prompt: "string",
@@ -20,13 +21,18 @@ export async function main(inputs: Inputs): Promise<MainResult> {
try { try {
core.info(`→ Starting agent run with Claude Code`); core.info(`→ Starting agent run with Claude Code`);
// Setup GitHub installation token setupGitConfig();
const githubInstallationToken = await setupGitHubInstallationToken(); const githubInstallationToken = await setupGitHubInstallationToken();
const repoContext = parseRepoContext();
setupGitAuth(githubInstallationToken, repoContext);
const agent = new ClaudeAgent({ const agent = new ClaudeAgent({
apiKey: inputs.anthropic_api_key!, apiKey: inputs.anthropic_api_key!,
githubInstallationToken, githubInstallationToken,
}); });
await agent.install(); await agent.install();
const result = await agent.execute(inputs.prompt); const result = await agent.execute(inputs.prompt);
+6 -44
View File
@@ -1,7 +1,5 @@
import { Octokit } from "@octokit/rest";
import { type } from "arktype"; import { type } from "arktype";
import { resolveRepoContext } from "../utils/repo-context.ts"; import { contextualize, tool } from "./shared.ts";
import { tool } from "./shared.ts";
export const Comment = type({ export const Comment = type({
issueNumber: type.number.describe("the issue number to comment on"), issueNumber: type.number.describe("the issue number to comment on"),
@@ -12,55 +10,19 @@ export const CommentTool = tool({
name: "create_issue_comment", name: "create_issue_comment",
description: "Create a comment on a GitHub issue", description: "Create a comment on a GitHub issue",
parameters: Comment, parameters: Comment,
execute: async ({ issueNumber, body }: { issueNumber: number; body: string }) => { execute: contextualize(async ({ issueNumber, body }, ctx) => {
try { const result = await ctx.octokit.rest.issues.createComment({
const githubInstallationToken = process.env.GITHUB_INSTALLATION_TOKEN; owner: ctx.owner,
if (!githubInstallationToken) { repo: ctx.name,
throw new Error("GITHUB_INSTALLATION_TOKEN environment variable is required");
}
const repoContext = resolveRepoContext();
const octokit = new Octokit({
auth: githubInstallationToken,
});
const result = await octokit.rest.issues.createComment({
owner: repoContext.owner,
repo: repoContext.name,
issue_number: issueNumber, issue_number: issueNumber,
body: body, body: body,
}); });
return { return {
content: [
{
type: "text",
text: JSON.stringify(
{
success: true, success: true,
commentId: result.data.id, commentId: result.data.id,
url: result.data.html_url, url: result.data.html_url,
body: result.data.body, 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,
};
}
},
}); });
+6 -7
View File
@@ -2,21 +2,20 @@
* Simple MCP configuration helper for adding our minimal GitHub comment server * Simple MCP configuration helper for adding our minimal GitHub comment server
*/ */
import { fromHere } from "@ark/fs"; import { fromHere } from "@ark/fs";
import { parseRepoContext } from "../utils/github.ts";
const actionPath = fromHere(".."); const actionPath = fromHere("..");
export const mcpServerName = "gh-pullfrog";
export function createMcpConfig(githubInstallationToken: string) { export function createMcpConfig(githubInstallationToken: string) {
const githubRepository = process.env.GITHUB_REPOSITORY; const repoContext = parseRepoContext();
if (!githubRepository) { const githubRepository = `${repoContext.owner}/${repoContext.name}`;
throw new Error(
"GITHUB_REPOSITORY environment variable is required for MCP GitHub integration"
);
}
return JSON.stringify( return JSON.stringify(
{ {
mcpServers: { mcpServers: {
minimal_github_comment: { [mcpServerName]: {
command: "node", command: "node",
args: [`${actionPath}/mcp/server.ts`], args: [`${actionPath}/mcp/server.ts`],
env: { env: {
+42
View File
@@ -0,0 +1,42 @@
import { type } from "arktype";
import { contextualize, tool } from "./shared.ts";
export const Issue = type({
title: type.string.describe("the title of the issue"),
body: type.string.describe("the body content of the issue"),
labels: type.string
.array()
.describe("optional array of label names to apply to the issue")
.optional(),
assignees: type.string
.array()
.describe("optional array of usernames to assign to the issue")
.optional(),
});
export const IssueTool = tool({
name: "create_issue",
description: "Create a new GitHub issue",
parameters: Issue,
execute: contextualize(async ({ title, body, labels, assignees }, ctx) => {
const result = await ctx.octokit.rest.issues.create({
owner: ctx.owner,
repo: ctx.name,
title: title,
body: body,
labels: labels ?? [],
assignees: assignees ?? [],
});
return {
success: true,
issueId: result.data.id,
number: result.data.number,
url: result.data.html_url,
title: result.data.title,
state: result.data.state,
labels: result.data.labels?.map((label) => (typeof label === "string" ? label : label.name)),
assignees: result.data.assignees?.map((assignee) => assignee.login),
};
}),
});
+42
View File
@@ -0,0 +1,42 @@
import { execSync } from "node:child_process";
import { type } from "arktype";
import { contextualize, tool } from "./shared.ts";
export const PullRequest = type({
title: type.string.describe("the title of the pull request"),
body: type.string.describe("the body content of the pull request"),
base: type.string.describe("the base branch to merge into (e.g., 'main')"),
});
export const PullRequestTool = tool({
name: "create_pull_request",
description: "Create a pull request from the current branch",
parameters: PullRequest,
execute: contextualize(async ({ title, body, base }, ctx) => {
// Get the current branch name
const currentBranch = execSync("git rev-parse --abbrev-ref HEAD", {
encoding: "utf8",
}).trim();
console.log(`Current branch: ${currentBranch}`);
const result = await ctx.octokit.rest.pulls.create({
owner: ctx.owner,
repo: ctx.name,
title: title,
body: body,
head: currentBranch,
base: base,
});
return {
success: true,
pullRequestId: result.data.id,
number: result.data.number,
url: result.data.html_url,
title: result.data.title,
head: result.data.head.ref,
base: result.data.base.ref,
};
}),
});
+4 -2
View File
@@ -2,13 +2,15 @@
// Minimal GitHub Issue Comment MCP Server // Minimal GitHub Issue Comment MCP Server
import { FastMCP } from "fastmcp"; import { FastMCP } from "fastmcp";
import { CommentTool } from "./comment.ts"; import { CommentTool } from "./comment.ts";
import { IssueTool } from "./issue.ts";
import { PullRequestTool } from "./pr.ts";
import { addTools } from "./shared.ts"; import { addTools } from "./shared.ts";
const server = new FastMCP({ const server = new FastMCP({
name: "Minimal GitHub Issue Comment Server", name: "gh-pullfrog",
version: "0.0.1", version: "0.0.1",
}); });
addTools(server, [CommentTool]); addTools(server, [CommentTool, IssueTool, PullRequestTool]);
server.start(); server.start();
+68 -1
View File
@@ -1,7 +1,37 @@
import { cached } from "@ark/util";
import { Octokit } from "@octokit/rest";
import type { StandardSchemaV1 } from "@standard-schema/spec"; import type { StandardSchemaV1 } from "@standard-schema/spec";
import type { FastMCP, Tool } from "fastmcp"; import type { FastMCP, Tool } from "fastmcp";
import { parseRepoContext, type RepoContext } from "../utils/github.ts";
export const tool = <const params>(tool: Tool<{}, StandardSchemaV1<params>>) => tool; export interface ToolResult {
content: {
type: "text";
text: string;
}[];
error?: string;
isError?: boolean;
}
export const getMcpContext = cached((): McpContext => {
const githubInstallationToken = process.env.GITHUB_INSTALLATION_TOKEN;
if (!githubInstallationToken) {
throw new Error("GITHUB_INSTALLATION_TOKEN environment variable is required");
}
return {
...parseRepoContext(),
octokit: new Octokit({
auth: githubInstallationToken,
}),
};
});
export interface McpContext extends RepoContext {
octokit: Octokit;
}
export const tool = <const params>(tool: Tool<any, StandardSchemaV1<params>>) => tool;
export const addTools = (server: FastMCP, tools: Tool<any, any>[]) => { export const addTools = (server: FastMCP, tools: Tool<any, any>[]) => {
for (const tool of tools) { for (const tool of tools) {
@@ -9,3 +39,40 @@ export const addTools = (server: FastMCP, tools: Tool<any, any>[]) => {
} }
return server; return server;
}; };
export const contextualize =
<T>(executor: (params: T, ctx: McpContext) => Promise<Record<string, any>>) =>
async (params: T): Promise<ToolResult> => {
try {
const ctx = getMcpContext();
const result = await executor(params, ctx);
return handleToolSuccess(result);
} catch (error) {
return handleToolError(error);
}
};
const handleToolSuccess = (data: Record<string, any>): ToolResult => {
return {
content: [
{
type: "text",
text: JSON.stringify(data, null, 2),
},
],
};
};
const handleToolError = (error: unknown): ToolResult => {
const errorMessage = error instanceof Error ? error.message : String(error);
return {
content: [
{
type: "text",
text: `Error: ${errorMessage}`,
},
],
error: errorMessage,
isError: true,
};
};
+3 -2
View File
@@ -1,6 +1,6 @@
{ {
"name": "@pullfrog/action", "name": "@pullfrog/action",
"version": "0.0.50", "version": "0.0.57",
"type": "module", "type": "module",
"files": [ "files": [
"index.js", "index.js",
@@ -21,7 +21,8 @@
}, },
"dependencies": { "dependencies": {
"@actions/core": "^1.11.1", "@actions/core": "^1.11.1",
"@ark/fs": "0.49.0", "@ark/fs": "0.50.0",
"@ark/util": "0.50.0",
"@octokit/rest": "^22.0.0", "@octokit/rest": "^22.0.0",
"@octokit/webhooks-types": "^7.6.1", "@octokit/webhooks-types": "^7.6.1",
"@standard-schema/spec": "1.0.0", "@standard-schema/spec": "1.0.0",
+8 -5
View File
@@ -12,8 +12,11 @@ importers:
specifier: ^1.11.1 specifier: ^1.11.1
version: 1.11.1 version: 1.11.1
'@ark/fs': '@ark/fs':
specifier: 0.49.0 specifier: 0.50.0
version: 0.49.0 version: 0.50.0
'@ark/util':
specifier: 0.50.0
version: 0.50.0
'@octokit/rest': '@octokit/rest':
specifier: ^22.0.0 specifier: ^22.0.0
version: 22.0.0 version: 22.0.0
@@ -63,8 +66,8 @@ packages:
'@actions/io@1.1.3': '@actions/io@1.1.3':
resolution: {integrity: sha512-wi9JjgKLYS7U/z8PPbco+PvTb/nRWjeoFlJ1Qer83k/3C5PHQi28hiVdeE2kHXmIL99mQFawx8qt/JPjZilJ8Q==} resolution: {integrity: sha512-wi9JjgKLYS7U/z8PPbco+PvTb/nRWjeoFlJ1Qer83k/3C5PHQi28hiVdeE2kHXmIL99mQFawx8qt/JPjZilJ8Q==}
'@ark/fs@0.49.0': '@ark/fs@0.50.0':
resolution: {integrity: sha512-AEjAQS/bu1CGIRiKK/XLaQ73cSJHixfexq28wNt+kBpQ0h1RwVIVzaGsn/+5IWw6DEbR7LB+3hil5gzrzEeyZQ==} resolution: {integrity: sha512-6OrxNt2T+/pL4RUMZK/aiVRLIS3acNs5uSpHDyZAP6+OXwXchFSQ1lJTH+uuGBlDWeQxPD7VmymYXLF5s1Eyhw==}
'@ark/regex@0.0.0': '@ark/regex@0.0.0':
resolution: {integrity: sha512-p4vsWnd/LRGOdGQglbwOguIVhPmCAf5UzquvnDoxqhhPWTP84wWgi1INea8MgJ4SnI2gp37f13oA4Waz9vwNYg==} resolution: {integrity: sha512-p4vsWnd/LRGOdGQglbwOguIVhPmCAf5UzquvnDoxqhhPWTP84wWgi1INea8MgJ4SnI2gp37f13oA4Waz9vwNYg==}
@@ -792,7 +795,7 @@ snapshots:
'@actions/io@1.1.3': {} '@actions/io@1.1.3': {}
'@ark/fs@0.49.0': {} '@ark/fs@0.50.0': {}
'@ark/regex@0.0.0': '@ark/regex@0.0.0':
dependencies: dependencies:
+1 -4
View File
@@ -3,7 +3,7 @@ import { existsSync } from "node:fs";
import { dirname, join } from "node:path"; import { dirname, join } from "node:path";
import { fileURLToPath } from "node:url"; import { fileURLToPath } from "node:url";
import { config } from "dotenv"; import { config } from "dotenv";
import { buildAction, setupTestRepo } from "./setup.ts"; import { setupTestRepo } from "./setup.ts";
const __filename = fileURLToPath(import.meta.url); const __filename = fileURLToPath(import.meta.url);
const __dirname = dirname(__filename); const __dirname = dirname(__filename);
@@ -19,8 +19,6 @@ export function runAct(prompt: string): void {
config({ path: envPath }); config({ path: envPath });
buildAction(actionPath);
const workflowPath = join(tempDir, ".github", "workflows", "pullfrog.yml"); const workflowPath = join(tempDir, ".github", "workflows", "pullfrog.yml");
const distPath = join(actionPath, ".act-dist"); const distPath = join(actionPath, ".act-dist");
@@ -54,7 +52,6 @@ export function runAct(prompt: string): void {
} }
}); });
const actCommand = actCommandParts.join(" "); const actCommand = actCommandParts.join(" ");
console.log("🚀 Running act with prompt:"); console.log("🚀 Running act with prompt:");
+23 -2
View File
@@ -1,6 +1,5 @@
import { createSign } from "node:crypto"; import { createSign } from "node:crypto";
import * as core from "@actions/core"; import * as core from "@actions/core";
import { resolveRepoContext } from "./repo-context.ts";
export interface InstallationToken { export interface InstallationToken {
token: string; token: string;
@@ -208,7 +207,7 @@ const findInstallationId = async (
}; };
async function acquireTokenViaGitHubApp(): Promise<string> { async function acquireTokenViaGitHubApp(): Promise<string> {
const repoContext = resolveRepoContext(); const repoContext = parseRepoContext();
const config: GitHubAppConfig = { const config: GitHubAppConfig = {
appId: process.env.GITHUB_APP_ID!, appId: process.env.GITHUB_APP_ID!,
@@ -250,3 +249,25 @@ export async function setupGitHubInstallationToken(): Promise<string> {
return token; return token;
} }
export interface RepoContext {
owner: string;
name: string;
}
/**
* Parse repository context from GITHUB_REPOSITORY environment variable.
*/
export function parseRepoContext(): RepoContext {
const githubRepo = process.env.GITHUB_REPOSITORY;
if (!githubRepo) {
throw new Error("GITHUB_REPOSITORY environment variable is required");
}
const [owner, name] = githubRepo.split("/");
if (!owner || !name) {
throw new Error(`Invalid GITHUB_REPOSITORY format: ${githubRepo}. Expected 'owner/repo'`);
}
return { owner, name };
}
-22
View File
@@ -1,22 +0,0 @@
export interface RepoContext {
owner: string;
name: string;
}
/**
* Resolve repository context from GITHUB_REPOSITORY environment variable.
* Throws if not available.
*/
export function resolveRepoContext(): 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 };
}
+26 -7
View File
@@ -1,5 +1,6 @@
import { execSync } from "node:child_process"; import { execSync } from "node:child_process";
import { existsSync, rmSync } from "node:fs"; import { existsSync, rmSync } from "node:fs";
import type { RepoContext } from "./github.ts";
export interface SetupOptions { export interface SetupOptions {
tempDir: string; tempDir: string;
@@ -38,12 +39,30 @@ export function setupTestRepo(options: SetupOptions): void {
} }
/** /**
* Build the action bundles * Setup git configuration to avoid identity errors
*/ */
export function buildAction(actionPath: string): void { export function setupGitConfig(): void {
console.log("🔨 Building fresh bundles with esbuild..."); console.log("🔧 Setting up git configuration...");
execSync("node esbuild.config.js", { execSync('git config --global user.email "action@pullfrog.ai"', { stdio: "inherit" });
cwd: actionPath, execSync('git config --global user.name "Pullfrog Action"', { stdio: "inherit" });
stdio: "inherit", }
});
/**
* Setup git authentication using GitHub token
*/
export function setupGitAuth(githubToken: string, repoContext: RepoContext): void {
console.log("🔐 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" });
console.log("✓ Removed existing authentication headers");
} catch {
console.log("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" });
console.log("✓ Updated remote URL with authentication token");
} }
+3 -1
View File
@@ -6,6 +6,7 @@ export interface SpawnOptions {
env?: Record<string, string>; env?: Record<string, string>;
input?: string; input?: string;
timeout?: number; timeout?: number;
cwd?: string;
onStdout?: (chunk: string) => void; onStdout?: (chunk: string) => void;
onStderr?: (chunk: string) => void; onStderr?: (chunk: string) => void;
} }
@@ -21,7 +22,7 @@ export interface SpawnResult {
* Spawn a subprocess with streaming callbacks and buffered results * Spawn a subprocess with streaming callbacks and buffered results
*/ */
export async function spawn(options: SpawnOptions): Promise<SpawnResult> { 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(); const startTime = Date.now();
let stdoutBuffer = ""; let stdoutBuffer = "";
@@ -31,6 +32,7 @@ export async function spawn(options: SpawnOptions): Promise<SpawnResult> {
const child = nodeSpawn(cmd, args, { const child = nodeSpawn(cmd, args, {
env: env ? { ...process.env, ...env } : process.env, env: env ? { ...process.env, ...env } : process.env,
stdio: ["pipe", "pipe", "pipe"], stdio: ["pipe", "pipe", "pipe"],
cwd: cwd || process.cwd(),
}); });
let timeoutId: NodeJS.Timeout | undefined; let timeoutId: NodeJS.Timeout | undefined;