Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
29 commits
Select commit Hold shift + click to select a range
418fde8
feat: add LLM, GitHub, and agent parse error classes
Jul 30, 2026
0780f94
feat: wire Action job through ParitokGateway lifecycle
Jul 30, 2026
eb66a5a
feat: implement OpenAI-compatible LLMClient bindings
Jul 30, 2026
9aa4d01
feat: implement agent review loop and prompt templates
Jul 30, 2026
3569ba7
feat: normalize agent findings with floor, dedupe, and caps
Jul 30, 2026
8c5649a
feat: add dated pricing table for cost estimates
Jul 30, 2026
f93ac88
feat: build CostReceipt from Paritok stats and pricing
Jul 30, 2026
9a63c8b
feat: publish idempotent LeanCI PR review comments
Jul 30, 2026
e721d5d
feat: export RunRecord metrics JSON for CI artifacts
Jul 30, 2026
2fa85be
feat: wire orchestrator happy path and CLI review entrypoint
Jul 30, 2026
010856b
feat: add DualRunController for compressed vs baseline reviews
Jul 30, 2026
75dfc33
docs: document secrets and first Action end-to-end checklist
Jul 30, 2026
20d6373
fix: install paritok proxy extras so the Action proxy can start
Jul 30, 2026
ff714df
fix: route Paritok upstream via configurable OpenAI-compatible URL
Jul 30, 2026
2a49c6a
Use OpenRouter free router for CI e2e.
Jul 30, 2026
95d3d3e
Raise LLM timeout and use a specific free OpenRouter model.
Jul 30, 2026
acaeb35
Route CI reviews through Gemini instead of OpenRouter.
Jul 30, 2026
9d2894c
Use gemini-flash-latest for CI e2e.
Jul 30, 2026
6879aea
Include LLM HTTP error bodies in failures.
Jul 30, 2026
583eb16
Preserve Gemini thought signatures across tool turns.
Jul 30, 2026
f84c8aa
Survive Gemini free-tier rate limits during e2e.
Jul 31, 2026
bb21e36
Retry Gemini 429s more aggressively for free-tier e2e.
Jul 31, 2026
9e96e1d
Switch CI e2e to gemini-flash-lite-latest.
Jul 31, 2026
58a885d
Route CI e2e through Groq instead of Gemini.
Jul 31, 2026
075048a
Unblock Groq e2e behind Cloudflare and force review wrap-up.
Jul 31, 2026
4127ecd
Fit Groq free TPM by shrinking review context.
Jul 31, 2026
5412019
Tighten Groq e2e context under 6k TPM.
Jul 31, 2026
3c76af6
Use Groq 70b with the small e2e context window.
Jul 31, 2026
73eba26
Add Groq Llama rates so successful reviews can build receipts.
Jul 31, 2026
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
9 changes: 9 additions & 0 deletions .github/workflows/leanci.yml
Original file line number Diff line number Diff line change
Expand Up @@ -20,6 +20,15 @@ jobs:

- name: Review pull request
uses: ./action
with:
# Groq OpenAI-compatible upstream (Paritok --openai-url).
# Local UA forwarder unwraps Cloudflare; keep context under free TPM.
openai_url: https://api.groq.com/openai
model: llama-3.3-70b-versatile
max_files: '2'
max_bytes: '20000'
max_tool_turns: '8'
max_findings: '5'
env:
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
PARITOK_API_KEY: ${{ secrets.PARITOK_API_KEY }}
Expand Down
35 changes: 35 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,35 @@
# LeanCI

Dependency-aware AI pull request review with measured Paritok compression.

## First end-to-end run (M3.6)

### Required repository secrets

Configure these under **Settings → Secrets and variables → Actions**:

| Secret | Required | Purpose |
| --- | --- | --- |
| `PARITOK_API_KEY` | Yes | Auth for the Paritok GPU proxy (`use_gpu_server: true`) |
| `OPENAI_API_KEY` | Yes | Upstream model provider key (forwarded through Paritok) |
| `GITHUB_TOKEN` | Automatic | Provided by Actions; workflow passes it for PR comments |

