This commit is contained in:
Colin McDonnell
2026-01-16 18:43:09 +00:00
committed by pullfrog[bot]
parent 101c666610
commit 69b9b96ddd
37 changed files with 1005 additions and 1005 deletions
+8 -7
View File
@@ -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
View File
@@ -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
View File
@@ -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
View File
@@ -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
View File
@@ -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
View File
@@ -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],
+700 -691
View File
File diff suppressed because it is too large Load Diff
+1 -1
View File
@@ -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");
-4
View File
@@ -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;
}
+37 -49
View File
@@ -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
View File
@@ -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
View File
@@ -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
View File
@@ -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
View File
@@ -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
View File
@@ -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
View File
@@ -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 ?? [],
+2 -2
View File
@@ -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
View File
@@ -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
View File
@@ -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
View File
@@ -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,
});
+4 -4
View File
@@ -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
View File
@@ -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
View File
@@ -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",
+4 -4
View File
@@ -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
View File
@@ -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));
}
+8 -8
View File
@@ -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,
});
+7 -14
View File
@@ -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
View File
@@ -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,
};
}
+1 -1
View File
@@ -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
+24
View File
@@ -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
View File
@@ -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
View File
@@ -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
View File
@@ -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
View File
@@ -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
View File
@@ -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 };
}
+4 -5
View File
@@ -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 };
}
}