Compare commits
5 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| ddb481f14e | |||
| 1b55da51a1 | |||
| 7c724d931b | |||
| 57e72ddf2b | |||
| 6f2ccedbf8 |
@@ -13,6 +13,15 @@ inputs:
|
|||||||
openai_api_key:
|
openai_api_key:
|
||||||
description: "OpenAI API key for Codex authentication"
|
description: "OpenAI API key for Codex authentication"
|
||||||
required: false
|
required: false
|
||||||
|
google_api_key:
|
||||||
|
description: "Google API key for Jules authentication"
|
||||||
|
required: false
|
||||||
|
gemini_api_key:
|
||||||
|
description: "Gemini API key for Jules authentication"
|
||||||
|
required: false
|
||||||
|
cursor_api_key:
|
||||||
|
description: "Cursor API key for Cursor authentication"
|
||||||
|
required: false
|
||||||
|
|
||||||
runs:
|
runs:
|
||||||
using: "node20"
|
using: "node20"
|
||||||
|
|||||||
+1
-1
@@ -6,7 +6,7 @@ import { agent, installFromNpmTarball } from "./shared.ts";
|
|||||||
|
|
||||||
export const claude = agent({
|
export const claude = agent({
|
||||||
name: "claude",
|
name: "claude",
|
||||||
inputKey: "anthropic_api_key",
|
inputKeys: ["anthropic_api_key"],
|
||||||
install: async () => {
|
install: async () => {
|
||||||
const versionRange = packageJson.dependencies["@anthropic-ai/claude-agent-sdk"] || "latest";
|
const versionRange = packageJson.dependencies["@anthropic-ai/claude-agent-sdk"] || "latest";
|
||||||
return await installFromNpmTarball({
|
return await installFromNpmTarball({
|
||||||
|
|||||||
+1
-1
@@ -7,7 +7,7 @@ import { agent, installFromNpmTarball } from "./shared.ts";
|
|||||||
|
|
||||||
export const codex = agent({
|
export const codex = agent({
|
||||||
name: "codex",
|
name: "codex",
|
||||||
inputKey: "openai_api_key",
|
inputKeys: ["openai_api_key"],
|
||||||
install: async () => {
|
install: async () => {
|
||||||
return await installFromNpmTarball({
|
return await installFromNpmTarball({
|
||||||
packageName: "@openai/codex",
|
packageName: "@openai/codex",
|
||||||
|
|||||||
+3
-1
@@ -1,9 +1,11 @@
|
|||||||
import { claude } from "./claude.ts";
|
import { claude } from "./claude.ts";
|
||||||
import { codex } from "./codex.ts";
|
import { codex } from "./codex.ts";
|
||||||
|
import { jules } from "./jules.ts";
|
||||||
|
|
||||||
export const agents = {
|
export const agents = {
|
||||||
claude,
|
claude,
|
||||||
codex,
|
codex,
|
||||||
|
jules,
|
||||||
} as const;
|
} as const;
|
||||||
|
|
||||||
export type AgentInputKey = (typeof agents)[keyof typeof agents]["inputKey"];
|
export type AgentInputKey = (typeof agents)[keyof typeof agents]["inputKeys"][number];
|
||||||
|
|||||||
+174
@@ -0,0 +1,174 @@
|
|||||||
|
import { log } from "../utils/cli.ts";
|
||||||
|
import { parseRepoContext } from "../utils/github.ts";
|
||||||
|
import { spawn } from "../utils/subprocess.ts";
|
||||||
|
import { addInstructions } from "./instructions.ts";
|
||||||
|
import { agent, installFromNpmTarball } from "./shared.ts";
|
||||||
|
|
||||||
|
export const jules = agent({
|
||||||
|
name: "jules",
|
||||||
|
inputKeys: ["google_api_key", "gemini_api_key"],
|
||||||
|
install: async () => {
|
||||||
|
return await installFromNpmTarball({
|
||||||
|
packageName: "@google/jules",
|
||||||
|
version: "latest",
|
||||||
|
executablePath: "run.cjs",
|
||||||
|
});
|
||||||
|
},
|
||||||
|
run: async ({
|
||||||
|
prompt,
|
||||||
|
apiKey,
|
||||||
|
mcpServers: _mcpServers,
|
||||||
|
githubInstallationToken: _githubInstallationToken,
|
||||||
|
cliPath,
|
||||||
|
}) => {
|
||||||
|
if (!apiKey) {
|
||||||
|
throw new Error("google_api_key is required for jules agent");
|
||||||
|
}
|
||||||
|
process.env.GOOGLE_API_KEY = apiKey;
|
||||||
|
|
||||||
|
const repoContext = parseRepoContext();
|
||||||
|
const repoName = `${repoContext.owner}/${repoContext.name}`;
|
||||||
|
|
||||||
|
log.info(`Creating Jules session for ${repoName}...`);
|
||||||
|
|
||||||
|
// Create a new remote session
|
||||||
|
const sessionPrompt = addInstructions(prompt);
|
||||||
|
log.info(`Starting session with prompt: ${prompt.substring(0, 100)}...`);
|
||||||
|
|
||||||
|
let sessionId: string | undefined;
|
||||||
|
try {
|
||||||
|
const createResult = await spawn({
|
||||||
|
cmd: "node",
|
||||||
|
args: [cliPath, "remote", "new", "--repo", repoName, "--session", sessionPrompt],
|
||||||
|
onStdout: (chunk) => {
|
||||||
|
log.info(chunk.trim());
|
||||||
|
// Try to extract session ID from output
|
||||||
|
const match = chunk.match(/session[:\s]+(\d+)/i) || chunk.match(/id[:\s]+(\d+)/i);
|
||||||
|
if (match && !sessionId) {
|
||||||
|
sessionId = match[1];
|
||||||
|
log.info(`✓ Session ID: ${sessionId}`);
|
||||||
|
}
|
||||||
|
},
|
||||||
|
onStderr: (chunk) => {
|
||||||
|
log.warning(chunk.trim());
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
if (createResult.exitCode !== 0) {
|
||||||
|
throw new Error(
|
||||||
|
`Failed to create Jules session: ${createResult.stderr || createResult.stdout || "Unknown error"}`
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
// If session ID wasn't extracted from stdout, try to parse it
|
||||||
|
if (!sessionId) {
|
||||||
|
const output = createResult.stdout + createResult.stderr;
|
||||||
|
const match = output.match(/session[:\s]+(\d+)/i) || output.match(/id[:\s]+(\d+)/i);
|
||||||
|
if (match) {
|
||||||
|
sessionId = match[1];
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!sessionId) {
|
||||||
|
log.warning("Could not extract session ID from output. Session may have been created.");
|
||||||
|
log.info(`Output: ${createResult.stdout}`);
|
||||||
|
} else {
|
||||||
|
log.info(`✓ Session created: ${sessionId}`);
|
||||||
|
}
|
||||||
|
} catch (error) {
|
||||||
|
const errorMessage = error instanceof Error ? error.message : String(error);
|
||||||
|
log.error(`Failed to create Jules session: ${errorMessage}`);
|
||||||
|
return {
|
||||||
|
success: false,
|
||||||
|
error: errorMessage,
|
||||||
|
output: "",
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
// Monitor session progress by polling session list
|
||||||
|
log.info("Monitoring session progress...");
|
||||||
|
let finalOutput = "";
|
||||||
|
const maxPollAttempts = 300; // ~50 minutes max (10 second intervals)
|
||||||
|
let pollAttempts = 0;
|
||||||
|
|
||||||
|
while (pollAttempts < maxPollAttempts) {
|
||||||
|
await new Promise((resolve) => setTimeout(resolve, 10000)); // Wait 10 seconds between polls
|
||||||
|
pollAttempts++;
|
||||||
|
|
||||||
|
try {
|
||||||
|
// List sessions to check status
|
||||||
|
const listResult = await spawn({
|
||||||
|
cmd: "node",
|
||||||
|
args: [cliPath, "remote", "list", "--session"],
|
||||||
|
onStdout: (chunk) => {
|
||||||
|
// Log session updates
|
||||||
|
const trimmed = chunk.trim();
|
||||||
|
if (trimmed) {
|
||||||
|
log.info(trimmed);
|
||||||
|
}
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
if (listResult.exitCode === 0) {
|
||||||
|
const output = listResult.stdout;
|
||||||
|
// Check if our session is complete
|
||||||
|
// The CLI output format may vary, so we look for completion indicators
|
||||||
|
if (sessionId && output.includes(sessionId)) {
|
||||||
|
// Try to determine if session is complete
|
||||||
|
// This is a heuristic - the actual output format may differ
|
||||||
|
if (
|
||||||
|
output.includes("completed") ||
|
||||||
|
output.includes("done") ||
|
||||||
|
output.includes("finished")
|
||||||
|
) {
|
||||||
|
log.info("Session appears to be completed");
|
||||||
|
finalOutput = "Session completed. Pulling results...";
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
} catch (error) {
|
||||||
|
const errorMessage = error instanceof Error ? error.message : String(error);
|
||||||
|
log.warning(`Error checking session status: ${errorMessage}`);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Pull results if session ID is available
|
||||||
|
if (sessionId) {
|
||||||
|
try {
|
||||||
|
log.info(`Pulling results for session ${sessionId}...`);
|
||||||
|
const pullResult = await spawn({
|
||||||
|
cmd: "node",
|
||||||
|
args: [cliPath, "remote", "pull", "--session", sessionId],
|
||||||
|
onStdout: (chunk) => {
|
||||||
|
log.info(chunk.trim());
|
||||||
|
},
|
||||||
|
onStderr: (chunk) => {
|
||||||
|
log.warning(chunk.trim());
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
if (pullResult.exitCode === 0) {
|
||||||
|
finalOutput = pullResult.stdout || "Results pulled successfully.";
|
||||||
|
} else {
|
||||||
|
log.warning(`Failed to pull results: ${pullResult.stderr || pullResult.stdout}`);
|
||||||
|
finalOutput = finalOutput || "Session completed. Check Jules dashboard for results.";
|
||||||
|
}
|
||||||
|
} catch (error) {
|
||||||
|
const errorMessage = error instanceof Error ? error.message : String(error);
|
||||||
|
log.warning(`Error pulling results: ${errorMessage}`);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (pollAttempts >= maxPollAttempts) {
|
||||||
|
log.warning("Session monitoring timeout reached. Session may still be in progress.");
|
||||||
|
finalOutput =
|
||||||
|
finalOutput || "Session monitoring timeout. Check Jules dashboard for session status.";
|
||||||
|
}
|
||||||
|
|
||||||
|
return {
|
||||||
|
success: true,
|
||||||
|
output: finalOutput || "Jules session completed. Check the Jules dashboard for results.",
|
||||||
|
};
|
||||||
|
},
|
||||||
|
});
|
||||||
+1
-1
@@ -133,7 +133,7 @@ export const agent = <const agent extends Agent>(agent: agent): agent => {
|
|||||||
|
|
||||||
export type Agent = {
|
export type Agent = {
|
||||||
name: string;
|
name: string;
|
||||||
inputKey: string;
|
inputKeys: string[];
|
||||||
install: () => Promise<string>;
|
install: () => Promise<string>;
|
||||||
run: (config: AgentConfig) => Promise<AgentResult>;
|
run: (config: AgentConfig) => Promise<AgentResult>;
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -5,6 +5,8 @@
|
|||||||
*/
|
*/
|
||||||
|
|
||||||
import * as core from "@actions/core";
|
import * as core from "@actions/core";
|
||||||
|
import { flatMorph } from "@ark/util";
|
||||||
|
import { agents } from "./agents/index.ts";
|
||||||
import { AgentName, type Inputs, main } from "./main.ts";
|
import { AgentName, type Inputs, main } from "./main.ts";
|
||||||
import { log } from "./utils/cli.ts";
|
import { log } from "./utils/cli.ts";
|
||||||
|
|
||||||
@@ -20,9 +22,10 @@ async function run(): Promise<void> {
|
|||||||
try {
|
try {
|
||||||
const inputs: Required<Inputs> = {
|
const inputs: Required<Inputs> = {
|
||||||
prompt: core.getInput("prompt", { required: true }),
|
prompt: core.getInput("prompt", { required: true }),
|
||||||
anthropic_api_key: core.getInput("anthropic_api_key"),
|
|
||||||
openai_api_key: core.getInput("openai_api_key"),
|
|
||||||
agent: core.getInput("agent") ? AgentName.assert(core.getInput("agent")) : undefined,
|
agent: core.getInput("agent") ? AgentName.assert(core.getInput("agent")) : undefined,
|
||||||
|
...flatMorph(agents, (_, agent) =>
|
||||||
|
agent.inputKeys.map((inputKey) => [inputKey, core.getInput(inputKey)])
|
||||||
|
),
|
||||||
};
|
};
|
||||||
|
|
||||||
const result = await main(inputs);
|
const result = await main(inputs);
|
||||||
|
|||||||
@@ -7,6 +7,7 @@ import { fetchRepoSettings } from "./utils/api.ts";
|
|||||||
import { log } from "./utils/cli.ts";
|
import { log } from "./utils/cli.ts";
|
||||||
import {
|
import {
|
||||||
parseRepoContext,
|
parseRepoContext,
|
||||||
|
type RepoContext,
|
||||||
revokeInstallationToken,
|
revokeInstallationToken,
|
||||||
setupGitHubInstallationToken,
|
setupGitHubInstallationToken,
|
||||||
} from "./utils/github.ts";
|
} from "./utils/github.ts";
|
||||||
@@ -16,13 +17,12 @@ export const AgentName = type.enumerated(...Object.values(agents).map((agent) =>
|
|||||||
export type AgentName = typeof AgentName.infer;
|
export type AgentName = typeof AgentName.infer;
|
||||||
|
|
||||||
export const AgentInputKey = type.enumerated(
|
export const AgentInputKey = type.enumerated(
|
||||||
...Object.values(agents).map((agent) => agent.inputKey)
|
...Object.values(agents).flatMap((agent) => agent.inputKeys)
|
||||||
);
|
);
|
||||||
export type AgentInputKey = typeof AgentInputKey.infer;
|
export type AgentInputKey = typeof AgentInputKey.infer;
|
||||||
|
|
||||||
const keyInputDefs = flatMorph(
|
const keyInputDefs = flatMorph(agents, (_, agent) =>
|
||||||
agents,
|
agent.inputKeys.map((inputKey) => [inputKey, "string | undefined?"] as const)
|
||||||
(_, agent) => [agent.inputKey, "string | undefined?"] as const
|
|
||||||
);
|
);
|
||||||
|
|
||||||
export const Inputs = type({
|
export const Inputs = type({
|
||||||
@@ -39,7 +39,54 @@ export interface MainResult {
|
|||||||
error?: string | undefined;
|
error?: string | undefined;
|
||||||
}
|
}
|
||||||
|
|
||||||
export type PromptJSON = {};
|
/**
|
||||||
|
* Throw an error for missing API key with helpful message linking to repo settings
|
||||||
|
*/
|
||||||
|
function throwMissingApiKeyError({
|
||||||
|
agentName,
|
||||||
|
inputKeys,
|
||||||
|
repoContext,
|
||||||
|
inputs,
|
||||||
|
}: {
|
||||||
|
agentName: string;
|
||||||
|
inputKeys: string[];
|
||||||
|
repoContext: RepoContext;
|
||||||
|
inputs: Inputs;
|
||||||
|
}): never {
|
||||||
|
const apiUrl = process.env.API_URL || "https://pullfrog.ai";
|
||||||
|
const settingsUrl = `${apiUrl}/console/${repoContext.owner}/${repoContext.name}`;
|
||||||
|
|
||||||
|
const secretNames = inputKeys.map((key) => `\`${key.toUpperCase()}\``);
|
||||||
|
const secretNameList =
|
||||||
|
inputKeys.length === 1 ? secretNames[0] : `one of ${secretNames.join(" or ")}`;
|
||||||
|
|
||||||
|
const githubRepoUrl = `https://github.com/${repoContext.owner}/${repoContext.name}`;
|
||||||
|
const githubSecretsUrl = `${githubRepoUrl}/settings/secrets/actions`;
|
||||||
|
|
||||||
|
// Find which agents have inputKeys that match the provided inputs
|
||||||
|
const availableAgents = Object.values(agents).filter((agent) =>
|
||||||
|
agent.inputKeys.some((inputKey) => inputs[inputKey])
|
||||||
|
);
|
||||||
|
|
||||||
|
let message = `Pullfrog is configured to use ${agentName}, but the associated API key was not provided.
|
||||||
|
|
||||||
|
To fix this, add the required secret to your GitHub repository:
|
||||||
|
|
||||||
|
1. Go to: ${githubSecretsUrl}
|
||||||
|
2. Click "New repository secret"
|
||||||
|
3. Set the name to ${secretNameList}
|
||||||
|
4. Set the value to your API key
|
||||||
|
5. Click "Add secret"`;
|
||||||
|
|
||||||
|
// If other credentials are present, suggest alternative agents
|
||||||
|
if (availableAgents.length > 0) {
|
||||||
|
const agentNames = availableAgents.map((agent) => agent.name).join(", ");
|
||||||
|
message += `\n\nAlternatively, configure Pullfrog to use an agent with existing credentials in your environment (${agentNames}) at ${settingsUrl}`;
|
||||||
|
}
|
||||||
|
|
||||||
|
log.error(message);
|
||||||
|
throw new Error(message);
|
||||||
|
}
|
||||||
|
|
||||||
export async function main(inputs: Inputs): Promise<MainResult> {
|
export async function main(inputs: Inputs): Promise<MainResult> {
|
||||||
let tokenToRevoke: string | null = null;
|
let tokenToRevoke: string | null = null;
|
||||||
@@ -81,11 +128,18 @@ export async function main(inputs: Inputs): Promise<MainResult> {
|
|||||||
// if yes, check if it's a webhook payload or toJSON(github.event)
|
// if yes, check if it's a webhook payload or toJSON(github.event)
|
||||||
// for webhook payloads, check the specified `agent` field
|
// for webhook payloads, check the specified `agent` field
|
||||||
|
|
||||||
// Get API key based on agent type
|
const matchingInputKey = agent.inputKeys.find((inputKey) => inputs[inputKey]);
|
||||||
|
|
||||||
const apiKey = inputs[agent.inputKey];
|
if (!matchingInputKey) {
|
||||||
|
throwMissingApiKeyError({
|
||||||
|
agentName,
|
||||||
|
inputKeys: agent.inputKeys,
|
||||||
|
repoContext,
|
||||||
|
inputs,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
if (!apiKey) throw new Error(`${agent.inputKey} is required for ${agentName} agent`);
|
const apiKey = inputs[matchingInputKey]!;
|
||||||
|
|
||||||
const result = await agent.run({
|
const result = await agent.run({
|
||||||
prompt: inputs.prompt,
|
prompt: inputs.prompt,
|
||||||
|
|||||||
+1
-1
@@ -1,6 +1,6 @@
|
|||||||
{
|
{
|
||||||
"name": "@pullfrog/action",
|
"name": "@pullfrog/action",
|
||||||
"version": "0.0.104",
|
"version": "0.0.105",
|
||||||
"type": "module",
|
"type": "module",
|
||||||
"files": [
|
"files": [
|
||||||
"index.js",
|
"index.js",
|
||||||
|
|||||||
@@ -2,8 +2,10 @@ import { existsSync, readFileSync } from "node:fs";
|
|||||||
import { extname, join, resolve } from "node:path";
|
import { extname, join, resolve } from "node:path";
|
||||||
import { pathToFileURL } from "node:url";
|
import { pathToFileURL } from "node:url";
|
||||||
import { fromHere } from "@ark/fs";
|
import { fromHere } from "@ark/fs";
|
||||||
|
import { flatMorph } from "@ark/util";
|
||||||
import arg from "arg";
|
import arg from "arg";
|
||||||
import { config } from "dotenv";
|
import { config } from "dotenv";
|
||||||
|
import { agents } from "./agents/index.ts";
|
||||||
import { type Inputs, main } from "./main.ts";
|
import { type Inputs, main } from "./main.ts";
|
||||||
import { log } from "./utils/cli.ts";
|
import { log } from "./utils/cli.ts";
|
||||||
import { setupTestRepo } from "./utils/setup.ts";
|
import { setupTestRepo } from "./utils/setup.ts";
|
||||||
@@ -22,9 +24,10 @@ export async function run(
|
|||||||
|
|
||||||
const inputs: Required<Inputs> = {
|
const inputs: Required<Inputs> = {
|
||||||
prompt,
|
prompt,
|
||||||
openai_api_key: process.env.OPENAI_API_KEY,
|
agent: "claude",
|
||||||
anthropic_api_key: process.env.ANTHROPIC_API_KEY,
|
...flatMorph(agents, (_, agent) =>
|
||||||
agent: "codex",
|
agent.inputKeys.map((inputKey) => [inputKey, process.env[inputKey.toUpperCase()]])
|
||||||
|
),
|
||||||
};
|
};
|
||||||
|
|
||||||
const result = await main(inputs);
|
const result = await main(inputs);
|
||||||
|
|||||||
Reference in New Issue
Block a user