diff --git a/.env.example b/.env.example new file mode 100644 index 0000000..ffc1294 --- /dev/null +++ b/.env.example @@ -0,0 +1,13 @@ +# Copy to .env and fill in. Never commit .env. +# +# Project API key from PostHog → Project settings (posthog-node uses this). +# Either name works; POSTHOG_API_KEY is preferred. +POSTHOG_API_KEY= +# Alias accepted by the CLI (same value as POSTHOG_API_KEY): +# POSTHOG_PROJECT_TOKEN= + +# e.g. https://us.i.posthog.com or https://eu.i.posthog.com +POSTHOG_HOST=https://us.i.posthog.com + +# Set to 1 to log when PostHog is unconfigured +# POSTHOG_DEBUG=1 diff --git a/.github/workflows/example-usage.yml b/.github/workflows/example-usage.yml index 79eacf1..5e7cc13 100644 --- a/.github/workflows/example-usage.yml +++ b/.github/workflows/example-usage.yml @@ -20,3 +20,6 @@ jobs: with: # generate locally with: claude setup-token claude-code-oauth-token: ${{ secrets.CLAUDE_CODE_OAUTH_TOKEN }} + # optional — product analytics + AI evals + posthog-api-key: ${{ secrets.POSTHOG_API_KEY }} + posthog-host: ${{ secrets.POSTHOG_HOST }} diff --git a/.gitignore b/.gitignore index 65c5ca0..62f9bf8 100644 --- a/.gitignore +++ b/.gitignore @@ -4,3 +4,4 @@ learning-profile.md .DS_Store LAUNCH.md DESIGN_BRIEF.md +graphify-out/ diff --git a/README.md b/README.md index 52798b0..1755bb3 100644 --- a/README.md +++ b/README.md @@ -40,8 +40,31 @@ pr-explainer 42 # current repo only pr-explainer https://github.com/some-org/some-repo/pull/42 # any repo pr-explainer some-org/some-repo#42 pr-explainer https://github.com/some-org/some-repo/pull/42 --no-quiz + +# Explain a whole local checkout (needs Graphify — see below) +pr-explainer repo +pr-explainer repo /path/to/checkout --no-quiz ``` +### Repo mode + +`pr-explainer repo` orients you to what a **repository** does — for non-engineers +or engineers outside that domain — using the same learning profile as PR mode. + +It builds a local [Graphify](https://graphify.com/) knowledge graph of the +checkout (structure, hubs, communities), combines that with recent merged PRs +and your profile, and writes a concise plain-language explainer. Output lands +in `~/.pr-explainer/explainers/repos/`. + +Requires a **local git checkout** with a GitHub remote (not a bare `owner/repo` +URL yet), plus Graphify: + +```bash +uv tool install graphifyy # or: pipx install graphifyy +``` + +If `graphify` is missing, repo mode exits with install instructions. + > **Note:** a bare number resolves against the GitHub repo of your current > directory. To explain a PR elsewhere, pass the full URL or `owner/repo#N`. > Only **merged** PRs are supported. @@ -67,6 +90,8 @@ once to log in if you haven't. Also requires the [GitHub CLI](https://cli.github.com/) (`gh`), authenticated (`gh auth login`). +**Repo mode** additionally requires [Graphify](https://graphify.com/docs) +(`uv tool install graphifyy`). ## GitHub Action (optional) The CLI is the main way to use this — point it at any PR, any time. The @@ -125,6 +150,13 @@ Profile lookup (first hit wins): | `EXPLAINER_DIR` | `~/.pr-explainer/explainers` | output directory | | `PR_EXPLAINER_NO_QUIZ` | unset | set to `1` to skip the interactive quiz | | `PR_EXPLAINER_QUIET` | unset | set to `1` to skip printing the summary to stderr | +| `POSTHOG_API_KEY` | unset | enables product analytics + `$ai_generation` for [PostHog AI Evals](https://posthog.com/docs/ai-evals). Alias: `POSTHOG_PROJECT_TOKEN` | +| `POSTHOG_HOST` | PostHog default | e.g. `https://us.i.posthog.com` | +| `POSTHOG_DEBUG` | unset | set to `1` to log when PostHog is unconfigured | + +Copy [`.env.example`](.env.example) to `.env` for local runs (loaded automatically from cwd or package root). For the GitHub Action, pass `posthog-api-key` / `posthog-host` inputs (see `action.yml`) via repo secrets — not only a local `.env`. + +When configured, the CLI emits `profile_initialized`, `explainer_generation_started`, `explainer_generated`, plus `$ai_generation` (for evals) and mode-specific `pr_explained` / `repo_explained`. See [`templates/learning-profile.example.md`](templates/learning-profile.example.md) for the profile format. @@ -147,6 +179,7 @@ machine except what `claude` itself sends. | Claude Code CLI not found | Install from https://claude.com/claude-code and run `claude` once to log in | | `gh` auth / forbidden errors | Run `gh auth login` | | GitHub CLI not found | Install from https://cli.github.com/ | +| Graphify CLI not found / repo mode | Install with `uv tool install graphifyy`, then retry `pr-explainer repo` | Quick sanity checks: diff --git a/action.yml b/action.yml index 35f89d9..556d081 100644 --- a/action.yml +++ b/action.yml @@ -15,6 +15,15 @@ inputs: anthropic-api-key: description: "Anthropic Console API key. Used only if claude-code-oauth-token is not set." required: false + posthog-api-key: + description: > + PostHog project API key (POSTHOG_API_KEY). Enables product analytics and + $ai_generation capture for AI Evals. Optional — CLI works without it. + required: false + posthog-host: + description: "PostHog host, e.g. https://us.i.posthog.com" + required: false + default: "https://us.i.posthog.com" profile-path: description: "Path to the learning profile file" required: false @@ -50,6 +59,8 @@ runs: LEARNING_PROFILE: ${{ inputs.profile-path }} EXPLAINER_DIR: ${{ inputs.output-dir }} GH_TOKEN: ${{ github.token }} + POSTHOG_API_KEY: ${{ inputs.posthog-api-key }} + POSTHOG_HOST: ${{ inputs.posthog-host }} run: | OUT_PATH=$(npx pr-explainer "${{ github.event.pull_request.number }}") echo "path=$OUT_PATH" >> "$GITHUB_OUTPUT" diff --git a/package-lock.json b/package-lock.json index e296f7e..f309ff2 100644 --- a/package-lock.json +++ b/package-lock.json @@ -1,19 +1,57 @@ { "name": "@shilpi1958/pr-explainer", - "version": "0.2.3", + "version": "0.2.4", "lockfileVersion": 3, "requires": true, "packages": { "": { "name": "@shilpi1958/pr-explainer", - "version": "0.2.3", + "version": "0.2.4", "license": "MIT", + "dependencies": { + "posthog-node": "^5.47.3" + }, "bin": { "pr-explainer": "src/cli.js" }, "engines": { "node": ">=18" } + }, + "node_modules/@posthog/core": { + "version": "1.46.1", + "resolved": "https://registry.npmjs.org/@posthog/core/-/core-1.46.1.tgz", + "integrity": "sha512-EoCFduRkvrg9E5ylMi4QnZCjlAdRJCq6tJouWfngBVR79XSI4iPvIWYA+CdzokAjk+TfSVBFVJ++4Im3r+T0Dg==", + "license": "MIT", + "dependencies": { + "@posthog/types": "^1.399.0" + } + }, + "node_modules/@posthog/types": { + "version": "1.399.0", + "resolved": "https://registry.npmjs.org/@posthog/types/-/types-1.399.0.tgz", + "integrity": "sha512-/WDwBzqIPko8VJ1B+0rlso2XQEz9+2sqtsY9Tqy3p1GhgTqsFakcz/PmMpAnA321LTEZVRcO6x5hAwABV4yrDw==", + "license": "MIT" + }, + "node_modules/posthog-node": { + "version": "5.47.3", + "resolved": "https://registry.npmjs.org/posthog-node/-/posthog-node-5.47.3.tgz", + "integrity": "sha512-mhKaZOGLgD5aKKTj6xNRE2K9vRJnRIj4FNZeguDNnCR0k8RKJh71KO78+UqVdOONBsMzoqb01AD/B+TtsK7YSw==", + "license": "MIT", + "dependencies": { + "@posthog/core": "^1.46.1" + }, + "engines": { + "node": "^20.20.0 || >=22.22.0" + }, + "peerDependencies": { + "rxjs": "^7.0.0" + }, + "peerDependenciesMeta": { + "rxjs": { + "optional": true + } + } } } } diff --git a/package.json b/package.json index 209859d..3da5a71 100644 --- a/package.json +++ b/package.json @@ -11,7 +11,9 @@ "templates" ], "scripts": { - "start": "node src/cli.js" + "start": "node src/cli.js", + "check": "node --check src/cli.js && node --check src/posthog.js && node --check src/load-env.js && node --check src/quiz.js && node --check src/claude.js && node --check src/github.js && node --check src/prompt.js && node --check src/display.js && node --check src/graphify.js && node --check src/repo-context.js", + "test": "node --test test/**/*.test.js" }, "keywords": [ "github", @@ -37,5 +39,8 @@ }, "engines": { "node": ">=18" + }, + "dependencies": { + "posthog-node": "^5.47.3" } } diff --git a/scripts/test-repo-prompt.js b/scripts/test-repo-prompt.js new file mode 100644 index 0000000..373d234 --- /dev/null +++ b/scripts/test-repo-prompt.js @@ -0,0 +1,105 @@ +#!/usr/bin/env node +/** + * Dev helper for iterating on buildRepoPrompt without the full CLI. + * Prefer: node src/cli.js repo --no-quiz + * Kept for prompt experiments (see issue #13). + */ +import { readFile, writeFile, mkdir } from "node:fs/promises"; +import { existsSync } from "node:fs"; +import path from "node:path"; +import os from "node:os"; +import { fileURLToPath } from "node:url"; +import { buildRepoPrompt } from "../src/prompt.js"; +import { getRepoIdentity, getRecentMergedPRs } from "../src/github.js"; +import { runClaude } from "../src/claude.js"; +import { execFile } from "node:child_process"; +import { promisify } from "node:util"; + +const execFileAsync = promisify(execFile); +const __dirname = path.dirname(fileURLToPath(import.meta.url)); +const ROOT = path.join(__dirname, ".."); + +function truncate(text, max, label) { + if (!text || text.length <= max) return text || ""; + return text.slice(0, max) + `\n\n... (${label} truncated at ${max} chars)`; +} + +function formatRecentPrs(prs, budget = 7000) { + if (!prs.length) return "(no recent merged PRs found)"; + const parts = []; + let used = 0; + for (const pr of prs) { + const body = truncate((pr.body || "").trim() || "(no description)", 600, `PR #${pr.number}`); + const block = `#${pr.number} ${pr.title}\n${body}`; + if (used + block.length > budget && parts.length) break; + parts.push(block); + used += block.length + 2; + } + return parts.join("\n\n"); +} + +async function godNodesSummary(repoRoot) { + try { + const { stdout } = await execFileAsync( + "graphify", + ["god-nodes", "--top", "10", "--graph", path.join(repoRoot, "graphify-out/graph.json")], + { cwd: repoRoot } + ); + return stdout.trim(); + } catch (err) { + return `(god-nodes unavailable: ${err.message})`; + } +} + +async function loadProfile() { + const candidates = [ + process.env.LEARNING_PROFILE, + path.join(ROOT, "learning-profile.md"), + path.join(os.homedir(), ".pr-explainer/learning-profile.md"), + path.join(ROOT, "templates/learning-profile.example.md"), + ].filter(Boolean); + for (const p of candidates) { + if (existsSync(p)) return { path: p, text: await readFile(p, "utf8") }; + } + throw new Error("No learning profile found"); +} + +async function main() { + const reportPath = path.join(ROOT, "graphify-out/GRAPH_REPORT.md"); + if (!existsSync(reportPath)) { + throw new Error(`Missing ${reportPath}. Run: graphify update .`); + } + + const profile = await loadProfile(); + const identity = await getRepoIdentity(ROOT); + const prs = await getRecentMergedPRs(ROOT, 8); + const report = truncate(await readFile(reportPath, "utf8"), 20_000, "GRAPH_REPORT"); + const graphSummary = truncate(await godNodesSummary(ROOT), 4_000, "god-nodes"); + + const prompt = buildRepoPrompt({ + profile: profile.text, + identity, + graphifyReport: report, + graphSummary, + recentPrs: formatRecentPrs(prs), + }); + + const outDir = path.join(ROOT, "graphify-out"); + await mkdir(outDir, { recursive: true }); + const promptPath = path.join(outDir, "repo-prompt-test.txt"); + await writeFile(promptPath, prompt, "utf8"); + console.error(`Wrote prompt (${prompt.length} chars) → ${promptPath}`); + console.error(`Profile: ${profile.path}`); + console.error("Calling claude…"); + + const entry = await runClaude(prompt); + const resultPath = path.join(outDir, "repo-explainer-sample.md"); + await writeFile(resultPath, entry + "\n", "utf8"); + console.error(`Wrote explainer → ${resultPath}`); + console.log(entry); +} + +main().catch((err) => { + console.error(`Error: ${err.message}`); + process.exitCode = 1; +}); diff --git a/src/cli.js b/src/cli.js index 98448cd..6863f2e 100755 --- a/src/cli.js +++ b/src/cli.js @@ -5,10 +5,12 @@ import path from "node:path"; import os from "node:os"; import { fileURLToPath } from "node:url"; import { getPR, getPRDiff } from "./github.js"; -import { buildPrompt } from "./prompt.js"; +import { buildPrompt, buildRepoPrompt } from "./prompt.js"; import { runClaude } from "./claude.js"; import { printExplainerSummary } from "./display.js"; import { runInteractiveQuiz } from "./quiz.js"; +import { gatherRepoContext } from "./repo-context.js"; +import { capture, captureAiGeneration, getDeviceId, shutdown } from "./posthog.js"; const MAX_DIFF_CHARS = 60_000; const CONFIG_DIR = path.join(os.homedir(), ".pr-explainer"); @@ -27,28 +29,30 @@ const TEMPLATE_PATH = path.join( function usage() { console.error( `Usage: pr-explainer [--no-quiz] + pr-explainer repo [path] [--no-quiz] pr-explainer init [--force] -Explains a merged pull request in plain language, tailored to your -learning profile — however technical or non-technical you are, and -whether or not you wrote the PR yourself. Ends with a multiple-choice -Quick check so it's something you retain, not just read. - -After the explainer is saved, a readable summary (title, Ships, What -changed, Why it was done this way, Why it matters) prints to stderr. -In an interactive terminal: Press Enter, then CHECK IT STUCK quiz -(a/b/c or 1/2/3, Enter to skip, q to quit), then optionally open the -saved file. Pass --no-quiz to skip the quiz (also skipped in CI / -non-TTY). Summary still prints unless PR_EXPLAINER_QUIET=1. +Explains merged pull requests — or a whole repository — in plain language, +tailored to your learning profile. Ends with a multiple-choice Quick check +so it's something you retain, not just read. PR a PR number ("42"), a PR URL, or "owner/repo#42" (a bare number resolves against the repo in your current directory) + repo explain what a local git checkout does (cwd, or path to a checkout). + Uses Graphify to map the code, then writes a reader-pitched summary. init create ~/.pr-explainer/learning-profile.md from the template (use --force to overwrite an existing profile) -Requires the Claude Code CLI ("claude") installed and logged in -(subscription or API key — whatever you already use for \`claude\`), and -the GitHub CLI ("gh") authenticated. +After the explainer is saved, a readable summary prints to stderr. +In an interactive terminal: Press Enter, then CHECK IT STUCK quiz +(a/b/c or 1/2/3, Enter to skip, q to quit), then optionally open the +saved file. Pass --no-quiz to skip the quiz (also skipped in CI / +non-TTY). Summary still prints unless PR_EXPLAINER_QUIET=1. + +Requires: + - Claude Code CLI ("claude") installed and logged in + - GitHub CLI ("gh") authenticated + - For repo mode: Graphify CLI ("graphify") — uv tool install graphifyy Profile lookup (first hit wins): 1. LEARNING_PROFILE env @@ -118,11 +122,15 @@ async function initProfile(force = false) { `Profile already exists at ${GLOBAL_PROFILE}\n` + `Edit it in place, or re-run with --force to overwrite from the template.` ); + const deviceId = await getDeviceId(); + capture("profile_initialized", deviceId, { force: false, created: false }); return; } await copyFile(TEMPLATE_PATH, GLOBAL_PROFILE); console.error(`Created ${GLOBAL_PROFILE}`); console.error("Edit that file to describe your role, then run pr-explainer ."); + const deviceId = await getDeviceId(); + capture("profile_initialized", deviceId, { force, created: true }); } function nextEntryNumber(dir) { @@ -144,6 +152,14 @@ function slugify(title) { .slice(0, 60); } +function repoSlug(identity, repoRoot) { + const raw = + identity?.nameWithOwner || + identity?.name || + path.basename(repoRoot); + return slugify(String(raw).replace(/\//g, "-")) || "repo"; +} + async function appendToIndex(outDir, { filename, title, pr }) { const indexPath = path.join(outDir, "index.md"); if (!existsSync(indexPath)) { @@ -160,25 +176,27 @@ async function appendToIndex(outDir, { filename, title, pr }) { await appendFile(indexPath, row, "utf8"); } -async function main() { - const { flags, positionals } = parseArgs(process.argv.slice(2)); - const command = positionals[0]; - - if (flags.help || !command) { - usage(); - process.exitCode = flags.help ? 0 : 1; - return; - } - - if (command === "init") { - await initProfile(flags.force); - return; +async function appendToRepoIndex(outDir, { filename, title, identity }) { + const indexPath = path.join(outDir, "index.md"); + if (!existsSync(indexPath)) { + await writeFile( + indexPath, + "# Repo explainers\n\nRepositories explained so far, most recent first.\n\n" + + "| Date | Repo | Title | Entry |\n|---|---|---|---|\n", + "utf8" + ); } + const date = new Date().toISOString().slice(0, 10); + const repoLabel = identity?.nameWithOwner || identity?.name || "repo"; + const repoLink = identity?.url || ""; + const repoCell = repoLink ? `[${repoLabel}](${repoLink})` : repoLabel; + const row = `| ${date} | ${repoCell} | ${title} | [${filename}](${filename}) |\n`; + await appendFile(indexPath, row, "utf8"); +} - const profile = await loadProfile(); - - const pr = await getPR(command); - let diff = await getPRDiff(command); +async function explainPR(prRef, flags, profile) { + const pr = await getPR(prRef); + let diff = await getPRDiff(prRef); if (diff.length > MAX_DIFF_CHARS) { diff = diff.slice(0, MAX_DIFF_CHARS) + @@ -188,7 +206,31 @@ async function main() { console.error(`Generating explainer for PR #${pr.number}: ${pr.title}`); const prompt = buildPrompt({ profile, pr, diff }); - const entry = await runClaude(prompt); + const deviceId = await getDeviceId(); + capture("explainer_generation_started", deviceId, { + mode: "pr", + pr_number: pr.number, + diff_truncated: diff.length >= MAX_DIFF_CHARS, + }); + const started = Date.now(); + let entry; + try { + entry = await runClaude(prompt); + } catch (err) { + captureAiGeneration(deviceId, { + prompt, + output: "", + latencySec: (Date.now() - started) / 1000, + mode: "pr", + error: err.message, + properties: { + pr_number: pr.number, + diff_truncated: diff.length >= MAX_DIFF_CHARS, + }, + }); + throw err; + } + const latencySec = (Date.now() - started) / 1000; const titleMatch = entry.match(/^#\s+(.+)$/m); const title = titleMatch ? titleMatch[1] : pr.title; @@ -203,11 +245,156 @@ async function main() { await appendToIndex(outDir, { filename, title, pr }); console.log(outPath); + captureAiGeneration(deviceId, { + prompt, + output: entry, + latencySec, + mode: "pr", + properties: { + pr_number: pr.number, + diff_truncated: diff.length >= MAX_DIFF_CHARS, + }, + }); + capture("explainer_generated", deviceId, { + mode: "pr", + pr_number: pr.number, + quiz_enabled: !flags.noQuiz, + diff_truncated: diff.length >= MAX_DIFF_CHARS, + latency_sec: latencySec, + }); + capture("pr_explained", deviceId, { + pr_number: pr.number, + quiz_enabled: !flags.noQuiz, + diff_truncated: diff.length >= MAX_DIFF_CHARS, + latency_sec: latencySec, + }); + + printExplainerSummary(entry); + await runInteractiveQuiz(entry, flags, outPath); +} + +async function explainRepo(repoPath, flags, profile) { + const root = path.resolve(repoPath || process.cwd()); + if (!existsSync(root)) { + throw new Error(`Path not found: ${root}`); + } + + console.error(`Gathering repo context for ${root}…`); + const ctx = await gatherRepoContext(root); + const label = ctx.identity?.nameWithOwner || ctx.identity?.name || path.basename(root); + console.error(`Generating repo explainer for ${label}…`); + + const prompt = buildRepoPrompt({ + profile, + identity: ctx.identity, + graphifyReport: ctx.report, + graphSummary: ctx.graphSummary, + recentPrs: ctx.recentPrs, + }); + const deviceId = await getDeviceId(); + capture("explainer_generation_started", deviceId, { + mode: "repo", + repo_name: ctx.identity?.nameWithOwner ?? ctx.identity?.name ?? null, + }); + const started = Date.now(); + let entry; + try { + entry = await runClaude(prompt); + } catch (err) { + captureAiGeneration(deviceId, { + prompt, + output: "", + latencySec: (Date.now() - started) / 1000, + mode: "repo", + error: err.message, + properties: { + repo_name: ctx.identity?.nameWithOwner ?? ctx.identity?.name ?? null, + }, + }); + throw err; + } + const latencySec = (Date.now() - started) / 1000; + + const titleMatch = entry.match(/^#\s+(.+)$/m); + const title = titleMatch ? titleMatch[1] : label; + + const baseDir = process.env.EXPLAINER_DIR || GLOBAL_EXPLAINERS; + const outDir = path.join(baseDir, "repos"); + await mkdir(outDir, { recursive: true }); + const num = nextEntryNumber(outDir); + const filename = `${num}-${repoSlug(ctx.identity, root)}.md`; + const outPath = path.join(outDir, filename); + + await writeFile(outPath, entry + "\n", "utf8"); + await appendToRepoIndex(outDir, { + filename, + title, + identity: ctx.identity, + }); + console.log(outPath); + + captureAiGeneration(deviceId, { + prompt, + output: entry, + latencySec, + mode: "repo", + properties: { + repo_name: ctx.identity?.nameWithOwner ?? ctx.identity?.name ?? null, + }, + }); + capture("explainer_generated", deviceId, { + mode: "repo", + repo_name: ctx.identity?.nameWithOwner ?? ctx.identity?.name ?? null, + quiz_enabled: !flags.noQuiz, + latency_sec: latencySec, + }); + capture("repo_explained", deviceId, { + repo_name: ctx.identity?.nameWithOwner ?? ctx.identity?.name ?? null, + quiz_enabled: !flags.noQuiz, + latency_sec: latencySec, + }); + printExplainerSummary(entry); await runInteractiveQuiz(entry, flags, outPath); } -main().catch((err) => { - console.error(`Error: ${err.message}`); - process.exitCode = 1; -}); +async function main() { + const { flags, positionals } = parseArgs(process.argv.slice(2)); + const command = positionals[0]; + + if (flags.help || !command) { + usage(); + process.exitCode = flags.help ? 0 : 1; + return; + } + + if (command === "init") { + await initProfile(flags.force); + return; + } + + const profile = await loadProfile(); + + if (command === "repo") { + const repoPath = positionals[1] || process.cwd(); + await explainRepo(repoPath, flags, profile); + return; + } + + await explainPR(command, flags, profile); +} + +main() + .catch(async (err) => { + try { + const deviceId = await getDeviceId(); + capture("cli_error", deviceId, { error_type: err.constructor?.name ?? "Error" }); + } catch { + // analytics failure must not alter exit behavior + } + console.error(`Error: ${err.message}`); + process.exitCode = 1; + }) + .finally(async () => { + await shutdown(); + }); diff --git a/src/display.js b/src/display.js index 4844109..36c3802 100644 --- a/src/display.js +++ b/src/display.js @@ -88,9 +88,9 @@ function writeSection(heading, body) { } /** - * Print title, Ships, and the three core sections to stderr so the user - * can read before the quiz. Skips Quick check (quiz covers it). - * Honors PR_EXPLAINER_QUIET=1. Always prints (TTY or not) unless quiet. + * Print a readable summary to stderr before the quiz. + * Auto-detects PR vs repo explainer from headings. + * Honors PR_EXPLAINER_QUIET=1. */ export function printExplainerSummary(markdown) { if (process.env.PR_EXPLAINER_QUIET === "1") return; @@ -98,24 +98,45 @@ export function printExplainerSummary(markdown) { const titleMatch = markdown.match(/^#\s+(.+)$/m); const title = titleMatch ? stripMdLite(titleMatch[1]) : null; - const shipsMatch = markdown.match(/\*\*Ships:\*\*\s*(.+)/i); - const ships = shipsMatch ? stripMdLite(shipsMatch[1]) : null; - const what = extractSection(markdown, "What changed"); - const whyWay = extractSection(markdown, "Why it was done this way"); - const whyMatters = extractSection(markdown, "Why it matters"); + const isRepo = + /##\s+What it does\b/i.test(markdown) || + /\*\*In short:\*\*/i.test(markdown); stderr.write("\n"); if (title) { stderr.write(bold(title) + "\n"); } - if (ships) { - stderr.write(dim("Ships: ") + ships + "\n"); - } - writeSection("What changed", what); - writeSection("Why it was done this way", whyWay); - writeSection("Why it matters", whyMatters); + if (isRepo) { + const inShortMatch = markdown.match(/\*\*In short:\*\*\s*(.+)/i); + const inShort = inShortMatch ? stripMdLite(inShortMatch[1]) : null; + if (inShort) { + stderr.write(dim("In short: ") + inShort + "\n"); + } + writeSection("What it does", extractSection(markdown, "What it does")); + writeSection( + "How it's put together", + extractSection(markdown, "How it's put together") + ); + writeSection( + "What the team has been working on", + extractSection(markdown, "What the team has been working on") + ); + writeSection("Why it matters", extractSection(markdown, "Why it matters")); + } else { + const shipsMatch = markdown.match(/\*\*Ships:\*\*\s*(.+)/i); + const ships = shipsMatch ? stripMdLite(shipsMatch[1]) : null; + if (ships) { + stderr.write(dim("Ships: ") + ships + "\n"); + } + writeSection("What changed", extractSection(markdown, "What changed")); + writeSection( + "Why it was done this way", + extractSection(markdown, "Why it was done this way") + ); + writeSection("Why it matters", extractSection(markdown, "Why it matters")); + } const quizSection = extractSection(markdown, "Quick check"); if (quizSection) { diff --git a/src/github.js b/src/github.js index e5d3976..94585ae 100644 --- a/src/github.js +++ b/src/github.js @@ -78,3 +78,54 @@ export async function getPR(prRef) { export async function getPRDiff(prRef) { return gh(["pr", "diff", String(prRef)]); } + +/** + * Repo metadata for the GitHub remote of a local checkout (or cwd). + * Runs `gh` with cwd set so bare invocations resolve the right repo. + */ +export async function getRepoIdentity(repoRoot = process.cwd()) { + const json = await ghIn(repoRoot, [ + "repo", + "view", + "--json", + "name,description,url,repositoryTopics,nameWithOwner,defaultBranchRef", + ]); + const data = JSON.parse(json); + const topics = Array.isArray(data.repositoryTopics) + ? data.repositoryTopics + .map((t) => (typeof t === "string" ? t : t?.name)) + .filter(Boolean) + : []; + return { + name: data.name || null, + nameWithOwner: data.nameWithOwner || null, + description: data.description || null, + url: data.url || null, + topics, + defaultBranch: data.defaultBranchRef?.name || null, + }; +} + +/** Recent merged PRs for orientation — titles + bodies, not diffs. */ +export async function getRecentMergedPRs(repoRoot = process.cwd(), limit = 8) { + const json = await ghIn(repoRoot, [ + "pr", + "list", + "--state", + "merged", + "--limit", + String(limit), + "--json", + "number,title,body,url,mergedAt", + ]); + return JSON.parse(json); +} + +async function ghIn(cwd, args) { + try { + const { stdout } = await execFileAsync("gh", args, { cwd }); + return stdout; + } catch (err) { + throw new Error(friendlyGhError(args, err)); + } +} diff --git a/src/graphify.js b/src/graphify.js new file mode 100644 index 0000000..5ee964a --- /dev/null +++ b/src/graphify.js @@ -0,0 +1,102 @@ +import { readFile, access } from "node:fs/promises"; +import { existsSync, constants as fsConstants } from "node:fs"; +import path from "node:path"; +import { execFile } from "node:child_process"; +import { promisify } from "node:util"; + +const execFileAsync = promisify(execFile); + +const REPORT_BUDGET = 20_000; +const SUMMARY_BUDGET = 4_000; + +function truncate(text, max, label) { + if (!text || text.length <= max) return text || ""; + return text.slice(0, max) + `\n\n... (${label} truncated at ${max} chars)`; +} + +function friendlyGraphifyMissing() { + return ( + "Graphify CLI (`graphify`) not found.\n" + + "Repo mode needs Graphify to build a knowledge graph of the checkout.\n" + + "Install it with:\n" + + " uv tool install graphifyy\n" + + " # or: pipx install graphifyy\n" + + "Then retry. Docs: https://graphify.com/docs" + ); +} + +async function runGraphify(args, { cwd } = {}) { + try { + const { stdout, stderr } = await execFileAsync("graphify", args, { + cwd, + maxBuffer: 10 * 1024 * 1024, + }); + return { stdout: stdout || "", stderr: stderr || "" }; + } catch (err) { + if (err.code === "ENOENT") { + throw new Error(friendlyGraphifyMissing()); + } + const detail = [err.stderr, err.stdout, err.message] + .map((s) => (s || "").trim()) + .filter(Boolean) + .join("\n"); + throw new Error(`graphify ${args.join(" ")} failed:\n${detail}`); + } +} + +/** + * Ensure Graphify has produced graphify-out/ for this checkout, then + * return truncated report + compact god-nodes summary for the prompt. + * Never returns full graph.json. + */ +export async function ensureGraphifyContext(repoRoot, { forceUpdate = false } = {}) { + const root = path.resolve(repoRoot); + const outDir = path.join(root, "graphify-out"); + const reportPath = path.join(outDir, "GRAPH_REPORT.md"); + const graphPath = path.join(outDir, "graph.json"); + + const hasReport = existsSync(reportPath); + const hasGraph = existsSync(graphPath); + + if (!hasReport || !hasGraph || forceUpdate) { + process.stderr.write("Building knowledge graph with Graphify…\n"); + await runGraphify(["update", "."], { cwd: root }); + } + + if (!existsSync(reportPath)) { + throw new Error( + `Graphify finished but ${reportPath} is missing.\n` + + `Try: cd ${root} && graphify update .` + ); + } + + const report = truncate( + await readFile(reportPath, "utf8"), + REPORT_BUDGET, + "GRAPH_REPORT" + ); + + let graphSummary = ""; + try { + await access(graphPath, fsConstants.R_OK); + const { stdout } = await runGraphify( + ["god-nodes", "--top", "10", "--graph", graphPath], + { cwd: root } + ); + graphSummary = truncate(stdout.trim(), SUMMARY_BUDGET, "god-nodes"); + } catch (err) { + if (err.message?.includes("graphify") && err.message?.includes("not found")) { + throw err; + } + graphSummary = `(god-nodes unavailable: ${err.message})`; + } + + return { + outDir, + reportPath, + report, + graphSummary, + }; +} + +export { REPORT_BUDGET, SUMMARY_BUDGET }; diff --git a/src/load-env.js b/src/load-env.js new file mode 100644 index 0000000..d0a50ce --- /dev/null +++ b/src/load-env.js @@ -0,0 +1,53 @@ +/** + * Minimal .env loader (no dotenv dependency). + * Loads KEY=VALUE lines into process.env without overriding existing values. + * Searches cwd then package root (parent of src/). + */ +import { readFileSync, existsSync } from "node:fs"; +import path from "node:path"; +import { fileURLToPath } from "node:url"; + +const __dirname = path.dirname(fileURLToPath(import.meta.url)); +const PACKAGE_ROOT = path.join(__dirname, ".."); + +function parseEnvFile(contents) { + const out = {}; + for (const raw of contents.split(/\n/)) { + const line = raw.trim(); + if (!line || line.startsWith("#")) continue; + const eq = line.indexOf("="); + if (eq <= 0) continue; + const key = line.slice(0, eq).trim(); + let val = line.slice(eq + 1).trim(); + if ( + (val.startsWith('"') && val.endsWith('"')) || + (val.startsWith("'") && val.endsWith("'")) + ) { + val = val.slice(1, -1); + } + out[key] = val; + } + return out; +} + +export function loadEnvFiles() { + const candidates = [ + path.join(process.cwd(), ".env"), + path.join(PACKAGE_ROOT, ".env"), + ]; + const seen = new Set(); + for (const file of candidates) { + const resolved = path.resolve(file); + if (seen.has(resolved) || !existsSync(resolved)) continue; + seen.add(resolved); + let parsed; + try { + parsed = parseEnvFile(readFileSync(resolved, "utf8")); + } catch { + continue; + } + for (const [k, v] of Object.entries(parsed)) { + if (process.env[k] === undefined) process.env[k] = v; + } + } +} diff --git a/src/posthog.js b/src/posthog.js new file mode 100644 index 0000000..06bfd2f --- /dev/null +++ b/src/posthog.js @@ -0,0 +1,184 @@ +/** + * PostHog analytics client for pr-explainer. + * + * Uses a persistent anonymous device ID stored in ~/.pr-explainer/device-id + * so returning users are tracked across sessions without collecting any PII. + * All captures are guarded behind POSTHOG_API_KEY or POSTHOG_PROJECT_TOKEN. + */ +import { PostHog } from "posthog-node"; +import { readFile, writeFile, mkdir } from "node:fs/promises"; +import { existsSync } from "node:fs"; +import { randomUUID } from "node:crypto"; +import path from "node:path"; +import os from "node:os"; +import { loadEnvFiles } from "./load-env.js"; + +loadEnvFiles(); + +const CONFIG_DIR = path.join(os.homedir(), ".pr-explainer"); +const DEVICE_ID_FILE = path.join(CONFIG_DIR, "device-id"); +const AI_TEXT_BUDGET = 80_000; + +let _client = null; +let _deviceId = null; +let _clientReady = false; + +function resolveApiKey() { + return ( + process.env.POSTHOG_API_KEY || + process.env.POSTHOG_PROJECT_TOKEN || + "" + ).trim(); +} + +function createClient() { + const apiKey = resolveApiKey(); + const host = (process.env.POSTHOG_HOST || "").trim() || undefined; + + if (!apiKey) { + if (process.env.POSTHOG_DEBUG === "1") { + console.error( + "POSTHOG_API_KEY / POSTHOG_PROJECT_TOKEN is unset — analytics and AI evals will be skipped.\n" + + "Set POSTHOG_API_KEY (or POSTHOG_PROJECT_TOKEN) and optional POSTHOG_HOST to enable." + ); + } + return null; + } + + return new PostHog(apiKey, { + host, + flushAt: 1, + flushInterval: 0, + enableExceptionAutocapture: true, + }); +} + +/** Lazily initialise (and memoize) the PostHog client. */ +export function getPostHog() { + if (!_clientReady) { + _client = createClient(); + _clientReady = true; + } + return _client; +} + +/** Test helper: drop memoized client so env changes take effect. */ +export function resetPostHogClient() { + _client = null; + _clientReady = false; +} + +/** Test helper: inject a fake client (skips env-based createClient). */ +export function setPostHogClientForTests(client) { + _client = client; + _clientReady = true; +} + +/** + * Return a stable anonymous device ID, creating and persisting one on first use. + * This is the distinct ID used for all events — no PII is collected. + */ +export async function getDeviceId() { + if (_deviceId) return _deviceId; + + if (existsSync(DEVICE_ID_FILE)) { + try { + _deviceId = (await readFile(DEVICE_ID_FILE, "utf8")).trim(); + if (_deviceId) return _deviceId; + } catch { + // fall through to generate a new one + } + } + + _deviceId = randomUUID(); + try { + await mkdir(CONFIG_DIR, { recursive: true }); + await writeFile(DEVICE_ID_FILE, _deviceId, "utf8"); + } catch { + // If we can't persist it, the in-memory ID is still fine for this session. + } + return _deviceId; +} + +/** + * Capture an event. A no-op when PostHog is not configured. + * @param {string} event + * @param {string} distinctId + * @param {Record} [properties] + */ +export function capture(event, distinctId, properties = {}) { + const client = getPostHog(); + if (!client) return false; + client.capture({ distinctId, event, properties }); + return true; +} + +/** + * Capture an LLM generation for PostHog AI Observability / AI Evals. + * @param {string} distinctId + * @param {{ + * prompt: string, + * output: string, + * latencySec: number, + * mode: "pr" | "repo", + * model?: string, + * properties?: Record, + * error?: string, + * }} opts + */ +export function captureAiGeneration(distinctId, opts) { + const client = getPostHog(); + if (!client) return false; + + const { + prompt, + output, + latencySec, + mode, + model = "claude-code", + properties = {}, + error, + } = opts; + + const input = truncateForAi(prompt, AI_TEXT_BUDGET); + const out = truncateForAi(output, AI_TEXT_BUDGET); + + client.capture({ + distinctId, + event: "$ai_generation", + properties: { + $ai_trace_id: randomUUID(), + $ai_span_name: mode === "repo" ? "repo_explainer" : "pr_explainer", + $ai_model: model, + $ai_provider: "anthropic", + $ai_input: [{ role: "user", content: input }], + $ai_output_choices: [{ role: "assistant", content: out }], + $ai_latency: latencySec, + $ai_is_error: Boolean(error), + ...(error ? { $ai_error: String(error).slice(0, 2000) } : {}), + explainer_mode: mode, + prompt_name: mode === "repo" ? "repo_explainer" : "pr_explainer", + ...properties, + }, + }); + return true; +} + +function truncateForAi(text, max) { + const s = String(text || ""); + if (s.length <= max) return s; + return s.slice(0, max) + `\n…(truncated at ${max} chars for PostHog)`; +} + +/** + * Flush all queued events and shut down the client. + * Call once before the process exits. + */ +export async function shutdown() { + const client = getPostHog(); + if (!client) return; + await client.shutdown(); + resetPostHogClient(); +} + +export { resolveApiKey, truncateForAi, AI_TEXT_BUDGET }; diff --git a/src/prompt.js b/src/prompt.js index 5a04337..fada6d1 100644 --- a/src/prompt.js +++ b/src/prompt.js @@ -74,3 +74,154 @@ Correct: . `; } + +/** + * Repo explainer prompt: Graphify structure + recent merges + profile. + * Actor is "this repo" / "the team" — orientation, not a changelog. + */ +export function buildRepoPrompt({ + profile, + identity, + graphifyReport, + graphSummary, + recentPrs, +}) { + const identityBlock = [ + identity?.nameWithOwner || identity?.name || "(unknown repo)", + identity?.description ? `Description: ${identity.description}` : null, + identity?.url ? `URL: ${identity.url}` : null, + identity?.topics?.length ? `Topics: ${identity.topics.join(", ")}` : null, + ] + .filter(Boolean) + .join("\n"); + + return `You write explainers of software repositories for a specific \ +reader, who may or may not be technical and may be new to this codebase \ +or working in a different domain. You will be given that reader's profile, \ +repo identity, a knowledge-graph report of how the code is structured \ +(from Graphify: communities, god nodes, connections), an optional compact \ +graph summary, and recent merged pull requests. Write ONE explainer in \ +Markdown, in plain language pitched precisely at this reader. + +The profile describes the reader's role and what they're currently trying \ +to understand better — it is not an exhaustive skill checklist. Use your \ +own judgment about what someone in that role would already know or care \ +about, and skip over that. Slow down specifically on the areas they said \ +they're trying to understand, and translate anything that assumes \ +technical background they don't have (e.g. explain what a CLI *is* in \ +plain terms if the reader is non-technical). + +Calibrate depth to the reader: +- If they are non-technical (product, ops, support, founder outside the \ +IDE, analyst): write a *product orientation*, not an architecture tour. \ +Lead with who it's for, what problem it solves, what you get when you \ +use it, and why the team's recent work matters for stakeholders. Use the \ +Graphify graph only as *private grounding* — do NOT surface file paths \ +(\`src/…\`), function names (\`main()\`, \`getPR()\`), module maps, \ +"god nodes," communities, or import/call relationships unless a single \ +plain-language capability absolutely needs a one-word name (e.g. "the \ +GitHub CLI"). Prefer verbs and outcomes ("fetches the pull request", \ +"writes a plain-English summary", "asks a short quiz") over structure. +- If they are an engineer in another domain: light structure is fine \ +(major parts and how data flows), still skip trivia and dense symbol lists. +- If they are deep in this stack: you may cite a few real paths/symbols \ +from the graph when it helps; still do not dump every node. + +Never assume the reader built this repo — you are orienting them to \ +someone else's system. Write "this repo" / "the team" as the actor, \ +never "you." + +Treat the Graphify report and graph summary as the grounded skeleton of \ +what the system *is*. Do not invent capabilities, modules, or connections \ +that are not present. Prefer graph-backed facts over marketing language \ +if they disagree. Focus on product purpose and load-bearing *behaviors*, \ +not an inventory of files. + +For "How it's put together" for non-technical readers: describe 3–5 \ +capabilities as a simple flow (e.g. "takes a PR link → reads what \ +changed → writes an explainer in your voice → optional quiz"). No \ +module-by-module breakdown. + +Recent merged PRs show what the team has been shipping *now*. Extract \ +2–3 themes max — not a changelog dump. Frame themes in product terms \ +(install experience, clarity for first-time users, etc.), not commit \ +hygiene. If there are no recent PRs, say so briefly and skip inventing \ +momentum. + +If the repo is small or routine for this reader, say so briefly instead of \ +inventing significance. + +Be concise. Prefer short sentences and tight bullets over long paragraphs. \ +Each section should make its point in a few lines — lead with the takeaway, \ +then one or two supporting facts. Cut throat-clearing, repetition, and \ +restating the same idea in softer words. Aim for a scannable brief, not an \ +essay. Rough budget: "In short" one sentence; each body section about \ +3–6 short lines or bullets; "Why it matters" two short paragraphs max \ +(or a short bullet list). + +Default to bullets for "What it does", "How it's put together", and \ +"What the team has been working on". Keep "Why it matters" to 2–4 short \ +bullets (or two sentences). No multi-paragraph walls. If a sentence does \ +not add new information, delete it. + +End with 2-3 short multiple-choice recall questions that test whether the \ +reader absorbed the explainer. Questions must match the reader's depth: \ +for non-technical readers, ask about purpose, audience, and stakeholder \ +takeaways — never filenames, function names, or which source file does \ +what. Examples: "what problem does this repo solve?", "what do you get \ +after running it?", "what have recent changes been optimizing for?". \ +Each question has exactly three short options (A/B/C) grounded in this \ +repo: one correct, two plausible distractors. Also write a one- or \ +two-sentence answer explanation inside the details block. + + +${profile} + + + +${identityBlock} + + + +${graphifyReport || "(no Graphify report provided)"} + + + +${graphSummary || "(none)"} + + + +${recentPrs || "(no recent merged PRs found)"} + + +Output only the Markdown explainer, structured as: +# + +**In short:** + +## What it does +... + +## How it's put together +... + +## What the team has been working on +... + +## Why it matters +... + +## Quick check +<2-3 multiple-choice questions. Use this exact shape so tools can parse it:> + +**Q1. ** +- A)