fix(coding-agent): share issue analysis sessions as gists
This commit is contained in:
+125
-123
@@ -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");
|
||||
|
||||
Reference in New Issue
Block a user