From abe9c9d9f15e843e56848849fc96d0a5486b47aa Mon Sep 17 00:00:00 2001 From: Armin Ronacher Date: Mon, 6 Jul 2026 00:35:04 +0200 Subject: [PATCH] feat(coding-agent): add CI issue analysis import flow --- .github/workflows/issue-analysis.yml | 218 +++++++++++++++++++++++++++ .pi/extensions/import-repro.ts | 195 ++++++++++++++++++++++++ 2 files changed, 413 insertions(+) create mode 100644 .github/workflows/issue-analysis.yml create mode 100644 .pi/extensions/import-repro.ts diff --git a/.github/workflows/issue-analysis.yml b/.github/workflows/issue-analysis.yml new file mode 100644 index 00000000..d2200e75 --- /dev/null +++ b/.github/workflows/issue-analysis.yml @@ -0,0 +1,218 @@ +# Runs the repo's /is prompt against an issue when the `pi-analyze` label is added. +# +# Setup required before this works: +# 1. Create a `pi-analyze` GitHub environment on the repo and add a +# `PI_AUTH_JSON` secret containing the contents of a pi auth.json +# (~/.pi/agent/auth.json). +# 2. Create the `pi-analyze` label. +# 3. Add a repository secret `EARENDIL_ORG_READ_TOKEN` with permission to +# read `earendil-works` org membership. The authorization job uses it to +# verify that the label actor is an active member of `earendil-works/staff`. +# 4. Optionally set a `PI_ISSUE_ANALYSIS_MODEL` repository variable to pin +# the model (e.g. "anthropic/claude-sonnet-4-5"). +# +# The session runs in a high-entropy checkout directory so the recorded cwd is +# a unique string. Import the session into a local checkout with the +# import-repro extension (.pi/extensions/import-repro.ts): +# pi "/import-repro " + +name: Issue Analysis + +on: + issues: + types: [labeled] + +permissions: + contents: read + issues: write + +concurrency: + group: issue-analysis-${{ github.event.issue.number }} + cancel-in-progress: false + +jobs: + authorize: + if: github.event.label.name == 'pi-analyze' + runs-on: ubuntu-latest + steps: + - name: Verify sender permission + uses: actions/github-script@v7 + env: + ORG_READ_TOKEN: ${{ secrets.EARENDIL_ORG_READ_TOKEN }} + with: + script: | + const username = context.payload.sender.login; + + async function removeTriggerLabel() { + try { + await github.rest.issues.removeLabel({ + owner: context.repo.owner, + repo: context.repo.repo, + issue_number: context.issue.number, + name: 'pi-analyze', + }); + } catch (error) { + if (error.status !== 404) throw error; + } + } + + if (!process.env.ORG_READ_TOKEN) { + await removeTriggerLabel(); + core.setFailed('EARENDIL_ORG_READ_TOKEN is not configured; refusing to run issue analysis.'); + return; + } + + try { + const { getOctokit } = require('@actions/github'); + const orgGithub = getOctokit(process.env.ORG_READ_TOKEN); + const { data: membership } = await orgGithub.rest.teams.getMembershipForUserInOrg({ + org: 'earendil-works', + team_slug: 'staff', + username, + }); + if (membership.state !== 'active') { + await removeTriggerLabel(); + core.setFailed(`@${username} is not an active earendil-works/staff member.`); + return; + } + console.log(`earendil-works/staff membership for @${username}: ${membership.state}`); + } catch (error) { + await removeTriggerLabel(); + const status = typeof error === 'object' && error !== null && 'status' in error ? error.status : 'unknown'; + core.setFailed(`Could not verify earendil-works/staff membership for @${username}: ${status}`); + return; + } + + const { data } = await github.rest.repos.getCollaboratorPermissionLevel({ + owner: context.repo.owner, + repo: context.repo.repo, + username, + }); + if (!['admin', 'write'].includes(data.permission)) { + await removeTriggerLabel(); + core.setFailed( + `@${username} has '${data.permission}' permission; write or admin is required to trigger issue analysis.`, + ); + } + + analyze: + needs: authorize + if: needs.authorize.result == 'success' + runs-on: ubuntu-latest + environment: pi-analyze + timeout-minutes: 45 + steps: + - name: Create high-entropy working directory name + id: workdir + run: echo "name=pi-ci-$(openssl rand -hex 16)" >> "$GITHUB_OUTPUT" + + - name: Checkout + uses: actions/checkout@v4 + with: + path: ${{ steps.workdir.outputs.name }} + + - name: Setup Node.js + uses: actions/setup-node@v4 + with: + node-version: 22 + cache: npm + cache-dependency-path: ${{ steps.workdir.outputs.name }}/package-lock.json + + - name: Install system dependencies + run: | + sudo apt-get update + sudo apt-get install -y fd-find ripgrep + sudo ln -s "$(which fdfind)" /usr/local/bin/fd + + - name: Install dependencies + working-directory: ${{ steps.workdir.outputs.name }} + run: npm ci --ignore-scripts + + - name: Write auth.json + env: + PI_AUTH_JSON: ${{ secrets.PI_AUTH_JSON }} + run: | + if [ -z "$PI_AUTH_JSON" ]; then + echo "PI_AUTH_JSON secret is not configured for the pi-analyze environment" >&2 + exit 1 + fi + mkdir -p "$RUNNER_TEMP/pi-agent" + printf '%s' "$PI_AUTH_JSON" > "$RUNNER_TEMP/pi-agent/auth.json" + chmod 600 "$RUNNER_TEMP/pi-agent/auth.json" + + - name: Run pi /is + shell: bash + working-directory: ${{ steps.workdir.outputs.name }} + env: + PI_CODING_AGENT_DIR: ${{ runner.temp }}/pi-agent + GH_TOKEN: ${{ github.token }} + ISSUE_URL: ${{ github.event.issue.html_url }} + PI_MODEL: ${{ vars.PI_ISSUE_ANALYSIS_MODEL }} + run: | + mkdir -p "$RUNNER_TEMP/pi-out/session" + args=(-p --approve --session-dir "$RUNNER_TEMP/pi-out/session") + if [ -n "$PI_MODEL" ]; then + args+=(--model "$PI_MODEL") + fi + ./pi-test.sh "${args[@]}" "/is $ISSUE_URL" | tee "$RUNNER_TEMP/pi-out/output.md" + + - name: Upload session artifact + id: upload + if: always() + uses: actions/upload-artifact@v4 + with: + name: pi-is-issue-${{ github.event.issue.number }}-run-${{ github.run_id }} + path: ${{ runner.temp }}/pi-out/ + if-no-files-found: error + + - name: Comment with session import instructions + if: always() && steps.upload.outcome == 'success' + uses: actions/github-script@v7 + env: + ARTIFACT_URL: ${{ steps.upload.outputs.artifact-url }} + RUN_URL: ${{ github.server_url }}/${{ github.repository }}/actions/runs/${{ github.run_id }} + with: + script: | + const artifactUrl = process.env.ARTIFACT_URL; + const runUrl = process.env.RUN_URL; + const issueNumber = context.issue.number; + const body = [ + 'Pi issue analysis finished.', + '', + `Session artifact: ${artifactUrl}`, + '', + 'Continue locally from a checkout with:', + '', + '```sh', + `pi "/import-repro ${runUrl}"`, + '```', + '', + 'Or import the latest analysis artifact for this issue with:', + '', + '```sh', + `pi "/import-repro #${issueNumber}"`, + '```', + ].join('\n'); + + await github.rest.issues.createComment({ + owner: context.repo.owner, + repo: context.repo.repo, + issue_number: issueNumber, + body, + }); + + - name: Remove trigger label + if: always() + uses: actions/github-script@v7 + with: + script: | + try { + await github.rest.issues.removeLabel({ + owner: context.repo.owner, + repo: context.repo.repo, + issue_number: context.issue.number, + name: 'pi-analyze', + }); + } catch (error) { + if (error.status !== 404) throw error; + } diff --git a/.pi/extensions/import-repro.ts b/.pi/extensions/import-repro.ts new file mode 100644 index 00000000..f80e4d88 --- /dev/null +++ b/.pi/extensions/import-repro.ts @@ -0,0 +1,195 @@ +/** + * Import a pi session recorded by the issue-analysis CI workflow + * (.github/workflows/issue-analysis.yml) and switch to it. + * + * The CI job runs in a high-entropy checkout directory; this command rewrites + * the recorded cwd to the local checkout, installs the session file into the + * current session directory, and switches to it. + * + * Usage (interactive command, also works as initial CLI message): + * /import-repro 123 + * /import-repro #123 + * /import-repro https://github.com/earendil-works/pi/issues/123 + * /import-repro https://github.com/earendil-works/pi/actions/runs/123456789 + * /import-repro /path/to/downloaded/session.jsonl + * + * pi "/import-repro 123" + */ + +import { existsSync, mkdtempSync, readdirSync, readFileSync, writeFileSync } from "node:fs"; +import { tmpdir } from "node:os"; +import { basename, isAbsolute, join, resolve } from "node:path"; +import type { ExtensionAPI, ExtensionCommandContext } from "@earendil-works/pi-coding-agent"; + +const ISSUE_REF_RE = /^#?(\d+)$/; +const ISSUE_URL_RE = /^https:\/\/github\.com\/([^/]+\/[^/]+)\/issues\/(\d+)(?:[/#?].*)?$/; +const RUN_URL_RE = /^https:\/\/github\.com\/([^/]+\/[^/]+)\/actions\/runs\/(\d+)(?:[/#?].*)?$/; +const ARTIFACT_PREFIX = "pi-is-issue-"; + +interface ArtifactInfo { + id: number; + name: string; + expired: boolean; + created_at: string; + workflow_run?: { id?: number }; +} + +interface SessionHeader { + type: string; + id: string; + cwd: string; +} + +function parseSessionHeader(raw: string): SessionHeader { + const newlineIndex = raw.indexOf("\n"); + const firstLine = newlineIndex === -1 ? raw : raw.slice(0, newlineIndex); + let header: unknown; + try { + header = JSON.parse(firstLine); + } catch { + throw new Error("first line of session file is not valid JSON"); + } + const h = header as Partial; + if (h.type !== "session" || typeof h.id !== "string" || typeof h.cwd !== "string" || h.cwd === "") { + throw new Error("session file has no valid session header with a cwd"); + } + return h as SessionHeader; +} + +/** Rewrite all occurrences of the recorded cwd (JSON-escaped) to the target cwd. */ +function rewriteSessionCwd(raw: string, sourceCwd: string, targetCwd: string): string { + if (sourceCwd === targetCwd) return raw; + const escapeJson = (value: string) => JSON.stringify(value).slice(1, -1); + return raw.split(escapeJson(sourceCwd)).join(escapeJson(targetCwd)); +} + +function findSessionFile(dir: string): string { + const entries = readdirSync(dir, { recursive: true, withFileTypes: true }); + const matches = entries + .filter((entry) => entry.isFile() && entry.name.endsWith(".jsonl")) + .map((entry) => join(entry.parentPath, entry.name)); + if (matches.length === 0) { + throw new Error(`no .jsonl session file found in artifact (${dir})`); + } + if (matches.length > 1) { + throw new Error(`multiple .jsonl files found in artifact: ${matches.join(", ")}`); + } + return matches[0]; +} + +export default function (pi: ExtensionAPI) { + async function gh(args: string[], cwd: string): Promise { + const result = await pi.exec("gh", args, { cwd, timeout: 120_000 }); + if (result.code !== 0) { + throw new Error(`gh ${args.slice(0, 2).join(" ")} failed: ${(result.stderr || result.stdout).trim()}`); + } + return result.stdout; + } + + async function resolveRepoSlug(cwd: string): Promise { + const output = await gh(["repo", "view", "--json", "nameWithOwner", "--jq", ".nameWithOwner"], cwd); + const slug = output.trim(); + if (!slug) throw new Error("could not determine repository (gh repo view returned nothing)"); + return slug; + } + + function pickArtifact(artifacts: ArtifactInfo[], filter: (name: string) => boolean): ArtifactInfo { + const candidates = artifacts + .filter((artifact) => !artifact.expired && filter(artifact.name)) + .sort((a, b) => b.created_at.localeCompare(a.created_at)); + if (candidates.length === 0) { + throw new Error("no matching issue-analysis artifact found (expired or never uploaded?)"); + } + return candidates[0]; + } + + /** Resolve a ref to a downloaded session .jsonl path. */ + async function fetchSessionFile(ref: string, cwd: string): Promise { + if (ref.endsWith(".jsonl")) { + const path = isAbsolute(ref) ? ref : resolve(cwd, ref); + if (!existsSync(path)) throw new Error(`session file not found: ${path}`); + return path; + } + + let repo: string; + let artifact: ArtifactInfo; + + const runMatch = ref.match(RUN_URL_RE); + const issueUrlMatch = ref.match(ISSUE_URL_RE); + const issueRefMatch = ref.match(ISSUE_REF_RE); + + if (runMatch) { + repo = runMatch[1]; + const runId = runMatch[2]; + const output = await gh(["api", `repos/${repo}/actions/runs/${runId}/artifacts`], cwd); + const parsed = JSON.parse(output) as { artifacts: ArtifactInfo[] }; + artifact = pickArtifact(parsed.artifacts, (name) => name.startsWith(ARTIFACT_PREFIX)); + } else if (issueUrlMatch || issueRefMatch) { + let issueNumber: string; + if (issueUrlMatch) { + repo = issueUrlMatch[1]; + issueNumber = issueUrlMatch[2]; + } else { + repo = await resolveRepoSlug(cwd); + issueNumber = (issueRefMatch as RegExpMatchArray)[1]; + } + const output = await gh(["api", `repos/${repo}/actions/artifacts?per_page=100`], cwd); + const parsed = JSON.parse(output) as { artifacts: ArtifactInfo[] }; + const namePattern = new RegExp(`^${ARTIFACT_PREFIX}${issueNumber}-run-\\d+$`); + artifact = pickArtifact(parsed.artifacts, (name) => namePattern.test(name)); + } else { + throw new Error(`unrecognized reference: ${ref} (expected issue number, issue URL, run URL, or .jsonl path)`); + } + + const runId = artifact.workflow_run?.id; + if (!runId) throw new Error(`artifact ${artifact.name} has no workflow run id`); + + const downloadDir = mkdtempSync(join(tmpdir(), "pi-import-repro-")); + await gh( + ["run", "download", String(runId), "--repo", repo, "--name", artifact.name, "--dir", downloadDir], + cwd, + ); + return findSessionFile(downloadDir); + } + + pi.registerCommand("import-repro", { + description: "Import a CI issue-analysis session (issue number, issue/run URL, or .jsonl path) and switch to it", + handler: async (args: string, ctx: ExtensionCommandContext) => { + const ref = args.trim(); + if (!ref) { + ctx.ui.notify("Usage: /import-repro ", "error"); + return; + } + + try { + const targetCwd = ctx.sessionManager.getCwd(); + const sessionDir = ctx.sessionManager.getSessionDir(); + + ctx.ui.notify(`Fetching session for ${ref}...`, "info"); + const sourceFile = await fetchSessionFile(ref, targetCwd); + + const raw = readFileSync(sourceFile, "utf8"); + const header = parseSessionHeader(raw); + const rewritten = rewriteSessionCwd(raw, header.cwd, targetCwd); + + const destination = join(sessionDir, basename(sourceFile)); + if (existsSync(destination)) { + const overwrite = await ctx.ui.confirm( + "Session already imported", + `Overwrite ${destination}? Local changes to that session will be lost.`, + ); + if (!overwrite) { + ctx.ui.notify("Import cancelled", "warning"); + return; + } + } + writeFileSync(destination, rewritten); + + ctx.ui.notify(`Imported session ${header.id} (cwd ${header.cwd} -> ${targetCwd})`, "info"); + await ctx.switchSession(destination); + } catch (error) { + ctx.ui.notify(`import-repro: ${error instanceof Error ? error.message : String(error)}`, "error"); + } + }, + }); +}