update working comment on error
This commit is contained in:
@@ -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
@@ -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"),
|
||||||
|
|||||||
@@ -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}`);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
Reference in New Issue
Block a user