mirror of
https://github.com/badlogic/pi-mono.git
synced 2026-07-09 17:28:45 +00:00
feat(coding-agent): add CI issue analysis import flow
This commit is contained in:
parent
1dac099022
commit
abe9c9d9f1
2 changed files with 413 additions and 0 deletions
218
.github/workflows/issue-analysis.yml
vendored
Normal file
218
.github/workflows/issue-analysis.yml
vendored
Normal file
|
|
@ -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 <issue number | issue URL | run URL>"
|
||||
|
||||
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;
|
||||
}
|
||||
195
.pi/extensions/import-repro.ts
Normal file
195
.pi/extensions/import-repro.ts
Normal file
|
|
@ -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<SessionHeader>;
|
||||
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<string> {
|
||||
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<string> {
|
||||
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<string> {
|
||||
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 <issue number | issue URL | run URL | session.jsonl>", "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");
|
||||
}
|
||||
},
|
||||
});
|
||||
}
|
||||
Loading…
Add table
Add a link
Reference in a new issue