ci-review-tools#
Agentic code review for Tangled Spindle CI — packaged as a Nix flake.
Setup#
CI (Spindle pipeline)#
Add this to .tangled/workflows/review.yml:
when:
- event: ["push"]
branch: ["main"]
clone:
depth: 40
engine: microvm
image: nixos
dependencies:
- git+https://tangled.org/<you>/ci-review-tools?ref=main#default
steps:
- name: "agentic code review"
environment:
LLM_API_KEY: "${{ secrets.LLM_API_KEY }}"
LLM_MODEL: "${{ secrets.LLM_MODEL }}"
LLM_CONTEXT_LIMIT: "${{ secrets.LLM_CONTEXT_LIMIT }}"
ATP_APP_PASSWORD: "${{ secrets.ATP_APP_PASSWORD }}"
command: ci-review
Secrets (repo Settings → Secrets):
| Secret | Required | Default | Description |
|---|---|---|---|
LLM_API_KEY |
Yes | — | API key for OpenAI-compatible LLM |
LLM_MODEL |
Yes | — | Model name (e.g. gpt-4o, deepseek-v4-flash) |
ATP_APP_PASSWORD |
Yes | — | AT Protocol app password |
LLM_BASE_URL |
No | https://api.openai.com/v1 |
API base URL |
LLM_CONTEXT_LIMIT |
No | 120000 |
Max tokens before degradation |
LLM_MAX_TOKENS |
No | 16384 |
Max output tokens per LLM call |
DETECTIVE_RUNS |
No | 2 |
Detective passes per review (more = more consistent) |
CI automatically receives TANGLED_REPO_DID, TANGLED_REPO_URL,
TANGLED_REPO_NAME, and TANGLED_PIPELINE_ID from Spindle — no manual config.
Local development#
Create a .env file in the repo root:
LLM_API_KEY=sk-...
LLM_MODEL=deepseek-v4-flash
ATP_IDENTIFIER=xenolandscapes.com
ATP_APP_PASSWORD=xxxx-xxxx-xxxx-xxxx
Run from any repo:
uv run --with tree-sitter-language-pack --with tree-sitter --with tiktoken --with openai \
python3 /path/to/ci-review-tools/ci-review.py
PDS is auto-resolved from the handle's DID document. Repo DID is auto-resolved
from the repo record on your PDS. No manual PDS or TANGLED_REPO_URL needed.
Set DETECTIVE_RUNS=1 for faster (but noisier) local testing.
Review flow#
The review runs in two passes. The detective runs multiple times for deterministic results — each run finds a different subset of issues.
Pass 1: Detective (runs N times, default 2)#
Finds everything — exhaustive, no judgment, no filtering.
Context:
- Inline diffs (
git diff -U999999) for every changed file - Full commit messages with per-commit file lists
- Dependency modules (imported by changed files, sorted by uniqueness)
- AGENTS.md conventions
Output per run: JSON array of raw findings — title, summary, problem, fix, commit references. No severity or impact.
Pass 2: Triage (runs once)#
Deduplicates across detective runs, filters, and assigns severity.
Context:
- Full file contents of every changed file (not diffs)
- Dependency modules (same as pass 1)
- AGENTS.md conventions
- All raw findings from ALL detective runs (tagged with
_runnumber) - No commit messages — judges purely on code + conventions
Output: Severity (high/medium/low), impact map, and drop flag for each finding. Duplicate findings (same issue, different wording) are dropped.
Merged result: Detective's title/summary/problem/fix + triage's severity/impact. Dropped findings excluded.
Context degradation#
If the detective pass exceeds LLM_CONTEXT_LIMIT tokens, context is degraded
across 5 levels. Inline diffs are the most valuable context and survive longest:
| Level | Changed files | Commits | Deps | AGENTS.md |
|---|---|---|---|---|
| 0 | Inline diffs | Full (message + body + files) | All | Yes |
| 1 | Inline diffs | Short (subject + files only) | All | Yes |
| 2 | Inline diffs | None | All | Yes |
| 3 | Inline diffs | None | None | Yes |
| 4 | Compact diff (3 lines) | None | None | Yes |
Dependencies in levels 0-2 are sorted by uniqueness — files imported by
fewer changed files rank first (e.g. 1/37 files before 35/37 files).
Framework code everyone imports is deprioritized.
Dependency detection#
Tree-sitter parses changed files and resolves imports 1 level deep:
- Built-in detection (
process()fromtree-sitter-language-pack): Python, JavaScript, TypeScript, Go, Rust, Java - Custom tree-sitter queries (
QueryCursor): Lua (require()), C (#include) - Resolution via
git ls-files '*/module/path.ext'— works for any project structure, no path guessing
Push range detection#
- Authenticates with AT Protocol via app password
- Fetches the pipeline record at
TANGLED_PIPELINE_IDviagetRecord - Extracts
triggerMetadata.push.oldShaand.newSha— the exact push range - Fallback: if the fetch fails (manual trigger, network error), reviews
HEAD~20
Issue format#
Each finding becomes a Tangled issue with:
Title: Emoji severity + one-line summary (🔴 Null pointer in buffer.lua)
Body:
## Summary
Brief description referencing specific files/functions
## The Problem
Context and mechanism — what triggers it, why it happens
## Impact
- **correctness:** description
- **safety:** description
## Recommended Fix
Concrete fix with code pointers
---
**Commit range:** `abc1234`..`def5678`
**Code Review:** CR-2026-07-25-3
Issues are created on the repo's own DID (resolved from sh.tangled.repo
record), not the user DID. The repo field MUST be a bare DID per the lexicon.
Identity resolution#
On startup, the tool resolves the full AT Protocol identity chain:
handle (xenolandscapes.com)
→ DID (did:web:... or did:plc:...)
→ DID document (.well-known/did.json or plc.directory)
→ PDS endpoint (AtprotoPersonalDataServer service)
→ repo record (sh.tangled.repo/<name>)
→ repo's own DID (repoDid field)
All derived from a single handle. Handles did:web and did:plc identities.
In CI, TANGLED_REPO_DID (owner DID) is used for auth — no handle needed.