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 +3 -0
- prxref/cli.py +214 -0
- prxref/config.py +147 -0
- prxref/forges/__init__.py +0 -0
- prxref/forges/base.py +101 -0
- prxref/forges/bitbucket.py +265 -0
- prxref/forges/github.py +190 -0
- prxref/forges/gitlab.py +369 -0
- prxref/formatter.py +197 -0
- prxref/llm.py +41 -0
- prxref/llm_backends.py +242 -0
- prxref/orchestrator.py +406 -0
- prxref/parser.py +110 -0
- prxref/prompts/.gitkeep +0 -0
- prxref/prompts/summary.md +11 -0
- prxref/prompts/worker.md +62 -0
- prxref/quality.py +265 -0
- prxref/reviewer.py +183 -0
- prxref/triage.py +367 -0
- prxref/webhooks.py +261 -0
- prxref-0.2.0.dist-info/METADATA +155 -0
- prxref-0.2.0.dist-info/RECORD +25 -0
- prxref-0.2.0.dist-info/WHEEL +4 -0
- prxref-0.2.0.dist-info/entry_points.txt +2 -0
- prxref-0.2.0.dist-info/licenses/LICENSE +21 -0
prxref/__init__.py
ADDED
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
|