Compare commits

...

8 Commits

Author SHA1 Message Date
Colin McDonnell 3724572346 0.0.134 2025-12-13 12:29:15 -08:00
Colin McDonnell 6b79fd4e29 Improve PR, add pwd 2025-12-13 12:28:59 -08:00
David Blass 6371584c80 ok 2025-12-13 00:35:03 -05:00
David Blass bb55216a6b iterate on pr fix 2025-12-11 18:02:44 -05:00
David Blass 7959a51995 update deps 2025-12-11 15:08:10 -05:00
Shawn Morreau 2c2f7cfe30 remove top level import 2025-12-11 15:06:32 -05:00
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
13 changed files with 14718 additions and 12994 deletions
+4
View File
@@ -223,5 +223,9 @@ The following is structured data about the GitHub event that triggered this run
${encodedEvent}`
: ""
}
************* RUNTIME CONTEXT *************
working_directory: ${process.cwd()}
`;
};
+9
View File
@@ -1,4 +1,5 @@
import { existsSync, mkdirSync, readFileSync, writeFileSync } from "node:fs";
import { join } from "node:path";
import { log } from "../utils/cli.ts";
import { spawn } from "../utils/subprocess.ts";
@@ -10,6 +11,14 @@ import {
setupProcessAgentEnv,
} from "./shared.ts";
// import { createOpencode } from "@opencode-ai/sdk"
// const { client } = await createOpencode({
// config: {
// ''
// }
// })
// opencode cli event types inferred from json output format
interface OpenCodeInitEvent {
type: "init";
+14339 -12881
View File
File diff suppressed because it is too large Load Diff
+9
View File
@@ -149,6 +149,15 @@ export type PayloadEvent =
trigger: "workflow_dispatch";
[key: string]: any;
}
| {
trigger: "fix_review";
issue_number: number;
review_id: number;
/** "all" to fix all comments, or specific comment IDs to fix */
comment_ids: number[] | "all";
branch: string;
[key: string]: any;
}
| {
trigger: "unknown";
[key: string]: any;
+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
+28 -7
View File
@@ -8,7 +8,7 @@ import { agents } from "./agents/index.ts";
import type { AgentResult } from "./agents/shared.ts";
import type { AgentName, Payload } from "./external.ts";
import { agentsManifest } from "./external.ts";
import { ensureProgressCommentUpdated } from "./mcp/comment.ts";
import { ensureProgressCommentUpdated, reportProgress } from "./mcp/comment.ts";
import { createMcpConfigs } from "./mcp/config.ts";
import { startMcpHttpServer } from "./mcp/server.ts";
import { getModes, modes } from "./modes.ts";
@@ -52,12 +52,13 @@ export interface MainResult {
export async function main(inputs: Inputs): Promise<MainResult> {
let mcpServerClose: (() => Promise<void>) | undefined;
let payload: Payload | undefined;
try {
const timer = new Timer();
// parse payload early to extract agent
const payload = parsePayload(inputs);
payload = parsePayload(inputs);
const partialCtx = await initializeContext(inputs, payload);
const ctx = partialCtx as MainContext;
@@ -77,6 +78,18 @@ export async function main(inputs: Inputs): Promise<MainResult> {
mcpServerClose = ctx.mcpServerClose;
timer.checkpoint("startMcpServer");
// check for empty comment_ids in fix_review trigger - report and exit early
if (
ctx.payload.event.trigger === "fix_review" &&
Array.isArray(ctx.payload.event.comment_ids) &&
ctx.payload.event.comment_ids.length === 0
) {
await reportProgress({
body: `👍 **No approved comments found**\n\nTo use "Fix 👍s", add a 👍 reaction to one or more inline review comments you want fixed.`,
});
return { success: true };
}
setupMcpServers(ctx);
await installAgentCli(ctx);
@@ -86,21 +99,29 @@ export async function main(inputs: Inputs): Promise<MainResult> {
const result = await runAgent(ctx);
const mainResult = await handleAgentResult(result);
// ensure progress comment is updated if it was never updated during execution
await ensureProgressCommentUpdated();
return mainResult;
} catch (error) {
const errorMessage = error instanceof Error ? error.message : "Unknown error occurred";
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();
// ensure progress comment is updated if it was never updated during execution
await ensureProgressCommentUpdated();
return {
success: false,
error: errorMessage,
};
} 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) {
await mcpServerClose();
}
+127 -54
View File
@@ -1,10 +1,18 @@
import { Octokit } from "@octokit/rest";
import { type } from "arktype";
import type { Payload } from "../external.ts";
import { agentsManifest } from "../external.ts";
import { parseRepoContext } from "../utils/github.ts";
import { fetchWorkflowRunInfo } from "../utils/api.ts";
import { buildPullfrogFooter, stripExistingFooter } from "../utils/buildPullfrogFooter.ts";
import { getGitHubInstallationToken, parseRepoContext } from "../utils/github.ts";
import { contextualize, getMcpContext, tool } from "./shared.ts";
const PULLFROG_DIVIDER = "<!-- PULLFROG_DIVIDER_DO_NOT_REMOVE_PLZ -->";
/**
* The prefix text for the initial "leaping into action" comment.
* This is used to identify if a comment is still in its initial state
* and hasn't been updated with progress or error messages.
*/
export const LEAPING_INTO_ACTION_PREFIX = "Leaping into action";
function buildCommentFooter(payload: Payload): string {
const repoContext = parseRepoContext();
@@ -12,25 +20,15 @@ function buildCommentFooter(payload: Payload): string {
const agentName = payload.agent;
const agentInfo = agentName ? agentsManifest[agentName] : null;
const agentDisplayName = agentInfo?.displayName || "Unknown agent";
const agentUrl = agentInfo?.url || "https://pullfrog.com";
// build workflow run link or show unavailable message
const workflowRunPart = runId
? `[View workflow run](https://github.com/${repoContext.owner}/${repoContext.name}/actions/runs/${runId})`
: "View workflow run";
return `
${PULLFROG_DIVIDER}
<sup><a href="https://pullfrog.com"><picture><source media="(prefers-color-scheme: dark)" srcset="https://pullfrog.com/logos/frog-white-full-128px.png"><img src="https://pullfrog.com/logos/frog-green-full-128px.png" width="9px" height="9px" style="vertical-align: middle; " alt="Pullfrog"></picture></a>&nbsp;&nbsp; Triggered by [Pullfrog](https://pullfrog.com) Using [${agentDisplayName}](${agentUrl}) ${workflowRunPart} [𝕏](https://x.com/pullfrogai)</sup>`;
}
function stripExistingFooter(body: string): string {
const dividerIndex = body.indexOf(PULLFROG_DIVIDER);
if (dividerIndex === -1) {
return body;
}
return body.substring(0, dividerIndex).trimEnd();
return buildPullfrogFooter({
triggeredBy: true,
agent: {
displayName: agentInfo?.displayName || "Unknown agent",
url: agentInfo?.url || "https://pullfrog.com",
},
workflowRun: runId ? { owner: repoContext.owner, repo: repoContext.name, runId } : undefined,
});
}
function addFooter(body: string, payload: Payload): string {
@@ -46,7 +44,8 @@ export const Comment = type({
export const CreateCommentTool = tool({
name: "create_issue_comment",
description: "Create a comment on a GitHub issue",
description:
"Create a comment on a GitHub issue. NOTE: Do NOT use this for progress updates or status summaries - use report_progress instead, which updates the existing progress comment.",
parameters: Comment,
execute: contextualize(async ({ issueNumber, body }, ctx) => {
const bodyWithFooter = addFooter(body, ctx.payload);
@@ -178,8 +177,7 @@ export async function reportProgress({ body }: { body: string }): Promise<
// 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;
throw new Error("cannot create progress comment: no issue_number found in the payload event");
}
const result = await ctx.octokit.rest.issues.createComment({
@@ -209,13 +207,6 @@ export const ReportProgressTool = tool({
execute: contextualize(async ({ 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 {
success: true,
...result,
@@ -230,55 +221,137 @@ export function wasProgressCommentUpdated(): boolean {
return progressCommentWasUpdated;
}
/**
* Delete the progress comment if it exists.
* Used after submitting a PR review since the review body contains all necessary info.
*/
export async function deleteProgressComment(): Promise<boolean> {
const existingCommentId = getProgressCommentId();
if (!existingCommentId) {
return false;
}
const ctx = getMcpContext();
await ctx.octokit.rest.issues.deleteComment({
owner: ctx.owner,
repo: ctx.name,
comment_id: existingCommentId,
});
// reset state but mark as "updated" so ensureProgressCommentUpdated doesn't try to handle it
progressCommentId = null;
progressCommentIdInitialized = true; // keep initialized so we don't re-fetch from env
progressCommentWasUpdated = true; // mark as handled so ensureProgressCommentUpdated skips
return true;
}
/**
* 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.
*
* 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> {
// only update if we have a progress comment ID from env var and it was never updated
const existingCommentId = getProgressCommentId();
if (!existingCommentId || progressCommentWasUpdated) {
export async function ensureProgressCommentUpdated(payload?: Payload): Promise<void> {
// skip if comment was already updated during execution
if (progressCommentWasUpdated) {
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 {
const ctx = getMcpContext();
const repoContext = parseRepoContext();
const runId = process.env.GITHUB_RUN_ID;
const workflowRunLink = runId
? `[workflow](https://github.com/${repoContext.owner}/${repoContext.name}/actions/runs/${runId})`
: "workflow";
resolvedPayload = ctx.payload;
} catch {
// MCP context not initialized, use provided payload
resolvedPayload = payload;
}
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.`;
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({
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
}
await octokit.rest.issues.updateComment({
owner: repoContext.owner,
repo: repoContext.name,
comment_id: existingCommentId,
body,
});
}
export const ReplyToReviewComment = type({
pull_number: type.number.describe("the pull request number"),
comment_id: type.number.describe("the ID of the review comment to reply to"),
body: type.string.describe("the reply text explaining how the feedback was addressed"),
body: type.string.describe(
"extremely brief reply (1 sentence max) explaining what was fixed, e.g. 'Fixed by renaming to X' or 'Added null check'"
),
});
export const ReplyToReviewCommentTool = tool({
name: "reply_to_review_comment",
description:
"Reply to a PR review comment thread explaining how the feedback was addressed. Use this after addressing each review comment to provide specific context about the changes made.",
"Reply to a PR review comment thread. Call this for EACH comment you address. Keep replies extremely brief (1 sentence max).",
parameters: ReplyToReviewComment,
execute: contextualize(async ({ pull_number, comment_id, body }, ctx) => {
const bodyWithFooter = addFooter(body, ctx.payload);
+38 -10
View File
@@ -1,12 +1,14 @@
import type { RestEndpointMethodTypes } from "@octokit/rest";
import { type } from "arktype";
import { buildPullfrogFooter } from "../utils/buildPullfrogFooter.ts";
import { deleteProgressComment } from "./comment.ts";
import { contextualize, tool } from "./shared.ts";
export const Review = type({
pull_number: type.number.describe("The pull request number to review"),
body: type.string
.describe(
"Brief summary or general feedback that doesn't apply to specific code locations. Keep it concise - most feedback should be in the 'comments' array."
"1-2 sentence high-level summary ONLY. Include urgency level and critical callouts (e.g., API key leak). ALL specific feedback MUST go in 'comments' array instead."
)
.optional(),
commit_id: type.string
@@ -23,14 +25,16 @@ export const Review = type({
"Side of the diff: LEFT (old code) or RIGHT (new code). Defaults to RIGHT if not provided."
)
.optional(),
body: type.string.describe("The comment text for this specific line"),
body: type.string.describe(
"The comment text for this specific line. For issues appearing multiple times, comment on the first occurrence and reference others."
),
start_line: type.number
.describe("Start line for multi-line comments (optional, for commenting on ranges)")
.optional(),
})
.array()
.describe(
"REQUIRED: Array of inline comments for specific code issues. Use this for all location-specific feedback. Use 'git diff origin/<base>...origin/<head>' to find the correct line numbers (typically use the line numbers shown on the RIGHT side for new code, LEFT side for old code)."
"PRIMARY location for ALL feedback. 95%+ of review content should be here. Use 'git diff origin/<base>...origin/<head>' to find correct line numbers (RIGHT side for new code, LEFT for old)."
)
.optional(),
});
@@ -38,19 +42,19 @@ export const Review = type({
export const ReviewTool = tool({
name: "submit_pull_request_review",
description:
"Submit a review (approve, request changes, or comment) for an existing pull request. " +
"IMPORTANT: Use 'comments' array for ALL specific code issues at the line-level. " +
"Only use 'body' for a brief summary or feedback that doesn't apply to a specific location.",
"Submit a review for an existing pull request. " +
"IMPORTANT: 95%+ of feedback should be in 'comments' array with file paths and line numbers. " +
"Only use 'body' for a 1-2 sentence summary with urgency and critical callouts.",
parameters: Review,
execute: contextualize(async ({ pull_number, body, commit_id, comments = [] }, ctx) => {
// Get the PR to determine the head commit if commit_id not provided
// get the PR to determine the head commit if commit_id not provided
const pr = await ctx.octokit.rest.pulls.get({
owner: ctx.owner,
repo: ctx.name,
pull_number,
});
// Compose the request
// compose the request
const params: RestEndpointMethodTypes["pulls"]["createReview"]["parameters"] = {
owner: ctx.owner,
repo: ctx.name,
@@ -65,7 +69,7 @@ export const ReviewTool = tool({
}
if (comments.length > 0) {
type ReviewComment = (typeof params.comments & {})[number];
// Convert comments to the format expected by GitHub API
// convert comments to the format expected by GitHub API
params.comments = comments.map((comment) => {
const reviewComment: ReviewComment = {
...comment,
@@ -79,9 +83,33 @@ export const ReviewTool = tool({
});
}
const result = await ctx.octokit.rest.pulls.createReview(params);
const reviewId = result.data.id;
// build quick links footer and update the review body
const apiUrl = process.env.API_URL || "https://pullfrog.com";
const fixAllUrl = `${apiUrl}/trigger/${ctx.owner}/${ctx.name}/${pull_number}?action=fix&review_id=${reviewId}`;
const fixApprovedUrl = `${apiUrl}/trigger/${ctx.owner}/${ctx.name}/${pull_number}?action=fix-approved&review_id=${reviewId}`;
const footer = buildPullfrogFooter({
customParts: [`[Fix all ➔](${fixAllUrl})`, `[Fix 👍s ➔](${fixApprovedUrl})`],
});
const updatedBody = (body || "") + footer;
// update the review with the footer
await ctx.octokit.rest.pulls.updateReview({
owner: ctx.owner,
repo: ctx.name,
pull_number,
review_id: reviewId,
body: updatedBody,
});
await deleteProgressComment();
return {
success: true,
reviewId: result.data.id,
reviewId,
html_url: result.data.html_url,
state: result.data.state,
user: result.data.user?.login,
+24 -9
View File
@@ -64,14 +64,19 @@ export function getModes({ disableProgressComment }: GetModesParams): Mode[] {
4. Make the necessary code changes to address the feedback. Work through each review comment systematically.
5. After addressing each review comment, use ${ghPullfrogMcpName}/reply_to_review_comment to reply directly to that comment thread explaining what change was made (keep replies concise, 1-2 sentences).
5. **CRITICAL: Reply to EACH review comment individually.** After fixing each comment, use ${ghPullfrogMcpName}/reply_to_review_comment to reply directly to that comment thread. Keep replies extremely brief (1 sentence max, e.g., "Fixed by renaming to X" or "Added null check").
6. Test your changes to ensure they work correctly.${disableProgressComment ? "" : `\n\n7. ${reportProgressInstruction}`}
6. Test your changes to ensure they work correctly.
8. When done, commit and push your changes to the existing PR branch. Do not create a new branch or PR - you are updating an existing one.
7. When done, commit and push your changes to the existing PR branch. Do not create a new branch or PR - you are updating an existing one.
${
disableProgressComment
? ""
: `
8. ${reportProgressInstruction}
9. Call report_progress one final time ONLY if you haven't already included a complete summary in a previous report_progress call. If you already called report_progress with complete information, you do NOT need to call it again. Only make a final call if you need to add missing information. **IMPORTANT**: Do NOT overwrite a good comment with details with a generic message.
`,
**CRITICAL: Keep the progress comment extremely brief.** The summary should be 1-2 sentences max (e.g., "Fixed 3 review comments and pushed changes."). Almost all detail belongs in the individual reply_to_review_comment calls, NOT in the progress comment.`
}`,
},
{
name: "Review",
@@ -80,13 +85,23 @@ export function getModes({ disableProgressComment }: GetModesParams): Mode[] {
prompt: `Follow these steps:
1. Get PR info with ${ghPullfrogMcpName}/get_pull_request (this automatically prepares the repository by fetching and checking out the PR branch)
2. View diff: git diff origin/<base>...origin/<head> (use line numbers from this for inline comments, replace <base> and <head> with 'base' and 'head' from PR info)
2. View diff: \`git diff origin/<base>...origin/<head>\` (replace <base> and <head> with 'base' and 'head' from PR info)
3. Read files from the checked-out PR branch to understand the implementation${disableProgressComment ? "" : `\n\n4. ${reportProgressInstruction}`}
3. Read files from the checked-out PR branch to understand the implementation. Always use **relative paths** from repo root (e.g., \`src/index.ts\`), never absolute paths.
5. When submitting review: use the 'comments' array for ALL specific code issues - include the file path and line position from the diff
4. Submit review using ${ghPullfrogMcpName}/submit_pull_request_review
6. Only use the 'body' field for a brief summary (1-2 sentences) or for feedback that doesn't apply to a specific code location`,
**CRITICAL: File paths and line numbers for inline comments**
- Use **relative paths** from repo root (e.g., \`packages/core/src/utils.ts\`)
- For line numbers, use the NEW file line number from the diff (shown after \`+\` in hunk headers like \`@@ -10,5 +12,8 @@\` means new file starts at line 12)
- Only comment on lines that appear in the diff - GitHub will reject comments on unchanged lines
**CRITICAL: Prioritize per-line feedback over summary text.**
- ALL specific feedback MUST go in the 'comments' array with file paths and line numbers from the diff
- for issues appearing in multiple places, comment on the FIRST occurrence and reference others (e.g., "also at lines X, Y" or "similar issue in otherFile.ts:42")
- the 'body' field is ONLY for: (1) a 1-2 sentence high-level overview, (2) urgency level (e.g., "minor suggestions" vs "blocking issues"), (3) critical security callouts (e.g., API key exposure)
- 95%+ of review content should be in per-line comments; the body should be just a couple sentences
- the review body will include quick action links for addressing feedback, so keep it concise`,
},
{
name: "Plan",
+3 -3
View File
@@ -1,6 +1,6 @@
{
"name": "@pullfrog/action",
"version": "0.0.130",
"version": "0.0.134",
"type": "module",
"files": [
"index.js",
@@ -31,13 +31,13 @@
"@octokit/rest": "^22.0.0",
"@octokit/webhooks-types": "^7.6.1",
"@openai/codex-sdk": "0.58.0",
"@opencode-ai/sdk": "^1.0.143",
"@standard-schema/spec": "1.0.0",
"arktype": "2.1.28",
"dotenv": "^17.2.3",
"execa": "^9.6.0",
"fastmcp": "^3.20.0",
"table": "^6.9.0",
"zod": "^3.25.76"
"table": "^6.9.0"
},
"devDependencies": {
"@types/node": "^24.7.2",
+8 -3
View File
@@ -32,6 +32,9 @@ importers:
'@openai/codex-sdk':
specifier: 0.58.0
version: 0.58.0
'@opencode-ai/sdk':
specifier: ^1.0.143
version: 1.0.143
'@standard-schema/spec':
specifier: 1.0.0
version: 1.0.0
@@ -50,9 +53,6 @@ importers:
table:
specifier: ^6.9.0
version: 6.9.0
zod:
specifier: ^3.25.76
version: 3.25.76
devDependencies:
'@types/node':
specifier: ^24.7.2
@@ -440,6 +440,9 @@ packages:
resolution: {integrity: sha512-Z5vK294gUS9cn7bpU/lJtgzqJy1UqIGee7WMP+1Z4a6AxDcTxFCLZI4YkH9praJfrgoj5bFeu+3V9HIoBBTzcw==}
engines: {node: '>=18'}
'@opencode-ai/sdk@1.0.143':
resolution: {integrity: sha512-dtmkBfJ7IIAHzL6KCzAlwc9GybfJONVeCsF6ePYySpkuhslDbRkZBJYb5vqGd1H5zdsgjc6JjuvmOf0rPWUL6A==}
'@sec-ant/readable-stream@0.4.1':
resolution: {integrity: sha512-831qok9r2t8AlxLko40y2ebgSDhenenCatLVeW/uBtnHPyhHOvG0C7TvfgecV+wHzIm5KUICgzmVpWS+IMEAeg==}
@@ -1410,6 +1413,8 @@ snapshots:
'@openai/codex-sdk@0.58.0': {}
'@opencode-ai/sdk@1.0.143': {}
'@sec-ant/readable-stream@0.4.1': {}
'@sindresorhus/merge-streams@4.0.0': {}
+71
View File
@@ -0,0 +1,71 @@
export const PULLFROG_DIVIDER = "<!-- PULLFROG_DIVIDER_DO_NOT_REMOVE_PLZ -->";
const FROG_LOGO = `<a href="https://pullfrog.com"><picture><source media="(prefers-color-scheme: dark)" srcset="https://pullfrog.com/logos/frog-white-full-128px.png"><img src="https://pullfrog.com/logos/frog-green-full-128px.png" width="9px" height="9px" style="vertical-align: middle; " alt="Pullfrog"></picture></a>`;
export interface AgentInfo {
displayName: string;
url: string;
}
export interface WorkflowRunInfo {
owner: string;
repo: string;
runId: string;
}
export interface BuildPullfrogFooterParams {
/** add "Triggered by Pullfrog" link */
triggeredBy?: boolean;
/** add "Using [agent](url)" link */
agent?: AgentInfo | undefined;
/** add "View workflow run" link */
workflowRun?: WorkflowRunInfo | undefined;
/** arbitrary custom parts (e.g., action links) */
customParts?: string[];
}
/**
* build a pullfrog footer with configurable parts
* always includes: frog logo at start, pullfrog.com link and X link at end
*/
export function buildPullfrogFooter(params: BuildPullfrogFooterParams): string {
const parts: string[] = [];
if (params.triggeredBy) {
parts.push("Triggered by [Pullfrog](https://pullfrog.com)");
}
if (params.agent) {
parts.push(`Using [${params.agent.displayName}](${params.agent.url})`);
}
if (params.workflowRun) {
const { owner, repo, runId } = params.workflowRun;
parts.push(`[View workflow run](https://github.com/${owner}/${repo}/actions/runs/${runId})`);
}
if (params.customParts) {
parts.push(...params.customParts);
}
const allParts = [
...parts,
"[pullfrog.com](https://pullfrog.com)",
"[𝕏](https://x.com/pullfrogai)",
];
return `
${PULLFROG_DIVIDER}
<sup>${FROG_LOGO}&nbsp;&nbsp; ${allParts.join(" ")}</sup>`;
}
/**
* strip any existing pullfrog footer from a comment body
*/
export function stripExistingFooter(body: string): string {
const dividerIndex = body.indexOf(PULLFROG_DIVIDER);
if (dividerIndex === -1) {
return body;
}
return body.substring(0, dividerIndex).trimEnd();
}
+57 -26
View File
@@ -1,24 +1,21 @@
import { reportProgress } from "../mcp/comment.ts";
import { getMcpContext } from "../mcp/shared.ts";
import { log } from "./cli.ts";
import { Octokit } from "@octokit/rest";
import { fetchWorkflowRunInfo } from "./api.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 {
try {
getMcpContext();
return true;
} catch {
return false;
function getProgressCommentIdFromEnv(): number | null {
const envCommentId = process.env.PULLFROG_PROGRESS_COMMENT_ID;
if (envCommentId) {
const parsed = parseInt(envCommentId, 10);
if (!Number.isNaN(parsed)) {
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({
error,
title,
@@ -26,18 +23,52 @@ export async function reportErrorToComment({
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;
const formattedError = title ? `${title}\n\n${error}` : `${error}`;
// try to get comment ID from env var first, then from database if needed
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 {
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}`);
// if no comment ID available, try using reportProgress (requires MCP context)
if (!commentId) {
try {
const { reportProgress } = await import("../mcp/comment.ts");
await reportProgress({ body: formattedError });
return;
} 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,
});
}