Do not put secrets in `paritok.yaml` or commit them. The Action writes a key-free `paritok.yaml` under `$RUNNER_TEMP` and injects `PARITOK_API_KEY` via the process environment. See `paritok.yaml.example`.

### Workflow

- File: `.github/workflows/leanci.yml`
- Triggers: `pull_request` (`opened`, `synchronize`, `reopened`) and `workflow_dispatch`
- Permissions: `contents: read`, `pull-requests: write`
- Runs composite action `./action` (starts Paritok proxy, then `python -m leanci`)

### Minimal PR checklist

1. Commit and push LeanCI pipeline code to the default branch (or the branch the workflow runs from).
2. Set `PARITOK_API_KEY` and `OPENAI_API_KEY` repository secrets.
3. Open a pull request (or push to an existing PR) that changes Python files.
4. Confirm the **LeanCI** workflow run succeeds.
5. Confirm the PR has a LeanCI review comment (`<!-- leanci:review -->`) with findings and a cost receipt.
6. Confirm the `leanci-metrics` artifact (`leanci-metrics.json`) was uploaded.

Default mode is `paritok` (single compressed run). Dual-run is opt-in via Action input `mode: dual_run`.
43 changes: 40 additions & 3 deletions action/action.yml
Original file line number Diff line number Diff line change
Expand Up @@ -39,6 +39,19 @@ inputs:
description: Python version used to run the LeanCI agent.
required: false
default: '3.11'
paritok_port:
description: Local Paritok proxy port (OPENAI_BASE_URL becomes http://127.0.0.1:<port>/v1).
required: false
default: '8080'
openai_url:
description: Upstream OpenAI-compatible base URL passed to Paritok as --openai-url (e.g. https://openrouter.ai/api).
required: false
default: ''

outputs:
metrics_path:
description: Path to the LeanCI metrics JSON artifact.
value: ${{ github.workspace }}/leanci-metrics.json

runs:
using: composite
Expand All @@ -48,11 +61,21 @@ runs:
with:
python-version: ${{ inputs.python_version }}

- name: Install LeanCI
- name: Install LeanCI and Paritok
shell: bash
run: python -m pip install --disable-pip-version-check "${{ github.action_path }}/.."
run: |
python -m pip install --disable-pip-version-check "${{ github.action_path }}/..[action]"
python -m pip install --disable-pip-version-check 'paritok[proxy]>=1.2.8'

- name: Run LeanCI
- name: Ensure ripgrep
shell: bash
run: |
if ! command -v rg >/dev/null 2>&1; then
sudo apt-get update
sudo apt-get install -y ripgrep
fi

- name: Run LeanCI with Paritok proxy
shell: bash
run: "${{ github.action_path }}/entrypoint.sh"
env:
Expand All @@ -68,3 +91,17 @@ runs:
LEANCI_BASE_SHA: ${{ github.event.pull_request.base.sha || github.event.before || github.sha }}
LEANCI_HEAD_SHA: ${{ github.event.pull_request.head.sha || github.sha }}
LEANCI_PR_NUMBER: ${{ github.event.pull_request.number }}
LEANCI_PARITOK_PORT: ${{ inputs.paritok_port }}
LEANCI_OPENAI_URL: ${{ inputs.openai_url }}
LEANCI_METRICS_PATH: ${{ github.workspace }}/leanci-metrics.json
# OPENAI_BASE_URL is set by action_runtime after the proxy is healthy.
# Secrets (PARITOK_API_KEY, OPENAI_API_KEY, GITHUB_TOKEN) are inherited
# from the workflow `env:` on `uses: ./action` (TDD §5.1).

- name: Upload LeanCI metrics
if: always()
uses: actions/upload-artifact@v4
with:
name: leanci-metrics
path: ${{ github.workspace }}/leanci-metrics.json
if-no-files-found: warn
7 changes: 3 additions & 4 deletions action/entrypoint.sh
Original file line number Diff line number Diff line change
@@ -1,7 +1,6 @@
#!/usr/bin/env bash
# Runs the LeanCI agent inside a GitHub Actions job.
# The Paritok proxy sidecar is started here from M2 onward; until then this
# only resolves and prints the run configuration.
# GitHub Action entrypoint (TDD §5 / §10).
# Starts the Paritok proxy via ParitokGateway, runs LeanCI, always stops the proxy.
set -euo pipefail

python -m leanci --dry-run
exec python -m leanci.action_runtime
5 changes: 5 additions & 0 deletions paritok.yaml.example
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
# Example Paritok proxy config rendered at Action runtime (TDD §7.2).
# LeanCI writes an equivalent file under $RUNNER_TEMP without embedding secrets.
# Provide PARITOK_API_KEY via the process environment instead of this file.
use_gpu_server: true
gpu_server: {}
4 changes: 4 additions & 0 deletions pyproject.toml
Original file line number Diff line number Diff line change
Expand Up @@ -12,13 +12,17 @@ dependencies = []

[project.optional-dependencies]
dev = ["pytest>=8.0"]
action = []

[project.scripts]
leanci = "leanci.__main__:cli"

[tool.setuptools.packages.find]
where = ["src"]

[tool.setuptools.package-data]
leanci = ["prompts/*.md"]

[tool.pytest.ini_options]
testpaths = ["tests"]
addopts = "-q"
49 changes: 38 additions & 11 deletions src/leanci/__main__.py
Original file line number Diff line number Diff line change
Expand Up @@ -3,22 +3,20 @@
from __future__ import annotations

import argparse
import asyncio
import json
import os
import sys
from collections.abc import Mapping, Sequence

from leanci import __version__
from leanci.config import load_config
from leanci.errors import LeanCIError
from leanci.models import RunStatus
from leanci.orchestrator import run as run_review_pipeline

EXIT_OK = 0
EXIT_ERROR = 1
EXIT_UNAVAILABLE = 2

_NO_REVIEW_YET = (
"leanci: the review pipeline is not wired up yet; re-run with --dry-run "
"to print the resolved configuration"
)


def build_parser() -> argparse.ArgumentParser:
Expand All @@ -32,22 +30,51 @@ def build_parser() -> argparse.ArgumentParser:
action="store_true",
help="resolve configuration from the environment, print it (secrets redacted), and exit",
)
parser.add_argument(
"--no-publish",
action="store_true",
help="run the review pipeline but skip GitHub comment publish",
)
return parser


def main(argv: Sequence[str] | None = None, env: Mapping[str, str] | None = None) -> int:
args = build_parser().parse_args(argv)
if not args.dry_run:
print(_NO_REVIEW_YET, file=sys.stderr)
return EXIT_UNAVAILABLE
source = os.environ if env is None else env

try:
config = load_config(env)
config = load_config(source)
except LeanCIError as exc:
print(f"leanci: {exc}", file=sys.stderr)
return EXIT_ERROR

print(json.dumps(config.redacted(), indent=2, sort_keys=True))
if args.dry_run:
print(json.dumps(config.redacted(), indent=2, sort_keys=True))
return EXIT_OK

publish = not args.no_publish
if source.get("LEANCI_PUBLISH", "").strip().lower() in {"false", "0", "no", "off"}:
publish = False

record = asyncio.run(run_review_pipeline(config, env=source, publish=publish))
print(
json.dumps(
{
"run_id": record.run_id,
"status": str(record.status),
"findings_count": len(record.findings),
"errors": [
{"error_class": e.error_class, "message": e.message}
for e in record.errors
],
},
indent=2,
sort_keys=True,
)
)

if record.status is RunStatus.FAILED and config.fail_on_error:
return EXIT_ERROR
return EXIT_OK


Expand Down
100 changes: 100 additions & 0 deletions src/leanci/action_runtime.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,100 @@
"""GitHub Action job wrapper around ParitokGateway (TDD §5 / §10 / §22 M2.2).

Starts the local proxy via ``ParitokGateway``, exports ``OPENAI_BASE_URL`` for
the compressed LLM binding, runs ``python -m leanci``, and always stops the
proxy. Does not re-implement proxy spawn/health logic.
"""

from __future__ import annotations

import os
import sys
from collections.abc import Callable, Mapping, MutableMapping, Sequence
from pathlib import Path
from typing import Any

from leanci.__main__ import main as leanci_main
from leanci.paritok_gateway import ParitokGateway
from leanci.upstream_forwarder import UpstreamForwarder

GatewayFactory = Callable[..., Any]
Runner = Callable[[list[str], dict[str, str]], int]

_DEFAULT_PORT = 8080
_FORWARDER_PORT = 8099


def run_action_job(
argv: Sequence[str] | None = None,
*,
env: Mapping[str, str] | MutableMapping[str, str] | None = None,
gateway: Any | None = None,
gateway_factory: GatewayFactory | None = None,
runner: Runner | None = None,
) -> int:
"""Start Paritok, run LeanCI with ``OPENAI_BASE_URL`` set, always stop proxy."""
working_env = dict(os.environ if env is None else env)
leanci_argv = list(argv) if argv is not None else []

forwarder = _maybe_start_forwarder(working_env)
gw = gateway if gateway is not None else _build_gateway(working_env, gateway_factory)
run = runner if runner is not None else _default_runner

try:
gw.start()
working_env["OPENAI_BASE_URL"] = gw.base_url
# Propagate into this process so in-process runners (and children) see it.
os.environ["OPENAI_BASE_URL"] = gw.base_url
return run(leanci_argv, working_env)
finally:
gw.stop()
if forwarder is not None:
forwarder.stop()


def _maybe_start_forwarder(env: MutableMapping[str, str]) -> UpstreamForwarder | None:
"""For Groq (Cloudflare-fronted), front Paritok with a UA-bearing local hop."""
openai_url = (env.get("LEANCI_OPENAI_URL") or "").strip()
if "api.groq.com" not in openai_url:
return None
forwarder = UpstreamForwarder(
target_base="https://api.groq.com/openai",
port=int(env.get("LEANCI_UPSTREAM_FORWARD_PORT") or str(_FORWARDER_PORT)),
)
forwarder.start()
env["LEANCI_OPENAI_URL"] = forwarder.openai_url
os.environ["LEANCI_OPENAI_URL"] = forwarder.openai_url
return forwarder


def _build_gateway(
env: Mapping[str, str],
factory: GatewayFactory | None,
) -> Any:
port = int(env.get("LEANCI_PARITOK_PORT", str(_DEFAULT_PORT)))
runner_temp = env.get("RUNNER_TEMP") or env.get("TMPDIR") or str(Path.cwd())
config_path = Path(runner_temp) / "paritok.yaml"
kwargs: dict[str, Any] = {
"port": port,
"config_path": config_path,
"paritok_api_key": env.get("PARITOK_API_KEY"),
"openai_api_key": env.get("OPENAI_API_KEY"),
"openai_url": env.get("LEANCI_OPENAI_URL") or None,
}
if factory is not None:
return factory(**kwargs)
return ParitokGateway(**kwargs)


def _default_runner(argv: list[str], env: dict[str, str]) -> int:
return leanci_main(argv, env=env)


def main(argv: Sequence[str] | None = None) -> int:
"""CLI used by ``action/entrypoint.sh``: ``python -m leanci.action_runtime``."""
args = list(sys.argv[1:] if argv is None else argv)
return run_action_job(args)


if __name__ == "__main__":
raise SystemExit(main())
Loading
Loading