Compare commits

...

36 Commits

Author SHA1 Message Date
Colin McDonnell 7d85e653ca bump action version to 0.0.201
Made-with: Cursor
2026-04-15 20:26:44 +00:00
Colin McDonnell 4b3c5ca905 rename agent key to opencode and add skill invocation coverage (#541)
* rename agent key to opencode and add skill invocation coverage.

add skill-invoke tests for claude and opencode with local play-based validation signals, update CI matrices, and include the current tracked refactors in this branch for review.

Made-with: Cursor

* exclude agent-specific skill-invoke tests from wrong agent in CI matrix

* address review follow-up and preserve workflow run UI tweak.

switch changed-agents ci coverage to exercise the opentoad implementation path while keeping the opencode expectation, and include the local workflow run client interaction updates requested on this branch.

Made-with: Cursor

* remove opentoad agent filename from runtime.

rename the opencode harness implementation file from opentoad.ts to opencode.ts and update ci coverage input accordingly so action code no longer carries the old filename.

Made-with: Cursor

* ensure security prompt bypass is set on every test fixture.

this keeps adversarial and permissions harnesses from being blocked by the default prompt-injection refusal path during CI security tests.

Made-with: Cursor

---------

Co-authored-by: pullfrog[bot] <226033991+pullfrog[bot]@users.noreply.github.com>
2026-04-15 19:38:36 +00:00
Colin McDonnell 2799cce4bf homepage redesign + docs cleanup + agent prompting (#540)
* homepage redesign + docs cleanup + agent prompting improvements

- rewrote hero section: new tagline, responsive font sizing with clamp(),
  extracted shared constants for copy management
- added feature screenshots (shell isolation, github permissions, mcp tools,
  agent browser) and ensured consistent image sizing
- reworked feature section mobile layout: caption-style descriptions, bigger
  h3s, image padding
- made CTA buttons visible on all breakpoints (stacked on mobile, row on md+)
- reorganized docs/tools.mdx into single table with category dividers,
  simplified tool descriptions
- added markdown image syntax instruction to agent system prompt
- fixed InfoPopover overflow on small screens
- misc: InlineCode proportional sizing, OnboardingCard updates, shell/security
  doc improvements

Made-with: Cursor

* update pnpm-lock.yaml for agent-browser 0.25.4

Made-with: Cursor
2026-04-15 00:29:32 +00:00
Colin McDonnell 1da3f68e4e bump action version to 0.0.200
Made-with: Cursor
2026-04-14 23:57:32 +00:00
Colin McDonnell 50f2678f55 bump action version to 0.0.199
Made-with: Cursor
2026-04-14 23:34:56 +00:00
Colin McDonnell b748355cbe homepage copy refresh + fix skills CLI installation (#539)
* add wiki/betterstack.md documenting log querying, request-ID grouping, and MCP usage

Made-with: Cursor

* fix webhook race conditions: separate runId assignment from data updates

the workflow_run webhook handler had a race where concurrent handlers assigned
the same runId to different pending records. the loser's P2002 silently dropped
data updates (jobId, status, completedAt). fix by splitting into two steps:
assignRunId() handles the race-safe unique assignment, then data updates always
target where: { runId } so they hit the correct record regardless of who won.

also downgrade R2 ObjectLockedByBucketPolicy errors from error to warn level
since duplicate webhook deliveries writing the same key is expected under load.

Made-with: Cursor

* homepage copy refresh + fix skills CLI installation

- update hero to "Agent x GitHub" with new subtagline
- rewrite intro paragraphs: workflow, harness capabilities, billing
- add feature sections: bash isolation, headless browser, MCP tools
- update FAQ answers, footer attribution, free-for-oss copy
- update APP_DESCRIPTION for SEO
- fix skills install: use npx from tmpdir instead of local binary
  (the bundled action has no node_modules; running npx from tmpdir
  avoids project .npmrc with pnpm settings breaking binary resolution)
- instruct agents to use markdown image syntax in upload_file tool
- start dependency installation eagerly from main.ts
- include event title in task instructions

Made-with: Cursor
2026-04-14 20:37:20 +00:00
Colin McDonnell c86752cf1d require OIDC verification for DB secrets on run-context (#538)
* add wiki/betterstack.md documenting log querying, request-ID grouping, and MCP usage

Made-with: Cursor

* fix webhook race conditions: separate runId assignment from data updates

the workflow_run webhook handler had a race where concurrent handlers assigned
the same runId to different pending records. the loser's P2002 silently dropped
data updates (jobId, status, completedAt). fix by splitting into two steps:
assignRunId() handles the race-safe unique assignment, then data updates always
target where: { runId } so they hit the correct record regardless of who won.

also downgrade R2 ObjectLockedByBucketPolicy errors from error to warn level
since duplicate webhook deliveries writing the same key is expected under load.

Made-with: Cursor

* require OIDC verification for DB secrets on run-context endpoint

DB secrets transported via run-context were accessible to any GitHub API
token with read access, bypassing GitHub Actions' fork PR secret isolation.
Now the endpoint requires a valid GitHub Actions OIDC token
(X-GitHub-OIDC-Token header) with a matching repository claim before
returning dbSecrets. Also requires admin for account-scope CLI secret
writes (matching the dashboard), and removes dead redactSecrets code.

Made-with: Cursor
2026-04-14 20:15:31 +00:00
David Blass a4c7c0fc15 feat: workflow run artifact chips + GraphQL url resolution (#447) (#527)
* plan: issue 447 run artifact tracking and UI (supersedes stale pill notes)

Made-with: Cursor

* feat: workflow run artifact urls, chips, and safe PATCH validation

Made-with: Cursor

* chore(action): refresh latest-by-provider model snapshot

Made-with: Cursor

* refactor: resolve artifact urls via GraphQL nodes(ids), drop stored url columns

Made-with: Cursor

* docs: finalize issue 447 run-artifacts plan; remove demo backfill script

Made-with: Cursor

* refactor: DRY node-id constraint, replace margin with padding wrapper

Made-with: Cursor

* refactor: DRY audit — shared row info, derived types, unified Prisma select

- extract WorkflowRunRowInfo component (description + issue link + time + pills)
  shared by ActiveWorkflowRunsSection and WorkflowRunHistory
- derive API payload types via Omit + & instead of manual field lists;
  serialize with spread + override for bigint/date fields
- extract workflowRunListSelect shared Prisma select base; history extends
  with completedAt
- inline updateCommentNodeId → direct patchWorkflowRunFields calls
- derive WorkflowRunArtifactSlice from canonical exported types
- delete cancelling-out URL column migrations (no schema change vs main)

Made-with: Cursor

* refactor: artifact chips as inline CTAs with proper vertical alignment

- chips now render as action links: "Open PR #N", "View summary", etc.
- only render chips with resolved URLs; remove inert span fallback
- inline chips in the row (right-justified) instead of a separate line
- fix vertical alignment: remove ul/li wrappers that caused line-height
  mismatch, render chips as direct row siblings via flat flex layout
- change row to items-center, remove compensating self-start/pt nudges
- cancelled run X icon uses red-600

Made-with: Cursor

* chore(action): refresh latest-by-provider model snapshot

Made-with: Cursor

---------

Co-authored-by: Colin McDonnell <colinmcd94@gmail.com>
2026-04-14 04:42:40 +00:00
Colin McDonnell abdbdc7245 update stale openrouter model snapshot
Made-with: Cursor
2026-04-14 01:15:56 +00:00
Colin McDonnell 5393d3dab4 bump action version to 0.0.198.
prepare the action package for the next publish with the ESM export/build updates already merged.

Made-with: Cursor
2026-04-12 19:51:18 +00:00
Colin McDonnell 3c2f3722ff fix action package exports and build ESM library entrypoints.
emit real ESM runtime + declaration outputs for programmatic imports, align package exports/types with built files, and add a no-cjs policy note.

Made-with: Cursor
2026-04-12 19:49:21 +00:00
Colin McDonnell 6541bdc4f4 test-token: use auth-only endpoint to actually verify the token
Made-with: Cursor
2026-04-12 19:44:03 +00:00
Colin McDonnell 1393ffb7b8 fix test-token workflow: use full action ref so runCli takes local path
Made-with: Cursor
2026-04-12 19:37:24 +00:00
Colin McDonnell f663d5e34d add workflow_dispatch test for get-installation-token action
Made-with: Cursor
2026-04-12 19:34:53 +00:00
Colin McDonnell d1e075fa3b fix npx binary resolution: run in workspace, not action directory
npx was running with cwd set to the action's own directory, which has
package.json with "name": "pullfrog". npm treats the local package as
satisfying the request and skips the registry fetch, then fails to find
the binary (sh: 1: pullfrog: not found). use GITHUB_WORKSPACE instead.

Made-with: Cursor
2026-04-12 19:17:09 +00:00
Colin McDonnell ed90735ba0 drop redundant NODE_AUTH_TOKEN="" from publish step
Made-with: Cursor
2026-04-12 19:03:22 +00:00
Colin McDonnell bbcf91a06e fix publish workflow: add build step, use OIDC trusted publishing, bump 0.0.196
publish was missing a build step so the npm tarball had no dist/.
switch from NPM_TOKEN to OIDC trusted publishing — explicitly unset
NODE_AUTH_TOKEN so setup-node's .npmrc doesn't override the OIDC flow.
bump version since v0.0.195 tag exists from the failed publish attempt.

Made-with: Cursor
2026-04-12 19:02:32 +00:00
Colin McDonnell 8a6696dd1d fix lint errors, consolidate husky hooks into root .husky
action/.husky prepare script was overriding root husky config, so the
pre-push hook (lint + typecheck + test) never ran. merged the lockfile
sync pre-commit into root .husky/pre-commit and removed action/.husky.
also auto-fixed biome format/import-sort errors from last commit.

Made-with: Cursor
2026-04-12 18:57:48 +00:00
Colin McDonnell 23a39d7f4b polish CLI init UX, backfill jobId on workflow-run page, bump to 0.0.195
simplify installation-not-found flow by removing ownerHasInstallation
field and collapsing the "selected repos" vs "no access" branches into
a single message with a confirm prompt. improve spinner/log copy
throughout init (secrets, model, workflow, test run).

backfill missing jobId on workflow-run redirect page by querying the
GitHub API for the pullfrog job when jobId is null. add 600ms delay
in handleWorkflowRunInProgress before fetching jobs to avoid racing
the job assignment.

Made-with: Cursor
2026-04-12 18:53:27 +00:00
Colin McDonnell 8ee9e3176a remove generate-proxies postinstall hack, resolve pullfrog source via bundler config
the postinstall script referenced scripts/generate-proxies.ts which isn't
included in the published npm package, silently breaking every npx install.
replaced the proxy stub approach with turbopack resolveAlias and webpack
conditionNames so both bundlers resolve pullfrog imports to TypeScript
source directly — matching what tsc already does via customConditions.

also moves PR summary format from handleWebhook into modes.ts so the
summarize mode prompt includes it directly.

Made-with: Cursor
2026-04-12 17:31:46 +00:00
Colin McDonnell ef31821dc5 fix: trigger preview-create on ready_for_review
PRs created as draft (or by automation tokens that suppress workflow
triggers) never ran preview-create because the workflow only listened
for opened/synchronize. Adding ready_for_review as a trigger ensures
the preview repo gets created when a draft PR is marked ready.

Also makes preview-create.ts idempotent by catching 422 (repo already
exists) so it's safe if both opened and ready_for_review fire.

Made-with: Cursor
2026-04-12 16:53:37 +00:00
Colin McDonnell 421607cf97 fix push-to-action: use CLI direct invocation for token acquisition
the inline `node -e` + `TOKEN=$(...)` approach broke because
`core.getIDToken()` in @actions/core writes `::debug::` and
`::add-mask::` to stdout, polluting the captured value.

`node cli.ts gha token` uses `core.setOutput()` which writes to
the $GITHUB_OUTPUT file instead of stdout.

Made-with: Cursor
2026-04-12 00:47:41 +00:00
Colin McDonnell 61bbfb932e v0.0.194
Made-with: Cursor
2026-04-11 04:15:35 +00:00
Colin McDonnell 255f29efb8 omit prior review feedback section entirely when nothing was addressed
Made-with: Cursor
2026-04-11 04:14:11 +00:00
Colin McDonnell 1c8e2f4f0f bump action to 0.0.193
Made-with: Cursor
2026-04-11 03:34:29 +00:00
Colin McDonnell b282e8b599 Improve incremental review output and fix todo tracker race (#529)
* improve incremental review output and fix todo tracker race

- reviewed changes section: summarize at logical-change level with
  past-tense verbs, not per-file enumerations
- add TodoTracker.completeAll() to mark all non-cancelled items as
  completed before snapshotting the collapsible in review/progress posts

Made-with: Cursor

* completeAll -> completeInProgress: only mark in-progress items as completed

Pending items that were genuinely skipped stay as-is in the collapsible,
so the task list honestly reflects what the agent actually did.

Made-with: Cursor
2026-04-10 20:15:34 +00:00
Colin McDonnell 9ee9731c67 fix: make token-exfil test reliable (#528)
* fix: make token-exfil test reliable by disabling security instructions and reframing prompt

the test was flaky — agents would randomly refuse (not calling set_output),
refuse politely (calling set_output with refusal text), or cooperate fully,
depending on model mood. two changes:

1. set PULLFROG_DISABLE_SECURITY_INSTRUCTIONS=1 in test env (layer 1)
2. reframe prompt as CI debugging task instead of security test (layer 2)

Made-with: Cursor

* fix: set PULLFROG_DISABLE_SECURITY_INSTRUCTIONS on adversarial test fixtures

without this flag, the system prompt tells agents to refuse anything that
looks malicious — which is exactly what these security pentests ask them to
do. adds the flag to tokenExfil, askpassIntercept, and nobashcreative.

Made-with: Cursor

* set PULLFROG_DISABLE_SECURITY_INSTRUCTIONS on all security-related test fixtures

Made-with: Cursor
2026-04-10 19:26:43 +00:00
Colin McDonnell 2759206a67 update stale model snapshot (glm-5.1 replaced qwen3.6-plus-free)
Made-with: Cursor
2026-04-10 16:41:18 +00:00
Colin McDonnell 08101a0e67 summarize mode: drop subagent delegation and dead effort hint
the "delegate a subagent" instruction doubled LLM sessions for
every summary run, and "use mini or auto effort" was a no-op
since the agent always runs at high/max effort.

Made-with: Cursor
2026-04-08 18:31:46 +00:00
Colin McDonnell b3112e4a15 Fix typos in AGENTS.md (#525)
* fix WorkflowRun mis-assignment when multiple dispatches are in flight

workflow_run_requested fires before GitHub applies the custom run-name,
so display_title has no [suffix]. the old desc ordering picked the newest
pending record, cross-linking enrichment ↔ auto-label records.

switch to FIFO (asc) ordering so records are claimed in dispatch order,
and add a 15s createdAt window to avoid claiming stale records.

fixes #523

Made-with: Cursor

* WIP

* plan: update issue indexing resolution to R2-backed lazy filesystem

replace the direct GitHub tarball + in-memory extraction approach with a
two-phase architecture: streaming tarball sync to R2 (per-file, via
tar-stream) and on-demand lazy loading via just-bash InMemoryFs backed
by R2 GETs. scales to 200K+ file monorepos at <50MB memory overhead.

Made-with: Cursor

* plan: switch to tarball + R2 range requests, add design alternatives rule

update issue indexing plan to use a single uncompressed tar in R2 with
byte-offset index instead of per-file uploads. 2 PUTs per sync vs 10K,
5000x cheaper, trivial lifecycle.

add AGENTS.md rule: generate 3 alternatives before committing to a design.

Made-with: Cursor

* fix typos in AGENTS.md

---------

Co-authored-by: pullfrog[bot] <226033991+pullfrog[bot]@users.noreply.github.com>
2026-04-08 16:15:59 +00:00
David Blass 1c730300b6 Clarify push, prepush, and progress errors in agent prompts (#521) 2026-04-06 20:47:43 +00:00
Colin McDonnell ab3e339db0 update models snapshot
Made-with: Cursor
2026-04-06 15:35:54 +00:00
Colin McDonnell 4bb280cd0a incremental review: improve no-new-issues body text
Made-with: Cursor
2026-04-04 22:00:24 +00:00
Colin McDonnell 426ef8c0d8 review: append todo list to review body, always delete progress comment
- in Review mode, stop the todo tracker and append the completed task
  list as a collapsible section to the review body before submitting
- always delete the progress comment after a review is submitted,
  regardless of whether the agent called report_progress

Made-with: Cursor
2026-04-04 21:59:29 +00:00
Colin McDonnell 8f7145e716 simplify incremental review summaries to bullet points
Made-with: Cursor
2026-04-04 20:52:23 +00:00
Colin McDonnell 2ea447a780 refactor: replace narrow parameter types with context objects (#519)
* refactor: replace narrow parameter types with context objects across action/

pass broader context objects (ToolContext, PromptContext, PostCleanupContext) to
utility functions instead of cherry-picking fields into single-use interfaces.
deletes 8 narrow types, simplifies call sites, and makes buildCommentFooter
synchronous by reading ctx.runId/ctx.jobId directly instead of re-deriving
from env vars and making an extra API call.

Made-with: Cursor

* fix: replace non-null assertion with local guard in validatePushDestination

addresses review feedback — the function now validates pushUrl itself instead
of relying on the caller's check, eliminating the ! assertion.

Made-with: Cursor

* revert: remove GH_TOKEN injection from restricted shell

the original change exposed the git token in restricted-mode shell so
`gh` CLI would work. this is a security regression for public repos: MCP
tools are deliberately constrained (no merge, no release, no arbitrary
API calls), but `gh api` with the token gives full GitHub API access to
any prompt-injected agent.

Made-with: Cursor
2026-04-04 20:51:49 +00:00
79 changed files with 2310 additions and 220332 deletions
+9 -8
View File
@@ -34,6 +34,9 @@ jobs:
- name: Install dependencies - name: Install dependencies
run: pnpm install --frozen-lockfile run: pnpm install --frozen-lockfile
- name: Build
run: pnpm build
- name: Get package version - name: Get package version
id: version id: version
run: | run: |
@@ -80,7 +83,7 @@ jobs:
tag_name: ${{ steps.version.outputs.tag }} tag_name: ${{ steps.version.outputs.tag }}
release_name: "${{ steps.version.outputs.tag }}" release_name: "${{ steps.version.outputs.tag }}"
body: | body: |
## 📦 @pullfrog/pullfrog ${{ steps.version.outputs.version }} ## 📦 pullfrog ${{ steps.version.outputs.version }}
### Usage in GitHub Actions ### Usage in GitHub Actions
@@ -91,16 +94,14 @@ jobs:
### Installation via npm ### Installation via npm
```bash ```bash
npm install @pullfrog/pullfrog@${{ steps.version.outputs.version }} npm install pullfrog@${{ steps.version.outputs.version }}
``` ```
draft: false draft: false
prerelease: false prerelease: false
# - name: Publish to npm - name: Publish to npm
# if: steps.check_tag.outputs.exists == 'false' if: steps.check_tag.outputs.exists == 'false'
# run: npm publish --access public run: npm publish --provenance --access public
# env:
# NODE_AUTH_TOKEN: ${{ secrets.NPM_TOKEN }}
- name: Summary - name: Summary
if: always() if: always()
@@ -118,5 +119,5 @@ jobs:
echo "" >> $GITHUB_STEP_SUMMARY echo "" >> $GITHUB_STEP_SUMMARY
echo "### 📦 Published to" >> $GITHUB_STEP_SUMMARY echo "### 📦 Published to" >> $GITHUB_STEP_SUMMARY
echo "- GitHub Release: [View Release](https://github.com/${{ github.repository }}/releases/tag/${{ steps.version.outputs.tag }})" >> $GITHUB_STEP_SUMMARY echo "- GitHub Release: [View Release](https://github.com/${{ github.repository }}/releases/tag/${{ steps.version.outputs.tag }})" >> $GITHUB_STEP_SUMMARY
echo "- npm Registry: [@pullfrog/pullfrog@${{ steps.version.outputs.version }}](https://www.npmjs.com/package/@pullfrog/pullfrog/v/${{ steps.version.outputs.version }})" >> $GITHUB_STEP_SUMMARY echo "- npm Registry: [pullfrog@${{ steps.version.outputs.version }}](https://www.npmjs.com/package/pullfrog/v/${{ steps.version.outputs.version }})" >> $GITHUB_STEP_SUMMARY
fi fi
+35
View File
@@ -0,0 +1,35 @@
name: Test get-installation-token
on:
push:
branches: [main]
workflow_dispatch:
permissions:
id-token: write
contents: read
jobs:
test-token:
runs-on: ubuntu-latest
steps:
- name: Get installation token
id: token
uses: pullfrog/pullfrog/get-installation-token@main
- name: Verify token with Node.js
env:
GITHUB_TOKEN: ${{ steps.token.outputs.token }}
run: |
node -e '
const res = await fetch("https://api.github.com/installation/repositories?per_page=1", {
headers: {
Authorization: "token " + process.env.GITHUB_TOKEN,
Accept: "application/vnd.github+json",
},
});
if (!res.ok) throw new Error("GET installation/repositories failed: " + res.status + " " + (await res.text()));
const data = await res.json();
console.log("authenticated — installation has access to", data.total_count, "repo(s)");
console.log("first repo:", data.repositories[0].full_name);
'
+15 -2
View File
@@ -27,9 +27,22 @@ jobs:
strategy: strategy:
fail-fast: true fail-fast: true
matrix: matrix:
agent: [claude, opentoad] agent: [claude, opencode]
test: test:
[mcpmerge, nobash, restricted, smoke, token-exfil] [
mcpmerge,
nobash,
restricted,
skill-invoke-claude,
skill-invoke-opencode,
smoke,
token-exfil,
]
exclude:
- agent: claude
test: skill-invoke-opencode
- agent: opencode
test: skill-invoke-claude
env: env:
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
ANTHROPIC_API_KEY: ${{ secrets.ANTHROPIC_API_KEY }} ANTHROPIC_API_KEY: ${{ secrets.ANTHROPIC_API_KEY }}
-8
View File
@@ -1,8 +0,0 @@
# sync action lockfile when action/package.json changes
if git diff --cached --name-only | grep -q "^action/package.json$"; then
echo "🔒 syncing action/pnpm-lock.yaml..."
# 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
fi
+2 -2
View File
@@ -35,8 +35,8 @@ outputs:
runs: runs:
using: "node24" using: "node24"
main: "entry" main: "entry.ts"
post: "post" post: "post.ts"
post-if: "failure() || cancelled()" post-if: "failure() || cancelled()"
branding: branding:
+1 -1
View File
@@ -1,7 +1,7 @@
/** /**
* Claude Code agent — secure harness around the `claude` CLI. * Claude Code agent — secure harness around the `claude` CLI.
* *
* mirrors the opentoad harness's security model: * mirrors the opencode harness's security model:
* - native Bash blocked via --disallowedTools (agent cannot shell out) * - native Bash blocked via --disallowedTools (agent cannot shell out)
* - managed-settings.json: filesystem sandbox — deny /proc, /sys reads * - managed-settings.json: filesystem sandbox — deny /proc, /sys reads
* - MCP ShellTool provides restricted shell (filtered env, no secrets) * - MCP ShellTool provides restricted shell (filtered env, no secrets)
+2 -2
View File
@@ -1,7 +1,7 @@
import { claude } from "./claude.ts"; import { claude } from "./claude.ts";
import { opentoad } from "./opentoad.ts"; import { opencode } from "./opencode.ts";
import type { Agent } from "./shared.ts"; import type { Agent } from "./shared.ts";
export type { Agent, AgentUsage } from "./shared.ts"; export type { Agent, AgentUsage } from "./shared.ts";
export const agents = { claude, opentoad } satisfies Record<string, Agent>; export const agents = { claude, opencode } satisfies Record<string, Agent>;
+4 -5
View File
@@ -1,10 +1,9 @@
/** /**
* OpenToad agent secure harness around OpenCode CLI. * OpenCode agent secure harness around OpenCode CLI.
* *
* transparently wraps OpenCode with a security layer: * transparently wraps OpenCode with a security layer:
* - bash: "deny" via OPENCODE_CONFIG_CONTENT (agent cannot shell out) * - bash: "deny" via OPENCODE_CONFIG_CONTENT (agent cannot shell out)
* - OPENCODE_PERMISSION: filesystem sandbox deny all external paths except /tmp * - OPENCODE_PERMISSION: filesystem sandbox deny all external paths except /tmp
* - untrusted .opencode/plugins/ and .opencode/tools/ deleted before launch
* - MCP ShellTool provides restricted shell (filtered env, no secrets) * - MCP ShellTool provides restricted shell (filtered env, no secrets)
* - MCP server injected alongside project config (not replacing) * - MCP server injected alongside project config (not replacing)
* - ASKPASS handles git auth separately (token never in subprocess env) * - ASKPASS handles git auth separately (token never in subprocess env)
@@ -607,8 +606,8 @@ async function runOpenCode(params: RunParams): Promise<AgentResult> {
// ── agent ─────────────────────────────────────────────────────────────────────── // ── agent ───────────────────────────────────────────────────────────────────────
export const opentoad = agent({ export const opencode = agent({
name: "opentoad", name: "opencode",
install: installOpencodeCli, install: installOpencodeCli,
run: async (ctx) => { run: async (ctx) => {
const cliPath = await installOpencodeCli(); const cliPath = await installOpencodeCli();
@@ -676,7 +675,7 @@ export const opentoad = agent({
log.info(`» dirty working tree (attempt ${attempt + 1}/${MAX_COMMIT_RETRIES}):\n${status}`); log.info(`» dirty working tree (attempt ${attempt + 1}/${MAX_COMMIT_RETRIES}):\n${status}`);
result = await runOpenCode({ result = await runOpenCode({
...runParams, ...runParams,
args: [...baseArgs, "--continue", buildCommitPrompt("opentoad", status)], args: [...baseArgs, "--continue", buildCommitPrompt("opencode", status)],
}); });
} }
+104
View File
@@ -0,0 +1,104 @@
import { basename } from "node:path";
import arg from "arg";
import pc from "picocolors";
import { runCli as runGhaCli } from "./commands/gha.ts";
import { runCli as runInitCli } from "./commands/init.ts";
const VERSION = process.env.CLI_VERSION ?? "0.0.0";
const bin = basename(process.argv[1] || "");
const PROG = bin === "pf" || bin === "pullfrog" ? bin : "pullfrog";
const rawArgs = process.argv.slice(2);
function printMainUsage(stream: typeof console.log): void {
stream(`usage: ${PROG} <command>\n`);
stream("commands:");
stream(" init set up pullfrog on the current repository");
stream("");
stream("global options:");
stream(" -h, --help show help");
stream(" -v, --version show version");
}
function parseGlobalArgs(args: string[]) {
return arg(
{
"--help": Boolean,
"--version": Boolean,
"-h": "--help",
"-v": "--version",
},
{
argv: args,
stopAtPositional: true,
}
);
}
function exitWithUsageError(message: string): never {
console.error(`${message}\n`);
printMainUsage(console.error);
process.exit(1);
}
async function run(): Promise<void> {
let globalParsed: ReturnType<typeof parseGlobalArgs>;
try {
globalParsed = parseGlobalArgs(rawArgs);
} catch (error) {
const message = error instanceof Error ? error.message : String(error);
exitWithUsageError(message);
}
if (globalParsed["--version"]) {
console.log(VERSION);
process.exit(0);
}
const command = globalParsed._[0];
const commandArgs = globalParsed._.slice(1);
if (!command) {
if (globalParsed["--help"]) {
console.log(`${pc.bold("pullfrog")} v${VERSION}\n`);
printMainUsage(console.log);
process.exit(0);
}
printMainUsage(console.log);
process.exit(0);
}
if (command === "init") {
await runInitCli({
args: commandArgs,
prog: PROG,
showHelp: globalParsed["--help"] === true,
});
return;
}
if (command === "gha") {
await runGhaCli({
args: commandArgs,
prog: PROG,
showHelp: globalParsed["--help"] === true,
});
return;
}
if (globalParsed["--help"]) {
printMainUsage(console.log);
process.exit(0);
}
console.error(`unknown command: ${pc.bold(command)}\n`);
printMainUsage(console.error);
process.exit(1);
}
try {
await run();
} catch (error) {
const message = error instanceof Error ? error.message : String(error);
console.error(pc.red(message));
process.exit(1);
}
+162
View File
@@ -0,0 +1,162 @@
import { dirname } from "node:path";
import * as core from "@actions/core";
import arg from "arg";
import { main } from "../main.ts";
import { log } from "../utils/cli.ts";
import { runPostCleanup } from "../utils/postCleanup.ts";
import { acquireInstallationToken, revokeInstallationToken } from "../utils/token.ts";
// GitHub Actions runs the action entry point with the node24 binary specified
// in action.yml, but doesn't add that binary's directory to PATH. Without this,
// spawned processes (pnpm, npm, etc.) resolve to the runner's default node (v20).
process.env.PATH = `${dirname(process.execPath)}:${process.env.PATH}`;
const STATE_TOKEN = "token";
interface GhaCliParams {
args: string[];
prog: string;
showHelp?: boolean;
}
async function runMain(): Promise<void> {
try {
const result = await main();
if (!result.success) {
throw new Error(result.error || "agent execution failed");
}
} catch (error) {
const errorMessage = error instanceof Error ? error.message : "unknown error occurred";
core.setFailed(`action failed: ${errorMessage}`);
}
}
async function runPost(): Promise<void> {
log.debug(`[post] script started at ${new Date().toISOString()}`);
try {
await runPostCleanup();
} catch (error) {
const message = error instanceof Error ? error.message : String(error);
log.error(`[post] unexpected error: ${message}`);
}
}
async function tokenMain(): Promise<void> {
const reposInput = core.getInput("repos");
const additionalRepos = reposInput
? reposInput
.split(",")
.map((r) => r.trim())
.filter(Boolean)
: [];
const token = await acquireInstallationToken({ repos: additionalRepos });
core.setSecret(token);
core.saveState(STATE_TOKEN, token);
core.setOutput("token", token);
const scope = additionalRepos.length
? `current repo + ${additionalRepos.join(", ")}`
: "current repo only";
core.info(`» installation token acquired (${scope})`);
}
async function tokenPost(): Promise<void> {
const token = core.getState(STATE_TOKEN);
if (!token) {
core.debug("no token found in state, skipping revocation");
return;
}
await revokeInstallationToken(token);
core.info("» installation token revoked");
}
function printGhaUsage(params: { stream: typeof console.log; prog: string }): void {
params.stream(`usage: ${params.prog} gha [token] [--post]\n`);
params.stream("run the github action runtime flow.");
params.stream("");
params.stream("subcommands:");
params.stream(" token acquire a github app installation token");
params.stream("");
params.stream("options:");
params.stream(" -h, --help show help");
params.stream(" --post run post-cleanup flow");
}
function parseGhaArgs(args: string[]) {
return arg(
{
"--help": Boolean,
"--post": Boolean,
"-h": "--help",
},
{
argv: args,
}
);
}
export async function runCli(params: GhaCliParams): Promise<void> {
if (params.showHelp) {
printGhaUsage({ stream: console.log, prog: params.prog });
return;
}
let parsed: ReturnType<typeof parseGhaArgs>;
try {
parsed = parseGhaArgs(params.args);
} catch (error) {
const message = error instanceof Error ? error.message : String(error);
console.error(`${message}\n`);
printGhaUsage({ stream: console.error, prog: params.prog });
process.exit(1);
}
if (parsed["--help"]) {
printGhaUsage({ stream: console.log, prog: params.prog });
return;
}
const normalizedArgs = ["gha"];
const positional = parsed._;
if (positional.length > 1) {
console.error(`unexpected positional arguments for gha: ${positional.slice(1).join(" ")}\n`);
printGhaUsage({ stream: console.error, prog: params.prog });
process.exit(1);
}
if (positional[0] === "token") {
normalizedArgs.push("token");
} else if (positional[0]) {
console.error(`unknown gha subcommand: ${positional[0]}\n`);
printGhaUsage({ stream: console.error, prog: params.prog });
process.exit(1);
}
if (parsed["--post"]) {
normalizedArgs.push("--post");
}
await run(normalizedArgs);
}
export async function run(args: string[]) {
try {
if (args.includes("token")) {
if (args.includes("--post")) {
await tokenPost();
} else {
await tokenMain();
}
} else if (args.includes("--post")) {
await runPost();
} else {
await runMain();
}
} catch (error) {
const message = error instanceof Error ? error.message : String(error);
core.setFailed(message);
}
}
+964
View File
@@ -0,0 +1,964 @@
import { execFileSync } from "node:child_process";
import * as p from "@clack/prompts";
import arg from "arg";
import pc from "picocolors";
import { modelAliases, type ProviderConfig, providers } from "../models.ts";
const PULLFROG_API_URL = (process.env.PULLFROG_API_URL || "https://pullfrog.com").replace(
/\/+$/,
""
);
function link(text: string, url: string): string {
return `\x1b]8;;${url}\x07${text}\x1b]8;;\x07`;
}
type CliProvider = {
id: string;
name: string;
envVars: readonly string[];
models: { value: string; label: string; hint?: string | undefined }[];
};
function buildProviders(): CliProvider[] {
return Object.entries(providers)
.filter(([key]) => key !== "opencode" && key !== "openrouter")
.map(([key, config]: [string, ProviderConfig]) => {
const aliases = modelAliases.filter((a) => a.provider === key);
const recommended = aliases.find((a) => a.preferred);
const sorted = [...aliases].sort((a, b) => {
if (a.preferred && !b.preferred) return -1;
if (!a.preferred && b.preferred) return 1;
return 0;
});
return {
id: key,
name: config.displayName,
envVars: config.envVars,
models: sorted.map((a) => ({
value: a.slug,
label: a.displayName,
hint: a === recommended ? "recommended" : undefined,
})),
};
});
}
const CLI_PROVIDERS = buildProviders();
function resolveModelProvider(slug: string): CliProvider | null {
const providerId = slug.split("/")[0];
return CLI_PROVIDERS.find((p) => p.id === providerId) ?? null;
}
// ── helpers ──
// active spinner reference so bail/catch can clean up the terminal
let activeSpin: ReturnType<typeof p.spinner> | null = null;
function bail(msg: string): never {
if (activeSpin) {
activeSpin.stop(pc.red("failed"));
activeSpin = null;
}
p.cancel(msg);
process.exit(1);
}
function handleCancel<T>(value: T | symbol): asserts value is T {
if (p.isCancel(value)) {
if (activeSpin) {
activeSpin.stop(pc.red("canceled."));
activeSpin = null;
}
p.cancel("canceled.");
process.exit(0);
}
}
function getGhToken(): string {
let token: string;
try {
token = execFileSync("gh", ["auth", "token"], { encoding: "utf-8" }).trim();
} catch {
bail(
`gh cli not found or not authenticated.\n` +
` ${pc.dim("install:")} https://cli.github.com\n` +
` ${pc.dim("then:")} gh auth login`
);
}
if (!token) {
bail(
`gh cli returned an empty token. try re-authenticating:\n` +
` ${pc.dim("run:")} gh auth login`
);
}
return token;
}
type GhApiResult<T = unknown> = { data: T; scopes: string | null };
async function ghApi<T = unknown>(path: string, token: string): Promise<GhApiResult<T>> {
const controller = new AbortController();
const timeout = setTimeout(() => controller.abort(), 30_000);
try {
const response = await fetch(`https://api.github.com${path}`, {
headers: {
authorization: `Bearer ${token}`,
accept: "application/vnd.github+json",
"x-github-api-version": "2022-11-28",
},
signal: controller.signal,
});
if (!response.ok) {
const body = await response.text().catch(() => "");
throw new Error(`github api ${path} returned ${response.status}: ${body}`);
}
const data = (await response.json().catch(() => {
throw new Error(`github api ${path} returned non-JSON response`);
})) as T;
return { data, scopes: response.headers.get("x-oauth-scopes") };
} finally {
clearTimeout(timeout);
}
}
function parseGitRemote(): { owner: string; repo: string } {
let url: string;
try {
url = execFileSync("git", ["remote", "get-url", "origin"], { encoding: "utf-8" }).trim();
} catch {
bail("not a git repository or no 'origin' remote found.");
}
const match = url.match(/github\.com(?::\d+)?[:/]+([^/]+)\/(.+?)(?:\.git)?(?:\/)?$/);
if (!match) bail(`could not parse github owner/repo from remote: ${url}`);
return { owner: match[1], repo: match[2] };
}
function openBrowser(url: string) {
try {
const platform = process.platform;
if (platform === "darwin") execFileSync("open", [url], { stdio: "ignore" });
else if (platform === "win32")
execFileSync("cmd", ["/c", "start", "", url], { stdio: "ignore" });
else execFileSync("xdg-open", [url], { stdio: "ignore" });
} catch {
// headless/SSH — user will open the URL manually
}
}
// ── Pullfrog API ──
type SecretsApiData = {
error?: string;
appSlug?: string;
installationId?: number | null;
repositorySelection?: string | null;
isOrg?: boolean;
accessible?: boolean;
repoSecrets?: string[];
orgSecrets?: string[];
pullfrogSecrets?: string[];
repoStatus?: string | null;
repoModel?: string | null;
hasRuns?: boolean;
};
type SecretsInfo = {
isOrg: boolean;
installationId: number | null;
secretsAccessible: boolean;
repoSecrets: string[];
orgSecrets: string[];
pullfrogSecrets: string[];
model: string | null;
hasRuns: boolean;
};
type InstallationNotFound = {
appSlug: string;
installationId: number | null;
repositorySelection: "all" | "selected" | null;
isOrg: boolean;
};
type StatusResult =
| ({ installed: true } & SecretsInfo)
| ({ installed: false } & InstallationNotFound);
type SessionApiData = {
id?: string;
installed?: boolean;
error?: string;
};
type SetupApiData = {
error?: string;
success?: boolean;
already_existed?: boolean;
pull_request_url?: string;
commit_url?: string;
hash?: string;
};
type DispatchApiData = {
error?: string;
url?: string;
};
type ApiResult<T = Record<string, unknown>> = { ok: boolean; status: number; data: T };
async function pullfrogApi<T = Record<string, unknown>>(ctx: {
path: string;
token: string;
method?: string;
body?: Record<string, unknown>;
}): Promise<ApiResult<T>> {
const headers: Record<string, string> = { authorization: `Bearer ${ctx.token}` };
if (ctx.body) headers["content-type"] = "application/json";
const controller = new AbortController();
const timeout = setTimeout(() => controller.abort(), 30_000);
try {
const response = await fetch(`${PULLFROG_API_URL}${ctx.path}`, {
method: ctx.method || "GET",
headers,
body: ctx.body ? JSON.stringify(ctx.body) : null,
signal: controller.signal,
});
const data = (await response.json().catch(() => ({}))) as T;
return { ok: response.ok, status: response.status, data };
} finally {
clearTimeout(timeout);
}
}
async function fetchStatus(ctx: {
token: string;
owner: string;
repo: string;
}): Promise<StatusResult> {
const result = await pullfrogApi<SecretsApiData>({
path: `/api/cli/secrets?owner=${encodeURIComponent(ctx.owner)}&repo=${encodeURIComponent(ctx.repo)}`,
token: ctx.token,
});
if (!result.ok) {
const errorMsg = result.data.error || "";
if (result.status === 401) bail("invalid or expired github token.");
if (result.status === 404) {
const sel = result.data.repositorySelection;
if (!result.data.appSlug) bail("server did not return appSlug");
return {
installed: false,
appSlug: result.data.appSlug,
installationId:
typeof result.data.installationId === "number" ? result.data.installationId : null,
repositorySelection: sel === "all" || sel === "selected" ? sel : null,
isOrg: result.data.isOrg === true,
};
}
bail(errorMsg || `secrets check failed (${result.status})`);
}
return {
installed: true,
isOrg: result.data.isOrg === true,
installationId:
typeof result.data.installationId === "number" ? result.data.installationId : null,
secretsAccessible: result.data.accessible !== false,
repoSecrets: result.data.repoSecrets || [],
orgSecrets: result.data.orgSecrets || [],
pullfrogSecrets: result.data.pullfrogSecrets || [],
model: result.data.repoModel ?? null,
hasRuns: result.data.hasRuns === true,
};
}
// ── sessions ──
async function createSession(ctx: {
token: string;
owner: string;
repo: string;
}): Promise<string | null> {
try {
const result = await pullfrogApi<SessionApiData>({
path: "/api/cli/session",
token: ctx.token,
method: "POST",
body: { owner: ctx.owner.toLowerCase(), repo: ctx.repo.toLowerCase() },
});
if (!result.ok || !result.data.id) return null;
return result.data.id;
} catch {
return null;
}
}
type PollResult = "installed" | "pending" | "expired";
async function pollSession(ctx: { token: string; sessionId: string }): Promise<PollResult> {
const result = await pullfrogApi<SessionApiData>({
path: `/api/cli/session/${ctx.sessionId}`,
token: ctx.token,
});
if (result.status === 410) return "expired";
if (!result.ok) return "pending";
return result.data.installed === true ? "installed" : "pending";
}
function cleanupSession(ctx: { token: string; sessionId: string }) {
void pullfrogApi({
path: `/api/cli/session/${ctx.sessionId}`,
token: ctx.token,
method: "DELETE",
}).catch(() => {});
}
// ── installation ──
const SESSION_POLL_MS = 750;
const FALLBACK_POLL_MS = 5_000;
const HINT_AFTER_MS = 10_000;
const TIMEOUT_MS = 3 * 60 * 1000;
function listenForKey(key: string) {
let triggered = false;
const onData = (data: Buffer) => {
if (data.toString().toLowerCase() === key) triggered = true;
};
process.stdin.setRawMode?.(true);
process.stdin.resume();
process.stdin.on("data", onData);
return {
consume() {
if (!triggered) return false;
triggered = false;
return true;
},
stop() {
process.stdin.removeListener("data", onData);
process.stdin.setRawMode?.(false);
process.stdin.pause();
},
};
}
function installationConfigUrl(ctx: { owner: string; installationId: number; isOrg: boolean }) {
return ctx.isOrg
? `https://github.com/organizations/${ctx.owner}/settings/installations/${ctx.installationId}`
: `https://github.com/settings/installations/${ctx.installationId}`;
}
async function ensureInstallation(ctx: {
token: string;
owner: string;
repo: string;
}): Promise<SecretsInfo> {
activeSpin!.start("checking pullfrog app installation");
const initial = await fetchStatus(ctx);
if (initial.installed) {
activeSpin!.stop(`pullfrog app is installed on ${pc.cyan(`@${ctx.owner}`)}`);
if (initial.installationId) {
const configUrl = installationConfigUrl({
owner: ctx.owner,
installationId: initial.installationId,
isOrg: initial.isOrg,
});
process.stdout.write(`${pc.gray(p.S_BAR)} ${link(pc.dim(configUrl), configUrl)}\n`);
}
return initial;
}
const sessionId = await createSession(ctx);
if (initial.installationId) {
const repoRef = pc.bold(`${ctx.owner}/${ctx.repo}`);
const configUrl = installationConfigUrl({
owner: ctx.owner,
installationId: initial.installationId,
isOrg: initial.isOrg,
});
activeSpin!.stop(`pullfrog is installed on selected repos, but ${repoRef} is not included.`);
p.log.info(
`add it under "Repository access" on the installation config page.\n ${pc.dim(configUrl)}`
);
const openIt = await p.confirm({ message: "open browser?", active: "yes", inactive: "no" });
handleCancel(openIt);
if (openIt) openBrowser(configUrl);
} else {
activeSpin!.stop("pullfrog app not installed");
const installUrl = `https://github.com/apps/${initial.appSlug}/installations/select_target?state=cli`;
p.log.info(`opening browser to install...\n ${pc.dim(installUrl)}`);
openBrowser(installUrl);
}
const isRepoAccessUpdate = !!initial.installationId;
const baseMsg = isRepoAccessUpdate
? "once you've added the repo, onboarding will proceed automatically"
: "once you've installed the app, onboarding will proceed automatically";
activeSpin!.start(baseMsg);
let activeSessionId = sessionId;
let pollMs = activeSessionId ? SESSION_POLL_MS : FALLBACK_POLL_MS;
const listener = listenForKey("r");
const startedAt = Date.now();
let hintShown = false;
try {
while (Date.now() - startedAt < TIMEOUT_MS) {
await new Promise((r) => setTimeout(r, pollMs));
if (!hintShown && Date.now() - startedAt > HINT_AFTER_MS) {
activeSpin!.message(`${baseMsg} ${pc.dim("(press r to recheck manually)")}`);
hintShown = true;
}
const doneMsg = isRepoAccessUpdate ? "repo access confirmed" : "pullfrog app installed";
if (listener.consume()) {
activeSpin!.message("rechecking via GitHub API");
try {
const status = await fetchStatus(ctx);
if (status.installed) {
if (activeSessionId) cleanupSession({ token: ctx.token, sessionId: activeSessionId });
activeSpin!.stop(doneMsg);
return status;
}
} catch {
// network error — keep going
}
activeSpin!.message(`${baseMsg} ${pc.dim("(press r to recheck manually)")}`);
continue;
}
if (activeSessionId) {
// fast path: lightweight DB session poll (no GitHub API calls)
try {
const result = await pollSession({ token: ctx.token, sessionId: activeSessionId });
if (result === "expired") {
activeSessionId = null;
pollMs = FALLBACK_POLL_MS;
continue;
}
if (result === "installed") {
const status = await fetchStatus(ctx);
if (status.installed) {
cleanupSession({ token: ctx.token, sessionId: activeSessionId });
activeSpin!.stop(doneMsg);
return status;
}
}
} catch {
// transient error — keep polling
}
} else {
// no session available — poll fetchStatus directly at slower interval
try {
const status = await fetchStatus(ctx);
if (status.installed) {
activeSpin!.stop(doneMsg);
return status;
}
} catch {
// transient error — keep polling
}
}
}
} finally {
listener.stop();
}
if (activeSessionId) cleanupSession({ token: ctx.token, sessionId: activeSessionId });
bail(
isRepoAccessUpdate
? "timed out waiting for repo access.\n" +
` ${pc.dim("add the repo, then re-run:")} npx pullfrog init`
: "timed out waiting for app installation.\n" +
` ${pc.dim("if your org requires admin approval, ask an admin to approve,")}\n` +
` ${pc.dim("then re-run:")} npx pullfrog init`
);
}
// ── secret management ──
type StorageMethod = "pullfrog" | "github";
type SecretScope = "account" | "repo";
type SecretSetResult = { saved: boolean; orgFailed: boolean };
function setGhSecret(ctx: {
name: string;
value: string;
org: string | null;
repoSlug: string;
}): SecretSetResult {
let orgFailed = false;
if (ctx.org) {
try {
execFileSync("gh", ["secret", "set", ctx.name, "--org", ctx.org, "--visibility", "all"], {
input: ctx.value,
stdio: ["pipe", "ignore", "pipe"],
encoding: "utf-8",
});
return { saved: true, orgFailed: false };
} catch {
orgFailed = true;
}
}
try {
execFileSync("gh", ["secret", "set", ctx.name, "--repo", ctx.repoSlug], {
input: ctx.value,
stdio: ["pipe", "ignore", "pipe"],
encoding: "utf-8",
});
return { saved: true, orgFailed };
} catch {
return { saved: false, orgFailed };
}
}
type PullfrogSecretResult = { saved: boolean; error: string };
async function setPullfrogSecret(ctx: {
token: string;
owner: string;
repo: string;
name: string;
value: string;
scope: SecretScope;
}): Promise<PullfrogSecretResult> {
const result = await pullfrogApi<{ success?: boolean; error?: string }>({
path: "/api/cli/secrets",
token: ctx.token,
method: "POST",
body: {
owner: ctx.owner,
repo: ctx.repo,
name: ctx.name,
value: ctx.value,
scope: ctx.scope,
},
});
if (result.ok && result.data.success === true) {
return { saved: true, error: "" };
}
return { saved: false, error: result.data.error || `api returned ${result.status}` };
}
async function promptScope(ctx: { owner: string; repo: string }): Promise<SecretScope> {
const scope = await p.select<SecretScope>({
message: "secret scope",
options: [
{ value: "account", label: `${ctx.owner} organization`, hint: "shared across repos" },
{ value: "repo", label: `${ctx.owner}/${ctx.repo} only` },
],
});
handleCancel(scope);
return scope;
}
async function handleSecret(ctx: {
token: string;
owner: string;
repo: string;
provider: CliProvider;
secrets: SecretsInfo;
}): Promise<void> {
const repoSecretsUrl = `https://github.com/${ctx.owner}/${ctx.repo}/settings/secrets/actions`;
const matches: { name: string; source: string }[] = [];
for (const v of ctx.provider.envVars) {
if (ctx.secrets.pullfrogSecrets.includes(v)) matches.push({ name: v, source: "pullfrog" });
else if (ctx.secrets.secretsAccessible && ctx.secrets.orgSecrets.includes(v))
matches.push({ name: v, source: "org secret" });
else if (ctx.secrets.secretsAccessible && ctx.secrets.repoSecrets.includes(v))
matches.push({ name: v, source: "repo secret" });
}
if (matches.length > 0) {
activeSpin!.start("");
activeSpin!.stop("secrets already configured");
for (const m of matches) {
process.stdout.write(
`${pc.gray(p.S_BAR)} ${pc.cyan(m.name)} ${pc.dim(`(${m.source})`)}\n`
);
}
return;
}
if (!ctx.secrets.secretsAccessible) {
p.log.info(`could not verify GitHub secrets (app lacks permission)`);
}
const hasOAuthOption = ctx.provider.envVars.includes("CLAUDE_CODE_OAUTH_TOKEN");
let envVar = ctx.provider.envVars[0];
if (hasOAuthOption) {
const authMethod = await p.select({
message: "which credential do you want to use?",
options: [
{
value: "oauth",
label: "Claude Code OAuth token",
hint: `run ${pc.cyan("claude setup-token")} — works with Pro/Max subscriptions`,
},
{
value: "api",
label: "Anthropic API key",
hint: "from console.anthropic.com",
},
],
});
handleCancel(authMethod);
if (authMethod === "oauth") envVar = "CLAUDE_CODE_OAUTH_TOKEN";
}
const method = await p.select<StorageMethod>({
message: `where should ${pc.cyan(envVar)} be stored?`,
options: [
{
value: "pullfrog",
label: "Pullfrog",
hint: "recommended — auto-injected, no workflow changes",
},
{
value: "github",
label: "GitHub Actions secret",
hint: "requires env block in pullfrog.yml",
},
],
});
handleCancel(method);
const pasteLabel =
envVar === "CLAUDE_CODE_OAUTH_TOKEN" ? "OAuth token" : `${ctx.provider.name} API key`;
const apiKey = await p.password({
message: `paste your ${pasteLabel} ${pc.dim("(Enter to skip)")}`,
mask: "*",
validate: () => undefined,
});
handleCancel(apiKey);
if (!apiKey) {
p.log.info(
`skipped — set it manually at:\n ${pc.dim(method === "pullfrog" ? `${PULLFROG_API_URL}/console/${ctx.owner}` : repoSecretsUrl)}`
);
return;
}
if (method === "pullfrog") {
const scope: SecretScope = ctx.secrets.isOrg ? await promptScope(ctx) : "account";
activeSpin!.start(`saving ${envVar}`);
let saveResult: PullfrogSecretResult;
try {
saveResult = await setPullfrogSecret({
token: ctx.token,
owner: ctx.owner,
repo: ctx.repo,
name: envVar,
value: apiKey,
scope,
});
} catch (error) {
activeSpin!.stop(pc.red("could not save secret"));
p.log.warn(
`${error instanceof Error ? error.message : "network error"}\n set it manually at: ${pc.dim(`${PULLFROG_API_URL}/console/${ctx.owner}`)}`
);
return;
}
if (saveResult.saved) {
activeSpin!.stop(`saved ${pc.cyan(envVar)} to Pullfrog`);
} else {
activeSpin!.stop(pc.red("could not save secret"));
p.log.warn(
`${saveResult.error}\n set it manually at: ${pc.dim(`${PULLFROG_API_URL}/console/${ctx.owner}`)}`
);
}
return;
}
// github actions secret path
let org: string | null = null;
if (ctx.secrets.isOrg) {
const scope = await promptScope(ctx);
org = scope === "account" ? ctx.owner : null;
}
const secretsUrl = org
? `https://github.com/organizations/${org}/settings/secrets/actions`
: repoSecretsUrl;
activeSpin!.start(`saving ${envVar}`);
const secretResult = setGhSecret({
name: envVar,
value: apiKey,
org,
repoSlug: `${ctx.owner}/${ctx.repo}`,
});
if (secretResult.saved) {
activeSpin!.stop(
`saved ${pc.cyan(envVar)} to ${org && !secretResult.orgFailed ? `${pc.dim(ctx.owner)} org secret` : "GitHub Actions secret"}`
);
if (secretResult.orgFailed) {
p.log.warn("org secret failed (admin access required) — saved as repo secret instead");
}
} else {
activeSpin!.stop(pc.red("could not set secret"));
p.log.warn(`set it manually at:\n ${pc.dim(secretsUrl)}`);
}
}
async function promptTestRun(ctx: { token: string; owner: string; repo: string }): Promise<void> {
const proceed = await p.select({
message: "test your installation?",
options: [
{ value: true, label: "yes", hint: "dispatches a test run in your GitHub Actions" },
{ value: false, label: "skip" },
],
});
handleCancel(proceed);
if (!proceed) return;
activeSpin!.start("dispatching test run");
const result = await pullfrogApi<DispatchApiData>({
path: "/api/cli/dispatch",
token: ctx.token,
method: "POST",
body: { owner: ctx.owner, repo: ctx.repo, prompt: "Tell me a joke" },
});
if (!result.ok) {
activeSpin!.stop(pc.red("could not dispatch"));
p.log.warn(result.data.error || `dispatch failed (${result.status})`);
return;
}
activeSpin!.stop("dispatched test run");
if (result.data.url) {
process.stdout.write(
`${pc.gray(p.S_BAR)} ${link(pc.dim(result.data.url), result.data.url)}\n`
);
openBrowser(result.data.url);
}
}
// ── main ──
async function main() {
p.intro(pc.bgGreen(pc.black(" pullfrog ")));
const spin = p.spinner();
activeSpin = spin;
// 1. authenticate
spin.start("authenticating with github");
const token = getGhToken();
const userResult = await ghApi<{ login: string }>("/user", token);
const user = userResult.data;
// gho_ tokens from `gh auth login` expose scopes via x-oauth-scopes header.
// fine-grained PATs (github_pat_) don't return scopes — they pass this check.
// split on ", " and match exact scope — .includes("repo") would false-positive on "public_repo"
const scopeSet = userResult.scopes !== null ? new Set(userResult.scopes.split(", ")) : null;
if (scopeSet !== null && !scopeSet.has("repo")) {
bail(
`your token is missing the ${pc.bold('"repo"')} scope.\n` +
` ${pc.dim("run:")} gh auth refresh --scopes repo\n` +
` ${pc.dim("then:")} npx pullfrog init`
);
}
spin.stop(`hello, ${pc.cyan(`@${user.login}`)}`);
// 2. detect repo
spin.start("detecting repository");
const remote = parseGitRemote();
spin.stop(`detected repo ${pc.cyan(`${remote.owner}/${remote.repo}`)}`);
// 3. ensure app installation + check secrets
const secrets = await ensureInstallation({ token, owner: remote.owner, repo: remote.repo });
// 4. select provider + model (skip if already set)
let model: string;
let provider: CliProvider;
if (secrets.model) {
model = secrets.model;
const resolved = resolveModelProvider(secrets.model);
if (!resolved) bail(`unknown model provider: ${secrets.model}`);
provider = resolved;
spin.start("");
spin.stop(`using model ${pc.cyan(secrets.model)}`);
} else {
const providerId = await p.select({
message: "select your preferred model provider",
options: CLI_PROVIDERS.map((cp) => ({
value: cp.id,
label: cp.name,
})),
});
handleCancel(providerId);
const found = CLI_PROVIDERS.find((cp) => cp.id === providerId);
if (!found) bail(`unknown provider: ${providerId}`);
provider = found;
if (provider.models.length === 1) {
model = provider.models[0].value;
spin.start("");
spin.stop(`using ${pc.bold(provider.models[0].label)}`);
} else {
const recommendedModel = provider.models.find((m) => m.hint === "recommended");
const options = provider.models.map((m) => {
if (m.hint) return { value: m.value, label: m.label, hint: m.hint };
return { value: m.value, label: m.label };
});
const selected = await p.select(
recommendedModel
? { message: "select model", initialValue: recommendedModel.value, options }
: { message: "select model", options }
);
handleCancel(selected);
model = selected;
}
}
// 5. check/set secret
await handleSecret({ token, owner: remote.owner, repo: remote.repo, provider, secrets });
// 6. create workflow
spin.start("creating pullfrog.yml workflow");
const result = await pullfrogApi<SetupApiData>({
path: "/api/cli/setup",
token,
method: "POST",
body: { owner: remote.owner, repo: remote.repo, model },
});
if (!result.ok) {
bail(result.data.error || `api returned ${result.status}`);
}
let skipTestRun = false;
if (result.data.already_existed) {
spin.stop("pullfrog.yml already exists");
} else if (result.data.pull_request_url) {
spin.stop("opened pull request with pullfrog.yml");
process.stdout.write(
`${pc.gray(p.S_BAR)} ${link(pc.dim(result.data.pull_request_url), result.data.pull_request_url)}\n`
);
openBrowser(result.data.pull_request_url);
const merged = await p.select({
message: "merge the PR to activate pullfrog, then continue",
options: [
{ value: true, label: "continue", hint: "PR has been merged" },
{ value: false, label: "skip" },
],
});
handleCancel(merged);
if (!merged) skipTestRun = true;
} else {
const short = result.data.hash?.slice(0, 7);
spin.stop(
short ? `committed pullfrog.yml to repo ${pc.dim(short)}` : "committed pullfrog.yml to repo"
);
}
if (!skipTestRun && !secrets.hasRuns) {
await promptTestRun({ token, owner: remote.owner, repo: remote.repo });
}
const consoleUrl = `${PULLFROG_API_URL}/console/${remote.owner}/${remote.repo}`;
spin.start("");
spin.stop("repo is configurable via the Pullfrog dashboard");
process.stdout.write(`${pc.gray(p.S_BAR)} ${link(pc.dim(consoleUrl), consoleUrl)}\n`);
activeSpin = null;
p.outro("done.");
}
interface InitCliParams {
args: string[];
prog: string;
showHelp?: boolean;
}
function printInitUsage(params: { stream: typeof console.log; prog: string }): void {
params.stream(`usage: ${params.prog} init\n`);
params.stream("set up pullfrog on the current repository.");
params.stream("");
params.stream("options:");
params.stream(" -h, --help show help");
}
function parseInitArgs(args: string[]) {
return arg(
{
"--help": Boolean,
"-h": "--help",
},
{
argv: args,
}
);
}
export async function runCli(params: InitCliParams): Promise<void> {
if (params.showHelp) {
printInitUsage({ stream: console.log, prog: params.prog });
return;
}
let parsed: ReturnType<typeof parseInitArgs>;
try {
parsed = parseInitArgs(params.args);
} catch (error) {
const message = error instanceof Error ? error.message : String(error);
console.error(`${message}\n`);
printInitUsage({ stream: console.error, prog: params.prog });
process.exit(1);
}
if (parsed["--help"]) {
printInitUsage({ stream: console.log, prog: params.prog });
return;
}
if (parsed._.length > 0) {
console.error(`unexpected positional arguments for init: ${parsed._.join(" ")}\n`);
printInitUsage({ stream: console.error, prog: params.prog });
process.exit(1);
}
await run();
}
export async function run() {
try {
await main();
} catch (error) {
if (activeSpin) {
activeSpin.stop(pc.red("failed"));
activeSpin = null;
}
const msg =
error instanceof Error && error.name === "AbortError"
? "request timed out — check your network connection and try again"
: error instanceof Error
? error.message
: String(error);
p.log.error(msg);
process.exit(1);
}
}
-151699
View File
File diff suppressed because one or more lines are too long
+4 -26
View File
@@ -1,29 +1,7 @@
#!/usr/bin/env node #!/usr/bin/env node
/** import { runPullfrogCli } from "./runCli.ts";
* entry point for pullfrog/pullfrog - unified action
*/
import { dirname } from "node:path"; runPullfrogCli({
import * as core from "@actions/core"; cliArgs: ["gha"],
import { main } from "./main.ts"; });
// GitHub Actions runs the action entry point with the node24 binary specified
// in action.yml, but doesn't add that binary's directory to PATH. Without this,
// spawned processes (pnpm, npm, etc.) resolve to the runner's default node (v20).
process.env.PATH = `${dirname(process.execPath)}:${process.env.PATH}`;
async function run(): Promise<void> {
try {
const result = await main();
if (!result.success) {
throw new Error(result.error || "Agent execution failed");
}
} catch (error) {
const errorMessage = error instanceof Error ? error.message : "Unknown error occurred";
core.setFailed(`Action failed: ${errorMessage}`);
}
}
await run();
+30 -21
View File
@@ -1,9 +1,12 @@
// @ts-check // @ts-check
import { build } from "esbuild"; import { build } from "esbuild";
import { readFileSync, writeFileSync } from "fs"; import { mkdirSync, readFileSync, rmSync, writeFileSync } from "fs";
const isMainOnlyBuild = process.argv.includes("--main-only"); const pkg = JSON.parse(readFileSync("package.json", "utf-8"));
rmSync("./dist", { recursive: true, force: true });
mkdirSync("./dist", { recursive: true });
// Plugin to strip shebangs from output files // Plugin to strip shebangs from output files
/** /**
@@ -61,30 +64,36 @@ const sharedConfig = {
drop: [], drop: [],
}; };
// Build the main entry bundle // Build the CLI bundle (published to npm, used by npx)
await build({ await build({
...sharedConfig, ...sharedConfig,
entryPoints: ["./entry.ts"], entryPoints: ["./cli.ts"],
outfile: "./entry", outfile: "./dist/cli.mjs",
target: "node20",
plugins: [stripShebangPlugin], plugins: [stripShebangPlugin],
define: {
"process.env.CLI_VERSION": JSON.stringify(pkg.version),
},
}); });
if (!isMainOnlyBuild) { // Build ESM library entrypoints for programmatic imports
// Build the post cleanup entry bundle await build({
await build({ ...sharedConfig,
...sharedConfig, entryPoints: ["./index.ts"],
entryPoints: ["./post.ts"], outfile: "./dist/index.js",
outfile: "./post", target: "node20",
plugins: [stripShebangPlugin], });
});
// Build the get-installation-token action await build({
await build({ ...sharedConfig,
...sharedConfig, entryPoints: ["./internal/index.ts"],
entryPoints: ["./get-installation-token/entry.ts"], outfile: "./dist/internal.js",
outfile: "./get-installation-token/entry", target: "node20",
plugins: [stripShebangPlugin], });
})
} // prepend shebang after strip (esbuild banner can't guarantee line 1 placement)
const cliPath = "./dist/cli.mjs";
const cliContent = readFileSync(cliPath, "utf8");
writeFileSync(cliPath, `#!/usr/bin/env node\n${cliContent}`);
console.log("» build completed successfully"); console.log("» build completed successfully");
+2 -2
View File
@@ -8,7 +8,7 @@
export const pullfrogMcpName = "pullfrog"; export const pullfrogMcpName = "pullfrog";
/** @see {@link file://./agents/shared.ts} Agent interface that uses this type */ /** @see {@link file://./agents/shared.ts} Agent interface that uses this type */
export type AgentId = "claude" | "opentoad"; export type AgentId = "claude" | "opencode";
/** /**
* format a tool name the way each agent's MCP client presents it to the model. * format a tool name the way each agent's MCP client presents it to the model.
@@ -19,7 +19,7 @@ export function formatMcpToolRef(agentId: AgentId, toolName: string): string {
switch (agentId) { switch (agentId) {
case "claude": case "claude":
return `mcp__${pullfrogMcpName}__${toolName}`; return `mcp__${pullfrogMcpName}__${toolName}`;
case "opentoad": case "opencode":
return `${pullfrogMcpName}_${toolName}`; return `${pullfrogMcpName}_${toolName}`;
default: default:
return agentId satisfies never; return agentId satisfies never;
+2 -2
View File
@@ -13,8 +13,8 @@ outputs:
runs: runs:
using: "node24" using: "node24"
main: "entry" main: "entry.ts"
post: "entry" post: "post.ts"
branding: branding:
icon: "key" icon: "key"
File diff suppressed because one or more lines are too long
+4 -68
View File
@@ -1,69 +1,5 @@
#!/usr/bin/env node import { runPullfrogCli } from "../runCli.ts";
/** runPullfrogCli({
* entry point for get-installation-token action. cliArgs: ["gha", "token"],
* handles both main and post execution using the isPost state pattern. });
*/
import * as core from "@actions/core";
import { acquireInstallationToken, revokeInstallationToken } from "../utils/token.ts";
const STATE_TOKEN = "token";
const STATE_IS_POST = "isPost";
async function main(): Promise<void> {
core.saveState(STATE_IS_POST, "true");
const reposInput = core.getInput("repos");
const additionalRepos = reposInput
? reposInput
.split(",")
.map((r) => r.trim())
.filter(Boolean)
: [];
const token = await acquireInstallationToken({ repos: additionalRepos });
// mask the token in logs
core.setSecret(token);
// save token to state for post cleanup
core.saveState(STATE_TOKEN, token);
// set as output
core.setOutput("token", token);
const scope = additionalRepos.length
? `current repo + ${additionalRepos.join(", ")}`
: "current repo only";
core.info(`» installation token acquired (${scope})`);
}
async function post(): Promise<void> {
const token = core.getState(STATE_TOKEN);
if (!token) {
core.debug("no token found in state, skipping revocation");
return;
}
await revokeInstallationToken(token);
core.info("» installation token revoked");
}
async function run(): Promise<void> {
try {
const isPost = core.getState(STATE_IS_POST) === "true";
if (isPost) {
await post();
} else {
await main();
}
} catch (error) {
const message = error instanceof Error ? error.message : String(error);
core.setFailed(message);
}
}
await run();
+6
View File
@@ -0,0 +1,6 @@
import { runPullfrogCli } from "../runCli.ts";
runPullfrogCli({
cliArgs: ["gha", "token", "--post"],
swallowErrors: true,
});
+36 -2
View File
@@ -1,7 +1,10 @@
// changes to tool permissions should be reflected in wiki/granular-tools.md // changes to tool permissions should be reflected in wiki/granular-tools.md
import { existsSync, readdirSync } from "node:fs";
import { join } from "node:path";
import * as core from "@actions/core"; import * as core from "@actions/core";
import { deleteProgressComment, reportProgress } from "./mcp/comment.ts"; import { deleteProgressComment, reportProgress } from "./mcp/comment.ts";
import { startInstallation } from "./mcp/dependencies.ts";
import { import {
initToolState, initToolState,
startMcpHttpServer, startMcpHttpServer,
@@ -307,6 +310,8 @@ export async function main(): Promise<MainResult> {
log.info(`» MCP server started at ${mcpHttpServer.url}`); log.info(`» MCP server started at ${mcpHttpServer.url}`);
timer.checkpoint("mcpServer"); timer.checkpoint("mcpServer");
startInstallation(toolContext);
if (payload.model) log.info(`» model: ${payload.model}`); if (payload.model) log.info(`» model: ${payload.model}`);
if (payload.timeout) log.info(`» timeout: ${payload.timeout}`); if (payload.timeout) log.info(`» timeout: ${payload.timeout}`);
log.info(`» push: ${payload.push}`); log.info(`» push: ${payload.push}`);
@@ -334,6 +339,21 @@ export async function main(): Promise<MainResult> {
log.info(instructions.full); log.info(instructions.full);
}); });
// OpenCode loads .opencode/plugin/ files at startup. if the repo has any,
// eagerly await dependency installation so plugin imports can resolve.
if (agentId === "opencode") {
const pluginDir = join(process.cwd(), ".opencode", "plugin");
const hasPlugins =
existsSync(pluginDir) && readdirSync(pluginDir).some((f) => /\.[jt]sx?$/.test(f));
if (hasPlugins && toolState.dependencyInstallation?.promise) {
log.info(
"» .opencode/plugin/ detected — awaiting dependency installation before agent start"
);
await toolState.dependencyInstallation.promise.catch(() => {});
timer.checkpoint("awaitDepsForPlugins");
}
}
// run agent, optionally with timeout enforcement // run agent, optionally with timeout enforcement
activityTimeout = createProcessOutputActivityTimeout({ activityTimeout = createProcessOutputActivityTimeout({
timeoutMs: DEFAULT_ACTIVITY_TIMEOUT_MS, timeoutMs: DEFAULT_ACTIVITY_TIMEOUT_MS,
@@ -407,6 +427,17 @@ export async function main(): Promise<MainResult> {
}); });
} }
// review submitted → always delete the progress comment.
// the review is the durable artifact; the progress comment is noise.
// defense-in-depth: covers the case where the agent calls report_progress
// despite mode instructions, which sets finalSummaryWritten and prevents
// the stranded-comment heuristic below from firing.
if (toolContext && toolState.review && toolState.progressCommentId) {
await deleteProgressComment(toolContext).catch((error) => {
log.debug(`review progress comment cleanup failed: ${error}`);
});
}
// clean up stranded progress comments. two cases: // clean up stranded progress comments. two cases:
// 1. wasUpdated=false: nothing wrote to the comment ("Leaping into action" orphan) // 1. wasUpdated=false: nothing wrote to the comment ("Leaping into action" orphan)
// 2. tracker published a checklist but the agent never wrote a final summary // 2. tracker published a checklist but the agent never wrote a final summary
@@ -446,9 +477,12 @@ export async function main(): Promise<MainResult> {
killTrackedChildren(); killTrackedChildren();
log.error(errorMessage); log.error(errorMessage);
// best-effort summary — don't mask the original error // best-effort summary — write the error so it's visible in the Actions summary tab
try { try {
await writeJobSummary(toolState); const errorSummary = `### ❌ Pullfrog failed\n\n\`\`\`\n${errorMessage}\n\`\`\``;
const usageSummary = formatUsageSummary(toolState.usageEntries);
const parts = [errorSummary, toolState.lastProgressBody, usageSummary].filter(Boolean);
await writeSummary(parts.join("\n\n"));
} catch {} } catch {}
try { try {
+12 -8
View File
@@ -1,7 +1,8 @@
import { Octokit } from "@octokit/rest"; import type { RestEndpointMethodTypes } from "@octokit/rest";
import { describe, expect, it } from "vitest"; import { describe, expect, it } from "vitest";
import { acquireNewToken } from "../utils/github.ts"; import { acquireNewToken, createOctokit } from "../utils/github.ts";
import { fetchAndFormatPrDiff } from "./checkout.ts"; import { fetchAndFormatPrDiff } from "./checkout.ts";
import type { ToolContext } from "./server.ts";
/** /**
* parses TOC entries like "- src/math.ts → lines 7-42" into structured data. * parses TOC entries like "- src/math.ts → lines 7-42" into structured data.
@@ -33,13 +34,16 @@ describe("fetchAndFormatPrDiff", () => {
{ timeout: 30000 }, { timeout: 30000 },
async () => { async () => {
const token = await getToken(); const token = await getToken();
const octokit = new Octokit({ auth: token }); const octokit = createOctokit(token);
const result = await fetchAndFormatPrDiff({ const ctx = {
octokit, octokit,
owner: "pullfrog", repo: {
repo: "test-repo", owner: "pullfrog",
pullNumber: 1, name: "test-repo",
}); data: {} as RestEndpointMethodTypes["repos"]["get"]["response"]["data"],
},
} as ToolContext;
const result = await fetchAndFormatPrDiff(ctx, 1);
// verify content includes TOC at the start // verify content includes TOC at the start
expect(result.content.startsWith(result.toc)).toBe(true); expect(result.content.startsWith(result.toc)).toBe(true);
+9 -18
View File
@@ -145,22 +145,18 @@ export type CheckoutPrResult = {
instructions: string; instructions: string;
}; };
type FetchPrDiffParams = {
octokit: Octokit;
owner: string;
repo: string;
pullNumber: number;
};
/** /**
* fetches PR files from GitHub and formats them with line numbers and TOC. * fetches PR files from GitHub and formats them with line numbers and TOC.
* this is the core diff formatting logic, extracted for testability. * this is the core diff formatting logic, extracted for testability.
*/ */
export async function fetchAndFormatPrDiff(params: FetchPrDiffParams): Promise<FormatFilesResult> { export async function fetchAndFormatPrDiff(
const files = await params.octokit.paginate(params.octokit.rest.pulls.listFiles, { ctx: ToolContext,
owner: params.owner, pullNumber: number
repo: params.repo, ): Promise<FormatFilesResult> {
pull_number: params.pullNumber, const files = await ctx.octokit.paginate(ctx.octokit.rest.pulls.listFiles, {
owner: ctx.repo.owner,
repo: ctx.repo.name,
pull_number: pullNumber,
per_page: 100, per_page: 100,
}); });
return formatFilesWithLineNumbers(files); return formatFilesWithLineNumbers(files);
@@ -502,12 +498,7 @@ export function CheckoutPrTool(ctx: ToolContext) {
} }
// fetch PR files and format with line numbers // fetch PR files and format with line numbers
const formatResult = await fetchAndFormatPrDiff({ const formatResult = await fetchAndFormatPrDiff(ctx, pull_number);
octokit: ctx.octokit,
owner: ctx.repo.owner,
repo: ctx.repo.name,
pullNumber: pull_number,
});
const diffPreview = formatResult.content.split("\n").slice(0, 100).join("\n"); const diffPreview = formatResult.content.split("\n").slice(0, 100).join("\n");
log.debug(`formatted diff preview (first 100 lines):\n${diffPreview}`); log.debug(`formatted diff preview (first 100 lines):\n${diffPreview}`);
const diffPath = join(tempDir, `pr-${pull_number}-${headShort}.diff`); const diffPath = join(tempDir, `pr-${pull_number}-${headShort}.diff`);
+57 -117
View File
@@ -1,50 +1,12 @@
import { type } from "arktype"; import { type } from "arktype";
import { apiFetch } from "../utils/apiFetch.ts";
import { getApiUrl } from "../utils/apiUrl.ts"; import { getApiUrl } from "../utils/apiUrl.ts";
import { buildPullfrogFooter, stripExistingFooter } from "../utils/buildPullfrogFooter.ts"; import { buildPullfrogFooter, stripExistingFooter } from "../utils/buildPullfrogFooter.ts";
import { log } from "../utils/cli.ts"; import { log } from "../utils/cli.ts";
import { fixDoubleEscapedString } from "../utils/fixDoubleEscapedString.ts"; import { fixDoubleEscapedString } from "../utils/fixDoubleEscapedString.ts";
import { type OctokitWithPlugins, parseRepoContext } from "../utils/github.ts"; import { patchWorkflowRunFields } from "../utils/patchWorkflowRunFields.ts";
import { retry } from "../utils/retry.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";
type CommentNodeIdField = "planCommentNodeId" | "summaryCommentNodeId";
// IMPORTANT: this route authenticates via Pullfrog API JWT (verifyApiToken),
// NOT a GitHub token. use ctx.apiToken here. see wiki/api-auth.md.
export async function updateCommentNodeId(
ctx: ToolContext,
field: CommentNodeIdField,
nodeId: string
): Promise<void> {
if (ctx.runId === undefined || !ctx.apiToken) return;
try {
await retry(
async () => {
const response = await apiFetch({
path: `/api/workflow-run/${ctx.runId}`,
method: "PATCH",
headers: {
authorization: `Bearer ${ctx.apiToken}`,
"content-type": "application/json",
},
body: JSON.stringify({ [field]: nodeId }),
signal: AbortSignal.timeout(10_000),
});
if (!response.ok) throw new Error(`PATCH workflow-run: ${response.status}`);
},
{
maxAttempts: 3,
delayMs: 2000,
label: `updateCommentNodeId(${field})`,
}
);
} catch (error) {
log.warning(`updateCommentNodeId(${field}) exhausted retries: ${error}`);
}
}
/** /**
* The prefix text for the initial "leaping into action" comment. * The prefix text for the initial "leaping into action" comment.
* This is used to identify if a comment is still in its initial state * This is used to identify if a comment is still in its initial state
@@ -52,65 +14,37 @@ export async function updateCommentNodeId(
*/ */
export const LEAPING_INTO_ACTION_PREFIX = "Leaping into action"; export const LEAPING_INTO_ACTION_PREFIX = "Leaping into action";
interface BuildCommentFooterParams { function buildCommentFooter(ctx: ToolContext, customParts?: string[]): string {
octokit?: OctokitWithPlugins | undefined; const runId = ctx.runId;
customParts?: string[] | undefined;
model?: string | undefined;
}
async function buildCommentFooter(params: BuildCommentFooterParams): Promise<string> {
const repoContext = parseRepoContext();
const runId = process.env.GITHUB_RUN_ID
? Number.parseInt(process.env.GITHUB_RUN_ID, 10)
: undefined;
let jobId: string | undefined;
if (runId && params.octokit) {
try {
const { data: jobs } = await params.octokit.rest.actions.listJobsForWorkflowRun({
owner: repoContext.owner,
repo: repoContext.name,
run_id: runId,
});
jobId = jobs.jobs[0]?.id.toString();
} catch {
// fall back to computed URL from runId alone
}
}
return buildPullfrogFooter({ return buildPullfrogFooter({
triggeredBy: true, triggeredBy: true,
workflowRun: runId workflowRun:
? { owner: repoContext.owner, repo: repoContext.name, runId, jobId } runId !== undefined
: undefined, ? {
customParts: params.customParts, owner: ctx.repo.owner,
model: params.model, repo: ctx.repo.name,
runId,
jobId: ctx.jobId,
}
: undefined,
customParts,
model: ctx.toolState.model,
}); });
} }
function buildImplementPlanLink( function buildImplementPlanLink(ctx: ToolContext, issueNumber: number, commentId: number): string {
owner: string,
repo: string,
issueNumber: number,
commentId: number
): string {
const apiUrl = getApiUrl(); const apiUrl = getApiUrl();
return `[Implement plan ➔](${apiUrl}/trigger/${owner}/${repo}/${issueNumber}?action=implement&comment_id=${commentId})`; return `[Implement plan ➔](${apiUrl}/trigger/${ctx.repo.owner}/${ctx.repo.name}/${issueNumber}?action=implement&comment_id=${commentId})`;
} }
export interface AddFooterCtx { export function addFooter(ctx: ToolContext, body: string): string {
octokit?: OctokitWithPlugins | undefined;
toolState?: { model?: string | undefined } | undefined;
}
export async function addFooter(ctx: AddFooterCtx, body: string): Promise<string> {
if (/<br\s*\/?>[ \t]*\n(?!\s*\n)/i.test(body)) { if (/<br\s*\/?>[ \t]*\n(?!\s*\n)/i.test(body)) {
throw new Error( throw new Error(
"body contains <br/> followed by a non-blank line, which breaks GitHub markdown rendering. always add a blank line after <br/> tags." "body contains <br/> followed by a non-blank line, which breaks GitHub markdown rendering. always add a blank line after <br/> tags."
); );
} }
const bodyWithoutFooter = stripExistingFooter(fixDoubleEscapedString(body)); const bodyWithoutFooter = stripExistingFooter(fixDoubleEscapedString(body));
const footer = await buildCommentFooter({ octokit: ctx.octokit, model: ctx.toolState?.model }); const footer = buildCommentFooter(ctx);
return `${bodyWithoutFooter}${footer}`; return `${bodyWithoutFooter}${footer}`;
} }
@@ -132,7 +66,7 @@ export function CreateCommentTool(ctx: ToolContext) {
"Create a comment on a GitHub issue or PR. For progress/plan updates on the current run use report_progress instead. Use type: 'Plan' for plan comments, type: 'Summary' for PR summary comments.", "Create a comment on a GitHub issue or PR. For progress/plan updates on the current run use report_progress instead. Use type: 'Plan' for plan comments, type: 'Summary' for PR summary comments.",
parameters: Comment, parameters: Comment,
execute: execute(async ({ issueNumber, body, type: commentType }) => { execute: execute(async ({ issueNumber, body, type: commentType }) => {
const bodyWithFooter = await addFooter(ctx, body); const bodyWithFooter = addFooter(ctx, body);
// if a summary comment already exists (found by select_mode), update instead of creating // if a summary comment already exists (found by select_mode), update instead of creating
if (commentType === "Summary" && ctx.toolState.existingSummaryCommentId) { if (commentType === "Summary" && ctx.toolState.existingSummaryCommentId) {
@@ -147,7 +81,7 @@ export function CreateCommentTool(ctx: ToolContext) {
}); });
if (result.data.node_id) { if (result.data.node_id) {
await updateCommentNodeId(ctx, "summaryCommentNodeId", result.data.node_id); await patchWorkflowRunFields(ctx, { summaryCommentNodeId: result.data.node_id });
} }
return { return {
@@ -165,11 +99,32 @@ export function CreateCommentTool(ctx: ToolContext) {
body: bodyWithFooter, body: bodyWithFooter,
}); });
if (commentType === "Plan" && result.data.node_id) { if (commentType === "Plan") {
await updateCommentNodeId(ctx, "planCommentNodeId", result.data.node_id); if (result.data.node_id) {
await patchWorkflowRunFields(ctx, { planCommentNodeId: result.data.node_id });
}
// add "Implement plan" link (needs comment ID, so create-then-update)
const customParts = [buildImplementPlanLink(ctx, issueNumber, result.data.id)];
const footer = buildCommentFooter(ctx, customParts);
const bodyWithPlanLink = `${stripExistingFooter(body)}${footer}`;
const updateResult = await ctx.octokit.rest.issues.updateComment({
owner: ctx.repo.owner,
repo: ctx.repo.name,
comment_id: result.data.id,
body: bodyWithPlanLink,
});
return {
success: true,
commentId: updateResult.data.id,
url: updateResult.data.html_url,
body: updateResult.data.body,
};
} }
if (commentType === "Summary" && result.data.node_id) { if (commentType === "Summary" && result.data.node_id) {
await updateCommentNodeId(ctx, "summaryCommentNodeId", result.data.node_id); await patchWorkflowRunFields(ctx, { summaryCommentNodeId: result.data.node_id });
} }
return { return {
@@ -193,7 +148,7 @@ export function EditCommentTool(ctx: ToolContext) {
description: "Edit a GitHub issue comment by its ID", description: "Edit a GitHub issue comment by its ID",
parameters: EditComment, parameters: EditComment,
execute: execute(async ({ commentId, body }) => { execute: execute(async ({ commentId, body }) => {
const bodyWithFooter = await addFooter(ctx, body); const bodyWithFooter = addFooter(ctx, body);
const result = await ctx.octokit.rest.issues.updateComment({ const result = await ctx.octokit.rest.issues.updateComment({
owner: ctx.repo.owner, owner: ctx.repo.owner,
@@ -259,15 +214,9 @@ export async function reportProgress(
if (target_plan_comment === true && ctx.toolState.existingPlanCommentId !== undefined) { if (target_plan_comment === true && ctx.toolState.existingPlanCommentId !== undefined) {
const commentId = ctx.toolState.existingPlanCommentId; const commentId = ctx.toolState.existingPlanCommentId;
const customParts = const customParts =
isPlanMode && issueNumber !== undefined issueNumber !== undefined ? [buildImplementPlanLink(ctx, issueNumber, commentId)] : undefined;
? [buildImplementPlanLink(ctx.repo.owner, ctx.repo.name, issueNumber, commentId)]
: undefined;
const bodyWithoutFooter = stripExistingFooter(body); const bodyWithoutFooter = stripExistingFooter(body);
const footer = await buildCommentFooter({ const footer = buildCommentFooter(ctx, customParts);
octokit: ctx.octokit,
customParts,
model: ctx.toolState.model,
});
const bodyWithFooter = `${bodyWithoutFooter}${footer}`; const bodyWithFooter = `${bodyWithoutFooter}${footer}`;
const result = await ctx.octokit.rest.issues.updateComment({ const result = await ctx.octokit.rest.issues.updateComment({
@@ -280,7 +229,7 @@ export async function reportProgress(
ctx.toolState.wasUpdated = true; ctx.toolState.wasUpdated = true;
if (isPlanMode && result.data.node_id) { if (isPlanMode && result.data.node_id) {
await updateCommentNodeId(ctx, "planCommentNodeId", result.data.node_id); await patchWorkflowRunFields(ctx, { planCommentNodeId: result.data.node_id });
} }
return { return {
@@ -297,15 +246,11 @@ export async function reportProgress(
if (existingCommentId) { if (existingCommentId) {
const customParts = const customParts =
isPlanMode && issueNumber !== undefined isPlanMode && issueNumber !== undefined
? [buildImplementPlanLink(ctx.repo.owner, ctx.repo.name, issueNumber, existingCommentId)] ? [buildImplementPlanLink(ctx, issueNumber, existingCommentId)]
: undefined; : undefined;
const bodyWithoutFooter = stripExistingFooter(body); const bodyWithoutFooter = stripExistingFooter(body);
const footer = await buildCommentFooter({ const footer = buildCommentFooter(ctx, customParts);
octokit: ctx.octokit,
customParts,
model: ctx.toolState.model,
});
const bodyWithFooter = `${bodyWithoutFooter}${footer}`; const bodyWithFooter = `${bodyWithoutFooter}${footer}`;
const result = await ctx.octokit.rest.issues.updateComment({ const result = await ctx.octokit.rest.issues.updateComment({
@@ -318,7 +263,7 @@ export async function reportProgress(
ctx.toolState.wasUpdated = true; ctx.toolState.wasUpdated = true;
if (isPlanMode && result.data.node_id) { if (isPlanMode && result.data.node_id) {
await updateCommentNodeId(ctx, "planCommentNodeId", result.data.node_id); await patchWorkflowRunFields(ctx, { planCommentNodeId: result.data.node_id });
} }
return { return {
@@ -343,7 +288,7 @@ export async function reportProgress(
} }
// for new comments, we need to create first, then update with Plan link if in Plan mode // for new comments, we need to create first, then update with Plan link if in Plan mode
const initialBody = await addFooter(ctx, body); const initialBody = addFooter(ctx, body);
const result = await ctx.octokit.rest.issues.createComment({ const result = await ctx.octokit.rest.issues.createComment({
owner: ctx.repo.owner, owner: ctx.repo.owner,
@@ -358,15 +303,9 @@ export async function reportProgress(
// if Plan mode, update the comment to add the "Implement plan" link // if Plan mode, update the comment to add the "Implement plan" link
if (isPlanMode) { if (isPlanMode) {
const customParts = [ const customParts = [buildImplementPlanLink(ctx, issueNumber, result.data.id)];
buildImplementPlanLink(ctx.repo.owner, ctx.repo.name, issueNumber, result.data.id),
];
const bodyWithoutFooter = stripExistingFooter(body); const bodyWithoutFooter = stripExistingFooter(body);
const footer = await buildCommentFooter({ const footer = buildCommentFooter(ctx, customParts);
octokit: ctx.octokit,
customParts,
model: ctx.toolState.model,
});
const bodyWithPlanLink = `${bodyWithoutFooter}${footer}`; const bodyWithPlanLink = `${bodyWithoutFooter}${footer}`;
const updateResult = await ctx.octokit.rest.issues.updateComment({ const updateResult = await ctx.octokit.rest.issues.updateComment({
@@ -377,7 +316,7 @@ export async function reportProgress(
}); });
if (updateResult.data.node_id) { if (updateResult.data.node_id) {
await updateCommentNodeId(ctx, "planCommentNodeId", updateResult.data.node_id); await patchWorkflowRunFields(ctx, { planCommentNodeId: updateResult.data.node_id });
} }
return { return {
@@ -410,6 +349,7 @@ export function ReportProgressTool(ctx: ToolContext) {
if (!params.target_plan_comment && ctx.toolState.todoTracker) { if (!params.target_plan_comment && ctx.toolState.todoTracker) {
ctx.toolState.todoTracker.cancel(); ctx.toolState.todoTracker.cancel();
await ctx.toolState.todoTracker.settled(); await ctx.toolState.todoTracker.settled();
ctx.toolState.todoTracker.completeInProgress();
const collapsible = ctx.toolState.todoTracker.renderCollapsible(); const collapsible = ctx.toolState.todoTracker.renderCollapsible();
if (collapsible) { if (collapsible) {
body = `${body}\n\n${collapsible}`; body = `${body}\n\n${collapsible}`;
@@ -490,7 +430,7 @@ export function ReplyToReviewCommentTool(ctx: ToolContext) {
"Reply to a PR review comment thread (NOT issue comments — this only works for inline review comments on PR diffs). Call this for EACH comment you address in AddressReviews mode. Keep replies extremely brief (1 sentence max).", "Reply to a PR review comment thread (NOT issue comments — this only works for inline review comments on PR diffs). Call this for EACH comment you address in AddressReviews mode. Keep replies extremely brief (1 sentence max).",
parameters: ReplyToReviewComment, parameters: ReplyToReviewComment,
execute: execute(async ({ pull_number, comment_id, body }) => { execute: execute(async ({ pull_number, comment_id, body }) => {
const bodyWithFooter = await addFooter(ctx, body); const bodyWithFooter = addFooter(ctx, body);
const result = await ctx.octokit.rest.pulls.createReplyForReviewComment({ const result = await ctx.octokit.rest.pulls.createReplyForReviewComment({
owner: ctx.repo.owner, owner: ctx.repo.owner,
+3 -2
View File
@@ -67,9 +67,10 @@ Inspect the repository structure to determine how dependencies should be install
} }
/** /**
* start dependency installation in the background (non-blocking, idempotent) * start dependency installation in the background (non-blocking, idempotent).
* called eagerly from main.ts at startup and also available via MCP tools.
*/ */
function startInstallation(ctx: ToolContext): void { export function startInstallation(ctx: ToolContext): void {
// already started or completed - do nothing // already started or completed - do nothing
if (ctx.toolState.dependencyInstallation) { if (ctx.toolState.dependencyInstallation) {
return; return;
+10 -20
View File
@@ -57,23 +57,20 @@ function normalizeUrl(url: string): string {
return url.replace(/\.git$/, "").toLowerCase(); return url.replace(/\.git$/, "").toLowerCase();
} }
type ValidatePushParams = {
branch: string;
pushUrl: string;
storedDest: StoredPushDest | undefined;
};
/** /**
* validate that the push destination matches expected URL. * validate that the push destination matches expected URL.
* 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(ctx: ToolContext, branch: string): PushDestination {
const dest = getPushDestination(params.branch, params.storedDest); const pushUrl = ctx.toolState.pushUrl;
if (!pushUrl) throw new Error("pushUrl not set - setupGit must run before push_branch");
if (normalizeUrl(dest.url) !== normalizeUrl(params.pushUrl)) { const dest = getPushDestination(branch, ctx.toolState.pushDest);
if (normalizeUrl(dest.url) !== normalizeUrl(pushUrl)) {
throw new Error( throw new Error(
`Push blocked: destination does not match expected repository.\n` + `Push blocked: destination does not match expected repository.\n` +
`Expected: ${params.pushUrl}\n` + `Expected: ${pushUrl}\n` +
`Actual: ${dest.url}\n` + `Actual: ${dest.url}\n` +
`Git configuration may have been tampered with.` `Git configuration may have been tampered with.`
); );
@@ -99,6 +96,7 @@ export function PushBranchTool(ctx: ToolContext) {
"Push the current branch to the remote repository. Omit branchName to push the current branch (recommended). " + "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. " + "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. " + "The correct remote and remote branch are determined automatically from branch config set by checkout_pr. " +
"Requires a clean working tree. Runs the repository prepush hook (if configured) before the network push — hook failure means tests/lint or similar in that script failed, not necessarily a Pullfrog timeout. " +
"Never force push unless explicitly requested. Pushes to the default branch are blocked in restricted mode.", "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 }) => {
@@ -113,21 +111,13 @@ export function PushBranchTool(ctx: ToolContext) {
const status = $("git", ["status", "--porcelain"], { log: false }); const status = $("git", ["status", "--porcelain"], { log: false });
if (status) { if (status) {
throw new Error( throw new Error(
`push blocked: working tree has uncommitted changes. commit or discard them before pushing.\n\n` + `push blocked: working tree is not clean (tracked changes and/or untracked files). commit, discard, or remove stray artifacts before pushing.\n\n` +
`git status:\n${status}` `git status:\n${status}`
); );
} }
// validate push destination matches expected URL // validate push destination matches expected URL
const pushUrl = ctx.toolState.pushUrl; const pushDest = validatePushDestination(ctx, branch);
if (!pushUrl) {
throw new Error("pushUrl not set - setupGit must run before push_branch");
}
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) {
+13 -5
View File
@@ -1,5 +1,6 @@
import { type } from "arktype"; import { type } from "arktype";
import { fixDoubleEscapedString } from "../utils/fixDoubleEscapedString.ts"; import { fixDoubleEscapedString } from "../utils/fixDoubleEscapedString.ts";
import { patchWorkflowRunFields } from "../utils/patchWorkflowRunFields.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";
@@ -21,16 +22,23 @@ export function IssueTool(ctx: ToolContext) {
name: "create_issue", name: "create_issue",
description: "Create a new GitHub issue", description: "Create a new GitHub issue",
parameters: Issue, parameters: Issue,
execute: execute(async ({ title, body, labels, assignees }) => { execute: execute(async (params) => {
const result = await ctx.octokit.rest.issues.create({ const result = await ctx.octokit.rest.issues.create({
owner: ctx.repo.owner, owner: ctx.repo.owner,
repo: ctx.repo.name, repo: ctx.repo.name,
title: title, title: params.title,
body: fixDoubleEscapedString(body), body: fixDoubleEscapedString(params.body),
labels: labels ?? [], labels: params.labels ?? [],
assignees: assignees ?? [], assignees: params.assignees ?? [],
}); });
const nodeId = result.data.node_id;
if (typeof nodeId === "string" && nodeId.length > 0) {
await patchWorkflowRunFields(ctx, {
issueNodeId: nodeId,
});
}
return { return {
success: true, success: true,
issueId: result.data.id, issueId: result.data.id,
+7
View File
@@ -2,6 +2,7 @@ import { type } from "arktype";
import { buildPullfrogFooter, stripExistingFooter } from "../utils/buildPullfrogFooter.ts"; import { buildPullfrogFooter, stripExistingFooter } from "../utils/buildPullfrogFooter.ts";
import { log } from "../utils/cli.ts"; import { log } from "../utils/cli.ts";
import { fixDoubleEscapedString } from "../utils/fixDoubleEscapedString.ts"; import { fixDoubleEscapedString } from "../utils/fixDoubleEscapedString.ts";
import { patchWorkflowRunFields } from "../utils/patchWorkflowRunFields.ts";
import { $ } from "../utils/shell.ts"; import { $ } from "../utils/shell.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";
@@ -94,6 +95,12 @@ export function CreatePullRequestTool(ctx: ToolContext) {
} }
} }
if (typeof result.data.node_id === "string" && result.data.node_id.length > 0) {
await patchWorkflowRunFields(ctx, {
prNodeId: result.data.node_id,
});
}
return { return {
success: true, success: true,
pullRequestId: result.data.id, pullRequestId: result.data.id,
+18 -29
View File
@@ -1,11 +1,11 @@
import type { RestEndpointMethodTypes } from "@octokit/rest"; import type { RestEndpointMethodTypes } from "@octokit/rest";
import { type } from "arktype"; import { type } from "arktype";
import { formatMcpToolRef } from "../external.ts"; import { formatMcpToolRef } from "../external.ts";
import { apiFetch } from "../utils/apiFetch.ts";
import { getApiUrl } from "../utils/apiUrl.ts"; import { getApiUrl } from "../utils/apiUrl.ts";
import { buildPullfrogFooter } from "../utils/buildPullfrogFooter.ts"; import { buildPullfrogFooter } from "../utils/buildPullfrogFooter.ts";
import { log } from "../utils/cli.ts"; import { log } from "../utils/cli.ts";
import { fixDoubleEscapedString } from "../utils/fixDoubleEscapedString.ts"; import { fixDoubleEscapedString } from "../utils/fixDoubleEscapedString.ts";
import { patchWorkflowRunFields } from "../utils/patchWorkflowRunFields.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";
@@ -83,6 +83,17 @@ export function CreatePullRequestReviewTool(ctx: ToolContext) {
execute: execute(async ({ pull_number, body, approved, commit_id, comments = [] }) => { execute: execute(async ({ pull_number, body, approved, commit_id, comments = [] }) => {
if (body) body = fixDoubleEscapedString(body); if (body) body = fixDoubleEscapedString(body);
// in Review mode (not IncrementalReview), append the completed task list
if (body && ctx.toolState.selectedMode === "Review" && ctx.toolState.todoTracker) {
ctx.toolState.todoTracker.cancel();
await ctx.toolState.todoTracker.settled();
ctx.toolState.todoTracker.completeInProgress();
const collapsible = ctx.toolState.todoTracker.renderCollapsible();
if (collapsible) {
body = `${body}\n\n${collapsible}`;
}
}
// set issue context (PRs are issues) // set issue context (PRs are issues)
ctx.toolState.issueNumber = pull_number; ctx.toolState.issueNumber = pull_number;
@@ -294,34 +305,12 @@ async function createAndSubmitWithFooter(
} }
/** /**
* report the review node ID to the server so the WorkflowRun is marked as "review submitted". * report the review node ID so the WorkflowRun is marked as "review submitted".
* exported for use in main.ts post-agent cleanup. * exported for use in main.ts post-agent cleanup.
*/ */
export async function reportReviewNodeId(ctx: ToolContext, reviewNodeId: string): Promise<void> { export async function reportReviewNodeId(
for (let remaining = 2; remaining >= 0; remaining--) { ctx: ToolContext,
try { params: { nodeId: string }
const response = await apiFetch({ ): Promise<void> {
path: `/api/workflow-run/${ctx.runId}`, await patchWorkflowRunFields(ctx, { reviewNodeId: params.nodeId });
method: "PATCH",
headers: {
authorization: `Bearer ${ctx.apiToken}`,
"content-type": "application/json",
},
body: JSON.stringify({ reviewNodeId }),
signal: AbortSignal.timeout(10_000),
});
if (response.ok) return;
if (remaining > 0) {
log.debug(`reportReviewNodeId got ${response.status}, retrying (${remaining} left)`);
await new Promise((r) => setTimeout(r, 2000));
}
} catch (error) {
if (remaining > 0) {
log.debug(`reportReviewNodeId failed, retrying (${remaining} left): ${error}`);
await new Promise((r) => setTimeout(r, 2000));
} else {
log.debug(`reportReviewNodeId exhausted retries: ${error}`);
}
}
}
} }
+14 -29
View File
@@ -1,6 +1,6 @@
import { type } from "arktype"; import { type } from "arktype";
import { formatMcpToolRef } from "../external.ts"; import { formatMcpToolRef } from "../external.ts";
import type { Mode } from "../modes.ts"; import { type Mode, PR_SUMMARY_FORMAT } from "../modes.ts";
import { apiFetch } from "../utils/apiFetch.ts"; import { apiFetch } from "../utils/apiFetch.ts";
import { log } from "../utils/log.ts"; import { log } from "../utils/log.ts";
import type { ToolContext } from "./server.ts"; import type { ToolContext } from "./server.ts";
@@ -39,17 +39,11 @@ An existing summary comment was found for this PR. Update it rather than creatin
1. Use \`previousSummaryBody\` from this response as the current summary to revise. 1. Use \`previousSummaryBody\` from this response as the current summary to revise.
2. Checkout the PR via \`${t("checkout_pr")}\` — this returns PR metadata and a \`diffPath\`. 2. Checkout the PR via \`${t("checkout_pr")}\` — this returns PR metadata and a \`diffPath\`.
3. Delegate a subagent with: 3. Read the diff using the TOC to selectively read relevant sections. Produce an updated summary reflecting the current state of the PR, using the existing summary (\`previousSummaryBody\`) as a starting point. If EVENT INSTRUCTIONS specify a custom format, follow that instead of the default format below.
- the diff file path and PR metadata 4. Call \`${t("edit_issue_comment")}\` with \`commentId: existingSummaryCommentId\` (from this response) and the updated summary body.
- the existing summary body (\`previousSummaryBody\`) so it can update rather than rewrite from scratch
- format instructions from EVENT INSTRUCTIONS (if any)
- instruct it to produce an updated summary reflecting the current state of the PR and return it as its final response
4. After the subagent completes, call \`${t("edit_issue_comment")}\` with \`commentId: existingSummaryCommentId\` (from this response) and the updated summary body.
5. Call \`${t("report_progress")}\` with a brief note (e.g., "Updated PR summary."). 5. Call \`${t("report_progress")}\` with a brief note (e.g., "Updated PR summary.").
### Effort ${PR_SUMMARY_FORMAT}`,
Use mini or auto effort.`,
}; };
} }
@@ -65,15 +59,14 @@ const modeInstructionParent: Record<string, string> = {
Fix: "Build", Fix: "Build",
}; };
type BuildGuidanceOpts = { function buildOrchestratorGuidance(
modeInstructions?: Record<string, string>; ctx: ToolContext,
overrideGuidance?: string; mode: Mode,
}; overrideGuidance?: string
): OrchestratorGuidance {
function buildOrchestratorGuidance(mode: Mode, opts: BuildGuidanceOpts = {}): OrchestratorGuidance { const hardcoded = overrideGuidance ?? mode.prompt ?? "";
const hardcoded = opts.overrideGuidance ?? mode.prompt ?? "";
const lookupKey = modeInstructionParent[mode.name] ?? mode.name; const lookupKey = modeInstructionParent[mode.name] ?? mode.name;
const userInstructions = opts.modeInstructions?.[lookupKey] ?? ""; const userInstructions = ctx.modeInstructions[lookupKey] ?? "";
const guidance = [hardcoded, userInstructions].filter(Boolean).join("\n\n"); const guidance = [hardcoded, userInstructions].filter(Boolean).join("\n\n");
return { return {
modeName: mode.name, modeName: mode.name,
@@ -172,8 +165,6 @@ export function SelectModeTool(ctx: ToolContext) {
ctx.toolState.selectedMode = selectedMode.name; ctx.toolState.selectedMode = selectedMode.name;
const guidanceOpts: BuildGuidanceOpts = { modeInstructions: ctx.modeInstructions };
if (selectedMode.name === "Plan") { if (selectedMode.name === "Plan") {
const issueNumber = params.issue_number ?? ctx.payload.event.issue_number; const issueNumber = params.issue_number ?? ctx.payload.event.issue_number;
if (issueNumber !== undefined) { if (issueNumber !== undefined) {
@@ -182,10 +173,7 @@ export function SelectModeTool(ctx: ToolContext) {
ctx.toolState.existingPlanCommentId = existing.commentId; ctx.toolState.existingPlanCommentId = existing.commentId;
ctx.toolState.previousPlanBody = existing.body; ctx.toolState.previousPlanBody = existing.body;
return { return {
...buildOrchestratorGuidance(selectedMode, { ...buildOrchestratorGuidance(ctx, selectedMode, overrides.PlanEdit),
...guidanceOpts,
overrideGuidance: overrides.PlanEdit,
}),
previousPlanBody: existing.body, previousPlanBody: existing.body,
}; };
} }
@@ -199,10 +187,7 @@ export function SelectModeTool(ctx: ToolContext) {
if (existing !== null) { if (existing !== null) {
ctx.toolState.existingSummaryCommentId = existing.commentId; ctx.toolState.existingSummaryCommentId = existing.commentId;
return { return {
...buildOrchestratorGuidance(selectedMode, { ...buildOrchestratorGuidance(ctx, selectedMode, overrides.SummaryUpdate),
...guidanceOpts,
overrideGuidance: overrides.SummaryUpdate,
}),
existingSummaryCommentId: existing.commentId, existingSummaryCommentId: existing.commentId,
previousSummaryBody: existing.body, previousSummaryBody: existing.body,
}; };
@@ -210,7 +195,7 @@ export function SelectModeTool(ctx: ToolContext) {
} }
} }
return buildOrchestratorGuidance(selectedMode, guidanceOpts); return buildOrchestratorGuidance(ctx, selectedMode);
}), }),
}); });
} }
+9 -2
View File
@@ -65,7 +65,7 @@ function detectSandboxMethod(): SandboxMethod {
// continue to try sudo // continue to try sudo
} }
// try sudo unshare (works on GHA runners) // sudo unshare (works on GHA runners)
try { try {
const result = spawnSync("sudo", ["unshare", "--pid", "--fork", "--mount-proc", "true"], { const result = spawnSync("sudo", ["unshare", "--pid", "--fork", "--mount-proc", "true"], {
timeout: 5000, timeout: 5000,
@@ -81,7 +81,7 @@ function detectSandboxMethod(): SandboxMethod {
} }
detectedSandboxMethod = "none"; detectedSandboxMethod = "none";
log.info("PID namespace isolation not available - falling back to env filtering only"); log.info("PID namespace isolation not available");
return "none"; return "none";
} }
@@ -97,6 +97,13 @@ const PROC_CLEANUP =
function spawnShell(params: SpawnParams): ChildProcess { function spawnShell(params: SpawnParams): ChildProcess {
const spawnOpts = { env: params.env, cwd: params.cwd, stdio: params.stdio, detached: true }; const spawnOpts = { env: params.env, cwd: params.cwd, stdio: params.stdio, detached: true };
const sandboxMethod = detectSandboxMethod(); const sandboxMethod = detectSandboxMethod();
const ci = process.env.CI === "true";
if (ci && sandboxMethod === "none") {
throw new Error(
"pid namespace isolation is required in CI but unavailable (both unshare and sudo unshare failed)"
);
}
if (sandboxMethod === "unshare") { if (sandboxMethod === "unshare") {
return spawn( return spawn(
+1 -1
View File
@@ -14,7 +14,7 @@ export function UploadFileTool(ctx: ToolContext) {
return tool({ return tool({
name: "upload_file", name: "upload_file",
description: description:
"upload a file to get a permanent public URL. use for screenshots, artifacts, or any files you want to reference in PRs/comments. max 10MB, images/text/archives allowed.", "upload a file to get a permanent public URL. use for screenshots, artifacts, or any files you want to reference in PRs/comments. max 10MB, images/text/archives allowed. when embedding uploaded images in comments or PR bodies, always use markdown image syntax: ![description](url)",
parameters: UploadFileParams, parameters: UploadFileParams,
execute: execute(async (params) => { execute: execute(async (params) => {
// read file from disk eagerly on purpose to avoid its content being changed by the time it's uploaded // read file from disk eagerly on purpose to avoid its content being changed by the time it's uploaded
+75 -36
View File
@@ -9,6 +9,55 @@ export interface Mode {
prompt?: string | undefined; prompt?: string | undefined;
} }
export const PR_SUMMARY_FORMAT = `### Default format
Follow this structure exactly:
<b>TL;DR</b> — 1-3 sentences on what the PR does and why. Focus on intent, not mechanics.
NOTE: use HTML bold <b>TL;DR</b>, NOT markdown bold **TL;DR**.
### Key changes
- **Short human-readable title** — 1 sentence per change. Write a short prose phrase (title case or sentence case); when you name a file, type, or function, put that name in backticks (e.g. **Add \`TodoTracker\` for live checklists**). A reviewer should understand the full PR from this list alone.
<sub><b>Summary</b> {file_count} files {commit_count} commits base: \`{base}\`\`{head}\`</sub>
NOTE: the metadata line goes AFTER the bullet list, not before it.
Then for each key change, a ## section with a short descriptive title that reads like a documentation heading (e.g. ## Live todo checklist tracking).
<br/>
## Example readable section title
> **Before:** [old behavior/state]<br/>**After:** [new behavior/state]
IMPORTANT: Before and After MUST be on a SINGLE blockquote line with an inline <br/> between them. Two separate \`>\` lines creates a double line break.
1-2 sentences of explanation. Break up text with tables, blockquotes, or lists — NEVER 3+ plain paragraphs in a row.
If a change warrants deeper explanation, use a blockquoted details/summary framed as a question:
> <details><summary>How does X work?</summary>
> Extended explanation here.
> </details>
End each section with a file links trail (3-4 key files max):
[\`file.ts\`](https://github.com/{owner}/{repo}/pull/{number}/files#diff-{sha256hex_of_filepath}) · ...
Single-feature PRs: skip the ## sections. Fold before/after and explanation into the header after key changes.
CRITICAL — GitHub markdown rendering rule:
GitHub's markdown parser requires a blank line between ALL block-level elements. This includes transitions between: HTML tags (<br/>, <sub>, <details>, <b>, etc.) and markdown syntax (headings, lists, blockquotes, paragraphs). Without a blank line, GitHub treats the following content as a continuation of the HTML block and renders markdown syntax as literal text. ALWAYS separate block-level elements with a blank line.
Rules:
- \`##\` titles and key-change bullet lead-ins are plain-language summaries; backtick only actual code tokens (files, types, functions) where they appear in the title
- ALL variable names, identifiers, and file names in body text must be in backticks
- ALL file references MUST link to the PR Files Changed view. Compute anchors by running \`echo -n 'path/to/file.ts' | sha256sum\` via shell for each file. NEVER fabricate hex strings — run the actual command. If shell is unavailable, omit the #diff- anchor rather than guessing.
- Add <br/> before each ## heading for visual spacing. Do NOT use horizontal rules (---)
- Do NOT include raw diff stats like '+123 / -45' or line counts
- Do NOT include code blocks or repeat diff contents
- Do NOT include a changelog section — the key changes list serves this purpose
- Focus on *intent*, not *what* — the diff already shows what changed
- Get the file count and commit count from the checkout_pr metadata, not by counting manually`;
function learningsStep(t: (toolName: string) => string, n: number): string { function learningsStep(t: (toolName: string) => string, n: number): string {
return `${n}. **learnings** (only if high confidence): if you discovered something about repo setup, test commands, conventions, or patterns that you are confident is correct and would reliably help future runs, call \`${t("update_learnings")}\` to persist it. skip this step if you are unsure or the finding is speculative/one-off. format as a flat bullet list (\`- \` per line, one fact per bullet). merge with existing learnings from the prompt — pass the FULL merged list. deduplicate, and drop bullets that are clearly wrong or no longer relevant to the current codebase.`; return `${n}. **learnings** (only if high confidence): if you discovered something about repo setup, test commands, conventions, or patterns that you are confident is correct and would reliably help future runs, call \`${t("update_learnings")}\` to persist it. skip this step if you are unsure or the finding is speculative/one-off. format as a flat bullet list (\`- \` per line, one fact per bullet). merge with existing learnings from the prompt — pass the FULL merged list. deduplicate, and drop bullets that are clearly wrong or no longer relevant to the current codebase.`;
} }
@@ -38,9 +87,9 @@ export function computeModes(agentId: AgentId): Mode[] {
- commit locally via shell (\`git add . && git commit -m "..."\`) - commit locally via shell (\`git add . && git commit -m "..."\`)
5. **finalize**: 5. **finalize**:
- push the branch via \`${t("push_branch")}\` - confirm a clean working tree, then push via \`${t("push_branch")}\` (see *SYSTEM* Git rules if this fails — prepush errors are usually the repo's tests/lint, not infra timeouts)
- create a PR via \`${t("create_pull_request")}\` - create a PR via \`${t("create_pull_request")}\`
- call \`${t("report_progress")}\` with the final summary including PR link - call \`${t("report_progress")}\` with the PR link or the exact error if push/PR failed
${learningsStep(t, 6)} ${learningsStep(t, 6)}
@@ -68,10 +117,10 @@ For simple, well-defined tasks, skip the plan phase and go straight to build.`,
- commit locally via shell (\`git add . && git commit -m "..."\`) - commit locally via shell (\`git add . && git commit -m "..."\`)
5. Finalize: 5. Finalize:
- push changes via \`${t("push_branch")}\` - confirm a clean working tree, then push via \`${t("push_branch")}\` (same push/prepush guidance as Build mode in *SYSTEM*)
- reply to each comment using \`${t("reply_to_review_comment")}\` - reply to each comment using \`${t("reply_to_review_comment")}\`
- resolve addressed threads via \`${t("resolve_review_thread")}\` - resolve addressed threads via \`${t("resolve_review_thread")}\`
- call \`${t("report_progress")}\` with a brief summary - call \`${t("report_progress")}\` with a brief summary (or the exact push error if push failed)
${learningsStep(t, 6)}`, ${learningsStep(t, 6)}`,
}, },
@@ -127,26 +176,23 @@ ${learningsStep(t, 6)}`,
- check whether prior review feedback was addressed by the new commits - check whether prior review feedback was addressed by the new commits
- trace data flow, check boundaries, verify assumptions, consider lifecycle, spot performance issues - trace data flow, check boundaries, verify assumptions, consider lifecycle, spot performance issues
- if the new commits remove, rename, or deprecate anything, run impact analysis with grep across code/tests/docs/comments/configs to find stale references and include those findings in the summary body - if the new commits remove, rename, or deprecate anything, run impact analysis with grep across code/tests/docs/comments/configs to find stale references and include those findings in the summary body
- never repeat prior feedback. if the author did not address an earlier comment, assume it was intentionally declined; only comment on genuinely new issues introduced by the new commits - never repeat prior feedback. only comment on genuinely new issues introduced by the new commits.
- draft inline comments with NEW line numbers from the full PR diff — every comment must be actionable (2-3 sentences max) - draft inline comments with NEW line numbers from the full PR diff — every comment must be actionable (2-3 sentences max)
- for large or cross-cutting PRs, consider delegating read-only subagents for parallel investigation. subagents must ONLY read files, grep, and search — no MCP tools, no writes, no shell commands, no side effects. collect their findings and use them to draft comments. - for large or cross-cutting PRs, consider delegating read-only subagents for parallel investigation. subagents must ONLY read files, grep, and search — no MCP tools, no writes, no shell commands, no side effects. collect their findings and use them to draft comments.
5. Self-critique: drop any comments that are praise, style preferences, speculative, about pre-existing code, or not actionable. 5. Self-critique: drop any comments that are praise, style preferences, speculative, about pre-existing code, or not actionable.
6. **Summarize incremental changes**: produce a concise summary of the changes since the last review. This summary MUST follow strict formatting rules: 6. **Summarize**: build two distinct sections for the review body:
- **never put more than 3 sentences in a row** — break up prose with lists, tables, or headings a. **Reviewed changes**: summarize at the logical-change level, not per-file. each bullet starts with a past-tense verb (e.g. \`- Extracted shared CLI runtime into a single module\`, \`- Renamed package to pullfrog\`). avoid file paths unless they add clarity. if the changes can be described in one sentence, use one sentence — no bullets needed.
- use bullet lists, tables, and structured formatting liberally b. **Prior review feedback** (only if any were addressed): list only the prior review comments that WERE addressed by the new commits (\`- [x] safeParse instead of parse — addressed\`). omit unaddressed comments. omit this entire section if nothing was addressed. a change can appear in both sections.
- start with a 1-sentence TL;DR of what the new commits do - no headings, no tables, no prose paragraphs in either section — just bullets
- then list the key changes (use a table for file-level changes if 3+ files changed, bullet list otherwise)
- note which prior review comments were addressed vs. not addressed (use a checklist: \`- [x] addressed\` / \`- [ ] not addressed\`)
- keep the entire summary compact — aim for ≤15 lines of markdown
- in some cases you may receive a complete diff for the whole pull request instead of an incremental one. when this happens, you will need to determine what changes have happened since Pullfrog's most recent review. - in some cases you may receive a complete diff for the whole pull request instead of an incremental one. when this happens, you will need to determine what changes have happened since Pullfrog's most recent review.
7. Submit — Do NOT call \`report_progress\` or \`create_issue_comment\` — the review is the final record and the progress comment will be cleaned up automatically. Follow these rules: 7. Submit — Do NOT call \`report_progress\` or \`create_issue_comment\` — the review is the final record and the progress comment will be cleaned up automatically. the review body always includes the reviewed changes from step 6a. append \`Prior review feedback:\\n\` with the checklist from step 6b only if any prior comments were addressed. Follow these rules:
- IF NO NEW ISSUES, NON-SUBSTANTIVE CHANGES ONLY (trivial formatting, import reordering, comment tweaks): do NOT submit a review. Do NOT call \`report_progress\`. Exit — the progress comment will be cleaned up automatically. - IF NO NEW ISSUES, NON-SUBSTANTIVE CHANGES ONLY (trivial formatting, import reordering, comment tweaks): do NOT submit a review. Do NOT call \`report_progress\`. Exit — the progress comment will be cleaned up automatically.
- ELSE IF NEW CRITICAL ISSUES (blocks merge): call \`${t("create_pull_request_review")}\` with \`approved: false\`, all comments, and the review body. The review body begins with a GitHub alert blockquote (e.g. \`> [!CAUTION]\\n> This PR introduces ...\`) + incremental summary from step 6. - ELSE IF NEW CRITICAL ISSUES (blocks merge): call \`${t("create_pull_request_review")}\` with \`approved: false\`, all comments, and the review body. body opens with a GitHub alert blockquote (e.g. \`> [!CAUTION]\\n> This PR introduces ...\`), then the reviewed changes summary and prior feedback (if any).
- ELSE IF NEW RECOMMENDED CHANGES (non-critical): call \`${t("create_pull_request_review")}\` with \`approved: false\`, all comments, and the review body. The review body begins with a GitHub alert blockquote (e.g. \`> [!IMPORTANT]\\n> Consider adding input validation for ...\`) + incremental summary. - ELSE IF NEW RECOMMENDED CHANGES (non-critical): call \`${t("create_pull_request_review")}\` with \`approved: false\`, all comments, and the review body. body opens with \`> [!IMPORTANT]\\n> ...\` alert, then the reviewed changes summary and prior feedback (if any).
- ELSE IF NO NEW ISSUES, SUBSTANTIVE CHANGES (new functionality, behavior changes, or fixes to prior review feedback): call \`${t("create_pull_request_review")}\` to create a PR review. If all Previous reviews have been properly addressed and no new issues were discovered, you can set \`approved: true\`. The "body" should state up front that no new issues were found. Then include a summary of the detected changes so the user knows that you have reviewed them.`, - ELSE IF NO NEW ISSUES, SUBSTANTIVE CHANGES (new functionality, behavior changes, or fixes to prior review feedback): call \`${t("create_pull_request_review")}\` to create a PR review. If all previous reviews have been properly addressed and no new issues were discovered, you can set \`approved: true\`. body opens with \`No new issues. Reviewed the following changes:\\n\`, then the reviewed changes summary and prior feedback (if any).`,
}, },
{ {
name: "Plan", name: "Plan",
@@ -184,8 +230,8 @@ ${learningsStep(t, 4)}`,
- commit locally via shell (\`git add . && git commit -m "..."\`) - commit locally via shell (\`git add . && git commit -m "..."\`)
5. Finalize: 5. Finalize:
- push changes via \`${t("push_branch")}\` - confirm a clean working tree, then push via \`${t("push_branch")}\` (same push/prepush guidance as Build mode in *SYSTEM*)
- call \`${t("report_progress")}\` with the diagnosis and fix summary - call \`${t("report_progress")}\` with the diagnosis and fix summary (or the exact push error if push failed)
${learningsStep(t, 6)}`, ${learningsStep(t, 6)}`,
}, },
@@ -201,8 +247,8 @@ ${learningsStep(t, 6)}`,
2. **Merge Attempt**: 2. **Merge Attempt**:
- Run \`git merge origin/<base_branch>\` via shell. - Run \`git merge origin/<base_branch>\` via shell.
- If it succeeds automatically, push via \`${t("push_branch")}\` and report success. - If it succeeds automatically, confirm a clean working tree, push via \`${t("push_branch")}\` (same push/prepush guidance as Build mode in *SYSTEM*), and call \`${t("report_progress")}\` with a brief success note or the exact push error if push failed — **then stop; do not run steps 34.**
- If it fails (conflicts), resolve them manually. - If it fails (conflicts), resolve them manually (continue to steps 34).
3. **Resolve Conflicts**: 3. **Resolve Conflicts**:
- Run \`git status\` or parse the merge output to find the list of conflicting files. - Run \`git status\` or parse the merge output to find the list of conflicting files.
@@ -212,8 +258,8 @@ ${learningsStep(t, 6)}`,
4. **Finalize**: 4. **Finalize**:
- Run a final verification (build/test) to ensure the resolution works. - Run a final verification (build/test) to ensure the resolution works.
- \`git add . && git commit -m "resolve merge conflicts"\` - \`git add . && git commit -m "resolve merge conflicts"\`
- Push via \`${t("push_branch")}\` - confirm a clean working tree, then push via \`${t("push_branch")}\` (same push/prepush guidance as Build mode in *SYSTEM*)
- Call \`${t("report_progress")}\` with a summary of what was resolved`, - Call \`${t("report_progress")}\` with a summary of what was resolved (or the exact push error if push failed)`,
}, },
{ {
name: "Task", name: "Task",
@@ -230,8 +276,8 @@ ${learningsStep(t, 6)}`,
- if code changes are needed: review your own diff before committing — verify only intended changes are present, no debug artifacts remain, and the changes are clean enough that a senior engineer would approve without hesitation - if code changes are needed: review your own diff before committing — verify only intended changes are present, no debug artifacts remain, and the changes are clean enough that a senior engineer would approve without hesitation
3. Finalize: 3. Finalize:
- if code changes were made, push all changes to a pull request (new or existing). \`git status\` must be clean before you finish. - if code changes were made, push to a pull request (new or existing) using \`${t("push_branch")}\` and \`${t("create_pull_request")}\` as needed. \`git status\` must be clean before you finish (see *SYSTEM* Git rules if push fails).
- call \`${t("report_progress")}\` with results - call \`${t("report_progress")}\` once with results — include exact tool errors if push or PR creation failed
- if the task involved labeling, commenting, or other GitHub operations, perform those directly - if the task involved labeling, commenting, or other GitHub operations, perform those directly
${learningsStep(t, 4)}`, ${learningsStep(t, 4)}`,
@@ -243,21 +289,14 @@ ${learningsStep(t, 4)}`,
prompt: `### Checklist prompt: `### Checklist
1. Checkout the PR via \`${t("checkout_pr")}\` — this returns PR metadata and a \`diffPath\`. 1. Checkout the PR via \`${t("checkout_pr")}\` — this returns PR metadata and a \`diffPath\`.
2. Delegate a subagent to analyze the diff and produce a structured summary. Include in its prompt: 2. Read the diff using the TOC to selectively read relevant sections (not the entire file). Produce a structured summary. If EVENT INSTRUCTIONS specify a custom format, follow that instead of the default format below.
- the diff file path 3. Call \`${t("create_issue_comment")}\` with \`type: "Summary"\` and the summary body.
- PR metadata (title, file count, commit count, base/head branches)
- format instructions from EVENT INSTRUCTIONS (if any); otherwise use default format: TL;DR, key changes list, per-change sections with plain-language \`##\` titles and before/after framing
- instruct it to use the TOC to selectively read relevant diff sections, not the entire file
- instruct it to return the full summary markdown as its final response
3. After the subagent completes, call \`${t("create_issue_comment")}\` with \`type: "Summary"\` and the summary body.
4. Call \`${t("report_progress")}\` with a brief note (e.g., "Posted PR summary."). 4. Call \`${t("report_progress")}\` with a brief note (e.g., "Posted PR summary.").
### Effort ${PR_SUMMARY_FORMAT}`,
Use mini or auto effort.`,
}, },
]; ];
} }
// static export for UI display — uses opentoad format as the readable default // static export for UI display — uses opencode format as the readable default
export const modes: Mode[] = computeModes("opentoad"); export const modes: Mode[] = computeModes("opencode");
+40 -38
View File
@@ -1,61 +1,60 @@
{ {
"name": "@pullfrog/pullfrog", "name": "pullfrog",
"version": "0.0.192", "version": "0.0.201",
"type": "module", "type": "module",
"bin": {
"pullfrog": "dist/cli.mjs",
"pullfrog-dev": "dist/cli.mjs",
"pf": "dist/cli.mjs"
},
"files": [ "files": [
"index.js", "dist/"
"index.cjs",
"index.d.ts",
"index.d.cts",
"agents",
"utils",
"main.js",
"main.d.ts"
], ],
"scripts": { "scripts": {
"test": "vitest", "test": "vitest",
"typecheck": "tsc --noEmit", "typecheck": "tsc --noEmit",
"build": "node esbuild.config.js", "build": "node esbuild.config.js && tsc -p tsconfig.exports.json",
"check:entrypoints": "node scripts/check-entrypoint-imports.ts",
"play": "node play.ts", "play": "node play.ts",
"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 install --no-frozen-lockfile", "lock": "pnpm install --no-frozen-lockfile",
"postinstall": "node scripts/generate-proxies.ts", "prepare": "cd .. && husky"
"prepare": "cd .. && husky action/.husky"
}, },
"dependencies": { "devDependencies": {
"@actions/core": "^1.11.1", "@actions/core": "^1.11.1",
"@anthropic-ai/claude-code": "2.1.85",
"@ark/fs": "0.56.0", "@ark/fs": "0.56.0",
"@ark/util": "0.56.0", "@ark/util": "0.56.0",
"@clack/prompts": "^1.2.0",
"@modelcontextprotocol/sdk": "^1.26.0",
"@octokit/plugin-throttling": "^11.0.3", "@octokit/plugin-throttling": "^11.0.3",
"@octokit/rest": "^22.0.0", "@octokit/rest": "^22.0.0",
"@octokit/webhooks-types": "^7.6.1", "@octokit/webhooks-types": "^7.6.1",
"@standard-schema/spec": "1.1.0", "@standard-schema/spec": "1.1.0",
"@toon-format/toon": "^1.0.0", "@toon-format/toon": "^1.0.0",
"ajv": "^8.18.0",
"arkregex": "0.0.5",
"arktype": "2.2.0",
"dotenv": "^17.2.3",
"execa": "^9.6.0",
"fastmcp": "^3.34.0",
"file-type": "^21.3.0",
"package-manager-detector": "^1.6.0",
"semver": "^7.7.3",
"table": "^6.9.0",
"turndown": "^7.2.0"
},
"devDependencies": {
"@anthropic-ai/claude-code": "2.1.85",
"agent-browser": "0.21.0",
"@modelcontextprotocol/sdk": "^1.26.0",
"@types/node": "^24.7.2", "@types/node": "^24.7.2",
"@types/semver": "^7.7.1", "@types/semver": "^7.7.1",
"@types/turndown": "^5.0.5", "@types/turndown": "^5.0.5",
"agent-browser": "0.25.4",
"ajv": "^8.18.0",
"arg": "^5.0.2", "arg": "^5.0.2",
"arkregex": "0.0.5",
"arktype": "2.2.0",
"dotenv": "^17.2.3",
"esbuild": "^0.25.9", "esbuild": "^0.25.9",
"execa": "^9.6.0",
"fastmcp": "^3.34.0",
"file-type": "^21.3.0",
"husky": "^9.0.0", "husky": "^9.0.0",
"opencode-ai": "1.1.56", "opencode-ai": "1.1.56",
"package-manager-detector": "^1.6.0",
"picocolors": "^1.1.1",
"semver": "^7.7.3",
"skills": "1.4.9",
"table": "^6.9.0",
"turndown": "^7.2.0",
"typescript": "^5.9.3", "typescript": "^5.9.3",
"vitest": "^4.0.17", "vitest": "^4.0.17",
"yaml": "^2.8.2" "yaml": "^2.8.2"
@@ -75,19 +74,22 @@
"url": "https://github.com/pullfrog/pullfrog/issues" "url": "https://github.com/pullfrog/pullfrog/issues"
}, },
"homepage": "https://github.com/pullfrog/pullfrog#readme", "homepage": "https://github.com/pullfrog/pullfrog#readme",
"zshy": { "main": "./dist/index.js",
"exports": "./index.ts"
},
"main": "./dist/index.cjs",
"module": "./dist/index.js", "module": "./dist/index.js",
"types": "./dist/index.d.cts", "types": "./dist/index.d.ts",
"exports": { "exports": {
".": { ".": {
"types": "./dist/index.d.cts", "@pullfrog/source": "./index.ts",
"types": "./dist/index.d.ts",
"import": "./dist/index.js", "import": "./dist/index.js",
"require": "./dist/index.cjs" "default": "./dist/index.js"
},
"./internal": {
"@pullfrog/source": "./internal/index.ts",
"types": "./dist/internal/index.d.ts",
"import": "./dist/internal.js",
"default": "./dist/internal.js"
}, },
"./internal": "./dist/internal.js",
"./package.json": "./package.json" "./package.json": "./package.json"
}, },
"packageManager": "pnpm@10.27.0+sha512.72d699da16b1179c14ba9e64dc71c9a40988cbdc65c264cb0e489db7de917f20dcf4d64d8723625f2969ba52d4b7e2a1170682d9ac2a5dcaeaab732b7e16f04a" "packageManager": "pnpm@10.27.0+sha512.72d699da16b1179c14ba9e64dc71c9a40988cbdc65c264cb0e489db7de917f20dcf4d64d8723625f2969ba52d4b7e2a1170682d9ac2a5dcaeaab732b7e16f04a"
+112 -46
View File
@@ -7,16 +7,25 @@ settings:
importers: importers:
.: .:
dependencies: devDependencies:
'@actions/core': '@actions/core':
specifier: ^1.11.1 specifier: ^1.11.1
version: 1.11.1 version: 1.11.1
'@anthropic-ai/claude-code':
specifier: 2.1.85
version: 2.1.85
'@ark/fs': '@ark/fs':
specifier: 0.56.0 specifier: 0.56.0
version: 0.56.0 version: 0.56.0
'@ark/util': '@ark/util':
specifier: 0.56.0 specifier: 0.56.0
version: 0.56.0 version: 0.56.0
'@clack/prompts':
specifier: ^1.2.0
version: 1.2.0
'@modelcontextprotocol/sdk':
specifier: ^1.26.0
version: 1.26.0(zod@4.3.6)
'@octokit/plugin-throttling': '@octokit/plugin-throttling':
specifier: ^11.0.3 specifier: ^11.0.3
version: 11.0.3(@octokit/core@7.0.5) version: 11.0.3(@octokit/core@7.0.5)
@@ -32,46 +41,6 @@ importers:
'@toon-format/toon': '@toon-format/toon':
specifier: ^1.0.0 specifier: ^1.0.0
version: 1.4.0 version: 1.4.0
ajv:
specifier: ^8.18.0
version: 8.18.0
arkregex:
specifier: 0.0.5
version: 0.0.5
arktype:
specifier: 2.2.0
version: 2.2.0
dotenv:
specifier: ^17.2.3
version: 17.2.3
execa:
specifier: ^9.6.0
version: 9.6.0
fastmcp:
specifier: ^3.34.0
version: 3.34.0(arktype@2.2.0)
file-type:
specifier: ^21.3.0
version: 21.3.0
package-manager-detector:
specifier: ^1.6.0
version: 1.6.0
semver:
specifier: ^7.7.3
version: 7.7.3
table:
specifier: ^6.9.0
version: 6.9.0
turndown:
specifier: ^7.2.0
version: 7.2.2
devDependencies:
'@anthropic-ai/claude-code':
specifier: 2.1.85
version: 2.1.85
'@modelcontextprotocol/sdk':
specifier: ^1.26.0
version: 1.26.0(zod@4.3.6)
'@types/node': '@types/node':
specifier: ^24.7.2 specifier: ^24.7.2
version: 24.7.2 version: 24.7.2
@@ -82,20 +51,59 @@ importers:
specifier: ^5.0.5 specifier: ^5.0.5
version: 5.0.6 version: 5.0.6
agent-browser: agent-browser:
specifier: 0.21.0 specifier: 0.25.4
version: 0.21.0 version: 0.25.4
ajv:
specifier: ^8.18.0
version: 8.18.0
arg: arg:
specifier: ^5.0.2 specifier: ^5.0.2
version: 5.0.2 version: 5.0.2
arkregex:
specifier: 0.0.5
version: 0.0.5
arktype:
specifier: 2.2.0
version: 2.2.0
dotenv:
specifier: ^17.2.3
version: 17.2.3
esbuild: esbuild:
specifier: ^0.25.9 specifier: ^0.25.9
version: 0.25.12 version: 0.25.12
execa:
specifier: ^9.6.0
version: 9.6.0
fastmcp:
specifier: ^3.34.0
version: 3.34.0(arktype@2.2.0)
file-type:
specifier: ^21.3.0
version: 21.3.0
husky: husky:
specifier: ^9.0.0 specifier: ^9.0.0
version: 9.1.7 version: 9.1.7
opencode-ai: opencode-ai:
specifier: 1.1.56 specifier: 1.1.56
version: 1.1.56 version: 1.1.56
package-manager-detector:
specifier: ^1.6.0
version: 1.6.0
picocolors:
specifier: ^1.1.1
version: 1.1.1
semver:
specifier: ^7.7.3
version: 7.7.3
skills:
specifier: 1.4.9
version: 1.4.9
table:
specifier: ^6.9.0
version: 6.9.0
turndown:
specifier: ^7.2.0
version: 7.2.2
typescript: typescript:
specifier: ^5.9.3 specifier: ^5.9.3
version: 5.9.3 version: 5.9.3
@@ -137,6 +145,12 @@ packages:
'@borewit/text-codec@0.2.1': '@borewit/text-codec@0.2.1':
resolution: {integrity: sha512-k7vvKPbf7J2fZ5klGRD9AeKfUvojuZIQ3BT5u7Jfv+puwXkUBUT5PVyMDfJZpy30CBDXGMgw7fguK/lpOMBvgw==} resolution: {integrity: sha512-k7vvKPbf7J2fZ5klGRD9AeKfUvojuZIQ3BT5u7Jfv+puwXkUBUT5PVyMDfJZpy30CBDXGMgw7fguK/lpOMBvgw==}
'@clack/core@1.2.0':
resolution: {integrity: sha512-qfxof/3T3t9DPU/Rj3OmcFyZInceqj/NVtO9rwIuJqCUgh32gwPjpFQQp/ben07qKlhpwq7GzfWpST4qdJ5Drg==}
'@clack/prompts@1.2.0':
resolution: {integrity: sha512-4jmztR9fMqPMjz6H/UZXj0zEmE43ha1euENwkckKKel4XpSfokExPo5AiVStdHSAlHekz4d0CA/r45Ok1E4D3w==}
'@esbuild/aix-ppc64@0.25.12': '@esbuild/aix-ppc64@0.25.12':
resolution: {integrity: sha512-Hhmwd6CInZ3dwpuGTF8fJG6yoWmsToE+vYgD4nytZVxcu1ulHpUQRAB1UJ8+N1Am3Mz4+xOByoQoSZf4D+CpkA==} resolution: {integrity: sha512-Hhmwd6CInZ3dwpuGTF8fJG6yoWmsToE+vYgD4nytZVxcu1ulHpUQRAB1UJ8+N1Am3Mz4+xOByoQoSZf4D+CpkA==}
engines: {node: '>=18'} engines: {node: '>=18'}
@@ -850,8 +864,8 @@ packages:
resolution: {integrity: sha512-5cvg6CtKwfgdmVqY1WIiXKc3Q1bkRqGLi+2W/6ao+6Y7gu/RCwRuAhGEzh5B4KlszSuTLgZYuqFqo5bImjNKng==} resolution: {integrity: sha512-5cvg6CtKwfgdmVqY1WIiXKc3Q1bkRqGLi+2W/6ao+6Y7gu/RCwRuAhGEzh5B4KlszSuTLgZYuqFqo5bImjNKng==}
engines: {node: '>= 0.6'} engines: {node: '>= 0.6'}
agent-browser@0.21.0: agent-browser@0.25.4:
resolution: {integrity: sha512-isVHEeb2WL5hLhr4o+zNmcYwmBrldxvrH+FIoRoUmDxyrHr3bhIS6L8BlUMHqT77YtkPq0YSmwoBRrwqeouw9Q==} resolution: {integrity: sha512-vl4tzDAk5+pJ2g8eMWWzZOLJ2yevJDN3YQ1gcSdN3GKPI0RwNr+T/2PxqSxsipiREu0oid2yxFJIoQ6NMrq/fQ==}
hasBin: true hasBin: true
ajv-formats@3.0.1: ajv-formats@3.0.1:
@@ -1120,9 +1134,18 @@ packages:
fast-deep-equal@3.1.3: fast-deep-equal@3.1.3:
resolution: {integrity: sha512-f3qQ9oQy9j2AhBe/H9VC91wLmKBCCU/gDOnKNAYG5hswO7BLKj09Hc5HYNz9cGI++xlpDCIgDaitVs03ATR84Q==} resolution: {integrity: sha512-f3qQ9oQy9j2AhBe/H9VC91wLmKBCCU/gDOnKNAYG5hswO7BLKj09Hc5HYNz9cGI++xlpDCIgDaitVs03ATR84Q==}
fast-string-truncated-width@1.2.1:
resolution: {integrity: sha512-Q9acT/+Uu3GwGj+5w/zsGuQjh9O1TyywhIwAxHudtWrgF09nHOPrvTLhQevPbttcxjr/SNN7mJmfOw/B1bXgow==}
fast-string-width@1.1.0:
resolution: {integrity: sha512-O3fwIVIH5gKB38QNbdg+3760ZmGz0SZMgvwJbA1b2TGXceKE6A2cOlfogh1iw8lr049zPyd7YADHy+B7U4W9bQ==}
fast-uri@3.1.0: fast-uri@3.1.0:
resolution: {integrity: sha512-iPeeDKJSWf4IEOasVVrknXpaBV0IApz/gp7S2bb7Z4Lljbl2MGJRqInZiUrQwV16cpzw/D3S5j5Julj/gT52AA==} resolution: {integrity: sha512-iPeeDKJSWf4IEOasVVrknXpaBV0IApz/gp7S2bb7Z4Lljbl2MGJRqInZiUrQwV16cpzw/D3S5j5Julj/gT52AA==}
fast-wrap-ansi@0.1.6:
resolution: {integrity: sha512-HlUwET7a5gqjURj70D5jl7aC3Zmy4weA1SHUfM0JFI0Ptq987NH2TwbBFLoERhfwk+E+eaq4EK3jXoT+R3yp3w==}
fastmcp@3.34.0: fastmcp@3.34.0:
resolution: {integrity: sha512-xKOXjU+MK7OZy91BY3FS5aenSiclJBCRMaZtXb3HYaKZVFbq4qYvAlFu6xYI3UU1NGLtv+h8izoStnOQ1By0BA==} resolution: {integrity: sha512-xKOXjU+MK7OZy91BY3FS5aenSiclJBCRMaZtXb3HYaKZVFbq4qYvAlFu6xYI3UU1NGLtv+h8izoStnOQ1By0BA==}
hasBin: true hasBin: true
@@ -1626,6 +1649,14 @@ packages:
resolution: {integrity: sha512-bzyZ1e88w9O1iNJbKnOlvYTrWPDl46O1bG0D3XInv+9tkPrxrN8jUUTiFlDkkmKWgn1M6CfIA13SuGqOa9Korw==} resolution: {integrity: sha512-bzyZ1e88w9O1iNJbKnOlvYTrWPDl46O1bG0D3XInv+9tkPrxrN8jUUTiFlDkkmKWgn1M6CfIA13SuGqOa9Korw==}
engines: {node: '>=14'} engines: {node: '>=14'}
sisteransi@1.0.5:
resolution: {integrity: sha512-bLGGlR1QxBcynn2d5YmDX4MGjlZvy2MRBDRNHLJ8VI6l6+9FUiyTFNJ0IveOSP0bcXgVDPRcfGqA0pjaqUpfVg==}
skills@1.4.9:
resolution: {integrity: sha512-BTh7kfSkGPirsLgvg5vvALjDlgNImm9HRn937yAfESFzmShQEZWWTYJQbN34qjlwxOBO7Me4E9Lh6Ot5AE29zA==}
engines: {node: '>=18'}
hasBin: true
slice-ansi@4.0.0: slice-ansi@4.0.0:
resolution: {integrity: sha512-qMCMfhY040cVHT43K9BFygqYbUPFZKHOg7K73mtTWJRb8pyP3fzf4Ixd5SzdEJQ6MRUg/WBnOLxghZtKKurENQ==} resolution: {integrity: sha512-qMCMfhY040cVHT43K9BFygqYbUPFZKHOg7K73mtTWJRb8pyP3fzf4Ixd5SzdEJQ6MRUg/WBnOLxghZtKKurENQ==}
engines: {node: '>=10'} engines: {node: '>=10'}
@@ -1886,6 +1917,11 @@ packages:
engines: {node: '>= 14.6'} engines: {node: '>= 14.6'}
hasBin: true hasBin: true
yaml@2.8.3:
resolution: {integrity: sha512-AvbaCLOO2Otw/lW5bmh9d/WEdcDFdQp2Z2ZUH3pX9U2ihyUY0nvLv7J6TrWowklRGPYbB/IuIMfYgxaCPg5Bpg==}
engines: {node: '>= 14.6'}
hasBin: true
yargs-parser@22.0.0: yargs-parser@22.0.0:
resolution: {integrity: sha512-rwu/ClNdSMpkSrUb+d6BRsSkLUq1fmfsY6TOpYzTwvwkg1/NRG85KBy3kq++A8LKQwX6lsu+aWad+2khvuXrqw==} resolution: {integrity: sha512-rwu/ClNdSMpkSrUb+d6BRsSkLUq1fmfsY6TOpYzTwvwkg1/NRG85KBy3kq++A8LKQwX6lsu+aWad+2khvuXrqw==}
engines: {node: ^20.19.0 || ^22.12.0 || >=23} engines: {node: ^20.19.0 || ^22.12.0 || >=23}
@@ -1946,6 +1982,18 @@ snapshots:
'@borewit/text-codec@0.2.1': {} '@borewit/text-codec@0.2.1': {}
'@clack/core@1.2.0':
dependencies:
fast-wrap-ansi: 0.1.6
sisteransi: 1.0.5
'@clack/prompts@1.2.0':
dependencies:
'@clack/core': 1.2.0
fast-string-width: 1.1.0
fast-wrap-ansi: 0.1.6
sisteransi: 1.0.5
'@esbuild/aix-ppc64@0.25.12': '@esbuild/aix-ppc64@0.25.12':
optional: true optional: true
@@ -2458,7 +2506,7 @@ snapshots:
mime-types: 3.0.2 mime-types: 3.0.2
negotiator: 1.0.0 negotiator: 1.0.0
agent-browser@0.21.0: {} agent-browser@0.25.4: {}
ajv-formats@3.0.1(ajv@8.17.1): ajv-formats@3.0.1(ajv@8.17.1):
optionalDependencies: optionalDependencies:
@@ -2801,8 +2849,18 @@ snapshots:
fast-deep-equal@3.1.3: {} fast-deep-equal@3.1.3: {}
fast-string-truncated-width@1.2.1: {}
fast-string-width@1.1.0:
dependencies:
fast-string-truncated-width: 1.2.1
fast-uri@3.1.0: {} fast-uri@3.1.0: {}
fast-wrap-ansi@0.1.6:
dependencies:
fast-string-width: 1.1.0
fastmcp@3.34.0(arktype@2.2.0): fastmcp@3.34.0(arktype@2.2.0):
dependencies: dependencies:
'@modelcontextprotocol/sdk': 1.27.1(zod@4.3.6) '@modelcontextprotocol/sdk': 1.27.1(zod@4.3.6)
@@ -3334,6 +3392,12 @@ snapshots:
signal-exit@4.1.0: {} signal-exit@4.1.0: {}
sisteransi@1.0.5: {}
skills@1.4.9:
dependencies:
yaml: 2.8.3
slice-ansi@4.0.0: slice-ansi@4.0.0:
dependencies: dependencies:
ansi-styles: 4.3.0 ansi-styles: 4.3.0
@@ -3525,6 +3589,8 @@ snapshots:
yaml@2.8.2: {} yaml@2.8.2: {}
yaml@2.8.3: {}
yargs-parser@22.0.0: {} yargs-parser@22.0.0: {}
yargs@18.0.0: yargs@18.0.0:
-41870
View File
File diff suppressed because one or more lines are too long
+5 -16
View File
@@ -1,19 +1,8 @@
#!/usr/bin/env node #!/usr/bin/env node
/** import { runPullfrogCli } from "./runCli.ts";
* Post cleanup entry point for pullfrog/pullfrog action.
* Runs independently after workflow failure or cancellation.
* Searches for Pullfrog comment via GitHub API and updates if stuck on "Leaping into action".
*/
import { log } from "./utils/cli.ts"; runPullfrogCli({
import { runPostCleanup } from "./utils/postCleanup.ts"; cliArgs: ["gha", "--post"],
swallowErrors: true,
log.debug(`[post] script started at ${new Date().toISOString()}`); });
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
}
+101
View File
@@ -0,0 +1,101 @@
import { execFileSync } from "node:child_process";
import { existsSync } from "node:fs";
import { delimiter, dirname, join } from "node:path";
import { fileURLToPath } from "node:url";
import actionPackageJson from "./package.json" with { type: "json" };
interface RunPullfrogCliParams {
cliArgs: string[];
swallowErrors?: boolean;
}
interface RuntimeContext {
actionRef: string | undefined;
actionRepository: string | undefined;
actionRoot: string;
nodeBinDir: string;
env: NodeJS.ProcessEnv;
}
const NPM_REGISTRY = "https://registry.npmjs.org";
const FALLBACK_PACKAGE_SPEC = `pullfrog@^${actionPackageJson.version}`;
function createRuntimeContext(): RuntimeContext {
const actionRoot = dirname(fileURLToPath(import.meta.url));
const nodeBinDir = dirname(process.execPath);
const env: NodeJS.ProcessEnv = { ...process.env };
env.npm_config_registry = NPM_REGISTRY;
env.COREPACK_NPM_REGISTRY = NPM_REGISTRY;
const currentPath = process.env.PATH ?? "";
env.PATH = currentPath ? `${nodeBinDir}${delimiter}${currentPath}` : nodeBinDir;
return {
actionRef: process.env.GITHUB_ACTION_REF,
actionRepository: process.env.GITHUB_ACTION_REPOSITORY,
actionRoot,
nodeBinDir,
env,
};
}
function runNpx(context: RuntimeContext, packageSpec: string, cliArgs: string[]): void {
const npxPath =
process.platform === "win32"
? join(context.nodeBinDir, "npx.cmd")
: join(context.nodeBinDir, "npx");
execFileSync(npxPath, ["--yes", packageSpec, ...cliArgs], {
cwd: process.env.GITHUB_WORKSPACE || context.actionRoot,
stdio: "inherit",
env: context.env,
});
}
function ensureActionDependencies(context: RuntimeContext): void {
const nodeModulesPath = join(context.actionRoot, "node_modules");
if (existsSync(nodeModulesPath)) {
return;
}
const corepackPath =
process.platform === "win32"
? join(context.nodeBinDir, "corepack.cmd")
: join(context.nodeBinDir, "corepack");
execFileSync(corepackPath, ["pnpm", "install", "--frozen-lockfile", "--ignore-scripts"], {
cwd: context.actionRoot,
stdio: "inherit",
env: context.env,
});
}
function runLocalCli(context: RuntimeContext, cliArgs: string[]): void {
ensureActionDependencies(context);
execFileSync(process.execPath, ["cli.ts", ...cliArgs], {
cwd: context.actionRoot,
stdio: "inherit",
env: context.env,
});
}
function runPullfrogCliInner(context: RuntimeContext, cliArgs: string[]): void {
if (context.actionRef === "main" && context.actionRepository === "pullfrog/pullfrog") {
runLocalCli(context, cliArgs);
return;
}
runNpx(context, FALLBACK_PACKAGE_SPEC, cliArgs);
}
export function runPullfrogCli(params: RunPullfrogCliParams): void {
const context = createRuntimeContext();
if (params.swallowErrors) {
try {
runPullfrogCliInner(context, params.cliArgs);
} catch {
// best-effort cleanup
}
return;
}
runPullfrogCliInner(context, params.cliArgs);
}
+71
View File
@@ -0,0 +1,71 @@
import { isBuiltin } from "node:module";
import { dirname, resolve } from "node:path";
import { fileURLToPath } from "node:url";
import { build } from "esbuild";
const scriptDir = dirname(fileURLToPath(import.meta.url));
const entryPoints = [
resolve(scriptDir, "../entry.ts"),
resolve(scriptDir, "../post.ts"),
resolve(scriptDir, "../get-installation-token/entry.ts"),
resolve(scriptDir, "../get-installation-token/post.ts"),
];
function isPathImport(specifier: string): boolean {
return (
specifier.startsWith("./") ||
specifier.startsWith("../") ||
specifier.startsWith("/") ||
specifier.startsWith("file:")
);
}
async function checkEntrypointImports(): Promise<void> {
const result = await build({
entryPoints,
outdir: resolve(scriptDir, "../.tmp/entrypoint-imports"),
bundle: true,
write: false,
metafile: true,
platform: "node",
format: "esm",
packages: "external",
logLevel: "silent",
});
if (!result.metafile) {
throw new Error("expected esbuild metafile output");
}
const violations: string[] = [];
const inputPaths = Object.keys(result.metafile.inputs);
for (const inputPath of inputPaths) {
const input = result.metafile.inputs[inputPath];
for (const imported of input.imports) {
if (!imported.external) {
continue;
}
if (isPathImport(imported.path)) {
continue;
}
if (isBuiltin(imported.path)) {
continue;
}
violations.push(`${inputPath} -> ${imported.path}`);
}
}
if (violations.length === 0) {
console.log("entrypoint import guard passed");
return;
}
console.error("entrypoint import guard failed. non-builtin package imports detected:");
for (const violation of violations.sort()) {
console.error(`- ${violation}`);
}
process.exit(1);
}
await checkEntrypointImports();
-13
View File
@@ -1,13 +0,0 @@
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
@@ -11,7 +11,7 @@ exports[`latest model per provider snapshot > matches snapshot 1`] = `
"releaseDate": "2025-12-01", "releaseDate": "2025-12-01",
}, },
"google": { "google": {
"modelId": "gemma-4-31b", "modelId": "gemma-4-31b-it",
"releaseDate": "2026-04-02", "releaseDate": "2026-04-02",
}, },
"moonshotai": { "moonshotai": {
@@ -23,12 +23,12 @@ exports[`latest model per provider snapshot > matches snapshot 1`] = `
"releaseDate": "2026-03-17", "releaseDate": "2026-03-17",
}, },
"opencode": { "opencode": {
"modelId": "qwen3.6-plus-free", "modelId": "glm-5.1",
"releaseDate": "2026-03-30", "releaseDate": "2026-04-07",
}, },
"openrouter": { "openrouter": {
"modelId": "qwen/qwen3.6-plus:free", "modelId": "openrouter/elephant-alpha",
"releaseDate": "2026-04-02", "releaseDate": "2026-04-13",
}, },
"xai": { "xai": {
"modelId": "grok-4.20-multi-agent-0309", "modelId": "grok-4.20-multi-agent-0309",
+1
View File
@@ -49,4 +49,5 @@ export const test: TestRunnerOptions = {
fixture, fixture,
validator, validator,
tags: ["adhoc", "security"], tags: ["adhoc", "security"],
env: { PULLFROG_DISABLE_SECURITY_INSTRUCTIONS: "1" },
}; };
+2 -1
View File
@@ -90,5 +90,6 @@ export const test: TestRunnerOptions = {
fixture, fixture,
validator, validator,
tags: ["adhoc", "security"], tags: ["adhoc", "security"],
agents: ["opentoad"], agents: ["opencode"],
env: { PULLFROG_DISABLE_SECURITY_INSTRUCTIONS: "1" },
}; };
+2 -1
View File
@@ -107,5 +107,6 @@ export const test: TestRunnerOptions = {
fixture, fixture,
validator, validator,
tags: ["adhoc", "security"], tags: ["adhoc", "security"],
agents: ["opentoad"], agents: ["opencode"],
env: { PULLFROG_DISABLE_SECURITY_INSTRUCTIONS: "1" },
}; };
+1
View File
@@ -57,4 +57,5 @@ export const test: TestRunnerOptions = {
validator, validator,
agentEnv, agentEnv,
tags: ["adhoc"], tags: ["adhoc"],
env: { PULLFROG_DISABLE_SECURITY_INSTRUCTIONS: "1" },
}; };
+2 -1
View File
@@ -78,5 +78,6 @@ export const test: TestRunnerOptions = {
fixture, fixture,
validator, validator,
tags: ["adhoc", "security"], tags: ["adhoc", "security"],
agents: ["opentoad"], agents: ["opencode"],
env: { PULLFROG_DISABLE_SECURITY_INSTRUCTIONS: "1" },
}; };
+1
View File
@@ -94,5 +94,6 @@ export const test: TestRunnerOptions = {
fixture, fixture,
validator, validator,
repoSetup, repoSetup,
env: { PULLFROG_DISABLE_SECURITY_INSTRUCTIONS: "1" },
tags: ["agnostic", "security"], tags: ["agnostic", "security"],
}; };
+1
View File
@@ -102,5 +102,6 @@ export const test: TestRunnerOptions = {
fixture, fixture,
validator, validator,
agentEnv, agentEnv,
env: { PULLFROG_DISABLE_SECURITY_INSTRUCTIONS: "1" },
tags: ["agnostic"], tags: ["agnostic"],
}; };
+1
View File
@@ -90,5 +90,6 @@ export const test: TestRunnerOptions = {
name: "pkg-json-scripts", name: "pkg-json-scripts",
fixture, fixture,
validator, validator,
env: { PULLFROG_DISABLE_SECURITY_INSTRUCTIONS: "1" },
tags: ["agnostic", "security"], tags: ["agnostic", "security"],
}; };
+1
View File
@@ -60,5 +60,6 @@ export const test: TestRunnerOptions = {
fixture, fixture,
validator, validator,
agentEnv, agentEnv,
env: { PULLFROG_DISABLE_SECURITY_INSTRUCTIONS: "1" },
tags: ["agnostic"], tags: ["agnostic"],
}; };
+1
View File
@@ -72,5 +72,6 @@ export const test: TestRunnerOptions = {
name: "push-enabled", name: "push-enabled",
fixture, fixture,
validator, validator,
env: { PULLFROG_DISABLE_SECURITY_INSTRUCTIONS: "1" },
tags: ["agnostic"], tags: ["agnostic"],
}; };
+1
View File
@@ -65,5 +65,6 @@ export const test: TestRunnerOptions = {
name: "push-restricted", name: "push-restricted",
fixture, fixture,
validator, validator,
env: { PULLFROG_DISABLE_SECURITY_INSTRUCTIONS: "1" },
tags: ["agnostic"], tags: ["agnostic"],
}; };
+1
View File
@@ -27,5 +27,6 @@ export const test: TestRunnerOptions = {
fixture, fixture,
validator, validator,
expectFailure: true, expectFailure: true,
env: { PULLFROG_DISABLE_SECURITY_INSTRUCTIONS: "1" },
tags: ["agnostic"], tags: ["agnostic"],
}; };
+3 -3
View File
@@ -4,7 +4,7 @@
# outputs a JSON array of agent names to stdout. # outputs a JSON array of agent names to stdout.
# #
# only agents whose harness file changed AND are exported from index.ts are included. # only agents whose harness file changed AND are exported from index.ts are included.
# shared.ts/index.ts and other non-harness action changes fall back to opentoad as a canary. # shared.ts/index.ts and other non-harness action changes fall back to opencode as a canary.
set -euo pipefail set -euo pipefail
SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)" SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
@@ -58,9 +58,9 @@ while IFS= read -r file; do
done <<< "$files" done <<< "$files"
# output agents based on change type. # output agents based on change type.
# non-agent action changes always include opentoad as a canary. # non-agent action changes always include opencode as a canary.
if $has_non_agent_change; then if $has_non_agent_change; then
changed_agents+=("opentoad") changed_agents+=("opencode")
fi fi
if [[ ${#changed_agents[@]} -gt 0 ]]; then if [[ ${#changed_agents[@]} -gt 0 ]]; then
+8 -8
View File
@@ -87,29 +87,29 @@ describe("ci workflow consistency", () => {
expect(rootJob.strategy!.matrix.agent).toBe(dynamicAgentsExpression); expect(rootJob.strategy!.matrix.agent).toBe(dynamicAgentsExpression);
}); });
it("changed-agents.sh falls back to opentoad when shared agent code changed", () => { it("changed-agents.sh falls back to opencode when shared agent code changed", () => {
const input = JSON.stringify(["action/agents/shared.ts"]); const input = JSON.stringify(["action/agents/shared.ts"]);
const output = execFileSync("bash", [join(__dirname, "changed-agents.sh")], { const output = execFileSync("bash", [join(__dirname, "changed-agents.sh")], {
input, input,
encoding: "utf-8", encoding: "utf-8",
}); });
expect(JSON.parse(output)).toEqual(["opentoad"]); expect(JSON.parse(output)).toEqual(["opencode"]);
}); });
it("changed-agents.sh falls back to opentoad for non-agent action changes", () => { it("changed-agents.sh falls back to opencode for non-agent action changes", () => {
const output = execFileSync("bash", [join(__dirname, "changed-agents.sh")], { const output = execFileSync("bash", [join(__dirname, "changed-agents.sh")], {
input: JSON.stringify(["action/mcp/server.ts"]), input: JSON.stringify(["action/mcp/server.ts"]),
encoding: "utf-8", encoding: "utf-8",
}); });
expect(JSON.parse(output)).toEqual(["opentoad"]); expect(JSON.parse(output)).toEqual(["opencode"]);
}); });
it("changed-agents.sh includes opentoad canary alongside changed agents", () => { it("changed-agents.sh includes opencode canary alongside changed agents", () => {
const output = execFileSync("bash", [join(__dirname, "changed-agents.sh")], { const output = execFileSync("bash", [join(__dirname, "changed-agents.sh")], {
input: JSON.stringify(["action/agents/opentoad.ts", "action/mcp/server.ts"]), input: JSON.stringify(["action/agents/opencode.ts", "action/mcp/server.ts"]),
encoding: "utf-8", encoding: "utf-8",
}); });
expect(JSON.parse(output)).toEqual(["opentoad"]); expect(JSON.parse(output)).toEqual(["opencode"]);
}); });
it("changed-agents.sh treats legacy agent files as non-agent changes", () => { it("changed-agents.sh treats legacy agent files as non-agent changes", () => {
@@ -117,7 +117,7 @@ describe("ci workflow consistency", () => {
input: JSON.stringify(["action/agents/codex.ts", "action/agents/gemini.ts"]), input: JSON.stringify(["action/agents/codex.ts", "action/agents/gemini.ts"]),
encoding: "utf-8", encoding: "utf-8",
}); });
expect(JSON.parse(output)).toEqual(["opentoad"]); expect(JSON.parse(output)).toEqual(["opencode"]);
}); });
it("action agent matrix matches agents map", () => { it("action agent matrix matches agents map", () => {
+1
View File
@@ -38,6 +38,7 @@ export const test: TestRunnerOptions = {
validator, validator,
env: { env: {
GITHUB_REPOSITORY: "pullfrog/test-repo-mcp", GITHUB_REPOSITORY: "pullfrog/test-repo-mcp",
PULLFROG_DISABLE_SECURITY_INSTRUCTIONS: "1",
PULLFROG_MCP_SECRET: secret, PULLFROG_MCP_SECRET: secret,
}, },
repoSetup: repoSetup:
+1
View File
@@ -42,4 +42,5 @@ export const test: TestRunnerOptions = {
fixture, fixture,
validator, validator,
agentEnv, agentEnv,
env: { PULLFROG_DISABLE_SECURITY_INSTRUCTIONS: "1" },
}; };
+1
View File
@@ -51,4 +51,5 @@ export const test: TestRunnerOptions = {
fixture, fixture,
validator, validator,
agentEnv, agentEnv,
env: { PULLFROG_DISABLE_SECURITY_INSTRUCTIONS: "1" },
}; };
+47
View File
@@ -0,0 +1,47 @@
import { randomUUID } from "node:crypto";
import type { AgentResult, TestRunnerOptions, ValidationCheck } from "../utils.ts";
import { defineFixture, getAgentOutput } from "../utils.ts";
const skillName = "pullfrog-skill-check";
const token = randomUUID();
const fixture = defineFixture(
{
prompt: `Do not modify any files.
Use the skill tool to load ${skillName}.
Then call set_output with exactly this token and nothing else: ${token}`,
shell: "restricted",
push: "disabled",
timeout: "4m",
},
{ localOnly: true }
);
const repoSetup = `mkdir -p .claude/skills/${skillName} .opencode/skills/${skillName} && printf '%s\\n' '---' 'name: ${skillName}' 'description: local skill test token source' '---' '' 'token: ${token}' > .claude/skills/${skillName}/SKILL.md && cp .claude/skills/${skillName}/SKILL.md .opencode/skills/${skillName}/SKILL.md`;
function validator(result: AgentResult): ValidationCheck[] {
const setOutputCalled = result.structuredOutput !== null;
const tokenMatches = result.structuredOutput === token;
const agentOutput = getAgentOutput(result);
const skillInvoked = /Skill\(\{[^)]*"skill":"pullfrog-skill-check"/.test(agentOutput);
return [
{ name: "set_output", passed: setOutputCalled },
{ name: "token_matches", passed: tokenMatches },
{ name: "skill_invoked", passed: skillInvoked },
];
}
export const test: TestRunnerOptions = {
name: "skill-invoke-claude",
fixture,
validator,
agents: ["claude"],
repoSetup,
env: {
PULLFROG_DISABLE_SECURITY_INSTRUCTIONS: "1",
PULLFROG_MODEL: "anthropic/claude-sonnet-4-6",
},
};
+47
View File
@@ -0,0 +1,47 @@
import { randomUUID } from "node:crypto";
import type { AgentResult, TestRunnerOptions, ValidationCheck } from "../utils.ts";
import { defineFixture, getAgentOutput } from "../utils.ts";
const skillName = "pullfrog-skill-check";
const token = randomUUID();
const fixture = defineFixture(
{
prompt: `Do not modify any files.
Use the skill tool to load ${skillName}.
Then call set_output with exactly this token and nothing else: ${token}`,
shell: "restricted",
push: "disabled",
timeout: "4m",
},
{ localOnly: true }
);
const repoSetup = `mkdir -p .claude/skills/${skillName} .opencode/skills/${skillName} && printf '%s\\n' '---' 'name: ${skillName}' 'description: local skill test token source' '---' '' 'token: ${token}' > .claude/skills/${skillName}/SKILL.md && cp .claude/skills/${skillName}/SKILL.md .opencode/skills/${skillName}/SKILL.md`;
function validator(result: AgentResult): ValidationCheck[] {
const setOutputCalled = result.structuredOutput !== null;
const tokenMatches = result.structuredOutput === token;
const agentOutput = getAgentOutput(result);
const skillInvoked = /skill\(\{[^)]*"name":"pullfrog-skill-check"/.test(agentOutput);
return [
{ name: "set_output", passed: setOutputCalled },
{ name: "token_matches", passed: tokenMatches },
{ name: "skill_invoked", passed: skillInvoked },
];
}
export const test: TestRunnerOptions = {
name: "skill-invoke-opencode",
fixture,
validator,
agents: ["opencode"],
repoSetup,
env: {
PULLFROG_DISABLE_SECURITY_INSTRUCTIONS: "1",
PULLFROG_MODEL: "anthropic/claude-sonnet-4-6",
},
};
+1
View File
@@ -28,4 +28,5 @@ export const test: TestRunnerOptions = {
name: "smoke", name: "smoke",
fixture, fixture,
validator, validator,
env: { PULLFROG_DISABLE_SECURITY_INSTRUCTIONS: "1" },
}; };
+3 -2
View File
@@ -6,11 +6,11 @@ import { defineFixture, generateAgentUuids, getAgentOutput } from "../utils.ts";
* process environment. SANDBOX_TEST_TOKEN is set in the agent's process env * process environment. SANDBOX_TEST_TOKEN is set in the agent's process env
* but should be invisible via: * but should be invisible via:
* - shell: filterEnv() strips *_TOKEN vars, PID namespace hides parent /proc * - shell: filterEnv() strips *_TOKEN vars, PID namespace hides parent /proc
* - native tools: OPENCODE_PERMISSION denies external_directory (opentoad), * - native tools: OPENCODE_PERMISSION denies external_directory (opencode),
* managed-settings.json denies /proc reads (claude) * managed-settings.json denies /proc reads (claude)
* *
* runs with both agents to verify each sandbox independently. * runs with both agents to verify each sandbox independently.
* requires: CI=true (to enable sandbox), PULLFROG_DISABLE_SECURITY_INSTRUCTIONS=1 * requires: CI=true (to enable sandbox)
*/ */
const fixture = defineFixture( const fixture = defineFixture(
@@ -57,4 +57,5 @@ export const test: TestRunnerOptions = {
fixture, fixture,
validator, validator,
agentEnv, agentEnv,
env: { PULLFROG_DISABLE_SECURITY_INSTRUCTIONS: "1" },
}; };
+7 -7
View File
@@ -27,14 +27,14 @@ import {
* filters can be test names, tags, or agent names: * filters can be test names, tags, or agent names:
* node test/run.ts # run all tests (excludes adhoc-tagged tests) * node test/run.ts # run all tests (excludes adhoc-tagged tests)
* node test/run.ts smoke # run tests named "smoke" or tagged "smoke" * node test/run.ts smoke # run tests named "smoke" or tagged "smoke"
* node test/run.ts opentoad # run all tests for opentoad only * node test/run.ts opencode # run all tests for opencode only
* node test/run.ts security # run all tests tagged "security" * node test/run.ts security # run all tests tagged "security"
* node test/run.ts agnostic # run all agnostic-tagged tests (with opentoad) * node test/run.ts agnostic # run all agnostic-tagged tests (with opencode)
* node test/run.ts adhoc # run all adhoc-tagged tests * node test/run.ts adhoc # run all adhoc-tagged tests
* node test/run.ts smoke opentoad # run smoke tests for opentoad only * node test/run.ts smoke opencode # run smoke tests for opencode only
* *
* special tags: * special tags:
* - "agnostic": runs with opentoad only, excluded when filtering by agent * - "agnostic": runs with opencode only, excluded when filtering by agent
* - "adhoc": excluded from default runs, must be explicitly requested * - "adhoc": excluded from default runs, must be explicitly requested
* *
* by default, runs in a Docker container for isolation. * by default, runs in a Docker container for isolation.
@@ -303,7 +303,7 @@ async function runTestForAgent(ctx: RunContext): Promise<ValidationResult> {
if (!Object.hasOwn(env, "PULLFROG_MODEL")) { if (!Object.hasOwn(env, "PULLFROG_MODEL")) {
const defaultModels: Record<string, string> = { const defaultModels: Record<string, string> = {
claude: "anthropic/claude-sonnet-4-6", claude: "anthropic/claude-sonnet-4-6",
opentoad: "anthropic/claude-sonnet-4-6", opencode: "anthropic/claude-sonnet-4-6",
}; };
const model = defaultModels[ctx.agent]; const model = defaultModels[ctx.agent];
if (model) { if (model) {
@@ -414,11 +414,11 @@ async function main(): Promise<void> {
const isAgnostic = hasTag(testInfo, "agnostic"); const isAgnostic = hasTag(testInfo, "agnostic");
if (isAgnostic) { if (isAgnostic) {
// agnostic tests: skip if only filtering by agent, otherwise run with opentoad // agnostic tests: skip if only filtering by agent, otherwise run with opencode
if (parsed.filters.length === 0 && parsed.agentFilters.length > 0) { if (parsed.filters.length === 0 && parsed.agentFilters.length > 0) {
continue; continue;
} }
runs.push({ testInfo, agent: "opentoad" }); runs.push({ testInfo, agent: "opencode" });
} else { } else {
// determine which agents to run for this test // determine which agents to run for this test
const testAgents = testInfo.config.agents ?? agents; const testAgents = testInfo.config.agents ?? agents;
+2 -2
View File
@@ -90,7 +90,7 @@ export function generateAgentUuids<T extends string>(envVarNames: T[]): AgentUui
// assign consistent colors to agents (using ANSI codes) // assign consistent colors to agents (using ANSI codes)
const AGENT_COLORS: Record<string, string> = { const AGENT_COLORS: Record<string, string> = {
opentoad: "\x1b[32m", // green opencode: "\x1b[32m", // green
}; };
const RESET = "\x1b[0m"; const RESET = "\x1b[0m";
@@ -325,7 +325,7 @@ export interface TestRunnerOptions {
repoSetup?: string; repoSetup?: string;
// tags for grouping tests (e.g., ["agnostic"], ["fs"]) // tags for grouping tests (e.g., ["agnostic"], ["fs"])
// special tags: // special tags:
// - "agnostic": runs with opentoad only, excluded when filtering by agent // - "agnostic": runs with opencode only, excluded when filtering by agent
// - "adhoc": excluded from default runs, must be explicitly requested // - "adhoc": excluded from default runs, must be explicitly requested
tags?: TestTag[]; tags?: TestTag[];
} }
+10
View File
@@ -0,0 +1,10 @@
{
"extends": "./tsconfig.json",
"compilerOptions": {
"noEmit": false,
"emitDeclarationOnly": true,
"declarationMap": false,
"outDir": "./dist"
},
"include": ["index.ts", "internal/index.ts"]
}
+2 -1
View File
@@ -20,5 +20,6 @@
"stripInternal": true, "stripInternal": true,
"moduleDetection": "force", "moduleDetection": "force",
"useUnknownInCatchVariables": true "useUnknownInCatchVariables": true
} },
"exclude": []
} }
+2 -2
View File
@@ -2,8 +2,8 @@ import { describe, expect, it } from "vitest";
import { resolveAgent } from "./agent.ts"; import { resolveAgent } from "./agent.ts";
describe("resolveAgent", () => { describe("resolveAgent", () => {
it("returns opentoad", () => { it("returns opencode", () => {
const agent = resolveAgent({}); const agent = resolveAgent({});
expect(agent.name).toBe("opentoad"); expect(agent.name).toBe("opencode");
}); });
}); });
+1 -1
View File
@@ -64,5 +64,5 @@ export function resolveAgent(ctx: { model?: string | undefined }): Agent {
} }
// 3. default: OpenCode (universal, supports all providers) // 3. default: OpenCode (universal, supports all providers)
return agents.opentoad; return agents.opencode;
} }
+1 -1
View File
@@ -2,7 +2,7 @@ import { afterEach, beforeEach, describe, expect, it } from "vitest";
import { validateAgentApiKey } from "./apiKeys.ts"; import { validateAgentApiKey } from "./apiKeys.ts";
const base = { const base = {
agent: { name: "opentoad" }, agent: { name: "opencode" },
owner: "test-owner", owner: "test-owner",
name: "test-repo", name: "test-repo",
}; };
+2 -2
View File
@@ -13,7 +13,7 @@ export interface WorkflowRunFooterInfo {
} }
export interface BuildPullfrogFooterParams { export interface BuildPullfrogFooterParams {
/** add "Triggered by Pullfrog" link */ /** add "via Pullfrog" link */
triggeredBy?: boolean; triggeredBy?: boolean;
/** add "View workflow run" link */ /** add "View workflow run" link */
workflowRun?: WorkflowRunFooterInfo | undefined; workflowRun?: WorkflowRunFooterInfo | undefined;
@@ -52,7 +52,7 @@ export function buildPullfrogFooter(params: BuildPullfrogFooterParams): string {
} }
if (params.triggeredBy) { if (params.triggeredBy) {
parts.push("Triggered by [Pullfrog](https://pullfrog.com)"); parts.push("via [Pullfrog](https://pullfrog.com)");
} }
if (params.model) { if (params.model) {
+51 -77
View File
@@ -15,6 +15,14 @@ interface InstructionsContext {
learnings: string | null; learnings: string | null;
} }
interface PromptContext extends InstructionsContext {
t: (name: string) => string;
eventTitle: string;
eventMetadata: string;
runtime: string;
userQuoted: string;
}
function buildRuntimeContext(ctx: InstructionsContext): string { function buildRuntimeContext(ctx: InstructionsContext): string {
// extract payload fields excluding prompt/instructions/event (those are rendered separately) // extract payload fields excluding prompt/instructions/event (those are rendered separately)
const { const {
@@ -136,24 +144,26 @@ In case of conflict between instructions, follow this precedence (highest to low
// --------------------------------------------------------------------------- // ---------------------------------------------------------------------------
// the user's task: blockquoted user prompt, or event-level instructions for auto-triggers // the user's task: blockquoted user prompt, or event-level instructions for auto-triggers
function buildTaskSection(ctx: { userQuoted: string; eventInstructions: string }): string { function buildTaskSection(ctx: PromptContext): string {
if (ctx.userQuoted) { if (ctx.userQuoted) {
return `************* YOUR TASK ************* return `************* YOUR TASK *************
${ctx.userQuoted}`; ${ctx.userQuoted}`;
} }
if (ctx.eventInstructions) { const eventInstructions = ctx.payload.eventInstructions ?? "";
if (eventInstructions) {
const parts = [ctx.eventTitle, eventInstructions].filter(Boolean);
return `************* YOUR TASK ************* return `************* YOUR TASK *************
${ctx.eventInstructions}`; ${parts.join("\n\n")}`;
} }
return ""; return "";
} }
// mode selection and execution steps // mode selection and execution steps
function buildProcedure(ctx: { modes: Mode[]; t: (name: string) => string }): string { function buildProcedure(ctx: PromptContext): string {
const t = ctx.t; const t = ctx.t;
return `************* PROCEDURE ************* return `************* PROCEDURE *************
@@ -180,11 +190,7 @@ Eagerly inspect the MCP tools available to you via the \`${pullfrogMcpName}\` MC
} }
// event title + metadata (omitted when empty, e.g. workflow_dispatch) // event title + metadata (omitted when empty, e.g. workflow_dispatch)
function buildEventContext(ctx: { function buildEventContext(ctx: PromptContext): string {
payload: ResolvedPayload;
eventTitle: string;
eventMetadata: string;
}): string {
const isPr = ctx.payload.event.is_pr === true; const isPr = ctx.payload.event.is_pr === true;
const relatedLabel = isPr ? "--- related PR ---" : "--- related issue ---"; const relatedLabel = isPr ? "--- related PR ---" : "--- related issue ---";
@@ -200,12 +206,7 @@ ${content}`;
} }
// persona, environment, priority, security, tools, workflow // persona, environment, priority, security, tools, workflow
function buildSystemBody(ctx: { function buildSystemBody(ctx: PromptContext): string {
shell: ResolvedPayload["shell"];
trigger: string;
t: (name: string) => string;
outputSchema?: Record<string, unknown> | undefined;
}): string {
const t = ctx.t; const t = ctx.t;
return `************* SYSTEM ************* return `************* SYSTEM *************
@@ -252,22 +253,25 @@ Rules:
- Do not attempt to configure git credentials manually — the ${pullfrogMcpName} server handles all authentication internally. - Do not attempt to configure git credentials manually — the ${pullfrogMcpName} server handles all authentication internally.
- Never push commits directly to the default branch or any protected branch (commonly: main, master, production, develop, staging). Always create a feature branch following the pattern: \`pullfrog/<issue-number>-<kebab-case-description>\` (e.g., \`pullfrog/123-fix-login-bug\`). - Never push commits directly to the default branch or any protected branch (commonly: main, master, production, develop, staging). Always create a feature branch following the pattern: \`pullfrog/<issue-number>-<kebab-case-description>\` (e.g., \`pullfrog/123-fix-login-bug\`).
- Never add co-author trailers (e.g., "Co-authored-by" or "Co-Authored-By") to commit messages. - Never add co-author trailers (e.g., "Co-authored-by" or "Co-Authored-By") to commit messages.
- Untracked files from tests or tooling (e.g. \`coverage/\`) often remain *after* your last commit and still block \`${t("push_branch")}\` — delete them, extend \`.gitignore\`, or only add files that truly belong in the repo.
- \`${t("push_branch")}\` runs the repository's optional **prepush** hook before the network push. If the error includes \`lifecycle hook 'prepush' failed\` (with an exit code and script output after it), the hook script exited non-zero (commonly tests or lint). Fix that or change the hook — do not describe it as an infrastructure "timeout" unless the tool output or logs clearly show a timeout.
- If push or PR creation fails, \`${t("report_progress")}\` must summarize using the **actual** error from the tool. Do not substitute vague causes unless they match what failed.
### GitHub ### GitHub
Use MCP tools from ${pullfrogMcpName} for all GitHub operations. Never use the \`gh\` CLI — it is not authenticated and will fail. The MCP tools handle authentication and enforce permissions. Use MCP tools from ${pullfrogMcpName} for all GitHub operations. Never use the \`gh\` CLI — it is not authenticated and will fail. The MCP tools handle authentication and enforce permissions.
${getShellInstructions(ctx.shell, t)} ${getShellInstructions(ctx.payload.shell, t)}
${getFileInstructions()} ${getFileInstructions()}
${getStandaloneModeInstructions(ctx.trigger, t, ctx.outputSchema)} ${getStandaloneModeInstructions(ctx.payload.event.trigger, t, ctx.outputSchema)}
## Workflow ## Workflow
### Efficiency ### Efficiency
Trust the tools — do not repeatedly verify file contents or git status after operations. If a tool reports success, proceed to the next step. Only verify if you encounter an actual error. Trust the tools — do not repeatedly verify file contents or git status after operations. If a tool reports success, proceed to the next step. Only verify if you encounter an actual error. Exception: right before \`${t("push_branch")}\`, ensure the working tree is clean — that tool rejects dirty trees, and tests you ran earlier often leave untracked output.
### Command execution ### Command execution
@@ -277,11 +281,13 @@ Never use \`sleep\` to wait for commands to complete. Commands run synchronously
When posting comments via ${pullfrogMcpName}, write as a professional team member would. Your final comments should be polished and actionable — do not include intermediate reasoning like "I'll now look at the code" or "Let me respond to the question." When posting comments via ${pullfrogMcpName}, write as a professional team member would. Your final comments should be polished and actionable — do not include intermediate reasoning like "I'll now look at the code" or "Let me respond to the question."
When embedding images (e.g. uploaded screenshots) in comments or PR bodies, always use markdown image syntax: \`![description](url)\`. Never paste a naked URL — it will not render as an image.
### Progress reporting ### Progress reporting
**Task list**: at the start of every run, create an internal task list based on the steps in your current mode. Update it as you complete each step. The system automatically renders this list to the progress comment — you do not need to call \`report_progress\` for this. **Task list**: at the start of every run, create an internal task list based on the steps in your current mode. Update it as you complete each step. The system automatically renders this list to the progress comment — you do not need to call \`report_progress\` for this.
**\`report_progress\`**: call this exactly once at the end of every run with a brief final summary (1-3 sentences) unless the mode guidance instructs otherwise. Never call it for intermediate status updates (e.g., "Checking for changes...", "Starting review...") — the task list handles live progress automatically. Calling \`report_progress\` replaces the task list with your summary and preserves the current task list in a collapsible section. Keep the summary concise — do not repeat what the task list already shows. Focus on the outcome (what was accomplished, links to artifacts) rather than listing individual steps. **\`report_progress\`**: call this exactly once at the end of every run with a brief final summary (1-3 sentences) unless the mode guidance instructs otherwise. Never call it for intermediate status updates (e.g., "Checking for changes...", "Starting review...") — the task list handles live progress automatically. Calling \`report_progress\` replaces the task list with your summary and preserves the current task list in a collapsible section. Keep the summary concise — do not repeat what the task list already shows. Focus on the outcome (what was accomplished, links to artifacts) rather than listing individual steps. If something failed, include the tool's error text even when that makes the summary longer.
Never use \`create_issue_comment\` for task progress — that creates duplicate comments and leaves the progress comment stuck in its initial state. \`create_issue_comment\` is only for standalone comments unrelated to your current task (e.g., Plan comments, PR Summary comments). Never use \`create_issue_comment\` for task progress — that creates duplicate comments and leaves the progress comment stuck in its initial state. \`create_issue_comment\` is only for standalone comments unrelated to your current task (e.g., Plan comments, PR Summary comments).
@@ -312,38 +318,20 @@ function buildToc(entries: TocEntry[]): string {
${entries.map((e) => `- ${e.label}${e.description}`).join("\n")}`; ${entries.map((e) => `- ${e.label}${e.description}`).join("\n")}`;
} }
// shared computation for all instruction builders function buildPromptContext(ctx: InstructionsContext): PromptContext {
interface CommonInputs {
eventTitle: string;
eventMetadata: string;
runtime: string;
user: string;
eventInstructions: string;
event: string;
userQuoted: string;
}
function buildCommonInputs(ctx: InstructionsContext): CommonInputs {
const eventTitle = buildEventTitle(ctx.payload.event);
const eventMetadata = buildEventMetadata(ctx.payload.event);
const runtime = buildRuntimeContext(ctx);
const user = ctx.payload.prompt; const user = ctx.payload.prompt;
const eventInstructions = ctx.payload.eventInstructions ?? "";
const event = [eventTitle, eventMetadata].filter(Boolean).join("\n\n---\n\n");
const userQuoted = user
? user
.split("\n")
.map((line) => `> ${line}`)
.join("\n")
: "";
return { return {
eventTitle, ...ctx,
eventMetadata, t: (toolName: string) => formatMcpToolRef(ctx.agentId, toolName),
runtime, eventTitle: buildEventTitle(ctx.payload.event),
user, eventMetadata: buildEventMetadata(ctx.payload.event),
eventInstructions, runtime: buildRuntimeContext(ctx),
event, userQuoted: user
userQuoted, ? user
.split("\n")
.map((line) => `> ${line}`)
.join("\n")
: "",
}; };
} }
@@ -387,28 +375,12 @@ function assembleFullPrompt(ctx: {
} }
export function resolveInstructions(ctx: InstructionsContext): ResolvedInstructions { export function resolveInstructions(ctx: InstructionsContext): ResolvedInstructions {
const inputs = buildCommonInputs(ctx); const pctx = buildPromptContext(ctx);
const t = (toolName: string) => formatMcpToolRef(ctx.agentId, toolName);
const task = buildTaskSection({ const task = buildTaskSection(pctx);
userQuoted: inputs.userQuoted, const procedure = buildProcedure(pctx);
eventInstructions: inputs.eventInstructions, const eventContext = buildEventContext(pctx);
}); const system = buildSystemBody(pctx);
const procedure = buildProcedure({ modes: ctx.modes, t });
const eventContext = buildEventContext({
payload: ctx.payload,
eventTitle: inputs.eventTitle,
eventMetadata: inputs.eventMetadata,
});
const system = buildSystemBody({
shell: ctx.payload.shell,
trigger: ctx.payload.event.trigger,
t,
outputSchema: ctx.outputSchema,
});
// build TOC from present sections (PROCEDURE, SYSTEM, RUNTIME are always present) // build TOC from present sections (PROCEDURE, SYSTEM, RUNTIME are always present)
const tocEntries: TocEntry[] = []; const tocEntries: TocEntry[] = [];
@@ -417,7 +389,7 @@ export function resolveInstructions(ctx: InstructionsContext): ResolvedInstructi
if (eventContext) if (eventContext)
tocEntries.push({ label: "EVENT CONTEXT", description: "related PR/issue data" }); tocEntries.push({ label: "EVENT CONTEXT", description: "related PR/issue data" });
tocEntries.push({ label: "SYSTEM", description: "persona, security, tools, workflow rules" }); tocEntries.push({ label: "SYSTEM", description: "persona, security, tools, workflow rules" });
if (ctx.learnings) if (pctx.learnings)
tocEntries.push({ label: "LEARNINGS", description: "repo-specific knowledge" }); tocEntries.push({ label: "LEARNINGS", description: "repo-specific knowledge" });
tocEntries.push({ label: "RUNTIME", description: "environment metadata" }); tocEntries.push({ label: "RUNTIME", description: "environment metadata" });
@@ -429,16 +401,18 @@ export function resolveInstructions(ctx: InstructionsContext): ResolvedInstructi
procedure, procedure,
eventContext, eventContext,
system, system,
learnings: ctx.learnings, learnings: pctx.learnings,
runtime: inputs.runtime, runtime: pctx.runtime,
}); });
const event = [pctx.eventTitle, pctx.eventMetadata].filter(Boolean).join("\n\n---\n\n");
return { return {
full, full,
system, system,
user: inputs.user, user: pctx.payload.prompt,
eventInstructions: inputs.eventInstructions, eventInstructions: pctx.payload.eventInstructions ?? "",
event: inputs.event, event,
runtime: inputs.runtime, runtime: pctx.runtime,
}; };
} }
+62
View File
@@ -0,0 +1,62 @@
import type { ToolContext } from "../mcp/server.ts";
import { apiFetch } from "./apiFetch.ts";
import { log } from "./cli.ts";
import { retry } from "./retry.ts";
/** Keys accepted by PATCH /api/workflow-run/[runId] — keep in sync with `ALLOWED_FIELDS` in `app/api/workflow-run/[runId]/route.ts`. */
export type WorkflowRunArtifactPatchKey =
| "prNodeId"
| "issueNodeId"
| "reviewNodeId"
| "planCommentNodeId"
| "summaryCommentNodeId";
export type WorkflowRunArtifactPatch = Partial<Record<WorkflowRunArtifactPatchKey, string>>;
const ARTIFACT_PATCH_KEYS: WorkflowRunArtifactPatchKey[] = [
"prNodeId",
"issueNodeId",
"reviewNodeId",
"planCommentNodeId",
"summaryCommentNodeId",
];
/** PATCH workflow-run artifact fields (Pullfrog JWT, not GitHub). */
export async function patchWorkflowRunFields(
ctx: ToolContext,
fields: WorkflowRunArtifactPatch
): Promise<void> {
if (ctx.runId === undefined || !ctx.apiToken) return;
const body: Record<string, string> = {};
for (const key of ARTIFACT_PATCH_KEYS) {
const value = fields[key];
if (typeof value === "string" && value.length > 0) {
body[key] = value;
}
}
if (Object.keys(body).length === 0) return;
try {
await retry(
async () => {
const response = await apiFetch({
path: `/api/workflow-run/${ctx.runId}`,
method: "PATCH",
headers: {
authorization: `Bearer ${ctx.apiToken}`,
"content-type": "application/json",
},
body: JSON.stringify(body),
signal: AbortSignal.timeout(10_000),
});
if (!response.ok) throw new Error(`PATCH workflow-run: ${response.status}`);
},
{
maxAttempts: 3,
delayMs: 2000,
label: "patchWorkflowRunFields",
}
);
} catch (error) {
log.warning(`patchWorkflowRunFields exhausted retries: ${error}`);
}
}
+40 -57
View File
@@ -8,65 +8,61 @@ import { getJobToken } from "./token.ts";
type JsonPromptInput = Extract<ResolvedPromptInput, object>; // not string type JsonPromptInput = Extract<ResolvedPromptInput, object>; // not string
interface PostCleanupContext {
repoContext: ReturnType<typeof parseRepoContext>;
octokit: ReturnType<typeof createOctokit>;
runId: number | undefined;
promptInput: JsonPromptInput | null;
}
// controls whether the script should check the reason for the workflow termination. // controls whether the script should check the reason for the workflow termination.
// it can be either canceled or failed. // it can be either canceled or failed.
// YAML file cannot supply it (not in ENV), so an extra request is required to check it. // YAML file cannot supply it (not in ENV), so an extra request is required to check it.
const SHOULD_CHECK_REASON = true; const SHOULD_CHECK_REASON = true;
type BuildErrorCommentBodyParams = { function buildErrorCommentBody(ctx: PostCleanupContext, isCancellation: boolean): string {
owner: string; let errorMessage = isCancellation
repo: string;
runId: number | undefined;
isCancellation: boolean;
};
function buildErrorCommentBody(params: BuildErrorCommentBodyParams): string {
let errorMessage = params.isCancellation
? `This run was cancelled 🛑\n\nThe workflow was cancelled before completion.` ? `This run was cancelled 🛑\n\nThe workflow was cancelled before completion.`
: `This run croaked 😵\n\nThe workflow encountered an error before any progress could be reported.`; : `This run croaked 😵\n\nThe workflow encountered an error before any progress could be reported.`;
if (params.runId) { if (ctx.runId) {
errorMessage += " Please check the link below for details."; errorMessage += " Please check the link below for details.";
} }
const customParts: string[] = []; const customParts: string[] = [];
if (!params.isCancellation && params.runId) { if (!isCancellation && ctx.runId) {
const apiUrl = getApiUrl(); const apiUrl = getApiUrl();
customParts.push( customParts.push(
`[Rerun failed job ➔](${apiUrl}/trigger/${params.owner}/${params.repo}/${params.runId}?action=rerun)` `[Rerun failed job ➔](${apiUrl}/trigger/${ctx.repoContext.owner}/${ctx.repoContext.name}/${ctx.runId}?action=rerun)`
); );
} }
const footer = buildPullfrogFooter({ const footer = buildPullfrogFooter({
triggeredBy: true, triggeredBy: true,
workflowRun: params.runId workflowRun: ctx.runId
? { owner: params.owner, repo: params.repo, runId: params.runId } ? {
owner: ctx.repoContext.owner,
repo: ctx.repoContext.name,
runId: ctx.runId,
}
: undefined, : undefined,
customParts, customParts,
}); });
return `${errorMessage}${footer}`; return `${errorMessage}${footer}`;
} }
type ValidateStuckCommentParams = { async function validateStuckProgressComment(ctx: PostCleanupContext): Promise<number | null> {
promptInput: JsonPromptInput | null; if (!ctx.promptInput?.progressCommentId) {
octokit: ReturnType<typeof createOctokit>;
owner: string;
repo: string;
};
async function validateStuckProgressComment(
params: ValidateStuckCommentParams
): Promise<number | null> {
if (!params.promptInput?.progressCommentId) {
log.info("[post] no progressCommentId in prompt input, skipping cleanup"); log.info("[post] no progressCommentId in prompt input, skipping cleanup");
return null; return null;
} }
const commentId = parseInt(params.promptInput.progressCommentId, 10); const commentId = parseInt(ctx.promptInput.progressCommentId, 10);
log.info(`[post] validating progressCommentId from prompt input: ${commentId}`); log.info(`[post] validating progressCommentId from prompt input: ${commentId}`);
try { try {
const commentResult = await params.octokit.rest.issues.getComment({ const commentResult = await ctx.octokit.rest.issues.getComment({
owner: params.owner, owner: ctx.repoContext.owner,
repo: params.repo, repo: ctx.repoContext.name,
comment_id: commentId, comment_id: commentId,
}); });
@@ -93,19 +89,13 @@ async function validateStuckProgressComment(
} }
} }
type GetIsCancelledParams = { async function getIsCancelled(ctx: PostCleanupContext): Promise<boolean> {
repoContext: ReturnType<typeof parseRepoContext>; if (!ctx.runId) return false; // can't check without a run ID — assume failure
octokit: ReturnType<typeof createOctokit>;
runId: number | undefined;
};
async function getIsCancelled(params: GetIsCancelledParams): Promise<boolean> {
if (!params.runId) return false; // can't check without a run ID — assume failure
try { try {
const jobsResult = await params.octokit.rest.actions.listJobsForWorkflowRun({ const jobsResult = await ctx.octokit.rest.actions.listJobsForWorkflowRun({
owner: params.repoContext.owner, owner: ctx.repoContext.owner,
repo: params.repoContext.name, repo: ctx.repoContext.name,
run_id: params.runId, run_id: ctx.runId,
}); });
// find current job by matching GITHUB_JOB env var. // find current job by matching GITHUB_JOB env var.
@@ -166,30 +156,23 @@ export async function runPostCleanup(): Promise<void> {
const repoContext = parseRepoContext(); const repoContext = parseRepoContext();
const octokit = createOctokit(token); const octokit = createOctokit(token);
const commentId = await validateStuckProgressComment({ const ctx: PostCleanupContext = { repoContext, octokit, runId, promptInput };
promptInput,
octokit, const commentId = await validateStuckProgressComment(ctx);
owner: repoContext.owner,
repo: repoContext.name,
});
if (!commentId) return log.info("» [post] no stuck progress comment to update, skipping cleanup"); 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`); log.info(`» [post] validated stuck comment: ${commentId}, updating with error message`);
try { try {
const body = buildErrorCommentBody({ const body = buildErrorCommentBody(
owner: repoContext.owner, ctx,
repo: repoContext.name, SHOULD_CHECK_REASON ? await getIsCancelled(ctx) : false
runId, );
isCancellation: SHOULD_CHECK_REASON
? await getIsCancelled({ octokit, repoContext, runId })
: false,
});
await octokit.rest.issues.updateComment({ await ctx.octokit.rest.issues.updateComment({
owner: repoContext.owner, owner: ctx.repoContext.owner,
repo: repoContext.name, repo: ctx.repoContext.name,
comment_id: commentId, comment_id: commentId,
body, body,
}); });
+1 -1
View File
@@ -24,7 +24,7 @@ export async function postReviewCleanup(ctx: ToolContext): Promise<void> {
delete ctx.toolState.review; delete ctx.toolState.review;
// mark review as submitted — unlocks webhook dedup for new pushes // mark review as submitted — unlocks webhook dedup for new pushes
await bestEffort(() => reportReviewNodeId(ctx, review.nodeId), "reportReviewNodeId"); await bestEffort(() => reportReviewNodeId(ctx, { nodeId: review.nodeId }), "reportReviewNodeId");
// dispatch follow-up if PR HEAD moved past the reviewed commit // dispatch follow-up if PR HEAD moved past the reviewed commit
if (review.reviewedSha) { if (review.reviewedSha) {
+10 -4
View File
@@ -57,18 +57,24 @@ const defaultRunContext: RunContext = {
export async function fetchRunContext(params: { export async function fetchRunContext(params: {
token: string; token: string;
repoContext: RepoContext; repoContext: RepoContext;
oidcToken?: string | undefined;
}): Promise<RunContext> { }): Promise<RunContext> {
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 headers: Record<string, string> = {
Authorization: `Bearer ${params.token}`,
"Content-Type": "application/json",
};
if (params.oidcToken) {
headers["X-GitHub-OIDC-Token"] = params.oidcToken;
}
const response = await apiFetch({ const response = await apiFetch({
path: `/api/repo/${params.repoContext.owner}/${params.repoContext.name}/run-context`, path: `/api/repo/${params.repoContext.owner}/${params.repoContext.name}/run-context`,
headers: { headers,
Authorization: `Bearer ${params.token}`,
"Content-Type": "application/json",
},
signal: controller.signal, signal: controller.signal,
}); });
+9 -1
View File
@@ -1,3 +1,4 @@
import * as core from "@actions/core";
import type { Octokit } from "@octokit/rest"; import type { Octokit } from "@octokit/rest";
import packageJson from "../package.json" with { type: "json" }; import packageJson from "../package.json" with { type: "json" };
import { log } from "./cli.ts"; import { log } from "./cli.ts";
@@ -32,9 +33,16 @@ export async function resolveRunContextData(
const repoContext = parseRepoContext(); const repoContext = parseRepoContext();
let oidcToken: string | undefined;
try {
oidcToken = await core.getIDToken("pullfrog-api");
} catch {
// OIDC not available (local dev, non-actions environment, fork PRs)
}
const [repoResponse, runContext] = await Promise.all([ const [repoResponse, runContext] = await Promise.all([
params.octokit.repos.get({ owner: repoContext.owner, repo: repoContext.name }), params.octokit.repos.get({ owner: repoContext.owner, repo: repoContext.name }),
fetchRunContext({ token: params.token, repoContext }), fetchRunContext({ token: params.token, repoContext, oidcToken }),
]); ]);
return { return {
+1 -39
View File
@@ -1,10 +1,7 @@
/** /**
* Secret detection and redaction utilities * Secret detection and env filtering utilities
* Redacts actual secret values rather than using pattern matching
*/ */
import { getGitHubInstallationToken } from "./token.ts";
// patterns for sensitive env var names // patterns for sensitive env var names
export const SENSITIVE_PATTERNS = [ export const SENSITIVE_PATTERNS = [
/_KEY$/i, /_KEY$/i,
@@ -47,38 +44,3 @@ export function resolveEnv(mode: EnvMode | undefined): Record<string, string | u
// custom env object - merge with restricted base // custom env object - merge with restricted base
return { ...filterEnv(), ...mode }; return { ...filterEnv(), ...mode };
} }
function getAllSecrets(): string[] {
const secrets: string[] = [];
// collect all env var values matching SENSITIVE_PATTERNS
for (const [key, value] of Object.entries(process.env)) {
if (value && isSensitiveEnvName(key)) {
secrets.push(value);
}
}
// add GitHub installation token (stored in memory, not in env)
try {
const token = getGitHubInstallationToken();
if (token) {
secrets.push(token);
}
} catch {
// token not set yet, ignore
}
return secrets;
}
export function redactSecrets(content: string, secrets?: string[]): string {
const secretsToRedact = [...(secrets ?? []), ...getAllSecrets()];
let redacted = content;
for (const secret of secretsToRedact) {
if (secret) {
const escaped = secret.replace(/[.*+?^${}()|[\]\\]/g, "\\$&");
redacted = redacted.replaceAll(new RegExp(escaped, "g"), "[REDACTED_SECRET]");
}
}
return redacted;
}
+29 -2
View File
@@ -1,6 +1,19 @@
import { spawnSync } from "node:child_process"; import { spawnSync } from "node:child_process";
import { tmpdir } from "node:os";
import { log } from "./cli.ts"; import { log } from "./cli.ts";
import { getDevDependencyVersion } from "./version.ts";
const skillsVersion = getDevDependencyVersion("skills");
/**
* install a skill globally via the `skills` CLI.
*
* runs `npx skills add <ref> --skill <name> -g` with `cwd` set to os tmpdir
* so npm doesn't walk up and find a project-level `.npmrc` with pnpm-specific
* settings (e.g. `public-hoist-pattern`) that break npx binary resolution.
* the `-g` flag writes to `$HOME/.agents/skills/` which is controlled by
* `params.env.HOME` (the fake HOME), so cwd has no effect on install location.
*/
export function addSkill(params: { export function addSkill(params: {
ref: string; ref: string;
skill: string; skill: string;
@@ -9,8 +22,20 @@ export function addSkill(params: {
}): void { }): void {
const result = spawnSync( const result = spawnSync(
"npx", "npx",
["skills", "add", params.ref, "--skill", params.skill, "-g", "-a", params.agent, "-y"], [
"-y",
`skills@${skillsVersion}`,
"add",
params.ref,
"--skill",
params.skill,
"-g",
"-a",
params.agent,
"-y",
],
{ {
cwd: tmpdir(),
env: { ...process.env, ...params.env }, env: { ...process.env, ...params.env },
stdio: "pipe", stdio: "pipe",
timeout: 30_000, timeout: 30_000,
@@ -19,6 +44,8 @@ export function addSkill(params: {
if (result.status === 0) { if (result.status === 0) {
log.info(`installed ${params.skill} skill (${params.agent})`); log.info(`installed ${params.skill} skill (${params.agent})`);
} else { } else {
log.info(`${params.skill} skill install failed: ${(result.stderr?.toString() || "").trim()}`); const stderr = (result.stderr?.toString() || "").trim();
const errorMsg = result.error ? result.error.message : stderr;
log.info(`${params.skill} skill install failed: ${errorMsg}`);
} }
} }
+8
View File
@@ -56,6 +56,8 @@ export type TodoTracker = {
cancel: () => void; cancel: () => void;
/** resolves when any in-flight onUpdate call completes */ /** resolves when any in-flight onUpdate call completes */
settled: () => Promise<void>; settled: () => Promise<void>;
/** mark in-progress items as completed (for final snapshot before review/progress post) */
completeInProgress: () => void;
renderCollapsible: () => string; renderCollapsible: () => string;
readonly enabled: boolean; readonly enabled: boolean;
/** true after the tracker has successfully called onUpdate at least once */ /** true after the tracker has successfully called onUpdate at least once */
@@ -135,6 +137,12 @@ export function createTodoTracker(onUpdate: (body: string) => Promise<void>): To
await inflightPromise; await inflightPromise;
}, },
completeInProgress() {
for (const item of state.values()) {
if (item.status === "in_progress") item.status = "completed";
}
},
renderCollapsible(): string { renderCollapsible(): string {
if (state.size === 0) return ""; if (state.size === 0) return "";
const todos = Array.from(state.values()); const todos = Array.from(state.values());
+1 -1
View File
@@ -6,7 +6,7 @@ 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";
// re-export for get-installation-token action // re-export for `pullfrog gha token` subcommand
export { acquireNewToken as acquireInstallationToken }; export { acquireNewToken as acquireInstallationToken };
export { revokeGitHubInstallationToken as revokeInstallationToken }; export { revokeGitHubInstallationToken as revokeInstallationToken };