feat(action): Update "Leaping" comment when workflow failed or cancelled early (#230)
* feat(action): Do cleanup when workflow failed or cancelled early. * fix: avoid naming collision with get-installation-token state. * tmp: add more logging for debugging purposes. * fix: rm wasUpdated state. * FIX: changing approach, separate entrypoint, using db to handle cases when main() never ran. * fix(cleanup): revert changes to exitHandler. * FIX: using event payload for issue and comment retrieval instead of DB. * FIX: use installation token. * feat(docs): wiki article explaining how it works. * fix(docs): shortening. * fix(docs): shortening. * fix: no console. * todo: DNRY for findPullfrogComment. * FIX(DNRY): upgrading octokit/rest and reusing findInitialComment() from triggerWorkflow.ts. * Revert "FIX(DNRY): upgrading octokit/rest and reusing findInitialComment() from triggerWorkflow.ts." This reverts commit 7dd239ba0986c5b0aeacb6ddc9f2deddb83aee82. * fix: rm todo. * fix(DNRY): shortening early exit logging statements. * FIX(API): Avoid extra call for comment body. * feat(DNRY): extracting and reusing buildWorkflowErrorMessage() from exitHandler.ts. * fix(DNRY): extracting more similarities into buildErrorCommentBody. * feat: Add conditional reason check. * fix(merge): replacing resolveInstallationToken with getJobToken. * fix(debug): using higher severity. * fix: Adjusting the implementation of getIsCancelled to use job status instead of workflow. * fix: Take steps conclusion into account when job is in progress. * fix: generic log msg. * fix: jsdoc. * fix(docs): Updating the wiki article according to recent changes. * fix(post): Handling the case when current job runs within matrix. * fix: finding the most recent comment that is ours ANS stuck. * feat(opt): using progressCommentId from object-based prompt when present. * fix(docs): Shortening the documentation 3 times down. * FIX: Only using the prompt.progressCommentId but with validation that it is stuck.
This commit is contained in:
committed by
pullfrog[bot]
parent
51205b3d0a
commit
60da0e5749
@@ -42,6 +42,8 @@ outputs:
|
|||||||
runs:
|
runs:
|
||||||
using: "node24"
|
using: "node24"
|
||||||
main: "entry"
|
main: "entry"
|
||||||
|
post: "post"
|
||||||
|
post-if: "failure() || cancelled()"
|
||||||
|
|
||||||
branding:
|
branding:
|
||||||
icon: "code"
|
icon: "code"
|
||||||
|
|||||||
@@ -145666,6 +145666,19 @@ ${ctx.error}` : ctx.error;
|
|||||||
}
|
}
|
||||||
|
|
||||||
// utils/exitHandler.ts
|
// utils/exitHandler.ts
|
||||||
|
function buildErrorCommentBody(params) {
|
||||||
|
const workflowRunLink = params.runId ? `[workflow run logs](https://github.com/${params.owner}/${params.repo}/actions/runs/${params.runId})` : "workflow run logs";
|
||||||
|
const errorMessage = params.isCancellation ? `This run was cancelled \u{1F6D1}
|
||||||
|
|
||||||
|
The workflow was cancelled before completion. Please check the ${workflowRunLink} for details.` : `This run croaked \u{1F635}
|
||||||
|
|
||||||
|
The workflow encountered an error before any progress could be reported. Please check the ${workflowRunLink} for details.`;
|
||||||
|
const footer = buildPullfrogFooter({
|
||||||
|
triggeredBy: true,
|
||||||
|
workflowRun: params.runId ? { owner: params.owner, repo: params.repo, runId: params.runId } : void 0
|
||||||
|
});
|
||||||
|
return `${errorMessage}${footer}`;
|
||||||
|
}
|
||||||
var cleanupFn;
|
var cleanupFn;
|
||||||
function setupExitHandler(toolState) {
|
function setupExitHandler(toolState) {
|
||||||
let hasCleanedUp = false;
|
let hasCleanedUp = false;
|
||||||
@@ -145689,21 +145702,17 @@ function setupExitHandler(toolState) {
|
|||||||
const commentBody = existingComment.data.body || "";
|
const commentBody = existingComment.data.body || "";
|
||||||
if (commentBody.startsWith(LEAPING_INTO_ACTION_PREFIX)) {
|
if (commentBody.startsWith(LEAPING_INTO_ACTION_PREFIX)) {
|
||||||
const runId = process.env.GITHUB_RUN_ID;
|
const runId = process.env.GITHUB_RUN_ID;
|
||||||
const workflowRunLink = runId ? `[workflow run logs](https://github.com/${repoContext.owner}/${repoContext.name}/actions/runs/${runId})` : "workflow run logs";
|
const body = buildErrorCommentBody({
|
||||||
const errorMessage = isCancellation ? `This run was cancelled \u{1F6D1}
|
owner: repoContext.owner,
|
||||||
|
repo: repoContext.name,
|
||||||
The workflow was cancelled before completion. Please check the ${workflowRunLink} for details.` : `This run croaked \u{1F635}
|
runId,
|
||||||
|
isCancellation
|
||||||
The workflow encountered an error before any progress could be reported. Please check the ${workflowRunLink} for details.`;
|
|
||||||
const footer = buildPullfrogFooter({
|
|
||||||
triggeredBy: true,
|
|
||||||
workflowRun: runId ? { owner: repoContext.owner, repo: repoContext.name, runId } : void 0
|
|
||||||
});
|
});
|
||||||
await octokit.rest.issues.updateComment({
|
await octokit.rest.issues.updateComment({
|
||||||
owner: repoContext.owner,
|
owner: repoContext.owner,
|
||||||
repo: repoContext.name,
|
repo: repoContext.name,
|
||||||
comment_id: commentId,
|
comment_id: commentId,
|
||||||
body: `${errorMessage}${footer}`
|
body
|
||||||
});
|
});
|
||||||
log.info("\xBB updated progress comment with error message");
|
log.info("\xBB updated progress comment with error message");
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -67,6 +67,14 @@ await build({
|
|||||||
plugins: [stripShebangPlugin],
|
plugins: [stripShebangPlugin],
|
||||||
});
|
});
|
||||||
|
|
||||||
|
// Build the post cleanup entry bundle
|
||||||
|
await build({
|
||||||
|
...sharedConfig,
|
||||||
|
entryPoints: ["./post.ts"],
|
||||||
|
outfile: "./post",
|
||||||
|
plugins: [stripShebangPlugin],
|
||||||
|
});
|
||||||
|
|
||||||
// Build the get-installation-token action
|
// Build the get-installation-token action
|
||||||
await build({
|
await build({
|
||||||
...sharedConfig,
|
...sharedConfig,
|
||||||
|
|||||||
@@ -0,0 +1,178 @@
|
|||||||
|
#!/usr/bin/env node
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Post cleanup entry point for pullfrog/pullfrog action.
|
||||||
|
* Runs independently after workflow failure or cancellation.
|
||||||
|
* Searches for Pullfrog comment via GitHub API and updates if stuck on "Leaping into action".
|
||||||
|
*/
|
||||||
|
|
||||||
|
import { LEAPING_INTO_ACTION_PREFIX } from "./mcp/comment.ts";
|
||||||
|
import { log } from "./utils/cli.ts";
|
||||||
|
import { buildErrorCommentBody } from "./utils/exitHandler.ts";
|
||||||
|
import { createOctokit, parseRepoContext } from "./utils/github.ts";
|
||||||
|
import { type ResolvedPromptInput, resolvePromptInput } from "./utils/payload.ts";
|
||||||
|
import { getJobToken } from "./utils/token.ts";
|
||||||
|
|
||||||
|
type JsonPromptInput = Extract<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
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
log.debug(`[post] script started at ${new Date().toISOString()}`);
|
||||||
|
await run();
|
||||||
+30
-13
@@ -5,6 +5,30 @@ import { log } from "./cli.ts";
|
|||||||
import { createOctokit, parseRepoContext } from "./github.ts";
|
import { createOctokit, parseRepoContext } from "./github.ts";
|
||||||
import { revokeGitHubInstallationToken } from "./token.ts";
|
import { revokeGitHubInstallationToken } from "./token.ts";
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Build error comment body with error message and footer
|
||||||
|
*/
|
||||||
|
export function buildErrorCommentBody(params: {
|
||||||
|
owner: string;
|
||||||
|
repo: string;
|
||||||
|
runId: string | undefined;
|
||||||
|
isCancellation: boolean;
|
||||||
|
}): string {
|
||||||
|
const workflowRunLink = params.runId
|
||||||
|
? `[workflow run logs](https://github.com/${params.owner}/${params.repo}/actions/runs/${params.runId})`
|
||||||
|
: "workflow run logs";
|
||||||
|
const errorMessage = params.isCancellation
|
||||||
|
? `This run was cancelled 🛑\n\nThe workflow was cancelled before completion. Please check the ${workflowRunLink} for details.`
|
||||||
|
: `This run croaked 😵\n\nThe workflow encountered an error before any progress could be reported. Please check the ${workflowRunLink} for details.`;
|
||||||
|
const footer = buildPullfrogFooter({
|
||||||
|
triggeredBy: true,
|
||||||
|
workflowRun: params.runId
|
||||||
|
? { owner: params.owner, repo: params.repo, runId: params.runId }
|
||||||
|
: undefined,
|
||||||
|
});
|
||||||
|
return `${errorMessage}${footer}`;
|
||||||
|
}
|
||||||
|
|
||||||
let cleanupFn: ((isCancellation: boolean) => Promise<void>) | undefined;
|
let cleanupFn: ((isCancellation: boolean) => Promise<void>) | undefined;
|
||||||
|
|
||||||
export function setupExitHandler(toolState: ToolState): void {
|
export function setupExitHandler(toolState: ToolState): void {
|
||||||
@@ -37,26 +61,19 @@ export function setupExitHandler(toolState: ToolState): void {
|
|||||||
// only update if comment still shows the initial "leaping into action" message
|
// only update if comment still shows the initial "leaping into action" message
|
||||||
if (commentBody.startsWith(LEAPING_INTO_ACTION_PREFIX)) {
|
if (commentBody.startsWith(LEAPING_INTO_ACTION_PREFIX)) {
|
||||||
const runId = process.env.GITHUB_RUN_ID;
|
const runId = process.env.GITHUB_RUN_ID;
|
||||||
const workflowRunLink = runId
|
|
||||||
? `[workflow run logs](https://github.com/${repoContext.owner}/${repoContext.name}/actions/runs/${runId})`
|
|
||||||
: "workflow run logs";
|
|
||||||
|
|
||||||
const errorMessage = isCancellation
|
const body = buildErrorCommentBody({
|
||||||
? `This run was cancelled 🛑\n\nThe workflow was cancelled before completion. Please check the ${workflowRunLink} for details.`
|
owner: repoContext.owner,
|
||||||
: `This run croaked 😵\n\nThe workflow encountered an error before any progress could be reported. Please check the ${workflowRunLink} for details.`;
|
repo: repoContext.name,
|
||||||
|
runId,
|
||||||
const footer = buildPullfrogFooter({
|
isCancellation,
|
||||||
triggeredBy: true,
|
|
||||||
workflowRun: runId
|
|
||||||
? { owner: repoContext.owner, repo: repoContext.name, runId }
|
|
||||||
: undefined,
|
|
||||||
});
|
});
|
||||||
|
|
||||||
await octokit.rest.issues.updateComment({
|
await octokit.rest.issues.updateComment({
|
||||||
owner: repoContext.owner,
|
owner: repoContext.owner,
|
||||||
repo: repoContext.name,
|
repo: repoContext.name,
|
||||||
comment_id: commentId,
|
comment_id: commentId,
|
||||||
body: `${errorMessage}${footer}`,
|
body,
|
||||||
});
|
});
|
||||||
|
|
||||||
log.info("» updated progress comment with error message");
|
log.info("» updated progress comment with error message");
|
||||||
|
|||||||
Reference in New Issue
Block a user