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