add Summarize mode for updatable PR summary comments (#470)

* add Summarize mode for updatable PR summary comments

Introduces a Summarize mode that manages a single summary comment per PR,
updated in place on subsequent pushes. Mirrors the Plan/PlanEdit pattern:
API endpoint for existing-comment lookup at select_mode time, node ID
tracking on WorkflowRun, and SummaryUpdate guidance for edits.

Also fixes summary format instructions: Before/After uses inline <br/>
to avoid double line breaks, metadata line placed after key changes,
SHA-256 anchor instructions strengthened against fabrication.

Made-with: Cursor

* fix pre-existing lint error in checkout.ts

Made-with: Cursor

* fix dead restricted param in deepenForBeforeSha

GitAuthOptions dropped the restricted field in the ASKPASS refactor (#478)
but deepenForBeforeSha (#471) still passed it. Remove the field and the
now-unused shell param from DeepenForBeforeShaParams.

Made-with: Cursor
This commit is contained in:
Colin McDonnell
2026-03-12 05:32:17 +00:00
committed by pullfrog[bot]
parent 6d25adfd1a
commit 4a8c432a48
8 changed files with 309 additions and 28 deletions
+48
View File
@@ -334,6 +334,44 @@ export async function checkoutPrBranch(
};
}
type DeepenForBeforeShaParams = {
gitToken: string;
beforeSha: string;
};
function deepenForBeforeSha(params: DeepenForBeforeShaParams): void {
const isShallow =
$("git", ["rev-parse", "--is-shallow-repository"], { log: false }).trim() === "true";
if (!isShallow) return;
const maxIterations = 10;
for (let i = 0; i < maxIterations; i++) {
try {
$("git", ["cat-file", "-t", params.beforeSha], { log: false });
log.debug(`» before_sha ${params.beforeSha.slice(0, 7)} is now reachable`);
return;
} catch {
// not reachable yet, deepen
}
log.debug(
`» deepening by 50 to reach before_sha ${params.beforeSha.slice(0, 7)} (attempt ${i + 1}/${maxIterations})`
);
try {
$git("fetch", ["--deepen=50", "--no-tags", "origin"], {
token: params.gitToken,
});
} catch {
log.debug(`» deepen for before_sha failed (force-push may have rewritten history)`);
return;
}
}
log.debug(
`» before_sha ${params.beforeSha.slice(0, 7)} not reachable after ${maxIterations * 50} commits`
);
}
export function CheckoutPrTool(ctx: ToolContext) {
return tool({
name: "checkout_pr",
@@ -352,6 +390,16 @@ export function CheckoutPrTool(ctx: ToolContext) {
postCheckoutScript: ctx.postCheckoutScript,
});
// for incremental review/rereview: deepen the clone to include before_sha
// so `git diff before_sha...HEAD` works without the agent needing to fetch manually
const event = ctx.payload.event;
if ("before_sha" in event && event.before_sha) {
deepenForBeforeSha({
gitToken: ctx.gitToken,
beforeSha: event.before_sha,
});
}
const pr = await ctx.octokit.rest.pulls.get({
owner: ctx.repo.owner,
repo: ctx.repo.name,
+21 -12
View File
@@ -9,8 +9,14 @@ import { retry } from "../utils/retry.ts";
import type { ToolContext } from "./server.ts";
import { execute, tool } from "./shared.ts";
/** PATCH workflow-run with plan comment node_id so plan revisions can update that comment in place. */
async function updatePlanCommentId(ctx: ToolContext, planCommentNodeId: string): Promise<void> {
type CommentNodeIdField = "planCommentNodeId" | "summaryCommentNodeId";
/** PATCH workflow-run with a comment node_id so future runs can update that comment in place. */
export async function updateCommentNodeId(
ctx: ToolContext,
field: CommentNodeIdField,
nodeId: string
): Promise<void> {
if (ctx.runId === undefined || !ctx.apiToken) return;
try {
await retry(
@@ -22,7 +28,7 @@ async function updatePlanCommentId(ctx: ToolContext, planCommentNodeId: string):
authorization: `Bearer ${ctx.apiToken}`,
"content-type": "application/json",
},
body: JSON.stringify({ planCommentNodeId }),
body: JSON.stringify({ [field]: nodeId }),
signal: AbortSignal.timeout(10_000),
});
if (!response.ok) throw new Error(`PATCH workflow-run: ${response.status}`);
@@ -30,11 +36,11 @@ async function updatePlanCommentId(ctx: ToolContext, planCommentNodeId: string):
{
maxAttempts: 3,
delayMs: 2000,
label: "updatePlanCommentId",
label: `updateCommentNodeId(${field})`,
}
);
} catch (error) {
log.warning(`updatePlanCommentId exhausted retries: ${error}`);
log.warning(`updateCommentNodeId(${field}) exhausted retries: ${error}`);
}
}
@@ -107,9 +113,9 @@ export const Comment = type({
issueNumber: type.number.describe("the issue number to comment on"),
body: type.string.describe("the comment body content"),
type: type
.enumerated("Plan", "Comment")
.enumerated("Plan", "Summary", "Comment")
.describe(
"Plan: record this comment as the plan for this run (use report_progress for progress/plan updates on the current run). Comment: regular comment (default)."
"Plan: record as the plan for this run. Summary: record as the PR summary comment (one per PR, updated in place). Comment: regular comment (default)."
)
.optional(),
});
@@ -118,7 +124,7 @@ export function CreateCommentTool(ctx: ToolContext) {
return tool({
name: "create_issue_comment",
description:
"Create a comment on a GitHub issue. For progress/plan updates on the current run use report_progress instead. Use type: 'Plan' only when creating a standalone plan comment to record as this run's plan.",
"Create a comment on a GitHub issue or PR. For progress/plan updates on the current run use report_progress instead. Use type: 'Plan' for plan comments, type: 'Summary' for PR summary comments.",
parameters: Comment,
execute: execute(async ({ issueNumber, body, type: commentType }) => {
const bodyWithFooter = await addFooter(ctx, body);
@@ -131,7 +137,10 @@ export function CreateCommentTool(ctx: ToolContext) {
});
if (commentType === "Plan" && result.data.node_id) {
await updatePlanCommentId(ctx, result.data.node_id);
await updateCommentNodeId(ctx, "planCommentNodeId", result.data.node_id);
}
if (commentType === "Summary" && result.data.node_id) {
await updateCommentNodeId(ctx, "summaryCommentNodeId", result.data.node_id);
}
return {
@@ -241,7 +250,7 @@ export async function reportProgress(
ctx.toolState.wasUpdated = true;
if (isPlanMode && result.data.node_id) {
await updatePlanCommentId(ctx, result.data.node_id);
await updateCommentNodeId(ctx, "planCommentNodeId", result.data.node_id);
}
return {
@@ -278,7 +287,7 @@ export async function reportProgress(
ctx.toolState.wasUpdated = true;
if (isPlanMode && result.data.node_id) {
await updatePlanCommentId(ctx, result.data.node_id);
await updateCommentNodeId(ctx, "planCommentNodeId", result.data.node_id);
}
return {
@@ -336,7 +345,7 @@ export async function reportProgress(
});
if (updateResult.data.node_id) {
await updatePlanCommentId(ctx, updateResult.data.node_id);
await updateCommentNodeId(ctx, "planCommentNodeId", updateResult.data.node_id);
}
return {
+11
View File
@@ -218,6 +218,8 @@ const NOSHELL_BLOCKED_SUBCOMMANDS: Record<string, string> = {
// (avoids false positives like --exclude matching --exec)
const NOSHELL_BLOCKED_ARGS = ["--exec", "--extcmd", "--upload-pack", "--receive-pack"];
const COLLAPSE_THRESHOLD = 200;
// SECURITY: subcommand must match [a-z][a-z0-9-]* to reject flags passed as the subcommand.
// this blocks injection of global git options like -c, -C, --exec-path, --config-env, etc.
//
@@ -269,6 +271,15 @@ export function GitTool(ctx: ToolContext) {
}
const output = $("git", [subcommand, ...args], { log: false });
const lineCount = output.split("\n").length;
if (lineCount > COLLAPSE_THRESHOLD) {
log.group(`git ${subcommand} output (${lineCount} lines)`, () => {
log.info(output);
});
} else if (output) {
log.info(output);
}
return { success: true, output };
}),
});
+73 -1
View File
@@ -7,7 +7,7 @@ import { execute, tool } from "./shared.ts";
export const SelectModeParams = type({
mode: type.string.describe(
"the name of the mode to select (e.g., 'Build', 'Plan', 'Review', 'IncrementalReview', 'Fix', 'AddressReviews', 'Task', 'ResolveConflicts')"
"the name of the mode to select (e.g., 'Build', 'Plan', 'Review', 'IncrementalReview', 'Fix', 'AddressReviews', 'Task', 'ResolveConflicts', 'Summarize')"
),
"issue_number?": type("number").describe(
"optional issue number; when provided with Plan mode, used to look up an existing plan comment for this issue (edit vs create)"
@@ -185,6 +185,38 @@ An existing plan comment was found for this issue. Update that comment with the
- call \`${ghPullfrogMcpName}/report_progress\` with results
- if the task involved code changes, push via \`${ghPullfrogMcpName}/push_branch\` and create a PR via \`${ghPullfrogMcpName}/create_pull_request\`
- if the task involved labeling, commenting, or other GitHub operations, perform those directly`,
Summarize: `### Checklist
1. Checkout the PR via \`${ghPullfrogMcpName}/checkout_pr\` — this returns PR metadata and a \`diffPath\`.
2. Delegate a subagent to analyze the diff and produce a structured summary. Include in its prompt:
- the diff file path
- PR metadata (title, file count, commit count, base/head branches)
- format instructions from EVENT INSTRUCTIONS (if any); otherwise use default format: TL;DR, key changes list, per-change sections with before/after framing
- instruct it to use the TOC to selectively read relevant diff sections, not the entire file
- instruct it to return the full summary markdown via \`${ghPullfrogMcpName}/set_output\`
3. After the subagent completes, call \`${ghPullfrogMcpName}/create_issue_comment\` with \`type: "Summary"\` and the summary body.
### Effort
Use mini or auto effort.`,
SummaryUpdate: `### Checklist (updating existing summary)
An existing summary comment was found for this PR. Update it rather than creating a new one.
1. Use \`previousSummaryBody\` from this response as the current summary to revise.
2. Checkout the PR via \`${ghPullfrogMcpName}/checkout_pr\` — this returns PR metadata and a \`diffPath\`.
3. Delegate a subagent with:
- the diff file path and PR metadata
- the existing summary body (\`previousSummaryBody\`) so it can update rather than rewrite from scratch
- format instructions from EVENT INSTRUCTIONS (if any)
- instruct it to produce an updated summary reflecting the current state of the PR and return via \`${ghPullfrogMcpName}/set_output\`
4. After the subagent completes, call \`${ghPullfrogMcpName}/edit_issue_comment\` with \`commentId: existingSummaryCommentId\` (from this response) and the updated summary body.
### Effort
Use mini or auto effort.`,
};
type OrchestratorGuidance = {
@@ -218,6 +250,9 @@ function buildOrchestratorGuidance(mode: Mode, opts: BuildGuidanceOpts = {}): Or
// matches the API response for /repo/[owner]/[repo]/issue/[issueNumber]/plan-comment
export type PlanCommentResponsePayload = { error: string } | { commentId: number; body: string };
// matches the API response for /repo/[owner]/[repo]/pr/[prNumber]/summary-comment
export type SummaryCommentResponsePayload = { error: string } | { commentId: number; body: string };
async function fetchExistingPlanComment(
ctx: ToolContext,
issueNumber: number
@@ -237,6 +272,25 @@ async function fetchExistingPlanComment(
}
}
async function fetchExistingSummaryComment(
ctx: ToolContext,
prNumber: number
): Promise<Extract<SummaryCommentResponsePayload, { commentId: number }> | null> {
if (!ctx.apiToken) return null;
try {
const response = await apiFetch({
path: `/api/repo/${ctx.repo.owner}/${ctx.repo.name}/pr/${prNumber}/summary-comment`,
method: "GET",
headers: { authorization: `Bearer ${ctx.apiToken}` },
signal: AbortSignal.timeout(10_000),
});
const data = (await response.json()) as SummaryCommentResponsePayload;
return response.ok && "commentId" in data ? data : null;
} catch {
return null;
}
}
export function SelectModeTool(ctx: ToolContext) {
return tool({
name: "select_mode",
@@ -287,6 +341,24 @@ export function SelectModeTool(ctx: ToolContext) {
}
}
if (selectedMode.name === "Summarize") {
const prNumber = ctx.payload.event.issue_number;
if (prNumber !== undefined) {
const existing = await fetchExistingSummaryComment(ctx, prNumber);
if (existing !== null) {
ctx.toolState.existingSummaryCommentId = existing.commentId;
return {
...buildOrchestratorGuidance(selectedMode, {
...guidanceOpts,
overrideGuidance: modeGuidance.SummaryUpdate,
}),
existingSummaryCommentId: existing.commentId,
previousSummaryBody: existing.body,
};
}
}
}
return buildOrchestratorGuidance(selectedMode, guidanceOpts);
}),
});
+2
View File
@@ -85,6 +85,8 @@ export interface ToolState {
// set by select_mode when Plan + issue_number and plan-comment API returns existing plan (for report_progress target_plan_comment)
existingPlanCommentId?: number;
previousPlanBody?: string;
// set by select_mode when Summarize mode and summary-comment API returns existing summary
existingSummaryCommentId?: number;
output?: string;
usageEntries: AgentUsage[];
}