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

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: 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.tsCIJob type, findRetryDelay(), formatProgress().
  • process.tsrunCommand() 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

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

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-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

{
  "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:

  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

{
  ".": "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.