Skip to content
Open
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
46 changes: 43 additions & 3 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -16,8 +16,8 @@ GitHub PR reviews → download → extract → synthesize → dedupe → place
```

1. **Download** — fetch PR data (reviews, comments, diffs) via `gh` CLI
2. **Extract** — use Claude to identify actionable changes and generalizable rules from each comment (bot comments are filtered out automatically)
3. **Synthesize** — embed generalizations, cluster by similarity, extract validated rules. Each rule is scored based on the LLM's confidence multiplied by a factor for how many unique PRs the rule's evidence spans (1 PR = 0.6×, 2 = 0.85×, 3 = 0.95×, 4+ = 1.0×), so rules that came up across more reviews score higher.
2. **Extract** — use Claude to identify actionable changes and generalizable rules from each comment. Bot comments are filtered out automatically, *except* those a human has endorsed with a 👍 reaction — a thumbs-up reclaims a bot's autoreview comment as human-vetted signal (see [Reactions](#reactions))
3. **Synthesize** — embed generalizations, cluster by similarity, extract validated rules. Each rule is scored based on the LLM's confidence multiplied by a factor for how many unique PRs the rule's evidence spans (1 PR = 0.6×, 2 = 0.85×, 3 = 0.95×, 4+ = 1.0×), so rules that came up across more reviews score higher. A [reaction](#reactions) factor then adjusts the score: rules whose source comments were endorsed (👍) are boosted (1.25×), vetoed (👎) are penalized (0.4×).
4. **Dedupe** — three-pass deduplication (embedding clusters + category review + post-consolidation review)
5. **Place** — determine where each rule belongs (root, directory, file, cross-file), filtering by `min_score` floor (default 0.5)
6. **Group** — filter by `min_score` threshold (default 0.5) and organize by topic for progressive disclosure
Expand Down Expand Up @@ -123,6 +123,7 @@ uv run braindump --repo owner/repo run [--from STAGE] [--skip STAGE ...] [--sinc
- `--authors`: Author filter for extract (default: `all`)
- `--min-score`: Override rule score threshold (default: 0.5)
- `--max-rules N`: Cap the number of rules in group stage (top-scored)
- `--reaction-authors`: Comma-separated logins whose 👍/👎 reactions count (default: anyone). See [Reactions](#reactions)
- `--fresh`: Wipe all stage outputs and start from scratch

### `download` — Fetch PR data
Expand All @@ -140,6 +141,7 @@ uv run braindump --repo owner/repo extract [--authors USER] [--limit N] [--rando
- `--prs`: Filter to specific PR numbers (comma-separated)
- `--limit N`: Limit number of comments to process
- `--random`: Randomly sample comments (with `--seed` for reproducibility)
- `--reaction-authors`: Comma-separated logins whose 👍/👎 reactions count (default: anyone). See [Reactions](#reactions)

### `synthesize` — Cluster and validate rules

Expand Down Expand Up @@ -182,6 +184,25 @@ uv run braindump --repo owner/repo run --from group --max-rules 80
uv run braindump --repo owner/repo generate [--dry-run] [--concurrency 10]
```

### `apply` — Merge generated files into a repo

`generate` writes to the staging directory (`data/<owner>/<repo>/7-generate/`). `apply` merges those files into an actual repo **without clobbering hand-written content**:

```bash
uv run braindump --repo owner/repo apply --into /path/to/repo [--dry-run]
```

Every generated file wraps braindump's output between `<!-- braindump: ... -->` and `<!-- /braindump -->` markers. When merging:

- If the target file already has these markers → only the fenced block is replaced; anything you wrote above or below it is preserved.
- If the target exists without markers → the block is appended after your content.
- If the target doesn't exist → it's created.

This applies uniformly to `AGENTS.md` and topic docs under `agent_docs/`, so it's safe to keep your own additions in any of them. Files braindump doesn't generate are never touched. Use `--dry-run` to preview which files would be created/merged/left unchanged.

