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