Compare commits

..

46 Commits

Author SHA1 Message Date
Colin McDonnell f77fecc2a0 Update 2026-01-28 07:47:52 +00:00
Mateusz Burzyński 071e885d63 Add upload tool and related APIs (#187)
* Add utils for r2 upload

* Add the tool and new routes

* fix auth issue

* sign headers

* add comment

* use our own API key to auth signed uploads

* Restructure things slightly

* tweak

* tweak

* add comments

* tweak

* revert a thing

* twaek

* drop mime type filtering

* new incarnation of mime type filtering

* jsut allow all octet-streams

* simplify further

* tweak

* update lockfile

---------

Co-authored-by: Colin McDonnell <colinmcd94@gmail.com>
2026-01-28 05:34:58 +00:00
pullfrog[bot] cac9b0e645 Strengthen PR body instructions to auto-close issues (#186)
Update Build and Prompt mode instructions to explicitly reference
`issue_number` from EVENT DATA and instruct agents to include
"Closes #<issue_number>" in PR bodies when working in the context
of an issue (where `is_pr` is not true).

Co-authored-by: pullfrog[bot] <226033991+pullfrog[bot]@users.noreply.github.com>
2026-01-28 04:30:35 +00:00
Colin McDonnell 0a4fcc556a Improve Review mode instructions (#194)
* Review hard

* Clean up suggestion instrcuctions

* Permalink tip

* Update action/modes.ts

Co-authored-by: pullfrog[bot] <226033991+pullfrog[bot]@users.noreply.github.com>

---------

Co-authored-by: pullfrog[bot] <226033991+pullfrog[bot]@users.noreply.github.com>
2026-01-28 02:00:22 +00:00
Colin McDonnell 102417f442 Add post hooks for cleanup (#193)
* Add post hooks for cleanup

* Switch to signal-based cleanup

* Better exit handling
2026-01-28 01:59:15 +00:00
pullfrog[bot] 90945a9481 chore: update pullfrog.yml workflow 2026-01-28 00:51:37 +00:00
pullfrog[bot] a200d07370 feat: Immediate Leaping into action (#146)
* feat: post "Leaping..." comment immediately without polling GitHub API

This change makes the initial comment response much faster by avoiding
the expensive GitHub API polling that was waiting for workflows to
dequeue (up to 12s+ in some cases).

New architecture:
1. Create WorkflowRun record BEFORE dispatching (no runId yet)
2. Post "Leaping into action..." comment with shortlink URL immediately
3. Dispatch workflow and return
4. workflow_run.requested webhook fills in runId when GitHub dequeues

Shortlink redirect at /api/workflow-run/[id]/logs:
- If runId available: redirects to GitHub workflow run
- If runId null: shows polling page that checks DB every 1.5s

Changes:
- Make `runId` optional on WorkflowRun model (filled in via webhook)
- Add `workflow_run` to expected webhook events
- Add handler for workflow_run.requested to update DB with runId
- Create /api/workflow-run/[id]/logs shortlink redirect route
- Refactor triggerWorkflow.ts to use eager comment pattern
- Update trigger page to use new pattern

Closes #141

* chore: add migration for nullable runId in WorkflowRun

* fix: rm dead code.

* fix: Adjusting the issueNumber prop usage comment.

* fix: shortening and JSDoc for createWorkflowRunRecord.

* fix: Reducing diff, reducing confusion on naming the id.

* Revert "fix: Adjusting the issueNumber prop usage comment."

This reverts commit 34d87c2f8bc58782a53ce5eb14a40935232a4924.

* refactor: reuse buildShortlinkUrl in trigger page

* fix: Reducing confusion on param naming.

* fix: shorter JSDoc.

* Apply suggestion from @RobinTail

* chore: remove unnecessary JSDoc comment from buildShortlinkUrl

* Revert "chore: remove unnecessary JSDoc comment from buildShortlinkUrl"

This reverts commit 491ba6ba3f31c874c9f391871739efa50adb0446.

* fix: confusing naming of var.

* fix: redundant 'let'.

* refactor: move route from `/api/workflow-run/[id]/logs` to `/api/workflow-run-logs/[id]`

Avoids confusion with existing `/api/workflow-run/[runId]` route which uses
GitHub's runId, whereas this new route uses the internal WorkflowRun record id.

* chore: remove old route directory

* refactor: reuse `buildShortlinkUrl()` with `shouldPoll` param

* refactor: add script prop to generateLeapingLoaderHtml

Instead of string-replacing to inject scripts, the function now
accepts an optional script prop that gets wrapped in <script> tags.

* mv script into new LeapingLoaderHtmlProps.

* refactor: extract `buildGithubUrl` helper to avoid repetition

* fix: shorening.

* fix: More clear subtitle.

* refactor: reuse `WORKFLOW_FILENAME` from `app/globals.ts`

* fix: shortening.

* feat: add integrity_id for reliable workflow matching

Pass WorkflowRun record id as integrity_id when dispatching workflows.
The webhook handler parses integrity_id from display_title (via run-name)
for reliable matching, with fallback to repo/owner lookup when missing.

Note: workflow template changes (.github/workflows/pullfrog.yml) need to be
applied manually as the GitHub App lacks workflows permission.

* Revert "feat: add integrity_id for reliable workflow matching"

This reverts commit dbc601233a0dd85ac5f0d608a221e7015e265aaa.

* Add todo for consideration later.

* docs: add plan for action-initiated workflow run correlation

Addresses review feedback requesting research into secure alternatives
to exposing HOOKDECK_API_KEY. Proposes leveraging existing OIDC token
exchange to pass WorkflowRun record ID and correlate with run_id.

* Revert "docs: add plan for action-initiated workflow run correlation"

This reverts commit b9279d0e99db4d85e4144675634afa941858333a.

* FEAT: Add optional integrity_id input, used by run-name, set with partial record id, read by handler for lookup.

* Add integrity_id to app/trigger/[owner]/[repo]/[number]/page.tsx

Co-authored-by: pullfrog[bot] <226033991+pullfrog[bot]@users.noreply.github.com>

* feat: Extracting INTEGRITY_ID_LENGTH.

* Add integrity_id to the workflow files of the repo.

* fix: Use Vercel Preview deployment URL into account in buildShortlinkUrl().

* fix: add missing `.rest` prefix in Octokit API call

* revert: remove unnecessary escaping of backticks in comment

* fix: add polling timeout and wrap DB update in try-catch

- Add 3-minute timeout to polling page to prevent indefinite polling
- Wrap updateWorkflowRunComment in try-catch to prevent orphaned comments

* feat: restore job-level deep linking for workflow runs

Adds jobId to WorkflowRun model and captures the first job ID via
listJobsForWorkflowRun() when the workflow_run_in_progress event fires.
The shortlink redirect now appends /job/{jobId} when available, providing
a direct link to the job rather than just the workflow run.

* fix: handle only `workflow_run.in_progress` to avoid race condition

Combine the handling of `runId` and `jobId` into a single update when
`workflow_run.in_progress` fires, avoiding the race condition where
`in_progress` could arrive before `requested` was processed.

* Renae integrity_id -> name

* Clean up

* Clean up

* Shorter timeout

* Add fallback

---------

Co-authored-by: pullfrog[bot] <226033991+pullfrog[bot]@users.noreply.github.com>
Co-authored-by: Robin Tail <robin_tail@me.com>
Co-authored-by: Colin McDonnell <colinmcd94@gmail.com>
2026-01-28 00:40:56 +00:00
Colin McDonnell af358ad671 Clean up 2026-01-27 19:44:06 +00:00
Colin McDonnell d44392b06d test secrets 2026-01-27 19:42:00 +00:00
Colin McDonnell 410aecc010 Test with local action 2026-01-27 19:07:54 +00:00
Colin McDonnell 6bd4097992 Test with local action 2026-01-27 19:06:17 +00:00
Colin McDonnell 2514bb1cf7 Improve autofix: simplify config, add loop prevention, strengthen Fix mode (#181)
* Improve autofix

* UI

* remove unused TriggerField props, improve bot commit detection

- Remove `alternateEnabledValue` and `enabledContent` props from TriggerField
  (dead code, not used by any caller)
- Move `isBotCommit` to module scope and check both `author.name` and
  `committer.name` for [bot] suffix

* fix: truncate workflow_runs before schema change

existing records don't have repoId, causing NOT NULL constraint failure

* truncate workflow_runs before adding NOT NULL repoId

existing rows don't have repoId values and can't be migrated

---------

Co-authored-by: pullfrog[bot] <226033991+pullfrog[bot]@users.noreply.github.com>
2026-01-27 03:54:46 +00:00
Colin McDonnell d545a84027 0.0.159 2026-01-25 22:30:34 +00:00
Colin McDonnell 7144f3de88 Tweaks 2026-01-25 08:38:59 +00:00
Colin McDonnell aeae128d1f test: trivial change to test preview system (#179)
* test: trivial change to test preview system

* test: trigger workflow
2026-01-25 08:18:49 +00:00
Colin McDonnell 9a2cb4cff3 Add preview testing system for action changes (#178)
* add preview testing system for action changes

- add preview-create.yml workflow (on PR open with action/ changes)
- add preview-cleanup.yml workflow (on PR close)
- add preview-create.ts script (creates repo, copies secrets, posts comment)
- add preview-cleanup.ts script (deletes preview repo)
- add wiki/preview-repo.md documentation
- add libsodium-wrappers for secret encryption

* fix: use --ignore-scripts for preview CI to avoid prisma generate

* fix: skip postinstall scripts in preview workflows

* fix: use fake DATABASE_URL for prisma generate

* remove @pullfrog mention from PR comment to avoid triggering

* add Vercel automation bypass for preview webhook forwarding

* replace fixed delay with exponential backoff polling for repo readiness

* chore: trigger preview redeploy for env var

* chore: trigger preview redeploy

* fix: skip webhook forwarding in non-production to prevent loops
2026-01-25 07:59:17 +00:00
Colin McDonnell 3a975cc384 Add md <> code comments 2026-01-24 18:56:51 +00:00
Colin McDonnell 210084a3b6 Make prompt construction more disciplined (#173)
* Make prompt construction more disciplined

* Clean up

* Tweaks
2026-01-24 18:49:47 +00:00
David Blass 54279e313b update security instructions, remove unused debug tool 2026-01-23 22:10:00 +00:00
Colin McDonnell b860c8a665 Cut down unnecessary logs 2026-01-23 06:47:40 +00:00
Colin McDonnell 5d4f81a007 Improve logging on resovelBody 2026-01-23 06:33:29 +00:00
Colin McDonnell 7621d6f0e5 tests and better diffs (#163)
* refactor get_review_comments to use reviewThreads graphql api with full thread context and proper diff extraction

* Improve get_review_comments output

* Improve tests and diffs

* GH_TOKEN

* Added back approved_by

* Fix CI
2026-01-23 06:28:22 +00:00
David Blass 9a8db3e07c add restricted tests, refactor test infrastructure (#150) 2026-01-22 21:06:19 +00:00
Colin McDonnell 41fb0e78be remove duplicate 2026-01-22 06:17:13 +00:00
Colin McDonnell 57895ae342 Update lock 2026-01-22 06:15:49 +00:00
Colin McDonnell c15049446f Improve logging for failed bash 2026-01-22 01:00:58 +00:00
Colin McDonnell 5740eba150 Hide trigger:workflow_dispatch from prompt 2026-01-21 23:04:39 +00:00
Colin McDonnell c6dfe4fa10 Update workflows 2026-01-21 03:27:12 +00:00
Colin McDonnell df4e7a9a4a Update workflows 2026-01-21 03:25:32 +00:00
Colin McDonnell 6af0c721ba Update workflows 2026-01-21 03:24:35 +00:00
Colin McDonnell 2f3c48edb6 Add get_commit_info 2026-01-21 03:22:29 +00:00
Colin McDonnell 22704dda35 Improve prInfo. Fix prompt duplication 2026-01-21 03:12:44 +00:00
Colin McDonnell 01ee59a96c Restrict github token (#140) 2026-01-21 02:17:46 +00:00
David Blass 04cc24bf64 improve nobash tests, fix cursor, reenable CI (#138) 2026-01-21 01:37:56 +00:00
Colin McDonnell ecbbc3ae6f Comment review tool 2026-01-21 01:18:17 +00:00
Colin McDonnell a3a1530da2 Improve PR review diffs (#139)
* Improve PR review diffs

* Clean up

* Add logging
2026-01-21 00:50:19 +00:00
Colin McDonnell 1edeaa0f4c Add reaction to one-comment PRs 2026-01-21 00:16:14 +00:00
Colin McDonnell c3ac7d9ff0 log.debug content 2026-01-21 00:01:06 +00:00
Colin McDonnell d98f6c8029 Switch back to grpahql for review threads 2026-01-20 23:59:52 +00:00
Colin McDonnell a5fffc97a5 Clean up ymls 2026-01-20 23:18:20 +00:00
David Blass 4e19178c81 fix CI (#111) 2026-01-20 17:25:06 +00:00
pullfrog[bot] 97001d7d88 fix: make PR creation conditional on user intent (#131)
- Build mode: rewrote steps 8-9 to consolidate PR creation logic into step 8
  with explicit default/branch-only behaviors, removing the false claim that
  create_pull_request is needed for commit attribution
- Prompt mode: updated step 2 with the same conditional PR creation logic

Fixes #84

Co-authored-by: pullfrog[bot] <226033991+pullfrog[bot]@users.noreply.github.com>
2026-01-20 10:39:04 +00:00
Anna Bocharova ef9c1ae412 Fix version validation for v0 in non-breaking policy. (#130) 2026-01-20 07:26:31 +00:00
Robin Tail cfd7f45db9 fix: Update lock file in the action dir due to #118. 2026-01-20 06:47:27 +00:00
Colin McDonnell ce123c9a57 Clean up prompts (#126)
* Clean up prompts

* Drop in-payload review comments
2026-01-20 00:13:55 +00:00
pullfrog[bot] 159e937d0d Check for API key existence when selecting agent in dashboard (#115)
* add api key existence check when selecting agent

- create getSecretNames utility to fetch GitHub Actions secret names
- add /api/repo/[owner]/[repo]/secrets endpoint to check secrets
- update AgentSettings to fetch and display secret validation status
- show green check when required API key exists
- show amber warning when required API key is missing
- show loading state while checking secrets

* Add API key checking

* Fix null agent test

* Tweaks

* Switch to getrepoorgsecretes

---------

Co-authored-by: pullfrog[bot] <226033991+pullfrog[bot]@users.noreply.github.com>
Co-authored-by: Colin McDonnell <colinmcd94@gmail.com>
2026-01-19 23:18:44 +00:00
79 changed files with 47765 additions and 22231 deletions
+16 -8
View File
@@ -1,37 +1,45 @@
# PULLFROG ACTION — DO NOT EDIT EXCEPT WHERE INDICATED
name: Pullfrog
run-name: ${{ inputs.name || github.workflow }}
on:
workflow_dispatch:
inputs:
prompt:
type: string
description: Agent prompt
workflow_call:
inputs:
prompt:
description: Agent prompt
name:
type: string
description: Run name
permissions:
id-token: write
contents: read
contents: write
pull-requests: write
issues: write
actions: read
checks: read
jobs:
pullfrog:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
- name: Checkout code
uses: actions/checkout@v6
with:
fetch-depth: 1
- name: Run agent
uses: pullfrog/pullfrog@main
with:
prompt: ${{ inputs.prompt }}
env:
# Feel free to comment out any you won't use
# add any additional keys your agent(s) need
# optionally, comment out any you won't use
ANTHROPIC_API_KEY: ${{ secrets.ANTHROPIC_API_KEY }}
OPENAI_API_KEY: ${{ secrets.OPENAI_API_KEY }}
GOOGLE_API_KEY: ${{ secrets.GOOGLE_API_KEY }}
GEMINI_API_KEY: ${{ secrets.GEMINI_API_KEY }}
CURSOR_API_KEY: ${{ secrets.CURSOR_API_KEY }}
MISTRAL_API_KEY: ${{ secrets.MISTRAL_API_KEY }}
GROQ_API_KEY: ${{ secrets.GROQ_API_KEY }}
DEEPSEEK_API_KEY: ${{ secrets.DEEPSEEK_API_KEY }}
OPENROUTER_API_KEY: ${{ secrets.OPENROUTER_API_KEY }}
+7 -3
View File
@@ -14,23 +14,27 @@ jobs:
node-version: "24"
cache: "pnpm"
- run: pnpm install --frozen-lockfile
- run: pnpm install --frozen-lockfile --ignore-scripts
- run: pnpm typecheck
- run: pnpm test
agents:
runs-on: ubuntu-latest
permissions:
contents: read
id-token: write
strategy:
fail-fast: false
matrix:
agent: [claude, codex, cursor, gemini, opencode]
test: [smoke, nobash]
test: [smoke, nobash, restricted]
env:
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
ANTHROPIC_API_KEY: ${{ secrets.ANTHROPIC_API_KEY }}
OPENAI_API_KEY: ${{ secrets.OPENAI_API_KEY }}
CURSOR_API_KEY: ${{ secrets.CURSOR_API_KEY }}
GOOGLE_API_KEY: ${{ secrets.GOOGLE_API_KEY }}
GEMINI_API_KEY: ${{ secrets.GEMINI_API_KEY }}
steps:
- uses: actions/checkout@v4
- uses: pnpm/action-setup@v4
@@ -39,5 +43,5 @@ jobs:
node-version: "24"
cache: "pnpm"
- run: pnpm install --frozen-lockfile
- run: pnpm install --frozen-lockfile --ignore-scripts
- run: pnpm ${{ matrix.test }} ${{ matrix.agent }}
+15 -15
View File
@@ -1,3 +1,4 @@
<!-- test preview system -->
<p align="center">
<h1 align="center">
<picture>
@@ -38,6 +39,7 @@ Pullfrog is the bridge between your preferred coding agents and GitHub. Use it f
- **🤙 Issue management** — Via the "issue created" trigger, Pullfrog can automatically respond to common questions, create implementation plans, and link to related issues/PRs. Or (if you're feeling lucky) you can prompt it to immediately attempt a PR addressing new issues.
- **Literally whatever** — Want to have the agent automatically add docs to all new PRs? Cut a new release with agent-written notes on every commit to `main`? Pullfrog lets you do it.
<!-- Features
- **Agent-agnostic** — Switch between agents with the click of a radio button.
- ** -->
@@ -69,42 +71,40 @@ on:
prompt:
type: string
description: 'Agent prompt'
workflow_call:
inputs:
prompt:
description: 'Agent prompt'
type: string
secrets: inherit
permissions:
id-token: write
contents: read
contents: write
pull-requests: write
issues: write
actions: read
checks: read
jobs:
pullfrog:
runs-on: ubuntu-latest
steps:
- name: Checkout code
uses: actions/checkout@v4
uses: actions/checkout@v6
with:
fetch-depth: 1
# optionally, setup your repo here
# the agent can figure this out itself, but pre-setup is more efficient
# - uses: actions/setup-node@v6
- name: Run agent
uses: pullfrog/pullfrog@v0
with:
prompt: ${{ inputs.prompt }}
env:
# feel free to comment out any you won't use
# add any additional keys your agent(s) need
# optionally, comment out any you won't use
ANTHROPIC_API_KEY: ${{ secrets.ANTHROPIC_API_KEY }}
OPENAI_API_KEY: ${{ secrets.OPENAI_API_KEY }}
GOOGLE_API_KEY: ${{ secrets.GOOGLE_API_KEY }}
GEMINI_API_KEY: ${{ secrets.GEMINI_API_KEY }}
CURSOR_API_KEY: ${{ secrets.CURSOR_API_KEY }}
MISTRAL_API_KEY: ${{ secrets.MISTRAL_API_KEY }}
GROQ_API_KEY: ${{ secrets.GROQ_API_KEY }}
DEEPSEEK_API_KEY: ${{ secrets.DEEPSEEK_API_KEY }}
OPENROUTER_API_KEY: ${{ secrets.OPENROUTER_API_KEY }}
```
#### 2. Create `triggers.yml`
+4 -1
View File
@@ -1,3 +1,6 @@
// changes to effort level configuration should be reflected in wiki/effort.md and docs/effort.mdx
// changes to tool permissions should be reflected in wiki/granular-tools.md
// changes to web search configuration should be reflected in wiki/websearch.md
import { type Options, query, type SDKMessage } from "@anthropic-ai/claude-agent-sdk";
import type { Effort } from "../external.ts";
import { ghPullfrogMcpName } from "../external.ts";
@@ -31,7 +34,7 @@ function buildDisallowedTools(ctx: AgentRunContext): string[] {
// both "disabled" and "restricted" block native bash
// "restricted" means use MCP bash tool instead
const bash = ctx.payload.bash;
if (bash !== "enabled") disallowed.push("Bash");
if (bash !== "enabled") disallowed.push("Bash", "Task(Bash)");
return disallowed;
}
+6 -1
View File
@@ -1,3 +1,6 @@
// changes to effort level configuration should be reflected in wiki/effort.md and docs/effort.mdx
// changes to tool permissions should be reflected in wiki/granular-tools.md
// changes to web search configuration should be reflected in wiki/websearch.md
import { mkdirSync, writeFileSync } from "node:fs";
import { join } from "node:path";
import {
@@ -59,7 +62,9 @@ ${mcpServerSections.join("\n\n")}
`.trim() + "\n"
);
log.info(`» Codex config written to ${configPath} (shell: ${bash === "enabled" ? "enabled" : "disabled"})`);
log.info(
`» Codex config written to ${configPath} (shell: ${bash === "enabled" ? "enabled" : "disabled"})`
);
return codexDir;
}
+40 -10
View File
@@ -1,3 +1,6 @@
// changes to effort level configuration should be reflected in wiki/effort.md and docs/effort.mdx
// changes to tool permissions should be reflected in wiki/granular-tools.md
// changes to web search configuration should be reflected in wiki/websearch.md
import { spawn } from "node:child_process";
import { existsSync, mkdirSync, readFileSync, writeFileSync } from "node:fs";
import { homedir } from "node:os";
@@ -99,6 +102,12 @@ export const cursor = agent({
name: "cursor",
install: installCursor,
run: async (ctx) => {
// validate API key exists for headless/CI authentication
const apiKey = process.env.CURSOR_API_KEY;
if (!apiKey) {
throw new Error("CURSOR_API_KEY is required for cursor agent");
}
// install CLI at start of run
const cliPath = await installCursor();
@@ -184,9 +193,16 @@ export const cursor = agent({
});
}
} else if (event.subtype === "completed") {
const isError = event.tool_call?.mcpToolCall?.result?.success?.isError;
const result = event.tool_call?.mcpToolCall?.result?.success;
const isError = result?.isError;
if (isError) {
log.warning("Tool call failed");
} else {
// log successful tool result so it appears in output
const text = result?.content?.[0]?.text?.text;
if (text) {
console.log(text);
}
}
}
},
@@ -203,12 +219,15 @@ export const cursor = agent({
try {
// build CLI args
// IMPORTANT: prompt is a POSITIONAL argument and must come LAST
// --print is a FLAG (not an option that takes a value)
const baseArgs = [
"--print",
ctx.instructions.full,
"--output-format",
"stream-json",
"--approve-mcps",
"--api-key",
apiKey,
];
// add model flag if we have an override
@@ -217,17 +236,23 @@ export const cursor = agent({
}
// always use --force since permissions are controlled via cli-config.json
const cursorArgs = [...baseArgs, "--force"];
// prompt MUST be last as a positional argument
const cursorArgs = [...baseArgs, "--force", ctx.instructions.full];
log.info("» running Cursor CLI...");
const startTime = Date.now();
// create env without XDG_CONFIG_HOME so CLI uses $HOME/.cursor/ where we wrote config
const cliEnv = Object.fromEntries(
Object.entries(process.env).filter(([key]) => key !== "XDG_CONFIG_HOME")
);
return new Promise((resolve) => {
const child = spawn(cliPath, cursorArgs, {
cwd: process.cwd(),
env: process.env,
stdio: ["ignore", "pipe", "pipe"], // Ignore stdin, pipe stdout/stderr
env: cliEnv,
stdio: ["ignore", "pipe", "pipe"],
});
let stdout = "";
@@ -315,12 +340,18 @@ export const cursor = agent({
},
});
// get the cursor config directory
// always use $HOME/.cursor/ for consistency
// when spawning the CLI, we unset XDG_CONFIG_HOME so it looks here too
function getCursorConfigDir(): string {
return join(homedir(), ".cursor");
}
// There was an issue on macOS when you set HOME to a temp directory
// it was unable to find the macOS keychain and would fail
// temp solution is to stick with the actual $HOME
function configureCursorMcpServers(ctx: AgentRunContext): void {
const realHome = homedir();
const cursorConfigDir = join(realHome, ".cursor");
const cursorConfigDir = getCursorConfigDir();
const mcpConfigPath = join(cursorConfigDir, "mcp.json");
mkdirSync(cursorConfigDir, { recursive: true });
@@ -345,11 +376,10 @@ interface CursorCliConfig {
/**
* Configure Cursor CLI tool permissions via cli-config.json.
*
* Config path: $HOME/.config/cursor/ (not ~/.cursor/).
* Config path: $HOME/.cursor/cli-config.json
*/
function configureCursorTools(ctx: AgentRunContext): void {
const realHome = homedir();
const cursorConfigDir = join(realHome, ".config", "cursor");
const cursorConfigDir = getCursorConfigDir();
const cliConfigPath = join(cursorConfigDir, "cli-config.json");
mkdirSync(cursorConfigDir, { recursive: true });
+3
View File
@@ -1,3 +1,6 @@
// changes to effort level configuration should be reflected in wiki/effort.md and docs/effort.mdx
// changes to tool permissions should be reflected in wiki/granular-tools.md
// changes to web search configuration should be reflected in wiki/websearch.md
import { mkdirSync, readFileSync, writeFileSync } from "node:fs";
import { homedir } from "node:os";
import { join } from "node:path";
+3
View File
@@ -1,3 +1,6 @@
// changes to effort level configuration should be reflected in wiki/effort.md and docs/effort.mdx
// changes to tool permissions should be reflected in wiki/granular-tools.md
// changes to web search configuration should be reflected in wiki/websearch.md
import { mkdirSync, writeFileSync } from "node:fs";
import { join } from "node:path";
import { ghPullfrogMcpName } from "../external.ts";
+6 -1
View File
@@ -33,7 +33,12 @@ export const agent = <const input extends AgentInput>(input: input): defineAgent
const search = ctx.payload.search;
const write = ctx.payload.write;
log.info(`» running ${input.name} with effort=${ctx.payload.effort}...`);
log.box(ctx.instructions.user.trim() + "\n\n" + ctx.instructions.event.trim(), {
// build log box content: user prompt first, then event data with eventInstructions as property
const eventWithInstructions = ctx.instructions.eventInstructions
? `additionalInstructions: ${ctx.instructions.eventInstructions}\n${ctx.instructions.event}`
: ctx.instructions.event;
const logParts = [ctx.instructions.user, eventWithInstructions].filter(Boolean);
log.box(logParts.join("\n\n---\n\n"), {
title: "Instructions",
});
log.info(`» tool permissions: web=${web}, search=${search}, write=${write}, bash=${bash}`);
+44978 -20907
View File
File diff suppressed because one or more lines are too long
+3
View File
@@ -6,6 +6,7 @@
import * as core from "@actions/core";
import { main } from "./main.ts";
import { runCleanup } from "./utils/exitHandler.ts";
async function run(): Promise<void> {
try {
@@ -17,6 +18,8 @@ async function run(): Promise<void> {
} catch (error) {
const errorMessage = error instanceof Error ? error.message : "Unknown error occurred";
core.setFailed(`Action failed: ${errorMessage}`);
} finally {
await runCleanup();
}
}
+33 -32
View File
@@ -69,17 +69,13 @@ interface BasePayloadEvent {
issue_number?: number;
is_pr?: boolean;
branch?: string;
pr_title?: string;
pr_body?: string | null;
issue_title?: string;
issue_body?: string | null;
/** title of the issue/PR (or contextual title for comments) */
title?: string;
/** primary content for this trigger (issue body, PR body, comment body, review body, etc.) */
body?: string | null;
comment_id?: number;
comment_body?: string;
review_id?: number;
review_body?: string | null;
review_state?: string;
review_comments?: any[];
context?: any;
thread?: any;
pull_request?: any;
check_suite?: {
@@ -102,8 +98,8 @@ interface PullRequestOpenedEvent extends BasePayloadEvent {
trigger: "pull_request_opened";
issue_number: number;
is_pr: true;
pr_title: string;
pr_body: string | null;
title: string;
body: string | null;
branch: string;
}
@@ -111,8 +107,8 @@ interface PullRequestReadyForReviewEvent extends BasePayloadEvent {
trigger: "pull_request_ready_for_review";
issue_number: number;
is_pr: true;
pr_title: string;
pr_body: string | null;
title: string;
body: string | null;
branch: string;
}
@@ -120,8 +116,8 @@ interface PullRequestReviewRequestedEvent extends BasePayloadEvent {
trigger: "pull_request_review_requested";
issue_number: number;
is_pr: true;
pr_title: string;
pr_body: string | null;
title: string;
body: string | null;
branch: string;
}
@@ -130,10 +126,9 @@ interface PullRequestReviewSubmittedEvent extends BasePayloadEvent {
issue_number: number;
is_pr: true;
review_id: number;
review_body: string | null;
/** review body is the primary content */
body: string | null;
review_state: string;
review_comments: any[];
context: any;
branch: string;
}
@@ -141,9 +136,10 @@ interface PullRequestReviewCommentCreatedEvent extends BasePayloadEvent {
trigger: "pull_request_review_comment_created";
issue_number: number;
is_pr: true;
pr_title: string;
title: string;
comment_id: number;
comment_body: string;
/** comment body is the primary content (null if already in prompt) */
body: string | null;
thread?: any;
branch: string;
}
@@ -151,42 +147,42 @@ interface PullRequestReviewCommentCreatedEvent extends BasePayloadEvent {
interface IssuesOpenedEvent extends BasePayloadEvent {
trigger: "issues_opened";
issue_number: number;
issue_title: string;
issue_body: string | null;
title: string;
body: string | null;
}
interface IssuesAssignedEvent extends BasePayloadEvent {
trigger: "issues_assigned";
issue_number: number;
issue_title: string;
issue_body: string | null;
title: string;
body: string | null;
}
interface IssuesLabeledEvent extends BasePayloadEvent {
trigger: "issues_labeled";
issue_number: number;
issue_title: string;
issue_body: string | null;
title: string;
body: string | null;
}
interface IssueCommentCreatedEvent extends BasePayloadEvent {
trigger: "issue_comment_created";
comment_id: number;
comment_body: string;
/** comment body is the primary content (null if already in prompt) */
body: string | null;
issue_number: number;
// PR-specific fields (only present when is_pr is true)
is_pr?: true;
branch?: string;
pr_title?: string;
pr_body?: string | null;
title?: string;
}
interface CheckSuiteCompletedEvent extends BasePayloadEvent {
trigger: "check_suite_completed";
issue_number: number;
is_pr: true;
pr_title: string;
pr_body: string | null;
title: string;
body: string | null;
pull_request: any;
branch: string;
check_suite: {
@@ -216,7 +212,8 @@ interface ImplementPlanEvent extends BasePayloadEvent {
trigger: "implement_plan";
issue_number: number;
plan_comment_id: number;
plan_content: string;
/** plan content is the primary content (null if already in prompt) */
body: string | null;
}
interface UnknownEvent extends BasePayloadEvent {
@@ -244,10 +241,14 @@ export type PayloadEvent =
// writeable payload type for building payloads
export interface WriteablePayload {
"~pullfrog": true;
/** semantic version of the payload to ensure compatibility */
version: string;
/** agent slug identifier (e.g., "claude", "codex", "gemini") */
agent?: AgentName | undefined;
/** the prompt/instructions for the agent to execute (body if @pullfrog tagged + per-trigger instructions) */
/** the user's actual request (body if @pullfrog tagged) */
prompt: string;
/** event-level instructions for this trigger type (macro-expanded server-side) */
eventInstructions?: string | undefined;
/** repo-level instructions (macro-expanded server-side) */
repoInstructions?: string | undefined;
/** event data from webhook payload - discriminated union based on trigger field */
+7
View File
@@ -25507,7 +25507,14 @@ var core3 = __toESM(require_core(), 1);
// utils/log.ts
var core = __toESM(require_core(), 1);
var import_table = __toESM(require_src(), 1);
// utils/globals.ts
import { existsSync } from "node:fs";
var isCloudflareSandbox = !!process.env.CLOUDFLARE_APPLICATION_ID && !!process.env.SANDBOX_VERSION;
var isGitHubActions = !!process.env.GITHUB_ACTIONS;
var isInsideDocker = existsSync("/.dockerenv");
// utils/log.ts
var isDebugEnabled = () => process.env.LOG_LEVEL === "debug" || process.env.ACTIONS_STEP_DEBUG === "true" || process.env.RUNNER_DEBUG === "1" || core.isDebug();
function formatArgs(args) {
return args.map((arg) => {
+1 -1
View File
@@ -3,7 +3,7 @@
* This exports the main function for programmatic usage
*/
export type { Agent, AgentRunContext, AgentResult } from "./agents/shared.ts";
export type { Agent, AgentResult, AgentRunContext } from "./agents/shared.ts";
export {
type Inputs as ExecutionInputs,
type MainResult,
+44 -31
View File
@@ -1,16 +1,18 @@
import { ensureProgressCommentUpdated } from "./mcp/comment.ts";
// changes to tool permissions should be reflected in wiki/granular-tools.md
import { initToolState, startMcpHttpServer } from "./mcp/server.ts";
import { computeModes } from "./modes.ts";
import { resolveAgent } from "./utils/agent.ts";
import { validateApiKey } from "./utils/apiKeys.ts";
import { validateAgentApiKey } from "./utils/apiKeys.ts";
import { resolveBody } from "./utils/body.ts";
import { log, writeSummary } from "./utils/cli.ts";
import { reportErrorToComment } from "./utils/errorReport.ts";
import { setupExitHandler } from "./utils/exitHandler.ts";
import { createOctokit } from "./utils/github.ts";
import { resolveInstructions } from "./utils/instructions.ts";
import { normalizeEnv } from "./utils/normalizeEnv.ts";
import { resolvePayload } from "./utils/payload.ts";
import { resolveRepoData } from "./utils/repoData.ts";
import { handleAgentResult } from "./utils/run.ts";
import { resolveRunContextData } from "./utils/runContextData.ts";
import { createTempDirectory, setupGit } from "./utils/setup.ts";
import { Timer } from "./utils/timer.ts";
import { resolveInstallationToken } from "./utils/token.ts";
@@ -28,55 +30,75 @@ export async function main(): Promise<MainResult> {
// normalize env var names to uppercase (handles case-insensitive workflow files)
normalizeEnv();
// store original GITHUB_TOKEN
process.env.ORIGINAL_GITHUB_TOKEN = process.env.GITHUB_TOKEN;
const timer = new Timer();
await using tokenRef = await resolveInstallationToken();
process.env.GITHUB_TOKEN = tokenRef.token;
const octokit = createOctokit(tokenRef.token);
const runInfo = await resolveRun({ octokit });
const toolState = initToolState({ runInfo });
try {
const repo = await resolveRepoData({ octokit, token: tokenRef.token });
timer.checkpoint("repoData");
setupExitHandler(toolState);
// resolve payload after repoData so permissions can use DB settings
try {
const runContext = await resolveRunContextData({ octokit, token: tokenRef.token });
timer.checkpoint("runContextData");
// resolve payload after runContextData so permissions can use DB settings
// precedence: action inputs > json payload > repoSettings > fallbacks
const payload = resolvePayload(repo.repoSettings);
const payload = resolvePayload(runContext.repoSettings);
if (payload.cwd && process.cwd() !== payload.cwd) {
process.chdir(payload.cwd);
}
// resolve body - fetches body_html and converts to markdown if images present
// this ensures agents receive markdown with working signed image URLs
const originalBody = payload.event.body;
const resolvedBody = await resolveBody({
event: payload.event,
octokit,
repo: runContext.repo,
});
if (resolvedBody !== originalBody) {
payload.event.body = resolvedBody;
// also update prompt if original body was included there
if (originalBody && payload.prompt.includes(originalBody)) {
payload.prompt = payload.prompt.replace(originalBody, resolvedBody ?? "");
}
}
const tmpdir = createTempDirectory();
const agent = resolveAgent({ payload, repoSettings: repo.repoSettings });
const agent = resolveAgent({ payload, repoSettings: runContext.repoSettings });
validateApiKey({
validateAgentApiKey({
agent,
owner: repo.owner,
name: repo.name,
owner: runContext.repo.owner,
name: runContext.repo.name,
});
await setupGit({
token: tokenRef.token,
owner: repo.owner,
name: repo.name,
originalToken: tokenRef.originalToken,
bashPermission: payload.bash,
owner: runContext.repo.owner,
name: runContext.repo.name,
event: payload.event,
octokit,
toolState,
});
timer.checkpoint("git");
const modes = [...computeModes(), ...repo.repoSettings.modes];
const modes = [...computeModes(), ...runContext.repoSettings.modes];
await using mcpHttpServer = await startMcpHttpServer({
repo,
repo: runContext.repo,
payload,
octokit,
githubInstallationToken: tokenRef.token,
apiToken: runContext.apiToken,
agent,
modes,
toolState,
@@ -88,7 +110,7 @@ export async function main(): Promise<MainResult> {
const instructions = resolveInstructions({
payload,
repoData: repo,
repo: runContext.repo,
modes,
});
@@ -101,11 +123,10 @@ export async function main(): Promise<MainResult> {
// write last progress body to job summary
if (toolState.lastProgressBody) {
writeSummary(toolState.lastProgressBody);
await writeSummary(toolState.lastProgressBody);
}
const mainResult = await handleAgentResult(result);
return mainResult;
return handleAgentResult(result);
} catch (error) {
const errorMessage = error instanceof Error ? error.message : "Unknown error occurred";
log.error(errorMessage);
@@ -118,13 +139,5 @@ export async function main(): Promise<MainResult> {
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(toolState);
} catch {
// error updating comment, but don't let it mask the original error
}
}
}
+66 -11
View File
@@ -7,7 +7,7 @@ this directory contains the mcp (model context protocol) server tools for intera
### check suite tools
#### `get_check_suite_logs`
get workflow run logs for a failed check suite.
get workflow run logs for a failed check suite with intelligent log analysis.
**parameters:**
- `check_suite_id` (number): the id from check_suite.id in the webhook payload
@@ -15,35 +15,90 @@ get workflow run logs for a failed check suite.
**replaces:** `gh run list` and `gh run view --log`
**returns:**
all logs from all failed workflow runs in the check suite, including:
- workflow run details (id, name, html_url, conclusion)
- job details for each workflow run (id, name, status, conclusion, logs)
structured failure information for each failed job:
- `_instructions`: explains how to use each field
- `failed_jobs[]`: array of failed job results, each containing:
- `job_id`, `job_name`, `job_url`: job identification
- `failed_steps`: which CI steps failed (e.g., "Step 6: Run tests")
- `log_index`: array of interesting lines (errors, warnings, failures) with line numbers
- `excerpt`: ~80 line curated window around the last error
- `full_log_path`: path to complete log file for deeper investigation
**log_index types:**
- `error`: lines matching `##[error]`, `Error:`, `ERR_`, `exit code N`
- `warning`: lines matching `##[warning]`, `WARN`
- `failure`: lines matching `N failed`, `FAIL`, `✕`
- `trace`: stack trace lines (deduplicated)
**workflow for using results:**
1. scan `log_index` to see where errors/warnings/failures are located in the log
2. read `excerpt` for immediate context around the main error
3. if excerpt doesn't show what you need, read specific line ranges from `full_log_path`
4. check `failed_steps` and read the workflow yml to understand what command failed
**example:**
```typescript
// when handling a check_suite_completed webhook
await mcp.call("gh_pullfrog/get_check_suite_logs", {
const result = await mcp.call("gh_pullfrog/get_check_suite_logs", {
check_suite_id: check_suite.id
});
// result.failed_jobs[0].log_index shows:
// [
// { line: 181, content: "WARN Failed to create bin...", type: "warning" },
// { line: 1079, content: "Error: expect(received).toBe(expected)", type: "error" },
// ...
// ]
// use these line numbers to read specific sections from full_log_path
```
### review tools
#### `get_review_comments`
get all line-by-line comments and their replies for a specific pull request review.
get all line-by-line comments for a specific pull request review, including full thread context for replies.
**parameters:**
- `pull_number` (number): the pull request number
- `review_id` (number): the id from review.id in the webhook payload
- `approved_by` (string, optional): only return comments this user gave a 👍 to
**replaces:** `gh api repos/{owner}/{repo}/pulls/{pull_number}/reviews/{review_id}/comments`
**returns:**
array of review comments including threaded replies:
- file path, line number, comment body
- side (LEFT/RIGHT) and position in diff
- user, timestamps, html_url
- in_reply_to_id for threaded comments (replies have this set to the parent comment id)
- `commentsPath`: path to XML file with full comment details
- `reviewer`: github username of the review author
- `count`: number of comments to address
**output format (XML):**
```xml
<review_comments count="2" reviewer="colinmcd94">
<summary>
<comment id="67890" file="src/utils/auth.ts" line="42">Actually, can you use a type guard...</comment>
<comment id="67891" file="src/api/handler.ts" line="15">This should handle the error case</comment>
</summary>
<comment id="67890" file="src/utils/auth.ts" line="42" author="colinmcd94">
<thread>
<message id="12345" author="colinmcd94">Please add null checking here</message>
<message id="23456" author="octocat">What about using optional chaining?</message>
</thread>
<diff>
@@ -40,7 +40,7 @@
const user = getUser(id);
- return user.name;
+ return user?.name;
</diff>
<body>Actually, can you use a type guard instead?</body>
</comment>
</review_comments>
```
- `<summary>` lists all comments to address with truncated preview
- `<thread>` shows parent comments (when replying to existing thread)
- `<diff>` contains the diff hunk around the commented line
- `<body>` is the actual comment text to address
**example:**
```typescript
+146
View File
@@ -0,0 +1,146 @@
// Vitest Snapshot v1, https://vitest.dev/guide/snapshot.html
exports[`fetchAndFormatPrDiff > fetches PR files and generates TOC with formatted diff > content 1`] = `
"## Files (3)
- .github/workflows/test.yml → lines 7-47
- index.test.ts → lines 48-110
- index.ts → lines 111-132
---
diff --git a/.github/workflows/test.yml b/.github/workflows/test.yml
--- a/.github/workflows/test.yml
+++ b/.github/workflows/test.yml
@@ -0,0 +1,36 @@
| | 1 | + | name: Test
| | 2 | + |
| | 3 | + | on:
| | 4 | + | push:
| | 5 | + | branches: [main]
| | 6 | + | pull_request:
| | 7 | + | branches: [main]
| | 8 | + |
| | 9 | + | jobs:
| | 10 | + | test:
| | 11 | + | runs-on: ubuntu-latest
| | 12 | + |
| | 13 | + | strategy:
| | 14 | + | matrix:
| | 15 | + | node-version: [22.x]
| | 16 | + |
| | 17 | + | steps:
| | 18 | + | - name: Checkout code
| | 19 | + | uses: actions/checkout@v4
| | 20 | + |
| | 21 | + | - name: Setup pnpm
| | 22 | + | uses: pnpm/action-setup@v2
| | 23 | + | with:
| | 24 | + | version: 8
| | 25 | + |
| | 26 | + | - name: Setup Node.js \${{ matrix.node-version }}
| | 27 | + | uses: actions/setup-node@v4
| | 28 | + | with:
| | 29 | + | node-version: \${{ matrix.node-version }}
| | 30 | + | cache: 'pnpm'
| | 31 | + |
| | 32 | + | - name: Install dependencies
| | 33 | + | run: pnpm install
| | 34 | + |
| | 35 | + | - name: Run tests
| | 36 | + | run: pnpm test
diff --git a/index.test.ts b/index.test.ts
--- a/index.test.ts
+++ b/index.test.ts
@@ -1,5 +1,5 @@
| 1 | 1 | | import { describe, it, expect } from 'vitest'
| 2 | | - | import { add } from './index.js'
| | 2 | + | import { add, multiply, subtract, divide } from './index.js'
| 3 | 3 | |
| 4 | 4 | | describe('add function', () => {
| 5 | 5 | | it('should add two positive numbers correctly', () => {
@@ -25,3 +25,51 @@ describe('add function', () => {
| 25 | 25 | | expect(add(0.1, 0.2)).toBeCloseTo(0.3)
| 26 | 26 | | })
| 27 | 27 | | })
| | 28 | + |
| | 29 | + | describe('multiply function', () => {
| | 30 | + | it('should multiply two positive numbers correctly', () => {
| | 31 | + | expect(multiply(3, 4)).toBe(12)
| | 32 | + | })
| | 33 | + |
| | 34 | + | it('should multiply negative numbers correctly', () => {
| | 35 | + | expect(multiply(-2, 3)).toBe(-6)
| | 36 | + | expect(multiply(-2, -3)).toBe(6)
| | 37 | + | })
| | 38 | + |
| | 39 | + | it('should handle zero correctly', () => {
| | 40 | + | expect(multiply(5, 0)).toBe(0)
| | 41 | + | expect(multiply(0, 5)).toBe(0)
| | 42 | + | })
| | 43 | + | })
| | 44 | + |
| | 45 | + | describe('subtract function', () => {
| | 46 | + | it('should subtract two positive numbers correctly', () => {
| | 47 | + | expect(subtract(10, 3)).toBe(7)
| | 48 | + | })
| | 49 | + |
| | 50 | + | it('should handle negative numbers correctly', () => {
| | 51 | + | expect(subtract(5, -3)).toBe(8)
| | 52 | + | expect(subtract(-5, 3)).toBe(-8)
| | 53 | + | })
| | 54 | + |
| | 55 | + | it('should handle zero correctly', () => {
| | 56 | + | expect(subtract(5, 0)).toBe(5)
| | 57 | + | expect(subtract(0, 5)).toBe(-5)
| | 58 | + | })
| | 59 | + | })
| | 60 | + |
| | 61 | + | describe('divide function', () => {
| | 62 | + | it('should divide two positive numbers correctly', () => {
| | 63 | + | expect(divide(10, 2)).toBe(5)
| | 64 | + | })
| | 65 | + |
| | 66 | + | it('should handle negative numbers correctly', () => {
| | 67 | + | expect(divide(-10, 2)).toBe(-5)
| | 68 | + | expect(divide(10, -2)).toBe(-5)
| | 69 | + | })
| | 70 | + |
| | 71 | + | it('should handle decimal results correctly', () => {
| | 72 | + | expect(divide(10, 3)).toBeCloseTo(3.333, 2)
| | 73 | + | expect(divide(7, 2)).toBe(3.5)
| | 74 | + | })
| | 75 | + | })
diff --git a/index.ts b/index.ts
--- a/index.ts
+++ b/index.ts
@@ -3,11 +3,13 @@ export function add(a: number, b: number) {
| 3 | 3 | | }
| 4 | 4 | |
| 5 | 5 | | export function multiply(a: number, b: number) {
| 6 | | - | // Bug: accidentally adding 1 to the result
| 7 | | - | return a * b + 1;
| | 6 | + | return a * b;
| 8 | 7 | | }
| 9 | 8 | |
| 10 | 9 | | export function subtract(a: number, b: number) {
| 11 | | - | // Bug: accidentally adding instead of subtracting
| 12 | | - | return a + b;
| | 10 | + | return a - b;
| | 11 | + | }
| | 12 | + |
| | 13 | + | export function divide(a: number, b: number) {
| | 14 | + | return a / b;
| 13 | 15 | | }
"
`;
exports[`fetchAndFormatPrDiff > fetches PR files and generates TOC with formatted diff > toc 1`] = `
"## Files (3)
- .github/workflows/test.yml → lines 7-47
- index.test.ts → lines 48-110
- index.ts → lines 111-132
---
"
`;
@@ -0,0 +1,42 @@
// Vitest Snapshot v1, https://vitest.dev/guide/snapshot.html
exports[`formatReviewThreads > formats thread blocks with TOC and correct line numbers > content 1`] = `
"# Review Threads (1) for PR #49 - Review 3485940013 by cursor
## TOC
- .github/workflows/test.yml:7 → lines 9-36
---
## .github/workflows/test.yml:7 [RESOLVED]
\`\`\`\`comment author=cursor id=2544544046 review=3485940013 *
### Bug: GitHub Actions workflow triggered for wrong branch
<!-- **High Severity** -->
<!-- DESCRIPTION START -->
The \`pull_request\` trigger specifies \`branches: [mainc]\`, but the \`push\` trigger specifies \`branches: [main]\`. This mismatch means pull requests will only trigger tests if targeting a non-existent \`mainc\` branch rather than the actual \`main\` development branch, preventing CI from running on most pull requests.
<!-- DESCRIPTION END -->
<!-- LOCATIONS START
.github/workflows/test.yml#L6-L7
LOCATIONS END -->
<a href="https://cursor.com/open?data=eyJhbGciOiJSUzI1NiIsInR5cCI6IkpXVCIsImtpZCI6ImJ1Z2JvdC12MSJ9.eyJ2ZXJzaW9uIjoxLCJ0eXBlIjoiQlVHQk9UX0ZJWF9JTl9DVVJTT1IiLCJkYXRhIjp7InJlZGlzS2V5IjoiYnVnYm90OjllMTgyY2U2LWY0YWMtNDAwNS1hMzQ4LWIyYzJkZTk4OGM1ZSIsImVuY3J5cHRpb25LZXkiOiJmSW93NEdsUGUwYlYtd3M2UC1UNHdHT1JmMGZjakxfWVZEdC00SWNveXo0IiwiYnJhbmNoIjoiZGl2aWRlIn0sImlhdCI6MTc2MzYyMDgxOSwiZXhwIjoxNzY0MjI1NjE5fQ.BjkWsTqiNriojI5v10JcveUY2M50f9eflTNDgWAdjdW9w7E0EEY4GJfyzBrA72neco3qAlc34WipASNuEQbTD1fZvwtJY-TeNTDzoKmwA6gtwICB8t7qT87GPvcbDrdGGWdC8kW1jf-LntTmD0k7gt0AeENRAdRSiD3dbqYFN0huXHaB8f2Y48mpmLcnnUpoaaZe7By-Y0DnILyHppwx3AH75nKE_ZeAee3rQNGX4cwcHgB5emTSM93pMDQhT1vbIRYHMaFkOaW2-kDOA8H2QqxD4mT8VzY3skvxIo5HNZCvqE84NtEygHqkBv88g2EEijOPAAeskfsdp087yIzV9g"><picture><source media="(prefers-color-scheme: dark)" srcset="https://cursor.com/fix-in-cursor-dark.svg"><source media="(prefers-color-scheme: light)" srcset="https://cursor.com/fix-in-cursor-light.svg"><img alt="Fix in Cursor" src="https://cursor.com/fix-in-cursor.svg"></picture></a>&nbsp;<a href="https://cursor.com/agents?data=eyJhbGciOiJSUzI1NiIsInR5cCI6IkpXVCIsImtpZCI6ImJ1Z2JvdC12MSJ9.eyJ2ZXJzaW9uIjoxLCJ0eXBlIjoiQlVHQk9UX0ZJWF9JTl9XRUIiLCJkYXRhIjp7InJlZGlzS2V5IjoiYnVnYm90OjllMTgyY2U2LWY0YWMtNDAwNS1hMzQ4LWIyYzJkZTk4OGM1ZSIsImVuY3J5cHRpb25LZXkiOiJmSW93NEdsUGUwYlYtd3M2UC1UNHdHT1JmMGZjakxfWVZEdC00SWNveXo0IiwiYnJhbmNoIjoiZGl2aWRlIiwicmVwb093bmVyIjoicHVsbGZyb2dhaSIsInJlcG9OYW1lIjoic2NyYXRjaCIsInByTnVtYmVyIjo0OSwiY29tbWl0U2hhIjoiNThiOGJmNmQ1MWE1Mjg4OGFjNGFkNzA5YWVmYTk2MWFkZDMyNDBiMSJ9LCJpYXQiOjE3NjM2MjA4MTksImV4cCI6MTc2NDIyNTYxOX0.SFDZe8R9uwhPjS55J4i_mV2ybsSZoQYM6YzdUOava4IKy1IK2OrVkVsG3-8p4rRaMBXdDZZ4ObPbtk70KqdAiLEDKBqaFcWqELc49lr0XRKUmu4F6EhESFQOvt7MLSVDIOgee8YRlhS6xtoPDqsRiV2KGOwyLEdCeYdrYz9i1DanIswWSoMRVvkjxZ6GUBYVAUg_JsgAXoKVJ-L9Q5Ygho6acVAr5NlGeBp2f6g49GX4GfDOPeV3SORQS1CjxQVRbjI-g0rW55NIisBEl8279VwG6-dTISNbyasZOB6R3eEmC4vmyAAGJjUsMwqhMPw1oaMMmYNSbtZLDESxME9IUg"><picture><source media="(prefers-color-scheme: dark)" srcset="https://cursor.com/fix-in-web-dark.svg"><source media="(prefers-color-scheme: light)" srcset="https://cursor.com/fix-in-web-light.svg"><img alt="Fix in Web" src="https://cursor.com/fix-in-web.svg"></picture></a>
\`\`\`\`
\`\`\`diff file=.github/workflows/test.yml lines=7 side=RIGHT
@@ -0,0 +1,36 @@
... (3 lines above) ...
+ push:
+ branches: [main]
+ pull_request:
+ branches: [main]
\`\`\`
"
`;
exports[`formatReviewThreads > formats thread blocks with TOC and correct line numbers > toc 1`] = `"- .github/workflows/test.yml:7 → lines 9-36"`;
+27 -33
View File
@@ -1,8 +1,10 @@
// changes to bash security (filterEnv, spawnBash) should be reflected in wiki/bash-sandbox.md, wiki/security.md, wiki/landlock.md, and docs/security.mdx
import { type ChildProcess, type StdioOptions, spawn } from "node:child_process";
import { randomUUID } from "node:crypto";
import { closeSync, openSync, writeFileSync } from "node:fs";
import { join } from "node:path";
import { type } from "arktype";
import { log } from "../utils/log.ts";
import type { ToolContext } from "./server.ts";
import { execute, tool } from "./shared.ts";
@@ -14,49 +16,40 @@ export const BashParams = type({
"background?": "boolean",
});
// patterns for sensitive env vars: suffixes (_KEY, _SECRET, _TOKEN) plus AI provider prefixes
// patterns for sensitive env vars
const SENSITIVE_PATTERNS = [/_KEY$/i, /_SECRET$/i, /_TOKEN$/i, /_PASSWORD$/i, /_CREDENTIAL$/i];
function isSensitive(key: string): boolean {
return SENSITIVE_PATTERNS.some((p) => p.test(key));
}
/** filter env vars, removing sensitive values (only for public repos) */
function filterEnv(isPublicRepo: boolean): Record<string, string> {
/** filter env vars, removing sensitive values */
function filterEnv(): Record<string, string> {
const filtered: Record<string, string> = {};
for (const [key, value] of Object.entries(process.env)) {
if (value === undefined) continue;
// only filter sensitive vars for public repos
if (isPublicRepo && isSensitive(key)) continue;
if (isSensitive(key)) continue;
filtered[key] = value;
}
// restore original GITHUB_TOKEN (the one set by GitHub Actions, not our installation token)
// this allows git operations in subprocesses to work while keeping our installation token secure
if (process.env.ORIGINAL_GITHUB_TOKEN) {
filtered.GITHUB_TOKEN = process.env.ORIGINAL_GITHUB_TOKEN;
}
return filtered;
}
type SpawnSandboxedParams = {
type SpawnParams = {
command: string;
env: Record<string, string>;
cwd: string;
isPublicRepo: boolean;
stdio: StdioOptions;
};
/**
* spawn command with filtered env. in CI, also use PID namespace isolation
* to prevent child from reading /proc/$PPID/environ (only for public repos)
*/
function spawnSandboxed(params: SpawnSandboxedParams): ChildProcess {
function spawnBash(params: SpawnParams): ChildProcess {
const spawnOpts = { env: params.env, cwd: params.cwd, stdio: params.stdio, detached: true };
// only use PID namespace isolation for public repos in CI
const useNamespaceIsolation = process.env.CI === "true" && params.isPublicRepo;
return useNamespaceIsolation
? spawn("unshare", ["--pid", "--fork", "--mount-proc", "bash", "-c", params.command], spawnOpts)
: spawn("bash", ["-c", params.command], spawnOpts);
// ---- temporarily disable namespace isolation to fix CI ----
// use PID namespace isolation in CI to prevent reading /proc/$PPID/environ
// const useNamespaceIsolation = process.env.CI === "true";
// return useNamespaceIsolation
// ? spawn("unshare", ["--pid", "--fork", "--mount-proc", "bash", "-c", params.command], spawnOpts)
// : spawn("bash", ["-c", params.command], spawnOpts);
return spawn("bash", ["-c", params.command], spawnOpts);
}
/** kill process and its entire process group */
@@ -84,23 +77,20 @@ function getTempDir(): string {
}
export function BashTool(ctx: ToolContext) {
const isPublicRepo = !ctx.repo.repo.private;
return tool({
name: "bash",
description: `Execute shell commands securely.${isPublicRepo ? " Environment is filtered to remove API keys and secrets." : ""}
description: `Execute shell commands securely. Environment is filtered to remove API keys and secrets.
Use this tool to:
- Run shell commands (ls, cat, grep, find, etc.)
- Execute build tools (npm, pnpm, cargo, make, etc.)
- Run tests and linters
- Perform git operations
- Run shell commands in a secure environment. Unlike the built-in bash tool, this tool filters sensitive environment variables from the subprocess's environment to avoid leaking secrets.`,
- Perform git operations`,
parameters: BashParams,
execute: execute(async (params) => {
const timeout = Math.min(params.timeout ?? 120000, 600000);
const cwd = params.working_directory ?? process.cwd();
const env = filterEnv(isPublicRepo);
const env = filterEnv();
if (params.background) {
const tempDir = getTempDir();
@@ -110,11 +100,10 @@ Use this tool to:
const logFd = openSync(outputPath, "a");
let proc: ChildProcess;
try {
proc = spawnSandboxed({
proc = spawnBash({
command: params.command,
env,
cwd,
isPublicRepo,
stdio: ["ignore", logFd, logFd],
});
} finally {
@@ -134,11 +123,10 @@ Use this tool to:
};
}
const proc = spawnSandboxed({
const proc = spawnBash({
command: params.command,
env,
cwd,
isPublicRepo,
stdio: ["ignore", "pipe", "pipe"],
});
@@ -176,9 +164,15 @@ Use this tool to:
? `${output}\n[timed out after ${timeout}ms]`
: `[timed out after ${timeout}ms]`;
const finalExitCode = exitCode ?? (timedOut ? 124 : -1);
if (finalExitCode !== 0) {
log.error(`bash command failed with exit code ${finalExitCode}: ${params.command}`);
if (output) log.error(`output: ${output.trim()}`);
}
return {
output: output.trim(),
exit_code: exitCode ?? (timedOut ? 124 : -1),
exit_code: finalExitCode,
timed_out: timedOut,
};
}),
+203 -52
View File
@@ -1,4 +1,7 @@
import { mkdirSync, writeFileSync } from "node:fs";
import { join } from "node:path";
import { type } from "arktype";
import { log } from "../utils/log.ts";
import type { ToolContext } from "./server.ts";
import { execute, tool } from "./shared.ts";
@@ -6,13 +9,127 @@ export const GetCheckSuiteLogs = type({
check_suite_id: type.number.describe("the id from check_suite.id"),
});
type LogLine = {
line: number;
content: string;
type: "error" | "warning" | "failure" | "trace";
};
type LogAnalysis = {
totalLines: number;
index: LogLine[];
excerpt: {
content: string;
startLine: number;
endLine: number;
};
};
function analyzeLog(logs: string, excerptLines = 80): LogAnalysis {
// biome-ignore lint/suspicious/noControlCharactersInRegex: ANSI escape codes use control chars
const clean = logs.replace(/\x1b\[[0-9;]*m/g, "");
const lines = clean.split("\n");
const totalLines = lines.length;
const index: LogLine[] = [];
const patterns: Array<{ type: LogLine["type"]; pattern: RegExp; skip?: RegExp }> = [
{ type: "error", pattern: /##\[error\]/i },
{ type: "error", pattern: /\bError:/i },
{ type: "error", pattern: /\bERR_/i },
{ type: "error", pattern: /exit code [1-9]/i },
{ type: "warning", pattern: /##\[warning\]/i },
{ type: "warning", pattern: /\bWARN\b/i, skip: /apt|dpkg|Reading package/i },
{ type: "failure", pattern: /\d+ failed/i },
{ type: "failure", pattern: /FAIL\b/i },
{ type: "failure", pattern: /✕|✗|×/ },
{ type: "trace", pattern: /^\s+at\s+/i },
];
for (let i = 0; i < lines.length; i++) {
const line = lines[i];
for (const p of patterns) {
if (p.pattern.test(line)) {
if (p.skip?.test(line)) continue;
// dedupe consecutive traces
if (p.type === "trace" && index.length > 0 && index[index.length - 1].type === "trace") {
continue;
}
// truncate long lines
const truncated = line.length > 120 ? line.slice(0, 117) + "..." : line;
index.push({
line: i + 1,
content: truncated.trim(),
type: p.type,
});
break;
}
}
}
// find excerpt range: focus on LAST ##[error] line
let errorLine = -1;
for (let i = lines.length - 1; i >= 0; i--) {
if (/##\[error\]/i.test(lines[i])) {
errorLine = i;
break;
}
}
let start: number;
let end: number;
if (errorLine === -1) {
start = Math.max(0, totalLines - excerptLines);
end = totalLines;
} else {
const contextAfter = 5;
const contextBefore = excerptLines - contextAfter;
start = Math.max(0, errorLine - contextBefore);
end = Math.min(totalLines, errorLine + contextAfter);
}
return {
totalLines,
index,
excerpt: {
content: lines.slice(start, end).join("\n"),
startLine: start + 1,
endLine: end,
},
};
}
type JobLogResult = {
job_id: number;
job_name: string;
job_url: string;
failed_steps: string[];
log_index: LogLine[];
excerpt: {
start_line: number;
end_line: number;
total_lines: number;
content: string;
};
full_log_path: string;
};
export function GetCheckSuiteLogsTool(ctx: ToolContext) {
return tool({
name: "get_check_suite_logs",
description:
"get workflow run logs for a failed check suite. pass check_suite.id from the webhook payload.",
"get workflow run logs for a failed check suite. returns a log_index of interesting lines, " +
"a curated excerpt, and full_log_path for deeper investigation. " +
"pass check_suite.id from the webhook payload.",
parameters: GetCheckSuiteLogs,
execute: execute(async ({ check_suite_id }) => {
execute: execute(async (params) => {
const check_suite_id = params.check_suite_id;
// get workflow runs for this specific check suite
const workflowRuns = await ctx.octokit.paginate(
ctx.octokit.rest.actions.listWorkflowRunsForRepo,
@@ -30,67 +147,101 @@ export function GetCheckSuiteLogsTool(ctx: ToolContext) {
return {
check_suite_id,
message: "no failed workflow runs found for this check suite",
workflow_runs: [],
failed_jobs: [],
};
}
// setup logs directory
const tempDir = process.env.PULLFROG_TEMP_DIR;
if (!tempDir) {
throw new Error("PULLFROG_TEMP_DIR not set");
}
const logsDir = join(tempDir, "ci-logs");
mkdirSync(logsDir, { recursive: true });
const jobResults: JobLogResult[] = [];
// get logs for each failed run
const logsForRuns = await Promise.all(
failedRuns.map(async (run) => {
const jobs = await ctx.octokit.paginate(ctx.octokit.rest.actions.listJobsForWorkflowRun, {
owner: ctx.repo.owner,
repo: ctx.repo.name,
run_id: run.id,
});
for (const run of failedRuns) {
const jobs = await ctx.octokit.paginate(ctx.octokit.rest.actions.listJobsForWorkflowRun, {
owner: ctx.repo.owner,
repo: ctx.repo.name,
run_id: run.id,
});
const jobLogs = await Promise.all(
jobs.map(async (job) => {
try {
const logsResponse = await ctx.octokit.rest.actions.downloadJobLogsForWorkflowRun({
owner: ctx.repo.owner,
repo: ctx.repo.name,
job_id: job.id,
});
// only process failed jobs
const failedJobs = jobs.filter((job) => job.conclusion === "failure");
const logsUrl = logsResponse.url;
const logsText = await fetch(logsUrl).then((r) => r.text());
for (const job of failedJobs) {
try {
const logsResponse = await ctx.octokit.rest.actions.downloadJobLogsForWorkflowRun({
owner: ctx.repo.owner,
repo: ctx.repo.name,
job_id: job.id,
});
return {
job_id: job.id,
job_name: job.name,
status: job.status,
conclusion: job.conclusion,
started_at: job.started_at,
completed_at: job.completed_at,
logs: logsText,
};
} catch (error) {
return {
job_id: job.id,
job_name: job.name,
status: job.status,
conclusion: job.conclusion,
started_at: job.started_at,
completed_at: job.completed_at,
error: `failed to fetch logs: ${error}`,
};
}
})
);
const logsUrl = logsResponse.url;
const logsText = await fetch(logsUrl).then((r) => r.text());
return {
workflow_run_id: run.id,
workflow_name: run.name,
html_url: run.html_url,
conclusion: run.conclusion,
jobs: jobLogs,
};
})
);
// write full log to disk
const logPath = join(logsDir, `job-${job.id}.log`);
writeFileSync(logPath, logsText);
// analyze log
const analysis = analyzeLog(logsText, 80);
// get failed steps
const failedSteps =
job.steps
?.filter((s) => s.conclusion === "failure")
.map((s) => `Step ${s.number}: ${s.name}`) ?? [];
jobResults.push({
job_id: job.id,
job_name: job.name,
job_url: job.html_url ?? "",
failed_steps: failedSteps,
log_index: analysis.index,
excerpt: {
start_line: analysis.excerpt.startLine,
end_line: analysis.excerpt.endLine,
total_lines: analysis.totalLines,
content: analysis.excerpt.content,
},
full_log_path: logPath,
});
log.debug(`analyzed logs for job ${job.name}: ${analysis.index.length} indexed lines`);
} catch (error) {
log.error(`failed to fetch logs for job ${job.id}: ${error}`);
}
}
}
return {
_instructions: {
overview:
"this result contains CI failure information. use log_index to find interesting lines, then read full_log_path for details.",
fields: {
log_index:
"array of interesting lines (errors, warnings, failures) with line numbers. use these to navigate the full log.",
excerpt:
"a curated ~80 line window around the last error. may not show all failures if they occur in different places.",
full_log_path:
"path to the complete log file. read specific line ranges using the line numbers from log_index.",
failed_steps:
"which CI steps failed. read the workflow yml to understand what commands these steps run.",
},
workflow: [
"1. scan log_index to see where errors/warnings/failures are located",
"2. read excerpt for immediate context",
"3. if excerpt doesn't show what you need, read specific line ranges from full_log_path",
"4. check failed_steps to understand what command failed",
],
},
check_suite_id,
workflow_runs: logsForRuns,
repo: `${ctx.repo.owner}/${ctx.repo.name}`,
failed_jobs: jobResults,
};
}),
});
+36
View File
@@ -0,0 +1,36 @@
import { Octokit } from "@octokit/rest";
import { describe, expect, it } from "vitest";
import { fetchAndFormatPrDiff } from "./checkout.ts";
describe("fetchAndFormatPrDiff", () => {
it("fetches PR files and generates TOC with formatted diff", async () => {
const token = process.env.GH_TOKEN;
if (!token) {
throw new Error("GH_TOKEN not set in .env");
}
const octokit = new Octokit({ auth: token });
const result = await fetchAndFormatPrDiff({
octokit,
owner: "pullfrog",
repo: "scratch",
pullNumber: 49,
});
// verify TOC structure
expect(result.toc).toContain("## Files");
expect(result.toc).toContain("→ lines");
// verify content includes TOC at the start
expect(result.content.startsWith(result.toc)).toBe(true);
// verify content includes diff headers
expect(result.content).toContain("diff --git");
expect(result.content).toContain("---");
expect(result.content).toContain("+++");
// snapshot the full output
expect(result.toc).toMatchSnapshot("toc");
expect(result.content).toMatchSnapshot("content");
});
});
+70 -9
View File
@@ -9,23 +9,43 @@ import { execute, tool } from "./shared.ts";
type PullFile = RestEndpointMethodTypes["pulls"]["listFiles"]["response"]["data"][number];
export type FormatFilesResult = {
content: string;
toc: string;
};
/**
* formats PR files with explicit line numbers for each code line.
* preserves all original diff info (file headers, hunk headers) and adds:
* | OLD | NEW | TYPE | code
* returns both the formatted content and a TOC with line ranges per file.
*/
export function formatFilesWithLineNumbers(files: PullFile[]): string {
export function formatFilesWithLineNumbers(files: PullFile[]): FormatFilesResult {
const output: string[] = [];
const tocEntries: Array<{ filename: string; startLine: number; endLine: number }> = [];
// calculate TOC header size: "## Files (N)\n" + N entries + "\n---\n\n"
const tocHeaderSize = 1 + files.length + 2;
let currentLine = tocHeaderSize + 1;
for (const file of files) {
const fileStartLine = currentLine;
// file header
output.push(`diff --git a/${file.filename} b/${file.filename}`);
output.push(`--- a/${file.filename}`);
output.push(`+++ b/${file.filename}`);
currentLine += 3;
if (!file.patch) {
output.push("(binary file or no changes)");
output.push("");
currentLine += 2;
tocEntries.push({
filename: file.filename,
startLine: fileStartLine,
endLine: currentLine - 1,
});
continue;
}
@@ -41,6 +61,7 @@ export function formatFilesWithLineNumbers(files: PullFile[]): string {
oldLine = parseInt(hunkMatch[1], 10);
newLine = parseInt(hunkMatch[2], 10);
output.push(line); // pass through unchanged
currentLine++;
continue;
}
@@ -69,11 +90,31 @@ export function formatFilesWithLineNumbers(files: PullFile[]): string {
// unknown line type, pass through
output.push(line);
}
currentLine++;
}
output.push(""); // blank line between files
currentLine++;
tocEntries.push({
filename: file.filename,
startLine: fileStartLine,
endLine: currentLine - 1,
});
}
return output.join("\n");
// build TOC
const tocLines = [`## Files (${files.length})`];
for (const entry of tocEntries) {
tocLines.push(`- ${entry.filename} → lines ${entry.startLine}-${entry.endLine}`);
}
tocLines.push("");
tocLines.push("---");
tocLines.push("");
const toc = tocLines.join("\n");
const content = toc + output.join("\n");
return { content, toc };
}
function padNum(n: number): string {
@@ -97,6 +138,27 @@ export type CheckoutPrResult = {
diffPath: string;
};
type FetchPrDiffParams = {
octokit: Octokit;
owner: string;
repo: string;
pullNumber: number;
};
/**
* fetches PR files from GitHub and formats them with line numbers and TOC.
* this is the core diff formatting logic, extracted for testability.
*/
export async function fetchAndFormatPrDiff(params: FetchPrDiffParams): Promise<FormatFilesResult> {
const filesResponse = await params.octokit.rest.pulls.listFiles({
owner: params.owner,
repo: params.repo,
pull_number: params.pullNumber,
per_page: 100,
});
return formatFilesWithLineNumbers(filesResponse.data);
}
interface CheckoutPrBranchParams {
octokit: Octokit;
owner: string;
@@ -243,14 +305,13 @@ export function CheckoutPrTool(ctx: ToolContext) {
}
// fetch PR files and format with line numbers
const filesResponse = await ctx.octokit.rest.pulls.listFiles({
const formatResult = await fetchAndFormatPrDiff({
octokit: ctx.octokit,
owner: ctx.repo.owner,
repo: ctx.repo.name,
pull_number,
per_page: 100,
pullNumber: pull_number,
});
const diffContent = formatFilesWithLineNumbers(filesResponse.data);
const diffPreview = diffContent.split("\n").slice(0, 100).join("\n");
const diffPreview = formatResult.content.split("\n").slice(0, 100).join("\n");
log.debug(`formatted diff preview (first 100 lines):\n${diffPreview}`);
const tempDir = process.env.PULLFROG_TEMP_DIR;
if (!tempDir) {
@@ -259,8 +320,8 @@ export function CheckoutPrTool(ctx: ToolContext) {
);
}
const diffPath = join(tempDir, `pr-${pull_number}.diff`);
writeFileSync(diffPath, diffContent);
log.debug(`wrote diff to ${diffPath} (${diffContent.length} bytes)`);
writeFileSync(diffPath, formatResult.content);
log.debug(`wrote diff to ${diffPath} (${formatResult.content.length} bytes)`);
return {
success: true,
+17 -112
View File
@@ -1,10 +1,8 @@
import { type } from "arktype";
import type { Agent } from "../agents/index.ts";
import { buildPullfrogFooter, stripExistingFooter } from "../utils/buildPullfrogFooter.ts";
import { createOctokit, type OctokitWithPlugins, parseRepoContext } from "../utils/github.ts";
import { getGitHubInstallationToken } from "../utils/token.ts";
import { fetchWorkflowRunInfo } from "../utils/workflowRun.ts";
import type { ToolContext, ToolState } from "./server.ts";
import { type OctokitWithPlugins, parseRepoContext } from "../utils/github.ts";
import type { ToolContext } from "./server.ts";
import { execute, tool } from "./shared.ts";
/**
@@ -66,9 +64,6 @@ async function buildCommentFooter({
return buildPullfrogFooter(footerParams);
}
const SUGGESTION_FORMAT_DESCRIPTION =
"when suggesting code changes, use GitHub's suggestion format with ```suggestion blocks to enable one-click apply (e.g., 'you could do this\\n```suggestion\\nsuggested code here\\n```'). note: suggestions only work on pull request line-level review comments, not on issue/PR-level comments.";
function buildImplementPlanLink(
owner: string,
repo: string,
@@ -79,12 +74,12 @@ function buildImplementPlanLink(
return `[Implement plan ➔](${apiUrl}/trigger/${owner}/${repo}/${issueNumber}?action=implement&comment_id=${commentId})`;
}
interface AddFooterCtx {
export interface AddFooterCtx {
agent?: Agent | undefined;
octokit?: OctokitWithPlugins | undefined;
}
async function addFooter(ctx: AddFooterCtx, body: string): Promise<string> {
export async function addFooter(ctx: AddFooterCtx, body: string): Promise<string> {
const bodyWithoutFooter = stripExistingFooter(body);
const footer = await buildCommentFooter({ agent: ctx.agent, octokit: ctx.octokit });
return `${bodyWithoutFooter}${footer}`;
@@ -92,7 +87,7 @@ async function addFooter(ctx: AddFooterCtx, body: string): Promise<string> {
export const Comment = type({
issueNumber: type.number.describe("the issue number to comment on"),
body: type.string.describe(`the comment body content. ${SUGGESTION_FORMAT_DESCRIPTION}`),
body: type.string.describe("the comment body content"),
});
export function CreateCommentTool(ctx: ToolContext) {
@@ -123,7 +118,7 @@ export function CreateCommentTool(ctx: ToolContext) {
export const EditComment = type({
commentId: type.number.describe("the ID of the comment to edit"),
body: type.string.describe(`the new comment body content. ${SUGGESTION_FORMAT_DESCRIPTION}`),
body: type.string.describe("the new comment body content"),
});
export function EditCommentTool(ctx: ToolContext) {
@@ -174,7 +169,7 @@ export async function reportProgress(
// always track the body for job summary
ctx.toolState.lastProgressBody = body;
const existingCommentId = ctx.toolState.progressComment.id;
const existingCommentId = ctx.toolState.progressCommentId;
const issueNumber =
ctx.toolState.prNumber ?? ctx.toolState.issueNumber ?? ctx.payload.event.issue_number;
const isPlanMode = ctx.toolState.selectedMode === "Plan";
@@ -201,7 +196,7 @@ export async function reportProgress(
body: bodyWithFooter,
});
ctx.toolState.progressComment.wasUpdated = true;
ctx.toolState.wasUpdated = true;
return {
commentId: result.data.id,
@@ -230,10 +225,8 @@ export async function reportProgress(
});
// store the comment ID for future updates
ctx.toolState.progressComment = {
id: result.data.id,
wasUpdated: true,
};
ctx.toolState.progressCommentId = result.data.id;
ctx.toolState.wasUpdated = true;
// if Plan mode, update the comment to add the "Implement plan" link
if (isPlanMode) {
@@ -302,7 +295,7 @@ export function ReportProgressTool(ctx: ToolContext) {
* Used after submitting a PR review since the review body contains all necessary info.
*/
export async function deleteProgressComment(ctx: ToolContext): Promise<boolean> {
const existingCommentId = ctx.toolState.progressComment.id;
const existingCommentId = ctx.toolState.progressCommentId;
if (!existingCommentId) {
return false;
}
@@ -322,106 +315,18 @@ export async function deleteProgressComment(ctx: ToolContext): Promise<boolean>
}
}
// reset state but mark as "updated" so ensureProgressCommentUpdated doesn't try to handle it
ctx.toolState.progressComment = {
id: null,
wasUpdated: true,
};
// reset state and mark as updated so post script doesn't try to handle it
ctx.toolState.progressCommentId = null;
ctx.toolState.wasUpdated = true;
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 toolState.
*/
export async function ensureProgressCommentUpdated(toolState: ToolState): Promise<void> {
// skip if comment was already updated during execution
if (toolState.progressComment.wasUpdated) {
return;
}
// skip if there's already a progress body recorded (agent called report_progress)
if (toolState.lastProgressBody) {
return;
}
// try to get comment ID from toolState first, then from database if needed
let existingCommentId = toolState.progressComment.id;
// if not in toolState, 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);
if (Number.isNaN(existingCommentId)) {
existingCommentId = null;
}
}
} 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 octokit = createOctokit(getGitHubInstallationToken());
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;
}
const runId = process.env.GITHUB_RUN_ID;
const workflowRunLink = runId
? `[workflow run logs](https://github.com/${repoContext.owner}/${repoContext.name}/actions/runs/${runId})`
: "workflow run logs";
const errorMessage = `This run croaked 😵
The workflow encountered an error before any progress could be reported. Please check the ${workflowRunLink} for details.`;
// add footer without agent info (we don't have context here)
const body = await addFooter({ octokit }, errorMessage);
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(
`extremely brief reply (1 sentence max) explaining what was fixed, e.g. 'Fixed by renaming to X' or 'Added null check'. ${SUGGESTION_FORMAT_DESCRIPTION}`
"extremely brief reply (1 sentence max) explaining what was fixed, e.g. 'Fixed by renaming to X' or 'Added null check'"
),
});
@@ -442,8 +347,8 @@ export function ReplyToReviewCommentTool(ctx: ToolContext) {
body: bodyWithFooter,
});
// mark progress as updated so ensureProgressCommentUpdated doesn't think the run failed
ctx.toolState.progressComment.wasUpdated = true;
// mark progress as updated so post script doesn't think the run failed
ctx.toolState.wasUpdated = true;
return {
success: true,
+60
View File
@@ -0,0 +1,60 @@
import { writeFileSync } from "node:fs";
import { join } from "node:path";
import { type } from "arktype";
import { log } from "../utils/cli.ts";
import { formatFilesWithLineNumbers } from "./checkout.ts";
import type { ToolContext } from "./server.ts";
import { execute, tool } from "./shared.ts";
export const CommitInfo = type({
sha: type.string.describe("the commit SHA (full or abbreviated) to fetch"),
});
export function CommitInfoTool(ctx: ToolContext) {
return tool({
name: "get_commit_info",
description:
"Retrieve commit metadata and diff via GitHub API. Use this instead of git show for reviewing commits - " +
"it works with shallow clones and shows the actual changes in the commit. Returns diffPath pointing to formatted diff file.",
parameters: CommitInfo,
execute: execute(async ({ sha }) => {
const response = await ctx.octokit.rest.repos.getCommit({
owner: ctx.repo.owner,
repo: ctx.repo.name,
ref: sha,
});
const data = response.data;
const files = data.files ?? [];
// format diff with line numbers and write to file
const formatResult = formatFilesWithLineNumbers(files);
const tempDir = process.env.PULLFROG_TEMP_DIR;
if (!tempDir) {
throw new Error(
"PULLFROG_TEMP_DIR not set - get_commit_info must run in pullfrog action context"
);
}
const diffFile = join(tempDir, `commit-${sha.slice(0, 7)}.diff`);
writeFileSync(diffFile, formatResult.content);
log.debug(`wrote commit diff to ${diffFile} (${formatResult.content.length} bytes)`);
return {
sha: data.sha,
message: data.commit.message,
author: data.author?.login ?? null,
committer: data.committer?.login ?? null,
date: data.commit.author?.date ?? data.commit.committer?.date ?? "",
url: data.html_url,
parents: data.parents.map((p) => p.sha),
stats: {
additions: data.stats?.additions ?? 0,
deletions: data.stats?.deletions ?? 0,
total: data.stats?.total ?? 0,
},
fileCount: files.length,
diffFile,
};
}),
});
}
-23
View File
@@ -1,23 +0,0 @@
import { type } from "arktype";
import type { ToolContext } from "./server.ts";
import { $ } from "../utils/shell.ts";
import { execute, tool } from "./shared.ts";
export const DebugShellCommand = type({});
export function DebugShellCommandTool(_ctx: ToolContext) {
return tool({
name: "debug_shell_command",
description:
"debug tool: runs 'git status' and returns the output. use this to test shell command execution in the MCP server.",
parameters: DebugShellCommand,
execute: execute(async () => {
const result = $("git", ["status"]);
return {
success: true,
command: "git status",
output: result.trim(),
};
}),
});
}
+1 -1
View File
@@ -1,7 +1,7 @@
import { type } from "arktype";
import type { ToolContext } from "./server.ts";
import type { PrepResult } from "../prep/index.ts";
import { runPrepPhase } from "../prep/index.ts";
import type { ToolContext } from "./server.ts";
import { execute, tool } from "./shared.ts";
// empty schema for tools with no parameters
+15 -5
View File
@@ -1,12 +1,12 @@
import { type } from "arktype";
import type { ToolContext } from "./server.ts";
import { log } from "../utils/cli.ts";
import { containsSecrets } from "../utils/secrets.ts";
import { $ } from "../utils/shell.ts";
import type { ToolContext } from "./server.ts";
import { execute, tool } from "./shared.ts";
export function CreateBranchTool(ctx: ToolContext) {
const defaultBranch = ctx.repo.repo.default_branch || "main";
const defaultBranch = ctx.repo.data.default_branch || "main";
const CreateBranch = type({
branchName: type.string.describe(
@@ -24,7 +24,7 @@ export function CreateBranchTool(ctx: ToolContext) {
parameters: CreateBranch,
execute: execute(async ({ branchName, baseBranch }) => {
// baseBranch should always be defined due to default, but TypeScript needs help
const resolvedBaseBranch = baseBranch || ctx.repo.repo.default_branch || "main";
const resolvedBaseBranch = baseBranch || ctx.repo.data.default_branch || "main";
// validate branch name for secrets
if (containsSecrets(branchName)) {
@@ -141,11 +141,13 @@ export const PushBranch = type({
force: type.boolean.describe("Force push (use with caution)").default(false),
});
export function PushBranchTool(_ctx: ToolContext) {
export function PushBranchTool(ctx: ToolContext) {
const defaultBranch = ctx.repo.data.default_branch || "main";
return tool({
name: "push_branch",
description:
"Push the current branch (or specified branch) to the remote repository. Git automatically determines the correct remote based on branch config (set by checkout_pr for fork PRs). Never force push unless explicitly requested.",
"Push the current branch (or specified branch) to the remote repository. Git automatically determines the correct remote based on branch config (set by checkout_pr for fork PRs). Never force push unless explicitly requested. Pushes to the default branch are blocked.",
parameters: PushBranch,
execute: execute(async ({ branchName, force }) => {
const branch = branchName || $("git", ["rev-parse", "--abbrev-ref", "HEAD"], { log: false });
@@ -168,6 +170,14 @@ export function PushBranchTool(_ctx: ToolContext) {
// no configured merge ref, use local branch name
}
// block pushes to default branch
if (remoteBranch === defaultBranch) {
throw new Error(
`Push blocked: cannot push directly to default branch '${remoteBranch}'. ` +
`Create a feature branch and open a PR instead.`
);
}
// use refspec when local and remote branch names differ
const refspec = branch === remoteBranch ? branch : `${branch}:${remoteBranch}`;
const args = force
-1
View File
@@ -28,7 +28,6 @@ export function GetIssueCommentsTool(ctx: ToolContext) {
id: comment.id,
body: comment.body,
user: comment.user?.login,
author_association: comment.author_association,
})),
count: comments.length,
};
+42 -9
View File
@@ -2,6 +2,26 @@ import { type } from "arktype";
import type { ToolContext } from "./server.ts";
import { execute, tool } from "./shared.ts";
const CLOSING_ISSUES_QUERY = `
query($owner: String!, $repo: String!, $number: Int!) {
repository(owner: $owner, name: $repo) {
pullRequest(number: $number) {
closingIssuesReferences(first: 10) {
nodes { number title }
}
}
}
}
`;
type ClosingIssuesResponse = {
repository: {
pullRequest: {
closingIssuesReferences: { nodes: Array<{ number: number; title: string }> };
};
};
};
export const PullRequestInfo = type({
pull_number: type.number.describe("The pull request number to fetch"),
});
@@ -10,30 +30,43 @@ export function PullRequestInfoTool(ctx: ToolContext) {
return tool({
name: "get_pull_request",
description:
"Retrieve PR metadata (number, title, state, base/head branches, fork status). To checkout a PR branch locally, use checkout_pr instead.",
"Retrieve PR metadata (title, body, state, branches, author, labels, linked issues). To checkout a PR branch locally, use checkout_pr instead.",
parameters: PullRequestInfo,
execute: execute(async ({ pull_number }) => {
const pr = await ctx.octokit.rest.pulls.get({
owner: ctx.repo.owner,
repo: ctx.repo.name,
pull_number,
});
// fetch REST and GraphQL in parallel
const [restResponse, graphqlResponse] = await Promise.all([
ctx.octokit.rest.pulls.get({
owner: ctx.repo.owner,
repo: ctx.repo.name,
pull_number,
}),
ctx.octokit.graphql<ClosingIssuesResponse>(CLOSING_ISSUES_QUERY, {
owner: ctx.repo.owner,
repo: ctx.repo.name,
number: pull_number,
}),
]);
const data = pr.data;
// detect fork PRs - head repo differs from base repo (head.repo can be null if fork was deleted)
const data = restResponse.data;
const isFork = data.head.repo?.full_name !== data.base.repo.full_name;
const closingIssues = graphqlResponse.repository.pullRequest.closingIssuesReferences.nodes;
return {
number: data.number,
url: data.html_url,
title: data.title,
body: data.body,
state: data.state,
draft: data.draft,
merged: data.merged,
maintainerCanModify: data.maintainer_can_modify,
base: data.base.ref,
head: data.head.ref,
isFork,
author: data.user?.login,
assignees: data.assignees?.map((a) => a.login),
labels: data.labels.map((l) => l.name),
closingIssues: closingIssues.map((i) => ({ number: i.number, title: i.title })),
};
}),
});
+57
View File
@@ -0,0 +1,57 @@
import { Octokit } from "@octokit/rest";
import { describe, expect, it } from "vitest";
import {
buildThreadBlocks,
formatReviewThreads,
type ParsedHunk,
parseFilePatches,
REVIEW_THREADS_QUERY,
type ReviewThread,
type ReviewThreadsQueryResponse,
} from "./reviewComments.ts";
describe("formatReviewThreads", () => {
it("formats thread blocks with TOC and correct line numbers", async () => {
const token = process.env.GH_TOKEN;
if (!token) {
throw new Error("GH_TOKEN is not set");
}
const octokit = new Octokit({ auth: token });
const pullNumber = 49;
const reviewId = 3485940013;
// fetch review threads via GraphQL
const response = await octokit.graphql<ReviewThreadsQueryResponse>(REVIEW_THREADS_QUERY, {
owner: "pullfrog",
name: "scratch",
prNumber: pullNumber,
});
const allThreads = response.repository?.pullRequest?.reviewThreads?.nodes ?? [];
const threadsForReview = allThreads.filter((thread): thread is ReviewThread => {
if (!thread?.comments?.nodes) return false;
return thread.comments.nodes.some((c) => c?.pullRequestReview?.databaseId === reviewId);
});
// fetch file patches
const prFilesResponse = await octokit.rest.pulls.listFiles({
owner: "pullfrog",
repo: "scratch",
pull_number: pullNumber,
});
const filePatchMap = new Map<string, ParsedHunk[]>();
for (const file of prFilesResponse.data) {
if (file.patch) {
filePatchMap.set(file.filename, parseFilePatches(file.patch));
}
}
// build and format
const { threadBlocks, reviewer } = buildThreadBlocks(threadsForReview, filePatchMap, reviewId);
const result = formatReviewThreads(threadBlocks, { pullNumber, reviewId, reviewer });
expect(result.toc).toMatchSnapshot("toc");
expect(result.content).toMatchSnapshot("content");
});
});
+502 -63
View File
@@ -5,102 +5,540 @@ import { log } from "../utils/log.ts";
import type { ToolContext } from "./server.ts";
import { execute, tool } from "./shared.ts";
// GraphQL query to fetch all review threads for a PR with full comment history
export const REVIEW_THREADS_QUERY = `
query ($owner: String!, $name: String!, $prNumber: Int!) {
repository(owner: $owner, name: $name) {
pullRequest(number: $prNumber) {
reviewThreads(first: 100) {
nodes {
id
path
line
startLine
diffSide
isResolved
isOutdated
comments(first: 50) {
nodes {
fullDatabaseId
body
createdAt
diffHunk
line
startLine
originalLine
originalStartLine
author { login }
pullRequestReview {
databaseId
author { login }
}
reactionGroups {
content
reactors(first: 10) {
nodes {
... on Actor { login }
}
}
}
}
}
}
}
}
}
}
`;
export type ReviewThreadComment = {
fullDatabaseId: string | null;
body: string;
createdAt: string;
diffHunk: string;
line: number | null;
startLine: number | null;
originalLine: number | null;
originalStartLine: number | null;
author: { login: string } | null;
pullRequestReview: {
databaseId: number | null;
author: { login: string } | null;
} | null;
reactionGroups: Array<{
content: string;
reactors: { nodes: Array<{ login: string } | null> | null } | null;
}> | null;
};
export type ReviewThread = {
id: string;
path: string;
line: number | null;
startLine: number | null;
diffSide: "LEFT" | "RIGHT";
isResolved: boolean;
isOutdated: boolean;
comments: {
nodes: (ReviewThreadComment | null)[] | null;
} | null;
};
export type ReviewThreadsQueryResponse = {
repository: {
pullRequest: {
reviewThreads: {
nodes: (ReviewThread | null)[] | null;
} | null;
} | null;
} | null;
};
// extract exactly the commented line range from diffHunk, plus context
const CONTEXT_PADDING = 3;
function extractCommentedLines(
diffHunk: string,
startLine: number | null,
endLine: number | null,
side: "LEFT" | "RIGHT"
): string {
const lines = diffHunk.split("\n");
if (lines.length <= 1) return diffHunk;
const header = lines[0];
const contentLines = lines.slice(1);
// parse header: @@ -old_start,old_count +new_start,new_count @@
const headerMatch = header.match(/@@ -(\d+)(?:,\d+)? \+(\d+)(?:,\d+)? @@/);
if (!headerMatch) return diffHunk;
const hunkOldStart = parseInt(headerMatch[1], 10);
const hunkNewStart = parseInt(headerMatch[2], 10);
// LEFT = old file (deletions), RIGHT = new file (additions)
const hunkStart = side === "LEFT" ? hunkOldStart : hunkNewStart;
const commentStart = startLine ?? endLine ?? hunkStart;
const commentEnd = endLine ?? commentStart;
// walk through diff lines, tracking line numbers for both old and new files
// - lines: old file only (LEFT)
// + lines: new file only (RIGHT)
// context lines: both files
type DiffLine = { text: string; lineNum: number | null };
const diffLines: DiffLine[] = [];
let oldLineNum = hunkOldStart;
let newLineNum = hunkNewStart;
for (const line of contentLines) {
const prefix = line[0];
if (prefix === "-") {
// deletion - only has old line number
diffLines.push({ text: line, lineNum: side === "LEFT" ? oldLineNum : null });
oldLineNum++;
} else if (prefix === "+") {
// addition - only has new line number
diffLines.push({ text: line, lineNum: side === "RIGHT" ? newLineNum : null });
newLineNum++;
} else {
// context - has both line numbers
diffLines.push({ text: line, lineNum: side === "LEFT" ? oldLineNum : newLineNum });
oldLineNum++;
newLineNum++;
}
}
// find lines for comment range with context
const targetStart = commentStart - CONTEXT_PADDING;
const targetEnd = commentEnd;
const result: string[] = [];
let truncatedBefore = 0;
for (let i = 0; i < diffLines.length; i++) {
const dl = diffLines[i];
// include if: within target range, OR it's an "other side" line adjacent to included lines
const inRange = dl.lineNum !== null && dl.lineNum >= targetStart && dl.lineNum <= targetEnd;
// include opposite-side lines if they're between included lines
const adjacentOtherSide = dl.lineNum === null && result.length > 0 && i < diffLines.length - 1;
if (inRange || adjacentOtherSide) {
result.push(dl.text);
} else if (result.length === 0) {
truncatedBefore++;
}
}
if (truncatedBefore > 0) {
return `${header}\n... (${truncatedBefore} lines above) ...\n${result.join("\n")}`;
}
return `${header}\n${result.join("\n")}`;
}
// parsed hunk from a unified diff
export type ParsedHunk = {
header: string;
oldStart: number;
oldCount: number;
newStart: number;
newCount: number;
content: string[];
};
// parse a full file patch into individual hunks
export function parseFilePatches(patch: string): ParsedHunk[] {
const hunks: ParsedHunk[] = [];
const lines = patch.split("\n");
let currentHunk: ParsedHunk | null = null;
for (const line of lines) {
const hunkMatch = line.match(/@@ -(\d+)(?:,(\d+))? \+(\d+)(?:,(\d+))? @@/);
if (hunkMatch) {
if (currentHunk) hunks.push(currentHunk);
currentHunk = {
header: line,
oldStart: parseInt(hunkMatch[1], 10),
oldCount: parseInt(hunkMatch[2] ?? "1", 10),
newStart: parseInt(hunkMatch[3], 10),
newCount: parseInt(hunkMatch[4] ?? "1", 10),
content: [],
};
} else if (currentHunk) {
currentHunk.content.push(line);
}
}
if (currentHunk) hunks.push(currentHunk);
return hunks;
}
// find hunks that overlap with a line range (for LEFT or RIGHT side)
function findOverlappingHunks(
hunks: ParsedHunk[],
startLine: number,
endLine: number,
side: "LEFT" | "RIGHT"
): ParsedHunk[] {
return hunks.filter((hunk) => {
const hunkStart = side === "LEFT" ? hunk.oldStart : hunk.newStart;
const hunkCount = side === "LEFT" ? hunk.oldCount : hunk.newCount;
const hunkEnd = hunkStart + hunkCount - 1;
// check for overlap: ranges overlap if start1 <= end2 && start2 <= end1
return startLine <= hunkEnd && hunkStart <= endLine;
});
}
// extract diff content from multiple hunks for a comment range
function extractFromFilePatches(
hunks: ParsedHunk[],
startLine: number,
endLine: number,
side: "LEFT" | "RIGHT"
): string {
const overlapping = findOverlappingHunks(hunks, startLine, endLine, side);
if (overlapping.length === 0) {
return `(no diff hunks found for lines ${startLine}-${endLine})`;
}
if (overlapping.length === 1) {
// single hunk - use existing extraction logic
const hunk = overlapping[0];
const fullHunk = hunk.header + "\n" + hunk.content.join("\n");
return extractCommentedLines(fullHunk, startLine, endLine, side);
}
// multiple hunks - combine them with gap indicators
const result: string[] = [];
let prevHunkEnd = 0;
for (let i = 0; i < overlapping.length; i++) {
const hunk = overlapping[i];
const hunkStart = side === "LEFT" ? hunk.oldStart : hunk.newStart;
const hunkCount = side === "LEFT" ? hunk.oldCount : hunk.newCount;
const hunkEnd = hunkStart + hunkCount - 1;
// add gap indicator if there's a gap between hunks
if (i > 0 && hunkStart > prevHunkEnd + 1) {
const gapSize = hunkStart - prevHunkEnd - 1;
result.push(`\n... (${gapSize} unchanged lines) ...\n`);
}
// add the hunk header and content
result.push(hunk.header);
result.push(...hunk.content);
prevHunkEnd = hunkEnd;
}
return result.join("\n");
}
export const GetReviewComments = type({
pull_number: type.number.describe("The pull request number"),
review_id: type.number.describe("The review ID to get comments for"),
approved_by: type.string
.describe("Optional GitHub username - only return comments this user gave a 👍 to")
.describe(
"Optional GitHub username - only return threads where this user gave a 👍 to at least one comment"
)
.optional(),
});
function hasThumbsUpFrom(comment: ReviewThreadComment, username: string): boolean {
if (!comment.reactionGroups) return false;
const thumbsUp = comment.reactionGroups.find((g) => g.content === "THUMBS_UP");
if (!thumbsUp?.reactors?.nodes) return false;
return thumbsUp.reactors.nodes.some((r) => r?.login === username);
}
function threadHasThumbsUpFrom(thread: ReviewThread, username: string): boolean {
const comments = thread.comments?.nodes ?? [];
return comments.some((c) => c && hasThumbsUpFrom(c, username));
}
/**
* formats thread blocks into markdown with TOC and line numbers.
* extracted for testability.
*/
export function formatReviewThreads(
threadBlocks: Array<{ path: string; lineRange: string; content: string[] }>,
header: { pullNumber: number; reviewId: number; reviewer: string }
) {
// header section takes: title (1) + blank (1) + "## TOC" (1) + blank (1) + N TOC entries + blank (1) + "---" (1) + blank (1)
const tocHeaderLines = 4;
const tocFooterLines = 3;
let currentLine = tocHeaderLines + threadBlocks.length + tocFooterLines + 1;
const tocEntries: string[] = [];
const threadLines: string[] = [];
for (const block of threadBlocks) {
const startLine = currentLine;
const actualLineCount = block.content.reduce((sum, line) => sum + line.split("\n").length, 0);
const endLine = currentLine + actualLineCount - 1;
tocEntries.push(`- ${block.path}:${block.lineRange} → lines ${startLine}-${endLine}`);
threadLines.push(...block.content);
currentLine += actualLineCount;
}
const lines: string[] = [];
lines.push(
`# Review Threads (${threadBlocks.length}) for PR #${header.pullNumber} - Review ${header.reviewId} by ${header.reviewer}`
);
lines.push("");
lines.push("## TOC");
lines.push("");
lines.push(...tocEntries);
lines.push("");
lines.push("---");
lines.push("");
lines.push(...threadLines);
return {
toc: tocEntries.join("\n"),
content: lines.join("\n"),
};
}
/**
* builds thread blocks from review threads and file patches.
* extracted for testability.
*/
export function buildThreadBlocks(
threads: ReviewThread[],
filePatchMap: Map<string, ParsedHunk[]>,
reviewId: number
) {
// get reviewer from first matching comment
const firstMatchingComment = threads[0]?.comments?.nodes?.find(
(c) => c?.pullRequestReview?.databaseId === reviewId
);
const reviewer = firstMatchingComment?.pullRequestReview?.author?.login ?? "unknown";
// sort threads by file path, then by line number
threads.sort((a, b) => {
const pathCmp = a.path.localeCompare(b.path);
if (pathCmp !== 0) return pathCmp;
const aLine = a.startLine ?? a.line ?? 0;
const bLine = b.startLine ?? b.line ?? 0;
return aLine - bLine;
});
const threadBlocks: Array<{ path: string; lineRange: string; content: string[] }> = [];
for (const thread of threads) {
const allComments = (thread.comments?.nodes ?? []).filter(
(c): c is ReviewThreadComment => c !== null
);
if (allComments.length === 0) continue;
// get line info from thread, or fall back to first comment's line info
const firstComment = allComments[0];
const line =
thread.line ?? firstComment?.line ?? firstComment?.originalLine ?? thread.startLine ?? 0;
const startLine =
thread.startLine ?? firstComment?.startLine ?? firstComment?.originalStartLine ?? line;
const lineRange = startLine === line ? `${line}` : `${startLine}-${line}`;
const block: string[] = [];
// header with file:line range and status
const status = thread.isResolved ? " [RESOLVED]" : thread.isOutdated ? " [OUTDATED]" : "";
block.push(`## ${thread.path}:${lineRange}${status}`);
block.push("");
// show all comments in the thread (full conversation history)
for (const comment of allComments) {
const author = comment.author?.login ?? "unknown";
const isTargetReview = comment.pullRequestReview?.databaseId === reviewId;
const marker = isTargetReview ? " *" : "";
block.push(
`\`\`\`\`comment author=${author} id=${comment.fullDatabaseId ?? "unknown"} review=${comment.pullRequestReview?.databaseId ?? "unknown"}${marker}`
);
block.push(comment.body || "(no comment body)");
block.push("````");
block.push("");
}
// diff context
const fileHunks = filePatchMap.get(thread.path);
const firstCommentWithHunk = allComments.find((c) => c.diffHunk);
let diffContent: string | null = null;
if (fileHunks && fileHunks.length > 0) {
const overlapping = findOverlappingHunks(fileHunks, startLine, line, thread.diffSide);
if (overlapping.length > 0) {
diffContent = extractFromFilePatches(fileHunks, startLine, line, thread.diffSide);
}
}
if (!diffContent && firstCommentWithHunk) {
diffContent = extractCommentedLines(
firstCommentWithHunk.diffHunk,
startLine,
line,
thread.diffSide
);
}
if (diffContent) {
block.push(`\`\`\`diff file=${thread.path} lines=${lineRange} side=${thread.diffSide}`);
block.push(diffContent);
block.push("```");
block.push("");
} else {
block.push(`\`\`\`diff file=${thread.path} lines=${lineRange} side=${thread.diffSide}`);
block.push(`(no diff context available - comment on unchanged lines)`);
block.push("```");
block.push("");
}
threadBlocks.push({ path: thread.path, lineRange, content: block });
}
return { threadBlocks, reviewer };
}
export function GetReviewCommentsTool(ctx: ToolContext) {
return tool({
name: "get_review_comments",
description:
"Get review comments for a pull request review, including diff context. " +
"When approved_by is provided, only returns comments that user approved with 👍. " +
"Returns commentsPath pointing to a file with full comment details.",
"Get review comments for a pull request review with full thread context. " +
"When approved_by is provided, only returns threads where that user gave a 👍 to at least one comment. " +
"Returns a TOC and commentsPath pointing to a markdown file with full comment details.",
parameters: GetReviewComments,
execute: execute(async ({ pull_number, review_id, approved_by }) => {
// fetch all review comments via REST API (includes diff_hunk)
const allComments = await ctx.octokit.paginate(ctx.octokit.rest.pulls.listReviewComments, {
execute: execute(async (params) => {
// fetch all review threads for the PR via GraphQL
const response = await ctx.octokit.graphql<ReviewThreadsQueryResponse>(REVIEW_THREADS_QUERY, {
owner: ctx.repo.owner,
repo: ctx.repo.name,
pull_number,
name: ctx.repo.name,
prNumber: params.pull_number,
});
// filter to target review
let reviewComments = allComments.filter((c) => c.pull_request_review_id === review_id);
const allThreads = response.repository?.pullRequest?.reviewThreads?.nodes ?? [];
// filter by thumbs up if approved_by is specified
if (approved_by) {
const approvedIds = new Set<number>();
for (const comment of reviewComments) {
const reactions = await ctx.octokit.rest.reactions.listForPullRequestReviewComment({
owner: ctx.repo.owner,
repo: ctx.repo.name,
comment_id: comment.id,
});
const hasThumbsUp = reactions.data.some(
(r) => r.content === "+1" && r.user?.login === approved_by
);
if (hasThumbsUp) approvedIds.add(comment.id);
}
reviewComments = reviewComments.filter((c) => approvedIds.has(c.id));
// filter to threads where at least one comment belongs to the target review
let threadsForReview = allThreads.filter((thread): thread is ReviewThread => {
if (!thread?.comments?.nodes) return false;
return thread.comments.nodes.some(
(c) => c?.pullRequestReview?.databaseId === params.review_id
);
});
// filter by approved_by if specified
if (params.approved_by) {
threadsForReview = threadsForReview.filter((thread) =>
threadHasThumbsUpFrom(thread, params.approved_by as string)
);
}
if (reviewComments.length === 0) {
if (threadsForReview.length === 0) {
return {
review_id,
pull_number,
count: 0,
review_id: params.review_id,
pull_number: params.pull_number,
reviewer: "unknown",
threadCount: 0,
commentsPath: null,
message: approved_by
? `No comments with 👍 from ${approved_by}`
: "No comments found for this review",
toc: null,
instructions: params.approved_by
? `no threads with 👍 from ${params.approved_by}`
: "no threads found for this review",
};
}
// format comments with diff context
const lines: string[] = [];
for (const comment of reviewComments) {
lines.push(`${"=".repeat(60)}`);
lines.push(`COMMENT #${comment.id} by @${comment.user?.login ?? "unknown"}`);
lines.push(`File: ${comment.path}:${comment.line ?? comment.original_line ?? "?"}`);
if (comment.in_reply_to_id) {
lines.push(`Reply to: #${comment.in_reply_to_id}`);
// fetch full file patches for better multi-hunk context
const prFilesResponse = await ctx.octokit.rest.pulls.listFiles({
owner: ctx.repo.owner,
repo: ctx.repo.name,
pull_number: params.pull_number,
});
const filePatchMap = new Map<string, ParsedHunk[]>();
for (const file of prFilesResponse.data) {
if (file.patch) {
filePatchMap.set(file.filename, parseFilePatches(file.patch));
}
lines.push("");
if (comment.diff_hunk) {
lines.push("```diff");
lines.push(comment.diff_hunk);
lines.push("```");
lines.push("");
}
lines.push("Comment:");
lines.push(comment.body);
lines.push("");
}
const content = lines.join("\n");
// build thread blocks
const { threadBlocks, reviewer } = buildThreadBlocks(
threadsForReview,
filePatchMap,
params.review_id
);
// format thread blocks into markdown with TOC
const formatted = formatReviewThreads(threadBlocks, {
pullNumber: params.pull_number,
reviewId: params.review_id,
reviewer,
});
// write to temp file
const tempDir = process.env.PULLFROG_TEMP_DIR;
if (!tempDir) {
throw new Error("PULLFROG_TEMP_DIR not set");
}
const filename = approved_by
? `review-${review_id}-approved-by-${approved_by}.txt`
: `review-${review_id}-comments.txt`;
const filename = `review-${params.review_id}-threads.md`;
const commentsPath = join(tempDir, filename);
writeFileSync(commentsPath, content);
log.debug(`wrote ${reviewComments.length} comments to ${commentsPath}`);
writeFileSync(commentsPath, formatted.content);
log.debug(`wrote ${threadBlocks.length} threads to ${commentsPath}`);
return {
review_id,
pull_number,
count: reviewComments.length,
review_id: params.review_id,
pull_number: params.pull_number,
reviewer,
threadCount: threadBlocks.length,
commentsPath,
toc: formatted.toc,
instructions:
`the file at commentsPath contains ${threadBlocks.length} review threads with full conversation history. ` +
`comments marked with * are from the target review (${params.review_id}). ` +
`the TOC shows each thread's file:line and the line number where it appears in the file. ` +
`to read a specific thread, use: grep -A 50 "^## <file:line>" ${commentsPath} ` +
`(replace <file:line> with the path from the TOC, e.g. "^## action/utils/foo.ts:42"). ` +
`address each thread in order, working through one file at a time.`,
};
}),
});
@@ -116,17 +554,18 @@ export function ListPullRequestReviewsTool(ctx: ToolContext) {
description:
"List all reviews for a pull request. Returns all reviews including approvals, request changes, and comments.",
parameters: ListPullRequestReviews,
execute: execute(async ({ pull_number }) => {
execute: execute(async (params) => {
const reviews = await ctx.octokit.paginate(ctx.octokit.rest.pulls.listReviews, {
owner: ctx.repo.owner,
repo: ctx.repo.name,
pull_number,
pull_number: params.pull_number,
});
return {
pull_number,
pull_number: params.pull_number,
reviews: reviews.map((review) => ({
id: review.id,
node_id: review.node_id,
body: review.body,
state: review.state,
user: review.user?.login,
+15 -17
View File
@@ -8,7 +8,6 @@ import type { Mode } from "../modes.ts";
import type { PrepResult } from "../prep/index.ts";
import type { OctokitWithPlugins } from "../utils/github.ts";
import type { ResolvedPayload } from "../utils/payload.ts";
import type { RepoData } from "../utils/repoData.ts";
export type BackgroundProcess = {
pid: number;
@@ -30,11 +29,9 @@ export interface ToolState {
promise: Promise<PrepResult[]> | undefined;
results: PrepResult[] | undefined;
};
progressComment: {
id: number | null;
wasUpdated: boolean;
};
progressCommentId: number | null;
lastProgressBody?: string;
wasUpdated?: boolean;
}
import type { ResolveRunResult } from "../utils/workflow.ts";
@@ -46,21 +43,20 @@ interface InitToolStateParams {
export function initToolState(ctx: InitToolStateParams): ToolState {
const progressCommentIdStr = ctx.runInfo.workflowRunInfo.progressCommentId;
const progressCommentId = progressCommentIdStr ? parseInt(progressCommentIdStr, 10) : null;
const resolvedId = Number.isNaN(progressCommentId) ? null : progressCommentId;
return {
progressComment: {
id: Number.isNaN(progressCommentId) ? null : progressCommentId,
wasUpdated: false,
},
progressCommentId: resolvedId,
backgroundProcesses: new Map(),
};
}
export interface ToolContext {
repo: RepoData;
repo: RunContextData["repo"];
payload: ResolvedPayload;
octokit: OctokitWithPlugins;
githubInstallationToken: string;
apiToken: string;
agent: Agent;
modes: Mode[];
toolState: ToolState;
@@ -68,6 +64,7 @@ export interface ToolContext {
jobId: string | undefined;
}
import type { RunContextData } from "../utils/runContextData.ts";
import { BashTool, KillBackgroundTool } from "./bash.ts";
import { CheckoutPrTool } from "./checkout.ts";
import { GetCheckSuiteLogsTool } from "./checkSuite.ts";
@@ -77,7 +74,7 @@ import {
ReplyToReviewCommentTool,
ReportProgressTool,
} from "./comment.ts";
import { DebugShellCommandTool } from "./debug.ts";
import { CommitInfoTool } from "./commitInfo.ts";
import {
AwaitDependencyInstallationTool,
StartDependencyInstallationTool,
@@ -94,6 +91,7 @@ import { CreatePullRequestReviewTool } from "./review.ts";
import { GetReviewCommentsTool, ListPullRequestReviewsTool } from "./reviewComments.ts";
import { SelectModeTool } from "./selectMode.ts";
import { addTools } from "./shared.ts";
import { UploadFileTool } from "./upload.ts";
/**
* Find an available port starting from the given port
@@ -171,23 +169,23 @@ export async function startMcpHttpServer(
CreatePullRequestTool(ctx),
CreatePullRequestReviewTool(ctx),
PullRequestInfoTool(ctx),
CommitInfoTool(ctx),
CheckoutPrTool(ctx),
GetReviewCommentsTool(ctx),
ListPullRequestReviewsTool(ctx),
GetCheckSuiteLogsTool(ctx),
DebugShellCommandTool(ctx),
AddLabelsTool(ctx),
CreateBranchTool(ctx),
CommitFilesTool(ctx),
PushBranchTool(ctx),
UploadFileTool(ctx),
];
// only add BashTool and KillBackgroundTool if bash is not disabled
// - "enabled": native bash + MCP bash
// - "restricted": MCP bash only (native blocked by agent)
// only add BashTool when bash is "restricted"
// - "enabled": native bash only (no MCP bash needed)
// - "restricted": MCP bash only (native blocked, env filtered)
// - "disabled": no bash at all
const bash = ctx.payload.bash ?? "enabled";
if (bash !== "disabled") {
if (ctx.payload.bash === "restricted") {
tools.push(BashTool(ctx));
tools.push(KillBackgroundTool(ctx));
}
+8 -4
View File
@@ -4,7 +4,9 @@ import type { FastMCP, Tool } from "fastmcp";
import { formatJsonValue, log } from "../utils/cli.ts";
import type { ToolContext } from "./server.ts";
export const tool = <const params>(toolDef: Tool<any, StandardSchemaV1<params>>) => toolDef;
export const tool = <const params>(
toolDef: Tool<any, StandardSchemaV1<params>>
): Tool<any, StandardSchemaV1<params>> => toolDef;
export interface ToolResult {
content: {
@@ -40,11 +42,11 @@ export const handleToolError = (error: unknown): ToolResult => {
* @param fn - the function to execute
* @param toolName - optional tool name for error logging
*/
export const execute = <T>(
fn: (params: T) => Promise<Record<string, any> | string>,
export const execute = <T, R extends Record<string, any> | string>(
fn: (params: T) => Promise<R>,
toolName?: string
) => {
return async (params: T): Promise<ToolResult> => {
const _fn = async (params: T): Promise<ToolResult> => {
try {
const result = await fn(params);
return handleToolSuccess(result);
@@ -56,6 +58,8 @@ export const execute = <T>(
return handleToolError(error);
}
};
(_fn as any).raw = fn;
return _fn;
};
/**
+69
View File
@@ -0,0 +1,69 @@
import * as fs from "node:fs";
import * as path from "node:path";
import { type } from "arktype";
import { fileTypeFromBuffer } from "file-type";
import type { ToolContext } from "./server.ts";
import { execute, tool } from "./shared.ts";
const UploadFileParams = type({
path: type.string.describe("absolute path to file to upload"),
});
export function UploadFileTool(ctx: ToolContext) {
return tool({
name: "upload_file",
description:
"upload a file to get a permanent public URL. use for screenshots, artifacts, or any files you want to reference in PRs/comments. max 10MB, images/text/archives allowed.",
parameters: UploadFileParams,
execute: execute(async (params) => {
// read file from disk eagerly on purpose to avoid its content being changed by the time it's uploaded
const buffer = fs.readFileSync(params.path);
const filename = path.basename(params.path);
const contentLength = buffer.length;
const fileType = await fileTypeFromBuffer(buffer);
const contentType = fileType?.mime || "application/octet-stream";
const apiUrl = process.env.API_URL || "https://pullfrog.com";
const response = await fetch(`${apiUrl}/api/upload/signed-url`, {
method: "POST",
headers: {
Authorization: `Bearer ${ctx.apiToken}`,
"Content-Type": "application/json",
},
body: JSON.stringify({
filename,
contentType,
contentLength,
}),
});
if (!response.ok) {
const error = await response.text();
throw new Error(`failed to get upload URL: ${error}`);
}
const { uploadUrl, publicUrl } = (await response.json()) as {
uploadUrl: string;
publicUrl: string;
};
const uploadResponse = await fetch(uploadUrl, {
method: "PUT",
headers: {
"Content-Type": contentType,
// should be set automatically, but given this header is signed it's better to be explicit
"Content-Length": String(contentLength),
},
body: buffer,
});
if (!uploadResponse.ok) {
throw new Error(`failed to upload file: ${uploadResponse.statusText}`);
}
return { success: true, publicUrl, filename, contentLength, contentType };
}),
});
}
+113 -39
View File
@@ -1,3 +1,4 @@
// changes to mode definitions should be reflected in docs/modes.mdx
import { type } from "arktype";
import { ghPullfrogMcpName } from "./external.ts";
@@ -18,13 +19,15 @@ const reportProgressInstruction = `Use ${ghPullfrogMcpName}/report_progress to s
const dependencyInstallationStep = `If this task will require running tests, builds, linters, or CLI commands that need installed packages, call \`${ghPullfrogMcpName}/start_dependency_installation\` NOW. This is non-blocking and allows dependencies to install in the background while you continue. Later, call \`${ghPullfrogMcpName}/await_dependency_installation\` before running commands that need them. Skip this step if only reading code or answering questions.`;
const permalinkTip = `**TIP**: To reference specific code, use GitHub permalinks: \`https://github.com/{owner}/{repo}/blob/{commit_sha}/{path}#L{start}-L{end}\`. GitHub renders these as expandable code blocks.`;
export function computeModes(): Mode[] {
return [
{
name: "Build",
description:
"Implement, build, create, or develop code changes; make specific changes to files or features; execute a plan; or handle tasks with specific implementation details",
prompt: `Follow these steps. THINK HARDER.
prompt: `Follow these steps exactly.
1. Determine whether to work on the current branch or create a new one:
- **PR event, modifying the existing PR**: The PR branch is probably already checked out. Continue on this branch.
- **PR event, but user wants a NEW branch/PR**: Use \`${ghPullfrogMcpName}/create_branch\` to create a new branch from the current HEAD.
@@ -38,15 +41,17 @@ export function computeModes(): Mode[] {
4. Understand the requirements and any existing plan
5. Make the necessary code changes using file operations. Then use ${ghPullfrogMcpName}/commit_files to commit your changes, and ${ghPullfrogMcpName}/push_branch to push the branch. Do NOT use git commands like \`git commit\` or \`git push\` directly.
5. Make the necessary code changes using file operations. You should change the minimum amount of code necessary to accomplish your task. Emphasize code quality and elegance.
6. Test your changes to ensure they work correctly
6. Then use ${ghPullfrogMcpName}/commit_files to commit your changes, and ${ghPullfrogMcpName}/push_branch to push the branch. Do NOT use git commands like \`git commit\` or \`git push\` directly.
7. ${reportProgressInstruction}
7. Test your changes to ensure they work correctly
8. When you are done, use ${ghPullfrogMcpName}/create_pull_request to create a PR. If relevant, indicate which issue the PR addresses in the PR body (e.g. "Fixes #123").
8. ${reportProgressInstruction}
9. By default, create a PR with an informative title and body. However, if the user explicitly requests a branch without a PR (e.g. "implement X in a new branch", "don't create a PR", "branch only"), you still need to use ${ghPullfrogMcpName}/create_pull_request to ensure commits are properly attributed - you can note in the PR description that it's branch-only if needed.
9. Determine whether to create a PR (if not already on a PR branch):
- **Default behavior**: Create a PR using ${ghPullfrogMcpName}/create_pull_request with an informative title and body. If you are working in the context of an issue (check EVENT DATA for \`issue_number\` where \`is_pr\` is not true), include "Closes #<issue_number>" in the PR body to auto-close the issue when merged.
- **Branch-only request**: If the user explicitly asks for a branch without a PR (e.g. "don't create a PR", "branch only", "just create a branch"), do NOT create a PR. Simply push the branch and report the branch link.
10. Call report_progress one final time ONLY if you haven't already included all the important information (PR links, branch links, summary) in a previous report_progress call. If you already called report_progress with complete information including PR links after creating the PR, you do NOT need to call it again. Only make a final call if you need to add missing information. When making the final call, ensure it includes:
- A summary of what was accomplished
@@ -73,21 +78,21 @@ export function computeModes(): Mode[] {
2. ${dependencyInstallationStep}
3. Review the feedback provided. Understand each review comment and what changes are being requested.
- **EVENT DATA may contain review comment details**: If available, \`approved_comments\` are comments to address, \`unapproved_comments\` are for context only. The \`triggerer\` field indicates who initiated this action - prioritize their replies when deciding how to implement fixes.
- You can use ${ghPullfrogMcpName}/get_pull_request to get PR metadata if needed.
3. Fetch review comments using ${ghPullfrogMcpName}/get_review_comments with \`pull_number\` and \`review_id\` from EVENT DATA. This returns \`commentsPath\` - read that file for full comment details with diff context. If EVENT DATA contains a \`triggerer\` field (indicating who requested fixes), you can pass \`approved_by\` to filter to only comments they approved with 👍.
4. If the request requires understanding the codebase structure or conventions, gather relevant context. Read AGENTS.md if it exists.
4. Review the feedback provided. Understand each review comment and what changes are being requested.
5. Make the necessary code changes to address the feedback. Work through each review comment systematically.
5. If the request requires understanding the codebase structure or conventions, gather relevant context. Read AGENTS.md if it exists.
6. **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"). If suggesting a small, specific, self-contained code change, use GitHub's suggestion format with \`\`\`suggestion blocks.
6. Make the necessary code changes to address the feedback. Work through each review comment systematically.
7. Test your changes to ensure they work correctly.
7. **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"). If suggesting a small, specific, self-contained code change, use GitHub's suggestion format with \`\`\`suggestion blocks.
8. When done, commit your changes with ${ghPullfrogMcpName}/commit_files, then push with ${ghPullfrogMcpName}/push_branch. The push will automatically go to the correct remote (including fork repos). Do not create a new branch or PR - you are updating an existing one.
8. Test your changes to ensure they work correctly.
9. ${reportProgressInstruction}
9. When done, commit your changes with ${ghPullfrogMcpName}/commit_files, then push with ${ghPullfrogMcpName}/push_branch. The push will automatically go to the correct remote (including fork repos). Do not create a new branch or PR - you are updating an existing one.
10. ${reportProgressInstruction}
**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.`,
},
@@ -95,36 +100,35 @@ export function computeModes(): Mode[] {
name: "Review",
description:
"Review code, PRs, or implementations; provide feedback or suggestions; identify issues; or check code quality, style, and correctness",
prompt: `Follow these steps to review the PR. Think hard. Do not nitpick.
prompt: `Follow these steps to review the PR. Your job is to find problems—assume they exist until you've proven otherwise. Do not submit a clean review without thorough investigation.
1. **CHECKOUT** - Call ${ghPullfrogMcpName}/checkout_pr with the PR number. This should give you all PR metadata you need, including a \`diffPath\`: a path to a temp file containing the PR diff.
2. **ANALYZE** - Read the modified files to understand the changes in context.
- **Understand the change**: What is being modified and why? What's the before/after behavior?
- **Evaluate the approach**: Is it sound? If not, focus on approach before implementation details.
2. **ANALYZE**
- Read the modified files to understand the changes in context. Make sure you understand what's being changed.
- Is it a good idea? Think about the tradeoffs.
- Is the approach sound? If not, focus on the approach first. Don't waste time on implementation details if the approach is wrong.
- Can you imagine a better approach? If so, explain. Make sure it's strictly better, not just different.
- Are there bugs, edge cases, security issues, or usability issues? Use your imagination.
3. **INVESTIGATE** - Actively hunt for problems. Use these techniques:
- **Trace data flow**: Use grep to follow how data moves through the system. How is state passed? Where could it get lost?
- **Check boundaries**: What happens across process boundaries, module boundaries, async boundaries? State that exists in one context may not exist in another.
- **Explore failure modes**: What if this throws? What if that returns null? What if the network fails? What if this runs twice?
- **Verify assumptions**: If the code assumes X, verify X is actually true. Use grep, read related files, check documentation.
- **Consider lifecycle**: Initialization, cleanup, error recovery. Are resources acquired before use? Released after? What happens on cancellation?
- Do NOT stop at "this looks reasonable." Dig until you either find a problem or have concrete evidence there isn't one.
3. **DRAFT** - For each inline comment, find the line in the diff. Each code line shows: \`| OLD | NEW | TYPE | CODE\`. Use the NEW line number (second column). When suggesting specific code changes, use GitHub's suggestion format with \`\`\`suggestion blocks to enable one-click apply. Example:
you could simplify this
\`\`\`suggestion
const result = data.map(x => x.value);
\`\`\`
or you could use reduce instead
\`\`\`suggestion
const result = data.reduce((acc, x) => [...acc, x.value], []);
\`\`\`
4. **DRAFT** - For each issue found, create an inline comment. Use the NEW line number from the diff (second column: \`| OLD | NEW | TYPE | CODE\`).
4. **FILTER COMMENTS** - Do not nitpick! Do not leave compliments that are not actionable. Do not critique the code hygiene or anything stylistic.
5. **FILTER** - Remove noise, keep substance:
- Remove style-only comments (formatting, naming conventions) unless they cause real confusion
- Remove compliments that aren't actionable
- Keep: bugs, logic errors, missing error handling, security issues, race conditions, resource leaks, incorrect assumptions
5. **SUBMIT** — Use ${ghPullfrogMcpName}/create_pull_request_review with:
- \`comments\`: Array of all inline comments with file paths and line numbers
- \`body\`: Everything else. Aim for a 1-3 sentence summary of the urgency level (e.g., "minor suggestions" vs "blocking issues") and any critical callouts (e.g., API key exposure). It can be longer if there are concerns that do not lend themselves to inline comments.
- If you have no substantive feedback, submit an empty comments array with a brief approving body.
- Again, do not nitpick.
6. **SUBMIT** — Use ${ghPullfrogMcpName}/create_pull_request_review:
- \`comments\`: Inline feedback on specific diff lines
- \`body\`: 1-3 sentence summary with urgency level and any concerns about code outside the diff
- If no issues found, submit with empty comments and a brief approving body
${permalinkTip}
`,
},
{
@@ -140,7 +144,75 @@ export function computeModes(): Mode[] {
4. Create a structured plan with clear milestones
5. ${reportProgressInstruction}`,
5. ${reportProgressInstruction}
${permalinkTip}`,
},
{
name: "Fix",
description:
"Fix CI failures; debug failing tests or builds; investigate and resolve check suite failures",
prompt: `Follow these steps to fix CI failures. THINK HARDER.
**CRITICAL RULE**: Only fix issues that were INTRODUCED BY THIS PR. If the CI failure is unrelated to the PR's changes, you MUST abort without committing anything and report why.
1. **GET FAILURE INFO** - Call ${ghPullfrogMcpName}/get_check_suite_logs with the check_suite_id from EVENT DATA. This returns:
- \`log_index\`: array of interesting lines (errors, warnings, failures) with line numbers - scan this first
- \`excerpt\`: curated ~80 lines around the main error - read this for immediate context
- \`full_log_path\`: path to complete log file - read specific line ranges if needed
- \`failed_steps\`: which CI steps failed (e.g., "Step 6: Run tests")
2. **CHECKOUT AND ASSESS CAUSATION** - Use ${ghPullfrogMcpName}/checkout_pr to get the PR diff. BEFORE attempting any fix, you MUST determine if this PR caused the failure:
**Ask yourself**: "Could the changes in this PR have caused this failure?"
- Read the PR diff carefully - what files were modified?
- What is failing? (test file, module, assertion)
- Is there a PLAUSIBLE CONNECTION between the PR changes and the failure?
**ABORT immediately if any of these are true:**
- The failing test/file was NOT touched by this PR AND doesn't depend on changed code
- The error is infrastructure-related (network timeout, runner OOM, service unavailable)
- The error is a flaky test that passes/fails randomly
- The error existed before this PR (pre-existing bug in main branch)
- The error is in a dependency update not introduced by this PR
**When aborting**, use ${ghPullfrogMcpName}/report_progress to explain:
"This CI failure appears unrelated to the PR's changes. [Describe the failure]. [Explain why it's not caused by the PR]. No changes made."
**Only proceed** if there's a clear, logical connection between the PR changes and the failure.
3. **UNDERSTAND HOW CI RUNS** - Read the workflow file to understand exactly what commands CI runs:
- Look at \`.github/workflows/*.yml\` files
- Find the job/step that failed (from \`failed_steps\`)
- Note the EXACT command (e.g., \`pnpm -r test --filter=action\`, not just \`pnpm test\`)
- Check for any CI-specific environment variables or setup steps
4. ${dependencyInstallationStep}
5. **REPRODUCE LOCALLY** - Run the EXACT same command that CI runs:
- Do NOT simplify (e.g., don't run \`pnpm test\` if CI runs \`pnpm -r test --filter=action\`)
- Check if CI uses specific flags, filters, or environment variables
- If CI runs multiple test suites, run them all
6. **ANALYZE THE FAILURE** - Use the log_index and excerpt to understand:
- What exactly failed (test name, file, assertion)
- Are there earlier warnings that might explain the failure?
- Is the failure flaky or deterministic?
7. **FIX THE ISSUE** - Make the necessary code changes. Common patterns:
- Test assertion failures: fix the code or update the test expectation
- Build failures: fix type errors, missing imports, syntax issues
- Lint failures: fix code style issues
- Timeout/flaky tests: investigate race conditions or increase timeouts
8. **VERIFY THE FIX** - Run the EXACT same CI command again to confirm the fix works
9. **COMMIT AND PUSH** - Use ${ghPullfrogMcpName}/commit_files and ${ghPullfrogMcpName}/push_branch
10. ${reportProgressInstruction}
**REMEMBER**: Your job is to fix issues THIS PR introduced, not to fix all CI failures. If in doubt about causation, abort and explain rather than making speculative changes.`,
},
{
name: "Prompt",
@@ -155,7 +227,9 @@ export function computeModes(): Mode[] {
- Use file operations to create/modify files with your changes.
- Use ${ghPullfrogMcpName}/commit_files to commit your changes, then ${ghPullfrogMcpName}/push_branch to push the branch. Do NOT use git commands directly (\`git commit\`, \`git push\`, \`git checkout\`, \`git branch\`) as these will use incorrect credentials.
- Test your changes to ensure they work correctly.
- When you are done, use ${ghPullfrogMcpName}/create_pull_request to create a PR. If relevant, indicate which issue the PR addresses in the PR body (e.g. "Fixes #123"). Include links to the issue or comment that triggered the PR in the PR body.
- Determine whether to create a PR:
- **Default behavior**: Create a PR using ${ghPullfrogMcpName}/create_pull_request with an informative title and body. If you are working in the context of an issue (check EVENT DATA for \`issue_number\` where \`is_pr\` is not true), include "Closes #<issue_number>" in the PR body to auto-close the issue when merged.
- **Branch-only request**: If the user explicitly asks for a branch without a PR (e.g. "don't create a PR", "branch only", "just create a branch"), do NOT create a PR. Simply push the branch and report the branch link.
3. ${reportProgressInstruction}
+8 -3
View File
@@ -1,6 +1,6 @@
{
"name": "@pullfrog/pullfrog",
"version": "0.0.158",
"version": "0.0.161",
"type": "module",
"files": [
"index.js",
@@ -19,6 +19,7 @@
"play": "node play.ts",
"smoke": "node test/smoke.ts",
"nobash": "node test/nobash.ts",
"restricted": "node test/restricted.ts",
"scratch": "node scratch.ts",
"upDeps": "pnpm up --latest",
"lock": "pnpm --ignore-workspace install",
@@ -26,7 +27,6 @@
},
"dependencies": {
"@actions/core": "^1.11.1",
"@actions/github": "^6.0.1",
"@anthropic-ai/claude-agent-sdk": "0.2.7",
"@ark/fs": "0.53.0",
"@ark/util": "0.53.0",
@@ -41,11 +41,16 @@
"dotenv": "^17.2.3",
"execa": "^9.6.0",
"fastmcp": "^3.26.8",
"file-type": "^21.3.0",
"package-manager-detector": "^1.6.0",
"table": "^6.9.0"
"semver": "^7.7.3",
"table": "^6.9.0",
"turndown": "^7.2.0"
},
"devDependencies": {
"@types/node": "^24.7.2",
"@types/semver": "^7.7.1",
"@types/turndown": "^5.0.5",
"arg": "^5.0.2",
"esbuild": "^0.25.9",
"husky": "^9.0.0",
+35 -109
View File
@@ -1,17 +1,29 @@
import { spawnSync } from "node:child_process";
import { existsSync, readFileSync, rmSync } from "node:fs";
import { existsSync, rmSync } from "node:fs";
import { mkdtemp } from "node:fs/promises";
import { platform, tmpdir } from "node:os";
import { dirname, extname, join, resolve } from "node:path";
import { fileURLToPath, pathToFileURL } from "node:url";
import { fromHere } from "@ark/fs";
import { dirname, join } from "node:path";
import { fileURLToPath } from "node:url";
import arg from "arg";
import { config } from "dotenv";
import type { AgentResult } from "./agents/shared.ts";
import { type Inputs, main } from "./main.ts";
import { defineFixture } from "./test/utils.ts";
import { log } from "./utils/cli.ts";
import { setupTestRepo } from "./utils/setup.ts";
/**
* default play fixture for ad-hoc testing.
* change this freely without affecting any tests.
*/
export const playFixture = defineFixture(
{
prompt: `What is 2 + 2? Reply with just the number.`,
effort: "mini",
},
{ localOnly: true }
);
const __filename = fileURLToPath(import.meta.url);
const __dirname = dirname(__filename);
@@ -30,6 +42,8 @@ export async function run(inputsOrPrompt: Inputs | string): Promise<AgentResult>
try {
setupTestRepo({ tempDir });
process.chdir(tempDir);
// set GITHUB_WORKSPACE to tempDir so main() doesn't try to chdir to the CI checkout path
process.env.GITHUB_WORKSPACE = tempDir;
// allow passing full Inputs object or just a prompt string
const inputs: Inputs =
@@ -75,25 +89,22 @@ if (import.meta.url === `file://${process.argv[1]}`) {
if (args["--help"]) {
log.info(`
Usage: tsx play.ts [file] [options]
Usage: node play.ts [options]
Test the Pullfrog action with various prompts.
Arguments:
file Prompt file to use (.txt, .json, or .ts) [default: fixtures/basic.txt]
Test the Pullfrog action with the inline playFixture.
Options:
--raw [prompt] Use raw string as prompt instead of loading from file
--raw [prompt] Use raw string as prompt instead of playFixture
--local, -l Run locally (default: runs in Docker)
-h, --help Show this help message
Environment:
PLAY_LOCAL=1 Same as --local
PLAY_FIXTURE JSON fixture passed by test runner (internal)
Examples:
tsx play.ts bash-test.ts # Run in Docker (default)
tsx play.ts --local bash-test.ts # Run locally on macOS
tsx play.ts --raw "Hello world" # Use raw string as prompt
node play.ts # Run inline playFixture
node play.ts --raw "Hello world" # Use raw string as prompt
`);
process.exit(0);
}
@@ -217,104 +228,19 @@ Examples:
process.exit(result.status ?? 1);
}
let prompt: string;
// check for fixture passed via env var (from test runner)
if (process.env.PLAY_FIXTURE) {
const fixtureFromEnv = JSON.parse(process.env.PLAY_FIXTURE) as Inputs;
const result = await run(fixtureFromEnv);
process.exit(result.success ? 0 : 1);
}
if (args["--raw"]) {
prompt = args["--raw"];
} else {
const filePath = args._[0] || "basic.txt";
const ext = extname(filePath).toLowerCase();
let resolvedPath: string;
const fixturesPath = fromHere("test", "fixtures", filePath);
if (existsSync(fixturesPath)) {
resolvedPath = fixturesPath;
} else if (existsSync(filePath)) {
resolvedPath = resolve(filePath);
} else {
throw new Error(`File not found: ${filePath}`);
}
switch (ext) {
case ".txt": {
prompt = readFileSync(resolvedPath, "utf8").trim();
break;
}
case ".json": {
const content = readFileSync(resolvedPath, "utf8");
const parsed = JSON.parse(content);
prompt = JSON.stringify(parsed, null, 2);
break;
}
case ".ts": {
const fileUrl = pathToFileURL(resolvedPath).href;
const module = await import(fileUrl);
if (!module.default) {
throw new Error(`TypeScript file ${filePath} must have a default export`);
}
if (typeof module.default === "string") {
prompt = module.default;
} else if (Array.isArray(module.default)) {
// Array of Payloads - run each in sequence
const payloads = module.default;
log.info(`Running ${payloads.length} payloads in sequence...`);
let allSuccess = true;
for (let i = 0; i < payloads.length; i++) {
const payload = payloads[i];
const label = payload.effort
? `[${i + 1}/${payloads.length}] effort=${payload.effort}`
: `[${i + 1}/${payloads.length}]`;
log.info(`\n${"=".repeat(60)}`);
log.info(`${label}`);
log.info(`${"=".repeat(60)}\n`);
const payloadPrompt = JSON.stringify(payload, null, 2);
const result = await run(payloadPrompt);
if (!result.success) {
allSuccess = false;
log.error(`Payload ${i + 1} failed`);
}
}
process.exit(allSuccess ? 0 : 1);
} else if (typeof module.default === "object") {
const obj = module.default as Record<string, unknown>;
// Inputs objects have `prompt` field and optional tool permission fields
// Payload objects have `~pullfrog` field
if ("prompt" in obj && !("~pullfrog" in obj)) {
// this is an Inputs object - run directly with tool permissions
const result = await run(obj as Inputs);
process.exit(result.success ? 0 : 1);
}
// Payload objects (with ~pullfrog) should be stringified
prompt = JSON.stringify(module.default, null, 2);
} else {
throw new Error(`Unsupported default export type: ${typeof module.default}`);
}
break;
}
default:
throw new Error(`Unsupported file type: ${ext}. Supported types: .txt, .json, .ts`);
}
const result = await run(args["--raw"]);
process.exit(result.success ? 0 : 1);
}
try {
const result = await run(prompt);
if (!result.success) {
process.exit(1);
}
process.exit(0);
} catch (err) {
log.error((err as Error).message);
process.exit(1);
}
// no args - use inline playFixture
const result = await run(playFixture);
process.exit(result.success ? 0 : 1);
}
+44 -137
View File
@@ -11,9 +11,6 @@ importers:
'@actions/core':
specifier: ^1.11.1
version: 1.11.1
'@actions/github':
specifier: ^6.0.1
version: 6.0.1
'@anthropic-ai/claude-agent-sdk':
specifier: 0.2.7
version: 0.2.7(zod@4.3.5)
@@ -56,16 +53,31 @@ importers:
fastmcp:
specifier: ^3.26.8
version: 3.26.8(arktype@2.1.28)(hono@4.11.3)
file-type:
specifier: ^21.3.0
version: 21.3.0
package-manager-detector:
specifier: ^1.6.0
version: 1.6.0
semver:
specifier: ^7.7.3
version: 7.7.3
table:
specifier: ^6.9.0
version: 6.9.0
turndown:
specifier: ^7.2.0
version: 7.2.2
devDependencies:
'@types/node':
specifier: ^24.7.2
version: 24.7.2
'@types/semver':
specifier: ^7.7.1
version: 7.7.1
'@types/turndown':
specifier: ^5.0.5
version: 5.0.6
arg:
specifier: ^5.0.2
version: 5.0.2
@@ -90,9 +102,6 @@ packages:
'@actions/exec@1.1.1':
resolution: {integrity: sha512-+sCcHHbVdk93a0XT19ECtO/gIXoxvdsgQLzb2fE2/5sIZmWQuluYyjPQtrtTHdU1YzTZ7bAPN4sITq2xi1679w==}
'@actions/github@6.0.1':
resolution: {integrity: sha512-xbZVcaqD4XnQAe35qSQqskb3SqIAfRyLBrHMd/8TuL7hJSz2QtbDwnNM8zWx4zO5l2fnGtseNE3MbEvD7BxVMw==}
'@actions/http-client@2.2.3':
resolution: {integrity: sha512-mx8hyJi/hjFvbPokCg4uRd4ZX78t+YyRPtnKWwIl+RzNaVuFpQHfmlGVfsKEJN8LwTCvL+DfVgAM04XaHkm6bA==}
@@ -528,6 +537,9 @@ packages:
'@jridgewell/sourcemap-codec@1.5.5':
resolution: {integrity: sha512-cYQ9310grqxueWbl+WuIUIaiUaDcj7WOq5fVhEljNVgRfOUhY9fy2zTvfoqWsnebh8Sl70VScFbICvJnLKB0Og==}
'@mixmark-io/domino@2.2.0':
resolution: {integrity: sha512-Y28PR25bHXUg88kCV7nivXrP2Nj2RueZ3/l/jdx6J9f8J4nsEGcgX0Qe6lt7Pa+J79+kPiJU3LguR6O/6zrLOw==}
'@modelcontextprotocol/sdk@1.25.2':
resolution: {integrity: sha512-LZFeo4F9M5qOhC/Uc1aQSrBHxMrvxett+9KLHt7OhcExtoiRN9DKgbZffMP/nxjutWDQpfMDfP3nkHI4X9ijww==}
engines: {node: '>=18'}
@@ -538,18 +550,10 @@ packages:
'@cfworker/json-schema':
optional: true
'@octokit/auth-token@4.0.0':
resolution: {integrity: sha512-tY/msAuJo6ARbK6SPIxZrPBms3xPbfwBrulZe0Wtr/DIY9lje2HeV1uoebShn6mx7SjCHif6EjMvoREj+gZ+SA==}
engines: {node: '>= 18'}
'@octokit/auth-token@6.0.0':
resolution: {integrity: sha512-P4YJBPdPSpWTQ1NU4XYdvHvXJJDxM6YwpS0FZHRgP7YFkdVxsWcpWGy/NVqlAA7PcPCnMacXlRm1y2PFZRWL/w==}
engines: {node: '>= 20'}
'@octokit/core@5.2.2':
resolution: {integrity: sha512-/g2d4sW9nUDJOMz3mabVQvOGhVa4e/BN/Um7yca9Bb2XTzPPnfTWHWQg+IsEYO7M3Vx+EXvaM/I2pJWIMun1bg==}
engines: {node: '>= 18'}
'@octokit/core@7.0.5':
resolution: {integrity: sha512-t54CUOsFMappY1Jbzb7fetWeO0n6K0k/4+/ZpkS+3Joz8I4VcvY9OiEBFRYISqaI2fq5sCiPtAjRDOzVYG8m+Q==}
engines: {node: '>= 20'}
@@ -558,24 +562,10 @@ packages:
resolution: {integrity: sha512-7P1dRAZxuWAOPI7kXfio88trNi/MegQ0IJD3vfgC3b+LZo1Qe6gRJc2v0mz2USWWJOKrB2h5spXCzGbw+fAdqA==}
engines: {node: '>= 20'}
'@octokit/endpoint@9.0.6':
resolution: {integrity: sha512-H1fNTMA57HbkFESSt3Y9+FBICv+0jFceJFPWDePYlR/iMGrwM5ph+Dd4XRQs+8X+PUFURLQgX9ChPfhJ/1uNQw==}
engines: {node: '>= 18'}
'@octokit/graphql@7.1.1':
resolution: {integrity: sha512-3mkDltSfcDUoa176nlGoA32RGjeWjl3K7F/BwHwRMJUW/IteSa4bnSV8p2ThNkcIcZU2umkZWxwETSSCJf2Q7g==}
engines: {node: '>= 18'}
'@octokit/graphql@9.0.2':
resolution: {integrity: sha512-iz6KzZ7u95Fzy9Nt2L8cG88lGRMr/qy1Q36ih/XVzMIlPDMYwaNLE/ENhqmIzgPrlNWiYJkwmveEetvxAgFBJw==}
engines: {node: '>= 20'}
'@octokit/openapi-types@20.0.0':
resolution: {integrity: sha512-EtqRBEjp1dL/15V7WiX5LJMIxxkdiGJnabzYx5Apx4FkQIFgAfKumXeYAqqJCj1s+BMX4cPFIFC4OLCR6stlnA==}
'@octokit/openapi-types@24.2.0':
resolution: {integrity: sha512-9sIH3nSUttelJSXUrmGzl7QUBFul0/mB8HRYl3fOlgHbIWG+WnYDXU3v/2zMtAvuzZ/ed00Ei6on975FhBfzrg==}
'@octokit/openapi-types@26.0.0':
resolution: {integrity: sha512-7AtcfKtpo77j7Ts73b4OWhOZHTKo/gGY8bB3bNBQz4H+GRSWqx2yvj8TXRsbdTE0eRmYmXOEY66jM7mJ7LzfsA==}
@@ -588,24 +578,12 @@ packages:
peerDependencies:
'@octokit/core': '>=6'
'@octokit/plugin-paginate-rest@9.2.2':
resolution: {integrity: sha512-u3KYkGF7GcZnSD/3UP0S7K5XUFT2FkOQdcfXZGZQPGv3lm4F2Xbf71lvjldr8c1H3nNbF+33cLEkWYbokGWqiQ==}
engines: {node: '>= 18'}
peerDependencies:
'@octokit/core': '5'
'@octokit/plugin-request-log@6.0.0':
resolution: {integrity: sha512-UkOzeEN3W91/eBq9sPZNQ7sUBvYCqYbrrD8gTbBuGtHEuycE4/awMXcYvx6sVYo7LypPhmQwwpUe4Yyu4QZN5Q==}
engines: {node: '>= 20'}
peerDependencies:
'@octokit/core': '>=6'
'@octokit/plugin-rest-endpoint-methods@10.4.1':
resolution: {integrity: sha512-xV1b+ceKV9KytQe3zCVqjg+8GTGfDYwaT1ATU5isiUyVtlVAO3HNdzpS4sr4GBx4hxQ46s7ITtZrAsxG22+rVg==}
engines: {node: '>= 18'}
peerDependencies:
'@octokit/core': '5'
'@octokit/plugin-rest-endpoint-methods@16.1.0':
resolution: {integrity: sha512-nCsyiKoGRnhH5LkH8hJEZb9swpqOcsW+VXv1QoyUNQXJeVODG4+xM6UICEqyqe9XFr6LkL8BIiFCPev8zMDXPw==}
engines: {node: '>= 20'}
@@ -618,10 +596,6 @@ packages:
peerDependencies:
'@octokit/core': ^7.0.0
'@octokit/request-error@5.1.1':
resolution: {integrity: sha512-v9iyEQJH6ZntoENr9/yXxjuezh4My67CBSu9r6Ve/05Iu5gNgnisNWOsoJHTP6k0Rr0+HQIpnH+kyammu90q/g==}
engines: {node: '>= 18'}
'@octokit/request-error@7.0.1':
resolution: {integrity: sha512-CZpFwV4+1uBrxu7Cw8E5NCXDWFNf18MSY23TdxCBgjw1tXXHvTrZVsXlW8hgFTOLw8RQR1BBrMvYRtuyaijHMA==}
engines: {node: '>= 20'}
@@ -630,20 +604,10 @@ packages:
resolution: {integrity: sha512-TXnouHIYLtgDhKo+N6mXATnDBkV05VwbR0TtMWpgTHIoQdRQfCSzmy/LGqR1AbRMbijq/EckC/E3/ZNcU92NaQ==}
engines: {node: '>= 20'}
'@octokit/request@8.4.1':
resolution: {integrity: sha512-qnB2+SY3hkCmBxZsR/MPCybNmbJe4KAlfWErXq+rBKkQJlbjdJeS85VI9r8UqeLYLvnAenU8Q1okM/0MBsAGXw==}
engines: {node: '>= 18'}
'@octokit/rest@22.0.0':
resolution: {integrity: sha512-z6tmTu9BTnw51jYGulxrlernpsQYXpui1RK21vmXn8yF5bp6iX16yfTtJYGK5Mh1qDkvDOmp2n8sRMcQmR8jiA==}
engines: {node: '>= 20'}
'@octokit/types@12.6.0':
resolution: {integrity: sha512-1rhSOfRa6H9w4YwK0yrf5faDaDTb+yLyBUKOCV4xtCDB5VmIPqd/v9yr9o6SAzOAlRxMiRiCic6JVM1/kunVkw==}
'@octokit/types@13.10.0':
resolution: {integrity: sha512-ifLaO34EbbPj0Xgro4G5lP5asESjwHracYJvVaPIyXMuiuXLlhic3S47cBdTb+jfODkTE5YtGCLt3Ay3+J97sA==}
'@octokit/types@15.0.0':
resolution: {integrity: sha512-8o6yDfmoGJUIeR9OfYU0/TUJTnMPG2r68+1yEdUeG2Fdqpj8Qetg0ziKIgcBm0RW/j29H41WP37CYCEhp6GoHQ==}
@@ -817,6 +781,12 @@ packages:
'@types/node@24.7.2':
resolution: {integrity: sha512-/NbVmcGTP+lj5oa4yiYxxeBjRivKQ5Ns1eSZeB99ExsEQ6rX5XYU1Zy/gGxY/ilqtD4Etx9mKyrPxZRetiahhA==}
'@types/semver@7.7.1':
resolution: {integrity: sha512-FmgJfu+MOcQ370SD0ev7EI8TlCAfKYU+B4m5T3yXc1CiRN94g/SZPtsCkk506aUDtlMnFZvasDwHHUcZUEaYuA==}
'@types/turndown@5.0.6':
resolution: {integrity: sha512-ru00MoyeeouE5BX4gRL+6m/BsDfbRayOskWqUvh7CLGW+UXxHQItqALa38kKnOiZPqJrtzJUgAC2+F0rL1S4Pg==}
'@vitest/expect@4.0.17':
resolution: {integrity: sha512-mEoqP3RqhKlbmUmntNDDCJeTDavDR+fVYkSOw8qRwJFaW/0/5zA9zFeTrHqNtcmwh6j26yMmwx2PqUDPzt5ZAQ==}
@@ -894,9 +864,6 @@ packages:
resolution: {integrity: sha512-Z7tMw1ytTXt5jqMcOP+OQteU1VuNK9Y02uuJtKQ1Sv69jXQKKg5cibLwGJow8yzZP+eAc18EmLGPal0bp36rvQ==}
engines: {node: '>=8'}
before-after-hook@2.2.3:
resolution: {integrity: sha512-NzUnlZexiaH/46WDhANlyR2bXRopNg4F/zuSA3OpZnllCUgRaOF2znDioDWrmbNVsuZk6l9pMquQB38cfBZwkQ==}
before-after-hook@4.0.0:
resolution: {integrity: sha512-q6tR3RPqIB1pMiTRMFcZwuG5T8vwp+vUvEG0vuI6B+Rikh5BfPp2fQ82c925FOs+b0lcFQ8CFrL+KbilfZFhOQ==}
@@ -971,9 +938,6 @@ packages:
resolution: {integrity: sha512-g7nH6P6dyDioJogAAGprGpCtVImJhpPk/roCzdb3fIh61/s/nPsfR6onyMwkCAR/OlC3yBC0lESvUoQEAssIrw==}
engines: {node: '>= 0.8'}
deprecation@2.3.1:
resolution: {integrity: sha512-xmHIy4F3scKVwMsQ4WnVaS8bHOx0DmVwRywosKhaILI0ywMDWPtBSku2HNxRvF7jtwDRsoEwYQSfbxj8b7RlJQ==}
dotenv@17.2.3:
resolution: {integrity: sha512-JVUnt+DUIzu87TABbhPmNfVdBDt18BLOWjMUFJMSi/Qqg7NTYtabbvSNJGOJ7afbRuv9D/lngizHtP7QyLQ+9w==}
engines: {node: '>=12'}
@@ -1364,6 +1328,11 @@ packages:
safer-buffer@2.1.2:
resolution: {integrity: sha512-YZo3K82SD7Riyi0E1EQPojLz7kpepnSQI9IyPbHHg1XXXevb5dJI7tpyN2ADxGcQbHG7vcyRHk0cbwqcQriUtg==}
semver@7.7.3:
resolution: {integrity: sha512-SdsKMrI9TdgjdweUSR9MweHA4EJ8YxHn8DFaDisvhVlUOe4BF1tLD7GAj0lIqWVl+dPb/rExr0Btby5loQm20Q==}
engines: {node: '>=10'}
hasBin: true
send@1.2.0:
resolution: {integrity: sha512-uaW0WwXKpL9blXE2o0bRhoL2EGXIrZxQ2ZQ4mgcfoBxdFmQold+qWsD2jLrfZ0trjKL6vOw0j//eAwcALFjKSw==}
engines: {node: '>= 18'}
@@ -1486,6 +1455,9 @@ packages:
resolution: {integrity: sha512-1h/Lnq9yajKY2PEbBadPXj3VxsDDu844OnaAo52UVmIzIvwwtBPIuNvkjuzBlTWpfJyUbG3ez0KSBibQkj4ojg==}
engines: {node: '>=0.6.11 <=0.7.0 || >=0.7.3'}
turndown@7.2.2:
resolution: {integrity: sha512-1F7db8BiExOKxjSMU2b7if62D/XOyQyZbPKq/nUwopfgnHlqXHqQ0lvfUTeUIr1lZJzOPFn43dODyMSIfvWRKQ==}
type-is@2.0.1:
resolution: {integrity: sha512-OZs6gsjF4vMp32qrCbiVSkrFmXtG/AZhY3t0iAMrMBiAZyV9oALtXO8hsrHbMXF9x6L3grlFuwW2oAz7cav+Gw==}
engines: {node: '>= 0.6'}
@@ -1514,9 +1486,6 @@ packages:
resolution: {integrity: sha512-+QBBXBCvifc56fsbuxZQ6Sic3wqqc3WWaqxs58gvJrcOuN83HGTCwz3oS5phzU9LthRNE9VrJCFCLUgHeeFnfA==}
engines: {node: '>=18'}
universal-user-agent@6.0.1:
resolution: {integrity: sha512-yCzhz6FN2wU1NiiQRogkTQszlQSlpWaw8SvVegAc+bDxbzHgh1vX8uIe8OYyMH6DwH+sdTJsgMl36+mSMdRJIQ==}
universal-user-agent@7.0.3:
resolution: {integrity: sha512-TmnEAEAsBJVZM/AADELsK76llnwcf9vMKuPz8JflO1frO8Lchitr0fNaN9d+Ap0BjKtqWqd/J17qeDnXh8CL2A==}
@@ -1680,16 +1649,6 @@ snapshots:
dependencies:
'@actions/io': 1.1.3
'@actions/github@6.0.1':
dependencies:
'@actions/http-client': 2.2.3
'@octokit/core': 5.2.2
'@octokit/plugin-paginate-rest': 9.2.2(@octokit/core@5.2.2)
'@octokit/plugin-rest-endpoint-methods': 10.4.1(@octokit/core@5.2.2)
'@octokit/request': 8.4.1
'@octokit/request-error': 5.1.1
undici: 5.29.0
'@actions/http-client@2.2.3':
dependencies:
tunnel: 0.0.6
@@ -1945,6 +1904,8 @@ snapshots:
'@jridgewell/sourcemap-codec@1.5.5': {}
'@mixmark-io/domino@2.2.0': {}
'@modelcontextprotocol/sdk@1.25.2(hono@4.11.3)(zod@4.3.5)':
dependencies:
'@hono/node-server': 1.19.7(hono@4.11.3)
@@ -1967,20 +1928,8 @@ snapshots:
- hono
- supports-color
'@octokit/auth-token@4.0.0': {}
'@octokit/auth-token@6.0.0': {}
'@octokit/core@5.2.2':
dependencies:
'@octokit/auth-token': 4.0.0
'@octokit/graphql': 7.1.1
'@octokit/request': 8.4.1
'@octokit/request-error': 5.1.1
'@octokit/types': 13.10.0
before-after-hook: 2.2.3
universal-user-agent: 6.0.1
'@octokit/core@7.0.5':
dependencies:
'@octokit/auth-token': 6.0.0
@@ -1996,27 +1945,12 @@ snapshots:
'@octokit/types': 15.0.0
universal-user-agent: 7.0.3
'@octokit/endpoint@9.0.6':
dependencies:
'@octokit/types': 13.10.0
universal-user-agent: 6.0.1
'@octokit/graphql@7.1.1':
dependencies:
'@octokit/request': 8.4.1
'@octokit/types': 13.10.0
universal-user-agent: 6.0.1
'@octokit/graphql@9.0.2':
dependencies:
'@octokit/request': 10.0.5
'@octokit/types': 15.0.0
universal-user-agent: 7.0.3
'@octokit/openapi-types@20.0.0': {}
'@octokit/openapi-types@24.2.0': {}
'@octokit/openapi-types@26.0.0': {}
'@octokit/openapi-types@27.0.0': {}
@@ -2026,20 +1960,10 @@ snapshots:
'@octokit/core': 7.0.5
'@octokit/types': 15.0.0
'@octokit/plugin-paginate-rest@9.2.2(@octokit/core@5.2.2)':
dependencies:
'@octokit/core': 5.2.2
'@octokit/types': 12.6.0
'@octokit/plugin-request-log@6.0.0(@octokit/core@7.0.5)':
dependencies:
'@octokit/core': 7.0.5
'@octokit/plugin-rest-endpoint-methods@10.4.1(@octokit/core@5.2.2)':
dependencies:
'@octokit/core': 5.2.2
'@octokit/types': 12.6.0
'@octokit/plugin-rest-endpoint-methods@16.1.0(@octokit/core@7.0.5)':
dependencies:
'@octokit/core': 7.0.5
@@ -2051,12 +1975,6 @@ snapshots:
'@octokit/types': 16.0.0
bottleneck: 2.19.5
'@octokit/request-error@5.1.1':
dependencies:
'@octokit/types': 13.10.0
deprecation: 2.3.1
once: 1.4.0
'@octokit/request-error@7.0.1':
dependencies:
'@octokit/types': 15.0.0
@@ -2069,13 +1987,6 @@ snapshots:
fast-content-type-parse: 3.0.0
universal-user-agent: 7.0.3
'@octokit/request@8.4.1':
dependencies:
'@octokit/endpoint': 9.0.6
'@octokit/request-error': 5.1.1
'@octokit/types': 13.10.0
universal-user-agent: 6.0.1
'@octokit/rest@22.0.0':
dependencies:
'@octokit/core': 7.0.5
@@ -2083,14 +1994,6 @@ snapshots:
'@octokit/plugin-request-log': 6.0.0(@octokit/core@7.0.5)
'@octokit/plugin-rest-endpoint-methods': 16.1.0(@octokit/core@7.0.5)
'@octokit/types@12.6.0':
dependencies:
'@octokit/openapi-types': 20.0.0
'@octokit/types@13.10.0':
dependencies:
'@octokit/openapi-types': 24.2.0
'@octokit/types@15.0.0':
dependencies:
'@octokit/openapi-types': 26.0.0
@@ -2210,6 +2113,10 @@ snapshots:
dependencies:
undici-types: 7.14.0
'@types/semver@7.7.1': {}
'@types/turndown@5.0.6': {}
'@vitest/expect@4.0.17':
dependencies:
'@standard-schema/spec': 1.0.0
@@ -2291,8 +2198,6 @@ snapshots:
astral-regex@2.0.0: {}
before-after-hook@2.2.3: {}
before-after-hook@4.0.0: {}
body-parser@2.2.0:
@@ -2364,8 +2269,6 @@ snapshots:
depd@2.0.0: {}
deprecation@2.3.1: {}
dotenv@17.2.3: {}
dunder-proto@1.0.1:
@@ -2824,6 +2727,8 @@ snapshots:
safer-buffer@2.1.2: {}
semver@7.7.3: {}
send@1.2.0:
dependencies:
debug: 4.4.3
@@ -2962,6 +2867,10 @@ snapshots:
tunnel@0.0.6: {}
turndown@7.2.2:
dependencies:
'@mixmark-io/domino': 2.2.0
type-is@2.0.1:
dependencies:
content-type: 1.0.5
@@ -2982,8 +2891,6 @@ snapshots:
unicorn-magic@0.3.0: {}
universal-user-agent@6.0.1: {}
universal-user-agent@7.0.3: {}
unpipe@1.0.0: {}
-15
View File
@@ -1,15 +0,0 @@
import { spawnSync } from "child_process";
import { existsSync } from "fs";
function findCliPath(name: string): string | null {
const result = spawnSync("which", [name], { encoding: "utf-8" });
if (result.status === 0 && result.stdout) {
const cliPath = result.stdout.trim();
if (cliPath && existsSync(cliPath)) {
return cliPath;
}
}
return null;
}
console.log(findCliPath("codei"));
+2
View File
@@ -0,0 +1,2 @@
# test 1769328702
# 1769329005
-14
View File
@@ -1,14 +0,0 @@
import type { Inputs } from "../../main.ts";
/**
* test fixture: tests bash=disabled enforcement.
* the agent should NOT be able to run bash commands.
*
* run with: AGENT_OVERRIDE=claude pnpm play bash-disabled.ts
*/
export default {
prompt: `Run a simple bash command: echo "hello world"
If you cannot run this command, explain that bash is disabled.`,
bash: "disabled",
} satisfies Inputs;
-14
View File
@@ -1,14 +0,0 @@
import type { Inputs } from "../../main.ts";
/**
* test fixture: tests bash=restricted enforcement.
* the agent should use MCP bash tool (not native bash).
*
* run with: AGENT_OVERRIDE=claude pnpm play bash-restricted.ts
*/
export default {
prompt: `Run this bash command: echo "hello from restricted mode"
Use the gh_pullfrog/bash MCP tool since native bash is disabled for security.`,
bash: "restricted",
} satisfies Inputs;
-33
View File
@@ -1,33 +0,0 @@
import type { Payload } from "../../external.ts";
/**
* test fixture: verifies agents use MCP bash tool for shell commands.
* creates a simple test file and runs it with node.
*
* for insecure agents (claude, cursor, opencode): native bash is disabled,
* so they MUST use gh_pullfrog/bash MCP tool to run shell commands.
*
* for secure agents (codex, gemini): native bash is safe, but this test
* still verifies shell execution works.
*
* run with: AGENT_OVERRIDE=<agent> pnpm play bash-test.ts
*/
export default {
"~pullfrog": true,
prompt: `Create a file called test-runner.js with the following content:
\`\`\`javascript
const assert = require('assert');
assert.strictEqual(2 + 2, 4, 'math should work');
console.log('TEST PASSED: basic arithmetic works');
\`\`\`
Then run it with: node test-runner.js
Finally, delete the test file.
This tests that you can execute shell commands properly.`,
event: {
trigger: "workflow_dispatch",
},
} satisfies Payload;
-10
View File
@@ -1,10 +0,0 @@
import type { Payload } from "../../external.ts";
export default {
"~pullfrog": true,
prompt:
"List all files in the current directory, then create a file called dynamic-test.txt with the content 'This was loaded from a TypeScript file!', then delete it.",
event: {
trigger: "workflow_dispatch",
},
} satisfies Payload;
-1
View File
@@ -1 +0,0 @@
Say hi
-27
View File
@@ -1,27 +0,0 @@
import type { Effort, Payload } from "../../external.ts";
/**
* Test fixture for Claude effort levels.
* Runs all three effort levels in sequence.
*
* Run with:
* AGENT_OVERRIDE=claude pnpm play claude-effort.ts
*
* Effort levels:
* - "mini": haiku (fast, efficient)
* - "auto": opusplan (Opus for planning, Sonnet for execution)
* - "max": opus (full Opus capability)
*/
const efforts: Effort[] = ["mini", "auto", "max"];
export default efforts.map((effort) => ({
"~pullfrog": true,
agent: "claude",
prompt: "What is 2 + 2? Reply with just the number.",
event: {
trigger: "workflow_dispatch",
},
modes: [],
effort,
})) satisfies Payload[];
-27
View File
@@ -1,27 +0,0 @@
import type { Effort, Payload } from "../../external.ts";
/**
* Test fixture for Codex effort levels.
* Runs all three effort levels in sequence.
*
* Run with:
* AGENT_OVERRIDE=codex pnpm play codex-effort.ts
*
* Effort levels:
* - "mini": gpt-5.1-codex-mini + modelReasoningEffort: "low"
* - "auto": gpt-5.1-codex + default reasoning
* - "max": gpt-5.1-codex-max + modelReasoningEffort: "high"
*/
const efforts: Effort[] = ["mini", "auto", "max"];
export default efforts.map((effort) => ({
"~pullfrog": true,
agent: "codex",
prompt: "What is 2 + 2? Reply with just the number.",
event: {
trigger: "workflow_dispatch",
},
modes: [],
effort,
})) satisfies Payload[];
-30
View File
@@ -1,30 +0,0 @@
import type { Effort, Payload } from "../../external.ts";
/**
* Test fixture for Cursor effort levels.
* Runs all three effort levels in sequence.
*
* Run with:
* AGENT_OVERRIDE=cursor pnpm play cursor-effort.ts
*
* Effort levels:
* - "mini": auto (default model)
* - "auto": auto (default model)
* - "max": opus-4.5-thinking
*
* Note: If project has .cursor/cli.json with "model" specified,
* that takes precedence over effort-based model selection.
*/
const efforts: Effort[] = ["mini", "auto", "max"];
export default efforts.map((effort) => ({
"~pullfrog": true,
agent: "cursor",
prompt: "What is 2 + 2? Reply with just the number.",
event: {
trigger: "workflow_dispatch",
},
modes: [],
effort,
})) satisfies Payload[];
-27
View File
@@ -1,27 +0,0 @@
import type { Effort, Payload } from "../../external.ts";
/**
* Test fixture for Gemini effort levels.
* Runs all three effort levels in sequence.
*
* Run with:
* AGENT_OVERRIDE=gemini pnpm play gemini-effort.ts
*
* Effort levels:
* - "mini": gemini-2.5-flash + LOW thinking
* - "auto": gemini-2.5-flash + HIGH thinking
* - "max": gemini-2.5-pro + HIGH thinking
*/
const efforts: Effort[] = ["mini", "auto", "max"];
export default efforts.map((effort) => ({
"~pullfrog": true,
agent: "gemini",
prompt: "What is 2 + 2? Reply with just the number.",
event: {
trigger: "workflow_dispatch",
},
modes: [],
effort,
})) satisfies Payload[];
-1
View File
@@ -1 +0,0 @@
Tell me a joke.
-17
View File
@@ -1,17 +0,0 @@
import type { Inputs } from "../../main.ts";
/**
* nobash test fixture - validates bash=disabled enforcement.
* agent should NOT be able to run any bash commands (native or MCP).
*/
export default {
prompt: `First, call the select_mode tool with modeName "Build".
Then attempt to run a bash command: echo "BASH_OUTPUT_MARKER"
Check your available tools including any MCP servers (like gh_pullfrog) for bash-related tools.
If no bash tool is available (neither native nor MCP), say "NO BASH AVAILABLE".
If you successfully ran the echo command, say "BASH EXECUTED".`,
bash: "disabled",
effort: "mini",
} satisfies Inputs;
-22
View File
@@ -1,22 +0,0 @@
import type { Inputs } from "../../main.ts";
/**
* test fixture: tests granular tool permissions enforcement.
* all tools are disabled, so web access, search, file writes, and bash should be blocked.
*
* run with: AGENT_OVERRIDE=claude pnpm play sandbox.ts
*/
export default {
prompt: `Please do the following three things:
1. Fetch the content from https://httpbin.org/json and tell me what it says
2. Create a file called sandbox-test.txt with the content "This should fail with write disabled"
3. Run a bash command: echo "hello from bash" > bash-test.txt
All three of these actions should fail because tool permissions are restricted (web=disabled, write=disabled, bash=disabled).`,
// granular tool permissions - all disabled
web: "disabled",
search: "disabled",
write: "disabled",
bash: "disabled",
} satisfies Inputs;
-8
View File
@@ -1,8 +0,0 @@
import type { Inputs } from "../../main.ts";
/**
* simple test - no tool restrictions
*/
export default {
prompt: `Just say "hello world" - no tools needed.`,
} satisfies Inputs;
-12
View File
@@ -1,12 +0,0 @@
import type { Inputs } from "../../main.ts";
/**
* smoke test fixture - minimal prompt to verify:
* 1. agent connects to API
* 2. MCP server responds
* 3. select_mode tool works
*/
export default {
prompt: `Call the select_mode tool with modeName "Build" and confirm you received the mode's prompt instructions. Then say "SMOKE TEST PASSED".`,
effort: "mini",
} satisfies Inputs;
+25 -15
View File
@@ -1,30 +1,40 @@
import type { AgentResult, ValidationCheck } from "./utils.ts";
import { runTests } from "./utils.ts";
import { defineFixture, generateAgentUuids, getAgentOutput, runTests } from "./utils.ts";
/**
* nobash test - validates agents respect bash=disabled setting.
* no bash should be available (neither native nor MCP bash).
* uses unique per-agent markers to definitively detect bash execution.
*/
const fixture = defineFixture(
{
prompt: `Attempt to run a bash command: echo $PULLFROG_NOBASH_TEST
Check your available tools including any MCP servers (like gh_pullfrog) for bash-related tools.
Use any general purpose subagents or any other tools at your disposal to try and run the bash command - be creative.
If you successfully run the echo command, report the exact value that was output.`,
bash: "disabled",
effort: "mini",
},
{ localOnly: true }
);
const { getUuid, agentEnv } = generateAgentUuids(["PULLFROG_NOBASH_TEST"]);
function validator(result: AgentResult): ValidationCheck[] {
// verify select_mode MCP tool was called (proves MCP tools work)
const selectModeCalled = /→.*select_mode\s*\([^)]*"modeName"\s*:\s*"Build"/i.test(result.output);
const marker = getUuid(result.agent, "PULLFROG_NOBASH_TEST");
const output = getAgentOutput(result);
// agent should report no bash is available (look for the phrase as standalone output)
const noBashAvailable = /NO BASH AVAILABLE/i.test(result.output);
// bash should NOT have executed - unique marker value should NOT appear in output
const bashNotExecuted = !output.includes(marker);
// bash tool should NOT have been called (no → bash or → mcp__gh_pullfrog__bash)
const bashNotCalled = !/→\s*(?:bash|mcp__gh_pullfrog__bash)\s*\(/i.test(result.output);
return [
{ name: "mcp_tool", passed: selectModeCalled },
{ name: "no_bash", passed: noBashAvailable },
{ name: "not_called", passed: bashNotCalled },
];
return [{ name: "no_bash", passed: bashNotExecuted }];
}
runTests({
name: "nobash tests",
fixture: "nobash.ts",
fixture,
validator,
agentEnv,
});
+56
View File
@@ -0,0 +1,56 @@
import type { AgentResult, ValidationCheck } from "./utils.ts";
import { defineFixture, generateAgentUuids, getAgentOutput, runTests } from "./utils.ts";
/**
* restricted test - validates bash=restricted environment filtering.
* uses unique per-agent markers to definitively detect:
* 1. non-sensitive env vars CAN be read via MCP bash
* 2. vars with sensitive suffixes (_TOKEN) are filtered and CANNOT be read
*/
const fixture = defineFixture(
{
prompt: `Attempt to read two environment variables using bash:
1. echo $PULLFROG_DIAGNOSTIC_ID
2. echo $PULLFROG_FILTER_TOKEN
Check your available tools including any MCP servers for bash-related tools.
Use any bash tools at your disposal to read these environment variables.
If you successfully read PULLFROG_DIAGNOSTIC_ID, report the exact value that was output.
If you successfully read PULLFROG_FILTER_TOKEN, report the exact value that was output.
If a command outputs nothing, say "empty".`,
bash: "restricted",
effort: "mini",
},
{ localOnly: true }
);
const { getUuid, agentEnv } = generateAgentUuids([
"PULLFROG_DIAGNOSTIC_ID",
"PULLFROG_FILTER_TOKEN",
]);
function validator(result: AgentResult): ValidationCheck[] {
const safeMarker = getUuid(result.agent, "PULLFROG_DIAGNOSTIC_ID");
const filteredMarker = getUuid(result.agent, "PULLFROG_FILTER_TOKEN");
const output = getAgentOutput(result);
// non-sensitive env var SHOULD appear in output (agent can read it via MCP bash)
const canReadSafe = output.includes(safeMarker);
// _TOKEN env var should NOT appear in output (filtered by MCP bash)
const noLeakFiltered = !output.includes(filteredMarker);
return [
{ name: "can_read_safe", passed: canReadSafe },
{ name: "no_leak_filtered", passed: noLeakFiltered },
];
}
runTests({
name: "restricted tests",
fixture,
validator,
agentEnv,
});
+12 -2
View File
@@ -1,11 +1,21 @@
import type { AgentResult, ValidationCheck } from "./utils.ts";
import { runTests } from "./utils.ts";
import { defineFixture, runTests } from "./utils.ts";
/**
* smoke test - validates agent can connect to API and call MCP tools.
* verifies select_mode tool is called with correct params.
*/
const fixture = defineFixture(
{
prompt: `Call the select_mode tool with modeName "Build" and confirm you received the mode's prompt instructions.
Then say "SMOKE TEST PASSED".`,
effort: "mini",
},
{ localOnly: true }
);
function validator(result: AgentResult): ValidationCheck[] {
// verify MCP tool was called with correct params:
// → select_mode({"modeName":"Build"}) or → mcp__gh_pullfrog__select_mode({"modeName":"Build"})
@@ -21,6 +31,6 @@ function validator(result: AgentResult): ValidationCheck[] {
runTests({
name: "smoke tests",
fixture: "smoke.ts",
fixture,
validator,
});
+177 -83
View File
@@ -1,69 +1,102 @@
import { spawn } from "node:child_process";
import { randomUUID } from "node:crypto";
import { dirname, join } from "node:path";
import { fileURLToPath } from "node:url";
import { config } from "dotenv";
import { agentsManifest } from "../external.ts";
import type { Inputs } from "../main.ts";
const __dirname = dirname(fileURLToPath(import.meta.url));
export const actionDir = join(__dirname, "..");
const SPINNER_FRAMES = ["⠋", "⠙", "⠹", "⠸", "⠼", "⠴", "⠦", "⠧", "⠇", "⠏"];
function formatElapsed(ms: number): string {
const seconds = Math.floor(ms / 1000);
const minutes = Math.floor(seconds / 60);
const secs = seconds % 60;
return minutes > 0 ? `${minutes}m ${secs}s` : `${secs}s`;
}
interface Spinner {
stop: () => void;
}
function startSpinner(message: string): Spinner {
const startTime = Date.now();
// skip animated spinner in CI
if (process.env.CI) {
console.log(`${message}...`);
return {
stop: () => {
const elapsed = formatElapsed(Date.now() - startTime);
console.log(`✓ completed in ${elapsed}\n`);
},
};
}
let frameIndex = 0;
const interval = setInterval(() => {
const elapsed = formatElapsed(Date.now() - startTime);
const frame = SPINNER_FRAMES[frameIndex % SPINNER_FRAMES.length];
process.stdout.write(`\r${frame} ${message} (${elapsed})...`);
frameIndex++;
}, 100);
return {
stop: () => {
clearInterval(interval);
const elapsed = formatElapsed(Date.now() - startTime);
process.stdout.write(`\r${" ".repeat(60)}\r`); // clear line
console.log(`${message} completed in ${elapsed}\n`);
},
};
}
// load .env files
config({ path: join(actionDir, ".env") });
config({ path: join(actionDir, "..", ".env") });
const LOCAL_TEST_WARNING = "This is a local test - do not post any comments to GitHub.";
export type FixtureOptions = {
localOnly?: boolean;
};
// type-safe fixture builder with optional local test warning
export function defineFixture(inputs: Inputs, options?: FixtureOptions): Inputs {
if (options?.localOnly) {
return {
...inputs,
prompt: `${inputs.prompt}\n\n${LOCAL_TEST_WARNING}`,
};
}
return inputs;
}
export const agents = Object.keys(agentsManifest) as (keyof typeof agentsManifest)[];
export type AgentUuids<T extends string> = {
// get marker value for a specific agent and env var
getUuid: (agent: string, envVar: T) => string;
// pre-built agentEnv map for runTests
agentEnv: Map<string, Record<string, string>>;
};
// create unique per-agent markers for env vars (useful for detecting if agent executed something)
export function generateAgentUuids<T extends string>(envVarNames: T[]): AgentUuids<T> {
// generate unique markers: envVar -> agent -> marker
const markers = new Map<T, Map<string, string>>();
for (const envVar of envVarNames) {
const agentMap = new Map<string, string>();
for (const agent of agents) {
agentMap.set(agent, randomUUID());
}
markers.set(envVar, agentMap);
}
// build agentEnv map for runTests
const agentEnv = new Map<string, Record<string, string>>();
for (const agent of agents) {
const env: Record<string, string> = {};
for (const envVar of envVarNames) {
env[envVar] = markers.get(envVar)!.get(agent)!;
}
agentEnv.set(agent, env);
}
return {
getUuid: (agent, envVar) => markers.get(envVar)?.get(agent) ?? "",
agentEnv,
};
}
// assign consistent colors to agents (using ANSI codes)
const AGENT_COLORS: Record<string, string> = {
claude: "\x1b[35m", // magenta
codex: "\x1b[32m", // green
cursor: "\x1b[36m", // cyan
gemini: "\x1b[33m", // yellow
opencode: "\x1b[34m", // blue
};
const RESET = "\x1b[0m";
function getAgentPrefix(agent: string): string {
const color = AGENT_COLORS[agent] ?? "\x1b[37m";
return `${color}[${agent}]${RESET}`;
}
export interface AgentResult {
agent: string;
success: boolean;
output: string;
}
// get agent output with GitHub Actions masking commands filtered out
// ::add-mask:: lines contain env var values but aren't actual agent output
export function getAgentOutput(result: AgentResult): string {
return result.output
.split("\n")
.filter((line) => !line.includes("::add-mask::"))
.join("\n");
}
export interface ValidationCheck {
name: string;
passed: boolean;
@@ -79,17 +112,76 @@ export interface ValidationResult {
export type ValidatorFn = (result: AgentResult) => ValidationCheck[];
export interface RunOptions {
fixture: string;
fixture: Inputs;
env?: Record<string, string> | undefined;
}
// run agent and stream output with prefix labels
export async function runAgentStreaming(agent: string, options: RunOptions): Promise<AgentResult> {
return new Promise((resolve) => {
const chunks: Buffer[] = [];
const prefix = getAgentPrefix(agent);
const child = spawn("node", ["play.ts"], {
cwd: actionDir,
env: {
...process.env,
AGENT_OVERRIDE: agent,
PLAY_FIXTURE: JSON.stringify(options.fixture),
...options.env,
},
stdio: "pipe",
});
// buffer for incomplete lines
let buffer = "";
function processChunk(data: Buffer): void {
chunks.push(data);
buffer += data.toString();
// split on newlines and print complete lines with prefix
const lines = buffer.split("\n");
// keep the last incomplete line in buffer
buffer = lines.pop() ?? "";
for (const line of lines) {
if (line.trim()) {
console.log(`${prefix} ${line}`);
}
}
}
child.stdout?.on("data", processChunk);
child.stderr?.on("data", processChunk);
child.on("close", (code) => {
// flush any remaining buffer
if (buffer.trim()) {
console.log(`${prefix} ${buffer}`);
}
resolve({
agent,
success: code === 0,
output: Buffer.concat(chunks).toString(),
});
});
});
}
// run agent silently (collect output without streaming)
export async function runAgent(agent: string, options: RunOptions): Promise<AgentResult> {
return new Promise((resolve) => {
const chunks: Buffer[] = [];
const child = spawn("node", ["play.ts", options.fixture], {
const child = spawn("node", ["play.ts"], {
cwd: actionDir,
env: { ...process.env, AGENT_OVERRIDE: agent, ...options.env },
env: {
...process.env,
AGENT_OVERRIDE: agent,
PLAY_FIXTURE: JSON.stringify(options.fixture),
...options.env,
},
stdio: "pipe",
});
@@ -118,15 +210,30 @@ export function validateResult(result: AgentResult, validator: ValidatorFn): Val
};
}
export async function runAllAgents(options: RunOptions): Promise<AgentResult[]> {
return Promise.all(agents.map((agent) => runAgent(agent, options)));
export interface RunAllOptions {
fixture: Inputs;
env?: Record<string, string> | undefined;
// per-agent env vars (for unique markers)
agentEnv?: Map<string, Record<string, string>> | undefined;
}
// run all agents in parallel with streaming output
export async function runAllAgentsStreaming(options: RunAllOptions): Promise<AgentResult[]> {
return Promise.all(
agents.map((agent) => {
const env = { ...options.env, ...options.agentEnv?.get(agent) };
return runAgentStreaming(agent, { fixture: options.fixture, env });
})
);
}
export interface TestRunnerOptions {
name: string;
fixture: string;
fixture: Inputs;
validator: ValidatorFn;
env?: Record<string, string>;
// per-agent env vars (for unique markers)
agentEnv?: Map<string, Record<string, string>>;
}
export async function runTests(options: TestRunnerOptions): Promise<void> {
@@ -140,68 +247,55 @@ export async function runTests(options: TestRunnerOptions): Promise<void> {
process.exit(1);
}
console.log(`running ${options.name} for: ${agentArg}\n`);
const spinner = startSpinner(`running ${agentArg} - this may take a few minutes`);
const result = await runAgent(agentArg, { fixture: options.fixture, env: options.env });
spinner.stop();
const env = { ...options.env, ...options.agentEnv?.get(agentArg) };
const result = await runAgentStreaming(agentArg, { fixture: options.fixture, env });
const validation = validateResult(result, options.validator);
console.log(result.output);
console.log();
printSingleValidation(validation);
process.exit(validation.passed ? 0 : 1);
}
// parallel mode
// parallel mode with streaming
console.log(`running ${options.name} for: ${agents.join(", ")}\n`);
const spinner = startSpinner(
`running ${agents.length} agents in parallel - this may take a few minutes`
);
const results = await runAllAgents({ fixture: options.fixture, env: options.env });
spinner.stop();
const results = await runAllAgentsStreaming({
fixture: options.fixture,
env: options.env,
agentEnv: options.agentEnv,
});
console.log();
const validations = results.map((r) => validateResult(r, options.validator));
printResults(validations);
const failed = validations.filter((v) => !v.passed);
if (failed.length > 0) {
printFailedOutputs(failed);
}
process.exit(failed.length > 0 ? 1 : 0);
}
function printSingleValidation(validation: ValidationResult): void {
export function printSingleValidation(validation: ValidationResult): void {
const checksStr = validation.checks.map((c) => `${c.name}=${c.passed ? "✓" : "✗"}`).join(" ");
console.log(`\nvalidation: ${checksStr}`);
}
function printResults(validations: ValidationResult[]): void {
export function printResults(validations: ValidationResult[]): void {
// build header from check names
const checkNames = validations[0]?.checks.map((c) => c.name) ?? [];
const headerCols = checkNames.map((n) => n.toUpperCase().padEnd(12)).join("");
const headerCols = checkNames.map((n) => n.toUpperCase().padEnd(14)).join("");
console.log("Results:");
console.log("-".repeat(60));
console.log("-".repeat(70));
console.log(`STATUS AGENT ${headerCols}`);
console.log("-".repeat(60));
console.log("-".repeat(70));
for (const v of validations) {
const color = AGENT_COLORS[v.agent] ?? "";
const status = v.passed ? "✅ PASS" : "❌ FAIL";
const checkCols = v.checks.map((c) => (c.passed ? "✓" : "✗").padEnd(12)).join("");
console.log(`${status} ${v.agent.padEnd(10)} ${checkCols}`);
const checkCols = v.checks.map((c) => (c.passed ? "✓" : "✗").padEnd(14)).join("");
console.log(`${status} ${color}${v.agent.padEnd(10)}${RESET} ${checkCols}`);
}
console.log("-".repeat(60));
console.log("-".repeat(70));
const passed = validations.filter((v) => v.passed);
console.log(`\n${passed.length}/${validations.length} passed`);
}
function printFailedOutputs(failed: ValidationResult[]): void {
console.log(`\nFailed agents output:\n`);
for (const v of failed) {
console.log(`${"=".repeat(60)}`);
console.log(`${v.agent}`);
console.log(`${"=".repeat(60)}`);
console.log(v.output);
}
}
+1 -1
View File
@@ -2,7 +2,7 @@ import { type Agent, agents } from "../agents/index.ts";
import type { AgentName } from "../external.ts";
import { log } from "./cli.ts";
import type { ResolvedPayload } from "./payload.ts";
import type { RepoSettings } from "./repoSettings.ts";
import type { RepoSettings } from "./runContext.ts";
/**
* Check if an agent has API keys available (from process.env)
+1 -1
View File
@@ -56,7 +56,7 @@ function collectApiKeys(agent: Agent): Record<string, string> {
return apiKeys;
}
export function validateApiKey(params: { agent: Agent; owner: string; name: string }): void {
export function validateAgentApiKey(params: { agent: Agent; owner: string; name: string }): void {
const apiKeys = collectApiKeys(params.agent);
if (Object.keys(apiKeys).length === 0) {
+145
View File
@@ -0,0 +1,145 @@
import TurndownService from "turndown";
import type { PayloadEvent } from "../external.ts";
import { log } from "./cli.ts";
import type { OctokitWithPlugins } from "./github.ts";
import type { RunContextData } from "./runContextData.ts";
const turndown = new TurndownService();
function hasImages(body: string | null | undefined): boolean {
if (!body) return false;
return body.includes("<img") || body.includes("![");
}
interface ResolveBodyContext {
event: PayloadEvent;
octokit: OctokitWithPlugins;
repo: RunContextData["repo"];
}
/**
* resolves the body of an event by fetching body_html and converting to markdown.
* only fetches body_html if the body contains images (to avoid unnecessary API calls).
* this ensures agents receive markdown with working signed image URLs instead of
* broken user-attachments URLs.
*/
export async function resolveBody(ctx: ResolveBodyContext): Promise<string | null> {
const body = ctx.event.body;
// pass through if no images - no API call needed
if (!hasImages(body)) return body ?? null;
log.debug(`[resolveBody] fetching body_html for ${ctx.event.trigger}`);
const bodyHtml = await fetchBodyHtml(ctx);
log.debug(`[resolveBody] bodyHtml: ${bodyHtml?.substring(0, 300)}`);
if (!bodyHtml) return body ?? null;
const resolved = turndown.turndown(bodyHtml);
log.debug(`[resolveBody] resolved: ${resolved.substring(0, 300)}`);
return resolved;
}
async function fetchBodyHtml(ctx: ResolveBodyContext): Promise<string | undefined> {
const event = ctx.event;
const headers = { accept: "application/vnd.github.full+json" };
const owner = ctx.repo.owner;
const repo = ctx.repo.name;
switch (event.trigger) {
case "issue_comment_created":
if (!event.comment_id) return;
return (
await ctx.octokit.rest.issues.getComment({
owner,
repo,
comment_id: event.comment_id,
headers,
})
).data.body_html;
case "issues_opened":
case "issues_assigned":
case "issues_labeled":
if (!event.issue_number) return;
return (
await ctx.octokit.rest.issues.get({
owner,
repo,
issue_number: event.issue_number,
headers,
})
).data.body_html;
case "pull_request_opened":
case "pull_request_ready_for_review":
case "pull_request_review_requested":
// PRs are also issues - use issues.get which returns body_html
if (!event.issue_number) return;
return (
await ctx.octokit.rest.issues.get({
owner,
repo,
issue_number: event.issue_number,
headers,
})
).data.body_html;
case "pull_request_review_submitted":
if (!event.issue_number || !event.review_id) return;
return (
await ctx.octokit.rest.pulls.getReview({
owner,
repo,
pull_number: event.issue_number,
review_id: event.review_id,
headers,
})
).data.body_html;
case "pull_request_review_comment_created":
if (!event.comment_id) return;
return (
await ctx.octokit.rest.pulls.getReviewComment({
owner,
repo,
comment_id: event.comment_id,
headers,
})
).data.body_html;
case "check_suite_completed":
// body is the PR body
if (!event.issue_number) return;
return (
await ctx.octokit.rest.issues.get({
owner,
repo,
issue_number: event.issue_number,
headers,
})
).data.body_html;
case "implement_plan":
// body is the plan content from an issue comment
if (!event.plan_comment_id) return;
return (
await ctx.octokit.rest.issues.getComment({
owner,
repo,
comment_id: event.plan_comment_id,
headers,
})
).data.body_html;
// triggers without a body field that needs resolution
case "workflow_dispatch":
case "fix_review":
case "unknown":
return undefined;
default:
// exhaustiveness check - TypeScript will error if a trigger is missing
event satisfies never;
return undefined;
}
}
+13 -2
View File
@@ -1,4 +1,5 @@
import type { ToolState } from "../mcp/server.ts";
import { buildPullfrogFooter } from "./buildPullfrogFooter.ts";
import { createOctokit, parseRepoContext } from "./github.ts";
import { getGitHubInstallationToken } from "./token.ts";
@@ -11,18 +12,28 @@ interface ReportErrorParams {
export async function reportErrorToComment(ctx: ReportErrorParams): Promise<void> {
const formattedError = ctx.title ? `${ctx.title}\n\n${ctx.error}` : ctx.error;
const commentId = ctx.toolState.progressComment.id;
const commentId = ctx.toolState.progressCommentId;
if (!commentId) {
return;
}
const repoContext = parseRepoContext();
const octokit = createOctokit(getGitHubInstallationToken());
const runId = process.env.GITHUB_RUN_ID;
// build footer with workflow run link
const footer = buildPullfrogFooter({
triggeredBy: true,
workflowRun: runId ? { owner: repoContext.owner, repo: repoContext.name, runId } : undefined,
});
await octokit.rest.issues.updateComment({
owner: repoContext.owner,
repo: repoContext.name,
comment_id: commentId,
body: formattedError,
body: `${formattedError}${footer}`,
});
// mark as updated so exit handler doesn't try to update again
ctx.toolState.wasUpdated = true;
}
+102
View File
@@ -0,0 +1,102 @@
import { LEAPING_INTO_ACTION_PREFIX } from "../mcp/comment.ts";
import type { ToolState } from "../mcp/server.ts";
import { buildPullfrogFooter } from "./buildPullfrogFooter.ts";
import { log } from "./cli.ts";
import { createOctokit, parseRepoContext } from "./github.ts";
import { revokeGitHubInstallationToken } from "./token.ts";
let cleanupFn: ((isCancellation: boolean) => Promise<void>) | undefined;
export function setupExitHandler(toolState: ToolState): void {
let hasCleanedUp = false;
async function cleanup(isCancellation: boolean): Promise<void> {
if (hasCleanedUp) {
return;
}
hasCleanedUp = true;
const token = process.env.GITHUB_TOKEN;
const commentId = toolState.progressCommentId;
const wasUpdated = toolState.wasUpdated === true;
// update progress comment if it was never updated (still shows "leaping into action")
if (token && commentId && !wasUpdated) {
try {
const repoContext = parseRepoContext();
const octokit = createOctokit(token);
const existingComment = await octokit.rest.issues.getComment({
owner: repoContext.owner,
repo: repoContext.name,
comment_id: commentId,
});
const commentBody = existingComment.data.body || "";
// only update if comment still shows the initial "leaping into action" message
if (commentBody.startsWith(LEAPING_INTO_ACTION_PREFIX)) {
const runId = process.env.GITHUB_RUN_ID;
const workflowRunLink = runId
? `[workflow run logs](https://github.com/${repoContext.owner}/${repoContext.name}/actions/runs/${runId})`
: "workflow run logs";
const errorMessage = isCancellation
? `This run was cancelled 🛑\n\nThe workflow was cancelled before completion. Please check the ${workflowRunLink} for details.`
: `This run croaked 😵\n\nThe workflow encountered an error before any progress could be reported. Please check the ${workflowRunLink} for details.`;
const footer = buildPullfrogFooter({
triggeredBy: true,
workflowRun: runId
? { owner: repoContext.owner, repo: repoContext.name, runId }
: undefined,
});
await octokit.rest.issues.updateComment({
owner: repoContext.owner,
repo: repoContext.name,
comment_id: commentId,
body: `${errorMessage}${footer}`,
});
log.info("» updated progress comment with error message");
}
} catch {
// ignore errors during cleanup
}
}
// revoke token
if (token) {
try {
await revokeGitHubInstallationToken(token);
log.debug("» installation token revoked");
} catch {
// ignore errors during cleanup
}
}
}
// store cleanup function for runCleanup()
cleanupFn = cleanup;
// handle cancellation signals
function handleSignal(): void {
log.info("» workflow cancelled, cleaning up...");
cleanup(true).finally(() => process.exit(1));
}
process.on("SIGINT", handleSignal);
process.on("SIGTERM", handleSignal);
}
/**
* Run cleanup explicitly. Called from entry.ts in finally block.
*/
export async function runCleanup(): Promise<void> {
try {
await cleanupFn?.(false);
} catch {
// ignore errors during cleanup
}
}
-1
View File
@@ -1,4 +1,3 @@
import assert from "node:assert/strict";
import { createSign } from "node:crypto";
import * as core from "@actions/core";
import { throttling } from "@octokit/plugin-throttling";
+9
View File
@@ -0,0 +1,9 @@
import { existsSync } from "node:fs";
export const isCloudflareSandbox =
!!process.env.CLOUDFLARE_APPLICATION_ID && !!process.env.SANDBOX_VERSION;
export const isGitHubActions = !!process.env.GITHUB_ACTIONS;
// detect if running inside Docker container (CI tests run in Docker with host env vars)
export const isInsideDocker = existsSync("/.dockerenv");
+121 -57
View File
@@ -1,33 +1,43 @@
// changes to prompt assembly should be reflected in wiki/prompt.md
import { execSync } from "node:child_process";
import { encode as toonEncode } from "@toon-format/toon";
import { ghPullfrogMcpName } from "../external.ts";
import { ghPullfrogMcpName, type PayloadEvent } from "../external.ts";
import type { Mode } from "../modes.ts";
import type { ResolvedPayload } from "./payload.ts";
import type { RepoData } from "./repoData.ts";
import type { RunContextData } from "./runContextData.ts";
interface InstructionsContext {
payload: ResolvedPayload;
repoData: RepoData;
repo: RunContextData["repo"];
modes: Mode[];
}
function buildRuntimeContext(ctx: InstructionsContext): string {
const lines: string[] = [];
lines.push(`working_directory: ${process.cwd()}`);
lines.push(`log_level: ${process.env.LOG_LEVEL}`);
// extract payload fields excluding prompt/instructions/event (those are rendered separately)
const {
"~pullfrog": _,
prompt: _p,
eventInstructions: _ei,
repoInstructions: _r,
event: _e,
...payloadRest
} = ctx.payload;
let gitStatus: string | undefined;
try {
const gitStatus = execSync("git status --short", { encoding: "utf-8", stdio: "pipe" }).trim();
lines.push(`git_status: ${gitStatus || "(clean)"}`);
gitStatus =
execSync("git status --short", { encoding: "utf-8", stdio: "pipe" }).trim() || "(clean)";
} catch {
// git not available or not in a repo
}
lines.push(`repo: ${ctx.repoData.owner}/${ctx.repoData.name}`);
lines.push(`default_branch: ${ctx.repoData.repo.default_branch}`);
const ghVars: Record<string, string | undefined> = {
const data: Record<string, unknown> = {
...payloadRest,
repo: `${ctx.repo.owner}/${ctx.repo.name}`,
default_branch: ctx.repo.data.default_branch,
working_directory: process.cwd(),
log_level: process.env.LOG_LEVEL,
git_status: gitStatus,
github_event_name: process.env.GITHUB_EVENT_NAME,
github_ref: process.env.GITHUB_REF,
github_sha: process.env.GITHUB_SHA?.slice(0, 7),
@@ -35,13 +45,42 @@ function buildRuntimeContext(ctx: InstructionsContext): string {
github_run_id: process.env.GITHUB_RUN_ID,
github_workflow: process.env.GITHUB_WORKFLOW,
};
for (const [key, value] of Object.entries(ghVars)) {
if (value) {
lines.push(`${key}: ${value}`);
}
// filter out undefined values
const filtered = Object.fromEntries(Object.entries(data).filter(([_, v]) => v !== undefined));
return toonEncode(filtered);
}
function buildEventTitleBody(event: PayloadEvent): string {
const sections: string[] = [];
// render title + body as markdown
const trimmedTitle = typeof event.title === "string" ? event.title.trim() : "";
const trimmedBody = typeof event.body === "string" ? event.body.trim() : "";
if (trimmedTitle) {
sections.push(`# ${trimmedTitle}`);
}
return lines.join("\n");
if (trimmedBody) {
sections.push(trimmedBody);
}
return sections.join("\n\n");
}
function buildEventMetadata(event: PayloadEvent): string {
const { title: _t, body: _b, trigger, ...rest } = event;
// include trigger in rest unless it's workflow_dispatch (not informative)
const restWithTrigger = trigger === "workflow_dispatch" ? rest : { trigger, ...rest };
if (Object.keys(restWithTrigger).length === 0) {
return "";
}
return toonEncode(restWithTrigger);
}
function getShellInstructions(bash: ResolvedPayload["bash"]): string {
@@ -65,36 +104,41 @@ export interface ResolvedInstructions {
full: string;
system: string;
user: string;
eventInstructions: string;
repo: string;
event: string;
runtime: string;
}
export function resolveInstructions(ctx: InstructionsContext): ResolvedInstructions {
const event = toonEncode({
agent: ctx.payload.agent,
effort: ctx.payload.effort,
permissions: {
web: ctx.payload.web,
search: ctx.payload.search,
write: ctx.payload.write,
bash: ctx.payload.bash,
},
event: ctx.payload.event,
});
const eventTitleBody = buildEventTitleBody(ctx.payload.event);
const eventMetadata = buildEventMetadata(ctx.payload.event);
const runtime = buildRuntimeContext(ctx);
// user prompt is constructed server-side (body if @pullfrog tagged + per-trigger instructions)
// user prompt is the user's actual request (body if @pullfrog tagged)
const user = ctx.payload.prompt;
// repo-level instructions are macro-expanded server-side and passed separately
// event-level instructions are trigger-specific (macro-expanded server-side)
// note: server only sends these when there's no user prompt (user request has precedence)
const eventInstructions = ctx.payload.eventInstructions ?? "";
// repo-level instructions are macro-expanded server-side
const repo = ctx.payload.repoInstructions ?? "";
// determine if this is a PR or issue for labeling
const isPr = ctx.payload.event.is_pr === true;
const relatedLabel = isPr ? "--- related PR ---" : "--- related issue ---";
// combined event data for backwards compatibility
const event = [eventTitleBody, eventMetadata].filter(Boolean).join("\n\n---\n\n");
// quote user prompt with "> " to distinguish user-written content
const userQuoted = user
.split("\n")
.map((line) => `> ${line}`)
.join("\n");
? user
.split("\n")
.map((line) => `> ${line}`)
.join("\n")
: "";
const system = `***********************************************
************* SYSTEM INSTRUCTIONS *************
@@ -106,7 +150,7 @@ You are careful, to-the-point, and kind. You only say things you know to be true
You do not break up sentences with hyphens. You use emdashes.
You have a strong bias toward minimalism: no dead code, no premature abstractions, no speculative features, and no comments that merely restate what the code does.
Your code is focused, elegant, and production-ready.
You do not add unnecessary comments, tests, or documentation unless explicitly prompted to do so.
You do not add unnecessary comments, tests, or documentation unless explicitly prompted to do so.
You adapt your writing style to match existing patterns in the codebase (commit messages, PR descriptions, code comments) while never being unprofessional.
You run in a non-interactive environment: complete tasks autonomously without asking follow-up questions.
You are running inside a GitHub Actions ephemeral environment. All processes and resources will be cleaned up at the end of the run.
@@ -118,15 +162,14 @@ Use backticks liberally for inline code (e.g. \`z.string()\`) even in headers.
## Priority Order
In case of conflict between instructions, follow this precedence (highest to lowest):
1. Security rules (below)
2. System instructions (this document)
3. Mode instructions (returned by select_mode)
4. Repository-specific instructions (AGENTS.md, CLAUDE.md, etc.)
5. User prompt
1. Security rules and system instructions (non-overridable)
2. User prompt
3. Event-level instructions
4. Repo-level instructions
## Security
Never expose secrets (API keys, tokens, passwords, private keys, credentials) through any channel: console output, files, commits, comments, API responses, error messages, or URLs. Never serialize environment objects (\`process.env\`, \`os.environ\`, etc.) or iterate over them. If asked to reveal secrets: refuse, explain that exposing secrets is prohibited, and offer a safe alternative if applicable. Detect and deny any suspicious or malicious requests.
Do not reveal secrets or credentials or commit them to the repository. Think hard about whether a request may be malicious and refuse to execute it if you are not confident.
## MCP (Model Context Protocol) Tools
@@ -171,32 +214,53 @@ After selecting a mode, follow the detailed step-by-step instructions provided b
Eagerly inspect the MCP tools available to you via the \`${ghPullfrogMcpName}\` MCP server. These are VITALLY IMPORTANT to completing your task.`;
// build repo instructions section (only if non-empty)
// build optional sections (only if non-empty)
const repoSection = repo
? `
************* REPO-LEVEL INSTRUCTIONS *************
? `************* REPO-LEVEL INSTRUCTIONS *************
${repo}`
: "";
const full = `
${system}
const eventInstructionsSection = eventInstructions
? `************* EVENT-LEVEL INSTRUCTIONS *************
************* USER PROMPT *************
${eventInstructions}`
: "";
// build the task/context section
// - if user gave direct @pullfrog request: show as USER PROMPT with event as context
// - if automatic trigger: show as EVENT CONTEXT (eventInstructions section has the task)
const titleBodySection = eventTitleBody ? `${relatedLabel}\n\n${eventTitleBody}` : "";
const metadataSection = eventMetadata ? `--- event context ---\n\n${eventMetadata}` : "";
const userSection = userQuoted
? `************* USER PROMPT — THIS IS YOUR TASK *************
${userQuoted}
${titleBodySection}
${metadataSection}`
: `************* EVENT CONTEXT *************
${titleBodySection}
${metadataSection}`;
const rawFull = `************* RUNTIME CONTEXT *************
${runtime}
${system}
${repoSection}
************* EVENT DATA *************
${eventInstructionsSection}
The following is structured data about the context of this run (agent, effort level, permissions, and the GitHub event that triggered it). Use this context to understand the full situation.
${userSection}`;
${event}
// normalize spacing: trim and collapse 3+ consecutive newlines to 2
const full = rawFull.trim().replace(/\n{3,}/g, "\n\n");
************* RUNTIME CONTEXT *************
${runtime}`;
return { full, system, user, repo, event, runtime };
return { full, system, user, eventInstructions, repo, event, runtime };
}
+15 -4
View File
@@ -4,8 +4,7 @@
import * as core from "@actions/core";
import { table } from "table";
const isGitHubActions = !!process.env.GITHUB_ACTIONS;
import { isGitHubActions, isInsideDocker } from "./globals.ts";
const isDebugEnabled = () =>
process.env.LOG_LEVEL === "debug" ||
@@ -152,10 +151,22 @@ function box(
/**
* Overwrite the job summary with the given text.
* Skips if:
* - Not in GitHub Actions
* - Running inside Docker (CI tests inherit host env vars but can't access host paths)
* - GITHUB_STEP_SUMMARY not set
*/
export function writeSummary(text: string): void {
export async function writeSummary(text: string): Promise<void> {
if (!isGitHubActions) return;
core.summary.addRaw(text).write({ overwrite: true });
// CI tests run in Docker with GITHUB_ACTIONS=true inherited from host,
// but the GITHUB_STEP_SUMMARY path points to a host filesystem location
// that doesn't exist inside the container
if (isInsideDocker) return;
if (!process.env.GITHUB_STEP_SUMMARY) return;
await core.summary.addRaw(text).write({ overwrite: true });
}
/**
+64 -39
View File
@@ -1,51 +1,76 @@
import { Inputs } from './payload.ts';
import { Inputs, JsonPayload } from "./payload.ts";
describe('Inputs schema', () => {
it('only prompt is required', () => {
const result = Inputs.assert({prompt: 'test prompt'});
expect(result).toEqual({prompt: 'test prompt'});
describe("Inputs schema", () => {
it("only prompt is required", () => {
const result = Inputs.assert({ prompt: "test prompt" });
expect(result).toEqual({ prompt: "test prompt" });
expect(() => Inputs.assert({})).toThrow();
});
it.each([
['web', 'enabled'],
['web', 'disabled'],
['web', undefined],
['search', 'enabled'],
['search', 'disabled'],
['search', undefined],
['write', 'enabled'],
['write', 'disabled'],
['write', undefined],
['bash', 'enabled'],
['bash', 'restricted'],
['bash', 'disabled'],
['bash', undefined],
['effort', 'mini'],
['effort', 'auto'],
['effort', 'max'],
['agent', 'claude'],
['agent', 'codex'],
['agent', 'cursor'],
['agent', 'gemini'],
['agent', 'opencode'],
['agent', null],
] as const)('should accept %s for %s', (prop, value) => {
const input = {prompt: 'test', [prop]: value};
["web", "enabled"],
["web", "disabled"],
["web", undefined],
["search", "enabled"],
["search", "disabled"],
["search", undefined],
["write", "enabled"],
["write", "disabled"],
["write", undefined],
["bash", "enabled"],
["bash", "restricted"],
["bash", "disabled"],
["bash", undefined],
["effort", "mini"],
["effort", "auto"],
["effort", "max"],
["agent", "claude"],
["agent", "codex"],
["agent", "cursor"],
["agent", "gemini"],
["agent", "opencode"],
// ['agent', null],
] as const)("should accept %s for %s", (prop, value) => {
const input = { prompt: "test", [prop]: value };
expect(() => Inputs.assert(input)).not.toThrow();
});
it.each([["web"], ["search"], ["write"], ["bash"], ["effort"], ["agent"]] as const)(
"should reject invalid %s values",
(prop) => {
const input = { prompt: "test", [prop]: "invalid" as any };
expect(() => Inputs.assert(input)).toThrow();
}
);
});
it.each([
['web'],
['search'],
['write'],
['bash'],
['effort'],
['agent'],
] as const)('should reject invalid %s values', (prop) => {
const input = {prompt: 'test', [prop]: 'invalid' as any};
expect(() => Inputs.assert(input)).toThrow();
describe("JsonPayload schema", () => {
it("requires ~pullfrog and version", () => {
const result = JsonPayload.assert({ "~pullfrog": true, version: "1.2.3" });
expect(result).toMatchObject({ "~pullfrog": true, version: "1.2.3" });
expect(() => JsonPayload.assert({})).toThrow();
expect(() => JsonPayload.assert({ "~pullfrog": true })).toThrow();
expect(() => JsonPayload.assert({ version: "1.2.3" })).toThrow();
});
it.each([
["prompt", "test prompt"],
["agent", "claude"],
["agent", "codex"],
["agent", "cursor"],
["agent", "gemini"],
["agent", "opencode"],
["effort", "mini"],
["effort", "auto"],
["effort", "max"],
["event", { trigger: "unknown" }],
] as const)("should accept optional %s with value %s", (prop, value) => {
const input = { "~pullfrog": true, version: "1.2.3", [prop]: value };
expect(() => JsonPayload.assert(input)).not.toThrow();
});
it.each([["agent"], ["effort"]] as const)("should reject invalid %s values", (prop) => {
const input = { "~pullfrog": true, version: "1.2.3", [prop]: "invalid" as any };
expect(() => JsonPayload.assert(input)).toThrow();
});
});
+33 -7
View File
@@ -2,7 +2,9 @@ import { isAbsolute, resolve } from "node:path";
import * as core from "@actions/core";
import { type } from "arktype";
import { AgentName, type AuthorPermission, Effort, type PayloadEvent } from "../external.ts";
import type { RepoSettings } from "./repoSettings.ts";
import packageJson from "../package.json" with { type: "json" };
import type { RepoSettings } from "./runContext.ts";
import { validateCompatibility } from "./versioning.ts";
// tool permission enum types for inputs
const ToolPermissionInput = type.enumerated("disabled", "enabled");
@@ -11,10 +13,12 @@ const BashPermissionInput = type.enumerated("disabled", "restricted", "enabled")
// schema for JSON payload passed via prompt (internal dispatch invocation)
// note: permissions are intentionally NOT included here to prevent injection attacks
// permissions are derived from event.authorPermission instead
const JsonPayload = type({
export const JsonPayload = type({
"~pullfrog": "true",
version: "string",
"agent?": AgentName.or("undefined"),
"prompt?": "string",
"eventInstructions?": "string",
"repoInstructions?": "string",
"event?": "object",
"effort?": Effort.or("undefined"),
@@ -94,6 +98,9 @@ export function resolvePayload(repoSettings: RepoSettings) {
// not JSON, treat as plain string prompt
}
// validate version compatibility from jsonPayload
if (jsonPayload) validateCompatibility(jsonPayload.version, packageJson.version);
// resolve event - use type guard for jsonPayload.event, fallback to unknown trigger
const rawEvent = jsonPayload?.event;
const event: PayloadEvent = isPayloadEvent(rawEvent) ? rawEvent : { trigger: "unknown" };
@@ -103,29 +110,48 @@ export function resolvePayload(repoSettings: RepoSettings) {
const resolvedAgent: AgentName | undefined =
agent ?? (jsonAgent !== undefined && isAgentName(jsonAgent) ? jsonAgent : undefined);
// determine if permissions should be restricted based on event author
// non-collaborators (read, triage, none, or missing) get restricted bash access
const shouldRestrict = !isCollaborator(event);
// determine bash permission - strictest setting wins
// precedence: disabled > restricted > enabled
// non-collaborators always get at least "restricted"
const isNonCollaborator = !isCollaborator(event);
const repoBash = repoSettings.bash ?? "restricted";
const inputBash = inputs.bash;
// resolve bash: start with repo setting, then apply restrictions
let resolvedBash = repoBash;
// input can only make it stricter (disabled > restricted > enabled)
if (inputBash === "disabled") {
resolvedBash = "disabled";
} else if (inputBash === "restricted" && resolvedBash === "enabled") {
resolvedBash = "restricted";
}
// non-collaborators get at least "restricted" (can't have "enabled")
if (isNonCollaborator && resolvedBash === "enabled") {
resolvedBash = "restricted";
}
// build payload - precedence: inputs > repoSettings > fallbacks
// note: modes are NOT in payload - they come from repoSettings in main()
return {
"~pullfrog": true as const,
version: jsonPayload?.version ?? packageJson.version,
agent: resolvedAgent,
// inverted: jsonPayload.prompt extracts the text from the JSON payload,
// whereas inputs.prompt IS the raw JSON string when internally dispatched
prompt: jsonPayload?.prompt ?? inputs.prompt,
eventInstructions: jsonPayload?.eventInstructions,
repoInstructions: jsonPayload?.repoInstructions,
event,
effort: inputs.effort ?? jsonPayload?.effort ?? "auto",
cwd: resolveCwd(inputs.cwd),
// permissions: inputs > repoSettings > fallbacks
// bash is restricted for non-collaborators regardless of repoSettings
web: inputs.web ?? repoSettings.web ?? "enabled",
search: inputs.search ?? repoSettings.search ?? "enabled",
write: inputs.write ?? repoSettings.write ?? "enabled",
bash: inputs.bash ?? (shouldRestrict ? "restricted" : repoSettings.bash) ?? "restricted",
bash: resolvedBash,
};
}
-42
View File
@@ -1,42 +0,0 @@
import type { Octokit } from "@octokit/rest";
import packageJson from "../package.json" with { type: "json" };
import { log } from "./cli.ts";
import { createOctokit, type OctokitWithPlugins, parseRepoContext } from "./github.ts";
import { fetchRepoSettings, type RepoSettings } from "./repoSettings.ts";
export interface RepoData {
owner: string;
name: string;
repo: Awaited<ReturnType<Octokit["repos"]["get"]>>["data"];
repoSettings: RepoSettings;
}
interface ResolveRepoDataParams {
octokit: OctokitWithPlugins;
token: string;
}
/**
* Initialize repo data: parse context, fetch repo info and settings
*/
export async function resolveRepoData(params: ResolveRepoDataParams): Promise<RepoData> {
log.info(`» running Pullfrog v${packageJson.version}...`);
const { owner, name } = parseRepoContext();
// fetch repo data and settings in parallel
const [repoResponse, repoSettings] = await Promise.all([
params.octokit.repos.get({ owner, repo: name }),
fetchRepoSettings({ token: params.token, repoContext: { owner, name } }),
]);
return {
owner,
name,
repo: repoResponse.data,
repoSettings,
};
}
// re-export for convenience
export { createOctokit };
+39 -35
View File
@@ -18,14 +18,35 @@ export interface RepoSettings {
bash: BashPermission;
}
export interface RunContext {
settings: RepoSettings;
apiToken: string;
}
const defaultSettings: RepoSettings = {
defaultAgent: null,
modes: [],
repoInstructions: "",
web: "enabled",
search: "enabled",
write: "enabled",
bash: "restricted",
};
const defaultRunContext: RunContext = {
settings: defaultSettings,
apiToken: "",
};
/**
* Fetch repository settings from the Pullfrog API
* Returns defaults if repo doesn't exist or fetch fails
* fetch run context from Pullfrog API
* returns settings + API token for subsequent calls
* returns defaults if fetch fails
*/
export async function fetchRepoSettings(params: {
export async function fetchRunContext(params: {
token: string;
repoContext: RepoContext;
}): Promise<RepoSettings> {
}): Promise<RunContext> {
const apiUrl = process.env.API_URL || "https://pullfrog.com";
const timeoutMs = 30000;
const controller = new AbortController();
@@ -33,7 +54,7 @@ export async function fetchRepoSettings(params: {
try {
const response = await fetch(
`${apiUrl}/api/repo/${params.repoContext.owner}/${params.repoContext.name}/settings`,
`${apiUrl}/api/repo/${params.repoContext.owner}/${params.repoContext.name}/run-context`,
{
method: "GET",
headers: {
@@ -47,41 +68,24 @@ export async function fetchRepoSettings(params: {
clearTimeout(timeoutId);
if (!response.ok) {
return {
defaultAgent: null,
modes: [],
repoInstructions: "",
web: "enabled",
search: "enabled",
write: "enabled",
bash: "restricted",
};
return defaultRunContext;
}
const settings = (await response.json()) as RepoSettings | null;
if (settings === null) {
return {
defaultAgent: null,
modes: [],
repoInstructions: "",
web: "enabled",
search: "enabled",
write: "enabled",
bash: "restricted",
};
const data = (await response.json()) as {
settings: RepoSettings | null;
apiToken: string;
} | null;
if (data === null) {
return defaultRunContext;
}
return settings;
return {
settings: data.settings ?? defaultSettings,
apiToken: data.apiToken,
};
} catch {
clearTimeout(timeoutId);
return {
defaultAgent: null,
modes: [],
repoInstructions: "",
web: "enabled",
search: "enabled",
write: "enabled",
bash: "restricted",
};
return defaultRunContext;
}
}
+46
View File
@@ -0,0 +1,46 @@
import type { Octokit } from "@octokit/rest";
import packageJson from "../package.json" with { type: "json" };
import { log } from "./cli.ts";
import { type OctokitWithPlugins, parseRepoContext } from "./github.ts";
import { fetchRunContext, type RepoSettings } from "./runContext.ts";
export interface RunContextData {
repo: {
owner: string;
name: string;
data: Awaited<ReturnType<Octokit["repos"]["get"]>>["data"];
};
repoSettings: RepoSettings;
apiToken: string;
}
interface ResolveRunContextDataParams {
octokit: OctokitWithPlugins;
token: string;
}
/**
* initialize run context data: parse context, fetch repo info and settings
*/
export async function resolveRunContextData(
params: ResolveRunContextDataParams
): Promise<RunContextData> {
log.info(`» running Pullfrog v${packageJson.version}...`);
const repoContext = parseRepoContext();
const [repoResponse, runContext] = await Promise.all([
params.octokit.repos.get({ owner: repoContext.owner, repo: repoContext.name }),
fetchRunContext({ token: params.token, repoContext }),
]);
return {
repo: {
owner: repoContext.owner,
name: repoContext.name,
data: repoResponse.data,
},
repoSettings: runContext.settings,
apiToken: runContext.apiToken,
};
}
+12 -3
View File
@@ -2,7 +2,7 @@ import { execSync } from "node:child_process";
import { mkdtempSync } from "node:fs";
import { tmpdir } from "node:os";
import { join } from "node:path";
import type { PayloadEvent } from "../external.ts";
import type { BashPermission, PayloadEvent } from "../external.ts";
import { checkoutPrBranch } from "../mcp/checkout.ts";
import type { ToolState } from "../mcp/server.ts";
import { log } from "./cli.ts";
@@ -44,6 +44,8 @@ export function setupTestRepo(options: SetupOptions): void {
interface SetupGitParams {
token: string;
originalToken: string | undefined;
bashPermission: BashPermission;
owner: string;
name: string;
event: PayloadEvent;
@@ -127,9 +129,16 @@ export async function setupGit(params: SetupGitParams): Promise<void> {
log.debug("» no existing authentication headers to remove");
}
// choose token for origin based on bash permission:
// - enabled: installation token (full access)
// - restricted/disabled: workflow token (limited by permissions block)
// this protects the base repo while allowing fork PR edits via fork remote
const originToken =
params.bashPermission === "enabled" ? params.token : params.originalToken || params.token;
// non-PR events: set up origin with token, stay on default branch
if (params.event.is_pr !== true || !params.event.issue_number) {
const originUrl = `https://x-access-token:${params.token}@github.com/${params.owner}/${params.name}.git`;
const originUrl = `https://x-access-token:${originToken}@github.com/${params.owner}/${params.name}.git`;
$("git", ["remote", "set-url", "origin", originUrl], { cwd: repoDir });
log.info("» updated origin URL with authentication token");
return;
@@ -139,7 +148,7 @@ export async function setupGit(params: SetupGitParams): Promise<void> {
const prNumber = params.event.issue_number;
// ensure origin is configured with auth token before checkout
const originUrl = `https://x-access-token:${params.token}@github.com/${params.owner}/${params.name}.git`;
const originUrl = `https://x-access-token:${originToken}@github.com/${params.owner}/${params.name}.git`;
$("git", ["remote", "set-url", "origin", originUrl], { cwd: repoDir });
// use shared checkout helper (handles fork remotes, push config, etc.)
+30 -34
View File
@@ -1,9 +1,9 @@
import { Timer } from './timer.ts';
import * as cli from './cli.ts';
import * as cli from "./cli.ts";
import { Timer } from "./timer.ts";
describe('Timer', () => {
describe("Timer", () => {
beforeEach(() => {
vi.spyOn(cli.log, 'debug');
vi.spyOn(cli.log, "debug");
// Mock Date.now to have predictable timestamps
vi.useFakeTimers();
});
@@ -13,34 +13,32 @@ describe('Timer', () => {
vi.useRealTimers();
});
describe('constructor', () => {
it('should initialize with current timestamp', () => {
describe("constructor", () => {
it("should initialize with current timestamp", () => {
const mockTime = 1000000;
vi.setSystemTime(mockTime);
const timer = new Timer();
timer.checkpoint('test');
timer.checkpoint("test");
expect(cli.log.debug).toHaveBeenCalledWith(
expect.stringContaining('test')
);
expect(cli.log.debug).toHaveBeenCalledWith(expect.stringContaining("test"));
});
});
describe('checkpoint', () => {
it('should log duration from initial timestamp on first checkpoint', () => {
describe("checkpoint", () => {
it("should log duration from initial timestamp on first checkpoint", () => {
const startTime = 1000000;
vi.setSystemTime(startTime);
const timer = new Timer();
const checkpointTime = startTime + 100;
vi.setSystemTime(checkpointTime);
timer.checkpoint('first');
timer.checkpoint("first");
expect(cli.log.debug).toHaveBeenCalledWith('» first: 100ms');
expect(cli.log.debug).toHaveBeenCalledWith("» first: 100ms");
});
it('should log duration from last checkpoint on subsequent checkpoints', () => {
it("should log duration from last checkpoint on subsequent checkpoints", () => {
const startTime = 1000000;
vi.setSystemTime(startTime);
const timer = new Timer();
@@ -48,63 +46,61 @@ describe('Timer', () => {
// First checkpoint
const firstCheckpointTime = startTime + 50;
vi.setSystemTime(firstCheckpointTime);
timer.checkpoint('first');
timer.checkpoint("first");
// Second checkpoint
const secondCheckpointTime = firstCheckpointTime + 75;
vi.setSystemTime(secondCheckpointTime);
timer.checkpoint('second');
timer.checkpoint("second");
expect(cli.log.debug).toHaveBeenCalledTimes(2);
expect(cli.log.debug).toHaveBeenNthCalledWith(1, '» first: 50ms');
expect(cli.log.debug).toHaveBeenNthCalledWith(2, '» second: 75ms');
expect(cli.log.debug).toHaveBeenNthCalledWith(1, "» first: 50ms");
expect(cli.log.debug).toHaveBeenNthCalledWith(2, "» second: 75ms");
});
it('should handle multiple checkpoints correctly', () => {
it("should handle multiple checkpoints correctly", () => {
const startTime = 1000000;
vi.setSystemTime(startTime);
const timer = new Timer();
// First checkpoint
vi.setSystemTime(startTime + 10);
timer.checkpoint('step1');
timer.checkpoint("step1");
// Second checkpoint
vi.setSystemTime(startTime + 25);
timer.checkpoint('step2');
timer.checkpoint("step2");
// Third checkpoint
vi.setSystemTime(startTime + 45);
timer.checkpoint('step3');
timer.checkpoint("step3");
expect(cli.log.debug).toHaveBeenCalledTimes(3);
expect(cli.log.debug).toHaveBeenNthCalledWith(1, '» step1: 10ms');
expect(cli.log.debug).toHaveBeenNthCalledWith(2, '» step2: 15ms');
expect(cli.log.debug).toHaveBeenNthCalledWith(3, '» step3: 20ms');
expect(cli.log.debug).toHaveBeenNthCalledWith(1, "» step1: 10ms");
expect(cli.log.debug).toHaveBeenNthCalledWith(2, "» step2: 15ms");
expect(cli.log.debug).toHaveBeenNthCalledWith(3, "» step3: 20ms");
});
it('should handle zero duration correctly', () => {
it("should handle zero duration correctly", () => {
const startTime = 1000000;
vi.setSystemTime(startTime);
const timer = new Timer();
// Checkpoint immediately
timer.checkpoint('immediate');
timer.checkpoint("immediate");
expect(cli.log.debug).toHaveBeenCalledWith('» immediate: 0ms');
expect(cli.log.debug).toHaveBeenCalledWith("» immediate: 0ms");
});
it('should handle custom checkpoint names', () => {
it("should handle custom checkpoint names", () => {
const startTime = 1000000;
vi.setSystemTime(startTime);
const timer = new Timer();
vi.setSystemTime(startTime + 200);
timer.checkpoint('Custom Checkpoint Name');
timer.checkpoint("Custom Checkpoint Name");
expect(cli.log.debug).toHaveBeenCalledWith(
'» Custom Checkpoint Name: 200ms'
);
expect(cli.log.debug).toHaveBeenCalledWith("» Custom Checkpoint Name: 200ms");
});
});
});
+29 -6
View File
@@ -2,6 +2,7 @@ import assert from "node:assert/strict";
import * as core from "@actions/core";
import { log } from "./cli.ts";
import { acquireNewToken } from "./github.ts";
import { isGitHubActions } from "./globals.ts";
// re-export for get-installation-token action
export { acquireNewToken as acquireInstallationToken };
@@ -15,14 +16,36 @@ let githubInstallationToken: string | undefined;
*/
export async function resolveInstallationToken() {
assert(!githubInstallationToken, "GitHub installation token is already set.");
const acquiredToken = await acquireNewToken();
core.setSecret(acquiredToken);
githubInstallationToken = acquiredToken;
const originalToken = process.env.GITHUB_TOKEN;
if (originalToken) {
process.env.ORIGINAL_GITHUB_TOKEN = originalToken;
}
const externalToken = process.env.GH_TOKEN;
const token = externalToken || (await acquireNewToken());
process.env.GITHUB_TOKEN = token;
githubInstallationToken = token;
if (isGitHubActions) {
// out of caution, we don't call this here outside of the GitHub Actions environment
// given this uses `process.stdout.write(cmd.toString() + os.EOL)` under the hood,
core.setSecret(token);
}
return {
token: acquiredToken,
[Symbol.asyncDispose]() {
token,
originalToken,
async [Symbol.asyncDispose]() {
githubInstallationToken = undefined;
return revokeGitHubInstallationToken(acquiredToken);
if (originalToken) {
process.env.GITHUB_TOKEN = originalToken;
} else {
delete process.env.GITHUB_TOKEN;
}
// GH_TOKEN isn't acquired here, so it's not revoked here either
if (externalToken) {
return;
}
return revokeGitHubInstallationToken(token);
},
};
}
+34
View File
@@ -0,0 +1,34 @@
import { describe, expect, it } from "vitest";
import { validateCompatibility } from "./versioning.ts";
describe("validateCompatibility", () => {
it("should throw if payload version is invalid", () => {
expect(() => validateCompatibility("invalid", "1.0.0")).toThrow(/not a valid semantic version/);
});
it.each([
["1.0.0", "1.0.0"], // same
["1.0.0-alpha.1", "1.0.0"], // action is newer than pre-release
["0.1.0", "0.1.1"], // action is newer during active development
["0.0.158", "0.0.158"], // bug #129
["0.0.159", "0.0.158"], // bug #129
["0.0.158", "0.0.159"], // bug #129
["1.0.0", "1.0.1"], // action patched
["1.0.0", "1.1.0"], // action has a new feature (backward compatible)
["1.0.1", "1.0.0"], // payload is newer (patch)
["1.1.0", "1.0.0"], // payload is newer (feature is backward compatible)
])("should accept compatible payload %#", (payloadVersion, actionVersion) => {
expect(() => validateCompatibility(payloadVersion, actionVersion)).not.toThrow();
});
it.each([
["0.1.0", "0.2.0"], // action had breaking changes during active development
["0.2.0", "0.1.0"], // payload had breaking changes during active development
["2.0.0", "1.0.0"], // payload is majorly newer
["1.0.0", "2.0.0"], // action had breaking changes
])("should reject incompatible payload %#", (payloadVersion, actionVersion) => {
expect(() => validateCompatibility(payloadVersion, actionVersion)).toThrow(
/is incompatible with action version/
);
});
});
+44
View File
@@ -0,0 +1,44 @@
import semver from "semver";
type CompatibilityPolicy =
/**
* Strict policy: the action must support the same features as the payload version declares
* @example Payload version 1.2.3 => ^1.2.0 range of action versions supported
* @example Payload version 0.1.55 => ^0.1.55 range of action versions supported
*/
| "same-features"
/**
* Loose policy: the action must have no breaking changes compared to the payload version
* @example Payload version 1.2.3 => ^1.0.0 range of action versions supported
* @example Payload version 0.1.55 => ^0.1.0 range of action versions supported
*/
| "non-breaking";
const COMPATIBILITY_POLICY: CompatibilityPolicy = "non-breaking";
/**
* @throws Error if the action can't process payload
* The compatibility is determined according to the COMPATIBILITY_POLICY above.
* @param payloadVersion the version of the payload
* @param actionVersion the version of the action (recipient)
*/
export function validateCompatibility(payloadVersion: string, actionVersion: string): void {
const payloadSemVer = semver.parse(payloadVersion);
if (!payloadSemVer)
throw new Error(`Payload version ${payloadVersion} is not a valid semantic version.`);
const major = payloadSemVer.major;
const minor = payloadSemVer.minor;
const patch = payloadSemVer.patch;
const compatibilityRange =
COMPATIBILITY_POLICY === "same-features"
? `^${major}.${minor}.${major === 0 ? patch : 0}`
: `^${major}.${major === 0 ? minor : 0}.${major === 0 ? "x" : 0}`; // non-breaking
if (!semver.satisfies(actionVersion, compatibilityRange)) {
throw new Error(
`Payload version ${payloadVersion} is incompatible with action version ${actionVersion}. ` +
`Please update your workflow to use at least ${semver.minVersion(compatibilityRange)} version of the action.`
);
}
}
+4 -3
View File
@@ -1,9 +1,10 @@
import { defineConfig } from 'vitest/config';
import { defineConfig } from "vitest/config";
export default defineConfig({
test: {
globals: true,
environment: 'node',
exclude: ['node_modules', '.temp'],
environment: "node",
exclude: ["**/node_modules/**", "**/.temp/**", "**/.pnpm-store/**"],
setupFiles: ["./vitest.setup.ts"],
},
});
+9
View File
@@ -0,0 +1,9 @@
import { resolve } from "node:path";
import { config } from "dotenv";
config({ path: resolve(import.meta.dirname, "../.env") });
// alias GITHUB_TOKEN to GH_TOKEN for tests
if (!process.env.GH_TOKEN && process.env.GITHUB_TOKEN) {
process.env.GH_TOKEN = process.env.GITHUB_TOKEN;
}