refactor progress comments into a bundled type + helper module (#567)

* refactor progress comments into a single bundled type + helper module

introduce ProgressComment ({ id, type: "issue" | "review" }) as the canonical handle for
the GitHub comment a run uses to report progress, and route every read/update/delete/create
through a single helper module (action/utils/progressComment.ts). previously every site that
touched the progress comment hardcoded octokit.rest.issues.*Comment, which made adding a
second comment type (review-thread replies) require duplicating the same branch in 6+ places
— the same shape that bit pullfrog/app#445.

new capability: when the address-reviews trigger fires for a one-off review comment, the
"Leaping into action" comment is now posted as a reply in that review thread instead of as
a top-level PR timeline comment. the helper handles failure (e.g. parent comment deleted)
by silently falling back to a top-level issue comment, so the run never loses its progress
surface.

changes:

- action/utils/progressComment.ts (new) — ProgressComment type + getProgressComment,
  updateProgressComment, deleteProgressCommentApi, createLeapingProgressComment. uses a
  structural Octokit interface to bridge the @octokit/rest version mismatch between the
  action package (v22) and the root project (v21).
- action/internal/index.ts — re-export the new types and helpers for cross-boundary use.
- action/external.ts, action/utils/payload.ts — replace progressCommentId: string with
  progressComment: { id: string, type: "issue" | "review" } in WriteablePayload + JsonPayload.
  wire-format breaking, no legacy fallback (in-flight runs across the deploy lose their
  progress comment, fine).
- action/mcp/server.ts — ToolState.progressCommentId becomes
  progressComment: ProgressComment | null | undefined (same tristate semantics).
- action/main.ts, action/mcp/comment.ts, action/utils/errorReport.ts,
  action/utils/postCleanup.ts — every issues.*Comment call against the progress comment
  routes through the helper module. zero hardcoded API branching outside the helper.
- utils/github/triggerWorkflow.ts — drop createLeapingComment + updateCommentToLeaping;
  dispatchAndTrackWorkflow gains a resolution chain (existingComment → replyToReviewComment
  → triggeringIssue → none) and an existingComment: ProgressComment param plus
  replyToReviewComment: { pullNumber, commentId }.
- utils/webhooks/handleWebhook.ts — dispatch closure threads replyToReviewComment through;
  the one-off review comment branch passes it and skips the now-redundant eyes reaction
  on the comment we're about to reply to.
- app/trigger/[owner]/[repo]/[number]/page.tsx, utils/github/runActionLocal.ts,
  app/api/cli/dispatch/route.ts, app/api/dispatch-workflow/route.ts — call sites updated to
  new shape.

no schema or DB column changes. the existing WorkflowRun.progressCommentId column is still
written by id only; type lives only on the in-flight payload, which is sufficient for
runtime since it's the only thing that needs to know which API to call.

* anneal pass 1: fallback visibility + stale doc/comment updates

- progressComment.ts: when reviewReply→issue fallback fires, prepend a [!NOTE] callout
  with a permalink back to the original review comment. without this, the parent comment
  showed no eyes reaction (deliberately skipped) and no reply, leaving the user with no
  signal that anything happened.
- wiki/post-cleanup.md: update progressCommentId references to progressComment, document
  the new helper-based dispatch by type.
- wiki/main.md: update initToolState({ progressCommentId }) → ({ progressComment })
  in the resolver-chain diagram.
- action/main.ts, action/mcp/review.ts: update two stale comments that referenced the
  old field name.

* anneal pass 2: post-cleanup detection through fallback notice + log cleanup

- isLeapingIntoActionCommentBody: strip a leading GFM blockquote/alert before
  testing the leaping prefix. without this, the [!NOTE] callout that the
  reviewReply→issue fallback prepends would prevent post-cleanup from
  recognizing the stuck "Leaping into action..." comment, leaving it permanently
  on the PR timeline if the workflow died before any progress update.
- progressComment helper: switch from log.warning (action-flavored, emits a
  ::warning:: GitHub Actions annotation) to console.warn so the helper doesn't
  pollute Vercel logs when invoked from the webhook context.
- triggerWorkflow.ts: drop the duplicate caller-side log on review-reply
  failure — the helper already speaks loudly. Reword the catch-branch log to
  reflect that it now only fires when both the reply AND the helper's internal
  fallback failed.
- progressComment.ts: document that the [!NOTE] fallback notice is overwritten
  on the first report_progress call, and explain the trade-off vs persisting
  it through the action payload + ToolState.

* debloat: drop the [!NOTE] fallback callout

Reverting two pieces from the prior anneal pass:

- progressComment.ts: drop the [!NOTE] callout that the reviewReply→issue fallback
  prepended to the leaping body. It disappeared on the agent's first report_progress
  call, which made it half-committed to visibility — worse than either properly
  persisting it (real engineering) or leaving the fallback silent (current choice).
  The console.warn diagnostic and the workflow-run footer link in the leaping
  comment itself give us enough signal for the rare case where both API endpoints
  fail at once.
- isLeapingIntoActionCommentBody: revert the leading-blockquote stripping; only
  needed to compensate for the [!NOTE] callout.

Keeping: the console.warn-vs-log.warning fix (real cross-runtime concern), the
duplicate-log drop in triggerWorkflow.ts, the wiki updates, and the two stale
source-comment fixes.

* fix: prevent stranded task list overwriting post-cleanup message

When a run is cancelled, the action's todoTracker may have an HTTP write in
flight to GitHub when SIGTERM lands. The action process dies, but the request
data has already left the socket — GitHub processes it and updates the comment
body to the (stale) task list. Meanwhile post-cleanup, running in a separate
process, writes the "This run was cancelled 🛑" message. If the tracker's
in-flight write happens to land *after* post-cleanup's write, the user never
sees the cancellation message.

Two-layer fix:
- Action side: cancel the tracker in the SIGTERM signal handler so no new
  debounced writes get scheduled. This shrinks the race window but can't
  un-send a request already on the wire.
- Post-cleanup side: after writing, verify the body landed and re-issue if
  another write clobbered ours. Loops up to 3× with a 3s settle delay so
  delayed in-flight writes from the dying action have time to arrive before
  our read-back check decides whether to retry.

* lint: import createLeapingProgressComment from pullfrog/internal in test script

* address bot review findings: reply-target root, version bump, GET error handling

Three real findings from the bot reviews on #567 plus a small DRY pass:

1. handleWebhook reply-target: `newComments[0]` may be a reply, not a
   top-level review comment. `getReviewCommentsWithReplies` returns root +
   replies for any thread the review touched, and `pull_request_review_id`
   filtering only narrows by *which review submitted*, not *root vs reply*.
   When a user submits a single reply as their entire review (e.g. replying
   to someone else's comment to ping @pullfrog), the reply ID flowed through
   to `createReplyForReviewComment`, which 422s on replies-to-replies and
   degraded to a top-level issue comment — exactly the polluted-PR-timeline
   behavior this PR was built to remove. Walk up `in_reply_to` from the
   already-fetched thread data to find the root and reply there instead.

2. action/package.json: bumped 0.0.202 → 0.0.204. main is at 0.0.203 and
   our wire format changed; without a bump validateCompatibility can't
   surface the mismatch on the deploy boundary, and the merge would have
   gone backwards.

3. postCleanup writeAndVerify: distinguish a thrown verify-GET from a
   "body got overwritten" mismatch. Treating a transient 5xx/rate-limit GET
   the same as a clobber wasted PUT attempts and printed a misleading
   "in-flight writes kept clobbering us" warning. We trust our PUT (which
   returned 200) and exit instead of amplifying writes against a flaky API.

4. Small DRY: extracted parseProgressComment for the
   `{ id: string; type } -> ProgressComment` parse that had drifted across
   server.ts and postCleanup.ts.
This commit is contained in:
Colin McDonnell
2026-05-06 01:50:58 +00:00
committed by pullfrog[bot]
parent 1e17a76863
commit 560e27bda5
11 changed files with 461 additions and 116 deletions
+57 -61
View File
@@ -4,6 +4,11 @@ import { buildPullfrogFooter, stripExistingFooter } from "../utils/buildPullfrog
import { log } from "../utils/cli.ts";
import { fixDoubleEscapedString } from "../utils/fixDoubleEscapedString.ts";
import { patchWorkflowRunFields } from "../utils/patchWorkflowRunFields.ts";
import {
createLeapingProgressComment,
deleteProgressCommentApi,
updateProgressComment,
} from "../utils/progressComment.ts";
import type { ToolContext } from "./server.ts";
import { execute, tool } from "./shared.ts";
@@ -184,12 +189,15 @@ export const ReportProgress = type({
/**
* Report progress to a GitHub comment.
*
* progressCommentId has three states:
* progressComment has three states:
* - undefined: no comment yet — will create one if an issue/PR target exists
* - number: active comment — will update it in place
* - object: active comment — will update it in place via the right REST endpoint for its type
* - null: deliberately deleted (e.g. after submitting a PR review) — skips silently
*
* The body is always tracked in lastProgressBody for the job summary regardless of comment state.
*
* The "existing plan comment" path always targets a top-level issue comment (plan comments are
* created by create_issue_comment with type:"Plan", never as review-thread replies).
*/
export async function reportProgress(
ctx: ToolContext,
@@ -212,6 +220,7 @@ export async function reportProgress(
const issueNumber = ctx.payload.event.issue_number ?? ctx.toolState.issueNumber;
const isPlanMode = ctx.toolState.selectedMode === "Plan";
const apiCtx = { octokit: ctx.octokit, owner: ctx.repo.owner, repo: ctx.repo.name };
// when editing existing plan: update the plan comment from tool state (set by select_mode)
if (target_plan_comment === true && ctx.toolState.existingPlanCommentId === undefined) {
@@ -225,63 +234,57 @@ export async function reportProgress(
const footer = buildCommentFooter(ctx, customParts);
const bodyWithFooter = `${bodyWithoutFooter}${footer}`;
const result = await ctx.octokit.rest.issues.updateComment({
owner: ctx.repo.owner,
repo: ctx.repo.name,
comment_id: commentId,
body: bodyWithFooter,
});
const result = await updateProgressComment(
apiCtx,
{ id: commentId, type: "issue" },
bodyWithFooter
);
ctx.toolState.wasUpdated = true;
if (isPlanMode && result.data.node_id) {
await patchWorkflowRunFields(ctx, { planCommentNodeId: result.data.node_id });
if (isPlanMode && result.node_id) {
await patchWorkflowRunFields(ctx, { planCommentNodeId: result.node_id });
}
return {
commentId: result.data.id,
url: result.data.html_url,
body: result.data.body || "",
commentId: result.id,
url: result.html_url,
body: result.body || "",
action: "updated",
};
}
const existingCommentId = ctx.toolState.progressCommentId;
const existingComment = ctx.toolState.progressComment;
// if we already have a progress comment, update it
if (existingCommentId) {
if (existingComment) {
const customParts =
isPlanMode && issueNumber !== undefined
? [buildImplementPlanLink(ctx, issueNumber, existingCommentId)]
? [buildImplementPlanLink(ctx, issueNumber, existingComment.id)]
: undefined;
const bodyWithoutFooter = stripExistingFooter(body);
const footer = buildCommentFooter(ctx, customParts);
const bodyWithFooter = `${bodyWithoutFooter}${footer}`;
const result = await ctx.octokit.rest.issues.updateComment({
owner: ctx.repo.owner,
repo: ctx.repo.name,
comment_id: existingCommentId,
body: bodyWithFooter,
});
const result = await updateProgressComment(apiCtx, existingComment, bodyWithFooter);
ctx.toolState.wasUpdated = true;
if (isPlanMode && result.data.node_id) {
await patchWorkflowRunFields(ctx, { planCommentNodeId: result.data.node_id });
if (isPlanMode && result.node_id) {
await patchWorkflowRunFields(ctx, { planCommentNodeId: result.node_id });
}
return {
commentId: result.data.id,
url: result.data.html_url,
body: result.data.body || "",
commentId: result.id,
url: result.html_url,
body: result.body || "",
action: "updated",
};
}
// null = progress comment was deleted by stranded-comment cleanup in main.ts
if (existingCommentId === null) {
if (existingComment === null) {
return { body, action: "skipped" };
}
@@ -294,49 +297,43 @@ export async function reportProgress(
}
// for new comments, we need to create first, then update with Plan link if in Plan mode
// self-created progress comments are always top-level issue comments — review-reply
// progress comments only originate from the dispatch path and arrive pre-created.
const initialBody = addFooter(ctx, body);
const created = await createLeapingProgressComment(
apiCtx,
{ kind: "issue", issueNumber },
initialBody
);
const result = await ctx.octokit.rest.issues.createComment({
owner: ctx.repo.owner,
repo: ctx.repo.name,
issue_number: issueNumber,
body: initialBody,
});
// store the comment ID for future updates
ctx.toolState.progressCommentId = result.data.id;
ctx.toolState.progressComment = created.comment;
ctx.toolState.wasUpdated = true;
// if Plan mode, update the comment to add the "Implement plan" link
if (isPlanMode) {
const customParts = [buildImplementPlanLink(ctx, issueNumber, result.data.id)];
const customParts = [buildImplementPlanLink(ctx, issueNumber, created.comment.id)];
const bodyWithoutFooter = stripExistingFooter(body);
const footer = buildCommentFooter(ctx, customParts);
const bodyWithPlanLink = `${bodyWithoutFooter}${footer}`;
const updateResult = await ctx.octokit.rest.issues.updateComment({
owner: ctx.repo.owner,
repo: ctx.repo.name,
comment_id: result.data.id,
body: bodyWithPlanLink,
});
const updateResult = await updateProgressComment(apiCtx, created.comment, bodyWithPlanLink);
if (updateResult.data.node_id) {
await patchWorkflowRunFields(ctx, { planCommentNodeId: updateResult.data.node_id });
if (updateResult.node_id) {
await patchWorkflowRunFields(ctx, { planCommentNodeId: updateResult.node_id });
}
return {
commentId: updateResult.data.id,
url: updateResult.data.html_url,
body: updateResult.data.body || "",
commentId: updateResult.id,
url: updateResult.html_url,
body: updateResult.body || "",
action: "created",
};
}
return {
commentId: result.data.id,
url: result.data.html_url,
body: result.data.body || "",
commentId: created.comment.id,
url: created.html_url,
body: created.body || "",
action: "created",
};
}
@@ -393,20 +390,19 @@ export function ReportProgressTool(ctx: ToolContext) {
* Delete the progress comment if it exists.
* Used by main.ts for stranded-comment cleanup (orphaned "Leaping into action" or
* checklist left by the todo tracker when the agent didn't call report_progress).
* Sets progressCommentId to null so subsequent report_progress calls are no-ops.
* Sets progressComment to null so subsequent report_progress calls are no-ops.
*/
export async function deleteProgressComment(ctx: ToolContext): Promise<boolean> {
const existingCommentId = ctx.toolState.progressCommentId;
if (!existingCommentId) {
const existing = ctx.toolState.progressComment;
if (!existing) {
return false;
}
try {
await ctx.octokit.rest.issues.deleteComment({
owner: ctx.repo.owner,
repo: ctx.repo.name,
comment_id: existingCommentId,
});
await deleteProgressCommentApi(
{ octokit: ctx.octokit, owner: ctx.repo.owner, repo: ctx.repo.name },
existing
);
} catch (error) {
// ignore 404 - comment already deleted
if (error instanceof Error && error.message.includes("Not Found")) {
@@ -417,7 +413,7 @@ export async function deleteProgressComment(ctx: ToolContext): Promise<boolean>
}
// set to null (not undefined) so report_progress skips instead of creating a new comment
ctx.toolState.progressCommentId = null;
ctx.toolState.progressComment = null;
return true;
}
+1 -1
View File
@@ -540,7 +540,7 @@ export function CreatePullRequestReviewTool(ctx: ToolContext) {
// a submitted review obsoletes the progress comment — the review IS the
// durable artifact. owned here (not in main.ts) so cleanup is atomic with
// submission and survives any path out of the run (success, timeout,
// crash). deleteProgressComment sets progressCommentId = null, so a later
// crash). deleteProgressComment sets progressComment = null, so a later
// report_progress call short-circuits to a no-op.
// best-effort: a cleanup failure must not turn a successful review into
// a tool-call failure visible to the agent.
+13 -9
View File
@@ -12,6 +12,11 @@ import { log } from "../utils/cli.ts";
import type { DiffCoverageState } from "../utils/diffCoverage.ts";
import type { OctokitWithPlugins } from "../utils/github.ts";
import type { ResolvedPayload } from "../utils/payload.ts";
import {
type ProgressComment,
type ProgressCommentType,
parseProgressComment,
} from "../utils/progressComment.ts";
import type { AccountPlan } from "../utils/runContext.ts";
import type { RunContextData } from "../utils/runContextData.ts";
import type { TodoTracker } from "../utils/todoTracking.ts";
@@ -110,8 +115,8 @@ export interface ToolState {
promise: Promise<PrepResult[]> | undefined;
results: PrepResult[] | undefined;
};
// undefined = no comment yet, number = active comment, null = deliberately deleted
progressCommentId: number | null | undefined;
// undefined = no comment yet, object = active comment, null = deliberately deleted
progressComment: ProgressComment | null | undefined;
// immutable snapshot: true if a progress comment was pre-created at init time.
// survives deleteProgressComment so handleAgentResult can still detect "expected but never reported".
hadProgressComment: boolean;
@@ -133,20 +138,19 @@ export interface ToolState {
}
interface InitToolStateParams {
progressCommentId: string | undefined;
progressComment: { id: string; type: ProgressCommentType } | undefined;
}
export function initToolState(params: InitToolStateParams): ToolState {
const parsed = params.progressCommentId ? parseInt(params.progressCommentId, 10) : NaN;
const resolvedId = Number.isNaN(parsed) || parsed <= 0 ? undefined : parsed;
const resolved = parseProgressComment(params.progressComment);
if (resolvedId) {
log.info(`» using pre-created progress comment: ${resolvedId}`);
if (resolved) {
log.info(`» using pre-created progress comment: ${resolved.id} (${resolved.type})`);
}
return {
progressCommentId: resolvedId,
hadProgressComment: !!resolvedId,
progressComment: resolved,
hadProgressComment: !!resolved,
backgroundProcesses: new Map(),
usageEntries: [],
};