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 ADDED
@@ -0,0 +1,8 @@
1
+ """glreview - GitLab code review tracking for scientific software."""
2
+
3
+ try:
4
+ from ._version import version as __version__
5
+ except ImportError:
6
+ __version__ = "0.0.0.dev0"
7
+
8
+ __all__ = ["__version__"]
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