97937f46f7
* console UI improvements and cleanup - add verify workflow button and API endpoint for manual installation check - move env var check into PromptBox as blocking overlay (hoisted to RepoConsole) - extract FlagsCheatSheet modal, replace verbose flag hints everywhere - add info popovers for repo setup / post-checkout script descriptions - remove unused prAutoFixCiFailures schema fields and migration - default mentionAllowNonCollaborator to disabled for safety on public repos - update docs for triggers and getting started Co-authored-by: Cursor <cursoragent@cursor.com> * add diagnostic logging for push_branch bug investigation temporary [push-debug] logs to trace why getPushDestination falls back to origin/<localBranch> instead of using the correct remote branch name for same-repo PRs. Co-authored-by: Cursor <cursoragent@cursor.com> * add git config diagnostic to verify original bug cause Co-authored-by: Cursor <cursoragent@cursor.com> * temporarily disable StoredPushDest to test git config path Co-authored-by: Cursor <cursoragent@cursor.com> * remove diagnostic logging for push_branch investigation verified that StoredPushDest fix works correctly on preview repo. both the stored dest path and the git config fallback resolve to the correct remote branch in the GitHub Actions environment. Co-authored-by: Cursor <cursoragent@cursor.com> * fix formatting in AgentSettings and TriggersSettings Co-authored-by: Cursor <cursoragent@cursor.com> * pass derived env var state to PromptBox instead of raw secrets data eliminates duplicated derivation logic between RepoConsole and PromptBox by passing envVarMissing, envVarChecking, and agentKeyNames as props. Co-authored-by: Cursor <cursoragent@cursor.com> * fix: prevent duplicate comment after PR review deletes progress comment progressCommentId now uses three states: undefined (no comment yet), number (active), null (deliberately deleted). After create_pull_request_review deletes the progress comment, subsequent report_progress calls skip instead of creating a new comment. Co-authored-by: Cursor <cursoragent@cursor.com> * effort descriptions, test ordering, husky, docs images, typo fix - rewrote delegation effort level descriptions to per-level breakdown - action-agents now waits for action-agnostic; action-agnostic waits for root - added husky + lint-staged (biome check --write on staged files) - updated triggers docs images and triggers.mdx content - fixed "figured" → "figures" typo on landing page - updated pnpm-lock.yaml Co-authored-by: Cursor <cursoragent@cursor.com> * Commit --------- Co-authored-by: Cursor <cursoragent@cursor.com>
333 lines
9.4 KiB
TypeScript
333 lines
9.4 KiB
TypeScript
import "./arkConfig.ts";
|
|
import { createServer } from "node:net";
|
|
// this must be imported first
|
|
import { FastMCP, type Tool } from "fastmcp";
|
|
import type { Agent } from "../agents/index.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";
|
|
|
|
export type BackgroundProcess = {
|
|
pid: number;
|
|
outputPath: string;
|
|
pidPath: string;
|
|
};
|
|
|
|
export type StoredPushDest = {
|
|
remoteName: string;
|
|
remoteBranch: string;
|
|
localBranch: string;
|
|
};
|
|
|
|
export interface ToolState {
|
|
// where we're allowed to push - base repo initially, fork URL for fork PRs
|
|
// set by setupGit, updated by checkout_pr. always set before push validation.
|
|
pushUrl?: string;
|
|
// push destination set by checkout_pr - used as primary source in push_branch
|
|
// because git config reads can fail in certain environments
|
|
pushDest?: StoredPushDest;
|
|
// issue or PR number (same number space in GitHub)
|
|
issueNumber?: number;
|
|
selectedMode?: string;
|
|
// true while a subagent is running via the delegate tool — prevents recursive delegation
|
|
delegationActive: boolean;
|
|
backgroundProcesses: Map<string, BackgroundProcess>;
|
|
review?: {
|
|
id: number;
|
|
nodeId: string;
|
|
};
|
|
dependencyInstallation?: {
|
|
status: "not_started" | "in_progress" | "completed" | "failed";
|
|
promise: Promise<PrepResult[]> | undefined;
|
|
results: PrepResult[] | undefined;
|
|
};
|
|
// undefined = no comment yet, number = active comment, null = deliberately deleted
|
|
progressCommentId: number | null | undefined;
|
|
lastProgressBody?: string;
|
|
wasUpdated?: boolean;
|
|
output?: string;
|
|
}
|
|
|
|
interface InitToolStateParams {
|
|
progressCommentId: string | undefined;
|
|
}
|
|
|
|
export function initToolState(params: InitToolStateParams): ToolState {
|
|
const parsed = params.progressCommentId ? parseInt(params.progressCommentId, 10) : NaN;
|
|
const resolvedId = Number.isNaN(parsed) ? undefined : parsed;
|
|
|
|
if (resolvedId) {
|
|
log.info(`» using pre-created progress comment: ${resolvedId}`);
|
|
}
|
|
|
|
return {
|
|
progressCommentId: resolvedId,
|
|
delegationActive: false,
|
|
backgroundProcesses: new Map(),
|
|
};
|
|
}
|
|
|
|
export interface ToolContext {
|
|
repo: RunContextData["repo"];
|
|
payload: ResolvedPayload;
|
|
octokit: OctokitWithPlugins;
|
|
githubInstallationToken: string;
|
|
gitToken: string;
|
|
apiToken: string;
|
|
agent: Agent;
|
|
modes: Mode[];
|
|
postCheckoutScript: string | null;
|
|
toolState: ToolState;
|
|
runId: string;
|
|
jobId: string | undefined;
|
|
// set after MCP server starts — used by delegate tool to pass URL to subagents
|
|
mcpServerUrl: string;
|
|
tmpdir: string;
|
|
}
|
|
|
|
import { log } from "../utils/cli.ts";
|
|
import type { RunContextData } from "../utils/runContextData.ts";
|
|
import { BashTool, KillBackgroundTool } from "./bash.ts";
|
|
import { CheckoutPrTool } from "./checkout.ts";
|
|
import { GetCheckSuiteLogsTool } from "./checkSuite.ts";
|
|
import {
|
|
CreateCommentTool,
|
|
EditCommentTool,
|
|
ReplyToReviewCommentTool,
|
|
ReportProgressTool,
|
|
} from "./comment.ts";
|
|
import { CommitInfoTool } from "./commitInfo.ts";
|
|
import { DelegateTool } from "./delegate.ts";
|
|
import {
|
|
AwaitDependencyInstallationTool,
|
|
StartDependencyInstallationTool,
|
|
} from "./dependencies.ts";
|
|
import {
|
|
FileDeleteTool,
|
|
FileEditTool,
|
|
FileReadTool,
|
|
FileWriteTool,
|
|
ListDirectoryTool,
|
|
} from "./file.ts";
|
|
import { DeleteBranchTool, GitFetchTool, GitTool, PushBranchTool, PushTagsTool } from "./git.ts";
|
|
import { IssueTool } from "./issue.ts";
|
|
import { GetIssueCommentsTool } from "./issueComments.ts";
|
|
import { GetIssueEventsTool } from "./issueEvents.ts";
|
|
import { IssueInfoTool } from "./issueInfo.ts";
|
|
import { AddLabelsTool } from "./labels.ts";
|
|
import { SetOutputTool } from "./output.ts";
|
|
import { CreatePullRequestTool } from "./pr.ts";
|
|
import { PullRequestInfoTool } from "./prInfo.ts";
|
|
import { CreatePullRequestReviewTool } from "./review.ts";
|
|
import {
|
|
GetReviewCommentsTool,
|
|
ListPullRequestReviewsTool,
|
|
ResolveReviewThreadTool,
|
|
} from "./reviewComments.ts";
|
|
import { addTools } from "./shared.ts";
|
|
import { UploadFileTool } from "./upload.ts";
|
|
|
|
const mcpPortStart = 3764;
|
|
const mcpPortAttempts = 100;
|
|
const mcpHost = "127.0.0.1";
|
|
const mcpEndpoint = "/mcp";
|
|
|
|
function readEnvPort(): number | null {
|
|
const rawPort = process.env.PULLFROG_MCP_PORT;
|
|
if (!rawPort) return null;
|
|
const parsed = Number.parseInt(rawPort, 10);
|
|
if (!Number.isInteger(parsed) || parsed <= 0 || parsed > 65535) {
|
|
throw new Error(`invalid PULLFROG_MCP_PORT: ${rawPort}`);
|
|
}
|
|
return parsed;
|
|
}
|
|
|
|
function isPortAvailable(port: number): Promise<boolean> {
|
|
return new Promise((resolve) => {
|
|
const server = createServer();
|
|
server.unref();
|
|
server.once("error", () => resolve(false));
|
|
server.once("listening", () => {
|
|
server.close(() => resolve(true));
|
|
});
|
|
server.listen(port, mcpHost);
|
|
});
|
|
}
|
|
|
|
function getErrorMessage(error: unknown): string {
|
|
if (error instanceof Error) return error.message;
|
|
return String(error);
|
|
}
|
|
|
|
function isAddressInUse(error: unknown): boolean {
|
|
const message = getErrorMessage(error).toLowerCase();
|
|
return message.includes("eaddrinuse") || message.includes("address already in use");
|
|
}
|
|
function buildTools(ctx: ToolContext): Tool<any, any>[] {
|
|
const tools: Tool<any, any>[] = [
|
|
DelegateTool(ctx),
|
|
StartDependencyInstallationTool(ctx),
|
|
AwaitDependencyInstallationTool(ctx),
|
|
CreateCommentTool(ctx),
|
|
EditCommentTool(ctx),
|
|
ReplyToReviewCommentTool(ctx),
|
|
IssueTool(ctx),
|
|
IssueInfoTool(ctx),
|
|
GetIssueCommentsTool(ctx),
|
|
GetIssueEventsTool(ctx),
|
|
CreatePullRequestTool(ctx),
|
|
CreatePullRequestReviewTool(ctx),
|
|
PullRequestInfoTool(ctx),
|
|
CommitInfoTool(ctx),
|
|
CheckoutPrTool(ctx),
|
|
GetReviewCommentsTool(ctx),
|
|
ListPullRequestReviewsTool(ctx),
|
|
ResolveReviewThreadTool(ctx),
|
|
GetCheckSuiteLogsTool(ctx),
|
|
AddLabelsTool(ctx),
|
|
PushBranchTool(ctx),
|
|
GitTool(ctx),
|
|
GitFetchTool(ctx),
|
|
DeleteBranchTool(ctx),
|
|
PushTagsTool(ctx),
|
|
UploadFileTool(ctx),
|
|
SetOutputTool(ctx),
|
|
FileReadTool(ctx),
|
|
FileWriteTool(ctx),
|
|
FileEditTool(ctx),
|
|
FileDeleteTool(ctx),
|
|
ListDirectoryTool(ctx),
|
|
];
|
|
|
|
// only add BashTool when bash is "restricted"
|
|
// - "enabled": native bash only (no MCP bash needed)
|
|
// - "restricted": MCP bash only (native blocked, env filtered)
|
|
// - "disabled": no bash at all
|
|
if (ctx.payload.bash === "restricted") {
|
|
tools.push(BashTool(ctx));
|
|
tools.push(KillBackgroundTool(ctx));
|
|
}
|
|
|
|
tools.push(ReportProgressTool(ctx));
|
|
|
|
return tools;
|
|
}
|
|
|
|
type McpStartResult = {
|
|
server: FastMCP;
|
|
url: string;
|
|
port: number;
|
|
};
|
|
|
|
async function tryStartMcpServer(ctx: ToolContext, port: number): Promise<McpStartResult | null> {
|
|
const server = new FastMCP({
|
|
name: ghPullfrogMcpName,
|
|
version: "0.0.1",
|
|
});
|
|
const tools = buildTools(ctx);
|
|
addTools(ctx, server, tools);
|
|
|
|
try {
|
|
await server.start({
|
|
transportType: "httpStream",
|
|
httpStream: {
|
|
port,
|
|
host: mcpHost,
|
|
endpoint: mcpEndpoint,
|
|
},
|
|
});
|
|
const url = `http://${mcpHost}:${port}${mcpEndpoint}`;
|
|
return { server, url, port };
|
|
} catch (error) {
|
|
if (!isAddressInUse(error)) {
|
|
throw error;
|
|
}
|
|
try {
|
|
await server.stop();
|
|
} catch {
|
|
// ignore cleanup errors on failed start
|
|
}
|
|
return null;
|
|
}
|
|
}
|
|
|
|
async function selectMcpPort(ctx: ToolContext): Promise<McpStartResult> {
|
|
let lastError: unknown = null;
|
|
|
|
const requestedPort = readEnvPort();
|
|
if (requestedPort !== null) {
|
|
if (await isPortAvailable(requestedPort)) {
|
|
const requestedResult = await tryStartMcpServer(ctx, requestedPort);
|
|
if (requestedResult) {
|
|
return requestedResult;
|
|
}
|
|
}
|
|
}
|
|
|
|
// randomize start offset to reduce collision chance in parallel runs
|
|
const randomOffset = Math.floor(Math.random() * 50);
|
|
|
|
for (let offset = 0; offset < mcpPortAttempts; offset++) {
|
|
const port = mcpPortStart + randomOffset + offset;
|
|
try {
|
|
if (!(await isPortAvailable(port))) {
|
|
continue;
|
|
}
|
|
const result = await tryStartMcpServer(ctx, port);
|
|
if (result) {
|
|
return result;
|
|
}
|
|
} catch (error) {
|
|
lastError = error;
|
|
if (!isAddressInUse(error)) {
|
|
throw error;
|
|
}
|
|
}
|
|
}
|
|
|
|
const message = getErrorMessage(lastError);
|
|
throw new Error(
|
|
`could not find available mcp port starting at ${mcpPortStart} (last error: ${message})`
|
|
);
|
|
}
|
|
|
|
async function killBackgroundProcesses(toolState: ToolState): Promise<void> {
|
|
const backgroundProcesses = toolState.backgroundProcesses;
|
|
if (backgroundProcesses.size === 0) return;
|
|
for (const proc of backgroundProcesses.values()) {
|
|
try {
|
|
process.kill(-proc.pid, "SIGTERM");
|
|
} catch {
|
|
// already dead
|
|
}
|
|
}
|
|
await new Promise((resolve) => setTimeout(resolve, 200));
|
|
for (const proc of backgroundProcesses.values()) {
|
|
try {
|
|
process.kill(-proc.pid, "SIGKILL");
|
|
} catch {
|
|
// already dead
|
|
}
|
|
}
|
|
backgroundProcesses.clear();
|
|
}
|
|
|
|
/**
|
|
* Start the MCP HTTP server and return the URL and close function
|
|
*/
|
|
export async function startMcpHttpServer(
|
|
ctx: ToolContext
|
|
): Promise<{ url: string; [Symbol.asyncDispose]: () => Promise<void> }> {
|
|
const startResult = await selectMcpPort(ctx);
|
|
|
|
return {
|
|
url: startResult.url,
|
|
[Symbol.asyncDispose]: async () => {
|
|
await killBackgroundProcesses(ctx.toolState);
|
|
await startResult.server.stop();
|
|
},
|
|
};
|
|
}
|