github-backup 0.56.0__py3-none-any.whl → 0.58.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.
- github_backup/__init__.py +1 -1
- github_backup/github_backup.py +237 -233
- {github_backup-0.56.0.dist-info → github_backup-0.58.0.dist-info}/METADATA +40 -3
- github_backup-0.58.0.dist-info/RECORD +11 -0
- github_backup-0.56.0.dist-info/RECORD +0 -11
- {github_backup-0.56.0.dist-info → github_backup-0.58.0.dist-info}/WHEEL +0 -0
- {github_backup-0.56.0.dist-info → github_backup-0.58.0.dist-info}/entry_points.txt +0 -0
- {github_backup-0.56.0.dist-info → github_backup-0.58.0.dist-info}/licenses/LICENSE.txt +0 -0
- {github_backup-0.56.0.dist-info → github_backup-0.58.0.dist-info}/top_level.txt +0 -0
- {github_backup-0.56.0.dist-info → github_backup-0.58.0.dist-info}/zip-safe +0 -0
github_backup/__init__.py
CHANGED
|
@@ -1 +1 @@
|
|
|
1
|
-
__version__ = "0.
|
|
1
|
+
__version__ = "0.58.0"
|
github_backup/github_backup.py
CHANGED
|
@@ -12,6 +12,7 @@ import json
|
|
|
12
12
|
import logging
|
|
13
13
|
import os
|
|
14
14
|
import platform
|
|
15
|
+
import random
|
|
15
16
|
import re
|
|
16
17
|
import select
|
|
17
18
|
import socket
|
|
@@ -19,6 +20,7 @@ import ssl
|
|
|
19
20
|
import subprocess
|
|
20
21
|
import sys
|
|
21
22
|
import time
|
|
23
|
+
from collections.abc import Generator
|
|
22
24
|
from datetime import datetime
|
|
23
25
|
from http.client import IncompleteRead
|
|
24
26
|
from urllib.error import HTTPError, URLError
|
|
@@ -74,6 +76,9 @@ else:
|
|
|
74
76
|
" 3. Debian/Ubuntu: apt-get install ca-certificates\n\n"
|
|
75
77
|
)
|
|
76
78
|
|
|
79
|
+
# Retry configuration
|
|
80
|
+
MAX_RETRIES = 5
|
|
81
|
+
|
|
77
82
|
|
|
78
83
|
def logging_subprocess(
|
|
79
84
|
popenargs, stdout_log_level=logging.DEBUG, stderr_log_level=logging.ERROR, **kwargs
|
|
@@ -440,6 +445,12 @@ def parse_args(args=None):
|
|
|
440
445
|
dest="include_assets",
|
|
441
446
|
help="include assets alongside release information; only applies if including releases",
|
|
442
447
|
)
|
|
448
|
+
parser.add_argument(
|
|
449
|
+
"--skip-assets-on",
|
|
450
|
+
dest="skip_assets_on",
|
|
451
|
+
nargs="*",
|
|
452
|
+
help="skip asset downloads for these repositories",
|
|
453
|
+
)
|
|
443
454
|
parser.add_argument(
|
|
444
455
|
"--attachments",
|
|
445
456
|
action="store_true",
|
|
@@ -561,7 +572,7 @@ def get_github_host(args):
|
|
|
561
572
|
|
|
562
573
|
|
|
563
574
|
def read_file_contents(file_uri):
|
|
564
|
-
return open(file_uri[len(FILE_URI_PREFIX):], "rt").readline().strip()
|
|
575
|
+
return open(file_uri[len(FILE_URI_PREFIX) :], "rt").readline().strip()
|
|
565
576
|
|
|
566
577
|
|
|
567
578
|
def get_github_repo_url(args, repository):
|
|
@@ -597,175 +608,181 @@ def get_github_repo_url(args, repository):
|
|
|
597
608
|
return repo_url
|
|
598
609
|
|
|
599
610
|
|
|
600
|
-
def
|
|
601
|
-
|
|
602
|
-
|
|
603
|
-
|
|
604
|
-
|
|
611
|
+
def calculate_retry_delay(attempt, headers):
|
|
612
|
+
"""Calculate delay before next retry with exponential backoff."""
|
|
613
|
+
# Respect retry-after header if present
|
|
614
|
+
if retry_after := headers.get("retry-after"):
|
|
615
|
+
return int(retry_after)
|
|
605
616
|
|
|
606
|
-
|
|
607
|
-
|
|
608
|
-
|
|
609
|
-
|
|
610
|
-
request_per_page = per_page
|
|
617
|
+
# Respect rate limit reset time
|
|
618
|
+
if int(headers.get("x-ratelimit-remaining", 1)) < 1:
|
|
619
|
+
reset_time = int(headers.get("x-ratelimit-reset", 0))
|
|
620
|
+
return max(10, reset_time - calendar.timegm(time.gmtime()))
|
|
611
621
|
|
|
612
|
-
|
|
613
|
-
|
|
614
|
-
|
|
615
|
-
next_url or template,
|
|
616
|
-
auth,
|
|
617
|
-
as_app=args.as_app,
|
|
618
|
-
fine=True if args.token_fine is not None else False,
|
|
619
|
-
) # noqa
|
|
620
|
-
r, errors = _get_response(request, auth, next_url or template)
|
|
622
|
+
# Exponential backoff with jitter for server errors (1s base, 120s max)
|
|
623
|
+
delay = min(1.0 * (2**attempt), 120.0)
|
|
624
|
+
return delay + random.uniform(0, delay * 0.1)
|
|
621
625
|
|
|
622
|
-
status_code = int(r.getcode())
|
|
623
626
|
|
|
624
|
-
|
|
625
|
-
|
|
626
|
-
|
|
627
|
-
try:
|
|
628
|
-
response_data = json.loads(r.read().decode("utf-8"))
|
|
629
|
-
dmca_url = response_data.get("block", {}).get("html_url")
|
|
630
|
-
except Exception:
|
|
631
|
-
pass
|
|
632
|
-
raise RepositoryUnavailableError(
|
|
633
|
-
"Repository unavailable due to legal reasons (HTTP 451)",
|
|
634
|
-
dmca_url=dmca_url
|
|
635
|
-
)
|
|
627
|
+
def retrieve_data(args, template, query_args=None, paginated=True):
|
|
628
|
+
"""
|
|
629
|
+
Fetch the data from GitHub API.
|
|
636
630
|
|
|
637
|
-
|
|
638
|
-
|
|
639
|
-
|
|
640
|
-
|
|
641
|
-
|
|
642
|
-
|
|
643
|
-
except json.decoder.JSONDecodeError:
|
|
644
|
-
logger.warning("JSON decode error detected")
|
|
645
|
-
read_error = True
|
|
646
|
-
except TimeoutError:
|
|
647
|
-
logger.warning("Tiemout error detected")
|
|
648
|
-
read_error = True
|
|
649
|
-
else:
|
|
650
|
-
read_error = False
|
|
631
|
+
Handle both single requests and pagination with yield of individual dicts.
|
|
632
|
+
Handles throttling, retries, read errors, and DMCA takedowns.
|
|
633
|
+
"""
|
|
634
|
+
query_args = query_args or {}
|
|
635
|
+
auth = get_auth(args, encode=not args.as_app)
|
|
636
|
+
per_page = 100
|
|
651
637
|
|
|
652
|
-
|
|
653
|
-
|
|
654
|
-
|
|
655
|
-
|
|
656
|
-
|
|
657
|
-
|
|
638
|
+
def _extract_next_page_url(link_header):
|
|
639
|
+
for link in link_header.split(","):
|
|
640
|
+
if 'rel="next"' in link:
|
|
641
|
+
return link[link.find("<") + 1:link.find(">")]
|
|
642
|
+
return None
|
|
643
|
+
|
|
644
|
+
def fetch_all() -> Generator[dict, None, None]:
|
|
645
|
+
next_url = None
|
|
646
|
+
|
|
647
|
+
while True:
|
|
648
|
+
# FIRST: Fetch response
|
|
649
|
+
|
|
650
|
+
for attempt in range(MAX_RETRIES):
|
|
651
|
+
request = _construct_request(
|
|
652
|
+
per_page=per_page if paginated else None,
|
|
653
|
+
query_args=query_args,
|
|
654
|
+
template=next_url or template,
|
|
655
|
+
auth=auth,
|
|
656
|
+
as_app=args.as_app,
|
|
657
|
+
fine=args.token_fine is not None,
|
|
658
|
+
)
|
|
659
|
+
http_response = make_request_with_retry(request, auth)
|
|
660
|
+
|
|
661
|
+
match http_response.getcode():
|
|
662
|
+
case 200:
|
|
663
|
+
# Success - Parse JSON response
|
|
664
|
+
try:
|
|
665
|
+
response = json.loads(http_response.read().decode("utf-8"))
|
|
666
|
+
break # Exit retry loop and handle the data returned
|
|
667
|
+
except (
|
|
668
|
+
IncompleteRead,
|
|
669
|
+
json.decoder.JSONDecodeError,
|
|
670
|
+
TimeoutError,
|
|
671
|
+
) as e:
|
|
672
|
+
logger.warning(f"{type(e).__name__} reading response")
|
|
673
|
+
if attempt < MAX_RETRIES - 1:
|
|
674
|
+
delay = calculate_retry_delay(attempt, {})
|
|
675
|
+
logger.warning(
|
|
676
|
+
f"Retrying in {delay:.1f}s (attempt {attempt + 1}/{MAX_RETRIES})"
|
|
677
|
+
)
|
|
678
|
+
time.sleep(delay)
|
|
679
|
+
continue # Next retry attempt
|
|
680
|
+
|
|
681
|
+
case 451:
|
|
682
|
+
# DMCA takedown - extract URL if available, then raise
|
|
683
|
+
dmca_url = None
|
|
684
|
+
try:
|
|
685
|
+
response_data = json.loads(
|
|
686
|
+
http_response.read().decode("utf-8")
|
|
687
|
+
)
|
|
688
|
+
dmca_url = response_data.get("block", {}).get("html_url")
|
|
689
|
+
except Exception:
|
|
690
|
+
pass
|
|
691
|
+
raise RepositoryUnavailableError(
|
|
692
|
+
"Repository unavailable due to legal reasons (HTTP 451)",
|
|
693
|
+
dmca_url=dmca_url,
|
|
694
|
+
)
|
|
695
|
+
|
|
696
|
+
case _:
|
|
697
|
+
raise Exception(
|
|
698
|
+
f"API request returned HTTP {http_response.getcode()}: {http_response.reason}"
|
|
699
|
+
)
|
|
700
|
+
else:
|
|
701
|
+
logger.error(
|
|
702
|
+
f"Failed to read response after {MAX_RETRIES} attempts for {next_url or template}"
|
|
703
|
+
)
|
|
704
|
+
raise Exception(
|
|
705
|
+
f"Failed to read response after {MAX_RETRIES} attempts for {next_url or template}"
|
|
658
706
|
)
|
|
659
|
-
)
|
|
660
|
-
time.sleep(args.throttle_pause)
|
|
661
|
-
|
|
662
|
-
retries = 0
|
|
663
|
-
while retries < 3 and (status_code == 502 or read_error):
|
|
664
|
-
logger.warning("API request failed. Retrying in 5 seconds")
|
|
665
|
-
retries += 1
|
|
666
|
-
time.sleep(5)
|
|
667
|
-
request = _construct_request(
|
|
668
|
-
request_per_page,
|
|
669
|
-
query_args,
|
|
670
|
-
next_url or template,
|
|
671
|
-
auth,
|
|
672
|
-
as_app=args.as_app,
|
|
673
|
-
fine=True if args.token_fine is not None else False,
|
|
674
|
-
) # noqa
|
|
675
|
-
r, errors = _get_response(request, auth, next_url or template)
|
|
676
|
-
|
|
677
|
-
status_code = int(r.getcode())
|
|
678
|
-
try:
|
|
679
|
-
response = json.loads(r.read().decode("utf-8"))
|
|
680
|
-
read_error = False
|
|
681
|
-
except IncompleteRead:
|
|
682
|
-
logger.warning("Incomplete read error detected")
|
|
683
|
-
read_error = True
|
|
684
|
-
except json.decoder.JSONDecodeError:
|
|
685
|
-
logger.warning("JSON decode error detected")
|
|
686
|
-
read_error = True
|
|
687
|
-
except TimeoutError:
|
|
688
|
-
logger.warning("Tiemout error detected")
|
|
689
|
-
read_error = True
|
|
690
|
-
|
|
691
|
-
if status_code != 200:
|
|
692
|
-
template = "API request returned HTTP {0}: {1}"
|
|
693
|
-
errors.append(template.format(status_code, r.reason))
|
|
694
|
-
raise Exception(", ".join(errors))
|
|
695
|
-
|
|
696
|
-
if read_error:
|
|
697
|
-
template = "API request problem reading response for {0}"
|
|
698
|
-
errors.append(template.format(request))
|
|
699
|
-
raise Exception(", ".join(errors))
|
|
700
|
-
|
|
701
|
-
if len(errors) == 0:
|
|
702
|
-
if type(response) is list:
|
|
703
|
-
for resp in response:
|
|
704
|
-
yield resp
|
|
705
|
-
# Parse Link header for next page URL (cursor-based pagination)
|
|
706
|
-
link_header = r.headers.get("Link", "")
|
|
707
|
-
next_url = None
|
|
708
|
-
if link_header:
|
|
709
|
-
# Parse Link header: <https://api.github.com/...?per_page=100&after=cursor>; rel="next"
|
|
710
|
-
for link in link_header.split(","):
|
|
711
|
-
if 'rel="next"' in link:
|
|
712
|
-
next_url = link[link.find("<") + 1:link.find(">")]
|
|
713
|
-
break
|
|
714
|
-
if not next_url:
|
|
715
|
-
break
|
|
716
|
-
elif type(response) is dict and single_request:
|
|
717
|
-
yield response
|
|
718
707
|
|
|
719
|
-
|
|
720
|
-
raise Exception(", ".join(errors))
|
|
708
|
+
# SECOND: Process and paginate
|
|
721
709
|
|
|
722
|
-
|
|
723
|
-
|
|
710
|
+
# Pause before next request if rate limit is low
|
|
711
|
+
if (
|
|
712
|
+
remaining := int(http_response.headers.get("x-ratelimit-remaining", 0))
|
|
713
|
+
) <= (args.throttle_limit or 0):
|
|
714
|
+
if args.throttle_limit:
|
|
715
|
+
logger.info(
|
|
716
|
+
f"Throttling: {remaining} requests left, pausing {args.throttle_pause}s"
|
|
717
|
+
)
|
|
718
|
+
time.sleep(args.throttle_pause)
|
|
724
719
|
|
|
720
|
+
# Yield results
|
|
721
|
+
if isinstance(response, list):
|
|
722
|
+
yield from response
|
|
723
|
+
elif isinstance(response, dict):
|
|
724
|
+
yield response
|
|
725
725
|
|
|
726
|
-
|
|
727
|
-
|
|
726
|
+
# Check for more pages
|
|
727
|
+
if not paginated or not (
|
|
728
|
+
next_url := _extract_next_page_url(
|
|
729
|
+
http_response.headers.get("Link", "")
|
|
730
|
+
)
|
|
731
|
+
):
|
|
732
|
+
break # No more data
|
|
728
733
|
|
|
734
|
+
return list(fetch_all())
|
|
729
735
|
|
|
730
|
-
def get_query_args(query_args=None):
|
|
731
|
-
if not query_args:
|
|
732
|
-
query_args = {}
|
|
733
|
-
return query_args
|
|
734
736
|
|
|
737
|
+
def make_request_with_retry(request, auth):
|
|
738
|
+
"""Make HTTP request with automatic retry for transient errors."""
|
|
735
739
|
|
|
736
|
-
def
|
|
737
|
-
|
|
738
|
-
|
|
739
|
-
|
|
740
|
-
|
|
741
|
-
|
|
742
|
-
|
|
740
|
+
def is_retryable_status(status_code, headers):
|
|
741
|
+
# Server errors are always retryable
|
|
742
|
+
if status_code in (500, 502, 503, 504):
|
|
743
|
+
return True
|
|
744
|
+
# Rate limit (403/429) is retryable if limit exhausted
|
|
745
|
+
if status_code in (403, 429):
|
|
746
|
+
return int(headers.get("x-ratelimit-remaining", 1)) < 1
|
|
747
|
+
return False
|
|
748
|
+
|
|
749
|
+
for attempt in range(MAX_RETRIES):
|
|
743
750
|
try:
|
|
744
|
-
|
|
751
|
+
return urlopen(request, context=https_ctx)
|
|
752
|
+
|
|
745
753
|
except HTTPError as exc:
|
|
746
|
-
|
|
747
|
-
|
|
748
|
-
|
|
749
|
-
|
|
750
|
-
|
|
751
|
-
|
|
752
|
-
raise
|
|
753
|
-
except socket.error as e:
|
|
754
|
-
logger.warning(e.strerror)
|
|
755
|
-
should_continue, retry_timeout = _request_url_error(template, retry_timeout)
|
|
756
|
-
if not should_continue:
|
|
754
|
+
# HTTPError can be used as a response-like object
|
|
755
|
+
if not is_retryable_status(exc.code, exc.headers):
|
|
756
|
+
raise # Non-retryable error
|
|
757
|
+
|
|
758
|
+
if attempt >= MAX_RETRIES - 1:
|
|
759
|
+
logger.error(f"HTTP {exc.code} failed after {MAX_RETRIES} attempts")
|
|
757
760
|
raise
|
|
758
761
|
|
|
759
|
-
|
|
760
|
-
|
|
762
|
+
delay = calculate_retry_delay(attempt, exc.headers)
|
|
763
|
+
logger.warning(
|
|
764
|
+
f"HTTP {exc.code}, retrying in {delay:.1f}s "
|
|
765
|
+
f"(attempt {attempt + 1}/{MAX_RETRIES})"
|
|
766
|
+
)
|
|
767
|
+
if auth is None and exc.code in (403, 429):
|
|
768
|
+
logger.info("Hint: Authenticate to raise your GitHub rate limit")
|
|
769
|
+
time.sleep(delay)
|
|
761
770
|
|
|
762
|
-
|
|
763
|
-
|
|
771
|
+
except (URLError, socket.error) as e:
|
|
772
|
+
if attempt >= MAX_RETRIES - 1:
|
|
773
|
+
logger.error(f"Connection error failed after {MAX_RETRIES} attempts: {e}")
|
|
774
|
+
raise
|
|
775
|
+
delay = calculate_retry_delay(attempt, {})
|
|
776
|
+
logger.warning(
|
|
777
|
+
f"Connection error: {e}, retrying in {delay:.1f}s "
|
|
778
|
+
f"(attempt {attempt + 1}/{MAX_RETRIES})"
|
|
779
|
+
)
|
|
780
|
+
time.sleep(delay)
|
|
764
781
|
|
|
782
|
+
raise Exception(f"Request failed after {MAX_RETRIES} attempts") # pragma: no cover
|
|
765
783
|
|
|
766
|
-
|
|
767
|
-
|
|
768
|
-
):
|
|
784
|
+
|
|
785
|
+
def _construct_request(per_page, query_args, template, auth, as_app=None, fine=False):
|
|
769
786
|
# If template is already a full URL with query params (from Link header), use it directly
|
|
770
787
|
if "?" in template and template.startswith("http"):
|
|
771
788
|
request_url = template
|
|
@@ -796,9 +813,6 @@ def _construct_request(
|
|
|
796
813
|
else:
|
|
797
814
|
auth = auth.encode("ascii")
|
|
798
815
|
request.add_header("Authorization", "token ".encode("ascii") + auth)
|
|
799
|
-
request.add_header(
|
|
800
|
-
"Accept", "application/vnd.github.machine-man-preview+json"
|
|
801
|
-
)
|
|
802
816
|
|
|
803
817
|
log_url = template if "?" not in template else template.split("?")[0]
|
|
804
818
|
if querystring:
|
|
@@ -807,52 +821,6 @@ def _construct_request(
|
|
|
807
821
|
return request
|
|
808
822
|
|
|
809
823
|
|
|
810
|
-
def _request_http_error(exc, auth, errors):
|
|
811
|
-
# HTTPError behaves like a Response so we can
|
|
812
|
-
# check the status code and headers to see exactly
|
|
813
|
-
# what failed.
|
|
814
|
-
|
|
815
|
-
should_continue = False
|
|
816
|
-
headers = exc.headers
|
|
817
|
-
limit_remaining = int(headers.get("x-ratelimit-remaining", 0))
|
|
818
|
-
|
|
819
|
-
if exc.code == 403 and limit_remaining < 1:
|
|
820
|
-
# The X-RateLimit-Reset header includes a
|
|
821
|
-
# timestamp telling us when the limit will reset
|
|
822
|
-
# so we can calculate how long to wait rather
|
|
823
|
-
# than inefficiently polling:
|
|
824
|
-
gm_now = calendar.timegm(time.gmtime())
|
|
825
|
-
reset = int(headers.get("x-ratelimit-reset", 0)) or gm_now
|
|
826
|
-
# We'll never sleep for less than 10 seconds:
|
|
827
|
-
delta = max(10, reset - gm_now)
|
|
828
|
-
|
|
829
|
-
limit = headers.get("x-ratelimit-limit")
|
|
830
|
-
logger.warning(
|
|
831
|
-
"Exceeded rate limit of {} requests; waiting {} seconds to reset".format(
|
|
832
|
-
limit, delta
|
|
833
|
-
)
|
|
834
|
-
) # noqa
|
|
835
|
-
|
|
836
|
-
if auth is None:
|
|
837
|
-
logger.info("Hint: Authenticate to raise your GitHub rate limit")
|
|
838
|
-
|
|
839
|
-
time.sleep(delta)
|
|
840
|
-
should_continue = True
|
|
841
|
-
return errors, should_continue
|
|
842
|
-
|
|
843
|
-
|
|
844
|
-
def _request_url_error(template, retry_timeout):
|
|
845
|
-
# In case of a connection timing out, we can retry a few time
|
|
846
|
-
# But we won't crash and not back-up the rest now
|
|
847
|
-
logger.info("'{}' timed out".format(template))
|
|
848
|
-
retry_timeout -= 1
|
|
849
|
-
|
|
850
|
-
if retry_timeout >= 0:
|
|
851
|
-
return True, retry_timeout
|
|
852
|
-
|
|
853
|
-
raise Exception("'{}' timed out to much, skipping!".format(template))
|
|
854
|
-
|
|
855
|
-
|
|
856
824
|
class S3HTTPRedirectHandler(HTTPRedirectHandler):
|
|
857
825
|
"""
|
|
858
826
|
A subclassed redirect handler for downloading Github assets from S3.
|
|
@@ -1480,9 +1448,11 @@ def download_attachments(
|
|
|
1480
1448
|
manifest = {
|
|
1481
1449
|
"issue_number": number,
|
|
1482
1450
|
"issue_type": item_type,
|
|
1483
|
-
"repository":
|
|
1484
|
-
|
|
1485
|
-
|
|
1451
|
+
"repository": (
|
|
1452
|
+
f"{args.user}/{args.repository}"
|
|
1453
|
+
if hasattr(args, "repository") and args.repository
|
|
1454
|
+
else args.user
|
|
1455
|
+
),
|
|
1486
1456
|
"manifest_updated_at": datetime.now(timezone.utc).isoformat(),
|
|
1487
1457
|
"attachments": attachment_metadata_list,
|
|
1488
1458
|
}
|
|
@@ -1500,7 +1470,7 @@ def download_attachments(
|
|
|
1500
1470
|
|
|
1501
1471
|
def get_authenticated_user(args):
|
|
1502
1472
|
template = "https://{0}/user".format(get_github_api_host(args))
|
|
1503
|
-
data = retrieve_data(args, template,
|
|
1473
|
+
data = retrieve_data(args, template, paginated=False)
|
|
1504
1474
|
return data[0]
|
|
1505
1475
|
|
|
1506
1476
|
|
|
@@ -1514,7 +1484,7 @@ def check_git_lfs_install():
|
|
|
1514
1484
|
|
|
1515
1485
|
def retrieve_repositories(args, authenticated_user):
|
|
1516
1486
|
logger.info("Retrieving repositories")
|
|
1517
|
-
|
|
1487
|
+
paginated = True
|
|
1518
1488
|
if args.user == authenticated_user["login"]:
|
|
1519
1489
|
# we must use the /user/repos API to be able to access private repos
|
|
1520
1490
|
template = "https://{0}/user/repos".format(get_github_api_host(args))
|
|
@@ -1537,18 +1507,16 @@ def retrieve_repositories(args, authenticated_user):
|
|
|
1537
1507
|
repo_path = args.repository
|
|
1538
1508
|
else:
|
|
1539
1509
|
repo_path = "{0}/{1}".format(args.user, args.repository)
|
|
1540
|
-
|
|
1541
|
-
template = "https://{0}/repos/{1}".format(
|
|
1542
|
-
get_github_api_host(args), repo_path
|
|
1543
|
-
)
|
|
1510
|
+
paginated = False
|
|
1511
|
+
template = "https://{0}/repos/{1}".format(get_github_api_host(args), repo_path)
|
|
1544
1512
|
|
|
1545
|
-
repos = retrieve_data(args, template,
|
|
1513
|
+
repos = retrieve_data(args, template, paginated=paginated)
|
|
1546
1514
|
|
|
1547
1515
|
if args.all_starred:
|
|
1548
1516
|
starred_template = "https://{0}/users/{1}/starred".format(
|
|
1549
1517
|
get_github_api_host(args), args.user
|
|
1550
1518
|
)
|
|
1551
|
-
starred_repos = retrieve_data(args, starred_template
|
|
1519
|
+
starred_repos = retrieve_data(args, starred_template)
|
|
1552
1520
|
# flag each repo as starred for downstream processing
|
|
1553
1521
|
for item in starred_repos:
|
|
1554
1522
|
item.update({"is_starred": True})
|
|
@@ -1558,14 +1526,17 @@ def retrieve_repositories(args, authenticated_user):
|
|
|
1558
1526
|
gists_template = "https://{0}/users/{1}/gists".format(
|
|
1559
1527
|
get_github_api_host(args), args.user
|
|
1560
1528
|
)
|
|
1561
|
-
gists = retrieve_data(args, gists_template
|
|
1529
|
+
gists = retrieve_data(args, gists_template)
|
|
1562
1530
|
# flag each repo as a gist for downstream processing
|
|
1563
1531
|
for item in gists:
|
|
1564
1532
|
item.update({"is_gist": True})
|
|
1565
1533
|
repos.extend(gists)
|
|
1566
1534
|
|
|
1567
1535
|
if args.include_starred_gists:
|
|
1568
|
-
if
|
|
1536
|
+
if (
|
|
1537
|
+
not authenticated_user.get("login")
|
|
1538
|
+
or args.user.lower() != authenticated_user["login"].lower()
|
|
1539
|
+
):
|
|
1569
1540
|
logger.warning(
|
|
1570
1541
|
"Cannot retrieve starred gists for '%s'. GitHub only allows access to the authenticated user's starred gists.",
|
|
1571
1542
|
args.user,
|
|
@@ -1574,9 +1545,7 @@ def retrieve_repositories(args, authenticated_user):
|
|
|
1574
1545
|
starred_gists_template = "https://{0}/gists/starred".format(
|
|
1575
1546
|
get_github_api_host(args)
|
|
1576
1547
|
)
|
|
1577
|
-
starred_gists = retrieve_data(
|
|
1578
|
-
args, starred_gists_template, single_request=False
|
|
1579
|
-
)
|
|
1548
|
+
starred_gists = retrieve_data(args, starred_gists_template)
|
|
1580
1549
|
# flag each repo as a starred gist for downstream processing
|
|
1581
1550
|
for item in starred_gists:
|
|
1582
1551
|
item.update({"is_gist": True, "is_starred": True})
|
|
@@ -1673,9 +1642,11 @@ def backup_repositories(args, output_directory, repositories):
|
|
|
1673
1642
|
|
|
1674
1643
|
include_gists = args.include_gists or args.include_starred_gists
|
|
1675
1644
|
include_starred = args.all_starred and repository.get("is_starred")
|
|
1676
|
-
if (
|
|
1677
|
-
|
|
1678
|
-
|
|
1645
|
+
if (
|
|
1646
|
+
(args.include_repository or args.include_everything)
|
|
1647
|
+
or (include_gists and repository.get("is_gist"))
|
|
1648
|
+
or include_starred
|
|
1649
|
+
):
|
|
1679
1650
|
repo_name = (
|
|
1680
1651
|
repository.get("name")
|
|
1681
1652
|
if not repository.get("is_gist")
|
|
@@ -1735,7 +1706,9 @@ def backup_repositories(args, output_directory, repositories):
|
|
|
1735
1706
|
include_assets=args.include_assets or args.include_everything,
|
|
1736
1707
|
)
|
|
1737
1708
|
except RepositoryUnavailableError as e:
|
|
1738
|
-
logger.warning(
|
|
1709
|
+
logger.warning(
|
|
1710
|
+
f"Repository {repository['full_name']} is unavailable (HTTP 451)"
|
|
1711
|
+
)
|
|
1739
1712
|
if e.dmca_url:
|
|
1740
1713
|
logger.warning(f"DMCA notice: {e.dmca_url}")
|
|
1741
1714
|
logger.info(f"Skipping remaining resources for {repository['full_name']}")
|
|
@@ -1795,7 +1768,11 @@ def backup_issues(args, repo_cwd, repository, repos_template):
|
|
|
1795
1768
|
modified = os.path.getmtime(issue_file)
|
|
1796
1769
|
modified = datetime.fromtimestamp(modified).strftime("%Y-%m-%dT%H:%M:%SZ")
|
|
1797
1770
|
if modified > issue["updated_at"]:
|
|
1798
|
-
logger.info(
|
|
1771
|
+
logger.info(
|
|
1772
|
+
"Skipping issue {0} because it wasn't modified since last backup".format(
|
|
1773
|
+
number
|
|
1774
|
+
)
|
|
1775
|
+
)
|
|
1799
1776
|
continue
|
|
1800
1777
|
|
|
1801
1778
|
if args.include_issue_comments or args.include_everything:
|
|
@@ -1837,14 +1814,14 @@ def backup_pulls(args, repo_cwd, repository, repos_template):
|
|
|
1837
1814
|
pull_states = ["open", "closed"]
|
|
1838
1815
|
for pull_state in pull_states:
|
|
1839
1816
|
query_args["state"] = pull_state
|
|
1840
|
-
_pulls =
|
|
1817
|
+
_pulls = retrieve_data(args, _pulls_template, query_args=query_args)
|
|
1841
1818
|
for pull in _pulls:
|
|
1842
1819
|
if args.since and pull["updated_at"] < args.since:
|
|
1843
1820
|
break
|
|
1844
1821
|
if not args.since or pull["updated_at"] >= args.since:
|
|
1845
1822
|
pulls[pull["number"]] = pull
|
|
1846
1823
|
else:
|
|
1847
|
-
_pulls =
|
|
1824
|
+
_pulls = retrieve_data(args, _pulls_template, query_args=query_args)
|
|
1848
1825
|
for pull in _pulls:
|
|
1849
1826
|
if args.since and pull["updated_at"] < args.since:
|
|
1850
1827
|
break
|
|
@@ -1852,7 +1829,7 @@ def backup_pulls(args, repo_cwd, repository, repos_template):
|
|
|
1852
1829
|
pulls[pull["number"]] = retrieve_data(
|
|
1853
1830
|
args,
|
|
1854
1831
|
_pulls_template + "/{}".format(pull["number"]),
|
|
1855
|
-
|
|
1832
|
+
paginated=False,
|
|
1856
1833
|
)[0]
|
|
1857
1834
|
|
|
1858
1835
|
logger.info("Saving {0} pull requests to disk".format(len(list(pulls.keys()))))
|
|
@@ -1869,7 +1846,11 @@ def backup_pulls(args, repo_cwd, repository, repos_template):
|
|
|
1869
1846
|
modified = os.path.getmtime(pull_file)
|
|
1870
1847
|
modified = datetime.fromtimestamp(modified).strftime("%Y-%m-%dT%H:%M:%SZ")
|
|
1871
1848
|
if modified > pull["updated_at"]:
|
|
1872
|
-
logger.info(
|
|
1849
|
+
logger.info(
|
|
1850
|
+
"Skipping pull request {0} because it wasn't modified since last backup".format(
|
|
1851
|
+
number
|
|
1852
|
+
)
|
|
1853
|
+
)
|
|
1873
1854
|
continue
|
|
1874
1855
|
if args.include_pull_comments or args.include_everything:
|
|
1875
1856
|
template = comments_regular_template.format(number)
|
|
@@ -1919,9 +1900,11 @@ def backup_milestones(args, repo_cwd, repository, repos_template):
|
|
|
1919
1900
|
elif written_count == 0:
|
|
1920
1901
|
logger.info("{0} milestones unchanged, skipped write".format(total))
|
|
1921
1902
|
else:
|
|
1922
|
-
logger.info(
|
|
1923
|
-
|
|
1924
|
-
|
|
1903
|
+
logger.info(
|
|
1904
|
+
"Saved {0} of {1} milestones to disk ({2} unchanged)".format(
|
|
1905
|
+
written_count, total, total - written_count
|
|
1906
|
+
)
|
|
1907
|
+
)
|
|
1925
1908
|
|
|
1926
1909
|
|
|
1927
1910
|
def backup_labels(args, repo_cwd, repository, repos_template):
|
|
@@ -1975,6 +1958,20 @@ def backup_releases(args, repo_cwd, repository, repos_template, include_assets=F
|
|
|
1975
1958
|
)
|
|
1976
1959
|
releases = releases[: args.number_of_latest_releases]
|
|
1977
1960
|
|
|
1961
|
+
# Check if this repo should skip asset downloads (case-insensitive)
|
|
1962
|
+
skip_assets = False
|
|
1963
|
+
if include_assets:
|
|
1964
|
+
repo_name = repository.get("name", "").lower()
|
|
1965
|
+
repo_full_name = repository.get("full_name", "").lower()
|
|
1966
|
+
skip_repos = [r.lower() for r in (args.skip_assets_on or [])]
|
|
1967
|
+
skip_assets = repo_name in skip_repos or repo_full_name in skip_repos
|
|
1968
|
+
if skip_assets:
|
|
1969
|
+
logger.info(
|
|
1970
|
+
"Skipping assets for {0} ({1} releases) due to --skip-assets-on".format(
|
|
1971
|
+
repository.get("name"), len(releases)
|
|
1972
|
+
)
|
|
1973
|
+
)
|
|
1974
|
+
|
|
1978
1975
|
# for each release, store it
|
|
1979
1976
|
written_count = 0
|
|
1980
1977
|
for release in releases:
|
|
@@ -1986,7 +1983,7 @@ def backup_releases(args, repo_cwd, repository, repos_template, include_assets=F
|
|
|
1986
1983
|
if json_dump_if_changed(release, output_filepath):
|
|
1987
1984
|
written_count += 1
|
|
1988
1985
|
|
|
1989
|
-
if include_assets:
|
|
1986
|
+
if include_assets and not skip_assets:
|
|
1990
1987
|
assets = retrieve_data(args, release["assets_url"])
|
|
1991
1988
|
if len(assets) > 0:
|
|
1992
1989
|
# give release asset files somewhere to live & download them (not including source archives)
|
|
@@ -2008,9 +2005,11 @@ def backup_releases(args, repo_cwd, repository, repos_template, include_assets=F
|
|
|
2008
2005
|
elif written_count == 0:
|
|
2009
2006
|
logger.info("{0} releases unchanged, skipped write".format(total))
|
|
2010
2007
|
else:
|
|
2011
|
-
logger.info(
|
|
2012
|
-
|
|
2013
|
-
|
|
2008
|
+
logger.info(
|
|
2009
|
+
"Saved {0} of {1} releases to disk ({2} unchanged)".format(
|
|
2010
|
+
written_count, total, total - written_count
|
|
2011
|
+
)
|
|
2012
|
+
)
|
|
2014
2013
|
|
|
2015
2014
|
|
|
2016
2015
|
def fetch_repository(
|
|
@@ -2024,9 +2023,12 @@ def fetch_repository(
|
|
|
2024
2023
|
):
|
|
2025
2024
|
if bare_clone:
|
|
2026
2025
|
if os.path.exists(local_dir):
|
|
2027
|
-
clone_exists =
|
|
2028
|
-
|
|
2029
|
-
|
|
2026
|
+
clone_exists = (
|
|
2027
|
+
subprocess.check_output(
|
|
2028
|
+
["git", "rev-parse", "--is-bare-repository"], cwd=local_dir
|
|
2029
|
+
)
|
|
2030
|
+
== b"true\n"
|
|
2031
|
+
)
|
|
2030
2032
|
else:
|
|
2031
2033
|
clone_exists = False
|
|
2032
2034
|
else:
|
|
@@ -2047,7 +2049,9 @@ def fetch_repository(
|
|
|
2047
2049
|
)
|
|
2048
2050
|
else:
|
|
2049
2051
|
logger.info(
|
|
2050
|
-
"Skipping {0} (repository not accessible - may be empty, private, or credentials invalid)".format(
|
|
2052
|
+
"Skipping {0} (repository not accessible - may be empty, private, or credentials invalid)".format(
|
|
2053
|
+
name
|
|
2054
|
+
)
|
|
2051
2055
|
)
|
|
2052
2056
|
return
|
|
2053
2057
|
|
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
Metadata-Version: 2.4
|
|
2
2
|
Name: github-backup
|
|
3
|
-
Version: 0.
|
|
3
|
+
Version: 0.58.0
|
|
4
4
|
Summary: backup a github user or organization
|
|
5
5
|
Home-page: http://github.com/josegonzalez/python-github-backup
|
|
6
6
|
Author: Jose Diaz-Gonzalez
|
|
@@ -80,8 +80,8 @@ CLI Help output::
|
|
|
80
80
|
[--keychain-name OSX_KEYCHAIN_ITEM_NAME]
|
|
81
81
|
[--keychain-account OSX_KEYCHAIN_ITEM_ACCOUNT]
|
|
82
82
|
[--releases] [--latest-releases NUMBER_OF_LATEST_RELEASES]
|
|
83
|
-
[--skip-prerelease] [--assets] [--
|
|
84
|
-
[--exclude [REPOSITORY [REPOSITORY ...]]
|
|
83
|
+
[--skip-prerelease] [--assets] [--skip-assets-on [REPO ...]]
|
|
84
|
+
[--attachments] [--exclude [REPOSITORY [REPOSITORY ...]]
|
|
85
85
|
[--throttle-limit THROTTLE_LIMIT] [--throttle-pause THROTTLE_PAUSE]
|
|
86
86
|
USER
|
|
87
87
|
|
|
@@ -163,6 +163,9 @@ CLI Help output::
|
|
|
163
163
|
--skip-prerelease skip prerelease and draft versions; only applies if including releases
|
|
164
164
|
--assets include assets alongside release information; only
|
|
165
165
|
applies if including releases
|
|
166
|
+
--skip-assets-on [REPO ...]
|
|
167
|
+
skip asset downloads for these repositories (e.g.
|
|
168
|
+
--skip-assets-on repo1 owner/repo2)
|
|
166
169
|
--attachments download user-attachments from issues and pull requests
|
|
167
170
|
to issues/attachments/{issue_number}/ and
|
|
168
171
|
pulls/attachments/{pull_number}/ directories
|
|
@@ -204,6 +207,37 @@ Customise the permissions for your use case, but for a personal account full bac
|
|
|
204
207
|
**Repository permissions**: Read access to contents, issues, metadata, pull requests, and webhooks.
|
|
205
208
|
|
|
206
209
|
|
|
210
|
+
GitHub Apps
|
|
211
|
+
~~~~~~~~~~~
|
|
212
|
+
|
|
213
|
+
GitHub Apps are ideal for organization backups in CI/CD. Tokens are scoped to specific repositories and expire after 1 hour.
|
|
214
|
+
|
|
215
|
+
**One-time setup:**
|
|
216
|
+
|
|
217
|
+
1. Create a GitHub App at *Settings -> Developer Settings -> GitHub Apps -> New GitHub App*
|
|
218
|
+
2. Set a name and homepage URL (can be any URL)
|
|
219
|
+
3. Uncheck "Webhook > Active" (not needed for backups)
|
|
220
|
+
4. Set permissions (same as fine-grained tokens above)
|
|
221
|
+
5. Click "Create GitHub App", then note the **App ID** shown on the next page
|
|
222
|
+
6. Under "Private keys", click "Generate a private key" and save the downloaded file
|
|
223
|
+
7. Go to *Install App* in your app's settings
|
|
224
|
+
8. Select the account/organization and which repositories to back up
|
|
225
|
+
|
|
226
|
+
**CI/CD usage with GitHub Actions:**
|
|
227
|
+
|
|
228
|
+
Store the App ID as a repository variable and the private key contents as a secret, then use ``actions/create-github-app-token``::
|
|
229
|
+
|
|
230
|
+
- uses: actions/create-github-app-token@v1
|
|
231
|
+
id: app-token
|
|
232
|
+
with:
|
|
233
|
+
app-id: ${{ vars.APP_ID }}
|
|
234
|
+
private-key: ${{ secrets.APP_PRIVATE_KEY }}
|
|
235
|
+
|
|
236
|
+
- run: github-backup myorg -t ${{ steps.app-token.outputs.token }} --as-app -o ./backup --all
|
|
237
|
+
|
|
238
|
+
Note: Installation tokens expire after 1 hour. For long-running backups, use a fine-grained personal access token instead.
|
|
239
|
+
|
|
240
|
+
|
|
207
241
|
Prefer SSH
|
|
208
242
|
~~~~~~~~~~
|
|
209
243
|
|
|
@@ -391,6 +425,9 @@ Debug an error/block or incomplete backup into a temporary directory. Omit "incr
|
|
|
391
425
|
|
|
392
426
|
github-backup -f $FINE_ACCESS_TOKEN -o /tmp/github-backup/ -l debug -P --all-starred --starred --watched --followers --following --issues --issue-comments --issue-events --pulls --pull-comments --pull-commits --labels --milestones --repositories --wikis --releases --assets --pull-details --gists --starred-gists $GH_USER
|
|
393
427
|
|
|
428
|
+
Pipe a token from stdin to avoid storing it in environment variables or command history (Unix-like systems only)::
|
|
429
|
+
|
|
430
|
+
my-secret-manager get github-token | github-backup user -t file:///dev/stdin -o /backup --repositories
|
|
394
431
|
|
|
395
432
|
Restoring from Backup
|
|
396
433
|
=====================
|
|
@@ -0,0 +1,11 @@
|
|
|
1
|
+
github_backup/__init__.py,sha256=oIkJTCdzxBkcFHWQdIvMrmJih4tpnF1BnfoNPd1mB48,23
|
|
2
|
+
github_backup/__main__.py,sha256=3yorCiC63maaxbjdtaeAs-G-yAAxKSO3mprhGfa1Fho,271
|
|
3
|
+
github_backup/cli.py,sha256=zMUU0pTnh9KPqv8K--KNpUNf-yaA1qWeI6AdC1KVrn8,2361
|
|
4
|
+
github_backup/github_backup.py,sha256=Q3EcCwO7X8v5MPdWilx0zsJa8FLUINzSI8bjHw08O50,81287
|
|
5
|
+
github_backup-0.58.0.dist-info/licenses/LICENSE.txt,sha256=ML-pLyn1qtAKo_DHMcQ8-p7k3f8cp-IR-mf5B49R2rk,1085
|
|
6
|
+
github_backup-0.58.0.dist-info/METADATA,sha256=wYRqf6V7hjtir_td4uYuK296O5KHeqpCPGhsr-ndnK8,25921
|
|
7
|
+
github_backup-0.58.0.dist-info/WHEEL,sha256=_zCd3N1l69ArxyTb8rzEoP9TpbYXkqRFSNOD5OuxnTs,91
|
|
8
|
+
github_backup-0.58.0.dist-info/entry_points.txt,sha256=cLPKK7MqC77QVb6HnT2mnyL_f-ciavJG_Y9vVHlCF68,57
|
|
9
|
+
github_backup-0.58.0.dist-info/top_level.txt,sha256=Za6fKRAlxZ0b3ZBH_osPqklLe14LetdNAkvAQANT2AA,14
|
|
10
|
+
github_backup-0.58.0.dist-info/zip-safe,sha256=AbpHGcgLb-kRsJGnwFEktk7uzpZOCcBY74-YBdrKVGs,1
|
|
11
|
+
github_backup-0.58.0.dist-info/RECORD,,
|
|
@@ -1,11 +0,0 @@
|
|
|
1
|
-
github_backup/__init__.py,sha256=pLF99dIRilisWyZd9CliPPWqIlV81Xy8XtGEMuThTaM,23
|
|
2
|
-
github_backup/__main__.py,sha256=3yorCiC63maaxbjdtaeAs-G-yAAxKSO3mprhGfa1Fho,271
|
|
3
|
-
github_backup/cli.py,sha256=zMUU0pTnh9KPqv8K--KNpUNf-yaA1qWeI6AdC1KVrn8,2361
|
|
4
|
-
github_backup/github_backup.py,sha256=wlVrA50q5iP8BbJ_6WIIMEP7MKl_B3cKm2zvEtNYMS4,80777
|
|
5
|
-
github_backup-0.56.0.dist-info/licenses/LICENSE.txt,sha256=ML-pLyn1qtAKo_DHMcQ8-p7k3f8cp-IR-mf5B49R2rk,1085
|
|
6
|
-
github_backup-0.56.0.dist-info/METADATA,sha256=0N-CqtE9r3amkSHGkrgR3YeG7wylthaM0Q2RDct5vbc,24230
|
|
7
|
-
github_backup-0.56.0.dist-info/WHEEL,sha256=_zCd3N1l69ArxyTb8rzEoP9TpbYXkqRFSNOD5OuxnTs,91
|
|
8
|
-
github_backup-0.56.0.dist-info/entry_points.txt,sha256=cLPKK7MqC77QVb6HnT2mnyL_f-ciavJG_Y9vVHlCF68,57
|
|
9
|
-
github_backup-0.56.0.dist-info/top_level.txt,sha256=Za6fKRAlxZ0b3ZBH_osPqklLe14LetdNAkvAQANT2AA,14
|
|
10
|
-
github_backup-0.56.0.dist-info/zip-safe,sha256=AbpHGcgLb-kRsJGnwFEktk7uzpZOCcBY74-YBdrKVGs,1
|
|
11
|
-
github_backup-0.56.0.dist-info/RECORD,,
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|