#!/usr/bin/env node /** * Post cleanup entry point for pullfrog/pullfrog action. * Runs independently after workflow failure or cancellation. * Searches for Pullfrog comment via GitHub API and updates if stuck on "Leaping into action". */ import { LEAPING_INTO_ACTION_PREFIX } from "./mcp/comment.ts"; import { log } from "./utils/cli.ts"; import { buildErrorCommentBody } from "./utils/exitHandler.ts"; import { createOctokit, parseRepoContext } from "./utils/github.ts"; import { type ResolvedPromptInput, resolvePromptInput } from "./utils/payload.ts"; import { getJobToken } from "./utils/token.ts"; type JsonPromptInput = Extract; // not string /** * 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; /** * Validate that the progress comment is stuck on "Leaping into action" * Fetches the comment by ID and checks if it starts with LEAPING_INTO_ACTION_PREFIX * Returns the comment ID if stuck, null otherwise */ async function validateStuckProgressComment( promptInput: JsonPromptInput | null, octokit: ReturnType, owner: string, repo: string ): Promise { if (!promptInput?.progressCommentId) { log.info("[post] no progressCommentId in prompt input, skipping cleanup"); return null; } const commentId = parseInt(promptInput.progressCommentId, 10); log.info(`[post] validating progressCommentId from prompt input: ${commentId}`); try { const { data: comment } = await octokit.rest.issues.getComment({ owner, repo, comment_id: commentId, }); // check if comment is stuck on "Leaping into action" if (comment.body?.startsWith(LEAPING_INTO_ACTION_PREFIX)) { log.info(`[post] comment ${commentId} is stuck on "Leaping into action"`); return commentId; } log.info(`[post] comment ${commentId} is not stuck (already updated or different content)`); return null; } catch (error) { const errorMessage = error instanceof Error ? error.message : String(error); log.error(`[post] failed to get comment ${commentId}: ${errorMessage}`); return null; } } /** * Detect if the workflow or its steps is cancelled. * While the job is still in_progress, the individual steps may have their conclusions set. */ async function getIsCancelled(params: { repoContext: ReturnType, octokit: ReturnType, runIdStr: string; }): Promise { try { const { data: jobs } = await params.octokit.rest.actions.listJobsForWorkflowRun({ owner: params.repoContext.owner, repo: params.repoContext.name, run_id: Number.parseInt(params.runIdStr, 10), }); // find current job by matching GITHUB_JOB env var // Note: 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 ? jobs.jobs.find(j => j.name === currentJobName || j.name.startsWith(`${currentJobName} (`)) : jobs.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.warning(`[post] failed to get job status: ${error instanceof Error ? error.message : String(error)}`); } return false; // assuming failure } async function runPostCleanup(): Promise { log.info("» [post] starting post cleanup"); const runIdStr = process.env.GITHUB_RUN_ID; if (!runIdStr) return log.info("» [post] no GITHUB_RUN_ID available, skipping cleanup"); // 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.warning(`[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); // validate that progressCommentId from prompt input is stuck on "Leaping into action" const commentId = await validateStuckProgressComment(promptInput, octokit, repoContext.owner, repoContext.name); if (!commentId) return log.info("» [post] no stuck progress comment to update, skipping cleanup"); log.info(`» [post] validated stuck comment: ${commentId}, updating with error message`); try { const body = buildErrorCommentBody({ owner: repoContext.owner, repo: repoContext.name, runId: runIdStr, isCancellation: SHOULD_CHECK_REASON ? await getIsCancelled({ octokit, repoContext, runIdStr }) : false, }); await octokit.rest.issues.updateComment({ owner: repoContext.owner, repo: repoContext.name, comment_id: commentId, body, }); log.info("» [post] successfully updated progress comment"); } catch (error) { const errorMessage = error instanceof Error ? error.message : String(error); log.error(`[post] failed to update comment: ${errorMessage}`); } } async function run(): Promise { try { await runPostCleanup(); } catch (error) { const message = error instanceof Error ? error.message : String(error); log.error(`[post] unexpected error: ${message}`); // don't fail the post script - best effort cleanup } } log.debug(`[post] script started at ${new Date().toISOString()}`); await run();