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/gitlab.py ADDED
@@ -0,0 +1,398 @@
1
+ """GitLab integration via python-gitlab library."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import os
6
+ from dataclasses import dataclass
7
+ from functools import lru_cache
8
+
9
+ import gitlab
10
+ from gitlab.exceptions import GitlabAuthenticationError, GitlabGetError
11
+
12
+
13
+ @dataclass
14
+ class GitLabConfig:
15
+ """GitLab connection configuration."""
16
+
17
+ url: str
18
+ private_token: str | None = None
19
+ job_token: str | None = None
20
+ oauth_token: str | None = None
21
+
22
+ @classmethod
23
+ def from_environment(cls, host: str | None = None) -> "GitLabConfig":
24
+ """Create config from environment variables.
25
+
26
+ Checks (in order):
27
+ 1. CI_JOB_TOKEN (for CI pipelines)
28
+ 2. GITLAB_PRIVATE_TOKEN
29
+ 3. GITLAB_TOKEN (alias)
30
+ 4. GITLAB_OAUTH_TOKEN
31
+
32
+ Args:
33
+ host: GitLab host (e.g., "gitlab.com"). If None, uses GITLAB_URL env
34
+ or defaults to "gitlab.com".
35
+
36
+ Returns:
37
+ GitLabConfig instance.
38
+ """
39
+ url = os.environ.get("GITLAB_URL")
40
+ if not url:
41
+ url = f"https://{host}" if host else "https://gitlab.com"
42
+
43
+ return cls(
44
+ url=url,
45
+ job_token=os.environ.get("CI_JOB_TOKEN"),
46
+ private_token=os.environ.get("GITLAB_PRIVATE_TOKEN")
47
+ or os.environ.get("GITLAB_TOKEN"),
48
+ oauth_token=os.environ.get("GITLAB_OAUTH_TOKEN"),
49
+ )
50
+
51
+ @property
52
+ def is_authenticated(self) -> bool:
53
+ """Check if any authentication token is configured."""
54
+ return bool(self.private_token or self.job_token or self.oauth_token)
55
+
56
+
57
+ @lru_cache(maxsize=1)
58
+ def _get_gitlab_client(url: str, token: str | None, job_token: str | None) -> gitlab.Gitlab:
59
+ """Get a cached GitLab client instance."""
60
+ if job_token:
61
+ return gitlab.Gitlab(url, job_token=job_token)
62
+ elif token:
63
+ return gitlab.Gitlab(url, private_token=token)
64
+ else:
65
+ return gitlab.Gitlab(url)
66
+
67
+
68
+ def get_gitlab_client(config: GitLabConfig | None = None) -> gitlab.Gitlab | None:
69
+ """Get a GitLab client instance.
70
+
71
+ Args:
72
+ config: GitLab configuration. If None, reads from environment.
73
+
74
+ Returns:
75
+ Authenticated GitLab client, or None if not configured.
76
+ """
77
+ if config is None:
78
+ config = GitLabConfig.from_environment()
79
+
80
+ if not config.is_authenticated:
81
+ return None
82
+
83
+ try:
84
+ gl = _get_gitlab_client(
85
+ config.url,
86
+ config.private_token or config.oauth_token,
87
+ config.job_token,
88
+ )
89
+ gl.auth()
90
+ return gl
91
+ except GitlabAuthenticationError:
92
+ return None
93
+
94
+
95
+ def gitlab_available(host: str | None = None) -> bool:
96
+ """Check if GitLab authentication is available.
97
+
98
+ Args:
99
+ host: GitLab host to check against.
100
+
101
+ Returns:
102
+ True if authenticated successfully.
103
+ """
104
+ config = GitLabConfig.from_environment(host)
105
+ if not config.is_authenticated:
106
+ return False
107
+
108
+ client = get_gitlab_client(config)
109
+ return client is not None
110
+
111
+
112
+ @dataclass
113
+ class GitLabIssue:
114
+ """Represents a GitLab issue."""
115
+
116
+ number: str
117
+ url: str
118
+ title: str
119
+ state: str
120
+ assignees: list[str]
121
+ labels: list[str]
122
+
123
+ @classmethod
124
+ def from_api_response(cls, issue) -> "GitLabIssue":
125
+ """Create from python-gitlab issue object."""
126
+ return cls(
127
+ number=str(issue.iid),
128
+ url=issue.web_url,
129
+ title=issue.title,
130
+ state=issue.state,
131
+ assignees=[a["username"] for a in issue.assignees] if issue.assignees else [],
132
+ labels=issue.labels or [],
133
+ )
134
+
135
+
136
+ @dataclass
137
+ class GitLabMember:
138
+ """Represents a GitLab project member."""
139
+
140
+ username: str
141
+ name: str
142
+ access_level: int
143
+
144
+ @property
145
+ def access_level_name(self) -> str:
146
+ """Human-readable access level."""
147
+ levels = {
148
+ 50: "Owner",
149
+ 40: "Maintainer",
150
+ 30: "Developer",
151
+ 20: "Reporter",
152
+ 10: "Guest",
153
+ }
154
+ return levels.get(self.access_level, f"Level {self.access_level}")
155
+
156
+ @classmethod
157
+ def from_api_response(cls, member) -> "GitLabMember":
158
+ """Create from python-gitlab member object."""
159
+ return cls(
160
+ username=member.username,
161
+ name=member.name,
162
+ access_level=member.access_level,
163
+ )
164
+
165
+
166
+ def _get_project(gl: gitlab.Gitlab, project_path: str):
167
+ """Get a project by path.
168
+
169
+ Args:
170
+ gl: GitLab client.
171
+ project_path: Project path (e.g., "user/project").
172
+
173
+ Returns:
174
+ Project object or None.
175
+ """
176
+ try:
177
+ return gl.projects.get(project_path)
178
+ except GitlabGetError:
179
+ return None
180
+
181
+
182
+ def create_issue(
183
+ title: str,
184
+ description: str,
185
+ project_path: str,
186
+ host: str | None = None,
187
+ labels: list[str] | None = None,
188
+ assignee: str | None = None,
189
+ ) -> GitLabIssue | None:
190
+ """Create a GitLab issue.
191
+
192
+ Args:
193
+ title: Issue title.
194
+ description: Issue body (markdown).
195
+ project_path: Project path (e.g., "user/project").
196
+ host: GitLab host.
197
+ labels: Labels to apply.
198
+ assignee: Username to assign (without @).
199
+
200
+ Returns:
201
+ Created issue, or None on failure.
202
+ """
203
+ config = GitLabConfig.from_environment(host)
204
+ gl = get_gitlab_client(config)
205
+
206
+ if not gl:
207
+ return None
208
+
209
+ project = _get_project(gl, project_path)
210
+ if not project:
211
+ return None
212
+
213
+ issue_data = {
214
+ "title": title,
215
+ "description": description,
216
+ }
217
+
218
+ if labels:
219
+ issue_data["labels"] = labels
220
+
221
+ if assignee:
222
+ # Look up user ID from username
223
+ username = assignee.lstrip("@")
224
+ try:
225
+ users = gl.users.list(username=username)
226
+ if users:
227
+ issue_data["assignee_ids"] = [users[0].id]
228
+ except Exception:
229
+ pass # Continue without assignee if lookup fails
230
+
231
+ try:
232
+ issue = project.issues.create(issue_data)
233
+ return GitLabIssue.from_api_response(issue)
234
+ except Exception:
235
+ return None
236
+
237
+
238
+ def get_issue(
239
+ issue_number: str,
240
+ project_path: str,
241
+ host: str | None = None,
242
+ ) -> GitLabIssue | None:
243
+ """Get details for an issue.
244
+
245
+ Args:
246
+ issue_number: Issue number (without #).
247
+ project_path: Project path (e.g., "user/project").
248
+ host: GitLab host.
249
+
250
+ Returns:
251
+ Issue details, or None if not found.
252
+ """
253
+ config = GitLabConfig.from_environment(host)
254
+ gl = get_gitlab_client(config)
255
+
256
+ if not gl:
257
+ return None
258
+
259
+ project = _get_project(gl, project_path)
260
+ if not project:
261
+ return None
262
+
263
+ try:
264
+ issue = project.issues.get(int(issue_number))
265
+ return GitLabIssue.from_api_response(issue)
266
+ except Exception:
267
+ return None
268
+
269
+
270
+ def get_project_members(
271
+ project_path: str,
272
+ host: str | None = None,
273
+ ) -> list[GitLabMember]:
274
+ """Get all project members.
275
+
276
+ Args:
277
+ project_path: Project path (e.g., "user/project").
278
+ host: GitLab host.
279
+
280
+ Returns:
281
+ List of members, empty list on failure.
282
+ """
283
+ config = GitLabConfig.from_environment(host)
284
+ gl = get_gitlab_client(config)
285
+
286
+ if not gl:
287
+ return []
288
+
289
+ project = _get_project(gl, project_path)
290
+ if not project:
291
+ return []
292
+
293
+ try:
294
+ # Get all members including inherited
295
+ members = project.members_all.list(all=True)
296
+ return [GitLabMember.from_api_response(m) for m in members]
297
+ except Exception:
298
+ return []
299
+
300
+
301
+ def close_issue(
302
+ issue_number: str,
303
+ project_path: str,
304
+ host: str | None = None,
305
+ comment: str | None = None,
306
+ ) -> bool:
307
+ """Close a GitLab issue.
308
+
309
+ Args:
310
+ issue_number: Issue number (without #).
311
+ project_path: Project path (e.g., "user/project").
312
+ host: GitLab host.
313
+ comment: Optional comment to add before closing.
314
+
315
+ Returns:
316
+ True if closed successfully, False otherwise.
317
+ """
318
+ config = GitLabConfig.from_environment(host)
319
+ gl = get_gitlab_client(config)
320
+
321
+ if not gl:
322
+ return False
323
+
324
+ project = _get_project(gl, project_path)
325
+ if not project:
326
+ return False
327
+
328
+ try:
329
+ issue = project.issues.get(int(issue_number))
330
+
331
+ # Add comment if provided
332
+ if comment:
333
+ issue.notes.create({"body": comment})
334
+
335
+ # Close the issue
336
+ issue.state_event = "close"
337
+ issue.save()
338
+ return True
339
+ except Exception:
340
+ return False
341
+
342
+
343
+ def add_issue_comment(
344
+ issue_number: str,
345
+ project_path: str,
346
+ comment: str,
347
+ host: str | None = None,
348
+ ) -> bool:
349
+ """Add a comment to a GitLab issue.
350
+
351
+ Args:
352
+ issue_number: Issue number (without #).
353
+ project_path: Project path (e.g., "user/project").
354
+ comment: Comment text.
355
+ host: GitLab host.
356
+
357
+ Returns:
358
+ True if comment added successfully, False otherwise.
359
+ """
360
+ config = GitLabConfig.from_environment(host)
361
+ gl = get_gitlab_client(config)
362
+
363
+ if not gl:
364
+ return False
365
+
366
+ project = _get_project(gl, project_path)
367
+ if not project:
368
+ return False
369
+
370
+ try:
371
+ issue = project.issues.get(int(issue_number))
372
+ issue.notes.create({"body": comment})
373
+ return True
374
+ except Exception:
375
+ return False
376
+
377
+
378
+ def get_compare_url(
379
+ project: str,
380
+ host: str,
381
+ from_commit: str,
382
+ to_commit: str,
383
+ ) -> str:
384
+ """Generate a GitLab compare URL.
385
+
386
+ Args:
387
+ project: Project path (e.g., "user/project").
388
+ host: GitLab host (e.g., "gitlab.com").
389
+ from_commit: Base commit SHA.
390
+ to_commit: Head commit SHA.
391
+
392
+ Returns:
393
+ Compare URL.
394
+ """
395
+ return (
396
+ f"https://{host}/{project}/-/compare/"
397
+ f"{from_commit}...{to_commit}?from_project_id=&straight=false"
398
+ )
glreview/registry.py ADDED
@@ -0,0 +1,179 @@
1
+ """Registry for tracking review status of modules."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import json
6
+ from dataclasses import dataclass, field, asdict
7
+ from datetime import datetime, timezone
8
+ from pathlib import Path
9
+ from typing import Iterator
10
+
11
+ SCHEMA_VERSION = "1"
12
+
13
+
14
+ @dataclass
15
+ class ModuleStatus:
16
+ """Review status for a single module."""
17
+
18
+ path: str
19
+ status: str = "needs_review" # needs_review, in_progress, reviewed, needs_update
20
+ priority: str = "medium"
21
+ lines: int = 0
22
+ reviewers_required: int = 1
23
+ last_reviewed_commit: str | None = None
24
+ last_reviewed_date: str | None = None
25
+ reviewers: list[str] = field(default_factory=list)
26
+ review_issue: str | None = None
27
+ assigned_reviewer: str | None = None
28
+ review_started_commit: str | None = None # Commit when review was started
29
+ notes: str = ""
30
+
31
+ def to_dict(self) -> dict:
32
+ """Convert to dictionary for JSON serialization."""
33
+ return {k: v for k, v in asdict(self).items() if k != "path"}
34
+
35
+ @classmethod
36
+ def from_dict(cls, path: str, data: dict) -> "ModuleStatus":
37
+ """Create from dictionary."""
38
+ return cls(
39
+ path=path,
40
+ status=data.get("status", "needs_review"),
41
+ priority=data.get("priority", "medium"),
42
+ lines=data.get("lines", 0),
43
+ reviewers_required=data.get("reviewers_required", 1),
44
+ last_reviewed_commit=data.get("last_reviewed_commit"),
45
+ last_reviewed_date=data.get("last_reviewed_date"),
46
+ reviewers=data.get("reviewers", []),
47
+ review_issue=data.get("review_issue"),
48
+ assigned_reviewer=data.get("assigned_reviewer"),
49
+ review_started_commit=data.get("review_started_commit"),
50
+ notes=data.get("notes", ""),
51
+ )
52
+
53
+
54
+ @dataclass
55
+ class Registry:
56
+ """Registry of module review statuses."""
57
+
58
+ modules: dict[str, ModuleStatus] = field(default_factory=dict)
59
+ last_updated: str | None = None
60
+ current_commit: str | None = None
61
+
62
+ def get(self, path: str) -> ModuleStatus | None:
63
+ """Get the status for a module."""
64
+ return self.modules.get(path)
65
+
66
+ def set(self, status: ModuleStatus) -> None:
67
+ """Set the status for a module."""
68
+ self.modules[status.path] = status
69
+
70
+ def remove(self, path: str) -> bool:
71
+ """Remove a module from the registry. Returns True if it existed."""
72
+ if path in self.modules:
73
+ del self.modules[path]
74
+ return True
75
+ return False
76
+
77
+ def __iter__(self) -> Iterator[ModuleStatus]:
78
+ """Iterate over all module statuses."""
79
+ return iter(self.modules.values())
80
+
81
+ def __len__(self) -> int:
82
+ """Return the number of modules in the registry."""
83
+ return len(self.modules)
84
+
85
+ def filter(
86
+ self,
87
+ status: str | None = None,
88
+ priority: str | None = None,
89
+ ) -> Iterator[ModuleStatus]:
90
+ """Filter modules by status and/or priority."""
91
+ for module in self:
92
+ if status and module.status != status:
93
+ continue
94
+ if priority and module.priority != priority:
95
+ continue
96
+ yield module
97
+
98
+ @property
99
+ def summary(self) -> dict[str, int]:
100
+ """Get a summary of review statuses."""
101
+ counts = {
102
+ "total": 0,
103
+ "reviewed": 0,
104
+ "needs_review": 0,
105
+ "in_progress": 0,
106
+ "needs_update": 0,
107
+ "total_lines": 0,
108
+ "reviewed_lines": 0,
109
+ }
110
+ for module in self:
111
+ counts["total"] += 1
112
+ counts["total_lines"] += module.lines
113
+
114
+ if module.status == "reviewed":
115
+ counts["reviewed"] += 1
116
+ counts["reviewed_lines"] += module.lines
117
+ elif module.status == "in_progress":
118
+ counts["in_progress"] += 1
119
+ elif module.status in ("needs_review", "needs_update"):
120
+ counts[module.status] += 1
121
+
122
+ return counts
123
+
124
+ def to_dict(self) -> dict:
125
+ """Convert to dictionary for JSON serialization."""
126
+ return {
127
+ "version": SCHEMA_VERSION,
128
+ "last_updated": self.last_updated,
129
+ "current_commit": self.current_commit,
130
+ "modules": {path: mod.to_dict() for path, mod in self.modules.items()},
131
+ }
132
+
133
+ @classmethod
134
+ def from_dict(cls, data: dict) -> "Registry":
135
+ """Create from dictionary."""
136
+ modules = {}
137
+ for path, mod_data in data.get("modules", {}).items():
138
+ modules[path] = ModuleStatus.from_dict(path, mod_data)
139
+
140
+ return cls(
141
+ modules=modules,
142
+ last_updated=data.get("last_updated"),
143
+ current_commit=data.get("current_commit"),
144
+ )
145
+
146
+
147
+ def load_registry(path: Path) -> Registry:
148
+ """Load registry from a JSON file.
149
+
150
+ Args:
151
+ path: Path to the registry file.
152
+
153
+ Returns:
154
+ Registry object. Returns empty registry if file doesn't exist.
155
+ """
156
+ if not path.exists():
157
+ return Registry()
158
+
159
+ with open(path) as f:
160
+ data = json.load(f)
161
+
162
+ return Registry.from_dict(data)
163
+
164
+
165
+ def save_registry(registry: Registry, path: Path, commit: str | None = None) -> None:
166
+ """Save registry to a JSON file.
167
+
168
+ Args:
169
+ registry: Registry to save.
170
+ path: Path to write to.
171
+ commit: Current git commit SHA to record.
172
+ """
173
+ registry.last_updated = datetime.now(timezone.utc).isoformat()
174
+ if commit:
175
+ registry.current_commit = commit
176
+
177
+ with open(path, "w") as f:
178
+ json.dump(registry.to_dict(), f, indent=2)
179
+ f.write("\n")