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/__init__.py +8 -0
- glreview/_version.py +34 -0
- glreview/analyze.py +185 -0
- glreview/claude.py +266 -0
- glreview/cli.py +1227 -0
- glreview/config.py +149 -0
- glreview/discovery.py +73 -0
- glreview/git.py +217 -0
- glreview/gitlab.py +398 -0
- glreview/registry.py +179 -0
- glreview/templates/claude_review_prompt.md +177 -0
- glreview/templates/issue.md +61 -0
- glreview-0.1.0.dist-info/METADATA +211 -0
- glreview-0.1.0.dist-info/RECORD +17 -0
- glreview-0.1.0.dist-info/WHEEL +5 -0
- glreview-0.1.0.dist-info/entry_points.txt +2 -0
- glreview-0.1.0.dist-info/top_level.txt +1 -0
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
|
+

|
|
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()
|