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/config.py
ADDED
|
@@ -0,0 +1,149 @@
|
|
|
1
|
+
"""Configuration loading from pyproject.toml."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import sys
|
|
6
|
+
from dataclasses import dataclass, field
|
|
7
|
+
from fnmatch import fnmatch
|
|
8
|
+
from pathlib import Path
|
|
9
|
+
from typing import Any
|
|
10
|
+
|
|
11
|
+
if sys.version_info >= (3, 11):
|
|
12
|
+
import tomllib
|
|
13
|
+
else:
|
|
14
|
+
import tomli as tomllib
|
|
15
|
+
|
|
16
|
+
|
|
17
|
+
@dataclass
|
|
18
|
+
class PriorityRule:
|
|
19
|
+
"""A rule for assigning priority to modules based on path patterns."""
|
|
20
|
+
|
|
21
|
+
pattern: str
|
|
22
|
+
level: str = "medium"
|
|
23
|
+
reviewers_required: int = 1
|
|
24
|
+
|
|
25
|
+
def matches(self, path: str) -> bool:
|
|
26
|
+
"""Check if this rule matches the given path."""
|
|
27
|
+
return fnmatch(path, self.pattern)
|
|
28
|
+
|
|
29
|
+
|
|
30
|
+
@dataclass
|
|
31
|
+
class Config:
|
|
32
|
+
"""Configuration for glreview."""
|
|
33
|
+
|
|
34
|
+
# Source patterns to discover modules
|
|
35
|
+
sources: list[str] = field(default_factory=lambda: ["src/**/*.py"])
|
|
36
|
+
|
|
37
|
+
# Patterns to exclude
|
|
38
|
+
exclude: list[str] = field(default_factory=lambda: ["**/_version.py"])
|
|
39
|
+
|
|
40
|
+
# Path to the registry file
|
|
41
|
+
registry: str = "review_registry.json"
|
|
42
|
+
|
|
43
|
+
# GitLab issue labels
|
|
44
|
+
issue_labels: list[str] = field(default_factory=lambda: ["review"])
|
|
45
|
+
|
|
46
|
+
# Path to custom issue template (optional)
|
|
47
|
+
issue_template: str | None = None
|
|
48
|
+
|
|
49
|
+
# Priority rules (in order of precedence)
|
|
50
|
+
priority_rules: list[PriorityRule] = field(default_factory=list)
|
|
51
|
+
|
|
52
|
+
# Named reviewer teams
|
|
53
|
+
teams: dict[str, list[str]] = field(default_factory=dict)
|
|
54
|
+
|
|
55
|
+
# Project root directory
|
|
56
|
+
root: Path = field(default_factory=Path.cwd)
|
|
57
|
+
|
|
58
|
+
def get_priority(self, path: str) -> tuple[str, int]:
|
|
59
|
+
"""Get the priority level and required reviewers for a path.
|
|
60
|
+
|
|
61
|
+
Returns:
|
|
62
|
+
Tuple of (level, reviewers_required)
|
|
63
|
+
"""
|
|
64
|
+
for rule in self.priority_rules:
|
|
65
|
+
if rule.matches(path):
|
|
66
|
+
return rule.level, rule.reviewers_required
|
|
67
|
+
|
|
68
|
+
# Default
|
|
69
|
+
return "medium", 1
|
|
70
|
+
|
|
71
|
+
@property
|
|
72
|
+
def registry_path(self) -> Path:
|
|
73
|
+
"""Get the absolute path to the registry file."""
|
|
74
|
+
return self.root / self.registry
|
|
75
|
+
|
|
76
|
+
|
|
77
|
+
def load_config(project_root: Path | None = None) -> Config:
|
|
78
|
+
"""Load configuration from pyproject.toml.
|
|
79
|
+
|
|
80
|
+
Args:
|
|
81
|
+
project_root: Path to the project root. If None, searches upward
|
|
82
|
+
from cwd for pyproject.toml.
|
|
83
|
+
|
|
84
|
+
Returns:
|
|
85
|
+
Config object with loaded settings.
|
|
86
|
+
"""
|
|
87
|
+
if project_root is None:
|
|
88
|
+
project_root = find_project_root()
|
|
89
|
+
|
|
90
|
+
pyproject_path = project_root / "pyproject.toml"
|
|
91
|
+
|
|
92
|
+
if not pyproject_path.exists():
|
|
93
|
+
# Return defaults
|
|
94
|
+
return Config(root=project_root)
|
|
95
|
+
|
|
96
|
+
with open(pyproject_path, "rb") as f:
|
|
97
|
+
data = tomllib.load(f)
|
|
98
|
+
|
|
99
|
+
tool_config = data.get("tool", {}).get("glreview", {})
|
|
100
|
+
|
|
101
|
+
# Parse priority rules
|
|
102
|
+
priority_rules = []
|
|
103
|
+
for rule_data in tool_config.get("priority", []):
|
|
104
|
+
priority_rules.append(
|
|
105
|
+
PriorityRule(
|
|
106
|
+
pattern=rule_data.get("pattern", "**/*.py"),
|
|
107
|
+
level=rule_data.get("level", "medium"),
|
|
108
|
+
reviewers_required=rule_data.get("reviewers_required", 1),
|
|
109
|
+
)
|
|
110
|
+
)
|
|
111
|
+
|
|
112
|
+
return Config(
|
|
113
|
+
sources=tool_config.get("sources", ["src/**/*.py"]),
|
|
114
|
+
exclude=tool_config.get("exclude", ["**/_version.py"]),
|
|
115
|
+
registry=tool_config.get("registry", "review_registry.json"),
|
|
116
|
+
issue_labels=tool_config.get("issue_labels", ["review"]),
|
|
117
|
+
issue_template=tool_config.get("issue_template"),
|
|
118
|
+
priority_rules=priority_rules,
|
|
119
|
+
teams=tool_config.get("teams", {}),
|
|
120
|
+
root=project_root,
|
|
121
|
+
)
|
|
122
|
+
|
|
123
|
+
|
|
124
|
+
def find_project_root(start: Path | None = None) -> Path:
|
|
125
|
+
"""Find the project root by searching upward for pyproject.toml or .git.
|
|
126
|
+
|
|
127
|
+
Args:
|
|
128
|
+
start: Starting directory. Defaults to cwd.
|
|
129
|
+
|
|
130
|
+
Returns:
|
|
131
|
+
Path to the project root.
|
|
132
|
+
|
|
133
|
+
Raises:
|
|
134
|
+
FileNotFoundError: If no project root can be found.
|
|
135
|
+
"""
|
|
136
|
+
if start is None:
|
|
137
|
+
start = Path.cwd()
|
|
138
|
+
|
|
139
|
+
current = start.resolve()
|
|
140
|
+
|
|
141
|
+
while current != current.parent:
|
|
142
|
+
if (current / "pyproject.toml").exists():
|
|
143
|
+
return current
|
|
144
|
+
if (current / ".git").exists():
|
|
145
|
+
return current
|
|
146
|
+
current = current.parent
|
|
147
|
+
|
|
148
|
+
# Fall back to start directory
|
|
149
|
+
return start.resolve()
|
glreview/discovery.py
ADDED
|
@@ -0,0 +1,73 @@
|
|
|
1
|
+
"""Module discovery from glob patterns."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
from fnmatch import fnmatch
|
|
6
|
+
from pathlib import Path
|
|
7
|
+
|
|
8
|
+
|
|
9
|
+
def discover_modules(
|
|
10
|
+
root: Path,
|
|
11
|
+
patterns: list[str],
|
|
12
|
+
exclude: list[str] | None = None,
|
|
13
|
+
) -> list[str]:
|
|
14
|
+
"""Discover modules matching glob patterns.
|
|
15
|
+
|
|
16
|
+
Args:
|
|
17
|
+
root: Project root directory.
|
|
18
|
+
patterns: Glob patterns to match (e.g., ["src/**/*.py"]).
|
|
19
|
+
exclude: Patterns to exclude.
|
|
20
|
+
|
|
21
|
+
Returns:
|
|
22
|
+
List of relative paths to discovered modules.
|
|
23
|
+
"""
|
|
24
|
+
exclude = exclude or []
|
|
25
|
+
modules = set()
|
|
26
|
+
|
|
27
|
+
for pattern in patterns:
|
|
28
|
+
for path in root.glob(pattern):
|
|
29
|
+
if not path.is_file():
|
|
30
|
+
continue
|
|
31
|
+
|
|
32
|
+
rel_path = str(path.relative_to(root))
|
|
33
|
+
|
|
34
|
+
# Check exclusions
|
|
35
|
+
excluded = False
|
|
36
|
+
for exc_pattern in exclude:
|
|
37
|
+
if fnmatch(rel_path, exc_pattern):
|
|
38
|
+
excluded = True
|
|
39
|
+
break
|
|
40
|
+
|
|
41
|
+
if not excluded:
|
|
42
|
+
modules.add(rel_path)
|
|
43
|
+
|
|
44
|
+
return sorted(modules)
|
|
45
|
+
|
|
46
|
+
|
|
47
|
+
def sync_registry(
|
|
48
|
+
root: Path,
|
|
49
|
+
patterns: list[str],
|
|
50
|
+
exclude: list[str] | None = None,
|
|
51
|
+
existing: dict[str, any] | None = None,
|
|
52
|
+
get_priority: callable | None = None,
|
|
53
|
+
) -> tuple[list[str], list[str]]:
|
|
54
|
+
"""Sync registry with discovered modules.
|
|
55
|
+
|
|
56
|
+
Args:
|
|
57
|
+
root: Project root directory.
|
|
58
|
+
patterns: Glob patterns for discovery.
|
|
59
|
+
exclude: Patterns to exclude.
|
|
60
|
+
existing: Existing module paths in registry.
|
|
61
|
+
get_priority: Function to get (level, reviewers_required) for a path.
|
|
62
|
+
|
|
63
|
+
Returns:
|
|
64
|
+
Tuple of (added_paths, removed_paths).
|
|
65
|
+
"""
|
|
66
|
+
existing = existing or {}
|
|
67
|
+
discovered = set(discover_modules(root, patterns, exclude))
|
|
68
|
+
current = set(existing.keys())
|
|
69
|
+
|
|
70
|
+
added = discovered - current
|
|
71
|
+
removed = current - discovered
|
|
72
|
+
|
|
73
|
+
return sorted(added), sorted(removed)
|
glreview/git.py
ADDED
|
@@ -0,0 +1,217 @@
|
|
|
1
|
+
"""Git operations for tracking commits and diffs."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import subprocess
|
|
6
|
+
from pathlib import Path
|
|
7
|
+
|
|
8
|
+
|
|
9
|
+
class GitError(Exception):
|
|
10
|
+
"""Error running git command."""
|
|
11
|
+
|
|
12
|
+
pass
|
|
13
|
+
|
|
14
|
+
|
|
15
|
+
def run_git(
|
|
16
|
+
*args: str,
|
|
17
|
+
cwd: Path | None = None,
|
|
18
|
+
check: bool = True,
|
|
19
|
+
) -> subprocess.CompletedProcess:
|
|
20
|
+
"""Run a git command.
|
|
21
|
+
|
|
22
|
+
Args:
|
|
23
|
+
*args: Git command arguments.
|
|
24
|
+
cwd: Working directory.
|
|
25
|
+
check: Whether to raise on non-zero exit.
|
|
26
|
+
|
|
27
|
+
Returns:
|
|
28
|
+
Completed process with stdout/stderr.
|
|
29
|
+
|
|
30
|
+
Raises:
|
|
31
|
+
GitError: If check=True and command fails.
|
|
32
|
+
"""
|
|
33
|
+
result = subprocess.run(
|
|
34
|
+
["git", *args],
|
|
35
|
+
cwd=cwd,
|
|
36
|
+
capture_output=True,
|
|
37
|
+
text=True,
|
|
38
|
+
)
|
|
39
|
+
|
|
40
|
+
if check and result.returncode != 0:
|
|
41
|
+
raise GitError(f"git {' '.join(args)} failed: {result.stderr}")
|
|
42
|
+
|
|
43
|
+
return result
|
|
44
|
+
|
|
45
|
+
|
|
46
|
+
def get_current_commit(cwd: Path | None = None, short: bool = True) -> str:
|
|
47
|
+
"""Get the current HEAD commit SHA.
|
|
48
|
+
|
|
49
|
+
Args:
|
|
50
|
+
cwd: Working directory.
|
|
51
|
+
short: If True, return short SHA (7 chars).
|
|
52
|
+
|
|
53
|
+
Returns:
|
|
54
|
+
Commit SHA string.
|
|
55
|
+
"""
|
|
56
|
+
args = ["rev-parse"]
|
|
57
|
+
if short:
|
|
58
|
+
args.append("--short")
|
|
59
|
+
args.append("HEAD")
|
|
60
|
+
|
|
61
|
+
result = run_git(*args, cwd=cwd)
|
|
62
|
+
return result.stdout.strip()
|
|
63
|
+
|
|
64
|
+
|
|
65
|
+
def get_full_commit(short_sha: str, cwd: Path | None = None) -> str | None:
|
|
66
|
+
"""Expand a short SHA to full SHA.
|
|
67
|
+
|
|
68
|
+
Args:
|
|
69
|
+
short_sha: Short commit SHA.
|
|
70
|
+
cwd: Working directory.
|
|
71
|
+
|
|
72
|
+
Returns:
|
|
73
|
+
Full commit SHA, or None if not found.
|
|
74
|
+
"""
|
|
75
|
+
result = run_git("rev-parse", short_sha, cwd=cwd, check=False)
|
|
76
|
+
if result.returncode != 0:
|
|
77
|
+
return None
|
|
78
|
+
return result.stdout.strip()
|
|
79
|
+
|
|
80
|
+
|
|
81
|
+
def has_changes_since(
|
|
82
|
+
filepath: str,
|
|
83
|
+
since_commit: str,
|
|
84
|
+
cwd: Path | None = None,
|
|
85
|
+
) -> bool:
|
|
86
|
+
"""Check if a file has changed since a given commit.
|
|
87
|
+
|
|
88
|
+
Args:
|
|
89
|
+
filepath: Path to the file (relative to repo root).
|
|
90
|
+
since_commit: Commit SHA to compare against.
|
|
91
|
+
cwd: Working directory.
|
|
92
|
+
|
|
93
|
+
Returns:
|
|
94
|
+
True if the file has changes, False otherwise.
|
|
95
|
+
"""
|
|
96
|
+
if not since_commit:
|
|
97
|
+
return True # Never reviewed means "has changes"
|
|
98
|
+
|
|
99
|
+
full_commit = get_full_commit(since_commit, cwd=cwd)
|
|
100
|
+
if not full_commit:
|
|
101
|
+
return True # Can't find commit, assume changes
|
|
102
|
+
|
|
103
|
+
result = run_git(
|
|
104
|
+
"diff", "--quiet", full_commit, "HEAD", "--", filepath,
|
|
105
|
+
cwd=cwd,
|
|
106
|
+
check=False,
|
|
107
|
+
)
|
|
108
|
+
|
|
109
|
+
return result.returncode != 0 # Non-zero means changes exist
|
|
110
|
+
|
|
111
|
+
|
|
112
|
+
def get_changed_files(
|
|
113
|
+
base: str = "HEAD~1",
|
|
114
|
+
head: str = "HEAD",
|
|
115
|
+
cwd: Path | None = None,
|
|
116
|
+
) -> list[str]:
|
|
117
|
+
"""Get list of files changed between two commits.
|
|
118
|
+
|
|
119
|
+
Args:
|
|
120
|
+
base: Base commit.
|
|
121
|
+
head: Head commit.
|
|
122
|
+
cwd: Working directory.
|
|
123
|
+
|
|
124
|
+
Returns:
|
|
125
|
+
List of changed file paths.
|
|
126
|
+
"""
|
|
127
|
+
result = run_git(
|
|
128
|
+
"diff", "--name-only", f"{base}...{head}",
|
|
129
|
+
cwd=cwd,
|
|
130
|
+
check=False,
|
|
131
|
+
)
|
|
132
|
+
|
|
133
|
+
if result.returncode != 0:
|
|
134
|
+
return []
|
|
135
|
+
|
|
136
|
+
return [f for f in result.stdout.strip().split("\n") if f]
|
|
137
|
+
|
|
138
|
+
|
|
139
|
+
def get_remote_url(cwd: Path | None = None) -> str | None:
|
|
140
|
+
"""Get the origin remote URL.
|
|
141
|
+
|
|
142
|
+
Args:
|
|
143
|
+
cwd: Working directory.
|
|
144
|
+
|
|
145
|
+
Returns:
|
|
146
|
+
Remote URL or None.
|
|
147
|
+
"""
|
|
148
|
+
result = run_git("remote", "get-url", "origin", cwd=cwd, check=False)
|
|
149
|
+
if result.returncode != 0:
|
|
150
|
+
return None
|
|
151
|
+
return result.stdout.strip()
|
|
152
|
+
|
|
153
|
+
|
|
154
|
+
def get_gitlab_project(cwd: Path | None = None) -> str | None:
|
|
155
|
+
"""Extract GitLab project path from remote URL.
|
|
156
|
+
|
|
157
|
+
Args:
|
|
158
|
+
cwd: Working directory.
|
|
159
|
+
|
|
160
|
+
Returns:
|
|
161
|
+
Project path (e.g., "user/project") or None.
|
|
162
|
+
"""
|
|
163
|
+
url = get_remote_url(cwd=cwd)
|
|
164
|
+
if not url:
|
|
165
|
+
return None
|
|
166
|
+
|
|
167
|
+
# Handle SSH: git@git.ligo.org:user/project.git
|
|
168
|
+
if url.startswith("git@"):
|
|
169
|
+
path = url.split(":")[-1]
|
|
170
|
+
# Handle HTTPS: https://git.ligo.org/user/project.git
|
|
171
|
+
elif "://" in url:
|
|
172
|
+
path = "/".join(url.split("/")[-2:])
|
|
173
|
+
else:
|
|
174
|
+
return None
|
|
175
|
+
|
|
176
|
+
return path.removesuffix(".git")
|
|
177
|
+
|
|
178
|
+
|
|
179
|
+
def get_gitlab_host(cwd: Path | None = None) -> str | None:
|
|
180
|
+
"""Extract GitLab host from remote URL.
|
|
181
|
+
|
|
182
|
+
Args:
|
|
183
|
+
cwd: Working directory.
|
|
184
|
+
|
|
185
|
+
Returns:
|
|
186
|
+
Host (e.g., "git.ligo.org") or None.
|
|
187
|
+
"""
|
|
188
|
+
url = get_remote_url(cwd=cwd)
|
|
189
|
+
if not url:
|
|
190
|
+
return None
|
|
191
|
+
|
|
192
|
+
# Handle SSH: git@git.ligo.org:user/project.git
|
|
193
|
+
if url.startswith("git@"):
|
|
194
|
+
return url.split("@")[1].split(":")[0]
|
|
195
|
+
# Handle HTTPS: https://git.ligo.org/user/project.git
|
|
196
|
+
elif "://" in url:
|
|
197
|
+
return url.split("/")[2]
|
|
198
|
+
else:
|
|
199
|
+
return None
|
|
200
|
+
|
|
201
|
+
|
|
202
|
+
def count_lines(filepath: str, cwd: Path | None = None) -> int:
|
|
203
|
+
"""Count the number of lines in a file.
|
|
204
|
+
|
|
205
|
+
Args:
|
|
206
|
+
filepath: Path to the file.
|
|
207
|
+
cwd: Working directory.
|
|
208
|
+
|
|
209
|
+
Returns:
|
|
210
|
+
Number of lines, or 0 if file doesn't exist.
|
|
211
|
+
"""
|
|
212
|
+
full_path = (cwd or Path.cwd()) / filepath
|
|
213
|
+
if not full_path.exists():
|
|
214
|
+
return 0
|
|
215
|
+
|
|
216
|
+
with open(full_path) as f:
|
|
217
|
+
return sum(1 for _ in f)
|