Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
13 changes: 13 additions & 0 deletions .env.example
Original file line number Diff line number Diff line change
@@ -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
3 changes: 3 additions & 0 deletions .github/workflows/example-usage.yml
Original file line number Diff line number Diff line change
Expand Up @@ -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 }}
1 change: 1 addition & 0 deletions .gitignore
Original file line number Diff line number Diff line change
Expand Up @@ -4,3 +4,4 @@ learning-profile.md
.DS_Store
LAUNCH.md
DESIGN_BRIEF.md
graphify-out/
33 changes: 33 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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.
Expand All @@ -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
Expand Down Expand Up @@ -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.
Expand All @@ -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:

Expand Down
11 changes: 11 additions & 0 deletions action.yml
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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"
Expand Down
42 changes: 40 additions & 2 deletions package-lock.json

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

7 changes: 6 additions & 1 deletion package.json
Original file line number Diff line number Diff line change
Expand Up @@ -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",
Expand All @@ -37,5 +39,8 @@
},
"engines": {
"node": ">=18"
},
"dependencies": {
"posthog-node": "^5.47.3"
}
}
105 changes: 105 additions & 0 deletions scripts/test-repo-prompt.js
Original file line number Diff line number Diff line change
@@ -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;
});
Loading
Loading