Add "Rerun failed job ➔" link to error comment footer (#355)

* add "Rerun failed job" link to error comment footer

* Remove issueNumber guard from rerun link

The rerun action only needs run_id — the issue number in the trigger
URL path is just a route segment requirement. Use 0 as a fallback
so the link is always shown when a run ID is available.

* Overload `[number]` path segment as `runId` for the rerun action

For the rerun trigger, the `[number]` path param now carries the
workflow run ID instead of an issue number. The rerun link changes
from `/trigger/o/r/ISSUE?action=rerun&run_id=RID` to
`/trigger/o/r/RID?action=rerun`.

- Remove `run_id` query param from page.tsx and searchParams type
- Add error handling around `reRunWorkflow` for invalid run IDs
- Drop `issueNumber` from `BuildErrorCommentBodyParams` and all
  rerun link builders (errorReport, exitHandler, postCleanup)

* Reorder validation: action first, then rerun, then issueNumber

* address review: remove early toolState assignment, restructure rerun into dedicated block

* revert self-contained rerun block, share auth logic via issueOrRunId

* improve error handling

* normalize runid early

---------

Co-authored-by: pullfrog[bot] <226033991+pullfrog[bot]@users.noreply.github.com>
Co-authored-by: Cursor <cursoragent@cursor.com>
Co-authored-by: Mateusz Burzyński <mateuszburzynski@gmail.com>
This commit is contained in:
pullfrog[bot]
2026-02-27 12:32:06 +00:00
committed by pullfrog[bot]
co-authored by pullfrog[bot] <226033991+pullfrog[bot]@users.noreply.github.com> Cursor Mateusz Burzyński
parent c0fd69560f
commit 20b08b5321
9 changed files with 110 additions and 46 deletions
+16 -8
View File
@@ -141312,14 +141312,14 @@ async function buildCommentFooter({
customParts customParts
}) { }) {
const repoContext = parseRepoContext(); const repoContext = parseRepoContext();
const runId = process.env.GITHUB_RUN_ID; const runId = process.env.GITHUB_RUN_ID ? Number.parseInt(process.env.GITHUB_RUN_ID, 10) : void 0;
let jobId; let jobId;
if (runId && octokit) { if (runId && octokit) {
try { try {
const { data: jobs } = await octokit.rest.actions.listJobsForWorkflowRun({ const { data: jobs } = await octokit.rest.actions.listJobsForWorkflowRun({
owner: repoContext.owner, owner: repoContext.owner,
repo: repoContext.name, repo: repoContext.name,
run_id: parseInt(runId, 10) run_id: runId
}); });
jobId = jobs.jobs[0]?.id.toString(); jobId = jobs.jobs[0]?.id.toString();
} catch { } catch {
@@ -143345,12 +143345,12 @@ function CreatePullRequestReviewTool(ctx) {
} }
} }
const footer = buildPullfrogFooter({ const footer = buildPullfrogFooter({
workflowRun: { workflowRun: ctx.runId ? {
owner: ctx.repo.owner, owner: ctx.repo.owner,
repo: ctx.repo.name, repo: ctx.repo.name,
runId: ctx.runId, runId: ctx.runId,
jobId: ctx.jobId jobId: ctx.jobId
}, } : void 0,
customParts customParts
}); });
const updatedBody = (body || "") + footer; const updatedBody = (body || "") + footer;
@@ -146893,10 +146893,18 @@ ${ctx.error}` : ctx.error;
} }
const repoContext = parseRepoContext(); const repoContext = parseRepoContext();
const octokit = createOctokit(getGitHubInstallationToken()); const octokit = createOctokit(getGitHubInstallationToken());
const runId = process.env.GITHUB_RUN_ID; const runId = process.env.GITHUB_RUN_ID ? Number.parseInt(process.env.GITHUB_RUN_ID, 10) : void 0;
const customParts = [];
if (runId) {
const apiUrl = getApiUrl();
customParts.push(
`[Rerun failed job \u2794](${apiUrl}/trigger/${repoContext.owner}/${repoContext.name}/${runId}?action=rerun)`
);
}
const footer = buildPullfrogFooter({ const footer = buildPullfrogFooter({
triggeredBy: true, triggeredBy: true,
workflowRun: runId ? { owner: repoContext.owner, repo: repoContext.name, runId } : void 0 workflowRun: runId ? { owner: repoContext.owner, repo: repoContext.name, runId } : void 0,
customParts
}); });
await octokit.rest.issues.updateComment({ await octokit.rest.issues.updateComment({
owner: repoContext.owner, owner: repoContext.owner,
@@ -147626,7 +147634,7 @@ function parseTimeString(input) {
// utils/workflow.ts // utils/workflow.ts
async function resolveRun(params) { async function resolveRun(params) {
const runId = process.env.GITHUB_RUN_ID || ""; const runId = process.env.GITHUB_RUN_ID ? Number.parseInt(process.env.GITHUB_RUN_ID, 10) : void 0;
const githubRepo = process.env.GITHUB_REPOSITORY; const githubRepo = process.env.GITHUB_REPOSITORY;
if (!githubRepo || !githubRepo.includes("/")) { if (!githubRepo || !githubRepo.includes("/")) {
throw new Error(`GITHUB_REPOSITORY env var must be set to "owner/repo", got: ${githubRepo}`); throw new Error(`GITHUB_REPOSITORY env var must be set to "owner/repo", got: ${githubRepo}`);
@@ -147638,7 +147646,7 @@ async function resolveRun(params) {
const jobs = await params.octokit.rest.actions.listJobsForWorkflowRun({ const jobs = await params.octokit.rest.actions.listJobsForWorkflowRun({
owner, owner,
repo, repo,
run_id: parseInt(runId, 10) run_id: runId
}); });
const matchingJob = jobs.data.jobs.find((job) => job.name === jobName); const matchingJob = jobs.data.jobs.find((job) => job.name === jobName);
if (matchingJob) { if (matchingJob) {
+4 -2
View File
@@ -25,7 +25,9 @@ async function buildCommentFooter({
customParts, customParts,
}: BuildCommentFooterParams): Promise<string> { }: BuildCommentFooterParams): Promise<string> {
const repoContext = parseRepoContext(); const repoContext = parseRepoContext();
const runId = process.env.GITHUB_RUN_ID; const runId = process.env.GITHUB_RUN_ID
? Number.parseInt(process.env.GITHUB_RUN_ID, 10)
: undefined;
let jobId: string | undefined; let jobId: string | undefined;
if (runId && octokit) { if (runId && octokit) {
@@ -34,7 +36,7 @@ async function buildCommentFooter({
const { data: jobs } = await octokit.rest.actions.listJobsForWorkflowRun({ const { data: jobs } = await octokit.rest.actions.listJobsForWorkflowRun({
owner: repoContext.owner, owner: repoContext.owner,
repo: repoContext.name, repo: repoContext.name,
run_id: parseInt(runId, 10), run_id: runId,
}); });
// use the first job's ID available // use the first job's ID available
jobId = jobs.jobs[0]?.id.toString(); jobId = jobs.jobs[0]?.id.toString();
+8 -6
View File
@@ -139,12 +139,14 @@ export function CreatePullRequestReviewTool(ctx: ToolContext) {
} }
const footer = buildPullfrogFooter({ const footer = buildPullfrogFooter({
workflowRun: { workflowRun: ctx.runId
owner: ctx.repo.owner, ? {
repo: ctx.repo.name, owner: ctx.repo.owner,
runId: ctx.runId, repo: ctx.repo.name,
jobId: ctx.jobId, runId: ctx.runId,
}, jobId: ctx.jobId,
}
: undefined,
customParts, customParts,
}); });
+1 -1
View File
@@ -100,7 +100,7 @@ export interface ToolContext {
modes: Mode[]; modes: Mode[];
postCheckoutScript: string | null; postCheckoutScript: string | null;
toolState: ToolState; toolState: ToolState;
runId: string; runId: number | undefined;
jobId: string | undefined; jobId: string | undefined;
// set after MCP server starts — used by delegate tool to pass URL to subagents // set after MCP server starts — used by delegate tool to pass URL to subagents
mcpServerUrl: string; mcpServerUrl: string;
+36 -10
View File
@@ -37493,6 +37493,22 @@ var schema = ark.schema;
var define2 = ark.define; var define2 = ark.define;
var declare = ark.declare; var declare = ark.declare;
// utils/apiUrl.ts
function isLocalUrl(url2) {
return url2.hostname === "localhost" || url2.hostname === "127.0.0.1";
}
function getApiUrl() {
const raw = process.env.API_URL || "https://pullfrog.com";
const parsed2 = new URL(raw);
if (parsed2.protocol !== "https:" && !isLocalUrl(parsed2)) {
throw new Error(
`API_URL must use https:// (got ${parsed2.protocol}). only localhost is exempt.`
);
}
log.debug(`resolved API_URL: ${raw}`);
return raw;
}
// utils/buildPullfrogFooter.ts // utils/buildPullfrogFooter.ts
var PULLFROG_DIVIDER = "<!-- PULLFROG_DIVIDER_DO_NOT_REMOVE_PLZ -->"; 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>`; 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>`;
@@ -41419,15 +41435,25 @@ function getJobToken() {
// utils/postCleanup.ts // utils/postCleanup.ts
var SHOULD_CHECK_REASON = true; var SHOULD_CHECK_REASON = true;
function buildErrorCommentBody(params) { function buildErrorCommentBody(params) {
const workflowRunLink = params.runId ? `[workflow run logs](https://github.com/${params.owner}/${params.repo}/actions/runs/${params.runId})` : "workflow run logs"; let errorMessage = params.isCancellation ? `This run was cancelled \u{1F6D1}
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 was cancelled before completion.` : `This run croaked \u{1F635}
The workflow encountered an error before any progress could be reported. Please check the ${workflowRunLink} for details.`; The workflow encountered an error before any progress could be reported.`;
if (params.runId) {
errorMessage += " Please check the link below for details.";
}
const customParts = [];
if (!params.isCancellation && params.runId) {
const apiUrl = getApiUrl();
customParts.push(
`[Rerun failed job \u2794](${apiUrl}/trigger/${params.owner}/${params.repo}/${params.runId}?action=rerun)`
);
}
const footer = buildPullfrogFooter({ const footer = buildPullfrogFooter({
triggeredBy: true, triggeredBy: true,
workflowRun: params.runId ? { owner: params.owner, repo: params.repo, runId: params.runId } : void 0 workflowRun: params.runId ? { owner: params.owner, repo: params.repo, runId: params.runId } : void 0,
customParts
}); });
return `${errorMessage}${footer}`; return `${errorMessage}${footer}`;
} }
@@ -41457,12 +41483,12 @@ async function validateStuckProgressComment(params) {
} }
} }
async function getIsCancelled(params) { async function getIsCancelled(params) {
if (!params.runIdStr) return false; if (!params.runId) return false;
try { try {
const jobsResult = await params.octokit.rest.actions.listJobsForWorkflowRun({ const jobsResult = await params.octokit.rest.actions.listJobsForWorkflowRun({
owner: params.repoContext.owner, owner: params.repoContext.owner,
repo: params.repoContext.name, repo: params.repoContext.name,
run_id: Number.parseInt(params.runIdStr, 10) run_id: params.runId
}); });
const currentJobName = process.env.GITHUB_JOB; const currentJobName = process.env.GITHUB_JOB;
const currentJob = currentJobName ? jobsResult.data.jobs.find( const currentJob = currentJobName ? jobsResult.data.jobs.find(
@@ -41489,7 +41515,7 @@ async function getIsCancelled(params) {
} }
async function runPostCleanup() { async function runPostCleanup() {
log.info("\xBB [post] starting post cleanup"); log.info("\xBB [post] starting post cleanup");
const runIdStr = process.env.GITHUB_RUN_ID; const runId = process.env.GITHUB_RUN_ID ? Number.parseInt(process.env.GITHUB_RUN_ID, 10) : void 0;
let promptInput = null; let promptInput = null;
try { try {
const resolved = resolvePromptInput(); const resolved = resolvePromptInput();
@@ -41514,8 +41540,8 @@ async function runPostCleanup() {
const body = buildErrorCommentBody({ const body = buildErrorCommentBody({
owner: repoContext.owner, owner: repoContext.owner,
repo: repoContext.name, repo: repoContext.name,
runId: runIdStr, runId,
isCancellation: SHOULD_CHECK_REASON ? await getIsCancelled({ octokit, repoContext, runIdStr }) : false isCancellation: SHOULD_CHECK_REASON ? await getIsCancelled({ octokit, repoContext, runId }) : false
}); });
await octokit.rest.issues.updateComment({ await octokit.rest.issues.updateComment({
owner: repoContext.owner, owner: repoContext.owner,
+1 -1
View File
@@ -10,7 +10,7 @@ export interface AgentInfo {
export interface WorkflowRunFooterInfo { export interface WorkflowRunFooterInfo {
owner: string; owner: string;
repo: string; repo: string;
runId: string; runId: number;
/** optional job ID - if provided, will append /job/{jobId} to the workflow run URL */ /** optional job ID - if provided, will append /job/{jobId} to the workflow run URL */
jobId?: string | undefined; jobId?: string | undefined;
} }
+13 -2
View File
@@ -1,4 +1,5 @@
import type { ToolState } from "../mcp/server.ts"; import type { ToolState } from "../mcp/server.ts";
import { getApiUrl } from "./apiUrl.ts";
import { buildPullfrogFooter } from "./buildPullfrogFooter.ts"; import { buildPullfrogFooter } from "./buildPullfrogFooter.ts";
import { createOctokit, parseRepoContext } from "./github.ts"; import { createOctokit, parseRepoContext } from "./github.ts";
import { getGitHubInstallationToken } from "./token.ts"; import { getGitHubInstallationToken } from "./token.ts";
@@ -19,12 +20,22 @@ export async function reportErrorToComment(ctx: ReportErrorParams): Promise<void
const repoContext = parseRepoContext(); const repoContext = parseRepoContext();
const octokit = createOctokit(getGitHubInstallationToken()); const octokit = createOctokit(getGitHubInstallationToken());
const runId = process.env.GITHUB_RUN_ID; const runId = process.env.GITHUB_RUN_ID
? Number.parseInt(process.env.GITHUB_RUN_ID, 10)
: undefined;
const customParts: string[] = [];
if (runId) {
const apiUrl = getApiUrl();
customParts.push(
`[Rerun failed job ➔](${apiUrl}/trigger/${repoContext.owner}/${repoContext.name}/${runId}?action=rerun)`
);
}
// build footer with workflow run link
const footer = buildPullfrogFooter({ const footer = buildPullfrogFooter({
triggeredBy: true, triggeredBy: true,
workflowRun: runId ? { owner: repoContext.owner, repo: repoContext.name, runId } : undefined, workflowRun: runId ? { owner: repoContext.owner, repo: repoContext.name, runId } : undefined,
customParts,
}); });
await octokit.rest.issues.updateComment({ await octokit.rest.issues.updateComment({
+26 -13
View File
@@ -1,4 +1,5 @@
import { LEAPING_INTO_ACTION_PREFIX } from "../mcp/comment.ts"; import { LEAPING_INTO_ACTION_PREFIX } from "../mcp/comment.ts";
import { getApiUrl } from "./apiUrl.ts";
import { buildPullfrogFooter } from "./buildPullfrogFooter.ts"; import { buildPullfrogFooter } from "./buildPullfrogFooter.ts";
import { log } from "./cli.ts"; import { log } from "./cli.ts";
import { createOctokit, parseRepoContext } from "./github.ts"; import { createOctokit, parseRepoContext } from "./github.ts";
@@ -15,22 +16,32 @@ const SHOULD_CHECK_REASON = true;
type BuildErrorCommentBodyParams = { type BuildErrorCommentBodyParams = {
owner: string; owner: string;
repo: string; repo: string;
runId: string | undefined; runId: number | undefined;
isCancellation: boolean; isCancellation: boolean;
}; };
function buildErrorCommentBody(params: BuildErrorCommentBodyParams): string { function buildErrorCommentBody(params: BuildErrorCommentBodyParams): string {
const workflowRunLink = params.runId let errorMessage = params.isCancellation
? `[workflow run logs](https://github.com/${params.owner}/${params.repo}/actions/runs/${params.runId})` ? `This run was cancelled 🛑\n\nThe workflow was cancelled before completion.`
: "workflow run logs"; : `This run croaked 😵\n\nThe workflow encountered an error before any progress could be reported.`;
const errorMessage = params.isCancellation
? `This run was cancelled 🛑\n\nThe workflow was cancelled before completion. Please check the ${workflowRunLink} for details.` if (params.runId) {
: `This run croaked 😵\n\nThe workflow encountered an error before any progress could be reported. Please check the ${workflowRunLink} for details.`; errorMessage += " Please check the link below for details.";
}
const customParts: string[] = [];
if (!params.isCancellation && params.runId) {
const apiUrl = getApiUrl();
customParts.push(
`[Rerun failed job ➔](${apiUrl}/trigger/${params.owner}/${params.repo}/${params.runId}?action=rerun)`
);
}
const footer = buildPullfrogFooter({ const footer = buildPullfrogFooter({
triggeredBy: true, triggeredBy: true,
workflowRun: params.runId workflowRun: params.runId
? { owner: params.owner, repo: params.repo, runId: params.runId } ? { owner: params.owner, repo: params.repo, runId: params.runId }
: undefined, : undefined,
customParts,
}); });
return `${errorMessage}${footer}`; return `${errorMessage}${footer}`;
} }
@@ -76,16 +87,16 @@ async function validateStuckProgressComment(
type GetIsCancelledParams = { type GetIsCancelledParams = {
repoContext: ReturnType<typeof parseRepoContext>; repoContext: ReturnType<typeof parseRepoContext>;
octokit: ReturnType<typeof createOctokit>; octokit: ReturnType<typeof createOctokit>;
runIdStr: string | undefined; runId: number | undefined;
}; };
async function getIsCancelled(params: GetIsCancelledParams): Promise<boolean> { async function getIsCancelled(params: GetIsCancelledParams): Promise<boolean> {
if (!params.runIdStr) return false; // can't check without a run ID — assume failure if (!params.runId) return false; // can't check without a run ID — assume failure
try { try {
const jobsResult = await params.octokit.rest.actions.listJobsForWorkflowRun({ const jobsResult = await params.octokit.rest.actions.listJobsForWorkflowRun({
owner: params.repoContext.owner, owner: params.repoContext.owner,
repo: params.repoContext.name, repo: params.repoContext.name,
run_id: Number.parseInt(params.runIdStr, 10), run_id: params.runId,
}); });
// find current job by matching GITHUB_JOB env var. // find current job by matching GITHUB_JOB env var.
@@ -125,7 +136,9 @@ async function getIsCancelled(params: GetIsCancelledParams): Promise<boolean> {
export async function runPostCleanup(): Promise<void> { export async function runPostCleanup(): Promise<void> {
log.info("» [post] starting post cleanup"); log.info("» [post] starting post cleanup");
const runIdStr = process.env.GITHUB_RUN_ID; const runId = process.env.GITHUB_RUN_ID
? Number.parseInt(process.env.GITHUB_RUN_ID, 10)
: undefined;
// resolve prompt input once and use it for both issue number and comment ID extraction // 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 // only use the object form (JSON payload), not plain string prompts
@@ -159,9 +172,9 @@ export async function runPostCleanup(): Promise<void> {
const body = buildErrorCommentBody({ const body = buildErrorCommentBody({
owner: repoContext.owner, owner: repoContext.owner,
repo: repoContext.name, repo: repoContext.name,
runId: runIdStr, runId,
isCancellation: SHOULD_CHECK_REASON isCancellation: SHOULD_CHECK_REASON
? await getIsCancelled({ octokit, repoContext, runIdStr }) ? await getIsCancelled({ octokit, repoContext, runId })
: false, : false,
}); });
+5 -3
View File
@@ -6,7 +6,7 @@ interface ResolveRunParams {
} }
export interface ResolveRunResult { export interface ResolveRunResult {
runId: string; runId: number | undefined;
jobId: string | undefined; jobId: string | undefined;
} }
@@ -15,7 +15,9 @@ export interface ResolveRunResult {
* Uses GITHUB_REPOSITORY and GITHUB_RUN_ID env vars. * Uses GITHUB_REPOSITORY and GITHUB_RUN_ID env vars.
*/ */
export async function resolveRun(params: ResolveRunParams): Promise<ResolveRunResult> { export async function resolveRun(params: ResolveRunParams): Promise<ResolveRunResult> {
const runId = process.env.GITHUB_RUN_ID || ""; const runId = process.env.GITHUB_RUN_ID
? Number.parseInt(process.env.GITHUB_RUN_ID, 10)
: undefined;
const githubRepo = process.env.GITHUB_REPOSITORY; const githubRepo = process.env.GITHUB_REPOSITORY;
if (!githubRepo || !githubRepo.includes("/")) { if (!githubRepo || !githubRepo.includes("/")) {
throw new Error(`GITHUB_REPOSITORY env var must be set to "owner/repo", got: ${githubRepo}`); throw new Error(`GITHUB_REPOSITORY env var must be set to "owner/repo", got: ${githubRepo}`);
@@ -28,7 +30,7 @@ export async function resolveRun(params: ResolveRunParams): Promise<ResolveRunRe
const jobs = await params.octokit.rest.actions.listJobsForWorkflowRun({ const jobs = await params.octokit.rest.actions.listJobsForWorkflowRun({
owner, owner,
repo, repo,
run_id: parseInt(runId, 10), run_id: runId,
}); });
const matchingJob = jobs.data.jobs.find((job) => job.name === jobName); const matchingJob = jobs.data.jobs.find((job) => job.name === jobName);
if (matchingJob) { if (matchingJob) {