run post action cleanup in play.ts (#344)

* run post action cleanup in play script after main completes

* clarify that GITHUB_RUN_ID is the actual bail-out gate in play context

* treat GITHUB_RUN_ID as optional in post cleanup

* replace dynamic import with static import of `runPostCleanup`

Export `runPostCleanup` from post.ts and guard the top-level
execution with `import.meta.url` so it only auto-runs as an
entry point. play.ts now statically imports and calls it.

* move runPostCleanup into finally block and let failures propagate

* refactor post cleanup into utility module

move post cleanup logic into a dedicated utility and keep post.ts as a pure script entrypoint. update play.ts to import the shared utility directly and normalize direct-execution detection.

Co-authored-by: Cursor <cursoragent@cursor.com>

---------

Co-authored-by: pullfrog[bot] <226033991+pullfrog[bot]@users.noreply.github.com>
Co-authored-by: Colin McDonnell <colinmcd94@gmail.com>
Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
pullfrog[bot]
2026-02-18 18:32:24 +00:00
committed by pullfrog[bot]
parent 3bf2f8596f
commit 9948c08e7d
4 changed files with 378 additions and 371 deletions
+15 -4
View File
@@ -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<AgentResult>
}
}
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<AgentResult>
}
}
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,
+192 -193
View File
@@ -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 = "<!-- PULLFROG_DIVIDER_DO_NOT_REMOVE_PLZ -->";
var FROG_LOGO = `<a href="https://pullfrog.com"><picture><source media="(prefers-color-scheme: dark)" srcset="https://pullfrog.com/logos/frog-white-full-18px.png"><img src="https://pullfrog.com/logos/frog-green-full-18px.png" width="9px" height="9px" style="vertical-align: middle; " alt="Pullfrog"></picture></a>`;
@@ -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:
+8 -174
View File
@@ -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<ResolvedPromptInput, object>; // 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<typeof createOctokit>,
owner: string,
repo: string
): Promise<number | null> {
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<typeof parseRepoContext>;
octokit: ReturnType<typeof createOctokit>;
runIdStr: string;
}): Promise<boolean> {
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<void> {
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<void> {
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
}
+163
View File
@@ -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<ResolvedPromptInput, object>; // 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<typeof createOctokit>,
owner: string,
repo: string
): Promise<number | null> {
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<typeof parseRepoContext>;
octokit: ReturnType<typeof createOctokit>;
runIdStr: string | undefined;
}): Promise<boolean> {
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<void> {
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}`);
}
}