560e27bda5
* 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.
247 lines
8.8 KiB
TypeScript
247 lines
8.8 KiB
TypeScript
import { isLeapingIntoActionCommentBody } from "../mcp/comment.ts";
|
|
import { getApiUrl } from "./apiUrl.ts";
|
|
import { buildPullfrogFooter } from "./buildPullfrogFooter.ts";
|
|
import { log } from "./cli.ts";
|
|
import { createOctokit, parseRepoContext } from "./github.ts";
|
|
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
|
|
|
|
interface PostCleanupContext {
|
|
repoContext: ReturnType<typeof parseRepoContext>;
|
|
octokit: ReturnType<typeof createOctokit>;
|
|
runId: number | undefined;
|
|
promptInput: JsonPromptInput | null;
|
|
}
|
|
|
|
// controls whether the script should check the reason for the workflow termination.
|
|
// it can be either canceled or failed.
|
|
// YAML file cannot supply it (not in ENV), so an extra request is required to check it.
|
|
const SHOULD_CHECK_REASON = true;
|
|
|
|
function buildErrorCommentBody(ctx: PostCleanupContext, isCancellation: boolean): string {
|
|
let errorMessage = isCancellation
|
|
? `This run was cancelled 🛑\n\nThe workflow was cancelled before completion.`
|
|
: `This run croaked 😵\n\nThe workflow encountered an error before any progress could be reported.`;
|
|
|
|
if (ctx.runId) {
|
|
errorMessage += " Please check the link below for details.";
|
|
}
|
|
|
|
const customParts: string[] = [];
|
|
if (!isCancellation && ctx.runId) {
|
|
const apiUrl = getApiUrl();
|
|
customParts.push(
|
|
`[Rerun failed job ➔](${apiUrl}/trigger/${ctx.repoContext.owner}/${ctx.repoContext.name}/${ctx.runId}?action=rerun)`
|
|
);
|
|
}
|
|
const footer = buildPullfrogFooter({
|
|
triggeredBy: true,
|
|
workflowRun: ctx.runId
|
|
? {
|
|
owner: ctx.repoContext.owner,
|
|
repo: ctx.repoContext.name,
|
|
runId: ctx.runId,
|
|
}
|
|
: undefined,
|
|
customParts,
|
|
});
|
|
return `${errorMessage}${footer}`;
|
|
}
|
|
|
|
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 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 fetched = await getProgressComment(
|
|
{ octokit: ctx.octokit, owner: ctx.repoContext.owner, repo: ctx.repoContext.name },
|
|
comment
|
|
);
|
|
|
|
const body = fetched.body ?? "";
|
|
|
|
if (isLeapingIntoActionCommentBody(body)) {
|
|
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 ${comment.id} is stuck on a todo checklist`);
|
|
return comment;
|
|
}
|
|
|
|
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 ${comment.id}: ${errorMessage}`);
|
|
return null;
|
|
}
|
|
}
|
|
|
|
async function getIsCancelled(ctx: PostCleanupContext): Promise<boolean> {
|
|
if (!ctx.runId) return false; // can't check without a run ID — assume failure
|
|
try {
|
|
const jobsResult = await ctx.octokit.rest.actions.listJobsForWorkflowRun({
|
|
owner: ctx.repoContext.owner,
|
|
repo: ctx.repoContext.name,
|
|
run_id: ctx.runId,
|
|
});
|
|
|
|
// find current job by matching GITHUB_JOB env var.
|
|
// GITHUB_JOB is the job ID (yaml key), but job.name is the display name.
|
|
// for matrix jobs, the name includes matrix values like "build (ubuntu-latest, node-18)"
|
|
// so we match jobs that START with the job ID
|
|
const currentJobName = process.env.GITHUB_JOB;
|
|
const currentJob = currentJobName
|
|
? jobsResult.data.jobs.find(
|
|
(j) => j.name === currentJobName || j.name.startsWith(`${currentJobName} (`)
|
|
)
|
|
: jobsResult.data.jobs[0]; // fallback to first job
|
|
|
|
if (!currentJob) {
|
|
log.warning("[post] could not find current job");
|
|
return false;
|
|
}
|
|
|
|
log.info(`[post] job status: ${currentJob.status}, conclusion: ${currentJob.conclusion}`);
|
|
if (currentJob.conclusion === "cancelled") return true; // whole job explicit cancellation
|
|
|
|
// but if it's still null, check steps for cancellation:
|
|
const cancelledStep = currentJob.steps?.find((step) => step.conclusion === "cancelled");
|
|
if (cancelledStep) {
|
|
log.info(`[post] found cancelled step: ${cancelledStep.name}`);
|
|
return true;
|
|
}
|
|
log.info("[post] no cancellation found, assuming failure");
|
|
} catch (error) {
|
|
log.info(
|
|
`[post] failed to get job status: ${error instanceof Error ? error.message : String(error)}`
|
|
);
|
|
}
|
|
return false; // assuming failure
|
|
}
|
|
|
|
export async function runPostCleanup(): Promise<void> {
|
|
log.info("» [post] starting post cleanup");
|
|
|
|
const runId = process.env.GITHUB_RUN_ID
|
|
? Number.parseInt(process.env.GITHUB_RUN_ID, 10)
|
|
: undefined;
|
|
|
|
// resolve prompt input once and use it for both issue number and comment ID extraction
|
|
// only use the object form (JSON payload), not plain string prompts
|
|
let promptInput: JsonPromptInput | null = null;
|
|
try {
|
|
const resolved = resolvePromptInput();
|
|
if (typeof resolved !== "string") promptInput = resolved;
|
|
} catch (error) {
|
|
log.info(
|
|
`[post] failed to resolve prompt input: ${error instanceof Error ? error.message : String(error)}`
|
|
);
|
|
}
|
|
|
|
// get job token for API calls
|
|
const token = getJobToken();
|
|
const repoContext = parseRepoContext();
|
|
const octokit = createOctokit(token);
|
|
|
|
const ctx: PostCleanupContext = { repoContext, octokit, runId, promptInput };
|
|
|
|
const stuck = await validateStuckProgressComment(ctx);
|
|
|
|
if (!stuck) return log.info("» [post] no stuck progress comment to update, skipping cleanup");
|
|
|
|
log.info(
|
|
`» [post] validated stuck comment: ${stuck.id} (${stuck.type}), updating with error message`
|
|
);
|
|
|
|
try {
|
|
const body = buildErrorCommentBody(
|
|
ctx,
|
|
SHOULD_CHECK_REASON ? await getIsCancelled(ctx) : false
|
|
);
|
|
|
|
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)`
|
|
);
|
|
}
|