github-backup 0.60.0__py3-none-any.whl → 0.61.1__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 CHANGED
@@ -1 +1 @@
1
- __version__ = "0.60.0"
1
+ __version__ = "0.61.1"
github_backup/cli.py CHANGED
@@ -46,6 +46,16 @@ def main():
46
46
  "Use -t/--token or -f/--token-fine to authenticate."
47
47
  )
48
48
 
49
+ # Issue #477: Fine-grained PATs cannot download all attachment types from
50
+ # private repos. Image attachments will be retried via Markdown API workaround.
51
+ if args.include_attachments and args.token_fine:
52
+ logger.warning(
53
+ "Using --attachments with fine-grained token. Due to GitHub platform "
54
+ "limitations, file attachments (PDFs, etc.) from private repos may fail. "
55
+ "Image attachments will be retried via workaround. For full attachment "
56
+ "support, use --token-classic instead."
57
+ )
58
+
49
59
  if args.quiet:
50
60
  logger.setLevel(logging.WARNING)
51
61
 
@@ -310,6 +310,12 @@ def parse_args(args=None):
310
310
  dest="include_milestones",
311
311
  help="include milestones in backup",
312
312
  )
313
+ parser.add_argument(
314
+ "--security-advisories",
315
+ action="store_true",
316
+ dest="include_security_advisories",
317
+ help="include security advisories in backup",
318
+ )
313
319
  parser.add_argument(
314
320
  "--repositories",
315
321
  action="store_true",
@@ -1056,6 +1062,65 @@ def download_attachment_file(url, path, auth, as_app=False, fine=False):
1056
1062
  return metadata
1057
1063
 
1058
1064
 
1065
+ def get_jwt_signed_url_via_markdown_api(url, token, repo_context):
1066
+ """Convert a user-attachments/assets URL to a JWT-signed URL via Markdown API.
1067
+
1068
+ GitHub's Markdown API renders image URLs and returns HTML containing
1069
+ JWT-signed private-user-images.githubusercontent.com URLs that work
1070
+ without token authentication.
1071
+
1072
+ This is a workaround for issue #477 where fine-grained PATs cannot
1073
+ download user-attachments URLs from private repos directly.
1074
+
1075
+ Limitations:
1076
+ - Only works for /assets/ URLs (images)
1077
+ - Does NOT work for /files/ URLs (PDFs, text files, etc.)
1078
+ - JWT URLs expire after ~5 minutes
1079
+
1080
+ Args:
1081
+ url: The github.com/user-attachments/assets/UUID URL
1082
+ token: Raw fine-grained PAT (github_pat_...)
1083
+ repo_context: Repository context as "owner/repo"
1084
+
1085
+ Returns:
1086
+ str: JWT-signed URL from private-user-images.githubusercontent.com
1087
+ None: If conversion fails
1088
+ """
1089
+
1090
+ try:
1091
+ payload = json.dumps(
1092
+ {"text": f"![img]({url})", "mode": "gfm", "context": repo_context}
1093
+ ).encode("utf-8")
1094
+
1095
+ request = Request("https://api.github.com/markdown", data=payload, method="POST")
1096
+ request.add_header("Authorization", f"token {token}")
1097
+ request.add_header("Content-Type", "application/json")
1098
+ request.add_header("Accept", "application/vnd.github+json")
1099
+
1100
+ html = urlopen(request, timeout=30).read().decode("utf-8")
1101
+
1102
+ # Parse JWT-signed URL from HTML response
1103
+ # Format: <img src="https://private-user-images.githubusercontent.com/...?jwt=..." ...>
1104
+ if match := re.search(
1105
+ r'src="(https://private-user-images\.githubusercontent\.com/[^"]+)"', html
1106
+ ):
1107
+ jwt_url = match.group(1)
1108
+ logger.debug("Converted attachment URL to JWT-signed URL via Markdown API")
1109
+ return jwt_url
1110
+
1111
+ logger.debug("Markdown API response did not contain JWT-signed URL")
1112
+ return None
1113
+
1114
+ except HTTPError as e:
1115
+ logger.debug(
1116
+ "Markdown API request failed with HTTP {0}: {1}".format(e.code, e.reason)
1117
+ )
1118
+ return None
1119
+ except Exception as e:
1120
+ logger.debug("Markdown API request failed: {0}".format(str(e)))
1121
+ return None
1122
+
1123
+
1059
1124
  def extract_attachment_urls(item_data, issue_number=None, repository_full_name=None):
1060
1125
  """Extract GitHub-hosted attachment URLs from issue/PR body and comments.
1061
1126
 
@@ -1409,15 +1474,46 @@ def download_attachments(
1409
1474
  filename = get_attachment_filename(url)
1410
1475
  filepath = os.path.join(attachments_dir, filename)
1411
1476
 
1412
- # Download and get metadata
1413
- metadata = download_attachment_file(
1414
- url,
1415
- filepath,
1416
- get_auth(args, encode=not args.as_app),
1417
- as_app=args.as_app,
1418
- fine=args.token_fine is not None,
1477
+ # Issue #477: Fine-grained PATs cannot download user-attachments/assets
1478
+ # from private repos directly (404). Use Markdown API workaround to get
1479
+ # a JWT-signed URL. Only works for /assets/ (images), not /files/.
1480
+ needs_jwt = (
1481
+ args.token_fine is not None
1482
+ and repository.get("private", False)
1483
+ and "github.com/user-attachments/assets/" in url
1419
1484
  )
1420
1485
 
1486
+ if not needs_jwt:
1487
+ # NORMAL download path
1488
+ metadata = download_attachment_file(
1489
+ url,
1490
+ filepath,
1491
+ get_auth(args, encode=not args.as_app),
1492
+ as_app=args.as_app,
1493
+ fine=args.token_fine is not None,
1494
+ )
1495
+ elif jwt_url := get_jwt_signed_url_via_markdown_api(
1496
+ url, args.token_fine, repository["full_name"]
1497
+ ):
1498
+ # JWT needed and extracted, download via JWT
1499
+ metadata = download_attachment_file(
1500
+ jwt_url, filepath, auth=None, as_app=False, fine=False
1501
+ )
1502
+ metadata["url"] = url # Apply back the original URL
1503
+ metadata["jwt_workaround"] = True
1504
+ else:
1505
+ # Markdown API workaround failed - skip download we know will fail
1506
+ metadata = {
1507
+ "url": url,
1508
+ "success": False,
1509
+ "skipped_at": datetime.now(timezone.utc).isoformat(),
1510
+ "error": "Fine-grained token cannot download private repo attachments. "
1511
+ "Markdown API workaround failed. Use --token-classic instead.",
1512
+ }
1513
+ logger.warning(
1514
+ "Skipping attachment {0}: {1}".format(url, metadata["error"])
1515
+ )
1516
+
1421
1517
  # If download succeeded but we got an extension from Content-Disposition,
1422
1518
  # we may need to rename the file to add the extension
1423
1519
  if metadata["success"] and metadata.get("original_filename"):
@@ -1718,6 +1814,9 @@ def backup_repositories(args, output_directory, repositories):
1718
1814
  if args.include_milestones or args.include_everything:
1719
1815
  backup_milestones(args, repo_cwd, repository, repos_template)
1720
1816
 
1817
+ if args.include_security_advisories or args.include_everything:
1818
+ backup_security_advisories(args, repo_cwd, repository, repos_template)
1819
+
1721
1820
  if args.include_labels or args.include_everything:
1722
1821
  backup_labels(args, repo_cwd, repository, repos_template)
1723
1822
 
@@ -1934,6 +2033,43 @@ def backup_milestones(args, repo_cwd, repository, repos_template):
1934
2033
  )
1935
2034
 
1936
2035
 
2036
+ def backup_security_advisories(args, repo_cwd, repository, repos_template):
2037
+ advisory_cwd = os.path.join(repo_cwd, "security-advisories")
2038
+ if args.skip_existing and os.path.isdir(advisory_cwd):
2039
+ return
2040
+
2041
+ logger.info("Retrieving {0} security advisories".format(repository["full_name"]))
2042
+ mkdir_p(repo_cwd, advisory_cwd)
2043
+
2044
+ template = "{0}/{1}/security-advisories".format(
2045
+ repos_template, repository["full_name"]
2046
+ )
2047
+
2048
+ _advisories = retrieve_data(args, template)
2049
+
2050
+ advisories = {}
2051
+ for advisory in _advisories:
2052
+ advisories[advisory["ghsa_id"]] = advisory
2053
+
2054
+ written_count = 0
2055
+ for ghsa_id, advisory in list(advisories.items()):
2056
+ advisory_file = "{0}/{1}.json".format(advisory_cwd, ghsa_id)
2057
+ if json_dump_if_changed(advisory, advisory_file):
2058
+ written_count += 1
2059
+
2060
+ total = len(advisories)
2061
+ if written_count == total:
2062
+ logger.info("Saved {0} security advisories to disk".format(total))
2063
+ elif written_count == 0:
2064
+ logger.info("{0} security advisories unchanged, skipped write".format(total))
2065
+ else:
2066
+ logger.info(
2067
+ "Saved {0} of {1} security advisories to disk ({2} unchanged)".format(
2068
+ written_count, total, total - written_count
2069
+ )
2070
+ )
2071
+
2072
+
1937
2073
  def backup_labels(args, repo_cwd, repository, repos_template):
1938
2074
  label_cwd = os.path.join(repo_cwd, "labels")
1939
2075
  output_file = "{0}/labels.json".format(label_cwd)
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: github-backup
3
- Version: 0.60.0
3
+ Version: 0.61.1
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
@@ -73,9 +73,9 @@ CLI Help output::
73
73
  [--watched] [--followers] [--following] [--all]
74
74
  [--issues] [--issue-comments] [--issue-events] [--pulls]
75
75
  [--pull-comments] [--pull-commits] [--pull-details]
76
- [--labels] [--hooks] [--milestones] [--repositories]
77
- [--bare] [--no-prune] [--lfs] [--wikis] [--gists]
78
- [--starred-gists] [--skip-archived] [--skip-existing]
76
+ [--labels] [--hooks] [--milestones] [--security-advisories]
77
+ [--repositories] [--bare] [--no-prune] [--lfs] [--wikis]
78
+ [--gists] [--starred-gists] [--skip-archived] [--skip-existing]
79
79
  [-L [LANGUAGES ...]] [-N NAME_REGEX] [-H GITHUB_HOST]
80
80
  [-O] [-R REPOSITORY] [-P] [-F] [--prefer-ssh] [-v]
81
81
  [--keychain-name OSX_KEYCHAIN_ITEM_NAME]
@@ -85,7 +85,7 @@ CLI Help output::
85
85
  [--skip-assets-on [SKIP_ASSETS_ON ...]] [--attachments]
86
86
  [--throttle-limit THROTTLE_LIMIT]
87
87
  [--throttle-pause THROTTLE_PAUSE]
88
- [--exclude [EXCLUDE ...]]
88
+ [--exclude [EXCLUDE ...]] [--retries MAX_RETRIES]
89
89
  USER
90
90
 
91
91
  Backup a github account
@@ -131,6 +131,8 @@ CLI Help output::
131
131
  --hooks include hooks in backup (works only when
132
132
  authenticated)
133
133
  --milestones include milestones in backup
134
+ --security-advisories
135
+ include security advisories in backup
134
136
  --repositories include repository clone in backup
135
137
  --bare clone bare repositories
136
138
  --no-prune disable prune option for git fetch
@@ -309,6 +311,8 @@ The tool automatically extracts file extensions from HTTP headers to ensure file
309
311
 
310
312
  **Repository filtering** for repo files/assets handles renamed and transferred repositories gracefully. URLs are included if they either match the current repository name directly, or redirect to it (e.g., ``willmcgugan/rich`` redirects to ``Textualize/rich`` after transfer).
311
313
 
314
+ **Fine-grained token limitation:** Due to a GitHub platform limitation, fine-grained personal access tokens (``github_pat_...``) cannot download attachments from private repositories directly. This affects both ``/assets/`` (images) and ``/files/`` (documents) URLs. The tool implements a workaround for image attachments using GitHub's Markdown API, which converts URLs to temporary JWT-signed URLs that can be downloaded. However, this workaround only works for images - document attachments (PDFs, text files, etc.) will fail with 404 errors when using fine-grained tokens on private repos. For full attachment support on private repositories, use a classic token (``-t``) instead of a fine-grained token (``-f``). See `#477 <https://github.com/josegonzalez/python-github-backup/issues/477>`_ for details.
315
+
312
316
 
313
317
  Run in Docker container
314
318
  -----------------------
@@ -431,7 +435,7 @@ Quietly and incrementally backup useful Github user data (public and private rep
431
435
  export FINE_ACCESS_TOKEN=SOME-GITHUB-TOKEN
432
436
  GH_USER=YOUR-GITHUB-USER
433
437
 
434
- github-backup -f $FINE_ACCESS_TOKEN --prefer-ssh -o ~/github-backup/ -l error -P -i --all-starred --starred --watched --followers --following --issues --issue-comments --issue-events --pulls --pull-comments --pull-commits --labels --milestones --repositories --wikis --releases --assets --attachments --pull-details --gists --starred-gists $GH_USER
438
+ github-backup -f $FINE_ACCESS_TOKEN --prefer-ssh -o ~/github-backup/ -l error -P -i --all-starred --starred --watched --followers --following --issues --issue-comments --issue-events --pulls --pull-comments --pull-commits --labels --milestones --security-advisories --repositories --wikis --releases --assets --attachments --pull-details --gists --starred-gists $GH_USER
435
439
 
436
440
  Debug an error/block or incomplete backup into a temporary directory. Omit "incremental" to fill a previous incomplete backup. ::
437
441
 
@@ -0,0 +1,11 @@
1
+ github_backup/__init__.py,sha256=3UxN07YtETzDhpq7DYM8JY8aOjsIEIETvTVen65rK2k,23
2
+ github_backup/__main__.py,sha256=3yorCiC63maaxbjdtaeAs-G-yAAxKSO3mprhGfa1Fho,271
3
+ github_backup/cli.py,sha256=gPYU0CviuHB19y8RHhcD0p8xHS3UcJbBIK6HVfWazTc,2902
4
+ github_backup/github_backup.py,sha256=ApN6orMb-BlJJVI_PTZZkzteEzuZBhTySydlRWU-Bi0,87911
5
+ github_backup-0.61.1.dist-info/licenses/LICENSE.txt,sha256=ML-pLyn1qtAKo_DHMcQ8-p7k3f8cp-IR-mf5B49R2rk,1085
6
+ github_backup-0.61.1.dist-info/METADATA,sha256=-iHVrHGbT0z0R9VqXbltR7pRxDV18aOnfFdofA73K3s,27503
7
+ github_backup-0.61.1.dist-info/WHEEL,sha256=_zCd3N1l69ArxyTb8rzEoP9TpbYXkqRFSNOD5OuxnTs,91
8
+ github_backup-0.61.1.dist-info/entry_points.txt,sha256=cLPKK7MqC77QVb6HnT2mnyL_f-ciavJG_Y9vVHlCF68,57
9
+ github_backup-0.61.1.dist-info/top_level.txt,sha256=Za6fKRAlxZ0b3ZBH_osPqklLe14LetdNAkvAQANT2AA,14
10
+ github_backup-0.61.1.dist-info/zip-safe,sha256=AbpHGcgLb-kRsJGnwFEktk7uzpZOCcBY74-YBdrKVGs,1
11
+ github_backup-0.61.1.dist-info/RECORD,,
@@ -1,11 +0,0 @@
1
- github_backup/__init__.py,sha256=XCDU0PzRSGNchH5cFRqniZdytnPgXmacSgzeC0BxXiI,23
2
- github_backup/__main__.py,sha256=3yorCiC63maaxbjdtaeAs-G-yAAxKSO3mprhGfa1Fho,271
3
- github_backup/cli.py,sha256=ifnOne5AaEI7laofgIq3geURJtvbA-bzxSQgLcmZgd0,2345
4
- github_backup/github_backup.py,sha256=DJ1mFWELLAZBbM8UXmsHOxin3OBGFIFZk_SXQdhv_7w,82562
5
- github_backup-0.60.0.dist-info/licenses/LICENSE.txt,sha256=ML-pLyn1qtAKo_DHMcQ8-p7k3f8cp-IR-mf5B49R2rk,1085
6
- github_backup-0.60.0.dist-info/METADATA,sha256=Lwu1h4UYm6z8OUq9iieqgS_7ITlsB-oQujHB0h302PM,26529
7
- github_backup-0.60.0.dist-info/WHEEL,sha256=_zCd3N1l69ArxyTb8rzEoP9TpbYXkqRFSNOD5OuxnTs,91
8
- github_backup-0.60.0.dist-info/entry_points.txt,sha256=cLPKK7MqC77QVb6HnT2mnyL_f-ciavJG_Y9vVHlCF68,57
9
- github_backup-0.60.0.dist-info/top_level.txt,sha256=Za6fKRAlxZ0b3ZBH_osPqklLe14LetdNAkvAQANT2AA,14
10
- github_backup-0.60.0.dist-info/zip-safe,sha256=AbpHGcgLb-kRsJGnwFEktk7uzpZOCcBY74-YBdrKVGs,1
11
- github_backup-0.60.0.dist-info/RECORD,,