semantic-code-review 0.24.2__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.
- semantic_code_review/__init__.py +5 -0
- semantic_code_review/augment/__init__.py +3 -0
- semantic_code_review/augment/agents.py +95 -0
- semantic_code_review/augment/console.py +440 -0
- semantic_code_review/augment/extra_review.py +237 -0
- semantic_code_review/augment/fold_summary.py +447 -0
- semantic_code_review/augment/hunks.py +264 -0
- semantic_code_review/augment/mcp_server.py +156 -0
- semantic_code_review/augment/overview.py +233 -0
- semantic_code_review/augment/pass_.py +171 -0
- semantic_code_review/augment/pipeline.py +571 -0
- semantic_code_review/augment/progress.py +223 -0
- semantic_code_review/augment/prompts.py +93 -0
- semantic_code_review/augment/schemas.py +408 -0
- semantic_code_review/augment/tools.py +475 -0
- semantic_code_review/augment/trace_adapter.py +369 -0
- semantic_code_review/backends/__init__.py +95 -0
- semantic_code_review/backends/_cli_driver.py +571 -0
- semantic_code_review/backends/anthropic_sdk.py +56 -0
- semantic_code_review/backends/base.py +104 -0
- semantic_code_review/backends/claude_cli.py +421 -0
- semantic_code_review/backends/google_sdk.py +73 -0
- semantic_code_review/backends/openai_compat.py +42 -0
- semantic_code_review/cache/__init__.py +3 -0
- semantic_code_review/cache/store.py +87 -0
- semantic_code_review/cli/__init__.py +70 -0
- semantic_code_review/cli/_shared.py +157 -0
- semantic_code_review/cli/augment.py +74 -0
- semantic_code_review/cli/config_cmd.py +266 -0
- semantic_code_review/cli/fetch.py +33 -0
- semantic_code_review/cli/lint.py +27 -0
- semantic_code_review/cli/pr.py +98 -0
- semantic_code_review/cli/review.py +104 -0
- semantic_code_review/cli/runs_cmd.py +17 -0
- semantic_code_review/cli/show.py +19 -0
- semantic_code_review/cli/strip.py +20 -0
- semantic_code_review/config.py +563 -0
- semantic_code_review/config_template.py +154 -0
- semantic_code_review/fetch/__init__.py +61 -0
- semantic_code_review/fetch/anchor.py +244 -0
- semantic_code_review/fetch/github.py +229 -0
- semantic_code_review/fetch/github_comments.py +404 -0
- semantic_code_review/fetch/local.py +443 -0
- semantic_code_review/fetch/run_source.py +70 -0
- semantic_code_review/format/__init__.py +3 -0
- semantic_code_review/format/emit.py +163 -0
- semantic_code_review/format/lint.py +74 -0
- semantic_code_review/format/parse.py +574 -0
- semantic_code_review/format/sidecar.py +19 -0
- semantic_code_review/format/strip.py +20 -0
- semantic_code_review/git_ops.py +252 -0
- semantic_code_review/paths.py +61 -0
- semantic_code_review/review/__init__.py +3 -0
- semantic_code_review/review/comments.py +176 -0
- semantic_code_review/review/github.py +284 -0
- semantic_code_review/review/github_graphql.py +433 -0
- semantic_code_review/review/pr_flow.py +299 -0
- semantic_code_review/review/runner.py +407 -0
- semantic_code_review/review/server.py +1043 -0
- semantic_code_review/structural/__init__.py +41 -0
- semantic_code_review/structural/diff.py +102 -0
- semantic_code_review/structural/parse.py +330 -0
- semantic_code_review/structural/symbols.py +43 -0
- semantic_code_review/viewer/__init__.py +3 -0
- semantic_code_review/viewer/assets/annotations.ts +547 -0
- semantic_code_review/viewer/assets/boot.ts +273 -0
- semantic_code_review/viewer/assets/comment_store.ts +121 -0
- semantic_code_review/viewer/assets/comments.ts +624 -0
- semantic_code_review/viewer/assets/console.ts +426 -0
- semantic_code_review/viewer/assets/console_render.ts +213 -0
- semantic_code_review/viewer/assets/console_selection.ts +102 -0
- semantic_code_review/viewer/assets/data_store.ts +156 -0
- semantic_code_review/viewer/assets/file_rows.ts +49 -0
- semantic_code_review/viewer/assets/folds.ts +560 -0
- semantic_code_review/viewer/assets/index.html +62 -0
- semantic_code_review/viewer/assets/post_modal.ts +383 -0
- semantic_code_review/viewer/assets/progress.ts +138 -0
- semantic_code_review/viewer/assets/render.ts +937 -0
- semantic_code_review/viewer/assets/sidebar.ts +429 -0
- semantic_code_review/viewer/assets/sse.ts +78 -0
- semantic_code_review/viewer/assets/text_highlight.ts +215 -0
- semantic_code_review/viewer/assets/types.d.ts +388 -0
- semantic_code_review/viewer/assets/vendor/LICENSE +29 -0
- semantic_code_review/viewer/assets/vendor/VENDOR.md +55 -0
- semantic_code_review/viewer/assets/vendor/github-dark.min.css +10 -0
- semantic_code_review/viewer/assets/vendor/github.min.css +10 -0
- semantic_code_review/viewer/assets/vendor/highlight.min.js +1244 -0
- semantic_code_review/viewer/assets/vendor/mermaid.LICENSE +21 -0
- semantic_code_review/viewer/assets/vendor/mermaid.min.js +3587 -0
- semantic_code_review/viewer/assets/vendor/refresh.sh +65 -0
- semantic_code_review/viewer/assets/viewer.css +1202 -0
- semantic_code_review/viewer/assets/viewer.js +10588 -0
- semantic_code_review/viewer/build_json.py +546 -0
- semantic_code_review/viewer/hunk_layout.py +471 -0
- semantic_code_review-0.24.2.dist-info/METADATA +348 -0
- semantic_code_review-0.24.2.dist-info/RECORD +100 -0
- semantic_code_review-0.24.2.dist-info/WHEEL +5 -0
- semantic_code_review-0.24.2.dist-info/entry_points.txt +2 -0
- semantic_code_review-0.24.2.dist-info/licenses/LICENSE +21 -0
- semantic_code_review-0.24.2.dist-info/top_level.txt +1 -0
|
@@ -0,0 +1,475 @@
|
|
|
1
|
+
"""Repo tools the LLM can call during a per-hunk pass.
|
|
2
|
+
|
|
3
|
+
Every tool is read-only, operates on the fetched worktrees, and returns
|
|
4
|
+
text. Large results are truncated and flagged so the model can narrow
|
|
5
|
+
its query.
|
|
6
|
+
|
|
7
|
+
`RepoTools` is the single source of truth for the tool surface. Methods
|
|
8
|
+
decorated with `@_tool` are exported to two consumers:
|
|
9
|
+
|
|
10
|
+
* pydantic-ai SDK Agents — via `TOOL_FUNCTIONS`, a list of `RunContext`-
|
|
11
|
+
wrapping callables produced from the decorated methods.
|
|
12
|
+
* The MCP stdio server (`mcp_server.py`) — via `mcp_tool_schemas()` for
|
|
13
|
+
the `tools/list` payload and `mcp_dispatch()` for `tools/call`.
|
|
14
|
+
|
|
15
|
+
Both surfaces are derived by introspection: rename a `RepoTools` method
|
|
16
|
+
and both update with no other edits.
|
|
17
|
+
"""
|
|
18
|
+
|
|
19
|
+
from __future__ import annotations
|
|
20
|
+
|
|
21
|
+
import inspect
|
|
22
|
+
import os
|
|
23
|
+
import shutil
|
|
24
|
+
import subprocess
|
|
25
|
+
from collections.abc import Callable
|
|
26
|
+
from dataclasses import dataclass
|
|
27
|
+
from pathlib import Path
|
|
28
|
+
from typing import Any, cast
|
|
29
|
+
|
|
30
|
+
from pydantic_ai import RunContext
|
|
31
|
+
from pydantic_ai.tools import Tool
|
|
32
|
+
|
|
33
|
+
from .. import git_ops, structural
|
|
34
|
+
|
|
35
|
+
TOOL_RESULT_CAP_BYTES = 20 * 1024
|
|
36
|
+
|
|
37
|
+
|
|
38
|
+
# Resolved once at import; tests that want to force a path can monkeypatch.
|
|
39
|
+
_HAS_RIPGREP = shutil.which("rg") is not None
|
|
40
|
+
|
|
41
|
+
|
|
42
|
+
_TOOL_EXPORT_ATTR = "_repo_tool_export"
|
|
43
|
+
|
|
44
|
+
|
|
45
|
+
def _tool(method: Callable) -> Callable:
|
|
46
|
+
"""Mark a `RepoTools` method as part of the LLM-facing tool surface."""
|
|
47
|
+
setattr(method, _TOOL_EXPORT_ATTR, True)
|
|
48
|
+
return method
|
|
49
|
+
|
|
50
|
+
|
|
51
|
+
@dataclass
|
|
52
|
+
class RepoTools:
|
|
53
|
+
head_worktree: Path
|
|
54
|
+
repo_git: Path
|
|
55
|
+
base_sha: str
|
|
56
|
+
head_sha: str
|
|
57
|
+
# Optional augmented diff (an `AnnotatedDiff`), bound only by the
|
|
58
|
+
# review console so its `hunk(id)` accessor can resolve a viewer
|
|
59
|
+
# hunk id to that hunk's diff text. Kept loosely typed (`Any`) so
|
|
60
|
+
# the augment-side schemas stay off this module's import path;
|
|
61
|
+
# left None on every augment/MCP path, where no sidecar exists yet.
|
|
62
|
+
diff: Any = None
|
|
63
|
+
|
|
64
|
+
# --- file reads -------------------------------------------------------
|
|
65
|
+
|
|
66
|
+
@_tool
|
|
67
|
+
def read_file(self, path: str, start_line: int | None = None, end_line: int | None = None) -> str:
|
|
68
|
+
"""Read a file from the head worktree. Returns up to 20 KB of text.
|
|
69
|
+
|
|
70
|
+
Args:
|
|
71
|
+
path: Path relative to repo root.
|
|
72
|
+
start_line: 1-indexed start line (optional).
|
|
73
|
+
end_line: 1-indexed end line inclusive (optional).
|
|
74
|
+
"""
|
|
75
|
+
full = (self.head_worktree / path).resolve()
|
|
76
|
+
if not _is_inside(full, self.head_worktree):
|
|
77
|
+
return f"error: path outside worktree: {path}"
|
|
78
|
+
if not full.exists():
|
|
79
|
+
return f"error: not found: {path}"
|
|
80
|
+
try:
|
|
81
|
+
text = full.read_text(encoding="utf-8", errors="replace")
|
|
82
|
+
except OSError as e:
|
|
83
|
+
return f"error: could not read {path}: {e}"
|
|
84
|
+
return _slice_and_cap(text, start_line, end_line)
|
|
85
|
+
|
|
86
|
+
@_tool
|
|
87
|
+
def read_file_at(self, sha: str, path: str, start_line: int | None = None, end_line: int | None = None) -> str:
|
|
88
|
+
"""Read a file at a specific commit SHA (e.g. the PR base).
|
|
89
|
+
|
|
90
|
+
Use for pre-change content.
|
|
91
|
+
|
|
92
|
+
Args:
|
|
93
|
+
sha: Commit SHA.
|
|
94
|
+
path: Path relative to repo root.
|
|
95
|
+
start_line: 1-indexed start line (optional).
|
|
96
|
+
end_line: 1-indexed end line inclusive (optional).
|
|
97
|
+
"""
|
|
98
|
+
rc, stdout, stderr = git_ops.git_capture(
|
|
99
|
+
self.repo_git,
|
|
100
|
+
"show",
|
|
101
|
+
f"{sha}:{path}",
|
|
102
|
+
)
|
|
103
|
+
if rc != 0:
|
|
104
|
+
return f"error: git show {sha}:{path} failed: {stderr.strip()}"
|
|
105
|
+
return _slice_and_cap(stdout, start_line, end_line)
|
|
106
|
+
|
|
107
|
+
# --- structure --------------------------------------------------------
|
|
108
|
+
|
|
109
|
+
@_tool
|
|
110
|
+
def outline(self, path: str, sha: str | None = None) -> str:
|
|
111
|
+
"""Structural symbol outline of a file, as a JSON array.
|
|
112
|
+
|
|
113
|
+
Deterministic tree-sitter parse — no LLM, no hallucination. Each
|
|
114
|
+
entry is a definition (class / function / constant) with its
|
|
115
|
+
`name`, `qualified_name`, 1-indexed line `range`, declared
|
|
116
|
+
`signature` (or null), and nested `children` (class ▸ method).
|
|
117
|
+
Unsupported language or parse failure ⇒ `[]`.
|
|
118
|
+
|
|
119
|
+
Args:
|
|
120
|
+
path: Path relative to repo root.
|
|
121
|
+
sha: Commit SHA to read the file at (defaults to head worktree).
|
|
122
|
+
"""
|
|
123
|
+
lang = structural.language_for_path(path)
|
|
124
|
+
if lang is None:
|
|
125
|
+
return "[]"
|
|
126
|
+
source = self._read_source(path, sha)
|
|
127
|
+
if source is None:
|
|
128
|
+
return "[]"
|
|
129
|
+
symbols = structural.outline_symbols(source, lang)
|
|
130
|
+
return _cap(structural.symbols_to_json(symbols))
|
|
131
|
+
|
|
132
|
+
def _read_source(self, path: str, sha: str | None) -> str | None:
|
|
133
|
+
"""Raw file text from the head worktree (``sha is None``) or at a
|
|
134
|
+
revision via ``git show``. ``None`` if it can't be read.
|
|
135
|
+
"""
|
|
136
|
+
if sha is None:
|
|
137
|
+
full = (self.head_worktree / path).resolve()
|
|
138
|
+
if not _is_inside(full, self.head_worktree) or not full.is_file():
|
|
139
|
+
return None
|
|
140
|
+
try:
|
|
141
|
+
return full.read_text(encoding="utf-8", errors="replace")
|
|
142
|
+
except OSError:
|
|
143
|
+
return None
|
|
144
|
+
rc, stdout, _stderr = git_ops.git_capture(self.repo_git, "show", f"{sha}:{path}")
|
|
145
|
+
return stdout if rc == 0 else None
|
|
146
|
+
|
|
147
|
+
@_tool
|
|
148
|
+
def symbol_at(self, path: str, line: int, sha: str | None = None) -> str:
|
|
149
|
+
"""Innermost symbol enclosing a line, as a JSON object (or `null`).
|
|
150
|
+
|
|
151
|
+
Deterministic tree-sitter parse — no LLM. Returns the most
|
|
152
|
+
specific definition (the method, not its class) whose 1-indexed
|
|
153
|
+
line `range` covers `line`, with its `name`, `qualified_name`,
|
|
154
|
+
`signature`, and nested `children`. `null` if no symbol covers
|
|
155
|
+
the line, or the language is unsupported / file unreadable.
|
|
156
|
+
|
|
157
|
+
Args:
|
|
158
|
+
path: Path relative to repo root.
|
|
159
|
+
line: 1-indexed line number.
|
|
160
|
+
sha: Commit SHA to read the file at (defaults to head worktree).
|
|
161
|
+
"""
|
|
162
|
+
lang = structural.language_for_path(path)
|
|
163
|
+
if lang is None:
|
|
164
|
+
return "null"
|
|
165
|
+
source = self._read_source(path, sha)
|
|
166
|
+
if source is None:
|
|
167
|
+
return "null"
|
|
168
|
+
symbols = structural.outline_symbols(source, lang)
|
|
169
|
+
return structural.symbol_to_json(structural.enclosing_symbol(symbols, line))
|
|
170
|
+
|
|
171
|
+
def compute_symbol_delta(self) -> structural.SymbolDelta:
|
|
172
|
+
"""Deterministic base→head structural delta over the whole diff.
|
|
173
|
+
|
|
174
|
+
Compares the base commit against the head worktree for every
|
|
175
|
+
changed file in a supported language and merges the per-file
|
|
176
|
+
`qualified_name` set-diffs into one diff-wide `SymbolDelta`.
|
|
177
|
+
Changed files in unsupported languages are silently absent.
|
|
178
|
+
|
|
179
|
+
Underlies both the `changed_symbols` tool (JSON for the LLM) and
|
|
180
|
+
the overview seed (the `SymbolDelta` object, consumed in-process).
|
|
181
|
+
Raises `git_ops.GitError` if the diff can't be enumerated.
|
|
182
|
+
"""
|
|
183
|
+
paths = git_ops.diff_name_only(self.repo_git, self.base_sha, self.head_sha)
|
|
184
|
+
deltas = []
|
|
185
|
+
for path in paths:
|
|
186
|
+
lang = structural.language_for_path(path)
|
|
187
|
+
if lang is None:
|
|
188
|
+
continue
|
|
189
|
+
base_src = self._read_source(path, self.base_sha)
|
|
190
|
+
head_src = self._read_source(path, None)
|
|
191
|
+
base_syms = structural.outline_symbols(base_src, lang) if base_src is not None else []
|
|
192
|
+
head_syms = structural.outline_symbols(head_src, lang) if head_src is not None else []
|
|
193
|
+
deltas.append(structural.diff_file(path, base_syms, head_syms))
|
|
194
|
+
return structural.merge(deltas)
|
|
195
|
+
|
|
196
|
+
@_tool
|
|
197
|
+
def changed_symbols(self) -> str:
|
|
198
|
+
"""Deterministic structural delta of the whole diff, as JSON.
|
|
199
|
+
|
|
200
|
+
Compares the base commit against the head worktree for every
|
|
201
|
+
changed file in a supported language, returning
|
|
202
|
+
`{added, removed, modified}` lists of symbols by `qualified_name`
|
|
203
|
+
set-diff — no LLM, no hallucination. `modified` means the same
|
|
204
|
+
qualified name on both sides with a differing line range; a
|
|
205
|
+
same-span body edit is not flagged. Each entry carries its
|
|
206
|
+
`path`, `kind`, `name`, `qualified_name`, declared `signature`,
|
|
207
|
+
and the line `range` on its live side (head for added/modified,
|
|
208
|
+
base for removed). Changed files in unsupported languages are
|
|
209
|
+
silently absent.
|
|
210
|
+
"""
|
|
211
|
+
try:
|
|
212
|
+
delta = self.compute_symbol_delta()
|
|
213
|
+
except git_ops.GitError as e:
|
|
214
|
+
return f"error: {e}"
|
|
215
|
+
return _cap(delta.model_dump_json())
|
|
216
|
+
|
|
217
|
+
# --- search -----------------------------------------------------------
|
|
218
|
+
|
|
219
|
+
@_tool
|
|
220
|
+
def grep(self, pattern: str, path_glob: str | None = None, max_hits: int = 50) -> str:
|
|
221
|
+
"""Search the head worktree with ripgrep.
|
|
222
|
+
|
|
223
|
+
Returns path:line:text matches, capped at 50 by default. Falls back
|
|
224
|
+
to `git grep` when ripgrep is unavailable. Output is ``path:line:text``
|
|
225
|
+
with worktree prefix stripped.
|
|
226
|
+
|
|
227
|
+
Args:
|
|
228
|
+
pattern: Pattern to search for.
|
|
229
|
+
path_glob: Restrict to matching files (e.g. 'src/**/*.py').
|
|
230
|
+
max_hits: Maximum matches to return.
|
|
231
|
+
"""
|
|
232
|
+
if _HAS_RIPGREP:
|
|
233
|
+
return self._grep_rg(pattern, path_glob, max_hits)
|
|
234
|
+
return self._grep_git(pattern, path_glob, max_hits)
|
|
235
|
+
|
|
236
|
+
def _grep_rg(self, pattern: str, path_glob: str | None, max_hits: int) -> str:
|
|
237
|
+
args = ["rg", "--no-heading", "-n", "--max-count", str(max_hits), "-e", pattern]
|
|
238
|
+
if path_glob:
|
|
239
|
+
args += ["--glob", path_glob]
|
|
240
|
+
args.append(str(self.head_worktree))
|
|
241
|
+
result = subprocess.run(args, capture_output=True, text=True, check=False)
|
|
242
|
+
if result.returncode not in (0, 1): # 1 = no matches
|
|
243
|
+
return f"error: rg failed: {result.stderr.strip()}"
|
|
244
|
+
prefix = str(self.head_worktree) + os.sep
|
|
245
|
+
out = "\n".join(line.removeprefix(prefix) for line in result.stdout.splitlines())
|
|
246
|
+
return _cap(out)
|
|
247
|
+
|
|
248
|
+
def _grep_git(self, pattern: str, path_glob: str | None, max_hits: int) -> str:
|
|
249
|
+
"""Fallback search via ``git grep`` — always available since git is a
|
|
250
|
+
hard requirement. Respects .gitignore; only searches tracked files.
|
|
251
|
+
"""
|
|
252
|
+
try:
|
|
253
|
+
return _cap(git_ops.grep(self.head_worktree, pattern, path_glob, max_hits))
|
|
254
|
+
except git_ops.GitError as e:
|
|
255
|
+
return f"error: {e}"
|
|
256
|
+
|
|
257
|
+
# --- listing ----------------------------------------------------------
|
|
258
|
+
|
|
259
|
+
@_tool
|
|
260
|
+
def list_dir(self, path: str = "") -> str:
|
|
261
|
+
"""List a directory in the head worktree (shallow, hidden files skipped).
|
|
262
|
+
|
|
263
|
+
Args:
|
|
264
|
+
path: Path relative to repo root (empty for root).
|
|
265
|
+
"""
|
|
266
|
+
full = (self.head_worktree / path).resolve() if path else self.head_worktree
|
|
267
|
+
if not _is_inside(full, self.head_worktree):
|
|
268
|
+
return f"error: path outside worktree: {path}"
|
|
269
|
+
if not full.is_dir():
|
|
270
|
+
return f"error: not a directory: {path}"
|
|
271
|
+
entries: list[str] = []
|
|
272
|
+
for p in sorted(full.iterdir()):
|
|
273
|
+
if p.name.startswith("."):
|
|
274
|
+
continue
|
|
275
|
+
marker = "/" if p.is_dir() else ""
|
|
276
|
+
entries.append(f"{p.name}{marker}")
|
|
277
|
+
return _cap("\n".join(entries))
|
|
278
|
+
|
|
279
|
+
# --- git --------------------------------------------------------------
|
|
280
|
+
|
|
281
|
+
@_tool
|
|
282
|
+
def git_log(self, path: str, limit: int = 5) -> str:
|
|
283
|
+
"""Recent commits touching a path (short form).
|
|
284
|
+
|
|
285
|
+
Args:
|
|
286
|
+
path: Path relative to repo root.
|
|
287
|
+
limit: Maximum commits to return.
|
|
288
|
+
"""
|
|
289
|
+
try:
|
|
290
|
+
return _cap(
|
|
291
|
+
git_ops.git(
|
|
292
|
+
self.repo_git,
|
|
293
|
+
"log",
|
|
294
|
+
f"-n{limit}",
|
|
295
|
+
"--oneline",
|
|
296
|
+
"--",
|
|
297
|
+
path,
|
|
298
|
+
)
|
|
299
|
+
)
|
|
300
|
+
except git_ops.GitError as e:
|
|
301
|
+
return f"error: {e}"
|
|
302
|
+
|
|
303
|
+
# --- diff (review console only) ---------------------------------------
|
|
304
|
+
|
|
305
|
+
def hunk(self, hunk_id: str) -> str:
|
|
306
|
+
"""Read one hunk's diff text, addressed by its viewer id.
|
|
307
|
+
|
|
308
|
+
Returns the hunk's `@@` header followed by its body (the `+`/`-`/
|
|
309
|
+
context lines as they appear in the change under review). Use this
|
|
310
|
+
to pull the exact diff for a hunk the reviewer is asking about
|
|
311
|
+
rather than re-reading whole files.
|
|
312
|
+
|
|
313
|
+
Args:
|
|
314
|
+
hunk_id: Viewer hunk id of the form 'H<file_idx>_<hunk_idx>'.
|
|
315
|
+
"""
|
|
316
|
+
if self.diff is None:
|
|
317
|
+
return "error: no diff bound — hunk() is only available in the review console"
|
|
318
|
+
try:
|
|
319
|
+
fi, hi = _parse_hunk_id(hunk_id)
|
|
320
|
+
except ValueError as e:
|
|
321
|
+
return f"error: {e}"
|
|
322
|
+
files = getattr(self.diff, "files", [])
|
|
323
|
+
if not (0 <= fi < len(files)):
|
|
324
|
+
return f"error: file index {fi} not in diff (hunk_id {hunk_id!r})"
|
|
325
|
+
hunks = files[fi].hunks
|
|
326
|
+
if not (0 <= hi < len(hunks)):
|
|
327
|
+
return f"error: hunk index {hi} not in file {files[fi].path!r} (hunk_id {hunk_id!r})"
|
|
328
|
+
parsed = hunks[hi].parsed
|
|
329
|
+
body = parsed.body or ""
|
|
330
|
+
text = parsed.header if not body else f"{parsed.header}\n{body}"
|
|
331
|
+
return _cap(f"# {files[fi].path}\n{text}")
|
|
332
|
+
|
|
333
|
+
|
|
334
|
+
def _parse_hunk_id(hunk_id: str) -> tuple[int, int]:
|
|
335
|
+
"""`"H{fi}_{hi}"` -> (fi, hi). Raises ValueError on malformed input.
|
|
336
|
+
|
|
337
|
+
Mirrors `review/server.py`'s parser; duplicated rather than imported
|
|
338
|
+
to keep this module free of the stdlib-only server module.
|
|
339
|
+
"""
|
|
340
|
+
if not hunk_id.startswith("H") or "_" not in hunk_id:
|
|
341
|
+
raise ValueError(f"malformed hunk_id {hunk_id!r}")
|
|
342
|
+
try:
|
|
343
|
+
fi_str, hi_str = hunk_id[1:].split("_", 1)
|
|
344
|
+
return int(fi_str), int(hi_str)
|
|
345
|
+
except ValueError as e:
|
|
346
|
+
raise ValueError(f"malformed hunk_id {hunk_id!r}") from e
|
|
347
|
+
|
|
348
|
+
|
|
349
|
+
def _is_inside(child: Path, parent: Path) -> bool:
|
|
350
|
+
try:
|
|
351
|
+
child.relative_to(parent.resolve())
|
|
352
|
+
return True
|
|
353
|
+
except ValueError:
|
|
354
|
+
return False
|
|
355
|
+
|
|
356
|
+
|
|
357
|
+
def _cap(text: str) -> str:
|
|
358
|
+
data = text.encode("utf-8")
|
|
359
|
+
if len(data) <= TOOL_RESULT_CAP_BYTES:
|
|
360
|
+
return text
|
|
361
|
+
cut = data[:TOOL_RESULT_CAP_BYTES].decode("utf-8", errors="replace")
|
|
362
|
+
return cut + "\n\n... [truncated — narrow your query] ..."
|
|
363
|
+
|
|
364
|
+
|
|
365
|
+
def _slice_and_cap(text: str, start_line: int | None, end_line: int | None) -> str:
|
|
366
|
+
if start_line is None and end_line is None:
|
|
367
|
+
return _cap(text)
|
|
368
|
+
lines = text.splitlines(keepends=True)
|
|
369
|
+
s = (start_line - 1) if start_line else 0
|
|
370
|
+
e = end_line if end_line else len(lines)
|
|
371
|
+
s = max(0, s)
|
|
372
|
+
e = min(len(lines), e)
|
|
373
|
+
return _cap("".join(lines[s:e]))
|
|
374
|
+
|
|
375
|
+
|
|
376
|
+
# ---------------------------------------------------------------------------
|
|
377
|
+
# Tool surface — derived from `RepoTools`
|
|
378
|
+
# ---------------------------------------------------------------------------
|
|
379
|
+
#
|
|
380
|
+
# Both the pydantic-ai Agent (`tools=TOOL_FUNCTIONS`) and the MCP stdio
|
|
381
|
+
# server (`mcp_tool_schemas`, `mcp_dispatch`) read from the same set of
|
|
382
|
+
# `@_tool`-marked methods. Adding/renaming a tool means editing one
|
|
383
|
+
# method — the wire surface follows.
|
|
384
|
+
|
|
385
|
+
|
|
386
|
+
def _exported_methods() -> list[tuple[str, Callable]]:
|
|
387
|
+
"""Return `(name, func)` for each `@_tool`-marked method, in source order."""
|
|
388
|
+
out: list[tuple[str, Callable]] = []
|
|
389
|
+
for name, attr in vars(RepoTools).items():
|
|
390
|
+
if callable(attr) and getattr(attr, _TOOL_EXPORT_ATTR, False):
|
|
391
|
+
out.append((name, attr))
|
|
392
|
+
return out
|
|
393
|
+
|
|
394
|
+
|
|
395
|
+
def _make_tool_fn(method_name: str, method: Callable) -> Callable:
|
|
396
|
+
"""Wrap a `RepoTools` method as a pydantic-ai-compatible tool function.
|
|
397
|
+
|
|
398
|
+
The returned callable takes `RunContext[RepoTools]` followed by the
|
|
399
|
+
method's parameters (minus `self`), and forwards to the matching
|
|
400
|
+
method on `ctx.deps`. Name, docstring, signature, and annotations
|
|
401
|
+
are copied so pydantic-ai's introspection produces the same schema
|
|
402
|
+
a hand-written wrapper would.
|
|
403
|
+
"""
|
|
404
|
+
sig = inspect.signature(method)
|
|
405
|
+
method_params = list(sig.parameters.values())[1:] # drop self
|
|
406
|
+
ctx_param = inspect.Parameter(
|
|
407
|
+
"ctx",
|
|
408
|
+
inspect.Parameter.POSITIONAL_OR_KEYWORD,
|
|
409
|
+
annotation=RunContext[RepoTools],
|
|
410
|
+
)
|
|
411
|
+
new_sig = sig.replace(
|
|
412
|
+
parameters=[ctx_param, *method_params],
|
|
413
|
+
return_annotation=str,
|
|
414
|
+
)
|
|
415
|
+
|
|
416
|
+
async def fn(ctx: RunContext[RepoTools], **kwargs: Any) -> str:
|
|
417
|
+
return getattr(ctx.deps, method_name)(**kwargs)
|
|
418
|
+
|
|
419
|
+
fn.__name__ = method_name
|
|
420
|
+
fn.__qualname__ = method_name
|
|
421
|
+
fn.__doc__ = method.__doc__
|
|
422
|
+
fn.__signature__ = new_sig # type: ignore[attr-defined]
|
|
423
|
+
annotations: dict[str, Any] = {
|
|
424
|
+
p.name: p.annotation for p in (ctx_param, *method_params) if p.annotation is not inspect.Parameter.empty
|
|
425
|
+
}
|
|
426
|
+
annotations["return"] = str
|
|
427
|
+
fn.__annotations__ = annotations
|
|
428
|
+
return fn
|
|
429
|
+
|
|
430
|
+
|
|
431
|
+
TOOL_FUNCTIONS: list = [_make_tool_fn(n, m) for n, m in _exported_methods()]
|
|
432
|
+
|
|
433
|
+
|
|
434
|
+
def console_tool_functions() -> list:
|
|
435
|
+
"""Tool surface for the review console: the shared `@_tool` surface
|
|
436
|
+
plus the console-only `hunk(id)` diff accessor.
|
|
437
|
+
|
|
438
|
+
`hunk` is deliberately *not* `@_tool`-marked — it needs a bound diff
|
|
439
|
+
that only exists once augmentation has produced the sidecar, so it
|
|
440
|
+
has no place on the augment-time per-hunk pass or the MCP server.
|
|
441
|
+
The console binds `RepoTools.diff` and wires this extended list as
|
|
442
|
+
its `tools=`.
|
|
443
|
+
"""
|
|
444
|
+
return [*TOOL_FUNCTIONS, _make_tool_fn("hunk", RepoTools.hunk)]
|
|
445
|
+
|
|
446
|
+
|
|
447
|
+
def mcp_tool_schemas() -> list[dict[str, Any]]:
|
|
448
|
+
"""MCP `tools/list` payload, derived from `TOOL_FUNCTIONS`."""
|
|
449
|
+
out: list[dict[str, Any]] = []
|
|
450
|
+
for fn in TOOL_FUNCTIONS:
|
|
451
|
+
tool = Tool(fn)
|
|
452
|
+
out.append(
|
|
453
|
+
{
|
|
454
|
+
"name": tool.name,
|
|
455
|
+
"description": tool.description or "",
|
|
456
|
+
"inputSchema": tool.function_schema.json_schema,
|
|
457
|
+
}
|
|
458
|
+
)
|
|
459
|
+
return out
|
|
460
|
+
|
|
461
|
+
|
|
462
|
+
def mcp_dispatch(repo_tools: RepoTools, name: str, args: dict[str, Any]) -> str:
|
|
463
|
+
"""Run a tool by name against `repo_tools` for the MCP `tools/call` path.
|
|
464
|
+
|
|
465
|
+
Only methods marked with `@_tool` on `RepoTools` are reachable —
|
|
466
|
+
private helpers and dunder attrs are rejected.
|
|
467
|
+
"""
|
|
468
|
+
method = getattr(repo_tools, name, None)
|
|
469
|
+
if not callable(method) or not getattr(method, _TOOL_EXPORT_ATTR, False):
|
|
470
|
+
return f"error: unknown tool {name!r}"
|
|
471
|
+
try:
|
|
472
|
+
# Exported tool methods return str; getattr erases that to object.
|
|
473
|
+
return cast("str", method(**args))
|
|
474
|
+
except TypeError as e:
|
|
475
|
+
return f"error: bad args for {name}: {e}"
|