> [!NOTE]
> Edits made *inside* the fenced block are overwritten on the next `apply`. Keep your hand-written content outside the markers.

### `status` — Pipeline status

```bash
Expand All @@ -206,7 +227,7 @@ All per-repo data lives under `data/<owner>/<repo>/`:
data/
pydantic/
pydantic-ai/
1-download/ # Raw GitHub API data (PRs, diffs, review comments)
1-download/ # Raw GitHub API data (PRs, diffs, review comments, reactions)
2-extract/ # extractions.jsonl, checkpoint.json
3-synthesize/ # rules.jsonl, embeddings, clusters
4-dedupe/ # rules.jsonl (deduped), embeddings, merge_log
Expand All @@ -233,6 +254,25 @@ Overrides cluster with similar extracted rules and replace them during the `dedu
uv run braindump --repo owner/repo run --from dedupe --fresh
```

## Reactions

A 👍/👎 reaction on a review comment is an explicit human verdict on that comment — the highest-precision signal in the pipeline. `download` fetches reaction attribution (who reacted) for any comment that has reactions, and it feeds two things:

- **Reclaiming bot comments.** Bot autoreview comments (e.g. `github-actions[bot]`, `coderabbitai[bot]`) are normally filtered out. But if you 👍 one, it's reclaimed and its rules flow into the pipeline as human-vetted signal. This lets you curate a bot reviewer's output by simply reacting to the good comments.
- **Scoring.** The net thumbs signal (👍 minus 👎) across a rule's source comments adjusts its score: endorsed rules are boosted (1.25×), vetoed rules penalized (0.4×). An endorsed rule from a single PR can clear the score floor it would otherwise miss; a vetoed rule can drop below it and be excluded.

By default any user's reactions count. Use `--reaction-authors` to restrict this to specific logins (e.g. maintainers):

```bash
uv run braindump --repo owner/repo run --reaction-authors alice,bob
```

Reactions are captured at `download`, but the allowlist is applied at `extract`, so changing `--reaction-authors` only requires re-running from extract — no re-download:

```bash
uv run braindump --repo owner/repo run --from extract --reaction-authors alice
```

## Model configuration

By default, braindump uses the [Pydantic AI Gateway](https://ai.pydantic.dev/gateway/) with `anthropic:claude-sonnet-4-5` for LLM tasks and `openai:text-embedding-3-small` for embeddings. Both models are configurable via flags or environment variables:
Expand Down
2 changes: 2 additions & 0 deletions src/braindump/cli.py
Original file line number Diff line number Diff line change
Expand Up @@ -51,6 +51,7 @@ def main(


# Register all commands
from braindump.commands.apply import apply as _apply # noqa: E402
from braindump.commands.dedupe import dedupe as _dedupe # noqa: E402
from braindump.commands.download import download as _download # noqa: E402
from braindump.commands.extract import extract as _extract # noqa: E402
Expand All @@ -69,6 +70,7 @@ def main(
app.command(name="place")(_place)
app.command(name="group")(_group)
app.command(name="generate")(_generate)
app.command(name="apply")(_apply)
app.command(name="run")(_run)
app.command(name="lookup")(_lookup)
app.command(name="status")(_status)
58 changes: 58 additions & 0 deletions src/braindump/commands/apply.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,58 @@
"""Apply generated files into a target repo via non-destructive block merge."""

from __future__ import annotations

from pathlib import Path

import typer

from braindump.commands.generate import merge_braindump_block
from braindump.config import RepoConfig
from braindump.progress import console


def _run(config: RepoConfig, into: Path, dry_run: bool = False) -> None:
output_dir = config.output_dir
generated = sorted(output_dir.rglob("*.md")) if output_dir.exists() else []
if not generated:
console.print(
f"[red]No generated .md files under {output_dir}. Run `generate` first.[/red]"
)
raise typer.Exit(1)

into = into.resolve()
created = merged = unchanged = 0
for src in generated:
rel = src.relative_to(output_dir)
target = into / rel
content = src.read_text()

if target.exists():
existing = target.read_text()
new_content = merge_braindump_block(existing, content)
if new_content == existing:
verb, unchanged = "unchanged", unchanged + 1
else:
verb, merged = "merge", merged + 1
else:
new_content, verb, created = content, "create", created + 1

console.print(f"[dim]{verb:9}[/dim] {target}")
if not dry_run and verb != "unchanged":
target.parent.mkdir(parents=True, exist_ok=True)
target.write_text(new_content)

prefix = "[yellow]Dry run — nothing written.[/yellow] " if dry_run else ""
console.print(
f"\n{prefix}{created} created, {merged} merged, {unchanged} unchanged (into {into})"
)


def apply(
ctx: typer.Context,
into: Path = typer.Option(..., "--into", help="Target repo root to merge generated files into"),
dry_run: bool = typer.Option(False, "--dry-run", help="Show what would change without writing"),
) -> None:
"""Merge generated AGENTS.md files into a repo, preserving hand-written content."""
config: RepoConfig = ctx.obj["config"]
_run(config, into=into, dry_run=dry_run)
13 changes: 11 additions & 2 deletions src/braindump/commands/dedupe.py
Original file line number Diff line number Diff line change
Expand Up @@ -198,6 +198,7 @@ def load_overrides(overrides_path: Path) -> list[dict]:
"source_comments": [],
"source_prs": [],
"unique_pr_count": 5,
"reaction_net": 0,
"cluster_coherence": 1.0,
"common_pattern": "",
"_is_override": True,
Expand Down Expand Up @@ -303,6 +304,7 @@ def _build_output_rules(
combined_comments = list({c for r in source_rules for c in r["source_comments"]})
combined_prs = sorted({p for r in source_rules for p in r["source_prs"]})
unique_pr_count = len(combined_prs)
combined_reaction_net = sum(r.get("reaction_net", 0) for r in source_rules)
# Pick the lowest unused source rule ID to avoid duplicates
# when the LLM references overlapping sources across output rules
available_ids = sorted(r["rule_id"] for r in source_rules)
Expand All @@ -327,7 +329,9 @@ def _build_output_rules(
"text": consolidated.text,
"reason": consolidated.reason,
"confidence": consolidated.confidence,
"rule_score": calculate_rule_score(consolidated.confidence, unique_pr_count),
"rule_score": calculate_rule_score(
consolidated.confidence, unique_pr_count, combined_reaction_net
),
"category": consolidated.category,
"scope": consolidated.scope,
"example_bad": example_bad,
Expand All @@ -336,6 +340,7 @@ def _build_output_rules(
"source_comments": combined_comments,
"source_prs": combined_prs,
"unique_pr_count": unique_pr_count,
"reaction_net": combined_reaction_net,
"cluster_coherence": max(r.get("cluster_coherence", 0) for r in source_rules),
"common_pattern": best_source.get("common_pattern", ""),
"_merged_from": sorted(r["rule_id"] for r in source_rules),
Expand All @@ -356,6 +361,7 @@ async def consolidate_cluster(cluster_rules: list[dict]) -> tuple[list[dict], De
combined_comments = list({c for r in extracted for c in r.get("source_comments", [])})
combined_prs = sorted({p for r in extracted for p in r.get("source_prs", [])})
unique_pr_count = max(len(combined_prs), override["unique_pr_count"])
combined_reaction_net = sum(r.get("reaction_net", 0) for r in extracted)
example_bad = override.get("example_bad")
example_good = override.get("example_good")
for r in extracted:
Expand All @@ -382,7 +388,9 @@ async def consolidate_cluster(cluster_rules: list[dict]) -> tuple[list[dict], De
"text": override["text"],
"reason": override["reason"],
"confidence": override["confidence"],
"rule_score": calculate_rule_score(override["confidence"], unique_pr_count),
"rule_score": calculate_rule_score(
override["confidence"], unique_pr_count, combined_reaction_net
),
"category": category,
"scope": scope,
"example_bad": example_bad,
Expand All @@ -391,6 +399,7 @@ async def consolidate_cluster(cluster_rules: list[dict]) -> tuple[list[dict], De
"source_comments": combined_comments,
"source_prs": combined_prs,
"unique_pr_count": unique_pr_count,
"reaction_net": combined_reaction_net,
"cluster_coherence": max(
(r.get("cluster_coherence", 0) for r in extracted), default=1.0
),
Expand Down
54 changes: 52 additions & 2 deletions src/braindump/commands/download.py
Original file line number Diff line number Diff line change
Expand Up @@ -30,6 +30,42 @@ async def _gh_async(*args: str) -> tuple[int, str, str]:
return proc.returncode or 0, stdout.decode(), stderr.decode()


async def _fetch_reactions(repo: str, output_dir, pr_num: int, comments_json: str) -> None:
"""Fetch reactor attribution for a PR's reacted review comments → sidecar.

Writes ``review_comment_reactions/{pr_num}.json`` mapping comment id (str)
to a list of ``{"content", "user"}`` entries. Comments with no reactions are
skipped, so the file is empty ``{}`` for most PRs.
"""
try:
comments = json.loads(comments_json)
except json.JSONDecodeError:
comments = []

reactions_by_comment: dict[str, list[dict]] = {}
for c in comments:
if c.get("reactions", {}).get("total_count", 0) <= 0:
continue
cid = c["id"]
with logfire.span("fetch reactions: comment {cid}", cid=cid):
rc, stdout, _ = await _gh_async(
"api", f"repos/{repo}/pulls/comments/{cid}/reactions", "--paginate"
)
if rc != 0:
continue
try:
entries = json.loads(stdout)
except json.JSONDecodeError:
entries = []
reactions_by_comment[str(cid)] = [
{"content": e.get("content"), "user": e.get("user", {}).get("login")} for e in entries
]

(output_dir / "review_comment_reactions" / f"{pr_num}.json").write_text(
json.dumps(reactions_by_comment)
)


@logfire.instrument("download", extract_args={"config": ["repo"]})
def _run(
config: RepoConfig,
Expand All @@ -54,7 +90,14 @@ def _run(
shutil.rmtree(output_dir)

# Create directories
for subdir in ["prs", "reviews", "review_comments", "diffs", "issue_comments"]:
for subdir in [
"prs",
"reviews",
"review_comments",
"review_comment_reactions",
"diffs",
"issue_comments",
]:
(output_dir / subdir).mkdir(parents=True, exist_ok=True)

# Build search filter
Expand Down Expand Up @@ -105,12 +148,13 @@ async def _async_download(
semaphore = asyncio.Semaphore(concurrency)

def _pr_already_downloaded(pr_num: int) -> bool:
"""Check if all 5 expected files exist for a PR."""
"""Check if all expected files exist for a PR."""
return all(
[
(output_dir / "prs" / f"{pr_num}.json").exists(),
(output_dir / "reviews" / f"{pr_num}.json").exists(),
(output_dir / "review_comments" / f"{pr_num}.json").exists(),
(output_dir / "review_comment_reactions" / f"{pr_num}.json").exists(),
(output_dir / "diffs" / f"{pr_num}.diff").exists(),
(output_dir / "issue_comments" / f"{pr_num}.json").exists(),
]
Expand Down Expand Up @@ -159,6 +203,12 @@ async def download_pr(pr_num: int) -> None:
stderr=stderr[:200],
)

# Reaction attribution: for comments that carry reactions, fetch
# who reacted (a 👍/👎 is an explicit human verdict on the comment).
# Only reacted comments are queried, so this is cheap.
if rc == 0:
await _fetch_reactions(repo, output_dir, pr_num, stdout)

# Diff
with logfire.span("fetch diff: #{pr_num}", pr_num=pr_num):
rc, stdout, stderr = await _gh_async(
Expand Down
Loading
Loading