github-backup 0.61.0__tar.gz → 0.61.2__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.
Files changed (30) hide show
  1. {github_backup-0.61.0 → github_backup-0.61.2}/CHANGES.rst +72 -1
  2. {github_backup-0.61.0/github_backup.egg-info → github_backup-0.61.2}/PKG-INFO +14 -1
  3. {github_backup-0.61.0 → github_backup-0.61.2}/README +13 -0
  4. {github_backup-0.61.0 → github_backup-0.61.2}/README.rst +13 -0
  5. github_backup-0.61.2/github_backup/__init__.py +1 -0
  6. {github_backup-0.61.0 → github_backup-0.61.2}/github_backup/cli.py +10 -0
  7. {github_backup-0.61.0 → github_backup-0.61.2}/github_backup/github_backup.py +110 -11
  8. {github_backup-0.61.0 → github_backup-0.61.2/github_backup.egg-info}/PKG-INFO +14 -1
  9. {github_backup-0.61.0 → github_backup-0.61.2}/release-requirements.txt +1 -1
  10. {github_backup-0.61.0 → github_backup-0.61.2}/tests/test_all_starred.py +9 -53
  11. {github_backup-0.61.0 → github_backup-0.61.2}/tests/test_attachments.py +147 -13
  12. {github_backup-0.61.0 → github_backup-0.61.2}/tests/test_case_sensitivity.py +6 -40
  13. {github_backup-0.61.0 → github_backup-0.61.2}/tests/test_http_451.py +6 -30
  14. {github_backup-0.61.0 → github_backup-0.61.2}/tests/test_pagination.py +10 -24
  15. {github_backup-0.61.0 → github_backup-0.61.2}/tests/test_retrieve_data.py +26 -61
  16. {github_backup-0.61.0 → github_backup-0.61.2}/tests/test_skip_assets_on.py +15 -61
  17. {github_backup-0.61.0 → github_backup-0.61.2}/tests/test_starred_skip_size_over.py +26 -49
  18. github_backup-0.61.0/github_backup/__init__.py +0 -1
  19. {github_backup-0.61.0 → github_backup-0.61.2}/LICENSE.txt +0 -0
  20. {github_backup-0.61.0 → github_backup-0.61.2}/MANIFEST.in +0 -0
  21. {github_backup-0.61.0 → github_backup-0.61.2}/github_backup/__main__.py +0 -0
  22. {github_backup-0.61.0 → github_backup-0.61.2}/github_backup.egg-info/SOURCES.txt +0 -0
  23. {github_backup-0.61.0 → github_backup-0.61.2}/github_backup.egg-info/dependency_links.txt +0 -0
  24. {github_backup-0.61.0 → github_backup-0.61.2}/github_backup.egg-info/entry_points.txt +0 -0
  25. {github_backup-0.61.0 → github_backup-0.61.2}/github_backup.egg-info/top_level.txt +0 -0
  26. {github_backup-0.61.0 → github_backup-0.61.2}/github_backup.egg-info/zip-safe +0 -0
  27. {github_backup-0.61.0 → github_backup-0.61.2}/requirements.txt +0 -0
  28. {github_backup-0.61.0 → github_backup-0.61.2}/setup.cfg +0 -0
  29. {github_backup-0.61.0 → github_backup-0.61.2}/setup.py +0 -0
  30. {github_backup-0.61.0 → github_backup-0.61.2}/tests/test_json_dump_if_changed.py +0 -0
@@ -1,9 +1,80 @@
1
1
  Changelog
2
2
  =========
3
3
 
4
- 0.61.0 (2026-01-12)
4
+ 0.61.2 (2026-01-19)
5
5
  -------------------
6
6
  ------------------------
