Refactor (#109)
This commit is contained in:
committed by
pullfrog[bot]
parent
101c666610
commit
69b9b96ddd
+8
-7
@@ -21,16 +21,17 @@ const claudeEffortModels: Record<Effort, string> = {
|
||||
// This approach could replace model selection if effort proves effective for controlling capability.
|
||||
|
||||
/**
|
||||
* Build disallowedTools list from ToolPermissions.
|
||||
* Build disallowedTools list from payload permissions.
|
||||
*/
|
||||
function buildDisallowedTools(ctx: AgentRunContext): string[] {
|
||||
const disallowed: string[] = [];
|
||||
if (ctx.tools.web === "disabled") disallowed.push("WebFetch");
|
||||
if (ctx.tools.search === "disabled") disallowed.push("WebSearch");
|
||||
if (ctx.tools.write === "disabled") disallowed.push("Write");
|
||||
if (ctx.payload.web === "disabled") disallowed.push("WebFetch");
|
||||
if (ctx.payload.search === "disabled") disallowed.push("WebSearch");
|
||||
if (ctx.payload.write === "disabled") disallowed.push("Write");
|
||||
// both "disabled" and "restricted" block native bash
|
||||
// "restricted" means use MCP bash tool instead
|
||||
if (ctx.tools.bash !== "enabled") disallowed.push("Bash");
|
||||
const bash = ctx.payload.bash;
|
||||
if (bash !== "enabled") disallowed.push("Bash");
|
||||
return disallowed;
|
||||
}
|
||||
|
||||
@@ -51,8 +52,8 @@ export const claude = agent({
|
||||
const cliPath = await installClaude();
|
||||
|
||||
// select model based on effort level
|
||||
const model = claudeEffortModels[ctx.effort];
|
||||
log.info(`» using model: ${model} (effort: ${ctx.effort})`);
|
||||
const model = claudeEffortModels[ctx.payload.effort];
|
||||
log.info(`» using model: ${model} (effort: ${ctx.payload.effort})`);
|
||||
|
||||
// build disallowedTools based on tool permissions
|
||||
const disallowedTools = buildDisallowedTools(ctx);
|
||||
|
||||
+14
-10
@@ -42,8 +42,9 @@ function writeCodexConfig(ctx: AgentRunContext): string {
|
||||
// build features section for tool control
|
||||
// disable native shell if bash is "disabled" or "restricted"
|
||||
// when "restricted", agent uses MCP bash tool which filters secrets
|
||||
const bash = ctx.payload.bash;
|
||||
const features: string[] = [];
|
||||
if (ctx.tools.bash !== "enabled") {
|
||||
if (bash !== "enabled") {
|
||||
features.push("shell_command_tool = false");
|
||||
features.push("unified_exec = false");
|
||||
}
|
||||
@@ -58,9 +59,7 @@ ${mcpServerSections.join("\n\n")}
|
||||
`.trim() + "\n"
|
||||
);
|
||||
|
||||
log.info(
|
||||
`» Codex config written to ${configPath} (shell: ${ctx.tools.bash === "enabled" ? "enabled" : "disabled"})`
|
||||
);
|
||||
log.info(`» Codex config written to ${configPath} (shell: ${bash === "enabled" ? "enabled" : "disabled"})`);
|
||||
|
||||
return codexDir;
|
||||
}
|
||||
@@ -90,16 +89,21 @@ export const codex = agent({
|
||||
process.env.CODEX_HOME = codexDir;
|
||||
|
||||
// get model and reasoning effort based on effort level
|
||||
const model = codexModel[ctx.effort];
|
||||
const modelReasoningEffort = codexReasoningEffort[ctx.effort];
|
||||
const model = codexModel[ctx.payload.effort];
|
||||
const modelReasoningEffort = codexReasoningEffort[ctx.payload.effort];
|
||||
log.info(`» using model: ${model}`);
|
||||
if (modelReasoningEffort) {
|
||||
log.info(`» using modelReasoningEffort: ${modelReasoningEffort}`);
|
||||
}
|
||||
|
||||
// Configure Codex
|
||||
const apiKey = process.env.OPENAI_API_KEY;
|
||||
if (!apiKey) {
|
||||
throw new Error("OPENAI_API_KEY is required for codex agent");
|
||||
}
|
||||
|
||||
const codexOptions: CodexOptions = {
|
||||
apiKey: ctx.apiKey,
|
||||
apiKey,
|
||||
codexPathOverride: cliPath,
|
||||
};
|
||||
|
||||
@@ -110,11 +114,11 @@ export const codex = agent({
|
||||
model,
|
||||
approvalPolicy: "never" as const,
|
||||
// write: "disabled" → read-only sandbox, otherwise full access for git ops
|
||||
sandboxMode: ctx.tools.write === "disabled" ? "read-only" : "danger-full-access",
|
||||
sandboxMode: ctx.payload.write === "disabled" ? "read-only" : "danger-full-access",
|
||||
// web: controls network access
|
||||
networkAccessEnabled: ctx.tools.web !== "disabled",
|
||||
networkAccessEnabled: ctx.payload.web !== "disabled",
|
||||
// search: controls web search
|
||||
webSearchEnabled: ctx.tools.search !== "disabled",
|
||||
webSearchEnabled: ctx.payload.search !== "disabled",
|
||||
...(modelReasoningEffort && { modelReasoningEffort }),
|
||||
};
|
||||
|
||||
|
||||
+11
-10
@@ -116,19 +116,19 @@ export const cursor = agent({
|
||||
if (projectConfig.model) {
|
||||
log.info(`» using model from project .cursor/cli.json: ${projectConfig.model}`);
|
||||
} else {
|
||||
modelOverride = cursorEffortModels[ctx.effort];
|
||||
modelOverride = cursorEffortModels[ctx.payload.effort];
|
||||
}
|
||||
} catch {
|
||||
modelOverride = cursorEffortModels[ctx.effort];
|
||||
modelOverride = cursorEffortModels[ctx.payload.effort];
|
||||
}
|
||||
} else {
|
||||
modelOverride = cursorEffortModels[ctx.effort];
|
||||
modelOverride = cursorEffortModels[ctx.payload.effort];
|
||||
}
|
||||
|
||||
if (modelOverride) {
|
||||
log.info(`» using model: ${modelOverride}, effort=${ctx.effort}`);
|
||||
log.info(`» using model: ${modelOverride}, effort=${ctx.payload.effort}`);
|
||||
} else if (!existsSync(projectCliConfigPath)) {
|
||||
log.info(`» using default model, effort=${ctx.effort}`);
|
||||
log.info(`» using default model, effort=${ctx.payload.effort}`);
|
||||
}
|
||||
|
||||
// track logged model_call_ids to avoid duplicates
|
||||
@@ -354,22 +354,23 @@ function configureCursorTools(ctx: AgentRunContext): void {
|
||||
mkdirSync(cursorConfigDir, { recursive: true });
|
||||
|
||||
// build deny list based on tool permissions
|
||||
const bash = ctx.payload.bash;
|
||||
const deny: string[] = [];
|
||||
if (ctx.tools.search === "disabled") deny.push("WebSearch");
|
||||
if (ctx.tools.write === "disabled") deny.push("Write(**)");
|
||||
if (ctx.payload.search === "disabled") deny.push("WebSearch");
|
||||
if (ctx.payload.write === "disabled") deny.push("Write(**)");
|
||||
// both "disabled" and "restricted" block native shell
|
||||
if (ctx.tools.bash !== "enabled") deny.push("Shell(*)");
|
||||
if (bash !== "enabled") deny.push("Shell(*)");
|
||||
|
||||
const config: CursorCliConfig = {
|
||||
permissions: {
|
||||
allow: ctx.tools.write === "disabled" ? ["Read(**)"] : ["Read(**)", "Write(**)"],
|
||||
allow: ctx.payload.write === "disabled" ? ["Read(**)"] : ["Read(**)", "Write(**)"],
|
||||
deny,
|
||||
},
|
||||
};
|
||||
|
||||
// web: "disabled" requires sandbox with network blocking
|
||||
// sandbox.networkAccess: "allowlist" blocks network in shell subprocesses via seatbelt
|
||||
if (ctx.tools.web === "disabled") {
|
||||
if (ctx.payload.web === "disabled") {
|
||||
config.sandbox = {
|
||||
mode: "enabled",
|
||||
networkAccess: "allowlist",
|
||||
|
||||
+8
-7
@@ -176,8 +176,8 @@ export const gemini = agent({
|
||||
|
||||
const model = configureGeminiSettings(ctx);
|
||||
|
||||
if (!ctx.apiKey) {
|
||||
throw new Error("google_api_key or gemini_api_key is required for gemini agent");
|
||||
if (!process.env.GOOGLE_API_KEY && !process.env.GEMINI_API_KEY) {
|
||||
throw new Error("GOOGLE_API_KEY or GEMINI_API_KEY is required for gemini agent");
|
||||
}
|
||||
|
||||
// build CLI args - --yolo for auto-approval
|
||||
@@ -278,7 +278,7 @@ export const gemini = agent({
|
||||
* See: https://github.com/google-gemini/gemini-cli/blob/main/docs/get-started/configuration.md
|
||||
*/
|
||||
function configureGeminiSettings(ctx: AgentRunContext): string {
|
||||
const { model, thinkingLevel } = geminiEffortConfig[ctx.effort];
|
||||
const { model, thinkingLevel } = geminiEffortConfig[ctx.payload.effort];
|
||||
log.info(`» using model: ${model}, thinkingLevel: ${thinkingLevel}`);
|
||||
|
||||
const realHome = homedir();
|
||||
@@ -319,11 +319,12 @@ function configureGeminiSettings(ctx: AgentRunContext): string {
|
||||
};
|
||||
|
||||
// build tools.exclude based on permissions (v0.3.0+ nested format)
|
||||
const bash = ctx.payload.bash;
|
||||
const exclude: string[] = [];
|
||||
if (ctx.tools.bash !== "enabled") exclude.push("run_shell_command");
|
||||
if (ctx.tools.write === "disabled") exclude.push("write_file");
|
||||
if (ctx.tools.web === "disabled") exclude.push("web_fetch");
|
||||
if (ctx.tools.search === "disabled") exclude.push("google_web_search");
|
||||
if (bash !== "enabled") exclude.push("run_shell_command");
|
||||
if (ctx.payload.write === "disabled") exclude.push("write_file");
|
||||
if (ctx.payload.web === "disabled") exclude.push("web_fetch");
|
||||
if (ctx.payload.search === "disabled") exclude.push("google_web_search");
|
||||
|
||||
// merge with existing settings, overwriting mcpServers and modelConfig
|
||||
const newSettings: Record<string, unknown> = {
|
||||
|
||||
+4
-3
@@ -186,10 +186,11 @@ function configureOpenCode(ctx: AgentRunContext): void {
|
||||
|
||||
// build permission object based on tool permissions
|
||||
// note: OpenCode has no built-in web search tool
|
||||
const bash = ctx.payload.bash;
|
||||
const permission = {
|
||||
edit: ctx.tools.write === "disabled" ? "deny" : "allow",
|
||||
bash: ctx.tools.bash !== "enabled" ? "deny" : "allow",
|
||||
webfetch: ctx.tools.web === "disabled" ? "deny" : "allow",
|
||||
edit: ctx.payload.write === "disabled" ? "deny" : "allow",
|
||||
bash: bash !== "enabled" ? "deny" : "allow",
|
||||
webfetch: ctx.payload.web === "disabled" ? "deny" : "allow",
|
||||
doom_loop: "allow",
|
||||
external_directory: "allow",
|
||||
};
|
||||
|
||||
+9
-25
@@ -1,6 +1,7 @@
|
||||
import type { show } from "@ark/util";
|
||||
import { type AgentManifest, type AgentName, agentsManifest, type Effort } from "../external.ts";
|
||||
import { type AgentManifest, type AgentName, agentsManifest } from "../external.ts";
|
||||
import { log } from "../utils/cli.ts";
|
||||
import type { ResolvedPayload } from "../utils/payload.ts";
|
||||
|
||||
/**
|
||||
* Result returned by agent execution
|
||||
@@ -12,44 +13,27 @@ export interface AgentResult {
|
||||
metadata?: Record<string, unknown>;
|
||||
}
|
||||
|
||||
/**
|
||||
* Tool permission levels
|
||||
*/
|
||||
export type ToolPermission = "disabled" | "enabled";
|
||||
export type BashPermission = "disabled" | "restricted" | "enabled";
|
||||
|
||||
/**
|
||||
* Granular tool permissions for agents
|
||||
*/
|
||||
export interface ToolPermissions {
|
||||
web: ToolPermission;
|
||||
search: ToolPermission;
|
||||
write: ToolPermission;
|
||||
bash: BashPermission;
|
||||
}
|
||||
|
||||
/**
|
||||
* Minimal context passed to agent.run()
|
||||
*/
|
||||
export interface AgentRunContext {
|
||||
effort: Effort;
|
||||
tools: ToolPermissions;
|
||||
payload: ResolvedPayload;
|
||||
mcpServerUrl: string;
|
||||
tmpdir: string;
|
||||
instructions: string;
|
||||
apiKey: string;
|
||||
apiKeys: Record<string, string>;
|
||||
}
|
||||
|
||||
export const agent = <const input extends AgentInput>(input: input): defineAgent<input> => {
|
||||
return {
|
||||
...input,
|
||||
run: async (ctx: AgentRunContext): Promise<AgentResult> => {
|
||||
log.info(`» running ${input.name} with effort=${ctx.effort}...`);
|
||||
const bash = ctx.payload.bash;
|
||||
const web = ctx.payload.web;
|
||||
const search = ctx.payload.search;
|
||||
const write = ctx.payload.write;
|
||||
log.info(`» running ${input.name} with effort=${ctx.payload.effort}...`);
|
||||
log.box(ctx.instructions, { title: "Instructions" });
|
||||
log.info(
|
||||
`» tool permissions: web=${ctx.tools.web}, search=${ctx.tools.search}, write=${ctx.tools.write}, bash=${ctx.tools.bash}`
|
||||
);
|
||||
log.info(`» tool permissions: web=${web}, search=${search}, write=${write}, bash=${bash}`);
|
||||
return input.run(ctx);
|
||||
},
|
||||
...agentsManifest[input.name],
|
||||
|
||||
@@ -9,7 +9,7 @@ import { main } from "./main.ts";
|
||||
|
||||
async function run(): Promise<void> {
|
||||
try {
|
||||
const result = await main(core);
|
||||
const result = await main();
|
||||
|
||||
if (!result.success) {
|
||||
throw new Error(result.error || "Agent execution failed");
|
||||
|
||||
@@ -278,10 +278,6 @@ export interface WriteablePayload extends DispatchOptions {
|
||||
event: PayloadEvent;
|
||||
/** effort level for model selection (mini, auto, max) - defaults to "auto" */
|
||||
effort?: Effort | undefined;
|
||||
/** optional IDs of the issue, PR, or comment that the agent is working on */
|
||||
comment_id?: number | null | undefined;
|
||||
issue_id?: number | null | undefined;
|
||||
pr_id?: number | null | undefined;
|
||||
/** working directory for the agent */
|
||||
cwd?: string | null | undefined;
|
||||
}
|
||||
|
||||
@@ -1,19 +1,20 @@
|
||||
import { ensureProgressCommentUpdated } from "./mcp/comment.ts";
|
||||
import { startMcpHttpServer, type ToolContext, type ToolState } from "./mcp/server.ts";
|
||||
import { computeModes } from "./modes.ts";
|
||||
import { resolveAgent } from "./utils/agent.ts";
|
||||
import { validateApiKey } from "./utils/apiKeys.ts";
|
||||
import { log } from "./utils/cli.ts";
|
||||
import { reportErrorToComment } from "./utils/errorReport.ts";
|
||||
import { createOctokit } from "./utils/github.ts";
|
||||
import { resolveInstructions } from "./utils/instructions.ts";
|
||||
import { normalizeEnv } from "./utils/normalizeEnv.ts";
|
||||
import { resolvePayload } from "./utils/payload.ts";
|
||||
import { resolveRepoData } from "./utils/repoData.ts";
|
||||
import { resolveAgent } from "./utils/resolveAgent.ts";
|
||||
import { handleAgentResult, resolvePermissions } from "./utils/run.ts";
|
||||
import { handleAgentResult } from "./utils/run.ts";
|
||||
import { createTempDirectory, setupGit } from "./utils/setup.ts";
|
||||
import { Timer } from "./utils/timer.ts";
|
||||
import { resolveInstallationToken } from "./utils/token.ts";
|
||||
import { resolveRunId } from "./utils/workflow.ts";
|
||||
import { resolveRun } from "./utils/workflow.ts";
|
||||
|
||||
export { Inputs } from "./utils/payload.ts";
|
||||
|
||||
@@ -23,74 +24,66 @@ export interface MainResult {
|
||||
error?: string | undefined;
|
||||
}
|
||||
|
||||
export async function main(core: {
|
||||
getInput: (name: string, options?: { required?: boolean }) => string;
|
||||
}): Promise<MainResult> {
|
||||
export async function main(): Promise<MainResult> {
|
||||
// normalize env var names to uppercase (handles case-insensitive workflow files)
|
||||
normalizeEnv();
|
||||
|
||||
// store original GITHUB_TOKEN
|
||||
process.env.ORIGINAL_GITHUB_TOKEN = process.env.GITHUB_TOKEN;
|
||||
|
||||
const payload = resolvePayload(core);
|
||||
if (payload.cwd && process.cwd() !== payload.cwd) {
|
||||
process.chdir(payload.cwd);
|
||||
}
|
||||
|
||||
const timer = new Timer();
|
||||
await using tokenRef = await resolveInstallationToken();
|
||||
process.env.GITHUB_TOKEN = tokenRef.token;
|
||||
|
||||
const octokit = createOctokit(tokenRef.token);
|
||||
const runInfo = await resolveRun({ octokit });
|
||||
const hasProgressComment = runInfo.workflowRunInfo.progressCommentId !== null;
|
||||
|
||||
try {
|
||||
const repoData = await resolveRepoData(tokenRef.token);
|
||||
const tmpdir = await createTempDirectory();
|
||||
const repo = await resolveRepoData({ octokit, token: tokenRef.token });
|
||||
timer.checkpoint("repoData");
|
||||
|
||||
const agent = resolveAgent({ payload, repoSettings: repoData.repoSettings });
|
||||
// resolve payload after repoData so permissions can use DB settings
|
||||
// precedence: action inputs > json payload > repoSettings > fallbacks
|
||||
const payload = resolvePayload(repo.repoSettings);
|
||||
if (payload.cwd && process.cwd() !== payload.cwd) {
|
||||
process.chdir(payload.cwd);
|
||||
}
|
||||
|
||||
const { apiKey, apiKeys } = validateApiKey({
|
||||
const tmpdir = await createTempDirectory();
|
||||
|
||||
const agent = resolveAgent({ payload, repoSettings: repo.repoSettings });
|
||||
|
||||
validateApiKey({
|
||||
agent,
|
||||
owner: repoData.owner,
|
||||
name: repoData.name,
|
||||
});
|
||||
|
||||
// compute tool permissions early
|
||||
const tools = resolvePermissions({
|
||||
payload,
|
||||
isPublicRepo: !repoData.repo.private,
|
||||
owner: repo.owner,
|
||||
name: repo.name,
|
||||
});
|
||||
|
||||
const toolState: ToolState = {};
|
||||
await setupGit({
|
||||
token: tokenRef.token,
|
||||
owner: repoData.owner,
|
||||
name: repoData.name,
|
||||
owner: repo.owner,
|
||||
name: repo.name,
|
||||
event: payload.event,
|
||||
octokit: repoData.octokit,
|
||||
octokit,
|
||||
toolState,
|
||||
});
|
||||
timer.checkpoint("git");
|
||||
|
||||
const modes = [
|
||||
...computeModes({ disableProgressComment: payload.disableProgressComment }),
|
||||
...repoData.repoSettings.modes,
|
||||
];
|
||||
const { runId, jobId } = await resolveRunId(repoData);
|
||||
const modes = [...computeModes({ hasProgressComment }), ...repo.repoSettings.modes];
|
||||
|
||||
const toolContext: ToolContext = {
|
||||
owner: repoData.owner,
|
||||
name: repoData.name,
|
||||
repo: { default_branch: repoData.repo.default_branch, private: repoData.repo.private },
|
||||
repo,
|
||||
payload,
|
||||
octokit,
|
||||
githubInstallationToken: tokenRef.token,
|
||||
octokit: repoData.octokit,
|
||||
agent,
|
||||
event: payload.event,
|
||||
disableProgressComment: payload.disableProgressComment,
|
||||
modes,
|
||||
toolState,
|
||||
runId,
|
||||
jobId,
|
||||
tools,
|
||||
runId: runInfo.runId,
|
||||
jobId: runInfo.jobId,
|
||||
hasProgressComment,
|
||||
};
|
||||
|
||||
await using mcpHttpServer = await startMcpHttpServer(toolContext);
|
||||
@@ -100,19 +93,16 @@ export async function main(core: {
|
||||
const instructions = resolveInstructions({
|
||||
prompt: payload.prompt,
|
||||
event: payload.event,
|
||||
repoData,
|
||||
repoData: repo,
|
||||
modes,
|
||||
bash: tools.bash,
|
||||
bash: payload.bash,
|
||||
});
|
||||
|
||||
const result = await agent.run({
|
||||
effort: payload.effort,
|
||||
tools,
|
||||
payload,
|
||||
mcpServerUrl: mcpHttpServer.url,
|
||||
tmpdir,
|
||||
instructions,
|
||||
apiKey,
|
||||
apiKeys,
|
||||
});
|
||||
const mainResult = await handleAgentResult(result);
|
||||
return mainResult;
|
||||
@@ -132,9 +122,7 @@ export async function main(core: {
|
||||
// ensure progress comment is updated if it was never updated during execution
|
||||
// do this before revoking the token so we can still make API calls
|
||||
try {
|
||||
await ensureProgressCommentUpdated({
|
||||
disableProgressComment: payload.disableProgressComment,
|
||||
});
|
||||
await ensureProgressCommentUpdated({ hasProgressComment });
|
||||
} catch {
|
||||
// error updating comment, but don't let it mask the original error
|
||||
}
|
||||
|
||||
+1
-1
@@ -68,7 +68,7 @@ async function killProcessGroup(proc: ChildProcess): Promise<void> {
|
||||
}
|
||||
|
||||
export function BashTool(ctx: ToolContext) {
|
||||
const isPublicRepo = !ctx.repo.private;
|
||||
const isPublicRepo = !ctx.repo.repo.private;
|
||||
|
||||
return tool({
|
||||
name: "bash",
|
||||
|
||||
+6
-6
@@ -17,8 +17,8 @@ export function GetCheckSuiteLogsTool(ctx: ToolContext) {
|
||||
const workflowRuns = await ctx.octokit.paginate(
|
||||
ctx.octokit.rest.actions.listWorkflowRunsForRepo,
|
||||
{
|
||||
owner: ctx.owner,
|
||||
repo: ctx.name,
|
||||
owner: ctx.repo.owner,
|
||||
repo: ctx.repo.name,
|
||||
check_suite_id,
|
||||
per_page: 100,
|
||||
}
|
||||
@@ -38,8 +38,8 @@ export function GetCheckSuiteLogsTool(ctx: ToolContext) {
|
||||
const logsForRuns = await Promise.all(
|
||||
failedRuns.map(async (run) => {
|
||||
const jobs = await ctx.octokit.paginate(ctx.octokit.rest.actions.listJobsForWorkflowRun, {
|
||||
owner: ctx.owner,
|
||||
repo: ctx.name,
|
||||
owner: ctx.repo.owner,
|
||||
repo: ctx.repo.name,
|
||||
run_id: run.id,
|
||||
});
|
||||
|
||||
@@ -47,8 +47,8 @@ export function GetCheckSuiteLogsTool(ctx: ToolContext) {
|
||||
jobs.map(async (job) => {
|
||||
try {
|
||||
const logsResponse = await ctx.octokit.rest.actions.downloadJobLogsForWorkflowRun({
|
||||
owner: ctx.owner,
|
||||
repo: ctx.name,
|
||||
owner: ctx.repo.owner,
|
||||
repo: ctx.repo.name,
|
||||
job_id: job.id,
|
||||
});
|
||||
|
||||
|
||||
+6
-6
@@ -221,8 +221,8 @@ export function CheckoutPrTool(ctx: ToolContext) {
|
||||
execute: execute(async ({ pull_number }) => {
|
||||
const result = await checkoutPrBranch({
|
||||
octokit: ctx.octokit,
|
||||
owner: ctx.owner,
|
||||
name: ctx.name,
|
||||
owner: ctx.repo.owner,
|
||||
name: ctx.repo.name,
|
||||
token: ctx.githubInstallationToken,
|
||||
pullNumber: pull_number,
|
||||
});
|
||||
@@ -232,8 +232,8 @@ export function CheckoutPrTool(ctx: ToolContext) {
|
||||
|
||||
// fetch PR metadata to return result
|
||||
const pr = await ctx.octokit.rest.pulls.get({
|
||||
owner: ctx.owner,
|
||||
repo: ctx.name,
|
||||
owner: ctx.repo.owner,
|
||||
repo: ctx.repo.name,
|
||||
pull_number,
|
||||
});
|
||||
|
||||
@@ -244,8 +244,8 @@ export function CheckoutPrTool(ctx: ToolContext) {
|
||||
|
||||
// fetch PR files and format with line numbers
|
||||
const filesResponse = await ctx.octokit.rest.pulls.listFiles({
|
||||
owner: ctx.owner,
|
||||
repo: ctx.name,
|
||||
owner: ctx.repo.owner,
|
||||
repo: ctx.repo.name,
|
||||
pull_number,
|
||||
per_page: 100,
|
||||
});
|
||||
|
||||
+23
-20
@@ -106,8 +106,8 @@ export function CreateCommentTool(ctx: ToolContext) {
|
||||
const bodyWithFooter = await addFooter(ctx, body);
|
||||
|
||||
const result = await ctx.octokit.rest.issues.createComment({
|
||||
owner: ctx.owner,
|
||||
repo: ctx.name,
|
||||
owner: ctx.repo.owner,
|
||||
repo: ctx.repo.name,
|
||||
issue_number: issueNumber,
|
||||
body: bodyWithFooter,
|
||||
});
|
||||
@@ -136,8 +136,8 @@ export function EditCommentTool(ctx: ToolContext) {
|
||||
const bodyWithFooter = await addFooter(ctx, body);
|
||||
|
||||
const result = await ctx.octokit.rest.issues.updateComment({
|
||||
owner: ctx.owner,
|
||||
repo: ctx.name,
|
||||
owner: ctx.repo.owner,
|
||||
repo: ctx.repo.name,
|
||||
comment_id: commentId,
|
||||
body: bodyWithFooter,
|
||||
});
|
||||
@@ -211,14 +211,15 @@ export async function reportProgress(
|
||||
| undefined
|
||||
> {
|
||||
const existingCommentId = getProgressCommentId();
|
||||
const issueNumber = ctx.toolState.prNumber ?? ctx.toolState.issueNumber ?? ctx.event.issue_number;
|
||||
const issueNumber =
|
||||
ctx.toolState.prNumber ?? ctx.toolState.issueNumber ?? ctx.payload.event.issue_number;
|
||||
const isPlanMode = ctx.toolState.selectedMode === "Plan";
|
||||
|
||||
// if we already have a progress comment, update it
|
||||
if (existingCommentId) {
|
||||
const customParts =
|
||||
isPlanMode && issueNumber !== undefined
|
||||
? [buildImplementPlanLink(ctx.owner, ctx.name, issueNumber, existingCommentId)]
|
||||
? [buildImplementPlanLink(ctx.repo.owner, ctx.repo.name, issueNumber, existingCommentId)]
|
||||
: undefined;
|
||||
|
||||
const bodyWithoutFooter = stripExistingFooter(body);
|
||||
@@ -230,8 +231,8 @@ export async function reportProgress(
|
||||
const bodyWithFooter = `${bodyWithoutFooter}${footer}`;
|
||||
|
||||
const result = await ctx.octokit.rest.issues.updateComment({
|
||||
owner: ctx.owner,
|
||||
repo: ctx.name,
|
||||
owner: ctx.repo.owner,
|
||||
repo: ctx.repo.name,
|
||||
comment_id: existingCommentId,
|
||||
body: bodyWithFooter,
|
||||
});
|
||||
@@ -259,8 +260,8 @@ export async function reportProgress(
|
||||
const initialBody = await addFooter(ctx, body);
|
||||
|
||||
const result = await ctx.octokit.rest.issues.createComment({
|
||||
owner: ctx.owner,
|
||||
repo: ctx.name,
|
||||
owner: ctx.repo.owner,
|
||||
repo: ctx.repo.name,
|
||||
issue_number: issueNumber,
|
||||
body: initialBody,
|
||||
});
|
||||
@@ -271,7 +272,9 @@ export async function reportProgress(
|
||||
|
||||
// if Plan mode, update the comment to add the "Implement plan" link
|
||||
if (isPlanMode) {
|
||||
const customParts = [buildImplementPlanLink(ctx.owner, ctx.name, issueNumber, result.data.id)];
|
||||
const customParts = [
|
||||
buildImplementPlanLink(ctx.repo.owner, ctx.repo.name, issueNumber, result.data.id),
|
||||
];
|
||||
const bodyWithoutFooter = stripExistingFooter(body);
|
||||
const footer = await buildCommentFooter({
|
||||
agent: ctx.agent,
|
||||
@@ -281,8 +284,8 @@ export async function reportProgress(
|
||||
const bodyWithPlanLink = `${bodyWithoutFooter}${footer}`;
|
||||
|
||||
const updateResult = await ctx.octokit.rest.issues.updateComment({
|
||||
owner: ctx.owner,
|
||||
repo: ctx.name,
|
||||
owner: ctx.repo.owner,
|
||||
repo: ctx.repo.name,
|
||||
comment_id: result.data.id,
|
||||
body: bodyWithPlanLink,
|
||||
});
|
||||
@@ -353,8 +356,8 @@ export async function deleteProgressComment(ctx: ToolContext): Promise<boolean>
|
||||
|
||||
try {
|
||||
await ctx.octokit.rest.issues.deleteComment({
|
||||
owner: ctx.owner,
|
||||
repo: ctx.name,
|
||||
owner: ctx.repo.owner,
|
||||
repo: ctx.repo.name,
|
||||
comment_id: existingCommentId,
|
||||
});
|
||||
} catch (error) {
|
||||
@@ -375,7 +378,7 @@ export async function deleteProgressComment(ctx: ToolContext): Promise<boolean>
|
||||
}
|
||||
|
||||
interface EnsureProgressCommentUpdatedParams {
|
||||
disableProgressComment: boolean;
|
||||
hasProgressComment: boolean;
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -394,8 +397,8 @@ export async function ensureProgressCommentUpdated(
|
||||
return;
|
||||
}
|
||||
|
||||
// skip if progress comments are disabled
|
||||
if (params.disableProgressComment) {
|
||||
// skip if no progress comment was created for this run
|
||||
if (!params.hasProgressComment) {
|
||||
return;
|
||||
}
|
||||
|
||||
@@ -485,8 +488,8 @@ export function ReplyToReviewCommentTool(ctx: ToolContext) {
|
||||
const bodyWithFooter = await addFooter(ctx, body);
|
||||
|
||||
const result = await ctx.octokit.rest.pulls.createReplyForReviewComment({
|
||||
owner: ctx.owner,
|
||||
repo: ctx.name,
|
||||
owner: ctx.repo.owner,
|
||||
repo: ctx.repo.name,
|
||||
pull_number,
|
||||
comment_id,
|
||||
body: bodyWithFooter,
|
||||
|
||||
+2
-2
@@ -6,7 +6,7 @@ import { $ } from "../utils/shell.ts";
|
||||
import { execute, tool } from "./shared.ts";
|
||||
|
||||
export function CreateBranchTool(ctx: ToolContext) {
|
||||
const defaultBranch = ctx.repo.default_branch || "main";
|
||||
const defaultBranch = ctx.repo.repo.default_branch || "main";
|
||||
|
||||
const CreateBranch = type({
|
||||
branchName: type.string.describe(
|
||||
@@ -24,7 +24,7 @@ export function CreateBranchTool(ctx: ToolContext) {
|
||||
parameters: CreateBranch,
|
||||
execute: execute(async ({ branchName, baseBranch }) => {
|
||||
// baseBranch should always be defined due to default, but TypeScript needs help
|
||||
const resolvedBaseBranch = baseBranch || ctx.repo.default_branch || "main";
|
||||
const resolvedBaseBranch = baseBranch || ctx.repo.repo.default_branch || "main";
|
||||
|
||||
// validate branch name for secrets
|
||||
if (containsSecrets(branchName)) {
|
||||
|
||||
+2
-2
@@ -22,8 +22,8 @@ export function IssueTool(ctx: ToolContext) {
|
||||
parameters: Issue,
|
||||
execute: execute(async ({ title, body, labels, assignees }) => {
|
||||
const result = await ctx.octokit.rest.issues.create({
|
||||
owner: ctx.owner,
|
||||
repo: ctx.name,
|
||||
owner: ctx.repo.owner,
|
||||
repo: ctx.repo.name,
|
||||
title: title,
|
||||
body: body,
|
||||
labels: labels ?? [],
|
||||
|
||||
@@ -17,8 +17,8 @@ export function GetIssueCommentsTool(ctx: ToolContext) {
|
||||
ctx.toolState.issueNumber = issue_number;
|
||||
|
||||
const comments = await ctx.octokit.paginate(ctx.octokit.rest.issues.listComments, {
|
||||
owner: ctx.owner,
|
||||
repo: ctx.name,
|
||||
owner: ctx.repo.owner,
|
||||
repo: ctx.repo.name,
|
||||
issue_number,
|
||||
});
|
||||
|
||||
|
||||
+2
-2
@@ -17,8 +17,8 @@ export function GetIssueEventsTool(ctx: ToolContext) {
|
||||
ctx.toolState.issueNumber = issue_number;
|
||||
|
||||
const events = await ctx.octokit.paginate(ctx.octokit.rest.issues.listEventsForTimeline, {
|
||||
owner: ctx.owner,
|
||||
repo: ctx.name,
|
||||
owner: ctx.repo.owner,
|
||||
repo: ctx.repo.name,
|
||||
issue_number,
|
||||
});
|
||||
|
||||
|
||||
+2
-2
@@ -13,8 +13,8 @@ export function IssueInfoTool(ctx: ToolContext) {
|
||||
parameters: IssueInfo,
|
||||
execute: execute(async ({ issue_number }) => {
|
||||
const issue = await ctx.octokit.rest.issues.get({
|
||||
owner: ctx.owner,
|
||||
repo: ctx.name,
|
||||
owner: ctx.repo.owner,
|
||||
repo: ctx.repo.name,
|
||||
issue_number,
|
||||
});
|
||||
|
||||
|
||||
+2
-2
@@ -15,8 +15,8 @@ export function AddLabelsTool(ctx: ToolContext) {
|
||||
parameters: AddLabelsParams,
|
||||
execute: execute(async ({ issue_number, labels }) => {
|
||||
const result = await ctx.octokit.rest.issues.addLabels({
|
||||
owner: ctx.owner,
|
||||
repo: ctx.name,
|
||||
owner: ctx.repo.owner,
|
||||
repo: ctx.repo.name,
|
||||
issue_number,
|
||||
labels,
|
||||
});
|
||||
|
||||
@@ -1,9 +1,9 @@
|
||||
import { type } from "arktype";
|
||||
import type { ToolContext } from "./server.ts";
|
||||
import { buildPullfrogFooter, stripExistingFooter } from "../utils/buildPullfrogFooter.ts";
|
||||
import { log } from "../utils/cli.ts";
|
||||
import { containsSecrets } from "../utils/secrets.ts";
|
||||
import { $ } from "../utils/shell.ts";
|
||||
import type { ToolContext } from "./server.ts";
|
||||
import { execute, tool } from "./shared.ts";
|
||||
|
||||
export const PullRequest = type({
|
||||
@@ -17,7 +17,7 @@ function buildPrBodyWithFooter(ctx: ToolContext, body: string): string {
|
||||
triggeredBy: true,
|
||||
agent: { displayName: ctx.agent.displayName, url: ctx.agent.url },
|
||||
workflowRun: ctx.runId
|
||||
? { owner: ctx.owner, repo: ctx.name, runId: ctx.runId, jobId: ctx.jobId }
|
||||
? { owner: ctx.repo.owner, repo: ctx.repo.name, runId: ctx.runId, jobId: ctx.jobId }
|
||||
: undefined,
|
||||
});
|
||||
|
||||
@@ -56,8 +56,8 @@ export function CreatePullRequestTool(ctx: ToolContext) {
|
||||
const bodyWithFooter = buildPrBodyWithFooter(ctx, body);
|
||||
|
||||
const result = await ctx.octokit.rest.pulls.create({
|
||||
owner: ctx.owner,
|
||||
repo: ctx.name,
|
||||
owner: ctx.repo.owner,
|
||||
repo: ctx.repo.name,
|
||||
title: title,
|
||||
body: bodyWithFooter,
|
||||
head: currentBranch,
|
||||
|
||||
+2
-2
@@ -14,8 +14,8 @@ export function PullRequestInfoTool(ctx: ToolContext) {
|
||||
parameters: PullRequestInfo,
|
||||
execute: execute(async ({ pull_number }) => {
|
||||
const pr = await ctx.octokit.rest.pulls.get({
|
||||
owner: ctx.owner,
|
||||
repo: ctx.name,
|
||||
owner: ctx.repo.owner,
|
||||
repo: ctx.repo.name,
|
||||
pull_number,
|
||||
});
|
||||
|
||||
|
||||
+20
-20
@@ -57,8 +57,8 @@ export function CreatePullRequestReviewTool(ctx: ToolContext) {
|
||||
|
||||
// compose the request
|
||||
const params: RestEndpointMethodTypes["pulls"]["createReview"]["parameters"] = {
|
||||
owner: ctx.owner,
|
||||
repo: ctx.name,
|
||||
owner: ctx.repo.owner,
|
||||
repo: ctx.repo.name,
|
||||
pull_number,
|
||||
event: "COMMENT",
|
||||
};
|
||||
@@ -68,8 +68,8 @@ export function CreatePullRequestReviewTool(ctx: ToolContext) {
|
||||
} else {
|
||||
// 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,
|
||||
owner: ctx.repo.owner,
|
||||
repo: ctx.repo.name,
|
||||
pull_number,
|
||||
});
|
||||
params.commit_id = pr.data.head.sha;
|
||||
@@ -98,11 +98,11 @@ export function CreatePullRequestReviewTool(ctx: ToolContext) {
|
||||
|
||||
// build quick links footer and update the review body
|
||||
const apiUrl = process.env.API_URL || "https://pullfrog.com";
|
||||
const fixAllUrl = `${apiUrl}/trigger/${ctx.owner}/${ctx.name}/${pull_number}?action=fix&review_id=${reviewId}`;
|
||||
const fixApprovedUrl = `${apiUrl}/trigger/${ctx.owner}/${ctx.name}/${pull_number}?action=fix-approved&review_id=${reviewId}`;
|
||||
const fixAllUrl = `${apiUrl}/trigger/${ctx.repo.owner}/${ctx.repo.name}/${pull_number}?action=fix&review_id=${reviewId}`;
|
||||
const fixApprovedUrl = `${apiUrl}/trigger/${ctx.repo.owner}/${ctx.repo.name}/${pull_number}?action=fix-approved&review_id=${reviewId}`;
|
||||
|
||||
const footer = buildPullfrogFooter({
|
||||
workflowRun: { owner: ctx.owner, repo: ctx.name, runId: ctx.runId, jobId: ctx.jobId },
|
||||
workflowRun: { owner: ctx.repo.owner, repo: ctx.repo.name, runId: ctx.runId, jobId: ctx.jobId },
|
||||
customParts: [`[Fix all ➔](${fixAllUrl})`, `[Fix 👍s ➔](${fixApprovedUrl})`],
|
||||
});
|
||||
|
||||
@@ -110,8 +110,8 @@ export function CreatePullRequestReviewTool(ctx: ToolContext) {
|
||||
|
||||
// update the review with the footer
|
||||
await ctx.octokit.rest.pulls.updateReview({
|
||||
owner: ctx.owner,
|
||||
repo: ctx.name,
|
||||
owner: ctx.repo.owner,
|
||||
repo: ctx.repo.name,
|
||||
pull_number,
|
||||
review_id: reviewId,
|
||||
body: updatedBody,
|
||||
@@ -171,8 +171,8 @@ async function findPendingReview(
|
||||
pull_number: number
|
||||
): Promise<{ id: number; node_id: string } | null> {
|
||||
const reviews = await ctx.octokit.rest.pulls.listReviews({
|
||||
owner: ctx.owner,
|
||||
repo: ctx.name,
|
||||
owner: ctx.repo.owner,
|
||||
repo: ctx.repo.name,
|
||||
pull_number,
|
||||
per_page: 100,
|
||||
});
|
||||
@@ -207,8 +207,8 @@ export function StartReviewTool(ctx: ToolContext) {
|
||||
|
||||
// get the PR to get head commit SHA
|
||||
const pr = await ctx.octokit.rest.pulls.get({
|
||||
owner: ctx.owner,
|
||||
repo: ctx.name,
|
||||
owner: ctx.repo.owner,
|
||||
repo: ctx.repo.name,
|
||||
pull_number,
|
||||
});
|
||||
|
||||
@@ -219,8 +219,8 @@ export function StartReviewTool(ctx: ToolContext) {
|
||||
log.debug(`creating pending review for PR #${pull_number}...`);
|
||||
try {
|
||||
const result = await ctx.octokit.rest.pulls.createReview({
|
||||
owner: ctx.owner,
|
||||
repo: ctx.name,
|
||||
owner: ctx.repo.owner,
|
||||
repo: ctx.repo.name,
|
||||
pull_number,
|
||||
commit_id: pr.data.head.sha,
|
||||
// no 'event' = PENDING review
|
||||
@@ -386,11 +386,11 @@ export function SubmitReviewTool(ctx: ToolContext) {
|
||||
|
||||
// build quick links footer
|
||||
const apiUrl = process.env.API_URL || "https://pullfrog.com";
|
||||
const fixAllUrl = `${apiUrl}/trigger/${ctx.owner}/${ctx.name}/${ctx.toolState.prNumber}?action=fix&review_id=${reviewId}`;
|
||||
const fixApprovedUrl = `${apiUrl}/trigger/${ctx.owner}/${ctx.name}/${ctx.toolState.prNumber}?action=fix-approved&review_id=${reviewId}`;
|
||||
const fixAllUrl = `${apiUrl}/trigger/${ctx.repo.owner}/${ctx.repo.name}/${ctx.toolState.prNumber}?action=fix&review_id=${reviewId}`;
|
||||
const fixApprovedUrl = `${apiUrl}/trigger/${ctx.repo.owner}/${ctx.repo.name}/${ctx.toolState.prNumber}?action=fix-approved&review_id=${reviewId}`;
|
||||
|
||||
const footer = buildPullfrogFooter({
|
||||
workflowRun: { owner: ctx.owner, repo: ctx.name, runId: ctx.runId, jobId: ctx.jobId },
|
||||
workflowRun: { owner: ctx.repo.owner, repo: ctx.repo.name, runId: ctx.runId, jobId: ctx.jobId },
|
||||
customParts: [`[Fix all ➔](${fixAllUrl})`, `[Fix 👍s ➔](${fixApprovedUrl})`],
|
||||
});
|
||||
|
||||
@@ -398,8 +398,8 @@ export function SubmitReviewTool(ctx: ToolContext) {
|
||||
|
||||
// submit the pending review via REST
|
||||
const result = await ctx.octokit.rest.pulls.submitReview({
|
||||
owner: ctx.owner,
|
||||
repo: ctx.name,
|
||||
owner: ctx.repo.owner,
|
||||
repo: ctx.repo.name,
|
||||
pull_number: ctx.toolState.prNumber,
|
||||
review_id: reviewId,
|
||||
event: "COMMENT",
|
||||
|
||||
@@ -95,8 +95,8 @@ export function GetReviewCommentsTool(ctx: ToolContext) {
|
||||
execute: execute(async ({ pull_number, review_id }) => {
|
||||
// fetch all review threads using graphql
|
||||
const response = await ctx.octokit.graphql<GraphQLResponse>(REVIEW_THREADS_QUERY, {
|
||||
owner: ctx.owner,
|
||||
repo: ctx.name,
|
||||
owner: ctx.repo.owner,
|
||||
repo: ctx.repo.name,
|
||||
pullNumber: pull_number,
|
||||
});
|
||||
|
||||
@@ -197,8 +197,8 @@ export function ListPullRequestReviewsTool(ctx: ToolContext) {
|
||||
parameters: ListPullRequestReviews,
|
||||
execute: execute(async ({ pull_number }) => {
|
||||
const reviews = await ctx.octokit.paginate(ctx.octokit.rest.pulls.listReviews, {
|
||||
owner: ctx.owner,
|
||||
repo: ctx.name,
|
||||
owner: ctx.repo.owner,
|
||||
repo: ctx.repo.name,
|
||||
pull_number,
|
||||
});
|
||||
|
||||
|
||||
+12
-12
@@ -1,12 +1,14 @@
|
||||
import "./arkConfig.ts";
|
||||
import { createServer } from "node:net";
|
||||
// this must be imported first
|
||||
import type { Octokit } from "@octokit/rest";
|
||||
import { FastMCP, type Tool } from "fastmcp";
|
||||
import type { Agent } from "../agents/index.ts";
|
||||
import { ghPullfrogMcpName, type PayloadEvent } from "../external.ts";
|
||||
import { ghPullfrogMcpName } from "../external.ts";
|
||||
import type { Mode } from "../modes.ts";
|
||||
import type { PrepResult } from "../prep/index.ts";
|
||||
import type { OctokitWithPlugins } from "../utils/github.ts";
|
||||
import type { ResolvedPayload } from "../utils/payload.ts";
|
||||
import type { RepoData } from "../utils/repoData.ts";
|
||||
|
||||
export interface ToolState {
|
||||
prNumber?: number;
|
||||
@@ -24,22 +26,19 @@ export interface ToolState {
|
||||
}
|
||||
|
||||
export interface ToolContext {
|
||||
tools: ToolPermissions;
|
||||
owner: string;
|
||||
name: string;
|
||||
repo: { default_branch: string; private: boolean };
|
||||
repo: RepoData;
|
||||
payload: ResolvedPayload;
|
||||
octokit: OctokitWithPlugins;
|
||||
githubInstallationToken: string;
|
||||
octokit: Octokit;
|
||||
agent: Agent;
|
||||
event: PayloadEvent;
|
||||
disableProgressComment: boolean;
|
||||
modes: Mode[];
|
||||
toolState: ToolState;
|
||||
runId: string;
|
||||
jobId: string | undefined;
|
||||
/** true if a progress comment was pre-created for this run */
|
||||
hasProgressComment: boolean;
|
||||
}
|
||||
|
||||
import type { ToolPermissions } from "../agents/shared.ts";
|
||||
import { BashTool } from "./bash.ts";
|
||||
import { CheckoutPrTool } from "./checkout.ts";
|
||||
import { GetCheckSuiteLogsTool } from "./checkSuite.ts";
|
||||
@@ -137,11 +136,12 @@ export async function startMcpHttpServer(
|
||||
// - "enabled": native bash + MCP bash
|
||||
// - "restricted": MCP bash only (native blocked by agent)
|
||||
// - "disabled": no bash at all
|
||||
if (ctx.tools.bash !== "disabled") {
|
||||
const bash = ctx.payload.bash ?? "enabled";
|
||||
if (bash !== "disabled") {
|
||||
tools.push(BashTool(ctx));
|
||||
}
|
||||
|
||||
if (!ctx.disableProgressComment) {
|
||||
if (ctx.hasProgressComment) {
|
||||
tools.push(ReportProgressTool(ctx));
|
||||
}
|
||||
|
||||
|
||||
@@ -15,7 +15,7 @@ export const ModeSchema = type({
|
||||
});
|
||||
|
||||
export interface ComputeModesParams {
|
||||
disableProgressComment: boolean;
|
||||
hasProgressComment: boolean;
|
||||
}
|
||||
|
||||
const reportProgressInstruction = `Use ${ghPullfrogMcpName}/report_progress to share progress and results. Continue calling it as you make progress - it will update the same comment. Never create additional comments manually.`;
|
||||
@@ -23,7 +23,7 @@ const reportProgressInstruction = `Use ${ghPullfrogMcpName}/report_progress to s
|
||||
const dependencyInstallationStep = `If this task will require running tests, builds, linters, or CLI commands that need installed packages, call \`${ghPullfrogMcpName}/start_dependency_installation\` NOW. This is non-blocking and allows dependencies to install in the background while you continue. Later, call \`${ghPullfrogMcpName}/await_dependency_installation\` before running commands that need them. Skip this step if only reading code or answering questions.`;
|
||||
|
||||
export function computeModes(ctx: ComputeModesParams): Mode[] {
|
||||
const disableProgressComment = ctx.disableProgressComment;
|
||||
const hasProgressComment = ctx.hasProgressComment;
|
||||
return [
|
||||
{
|
||||
name: "Build",
|
||||
@@ -92,12 +92,12 @@ export function computeModes(ctx: ComputeModesParams): Mode[] {
|
||||
|
||||
8. When done, commit your changes with ${ghPullfrogMcpName}/commit_files, then push with ${ghPullfrogMcpName}/push_branch. The push will automatically go to the correct remote (including fork repos). Do not create a new branch or PR - you are updating an existing one.
|
||||
${
|
||||
disableProgressComment
|
||||
? ""
|
||||
: `
|
||||
hasProgressComment
|
||||
? `
|
||||
9. ${reportProgressInstruction}
|
||||
|
||||
**CRITICAL: Keep the progress comment extremely brief.** The summary should be 1-2 sentences max (e.g., "Fixed 3 review comments and pushed changes."). Almost all detail belongs in the individual reply_to_review_comment calls, NOT in the progress comment.`
|
||||
: ""
|
||||
}`,
|
||||
},
|
||||
{
|
||||
@@ -147,14 +147,14 @@ ${
|
||||
|
||||
3. Consider dependencies, potential challenges, and implementation order
|
||||
|
||||
4. Create a structured plan with clear milestones${disableProgressComment ? "" : `\n\n5. ${reportProgressInstruction}`}`,
|
||||
4. Create a structured plan with clear milestones${hasProgressComment ? `\n\n5. ${reportProgressInstruction}` : ""}`,
|
||||
},
|
||||
{
|
||||
name: "Prompt",
|
||||
description:
|
||||
"Fallback for tasks that don't fit other workflows, e.g. direct prompts via comments, or requests requiring general assistance",
|
||||
prompt: `Follow these steps. THINK HARDER.
|
||||
1. 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.${disableProgressComment ? "" : " When creating comments, always use report_progress. Do not use create_issue_comment."}
|
||||
1. 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.${hasProgressComment ? " When creating comments, always use report_progress. Do not use create_issue_comment." : ""}
|
||||
|
||||
2. If the task involves making code changes:
|
||||
- Create a branch using ${ghPullfrogMcpName}/create_branch. Branch names should be prefixed with "pullfrog/" and reflect the exact changes you are making. Never commit directly to main, master, or production.
|
||||
@@ -172,5 +172,5 @@ ${
|
||||
}
|
||||
|
||||
export const modes: Mode[] = computeModes({
|
||||
disableProgressComment: false,
|
||||
hasProgressComment: true,
|
||||
});
|
||||
|
||||
@@ -35,21 +35,14 @@ export async function run(inputsOrPrompt: Inputs | string): Promise<AgentResult>
|
||||
const inputs: Inputs =
|
||||
typeof inputsOrPrompt === "string" ? { prompt: inputsOrPrompt } : inputsOrPrompt;
|
||||
|
||||
// Mock core.getInput to simulate Github Actions input
|
||||
const mockCore = {
|
||||
getInput: (name: string, options?: { required?: boolean }): string => {
|
||||
const value = inputs[name as keyof Inputs];
|
||||
if (value === undefined || value === null) {
|
||||
if (options?.required) {
|
||||
throw new Error(`Input required and not supplied: ${name}`);
|
||||
}
|
||||
return "";
|
||||
}
|
||||
return String(value);
|
||||
},
|
||||
};
|
||||
// set INPUT_* env vars for @actions/core.getInput()
|
||||
for (const [key, value] of Object.entries(inputs)) {
|
||||
if (value !== undefined && value !== null) {
|
||||
process.env[`INPUT_${key.toUpperCase()}`] = String(value);
|
||||
}
|
||||
}
|
||||
|
||||
const result = await main(mockCore);
|
||||
const result = await main();
|
||||
|
||||
process.chdir(originalCwd);
|
||||
|
||||
|
||||
+1
-11
@@ -1,10 +1,5 @@
|
||||
import type { Agent } from "../agents/index.ts";
|
||||
|
||||
export interface ApiKeySetup {
|
||||
apiKey: string;
|
||||
apiKeys: Record<string, string>;
|
||||
}
|
||||
|
||||
/**
|
||||
* Build a helpful error message for missing API key with links to repo settings
|
||||
*/
|
||||
@@ -61,7 +56,7 @@ function collectApiKeys(agent: Agent): Record<string, string> {
|
||||
return apiKeys;
|
||||
}
|
||||
|
||||
export function validateApiKey(params: { agent: Agent; owner: string; name: string }): ApiKeySetup {
|
||||
export function validateApiKey(params: { agent: Agent; owner: string; name: string }): void {
|
||||
const apiKeys = collectApiKeys(params.agent);
|
||||
|
||||
if (Object.keys(apiKeys).length === 0) {
|
||||
@@ -73,9 +68,4 @@ export function validateApiKey(params: { agent: Agent; owner: string; name: stri
|
||||
})
|
||||
);
|
||||
}
|
||||
|
||||
return {
|
||||
apiKey: Object.values(apiKeys)[0],
|
||||
apiKeys,
|
||||
};
|
||||
}
|
||||
|
||||
@@ -142,7 +142,7 @@ ${getShellInstructions(input.bash)}
|
||||
|
||||
### Available modes
|
||||
|
||||
${[...computeModes({ disableProgressComment: false }), ...input.modes].map((w) => ` - "${w.name}": ${w.description}`).join("\n")}
|
||||
${[...computeModes({ hasProgressComment: true }), ...input.modes].map((w) => ` - "${w.name}": ${w.description}`).join("\n")}
|
||||
|
||||
### Following the mode instructions
|
||||
|
||||
|
||||
@@ -1,11 +1,27 @@
|
||||
import { log } from "./cli.ts";
|
||||
|
||||
// patterns for sensitive env vars: suffixes (_KEY, _SECRET, _TOKEN) plus AI provider prefixes
|
||||
const SENSITIVE_PATTERNS = [/_KEY$/i, /_SECRET$/i, /_TOKEN$/i, /_PASSWORD$/i, /_CREDENTIAL$/i];
|
||||
|
||||
function isSensitive(key: string): boolean {
|
||||
return SENSITIVE_PATTERNS.some((p) => p.test(key));
|
||||
}
|
||||
|
||||
function maskValue(value: string | undefined) {
|
||||
if (value && typeof value === "string" && value.trim().length > 0) {
|
||||
// ::add-mask::value tells GitHub Actions to mask this value in logs
|
||||
console.log(`::add-mask::${value}`);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Normalize environment variables to uppercase.
|
||||
* This handles case-insensitive env var names (e.g., `anthropic_api_key` -> `ANTHROPIC_API_KEY`).
|
||||
*
|
||||
* If there are conflicts (same key with different capitalizations but different values),
|
||||
* logs a warning and keeps the uppercase version.
|
||||
*
|
||||
* Also registers sensitive values as masks in GitHub Actions.
|
||||
*/
|
||||
export function normalizeEnv(): void {
|
||||
const upperKeys = new Map<string, string[]>();
|
||||
@@ -20,6 +36,14 @@ export function normalizeEnv(): void {
|
||||
|
||||
// process each group
|
||||
for (const [upperKey, keys] of upperKeys) {
|
||||
// if sensitive, ensure we mask the value (regardless of whether we rename it or not)
|
||||
if (isSensitive(upperKey)) {
|
||||
// mask all values associated with this key group
|
||||
for (const key of keys) {
|
||||
maskValue(process.env[key]);
|
||||
}
|
||||
}
|
||||
|
||||
if (keys.length === 1) {
|
||||
const key = keys[0];
|
||||
if (key !== upperKey) {
|
||||
|
||||
+10
-16
@@ -1,4 +1,5 @@
|
||||
import { isAbsolute, resolve } from "node:path";
|
||||
import * as core from "@actions/core";
|
||||
import { type } from "arktype";
|
||||
import {
|
||||
AgentName,
|
||||
@@ -6,6 +7,7 @@ import {
|
||||
Effort,
|
||||
type PayloadEvent,
|
||||
} from "../external.ts";
|
||||
import type { RepoSettings } from "./repoSettings.ts";
|
||||
|
||||
// tool permission enum types for inputs
|
||||
const ToolPermissionInput = type.enumerated("disabled", "enabled");
|
||||
@@ -22,10 +24,6 @@ const JsonPayload = type({
|
||||
"search?": ToolPermissionInput,
|
||||
"write?": ToolPermissionInput,
|
||||
"bash?": BashPermissionInput,
|
||||
"disableProgressComment?": "true",
|
||||
"comment_id?": "number|null",
|
||||
"issue_id?": "number|null",
|
||||
"pr_id?": "number|null",
|
||||
});
|
||||
|
||||
// inputs schema - action inputs from core.getInput()
|
||||
@@ -61,9 +59,7 @@ function resolveCwd(cwd: string | null | undefined): string | null {
|
||||
return workspace ? resolve(workspace, cwd) : cwd;
|
||||
}
|
||||
|
||||
export function resolvePayload(core: {
|
||||
getInput: (name: string, options?: { required?: boolean }) => string;
|
||||
}) {
|
||||
export function resolvePayload(repoSettings: RepoSettings) {
|
||||
const inputs = Inputs.assert({
|
||||
prompt: core.getInput("prompt", { required: true }),
|
||||
effort: core.getInput("effort") || "auto",
|
||||
@@ -107,7 +103,7 @@ export function resolvePayload(core: {
|
||||
agent ??
|
||||
(jsonAgent !== undefined && jsonAgent !== "null" && isAgentName(jsonAgent) ? jsonAgent : null);
|
||||
|
||||
// build payload - precedence: inputs > jsonPayload > defaults
|
||||
// build payload - precedence: inputs > jsonPayload > repoSettings > fallbacks
|
||||
// note: modes are NOT in payload - they come from repoSettings in main()
|
||||
return {
|
||||
"~pullfrog": true as const,
|
||||
@@ -115,15 +111,13 @@ export function resolvePayload(core: {
|
||||
prompt: inputs.prompt ?? jsonPayload?.prompt,
|
||||
event,
|
||||
effort: inputs.effort ?? jsonPayload?.effort ?? "auto",
|
||||
web: inputs.web ?? jsonPayload?.web,
|
||||
search: inputs.search ?? jsonPayload?.search,
|
||||
write: inputs.write ?? jsonPayload?.write,
|
||||
bash: inputs.bash ?? jsonPayload?.bash,
|
||||
disableProgressComment: jsonPayload?.disableProgressComment === true,
|
||||
comment_id: jsonPayload?.comment_id ?? null,
|
||||
issue_id: jsonPayload?.issue_id ?? null,
|
||||
pr_id: jsonPayload?.pr_id ?? null,
|
||||
cwd: resolveCwd(inputs.cwd),
|
||||
|
||||
// permissions: inputs > jsonPayload > repoSettings > fallbacks
|
||||
web: inputs.web ?? jsonPayload?.web ?? repoSettings.web ?? "enabled",
|
||||
search: inputs.search ?? jsonPayload?.search ?? repoSettings.search ?? "enabled",
|
||||
write: inputs.write ?? jsonPayload?.write ?? repoSettings.write ?? "enabled",
|
||||
bash: inputs.bash ?? jsonPayload?.bash ?? repoSettings.bash ?? "restricted",
|
||||
};
|
||||
}
|
||||
|
||||
|
||||
+13
-9
@@ -1,38 +1,42 @@
|
||||
import type { Octokit } from "@octokit/rest";
|
||||
import packageJson from "../package.json" with { type: "json" };
|
||||
import { log } from "./cli.ts";
|
||||
import { createOctokit, parseRepoContext } from "./github.ts";
|
||||
import { createOctokit, type OctokitWithPlugins, parseRepoContext } from "./github.ts";
|
||||
import { fetchRepoSettings, type RepoSettings } from "./repoSettings.ts";
|
||||
|
||||
export interface RepoData {
|
||||
owner: string;
|
||||
name: string;
|
||||
octokit: Octokit;
|
||||
repo: Awaited<ReturnType<Octokit["repos"]["get"]>>["data"];
|
||||
repoSettings: RepoSettings;
|
||||
}
|
||||
|
||||
interface ResolveRepoDataParams {
|
||||
octokit: OctokitWithPlugins;
|
||||
token: string;
|
||||
}
|
||||
|
||||
/**
|
||||
* Initialize GitHub connection: token, octokit, repo data, settings
|
||||
* Initialize repo data: parse context, fetch repo info and settings
|
||||
*/
|
||||
export async function resolveRepoData(token: string): Promise<RepoData> {
|
||||
export async function resolveRepoData(params: ResolveRepoDataParams): Promise<RepoData> {
|
||||
log.info(`» running Pullfrog v${packageJson.version}...`);
|
||||
|
||||
const { owner, name } = parseRepoContext();
|
||||
|
||||
const octokit = createOctokit(token);
|
||||
|
||||
// fetch repo data and settings in parallel
|
||||
const [repoResponse, repoSettings] = await Promise.all([
|
||||
octokit.repos.get({ owner, repo: name }),
|
||||
fetchRepoSettings({ token, repoContext: { owner, name } }),
|
||||
params.octokit.repos.get({ owner, repo: name }),
|
||||
fetchRepoSettings({ token: params.token, repoContext: { owner, name } }),
|
||||
]);
|
||||
|
||||
return {
|
||||
owner,
|
||||
name,
|
||||
octokit,
|
||||
repo: repoResponse.data,
|
||||
repoSettings,
|
||||
};
|
||||
}
|
||||
|
||||
// re-export for convenience
|
||||
export { createOctokit };
|
||||
|
||||
+25
-13
@@ -10,22 +10,13 @@ export interface Mode {
|
||||
|
||||
export interface RepoSettings {
|
||||
defaultAgent: AgentName | null;
|
||||
modes: Mode[];
|
||||
web: ToolPermission;
|
||||
search: ToolPermission;
|
||||
write: ToolPermission;
|
||||
bash: BashPermission;
|
||||
modes: Mode[];
|
||||
}
|
||||
|
||||
export const DEFAULT_REPO_SETTINGS: RepoSettings = {
|
||||
defaultAgent: null,
|
||||
web: "enabled",
|
||||
search: "enabled",
|
||||
write: "enabled",
|
||||
bash: "restricted",
|
||||
modes: [],
|
||||
};
|
||||
|
||||
/**
|
||||
* Fetch repository settings from the Pullfrog API
|
||||
* Returns defaults if repo doesn't exist or fetch fails
|
||||
@@ -55,17 +46,38 @@ export async function fetchRepoSettings(params: {
|
||||
clearTimeout(timeoutId);
|
||||
|
||||
if (!response.ok) {
|
||||
return DEFAULT_REPO_SETTINGS;
|
||||
return {
|
||||
defaultAgent: null,
|
||||
modes: [],
|
||||
web: "enabled",
|
||||
search: "enabled",
|
||||
write: "enabled",
|
||||
bash: "restricted",
|
||||
};
|
||||
}
|
||||
|
||||
const settings = (await response.json()) as RepoSettings | null;
|
||||
if (settings === null) {
|
||||
return DEFAULT_REPO_SETTINGS;
|
||||
return {
|
||||
defaultAgent: null,
|
||||
modes: [],
|
||||
web: "enabled",
|
||||
search: "enabled",
|
||||
write: "enabled",
|
||||
bash: "restricted",
|
||||
};
|
||||
}
|
||||
|
||||
return settings;
|
||||
} catch {
|
||||
clearTimeout(timeoutId);
|
||||
return DEFAULT_REPO_SETTINGS;
|
||||
return {
|
||||
defaultAgent: null,
|
||||
modes: [],
|
||||
web: "enabled",
|
||||
search: "enabled",
|
||||
write: "enabled",
|
||||
bash: "restricted",
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
+1
-18
@@ -1,23 +1,6 @@
|
||||
import type { AgentResult, ToolPermissions } from "../agents/shared.ts";
|
||||
import type { AgentResult } from "../agents/shared.ts";
|
||||
import type { MainResult } from "../main.ts";
|
||||
import { log } from "./cli.ts";
|
||||
import type { ResolvedPayload } from "./payload.ts";
|
||||
|
||||
/**
|
||||
* Compute tool permissions from inputs.
|
||||
* For run action, bash defaults to restricted for public repos when unset.
|
||||
*/
|
||||
export function resolvePermissions(params: {
|
||||
payload: ResolvedPayload;
|
||||
isPublicRepo: boolean;
|
||||
}): ToolPermissions {
|
||||
return {
|
||||
web: params.payload.web ?? "enabled",
|
||||
search: params.payload.search ?? "enabled",
|
||||
write: params.payload.write ?? "enabled",
|
||||
bash: params.payload.bash ?? (params.isPublicRepo ? "restricted" : "enabled"),
|
||||
};
|
||||
}
|
||||
|
||||
export async function handleAgentResult(result: AgentResult): Promise<MainResult> {
|
||||
if (!result.success) {
|
||||
|
||||
+29
-16
@@ -1,29 +1,42 @@
|
||||
import { log } from "./cli.ts";
|
||||
import type { RepoData } from "./repoData.ts";
|
||||
import { fetchWorkflowRunInfo } from "./workflowRun.ts";
|
||||
import type { OctokitWithPlugins } from "./github.ts";
|
||||
import { fetchWorkflowRunInfo, type WorkflowRunInfo } from "./workflowRun.ts";
|
||||
|
||||
interface ResolveRunParams {
|
||||
octokit: OctokitWithPlugins;
|
||||
}
|
||||
|
||||
export interface ResolveRunResult {
|
||||
runId: string;
|
||||
jobId: string | undefined;
|
||||
workflowRunInfo: WorkflowRunInfo;
|
||||
}
|
||||
|
||||
/**
|
||||
* Resolve GitHub Actions workflow run context (runId, jobId, progress comment)
|
||||
* Resolve GitHub Actions workflow run context.
|
||||
* Uses GITHUB_REPOSITORY and GITHUB_RUN_ID env vars.
|
||||
*/
|
||||
export async function resolveRunId(
|
||||
repoData: RepoData
|
||||
): Promise<{ runId: string; jobId: string | undefined }> {
|
||||
export async function resolveRun(params: ResolveRunParams): Promise<ResolveRunResult> {
|
||||
const runId = process.env.GITHUB_RUN_ID || "";
|
||||
const githubRepo = process.env.GITHUB_REPOSITORY;
|
||||
if (!githubRepo || !githubRepo.includes("/")) {
|
||||
throw new Error(`GITHUB_REPOSITORY env var must be set to "owner/repo", got: ${githubRepo}`);
|
||||
}
|
||||
const [owner, repo] = githubRepo.split("/");
|
||||
|
||||
if (runId) {
|
||||
const workflowRunInfo = await fetchWorkflowRunInfo(runId);
|
||||
if (workflowRunInfo.progressCommentId) {
|
||||
process.env.PULLFROG_PROGRESS_COMMENT_ID = workflowRunInfo.progressCommentId;
|
||||
log.info(`» using pre-created progress comment: ${workflowRunInfo.progressCommentId}`);
|
||||
}
|
||||
const workflowRunInfo = runId ? await fetchWorkflowRunInfo(runId) : { progressCommentId: null };
|
||||
|
||||
if (workflowRunInfo.progressCommentId) {
|
||||
process.env.PULLFROG_PROGRESS_COMMENT_ID = workflowRunInfo.progressCommentId;
|
||||
log.info(`» using pre-created progress comment: ${workflowRunInfo.progressCommentId}`);
|
||||
}
|
||||
|
||||
let jobId: string | undefined;
|
||||
const jobName = process.env.GITHUB_JOB;
|
||||
if (jobName && runId) {
|
||||
const jobs = await repoData.octokit.rest.actions.listJobsForWorkflowRun({
|
||||
owner: repoData.owner,
|
||||
repo: repoData.name,
|
||||
const jobs = await params.octokit.rest.actions.listJobsForWorkflowRun({
|
||||
owner,
|
||||
repo,
|
||||
run_id: parseInt(runId, 10),
|
||||
});
|
||||
const matchingJob = jobs.data.jobs.find((job) => job.name === jobName);
|
||||
@@ -33,5 +46,5 @@ export async function resolveRunId(
|
||||
}
|
||||
}
|
||||
|
||||
return { runId, jobId };
|
||||
return { runId, jobId, workflowRunInfo };
|
||||
}
|
||||
|
||||
@@ -1,11 +1,10 @@
|
||||
export interface WorkflowRunInfo {
|
||||
progressCommentId: string | null;
|
||||
issueNumber: number | null;
|
||||
}
|
||||
|
||||
/**
|
||||
* Fetch workflow run info from the Pullfrog API
|
||||
* Returns the pre-created progress comment ID if one exists
|
||||
* Fetch workflow run info from the Pullfrog API.
|
||||
* Returns the pre-created progress comment ID if one exists.
|
||||
*/
|
||||
export async function fetchWorkflowRunInfo(runId: string): Promise<WorkflowRunInfo> {
|
||||
const apiUrl = process.env.API_URL || "https://pullfrog.com";
|
||||
@@ -25,13 +24,13 @@ export async function fetchWorkflowRunInfo(runId: string): Promise<WorkflowRunIn
|
||||
clearTimeout(timeoutId);
|
||||
|
||||
if (!response.ok) {
|
||||
return { progressCommentId: null, issueNumber: null };
|
||||
return { progressCommentId: null };
|
||||
}
|
||||
|
||||
const data = (await response.json()) as WorkflowRunInfo;
|
||||
return data;
|
||||
} catch {
|
||||
clearTimeout(timeoutId);
|
||||
return { progressCommentId: null, issueNumber: null };
|
||||
return { progressCommentId: null };
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user