Compare commits

...

2 Commits

Author SHA1 Message Date
Shawn Morreau fb7d9e0d34 move croaked logic, ensure API key error populates comment 2025-12-11 14:55:07 -05:00
Colin McDonnell dcbac16663 Tweak 2025-12-10 15:02:15 -08:00
8 changed files with 14408 additions and 12928 deletions
+9
View File
@@ -1,4 +1,5 @@
import { existsSync, mkdirSync, readFileSync, writeFileSync } from "node:fs"; import { existsSync, mkdirSync, readFileSync, writeFileSync } from "node:fs";
import { join } from "node:path"; import { join } from "node:path";
import { log } from "../utils/cli.ts"; import { log } from "../utils/cli.ts";
import { spawn } from "../utils/subprocess.ts"; import { spawn } from "../utils/subprocess.ts";
@@ -10,6 +11,14 @@ import {
setupProcessAgentEnv, setupProcessAgentEnv,
} from "./shared.ts"; } from "./shared.ts";
// import { createOpencode } from "@opencode-ai/sdk"
// const { client } = await createOpencode({
// config: {
// ''
// }
// })
// opencode cli event types inferred from json output format // opencode cli event types inferred from json output format
interface OpenCodeInitEvent { interface OpenCodeInitEvent {
type: "init"; type: "init";
+14235 -12862
View File
File diff suppressed because it is too large Load Diff
+1 -1
View File
@@ -1 +1 @@
Use the debug_shell_command MCP tool to run `git status` and tell me what agent is running Tell me a joke
+15 -6
View File
@@ -52,12 +52,13 @@ export interface MainResult {
export async function main(inputs: Inputs): Promise<MainResult> { export async function main(inputs: Inputs): Promise<MainResult> {
let mcpServerClose: (() => Promise<void>) | undefined; let mcpServerClose: (() => Promise<void>) | undefined;
let payload: Payload | undefined;
try { try {
const timer = new Timer(); const timer = new Timer();
// parse payload early to extract agent // parse payload early to extract agent
const payload = parsePayload(inputs); payload = parsePayload(inputs);
const partialCtx = await initializeContext(inputs, payload); const partialCtx = await initializeContext(inputs, payload);
const ctx = partialCtx as MainContext; const ctx = partialCtx as MainContext;
@@ -86,21 +87,29 @@ export async function main(inputs: Inputs): Promise<MainResult> {
const result = await runAgent(ctx); const result = await runAgent(ctx);
const mainResult = await handleAgentResult(result); const mainResult = await handleAgentResult(result);
// ensure progress comment is updated if it was never updated during execution
await ensureProgressCommentUpdated();
return mainResult; 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 }); try {
await reportErrorToComment({ error: errorMessage });
} catch {
// error reporting failed, but don't let it mask the original error
}
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,
}; };
} finally { } finally {
// ensure progress comment is updated if it was never updated during execution
// do this before revoking the token so we can still make API calls
try {
await ensureProgressCommentUpdated(payload);
} catch {
// error updating comment, but don't let it mask the original error
}
if (mcpServerClose) { if (mcpServerClose) {
await mcpServerClose(); await mcpServerClose();
} }
+81 -32
View File
@@ -1,7 +1,10 @@
import { Octokit } from "@octokit/rest";
import { type } from "arktype"; import { type } from "arktype";
import { LEAPING_INTO_ACTION_PREFIX } from "../../utils/github/leapingComment.ts";
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 { fetchWorkflowRunInfo } from "../utils/api.ts";
import { getGitHubInstallationToken, parseRepoContext } from "../utils/github.ts";
import { contextualize, getMcpContext, 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 -->";
@@ -178,8 +181,7 @@ export async function reportProgress({ body }: { body: string }): Promise<
// no existing comment - create one // no existing comment - create one
const issueNumber = ctx.payload.event.issue_number; const issueNumber = ctx.payload.event.issue_number;
if (issueNumber === undefined) { if (issueNumber === undefined) {
// fail silently - cannot create comment without issue_number throw new Error("cannot create progress comment: no issue_number found in the payload event");
return undefined;
} }
const result = await ctx.octokit.rest.issues.createComment({ const result = await ctx.octokit.rest.issues.createComment({
@@ -209,13 +211,6 @@ export const ReportProgressTool = tool({
execute: contextualize(async ({ body }) => { execute: contextualize(async ({ body }) => {
const result = await reportProgress({ body }); const result = await reportProgress({ body });
if (!result) {
return {
success: false,
message: "cannot create progress comment: no issue_number found in the payload event",
};
}
return { return {
success: true, success: true,
...result, ...result,
@@ -234,39 +229,93 @@ export function wasProgressCommentUpdated(): boolean {
* Ensure the progress comment is updated with a generic error message if it was never updated. * 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 * This should be called after agent execution completes to handle cases where the agent
* exited without ever calling reportProgress. * exited without ever calling reportProgress.
*
* Works even if MCP context is not initialized (e.g., if error occurs before MCP server starts).
* Will fetch comment ID from database if not available in environment variable.
*/ */
export async function ensureProgressCommentUpdated(): Promise<void> { export async function ensureProgressCommentUpdated(payload?: Payload): Promise<void> {
// only update if we have a progress comment ID from env var and it was never updated // skip if comment was already updated during execution
const existingCommentId = getProgressCommentId(); if (progressCommentWasUpdated) {
if (!existingCommentId || progressCommentWasUpdated) {
return; return;
} }
// check if MCP context is initialized (MCP server started) // try to get comment ID from env var first, then from database if needed
let existingCommentId = getProgressCommentId();
// if not in env var, try fetching from database using run ID
if (!existingCommentId) {
const runId = process.env.GITHUB_RUN_ID;
if (runId) {
try {
const workflowRunInfo = await fetchWorkflowRunInfo(runId);
if (workflowRunInfo.progressCommentId) {
existingCommentId = parseInt(workflowRunInfo.progressCommentId, 10);
// cache it in env var for future use
if (!Number.isNaN(existingCommentId)) {
process.env.PULLFROG_PROGRESS_COMMENT_ID = workflowRunInfo.progressCommentId;
}
}
} catch {
// database fetch failed, continue without comment ID
}
}
}
// if still no comment ID, nothing to update
if (!existingCommentId) {
return;
}
// check if comment still says "leaping into action" - if it's been updated with an error, don't overwrite it
const repoContext = parseRepoContext();
const token = getGitHubInstallationToken();
const octokit = new Octokit({ auth: token });
try {
const existingComment = await octokit.rest.issues.getComment({
owner: repoContext.owner,
repo: repoContext.name,
comment_id: existingCommentId,
});
const commentBody = existingComment.data.body || "";
// if comment doesn't start with the leaping prefix, it's already been updated with an error or progress
if (!commentBody.startsWith(LEAPING_INTO_ACTION_PREFIX)) {
return;
}
} catch {
// can't fetch comment, skip update
return;
}
// try to get payload from MCP context if available, otherwise use provided payload
let resolvedPayload: Payload | undefined;
try { try {
const ctx = getMcpContext(); const ctx = getMcpContext();
const repoContext = parseRepoContext(); resolvedPayload = ctx.payload;
const runId = process.env.GITHUB_RUN_ID; } catch {
const workflowRunLink = runId // MCP context not initialized, use provided payload
? `[workflow](https://github.com/${repoContext.owner}/${repoContext.name}/actions/runs/${runId})` resolvedPayload = payload;
: "workflow"; }
const errorMessage = `❌ this run croaked const runId = process.env.GITHUB_RUN_ID;
const workflowRunLink = runId
? `[workflow](https://github.com/${repoContext.owner}/${repoContext.name}/actions/runs/${runId})`
: "workflow";
const errorMessage = `❌ this run croaked
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. Please check the ${workflowRunLink} for details.`;
const bodyWithFooter = addFooter(errorMessage, ctx.payload); // add footer if we have payload, otherwise use plain message
const body = resolvedPayload ? addFooter(errorMessage, resolvedPayload) : errorMessage;
await ctx.octokit.rest.issues.updateComment({ await octokit.rest.issues.updateComment({
owner: ctx.owner, owner: repoContext.owner,
repo: ctx.name, repo: repoContext.name,
comment_id: existingCommentId, comment_id: existingCommentId,
body: bodyWithFooter, body,
}); });
} 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({
+2 -1
View File
@@ -1,6 +1,6 @@
{ {
"name": "@pullfrog/action", "name": "@pullfrog/action",
"version": "0.0.130", "version": "0.0.131",
"type": "module", "type": "module",
"files": [ "files": [
"index.js", "index.js",
@@ -31,6 +31,7 @@
"@octokit/rest": "^22.0.0", "@octokit/rest": "^22.0.0",
"@octokit/webhooks-types": "^7.6.1", "@octokit/webhooks-types": "^7.6.1",
"@openai/codex-sdk": "0.58.0", "@openai/codex-sdk": "0.58.0",
"@opencode-ai/sdk": "^1.0.143",
"@standard-schema/spec": "1.0.0", "@standard-schema/spec": "1.0.0",
"arktype": "2.1.28", "arktype": "2.1.28",
"dotenv": "^17.2.3", "dotenv": "^17.2.3",
+8
View File
@@ -32,6 +32,9 @@ importers:
'@openai/codex-sdk': '@openai/codex-sdk':
specifier: 0.58.0 specifier: 0.58.0
version: 0.58.0 version: 0.58.0
'@opencode-ai/sdk':
specifier: ^1.0.143
version: 1.0.143
'@standard-schema/spec': '@standard-schema/spec':
specifier: 1.0.0 specifier: 1.0.0
version: 1.0.0 version: 1.0.0
@@ -440,6 +443,9 @@ packages:
resolution: {integrity: sha512-Z5vK294gUS9cn7bpU/lJtgzqJy1UqIGee7WMP+1Z4a6AxDcTxFCLZI4YkH9praJfrgoj5bFeu+3V9HIoBBTzcw==} resolution: {integrity: sha512-Z5vK294gUS9cn7bpU/lJtgzqJy1UqIGee7WMP+1Z4a6AxDcTxFCLZI4YkH9praJfrgoj5bFeu+3V9HIoBBTzcw==}
engines: {node: '>=18'} engines: {node: '>=18'}
'@opencode-ai/sdk@1.0.143':
resolution: {integrity: sha512-dtmkBfJ7IIAHzL6KCzAlwc9GybfJONVeCsF6ePYySpkuhslDbRkZBJYb5vqGd1H5zdsgjc6JjuvmOf0rPWUL6A==}
'@sec-ant/readable-stream@0.4.1': '@sec-ant/readable-stream@0.4.1':
resolution: {integrity: sha512-831qok9r2t8AlxLko40y2ebgSDhenenCatLVeW/uBtnHPyhHOvG0C7TvfgecV+wHzIm5KUICgzmVpWS+IMEAeg==} resolution: {integrity: sha512-831qok9r2t8AlxLko40y2ebgSDhenenCatLVeW/uBtnHPyhHOvG0C7TvfgecV+wHzIm5KUICgzmVpWS+IMEAeg==}
@@ -1410,6 +1416,8 @@ snapshots:
'@openai/codex-sdk@0.58.0': {} '@openai/codex-sdk@0.58.0': {}
'@opencode-ai/sdk@1.0.143': {}
'@sec-ant/readable-stream@0.4.1': {} '@sec-ant/readable-stream@0.4.1': {}
'@sindresorhus/merge-streams@4.0.0': {} '@sindresorhus/merge-streams@4.0.0': {}
+57 -26
View File
@@ -1,24 +1,21 @@
import { reportProgress } from "../mcp/comment.ts"; import { Octokit } from "@octokit/rest";
import { getMcpContext } from "../mcp/shared.ts"; import { fetchWorkflowRunInfo } from "./api.ts";
import { log } from "./cli.ts"; import { getGitHubInstallationToken, parseRepoContext } from "./github.ts";
/** /**
* Check if MCP context is initialized (i.e., MCP server has started) * Get progress comment ID from environment variable or database.
*/ */
function isMcpContextInitialized(): boolean { function getProgressCommentIdFromEnv(): number | null {
try { const envCommentId = process.env.PULLFROG_PROGRESS_COMMENT_ID;
getMcpContext(); if (envCommentId) {
return true; const parsed = parseInt(envCommentId, 10);
} catch { if (!Number.isNaN(parsed)) {
return false; return parsed;
}
} }
return null;
} }
/**
* 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({ export async function reportErrorToComment({
error, error,
title, title,
@@ -26,18 +23,52 @@ export async function reportErrorToComment({
error: string; error: string;
title?: string; title?: string;
}): Promise<void> { }): Promise<void> {
// only report if MCP context is initialized (MCP server has started) const formattedError = title ? `${title}\n\n${error}` : `${error}`;
if (!isMcpContextInitialized()) {
log.debug("skipping error comment update: MCP context not initialized"); // try to get comment ID from env var first, then from database if needed
return; let commentId = getProgressCommentIdFromEnv();
// if not in env var, try fetching from database using run ID
if (!commentId) {
const runId = process.env.GITHUB_RUN_ID;
if (runId) {
try {
const workflowRunInfo = await fetchWorkflowRunInfo(runId);
if (workflowRunInfo.progressCommentId) {
const parsed = parseInt(workflowRunInfo.progressCommentId, 10);
if (!Number.isNaN(parsed)) {
commentId = parsed;
// cache it in env var for future use
process.env.PULLFROG_PROGRESS_COMMENT_ID = workflowRunInfo.progressCommentId;
}
}
} catch {
// database fetch failed, continue without comment ID
}
}
} }
try { // if no comment ID available, try using reportProgress (requires MCP context)
const formattedError = title ? `${title}\n\n${error}` : `${error}`; if (!commentId) {
await reportProgress({ body: formattedError }); try {
} catch (reportError) { const { reportProgress } = await import("../mcp/comment.ts");
// log but don't throw - we don't want error reporting to fail the workflow await reportProgress({ body: formattedError });
const errorMessage = reportError instanceof Error ? reportError.message : String(reportError); return;
log.warning(`failed to report error to comment: ${errorMessage}`); } catch {
// MCP context not available, can't create/update comment
return;
}
} }
// update comment directly using GitHub API
const repoContext = parseRepoContext();
const token = getGitHubInstallationToken();
const octokit = new Octokit({ auth: token });
await octokit.rest.issues.updateComment({
owner: repoContext.owner,
repo: repoContext.name,
comment_id: commentId,
body: formattedError,
});
} }