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