glreview 0.1.0__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.
glreview/cli.py ADDED
@@ -0,0 +1,1227 @@
1
+ """Command-line interface for glreview."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import sys
6
+ from datetime import datetime, timezone
7
+ from pathlib import Path
8
+ from importlib.resources import files
9
+
10
+ import click
11
+ from jinja2 import Environment, FileSystemLoader, BaseLoader
12
+
13
+ from . import __version__
14
+ from .analyze import analyze_module
15
+ from .config import Config, load_config
16
+ from .discovery import discover_modules, sync_registry
17
+ from .git import (
18
+ count_lines,
19
+ get_current_commit,
20
+ get_gitlab_host,
21
+ get_gitlab_project,
22
+ has_changes_since,
23
+ )
24
+ from .gitlab import (
25
+ add_issue_comment,
26
+ close_issue,
27
+ create_issue,
28
+ get_compare_url,
29
+ get_issue,
30
+ get_project_members,
31
+ gitlab_available,
32
+ )
33
+ from .registry import ModuleStatus, Registry, load_registry, save_registry
34
+ from .claude import (
35
+ build_review_prompt,
36
+ chunk_source_code,
37
+ claude_available,
38
+ get_file_diff,
39
+ run_claude_review,
40
+ )
41
+
42
+
43
+ @click.group()
44
+ @click.version_option(version=__version__)
45
+ @click.pass_context
46
+ def main(ctx: click.Context) -> None:
47
+ """glreview - GitLab code review tracking for scientific software."""
48
+ ctx.ensure_object(dict)
49
+ ctx.obj["config"] = load_config()
50
+
51
+
52
+ def _load_issue_template(config) -> str:
53
+ """Load the issue template content.
54
+
55
+ Uses custom template from config.issue_template if specified,
56
+ otherwise falls back to the default bundled template.
57
+
58
+ Args:
59
+ config: Config object with optional issue_template path.
60
+
61
+ Returns:
62
+ Template content as string.
63
+ """
64
+ if config.issue_template:
65
+ custom_path = config.root / config.issue_template
66
+ if custom_path.exists():
67
+ return custom_path.read_text()
68
+ else:
69
+ click.secho(f"Warning: Custom template not found: {custom_path}", fg="yellow")
70
+ click.echo("Falling back to default template.")
71
+
72
+ # Load default template from package
73
+ template_file = files("glreview.templates").joinpath("issue.md")
74
+ return template_file.read_text()
75
+
76
+
77
+ def _render_issue_description(
78
+ config,
79
+ path: str,
80
+ module,
81
+ current_commit: str,
82
+ diff_url: str | None = None,
83
+ ) -> str:
84
+ """Render the issue description from template.
85
+
86
+ Args:
87
+ config: Config object.
88
+ path: Module path.
89
+ module: ModuleStatus object.
90
+ current_commit: Current git commit SHA.
91
+ diff_url: URL to diff (for re-reviews).
92
+
93
+ Returns:
94
+ Rendered issue description.
95
+ """
96
+ template_content = _load_issue_template(config)
97
+
98
+ env = Environment(loader=BaseLoader())
99
+ template = env.from_string(template_content)
100
+
101
+ # Analyze module structure
102
+ summary = analyze_module(path, cwd=config.root)
103
+ module_contents = summary.format_summary()
104
+
105
+ context = {
106
+ "path": path,
107
+ "lines": module.lines,
108
+ "priority": module.priority,
109
+ "reviewers_required": module.reviewers_required,
110
+ "current_commit": current_commit,
111
+ "previous_commit": module.last_reviewed_commit if module.status == "reviewed" else None,
112
+ "previous_date": module.last_reviewed_date or "unknown",
113
+ "previous_reviewers": ", ".join(module.reviewers) or "none",
114
+ "diff_url": diff_url or "",
115
+ "module_contents": module_contents,
116
+ "classes": summary.public_classes,
117
+ "functions": summary.public_functions,
118
+ }
119
+
120
+ return template.render(**context)
121
+
122
+
123
+ def _print_pyproject_suggestion(sources: str) -> None:
124
+ """Print suggested pyproject.toml configuration."""
125
+ click.echo()
126
+ click.echo("Add to pyproject.toml:")
127
+ click.echo()
128
+ click.echo(" [tool.glreview]")
129
+ click.echo(f' sources = ["{sources}"]')
130
+ click.echo(' exclude = ["**/_version.py", "**/test_*.py"]')
131
+ click.echo()
132
+ click.echo(" # Optional: custom issue template (Jinja2 format)")
133
+ click.echo(' # issue_template = ".glreview/issue_template.md"')
134
+ click.echo()
135
+ click.echo(" # Optional: priority rules (first match wins)")
136
+ click.echo(" # [[tool.glreview.priority]]")
137
+ click.echo(' # pattern = "src/**/core.py"')
138
+ click.echo(' # level = "critical"')
139
+ click.echo(" # reviewers_required = 2")
140
+
141
+
142
+ @main.command()
143
+ @click.option(
144
+ "--sources",
145
+ default="src/**/*.py",
146
+ help="Glob pattern for source files",
147
+ )
148
+ @click.pass_context
149
+ def init(ctx: click.Context, sources: str) -> None:
150
+ """Initialize glreview in a project.
151
+
152
+ This command is idempotent: if a registry already exists, it behaves
153
+ like 'glreview sync' - adding new modules and removing deleted ones
154
+ while preserving review status for existing modules.
155
+ """
156
+ config = ctx.obj["config"]
157
+
158
+ # Idempotent: if registry exists, sync instead of failing
159
+ if config.registry_path.exists():
160
+ click.echo(f"Registry exists at {config.registry}, syncing...")
161
+ ctx.invoke(sync)
162
+ _print_pyproject_suggestion(sources)
163
+ return
164
+
165
+ # Discover modules
166
+ patterns = [sources]
167
+ modules = discover_modules(config.root, patterns, config.exclude)
168
+
169
+ if not modules:
170
+ click.echo(f"No modules found matching pattern: {sources}")
171
+ click.echo("Check your source pattern or create some files first.")
172
+ return
173
+
174
+ # Create registry
175
+ registry = Registry()
176
+
177
+ for path in modules:
178
+ level, reviewers_required = config.get_priority(path)
179
+ lines = count_lines(path, cwd=config.root)
180
+
181
+ registry.set(
182
+ ModuleStatus(
183
+ path=path,
184
+ status="needs_review",
185
+ priority=level,
186
+ lines=lines,
187
+ reviewers_required=reviewers_required,
188
+ )
189
+ )
190
+
191
+ # Save
192
+ commit = get_current_commit(cwd=config.root)
193
+ save_registry(registry, config.registry_path, commit=commit)
194
+
195
+ click.echo(f"Initialized glreview with {len(modules)} modules")
196
+ click.echo(f"Registry: {config.registry}")
197
+ _print_pyproject_suggestion(sources)
198
+ click.echo()
199
+ click.echo("Next steps:")
200
+ click.echo(" 1. Commit review_registry.json")
201
+ click.echo(" 2. Run 'glreview status' to see review coverage")
202
+ click.echo(" 3. Run 'glreview ci-config' for GitLab CI setup")
203
+
204
+
205
+ @main.command()
206
+ @click.argument("path", required=False)
207
+ @click.option("--json", "as_json", is_flag=True, help="Output as JSON")
208
+ @click.pass_context
209
+ def status(ctx: click.Context, path: str | None, as_json: bool) -> None:
210
+ """Check review status."""
211
+ config = ctx.obj["config"]
212
+ registry = load_registry(config.registry_path)
213
+
214
+ if not registry.modules:
215
+ click.echo("No modules in registry. Run 'glreview init' first.")
216
+ return
217
+
218
+ if path:
219
+ # Single module status
220
+ module = registry.get(path)
221
+ if not module:
222
+ click.echo(f"Error: {path} not in registry.")
223
+ sys.exit(1)
224
+
225
+ if as_json:
226
+ import json
227
+ click.echo(json.dumps(module.to_dict(), indent=2))
228
+ return
229
+
230
+ click.echo(f"Module: {path}")
231
+ click.echo(f" Status: {module.status}")
232
+ click.echo(f" Priority: {module.priority}")
233
+ click.echo(f" Lines: {module.lines}")
234
+
235
+ if module.status == "reviewed":
236
+ click.echo(f" Last reviewed: {module.last_reviewed_date}")
237
+ click.echo(f" Commit: {module.last_reviewed_commit}")
238
+ click.echo(f" Reviewers: {', '.join(module.reviewers)}")
239
+
240
+ if has_changes_since(path, module.last_reviewed_commit, cwd=config.root):
241
+ click.secho(" ⚠ Changes detected since last review!", fg="yellow")
242
+ else:
243
+ click.secho(" ✓ No changes since last review", fg="green")
244
+
245
+ elif module.status == "in_progress":
246
+ click.echo(f" Issue: {module.review_issue or 'unknown'}")
247
+ click.echo(f" Assigned: {module.assigned_reviewer or 'unassigned'}")
248
+
249
+ else:
250
+ # Summary status
251
+ summary = registry.summary
252
+
253
+ if as_json:
254
+ import json
255
+ click.echo(json.dumps(summary, indent=2))
256
+ return
257
+
258
+ total = summary["total"]
259
+ reviewed = summary["reviewed"]
260
+ in_progress = summary["in_progress"]
261
+ needs_review_count = summary["needs_review"]
262
+ pct = (reviewed / total * 100) if total > 0 else 0
263
+ line_pct = (summary["reviewed_lines"] / summary["total_lines"] * 100) if summary["total_lines"] > 0 else 0
264
+
265
+ # Header with progress bar
266
+ bar_width = 20
267
+ filled = int(bar_width * pct / 100)
268
+ bar = "█" * filled + "░" * (bar_width - filled)
269
+
270
+ click.echo()
271
+ click.echo(f" Review Progress: [{bar}] {pct:.0f}%")
272
+ click.echo()
273
+ click.echo(f" Modules: {reviewed}/{total} reviewed")
274
+ click.echo(f" Lines: {summary['reviewed_lines']:,}/{summary['total_lines']:,} ({line_pct:.0f}%)")
275
+ click.echo()
276
+
277
+ # In Progress section
278
+ in_progress_mods = list(registry.filter(status="in_progress"))
279
+ if in_progress_mods:
280
+ click.secho(f"◐ In Progress ({len(in_progress_mods)})", fg="cyan", bold=True)
281
+ for mod in sorted(in_progress_mods, key=lambda m: m.path):
282
+ assignee = mod.assigned_reviewer or "unassigned"
283
+ issue = mod.review_issue or ""
284
+ # Shorten issue URL for display
285
+ if "/" in issue:
286
+ issue = "#" + issue.rstrip("/").split("/")[-1]
287
+ click.echo(f" {mod.path}")
288
+ click.echo(f" Assignee: {assignee} Issue: {issue}")
289
+ if mod.review_started_commit:
290
+ click.echo(f" Started at: {mod.review_started_commit}")
291
+ click.echo()
292
+
293
+ # Needs Review section
294
+ needs_review = list(registry.filter(status="needs_review"))
295
+ needs_review.extend(registry.filter(status="needs_update"))
296
+
297
+ if needs_review:
298
+ click.secho(f"○ Needs Review ({len(needs_review)})", fg="yellow", bold=True)
299
+ for mod in sorted(needs_review, key=lambda m: (
300
+ {"critical": 0, "high": 1, "medium": 2, "low": 3}.get(m.priority, 99),
301
+ m.path
302
+ )):
303
+ priority_colors = {"critical": "red", "high": "yellow", "medium": None, "low": "bright_black"}
304
+ color = priority_colors.get(mod.priority)
305
+ priority_str = f"[{mod.priority}]"
306
+ if color:
307
+ priority_str = click.style(priority_str, fg=color)
308
+ click.echo(f" {priority_str} {mod.path} ({mod.lines} lines)")
309
+ click.echo()
310
+
311
+ # Reviewed section (show if any have changes)
312
+ reviewed_mods = list(registry.filter(status="reviewed"))
313
+ stale_mods = []
314
+ for mod in reviewed_mods:
315
+ if has_changes_since(mod.path, mod.last_reviewed_commit, cwd=config.root):
316
+ stale_mods.append(mod)
317
+
318
+ if stale_mods:
319
+ click.secho(f"⚠ Reviewed but Changed ({len(stale_mods)})", fg="red", bold=True)
320
+ for mod in sorted(stale_mods, key=lambda m: m.path):
321
+ click.echo(f" {mod.path}")
322
+ click.echo(f" Last reviewed: {mod.last_reviewed_date} ({mod.last_reviewed_commit})")
323
+ click.echo()
324
+ elif reviewed_mods:
325
+ click.secho(f"✓ Reviewed ({len(reviewed_mods)})", fg="green", bold=True)
326
+ click.echo(f" All {len(reviewed_mods)} reviewed modules are up to date.")
327
+ click.echo()
328
+
329
+
330
+ @main.command("list")
331
+ @click.option("--status", "filter_status", help="Filter by status")
332
+ @click.option("--priority", "filter_priority", help="Filter by priority")
333
+ @click.pass_context
334
+ def list_modules(
335
+ ctx: click.Context,
336
+ filter_status: str | None,
337
+ filter_priority: str | None,
338
+ ) -> None:
339
+ """List modules in the registry."""
340
+ config = ctx.obj["config"]
341
+ registry = load_registry(config.registry_path)
342
+
343
+ modules = list(registry.filter(status=filter_status, priority=filter_priority))
344
+
345
+ if not modules:
346
+ click.echo("No modules match the filters.")
347
+ return
348
+
349
+ for mod in sorted(modules, key=lambda m: m.path):
350
+ status_icon = {
351
+ "reviewed": "✓",
352
+ "in_progress": "◐",
353
+ "needs_review": "○",
354
+ "needs_update": "!",
355
+ }.get(mod.status, "?")
356
+
357
+ click.echo(f"{status_icon} [{mod.priority:8}] {mod.path}")
358
+
359
+
360
+ @main.command()
361
+ @click.argument("path")
362
+ @click.option("--assignee", "-a", help="Assign reviewer (@username)")
363
+ @click.option("--issue", "-i", help="Manual issue number (if glab unavailable)")
364
+ @click.pass_context
365
+ def start(ctx: click.Context, path: str, assignee: str | None, issue: str | None) -> None:
366
+ """Start a review for a module."""
367
+ config = ctx.obj["config"]
368
+ registry = load_registry(config.registry_path)
369
+
370
+ module = registry.get(path)
371
+ if not module:
372
+ click.echo(f"Error: {path} not in registry.")
373
+ click.echo()
374
+ click.echo("Available modules:")
375
+ for m in sorted(registry.modules.keys())[:10]:
376
+ click.echo(f" {m}")
377
+ if len(registry.modules) > 10:
378
+ click.echo(f" ... and {len(registry.modules) - 10} more")
379
+ sys.exit(1)
380
+
381
+ # Check if already in progress
382
+ if module.status == "in_progress":
383
+ click.echo(f"Review already in progress for {path}")
384
+ if module.review_issue:
385
+ click.echo(f" Issue: {module.review_issue}")
386
+ sys.exit(1)
387
+
388
+ # Check if reviewed and no changes
389
+ if module.status == "reviewed":
390
+ if not has_changes_since(path, module.last_reviewed_commit, cwd=config.root):
391
+ click.secho(f"✓ {path} is already reviewed at current commit", fg="green")
392
+ click.echo(" No changes since last review.")
393
+ sys.exit(0)
394
+ else:
395
+ click.echo(f"Changes detected since last review ({module.last_reviewed_commit})")
396
+
397
+ current_commit = get_current_commit(cwd=config.root)
398
+ module_name = Path(path).stem
399
+
400
+ # Build issue description from template
401
+ project = get_gitlab_project(cwd=config.root)
402
+ host = get_gitlab_host(cwd=config.root) or "gitlab.com"
403
+
404
+ diff_url = None
405
+ if module.last_reviewed_commit and module.status == "reviewed":
406
+ diff_url = get_compare_url(project, host, module.last_reviewed_commit, current_commit)
407
+
408
+ description = _render_issue_description(
409
+ config=config,
410
+ path=path,
411
+ module=module,
412
+ current_commit=current_commit,
413
+ diff_url=diff_url,
414
+ )
415
+
416
+ labels = config.issue_labels + [f"priority::{module.priority}"]
417
+
418
+ if not gitlab_available(host):
419
+ click.secho("Warning: GitLab authentication not configured.", fg="yellow")
420
+ click.echo()
421
+ click.echo("Set GITLAB_PRIVATE_TOKEN environment variable, or")
422
+ click.echo("create the issue manually with:")
423
+ click.echo(f" Title: [REVIEW] {module_name}")
424
+ click.echo(f" Labels: {', '.join(labels)}")
425
+ click.echo()
426
+
427
+ if issue:
428
+ module.status = "in_progress"
429
+ module.review_issue = issue
430
+ module.assigned_reviewer = assignee
431
+ module.review_started_commit = current_commit
432
+ save_registry(registry, config.registry_path, commit=current_commit)
433
+ click.secho(f"✓ Registry updated with issue {issue}", fg="green")
434
+ else:
435
+ click.echo("Then run:")
436
+ click.echo(f" glreview start {path} --issue <number>")
437
+ sys.exit(1)
438
+ return
439
+
440
+ # Create the issue
441
+ click.echo(f"Creating review issue for {path}...")
442
+
443
+ created = create_issue(
444
+ title=f"[REVIEW] {module_name}",
445
+ description=description,
446
+ project_path=project,
447
+ host=host,
448
+ labels=labels,
449
+ assignee=assignee,
450
+ )
451
+
452
+ if not created:
453
+ click.secho("Failed to create issue.", fg="red")
454
+ sys.exit(1)
455
+
456
+ # Update registry
457
+ module.status = "in_progress"
458
+ module.review_issue = created.url or f"#{created.number}"
459
+ module.assigned_reviewer = assignee
460
+ module.review_started_commit = current_commit
461
+ save_registry(registry, config.registry_path, commit=current_commit)
462
+
463
+ click.echo()
464
+ click.secho(f"✓ Review started for {path}", fg="green")
465
+ click.echo(f" Issue: {created.url or created.number}")
466
+ click.echo(f" Priority: {module.priority}")
467
+ if assignee:
468
+ click.echo(f" Assignee: {assignee}")
469
+ click.echo()
470
+ click.echo("When review is complete, run:")
471
+ click.echo(f" glreview signoff {path}")
472
+
473
+
474
+ @main.command()
475
+ @click.argument("path")
476
+ @click.option("--reviewer", "-r", help="Reviewer (@username)")
477
+ @click.option("--skip-verify", is_flag=True, help="Skip issue verification")
478
+ @click.option("--force", "-f", is_flag=True, help="Force sign-off")
479
+ @click.option("--acknowledge", is_flag=True, help="Acknowledge changes made during review")
480
+ @click.pass_context
481
+ def signoff(
482
+ ctx: click.Context,
483
+ path: str,
484
+ reviewer: str | None,
485
+ skip_verify: bool,
486
+ force: bool,
487
+ acknowledge: bool,
488
+ ) -> None:
489
+ """Sign off on a completed review."""
490
+ config = ctx.obj["config"]
491
+ registry = load_registry(config.registry_path)
492
+
493
+ module = registry.get(path)
494
+ if not module:
495
+ click.echo(f"Error: {path} not in registry.")
496
+ sys.exit(1)
497
+
498
+ if module.status == "reviewed" and not force:
499
+ click.secho(f"✓ {path} is already reviewed.", fg="green")
500
+ click.echo(" Use --force to re-sign-off.")
501
+ sys.exit(0)
502
+
503
+ if module.status != "in_progress" and not force:
504
+ click.echo(f"Error: {path} is not in review (status: {module.status})")
505
+ click.echo("Start a review first with:")
506
+ click.echo(f" glreview start {path}")
507
+ sys.exit(1)
508
+
509
+ # Check if file changed since review started
510
+ if module.review_started_commit and not acknowledge:
511
+ if has_changes_since(path, module.review_started_commit, cwd=config.root):
512
+ project = get_gitlab_project(cwd=config.root)
513
+ host = get_gitlab_host(cwd=config.root) or "gitlab.com"
514
+ current = get_current_commit(cwd=config.root)
515
+ diff_url = get_compare_url(project, host, module.review_started_commit, current)
516
+
517
+ click.secho("⚠ File changed during review!", fg="yellow")
518
+ click.echo()
519
+ click.echo(f" Review started at: {module.review_started_commit}")
520
+ click.echo(f" Current commit: {current}")
521
+ click.echo(f" View changes: {diff_url}")
522
+ click.echo()
523
+ click.echo("Options:")
524
+ click.echo(" --acknowledge Sign off confirming you reviewed the changes")
525
+ click.echo(" --force Sign off without checks")
526
+ sys.exit(1)
527
+
528
+ # Extract issue number
529
+ issue_number = None
530
+ if module.review_issue:
531
+ if module.review_issue.startswith("#"):
532
+ issue_number = module.review_issue[1:]
533
+ elif "/" in module.review_issue:
534
+ issue_number = module.review_issue.rstrip("/").split("/")[-1]
535
+ else:
536
+ issue_number = module.review_issue
537
+
538
+ # Verify issue is closed
539
+ project = get_gitlab_project(cwd=config.root)
540
+ host = get_gitlab_host(cwd=config.root)
541
+
542
+ if issue_number and gitlab_available(host) and not skip_verify:
543
+ click.echo(f"Checking issue #{issue_number}...")
544
+ issue = get_issue(issue_number, project_path=project, host=host)
545
+
546
+ if issue:
547
+ if issue.state != "closed":
548
+ click.secho(
549
+ f"Error: Issue #{issue_number} is not closed (state: {issue.state})",
550
+ fg="red",
551
+ )
552
+ click.echo()
553
+ click.echo("Close the issue first, or use --skip-verify to bypass.")
554
+ sys.exit(1)
555
+
556
+ # Extract reviewer from issue
557
+ if not reviewer and issue.assignees:
558
+ reviewer = f"@{issue.assignees[0]}"
559
+
560
+ click.secho(f" State: {issue.state} ✓", fg="green")
561
+
562
+ if not reviewer:
563
+ click.secho("Error: Could not determine reviewer.", fg="red")
564
+ click.echo("Specify with --reviewer @username")
565
+ sys.exit(1)
566
+
567
+ # Update registry
568
+ current_commit = get_current_commit(cwd=config.root)
569
+
570
+ module.status = "reviewed"
571
+ module.last_reviewed_commit = current_commit
572
+ module.last_reviewed_date = datetime.now(timezone.utc).strftime("%Y-%m-%d")
573
+
574
+ # Add reviewer(s)
575
+ new_reviewers = [r.strip() for r in reviewer.split(",")]
576
+ existing = set(module.reviewers)
577
+ module.reviewers = sorted(existing.union(new_reviewers))
578
+
579
+ # Clear in-progress fields
580
+ module.assigned_reviewer = None
581
+ module.review_started_commit = None
582
+
583
+ save_registry(registry, config.registry_path, commit=current_commit)
584
+
585
+ click.echo()
586
+ click.secho(f"✓ {path}", fg="green")
587
+ click.echo(f" Status: reviewed")
588
+ click.echo(f" Commit: {current_commit}")
589
+ click.echo(f" Reviewer: {reviewer}")
590
+ if module.review_issue:
591
+ click.echo(f" Issue: {module.review_issue}")
592
+
593
+
594
+ @main.command()
595
+ @click.argument("path")
596
+ @click.option("--leave-open", is_flag=True, help="Leave GitLab issue open")
597
+ @click.option("--restart", is_flag=True, help="Restart review (update start commit)")
598
+ @click.option("--reason", "-m", help="Reason for cancellation")
599
+ @click.pass_context
600
+ def cancel(
601
+ ctx: click.Context,
602
+ path: str,
603
+ leave_open: bool,
604
+ restart: bool,
605
+ reason: str | None,
606
+ ) -> None:
607
+ """Cancel or restart a review in progress."""
608
+ config = ctx.obj["config"]
609
+ registry = load_registry(config.registry_path)
610
+
611
+ module = registry.get(path)
612
+ if not module:
613
+ click.echo(f"Error: {path} not in registry.")
614
+ sys.exit(1)
615
+
616
+ if module.status != "in_progress":
617
+ click.echo(f"Error: {path} is not in progress (status: {module.status})")
618
+ sys.exit(1)
619
+
620
+ # Extract issue number
621
+ issue_number = None
622
+ issue_url = module.review_issue
623
+ if issue_url:
624
+ if issue_url.startswith("#"):
625
+ issue_number = issue_url[1:]
626
+ elif "/" in issue_url:
627
+ issue_number = issue_url.rstrip("/").split("/")[-1]
628
+ else:
629
+ issue_number = issue_url
630
+
631
+ project = get_gitlab_project(cwd=config.root)
632
+ host = get_gitlab_host(cwd=config.root)
633
+ current_commit = get_current_commit(cwd=config.root)
634
+
635
+ if restart:
636
+ # Restart: update start commit, add comment to issue
637
+ module.review_started_commit = current_commit
638
+
639
+ if issue_number and gitlab_available(host):
640
+ comment = f"**Review restarted** at commit `{current_commit}`"
641
+ if reason:
642
+ comment += f"\n\nReason: {reason}"
643
+ if add_issue_comment(issue_number, project, comment, host=host):
644
+ click.echo(f"Added restart comment to issue #{issue_number}")
645
+ else:
646
+ click.secho("Warning: Could not add comment to issue.", fg="yellow")
647
+ elif issue_number:
648
+ click.secho(
649
+ f"Warning: Could not update issue #{issue_number} (no API access).",
650
+ fg="yellow",
651
+ )
652
+ click.echo("Issue may have stale information.")
653
+
654
+ save_registry(registry, config.registry_path, commit=current_commit)
655
+
656
+ click.echo()
657
+ click.secho(f"✓ Review restarted for {path}", fg="green")
658
+ click.echo(f" New start commit: {current_commit}")
659
+ if issue_url:
660
+ click.echo(f" Issue: {issue_url}")
661
+ return
662
+
663
+ # Cancel: restore previous state
664
+ was_previously_reviewed = module.last_reviewed_commit is not None
665
+
666
+ # Try to close the issue
667
+ issue_closed = False
668
+ if issue_number and not leave_open:
669
+ if gitlab_available(host):
670
+ comment = "**Review canceled**"
671
+ if reason:
672
+ comment += f"\n\nReason: {reason}"
673
+ if was_previously_reviewed:
674
+ comment += f"\n\nRestoring to previous review at `{module.last_reviewed_commit}`"
675
+
676
+ if close_issue(issue_number, project, host=host, comment=comment):
677
+ issue_closed = True
678
+ click.echo(f"Closed issue #{issue_number}")
679
+ else:
680
+ click.secho(f"Warning: Could not close issue #{issue_number}.", fg="yellow")
681
+ else:
682
+ click.secho("Error: Cannot close issue (no GitLab API access).", fg="red")
683
+ click.echo()
684
+ click.echo("Options:")
685
+ click.echo(" --leave-open Proceed without closing issue")
686
+ click.echo(" Set GITLAB_PRIVATE_TOKEN to enable API access")
687
+ sys.exit(1)
688
+
689
+ # Clear in-progress fields
690
+ module.review_issue = None
691
+ module.review_started_commit = None
692
+ module.assigned_reviewer = None
693
+
694
+ # Restore status
695
+ if was_previously_reviewed:
696
+ module.status = "reviewed"
697
+ else:
698
+ module.status = "needs_review"
699
+
700
+ # Store reason in notes if provided
701
+ if reason:
702
+ module.notes = f"Canceled: {reason}"
703
+
704
+ save_registry(registry, config.registry_path, commit=current_commit)
705
+
706
+ click.echo()
707
+ click.secho(f"✓ Review canceled for {path}", fg="green")
708
+ if was_previously_reviewed:
709
+ click.echo(f" Status: restored to reviewed")
710
+ click.echo(f" Previous commit: {module.last_reviewed_commit}")
711
+ click.echo(f" Previous reviewers: {', '.join(module.reviewers) or 'none'}")
712
+ else:
713
+ click.echo(f" Status: needs_review (no previous review)")
714
+
715
+ if issue_url and not issue_closed:
716
+ click.echo()
717
+ click.secho(f" Note: Issue {issue_url} left open - close manually", fg="yellow")
718
+
719
+
720
+ @main.command("claude-review")
721
+ @click.argument("path")
722
+ @click.option("--post", is_flag=True, help="Post findings to GitLab issue")
723
+ @click.option("--diff-only", is_flag=True, help="Only include diff in review (re-reviews)")
724
+ @click.option("--model", default="sonnet", help="Claude model to use")
725
+ @click.option("--dry-run", is_flag=True, help="Show prompt without running Claude")
726
+ @click.pass_context
727
+ def claude_review(
728
+ ctx: click.Context,
729
+ path: str,
730
+ post: bool,
731
+ diff_only: bool,
732
+ model: str,
733
+ dry_run: bool,
734
+ ) -> None:
735
+ """Run AI-assisted code review using Claude."""
736
+ config = ctx.obj["config"]
737
+ registry = load_registry(config.registry_path)
738
+
739
+ module = registry.get(path)
740
+ if not module:
741
+ click.echo(f"Error: {path} not in registry.")
742
+ sys.exit(1)
743
+
744
+ if module.status != "in_progress":
745
+ click.echo(f"Error: {path} is not in progress (status: {module.status})")
746
+ click.echo("Start a review first with:")
747
+ click.echo(f" glreview start {path}")
748
+ sys.exit(1)
749
+
750
+ # Check claude is available
751
+ if not dry_run and not claude_available():
752
+ click.secho("Error: claude CLI not found.", fg="red")
753
+ click.echo()
754
+ click.echo("Install Claude Code from: https://claude.ai/code")
755
+ sys.exit(1)
756
+
757
+ # Read source file
758
+ source_path = config.root / path
759
+ if not source_path.exists():
760
+ click.echo(f"Error: File not found: {source_path}")
761
+ sys.exit(1)
762
+
763
+ source_code = source_path.read_text()
764
+
765
+ # Get diff if re-review
766
+ diff = None
767
+ if module.last_reviewed_commit:
768
+ diff = get_file_diff(path, module.last_reviewed_commit, cwd=config.root)
769
+
770
+ # Check if we need to chunk
771
+ chunks = chunk_source_code(source_code)
772
+
773
+ if len(chunks) > 1:
774
+ click.secho(f"⚠ Large file: splitting into {len(chunks)} chunks", fg="yellow")
775
+ click.echo()
776
+
777
+ # Process each chunk
778
+ all_results = []
779
+ for chunk in chunks:
780
+ if len(chunks) > 1:
781
+ click.echo(f"Processing chunk {chunk.chunk_num}/{chunk.total_chunks} "
782
+ f"(lines {chunk.start_line}-{chunk.end_line})...")
783
+
784
+ # Build prompt
785
+ code_to_review = chunk.content
786
+ if diff_only and diff:
787
+ code_to_review = f"# Diff only mode - showing changes\n\n{diff}"
788
+
789
+ prompt = build_review_prompt(
790
+ path=path,
791
+ source_code=code_to_review,
792
+ module_status=module,
793
+ config=config,
794
+ diff=diff if not diff_only else None,
795
+ chunk_info=chunk if len(chunks) > 1 else None,
796
+ )
797
+
798
+ if dry_run:
799
+ click.echo("=" * 60)
800
+ click.echo("PROMPT (dry-run mode):")
801
+ click.echo("=" * 60)
802
+ click.echo(prompt)
803
+ click.echo("=" * 60)
804
+ if len(chunks) > 1 and chunk.chunk_num < len(chunks):
805
+ click.echo()
806
+ continue
807
+
808
+ # Run Claude
809
+ click.echo(f"Running Claude review ({model})...")
810
+ result, success = run_claude_review(prompt, model=model)
811
+
812
+ if not success:
813
+ click.secho(f"Error: {result}", fg="red")
814
+ sys.exit(1)
815
+
816
+ all_results.append(result)
817
+
818
+ if len(chunks) > 1:
819
+ click.echo()
820
+
821
+ if dry_run:
822
+ return
823
+
824
+ # Combine results
825
+ if len(all_results) == 1:
826
+ final_result = all_results[0]
827
+ else:
828
+ final_result = "# Combined Review (Multiple Chunks)\n\n"
829
+ for i, result in enumerate(all_results, 1):
830
+ final_result += f"## Chunk {i}\n\n{result}\n\n---\n\n"
831
+
832
+ # Output results
833
+ click.echo()
834
+ click.echo("=" * 60)
835
+ click.secho("CLAUDE REVIEW RESULTS", fg="cyan", bold=True)
836
+ click.echo("=" * 60)
837
+ click.echo(final_result)
838
+ click.echo("=" * 60)
839
+
840
+ # Post to issue if requested
841
+ if post:
842
+ issue_number = None
843
+ if module.review_issue:
844
+ if module.review_issue.startswith("#"):
845
+ issue_number = module.review_issue[1:]
846
+ elif "/" in module.review_issue:
847
+ issue_number = module.review_issue.rstrip("/").split("/")[-1]
848
+ else:
849
+ issue_number = module.review_issue
850
+
851
+ if not issue_number:
852
+ click.secho("Warning: No issue found to post to.", fg="yellow")
853
+ else:
854
+ project = get_gitlab_project(cwd=config.root)
855
+ host = get_gitlab_host(cwd=config.root)
856
+
857
+ if not gitlab_available(host):
858
+ click.secho("Warning: GitLab API not available, cannot post.", fg="yellow")
859
+ else:
860
+ comment = f"## Claude Code Review\n\n{final_result}\n\n---\n_Generated by `glreview claude-review`_"
861
+ if add_issue_comment(issue_number, project, comment, host=host):
862
+ click.secho(f"✓ Posted review to issue #{issue_number}", fg="green")
863
+ else:
864
+ click.secho(f"Warning: Could not post to issue #{issue_number}", fg="yellow")
865
+
866
+
867
+ @main.command()
868
+ @click.option("--team", "-t", help="Filter by team name")
869
+ @click.pass_context
870
+ def reviewers(ctx: click.Context, team: str | None) -> None:
871
+ """List available reviewers."""
872
+ config = ctx.obj["config"]
873
+
874
+ # Check for team filter
875
+ if team:
876
+ if team not in config.teams:
877
+ click.echo(f"Unknown team: {team}")
878
+ click.echo(f"Available teams: {', '.join(config.teams.keys()) or 'none'}")
879
+ sys.exit(1)
880
+
881
+ click.echo(f"Team '{team}':")
882
+ for member in config.teams[team]:
883
+ click.echo(f" {member}")
884
+ return
885
+
886
+ # Show configured teams
887
+ if config.teams:
888
+ click.echo("Configured teams:")
889
+ for team_name, members in config.teams.items():
890
+ click.echo(f" {team_name}: {', '.join(members)}")
891
+ click.echo()
892
+
893
+ # Query GitLab
894
+ project = get_gitlab_project(cwd=config.root)
895
+ host = get_gitlab_host(cwd=config.root)
896
+
897
+ if not gitlab_available(host):
898
+ click.secho("GitLab authentication not configured.", fg="yellow")
899
+ click.echo()
900
+ click.echo("Set one of these environment variables:")
901
+ click.echo(" GITLAB_PRIVATE_TOKEN - Personal access token")
902
+ click.echo(" GITLAB_TOKEN - Alias for above")
903
+ click.echo(" CI_JOB_TOKEN - For CI pipelines")
904
+ click.echo()
905
+ click.echo("Optionally set GITLAB_URL for self-hosted instances.")
906
+ return
907
+
908
+ click.echo("Fetching project members...")
909
+ members = get_project_members(project_path=project, host=host)
910
+
911
+ if not members:
912
+ click.echo("Could not fetch project members.")
913
+ click.echo("Check your token has 'read_api' scope.")
914
+ return
915
+
916
+ # Group by access level
917
+ by_level: dict[str, list] = {}
918
+ for member in sorted(members, key=lambda m: (-m.access_level, m.username)):
919
+ level_name = member.access_level_name
920
+ if level_name not in by_level:
921
+ by_level[level_name] = []
922
+ by_level[level_name].append(member)
923
+
924
+ click.echo()
925
+ click.echo("Project members:")
926
+ for level_name in ["Owner", "Maintainer", "Developer", "Reporter", "Guest"]:
927
+ if level_name not in by_level:
928
+ continue
929
+ click.echo(f"\n {level_name}s:")
930
+ for member in by_level[level_name]:
931
+ click.echo(f" @{member.username:<20} {member.name}")
932
+
933
+ click.echo()
934
+ click.echo("Usage: glreview start <file> --assignee @username")
935
+
936
+
937
+ @main.command()
938
+ @click.option("--base", default="origin/main", help="Base branch to compare against")
939
+ @click.pass_context
940
+ def check(ctx: click.Context, base: str) -> None:
941
+ """CI check for changes to reviewed files."""
942
+ config = ctx.obj["config"]
943
+ registry = load_registry(config.registry_path)
944
+
945
+ warnings = []
946
+ critical = []
947
+
948
+ for module in registry:
949
+ if module.status != "reviewed":
950
+ continue
951
+
952
+ if has_changes_since(module.path, module.last_reviewed_commit, cwd=config.root):
953
+ msg = f"{module.path} changed since review ({module.last_reviewed_commit})"
954
+ warnings.append(msg)
955
+ if module.priority == "critical":
956
+ critical.append(module.path)
957
+
958
+ if not warnings:
959
+ click.secho("✓ No reviewed files have changed.", fg="green")
960
+ sys.exit(0)
961
+
962
+ click.secho("⚠ Reviewed files have changed:", fg="yellow")
963
+ for msg in warnings:
964
+ click.echo(f" {msg}")
965
+
966
+ if critical:
967
+ click.echo()
968
+ click.secho("Critical modules changed:", fg="red")
969
+ for path in critical:
970
+ click.echo(f" {path}")
971
+
972
+ click.echo()
973
+ click.echo("Consider re-reviewing these modules after merging.")
974
+ sys.exit(1)
975
+
976
+
977
+ @main.command()
978
+ @click.pass_context
979
+ def sync(ctx: click.Context) -> None:
980
+ """Sync registry with filesystem."""
981
+ config = ctx.obj["config"]
982
+ registry = load_registry(config.registry_path)
983
+
984
+ added, removed = sync_registry(
985
+ config.root,
986
+ config.sources,
987
+ config.exclude,
988
+ registry.modules,
989
+ )
990
+
991
+ if not added and not removed:
992
+ click.echo("Registry is in sync with filesystem.")
993
+ return
994
+
995
+ if added:
996
+ click.echo(f"Adding {len(added)} new modules:")
997
+ for path in added:
998
+ level, reviewers_required = config.get_priority(path)
999
+ lines = count_lines(path, cwd=config.root)
1000
+
1001
+ registry.set(
1002
+ ModuleStatus(
1003
+ path=path,
1004
+ status="needs_review",
1005
+ priority=level,
1006
+ lines=lines,
1007
+ reviewers_required=reviewers_required,
1008
+ )
1009
+ )
1010
+ click.echo(f" + {path}")
1011
+
1012
+ if removed:
1013
+ click.echo(f"Removing {len(removed)} deleted modules:")
1014
+ for path in removed:
1015
+ registry.remove(path)
1016
+ click.echo(f" - {path}")
1017
+
1018
+ commit = get_current_commit(cwd=config.root)
1019
+ save_registry(registry, config.registry_path, commit=commit)
1020
+
1021
+ click.secho("✓ Registry synced.", fg="green")
1022
+
1023
+
1024
+ @main.command()
1025
+ @click.option("--sync", "do_sync", is_flag=True, help="Run sync before generating report")
1026
+ @click.option("--format", "fmt", type=click.Choice(["markdown", "json", "badge"]), default="markdown", help="Output format")
1027
+ @click.option("--output", "-o", type=click.Path(), help="Write report to file")
1028
+ @click.option("--commit", "do_commit", is_flag=True, help="Commit registry changes (for CI)")
1029
+ @click.option("--message", "-m", default="Update review registry", help="Commit message")
1030
+ @click.pass_context
1031
+ def report(
1032
+ ctx: click.Context,
1033
+ do_sync: bool,
1034
+ fmt: str,
1035
+ output: str | None,
1036
+ do_commit: bool,
1037
+ message: str,
1038
+ ) -> None:
1039
+ """Generate review coverage report."""
1040
+ config = ctx.obj["config"]
1041
+
1042
+ # Run sync first if requested
1043
+ if do_sync:
1044
+ ctx.invoke(sync)
1045
+ click.echo()
1046
+
1047
+ registry = load_registry(config.registry_path)
1048
+
1049
+ if not registry.modules:
1050
+ click.echo("No modules in registry. Run 'glreview init' first.")
1051
+ sys.exit(1)
1052
+
1053
+ # Calculate stats
1054
+ summary = registry.summary
1055
+ total = summary["total"]
1056
+ reviewed = summary["reviewed"]
1057
+ in_progress = summary["in_progress"]
1058
+ needs_review = summary["needs_review"]
1059
+ total_lines = summary["total_lines"]
1060
+ reviewed_lines = summary["reviewed_lines"]
1061
+
1062
+ coverage_pct = (reviewed / total * 100) if total > 0 else 0
1063
+ line_coverage_pct = (reviewed_lines / total_lines * 100) if total_lines > 0 else 0
1064
+
1065
+ # Generate report in requested format
1066
+ if fmt == "json":
1067
+ import json
1068
+ report_data = {
1069
+ "coverage_pct": round(coverage_pct, 1),
1070
+ "line_coverage_pct": round(line_coverage_pct, 1),
1071
+ "reviewed": reviewed,
1072
+ "in_progress": in_progress,
1073
+ "needs_review": needs_review,
1074
+ "total": total,
1075
+ "reviewed_lines": reviewed_lines,
1076
+ "total_lines": total_lines,
1077
+ }
1078
+ report_text = json.dumps(report_data, indent=2)
1079
+
1080
+ elif fmt == "badge":
1081
+ # Generate shields.io badge URL
1082
+ if coverage_pct >= 90:
1083
+ color = "brightgreen"
1084
+ elif coverage_pct >= 70:
1085
+ color = "green"
1086
+ elif coverage_pct >= 50:
1087
+ color = "yellow"
1088
+ else:
1089
+ color = "red"
1090
+ badge_url = f"https://img.shields.io/badge/review-{coverage_pct:.0f}%25-{color}"
1091
+ report_text = badge_url
1092
+
1093
+ else: # markdown
1094
+ # Determine badge color
1095
+ if coverage_pct >= 90:
1096
+ color = "brightgreen"
1097
+ elif coverage_pct >= 70:
1098
+ color = "green"
1099
+ elif coverage_pct >= 50:
1100
+ color = "yellow"
1101
+ else:
1102
+ color = "red"
1103
+ badge_url = f"https://img.shields.io/badge/review-{coverage_pct:.0f}%25-{color}"
1104
+
1105
+ report_text = f"""## Review Coverage
1106
+
1107
+ ![Coverage]({badge_url})
1108
+
1109
+ | Status | Modules | Lines |
1110
+ |--------|---------|-------|
1111
+ | ✓ Reviewed | {reviewed} | {reviewed_lines:,} |
1112
+ | ◐ In Progress | {in_progress} | - |
1113
+ | ○ Needs Review | {needs_review} | {total_lines - reviewed_lines:,} |
1114
+
1115
+ **Coverage:** {coverage_pct:.0f}% of modules, {line_coverage_pct:.0f}% of lines
1116
+ """
1117
+
1118
+ # Output report
1119
+ if output:
1120
+ Path(output).write_text(report_text)
1121
+ click.echo(f"Report written to {output}")
1122
+ else:
1123
+ click.echo(report_text)
1124
+
1125
+ # Commit if requested
1126
+ if do_commit:
1127
+ import subprocess
1128
+
1129
+ # Check if there are changes to commit
1130
+ result = subprocess.run(
1131
+ ["git", "status", "--porcelain", config.registry],
1132
+ cwd=config.root,
1133
+ capture_output=True,
1134
+ text=True,
1135
+ )
1136
+
1137
+ if not result.stdout.strip():
1138
+ click.echo("No changes to commit.")
1139
+ return
1140
+
1141
+ # Stage and commit
1142
+ subprocess.run(
1143
+ ["git", "add", config.registry],
1144
+ cwd=config.root,
1145
+ check=True,
1146
+ )
1147
+
1148
+ if output:
1149
+ subprocess.run(
1150
+ ["git", "add", output],
1151
+ cwd=config.root,
1152
+ check=False, # May not exist in repo yet
1153
+ )
1154
+
1155
+ result = subprocess.run(
1156
+ ["git", "commit", "-m", message],
1157
+ cwd=config.root,
1158
+ capture_output=True,
1159
+ text=True,
1160
+ )
1161
+
1162
+ if result.returncode == 0:
1163
+ click.secho(f"✓ Committed: {message}", fg="green")
1164
+ else:
1165
+ click.secho(f"Warning: Commit failed: {result.stderr}", fg="yellow")
1166
+
1167
+
1168
+ @main.command("ci-config")
1169
+ def ci_config() -> None:
1170
+ """Print recommended GitLab CI configuration."""
1171
+ config_yaml = '''# Add to .gitlab-ci.yml
1172
+
1173
+ stages:
1174
+ - review
1175
+
1176
+ # Sync and report on MR pushes
1177
+ review-mr:
1178
+ stage: review
1179
+ image: python:3.12
1180
+ script:
1181
+ - pip install glreview
1182
+ - git config user.email "gitlab-ci@$CI_SERVER_HOST"
1183
+ - git config user.name "GitLab CI"
1184
+ - git checkout $CI_COMMIT_REF_NAME
1185
+ - glreview report --sync --format markdown --output REVIEW_COVERAGE.md --commit -m "chore: update review registry [skip ci]"
1186
+ - git push http://gitlab-ci-token:${CI_JOB_TOKEN}@${CI_SERVER_HOST}/${CI_PROJECT_PATH}.git HEAD:$CI_COMMIT_REF_NAME
1187
+ rules:
1188
+ - if: $CI_PIPELINE_SOURCE == "merge_request_event"
1189
+
1190
+ # Sync and report on main branch
1191
+ review-main:
1192
+ stage: review
1193
+ image: python:3.12
1194
+ script:
1195
+ - pip install glreview
1196
+ - git config user.email "gitlab-ci@$CI_SERVER_HOST"
1197
+ - git config user.name "GitLab CI"
1198
+ - git checkout $CI_COMMIT_BRANCH
1199
+ - glreview report --sync --format markdown --output REVIEW_COVERAGE.md --commit -m "chore: update review registry [skip ci]"
1200
+ - git push http://gitlab-ci-token:${CI_JOB_TOKEN}@${CI_SERVER_HOST}/${CI_PROJECT_PATH}.git HEAD:$CI_COMMIT_BRANCH
1201
+ rules:
1202
+ - if: $CI_COMMIT_BRANCH == $CI_DEFAULT_BRANCH
1203
+ changes:
1204
+ - "src/**/*.py" # Adjust to match your sources pattern
1205
+
1206
+ # Block releases without 100% coverage (optional)
1207
+ release-gate:
1208
+ stage: review
1209
+ image: python:3.12
1210
+ script:
1211
+ - pip install glreview
1212
+ - |
1213
+ COVERAGE=$(glreview report --format json | python -c "import sys,json; print(int(json.load(sys.stdin)['coverage_pct']))")
1214
+ if [ "$COVERAGE" -lt 100 ]; then
1215
+ echo "Review coverage is ${COVERAGE}%, need 100% for release"
1216
+ glreview status
1217
+ exit 1
1218
+ fi
1219
+ echo "Review coverage is 100% ✓"
1220
+ rules:
1221
+ - if: $CI_COMMIT_TAG
1222
+ '''
1223
+ click.echo(config_yaml)
1224
+
1225
+
1226
+ if __name__ == "__main__":
1227
+ main()