Files
my-pi/pi-permission-system/docs/plans/archive/0153-pi-github-tools-extension.md

600 lines
25 KiB
Markdown

---
issue: 153
issue_title: "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`: pure `lib/` functions accept `onProgress` callbacks; the Pi wrapper maps to `onUpdate`.
- Auto-detect `owner/repo` from `gh repo view --json owner,name` with 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 `onProgress` callbacks to Pi's `onUpdate` streaming 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-issue` template 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), polls `gh run list` until a run matching a SHA appears or timeout.
- **`watchRun`** — 15 s poll interval, `formatProgress` produces compact `[2/5] deploy — in_progress (120s)` lines.
- **`listRuns`** — simple `gh run list` with structured output.
- **`ci-helpers.ts`** — `CIJob` type, `findRetryDelay()`, `formatProgress()`.
- **`process.ts`** — `runCommand()` wrapping `child_process.spawn`, `sleep()` helper.
Things to **not** port:
- `github-project.ts` — hardcoded `ORG`, `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
```typescript
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
1. Try `gh repo view --json owner,name` — authoritative, requires `gh auth`.
2. Fallback: parse `git remote get-url origin` — handles `git@github.com:owner/repo.git` and `https://github.com/owner/repo.git` formats.
3. Cache the result for the extension lifetime (detect once at first tool call, not at load time).
## Design Overview
### Package structure
```text
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
```text
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
```typescript
// 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
```typescript
// 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`
```jsonc
{
"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-tui` peer dependency — this extension does not import TUI types.
- No runtime `dependencies` — all work is done via `child_process.spawn` calling the `gh` CLI.
- `typebox` is 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`
```jsonc
{
"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`
```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`
```toml
[env]
_.path = ["scripts/bin"]
```
### `scripts/bin/npm`
Copy from `pi-permission-system/scripts/bin/npm` — the pnpm-enforcement shim.
### `.gitignore`
```text
node_modules/
*.log
.DS_Store
dist/
coverage/
logs/
*.tmp
```
### `.github/workflows/ci.yml`
Same structure as `pi-permission-system`:
1. `check` job — checkout, pnpm setup, `pnpm install --frozen-lockfile`, type check (`pnpm run build`), lint (`pnpm run lint:all`), test (`pnpm test`).
2. `release-please` job — runs on `main` after `check` passes, uses `googleapis/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`
```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 the `tools/` and `progress.ts` wrappers touch Pi.
- `gh` CLI is the sole external dependency; no other binaries assumed.
- Testing strategy: mock `runCommand` in `lib/` 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.
1. **New unit tests enabled**: The portable `lib/` layer is fully testable by mocking `runCommand`.
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.
2. **No existing tests to simplify**: Greenfield.
3. **Integration tests**: The `tools/` wrappers are thin enough that integration tests are optional.
The `onUpdate` mapping in `progress.ts` is 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`, verify `pnpm run build` and `pnpm run lint:all` pass on an empty `src/extension.ts` stub (`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**: `runCommand` spawns a process and captures stdout/stderr/exitCode; `sleep` resolves after delay.
- **Commit**: `feat: add process helpers (runCommand, sleep)`
### Cycle 2: CI helpers (pure functions)
- **Test surface**: `tests/lib/ci-helpers.test.ts`
- **Covers**: `findRetryDelay` backoff curve (attempt 1→0, 2→5, 3→10, 4→20, 5→30, 6→30 cap); `formatProgress` output 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()` uses `gh repo view` when 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_planned` normalized.
- **Commit**: `feat: add issue close business logic`
### Cycle 7: Progress adapter
- **Test surface**: `tests/progress.test.ts`
- **Covers**: `createProgressCallback` returns 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
1. **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.
The `promptSnippet` can guide the LLM.
2. **Release-please detection heuristic** — Should `release_pr_find` search by label (`autorelease: pending`) or title pattern (`chore(main): release`)?
Both are release-please conventions.
May need to try both.
3. **`release_pr_merge` merge strategy** — The issue says `--rebase`.
Some repos use `--squash` or `--merge`.
Consider making it a parameter with a default.
4. **TypeBox version alignment** — Pi uses `typebox` v1.
Confirm the extension's `peerDependencies` should declare `typebox` v1 or rely on Pi's copy.