fix(coding-agent): share issue analysis sessions as gists

This commit is contained in:
Armin Ronacher 2026-07-06 00:58:45 +02:00
parent d1e72d0585
commit 3df11fd886
3 changed files with 181 additions and 157 deletions

View file

@ -6,15 +6,15 @@
# (~/.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").
# read `earendil-works` org membership and create secret gists. The
# authorization job uses it to verify that the label actor is an active
# member of `earendil-works/staff`; the analysis job uses it to upload
# the exported session gist.
#
# 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>"
# pi "/import-repro <gist-id | gist-url | pi.dev/session URL>"
name: Issue Analysis
@ -124,6 +124,8 @@ jobs:
runs-on: ubuntu-latest
environment: pi-analyze
timeout-minutes: 45
env:
ISSUE_ANALYSIS_MODEL: openai-codex/gpt-5.5
steps:
- name: Create high-entropy working directory name
id: workdir
@ -170,57 +172,77 @@ jobs:
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"
./pi-test.sh \
-p \
--approve \
--session-dir "$RUNNER_TEMP/pi-out/session" \
--model "$ISSUE_ANALYSIS_MODEL" \
"/is $ISSUE_URL" | tee "$RUNNER_TEMP/pi-out/output.md"
- name: Upload session artifact
id: upload
- name: Export session files
id: export_session_files
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
shell: bash
working-directory: ${{ steps.workdir.outputs.name }}
env:
PI_CODING_AGENT_DIR: ${{ runner.temp }}/pi-agent
run: |
session_file="$(find "$RUNNER_TEMP/pi-out/session" -type f -name '*.jsonl' | head -n 1)"
if [ -z "$session_file" ]; then
echo "No session jsonl file found" >&2
exit 1
fi
cp "$session_file" "$RUNNER_TEMP/pi-out/session.jsonl"
./pi-test.sh --no-extensions --export "$RUNNER_TEMP/pi-out/session.jsonl" "$RUNNER_TEMP/pi-out/session.html"
- name: Upload session gist
id: gist
if: always() && steps.export_session_files.outcome == 'success'
shell: bash
env:
GH_TOKEN: ${{ secrets.EARENDIL_ORG_READ_TOKEN }}
run: |
if [ -z "$GH_TOKEN" ]; then
echo "EARENDIL_ORG_READ_TOKEN is not configured" >&2
exit 1
fi
gist_url="$(gh gist create --public=false "$RUNNER_TEMP/pi-out/session.html" "$RUNNER_TEMP/pi-out/session.jsonl")"
gist_id="${gist_url##*/}"
echo "url=$gist_url" >> "$GITHUB_OUTPUT"
echo "id=$gist_id" >> "$GITHUB_OUTPUT"
echo "share_url=https://pi.dev/session/#$gist_id" >> "$GITHUB_OUTPUT"
- name: Comment with session import instructions
if: always() && steps.upload.outcome == 'success'
if: always() && steps.gist.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 }}
GIST_URL: ${{ steps.gist.outputs.url }}
GIST_ID: ${{ steps.gist.outputs.id }}
SHARE_URL: ${{ steps.gist.outputs.share_url }}
with:
script: |
const artifactUrl = process.env.ARTIFACT_URL;
const runUrl = process.env.RUN_URL;
const issueNumber = context.issue.number;
const gistUrl = process.env.GIST_URL;
const gistId = process.env.GIST_ID;
const shareUrl = process.env.SHARE_URL;
const body = [
'Pi issue analysis finished.',
'',
`Session artifact: ${artifactUrl}`,
`Share URL: ${shareUrl}`,
`Gist: ${gistUrl}`,
'',
'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}"`,
`pi "/import-repro ${gistId}"`,
'```',
].join('\n');
await github.rest.issues.createComment({
owner: context.repo.owner,
repo: context.repo.repo,
issue_number: issueNumber,
issue_number: context.issue.number,
body,
});

View file

