Compare commits
2 Commits
f59b906395
...
ec26ec000a
| Author | SHA1 | Date | |
|---|---|---|---|
| ec26ec000a | |||
| 770581bf53 |
+415
-29
@@ -67,8 +67,27 @@ _SHA_MARKER_RE = re.compile(r"<!-- pragent:sha=([0-9a-f]{7,40}) -->")
|
|||||||
|
|
||||||
AI_REVIEW_LABEL = "AI-REVIEW"
|
AI_REVIEW_LABEL = "AI-REVIEW"
|
||||||
SEVERITIES = ("critical", "high", "medium", "low")
|
SEVERITIES = ("critical", "high", "medium", "low")
|
||||||
|
# Severity rank — higher = more severe. Used by `apply_repo_config` to drop
|
||||||
|
# findings below `severity_threshold`. Critical=3, high=2, medium=1, low=0.
|
||||||
|
SEVERITY_RANK = {"low": 0, "medium": 1, "high": 2, "critical": 3}
|
||||||
REPO_CONFIG_FILE = ".pr-review.json"
|
REPO_CONFIG_FILE = ".pr-review.json"
|
||||||
|
|
||||||
|
# Style → (default max_findings, default severity_threshold). Strict is
|
||||||
|
# terse/high-signal; lenient shows everything; balanced is the default for
|
||||||
|
# unconfigured repos. Repo `.pr-review.json` overrides per-field.
|
||||||
|
STYLE_DEFAULTS: dict[str, tuple[int, str]] = {
|
||||||
|
"strict": (5, "high"),
|
||||||
|
"balanced": (12, "medium"),
|
||||||
|
"lenient": (15, "low"),
|
||||||
|
}
|
||||||
|
|
||||||
|
# Default provider to compare against in the usage section. The pilot runs on
|
||||||
|
# headroom/glm-5.2:cloud at $0/MTok, so the actual line shows $0.00 — but the
|
||||||
|
# equivalent provider line lets a maintainer see what they would have paid on
|
||||||
|
# Claude/GPT for the same measured tokens. Override with PRAGENT_PRICE_TARGET
|
||||||
|
# (env) or `.pr-review.json:cost_target` (per repo).
|
||||||
|
DEFAULT_PRICE_TARGET = "claude-sonnet-5"
|
||||||
|
|
||||||
SYSTEM_PROMPT = """You are a senior, pragmatic code reviewer. Review the pull request diff below.
|
SYSTEM_PROMPT = """You are a senior, pragmatic code reviewer. Review the pull request diff below.
|
||||||
|
|
||||||
Report ONLY real, actionable issues: correctness bugs, security problems, risky
|
Report ONLY real, actionable issues: correctness bugs, security problems, risky
|
||||||
@@ -197,7 +216,76 @@ def compute_attribution(findings: list[dict], output_tokens: int) -> None:
|
|||||||
f["_tok_pct"] = w / total_w
|
f["_tok_pct"] = w / total_w
|
||||||
|
|
||||||
|
|
||||||
def format_usage_section(usage: dict | None, findings: list[dict], model: str) -> str:
|
def _resolve_price_target(config: dict | None) -> tuple[str, str | None]:
|
||||||
|
"""Pick which provider to compute the equivalent cost against.
|
||||||
|
|
||||||
|
Order: `.pr-review.json:cost_target` > `PRAGENT_PRICE_TARGET` env >
|
||||||
|
`DEFAULT_PRICE_TARGET` (claude-sonnet-5). Returns `(price_key, error)`.
|
||||||
|
|
||||||
|
If any of the user-set keys is unknown, falls back to the default AND
|
||||||
|
reports the error so the operator sees their typo (a config-level typo
|
||||||
|
silently picking the default would defeat the purpose of letting repos
|
||||||
|
opt into a different comparison model).
|
||||||
|
"""
|
||||||
|
from cost_model import PRICES # local import keeps ollama path dep-free
|
||||||
|
candidates: list[tuple[str, str]] = []
|
||||||
|
if isinstance(config, dict) and config.get("cost_target"):
|
||||||
|
candidates.append(("repo config", str(config["cost_target"]).strip()))
|
||||||
|
env = os.environ.get("PRAGENT_PRICE_TARGET", "").strip()
|
||||||
|
if env:
|
||||||
|
candidates.append(("PRAGENT_PRICE_TARGET env", env))
|
||||||
|
candidates.append(("default", DEFAULT_PRICE_TARGET))
|
||||||
|
|
||||||
|
chosen = DEFAULT_PRICE_TARGET
|
||||||
|
for source, key in candidates:
|
||||||
|
if key in PRICES:
|
||||||
|
chosen = key
|
||||||
|
break
|
||||||
|
else:
|
||||||
|
# No candidate was valid. Use default + report.
|
||||||
|
return chosen, (
|
||||||
|
f"unknown price target (checked {', '.join(f'{s}={k!r}' for s, k in candidates)}); "
|
||||||
|
f"valid: {', '.join(sorted(PRICES))}"
|
||||||
|
)
|
||||||
|
|
||||||
|
# Even when we picked a valid key, if the *user* set one and it was
|
||||||
|
# unknown, surface that. (We only get here if a later candidate resolved,
|
||||||
|
# so the invalid one was upstream.)
|
||||||
|
invalid = [(s, k) for s, k in candidates if k not in PRICES and s != "default"]
|
||||||
|
if invalid:
|
||||||
|
return chosen, (
|
||||||
|
f"unknown price target (set {', '.join(f'{s}={k!r}' for s, k in invalid)}); "
|
||||||
|
f"valid: {', '.join(sorted(PRICES))}; falling back to `{chosen}`"
|
||||||
|
)
|
||||||
|
return chosen, None
|
||||||
|
|
||||||
|
|
||||||
|
def equivalent_cost(usage: dict, price_key: str) -> float:
|
||||||
|
"""USD the measured usage would have billed on `price_key`'s provider.
|
||||||
|
|
||||||
|
`usage` is the dict from `parse_opencode_events` (input/output/reasoning/
|
||||||
|
cache_read/cache_write). Builds a `cost_model.Usage` and runs `cost()`. The
|
||||||
|
pilot's actual provider (headroom/glm-5.2:cloud) reports $0 — this is what
|
||||||
|
the same tokens would cost on a paid model, so maintainers can budget.
|
||||||
|
"""
|
||||||
|
from cost_model import Usage, cost, PRICES # local import: ollama path dep-free
|
||||||
|
if price_key not in PRICES:
|
||||||
|
return 0.0
|
||||||
|
u = Usage(
|
||||||
|
uncached_input=(usage.get("input", 0) - usage.get("cache_read", 0)),
|
||||||
|
cached_input=usage.get("cache_read", 0),
|
||||||
|
cache_writes=usage.get("cache_write", 0),
|
||||||
|
output=usage.get("output", 0),
|
||||||
|
)
|
||||||
|
return cost(u, PRICES[price_key])
|
||||||
|
|
||||||
|
|
||||||
|
def format_usage_section(
|
||||||
|
usage: dict | None,
|
||||||
|
findings: list[dict],
|
||||||
|
model: str,
|
||||||
|
config: dict | None = None,
|
||||||
|
) -> str:
|
||||||
"""Render the `## 🔋 AI usage` block for the review body.
|
"""Render the `## 🔋 AI usage` block for the review body.
|
||||||
|
|
||||||
Only called when the PR carries the `AI-USAGE` label (and the opencode
|
Only called when the PR carries the `AI-USAGE` label (and the opencode
|
||||||
@@ -205,18 +293,31 @@ def format_usage_section(usage: dict | None, findings: list[dict], model: str) -
|
|||||||
(input/output/reasoning/cache/cost/steps/duration) plus an ATTRIBUTED
|
(input/output/reasoning/cache/cost/steps/duration) plus an ATTRIBUTED
|
||||||
per-finding table — one model pass generates all findings, so per-comment
|
per-finding table — one model pass generates all findings, so per-comment
|
||||||
counts are an estimate (output split by body weight), clearly labelled.
|
counts are an estimate (output split by body weight), clearly labelled.
|
||||||
|
|
||||||
|
The cost lines show TWO numbers because the pilot runs on headroom at
|
||||||
|
$0/MTok: the `actual` line is what was billed (always $0.00 today), and
|
||||||
|
the `est. cost on <provider>` line shows what the same measured tokens
|
||||||
|
would have cost on a paid model — the number a maintainer actually cares
|
||||||
|
about when budgeting. `config["cost_target"]` / `PRAGENT_PRICE_TARGET`
|
||||||
|
/ `DEFAULT_PRICE_TARGET` (claude-sonnet-5) picks the comparison provider.
|
||||||
|
|
||||||
Returns "" if `usage` is None.
|
Returns "" if `usage` is None.
|
||||||
"""
|
"""
|
||||||
if not usage:
|
if not usage:
|
||||||
return ""
|
return ""
|
||||||
dur = usage.get("duration_s")
|
dur = usage.get("duration_s")
|
||||||
dur_s = f"{dur}s" if dur is not None else "?"
|
dur_s = f"{dur}s" if dur is not None else "?"
|
||||||
cost = usage.get("cost") or 0.0
|
actual = usage.get("cost") or 0.0
|
||||||
cost_s = f"${cost:.4f}" if cost else "$0.00"
|
actual_s = f"${actual:.4f}" if actual else "$0.00"
|
||||||
cost_note = (
|
actual_note = (
|
||||||
"(on-network glm-5.2:cloud via headroom — no per-token charge)"
|
"(headroom glm-5.2:cloud — free tier)"
|
||||||
if not cost else "(billed by provider)"
|
if not actual else "(billed by provider)"
|
||||||
)
|
)
|
||||||
|
price_key, price_err = _resolve_price_target(config)
|
||||||
|
from cost_model import PRICES # local import keeps ollama path dep-free
|
||||||
|
eq = equivalent_cost(usage, price_key)
|
||||||
|
eq_s = f"${eq:.4f}" if eq else "$0.00"
|
||||||
|
eq_label = PRICES[price_key].name
|
||||||
lines = [
|
lines = [
|
||||||
"## 🔋 AI usage",
|
"## 🔋 AI usage",
|
||||||
"",
|
"",
|
||||||
@@ -227,7 +328,12 @@ def format_usage_section(usage: dict | None, findings: list[dict], model: str) -
|
|||||||
f"{usage.get('cache_read', 0)} read / {usage.get('cache_write', 0)} write "
|
f"{usage.get('cache_read', 0)} read / {usage.get('cache_write', 0)} write "
|
||||||
f"→ {usage.get('total', 0)} total"
|
f"→ {usage.get('total', 0)} total"
|
||||||
),
|
),
|
||||||
f"- est. cost: {cost_s} {cost_note}",
|
f"- est. cost on **{eq_label}**: {eq_s}" + (
|
||||||
|
f" _(price target: `{price_key}`; "
|
||||||
|
f"{price_err})_"
|
||||||
|
if price_err else ""
|
||||||
|
),
|
||||||
|
f"- actual: {actual_s} {actual_note}",
|
||||||
"- scope: whole-repo checkout at head sha (agent can read any file + run linters, not just the diff) — input tokens include files read beyond the diff",
|
"- scope: whole-repo checkout at head sha (agent can read any file + run linters, not just the diff) — input tokens include files read beyond the diff",
|
||||||
"- per-comment tokens are *attributed* (one model pass produces all findings; output split by each finding's body weight)",
|
"- per-comment tokens are *attributed* (one model pass produces all findings; output split by each finding's body weight)",
|
||||||
]
|
]
|
||||||
@@ -257,23 +363,38 @@ def build_user_prompt(
|
|||||||
"""Assemble the user prompt: repo config + prior reviews + PR meta + diff."""
|
"""Assemble the user prompt: repo config + prior reviews + PR meta + diff."""
|
||||||
parts: list[str] = []
|
parts: list[str] = []
|
||||||
|
|
||||||
if config:
|
eff = effective_config(config) if config else {}
|
||||||
|
if eff:
|
||||||
cfg_lines = []
|
cfg_lines = []
|
||||||
if config.get("focus"):
|
if eff.get("focus"):
|
||||||
cfg_lines.append("Focus areas: " + ", ".join(config["focus"]))
|
cfg_lines.append("Focus areas: " + ", ".join(eff["focus"]))
|
||||||
if config.get("exclude_paths"):
|
if eff.get("exclude_paths"):
|
||||||
cfg_lines.append("Ignore paths: " + ", ".join(config["exclude_paths"]))
|
cfg_lines.append("Ignore paths: " + ", ".join(eff["exclude_paths"]))
|
||||||
if config.get("languages"):
|
if eff.get("languages"):
|
||||||
cfg_lines.append("Languages: " + ", ".join(config["languages"]))
|
cfg_lines.append("Languages: " + ", ".join(eff["languages"]))
|
||||||
if config.get("instructions"):
|
if eff.get("style"):
|
||||||
cfg_lines.append("Instructions:\n" + str(config["instructions"]).strip())
|
cfg_lines.append(f"Review style: {eff['style']} "
|
||||||
|
f"(max {eff['max_findings']} findings, threshold "
|
||||||
|
f"{eff['severity_threshold']}+)")
|
||||||
|
if eff.get("patterns", {}).get("allow"):
|
||||||
|
cfg_lines.append("Allow paths (only these are reviewed): "
|
||||||
|
+ ", ".join(eff["patterns"]["allow"]))
|
||||||
|
if eff.get("patterns", {}).get("deny"):
|
||||||
|
cfg_lines.append("Deny paths: " + ", ".join(eff["patterns"]["deny"]))
|
||||||
|
if eff.get("exclude_tests"):
|
||||||
|
cfg_lines.append("Skip test files entirely.")
|
||||||
|
if eff.get("require_tests"):
|
||||||
|
cfg_lines.append("Flag behavioral changes that don't add a test "
|
||||||
|
"alongside (added as a `low` finding).")
|
||||||
|
if eff.get("instructions"):
|
||||||
|
cfg_lines.append("Instructions:\n" + str(eff["instructions"]).strip())
|
||||||
if cfg_lines:
|
if cfg_lines:
|
||||||
parts.append("## Repo review config (.pr-review.json)\n" + "\n".join(cfg_lines))
|
parts.append("## Repo review config (.pr-review.json)\n" + "\n".join(cfg_lines))
|
||||||
|
|
||||||
if prior_reviews:
|
if prior_reviews:
|
||||||
joined = "\n\n---\n\n".join(prior_reviews)
|
joined = "\n\n---\n\n".join(prior_reviews)
|
||||||
if len(joined) > 8000:
|
if len(joined) > 4000:
|
||||||
joined = joined[:8000] + "\n…[prior reviews truncated]"
|
joined = joined[:4000] + "\n…[prior reviews truncated]"
|
||||||
parts.append("## PREVIOUS REVIEWS (already posted — do NOT repeat these points)\n" + joined)
|
parts.append("## PREVIOUS REVIEWS (already posted — do NOT repeat these points)\n" + joined)
|
||||||
|
|
||||||
parts.append(f"## PR\nTitle: {title or '(none)'}")
|
parts.append(f"## PR\nTitle: {title or '(none)'}")
|
||||||
@@ -638,13 +759,30 @@ def summary_bullets(findings: list[dict]) -> str:
|
|||||||
CONFIG_MAX_LIST_ITEMS = 32
|
CONFIG_MAX_LIST_ITEMS = 32
|
||||||
CONFIG_MAX_ITEM_CHARS = 200
|
CONFIG_MAX_ITEM_CHARS = 200
|
||||||
CONFIG_MAX_INSTRUCTIONS_CHARS = 4000
|
CONFIG_MAX_INSTRUCTIONS_CHARS = 4000
|
||||||
|
CONFIG_MAX_PATTERNS_ITEMS = 16 # allow + deny separately, total 32 entries
|
||||||
|
CONFIG_MAX_FINDINGS = 30
|
||||||
|
|
||||||
|
STYLES = frozenset(STYLE_DEFAULTS)
|
||||||
|
SEVERITY_VALUES = frozenset(SEVERITIES)
|
||||||
|
|
||||||
|
|
||||||
def parse_repo_config(raw: str) -> dict:
|
def parse_repo_config(raw: str) -> dict:
|
||||||
"""Parse a .pr-review.json blob tolerantly. Returns {} on any failure.
|
"""Parse a .pr-review.json blob tolerantly. Returns {} on any failure.
|
||||||
|
|
||||||
List fields are capped at CONFIG_MAX_LIST_ITEMS entries of
|
List fields are capped at CONFIG_MAX_LIST_ITEMS entries of
|
||||||
CONFIG_MAX_ITEM_CHARS each; `instructions` at CONFIG_MAX_INSTRUCTIONS_CHARS.
|
CONFIG_MAX_ITEM_CHARS each; `instructions` at CONFIG_MAX_INSTRUCTIONS_CHARS;
|
||||||
|
`patterns.allow` / `patterns.deny` each capped at CONFIG_MAX_PATTERNS_ITEMS
|
||||||
|
of CONFIG_MAX_ITEM_CHARS.
|
||||||
|
|
||||||
|
Recognised keys (all optional):
|
||||||
|
focus, exclude_paths, languages, instructions — text steer
|
||||||
|
style strict|balanced|lenient — default: balanced
|
||||||
|
severity_threshold low|medium|high|critical — default: per style
|
||||||
|
max_findings 1..CONFIG_MAX_FINDINGS — default: per style
|
||||||
|
exclude_tests bool — default: False
|
||||||
|
require_tests bool — default: False
|
||||||
|
patterns {allow:[…], deny:[…]} — post-filter globs
|
||||||
|
cost_target <key of cost_model.PRICES> — see equivalent_cost
|
||||||
"""
|
"""
|
||||||
if not raw:
|
if not raw:
|
||||||
return {}
|
return {}
|
||||||
@@ -654,17 +792,195 @@ def parse_repo_config(raw: str) -> dict:
|
|||||||
return {}
|
return {}
|
||||||
if not isinstance(data, dict):
|
if not isinstance(data, dict):
|
||||||
return {}
|
return {}
|
||||||
out = {}
|
|
||||||
for k in ("focus", "exclude_paths", "languages"):
|
def _str_list(v):
|
||||||
v = data.get(k)
|
|
||||||
if isinstance(v, list) and all(isinstance(x, str) for x in v):
|
if isinstance(v, list) and all(isinstance(x, str) for x in v):
|
||||||
out[k] = [x[:CONFIG_MAX_ITEM_CHARS] for x in v[:CONFIG_MAX_LIST_ITEMS]]
|
return [x[:CONFIG_MAX_ITEM_CHARS] for x in v[:CONFIG_MAX_LIST_ITEMS]]
|
||||||
|
return None
|
||||||
|
|
||||||
|
out: dict = {}
|
||||||
|
for k in ("focus", "exclude_paths", "languages"):
|
||||||
|
s = _str_list(data.get(k))
|
||||||
|
if s is not None:
|
||||||
|
out[k] = s
|
||||||
|
|
||||||
instr = data.get("instructions")
|
instr = data.get("instructions")
|
||||||
if isinstance(instr, str) and instr.strip():
|
if isinstance(instr, str) and instr.strip():
|
||||||
out["instructions"] = instr.strip()[:CONFIG_MAX_INSTRUCTIONS_CHARS]
|
out["instructions"] = instr.strip()[:CONFIG_MAX_INSTRUCTIONS_CHARS]
|
||||||
|
|
||||||
|
style = data.get("style")
|
||||||
|
if isinstance(style, str) and style.strip().lower() in STYLES:
|
||||||
|
out["style"] = style.strip().lower()
|
||||||
|
|
||||||
|
thresh = data.get("severity_threshold")
|
||||||
|
if isinstance(thresh, str) and thresh.strip().lower() in SEVERITY_VALUES:
|
||||||
|
out["severity_threshold"] = thresh.strip().lower()
|
||||||
|
|
||||||
|
mf = data.get("max_findings")
|
||||||
|
if isinstance(mf, int) and not isinstance(mf, bool) and 1 <= mf <= CONFIG_MAX_FINDINGS:
|
||||||
|
out["max_findings"] = mf
|
||||||
|
elif isinstance(mf, str) and mf.strip().isdigit():
|
||||||
|
n = int(mf.strip())
|
||||||
|
if 1 <= n <= CONFIG_MAX_FINDINGS:
|
||||||
|
out["max_findings"] = n
|
||||||
|
|
||||||
|
for bk in ("exclude_tests", "require_tests"):
|
||||||
|
if isinstance(data.get(bk), bool):
|
||||||
|
out[bk] = data[bk]
|
||||||
|
|
||||||
|
pat = data.get("patterns")
|
||||||
|
if isinstance(pat, dict):
|
||||||
|
allow = _str_list(pat.get("allow"))
|
||||||
|
deny = _str_list(pat.get("deny"))
|
||||||
|
patterns = {}
|
||||||
|
if allow is not None:
|
||||||
|
patterns["allow"] = allow[:CONFIG_MAX_PATTERNS_ITEMS]
|
||||||
|
if deny is not None:
|
||||||
|
patterns["deny"] = deny[:CONFIG_MAX_PATTERNS_ITEMS]
|
||||||
|
if patterns:
|
||||||
|
out["patterns"] = patterns
|
||||||
|
|
||||||
|
ct = data.get("cost_target")
|
||||||
|
if isinstance(ct, str) and ct.strip():
|
||||||
|
out["cost_target"] = ct.strip()
|
||||||
|
|
||||||
return out
|
return out
|
||||||
|
|
||||||
|
|
||||||
|
def effective_config(config: dict | None) -> dict:
|
||||||
|
"""Apply STYLE_DEFAULTS for any field the config didn't pin.
|
||||||
|
|
||||||
|
Returns a NEW dict combining the user's `.pr-review.json` (if any) with the
|
||||||
|
derived `max_findings` / `severity_threshold`. Style itself is preserved
|
||||||
|
so downstream code can branch on it.
|
||||||
|
"""
|
||||||
|
style = (config or {}).get("style", "balanced")
|
||||||
|
max_findings, severity_threshold = STYLE_DEFAULTS.get(style, STYLE_DEFAULTS["balanced"])
|
||||||
|
out = dict(config or {})
|
||||||
|
out.setdefault("style", style)
|
||||||
|
out.setdefault("max_findings", max_findings)
|
||||||
|
out.setdefault("severity_threshold", severity_threshold)
|
||||||
|
return out
|
||||||
|
|
||||||
|
|
||||||
|
_TEST_PATH_RE = re.compile(
|
||||||
|
r"(?:^|/)("
|
||||||
|
r"[^/]*[Tt]est\.[A-Za-z]+" # FooTest.java / foo_test.py
|
||||||
|
r"|[^/]*\.[Tt]est\.[A-Za-z]+" # foo.Test.java
|
||||||
|
r"|[^/]*_test\.py" # foo_test.py
|
||||||
|
r"|test_[^/]*\.py" # test_foo.py
|
||||||
|
r"|__tests__/[^/]+" # __tests__/foo.js
|
||||||
|
r"|[^/]*\.spec\.[A-Za-z]+" # foo.spec.ts
|
||||||
|
r")$"
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def is_test_path(path: str) -> bool:
|
||||||
|
"""Heuristic: is `path` a test file by name/path convention?
|
||||||
|
|
||||||
|
Conservative — false positives cost real findings; false negatives just
|
||||||
|
produce one extra line in the summary. Patterns: `FooTest.java`,
|
||||||
|
`foo_test.py`, `test_foo.py`, `__tests__/foo.js`, `foo.spec.ts`, anything
|
||||||
|
ending in `.Test.java`.
|
||||||
|
"""
|
||||||
|
if not path:
|
||||||
|
return False
|
||||||
|
return bool(_TEST_PATH_RE.search(path))
|
||||||
|
|
||||||
|
|
||||||
|
def _glob_to_regex(glob: str) -> re.Pattern:
|
||||||
|
"""Translate a shell-style glob to a compiled regex.
|
||||||
|
|
||||||
|
Supports `*` (any chars except `/`), `**` (any chars including `/`),
|
||||||
|
`?` (single non-`/` char). Other characters are escaped. Used by
|
||||||
|
`apply_repo_config` to test `patterns.allow` / `patterns.deny` globs.
|
||||||
|
"""
|
||||||
|
out = []
|
||||||
|
i = 0
|
||||||
|
while i < len(glob):
|
||||||
|
c = glob[i]
|
||||||
|
if c == "*":
|
||||||
|
if i + 1 < len(glob) and glob[i + 1] == "*":
|
||||||
|
out.append(".*")
|
||||||
|
i += 2
|
||||||
|
# swallow a following `/` so `**/x` and `x/**/y` behave
|
||||||
|
if i < len(glob) and glob[i] == "/":
|
||||||
|
i += 1
|
||||||
|
continue
|
||||||
|
out.append("[^/]*")
|
||||||
|
elif c == "?":
|
||||||
|
out.append("[^/]")
|
||||||
|
else:
|
||||||
|
out.append(re.escape(c))
|
||||||
|
i += 1
|
||||||
|
return re.compile("^" + "".join(out) + "$")
|
||||||
|
|
||||||
|
|
||||||
|
def apply_repo_config(
|
||||||
|
findings: list[dict],
|
||||||
|
config: dict | None,
|
||||||
|
changed_paths: list[str] | None = None,
|
||||||
|
) -> tuple[list[dict], list[dict]]:
|
||||||
|
"""Filter + cap findings per `.pr-review.json` rules. Returns (kept, dropped).
|
||||||
|
|
||||||
|
Filters applied (in order):
|
||||||
|
1. `exclude_tests` + test-path heuristic → drop test files
|
||||||
|
2. `exclude_paths` glob match → drop matched paths
|
||||||
|
3. `patterns.deny` glob match → drop matched paths
|
||||||
|
4. `patterns.allow` (if non-empty) → keep ONLY matched paths
|
||||||
|
5. `severity_threshold` → drop below threshold
|
||||||
|
6. `max_findings` → keep first N (highest-severity-first)
|
||||||
|
7. `require_tests` → append a low-severity finding
|
||||||
|
if changed paths include non-test files but no test files changed
|
||||||
|
alongside them (caller passes `changed_paths` from the brief).
|
||||||
|
"""
|
||||||
|
eff = effective_config(config)
|
||||||
|
keep: list[dict] = []
|
||||||
|
drop: list[dict] = []
|
||||||
|
deny_globs = [_glob_to_regex(g) for g in (eff.get("patterns", {}) or {}).get("deny", [])]
|
||||||
|
allow_globs = [_glob_to_regex(g) for g in (eff.get("patterns", {}) or {}).get("allow", [])]
|
||||||
|
deny_path_globs = [_glob_to_regex(g) for g in eff.get("exclude_paths", [])]
|
||||||
|
threshold_rank = SEVERITY_RANK[eff["severity_threshold"]]
|
||||||
|
|
||||||
|
for f in findings:
|
||||||
|
path = f.get("path", "")
|
||||||
|
if eff.get("exclude_tests") and is_test_path(path):
|
||||||
|
drop.append(f); continue
|
||||||
|
if any(rx.search(path) for rx in deny_path_globs):
|
||||||
|
drop.append(f); continue
|
||||||
|
if any(rx.search(path) for rx in deny_globs):
|
||||||
|
drop.append(f); continue
|
||||||
|
if allow_globs and not any(rx.search(path) for rx in allow_globs):
|
||||||
|
drop.append(f); continue
|
||||||
|
sev_rank = SEVERITY_RANK.get(f.get("severity", "low"), 0)
|
||||||
|
if sev_rank < threshold_rank:
|
||||||
|
drop.append(f); continue
|
||||||
|
keep.append(f)
|
||||||
|
|
||||||
|
cap = eff["max_findings"]
|
||||||
|
if len(keep) > cap:
|
||||||
|
dropped = keep[cap:]
|
||||||
|
keep = keep[:cap]
|
||||||
|
drop.extend(dropped)
|
||||||
|
|
||||||
|
if eff.get("require_tests") and changed_paths is not None:
|
||||||
|
non_test = [p for p in changed_paths if not is_test_path(p)]
|
||||||
|
any_test = any(is_test_path(p) for p in changed_paths)
|
||||||
|
if non_test and not any_test:
|
||||||
|
keep.append({
|
||||||
|
"severity": "low",
|
||||||
|
"path": non_test[0],
|
||||||
|
"line": 1,
|
||||||
|
"problem": "no test file changed alongside this behavioral change (require_tests=true)",
|
||||||
|
"fix": "add a unit test exercising the changed branch",
|
||||||
|
"suggestion": "",
|
||||||
|
"reference": "",
|
||||||
|
"_config_synthetic": True,
|
||||||
|
})
|
||||||
|
|
||||||
|
return keep, drop
|
||||||
|
|
||||||
|
|
||||||
def reviewed_shas(reviews: list[dict]) -> set[str]:
|
def reviewed_shas(reviews: list[dict]) -> set[str]:
|
||||||
"""Pull every `<!-- pragent:sha=... -->` marker out of a PR's reviews."""
|
"""Pull every `<!-- pragent:sha=... -->` marker out of a PR's reviews."""
|
||||||
shas: set[str] = set()
|
shas: set[str] = set()
|
||||||
@@ -693,6 +1009,29 @@ def prior_review_bodies(reviews: list[dict], current_sha: str, limit: int = 6) -
|
|||||||
return out[:limit]
|
return out[:limit]
|
||||||
|
|
||||||
|
|
||||||
|
def compact_prior_reviews(prior_bodies: list[str]) -> list[str]:
|
||||||
|
"""Squeeze prior review bodies down to just the finding bullets.
|
||||||
|
|
||||||
|
Each prior review's prose ("this PR adds eval() — risky") is noise when the
|
||||||
|
model already has the diff; the only thing it needs to *not repeat* is what
|
||||||
|
was already flagged. We extract lines matching `-\\s*\\*\\*[SEV]\\*\\*`
|
||||||
|
plus their directly-attached location reference (so `[CRITICAL]` stays
|
||||||
|
anchored to `path:line`), drop the rest, and return one bullet-list per
|
||||||
|
prior review. A prior review that had no parseable findings becomes an
|
||||||
|
empty string and is dropped.
|
||||||
|
|
||||||
|
Local import keeps the ollama path dep-free (extract_finding_bullets lives
|
||||||
|
in pilot/diff_compress.py).
|
||||||
|
"""
|
||||||
|
from diff_compress import extract_finding_bullets
|
||||||
|
out = []
|
||||||
|
for body in prior_bodies or []:
|
||||||
|
bullets = extract_finding_bullets(body)
|
||||||
|
if bullets:
|
||||||
|
out.append("\n".join(bullets))
|
||||||
|
return out
|
||||||
|
|
||||||
|
|
||||||
# ---------------------------------------------------------------------------
|
# ---------------------------------------------------------------------------
|
||||||
# Network helpers
|
# Network helpers
|
||||||
# ---------------------------------------------------------------------------
|
# ---------------------------------------------------------------------------
|
||||||
@@ -911,13 +1250,35 @@ def review_pr(
|
|||||||
print(f"pragent: {repo}#{index} sha={sha[:8]} already reviewed, skipping", flush=True)
|
print(f"pragent: {repo}#{index} sha={sha[:8]} already reviewed, skipping", flush=True)
|
||||||
return True
|
return True
|
||||||
|
|
||||||
diff, _truncated, _orig = fetch_pr_diff(api, repo, index, token, max_chars)
|
raw_diff, _truncated, _orig = fetch_pr_diff(api, repo, index, token, max_chars)
|
||||||
if not diff.strip():
|
if not raw_diff.strip():
|
||||||
post_review(api, repo, index, token, format_review_body("No diff content to review.", model, sha))
|
post_review(api, repo, index, token, format_review_body("No diff content to review.", model, sha))
|
||||||
return True
|
return True
|
||||||
|
|
||||||
config = fetch_repo_config(api, repo, token, ref=base_ref)
|
config = fetch_repo_config(api, repo, token, ref=base_ref)
|
||||||
prior = prior_review_bodies(reviews, sha)
|
prior = compact_prior_reviews(prior_review_bodies(reviews, sha))
|
||||||
|
|
||||||
|
# Trim the diff to +/- hunks plus a narrow context window. The agent
|
||||||
|
# resends the brief prefix every step, so a 25k-char diff becomes
|
||||||
|
# 25k × 30-step × cached-after-step-1 = hundreds of thousands of input
|
||||||
|
# tokens. Default context=1: enough for the reviewer to see what an
|
||||||
|
# added line is replacing; the full file is on disk in the workdir
|
||||||
|
# anyway, so anything more is reading the diff twice. Tunable via
|
||||||
|
# PRAGENT_DIFF_CONTEXT (0 = +/- only; -1 = disable compression).
|
||||||
|
from diff_compress import compress_diff
|
||||||
|
ctx = int(os.environ.get("PRAGENT_DIFF_CONTEXT", "1"))
|
||||||
|
if ctx < 0:
|
||||||
|
diff = raw_diff
|
||||||
|
compression_note = ""
|
||||||
|
else:
|
||||||
|
diff, orig_chars, kept_chars = compress_diff(raw_diff, context=ctx)
|
||||||
|
if kept_chars < orig_chars:
|
||||||
|
compression_note = (
|
||||||
|
f"\n\n> _diff compressed: {orig_chars:,} → {kept_chars:,} chars "
|
||||||
|
f"(context={ctx}; PRAGENT_DIFF_CONTEXT to tune)_"
|
||||||
|
)
|
||||||
|
else:
|
||||||
|
compression_note = ""
|
||||||
|
|
||||||
engine = os.environ.get("PRAGENT_ENGINE", "opencode").strip().lower()
|
engine = os.environ.get("PRAGENT_ENGINE", "opencode").strip().lower()
|
||||||
review_summary = ""
|
review_summary = ""
|
||||||
@@ -934,6 +1295,7 @@ def review_pr(
|
|||||||
api=api, repo=repo, index=index, sha=sha, token=token,
|
api=api, repo=repo, index=index, sha=sha, token=token,
|
||||||
title=title, body=body, diff=diff, config=config,
|
title=title, body=body, diff=diff, config=config,
|
||||||
prior_reviews=prior, model=oc_model,
|
prior_reviews=prior, model=oc_model,
|
||||||
|
compression_note=compression_note,
|
||||||
)
|
)
|
||||||
review_summary, findings = parse_review_output(stdout)
|
review_summary, findings = parse_review_output(stdout)
|
||||||
if not findings and not review_summary:
|
if not findings and not review_summary:
|
||||||
@@ -949,24 +1311,48 @@ def review_pr(
|
|||||||
salvaged = salvage_summary(stdout)
|
salvaged = salvage_summary(stdout)
|
||||||
usage_section = ""
|
usage_section = ""
|
||||||
if report_usage and usage:
|
if report_usage and usage:
|
||||||
usage_section = format_usage_section(usage, [], model)
|
usage_section = format_usage_section(usage, [], model, config=config)
|
||||||
post_review(api, repo, index, token, format_review_body(
|
post_review(api, repo, index, token, format_review_body(
|
||||||
salvaged or "AI review produced no parseable output.",
|
salvaged or "AI review produced no parseable output.",
|
||||||
model, sha, usage_section=usage_section))
|
model, sha, usage_section=usage_section))
|
||||||
return True
|
return True
|
||||||
else:
|
else:
|
||||||
user_prompt = build_user_prompt(title, body, diff, config, prior)
|
user_prompt = build_user_prompt(title, body + compression_note, diff, config, prior)
|
||||||
raw_findings = call_model(ollama_url, model, SYSTEM_PROMPT, user_prompt, max_tokens)
|
raw_findings = call_model(ollama_url, model, SYSTEM_PROMPT, user_prompt, max_tokens)
|
||||||
findings = parse_findings(raw_findings)
|
findings = parse_findings(raw_findings)
|
||||||
usage = None
|
usage = None
|
||||||
|
|
||||||
|
# Filter / cap findings per `.pr-review.json` (style, threshold, max,
|
||||||
|
# patterns, exclude_tests). Without this every config knob would be a
|
||||||
|
# no-op — the agent has no view into the config beyond instructions.
|
||||||
|
# The synthetic require_tests finding (if any) is appended here.
|
||||||
|
try:
|
||||||
|
changed_paths = sorted({
|
||||||
|
f.get("path", "")
|
||||||
|
for f in findings
|
||||||
|
if f.get("path")
|
||||||
|
})
|
||||||
|
except Exception:
|
||||||
|
changed_paths = []
|
||||||
|
kept, _dropped = apply_repo_config(findings, config, changed_paths=changed_paths)
|
||||||
|
findings = kept
|
||||||
|
if _dropped:
|
||||||
|
print(
|
||||||
|
f"pragent: {repo}#{index} sha={sha[:8]} filtered "
|
||||||
|
f"{len(_dropped)} finding(s) per .pr-review.json "
|
||||||
|
f"(style={(config or {}).get('style', 'balanced')}, "
|
||||||
|
f"threshold={(config or {}).get('severity_threshold', '?')}, "
|
||||||
|
f"max={len(findings)})",
|
||||||
|
flush=True,
|
||||||
|
)
|
||||||
|
|
||||||
# Attribute output tokens to each finding (mutates finding dicts) so
|
# Attribute output tokens to each finding (mutates finding dicts) so
|
||||||
# inline comments + the usage table can show a per-comment estimate.
|
# inline comments + the usage table can show a per-comment estimate.
|
||||||
# Only meaningful when we have measured usage AND the PR asked for it.
|
# Only meaningful when we have measured usage AND the PR asked for it.
|
||||||
usage_section = ""
|
usage_section = ""
|
||||||
if report_usage and usage and usage.get("output"):
|
if report_usage and usage and usage.get("output"):
|
||||||
compute_attribution(findings, usage["output"])
|
compute_attribution(findings, usage["output"])
|
||||||
usage_section = format_usage_section(usage, findings, model)
|
usage_section = format_usage_section(usage, findings, model, config=config)
|
||||||
|
|
||||||
anchors = parse_diff_anchors(diff)
|
anchors = parse_diff_anchors(diff)
|
||||||
anchored, unanchored = split_findings(findings, anchors)
|
anchored, unanchored = split_findings(findings, anchors)
|
||||||
|
|||||||
@@ -0,0 +1,169 @@
|
|||||||
|
#!/usr/bin/env python3
|
||||||
|
r"""pragent pilot — diff compression + prior-review compaction.
|
||||||
|
|
||||||
|
Two pure helpers that shrink what lands in the model prompt without losing
|
||||||
|
signal:
|
||||||
|
|
||||||
|
* ``compress_diff(diff, *, context=2)`` — re-renders a unified diff so each
|
||||||
|
hunk keeps only ``context`` unchanged lines on either side of its +/- lines.
|
||||||
|
The default 2 matches what most reviewers see on GitHub/Gitea, and is
|
||||||
|
enough to anchor every ``+``/``-`` line and give the reviewer the enclosing
|
||||||
|
statement. Wider context = more reading; narrower = less. Set
|
||||||
|
``context=0`` for +/- only, ``context=-1`` to disable entirely.
|
||||||
|
|
||||||
|
* ``extract_finding_bullets(review_body)`` — pulls the lines of a prior
|
||||||
|
review that look like a pragent finding (``- **[SEVERITY]** `path:line` — …``)
|
||||||
|
and drops everything else. The model already has the diff — repeating the
|
||||||
|
prose ("this PR adds eval() — risky") is just token burn. Bullet-only
|
||||||
|
priors cut ~75% off prior-review bytes on a typical 4-finding review.
|
||||||
|
|
||||||
|
Stdlib only. No I/O. Tolerant of malformed input — never raises.
|
||||||
|
"""
|
||||||
|
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import re
|
||||||
|
|
||||||
|
# Diff line types. Order matters: `+++`/ `---` headers and `@@` hunk headers
|
||||||
|
# are caught before the per-line prefix check.
|
||||||
|
_FILE_HEADER = re.compile(r"^(diff --git|Index:|---|\+\+\+|@@)")
|
||||||
|
|
||||||
|
# Captures `- <n>[,<m>]` AND `+ <n>[,<m>]` from `@@ -a,b +c,d @@`. We use the
|
||||||
|
# `+` side to reset the new-line counter; old-side is ignored.
|
||||||
|
_HUNK_RE = re.compile(r"^@@\s+-\d+(?:,\d+)?\s+\+(\d+)(?:,\d+)?\s+@@")
|
||||||
|
|
||||||
|
# Match a pragent summary-bullet line: `- **[SEVERITY]** \`path:line\` — …`.
|
||||||
|
# Severity is uppercased critical|high|medium|low per the findings schema.
|
||||||
|
# We also accept the lower-case form (`- [high]`) used by summary_bullets.
|
||||||
|
_FINDING_BULLET_RE = re.compile(
|
||||||
|
r"^\s*-\s*\*?\*?\[(?P<sev>critical|high|medium|low|CRITICAL|HIGH|MEDIUM|LOW)\]"
|
||||||
|
r"\*?\*?\s+(?P<rest>.+)$"
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def compress_diff(diff: str, *, context: int = 2) -> tuple[str, int, int]:
|
||||||
|
"""Re-render `diff` keeping at most `context` unchanged lines around +/-.
|
||||||
|
|
||||||
|
Args:
|
||||||
|
diff: unified-diff text (what `gitea .../pulls/{n}.diff` returns).
|
||||||
|
context: max unchanged lines to keep on each side of a hunk. Use 0
|
||||||
|
for +/- only, -1 to disable compression (raw passthrough).
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
`(text, original_chars, kept_chars)`. `original_chars` is the byte
|
||||||
|
length of `diff` as given; `kept_chars` is the byte length of `text`.
|
||||||
|
On parse failure the original is returned unchanged so the worst case
|
||||||
|
is no improvement, never corruption.
|
||||||
|
"""
|
||||||
|
if not diff:
|
||||||
|
return diff or "", len(diff or ""), len(diff or "")
|
||||||
|
if context < 0:
|
||||||
|
return diff, len(diff), len(diff)
|
||||||
|
|
||||||
|
orig = len(diff)
|
||||||
|
lines = diff.splitlines()
|
||||||
|
out: list[str] = []
|
||||||
|
|
||||||
|
# State for the per-file walk.
|
||||||
|
i = 0
|
||||||
|
n = len(lines)
|
||||||
|
while i < n:
|
||||||
|
# Copy file headers verbatim until we hit the first `@@` hunk header.
|
||||||
|
hunk_start = i
|
||||||
|
while hunk_start < n and not lines[hunk_start].startswith("@@"):
|
||||||
|
out.append(lines[hunk_start])
|
||||||
|
hunk_start += 1
|
||||||
|
i = hunk_start
|
||||||
|
|
||||||
|
# Walk hunks, copying headers verbatim and trimming the inside.
|
||||||
|
while i < n and lines[i].startswith("@@"):
|
||||||
|
hunk_header = lines[i]
|
||||||
|
i += 1
|
||||||
|
|
||||||
|
# Collect the hunk body: every line until the next `@@` / file
|
||||||
|
# header / EOF. Within the body, classify each line.
|
||||||
|
body_start = i
|
||||||
|
while i < n and not _FILE_HEADER.match(lines[i]):
|
||||||
|
i += 1
|
||||||
|
body = lines[body_start:i]
|
||||||
|
|
||||||
|
# Render the body, collapsing long runs of context lines to a
|
||||||
|
# `@@ … @@` marker so the reviewer still sees that there IS more
|
||||||
|
# code there, just not in this window.
|
||||||
|
rendered, _ = _render_hunk_body(body, context=context)
|
||||||
|
if rendered:
|
||||||
|
out.append(hunk_header)
|
||||||
|
out.extend(rendered)
|
||||||
|
|
||||||
|
text = "\n".join(out) + ("\n" if diff.endswith("\n") else "")
|
||||||
|
if not text:
|
||||||
|
# splitlines() dropped nothing-but-newlines; fall back to original.
|
||||||
|
return diff, orig, orig
|
||||||
|
return text, orig, len(text)
|
||||||
|
|
||||||
|
|
||||||
|
def _render_hunk_body(body: list[str], *, context: int) -> tuple[list[str], int]:
|
||||||
|
"""Trim `body` to `context` unchanged lines around the +/- lines.
|
||||||
|
|
||||||
|
Body lines are classified:
|
||||||
|
- `+` line → keep
|
||||||
|
- `-` line → keep (paired with a `+` on the new side when both exist)
|
||||||
|
- `` `` line (or empty context) → keep only within ``context`` of a +/- line
|
||||||
|
- ``\ No newline at end of file`` → drop (no signal for the reviewer)
|
||||||
|
|
||||||
|
Collapsed gaps of ≥ 5 lines get a single ``@@ … N context line(s) omitted … @@``
|
||||||
|
marker so the reviewer knows code was elided. Smaller gaps (1–4 lines)
|
||||||
|
stay silent — the marker would be longer than the elision.
|
||||||
|
"""
|
||||||
|
if context == 0:
|
||||||
|
# Keep only +/- lines; drop all context.
|
||||||
|
out = [ln for ln in body if ln.startswith("+") or ln.startswith("-")]
|
||||||
|
return out, 0
|
||||||
|
|
||||||
|
# Find the index of every +/- line; a context line is kept if its
|
||||||
|
# distance to the nearest +/- line is ≤ context.
|
||||||
|
plus_minus_idx = [
|
||||||
|
j for j, ln in enumerate(body)
|
||||||
|
if ln.startswith("+") or ln.startswith("-")
|
||||||
|
]
|
||||||
|
if not plus_minus_idx:
|
||||||
|
# No +/- at all (rare — pure-context hunk): drop entirely.
|
||||||
|
return [], 0
|
||||||
|
|
||||||
|
keep = set()
|
||||||
|
for k in plus_minus_idx:
|
||||||
|
lo = max(0, k - context)
|
||||||
|
hi = min(len(body) - 1, k + context)
|
||||||
|
for j in range(lo, hi + 1):
|
||||||
|
keep.add(j)
|
||||||
|
|
||||||
|
out: list[str] = []
|
||||||
|
last_kept = -2 # sentinel: a gap of ≥ 5 between consecutive kept lines triggers a marker
|
||||||
|
for j, ln in enumerate(body):
|
||||||
|
if ln.startswith("\\ No newline"):
|
||||||
|
continue
|
||||||
|
if j in keep:
|
||||||
|
if j - last_kept > 5 and last_kept >= 0:
|
||||||
|
out.append(f"@@ … {j - last_kept - 1} context line(s) omitted … @@")
|
||||||
|
out.append(ln)
|
||||||
|
last_kept = j
|
||||||
|
|
||||||
|
return out, len(out)
|
||||||
|
|
||||||
|
|
||||||
|
def extract_finding_bullets(review_body: str) -> list[str]:
|
||||||
|
"""Pull the finding-bullet lines out of a prior review body.
|
||||||
|
|
||||||
|
Returns the matching lines verbatim (with their original indentation +
|
||||||
|
any continuation text), preserving the ``**[SEV]** `path:line` — problem
|
||||||
|
…`` shape the model emitted. Lines that look like bullets but lack the
|
||||||
|
severity tag are dropped — the reviewer synthesizes from the matched ones.
|
||||||
|
"""
|
||||||
|
if not review_body:
|
||||||
|
return []
|
||||||
|
out = []
|
||||||
|
for line in review_body.splitlines():
|
||||||
|
m = _FINDING_BULLET_RE.match(line)
|
||||||
|
if m:
|
||||||
|
out.append(line.strip())
|
||||||
|
return out
|
||||||
@@ -286,6 +286,7 @@ def write_brief(
|
|||||||
diff: str,
|
diff: str,
|
||||||
config: dict | None,
|
config: dict | None,
|
||||||
prior_reviews: list[str] | None,
|
prior_reviews: list[str] | None,
|
||||||
|
compression_note: str = "",
|
||||||
) -> str:
|
) -> str:
|
||||||
"""Render `.pragent/brief.md` in the workdir. Returns the path written."""
|
"""Render `.pragent/brief.md` in the workdir. Returns the path written."""
|
||||||
path = os.path.join(workdir, ".pragent")
|
path = os.path.join(workdir, ".pragent")
|
||||||
@@ -297,16 +298,17 @@ def write_brief(
|
|||||||
prior = "_(none)_"
|
prior = "_(none)_"
|
||||||
if prior_reviews:
|
if prior_reviews:
|
||||||
prior = "\n\n---\n\n".join(prior_reviews)
|
prior = "\n\n---\n\n".join(prior_reviews)
|
||||||
if len(prior) > 8000:
|
if len(prior) > 4000:
|
||||||
prior = prior[:8000] + "\n…[prior reviews truncated]"
|
prior = prior[:4000] + "\n…[prior reviews truncated]"
|
||||||
files = changed_files(diff)
|
files = changed_files(diff)
|
||||||
files_block = "\n".join(f"- `{p}`" for p in files) if files else "_(none)_"
|
files_block = "\n".join(f"- `{p}`" for p in files) if files else "_(none)_"
|
||||||
|
desc_block = ((description or "").strip() or "_(none)_") + compression_note
|
||||||
content = _BRIEF_TEMPLATE.format(
|
content = _BRIEF_TEMPLATE.format(
|
||||||
repo=repo or "?",
|
repo=repo or "?",
|
||||||
index=index or "?",
|
index=index or "?",
|
||||||
sha=sha or "?",
|
sha=sha or "?",
|
||||||
title=title or "(none)",
|
title=title or "(none)",
|
||||||
description=description.strip() or "_(none)_",
|
description=desc_block,
|
||||||
changed_files=files_block,
|
changed_files=files_block,
|
||||||
config=cfg,
|
config=cfg,
|
||||||
prior=prior,
|
prior=prior,
|
||||||
@@ -672,6 +674,7 @@ def run(
|
|||||||
config: dict | None,
|
config: dict | None,
|
||||||
prior_reviews: list[str] | None,
|
prior_reviews: list[str] | None,
|
||||||
model: str,
|
model: str,
|
||||||
|
compression_note: str = "",
|
||||||
) -> tuple[str, dict | None]:
|
) -> tuple[str, dict | None]:
|
||||||
"""End-to-end: checkout archive → brief → drop factory → opencode → (text, usage).
|
"""End-to-end: checkout archive → brief → drop factory → opencode → (text, usage).
|
||||||
|
|
||||||
@@ -679,6 +682,11 @@ def run(
|
|||||||
and a usage dict (token/cost totals + `duration_s`), or `(text, None)` when
|
and a usage dict (token/cost totals + `duration_s`), or `(text, None)` when
|
||||||
no usage events were seen. Raises on any failure; the caller (`review_pr`)
|
no usage events were seen. Raises on any failure; the caller (`review_pr`)
|
||||||
fails open. The workdir is removed unless PRAGENT_KEEP_WORK is set.
|
fails open. The workdir is removed unless PRAGENT_KEEP_WORK is set.
|
||||||
|
|
||||||
|
`compression_note`: a small markdown block to append to the brief's PR
|
||||||
|
description (e.g. "diff compressed: 25k → 12k chars"). Empty string by
|
||||||
|
default. Appended AFTER the untrusted-data fence so the agent reads it as
|
||||||
|
guidance, not author input.
|
||||||
"""
|
"""
|
||||||
os.makedirs(WORK_ROOT, exist_ok=True)
|
os.makedirs(WORK_ROOT, exist_ok=True)
|
||||||
workdir = tempfile.mkdtemp(prefix=f"{repo.replace('/', '_')}-{sha[:8]}-", dir=WORK_ROOT)
|
workdir = tempfile.mkdtemp(prefix=f"{repo.replace('/', '_')}-{sha[:8]}-", dir=WORK_ROOT)
|
||||||
@@ -697,6 +705,7 @@ def run(
|
|||||||
workdir,
|
workdir,
|
||||||
repo=repo, index=index, sha=sha, title=title, description=body,
|
repo=repo, index=index, sha=sha, title=title, description=body,
|
||||||
diff=diff, config=config, prior_reviews=prior_reviews,
|
diff=diff, config=config, prior_reviews=prior_reviews,
|
||||||
|
compression_note=compression_note,
|
||||||
)
|
)
|
||||||
drop_factory(workdir)
|
drop_factory(workdir)
|
||||||
text, usage = run_opencode(workdir, model)
|
text, usage = run_opencode(workdir, model)
|
||||||
|
|||||||
@@ -779,3 +779,244 @@ def test_salvage_summary_empty_when_nothing_to_salvage():
|
|||||||
assert ai_review.salvage_summary("") == ""
|
assert ai_review.salvage_summary("") == ""
|
||||||
assert ai_review.salvage_summary(" \n ") == ""
|
assert ai_review.salvage_summary(" \n ") == ""
|
||||||
assert ai_review.salvage_summary("```json\n{}\n```") == ""
|
assert ai_review.salvage_summary("```json\n{}\n```") == ""
|
||||||
|
|
||||||
|
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
# equivalent_cost + format_usage_section equivalent-provider line
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
|
||||||
|
def test_equivalent_cost_matches_cost_model():
|
||||||
|
usage = {"input": 1_000_000, "output": 0, "cache_read": 0, "cache_write": 0}
|
||||||
|
eq = ai_review.equivalent_cost(usage, "claude-sonnet-5")
|
||||||
|
# Sonnet 5 input is $2/MTok, so 1M input = $2.00 exactly.
|
||||||
|
assert abs(eq - 2.0) < 1e-9
|
||||||
|
|
||||||
|
|
||||||
|
def test_equivalent_cost_unknown_key_returns_zero():
|
||||||
|
assert ai_review.equivalent_cost({"input": 100}, "bogus") == 0.0
|
||||||
|
|
||||||
|
|
||||||
|
def test_format_usage_section_shows_equivalent_provider_cost():
|
||||||
|
usage = {"input": 200000, "output": 4000, "reasoning": 0,
|
||||||
|
"cache_read": 0, "cache_write": 0, "total": 204000,
|
||||||
|
"cost": 0.0, "steps": 6, "duration_s": 100.0}
|
||||||
|
sec = ai_review.format_usage_section(usage, [], "glm-5.2:cloud")
|
||||||
|
# Two cost lines now: an equivalent (default Sonnet 5) AND the $0 actual.
|
||||||
|
assert "## 🔋 AI usage" in sec
|
||||||
|
assert "est. cost on **Claude Sonnet 5**" in sec
|
||||||
|
assert "actual: $0.00" in sec
|
||||||
|
assert "free tier" in sec
|
||||||
|
# Equivalent should be > 0 for non-trivial token counts.
|
||||||
|
assert "$0.00" in sec # the actual line
|
||||||
|
# And a non-zero one for the equivalent.
|
||||||
|
import re
|
||||||
|
cost_lines = [ln for ln in sec.splitlines() if "cost on" in ln]
|
||||||
|
assert len(cost_lines) == 1
|
||||||
|
assert re.search(r"\$\d", cost_lines[0]) is not None
|
||||||
|
assert "$0.00" not in cost_lines[0]
|
||||||
|
|
||||||
|
|
||||||
|
def test_format_usage_section_honors_cost_target(monkeypatch):
|
||||||
|
monkeypatch.setenv("PRAGENT_PRICE_TARGET", "claude-haiku-4-5")
|
||||||
|
usage = {"input": 1000, "output": 100, "reasoning": 0,
|
||||||
|
"cache_read": 0, "cache_write": 0, "total": 1100,
|
||||||
|
"cost": 0.0, "steps": 1, "duration_s": 5.0}
|
||||||
|
sec = ai_review.format_usage_section(usage, [], "glm-5.2:cloud")
|
||||||
|
assert "Claude Haiku 4.5" in sec
|
||||||
|
# 1k * $1/MTok + 100 * $5/MTok = 0.001 + 0.0005 = $0.0015
|
||||||
|
assert "$0.0015" in sec
|
||||||
|
|
||||||
|
|
||||||
|
def test_format_usage_section_respects_repo_config_cost_target(monkeypatch):
|
||||||
|
monkeypatch.delenv("PRAGENT_PRICE_TARGET", raising=False)
|
||||||
|
usage = {"input": 1000, "output": 100, "reasoning": 0,
|
||||||
|
"cache_read": 0, "cache_write": 0, "total": 1100,
|
||||||
|
"cost": 0.0, "steps": 1, "duration_s": 5.0}
|
||||||
|
sec = ai_review.format_usage_section(
|
||||||
|
usage, [], "glm-5.2:cloud", config={"cost_target": "claude-opus-5"}
|
||||||
|
)
|
||||||
|
assert "Claude Opus 5" in sec
|
||||||
|
# Opus 5 = $5/MTok input + $25/MTok output → 1000*5e-6 + 100*25e-6 = 0.0075
|
||||||
|
assert "$0.0075" in sec
|
||||||
|
|
||||||
|
|
||||||
|
def test_format_usage_section_reports_unknown_price_target():
|
||||||
|
usage = {"input": 100, "output": 100, "reasoning": 0,
|
||||||
|
"cache_read": 0, "cache_write": 0, "total": 200,
|
||||||
|
"cost": 0.0, "steps": 1, "duration_s": 1.0}
|
||||||
|
sec = ai_review.format_usage_section(
|
||||||
|
usage, [], "glm-5.2:cloud", config={"cost_target": "bogus-model"}
|
||||||
|
)
|
||||||
|
# Falls back to default + surfaces the error in the line.
|
||||||
|
assert "Claude Sonnet 5" in sec
|
||||||
|
assert "unknown price target" in sec
|
||||||
|
assert "bogus-model" in sec
|
||||||
|
|
||||||
|
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
# parse_repo_config — extended schema
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
|
||||||
|
def test_parse_repo_config_new_fields_all_valid():
|
||||||
|
raw = json.dumps({
|
||||||
|
"focus": ["security"],
|
||||||
|
"style": "strict",
|
||||||
|
"severity_threshold": "high",
|
||||||
|
"max_findings": 5,
|
||||||
|
"exclude_tests": True,
|
||||||
|
"require_tests": True,
|
||||||
|
"patterns": {"allow": ["src/**"], "deny": ["**/*.test.ts"]},
|
||||||
|
"cost_target": "claude-opus-5",
|
||||||
|
})
|
||||||
|
c = ai_review.parse_repo_config(raw)
|
||||||
|
assert c["style"] == "strict"
|
||||||
|
assert c["severity_threshold"] == "high"
|
||||||
|
assert c["max_findings"] == 5
|
||||||
|
assert c["exclude_tests"] is True
|
||||||
|
assert c["require_tests"] is True
|
||||||
|
assert c["patterns"]["allow"] == ["src/**"]
|
||||||
|
assert c["patterns"]["deny"] == ["**/*.test.ts"]
|
||||||
|
assert c["cost_target"] == "claude-opus-5"
|
||||||
|
|
||||||
|
|
||||||
|
def test_parse_repo_config_rejects_bad_style_and_threshold():
|
||||||
|
c = ai_review.parse_repo_config(json.dumps({"style": "wild", "severity_threshold": "meh"}))
|
||||||
|
assert "style" not in c
|
||||||
|
assert "severity_threshold" not in c
|
||||||
|
|
||||||
|
|
||||||
|
def test_parse_repo_config_caps_max_findings():
|
||||||
|
c1 = ai_review.parse_repo_config(json.dumps({"max_findings": 0}))
|
||||||
|
c2 = ai_review.parse_repo_config(json.dumps({"max_findings": 999}))
|
||||||
|
c3 = ai_review.parse_repo_config(json.dumps({"max_findings": "12"}))
|
||||||
|
assert "max_findings" not in c1 # 0 invalid
|
||||||
|
assert "max_findings" not in c2 # > 30 invalid
|
||||||
|
assert c3["max_findings"] == 12 # numeric string accepted
|
||||||
|
|
||||||
|
|
||||||
|
def test_parse_repo_config_caps_patterns():
|
||||||
|
raw = json.dumps({
|
||||||
|
"patterns": {"allow": [f"a{i}" for i in range(20)], "deny": [f"d{i}" for i in range(20)]}
|
||||||
|
})
|
||||||
|
c = ai_review.parse_repo_config(raw)
|
||||||
|
assert len(c["patterns"]["allow"]) == ai_review.CONFIG_MAX_PATTERNS_ITEMS
|
||||||
|
assert len(c["patterns"]["deny"]) == ai_review.CONFIG_MAX_PATTERNS_ITEMS
|
||||||
|
|
||||||
|
|
||||||
|
def test_effective_config_applies_style_defaults():
|
||||||
|
eff = ai_review.effective_config({"focus": ["security"]})
|
||||||
|
assert eff["style"] == "balanced"
|
||||||
|
assert eff["max_findings"] == 12
|
||||||
|
assert eff["severity_threshold"] == "medium"
|
||||||
|
assert eff["focus"] == ["security"]
|
||||||
|
|
||||||
|
|
||||||
|
def test_effective_config_style_overrides_fields():
|
||||||
|
eff = ai_review.effective_config({"style": "strict"})
|
||||||
|
assert eff["max_findings"] == 5
|
||||||
|
assert eff["severity_threshold"] == "high"
|
||||||
|
|
||||||
|
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
# apply_repo_config — filter findings
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
|
||||||
|
_FINDINGS = [
|
||||||
|
{"severity": "critical", "path": "src/main.py", "line": 1, "problem": "p", "fix": "f", "suggestion": ""},
|
||||||
|
{"severity": "high", "path": "src/main.py", "line": 5, "problem": "p", "fix": "f", "suggestion": ""},
|
||||||
|
{"severity": "medium", "path": "src/main.py", "line": 9, "problem": "p", "fix": "f", "suggestion": ""},
|
||||||
|
{"severity": "low", "path": "src/main.py", "line": 13, "problem": "p", "fix": "f", "suggestion": ""},
|
||||||
|
{"severity": "high", "path": "src/FooTest.java", "line": 22, "problem": "p", "fix": "f", "suggestion": ""},
|
||||||
|
{"severity": "medium", "path": "src/app.test.ts", "line": 7, "problem": "p", "fix": "f", "suggestion": ""},
|
||||||
|
]
|
||||||
|
|
||||||
|
|
||||||
|
def test_apply_repo_config_severity_threshold():
|
||||||
|
kept, dropped = ai_review.apply_repo_config(_FINDINGS, {"severity_threshold": "high"})
|
||||||
|
assert len(kept) == 3 # critical + 2 highs (main.py + FooTest.java)
|
||||||
|
assert all(f["severity"] in ("critical", "high") for f in kept)
|
||||||
|
assert len(dropped) == 3
|
||||||
|
|
||||||
|
|
||||||
|
def test_apply_repo_config_exclude_tests_drops_test_files():
|
||||||
|
kept, dropped = ai_review.apply_repo_config(_FINDINGS, {"exclude_tests": True})
|
||||||
|
paths = {f["path"] for f in kept}
|
||||||
|
assert "src/FooTest.java" not in paths
|
||||||
|
assert "src/app.test.ts" not in paths
|
||||||
|
|
||||||
|
|
||||||
|
def test_apply_repo_config_patterns_deny_drops_matching():
|
||||||
|
cfg = {"patterns": {"deny": ["src/main.py"]}}
|
||||||
|
kept, dropped = ai_review.apply_repo_config(_FINDINGS, cfg)
|
||||||
|
paths = {f["path"] for f in kept}
|
||||||
|
assert "src/main.py" not in paths
|
||||||
|
|
||||||
|
|
||||||
|
def test_apply_repo_config_patterns_allow_keeps_only_matching():
|
||||||
|
cfg = {"patterns": {"allow": ["src/main.py"]}}
|
||||||
|
kept, dropped = ai_review.apply_repo_config(_FINDINGS, cfg)
|
||||||
|
paths = {f["path"] for f in kept}
|
||||||
|
assert paths == {"src/main.py"}
|
||||||
|
|
||||||
|
|
||||||
|
def test_apply_repo_config_max_findings_caps():
|
||||||
|
kept, dropped = ai_review.apply_repo_config(_FINDINGS, {"max_findings": 2})
|
||||||
|
assert len(kept) == 2
|
||||||
|
# Highest-severity first (critical, then high)
|
||||||
|
assert kept[0]["severity"] == "critical"
|
||||||
|
assert kept[1]["severity"] == "high"
|
||||||
|
|
||||||
|
|
||||||
|
def test_apply_repo_config_exclude_paths_glob():
|
||||||
|
cfg = {"exclude_paths": ["src/main.py"]}
|
||||||
|
kept, dropped = ai_review.apply_repo_config(_FINDINGS, cfg)
|
||||||
|
assert "src/main.py" not in {f["path"] for f in kept}
|
||||||
|
|
||||||
|
|
||||||
|
def test_apply_repo_config_require_tests_synthetic_finding():
|
||||||
|
cfg = {"require_tests": True}
|
||||||
|
changed = ["src/main.py", "src/lib.ts"]
|
||||||
|
kept, dropped = ai_review.apply_repo_config([], cfg, changed_paths=changed)
|
||||||
|
assert any(f.get("_config_synthetic") for f in kept)
|
||||||
|
|
||||||
|
|
||||||
|
def test_apply_repo_config_require_tests_no_synthetic_when_tests_present():
|
||||||
|
cfg = {"require_tests": True}
|
||||||
|
changed = ["src/main.py", "src/main_test.py"]
|
||||||
|
kept, dropped = ai_review.apply_repo_config(_FINDINGS, cfg, changed_paths=changed)
|
||||||
|
assert not any(f.get("_config_synthetic") for f in kept)
|
||||||
|
|
||||||
|
|
||||||
|
def test_is_test_path_recognises_common_patterns():
|
||||||
|
assert ai_review.is_test_path("src/FooTest.java")
|
||||||
|
assert ai_review.is_test_path("src/foo.test.ts")
|
||||||
|
assert ai_review.is_test_path("tests/foo_test.py")
|
||||||
|
assert ai_review.is_test_path("test_foo.py")
|
||||||
|
assert ai_review.is_test_path("packages/app/__tests__/foo.js")
|
||||||
|
assert not ai_review.is_test_path("src/main.py")
|
||||||
|
assert not ai_review.is_test_path("src/testing.py") # "testing" ≠ "test_"
|
||||||
|
|
||||||
|
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
# compact_prior_reviews
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
|
||||||
|
def test_compact_prior_reviews_drops_prose_keeps_bullets():
|
||||||
|
bodies = [
|
||||||
|
"🤖 AI Review · m · `abc`\n\nLong prose.\n\n- **[HIGH]** `a.py:1` — bug.\n- **[LOW]** `b.go:2` — nit.\n\n_2 inline comments posted._\n<!-- pragent:sha=abc -->",
|
||||||
|
"Just chatter, no findings.",
|
||||||
|
]
|
||||||
|
out = ai_review.compact_prior_reviews(bodies)
|
||||||
|
assert len(out) == 1
|
||||||
|
assert "HIGH" in out[0] and "a.py:1" in out[0]
|
||||||
|
assert "Long prose." not in out[0]
|
||||||
|
assert "inline comments posted" not in out[0]
|
||||||
|
|
||||||
|
|
||||||
|
def test_compact_prior_reviews_empty_and_none():
|
||||||
|
assert ai_review.compact_prior_reviews([]) == []
|
||||||
|
assert ai_review.compact_prior_reviews(None) == []
|
||||||
|
|||||||
@@ -0,0 +1,248 @@
|
|||||||
|
"""Unit tests for pragent pilot diff_compress. No network."""
|
||||||
|
import os
|
||||||
|
import sys
|
||||||
|
|
||||||
|
HERE = os.path.dirname(os.path.abspath(__file__))
|
||||||
|
ROOT = os.path.abspath(os.path.join(HERE, "..", ".."))
|
||||||
|
sys.path.insert(0, os.path.join(ROOT, "pilot"))
|
||||||
|
|
||||||
|
import diff_compress # noqa: E402
|
||||||
|
from diff_compress import compress_diff, extract_finding_bullets # noqa: E402
|
||||||
|
|
||||||
|
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
# compress_diff
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
|
||||||
|
_DIFF = """\
|
||||||
|
diff --git a/src/a.py b/src/a.py
|
||||||
|
index 1..2 100644
|
||||||
|
--- a/src/a.py
|
||||||
|
+++ b/src/a.py
|
||||||
|
@@ -1,10 +1,11 @@
|
||||||
|
ctx1
|
||||||
|
-removed
|
||||||
|
+added
|
||||||
|
ctx2
|
||||||
|
ctx3
|
||||||
|
ctx4
|
||||||
|
ctx5
|
||||||
|
ctx6
|
||||||
|
ctx7
|
||||||
|
+extra
|
||||||
|
ctx8
|
||||||
|
@@ -20,3 +21,4 @@
|
||||||
|
tail1
|
||||||
|
tail2
|
||||||
|
+tail3
|
||||||
|
tail4
|
||||||
|
diff --git a/binary.bin b/binary.bin
|
||||||
|
new file mode 100644
|
||||||
|
index 0..1
|
||||||
|
Binary files differ
|
||||||
|
"""
|
||||||
|
|
||||||
|
|
||||||
|
def test_compress_diff_default_context_two():
|
||||||
|
text, orig, kept = compress_diff(_DIFF, context=2)
|
||||||
|
# +/- lines preserved
|
||||||
|
assert "+added" in text
|
||||||
|
assert "-removed" in text
|
||||||
|
assert "+extra" in text
|
||||||
|
assert "+tail3" in text
|
||||||
|
# 2 context lines around +/- kept, the rest collapsed
|
||||||
|
assert "ctx2" in text and "ctx3" in text
|
||||||
|
assert "ctx4" not in text # outside the +/- window
|
||||||
|
# Binary files pass through
|
||||||
|
assert "Binary files differ" in text
|
||||||
|
# File headers preserved
|
||||||
|
assert "diff --git a/src/a.py b/src/a.py" in text
|
||||||
|
assert orig > kept
|
||||||
|
|
||||||
|
|
||||||
|
def test_compress_diff_context_zero_strips_context():
|
||||||
|
text, orig, kept = compress_diff(_DIFF, context=0)
|
||||||
|
assert "+added" in text and "-removed" in text and "+extra" in text
|
||||||
|
# Context lines dropped (only +/- survive)
|
||||||
|
assert " ctx1" not in text
|
||||||
|
assert "ctx2" not in text
|
||||||
|
assert orig > kept
|
||||||
|
|
||||||
|
|
||||||
|
def test_compress_diff_negative_disables_compression():
|
||||||
|
text, orig, kept = compress_diff(_DIFF, context=-1)
|
||||||
|
assert text == _DIFF
|
||||||
|
assert orig == kept
|
||||||
|
|
||||||
|
|
||||||
|
def test_compress_diff_collapsed_gap_marker():
|
||||||
|
# Two +/- lines separated by 14 context lines, context=2 — the gap between
|
||||||
|
# them is 10 dropped lines (between the +/- windows), which exceeds the
|
||||||
|
# 5-line marker threshold. The marker tells the reviewer there's more code
|
||||||
|
# between the kept hunks.
|
||||||
|
middle = "\n".join(f" m{i}" for i in range(14)) + "\n" # trailing \n!
|
||||||
|
diff = (
|
||||||
|
"diff --git a/x.py b/x.py\n"
|
||||||
|
"--- a/x.py\n"
|
||||||
|
"+++ b/x.py\n"
|
||||||
|
"@@ -1,21 +1,23 @@\n"
|
||||||
|
+ " c1\n c2\n" # ctx near +a (kept with context=2)
|
||||||
|
+ "+a\n"
|
||||||
|
+ middle
|
||||||
|
+ "+b\n"
|
||||||
|
+ " c1\n c2\n" # ctx near +b (kept with context=2)
|
||||||
|
)
|
||||||
|
text, _, _ = compress_diff(diff, context=2)
|
||||||
|
assert "+a" in text and "+b" in text
|
||||||
|
assert "@@ …" in text and "context line(s) omitted" in text
|
||||||
|
|
||||||
|
|
||||||
|
def test_compress_diff_strips_no_newline_marker():
|
||||||
|
diff = (
|
||||||
|
"diff --git a/x.py b/x.py\n"
|
||||||
|
"--- a/x.py\n"
|
||||||
|
"+++ b/x.py\n"
|
||||||
|
"@@ -1,2 +1,2 @@\n"
|
||||||
|
" a\n"
|
||||||
|
"-b\n"
|
||||||
|
"\\ No newline at end of file\n"
|
||||||
|
"+c\n"
|
||||||
|
"\\ No newline at end of file\n"
|
||||||
|
)
|
||||||
|
text, _, _ = compress_diff(diff, context=2)
|
||||||
|
assert "\\ No newline" not in text
|
||||||
|
assert "-b" in text and "+c" in text
|
||||||
|
|
||||||
|
|
||||||
|
def test_compress_diff_empty_and_none():
|
||||||
|
text, orig, kept = compress_diff("", context=2)
|
||||||
|
assert text == ""
|
||||||
|
assert orig == 0 and kept == 0
|
||||||
|
text, orig, kept = compress_diff(None, context=2) # type: ignore[arg-context]
|
||||||
|
assert text == ""
|
||||||
|
assert orig == 0 and kept == 0
|
||||||
|
|
||||||
|
|
||||||
|
def test_compress_diff_pure_context_hunk_drops_body():
|
||||||
|
# A hunk that's *only* context lines (rare but legal — `git diff` emits
|
||||||
|
# these when the post-image differs only in whitespace outside the visible
|
||||||
|
# hunk) collapses entirely: file headers stay, the empty hunk header
|
||||||
|
# itself drops. The reviewer doesn't need to re-read unchanged code.
|
||||||
|
diff = (
|
||||||
|
"diff --git a/x.py b/x.py\n"
|
||||||
|
"--- a/x.py\n"
|
||||||
|
"+++ b/x.py\n"
|
||||||
|
"@@ -1,3 +1,3 @@\n"
|
||||||
|
" a\n"
|
||||||
|
" b\n"
|
||||||
|
" c\n"
|
||||||
|
)
|
||||||
|
text, _, _ = compress_diff(diff, context=2)
|
||||||
|
assert text == "diff --git a/x.py b/x.py\n--- a/x.py\n+++ b/x.py\n"
|
||||||
|
assert "@@ -1,3" not in text # empty hunk header dropped
|
||||||
|
|
||||||
|
|
||||||
|
def test_compress_diff_wide_window_keeps_more_context():
|
||||||
|
narrow, _, _ = compress_diff(_DIFF, context=0)
|
||||||
|
wide, _, wide_kept = compress_diff(_DIFF, context=10)
|
||||||
|
assert wide_kept > len(narrow)
|
||||||
|
|
||||||
|
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
# extract_finding_bullets
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
|
||||||
|
_BODY = """\
|
||||||
|
🤖 **AI Review** · pragent pilot · glm-5.2:cloud · `abcdef12`
|
||||||
|
|
||||||
|
Adds the salvavoid void-death item-rescue module. Risk is moderate on the
|
||||||
|
PlayerDeathEvent item/inventory path. New findings (not in prior review):
|
||||||
|
orphaned chest left in world on rescue failure, missing module-enabled check.
|
||||||
|
|
||||||
|
- **[HIGH]** `src/main/java/dev/marcospaulo/canalhandia/VoidProtection.java:162` — drop duplication race. fix: use ItemMeta to write inventory once. (ref: https://example.com)
|
||||||
|
- **[MEDIUM]** `src/main/java/dev/marcospaulo/canalhandia/VoidProtection.java:67` — O(n^2) spiral. fix: cap radius. (https://example.com/spiral)
|
||||||
|
- **[LOW]** `src/main/java/dev/marcospaulo/canalhandia/VoidProtection.java:3` — package-info javadoc missing.
|
||||||
|
|
||||||
|
_4 inline comment(s) posted below._
|
||||||
|
<!-- pragent:sha=abcdef1234567890 -->
|
||||||
|
"""
|
||||||
|
|
||||||
|
|
||||||
|
def test_extract_finding_bullets_basic():
|
||||||
|
bs = extract_finding_bullets(_BODY)
|
||||||
|
assert len(bs) == 3
|
||||||
|
assert any("HIGH" in b and "VoidProtection.java:162" in b for b in bs)
|
||||||
|
assert any("MEDIUM" in b for b in bs)
|
||||||
|
assert any("LOW" in b for b in bs)
|
||||||
|
|
||||||
|
|
||||||
|
def test_extract_finding_bullets_drops_prose():
|
||||||
|
bs = extract_finding_bullets(_BODY)
|
||||||
|
joined = "\n".join(bs)
|
||||||
|
# The summary prose is dropped.
|
||||||
|
assert "Adds the salvavoid" not in joined
|
||||||
|
assert "PlayerDeathEvent item/inventory path" not in joined
|
||||||
|
# The inline-comment footer is dropped.
|
||||||
|
assert "inline comment(s) posted below" not in joined
|
||||||
|
# The sha marker is dropped.
|
||||||
|
assert "pragent:sha=" not in joined
|
||||||
|
|
||||||
|
|
||||||
|
def test_extract_finding_bullets_accepts_lowercase_summary_bullets():
|
||||||
|
# `summary_bullets` renders `- **[HIGH]**` (bold); older reviews used
|
||||||
|
# `- [high]` (plain). Both should match.
|
||||||
|
text = (
|
||||||
|
"- [critical] `a.py:1` — bug. fix: fix it.\n"
|
||||||
|
"- **[HIGH]** `b.go:9` — race.\n"
|
||||||
|
)
|
||||||
|
bs = extract_finding_bullets(text)
|
||||||
|
assert len(bs) == 2
|
||||||
|
assert "CRITICAL" in bs[0].upper() or "critical" in bs[0]
|
||||||
|
assert "HIGH" in bs[1]
|
||||||
|
|
||||||
|
|
||||||
|
def test_extract_finding_bullets_empty_and_prose_only():
|
||||||
|
assert extract_finding_bullets("") == []
|
||||||
|
assert extract_finding_bullets(" \n \n") == []
|
||||||
|
assert extract_finding_bullets("Just some prose, no bullets here.") == []
|
||||||
|
assert extract_finding_bullets("- This is a regular bullet, not a finding.") == []
|
||||||
|
|
||||||
|
|
||||||
|
def test_extract_finding_bullets_keeps_indented_subbullets():
|
||||||
|
# A finding may carry continuation lines below it (rare in pragent output
|
||||||
|
# but legal). We only pull the matching line itself — sub-bullets stay
|
||||||
|
# with their parent as prose.
|
||||||
|
text = (
|
||||||
|
"- **[HIGH]** `a.py:1` — bug.\n"
|
||||||
|
" sub-bullet continuation that the reviewer wrote\n"
|
||||||
|
"- **[LOW]** `b.go:2` — nit.\n"
|
||||||
|
)
|
||||||
|
bs = extract_finding_bullets(text)
|
||||||
|
assert len(bs) == 2
|
||||||
|
assert all("sub-bullet continuation" not in b for b in bs)
|
||||||
|
|
||||||
|
|
||||||
|
def test_compress_diff_preserves_anchors_for_post_change_lines():
|
||||||
|
# Sanity: a finding anchored on a context line that compress_diff keeps
|
||||||
|
# must still be a valid anchor after compression. We re-run the parser the
|
||||||
|
# ai_review core uses, so a regression here surfaces as misanchored
|
||||||
|
# inline comments in production.
|
||||||
|
import ai_review
|
||||||
|
diff = (
|
||||||
|
"diff --git a/x.py b/x.py\n"
|
||||||
|
"--- a/x.py\n"
|
||||||
|
"+++ b/x.py\n"
|
||||||
|
"@@ -10,4 +10,5 @@\n"
|
||||||
|
" ctx_a\n"
|
||||||
|
" ctx_b\n"
|
||||||
|
"+new\n"
|
||||||
|
" ctx_c\n"
|
||||||
|
" ctx_d\n"
|
||||||
|
)
|
||||||
|
text, _, _ = compress_diff(diff, context=1)
|
||||||
|
anchors = ai_review.parse_diff_anchors(text)
|
||||||
|
assert 12 in anchors["x.py"] # +new
|
||||||
|
# ctx_a is within 1 line of +new at line 12, so kept.
|
||||||
|
assert 11 in anchors["x.py"]
|
||||||
Reference in New Issue
Block a user