update working comment on error

This commit is contained in:
Shawn Morreau
2025-12-04 15:05:53 -05:00
parent 6e337407a7
commit 7f81415259
3 changed files with 184 additions and 61 deletions
+27 -18
View File
@@ -8,12 +8,14 @@ import { agents } from "./agents/index.ts";
import type { AgentResult } from "./agents/shared.ts"; import type { AgentResult } from "./agents/shared.ts";
import type { AgentName, AgentName as AgentNameType, Payload } from "./external.ts"; import type { AgentName, AgentName as AgentNameType, Payload } from "./external.ts";
import { agentsManifest } from "./external.ts"; import { agentsManifest } from "./external.ts";
import { ensureProgressCommentUpdated } from "./mcp/comment.ts";
import { createMcpConfigs } from "./mcp/config.ts"; import { createMcpConfigs } from "./mcp/config.ts";
import { startMcpHttpServer } from "./mcp/server.ts"; import { startMcpHttpServer } from "./mcp/server.ts";
import { modes } from "./modes.ts"; import { modes } from "./modes.ts";
import packageJson from "./package.json" with { type: "json" }; import packageJson from "./package.json" with { type: "json" };
import { fetchRepoSettings, fetchWorkflowRunInfo } from "./utils/api.ts"; import { fetchRepoSettings, fetchWorkflowRunInfo } from "./utils/api.ts";
import { log } from "./utils/cli.ts"; import { log } from "./utils/cli.ts";
import { reportErrorToComment } from "./utils/errorReport.ts";
import { import {
parseRepoContext, parseRepoContext,
type RepoContext, type RepoContext,
@@ -68,14 +70,20 @@ export async function main(inputs: Inputs): Promise<MainResult> {
mcpServerClose = ctx.mcpServerClose; mcpServerClose = ctx.mcpServerClose;
setupMcpServers(ctx); setupMcpServers(ctx);
await installAgentCli(ctx); await installAgentCli(ctx);
validateApiKey(ctx); await validateApiKey(ctx);
const result = await runAgent(ctx); const result = await runAgent(ctx);
return await handleAgentResult(result); const mainResult = await handleAgentResult(result);
// ensure progress comment is updated if it was never updated during execution
await ensureProgressCommentUpdated();
return mainResult;
} catch (error) { } catch (error) {
const errorMessage = error instanceof Error ? error.message : "Unknown error occurred"; const errorMessage = error instanceof Error ? error.message : "Unknown error occurred";
log.error(errorMessage); log.error(errorMessage);
await reportErrorToComment({ error: errorMessage });
await log.writeSummary(); await log.writeSummary();
// ensure progress comment is updated if it was never updated during execution
await ensureProgressCommentUpdated();
return { return {
success: false, success: false,
error: errorMessage, error: errorMessage,
@@ -111,18 +119,17 @@ function getAllPossibleKeyNames(): string[] {
/** /**
* Throw an error for missing API key with helpful message linking to repo settings * Throw an error for missing API key with helpful message linking to repo settings
*/ */
function throwMissingApiKeyError({ async function throwMissingApiKeyError({
agentName, agent,
inputKeys,
repoContext, repoContext,
}: { }: {
agentName: string | null; agent: (typeof agents)[AgentNameType] | null;
inputKeys: string[];
repoContext: RepoContext; repoContext: RepoContext;
}): never { }): Promise<never> {
const apiUrl = process.env.API_URL || "https://pullfrog.ai"; const apiUrl = process.env.API_URL || "https://pullfrog.ai";
const settingsUrl = `${apiUrl}/console/${repoContext.owner}/${repoContext.name}`; const settingsUrl = `${apiUrl}/console/${repoContext.owner}/${repoContext.name}`;
const inputKeys = agent?.apiKeyNames || getAllPossibleKeyNames();
const secretNames = inputKeys.map((key) => `\`${key.toUpperCase()}\``); const secretNames = inputKeys.map((key) => `\`${key.toUpperCase()}\``);
const secretNameList = const secretNameList =
inputKeys.length === 1 ? secretNames[0] : `one of ${secretNames.join(" or ")}`; inputKeys.length === 1 ? secretNames[0] : `one of ${secretNames.join(" or ")}`;
@@ -131,9 +138,9 @@ function throwMissingApiKeyError({
const githubSecretsUrl = `${githubRepoUrl}/settings/secrets/actions`; const githubSecretsUrl = `${githubRepoUrl}/settings/secrets/actions`;
let message = `${ let message = `${
agentName === null agent === null
? "Pullfrog has no agent configured and no API keys are available in the environment." ? "Pullfrog has no agent configured and no API keys are available in the environment."
: `Pullfrog is configured to use ${agentName}, but the associated API key was not provided.` : `Pullfrog is configured to use ${agent.displayName}, but the associated API key was not provided.`
} }
To fix this, add the required secret to your GitHub repository: To fix this, add the required secret to your GitHub repository:
@@ -144,11 +151,13 @@ To fix this, add the required secret to your GitHub repository:
4. Set the value to your API key 4. Set the value to your API key
5. Click "Add secret"`; 5. Click "Add secret"`;
if (agentName === null) { if (agent === null) {
message += `\n\nAlternatively, configure Pullfrog to use an agent at ${settingsUrl}`; message += `\n\nAlternatively, configure Pullfrog to use an agent at ${settingsUrl}`;
} }
log.error(message); log.error(message);
// report to comment if MCP context is available (server has started)
await reportErrorToComment({ error: message });
throw new Error(message); throw new Error(message);
} }
@@ -229,9 +238,8 @@ async function resolveAgent(
log.debug(`Available agents: ${availableAgentNames || "none"}`); log.debug(`Available agents: ${availableAgentNames || "none"}`);
if (availableAgents.length === 0) { if (availableAgents.length === 0) {
throwMissingApiKeyError({ await throwMissingApiKeyError({
agentName: configuredAgentName, agent: null,
inputKeys: getAllPossibleKeyNames(),
repoContext, repoContext,
}); });
} }
@@ -306,14 +314,15 @@ async function installAgentCli(ctx: MainContext): Promise<void> {
} }
} }
function validateApiKey(ctx: MainContext): void { async function validateApiKey(ctx: MainContext): Promise<void> {
const matchingInputKey = ctx.agent.apiKeyNames.find((inputKey) => ctx.inputs[inputKey]); const matchingInputKey = ctx.agent.apiKeyNames.find((inputKey) => ctx.inputs[inputKey]);
if (!matchingInputKey) { if (!matchingInputKey) {
throwMissingApiKeyError({ await throwMissingApiKeyError({
agentName: ctx.agentName, agent: ctx.agent,
inputKeys: ctx.agent.apiKeyNames,
repoContext: ctx.repoContext, repoContext: ctx.repoContext,
}); });
// unreachable - throwMissingApiKeyError always throws
return;
} }
ctx.apiKey = ctx.inputs[matchingInputKey]!; ctx.apiKey = ctx.inputs[matchingInputKey]!;
} }
+112 -43
View File
@@ -2,7 +2,7 @@ import { type } from "arktype";
import type { Payload } from "../external.ts"; import type { Payload } from "../external.ts";
import { agentsManifest } from "../external.ts"; import { agentsManifest } from "../external.ts";
import { parseRepoContext } from "../utils/github.ts"; import { parseRepoContext } from "../utils/github.ts";
import { contextualize, tool } from "./shared.ts"; import { contextualize, getMcpContext, tool } from "./shared.ts";
const PULLFROG_DIVIDER = "<!-- PULLFROG_DIVIDER_DO_NOT_REMOVE_PLZ -->"; const PULLFROG_DIVIDER = "<!-- PULLFROG_DIVIDER_DO_NOT_REMOVE_PLZ -->";
@@ -117,6 +117,9 @@ function getProgressCommentIdFromEnv(): number | null {
let progressCommentId: number | null = null; let progressCommentId: number | null = null;
let progressCommentIdInitialized = false; let progressCommentIdInitialized = false;
// track whether the progress comment was updated during execution
let progressCommentWasUpdated = false;
function getProgressCommentId(): number | null { function getProgressCommentId(): number | null {
if (!progressCommentIdInitialized) { if (!progressCommentIdInitialized) {
progressCommentId = getProgressCommentIdFromEnv(); progressCommentId = getProgressCommentIdFromEnv();
@@ -134,66 +137,132 @@ export const ReportProgress = type({
body: type.string.describe("the progress update content to share"), body: type.string.describe("the progress update content to share"),
}); });
/**
* Standalone function to report progress to GitHub comment.
* Can be called directly without going through the MCP tool interface.
* Returns result data if successful, undefined if comment cannot be created.
*/
export async function reportProgress({ body }: { body: string }): Promise<
| {
commentId: number;
url: string;
body: string;
action: "created" | "updated";
}
| undefined
> {
const ctx = getMcpContext();
const bodyWithFooter = addFooter(body, ctx.payload);
const existingCommentId = getProgressCommentId();
// if we already have a progress comment, update it
if (existingCommentId) {
const result = await ctx.octokit.rest.issues.updateComment({
owner: ctx.owner,
repo: ctx.name,
comment_id: existingCommentId,
body: bodyWithFooter,
});
progressCommentWasUpdated = true;
return {
commentId: result.data.id,
url: result.data.html_url,
body: result.data.body || "",
action: "updated",
};
}
// no existing comment - create one
const issueNumber = ctx.payload.event.issue_number;
if (issueNumber === undefined) {
// fail silently - cannot create comment without issue_number
return undefined;
}
const result = await ctx.octokit.rest.issues.createComment({
owner: ctx.owner,
repo: ctx.name,
issue_number: issueNumber,
body: bodyWithFooter,
});
// store the comment ID for future updates
setProgressCommentId(result.data.id);
progressCommentWasUpdated = true;
return {
commentId: result.data.id,
url: result.data.html_url,
body: result.data.body || "",
action: "created",
};
}
export const ReportProgressTool = tool({ export const ReportProgressTool = tool({
name: "report_progress", name: "report_progress",
description: description:
"Share progress on the associated GitHub issue/PR. Call this to post updates as you work. The first call creates a comment, subsequent calls update it. Use this throughout your work to keep stakeholders informed.", "Share progress on the associated GitHub issue/PR. Call this to post updates as you work. The first call creates a comment, subsequent calls update it. Use this throughout your work to keep stakeholders informed.",
parameters: ReportProgress, parameters: ReportProgress,
execute: contextualize(async ({ body }, ctx) => { execute: contextualize(async ({ body }) => {
const bodyWithFooter = addFooter(body, ctx.payload); const result = await reportProgress({ body });
const existingCommentId = getProgressCommentId();
// if we already have a progress comment, update it if (!result) {
if (existingCommentId) {
const result = await ctx.octokit.rest.issues.updateComment({
owner: ctx.owner,
repo: ctx.name,
comment_id: existingCommentId,
body: bodyWithFooter,
});
return {
success: true,
commentId: result.data.id,
url: result.data.html_url,
body: result.data.body,
action: "updated",
};
}
// no existing comment - create one
const issueNumber = ctx.payload.event.issue_number;
if (issueNumber === undefined) {
// fail silently
return { return {
success: false, success: false,
message: "cannot create progress comment: no issue_number found in the payload event", message: "cannot create progress comment: no issue_number found in the payload event",
}; };
// throw new Error(
// "cannot create progress comment: no issue_number found in the payload event"
// );
} }
const result = await ctx.octokit.rest.issues.createComment({
owner: ctx.owner,
repo: ctx.name,
issue_number: issueNumber,
body: bodyWithFooter,
});
// store the comment ID for future updates
setProgressCommentId(result.data.id);
return { return {
success: true, success: true,
commentId: result.data.id, ...result,
url: result.data.html_url,
body: result.data.body,
action: "created",
}; };
}), }),
}); });
/**
* Check if the progress comment was updated during execution
*/
export function wasProgressCommentUpdated(): boolean {
return progressCommentWasUpdated;
}
/**
* Ensure the progress comment is updated with a generic error message if it was never updated.
* This should be called after agent execution completes to handle cases where the agent
* exited without ever calling reportProgress.
*/
export async function ensureProgressCommentUpdated(): Promise<void> {
// only update if we have a progress comment ID from env var and it was never updated
const existingCommentId = getProgressCommentId();
if (!existingCommentId || progressCommentWasUpdated) {
return;
}
// check if MCP context is initialized (MCP server started)
try {
const ctx = getMcpContext();
const errorMessage = `🐸 this run croaked
The workflow encountered an error before any progress could be reported. Please check the workflow run logs for details.`;
const bodyWithFooter = addFooter(errorMessage, ctx.payload);
await ctx.octokit.rest.issues.updateComment({
owner: ctx.owner,
repo: ctx.name,
comment_id: existingCommentId,
body: bodyWithFooter,
});
} catch {
// fail silently - MCP context not initialized or other error
// don't want to fail the workflow if we can't update the comment
}
}
export const ReplyToReviewComment = type({ export const ReplyToReviewComment = type({
pull_number: type.number.describe("the pull request number"), pull_number: type.number.describe("the pull request number"),
comment_id: type.number.describe("the ID of the review comment to reply to"), comment_id: type.number.describe("the ID of the review comment to reply to"),
+45
View File
@@ -0,0 +1,45 @@
import { log } from "./cli.ts";
import { reportProgress } from "../mcp/comment.ts";
import { getMcpContext } from "../mcp/shared.ts";
/**
* Check if MCP context is initialized (i.e., MCP server has started)
*/
function isMcpContextInitialized(): boolean {
try {
getMcpContext();
return true;
} catch {
return false;
}
}
/**
* Report an error to the GitHub working comment.
* Formats the error message for GitHub markdown and updates the progress comment.
* Handles failures gracefully - logs but doesn't throw.
*/
export async function reportErrorToComment({
error,
title,
}: {
error: string;
title?: string;
}): Promise<void> {
// only report if MCP context is initialized (MCP server has started)
if (!isMcpContextInitialized()) {
log.debug("skipping error comment update: MCP context not initialized");
return;
}
try {
const formattedError = title ? `${title}\n\n${error}` : `${error}`;
await reportProgress({ body: formattedError });
} catch (reportError) {
// log but don't throw - we don't want error reporting to fail the workflow
const errorMessage =
reportError instanceof Error ? reportError.message : String(reportError);
log.warning(`failed to report error to comment: ${errorMessage}`);
}
}