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,299 @@
|
|
|
1
|
+
"""End-to-end GitHub PR review flow: resolve → fetch → serve → post.
|
|
2
|
+
|
|
3
|
+
Drives `scr pr`: preflights ``gh``, resolves the PR number (picker or
|
|
4
|
+
explicit), materialises a run directory, optionally runs the augment
|
|
5
|
+
pipeline, serves the viewer until the reviewer is done. Posting is
|
|
6
|
+
confirmed in the **viewer's modal**, not on the terminal — the
|
|
7
|
+
reviewer reviews comments inline, clicks Done, ticks/unticks the
|
|
8
|
+
final list, and confirms. The server fires the post callback on
|
|
9
|
+
their behalf and reports the result back via ``ServeResult.posted``.
|
|
10
|
+
|
|
11
|
+
The legacy terminal y/N flow lives behind ``--yes`` only as a way to
|
|
12
|
+
skip the modal entirely: the server stays out of posting mode and
|
|
13
|
+
the CLI posts after the viewer exits.
|
|
14
|
+
|
|
15
|
+
The flow uses plain ``sys.stderr`` / ``sys.stdout`` for I/O so it's
|
|
16
|
+
testable without a Typer dependency. ``cli/pr.py`` is the CLI wrapper
|
|
17
|
+
that builds a :class:`PrFlowOptions` from command-line args and calls
|
|
18
|
+
:func:`run_pr_flow`.
|
|
19
|
+
"""
|
|
20
|
+
|
|
21
|
+
from __future__ import annotations
|
|
22
|
+
|
|
23
|
+
import json
|
|
24
|
+
import sys
|
|
25
|
+
from collections.abc import Callable
|
|
26
|
+
from dataclasses import dataclass
|
|
27
|
+
from pathlib import Path
|
|
28
|
+
from typing import Any
|
|
29
|
+
|
|
30
|
+
from ..augment.agents import Client
|
|
31
|
+
from ..fetch import GhFetchError, materialize_github_pr_run, preflight_gh
|
|
32
|
+
from .comments import CommentStore, format_markdown
|
|
33
|
+
from .github import (
|
|
34
|
+
GhError,
|
|
35
|
+
PostResult,
|
|
36
|
+
comments_to_github,
|
|
37
|
+
list_review_requested_prs,
|
|
38
|
+
pick_pr_interactive,
|
|
39
|
+
)
|
|
40
|
+
from .github_graphql import post_review_via_graphql
|
|
41
|
+
from .runner import (
|
|
42
|
+
_build_console_task,
|
|
43
|
+
_build_fold_summary_task,
|
|
44
|
+
serve_review,
|
|
45
|
+
)
|
|
46
|
+
from .server import PostCallable
|
|
47
|
+
|
|
48
|
+
|
|
49
|
+
@dataclass(frozen=True)
|
|
50
|
+
class PrFlowOptions:
|
|
51
|
+
"""All inputs the PR flow needs.
|
|
52
|
+
|
|
53
|
+
``model`` and ``client`` are caller-resolved (typically via the CLI's
|
|
54
|
+
config + backend selection); ``extra_review_prompt`` is the
|
|
55
|
+
already-resolved prompt text (None means none). ``yes`` bypasses the
|
|
56
|
+
in-browser confirmation modal — the viewer's Done button stays a
|
|
57
|
+
plain exit and the CLI posts everything after it returns.
|
|
58
|
+
"""
|
|
59
|
+
|
|
60
|
+
repo: str
|
|
61
|
+
number: int | None
|
|
62
|
+
runs_root: Path
|
|
63
|
+
augment: bool
|
|
64
|
+
model: str
|
|
65
|
+
concurrency: int
|
|
66
|
+
no_cache: bool
|
|
67
|
+
cache_dir: Path | None
|
|
68
|
+
open_browser: bool
|
|
69
|
+
port: int
|
|
70
|
+
timeout: int
|
|
71
|
+
extra_review_prompt: str | None
|
|
72
|
+
client: Client | None
|
|
73
|
+
yes: bool
|
|
74
|
+
|
|
75
|
+
|
|
76
|
+
def run_pr_flow(opts: PrFlowOptions) -> int:
|
|
77
|
+
"""Drive the PR review end-to-end. Returns the exit code.
|
|
78
|
+
|
|
79
|
+
Exit codes:
|
|
80
|
+
0 — review completed cleanly (no unresolved local comments).
|
|
81
|
+
1 — graceful user-abort (no PR picked, posting cancelled, etc.).
|
|
82
|
+
2 — error condition: missing ``gh``, fetch failed, post failed,
|
|
83
|
+
or review completed with unresolved local comments.
|
|
84
|
+
"""
|
|
85
|
+
try:
|
|
86
|
+
preflight_gh()
|
|
87
|
+
except GhFetchError as e:
|
|
88
|
+
_err(f"scr pr: {e}")
|
|
89
|
+
return 2
|
|
90
|
+
|
|
91
|
+
number = opts.number
|
|
92
|
+
if number is None:
|
|
93
|
+
code, picked = _resolve_pr_number(opts.repo)
|
|
94
|
+
if picked is None:
|
|
95
|
+
return code or 1
|
|
96
|
+
number = picked
|
|
97
|
+
|
|
98
|
+
pr_url = f"https://github.com/{opts.repo}/pull/{number}"
|
|
99
|
+
try:
|
|
100
|
+
run_dir = materialize_github_pr_run(pr_url, opts.runs_root)
|
|
101
|
+
except GhFetchError as e:
|
|
102
|
+
_err(f"scr pr: {e}")
|
|
103
|
+
return 2
|
|
104
|
+
|
|
105
|
+
meta = json.loads((run_dir / "meta.json").read_text(encoding="utf-8"))
|
|
106
|
+
head_sha = meta.get("headRefOid", "")
|
|
107
|
+
if not head_sha:
|
|
108
|
+
_err("scr pr: meta.json is missing headRefOid; can't anchor review")
|
|
109
|
+
return 2
|
|
110
|
+
|
|
111
|
+
augment_task, fold_summary_task, console_task = _build_tasks(opts, run_dir)
|
|
112
|
+
if not opts.augment:
|
|
113
|
+
# Mirror cli/review.py's behaviour: copy raw → augmented so render
|
|
114
|
+
# has something to parse when augment is skipped.
|
|
115
|
+
(run_dir / "augmented.diff").write_text(
|
|
116
|
+
(run_dir / "raw.diff").read_text(encoding="utf-8"),
|
|
117
|
+
encoding="utf-8",
|
|
118
|
+
)
|
|
119
|
+
|
|
120
|
+
# `--yes` skips the modal entirely — server stays out of posting
|
|
121
|
+
# mode (Done = plain /exit) and the CLI does the post itself after
|
|
122
|
+
# serve_review returns. Default mode wires the callback + meta so
|
|
123
|
+
# the viewer's Done opens the confirm modal.
|
|
124
|
+
post_callback: PostCallable | None = None
|
|
125
|
+
post_meta: dict[str, Any] | None = None
|
|
126
|
+
if not opts.yes:
|
|
127
|
+
post_callback = _build_post_callback(opts.repo, number, run_dir)
|
|
128
|
+
post_meta = {
|
|
129
|
+
"repo": opts.repo,
|
|
130
|
+
"number": number,
|
|
131
|
+
"head_sha": head_sha,
|
|
132
|
+
}
|
|
133
|
+
|
|
134
|
+
result = serve_review(
|
|
135
|
+
run_dir,
|
|
136
|
+
augment=augment_task,
|
|
137
|
+
fold_summary=fold_summary_task,
|
|
138
|
+
console=console_task,
|
|
139
|
+
post=post_callback,
|
|
140
|
+
post_meta=post_meta,
|
|
141
|
+
port=opts.port,
|
|
142
|
+
timeout=opts.timeout,
|
|
143
|
+
open_browser=opts.open_browser,
|
|
144
|
+
)
|
|
145
|
+
|
|
146
|
+
posted: PostResult | None = result.posted
|
|
147
|
+
|
|
148
|
+
# CLI-side fallback for --yes: the server didn't post (we didn't
|
|
149
|
+
# wire it for that), so post everything ourselves now.
|
|
150
|
+
if posted is None and opts.yes:
|
|
151
|
+
mapped = comments_to_github(result.comments)
|
|
152
|
+
if not mapped:
|
|
153
|
+
_err(f"scr pr: no new local comments to post; comments are in {run_dir / 'comments.json'}.")
|
|
154
|
+
return 0 if result.clean else 2
|
|
155
|
+
try:
|
|
156
|
+
posted = post_review_via_graphql(opts.repo, number, mapped)
|
|
157
|
+
except GhError as e:
|
|
158
|
+
_err(f"scr pr: posting failed: {e}")
|
|
159
|
+
_err(f"comments are still in {run_dir / 'comments.json'} — re-run with --no-augment to retry.")
|
|
160
|
+
return 2
|
|
161
|
+
|
|
162
|
+
if posted is not None:
|
|
163
|
+
# Comments are on GitHub; the URL is the artefact. Keep stdout
|
|
164
|
+
# minimal so a slash command (or any downstream LLM) doesn't
|
|
165
|
+
# ingest the comment bodies and treat them as instructions.
|
|
166
|
+
sys.stdout.write(f"# Posted to {posted.review_url}\n")
|
|
167
|
+
word = "comment" if posted.posted == 1 else "comments"
|
|
168
|
+
sys.stdout.write(f"_{posted.posted} {word} posted._\n")
|
|
169
|
+
sys.stdout.flush()
|
|
170
|
+
_err(f"scr pr: posted {posted.posted} comment(s) — {posted.review_url}")
|
|
171
|
+
return 0 if result.clean else 2
|
|
172
|
+
|
|
173
|
+
# No post happened — modal cancelled, tab closed, --no-augment with
|
|
174
|
+
# no comments, etc. Dump the markdown so the user / a calling script
|
|
175
|
+
# has a record of what was being reviewed.
|
|
176
|
+
local_comments = [c for c in result.comments if c.source == "local"]
|
|
177
|
+
sys.stdout.write(format_markdown(local_comments, run_slug=run_dir.name))
|
|
178
|
+
sys.stdout.flush()
|
|
179
|
+
return 0 if result.clean else 2
|
|
180
|
+
|
|
181
|
+
|
|
182
|
+
def _resolve_pr_number(repo: str) -> tuple[int | None, int | None]:
|
|
183
|
+
"""Pick a PR number when the caller didn't supply one.
|
|
184
|
+
|
|
185
|
+
Returns ``(exit_code, number)``: on success ``(None, picked)``; on
|
|
186
|
+
a graceful early exit ``(code, None)`` so the caller can return
|
|
187
|
+
the code.
|
|
188
|
+
"""
|
|
189
|
+
try:
|
|
190
|
+
prs = list_review_requested_prs(repo)
|
|
191
|
+
except GhError as e:
|
|
192
|
+
_err(f"scr pr: {e}")
|
|
193
|
+
return 1, None
|
|
194
|
+
if not prs:
|
|
195
|
+
_err(
|
|
196
|
+
f"scr pr: no open PRs in {repo} are requesting your review. "
|
|
197
|
+
"Pass an explicit PR number, or open the list on github.com."
|
|
198
|
+
)
|
|
199
|
+
return 1, None
|
|
200
|
+
if len(prs) == 1:
|
|
201
|
+
_err(f"scr pr: reviewing {repo}#{prs[0].number} — {prs[0].title}")
|
|
202
|
+
return None, prs[0].number
|
|
203
|
+
picked = pick_pr_interactive(repo, prs)
|
|
204
|
+
if picked is None:
|
|
205
|
+
_err("scr pr: no PR selected")
|
|
206
|
+
return 1, None
|
|
207
|
+
return None, picked
|
|
208
|
+
|
|
209
|
+
|
|
210
|
+
def _build_tasks(
|
|
211
|
+
opts: PrFlowOptions,
|
|
212
|
+
run_dir: Path,
|
|
213
|
+
) -> tuple[Callable | None, Callable | None, Callable | None]:
|
|
214
|
+
"""Build the augment + fold-summary + console closures, or ``(None,
|
|
215
|
+
None, None)`` when augmentation is skipped (the console grounds its
|
|
216
|
+
answers in the augment sidecar, so it's unavailable without it).
|
|
217
|
+
"""
|
|
218
|
+
if not opts.augment:
|
|
219
|
+
return None, None, None
|
|
220
|
+
|
|
221
|
+
# Imports inside: anthropic SDK + augment pipeline are lazy-loaded so
|
|
222
|
+
# `--no-augment` runs (and `scr --help`) don't pay the cost.
|
|
223
|
+
from ..augment.pipeline import augment_run_dir
|
|
224
|
+
from ..augment.prompts import PROMPT_VERSION
|
|
225
|
+
from ..cache.store import CacheStore
|
|
226
|
+
|
|
227
|
+
cache = (
|
|
228
|
+
None
|
|
229
|
+
if opts.no_cache
|
|
230
|
+
else CacheStore(
|
|
231
|
+
root=opts.cache_dir,
|
|
232
|
+
prompt_version=PROMPT_VERSION,
|
|
233
|
+
)
|
|
234
|
+
)
|
|
235
|
+
|
|
236
|
+
async def augment_task(rd: Path, publish: Callable[[str, dict[str, Any]], None]) -> None:
|
|
237
|
+
await augment_run_dir(
|
|
238
|
+
rd,
|
|
239
|
+
model=opts.model,
|
|
240
|
+
concurrency=opts.concurrency,
|
|
241
|
+
cache=cache,
|
|
242
|
+
client=opts.client,
|
|
243
|
+
extra_review_prompt=opts.extra_review_prompt,
|
|
244
|
+
# Page carries the progress display now; suppress the
|
|
245
|
+
# terminal meter to avoid duplicate noise and to keep
|
|
246
|
+
# the listening-URL / warning lines unobstructed.
|
|
247
|
+
show_progress=False,
|
|
248
|
+
on_event=publish,
|
|
249
|
+
)
|
|
250
|
+
|
|
251
|
+
fold_summary_task = _build_fold_summary_task(
|
|
252
|
+
client=opts.client,
|
|
253
|
+
model=opts.model,
|
|
254
|
+
cache=cache,
|
|
255
|
+
run_dir=run_dir,
|
|
256
|
+
)
|
|
257
|
+
# Console reuses the augment backend (SDK streams; CLI answers
|
|
258
|
+
# one-shot). When opts.client is None augment defaults to the
|
|
259
|
+
# Anthropic SDK, so mirror that for the console's client.
|
|
260
|
+
console_client = opts.client or Client(model=f"anthropic:{opts.model}")
|
|
261
|
+
console_task = _build_console_task(client=console_client, run_dir=run_dir)
|
|
262
|
+
return augment_task, fold_summary_task, console_task
|
|
263
|
+
|
|
264
|
+
|
|
265
|
+
def _build_post_callback(
|
|
266
|
+
repo: str,
|
|
267
|
+
number: int,
|
|
268
|
+
run_dir: Path,
|
|
269
|
+
) -> PostCallable:
|
|
270
|
+
"""Closure the server fires on /post-review.
|
|
271
|
+
|
|
272
|
+
Reads the latest comments off ``comments.json`` (the store mutates
|
|
273
|
+
throughout the session), keeps every local comment whose id is in
|
|
274
|
+
``selected_ids`` plus every non-local comment (needed for reply-
|
|
275
|
+
parent ``node_id`` lookups in :func:`comments_to_github`), maps,
|
|
276
|
+
and posts via GraphQL. Errors propagate; the server returns 500
|
|
277
|
+
to the modal so the reviewer sees the failure and can retry.
|
|
278
|
+
"""
|
|
279
|
+
|
|
280
|
+
def post(selected_ids: list[str]) -> PostResult:
|
|
281
|
+
store = CommentStore(run_dir / "comments.json")
|
|
282
|
+
all_comments = store.all()
|
|
283
|
+
selected = set(selected_ids)
|
|
284
|
+
filtered = [c for c in all_comments if c.source != "local" or c.id in selected]
|
|
285
|
+
mapped = comments_to_github(filtered)
|
|
286
|
+
return post_review_via_graphql(repo, number, mapped)
|
|
287
|
+
|
|
288
|
+
return post
|
|
289
|
+
|
|
290
|
+
|
|
291
|
+
def _err(msg: str) -> None:
|
|
292
|
+
"""Write ``msg`` to stderr, appending a newline if missing, then flush."""
|
|
293
|
+
if not msg.endswith("\n"):
|
|
294
|
+
msg = msg + "\n"
|
|
295
|
+
sys.stderr.write(msg)
|
|
296
|
+
sys.stderr.flush()
|
|
297
|
+
|
|
298
|
+
|
|
299
|
+
__all__ = ["PrFlowOptions", "run_pr_flow"]
|
|
@@ -0,0 +1,407 @@
|
|
|
1
|
+
"""Orchestrate a ``scr review`` session end-to-end.
|
|
2
|
+
|
|
3
|
+
Given a git ref/range and optional spec markdown, synthesise a run
|
|
4
|
+
directory compatible with the existing augment + render pipeline,
|
|
5
|
+
optionally run the LLM augmentation, render the HTML, spawn the
|
|
6
|
+
ephemeral server, and drain reviewer comments to stdout when the
|
|
7
|
+
viewer signals done.
|
|
8
|
+
"""
|
|
9
|
+
|
|
10
|
+
from __future__ import annotations
|
|
11
|
+
|
|
12
|
+
import asyncio
|
|
13
|
+
import json
|
|
14
|
+
import logging
|
|
15
|
+
import sys
|
|
16
|
+
import threading
|
|
17
|
+
import webbrowser
|
|
18
|
+
from collections.abc import Callable, Coroutine
|
|
19
|
+
from dataclasses import dataclass, field
|
|
20
|
+
from pathlib import Path
|
|
21
|
+
from typing import Any
|
|
22
|
+
|
|
23
|
+
from ..augment.agents import Client
|
|
24
|
+
from ..augment.prompts import PROMPT_VERSION
|
|
25
|
+
from ..cache.store import CacheStore
|
|
26
|
+
from ..fetch import materialize_local_diff_run
|
|
27
|
+
from ..format.parse import parse_augmented_diff
|
|
28
|
+
from ..paths import default_runs_root as _default_runs_root
|
|
29
|
+
from ..viewer.build_json import build_pending_viewer_json, build_viewer_json
|
|
30
|
+
from .comments import CommentStore, format_markdown
|
|
31
|
+
from .github import PostResult
|
|
32
|
+
from .server import PostCallable, ReviewServer
|
|
33
|
+
|
|
34
|
+
log = logging.getLogger(__name__)
|
|
35
|
+
|
|
36
|
+
|
|
37
|
+
#: Signature of the augment callable accepted by ``serve_review``. The
|
|
38
|
+
#: second argument is the publisher bound to the live review server's
|
|
39
|
+
#: SSE channel; pass it through to ``augment_run_dir(on_event=...)`` so
|
|
40
|
+
#: the pipeline can stream overview / per-hunk events to the page.
|
|
41
|
+
AugmentCallable = Callable[
|
|
42
|
+
[Path, Callable[[str, dict], None]],
|
|
43
|
+
Coroutine[Any, Any, None],
|
|
44
|
+
]
|
|
45
|
+
|
|
46
|
+
|
|
47
|
+
#: Signature of the on-demand fold-summary callable accepted by
|
|
48
|
+
#: ``serve_review``. The closure resolves the sidecar, calls the LLM
|
|
49
|
+
#: against the addressed file, persists the new ``FoldDescription``,
|
|
50
|
+
#: and returns the broadcast payload (the dict the server fans out as
|
|
51
|
+
#: an SSE event and sends back to the requesting tab). Wired up only
|
|
52
|
+
#: when an LLM backend is available (``opts.augment is True``);
|
|
53
|
+
#: ``--no-augment`` reviews leave this at ``None`` and the route
|
|
54
|
+
#: returns 409 unconditionally.
|
|
55
|
+
FoldSummaryCallable = Callable[
|
|
56
|
+
# (file_idx, context, right_range, left_range, qualified_name, kind)
|
|
57
|
+
[
|
|
58
|
+
int,
|
|
59
|
+
str,
|
|
60
|
+
"tuple[int, int] | None",
|
|
61
|
+
"tuple[int, int] | None",
|
|
62
|
+
"str | None",
|
|
63
|
+
"str | None",
|
|
64
|
+
],
|
|
65
|
+
Coroutine[Any, Any, dict],
|
|
66
|
+
]
|
|
67
|
+
|
|
68
|
+
|
|
69
|
+
#: Signature of the streaming console turn driver accepted by
|
|
70
|
+
#: ``serve_review``. Called as ``(question, history, on_delta, on_tool,
|
|
71
|
+
#: cancel)`` and awaited to ``(answer_text, new_history)``:
|
|
72
|
+
#: ``on_delta(str)`` / ``on_tool(str)`` stream text and tool activity,
|
|
73
|
+
#: ``cancel`` is the ``threading.Event`` the driver polls between
|
|
74
|
+
#: chunks. Wired only when augmentation runs on an SDK backend;
|
|
75
|
+
#: ``--no-augment`` and CLI-subprocess reviews leave this ``None`` and
|
|
76
|
+
#: /console/ask 409s (CLI support is Slice 5).
|
|
77
|
+
ConsoleCallable = Callable[
|
|
78
|
+
[
|
|
79
|
+
str,
|
|
80
|
+
"list | None",
|
|
81
|
+
"Callable[[str], None]",
|
|
82
|
+
"Callable[[str], None]",
|
|
83
|
+
"threading.Event",
|
|
84
|
+
],
|
|
85
|
+
Coroutine[Any, Any, "tuple[str, list]"],
|
|
86
|
+
]
|
|
87
|
+
|
|
88
|
+
|
|
89
|
+
@dataclass
|
|
90
|
+
class ReviewOptions:
|
|
91
|
+
spec: str # git ref or range, user-supplied
|
|
92
|
+
spec_markdown: Path | None = None
|
|
93
|
+
runs_root: Path = field(default_factory=_default_runs_root)
|
|
94
|
+
repo_root: Path | None = None
|
|
95
|
+
no_staged: bool = False
|
|
96
|
+
no_unstaged: bool = False
|
|
97
|
+
augment: bool = True
|
|
98
|
+
model: str = "claude-opus-4-7"
|
|
99
|
+
concurrency: int = 8
|
|
100
|
+
no_cache: bool = False
|
|
101
|
+
cache_dir: Path | None = None
|
|
102
|
+
open_browser: bool = True
|
|
103
|
+
port: int = 0
|
|
104
|
+
timeout: int = 3600
|
|
105
|
+
# Optional preselected backend handle. None → augment_run_dir
|
|
106
|
+
# defaults to a `Client` for the Anthropic SDK path.
|
|
107
|
+
client: Client | None = None
|
|
108
|
+
# Optional file-loaded text for the extra-review pass. When set,
|
|
109
|
+
# each hunk gets a second LLM call with this as the system prompt;
|
|
110
|
+
# the returned line-anchored notes merge into hunk.line_notes.
|
|
111
|
+
extra_review_prompt: str | None = None
|
|
112
|
+
show_progress: bool = True
|
|
113
|
+
|
|
114
|
+
|
|
115
|
+
def run_review(opts: ReviewOptions) -> int:
|
|
116
|
+
"""Run a full review session. Returns the process exit code."""
|
|
117
|
+
run_dir = materialize_local_diff_run(
|
|
118
|
+
opts.spec,
|
|
119
|
+
opts.runs_root,
|
|
120
|
+
repo_root=opts.repo_root,
|
|
121
|
+
no_staged=opts.no_staged,
|
|
122
|
+
no_unstaged=opts.no_unstaged,
|
|
123
|
+
spec_md_path=opts.spec_markdown,
|
|
124
|
+
)
|
|
125
|
+
|
|
126
|
+
augment_task: AugmentCallable | None = None
|
|
127
|
+
fold_summary_task: FoldSummaryCallable | None = None
|
|
128
|
+
console_task: ConsoleCallable | None = None
|
|
129
|
+
if opts.augment:
|
|
130
|
+
from ..augment.pipeline import augment_run_dir # lazy: anthropic SDK
|
|
131
|
+
|
|
132
|
+
cache = None if opts.no_cache else CacheStore(root=opts.cache_dir, prompt_version=PROMPT_VERSION)
|
|
133
|
+
|
|
134
|
+
async def _run_augment(rd: Path, publish: Callable[..., None]) -> None:
|
|
135
|
+
await augment_run_dir(
|
|
136
|
+
rd,
|
|
137
|
+
model=opts.model,
|
|
138
|
+
concurrency=opts.concurrency,
|
|
139
|
+
cache=cache,
|
|
140
|
+
client=opts.client,
|
|
141
|
+
extra_review_prompt=opts.extra_review_prompt,
|
|
142
|
+
# The page now carries the progress display, so silence
|
|
143
|
+
# the terminal meter — its redraw line would just fight
|
|
144
|
+
# the listening-URL / per-hunk warning log lines.
|
|
145
|
+
show_progress=False,
|
|
146
|
+
on_event=publish,
|
|
147
|
+
)
|
|
148
|
+
|
|
149
|
+
augment_task = _run_augment
|
|
150
|
+
|
|
151
|
+
fold_summary_task = _build_fold_summary_task(
|
|
152
|
+
client=opts.client,
|
|
153
|
+
model=opts.model,
|
|
154
|
+
cache=cache,
|
|
155
|
+
run_dir=run_dir,
|
|
156
|
+
)
|
|
157
|
+
|
|
158
|
+
# The console reuses the augment backend — SDK backends stream
|
|
159
|
+
# token-by-token, CLI subprocess backends answer one-shot per turn
|
|
160
|
+
# (ADR 0002, Slice 5). When opts.client is None the augment path
|
|
161
|
+
# defaults to the Anthropic SDK, so we mirror that to construct
|
|
162
|
+
# the console's client.
|
|
163
|
+
console_client = opts.client or Client(model=f"anthropic:{opts.model}")
|
|
164
|
+
console_task = _build_console_task(
|
|
165
|
+
client=console_client,
|
|
166
|
+
run_dir=run_dir,
|
|
167
|
+
)
|
|
168
|
+
else:
|
|
169
|
+
# When augment is skipped, copy raw.diff to augmented.diff so render
|
|
170
|
+
# has something to parse. It'll have no annotations.
|
|
171
|
+
(run_dir / "augmented.diff").write_text(
|
|
172
|
+
(run_dir / "raw.diff").read_text(encoding="utf-8"),
|
|
173
|
+
encoding="utf-8",
|
|
174
|
+
)
|
|
175
|
+
|
|
176
|
+
result = serve_review(
|
|
177
|
+
run_dir,
|
|
178
|
+
augment=augment_task,
|
|
179
|
+
fold_summary=fold_summary_task,
|
|
180
|
+
console=console_task,
|
|
181
|
+
port=opts.port,
|
|
182
|
+
timeout=opts.timeout,
|
|
183
|
+
open_browser=opts.open_browser,
|
|
184
|
+
)
|
|
185
|
+
# The markdown dump is the reviewer's "new notes" feed — ingested
|
|
186
|
+
# upstream comments are already on GitHub and would crowd it out.
|
|
187
|
+
local_comments = [c for c in result.comments if c.source == "local"]
|
|
188
|
+
markdown = format_markdown(local_comments, run_slug=run_dir.name)
|
|
189
|
+
sys.stdout.write(markdown)
|
|
190
|
+
sys.stdout.flush()
|
|
191
|
+
return 0 if result.clean else 2
|
|
192
|
+
|
|
193
|
+
|
|
194
|
+
@dataclass
|
|
195
|
+
class ServeResult:
|
|
196
|
+
"""Outcome of `serve_review`. Returned in addition to the side
|
|
197
|
+
effect of `comments.json` on disk so callers don't have to re-load
|
|
198
|
+
it (and so each caller can decide what to do with the comments —
|
|
199
|
+
`scr review` prints markdown, `scr pr` posts to GitHub).
|
|
200
|
+
|
|
201
|
+
``posted`` is set when the viewer's confirmation modal fired a
|
|
202
|
+
successful /post-review (only possible when the caller supplied a
|
|
203
|
+
``post`` callback to ``serve_review``). None means "no post
|
|
204
|
+
happened" — cancelled, no postable comments, or the caller wasn't
|
|
205
|
+
in posting mode at all.
|
|
206
|
+
"""
|
|
207
|
+
|
|
208
|
+
comments: list # list[Comment] — kept loose to avoid an import cycle
|
|
209
|
+
clean: bool # True iff the viewer signalled Done within the timeout
|
|
210
|
+
posted: PostResult | None = None
|
|
211
|
+
|
|
212
|
+
|
|
213
|
+
def serve_review(
|
|
214
|
+
run_dir: Path,
|
|
215
|
+
*,
|
|
216
|
+
augment: AugmentCallable | None = None,
|
|
217
|
+
fold_summary: FoldSummaryCallable | None = None,
|
|
218
|
+
console: ConsoleCallable | None = None,
|
|
219
|
+
post: PostCallable | None = None,
|
|
220
|
+
post_meta: dict | None = None,
|
|
221
|
+
port: int = 0,
|
|
222
|
+
timeout: int = 3600,
|
|
223
|
+
open_browser: bool = True,
|
|
224
|
+
on_ready: Callable[[str], None] | None = None,
|
|
225
|
+
) -> ServeResult:
|
|
226
|
+
"""Render the viewer for a populated run dir, host the back-channel
|
|
227
|
+
server, block on the user clicking Done, and return the comments
|
|
228
|
+
they left.
|
|
229
|
+
|
|
230
|
+
Both `cli.review` (local diff) and `cli.pr` (GitHub PR) call this
|
|
231
|
+
with a run dir whose `meta.json`, `raw.diff`, and worktrees are
|
|
232
|
+
already in place. If ``augment`` is supplied, the server starts
|
|
233
|
+
immediately with a pending viewer (file/hunk structure visible,
|
|
234
|
+
no annotations yet); the augmentation coroutine then runs while
|
|
235
|
+
the page is live, publishing per-hunk SSE events as completions
|
|
236
|
+
land. After the pass finishes, `update_viewer_json` swaps the
|
|
237
|
+
`/data.json` payload to the augmented state and a `done` event
|
|
238
|
+
flushes any still-pending placeholders. If ``augment`` is None,
|
|
239
|
+
the run dir is expected to already contain ``augmented.diff``
|
|
240
|
+
(the caller skipped augmentation upstream).
|
|
241
|
+
"""
|
|
242
|
+
if augment is not None:
|
|
243
|
+
# Pre-augment: a file/hunk skeleton so the page is responsive
|
|
244
|
+
# while the LLM pass runs. The viewer JS sees `pending: true`
|
|
245
|
+
# and shows "analysing…" placeholders for each hunk.
|
|
246
|
+
viewer_json = build_pending_viewer_json(run_dir)
|
|
247
|
+
else:
|
|
248
|
+
viewer_json = _load_viewer_json(run_dir)
|
|
249
|
+
srv = ReviewServer(
|
|
250
|
+
run_dir=run_dir,
|
|
251
|
+
viewer_json=viewer_json,
|
|
252
|
+
port=port,
|
|
253
|
+
post_callback=post,
|
|
254
|
+
post_meta=post_meta,
|
|
255
|
+
)
|
|
256
|
+
srv.start()
|
|
257
|
+
try:
|
|
258
|
+
log.info("review server at %s", srv.url())
|
|
259
|
+
sys.stderr.write(f"scr review: listening on {srv.url()}\n")
|
|
260
|
+
sys.stderr.flush()
|
|
261
|
+
if on_ready is not None:
|
|
262
|
+
on_ready(srv.url())
|
|
263
|
+
if open_browser:
|
|
264
|
+
try:
|
|
265
|
+
webbrowser.open(srv.url())
|
|
266
|
+
except Exception as e: # noqa: BLE001
|
|
267
|
+
log.warning("could not open browser: %s", e)
|
|
268
|
+
|
|
269
|
+
if augment is not None:
|
|
270
|
+
# Run augmentation while the server is live, streaming each
|
|
271
|
+
# overview / per-hunk completion to the page via SSE. After
|
|
272
|
+
# the pass returns, swap `/data.json` to the augmented state
|
|
273
|
+
# so any tab opened post-augment (or a manual reload) sees
|
|
274
|
+
# the final view, then publish `done` so connected viewers
|
|
275
|
+
# can finalise any still-pending placeholders.
|
|
276
|
+
augment_error: BaseException | None = None
|
|
277
|
+
try:
|
|
278
|
+
asyncio.run(augment(run_dir, srv.publish))
|
|
279
|
+
except BaseException as e:
|
|
280
|
+
augment_error = e
|
|
281
|
+
log.exception("augmentation failed; page stays on pending view")
|
|
282
|
+
sys.stderr.write(f"scr review: augment failed: {e}\n")
|
|
283
|
+
if (run_dir / "augmented.diff").exists():
|
|
284
|
+
final_json = _load_viewer_json(run_dir)
|
|
285
|
+
srv.update_viewer_json(final_json)
|
|
286
|
+
# Augmentation has emitted a sidecar, so the /fold-summary
|
|
287
|
+
# route can now resolve hunk_ids. Bind the summariser here
|
|
288
|
+
# rather than at start() to prevent races against a tab
|
|
289
|
+
# that opens before augmentation lands.
|
|
290
|
+
if fold_summary is not None:
|
|
291
|
+
srv.set_fold_summariser(fold_summary)
|
|
292
|
+
# Same gate as the fold summariser: the console needs the
|
|
293
|
+
# sidecar on disk to ground its answers, so bind it here
|
|
294
|
+
# rather than at start(). Unset for --no-augment / CLI
|
|
295
|
+
# backends, where /console/ask stays 409.
|
|
296
|
+
if console is not None:
|
|
297
|
+
srv.set_console_asker(console)
|
|
298
|
+
srv.publish("done", {"reason": "augment-complete"})
|
|
299
|
+
if augment_error is not None and not isinstance(augment_error, Exception):
|
|
300
|
+
# KeyboardInterrupt / SystemExit shouldn't be swallowed —
|
|
301
|
+
# re-raise after the page has its latest state pushed.
|
|
302
|
+
raise augment_error
|
|
303
|
+
|
|
304
|
+
clean = srv.wait_until_done(timeout=timeout)
|
|
305
|
+
finally:
|
|
306
|
+
srv.stop()
|
|
307
|
+
|
|
308
|
+
store = CommentStore(run_dir / "comments.json")
|
|
309
|
+
return ServeResult(
|
|
310
|
+
comments=store.all(),
|
|
311
|
+
clean=clean,
|
|
312
|
+
posted=srv.ctx.posted_result,
|
|
313
|
+
)
|
|
314
|
+
|
|
315
|
+
|
|
316
|
+
def _build_fold_summary_task(
|
|
317
|
+
*,
|
|
318
|
+
client: Client | None,
|
|
319
|
+
model: str,
|
|
320
|
+
cache: CacheStore | None,
|
|
321
|
+
run_dir: Path,
|
|
322
|
+
) -> FoldSummaryCallable:
|
|
323
|
+
"""Construct the FoldSummaryCallable that ``serve_review`` installs
|
|
324
|
+
onto the review server once augmentation completes. The closure
|
|
325
|
+
captures the LLM backend + cache + run_dir so the server module
|
|
326
|
+
stays independent of the augment-side machinery.
|
|
327
|
+
"""
|
|
328
|
+
# Lazy import: keeps the SDK / pydantic-ai dep out of the
|
|
329
|
+
# `--no-augment` path.
|
|
330
|
+
from ..augment.fold_summary import apply_fold_summary_to_run
|
|
331
|
+
|
|
332
|
+
async def task(
|
|
333
|
+
file_idx: int,
|
|
334
|
+
context: str,
|
|
335
|
+
right_range: tuple[int, int] | None,
|
|
336
|
+
left_range: tuple[int, int] | None,
|
|
337
|
+
qualified_name: str | None = None,
|
|
338
|
+
kind: str | None = None,
|
|
339
|
+
) -> dict:
|
|
340
|
+
# client is None only when augment is False; in that path
|
|
341
|
+
# serve_review never wires this task up, so a None here would
|
|
342
|
+
# be a wiring bug — fail loudly.
|
|
343
|
+
assert client is not None, "fold-summary task called without an LLM backend"
|
|
344
|
+
return await apply_fold_summary_to_run(
|
|
345
|
+
client,
|
|
346
|
+
run_dir=run_dir,
|
|
347
|
+
file_idx=file_idx,
|
|
348
|
+
context=context, # type: ignore[arg-type]
|
|
349
|
+
right_range=right_range,
|
|
350
|
+
left_range=left_range,
|
|
351
|
+
qualified_name=qualified_name,
|
|
352
|
+
kind=kind,
|
|
353
|
+
model=model,
|
|
354
|
+
cache=cache,
|
|
355
|
+
)
|
|
356
|
+
|
|
357
|
+
return task
|
|
358
|
+
|
|
359
|
+
|
|
360
|
+
def _build_console_task(
|
|
361
|
+
*,
|
|
362
|
+
client: Client,
|
|
363
|
+
run_dir: Path,
|
|
364
|
+
) -> ConsoleCallable:
|
|
365
|
+
"""Construct the console turn driver ``serve_review`` installs once
|
|
366
|
+
augmentation completes. Captures the LLM backend + run_dir so the
|
|
367
|
+
server module stays independent of the augment-side machinery.
|
|
368
|
+
"""
|
|
369
|
+
# Lazy import: keeps pydantic-ai off the `--no-augment` path.
|
|
370
|
+
from ..augment.console import stream_console_turn
|
|
371
|
+
|
|
372
|
+
async def task(
|
|
373
|
+
question: str,
|
|
374
|
+
history: list | None,
|
|
375
|
+
on_delta: Callable[[str], None],
|
|
376
|
+
on_tool: Callable[[str], None],
|
|
377
|
+
cancel: threading.Event,
|
|
378
|
+
selection: Any = None,
|
|
379
|
+
) -> tuple[str, list]:
|
|
380
|
+
return await stream_console_turn(
|
|
381
|
+
client,
|
|
382
|
+
run_dir=run_dir,
|
|
383
|
+
question=question,
|
|
384
|
+
history=history,
|
|
385
|
+
on_delta=on_delta,
|
|
386
|
+
on_tool=on_tool,
|
|
387
|
+
cancel=cancel,
|
|
388
|
+
selection=selection,
|
|
389
|
+
)
|
|
390
|
+
|
|
391
|
+
return task
|
|
392
|
+
|
|
393
|
+
|
|
394
|
+
def _load_viewer_json(run_dir: Path) -> dict:
|
|
395
|
+
meta = json.loads((run_dir / "meta.json").read_text(encoding="utf-8"))
|
|
396
|
+
augmented = run_dir / "augmented.diff"
|
|
397
|
+
if not augmented.exists():
|
|
398
|
+
return {"version": "1", "pr": {}, "files": []}
|
|
399
|
+
diff = parse_augmented_diff(augmented.read_text(encoding="utf-8"))
|
|
400
|
+
head_dir = run_dir / "head"
|
|
401
|
+
base_dir = run_dir / "base"
|
|
402
|
+
return build_viewer_json(
|
|
403
|
+
diff,
|
|
404
|
+
meta,
|
|
405
|
+
head_dir=head_dir if head_dir.exists() else None,
|
|
406
|
+
base_dir=base_dir if base_dir.exists() else None,
|
|
407
|
+
)
|