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.
Files changed (100) hide show
  1. semantic_code_review/__init__.py +5 -0
  2. semantic_code_review/augment/__init__.py +3 -0
  3. semantic_code_review/augment/agents.py +95 -0
  4. semantic_code_review/augment/console.py +440 -0
  5. semantic_code_review/augment/extra_review.py +237 -0
  6. semantic_code_review/augment/fold_summary.py +447 -0
  7. semantic_code_review/augment/hunks.py +264 -0
  8. semantic_code_review/augment/mcp_server.py +156 -0
  9. semantic_code_review/augment/overview.py +233 -0
  10. semantic_code_review/augment/pass_.py +171 -0
  11. semantic_code_review/augment/pipeline.py +571 -0
  12. semantic_code_review/augment/progress.py +223 -0
  13. semantic_code_review/augment/prompts.py +93 -0
  14. semantic_code_review/augment/schemas.py +408 -0
  15. semantic_code_review/augment/tools.py +475 -0
  16. semantic_code_review/augment/trace_adapter.py +369 -0
  17. semantic_code_review/backends/__init__.py +95 -0
  18. semantic_code_review/backends/_cli_driver.py +571 -0
  19. semantic_code_review/backends/anthropic_sdk.py +56 -0
  20. semantic_code_review/backends/base.py +104 -0
  21. semantic_code_review/backends/claude_cli.py +421 -0
  22. semantic_code_review/backends/google_sdk.py +73 -0
  23. semantic_code_review/backends/openai_compat.py +42 -0
  24. semantic_code_review/cache/__init__.py +3 -0
  25. semantic_code_review/cache/store.py +87 -0
  26. semantic_code_review/cli/__init__.py +70 -0
  27. semantic_code_review/cli/_shared.py +157 -0
  28. semantic_code_review/cli/augment.py +74 -0
  29. semantic_code_review/cli/config_cmd.py +266 -0
  30. semantic_code_review/cli/fetch.py +33 -0
  31. semantic_code_review/cli/lint.py +27 -0
  32. semantic_code_review/cli/pr.py +98 -0
  33. semantic_code_review/cli/review.py +104 -0
  34. semantic_code_review/cli/runs_cmd.py +17 -0
  35. semantic_code_review/cli/show.py +19 -0
  36. semantic_code_review/cli/strip.py +20 -0
  37. semantic_code_review/config.py +563 -0
  38. semantic_code_review/config_template.py +154 -0
  39. semantic_code_review/fetch/__init__.py +61 -0
  40. semantic_code_review/fetch/anchor.py +244 -0
  41. semantic_code_review/fetch/github.py +229 -0
  42. semantic_code_review/fetch/github_comments.py +404 -0
  43. semantic_code_review/fetch/local.py +443 -0
  44. semantic_code_review/fetch/run_source.py +70 -0
  45. semantic_code_review/format/__init__.py +3 -0
  46. semantic_code_review/format/emit.py +163 -0
  47. semantic_code_review/format/lint.py +74 -0
  48. semantic_code_review/format/parse.py +574 -0
  49. semantic_code_review/format/sidecar.py +19 -0
  50. semantic_code_review/format/strip.py +20 -0
  51. semantic_code_review/git_ops.py +252 -0
  52. semantic_code_review/paths.py +61 -0
  53. semantic_code_review/review/__init__.py +3 -0
  54. semantic_code_review/review/comments.py +176 -0
  55. semantic_code_review/review/github.py +284 -0
  56. semantic_code_review/review/github_graphql.py +433 -0
  57. semantic_code_review/review/pr_flow.py +299 -0
  58. semantic_code_review/review/runner.py +407 -0
  59. semantic_code_review/review/server.py +1043 -0
  60. semantic_code_review/structural/__init__.py +41 -0
  61. semantic_code_review/structural/diff.py +102 -0
  62. semantic_code_review/structural/parse.py +330 -0
  63. semantic_code_review/structural/symbols.py +43 -0
  64. semantic_code_review/viewer/__init__.py +3 -0
  65. semantic_code_review/viewer/assets/annotations.ts +547 -0
  66. semantic_code_review/viewer/assets/boot.ts +273 -0
  67. semantic_code_review/viewer/assets/comment_store.ts +121 -0
  68. semantic_code_review/viewer/assets/comments.ts +624 -0
  69. semantic_code_review/viewer/assets/console.ts +426 -0
  70. semantic_code_review/viewer/assets/console_render.ts +213 -0
  71. semantic_code_review/viewer/assets/console_selection.ts +102 -0
  72. semantic_code_review/viewer/assets/data_store.ts +156 -0
  73. semantic_code_review/viewer/assets/file_rows.ts +49 -0
  74. semantic_code_review/viewer/assets/folds.ts +560 -0
  75. semantic_code_review/viewer/assets/index.html +62 -0
  76. semantic_code_review/viewer/assets/post_modal.ts +383 -0
  77. semantic_code_review/viewer/assets/progress.ts +138 -0
  78. semantic_code_review/viewer/assets/render.ts +937 -0
  79. semantic_code_review/viewer/assets/sidebar.ts +429 -0
  80. semantic_code_review/viewer/assets/sse.ts +78 -0
  81. semantic_code_review/viewer/assets/text_highlight.ts +215 -0
  82. semantic_code_review/viewer/assets/types.d.ts +388 -0
  83. semantic_code_review/viewer/assets/vendor/LICENSE +29 -0
  84. semantic_code_review/viewer/assets/vendor/VENDOR.md +55 -0
  85. semantic_code_review/viewer/assets/vendor/github-dark.min.css +10 -0
  86. semantic_code_review/viewer/assets/vendor/github.min.css +10 -0
  87. semantic_code_review/viewer/assets/vendor/highlight.min.js +1244 -0
  88. semantic_code_review/viewer/assets/vendor/mermaid.LICENSE +21 -0
  89. semantic_code_review/viewer/assets/vendor/mermaid.min.js +3587 -0
  90. semantic_code_review/viewer/assets/vendor/refresh.sh +65 -0
  91. semantic_code_review/viewer/assets/viewer.css +1202 -0
  92. semantic_code_review/viewer/assets/viewer.js +10588 -0
  93. semantic_code_review/viewer/build_json.py +546 -0
  94. semantic_code_review/viewer/hunk_layout.py +471 -0
  95. semantic_code_review-0.24.2.dist-info/METADATA +348 -0
  96. semantic_code_review-0.24.2.dist-info/RECORD +100 -0
  97. semantic_code_review-0.24.2.dist-info/WHEEL +5 -0
  98. semantic_code_review-0.24.2.dist-info/entry_points.txt +2 -0
  99. semantic_code_review-0.24.2.dist-info/licenses/LICENSE +21 -0
  100. 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
+ )