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:
committed by
pullfrog[bot]
parent
3bf2f8596f
commit
9948c08e7d
@@ -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,
|
||||
|
||||
@@ -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:
|
||||
|
||||
@@ -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
|
||||
}
|
||||
|
||||
@@ -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}`);
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user