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/__init__.py
ADDED
glreview/_version.py
ADDED
|
@@ -0,0 +1,34 @@
|
|
|
1
|
+
# file generated by setuptools-scm
|
|
2
|
+
# don't change, don't track in version control
|
|
3
|
+
|
|
4
|
+
__all__ = [
|
|
5
|
+
"__version__",
|
|
6
|
+
"__version_tuple__",
|
|
7
|
+
"version",
|
|
8
|
+
"version_tuple",
|
|
9
|
+
"__commit_id__",
|
|
10
|
+
"commit_id",
|
|
11
|
+
]
|
|
12
|
+
|
|
13
|
+
TYPE_CHECKING = False
|
|
14
|
+
if TYPE_CHECKING:
|
|
15
|
+
from typing import Tuple
|
|
16
|
+
from typing import Union
|
|
17
|
+
|
|
18
|
+
VERSION_TUPLE = Tuple[Union[int, str], ...]
|
|
19
|
+
COMMIT_ID = Union[str, None]
|
|
20
|
+
else:
|
|
21
|
+
VERSION_TUPLE = object
|
|
22
|
+
COMMIT_ID = object
|
|
23
|
+
|
|
24
|
+
version: str
|
|
25
|
+
__version__: str
|
|
26
|
+
__version_tuple__: VERSION_TUPLE
|
|
27
|
+
version_tuple: VERSION_TUPLE
|
|
28
|
+
commit_id: COMMIT_ID
|
|
29
|
+
__commit_id__: COMMIT_ID
|
|
30
|
+
|
|
31
|
+
__version__ = version = '0.1.0'
|
|
32
|
+
__version_tuple__ = version_tuple = (0, 1, 0)
|
|
33
|
+
|
|
34
|
+
__commit_id__ = commit_id = None
|
glreview/analyze.py
ADDED
|
@@ -0,0 +1,185 @@
|
|
|
1
|
+
"""Static analysis of Python modules using AST."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import ast
|
|
6
|
+
from dataclasses import dataclass, field
|
|
7
|
+
from pathlib import Path
|
|
8
|
+
|
|
9
|
+
|
|
10
|
+
@dataclass
|
|
11
|
+
class FunctionInfo:
|
|
12
|
+
"""Information about a function or method."""
|
|
13
|
+
|
|
14
|
+
name: str
|
|
15
|
+
lineno: int
|
|
16
|
+
is_method: bool = False
|
|
17
|
+
is_private: bool = False
|
|
18
|
+
|
|
19
|
+
@property
|
|
20
|
+
def display_name(self) -> str:
|
|
21
|
+
"""Name for display, with privacy indicator."""
|
|
22
|
+
return self.name
|
|
23
|
+
|
|
24
|
+
|
|
25
|
+
@dataclass
|
|
26
|
+
class ClassInfo:
|
|
27
|
+
"""Information about a class."""
|
|
28
|
+
|
|
29
|
+
name: str
|
|
30
|
+
lineno: int
|
|
31
|
+
methods: list[FunctionInfo] = field(default_factory=list)
|
|
32
|
+
is_private: bool = False
|
|
33
|
+
|
|
34
|
+
@property
|
|
35
|
+
def public_methods(self) -> list[FunctionInfo]:
|
|
36
|
+
"""Get non-private, non-dunder methods."""
|
|
37
|
+
return [
|
|
38
|
+
m for m in self.methods
|
|
39
|
+
if not m.name.startswith("_") or m.name.startswith("__") and m.name.endswith("__")
|
|
40
|
+
]
|
|
41
|
+
|
|
42
|
+
|
|
43
|
+
@dataclass
|
|
44
|
+
class ModuleSummary:
|
|
45
|
+
"""Summary of a Python module's contents."""
|
|
46
|
+
|
|
47
|
+
classes: list[ClassInfo] = field(default_factory=list)
|
|
48
|
+
functions: list[FunctionInfo] = field(default_factory=list)
|
|
49
|
+
imports: list[str] = field(default_factory=list)
|
|
50
|
+
parse_error: str | None = None
|
|
51
|
+
|
|
52
|
+
@property
|
|
53
|
+
def public_classes(self) -> list[ClassInfo]:
|
|
54
|
+
"""Get non-private classes."""
|
|
55
|
+
return [c for c in self.classes if not c.name.startswith("_")]
|
|
56
|
+
|
|
57
|
+
@property
|
|
58
|
+
def public_functions(self) -> list[FunctionInfo]:
|
|
59
|
+
"""Get non-private module-level functions."""
|
|
60
|
+
return [f for f in self.functions if not f.name.startswith("_")]
|
|
61
|
+
|
|
62
|
+
def format_summary(self, max_items: int = 8) -> str:
|
|
63
|
+
"""Format a concise summary for display.
|
|
64
|
+
|
|
65
|
+
Args:
|
|
66
|
+
max_items: Maximum items to show per category before truncating.
|
|
67
|
+
|
|
68
|
+
Returns:
|
|
69
|
+
Formatted summary string.
|
|
70
|
+
"""
|
|
71
|
+
if self.parse_error:
|
|
72
|
+
return f"_Could not parse: {self.parse_error}_"
|
|
73
|
+
|
|
74
|
+
lines = []
|
|
75
|
+
|
|
76
|
+
# Classes
|
|
77
|
+
pub_classes = self.public_classes
|
|
78
|
+
if pub_classes:
|
|
79
|
+
names = [c.name for c in pub_classes[:max_items]]
|
|
80
|
+
class_str = ", ".join(f"`{n}`" for n in names)
|
|
81
|
+
if len(pub_classes) > max_items:
|
|
82
|
+
class_str += f" _+{len(pub_classes) - max_items} more_"
|
|
83
|
+
lines.append(f"**Classes:** {class_str}")
|
|
84
|
+
|
|
85
|
+
# Functions
|
|
86
|
+
pub_funcs = self.public_functions
|
|
87
|
+
if pub_funcs:
|
|
88
|
+
names = [f.name for f in pub_funcs[:max_items]]
|
|
89
|
+
func_str = ", ".join(f"`{n}`" for n in names)
|
|
90
|
+
if len(pub_funcs) > max_items:
|
|
91
|
+
func_str += f" _+{len(pub_funcs) - max_items} more_"
|
|
92
|
+
lines.append(f"**Functions:** {func_str}")
|
|
93
|
+
|
|
94
|
+
if not lines:
|
|
95
|
+
return "_No public classes or functions_"
|
|
96
|
+
|
|
97
|
+
return "\n".join(lines)
|
|
98
|
+
|
|
99
|
+
|
|
100
|
+
def analyze_python_file(path: Path) -> ModuleSummary:
|
|
101
|
+
"""Analyze a Python file and extract its structure.
|
|
102
|
+
|
|
103
|
+
Args:
|
|
104
|
+
path: Path to the Python file.
|
|
105
|
+
|
|
106
|
+
Returns:
|
|
107
|
+
ModuleSummary with classes, functions, and imports.
|
|
108
|
+
"""
|
|
109
|
+
summary = ModuleSummary()
|
|
110
|
+
|
|
111
|
+
try:
|
|
112
|
+
source = path.read_text(encoding="utf-8")
|
|
113
|
+
tree = ast.parse(source, filename=str(path))
|
|
114
|
+
except SyntaxError as e:
|
|
115
|
+
summary.parse_error = f"Syntax error at line {e.lineno}"
|
|
116
|
+
return summary
|
|
117
|
+
except Exception as e:
|
|
118
|
+
summary.parse_error = str(e)
|
|
119
|
+
return summary
|
|
120
|
+
|
|
121
|
+
for node in ast.iter_child_nodes(tree):
|
|
122
|
+
if isinstance(node, ast.ClassDef):
|
|
123
|
+
class_info = ClassInfo(
|
|
124
|
+
name=node.name,
|
|
125
|
+
lineno=node.lineno,
|
|
126
|
+
is_private=node.name.startswith("_"),
|
|
127
|
+
)
|
|
128
|
+
|
|
129
|
+
# Extract methods
|
|
130
|
+
for item in node.body:
|
|
131
|
+
if isinstance(item, ast.FunctionDef | ast.AsyncFunctionDef):
|
|
132
|
+
class_info.methods.append(
|
|
133
|
+
FunctionInfo(
|
|
134
|
+
name=item.name,
|
|
135
|
+
lineno=item.lineno,
|
|
136
|
+
is_method=True,
|
|
137
|
+
is_private=item.name.startswith("_"),
|
|
138
|
+
)
|
|
139
|
+
)
|
|
140
|
+
|
|
141
|
+
summary.classes.append(class_info)
|
|
142
|
+
|
|
143
|
+
elif isinstance(node, ast.FunctionDef | ast.AsyncFunctionDef):
|
|
144
|
+
summary.functions.append(
|
|
145
|
+
FunctionInfo(
|
|
146
|
+
name=node.name,
|
|
147
|
+
lineno=node.lineno,
|
|
148
|
+
is_method=False,
|
|
149
|
+
is_private=node.name.startswith("_"),
|
|
150
|
+
)
|
|
151
|
+
)
|
|
152
|
+
|
|
153
|
+
elif isinstance(node, ast.Import):
|
|
154
|
+
for alias in node.names:
|
|
155
|
+
summary.imports.append(alias.name)
|
|
156
|
+
|
|
157
|
+
elif isinstance(node, ast.ImportFrom):
|
|
158
|
+
if node.module:
|
|
159
|
+
summary.imports.append(node.module)
|
|
160
|
+
|
|
161
|
+
return summary
|
|
162
|
+
|
|
163
|
+
|
|
164
|
+
def analyze_module(path: str, cwd: Path | None = None) -> ModuleSummary:
|
|
165
|
+
"""Analyze a module by path.
|
|
166
|
+
|
|
167
|
+
Args:
|
|
168
|
+
path: Relative path to the module.
|
|
169
|
+
cwd: Working directory (defaults to cwd).
|
|
170
|
+
|
|
171
|
+
Returns:
|
|
172
|
+
ModuleSummary for the module.
|
|
173
|
+
"""
|
|
174
|
+
if cwd is None:
|
|
175
|
+
cwd = Path.cwd()
|
|
176
|
+
|
|
177
|
+
full_path = cwd / path
|
|
178
|
+
|
|
179
|
+
if not full_path.exists():
|
|
180
|
+
return ModuleSummary(parse_error="File not found")
|
|
181
|
+
|
|
182
|
+
if not full_path.suffix == ".py":
|
|
183
|
+
return ModuleSummary(parse_error="Not a Python file")
|
|
184
|
+
|
|
185
|
+
return analyze_python_file(full_path)
|
glreview/claude.py
ADDED
|
@@ -0,0 +1,266 @@
|
|
|
1
|
+
"""Claude Code integration for AI-assisted reviews."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import shutil
|
|
6
|
+
import subprocess
|
|
7
|
+
from dataclasses import dataclass
|
|
8
|
+
from importlib.resources import files
|
|
9
|
+
from pathlib import Path
|
|
10
|
+
|
|
11
|
+
from jinja2 import Environment, BaseLoader
|
|
12
|
+
|
|
13
|
+
from .analyze import analyze_module
|
|
14
|
+
|
|
15
|
+
|
|
16
|
+
# Approximate tokens per character (conservative estimate)
|
|
17
|
+
CHARS_PER_TOKEN = 4
|
|
18
|
+
MAX_TOKENS = 100000 # Leave room for prompt and response
|
|
19
|
+
MAX_SOURCE_CHARS = MAX_TOKENS * CHARS_PER_TOKEN
|
|
20
|
+
|
|
21
|
+
|
|
22
|
+
@dataclass
|
|
23
|
+
class ChunkInfo:
|
|
24
|
+
"""Information about a code chunk for review."""
|
|
25
|
+
|
|
26
|
+
content: str
|
|
27
|
+
start_line: int
|
|
28
|
+
end_line: int
|
|
29
|
+
chunk_num: int
|
|
30
|
+
total_chunks: int
|
|
31
|
+
|
|
32
|
+
|
|
33
|
+
def claude_available() -> bool:
|
|
34
|
+
"""Check if claude CLI is available."""
|
|
35
|
+
return shutil.which("claude") is not None
|
|
36
|
+
|
|
37
|
+
|
|
38
|
+
def get_priority_description(priority: str) -> str:
|
|
39
|
+
"""Get human-readable priority description."""
|
|
40
|
+
descriptions = {
|
|
41
|
+
"critical": "Core functionality - requires thorough review",
|
|
42
|
+
"high": "Important module - careful review recommended",
|
|
43
|
+
"medium": "Standard module - normal review",
|
|
44
|
+
"low": "Low-risk module - basic review sufficient",
|
|
45
|
+
}
|
|
46
|
+
return descriptions.get(priority, "Standard review")
|
|
47
|
+
|
|
48
|
+
|
|
49
|
+
def chunk_source_code(source: str, max_chars: int = MAX_SOURCE_CHARS) -> list[ChunkInfo]:
|
|
50
|
+
"""Split source code into chunks if too large.
|
|
51
|
+
|
|
52
|
+
Tries to split on function/class boundaries when possible.
|
|
53
|
+
|
|
54
|
+
Args:
|
|
55
|
+
source: Source code string.
|
|
56
|
+
max_chars: Maximum characters per chunk.
|
|
57
|
+
|
|
58
|
+
Returns:
|
|
59
|
+
List of ChunkInfo objects.
|
|
60
|
+
"""
|
|
61
|
+
if len(source) <= max_chars:
|
|
62
|
+
lines = source.count('\n') + 1
|
|
63
|
+
return [ChunkInfo(
|
|
64
|
+
content=source,
|
|
65
|
+
start_line=1,
|
|
66
|
+
end_line=lines,
|
|
67
|
+
chunk_num=1,
|
|
68
|
+
total_chunks=1,
|
|
69
|
+
)]
|
|
70
|
+
|
|
71
|
+
# Split into lines
|
|
72
|
+
lines = source.split('\n')
|
|
73
|
+
chunks = []
|
|
74
|
+
current_chunk_lines = []
|
|
75
|
+
current_chunk_start = 1
|
|
76
|
+
current_chars = 0
|
|
77
|
+
|
|
78
|
+
for i, line in enumerate(lines, 1):
|
|
79
|
+
line_with_newline = line + '\n'
|
|
80
|
+
|
|
81
|
+
# Check if adding this line would exceed limit
|
|
82
|
+
if current_chars + len(line_with_newline) > max_chars and current_chunk_lines:
|
|
83
|
+
# Try to find a good break point (def, class, or blank line)
|
|
84
|
+
break_point = len(current_chunk_lines)
|
|
85
|
+
|
|
86
|
+
# Look back for a good break point
|
|
87
|
+
for j in range(len(current_chunk_lines) - 1, max(0, len(current_chunk_lines) - 50), -1):
|
|
88
|
+
check_line = current_chunk_lines[j].strip()
|
|
89
|
+
if (check_line.startswith('def ') or
|
|
90
|
+
check_line.startswith('class ') or
|
|
91
|
+
check_line.startswith('async def ') or
|
|
92
|
+
check_line == ''):
|
|
93
|
+
break_point = j
|
|
94
|
+
break
|
|
95
|
+
|
|
96
|
+
# Save current chunk
|
|
97
|
+
chunk_content = '\n'.join(current_chunk_lines[:break_point])
|
|
98
|
+
chunks.append(ChunkInfo(
|
|
99
|
+
content=chunk_content,
|
|
100
|
+
start_line=current_chunk_start,
|
|
101
|
+
end_line=current_chunk_start + break_point - 1,
|
|
102
|
+
chunk_num=len(chunks) + 1,
|
|
103
|
+
total_chunks=0, # Will update later
|
|
104
|
+
))
|
|
105
|
+
|
|
106
|
+
# Start new chunk with remaining lines
|
|
107
|
+
current_chunk_lines = current_chunk_lines[break_point:]
|
|
108
|
+
current_chunk_start = current_chunk_start + break_point
|
|
109
|
+
current_chars = sum(len(l) + 1 for l in current_chunk_lines)
|
|
110
|
+
|
|
111
|
+
current_chunk_lines.append(line)
|
|
112
|
+
current_chars += len(line_with_newline)
|
|
113
|
+
|
|
114
|
+
# Don't forget the last chunk
|
|
115
|
+
if current_chunk_lines:
|
|
116
|
+
chunks.append(ChunkInfo(
|
|
117
|
+
content='\n'.join(current_chunk_lines),
|
|
118
|
+
start_line=current_chunk_start,
|
|
119
|
+
end_line=current_chunk_start + len(current_chunk_lines) - 1,
|
|
120
|
+
chunk_num=len(chunks) + 1,
|
|
121
|
+
total_chunks=0,
|
|
122
|
+
))
|
|
123
|
+
|
|
124
|
+
# Update total_chunks
|
|
125
|
+
for chunk in chunks:
|
|
126
|
+
chunk.total_chunks = len(chunks)
|
|
127
|
+
|
|
128
|
+
return chunks
|
|
129
|
+
|
|
130
|
+
|
|
131
|
+
def build_review_prompt(
|
|
132
|
+
path: str,
|
|
133
|
+
source_code: str,
|
|
134
|
+
module_status,
|
|
135
|
+
config,
|
|
136
|
+
diff: str | None = None,
|
|
137
|
+
chunk_info: ChunkInfo | None = None,
|
|
138
|
+
) -> str:
|
|
139
|
+
"""Build the review prompt for Claude.
|
|
140
|
+
|
|
141
|
+
Args:
|
|
142
|
+
path: Module path.
|
|
143
|
+
source_code: Source code to review.
|
|
144
|
+
module_status: ModuleStatus object.
|
|
145
|
+
config: Config object.
|
|
146
|
+
diff: Diff since last review (for re-reviews).
|
|
147
|
+
chunk_info: Chunk information if reviewing in parts.
|
|
148
|
+
|
|
149
|
+
Returns:
|
|
150
|
+
Formatted prompt string.
|
|
151
|
+
"""
|
|
152
|
+
# Load template
|
|
153
|
+
template_file = files("glreview.templates").joinpath("claude_review_prompt.md")
|
|
154
|
+
template_content = template_file.read_text()
|
|
155
|
+
|
|
156
|
+
env = Environment(loader=BaseLoader())
|
|
157
|
+
template = env.from_string(template_content)
|
|
158
|
+
|
|
159
|
+
# Analyze module
|
|
160
|
+
summary = analyze_module(path, cwd=config.root)
|
|
161
|
+
module_contents = summary.format_summary()
|
|
162
|
+
|
|
163
|
+
# Determine if this is a re-review
|
|
164
|
+
is_rereview = (
|
|
165
|
+
module_status.last_reviewed_commit is not None and
|
|
166
|
+
module_status.status in ("in_progress", "reviewed")
|
|
167
|
+
)
|
|
168
|
+
|
|
169
|
+
# Build context
|
|
170
|
+
context = {
|
|
171
|
+
"path": path,
|
|
172
|
+
"lines": module_status.lines,
|
|
173
|
+
"priority": module_status.priority,
|
|
174
|
+
"priority_description": get_priority_description(module_status.priority),
|
|
175
|
+
"module_contents": module_contents,
|
|
176
|
+
"source_code": source_code,
|
|
177
|
+
"is_rereview": is_rereview,
|
|
178
|
+
"previous_commit": module_status.last_reviewed_commit,
|
|
179
|
+
"previous_date": module_status.last_reviewed_date or "unknown",
|
|
180
|
+
"previous_reviewers": ", ".join(module_status.reviewers) or "none",
|
|
181
|
+
"diff": diff,
|
|
182
|
+
}
|
|
183
|
+
|
|
184
|
+
prompt = template.render(**context)
|
|
185
|
+
|
|
186
|
+
# Add chunk info if applicable
|
|
187
|
+
if chunk_info and chunk_info.total_chunks > 1:
|
|
188
|
+
chunk_header = f"""
|
|
189
|
+
**NOTE: This is chunk {chunk_info.chunk_num} of {chunk_info.total_chunks}**
|
|
190
|
+
**Lines {chunk_info.start_line}-{chunk_info.end_line}**
|
|
191
|
+
|
|
192
|
+
Review this portion and note that you may not have full context from other parts of the file.
|
|
193
|
+
|
|
194
|
+
---
|
|
195
|
+
|
|
196
|
+
"""
|
|
197
|
+
# Insert after the first line
|
|
198
|
+
lines = prompt.split('\n', 1)
|
|
199
|
+
prompt = lines[0] + '\n' + chunk_header + lines[1]
|
|
200
|
+
|
|
201
|
+
return prompt
|
|
202
|
+
|
|
203
|
+
|
|
204
|
+
def run_claude_review(
|
|
205
|
+
prompt: str,
|
|
206
|
+
model: str = "sonnet",
|
|
207
|
+
) -> tuple[str, bool]:
|
|
208
|
+
"""Run Claude CLI with the review prompt.
|
|
209
|
+
|
|
210
|
+
Args:
|
|
211
|
+
prompt: The review prompt.
|
|
212
|
+
model: Claude model to use.
|
|
213
|
+
|
|
214
|
+
Returns:
|
|
215
|
+
Tuple of (response_text, success).
|
|
216
|
+
"""
|
|
217
|
+
if not claude_available():
|
|
218
|
+
return "Error: claude CLI not found. Install Claude Code first.", False
|
|
219
|
+
|
|
220
|
+
try:
|
|
221
|
+
# Run claude with --print to get output directly
|
|
222
|
+
result = subprocess.run(
|
|
223
|
+
["claude", "--print", "--model", model, prompt],
|
|
224
|
+
capture_output=True,
|
|
225
|
+
text=True,
|
|
226
|
+
timeout=300, # 5 minute timeout
|
|
227
|
+
)
|
|
228
|
+
|
|
229
|
+
if result.returncode != 0:
|
|
230
|
+
return f"Error running claude: {result.stderr}", False
|
|
231
|
+
|
|
232
|
+
return result.stdout, True
|
|
233
|
+
|
|
234
|
+
except subprocess.TimeoutExpired:
|
|
235
|
+
return "Error: Claude review timed out after 5 minutes.", False
|
|
236
|
+
except Exception as e:
|
|
237
|
+
return f"Error: {e}", False
|
|
238
|
+
|
|
239
|
+
|
|
240
|
+
def get_file_diff(
|
|
241
|
+
path: str,
|
|
242
|
+
since_commit: str,
|
|
243
|
+
cwd: Path | None = None,
|
|
244
|
+
) -> str | None:
|
|
245
|
+
"""Get diff for a file since a given commit.
|
|
246
|
+
|
|
247
|
+
Args:
|
|
248
|
+
path: File path.
|
|
249
|
+
since_commit: Commit to diff from.
|
|
250
|
+
cwd: Working directory.
|
|
251
|
+
|
|
252
|
+
Returns:
|
|
253
|
+
Diff string, or None if no diff or error.
|
|
254
|
+
"""
|
|
255
|
+
try:
|
|
256
|
+
result = subprocess.run(
|
|
257
|
+
["git", "diff", since_commit, "HEAD", "--", path],
|
|
258
|
+
cwd=cwd,
|
|
259
|
+
capture_output=True,
|
|
260
|
+
text=True,
|
|
261
|
+
)
|
|
262
|
+
if result.returncode == 0 and result.stdout.strip():
|
|
263
|
+
return result.stdout
|
|
264
|
+
except Exception:
|
|
265
|
+
pass
|
|
266
|
+
return None
|