diff --git a/play.ts b/play.ts index 613d8e3..7578cfd 100644 --- a/play.ts +++ b/play.ts @@ -1,8 +1,8 @@ import { execSync } from "node:child_process"; import { mkdtemp } from "node:fs/promises"; import { tmpdir } from "node:os"; -import { dirname, join } from "node:path"; -import { fileURLToPath } from "node:url"; +import { dirname, join, resolve } from "node:path"; +import { fileURLToPath, pathToFileURL } from "node:url"; import arg from "arg"; import { config } from "dotenv"; import type { AgentResult } from "./agents/shared.ts"; @@ -12,6 +12,7 @@ import { log } from "./utils/cli.ts"; import { runInDocker } from "./utils/docker.ts"; import { ensureGitHubToken } from "./utils/github.ts"; import { isInsideDocker } from "./utils/globals.ts"; +import { runPostCleanup } from "./utils/postCleanup.ts"; import { setupTestRepo } from "./utils/setup.ts"; /** @@ -68,7 +69,13 @@ export async function run(inputsOrPrompt: Inputs | string): Promise } } - const result = await main(); + // wrap main() so post cleanup runs even on failure (mirrors action.yml post-if: "failure() || cancelled()") + let result: AgentResult; + try { + result = await main(); + } finally { + await runPostCleanup(); + } process.chdir(originalCwd); @@ -95,7 +102,11 @@ export async function run(inputsOrPrompt: Inputs | string): Promise } } -if (import.meta.url === `file://${process.argv[1]}`) { +const isDirectExecution = process.argv[1] + ? import.meta.url === pathToFileURL(resolve(process.argv[1])).href + : false; + +if (isDirectExecution) { const args = arg({ "--help": Boolean, "--raw": String, diff --git a/post b/post index 82a087a..92377bd 100755 --- a/post +++ b/post @@ -25838,7 +25838,7 @@ var require_light = __commonJS({ } return this.Events.trigger("scheduled", { args: this.args, options: this.options }); } - async doExecute(chained, clearGlobalState, run2, free) { + async doExecute(chained, clearGlobalState, run, free) { var error2, eventInfo, passed; if (this.retryCount === 0) { this._assertStatus("RUNNING"); @@ -25858,10 +25858,10 @@ var require_light = __commonJS({ } } catch (error1) { error2 = error1; - return this._onFailure(error2, eventInfo, clearGlobalState, run2, free); + return this._onFailure(error2, eventInfo, clearGlobalState, run, free); } } - doExpire(clearGlobalState, run2, free) { + doExpire(clearGlobalState, run, free) { var error2, eventInfo; if (this._states.jobStatus(this.options.id === "RUNNING")) { this._states.next(this.options.id); @@ -25869,9 +25869,9 @@ var require_light = __commonJS({ this._assertStatus("EXECUTING"); eventInfo = { args: this.args, options: this.options, retryCount: this.retryCount }; error2 = new BottleneckError$1(`This job timed out after ${this.options.expiration} ms.`); - return this._onFailure(error2, eventInfo, clearGlobalState, run2, free); + return this._onFailure(error2, eventInfo, clearGlobalState, run, free); } - async _onFailure(error2, eventInfo, clearGlobalState, run2, free) { + async _onFailure(error2, eventInfo, clearGlobalState, run, free) { var retry2, retryAfter; if (clearGlobalState()) { retry2 = await this.Events.trigger("failed", error2, eventInfo); @@ -25879,7 +25879,7 @@ var require_light = __commonJS({ retryAfter = ~~retry2; this.Events.trigger("retry", `Retrying ${this.options.id} after ${retryAfter} ms`, eventInfo); this.retryCount++; - return run2(retryAfter); + return run(retryAfter); } else { this.doDone(eventInfo); await free(this.options, eventInfo); @@ -26517,17 +26517,17 @@ var require_light = __commonJS({ } } _run(index, job, wait) { - var clearGlobalState, free, run2; + var clearGlobalState, free, run; job.doRun(); clearGlobalState = this._clearGlobalState.bind(this, index); - run2 = this._run.bind(this, index, job); + run = this._run.bind(this, index, job); free = this._free.bind(this, index, job); return this._scheduled[index] = { timeout: setTimeout(() => { - return job.doExecute(this._limiter, clearGlobalState, run2, free); + return job.doExecute(this._limiter, clearGlobalState, run, free); }, wait), expiration: job.options.expiration != null ? setTimeout(function() { - return job.doExpire(clearGlobalState, run2, free); + return job.doExpire(clearGlobalState, run, free); }, wait + job.options.expiration) : void 0, job }; @@ -28843,6 +28843,178 @@ var require_semver2 = __commonJS({ } }); +// utils/log.ts +var core = __toESM(require_core(), 1); +var import_table = __toESM(require_src(), 1); + +// utils/globals.ts +import { existsSync } from "node:fs"; +var isCloudflareSandbox = !!process.env.CLOUDFLARE_APPLICATION_ID && !!process.env.SANDBOX_VERSION; +var isGitHubActions = !!process.env.GITHUB_ACTIONS; +var isInsideDocker = existsSync("/.dockerenv"); + +// utils/log.ts +var isDebugEnabled = () => process.env.LOG_LEVEL === "debug" || process.env.ACTIONS_STEP_DEBUG === "true" || core.isDebug(); +function formatArgs(args2) { + return args2.map((arg) => { + if (typeof arg === "string") return arg; + if (arg instanceof Error) return `${arg.message} +${arg.stack}`; + return JSON.stringify(arg); + }).join(" "); +} +function startGroup2(name) { + if (isGitHubActions) { + core.startGroup(name); + } else { + console.group(name); + } +} +function endGroup2() { + if (isGitHubActions) { + core.endGroup(); + } else { + console.groupEnd(); + } +} +function group(name, fn2) { + startGroup2(name); + fn2(); + endGroup2(); +} +function boxString(text, options) { + const { title, maxWidth = 80, indent: indent2 = "", padding = 1 } = options || {}; + const lines = text.trim().split("\n"); + const wrappedLines = []; + for (const line of lines) { + if (line.length <= maxWidth - padding * 2) { + wrappedLines.push(line); + } else { + const words = line.split(" "); + let currentLine = ""; + for (const word of words) { + const testLine = currentLine ? `${currentLine} ${word}` : word; + if (testLine.length <= maxWidth - padding * 2) { + currentLine = testLine; + } else { + if (currentLine) { + wrappedLines.push(currentLine); + currentLine = ""; + } + const maxLineLength2 = maxWidth - padding * 2; + let remainingWord = word; + while (remainingWord.length > maxLineLength2) { + wrappedLines.push(remainingWord.substring(0, maxLineLength2)); + remainingWord = remainingWord.substring(maxLineLength2); + } + currentLine = remainingWord; + } + } + if (currentLine) { + wrappedLines.push(currentLine); + } + } + } + const maxLineLength = Math.max(...wrappedLines.map((line) => line.length)); + const contentBoxWidth = maxLineLength + padding * 2; + const titleLineLength = title ? ` ${title} `.length : 0; + const boxWidth = Math.max(contentBoxWidth, titleLineLength); + let result = ""; + if (title) { + const titleLine = ` ${title} `; + const titlePadding = Math.max(0, boxWidth - titleLine.length); + result += `${indent2}\u250C${titleLine}${"\u2500".repeat(titlePadding)}\u2510 +`; + } + if (!title) { + result += `${indent2}\u250C${"\u2500".repeat(boxWidth)}\u2510 +`; + } + for (const line of wrappedLines) { + const paddedLine = line.padEnd(maxLineLength); + result += `${indent2}\u2502${" ".repeat(padding)}${paddedLine}${" ".repeat(padding)}\u2502 +`; + } + result += `${indent2}\u2514${"\u2500".repeat(boxWidth)}\u2518`; + return result; +} +function box(text, options) { + const boxContent = boxString(text, options); + core.info(boxContent); +} +function printTable(rows, options) { + const { title } = options || {}; + const tableData = rows.map( + (row) => row.map((cell) => { + if (typeof cell === "string") { + return cell; + } + return cell.data; + }) + ); + const formatted = (0, import_table.table)(tableData); + if (title) { + core.info(` +${title}`); + } + core.info(` +${formatted} +`); +} +function separator(length = 50) { + const separatorText = "\u2500".repeat(length); + core.info(separatorText); +} +function ts() { + return isDebugEnabled() ? `[${(/* @__PURE__ */ new Date()).toISOString()}] ` : ""; +} +var log = { + /** Print info message */ + info: (...args2) => { + core.info(`${ts()}${formatArgs(args2)}`); + }, + /** Print warning message */ + warning: (...args2) => { + core.warning(`${ts()}${formatArgs(args2)}`); + }, + /** Print error message */ + error: (...args2) => { + core.error(`${ts()}${formatArgs(args2)}`); + }, + /** Print success message */ + success: (...args2) => { + core.info(`${ts()}\xBB ${formatArgs(args2)}`); + }, + /** Print debug message (only if LOG_LEVEL=debug) */ + debug: (...args2) => { + if (isDebugEnabled()) { + core.info(`[${(/* @__PURE__ */ new Date()).toISOString()}] [DEBUG] ${formatArgs(args2)}`); + } + }, + /** Print a formatted box with text */ + box, + /** Print a formatted table using the table package */ + table: printTable, + /** Print a separator line */ + separator, + /** Start a collapsed group (GitHub Actions) or regular group (local) */ + startGroup: startGroup2, + /** End a collapsed group */ + endGroup: endGroup2, + /** Run a callback within a collapsed group */ + group, + /** Log tool call information to console with formatted output */ + toolCall: ({ toolName, input }) => { + const inputFormatted = formatJsonValue(input); + const output = inputFormatted !== "{}" ? `\xBB ${toolName}(${inputFormatted})` : `\xBB ${toolName}()`; + log.info(output.trimEnd()); + } +}; +function formatJsonValue(value2) { + const compact = JSON.stringify(value2); + return compact.length > 80 || compact.includes("\n") ? JSON.stringify(value2, null, 2) : compact; +} + // node_modules/.pnpm/@ark+util@0.56.0/node_modules/@ark/util/out/arrays.js var liftArray = (data) => Array.isArray(data) ? data : [data]; var spliterate = (arr, predicate) => { @@ -37299,178 +37471,6 @@ var schema = ark.schema; var define2 = ark.define; var declare = ark.declare; -// utils/log.ts -var core = __toESM(require_core(), 1); -var import_table = __toESM(require_src(), 1); - -// utils/globals.ts -import { existsSync } from "node:fs"; -var isCloudflareSandbox = !!process.env.CLOUDFLARE_APPLICATION_ID && !!process.env.SANDBOX_VERSION; -var isGitHubActions = !!process.env.GITHUB_ACTIONS; -var isInsideDocker = existsSync("/.dockerenv"); - -// utils/log.ts -var isDebugEnabled = () => process.env.LOG_LEVEL === "debug" || process.env.ACTIONS_STEP_DEBUG === "true" || core.isDebug(); -function formatArgs(args2) { - return args2.map((arg) => { - if (typeof arg === "string") return arg; - if (arg instanceof Error) return `${arg.message} -${arg.stack}`; - return JSON.stringify(arg); - }).join(" "); -} -function startGroup2(name) { - if (isGitHubActions) { - core.startGroup(name); - } else { - console.group(name); - } -} -function endGroup2() { - if (isGitHubActions) { - core.endGroup(); - } else { - console.groupEnd(); - } -} -function group(name, fn2) { - startGroup2(name); - fn2(); - endGroup2(); -} -function boxString(text, options) { - const { title, maxWidth = 80, indent: indent2 = "", padding = 1 } = options || {}; - const lines = text.trim().split("\n"); - const wrappedLines = []; - for (const line of lines) { - if (line.length <= maxWidth - padding * 2) { - wrappedLines.push(line); - } else { - const words = line.split(" "); - let currentLine = ""; - for (const word of words) { - const testLine = currentLine ? `${currentLine} ${word}` : word; - if (testLine.length <= maxWidth - padding * 2) { - currentLine = testLine; - } else { - if (currentLine) { - wrappedLines.push(currentLine); - currentLine = ""; - } - const maxLineLength2 = maxWidth - padding * 2; - let remainingWord = word; - while (remainingWord.length > maxLineLength2) { - wrappedLines.push(remainingWord.substring(0, maxLineLength2)); - remainingWord = remainingWord.substring(maxLineLength2); - } - currentLine = remainingWord; - } - } - if (currentLine) { - wrappedLines.push(currentLine); - } - } - } - const maxLineLength = Math.max(...wrappedLines.map((line) => line.length)); - const contentBoxWidth = maxLineLength + padding * 2; - const titleLineLength = title ? ` ${title} `.length : 0; - const boxWidth = Math.max(contentBoxWidth, titleLineLength); - let result = ""; - if (title) { - const titleLine = ` ${title} `; - const titlePadding = Math.max(0, boxWidth - titleLine.length); - result += `${indent2}\u250C${titleLine}${"\u2500".repeat(titlePadding)}\u2510 -`; - } - if (!title) { - result += `${indent2}\u250C${"\u2500".repeat(boxWidth)}\u2510 -`; - } - for (const line of wrappedLines) { - const paddedLine = line.padEnd(maxLineLength); - result += `${indent2}\u2502${" ".repeat(padding)}${paddedLine}${" ".repeat(padding)}\u2502 -`; - } - result += `${indent2}\u2514${"\u2500".repeat(boxWidth)}\u2518`; - return result; -} -function box(text, options) { - const boxContent = boxString(text, options); - core.info(boxContent); -} -function printTable(rows, options) { - const { title } = options || {}; - const tableData = rows.map( - (row) => row.map((cell) => { - if (typeof cell === "string") { - return cell; - } - return cell.data; - }) - ); - const formatted = (0, import_table.table)(tableData); - if (title) { - core.info(` -${title}`); - } - core.info(` -${formatted} -`); -} -function separator(length = 50) { - const separatorText = "\u2500".repeat(length); - core.info(separatorText); -} -function ts() { - return isDebugEnabled() ? `[${(/* @__PURE__ */ new Date()).toISOString()}] ` : ""; -} -var log = { - /** Print info message */ - info: (...args2) => { - core.info(`${ts()}${formatArgs(args2)}`); - }, - /** Print warning message */ - warning: (...args2) => { - core.warning(`${ts()}${formatArgs(args2)}`); - }, - /** Print error message */ - error: (...args2) => { - core.error(`${ts()}${formatArgs(args2)}`); - }, - /** Print success message */ - success: (...args2) => { - core.info(`${ts()}\xBB ${formatArgs(args2)}`); - }, - /** Print debug message (only if LOG_LEVEL=debug) */ - debug: (...args2) => { - if (isDebugEnabled()) { - core.info(`[${(/* @__PURE__ */ new Date()).toISOString()}] [DEBUG] ${formatArgs(args2)}`); - } - }, - /** Print a formatted box with text */ - box, - /** Print a formatted table using the table package */ - table: printTable, - /** Print a separator line */ - separator, - /** Start a collapsed group (GitHub Actions) or regular group (local) */ - startGroup: startGroup2, - /** End a collapsed group */ - endGroup: endGroup2, - /** Run a callback within a collapsed group */ - group, - /** Log tool call information to console with formatted output */ - toolCall: ({ toolName, input }) => { - const inputFormatted = formatJsonValue(input); - const output = inputFormatted !== "{}" ? `\xBB ${toolName}(${inputFormatted})` : `\xBB ${toolName}()`; - log.info(output.trimEnd()); - } -}; -function formatJsonValue(value2) { - const compact = JSON.stringify(value2); - return compact.length > 80 || compact.includes("\n") ? JSON.stringify(value2, null, 2) : compact; -} - // utils/buildPullfrogFooter.ts var PULLFROG_DIVIDER = ""; var FROG_LOGO = `Pullfrog`; @@ -41413,7 +41413,7 @@ function resolvePromptInput() { return jsonPayload; } -// post.ts +// utils/postCleanup.ts var SHOULD_CHECK_REASON = true; async function validateStuckProgressComment(promptInput, octokit, owner, repo) { if (!promptInput?.progressCommentId) { @@ -41441,6 +41441,7 @@ async function validateStuckProgressComment(promptInput, octokit, owner, repo) { } } async function getIsCancelled(params) { + if (!params.runIdStr) return false; try { const { data: jobs } = await params.octokit.rest.actions.listJobsForWorkflowRun({ owner: params.repoContext.owner, @@ -41471,7 +41472,6 @@ async function getIsCancelled(params) { async function runPostCleanup() { log.info("\xBB [post] starting post cleanup"); const runIdStr = process.env.GITHUB_RUN_ID; - if (!runIdStr) return log.info("\xBB [post] no GITHUB_RUN_ID available, skipping cleanup"); let promptInput = null; try { const resolved = resolvePromptInput(); @@ -41511,16 +41511,15 @@ async function runPostCleanup() { log.error(`[post] failed to update comment: ${errorMessage}`); } } -async function run() { - try { - await runPostCleanup(); - } catch (error2) { - const message = error2 instanceof Error ? error2.message : String(error2); - log.error(`[post] unexpected error: ${message}`); - } -} + +// post.ts log.debug(`[post] script started at ${(/* @__PURE__ */ new Date()).toISOString()}`); -await run(); +try { + await runPostCleanup(); +} catch (error2) { + const message = error2 instanceof Error ? error2.message : String(error2); + log.error(`[post] unexpected error: ${message}`); +} /*! Bundled license information: undici/lib/fetch/body.js: diff --git a/post.ts b/post.ts index 1f602f8..74d7bb4 100644 --- a/post.ts +++ b/post.ts @@ -6,180 +6,14 @@ * 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 - } -} +import { runPostCleanup } from "./utils/postCleanup.ts"; log.debug(`[post] script started at ${new Date().toISOString()}`); -await run(); +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 +} diff --git a/utils/postCleanup.ts b/utils/postCleanup.ts new file mode 100644 index 0000000..6515527 --- /dev/null +++ b/utils/postCleanup.ts @@ -0,0 +1,163 @@ +import { LEAPING_INTO_ACTION_PREFIX } from "../mcp/comment.ts"; +import { log } from "./cli.ts"; +import { buildErrorCommentBody } from "./exitHandler.ts"; +import { createOctokit, parseRepoContext } from "./github.ts"; +import { type ResolvedPromptInput, resolvePromptInput } from "./payload.ts"; +import { getJobToken } from "./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 | undefined; +}): Promise { + if (!params.runIdStr) return false; // can't check without a run ID — assume failure + 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 +} + +export async function runPostCleanup(): Promise { + log.info("» [post] starting post cleanup"); + + const runIdStr = process.env.GITHUB_RUN_ID; + + // 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}`); + } +}