Compare commits
19 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| 7c8dd7f43c | |||
| 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_MODEL: ${{ vars.GEMINI_MODEL }}
|
||||
OPENCODE_MODEL: ${{ vars.OPENCODE_MODEL }}
|
||||
OPENCODE_MODEL_MINI: ${{ vars.OPENCODE_MODEL_MINI }}
|
||||
OPENCODE_MODEL_MAX: ${{ vars.OPENCODE_MODEL_MAX }}
|
||||
steps:
|
||||
- uses: actions/checkout@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">
|
||||
<h1 align="center">
|
||||
<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 tool permissions should be reflected in wiki/granular-tools.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 { performance } from "node:perf_hooks";
|
||||
import { ghPullfrogMcpName } from "../external.ts";
|
||||
@@ -38,6 +38,159 @@ function detectProviderError(text: string): string | 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> {
|
||||
return await installFromNpmTarball({
|
||||
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).
|
||||
const args = ["run", ctx.instructions.full, "--format", "json", "--print-logs"];
|
||||
|
||||
// only override model when OPENCODE_MODEL is set (e.g., test environments with
|
||||
// restricted API quotas). in production, OpenCode auto-selects the best available
|
||||
// model based on which provider API keys are present.
|
||||
const modelOverride = process.env.OPENCODE_MODEL;
|
||||
// resolve model override from environment.
|
||||
// precedence:
|
||||
// 1) effort-specific overrides (OPENCODE_MODEL_MINI / OPENCODE_MODEL_MAX)
|
||||
// 2) OPENCODE_MODEL fallback
|
||||
// 3) OpenCode auto-select
|
||||
const modelOverride = resolveModelOverride({
|
||||
effort: ctx.payload.effort,
|
||||
env: process.env,
|
||||
});
|
||||
if (modelOverride) {
|
||||
args.push("--model", modelOverride);
|
||||
log.info(`» model: ${modelOverride} (override)`);
|
||||
args.push("--model", modelOverride.model);
|
||||
log.info(`» model: ${modelOverride.model} (override via ${modelOverride.source})`);
|
||||
} else {
|
||||
log.info(`» model: auto-selected by OpenCode`);
|
||||
}
|
||||
@@ -89,6 +247,31 @@ export const opencode = agent({
|
||||
GOOGLE_GENERATIVE_AI_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
|
||||
delete env.GITHUB_TOKEN;
|
||||
|
||||
@@ -194,7 +377,7 @@ export const opencode = agent({
|
||||
lastProviderError = providerError;
|
||||
log.info(`» provider error detected (${providerError}): ${trimmed.substring(0, 500)}`);
|
||||
} 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
|
||||
// call logs in the GitHub Actions step output.
|
||||
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 {
|
||||
success: true,
|
||||
output: finalOutput || output,
|
||||
@@ -301,28 +493,41 @@ function configureOpenCode(ctx: AgentRunContext): void {
|
||||
const configDir = join(ctx.tmpdir, ".config", "opencode");
|
||||
mkdirSync(configDir, { recursive: true });
|
||||
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
|
||||
const opencodeMcpServers = {
|
||||
[ghPullfrogMcpName]: { type: "remote" as const, url: ctx.mcpServerUrl },
|
||||
};
|
||||
const opencodeMcpServers: Record<string, unknown> = {};
|
||||
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
|
||||
// note: OpenCode has no built-in web search tool
|
||||
const shell = ctx.payload.shell;
|
||||
const permission = {
|
||||
edit: "deny",
|
||||
read: "deny",
|
||||
bash: shell !== "enabled" ? "deny" : "allow",
|
||||
webfetch: ctx.payload.web === "disabled" ? "deny" : "allow",
|
||||
external_directory: "deny",
|
||||
};
|
||||
const permission: Record<string, unknown> = {};
|
||||
const repoPermission = getRecordProperty({ value: repoConfig, key: "permission" });
|
||||
if (repoPermission) {
|
||||
Object.assign(permission, repoPermission);
|
||||
}
|
||||
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
|
||||
const config = {
|
||||
mcp: opencodeMcpServers,
|
||||
permission,
|
||||
};
|
||||
const config: OpenCodeConfig = {};
|
||||
if (repoConfig) {
|
||||
Object.assign(config, repoConfig);
|
||||
}
|
||||
config.mcp = opencodeMcpServers;
|
||||
config.permission = permission;
|
||||
|
||||
const configJson = JSON.stringify(config, null, 2);
|
||||
try {
|
||||
|
||||
@@ -4,9 +4,15 @@
|
||||
* entry point for pullfrog/pullfrog - unified action
|
||||
*/
|
||||
|
||||
import { dirname } from "node:path";
|
||||
import * as core from "@actions/core";
|
||||
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> {
|
||||
try {
|
||||
const result = await main();
|
||||
|
||||
+3
-3
@@ -229,8 +229,8 @@ interface FixReviewEvent extends BasePayloadEvent {
|
||||
issue_number: number;
|
||||
is_pr: true;
|
||||
review_id: number;
|
||||
/** username of the person who triggered this action - use with get_review_comments approved_by */
|
||||
triggerer: string;
|
||||
/** when true, only address comments the triggerer approved with 👍 (vs all comments) */
|
||||
approved_only?: boolean | undefined;
|
||||
}
|
||||
|
||||
interface ImplementPlanEvent extends BasePayloadEvent {
|
||||
@@ -273,7 +273,7 @@ export interface WriteablePayload {
|
||||
/** the user's actual request (body if @pullfrog tagged) */
|
||||
prompt: string;
|
||||
/** 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) */
|
||||
eventInstructions?: string | undefined;
|
||||
/** repo-level instructions (flag-expanded server-side) */
|
||||
|
||||
@@ -25507,6 +25507,7 @@ var core3 = __toESM(require_core(), 1);
|
||||
// utils/log.ts
|
||||
var core = __toESM(require_core(), 1);
|
||||
var import_table = __toESM(require_src(), 1);
|
||||
import { AsyncLocalStorage } from "node:async_hooks";
|
||||
|
||||
// utils/globals.ts
|
||||
import { existsSync } from "node:fs";
|
||||
@@ -25515,6 +25516,20 @@ var isGitHubActions = !!process.env.GITHUB_ACTIONS;
|
||||
var isInsideDocker = existsSync("/.dockerenv");
|
||||
|
||||
// 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 isLocalDebugEnabled = () => process.env.LOG_LEVEL === "debug" || process.env.ACTIONS_STEP_DEBUG === "true";
|
||||
var isDebugEnabled = () => isLocalDebugEnabled() || isRunnerDebugEnabled();
|
||||
@@ -25530,10 +25545,11 @@ ${arg.stack}`;
|
||||
}).join(" ");
|
||||
}
|
||||
function startGroup2(name) {
|
||||
const prefixed = prefixPlain(name);
|
||||
if (isGitHubActions) {
|
||||
core.startGroup(name);
|
||||
core.startGroup(prefixed);
|
||||
} else {
|
||||
console.group(name);
|
||||
console.group(prefixed);
|
||||
}
|
||||
}
|
||||
function endGroup2() {
|
||||
@@ -25606,7 +25622,7 @@ function boxString(text, options) {
|
||||
}
|
||||
function box(text, options) {
|
||||
const boxContent = boxString(text, options);
|
||||
core.info(boxContent);
|
||||
core.info(prefixLines(boxContent));
|
||||
}
|
||||
function printTable(rows, options) {
|
||||
const { title } = options || {};
|
||||
@@ -25620,42 +25636,42 @@ function printTable(rows, options) {
|
||||
);
|
||||
const formatted = (0, import_table.table)(tableData);
|
||||
if (title) {
|
||||
core.info(`
|
||||
${title}`);
|
||||
core.info(prefixLines(`
|
||||
${title}`));
|
||||
}
|
||||
core.info(`
|
||||
core.info(prefixLines(`
|
||||
${formatted}
|
||||
`);
|
||||
`));
|
||||
}
|
||||
function separator(length = 50) {
|
||||
const separatorText = "\u2500".repeat(length);
|
||||
core.info(separatorText);
|
||||
core.info(prefixLines(separatorText));
|
||||
}
|
||||
var log = {
|
||||
/** Print info message */
|
||||
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. */
|
||||
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. */
|
||||
error: (...args) => {
|
||||
core.error(`${ts()}${formatArgs(args)}`);
|
||||
core.error(prefixLines(`${ts()}${formatArgs(args)}`));
|
||||
},
|
||||
/** Print success message */
|
||||
success: (...args) => {
|
||||
core.info(`${ts()}\xBB ${formatArgs(args)}`);
|
||||
core.info(prefixLines(`${ts()}\xBB ${formatArgs(args)}`));
|
||||
},
|
||||
/** Print debug message (only when debug mode is enabled) */
|
||||
debug: (...args) => {
|
||||
if (isRunnerDebugEnabled()) {
|
||||
core.debug(formatArgs(args));
|
||||
core.debug(prefixLines(formatArgs(args)));
|
||||
return;
|
||||
}
|
||||
if (isLocalDebugEnabled()) {
|
||||
core.info(`${ts()}[DEBUG] ${formatArgs(args)}`);
|
||||
core.info(prefixLines(`${ts()}[DEBUG] ${formatArgs(args)}`));
|
||||
}
|
||||
},
|
||||
/** 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")}`);
|
||||
}
|
||||
|
||||
const mainResult = await handleAgentResult({
|
||||
result,
|
||||
toolState,
|
||||
silent: payload.event.silent ?? false,
|
||||
});
|
||||
|
||||
return {
|
||||
...handleAgentResult(result),
|
||||
...mainResult,
|
||||
result: toolState.output,
|
||||
};
|
||||
} catch (error) {
|
||||
|
||||
@@ -1,11 +1,40 @@
|
||||
// Vitest Snapshot v1, https://vitest.dev/guide/snapshot.html
|
||||
|
||||
exports[`formatReviewThreads > formats thread blocks with TOC and correct line numbers > content 1`] = `
|
||||
"# Review Threads (1) for PR #49 - Review 3485940013 by cursor
|
||||
exports[`getFormattedReviewThreads > formats body-only review > content 1`] = `
|
||||
"# 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
|
||||
|
||||
- .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,
|
||||
}: BuildCommentFooterParams): Promise<string> {
|
||||
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;
|
||||
if (runId && octokit) {
|
||||
@@ -34,7 +36,7 @@ async function buildCommentFooter({
|
||||
const { data: jobs } = await octokit.rest.actions.listJobsForWorkflowRun({
|
||||
owner: repoContext.owner,
|
||||
repo: repoContext.name,
|
||||
run_id: parseInt(runId, 10),
|
||||
run_id: runId,
|
||||
});
|
||||
// use the first job's ID available
|
||||
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
|
||||
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 effort = task.effort ?? "auto";
|
||||
@@ -100,10 +103,10 @@ export function DelegateTool(ctx: ToolContext) {
|
||||
const results: DelegateTaskResult[] = taskEntries.map((entry, i) => {
|
||||
const outcome = settled[i];
|
||||
const error = outcome.status === "rejected" ? String(outcome.reason) : outcome.value.error;
|
||||
log.debug(
|
||||
`» task "${entry.task.label}" result: output=${entry.subagent.output !== undefined}, status=${entry.subagent.status}`
|
||||
);
|
||||
return buildTaskResult(entry.task.label, entry.effort, entry.subagent, error);
|
||||
const result = buildTaskResult(entry.task.label, entry.effort, entry.subagent, error);
|
||||
const status = result.success ? "succeeded" : "failed";
|
||||
log.box(result.summary, { title: `task "${entry.task.label}" ${status}` });
|
||||
return result;
|
||||
});
|
||||
|
||||
const succeeded = results.filter((r) => r.success).length;
|
||||
|
||||
+25
-9
@@ -138,10 +138,26 @@ export function PushBranchTool(ctx: ToolContext) {
|
||||
if (force) {
|
||||
log.warning(`force pushing - this will overwrite remote history`);
|
||||
}
|
||||
$git("push", pushArgs, {
|
||||
token: ctx.gitToken,
|
||||
restricted: ctx.payload.shell !== "enabled",
|
||||
});
|
||||
|
||||
try {
|
||||
$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 {
|
||||
success: true,
|
||||
@@ -157,10 +173,10 @@ export function PushBranchTool(ctx: ToolContext) {
|
||||
|
||||
// commands that require authentication - redirect to dedicated tools
|
||||
const AUTH_REQUIRED_REDIRECT: Record<string, string> = {
|
||||
push: "Use push_branch tool instead.",
|
||||
fetch: "Use git_fetch tool instead.",
|
||||
pull: "Use git_fetch + git merge instead.",
|
||||
clone: "Repository already cloned. Use checkout_pr for PR branches.",
|
||||
push: "use the push_branch tool instead — it handles authentication and permission checks.",
|
||||
fetch: "use the git_fetch tool instead — it handles authentication.",
|
||||
pull: "use git_fetch to fetch the remote ref, then use this git tool with subcommand 'merge' or 'rebase' locally.",
|
||||
clone: "the repository is already cloned. use checkout_pr for PR branches.",
|
||||
};
|
||||
|
||||
// SECURITY: subcommands blocked when shell is disabled.
|
||||
@@ -219,7 +235,7 @@ export function GitTool(ctx: ToolContext) {
|
||||
|
||||
const redirect = AUTH_REQUIRED_REDIRECT[subcommand];
|
||||
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.
|
||||
|
||||
@@ -9,6 +9,9 @@ export const PullRequest = type({
|
||||
title: type.string.describe("the title 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')"),
|
||||
"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 {
|
||||
@@ -71,10 +74,11 @@ export function CreatePullRequestTool(ctx: ToolContext) {
|
||||
body: bodyWithFooter,
|
||||
head: currentBranch,
|
||||
base: params.base,
|
||||
draft: params.draft ?? false,
|
||||
});
|
||||
|
||||
// best-effort: request review from the user who triggered the workflow
|
||||
const reviewer = ctx.payload.triggeringUser;
|
||||
const reviewer = ctx.payload.triggerer;
|
||||
if (reviewer) {
|
||||
try {
|
||||
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."
|
||||
)
|
||||
.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
|
||||
.describe("Optional SHA of the commit being reviewed. Defaults to latest.")
|
||||
.optional(),
|
||||
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(
|
||||
"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. " +
|
||||
"Use 'suggestion' to propose replacement code - MUST preserve exact indentation of original code. " +
|
||||
"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,
|
||||
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)
|
||||
ctx.toolState.issueNumber = pull_number;
|
||||
|
||||
@@ -68,7 +78,7 @@ export function CreatePullRequestReviewTool(ctx: ToolContext) {
|
||||
owner: ctx.repo.owner,
|
||||
repo: ctx.repo.name,
|
||||
pull_number,
|
||||
event: "COMMENT",
|
||||
event: approved ? "APPROVE" : "COMMENT",
|
||||
};
|
||||
if (body) params.body = body;
|
||||
if (commit_id) {
|
||||
@@ -115,20 +125,28 @@ export function CreatePullRequestReviewTool(ctx: ToolContext) {
|
||||
// build quick links footer and update the review body
|
||||
// only include "Fix all" and "Fix 👍s" links if there are actual review comments
|
||||
const customParts: string[] = [];
|
||||
if (comments.length > 0) {
|
||||
const apiUrl = getApiUrl();
|
||||
const fixAllUrl = `${apiUrl}/trigger/${ctx.repo.owner}/${ctx.repo.name}/${pull_number}?action=fix&review_id=${reviewId}`;
|
||||
const fixApprovedUrl = `${apiUrl}/trigger/${ctx.repo.owner}/${ctx.repo.name}/${pull_number}?action=fix-approved&review_id=${reviewId}`;
|
||||
customParts.push(`[Fix all ➔](${fixAllUrl})`, `[Fix 👍s ➔](${fixApprovedUrl})`);
|
||||
if (!approved) {
|
||||
if (comments.length > 0) {
|
||||
const apiUrl = getApiUrl();
|
||||
const fixAllUrl = `${apiUrl}/trigger/${ctx.repo.owner}/${ctx.repo.name}/${pull_number}?action=fix&review_id=${reviewId}`;
|
||||
const fixApprovedUrl = `${apiUrl}/trigger/${ctx.repo.owner}/${ctx.repo.name}/${pull_number}?action=fix-approved&review_id=${reviewId}`;
|
||||
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({
|
||||
workflowRun: {
|
||||
owner: ctx.repo.owner,
|
||||
repo: ctx.repo.name,
|
||||
runId: ctx.runId,
|
||||
jobId: ctx.jobId,
|
||||
},
|
||||
workflowRun: ctx.runId
|
||||
? {
|
||||
owner: ctx.repo.owner,
|
||||
repo: ctx.repo.name,
|
||||
runId: ctx.runId,
|
||||
jobId: ctx.jobId,
|
||||
}
|
||||
: undefined,
|
||||
customParts,
|
||||
});
|
||||
|
||||
|
||||
+22
-39
@@ -1,60 +1,43 @@
|
||||
import { Octokit } from "@octokit/rest";
|
||||
import { describe, expect, it } from "vitest";
|
||||
import { acquireNewToken } from "../utils/github.ts";
|
||||
import {
|
||||
buildThreadBlocks,
|
||||
formatReviewThreads,
|
||||
type ParsedHunk,
|
||||
parseFilePatches,
|
||||
REVIEW_THREADS_QUERY,
|
||||
type ReviewThread,
|
||||
type ReviewThreadsQueryResponse,
|
||||
} from "./reviewComments.ts";
|
||||
import { getReviewData } from "./reviewComments.ts";
|
||||
|
||||
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;
|
||||
return await acquireNewToken();
|
||||
}
|
||||
|
||||
describe("formatReviewThreads", () => {
|
||||
describe("getFormattedReviewThreads", () => {
|
||||
it("formats thread blocks with TOC and correct line numbers", { timeout: 30000 }, async () => {
|
||||
const token = await getToken();
|
||||
const octokit = new Octokit({ auth: token });
|
||||
const pullNumber = 49;
|
||||
const reviewId = 3485940013;
|
||||
|
||||
// fetch review threads via GraphQL
|
||||
const response = await octokit.graphql<ReviewThreadsQueryResponse>(REVIEW_THREADS_QUERY, {
|
||||
const { formatted } = (await getReviewData({
|
||||
octokit,
|
||||
owner: "pullfrog",
|
||||
name: "scratch",
|
||||
prNumber: pullNumber,
|
||||
});
|
||||
pullNumber: 49,
|
||||
reviewId: 3485940013,
|
||||
}))!;
|
||||
|
||||
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 === reviewId);
|
||||
});
|
||||
expect(formatted.toc).toMatchSnapshot("toc");
|
||||
expect(formatted.content).toMatchSnapshot("content");
|
||||
});
|
||||
|
||||
// fetch file patches
|
||||
const prFilesResponse = await octokit.rest.pulls.listFiles({
|
||||
it("formats body-only review", { timeout: 30000 }, async () => {
|
||||
const token = await getToken();
|
||||
const octokit = new Octokit({ auth: token });
|
||||
|
||||
const { formatted } = (await getReviewData({
|
||||
octokit,
|
||||
owner: "pullfrog",
|
||||
repo: "scratch",
|
||||
pull_number: pullNumber,
|
||||
});
|
||||
const filePatchMap = new Map<string, ParsedHunk[]>();
|
||||
for (const file of prFilesResponse.data) {
|
||||
if (file.patch) {
|
||||
filePatchMap.set(file.filename, parseFilePatches(file.patch));
|
||||
}
|
||||
}
|
||||
name: "scratch",
|
||||
pullNumber: 64,
|
||||
reviewId: 3531000326,
|
||||
}))!;
|
||||
|
||||
// build and format
|
||||
const { threadBlocks, reviewer } = buildThreadBlocks(threadsForReview, filePatchMap, reviewId);
|
||||
const result = formatReviewThreads(threadBlocks, { pullNumber, reviewId, reviewer });
|
||||
|
||||
expect(result.toc).toMatchSnapshot("toc");
|
||||
expect(result.content).toMatchSnapshot("content");
|
||||
expect(formatted.toc).toMatchSnapshot("toc");
|
||||
expect(formatted.content).toMatchSnapshot("content");
|
||||
});
|
||||
});
|
||||
|
||||
+129
-71
@@ -1,6 +1,8 @@
|
||||
import { writeFileSync } from "node:fs";
|
||||
import { join } from "node:path";
|
||||
import type { Octokit } from "@octokit/rest";
|
||||
import { type } from "arktype";
|
||||
import { stripExistingFooter } from "../utils/buildPullfrogFooter.ts";
|
||||
import { log } from "../utils/log.ts";
|
||||
import type { ToolContext } from "./server.ts";
|
||||
import { execute, tool } from "./shared.ts";
|
||||
@@ -94,6 +96,16 @@ export type ReviewThreadsQueryResponse = {
|
||||
} | 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
|
||||
const CONTEXT_PADDING = 3;
|
||||
|
||||
@@ -279,19 +291,14 @@ function extractFromFilePatches(
|
||||
export const GetReviewComments = type({
|
||||
pull_number: type.number.describe("The pull request number"),
|
||||
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 {
|
||||
if (!comment.reactionGroups) return false;
|
||||
const thumbsUp = comment.reactionGroups.find((g) => g.content === "THUMBS_UP");
|
||||
if (!thumbsUp?.reactors?.nodes) return false;
|
||||
const usernameNeedle = username.toLowerCase();
|
||||
return thumbsUp.reactors.nodes.some((r) => r?.login?.toLowerCase() === usernameNeedle);
|
||||
const needle = username.toLowerCase();
|
||||
return thumbsUp.reactors.nodes.some((r) => r?.login?.toLowerCase() === needle);
|
||||
}
|
||||
|
||||
function threadHasThumbsUpFrom(thread: ReviewThread, username: string): boolean {
|
||||
@@ -305,19 +312,26 @@ function threadHasThumbsUpFrom(thread: ReviewThread, username: string): boolean
|
||||
*/
|
||||
export function formatReviewThreads(
|
||||
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)
|
||||
const tocHeaderLines = 4;
|
||||
const tocFooterLines = 3;
|
||||
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 threadLines: string[] = [];
|
||||
|
||||
for (const block of threadBlocks) {
|
||||
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;
|
||||
tocEntries.push(`- ${block.path}:${block.lineRange} → lines ${startLine}-${endLine}`);
|
||||
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}`
|
||||
);
|
||||
lines.push("");
|
||||
lines.push("## TOC");
|
||||
lines.push("");
|
||||
lines.push(...tocEntries);
|
||||
lines.push("");
|
||||
if (threadBlocks.length > 0) {
|
||||
lines.push("## TOC");
|
||||
lines.push("");
|
||||
lines.push(...tocEntries);
|
||||
lines.push("");
|
||||
}
|
||||
lines.push(...reviewBodyLines);
|
||||
lines.push("---");
|
||||
lines.push("");
|
||||
lines.push(...threadLines);
|
||||
@@ -352,12 +369,6 @@ export function buildThreadBlocks(
|
||||
filePatchMap: Map<string, ParsedHunk[]>,
|
||||
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
|
||||
threads.sort((a, b) => {
|
||||
const pathCmp = a.path.localeCompare(b.path);
|
||||
@@ -439,7 +450,89 @@ export function buildThreadBlocks(
|
||||
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) {
|
||||
@@ -447,35 +540,26 @@ export function GetReviewCommentsTool(ctx: ToolContext) {
|
||||
name: "get_review_comments",
|
||||
description:
|
||||
"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.",
|
||||
parameters: GetReviewComments,
|
||||
execute: execute(async (params) => {
|
||||
// fetch all review threads for the PR via GraphQL
|
||||
const response = await ctx.octokit.graphql<ReviewThreadsQueryResponse>(REVIEW_THREADS_QUERY, {
|
||||
// auto-filter to approved comments when the event has approved_only set
|
||||
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,
|
||||
name: ctx.repo.name,
|
||||
prNumber: params.pull_number,
|
||||
pullNumber: params.pull_number,
|
||||
reviewId: params.review_id,
|
||||
approvedBy,
|
||||
});
|
||||
|
||||
const allThreads = response.repository?.pullRequest?.reviewThreads?.nodes ?? [];
|
||||
|
||||
// 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) {
|
||||
if (!result) {
|
||||
return {
|
||||
review_id: params.review_id,
|
||||
pull_number: params.pull_number,
|
||||
@@ -483,40 +567,14 @@ export function GetReviewCommentsTool(ctx: ToolContext) {
|
||||
threadCount: 0,
|
||||
commentsPath: null,
|
||||
toc: null,
|
||||
instructions: params.approved_by
|
||||
? `no threads with 👍 from ${params.approved_by}`
|
||||
instructions: approvedBy
|
||||
? `no threads with 👍 from ${approvedBy}`
|
||||
: "no threads found for this review",
|
||||
};
|
||||
}
|
||||
|
||||
// fetch full file patches for better multi-hunk context
|
||||
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));
|
||||
}
|
||||
}
|
||||
const { threadBlocks, reviewer, formatted } = result;
|
||||
|
||||
// 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;
|
||||
if (!tempDir) {
|
||||
throw new Error("PULLFROG_TEMP_DIR not set");
|
||||
|
||||
+31
-49
@@ -100,7 +100,7 @@ export interface ToolContext {
|
||||
modes: Mode[];
|
||||
postCheckoutScript: string | null;
|
||||
toolState: ToolState;
|
||||
runId: string;
|
||||
runId: number | undefined;
|
||||
jobId: string | undefined;
|
||||
// set after MCP server starts — used by delegate tool to pass URL to subagents
|
||||
mcpServerUrl: string;
|
||||
@@ -188,50 +188,27 @@ function isAddressInUse(error: unknown): boolean {
|
||||
return message.includes("eaddrinuse") || message.includes("address already in use");
|
||||
}
|
||||
|
||||
// subagent tools: file ops, shell, read-only GitHub, upload, set_output.
|
||||
// no git/checkout (mutates shared state), no dependencies (shared state),
|
||||
// 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>[] {
|
||||
// tools shared by both orchestrator and subagent servers
|
||||
function buildCommonTools(ctx: ToolContext): Tool<any, any>[] {
|
||||
const tools: Tool<any, any>[] = [
|
||||
StartDependencyInstallationTool(ctx),
|
||||
AwaitDependencyInstallationTool(ctx),
|
||||
CreateCommentTool(ctx),
|
||||
EditCommentTool(ctx),
|
||||
ReplyToReviewCommentTool(ctx),
|
||||
IssueTool(ctx),
|
||||
IssueInfoTool(ctx),
|
||||
GetIssueCommentsTool(ctx),
|
||||
GetIssueEventsTool(ctx),
|
||||
CreatePullRequestReviewTool(ctx),
|
||||
PullRequestInfoTool(ctx),
|
||||
CommitInfoTool(ctx),
|
||||
CheckoutPrTool(ctx),
|
||||
GetReviewCommentsTool(ctx),
|
||||
ListPullRequestReviewsTool(ctx),
|
||||
ResolveReviewThreadTool(ctx),
|
||||
GetCheckSuiteLogsTool(ctx),
|
||||
AddLabelsTool(ctx),
|
||||
GitTool(ctx),
|
||||
GitFetchTool(ctx),
|
||||
UploadFileTool(ctx),
|
||||
@@ -241,22 +218,6 @@ function buildOrchestratorTools(ctx: ToolContext): Tool<any, any>[] {
|
||||
FileEditTool(ctx),
|
||||
FileDeleteTool(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"
|
||||
@@ -271,6 +232,27 @@ function buildOrchestratorTools(ctx: ToolContext): Tool<any, any>[] {
|
||||
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 = {
|
||||
server: FastMCP;
|
||||
url: string;
|
||||
|
||||
+38
-12
@@ -2,6 +2,7 @@
|
||||
import { type ChildProcess, type StdioOptions, spawn, spawnSync } from "node:child_process";
|
||||
import { randomUUID } from "node:crypto";
|
||||
import { closeSync, openSync, writeFileSync } from "node:fs";
|
||||
import { userInfo } from "node:os";
|
||||
import { join } from "node:path";
|
||||
import { type } from "arktype";
|
||||
import { log } from "../utils/log.ts";
|
||||
@@ -82,33 +83,39 @@ function detectSandboxMethod(): SandboxMethod {
|
||||
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 {
|
||||
const spawnOpts = { env: params.env, cwd: params.cwd, stdio: params.stdio, detached: true };
|
||||
const sandboxMethod = detectSandboxMethod();
|
||||
|
||||
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(
|
||||
"unshare",
|
||||
["--pid", "--fork", "--mount-proc", "bash", "-c", params.command],
|
||||
["--pid", "--fork", "--mount-proc", "bash", "-c", `${PROC_CLEANUP} ${params.command}`],
|
||||
spawnOpts
|
||||
);
|
||||
}
|
||||
|
||||
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[] = [];
|
||||
for (const [k, v] of Object.entries(params.env)) {
|
||||
if (v !== undefined) {
|
||||
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(
|
||||
"sudo",
|
||||
[
|
||||
@@ -120,9 +127,9 @@ function spawnShell(params: SpawnParams): ChildProcess {
|
||||
"--mount-proc",
|
||||
"bash",
|
||||
"-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;
|
||||
}
|
||||
|
||||
/** 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) {
|
||||
return tool({
|
||||
name: "shell",
|
||||
@@ -162,9 +177,20 @@ Use this tool to:
|
||||
- Run shell commands (ls, cat, grep, find, etc.)
|
||||
- Execute build tools (npm, pnpm, cargo, make, etc.)
|
||||
- Run tests and linters
|
||||
- Perform git operations`,
|
||||
|
||||
Do NOT use this tool for git commands — use the dedicated git tools instead.`,
|
||||
parameters: ShellParams,
|
||||
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 cwd = params.working_directory ?? process.cwd();
|
||||
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 { markActivity } from "../utils/activity.ts";
|
||||
import type { ResolvedInstructions } from "../utils/instructions.ts";
|
||||
import { withLogPrefix } from "../utils/log.ts";
|
||||
import { type SubagentState, startSubagentMcpServer, type ToolContext } from "./server.ts";
|
||||
|
||||
type CreateSubagentParams = {
|
||||
@@ -132,41 +133,43 @@ type RunSubagentResult = {
|
||||
};
|
||||
|
||||
export async function runSubagent(params: RunSubagentParams): Promise<RunSubagentResult> {
|
||||
params.subagent.keepAliveInterval = setInterval(markActivity, 30_000);
|
||||
const mcpServer = await startSubagentMcpServer({
|
||||
ctx: params.ctx,
|
||||
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({
|
||||
return withLogPrefix(`[${params.subagent.label}]`, async () => {
|
||||
params.subagent.keepAliveInterval = setInterval(markActivity, 30_000);
|
||||
const mcpServer = await startSubagentMcpServer({
|
||||
ctx: params.ctx,
|
||||
label: params.subagent.label,
|
||||
instructions: params.instructions,
|
||||
subagentId: params.subagent.id,
|
||||
});
|
||||
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);
|
||||
// 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 {
|
||||
writeFileSync(params.subagent.stdoutFilePath, "", "utf-8");
|
||||
} catch {
|
||||
// best-effort
|
||||
const subagentPayload = { ...params.ctx.payload, effort: params.effort };
|
||||
const subagentInstructions = buildSubagentInstructions({
|
||||
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:
|
||||
- **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.
|
||||
|
||||
|
||||
Branch names must be prefixed with "pullfrog/" and be specific enough to avoid collisions. Never commit directly to main/master/production.
|
||||
|
||||
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):
|
||||
- **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.
|
||||
|
||||
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
|
||||
- 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
|
||||
[View PR ➔](https://github.com/org/repo/pull/123)
|
||||
\`\`\`
|
||||
@@ -78,7 +79,7 @@ export function computeModes(): Mode[] {
|
||||
|
||||
2. **DEPENDENCIES** - ${dependencyInstallationStep}
|
||||
|
||||
3. **FETCH COMMENTS** - Fetch review comments using ${ghPullfrogMcpName}/get_review_comments with \`pull_number\` and \`review_id\` from EVENT DATA. This returns \`commentsPath\` - read that file for full comment details with diff context. If EVENT DATA contains a \`triggerer\` field (indicating who requested fixes), you can pass \`approved_by\` to filter to only comments they approved with 👍.
|
||||
3. **FETCH COMMENTS** - Fetch review comments using ${ghPullfrogMcpName}/get_review_comments with \`pull_number\` and \`review_id\` from EVENT DATA. This returns \`commentsPath\` - read that file for full comment details with diff context. When \`approved_only\` is set in EVENT DATA, only approved comments are returned automatically.
|
||||
|
||||
4. **UNDERSTAND** - Review the feedback provided. Understand each review comment and what changes are being requested.
|
||||
|
||||
@@ -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:
|
||||
- \`body\`: The summary from step 5
|
||||
- \`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}
|
||||
`,
|
||||
@@ -162,23 +164,23 @@ ${permalinkTip}`,
|
||||
- \`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:
|
||||
|
||||
|
||||
**Ask yourself**: "Could the changes in this PR have caused this failure?"
|
||||
|
||||
|
||||
- Read the PR diff carefully - what files were modified?
|
||||
- What is failing? (test file, module, assertion)
|
||||
- Is there a PLAUSIBLE CONNECTION between the PR changes and the failure?
|
||||
|
||||
|
||||
**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 error is infrastructure-related (network timeout, runner OOM, service unavailable)
|
||||
- The error is a flaky test that passes/fails randomly
|
||||
- The error existed before this PR (pre-existing bug in main branch)
|
||||
- The error is in a dependency update not introduced by this PR
|
||||
|
||||
|
||||
**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."
|
||||
|
||||
|
||||
**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:
|
||||
@@ -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.
|
||||
- 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.
|
||||
- **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.
|
||||
|
||||
5. **PROGRESS** - ${reportProgressInstruction}
|
||||
|
||||
+1
-1
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "@pullfrog/pullfrog",
|
||||
"version": "0.0.171",
|
||||
"version": "0.0.175",
|
||||
"type": "module",
|
||||
"files": [
|
||||
"index.js",
|
||||
|
||||
@@ -28846,6 +28846,7 @@ var require_semver2 = __commonJS({
|
||||
// utils/log.ts
|
||||
var core = __toESM(require_core(), 1);
|
||||
var import_table = __toESM(require_src(), 1);
|
||||
import { AsyncLocalStorage } from "node:async_hooks";
|
||||
|
||||
// utils/globals.ts
|
||||
import { existsSync } from "node:fs";
|
||||
@@ -28854,6 +28855,20 @@ var isGitHubActions = !!process.env.GITHUB_ACTIONS;
|
||||
var isInsideDocker = existsSync("/.dockerenv");
|
||||
|
||||
// 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 isLocalDebugEnabled = () => process.env.LOG_LEVEL === "debug" || process.env.ACTIONS_STEP_DEBUG === "true";
|
||||
var isDebugEnabled = () => isLocalDebugEnabled() || isRunnerDebugEnabled();
|
||||
@@ -28869,10 +28884,11 @@ ${arg.stack}`;
|
||||
}).join(" ");
|
||||
}
|
||||
function startGroup2(name) {
|
||||
const prefixed = prefixPlain(name);
|
||||
if (isGitHubActions) {
|
||||
core.startGroup(name);
|
||||
core.startGroup(prefixed);
|
||||
} else {
|
||||
console.group(name);
|
||||
console.group(prefixed);
|
||||
}
|
||||
}
|
||||
function endGroup2() {
|
||||
@@ -28945,7 +28961,7 @@ function boxString(text, options) {
|
||||
}
|
||||
function box(text, options) {
|
||||
const boxContent = boxString(text, options);
|
||||
core.info(boxContent);
|
||||
core.info(prefixLines(boxContent));
|
||||
}
|
||||
function printTable(rows, options) {
|
||||
const { title } = options || {};
|
||||
@@ -28959,42 +28975,42 @@ function printTable(rows, options) {
|
||||
);
|
||||
const formatted = (0, import_table.table)(tableData);
|
||||
if (title) {
|
||||
core.info(`
|
||||
${title}`);
|
||||
core.info(prefixLines(`
|
||||
${title}`));
|
||||
}
|
||||
core.info(`
|
||||
core.info(prefixLines(`
|
||||
${formatted}
|
||||
`);
|
||||
`));
|
||||
}
|
||||
function separator(length = 50) {
|
||||
const separatorText = "\u2500".repeat(length);
|
||||
core.info(separatorText);
|
||||
core.info(prefixLines(separatorText));
|
||||
}
|
||||
var log = {
|
||||
/** Print info message */
|
||||
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. */
|
||||
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. */
|
||||
error: (...args2) => {
|
||||
core.error(`${ts()}${formatArgs(args2)}`);
|
||||
core.error(prefixLines(`${ts()}${formatArgs(args2)}`));
|
||||
},
|
||||
/** Print success message */
|
||||
success: (...args2) => {
|
||||
core.info(`${ts()}\xBB ${formatArgs(args2)}`);
|
||||
core.info(prefixLines(`${ts()}\xBB ${formatArgs(args2)}`));
|
||||
},
|
||||
/** Print debug message (only when debug mode is enabled) */
|
||||
debug: (...args2) => {
|
||||
if (isRunnerDebugEnabled()) {
|
||||
core.debug(formatArgs(args2));
|
||||
core.debug(prefixLines(formatArgs(args2)));
|
||||
return;
|
||||
}
|
||||
if (isLocalDebugEnabled()) {
|
||||
core.info(`${ts()}[DEBUG] ${formatArgs(args2)}`);
|
||||
core.info(prefixLines(`${ts()}[DEBUG] ${formatArgs(args2)}`));
|
||||
}
|
||||
},
|
||||
/** Print a formatted box with text */
|
||||
@@ -37477,6 +37493,22 @@ var schema = ark.schema;
|
||||
var define2 = ark.define;
|
||||
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
|
||||
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>`;
|
||||
@@ -41235,7 +41267,7 @@ var Effort = type.enumerated("mini", "auto", "max");
|
||||
// package.json
|
||||
var package_default = {
|
||||
name: "@pullfrog/pullfrog",
|
||||
version: "0.0.171",
|
||||
version: "0.0.175",
|
||||
type: "module",
|
||||
files: [
|
||||
"index.js",
|
||||
@@ -41350,7 +41382,7 @@ var JsonPayload = type({
|
||||
version: "string",
|
||||
"agent?": AgentName.or("undefined"),
|
||||
prompt: "string",
|
||||
"triggeringUser?": "string | undefined",
|
||||
"triggerer?": "string | undefined",
|
||||
"eventInstructions?": "string",
|
||||
"repoInstructions?": "string",
|
||||
"event?": "object",
|
||||
@@ -41403,15 +41435,25 @@ function getJobToken() {
|
||||
// utils/postCleanup.ts
|
||||
var SHOULD_CHECK_REASON = true;
|
||||
function buildErrorCommentBody(params) {
|
||||
const workflowRunLink = params.runId ? `[workflow run logs](https://github.com/${params.owner}/${params.repo}/actions/runs/${params.runId})` : "workflow run logs";
|
||||
const errorMessage = params.isCancellation ? `This run was cancelled \u{1F6D1}
|
||||
let 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({
|
||||
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}`;
|
||||
}
|
||||
@@ -41441,12 +41483,12 @@ async function validateStuckProgressComment(params) {
|
||||
}
|
||||
}
|
||||
async function getIsCancelled(params) {
|
||||
if (!params.runIdStr) return false;
|
||||
if (!params.runId) return false;
|
||||
try {
|
||||
const jobsResult = await params.octokit.rest.actions.listJobsForWorkflowRun({
|
||||
owner: params.repoContext.owner,
|
||||
repo: params.repoContext.name,
|
||||
run_id: Number.parseInt(params.runIdStr, 10)
|
||||
run_id: params.runId
|
||||
});
|
||||
const currentJobName = process.env.GITHUB_JOB;
|
||||
const currentJob = currentJobName ? jobsResult.data.jobs.find(
|
||||
@@ -41473,7 +41515,7 @@ async function getIsCancelled(params) {
|
||||
}
|
||||
async function runPostCleanup() {
|
||||
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;
|
||||
try {
|
||||
const resolved = resolvePromptInput();
|
||||
@@ -41498,8 +41540,8 @@ async function runPostCleanup() {
|
||||
const body = buildErrorCommentBody({
|
||||
owner: repoContext.owner,
|
||||
repo: repoContext.name,
|
||||
runId: runIdStr,
|
||||
isCancellation: SHOULD_CHECK_REASON ? await getIsCancelled({ octokit, repoContext, runIdStr }) : false
|
||||
runId,
|
||||
isCancellation: SHOULD_CHECK_REASON ? await getIsCancelled({ octokit, repoContext, runId }) : false
|
||||
});
|
||||
await octokit.rest.issues.updateComment({
|
||||
owner: repoContext.owner,
|
||||
|
||||
@@ -159,13 +159,16 @@ export const installNodeDependencies: PrepDefinition = {
|
||||
cmd: resolved.command,
|
||||
args: resolved.args,
|
||||
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) {
|
||||
// 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}`;
|
||||
return {
|
||||
language: "node",
|
||||
|
||||
@@ -162,21 +162,28 @@ export const installPythonDependencies: PrepDefinition = {
|
||||
|
||||
// run the install command
|
||||
const [cmd, ...args] = config.installCmd;
|
||||
log.info(`» running: ${cmd} ${args.join(" ")}`);
|
||||
const fullCommand = `${cmd} ${args.join(" ")}`;
|
||||
log.info(`» running: ${fullCommand}`);
|
||||
const result = await spawn({
|
||||
cmd,
|
||||
args,
|
||||
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) {
|
||||
return {
|
||||
language: "python",
|
||||
packageManager: config.tool,
|
||||
configFile: config.file,
|
||||
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",
|
||||
...new Set(Object.values(agentsManifest).flatMap((a) => a.apiKeyNames)),
|
||||
"GEMINI_MODEL",
|
||||
"OPENCODE_MODEL_MAX",
|
||||
"OPENCODE_MODEL_MINI",
|
||||
"OPENCODE_MODEL",
|
||||
].sort();
|
||||
|
||||
|
||||
@@ -10,7 +10,7 @@ export interface AgentInfo {
|
||||
export interface WorkflowRunFooterInfo {
|
||||
owner: string;
|
||||
repo: string;
|
||||
runId: string;
|
||||
runId: number;
|
||||
/** optional job ID - if provided, will append /job/{jobId} to the workflow run URL */
|
||||
jobId?: string | undefined;
|
||||
}
|
||||
|
||||
@@ -120,6 +120,8 @@ const testEnvAllowList = new Set([
|
||||
"GOOGLE_GENERATIVE_AI_API_KEY",
|
||||
"CURSOR_API_KEY",
|
||||
"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
|
||||
"LOG_LEVEL",
|
||||
"DEBUG",
|
||||
|
||||
+13
-2
@@ -1,4 +1,5 @@
|
||||
import type { ToolState } from "../mcp/server.ts";
|
||||
import { getApiUrl } from "./apiUrl.ts";
|
||||
import { buildPullfrogFooter } from "./buildPullfrogFooter.ts";
|
||||
import { createOctokit, parseRepoContext } from "./github.ts";
|
||||
import { getGitHubInstallationToken } from "./token.ts";
|
||||
@@ -19,12 +20,22 @@ export async function reportErrorToComment(ctx: ReportErrorParams): Promise<void
|
||||
|
||||
const repoContext = parseRepoContext();
|
||||
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({
|
||||
triggeredBy: true,
|
||||
workflowRun: runId ? { owner: repoContext.owner, repo: repoContext.name, runId } : undefined,
|
||||
customParts,
|
||||
});
|
||||
|
||||
await octokit.rest.issues.updateComment({
|
||||
|
||||
+45
-12
@@ -2,11 +2,43 @@
|
||||
* 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 { table } from "table";
|
||||
import type { AgentUsage } from "../agents/shared.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 isLocalDebugEnabled = () =>
|
||||
@@ -36,10 +68,11 @@ function formatArgs(args: unknown[]): string {
|
||||
* Start a collapsed group (GitHub Actions) or regular group (local)
|
||||
*/
|
||||
function startGroup(name: string): void {
|
||||
const prefixed = prefixPlain(name);
|
||||
if (isGitHubActions) {
|
||||
core.startGroup(name);
|
||||
core.startGroup(prefixed);
|
||||
} else {
|
||||
console.group(name);
|
||||
console.group(prefixed);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -153,7 +186,7 @@ function box(
|
||||
}
|
||||
): void {
|
||||
const boxContent = boxString(text, options);
|
||||
core.info(boxContent);
|
||||
core.info(prefixLines(boxContent));
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -200,9 +233,9 @@ function printTable(
|
||||
const formatted = table(tableData);
|
||||
|
||||
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 {
|
||||
const separatorText = "─".repeat(length);
|
||||
core.info(separatorText);
|
||||
core.info(prefixLines(separatorText));
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -219,32 +252,32 @@ function separator(length: number = 50): void {
|
||||
export const log = {
|
||||
/** Print info message */
|
||||
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. */
|
||||
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. */
|
||||
error: (...args: unknown[]): void => {
|
||||
core.error(`${ts()}${formatArgs(args)}`);
|
||||
core.error(prefixLines(`${ts()}${formatArgs(args)}`));
|
||||
},
|
||||
|
||||
/** Print success message */
|
||||
success: (...args: unknown[]): void => {
|
||||
core.info(`${ts()}» ${formatArgs(args)}`);
|
||||
core.info(prefixLines(`${ts()}» ${formatArgs(args)}`));
|
||||
},
|
||||
|
||||
/** Print debug message (only when debug mode is enabled) */
|
||||
debug: (...args: unknown[]): void => {
|
||||
if (isRunnerDebugEnabled()) {
|
||||
core.debug(formatArgs(args));
|
||||
core.debug(prefixLines(formatArgs(args)));
|
||||
return;
|
||||
}
|
||||
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",
|
||||
"agent?": AgentName.or("undefined"),
|
||||
prompt: "string",
|
||||
"triggeringUser?": "string | undefined",
|
||||
"triggerer?": "string | undefined",
|
||||
|
||||
"eventInstructions?": "string",
|
||||
"repoInstructions?": "string",
|
||||
"event?": "object",
|
||||
@@ -167,8 +168,8 @@ export function resolvePayload(
|
||||
version: jsonPayload?.version ?? packageJson.version,
|
||||
agent: resolvedAgent,
|
||||
prompt,
|
||||
triggeringUser:
|
||||
jsonPayload?.triggeringUser ??
|
||||
triggerer:
|
||||
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
|
||||
(!isPullfrog(process.env.GITHUB_ACTOR) ? process.env.GITHUB_ACTOR : undefined),
|
||||
eventInstructions: jsonPayload?.eventInstructions,
|
||||
|
||||
+26
-13
@@ -1,4 +1,5 @@
|
||||
import { LEAPING_INTO_ACTION_PREFIX } from "../mcp/comment.ts";
|
||||
import { getApiUrl } from "./apiUrl.ts";
|
||||
import { buildPullfrogFooter } from "./buildPullfrogFooter.ts";
|
||||
import { log } from "./cli.ts";
|
||||
import { createOctokit, parseRepoContext } from "./github.ts";
|
||||
@@ -15,22 +16,32 @@ const SHOULD_CHECK_REASON = true;
|
||||
type BuildErrorCommentBodyParams = {
|
||||
owner: string;
|
||||
repo: string;
|
||||
runId: string | undefined;
|
||||
runId: number | undefined;
|
||||
isCancellation: boolean;
|
||||
};
|
||||
|
||||
function buildErrorCommentBody(params: BuildErrorCommentBodyParams): string {
|
||||
const workflowRunLink = params.runId
|
||||
? `[workflow run logs](https://github.com/${params.owner}/${params.repo}/actions/runs/${params.runId})`
|
||||
: "workflow run logs";
|
||||
const errorMessage = params.isCancellation
|
||||
? `This run was cancelled 🛑\n\nThe workflow was cancelled before completion. Please check the ${workflowRunLink} for details.`
|
||||
: `This run croaked 😵\n\nThe workflow encountered an error before any progress could be reported. Please check the ${workflowRunLink} for details.`;
|
||||
let errorMessage = params.isCancellation
|
||||
? `This run was cancelled 🛑\n\nThe workflow was cancelled before completion.`
|
||||
: `This run croaked 😵\n\nThe workflow encountered an error before any progress could be reported.`;
|
||||
|
||||
if (params.runId) {
|
||||
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({
|
||||
triggeredBy: true,
|
||||
workflowRun: params.runId
|
||||
? { owner: params.owner, repo: params.repo, runId: params.runId }
|
||||
: undefined,
|
||||
customParts,
|
||||
});
|
||||
return `${errorMessage}${footer}`;
|
||||
}
|
||||
@@ -76,16 +87,16 @@ async function validateStuckProgressComment(
|
||||
type GetIsCancelledParams = {
|
||||
repoContext: ReturnType<typeof parseRepoContext>;
|
||||
octokit: ReturnType<typeof createOctokit>;
|
||||
runIdStr: string | undefined;
|
||||
runId: number | undefined;
|
||||
};
|
||||
|
||||
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 {
|
||||
const jobsResult = await params.octokit.rest.actions.listJobsForWorkflowRun({
|
||||
owner: params.repoContext.owner,
|
||||
repo: params.repoContext.name,
|
||||
run_id: Number.parseInt(params.runIdStr, 10),
|
||||
run_id: params.runId,
|
||||
});
|
||||
|
||||
// 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> {
|
||||
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
|
||||
// only use the object form (JSON payload), not plain string prompts
|
||||
@@ -159,9 +172,9 @@ export async function runPostCleanup(): Promise<void> {
|
||||
const body = buildErrorCommentBody({
|
||||
owner: repoContext.owner,
|
||||
repo: repoContext.name,
|
||||
runId: runIdStr,
|
||||
runId,
|
||||
isCancellation: SHOULD_CHECK_REASON
|
||||
? await getIsCancelled({ octokit, repoContext, runIdStr })
|
||||
? await getIsCancelled({ octokit, repoContext, runId })
|
||||
: false,
|
||||
});
|
||||
|
||||
|
||||
+29
-5
@@ -1,13 +1,37 @@
|
||||
import type { AgentResult } from "../agents/shared.ts";
|
||||
import type { MainResult } from "../main.ts";
|
||||
import type { ToolState } from "../mcp/server.ts";
|
||||
import { log } from "./cli.ts";
|
||||
import { reportErrorToComment } from "./errorReport.ts";
|
||||
|
||||
export function handleAgentResult(result: AgentResult): MainResult {
|
||||
if (!result.success) {
|
||||
export interface HandleAgentResultParams {
|
||||
result: AgentResult;
|
||||
toolState: ToolState;
|
||||
silent: boolean | undefined;
|
||||
}
|
||||
|
||||
export async function handleAgentResult(ctx: HandleAgentResultParams): Promise<MainResult> {
|
||||
if (!ctx.result.success) {
|
||||
return {
|
||||
success: false,
|
||||
error: result.error || "Agent execution failed",
|
||||
output: result.output!,
|
||||
error: ctx.result.error || "Agent execution failed",
|
||||
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 {
|
||||
success: true,
|
||||
output: result.output || "",
|
||||
output: ctx.result.output || "",
|
||||
};
|
||||
}
|
||||
|
||||
+5
-3
@@ -6,7 +6,7 @@ interface ResolveRunParams {
|
||||
}
|
||||
|
||||
export interface ResolveRunResult {
|
||||
runId: string;
|
||||
runId: number | undefined;
|
||||
jobId: string | undefined;
|
||||
}
|
||||
|
||||
@@ -15,7 +15,9 @@ export interface ResolveRunResult {
|
||||
* Uses GITHUB_REPOSITORY and GITHUB_RUN_ID env vars.
|
||||
*/
|
||||
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;
|
||||
if (!githubRepo || !githubRepo.includes("/")) {
|
||||
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({
|
||||
owner,
|
||||
repo,
|
||||
run_id: parseInt(runId, 10),
|
||||
run_id: runId,
|
||||
});
|
||||
const matchingJob = jobs.data.jobs.find((job) => job.name === jobName);
|
||||
if (matchingJob) {
|
||||
|
||||
Reference in New Issue
Block a user