Skip to content

Commit 9db2751

Browse files
authored
Phase whygraph scan into three ordered phases with a results panel (#33)
`whygraph scan` already sequenced git/GitHub before the LLM analyzer, but every crawler's progress bar was registered at construction and all five were built before any thread started, so the run *looked* fully parallel. Make the structure real and visible: - Three ordered phases — Phase 1 git + GitHub (concurrent), Phase 2 pr-origins, Phase 3 analyze (the slow LLM long pole, last and alone) — with CodeGraph a best-effort background task spanning the whole scan. - Construct each phase's crawlers when its phase starts, so bars appear per-phase instead of all at once (no Crawler render-path change). - Icon'd, numbered `console.rule` headers (numbered against the count of phases that actually run), per-phase timing + a dim completion line, and a Progress column set with M-of-N + elapsed for the LLM phase. - New closing results panel bookending the pre-scan preview: per-phase status glyph, summary, timing, and total elapsed. Fed by a single additive `Crawler.summary` attribute set by each crawler's `work()`. Sequencing analyze last is behavior-preserving: it only ever describes main-walk commits (todo is bounded by `repository.commits`), so recovered on_default_branch=0 origin commits stay on lazy backfill exactly as before. Tests: new phase-ordering / header / skip cases, a defensive results-panel unit test, per-crawler summary strings, and an origin-commits-stay-lazy behavior pin. 527 passed; ruff check + format clean.
1 parent e7e9bee commit 9db2751

11 files changed

Lines changed: 653 additions & 59 deletions

src/whygraph/cli/commands/scan.py

Lines changed: 259 additions & 50 deletions
Original file line numberDiff line numberDiff line change
@@ -3,12 +3,20 @@
33
from __future__ import annotations
44

55
import os
6+
import time
67
from pathlib import Path
78
from typing import TYPE_CHECKING, TypeVar
89

910
import click
1011
from rich.panel import Panel
11-
from rich.progress import Progress
12+
from rich.progress import (
13+
BarColumn,
14+
MofNCompleteColumn,
15+
Progress,
16+
SpinnerColumn,
17+
TextColumn,
18+
TimeElapsedColumn,
19+
)
1220
from rich.table import Table
1321
from rich.text import Text
1422

@@ -31,6 +39,13 @@
3139

3240
_T = TypeVar("_T")
3341

42+
# Per-phase icons for the live headers and the closing results panel.
43+
# Kept in one place so the whole set is trivially swappable (plan §10.5).
44+
_ICON_STRUCTURAL = "🔎"
45+
_ICON_PR_ORIGINS = "🔗"
46+
_ICON_LLM = "🧠"
47+
_ICON_CODEGRAPH = "🕸"
48+
3449

3550
@click.command(name="scan")
3651
@click.option(
@@ -39,8 +54,9 @@
3954
is_flag=True,
4055
default=False,
4156
help=(
42-
"Skip Phase 2 (per-commit LLM descriptions). The git and GitHub "
43-
"crawlers still run. The MCP tools `whygraph_evidence_for` and "
57+
"Skip the final LLM-descriptions phase (per-commit descriptions). "
58+
"The git and GitHub crawlers still run. The MCP tools "
59+
"`whygraph_evidence_for` and "
4460
"`whygraph_rationale_brief` lazily backfill descriptions on demand, "
4561
"and a later `whygraph scan` (without this flag) backfills the rest."
4662
),
@@ -144,73 +160,134 @@ def scan_cmd(
144160
pr_origins_enabled=enrich_pr_origins and github_client is not None,
145161
)
146162

163+
# Which optional phases have work — decided up front so the phase
164+
# headers can be numbered against the count of phases that actually run.
165+
run_pr_origins = enrich_pr_origins and github_client is not None
166+
run_analyze = descriptor is not None
167+
phase_total = 1 + int(run_pr_origins) + int(run_analyze) # Phase 1 always runs
168+
147169
scan_log_path = db_path.parent / "scan.log"
148-
with scan_log_redirect(scan_log_path), Progress() as progress:
149-
# CodeGraph refresh — runs concurrently as its own crawler. It
150-
# writes .codegraph/ and has no data dependency on the WhyGraph DB,
151-
# so it overlaps the entire crawl (started with phase 1, joined
152-
# last). Best-effort: failures land on .warning, not .error.
170+
phase_timings: dict[str, float] = {}
171+
ran: list[Crawler] = []
172+
scan_t0 = time.monotonic()
173+
# Share the stderr `console` with Progress so the phase headers render
174+
# above the live bars on one stream, and add an M-of-N + elapsed column
175+
# so the slow LLM phase reports "12/45 · 0:00:31".
176+
with (
177+
scan_log_redirect(scan_log_path),
178+
Progress(
179+
SpinnerColumn(),
180+
TextColumn("[progress.description]{task.description}"),
181+
BarColumn(),
182+
MofNCompleteColumn(),
183+
TimeElapsedColumn(),
184+
console=console,
185+
) as progress,
186+
):
187+
# CodeGraph refresh — a background crawler. It writes .codegraph/
188+
# and has no data dependency on the WhyGraph DB, so it overlaps the
189+
# entire crawl (started before Phase 1, joined last). Best-effort:
190+
# failures land on .warning, not .error.
153191
codegraph_crawler = (
154192
CodeGraphCrawler(
155193
progress, project_root=repository.root, image=codegraph_image
156194
)
157195
if refresh_codegraph
158196
else None
159197
)
198+
if codegraph_crawler is not None:
199+
codegraph_crawler.start()
160200

161-
# Phase 1 — source crawlers, run concurrently.
201+
n = 0
202+
203+
# ── Phase 1 · Structural crawl — git + GitHub, concurrent. ──
204+
n += 1
205+
console.rule(
206+
f"{_ICON_STRUCTURAL} Phase {n}/{phase_total} · Structural crawl",
207+
style="cyan",
208+
)
209+
t0 = time.monotonic()
162210
phase1: list[Crawler] = [GitCrawler(progress, repository=repository)]
163211
if github_client is not None:
164212
phase1.append(GitHubCrawler(progress, client=github_client))
165-
166-
# Phase 2 — started only once phase 1 has joined (it reads the
167-
# commits + PRs phase 1 persisted). The analyzer and the PR-origin
168-
# enricher run concurrently: analyze only touches main-walk commits,
169-
# the enricher only inserts new on_default_branch=0 rows, so they
170-
# never contend over the same commit row.
171-
phase2: list[Crawler] = []
172-
if descriptor is not None:
173-
phase2.append(
174-
AnalyzeCrawler(
175-
progress,
176-
repository=repository,
177-
descriptor=descriptor,
178-
max_workers=config.scan_max_workers,
179-
large_commit_file_count=config.analyze.large_commit_file_count,
180-
)
181-
)
182-
# The enricher needs PR rows (the GitHub crawler ran) and the
183-
# network for its fetch — so it is gated on a resolved client, which
184-
# is itself None under --no-remote.
185-
if enrich_pr_origins and github_client is not None:
186-
phase2.append(
187-
PROriginEnricher(
188-
progress,
189-
repository=repository,
190-
min_commits=config.analyze.pr_origin_min_commits,
191-
large_commit_file_count=config.analyze.large_commit_file_count,
192-
)
193-
)
194-
195-
if codegraph_crawler is not None:
196-
codegraph_crawler.start()
213+
ran += phase1
197214
for c in phase1:
198215
c.start()
199216
for c in phase1:
200217
c.join()
201-
for c in phase2:
202-
c.start()
203-
for c in phase2:
204-
c.join()
218+
phase_timings["Structural crawl"] = time.monotonic() - t0
219+
_print_phase_done(
220+
"Structural crawl",
221+
phase_timings["Structural crawl"],
222+
ok=all(c.error is None for c in phase1),
223+
)
224+
225+
# ── Phase 2 · PR-origin recovery — needs Phase 1's git + PR rows.
226+
# Gated on a resolved client, which is None under --no-remote. ──
227+
if run_pr_origins:
228+
n += 1
229+
console.rule(
230+
f"{_ICON_PR_ORIGINS} Phase {n}/{phase_total} · PR-origin recovery",
231+
style="cyan",
232+
)
233+
t0 = time.monotonic()
234+
enricher = PROriginEnricher(
235+
progress,
236+
repository=repository,
237+
min_commits=config.analyze.pr_origin_min_commits,
238+
large_commit_file_count=config.analyze.large_commit_file_count,
239+
)
240+
ran.append(enricher)
241+
enricher.start()
242+
enricher.join()
243+
phase_timings["PR-origin recovery"] = time.monotonic() - t0
244+
_print_phase_done(
245+
"PR-origin recovery",
246+
phase_timings["PR-origin recovery"],
247+
ok=enricher.error is None,
248+
)
249+
250+
# ── Phase 3 · LLM descriptions — the slow, token-heavy long pole,
251+
# run strictly last and alone. Only ever describes main-walk
252+
# commits, so the recovered on_default_branch=0 rows stay lazy. ──
253+
if run_analyze:
254+
n += 1
255+
console.rule(
256+
f"{_ICON_LLM} Phase {n}/{phase_total} · LLM descriptions",
257+
style="cyan",
258+
)
259+
t0 = time.monotonic()
260+
analyzer = AnalyzeCrawler(
261+
progress,
262+
repository=repository,
263+
descriptor=descriptor,
264+
max_workers=config.scan_max_workers,
265+
large_commit_file_count=config.analyze.large_commit_file_count,
266+
)
267+
ran.append(analyzer)
268+
analyzer.start()
269+
analyzer.join()
270+
phase_timings["LLM descriptions"] = time.monotonic() - t0
271+
_print_phase_done(
272+
"LLM descriptions",
273+
phase_timings["LLM descriptions"],
274+
ok=analyzer.error is None,
275+
)
276+
205277
if codegraph_crawler is not None:
206278
codegraph_crawler.join()
207279

208-
console.print(f"Scan log: {scan_log_path}")
209-
210-
if codegraph_crawler is not None and codegraph_crawler.warning is not None:
211-
console.print(Text(codegraph_crawler.warning, style="yellow"))
280+
total_elapsed = time.monotonic() - scan_t0
281+
_render_results_panel(
282+
ran=ran,
283+
codegraph_crawler=codegraph_crawler,
284+
db_path=db_path,
285+
scan_log_path=scan_log_path,
286+
phase_timings=phase_timings,
287+
total_elapsed=total_elapsed,
288+
)
212289

213-
crawlers = phase1 + phase2
290+
crawlers = list(ran)
214291
if codegraph_crawler is not None:
215292
crawlers.append(codegraph_crawler)
216293
failed = [c for c in crawlers if c.error is not None]
@@ -220,6 +297,138 @@ def scan_cmd(
220297
raise click.exceptions.Exit(1)
221298

222299

300+
def _fmt_elapsed(seconds: float) -> str:
301+
"""Format an elapsed duration as ``"4.1s"`` or ``"2m 08s"``."""
302+
if seconds < 60:
303+
return f"{seconds:.1f}s"
304+
minutes, secs = divmod(int(round(seconds)), 60)
305+
return f"{minutes}m {secs:02d}s"
306+
307+
308+
def _print_phase_done(title: str, seconds: float, *, ok: bool) -> None:
309+
"""Print the dim per-phase completion line under that phase's bars.
310+
311+
``ok`` reflects whether every crawler in the phase finished without an
312+
error; a failed phase gets a red ``✗`` (the real error is also surfaced
313+
by the closing failure sweep and results panel).
314+
"""
315+
glyph = "✓" if ok else "✗"
316+
console.print(
317+
f" {glyph} {title} · {_fmt_elapsed(seconds)}",
318+
style="dim" if ok else "red",
319+
)
320+
321+
322+
def _status_glyph(*, ok: bool, warn: bool = False) -> Text:
323+
"""Return the results-panel status cell: ``✓`` / ``⚠`` / ``✗``."""
324+
if not ok:
325+
return Text("✗", style="red")
326+
if warn:
327+
return Text("⚠", style="yellow")
328+
return Text("✓", style="green")
329+
330+
331+
def _optional_phase_cells(
332+
crawler: "Crawler | None", timing: str
333+
) -> tuple[object, object, str]:
334+
"""Return the (status, summary, timing) cells for an optional phase.
335+
336+
A crawler that never ran (phase skipped via ``--no-remote`` /
337+
``--no-llm-descriptions``) renders a dim ``— skipped`` status with no
338+
summary or timing.
339+
"""
340+
if crawler is None:
341+
return Text("— skipped", style="dim"), "", ""
342+
return _status_glyph(ok=crawler.error is None), crawler.summary or "—", timing
343+
344+
345+
def _render_results_panel(
346+
*,
347+
ran: "list[Crawler]",
348+
codegraph_crawler: "Crawler | None",
349+
db_path: Path,
350+
scan_log_path: Path,
351+
phase_timings: "dict[str, float]",
352+
total_elapsed: float,
353+
) -> None:
354+
"""Print the closing results panel — a bookend to the pre-scan panel.
355+
356+
One row per phase (git + GitHub merge into the structural row), each
357+
carrying a status glyph, the crawler's own one-line summary, and the
358+
phase's elapsed time; then the database / scan-log paths. CodeGraph is
359+
a background task, so it gets a row but no per-phase timing. Pure
360+
formatting over data already in hand — no DB or network access — so it
361+
can never turn a successful crawl into a crash.
362+
"""
363+
by_name = {c.name: c for c in ran}
364+
git = by_name.get("git")
365+
github = by_name.get("github")
366+
enricher = by_name.get("pr-origins")
367+
analyzer = by_name.get("analyze")
368+
369+
def _timing(title: str) -> str:
370+
seconds = phase_timings.get(title)
371+
return _fmt_elapsed(seconds) if seconds is not None else ""
372+
373+
grid = Table.grid(padding=(0, 2))
374+
grid.add_column(no_wrap=True) # icon
375+
grid.add_column(style="bold cyan", no_wrap=True) # label
376+
grid.add_column(justify="center", no_wrap=True) # status
377+
grid.add_column(overflow="fold") # summary
378+
grid.add_column(justify="right", no_wrap=True) # timing
379+
380+
# Structural row — git + GitHub combined into one phase row.
381+
structural = [c for c in (git, github) if c is not None]
382+
structural_summary = " · ".join(c.summary for c in structural if c.summary) or "—"
383+
grid.add_row(
384+
_ICON_STRUCTURAL,
385+
"Structural crawl",
386+
_status_glyph(ok=all(c.error is None for c in structural)),
387+
structural_summary,
388+
_timing("Structural crawl"),
389+
)
390+
grid.add_row(
391+
_ICON_PR_ORIGINS,
392+
"PR-origin recovery",
393+
*_optional_phase_cells(enricher, _timing("PR-origin recovery")),
394+
)
395+
grid.add_row(
396+
_ICON_LLM,
397+
"LLM descriptions",
398+
*_optional_phase_cells(analyzer, _timing("LLM descriptions")),
399+
)
400+
401+
# CodeGraph — background task; no per-phase timing.
402+
if codegraph_crawler is None:
403+
grid.add_row(
404+
_ICON_CODEGRAPH, "CodeGraph", Text("— skipped", style="dim"), "", ""
405+
)
406+
else:
407+
warning = getattr(codegraph_crawler, "warning", None)
408+
grid.add_row(
409+
_ICON_CODEGRAPH,
410+
"CodeGraph",
411+
_status_glyph(ok=codegraph_crawler.error is None, warn=warning is not None),
412+
codegraph_crawler.summary or warning or "—",
413+
"",
414+
)
415+
416+
grid.add_row("", "", "", "", "")
417+
grid.add_row("", "Database", "", str(db_path), "")
418+
grid.add_row("", "Scan log", "", str(scan_log_path), "")
419+
420+
console.print(
421+
Panel(
422+
grid,
423+
title=f"whygraph scan · done in {_fmt_elapsed(total_elapsed)}",
424+
title_align="left",
425+
border_style="cyan",
426+
padding=(1, 2),
427+
)
428+
)
429+
console.print()
430+
431+
223432
def _apply_github_token(config: "Config") -> None:
224433
"""Export the configured GitHub token so every ``gh`` subprocess sees it.
225434

src/whygraph/scan/__init__.py

Lines changed: 12 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -1,14 +1,20 @@
1-
"""Scan subsystem — concurrent crawlers that populate the WhyGraph DB.
1+
"""Scan subsystem — phased crawlers that populate the WhyGraph DB.
22
33
Exposes :class:`Crawler` (the threaded base class) and the concrete
44
crawlers — :class:`GitCrawler` for local git history,
55
:class:`GitHubCrawler` for GitHub pull requests and issues,
66
:class:`CodeGraphCrawler` which refreshes the CodeGraph index,
7-
:class:`AnalyzeCrawler` which describes each commit's diff with an LLM
8-
(run after :class:`GitCrawler`), and :class:`PROriginEnricher` which
9-
recovers a squash-merged PR's original commits. The CLI runs the source
10-
crawlers (and CodeGraph) concurrently, then the analyzer and enricher,
11-
against the shared SQLite database.
7+
:class:`AnalyzeCrawler` which describes each commit's diff with an LLM,
8+
and :class:`PROriginEnricher` which recovers a squash-merged PR's original
9+
commits.
10+
11+
The CLI drives them in three ordered phases against the shared SQLite
12+
database: **Phase 1** runs :class:`GitCrawler` and :class:`GitHubCrawler`
13+
concurrently; **Phase 2** runs :class:`PROriginEnricher` (which needs the
14+
git + PR rows Phase 1 persisted); **Phase 3** runs :class:`AnalyzeCrawler`
15+
last and alone (the slow, token-heavy LLM pass). :class:`CodeGraphCrawler`
16+
is a best-effort background task started before Phase 1 and joined after
17+
Phase 3 — it has no data dependency on the DB, so it spans the whole scan.
1218
"""
1319

1420
from whygraph.scan.analyze_crawler import AnalyzeCrawler

0 commit comments

Comments
 (0)