prxref 0.2.0__py3-none-any.whl

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
prxref/__init__.py ADDED
@@ -0,0 +1,3 @@
1
+ """prxref — fast automated AI code review for Bitbucket, GitLab, and GitHub."""
2
+
3
+ __version__ = "0.2.0"
prxref/cli.py ADDED
@@ -0,0 +1,214 @@
1
+ """prxref command-line interface.
2
+
3
+ Provides two subcommands:
4
+ * ``review --pr-url URL`` — one-shot PR/MR review from a forge URL.
5
+ * ``serve [--port N] [--host H]`` — webhook listener daemon.
6
+
7
+ Non-blocking doctrine: ``review`` exits 0 on all review errors (empty diffs,
8
+ network failures, LLM timeouts, bad credentials), printing diagnostic notes to
9
+ stderr so a pipeline step never fails the build over an advisor's error. The one
10
+ exception is a missing-configuration error, which is a usage error rather than a
11
+ review outcome and exits 2.
12
+ """
13
+ from __future__ import annotations
14
+
15
+ import argparse
16
+ import importlib
17
+ import logging
18
+ import sys
19
+ import time
20
+ from typing import Any
21
+
22
+ import prxref
23
+ from prxref.config import load_config, make_forge
24
+ from prxref.forges.base import detect_forge
25
+ from prxref.llm import ConfigError
26
+
27
+ logger = logging.getLogger("prxref")
28
+
29
+
30
+ def _build_parser() -> argparse.ArgumentParser:
31
+ parser = argparse.ArgumentParser(
32
+ prog="prxref",
33
+ description="Fast automated AI code review for Bitbucket, GitLab, and GitHub.",
34
+ )
35
+ parser.add_argument(
36
+ "--version",
37
+ action="store_true",
38
+ help="print version and exit",
39
+ )
40
+ sub = parser.add_subparsers(dest="command")
41
+
42
+ rev = sub.add_parser("review", help="review one PR/MR from its web URL")
43
+ rev.add_argument(
44
+ "--pr-url",
45
+ required=True,
46
+ help="full URL of the PR or MR on Bitbucket, GitHub, or GitLab",
47
+ )
48
+ rev.add_argument(
49
+ "--no-post",
50
+ action="store_true",
51
+ help="dry run: do not post comments to the forge",
52
+ )
53
+ rev.add_argument(
54
+ "--max-chunks",
55
+ type=int,
56
+ default=None,
57
+ help="override the maximum number of diff chunks to review",
58
+ )
59
+ rev.add_argument(
60
+ "-v",
61
+ "--verbose",
62
+ action="store_true",
63
+ help="print timing, token, and findings breakdown to stdout",
64
+ )
65
+
66
+ srv = sub.add_parser("serve", help="run webhook listener daemon")
67
+ srv.add_argument(
68
+ "--port",
69
+ type=int,
70
+ default=8080,
71
+ help="HTTP port to listen on (default 8080)",
72
+ )
73
+ srv.add_argument(
74
+ "--host",
75
+ default="0.0.0.0",
76
+ help="bind address (default 0.0.0.0)",
77
+ )
78
+
79
+ return parser
80
+
81
+
82
+ def _fmt_counts(result: Any) -> str:
83
+ if not isinstance(result, dict):
84
+ return "-"
85
+ active = result.get("findings_active")
86
+ if not isinstance(active, list):
87
+ return "-"
88
+ sev: dict[str, int] = {}
89
+ for f in active:
90
+ sev[getattr(f, "severity", "?")] = sev.get(getattr(f, "severity", "?"), 0) + 1
91
+ items = [f"{k}={v}" for k, v in sorted(sev.items())]
92
+ return " ".join(items) if items else "0"
93
+
94
+
95
+ def _fmt_tokens(result: Any) -> str:
96
+ if not isinstance(result, dict):
97
+ return "0+0"
98
+ tokens = result.get("tokens")
99
+ if isinstance(tokens, dict):
100
+ return f"{tokens.get('input', 0)}+{tokens.get('output', 0)}"
101
+ inp = result.get("input_tokens", 0)
102
+ out = result.get("output_tokens", 0)
103
+ return f"{inp}+{out}"
104
+
105
+
106
+ def _print_summary(
107
+ result: Any,
108
+ elapsed_s: float,
109
+ *,
110
+ verbose: bool,
111
+ out=None,
112
+ ) -> None:
113
+ target = sys.stdout if out is None else out
114
+ verdict = result.get("verdict") if isinstance(result, dict) else result
115
+ print(f"verdict: {verdict if verdict is not None else 'done'}", file=target)
116
+ failed = result.get("chunks_failed", 0) if isinstance(result, dict) else 0
117
+ if failed:
118
+ reviewed = result.get("chunks_reviewed", 0)
119
+ print(f"coverage: {reviewed}/{reviewed + failed} chunks reviewed", file=target)
120
+ if not verbose:
121
+ return
122
+ dropped = result.get("findings_dropped", []) if isinstance(result, dict) else []
123
+ dropped = len(dropped) if isinstance(dropped, list) else 0
124
+ print(f"counts: {_fmt_counts(result)} (dropped: {dropped})", file=target)
125
+ print(f"elapsed: {elapsed_s:.1f}s tokens: {_fmt_tokens(result)}", file=target)
126
+
127
+
128
+ def _run_review(
129
+ url: str,
130
+ *,
131
+ post: bool = True,
132
+ max_chunks: int | None = None,
133
+ config: dict | None = None,
134
+ ) -> Any:
135
+ ref = detect_forge(url)
136
+ if ref is None:
137
+ return None
138
+ cfg = config if config is not None else load_config(max_chunks=max_chunks)
139
+ forge = make_forge(ref)
140
+ llm = importlib.import_module("prxref.llm_backends").create_llm_client(cfg)
141
+ orchestrate = importlib.import_module("prxref.orchestrator").orchestrate_review
142
+ return orchestrate(
143
+ forge=forge,
144
+ ref=ref,
145
+ llm=llm,
146
+ post=post,
147
+ max_chunks=max_chunks if max_chunks is not None else cfg.get("MAX_CHUNKS", 8),
148
+ )
149
+
150
+
151
+ def _webhook_handler(url: str) -> None:
152
+ try:
153
+ _run_review(url, post=True)
154
+ except Exception:
155
+ logger.exception("webhook review failed for %s", url)
156
+
157
+
158
+ def _cmd_review(args: argparse.Namespace) -> int:
159
+ t0 = time.perf_counter()
160
+ try:
161
+ result = _run_review(
162
+ args.pr_url,
163
+ post=not args.no_post,
164
+ max_chunks=args.max_chunks,
165
+ )
166
+ except ConfigError as exc:
167
+ print(f"configuration error: {exc}", file=sys.stderr)
168
+ return 2
169
+ except Exception as exc:
170
+ print(f"review failed: {exc}", file=sys.stderr)
171
+ logger.debug("review failed with traceback", exc_info=True)
172
+ return 0
173
+
174
+ if result is None:
175
+ print(
176
+ f"unrecognized PR URL {args.pr_url!r} — expected bitbucket.org, "
177
+ "github.com, or gitlab.com PR/MR link",
178
+ file=sys.stderr,
179
+ )
180
+ return 0
181
+
182
+ elapsed = time.perf_counter() - t0
183
+ _print_summary(result, elapsed, verbose=args.verbose)
184
+ return 0
185
+
186
+
187
+ def _cmd_serve(args: argparse.Namespace) -> int:
188
+ serve_fn = importlib.import_module("prxref.webhooks").serve
189
+ serve_fn(port=args.port, host=args.host, handler=_webhook_handler)
190
+ return 0
191
+
192
+
193
+ def main(argv: list[str] | None = None) -> int:
194
+ """CLI entry point dispatching ``review``, ``serve``, or ``--version``."""
195
+ parser = _build_parser()
196
+ args = parser.parse_args(argv)
197
+
198
+ logging.basicConfig(
199
+ level=logging.DEBUG if getattr(args, "verbose", False) else logging.INFO,
200
+ format="%(levelname)s %(message)s",
201
+ stream=sys.stderr,
202
+ )
203
+
204
+ if getattr(args, "version", False):
205
+ print(prxref.__version__)
206
+ return 0
207
+
208
+ if args.command == "review":
209
+ return _cmd_review(args)
210
+ if args.command == "serve":
211
+ return _cmd_serve(args)
212
+
213
+ parser.print_help(sys.stderr)
214
+ return 2
prxref/config.py ADDED
@@ -0,0 +1,147 @@
1
+ """Configuration loading and forge-factory wiring.
2
+
3
+ Canonical environment-variable table (every name prefixed PRXREF_):
4
+
5
+ LLM / pipeline:
6
+ PRXREF_LLM_BACKEND LLM backend: openai-compat | ferry | http (aliases) | litellm
7
+ PRXREF_LLM_BASE_URL Base URL for the chosen backend (optional)
8
+ PRXREF_LLM_API_KEY API key for the chosen backend (optional)
9
+ PRXREF_LLM_MODELS Comma-separated model fallback chain, first
10
+ that answers wins; empty = backend default
11
+ PRXREF_LLM_REASONING_EFFORT Reasoning effort for models that cannot
12
+ disable reasoning; provider-specific string,
13
+ passed through unvalidated; empty = omit
14
+ PRXREF_CONFIDENCE_FLOOR Findings below this confidence are dropped
15
+ (default 0.6)
16
+ PRXREF_MAX_ERROR_FINDINGS Max error-severity findings reported per
17
+ review (legacy alias: PRXREF_MAX_ERRORS)
18
+ PRXREF_MAX_CHUNKS Max diff chunks reviewed per PR (default 8)
19
+
20
+ Per-forge auth:
21
+ PRXREF_BITBUCKET_TOKEN Bitbucket bearer token
22
+ PRXREF_BITBUCKET_USER Bitbucket username (app-password pair)
23
+ PRXREF_BITBUCKET_APP_PASSWORD Bitbucket app password
24
+ PRXREF_GITHUB_TOKEN GitHub token (github.com)
25
+ PRXREF_GITHUB_ENTERPRISE_TOKEN GitHub Enterprise token (GHES hosts)
26
+ PRXREF_GITLAB_TOKEN GitLab token
27
+
28
+ Webhooks:
29
+ PRXREF_BITBUCKET_WEBHOOK_SECRET HMAC secret for Bitbucket webhook payloads
30
+ PRXREF_GITHUB_WEBHOOK_SECRET HMAC secret for GitHub webhook payloads
31
+ PRXREF_GITLAB_WEBHOOK_SECRET HMAC secret for GitLab webhook payloads
32
+ PRXREF_ALLOW_UNSIGNED literal "1" accepts unsigned
33
+ webhooks (default off; insecure)
34
+
35
+ Precedence: built-in defaults < environment < ``overrides`` kwargs.
36
+ ``None``-valued overrides are ignored (callers may pass optional values).
37
+ Unknown override keys raise ``ValueError`` so typos surface immediately.
38
+ """
39
+ from __future__ import annotations
40
+
41
+ import os
42
+
43
+ from prxref.forges.base import Forge, PRRef
44
+
45
+ from .quality import DEFAULT_MAX_ERRORS
46
+
47
+ _ENV_PREFIX = "PRXREF_"
48
+
49
+ _DEFAULTS: dict[str, object] = {
50
+ "llm_backend": "openai-compat",
51
+ "llm_base_url": "",
52
+ "llm_api_key": "",
53
+ "llm_models": [],
54
+ "llm_reasoning_effort": "",
55
+ "confidence_floor": 0.6,
56
+ "max_error_findings": DEFAULT_MAX_ERRORS,
57
+ "max_chunks": 8,
58
+ "bitbucket_token": "",
59
+ "bitbucket_user": "",
60
+ "bitbucket_app_password": "",
61
+ "github_token": "",
62
+ "github_enterprise_token": "",
63
+ "gitlab_token": "",
64
+ "bitbucket_webhook_secret": "",
65
+ "github_webhook_secret": "",
66
+ "gitlab_webhook_secret": "",
67
+ "allow_unsigned": False,
68
+ }
69
+
70
+ _INT_KEYS = frozenset({"max_error_findings", "max_chunks"})
71
+ _FLOAT_KEYS = frozenset({"confidence_floor"})
72
+ _BOOL_KEYS = frozenset({"allow_unsigned"})
73
+ _LIST_KEYS = frozenset({"llm_models"})
74
+
75
+ _LEGACY_ENV_ALIASES: dict[str, str] = {
76
+ "max_error_findings": _ENV_PREFIX + "MAX_ERRORS",
77
+ }
78
+
79
+
80
+ def _truthy(raw: str) -> bool:
81
+ """Parse a security-gating boolean; only the literal "1" enables it.
82
+
83
+ Deliberately rejects "true"/"yes"/"on" so a typo or a shell quirk fails safe
84
+ with verification left ON. Must stay identical to
85
+ prxref.webhooks._allow_unsigned, which is the gate that actually runs;
86
+ TestAllowUnsignedAgreesWithGate pins the two together.
87
+ """
88
+ return raw.strip() == "1"
89
+
90
+
91
+ def _coerce_env(key: str, raw: str) -> object:
92
+ try:
93
+ if key in _INT_KEYS:
94
+ return int(raw)
95
+ if key in _FLOAT_KEYS:
96
+ return float(raw)
97
+ if key in _BOOL_KEYS:
98
+ return _truthy(raw)
99
+ if key in _LIST_KEYS:
100
+ return [part.strip() for part in raw.split(",") if part.strip()]
101
+ return raw
102
+ except ValueError as exc:
103
+ raise ValueError(f"{_ENV_PREFIX}{key.upper()}: {exc}") from exc
104
+
105
+
106
+ def load_config(**overrides: object) -> dict:
107
+ """Build the runtime config dict from defaults, environment, then overrides.
108
+
109
+ Keys mirror the env table above (lowercase, no prefix). Env values are
110
+ type-coerced per key (int / float / bool / comma-list / str); a malformed
111
+ env value raises ``ValueError`` naming the offending variable.
112
+ """
113
+ cfg: dict[str, object] = dict(_DEFAULTS)
114
+ for key in _DEFAULTS:
115
+ raw = os.environ.get(_ENV_PREFIX + key.upper())
116
+ if raw is None or raw == "":
117
+ legacy = _LEGACY_ENV_ALIASES.get(key)
118
+ raw = os.environ.get(legacy) if legacy else None
119
+ if raw is None or raw == "":
120
+ continue
121
+ cfg[key] = _coerce_env(key, raw)
122
+ for key, value in overrides.items():
123
+ if key not in _DEFAULTS:
124
+ raise ValueError(f"unknown config key: {key!r}")
125
+ if value is None:
126
+ continue
127
+ cfg[key] = value
128
+ return cfg
129
+
130
+
131
+ def make_forge(ref: PRRef, session=None) -> Forge:
132
+ """Instantiate the ForgeImpl matching ``ref.forge``.
133
+
134
+ ``session`` optionally injects a custom ``requests.Session`` (tests,
135
+ shared connection pools). Unknown forge names raise ``ValueError``.
136
+ """
137
+ from prxref.forges import bitbucket, github, gitlab
138
+
139
+ impls = {
140
+ "bitbucket": bitbucket.ForgeImpl,
141
+ "github": github.ForgeImpl,
142
+ "gitlab": gitlab.ForgeImpl,
143
+ }
144
+ impl = impls.get(ref.forge)
145
+ if impl is None:
146
+ raise ValueError(f"unknown forge: {ref.forge!r}")
147
+ return impl(session=session)
File without changes
prxref/forges/base.py ADDED
@@ -0,0 +1,101 @@
1
+ """Forge contract: one Protocol, three implementations (bitbucket, github, gitlab).
2
+
3
+ Every value that flows through the pipeline is forge-agnostic past this module.
4
+ Diff handling is deliberately unified: each forge returns ONE raw unified diff
5
+ string; parsing/chunking happens downstream in triage.py, identically for all
6
+ three forges.
7
+ """
8
+ from __future__ import annotations
9
+
10
+ from collections.abc import Sequence
11
+ from dataclasses import dataclass
12
+ from typing import Protocol
13
+
14
+
15
+ @dataclass(frozen=True)
16
+ class PRRef:
17
+ """A pull/merge request identity, normalized across forges."""
18
+
19
+ forge: str # "bitbucket" | "github" | "gitlab"
20
+ host: str # e.g. "bitbucket.org", "github.com", "gitlab.com", or self-hosted host
21
+ owner: str # workspace / org / group
22
+ repo: str
23
+ number: int
24
+ url: str
25
+
26
+
27
+ @dataclass
28
+ class InlineComment:
29
+ """A comment anchored to one line of the new file."""
30
+
31
+ path: str
32
+ line: int # line in the NEW file
33
+ body: str
34
+ side: str = "RIGHT"
35
+
36
+
37
+ @dataclass
38
+ class Thread:
39
+ """An existing discussion thread on a PR (for dedup against re-review)."""
40
+
41
+ path: str | None
42
+ line: int | None
43
+ resolved: bool
44
+ author: str
45
+ body_snippet: str
46
+
47
+
48
+ @dataclass
49
+ class PRData:
50
+ """Normalized PR metadata returned by get_pr()."""
51
+
52
+ title: str
53
+ description: str
54
+ author: str
55
+ source_branch: str
56
+ target_branch: str
57
+ source_sha: str
58
+ target_sha: str
59
+ raw: dict # forge-native payload, for forge-specific needs
60
+
61
+
62
+ class Forge(Protocol):
63
+ """The contract every forge adapter implements."""
64
+
65
+ name: str
66
+
67
+ @staticmethod
68
+ def parse_pr_url(url: str) -> PRRef | None:
69
+ """Return a PRRef if this forge recognizes the URL, else None."""
70
+ ...
71
+
72
+ def get_pr(self, ref: PRRef) -> PRData:
73
+ """Fetch normalized PR metadata."""
74
+ ...
75
+
76
+ def get_diff(self, ref: PRRef) -> str:
77
+ """Fetch the raw unified diff of the PR (all files)."""
78
+ ...
79
+
80
+ def post_summary(self, ref: PRRef, body: str) -> None:
81
+ """Post (or update) the top-level review summary comment."""
82
+ ...
83
+
84
+ def post_inline_comments(self, ref: PRRef, comments: Sequence[InlineComment]) -> int:
85
+ """Post inline comments; returns the number actually posted."""
86
+ ...
87
+
88
+ def list_threads(self, ref: PRRef) -> list[Thread]:
89
+ """List existing threads so re-reviews skip already-discussed findings."""
90
+ ...
91
+
92
+
93
+ def detect_forge(url: str) -> PRRef | None:
94
+ """Try each registered forge's URL parser in order."""
95
+ from . import bitbucket, github, gitlab
96
+
97
+ for forge in (bitbucket, github, gitlab):
98
+ ref = forge.ForgeImpl.parse_pr_url(url)
99
+ if ref is not None:
100
+ return ref
101
+ return None