25 KiB
issue, issue_title
| issue | issue_title |
|---|---|
| 153 | Create Pi extension with GitHub CI/release tools for deterministic `/ship-issue` |
Pi GitHub Tools Extension
Problem Statement
The /ship-issue template instructs the agent to poll CI status via prose — sleep 15, re-check gh run list, interpret "up to ~3 times" loosely.
This consumes turns and context on mechanical polling, produces non-deterministic behavior (the LLM sometimes gives up early), and has no structured progress reporting.
The same friction applies to watching the release workflow after merging a release-please PR.
The @repone/agent-tools package in the repone project already solves this with purpose-built CI tools that have polling, exponential backoff, progress streaming, and structured success/timeout returns.
Those tools are host-agnostic business logic with thin OpenCode wrappers.
The goal is to port and generalize this pattern into a Pi extension.
Goals
- Create a new standalone Pi extension (
pi-github-tools) in a separate repository. - Register deterministic tools via
pi.registerTool():ci_find,ci_watch,ci_list,release_pr_find,release_pr_merge,release_watch,issue_close. - Port the portable business logic pattern from
@repone/agent-tools: purelib/functions acceptonProgresscallbacks; the Pi wrapper maps toonUpdate. - Auto-detect
owner/repofromgh repo view --json owner,namewith git-remote parsing as fallback — no hardcoded org/repo constants. - Return structured text results (not JSON to the LLM) with clear success/timeout/error states.
- Map
onProgresscallbacks to Pi'sonUpdatestreaming mechanism for real-time progress in the TUI.
Non-Goals
- Board/project integration (move to column, set rank) — repo-specific, not portable.
- Issue creation, editing, triaging, or dependency management — too repo-specific for a general tool.
- Modifying the
/ship-issuetemplate in this plan — that's a follow-up after the tools are available. - Publishing to npm — the extension is installed via git URL in Pi settings; npm publishing is a follow-up.
- Integrating with
pi-permission-system— this is a separate extension with no permission surface.
Background
Prior art: @repone/agent-tools
Located at ~/tinyigsoftware/repone/agent-tools/.
Architecture: portable business logic in src/ (ci.ts, issue.ts, release.ts) backed by helpers in src/lib/ (ci-helpers.ts, github-project.ts, process.ts).
OpenCode-specific wrappers in .opencode/tools/ are thin adapters that call the business logic and map onProgress to context.metadata({ title }).
Key patterns to port:
findRun— exponential backoff (5 s base, 30 s cap), pollsgh run listuntil a run matching a SHA appears or timeout.watchRun— 15 s poll interval,formatProgressproduces compact[2/5] deploy — in_progress (120s)lines.listRuns— simplegh run listwith structured output.ci-helpers.ts—CIJobtype,findRetryDelay(),formatProgress().process.ts—runCommand()wrappingchild_process.spawn,sleep()helper.
Things to not port:
github-project.ts— hardcodedORG,REPO,PROJECT_NUMBER,STATUS_OPTIONS,PRODUCTION_URL. Replace with auto-detected owner/repo.board.ts,milestone.ts,retro.ts,devserver.ts,dod-preflight.ts— repo-specific.temp-file.ts— only needed for issue body creation (not in scope).
Pi registerTool API
pi.registerTool<TParams>({
name: string;
label: string;
description: string;
promptSnippet?: string;
promptGuidelines?: string[];
parameters: TParams; // TypeBox schema
execute(
toolCallId: string,
params: Static<TParams>,
signal: AbortSignal | undefined,
onUpdate: AgentToolUpdateCallback<TDetails> | undefined,
ctx: ExtensionContext,
): Promise<AgentToolResult<TDetails>>;
});
Progress streaming: call onUpdate?.({ type: "progress", content }) during execution.
Pi uses typebox v1 (import { Type } from "typebox").
Repo detection strategy
- Try
gh repo view --json owner,name— authoritative, requiresgh auth. - Fallback: parse
git remote get-url origin— handlesgit@github.com:owner/repo.gitandhttps://github.com/owner/repo.gitformats. - Cache the result for the extension lifetime (detect once at first tool call, not at load time).
Design Overview
Package structure
pi-github-tools/
├── package.json # pi.extensions entry, typebox peer dep
├── tsconfig.json # ES2023, noEmit
├── biome.json
├── vitest.config.ts
├── src/
│ ├── extension.ts # default export: registers all tools
│ ├── tools/
│ │ ├── ci-find.ts # Pi tool wrapper for findRun
│ │ ├── ci-watch.ts # Pi tool wrapper for watchRun
│ │ ├── ci-list.ts # Pi tool wrapper for listRuns
│ │ ├── release-pr-find.ts
│ │ ├── release-pr-merge.ts
│ │ ├── release-watch.ts
│ │ └── issue-close.ts
│ ├── lib/
│ │ ├── ci.ts # portable: findRun, watchRun, listRuns
│ │ ├── ci-helpers.ts # CIJob, findRetryDelay, formatProgress
│ │ ├── release.ts # portable: findReleasePR, mergeReleasePR, watchRelease
│ │ ├── issue.ts # portable: closeIssue
│ │ ├── github.ts # portable: gh(), ghJson(), detectRepo()
│ │ └── process.ts # portable: runCommand(), sleep()
│ └── progress.ts # maps onProgress → Pi onUpdate
└── tests/
├── lib/
│ ├── ci.test.ts
│ ├── ci-helpers.test.ts
│ ├── release.test.ts
│ ├── issue.test.ts
│ ├── github.test.ts
│ └── process.test.ts
└── tools/
└── (integration-style tests if needed)
Data flow
LLM calls ci_find(workflow, expected_sha, timeout)
→ Pi dispatches to tools/ci-find.ts execute()
→ calls lib/ci.ts findRun({ workflow, expectedSha, timeout, onProgress })
→ onProgress mapped to Pi onUpdate via progress.ts
→ lib/ci.ts calls lib/github.ts ghJson() for polling
→ ghJson() calls runCommand() which spawns `gh`
→ returns structured text result
→ Pi returns AgentToolResult to LLM
Tool specifications
ci_find
| Field | Value |
|---|---|
| Parameters | workflow: string, expected_sha: string, timeout?: number (default 120) |
| Behavior | Exponential backoff polling (5 s base, 30 s cap). Polls gh run list until a run matching expected_sha appears. |
| Success | Returns run_id, url, status, sha, title, and job list. |
| Timeout | Returns structured timeout message with last-seen SHA and retry count. |
| Progress | Emits awaiting <workflow> run for <short_sha>... (attempt N, Ns elapsed) |
ci_watch
| Field | Value |
|---|---|
| Parameters | workflow: string, run_id: number, timeout?: number (default 300) |
| Behavior | 15 s poll interval. Polls gh run view by run ID. |
| Success | Returns full progress log and final status. |
| Timeout | Returns progress log with timeout line. |
| Progress | Emits [completed/total] active_job — in_progress (Ns) per cycle |
ci_list
| Field | Value |
|---|---|
| Parameters | workflow: string, limit?: number (default 5) |
| Behavior | Single gh run list call. |
| Returns | Status, name, SHA, run ID, URL per run. |
release_pr_find
| Field | Value |
|---|---|
| Parameters | timeout?: number (default 120) |
| Behavior | Polls gh pr list filtering for release-please PRs until one appears or timeout. Uses exponential backoff. |
| Success | Returns PR number, title, head branch, mergeable status, URL. |
| Timeout | Structured timeout with retry count. |
release_pr_merge
| Field | Value |
|---|---|
| Parameters | pr_number: number |
| Behavior | Checks PR is MERGEABLE + CLEAN. Merges with --rebase. Runs git pull --ff-only. |
| Success | Returns merge confirmation with new HEAD SHA. |
| Error | Structured error if not mergeable (with reason). |
release_watch
| Field | Value |
|---|---|
| Parameters | expected_sha?: string, timeout?: number (default 180) |
| Behavior | Polls for a new git tag on HEAD or watches the release workflow run. |
| Success | Returns version, tag name, tag SHA. |
| Timeout | Structured timeout. |
issue_close
| Field | Value |
|---|---|
| Parameters | issue_number: number, comment?: string, reason?: string (default "completed") |
| Behavior | gh issue close with optional comment. Validates reason is completed or not_planned. |
| Returns | Confirmation message. |
Progress mapping
// src/progress.ts
import type { AgentToolUpdateCallback } from "@earendil-works/pi-coding-agent";
export function createProgressCallback(
onUpdate: AgentToolUpdateCallback<unknown> | undefined,
): ((line: string) => void) | undefined {
if (!onUpdate) return undefined;
return (line: string) => {
onUpdate({ type: "progress", content: line });
};
}
Repo detection
// src/lib/github.ts
interface RepoInfo { owner: string; repo: string }
let cachedRepo: RepoInfo | undefined;
export async function detectRepo(): Promise<RepoInfo> {
if (cachedRepo) return cachedRepo;
// Try gh first
try {
const result = await ghJson<{ owner: { login: string }; name: string }>(
"repo", "view", "--json", "owner,name",
);
cachedRepo = { owner: result.owner.login, repo: result.name };
return cachedRepo;
} catch {
// Fall back to git remote
}
const { stdout } = await runCommand({ cmd: "git", args: ["remote", "get-url", "origin"] });
const match = stdout.trim().match(/github\.com[:/]([^/]+)\/([^/.]+)/);
if (!match) throw new Error("Could not detect GitHub repository from git remote");
cachedRepo = { owner: match[1], repo: match[2] };
return cachedRepo;
}
The gh() and ghJson() helpers accept repo-aware commands by prepending -R owner/repo when the command targets a specific repo, or omitting it for commands that infer from CWD (like gh run list).
Since all CI/release tools run in the project directory, most gh commands can rely on CWD detection.
detectRepo() is primarily needed for tools that construct URLs or display owner/repo in output.
Error handling
All tools return AgentToolResult with { content, isError }:
- Success:
{ content: structuredText, isError: false } - Timeout:
{ content: structuredTimeoutMessage, isError: false }— timeouts are expected outcomes, not errors. - Error (gh not installed, auth failure, network):
{ content: errorMessage, isError: true }
This matches the repone pattern where timeouts return structured messages rather than throwing.
Module-Level Changes
This is a new standalone repository.
All files are new — no changes to pi-permission-system.
src/lib/process.ts — NEW
Port from @repone/agent-tools/src/lib/process.ts.
runCommand() and sleep() — unchanged.
src/lib/github.ts — NEW
Replaces @repone/agent-tools/src/lib/github-project.ts.
Drops all hardcoded constants (ORG, REPO, PROJECT_NUMBER, PRODUCTION_URL, STATUS_OPTIONS).
Adds detectRepo() with gh repo view + git remote fallback.
Keeps gh() and ghJson() helpers.
src/lib/ci-helpers.ts — NEW
Port from @repone/agent-tools/src/lib/ci-helpers.ts.
CIJob, findRetryDelay(), formatProgress() — unchanged.
src/lib/ci.ts — NEW
Port from @repone/agent-tools/src/ci.ts.
Remove PRODUCTION_URL references from formatFind and formatWatch.
Remove the workflow parameter from formatWatch and formatFind (it was only used for the production URL conditional).
src/lib/release.ts — NEW
New module.
findReleasePR() — polls gh pr list --label "autorelease: pending" or --search "release-please" with backoff.
mergeReleasePR() — checks mergeable state, merges with --rebase, pulls.
watchRelease() — polls git tag --points-at HEAD or watches the release workflow.
src/lib/issue.ts — NEW
Port simplified closeIssue() from @repone/agent-tools/src/issue.ts.
Drop board integration (moveToStatus).
Keep reason validation (completed | not_planned).
src/progress.ts — NEW
Maps onProgress callback to Pi's onUpdate.
src/tools/*.ts — NEW (7 files)
Thin Pi wrappers.
Each file exports a function that accepts pi: ExtensionAPI and calls pi.registerTool() with TypeBox parameter schema, description, promptSnippet, and an execute function that delegates to the corresponding lib/ function.
src/extension.ts — NEW
Default export piGithubToolsExtension(pi: ExtensionAPI).
Calls each tool registration function.
tests/lib/*.test.ts — NEW (6 files)
Unit tests for all portable business logic.
Mock runCommand to avoid real gh calls.
Test backoff timing, progress formatting, timeout handling, structured output.
Repository Scaffolding
The new pi-github-tools repo must mirror the conventions established in pi-permission-system.
This section specifies every config file and its contents.
package.json
{
"name": "@gotgenes/pi-github-tools",
"version": "0.0.0",
"description": "Pi extension providing deterministic GitHub CI, release, and issue tools.",
"type": "module",
"files": ["src", "README.md", "CHANGELOG.md", "LICENSE"],
"scripts": {
"prepare": "command -v prek >/dev/null 2>&1 && prek install || echo 'prek not found — skipping hook install (see README)'",
"build": "tsc -p tsconfig.json",
"lint": "biome check .",
"lint:fix": "biome check --write .",
"lint:md": "markdownlint-cli2 '*.md' 'docs/**/*.md'",
"lint:md:fix": "markdownlint-cli2 --fix '*.md' 'docs/**/*.md'",
"lint:imports": "! grep -rn --include='*.ts' 'from \"\\.[./][^\"]*\\.js\"' src/ tests/",
"lint:all": "pnpm run lint && pnpm run lint:md && pnpm run lint:imports",
"format": "biome format --write .",
"test": "vitest run",
"test:watch": "vitest",
"check": "pnpm run build && pnpm run lint:all && pnpm run test"
},
"keywords": ["pi-package", "pi", "pi-extension", "pi-coding-agent", "github", "ci", "release"],
"author": { "name": "Chris Lasher" },
"license": "MIT",
"repository": {
"type": "git",
"url": "git+https://github.com/gotgenes/pi-github-tools.git"
},
"homepage": "https://github.com/gotgenes/pi-github-tools#readme",
"bugs": { "url": "https://github.com/gotgenes/pi-github-tools/issues" },
"packageManager": "pnpm@11.0.8",
"engines": { "node": ">=20" },
"publishConfig": { "access": "public" },
"pi": {
"extensions": ["./src/extension.ts"]
},
"peerDependencies": {
"@earendil-works/pi-coding-agent": "*"
},
"devDependencies": {
"@biomejs/biome": "^2.4.14",
"@earendil-works/pi-coding-agent": "^0.74.0",
"@types/node": "^25.6.2",
"markdownlint-cli2": "^0.22.1",
"typescript": "6.0.3",
"vitest": "^4.1.5"
}
}
Notes:
- No
@earendil-works/pi-tuipeer dependency — this extension does not import TUI types. - No runtime
dependencies— all work is done viachild_process.spawncalling theghCLI. typeboxis re-exported by@earendil-works/pi-coding-agent; no separate dependency needed.- Version starts at
0.0.0; release-please bumps it on first release.
tsconfig.json
{
"compilerOptions": {
"target": "ES2023",
"module": "ESNext",
"moduleResolution": "Bundler",
"noEmit": true,
"strict": false,
"skipLibCheck": true,
"resolveJsonModule": true,
"allowSyntheticDefaultImports": true
},
"include": ["src/**/*.ts", "tests/**/*.ts"],
"exclude": ["node_modules"]
}
biome.json
Identical to pi-permission-system/biome.json — same formatter, linter, and test-file overrides.
.markdownlint-cli2.yaml
ignores:
- "CHANGELOG.md"
config:
line-length: false
no-duplicate-heading:
siblings_only: true
no-inline-html:
allowed_elements:
- p
- img
first-line-heading: false
prek.toml
Identical to pi-permission-system/prek.toml — trailing-whitespace, end-of-file-fixer, check-added-large-files, biome check, and markdownlint-cli2 hooks.
mise.toml
[env]
_.path = ["scripts/bin"]
scripts/bin/npm
Copy from pi-permission-system/scripts/bin/npm — the pnpm-enforcement shim.
.gitignore
node_modules/
*.log
.DS_Store
dist/
coverage/
logs/
*.tmp
.github/workflows/ci.yml
Same structure as pi-permission-system:
checkjob — checkout, pnpm setup,pnpm install --frozen-lockfile, type check (pnpm run build), lint (pnpm run lint:all), test (pnpm test).release-pleasejob — runs onmainaftercheckpasses, usesgoogleapis/release-please-action@v5, publishes to npm via OIDC trusted publishing.
release-please-config.json
Identical to pi-permission-system/release-please-config.json — same changelog sections and include-v-in-tag: true.
.release-please-manifest.json
{
".": "0.0.0"
}
AGENTS.md
Project-specific agent instructions covering:
- Project purpose (deterministic GitHub CI/release tools for Pi).
- pnpm-only rule, ES2023 target, Conventional Commits.
- Portable
lib/code must not import Pi SDK types — only thetools/andprogress.tswrappers touch Pi. ghCLI is the sole external dependency; no other binaries assumed.- Testing strategy: mock
runCommandinlib/tests;tools/wrappers are thin and tested lightly.
LICENSE
MIT license, matching pi-permission-system.
Test Impact Analysis
This is a greenfield package — no existing tests to consider.
- New unit tests enabled: The portable
lib/layer is fully testable by mockingrunCommand. This includes backoff timing (findRetryDelay), progress formatting (formatProgress), poll loop exit conditions, timeout vs. success branching, repo detection fallback logic, and PR merge precondition checking. - No existing tests to simplify: Greenfield.
- Integration tests: The
tools/wrappers are thin enough that integration tests are optional. TheonUpdatemapping inprogress.tsis a one-liner.
TDD Order
Cycle 0: Repository scaffolding
- Covers: Create the GitHub repo, initialize with all config files from the Repository Scaffolding section, run
pnpm install, verifypnpm run buildandpnpm run lint:allpass on an emptysrc/extension.tsstub (export default function piGithubToolsExtension() {}). - Commit:
chore: initialize pi-github-tools repo with project scaffolding
Cycle 1: Process helpers
- Test surface:
tests/lib/process.test.ts - Covers:
runCommandspawns a process and captures stdout/stderr/exitCode;sleepresolves after delay. - Commit:
feat: add process helpers (runCommand, sleep)
Cycle 2: CI helpers (pure functions)
- Test surface:
tests/lib/ci-helpers.test.ts - Covers:
findRetryDelaybackoff curve (attempt 1→0, 2→5, 3→10, 4→20, 5→30, 6→30 cap);formatProgressoutput for no-jobs, queued, in-progress, mixed states. - Commit:
feat: add CI helper functions (findRetryDelay, formatProgress)
Cycle 3: GitHub helpers and repo detection
- Test surface:
tests/lib/github.test.ts - Covers:
gh()throws on non-zero exit;ghJson()parses JSON output;detectRepo()usesgh repo viewwhen available;detectRepo()falls back to git remote parsing for SSH and HTTPS URLs;detectRepo()caches result. - Commit:
feat: add GitHub helpers with auto repo detection
Cycle 4: CI find/watch/list
- Test surface:
tests/lib/ci.test.ts - Covers:
findRun— success on first poll, success after retries, timeout with last-seen info, onProgress callback invocation;watchRun— run completes immediately, run completes after polls, timeout, progress lines;listRuns— formats output, handles empty list. - Commit:
feat: add CI business logic (findRun, watchRun, listRuns)
Cycle 5: Release tools
- Test surface:
tests/lib/release.test.ts - Covers:
findReleasePR— finds PR on first poll, timeout;mergeReleasePR— success, not-mergeable error, pull failure;watchRelease— tag appears, timeout. - Commit:
feat: add release business logic (findReleasePR, mergeReleasePR, watchRelease)
Cycle 6: Issue close
- Test surface:
tests/lib/issue.test.ts - Covers:
closeIssue— success with comment, success without comment, invalid reason rejected,not_plannednormalized. - Commit:
feat: add issue close business logic
Cycle 7: Progress adapter
- Test surface:
tests/progress.test.ts - Covers:
createProgressCallbackreturns undefined when onUpdate is undefined; returns a function that calls onUpdate with progress type. - Commit:
feat: add Pi progress adapter
Cycle 8: Pi tool wrappers and extension entry
- Test surface:
tests/tools/(light integration) or manual verification. - Covers: Each tool is registered with correct name, description, and parameter schema; execute delegates to lib function.
- Commit:
feat: register all tools via Pi extension entry point
Cycle 9: Documentation
- Covers: README with installation, tool reference, and usage examples.
- Commit:
docs: add README with tool reference and setup instructions
Risks and Mitigations
Could this silently weaken a permission?
No. This extension registers new tools — it does not modify any permission surface, policy, or gate in pi-permission-system.
The tools invoke gh CLI commands via child_process.spawn, which flow through Pi's normal bash permission gate if the permission system is active.
gh CLI availability
All tools depend on the gh CLI being installed and authenticated.
Mitigation: Tools return a clear isError: true result if gh is not found or auth fails, rather than crashing.
onUpdate API stability
Pi's AgentToolUpdateCallback type is not documented as stable.
Mitigation: The progress adapter is a single function — easy to update if the API changes.
Module-scope cache for detectRepo()
A module-scope cachedRepo variable works here because this is a single extension loaded once — unlike pi-permission-system, there's no jiti isolation concern within the same extension.
Mitigation: Document the caching behavior; expose a resetRepoCache() for tests.
Exponential backoff timing in tests
Testing real backoff delays would make tests slow.
Mitigation: Mock sleep() in tests; test findRetryDelay as a pure function separately.
Open Questions
- Workflow name defaults — Should tools default to a workflow name (e.g.,
ci.yml) or require it explicitly? Leaning toward requiring it — workflow names vary across projects. ThepromptSnippetcan guide the LLM. - Release-please detection heuristic — Should
release_pr_findsearch by label (autorelease: pending) or title pattern (chore(main): release)? Both are release-please conventions. May need to try both. release_pr_mergemerge strategy — The issue says--rebase. Some repos use--squashor--merge. Consider making it a parameter with a default.- TypeBox version alignment — Pi uses
typeboxv1. Confirm the extension'speerDependenciesshould declaretypeboxv1 or rely on Pi's copy.