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:
committed by
pullfrog[bot]
parent
1e17a76863
commit
560e27bda5
+89
-28
@@ -4,6 +4,12 @@ import { buildPullfrogFooter } from "./buildPullfrogFooter.ts";
|
||||
import { log } from "./cli.ts";
|
||||
import { createOctokit, parseRepoContext } from "./github.ts";
|
||||
import { type ResolvedPromptInput, resolvePromptInput } from "./payload.ts";
|
||||
import {
|
||||
getProgressComment,
|
||||
type ProgressComment,
|
||||
parseProgressComment,
|
||||
updateProgressComment,
|
||||
} from "./progressComment.ts";
|
||||
import { getJobToken } from "./token.ts";
|
||||
|
||||
type JsonPromptInput = Extract<ResolvedPromptInput, object>; // not string
|
||||
@@ -50,41 +56,47 @@ function buildErrorCommentBody(ctx: PostCleanupContext, isCancellation: boolean)
|
||||
return `${errorMessage}${footer}`;
|
||||
}
|
||||
|
||||
async function validateStuckProgressComment(ctx: PostCleanupContext): Promise<number | null> {
|
||||
if (!ctx.promptInput?.progressCommentId) {
|
||||
log.info("[post] no progressCommentId in prompt input, skipping cleanup");
|
||||
async function validateStuckProgressComment(
|
||||
ctx: PostCleanupContext
|
||||
): Promise<ProgressComment | null> {
|
||||
const promptComment = ctx.promptInput?.progressComment;
|
||||
if (!promptComment) {
|
||||
log.info("[post] no progressComment in prompt input, skipping cleanup");
|
||||
return null;
|
||||
}
|
||||
|
||||
const commentId = parseInt(ctx.promptInput.progressCommentId, 10);
|
||||
log.info(`[post] validating progressCommentId from prompt input: ${commentId}`);
|
||||
const comment = parseProgressComment(promptComment);
|
||||
if (!comment) {
|
||||
log.info(`[post] progressComment.id is not a positive integer: ${promptComment.id}`);
|
||||
return null;
|
||||
}
|
||||
log.info(`[post] validating progressComment from prompt input: ${comment.id} (${comment.type})`);
|
||||
|
||||
try {
|
||||
const commentResult = await ctx.octokit.rest.issues.getComment({
|
||||
owner: ctx.repoContext.owner,
|
||||
repo: ctx.repoContext.name,
|
||||
comment_id: commentId,
|
||||
});
|
||||
const fetched = await getProgressComment(
|
||||
{ octokit: ctx.octokit, owner: ctx.repoContext.owner, repo: ctx.repoContext.name },
|
||||
comment
|
||||
);
|
||||
|
||||
const body = commentResult.data.body ?? "";
|
||||
const body = fetched.body ?? "";
|
||||
|
||||
if (isLeapingIntoActionCommentBody(body)) {
|
||||
log.info(`[post] comment ${commentId} is stuck on "Leaping into action"`);
|
||||
return commentId;
|
||||
log.info(`[post] comment ${comment.id} is stuck on "Leaping into action"`);
|
||||
return comment;
|
||||
}
|
||||
|
||||
// detect stranded todo checklists left by the tracker when the process was killed
|
||||
// before the agent could call report_progress with a final summary
|
||||
if (/^- \[[ x]\] |^- \*\*→\*\* |^- ~~/.test(body)) {
|
||||
log.info(`[post] comment ${commentId} is stuck on a todo checklist`);
|
||||
return commentId;
|
||||
log.info(`[post] comment ${comment.id} is stuck on a todo checklist`);
|
||||
return comment;
|
||||
}
|
||||
|
||||
log.info(`[post] comment ${commentId} is not stuck (already updated or different content)`);
|
||||
log.info(`[post] comment ${comment.id} is not stuck (already updated or different content)`);
|
||||
return null;
|
||||
} catch (error) {
|
||||
const errorMessage = error instanceof Error ? error.message : String(error);
|
||||
log.info(`[post] failed to get comment ${commentId}: ${errorMessage}`);
|
||||
log.info(`[post] failed to get comment ${comment.id}: ${errorMessage}`);
|
||||
return null;
|
||||
}
|
||||
}
|
||||
@@ -158,11 +170,13 @@ export async function runPostCleanup(): Promise<void> {
|
||||
|
||||
const ctx: PostCleanupContext = { repoContext, octokit, runId, promptInput };
|
||||
|
||||
const commentId = await validateStuckProgressComment(ctx);
|
||||
const stuck = await validateStuckProgressComment(ctx);
|
||||
|
||||
if (!commentId) return log.info("» [post] no stuck progress comment to update, skipping cleanup");
|
||||
if (!stuck) return log.info("» [post] no stuck progress comment to update, skipping cleanup");
|
||||
|
||||
log.info(`» [post] validated stuck comment: ${commentId}, updating with error message`);
|
||||
log.info(
|
||||
`» [post] validated stuck comment: ${stuck.id} (${stuck.type}), updating with error message`
|
||||
);
|
||||
|
||||
try {
|
||||
const body = buildErrorCommentBody(
|
||||
@@ -170,16 +184,63 @@ export async function runPostCleanup(): Promise<void> {
|
||||
SHOULD_CHECK_REASON ? await getIsCancelled(ctx) : false
|
||||
);
|
||||
|
||||
await ctx.octokit.rest.issues.updateComment({
|
||||
owner: ctx.repoContext.owner,
|
||||
repo: ctx.repoContext.name,
|
||||
comment_id: commentId,
|
||||
body,
|
||||
});
|
||||
|
||||
log.info("» [post] successfully updated progress comment");
|
||||
await writeAndVerify(ctx, stuck, body);
|
||||
} catch (error) {
|
||||
const errorMessage = error instanceof Error ? error.message : String(error);
|
||||
log.info(`[post] failed to update comment: ${errorMessage}`);
|
||||
}
|
||||
}
|
||||
|
||||
// post-cleanup runs in a separate process from the cancelled action, so any in-flight
|
||||
// HTTP write the action's todoTracker had on the wire when SIGTERM landed can still get
|
||||
// processed by GitHub *after* our update — clobbering the cancellation message back to
|
||||
// the stale task list. (action-side mitigation: SIGTERM handler cancels the tracker; here
|
||||
// we close the remaining race by reading back our write and re-issuing if it lost.)
|
||||
const VERIFY_DELAY_MS = 3000;
|
||||
const MAX_WRITE_ATTEMPTS = 3;
|
||||
|
||||
async function writeAndVerify(
|
||||
ctx: PostCleanupContext,
|
||||
comment: ProgressComment,
|
||||
body: string
|
||||
): Promise<void> {
|
||||
const apiCtx = {
|
||||
octokit: ctx.octokit,
|
||||
owner: ctx.repoContext.owner,
|
||||
repo: ctx.repoContext.name,
|
||||
};
|
||||
for (let attempt = 1; attempt <= MAX_WRITE_ATTEMPTS; attempt++) {
|
||||
await updateProgressComment(apiCtx, comment, body);
|
||||
await new Promise((resolve) => setTimeout(resolve, VERIFY_DELAY_MS));
|
||||
|
||||
let fetched: Awaited<ReturnType<typeof getProgressComment>>;
|
||||
try {
|
||||
fetched = await getProgressComment(apiCtx, comment);
|
||||
} catch (error) {
|
||||
// verify GET failed (5xx, secondary rate limit, network blip). the PUT itself
|
||||
// returned 200, so we trust it landed; another write-and-verify pass would just
|
||||
// amplify writes against a flaky GitHub. log and exit — if a stale tracker write
|
||||
// does clobber us, the comment will be wrong but the agent's commit + replies
|
||||
// already conveyed the substance of the run.
|
||||
log.warning(
|
||||
`[post] verify GET failed after attempt ${attempt} — trusting our PUT landed: ${
|
||||
error instanceof Error ? error.message : String(error)
|
||||
}`
|
||||
);
|
||||
return;
|
||||
}
|
||||
|
||||
if (fetched.body === body) {
|
||||
log.info(
|
||||
`» [post] successfully updated progress comment (attempt ${attempt}/${MAX_WRITE_ATTEMPTS})`
|
||||
);
|
||||
return;
|
||||
}
|
||||
log.info(
|
||||
`[post] body was overwritten after our write (attempt ${attempt}/${MAX_WRITE_ATTEMPTS}), retrying`
|
||||
);
|
||||
}
|
||||
log.warning(
|
||||
`[post] gave up after ${MAX_WRITE_ATTEMPTS} attempts — comment may be stale (in-flight writes from the cancelled run kept clobbering us)`
|
||||
);
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user