Skip to content

Commit 9083e8a

Browse files
authored
Merge pull request #205 from sunmh207/feature/202606-deep-review
Feature/202606 deep review
2 parents 45bae57 + ad46257 commit 9083e8a

44 files changed

Lines changed: 6461 additions & 20 deletions

Some content is hidden

Large Commits have some content hidden by default. Use the searchbox below for content that may be hidden.

.gitignore

Lines changed: 7 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -17,3 +17,10 @@ __pycache__/
1717
.cursor
1818
.claude/
1919
openspec/
20+
21+
# Agentic mode local repo cache
22+
data/repo_cache/
23+
24+
# Test coverage artifact
25+
.coverage
26+
.pytest_cache/

README.md

Lines changed: 41 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -18,10 +18,18 @@
1818
- 📊 可视化 Dashboard
1919
- 集中展示所有 Code Review 记录,项目统计、开发者统计,数据说话,甩锅无门!
2020
- 🎭 Review Style 任你选
21-
- 专业型 🤵:严谨细致,正式专业。
22-
- 讽刺型 😈:毒舌吐槽,专治不服("这代码是用脚写的吗?")
23-
- 绅士型 🌸:温柔建议,如沐春风("或许这里可以再优化一下呢~")
21+
- 专业型 🤵:严谨细致,正式专业。
22+
- 讽刺型 😈:毒舌吐槽,专治不服("这代码是用脚写的吗?")
23+
- 绅士型 🌸:温柔建议,如沐春风("或许这里可以再优化一下呢~")
2424
- 幽默型 🤪:搞笑点评,快乐改码("这段 if-else 比我的相亲经历还曲折!")
25+
- 🤖 Agentic Review 模式(可选)
26+
- LLM 拥有工具调用能力(`read_file` / 沙箱 `run_command`),
27+
可在本地克隆的代码库内自主探索,产出更全面的 review 结果。
28+
- shell 默认仅允许读类命令(`ls` / `cat` / `grep` / `find` / `git log` …),
29+
沙箱 + 路径越界 + 30s 超时三重防护。
30+
- 任意阶段失败(clone / fetch / LLM / 工具调用)自动降级回 `diff_only`
31+
保证至少返回与原版一致的 review。
32+
- 详细配置与开销说明见下方 [Agentic Review Mode](#agentic-review-mode-可选)
2533

2634
**效果图:**
2735

@@ -181,6 +189,36 @@ python -m biz.cmd.review
181189

182190
参见 [常见问题](doc/faq.md)
183191

192+
## Agentic Review Mode (可选)
193+
194+
`REVIEW_STRATEGY` 环境变量切换两种 review 策略:
195+
196+
- `diff_only`(默认):仅对 diff 做 review,行为与原版完全一致。
197+
- `agentic`:LLM 拥有工具调用能力(read_file / 沙箱 shell),
198+
可在本地克隆的代码库内自主探索,产出更全面的 review 结果。
199+
200+
启用 agentic 模式:
201+
202+
```bash
203+
REVIEW_STRATEGY=agentic
204+
REPO_CACHE_DIR=/var/data/repo_cache # 可选,默认 data/repo_cache/
205+
AGENT_MAX_ITERATIONS=20 # 可选,默认 20
206+
```
207+
208+
agentic 模式会按需在 `REPO_CACHE_DIR` 下克隆/更新目标项目(约 10MB~2GB / 项目)。
209+
任意阶段失败(clone / fetch / LLM / 工具调用异常)都会自动降级回 `diff_only`
210+
保证至少返回与原版一致的 review。
211+
212+
agentic 模式的额外开销:
213+
214+
- 磁盘:建议预留 ≥ 50GB
215+
- 内存:单次 session 峰值 ~500MB
216+
- Token:单次 review 平均 5k~50k tokens(diff_only 的 3~10 倍)
217+
- 时延:30s~5min / review
218+
219+
⚠️ shell 工具有沙箱(命令白名单 + 黑名单 + 路径越界检查 + 30s 超时),
220+
默认只允许读类命令;如需放开请通过 `AGENT_SHELL_ALLOWLIST` / `AGENT_SHELL_BLOCKLIST` 调整。
221+
184222
## 相关项目
185223

186224
### 1. Code Review Pro 版

biz/agent/__init__.py

Whitespace-only changes.

biz/agent/agentic_reviewer.py

Lines changed: 198 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,198 @@
1+
"""Top-level entry point for agentic review, used by worker.py."""
2+
from __future__ import annotations
3+
4+
import json
5+
import os
6+
import re
7+
import time
8+
from dataclasses import asdict, dataclass
9+
from pathlib import Path
10+
from typing import Any
11+
12+
from biz.agent.llm_adapter import LLMAdapter
13+
from biz.agent.prompts import load_prompt
14+
from biz.agent.repo_syncer import LocalRepoSyncer
15+
from biz.agent.runner import AgentRunner
16+
from biz.agent.tools import register_default_tools
17+
from biz.agent.tool_registry import ToolRegistry
18+
from biz.llm.factory import Factory
19+
from biz.utils.code_reviewer import CodeReviewer
20+
from biz.utils.log import logger
21+
from biz.utils.im import notifier
22+
23+
24+
# Same regex CodeReviewer.parse_review_score uses; reused as a sanity gate
25+
# so we don't post the agent's tool-selection reasoning as a "review".
26+
_REVIEW_SCORE_RE = re.compile(r"总分[::]\s*(\d+)分?")
27+
28+
29+
def _slugify_repo_key(provider: str, project: str) -> str:
30+
"""Build a stable cache key for a project."""
31+
return f"{provider}_{project}".replace("/", "_").replace(" ", "_")
32+
33+
34+
def _parse_csv_env(name: str, default: list[str]) -> list[str]:
35+
"""Read a comma-separated env var, fall back to `default` if unset/empty."""
36+
raw = os.getenv(name, "").strip()
37+
if not raw:
38+
return list(default)
39+
return [item.strip() for item in raw.split(",") if item.strip()] or list(default)
40+
41+
42+
def _looks_like_review(text: str | None) -> bool:
43+
"""Heuristic: a well-formed agentic review must include the `总分:XX分` marker."""
44+
if not text:
45+
return False
46+
return bool(_REVIEW_SCORE_RE.search(text))
47+
48+
49+
@dataclass
50+
class ReviewLog:
51+
event: str
52+
project: str
53+
ref: str
54+
strategy: str
55+
iterations: int
56+
total_tokens_est: int
57+
duration_ms: int
58+
review_result_length: int
59+
score: int
60+
degraded: bool
61+
tool_calls: list[dict]
62+
63+
64+
def _estimate_tokens(messages: list[dict]) -> int:
65+
"""Rough token estimate: sum of len(content)//4 over assistant messages."""
66+
total = 0
67+
for m in messages:
68+
if m.get("role") != "assistant":
69+
continue
70+
content = m.get("content") or ""
71+
if isinstance(content, str):
72+
total += len(content) // 4
73+
return total
74+
75+
76+
def _collect_tool_calls(messages: list[dict]) -> list[dict]:
77+
"""Flatten tool_calls from assistant messages for structured logging."""
78+
calls: list[dict] = []
79+
for m in messages:
80+
if m.get("role") != "assistant":
81+
continue
82+
for call in m.get("tool_calls") or []:
83+
calls.append(call)
84+
return calls
85+
86+
87+
class AgenticReviewer:
88+
def __init__(
89+
self,
90+
*,
91+
repo_url: str,
92+
repo_key: str,
93+
ref: str,
94+
cache_root: Path | str,
95+
adapter: LLMAdapter | None = None,
96+
max_iterations: int = 20,
97+
total_token_cap: int = 80_000,
98+
) -> None:
99+
self.repo_url = repo_url
100+
self.repo_key = repo_key
101+
self.ref = ref
102+
self.cache_root = Path(cache_root)
103+
self.adapter = adapter
104+
self.max_iterations = max_iterations
105+
self.total_token_cap = total_token_cap
106+
107+
def _build_adapter(self) -> LLMAdapter:
108+
if self.adapter is not None:
109+
return self.adapter
110+
client = Factory().getClient()
111+
return LLMAdapter(client)
112+
113+
def _build_registry(self, repo_root: Path) -> ToolRegistry:
114+
registry = ToolRegistry()
115+
from biz.agent.tools.run_command import (
116+
DEFAULT_ALLOWLIST,
117+
DEFAULT_BLOCKLIST,
118+
)
119+
allow = _parse_csv_env("AGENT_SHELL_ALLOWLIST", DEFAULT_ALLOWLIST)
120+
block = _parse_csv_env("AGENT_SHELL_BLOCKLIST", DEFAULT_BLOCKLIST)
121+
register_default_tools(registry, repo_root, allowlist=allow, blocklist=block)
122+
return registry
123+
124+
def review(self, diffs_text: str, commits_text: str) -> str:
125+
start = time.monotonic()
126+
# 1. Sync repo locally.
127+
try:
128+
syncer = LocalRepoSyncer(cache_root=self.cache_root)
129+
repo_root = syncer.sync_to(url=self.repo_url, key=self.repo_key, ref=self.ref)
130+
except Exception as e:
131+
logger.error("agentic repo sync failed, degrading: %s", e)
132+
notifier.send_notification(content=f"[agentic] repo sync failed: {e}; falling back to diff_only")
133+
return CodeReviewer().review_and_strip_code(diffs_text, commits_text)
134+
135+
# 2. Build adapter, registry, runner.
136+
adapter = self._build_adapter()
137+
registry = self._build_registry(repo_root)
138+
runner = AgentRunner(
139+
adapter=adapter,
140+
registry=registry,
141+
max_iterations=self.max_iterations,
142+
total_token_cap=self.total_token_cap,
143+
)
144+
145+
# 3. Build initial messages from prompt template.
146+
prompts = load_prompt("agentic_code_review_prompt", style=os.getenv("REVIEW_STYLE", "professional"))
147+
user_content = prompts["user_message"]["content"].format(
148+
diffs_text=diffs_text,
149+
commits_text=commits_text,
150+
repo_root=str(repo_root),
151+
)
152+
messages = [prompts["system_message"], {"role": "user", "content": user_content}]
153+
154+
# 4. Run the agent loop with soft-degrade; collect metadata for logging.
155+
run_meta: dict[str, Any] = {}
156+
result: str
157+
degraded = False
158+
try:
159+
result = runner.run(messages, out=run_meta)
160+
except Exception as e:
161+
logger.error("agentic run failed, degrading to diff_only: %s", e)
162+
notifier.send_notification(content=f"[agentic] run failed: {e}; falling back to diff_only")
163+
degraded = True
164+
result = CodeReviewer().review_and_strip_code(diffs_text, commits_text)
165+
166+
# 4b. Defense-in-depth: if the agent's text doesn't look like a review
167+
# (missing the `总分:XX分` marker), treat the leak as a failure and
168+
# fall back to diff_only. Otherwise the agent's tool-selection
169+
# reasoning — e.g. "AST query doesn't find references, let me check
170+
# the openspec folder" — would be posted to GitLab as a "review".
171+
if not degraded and not _looks_like_review(result):
172+
logger.warning(
173+
"agent output missing 总分 marker (len=%d), degrading to diff_only",
174+
len(result or ""),
175+
)
176+
notifier.send_notification(
177+
content="[agentic] output missing 总分 marker; falling back to diff_only"
178+
)
179+
degraded = True
180+
result = CodeReviewer().review_and_strip_code(diffs_text, commits_text)
181+
182+
# 5. Emit structured per-review log line.
183+
run_messages = run_meta.get("messages", messages)
184+
log_entry = ReviewLog(
185+
event="agentic_review",
186+
project=self.repo_key,
187+
ref=self.ref,
188+
strategy="agentic",
189+
iterations=run_meta.get("iterations", 0),
190+
total_tokens_est=_estimate_tokens(run_messages),
191+
duration_ms=int((time.monotonic() - start) * 1000),
192+
review_result_length=len(result),
193+
score=CodeReviewer.parse_review_score(review_text=result),
194+
degraded=degraded,
195+
tool_calls=_collect_tool_calls(run_messages),
196+
)
197+
logger.info(json.dumps(asdict(log_entry), ensure_ascii=False))
198+
return result

0 commit comments

Comments
 (0)