Skip to content

Commit 4ba7871

Browse files
authored
Merge pull request #1 from CodewithJha/feat/e2e-review-pipeline
Enable end-to-end LeanCI PR review pipeline
2 parents 79b6ec7 + 73eba26 commit 4ba7871

36 files changed

Lines changed: 4808 additions & 22 deletions

.github/workflows/leanci.yml

Lines changed: 9 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -20,6 +20,15 @@ jobs:
2020

2121
- name: Review pull request
2222
uses: ./action
23+
with:
24+
# Groq OpenAI-compatible upstream (Paritok --openai-url).
25+
# Local UA forwarder unwraps Cloudflare; keep context under free TPM.
26+
openai_url: https://api.groq.com/openai
27+
model: llama-3.3-70b-versatile
28+
max_files: '2'
29+
max_bytes: '20000'
30+
max_tool_turns: '8'
31+
max_findings: '5'
2332
env:
2433
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
2534
PARITOK_API_KEY: ${{ secrets.PARITOK_API_KEY }}

README.md

Lines changed: 35 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,35 @@
1+
# LeanCI
2+
3+
Dependency-aware AI pull request review with measured Paritok compression.
4+
5+
## First end-to-end run (M3.6)
6+
7+
### Required repository secrets
8+
9+
Configure these under **Settings → Secrets and variables → Actions**:
10+
11+
| Secret | Required | Purpose |
12+
| --- | --- | --- |
13+
| `PARITOK_API_KEY` | Yes | Auth for the Paritok GPU proxy (`use_gpu_server: true`) |
14+
| `OPENAI_API_KEY` | Yes | Upstream model provider key (forwarded through Paritok) |
15+
| `GITHUB_TOKEN` | Automatic | Provided by Actions; workflow passes it for PR comments |
16+
17+
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`.
18+
19+
### Workflow
20+
21+
- File: `.github/workflows/leanci.yml`
22+
- Triggers: `pull_request` (`opened`, `synchronize`, `reopened`) and `workflow_dispatch`
23+
- Permissions: `contents: read`, `pull-requests: write`
24+
- Runs composite action `./action` (starts Paritok proxy, then `python -m leanci`)
25+
26+
### Minimal PR checklist
27+
28+
1. Commit and push LeanCI pipeline code to the default branch (or the branch the workflow runs from).
29+
2. Set `PARITOK_API_KEY` and `OPENAI_API_KEY` repository secrets.
30+
3. Open a pull request (or push to an existing PR) that changes Python files.
31+
4. Confirm the **LeanCI** workflow run succeeds.
32+
5. Confirm the PR has a LeanCI review comment (`<!-- leanci:review -->`) with findings and a cost receipt.
33+
6. Confirm the `leanci-metrics` artifact (`leanci-metrics.json`) was uploaded.
34+
35+
Default mode is `paritok` (single compressed run). Dual-run is opt-in via Action input `mode: dual_run`.

action/action.yml

Lines changed: 40 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -39,6 +39,19 @@ inputs:
3939
description: Python version used to run the LeanCI agent.
4040
required: false
4141
default: '3.11'
42+
paritok_port:
43+
description: Local Paritok proxy port (OPENAI_BASE_URL becomes http://127.0.0.1:<port>/v1).
44+
required: false
45+
default: '8080'
46+
openai_url:
47+
description: Upstream OpenAI-compatible base URL passed to Paritok as --openai-url (e.g. https://openrouter.ai/api).
48+
required: false
49+
default: ''
50+
51+
outputs:
52+
metrics_path:
53+
description: Path to the LeanCI metrics JSON artifact.
54+
value: ${{ github.workspace }}/leanci-metrics.json
4255

4356
runs:
4457
using: composite
@@ -48,11 +61,21 @@ runs:
4861
with:
4962
python-version: ${{ inputs.python_version }}
5063

51-
- name: Install LeanCI
64+
- name: Install LeanCI and Paritok
5265
shell: bash
53-
run: python -m pip install --disable-pip-version-check "${{ github.action_path }}/.."
66+
run: |
67+
python -m pip install --disable-pip-version-check "${{ github.action_path }}/..[action]"
68+
python -m pip install --disable-pip-version-check 'paritok[proxy]>=1.2.8'
5469
55-
- name: Run LeanCI
70+
- name: Ensure ripgrep
71+
shell: bash
72+
run: |
73+
if ! command -v rg >/dev/null 2>&1; then
74+
sudo apt-get update
75+
sudo apt-get install -y ripgrep
76+
fi
77+
78+
- name: Run LeanCI with Paritok proxy
5679
shell: bash
5780
run: "${{ github.action_path }}/entrypoint.sh"
5881
env:
@@ -68,3 +91,17 @@ runs:
6891
LEANCI_BASE_SHA: ${{ github.event.pull_request.base.sha || github.event.before || github.sha }}
6992
LEANCI_HEAD_SHA: ${{ github.event.pull_request.head.sha || github.sha }}
7093
LEANCI_PR_NUMBER: ${{ github.event.pull_request.number }}
94+
LEANCI_PARITOK_PORT: ${{ inputs.paritok_port }}
95+
LEANCI_OPENAI_URL: ${{ inputs.openai_url }}
96+
LEANCI_METRICS_PATH: ${{ github.workspace }}/leanci-metrics.json
97+
# OPENAI_BASE_URL is set by action_runtime after the proxy is healthy.
98+
# Secrets (PARITOK_API_KEY, OPENAI_API_KEY, GITHUB_TOKEN) are inherited
99+
# from the workflow `env:` on `uses: ./action` (TDD §5.1).
100+
101+
- name: Upload LeanCI metrics
102+
if: always()
103+
uses: actions/upload-artifact@v4
104+
with:
105+
name: leanci-metrics
106+
path: ${{ github.workspace }}/leanci-metrics.json
107+
if-no-files-found: warn

action/entrypoint.sh

Lines changed: 3 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -1,7 +1,6 @@
11
#!/usr/bin/env bash
2-
# Runs the LeanCI agent inside a GitHub Actions job.
3-
# The Paritok proxy sidecar is started here from M2 onward; until then this
4-
# only resolves and prints the run configuration.
2+
# GitHub Action entrypoint (TDD §5 / §10).
3+
# Starts the Paritok proxy via ParitokGateway, runs LeanCI, always stops the proxy.
54
set -euo pipefail
65

7-
python -m leanci --dry-run
6+
exec python -m leanci.action_runtime

paritok.yaml.example

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,5 @@
1+
# Example Paritok proxy config rendered at Action runtime (TDD §7.2).
2+
# LeanCI writes an equivalent file under $RUNNER_TEMP without embedding secrets.
3+
# Provide PARITOK_API_KEY via the process environment instead of this file.
4+
use_gpu_server: true
5+
gpu_server: {}

pyproject.toml

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -12,13 +12,17 @@ dependencies = []
1212

1313
[project.optional-dependencies]
1414
dev = ["pytest>=8.0"]
15+
action = []
1516

1617
[project.scripts]
1718
leanci = "leanci.__main__:cli"
1819

1920
[tool.setuptools.packages.find]
2021
where = ["src"]
2122

23+
[tool.setuptools.package-data]
24+
leanci = ["prompts/*.md"]
25+
2226
[tool.pytest.ini_options]
2327
testpaths = ["tests"]
2428
addopts = "-q"

src/leanci/__main__.py

Lines changed: 38 additions & 11 deletions
Original file line numberDiff line numberDiff line change
@@ -3,22 +3,20 @@
33
from __future__ import annotations
44

55
import argparse
6+
import asyncio
67
import json
8+
import os
79
import sys
810
from collections.abc import Mapping, Sequence
911

1012
from leanci import __version__
1113
from leanci.config import load_config
1214
from leanci.errors import LeanCIError
15+
from leanci.models import RunStatus
16+
from leanci.orchestrator import run as run_review_pipeline
1317

1418
EXIT_OK = 0
1519
EXIT_ERROR = 1
16-
EXIT_UNAVAILABLE = 2
17-
18-
_NO_REVIEW_YET = (
19-
"leanci: the review pipeline is not wired up yet; re-run with --dry-run "
20-
"to print the resolved configuration"
21-
)
2220

2321

2422
def build_parser() -> argparse.ArgumentParser:
@@ -32,22 +30,51 @@ def build_parser() -> argparse.ArgumentParser:
3230
action="store_true",
3331
help="resolve configuration from the environment, print it (secrets redacted), and exit",
3432
)
33+
parser.add_argument(
34+
"--no-publish",
35+
action="store_true",
36+
help="run the review pipeline but skip GitHub comment publish",
37+
)
3538
return parser
3639

3740

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

4445
try:
45-
config = load_config(env)
46+
config = load_config(source)
4647
except LeanCIError as exc:
4748
print(f"leanci: {exc}", file=sys.stderr)
4849
return EXIT_ERROR
4950

50-
print(json.dumps(config.redacted(), indent=2, sort_keys=True))
51+
if args.dry_run:
52+
print(json.dumps(config.redacted(), indent=2, sort_keys=True))
53+
return EXIT_OK
54+
55+
publish = not args.no_publish
56+
if source.get("LEANCI_PUBLISH", "").strip().lower() in {"false", "0", "no", "off"}:
57+
publish = False
58+
59+
record = asyncio.run(run_review_pipeline(config, env=source, publish=publish))
60+
print(
61+
json.dumps(
62+
{
63+
"run_id": record.run_id,
64+
"status": str(record.status),
65+
"findings_count": len(record.findings),
66+
"errors": [
67+
{"error_class": e.error_class, "message": e.message}
68+
for e in record.errors
69+
],
70+
},
71+
indent=2,
72+
sort_keys=True,
73+
)
74+
)
75+
76+
if record.status is RunStatus.FAILED and config.fail_on_error:
77+
return EXIT_ERROR
5178
return EXIT_OK
5279

5380

src/leanci/action_runtime.py

Lines changed: 100 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,100 @@
1+
"""GitHub Action job wrapper around ParitokGateway (TDD §5 / §10 / §22 M2.2).
2+
3+
Starts the local proxy via ``ParitokGateway``, exports ``OPENAI_BASE_URL`` for
4+
the compressed LLM binding, runs ``python -m leanci``, and always stops the
5+
proxy. Does not re-implement proxy spawn/health logic.
6+
"""
7+
8+
from __future__ import annotations
9+
10+
import os
11+
import sys
12+
from collections.abc import Callable, Mapping, MutableMapping, Sequence
13+
from pathlib import Path
14+
from typing import Any
15+
16+
from leanci.__main__ import main as leanci_main
17+
from leanci.paritok_gateway import ParitokGateway
18+
from leanci.upstream_forwarder import UpstreamForwarder
19+
20+
GatewayFactory = Callable[..., Any]
21+
Runner = Callable[[list[str], dict[str, str]], int]
22+
23+
_DEFAULT_PORT = 8080
24+
_FORWARDER_PORT = 8099
25+
26+
27+
def run_action_job(
28+
argv: Sequence[str] | None = None,
29+
*,
30+
env: Mapping[str, str] | MutableMapping[str, str] | None = None,
31+
gateway: Any | None = None,
32+
gateway_factory: GatewayFactory | None = None,
33+
runner: Runner | None = None,
34+
) -> int:
35+
"""Start Paritok, run LeanCI with ``OPENAI_BASE_URL`` set, always stop proxy."""
36+
working_env = dict(os.environ if env is None else env)
37+
leanci_argv = list(argv) if argv is not None else []
38+
39+
forwarder = _maybe_start_forwarder(working_env)
40+
gw = gateway if gateway is not None else _build_gateway(working_env, gateway_factory)
41+
run = runner if runner is not None else _default_runner
42+
43+
try:
44+
gw.start()
45+
working_env["OPENAI_BASE_URL"] = gw.base_url
46+
# Propagate into this process so in-process runners (and children) see it.
47+
os.environ["OPENAI_BASE_URL"] = gw.base_url
48+
return run(leanci_argv, working_env)
49+
finally:
50+
gw.stop()
51+
if forwarder is not None:
52+
forwarder.stop()
53+
54+
55+
def _maybe_start_forwarder(env: MutableMapping[str, str]) -> UpstreamForwarder | None:
56+
"""For Groq (Cloudflare-fronted), front Paritok with a UA-bearing local hop."""
57+
openai_url = (env.get("LEANCI_OPENAI_URL") or "").strip()
58+
if "api.groq.com" not in openai_url:
59+
return None
60+
forwarder = UpstreamForwarder(
61+
target_base="https://api.groq.com/openai",
62+
port=int(env.get("LEANCI_UPSTREAM_FORWARD_PORT") or str(_FORWARDER_PORT)),
63+
)
64+
forwarder.start()
65+
env["LEANCI_OPENAI_URL"] = forwarder.openai_url
66+
os.environ["LEANCI_OPENAI_URL"] = forwarder.openai_url
67+
return forwarder
68+
69+
70+
def _build_gateway(
71+
env: Mapping[str, str],
72+
factory: GatewayFactory | None,
73+
) -> Any:
74+
port = int(env.get("LEANCI_PARITOK_PORT", str(_DEFAULT_PORT)))
75+
runner_temp = env.get("RUNNER_TEMP") or env.get("TMPDIR") or str(Path.cwd())
76+
config_path = Path(runner_temp) / "paritok.yaml"
77+
kwargs: dict[str, Any] = {
78+
"port": port,
79+
"config_path": config_path,
80+
"paritok_api_key": env.get("PARITOK_API_KEY"),
81+
"openai_api_key": env.get("OPENAI_API_KEY"),
82+
"openai_url": env.get("LEANCI_OPENAI_URL") or None,
83+
}
84+
if factory is not None:
85+
return factory(**kwargs)
86+
return ParitokGateway(**kwargs)
87+
88+
89+
def _default_runner(argv: list[str], env: dict[str, str]) -> int:
90+
return leanci_main(argv, env=env)
91+
92+
93+
def main(argv: Sequence[str] | None = None) -> int:
94+
"""CLI used by ``action/entrypoint.sh``: ``python -m leanci.action_runtime``."""
95+
args = list(sys.argv[1:] if argv is None else argv)
96+
return run_action_job(args)
97+
98+
99+
if __name__ == "__main__":
100+
raise SystemExit(main())

0 commit comments

Comments
 (0)