Compare commits

..

42 Commits

Author SHA1 Message Date
Colin McDonnell a90743e9fe 0.0.167
Co-authored-by: Cursor <cursoragent@cursor.com>
2026-02-20 19:19:25 +00:00
Colin McDonnell 3d0c12976e improve review mode: no compliments, no unrelated nitpicks
Co-authored-by: Cursor <cursoragent@cursor.com>
2026-02-20 19:19:09 +00:00
Colin McDonnell 823fa3a39b 0.0.166
Co-authored-by: Cursor <cursoragent@cursor.com>
2026-02-20 19:17:50 +00:00
Colin McDonnell caa3cf4d4b 0.0.165 2026-02-20 15:53:11 +00:00
Colin McDonnell 8e53ce4e6b Improve review prompting 2026-02-20 15:43:14 +00:00
Anna Bocharova 95c1a5757e Correct copyright holder name in LICENSE file (#368) 2026-02-20 12:39:11 +00:00
pullfrog[bot] ee100354da fix: replace domain-specific exit handler with generic signal handler registry (#299)
* fix: replace domain-specific exit handler with generic signal handler registry

Rewrite exitHandler.ts as a generic, domain-agnostic exit signal module
that exports onExitSignal(handler) returning a dispose function.

- subprocess.ts now registers via onExitSignal instead of direct
  process.on(SIGINT/SIGTERM) calls
- resolveTokens registers a signal handler that captures tokens by
  closure, fixing the race condition where the exit handler would
  read the wrong token after disposal
- Remove setupExitHandler and runCleanup — domain cleanup is handled
  by post.ts + await using

Co-authored-by: Cursor <cursoragent@cursor.com>

* tweaks

* simplify handler installation

* extract to util

* fix race in dispose

* wrap dispose body in try/finally to ensure disposingRef always settles

---------

Co-authored-by: Mateusz Burzyński <mateuszburzynski@gmail.com>
Co-authored-by: Cursor <cursoragent@cursor.com>
Co-authored-by: pullfrog[bot] <226033991+pullfrog[bot]@users.noreply.github.com>
2026-02-20 10:23:56 +00:00
pullfrog[bot] 70f1c47a28 Audit core.warning/core.error usage (#269)
* Stop using command-based logs for warnings and errors

Co-authored-by: Cursor <cursoragent@cursor.com>

* revert

* tweak

* de-noise

* Remove redundant ts() timestamp prefix from log calls

* Restore timestamped logging and refine debug output routing.

Bring back timestamp prefixes for standard logs and make log.debug emit via core.debug when runner debug is enabled, while still surfacing debug lines for --debug runs.

Co-authored-by: Cursor <cursoragent@cursor.com>

---------

Co-authored-by: Mateusz Burzyński <mateuszburzynski@gmail.com>
Co-authored-by: Cursor <cursoragent@cursor.com>
Co-authored-by: pullfrog[bot] <226033991+pullfrog[bot]@users.noreply.github.com>
Co-authored-by: Colin McDonnell <colinmcd94@gmail.com>
2026-02-19 23:11:47 +00:00
Mateusz Burzyński 4ee1ae89a5 Fix isPullfrog checks to handle the dev app (#362) 2026-02-19 21:06:35 +00:00
Mateusz Burzyński 185ca7a832 Avoid using --ignore-workspace (#353) 2026-02-19 14:41:21 +00:00
Mateusz Burzyński 4ecff49b72 Request reviews from the PR's human initiator (#340)
* Request reviews from the PR's human initiator

* add logs

* await the request reviewers call
2026-02-19 14:00:57 +00:00
Colin McDonnell df3ec6b815 switch local dev to dedicated GitHub App + Clerk project (#347)
* update hookdeck source to github-dev for local development

Co-authored-by: Cursor <cursoragent@cursor.com>

* use GITHUB_APP_SLUG env var for install URLs instead of hardcoded slug

Co-authored-by: Cursor <cursoragent@cursor.com>

* replace GITHUB_TOKEN alias hack with ensureGitHubToken in vitest setup

Co-authored-by: Cursor <cursoragent@cursor.com>

* add neon CLI reference wiki page

Co-authored-by: Cursor <cursoragent@cursor.com>

* use select_target for GitHub App install URL to show account picker

Co-authored-by: Cursor <cursoragent@cursor.com>

* scope repo listing to installation access and invalidate paged cache

when repository_selection is "selected", use the REST installation repos
list instead of the unscoped GraphQL repositoryOwner query. also filter
active repos against the allowed set. add getInstallationReposPage cache
invalidation alongside existing getInstallationRepos invalidation in
webhooks and the GitHub App callback.

Co-authored-by: Cursor <cursoragent@cursor.com>

* clear getUserInstallations cache on repo add/remove webhooks

repository_selection changes (e.g. "all" -> "selected") trigger
repositories_added/removed events, so the installation metadata
cache must be refreshed to pick up the new selection mode.

Co-authored-by: Cursor <cursoragent@cursor.com>

---------

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-02-19 04:37:35 +00:00
Colin McDonnell 4a9d83b102 add webhook identity context to alerts and typed workflow permissions
Include actor/account github identity details in installation and repo lifecycle alerting, add shared identity helpers, and tighten CI workflow permission typing for safer validation.

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-02-18 19:01:56 +00:00
Colin McDonnell 57537d1a95 move instructions logging earlier and clarify built-in tool logs
Log the instructions box immediately after instruction resolution in main, and standardize agent permission summaries to debug-level "disallowed built-ins" output to reduce confusion with pullfrog MCP tools.

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-02-18 19:01:08 +00:00
pullfrog[bot] 9948c08e7d run post action cleanup in play.ts (#344)
* run post action cleanup in play script after main completes

* clarify that GITHUB_RUN_ID is the actual bail-out gate in play context

* treat GITHUB_RUN_ID as optional in post cleanup

* replace dynamic import with static import of `runPostCleanup`

Export `runPostCleanup` from post.ts and guard the top-level
execution with `import.meta.url` so it only auto-runs as an
entry point. play.ts now statically imports and calls it.

* move runPostCleanup into finally block and let failures propagate

* refactor post cleanup into utility module

move post cleanup logic into a dedicated utility and keep post.ts as a pure script entrypoint. update play.ts to import the shared utility directly and normalize direct-execution detection.

Co-authored-by: Cursor <cursoragent@cursor.com>

---------

Co-authored-by: pullfrog[bot] <226033991+pullfrog[bot]@users.noreply.github.com>
Co-authored-by: Colin McDonnell <colinmcd94@gmail.com>
Co-authored-by: Cursor <cursoragent@cursor.com>
2026-02-18 18:32:24 +00:00
Colin McDonnell 3bf2f8596f add operational alerting and harden account creation flows
Introduce email alerts for new installations/account creation/repo promotion, restore atomic DB writes for account-related creation paths, and update docs references after removing the MCP README.

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-02-18 15:57:44 +00:00
Mateusz Burzyński 510f2c96f9 Fix the availability of some @anthropic-ai/claude-agent-sdk types (#322)
* Fix the availability of some `@anthropic-ai/claude-agent-sdk` types

* update it in the action too

* fix types
2026-02-18 12:12:40 +00:00
Mateusz Burzyński df13253d48 Fixed approved comments lookup for users with capital letter in GitHub login (#330)
* Fixed approved commens lookup for users with capital letter in GitHub login

* handle other place too
2026-02-17 21:11:19 +00:00
Colin McDonnell eb22433760 bump action patch version and sync queued waitlist updates
Increment @pullfrog/pullfrog from 0.0.163 to 0.0.164 and include current beta/docs/waitlist script changes.

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-02-17 03:21:44 +00:00
Colin McDonnell b6658ddbc1 improve agent CI matrix, token permissions, and waitlist follower backfill (#313)
* add workflows permission to git token and waitlist improvements

- add `workflows` to `InstallationTokenPermissions` type in both action and API token routes
- include `workflows: write` in the git token so agents can push workflow file changes
- add `githubFollowers` field to WaitlistSignup schema with migration
- add script to populate waitlist followers from GitHub API
- add frog-green-square-border logo asset

Co-authored-by: Cursor <cursoragent@cursor.com>

* improve CI, agent logging, token permissions, and delegation guardrails

- add format check and build step to root CI job
- standardize agent model/effort log lines across all agents
- fix GitHub App permissions types to match OpenAPI schema (workflows is write-only)
- improve delegation error message to prevent subagent recursion
- demote noisy OpenCode stderr to debug level
- add subagent delegation rules to resolved instructions

Co-authored-by: Cursor <cursoragent@cursor.com>

* fix graphql partial error handling, update delegation message, add workflow_run fixtures

Co-authored-by: Cursor <cursoragent@cursor.com>

* remove module-level env var throws that break CI build

Co-authored-by: Cursor <cursoragent@cursor.com>

* fix logging bug and type hole from PR review

- use batch-local notFound counter so per-batch log doesn't undercount
- add workflows to WorkflowTokenPermissions so wire type matches what action sends

Co-authored-by: Cursor <cursoragent@cursor.com>

* lazy-init appOctokit to fix next build without env vars

Co-authored-by: Cursor <cursoragent@cursor.com>

* drop pnpm build from CI test workflow

Co-authored-by: Cursor <cursoragent@cursor.com>

* fix delegate-effort test regex to match actual log format, disable fail-fast for agnostic tests

the test was matching `running \w+ with effort=auto` but the actual log
line from shared.ts is `» effort:  auto`. also temporarily set
fail-fast: false on action-agnostic so all failures surface at once.

Co-authored-by: Cursor <cursoragent@cursor.com>

* disable fail-fast in action workflow too, relax ci.test.ts to match

both workflow files now use fail-fast: false for agnostic tests so all
matrix jobs run to completion. the ci consistency test now checks that
the two workflows agree rather than requiring true.

Co-authored-by: Cursor <cursoragent@cursor.com>

* restore fail-fast: true now that all agnostic tests pass

Co-authored-by: Cursor <cursoragent@cursor.com>

* skip agent tests in CI when agent harness file didn't change

adds action/test/changed-agents.sh which reads the PR diff (via
dorny/paths-filter) and outputs only agents whose harness file was
modified. the action-agents matrix now uses this dynamic list instead
of a hardcoded array, so e.g. a PR touching only cursor.ts runs 6
jobs instead of 30.

Co-authored-by: Cursor <cursoragent@cursor.com>

* update ci.test.ts to validate dynamic agent matrix

the test now checks that the matrix references the changes job output
and that changed-agents.sh correctly discovers all agents.

Co-authored-by: Cursor <cursoragent@cursor.com>

* parallelize action jobs and use claude canary fallback for shared changes

runs action-agents in parallel with action-agnostic after root/changes, and updates changed-agents logic so shared or non-harness action runtime changes run only claude while harness-specific edits run only those changed agents.

Co-authored-by: Cursor <cursoragent@cursor.com>

---------

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-02-16 22:36:29 +00:00
Colin McDonnell 37dcea86b9 0.0.163 2026-02-16 04:42:33 +00:00
Colin McDonnell 97937f46f7 console UI improvements and cleanup (#311)
* console UI improvements and cleanup

- add verify workflow button and API endpoint for manual installation check
- move env var check into PromptBox as blocking overlay (hoisted to RepoConsole)
- extract FlagsCheatSheet modal, replace verbose flag hints everywhere
- add info popovers for repo setup / post-checkout script descriptions
- remove unused prAutoFixCiFailures schema fields and migration
- default mentionAllowNonCollaborator to disabled for safety on public repos
- update docs for triggers and getting started

Co-authored-by: Cursor <cursoragent@cursor.com>

* add diagnostic logging for push_branch bug investigation

temporary [push-debug] logs to trace why getPushDestination falls back
to origin/<localBranch> instead of using the correct remote branch name
for same-repo PRs.

Co-authored-by: Cursor <cursoragent@cursor.com>

* add git config diagnostic to verify original bug cause

Co-authored-by: Cursor <cursoragent@cursor.com>

* temporarily disable StoredPushDest to test git config path

Co-authored-by: Cursor <cursoragent@cursor.com>

* remove diagnostic logging for push_branch investigation

verified that StoredPushDest fix works correctly on preview repo.
both the stored dest path and the git config fallback resolve to the
correct remote branch in the GitHub Actions environment.

Co-authored-by: Cursor <cursoragent@cursor.com>

* fix formatting in AgentSettings and TriggersSettings

Co-authored-by: Cursor <cursoragent@cursor.com>

* pass derived env var state to PromptBox instead of raw secrets data

eliminates duplicated derivation logic between RepoConsole and PromptBox
by passing envVarMissing, envVarChecking, and agentKeyNames as props.

Co-authored-by: Cursor <cursoragent@cursor.com>

* fix: prevent duplicate comment after PR review deletes progress comment

progressCommentId now uses three states: undefined (no comment yet),
number (active), null (deliberately deleted). After create_pull_request_review
deletes the progress comment, subsequent report_progress calls skip instead
of creating a new comment.

Co-authored-by: Cursor <cursoragent@cursor.com>

* effort descriptions, test ordering, husky, docs images, typo fix

- rewrote delegation effort level descriptions to per-level breakdown
- action-agents now waits for action-agnostic; action-agnostic waits for root
- added husky + lint-staged (biome check --write on staged files)
- updated triggers docs images and triggers.mdx content
- fixed "figured" → "figures" typo on landing page
- updated pnpm-lock.yaml

Co-authored-by: Cursor <cursoragent@cursor.com>

* Commit

---------

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-02-16 04:41:04 +00:00
Colin McDonnell e45c4a84a2 remove dead preview API request forwarding (#309)
the action now calls preview deployments directly via API_URL secret
(set by preview-create.ts), making the production-side forwarding
fallback unnecessary. also removes orphaned workflowRun.ts interface.

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-02-14 04:04:23 +00:00
Colin McDonnell 9a1f3bdb0a relax filesystem permissions: reads allow temp dir, writes conditional on bash (#308)
reads (file_read, list_directory) now allow paths within the repo OR
PULLFROG_TEMP_DIR. this fixes the bug where agents with full permissions
couldn't read PR diffs, CI logs, review threads, or background bash
output because those files live under /tmp/pullfrog-xxx/.

writes (file_write, file_edit, file_delete) now only enforce repo-scoping
when bash !== "enabled". when bash=enabled the agent can write anywhere
via native bash, so restricting file_write was security theater. .git/
stays blocked in all modes as defense-in-depth.

replaced the conflated resolveAndValidatePath/validateWritePath helpers
with separate resolveReadPath and resolveWritePath functions that cleanly
separate path resolution from permission enforcement.

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-02-14 04:02:45 +00:00
Colin McDonnell b80c78bdbe Tweak 2026-02-14 03:58:27 +00:00
Colin McDonnell 8fd2b6aacb Tweak 2026-02-14 03:52:08 +00:00
Colin McDonnell 6ac428ee2b Tweak 2026-02-14 03:46:12 +00:00
Colin McDonnell 375e8e4455 use codex-mini-latest for mini effort level
Co-authored-by: Cursor <cursoragent@cursor.com>
2026-02-14 03:38:04 +00:00
Colin McDonnell 593a956665 clarify mcpmerge prompt to extract inner value from JSON response
Co-authored-by: Cursor <cursoragent@cursor.com>
2026-02-14 03:35:30 +00:00
Colin McDonnell 80ab5bad34 Tweak 2026-02-14 03:30:17 +00:00
Colin McDonnell 6313b09e30 switch opencode tests to codex, gemini tests to flash-preview
Co-authored-by: Cursor <cursoragent@cursor.com>
2026-02-14 03:24:21 +00:00
Colin McDonnell b753c67d0a switch test default models to gemini-2.5-pro
Co-authored-by: Cursor <cursoragent@cursor.com>
2026-02-14 03:12:47 +00:00
Colin McDonnell 4789a2b5e3 respect GEMINI_MODEL and OPENCODE_MODEL env vars in test runner
Co-authored-by: Cursor <cursoragent@cursor.com>
2026-02-14 02:49:13 +00:00
Colin McDonnell 06683c1e0a respect GEMINI_MODEL and OPENCODE_MODEL env vars in test runner
Co-authored-by: Cursor <cursoragent@cursor.com>
2026-02-14 02:46:39 +00:00
Colin McDonnell 796c56a0c2 add model override vars to expected CI env vars
Co-authored-by: Cursor <cursoragent@cursor.com>
2026-02-14 02:39:40 +00:00
Colin McDonnell 002f550e56 pass GEMINI_MODEL and OPENCODE_MODEL through root test workflow
Co-authored-by: Cursor <cursoragent@cursor.com>
2026-02-14 02:37:58 +00:00
Colin McDonnell 0e1f1ccbb7 pass GEMINI_MODEL and OPENCODE_MODEL vars through CI and Docker
Co-authored-by: Cursor <cursoragent@cursor.com>
2026-02-14 02:33:00 +00:00
Colin McDonnell 8a64742ddf sync action workflow fail-fast to match root workflow
the root workflow was updated to fail-fast: true but the action
workflow wasn't updated to match. the ci consistency test enforces
they stay in sync.

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-02-14 02:10:18 +00:00
Mateusz Burzyński 8037c118cc Generate tokens before running action/play.ts (#296)
* Generate tokens before running `action/play.ts`

* Extract `ensureGitHubToken` utility

---------

Co-authored-by: pullfrog[bot] <226033991+pullfrog[bot]@users.noreply.github.com>
2026-02-13 20:09:54 +00:00
Colin McDonnell 6f108237d4 Deployment protection bypass (#298)
* test preview bypass 2

Co-authored-by: Cursor <cursoragent@cursor.com>

* add apiFetch wrapper with Vercel bypass via query param + header

the template workflow was missing VERCEL_AUTOMATION_BYPASS_SECRET,
so all action API calls to preview deployments hit Vercel's
deployment protection without bypass. this also consolidates the
bypass logic into a single fetch wrapper that applies the secret
as both a query parameter (matching server-side forwarding) and
a header for belt-and-suspenders reliability.

Co-authored-by: Cursor <cursoragent@cursor.com>

* security hardening for Vercel bypass

- redact bypass token from webhook forwarder logs and response body
- remove dead x-preview-api-forward header
- refactor getAllSecrets() to use SENSITIVE_PATTERNS instead of hardcoded list
- enforce https:// on API_URL (localhost exempt for local dev)

Co-authored-by: Cursor <cursoragent@cursor.com>

---------

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-02-13 20:01:48 +00:00
Mateusz Burzyński d5508d99bb Base Cloudflare integration with the codebase (#261)
* Base Cloudflare integration with the codebase

Co-authored-by: Cursor <cursoragent@cursor.com>

* Add a trigger script

* tweak script

* rename dir

* remove preinstallation from Dockerfile

* stream output

* fix type error

* remove redundant waitUntil

* use alarm

* rename to ActionSandbox

* tweak timeouts

* update

* update wrangler types

* fix bad rebase

* update env var name

* rename queues

* add settings to avoid pesky warnings

* add catch

* retry enqueueIndexingJob

* forward to DLQ

---------

Co-authored-by: Cursor <cursoragent@cursor.com>
Co-authored-by: Colin McDonnell <colinmcd94@gmail.com>
2026-02-13 19:17:27 +00:00
Colin McDonnell 6a77ea6612 fix push_branch resolving to wrong remote branch (#282)
getPushDestination used git's @{push} which under push.default=simple
resolves using the local branch name as the remote branch name. since
checkout_pr uses pr-N as the local name, this resolved to origin/pr-N
instead of the actual PR branch (e.g. origin/pullfrog/feature-branch).

this caused two failure modes:
- agent passes remote branch name to push_branch → "src refspec does
  not match any" because no local branch has that name
- agent calls push_branch with no args → silently pushes to a new
  remote branch pr-N instead of updating the PR branch

fix: read branch.X.pushRemote and branch.X.merge from git config
directly (the exact config checkout_pr already writes) instead of
relying on @{push}. also rename head → localBranch + remoteBranch
in CheckoutPrResult to make the distinction explicit.

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-02-13 19:15:40 +00:00
65 changed files with 1955 additions and 1752 deletions
+2
View File
@@ -32,6 +32,8 @@ jobs:
with: with:
prompt: ${{ inputs.prompt }} prompt: ${{ inputs.prompt }}
env: env:
API_URL: ${{ secrets.API_URL }}
VERCEL_AUTOMATION_BYPASS_SECRET: ${{ secrets.VERCEL_AUTOMATION_BYPASS_SECRET }}
# add any additional keys your agent(s) need # add any additional keys your agent(s) need
# optionally, comment out any you won't use # optionally, comment out any you won't use
ANTHROPIC_API_KEY: ${{ secrets.ANTHROPIC_API_KEY }} ANTHROPIC_API_KEY: ${{ secrets.ANTHROPIC_API_KEY }}
+7 -5
View File
@@ -14,7 +14,7 @@ jobs:
node-version: "24" node-version: "24"
cache: "pnpm" cache: "pnpm"
- run: pnpm install --frozen-lockfile --ignore-scripts - run: pnpm install --frozen-lockfile
- run: pnpm typecheck - run: pnpm typecheck
- run: pnpm test - run: pnpm test
@@ -25,7 +25,7 @@ jobs:
contents: read contents: read
id-token: write id-token: write
strategy: strategy:
fail-fast: false fail-fast: true
matrix: matrix:
agent: [claude, codex, cursor, gemini, opencode] agent: [claude, codex, cursor, gemini, opencode]
test: test:
@@ -37,6 +37,8 @@ jobs:
CURSOR_API_KEY: ${{ secrets.CURSOR_API_KEY }} CURSOR_API_KEY: ${{ secrets.CURSOR_API_KEY }}
GOOGLE_API_KEY: ${{ secrets.GOOGLE_API_KEY }} GOOGLE_API_KEY: ${{ secrets.GOOGLE_API_KEY }}
GEMINI_API_KEY: ${{ secrets.GEMINI_API_KEY }} GEMINI_API_KEY: ${{ secrets.GEMINI_API_KEY }}
GEMINI_MODEL: ${{ vars.GEMINI_MODEL }}
OPENCODE_MODEL: ${{ vars.OPENCODE_MODEL }}
steps: steps:
- uses: actions/checkout@v4 - uses: actions/checkout@v4
- uses: pnpm/action-setup@v4 - uses: pnpm/action-setup@v4
@@ -45,7 +47,7 @@ jobs:
node-version: "24" node-version: "24"
cache: "pnpm" cache: "pnpm"
- run: pnpm install --frozen-lockfile --ignore-scripts - run: pnpm install --frozen-lockfile
- run: pnpm runtest ${{ matrix.test }} ${{ matrix.agent }} - run: pnpm runtest ${{ matrix.test }} ${{ matrix.agent }}
agnostic: agnostic:
@@ -55,7 +57,7 @@ jobs:
contents: read contents: read
id-token: write id-token: write
strategy: strategy:
fail-fast: false fail-fast: true
matrix: matrix:
test: test:
[ [
@@ -85,5 +87,5 @@ jobs:
node-version: "24" node-version: "24"
cache: "pnpm" cache: "pnpm"
- run: pnpm install --frozen-lockfile --ignore-scripts - run: pnpm install --frozen-lockfile
- run: pnpm runtest ${{ matrix.test }} - run: pnpm runtest ${{ matrix.test }}
+3 -1
View File
@@ -1,6 +1,8 @@
# sync action lockfile when action/package.json changes # sync action lockfile when action/package.json changes
if git diff --cached --name-only | grep -q "^action/package.json$"; then if git diff --cached --name-only | grep -q "^action/package.json$"; then
echo "🔒 syncing action/pnpm-lock.yaml..." echo "🔒 syncing action/pnpm-lock.yaml..."
pnpm --ignore-workspace -C action install --no-frozen-lockfile # note: pnpm -C action install will *not* treat "action" as a monorepo root if run from repo root;
# to install with action/ as the workspace root (and search upwards), cd into action first:
(cd action && pnpm install --no-frozen-lockfile)
git add action/pnpm-lock.yaml git add action/pnpm-lock.yaml
fi fi
+1 -1
View File
@@ -1,6 +1,6 @@
MIT License MIT License
Copyright (c) 2026 pullfrog Copyright (c) 2026 Pullfrog, Inc.
Permission is hereby granted, free of charge, to any person obtaining a copy Permission is hereby granted, free of charge, to any person obtaining a copy
of this software and associated documentation files (the "Software"), to deal of this software and associated documentation files (the "Software"), to deal
+1 -1
View File
@@ -1,4 +1,4 @@
<!-- test preview system --> <!-- trivial touch --> <!-- test preview system --> <!-- test bypass 2 -->
<p align="center"> <p align="center">
<h1 align="center"> <h1 align="center">
<picture> <picture>
+20 -12
View File
@@ -63,7 +63,7 @@ function writeMcpConfig(ctx: AgentRunContext): string {
}; };
writeFileSync(configPath, JSON.stringify(mcpConfig, null, 2), "utf-8"); writeFileSync(configPath, JSON.stringify(mcpConfig, null, 2), "utf-8");
log.info(`» MCP config written to ${configPath}`); log.debug(`» MCP config written to ${configPath}`);
return configPath; return configPath;
} }
@@ -86,12 +86,12 @@ export const claude = agent({
// select model and effort level // select model and effort level
const model = claudeEffortModels[ctx.payload.effort]; const model = claudeEffortModels[ctx.payload.effort];
const effortLevel = claudeEffortLevels[ctx.payload.effort]; const effortLevel = claudeEffortLevels[ctx.payload.effort];
log.info(`» using model: ${model}${effortLevel ? ` (effort: ${effortLevel})` : ""}`); log.info(`» model: ${model}${effortLevel ? ` (effort: ${effortLevel})` : ""}`);
// build disallowedTools based on tool permissions // build disallowedTools based on tool permissions
const disallowedTools = buildDisallowedTools(ctx); const disallowedTools = buildDisallowedTools(ctx);
if (disallowedTools.length > 0) { if (disallowedTools.length > 0) {
log.info(`» disallowed tools: ${disallowedTools.join(", ")}`); log.debug(`» disallowed built-ins: ${JSON.stringify(disallowedTools)}`);
} }
// write MCP config file // write MCP config file
@@ -173,8 +173,7 @@ export const claude = agent({
onStderr: (chunk) => { onStderr: (chunk) => {
const trimmed = chunk.trim(); const trimmed = chunk.trim();
if (trimmed) { if (trimmed) {
log.debug(`[claude stderr] ${trimmed}`); log.info(`[claude stderr] ${trimmed}`);
log.warning(trimmed);
finalOutput += trimmed + "\n"; finalOutput += trimmed + "\n";
} }
}, },
@@ -239,10 +238,13 @@ const messageHandlers: SDKMessageHandlers = {
user: (data, bashToolIds, thinkingTimer) => { user: (data, bashToolIds, thinkingTimer) => {
if (data.message?.content) { if (data.message?.content) {
for (const content of data.message.content) { for (const content of data.message.content) {
if (typeof content === "string") {
continue;
}
if (content.type === "tool_result") { if (content.type === "tool_result") {
thinkingTimer.markToolResult(); thinkingTimer.markToolResult();
const toolUseId = (content as any).tool_use_id; const toolUseId = content.tool_use_id;
const isBashTool = toolUseId && bashToolIds.has(toolUseId); const isBashTool = toolUseId && bashToolIds.has(toolUseId);
const outputContent = const outputContent =
@@ -250,7 +252,13 @@ const messageHandlers: SDKMessageHandlers = {
? content.content ? content.content
: Array.isArray(content.content) : Array.isArray(content.content)
? content.content ? content.content
.map((c: any) => (typeof c === "string" ? c : c.text || JSON.stringify(c))) .map((entry: unknown) =>
typeof entry === "string"
? entry
: typeof entry === "object" && entry !== null && "text" in entry
? String(entry.text)
: JSON.stringify(entry)
)
.join("\n") .join("\n")
: String(content.content); : String(content.content);
@@ -258,7 +266,7 @@ const messageHandlers: SDKMessageHandlers = {
// Log bash output in a collapsed group // Log bash output in a collapsed group
log.startGroup(`bash output`); log.startGroup(`bash output`);
if (content.is_error) { if (content.is_error) {
log.warning(outputContent); log.info(outputContent);
} else { } else {
log.info(outputContent); log.info(outputContent);
} }
@@ -266,7 +274,7 @@ const messageHandlers: SDKMessageHandlers = {
// Clean up the tracked ID // Clean up the tracked ID
bashToolIds.delete(toolUseId); bashToolIds.delete(toolUseId);
} else if (content.is_error) { } else if (content.is_error) {
log.warning(`Tool error: ${outputContent}`); log.info(`Tool error: ${outputContent}`);
} else { } else {
// log successful non-bash tool result at debug level // log successful non-bash tool result at debug level
log.debug(`tool output: ${outputContent}`); log.debug(`tool output: ${outputContent}`);
@@ -301,11 +309,11 @@ const messageHandlers: SDKMessageHandlers = {
], ],
]); ]);
} else if (data.subtype === "error_max_turns") { } else if (data.subtype === "error_max_turns") {
log.error(`Max turns reached: ${JSON.stringify(data)}`); log.info(`Max turns reached: ${JSON.stringify(data)}`);
} else if (data.subtype === "error_during_execution") { } else if (data.subtype === "error_during_execution") {
log.error(`Execution error: ${JSON.stringify(data)}`); log.info(`Execution error: ${JSON.stringify(data)}`);
} else { } else {
log.error(`Failed: ${JSON.stringify(data)}`); log.info(`Failed: ${JSON.stringify(data)}`);
} }
}, },
system: () => {}, system: () => {},
+11 -13
View File
@@ -29,7 +29,7 @@ const FALLBACK_MODEL = "gpt-5.2-codex";
function getCodexEffortConfig(model: string): Record<Effort, CodexEffortConfig> { function getCodexEffortConfig(model: string): Record<Effort, CodexEffortConfig> {
return { return {
mini: { model: "gpt-5.1-codex", reasoningEffort: "low" }, mini: { model: "gpt-5.2-codex", reasoningEffort: "low" },
auto: { model }, auto: { model },
max: { model, reasoningEffort: "high" }, max: { model, reasoningEffort: "high" },
}; };
@@ -43,7 +43,7 @@ async function isModelAvailable(ctx: { apiKey: string; model: string }): Promise
signal: AbortSignal.timeout(10_000), signal: AbortSignal.timeout(10_000),
}); });
if (!response.ok) { if (!response.ok) {
log.warning( log.info(
`failed to list models (HTTP ${response.status}), falling back to ${FALLBACK_MODEL}` `failed to list models (HTTP ${response.status}), falling back to ${FALLBACK_MODEL}`
); );
return false; return false;
@@ -51,7 +51,7 @@ async function isModelAvailable(ctx: { apiKey: string; model: string }): Promise
const body = (await response.json()) as { data: Array<{ id: string }> }; const body = (await response.json()) as { data: Array<{ id: string }> };
return body.data.some((m) => m.id === ctx.model); return body.data.some((m) => m.id === ctx.model);
} catch (err) { } catch (err) {
log.warning(`failed to list models: ${err}, falling back to ${FALLBACK_MODEL}`); log.info(`failed to list models: ${err}, falling back to ${FALLBACK_MODEL}`);
return false; return false;
} }
} }
@@ -155,10 +155,9 @@ export const codex = agent({
// get model and reasoning effort based on effort level // get model and reasoning effort based on effort level
const effortConfig = getCodexEffortConfig(model)[ctx.payload.effort]; const effortConfig = getCodexEffortConfig(model)[ctx.payload.effort];
log.info(`» using model: ${effortConfig.model} (effort: ${ctx.payload.effort})`); log.info(
if (effortConfig.reasoningEffort) { `» model: ${effortConfig.model}${effortConfig.reasoningEffort ? ` (reasoningEffort: ${effortConfig.reasoningEffort})` : ""}`
log.info(`» using modelReasoningEffort: ${effortConfig.reasoningEffort}`); );
}
// determine sandbox mode based on push permission // determine sandbox mode based on push permission
// push: "disabled" → read-only sandbox, otherwise workspace-write. // push: "disabled" → read-only sandbox, otherwise workspace-write.
@@ -263,8 +262,7 @@ export const codex = agent({
onStderr: (chunk) => { onStderr: (chunk) => {
const trimmed = chunk.trim(); const trimmed = chunk.trim();
if (trimmed) { if (trimmed) {
log.debug(`[codex stderr] ${trimmed}`); log.info(`[codex stderr] ${trimmed}`);
log.warning(trimmed);
finalOutput += trimmed + "\n"; finalOutput += trimmed + "\n";
} }
}, },
@@ -320,7 +318,7 @@ const messageHandlers: {
]); ]);
}, },
"turn.failed": (event) => { "turn.failed": (event) => {
log.error(`Turn failed: ${event.error.message}`); log.info(`Turn failed: ${event.error.message}`);
}, },
"item.started": (event, commandExecutionIds, thinkingTimer) => { "item.started": (event, commandExecutionIds, thinkingTimer) => {
const item = event.item; const item = event.item;
@@ -363,7 +361,7 @@ const messageHandlers: {
thinkingTimer.markToolResult(); thinkingTimer.markToolResult();
log.startGroup(`bash output`); log.startGroup(`bash output`);
if (item.status === "failed" || (item.exit_code !== undefined && item.exit_code !== 0)) { if (item.status === "failed" || (item.exit_code !== undefined && item.exit_code !== 0)) {
log.warning(item.aggregated_output || "Command failed"); log.info(item.aggregated_output || "Command failed");
} else { } else {
log.info(item.aggregated_output || ""); log.info(item.aggregated_output || "");
} }
@@ -373,7 +371,7 @@ const messageHandlers: {
} else if (item.type === "mcp_tool_call") { } else if (item.type === "mcp_tool_call") {
thinkingTimer.markToolResult(); thinkingTimer.markToolResult();
if (item.status === "failed" && item.error) { if (item.status === "failed" && item.error) {
log.warning(`MCP tool call failed: ${item.error.message}`); log.info(`MCP tool call failed: ${item.error.message}`);
} else if ((item as any).output) { } else if ((item as any).output) {
// log successful MCP tool call output so it appears in captured output // log successful MCP tool call output so it appears in captured output
const output = (item as any).output; const output = (item as any).output;
@@ -389,6 +387,6 @@ const messageHandlers: {
} }
}, },
error: (event) => { error: (event) => {
log.error(`Error: ${event.message}`); log.info(`Error: ${event.message}`);
}, },
}; };
+9 -7
View File
@@ -134,7 +134,7 @@ export const cursor = agent({
try { try {
const projectConfig = JSON.parse(readFileSync(projectCliConfigPath, "utf-8")); const projectConfig = JSON.parse(readFileSync(projectCliConfigPath, "utf-8"));
if (projectConfig.model) { if (projectConfig.model) {
log.info(`» using model from project .cursor/cli.json: ${projectConfig.model}`); log.info(`» model: ${projectConfig.model} (from .cursor/cli.json)`);
} else { } else {
modelOverride = cursorEffortModels[ctx.payload.effort]; modelOverride = cursorEffortModels[ctx.payload.effort];
} }
@@ -146,9 +146,9 @@ export const cursor = agent({
} }
if (modelOverride) { if (modelOverride) {
log.info(`» using model: ${modelOverride}, effort=${ctx.payload.effort}`); log.info(`» model: ${modelOverride}`);
} else if (!existsSync(projectCliConfigPath)) { } else if (!existsSync(projectCliConfigPath)) {
log.info(`» using default model, effort=${ctx.payload.effort}`); log.info(`» model: default`);
} }
// track logged model_call_ids to avoid duplicates // track logged model_call_ids to avoid duplicates
@@ -210,7 +210,7 @@ export const cursor = agent({
const result = event.tool_call?.mcpToolCall?.result?.success; const result = event.tool_call?.mcpToolCall?.result?.success;
const isError = result?.isError; const isError = result?.isError;
if (isError) { if (isError) {
log.warning("Tool call failed"); log.info("Tool call failed");
} else { } else {
// log successful tool result so it appears in output // log successful tool result so it appears in output
// handle both formats: { text: string } or { text: { text: string } } // handle both formats: { text: string } or { text: { text: string } }
@@ -320,12 +320,12 @@ export const cursor = agent({
const text = data.toString(); const text = data.toString();
stderr += text; stderr += text;
process.stderr.write(text); process.stderr.write(text);
log.warning(text); log.info(text);
}); });
child.on("close", async (code, signal) => { child.on("close", async (code, signal) => {
if (signal) { if (signal) {
log.warning(`Cursor CLI terminated by signal: ${signal}`); log.info(`Cursor CLI terminated by signal: ${signal}`);
} }
const duration = ((performance.now() - startTime) / 1000).toFixed(1); const duration = ((performance.now() - startTime) / 1000).toFixed(1);
@@ -439,5 +439,7 @@ function configureCursorTools(ctx: AgentRunContext): void {
} }
writeFileSync(cliConfigPath, JSON.stringify(config, null, 2), "utf-8"); writeFileSync(cliConfigPath, JSON.stringify(config, null, 2), "utf-8");
log.info(`» CLI config written to ${cliConfigPath}`, JSON.stringify(config, null, 2)); log.info(`» CLI config written to ${cliConfigPath}`);
log.debug(`» disallowed built-ins: ${JSON.stringify(deny)}`);
log.debug(`» CLI config contents: ${JSON.stringify(config, null, 2)}`);
} }
+6 -7
View File
@@ -149,7 +149,7 @@ const messageHandlers = {
if (event.status === "error") { if (event.status === "error") {
const errorMsg = const errorMsg =
typeof event.output === "string" ? event.output : JSON.stringify(event.output); typeof event.output === "string" ? event.output : JSON.stringify(event.output);
log.warning(`Tool call failed: ${errorMsg}`); log.info(`Tool call failed: ${errorMsg}`);
} else if (event.output) { } else if (event.output) {
// log successful tool result so it appears in output // log successful tool result so it appears in output
const outputStr = const outputStr =
@@ -270,8 +270,7 @@ export const gemini = agent({
onStderr: (chunk) => { onStderr: (chunk) => {
const trimmed = chunk.trim(); const trimmed = chunk.trim();
if (trimmed) { if (trimmed) {
log.debug(`[gemini stderr] ${trimmed}`); log.info(`[gemini stderr] ${trimmed}`);
log.warning(trimmed);
finalOutput += trimmed + "\n"; finalOutput += trimmed + "\n";
} }
}, },
@@ -286,7 +285,7 @@ export const gemini = agent({
// retry on transient API errors (500, 503, INTERNAL, etc.) // retry on transient API errors (500, 503, INTERNAL, etc.)
if (attempt < MAX_ATTEMPTS && isTransientApiError(errorMessage)) { if (attempt < MAX_ATTEMPTS && isTransientApiError(errorMessage)) {
log.warning( log.info(
`» transient Gemini API error on attempt ${attempt}/${MAX_ATTEMPTS}, retrying in ${RETRY_DELAY_MS / 1000}s...` `» transient Gemini API error on attempt ${attempt}/${MAX_ATTEMPTS}, retrying in ${RETRY_DELAY_MS / 1000}s...`
); );
await new Promise((resolve) => setTimeout(resolve, RETRY_DELAY_MS)); await new Promise((resolve) => setTimeout(resolve, RETRY_DELAY_MS));
@@ -313,7 +312,7 @@ export const gemini = agent({
// retry on transient API errors from spawn exceptions too // retry on transient API errors from spawn exceptions too
if (attempt < MAX_ATTEMPTS && isTransientApiError(errorMessage)) { if (attempt < MAX_ATTEMPTS && isTransientApiError(errorMessage)) {
log.warning( log.info(
`» transient Gemini API error on attempt ${attempt}/${MAX_ATTEMPTS}, retrying in ${RETRY_DELAY_MS / 1000}s...` `» transient Gemini API error on attempt ${attempt}/${MAX_ATTEMPTS}, retrying in ${RETRY_DELAY_MS / 1000}s...`
); );
await new Promise((resolve) => setTimeout(resolve, RETRY_DELAY_MS)); await new Promise((resolve) => setTimeout(resolve, RETRY_DELAY_MS));
@@ -345,7 +344,7 @@ function configureGeminiSettings(ctx: AgentRunContext): string {
// allow env var override for tests (e.g., to avoid flash RPD quota limits) // allow env var override for tests (e.g., to avoid flash RPD quota limits)
const model = process.env.GEMINI_MODEL ?? effortConfig.model; const model = process.env.GEMINI_MODEL ?? effortConfig.model;
const thinkingLevel = effortConfig.thinkingLevel; const thinkingLevel = effortConfig.thinkingLevel;
log.info(`» using model: ${model}, thinkingLevel: ${thinkingLevel}`); log.info(`» model: ${model} (thinkingLevel: ${thinkingLevel})`);
const realHome = homedir(); const realHome = homedir();
const geminiConfigDir = join(realHome, ".gemini"); const geminiConfigDir = join(realHome, ".gemini");
@@ -413,7 +412,7 @@ function configureGeminiSettings(ctx: AgentRunContext): string {
writeFileSync(settingsPath, JSON.stringify(newSettings, null, 2), "utf-8"); writeFileSync(settingsPath, JSON.stringify(newSettings, null, 2), "utf-8");
log.info(`» Gemini settings written to ${settingsPath}`); log.info(`» Gemini settings written to ${settingsPath}`);
if (exclude.length > 0) { if (exclude.length > 0) {
log.info(`» excluded tools: ${exclude.join(", ")}`); log.debug(`» disallowed built-ins: ${JSON.stringify(exclude)}`);
} }
return model; return model;
+35 -37
View File
@@ -72,7 +72,9 @@ export const opencode = agent({
const modelOverride = process.env.OPENCODE_MODEL; const modelOverride = process.env.OPENCODE_MODEL;
if (modelOverride) { if (modelOverride) {
args.push("--model", modelOverride); args.push("--model", modelOverride);
log.info(`» using model override: ${modelOverride}`); log.info(`» model: ${modelOverride} (override)`);
} else {
log.info(`» model: auto-selected by OpenCode`);
} }
process.env.HOME = tempHome; process.env.HOME = tempHome;
@@ -152,7 +154,7 @@ export const opencode = agent({
activeToolCalls > 0 activeToolCalls > 0
? ` (waiting for ${activeToolCalls} tool call${activeToolCalls > 1 ? "s" : ""})` ? ` (waiting for ${activeToolCalls} tool call${activeToolCalls > 1 ? "s" : ""})`
: " (OpenCode may be processing internally - LLM calls, planning, etc.)"; : " (OpenCode may be processing internally - LLM calls, planning, etc.)";
log.warning( log.info(
`» no activity for ${(timeSinceLastActivity / 1000).toFixed(1)}s${toolCallInfo} (${eventCount} events processed so far)` `» no activity for ${(timeSinceLastActivity / 1000).toFixed(1)}s${toolCallInfo} (${eventCount} events processed so far)`
); );
} }
@@ -184,15 +186,12 @@ export const opencode = agent({
const providerError = detectProviderError(trimmed); const providerError = detectProviderError(trimmed);
if (providerError) { if (providerError) {
lastProviderError = providerError; lastProviderError = providerError;
log.error(`» provider error detected (${providerError}): ${trimmed.substring(0, 500)}`); log.info(`» provider error detected (${providerError}): ${trimmed.substring(0, 500)}`);
} else { } else {
// try to parse as JSON for structured logging, fall back to warning // OpenCode's --print-logs output goes to stderr. demote internal
try { // INFO/DEBUG bus traffic to debug so it doesn't drown out tool
const parsed = JSON.parse(trimmed); // call logs in the GitHub Actions step output.
log.debug(JSON.stringify(parsed, null, 2)); log.debug(trimmed);
} catch {
log.warning(trimmed);
}
} }
}, },
}); });
@@ -208,9 +207,9 @@ export const opencode = agent({
const diagnosis = lastProviderError const diagnosis = lastProviderError
? `provider error: ${lastProviderError}` ? `provider error: ${lastProviderError}`
: "unknown cause (no stdout events received)"; : "unknown cause (no stdout events received)";
log.error(`» OpenCode produced 0 events (${diagnosis})`); log.info(`» OpenCode produced 0 events (${diagnosis})`);
if (stderrContext) { if (stderrContext) {
log.error(`» last stderr output:\n${stderrContext}`); log.info(`» last stderr output:\n${stderrContext}`);
} }
} }
@@ -264,12 +263,12 @@ export const opencode = agent({
? "OpenCode produced 0 stdout events - check if the model provider is reachable" ? "OpenCode produced 0 stdout events - check if the model provider is reachable"
: `${eventCount} events were processed before the hang`; : `${eventCount} events were processed before the hang`;
log.error( log.info(
`» OpenCode ${isActivityTimeout ? "hung" : "failed"} after ${(duration / 1000).toFixed(1)}s: ${errorMessage}` `» OpenCode ${isActivityTimeout ? "hung" : "failed"} after ${(duration / 1000).toFixed(1)}s: ${errorMessage}`
); );
log.error(`» diagnosis: ${diagnosis}`); log.info(`» diagnosis: ${diagnosis}`);
if (stderrContext) { if (stderrContext) {
log.error( log.info(
`» recent stderr (last ${Math.min(recentStderr.length, 10)} lines):\n${stderrContext}` `» recent stderr (last ${Math.min(recentStderr.length, 10)} lines):\n${stderrContext}`
); );
} }
@@ -325,9 +324,7 @@ function configureOpenCode(ctx: AgentRunContext): void {
} }
log.info(`» OpenCode config written to ${configPath}`); log.info(`» OpenCode config written to ${configPath}`);
log.info( log.debug(`» disallowed built-ins: ${JSON.stringify(permission)}`);
`» OpenCode permissions: edit=${permission.edit}, bash=${permission.bash}, webfetch=${permission.webfetch}`
);
log.debug(`OpenCode config contents:\n${configJson}`); log.debug(`OpenCode config contents:\n${configJson}`);
} }
@@ -558,27 +555,28 @@ const messageHandlers = {
const status = event.part?.state?.status; const status = event.part?.state?.status;
const output = event.part?.state?.output; const output = event.part?.state?.output;
// debug log all tool_use events to diagnose missing bash/MCP tool calls
if (!toolName || !toolId) { if (!toolName || !toolId) {
log.debug(`» tool_use event missing toolName or toolId: ${JSON.stringify(event)}`); // surface dropped tool_use events visibly so missing tool calls are diagnosable
log.info(
`» tool_use event missing toolName or toolId: ${JSON.stringify(event).substring(0, 500)}`
);
return;
} }
if (toolName && toolId) { // track tool call in current step
// track tool call in current step if (stepHistory.length > 0) {
if (stepHistory.length > 0) { stepHistory[stepHistory.length - 1].toolCalls.push(toolName);
stepHistory[stepHistory.length - 1].toolCalls.push(toolName); }
}
thinkingTimer.markToolCall(); thinkingTimer.markToolCall();
log.toolCall({ log.toolCall({
toolName, toolName,
input: parameters || {}, input: parameters || {},
}); });
// if tool already completed (status in same event), log output // if tool already completed (status in same event), log output
if (status === "completed" && output) { if (status === "completed" && output) {
log.debug(` output: ${output}`); log.debug(` output: ${output}`);
}
} }
}, },
tool_result: (event: OpenCodeToolResultEvent, thinkingTimer: ThinkingTimer) => { tool_result: (event: OpenCodeToolResultEvent, thinkingTimer: ThinkingTimer) => {
@@ -602,7 +600,7 @@ const messageHandlers = {
log.debug(` output: ${typeof output === "string" ? output : JSON.stringify(output)}`); log.debug(` output: ${typeof output === "string" ? output : JSON.stringify(output)}`);
} }
if (toolDuration > 5000) { if (toolDuration > 5000) {
log.warning( log.info(
`» ⚠️ tool call took ${(toolDuration / 1000).toFixed(1)}s - this may indicate network latency or slow processing` `» ⚠️ tool call took ${(toolDuration / 1000).toFixed(1)}s - this may indicate network latency or slow processing`
); );
} }
@@ -610,7 +608,7 @@ const messageHandlers = {
} }
if (status === "error") { if (status === "error") {
const errorMsg = typeof output === "string" ? output : JSON.stringify(output); const errorMsg = typeof output === "string" ? output : JSON.stringify(output);
log.error(`» ❌ tool call failed: ${errorMsg}`); log.info(`» ❌ tool call failed: ${errorMsg}`);
} else if (output) { } else if (output) {
// log successful tool result so it appears in captured output // log successful tool result so it appears in captured output
const outputStr = typeof output === "string" ? output : JSON.stringify(output); const outputStr = typeof output === "string" ? output : JSON.stringify(output);
@@ -626,7 +624,7 @@ const messageHandlers = {
); );
if (event.status === "error") { if (event.status === "error") {
log.error(`» OpenCode CLI failed: ${JSON.stringify(event)}`); log.info(`» OpenCode CLI failed: ${JSON.stringify(event)}`);
} else { } else {
// log tokens once at the end (use stats from result if available, otherwise use accumulated from step_finish) // log tokens once at the end (use stats from result if available, otherwise use accumulated from step_finish)
const inputTokens = event.stats?.input_tokens || accumulatedTokens.input || 0; const inputTokens = event.stats?.input_tokens || accumulatedTokens.input || 0;
+9 -19
View File
@@ -28,25 +28,15 @@ export const agent = <const input extends AgentInput>(input: input): defineAgent
return { return {
...input, ...input,
run: async (ctx: AgentRunContext): Promise<AgentResult> => { run: async (ctx: AgentRunContext): Promise<AgentResult> => {
const bash = ctx.payload.bash; log.info(`» agent: ${input.name}`);
const web = ctx.payload.web; log.info(`» effort: ${ctx.payload.effort}`);
const search = ctx.payload.search; if (ctx.payload.timeout) log.info(`» timeout: ${ctx.payload.timeout}`);
const push = ctx.payload.push; log.info(`» web: ${ctx.payload.web}`);
log.info( log.info(`» search: ${ctx.payload.search}`);
`» running ${input.name} with effort=${ctx.payload.effort}, timeout=${ctx.payload.timeout}...` log.info(`» push: ${ctx.payload.push}`);
); log.info(`» bash: ${ctx.payload.bash}`);
// build log box content: eventInstructions (if any) + user request (if any) + event data log.debug(`» payload: ${JSON.stringify(ctx.payload, null, 2)}`);
const logParts = [
ctx.instructions.eventInstructions
? `EVENT-LEVEL INSTRUCTIONS:\n${ctx.instructions.eventInstructions}`
: null,
ctx.instructions.user ? `USER REQUEST:\n${ctx.instructions.user}` : null,
ctx.instructions.event,
].filter(Boolean);
log.box(logParts.join("\n\n---\n\n"), {
title: "Instructions",
});
log.info(`» tool permissions: web=${web}, search=${search}, push=${push}, bash=${bash}`);
return input.run(ctx); return input.run(ctx);
}, },
...agentsManifest[input.name], ...agentsManifest[input.name],
+467 -455
View File
File diff suppressed because it is too large Load Diff
-3
View File
@@ -6,7 +6,6 @@
import * as core from "@actions/core"; import * as core from "@actions/core";
import { main } from "./main.ts"; import { main } from "./main.ts";
import { runCleanup } from "./utils/exitHandler.ts";
async function run(): Promise<void> { async function run(): Promise<void> {
try { try {
@@ -22,8 +21,6 @@ async function run(): Promise<void> {
} catch (error) { } catch (error) {
const errorMessage = error instanceof Error ? error.message : "Unknown error occurred"; const errorMessage = error instanceof Error ? error.message : "Unknown error occurred";
core.setFailed(`Action failed: ${errorMessage}`); core.setFailed(`Action failed: ${errorMessage}`);
} finally {
await runCleanup();
} }
} }
+18 -14
View File
@@ -3,6 +3,8 @@
import { build } from "esbuild"; import { build } from "esbuild";
import { readFileSync, writeFileSync } from "fs"; import { readFileSync, writeFileSync } from "fs";
const isMainOnlyBuild = process.argv.includes("--main-only");
// Plugin to strip shebangs from output files // Plugin to strip shebangs from output files
/** /**
* @type {import("esbuild").Plugin} * @type {import("esbuild").Plugin}
@@ -67,20 +69,22 @@ await build({
plugins: [stripShebangPlugin], plugins: [stripShebangPlugin],
}); });
// Build the post cleanup entry bundle if (!isMainOnlyBuild) {
await build({ // Build the post cleanup entry bundle
...sharedConfig, await build({
entryPoints: ["./post.ts"], ...sharedConfig,
outfile: "./post", entryPoints: ["./post.ts"],
plugins: [stripShebangPlugin], outfile: "./post",
}); plugins: [stripShebangPlugin],
});
// Build the get-installation-token action // Build the get-installation-token action
await build({ await build({
...sharedConfig, ...sharedConfig,
entryPoints: ["./get-installation-token/entry.ts"], entryPoints: ["./get-installation-token/entry.ts"],
outfile: "./get-installation-token/entry", outfile: "./get-installation-token/entry",
plugins: [stripShebangPlugin], plugins: [stripShebangPlugin],
}); })
}
console.log("» build completed successfully"); console.log("» build completed successfully");
+24
View File
@@ -61,6 +61,28 @@ export type ToolPermission = "disabled" | "enabled";
export type BashPermission = "disabled" | "restricted" | "enabled"; export type BashPermission = "disabled" | "restricted" | "enabled";
export type PushPermission = "disabled" | "restricted" | "enabled"; export type PushPermission = "disabled" | "restricted" | "enabled";
// workflow yml permissions for GITHUB_TOKEN
export type WorkflowPermissionValue = "read" | "write" | "none";
export type WorkflowIdTokenPermissionValue = "write" | "none";
export interface WorkflowPermissions {
actions?: WorkflowPermissionValue;
attestations?: WorkflowPermissionValue;
checks?: WorkflowPermissionValue;
contents?: WorkflowPermissionValue;
deployments?: WorkflowPermissionValue;
discussions?: WorkflowPermissionValue;
"id-token"?: WorkflowIdTokenPermissionValue;
issues?: WorkflowPermissionValue;
models?: WorkflowPermissionValue;
packages?: WorkflowPermissionValue;
pages?: WorkflowPermissionValue;
"pull-requests"?: WorkflowPermissionValue;
"repository-projects"?: WorkflowPermissionValue;
"security-events"?: WorkflowPermissionValue;
statuses?: WorkflowPermissionValue;
}
// permission level for the author who triggered the event // permission level for the author who triggered the event
// matches GitHub's permission levels: admin > write > maintain > triage > read > none // matches GitHub's permission levels: admin > write > maintain > triage > read > none
export type AuthorPermission = "admin" | "maintain" | "write" | "triage" | "read" | "none"; export type AuthorPermission = "admin" | "maintain" | "write" | "triage" | "read" | "none";
@@ -250,6 +272,8 @@ export interface WriteablePayload {
agent?: AgentName | undefined; agent?: AgentName | undefined;
/** the user's actual request (body if @pullfrog tagged) */ /** the user's actual request (body if @pullfrog tagged) */
prompt: string; prompt: string;
/** github username of the human who triggered this workflow run */
triggeringUser?: string | undefined;
/** event-level instructions for this trigger type (flag-expanded server-side) */ /** event-level instructions for this trigger type (flag-expanded server-side) */
eventInstructions?: string | undefined; eventInstructions?: string | undefined;
/** repo-level instructions (flag-expanded server-side) */ /** repo-level instructions (flag-expanded server-side) */
+92
View File
@@ -0,0 +1,92 @@
# `pullfrog/get-installation-token`
Get a GitHub App installation token in a workflow job. This convenience action makes it easier to integrate Pullfrog into existing CI workflows.
This action:
- Provides a GitHub App installation token for later workflow steps.
- Works for the current repository out of the box.
- Can optionally include additional repositories.
- Masks the token in logs.
- Revokes the token automatically in the post step.
## Requirements
- Workflow or job permissions must include `id-token: write`.
- The Pullfrog GitHub App must be installed on the target repositories.
- If you pass `repos`, each repository must be installed for the same app installation.
## Inputs
| Name | Required | Description |
| --- | --- | --- |
| `repos` | no | Comma-separated additional repo names to include, for example: `repo1,repo2`. The current repo is always included. |
## Outputs
| Name | Description |
| --- | --- |
| `token` | GitHub App installation token |
## Usage
### Basic (current repo only)
```yaml
permissions:
id-token: write
contents: read
jobs:
example:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
- name: Get installation token
id: token
uses: ./action/get-installation-token
- name: Call GitHub API with token
run: gh api repos/${{ github.repository }}
env:
GH_TOKEN: ${{ steps.token.outputs.token }}
```
### Include extra repositories
```yaml
permissions:
id-token: write
contents: read
jobs:
example:
runs-on: ubuntu-latest
steps:
- name: Get token for current repo plus extra repos
id: token
uses: ./action/get-installation-token
with:
repos: pullfrog,app
- name: Checkout another repo with installation token
uses: actions/checkout@v4
with:
repository: pullfrog/pullfrog
token: ${{ steps.token.outputs.token }}
path: action-repo
```
## Notes
- `repos` expects repository names, not `owner/repo`.
- Token lifetime is managed by GitHub, but this action also revokes the token during post-run cleanup.
- Prefer step output usage (`${{ steps.<id>.outputs.token }}`) rather than writing tokens to files.
## Troubleshooting
- `Error: id-token permission is required`:
Add `id-token: write` in workflow or job permissions.
- Token works for current repo but not an extra repo:
Ensure that repository is listed in `repos` and the app installation has access to it.
+70 -49
View File
@@ -399,7 +399,7 @@ var require_tunnel = __commonJS({
connectOptions.headers = connectOptions.headers || {}; connectOptions.headers = connectOptions.headers || {};
connectOptions.headers["Proxy-Authorization"] = "Basic " + new Buffer(connectOptions.proxyAuth).toString("base64"); connectOptions.headers["Proxy-Authorization"] = "Basic " + new Buffer(connectOptions.proxyAuth).toString("base64");
} }
debug2("making CONNECT request"); debug3("making CONNECT request");
var connectReq = self2.request(connectOptions); var connectReq = self2.request(connectOptions);
connectReq.useChunkedEncodingByDefault = false; connectReq.useChunkedEncodingByDefault = false;
connectReq.once("response", onResponse); connectReq.once("response", onResponse);
@@ -419,7 +419,7 @@ var require_tunnel = __commonJS({
connectReq.removeAllListeners(); connectReq.removeAllListeners();
socket.removeAllListeners(); socket.removeAllListeners();
if (res.statusCode !== 200) { if (res.statusCode !== 200) {
debug2( debug3(
"tunneling socket could not be established, statusCode=%d", "tunneling socket could not be established, statusCode=%d",
res.statusCode res.statusCode
); );
@@ -431,7 +431,7 @@ var require_tunnel = __commonJS({
return; return;
} }
if (head.length > 0) { if (head.length > 0) {
debug2("got illegal response body from proxy"); debug3("got illegal response body from proxy");
socket.destroy(); socket.destroy();
var error2 = new Error("got illegal response body from proxy"); var error2 = new Error("got illegal response body from proxy");
error2.code = "ECONNRESET"; error2.code = "ECONNRESET";
@@ -439,13 +439,13 @@ var require_tunnel = __commonJS({
self2.removeSocket(placeholder); self2.removeSocket(placeholder);
return; return;
} }
debug2("tunneling connection has established"); debug3("tunneling connection has established");
self2.sockets[self2.sockets.indexOf(placeholder)] = socket; self2.sockets[self2.sockets.indexOf(placeholder)] = socket;
return cb(socket); return cb(socket);
} }
function onError(cause) { function onError(cause) {
connectReq.removeAllListeners(); connectReq.removeAllListeners();
debug2( debug3(
"tunneling socket could not be established, cause=%s\n", "tunneling socket could not be established, cause=%s\n",
cause.message, cause.message,
cause.stack cause.stack
@@ -507,9 +507,9 @@ var require_tunnel = __commonJS({
} }
return target; return target;
} }
var debug2; var debug3;
if (process.env.NODE_DEBUG && /\btunnel\b/.test(process.env.NODE_DEBUG)) { if (process.env.NODE_DEBUG && /\btunnel\b/.test(process.env.NODE_DEBUG)) {
debug2 = function() { debug3 = function() {
var args = Array.prototype.slice.call(arguments); var args = Array.prototype.slice.call(arguments);
if (typeof args[0] === "string") { if (typeof args[0] === "string") {
args[0] = "TUNNEL: " + args[0]; args[0] = "TUNNEL: " + args[0];
@@ -519,10 +519,10 @@ var require_tunnel = __commonJS({
console.error.apply(console, args); console.error.apply(console, args);
}; };
} else { } else {
debug2 = function() { debug3 = function() {
}; };
} }
exports.debug = debug2; exports.debug = debug3;
} }
}); });
@@ -19733,10 +19733,10 @@ Support boolean input list: \`true | True | TRUE | false | False | FALSE\``);
return process.env["RUNNER_DEBUG"] === "1"; return process.env["RUNNER_DEBUG"] === "1";
} }
exports.isDebug = isDebug2; exports.isDebug = isDebug2;
function debug2(message) { function debug3(message) {
(0, command_1.issueCommand)("debug", {}, message); (0, command_1.issueCommand)("debug", {}, message);
} }
exports.debug = debug2; exports.debug = debug3;
function error2(message, properties = {}) { function error2(message, properties = {}) {
(0, command_1.issueCommand)("error", (0, utils_1.toCommandProperties)(properties), message instanceof Error ? message.toString() : message); (0, command_1.issueCommand)("error", (0, utils_1.toCommandProperties)(properties), message instanceof Error ? message.toString() : message);
} }
@@ -25515,7 +25515,12 @@ var isGitHubActions = !!process.env.GITHUB_ACTIONS;
var isInsideDocker = existsSync("/.dockerenv"); var isInsideDocker = existsSync("/.dockerenv");
// utils/log.ts // utils/log.ts
var isDebugEnabled = () => process.env.LOG_LEVEL === "debug" || process.env.ACTIONS_STEP_DEBUG === "true" || core.isDebug(); var isRunnerDebugEnabled = () => core.isDebug();
var isLocalDebugEnabled = () => process.env.LOG_LEVEL === "debug" || process.env.ACTIONS_STEP_DEBUG === "true";
var isDebugEnabled = () => isLocalDebugEnabled() || isRunnerDebugEnabled();
function ts() {
return isDebugEnabled() ? `[${(/* @__PURE__ */ new Date()).toISOString()}] ` : "";
}
function formatArgs(args) { function formatArgs(args) {
return args.map((arg) => { return args.map((arg) => {
if (typeof arg === "string") return arg; if (typeof arg === "string") return arg;
@@ -25626,19 +25631,16 @@ function separator(length = 50) {
const separatorText = "\u2500".repeat(length); const separatorText = "\u2500".repeat(length);
core.info(separatorText); core.info(separatorText);
} }
function ts() {
return isDebugEnabled() ? `[${(/* @__PURE__ */ new Date()).toISOString()}] ` : "";
}
var log = { var log = {
/** Print info message */ /** Print info message */
info: (...args) => { info: (...args) => {
core.info(`${ts()}${formatArgs(args)}`); core.info(`${ts()}${formatArgs(args)}`);
}, },
/** Print warning message */ /** Print a warning message. Use only for warnings that should be displayed in the job summary. */
warning: (...args) => { warning: (...args) => {
core.warning(`${ts()}${formatArgs(args)}`); core.warning(`${ts()}${formatArgs(args)}`);
}, },
/** Print error message */ /** Print an error message. Use only for errors that should be displayed in the job summary. */
error: (...args) => { error: (...args) => {
core.error(`${ts()}${formatArgs(args)}`); core.error(`${ts()}${formatArgs(args)}`);
}, },
@@ -25646,10 +25648,14 @@ var log = {
success: (...args) => { success: (...args) => {
core.info(`${ts()}\xBB ${formatArgs(args)}`); core.info(`${ts()}\xBB ${formatArgs(args)}`);
}, },
/** Print debug message (only if LOG_LEVEL=debug) */ /** Print debug message (only when debug mode is enabled) */
debug: (...args) => { debug: (...args) => {
if (isDebugEnabled()) { if (isRunnerDebugEnabled()) {
core.info(`[${(/* @__PURE__ */ new Date()).toISOString()}] [DEBUG] ${formatArgs(args)}`); core.debug(formatArgs(args));
return;
}
if (isLocalDebugEnabled()) {
core.info(`${ts()}[DEBUG] ${formatArgs(args)}`);
} }
}, },
/** Print a formatted box with text */ /** Print a formatted box with text */
@@ -25681,15 +25687,43 @@ var core2 = __toESM(require_core(), 1);
import { createSign } from "node:crypto"; import { createSign } from "node:crypto";
// utils/apiUrl.ts // utils/apiUrl.ts
function getApiUrl() { function isLocalUrl(url) {
const url = process.env.API_URL || "https://pullfrog.com"; return url.hostname === "localhost" || url.hostname === "127.0.0.1";
log.debug(`resolved API_URL: ${url}`);
return url;
} }
function getVercelBypassHeaders() { function getApiUrl() {
const secret = process.env.VERCEL_AUTOMATION_BYPASS_SECRET; const raw = process.env.API_URL || "https://pullfrog.com";
if (!secret) return {}; const parsed = new URL(raw);
return { "x-vercel-protection-bypass": secret }; if (parsed.protocol !== "https:" && !isLocalUrl(parsed)) {
throw new Error(
`API_URL must use https:// (got ${parsed.protocol}). only localhost is exempt.`
);
}
log.debug(`resolved API_URL: ${raw}`);
return raw;
}
// utils/apiFetch.ts
async function apiFetch(options) {
const apiUrl = getApiUrl();
const url = new URL(options.path, apiUrl);
const bypassSecret = process.env.VERCEL_AUTOMATION_BYPASS_SECRET;
if (bypassSecret) {
url.searchParams.set("x-vercel-protection-bypass", bypassSecret);
}
const headers = {
...options.headers
};
if (bypassSecret) {
headers["x-vercel-protection-bypass"] = bypassSecret;
}
log.debug(`api fetch: ${options.method ?? "GET"} ${url.pathname}`);
const init = {
method: options.method ?? "GET",
headers
};
if (options.body) init.body = options.body;
if (options.signal) init.signal = options.signal;
return fetch(url.toString(), init);
} }
// utils/retry.ts // utils/retry.ts
@@ -25712,9 +25746,7 @@ async function retry(fn, options = {}) {
throw error2; throw error2;
} }
const delay = delayMs * attempt; const delay = delayMs * attempt;
log.warning( log.info(`\xBB ${label} failed (attempt ${attempt}/${maxAttempts}), retrying in ${delay}ms...`);
`\xBB ${label} failed (attempt ${attempt}/${maxAttempts}), retrying in ${delay}ms...`
);
await new Promise((resolve) => setTimeout(resolve, delay)); await new Promise((resolve) => setTimeout(resolve, delay));
} }
} }
@@ -25729,37 +25761,26 @@ function isOIDCAvailable() {
} }
async function acquireTokenViaOIDC(opts) { async function acquireTokenViaOIDC(opts) {
const oidcToken = await core2.getIDToken("pullfrog-api"); const oidcToken = await core2.getIDToken("pullfrog-api");
const apiUrl = getApiUrl();
const params = new URLSearchParams();
const repos = [...opts?.repos ?? []]; const repos = [...opts?.repos ?? []];
const targetRepo = process.env.GITHUB_REPOSITORY?.split("/")[1]; const targetRepo = process.env.GITHUB_REPOSITORY?.split("/")[1];
if (targetRepo) { if (targetRepo) {
repos.push(targetRepo); repos.push(targetRepo);
} }
if (repos.length) { const reposParam = repos.length ? `?repos=${repos.join(",")}` : "";
params.set("repos", repos.join(","));
}
const queryString = params.toString() ? `?${params.toString()}` : "";
const timeoutMs = 3e4; const timeoutMs = 3e4;
const controller = new AbortController(); const controller = new AbortController();
const timeoutId = setTimeout(() => controller.abort(), timeoutMs); const timeoutId = setTimeout(() => controller.abort(), timeoutMs);
try { try {
const fetchOptions = { const tokenResponse = await apiFetch({
path: `/api/github/installation-token${reposParam}`,
method: "POST", method: "POST",
headers: { headers: {
Authorization: `Bearer ${oidcToken}`, Authorization: `Bearer ${oidcToken}`,
"Content-Type": "application/json", "Content-Type": "application/json"
...getVercelBypassHeaders()
}, },
body: opts?.permissions ? JSON.stringify({ permissions: opts.permissions }) : void 0,
signal: controller.signal signal: controller.signal
}; });
if (opts?.permissions) {
fetchOptions.body = JSON.stringify({ permissions: opts.permissions });
}
const tokenResponse = await fetch(
`${apiUrl}/api/github/installation-token${queryString}`,
fetchOptions
);
clearTimeout(timeoutId); clearTimeout(timeoutId);
if (!tokenResponse.ok) { if (!tokenResponse.ok) {
throw new Error(`Token exchange failed: ${tokenResponse.status} ${tokenResponse.statusText}`); throw new Error(`Token exchange failed: ${tokenResponse.status} ${tokenResponse.statusText}`);
@@ -25908,7 +25929,7 @@ async function revokeGitHubInstallationToken(token) {
}); });
log.debug("\xBB installation token revoked"); log.debug("\xBB installation token revoked");
} catch (error2) { } catch (error2) {
log.warning( log.info(
`Failed to revoke installation token: ${error2 instanceof Error ? error2.message : String(error2)}` `Failed to revoke installation token: ${error2 instanceof Error ? error2.message : String(error2)}`
); );
} }
+38
View File
@@ -0,0 +1,38 @@
/**
* Internal entrypoint for the root app.
* Re-exports shared types, values, and utilities needed by the Next.js app.
*/
export type {
AgentApiKeyName,
AgentManifest,
AuthorPermission,
BashPermission,
Payload,
PayloadEvent,
PushPermission,
ToolPermission,
WriteablePayload,
} from "../external.ts";
export {
AgentName,
agentsManifest,
Effort,
ghPullfrogMcpName,
} from "../external.ts";
export type {
AgentInfo,
BuildPullfrogFooterParams,
WorkflowRunFooterInfo,
} from "../utils/buildPullfrogFooter.ts";
export {
buildPullfrogFooter,
PULLFROG_DIVIDER,
stripExistingFooter,
} from "../utils/buildPullfrogFooter.ts";
export {
isValidTimeString,
parseTimeString,
TIMEOUT_DISABLED,
} from "../utils/time.ts";
+12 -6
View File
@@ -12,7 +12,6 @@ import { validateAgentApiKey } from "./utils/apiKeys.ts";
import { resolveBody } from "./utils/body.ts"; import { resolveBody } from "./utils/body.ts";
import { log, writeSummary } from "./utils/cli.ts"; import { log, writeSummary } from "./utils/cli.ts";
import { reportErrorToComment } from "./utils/errorReport.ts"; import { reportErrorToComment } from "./utils/errorReport.ts";
import { setupExitHandler } from "./utils/exitHandler.ts";
import { resolveGit } from "./utils/gitAuth.ts"; import { resolveGit } from "./utils/gitAuth.ts";
import { createOctokit } from "./utils/github.ts"; import { createOctokit } from "./utils/github.ts";
import { resolveInstructions } from "./utils/instructions.ts"; import { resolveInstructions } from "./utils/instructions.ts";
@@ -52,8 +51,6 @@ export async function main(): Promise<MainResult> {
typeof resolvedPromptInput !== "string" ? resolvedPromptInput.progressCommentId : undefined, typeof resolvedPromptInput !== "string" ? resolvedPromptInput.progressCommentId : undefined,
}); });
setupExitHandler(toolState);
// resolve and fingerprint git binary before any agent code runs // resolve and fingerprint git binary before any agent code runs
resolveGit(); resolveGit();
@@ -167,6 +164,17 @@ export async function main(): Promise<MainResult> {
repo: runContext.repo, repo: runContext.repo,
modes, modes,
}); });
// log instructions as soon as they are fully resolved
const logParts = [
instructions.eventInstructions
? `EVENT-LEVEL INSTRUCTIONS:\n${instructions.eventInstructions}`
: null,
instructions.user ? `USER REQUEST:\n${instructions.user}` : null,
instructions.event,
].filter(Boolean);
log.box(logParts.join("\n\n---\n\n"), {
title: "Instructions",
});
// run agent, optionally with timeout enforcement // run agent, optionally with timeout enforcement
activityTimeout = createProcessOutputActivityTimeout({ activityTimeout = createProcessOutputActivityTimeout({
@@ -236,8 +244,6 @@ export async function main(): Promise<MainResult> {
error: errorMessage, error: errorMessage,
}; };
} finally { } finally {
if (activityTimeout) { activityTimeout?.stop();
activityTimeout.stop();
}
} }
} }
-206
View File
@@ -1,206 +0,0 @@
# gh_pullfrog MCP Tools
this directory contains the mcp (model context protocol) server tools for interacting with github.
## available tools
### check suite tools
#### `get_check_suite_logs`
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
**replaces:** `gh run list` and `gh run view --log`
**returns:**
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
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 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:**
- `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
// when handling a pull_request_review_submitted webhook
await mcp.call("gh_pullfrog/get_review_comments", {
pull_number: 47,
review_id: review.id
});
```
#### `list_pull_request_reviews`
list all reviews for a pull request.
**parameters:**
- `pull_number` (number): the pull request number
**replaces:** `gh api repos/{owner}/{repo}/pulls/{pull_number}/reviews`
**returns:**
array of reviews with:
- review id, body, state (approved/changes_requested/commented)
- user, commit_id, submitted_at, html_url
**example:**
```typescript
await mcp.call("gh_pullfrog/list_pull_request_reviews", {
pull_number: 47
});
```
#### `reply_to_review_comment`
reply to a PR review comment thread explaining how the feedback was addressed.
**parameters:**
- `pull_number` (number): the pull request number
- `comment_id` (number): the ID of the review comment to reply to
- `body` (string): the reply text explaining how the feedback was addressed
**replaces:** `gh api repos/{owner}/{repo}/pulls/{pull_number}/comments/{comment_id}/replies`
**returns:**
the created reply comment including:
- comment id, body, html_url
- in_reply_to_id showing it's a reply to the specified comment
**example:**
```typescript
// after addressing a review comment
await mcp.call("gh_pullfrog/reply_to_review_comment", {
pull_number: 47,
comment_id: 2567334961,
body: "removed the function as requested"
});
```
### output tools
#### `set_output`
set the action output for consumption by subsequent workflow steps. useful when pullfrog is used as a step in a user-defined CI workflow (e.g., generating release notes).
**parameters:**
- `value` (string): the output value to expose
**returns:**
- `success`: true on success
the value will be available as the `result` output of the action, accessible via `${{ steps.<step-id>.outputs.result }}`.
**example:**
```typescript
// when generating content for downstream consumption
await mcp.call("gh_pullfrog/set_output", {
value: "## Release Notes\n\n- Added new feature X\n- Fixed bug Y"
});
```
**usage in workflow:**
```yaml
- uses: pullfrog/pullfrog@v1
id: notes
with:
prompt: "Generate release notes for v2.0.0"
- uses: softprops/action-gh-release@v1
with:
body: ${{ steps.notes.outputs.result }}
```
### other tools
see individual files for documentation on other tools:
- `comment.ts` - create, edit, and update comments
- `issue.ts` - create issues
- `output.ts` - set action output for workflow consumption
- `pr.ts` - create pull requests
- `prInfo.ts` - get pull request information
- `review.ts` - create pull request reviews
- `delegate.ts` - delegate task to a subagent with a specific mode and effort level
## usage in agents
agents should prefer using the mcp tools provided by this server. the `gh` cli is available as a fallback if needed, but mcp tools handle authentication and provide better integration.
the agent instructions automatically include guidance on using these tools.
+3 -3
View File
@@ -78,7 +78,7 @@ function detectSandboxMethod(): SandboxMethod {
} }
detectedSandboxMethod = "none"; detectedSandboxMethod = "none";
log.warning("PID namespace isolation not available - falling back to env filtering only"); log.info("PID namespace isolation not available - falling back to env filtering only");
return "none"; return "none";
} }
@@ -243,8 +243,8 @@ Use this tool to:
const finalExitCode = exitCode ?? (timedOut ? 124 : -1); const finalExitCode = exitCode ?? (timedOut ? 124 : -1);
if (finalExitCode !== 0) { if (finalExitCode !== 0) {
log.error(`bash command failed with exit code ${finalExitCode}: ${params.command}`); log.info(`bash command failed with exit code ${finalExitCode}: ${params.command}`);
if (output) log.error(`output: ${output.trim()}`); if (output) log.info(`output: ${output.trim()}`);
} }
return { return {
+1 -1
View File
@@ -213,7 +213,7 @@ export function GetCheckSuiteLogsTool(ctx: ToolContext) {
log.debug(`analyzed logs for job ${job.name}: ${analysis.index.length} indexed lines`); log.debug(`analyzed logs for job ${job.name}: ${analysis.index.length} indexed lines`);
} catch (error) { } catch (error) {
log.error(`failed to fetch logs for job ${job.id}: ${error}`); log.info(`failed to fetch logs for job ${job.id}: ${error}`);
} }
} }
} }
+16 -3
View File
@@ -132,7 +132,8 @@ export type CheckoutPrResult = {
number: number; number: number;
title: string; title: string;
base: string; base: string;
head: string; localBranch: string;
remoteBranch: string;
isFork: boolean; isFork: boolean;
maintainerCanModify: boolean; maintainerCanModify: boolean;
url: string; url: string;
@@ -289,6 +290,15 @@ export async function checkoutPrBranch(
toolState.pushUrl = `https://github.com/${headRepo.full_name}.git`; toolState.pushUrl = `https://github.com/${headRepo.full_name}.git`;
} }
// store push destination so push_branch can use it directly
// git config is the primary mechanism, but toolState serves as a reliable fallback
// in case git config reads fail in certain environments
toolState.pushDest = {
remoteName: isFork ? `pr-${pullNumber}` : "origin",
remoteBranch: headBranch,
localBranch,
};
// execute post-checkout lifecycle hook // execute post-checkout lifecycle hook
await executeLifecycleHook({ await executeLifecycleHook({
event: "post-checkout", event: "post-checkout",
@@ -356,7 +366,8 @@ export function CheckoutPrTool(ctx: ToolContext) {
number: pr.data.number, number: pr.data.number,
title: pr.data.title, title: pr.data.title,
base: pr.data.base.ref, base: pr.data.base.ref,
head: pr.data.head.ref, localBranch: `pr-${pull_number}`,
remoteBranch: `refs/heads/${pr.data.head.ref}`,
isFork: headRepo.full_name !== pr.data.base.repo.full_name, isFork: headRepo.full_name !== pr.data.base.repo.full_name,
maintainerCanModify: pr.data.maintainer_can_modify, maintainerCanModify: pr.data.maintainer_can_modify,
url: pr.data.html_url, url: pr.data.html_url,
@@ -367,7 +378,9 @@ export function CheckoutPrTool(ctx: ToolContext) {
`the diff file at diffPath contains a table of contents (TOC) at the top listing every changed file with its line range. ` + `the diff file at diffPath contains a table of contents (TOC) at the top listing every changed file with its line range. ` +
`use the line ranges to read specific files from the diff instead of reading the entire file. ` + `use the line ranges to read specific files from the diff instead of reading the entire file. ` +
`for example, if the TOC says "src/foo.ts → lines 5-42", read lines 5-42 from diffPath to see that file's changes. ` + `for example, if the TOC says "src/foo.ts → lines 5-42", read lines 5-42 from diffPath to see that file's changes. ` +
`review files selectively based on relevance rather than reading everything sequentially.`, `review files selectively based on relevance rather than reading everything sequentially. ` +
`the local branch is 'localBranch' (pr-{number}), not the remote branch name. ` +
`when pushing, omit branchName to use the current branch. do not use remoteBranch as a local branch name.`,
} satisfies CheckoutPrResult; } satisfies CheckoutPrResult;
}), }),
}); });
+16 -5
View File
@@ -148,10 +148,14 @@ export const ReportProgress = type({
}); });
/** /**
* Standalone function to report progress to GitHub comment. * Report progress to a GitHub comment.
* Can be called directly without going through the MCP tool interface. *
* Returns result data if successful. * progressCommentId has three states:
* When there's no comment target (no progressCommentId and no issueNumber), returns a "skipped" result. * - undefined: no comment yet — will create one if an issue/PR target exists
* - number: active comment — will update it in place
* - null: deliberately deleted (e.g. after submitting a PR review) — skips silently
*
* The body is always tracked in lastProgressBody for the job summary regardless of comment state.
*/ */
export async function reportProgress( export async function reportProgress(
ctx: ToolContext, ctx: ToolContext,
@@ -201,6 +205,11 @@ export async function reportProgress(
}; };
} }
// null = progress comment was deliberately deleted (e.g. by create_pull_request_review)
if (existingCommentId === null) {
return { body, action: "skipped" };
}
// no existing comment - need an issue/PR to create one on // no existing comment - need an issue/PR to create one on
// use fallback chain: dynamically set context > event payload // use fallback chain: dynamically set context > event payload
if (issueNumber === undefined) { if (issueNumber === undefined) {
@@ -288,6 +297,8 @@ export function ReportProgressTool(ctx: ToolContext) {
/** /**
* Delete the progress comment if it exists. * Delete the progress comment if it exists.
* Used after submitting a PR review since the review body contains all necessary info. * Used after submitting a PR review since the review body contains all necessary info.
* Sets progressCommentId to null, which prevents future report_progress calls from
* creating a new comment (the agent may call report_progress again after this).
*/ */
export async function deleteProgressComment(ctx: ToolContext): Promise<boolean> { export async function deleteProgressComment(ctx: ToolContext): Promise<boolean> {
const existingCommentId = ctx.toolState.progressCommentId; const existingCommentId = ctx.toolState.progressCommentId;
@@ -310,7 +321,7 @@ export async function deleteProgressComment(ctx: ToolContext): Promise<boolean>
} }
} }
// reset state and mark as updated so post script doesn't try to handle it // set to null (not undefined) so report_progress skips instead of creating a new comment
ctx.toolState.progressCommentId = null; ctx.toolState.progressCommentId = null;
ctx.toolState.wasUpdated = true; ctx.toolState.wasUpdated = true;
+4 -3
View File
@@ -12,7 +12,7 @@ export const DelegateParams = type({
"the name of the mode to delegate to (e.g., 'Build', 'Plan', 'Review', 'Fix', 'AddressReviews')" "the name of the mode to delegate to (e.g., 'Build', 'Plan', 'Review', 'Fix', 'AddressReviews')"
), ),
"effort?": Effort.describe( "effort?": Effort.describe(
'effort level for the subagent: "mini" (fast), "auto" (default, highly capable), or "max" (maximum capability)' 'effort level for the subagent: "mini" (low-effort and fast, only for simple tasks), "auto" (medium-effort, good for typical tasks that don\'t require significant reasoning), or "max" (high-effort, good for PR reviews and complex coding tasks)'
), ),
"instructions?": type.string.describe( "instructions?": type.string.describe(
"optional additional context or instructions for the subagent — use this to pass results from earlier delegations or narrow the subagent's focus" "optional additional context or instructions for the subagent — use this to pass results from earlier delegations or narrow the subagent's focus"
@@ -45,7 +45,8 @@ export function DelegateTool(ctx: ToolContext) {
// guard: prevent subagent recursion // guard: prevent subagent recursion
if (ctx.toolState.delegationActive) { if (ctx.toolState.delegationActive) {
return { return {
error: "delegation is not available inside a delegated subagent", error:
"delegation is not available inside a subagent. you are already running as a delegated subagent. complete the task directly using the available tools. do not attempt to delegate further. your last agent message will be passed to your supervisor and it will decide what to do next.",
}; };
} }
@@ -111,7 +112,7 @@ export function DelegateTool(ctx: ToolContext) {
} catch (err) { } catch (err) {
// normalize agent crashes into the same return shape as clean failures // normalize agent crashes into the same return shape as clean failures
const errorMessage = err instanceof Error ? err.message : String(err); const errorMessage = err instanceof Error ? err.message : String(err);
log.error(`» delegation to ${selectedMode.name} crashed: ${errorMessage}`); log.info(`» delegation to ${selectedMode.name} crashed: ${errorMessage}`);
return { return {
success: false, success: false,
mode: selectedMode.name, mode: selectedMode.name,
+103 -88
View File
@@ -9,6 +9,7 @@ import {
} from "node:fs"; } from "node:fs";
import { dirname, resolve } from "node:path"; import { dirname, resolve } from "node:path";
import { type } from "arktype"; import { type } from "arktype";
import type { BashPermission } from "../external.ts";
import type { ToolContext } from "./server.ts"; import type { ToolContext } from "./server.ts";
import { execute, tool } from "./shared.ts"; import { execute, tool } from "./shared.ts";
@@ -38,59 +39,6 @@ export const ListDirectoryParams = type({
path: "string", path: "string",
}); });
// resolve path and validate it is within the repository.
// uses realpathSync to follow symlinks and prevent symlink-based escapes.
//
// threat model: the primary scenario where symlink protection matters is a
// malicious PR that plants symlinks in the repo (e.g. `secrets -> /etc/shadow`).
// git materializes symlinks on linux, so after checkout the working tree contains
// live symlinks. without realpathSync, file_read("secrets") would leak host files.
//
// when bash is enabled the agent can already `cat /etc/shadow` directly, so the
// symlink check is defense-in-depth for that case. the check is most meaningful
// when bash is disabled — the agent's only filesystem access is through these MCP
// tools, and pre-planted symlinks become the sole escape vector.
//
// the path traversal check (resolve + startsWith) is always useful regardless of
// bash — it blocks `../../etc/passwd` style escapes on every configuration.
function resolveAndValidatePath(filePath: string): string {
const cwd = realpathSync(process.cwd());
const resolved = resolve(cwd, filePath);
// if the target exists, resolve symlinks to get the real location
if (existsSync(resolved)) {
const real = realpathSync(resolved);
if (real !== cwd && !real.startsWith(cwd + "/")) {
throw new Error(`path must be within the repository (symlink escape blocked): ${filePath}`);
}
return real;
}
// target doesn't exist yet (common for writes) — walk up to find
// the first existing ancestor and verify it resolves within the repo.
// this prevents creating files through symlinked parent directories.
let ancestor = dirname(resolved);
while (!existsSync(ancestor)) {
const parent = dirname(ancestor);
if (parent === ancestor) break; // reached filesystem root
ancestor = parent;
}
if (existsSync(ancestor)) {
const realAncestor = realpathSync(ancestor);
if (realAncestor !== cwd && !realAncestor.startsWith(cwd + "/")) {
throw new Error(`path must be within the repository (symlink escape blocked): ${filePath}`);
}
}
// also verify the resolved path (without symlink resolution) is within cwd
if (resolved !== cwd && !resolved.startsWith(cwd + "/")) {
throw new Error(`path must be within the repository: ${filePath}`);
}
return resolved;
}
// SECURITY: files that git interprets and can trigger code execution. // SECURITY: files that git interprets and can trigger code execution.
// .gitattributes can define filter drivers (clean/smudge) that execute arbitrary commands. // .gitattributes can define filter drivers (clean/smudge) that execute arbitrary commands.
// .gitmodules can reference malicious submodule URLs that execute code on update. // .gitmodules can reference malicious submodule URLs that execute code on update.
@@ -98,27 +46,92 @@ function resolveAndValidatePath(filePath: string): string {
// and could write these files via shell, so blocking via MCP is redundant. // and could write these files via shell, so blocking via MCP is redundant.
const GIT_INTERPRETED_FILES = [".gitattributes", ".gitmodules"]; const GIT_INTERPRETED_FILES = [".gitattributes", ".gitmodules"];
type BashPermission = "disabled" | "restricted" | "enabled"; // resolve and validate a read path. allows:
// 1. paths within the repo (with symlink protection to prevent malicious PR symlinks)
type ValidateWritePathParams = { // 2. paths within PULLFROG_TEMP_DIR (tool result files: diffs, CI logs, review threads, etc.)
filePath: string; function resolveReadPath(filePath: string): string {
bashPermission: BashPermission;
};
function validateWritePath(params: ValidateWritePathParams): string {
const resolved = resolveAndValidatePath(params.filePath);
const cwd = realpathSync(process.cwd()); const cwd = realpathSync(process.cwd());
const relative = resolved.slice(cwd.length + 1); const resolved = resolve(cwd, filePath);
if (relative === ".git" || relative.startsWith(".git/")) {
throw new Error(`writing to .git is not allowed: ${params.filePath}`); // allow reads from PULLFROG_TEMP_DIR (tool result files)
const tempDir = process.env.PULLFROG_TEMP_DIR;
if (tempDir && (resolved === tempDir || resolved.startsWith(tempDir + "/"))) {
return resolved;
} }
// block git-interpreted files only when bash is disabled (no shell = no other way to create them) // allow reads from the repo with symlink protection.
if (params.bashPermission === "disabled") { // threat model: a malicious PR plants symlinks (e.g. `secrets -> /etc/shadow`).
const basename = relative.split("/").pop() || ""; // git materializes symlinks on linux, so after checkout the working tree contains
// live symlinks. realpathSync catches these and blocks the read.
if (existsSync(resolved)) {
const real = realpathSync(resolved);
if (real === cwd || real.startsWith(cwd + "/")) {
return real;
}
throw new Error(`path must be within the repository (symlink escape blocked): ${filePath}`);
}
// path doesn't exist — check if it's within the repo
if (resolved === cwd || resolved.startsWith(cwd + "/")) {
return resolved;
}
throw new Error(`path must be within the repository or temp directory: ${filePath}`);
}
// resolve and validate a write path. enforces:
// - repo-scoping with symlink protection (when bash !== "enabled")
// - .git/ always blocked (defense-in-depth)
// - .gitattributes/.gitmodules blocked when bash === "disabled"
//
// when bash=enabled, repo-scoping is dropped — the agent can write anywhere via native
// bash, so restricting file_write to the repo would be security theater.
function resolveWritePath(filePath: string, bashPermission: BashPermission): string {
const cwd = realpathSync(process.cwd());
const resolved = resolve(cwd, filePath);
// repo-scoping: enforced when agent doesn't have full bash
if (bashPermission !== "enabled") {
if (existsSync(resolved)) {
const real = realpathSync(resolved);
if (real !== cwd && !real.startsWith(cwd + "/")) {
throw new Error(`path must be within the repository (symlink escape blocked): ${filePath}`);
}
} else {
// target doesn't exist yet — walk up to find the first existing ancestor
// and verify it resolves within the repo. prevents creating files through
// symlinked parent directories.
let ancestor = dirname(resolved);
while (!existsSync(ancestor)) {
const parent = dirname(ancestor);
if (parent === ancestor) break;
ancestor = parent;
}
if (existsSync(ancestor)) {
const realAncestor = realpathSync(ancestor);
if (realAncestor !== cwd && !realAncestor.startsWith(cwd + "/")) {
throw new Error(
`path must be within the repository (symlink escape blocked): ${filePath}`
);
}
}
if (resolved !== cwd && !resolved.startsWith(cwd + "/")) {
throw new Error(`path must be within the repository: ${filePath}`);
}
}
}
// .git always blocked anywhere in the path (defense-in-depth even with bash=enabled)
if (resolved.includes("/.git/") || resolved.endsWith("/.git")) {
throw new Error(`writing to .git is not allowed: ${filePath}`);
}
// git-interpreted files blocked anywhere in the path when bash is disabled
if (bashPermission === "disabled") {
const basename = resolved.split("/").pop() || "";
if (GIT_INTERPRETED_FILES.includes(basename)) { if (GIT_INTERPRETED_FILES.includes(basename)) {
throw new Error( throw new Error(
`writing to ${basename} is not allowed when bash is ${params.bashPermission} (can trigger code execution via git filter drivers): ${params.filePath}` `writing to ${basename} is not allowed when bash is ${bashPermission} (can trigger code execution via git filter drivers): ${filePath}`
); );
} }
} }
@@ -129,10 +142,12 @@ function validateWritePath(params: ValidateWritePathParams): string {
export function FileReadTool(_ctx: ToolContext) { export function FileReadTool(_ctx: ToolContext) {
return tool({ return tool({
name: "file_read", name: "file_read",
description: `Read a file from the repository. Path is relative to the repository root. Only paths within the current repository are allowed.`, description:
"Read a file. Path is relative to the repository root, or an absolute path " +
"to read tool result files (diffs, CI logs, etc.) from the temp directory.",
parameters: FileReadParams, parameters: FileReadParams,
execute: execute(async (params) => { execute: execute(async (params) => {
const resolved = resolveAndValidatePath(params.path); const resolved = resolveReadPath(params.path);
const raw = readFileSync(resolved, "utf-8"); const raw = readFileSync(resolved, "utf-8");
const lines = raw.split("\n"); const lines = raw.split("\n");
@@ -156,13 +171,12 @@ export function FileReadTool(_ctx: ToolContext) {
export function FileWriteTool(ctx: ToolContext) { export function FileWriteTool(ctx: ToolContext) {
return tool({ return tool({
name: "file_write", name: "file_write",
description: `Write content to a file in the repository. Path is relative to the repository root. Only paths within the current repository are allowed. Writes to .git/ are blocked. Creates parent directories if needed.`, description:
"Write content to a file. Path is relative to the repository root. " +
"Writes to .git/ are blocked. Creates parent directories if needed.",
parameters: FileWriteParams, parameters: FileWriteParams,
execute: execute(async (params) => { execute: execute(async (params) => {
const resolved = validateWritePath({ const resolved = resolveWritePath(params.path, ctx.payload.bash);
filePath: params.path,
bashPermission: ctx.payload.bash,
});
const dir = dirname(resolved); const dir = dirname(resolved);
mkdirSync(dir, { recursive: true }); mkdirSync(dir, { recursive: true });
writeFileSync(resolved, params.content, "utf-8"); writeFileSync(resolved, params.content, "utf-8");
@@ -174,7 +188,10 @@ export function FileWriteTool(ctx: ToolContext) {
export function FileEditTool(ctx: ToolContext) { export function FileEditTool(ctx: ToolContext) {
return tool({ return tool({
name: "file_edit", name: "file_edit",
description: `Replace text in a file. old_string must match exactly (including whitespace and indentation). By default replaces a single unique occurrence — set replace_all to replace every occurrence. Path is relative to the repository root. Writes to .git/ are blocked.`, description:
"Replace text in a file. old_string must match exactly (including whitespace and indentation). " +
"By default replaces a single unique occurrence — set replace_all to replace every occurrence. " +
"Path is relative to the repository root. Writes to .git/ are blocked.",
parameters: FileEditParams, parameters: FileEditParams,
execute: execute(async (params) => { execute: execute(async (params) => {
if (params.old_string.length === 0) { if (params.old_string.length === 0) {
@@ -184,10 +201,7 @@ export function FileEditTool(ctx: ToolContext) {
throw new Error("old_string and new_string are identical"); throw new Error("old_string and new_string are identical");
} }
const resolved = validateWritePath({ const resolved = resolveWritePath(params.path, ctx.payload.bash);
filePath: params.path,
bashPermission: ctx.payload.bash,
});
const content = readFileSync(resolved, "utf-8"); const content = readFileSync(resolved, "utf-8");
const count = content.split(params.old_string).length - 1; const count = content.split(params.old_string).length - 1;
@@ -213,13 +227,12 @@ export function FileEditTool(ctx: ToolContext) {
export function FileDeleteTool(ctx: ToolContext) { export function FileDeleteTool(ctx: ToolContext) {
return tool({ return tool({
name: "file_delete", name: "file_delete",
description: `Delete a file from the repository. Path is relative to the repository root. Only paths within the current repository are allowed. Deletes to .git/ are blocked. Cannot delete directories.`, description:
"Delete a file. Path is relative to the repository root. " +
"Deletes to .git/ are blocked. Cannot delete directories.",
parameters: FileDeleteParams, parameters: FileDeleteParams,
execute: execute(async (params) => { execute: execute(async (params) => {
const resolved = validateWritePath({ const resolved = resolveWritePath(params.path, ctx.payload.bash);
filePath: params.path,
bashPermission: ctx.payload.bash,
});
unlinkSync(resolved); unlinkSync(resolved);
return { path: params.path, deleted: true }; return { path: params.path, deleted: true };
}), }),
@@ -229,10 +242,12 @@ export function FileDeleteTool(ctx: ToolContext) {
export function ListDirectoryTool(_ctx: ToolContext) { export function ListDirectoryTool(_ctx: ToolContext) {
return tool({ return tool({
name: "list_directory", name: "list_directory",
description: `List files and directories. Path is relative to the repository root. Only paths within the current repository are allowed. Returns entries sorted with directories first, then alphabetically.`, description:
"List files and directories. Path is relative to the repository root, or an absolute path " +
"to list tool result files from the temp directory. Returns entries sorted with directories first, then alphabetically.",
parameters: ListDirectoryParams, parameters: ListDirectoryParams,
execute: execute(async (params) => { execute: execute(async (params) => {
const resolved = resolveAndValidatePath(params.path); const resolved = resolveReadPath(params.path);
const entries = readdirSync(resolved, { withFileTypes: true }); const entries = readdirSync(resolved, { withFileTypes: true });
const sorted = entries.sort((a, b) => { const sorted = entries.sort((a, b) => {
if (a.isDirectory() && !b.isDirectory()) return -1; if (a.isDirectory() && !b.isDirectory()) return -1;
+38 -31
View File
@@ -3,7 +3,7 @@ import { type } from "arktype";
import { log } from "../utils/cli.ts"; import { log } from "../utils/cli.ts";
import { $git } from "../utils/gitAuth.ts"; import { $git } from "../utils/gitAuth.ts";
import { $ } from "../utils/shell.ts"; import { $ } from "../utils/shell.ts";
import type { ToolContext } from "./server.ts"; import type { StoredPushDest, ToolContext } from "./server.ts";
import { execute, tool } from "./shared.ts"; import { execute, tool } from "./shared.ts";
type PushDestination = { type PushDestination = {
@@ -14,37 +14,36 @@ type PushDestination = {
/** /**
* get where git would actually push this branch. * get where git would actually push this branch.
* uses git's native @{push} resolution, falls back to origin if unset. * prefers the stored destination from toolState (set by checkout_pr) when it
* matches the current branch, because git config reads can silently fail in
* certain environments causing pushes to the wrong remote branch.
* *
* for branches created via checkout_pr: uses configured pushRemote/merge * falls back to reading branch.X.pushRemote and branch.X.merge from git config,
* for new branches (git checkout -b): falls back to origin/<branch> * and finally to origin/<branch> for branches created without checkout_pr.
*/ */
function getPushDestination(branch: string): PushDestination { function getPushDestination(
// try git's @{push} resolution first (works for checkout_pr branches) branch: string,
storedDest: StoredPushDest | undefined
): PushDestination {
// prefer stored destination from checkout_pr when it matches the current branch
if (storedDest && storedDest.localBranch === branch) {
log.debug(`using stored push destination: ${storedDest.remoteName}/${storedDest.remoteBranch}`);
const url = $("git", ["remote", "get-url", "--push", storedDest.remoteName], {
log: false,
}).trim();
return { remoteName: storedDest.remoteName, remoteBranch: storedDest.remoteBranch, url };
}
// fall back to git config (for branches not created by checkout_pr)
try { try {
const pushRef = $( const pushRemote = $("git", ["config", `branch.${branch}.pushRemote`], { log: false }).trim();
"git", const merge = $("git", ["config", `branch.${branch}.merge`], { log: false }).trim();
["rev-parse", "--abbrev-ref", "--symbolic-full-name", `${branch}@{push}`], const remoteBranch = merge.replace(/^refs\/heads\//, "");
{ log: false } const url = $("git", ["remote", "get-url", "--push", pushRemote], { log: false }).trim();
).trim(); return { remoteName: pushRemote, remoteBranch, url };
// pushRef is like "origin/main" or "pr-123/feature/foo"
// parse carefully to handle branch names with slashes
const slashIndex = pushRef.indexOf("/");
if (slashIndex === -1) {
throw new Error(`unexpected push ref format: ${pushRef}`);
}
const remoteName = pushRef.slice(0, slashIndex);
const remoteBranch = pushRef.slice(slashIndex + 1);
// get the actual URL git would push to (handles remote.X.pushurl)
const url = $("git", ["remote", "get-url", "--push", remoteName], { log: false }).trim();
return { remoteName, remoteBranch, url };
} catch { } catch {
// @{push} not configured - branch was created locally without checkout_pr // no push config - branch was created locally without checkout_pr
// fall back to origin with the same branch name log.debug(`no push config for ${branch}, falling back to origin/${branch}`);
log.debug(`no push tracking for ${branch}, falling back to origin/${branch}`);
const url = $("git", ["remote", "get-url", "--push", "origin"], { log: false }).trim(); const url = $("git", ["remote", "get-url", "--push", "origin"], { log: false }).trim();
return { remoteName: "origin", remoteBranch: branch, url }; return { remoteName: "origin", remoteBranch: branch, url };
} }
@@ -60,6 +59,7 @@ function normalizeUrl(url: string): string {
type ValidatePushParams = { type ValidatePushParams = {
branch: string; branch: string;
pushUrl: string; pushUrl: string;
storedDest: StoredPushDest | undefined;
}; };
/** /**
@@ -67,7 +67,7 @@ type ValidatePushParams = {
* pushUrl is set by setupGit (base repo) and updated by checkout_pr (fork repo). * pushUrl is set by setupGit (base repo) and updated by checkout_pr (fork repo).
*/ */
function validatePushDestination(params: ValidatePushParams): PushDestination { function validatePushDestination(params: ValidatePushParams): PushDestination {
const dest = getPushDestination(params.branch); const dest = getPushDestination(params.branch, params.storedDest);
if (normalizeUrl(dest.url) !== normalizeUrl(params.pushUrl)) { if (normalizeUrl(dest.url) !== normalizeUrl(params.pushUrl)) {
throw new Error( throw new Error(
@@ -95,7 +95,10 @@ export function PushBranchTool(ctx: ToolContext) {
return tool({ return tool({
name: "push_branch", name: "push_branch",
description: 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. Pushes to the default branch are blocked in restricted mode.", "Push the current branch to the remote repository. Omit branchName to push the current branch (recommended). " +
"If specifying branchName, use the LOCAL branch name (e.g., 'pr-1'), not the remote branch name. " +
"The correct remote and remote branch are determined automatically from branch config set by checkout_pr. " +
"Never force push unless explicitly requested. Pushes to the default branch are blocked in restricted mode.",
parameters: PushBranch, parameters: PushBranch,
execute: execute(async ({ branchName, force }) => { execute: execute(async ({ branchName, force }) => {
// permission check // permission check
@@ -110,7 +113,11 @@ export function PushBranchTool(ctx: ToolContext) {
if (!pushUrl) { if (!pushUrl) {
throw new Error("pushUrl not set - setupGit must run before push_branch"); throw new Error("pushUrl not set - setupGit must run before push_branch");
} }
const pushDest = validatePushDestination({ branch, pushUrl }); const pushDest = validatePushDestination({
branch,
pushUrl,
storedDest: ctx.toolState.pushDest,
});
// block pushes to default branch in restricted mode // block pushes to default branch in restricted mode
if (pushPermission === "restricted" && pushDest.remoteBranch === defaultBranch) { if (pushPermission === "restricted" && pushDest.remoteBranch === defaultBranch) {
+16
View File
@@ -44,6 +44,22 @@ export function CreatePullRequestTool(ctx: ToolContext) {
base: base, base: base,
}); });
// best-effort: request review from the user who triggered the workflow
const reviewer = ctx.payload.triggeringUser;
if (reviewer) {
try {
log.debug(`Requesting review from ${reviewer} on PR #${result.data.number}`);
await ctx.octokit.rest.pulls.requestReviewers({
owner: ctx.repo.owner,
repo: ctx.repo.name,
pull_number: result.data.number,
reviewers: [reviewer],
});
} catch {
log.info(`failed to request review from ${reviewer} on PR #${result.data.number}`);
}
}
return { return {
success: true, success: true,
pullRequestId: result.data.id, pullRequestId: result.data.id,
+3 -2
View File
@@ -290,7 +290,8 @@ function hasThumbsUpFrom(comment: ReviewThreadComment, username: string): boolea
if (!comment.reactionGroups) return false; if (!comment.reactionGroups) return false;
const thumbsUp = comment.reactionGroups.find((g) => g.content === "THUMBS_UP"); const thumbsUp = comment.reactionGroups.find((g) => g.content === "THUMBS_UP");
if (!thumbsUp?.reactors?.nodes) return false; if (!thumbsUp?.reactors?.nodes) return false;
return thumbsUp.reactors.nodes.some((r) => r?.login === username); const usernameNeedle = username.toLowerCase();
return thumbsUp.reactors.nodes.some((r) => r?.login?.toLowerCase() === usernameNeedle);
} }
function threadHasThumbsUpFrom(thread: ReviewThread, username: string): boolean { function threadHasThumbsUpFrom(thread: ReviewThread, username: string): boolean {
@@ -631,7 +632,7 @@ export function ResolveReviewThreadTool(ctx: ToolContext) {
const message = isResolved const message = isResolved
? `thread ${params.thread_id} was already resolved` ? `thread ${params.thread_id} was already resolved`
: `failed to resolve thread ${params.thread_id}: ${errorMessage}`; : `failed to resolve thread ${params.thread_id}: ${errorMessage}`;
log.warning(message); log.info(message);
return { return {
thread_id: params.thread_id, thread_id: params.thread_id,
+15 -7
View File
@@ -15,10 +15,19 @@ export type BackgroundProcess = {
pidPath: string; pidPath: string;
}; };
export type StoredPushDest = {
remoteName: string;
remoteBranch: string;
localBranch: string;
};
export interface ToolState { export interface ToolState {
// where we're allowed to push - base repo initially, fork URL for fork PRs // where we're allowed to push - base repo initially, fork URL for fork PRs
// set by setupGit, updated by checkout_pr. always set before push validation. // set by setupGit, updated by checkout_pr. always set before push validation.
pushUrl?: string; pushUrl?: string;
// push destination set by checkout_pr - used as primary source in push_branch
// because git config reads can fail in certain environments
pushDest?: StoredPushDest;
// issue or PR number (same number space in GitHub) // issue or PR number (same number space in GitHub)
issueNumber?: number; issueNumber?: number;
selectedMode?: string; selectedMode?: string;
@@ -34,7 +43,8 @@ export interface ToolState {
promise: Promise<PrepResult[]> | undefined; promise: Promise<PrepResult[]> | undefined;
results: PrepResult[] | undefined; results: PrepResult[] | undefined;
}; };
progressCommentId: number | null; // undefined = no comment yet, number = active comment, null = deliberately deleted
progressCommentId: number | null | undefined;
lastProgressBody?: string; lastProgressBody?: string;
wasUpdated?: boolean; wasUpdated?: boolean;
output?: string; output?: string;
@@ -45,13 +55,11 @@ interface InitToolStateParams {
} }
export function initToolState(params: InitToolStateParams): ToolState { export function initToolState(params: InitToolStateParams): ToolState {
const progressCommentId = params.progressCommentId const parsed = params.progressCommentId ? parseInt(params.progressCommentId, 10) : NaN;
? parseInt(params.progressCommentId, 10) const resolvedId = Number.isNaN(parsed) ? undefined : parsed;
: null;
const resolvedId = Number.isNaN(progressCommentId) ? null : progressCommentId;
if (progressCommentId) { if (resolvedId) {
log.info(`» using pre-created progress comment: ${progressCommentId}`); log.info(`» using pre-created progress comment: ${resolvedId}`);
} }
return { return {
+1 -1
View File
@@ -53,7 +53,7 @@ export const execute = <T, R extends Record<string, any> | string>(
} catch (error) { } catch (error) {
const errorMessage = error instanceof Error ? error.message : String(error); const errorMessage = error instanceof Error ? error.message : String(error);
const prefix = toolName ? `[${toolName}]` : "tool"; const prefix = toolName ? `[${toolName}]` : "tool";
log.error(`${prefix} error: ${errorMessage}`); log.info(`${prefix} error: ${errorMessage}`);
log.debug(`${prefix} params: ${formatJsonValue(params)}`); log.debug(`${prefix} params: ${formatJsonValue(params)}`);
return handleToolError(error); return handleToolError(error);
} }
+3 -5
View File
@@ -2,7 +2,7 @@ import * as fs from "node:fs";
import * as path from "node:path"; import * as path from "node:path";
import { type } from "arktype"; import { type } from "arktype";
import { fileTypeFromBuffer } from "file-type"; import { fileTypeFromBuffer } from "file-type";
import { getApiUrl, getVercelBypassHeaders } from "../utils/apiUrl.ts"; import { apiFetch } from "../utils/apiFetch.ts";
import type { ToolContext } from "./server.ts"; import type { ToolContext } from "./server.ts";
import { execute, tool } from "./shared.ts"; import { execute, tool } from "./shared.ts";
@@ -25,14 +25,12 @@ export function UploadFileTool(ctx: ToolContext) {
const fileType = await fileTypeFromBuffer(buffer); const fileType = await fileTypeFromBuffer(buffer);
const contentType = fileType?.mime || "application/octet-stream"; const contentType = fileType?.mime || "application/octet-stream";
const apiUrl = getApiUrl(); const response = await apiFetch({
path: "/api/upload/signed-url",
const response = await fetch(`${apiUrl}/api/upload/signed-url`, {
method: "POST", method: "POST",
headers: { headers: {
Authorization: `Bearer ${ctx.apiToken}`, Authorization: `Bearer ${ctx.apiToken}`,
"Content-Type": "application/json", "Content-Type": "application/json",
...getVercelBypassHeaders(),
}, },
body: JSON.stringify({ body: JSON.stringify({
filename, filename,
+8 -12
View File
@@ -99,7 +99,7 @@ export function computeModes(): Mode[] {
name: "Review", name: "Review",
description: description:
"Review code, PRs, or implementations; provide feedback or suggestions; identify issues; or check code quality, style, and correctness", "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. Your job is to find problems—assume they exist until you've proven otherwise. Do not submit a clean review without thorough investigation. 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. **If you have nothing interesting to say, do NOT submit a review at all—use \`report_progress\` instead.**
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. 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.
@@ -113,21 +113,17 @@ export function computeModes(): Mode[] {
- **Explore failure modes**: What if this throws? What if that returns null? What if the network fails? What if this runs twice? - **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. - **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? - **Consider lifecycle**: Initialization, cleanup, error recovery. Are resources acquired before use? Released after? What happens on cancellation?
- **Spot performance issues**: Nested loops over large collections, blocking I/O, memory leaks, excessive object creation in hot paths, inefficient array operations (e.g., repeated \`.find()\` in a loop).
- **Check PR consistency**: Does the PR title/description match the actual code changes? Flag significant discrepancies.
- Do NOT stop at "this looks reasonable." Dig until you either find a problem or have concrete evidence there isn't one. - Do NOT stop at "this looks reasonable." Dig until you either find a problem or have concrete evidence there isn't one.
4. **DRAFT LINE-BY-LINE COMMENTS** - For each issue found, draft an inline comment on the specific line. Use the NEW line number from the diff (second column: \`| OLD | NEW | TYPE | CODE\`). If no issues found, skip to step 6. 4. **DRAFT LINE-BY-LINE COMMENTS** - Every comment must be actionable: the author should need to change something in response. 2-3 sentences max. Use the NEW line number from the diff (second column: \`| OLD | NEW | TYPE | CODE\`). If no issues found, skip to step 5. NO COMPLIMENTS. NO NITPICKING ABOUT CHANGES UNRELATED TO THE MAIN CHANGE. Non-actionable comments (praise, style preferences, minor optimizations, documentation nits) must not be drafted. If no comments survive and you have no significant concerns, **do not submit a review**. Use \`${ghPullfrogMcpName}/report_progress\` to note the PR was reviewed and no issues were found.
5. **FILTER LINE-BY-LINE COMMENTS** - Each inline comment must be actionable. Remove anything that doesn't require action: 5. **WRITE SUMMARY** - Draft a 1-3 sentence summary for the review body. Include urgency level and any concerns about code outside the diff.
- **Not actionable → no comment**: Do NOT create inline comments for compliments (e.g., "this looks clean", "nice refactor") or general observations. These waste reviewer attention.
- **Actionable by agent → keep**: Bugs, logic errors, missing error handling, security issues, race conditions, resource leaks, incorrect assumptions.
- **Requires human decision → keep**: If something needs human judgment (architectural choice, product decision, tradeoff evaluation), create a comment clearly stating what decision is needed and why.
- Remove style-only comments (formatting, naming conventions) unless they cause real confusion.
6. **WRITE SUMMARY** - Draft a 1-3 sentence summary for the review body. Include urgency level and any concerns about code outside the diff. 6. **SUBMIT** — Use ${ghPullfrogMcpName}/create_pull_request_review:
- \`body\`: The summary from step 5
7. **SUBMIT** — Use ${ghPullfrogMcpName}/create_pull_request_review: - \`comments\`: The inline comments from step 4
- \`body\`: The summary from step 6
- \`comments\`: The filtered inline comments from step 5
${permalinkTip} ${permalinkTip}
`, `,
+6 -3
View File
@@ -1,6 +1,6 @@
{ {
"name": "@pullfrog/pullfrog", "name": "@pullfrog/pullfrog",
"version": "0.0.162", "version": "0.0.167",
"type": "module", "type": "module",
"files": [ "files": [
"index.js", "index.js",
@@ -20,7 +20,8 @@
"runtest": "node test/run.ts", "runtest": "node test/run.ts",
"scratch": "node scratch.ts", "scratch": "node scratch.ts",
"upDeps": "pnpm up --latest", "upDeps": "pnpm up --latest",
"lock": "pnpm --ignore-workspace install --no-frozen-lockfile", "lock": "pnpm install --no-frozen-lockfile",
"postinstall": "node scripts/generate-proxies.ts",
"prepare": "cd .. && husky action/.husky" "prepare": "cd .. && husky action/.husky"
}, },
"dependencies": { "dependencies": {
@@ -79,7 +80,9 @@
"types": "./dist/index.d.cts", "types": "./dist/index.d.cts",
"import": "./dist/index.js", "import": "./dist/index.js",
"require": "./dist/index.cjs" "require": "./dist/index.cjs"
} },
"./internal": "./dist/internal.js",
"./package.json": "./package.json"
}, },
"packageManager": "pnpm@10.27.0+sha512.72d699da16b1179c14ba9e64dc71c9a40988cbdc65c264cb0e489db7de917f20dcf4d64d8723625f2969ba52d4b7e2a1170682d9ac2a5dcaeaab732b7e16f04a" "packageManager": "pnpm@10.27.0+sha512.72d699da16b1179c14ba9e64dc71c9a40988cbdc65c264cb0e489db7de917f20dcf4d64d8723625f2969ba52d4b7e2a1170682d9ac2a5dcaeaab732b7e16f04a"
} }
+18 -4
View File
@@ -1,8 +1,8 @@
import { execSync } from "node:child_process"; import { execSync } from "node:child_process";
import { mkdtemp } from "node:fs/promises"; import { mkdtemp } from "node:fs/promises";
import { tmpdir } from "node:os"; import { tmpdir } from "node:os";
import { dirname, join } from "node:path"; import { dirname, join, resolve } from "node:path";
import { fileURLToPath } from "node:url"; import { fileURLToPath, pathToFileURL } from "node:url";
import arg from "arg"; import arg from "arg";
import { config } from "dotenv"; import { config } from "dotenv";
import type { AgentResult } from "./agents/shared.ts"; import type { AgentResult } from "./agents/shared.ts";
@@ -10,7 +10,9 @@ import { type Inputs, main } from "./main.ts";
import { defineFixture } from "./test/utils.ts"; import { defineFixture } from "./test/utils.ts";
import { log } from "./utils/cli.ts"; import { log } from "./utils/cli.ts";
import { runInDocker } from "./utils/docker.ts"; import { runInDocker } from "./utils/docker.ts";
import { ensureGitHubToken } from "./utils/github.ts";
import { isInsideDocker } from "./utils/globals.ts"; import { isInsideDocker } from "./utils/globals.ts";
import { runPostCleanup } from "./utils/postCleanup.ts";
import { setupTestRepo } from "./utils/setup.ts"; import { setupTestRepo } from "./utils/setup.ts";
/** /**
@@ -34,6 +36,8 @@ config();
config({ path: join(__dirname, "..", ".env") }); config({ path: join(__dirname, "..", ".env") });
export async function run(inputsOrPrompt: Inputs | string): Promise<AgentResult> { export async function run(inputsOrPrompt: Inputs | string): Promise<AgentResult> {
await ensureGitHubToken();
// create unique temp directory path in OS temp location for parallel execution // create unique temp directory path in OS temp location for parallel execution
// use a parent dir from mkdtemp, then clone into a 'repo' subdirectory // use a parent dir from mkdtemp, then clone into a 'repo' subdirectory
const tempParent = await mkdtemp(join(tmpdir(), "pullfrog-play-")); const tempParent = await mkdtemp(join(tmpdir(), "pullfrog-play-"));
@@ -65,7 +69,13 @@ export async function run(inputsOrPrompt: Inputs | string): Promise<AgentResult>
} }
} }
const result = await main(); // wrap main() so post cleanup runs even on failure (mirrors action.yml post-if: "failure() || cancelled()")
let result: AgentResult;
try {
result = await main();
} finally {
await runPostCleanup();
}
process.chdir(originalCwd); process.chdir(originalCwd);
@@ -92,7 +102,11 @@ export async function run(inputsOrPrompt: Inputs | string): Promise<AgentResult>
} }
} }
if (import.meta.url === `file://${process.argv[1]}`) { const isDirectExecution = process.argv[1]
? import.meta.url === pathToFileURL(resolve(process.argv[1])).href
: false;
if (isDirectExecution) {
const args = arg({ const args = arg({
"--help": Boolean, "--help": Boolean,
"--raw": String, "--raw": String,
+38
View File
@@ -4,6 +4,8 @@ settings:
autoInstallPeers: true autoInstallPeers: true
excludeLinksFromLockfile: false excludeLinksFromLockfile: false
packageExtensionsChecksum: sha256-Ae6BTffLg0DiuEWVZSk6skwAhBSw9mfAk50E5Iq3i80=
importers: importers:
.: .:
@@ -120,6 +122,15 @@ packages:
peerDependencies: peerDependencies:
zod: ^4.0.0 zod: ^4.0.0
'@anthropic-ai/sdk@0.77.0':
resolution: {integrity: sha512-TivlT6nfidz3sOyMF72T2x5AkmHrpT7JgL2e/0HNdh7b24v7JC8cR+rCY/42jA68xIsjmiGQ5IKMsH9feEKh3A==}
hasBin: true
peerDependencies:
zod: ^3.25.0 || ^4.0.0
peerDependenciesMeta:
zod:
optional: true
'@ark/fs@0.56.0': '@ark/fs@0.56.0':
resolution: {integrity: sha512-zY/wDDhcvmt6/upQwZM766PAnvIzdEMcgydUGd9pqY9FMGNo9I9uE4RYAfms9AeUUtbZJu2h2Ua0tvFsO5XF4Q==} resolution: {integrity: sha512-zY/wDDhcvmt6/upQwZM766PAnvIzdEMcgydUGd9pqY9FMGNo9I9uE4RYAfms9AeUUtbZJu2h2Ua0tvFsO5XF4Q==}
@@ -129,6 +140,10 @@ packages:
'@ark/util@0.56.0': '@ark/util@0.56.0':
resolution: {integrity: sha512-BghfRC8b9pNs3vBoDJhcta0/c1J1rsoS1+HgVUreMFPdhz/CRAKReAu57YEllNaSy98rWAdY1gE+gFup7OXpgA==} resolution: {integrity: sha512-BghfRC8b9pNs3vBoDJhcta0/c1J1rsoS1+HgVUreMFPdhz/CRAKReAu57YEllNaSy98rWAdY1gE+gFup7OXpgA==}
'@babel/runtime@7.28.6':
resolution: {integrity: sha512-05WQkdpL9COIMz4LjTxGpPNCdlpyimKppYNoJ5Di5EUObifl8t4tuLuUBBZEpoLYOmfvIWrsp9fCl0HoPRVTdA==}
engines: {node: '>=6.9.0'}
'@borewit/text-codec@0.1.1': '@borewit/text-codec@0.1.1':
resolution: {integrity: sha512-5L/uBxmjaCIX5h8Z+uu+kA9BQLkc/Wl06UGR5ajNRxu+/XjonB5i8JpgFMrPj3LXTCPA0pv8yxUvbUi+QthGGA==} resolution: {integrity: sha512-5L/uBxmjaCIX5h8Z+uu+kA9BQLkc/Wl06UGR5ajNRxu+/XjonB5i8JpgFMrPj3LXTCPA0pv8yxUvbUi+QthGGA==}
@@ -1182,6 +1197,10 @@ packages:
jose@6.1.3: jose@6.1.3:
resolution: {integrity: sha512-0TpaTfihd4QMNwrz/ob2Bp7X04yuxJkjRGi4aKmOqwhov54i6u79oCv7T+C7lo70MKH6BesI3vscD1yb/yzKXQ==} resolution: {integrity: sha512-0TpaTfihd4QMNwrz/ob2Bp7X04yuxJkjRGi4aKmOqwhov54i6u79oCv7T+C7lo70MKH6BesI3vscD1yb/yzKXQ==}
json-schema-to-ts@3.1.1:
resolution: {integrity: sha512-+DWg8jCJG2TEnpy7kOm/7/AxaYoaRbjVB4LFZLySZlWn8exGs3A4OLJR966cVvU26N7X9TWxl+Jsw7dzAqKT6g==}
engines: {node: '>=16'}
json-schema-traverse@1.0.0: json-schema-traverse@1.0.0:
resolution: {integrity: sha512-NM8/P9n3XjXhIZn1lLhkFaACTOURQXjWhV4BA/RnOv8xvgqtqpAX9IO4mRQxSx1Rlo4tqzeqb0sOlruaOy3dug==} resolution: {integrity: sha512-NM8/P9n3XjXhIZn1lLhkFaACTOURQXjWhV4BA/RnOv8xvgqtqpAX9IO4mRQxSx1Rlo4tqzeqb0sOlruaOy3dug==}
@@ -1454,6 +1473,9 @@ packages:
resolution: {integrity: sha512-kh9LVIWH5CnL63Ipf0jhlBIy0UsrMj/NJDfpsy1SqOXlLKEVyXXYrnFxFT1yOOYVGBSApeVnjPw/sBz5BfEjAQ==} resolution: {integrity: sha512-kh9LVIWH5CnL63Ipf0jhlBIy0UsrMj/NJDfpsy1SqOXlLKEVyXXYrnFxFT1yOOYVGBSApeVnjPw/sBz5BfEjAQ==}
engines: {node: '>=14.16'} engines: {node: '>=14.16'}
ts-algebra@2.0.0:
resolution: {integrity: sha512-FPAhNPFMrkwz76P7cdjdmiShwMynZYN6SgOujD1urY4oNm80Ou9oMdmbR45LotcKOXoy7wSmHkRFE6Mxbrhefw==}
tunnel@0.0.6: tunnel@0.0.6:
resolution: {integrity: sha512-1h/Lnq9yajKY2PEbBadPXj3VxsDDu844OnaAo52UVmIzIvwwtBPIuNvkjuzBlTWpfJyUbG3ez0KSBibQkj4ojg==} resolution: {integrity: sha512-1h/Lnq9yajKY2PEbBadPXj3VxsDDu844OnaAo52UVmIzIvwwtBPIuNvkjuzBlTWpfJyUbG3ez0KSBibQkj4ojg==}
engines: {node: '>=0.6.11 <=0.7.0 || >=0.7.3'} engines: {node: '>=0.6.11 <=0.7.0 || >=0.7.3'}
@@ -1666,6 +1688,7 @@ snapshots:
'@anthropic-ai/claude-agent-sdk@0.2.39(zod@4.3.5)': '@anthropic-ai/claude-agent-sdk@0.2.39(zod@4.3.5)':
dependencies: dependencies:
'@anthropic-ai/sdk': 0.77.0(zod@4.3.5)
zod: 4.3.5 zod: 4.3.5
optionalDependencies: optionalDependencies:
'@img/sharp-darwin-arm64': 0.33.5 '@img/sharp-darwin-arm64': 0.33.5
@@ -1677,6 +1700,12 @@ snapshots:
'@img/sharp-linuxmusl-x64': 0.33.5 '@img/sharp-linuxmusl-x64': 0.33.5
'@img/sharp-win32-x64': 0.33.5 '@img/sharp-win32-x64': 0.33.5
'@anthropic-ai/sdk@0.77.0(zod@4.3.5)':
dependencies:
json-schema-to-ts: 3.1.1
optionalDependencies:
zod: 4.3.5
'@ark/fs@0.56.0': {} '@ark/fs@0.56.0': {}
'@ark/schema@0.56.0': '@ark/schema@0.56.0':
@@ -1685,6 +1714,8 @@ snapshots:
'@ark/util@0.56.0': {} '@ark/util@0.56.0': {}
'@babel/runtime@7.28.6': {}
'@borewit/text-codec@0.1.1': {} '@borewit/text-codec@0.1.1': {}
'@esbuild/aix-ppc64@0.25.12': '@esbuild/aix-ppc64@0.25.12':
@@ -2589,6 +2620,11 @@ snapshots:
jose@6.1.3: {} jose@6.1.3: {}
json-schema-to-ts@3.1.1:
dependencies:
'@babel/runtime': 7.28.6
ts-algebra: 2.0.0
json-schema-traverse@1.0.0: {} json-schema-traverse@1.0.0: {}
json-schema-typed@8.0.2: {} json-schema-typed@8.0.2: {}
@@ -2871,6 +2907,8 @@ snapshots:
'@tokenizer/token': 0.3.0 '@tokenizer/token': 0.3.0
ieee754: 1.2.1 ieee754: 1.2.1
ts-algebra@2.0.0: {}
tunnel@0.0.6: {} tunnel@0.0.6: {}
turndown@7.2.2: turndown@7.2.2:
+6
View File
@@ -0,0 +1,6 @@
packages: [] # prevent looking upwards for the workspace root
packageExtensions:
"@anthropic-ai/claude-agent-sdk":
dependencies:
"@anthropic-ai/sdk": "*"
+288 -281
View File
@@ -399,7 +399,7 @@ var require_tunnel = __commonJS({
connectOptions.headers = connectOptions.headers || {}; connectOptions.headers = connectOptions.headers || {};
connectOptions.headers["Proxy-Authorization"] = "Basic " + new Buffer(connectOptions.proxyAuth).toString("base64"); connectOptions.headers["Proxy-Authorization"] = "Basic " + new Buffer(connectOptions.proxyAuth).toString("base64");
} }
debug("making CONNECT request"); debug2("making CONNECT request");
var connectReq = self2.request(connectOptions); var connectReq = self2.request(connectOptions);
connectReq.useChunkedEncodingByDefault = false; connectReq.useChunkedEncodingByDefault = false;
connectReq.once("response", onResponse); connectReq.once("response", onResponse);
@@ -419,7 +419,7 @@ var require_tunnel = __commonJS({
connectReq.removeAllListeners(); connectReq.removeAllListeners();
socket.removeAllListeners(); socket.removeAllListeners();
if (res.statusCode !== 200) { if (res.statusCode !== 200) {
debug( debug2(
"tunneling socket could not be established, statusCode=%d", "tunneling socket could not be established, statusCode=%d",
res.statusCode res.statusCode
); );
@@ -431,7 +431,7 @@ var require_tunnel = __commonJS({
return; return;
} }
if (head.length > 0) { if (head.length > 0) {
debug("got illegal response body from proxy"); debug2("got illegal response body from proxy");
socket.destroy(); socket.destroy();
var error2 = new Error("got illegal response body from proxy"); var error2 = new Error("got illegal response body from proxy");
error2.code = "ECONNRESET"; error2.code = "ECONNRESET";
@@ -439,13 +439,13 @@ var require_tunnel = __commonJS({
self2.removeSocket(placeholder); self2.removeSocket(placeholder);
return; return;
} }
debug("tunneling connection has established"); debug2("tunneling connection has established");
self2.sockets[self2.sockets.indexOf(placeholder)] = socket; self2.sockets[self2.sockets.indexOf(placeholder)] = socket;
return cb(socket); return cb(socket);
} }
function onError(cause) { function onError(cause) {
connectReq.removeAllListeners(); connectReq.removeAllListeners();
debug( debug2(
"tunneling socket could not be established, cause=%s\n", "tunneling socket could not be established, cause=%s\n",
cause.message, cause.message,
cause.stack cause.stack
@@ -507,9 +507,9 @@ var require_tunnel = __commonJS({
} }
return target; return target;
} }
var debug; var debug2;
if (process.env.NODE_DEBUG && /\btunnel\b/.test(process.env.NODE_DEBUG)) { if (process.env.NODE_DEBUG && /\btunnel\b/.test(process.env.NODE_DEBUG)) {
debug = function() { debug2 = function() {
var args2 = Array.prototype.slice.call(arguments); var args2 = Array.prototype.slice.call(arguments);
if (typeof args2[0] === "string") { if (typeof args2[0] === "string") {
args2[0] = "TUNNEL: " + args2[0]; args2[0] = "TUNNEL: " + args2[0];
@@ -519,10 +519,10 @@ var require_tunnel = __commonJS({
console.error.apply(console, args2); console.error.apply(console, args2);
}; };
} else { } else {
debug = function() { debug2 = function() {
}; };
} }
exports.debug = debug; exports.debug = debug2;
} }
}); });
@@ -19733,10 +19733,10 @@ Support boolean input list: \`true | True | TRUE | false | False | FALSE\``);
return process.env["RUNNER_DEBUG"] === "1"; return process.env["RUNNER_DEBUG"] === "1";
} }
exports.isDebug = isDebug2; exports.isDebug = isDebug2;
function debug(message) { function debug2(message) {
(0, command_1.issueCommand)("debug", {}, message); (0, command_1.issueCommand)("debug", {}, message);
} }
exports.debug = debug; exports.debug = debug2;
function error2(message, properties = {}) { function error2(message, properties = {}) {
(0, command_1.issueCommand)("error", (0, utils_1.toCommandProperties)(properties), message instanceof Error ? message.toString() : message); (0, command_1.issueCommand)("error", (0, utils_1.toCommandProperties)(properties), message instanceof Error ? message.toString() : message);
} }
@@ -25838,7 +25838,7 @@ var require_light = __commonJS({
} }
return this.Events.trigger("scheduled", { args: this.args, options: this.options }); return this.Events.trigger("scheduled", { args: this.args, options: this.options });
} }
async doExecute(chained, clearGlobalState, run2, free) { async doExecute(chained, clearGlobalState, run, free) {
var error2, eventInfo, passed; var error2, eventInfo, passed;
if (this.retryCount === 0) { if (this.retryCount === 0) {
this._assertStatus("RUNNING"); this._assertStatus("RUNNING");
@@ -25858,10 +25858,10 @@ var require_light = __commonJS({
} }
} catch (error1) { } catch (error1) {
error2 = error1; error2 = error1;
return this._onFailure(error2, eventInfo, clearGlobalState, run2, free); return this._onFailure(error2, eventInfo, clearGlobalState, run, free);
} }
} }
doExpire(clearGlobalState, run2, free) { doExpire(clearGlobalState, run, free) {
var error2, eventInfo; var error2, eventInfo;
if (this._states.jobStatus(this.options.id === "RUNNING")) { if (this._states.jobStatus(this.options.id === "RUNNING")) {
this._states.next(this.options.id); this._states.next(this.options.id);
@@ -25869,9 +25869,9 @@ var require_light = __commonJS({
this._assertStatus("EXECUTING"); this._assertStatus("EXECUTING");
eventInfo = { args: this.args, options: this.options, retryCount: this.retryCount }; eventInfo = { args: this.args, options: this.options, retryCount: this.retryCount };
error2 = new BottleneckError$1(`This job timed out after ${this.options.expiration} ms.`); error2 = new BottleneckError$1(`This job timed out after ${this.options.expiration} ms.`);
return this._onFailure(error2, eventInfo, clearGlobalState, run2, free); return this._onFailure(error2, eventInfo, clearGlobalState, run, free);
} }
async _onFailure(error2, eventInfo, clearGlobalState, run2, free) { async _onFailure(error2, eventInfo, clearGlobalState, run, free) {
var retry2, retryAfter; var retry2, retryAfter;
if (clearGlobalState()) { if (clearGlobalState()) {
retry2 = await this.Events.trigger("failed", error2, eventInfo); retry2 = await this.Events.trigger("failed", error2, eventInfo);
@@ -25879,7 +25879,7 @@ var require_light = __commonJS({
retryAfter = ~~retry2; retryAfter = ~~retry2;
this.Events.trigger("retry", `Retrying ${this.options.id} after ${retryAfter} ms`, eventInfo); this.Events.trigger("retry", `Retrying ${this.options.id} after ${retryAfter} ms`, eventInfo);
this.retryCount++; this.retryCount++;
return run2(retryAfter); return run(retryAfter);
} else { } else {
this.doDone(eventInfo); this.doDone(eventInfo);
await free(this.options, eventInfo); await free(this.options, eventInfo);
@@ -26517,17 +26517,17 @@ var require_light = __commonJS({
} }
} }
_run(index, job, wait) { _run(index, job, wait) {
var clearGlobalState, free, run2; var clearGlobalState, free, run;
job.doRun(); job.doRun();
clearGlobalState = this._clearGlobalState.bind(this, index); clearGlobalState = this._clearGlobalState.bind(this, index);
run2 = this._run.bind(this, index, job); run = this._run.bind(this, index, job);
free = this._free.bind(this, index, job); free = this._free.bind(this, index, job);
return this._scheduled[index] = { return this._scheduled[index] = {
timeout: setTimeout(() => { timeout: setTimeout(() => {
return job.doExecute(this._limiter, clearGlobalState, run2, free); return job.doExecute(this._limiter, clearGlobalState, run, free);
}, wait), }, wait),
expiration: job.options.expiration != null ? setTimeout(function() { expiration: job.options.expiration != null ? setTimeout(function() {
return job.doExpire(clearGlobalState, run2, free); return job.doExpire(clearGlobalState, run, free);
}, wait + job.options.expiration) : void 0, }, wait + job.options.expiration) : void 0,
job job
}; };
@@ -26949,9 +26949,9 @@ var require_constants6 = __commonJS({
var require_debug = __commonJS({ var require_debug = __commonJS({
"node_modules/.pnpm/semver@7.7.3/node_modules/semver/internal/debug.js"(exports, module) { "node_modules/.pnpm/semver@7.7.3/node_modules/semver/internal/debug.js"(exports, module) {
"use strict"; "use strict";
var debug = typeof process === "object" && process.env && process.env.NODE_DEBUG && /\bsemver\b/i.test(process.env.NODE_DEBUG) ? (...args2) => console.error("SEMVER", ...args2) : () => { var debug2 = typeof process === "object" && process.env && process.env.NODE_DEBUG && /\bsemver\b/i.test(process.env.NODE_DEBUG) ? (...args2) => console.error("SEMVER", ...args2) : () => {
}; };
module.exports = debug; module.exports = debug2;
} }
}); });
@@ -26964,7 +26964,7 @@ var require_re = __commonJS({
MAX_SAFE_BUILD_LENGTH, MAX_SAFE_BUILD_LENGTH,
MAX_LENGTH MAX_LENGTH
} = require_constants6(); } = require_constants6();
var debug = require_debug(); var debug2 = require_debug();
exports = module.exports = {}; exports = module.exports = {};
var re = exports.re = []; var re = exports.re = [];
var safeRe = exports.safeRe = []; var safeRe = exports.safeRe = [];
@@ -26987,7 +26987,7 @@ var require_re = __commonJS({
var createToken = (name, value2, isGlobal) => { var createToken = (name, value2, isGlobal) => {
const safe = makeSafeRegex(value2); const safe = makeSafeRegex(value2);
const index = R++; const index = R++;
debug(name, index, value2); debug2(name, index, value2);
t[name] = index; t[name] = index;
src[index] = value2; src[index] = value2;
safeSrc[index] = safe; safeSrc[index] = safe;
@@ -27091,7 +27091,7 @@ var require_identifiers = __commonJS({
var require_semver = __commonJS({ var require_semver = __commonJS({
"node_modules/.pnpm/semver@7.7.3/node_modules/semver/classes/semver.js"(exports, module) { "node_modules/.pnpm/semver@7.7.3/node_modules/semver/classes/semver.js"(exports, module) {
"use strict"; "use strict";
var debug = require_debug(); var debug2 = require_debug();
var { MAX_LENGTH, MAX_SAFE_INTEGER } = require_constants6(); var { MAX_LENGTH, MAX_SAFE_INTEGER } = require_constants6();
var { safeRe: re, t } = require_re(); var { safeRe: re, t } = require_re();
var parseOptions = require_parse_options(); var parseOptions = require_parse_options();
@@ -27113,7 +27113,7 @@ var require_semver = __commonJS({
`version is longer than ${MAX_LENGTH} characters` `version is longer than ${MAX_LENGTH} characters`
); );
} }
debug("SemVer", version, options); debug2("SemVer", version, options);
this.options = options; this.options = options;
this.loose = !!options.loose; this.loose = !!options.loose;
this.includePrerelease = !!options.includePrerelease; this.includePrerelease = !!options.includePrerelease;
@@ -27161,7 +27161,7 @@ var require_semver = __commonJS({
return this.version; return this.version;
} }
compare(other) { compare(other) {
debug("SemVer.compare", this.version, this.options, other); debug2("SemVer.compare", this.version, this.options, other);
if (!(other instanceof _SemVer)) { if (!(other instanceof _SemVer)) {
if (typeof other === "string" && other === this.version) { if (typeof other === "string" && other === this.version) {
return 0; return 0;
@@ -27212,7 +27212,7 @@ var require_semver = __commonJS({
do { do {
const a = this.prerelease[i]; const a = this.prerelease[i];
const b = other.prerelease[i]; const b = other.prerelease[i];
debug("prerelease compare", i, a, b); debug2("prerelease compare", i, a, b);
if (a === void 0 && b === void 0) { if (a === void 0 && b === void 0) {
return 0; return 0;
} else if (b === void 0) { } else if (b === void 0) {
@@ -27234,7 +27234,7 @@ var require_semver = __commonJS({
do { do {
const a = this.build[i]; const a = this.build[i];
const b = other.build[i]; const b = other.build[i];
debug("build compare", i, a, b); debug2("build compare", i, a, b);
if (a === void 0 && b === void 0) { if (a === void 0 && b === void 0) {
return 0; return 0;
} else if (b === void 0) { } else if (b === void 0) {
@@ -27862,21 +27862,21 @@ var require_range = __commonJS({
const loose = this.options.loose; const loose = this.options.loose;
const hr = loose ? re[t.HYPHENRANGELOOSE] : re[t.HYPHENRANGE]; const hr = loose ? re[t.HYPHENRANGELOOSE] : re[t.HYPHENRANGE];
range2 = range2.replace(hr, hyphenReplace(this.options.includePrerelease)); range2 = range2.replace(hr, hyphenReplace(this.options.includePrerelease));
debug("hyphen replace", range2); debug2("hyphen replace", range2);
range2 = range2.replace(re[t.COMPARATORTRIM], comparatorTrimReplace); range2 = range2.replace(re[t.COMPARATORTRIM], comparatorTrimReplace);
debug("comparator trim", range2); debug2("comparator trim", range2);
range2 = range2.replace(re[t.TILDETRIM], tildeTrimReplace); range2 = range2.replace(re[t.TILDETRIM], tildeTrimReplace);
debug("tilde trim", range2); debug2("tilde trim", range2);
range2 = range2.replace(re[t.CARETTRIM], caretTrimReplace); range2 = range2.replace(re[t.CARETTRIM], caretTrimReplace);
debug("caret trim", range2); debug2("caret trim", range2);
let rangeList = range2.split(" ").map((comp) => parseComparator(comp, this.options)).join(" ").split(/\s+/).map((comp) => replaceGTE0(comp, this.options)); let rangeList = range2.split(" ").map((comp) => parseComparator(comp, this.options)).join(" ").split(/\s+/).map((comp) => replaceGTE0(comp, this.options));
if (loose) { if (loose) {
rangeList = rangeList.filter((comp) => { rangeList = rangeList.filter((comp) => {
debug("loose invalid filter", comp, this.options); debug2("loose invalid filter", comp, this.options);
return !!comp.match(re[t.COMPARATORLOOSE]); return !!comp.match(re[t.COMPARATORLOOSE]);
}); });
} }
debug("range list", rangeList); debug2("range list", rangeList);
const rangeMap = /* @__PURE__ */ new Map(); const rangeMap = /* @__PURE__ */ new Map();
const comparators = rangeList.map((comp) => new Comparator(comp, this.options)); const comparators = rangeList.map((comp) => new Comparator(comp, this.options));
for (const comp of comparators) { for (const comp of comparators) {
@@ -27931,7 +27931,7 @@ var require_range = __commonJS({
var cache = new LRU(); var cache = new LRU();
var parseOptions = require_parse_options(); var parseOptions = require_parse_options();
var Comparator = require_comparator(); var Comparator = require_comparator();
var debug = require_debug(); var debug2 = require_debug();
var SemVer = require_semver(); var SemVer = require_semver();
var { var {
safeRe: re, safeRe: re,
@@ -27957,15 +27957,15 @@ var require_range = __commonJS({
}; };
var parseComparator = (comp, options) => { var parseComparator = (comp, options) => {
comp = comp.replace(re[t.BUILD], ""); comp = comp.replace(re[t.BUILD], "");
debug("comp", comp, options); debug2("comp", comp, options);
comp = replaceCarets(comp, options); comp = replaceCarets(comp, options);
debug("caret", comp); debug2("caret", comp);
comp = replaceTildes(comp, options); comp = replaceTildes(comp, options);
debug("tildes", comp); debug2("tildes", comp);
comp = replaceXRanges(comp, options); comp = replaceXRanges(comp, options);
debug("xrange", comp); debug2("xrange", comp);
comp = replaceStars(comp, options); comp = replaceStars(comp, options);
debug("stars", comp); debug2("stars", comp);
return comp; return comp;
}; };
var isX = (id) => !id || id.toLowerCase() === "x" || id === "*"; var isX = (id) => !id || id.toLowerCase() === "x" || id === "*";
@@ -27975,7 +27975,7 @@ var require_range = __commonJS({
var replaceTilde = (comp, options) => { var replaceTilde = (comp, options) => {
const r = options.loose ? re[t.TILDELOOSE] : re[t.TILDE]; const r = options.loose ? re[t.TILDELOOSE] : re[t.TILDE];
return comp.replace(r, (_, M, m, p, pr) => { return comp.replace(r, (_, M, m, p, pr) => {
debug("tilde", comp, _, M, m, p, pr); debug2("tilde", comp, _, M, m, p, pr);
let ret; let ret;
if (isX(M)) { if (isX(M)) {
ret = ""; ret = "";
@@ -27984,12 +27984,12 @@ var require_range = __commonJS({
} else if (isX(p)) { } else if (isX(p)) {
ret = `>=${M}.${m}.0 <${M}.${+m + 1}.0-0`; ret = `>=${M}.${m}.0 <${M}.${+m + 1}.0-0`;
} else if (pr) { } else if (pr) {
debug("replaceTilde pr", pr); debug2("replaceTilde pr", pr);
ret = `>=${M}.${m}.${p}-${pr} <${M}.${+m + 1}.0-0`; ret = `>=${M}.${m}.${p}-${pr} <${M}.${+m + 1}.0-0`;
} else { } else {
ret = `>=${M}.${m}.${p} <${M}.${+m + 1}.0-0`; ret = `>=${M}.${m}.${p} <${M}.${+m + 1}.0-0`;
} }
debug("tilde return", ret); debug2("tilde return", ret);
return ret; return ret;
}); });
}; };
@@ -27997,11 +27997,11 @@ var require_range = __commonJS({
return comp.trim().split(/\s+/).map((c) => replaceCaret(c, options)).join(" "); return comp.trim().split(/\s+/).map((c) => replaceCaret(c, options)).join(" ");
}; };
var replaceCaret = (comp, options) => { var replaceCaret = (comp, options) => {
debug("caret", comp, options); debug2("caret", comp, options);
const r = options.loose ? re[t.CARETLOOSE] : re[t.CARET]; const r = options.loose ? re[t.CARETLOOSE] : re[t.CARET];
const z = options.includePrerelease ? "-0" : ""; const z = options.includePrerelease ? "-0" : "";
return comp.replace(r, (_, M, m, p, pr) => { return comp.replace(r, (_, M, m, p, pr) => {
debug("caret", comp, _, M, m, p, pr); debug2("caret", comp, _, M, m, p, pr);
let ret; let ret;
if (isX(M)) { if (isX(M)) {
ret = ""; ret = "";
@@ -28014,7 +28014,7 @@ var require_range = __commonJS({
ret = `>=${M}.${m}.0${z} <${+M + 1}.0.0-0`; ret = `>=${M}.${m}.0${z} <${+M + 1}.0.0-0`;
} }
} else if (pr) { } else if (pr) {
debug("replaceCaret pr", pr); debug2("replaceCaret pr", pr);
if (M === "0") { if (M === "0") {
if (m === "0") { if (m === "0") {
ret = `>=${M}.${m}.${p}-${pr} <${M}.${m}.${+p + 1}-0`; ret = `>=${M}.${m}.${p}-${pr} <${M}.${m}.${+p + 1}-0`;
@@ -28025,7 +28025,7 @@ var require_range = __commonJS({
ret = `>=${M}.${m}.${p}-${pr} <${+M + 1}.0.0-0`; ret = `>=${M}.${m}.${p}-${pr} <${+M + 1}.0.0-0`;
} }
} else { } else {
debug("no pr"); debug2("no pr");
if (M === "0") { if (M === "0") {
if (m === "0") { if (m === "0") {
ret = `>=${M}.${m}.${p}${z} <${M}.${m}.${+p + 1}-0`; ret = `>=${M}.${m}.${p}${z} <${M}.${m}.${+p + 1}-0`;
@@ -28036,19 +28036,19 @@ var require_range = __commonJS({
ret = `>=${M}.${m}.${p} <${+M + 1}.0.0-0`; ret = `>=${M}.${m}.${p} <${+M + 1}.0.0-0`;
} }
} }
debug("caret return", ret); debug2("caret return", ret);
return ret; return ret;
}); });
}; };
var replaceXRanges = (comp, options) => { var replaceXRanges = (comp, options) => {
debug("replaceXRanges", comp, options); debug2("replaceXRanges", comp, options);
return comp.split(/\s+/).map((c) => replaceXRange(c, options)).join(" "); return comp.split(/\s+/).map((c) => replaceXRange(c, options)).join(" ");
}; };
var replaceXRange = (comp, options) => { var replaceXRange = (comp, options) => {
comp = comp.trim(); comp = comp.trim();
const r = options.loose ? re[t.XRANGELOOSE] : re[t.XRANGE]; const r = options.loose ? re[t.XRANGELOOSE] : re[t.XRANGE];
return comp.replace(r, (ret, gtlt, M, m, p, pr) => { return comp.replace(r, (ret, gtlt, M, m, p, pr) => {
debug("xRange", comp, ret, gtlt, M, m, p, pr); debug2("xRange", comp, ret, gtlt, M, m, p, pr);
const xM = isX(M); const xM = isX(M);
const xm = xM || isX(m); const xm = xM || isX(m);
const xp = xm || isX(p); const xp = xm || isX(p);
@@ -28095,16 +28095,16 @@ var require_range = __commonJS({
} else if (xp) { } else if (xp) {
ret = `>=${M}.${m}.0${pr} <${M}.${+m + 1}.0-0`; ret = `>=${M}.${m}.0${pr} <${M}.${+m + 1}.0-0`;
} }
debug("xRange return", ret); debug2("xRange return", ret);
return ret; return ret;
}); });
}; };
var replaceStars = (comp, options) => { var replaceStars = (comp, options) => {
debug("replaceStars", comp, options); debug2("replaceStars", comp, options);
return comp.trim().replace(re[t.STAR], ""); return comp.trim().replace(re[t.STAR], "");
}; };
var replaceGTE0 = (comp, options) => { var replaceGTE0 = (comp, options) => {
debug("replaceGTE0", comp, options); debug2("replaceGTE0", comp, options);
return comp.trim().replace(re[options.includePrerelease ? t.GTE0PRE : t.GTE0], ""); return comp.trim().replace(re[options.includePrerelease ? t.GTE0PRE : t.GTE0], "");
}; };
var hyphenReplace = (incPr) => ($0, from, fM, fm, fp, fpr, fb, to, tM, tm, tp, tpr) => { var hyphenReplace = (incPr) => ($0, from, fM, fm, fp, fpr, fb, to, tM, tm, tp, tpr) => {
@@ -28142,7 +28142,7 @@ var require_range = __commonJS({
} }
if (version.prerelease.length && !options.includePrerelease) { if (version.prerelease.length && !options.includePrerelease) {
for (let i = 0; i < set.length; i++) { for (let i = 0; i < set.length; i++) {
debug(set[i].semver); debug2(set[i].semver);
if (set[i].semver === Comparator.ANY) { if (set[i].semver === Comparator.ANY) {
continue; continue;
} }
@@ -28179,7 +28179,7 @@ var require_comparator = __commonJS({
} }
} }
comp = comp.trim().split(/\s+/).join(" "); comp = comp.trim().split(/\s+/).join(" ");
debug("comparator", comp, options); debug2("comparator", comp, options);
this.options = options; this.options = options;
this.loose = !!options.loose; this.loose = !!options.loose;
this.parse(comp); this.parse(comp);
@@ -28188,7 +28188,7 @@ var require_comparator = __commonJS({
} else { } else {
this.value = this.operator + this.semver.version; this.value = this.operator + this.semver.version;
} }
debug("comp", this); debug2("comp", this);
} }
parse(comp) { parse(comp) {
const r = this.options.loose ? re[t.COMPARATORLOOSE] : re[t.COMPARATOR]; const r = this.options.loose ? re[t.COMPARATORLOOSE] : re[t.COMPARATOR];
@@ -28210,7 +28210,7 @@ var require_comparator = __commonJS({
return this.value; return this.value;
} }
test(version) { test(version) {
debug("Comparator.test", version, this.options.loose); debug2("Comparator.test", version, this.options.loose);
if (this.semver === ANY || version === ANY) { if (this.semver === ANY || version === ANY) {
return true; return true;
} }
@@ -28267,7 +28267,7 @@ var require_comparator = __commonJS({
var parseOptions = require_parse_options(); var parseOptions = require_parse_options();
var { safeRe: re, t } = require_re(); var { safeRe: re, t } = require_re();
var cmp = require_cmp(); var cmp = require_cmp();
var debug = require_debug(); var debug2 = require_debug();
var SemVer = require_semver(); var SemVer = require_semver();
var Range = require_range(); var Range = require_range();
} }
@@ -28843,6 +28843,184 @@ var require_semver2 = __commonJS({
} }
}); });
// 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 isRunnerDebugEnabled = () => core.isDebug();
var isLocalDebugEnabled = () => process.env.LOG_LEVEL === "debug" || process.env.ACTIONS_STEP_DEBUG === "true";
var isDebugEnabled = () => isLocalDebugEnabled() || isRunnerDebugEnabled();
function ts() {
return isDebugEnabled() ? `[${(/* @__PURE__ */ new Date()).toISOString()}] ` : "";
}
function formatArgs(args2) {
return args2.map((arg) => {
if (typeof arg === "string") return arg;
if (arg instanceof Error) return `${arg.message}
${arg.stack}`;
return JSON.stringify(arg);
}).join(" ");
}
function startGroup2(name) {
if (isGitHubActions) {
core.startGroup(name);
} else {
console.group(name);
}
}
function endGroup2() {
if (isGitHubActions) {
core.endGroup();
} else {
console.groupEnd();
}
}
function group(name, fn2) {
startGroup2(name);
fn2();
endGroup2();
}
function boxString(text, options) {
const { title, maxWidth = 80, indent: indent2 = "", padding = 1 } = options || {};
const lines = text.trim().split("\n");
const wrappedLines = [];
for (const line of lines) {
if (line.length <= maxWidth - padding * 2) {
wrappedLines.push(line);
} else {
const words = line.split(" ");
let currentLine = "";
for (const word of words) {
const testLine = currentLine ? `${currentLine} ${word}` : word;
if (testLine.length <= maxWidth - padding * 2) {
currentLine = testLine;
} else {
if (currentLine) {
wrappedLines.push(currentLine);
currentLine = "";
}
const maxLineLength2 = maxWidth - padding * 2;
let remainingWord = word;
while (remainingWord.length > maxLineLength2) {
wrappedLines.push(remainingWord.substring(0, maxLineLength2));
remainingWord = remainingWord.substring(maxLineLength2);
}
currentLine = remainingWord;
}
}
if (currentLine) {
wrappedLines.push(currentLine);
}
}
}
const maxLineLength = Math.max(...wrappedLines.map((line) => line.length));
const contentBoxWidth = maxLineLength + padding * 2;
const titleLineLength = title ? ` ${title} `.length : 0;
const boxWidth = Math.max(contentBoxWidth, titleLineLength);
let result = "";
if (title) {
const titleLine = ` ${title} `;
const titlePadding = Math.max(0, boxWidth - titleLine.length);
result += `${indent2}\u250C${titleLine}${"\u2500".repeat(titlePadding)}\u2510
`;
}
if (!title) {
result += `${indent2}\u250C${"\u2500".repeat(boxWidth)}\u2510
`;
}
for (const line of wrappedLines) {
const paddedLine = line.padEnd(maxLineLength);
result += `${indent2}\u2502${" ".repeat(padding)}${paddedLine}${" ".repeat(padding)}\u2502
`;
}
result += `${indent2}\u2514${"\u2500".repeat(boxWidth)}\u2518`;
return result;
}
function box(text, options) {
const boxContent = boxString(text, options);
core.info(boxContent);
}
function printTable(rows, options) {
const { title } = options || {};
const tableData = rows.map(
(row) => row.map((cell) => {
if (typeof cell === "string") {
return cell;
}
return cell.data;
})
);
const formatted = (0, import_table.table)(tableData);
if (title) {
core.info(`
${title}`);
}
core.info(`
${formatted}
`);
}
function separator(length = 50) {
const separatorText = "\u2500".repeat(length);
core.info(separatorText);
}
var log = {
/** Print info message */
info: (...args2) => {
core.info(`${ts()}${formatArgs(args2)}`);
},
/** Print a warning message. Use only for warnings that should be displayed in the job summary. */
warning: (...args2) => {
core.warning(`${ts()}${formatArgs(args2)}`);
},
/** Print an error message. Use only for errors that should be displayed in the job summary. */
error: (...args2) => {
core.error(`${ts()}${formatArgs(args2)}`);
},
/** Print success message */
success: (...args2) => {
core.info(`${ts()}\xBB ${formatArgs(args2)}`);
},
/** Print debug message (only when debug mode is enabled) */
debug: (...args2) => {
if (isRunnerDebugEnabled()) {
core.debug(formatArgs(args2));
return;
}
if (isLocalDebugEnabled()) {
core.info(`${ts()}[DEBUG] ${formatArgs(args2)}`);
}
},
/** Print a formatted box with text */
box,
/** Print a formatted table using the table package */
table: printTable,
/** Print a separator line */
separator,
/** Start a collapsed group (GitHub Actions) or regular group (local) */
startGroup: startGroup2,
/** End a collapsed group */
endGroup: endGroup2,
/** Run a callback within a collapsed group */
group,
/** Log tool call information to console with formatted output */
toolCall: ({ toolName, input }) => {
const inputFormatted = formatJsonValue(input);
const output = inputFormatted !== "{}" ? `\xBB ${toolName}(${inputFormatted})` : `\xBB ${toolName}()`;
log.info(output.trimEnd());
}
};
function formatJsonValue(value2) {
const compact = JSON.stringify(value2);
return compact.length > 80 || compact.includes("\n") ? JSON.stringify(value2, null, 2) : compact;
}
// node_modules/.pnpm/@ark+util@0.56.0/node_modules/@ark/util/out/arrays.js // node_modules/.pnpm/@ark+util@0.56.0/node_modules/@ark/util/out/arrays.js
var liftArray = (data) => Array.isArray(data) ? data : [data]; var liftArray = (data) => Array.isArray(data) ? data : [data];
var spliterate = (arr, predicate) => { var spliterate = (arr, predicate) => {
@@ -37299,178 +37477,6 @@ var schema = ark.schema;
var define2 = ark.define; var define2 = ark.define;
var declare = ark.declare; var declare = ark.declare;
// 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" || core.isDebug();
function formatArgs(args2) {
return args2.map((arg) => {
if (typeof arg === "string") return arg;
if (arg instanceof Error) return `${arg.message}
${arg.stack}`;
return JSON.stringify(arg);
}).join(" ");
}
function startGroup2(name) {
if (isGitHubActions) {
core.startGroup(name);
} else {
console.group(name);
}
}
function endGroup2() {
if (isGitHubActions) {
core.endGroup();
} else {
console.groupEnd();
}
}
function group(name, fn2) {
startGroup2(name);
fn2();
endGroup2();
}
function boxString(text, options) {
const { title, maxWidth = 80, indent: indent2 = "", padding = 1 } = options || {};
const lines = text.trim().split("\n");
const wrappedLines = [];
for (const line of lines) {
if (line.length <= maxWidth - padding * 2) {
wrappedLines.push(line);
} else {
const words = line.split(" ");
let currentLine = "";
for (const word of words) {
const testLine = currentLine ? `${currentLine} ${word}` : word;
if (testLine.length <= maxWidth - padding * 2) {
currentLine = testLine;
} else {
if (currentLine) {
wrappedLines.push(currentLine);
currentLine = "";
}
const maxLineLength2 = maxWidth - padding * 2;
let remainingWord = word;
while (remainingWord.length > maxLineLength2) {
wrappedLines.push(remainingWord.substring(0, maxLineLength2));
remainingWord = remainingWord.substring(maxLineLength2);
}
currentLine = remainingWord;
}
}
if (currentLine) {
wrappedLines.push(currentLine);
}
}
}
const maxLineLength = Math.max(...wrappedLines.map((line) => line.length));
const contentBoxWidth = maxLineLength + padding * 2;
const titleLineLength = title ? ` ${title} `.length : 0;
const boxWidth = Math.max(contentBoxWidth, titleLineLength);
let result = "";
if (title) {
const titleLine = ` ${title} `;
const titlePadding = Math.max(0, boxWidth - titleLine.length);
result += `${indent2}\u250C${titleLine}${"\u2500".repeat(titlePadding)}\u2510
`;
}
if (!title) {
result += `${indent2}\u250C${"\u2500".repeat(boxWidth)}\u2510
`;
}
for (const line of wrappedLines) {
const paddedLine = line.padEnd(maxLineLength);
result += `${indent2}\u2502${" ".repeat(padding)}${paddedLine}${" ".repeat(padding)}\u2502
`;
}
result += `${indent2}\u2514${"\u2500".repeat(boxWidth)}\u2518`;
return result;
}
function box(text, options) {
const boxContent = boxString(text, options);
core.info(boxContent);
}
function printTable(rows, options) {
const { title } = options || {};
const tableData = rows.map(
(row) => row.map((cell) => {
if (typeof cell === "string") {
return cell;
}
return cell.data;
})
);
const formatted = (0, import_table.table)(tableData);
if (title) {
core.info(`
${title}`);
}
core.info(`
${formatted}
`);
}
function separator(length = 50) {
const separatorText = "\u2500".repeat(length);
core.info(separatorText);
}
function ts() {
return isDebugEnabled() ? `[${(/* @__PURE__ */ new Date()).toISOString()}] ` : "";
}
var log = {
/** Print info message */
info: (...args2) => {
core.info(`${ts()}${formatArgs(args2)}`);
},
/** Print warning message */
warning: (...args2) => {
core.warning(`${ts()}${formatArgs(args2)}`);
},
/** Print error message */
error: (...args2) => {
core.error(`${ts()}${formatArgs(args2)}`);
},
/** Print success message */
success: (...args2) => {
core.info(`${ts()}\xBB ${formatArgs(args2)}`);
},
/** Print debug message (only if LOG_LEVEL=debug) */
debug: (...args2) => {
if (isDebugEnabled()) {
core.info(`[${(/* @__PURE__ */ new Date()).toISOString()}] [DEBUG] ${formatArgs(args2)}`);
}
},
/** Print a formatted box with text */
box,
/** Print a formatted table using the table package */
table: printTable,
/** Print a separator line */
separator,
/** Start a collapsed group (GitHub Actions) or regular group (local) */
startGroup: startGroup2,
/** End a collapsed group */
endGroup: endGroup2,
/** Run a callback within a collapsed group */
group,
/** Log tool call information to console with formatted output */
toolCall: ({ toolName, input }) => {
const inputFormatted = formatJsonValue(input);
const output = inputFormatted !== "{}" ? `\xBB ${toolName}(${inputFormatted})` : `\xBB ${toolName}()`;
log.info(output.trimEnd());
}
};
function formatJsonValue(value2) {
const compact = JSON.stringify(value2);
return compact.length > 80 || compact.includes("\n") ? JSON.stringify(value2, null, 2) : compact;
}
// utils/buildPullfrogFooter.ts // utils/buildPullfrogFooter.ts
var PULLFROG_DIVIDER = "<!-- PULLFROG_DIVIDER_DO_NOT_REMOVE_PLZ -->"; var PULLFROG_DIVIDER = "<!-- PULLFROG_DIVIDER_DO_NOT_REMOVE_PLZ -->";
var FROG_LOGO = `<a href="https://pullfrog.com"><picture><source media="(prefers-color-scheme: dark)" srcset="https://pullfrog.com/logos/frog-white-full-18px.png"><img src="https://pullfrog.com/logos/frog-green-full-18px.png" width="9px" height="9px" style="vertical-align: middle; " alt="Pullfrog"></picture></a>`; var FROG_LOGO = `<a href="https://pullfrog.com"><picture><source media="(prefers-color-scheme: dark)" srcset="https://pullfrog.com/logos/frog-white-full-18px.png"><img src="https://pullfrog.com/logos/frog-green-full-18px.png" width="9px" height="9px" style="vertical-align: middle; " alt="Pullfrog"></picture></a>`;
@@ -41192,37 +41198,8 @@ var ReplyToReviewComment = type({
) )
}); });
// utils/token.ts
var core3 = __toESM(require_core(), 1);
function getJobToken() {
const inputToken = core3.getInput("token");
if (inputToken) {
return inputToken;
}
const fallbackToken = process.env.GH_TOKEN || process.env.GITHUB_TOKEN;
if (fallbackToken) {
return fallbackToken;
}
throw new Error("token input is required");
}
// utils/exitHandler.ts
function buildErrorCommentBody(params) {
const workflowRunLink = params.runId ? `[workflow run logs](https://github.com/${params.owner}/${params.repo}/actions/runs/${params.runId})` : "workflow run logs";
const errorMessage = params.isCancellation ? `This run was cancelled \u{1F6D1}
The workflow was cancelled before completion. Please check the ${workflowRunLink} for details.` : `This run croaked \u{1F635}
The workflow encountered an error before any progress could be reported. Please check the ${workflowRunLink} for details.`;
const footer = buildPullfrogFooter({
triggeredBy: true,
workflowRun: params.runId ? { owner: params.owner, repo: params.repo, runId: params.runId } : void 0
});
return `${errorMessage}${footer}`;
}
// utils/payload.ts // utils/payload.ts
var core4 = __toESM(require_core(), 1); var core3 = __toESM(require_core(), 1);
// external.ts // external.ts
var agentsManifest = { var agentsManifest = {
@@ -41258,7 +41235,7 @@ var Effort = type.enumerated("mini", "auto", "max");
// package.json // package.json
var package_default = { var package_default = {
name: "@pullfrog/pullfrog", name: "@pullfrog/pullfrog",
version: "0.0.162", version: "0.0.167",
type: "module", type: "module",
files: [ files: [
"index.js", "index.js",
@@ -41278,7 +41255,8 @@ var package_default = {
runtest: "node test/run.ts", runtest: "node test/run.ts",
scratch: "node scratch.ts", scratch: "node scratch.ts",
upDeps: "pnpm up --latest", upDeps: "pnpm up --latest",
lock: "pnpm --ignore-workspace install --no-frozen-lockfile", lock: "pnpm install --no-frozen-lockfile",
postinstall: "node scripts/generate-proxies.ts",
prepare: "cd .. && husky action/.husky" prepare: "cd .. && husky action/.husky"
}, },
dependencies: { dependencies: {
@@ -41337,7 +41315,9 @@ var package_default = {
types: "./dist/index.d.cts", types: "./dist/index.d.cts",
import: "./dist/index.js", import: "./dist/index.js",
require: "./dist/index.cjs" require: "./dist/index.cjs"
} },
"./internal": "./dist/internal.js",
"./package.json": "./package.json"
}, },
packageManager: "pnpm@10.27.0+sha512.72d699da16b1179c14ba9e64dc71c9a40988cbdc65c264cb0e489db7de917f20dcf4d64d8723625f2969ba52d4b7e2a1170682d9ac2a5dcaeaab732b7e16f04a" packageManager: "pnpm@10.27.0+sha512.72d699da16b1179c14ba9e64dc71c9a40988cbdc65c264cb0e489db7de917f20dcf4d64d8723625f2969ba52d4b7e2a1170682d9ac2a5dcaeaab732b7e16f04a"
}; };
@@ -41369,6 +41349,7 @@ var JsonPayload = type({
version: "string", version: "string",
"agent?": AgentName.or("undefined"), "agent?": AgentName.or("undefined"),
prompt: "string", prompt: "string",
"triggeringUser?": "string | undefined",
"eventInstructions?": "string", "eventInstructions?": "string",
"repoInstructions?": "string", "repoInstructions?": "string",
"event?": "object", "event?": "object",
@@ -41389,7 +41370,7 @@ var Inputs = type({
"cwd?": type.string.or("undefined") "cwd?": type.string.or("undefined")
}); });
function resolvePromptInput() { function resolvePromptInput() {
const prompt = core4.getInput("prompt", { required: true }); const prompt = core3.getInput("prompt", { required: true });
let parsed2; let parsed2;
try { try {
parsed2 = JSON.parse(prompt); parsed2 = JSON.parse(prompt);
@@ -41404,8 +41385,35 @@ function resolvePromptInput() {
return jsonPayload; return jsonPayload;
} }
// post.ts // utils/token.ts
var core4 = __toESM(require_core(), 1);
function getJobToken() {
const inputToken = core4.getInput("token");
if (inputToken) {
return inputToken;
}
const fallbackToken = process.env.GH_TOKEN || process.env.GITHUB_TOKEN;
if (fallbackToken) {
return fallbackToken;
}
throw new Error("token input is required");
}
// utils/postCleanup.ts
var SHOULD_CHECK_REASON = true; var SHOULD_CHECK_REASON = true;
function buildErrorCommentBody(params) {
const workflowRunLink = params.runId ? `[workflow run logs](https://github.com/${params.owner}/${params.repo}/actions/runs/${params.runId})` : "workflow run logs";
const errorMessage = params.isCancellation ? `This run was cancelled \u{1F6D1}
The workflow was cancelled before completion. Please check the ${workflowRunLink} for details.` : `This run croaked \u{1F635}
The workflow encountered an error before any progress could be reported. Please check the ${workflowRunLink} for details.`;
const footer = buildPullfrogFooter({
triggeredBy: true,
workflowRun: params.runId ? { owner: params.owner, repo: params.repo, runId: params.runId } : void 0
});
return `${errorMessage}${footer}`;
}
async function validateStuckProgressComment(promptInput, octokit, owner, repo) { async function validateStuckProgressComment(promptInput, octokit, owner, repo) {
if (!promptInput?.progressCommentId) { if (!promptInput?.progressCommentId) {
log.info("[post] no progressCommentId in prompt input, skipping cleanup"); log.info("[post] no progressCommentId in prompt input, skipping cleanup");
@@ -41427,11 +41435,12 @@ async function validateStuckProgressComment(promptInput, octokit, owner, repo) {
return null; return null;
} catch (error2) { } catch (error2) {
const errorMessage = error2 instanceof Error ? error2.message : String(error2); const errorMessage = error2 instanceof Error ? error2.message : String(error2);
log.error(`[post] failed to get comment ${commentId}: ${errorMessage}`); log.info(`[post] failed to get comment ${commentId}: ${errorMessage}`);
return null; return null;
} }
} }
async function getIsCancelled(params) { async function getIsCancelled(params) {
if (!params.runIdStr) return false;
try { try {
const { data: jobs } = await params.octokit.rest.actions.listJobsForWorkflowRun({ const { data: jobs } = await params.octokit.rest.actions.listJobsForWorkflowRun({
owner: params.repoContext.owner, owner: params.repoContext.owner,
@@ -41453,7 +41462,7 @@ async function getIsCancelled(params) {
} }
log.info("[post] no cancellation found, assuming failure"); log.info("[post] no cancellation found, assuming failure");
} catch (error2) { } catch (error2) {
log.warning( log.info(
`[post] failed to get job status: ${error2 instanceof Error ? error2.message : String(error2)}` `[post] failed to get job status: ${error2 instanceof Error ? error2.message : String(error2)}`
); );
} }
@@ -41462,13 +41471,12 @@ async function getIsCancelled(params) {
async function runPostCleanup() { async function runPostCleanup() {
log.info("\xBB [post] starting post cleanup"); log.info("\xBB [post] starting post cleanup");
const runIdStr = process.env.GITHUB_RUN_ID; const runIdStr = process.env.GITHUB_RUN_ID;
if (!runIdStr) return log.info("\xBB [post] no GITHUB_RUN_ID available, skipping cleanup");
let promptInput = null; let promptInput = null;
try { try {
const resolved = resolvePromptInput(); const resolved = resolvePromptInput();
if (typeof resolved !== "string") promptInput = resolved; if (typeof resolved !== "string") promptInput = resolved;
} catch (error2) { } catch (error2) {
log.warning( log.info(
`[post] failed to resolve prompt input: ${error2 instanceof Error ? error2.message : String(error2)}` `[post] failed to resolve prompt input: ${error2 instanceof Error ? error2.message : String(error2)}`
); );
} }
@@ -41499,19 +41507,18 @@ async function runPostCleanup() {
log.info("\xBB [post] successfully updated progress comment"); log.info("\xBB [post] successfully updated progress comment");
} catch (error2) { } catch (error2) {
const errorMessage = error2 instanceof Error ? error2.message : String(error2); const errorMessage = error2 instanceof Error ? error2.message : String(error2);
log.error(`[post] failed to update comment: ${errorMessage}`); log.info(`[post] failed to update comment: ${errorMessage}`);
}
}
async function run() {
try {
await runPostCleanup();
} catch (error2) {
const message = error2 instanceof Error ? error2.message : String(error2);
log.error(`[post] unexpected error: ${message}`);
} }
} }
// post.ts
log.debug(`[post] script started at ${(/* @__PURE__ */ new Date()).toISOString()}`); log.debug(`[post] script started at ${(/* @__PURE__ */ new Date()).toISOString()}`);
await run(); try {
await runPostCleanup();
} catch (error2) {
const message = error2 instanceof Error ? error2.message : String(error2);
log.error(`[post] unexpected error: ${message}`);
}
/*! Bundled license information: /*! Bundled license information:
undici/lib/fetch/body.js: undici/lib/fetch/body.js:
+8 -174
View File
@@ -6,180 +6,14 @@
* Searches for Pullfrog comment via GitHub API and updates if stuck on "Leaping into action". * Searches for Pullfrog comment via GitHub API and updates if stuck on "Leaping into action".
*/ */
import { LEAPING_INTO_ACTION_PREFIX } from "./mcp/comment.ts";
import { log } from "./utils/cli.ts"; import { log } from "./utils/cli.ts";
import { buildErrorCommentBody } from "./utils/exitHandler.ts"; import { runPostCleanup } from "./utils/postCleanup.ts";
import { createOctokit, parseRepoContext } from "./utils/github.ts";
import { type ResolvedPromptInput, resolvePromptInput } from "./utils/payload.ts";
import { getJobToken } from "./utils/token.ts";
type JsonPromptInput = Extract<ResolvedPromptInput, object>; // not string
/**
* Controls whether the script should check the reason for the workflow termination.
* It can be either canceled or failed.
* YAML file cannot supply it (not in ENV), so an extra request is required to check it.
* */
const SHOULD_CHECK_REASON = true;
/**
* Validate that the progress comment is stuck on "Leaping into action"
* Fetches the comment by ID and checks if it starts with LEAPING_INTO_ACTION_PREFIX
* Returns the comment ID if stuck, null otherwise
*/
async function validateStuckProgressComment(
promptInput: JsonPromptInput | null,
octokit: ReturnType<typeof createOctokit>,
owner: string,
repo: string
): Promise<number | null> {
if (!promptInput?.progressCommentId) {
log.info("[post] no progressCommentId in prompt input, skipping cleanup");
return null;
}
const commentId = parseInt(promptInput.progressCommentId, 10);
log.info(`[post] validating progressCommentId from prompt input: ${commentId}`);
try {
const { data: comment } = await octokit.rest.issues.getComment({
owner,
repo,
comment_id: commentId,
});
// check if comment is stuck on "Leaping into action"
if (comment.body?.startsWith(LEAPING_INTO_ACTION_PREFIX)) {
log.info(`[post] comment ${commentId} is stuck on "Leaping into action"`);
return commentId;
}
log.info(`[post] comment ${commentId} is not stuck (already updated or different content)`);
return null;
} catch (error) {
const errorMessage = error instanceof Error ? error.message : String(error);
log.error(`[post] failed to get comment ${commentId}: ${errorMessage}`);
return null;
}
}
/**
* Detect if the workflow or its steps is cancelled.
* While the job is still in_progress, the individual steps may have their conclusions set.
*/
async function getIsCancelled(params: {
repoContext: ReturnType<typeof parseRepoContext>;
octokit: ReturnType<typeof createOctokit>;
runIdStr: string;
}): Promise<boolean> {
try {
const { data: jobs } = await params.octokit.rest.actions.listJobsForWorkflowRun({
owner: params.repoContext.owner,
repo: params.repoContext.name,
run_id: Number.parseInt(params.runIdStr, 10),
});
// find current job by matching GITHUB_JOB env var
// Note: GITHUB_JOB is the job ID (yaml key), but job.name is the display name
// For matrix jobs, the name includes matrix values like "build (ubuntu-latest, node-18)"
// So we match jobs that START with the job ID
const currentJobName = process.env.GITHUB_JOB;
const currentJob = currentJobName
? jobs.jobs.find((j) => j.name === currentJobName || j.name.startsWith(`${currentJobName} (`))
: jobs.jobs[0]; // fallback to first job
if (!currentJob) {
log.warning("[post] could not find current job");
return false;
}
log.info(`[post] job status: ${currentJob.status}, conclusion: ${currentJob.conclusion}`);
if (currentJob.conclusion === "cancelled") return true; // whole job explicit cancellation
// but if it's still null, check steps for cancellation:
const cancelledStep = currentJob.steps?.find((step) => step.conclusion === "cancelled");
if (cancelledStep) {
log.info(`[post] found cancelled step: ${cancelledStep.name}`);
return true;
}
log.info("[post] no cancellation found, assuming failure");
} catch (error) {
log.warning(
`[post] failed to get job status: ${error instanceof Error ? error.message : String(error)}`
);
}
return false; // assuming failure
}
async function runPostCleanup(): Promise<void> {
log.info("» [post] starting post cleanup");
const runIdStr = process.env.GITHUB_RUN_ID;
if (!runIdStr) return log.info("» [post] no GITHUB_RUN_ID available, skipping cleanup");
// resolve prompt input once and use it for both issue number and comment ID extraction
// only use the object form (JSON payload), not plain string prompts
let promptInput: JsonPromptInput | null = null;
try {
const resolved = resolvePromptInput();
if (typeof resolved !== "string") promptInput = resolved;
} catch (error) {
log.warning(
`[post] failed to resolve prompt input: ${error instanceof Error ? error.message : String(error)}`
);
}
// get job token for API calls
const token = getJobToken();
const repoContext = parseRepoContext();
const octokit = createOctokit(token);
// validate that progressCommentId from prompt input is stuck on "Leaping into action"
const commentId = await validateStuckProgressComment(
promptInput,
octokit,
repoContext.owner,
repoContext.name
);
if (!commentId) return log.info("» [post] no stuck progress comment to update, skipping cleanup");
log.info(`» [post] validated stuck comment: ${commentId}, updating with error message`);
try {
const body = buildErrorCommentBody({
owner: repoContext.owner,
repo: repoContext.name,
runId: runIdStr,
isCancellation: SHOULD_CHECK_REASON
? await getIsCancelled({ octokit, repoContext, runIdStr })
: false,
});
await octokit.rest.issues.updateComment({
owner: repoContext.owner,
repo: repoContext.name,
comment_id: commentId,
body,
});
log.info("» [post] successfully updated progress comment");
} catch (error) {
const errorMessage = error instanceof Error ? error.message : String(error);
log.error(`[post] failed to update comment: ${errorMessage}`);
}
}
async function run(): Promise<void> {
try {
await runPostCleanup();
} catch (error) {
const message = error instanceof Error ? error.message : String(error);
log.error(`[post] unexpected error: ${message}`);
// don't fail the post script - best effort cleanup
}
}
log.debug(`[post] script started at ${new Date().toISOString()}`); log.debug(`[post] script started at ${new Date().toISOString()}`);
await run(); try {
await runPostCleanup();
} catch (error) {
const message = error instanceof Error ? error.message : String(error);
log.error(`[post] unexpected error: ${message}`);
// don't fail the post script - best effort cleanup
}
+13
View File
@@ -0,0 +1,13 @@
import { mkdirSync, writeFileSync } from "node:fs";
const proxies = [
{ dest: "dist/index.js", source: "../index.ts" },
{ dest: "dist/internal.js", source: "../internal/index.ts" },
];
mkdirSync("dist", { recursive: true });
for (const proxy of proxies) {
writeFileSync(proxy.dest, `export * from "${proxy.source}";\n`);
writeFileSync(proxy.dest.replace(/\.js$/, ".d.ts"), `export * from "${proxy.source}";\n`);
}
+5 -5
View File
@@ -30,11 +30,11 @@ function validator(result: AgentResult): ValidationCheck[] {
const setOutputCalled = output !== null; const setOutputCalled = output !== null;
const correctValue = setOutputCalled && /EFFORT_TEST_PASSED/i.test(output); const correctValue = setOutputCalled && /EFFORT_TEST_PASSED/i.test(output);
// the orchestrator runs at auto (effort=auto in its log line). // the orchestrator runs at auto (» effort: auto in its log line).
// the delegate tool should spawn the subagent at mini (effort=mini in its log line). // the delegate tool should spawn the subagent at mini (» effort: mini in its log line).
// if effort override works, we should see BOTH effort=auto AND effort=mini in the output. // if effort override works, we should see BOTH effort values in the output.
const orchestratorEffort = /running \w+ with effort=auto/i.test(agentOutput); const orchestratorEffort = /» effort:\s+auto/i.test(agentOutput);
const subagentEffort = /running \w+ with effort=mini/i.test(agentOutput); const subagentEffort = /» effort:\s+mini/i.test(agentOutput);
return [ return [
{ name: "set_output", passed: setOutputCalled }, { name: "set_output", passed: setOutputCalled },
+45
View File
@@ -0,0 +1,45 @@
#!/usr/bin/env bash
# determines which agents need testing based on changed files.
# reads changed file paths from stdin (JSON array or newline-delimited).
# outputs a JSON array of agent names to stdout.
#
# only agents whose harness file changed are included.
# shared.ts/index.ts and other non-harness action changes fall back to claude as a canary.
set -euo pipefail
# read stdin - auto-detect JSON array vs newline-delimited
input=$(cat)
if echo "$input" | jq -e 'type == "array"' > /dev/null 2>&1; then
files=$(echo "$input" | jq -r '.[]')
else
files="$input"
fi
# find which agent harness files changed
changed_agents=()
has_non_agent_change=false
while IFS= read -r file; do
[[ -z "$file" ]] && continue
case "$file" in
action/agents/shared.ts|action/agents/index.ts)
has_non_agent_change=true
;;
action/agents/*.ts)
changed_agents+=("$(basename "$file" .ts)")
;;
action/*)
has_non_agent_change=true
;;
esac
done <<< "$files"
# output agents based on change type.
# non-agent action changes run claude as a canary.
if [[ ${#changed_agents[@]} -gt 0 ]]; then
printf '%s\n' "${changed_agents[@]}" | sort -u | jq -R . | jq -sc .
elif $has_non_agent_change; then
echo '["claude"]'
else
echo '[]'
fi
+32 -11
View File
@@ -1,9 +1,10 @@
import { execFileSync } from "node:child_process";
import { readdirSync, readFileSync } from "node:fs"; import { readdirSync, readFileSync } from "node:fs";
import { dirname, join } from "node:path"; import { dirname, join } from "node:path";
import { fileURLToPath } from "node:url"; import { fileURLToPath } from "node:url";
import { describe, expect, it } from "vitest"; import { describe, expect, it } from "vitest";
import { parse } from "yaml"; import { parse } from "yaml";
import { agentsManifest } from "../external.ts"; import { agentsManifest, type WorkflowPermissions } from "../external.ts";
const __dirname = dirname(fileURLToPath(import.meta.url)); const __dirname = dirname(fileURLToPath(import.meta.url));
const actionDir = join(__dirname, ".."); const actionDir = join(__dirname, "..");
@@ -12,7 +13,7 @@ const rootDir = join(actionDir, "..");
type WorkflowJob = { type WorkflowJob = {
"runs-on": string; "runs-on": string;
"timeout-minutes"?: number; "timeout-minutes"?: number;
permissions?: Record<string, string>; permissions?: WorkflowPermissions;
strategy?: { "fail-fast": boolean; matrix: Record<string, string[]> }; strategy?: { "fail-fast": boolean; matrix: Record<string, string[]> };
env?: Record<string, string>; env?: Record<string, string>;
steps?: unknown[]; steps?: unknown[];
@@ -57,11 +58,14 @@ const expectedAgents = Object.keys(agentsManifest).sort();
const crossagentTests = getTestNamesFromDir("crossagent"); const crossagentTests = getTestNamesFromDir("crossagent");
const agnosticTests = getTestNamesFromDir("agnostic"); const agnosticTests = getTestNamesFromDir("agnostic");
const adhocTests = getTestNamesFromDir("adhoc"); const adhocTests = getTestNamesFromDir("adhoc");
const dynamicAgentsExpression = "$" + "{{ fromJSON(needs.changes.outputs.agents) }}";
// all API key names from all agents + GITHUB_TOKEN // all API key names from all agents + GITHUB_TOKEN + model overrides
const expectedAgentEnvVars = [ const expectedAgentEnvVars = [
"GITHUB_TOKEN", "GITHUB_TOKEN",
...new Set(Object.values(agentsManifest).flatMap((a) => a.apiKeyNames)), ...new Set(Object.values(agentsManifest).flatMap((a) => a.apiKeyNames)),
"GEMINI_MODEL",
"OPENCODE_MODEL",
].sort(); ].sort();
// agnostic tests only run with claude // agnostic tests only run with claude
@@ -82,8 +86,25 @@ describe("ci workflow consistency", () => {
const rootJob = rootWorkflow.jobs["action-agents"]; const rootJob = rootWorkflow.jobs["action-agents"];
const actionJob = actionWorkflow.jobs.agents; const actionJob = actionWorkflow.jobs.agents;
it("root agent matrix matches agentsManifest", () => { it("root agent matrix uses dynamic output from changes job", () => {
expect([...rootJob.strategy!.matrix.agent].sort()).toEqual(expectedAgents); expect(rootJob.strategy!.matrix.agent).toBe(dynamicAgentsExpression);
});
it("changed-agents.sh falls back to claude when shared agent code changed", () => {
const input = JSON.stringify(["action/agents/shared.ts"]);
const output = execFileSync("bash", [join(__dirname, "changed-agents.sh")], {
input,
encoding: "utf-8",
});
expect(JSON.parse(output)).toEqual(["claude"]);
});
it("changed-agents.sh falls back to claude for non-agent action changes", () => {
const output = execFileSync("bash", [join(__dirname, "changed-agents.sh")], {
input: JSON.stringify(["action/mcp/delegate.ts"]),
encoding: "utf-8",
});
expect(JSON.parse(output)).toEqual(["claude"]);
}); });
it("action agent matrix matches agentsManifest", () => { it("action agent matrix matches agentsManifest", () => {
@@ -114,9 +135,9 @@ describe("ci workflow consistency", () => {
expect(getEnvVarNames(rootJob)).toEqual(expectedAgentEnvVars); expect(getEnvVarNames(rootJob)).toEqual(expectedAgentEnvVars);
}); });
it("fail-fast is disabled in both", () => { it("fail-fast is enabled in both", () => {
expect(rootJob.strategy!["fail-fast"]).toBe(false); expect(rootJob.strategy!["fail-fast"]).toBe(true);
expect(actionJob.strategy!["fail-fast"]).toBe(false); expect(actionJob.strategy!["fail-fast"]).toBe(true);
}); });
}); });
@@ -148,9 +169,9 @@ describe("ci workflow consistency", () => {
expect(getEnvVarNames(rootJob)).toEqual(expectedAgnosticEnvVars); expect(getEnvVarNames(rootJob)).toEqual(expectedAgnosticEnvVars);
}); });
it("fail-fast is disabled in both", () => { it("fail-fast is enabled in both", () => {
expect(rootJob.strategy!["fail-fast"]).toBe(false); expect(rootJob.strategy!["fail-fast"]).toBe(true);
expect(actionJob.strategy!["fail-fast"]).toBe(false); expect(actionJob.strategy!["fail-fast"]).toBe(true);
}); });
}); });
}); });
+1 -1
View File
@@ -15,7 +15,7 @@ const secret = randomUUID();
const fixture = defineFixture( const fixture = defineFixture(
{ {
prompt: `Call the get_test_value tool from the robinMCP server, then call set_output with the exact value returned.`, prompt: `Call the get_test_value tool from the robinMCP server. It returns a JSON object with a "value" field. Extract that inner value string and pass it to set_output.`,
bash: "disabled", bash: "disabled",
effort: "mini", effort: "mini",
}, },
+7 -8
View File
@@ -13,9 +13,11 @@ import {
const fixture = defineFixture( const fixture = defineFixture(
{ {
prompt: `${buildBashToolPrompt("echo $PULLFROG_DIAGNOSTIC_ID")} prompt: `This is a test to determine token visibility in bash tool calls.
${buildBashToolPrompt("echo $PULLFROG_TEST_VALUE")}
Then also run: echo $PULLFROG_FILTER_TOKEN Then also run: echo $PULLFROG_TEST_TOKEN
Then call set_output with the exact output of each command, one per line: Then call set_output with the exact output of each command, one per line:
DIAGNOSTIC_ID=<value or "empty"> DIAGNOSTIC_ID=<value or "empty">
@@ -27,14 +29,11 @@ FILTER_TOKEN=<value or "empty">`,
{ localOnly: true } { localOnly: true }
); );
const { getUuid, agentEnv } = generateAgentUuids([ const { getUuid, agentEnv } = generateAgentUuids(["PULLFROG_TEST_VALUE", "PULLFROG_TEST_TOKEN"]);
"PULLFROG_DIAGNOSTIC_ID",
"PULLFROG_FILTER_TOKEN",
]);
function validator(result: AgentResult): ValidationCheck[] { function validator(result: AgentResult): ValidationCheck[] {
const safeMarker = getUuid(result.agent, "PULLFROG_DIAGNOSTIC_ID"); const safeMarker = getUuid(result.agent, "PULLFROG_TEST_VALUE");
const filteredMarker = getUuid(result.agent, "PULLFROG_FILTER_TOKEN"); const filteredMarker = getUuid(result.agent, "PULLFROG_TEST_TOKEN");
// require structured output from set_output tool // require structured output from set_output tool
const output = getStructuredOutput(result); const output = getStructuredOutput(result);
+7 -18
View File
@@ -3,13 +3,9 @@ import { dirname, join } from "node:path";
import { fileURLToPath } from "node:url"; import { fileURLToPath } from "node:url";
import { config } from "dotenv"; import { config } from "dotenv";
import { runInDocker } from "../utils/docker.ts"; import { runInDocker } from "../utils/docker.ts";
import { acquireNewToken } from "../utils/github.ts"; import { ensureGitHubToken } from "../utils/github.ts";
import { isInsideDocker } from "../utils/globals.ts"; import { isInsideDocker } from "../utils/globals.ts";
import { import { killTrackedChildren, setSignalHandler } from "../utils/subprocess.ts";
installSignalHandlers,
killTrackedChildren,
setSignalHandler,
} from "../utils/subprocess.ts";
import { import {
type AgentResult, type AgentResult,
agents, agents,
@@ -311,14 +307,14 @@ async function runTestForAgent(ctx: RunContext): Promise<ValidationResult> {
env.PULLFROG_TEST_REPO_SETUP = testConfig.repoSetup; env.PULLFROG_TEST_REPO_SETUP = testConfig.repoSetup;
} }
// opencode: override to google/gemini-3-pro-preview to avoid flash's tight RPD quota limits // opencode: use anthropic sonnet to avoid google quota issues and gemini doom-looping
if (ctx.agent === "opencode") { if (ctx.agent === "opencode") {
env.OPENCODE_MODEL = "google/gemini-3-pro-preview"; env.OPENCODE_MODEL ??= "anthropic/claude-sonnet-4-5";
} }
// gemini: override to pro for all tests (including mini-effort) to avoid flash's tight RPD quota limits // gemini: use flash for all tests (including mini-effort) to avoid pro quota limits
if (ctx.agent === "gemini") { if (ctx.agent === "gemini") {
env.GEMINI_MODEL = "gemini-3-pro-preview"; env.GEMINI_MODEL ??= "gemini-3-flash-preview";
} }
// build file-based env vars for MCP servers that don't inherit parent env // build file-based env vars for MCP servers that don't inherit parent env
@@ -388,13 +384,7 @@ async function main(): Promise<void> {
// run in Docker unless already inside // run in Docker unless already inside
if (!isInsideDocker) { if (!isInsideDocker) {
// acquire token for docker if needed // acquire token for docker if needed
if (!process.env.GITHUB_TOKEN && !process.env.GH_TOKEN) { await ensureGitHubToken();
if (process.env.GITHUB_APP_ID && process.env.GITHUB_PRIVATE_KEY) {
console.log("» acquiring github installation token...");
const token = await acquireNewToken();
process.env.GITHUB_TOKEN = token;
}
}
runTestsInDocker(args); runTestsInDocker(args);
} }
@@ -488,7 +478,6 @@ async function main(): Promise<void> {
} }
setSignalHandler(handleCancel); setSignalHandler(handleCancel);
installSignalHandlers();
// run tests with limited concurrency to avoid overwhelming agent APIs // run tests with limited concurrency to avoid overwhelming agent APIs
const maxConcurrency = 5; const maxConcurrency = 5;
+1 -3
View File
@@ -5,7 +5,7 @@ import { dirname, join } from "node:path";
import { fileURLToPath } from "node:url"; import { fileURLToPath } from "node:url";
import { agentsManifest } from "../external.ts"; import { agentsManifest } from "../external.ts";
import type { Inputs } from "../main.ts"; import type { Inputs } from "../main.ts";
import { installSignalHandlers, trackChild, untrackChild } from "../utils/subprocess.ts"; import { trackChild, untrackChild } from "../utils/subprocess.ts";
const __dirname = dirname(fileURLToPath(import.meta.url)); const __dirname = dirname(fileURLToPath(import.meta.url));
@@ -165,8 +165,6 @@ const DEFAULT_TEST_TIMEOUT = "10m";
// run agent and stream output with prefix labels // run agent and stream output with prefix labels
// note: activity timeout is enforced in action main and subprocess utils // note: activity timeout is enforced in action main and subprocess utils
export async function runAgentStreaming(options: RunStreamingOptions): Promise<AgentResult> { export async function runAgentStreaming(options: RunStreamingOptions): Promise<AgentResult> {
installSignalHandlers();
return new Promise((resolve) => { return new Promise((resolve) => {
const chunks: Buffer[] = []; const chunks: Buffer[] = [];
const prefix = getPrefix({ test: options.test, agent: options.agent }); const prefix = getPrefix({ test: options.test, agent: options.agent });
+50
View File
@@ -0,0 +1,50 @@
import { getApiUrl } from "./apiUrl.ts";
import { log } from "./cli.ts";
type ApiFetchOptions = {
path: string;
method?: string | undefined;
headers?: Record<string, string> | undefined;
body?: string | undefined;
signal?: AbortSignal | undefined;
};
/**
* fetch wrapper for hitting the Pullfrog API with Vercel deployment protection bypass.
*
* adds the bypass secret as BOTH a query parameter and a header for maximum reliability.
* the server-side forwarding code uses query params, and the Vercel docs say both work,
* so we do both as belt-and-suspenders.
*
* the query param approach is the primary bypass mechanism (matches server-side forwarding).
* the header is added as a fallback.
*/
export async function apiFetch(options: ApiFetchOptions): Promise<Response> {
const apiUrl = getApiUrl();
const url = new URL(options.path, apiUrl);
const bypassSecret = process.env.VERCEL_AUTOMATION_BYPASS_SECRET;
if (bypassSecret) {
url.searchParams.set("x-vercel-protection-bypass", bypassSecret);
}
const headers: Record<string, string> = {
...options.headers,
};
// also add as header for belt-and-suspenders
if (bypassSecret) {
headers["x-vercel-protection-bypass"] = bypassSecret;
}
log.debug(`api fetch: ${options.method ?? "GET"} ${url.pathname}`);
const init: RequestInit = {
method: options.method ?? "GET",
headers,
};
if (options.body) init.body = options.body;
if (options.signal) init.signal = options.signal;
return fetch(url.toString(), init);
}
+16 -13
View File
@@ -1,24 +1,27 @@
import { log } from "./cli.ts"; import { log } from "./cli.ts";
function isLocalUrl(url: URL): boolean {
return url.hostname === "localhost" || url.hostname === "127.0.0.1";
}
/** /**
* resolve the Pullfrog API base URL. * resolve the Pullfrog API base URL.
* *
* in the action: API_URL is not explicitly set, so this falls back to https://pullfrog.com. * in the action: API_URL is not explicitly set, so this falls back to https://pullfrog.com.
* in local dev: API_URL=http://localhost:3000 (from .env). * in local dev: API_URL=http://localhost:3000 (from .env).
*
* enforces https:// for non-local URLs to prevent cleartext credential transmission.
*/ */
export function getApiUrl(): string { export function getApiUrl(): string {
const url = process.env.API_URL || "https://pullfrog.com"; const raw = process.env.API_URL || "https://pullfrog.com";
log.debug(`resolved API_URL: ${url}`); const parsed = new URL(raw);
return url;
}
/** if (parsed.protocol !== "https:" && !isLocalUrl(parsed)) {
* returns headers needed to bypass Vercel deployment protection on preview deployments. throw new Error(
* when VERCEL_AUTOMATION_BYPASS_SECRET is set (preview repos), includes the bypass header. `API_URL must use https:// (got ${parsed.protocol}). only localhost is exempt.`
* otherwise returns an empty object (production / local dev). );
*/ }
export function getVercelBypassHeaders(): Record<string, string> {
const secret = process.env.VERCEL_AUTOMATION_BYPASS_SECRET; log.debug(`resolved API_URL: ${raw}`);
if (!secret) return {}; return raw;
return { "x-vercel-protection-bypass": secret };
} }
+1
View File
@@ -120,6 +120,7 @@ const testEnvAllowList = new Set([
"GOOGLE_GENERATIVE_AI_API_KEY", "GOOGLE_GENERATIVE_AI_API_KEY",
"CURSOR_API_KEY", "CURSOR_API_KEY",
"OPENCODE_MODEL", // override OpenCode model (e.g. google/gemini-3-flash-preview) for tests or user preference "OPENCODE_MODEL", // override OpenCode model (e.g. google/gemini-3-flash-preview) for tests or user preference
"GEMINI_MODEL", // override Gemini model (e.g. gemini-3-pro-preview) for tests or user preference
"LOG_LEVEL", "LOG_LEVEL",
"DEBUG", "DEBUG",
"NODE_ENV", "NODE_ENV",
+22 -106
View File
@@ -1,119 +1,35 @@
import { LEAPING_INTO_ACTION_PREFIX } from "../mcp/comment.ts"; import os from "node:os";
import type { ToolState } from "../mcp/server.ts";
import { buildPullfrogFooter } from "./buildPullfrogFooter.ts"; type ExitSignalHandler = (signal: "SIGINT" | "SIGTERM") => void | Promise<void>;
import { log } from "./cli.ts";
import { createOctokit, parseRepoContext } from "./github.ts"; const handlers = new Set<ExitSignalHandler>();
import { revokeGitHubInstallationToken } from "./token.ts"; let installed = false;
/** /**
* Build error comment body with error message and footer * Register a handler to run when the process receives SIGINT or SIGTERM.
* Returns a dispose function that removes the handler.
*/ */
export function buildErrorCommentBody(params: { export function onExitSignal(handler: ExitSignalHandler): () => void {
owner: string; installSignalHandlers();
repo: string; handlers.add(handler);
runId: string | undefined; return () => {
isCancellation: boolean; handlers.delete(handler);
}): string { };
const workflowRunLink = params.runId
? `[workflow run logs](https://github.com/${params.owner}/${params.repo}/actions/runs/${params.runId})`
: "workflow run logs";
const errorMessage = params.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: params.runId
? { owner: params.owner, repo: params.repo, runId: params.runId }
: undefined,
});
return `${errorMessage}${footer}`;
} }
let cleanupFn: ((isCancellation: boolean) => Promise<void>) | undefined; function installSignalHandlers(): void {
if (installed) return;
installed = true;
export function setupExitHandler(toolState: ToolState): void { async function handleSignal(signal: "SIGINT" | "SIGTERM") {
let hasCleanedUp = false; await Promise.allSettled([...handlers].map((h) => Promise.try(h, signal)));
exitWithSignal(signal);
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 body = buildErrorCommentBody({
owner: repoContext.owner,
repo: repoContext.name,
runId,
isCancellation,
});
await octokit.rest.issues.updateComment({
owner: repoContext.owner,
repo: repoContext.name,
comment_id: commentId,
body,
});
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("SIGINT", handleSignal);
process.on("SIGTERM", handleSignal); process.on("SIGTERM", handleSignal);
} }
/** export function exitWithSignal(signal: "SIGINT" | "SIGTERM") {
* Run cleanup explicitly. Called from entry.ts in finally block. process.exit(128 + os.constants.signals[signal]);
*/
export async function runCleanup(): Promise<void> {
try {
await cleanupFn?.(false);
} catch {
// ignore errors during cleanup
}
} }
+1 -1
View File
@@ -161,7 +161,7 @@ export function $git(
if (result.status !== 0) { if (result.status !== 0) {
const stderr = result.stderr?.trim() ?? ""; const stderr = result.stderr?.trim() ?? "";
log.error(`git ${subcommand} failed: ${stderr}`); log.info(`git ${subcommand} failed: ${stderr}`);
throw new Error(`git ${subcommand} failed: ${stderr}`); throw new Error(`git ${subcommand} failed: ${stderr}`);
} }
+36 -28
View File
@@ -2,7 +2,7 @@ import { createSign } from "node:crypto";
import * as core from "@actions/core"; import * as core from "@actions/core";
import { throttling } from "@octokit/plugin-throttling"; import { throttling } from "@octokit/plugin-throttling";
import { Octokit } from "@octokit/rest"; import { Octokit } from "@octokit/rest";
import { getApiUrl, getVercelBypassHeaders } from "./apiUrl.ts"; import { apiFetch } from "./apiFetch.ts";
import { retry } from "./retry.ts"; import { retry } from "./retry.ts";
export interface InstallationToken { export interface InstallationToken {
@@ -53,73 +53,63 @@ function isOIDCAvailable(): boolean {
); );
} }
// github installation token permission levels
type ReadWrite = "read" | "write"; type ReadWrite = "read" | "write";
type WriteOnly = "write"; type WriteOnly = "write";
type ReadOnly = "read";
// permission names use underscores (API format) /**
type InstallationTokenPermissions = { * GitHub App installation access token permissions.
* passed to `POST /app/installations/{id}/access_tokens` to scope the token.
* fields and allowed values come from the `app-permissions` OpenAPI schema.
* @see https://docs.github.com/en/rest/apps/installations#create-an-installation-access-token-for-an-app
* @see https://github.com/github/rest-api-description — components.schemas.app-permissions
*/
type GitHubAppPermissions = {
actions?: ReadWrite; actions?: ReadWrite;
artifact_metadata?: ReadWrite; artifact_metadata?: ReadWrite;
attestations?: ReadWrite; attestations?: ReadWrite;
checks?: ReadWrite; checks?: ReadWrite;
contents?: ReadWrite; contents?: ReadWrite;
deployments?: ReadWrite; deployments?: ReadWrite;
id_token?: WriteOnly;
issues?: ReadWrite;
models?: ReadOnly;
discussions?: ReadWrite; discussions?: ReadWrite;
issues?: ReadWrite;
packages?: ReadWrite; packages?: ReadWrite;
pages?: ReadWrite; pages?: ReadWrite;
pull_requests?: ReadWrite; pull_requests?: ReadWrite;
security_events?: ReadWrite; security_events?: ReadWrite;
statuses?: ReadWrite; statuses?: ReadWrite;
workflows?: WriteOnly;
}; };
type AcquireTokenOptions = { type AcquireTokenOptions = {
repos?: string[]; repos?: string[];
permissions?: InstallationTokenPermissions; permissions?: GitHubAppPermissions;
}; };
async function acquireTokenViaOIDC(opts?: AcquireTokenOptions): Promise<string> { async function acquireTokenViaOIDC(opts?: AcquireTokenOptions): Promise<string> {
const oidcToken = await core.getIDToken("pullfrog-api"); const oidcToken = await core.getIDToken("pullfrog-api");
const apiUrl = getApiUrl();
const params = new URLSearchParams();
// ensure the token covers GITHUB_REPOSITORY (may differ from OIDC claims repo)
const repos = [...(opts?.repos ?? [])]; const repos = [...(opts?.repos ?? [])];
const targetRepo = process.env.GITHUB_REPOSITORY?.split("/")[1]; const targetRepo = process.env.GITHUB_REPOSITORY?.split("/")[1];
if (targetRepo) { if (targetRepo) {
repos.push(targetRepo); repos.push(targetRepo);
} }
if (repos.length) { const reposParam = repos.length ? `?repos=${repos.join(",")}` : "";
params.set("repos", repos.join(","));
}
const queryString = params.toString() ? `?${params.toString()}` : "";
const timeoutMs = 30000; const timeoutMs = 30000;
const controller = new AbortController(); const controller = new AbortController();
const timeoutId = setTimeout(() => controller.abort(), timeoutMs); const timeoutId = setTimeout(() => controller.abort(), timeoutMs);
try { try {
const fetchOptions: RequestInit = { const tokenResponse = await apiFetch({
path: `/api/github/installation-token${reposParam}`,
method: "POST", method: "POST",
headers: { headers: {
Authorization: `Bearer ${oidcToken}`, Authorization: `Bearer ${oidcToken}`,
"Content-Type": "application/json", "Content-Type": "application/json",
...getVercelBypassHeaders(),
}, },
body: opts?.permissions ? JSON.stringify({ permissions: opts.permissions }) : undefined,
signal: controller.signal, signal: controller.signal,
}; });
if (opts?.permissions) {
fetchOptions.body = JSON.stringify({ permissions: opts.permissions });
}
const tokenResponse = await fetch(
`${apiUrl}/api/github/installation-token${queryString}`,
fetchOptions
);
clearTimeout(timeoutId); clearTimeout(timeoutId);
@@ -228,7 +218,7 @@ const checkRepositoryAccess = async (
const createInstallationToken = async ( const createInstallationToken = async (
jwt: string, jwt: string,
installationId: number, installationId: number,
permissions?: InstallationTokenPermissions permissions?: GitHubAppPermissions
): Promise<string> => { ): Promise<string> => {
const requestOpts: { method: string; headers: Record<string, string>; body?: string } = { const requestOpts: { method: string; headers: Record<string, string>; body?: string } = {
method: "POST", method: "POST",
@@ -287,6 +277,24 @@ async function acquireTokenViaGitHubApp(opts?: AcquireTokenOptions): Promise<str
return await createInstallationToken(jwt, installationId, opts?.permissions); return await createInstallationToken(jwt, installationId, opts?.permissions);
} }
/**
* Ensure a GitHub token is available in the environment.
*
* If neither `GITHUB_TOKEN` nor `GH_TOKEN` is set, attempts to acquire an
* installation token using `GITHUB_APP_ID` / `GITHUB_PRIVATE_KEY`.
*
* **Not intended for production use** this is a convenience for local
* development and test harnesses where tokens aren't pre-provisioned.
*/
export async function ensureGitHubToken(): Promise<void> {
if (!process.env.GITHUB_TOKEN && !process.env.GH_TOKEN) {
if (process.env.GITHUB_APP_ID && process.env.GITHUB_PRIVATE_KEY) {
const token = await acquireNewToken();
process.env.GITHUB_TOKEN = token;
}
}
}
export async function acquireNewToken(opts?: AcquireTokenOptions): Promise<string> { export async function acquireNewToken(opts?: AcquireTokenOptions): Promise<string> {
if (isOIDCAvailable()) { if (isOIDCAvailable()) {
return await retry(() => acquireTokenViaOIDC(opts), { return await retry(() => acquireTokenViaOIDC(opts), {
+11 -2
View File
@@ -346,7 +346,10 @@ export function resolveInstructions(ctx: InstructionsContext): ResolvedInstructi
Call \`delegate\` with a mode, effort level, and optional instructions: Call \`delegate\` with a mode, effort level, and optional instructions:
- \`mode\`: The workflow to run (see available modes below) - \`mode\`: The workflow to run (see available modes below)
- \`effort\`: \`"auto"\` (default, most capable), \`"mini"\` (fast, for simple tasks), or \`"max"\` (maximum capability) - \`effort\`:
- \`"mini"\`: low-effort and fast, for simple tasks
- \`"auto"\`: medium-effort, good for typical tasks that don't require significant reasoning
- \`"max"\`: high-effort, good for PR reviews and complex coding tasks.
- \`instructions\`: Optional additional context for the subagent. Use this to pass results from earlier delegations or narrow the subagent's focus. - \`instructions\`: Optional additional context for the subagent. Use this to pass results from earlier delegations or narrow the subagent's focus.
### Single vs. multi-phase delegation ### Single vs. multi-phase delegation
@@ -419,7 +422,13 @@ export function resolveSubagentInstructions(
): ResolvedInstructions { ): ResolvedInstructions {
const inputs = buildCommonInputs(ctx); const inputs = buildCommonInputs(ctx);
const subagentTaskSection = `You are operating in **${ctx.mode.name}** mode. const subagentTaskSection = `You are operating in **${ctx.mode.name}** mode as a delegated subagent. An orchestrator spawned you and will read your final output to decide what to do next.
### Delegation rules
- The \`delegate\` tool is NOT available to you — complete your task directly using the available tools.
- When you finish, end with a clear, concise summary: what you did, what succeeded, what failed, and any blockers or next steps. The orchestrator uses this to decide whether to delegate again or report final results.
- If you encounter an error you cannot resolve, report it clearly do not attempt to delegate or re-run yourself.
${ctx.mode.prompt}`; ${ctx.mode.prompt}`;
+20 -12
View File
@@ -6,8 +6,17 @@ import * as core from "@actions/core";
import { table } from "table"; import { table } from "table";
import { isGitHubActions, isInsideDocker } from "./globals.ts"; import { isGitHubActions, isInsideDocker } from "./globals.ts";
const isDebugEnabled = () => const isRunnerDebugEnabled = () => core.isDebug();
process.env.LOG_LEVEL === "debug" || process.env.ACTIONS_STEP_DEBUG === "true" || core.isDebug();
const isLocalDebugEnabled = () =>
process.env.LOG_LEVEL === "debug" || process.env.ACTIONS_STEP_DEBUG === "true";
const isDebugEnabled = () => isLocalDebugEnabled() || isRunnerDebugEnabled();
/** timestamp prefix for debug mode — empty string when debug is off */
function ts(): string {
return isDebugEnabled() ? `[${new Date().toISOString()}] ` : "";
}
/** /**
* Format arguments into a single string for logging * Format arguments into a single string for logging
@@ -206,23 +215,18 @@ function separator(length: number = 50): void {
/** /**
* Main logging utility object - import this once and access all utilities * Main logging utility object - import this once and access all utilities
*/ */
/** timestamp prefix for debug mode — empty string when debug is off */
function ts(): string {
return isDebugEnabled() ? `[${new Date().toISOString()}] ` : "";
}
export const log = { export const log = {
/** Print info message */ /** Print info message */
info: (...args: unknown[]): void => { info: (...args: unknown[]): void => {
core.info(`${ts()}${formatArgs(args)}`); core.info(`${ts()}${formatArgs(args)}`);
}, },
/** Print warning message */ /** Print a warning message. Use only for warnings that should be displayed in the job summary. */
warning: (...args: unknown[]): void => { warning: (...args: unknown[]): void => {
core.warning(`${ts()}${formatArgs(args)}`); core.warning(`${ts()}${formatArgs(args)}`);
}, },
/** Print error message */ /** Print an error message. Use only for errors that should be displayed in the job summary. */
error: (...args: unknown[]): void => { error: (...args: unknown[]): void => {
core.error(`${ts()}${formatArgs(args)}`); core.error(`${ts()}${formatArgs(args)}`);
}, },
@@ -232,10 +236,14 @@ export const log = {
core.info(`${ts()}» ${formatArgs(args)}`); core.info(`${ts()}» ${formatArgs(args)}`);
}, },
/** Print debug message (only if LOG_LEVEL=debug) */ /** Print debug message (only when debug mode is enabled) */
debug: (...args: unknown[]): void => { debug: (...args: unknown[]): void => {
if (isDebugEnabled()) { if (isRunnerDebugEnabled()) {
core.info(`[${new Date().toISOString()}] [DEBUG] ${formatArgs(args)}`); core.debug(formatArgs(args));
return;
}
if (isLocalDebugEnabled()) {
core.info(`${ts()}[DEBUG] ${formatArgs(args)}`);
} }
}, },
+10
View File
@@ -19,6 +19,7 @@ export const JsonPayload = type({
version: "string", version: "string",
"agent?": AgentName.or("undefined"), "agent?": AgentName.or("undefined"),
prompt: "string", prompt: "string",
"triggeringUser?": "string | undefined",
"eventInstructions?": "string", "eventInstructions?": "string",
"repoInstructions?": "string", "repoInstructions?": "string",
"event?": "object", "event?": "object",
@@ -108,6 +109,11 @@ function resolveNonPromptInputs() {
}); });
} }
const isPullfrog = (actor: string | null | undefined): boolean => {
actor = actor?.replace("[bot]", "");
return !!actor && (actor === "pullfrog" || actor === "pullfrogdev");
};
export function resolvePayload( export function resolvePayload(
resolvedPromptInput: ResolvedPromptInput, resolvedPromptInput: ResolvedPromptInput,
repoSettings: RepoSettings repoSettings: RepoSettings
@@ -161,6 +167,10 @@ export function resolvePayload(
version: jsonPayload?.version ?? packageJson.version, version: jsonPayload?.version ?? packageJson.version,
agent: resolvedAgent, agent: resolvedAgent,
prompt, prompt,
triggeringUser:
jsonPayload?.triggeringUser ??
// it's not a common use case but GITHUB_ACTOR can be a user when the workflow is manually triggered by a user through GitHub Actions UI
(!isPullfrog(process.env.GITHUB_ACTOR) ? process.env.GITHUB_ACTOR : undefined),
eventInstructions: jsonPayload?.eventInstructions, eventInstructions: jsonPayload?.eventInstructions,
repoInstructions: jsonPayload?.repoInstructions, repoInstructions: jsonPayload?.repoInstructions,
event, event,
+187
View File
@@ -0,0 +1,187 @@
import { LEAPING_INTO_ACTION_PREFIX } from "../mcp/comment.ts";
import { buildPullfrogFooter } from "./buildPullfrogFooter.ts";
import { log } from "./cli.ts";
import { createOctokit, parseRepoContext } from "./github.ts";
import { type ResolvedPromptInput, resolvePromptInput } from "./payload.ts";
import { getJobToken } from "./token.ts";
type JsonPromptInput = Extract<ResolvedPromptInput, object>; // not string
/**
* Controls whether the script should check the reason for the workflow termination.
* It can be either canceled or failed.
* YAML file cannot supply it (not in ENV), so an extra request is required to check it.
* */
const SHOULD_CHECK_REASON = true;
/**
* Build error comment body with error message and footer
*/
function buildErrorCommentBody(params: {
owner: string;
repo: string;
runId: string | undefined;
isCancellation: boolean;
}): string {
const workflowRunLink = params.runId
? `[workflow run logs](https://github.com/${params.owner}/${params.repo}/actions/runs/${params.runId})`
: "workflow run logs";
const errorMessage = params.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: params.runId
? { owner: params.owner, repo: params.repo, runId: params.runId }
: undefined,
});
return `${errorMessage}${footer}`;
}
/**
* Validate that the progress comment is stuck on "Leaping into action"
* Fetches the comment by ID and checks if it starts with LEAPING_INTO_ACTION_PREFIX
* Returns the comment ID if stuck, null otherwise
*/
async function validateStuckProgressComment(
promptInput: JsonPromptInput | null,
octokit: ReturnType<typeof createOctokit>,
owner: string,
repo: string
): Promise<number | null> {
if (!promptInput?.progressCommentId) {
log.info("[post] no progressCommentId in prompt input, skipping cleanup");
return null;
}
const commentId = parseInt(promptInput.progressCommentId, 10);
log.info(`[post] validating progressCommentId from prompt input: ${commentId}`);
try {
const { data: comment } = await octokit.rest.issues.getComment({
owner,
repo,
comment_id: commentId,
});
// check if comment is stuck on "Leaping into action"
if (comment.body?.startsWith(LEAPING_INTO_ACTION_PREFIX)) {
log.info(`[post] comment ${commentId} is stuck on "Leaping into action"`);
return commentId;
}
log.info(`[post] comment ${commentId} is not stuck (already updated or different content)`);
return null;
} catch (error) {
const errorMessage = error instanceof Error ? error.message : String(error);
log.info(`[post] failed to get comment ${commentId}: ${errorMessage}`);
return null;
}
}
/**
* Detect if the workflow or its steps is cancelled.
* While the job is still in_progress, the individual steps may have their conclusions set.
*/
async function getIsCancelled(params: {
repoContext: ReturnType<typeof parseRepoContext>;
octokit: ReturnType<typeof createOctokit>;
runIdStr: string | undefined;
}): Promise<boolean> {
if (!params.runIdStr) return false; // can't check without a run ID — assume failure
try {
const { data: jobs } = await params.octokit.rest.actions.listJobsForWorkflowRun({
owner: params.repoContext.owner,
repo: params.repoContext.name,
run_id: Number.parseInt(params.runIdStr, 10),
});
// find current job by matching GITHUB_JOB env var
// Note: GITHUB_JOB is the job ID (yaml key), but job.name is the display name
// For matrix jobs, the name includes matrix values like "build (ubuntu-latest, node-18)"
// So we match jobs that START with the job ID
const currentJobName = process.env.GITHUB_JOB;
const currentJob = currentJobName
? jobs.jobs.find((j) => j.name === currentJobName || j.name.startsWith(`${currentJobName} (`))
: jobs.jobs[0]; // fallback to first job
if (!currentJob) {
log.warning("[post] could not find current job");
return false;
}
log.info(`[post] job status: ${currentJob.status}, conclusion: ${currentJob.conclusion}`);
if (currentJob.conclusion === "cancelled") return true; // whole job explicit cancellation
// but if it's still null, check steps for cancellation:
const cancelledStep = currentJob.steps?.find((step) => step.conclusion === "cancelled");
if (cancelledStep) {
log.info(`[post] found cancelled step: ${cancelledStep.name}`);
return true;
}
log.info("[post] no cancellation found, assuming failure");
} catch (error) {
log.info(
`[post] failed to get job status: ${error instanceof Error ? error.message : String(error)}`
);
}
return false; // assuming failure
}
export async function runPostCleanup(): Promise<void> {
log.info("» [post] starting post cleanup");
const runIdStr = process.env.GITHUB_RUN_ID;
// resolve prompt input once and use it for both issue number and comment ID extraction
// only use the object form (JSON payload), not plain string prompts
let promptInput: JsonPromptInput | null = null;
try {
const resolved = resolvePromptInput();
if (typeof resolved !== "string") promptInput = resolved;
} catch (error) {
log.info(
`[post] failed to resolve prompt input: ${error instanceof Error ? error.message : String(error)}`
);
}
// get job token for API calls
const token = getJobToken();
const repoContext = parseRepoContext();
const octokit = createOctokit(token);
// validate that progressCommentId from prompt input is stuck on "Leaping into action"
const commentId = await validateStuckProgressComment(
promptInput,
octokit,
repoContext.owner,
repoContext.name
);
if (!commentId) return log.info("» [post] no stuck progress comment to update, skipping cleanup");
log.info(`» [post] validated stuck comment: ${commentId}, updating with error message`);
try {
const body = buildErrorCommentBody({
owner: repoContext.owner,
repo: repoContext.name,
runId: runIdStr,
isCancellation: SHOULD_CHECK_REASON
? await getIsCancelled({ octokit, repoContext, runIdStr })
: false,
});
await octokit.rest.issues.updateComment({
owner: repoContext.owner,
repo: repoContext.name,
comment_id: commentId,
body,
});
log.info("» [post] successfully updated progress comment");
} catch (error) {
const errorMessage = error instanceof Error ? error.message : String(error);
log.info(`[post] failed to update comment: ${errorMessage}`);
}
}
+1 -3
View File
@@ -37,9 +37,7 @@ export async function retry<T>(fn: () => Promise<T>, options: RetryOptions = {})
} }
const delay = delayMs * attempt; const delay = delayMs * attempt;
log.warning( log.info(`» ${label} failed (attempt ${attempt}/${maxAttempts}), retrying in ${delay}ms...`);
`» ${label} failed (attempt ${attempt}/${maxAttempts}), retrying in ${delay}ms...`
);
await new Promise((resolve) => setTimeout(resolve, delay)); await new Promise((resolve) => setTimeout(resolve, delay));
} }
} }
+9 -14
View File
@@ -1,5 +1,5 @@
import type { AgentName, BashPermission, PushPermission, ToolPermission } from "../external.ts"; import type { AgentName, BashPermission, PushPermission, ToolPermission } from "../external.ts";
import { getApiUrl, getVercelBypassHeaders } from "./apiUrl.ts"; import { apiFetch } from "./apiFetch.ts";
import type { RepoContext } from "./github.ts"; import type { RepoContext } from "./github.ts";
export interface Mode { export interface Mode {
@@ -52,24 +52,19 @@ export async function fetchRunContext(params: {
token: string; token: string;
repoContext: RepoContext; repoContext: RepoContext;
}): Promise<RunContext> { }): Promise<RunContext> {
const apiUrl = getApiUrl();
const timeoutMs = 30000; const timeoutMs = 30000;
const controller = new AbortController(); const controller = new AbortController();
const timeoutId = setTimeout(() => controller.abort(), timeoutMs); const timeoutId = setTimeout(() => controller.abort(), timeoutMs);
try { try {
const response = await fetch( const response = await apiFetch({
`${apiUrl}/api/repo/${params.repoContext.owner}/${params.repoContext.name}/run-context`, path: `/api/repo/${params.repoContext.owner}/${params.repoContext.name}/run-context`,
{ headers: {
method: "GET", Authorization: `Bearer ${params.token}`,
headers: { "Content-Type": "application/json",
Authorization: `Bearer ${params.token}`, },
"Content-Type": "application/json", signal: controller.signal,
...getVercelBypassHeaders(), });
},
signal: controller.signal,
}
);
clearTimeout(timeoutId); clearTimeout(timeoutId);
+5 -20
View File
@@ -3,7 +3,6 @@
* Redacts actual secret values rather than using pattern matching * Redacts actual secret values rather than using pattern matching
*/ */
import { agentsManifest } from "../external.ts";
import { getGitHubInstallationToken } from "./token.ts"; import { getGitHubInstallationToken } from "./token.ts";
// patterns for sensitive env var names // patterns for sensitive env var names
@@ -52,28 +51,14 @@ export function resolveEnv(mode: EnvMode | undefined): Record<string, string | u
function getAllSecrets(): string[] { function getAllSecrets(): string[] {
const secrets: string[] = []; const secrets: string[] = [];
// get all API key values from agent manifest // collect all env var values matching SENSITIVE_PATTERNS
for (const agent of Object.values(agentsManifest)) { for (const [key, value] of Object.entries(process.env)) {
for (const keyName of agent.apiKeyNames) { if (value && isSensitiveEnvName(key)) {
const envKey = keyName.toUpperCase(); secrets.push(value);
const value = process.env[envKey];
if (value) {
secrets.push(value);
}
} }
} }
// for OpenCode: also scan all API_KEY environment variables (since apiKeyNames is empty) // add GitHub installation token (stored in memory, not in env)
const opencodeAgent = agentsManifest.opencode;
if (opencodeAgent && opencodeAgent.apiKeyNames.length === 0) {
for (const [key, value] of Object.entries(process.env)) {
if (value && typeof value === "string" && key.includes("API_KEY")) {
secrets.push(value);
}
}
}
// add GitHub installation token
try { try {
const token = getGitHubInstallationToken(); const token = getGitHubInstallationToken();
if (token) { if (token) {
+2 -4
View File
@@ -69,7 +69,7 @@ export interface SetupGitParams extends GitContext {
* - sets up authentication via gitToken (minimal contents:write) * - sets up authentication via gitToken (minimal contents:write)
* - for PR events, checks out the PR branch using shared helper * - for PR events, checks out the PR branch using shared helper
* *
* gitToken is a minimal-permission token (contents:write only) used for git operations. * gitToken is a minimal-permission token (contents + workflows) used for git operations.
* it is assumed to be potentially exfiltratable, so it has limited scope. * it is assumed to be potentially exfiltratable, so it has limited scope.
*/ */
export async function setupGit(params: SetupGitParams): Promise<void> { export async function setupGit(params: SetupGitParams): Promise<void> {
@@ -121,9 +121,7 @@ export async function setupGit(params: SetupGitParams): Promise<void> {
} catch (error) { } catch (error) {
// If git config fails, log warning but don't fail the action // If git config fails, log warning but don't fail the action
// This can happen if we're not in a git repo or git isn't available // This can happen if we're not in a git repo or git isn't available
log.warning( log.info(`Failed to set git config: ${error instanceof Error ? error.message : String(error)}`);
`Failed to set git config: ${error instanceof Error ? error.message : String(error)}`
);
} }
// 2. setup authentication // 2. setup authentication
+19 -27
View File
@@ -2,6 +2,7 @@ import { type ChildProcess, spawn as nodeSpawn } from "node:child_process";
import { performance } from "node:perf_hooks"; import { performance } from "node:perf_hooks";
import { DEFAULT_ACTIVITY_CHECK_INTERVAL_MS, DEFAULT_ACTIVITY_TIMEOUT_MS } from "./activity.ts"; import { DEFAULT_ACTIVITY_CHECK_INTERVAL_MS, DEFAULT_ACTIVITY_TIMEOUT_MS } from "./activity.ts";
import { log } from "./cli.ts"; import { log } from "./cli.ts";
import { onExitSignal } from "./exitHandler.ts";
export type TrackChildOptions = { export type TrackChildOptions = {
child: ChildProcess; child: ChildProcess;
@@ -18,6 +19,9 @@ let externalSignalHandler: SignalHandler | null = null;
// track a child process for cleanup on Ctrl+C // track a child process for cleanup on Ctrl+C
export function trackChild(options: TrackChildOptions): void { export function trackChild(options: TrackChildOptions): void {
// the signal handler cleans up all tracked children
// so we only have to install it once some child gets tracked
installSignalHandler();
activeChildren.set(options.child, options.killGroup ?? false); activeChildren.set(options.child, options.killGroup ?? false);
} }
@@ -32,8 +36,7 @@ export function setSignalHandler(handler: SignalHandler | null): void {
} }
// kill all tracked children without exiting // kill all tracked children without exiting
export function killTrackedChildren(): number { export function killTrackedChildren() {
const count = activeChildren.size;
for (const entry of activeChildren) { for (const entry of activeChildren) {
const child = entry[0]; const child = entry[0];
const killGroup = entry[1]; const killGroup = entry[1];
@@ -47,35 +50,24 @@ export function killTrackedChildren(): number {
} }
child.kill("SIGKILL"); child.kill("SIGKILL");
} }
return count;
}
// cleanup handler for SIGINT/SIGTERM - kills all tracked children
function cleanupAndExit(signal: string): void {
const count = killTrackedChildren();
if (count > 0) {
log.info(`» received ${signal}, killing ${count} subprocess(es)...`);
}
// force exit after a short delay if process is stuck
setTimeout(() => process.exit(1), 500).unref();
process.exit(1);
}
function handleSignal(signal: NodeJS.Signals): void {
if (externalSignalHandler) {
externalSignalHandler(signal);
return;
}
cleanupAndExit(signal);
} }
// install signal handlers once (call early in process lifecycle) // install signal handlers once (call early in process lifecycle)
let handlersInstalled = false; let handlersInstalled = false;
export function installSignalHandlers(): void { function installSignalHandler(): void {
if (handlersInstalled) return; if (handlersInstalled) return;
handlersInstalled = true; handlersInstalled = true;
process.on("SIGINT", () => handleSignal("SIGINT")); onExitSignal((signal) => {
process.on("SIGTERM", () => handleSignal("SIGTERM")); if (externalSignalHandler) {
externalSignalHandler(signal);
return;
}
const count = activeChildren.size;
if (count > 0) {
log.info(`» received ${signal}, killing ${count} subprocess(es)...`);
}
killTrackedChildren();
});
} }
export interface SpawnOptions { export interface SpawnOptions {
@@ -106,7 +98,7 @@ export async function spawn(options: SpawnOptions): Promise<SpawnResult> {
const { cmd, args, env, input, timeout, cwd, stdio, onStdout, onStderr } = options; const { cmd, args, env, input, timeout, cwd, stdio, onStdout, onStderr } = options;
const activityTimeoutMs = options.activityTimeout ?? DEFAULT_ACTIVITY_TIMEOUT_MS; const activityTimeoutMs = options.activityTimeout ?? DEFAULT_ACTIVITY_TIMEOUT_MS;
installSignalHandlers(); installSignalHandler();
const startTime = performance.now(); const startTime = performance.now();
let stdoutBuffer = ""; let stdoutBuffer = "";
@@ -157,7 +149,7 @@ export async function spawn(options: SpawnOptions): Promise<SpawnResult> {
if (idleMs > activityTimeoutMs) { if (idleMs > activityTimeoutMs) {
isActivityTimedOut = true; isActivityTimedOut = true;
const idleSec = Math.round(idleMs / 1000); const idleSec = Math.round(idleMs / 1000);
log.error(`no output for ${idleSec}s from pid=${child.pid} (${cmd}), killing process`); log.info(`no output for ${idleSec}s from pid=${child.pid} (${cmd}), killing process`);
child.kill("SIGKILL"); child.kill("SIGKILL");
clearInterval(activityCheckIntervalId); clearInterval(activityCheckIntervalId);
} }
+36 -10
View File
@@ -2,6 +2,7 @@ import assert from "node:assert/strict";
import * as core from "@actions/core"; import * as core from "@actions/core";
import type { PushPermission } from "../external.ts"; import type { PushPermission } from "../external.ts";
import { log } from "./cli.ts"; import { log } from "./cli.ts";
import { onExitSignal } from "./exitHandler.ts";
import { acquireNewToken } from "./github.ts"; import { acquireNewToken } from "./github.ts";
import { isGitHubActions } from "./globals.ts"; import { isGitHubActions } from "./globals.ts";
@@ -105,12 +106,20 @@ export async function resolveTokens(params: ResolveTokensParams): Promise<TokenR
// create git token based on push permission (assumed exfiltratable) // create git token based on push permission (assumed exfiltratable)
// disabled = read-only, restricted/enabled = write (MCP tools enforce branch restrictions) // disabled = read-only, restricted/enabled = write (MCP tools enforce branch restrictions)
const gitContents = params.push === "disabled" ? "read" : "write"; // workflows permission is write-only in the API, so only requested when pushing is allowed
const gitToken = await acquireNewToken({ permissions: { contents: gitContents } }); const gitPermissions =
params.push === "disabled"
? { contents: "read" as const }
: { contents: "write" as const, workflows: "write" as const };
const gitToken = await acquireNewToken({ permissions: gitPermissions });
if (isGitHubActions) { if (isGitHubActions) {
core.setSecret(gitToken); core.setSecret(gitToken);
} }
log.info(`» acquired git token (contents:${gitContents})`); log.info(
`» acquired git token (${Object.entries(gitPermissions)
.map((e) => e.join(":"))
.join(", ")})`
);
// create full MCP token - not exfiltratable (only accessible via MCP tools) // create full MCP token - not exfiltratable (only accessible via MCP tools)
const mcpToken = await acquireNewToken(); const mcpToken = await acquireNewToken();
@@ -123,18 +132,35 @@ export async function resolveTokens(params: ResolveTokensParams): Promise<TokenR
const revertGithubToken = setEnvironmentVariable("GITHUB_TOKEN", mcpToken); const revertGithubToken = setEnvironmentVariable("GITHUB_TOKEN", mcpToken);
mcpTokenValue = mcpToken; mcpTokenValue = mcpToken;
return { let disposingRef: PromiseWithResolvers<void> | undefined;
gitToken,
mcpToken, const dispose = async () => {
async [Symbol.asyncDispose]() { if (disposingRef) {
// this can happen if the signal arrives when disposing tokens
// we make sure to wait for the current dispose to complete
return disposingRef.promise;
}
disposingRef = Promise.withResolvers();
try {
mcpTokenValue = undefined; mcpTokenValue = undefined;
revertGithubToken(); revertGithubToken();
// revoke both tokens
await Promise.all([ await Promise.all([
revokeGitHubInstallationToken(gitToken), revokeGitHubInstallationToken(gitToken),
revokeGitHubInstallationToken(mcpToken), revokeGitHubInstallationToken(mcpToken),
]); ]);
}, } finally {
removeSignalHandler();
disposingRef.resolve();
disposingRef = undefined;
}
};
const removeSignalHandler = onExitSignal(dispose);
return {
gitToken,
mcpToken,
[Symbol.asyncDispose]: dispose,
}; };
} }
@@ -161,7 +187,7 @@ export async function revokeGitHubInstallationToken(token: string): Promise<void
}); });
log.debug("» installation token revoked"); log.debug("» installation token revoked");
} catch (error) { } catch (error) {
log.warning( log.info(
`Failed to revoke installation token: ${error instanceof Error ? error.message : String(error)}` `Failed to revoke installation token: ${error instanceof Error ? error.message : String(error)}`
); );
} }
-3
View File
@@ -1,3 +0,0 @@
export interface WorkflowRunInfo {
progressCommentId: string | null;
}
+2 -4
View File
@@ -1,9 +1,7 @@
import { resolve } from "node:path"; import { resolve } from "node:path";
import { config } from "dotenv"; import { config } from "dotenv";
import { ensureGitHubToken } from "./utils/github.ts";
config({ path: resolve(import.meta.dirname, "../.env") }); config({ path: resolve(import.meta.dirname, "../.env") });
// alias GITHUB_TOKEN to GH_TOKEN for tests await ensureGitHubToken();
if (!process.env.GH_TOKEN && process.env.GITHUB_TOKEN) {
process.env.GH_TOKEN = process.env.GITHUB_TOKEN;
}