Compare commits
20 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| 7c8dd7f43c | |||
| de686da001 | |||
| c456fae716 | |||
| 20b08b5321 | |||
| c0fd69560f | |||
| e0bd984975 | |||
| c4d66bf7f6 | |||
| 1d2a06998c | |||
| 6311138132 | |||
| 1ed3da8273 | |||
| d5ab3706db | |||
| f8a871f723 | |||
| 52ec35790a | |||
| edd240f535 | |||
| cd1ea5267c | |||
| 4f1e4a2e7a | |||
| 73836d9c8f | |||
| da72d0d6ee | |||
| b472aa1ba9 | |||
| e2d8dfeebf |
@@ -39,6 +39,8 @@ jobs:
|
|||||||
GEMINI_API_KEY: ${{ secrets.GEMINI_API_KEY }}
|
GEMINI_API_KEY: ${{ secrets.GEMINI_API_KEY }}
|
||||||
GEMINI_MODEL: ${{ vars.GEMINI_MODEL }}
|
GEMINI_MODEL: ${{ vars.GEMINI_MODEL }}
|
||||||
OPENCODE_MODEL: ${{ vars.OPENCODE_MODEL }}
|
OPENCODE_MODEL: ${{ vars.OPENCODE_MODEL }}
|
||||||
|
OPENCODE_MODEL_MINI: ${{ vars.OPENCODE_MODEL_MINI }}
|
||||||
|
OPENCODE_MODEL_MAX: ${{ vars.OPENCODE_MODEL_MAX }}
|
||||||
steps:
|
steps:
|
||||||
- uses: actions/checkout@v4
|
- uses: actions/checkout@v4
|
||||||
- uses: pnpm/action-setup@v4
|
- uses: pnpm/action-setup@v4
|
||||||
|
|||||||
@@ -1,4 +1,4 @@
|
|||||||
<!-- test preview system --> <!-- test bypass 2 -->
|
<!-- test preview system --> <!-- test bypass 2 --> <!-- trigger preview repo creation -->
|
||||||
<p align="center">
|
<p align="center">
|
||||||
<h1 align="center">
|
<h1 align="center">
|
||||||
<picture>
|
<picture>
|
||||||
|
|||||||
+227
-22
@@ -1,7 +1,7 @@
|
|||||||
// changes to effort level configuration should be reflected in wiki/effort.md and docs/effort.mdx
|
// changes to effort level configuration should be reflected in wiki/effort.md and docs/effort.mdx
|
||||||
// changes to tool permissions should be reflected in wiki/granular-tools.md
|
// changes to tool permissions should be reflected in wiki/granular-tools.md
|
||||||
// changes to web search configuration should be reflected in wiki/websearch.md
|
// changes to web search configuration should be reflected in wiki/websearch.md
|
||||||
import { mkdirSync, writeFileSync } from "node:fs";
|
import { existsSync, mkdirSync, readFileSync, writeFileSync } from "node:fs";
|
||||||
import { join } from "node:path";
|
import { join } from "node:path";
|
||||||
import { performance } from "node:perf_hooks";
|
import { performance } from "node:perf_hooks";
|
||||||
import { ghPullfrogMcpName } from "../external.ts";
|
import { ghPullfrogMcpName } from "../external.ts";
|
||||||
@@ -38,6 +38,159 @@ function detectProviderError(text: string): string | null {
|
|||||||
return null;
|
return null;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
type OpenCodeConfig = {
|
||||||
|
mcp?: Record<string, unknown>;
|
||||||
|
permission?: Record<string, unknown>;
|
||||||
|
provider?: Record<string, unknown>;
|
||||||
|
model?: string;
|
||||||
|
enabled_providers?: string[];
|
||||||
|
[key: string]: unknown;
|
||||||
|
};
|
||||||
|
|
||||||
|
type RecordPropertyContext = {
|
||||||
|
value: unknown;
|
||||||
|
key: string;
|
||||||
|
};
|
||||||
|
|
||||||
|
type RepoConfigLoadContext = {
|
||||||
|
repoConfigPath: string;
|
||||||
|
};
|
||||||
|
|
||||||
|
type ProviderFromModelContext = {
|
||||||
|
model: string;
|
||||||
|
};
|
||||||
|
|
||||||
|
type InlineConfigOverrideContext = {
|
||||||
|
model: string;
|
||||||
|
};
|
||||||
|
|
||||||
|
type InlineConfigOverride = {
|
||||||
|
providerId: string;
|
||||||
|
content: string;
|
||||||
|
};
|
||||||
|
|
||||||
|
type ModelOverrideResolutionContext = {
|
||||||
|
effort: AgentRunContext["payload"]["effort"];
|
||||||
|
env: NodeJS.ProcessEnv;
|
||||||
|
};
|
||||||
|
|
||||||
|
type ModelOverrideResolution = {
|
||||||
|
model: string;
|
||||||
|
source: "OPENCODE_MODEL_MINI" | "OPENCODE_MODEL_MAX" | "OPENCODE_MODEL";
|
||||||
|
};
|
||||||
|
|
||||||
|
function isRecord(value: unknown): value is Record<string, unknown> {
|
||||||
|
return typeof value === "object" && value !== null && !Array.isArray(value);
|
||||||
|
}
|
||||||
|
|
||||||
|
function getRecordProperty(ctx: RecordPropertyContext): Record<string, unknown> | undefined {
|
||||||
|
if (!isRecord(ctx.value)) {
|
||||||
|
return undefined;
|
||||||
|
}
|
||||||
|
const propertyValue = ctx.value[ctx.key];
|
||||||
|
if (!isRecord(propertyValue)) {
|
||||||
|
return undefined;
|
||||||
|
}
|
||||||
|
return propertyValue;
|
||||||
|
}
|
||||||
|
|
||||||
|
function loadRepoOpenCodeConfig(ctx: RepoConfigLoadContext): OpenCodeConfig | undefined {
|
||||||
|
if (!existsSync(ctx.repoConfigPath)) {
|
||||||
|
log.info(`» repo opencode.json not found at ${ctx.repoConfigPath}`);
|
||||||
|
return undefined;
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
const rawConfig = readFileSync(ctx.repoConfigPath, "utf-8");
|
||||||
|
const parsedConfig = JSON.parse(rawConfig);
|
||||||
|
if (!isRecord(parsedConfig)) {
|
||||||
|
log.warning(`» repo opencode.json is not an object: ${ctx.repoConfigPath}`);
|
||||||
|
return undefined;
|
||||||
|
}
|
||||||
|
|
||||||
|
const providerConfig = getRecordProperty({ value: parsedConfig, key: "provider" });
|
||||||
|
if (providerConfig) {
|
||||||
|
const providerNames = Object.keys(providerConfig);
|
||||||
|
log.info(`» repo opencode provider config detected: ${providerNames.join(", ")}`);
|
||||||
|
}
|
||||||
|
|
||||||
|
const result: OpenCodeConfig = parsedConfig;
|
||||||
|
log.info(`» loaded repo opencode.json from ${ctx.repoConfigPath}`);
|
||||||
|
return result;
|
||||||
|
} catch (error) {
|
||||||
|
const errorMessage = error instanceof Error ? error.message : String(error);
|
||||||
|
log.warning(`» failed to parse repo opencode.json at ${ctx.repoConfigPath}: ${errorMessage}`);
|
||||||
|
return undefined;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function parseProviderFromModel(ctx: ProviderFromModelContext): string | undefined {
|
||||||
|
const trimmedModel = ctx.model.trim();
|
||||||
|
const slashIndex = trimmedModel.indexOf("/");
|
||||||
|
if (slashIndex <= 0) {
|
||||||
|
return undefined;
|
||||||
|
}
|
||||||
|
const providerId = trimmedModel.slice(0, slashIndex).trim().toLowerCase();
|
||||||
|
if (!providerId) {
|
||||||
|
return undefined;
|
||||||
|
}
|
||||||
|
return providerId;
|
||||||
|
}
|
||||||
|
|
||||||
|
function buildInlineConfigOverride(
|
||||||
|
ctx: InlineConfigOverrideContext
|
||||||
|
): InlineConfigOverride | undefined {
|
||||||
|
const providerId = parseProviderFromModel({ model: ctx.model });
|
||||||
|
if (!providerId) {
|
||||||
|
return undefined;
|
||||||
|
}
|
||||||
|
const inlineConfig: OpenCodeConfig = {
|
||||||
|
model: ctx.model,
|
||||||
|
enabled_providers: [providerId],
|
||||||
|
};
|
||||||
|
return {
|
||||||
|
providerId,
|
||||||
|
content: JSON.stringify(inlineConfig),
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
function readNonEmptyEnvVar(ctx: { env: NodeJS.ProcessEnv; name: string }): string | undefined {
|
||||||
|
const value = ctx.env[ctx.name];
|
||||||
|
if (!value) {
|
||||||
|
return undefined;
|
||||||
|
}
|
||||||
|
const trimmed = value.trim();
|
||||||
|
if (!trimmed) {
|
||||||
|
return undefined;
|
||||||
|
}
|
||||||
|
return trimmed;
|
||||||
|
}
|
||||||
|
|
||||||
|
function resolveModelOverride(
|
||||||
|
ctx: ModelOverrideResolutionContext
|
||||||
|
): ModelOverrideResolution | undefined {
|
||||||
|
if (ctx.effort === "mini") {
|
||||||
|
const miniModel = readNonEmptyEnvVar({ env: ctx.env, name: "OPENCODE_MODEL_MINI" });
|
||||||
|
if (miniModel) {
|
||||||
|
return { model: miniModel, source: "OPENCODE_MODEL_MINI" };
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (ctx.effort === "max") {
|
||||||
|
const maxModel = readNonEmptyEnvVar({ env: ctx.env, name: "OPENCODE_MODEL_MAX" });
|
||||||
|
if (maxModel) {
|
||||||
|
return { model: maxModel, source: "OPENCODE_MODEL_MAX" };
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const baseModel = readNonEmptyEnvVar({ env: ctx.env, name: "OPENCODE_MODEL" });
|
||||||
|
if (!baseModel) {
|
||||||
|
return undefined;
|
||||||
|
}
|
||||||
|
|
||||||
|
return { model: baseModel, source: "OPENCODE_MODEL" };
|
||||||
|
}
|
||||||
|
|
||||||
async function installOpencode(): Promise<string> {
|
async function installOpencode(): Promise<string> {
|
||||||
return await installFromNpmTarball({
|
return await installFromNpmTarball({
|
||||||
packageName: "opencode-ai",
|
packageName: "opencode-ai",
|
||||||
@@ -66,13 +219,18 @@ export const opencode = agent({
|
|||||||
// this is critical for debugging since opencode run suppresses errors by default (Issue #752).
|
// this is critical for debugging since opencode run suppresses errors by default (Issue #752).
|
||||||
const args = ["run", ctx.instructions.full, "--format", "json", "--print-logs"];
|
const args = ["run", ctx.instructions.full, "--format", "json", "--print-logs"];
|
||||||
|
|
||||||
// only override model when OPENCODE_MODEL is set (e.g., test environments with
|
// resolve model override from environment.
|
||||||
// restricted API quotas). in production, OpenCode auto-selects the best available
|
// precedence:
|
||||||
// model based on which provider API keys are present.
|
// 1) effort-specific overrides (OPENCODE_MODEL_MINI / OPENCODE_MODEL_MAX)
|
||||||
const modelOverride = process.env.OPENCODE_MODEL;
|
// 2) OPENCODE_MODEL fallback
|
||||||
|
// 3) OpenCode auto-select
|
||||||
|
const modelOverride = resolveModelOverride({
|
||||||
|
effort: ctx.payload.effort,
|
||||||
|
env: process.env,
|
||||||
|
});
|
||||||
if (modelOverride) {
|
if (modelOverride) {
|
||||||
args.push("--model", modelOverride);
|
args.push("--model", modelOverride.model);
|
||||||
log.info(`» model: ${modelOverride} (override)`);
|
log.info(`» model: ${modelOverride.model} (override via ${modelOverride.source})`);
|
||||||
} else {
|
} else {
|
||||||
log.info(`» model: auto-selected by OpenCode`);
|
log.info(`» model: auto-selected by OpenCode`);
|
||||||
}
|
}
|
||||||
@@ -89,6 +247,31 @@ export const opencode = agent({
|
|||||||
GOOGLE_GENERATIVE_AI_API_KEY:
|
GOOGLE_GENERATIVE_AI_API_KEY:
|
||||||
process.env.GOOGLE_GENERATIVE_AI_API_KEY || process.env.GEMINI_API_KEY,
|
process.env.GOOGLE_GENERATIVE_AI_API_KEY || process.env.GEMINI_API_KEY,
|
||||||
};
|
};
|
||||||
|
|
||||||
|
if (modelOverride) {
|
||||||
|
const inlineOverride = buildInlineConfigOverride({ model: modelOverride.model });
|
||||||
|
if (inlineOverride) {
|
||||||
|
env.OPENCODE_CONFIG_CONTENT = inlineOverride.content;
|
||||||
|
log.info(
|
||||||
|
`» OpenCode inline config override enabled: provider=${inlineOverride.providerId}, model=${modelOverride.model}`
|
||||||
|
);
|
||||||
|
} else {
|
||||||
|
log.warning(
|
||||||
|
`» skipping OpenCode inline config override: unable to parse provider from model "${modelOverride.model}"`
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const hasOpenRouterKey = Boolean(env.OPENROUTER_API_KEY);
|
||||||
|
const hasAnthropicKey = Boolean(env.ANTHROPIC_API_KEY);
|
||||||
|
const hasOpenAiKey = Boolean(env.OPENAI_API_KEY);
|
||||||
|
const hasGoogleKey = Boolean(
|
||||||
|
env.GOOGLE_API_KEY || env.GEMINI_API_KEY || env.GOOGLE_GENERATIVE_AI_API_KEY
|
||||||
|
);
|
||||||
|
log.info(
|
||||||
|
`» provider key presence: OPENROUTER=${hasOpenRouterKey ? "set" : "unset"}, ANTHROPIC=${hasAnthropicKey ? "set" : "unset"}, OPENAI=${hasOpenAiKey ? "set" : "unset"}, GOOGLE=${hasGoogleKey ? "set" : "unset"}`
|
||||||
|
);
|
||||||
|
|
||||||
// OpenCode doesn't support GitHub App installation tokens
|
// OpenCode doesn't support GitHub App installation tokens
|
||||||
delete env.GITHUB_TOKEN;
|
delete env.GITHUB_TOKEN;
|
||||||
|
|
||||||
@@ -194,7 +377,7 @@ export const opencode = agent({
|
|||||||
lastProviderError = providerError;
|
lastProviderError = providerError;
|
||||||
log.info(`» provider error detected (${providerError}): ${trimmed.substring(0, 500)}`);
|
log.info(`» provider error detected (${providerError}): ${trimmed.substring(0, 500)}`);
|
||||||
} else {
|
} else {
|
||||||
// OpenCode's --print-logs output goes to stderr. demote internal
|
//agent OpenCode's --print-logs output goes to stderr. demote internal
|
||||||
// INFO/DEBUG bus traffic to debug so it doesn't drown out tool
|
// INFO/DEBUG bus traffic to debug so it doesn't drown out tool
|
||||||
// call logs in the GitHub Actions step output.
|
// call logs in the GitHub Actions step output.
|
||||||
log.debug(trimmed);
|
log.debug(trimmed);
|
||||||
@@ -254,6 +437,15 @@ export const opencode = agent({
|
|||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
|
if (eventCount === 0 && lastProviderError) {
|
||||||
|
return {
|
||||||
|
success: false,
|
||||||
|
output: finalOutput || output,
|
||||||
|
error: `provider error: ${lastProviderError}`,
|
||||||
|
usage,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
return {
|
return {
|
||||||
success: true,
|
success: true,
|
||||||
output: finalOutput || output,
|
output: finalOutput || output,
|
||||||
@@ -301,28 +493,41 @@ function configureOpenCode(ctx: AgentRunContext): void {
|
|||||||
const configDir = join(ctx.tmpdir, ".config", "opencode");
|
const configDir = join(ctx.tmpdir, ".config", "opencode");
|
||||||
mkdirSync(configDir, { recursive: true });
|
mkdirSync(configDir, { recursive: true });
|
||||||
const configPath = join(configDir, "opencode.json");
|
const configPath = join(configDir, "opencode.json");
|
||||||
|
const repoConfigPath = join(process.cwd(), "opencode.json");
|
||||||
|
const repoConfig = loadRepoOpenCodeConfig({ repoConfigPath });
|
||||||
|
if (repoConfig?.model) {
|
||||||
|
log.info(`» repo opencode model configured: ${repoConfig.model}`);
|
||||||
|
}
|
||||||
|
|
||||||
// build MCP servers config
|
// build MCP servers config
|
||||||
const opencodeMcpServers = {
|
const opencodeMcpServers: Record<string, unknown> = {};
|
||||||
[ghPullfrogMcpName]: { type: "remote" as const, url: ctx.mcpServerUrl },
|
const repoMcpServers = getRecordProperty({ value: repoConfig, key: "mcp" });
|
||||||
};
|
if (repoMcpServers) {
|
||||||
|
Object.assign(opencodeMcpServers, repoMcpServers);
|
||||||
|
}
|
||||||
|
opencodeMcpServers[ghPullfrogMcpName] = { type: "remote" as const, url: ctx.mcpServerUrl };
|
||||||
|
|
||||||
// build permission object based on tool permissions
|
// build permission object based on tool permissions
|
||||||
// note: OpenCode has no built-in web search tool
|
// note: OpenCode has no built-in web search tool
|
||||||
const shell = ctx.payload.shell;
|
const shell = ctx.payload.shell;
|
||||||
const permission = {
|
const permission: Record<string, unknown> = {};
|
||||||
edit: "deny",
|
const repoPermission = getRecordProperty({ value: repoConfig, key: "permission" });
|
||||||
read: "deny",
|
if (repoPermission) {
|
||||||
bash: shell !== "enabled" ? "deny" : "allow",
|
Object.assign(permission, repoPermission);
|
||||||
webfetch: ctx.payload.web === "disabled" ? "deny" : "allow",
|
}
|
||||||
external_directory: "deny",
|
permission.edit = "deny";
|
||||||
};
|
permission.read = "deny";
|
||||||
|
permission.bash = shell !== "enabled" ? "deny" : "allow";
|
||||||
|
permission.webfetch = ctx.payload.web === "disabled" ? "deny" : "allow";
|
||||||
|
permission.external_directory = "deny";
|
||||||
|
|
||||||
// build complete config in one object
|
// build complete config in one object
|
||||||
const config = {
|
const config: OpenCodeConfig = {};
|
||||||
mcp: opencodeMcpServers,
|
if (repoConfig) {
|
||||||
permission,
|
Object.assign(config, repoConfig);
|
||||||
};
|
}
|
||||||
|
config.mcp = opencodeMcpServers;
|
||||||
|
config.permission = permission;
|
||||||
|
|
||||||
const configJson = JSON.stringify(config, null, 2);
|
const configJson = JSON.stringify(config, null, 2);
|
||||||
try {
|
try {
|
||||||
|
|||||||
@@ -4,9 +4,15 @@
|
|||||||
* entry point for pullfrog/pullfrog - unified action
|
* entry point for pullfrog/pullfrog - unified action
|
||||||
*/
|
*/
|
||||||
|
|
||||||
|
import { dirname } from "node:path";
|
||||||
import * as core from "@actions/core";
|
import * as core from "@actions/core";
|
||||||
import { main } from "./main.ts";
|
import { main } from "./main.ts";
|
||||||
|
|
||||||
|
// GitHub Actions runs the action entry point with the node24 binary specified
|
||||||
|
// in action.yml, but doesn't add that binary's directory to PATH. Without this,
|
||||||
|
// spawned processes (pnpm, npm, etc.) resolve to the runner's default node (v20).
|
||||||
|
process.env.PATH = `${dirname(process.execPath)}:${process.env.PATH}`;
|
||||||
|
|
||||||
async function run(): Promise<void> {
|
async function run(): Promise<void> {
|
||||||
try {
|
try {
|
||||||
const result = await main();
|
const result = await main();
|
||||||
|
|||||||
+3
-3
@@ -229,8 +229,8 @@ interface FixReviewEvent extends BasePayloadEvent {
|
|||||||
issue_number: number;
|
issue_number: number;
|
||||||
is_pr: true;
|
is_pr: true;
|
||||||
review_id: number;
|
review_id: number;
|
||||||
/** username of the person who triggered this action - use with get_review_comments approved_by */
|
/** when true, only address comments the triggerer approved with 👍 (vs all comments) */
|
||||||
triggerer: string;
|
approved_only?: boolean | undefined;
|
||||||
}
|
}
|
||||||
|
|
||||||
interface ImplementPlanEvent extends BasePayloadEvent {
|
interface ImplementPlanEvent extends BasePayloadEvent {
|
||||||
@@ -273,7 +273,7 @@ export interface WriteablePayload {
|
|||||||
/** the user's actual request (body if @pullfrog tagged) */
|
/** the user's actual request (body if @pullfrog tagged) */
|
||||||
prompt: string;
|
prompt: string;
|
||||||
/** github username of the human who triggered this workflow run */
|
/** github username of the human who triggered this workflow run */
|
||||||
triggeringUser?: string | undefined;
|
triggerer?: string | undefined;
|
||||||
/** event-level instructions for this trigger type (flag-expanded server-side) */
|
/** event-level instructions for this trigger type (flag-expanded server-side) */
|
||||||
eventInstructions?: string | undefined;
|
eventInstructions?: string | undefined;
|
||||||
/** repo-level instructions (flag-expanded server-side) */
|
/** repo-level instructions (flag-expanded server-side) */
|
||||||
|
|||||||
@@ -25507,6 +25507,7 @@ var core3 = __toESM(require_core(), 1);
|
|||||||
// utils/log.ts
|
// utils/log.ts
|
||||||
var core = __toESM(require_core(), 1);
|
var core = __toESM(require_core(), 1);
|
||||||
var import_table = __toESM(require_src(), 1);
|
var import_table = __toESM(require_src(), 1);
|
||||||
|
import { AsyncLocalStorage } from "node:async_hooks";
|
||||||
|
|
||||||
// utils/globals.ts
|
// utils/globals.ts
|
||||||
import { existsSync } from "node:fs";
|
import { existsSync } from "node:fs";
|
||||||
@@ -25515,6 +25516,20 @@ var isGitHubActions = !!process.env.GITHUB_ACTIONS;
|
|||||||
var isInsideDocker = existsSync("/.dockerenv");
|
var isInsideDocker = existsSync("/.dockerenv");
|
||||||
|
|
||||||
// utils/log.ts
|
// utils/log.ts
|
||||||
|
var logContext = new AsyncLocalStorage();
|
||||||
|
var MAGENTA = "\x1B[35m";
|
||||||
|
var RESET = "\x1B[0m";
|
||||||
|
function prefixLines(message) {
|
||||||
|
const ctx = logContext.getStore();
|
||||||
|
if (!ctx) return message;
|
||||||
|
const colored = `${MAGENTA}${ctx.prefix}${RESET} `;
|
||||||
|
return message.split("\n").map((line) => `${colored}${line}`).join("\n");
|
||||||
|
}
|
||||||
|
function prefixPlain(name) {
|
||||||
|
const ctx = logContext.getStore();
|
||||||
|
if (!ctx) return name;
|
||||||
|
return `${ctx.prefix} ${name}`;
|
||||||
|
}
|
||||||
var isRunnerDebugEnabled = () => core.isDebug();
|
var isRunnerDebugEnabled = () => core.isDebug();
|
||||||
var isLocalDebugEnabled = () => process.env.LOG_LEVEL === "debug" || process.env.ACTIONS_STEP_DEBUG === "true";
|
var isLocalDebugEnabled = () => process.env.LOG_LEVEL === "debug" || process.env.ACTIONS_STEP_DEBUG === "true";
|
||||||
var isDebugEnabled = () => isLocalDebugEnabled() || isRunnerDebugEnabled();
|
var isDebugEnabled = () => isLocalDebugEnabled() || isRunnerDebugEnabled();
|
||||||
@@ -25530,10 +25545,11 @@ ${arg.stack}`;
|
|||||||
}).join(" ");
|
}).join(" ");
|
||||||
}
|
}
|
||||||
function startGroup2(name) {
|
function startGroup2(name) {
|
||||||
|
const prefixed = prefixPlain(name);
|
||||||
if (isGitHubActions) {
|
if (isGitHubActions) {
|
||||||
core.startGroup(name);
|
core.startGroup(prefixed);
|
||||||
} else {
|
} else {
|
||||||
console.group(name);
|
console.group(prefixed);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
function endGroup2() {
|
function endGroup2() {
|
||||||
@@ -25606,7 +25622,7 @@ function boxString(text, options) {
|
|||||||
}
|
}
|
||||||
function box(text, options) {
|
function box(text, options) {
|
||||||
const boxContent = boxString(text, options);
|
const boxContent = boxString(text, options);
|
||||||
core.info(boxContent);
|
core.info(prefixLines(boxContent));
|
||||||
}
|
}
|
||||||
function printTable(rows, options) {
|
function printTable(rows, options) {
|
||||||
const { title } = options || {};
|
const { title } = options || {};
|
||||||
@@ -25620,42 +25636,42 @@ function printTable(rows, options) {
|
|||||||
);
|
);
|
||||||
const formatted = (0, import_table.table)(tableData);
|
const formatted = (0, import_table.table)(tableData);
|
||||||
if (title) {
|
if (title) {
|
||||||
core.info(`
|
core.info(prefixLines(`
|
||||||
${title}`);
|
${title}`));
|
||||||
}
|
}
|
||||||
core.info(`
|
core.info(prefixLines(`
|
||||||
${formatted}
|
${formatted}
|
||||||
`);
|
`));
|
||||||
}
|
}
|
||||||
function separator(length = 50) {
|
function separator(length = 50) {
|
||||||
const separatorText = "\u2500".repeat(length);
|
const separatorText = "\u2500".repeat(length);
|
||||||
core.info(separatorText);
|
core.info(prefixLines(separatorText));
|
||||||
}
|
}
|
||||||
var log = {
|
var log = {
|
||||||
/** Print info message */
|
/** Print info message */
|
||||||
info: (...args) => {
|
info: (...args) => {
|
||||||
core.info(`${ts()}${formatArgs(args)}`);
|
core.info(prefixLines(`${ts()}${formatArgs(args)}`));
|
||||||
},
|
},
|
||||||
/** Print a warning message. Use only for warnings that should be displayed in the job summary. */
|
/** Print a warning message. Use only for warnings that should be displayed in the job summary. */
|
||||||
warning: (...args) => {
|
warning: (...args) => {
|
||||||
core.warning(`${ts()}${formatArgs(args)}`);
|
core.warning(prefixLines(`${ts()}${formatArgs(args)}`));
|
||||||
},
|
},
|
||||||
/** Print an error message. Use only for errors that should be displayed in the job summary. */
|
/** Print an error message. Use only for errors that should be displayed in the job summary. */
|
||||||
error: (...args) => {
|
error: (...args) => {
|
||||||
core.error(`${ts()}${formatArgs(args)}`);
|
core.error(prefixLines(`${ts()}${formatArgs(args)}`));
|
||||||
},
|
},
|
||||||
/** Print success message */
|
/** Print success message */
|
||||||
success: (...args) => {
|
success: (...args) => {
|
||||||
core.info(`${ts()}\xBB ${formatArgs(args)}`);
|
core.info(prefixLines(`${ts()}\xBB ${formatArgs(args)}`));
|
||||||
},
|
},
|
||||||
/** Print debug message (only when debug mode is enabled) */
|
/** Print debug message (only when debug mode is enabled) */
|
||||||
debug: (...args) => {
|
debug: (...args) => {
|
||||||
if (isRunnerDebugEnabled()) {
|
if (isRunnerDebugEnabled()) {
|
||||||
core.debug(formatArgs(args));
|
core.debug(prefixLines(formatArgs(args)));
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
if (isLocalDebugEnabled()) {
|
if (isLocalDebugEnabled()) {
|
||||||
core.info(`${ts()}[DEBUG] ${formatArgs(args)}`);
|
core.info(prefixLines(`${ts()}[DEBUG] ${formatArgs(args)}`));
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
/** Print a formatted box with text */
|
/** Print a formatted box with text */
|
||||||
|
|||||||
@@ -235,8 +235,14 @@ export async function main(): Promise<MainResult> {
|
|||||||
log.info(`::pullfrog-output::${Buffer.from(toolState.output).toString("base64")}`);
|
log.info(`::pullfrog-output::${Buffer.from(toolState.output).toString("base64")}`);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const mainResult = await handleAgentResult({
|
||||||
|
result,
|
||||||
|
toolState,
|
||||||
|
silent: payload.event.silent ?? false,
|
||||||
|
});
|
||||||
|
|
||||||
return {
|
return {
|
||||||
...handleAgentResult(result),
|
...mainResult,
|
||||||
result: toolState.output,
|
result: toolState.output,
|
||||||
};
|
};
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
|
|||||||
@@ -1,11 +1,40 @@
|
|||||||
// Vitest Snapshot v1, https://vitest.dev/guide/snapshot.html
|
// Vitest Snapshot v1, https://vitest.dev/guide/snapshot.html
|
||||||
|
|
||||||
exports[`formatReviewThreads > formats thread blocks with TOC and correct line numbers > content 1`] = `
|
exports[`getFormattedReviewThreads > formats body-only review > content 1`] = `
|
||||||
"# Review Threads (1) for PR #49 - Review 3485940013 by cursor
|
"# Review Threads (0) for PR #64 - Review 3531000326 by pullfrog[bot]
|
||||||
|
|
||||||
|
## Review Body
|
||||||
|
|
||||||
|
This PR looks great. The retry logic is well-implemented and the tests are comprehensive.
|
||||||
|
|
||||||
|
---
|
||||||
|
"
|
||||||
|
`;
|
||||||
|
|
||||||
|
exports[`getFormattedReviewThreads > formats body-only review > toc 1`] = `""`;
|
||||||
|
|
||||||
|
exports[`getFormattedReviewThreads > formats thread blocks with TOC and correct line numbers > content 1`] = `
|
||||||
|
"# Review Threads (1) for PR #49 - Review 3485940013 by cursor[bot]
|
||||||
|
|
||||||
## TOC
|
## TOC
|
||||||
|
|
||||||
- .github/workflows/test.yml:7 → lines 9-36
|
- .github/workflows/test.yml:7 → lines 25-52
|
||||||
|
|
||||||
|
## Review Body
|
||||||
|
|
||||||
|
### This is the final PR Bugbot will review for you during this billing cycle
|
||||||
|
|
||||||
|
Your free Bugbot reviews will reset on November 30
|
||||||
|
|
||||||
|
<details>
|
||||||
|
<summary>Details</summary>
|
||||||
|
|
||||||
|
Your team is on the Bugbot Free tier. On this plan, Bugbot will review limited PRs each billing cycle for each member of your team.
|
||||||
|
|
||||||
|
To receive Bugbot reviews on all of your PRs, visit the [Cursor dashboard](https://www.cursor.com/dashboard?tab=bugbot) to activate Pro and start your 14-day free trial.
|
||||||
|
</details>
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
@@ -39,4 +68,4 @@ LOCATIONS END -->
|
|||||||
"
|
"
|
||||||
`;
|
`;
|
||||||
|
|
||||||
exports[`formatReviewThreads > formats thread blocks with TOC and correct line numbers > toc 1`] = `"- .github/workflows/test.yml:7 → lines 9-36"`;
|
exports[`getFormattedReviewThreads > formats thread blocks with TOC and correct line numbers > toc 1`] = `"- .github/workflows/test.yml:7 → lines 25-52"`;
|
||||||
|
|||||||
+4
-2
@@ -25,7 +25,9 @@ async function buildCommentFooter({
|
|||||||
customParts,
|
customParts,
|
||||||
}: BuildCommentFooterParams): Promise<string> {
|
}: BuildCommentFooterParams): Promise<string> {
|
||||||
const repoContext = parseRepoContext();
|
const repoContext = parseRepoContext();
|
||||||
const runId = process.env.GITHUB_RUN_ID;
|
const runId = process.env.GITHUB_RUN_ID
|
||||||
|
? Number.parseInt(process.env.GITHUB_RUN_ID, 10)
|
||||||
|
: undefined;
|
||||||
|
|
||||||
let jobId: string | undefined;
|
let jobId: string | undefined;
|
||||||
if (runId && octokit) {
|
if (runId && octokit) {
|
||||||
@@ -34,7 +36,7 @@ async function buildCommentFooter({
|
|||||||
const { data: jobs } = await octokit.rest.actions.listJobsForWorkflowRun({
|
const { data: jobs } = await octokit.rest.actions.listJobsForWorkflowRun({
|
||||||
owner: repoContext.owner,
|
owner: repoContext.owner,
|
||||||
repo: repoContext.name,
|
repo: repoContext.name,
|
||||||
run_id: parseInt(runId, 10),
|
run_id: runId,
|
||||||
});
|
});
|
||||||
// use the first job's ID available
|
// use the first job's ID available
|
||||||
jobId = jobs.jobs[0]?.id.toString();
|
jobId = jobs.jobs[0]?.id.toString();
|
||||||
|
|||||||
+9
-6
@@ -57,7 +57,7 @@ export function DelegateTool(ctx: ToolContext) {
|
|||||||
return tool({
|
return tool({
|
||||||
name: "delegate",
|
name: "delegate",
|
||||||
description:
|
description:
|
||||||
"Delegate research, local coding tasks, and codebase investigations to subagents. Accepts an array of tasks that run in parallel — use this to fan out work (e.g. reviewing different areas of a PR simultaneously). Each subagent receives ONLY the instructions you provide (plus a system preamble enforcing set_output). Use select_mode first to get guidance on how to craft instructions. Subagents have file operations, bash, read-only GitHub tools (PR/issue info, review comments, check suite logs), and upload_file. They have NO git/checkout tools (would conflict between parallel subagents), NO dependency tools, and NO GitHub-write tools (commenting, reviews, labels, issues). All state-mutating and user-facing operations are your responsibility as orchestrator.",
|
"Delegate research, local coding tasks, and codebase investigations to subagents. Accepts an array of tasks that run in parallel — use this to fan out work (e.g. reviewing different areas of a PR simultaneously). Each subagent receives ONLY the instructions you provide (plus a system preamble enforcing set_output). Use select_mode first to get guidance on how to craft instructions. Subagents have file operations, shell, read-only GitHub tools (PR/issue info, review comments, check suite logs), and upload_file. They have NO git/checkout tools (would conflict between parallel subagents), NO dependency tools, and NO GitHub-write tools (commenting, reviews, labels, issues). All state-mutating and user-facing operations are your responsibility as orchestrator.",
|
||||||
parameters: DelegateParams,
|
parameters: DelegateParams,
|
||||||
execute: execute(async (params) => {
|
execute: execute(async (params) => {
|
||||||
if (ctx.toolState.selfSubagentId) {
|
if (ctx.toolState.selfSubagentId) {
|
||||||
@@ -77,7 +77,10 @@ export function DelegateTool(ctx: ToolContext) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
// matched by delegate test validators — update tests if changed
|
// matched by delegate test validators — update tests if changed
|
||||||
log.info(`» delegating ${params.tasks.length} task(s) in parallel (mode=${mode})`);
|
const n = params.tasks.length;
|
||||||
|
log.info(
|
||||||
|
`» delegating ${n} task${n === 1 ? "" : "s"}${n > 1 ? " in parallel" : ""} (mode=${mode})`
|
||||||
|
);
|
||||||
|
|
||||||
const taskEntries = params.tasks.map((task) => {
|
const taskEntries = params.tasks.map((task) => {
|
||||||
const effort = task.effort ?? "auto";
|
const effort = task.effort ?? "auto";
|
||||||
@@ -100,10 +103,10 @@ export function DelegateTool(ctx: ToolContext) {
|
|||||||
const results: DelegateTaskResult[] = taskEntries.map((entry, i) => {
|
const results: DelegateTaskResult[] = taskEntries.map((entry, i) => {
|
||||||
const outcome = settled[i];
|
const outcome = settled[i];
|
||||||
const error = outcome.status === "rejected" ? String(outcome.reason) : outcome.value.error;
|
const error = outcome.status === "rejected" ? String(outcome.reason) : outcome.value.error;
|
||||||
log.debug(
|
const result = buildTaskResult(entry.task.label, entry.effort, entry.subagent, error);
|
||||||
`» task "${entry.task.label}" result: output=${entry.subagent.output !== undefined}, status=${entry.subagent.status}`
|
const status = result.success ? "succeeded" : "failed";
|
||||||
);
|
log.box(result.summary, { title: `task "${entry.task.label}" ${status}` });
|
||||||
return buildTaskResult(entry.task.label, entry.effort, entry.subagent, error);
|
return result;
|
||||||
});
|
});
|
||||||
|
|
||||||
const succeeded = results.filter((r) => r.success).length;
|
const succeeded = results.filter((r) => r.success).length;
|
||||||
|
|||||||
+25
-9
@@ -138,10 +138,26 @@ export function PushBranchTool(ctx: ToolContext) {
|
|||||||
if (force) {
|
if (force) {
|
||||||
log.warning(`force pushing - this will overwrite remote history`);
|
log.warning(`force pushing - this will overwrite remote history`);
|
||||||
}
|
}
|
||||||
$git("push", pushArgs, {
|
|
||||||
token: ctx.gitToken,
|
try {
|
||||||
restricted: ctx.payload.shell !== "enabled",
|
$git("push", pushArgs, {
|
||||||
});
|
token: ctx.gitToken,
|
||||||
|
restricted: ctx.payload.shell !== "enabled",
|
||||||
|
});
|
||||||
|
} catch (err) {
|
||||||
|
const msg = err instanceof Error ? err.message : String(err);
|
||||||
|
if (msg.includes("fetch first") || msg.includes("non-fast-forward")) {
|
||||||
|
throw new Error(
|
||||||
|
`push rejected: the remote branch '${pushDest.remoteBranch}' has new commits you don't have locally.\n\n` +
|
||||||
|
`to resolve this:\n` +
|
||||||
|
`1. use git_fetch to fetch the remote branch: git_fetch({ ref: "${pushDest.remoteBranch}" })\n` +
|
||||||
|
`2. use the git tool to rebase your changes: git({ subcommand: "rebase", args: ["origin/${pushDest.remoteBranch}"] })\n` +
|
||||||
|
`3. resolve any merge conflicts if needed\n` +
|
||||||
|
`4. retry push_branch`
|
||||||
|
);
|
||||||
|
}
|
||||||
|
throw err;
|
||||||
|
}
|
||||||
|
|
||||||
return {
|
return {
|
||||||
success: true,
|
success: true,
|
||||||
@@ -157,10 +173,10 @@ export function PushBranchTool(ctx: ToolContext) {
|
|||||||
|
|
||||||
// commands that require authentication - redirect to dedicated tools
|
// commands that require authentication - redirect to dedicated tools
|
||||||
const AUTH_REQUIRED_REDIRECT: Record<string, string> = {
|
const AUTH_REQUIRED_REDIRECT: Record<string, string> = {
|
||||||
push: "Use push_branch tool instead.",
|
push: "use the push_branch tool instead — it handles authentication and permission checks.",
|
||||||
fetch: "Use git_fetch tool instead.",
|
fetch: "use the git_fetch tool instead — it handles authentication.",
|
||||||
pull: "Use git_fetch + git merge instead.",
|
pull: "use git_fetch to fetch the remote ref, then use this git tool with subcommand 'merge' or 'rebase' locally.",
|
||||||
clone: "Repository already cloned. Use checkout_pr for PR branches.",
|
clone: "the repository is already cloned. use checkout_pr for PR branches.",
|
||||||
};
|
};
|
||||||
|
|
||||||
// SECURITY: subcommands blocked when shell is disabled.
|
// SECURITY: subcommands blocked when shell is disabled.
|
||||||
@@ -219,7 +235,7 @@ export function GitTool(ctx: ToolContext) {
|
|||||||
|
|
||||||
const redirect = AUTH_REQUIRED_REDIRECT[subcommand];
|
const redirect = AUTH_REQUIRED_REDIRECT[subcommand];
|
||||||
if (redirect) {
|
if (redirect) {
|
||||||
throw new Error(`git ${subcommand} requires authentication. ${redirect}`);
|
throw new Error(`git ${subcommand} is not available through this tool — ${redirect}`);
|
||||||
}
|
}
|
||||||
|
|
||||||
// SECURITY: block dangerous subcommands when shell is disabled.
|
// SECURITY: block dangerous subcommands when shell is disabled.
|
||||||
|
|||||||
@@ -9,6 +9,9 @@ export const PullRequest = type({
|
|||||||
title: type.string.describe("the title of the pull request"),
|
title: type.string.describe("the title of the pull request"),
|
||||||
body: type.string.describe("the body content of the pull request"),
|
body: type.string.describe("the body content of the pull request"),
|
||||||
base: type.string.describe("the base branch to merge into (e.g., 'main')"),
|
base: type.string.describe("the base branch to merge into (e.g., 'main')"),
|
||||||
|
"draft?": type.boolean.describe(
|
||||||
|
"if true, create the pull request as a draft. use when the user explicitly asks for a draft PR."
|
||||||
|
),
|
||||||
});
|
});
|
||||||
|
|
||||||
function buildPrBodyWithFooter(ctx: ToolContext, body: string): string {
|
function buildPrBodyWithFooter(ctx: ToolContext, body: string): string {
|
||||||
@@ -71,10 +74,11 @@ export function CreatePullRequestTool(ctx: ToolContext) {
|
|||||||
body: bodyWithFooter,
|
body: bodyWithFooter,
|
||||||
head: currentBranch,
|
head: currentBranch,
|
||||||
base: params.base,
|
base: params.base,
|
||||||
|
draft: params.draft ?? false,
|
||||||
});
|
});
|
||||||
|
|
||||||
// best-effort: request review from the user who triggered the workflow
|
// best-effort: request review from the user who triggered the workflow
|
||||||
const reviewer = ctx.payload.triggeringUser;
|
const reviewer = ctx.payload.triggerer;
|
||||||
if (reviewer) {
|
if (reviewer) {
|
||||||
try {
|
try {
|
||||||
log.debug(`requesting review from ${reviewer} on PR #${result.data.number}`);
|
log.debug(`requesting review from ${reviewer} on PR #${result.data.number}`);
|
||||||
|
|||||||
+33
-15
@@ -15,11 +15,18 @@ export const CreatePullRequestReview = type({
|
|||||||
"1-2 sentence high-level summary with urgency level, critical callouts, and feedback about code outside the diff. Specific feedback on diff lines goes in 'comments' array."
|
"1-2 sentence high-level summary with urgency level, critical callouts, and feedback about code outside the diff. Specific feedback on diff lines goes in 'comments' array."
|
||||||
)
|
)
|
||||||
.optional(),
|
.optional(),
|
||||||
|
approved: type.boolean
|
||||||
|
.describe(
|
||||||
|
"Set to true to submit as an approval. ONLY when the review contains no actionable feedback — neither inline comments nor actionable content in the body. Defaults to false (comment-only review). Rejections are not supported."
|
||||||
|
)
|
||||||
|
.optional(),
|
||||||
commit_id: type.string
|
commit_id: type.string
|
||||||
.describe("Optional SHA of the commit being reviewed. Defaults to latest.")
|
.describe("Optional SHA of the commit being reviewed. Defaults to latest.")
|
||||||
.optional(),
|
.optional(),
|
||||||
comments: type({
|
comments: type({
|
||||||
path: type.string.describe("The file path to comment on (relative to repo root)"),
|
path: type.string.describe(
|
||||||
|
"The file path to comment on (relative to repo root). Must be a file that appears in the PR diff."
|
||||||
|
),
|
||||||
line: type.number.describe(
|
line: type.number.describe(
|
||||||
"End line of the comment range. For single-line comments, set equal to 'start_line'. Use NEW column from diff format."
|
"End line of the comment range. For single-line comments, set equal to 'start_line'. Use NEW column from diff format."
|
||||||
),
|
),
|
||||||
@@ -57,9 +64,12 @@ export function CreatePullRequestReviewTool(ctx: ToolContext) {
|
|||||||
"Only use 'body' for a 1-2 sentence summary with urgency and critical callouts. " +
|
"Only use 'body' for a 1-2 sentence summary with urgency and critical callouts. " +
|
||||||
"Use 'suggestion' to propose replacement code - MUST preserve exact indentation of original code. " +
|
"Use 'suggestion' to propose replacement code - MUST preserve exact indentation of original code. " +
|
||||||
"Example replacing lines 42-44 (3 lines) with 5 lines: " +
|
"Example replacing lines 42-44 (3 lines) with 5 lines: " +
|
||||||
`{ path: 'src/api.ts', start_line: 42, line: 44, suggestion: ' const result = await fetch(url);\\n if (!result.ok) {\\n log.error(result.status);\\n throw new Error("request failed");\\n }' }`,
|
`{ path: 'src/api.ts', start_line: 42, line: 44, suggestion: ' const result = await fetch(url);\\n if (!result.ok) {\\n log.error(result.status);\\n throw new Error("request failed");\\n }' }` +
|
||||||
|
" CONSTRAINT: Inline comments can ONLY target files and lines that appear in the PR diff." +
|
||||||
|
" Commenting on files or lines outside the diff will cause GitHub API errors." +
|
||||||
|
" Put feedback about code outside the diff in 'body' instead.",
|
||||||
parameters: CreatePullRequestReview,
|
parameters: CreatePullRequestReview,
|
||||||
execute: execute(async ({ pull_number, body, commit_id, comments = [] }) => {
|
execute: execute(async ({ pull_number, body, approved, commit_id, comments = [] }) => {
|
||||||
// set issue context (PRs are issues)
|
// set issue context (PRs are issues)
|
||||||
ctx.toolState.issueNumber = pull_number;
|
ctx.toolState.issueNumber = pull_number;
|
||||||
|
|
||||||
@@ -68,7 +78,7 @@ export function CreatePullRequestReviewTool(ctx: ToolContext) {
|
|||||||
owner: ctx.repo.owner,
|
owner: ctx.repo.owner,
|
||||||
repo: ctx.repo.name,
|
repo: ctx.repo.name,
|
||||||
pull_number,
|
pull_number,
|
||||||
event: "COMMENT",
|
event: approved ? "APPROVE" : "COMMENT",
|
||||||
};
|
};
|
||||||
if (body) params.body = body;
|
if (body) params.body = body;
|
||||||
if (commit_id) {
|
if (commit_id) {
|
||||||
@@ -115,20 +125,28 @@ export function CreatePullRequestReviewTool(ctx: ToolContext) {
|
|||||||
// build quick links footer and update the review body
|
// build quick links footer and update the review body
|
||||||
// only include "Fix all" and "Fix 👍s" links if there are actual review comments
|
// only include "Fix all" and "Fix 👍s" links if there are actual review comments
|
||||||
const customParts: string[] = [];
|
const customParts: string[] = [];
|
||||||
if (comments.length > 0) {
|
if (!approved) {
|
||||||
const apiUrl = getApiUrl();
|
if (comments.length > 0) {
|
||||||
const fixAllUrl = `${apiUrl}/trigger/${ctx.repo.owner}/${ctx.repo.name}/${pull_number}?action=fix&review_id=${reviewId}`;
|
const apiUrl = getApiUrl();
|
||||||
const fixApprovedUrl = `${apiUrl}/trigger/${ctx.repo.owner}/${ctx.repo.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}`;
|
||||||
customParts.push(`[Fix all ➔](${fixAllUrl})`, `[Fix 👍s ➔](${fixApprovedUrl})`);
|
const fixApprovedUrl = `${apiUrl}/trigger/${ctx.repo.owner}/${ctx.repo.name}/${pull_number}?action=fix-approved&review_id=${reviewId}`;
|
||||||
|
customParts.push(`[Fix all ➔](${fixAllUrl})`, `[Fix 👍s ➔](${fixApprovedUrl})`);
|
||||||
|
} else if (body) {
|
||||||
|
const apiUrl = getApiUrl();
|
||||||
|
const fixUrl = `${apiUrl}/trigger/${ctx.repo.owner}/${ctx.repo.name}/${pull_number}?action=fix&review_id=${reviewId}`;
|
||||||
|
customParts.push(`[Fix it ➔](${fixUrl})`);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
const footer = buildPullfrogFooter({
|
const footer = buildPullfrogFooter({
|
||||||
workflowRun: {
|
workflowRun: ctx.runId
|
||||||
owner: ctx.repo.owner,
|
? {
|
||||||
repo: ctx.repo.name,
|
owner: ctx.repo.owner,
|
||||||
runId: ctx.runId,
|
repo: ctx.repo.name,
|
||||||
jobId: ctx.jobId,
|
runId: ctx.runId,
|
||||||
},
|
jobId: ctx.jobId,
|
||||||
|
}
|
||||||
|
: undefined,
|
||||||
customParts,
|
customParts,
|
||||||
});
|
});
|
||||||
|
|
||||||
|
|||||||
+22
-39
@@ -1,60 +1,43 @@
|
|||||||
import { Octokit } from "@octokit/rest";
|
import { Octokit } from "@octokit/rest";
|
||||||
import { describe, expect, it } from "vitest";
|
import { describe, expect, it } from "vitest";
|
||||||
import { acquireNewToken } from "../utils/github.ts";
|
import { acquireNewToken } from "../utils/github.ts";
|
||||||
import {
|
import { getReviewData } from "./reviewComments.ts";
|
||||||
buildThreadBlocks,
|
|
||||||
formatReviewThreads,
|
|
||||||
type ParsedHunk,
|
|
||||||
parseFilePatches,
|
|
||||||
REVIEW_THREADS_QUERY,
|
|
||||||
type ReviewThread,
|
|
||||||
type ReviewThreadsQueryResponse,
|
|
||||||
} from "./reviewComments.ts";
|
|
||||||
|
|
||||||
async function getToken(): Promise<string> {
|
async function getToken(): Promise<string> {
|
||||||
// prefer explicit GH_TOKEN, fall back to acquiring one via GitHub App credentials
|
|
||||||
if (process.env.GH_TOKEN) return process.env.GH_TOKEN;
|
if (process.env.GH_TOKEN) return process.env.GH_TOKEN;
|
||||||
return await acquireNewToken();
|
return await acquireNewToken();
|
||||||
}
|
}
|
||||||
|
|
||||||
describe("formatReviewThreads", () => {
|
describe("getFormattedReviewThreads", () => {
|
||||||
it("formats thread blocks with TOC and correct line numbers", { timeout: 30000 }, async () => {
|
it("formats thread blocks with TOC and correct line numbers", { timeout: 30000 }, async () => {
|
||||||
const token = await getToken();
|
const token = await getToken();
|
||||||
const octokit = new Octokit({ auth: token });
|
const octokit = new Octokit({ auth: token });
|
||||||
const pullNumber = 49;
|
|
||||||
const reviewId = 3485940013;
|
|
||||||
|
|
||||||
// fetch review threads via GraphQL
|
const { formatted } = (await getReviewData({
|
||||||
const response = await octokit.graphql<ReviewThreadsQueryResponse>(REVIEW_THREADS_QUERY, {
|
octokit,
|
||||||
owner: "pullfrog",
|
owner: "pullfrog",
|
||||||
name: "scratch",
|
name: "scratch",
|
||||||
prNumber: pullNumber,
|
pullNumber: 49,
|
||||||
});
|
reviewId: 3485940013,
|
||||||
|
}))!;
|
||||||
|
|
||||||
const allThreads = response.repository?.pullRequest?.reviewThreads?.nodes ?? [];
|
expect(formatted.toc).toMatchSnapshot("toc");
|
||||||
const threadsForReview = allThreads.filter((thread): thread is ReviewThread => {
|
expect(formatted.content).toMatchSnapshot("content");
|
||||||
if (!thread?.comments?.nodes) return false;
|
});
|
||||||
return thread.comments.nodes.some((c) => c?.pullRequestReview?.databaseId === reviewId);
|
|
||||||
});
|
|
||||||
|
|
||||||
// fetch file patches
|
it("formats body-only review", { timeout: 30000 }, async () => {
|
||||||
const prFilesResponse = await octokit.rest.pulls.listFiles({
|
const token = await getToken();
|
||||||
|
const octokit = new Octokit({ auth: token });
|
||||||
|
|
||||||
|
const { formatted } = (await getReviewData({
|
||||||
|
octokit,
|
||||||
owner: "pullfrog",
|
owner: "pullfrog",
|
||||||
repo: "scratch",
|
name: "scratch",
|
||||||
pull_number: pullNumber,
|
pullNumber: 64,
|
||||||
});
|
reviewId: 3531000326,
|
||||||
const filePatchMap = new Map<string, ParsedHunk[]>();
|
}))!;
|
||||||
for (const file of prFilesResponse.data) {
|
|
||||||
if (file.patch) {
|
|
||||||
filePatchMap.set(file.filename, parseFilePatches(file.patch));
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// build and format
|
expect(formatted.toc).toMatchSnapshot("toc");
|
||||||
const { threadBlocks, reviewer } = buildThreadBlocks(threadsForReview, filePatchMap, reviewId);
|
expect(formatted.content).toMatchSnapshot("content");
|
||||||
const result = formatReviewThreads(threadBlocks, { pullNumber, reviewId, reviewer });
|
|
||||||
|
|
||||||
expect(result.toc).toMatchSnapshot("toc");
|
|
||||||
expect(result.content).toMatchSnapshot("content");
|
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|||||||
+129
-71
@@ -1,6 +1,8 @@
|
|||||||
import { writeFileSync } from "node:fs";
|
import { writeFileSync } from "node:fs";
|
||||||
import { join } from "node:path";
|
import { join } from "node:path";
|
||||||
|
import type { Octokit } from "@octokit/rest";
|
||||||
import { type } from "arktype";
|
import { type } from "arktype";
|
||||||
|
import { stripExistingFooter } from "../utils/buildPullfrogFooter.ts";
|
||||||
import { log } from "../utils/log.ts";
|
import { log } from "../utils/log.ts";
|
||||||
import type { ToolContext } from "./server.ts";
|
import type { ToolContext } from "./server.ts";
|
||||||
import { execute, tool } from "./shared.ts";
|
import { execute, tool } from "./shared.ts";
|
||||||
@@ -94,6 +96,16 @@ export type ReviewThreadsQueryResponse = {
|
|||||||
} | null;
|
} | null;
|
||||||
};
|
};
|
||||||
|
|
||||||
|
export function countLines(str: string): number {
|
||||||
|
let count = 1;
|
||||||
|
let index = -1;
|
||||||
|
// biome-ignore lint/suspicious/noAssignInExpressions: assignment in while condition is intentional for indexOf loop pattern
|
||||||
|
while ((index = str.indexOf("\n", index + 1)) !== -1) {
|
||||||
|
count++;
|
||||||
|
}
|
||||||
|
return count;
|
||||||
|
}
|
||||||
|
|
||||||
// extract exactly the commented line range from diffHunk, plus context
|
// extract exactly the commented line range from diffHunk, plus context
|
||||||
const CONTEXT_PADDING = 3;
|
const CONTEXT_PADDING = 3;
|
||||||
|
|
||||||
@@ -279,19 +291,14 @@ function extractFromFilePatches(
|
|||||||
export const GetReviewComments = type({
|
export const GetReviewComments = type({
|
||||||
pull_number: type.number.describe("The pull request number"),
|
pull_number: type.number.describe("The pull request number"),
|
||||||
review_id: type.number.describe("The review ID to get comments for"),
|
review_id: type.number.describe("The review ID to get comments for"),
|
||||||
approved_by: type.string
|
|
||||||
.describe(
|
|
||||||
"Optional GitHub username - only return threads where this user gave a 👍 to at least one comment"
|
|
||||||
)
|
|
||||||
.optional(),
|
|
||||||
});
|
});
|
||||||
|
|
||||||
function hasThumbsUpFrom(comment: ReviewThreadComment, username: string): boolean {
|
function hasThumbsUpFrom(comment: ReviewThreadComment, username: string): boolean {
|
||||||
if (!comment.reactionGroups) return false;
|
if (!comment.reactionGroups) return false;
|
||||||
const thumbsUp = comment.reactionGroups.find((g) => g.content === "THUMBS_UP");
|
const thumbsUp = comment.reactionGroups.find((g) => g.content === "THUMBS_UP");
|
||||||
if (!thumbsUp?.reactors?.nodes) return false;
|
if (!thumbsUp?.reactors?.nodes) return false;
|
||||||
const usernameNeedle = username.toLowerCase();
|
const needle = username.toLowerCase();
|
||||||
return thumbsUp.reactors.nodes.some((r) => r?.login?.toLowerCase() === usernameNeedle);
|
return thumbsUp.reactors.nodes.some((r) => r?.login?.toLowerCase() === needle);
|
||||||
}
|
}
|
||||||
|
|
||||||
function threadHasThumbsUpFrom(thread: ReviewThread, username: string): boolean {
|
function threadHasThumbsUpFrom(thread: ReviewThread, username: string): boolean {
|
||||||
@@ -305,19 +312,26 @@ function threadHasThumbsUpFrom(thread: ReviewThread, username: string): boolean
|
|||||||
*/
|
*/
|
||||||
export function formatReviewThreads(
|
export function formatReviewThreads(
|
||||||
threadBlocks: Array<{ path: string; lineRange: string; content: string[] }>,
|
threadBlocks: Array<{ path: string; lineRange: string; content: string[] }>,
|
||||||
header: { pullNumber: number; reviewId: number; reviewer: string }
|
header: { pullNumber: number; reviewId: number; reviewer: string; reviewBody?: string }
|
||||||
) {
|
) {
|
||||||
// header section takes: title (1) + blank (1) + "## TOC" (1) + blank (1) + N TOC entries + blank (1) + "---" (1) + blank (1)
|
// header section takes: title (1) + blank (1) + "## TOC" (1) + blank (1) + N TOC entries + blank (1) + "---" (1) + blank (1)
|
||||||
const tocHeaderLines = 4;
|
const tocHeaderLines = 4;
|
||||||
const tocFooterLines = 3;
|
const tocFooterLines = 3;
|
||||||
let currentLine = tocHeaderLines + threadBlocks.length + tocFooterLines + 1;
|
let currentLine = tocHeaderLines + threadBlocks.length + tocFooterLines + 1;
|
||||||
|
|
||||||
|
// account for review body section if present
|
||||||
|
const reviewBodyLines: string[] = [];
|
||||||
|
if (header.reviewBody) {
|
||||||
|
reviewBodyLines.push("## Review Body", "", header.reviewBody, "");
|
||||||
|
currentLine += reviewBodyLines.reduce((sum, line) => sum + countLines(line), 0);
|
||||||
|
}
|
||||||
|
|
||||||
const tocEntries: string[] = [];
|
const tocEntries: string[] = [];
|
||||||
const threadLines: string[] = [];
|
const threadLines: string[] = [];
|
||||||
|
|
||||||
for (const block of threadBlocks) {
|
for (const block of threadBlocks) {
|
||||||
const startLine = currentLine;
|
const startLine = currentLine;
|
||||||
const actualLineCount = block.content.reduce((sum, line) => sum + line.split("\n").length, 0);
|
const actualLineCount = block.content.reduce((sum, line) => sum + countLines(line), 0);
|
||||||
const endLine = currentLine + actualLineCount - 1;
|
const endLine = currentLine + actualLineCount - 1;
|
||||||
tocEntries.push(`- ${block.path}:${block.lineRange} → lines ${startLine}-${endLine}`);
|
tocEntries.push(`- ${block.path}:${block.lineRange} → lines ${startLine}-${endLine}`);
|
||||||
threadLines.push(...block.content);
|
threadLines.push(...block.content);
|
||||||
@@ -329,10 +343,13 @@ export function formatReviewThreads(
|
|||||||
`# Review Threads (${threadBlocks.length}) for PR #${header.pullNumber} - Review ${header.reviewId} by ${header.reviewer}`
|
`# Review Threads (${threadBlocks.length}) for PR #${header.pullNumber} - Review ${header.reviewId} by ${header.reviewer}`
|
||||||
);
|
);
|
||||||
lines.push("");
|
lines.push("");
|
||||||
lines.push("## TOC");
|
if (threadBlocks.length > 0) {
|
||||||
lines.push("");
|
lines.push("## TOC");
|
||||||
lines.push(...tocEntries);
|
lines.push("");
|
||||||
lines.push("");
|
lines.push(...tocEntries);
|
||||||
|
lines.push("");
|
||||||
|
}
|
||||||
|
lines.push(...reviewBodyLines);
|
||||||
lines.push("---");
|
lines.push("---");
|
||||||
lines.push("");
|
lines.push("");
|
||||||
lines.push(...threadLines);
|
lines.push(...threadLines);
|
||||||
@@ -352,12 +369,6 @@ export function buildThreadBlocks(
|
|||||||
filePatchMap: Map<string, ParsedHunk[]>,
|
filePatchMap: Map<string, ParsedHunk[]>,
|
||||||
reviewId: number
|
reviewId: number
|
||||||
) {
|
) {
|
||||||
// get reviewer from first matching comment
|
|
||||||
const firstMatchingComment = threads[0]?.comments?.nodes?.find(
|
|
||||||
(c) => c?.pullRequestReview?.databaseId === reviewId
|
|
||||||
);
|
|
||||||
const reviewer = firstMatchingComment?.pullRequestReview?.author?.login ?? "unknown";
|
|
||||||
|
|
||||||
// sort threads by file path, then by line number
|
// sort threads by file path, then by line number
|
||||||
threads.sort((a, b) => {
|
threads.sort((a, b) => {
|
||||||
const pathCmp = a.path.localeCompare(b.path);
|
const pathCmp = a.path.localeCompare(b.path);
|
||||||
@@ -439,7 +450,89 @@ export function buildThreadBlocks(
|
|||||||
threadBlocks.push({ path: thread.path, lineRange, content: block });
|
threadBlocks.push({ path: thread.path, lineRange, content: block });
|
||||||
}
|
}
|
||||||
|
|
||||||
return { threadBlocks, reviewer };
|
return threadBlocks;
|
||||||
|
}
|
||||||
|
|
||||||
|
async function getReviewThreads(input: GetReviewDataInput) {
|
||||||
|
const response = await input.octokit.graphql<ReviewThreadsQueryResponse>(REVIEW_THREADS_QUERY, {
|
||||||
|
owner: input.owner,
|
||||||
|
name: input.name,
|
||||||
|
prNumber: input.pullNumber,
|
||||||
|
});
|
||||||
|
|
||||||
|
const allThreads = response.repository?.pullRequest?.reviewThreads?.nodes ?? [];
|
||||||
|
|
||||||
|
const threadsForReview = allThreads.filter((thread): thread is ReviewThread => {
|
||||||
|
if (!thread?.comments?.nodes) return false;
|
||||||
|
return thread.comments.nodes.some((c) => c?.pullRequestReview?.databaseId === input.reviewId);
|
||||||
|
});
|
||||||
|
|
||||||
|
if (!input.approvedBy) {
|
||||||
|
return threadsForReview;
|
||||||
|
}
|
||||||
|
|
||||||
|
const username = input.approvedBy;
|
||||||
|
return threadsForReview.filter((thread) => threadHasThumbsUpFrom(thread, username));
|
||||||
|
}
|
||||||
|
|
||||||
|
interface GetReviewDataInput {
|
||||||
|
octokit: Octokit;
|
||||||
|
owner: string;
|
||||||
|
name: string;
|
||||||
|
pullNumber: number;
|
||||||
|
reviewId: number;
|
||||||
|
approvedBy?: string | undefined;
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function getReviewData(input: GetReviewDataInput): Promise<
|
||||||
|
| {
|
||||||
|
threadBlocks: Array<{ path: string; lineRange: string; content: string[] }>;
|
||||||
|
reviewer: string;
|
||||||
|
formatted: { toc: string; content: string };
|
||||||
|
}
|
||||||
|
| undefined
|
||||||
|
> {
|
||||||
|
const [review, threads] = await Promise.all([
|
||||||
|
input.octokit.rest.pulls.getReview({
|
||||||
|
owner: input.owner,
|
||||||
|
repo: input.name,
|
||||||
|
pull_number: input.pullNumber,
|
||||||
|
review_id: input.reviewId,
|
||||||
|
}),
|
||||||
|
getReviewThreads(input),
|
||||||
|
]);
|
||||||
|
|
||||||
|
const rawReviewBody = review.data.body;
|
||||||
|
const reviewBody = rawReviewBody ? stripExistingFooter(rawReviewBody) : "";
|
||||||
|
const reviewer = review.data.user?.login ?? "unknown";
|
||||||
|
|
||||||
|
if (threads.length === 0 && !reviewBody) return undefined;
|
||||||
|
|
||||||
|
let threadBlocks: Array<{ path: string; lineRange: string; content: string[] }> = [];
|
||||||
|
|
||||||
|
if (threads.length > 0) {
|
||||||
|
const prFilesResponse = await input.octokit.rest.pulls.listFiles({
|
||||||
|
owner: input.owner,
|
||||||
|
repo: input.name,
|
||||||
|
pull_number: input.pullNumber,
|
||||||
|
});
|
||||||
|
const filePatchMap = new Map<string, ParsedHunk[]>();
|
||||||
|
for (const file of prFilesResponse.data) {
|
||||||
|
if (file.patch) {
|
||||||
|
filePatchMap.set(file.filename, parseFilePatches(file.patch));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
threadBlocks = buildThreadBlocks(threads, filePatchMap, input.reviewId);
|
||||||
|
}
|
||||||
|
|
||||||
|
const formatted = formatReviewThreads(threadBlocks, {
|
||||||
|
pullNumber: input.pullNumber,
|
||||||
|
reviewId: input.reviewId,
|
||||||
|
reviewer,
|
||||||
|
reviewBody,
|
||||||
|
});
|
||||||
|
|
||||||
|
return { threadBlocks, reviewer, formatted };
|
||||||
}
|
}
|
||||||
|
|
||||||
export function GetReviewCommentsTool(ctx: ToolContext) {
|
export function GetReviewCommentsTool(ctx: ToolContext) {
|
||||||
@@ -447,35 +540,26 @@ export function GetReviewCommentsTool(ctx: ToolContext) {
|
|||||||
name: "get_review_comments",
|
name: "get_review_comments",
|
||||||
description:
|
description:
|
||||||
"Get review comments for a pull request review with full thread context. " +
|
"Get review comments for a pull request review with full thread context. " +
|
||||||
"When approved_by is provided, only returns threads where that user gave a 👍 to at least one comment. " +
|
"Automatically filters to approved comments when applicable. " +
|
||||||
"Returns a TOC and commentsPath pointing to a markdown file with full comment details.",
|
"Returns a TOC and commentsPath pointing to a markdown file with full comment details.",
|
||||||
parameters: GetReviewComments,
|
parameters: GetReviewComments,
|
||||||
execute: execute(async (params) => {
|
execute: execute(async (params) => {
|
||||||
// fetch all review threads for the PR via GraphQL
|
// auto-filter to approved comments when the event has approved_only set
|
||||||
const response = await ctx.octokit.graphql<ReviewThreadsQueryResponse>(REVIEW_THREADS_QUERY, {
|
const approvedBy =
|
||||||
|
ctx.payload.event.trigger === "fix_review" && ctx.payload.event.approved_only
|
||||||
|
? ctx.payload.triggerer
|
||||||
|
: undefined;
|
||||||
|
|
||||||
|
const result = await getReviewData({
|
||||||
|
octokit: ctx.octokit,
|
||||||
owner: ctx.repo.owner,
|
owner: ctx.repo.owner,
|
||||||
name: ctx.repo.name,
|
name: ctx.repo.name,
|
||||||
prNumber: params.pull_number,
|
pullNumber: params.pull_number,
|
||||||
|
reviewId: params.review_id,
|
||||||
|
approvedBy,
|
||||||
});
|
});
|
||||||
|
|
||||||
const allThreads = response.repository?.pullRequest?.reviewThreads?.nodes ?? [];
|
if (!result) {
|
||||||
|
|
||||||
// filter to threads where at least one comment belongs to the target review
|
|
||||||
let threadsForReview = allThreads.filter((thread): thread is ReviewThread => {
|
|
||||||
if (!thread?.comments?.nodes) return false;
|
|
||||||
return thread.comments.nodes.some(
|
|
||||||
(c) => c?.pullRequestReview?.databaseId === params.review_id
|
|
||||||
);
|
|
||||||
});
|
|
||||||
|
|
||||||
// filter by approved_by if specified
|
|
||||||
if (params.approved_by) {
|
|
||||||
threadsForReview = threadsForReview.filter((thread) =>
|
|
||||||
threadHasThumbsUpFrom(thread, params.approved_by as string)
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
if (threadsForReview.length === 0) {
|
|
||||||
return {
|
return {
|
||||||
review_id: params.review_id,
|
review_id: params.review_id,
|
||||||
pull_number: params.pull_number,
|
pull_number: params.pull_number,
|
||||||
@@ -483,40 +567,14 @@ export function GetReviewCommentsTool(ctx: ToolContext) {
|
|||||||
threadCount: 0,
|
threadCount: 0,
|
||||||
commentsPath: null,
|
commentsPath: null,
|
||||||
toc: null,
|
toc: null,
|
||||||
instructions: params.approved_by
|
instructions: approvedBy
|
||||||
? `no threads with 👍 from ${params.approved_by}`
|
? `no threads with 👍 from ${approvedBy}`
|
||||||
: "no threads found for this review",
|
: "no threads found for this review",
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
// fetch full file patches for better multi-hunk context
|
const { threadBlocks, reviewer, formatted } = result;
|
||||||
const prFilesResponse = await ctx.octokit.rest.pulls.listFiles({
|
|
||||||
owner: ctx.repo.owner,
|
|
||||||
repo: ctx.repo.name,
|
|
||||||
pull_number: params.pull_number,
|
|
||||||
});
|
|
||||||
const filePatchMap = new Map<string, ParsedHunk[]>();
|
|
||||||
for (const file of prFilesResponse.data) {
|
|
||||||
if (file.patch) {
|
|
||||||
filePatchMap.set(file.filename, parseFilePatches(file.patch));
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// build thread blocks
|
|
||||||
const { threadBlocks, reviewer } = buildThreadBlocks(
|
|
||||||
threadsForReview,
|
|
||||||
filePatchMap,
|
|
||||||
params.review_id
|
|
||||||
);
|
|
||||||
|
|
||||||
// format thread blocks into markdown with TOC
|
|
||||||
const formatted = formatReviewThreads(threadBlocks, {
|
|
||||||
pullNumber: params.pull_number,
|
|
||||||
reviewId: params.review_id,
|
|
||||||
reviewer,
|
|
||||||
});
|
|
||||||
|
|
||||||
// write to temp file
|
|
||||||
const tempDir = process.env.PULLFROG_TEMP_DIR;
|
const tempDir = process.env.PULLFROG_TEMP_DIR;
|
||||||
if (!tempDir) {
|
if (!tempDir) {
|
||||||
throw new Error("PULLFROG_TEMP_DIR not set");
|
throw new Error("PULLFROG_TEMP_DIR not set");
|
||||||
|
|||||||
+44
-49
@@ -14,12 +14,8 @@ function resolveMode(modes: Mode[], modeName: string): Mode | null {
|
|||||||
return modes.find((m) => m.name.toLowerCase() === modeName.toLowerCase()) ?? null;
|
return modes.find((m) => m.name.toLowerCase() === modeName.toLowerCase()) ?? null;
|
||||||
}
|
}
|
||||||
|
|
||||||
function defaultGuidance(mode: Mode): string {
|
|
||||||
return `Delegate a subagent for this "${mode.name}" task via the \`tasks\` array. Craft a self-contained prompt that includes all context the subagent needs. Subagents have file ops, bash, and read-only GitHub tools — but NO git/checkout, dependency, GitHub-write, or remote-mutating tools. All state-mutating and user-facing operations are your responsibility as orchestrator.`;
|
|
||||||
}
|
|
||||||
|
|
||||||
const modeGuidance: Record<string, string> = {
|
const modeGuidance: Record<string, string> = {
|
||||||
Build: `For Build tasks, consider a multi-phase approach:
|
Build: `### Checklist
|
||||||
|
|
||||||
1. **plan phase** (optional, for complex tasks): delegate a subagent to analyze the requirements, read AGENTS.md and relevant code, and produce a step-by-step implementation plan. Include \`${ghPullfrogMcpName}/set_output\` with the plan so it returns to you. Use mini or auto effort. You can also use \`ask_question\` for codebase questions/investigations.
|
1. **plan phase** (optional, for complex tasks): delegate a subagent to analyze the requirements, read AGENTS.md and relevant code, and produce a step-by-step implementation plan. Include \`${ghPullfrogMcpName}/set_output\` with the plan so it returns to you. Use mini or auto effort. You can also use \`ask_question\` for codebase questions/investigations.
|
||||||
|
|
||||||
@@ -34,7 +30,7 @@ const modeGuidance: Record<string, string> = {
|
|||||||
- instruct the subagent to plan its approach before writing code: identify which files need to change, key design decisions, and edge cases. for non-trivial changes, consider whether there's a more elegant approach before committing to implementation.
|
- instruct the subagent to plan its approach before writing code: identify which files need to change, key design decisions, and edge cases. for non-trivial changes, consider whether there's a more elegant approach before committing to implementation.
|
||||||
- testing expectations: run relevant tests/lints before committing
|
- testing expectations: run relevant tests/lints before committing
|
||||||
- pre-commit quality check: instruct the subagent to review its own diff before committing — verify only intended changes are present, no debug artifacts or commented-out code remain, and no unrelated files were modified. the change should be clean enough that a senior engineer would approve it without hesitation. for non-trivial changes, ask whether there's a simpler way to achieve the same result.
|
- pre-commit quality check: instruct the subagent to review its own diff before committing — verify only intended changes are present, no debug artifacts or commented-out code remain, and no unrelated files were modified. the change should be clean enough that a senior engineer would approve it without hesitation. for non-trivial changes, ask whether there's a simpler way to achieve the same result.
|
||||||
- commit locally via bash (\`git add . && git commit -m "..."\`)
|
- commit locally via shell (\`git add . && git commit -m "..."\`)
|
||||||
- call \`${ghPullfrogMcpName}/set_output\` with a concise summary including the branch name (this is how results get back to you)
|
- call \`${ghPullfrogMcpName}/set_output\` with a concise summary including the branch name (this is how results get back to you)
|
||||||
|
|
||||||
4. **review phase** (optional, for non-trivial changes): before pushing, delegate a review subagent to check the pending diff. Use \`ask_question\` for quick spot-checks, or delegate a full Review subagent for high-stakes changes. This catches issues before they're public.
|
4. **review phase** (optional, for non-trivial changes): before pushing, delegate a review subagent to check the pending diff. Use \`ask_question\` for quick spot-checks, or delegate a full Review subagent for high-stakes changes. This catches issues before they're public.
|
||||||
@@ -44,32 +40,34 @@ const modeGuidance: Record<string, string> = {
|
|||||||
- create a PR via \`${ghPullfrogMcpName}/create_pull_request\`
|
- create a PR via \`${ghPullfrogMcpName}/create_pull_request\`
|
||||||
- call \`${ghPullfrogMcpName}/report_progress\` with the final summary including PR link
|
- call \`${ghPullfrogMcpName}/report_progress\` with the final summary including PR link
|
||||||
|
|
||||||
|
### Notes
|
||||||
|
|
||||||
For simple, well-defined tasks, a single build subagent is sufficient — skip the plan and review phases.
|
For simple, well-defined tasks, a single build subagent is sufficient — skip the plan and review phases.
|
||||||
|
|
||||||
Your subagent receives ONLY what you write. Include file paths, constraints, conventions, and any context from AGENTS.md or the codebase directly in the prompt. Subagents have file ops, bash, and read-only GitHub tools — but NO git/checkout, dependency, GitHub-write, or remote-mutating tools.`,
|
Your subagent receives ONLY what you write. Include file paths, constraints, conventions, and any context from AGENTS.md or the codebase directly in the prompt. Subagents have file ops, shell, and read-only GitHub tools — but NO git/checkout, dependency, GitHub-write, or remote-mutating tools.`,
|
||||||
|
|
||||||
AddressReviews: `Delegate a single subagent to address PR review feedback.
|
AddressReviews: `### Checklist
|
||||||
|
|
||||||
Before delegating, checkout the PR branch yourself via \`${ghPullfrogMcpName}/checkout_pr\` — subagents have no git/checkout tools.
|
1. Before delegating, checkout the PR branch yourself via \`${ghPullfrogMcpName}/checkout_pr\` — subagents have no git/checkout tools.
|
||||||
|
|
||||||
Include in its prompt:
|
2. Include in its prompt:
|
||||||
- instruct it to fetch review comments via \`${ghPullfrogMcpName}/get_review_comments\` (subagents have read-only GitHub tools)
|
- instruct it to fetch review comments via \`${ghPullfrogMcpName}/get_review_comments\` (subagents have read-only GitHub tools)
|
||||||
- for each comment: understand the feedback, make the code change, and record what was done
|
- for each comment: understand the feedback, make the code change, and record what was done
|
||||||
- test changes, then review the diff before committing — verify only intended changes are present, no debug artifacts remain, and the changes are clean enough that a senior engineer would approve without hesitation
|
- test changes, then review the diff before committing — verify only intended changes are present, no debug artifacts remain, and the changes are clean enough that a senior engineer would approve without hesitation
|
||||||
- commit locally via bash (\`git add . && git commit -m "..."\`)
|
- commit locally via shell (\`git add . && git commit -m "..."\`)
|
||||||
- call \`${ghPullfrogMcpName}/set_output\` with a JSON object: \`{ "summary": "...", "replies": [{ "comment_id": 123, "thread_id": "...", "reply": "Fixed by ..." }, ...] }\` — this is how results get back to you
|
- call \`${ghPullfrogMcpName}/set_output\` with a JSON object: \`{ "summary": "...", "replies": [{ "comment_id": 123, "thread_id": "...", "reply": "Fixed by ..." }, ...] }\` — this is how results get back to you
|
||||||
|
|
||||||
After the subagent completes:
|
3. After the subagent completes:
|
||||||
- push changes via \`${ghPullfrogMcpName}/push_branch\`
|
- push changes via \`${ghPullfrogMcpName}/push_branch\`
|
||||||
- reply to each comment using \`${ghPullfrogMcpName}/reply_to_review_comment\` with the subagent's suggested replies
|
- reply to each comment using \`${ghPullfrogMcpName}/reply_to_review_comment\` with the subagent's suggested replies
|
||||||
- resolve addressed threads via \`${ghPullfrogMcpName}/resolve_review_thread\`
|
- resolve addressed threads via \`${ghPullfrogMcpName}/resolve_review_thread\`
|
||||||
- call \`${ghPullfrogMcpName}/report_progress\` with a brief summary
|
- call \`${ghPullfrogMcpName}/report_progress\` with a brief summary
|
||||||
|
|
||||||
|
### Effort
|
||||||
|
|
||||||
Use auto or max effort depending on review complexity.`,
|
Use auto or max effort depending on review complexity.`,
|
||||||
|
|
||||||
Review: `For reviews, delegate multiple focused subagents in parallel — each investigating a different area or aspect of the PR.
|
Review: `### Checklist
|
||||||
|
|
||||||
### Approach
|
|
||||||
|
|
||||||
1. Checkout the PR via \`${ghPullfrogMcpName}/checkout_pr\` — this returns PR metadata and a \`diffPath\`. Read the diff to identify the major areas of change.
|
1. Checkout the PR via \`${ghPullfrogMcpName}/checkout_pr\` — this returns PR metadata and a \`diffPath\`. Read the diff to identify the major areas of change.
|
||||||
2. Delegate multiple subagents in a single \`${ghPullfrogMcpName}/delegate\` call, each focused on a specific area. For example, a PR touching action/, components/, and prisma/ might get three subagents: "action-review", "frontend-review", "schema-review".
|
2. Delegate multiple subagents in a single \`${ghPullfrogMcpName}/delegate\` call, each focused on a specific area. For example, a PR touching action/, components/, and prisma/ might get three subagents: "action-review", "frontend-review", "schema-review".
|
||||||
@@ -97,60 +95,57 @@ After all tasks complete, consolidate into a **single** review:
|
|||||||
|
|
||||||
Use max effort for thorough reviews.`,
|
Use max effort for thorough reviews.`,
|
||||||
|
|
||||||
Plan: `Delegate a single planning subagent:
|
Plan: `### Checklist
|
||||||
|
|
||||||
Include in its prompt:
|
1. Include in its prompt:
|
||||||
- the task to plan for
|
- the task to plan for
|
||||||
- relevant codebase context (file paths, architecture notes from AGENTS.md)
|
- relevant codebase context (file paths, architecture notes from AGENTS.md)
|
||||||
- instruct it to produce a structured, actionable plan with clear milestones
|
- instruct it to produce a structured, actionable plan with clear milestones
|
||||||
- call \`${ghPullfrogMcpName}/set_output\` with the plan (this is how results get back to you — you'll need the plan to craft the next subagent's prompt)
|
- call \`${ghPullfrogMcpName}/set_output\` with the plan (this is how results get back to you — you'll need the plan to craft the next subagent's prompt)
|
||||||
|
2. After the subagent completes, call \`${ghPullfrogMcpName}/report_progress\` with the plan.
|
||||||
|
|
||||||
After the subagent completes, call \`${ghPullfrogMcpName}/report_progress\` with the plan.
|
### Effort
|
||||||
|
|
||||||
Use mini or auto effort. After receiving the plan, you may delegate a Build subagent to implement it.`,
|
Use mini or auto effort. After receiving the plan, you may delegate a Build subagent to implement it.`,
|
||||||
|
|
||||||
Fix: `For CI fix tasks, consider a focused single-phase approach.
|
Fix: `### Checklist
|
||||||
|
|
||||||
Before delegating, checkout the PR branch yourself via \`${ghPullfrogMcpName}/checkout_pr\` — subagents have no git/checkout tools.
|
1. Before delegating, checkout the PR branch yourself via \`${ghPullfrogMcpName}/checkout_pr\` — subagents have no git/checkout tools.
|
||||||
|
|
||||||
Delegate a single fix subagent with:
|
2. Delegate a single fix subagent with:
|
||||||
- the check_suite_id to fetch logs via \`${ghPullfrogMcpName}/get_check_suite_logs\` (subagents have read-only GitHub tools)
|
- the check_suite_id to fetch logs via \`${ghPullfrogMcpName}/get_check_suite_logs\` (subagents have read-only GitHub tools)
|
||||||
- the PR diff file path (from checkout_pr result) so it can understand what the PR changed
|
- the PR diff file path (from checkout_pr result) so it can understand what the PR changed
|
||||||
- CRITICAL: instruct it to verify the failure was INTRODUCED BY THIS PR before fixing. If unrelated, abort and report.
|
- CRITICAL: instruct it to verify the failure was INTRODUCED BY THIS PR before fixing. If unrelated, abort and report.
|
||||||
- instruct it to read the workflow file, reproduce locally with the EXACT same commands CI runs
|
- instruct it to read the workflow file, reproduce locally with the EXACT same commands CI runs
|
||||||
- fix the issue, then verify the fix by re-running the exact CI command
|
- fix the issue, then verify the fix by re-running the exact CI command
|
||||||
- pre-commit quality check: review the diff before committing — verify only the fix is present, no debug artifacts, no unrelated changes. the fix should be clean enough that a senior engineer would approve it without hesitation.
|
- pre-commit quality check: review the diff before committing — verify only the fix is present, no debug artifacts, no unrelated changes. the fix should be clean enough that a senior engineer would approve it without hesitation.
|
||||||
- commit locally via bash (\`git add . && git commit -m "..."\`)
|
- commit locally via shell (\`git add . && git commit -m "..."\`)
|
||||||
- call \`${ghPullfrogMcpName}/set_output\` with a concise summary: what failed, why, and the fix applied (this is how results get back to you)
|
- call \`${ghPullfrogMcpName}/set_output\` with a concise summary: what failed, why, and the fix applied (this is how results get back to you)
|
||||||
|
|
||||||
After the subagent completes:
|
3. After the subagent completes:
|
||||||
- push changes via \`${ghPullfrogMcpName}/push_branch\`
|
- push changes via \`${ghPullfrogMcpName}/push_branch\`
|
||||||
- call \`${ghPullfrogMcpName}/report_progress\` with the diagnosis and fix summary
|
- call \`${ghPullfrogMcpName}/report_progress\` with the diagnosis and fix summary
|
||||||
|
|
||||||
|
### Effort
|
||||||
|
|
||||||
Use auto effort.`,
|
Use auto effort.`,
|
||||||
|
|
||||||
Task: `Handle this general-purpose task. For simple operations (labeling, commenting, answering questions, running a single command), you can often handle it directly without delegation.
|
Task: `### Checklist
|
||||||
|
|
||||||
When the task involves **substantial work** — code changes across multiple files, multi-step investigations, or tasks that benefit from focused context — use \`delegate\` and \`ask_question\` liberally:
|
1. Handle this general-purpose task. For simple operations (labeling, commenting, answering questions, running a single command), you can often handle it directly without delegation.
|
||||||
|
2. When the task involves **substantial work** — code changes across multiple files, multi-step investigations, or tasks that benefit from focused context — use \`delegate\` and \`ask_question\` liberally:
|
||||||
- \`ask_question\`: quick codebase research, finding files, understanding architecture. Use freely — multiple calls in sequence is fine.
|
- \`ask_question\`: quick codebase research, finding files, understanding architecture. Use freely — multiple calls in sequence is fine.
|
||||||
- \`delegate\`: research, local coding tasks, and codebase investigations. Each subagent gets dedicated context, so break complex work into focused subtasks and delegate each one. For independent subtasks, batch them in a single \`${ghPullfrogMcpName}/delegate\` call to run in parallel.
|
- \`delegate\`: research, local coding tasks, and codebase investigations. Each subagent gets dedicated context, so break complex work into focused subtasks and delegate each one. For independent subtasks, batch them in a single \`${ghPullfrogMcpName}/delegate\` call to run in parallel.
|
||||||
|
3. Include in each task's prompt:
|
||||||
### When delegating
|
- the full subtask description with all relevant context
|
||||||
|
- exactly what information to return. the subagent's output is your only way to get results back — be precise about what you need.
|
||||||
Include in each task's prompt:
|
- if code changes are needed: branch naming, testing, commit instructions (do NOT instruct to push or create PR)
|
||||||
- the full subtask description with all relevant context
|
- if code changes are needed: instruct it to review its own diff before committing — verify only intended changes are present, no debug artifacts remain, and the changes are clean enough that a senior engineer would approve without hesitation
|
||||||
- exactly what information to return. the subagent's output is your only way to get results back — be precise about what you need.
|
4. Post-delegation:
|
||||||
- if code changes are needed: branch naming, testing, commit instructions (do NOT instruct to push or create PR)
|
- call \`${ghPullfrogMcpName}/report_progress\` with results
|
||||||
- if code changes are needed: instruct it to review its own diff before committing — verify only intended changes are present, no debug artifacts remain, and the changes are clean enough that a senior engineer would approve without hesitation
|
- if the task involved code changes, push via \`${ghPullfrogMcpName}/push_branch\` and create a PR via \`${ghPullfrogMcpName}/create_pull_request\`
|
||||||
|
- if the task involved labeling, commenting, or other GitHub operations, perform those directly
|
||||||
### Post-delegation
|
5. Use mini effort for simple research tasks, auto for typical tasks, max for complex multi-file changes.`,
|
||||||
|
|
||||||
- call \`${ghPullfrogMcpName}/report_progress\` with results
|
|
||||||
- if the task involved code changes, push via \`${ghPullfrogMcpName}/push_branch\` and create a PR via \`${ghPullfrogMcpName}/create_pull_request\`
|
|
||||||
- if the task involved labeling, commenting, or other GitHub operations, perform those directly
|
|
||||||
|
|
||||||
Use mini effort for simple research tasks, auto for typical tasks, max for complex multi-file changes.`,
|
|
||||||
};
|
};
|
||||||
|
|
||||||
type OrchestratorGuidance = {
|
type OrchestratorGuidance = {
|
||||||
@@ -160,7 +155,7 @@ type OrchestratorGuidance = {
|
|||||||
};
|
};
|
||||||
|
|
||||||
function buildOrchestratorGuidance(mode: Mode): OrchestratorGuidance {
|
function buildOrchestratorGuidance(mode: Mode): OrchestratorGuidance {
|
||||||
const guidance = modeGuidance[mode.name] ?? defaultGuidance(mode);
|
const guidance = modeGuidance[mode.name] ?? "";
|
||||||
return {
|
return {
|
||||||
modeName: mode.name,
|
modeName: mode.name,
|
||||||
description: mode.description,
|
description: mode.description,
|
||||||
|
|||||||
+31
-49
@@ -100,7 +100,7 @@ export interface ToolContext {
|
|||||||
modes: Mode[];
|
modes: Mode[];
|
||||||
postCheckoutScript: string | null;
|
postCheckoutScript: string | null;
|
||||||
toolState: ToolState;
|
toolState: ToolState;
|
||||||
runId: string;
|
runId: number | undefined;
|
||||||
jobId: string | undefined;
|
jobId: string | undefined;
|
||||||
// set after MCP server starts — used by delegate tool to pass URL to subagents
|
// set after MCP server starts — used by delegate tool to pass URL to subagents
|
||||||
mcpServerUrl: string;
|
mcpServerUrl: string;
|
||||||
@@ -188,50 +188,27 @@ function isAddressInUse(error: unknown): boolean {
|
|||||||
return message.includes("eaddrinuse") || message.includes("address already in use");
|
return message.includes("eaddrinuse") || message.includes("address already in use");
|
||||||
}
|
}
|
||||||
|
|
||||||
// subagent tools: file ops, bash, read-only GitHub, upload, set_output.
|
// tools shared by both orchestrator and subagent servers
|
||||||
// no git/checkout (mutates shared state), no dependencies (shared state),
|
function buildCommonTools(ctx: ToolContext): Tool<any, any>[] {
|
||||||
// no GitHub-write (user-facing side effects), no delegation/remote-mutating.
|
|
||||||
function buildSubagentTools(ctx: ToolContext): Tool<any, any>[] {
|
|
||||||
const tools: Tool<any, any>[] = [
|
|
||||||
IssueInfoTool(ctx),
|
|
||||||
GetIssueCommentsTool(ctx),
|
|
||||||
GetIssueEventsTool(ctx),
|
|
||||||
PullRequestInfoTool(ctx),
|
|
||||||
CommitInfoTool(ctx),
|
|
||||||
GetReviewCommentsTool(ctx),
|
|
||||||
ListPullRequestReviewsTool(ctx),
|
|
||||||
GetCheckSuiteLogsTool(ctx),
|
|
||||||
UploadFileTool(ctx),
|
|
||||||
SetOutputTool(ctx),
|
|
||||||
FileReadTool(ctx),
|
|
||||||
FileWriteTool(ctx),
|
|
||||||
FileEditTool(ctx),
|
|
||||||
FileDeleteTool(ctx),
|
|
||||||
ListDirectoryTool(ctx),
|
|
||||||
];
|
|
||||||
|
|
||||||
if (ctx.payload.bash === "restricted") {
|
|
||||||
tools.push(BashTool(ctx));
|
|
||||||
tools.push(KillBackgroundTool(ctx));
|
|
||||||
}
|
|
||||||
|
|
||||||
return tools;
|
|
||||||
}
|
|
||||||
|
|
||||||
// orchestrator gets everything: file ops, bash, git, GitHub, delegation, remote-mutating
|
|
||||||
function buildOrchestratorTools(ctx: ToolContext): Tool<any, any>[] {
|
|
||||||
const tools: Tool<any, any>[] = [
|
const tools: Tool<any, any>[] = [
|
||||||
StartDependencyInstallationTool(ctx),
|
StartDependencyInstallationTool(ctx),
|
||||||
AwaitDependencyInstallationTool(ctx),
|
AwaitDependencyInstallationTool(ctx),
|
||||||
|
CreateCommentTool(ctx),
|
||||||
|
EditCommentTool(ctx),
|
||||||
|
ReplyToReviewCommentTool(ctx),
|
||||||
|
IssueTool(ctx),
|
||||||
IssueInfoTool(ctx),
|
IssueInfoTool(ctx),
|
||||||
GetIssueCommentsTool(ctx),
|
GetIssueCommentsTool(ctx),
|
||||||
GetIssueEventsTool(ctx),
|
GetIssueEventsTool(ctx),
|
||||||
|
CreatePullRequestReviewTool(ctx),
|
||||||
PullRequestInfoTool(ctx),
|
PullRequestInfoTool(ctx),
|
||||||
CommitInfoTool(ctx),
|
CommitInfoTool(ctx),
|
||||||
CheckoutPrTool(ctx),
|
CheckoutPrTool(ctx),
|
||||||
GetReviewCommentsTool(ctx),
|
GetReviewCommentsTool(ctx),
|
||||||
ListPullRequestReviewsTool(ctx),
|
ListPullRequestReviewsTool(ctx),
|
||||||
|
ResolveReviewThreadTool(ctx),
|
||||||
GetCheckSuiteLogsTool(ctx),
|
GetCheckSuiteLogsTool(ctx),
|
||||||
|
AddLabelsTool(ctx),
|
||||||
GitTool(ctx),
|
GitTool(ctx),
|
||||||
GitFetchTool(ctx),
|
GitFetchTool(ctx),
|
||||||
UploadFileTool(ctx),
|
UploadFileTool(ctx),
|
||||||
@@ -241,22 +218,6 @@ function buildOrchestratorTools(ctx: ToolContext): Tool<any, any>[] {
|
|||||||
FileEditTool(ctx),
|
FileEditTool(ctx),
|
||||||
FileDeleteTool(ctx),
|
FileDeleteTool(ctx),
|
||||||
ListDirectoryTool(ctx),
|
ListDirectoryTool(ctx),
|
||||||
CreateCommentTool(ctx),
|
|
||||||
EditCommentTool(ctx),
|
|
||||||
ReplyToReviewCommentTool(ctx),
|
|
||||||
CreatePullRequestReviewTool(ctx),
|
|
||||||
ResolveReviewThreadTool(ctx),
|
|
||||||
IssueTool(ctx),
|
|
||||||
AddLabelsTool(ctx),
|
|
||||||
ReportProgressTool(ctx),
|
|
||||||
SelectModeTool(ctx),
|
|
||||||
DelegateTool(ctx),
|
|
||||||
AskQuestionTool(ctx),
|
|
||||||
PushBranchTool(ctx),
|
|
||||||
PushTagsTool(ctx),
|
|
||||||
DeleteBranchTool(ctx),
|
|
||||||
CreatePullRequestTool(ctx),
|
|
||||||
UpdatePullRequestBodyTool(ctx),
|
|
||||||
];
|
];
|
||||||
|
|
||||||
// only add ShellTool when shell is "restricted"
|
// only add ShellTool when shell is "restricted"
|
||||||
@@ -271,6 +232,27 @@ function buildOrchestratorTools(ctx: ToolContext): Tool<any, any>[] {
|
|||||||
return tools;
|
return tools;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// orchestrator gets common tools + delegation + remote-mutating tools
|
||||||
|
function buildOrchestratorTools(ctx: ToolContext): Tool<any, any>[] {
|
||||||
|
return [
|
||||||
|
...buildCommonTools(ctx),
|
||||||
|
ReportProgressTool(ctx),
|
||||||
|
SelectModeTool(ctx),
|
||||||
|
DelegateTool(ctx),
|
||||||
|
AskQuestionTool(ctx),
|
||||||
|
PushBranchTool(ctx),
|
||||||
|
PushTagsTool(ctx),
|
||||||
|
DeleteBranchTool(ctx),
|
||||||
|
CreatePullRequestTool(ctx),
|
||||||
|
UpdatePullRequestBodyTool(ctx),
|
||||||
|
];
|
||||||
|
}
|
||||||
|
|
||||||
|
// subagent gets only common tools (no delegation, no remote mutation)
|
||||||
|
function buildSubagentTools(ctx: ToolContext): Tool<any, any>[] {
|
||||||
|
return buildCommonTools(ctx);
|
||||||
|
}
|
||||||
|
|
||||||
type McpStartResult = {
|
type McpStartResult = {
|
||||||
server: FastMCP;
|
server: FastMCP;
|
||||||
url: string;
|
url: string;
|
||||||
|
|||||||
+38
-12
@@ -2,6 +2,7 @@
|
|||||||
import { type ChildProcess, type StdioOptions, spawn, spawnSync } from "node:child_process";
|
import { type ChildProcess, type StdioOptions, spawn, spawnSync } from "node:child_process";
|
||||||
import { randomUUID } from "node:crypto";
|
import { randomUUID } from "node:crypto";
|
||||||
import { closeSync, openSync, writeFileSync } from "node:fs";
|
import { closeSync, openSync, writeFileSync } from "node:fs";
|
||||||
|
import { userInfo } from "node:os";
|
||||||
import { join } from "node:path";
|
import { join } from "node:path";
|
||||||
import { type } from "arktype";
|
import { type } from "arktype";
|
||||||
import { log } from "../utils/log.ts";
|
import { log } from "../utils/log.ts";
|
||||||
@@ -82,33 +83,39 @@ function detectSandboxMethod(): SandboxMethod {
|
|||||||
return "none";
|
return "none";
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// strip inherited proc mount that sits underneath --mount-proc's overlay.
|
||||||
|
// --mount-proc mounts fresh proc on top, but `umount /proc` peels it off and exposes the
|
||||||
|
// host's proc with all host PIDs — allowing /proc/<pid>/environ exfiltration.
|
||||||
|
// double-umount removes both layers, then a clean mount gives only sandbox PIDs.
|
||||||
|
// on unprivileged systems where umount fails, --mount-proc still provides isolation
|
||||||
|
// (the agent also can't umount in that case).
|
||||||
|
const PROC_CLEANUP =
|
||||||
|
"umount /proc 2>/dev/null; umount /proc 2>/dev/null; mount -t proc proc /proc 2>/dev/null;";
|
||||||
|
|
||||||
function spawnShell(params: SpawnParams): ChildProcess {
|
function spawnShell(params: SpawnParams): ChildProcess {
|
||||||
const spawnOpts = { env: params.env, cwd: params.cwd, stdio: params.stdio, detached: true };
|
const spawnOpts = { env: params.env, cwd: params.cwd, stdio: params.stdio, detached: true };
|
||||||
const sandboxMethod = detectSandboxMethod();
|
const sandboxMethod = detectSandboxMethod();
|
||||||
|
|
||||||
if (sandboxMethod === "unshare") {
|
if (sandboxMethod === "unshare") {
|
||||||
// use PID namespace isolation to prevent reading /proc/$PPID/environ
|
|
||||||
// this creates a new PID namespace where:
|
|
||||||
// 1. the subprocess becomes PID 1 in its namespace
|
|
||||||
// 2. parent PIDs are not visible (PPID = 0)
|
|
||||||
// 3. fresh /proc is mounted showing only sandbox PIDs
|
|
||||||
// combined with resolveEnv("restricted"), this prevents all /proc-based secret theft
|
|
||||||
return spawn(
|
return spawn(
|
||||||
"unshare",
|
"unshare",
|
||||||
["--pid", "--fork", "--mount-proc", "bash", "-c", params.command],
|
["--pid", "--fork", "--mount-proc", "bash", "-c", `${PROC_CLEANUP} ${params.command}`],
|
||||||
spawnOpts
|
spawnOpts
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
if (sandboxMethod === "sudo-unshare") {
|
if (sandboxMethod === "sudo-unshare") {
|
||||||
// on GHA runners, unprivileged namespaces are blocked but sudo works
|
|
||||||
// pass filtered env via sudo env command since sudo clears environment
|
|
||||||
const envArgs: string[] = [];
|
const envArgs: string[] = [];
|
||||||
for (const [k, v] of Object.entries(params.env)) {
|
for (const [k, v] of Object.entries(params.env)) {
|
||||||
if (v !== undefined) {
|
if (v !== undefined) {
|
||||||
envArgs.push(`${k}=${v}`);
|
envArgs.push(`${k}=${v}`);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
// drop back to original user after PROC_CLEANUP so files aren't owned by root.
|
||||||
|
// sudo is only needed for unshare; the actual command should run as the normal user
|
||||||
|
// to avoid ownership mismatches with file_write/file_edit (which run in the Node.js parent).
|
||||||
|
const username = userInfo().username;
|
||||||
|
const escaped = params.command.replace(/'/g, "'\\''");
|
||||||
return spawn(
|
return spawn(
|
||||||
"sudo",
|
"sudo",
|
||||||
[
|
[
|
||||||
@@ -120,9 +127,9 @@ function spawnShell(params: SpawnParams): ChildProcess {
|
|||||||
"--mount-proc",
|
"--mount-proc",
|
||||||
"bash",
|
"bash",
|
||||||
"-c",
|
"-c",
|
||||||
params.command,
|
`${PROC_CLEANUP} exec su -p -s /bin/bash ${username} -c '${escaped}'`,
|
||||||
],
|
],
|
||||||
{ ...spawnOpts, env: {} } // empty env since we pass via sudo env
|
{ ...spawnOpts, env: {} }
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -153,6 +160,14 @@ function getTempDir(): string {
|
|||||||
return tempDir;
|
return tempDir;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/** detect git as a command invocation (not as part of another word like .gitignore) */
|
||||||
|
function isGitCommand(command: string): boolean {
|
||||||
|
const trimmed = command.trim();
|
||||||
|
if (trimmed === "git" || trimmed.startsWith("git ")) return true;
|
||||||
|
if (trimmed.startsWith("sudo git")) return true;
|
||||||
|
return /[;&|]\s*(?:sudo\s+)?git(?:\s|$)/.test(trimmed);
|
||||||
|
}
|
||||||
|
|
||||||
export function ShellTool(ctx: ToolContext) {
|
export function ShellTool(ctx: ToolContext) {
|
||||||
return tool({
|
return tool({
|
||||||
name: "shell",
|
name: "shell",
|
||||||
@@ -162,9 +177,20 @@ Use this tool to:
|
|||||||
- Run shell commands (ls, cat, grep, find, etc.)
|
- Run shell commands (ls, cat, grep, find, etc.)
|
||||||
- Execute build tools (npm, pnpm, cargo, make, etc.)
|
- Execute build tools (npm, pnpm, cargo, make, etc.)
|
||||||
- Run tests and linters
|
- Run tests and linters
|
||||||
- Perform git operations`,
|
|
||||||
|
Do NOT use this tool for git commands — use the dedicated git tools instead.`,
|
||||||
parameters: ShellParams,
|
parameters: ShellParams,
|
||||||
execute: execute(async (params) => {
|
execute: execute(async (params) => {
|
||||||
|
if (isGitCommand(params.command)) {
|
||||||
|
throw new Error(
|
||||||
|
"git commands are not allowed in the shell tool. use the dedicated git tools instead:\n" +
|
||||||
|
"- git: local operations (status, log, diff, add, commit, checkout, merge, rebase, etc.)\n" +
|
||||||
|
"- push_branch: push to remote (handles authentication)\n" +
|
||||||
|
"- git_fetch: fetch from remote (handles authentication)\n" +
|
||||||
|
"- checkout_pr: check out PR branches"
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
const timeout = Math.min(params.timeout ?? 30000, 120000);
|
const timeout = Math.min(params.timeout ?? 30000, 120000);
|
||||||
const cwd = params.working_directory ?? process.cwd();
|
const cwd = params.working_directory ?? process.cwd();
|
||||||
const env = resolveEnv(ctx.payload.shell === "enabled" ? "inherit" : "restricted");
|
const env = resolveEnv(ctx.payload.shell === "enabled" ? "inherit" : "restricted");
|
||||||
|
|||||||
+37
-34
@@ -6,6 +6,7 @@ import type { Effort } from "../external.ts";
|
|||||||
import { ghPullfrogMcpName } from "../external.ts";
|
import { ghPullfrogMcpName } from "../external.ts";
|
||||||
import { markActivity } from "../utils/activity.ts";
|
import { markActivity } from "../utils/activity.ts";
|
||||||
import type { ResolvedInstructions } from "../utils/instructions.ts";
|
import type { ResolvedInstructions } from "../utils/instructions.ts";
|
||||||
|
import { withLogPrefix } from "../utils/log.ts";
|
||||||
import { type SubagentState, startSubagentMcpServer, type ToolContext } from "./server.ts";
|
import { type SubagentState, startSubagentMcpServer, type ToolContext } from "./server.ts";
|
||||||
|
|
||||||
type CreateSubagentParams = {
|
type CreateSubagentParams = {
|
||||||
@@ -71,7 +72,7 @@ const subagentSystemPreamble = `You are a focused subagent. Complete the task au
|
|||||||
|
|
||||||
Your tools are limited to:
|
Your tools are limited to:
|
||||||
- **File operations**: \`${ghPullfrogMcpName}/file_read\`, \`file_write\`, \`file_edit\`, \`file_delete\`, \`list_directory\`. Native file tools (Read, Write, StrReplace, etc.) are disabled — use the MCP versions.
|
- **File operations**: \`${ghPullfrogMcpName}/file_read\`, \`file_write\`, \`file_edit\`, \`file_delete\`, \`list_directory\`. Native file tools (Read, Write, StrReplace, etc.) are disabled — use the MCP versions.
|
||||||
- **Shell**: \`${ghPullfrogMcpName}/bash\` (if available). Use this for local git operations (\`git add\`, \`git commit\`, \`git diff\`, \`git log\`, \`git status\`), running tests, builds, and linters.
|
- **Shell**: \`${ghPullfrogMcpName}/shell\` (if available). Use this for local git operations (\`git add\`, \`git commit\`, \`git diff\`, \`git log\`, \`git status\`), running tests, builds, and linters.
|
||||||
- **Read-only GitHub**: \`get_pull_request\`, \`get_issue\`, \`get_issue_comments\`, \`get_issue_events\`, \`get_review_comments\`, \`list_pull_request_reviews\`, \`get_check_suite_logs\`, \`get_commit_info\`.
|
- **Read-only GitHub**: \`get_pull_request\`, \`get_issue\`, \`get_issue_comments\`, \`get_issue_events\`, \`get_review_comments\`, \`list_pull_request_reviews\`, \`get_check_suite_logs\`, \`get_commit_info\`.
|
||||||
- **Output**: \`${ghPullfrogMcpName}/upload_file\`, \`${ghPullfrogMcpName}/set_output\`.
|
- **Output**: \`${ghPullfrogMcpName}/upload_file\`, \`${ghPullfrogMcpName}/set_output\`.
|
||||||
|
|
||||||
@@ -132,41 +133,43 @@ type RunSubagentResult = {
|
|||||||
};
|
};
|
||||||
|
|
||||||
export async function runSubagent(params: RunSubagentParams): Promise<RunSubagentResult> {
|
export async function runSubagent(params: RunSubagentParams): Promise<RunSubagentResult> {
|
||||||
params.subagent.keepAliveInterval = setInterval(markActivity, 30_000);
|
return withLogPrefix(`[${params.subagent.label}]`, async () => {
|
||||||
const mcpServer = await startSubagentMcpServer({
|
params.subagent.keepAliveInterval = setInterval(markActivity, 30_000);
|
||||||
ctx: params.ctx,
|
const mcpServer = await startSubagentMcpServer({
|
||||||
subagentId: params.subagent.id,
|
|
||||||
});
|
|
||||||
// each subagent gets its own tmpdir so parallel agents don't clobber config files
|
|
||||||
const subagentTmpdir = join(params.ctx.tmpdir, params.subagent.id);
|
|
||||||
mkdirSync(subagentTmpdir, { recursive: true });
|
|
||||||
try {
|
|
||||||
const subagentPayload = { ...params.ctx.payload, effort: params.effort };
|
|
||||||
const subagentInstructions = buildSubagentInstructions({
|
|
||||||
ctx: params.ctx,
|
ctx: params.ctx,
|
||||||
label: params.subagent.label,
|
subagentId: params.subagent.id,
|
||||||
instructions: params.instructions,
|
|
||||||
});
|
});
|
||||||
const result = await params.ctx.agent.run({
|
// each subagent gets its own tmpdir so parallel agents don't clobber config files
|
||||||
payload: subagentPayload,
|
const subagentTmpdir = join(params.ctx.tmpdir, params.subagent.id);
|
||||||
mcpServerUrl: mcpServer.url,
|
mkdirSync(subagentTmpdir, { recursive: true });
|
||||||
tmpdir: subagentTmpdir,
|
|
||||||
instructions: subagentInstructions,
|
|
||||||
});
|
|
||||||
params.subagent.usage = result.usage;
|
|
||||||
writeFileSync(params.subagent.stdoutFilePath, result.output ?? "", "utf-8");
|
|
||||||
completeSubagent({ ctx: params.ctx, subagent: params.subagent, success: result.success });
|
|
||||||
return { success: result.success, error: result.error };
|
|
||||||
} catch (err) {
|
|
||||||
const errorMessage = err instanceof Error ? err.message : String(err);
|
|
||||||
try {
|
try {
|
||||||
writeFileSync(params.subagent.stdoutFilePath, "", "utf-8");
|
const subagentPayload = { ...params.ctx.payload, effort: params.effort };
|
||||||
} catch {
|
const subagentInstructions = buildSubagentInstructions({
|
||||||
// best-effort
|
ctx: params.ctx,
|
||||||
|
label: params.subagent.label,
|
||||||
|
instructions: params.instructions,
|
||||||
|
});
|
||||||
|
const result = await params.ctx.agent.run({
|
||||||
|
payload: subagentPayload,
|
||||||
|
mcpServerUrl: mcpServer.url,
|
||||||
|
tmpdir: subagentTmpdir,
|
||||||
|
instructions: subagentInstructions,
|
||||||
|
});
|
||||||
|
params.subagent.usage = result.usage;
|
||||||
|
writeFileSync(params.subagent.stdoutFilePath, result.output ?? "", "utf-8");
|
||||||
|
completeSubagent({ ctx: params.ctx, subagent: params.subagent, success: result.success });
|
||||||
|
return { success: result.success, error: result.error };
|
||||||
|
} catch (err) {
|
||||||
|
const errorMessage = err instanceof Error ? err.message : String(err);
|
||||||
|
try {
|
||||||
|
writeFileSync(params.subagent.stdoutFilePath, "", "utf-8");
|
||||||
|
} catch {
|
||||||
|
// best-effort
|
||||||
|
}
|
||||||
|
completeSubagent({ ctx: params.ctx, subagent: params.subagent, success: false });
|
||||||
|
return { success: false, error: errorMessage };
|
||||||
|
} finally {
|
||||||
|
await mcpServer.stop();
|
||||||
}
|
}
|
||||||
completeSubagent({ ctx: params.ctx, subagent: params.subagent, success: false });
|
});
|
||||||
return { success: false, error: errorMessage };
|
|
||||||
} finally {
|
|
||||||
await mcpServer.stop();
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -82,7 +82,7 @@ describe("per-server tool isolation - integration", () => {
|
|||||||
orchestratorServer.addTool(mockTool("push_branch", "push branch"));
|
orchestratorServer.addTool(mockTool("push_branch", "push branch"));
|
||||||
orchestratorServer.addTool(mockTool("create_pull_request", "create PR"));
|
orchestratorServer.addTool(mockTool("create_pull_request", "create PR"));
|
||||||
|
|
||||||
// subagent gets ONLY file ops, bash, read-only GitHub, upload, set_output
|
// subagent gets ONLY file ops, shell, read-only GitHub, upload, set_output
|
||||||
subagentServer = new FastMCP({ name: "subagent", version: "0.0.1" });
|
subagentServer = new FastMCP({ name: "subagent", version: "0.0.1" });
|
||||||
subagentServer.addTool(mockTool("file_read", "read a file"));
|
subagentServer.addTool(mockTool("file_read", "read a file"));
|
||||||
subagentServer.addTool(mockTool("set_output", "set output"));
|
subagentServer.addTool(mockTool("set_output", "set output"));
|
||||||
|
|||||||
@@ -32,7 +32,7 @@ export function computeModes(): Mode[] {
|
|||||||
1. **CHECKOUT** - Determine whether to checkout the existing PR branch or create a new one:
|
1. **CHECKOUT** - Determine whether to checkout the existing PR branch or create a new one:
|
||||||
- **PR event, modifying the existing PR**: Call \`${ghPullfrogMcpName}/checkout_pr\` with the PR number to checkout the PR branch.
|
- **PR event, modifying the existing PR**: Call \`${ghPullfrogMcpName}/checkout_pr\` with the PR number to checkout the PR branch.
|
||||||
- **PR event, but user wants a NEW branch/PR**: Create a new branch with \`git checkout -b pullfrog/branch-name\` via the \`${ghPullfrogMcpName}/git\` tool.
|
- **PR event, but user wants a NEW branch/PR**: Create a new branch with \`git checkout -b pullfrog/branch-name\` via the \`${ghPullfrogMcpName}/git\` tool.
|
||||||
|
|
||||||
Branch names must be prefixed with "pullfrog/" and be specific enough to avoid collisions. Never commit directly to main/master/production.
|
Branch names must be prefixed with "pullfrog/" and be specific enough to avoid collisions. Never commit directly to main/master/production.
|
||||||
|
|
||||||
2. **DEPENDENCIES** - ${dependencyInstallationStep}
|
2. **DEPENDENCIES** - ${dependencyInstallationStep}
|
||||||
@@ -51,12 +51,13 @@ export function computeModes(): Mode[] {
|
|||||||
|
|
||||||
9. **PR** - Determine whether to create a PR (if not already on a PR branch):
|
9. **PR** - Determine whether to create a PR (if not already on a PR branch):
|
||||||
- **Default behavior**: Create a PR using ${ghPullfrogMcpName}/create_pull_request with an informative title and body. If you are working in the context of an issue (check EVENT DATA for \`issue_number\` where \`is_pr\` is not true), include "Closes #<issue_number>" in the PR body to auto-close the issue when merged.
|
- **Default behavior**: Create a PR using ${ghPullfrogMcpName}/create_pull_request with an informative title and body. If you are working in the context of an issue (check EVENT DATA for \`issue_number\` where \`is_pr\` is not true), include "Closes #<issue_number>" in the PR body to auto-close the issue when merged.
|
||||||
|
- **Draft PR request**: If the user explicitly asks for a draft PR (e.g. "draft PR", "create as draft", "WIP"), create a PR with \`draft: true\`.
|
||||||
- **Branch-only request**: If the user explicitly asks for a branch without a PR (e.g. "don't create a PR", "branch only", "just create a branch"), do NOT create a PR. Simply push the branch and report the branch link.
|
- **Branch-only request**: If the user explicitly asks for a branch without a PR (e.g. "don't create a PR", "branch only", "just create a branch"), do NOT create a PR. Simply push the branch and report the branch link.
|
||||||
|
|
||||||
10. **FINAL REPORT** - Call report_progress one final time ONLY if you haven't already included all the important information (PR links, branch links, summary) in a previous report_progress call. If you already called report_progress with complete information including PR links after creating the PR, you do NOT need to call it again. Only make a final call if you need to add missing information. When making the final call, ensure it includes:
|
10. **FINAL REPORT** - Call report_progress one final time ONLY if you haven't already included all the important information (PR links, branch links, summary) in a previous report_progress call. If you already called report_progress with complete information including PR links after creating the PR, you do NOT need to call it again. Only make a final call if you need to add missing information. When making the final call, ensure it includes:
|
||||||
- A summary of what was accomplished
|
- A summary of what was accomplished
|
||||||
- Links to any artifacts created (PRs, branches, issues)
|
- Links to any artifacts created (PRs, branches, issues)
|
||||||
- If you created a PR, ALWAYS include the PR link. e.g.:
|
- If you created a PR, ALWAYS include the PR link. e.g.:
|
||||||
\`\`\`md
|
\`\`\`md
|
||||||
[View PR ➔](https://github.com/org/repo/pull/123)
|
[View PR ➔](https://github.com/org/repo/pull/123)
|
||||||
\`\`\`
|
\`\`\`
|
||||||
@@ -78,7 +79,7 @@ export function computeModes(): Mode[] {
|
|||||||
|
|
||||||
2. **DEPENDENCIES** - ${dependencyInstallationStep}
|
2. **DEPENDENCIES** - ${dependencyInstallationStep}
|
||||||
|
|
||||||
3. **FETCH COMMENTS** - Fetch review comments using ${ghPullfrogMcpName}/get_review_comments with \`pull_number\` and \`review_id\` from EVENT DATA. This returns \`commentsPath\` - read that file for full comment details with diff context. If EVENT DATA contains a \`triggerer\` field (indicating who requested fixes), you can pass \`approved_by\` to filter to only comments they approved with 👍.
|
3. **FETCH COMMENTS** - Fetch review comments using ${ghPullfrogMcpName}/get_review_comments with \`pull_number\` and \`review_id\` from EVENT DATA. This returns \`commentsPath\` - read that file for full comment details with diff context. When \`approved_only\` is set in EVENT DATA, only approved comments are returned automatically.
|
||||||
|
|
||||||
4. **UNDERSTAND** - Review the feedback provided. Understand each review comment and what changes are being requested.
|
4. **UNDERSTAND** - Review the feedback provided. Understand each review comment and what changes are being requested.
|
||||||
|
|
||||||
@@ -125,6 +126,7 @@ Keep the progress comment extremely brief. The summary should be 1-2 sentences m
|
|||||||
6. **SUBMIT** — Use ${ghPullfrogMcpName}/create_pull_request_review:
|
6. **SUBMIT** — Use ${ghPullfrogMcpName}/create_pull_request_review:
|
||||||
- \`body\`: The summary from step 5
|
- \`body\`: The summary from step 5
|
||||||
- \`comments\`: The inline comments from step 4
|
- \`comments\`: The inline comments from step 4
|
||||||
|
- \`approved\`: Set to \`true\` ONLY if the review contains no actionable feedback — neither inline comments nor actionable content in the body. An approval signals "no changes needed."
|
||||||
|
|
||||||
${permalinkTip}
|
${permalinkTip}
|
||||||
`,
|
`,
|
||||||
@@ -162,23 +164,23 @@ ${permalinkTip}`,
|
|||||||
- \`failed_steps\`: which CI steps failed (e.g., "Step 6: Run tests")
|
- \`failed_steps\`: which CI steps failed (e.g., "Step 6: Run tests")
|
||||||
|
|
||||||
2. **CHECKOUT AND ASSESS CAUSATION** - Use ${ghPullfrogMcpName}/checkout_pr to get the PR diff. BEFORE attempting any fix, you MUST determine if this PR caused the failure:
|
2. **CHECKOUT AND ASSESS CAUSATION** - Use ${ghPullfrogMcpName}/checkout_pr to get the PR diff. BEFORE attempting any fix, you MUST determine if this PR caused the failure:
|
||||||
|
|
||||||
**Ask yourself**: "Could the changes in this PR have caused this failure?"
|
**Ask yourself**: "Could the changes in this PR have caused this failure?"
|
||||||
|
|
||||||
- Read the PR diff carefully - what files were modified?
|
- Read the PR diff carefully - what files were modified?
|
||||||
- What is failing? (test file, module, assertion)
|
- What is failing? (test file, module, assertion)
|
||||||
- Is there a PLAUSIBLE CONNECTION between the PR changes and the failure?
|
- Is there a PLAUSIBLE CONNECTION between the PR changes and the failure?
|
||||||
|
|
||||||
**ABORT immediately if any of these are true:**
|
**ABORT immediately if any of these are true:**
|
||||||
- The failing test/file was NOT touched by this PR AND doesn't depend on changed code
|
- The failing test/file was NOT touched by this PR AND doesn't depend on changed code
|
||||||
- The error is infrastructure-related (network timeout, runner OOM, service unavailable)
|
- The error is infrastructure-related (network timeout, runner OOM, service unavailable)
|
||||||
- The error is a flaky test that passes/fails randomly
|
- The error is a flaky test that passes/fails randomly
|
||||||
- The error existed before this PR (pre-existing bug in main branch)
|
- The error existed before this PR (pre-existing bug in main branch)
|
||||||
- The error is in a dependency update not introduced by this PR
|
- The error is in a dependency update not introduced by this PR
|
||||||
|
|
||||||
**When aborting**, use ${ghPullfrogMcpName}/report_progress to explain:
|
**When aborting**, use ${ghPullfrogMcpName}/report_progress to explain:
|
||||||
"This CI failure appears unrelated to the PR's changes. [Describe the failure]. [Explain why it's not caused by the PR]. No changes made."
|
"This CI failure appears unrelated to the PR's changes. [Describe the failure]. [Explain why it's not caused by the PR]. No changes made."
|
||||||
|
|
||||||
**Only proceed** if there's a clear, logical connection between the PR changes and the failure.
|
**Only proceed** if there's a clear, logical connection between the PR changes and the failure.
|
||||||
|
|
||||||
3. **UNDERSTAND HOW CI RUNS** - Read the workflow file to understand exactly what commands CI runs:
|
3. **UNDERSTAND HOW CI RUNS** - Read the workflow file to understand exactly what commands CI runs:
|
||||||
@@ -233,6 +235,7 @@ Your job is to fix issues THIS PR introduced, not to fix all CI failures. If in
|
|||||||
- Commit your changes with \`${ghPullfrogMcpName}/git\` (\`git add .\` then \`git commit -m "message"\`), then push with \`${ghPullfrogMcpName}/push_branch\`. Do NOT use \`git push\` directly - it requires credentials that only the MCP tool provides.
|
- Commit your changes with \`${ghPullfrogMcpName}/git\` (\`git add .\` then \`git commit -m "message"\`), then push with \`${ghPullfrogMcpName}/push_branch\`. Do NOT use \`git push\` directly - it requires credentials that only the MCP tool provides.
|
||||||
- Determine whether to create a PR:
|
- Determine whether to create a PR:
|
||||||
- **Default behavior**: Create a PR using ${ghPullfrogMcpName}/create_pull_request with an informative title and body. If you are working in the context of an issue (check EVENT DATA for \`issue_number\` where \`is_pr\` is not true), include "Closes #<issue_number>" in the PR body to auto-close the issue when merged.
|
- **Default behavior**: Create a PR using ${ghPullfrogMcpName}/create_pull_request with an informative title and body. If you are working in the context of an issue (check EVENT DATA for \`issue_number\` where \`is_pr\` is not true), include "Closes #<issue_number>" in the PR body to auto-close the issue when merged.
|
||||||
|
- **Draft PR request**: If the user explicitly asks for a draft PR (e.g. "draft PR", "create as draft", "WIP"), create a PR with \`draft: true\`.
|
||||||
- **Branch-only request**: If the user explicitly asks for a branch without a PR (e.g. "don't create a PR", "branch only", "just create a branch"), do NOT create a PR. Simply push the branch and report the branch link.
|
- **Branch-only request**: If the user explicitly asks for a branch without a PR (e.g. "don't create a PR", "branch only", "just create a branch"), do NOT create a PR. Simply push the branch and report the branch link.
|
||||||
|
|
||||||
5. **PROGRESS** - ${reportProgressInstruction}
|
5. **PROGRESS** - ${reportProgressInstruction}
|
||||||
|
|||||||
+1
-1
@@ -1,6 +1,6 @@
|
|||||||
{
|
{
|
||||||
"name": "@pullfrog/pullfrog",
|
"name": "@pullfrog/pullfrog",
|
||||||
"version": "0.0.170",
|
"version": "0.0.175",
|
||||||
"type": "module",
|
"type": "module",
|
||||||
"files": [
|
"files": [
|
||||||
"index.js",
|
"index.js",
|
||||||
|
|||||||
@@ -28846,6 +28846,7 @@ var require_semver2 = __commonJS({
|
|||||||
// utils/log.ts
|
// utils/log.ts
|
||||||
var core = __toESM(require_core(), 1);
|
var core = __toESM(require_core(), 1);
|
||||||
var import_table = __toESM(require_src(), 1);
|
var import_table = __toESM(require_src(), 1);
|
||||||
|
import { AsyncLocalStorage } from "node:async_hooks";
|
||||||
|
|
||||||
// utils/globals.ts
|
// utils/globals.ts
|
||||||
import { existsSync } from "node:fs";
|
import { existsSync } from "node:fs";
|
||||||
@@ -28854,6 +28855,20 @@ var isGitHubActions = !!process.env.GITHUB_ACTIONS;
|
|||||||
var isInsideDocker = existsSync("/.dockerenv");
|
var isInsideDocker = existsSync("/.dockerenv");
|
||||||
|
|
||||||
// utils/log.ts
|
// utils/log.ts
|
||||||
|
var logContext = new AsyncLocalStorage();
|
||||||
|
var MAGENTA = "\x1B[35m";
|
||||||
|
var RESET = "\x1B[0m";
|
||||||
|
function prefixLines(message) {
|
||||||
|
const ctx = logContext.getStore();
|
||||||
|
if (!ctx) return message;
|
||||||
|
const colored = `${MAGENTA}${ctx.prefix}${RESET} `;
|
||||||
|
return message.split("\n").map((line) => `${colored}${line}`).join("\n");
|
||||||
|
}
|
||||||
|
function prefixPlain(name) {
|
||||||
|
const ctx = logContext.getStore();
|
||||||
|
if (!ctx) return name;
|
||||||
|
return `${ctx.prefix} ${name}`;
|
||||||
|
}
|
||||||
var isRunnerDebugEnabled = () => core.isDebug();
|
var isRunnerDebugEnabled = () => core.isDebug();
|
||||||
var isLocalDebugEnabled = () => process.env.LOG_LEVEL === "debug" || process.env.ACTIONS_STEP_DEBUG === "true";
|
var isLocalDebugEnabled = () => process.env.LOG_LEVEL === "debug" || process.env.ACTIONS_STEP_DEBUG === "true";
|
||||||
var isDebugEnabled = () => isLocalDebugEnabled() || isRunnerDebugEnabled();
|
var isDebugEnabled = () => isLocalDebugEnabled() || isRunnerDebugEnabled();
|
||||||
@@ -28869,10 +28884,11 @@ ${arg.stack}`;
|
|||||||
}).join(" ");
|
}).join(" ");
|
||||||
}
|
}
|
||||||
function startGroup2(name) {
|
function startGroup2(name) {
|
||||||
|
const prefixed = prefixPlain(name);
|
||||||
if (isGitHubActions) {
|
if (isGitHubActions) {
|
||||||
core.startGroup(name);
|
core.startGroup(prefixed);
|
||||||
} else {
|
} else {
|
||||||
console.group(name);
|
console.group(prefixed);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
function endGroup2() {
|
function endGroup2() {
|
||||||
@@ -28945,7 +28961,7 @@ function boxString(text, options) {
|
|||||||
}
|
}
|
||||||
function box(text, options) {
|
function box(text, options) {
|
||||||
const boxContent = boxString(text, options);
|
const boxContent = boxString(text, options);
|
||||||
core.info(boxContent);
|
core.info(prefixLines(boxContent));
|
||||||
}
|
}
|
||||||
function printTable(rows, options) {
|
function printTable(rows, options) {
|
||||||
const { title } = options || {};
|
const { title } = options || {};
|
||||||
@@ -28959,42 +28975,42 @@ function printTable(rows, options) {
|
|||||||
);
|
);
|
||||||
const formatted = (0, import_table.table)(tableData);
|
const formatted = (0, import_table.table)(tableData);
|
||||||
if (title) {
|
if (title) {
|
||||||
core.info(`
|
core.info(prefixLines(`
|
||||||
${title}`);
|
${title}`));
|
||||||
}
|
}
|
||||||
core.info(`
|
core.info(prefixLines(`
|
||||||
${formatted}
|
${formatted}
|
||||||
`);
|
`));
|
||||||
}
|
}
|
||||||
function separator(length = 50) {
|
function separator(length = 50) {
|
||||||
const separatorText = "\u2500".repeat(length);
|
const separatorText = "\u2500".repeat(length);
|
||||||
core.info(separatorText);
|
core.info(prefixLines(separatorText));
|
||||||
}
|
}
|
||||||
var log = {
|
var log = {
|
||||||
/** Print info message */
|
/** Print info message */
|
||||||
info: (...args2) => {
|
info: (...args2) => {
|
||||||
core.info(`${ts()}${formatArgs(args2)}`);
|
core.info(prefixLines(`${ts()}${formatArgs(args2)}`));
|
||||||
},
|
},
|
||||||
/** Print a warning message. Use only for warnings that should be displayed in the job summary. */
|
/** Print a warning message. Use only for warnings that should be displayed in the job summary. */
|
||||||
warning: (...args2) => {
|
warning: (...args2) => {
|
||||||
core.warning(`${ts()}${formatArgs(args2)}`);
|
core.warning(prefixLines(`${ts()}${formatArgs(args2)}`));
|
||||||
},
|
},
|
||||||
/** Print an error message. Use only for errors that should be displayed in the job summary. */
|
/** Print an error message. Use only for errors that should be displayed in the job summary. */
|
||||||
error: (...args2) => {
|
error: (...args2) => {
|
||||||
core.error(`${ts()}${formatArgs(args2)}`);
|
core.error(prefixLines(`${ts()}${formatArgs(args2)}`));
|
||||||
},
|
},
|
||||||
/** Print success message */
|
/** Print success message */
|
||||||
success: (...args2) => {
|
success: (...args2) => {
|
||||||
core.info(`${ts()}\xBB ${formatArgs(args2)}`);
|
core.info(prefixLines(`${ts()}\xBB ${formatArgs(args2)}`));
|
||||||
},
|
},
|
||||||
/** Print debug message (only when debug mode is enabled) */
|
/** Print debug message (only when debug mode is enabled) */
|
||||||
debug: (...args2) => {
|
debug: (...args2) => {
|
||||||
if (isRunnerDebugEnabled()) {
|
if (isRunnerDebugEnabled()) {
|
||||||
core.debug(formatArgs(args2));
|
core.debug(prefixLines(formatArgs(args2)));
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
if (isLocalDebugEnabled()) {
|
if (isLocalDebugEnabled()) {
|
||||||
core.info(`${ts()}[DEBUG] ${formatArgs(args2)}`);
|
core.info(prefixLines(`${ts()}[DEBUG] ${formatArgs(args2)}`));
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
/** Print a formatted box with text */
|
/** Print a formatted box with text */
|
||||||
@@ -37477,6 +37493,22 @@ var schema = ark.schema;
|
|||||||
var define2 = ark.define;
|
var define2 = ark.define;
|
||||||
var declare = ark.declare;
|
var declare = ark.declare;
|
||||||
|
|
||||||
|
// utils/apiUrl.ts
|
||||||
|
function isLocalUrl(url2) {
|
||||||
|
return url2.hostname === "localhost" || url2.hostname === "127.0.0.1";
|
||||||
|
}
|
||||||
|
function getApiUrl() {
|
||||||
|
const raw = process.env.API_URL || "https://pullfrog.com";
|
||||||
|
const parsed2 = new URL(raw);
|
||||||
|
if (parsed2.protocol !== "https:" && !isLocalUrl(parsed2)) {
|
||||||
|
throw new Error(
|
||||||
|
`API_URL must use https:// (got ${parsed2.protocol}). only localhost is exempt.`
|
||||||
|
);
|
||||||
|
}
|
||||||
|
log.debug(`resolved API_URL: ${raw}`);
|
||||||
|
return raw;
|
||||||
|
}
|
||||||
|
|
||||||
// utils/buildPullfrogFooter.ts
|
// utils/buildPullfrogFooter.ts
|
||||||
var PULLFROG_DIVIDER = "<!-- PULLFROG_DIVIDER_DO_NOT_REMOVE_PLZ -->";
|
var PULLFROG_DIVIDER = "<!-- PULLFROG_DIVIDER_DO_NOT_REMOVE_PLZ -->";
|
||||||
var FROG_LOGO = `<a href="https://pullfrog.com"><picture><source media="(prefers-color-scheme: dark)" srcset="https://pullfrog.com/logos/frog-white-full-18px.png"><img src="https://pullfrog.com/logos/frog-green-full-18px.png" width="9px" height="9px" style="vertical-align: middle; " alt="Pullfrog"></picture></a>`;
|
var FROG_LOGO = `<a href="https://pullfrog.com"><picture><source media="(prefers-color-scheme: dark)" srcset="https://pullfrog.com/logos/frog-white-full-18px.png"><img src="https://pullfrog.com/logos/frog-green-full-18px.png" width="9px" height="9px" style="vertical-align: middle; " alt="Pullfrog"></picture></a>`;
|
||||||
@@ -41235,7 +41267,7 @@ var Effort = type.enumerated("mini", "auto", "max");
|
|||||||
// package.json
|
// package.json
|
||||||
var package_default = {
|
var package_default = {
|
||||||
name: "@pullfrog/pullfrog",
|
name: "@pullfrog/pullfrog",
|
||||||
version: "0.0.170",
|
version: "0.0.175",
|
||||||
type: "module",
|
type: "module",
|
||||||
files: [
|
files: [
|
||||||
"index.js",
|
"index.js",
|
||||||
@@ -41350,7 +41382,7 @@ var JsonPayload = type({
|
|||||||
version: "string",
|
version: "string",
|
||||||
"agent?": AgentName.or("undefined"),
|
"agent?": AgentName.or("undefined"),
|
||||||
prompt: "string",
|
prompt: "string",
|
||||||
"triggeringUser?": "string | undefined",
|
"triggerer?": "string | undefined",
|
||||||
"eventInstructions?": "string",
|
"eventInstructions?": "string",
|
||||||
"repoInstructions?": "string",
|
"repoInstructions?": "string",
|
||||||
"event?": "object",
|
"event?": "object",
|
||||||
@@ -41403,15 +41435,25 @@ function getJobToken() {
|
|||||||
// utils/postCleanup.ts
|
// utils/postCleanup.ts
|
||||||
var SHOULD_CHECK_REASON = true;
|
var SHOULD_CHECK_REASON = true;
|
||||||
function buildErrorCommentBody(params) {
|
function buildErrorCommentBody(params) {
|
||||||
const workflowRunLink = params.runId ? `[workflow run logs](https://github.com/${params.owner}/${params.repo}/actions/runs/${params.runId})` : "workflow run logs";
|
let errorMessage = params.isCancellation ? `This run was cancelled \u{1F6D1}
|
||||||
const errorMessage = params.isCancellation ? `This run was cancelled \u{1F6D1}
|
|
||||||
|
|
||||||
The workflow was cancelled before completion. Please check the ${workflowRunLink} for details.` : `This run croaked \u{1F635}
|
The workflow was cancelled before completion.` : `This run croaked \u{1F635}
|
||||||
|
|
||||||
The workflow encountered an error before any progress could be reported. Please check the ${workflowRunLink} for details.`;
|
The workflow encountered an error before any progress could be reported.`;
|
||||||
|
if (params.runId) {
|
||||||
|
errorMessage += " Please check the link below for details.";
|
||||||
|
}
|
||||||
|
const customParts = [];
|
||||||
|
if (!params.isCancellation && params.runId) {
|
||||||
|
const apiUrl = getApiUrl();
|
||||||
|
customParts.push(
|
||||||
|
`[Rerun failed job \u2794](${apiUrl}/trigger/${params.owner}/${params.repo}/${params.runId}?action=rerun)`
|
||||||
|
);
|
||||||
|
}
|
||||||
const footer = buildPullfrogFooter({
|
const footer = buildPullfrogFooter({
|
||||||
triggeredBy: true,
|
triggeredBy: true,
|
||||||
workflowRun: params.runId ? { owner: params.owner, repo: params.repo, runId: params.runId } : void 0
|
workflowRun: params.runId ? { owner: params.owner, repo: params.repo, runId: params.runId } : void 0,
|
||||||
|
customParts
|
||||||
});
|
});
|
||||||
return `${errorMessage}${footer}`;
|
return `${errorMessage}${footer}`;
|
||||||
}
|
}
|
||||||
@@ -41441,12 +41483,12 @@ async function validateStuckProgressComment(params) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
async function getIsCancelled(params) {
|
async function getIsCancelled(params) {
|
||||||
if (!params.runIdStr) return false;
|
if (!params.runId) return false;
|
||||||
try {
|
try {
|
||||||
const jobsResult = await params.octokit.rest.actions.listJobsForWorkflowRun({
|
const jobsResult = await params.octokit.rest.actions.listJobsForWorkflowRun({
|
||||||
owner: params.repoContext.owner,
|
owner: params.repoContext.owner,
|
||||||
repo: params.repoContext.name,
|
repo: params.repoContext.name,
|
||||||
run_id: Number.parseInt(params.runIdStr, 10)
|
run_id: params.runId
|
||||||
});
|
});
|
||||||
const currentJobName = process.env.GITHUB_JOB;
|
const currentJobName = process.env.GITHUB_JOB;
|
||||||
const currentJob = currentJobName ? jobsResult.data.jobs.find(
|
const currentJob = currentJobName ? jobsResult.data.jobs.find(
|
||||||
@@ -41473,7 +41515,7 @@ async function getIsCancelled(params) {
|
|||||||
}
|
}
|
||||||
async function runPostCleanup() {
|
async function runPostCleanup() {
|
||||||
log.info("\xBB [post] starting post cleanup");
|
log.info("\xBB [post] starting post cleanup");
|
||||||
const runIdStr = process.env.GITHUB_RUN_ID;
|
const runId = process.env.GITHUB_RUN_ID ? Number.parseInt(process.env.GITHUB_RUN_ID, 10) : void 0;
|
||||||
let promptInput = null;
|
let promptInput = null;
|
||||||
try {
|
try {
|
||||||
const resolved = resolvePromptInput();
|
const resolved = resolvePromptInput();
|
||||||
@@ -41498,8 +41540,8 @@ async function runPostCleanup() {
|
|||||||
const body = buildErrorCommentBody({
|
const body = buildErrorCommentBody({
|
||||||
owner: repoContext.owner,
|
owner: repoContext.owner,
|
||||||
repo: repoContext.name,
|
repo: repoContext.name,
|
||||||
runId: runIdStr,
|
runId,
|
||||||
isCancellation: SHOULD_CHECK_REASON ? await getIsCancelled({ octokit, repoContext, runIdStr }) : false
|
isCancellation: SHOULD_CHECK_REASON ? await getIsCancelled({ octokit, repoContext, runId }) : false
|
||||||
});
|
});
|
||||||
await octokit.rest.issues.updateComment({
|
await octokit.rest.issues.updateComment({
|
||||||
owner: repoContext.owner,
|
owner: repoContext.owner,
|
||||||
|
|||||||
@@ -159,13 +159,16 @@ export const installNodeDependencies: PrepDefinition = {
|
|||||||
cmd: resolved.command,
|
cmd: resolved.command,
|
||||||
args: resolved.args,
|
args: resolved.args,
|
||||||
env: { PATH: process.env.PATH || "", HOME: process.env.HOME || "" },
|
env: { PATH: process.env.PATH || "", HOME: process.env.HOME || "" },
|
||||||
onStdout: (chunk) => process.stdout.write(chunk),
|
|
||||||
onStderr: (chunk) => process.stderr.write(chunk),
|
|
||||||
});
|
});
|
||||||
|
|
||||||
|
const output = [result.stdout, result.stderr].filter(Boolean).join("\n").trim();
|
||||||
|
if (output) {
|
||||||
|
log.startGroup(`${fullCommand} output`);
|
||||||
|
log.info(output);
|
||||||
|
log.endGroup();
|
||||||
|
}
|
||||||
|
|
||||||
if (result.exitCode !== 0) {
|
if (result.exitCode !== 0) {
|
||||||
// combine stdout and stderr for better error context (pnpm often outputs errors to stdout)
|
|
||||||
const output = [result.stdout, result.stderr].filter(Boolean).join("\n").trim();
|
|
||||||
const errorMessage = output || `exited with code ${result.exitCode}`;
|
const errorMessage = output || `exited with code ${result.exitCode}`;
|
||||||
return {
|
return {
|
||||||
language: "node",
|
language: "node",
|
||||||
|
|||||||
@@ -162,21 +162,28 @@ export const installPythonDependencies: PrepDefinition = {
|
|||||||
|
|
||||||
// run the install command
|
// run the install command
|
||||||
const [cmd, ...args] = config.installCmd;
|
const [cmd, ...args] = config.installCmd;
|
||||||
log.info(`» running: ${cmd} ${args.join(" ")}`);
|
const fullCommand = `${cmd} ${args.join(" ")}`;
|
||||||
|
log.info(`» running: ${fullCommand}`);
|
||||||
const result = await spawn({
|
const result = await spawn({
|
||||||
cmd,
|
cmd,
|
||||||
args,
|
args,
|
||||||
env: { PATH: process.env.PATH || "", HOME: process.env.HOME || "" },
|
env: { PATH: process.env.PATH || "", HOME: process.env.HOME || "" },
|
||||||
onStderr: (chunk) => process.stderr.write(chunk),
|
|
||||||
});
|
});
|
||||||
|
|
||||||
|
const output = [result.stdout, result.stderr].filter(Boolean).join("\n").trim();
|
||||||
|
if (output) {
|
||||||
|
log.startGroup(`${fullCommand} output`);
|
||||||
|
log.info(output);
|
||||||
|
log.endGroup();
|
||||||
|
}
|
||||||
|
|
||||||
if (result.exitCode !== 0) {
|
if (result.exitCode !== 0) {
|
||||||
return {
|
return {
|
||||||
language: "python",
|
language: "python",
|
||||||
packageManager: config.tool,
|
packageManager: config.tool,
|
||||||
configFile: config.file,
|
configFile: config.file,
|
||||||
dependenciesInstalled: false,
|
dependenciesInstalled: false,
|
||||||
issues: [result.stderr || `${cmd} exited with code ${result.exitCode}`],
|
issues: [output || `${cmd} exited with code ${result.exitCode}`],
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -65,6 +65,8 @@ const expectedAgentEnvVars = [
|
|||||||
"GITHUB_TOKEN",
|
"GITHUB_TOKEN",
|
||||||
...new Set(Object.values(agentsManifest).flatMap((a) => a.apiKeyNames)),
|
...new Set(Object.values(agentsManifest).flatMap((a) => a.apiKeyNames)),
|
||||||
"GEMINI_MODEL",
|
"GEMINI_MODEL",
|
||||||
|
"OPENCODE_MODEL_MAX",
|
||||||
|
"OPENCODE_MODEL_MINI",
|
||||||
"OPENCODE_MODEL",
|
"OPENCODE_MODEL",
|
||||||
].sort();
|
].sort();
|
||||||
|
|
||||||
|
|||||||
@@ -10,7 +10,7 @@ export interface AgentInfo {
|
|||||||
export interface WorkflowRunFooterInfo {
|
export interface WorkflowRunFooterInfo {
|
||||||
owner: string;
|
owner: string;
|
||||||
repo: string;
|
repo: string;
|
||||||
runId: string;
|
runId: number;
|
||||||
/** optional job ID - if provided, will append /job/{jobId} to the workflow run URL */
|
/** optional job ID - if provided, will append /job/{jobId} to the workflow run URL */
|
||||||
jobId?: string | undefined;
|
jobId?: string | undefined;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -120,6 +120,8 @@ const testEnvAllowList = new Set([
|
|||||||
"GOOGLE_GENERATIVE_AI_API_KEY",
|
"GOOGLE_GENERATIVE_AI_API_KEY",
|
||||||
"CURSOR_API_KEY",
|
"CURSOR_API_KEY",
|
||||||
"OPENCODE_MODEL", // override OpenCode model (e.g. google/gemini-3-flash-preview) for tests or user preference
|
"OPENCODE_MODEL", // override OpenCode model (e.g. google/gemini-3-flash-preview) for tests or user preference
|
||||||
|
"OPENCODE_MODEL_MINI", // effort-specific OpenCode model override for mini effort
|
||||||
|
"OPENCODE_MODEL_MAX", // effort-specific OpenCode model override for max effort
|
||||||
"GEMINI_MODEL", // override Gemini model (e.g. gemini-3-pro-preview) for tests or user preference
|
"GEMINI_MODEL", // override Gemini model (e.g. gemini-3-pro-preview) for tests or user preference
|
||||||
"LOG_LEVEL",
|
"LOG_LEVEL",
|
||||||
"DEBUG",
|
"DEBUG",
|
||||||
|
|||||||
+13
-2
@@ -1,4 +1,5 @@
|
|||||||
import type { ToolState } from "../mcp/server.ts";
|
import type { ToolState } from "../mcp/server.ts";
|
||||||
|
import { getApiUrl } from "./apiUrl.ts";
|
||||||
import { buildPullfrogFooter } from "./buildPullfrogFooter.ts";
|
import { buildPullfrogFooter } from "./buildPullfrogFooter.ts";
|
||||||
import { createOctokit, parseRepoContext } from "./github.ts";
|
import { createOctokit, parseRepoContext } from "./github.ts";
|
||||||
import { getGitHubInstallationToken } from "./token.ts";
|
import { getGitHubInstallationToken } from "./token.ts";
|
||||||
@@ -19,12 +20,22 @@ export async function reportErrorToComment(ctx: ReportErrorParams): Promise<void
|
|||||||
|
|
||||||
const repoContext = parseRepoContext();
|
const repoContext = parseRepoContext();
|
||||||
const octokit = createOctokit(getGitHubInstallationToken());
|
const octokit = createOctokit(getGitHubInstallationToken());
|
||||||
const runId = process.env.GITHUB_RUN_ID;
|
const runId = process.env.GITHUB_RUN_ID
|
||||||
|
? Number.parseInt(process.env.GITHUB_RUN_ID, 10)
|
||||||
|
: undefined;
|
||||||
|
|
||||||
|
const customParts: string[] = [];
|
||||||
|
if (runId) {
|
||||||
|
const apiUrl = getApiUrl();
|
||||||
|
customParts.push(
|
||||||
|
`[Rerun failed job ➔](${apiUrl}/trigger/${repoContext.owner}/${repoContext.name}/${runId}?action=rerun)`
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
// build footer with workflow run link
|
|
||||||
const footer = buildPullfrogFooter({
|
const footer = buildPullfrogFooter({
|
||||||
triggeredBy: true,
|
triggeredBy: true,
|
||||||
workflowRun: runId ? { owner: repoContext.owner, repo: repoContext.name, runId } : undefined,
|
workflowRun: runId ? { owner: repoContext.owner, repo: repoContext.name, runId } : undefined,
|
||||||
|
customParts,
|
||||||
});
|
});
|
||||||
|
|
||||||
await octokit.rest.issues.updateComment({
|
await octokit.rest.issues.updateComment({
|
||||||
|
|||||||
@@ -388,13 +388,13 @@ When all delegations are complete, call \`${ghPullfrogMcpName}/set_output\` with
|
|||||||
|
|
||||||
### Subagent capabilities
|
### Subagent capabilities
|
||||||
|
|
||||||
Subagents have: file operations, bash (for local git, tests, builds), read-only GitHub queries, and upload_file. They do NOT have: \`git\`, \`checkout_pr\`, \`push_branch\`, \`create_pull_request\`, \`create_pull_request_review\`, \`report_progress\`, \`create_issue_comment\`, \`reply_to_review_comment\`, \`resolve_review_thread\`, \`delegate\`, \`ask_question\`, or any dependency/remote-mutating tools. All GitHub-write and state-mutating operations are your responsibility.
|
Subagents have: file operations, shell (for local git, tests, builds), read-only GitHub queries, and upload_file. They do NOT have: \`git\`, \`checkout_pr\`, \`push_branch\`, \`create_pull_request\`, \`create_pull_request_review\`, \`report_progress\`, \`create_issue_comment\`, \`reply_to_review_comment\`, \`resolve_review_thread\`, \`delegate\`, \`ask_question\`, or any dependency/remote-mutating tools. All GitHub-write and state-mutating operations are your responsibility.
|
||||||
|
|
||||||
### Prompt-crafting rules
|
### Prompt-crafting rules
|
||||||
|
|
||||||
- Subagents have NO context beyond what you write. No repo instructions, no event data, no user prompt.
|
- Subagents have NO context beyond what you write. No repo instructions, no event data, no user prompt.
|
||||||
- Specify exactly what information the subagent should return. The subagent's \`set_output\` call is your only way to get results back — be precise about what you need.
|
- Specify exactly what information the subagent should return. The subagent's \`set_output\` call is your only way to get results back — be precise about what you need.
|
||||||
- Instruct subagents to use bash for local git (\`git add\`, \`git commit\`, \`git diff\`, \`git status\`).
|
- Instruct subagents to use shell for local git (\`git add\`, \`git commit\`, \`git diff\`, \`git status\`).
|
||||||
- Never instruct a subagent to push, create PRs, submit reviews, or post comments.
|
- Never instruct a subagent to push, create PRs, submit reviews, or post comments.
|
||||||
- For multi-phase flows, pass results from earlier phases into the next delegate call's prompts.
|
- For multi-phase flows, pass results from earlier phases into the next delegate call's prompts.
|
||||||
- You do NOT need to instruct subagents to call \`set_output\` — the system preamble handles this.
|
- You do NOT need to instruct subagents to call \`set_output\` — the system preamble handles this.
|
||||||
|
|||||||
+45
-12
@@ -2,11 +2,43 @@
|
|||||||
* Logging utilities that work well in both local and GitHub Actions environments
|
* Logging utilities that work well in both local and GitHub Actions environments
|
||||||
*/
|
*/
|
||||||
|
|
||||||
|
import { AsyncLocalStorage } from "node:async_hooks";
|
||||||
import * as core from "@actions/core";
|
import * as core from "@actions/core";
|
||||||
import { table } from "table";
|
import { table } from "table";
|
||||||
import type { AgentUsage } from "../agents/shared.ts";
|
import type { AgentUsage } from "../agents/shared.ts";
|
||||||
import { isGitHubActions, isInsideDocker } from "./globals.ts";
|
import { isGitHubActions, isInsideDocker } from "./globals.ts";
|
||||||
|
|
||||||
|
// --- subagent log prefix via AsyncLocalStorage ---
|
||||||
|
|
||||||
|
type LogContext = { prefix: string };
|
||||||
|
|
||||||
|
const logContext = new AsyncLocalStorage<LogContext>();
|
||||||
|
|
||||||
|
const MAGENTA = "\x1b[35m";
|
||||||
|
const RESET = "\x1b[0m";
|
||||||
|
|
||||||
|
/** run `fn` with every log line prefixed by `prefix` (e.g. "[task-label]") in magenta */
|
||||||
|
export function withLogPrefix<T>(prefix: string, fn: () => Promise<T>): Promise<T> {
|
||||||
|
return logContext.run({ prefix }, fn);
|
||||||
|
}
|
||||||
|
|
||||||
|
function prefixLines(message: string): string {
|
||||||
|
const ctx = logContext.getStore();
|
||||||
|
if (!ctx) return message;
|
||||||
|
const colored = `${MAGENTA}${ctx.prefix}${RESET} `;
|
||||||
|
return message
|
||||||
|
.split("\n")
|
||||||
|
.map((line) => `${colored}${line}`)
|
||||||
|
.join("\n");
|
||||||
|
}
|
||||||
|
|
||||||
|
/** plain-text prefix (no ANSI) for GitHub Actions group names */
|
||||||
|
function prefixPlain(name: string): string {
|
||||||
|
const ctx = logContext.getStore();
|
||||||
|
if (!ctx) return name;
|
||||||
|
return `${ctx.prefix} ${name}`;
|
||||||
|
}
|
||||||
|
|
||||||
const isRunnerDebugEnabled = () => core.isDebug();
|
const isRunnerDebugEnabled = () => core.isDebug();
|
||||||
|
|
||||||
const isLocalDebugEnabled = () =>
|
const isLocalDebugEnabled = () =>
|
||||||
@@ -36,10 +68,11 @@ function formatArgs(args: unknown[]): string {
|
|||||||
* Start a collapsed group (GitHub Actions) or regular group (local)
|
* Start a collapsed group (GitHub Actions) or regular group (local)
|
||||||
*/
|
*/
|
||||||
function startGroup(name: string): void {
|
function startGroup(name: string): void {
|
||||||
|
const prefixed = prefixPlain(name);
|
||||||
if (isGitHubActions) {
|
if (isGitHubActions) {
|
||||||
core.startGroup(name);
|
core.startGroup(prefixed);
|
||||||
} else {
|
} else {
|
||||||
console.group(name);
|
console.group(prefixed);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -153,7 +186,7 @@ function box(
|
|||||||
}
|
}
|
||||||
): void {
|
): void {
|
||||||
const boxContent = boxString(text, options);
|
const boxContent = boxString(text, options);
|
||||||
core.info(boxContent);
|
core.info(prefixLines(boxContent));
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@@ -200,9 +233,9 @@ function printTable(
|
|||||||
const formatted = table(tableData);
|
const formatted = table(tableData);
|
||||||
|
|
||||||
if (title) {
|
if (title) {
|
||||||
core.info(`\n${title}`);
|
core.info(prefixLines(`\n${title}`));
|
||||||
}
|
}
|
||||||
core.info(`\n${formatted}\n`);
|
core.info(prefixLines(`\n${formatted}\n`));
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@@ -210,7 +243,7 @@ function printTable(
|
|||||||
*/
|
*/
|
||||||
function separator(length: number = 50): void {
|
function separator(length: number = 50): void {
|
||||||
const separatorText = "─".repeat(length);
|
const separatorText = "─".repeat(length);
|
||||||
core.info(separatorText);
|
core.info(prefixLines(separatorText));
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@@ -219,32 +252,32 @@ function separator(length: number = 50): void {
|
|||||||
export const log = {
|
export const log = {
|
||||||
/** Print info message */
|
/** Print info message */
|
||||||
info: (...args: unknown[]): void => {
|
info: (...args: unknown[]): void => {
|
||||||
core.info(`${ts()}${formatArgs(args)}`);
|
core.info(prefixLines(`${ts()}${formatArgs(args)}`));
|
||||||
},
|
},
|
||||||
|
|
||||||
/** Print a warning message. Use only for warnings that should be displayed in the job summary. */
|
/** Print a warning message. Use only for warnings that should be displayed in the job summary. */
|
||||||
warning: (...args: unknown[]): void => {
|
warning: (...args: unknown[]): void => {
|
||||||
core.warning(`${ts()}${formatArgs(args)}`);
|
core.warning(prefixLines(`${ts()}${formatArgs(args)}`));
|
||||||
},
|
},
|
||||||
|
|
||||||
/** Print an error message. Use only for errors that should be displayed in the job summary. */
|
/** Print an error message. Use only for errors that should be displayed in the job summary. */
|
||||||
error: (...args: unknown[]): void => {
|
error: (...args: unknown[]): void => {
|
||||||
core.error(`${ts()}${formatArgs(args)}`);
|
core.error(prefixLines(`${ts()}${formatArgs(args)}`));
|
||||||
},
|
},
|
||||||
|
|
||||||
/** Print success message */
|
/** Print success message */
|
||||||
success: (...args: unknown[]): void => {
|
success: (...args: unknown[]): void => {
|
||||||
core.info(`${ts()}» ${formatArgs(args)}`);
|
core.info(prefixLines(`${ts()}» ${formatArgs(args)}`));
|
||||||
},
|
},
|
||||||
|
|
||||||
/** Print debug message (only when debug mode is enabled) */
|
/** Print debug message (only when debug mode is enabled) */
|
||||||
debug: (...args: unknown[]): void => {
|
debug: (...args: unknown[]): void => {
|
||||||
if (isRunnerDebugEnabled()) {
|
if (isRunnerDebugEnabled()) {
|
||||||
core.debug(formatArgs(args));
|
core.debug(prefixLines(formatArgs(args)));
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
if (isLocalDebugEnabled()) {
|
if (isLocalDebugEnabled()) {
|
||||||
core.info(`${ts()}[DEBUG] ${formatArgs(args)}`);
|
core.info(prefixLines(`${ts()}[DEBUG] ${formatArgs(args)}`));
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
|
||||||
|
|||||||
+4
-3
@@ -19,7 +19,8 @@ export const JsonPayload = type({
|
|||||||
version: "string",
|
version: "string",
|
||||||
"agent?": AgentName.or("undefined"),
|
"agent?": AgentName.or("undefined"),
|
||||||
prompt: "string",
|
prompt: "string",
|
||||||
"triggeringUser?": "string | undefined",
|
"triggerer?": "string | undefined",
|
||||||
|
|
||||||
"eventInstructions?": "string",
|
"eventInstructions?": "string",
|
||||||
"repoInstructions?": "string",
|
"repoInstructions?": "string",
|
||||||
"event?": "object",
|
"event?": "object",
|
||||||
@@ -167,8 +168,8 @@ export function resolvePayload(
|
|||||||
version: jsonPayload?.version ?? packageJson.version,
|
version: jsonPayload?.version ?? packageJson.version,
|
||||||
agent: resolvedAgent,
|
agent: resolvedAgent,
|
||||||
prompt,
|
prompt,
|
||||||
triggeringUser:
|
triggerer:
|
||||||
jsonPayload?.triggeringUser ??
|
jsonPayload?.triggerer ??
|
||||||
// it's not a common use case but GITHUB_ACTOR can be a user when the workflow is manually triggered by a user through GitHub Actions UI
|
// it's not a common use case but GITHUB_ACTOR can be a user when the workflow is manually triggered by a user through GitHub Actions UI
|
||||||
(!isPullfrog(process.env.GITHUB_ACTOR) ? process.env.GITHUB_ACTOR : undefined),
|
(!isPullfrog(process.env.GITHUB_ACTOR) ? process.env.GITHUB_ACTOR : undefined),
|
||||||
eventInstructions: jsonPayload?.eventInstructions,
|
eventInstructions: jsonPayload?.eventInstructions,
|
||||||
|
|||||||
+26
-13
@@ -1,4 +1,5 @@
|
|||||||
import { LEAPING_INTO_ACTION_PREFIX } from "../mcp/comment.ts";
|
import { LEAPING_INTO_ACTION_PREFIX } from "../mcp/comment.ts";
|
||||||
|
import { getApiUrl } from "./apiUrl.ts";
|
||||||
import { buildPullfrogFooter } from "./buildPullfrogFooter.ts";
|
import { buildPullfrogFooter } from "./buildPullfrogFooter.ts";
|
||||||
import { log } from "./cli.ts";
|
import { log } from "./cli.ts";
|
||||||
import { createOctokit, parseRepoContext } from "./github.ts";
|
import { createOctokit, parseRepoContext } from "./github.ts";
|
||||||
@@ -15,22 +16,32 @@ const SHOULD_CHECK_REASON = true;
|
|||||||
type BuildErrorCommentBodyParams = {
|
type BuildErrorCommentBodyParams = {
|
||||||
owner: string;
|
owner: string;
|
||||||
repo: string;
|
repo: string;
|
||||||
runId: string | undefined;
|
runId: number | undefined;
|
||||||
isCancellation: boolean;
|
isCancellation: boolean;
|
||||||
};
|
};
|
||||||
|
|
||||||
function buildErrorCommentBody(params: BuildErrorCommentBodyParams): string {
|
function buildErrorCommentBody(params: BuildErrorCommentBodyParams): string {
|
||||||
const workflowRunLink = params.runId
|
let errorMessage = params.isCancellation
|
||||||
? `[workflow run logs](https://github.com/${params.owner}/${params.repo}/actions/runs/${params.runId})`
|
? `This run was cancelled 🛑\n\nThe workflow was cancelled before completion.`
|
||||||
: "workflow run logs";
|
: `This run croaked 😵\n\nThe workflow encountered an error before any progress could be reported.`;
|
||||||
const errorMessage = params.isCancellation
|
|
||||||
? `This run was cancelled 🛑\n\nThe workflow was cancelled before completion. Please check the ${workflowRunLink} for details.`
|
if (params.runId) {
|
||||||
: `This run croaked 😵\n\nThe workflow encountered an error before any progress could be reported. Please check the ${workflowRunLink} for details.`;
|
errorMessage += " Please check the link below for details.";
|
||||||
|
}
|
||||||
|
|
||||||
|
const customParts: string[] = [];
|
||||||
|
if (!params.isCancellation && params.runId) {
|
||||||
|
const apiUrl = getApiUrl();
|
||||||
|
customParts.push(
|
||||||
|
`[Rerun failed job ➔](${apiUrl}/trigger/${params.owner}/${params.repo}/${params.runId}?action=rerun)`
|
||||||
|
);
|
||||||
|
}
|
||||||
const footer = buildPullfrogFooter({
|
const footer = buildPullfrogFooter({
|
||||||
triggeredBy: true,
|
triggeredBy: true,
|
||||||
workflowRun: params.runId
|
workflowRun: params.runId
|
||||||
? { owner: params.owner, repo: params.repo, runId: params.runId }
|
? { owner: params.owner, repo: params.repo, runId: params.runId }
|
||||||
: undefined,
|
: undefined,
|
||||||
|
customParts,
|
||||||
});
|
});
|
||||||
return `${errorMessage}${footer}`;
|
return `${errorMessage}${footer}`;
|
||||||
}
|
}
|
||||||
@@ -76,16 +87,16 @@ async function validateStuckProgressComment(
|
|||||||
type GetIsCancelledParams = {
|
type GetIsCancelledParams = {
|
||||||
repoContext: ReturnType<typeof parseRepoContext>;
|
repoContext: ReturnType<typeof parseRepoContext>;
|
||||||
octokit: ReturnType<typeof createOctokit>;
|
octokit: ReturnType<typeof createOctokit>;
|
||||||
runIdStr: string | undefined;
|
runId: number | undefined;
|
||||||
};
|
};
|
||||||
|
|
||||||
async function getIsCancelled(params: GetIsCancelledParams): Promise<boolean> {
|
async function getIsCancelled(params: GetIsCancelledParams): Promise<boolean> {
|
||||||
if (!params.runIdStr) return false; // can't check without a run ID — assume failure
|
if (!params.runId) return false; // can't check without a run ID — assume failure
|
||||||
try {
|
try {
|
||||||
const jobsResult = await params.octokit.rest.actions.listJobsForWorkflowRun({
|
const jobsResult = await params.octokit.rest.actions.listJobsForWorkflowRun({
|
||||||
owner: params.repoContext.owner,
|
owner: params.repoContext.owner,
|
||||||
repo: params.repoContext.name,
|
repo: params.repoContext.name,
|
||||||
run_id: Number.parseInt(params.runIdStr, 10),
|
run_id: params.runId,
|
||||||
});
|
});
|
||||||
|
|
||||||
// find current job by matching GITHUB_JOB env var.
|
// find current job by matching GITHUB_JOB env var.
|
||||||
@@ -125,7 +136,9 @@ async function getIsCancelled(params: GetIsCancelledParams): Promise<boolean> {
|
|||||||
export async function runPostCleanup(): Promise<void> {
|
export async function runPostCleanup(): Promise<void> {
|
||||||
log.info("» [post] starting post cleanup");
|
log.info("» [post] starting post cleanup");
|
||||||
|
|
||||||
const runIdStr = process.env.GITHUB_RUN_ID;
|
const runId = process.env.GITHUB_RUN_ID
|
||||||
|
? Number.parseInt(process.env.GITHUB_RUN_ID, 10)
|
||||||
|
: undefined;
|
||||||
|
|
||||||
// resolve prompt input once and use it for both issue number and comment ID extraction
|
// resolve prompt input once and use it for both issue number and comment ID extraction
|
||||||
// only use the object form (JSON payload), not plain string prompts
|
// only use the object form (JSON payload), not plain string prompts
|
||||||
@@ -159,9 +172,9 @@ export async function runPostCleanup(): Promise<void> {
|
|||||||
const body = buildErrorCommentBody({
|
const body = buildErrorCommentBody({
|
||||||
owner: repoContext.owner,
|
owner: repoContext.owner,
|
||||||
repo: repoContext.name,
|
repo: repoContext.name,
|
||||||
runId: runIdStr,
|
runId,
|
||||||
isCancellation: SHOULD_CHECK_REASON
|
isCancellation: SHOULD_CHECK_REASON
|
||||||
? await getIsCancelled({ octokit, repoContext, runIdStr })
|
? await getIsCancelled({ octokit, repoContext, runId })
|
||||||
: false,
|
: false,
|
||||||
});
|
});
|
||||||
|
|
||||||
|
|||||||
+29
-5
@@ -1,13 +1,37 @@
|
|||||||
import type { AgentResult } from "../agents/shared.ts";
|
import type { AgentResult } from "../agents/shared.ts";
|
||||||
import type { MainResult } from "../main.ts";
|
import type { MainResult } from "../main.ts";
|
||||||
|
import type { ToolState } from "../mcp/server.ts";
|
||||||
import { log } from "./cli.ts";
|
import { log } from "./cli.ts";
|
||||||
|
import { reportErrorToComment } from "./errorReport.ts";
|
||||||
|
|
||||||
export function handleAgentResult(result: AgentResult): MainResult {
|
export interface HandleAgentResultParams {
|
||||||
if (!result.success) {
|
result: AgentResult;
|
||||||
|
toolState: ToolState;
|
||||||
|
silent: boolean | undefined;
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function handleAgentResult(ctx: HandleAgentResultParams): Promise<MainResult> {
|
||||||
|
if (!ctx.result.success) {
|
||||||
return {
|
return {
|
||||||
success: false,
|
success: false,
|
||||||
error: result.error || "Agent execution failed",
|
error: ctx.result.error || "Agent execution failed",
|
||||||
output: result.output!,
|
output: ctx.result.output!,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!ctx.toolState.wasUpdated && ctx.toolState.progressCommentId && !ctx.silent) {
|
||||||
|
const error = ctx.result.error || "agent completed without reporting progress";
|
||||||
|
try {
|
||||||
|
await reportErrorToComment({
|
||||||
|
toolState: ctx.toolState,
|
||||||
|
error,
|
||||||
|
title: "Error",
|
||||||
|
});
|
||||||
|
} catch {}
|
||||||
|
return {
|
||||||
|
success: false,
|
||||||
|
error,
|
||||||
|
output: ctx.result.output || "",
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -15,6 +39,6 @@ export function handleAgentResult(result: AgentResult): MainResult {
|
|||||||
|
|
||||||
return {
|
return {
|
||||||
success: true,
|
success: true,
|
||||||
output: result.output || "",
|
output: ctx.result.output || "",
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|||||||
+5
-3
@@ -6,7 +6,7 @@ interface ResolveRunParams {
|
|||||||
}
|
}
|
||||||
|
|
||||||
export interface ResolveRunResult {
|
export interface ResolveRunResult {
|
||||||
runId: string;
|
runId: number | undefined;
|
||||||
jobId: string | undefined;
|
jobId: string | undefined;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -15,7 +15,9 @@ export interface ResolveRunResult {
|
|||||||
* Uses GITHUB_REPOSITORY and GITHUB_RUN_ID env vars.
|
* Uses GITHUB_REPOSITORY and GITHUB_RUN_ID env vars.
|
||||||
*/
|
*/
|
||||||
export async function resolveRun(params: ResolveRunParams): Promise<ResolveRunResult> {
|
export async function resolveRun(params: ResolveRunParams): Promise<ResolveRunResult> {
|
||||||
const runId = process.env.GITHUB_RUN_ID || "";
|
const runId = process.env.GITHUB_RUN_ID
|
||||||
|
? Number.parseInt(process.env.GITHUB_RUN_ID, 10)
|
||||||
|
: undefined;
|
||||||
const githubRepo = process.env.GITHUB_REPOSITORY;
|
const githubRepo = process.env.GITHUB_REPOSITORY;
|
||||||
if (!githubRepo || !githubRepo.includes("/")) {
|
if (!githubRepo || !githubRepo.includes("/")) {
|
||||||
throw new Error(`GITHUB_REPOSITORY env var must be set to "owner/repo", got: ${githubRepo}`);
|
throw new Error(`GITHUB_REPOSITORY env var must be set to "owner/repo", got: ${githubRepo}`);
|
||||||
@@ -28,7 +30,7 @@ export async function resolveRun(params: ResolveRunParams): Promise<ResolveRunRe
|
|||||||
const jobs = await params.octokit.rest.actions.listJobsForWorkflowRun({
|
const jobs = await params.octokit.rest.actions.listJobsForWorkflowRun({
|
||||||
owner,
|
owner,
|
||||||
repo,
|
repo,
|
||||||
run_id: parseInt(runId, 10),
|
run_id: runId,
|
||||||
});
|
});
|
||||||
const matchingJob = jobs.data.jobs.find((job) => job.name === jobName);
|
const matchingJob = jobs.data.jobs.find((job) => job.name === jobName);
|
||||||
if (matchingJob) {
|
if (matchingJob) {
|
||||||
|
|||||||
Reference in New Issue
Block a user