Compare commits
18 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| de686da001 | |||
| c456fae716 | |||
| 20b08b5321 | |||
| c0fd69560f | |||
| e0bd984975 | |||
| c4d66bf7f6 | |||
| 1d2a06998c | |||
| 6311138132 | |||
| 1ed3da8273 | |||
| d5ab3706db | |||
| f8a871f723 | |||
| 52ec35790a | |||
| edd240f535 | |||
| cd1ea5267c | |||
| 4f1e4a2e7a | |||
| 73836d9c8f | |||
| da72d0d6ee | |||
| b472aa1ba9 |
@@ -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();
|
||||||
|
|||||||
+8
-5
@@ -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");
|
||||||
|
|||||||
+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, shell, 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.shell === "restricted") {
|
|
||||||
tools.push(ShellTool(ctx));
|
|
||||||
tools.push(KillBackgroundTool(ctx));
|
|
||||||
}
|
|
||||||
|
|
||||||
return tools;
|
|
||||||
}
|
|
||||||
|
|
||||||
// orchestrator gets everything: file ops, shell, 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");
|
||||||
|
|||||||
+36
-33
@@ -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 = {
|
||||||
@@ -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();
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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)
|
||||||
\`\`\`
|
\`\`\`
|
||||||
@@ -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.171",
|
"version": "0.0.174",
|
||||||
"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.171",
|
version: "0.0.174",
|
||||||
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({
|
||||||
|
|||||||
+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