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 ADDED
@@ -0,0 +1,34 @@
1
+ """GitAcross – mirror releases and git commits across platforms."""
2
+
3
+ __version__ = "0.1.0"
4
+
5
+ from .config import Config, ProjectConfig
6
+ from .linter import (
7
+ ConfigFixer,
8
+ ConfigLinter,
9
+ FixIssue,
10
+ FixReport,
11
+ LintIssue,
12
+ LintReport,
13
+ LintSeverity,
14
+ fix_config,
15
+ lint_config,
16
+ )
17
+ from .main import sync_project, main
18
+
19
+ __all__ = [
20
+ "__version__",
21
+ "Config",
22
+ "ProjectConfig",
23
+ "ConfigFixer",
24
+ "ConfigLinter",
25
+ "FixIssue",
26
+ "FixReport",
27
+ "LintIssue",
28
+ "LintReport",
29
+ "LintSeverity",
30
+ "fix_config",
31
+ "lint_config",
32
+ "sync_project",
33
+ "main",
34
+ ]
gitacross/__main__.py ADDED
@@ -0,0 +1,6 @@
1
+ """Entry point when invoked with `python -m gitacross`."""
2
+
3
+ from .main import main
4
+
5
+ if __name__ == "__main__":
6
+ main()
gitacross/config.py ADDED
@@ -0,0 +1,218 @@
1
+ import logging
2
+ import os
3
+
4
+ import yaml
5
+
6
+ logger = logging.getLogger(__name__)
7
+
8
+ _VALID_PROJECT_KEYS = {
9
+ "name",
10
+ "enabled",
11
+ "source",
12
+ "target",
13
+ "renderer",
14
+ "retry",
15
+ "preserve_description",
16
+ "preserve_release_description",
17
+ "sync_assets",
18
+ "preserve_assets",
19
+ "include_assets",
20
+ "stream_assets",
21
+ }
22
+
23
+ _KNOWN_SOURCE_KEYS = {
24
+ "mode": "source",
25
+ "sync_from": "source",
26
+ "include_prereleases": "source",
27
+ "include_drafts": "source",
28
+ "tag_pattern": "source",
29
+ }
30
+
31
+ _KNOWN_ENDPOINT_KEYS = {
32
+ "repo": "source or target",
33
+ "api": "source or target",
34
+ "token": "source or target",
35
+ "branch": "source or target",
36
+ }
37
+
38
+
39
+ class Config:
40
+ def __init__(self, path):
41
+ with open(path) as f:
42
+ raw = yaml.safe_load(f)
43
+ if isinstance(raw, list):
44
+ # Flat list at top level: [ {name:..., source:..., ...} ]
45
+ raw = {"projects": raw}
46
+ elif raw is None:
47
+ raw = {"projects": []}
48
+ self.projects = [ProjectConfig(p) for p in raw.get("projects", [])]
49
+
50
+ @classmethod
51
+ def from_path(cls, path):
52
+ return cls(path)
53
+
54
+
55
+ class _EndpointConfig:
56
+ """Config for a source or target endpoint.
57
+
58
+ Fields shared by all types: type
59
+ Remote (gitea/github): repo, api, token
60
+ Local: path, tag_pattern/branch
61
+ """
62
+
63
+ def __init__(self, raw, is_source):
64
+ self.type = raw.get("type", "gitea" if is_source else "github")
65
+
66
+ # Remote endpoint fields
67
+ self.repo = raw.get("repo", "")
68
+ self.api = raw.get("api", "")
69
+ self.token = _resolve_token(raw.get("token", ""))
70
+
71
+ # Local endpoint fields
72
+ self.path = raw.get("path", "")
73
+ self.tag_pattern = raw.get("tag_pattern", "*") if is_source else None
74
+
75
+ # Branch (target only by default; also used in source commit-mode to pick branch tip)
76
+ self.branch = raw.get("branch", "main" if not is_source else "")
77
+
78
+ # Source-side release filtering (remote only, ignored for local)
79
+ self.include_prereleases = raw.get("include_prereleases", False)
80
+ self.include_drafts = raw.get("include_drafts", False)
81
+
82
+ # Optional: only sync releases from this tag onwards (no backfilling needed)
83
+ # In commit mode this should be a commit SHA instead of a tag name.
84
+ self.sync_from = raw.get("sync_from", "")
85
+
86
+ # Release list source (remote sources): "release" (API releases,
87
+ # default), "tag" (git tags treated as releases), or "commit"
88
+ # (sync latest HEAD commit; sync_from must be a commit SHA).
89
+ self.mode = raw.get("mode", "release")
90
+
91
+ @property
92
+ def is_remote(self):
93
+ return self.type in ("gitea", "github")
94
+
95
+ @property
96
+ def owner(self):
97
+ return self.repo.split("/")[0] if "/" in self.repo else ""
98
+
99
+ @property
100
+ def clone_url(self):
101
+ """HTTPS clone URL with token embedded for auth."""
102
+ if not self.api or not self.repo:
103
+ return ""
104
+ raw_host = self.api.split("://")[1].split("/")[0] if "://" in self.api else self.api
105
+ # GitHub's API lives at api.github.com, but its git host is github.com.
106
+ # Gitea's API typically lives on the same host as git, so no transform needed.
107
+ host = "github.com" if raw_host == "api.github.com" else raw_host
108
+ return f"https://{self.owner}:{self.token}@{host}/{self.repo}.git"
109
+
110
+
111
+ class ProjectConfig:
112
+ def __init__(self, raw):
113
+ self.name = raw["name"]
114
+ self.enabled = bool(raw.get("enabled", True))
115
+ raw_source = raw.get("source", {})
116
+ raw_target = raw.get("target", {})
117
+ self.source = _EndpointConfig(raw_source, is_source=True)
118
+ self.target = _EndpointConfig(raw_target, is_source=False)
119
+ self.renderer = _RendererConfig(raw.get("renderer", {}))
120
+ self.retry = _RetryConfig(raw.get("retry", {}))
121
+
122
+ # Check for misplaced or unknown keys at the project level
123
+ for k in raw:
124
+ if k in _VALID_PROJECT_KEYS:
125
+ continue
126
+ if k in _KNOWN_SOURCE_KEYS:
127
+ logger.warning(
128
+ "Project '%s': '%s' was specified at the project level, but must be configured under '%s:' (e.g. %s.%s: %s).",
129
+ self.name,
130
+ k,
131
+ _KNOWN_SOURCE_KEYS[k],
132
+ _KNOWN_SOURCE_KEYS[k],
133
+ k,
134
+ raw[k],
135
+ )
136
+ elif k in _KNOWN_ENDPOINT_KEYS:
137
+ logger.warning(
138
+ "Project '%s': '%s' was specified at the project level, but belongs under '%s:'.",
139
+ self.name,
140
+ k,
141
+ _KNOWN_ENDPOINT_KEYS[k],
142
+ )
143
+ else:
144
+ logger.warning(
145
+ "Project '%s': unrecognized configuration key '%s'.",
146
+ self.name,
147
+ k,
148
+ )
149
+
150
+ # preserve_description — single cascade: project → source → target → True
151
+ # Supports aliases: preserve_release_description (legacy)
152
+ self.preserve_description = _cascade(
153
+ raw, raw_source, raw_target,
154
+ keys=["preserve_description", "preserve_release_description"],
155
+ default=True,
156
+ )
157
+
158
+ # sync_assets — single cascade: project → source → target → False
159
+ # Supports aliases: preserve_assets, include_assets (legacy)
160
+ self.sync_assets = _cascade(
161
+ raw, raw_source, raw_target,
162
+ keys=["sync_assets", "preserve_assets", "include_assets"],
163
+ default=False,
164
+ )
165
+
166
+ # stream_assets — project-level only (no endpoint-level alias)
167
+ # When True, asset uploads stream from disk instead of buffering in RAM.
168
+ # Default is False (RAM) to preserve existing behaviour.
169
+ self.stream_assets = bool(raw.get("stream_assets", False))
170
+
171
+
172
+ class _AuthorConfig:
173
+ def __init__(self, raw):
174
+ self.name = raw.get("name", "")
175
+ self.email = raw.get("email", "")
176
+
177
+ @property
178
+ def enabled(self):
179
+ return bool(self.name) or bool(self.email)
180
+
181
+
182
+ class _RendererConfig:
183
+ def __init__(self, raw):
184
+ self.ignore = raw.get("ignore", [])
185
+ self.operations = raw.get("operations", [])
186
+ raw_author = raw.get("author")
187
+ self.author = _AuthorConfig(raw_author) if raw_author else _AuthorConfig({})
188
+
189
+
190
+ class _RetryConfig:
191
+ def __init__(self, raw):
192
+ self.max_attempts = raw.get("max_attempts", 3)
193
+ self.backoff_seconds = raw.get("backoff_seconds", 2)
194
+
195
+
196
+ # ---------------------------------------------------------------------------
197
+ # Helpers
198
+ # ---------------------------------------------------------------------------
199
+
200
+ def _cascade(raw_project, raw_source, raw_target, keys, default):
201
+ """Return the first value found for any of *keys* across project, source, target.
202
+
203
+ Lookup order: project-level first (highest priority), then source-level,
204
+ then target-level, then *default*. All alias keys are checked at each
205
+ level before moving to the next — so a project-level alias wins over a
206
+ source-level primary key.
207
+ """
208
+ for raw in (raw_project, raw_source, raw_target):
209
+ for k in keys:
210
+ if k in raw:
211
+ return raw[k]
212
+ return default
213
+
214
+
215
+ def _resolve_token(val):
216
+ if val.startswith("${") and val.endswith("}"):
217
+ return os.environ.get(val[2:-1], "")
218
+ return val
gitacross/git.py ADDED
@@ -0,0 +1,342 @@
1
+ import io
2
+ import logging
3
+ import os
4
+ import re
5
+ import subprocess
6
+ import tarfile
7
+ from pathlib import Path
8
+
9
+ logger = logging.getLogger(__name__)
10
+
11
+ # scheme://user:token@host — mask the token when logging URLs
12
+ _TOKEN_RE = re.compile(r"(://[^/@\s:]+):[^/@\s]+@")
13
+
14
+
15
+ def _redact(value):
16
+ """Mask credentials embedded in URLs (scheme://user:token@ → scheme://user:***@)."""
17
+ return _TOKEN_RE.sub(r"\1:***@", value)
18
+
19
+
20
+ def _log_git_stderr(command, returncode, stderr, level):
21
+ """Log a failed git command's stderr (decoded, redacted)."""
22
+ if not stderr:
23
+ return
24
+ msg = stderr.strip()
25
+ if not isinstance(msg, str):
26
+ msg = msg.decode(errors="replace")
27
+ if msg:
28
+ logger.log(
29
+ level,
30
+ "git %s failed (exit %d): %s",
31
+ command,
32
+ returncode,
33
+ _redact(msg),
34
+ )
35
+
36
+
37
+ def _git(*args, check=True, input_data=None, text=True, env=None):
38
+ cmd = ["git"] + [str(a) for a in args]
39
+ logger.debug("> git %s", _redact(" ".join(str(a) for a in args)))
40
+ try:
41
+ result = subprocess.run(
42
+ cmd,
43
+ capture_output=True,
44
+ text=text,
45
+ check=check,
46
+ input=input_data,
47
+ env=env,
48
+ )
49
+ except subprocess.CalledProcessError as e:
50
+ _log_git_stderr(args[0], e.returncode, e.stderr, level=logging.ERROR)
51
+ # Sanitize the command shown in the traceback (it may contain a token URL)
52
+ for i, part in enumerate(e.cmd):
53
+ if isinstance(part, str):
54
+ e.cmd[i] = _redact(part)
55
+ raise
56
+ if result.returncode != 0:
57
+ # check=False path — callers inspect the return code themselves
58
+ _log_git_stderr(args[0], result.returncode, result.stderr, level=logging.DEBUG)
59
+ return result
60
+
61
+
62
+ class GitRepo:
63
+ """A git repository for release operations.
64
+
65
+ Works with both bare mirrors (cloned from remote) and local repos.
66
+ Uses `--git-dir` + `--work-tree` for committing from a temp directory,
67
+ avoiding any git worktree management.
68
+ """
69
+
70
+ def __init__(self, git_dir, is_bare=False):
71
+ self.git_dir = Path(git_dir)
72
+ self.is_bare = is_bare
73
+
74
+ @classmethod
75
+ def ensure_mirror(cls, url, dest):
76
+ """Clone a bare mirror or update an existing one.
77
+
78
+ Fetches with --prune: a failed push leaves locally created refs that
79
+ the remote never accepted; pruning resets the mirror to the remote's
80
+ actual state so the next run re-syncs instead of treating those
81
+ stale refs as already-pushed.
82
+ """
83
+ path = Path(dest)
84
+ if path.exists():
85
+ # Update remote URL *before* fetching so token rotation takes effect
86
+ current = _git("-C", str(path), "remote", "get-url", "origin", check=False)
87
+ if current.returncode == 0 and current.stdout.strip() != url:
88
+ _git("-C", str(path), "remote", "set-url", "origin", url)
89
+ logger.info("Updated remote URL for mirror at %s", dest)
90
+ _git("-C", str(path), "fetch", "--tags", "--prune", "origin")
91
+ logger.info("Updated mirror at %s", dest)
92
+ else:
93
+ path.parent.mkdir(parents=True, exist_ok=True)
94
+ _git("clone", "--mirror", url, str(path))
95
+ logger.info("Cloned mirror from %s", _redact(url))
96
+ # Ensure author identity for automated commits
97
+ _git("-C", str(path), "config", "user.name", "GitAcross")
98
+ _git("-C", str(path), "config", "user.email", "sync@gitacross")
99
+ return cls(path, is_bare=True)
100
+
101
+ @classmethod
102
+ def local(cls, path):
103
+ """Open an existing local git repository."""
104
+ p = Path(path)
105
+ result = _git("-C", str(p), "rev-parse", "--git-dir")
106
+ git_dir = p / result.stdout.strip()
107
+ if not git_dir.exists():
108
+ raise ValueError(f"Not a git repository: {path}")
109
+ return cls(git_dir, is_bare=False)
110
+
111
+ def _g(self, *args, text=True, env=None, **kwargs):
112
+ """Run git command with --git-dir set."""
113
+ return _git("--git-dir", str(self.git_dir), *args, text=text, env=env, **kwargs)
114
+
115
+ def _gw(self, work_dir, *args, env=None, **kwargs):
116
+ """Run git command with --git-dir and --work-tree set."""
117
+ return _git(
118
+ "--git-dir",
119
+ str(self.git_dir),
120
+ "--work-tree",
121
+ str(work_dir),
122
+ *args,
123
+ env=env,
124
+ **kwargs,
125
+ )
126
+
127
+ def list_tags(self):
128
+ """Return all tag names in the repo."""
129
+ result = self._g("tag", "-l")
130
+ raw = result.stdout.strip()
131
+ return raw.split("\n") if raw else []
132
+
133
+ def list_tags_sorted_by_date(self):
134
+ """Return tag names sorted by their commit date (oldest first).
135
+
136
+ Uses creatordate which works for both annotated (taggerdate)
137
+ and lightweight (committerdate) tags.
138
+ """
139
+ result = self._g(
140
+ "for-each-ref",
141
+ "--sort=creatordate",
142
+ "--format=%(refname:short)",
143
+ "refs/tags/",
144
+ )
145
+ raw = result.stdout.strip()
146
+ return raw.split("\n") if raw else []
147
+
148
+ def tag_commit_date(self, tag):
149
+ """Return the ISO-8601 commit date for a tag."""
150
+ result = self._g("log", "-1", "--format=%cI", tag, check=False)
151
+ if result.returncode != 0:
152
+ return ""
153
+ return result.stdout.strip()
154
+
155
+ def resolve_commit(self, ref):
156
+ """Return the 40-char commit SHA for a ref, tag, or commitish."""
157
+ if not ref:
158
+ return ""
159
+ result = self._g("rev-parse", "--verify", f"{ref}^{{commit}}", check=False)
160
+ if result.returncode == 0:
161
+ return result.stdout.strip()
162
+ return ""
163
+
164
+ def export_commit(self, commit_sha, dest):
165
+ """Export a commit's file tree into *dest* (empty dir recommended)."""
166
+ result = self._g("archive", commit_sha, "--format=tar", text=False)
167
+ with tarfile.open(fileobj=io.BytesIO(result.stdout), mode="r|") as tar:
168
+ tar.extractall(path=str(dest))
169
+
170
+ def export_tag(self, tag, dest):
171
+ """Export a tag's file tree into *dest* (empty dir recommended)."""
172
+ commit_sha = self.resolve_commit(tag) or tag
173
+ self.export_commit(commit_sha, dest)
174
+
175
+ def ensure_branch(self, branch):
176
+ """Ensure HEAD points to *branch*, creating it if needed.
177
+
178
+ Priority: local branch → remote tracking → new branch.
179
+ """
180
+ # Already exists locally
181
+ local = self._g("show-ref", "--verify", f"refs/heads/{branch}", check=False)
182
+ if local.returncode == 0:
183
+ self._g("symbolic-ref", "HEAD", f"refs/heads/{branch}")
184
+ return
185
+
186
+ # Exists on remote
187
+ remote = self._g(
188
+ "show-ref", "--verify", f"refs/remotes/origin/{branch}", check=False
189
+ )
190
+ if remote.returncode == 0:
191
+ self._g("branch", "--force", branch, f"origin/{branch}")
192
+ self._g("symbolic-ref", "HEAD", f"refs/heads/{branch}")
193
+ return
194
+
195
+ # Brand new branch — just set HEAD, the ref is created on first commit
196
+ self._g("symbolic-ref", "HEAD", f"refs/heads/{branch}")
197
+
198
+ def commit(self, work_dir, message, date=None, author_name=None, author_email=None):
199
+ """Stage all files in *work_dir* and commit on current branch.
200
+
201
+ If *date* is provided (ISO-8601 string), sets both author and committer
202
+ dates so the commit appears at the correct chronological position.
203
+
204
+ If *author_name* and/or *author_email* are provided, they override the
205
+ git identity for this commit (both author and committer).
206
+ """
207
+ self._gw(work_dir, "add", "-A")
208
+ env = dict(os.environ)
209
+ if date:
210
+ env.update({
211
+ "GIT_AUTHOR_DATE": date,
212
+ "GIT_COMMITTER_DATE": date,
213
+ })
214
+ if author_name:
215
+ env.update({
216
+ "GIT_AUTHOR_NAME": author_name,
217
+ "GIT_COMMITTER_NAME": author_name,
218
+ })
219
+ if author_email:
220
+ env.update({
221
+ "GIT_AUTHOR_EMAIL": author_email,
222
+ "GIT_COMMITTER_EMAIL": author_email,
223
+ })
224
+ if not date and not author_name and not author_email:
225
+ env = None
226
+ result = self._gw(
227
+ work_dir,
228
+ "commit",
229
+ "--no-verify",
230
+ "-m",
231
+ message,
232
+ check=False,
233
+ env=env,
234
+ )
235
+ # Exit code 1 from `git commit` means "nothing to commit" (defined contract)
236
+ if result.returncode not in (0, 1):
237
+ result.check_returncode()
238
+ return result
239
+
240
+ def tag(self, name, message):
241
+ """Create (or force-update) an annotated tag."""
242
+ self._g("tag", "-f", name, "-m", message)
243
+
244
+ def reset_worktree(self):
245
+ """Populate the working tree to match HEAD. No-op for bare repos."""
246
+ if not self.is_bare:
247
+ _git("-C", str(self.git_dir.parent), "reset", "--hard", "HEAD")
248
+
249
+ def push(self, remote, *refs):
250
+ """Push specific refs to remote."""
251
+ # Mirrors (cloned with --mirror) reject explicit refspecs by default.
252
+ # Temporarily disable mirror mode so we can push only the refs we need.
253
+ if self.is_bare:
254
+ self._g("config", "--local", "remote.origin.mirror", "false")
255
+ try:
256
+ self._g("push", remote, *refs)
257
+ finally:
258
+ self._g("config", "--local", "remote.origin.mirror", "true")
259
+ else:
260
+ self._g("push", remote, *refs)
261
+
262
+ def tag_exists(self, tag):
263
+ """Check if a tag exists locally."""
264
+ result = self._g("show-ref", "--verify", f"refs/tags/{tag}", check=False)
265
+ return result.returncode == 0
266
+
267
+ def head_sha(self):
268
+ """Return the SHA of HEAD."""
269
+ result = self._g("rev-parse", "HEAD")
270
+ return result.stdout.strip()
271
+
272
+ def is_ancestor(self, older_sha, newer_sha):
273
+ """Return True if *older_sha* is a (strict) ancestor of *newer_sha*.
274
+
275
+ Uses ``git merge-base --is-ancestor`` which exits 0 when true and 1
276
+ when false. Returns False for any git error (e.g. unknown ref).
277
+ """
278
+ result = self._g(
279
+ "merge-base", "--is-ancestor", older_sha, newer_sha, check=False
280
+ )
281
+ return result.returncode == 0
282
+
283
+ def resolve_default_branch_head(self, branch=None):
284
+ """Return the commit SHA at the tip of the default (or given) branch.
285
+
286
+ Supports both bare mirrors (where refs usually live under ``refs/heads/``
287
+ and ``HEAD`` points to the default branch) and remote-tracking setups
288
+ (``refs/remotes/origin/``).
289
+ """
290
+ if branch:
291
+ candidates = [
292
+ f"refs/heads/{branch}",
293
+ f"refs/remotes/origin/{branch}",
294
+ f"refs/remotes/{branch}",
295
+ branch,
296
+ ]
297
+ for ref in candidates:
298
+ result = self._g("rev-parse", "--verify", ref, check=False)
299
+ if result.returncode == 0:
300
+ return result.stdout.strip()
301
+ logger.warning("Could not resolve branch '%s' in %s", branch, self.git_dir)
302
+ return ""
303
+
304
+ # Auto-detect default branch:
305
+ # 1. Try symbolic-ref HEAD (works on bare mirrors and non-bare working copies)
306
+ result = self._g("symbolic-ref", "--short", "HEAD", check=False)
307
+ if result.returncode == 0:
308
+ branch_name = result.stdout.strip()
309
+ for ref in (f"refs/heads/{branch_name}", branch_name, "HEAD"):
310
+ r = self._g("rev-parse", "--verify", ref, check=False)
311
+ if r.returncode == 0 and r.stdout.strip():
312
+ return r.stdout.strip()
313
+
314
+ # 2. Try refs/remotes/origin/HEAD
315
+ result = self._g(
316
+ "symbolic-ref", "--short", "refs/remotes/origin/HEAD", check=False
317
+ )
318
+ if result.returncode == 0:
319
+ remote_branch = result.stdout.strip() # e.g. "origin/main"
320
+ ref = f"refs/remotes/{remote_branch}"
321
+ r = self._g("rev-parse", "--verify", ref, check=False)
322
+ if r.returncode == 0 and r.stdout.strip():
323
+ return r.stdout.strip()
324
+
325
+ # 3. Fall back to common branch names in refs/heads/ or refs/remotes/origin/
326
+ for candidate in ("main", "master", "trunk", "development", "dev", "default"):
327
+ for ref in (f"refs/heads/{candidate}", f"refs/remotes/origin/{candidate}"):
328
+ r = self._g("rev-parse", "--verify", ref, check=False)
329
+ if r.returncode == 0 and r.stdout.strip():
330
+ return r.stdout.strip()
331
+
332
+ # 4. Fall back to direct HEAD rev-parse
333
+ r = self._g("rev-parse", "--verify", "HEAD", check=False)
334
+ if r.returncode == 0 and r.stdout.strip():
335
+ return r.stdout.strip()
336
+
337
+ logger.warning(
338
+ "Could not determine default branch for repository at %s",
339
+ self.git_dir,
340
+ )
341
+ return ""
342
+
gitacross/gitea.py ADDED
@@ -0,0 +1,9 @@
1
+ """Backwards-compatibility shim — re-exports GiteaClient from gitacross.providers.
2
+
3
+ New code should import from ``gitacross.providers.gitea`` or use
4
+ ``gitacross.providers.get_api_client`` instead.
5
+ """
6
+
7
+ from .providers.gitea import GiteaClient # noqa: F401
8
+
9
+ __all__ = ["GiteaClient"]
gitacross/github.py ADDED
@@ -0,0 +1,9 @@
1
+ """Backwards-compatibility shim — re-exports GitHubClient from gitacross.providers.
2
+
3
+ New code should import from ``gitacross.providers.github`` or use
4
+ ``gitacross.providers.get_api_client`` instead.
5
+ """
6
+
7
+ from .providers.github import GitHubClient # noqa: F401
8
+
9
+ __all__ = ["GitHubClient"]