dump-github 0.2.0__tar.gz → 0.2.1__tar.gz
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.
- {dump_github-0.2.0 → dump_github-0.2.1}/CHANGELOG.md +20 -0
- {dump_github-0.2.0/dump_github.egg-info → dump_github-0.2.1}/PKG-INFO +14 -2
- {dump_github-0.2.0 → dump_github-0.2.1}/README.md +13 -1
- dump_github-0.2.1/dump_github/_version.py +1 -0
- dump_github-0.2.1/dump_github/lib.py +345 -0
- {dump_github-0.2.0 → dump_github-0.2.1}/dump_github/main.py +26 -5
- {dump_github-0.2.0 → dump_github-0.2.1/dump_github.egg-info}/PKG-INFO +14 -2
- {dump_github-0.2.0 → dump_github-0.2.1}/tests/test_lib.py +160 -0
- {dump_github-0.2.0 → dump_github-0.2.1}/tests/test_main.py +72 -0
- dump_github-0.2.0/dump_github/_version.py +0 -1
- dump_github-0.2.0/dump_github/lib.py +0 -158
- {dump_github-0.2.0 → dump_github-0.2.1}/LICENSE +0 -0
- {dump_github-0.2.0 → dump_github-0.2.1}/MANIFEST.in +0 -0
- {dump_github-0.2.0 → dump_github-0.2.1}/dump_github/__init__.py +0 -0
- {dump_github-0.2.0 → dump_github-0.2.1}/dump_github.egg-info/SOURCES.txt +0 -0
- {dump_github-0.2.0 → dump_github-0.2.1}/dump_github.egg-info/dependency_links.txt +0 -0
- {dump_github-0.2.0 → dump_github-0.2.1}/dump_github.egg-info/entry_points.txt +0 -0
- {dump_github-0.2.0 → dump_github-0.2.1}/dump_github.egg-info/requires.txt +0 -0
- {dump_github-0.2.0 → dump_github-0.2.1}/dump_github.egg-info/top_level.txt +0 -0
- {dump_github-0.2.0 → dump_github-0.2.1}/pyproject.toml +0 -0
- {dump_github-0.2.0 → dump_github-0.2.1}/setup.cfg +0 -0
- {dump_github-0.2.0 → dump_github-0.2.1}/tests/__init__.py +0 -0
|
@@ -5,6 +5,26 @@ All notable changes to this project are documented in this file.
|
|
|
5
5
|
The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.1.0/)
|
|
6
6
|
and this project adheres to [Semantic Versioning](https://semver.org/).
|
|
7
7
|
|
|
8
|
+
## [0.2.1] - 2026-11-12
|
|
9
|
+
|
|
10
|
+
### Fixed
|
|
11
|
+
|
|
12
|
+
- Non-token mode silently returned no repositories when the anonymous GitHub API
|
|
13
|
+
hit its 60 requests/hour/IP rate limit (common on shared/NAT/CI networks).
|
|
14
|
+
Listing now falls back to the public GitHub web repository pages
|
|
15
|
+
(`github.com/{user}?tab=repositories`, `github.com/orgs/{org}/repositories`)
|
|
16
|
+
and prints a warning, so public backups work again.
|
|
17
|
+
- GitHub API failures are no longer swallowed: the CLI reports the error and
|
|
18
|
+
exits with status 1 instead of exiting 0 with no output.
|
|
19
|
+
- Zip downloads no longer assume `main` when the default branch is unknown
|
|
20
|
+
(web fallback); the `HEAD` archive is used, and token mode omits the ref so
|
|
21
|
+
GitHub resolves the default branch.
|
|
22
|
+
|
|
23
|
+
### Tests
|
|
24
|
+
|
|
25
|
+
- 27 tests, including the API rate-limit fallback, pagination, organisation
|
|
26
|
+
detection via the web listing, and the `HEAD` archive path.
|
|
27
|
+
|
|
8
28
|
## [0.2.0] - 2026-11-12
|
|
9
29
|
|
|
10
30
|
### Added
|
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
Metadata-Version: 2.4
|
|
2
2
|
Name: dump-github
|
|
3
|
-
Version: 0.2.
|
|
3
|
+
Version: 0.2.1
|
|
4
4
|
Summary: Back up users' GitHub repositories in case they need them later.
|
|
5
5
|
Author-email: core2002 <core2002@aliyun.com>
|
|
6
6
|
License-Expression: MIT
|
|
@@ -81,6 +81,15 @@ GitHub’s API requires a proper `Authorization: Bearer <token>` header. This to
|
|
|
81
81
|
- **Private repositories**: Without a token, only public repos are listed and can be cloned or archived. With a token that has the right scopes, you can include private repos you are allowed to access (for example when backing up your own account).
|
|
82
82
|
- **Rate limits**: Authenticated API requests get a much higher limit than anonymous requests.
|
|
83
83
|
|
|
84
|
+
**Without a token**
|
|
85
|
+
|
|
86
|
+
Only public repositories can be listed and downloaded. The anonymous GitHub API allows
|
|
87
|
+
60 requests per hour per IP, which shared/NATed networks and CI runners exhaust quickly;
|
|
88
|
+
if `api.github.com` is rate-limited or otherwise unavailable, the tool automatically
|
|
89
|
+
falls back to the public GitHub web listing
|
|
90
|
+
(`github.com/{user}?tab=repositories` / `github.com/orgs/{org}/repositories`), prints a
|
|
91
|
+
warning and keeps going. Private repositories still require a token.
|
|
92
|
+
|
|
84
93
|
**How the token is used**
|
|
85
94
|
|
|
86
95
|
- **Listing repos**: sent as `Bearer` on the REST API and scoped strictly to the requested account:
|
|
@@ -97,9 +106,12 @@ Create a token under GitHub → **Settings** → **Developer settings** → **Pe
|
|
|
97
106
|
**Examples**
|
|
98
107
|
|
|
99
108
|
```bash
|
|
100
|
-
# Public repos only (no token)
|
|
109
|
+
# Public repos only (no token; falls back to the web listing if the API is rate-limited)
|
|
101
110
|
dump-github Core2002 -p
|
|
102
111
|
|
|
112
|
+
# Public backup without any credentials
|
|
113
|
+
dump-github Core2002 -d
|
|
114
|
+
|
|
103
115
|
# Token on the command line (private + higher API limits)
|
|
104
116
|
dump-github username --token ghp_xxxxxxxxxxxx
|
|
105
117
|
|
|
@@ -49,6 +49,15 @@ GitHub’s API requires a proper `Authorization: Bearer <token>` header. This to
|
|
|
49
49
|
- **Private repositories**: Without a token, only public repos are listed and can be cloned or archived. With a token that has the right scopes, you can include private repos you are allowed to access (for example when backing up your own account).
|
|
50
50
|
- **Rate limits**: Authenticated API requests get a much higher limit than anonymous requests.
|
|
51
51
|
|
|
52
|
+
**Without a token**
|
|
53
|
+
|
|
54
|
+
Only public repositories can be listed and downloaded. The anonymous GitHub API allows
|
|
55
|
+
60 requests per hour per IP, which shared/NATed networks and CI runners exhaust quickly;
|
|
56
|
+
if `api.github.com` is rate-limited or otherwise unavailable, the tool automatically
|
|
57
|
+
falls back to the public GitHub web listing
|
|
58
|
+
(`github.com/{user}?tab=repositories` / `github.com/orgs/{org}/repositories`), prints a
|
|
59
|
+
warning and keeps going. Private repositories still require a token.
|
|
60
|
+
|
|
52
61
|
**How the token is used**
|
|
53
62
|
|
|
54
63
|
- **Listing repos**: sent as `Bearer` on the REST API and scoped strictly to the requested account:
|
|
@@ -65,9 +74,12 @@ Create a token under GitHub → **Settings** → **Developer settings** → **Pe
|
|
|
65
74
|
**Examples**
|
|
66
75
|
|
|
67
76
|
```bash
|
|
68
|
-
# Public repos only (no token)
|
|
77
|
+
# Public repos only (no token; falls back to the web listing if the API is rate-limited)
|
|
69
78
|
dump-github Core2002 -p
|
|
70
79
|
|
|
80
|
+
# Public backup without any credentials
|
|
81
|
+
dump-github Core2002 -d
|
|
82
|
+
|
|
71
83
|
# Token on the command line (private + higher API limits)
|
|
72
84
|
dump-github username --token ghp_xxxxxxxxxxxx
|
|
73
85
|
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
__version__ = "0.2.1"
|
|
@@ -0,0 +1,345 @@
|
|
|
1
|
+
import re
|
|
2
|
+
import warnings
|
|
3
|
+
from urllib.parse import quote
|
|
4
|
+
|
|
5
|
+
import requests
|
|
6
|
+
|
|
7
|
+
GITHUB_API = "https://api.github.com"
|
|
8
|
+
GITHUB_WEB = "https://github.com"
|
|
9
|
+
PER_PAGE = 100
|
|
10
|
+
WEB_USER_AGENT = "dump-github (+https://github.com/Core2002/dump_github)"
|
|
11
|
+
|
|
12
|
+
|
|
13
|
+
class GitHubAPIError(RuntimeError):
|
|
14
|
+
"""GitHub could not be read through the REST API (or the web fallback)."""
|
|
15
|
+
|
|
16
|
+
def __init__(self, status_code, message):
|
|
17
|
+
self.status_code = status_code
|
|
18
|
+
self.message = message
|
|
19
|
+
super().__init__(message)
|
|
20
|
+
|
|
21
|
+
|
|
22
|
+
def _github_auth_headers(token):
|
|
23
|
+
"""GitHub REST API expects Authorization: Bearer <token> (PAT classic / fine-grained)."""
|
|
24
|
+
if not token:
|
|
25
|
+
return {}
|
|
26
|
+
token = token.strip()
|
|
27
|
+
if not token:
|
|
28
|
+
return {}
|
|
29
|
+
if token.lower().startswith(("bearer ", "token ", "basic ")):
|
|
30
|
+
return {"Authorization": token}
|
|
31
|
+
return {"Authorization": "Bearer {}".format(token)}
|
|
32
|
+
|
|
33
|
+
|
|
34
|
+
def _api_headers(token=""):
|
|
35
|
+
headers = {
|
|
36
|
+
"Accept": "application/vnd.github+json",
|
|
37
|
+
"X-GitHub-Api-Version": "2022-11-28",
|
|
38
|
+
}
|
|
39
|
+
headers.update(_github_auth_headers(token))
|
|
40
|
+
return headers
|
|
41
|
+
|
|
42
|
+
|
|
43
|
+
def _api_get(path, token=""):
|
|
44
|
+
"""GET one GitHub REST API path and return the decoded JSON.
|
|
45
|
+
|
|
46
|
+
Raises GitHubAPIError instead of silently returning an empty result, so
|
|
47
|
+
callers can distinguish "no repositories" from "request failed".
|
|
48
|
+
"""
|
|
49
|
+
try:
|
|
50
|
+
response = requests.get(
|
|
51
|
+
GITHUB_API + path, headers=_api_headers(token), timeout=30
|
|
52
|
+
)
|
|
53
|
+
except requests.RequestException as exc:
|
|
54
|
+
raise GitHubAPIError(None, "request failed: {}".format(exc)) from exc
|
|
55
|
+
if response.status_code != 200:
|
|
56
|
+
message = "HTTP {}".format(response.status_code)
|
|
57
|
+
try:
|
|
58
|
+
payload = response.json()
|
|
59
|
+
except ValueError:
|
|
60
|
+
payload = None
|
|
61
|
+
if isinstance(payload, dict) and payload.get("message"):
|
|
62
|
+
message = "{} (HTTP {})".format(payload["message"], response.status_code)
|
|
63
|
+
raise GitHubAPIError(response.status_code, message)
|
|
64
|
+
try:
|
|
65
|
+
return response.json()
|
|
66
|
+
except ValueError as exc:
|
|
67
|
+
raise GitHubAPIError(
|
|
68
|
+
response.status_code, "invalid JSON response from GitHub"
|
|
69
|
+
) from exc
|
|
70
|
+
|
|
71
|
+
|
|
72
|
+
def get_authenticated_login(token=""):
|
|
73
|
+
"""Return the login of the token owner, or None if there is no usable token."""
|
|
74
|
+
if not _github_auth_headers(token):
|
|
75
|
+
return None
|
|
76
|
+
try:
|
|
77
|
+
account = _api_get("/user", token)
|
|
78
|
+
except GitHubAPIError:
|
|
79
|
+
return None
|
|
80
|
+
if not isinstance(account, dict):
|
|
81
|
+
return None
|
|
82
|
+
return account.get("login") or None
|
|
83
|
+
|
|
84
|
+
|
|
85
|
+
def get_account_type(username, token=""):
|
|
86
|
+
"""Return ``"Organization"``/``"User"`` for ``username``, or None if unknown."""
|
|
87
|
+
quoted = quote(username, safe="")
|
|
88
|
+
try:
|
|
89
|
+
account = _api_get("/users/{}".format(quoted), token)
|
|
90
|
+
except GitHubAPIError:
|
|
91
|
+
account = None
|
|
92
|
+
if isinstance(account, dict) and account.get("type"):
|
|
93
|
+
return account["type"]
|
|
94
|
+
# ``/users/{org}`` is not guaranteed to resolve for organisations, so fall
|
|
95
|
+
# back to the org endpoint, which unambiguously identifies one.
|
|
96
|
+
try:
|
|
97
|
+
org = _api_get("/orgs/{}".format(quoted), token)
|
|
98
|
+
except GitHubAPIError:
|
|
99
|
+
org = None
|
|
100
|
+
if isinstance(org, dict) and org.get("login"):
|
|
101
|
+
return "Organization"
|
|
102
|
+
return None
|
|
103
|
+
|
|
104
|
+
|
|
105
|
+
def _repos_path(username, page, authenticated_login, account_type):
|
|
106
|
+
"""Build a listing API path that only contains repositories owned by ``username``.
|
|
107
|
+
|
|
108
|
+
``GET /user/repos`` (used by an earlier version whenever a token was
|
|
109
|
+
supplied) lists every repository the token owner can see, which includes
|
|
110
|
+
repositories of the organisations the token owner is a member of, and it
|
|
111
|
+
ignores ``username`` entirely. Listing must therefore be scoped to the
|
|
112
|
+
requested account:
|
|
113
|
+
|
|
114
|
+
* organisation -> ``/orgs/{org}/repos?type=all``; only repositories owned
|
|
115
|
+
by that organisation, including private ones the token may access
|
|
116
|
+
* own user account -> ``/user/repos?affiliation=owner``; keeps private
|
|
117
|
+
repos, drops organisation/collaborator repos
|
|
118
|
+
* any other user account -> ``/users/{username}/repos?type=owner``
|
|
119
|
+
"""
|
|
120
|
+
quoted = quote(username, safe="")
|
|
121
|
+
if (account_type or "").lower() == "organization":
|
|
122
|
+
return "/orgs/{}/repos?type=all&per_page={}&page={}".format(
|
|
123
|
+
quoted, PER_PAGE, page
|
|
124
|
+
)
|
|
125
|
+
own_account = bool(authenticated_login) and (
|
|
126
|
+
authenticated_login.lower() == username.lower()
|
|
127
|
+
)
|
|
128
|
+
if own_account:
|
|
129
|
+
return "/user/repos?affiliation=owner&per_page={}&page={}".format(
|
|
130
|
+
PER_PAGE, page
|
|
131
|
+
)
|
|
132
|
+
return "/users/{}/repos?type=owner&per_page={}&page={}".format(
|
|
133
|
+
quoted, PER_PAGE, page
|
|
134
|
+
)
|
|
135
|
+
|
|
136
|
+
|
|
137
|
+
def _is_owned_by(repo, username):
|
|
138
|
+
owner = repo.get("owner") or {}
|
|
139
|
+
login = owner.get("login") or ""
|
|
140
|
+
if login:
|
|
141
|
+
return login.lower() == username.lower()
|
|
142
|
+
full_name = repo.get("full_name") or ""
|
|
143
|
+
return full_name.split("/", 1)[0].lower() == username.lower()
|
|
144
|
+
|
|
145
|
+
|
|
146
|
+
def _fetch_repos_page(username, page, token="", authenticated_login=None,
|
|
147
|
+
account_type=None):
|
|
148
|
+
"""Fetch one raw page of repositories owned by ``username``."""
|
|
149
|
+
path = _repos_path(username, page, authenticated_login, account_type)
|
|
150
|
+
repos = _api_get(path, token)
|
|
151
|
+
if not isinstance(repos, list):
|
|
152
|
+
return []
|
|
153
|
+
return [repo for repo in repos if isinstance(repo, dict)]
|
|
154
|
+
|
|
155
|
+
|
|
156
|
+
def get_page_of_repos(username, page, token="", authenticated_login=None,
|
|
157
|
+
account_type=None):
|
|
158
|
+
username = username.strip()
|
|
159
|
+
if account_type is None:
|
|
160
|
+
account_type = get_account_type(username, token)
|
|
161
|
+
if authenticated_login is None:
|
|
162
|
+
authenticated_login = get_authenticated_login(token)
|
|
163
|
+
repos = _fetch_repos_page(
|
|
164
|
+
username, page, token, authenticated_login, account_type
|
|
165
|
+
)
|
|
166
|
+
return [repo for repo in repos if _is_owned_by(repo, username)]
|
|
167
|
+
|
|
168
|
+
|
|
169
|
+
# --- public web fallback -------------------------------------------------
|
|
170
|
+
#
|
|
171
|
+
# api.github.com allows only 60 unauthenticated requests per hour *per IP*.
|
|
172
|
+
# Shared/NATed networks and CI runners exhaust that quickly, which used to make
|
|
173
|
+
# ``dump-github user`` silently return nothing. github.com's repository pages
|
|
174
|
+
# are not subject to that API quota, so they are used as a read-only fallback
|
|
175
|
+
# for public repositories when no token is configured.
|
|
176
|
+
|
|
177
|
+
_USER_REPO_TAG_RE = re.compile(r'<a\b[^>]*itemprop="name codeRepository"[^>]*>')
|
|
178
|
+
_WEB_REPO_HREF_RE = re.compile(r'href="/([^"/]+)/([^"/?#]+)"')
|
|
179
|
+
_WEB_NEXT_RE = re.compile(r'rel="next"')
|
|
180
|
+
_WEB_NON_REPO_PATHS = {
|
|
181
|
+
"followers",
|
|
182
|
+
"following",
|
|
183
|
+
"people",
|
|
184
|
+
"packages",
|
|
185
|
+
"projects",
|
|
186
|
+
"repositories",
|
|
187
|
+
"settings",
|
|
188
|
+
"sponsoring",
|
|
189
|
+
}
|
|
190
|
+
|
|
191
|
+
|
|
192
|
+
def _web_get(url):
|
|
193
|
+
try:
|
|
194
|
+
response = requests.get(
|
|
195
|
+
url, headers={"User-Agent": WEB_USER_AGENT}, timeout=30
|
|
196
|
+
)
|
|
197
|
+
except requests.RequestException as exc:
|
|
198
|
+
raise GitHubAPIError(None, "web request failed: {}".format(exc)) from exc
|
|
199
|
+
if response.status_code != 200:
|
|
200
|
+
raise GitHubAPIError(
|
|
201
|
+
response.status_code,
|
|
202
|
+
"github.com returned HTTP {}".format(response.status_code),
|
|
203
|
+
)
|
|
204
|
+
return response.text
|
|
205
|
+
|
|
206
|
+
|
|
207
|
+
def _web_repo(owner, name):
|
|
208
|
+
return {
|
|
209
|
+
"name": name,
|
|
210
|
+
"full_name": "{}/{}".format(owner, name),
|
|
211
|
+
"owner": {"login": owner},
|
|
212
|
+
"clone_url": "{}/{}/{}.git".format(GITHUB_WEB, owner, name),
|
|
213
|
+
# The default branch is not exposed by the web listing; zip downloads
|
|
214
|
+
# then use the HEAD archive instead.
|
|
215
|
+
}
|
|
216
|
+
|
|
217
|
+
|
|
218
|
+
def _parse_user_repos_page(html, username):
|
|
219
|
+
repos = []
|
|
220
|
+
seen = set()
|
|
221
|
+
for tag in _USER_REPO_TAG_RE.findall(html):
|
|
222
|
+
match = _WEB_REPO_HREF_RE.search(tag)
|
|
223
|
+
if not match:
|
|
224
|
+
continue
|
|
225
|
+
owner, name = match.group(1), match.group(2)
|
|
226
|
+
if owner.lower() != username.lower() or name in seen:
|
|
227
|
+
continue
|
|
228
|
+
seen.add(name)
|
|
229
|
+
repos.append(_web_repo(owner, name))
|
|
230
|
+
return repos
|
|
231
|
+
|
|
232
|
+
|
|
233
|
+
def _parse_org_repos_page(html, org):
|
|
234
|
+
repos = []
|
|
235
|
+
seen = set()
|
|
236
|
+
for owner, name in _WEB_REPO_HREF_RE.findall(html):
|
|
237
|
+
if owner.lower() != org.lower():
|
|
238
|
+
continue
|
|
239
|
+
if name.lower() in _WEB_NON_REPO_PATHS or name in seen:
|
|
240
|
+
continue
|
|
241
|
+
seen.add(name)
|
|
242
|
+
repos.append(_web_repo(owner, name))
|
|
243
|
+
return repos
|
|
244
|
+
|
|
245
|
+
|
|
246
|
+
def _web_page_url(username, page, is_org):
|
|
247
|
+
quoted = quote(username, safe="")
|
|
248
|
+
if is_org:
|
|
249
|
+
return "{}/orgs/{}/repositories?page={}".format(GITHUB_WEB, quoted, page)
|
|
250
|
+
return "{}/{}?tab=repositories&page={}".format(GITHUB_WEB, quoted, page)
|
|
251
|
+
|
|
252
|
+
|
|
253
|
+
def _parse_web_page(html, username, is_org):
|
|
254
|
+
if is_org:
|
|
255
|
+
return _parse_org_repos_page(html, username)
|
|
256
|
+
return _parse_user_repos_page(html, username)
|
|
257
|
+
|
|
258
|
+
|
|
259
|
+
def _search_via_web(username, account_type):
|
|
260
|
+
"""List public repositories from github.com when the REST API is unavailable."""
|
|
261
|
+
is_org = (account_type or "").lower() == "organization"
|
|
262
|
+
first = _web_get(_web_page_url(username, 1, is_org))
|
|
263
|
+
repos = _parse_web_page(first, username, is_org)
|
|
264
|
+
if not repos and account_type is None:
|
|
265
|
+
# The account may be an organisation: its profile page redirects to
|
|
266
|
+
# github.com/{org} and does not contain the plain repository list.
|
|
267
|
+
try:
|
|
268
|
+
org_first = _web_get(_web_page_url(username, 1, True))
|
|
269
|
+
except GitHubAPIError as exc:
|
|
270
|
+
if exc.status_code == 404:
|
|
271
|
+
return []
|
|
272
|
+
raise
|
|
273
|
+
org_repos = _parse_org_repos_page(org_first, username)
|
|
274
|
+
if not org_repos:
|
|
275
|
+
return []
|
|
276
|
+
is_org = True
|
|
277
|
+
repos = org_repos
|
|
278
|
+
first = org_first
|
|
279
|
+
if not repos:
|
|
280
|
+
return []
|
|
281
|
+
page = 1
|
|
282
|
+
while _WEB_NEXT_RE.search(first):
|
|
283
|
+
page += 1
|
|
284
|
+
html = _web_get(_web_page_url(username, page, is_org))
|
|
285
|
+
page_repos = _parse_web_page(html, username, is_org)
|
|
286
|
+
if not page_repos:
|
|
287
|
+
break
|
|
288
|
+
repos.extend(page_repos)
|
|
289
|
+
first = html
|
|
290
|
+
return repos
|
|
291
|
+
|
|
292
|
+
|
|
293
|
+
def _search_via_api(username, token, authenticated_login, account_type):
|
|
294
|
+
page = 0
|
|
295
|
+
res_repos = []
|
|
296
|
+
while True:
|
|
297
|
+
page += 1
|
|
298
|
+
repos = _fetch_repos_page(
|
|
299
|
+
username, page, token, authenticated_login, account_type
|
|
300
|
+
)
|
|
301
|
+
if len(repos) == 0:
|
|
302
|
+
break
|
|
303
|
+
res_repos += repos
|
|
304
|
+
if len(repos) < PER_PAGE:
|
|
305
|
+
break
|
|
306
|
+
return res_repos
|
|
307
|
+
|
|
308
|
+
|
|
309
|
+
def search_user_repos(username, token=""):
|
|
310
|
+
username = username.strip()
|
|
311
|
+
authenticated_login = get_authenticated_login(token)
|
|
312
|
+
account_type = get_account_type(username, token)
|
|
313
|
+
try:
|
|
314
|
+
repos = _search_via_api(
|
|
315
|
+
username, token, authenticated_login, account_type
|
|
316
|
+
)
|
|
317
|
+
except GitHubAPIError as exc:
|
|
318
|
+
if token or exc.status_code == 404:
|
|
319
|
+
raise
|
|
320
|
+
warnings.warn(
|
|
321
|
+
"GitHub API request failed ({}); falling back to the public GitHub "
|
|
322
|
+
"web listing. Only public repositories can be listed without a "
|
|
323
|
+
"token.".format(exc),
|
|
324
|
+
RuntimeWarning,
|
|
325
|
+
stacklevel=2,
|
|
326
|
+
)
|
|
327
|
+
repos = _search_via_web(username, account_type)
|
|
328
|
+
|
|
329
|
+
# Never return repositories that belong to somebody else (e.g. a user repo
|
|
330
|
+
# returned for an organisation, or an organisation the token owner is in).
|
|
331
|
+
result = []
|
|
332
|
+
seen = set()
|
|
333
|
+
for repo in repos:
|
|
334
|
+
if not _is_owned_by(repo, username):
|
|
335
|
+
continue
|
|
336
|
+
key = (repo.get("full_name") or repo.get("name") or "").lower()
|
|
337
|
+
if key in seen:
|
|
338
|
+
continue
|
|
339
|
+
seen.add(key)
|
|
340
|
+
result.append(repo)
|
|
341
|
+
return result
|
|
342
|
+
|
|
343
|
+
|
|
344
|
+
# Backward-compatible alias for the historical typo
|
|
345
|
+
serach_user_repos = search_user_repos
|
|
@@ -1,9 +1,10 @@
|
|
|
1
1
|
import argparse
|
|
2
2
|
import os
|
|
3
3
|
import subprocess
|
|
4
|
+
import sys
|
|
4
5
|
|
|
5
6
|
from ._version import __version__
|
|
6
|
-
from .lib import search_user_repos
|
|
7
|
+
from .lib import GitHubAPIError, search_user_repos
|
|
7
8
|
|
|
8
9
|
# One-shot credential helper: git asks for credentials, the helper answers with
|
|
9
10
|
# the token from the environment. The token is therefore never part of the
|
|
@@ -67,7 +68,7 @@ def clone_repo(repo, token=""):
|
|
|
67
68
|
|
|
68
69
|
|
|
69
70
|
def download_zip(user_name, reop, token="", limit_size=100):
|
|
70
|
-
ref = reop.get("default_branch")
|
|
71
|
+
ref = reop.get("default_branch")
|
|
71
72
|
# Always archive the repository the API actually listed. Using the
|
|
72
73
|
# positional username here would point at the wrong owner whenever the
|
|
73
74
|
# repo does not belong to that user.
|
|
@@ -88,7 +89,9 @@ def download_zip(user_name, reop, token="", limit_size=100):
|
|
|
88
89
|
if max_size > 0:
|
|
89
90
|
curl_cmd.extend(["--max-filesize", str(max_size)])
|
|
90
91
|
if token:
|
|
91
|
-
url = "https://api.github.com/repos/{}/zipball
|
|
92
|
+
url = "https://api.github.com/repos/{}/zipball".format(full_name)
|
|
93
|
+
if ref:
|
|
94
|
+
url += "/{}".format(ref)
|
|
92
95
|
curl_cmd.extend(
|
|
93
96
|
[
|
|
94
97
|
"-H",
|
|
@@ -101,7 +104,12 @@ def download_zip(user_name, reop, token="", limit_size=100):
|
|
|
101
104
|
]
|
|
102
105
|
)
|
|
103
106
|
else:
|
|
104
|
-
|
|
107
|
+
# The web listing does not know the default branch; GitHub resolves
|
|
108
|
+
# HEAD to it.
|
|
109
|
+
if ref:
|
|
110
|
+
url = "https://github.com/{}/archive/refs/heads/{}.zip".format(full_name, ref)
|
|
111
|
+
else:
|
|
112
|
+
url = "https://github.com/{}/archive/HEAD.zip".format(full_name)
|
|
105
113
|
curl_cmd.extend(["-o", file_name, url])
|
|
106
114
|
print("Download {} : {}".format(reop["name"], url))
|
|
107
115
|
subprocess.run(curl_cmd, check=False)
|
|
@@ -117,7 +125,20 @@ def download_zip(user_name, reop, token="", limit_size=100):
|
|
|
117
125
|
def main(argv=None):
|
|
118
126
|
args = parser.parse_args(argv)
|
|
119
127
|
token = (args.token or "").strip() or (os.environ.get("GITHUB_TOKEN") or "").strip()
|
|
120
|
-
|
|
128
|
+
try:
|
|
129
|
+
repos = search_user_repos(args.username, token)
|
|
130
|
+
except GitHubAPIError as exc:
|
|
131
|
+
print(
|
|
132
|
+
"error: cannot list repositories for {}: {}".format(args.username, exc),
|
|
133
|
+
file=sys.stderr,
|
|
134
|
+
)
|
|
135
|
+
raise SystemExit(1)
|
|
136
|
+
if not repos:
|
|
137
|
+
print(
|
|
138
|
+
"No public repositories found for {}.".format(args.username),
|
|
139
|
+
file=sys.stderr,
|
|
140
|
+
)
|
|
141
|
+
return
|
|
121
142
|
for repo in repos:
|
|
122
143
|
if args.print:
|
|
123
144
|
print(repo["clone_url"])
|
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
Metadata-Version: 2.4
|
|
2
2
|
Name: dump-github
|
|
3
|
-
Version: 0.2.
|
|
3
|
+
Version: 0.2.1
|
|
4
4
|
Summary: Back up users' GitHub repositories in case they need them later.
|
|
5
5
|
Author-email: core2002 <core2002@aliyun.com>
|
|
6
6
|
License-Expression: MIT
|
|
@@ -81,6 +81,15 @@ GitHub’s API requires a proper `Authorization: Bearer <token>` header. This to
|
|
|
81
81
|
- **Private repositories**: Without a token, only public repos are listed and can be cloned or archived. With a token that has the right scopes, you can include private repos you are allowed to access (for example when backing up your own account).
|
|
82
82
|
- **Rate limits**: Authenticated API requests get a much higher limit than anonymous requests.
|
|
83
83
|
|
|
84
|
+
**Without a token**
|
|
85
|
+
|
|
86
|
+
Only public repositories can be listed and downloaded. The anonymous GitHub API allows
|
|
87
|
+
60 requests per hour per IP, which shared/NATed networks and CI runners exhaust quickly;
|
|
88
|
+
if `api.github.com` is rate-limited or otherwise unavailable, the tool automatically
|
|
89
|
+
falls back to the public GitHub web listing
|
|
90
|
+
(`github.com/{user}?tab=repositories` / `github.com/orgs/{org}/repositories`), prints a
|
|
91
|
+
warning and keeps going. Private repositories still require a token.
|
|
92
|
+
|
|
84
93
|
**How the token is used**
|
|
85
94
|
|
|
86
95
|
- **Listing repos**: sent as `Bearer` on the REST API and scoped strictly to the requested account:
|
|
@@ -97,9 +106,12 @@ Create a token under GitHub → **Settings** → **Developer settings** → **Pe
|
|
|
97
106
|
**Examples**
|
|
98
107
|
|
|
99
108
|
```bash
|
|
100
|
-
# Public repos only (no token)
|
|
109
|
+
# Public repos only (no token; falls back to the web listing if the API is rate-limited)
|
|
101
110
|
dump-github Core2002 -p
|
|
102
111
|
|
|
112
|
+
# Public backup without any credentials
|
|
113
|
+
dump-github Core2002 -d
|
|
114
|
+
|
|
103
115
|
# Token on the command line (private + higher API limits)
|
|
104
116
|
dump-github username --token ghp_xxxxxxxxxxxx
|
|
105
117
|
|
|
@@ -1,3 +1,4 @@
|
|
|
1
|
+
import json
|
|
1
2
|
import os
|
|
2
3
|
import sys
|
|
3
4
|
import unittest
|
|
@@ -12,6 +13,7 @@ class FakeResponse:
|
|
|
12
13
|
def __init__(self, status_code, payload):
|
|
13
14
|
self.status_code = status_code
|
|
14
15
|
self._payload = payload
|
|
16
|
+
self.text = payload if isinstance(payload, str) else json.dumps(payload)
|
|
15
17
|
|
|
16
18
|
def json(self):
|
|
17
19
|
return self._payload
|
|
@@ -26,6 +28,37 @@ def make_repo(owner, name):
|
|
|
26
28
|
}
|
|
27
29
|
|
|
28
30
|
|
|
31
|
+
def url_matches(url, prefix):
|
|
32
|
+
return (
|
|
33
|
+
url == prefix
|
|
34
|
+
or url.startswith(prefix + "?")
|
|
35
|
+
or url.startswith(prefix + "/")
|
|
36
|
+
)
|
|
37
|
+
|
|
38
|
+
|
|
39
|
+
def user_repos_html(names, next_page=False):
|
|
40
|
+
body = "".join(
|
|
41
|
+
'<a href="/alice/{}" itemprop="name codeRepository" >{}</a>'.format(
|
|
42
|
+
name, name
|
|
43
|
+
)
|
|
44
|
+
for name in names
|
|
45
|
+
)
|
|
46
|
+
if next_page:
|
|
47
|
+
body += '<a rel="next" href="/alice?tab=repositories&page=2">Next</a>'
|
|
48
|
+
return body
|
|
49
|
+
|
|
50
|
+
|
|
51
|
+
def org_repos_html(org, names, next_page=False):
|
|
52
|
+
body = "".join(
|
|
53
|
+
'<a href="/{}/{}">{}</a>'.format(org, name, name) for name in names
|
|
54
|
+
)
|
|
55
|
+
if next_page:
|
|
56
|
+
body += '<a rel="next" href="/orgs/{}/repositories?page=2">Next</a>'.format(
|
|
57
|
+
org
|
|
58
|
+
)
|
|
59
|
+
return body
|
|
60
|
+
|
|
61
|
+
|
|
29
62
|
class ListingTests(unittest.TestCase):
|
|
30
63
|
def setUp(self):
|
|
31
64
|
self.calls = []
|
|
@@ -290,5 +323,132 @@ class ListingTests(unittest.TestCase):
|
|
|
290
323
|
self.assertEqual([repo["name"] for repo in repos], ["public-repo"])
|
|
291
324
|
|
|
292
325
|
|
|
326
|
+
class WebFallbackTests(unittest.TestCase):
|
|
327
|
+
def setUp(self):
|
|
328
|
+
self.calls = []
|
|
329
|
+
|
|
330
|
+
def fake_get(self, web):
|
|
331
|
+
def get(url, headers=None, timeout=None):
|
|
332
|
+
self.calls.append((url, headers or {}))
|
|
333
|
+
if url.startswith("https://api.github.com"):
|
|
334
|
+
return FakeResponse(403, {"message": "API rate limit exceeded"})
|
|
335
|
+
return web(url)
|
|
336
|
+
|
|
337
|
+
return get
|
|
338
|
+
|
|
339
|
+
def urls(self):
|
|
340
|
+
return [url for url, _ in self.calls]
|
|
341
|
+
|
|
342
|
+
def test_api_403_falls_back_to_web_listing_for_user(self):
|
|
343
|
+
def web(url):
|
|
344
|
+
if url.startswith("https://github.com/alice?tab=repositories"):
|
|
345
|
+
return FakeResponse(200, user_repos_html(["one", "two"]))
|
|
346
|
+
return FakeResponse(404, {"message": "Not Found"})
|
|
347
|
+
|
|
348
|
+
with mock.patch.object(lib.requests, "get", self.fake_get(web)):
|
|
349
|
+
with self.assertWarns(RuntimeWarning) as caught:
|
|
350
|
+
repos = lib.search_user_repos("alice")
|
|
351
|
+
|
|
352
|
+
self.assertEqual([repo["name"] for repo in repos], ["one", "two"])
|
|
353
|
+
self.assertIn("rate limit", str(caught.warning))
|
|
354
|
+
self.assertIn(
|
|
355
|
+
"https://github.com/alice?tab=repositories&page=1", self.urls()
|
|
356
|
+
)
|
|
357
|
+
self.assertTrue(
|
|
358
|
+
all("Authorization" not in headers for _, headers in self.calls)
|
|
359
|
+
)
|
|
360
|
+
self.assertEqual(
|
|
361
|
+
repos[0]["clone_url"], "https://github.com/alice/one.git"
|
|
362
|
+
)
|
|
363
|
+
self.assertNotIn("default_branch", repos[0])
|
|
364
|
+
|
|
365
|
+
def test_web_fallback_paginates_until_no_next_link(self):
|
|
366
|
+
first_page = ["repo-{}".format(i) for i in range(30)]
|
|
367
|
+
second_page = ["last-a", "last-b"]
|
|
368
|
+
|
|
369
|
+
def web(url):
|
|
370
|
+
if "page=1" in url:
|
|
371
|
+
return FakeResponse(200, user_repos_html(first_page, next_page=True))
|
|
372
|
+
if "page=2" in url:
|
|
373
|
+
return FakeResponse(200, user_repos_html(second_page))
|
|
374
|
+
return FakeResponse(404, {"message": "Not Found"})
|
|
375
|
+
|
|
376
|
+
with mock.patch.object(lib.requests, "get", self.fake_get(web)):
|
|
377
|
+
with self.assertWarns(RuntimeWarning):
|
|
378
|
+
repos = lib.search_user_repos("alice")
|
|
379
|
+
|
|
380
|
+
self.assertEqual(len(repos), 32)
|
|
381
|
+
self.assertEqual(repos[-1]["name"], "last-b")
|
|
382
|
+
self.assertFalse(any("page=3" in url for url in self.urls()))
|
|
383
|
+
|
|
384
|
+
def test_web_fallback_detects_organisation(self):
|
|
385
|
+
def web(url):
|
|
386
|
+
if url.startswith("https://github.com/acme-org?tab=repositories"):
|
|
387
|
+
return FakeResponse(200, "<html>organisation profile</html>")
|
|
388
|
+
if url.startswith("https://github.com/orgs/acme-org/repositories"):
|
|
389
|
+
return FakeResponse(200, org_repos_html("acme-org", ["org-a", "org-b"]))
|
|
390
|
+
return FakeResponse(404, {"message": "Not Found"})
|
|
391
|
+
|
|
392
|
+
with mock.patch.object(lib.requests, "get", self.fake_get(web)):
|
|
393
|
+
with self.assertWarns(RuntimeWarning):
|
|
394
|
+
repos = lib.search_user_repos("acme-org")
|
|
395
|
+
|
|
396
|
+
self.assertEqual([repo["name"] for repo in repos], ["org-a", "org-b"])
|
|
397
|
+
self.assertIn(
|
|
398
|
+
"https://github.com/orgs/acme-org/repositories?page=1", self.urls()
|
|
399
|
+
)
|
|
400
|
+
self.assertEqual(
|
|
401
|
+
repos[0]["full_name"], "acme-org/org-a"
|
|
402
|
+
)
|
|
403
|
+
|
|
404
|
+
def test_web_fallback_returns_empty_for_missing_account(self):
|
|
405
|
+
def web(url):
|
|
406
|
+
if url.startswith("https://github.com/nobody?tab=repositories"):
|
|
407
|
+
return FakeResponse(200, "<html></html>")
|
|
408
|
+
return FakeResponse(404, {"message": "Not Found"})
|
|
409
|
+
|
|
410
|
+
with mock.patch.object(lib.requests, "get", self.fake_get(web)):
|
|
411
|
+
with self.assertWarns(RuntimeWarning):
|
|
412
|
+
repos = lib.search_user_repos("nobody")
|
|
413
|
+
|
|
414
|
+
self.assertEqual(repos, [])
|
|
415
|
+
|
|
416
|
+
def test_token_mode_403_raises_and_does_not_scrape(self):
|
|
417
|
+
def fake_get(url, headers=None, timeout=None):
|
|
418
|
+
self.calls.append((url, headers or {}))
|
|
419
|
+
if url == "https://api.github.com/user":
|
|
420
|
+
return FakeResponse(200, {"login": "bob"})
|
|
421
|
+
if url == "https://api.github.com/users/alice":
|
|
422
|
+
return FakeResponse(200, {"login": "alice", "type": "User"})
|
|
423
|
+
if url.startswith("https://api.github.com/users/alice/repos"):
|
|
424
|
+
return FakeResponse(403, {"message": "API rate limit exceeded"})
|
|
425
|
+
return FakeResponse(200, user_repos_html(["should-not-be-used"]))
|
|
426
|
+
|
|
427
|
+
with mock.patch.object(lib.requests, "get", fake_get):
|
|
428
|
+
with self.assertRaises(lib.GitHubAPIError) as caught:
|
|
429
|
+
lib.search_user_repos("alice", token="ghp_test")
|
|
430
|
+
|
|
431
|
+
self.assertEqual(caught.exception.status_code, 403)
|
|
432
|
+
self.assertFalse(
|
|
433
|
+
any(url.startswith("https://github.com/") for url in self.urls())
|
|
434
|
+
)
|
|
435
|
+
|
|
436
|
+
def test_api_404_is_reported_instead_of_scraping(self):
|
|
437
|
+
def fake_get(url, headers=None, timeout=None):
|
|
438
|
+
self.calls.append((url, headers or {}))
|
|
439
|
+
if url.startswith("https://api.github.com"):
|
|
440
|
+
return FakeResponse(404, {"message": "Not Found"})
|
|
441
|
+
return FakeResponse(200, user_repos_html(["should-not-be-used"]))
|
|
442
|
+
|
|
443
|
+
with mock.patch.object(lib.requests, "get", fake_get):
|
|
444
|
+
with self.assertRaises(lib.GitHubAPIError) as caught:
|
|
445
|
+
lib.search_user_repos("ghost")
|
|
446
|
+
|
|
447
|
+
self.assertEqual(caught.exception.status_code, 404)
|
|
448
|
+
self.assertFalse(
|
|
449
|
+
any(url.startswith("https://github.com/") for url in self.urls())
|
|
450
|
+
)
|
|
451
|
+
|
|
452
|
+
|
|
293
453
|
if __name__ == "__main__":
|
|
294
454
|
unittest.main()
|
|
@@ -1,4 +1,6 @@
|
|
|
1
|
+
import contextlib
|
|
1
2
|
import importlib
|
|
3
|
+
import io
|
|
2
4
|
import os
|
|
3
5
|
import sys
|
|
4
6
|
import unittest
|
|
@@ -100,5 +102,75 @@ class CloneTests(unittest.TestCase):
|
|
|
100
102
|
self.assertNotIn("DUMP_GITHUB_TOKEN", result.stdout)
|
|
101
103
|
|
|
102
104
|
|
|
105
|
+
class CliBehaviourTests(unittest.TestCase):
|
|
106
|
+
def setUp(self):
|
|
107
|
+
self.calls = []
|
|
108
|
+
|
|
109
|
+
def fake_run(self, cmd, **kwargs):
|
|
110
|
+
self.calls.append((cmd, kwargs))
|
|
111
|
+
return mock.Mock(returncode=0)
|
|
112
|
+
|
|
113
|
+
def test_zip_without_token_uses_head_when_branch_unknown(self):
|
|
114
|
+
repo = {"name": "repo", "full_name": "alice/repo"}
|
|
115
|
+
with mock.patch.object(main.subprocess, "run", self.fake_run):
|
|
116
|
+
main.download_zip("alice", repo, limit_size=0)
|
|
117
|
+
|
|
118
|
+
cmd = self.calls[0][0]
|
|
119
|
+
self.assertEqual(
|
|
120
|
+
cmd[-1], "https://github.com/alice/repo/archive/HEAD.zip"
|
|
121
|
+
)
|
|
122
|
+
|
|
123
|
+
def test_zip_with_token_and_known_branch_uses_zipball_ref(self):
|
|
124
|
+
repo = {
|
|
125
|
+
"name": "repo",
|
|
126
|
+
"full_name": "alice/repo",
|
|
127
|
+
"default_branch": "main",
|
|
128
|
+
}
|
|
129
|
+
with mock.patch.object(main.subprocess, "run", self.fake_run):
|
|
130
|
+
main.download_zip("alice", repo, token="ghp_test", limit_size=0)
|
|
131
|
+
|
|
132
|
+
cmd = self.calls[0][0]
|
|
133
|
+
self.assertIn(
|
|
134
|
+
"https://api.github.com/repos/alice/repo/zipball/main", cmd
|
|
135
|
+
)
|
|
136
|
+
self.assertIn("Authorization: Bearer ghp_test", cmd)
|
|
137
|
+
|
|
138
|
+
def test_zip_with_token_and_unknown_branch_omits_ref(self):
|
|
139
|
+
repo = {"name": "repo", "full_name": "alice/repo"}
|
|
140
|
+
with mock.patch.object(main.subprocess, "run", self.fake_run):
|
|
141
|
+
main.download_zip("alice", repo, token="ghp_test", limit_size=0)
|
|
142
|
+
|
|
143
|
+
cmd = self.calls[0][0]
|
|
144
|
+
self.assertIn("https://api.github.com/repos/alice/repo/zipball", cmd)
|
|
145
|
+
self.assertFalse(
|
|
146
|
+
any("/zipball/" in part for part in cmd)
|
|
147
|
+
)
|
|
148
|
+
|
|
149
|
+
def test_listing_error_exits_nonzero_with_message(self):
|
|
150
|
+
err = io.StringIO()
|
|
151
|
+
with mock.patch.object(
|
|
152
|
+
main,
|
|
153
|
+
"search_user_repos",
|
|
154
|
+
side_effect=main.GitHubAPIError(
|
|
155
|
+
403, "API rate limit exceeded (HTTP 403)"
|
|
156
|
+
),
|
|
157
|
+
):
|
|
158
|
+
with self.assertRaises(SystemExit) as caught:
|
|
159
|
+
with contextlib.redirect_stderr(err):
|
|
160
|
+
main.main(["alice"])
|
|
161
|
+
|
|
162
|
+
self.assertEqual(caught.exception.code, 1)
|
|
163
|
+
self.assertIn("alice", err.getvalue())
|
|
164
|
+
self.assertIn("rate limit", err.getvalue())
|
|
165
|
+
|
|
166
|
+
def test_empty_listing_reports_message(self):
|
|
167
|
+
err = io.StringIO()
|
|
168
|
+
with mock.patch.object(main, "search_user_repos", return_value=[]):
|
|
169
|
+
with contextlib.redirect_stderr(err):
|
|
170
|
+
main.main(["alice"])
|
|
171
|
+
|
|
172
|
+
self.assertIn("No public repositories found", err.getvalue())
|
|
173
|
+
|
|
174
|
+
|
|
103
175
|
if __name__ == "__main__":
|
|
104
176
|
unittest.main()
|
|
@@ -1 +0,0 @@
|
|
|
1
|
-
__version__ = "0.2.0"
|
|
@@ -1,158 +0,0 @@
|
|
|
1
|
-
from urllib.parse import quote
|
|
2
|
-
|
|
3
|
-
import requests
|
|
4
|
-
|
|
5
|
-
GITHUB_API = "https://api.github.com"
|
|
6
|
-
PER_PAGE = 100
|
|
7
|
-
|
|
8
|
-
|
|
9
|
-
def _github_auth_headers(token):
|
|
10
|
-
"""GitHub REST API expects Authorization: Bearer <token> (PAT classic / fine-grained)."""
|
|
11
|
-
if not token:
|
|
12
|
-
return {}
|
|
13
|
-
token = token.strip()
|
|
14
|
-
if not token:
|
|
15
|
-
return {}
|
|
16
|
-
if token.lower().startswith(("bearer ", "token ", "basic ")):
|
|
17
|
-
return {"Authorization": token}
|
|
18
|
-
return {"Authorization": "Bearer {}".format(token)}
|
|
19
|
-
|
|
20
|
-
|
|
21
|
-
def _api_headers(token=""):
|
|
22
|
-
headers = {
|
|
23
|
-
"Accept": "application/vnd.github+json",
|
|
24
|
-
"X-GitHub-Api-Version": "2022-11-28",
|
|
25
|
-
}
|
|
26
|
-
headers.update(_github_auth_headers(token))
|
|
27
|
-
return headers
|
|
28
|
-
|
|
29
|
-
|
|
30
|
-
def _api_get(path, token=""):
|
|
31
|
-
"""GET one GitHub REST API path and return the decoded JSON, or None on failure."""
|
|
32
|
-
try:
|
|
33
|
-
response = requests.get(
|
|
34
|
-
GITHUB_API + path, headers=_api_headers(token), timeout=30
|
|
35
|
-
)
|
|
36
|
-
except requests.RequestException:
|
|
37
|
-
return None
|
|
38
|
-
if response.status_code != 200:
|
|
39
|
-
return None
|
|
40
|
-
try:
|
|
41
|
-
return response.json()
|
|
42
|
-
except ValueError:
|
|
43
|
-
return None
|
|
44
|
-
|
|
45
|
-
|
|
46
|
-
def get_authenticated_login(token=""):
|
|
47
|
-
"""Return the login of the token owner, or None if there is no usable token."""
|
|
48
|
-
if not _github_auth_headers(token):
|
|
49
|
-
return None
|
|
50
|
-
account = _api_get("/user", token)
|
|
51
|
-
if not isinstance(account, dict):
|
|
52
|
-
return None
|
|
53
|
-
return account.get("login") or None
|
|
54
|
-
|
|
55
|
-
|
|
56
|
-
def get_account_type(username, token=""):
|
|
57
|
-
"""Return ``"Organization"``/``"User"`` for ``username``, or None if unknown."""
|
|
58
|
-
quoted = quote(username, safe="")
|
|
59
|
-
account = _api_get("/users/{}".format(quoted), token)
|
|
60
|
-
if isinstance(account, dict) and account.get("type"):
|
|
61
|
-
return account["type"]
|
|
62
|
-
# ``/users/{org}`` is not guaranteed to resolve for organisations, so fall
|
|
63
|
-
# back to the org endpoint, which unambiguously identifies one.
|
|
64
|
-
org = _api_get("/orgs/{}".format(quoted), token)
|
|
65
|
-
if isinstance(org, dict) and org.get("login"):
|
|
66
|
-
return "Organization"
|
|
67
|
-
return None
|
|
68
|
-
|
|
69
|
-
|
|
70
|
-
def _repos_path(username, page, authenticated_login, account_type):
|
|
71
|
-
"""Build a listing API path that only contains repositories owned by ``username``.
|
|
72
|
-
|
|
73
|
-
``GET /user/repos`` (used by an earlier version whenever a token was
|
|
74
|
-
supplied) lists every repository the token owner can see, which includes
|
|
75
|
-
repositories of the organisations the token owner is a member of, and it
|
|
76
|
-
ignores ``username`` entirely. Listing must therefore be scoped to the
|
|
77
|
-
requested account:
|
|
78
|
-
|
|
79
|
-
* organisation -> ``/orgs/{org}/repos?type=all``; only repositories owned
|
|
80
|
-
by that organisation, including private ones the token may access
|
|
81
|
-
* own user account -> ``/user/repos?affiliation=owner``; keeps private
|
|
82
|
-
repos, drops organisation/collaborator repos
|
|
83
|
-
* any other user account -> ``/users/{username}/repos?type=owner``
|
|
84
|
-
"""
|
|
85
|
-
quoted = quote(username, safe="")
|
|
86
|
-
if (account_type or "").lower() == "organization":
|
|
87
|
-
return "/orgs/{}/repos?type=all&per_page={}&page={}".format(
|
|
88
|
-
quoted, PER_PAGE, page
|
|
89
|
-
)
|
|
90
|
-
own_account = bool(authenticated_login) and (
|
|
91
|
-
authenticated_login.lower() == username.lower()
|
|
92
|
-
)
|
|
93
|
-
if own_account:
|
|
94
|
-
return "/user/repos?affiliation=owner&per_page={}&page={}".format(
|
|
95
|
-
PER_PAGE, page
|
|
96
|
-
)
|
|
97
|
-
return "/users/{}/repos?type=owner&per_page={}&page={}".format(
|
|
98
|
-
quoted, PER_PAGE, page
|
|
99
|
-
)
|
|
100
|
-
|
|
101
|
-
|
|
102
|
-
def _is_owned_by(repo, username):
|
|
103
|
-
owner = repo.get("owner") or {}
|
|
104
|
-
login = owner.get("login") or ""
|
|
105
|
-
if login:
|
|
106
|
-
return login.lower() == username.lower()
|
|
107
|
-
full_name = repo.get("full_name") or ""
|
|
108
|
-
return full_name.split("/", 1)[0].lower() == username.lower()
|
|
109
|
-
|
|
110
|
-
|
|
111
|
-
def _fetch_repos_page(username, page, token="", authenticated_login=None,
|
|
112
|
-
account_type=None):
|
|
113
|
-
"""Fetch one raw page of repositories owned by ``username``."""
|
|
114
|
-
path = _repos_path(username, page, authenticated_login, account_type)
|
|
115
|
-
repos = _api_get(path, token)
|
|
116
|
-
if not isinstance(repos, list):
|
|
117
|
-
return []
|
|
118
|
-
return [repo for repo in repos if isinstance(repo, dict)]
|
|
119
|
-
|
|
120
|
-
|
|
121
|
-
def get_page_of_repos(username, page, token="", authenticated_login=None,
|
|
122
|
-
account_type=None):
|
|
123
|
-
username = username.strip()
|
|
124
|
-
if account_type is None:
|
|
125
|
-
account_type = get_account_type(username, token)
|
|
126
|
-
if authenticated_login is None:
|
|
127
|
-
authenticated_login = get_authenticated_login(token)
|
|
128
|
-
repos = _fetch_repos_page(
|
|
129
|
-
username, page, token, authenticated_login, account_type
|
|
130
|
-
)
|
|
131
|
-
return [repo for repo in repos if _is_owned_by(repo, username)]
|
|
132
|
-
|
|
133
|
-
|
|
134
|
-
def search_user_repos(username, token=""):
|
|
135
|
-
username = username.strip()
|
|
136
|
-
authenticated_login = get_authenticated_login(token)
|
|
137
|
-
account_type = get_account_type(username, token)
|
|
138
|
-
page = 0
|
|
139
|
-
res_repos = []
|
|
140
|
-
|
|
141
|
-
while True:
|
|
142
|
-
page += 1
|
|
143
|
-
repos = _fetch_repos_page(
|
|
144
|
-
username, page, token, authenticated_login, account_type
|
|
145
|
-
)
|
|
146
|
-
if len(repos) == 0:
|
|
147
|
-
break
|
|
148
|
-
res_repos += repos
|
|
149
|
-
if len(repos) < PER_PAGE:
|
|
150
|
-
break
|
|
151
|
-
|
|
152
|
-
# Never return repositories that belong to somebody else (e.g. a user repo
|
|
153
|
-
# returned for an organisation, or an organisation the token owner is in).
|
|
154
|
-
return [repo for repo in res_repos if _is_owned_by(repo, username)]
|
|
155
|
-
|
|
156
|
-
|
|
157
|
-
# Backward-compatible alias for the historical typo
|
|
158
|
-
serach_user_repos = search_user_repos
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|