@ -1,59 +1,106 @@
/**
* Import a pi session recorded by the issue-analysis CI workflow
* Import a pi session shared as a gist 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
* Usage:
* /import-repro b4d100022aefb12f25dd2d8485e0a82a
* /import-repro https://gist.github.com/mitsuhiko/b4d100022aefb12f25dd2d8485e0a82a
* /import-repro https://pi.dev/session/#b4d100022aefb12f25dd2d8485e0a82a
*
* pi "/import-repro 123"
* pi "/import-repro <gist-id>"
*/
import { existsSync, mkdtempSync, readdirSync, readFileSync, writeFileSync } from "node:fs";
import { tmpdir } from "node:os";
import { Buffer } from "node:buffer";
import { existsSync, readFileSync, writeFileSync } from "node:fs";
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 };
}
const GIST_ID_RE = /^[0-9a-fA-F]{20,}$/;
const GIST_URL_RE = /^https:\/\/gist\.github\.com\/(?:[^/]+\/)?([0-9a-fA-F]{20,})(?:[/#?].*)?$/;
const SHARE_URL_RE = /^https:\/\/pi\.dev\/session\/#([0-9a-fA-F]{20,})(?:[/#?].*)?$/;
const SESSION_DATA_RE = /<script id="session-data" type="application\/json">([^<]+)<\/script>/;
interface SessionHeader {
type: string;
type: "session";
id: string;
cwd: string;
[key: string]: unknown;
}
function parseSessionHeader(raw: string): SessionHeader {
interface ExportedSessionData {
header: SessionHeader | null;
entries: Array<Record<string, unknown>>;
}
interface GistFile {
filename?: string;
raw_url?: string;
content?: string;
truncated?: boolean;
}
interface GistResponse {
files?: Record<string, GistFile>;
}
function parseRef(ref: string, cwd: string): { type: "gist"; id: string } | { type: "file"; path: string } {
if (ref.endsWith(".html") || ref.endsWith(".jsonl")) {
return { type: "file", path: isAbsolute(ref) ? ref : resolve(cwd, ref) };
}
const shareMatch = ref.match(SHARE_URL_RE);
if (shareMatch) return { type: "gist", id: shareMatch[1] };
const gistMatch = ref.match(GIST_URL_RE);
if (gistMatch) return { type: "gist", id: gistMatch[1] };
if (GIST_ID_RE.test(ref)) return { type: "gist", id: ref };
throw new Error(`expected a gist ID, gist URL, pi.dev share URL, .html file, or .jsonl file: ${ref}`);
}
function parseSessionJsonl(raw: string): { header: SessionHeader; jsonl: string } {
const newlineIndex = raw.indexOf("\n");
const firstLine = newlineIndex === -1 ? raw : raw.slice(0, newlineIndex);
let header: unknown;
let parsed: unknown;
try {
header = JSON.parse(firstLine);
parsed = 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 === "") {
const header = parsed as Partial<SessionHeader>;
if (header.type !== "session" || typeof header.id !== "string" || typeof header.cwd !== "string" || header.cwd === "") {
throw new Error("session file has no valid session header with a cwd");
}
return h as SessionHeader;
return { header: header as SessionHeader, jsonl: raw };
}
function decodeExportedHtml(html: string): { header: SessionHeader; jsonl: string } {
const match = html.match(SESSION_DATA_RE);
if (!match) throw new Error("HTML does not contain embedded pi session data");
let data: unknown;
try {
data = JSON.parse(Buffer.from(match[1], "base64").toString("utf8"));
} catch {
throw new Error("embedded pi session data is not valid JSON");
}
const sessionData = data as Partial<ExportedSessionData>;
const header = sessionData.header;
if (!header || header.type !== "session" || typeof header.id !== "string" || typeof header.cwd !== "string") {
throw new Error("embedded pi session data has no valid session header");
}
if (!Array.isArray(sessionData.entries)) {
throw new Error("embedded pi session data has no entries array");
}
const lines = [header, ...sessionData.entries].map((entry) => JSON.stringify(entry));
return { header, jsonl: `${lines.join("\n")}\n` };
}
/** Rewrite all occurrences of the recorded cwd (JSON-escaped) to the target cwd. */
@ -63,116 +110,71 @@ function rewriteSessionCwd(raw: string, sourceCwd: string, targetCwd: string): s
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})`);
async function fetchText(url: string): Promise<string> {
const response = await fetch(url, { headers: { Accept: "application/vnd.github+json" } });
if (!response.ok) {
throw new Error(`failed to fetch ${url}: HTTP ${response.status}`);
}
if (matches.length > 1) {
throw new Error(`multiple .jsonl files found in artifact: ${matches.join(", ")}`);
}
return matches[0];
return await response.text();
}
async function readGistFile(file: GistFile): Promise<string> {
if (file.content && !file.truncated) return file.content;
if (!file.raw_url) throw new Error(`gist file ${file.filename ?? "<unknown>"} has no raw URL`);
return await fetchText(file.raw_url);
}
async function fetchGistSession(gistId: string): Promise<{ header: SessionHeader; jsonl: string }> {
const response = await fetch(`https://api.github.com/gists/${gistId}`, {
headers: {
Accept: "application/vnd.github+json",
"X-GitHub-Api-Version": "2022-11-28",
},
});
if (!response.ok) throw new Error(`failed to fetch gist ${gistId}: HTTP ${response.status}`);
const gist = (await response.json()) as GistResponse;
const files = Object.values(gist.files ?? {});
const jsonlFile = files.find((file) => file.filename?.endsWith(".jsonl"));
if (jsonlFile) return parseSessionJsonl(await readGistFile(jsonlFile));
const htmlFile = files.find((file) => file.filename?.endsWith(".html"));
if (htmlFile) return decodeExportedHtml(await readGistFile(htmlFile));
throw new Error(`gist ${gistId} has no .jsonl or .html session file`);
}
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",
description: "Import a CI issue-analysis session from a gist ID or pi.dev session URL 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");
ctx.ui.notify("Usage: /import-repro <gist-id | gist-url | pi.dev/session URL>", "error");
return;
}
try {
const targetCwd = ctx.sessionManager.getCwd();
const sessionDir = ctx.sessionManager.getSessionDir();
const parsedRef = parseRef(ref, targetCwd);
ctx.ui.notify(`Fetching session for ${ref}...`, "info");
const sourceFile = await fetchSessionFile(ref, targetCwd);
ctx.ui.notify(`Importing repro session from ${ref}...`, "info");
const raw = readFileSync(sourceFile, "utf8");
const header = parseSessionHeader(raw);
const rewritten = rewriteSessionCwd(raw, header.cwd, targetCwd);
let sourceName: string;
let decoded: { header: SessionHeader; jsonl: string };
if (parsedRef.type === "gist") {
decoded = await fetchGistSession(parsedRef.id);
sourceName = `${parsedRef.id}.jsonl`;
} else {
if (!existsSync(parsedRef.path)) throw new Error(`session file not found: ${parsedRef.path}`);
const raw = readFileSync(parsedRef.path, "utf8");
decoded = parsedRef.path.endsWith(".html") ? decodeExportedHtml(raw) : parseSessionJsonl(raw);
sourceName = basename(parsedRef.path).replace(/\.html$/, ".jsonl");
}
const destination = join(sessionDir, basename(sourceFile));
const rewritten = rewriteSessionCwd(decoded.jsonl, decoded.header.cwd, targetCwd);
const destination = join(sessionDir, sourceName);
if (existsSync(destination)) {
const overwrite = await ctx.ui.confirm(
"Session already imported",
@ -185,7 +187,7 @@ export default function (pi: ExtensionAPI) {
}
writeFileSync(destination, rewritten);
ctx.ui.notify(`Imported session ${header.id} (cwd ${header.cwd} -> ${targetCwd})`, "info");
ctx.ui.notify(`Imported session ${decoded.header.id} (cwd ${decoded.header.cwd} -> ${targetCwd})`, "info");
await ctx.switchSession(destination);
} catch (error) {
ctx.ui.notify(`import-repro: ${error instanceof Error ? error.message : String(error)}`, "error");

View file

@ -6,7 +6,7 @@ Analyze GitHub issue(s): $ARGUMENTS
For each issue:
1. Add the `inprogress` label to the issue via GitHub CLI and assign the issue to the local `gh` user before analysis starts. If either action fails, report that explicitly and continue.
1. If running under CI (`CI=true`), do not add the `inprogress` label and do not assign the issue. Otherwise, add the `inprogress` label to the issue via GitHub CLI and assign the issue to the local `gh` user before analysis starts. If either action fails, report that explicitly and continue.
2. Read the issue in full, including all comments and linked issues/PRs. Use fields supported by GitHub CLI, for example:
```sh
gh issue view <issue> --json title,body,comments,labels,assignees,state,url,author,createdAt,updatedAt,closedByPullRequestsReferences