7
+
8
+ Fix
9
+ ~~~
10
+ - Skip security advisories for private repos unless explicitly
11
+ requested. [Lukas Bestle]
12
+ - Handle 404 errors on security advisories. [Lukas Bestle]
13
+
14
+ Other
15
+ ~~~~~
16
+ - Chore(deps): bump black in the python-packages group.
17
+ [dependabot[bot]]
18
+
19
+ Bumps the python-packages group with 1 update: [black](https://github.com/psf/black).
20
+
21
+
22
+ Updates `black` from 25.12.0 to 26.1.0
23
+ - [Release notes](https://github.com/psf/black/releases)
24
+ - [Changelog](https://github.com/psf/black/blob/main/CHANGES.md)
25
+ - [Commits](https://github.com/psf/black/compare/25.12.0...26.1.0)
26
+
27
+ ---
28
+ updated-dependencies:
29
+ - dependency-name: black
30
+ dependency-version: 26.1.0
31
+ dependency-type: direct:production
32
+ update-type: version-update:semver-major
33
+ dependency-group: python-packages
34
+ ...
35
+ - Docs: Explain security advisories in README. [Lukas Bestle]
36
+ - Feat: Only make security advisory dir if successful. [Lukas Bestle]
37
+
38
+ Avoids empty directories for private repos
39
+
40
+
41
+ 0.61.1 (2026-01-13)
42
+ -------------------
43
+ - Refactor test fixtures to use shared create_args helper. [Rodos]
44
+
45
+ Uses the real parse_args() function to get CLI defaults, so when
46
+ new arguments are added they're automatically available to all tests.
47
+
48
+ Changes:
49
+ - Add tests/conftest.py with create_args fixture
50
+ - Update 8 test files to use shared fixture
51
+ - Remove duplicate _create_mock_args methods
52
+ - Remove redundant @pytest.fixture mock_args definitions
53
+
54
+ This eliminates the need to update multiple test files when
55
+ adding new CLI arguments.
56
+ - Fix fine-grained PAT attachment downloads for private repos (#477)
57
+ [Rodos]
58
+
59
+ Fine-grained personal access tokens cannot download attachments from
60
+ private repositories directly due to a GitHub platform limitation.
61
+
62
+ This adds a workaround for image attachments (/assets/ URLs) using
63
+ GitHub's Markdown API to convert URLs to JWT-signed URLs that can be
64
+ downloaded without authentication.
65
+
66
+ Changes:
67
+ - Add get_jwt_signed_url_via_markdown_api() function
68
+ - Detect fine-grained token + private repo + /assets/ URL upfront
69
+ - Use JWT workaround for those cases, mark success with jwt_workaround flag
70
+ - Skip download with skipped_at when workaround fails
71
+ - Add startup warning when using --attachments with fine-grained tokens
72
+ - Document limitation in README (file attachments still fail)
73
+ - Add 6 unit tests for JWT workaround logic
74
+
75
+
76
+ 0.61.0 (2026-01-12)
77
+ -------------------
7
78
  - Docs: Add missing `--retries` argument to README. [Lukas Bestle]
8
79
  - Test: Adapt tests to new argument. [Lukas Bestle]
9
80
  - Feat: Backup of repository security advisories. [Lukas Bestle]
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: github-backup
3
- Version: 0.61.0
3
+ Version: 0.61.2
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
@@ -311,6 +311,19 @@ The tool automatically extracts file extensions from HTTP headers to ensure file
311
311
 
312
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).
313
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
+
316
+
317
+ About security advisories
318
+ -------------------------
319
+
320
+ GitHub security advisories are only available in public repositories. GitHub does not provide the respective API endpoint for private repositories.
321
+
322
+ Therefore the logic is implemented as follows:
323
+ - Security advisories are included in the `--all` option.
324
+ - If only the `--all` option was provided, backups of security advisories are skipped for private repositories.
325
+ - If the `--security-advisories` option is provided (on its own or in addition to `--all`), a backup of security advisories is attempted for all repositories, with graceful handling if the GitHub API doesn't return any.
326
+
314
327
 
315
328
  Run in Docker container
316
329
  -----------------------
@@ -281,6 +281,19 @@ The tool automatically extracts file extensions from HTTP headers to ensure file
281
281
 
282
282
  **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).
283
283
 
284
+ **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.
285
+
286
+
287
+ About security advisories
288
+ -------------------------
289
+
290
+ GitHub security advisories are only available in public repositories. GitHub does not provide the respective API endpoint for private repositories.
291
+
292
+ Therefore the logic is implemented as follows:
293
+ - Security advisories are included in the `--all` option.
294
+ - If only the `--all` option was provided, backups of security advisories are skipped for private repositories.
295
+ - If the `--security-advisories` option is provided (on its own or in addition to `--all`), a backup of security advisories is attempted for all repositories, with graceful handling if the GitHub API doesn't return any.
296
+
284
297
 
285
298
  Run in Docker container
286
299
  -----------------------
@@ -281,6 +281,19 @@ The tool automatically extracts file extensions from HTTP headers to ensure file
281
281
 
282
282
  **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).
283
283
 
284
+ **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.
285
+
286
+
287
+ About security advisories
288
+ -------------------------
289
+
290
+ GitHub security advisories are only available in public repositories. GitHub does not provide the respective API endpoint for private repositories.
291
+
292
+ Therefore the logic is implemented as follows:
293
+ - Security advisories are included in the `--all` option.
294
+ - If only the `--all` option was provided, backups of security advisories are skipped for private repositories.
295
+ - If the `--security-advisories` option is provided (on its own or in addition to `--all`), a backup of security advisories is attempted for all repositories, with graceful handling if the GitHub API doesn't return any.
296
+
284
297
 
285
298
  Run in Docker container
286
299
  -----------------------
@@ -0,0 +1 @@
1
+ __version__ = "0.61.2"
@@ -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
 
@@ -1062,6 +1062,65 @@ def download_attachment_file(url, path, auth, as_app=False, fine=False):
1062
1062
  return metadata
1063
1063
 
1064
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
+
1065
1124
  def extract_attachment_urls(item_data, issue_number=None, repository_full_name=None):
1066
1125
  """Extract GitHub-hosted attachment URLs from issue/PR body and comments.
1067
1126
 
@@ -1415,15 +1474,46 @@ def download_attachments(
1415
1474
  filename = get_attachment_filename(url)
1416
1475
  filepath = os.path.join(attachments_dir, filename)
1417
1476
 
1418
- # Download and get metadata
1419
- metadata = download_attachment_file(
1420
- url,
1421
- filepath,
1422
- get_auth(args, encode=not args.as_app),
1423
- as_app=args.as_app,
1424
- 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
1425
1484
  )
1426
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
+
1427
1517
  # If download succeeded but we got an extension from Content-Disposition,
1428
1518
  # we may need to rename the file to add the extension
1429
1519
  if metadata["success"] and metadata.get("original_filename"):
@@ -1724,7 +1814,7 @@ def backup_repositories(args, output_directory, repositories):
1724
1814
  if args.include_milestones or args.include_everything:
1725
1815
  backup_milestones(args, repo_cwd, repository, repos_template)
1726
1816
 
1727
- if args.include_security_advisories or args.include_everything:
1817
+ if args.include_security_advisories or (args.include_everything and not repository["Private"]):
1728
1818
  backup_security_advisories(args, repo_cwd, repository, repos_template)
1729
1819
 
1730
1820
  if args.include_labels or args.include_everything:
@@ -1949,11 +2039,20 @@ def backup_security_advisories(args, repo_cwd, repository, repos_template):
1949
2039
  return
1950
2040
 
1951
2041
  logger.info("Retrieving {0} security advisories".format(repository["full_name"]))
1952
- mkdir_p(repo_cwd, advisory_cwd)
1953
2042
 
1954
- template = "{0}/{1}/security-advisories".format(repos_template, repository["full_name"])
2043
+ template = "{0}/{1}/security-advisories".format(
2044
+ repos_template, repository["full_name"]
2045
+ )
1955
2046
 
1956
- _advisories = retrieve_data(args, template)
2047
+ try:
2048
+ _advisories = retrieve_data(args, template)
2049
+ except Exception as e:
2050
+ if "404" in str(e):
2051
+ logger.info("Security advisories are not available for this repository, skipping")
2052
+ return
2053
+ raise
2054
+
2055
+ mkdir_p(repo_cwd, advisory_cwd)
1957
2056
 
1958
2057
  advisories = {}
1959
2058
  for advisory in _advisories:
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: github-backup
3
- Version: 0.61.0
3
+ Version: 0.61.2
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
@@ -311,6 +311,19 @@ The tool automatically extracts file extensions from HTTP headers to ensure file
311
311
 
312
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).
313
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
+
316
+
317
+ About security advisories
318
+ -------------------------
319
+
320
+ GitHub security advisories are only available in public repositories. GitHub does not provide the respective API endpoint for private repositories.
321
+
322
+ Therefore the logic is implemented as follows:
323
+ - Security advisories are included in the `--all` option.
324
+ - If only the `--all` option was provided, backups of security advisories are skipped for private repositories.
325
+ - If the `--security-advisories` option is provided (on its own or in addition to `--all`), a backup of security advisories is attempted for all repositories, with graceful handling if the GitHub API doesn't return any.
326
+
314
327
 
315
328
  Run in Docker container
316
329
  -----------------------
@@ -1,6 +1,6 @@
1
1
  # Linting & Formatting
2
2
  autopep8==2.3.2
3
- black==25.12.0
3
+ black==26.1.0
4
4
  flake8==7.3.0
5
5
 
6
6
  # Testing
@@ -1,7 +1,7 @@
1
1
  """Tests for --all-starred flag behavior (issue #225)."""
2
2
 
3
3
  import pytest
4
- from unittest.mock import Mock, patch
4
+ from unittest.mock import patch
5
5
 
6
6
  from github_backup import github_backup
7
7
 
@@ -12,58 +12,14 @@ class TestAllStarredCloning:
12
12
  Issue #225: --all-starred should clone starred repos without requiring --repositories.
13
13
  """
14
14
 
15
- def _create_mock_args(self, **overrides):
16
- """Create a mock args object with sensible defaults."""
17
- args = Mock()
18
- args.user = "testuser"
19
- args.output_directory = "/tmp/backup"
20
- args.include_repository = False
21
- args.include_everything = False
22
- args.include_gists = False
23
- args.include_starred_gists = False
24
- args.all_starred = False
25
- args.skip_existing = False
26
- args.bare_clone = False
27
- args.lfs_clone = False
28
- args.no_prune = False
29
- args.include_wiki = False
30
- args.include_issues = False
31
- args.include_issue_comments = False
32
- args.include_issue_events = False
33
- args.include_pulls = False
34
- args.include_pull_comments = False
35
- args.include_pull_commits = False
36
- args.include_pull_details = False
37
- args.include_labels = False
38
- args.include_hooks = False
39
- args.include_milestones = False
40
- args.include_security_advisories = False
41
- args.include_releases = False
42
- args.include_assets = False
43
- args.include_attachments = False
44
- args.incremental = False
45
- args.incremental_by_files = False
46
- args.github_host = None
47
- args.prefer_ssh = False
48
- args.token_classic = None
49
- args.token_fine = None
50
- args.as_app = False
51
- args.osx_keychain_item_name = None
52
- args.osx_keychain_item_account = None
53
-
54
- for key, value in overrides.items():
55
- setattr(args, key, value)
56
-
57
- return args
58
-
59
15
  @patch('github_backup.github_backup.fetch_repository')
60
16
  @patch('github_backup.github_backup.get_github_repo_url')
61
- def test_all_starred_clones_without_repositories_flag(self, mock_get_url, mock_fetch):
17
+ def test_all_starred_clones_without_repositories_flag(self, mock_get_url, mock_fetch, create_args):
62
18
  """--all-starred should clone starred repos without --repositories flag.
63
19
 
64
20
  This is the core fix for issue #225.
65
21
  """
66
- args = self._create_mock_args(all_starred=True)
22
+ args = create_args(all_starred=True)
67
23
  mock_get_url.return_value = "https://github.com/otheruser/awesome-project.git"
68
24
 
69
25
  # A starred repository (is_starred flag set by retrieve_repositories)
@@ -88,9 +44,9 @@ class TestAllStarredCloning:
88
44
 
89
45
  @patch('github_backup.github_backup.fetch_repository')
90
46
  @patch('github_backup.github_backup.get_github_repo_url')
91
- def test_starred_repo_not_cloned_without_all_starred_flag(self, mock_get_url, mock_fetch):
47
+ def test_starred_repo_not_cloned_without_all_starred_flag(self, mock_get_url, mock_fetch, create_args):
92
48
  """Starred repos should NOT be cloned if --all-starred is not set."""
93
- args = self._create_mock_args(all_starred=False)
49
+ args = create_args(all_starred=False)
94
50
  mock_get_url.return_value = "https://github.com/otheruser/awesome-project.git"
95
51
 
96
52
  starred_repo = {
@@ -111,9 +67,9 @@ class TestAllStarredCloning:
111
67
 
112
68
  @patch('github_backup.github_backup.fetch_repository')
113
69
  @patch('github_backup.github_backup.get_github_repo_url')
114
- def test_non_starred_repo_not_cloned_with_only_all_starred(self, mock_get_url, mock_fetch):
70
+ def test_non_starred_repo_not_cloned_with_only_all_starred(self, mock_get_url, mock_fetch, create_args):
115
71
  """Non-starred repos should NOT be cloned when only --all-starred is set."""
116
- args = self._create_mock_args(all_starred=True)
72
+ args = create_args(all_starred=True)
117
73
  mock_get_url.return_value = "https://github.com/testuser/my-project.git"
118
74
 
119
75
  # A regular (non-starred) repository
@@ -135,9 +91,9 @@ class TestAllStarredCloning:
135
91
 
136
92
  @patch('github_backup.github_backup.fetch_repository')
137
93
  @patch('github_backup.github_backup.get_github_repo_url')
138
- def test_repositories_flag_still_works(self, mock_get_url, mock_fetch):
94
+ def test_repositories_flag_still_works(self, mock_get_url, mock_fetch, create_args):
139
95
  """--repositories flag should still clone repos as before."""
140
- args = self._create_mock_args(include_repository=True)
96
+ args = create_args(include_repository=True)
141
97
  mock_get_url.return_value = "https://github.com/testuser/my-project.git"
142
98
 
143
99
  regular_repo = {
@@ -4,7 +4,7 @@ import json
4
4
  import os
5
5
  import tempfile
6
6
  from pathlib import Path
7
- from unittest.mock import Mock
7
+ from unittest.mock import Mock, patch
8
8
 
9
9
  import pytest
10
10
 
@@ -12,22 +12,13 @@ from github_backup import github_backup
12
12
 
13
13
 
14
14
  @pytest.fixture
15
- def attachment_test_setup(tmp_path):
15
+ def attachment_test_setup(tmp_path, create_args):
16
16
  """Fixture providing setup and helper for attachment download tests."""
17
- from unittest.mock import patch
18
-
19
17
  issue_cwd = tmp_path / "issues"
20
18
  issue_cwd.mkdir()
21
19
 
22
- # Mock args
23
- args = Mock()
24
- args.as_app = False
25
- args.token_fine = None
26
- args.token_classic = None
27
- args.osx_keychain_item_name = None
28
- args.osx_keychain_item_account = None
29
- args.user = "testuser"
30
- args.repository = "testrepo"
20
+ # Create args using shared fixture
21
+ args = create_args(user="testuser", repository="testrepo")
31
22
 
32
23
  repository = {"full_name": "testuser/testrepo"}
33
24
 
@@ -349,3 +340,146 @@ class TestManifestDuplicatePrevention:
349
340
  downloaded_urls[0]
350
341
  == "https://github.com/user-attachments/assets/unavailable"
351
342
  )
343
+
344
+
345
+ class TestJWTWorkaround:
346
+ """Test JWT workaround for fine-grained tokens on private repos (issue #477)."""
347
+
348
+ def test_markdown_api_extracts_jwt_url(self):
349
+ """Markdown API response with JWT URL is extracted correctly."""
350
+ html_response = (
351
+ '<p><a href="https://private-user-images.githubusercontent.com'
352
+ '/123/abc.png?jwt=eyJhbGciOiJ"><img src="https://private-user-'
353
+ 'images.githubusercontent.com/123/abc.png?jwt=eyJhbGciOiJ" '
354
+ 'alt="img"></a></p>'
355
+ )
356
+
357
+ mock_response = Mock()
358
+ mock_response.read.return_value = html_response.encode("utf-8")
359
+
360
+ with patch("github_backup.github_backup.urlopen", return_value=mock_response):
361
+ result = github_backup.get_jwt_signed_url_via_markdown_api(
362
+ "https://github.com/user-attachments/assets/abc123",
363
+ "github_pat_token",
364
+ "owner/repo"
365
+ )
366
+
367
+ expected = (
368
+ "https://private-user-images.githubusercontent.com"
369
+ "/123/abc.png?jwt=eyJhbGciOiJ"
370
+ )
371
+ assert result == expected
372
+
373
+ def test_markdown_api_returns_none_on_http_error(self):
374
+ """HTTP errors return None."""
375
+ from urllib.error import HTTPError
376
+
377
+ error = HTTPError("http://test", 403, "Forbidden", {}, None)
378
+ with patch("github_backup.github_backup.urlopen", side_effect=error):
379
+ result = github_backup.get_jwt_signed_url_via_markdown_api(
380
+ "https://github.com/user-attachments/assets/abc123",
381
+ "github_pat_token",
382
+ "owner/repo"
383
+ )
384
+
385
+ assert result is None
386
+
387
+ def test_markdown_api_returns_none_when_no_jwt_url(self):
388
+ """Response without JWT URL returns None."""
389
+ mock_response = Mock()
390
+ mock_response.read.return_value = b"<p>No image here</p>"
391
+
392
+ with patch("github_backup.github_backup.urlopen", return_value=mock_response):
393
+ result = github_backup.get_jwt_signed_url_via_markdown_api(
394
+ "https://github.com/user-attachments/assets/abc123",
395
+ "github_pat_token",
396
+ "owner/repo"
397
+ )
398
+
399
+ assert result is None
400
+
401
+ def test_needs_jwt_only_for_fine_grained_private_assets(self):
402
+ """needs_jwt is True only for fine-grained + private + /assets/ URL."""
403
+ assets_url = "https://github.com/user-attachments/assets/abc123"
404
+ files_url = "https://github.com/user-attachments/files/123/doc.pdf"
405
+ token_fine = "github_pat_test"
406
+ private = True
407
+ public = False
408
+
409
+ # Fine-grained + private + assets = True
410
+ needs_jwt = (
411
+ token_fine is not None
412
+ and private
413
+ and "github.com/user-attachments/assets/" in assets_url
414
+ )
415
+ assert needs_jwt is True
416
+
417
+ # Fine-grained + private + files = False
418
+ needs_jwt = (
419
+ token_fine is not None
420
+ and private
421
+ and "github.com/user-attachments/assets/" in files_url
422
+ )
423
+ assert needs_jwt is False
424
+
425
+ # Fine-grained + public + assets = False
426
+ needs_jwt = (
427
+ token_fine is not None
428
+ and public
429
+ and "github.com/user-attachments/assets/" in assets_url
430
+ )
431
+ assert needs_jwt is False
432
+
433
+ def test_jwt_workaround_sets_manifest_flag(self, attachment_test_setup):
434
+ """Successful JWT workaround sets jwt_workaround flag in manifest."""
435
+ setup = attachment_test_setup
436
+ setup["args"].token_fine = "github_pat_test"
437
+ setup["repository"]["private"] = True
438
+
439
+ issue_data = {"body": "https://github.com/user-attachments/assets/abc123"}
440
+
441
+ jwt_url = "https://private-user-images.githubusercontent.com/123/abc.png?jwt=token"
442
+
443
+ with patch(
444
+ "github_backup.github_backup.get_jwt_signed_url_via_markdown_api",
445
+ return_value=jwt_url
446
+ ), patch(
447
+ "github_backup.github_backup.download_attachment_file",
448
+ return_value={"success": True, "http_status": 200, "url": jwt_url}
449
+ ):
450
+ github_backup.download_attachments(
451
+ setup["args"], setup["issue_cwd"], issue_data, 123, setup["repository"]
452
+ )
453
+
454
+ manifest_path = os.path.join(setup["issue_cwd"], "attachments", "123", "manifest.json")
455
+ with open(manifest_path) as f:
456
+ manifest = json.load(f)
457
+
458
+ assert manifest["attachments"][0]["jwt_workaround"] is True
459
+ assert manifest["attachments"][0]["url"] == "https://github.com/user-attachments/assets/abc123"
460
+
461
+ def test_jwt_workaround_failure_uses_skipped_at(self, attachment_test_setup):
462
+ """Failed JWT workaround uses skipped_at instead of downloaded_at."""
463
+ setup = attachment_test_setup
464
+ setup["args"].token_fine = "github_pat_test"
465
+ setup["repository"]["private"] = True
466
+
467
+ issue_data = {"body": "https://github.com/user-attachments/assets/abc123"}
468
+
469
+ with patch(
470
+ "github_backup.github_backup.get_jwt_signed_url_via_markdown_api",
471
+ return_value=None # Markdown API failed
472
+ ):
473
+ github_backup.download_attachments(
474
+ setup["args"], setup["issue_cwd"], issue_data, 123, setup["repository"]
475
+ )
476
+
477
+ manifest_path = os.path.join(setup["issue_cwd"], "attachments", "123", "manifest.json")
478
+ with open(manifest_path) as f:
479
+ manifest = json.load(f)
480
+
481
+ attachment = manifest["attachments"][0]
482
+ assert attachment["success"] is False
483
+ assert "skipped_at" in attachment
484
+ assert "downloaded_at" not in attachment
485
+ assert "Use --token-classic" in attachment["error"]