Compare commits

...

9 Commits

Author SHA1 Message Date
Mateusz Burzyński de686da001 174 2026-02-27 12:44:04 +00:00
pullfrog[bot] c456fae716 Standardize on top-level triggerer property (#361)
* Standardize on top-level `triggerer` property

- Rename `triggeringUser` → `triggerer` as the single top-level payload property
- Remove redundant `triggerer` from `FixReviewEvent`, add `approvedOnly` boolean
- Auto-apply `approved_by` filtering in `get_review_comments` when `approvedOnly` is set
- Auto-assign created PRs to the triggerer
- Simplify `AddressReviews` mode prompt
- Accept both `triggerer` and `triggeringUser` in schema for backward compat

* Address review feedback: simplify approved_only, remove addAssignees, drop approved_by param

* Fix formatting in `action/mcp/pr.ts`

* re-add triggeringUser backward-compat fallback in payload schema

* auto-assign created PRs to the triggerer

* Revert "auto-assign created PRs to the triggerer"

This reverts commit c088c425fea33793eb299a001ffd253798d2c674.

* Revert "re-add triggeringUser backward-compat fallback in payload schema"

This reverts commit ae5b3cb3f1377cd4a634b2d48962c785c31013f3.

* backend compat

* tweak prompt to ensure compat

* Address review feedback

* chore: remove triggeringUser fallback

---------

Co-authored-by: pullfrog[bot] <226033991+pullfrog[bot]@users.noreply.github.com>
Co-authored-by: Mateusz Burzyński <mateuszburzynski@gmail.com>
Co-authored-by: Colin McDonnell <colinmcd94@gmail.com>
2026-02-27 12:43:37 +00:00
pullfrog[bot] 20b08b5321 Add "Rerun failed job ➔" link to error comment footer (#355)
* add "Rerun failed job" link to error comment footer

* Remove issueNumber guard from rerun link

The rerun action only needs run_id — the issue number in the trigger
URL path is just a route segment requirement. Use 0 as a fallback
so the link is always shown when a run ID is available.

* Overload `[number]` path segment as `runId` for the rerun action

For the rerun trigger, the `[number]` path param now carries the
workflow run ID instead of an issue number. The rerun link changes
from `/trigger/o/r/ISSUE?action=rerun&run_id=RID` to
`/trigger/o/r/RID?action=rerun`.

- Remove `run_id` query param from page.tsx and searchParams type
- Add error handling around `reRunWorkflow` for invalid run IDs
- Drop `issueNumber` from `BuildErrorCommentBodyParams` and all
  rerun link builders (errorReport, exitHandler, postCleanup)

* Reorder validation: action first, then rerun, then issueNumber

* address review: remove early toolState assignment, restructure rerun into dedicated block

* revert self-contained rerun block, share auth logic via issueOrRunId

* improve error handling

* normalize runid early

---------

Co-authored-by: pullfrog[bot] <226033991+pullfrog[bot]@users.noreply.github.com>
Co-authored-by: Cursor <cursoragent@cursor.com>
Co-authored-by: Mateusz Burzyński <mateuszburzynski@gmail.com>
2026-02-27 12:32:06 +00:00
pullfrog[bot] c0fd69560f Add "Fix it" link for body-only PR reviews (#338)
* Add "Fix it" link for body-only PR reviews

When a PR review has only body-level feedback (no inline comments),
the footer now includes a "Fix it" link that triggers the fix flow.

Also fetches the review body in the fix action's prompt so the agent
can address body-level feedback even when there are no inline comments.

* Move review body fetching into `get_review_comments` tool

Instead of fetching the review body in the trigger page and appending
it to the prompt, the `get_review_comments` MCP tool now fetches the
review body via the GitHub API and includes it in its markdown output
under a "Review Body" section. This keeps the trigger page simple and
lets the tool provide all review context in one place.

* fetch body early

* get reviewer from a better place

* cleanup structure to reuse more in test

* simplify

* simplify

* typecheck

* fetch review body via REST API; skip listFiles for body-only reviews

* update snapshot

* formatting

* cleanup

* fix line counting with `countNewlines` utility using `indexOf` loop

* rename `countNewlines` to `countLines` with 1-based line counting

* suppress biome lint warning for assignment in while condition

* remove unused `body` field from GraphQL review query and type

* add `approved` parameter to `create_pull_request_review` and skip fix links for approvals

* vibe instructions

* tighten up prompting

---------

Co-authored-by: pullfrog[bot] <226033991+pullfrog[bot]@users.noreply.github.com>
Co-authored-by: Mateusz Burzyński <mateuszburzynski@gmail.com>
2026-02-27 12:18:51 +00:00
pullfrog[bot] e0bd984975 Restrict create_pull_request_review comments to PR diff (#339)
* restrict review comments to PR diff in tool description

* fix constraint text: only files (not lines) are restricted to the diff

* Revert "fix constraint text: only files (not lines) are restricted to the diff"

This reverts commit 3f2e3d05e41c308f6640a468230fb0d69c0cc3e1.

---------

Co-authored-by: pullfrog[bot] <226033991+pullfrog[bot]@users.noreply.github.com>
Co-authored-by: Mateusz Burzyński <mateuszburzynski@gmail.com>
2026-02-27 11:23:56 +00:00
Colin McDonnell c4d66bf7f6 fix: block git in shell tool, add actionable errors for push rejections (#397)
Agents were bypassing the git auth boundary by running `git pull/push`
through the shell tool (which has no credentials). This caused auth
failures and cascading issues (botched rebases, corrupted files).

- shell: block all git commands with error directing to dedicated tools
- push_branch: catch "fetch first" rejections with step-by-step recovery
- git tool: improve auth redirect errors with specific tool guidance

Made-with: Cursor
2026-02-26 21:30:10 +00:00
Colin McDonnell 1d2a06998c Draft PRs 2026-02-26 18:49:22 +00:00
pullfrog[bot] 6311138132 fix: report errors to progress comment when agent fails without throwing (#376)
* fix: report errors to progress comment when agent fails without throwing

when an agent returns success: false without throwing (e.g., opencode exits
with code 0 despite provider errors), the catch block in main.ts is never
reached, leaving the progress comment stuck on "leaping into action."

two fixes:
- opencode: return success: false when 0 events processed and a provider
  error was detected (converts silent failures to explicit failures)
- main.ts: after handleAgentResult, check if the progress comment was ever
  updated — if not, report the error to the comment as a safety net

Co-authored-by: Cursor <cursoragent@cursor.com>

* address review: use result directly and force failure on unreported progress

* refactor: move safety-net logic into handleAgentResult

---------

Co-authored-by: pullfrog[bot] <226033991+pullfrog[bot]@users.noreply.github.com>
Co-authored-by: Cursor <cursoragent@cursor.com>
Co-authored-by: Anna Bocharova <robin_tail@me.com>
2026-02-26 08:36:36 +00:00
Colin McDonnell 1ed3da8273 add magenta log prefixes for delegated subagents (#387)
When delegate() runs multiple subagents in parallel, their logs
interleave without visual distinction. Use AsyncLocalStorage to
automatically prefix every log line inside runSubagent() with the
task label in magenta (e.g. [frontend-review]).

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-02-25 05:53:18 +00:00
25 changed files with 843 additions and 412 deletions
+9
View File
@@ -437,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,
+277 -138
View File
@@ -135589,6 +135589,7 @@ var Effort = type.enumerated("mini", "auto", "max");
// 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";
@@ -135597,6 +135598,23 @@ 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 withLogPrefix(prefix, fn2) {
return logContext.run({ prefix }, fn2);
}
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();
@@ -135612,10 +135630,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() {
@@ -135688,7 +135707,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));
} }
async function writeSummary(text) { async function writeSummary(text) {
if (!isGitHubActions) return; if (!isGitHubActions) return;
@@ -135708,42 +135727,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 */
@@ -136443,41 +136462,43 @@ ${params.instructions}`;
}; };
} }
async function runSubagent(params) { async function runSubagent(params) {
params.subagent.keepAliveInterval = setInterval(markActivity, 3e4); return withLogPrefix(`[${params.subagent.label}]`, async () => {
const mcpServer = await startSubagentMcpServer({ params.subagent.keepAliveInterval = setInterval(markActivity, 3e4);
ctx: params.ctx, const mcpServer = await startSubagentMcpServer({
subagentId: params.subagent.id
});
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({ const subagentTmpdir = join(params.ctx.tmpdir, params.subagent.id);
payload: subagentPayload, mkdirSync(subagentTmpdir, { recursive: true });
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 { try {
writeFileSync(params.subagent.stdoutFilePath, "", "utf-8"); const subagentPayload = { ...params.ctx.payload, effort: params.effort };
} catch { 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 {
}
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();
}
} }
// mcp/askQuestion.ts // mcp/askQuestion.ts
@@ -141291,14 +141312,14 @@ async function buildCommentFooter({
customParts customParts
}) { }) {
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) : void 0;
let jobId; let jobId;
if (runId && octokit) { if (runId && octokit) {
try { try {
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
}); });
jobId = jobs.jobs[0]?.id.toString(); jobId = jobs.jobs[0]?.id.toString();
} catch { } catch {
@@ -142684,10 +142705,26 @@ function PushBranchTool(ctx) {
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, { try {
token: ctx.gitToken, $git("push", pushArgs, {
restricted: ctx.payload.shell !== "enabled" 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.
to resolve this:
1. use git_fetch to fetch the remote branch: git_fetch({ ref: "${pushDest.remoteBranch}" })
2. use the git tool to rebase your changes: git({ subcommand: "rebase", args: ["origin/${pushDest.remoteBranch}"] })
3. resolve any merge conflicts if needed
4. retry push_branch`
);
}
throw err;
}
return { return {
success: true, success: true,
branch, branch,
@@ -142700,10 +142737,10 @@ function PushBranchTool(ctx) {
}); });
} }
var AUTH_REQUIRED_REDIRECT = { var AUTH_REQUIRED_REDIRECT = {
push: "Use push_branch tool instead.", push: "use the push_branch tool instead \u2014 it handles authentication and permission checks.",
fetch: "Use git_fetch tool instead.", fetch: "use the git_fetch tool instead \u2014 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."
}; };
var NOSHELL_BLOCKED_SUBCOMMANDS = { var NOSHELL_BLOCKED_SUBCOMMANDS = {
config: "Blocked: git config can set up filter drivers or hooks that execute arbitrary code.", config: "Blocked: git config can set up filter drivers or hooks that execute arbitrary code.",
@@ -142731,7 +142768,7 @@ function GitTool(ctx) {
const args2 = params.args ?? []; const args2 = params.args ?? [];
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 \u2014 ${redirect}`);
} }
if (ctx.payload.shell === "disabled") { if (ctx.payload.shell === "disabled") {
const blocked = NOSHELL_BLOCKED_SUBCOMMANDS[subcommand]; const blocked = NOSHELL_BLOCKED_SUBCOMMANDS[subcommand];
@@ -143073,7 +143110,10 @@ function SetOutputTool(ctx) {
var PullRequest = type({ var 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, body) { function buildPrBodyWithFooter(ctx, body) {
const footer = buildPullfrogFooter({ const footer = buildPullfrogFooter({
@@ -143124,9 +143164,10 @@ function CreatePullRequestTool(ctx) {
title: params.title, title: params.title,
body: bodyWithFooter, body: bodyWithFooter,
head: currentBranch, head: currentBranch,
base: params.base base: params.base,
draft: params.draft ?? false
}); });
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}`);
@@ -143216,9 +143257,14 @@ var CreatePullRequestReview = type({
body: type.string.describe( body: type.string.describe(
"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 \u2014 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(), commit_id: type.string.describe("Optional SHA of the commit being reviewed. Defaults to latest.").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."
), ),
@@ -143239,15 +143285,15 @@ var CreatePullRequestReview = type({
function CreatePullRequestReviewTool(ctx) { function CreatePullRequestReviewTool(ctx) {
return tool({ return tool({
name: "create_pull_request_review", name: "create_pull_request_review",
description: `Submit a review for an existing pull request. IMPORTANT: 95%+ of feedback should be in 'comments' array with file paths and line numbers. 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 }' }`, description: `Submit a review for an existing pull request. IMPORTANT: 95%+ of feedback should be in 'comments' array with file paths and line numbers. 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 }' } 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 = [] }) => {
ctx.toolState.issueNumber = pull_number; ctx.toolState.issueNumber = pull_number;
const params = { const params = {
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) {
@@ -143286,19 +143332,25 @@ function CreatePullRequestReviewTool(ctx) {
} }
const reviewId = result.data.id; const reviewId = result.data.id;
const customParts = []; const customParts = [];
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 \u2794](${fixAllUrl})`, `[Fix \u{1F44D}s \u2794](${fixApprovedUrl})`); const fixApprovedUrl = `${apiUrl}/trigger/${ctx.repo.owner}/${ctx.repo.name}/${pull_number}?action=fix-approved&review_id=${reviewId}`;
customParts.push(`[Fix all \u2794](${fixAllUrl})`, `[Fix \u{1F44D}s \u2794](${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 \u2794](${fixUrl})`);
}
} }
const footer = buildPullfrogFooter({ const footer = buildPullfrogFooter({
workflowRun: { workflowRun: ctx.runId ? {
owner: ctx.repo.owner, owner: ctx.repo.owner,
repo: ctx.repo.name, repo: ctx.repo.name,
runId: ctx.runId, runId: ctx.runId,
jobId: ctx.jobId jobId: ctx.jobId
}, } : void 0,
customParts customParts
}); });
const updatedBody = (body || "") + footer; const updatedBody = (body || "") + footer;
@@ -143369,6 +143421,14 @@ query ($owner: String!, $name: String!, $prNumber: Int!) {
} }
} }
`; `;
function countLines(str) {
let count = 1;
let index = -1;
while ((index = str.indexOf("\n", index + 1)) !== -1) {
count++;
}
return count;
}
var CONTEXT_PADDING = 3; var CONTEXT_PADDING = 3;
function extractCommentedLines(diffHunk, startLine, endLine, side) { function extractCommentedLines(diffHunk, startLine, endLine, side) {
const lines = diffHunk.split("\n"); const lines = diffHunk.split("\n");
@@ -143483,17 +143543,14 @@ function extractFromFilePatches(hunks, startLine, endLine, side) {
} }
var GetReviewComments = type({ var 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 \u{1F44D} to at least one comment"
).optional()
}); });
function hasThumbsUpFrom(comment, username) { function hasThumbsUpFrom(comment, username) {
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, username) { function threadHasThumbsUpFrom(thread, username) {
const comments = thread.comments?.nodes ?? []; const comments = thread.comments?.nodes ?? [];
@@ -143503,11 +143560,16 @@ function formatReviewThreads(threadBlocks, header) {
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;
const reviewBodyLines = [];
if (header.reviewBody) {
reviewBodyLines.push("## Review Body", "", header.reviewBody, "");
currentLine += reviewBodyLines.reduce((sum, line) => sum + countLines(line), 0);
}
const tocEntries = []; const tocEntries = [];
const threadLines = []; const threadLines = [];
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} \u2192 lines ${startLine}-${endLine}`); tocEntries.push(`- ${block.path}:${block.lineRange} \u2192 lines ${startLine}-${endLine}`);
threadLines.push(...block.content); threadLines.push(...block.content);
@@ -143518,10 +143580,13 @@ function formatReviewThreads(threadBlocks, header) {
`# 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);
@@ -143531,10 +143596,6 @@ function formatReviewThreads(threadBlocks, header) {
}; };
} }
function buildThreadBlocks(threads, filePatchMap, reviewId) { function buildThreadBlocks(threads, filePatchMap, reviewId) {
const firstMatchingComment = threads[0]?.comments?.nodes?.find(
(c) => c?.pullRequestReview?.databaseId === reviewId
);
const reviewer = firstMatchingComment?.pullRequestReview?.author?.login ?? "unknown";
threads.sort((a, b) => { threads.sort((a, b) => {
const pathCmp = a.path.localeCompare(b.path); const pathCmp = a.path.localeCompare(b.path);
if (pathCmp !== 0) return pathCmp; if (pathCmp !== 0) return pathCmp;
@@ -143597,32 +143658,78 @@ function buildThreadBlocks(threads, filePatchMap, reviewId) {
} }
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) {
const response = await input.octokit.graphql(REVIEW_THREADS_QUERY, {
owner: input.owner,
name: input.name,
prNumber: input.pullNumber
});
const allThreads = response.repository?.pullRequest?.reviewThreads?.nodes ?? [];
const threadsForReview = allThreads.filter((thread) => {
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));
}
async function getReviewData(input) {
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 void 0;
let threadBlocks = [];
if (threads.length > 0) {
const prFilesResponse = await input.octokit.rest.pulls.listFiles({
owner: input.owner,
repo: input.name,
pull_number: input.pullNumber
});
const filePatchMap = /* @__PURE__ */ new Map();
for (const file2 of prFilesResponse.data) {
if (file2.patch) {
filePatchMap.set(file2.filename, parseFilePatches(file2.patch));
}
}
threadBlocks = buildThreadBlocks(threads, filePatchMap, input.reviewId);
}
const formatted = formatReviewThreads(threadBlocks, {
pullNumber: input.pullNumber,
reviewId: input.reviewId,
reviewer,
reviewBody
});
return { threadBlocks, reviewer, formatted };
} }
function GetReviewCommentsTool(ctx) { function GetReviewCommentsTool(ctx) {
return tool({ return tool({
name: "get_review_comments", 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 \u{1F44D} to at least one comment. Returns a TOC and commentsPath pointing to a markdown file with full comment details.", description: "Get review comments for a pull request review with full thread context. Automatically filters to approved comments when applicable. 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) => {
const response = await ctx.octokit.graphql(REVIEW_THREADS_QUERY, { const approvedBy = ctx.payload.event.trigger === "fix_review" && ctx.payload.event.approved_only ? ctx.payload.triggerer : void 0;
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) {
let threadsForReview = allThreads.filter((thread) => {
if (!thread?.comments?.nodes) return false;
return thread.comments.nodes.some(
(c) => c?.pullRequestReview?.databaseId === params.review_id
);
});
if (params.approved_by) {
threadsForReview = threadsForReview.filter(
(thread) => threadHasThumbsUpFrom(thread, params.approved_by)
);
}
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,
@@ -143630,30 +143737,10 @@ function GetReviewCommentsTool(ctx) {
threadCount: 0, threadCount: 0,
commentsPath: null, commentsPath: null,
toc: null, toc: null,
instructions: params.approved_by ? `no threads with \u{1F44D} from ${params.approved_by}` : "no threads found for this review" instructions: approvedBy ? `no threads with \u{1F44D} from ${approvedBy}` : "no threads found for this review"
}; };
} }
const prFilesResponse = await ctx.octokit.rest.pulls.listFiles({ const { threadBlocks, reviewer, formatted } = result;
owner: ctx.repo.owner,
repo: ctx.repo.name,
pull_number: params.pull_number
});
const filePatchMap = /* @__PURE__ */ new Map();
for (const file2 of prFilesResponse.data) {
if (file2.patch) {
filePatchMap.set(file2.filename, parseFilePatches(file2.patch));
}
}
const { threadBlocks, reviewer } = buildThreadBlocks(
threadsForReview,
filePatchMap,
params.review_id
);
const formatted = formatReviewThreads(threadBlocks, {
pullNumber: params.pull_number,
reviewId: params.review_id,
reviewer
});
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");
@@ -144027,6 +144114,12 @@ function getTempDir() {
} }
return tempDir; return tempDir;
} }
function isGitCommand(command) {
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);
}
function ShellTool(ctx) { function ShellTool(ctx) {
return tool({ return tool({
name: "shell", name: "shell",
@@ -144036,9 +144129,15 @@ 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 \u2014 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 ?? 3e4, 12e4); const timeout = Math.min(params.timeout ?? 3e4, 12e4);
const cwd = params.working_directory ?? process.cwd(); const cwd = params.working_directory ?? process.cwd();
const env2 = resolveEnv(ctx.payload.shell === "enabled" ? "inherit" : "restricted"); const env2 = resolveEnv(ctx.payload.shell === "enabled" ? "inherit" : "restricted");
@@ -144445,6 +144544,7 @@ function computeModes() {
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:
@@ -144517,6 +144617,7 @@ Keep the progress comment extremely brief. The summary should be 1-2 sentences m
6. **SUBMIT** \u2014 Use ${ghPullfrogMcpName}/create_pull_request_review: 6. **SUBMIT** \u2014 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 \u2014 neither inline comments nor actionable content in the body. An approval signals "no changes needed."
${permalinkTip} ${permalinkTip}
` `
@@ -144622,6 +144723,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}
@@ -144639,7 +144741,7 @@ import { join as join11 } from "node:path";
// package.json // package.json
var package_default = { var package_default = {
name: "@pullfrog/pullfrog", name: "@pullfrog/pullfrog",
version: "0.0.173", version: "0.0.174",
type: "module", type: "module",
files: [ files: [
"index.js", "index.js",
@@ -146332,6 +146434,14 @@ ${stderrContext}`);
usage usage
}; };
} }
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,
@@ -146781,10 +146891,18 @@ ${ctx.error}` : ctx.error;
} }
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) : void 0;
const customParts = [];
if (runId) {
const apiUrl = getApiUrl();
customParts.push(
`[Rerun failed job \u2794](${apiUrl}/trigger/${repoContext.owner}/${repoContext.name}/${runId}?action=rerun)`
);
}
const footer = buildPullfrogFooter({ const footer = buildPullfrogFooter({
triggeredBy: true, triggeredBy: true,
workflowRun: runId ? { owner: repoContext.owner, repo: repoContext.name, runId } : void 0 workflowRun: runId ? { owner: repoContext.owner, repo: repoContext.name, runId } : void 0,
customParts
}); });
await octokit.rest.issues.updateComment({ await octokit.rest.issues.updateComment({
owner: repoContext.owner, owner: repoContext.owner,
@@ -147203,7 +147321,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",
@@ -147296,7 +147414,7 @@ function resolvePayload(resolvedPromptInput, repoSettings) {
version: jsonPayload?.version ?? package_default.version, version: jsonPayload?.version ?? package_default.version,
agent: resolvedAgent, agent: resolvedAgent,
prompt, prompt,
triggeringUser: jsonPayload?.triggeringUser ?? // 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 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 : void 0), (!isPullfrog(process.env.GITHUB_ACTOR) ? process.env.GITHUB_ACTOR : void 0),
eventInstructions: jsonPayload?.eventInstructions, eventInstructions: jsonPayload?.eventInstructions,
repoInstructions: jsonPayload?.repoInstructions, repoInstructions: jsonPayload?.repoInstructions,
@@ -147314,18 +147432,34 @@ function resolvePayload(resolvedPromptInput, repoSettings) {
} }
// utils/run.ts // utils/run.ts
function handleAgentResult(result) { async function handleAgentResult(ctx) {
if (!result.success) { 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 error49 = ctx.result.error || "agent completed without reporting progress";
try {
await reportErrorToComment({
toolState: ctx.toolState,
error: error49,
title: "Error"
});
} catch {
}
return {
success: false,
error: error49,
output: ctx.result.output || ""
}; };
} }
log.success("Task complete."); log.success("Task complete.");
return { return {
success: true, success: true,
output: result.output || "" output: ctx.result.output || ""
}; };
} }
@@ -147498,7 +147632,7 @@ function parseTimeString(input) {
// utils/workflow.ts // utils/workflow.ts
async function resolveRun(params) { async function resolveRun(params) {
const runId = process.env.GITHUB_RUN_ID || ""; const runId = process.env.GITHUB_RUN_ID ? Number.parseInt(process.env.GITHUB_RUN_ID, 10) : void 0;
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}`);
@@ -147510,7 +147644,7 @@ async function resolveRun(params) {
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) {
@@ -147675,8 +147809,13 @@ ${instructions.user}` : null,
if (toolState.output) { if (toolState.output) {
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 (_) { } catch (_) {
+3 -3
View File
@@ -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) */
+30 -14
View File
@@ -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 */
+7 -1
View File
@@ -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) {
+33 -4
View File
@@ -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
View File
@@ -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();
+25 -9
View File
@@ -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.
+5 -1
View File
@@ -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
View File
@@ -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
View File
@@ -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
View File
@@ -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");
+1 -1
View File
@@ -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;
+20 -1
View File
@@ -160,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",
@@ -169,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
View File
@@ -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();
}
} }
+3
View File
@@ -51,6 +51,7 @@ 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:
@@ -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}
`, `,
@@ -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
View File
@@ -1,6 +1,6 @@
{ {
"name": "@pullfrog/pullfrog", "name": "@pullfrog/pullfrog",
"version": "0.0.173", "version": "0.0.174",
"type": "module", "type": "module",
"files": [ "files": [
"index.js", "index.js",
+68 -26
View File
@@ -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.173", 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,
+1 -1
View File
@@ -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;
} }
+13 -2
View File
@@ -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
View File
@@ -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
View File
@@ -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
View File
@@ -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
View File
@@ -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
View File
@@ -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) {