gitacross 1.0.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.
- gitacross/__init__.py +34 -0
- gitacross/__main__.py +6 -0
- gitacross/config.py +218 -0
- gitacross/git.py +342 -0
- gitacross/gitea.py +9 -0
- gitacross/github.py +9 -0
- gitacross/linter.py +1105 -0
- gitacross/main.py +360 -0
- gitacross/providers/__init__.py +44 -0
- gitacross/providers/base.py +230 -0
- gitacross/providers/gitea.py +170 -0
- gitacross/providers/github.py +120 -0
- gitacross/renderer.py +195 -0
- gitacross/retry.py +23 -0
- gitacross/source.py +297 -0
- gitacross/state.py +67 -0
- gitacross/target.py +159 -0
- gitacross-1.0.0.dist-info/METADATA +318 -0
- gitacross-1.0.0.dist-info/RECORD +23 -0
- gitacross-1.0.0.dist-info/WHEEL +5 -0
- gitacross-1.0.0.dist-info/entry_points.txt +2 -0
- gitacross-1.0.0.dist-info/licenses/LICENSE +21 -0
- gitacross-1.0.0.dist-info/top_level.txt +1 -0
gitacross/main.py
ADDED
|
@@ -0,0 +1,360 @@
|
|
|
1
|
+
#!/usr/bin/env python3
|
|
2
|
+
"""GitAcross – mirror releases from any configured source to any target.
|
|
3
|
+
|
|
4
|
+
Usage:
|
|
5
|
+
gitacross --config config.yml [--project NAME] [--dry-run]
|
|
6
|
+
python -m gitacross --config config.yml [--project NAME] [--dry-run]
|
|
7
|
+
"""
|
|
8
|
+
|
|
9
|
+
import argparse
|
|
10
|
+
from datetime import datetime
|
|
11
|
+
import fnmatch
|
|
12
|
+
import logging
|
|
13
|
+
import shutil
|
|
14
|
+
import sys
|
|
15
|
+
import tempfile
|
|
16
|
+
from pathlib import Path
|
|
17
|
+
|
|
18
|
+
from .config import Config
|
|
19
|
+
from .renderer import apply_operations
|
|
20
|
+
from .retry import retry
|
|
21
|
+
from .source import create_source
|
|
22
|
+
from .state import State
|
|
23
|
+
from .target import create_target
|
|
24
|
+
|
|
25
|
+
logger = logging.getLogger(__name__)
|
|
26
|
+
|
|
27
|
+
|
|
28
|
+
def _setup_logging(verbose):
|
|
29
|
+
level = logging.DEBUG if verbose else logging.INFO
|
|
30
|
+
logging.basicConfig(
|
|
31
|
+
level=level,
|
|
32
|
+
format="%(asctime)s [%(levelname)s] %(message)s",
|
|
33
|
+
datefmt="%H:%M:%S",
|
|
34
|
+
)
|
|
35
|
+
|
|
36
|
+
|
|
37
|
+
def _clean_text(text):
|
|
38
|
+
"""Normalize text by converting escaped newlines (\\r\\n, \\n) into actual linebreaks."""
|
|
39
|
+
if not text or not isinstance(text, str):
|
|
40
|
+
return text or ""
|
|
41
|
+
if r"\n" in text or r"\r\n" in text:
|
|
42
|
+
return text.replace(r"\r\n", "\n").replace(r"\n", "\n")
|
|
43
|
+
return text
|
|
44
|
+
|
|
45
|
+
|
|
46
|
+
def _matches_asset_filter(asset_name, filter_spec):
|
|
47
|
+
"""Check if an asset name matches the filter specification."""
|
|
48
|
+
if not filter_spec:
|
|
49
|
+
return False
|
|
50
|
+
if filter_spec is True:
|
|
51
|
+
return True
|
|
52
|
+
if isinstance(filter_spec, str):
|
|
53
|
+
return fnmatch.fnmatch(asset_name, filter_spec)
|
|
54
|
+
if isinstance(filter_spec, (list, tuple, set)):
|
|
55
|
+
return any(fnmatch.fnmatch(asset_name, pat) for pat in filter_spec)
|
|
56
|
+
return False
|
|
57
|
+
|
|
58
|
+
|
|
59
|
+
def _sync_release_assets(
|
|
60
|
+
source,
|
|
61
|
+
target,
|
|
62
|
+
rel,
|
|
63
|
+
tag,
|
|
64
|
+
target_release,
|
|
65
|
+
sync_assets,
|
|
66
|
+
tmpdir,
|
|
67
|
+
retry_max=3,
|
|
68
|
+
retry_backoff=2,
|
|
69
|
+
dry_run=False,
|
|
70
|
+
stream_assets=False,
|
|
71
|
+
):
|
|
72
|
+
"""Sync release assets (prebuilts/files) from source to target."""
|
|
73
|
+
assets = rel.get("assets")
|
|
74
|
+
if assets is None and hasattr(source, "list_release_assets"):
|
|
75
|
+
assets = source.list_release_assets(rel.get("id") or tag)
|
|
76
|
+
assets = assets or []
|
|
77
|
+
|
|
78
|
+
matching = [
|
|
79
|
+
a for a in assets if _matches_asset_filter(a.get("name", ""), sync_assets)
|
|
80
|
+
]
|
|
81
|
+
if not matching:
|
|
82
|
+
logger.debug("No matching release assets to sync for %s", tag)
|
|
83
|
+
return
|
|
84
|
+
|
|
85
|
+
logger.info("Found %d matching asset(s) to sync for release %s", len(matching), tag)
|
|
86
|
+
|
|
87
|
+
for asset in matching:
|
|
88
|
+
name = asset.get("name")
|
|
89
|
+
size = asset.get("size", 0)
|
|
90
|
+
if dry_run:
|
|
91
|
+
logger.info(
|
|
92
|
+
"[DRY-RUN] Would sync release asset: %s (%s bytes)", name, size
|
|
93
|
+
)
|
|
94
|
+
continue
|
|
95
|
+
|
|
96
|
+
asset_dir = tmpdir / "assets"
|
|
97
|
+
asset_dir.mkdir(parents=True, exist_ok=True)
|
|
98
|
+
asset_path = asset_dir / name
|
|
99
|
+
|
|
100
|
+
logger.info("Downloading asset %s (%s bytes) from source", name, size)
|
|
101
|
+
retry(
|
|
102
|
+
lambda: source.download_asset(asset, asset_path),
|
|
103
|
+
max_attempts=retry_max,
|
|
104
|
+
backoff_seconds=retry_backoff,
|
|
105
|
+
)
|
|
106
|
+
|
|
107
|
+
logger.info(
|
|
108
|
+
"Uploading asset %s to target release %s%s",
|
|
109
|
+
name, tag, " (streaming)" if stream_assets else "",
|
|
110
|
+
)
|
|
111
|
+
target.upload_release_asset(
|
|
112
|
+
tag, asset_path, name=name, release=target_release, stream=stream_assets
|
|
113
|
+
)
|
|
114
|
+
|
|
115
|
+
|
|
116
|
+
def sync_project(project, state, dry_run=False):
|
|
117
|
+
"""Sync all new releases from project.source to project.target."""
|
|
118
|
+
logger.info(
|
|
119
|
+
"Syncing project: %s (%s → %s)",
|
|
120
|
+
project.name,
|
|
121
|
+
project.source.type,
|
|
122
|
+
project.target.type,
|
|
123
|
+
)
|
|
124
|
+
|
|
125
|
+
cache_dir = Path(".gitsync") / "cache"
|
|
126
|
+
source = create_source(project.source, cache_dir)
|
|
127
|
+
target = create_target(
|
|
128
|
+
project.target,
|
|
129
|
+
cache_dir,
|
|
130
|
+
retry_max=project.retry.max_attempts,
|
|
131
|
+
retry_backoff=project.retry.backoff_seconds,
|
|
132
|
+
author=project.renderer.author,
|
|
133
|
+
)
|
|
134
|
+
|
|
135
|
+
# Fetch releases from source
|
|
136
|
+
all_releases = source.fetch_releases()
|
|
137
|
+
new_releases = [
|
|
138
|
+
r for r in all_releases
|
|
139
|
+
if not state.has_release(
|
|
140
|
+
project.name,
|
|
141
|
+
# Commit mode: key by SHA; release/tag mode: key by tag name
|
|
142
|
+
r["commit_sha"] if r.get("tag_name") is None else r["tag_name"],
|
|
143
|
+
)
|
|
144
|
+
]
|
|
145
|
+
|
|
146
|
+
if not new_releases:
|
|
147
|
+
logger.info("No new releases to sync")
|
|
148
|
+
return
|
|
149
|
+
|
|
150
|
+
logger.info("Found %d new release(s)", len(new_releases))
|
|
151
|
+
|
|
152
|
+
# Setup target branch
|
|
153
|
+
target.setup(project.target.branch)
|
|
154
|
+
|
|
155
|
+
for rel in new_releases:
|
|
156
|
+
tag = rel["tag_name"]
|
|
157
|
+
commit_mode = tag is None # commit mode: no tag, keyed by SHA
|
|
158
|
+
state_key = rel["commit_sha"] if commit_mode else tag
|
|
159
|
+
short_sha = (rel.get("commit_sha") or "")[:12]
|
|
160
|
+
|
|
161
|
+
if commit_mode:
|
|
162
|
+
logger.info("Processing commit snapshot: %s", short_sha)
|
|
163
|
+
else:
|
|
164
|
+
logger.info("Processing release: %s", tag)
|
|
165
|
+
|
|
166
|
+
tmpdir = Path(tempfile.mkdtemp(prefix="gitsync-"))
|
|
167
|
+
try:
|
|
168
|
+
# Export source release
|
|
169
|
+
if hasattr(source, "export_release"):
|
|
170
|
+
source.export_release(rel, tmpdir)
|
|
171
|
+
else:
|
|
172
|
+
source.export_tag(tag, tmpdir)
|
|
173
|
+
logger.debug(
|
|
174
|
+
"Exported %s to work directory",
|
|
175
|
+
f"commit {short_sha}" if commit_mode else f"release {tag}",
|
|
176
|
+
)
|
|
177
|
+
|
|
178
|
+
# Apply render pipeline
|
|
179
|
+
apply_operations(tmpdir, project)
|
|
180
|
+
|
|
181
|
+
if dry_run:
|
|
182
|
+
files = sorted(tmpdir.rglob("*"))
|
|
183
|
+
logger.info(
|
|
184
|
+
"[DRY-RUN] Would commit and push: %s (%d files)",
|
|
185
|
+
f"commit {short_sha}" if commit_mode else f"Release {tag}",
|
|
186
|
+
len(files),
|
|
187
|
+
)
|
|
188
|
+
if project.sync_assets:
|
|
189
|
+
_sync_release_assets(
|
|
190
|
+
source=source,
|
|
191
|
+
target=target,
|
|
192
|
+
rel=rel,
|
|
193
|
+
tag=tag,
|
|
194
|
+
target_release=None,
|
|
195
|
+
sync_assets=project.sync_assets,
|
|
196
|
+
tmpdir=tmpdir,
|
|
197
|
+
retry_max=project.retry.max_attempts,
|
|
198
|
+
retry_backoff=project.retry.backoff_seconds,
|
|
199
|
+
dry_run=True,
|
|
200
|
+
stream_assets=project.stream_assets,
|
|
201
|
+
)
|
|
202
|
+
continue
|
|
203
|
+
|
|
204
|
+
# Commit to target (linear history on target branch)
|
|
205
|
+
# Use the source release/commit date so commits appear chronologically
|
|
206
|
+
source_date = rel.get("source_date") or rel.get("published_at") or ""
|
|
207
|
+
commit_message = (
|
|
208
|
+
f"Sync commit {short_sha}" if commit_mode else f"Release {tag}"
|
|
209
|
+
)
|
|
210
|
+
target.commit(tmpdir, commit_message, date=source_date)
|
|
211
|
+
|
|
212
|
+
target_release = None
|
|
213
|
+
if not commit_mode:
|
|
214
|
+
# Annotated tag
|
|
215
|
+
target.tag(tag, f"Release {tag}")
|
|
216
|
+
|
|
217
|
+
# Push (no-op for local targets)
|
|
218
|
+
target.push(project.target.branch, tag)
|
|
219
|
+
|
|
220
|
+
# Create release on target platform (no-op for local targets)
|
|
221
|
+
release_name = _clean_text(rel.get("name") or tag)
|
|
222
|
+
release_body = (
|
|
223
|
+
_clean_text(rel.get("body", ""))
|
|
224
|
+
if project.preserve_description
|
|
225
|
+
else ""
|
|
226
|
+
)
|
|
227
|
+
target_release = target.create_release(
|
|
228
|
+
tag=tag,
|
|
229
|
+
name=release_name,
|
|
230
|
+
body=release_body,
|
|
231
|
+
prerelease=rel.get("prerelease", False),
|
|
232
|
+
)
|
|
233
|
+
else:
|
|
234
|
+
# Commit mode: push the branch only (no tag, no release)
|
|
235
|
+
target.push(project.target.branch)
|
|
236
|
+
|
|
237
|
+
# Sync release assets/packages (prebuilts)
|
|
238
|
+
if project.sync_assets and not commit_mode:
|
|
239
|
+
_sync_release_assets(
|
|
240
|
+
source=source,
|
|
241
|
+
target=target,
|
|
242
|
+
rel=rel,
|
|
243
|
+
tag=tag,
|
|
244
|
+
target_release=target_release,
|
|
245
|
+
sync_assets=project.sync_assets,
|
|
246
|
+
tmpdir=tmpdir,
|
|
247
|
+
retry_max=project.retry.max_attempts,
|
|
248
|
+
retry_backoff=project.retry.backoff_seconds,
|
|
249
|
+
dry_run=False,
|
|
250
|
+
stream_assets=project.stream_assets,
|
|
251
|
+
)
|
|
252
|
+
|
|
253
|
+
# Persist state (commit mode: keyed by SHA; release/tag: by tag name)
|
|
254
|
+
source_commit = rel.get("commit_sha") or ""
|
|
255
|
+
if not source_commit and not commit_mode and hasattr(source, "resolve_commit"):
|
|
256
|
+
source_commit = source.resolve_commit(tag)
|
|
257
|
+
|
|
258
|
+
state.add_release(
|
|
259
|
+
project.name,
|
|
260
|
+
state_key,
|
|
261
|
+
{
|
|
262
|
+
"tag": tag,
|
|
263
|
+
"source_commit": source_commit,
|
|
264
|
+
"target_commit": target.head_sha(),
|
|
265
|
+
"source_date": source_date,
|
|
266
|
+
"sync_date": datetime.now().astimezone().isoformat(),
|
|
267
|
+
},
|
|
268
|
+
)
|
|
269
|
+
state.save()
|
|
270
|
+
|
|
271
|
+
logger.info(
|
|
272
|
+
"Synced %s (target commit %s)",
|
|
273
|
+
f"commit {short_sha}" if commit_mode else f"release {tag}",
|
|
274
|
+
target.head_sha(),
|
|
275
|
+
)
|
|
276
|
+
|
|
277
|
+
except Exception:
|
|
278
|
+
logger.exception("Failed to sync release %s", tag)
|
|
279
|
+
raise
|
|
280
|
+
finally:
|
|
281
|
+
shutil.rmtree(tmpdir, ignore_errors=True)
|
|
282
|
+
|
|
283
|
+
|
|
284
|
+
def main():
|
|
285
|
+
parser = argparse.ArgumentParser(description="Sync releases from source to target")
|
|
286
|
+
parser.add_argument("--config", required=True, help="Path to config.yml")
|
|
287
|
+
parser.add_argument("--project", help="Sync only this project (by name)")
|
|
288
|
+
parser.add_argument(
|
|
289
|
+
"--dry-run", action="store_true", help="Print changes without pushing"
|
|
290
|
+
)
|
|
291
|
+
parser.add_argument(
|
|
292
|
+
"--reset",
|
|
293
|
+
action="store_true",
|
|
294
|
+
help="Clear state and cache before running (fresh start)",
|
|
295
|
+
)
|
|
296
|
+
parser.add_argument(
|
|
297
|
+
"--lint",
|
|
298
|
+
action="store_true",
|
|
299
|
+
help="Lint config file for YAML errors, invalid settings, and redundant options",
|
|
300
|
+
)
|
|
301
|
+
parser.add_argument(
|
|
302
|
+
"--fix",
|
|
303
|
+
action="store_true",
|
|
304
|
+
help="Automatically fix misplaced keys and remove redundant options in config file",
|
|
305
|
+
)
|
|
306
|
+
parser.add_argument("-v", "--verbose", action="store_true", help="Debug logging")
|
|
307
|
+
args = parser.parse_args()
|
|
308
|
+
|
|
309
|
+
_setup_logging(args.verbose)
|
|
310
|
+
|
|
311
|
+
config_path = Path(args.config)
|
|
312
|
+
if not config_path.exists():
|
|
313
|
+
sys.exit(f"Config file not found: {config_path}")
|
|
314
|
+
|
|
315
|
+
if args.fix:
|
|
316
|
+
from .linter import fix_config, lint_config
|
|
317
|
+
|
|
318
|
+
fix_report = fix_config(config_path)
|
|
319
|
+
if not fix_report.is_valid:
|
|
320
|
+
sys.exit(1)
|
|
321
|
+
if args.lint:
|
|
322
|
+
print()
|
|
323
|
+
lint_report = lint_config(config_path)
|
|
324
|
+
sys.exit(0 if lint_report.is_valid else 1)
|
|
325
|
+
sys.exit(0)
|
|
326
|
+
|
|
327
|
+
if args.lint:
|
|
328
|
+
from .linter import lint_config
|
|
329
|
+
|
|
330
|
+
report = lint_config(config_path)
|
|
331
|
+
sys.exit(0 if report.is_valid else 1)
|
|
332
|
+
|
|
333
|
+
if args.reset:
|
|
334
|
+
gitsync = Path(".gitsync")
|
|
335
|
+
if gitsync.exists():
|
|
336
|
+
shutil.rmtree(gitsync)
|
|
337
|
+
logger.info("Cleared .gitsync/ (state + cache)")
|
|
338
|
+
|
|
339
|
+
config = Config.from_path(str(config_path))
|
|
340
|
+
state = State(".gitsync/state.yml")
|
|
341
|
+
|
|
342
|
+
projects = [
|
|
343
|
+
p for p in config.projects if not args.project or p.name == args.project
|
|
344
|
+
]
|
|
345
|
+
if args.project and not projects:
|
|
346
|
+
sys.exit(f"Project '{args.project}' not found in config")
|
|
347
|
+
|
|
348
|
+
for project in projects:
|
|
349
|
+
if not project.enabled:
|
|
350
|
+
logger.info("Skipping disabled project: %s", project.name)
|
|
351
|
+
continue
|
|
352
|
+
try:
|
|
353
|
+
sync_project(project, state, dry_run=args.dry_run)
|
|
354
|
+
except Exception:
|
|
355
|
+
logger.exception("Project %s failed", project.name)
|
|
356
|
+
sys.exit(1)
|
|
357
|
+
|
|
358
|
+
|
|
359
|
+
if __name__ == "__main__":
|
|
360
|
+
main()
|
|
@@ -0,0 +1,44 @@
|
|
|
1
|
+
"""Provider registry — maps platform type strings to API client classes.
|
|
2
|
+
|
|
3
|
+
To add a new provider:
|
|
4
|
+
1. Create ``sync/providers/<name>.py`` implementing the same interface as
|
|
5
|
+
``BaseAPIClient`` (``download_asset``, ``upload_asset`` at minimum).
|
|
6
|
+
2. Import the class here and register it in ``_REGISTRY``.
|
|
7
|
+
|
|
8
|
+
That's it — ``source.py`` and ``target.py`` use ``get_api_client`` and never
|
|
9
|
+
need to know which concrete class was returned.
|
|
10
|
+
"""
|
|
11
|
+
|
|
12
|
+
from .gitea import GiteaClient
|
|
13
|
+
from .github import GitHubClient
|
|
14
|
+
|
|
15
|
+
_REGISTRY: dict = {
|
|
16
|
+
"gitea": GiteaClient,
|
|
17
|
+
"github": GitHubClient,
|
|
18
|
+
}
|
|
19
|
+
|
|
20
|
+
|
|
21
|
+
def get_api_client(provider_type: str, api: str, repo: str, token: str):
|
|
22
|
+
"""Instantiate an API client for *provider_type*.
|
|
23
|
+
|
|
24
|
+
Raises ``ValueError`` for unknown types with a clear message listing the
|
|
25
|
+
known options, so users get actionable feedback when they mis-spell a type.
|
|
26
|
+
"""
|
|
27
|
+
cls = _REGISTRY.get(provider_type)
|
|
28
|
+
if cls is None:
|
|
29
|
+
known = ", ".join(sorted(_REGISTRY))
|
|
30
|
+
raise ValueError(
|
|
31
|
+
f"Unknown provider type '{provider_type}' — known types: {known}"
|
|
32
|
+
)
|
|
33
|
+
return cls(api, repo, token)
|
|
34
|
+
|
|
35
|
+
|
|
36
|
+
def register_provider(name: str, cls) -> None:
|
|
37
|
+
"""Register a custom provider class at runtime.
|
|
38
|
+
|
|
39
|
+
Useful for plugins or testing — call before ``get_api_client`` is invoked.
|
|
40
|
+
"""
|
|
41
|
+
_REGISTRY[name] = cls
|
|
42
|
+
|
|
43
|
+
|
|
44
|
+
__all__ = ["get_api_client", "register_provider", "GiteaClient", "GitHubClient"]
|
|
@@ -0,0 +1,230 @@
|
|
|
1
|
+
"""Base API client — shared implementation for all remote git hosting providers.
|
|
2
|
+
|
|
3
|
+
All HTTP, repository-management, and release-management logic lives here.
|
|
4
|
+
Provider subclasses only need to override:
|
|
5
|
+
- ``_headers`` : dict of default HTTP request headers
|
|
6
|
+
- ``_platform_name`` : human-readable name used in error messages
|
|
7
|
+
- ``_conflict_codes`` : HTTP codes that mean "repo already exists"
|
|
8
|
+
- ``_release_conflict_code`` : HTTP code meaning "release already exists"
|
|
9
|
+
- ``download_asset`` : provider-specific download logic
|
|
10
|
+
- ``upload_asset`` : provider-specific upload logic (RAM or stream)
|
|
11
|
+
|
|
12
|
+
Adding a brand-new provider therefore requires writing only those six items.
|
|
13
|
+
"""
|
|
14
|
+
|
|
15
|
+
import json
|
|
16
|
+
import logging
|
|
17
|
+
import urllib.error
|
|
18
|
+
import urllib.request
|
|
19
|
+
|
|
20
|
+
logger = logging.getLogger(__name__)
|
|
21
|
+
|
|
22
|
+
|
|
23
|
+
class BaseAPIClient:
|
|
24
|
+
"""Shared REST API behaviour for all remote provider clients."""
|
|
25
|
+
|
|
26
|
+
_platform_name: str = "Remote"
|
|
27
|
+
_conflict_codes: tuple = (409, 422)
|
|
28
|
+
_release_conflict_code: int = 422
|
|
29
|
+
|
|
30
|
+
def __init__(self, api: str, repo: str, token: str):
|
|
31
|
+
self.api = api.rstrip("/")
|
|
32
|
+
self.repo = repo
|
|
33
|
+
self.token = token
|
|
34
|
+
# Subclasses must set self._headers in their own __init__.
|
|
35
|
+
|
|
36
|
+
# ------------------------------------------------------------------
|
|
37
|
+
# Core HTTP helpers
|
|
38
|
+
# ------------------------------------------------------------------
|
|
39
|
+
|
|
40
|
+
def _request(self, method, path, data=None):
|
|
41
|
+
"""Make a JSON request relative to /repos/{repo}/{path}."""
|
|
42
|
+
url = f"{self.api}/repos/{self.repo}"
|
|
43
|
+
if path:
|
|
44
|
+
url += f"/{path}"
|
|
45
|
+
body = json.dumps(data).encode() if data else None
|
|
46
|
+
headers = dict(self._headers)
|
|
47
|
+
if data:
|
|
48
|
+
headers["Content-Type"] = "application/json"
|
|
49
|
+
req = urllib.request.Request(url, data=body, headers=headers, method=method)
|
|
50
|
+
try:
|
|
51
|
+
with urllib.request.urlopen(req) as resp:
|
|
52
|
+
raw = resp.read()
|
|
53
|
+
return json.loads(raw) if raw else None
|
|
54
|
+
except urllib.error.HTTPError as e:
|
|
55
|
+
body_text = e.read().decode()
|
|
56
|
+
logger.error("%s API error %s: %s", self._platform_name, e.code, body_text)
|
|
57
|
+
raise
|
|
58
|
+
|
|
59
|
+
def _request_url(self, method, url, data=None):
|
|
60
|
+
"""Make a JSON request to an arbitrary absolute URL."""
|
|
61
|
+
body = json.dumps(data).encode() if data else None
|
|
62
|
+
headers = dict(self._headers)
|
|
63
|
+
if data:
|
|
64
|
+
headers["Content-Type"] = "application/json"
|
|
65
|
+
req = urllib.request.Request(url, data=body, headers=headers, method=method)
|
|
66
|
+
try:
|
|
67
|
+
with urllib.request.urlopen(req) as resp:
|
|
68
|
+
raw = resp.read()
|
|
69
|
+
return json.loads(raw) if raw else None
|
|
70
|
+
except urllib.error.HTTPError as e:
|
|
71
|
+
body_text = e.read().decode()
|
|
72
|
+
logger.error(
|
|
73
|
+
"%s API error %s %s: %s",
|
|
74
|
+
self._platform_name, method, url, body_text,
|
|
75
|
+
)
|
|
76
|
+
raise
|
|
77
|
+
|
|
78
|
+
# ------------------------------------------------------------------
|
|
79
|
+
# Repository management
|
|
80
|
+
# ------------------------------------------------------------------
|
|
81
|
+
|
|
82
|
+
def ensure_repo_exists(self):
|
|
83
|
+
"""Ensure the repo exists on the platform, creating it if not.
|
|
84
|
+
|
|
85
|
+
Returns the repo metadata dict (includes clone_url, ssh_url, etc.).
|
|
86
|
+
Raises ``RuntimeError`` on auth errors or inaccessible repos.
|
|
87
|
+
"""
|
|
88
|
+
try:
|
|
89
|
+
return self._request("GET", "")
|
|
90
|
+
except urllib.error.HTTPError as e:
|
|
91
|
+
if e.code == 404:
|
|
92
|
+
logger.info(
|
|
93
|
+
"%s repo '%s' not found — creating it automatically",
|
|
94
|
+
self._platform_name,
|
|
95
|
+
self.repo,
|
|
96
|
+
)
|
|
97
|
+
return self._create_repo()
|
|
98
|
+
hint = {
|
|
99
|
+
401: "the token is invalid or expired",
|
|
100
|
+
403: "the token lacks the required permissions",
|
|
101
|
+
}.get(e.code, f"unexpected HTTP {e.code}")
|
|
102
|
+
raise RuntimeError(
|
|
103
|
+
f"{self._platform_name} repo '{self.repo}' is not accessible "
|
|
104
|
+
f"(HTTP {e.code}): {hint}."
|
|
105
|
+
) from e
|
|
106
|
+
|
|
107
|
+
# Keep the old name as an alias for backwards compatibility.
|
|
108
|
+
verify_access = ensure_repo_exists
|
|
109
|
+
|
|
110
|
+
def _create_repo(self):
|
|
111
|
+
"""Create the repository as private and return its metadata.
|
|
112
|
+
|
|
113
|
+
Uses ``/user/repos`` for personal accounts, ``/orgs/{org}/repos`` for orgs.
|
|
114
|
+
Raises ``RuntimeError`` on conflict (repo exists privately with wrong token scope).
|
|
115
|
+
"""
|
|
116
|
+
owner, name = self.repo.split("/", 1)
|
|
117
|
+
payload = {
|
|
118
|
+
"name": name,
|
|
119
|
+
"private": True, # safe default — user can change visibility later
|
|
120
|
+
"auto_init": False,
|
|
121
|
+
}
|
|
122
|
+
# Determine whether the owner is the authenticated user or an org.
|
|
123
|
+
try:
|
|
124
|
+
user_info = self._request_url("GET", f"{self.api}/user")
|
|
125
|
+
is_user = user_info.get("login", "").lower() == owner.lower()
|
|
126
|
+
except Exception:
|
|
127
|
+
is_user = True # fall back to user endpoint on any error
|
|
128
|
+
|
|
129
|
+
url = (
|
|
130
|
+
f"{self.api}/user/repos"
|
|
131
|
+
if is_user
|
|
132
|
+
else f"{self.api}/orgs/{owner}/repos"
|
|
133
|
+
)
|
|
134
|
+
|
|
135
|
+
body = json.dumps(payload).encode()
|
|
136
|
+
headers = dict(self._headers)
|
|
137
|
+
headers["Content-Type"] = "application/json"
|
|
138
|
+
req = urllib.request.Request(url, data=body, headers=headers, method="POST")
|
|
139
|
+
try:
|
|
140
|
+
with urllib.request.urlopen(req) as resp:
|
|
141
|
+
raw = resp.read()
|
|
142
|
+
meta = json.loads(raw) if raw else {}
|
|
143
|
+
logger.info(
|
|
144
|
+
"Created %s repo '%s' (private) at %s",
|
|
145
|
+
self._platform_name,
|
|
146
|
+
self.repo,
|
|
147
|
+
meta.get("html_url", ""),
|
|
148
|
+
)
|
|
149
|
+
return meta
|
|
150
|
+
except urllib.error.HTTPError as e:
|
|
151
|
+
body_text = e.read().decode()
|
|
152
|
+
if e.code in self._conflict_codes:
|
|
153
|
+
raise RuntimeError(
|
|
154
|
+
f"{self._platform_name} repo '{self.repo}' could not be created "
|
|
155
|
+
f"(HTTP {e.code}): the repository may already exist as a private "
|
|
156
|
+
"repo that this token cannot access. Ensure the token has "
|
|
157
|
+
"read/write access to the repo."
|
|
158
|
+
) from e
|
|
159
|
+
logger.error(
|
|
160
|
+
"Failed to create %s repo: %s %s",
|
|
161
|
+
self._platform_name, e.code, body_text,
|
|
162
|
+
)
|
|
163
|
+
raise RuntimeError(
|
|
164
|
+
f"Could not create {self._platform_name} repo '{self.repo}' "
|
|
165
|
+
f"(HTTP {e.code}): {body_text}"
|
|
166
|
+
) from e
|
|
167
|
+
|
|
168
|
+
# ------------------------------------------------------------------
|
|
169
|
+
# Release management
|
|
170
|
+
# ------------------------------------------------------------------
|
|
171
|
+
|
|
172
|
+
def list_releases(self, page=1, limit=50):
|
|
173
|
+
"""List releases (paginated). Override in subclasses if the query-param name differs."""
|
|
174
|
+
return self._request("GET", f"releases?page={page}&limit={limit}")
|
|
175
|
+
|
|
176
|
+
def get_release_by_tag(self, tag):
|
|
177
|
+
"""Get a release by tag name. Returns dict or None if not found (404)."""
|
|
178
|
+
try:
|
|
179
|
+
return self._request("GET", f"releases/tags/{tag}")
|
|
180
|
+
except urllib.error.HTTPError as e:
|
|
181
|
+
if e.code == 404:
|
|
182
|
+
return None
|
|
183
|
+
raise
|
|
184
|
+
|
|
185
|
+
def list_release_assets(self, release_id):
|
|
186
|
+
"""List assets for a release ID. Returns [] if not found (404)."""
|
|
187
|
+
try:
|
|
188
|
+
return self._request("GET", f"releases/{release_id}/assets") or []
|
|
189
|
+
except urllib.error.HTTPError as e:
|
|
190
|
+
if e.code == 404:
|
|
191
|
+
return []
|
|
192
|
+
raise
|
|
193
|
+
|
|
194
|
+
def create_release(self, tag, name, body, prerelease=False):
|
|
195
|
+
"""Create a release. Idempotent: returns None if already exists."""
|
|
196
|
+
try:
|
|
197
|
+
return self._request(
|
|
198
|
+
"POST",
|
|
199
|
+
"releases",
|
|
200
|
+
{
|
|
201
|
+
"tag_name": tag,
|
|
202
|
+
"name": name,
|
|
203
|
+
"body": body,
|
|
204
|
+
"prerelease": prerelease,
|
|
205
|
+
},
|
|
206
|
+
)
|
|
207
|
+
except urllib.error.HTTPError as e:
|
|
208
|
+
if e.code == self._release_conflict_code:
|
|
209
|
+
logger.info(
|
|
210
|
+
"Release %s already exists on %s (idempotent)",
|
|
211
|
+
tag, self._platform_name,
|
|
212
|
+
)
|
|
213
|
+
return None
|
|
214
|
+
raise
|
|
215
|
+
|
|
216
|
+
# ------------------------------------------------------------------
|
|
217
|
+
# Asset management — implemented by each provider
|
|
218
|
+
# ------------------------------------------------------------------
|
|
219
|
+
|
|
220
|
+
def download_asset(self, asset, dest):
|
|
221
|
+
"""Download a release asset to *dest* path. Must be overridden."""
|
|
222
|
+
raise NotImplementedError
|
|
223
|
+
|
|
224
|
+
def upload_asset(self, release_or_id, file_path, name=None, stream=False):
|
|
225
|
+
"""Upload an asset to a release. Must be overridden.
|
|
226
|
+
|
|
227
|
+
When *stream* is ``True`` the file should be streamed from disk rather
|
|
228
|
+
than fully buffered in RAM — useful for large prebuilt binaries.
|
|
229
|
+
"""
|
|
230
|
+
raise NotImplementedError
|