Compare commits

...

6 Commits

Author SHA1 Message Date
David Blass ddb481f14e bump version 2025-11-14 16:13:31 -05:00
David Blass 1b55da51a1 inputKeys array, missing key error message 2025-11-14 16:12:32 -05:00
David Blass 7c724d931b gemini_api_key 2025-11-14 15:41:55 -05:00
David Blass 57e72ddf2b iterate on jules 2025-11-14 15:40:15 -05:00
David Blass 6f2ccedbf8 begin jules support, derive inputs 2025-11-14 14:27:00 -05:00
David Blass d4a4dd59bb use working comment 2025-11-14 14:01:44 -05:00
16 changed files with 8437 additions and 7804 deletions
+9
View File
@@ -13,6 +13,15 @@ inputs:
openai_api_key:
description: "OpenAI API key for Codex authentication"
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:
using: "node20"
+1 -1
View File
@@ -6,7 +6,7 @@ import { agent, installFromNpmTarball } from "./shared.ts";
export const claude = agent({
name: "claude",
inputKey: "anthropic_api_key",
inputKeys: ["anthropic_api_key"],
install: async () => {
const versionRange = packageJson.dependencies["@anthropic-ai/claude-agent-sdk"] || "latest";
return await installFromNpmTarball({
+1 -1
View File
@@ -7,7 +7,7 @@ import { agent, installFromNpmTarball } from "./shared.ts";
export const codex = agent({
name: "codex",
inputKey: "openai_api_key",
inputKeys: ["openai_api_key"],
install: async () => {
return await installFromNpmTarball({
packageName: "@openai/codex",
+3 -1
View File
@@ -1,9 +1,11 @@
import { claude } from "./claude.ts";
import { codex } from "./codex.ts";
import { jules } from "./jules.ts";
export const agents = {
claude,
codex,
jules,
} as const;
export type AgentInputKey = (typeof agents)[keyof typeof agents]["inputKey"];
export type AgentInputKey = (typeof agents)[keyof typeof agents]["inputKeys"][number];
+174
View File
@@ -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
View File
@@ -133,7 +133,7 @@ export const agent = <const agent extends Agent>(agent: agent): agent => {
export type Agent = {
name: string;
inputKey: string;
inputKeys: string[];
install: () => Promise<string>;
run: (config: AgentConfig) => Promise<AgentResult>;
};
+8030 -7772
View File
File diff suppressed because it is too large Load Diff
+5 -2
View File
@@ -5,6 +5,8 @@
*/
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 { log } from "./utils/cli.ts";
@@ -20,9 +22,10 @@ async function run(): Promise<void> {
try {
const inputs: Required<Inputs> = {
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,
...flatMorph(agents, (_, agent) =>
agent.inputKeys.map((inputKey) => [inputKey, core.getInput(inputKey)])
),
};
const result = await main(inputs);
+1 -1
View File
@@ -1 +1 @@
choose a random animal emoji. add a comment to https://github.com/pullfrogai/scratch/issues/21 containing 50 of that emoji.
create a comment on https://github.com/pullfrogai/scratch/issues/21 with an implementation of an mcp tool for fetching issue comments from github
+62 -8
View File
@@ -7,6 +7,7 @@ import { fetchRepoSettings } from "./utils/api.ts";
import { log } from "./utils/cli.ts";
import {
parseRepoContext,
type RepoContext,
revokeInstallationToken,
setupGitHubInstallationToken,
} 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 const AgentInputKey = type.enumerated(
...Object.values(agents).map((agent) => agent.inputKey)
...Object.values(agents).flatMap((agent) => agent.inputKeys)
);
export type AgentInputKey = typeof AgentInputKey.infer;
const keyInputDefs = flatMorph(
agents,
(_, agent) => [agent.inputKey, "string | undefined?"] as const
const keyInputDefs = flatMorph(agents, (_, agent) =>
agent.inputKeys.map((inputKey) => [inputKey, "string | undefined?"] as const)
);
export const Inputs = type({
@@ -39,7 +39,54 @@ export interface MainResult {
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> {
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)
// 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({
prompt: inputs.prompt,
+57
View File
@@ -101499,6 +101499,61 @@ var EditCommentTool = tool({
};
})
});
var workingCommentId = null;
var WorkingComment = type({
issueNumber: type.number.describe("the issue number to comment on"),
intent: type("/^I'll .+$/").describe(
"the body of the initial comment expressing your intent to handle the request. must have the form 'I'll {summary of request}'"
)
});
var CreateWorkingCommentTool = tool({
name: "create_working_comment",
description: "Create an initial comment on a GitHub issue that will be updated as work progresses",
parameters: WorkingComment,
execute: contextualize(async ({ issueNumber, intent }, ctx) => {
if (workingCommentId) {
throw new Error("create_working_comment may not be called multiple times");
}
const result = await ctx.octokit.rest.issues.createComment({
owner: ctx.owner,
repo: ctx.name,
issue_number: issueNumber,
body: intent
});
workingCommentId = result.data.id;
return {
success: true,
commentId: result.data.id,
url: result.data.html_url,
body: result.data.body
};
})
});
var WorkingCommentUpdate = type({
body: type.string.describe("the new comment body content")
});
var UpdateWorkingCommentTool = tool({
name: "update_working_comment",
description: "Update a working comment on a GitHub issue",
parameters: WorkingCommentUpdate,
execute: contextualize(async ({ body }, ctx) => {
if (!workingCommentId) {
throw new Error("create_working_comment must be called before update_working_comment");
}
const result = await ctx.octokit.rest.issues.updateComment({
owner: ctx.owner,
repo: ctx.name,
comment_id: workingCommentId,
body
});
return {
success: true,
commentId: result.data.id,
url: result.data.html_url,
body: result.data.body
};
})
});
// mcp/issue.ts
var Issue = type({
@@ -101685,6 +101740,8 @@ var server = new FastMCP({
addTools(server, [
CreateCommentTool,
EditCommentTool,
CreateWorkingCommentTool,
UpdateWorkingCommentTool,
IssueTool,
PullRequestTool,
ReviewTool,
+66
View File
@@ -53,3 +53,69 @@ export const EditCommentTool = tool({
};
}),
});
let workingCommentId: number | null = null;
export const WorkingComment = type({
issueNumber: type.number.describe("the issue number to comment on"),
intent: type("/^I'll .+$/").describe(
"the body of the initial comment expressing your intent to handle the request. must have the form 'I'll {summary of request}'"
),
});
export const CreateWorkingCommentTool = tool({
name: "create_working_comment",
description:
"Create an initial comment on a GitHub issue that will be updated as work progresses",
parameters: WorkingComment,
execute: contextualize(async ({ issueNumber, intent }, ctx) => {
if (workingCommentId) {
throw new Error("create_working_comment may not be called multiple times");
}
const result = await ctx.octokit.rest.issues.createComment({
owner: ctx.owner,
repo: ctx.name,
issue_number: issueNumber,
body: intent,
});
workingCommentId = result.data.id;
return {
success: true,
commentId: result.data.id,
url: result.data.html_url,
body: result.data.body,
};
}),
});
export const WorkingCommentUpdate = type({
body: type.string.describe("the new comment body content"),
});
export const UpdateWorkingCommentTool = tool({
name: "update_working_comment",
description: "Update a working comment on a GitHub issue",
parameters: WorkingCommentUpdate,
execute: contextualize(async ({ body }, ctx) => {
if (!workingCommentId) {
throw new Error("create_working_comment must be called before update_working_comment");
}
const result = await ctx.octokit.rest.issues.updateComment({
owner: ctx.owner,
repo: ctx.name,
comment_id: workingCommentId,
body,
});
return {
success: true,
commentId: result.data.id,
url: result.data.html_url,
body: result.data.body,
};
}),
});
+8 -1
View File
@@ -1,7 +1,12 @@
#!/usr/bin/env node
// Minimal GitHub Issue Comment MCP Server
import { FastMCP } from "fastmcp";
import { CreateCommentTool, EditCommentTool } from "./comment.ts";
import {
CreateCommentTool,
CreateWorkingCommentTool,
EditCommentTool,
UpdateWorkingCommentTool,
} from "./comment.ts";
import { IssueTool } from "./issue.ts";
import { PullRequestTool } from "./pr.ts";
import { PullRequestInfoTool } from "./prInfo.ts";
@@ -16,6 +21,8 @@ const server = new FastMCP({
addTools(server, [
CreateCommentTool,
EditCommentTool,
CreateWorkingCommentTool,
UpdateWorkingCommentTool,
IssueTool,
PullRequestTool,
ReviewTool,
+12 -12
View File
@@ -6,49 +6,49 @@ export const modes = [
description:
"Create plans, break down tasks, outline steps, analyze requirements, understand scope of work, or provide task breakdowns",
prompt: `Follow these steps:
1. Create initial response comment using ${ghPullfrogMcpName}/create_issue_comment saying "I'll {summary of request}" and save the commentId
1. Create initial response comment using ${ghPullfrogMcpName}/create_working_comment saying "I'll {summary of request}"
2. If the request requires understanding the codebase structure, dependencies, or conventions, gather relevant context (read AGENTS.md if it exists, understand how to install dependencies, run tests, run builds, and make changes according to best practices). Skip this step if the prompt is trivial and self-contained.
3. Analyze the request and break it down into clear, actionable tasks
4. Consider dependencies, potential challenges, and implementation order
5. Create a structured plan with clear milestones
6. Update your comment using ${ghPullfrogMcpName}/edit_issue_comment with the commentId to present the plan in a clear, organized format
7. Continue updating the same comment as needed (never create additional comments - always use edit_issue_comment)`,
6. Update your comment using ${ghPullfrogMcpName}/update_working_comment to present the plan in a clear, organized format
7. Continue updating the same comment as needed (never create additional comments - always use update_working_comment)`,
},
{
name: "Build",
description:
"Implement, build, create, or develop code changes; make specific changes to files or features; execute a plan; or handle tasks with specific implementation details",
prompt: `Follow these steps:
1. Create initial response comment using ${ghPullfrogMcpName}/create_issue_comment saying "I'll {summary of request}" and save the commentId
1. Create initial response comment using ${ghPullfrogMcpName}/create_working_comment saying "I'll {summary of request}"
2. If the request requires understanding the codebase structure, dependencies, or conventions, gather relevant context (read AGENTS.md if it exists, understand how to install dependencies, run tests, run builds, and make changes according to best practices). Skip this step if the prompt is trivial and self-contained.
3. Understand the requirements and any existing plan
4. Make the necessary code changes
5. Test your changes to ensure they work correctly
6. Update your comment using ${ghPullfrogMcpName}/edit_issue_comment with the commentId to share progress and results
7. Continue updating the same comment as you make progress (never create additional comments - always use edit_issue_comment)`,
6. Update your comment using ${ghPullfrogMcpName}/update_working_comment to share progress and results
7. Continue updating the same comment as you make progress (never create additional comments - always use update_working_comment)`,
},
{
name: "Review",
description:
"Review code, PRs, or implementations; provide feedback or suggestions; identify issues; or check code quality, style, and correctness",
prompt: `Follow these steps:
1. Create initial response comment using ${ghPullfrogMcpName}/create_issue_comment saying "I'll review this" and save the commentId
1. Create initial response comment using ${ghPullfrogMcpName}/create_working_comment saying "I'll review this"
2. Get PR info with ${ghPullfrogMcpName}/get_pull_request (this automatically prepares the repository by fetching and checking out the PR branch)
3. View diff: git diff origin/<base>...origin/<head> (use line numbers from this for inline comments, replace <base> and <head> with 'base' and 'head' from PR info)
4. Read files from the checked-out PR branch to understand the implementation
5. Update your comment using ${ghPullfrogMcpName}/edit_issue_comment with findings as you review
5. Update your comment using ${ghPullfrogMcpName}/update_working_comment with findings as you review
6. When submitting review: use the 'comments' array for ALL specific code issues - include the file path and line position from the diff
7. Only use the 'body' field for a brief summary (1-2 sentences) or for feedback that doesn't apply to a specific code location
8. Continue updating the same comment as needed (never create additional comments - always use edit_issue_comment)`,
8. Continue updating the same comment as needed (never create additional comments - always use update_working_comment)`,
},
{
name: "Prompt",
description:
"Fallback for tasks that don't fit other workflows, direct prompts via comments, or requests requiring general assistance without a specific workflow pattern",
prompt: `Follow these steps:
1. Create an initial "Progress Comment" using ${ghPullfrogMcpName}/create_issue_comment saying "I'll {summary of request}" and save the commentId
1. Create an initial "Working Comment" using ${ghPullfrogMcpName}/create_working_comment saying "I'll {summary of request}"
2. Perform the requested task. Only take action if you have high confidence that you understand what is being asked. If you are not sure, ask for clarification. Take stock of the tools at your disposal.
3. As your work progresses, update your Progress Comment to share progress and results. Using ${ghPullfrogMcpName}/edit_issue_comment and the commentId you saved earlier. Do not create additional comments unless you are explicitly asked to do so.
4. When you finish the task, update the Progress Comment a final time to confirm completion. If you created any issues, PRs, etc, include appropriate links to it here.`,
3. As your work progresses, update your Working Comment to share progress and results using ${ghPullfrogMcpName}/update_working_comment. Do not create additional comments unless you are explicitly asked to do so.
4. When you finish the task, update the Working Comment a final time with a summary of the results and links to any created issues, PRs, etc.`,
},
] as const;
+1 -1
View File
@@ -1,6 +1,6 @@
{
"name": "@pullfrog/action",
"version": "0.0.103",
"version": "0.0.105",
"type": "module",
"files": [
"index.js",
+6 -3
View File
@@ -2,8 +2,10 @@ import { existsSync, readFileSync } from "node:fs";
import { extname, join, resolve } from "node:path";
import { pathToFileURL } from "node:url";
import { fromHere } from "@ark/fs";
import { flatMorph } from "@ark/util";
import arg from "arg";
import { config } from "dotenv";
import { agents } from "./agents/index.ts";
import { type Inputs, main } from "./main.ts";
import { log } from "./utils/cli.ts";
import { setupTestRepo } from "./utils/setup.ts";
@@ -22,9 +24,10 @@ export async function run(
const inputs: Required<Inputs> = {
prompt,
openai_api_key: process.env.OPENAI_API_KEY,
anthropic_api_key: process.env.ANTHROPIC_API_KEY,
agent: "codex",
agent: "claude",
...flatMorph(agents, (_, agent) =>
agent.inputKeys.map((inputKey) => [inputKey, process.env[inputKey.toUpperCase()]])
),
};
const result = await main(inputs);