github-backup 0.64.2__tar.gz → 0.65.0__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 (41) hide show
  1. {github_backup-0.64.2 → github_backup-0.65.0}/CHANGES.rst +72 -1
  2. {github_backup-0.64.2/github_backup.egg-info → github_backup-0.65.0}/PKG-INFO +28 -4
  3. {github_backup-0.64.2 → github_backup-0.65.0}/README +27 -3
  4. {github_backup-0.64.2 → github_backup-0.65.0}/README.rst +27 -3
  5. github_backup-0.65.0/github_backup/__init__.py +1 -0
  6. {github_backup-0.64.2 → github_backup-0.65.0}/github_backup/github_backup.py +187 -1
  7. {github_backup-0.64.2 → github_backup-0.65.0}/github_backup/graphql_queries.py +60 -0
  8. {github_backup-0.64.2 → github_backup-0.65.0/github_backup.egg-info}/PKG-INFO +28 -4
  9. {github_backup-0.64.2 → github_backup-0.65.0}/github_backup.egg-info/SOURCES.txt +1 -0
  10. github_backup-0.65.0/tests/test_issue_timeline.py +434 -0
  11. github_backup-0.64.2/github_backup/__init__.py +0 -1
  12. {github_backup-0.64.2 → github_backup-0.65.0}/LICENSE.txt +0 -0
  13. {github_backup-0.64.2 → github_backup-0.65.0}/MANIFEST.in +0 -0
  14. {github_backup-0.64.2 → github_backup-0.65.0}/github_backup/__main__.py +0 -0
  15. {github_backup-0.64.2 → github_backup-0.65.0}/github_backup/cli.py +0 -0
  16. {github_backup-0.64.2 → github_backup-0.65.0}/github_backup.egg-info/dependency_links.txt +0 -0
  17. {github_backup-0.64.2 → github_backup-0.65.0}/github_backup.egg-info/entry_points.txt +0 -0
  18. {github_backup-0.64.2 → github_backup-0.65.0}/github_backup.egg-info/top_level.txt +0 -0
  19. {github_backup-0.64.2 → github_backup-0.65.0}/github_backup.egg-info/zip-safe +0 -0
  20. {github_backup-0.64.2 → github_backup-0.65.0}/release-requirements.txt +0 -0
  21. {github_backup-0.64.2 → github_backup-0.65.0}/requirements.txt +0 -0
  22. {github_backup-0.64.2 → github_backup-0.65.0}/setup.cfg +0 -0
  23. {github_backup-0.64.2 → github_backup-0.65.0}/setup.py +0 -0
  24. {github_backup-0.64.2 → github_backup-0.65.0}/tests/test_all_starred.py +0 -0
  25. {github_backup-0.64.2 → github_backup-0.65.0}/tests/test_attachments.py +0 -0
  26. {github_backup-0.64.2 → github_backup-0.65.0}/tests/test_auth.py +0 -0
  27. {github_backup-0.64.2 → github_backup-0.65.0}/tests/test_case_sensitivity.py +0 -0
  28. {github_backup-0.64.2 → github_backup-0.65.0}/tests/test_disabled_issues_pulls.py +0 -0
  29. {github_backup-0.64.2 → github_backup-0.65.0}/tests/test_discussions.py +0 -0
  30. {github_backup-0.64.2 → github_backup-0.65.0}/tests/test_gists.py +0 -0
  31. {github_backup-0.64.2 → github_backup-0.65.0}/tests/test_http_451.py +0 -0
  32. {github_backup-0.64.2 → github_backup-0.65.0}/tests/test_incremental_per_repository.py +0 -0
  33. {github_backup-0.64.2 → github_backup-0.65.0}/tests/test_json_dump_if_changed.py +0 -0
  34. {github_backup-0.64.2 → github_backup-0.65.0}/tests/test_logging_subprocess.py +0 -0
  35. {github_backup-0.64.2 → github_backup-0.65.0}/tests/test_pagination.py +0 -0
  36. {github_backup-0.64.2 → github_backup-0.65.0}/tests/test_pull_incremental_pagination.py +0 -0
  37. {github_backup-0.64.2 → github_backup-0.65.0}/tests/test_pull_reviews.py +0 -0
  38. {github_backup-0.64.2 → github_backup-0.65.0}/tests/test_releases.py +0 -0
  39. {github_backup-0.64.2 → github_backup-0.65.0}/tests/test_retrieve_data.py +0 -0
  40. {github_backup-0.64.2 → github_backup-0.65.0}/tests/test_skip_assets_on.py +0 -0
  41. {github_backup-0.64.2 → github_backup-0.65.0}/tests/test_starred_skip_size_over.py +0 -0
@@ -1,9 +1,80 @@
1
1
  Changelog
2
2
  =========
3
3
 
4
- 0.64.2 (2026-07-18)
4
+ 0.65.0 (2026-07-27)
5
5
  -------------------
6
6
  ------------------------
7
+ - Refresh issue timelines when cross-references change (#168) [Rodos]
8
+
9
+ Cross-references do not change an issue's updated_at, so an incremental
10
+ run never lists the issue and never re-fetches its timeline. An issue
11
+ can be referenced from another repository years after its last activity
12
+ and the backup would never see it.
13
+
14
+ On incremental runs with --issue-timeline, ask GraphQL for each item's
15
+ cross-reference count and newest timestamp, compare both against what is
16
+ already saved, and re-fetch the timeline of anything that differs. Items
17
+ the since listing did not return are fetched individually. Both values
18
+ are needed: the count alone would miss a reference being added and
19
+ another deleted between runs, and the timestamp alone would miss a
20
+ deletion.
21
+
22
+ Pull requests are swept too when they are being stored as issues, which
23
+ is the case unless --pulls is used. They need their own query because
24
+ the issues connection excludes them, and because totalCount ignores the
25
+ itemTypes filter on a pull request although it honours it on an issue,
26
+ so their cross-references are counted from the nodes instead. Where that
27
+ count may have been truncated, the comparison falls back to the
28
+ timestamp alone rather than re-fetching on every run.
29
+
30
+ This costs one rate-limit point per 100 items against the GraphQL quota,
31
+ measured at 6 requests for a repository with 528 issues and pull
32
+ requests, and stores no new state: the comparison is against the
33
+ cross-references already in each item's timeline_data. A GraphQL failure
34
+ warns and leaves the rest of the backup unaffected.
35
+
36
+ Closes #168
37
+ - Add --issue-timeline flag to capture cross-reference events (#168)
38
+ [Rodos]
39
+
40
+ The issue events endpoint never returns cross-references, so an issue
41
+ mentioned from another issue or pull request leaves no trace in the
42
+ backup. GitHub only exposes those through the timeline endpoint. Full
43
+ backups miss them as well as incremental ones.
44
+
45
+ Adds --issue-timeline, which stores the timeline under a separate
46
+ timeline_data key alongside the unmodified event_data. The two
47
+ endpoints are complementary rather than nested: the timeline adds
48
+ cross-references, commits and reviews, while events carries
49
+ referenced entries the timeline drops, so neither replaces the other.
50
+
51
+ Timeline entries for comments are filtered out, since --issue-comments
52
+ already saves the bodies. Everything else is stored verbatim. The flag
53
+ is included in --all.
54
+
55
+ This makes the data obtainable, but does not change incremental
56
+ selection: a cross-reference does not bump the referenced issue's
57
+ updated_at, so an incremental run still never lists it. That half of
58
+ the issue remains open.
59
+
60
+ References #168
61
+ - Chore(deps): bump actions/setup-python from 6 to 7. [dependabot[bot]]
62
+
63
+ Bumps [actions/setup-python](https://github.com/actions/setup-python) from 6 to 7.
64
+ - [Release notes](https://github.com/actions/setup-python/releases)
65
+ - [Commits](https://github.com/actions/setup-python/compare/v6...v7)
66
+
67
+ ---
68
+ updated-dependencies:
69
+ - dependency-name: actions/setup-python
70
+ dependency-version: '7'
71
+ dependency-type: direct:production
72
+ update-type: version-update:semver-major
73
+ ...
74
+
75
+
76
+ 0.64.2 (2026-07-18)
77
+ -------------------
7
78
  - Add tests. [Duncan Ogilvie]
8
79
  - Skip fetching gists that have not been updated. [Duncan Ogilvie]
9
80
  - Skip retrieving repositories when not necessary. [Duncan Ogilvie]
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: github-backup
3
- Version: 0.64.2
3
+ Version: 0.65.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
@@ -71,7 +71,8 @@ CLI Help output::
71
71
  [--incremental-by-files]
72
72
  [--starred] [--all-starred] [--starred-skip-size-over MB]
73
73
  [--watched] [--followers] [--following] [--all]
74
- [--issues] [--issue-comments] [--issue-events] [--pulls]
74
+ [--issues] [--issue-comments] [--issue-events]
75
+ [--issue-timeline] [--pulls]
75
76
  [--pull-comments] [--pull-reviews] [--pull-commits]
76
77
  [--pull-details]
77
78
  [--labels] [--hooks] [--milestones] [--security-advisories]
@@ -126,6 +127,8 @@ CLI Help output::
126
127
  --issues include issues in backup
127
128
  --issue-comments include issue comments in backup
128
129
  --issue-events include issue events in backup
130
+ --issue-timeline include issue timeline in backup (cross-references,
131
+ commits and reviews; complements --issue-events)
129
132
  --pulls include pull requests in backup
130
133
  --pull-comments include pull request review comments in backup
131
134
  --pull-reviews include pull request reviews in backup
@@ -377,6 +380,27 @@ For finer control, avoid using ``--assets`` with starred repos, or use ``--skip-
377
380
 
378
381
  Alternatively, consider just storing links to starred repos in JSON format with ``--starred``.
379
382
 
383
+
384
+ About issue events and the issue timeline
385
+ -----------------------------------------
386
+
387
+ Use ``--issue-events`` and ``--issue-timeline`` with ``--issues``; on their own they back up nothing.
388
+
389
+ ``--issue-events`` backs up GitHub's issue events endpoint under each issue's ``event_data`` key. That endpoint returns a restricted set of event types and **never** includes cross-references, so an issue that was mentioned from another issue or pull request shows no sign of it. GitHub only exposes those through the timeline.
390
+
391
+ ``--issue-timeline`` backs up the timeline endpoint under a separate ``timeline_data`` key. The two flags are independent: ``event_data`` keeps its existing contents and shape whether or not the timeline is enabled, so nothing that already reads it is affected.
392
+
393
+ Use both flags together for a complete picture. Neither endpoint is a superset of the other, so they are complementary rather than alternatives. Only the timeline reports ``cross-referenced``, ``committed`` and ``reviewed`` entries. Only the events endpoint reliably reports ``referenced`` entries, where a commit elsewhere in the repository mentions the issue; the timeline omits many of these, substituting the issue's own ``committed`` entries instead.
394
+
395
+ Note that timeline entries are not all the same shape as events. ``cross-referenced`` and ``committed`` entries have no ``id`` field, so anything consuming ``timeline_data`` should not assume one is present. ``commented`` entries are excluded from the backup because they embed the full comment body, which ``--issue-comments`` already saves.
396
+
397
+ ``--issue-timeline`` is included in ``--all``. On issues with many cross-references it can be noticeably larger and slower than ``--issue-events`` alone, because each ``cross-referenced`` entry embeds the referencing issue in full. On a heavily cross-referenced issue this can mean several times the API requests and many times the JSON on disk. Ordinary repositories see little difference.
398
+
399
+ Incremental backups need extra help here, because being referenced from elsewhere does not change an issue's ``updated_at``. An issue can be mentioned from another repository years after its last activity, and the usual ``since`` listing will never report it as changed. On an incremental run with ``--issue-timeline``, github-backup therefore asks GitHub's GraphQL API for each item's cross-reference count and newest timestamp, compares those against what is already saved, and re-fetches the timeline of anything that differs. Pull requests are included in that check when they are being stored as issues, which is the case unless ``--pulls`` is used. The check needs no extra state on disk and also notices references that were later deleted. It costs one rate-limit point per 100 items checked, against GitHub's GraphQL quota, which is separate from the REST one. That is cheap in quota terms even on very large repositories, but it is not instant: a repository with 18,000 issues and pull requests took around 200 points and five minutes for the check alone.
400
+
401
+ If you enable ``--issue-timeline`` on an existing incremental backup, cross-references on older issues are picked up automatically by that check. Timeline entries of other kinds, such as commits and reviews, are only fetched when the issue itself is updated, so run once without ``--incremental`` if you want those filled in for historical issues too.
402
+
403
+
380
404
  About pull request reviews
381
405
  --------------------------
382
406
 
@@ -480,14 +504,14 @@ Quietly and incrementally backup useful Github user data (public and private rep
480
504
  FINE_ACCESS_TOKEN=SOME-GITHUB-TOKEN
481
505
  GH_USER=YOUR-GITHUB-USER
482
506
 
483
- 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-reviews --pull-commits --labels --milestones --security-advisories --discussions --repositories --wikis --releases --assets --attachments --pull-details --gists --starred-gists $GH_USER
507
+ 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 --issue-timeline --pulls --pull-comments --pull-reviews --pull-commits --labels --milestones --security-advisories --discussions --repositories --wikis --releases --assets --attachments --pull-details --gists --starred-gists $GH_USER
484
508
 
485
509
  Debug an error/block or incomplete backup into a temporary directory. Omit "incremental" to fill a previous incomplete backup. ::
486
510
 
487
511
  FINE_ACCESS_TOKEN=SOME-GITHUB-TOKEN
488
512
  GH_USER=YOUR-GITHUB-USER
489
513
 
490
- 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-reviews --pull-commits --labels --milestones --discussions --repositories --wikis --releases --assets --pull-details --gists --starred-gists $GH_USER
514
+ github-backup -f $FINE_ACCESS_TOKEN -o /tmp/github-backup/ -l debug -P --all-starred --starred --watched --followers --following --issues --issue-comments --issue-events --issue-timeline --pulls --pull-comments --pull-reviews --pull-commits --labels --milestones --discussions --repositories --wikis --releases --assets --pull-details --gists --starred-gists $GH_USER
491
515
 
492
516
  Pipe a token from stdin to avoid storing it in environment variables or command history (Unix-like systems only)::
493
517
 
@@ -41,7 +41,8 @@ CLI Help output::
41
41
  [--incremental-by-files]
42
42
  [--starred] [--all-starred] [--starred-skip-size-over MB]
43
43
  [--watched] [--followers] [--following] [--all]
44
- [--issues] [--issue-comments] [--issue-events] [--pulls]
44
+ [--issues] [--issue-comments] [--issue-events]
45
+ [--issue-timeline] [--pulls]
45
46
  [--pull-comments] [--pull-reviews] [--pull-commits]
46
47
  [--pull-details]
47
48
  [--labels] [--hooks] [--milestones] [--security-advisories]
@@ -96,6 +97,8 @@ CLI Help output::
96
97
  --issues include issues in backup
97
98
  --issue-comments include issue comments in backup
98
99
  --issue-events include issue events in backup
100
+ --issue-timeline include issue timeline in backup (cross-references,
101
+ commits and reviews; complements --issue-events)
99
102
  --pulls include pull requests in backup
100
103
  --pull-comments include pull request review comments in backup
101
104
  --pull-reviews include pull request reviews in backup
@@ -347,6 +350,27 @@ For finer control, avoid using ``--assets`` with starred repos, or use ``--skip-
347
350
 
348
351
  Alternatively, consider just storing links to starred repos in JSON format with ``--starred``.
349
352
 
353
+
354
+ About issue events and the issue timeline
355
+ -----------------------------------------
356
+
357
+ Use ``--issue-events`` and ``--issue-timeline`` with ``--issues``; on their own they back up nothing.
358
+
359
+ ``--issue-events`` backs up GitHub's issue events endpoint under each issue's ``event_data`` key. That endpoint returns a restricted set of event types and **never** includes cross-references, so an issue that was mentioned from another issue or pull request shows no sign of it. GitHub only exposes those through the timeline.
360
+
361
+ ``--issue-timeline`` backs up the timeline endpoint under a separate ``timeline_data`` key. The two flags are independent: ``event_data`` keeps its existing contents and shape whether or not the timeline is enabled, so nothing that already reads it is affected.
362
+
363
+ Use both flags together for a complete picture. Neither endpoint is a superset of the other, so they are complementary rather than alternatives. Only the timeline reports ``cross-referenced``, ``committed`` and ``reviewed`` entries. Only the events endpoint reliably reports ``referenced`` entries, where a commit elsewhere in the repository mentions the issue; the timeline omits many of these, substituting the issue's own ``committed`` entries instead.
364
+
365
+ Note that timeline entries are not all the same shape as events. ``cross-referenced`` and ``committed`` entries have no ``id`` field, so anything consuming ``timeline_data`` should not assume one is present. ``commented`` entries are excluded from the backup because they embed the full comment body, which ``--issue-comments`` already saves.
366
+
367
+ ``--issue-timeline`` is included in ``--all``. On issues with many cross-references it can be noticeably larger and slower than ``--issue-events`` alone, because each ``cross-referenced`` entry embeds the referencing issue in full. On a heavily cross-referenced issue this can mean several times the API requests and many times the JSON on disk. Ordinary repositories see little difference.
368
+
369
+ Incremental backups need extra help here, because being referenced from elsewhere does not change an issue's ``updated_at``. An issue can be mentioned from another repository years after its last activity, and the usual ``since`` listing will never report it as changed. On an incremental run with ``--issue-timeline``, github-backup therefore asks GitHub's GraphQL API for each item's cross-reference count and newest timestamp, compares those against what is already saved, and re-fetches the timeline of anything that differs. Pull requests are included in that check when they are being stored as issues, which is the case unless ``--pulls`` is used. The check needs no extra state on disk and also notices references that were later deleted. It costs one rate-limit point per 100 items checked, against GitHub's GraphQL quota, which is separate from the REST one. That is cheap in quota terms even on very large repositories, but it is not instant: a repository with 18,000 issues and pull requests took around 200 points and five minutes for the check alone.
370
+
371
+ If you enable ``--issue-timeline`` on an existing incremental backup, cross-references on older issues are picked up automatically by that check. Timeline entries of other kinds, such as commits and reviews, are only fetched when the issue itself is updated, so run once without ``--incremental`` if you want those filled in for historical issues too.
372
+
373
+
350
374
  About pull request reviews
351
375
  --------------------------
352
376
 
@@ -450,14 +474,14 @@ Quietly and incrementally backup useful Github user data (public and private rep
450
474
  FINE_ACCESS_TOKEN=SOME-GITHUB-TOKEN
451
475
  GH_USER=YOUR-GITHUB-USER
452
476
 
453
- 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-reviews --pull-commits --labels --milestones --security-advisories --discussions --repositories --wikis --releases --assets --attachments --pull-details --gists --starred-gists $GH_USER
477
+ 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 --issue-timeline --pulls --pull-comments --pull-reviews --pull-commits --labels --milestones --security-advisories --discussions --repositories --wikis --releases --assets --attachments --pull-details --gists --starred-gists $GH_USER
454
478
 
455
479
  Debug an error/block or incomplete backup into a temporary directory. Omit "incremental" to fill a previous incomplete backup. ::
456
480
 
457
481
  FINE_ACCESS_TOKEN=SOME-GITHUB-TOKEN
458
482
  GH_USER=YOUR-GITHUB-USER
459
483
 
460
- 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-reviews --pull-commits --labels --milestones --discussions --repositories --wikis --releases --assets --pull-details --gists --starred-gists $GH_USER
484
+ github-backup -f $FINE_ACCESS_TOKEN -o /tmp/github-backup/ -l debug -P --all-starred --starred --watched --followers --following --issues --issue-comments --issue-events --issue-timeline --pulls --pull-comments --pull-reviews --pull-commits --labels --milestones --discussions --repositories --wikis --releases --assets --pull-details --gists --starred-gists $GH_USER
461
485
 
462
486
  Pipe a token from stdin to avoid storing it in environment variables or command history (Unix-like systems only)::
463
487
 
@@ -41,7 +41,8 @@ CLI Help output::
41
41
  [--incremental-by-files]
42
42
  [--starred] [--all-starred] [--starred-skip-size-over MB]
43
43
  [--watched] [--followers] [--following] [--all]
44
- [--issues] [--issue-comments] [--issue-events] [--pulls]
44
+ [--issues] [--issue-comments] [--issue-events]
45
+ [--issue-timeline] [--pulls]
45
46
  [--pull-comments] [--pull-reviews] [--pull-commits]
46
47
  [--pull-details]
47
48
  [--labels] [--hooks] [--milestones] [--security-advisories]
@@ -96,6 +97,8 @@ CLI Help output::
96
97
  --issues include issues in backup
97
98
  --issue-comments include issue comments in backup
98
99
  --issue-events include issue events in backup
100
+ --issue-timeline include issue timeline in backup (cross-references,
101
+ commits and reviews; complements --issue-events)
99
102
  --pulls include pull requests in backup
100
103
  --pull-comments include pull request review comments in backup
101
104
  --pull-reviews include pull request reviews in backup
@@ -347,6 +350,27 @@ For finer control, avoid using ``--assets`` with starred repos, or use ``--skip-
347
350
 
348
351
  Alternatively, consider just storing links to starred repos in JSON format with ``--starred``.
349
352
 
353
+
354
+ About issue events and the issue timeline
355
+ -----------------------------------------
356
+
357
+ Use ``--issue-events`` and ``--issue-timeline`` with ``--issues``; on their own they back up nothing.
358
+
359
+ ``--issue-events`` backs up GitHub's issue events endpoint under each issue's ``event_data`` key. That endpoint returns a restricted set of event types and **never** includes cross-references, so an issue that was mentioned from another issue or pull request shows no sign of it. GitHub only exposes those through the timeline.
360
+
361
+ ``--issue-timeline`` backs up the timeline endpoint under a separate ``timeline_data`` key. The two flags are independent: ``event_data`` keeps its existing contents and shape whether or not the timeline is enabled, so nothing that already reads it is affected.
362
+
363
+ Use both flags together for a complete picture. Neither endpoint is a superset of the other, so they are complementary rather than alternatives. Only the timeline reports ``cross-referenced``, ``committed`` and ``reviewed`` entries. Only the events endpoint reliably reports ``referenced`` entries, where a commit elsewhere in the repository mentions the issue; the timeline omits many of these, substituting the issue's own ``committed`` entries instead.
364
+
365
+ Note that timeline entries are not all the same shape as events. ``cross-referenced`` and ``committed`` entries have no ``id`` field, so anything consuming ``timeline_data`` should not assume one is present. ``commented`` entries are excluded from the backup because they embed the full comment body, which ``--issue-comments`` already saves.
366
+
367
+ ``--issue-timeline`` is included in ``--all``. On issues with many cross-references it can be noticeably larger and slower than ``--issue-events`` alone, because each ``cross-referenced`` entry embeds the referencing issue in full. On a heavily cross-referenced issue this can mean several times the API requests and many times the JSON on disk. Ordinary repositories see little difference.
368
+
369
+ Incremental backups need extra help here, because being referenced from elsewhere does not change an issue's ``updated_at``. An issue can be mentioned from another repository years after its last activity, and the usual ``since`` listing will never report it as changed. On an incremental run with ``--issue-timeline``, github-backup therefore asks GitHub's GraphQL API for each item's cross-reference count and newest timestamp, compares those against what is already saved, and re-fetches the timeline of anything that differs. Pull requests are included in that check when they are being stored as issues, which is the case unless ``--pulls`` is used. The check needs no extra state on disk and also notices references that were later deleted. It costs one rate-limit point per 100 items checked, against GitHub's GraphQL quota, which is separate from the REST one. That is cheap in quota terms even on very large repositories, but it is not instant: a repository with 18,000 issues and pull requests took around 200 points and five minutes for the check alone.
370
+
371
+ If you enable ``--issue-timeline`` on an existing incremental backup, cross-references on older issues are picked up automatically by that check. Timeline entries of other kinds, such as commits and reviews, are only fetched when the issue itself is updated, so run once without ``--incremental`` if you want those filled in for historical issues too.
372
+
373
+
350
374
  About pull request reviews
351
375
  --------------------------
352
376
 
@@ -450,14 +474,14 @@ Quietly and incrementally backup useful Github user data (public and private rep
450
474
  FINE_ACCESS_TOKEN=SOME-GITHUB-TOKEN
451
475
  GH_USER=YOUR-GITHUB-USER
452
476
 
453
- 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-reviews --pull-commits --labels --milestones --security-advisories --discussions --repositories --wikis --releases --assets --attachments --pull-details --gists --starred-gists $GH_USER
477
+ 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 --issue-timeline --pulls --pull-comments --pull-reviews --pull-commits --labels --milestones --security-advisories --discussions --repositories --wikis --releases --assets --attachments --pull-details --gists --starred-gists $GH_USER
454
478
 
455
479
  Debug an error/block or incomplete backup into a temporary directory. Omit "incremental" to fill a previous incomplete backup. ::
456
480
 
457
481
  FINE_ACCESS_TOKEN=SOME-GITHUB-TOKEN
458
482
  GH_USER=YOUR-GITHUB-USER
459
483
 
460
- 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-reviews --pull-commits --labels --milestones --discussions --repositories --wikis --releases --assets --pull-details --gists --starred-gists $GH_USER
484
+ github-backup -f $FINE_ACCESS_TOKEN -o /tmp/github-backup/ -l debug -P --all-starred --starred --watched --followers --following --issues --issue-comments --issue-events --issue-timeline --pulls --pull-comments --pull-reviews --pull-commits --labels --milestones --discussions --repositories --wikis --releases --assets --pull-details --gists --starred-gists $GH_USER
461
485
 
462
486
  Pipe a token from stdin to avoid storing it in environment variables or command history (Unix-like systems only)::
463
487
 
@@ -0,0 +1 @@
1
+ __version__ = "0.65.0"
@@ -18,6 +18,7 @@ import subprocess
18
18
  import sys
19
19
  import threading
20
20
  import time
21
+ import traceback
21
22
  from collections.abc import Generator
22
23
  from datetime import datetime
23
24
  from http.client import IncompleteRead
@@ -33,10 +34,14 @@ except ImportError:
33
34
  VERSION = "unknown"
34
35
 
35
36
  from .graphql_queries import (
37
+ CROSS_REFERENCE_NODE_LIMIT,
38
+ CROSS_REFERENCE_PAGE_SIZE,
36
39
  DISCUSSION_DETAIL_QUERY,
37
40
  DISCUSSION_LIST_QUERY,
38
41
  DISCUSSION_PAGE_SIZE,
39
42
  DISCUSSION_REPLIES_QUERY,
43
+ ISSUE_CROSS_REFERENCE_COUNT_QUERY,
44
+ PULL_CROSS_REFERENCE_COUNT_QUERY,
40
45
  )
41
46
 
42
47
  FILE_URI_PREFIX = "file://"
@@ -278,6 +283,12 @@ def parse_args(args=None):
278
283
  dest="include_issue_events",
279
284
  help="include issue events in backup",
280
285
  )
286
+ parser.add_argument(
287
+ "--issue-timeline",
288
+ action="store_true",
289
+ dest="include_issue_timeline",
290
+ help="include issue timeline in backup (cross-references, commits and reviews; complements --issue-events)",
291
+ )
281
292
  parser.add_argument(
282
293
  "--pulls",
283
294
  action="store_true",
@@ -2569,6 +2580,141 @@ def backup_discussions(args, repo_cwd, repository):
2569
2580
  )
2570
2581
 
2571
2582
 
2583
+ def cross_reference_state(timeline_data):
2584
+ """Return (count, newest timestamp) of the cross-references in a timeline."""
2585
+ created = [
2586
+ item.get("created_at")
2587
+ for item in timeline_data or []
2588
+ if item.get("event") == "cross-referenced"
2589
+ ]
2590
+ return len(created), max((c for c in created if c), default=None)
2591
+
2592
+
2593
+ def _cross_reference_page(args, repository, query, connection, use_total_count):
2594
+ """Page one GraphQL connection, yielding {number: (count, newest)}.
2595
+
2596
+ totalCount honours the itemTypes filter on issues but not on pull
2597
+ requests, so pull request cross-references are counted from the nodes.
2598
+ """
2599
+ owner, name = _repository_owner_name(repository)
2600
+ state = {}
2601
+ after = None
2602
+ page = 1
2603
+
2604
+ while True:
2605
+ variables = {
2606
+ "owner": owner,
2607
+ "name": name,
2608
+ "after": after,
2609
+ "pageSize": CROSS_REFERENCE_PAGE_SIZE,
2610
+ }
2611
+ if not use_total_count:
2612
+ # Only the pull request query declares this.
2613
+ variables["nodeLimit"] = CROSS_REFERENCE_NODE_LIMIT
2614
+
2615
+ data = retrieve_graphql_data(
2616
+ args,
2617
+ query,
2618
+ variables,
2619
+ log_context="cross-references {0} {1} page {2}".format(
2620
+ repository["full_name"], connection, page
2621
+ ),
2622
+ )
2623
+ repository_data = data.get("repository")
2624
+ if repository_data is None:
2625
+ raise Exception(
2626
+ "Repository {0} not found in GraphQL response".format(
2627
+ repository["full_name"]
2628
+ )
2629
+ )
2630
+
2631
+ items = repository_data.get(connection) or {}
2632
+ for node in _connection_nodes(items):
2633
+ timeline = node.get("timelineItems") or {}
2634
+ created = [n.get("createdAt") for n in timeline.get("nodes") or [] if n]
2635
+ if use_total_count:
2636
+ count = timeline.get("totalCount", 0)
2637
+ elif len(created) >= CROSS_REFERENCE_NODE_LIMIT:
2638
+ # Counted from the nodes, so it may be truncated. Compare on
2639
+ # the timestamp alone rather than on a count we cannot trust.
2640
+ count = None
2641
+ else:
2642
+ count = len(created)
2643
+ state[node["number"]] = (count, created[-1] if created else None)
2644
+
2645
+ page_info = items.get("pageInfo") or {}
2646
+ if not page_info.get("hasNextPage"):
2647
+ break
2648
+
2649
+ after = page_info.get("endCursor")
2650
+ page += 1
2651
+
2652
+ return state
2653
+
2654
+
2655
+ def retrieve_cross_reference_state(args, repository, include_pulls=False):
2656
+ """Ask GitHub for each item's cross-reference count and newest timestamp."""
2657
+ state = _cross_reference_page(
2658
+ args,
2659
+ repository,
2660
+ ISSUE_CROSS_REFERENCE_COUNT_QUERY,
2661
+ "issues",
2662
+ use_total_count=True,
2663
+ )
2664
+ if include_pulls:
2665
+ state.update(
2666
+ _cross_reference_page(
2667
+ args,
2668
+ repository,
2669
+ PULL_CROSS_REFERENCE_COUNT_QUERY,
2670
+ "pullRequests",
2671
+ use_total_count=False,
2672
+ )
2673
+ )
2674
+ return state
2675
+
2676
+
2677
+ def find_stale_timeline_issues(args, issue_cwd, repository, include_pulls=False):
2678
+ """Issue numbers whose stored cross-references no longer match GitHub's.
2679
+
2680
+ Incremental runs select issues by updated_at, which a cross-reference does
2681
+ not change, so those issues would otherwise never be revisited.
2682
+ """
2683
+ if not get_graphql_auth(args):
2684
+ # The check needs the GraphQL API, which always requires a token.
2685
+ logger.debug("Skipping cross-reference check, no authentication")
2686
+ return set()
2687
+
2688
+ try:
2689
+ live = retrieve_cross_reference_state(args, repository, include_pulls)
2690
+ except Exception as e:
2691
+ logger.warning(
2692
+ "Unable to check {0} for new cross-references, "
2693
+ "issues referenced since the last backup may be missed: {1}".format(
2694
+ repository["full_name"], e
2695
+ )
2696
+ )
2697
+ logger.debug(traceback.format_exc())
2698
+ return set()
2699
+
2700
+ stale = set()
2701
+ for number, (count, newest) in live.items():
2702
+ existing = read_json_file_if_exists("{0}/{1}.json".format(issue_cwd, number))
2703
+ if existing is None:
2704
+ continue
2705
+ stored = cross_reference_state(existing.get("timeline_data"))
2706
+ changed = stored[1] != newest if count is None else stored != (count, newest)
2707
+ if changed:
2708
+ stale.add(number)
2709
+
2710
+ if stale:
2711
+ logger.info(
2712
+ "Refreshing {0} issues with new cross-references".format(len(stale))
2713
+ )
2714
+ logger.debug("Stale timelines: {0}".format(sorted(stale)))
2715
+ return stale
2716
+
2717
+
2572
2718
  def backup_issues(args, repo_cwd, repository, repos_template):
2573
2719
  has_issues_dir = os.path.isdir("{0}/issues/.git".format(repo_cwd))
2574
2720
  if args.skip_existing and has_issues_dir:
@@ -2584,6 +2730,7 @@ def backup_issues(args, repo_cwd, repository, repos_template):
2584
2730
  _issue_template = "{0}/{1}/issues".format(repos_template, repository["full_name"])
2585
2731
 
2586
2732
  should_include_pulls = args.include_pulls or args.include_everything
2733
+ include_timeline = args.include_issue_timeline or args.include_everything
2587
2734
  issue_states = ["open", "closed"]
2588
2735
  for issue_state in issue_states:
2589
2736
  query_args = {"filter": "all", "state": issue_state}
@@ -2600,6 +2747,30 @@ def backup_issues(args, repo_cwd, repository, repos_template):
2600
2747
 
2601
2748
  issues[issue["number"]] = issue
2602
2749
 
2750
+ stale_timelines = set()
2751
+ incremental_run = args.since or args.incremental_by_files
2752
+ if (
2753
+ include_timeline
2754
+ and incremental_run
2755
+ and any(f.endswith(".json") for f in os.listdir(issue_cwd))
2756
+ ):
2757
+ stale_timelines = find_stale_timeline_issues(
2758
+ args, issue_cwd, repository, include_pulls=not should_include_pulls
2759
+ )
2760
+ for number in sorted(stale_timelines - set(issues)):
2761
+ try:
2762
+ issues[number] = retrieve_data(
2763
+ args, "{0}/{1}".format(_issue_template, number), paginated=False
2764
+ )[0]
2765
+ except (HTTPError, RepositoryUnavailableError, IndexError) as e:
2766
+ # Deleted or transferred between the sweep and this fetch. The
2767
+ # refresh is opportunistic, so skip rather than fail the run.
2768
+ logger.warning(
2769
+ "Unable to refresh cross-references for issue {0}: {1}".format(
2770
+ number, e
2771
+ )
2772
+ )
2773
+
2603
2774
  if issues_skipped:
2604
2775
  issues_skipped_message = " (skipped {0} pull requests)".format(issues_skipped)
2605
2776
 
@@ -2610,9 +2781,14 @@ def backup_issues(args, repo_cwd, repository, repos_template):
2610
2781
  )
2611
2782
  comments_template = _issue_template + "/{0}/comments"
2612
2783
  events_template = _issue_template + "/{0}/events"
2784
+ timeline_template = _issue_template + "/{0}/timeline"
2613
2785
  for number, issue in list(issues.items()):
2614
2786
  issue_file = "{0}/{1}.json".format(issue_cwd, number)
2615
- if args.incremental_by_files and os.path.isfile(issue_file):
2787
+ if (
2788
+ args.incremental_by_files
2789
+ and os.path.isfile(issue_file)
2790
+ and number not in stale_timelines
2791
+ ):
2616
2792
  modified = os.path.getmtime(issue_file)
2617
2793
  modified = datetime.fromtimestamp(modified).strftime("%Y-%m-%dT%H:%M:%SZ")
2618
2794
  if modified > issue["updated_at"]:
@@ -2629,6 +2805,16 @@ def backup_issues(args, repo_cwd, repository, repos_template):
2629
2805
  if args.include_issue_events or args.include_everything:
2630
2806
  template = events_template.format(number)
2631
2807
  issues[number]["event_data"] = retrieve_data(args, template)
2808
+ if include_timeline:
2809
+ template = timeline_template.format(number)
2810
+ # "commented" timeline entries embed the full comment body, which
2811
+ # --issue-comments already backs up; storing them twice invites the
2812
+ # two copies to diverge.
2813
+ issues[number]["timeline_data"] = [
2814
+ item
2815
+ for item in retrieve_data(args, template)
2816
+ if item.get("event") != "commented"
2817
+ ]
2632
2818
  if args.include_attachments:
2633
2819
  download_attachments(
2634
2820
  args, issue_cwd, issues[number], number, repository, item_type="issue"
@@ -2,6 +2,66 @@
2
2
 
3
3
  DISCUSSION_PAGE_SIZE = 100
4
4
 
5
+ CROSS_REFERENCE_PAGE_SIZE = 100
6
+
7
+ # Cross-references do not change an issue's updatedAt, so the REST listing
8
+ # cannot report them as changed. Count and newest timestamp are compared
9
+ # against what is on disk: the count alone would miss an add paired with a
10
+ # delete, the timestamp alone would miss a delete.
11
+ ISSUE_CROSS_REFERENCE_COUNT_QUERY = """
12
+ query($owner: String!, $name: String!, $after: String, $pageSize: Int!) {
13
+ repository(owner: $owner, name: $name) {
14
+ issues(first: $pageSize, after: $after) {
15
+ nodes {
16
+ number
17
+ timelineItems(last: 1, itemTypes: [CROSS_REFERENCED_EVENT]) {
18
+ totalCount
19
+ nodes {
20
+ ... on CrossReferencedEvent {
21
+ createdAt
22
+ }
23
+ }
24
+ }
25
+ }
26
+ pageInfo {
27
+ hasNextPage
28
+ endCursor
29
+ }
30
+ }
31
+ }
32
+ }
33
+ """
34
+
35
+ # Pull requests are stored as issues when --pulls is not used, and the issues
36
+ # connection above excludes them. This query is shaped differently because
37
+ # totalCount ignores itemTypes on a pull request, unlike on an issue, so the
38
+ # cross-references have to be counted from the nodes themselves.
39
+ CROSS_REFERENCE_NODE_LIMIT = 100
40
+
41
+ PULL_CROSS_REFERENCE_COUNT_QUERY = """
42
+ query($owner: String!, $name: String!, $after: String, $pageSize: Int!,
43
+ $nodeLimit: Int!) {
44
+ repository(owner: $owner, name: $name) {
45
+ pullRequests(first: $pageSize, after: $after) {
46
+ nodes {
47
+ number
48
+ timelineItems(last: $nodeLimit, itemTypes: [CROSS_REFERENCED_EVENT]) {
49
+ nodes {
50
+ ... on CrossReferencedEvent {
51
+ createdAt
52
+ }
53
+ }
54
+ }
55
+ }
56
+ pageInfo {
57
+ hasNextPage
58
+ endCursor
59
+ }
60
+ }
61
+ }
62
+ }
63
+ """
64
+
5
65
  DISCUSSION_LIST_QUERY = """
6
66
  query($owner: String!, $name: String!, $after: String, $pageSize: Int!) {
7
67
  repository(owner: $owner, name: $name) {
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: github-backup
3
- Version: 0.64.2
3
+ Version: 0.65.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
@@ -71,7 +71,8 @@ CLI Help output::
71
71
  [--incremental-by-files]
72
72
  [--starred] [--all-starred] [--starred-skip-size-over MB]
73
73
  [--watched] [--followers] [--following] [--all]
74
- [--issues] [--issue-comments] [--issue-events] [--pulls]
74
+ [--issues] [--issue-comments] [--issue-events]
75
+ [--issue-timeline] [--pulls]
75
76
  [--pull-comments] [--pull-reviews] [--pull-commits]
76
77
  [--pull-details]
77
78
  [--labels] [--hooks] [--milestones] [--security-advisories]
@@ -126,6 +127,8 @@ CLI Help output::
126
127
  --issues include issues in backup
127
128
  --issue-comments include issue comments in backup
128
129
  --issue-events include issue events in backup
130
+ --issue-timeline include issue timeline in backup (cross-references,
131
+ commits and reviews; complements --issue-events)
129
132
  --pulls include pull requests in backup
130
133
  --pull-comments include pull request review comments in backup
131
134
  --pull-reviews include pull request reviews in backup
@@ -377,6 +380,27 @@ For finer control, avoid using ``--assets`` with starred repos, or use ``--skip-
377
380
 
378
381
  Alternatively, consider just storing links to starred repos in JSON format with ``--starred``.
379
382
 
383
+
384
+ About issue events and the issue timeline
385
+ -----------------------------------------
386
+
387
+ Use ``--issue-events`` and ``--issue-timeline`` with ``--issues``; on their own they back up nothing.
388
+
389
+ ``--issue-events`` backs up GitHub's issue events endpoint under each issue's ``event_data`` key. That endpoint returns a restricted set of event types and **never** includes cross-references, so an issue that was mentioned from another issue or pull request shows no sign of it. GitHub only exposes those through the timeline.
390
+
391
+ ``--issue-timeline`` backs up the timeline endpoint under a separate ``timeline_data`` key. The two flags are independent: ``event_data`` keeps its existing contents and shape whether or not the timeline is enabled, so nothing that already reads it is affected.
392
+
393
+ Use both flags together for a complete picture. Neither endpoint is a superset of the other, so they are complementary rather than alternatives. Only the timeline reports ``cross-referenced``, ``committed`` and ``reviewed`` entries. Only the events endpoint reliably reports ``referenced`` entries, where a commit elsewhere in the repository mentions the issue; the timeline omits many of these, substituting the issue's own ``committed`` entries instead.
394
+
395
+ Note that timeline entries are not all the same shape as events. ``cross-referenced`` and ``committed`` entries have no ``id`` field, so anything consuming ``timeline_data`` should not assume one is present. ``commented`` entries are excluded from the backup because they embed the full comment body, which ``--issue-comments`` already saves.
396
+
397
+ ``--issue-timeline`` is included in ``--all``. On issues with many cross-references it can be noticeably larger and slower than ``--issue-events`` alone, because each ``cross-referenced`` entry embeds the referencing issue in full. On a heavily cross-referenced issue this can mean several times the API requests and many times the JSON on disk. Ordinary repositories see little difference.
398
+
399
+ Incremental backups need extra help here, because being referenced from elsewhere does not change an issue's ``updated_at``. An issue can be mentioned from another repository years after its last activity, and the usual ``since`` listing will never report it as changed. On an incremental run with ``--issue-timeline``, github-backup therefore asks GitHub's GraphQL API for each item's cross-reference count and newest timestamp, compares those against what is already saved, and re-fetches the timeline of anything that differs. Pull requests are included in that check when they are being stored as issues, which is the case unless ``--pulls`` is used. The check needs no extra state on disk and also notices references that were later deleted. It costs one rate-limit point per 100 items checked, against GitHub's GraphQL quota, which is separate from the REST one. That is cheap in quota terms even on very large repositories, but it is not instant: a repository with 18,000 issues and pull requests took around 200 points and five minutes for the check alone.
400
+
401
+ If you enable ``--issue-timeline`` on an existing incremental backup, cross-references on older issues are picked up automatically by that check. Timeline entries of other kinds, such as commits and reviews, are only fetched when the issue itself is updated, so run once without ``--incremental`` if you want those filled in for historical issues too.
402
+
403
+
380
404
  About pull request reviews
381
405
  --------------------------
382
406
 
@@ -480,14 +504,14 @@ Quietly and incrementally backup useful Github user data (public and private rep
480
504
  FINE_ACCESS_TOKEN=SOME-GITHUB-TOKEN
481
505
  GH_USER=YOUR-GITHUB-USER
482
506
 
483
- 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-reviews --pull-commits --labels --milestones --security-advisories --discussions --repositories --wikis --releases --assets --attachments --pull-details --gists --starred-gists $GH_USER
507
+ 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 --issue-timeline --pulls --pull-comments --pull-reviews --pull-commits --labels --milestones --security-advisories --discussions --repositories --wikis --releases --assets --attachments --pull-details --gists --starred-gists $GH_USER
484
508
 
485
509
  Debug an error/block or incomplete backup into a temporary directory. Omit "incremental" to fill a previous incomplete backup. ::
486
510
 
487
511
  FINE_ACCESS_TOKEN=SOME-GITHUB-TOKEN
488
512
  GH_USER=YOUR-GITHUB-USER
489
513
 
490
- 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-reviews --pull-commits --labels --milestones --discussions --repositories --wikis --releases --assets --pull-details --gists --starred-gists $GH_USER
514
+ github-backup -f $FINE_ACCESS_TOKEN -o /tmp/github-backup/ -l debug -P --all-starred --starred --watched --followers --following --issues --issue-comments --issue-events --issue-timeline --pulls --pull-comments --pull-reviews --pull-commits --labels --milestones --discussions --repositories --wikis --releases --assets --pull-details --gists --starred-gists $GH_USER
491
515
 
492
516
  Pipe a token from stdin to avoid storing it in environment variables or command history (Unix-like systems only)::
493
517
 
@@ -26,6 +26,7 @@ tests/test_discussions.py
26
26
  tests/test_gists.py
27
27
  tests/test_http_451.py
28
28
  tests/test_incremental_per_repository.py
29
+ tests/test_issue_timeline.py
29
30
  tests/test_json_dump_if_changed.py
30
31
  tests/test_logging_subprocess.py
31
32
  tests/test_pagination.py
@@ -0,0 +1,434 @@
1
+ """Tests for issue timeline backup (GitHub issue #168).
2
+
3
+ The ``/issues/{number}/events`` endpoint never returns cross-reference events
4
+ ("mentioned in #22"); GitHub only exposes those via ``/issues/{number}/timeline``.
5
+ ``--issue-timeline`` backs up the timeline alongside the events, so those
6
+ references are captured.
7
+ """
8
+
9
+ import json
10
+ import os
11
+ import time
12
+
13
+ from github_backup import github_backup
14
+
15
+
16
+ def _issue(number=1, **extra):
17
+ issue = {
18
+ "number": number,
19
+ "title": "an issue",
20
+ "updated_at": "2026-07-01T00:00:00Z",
21
+ }
22
+ issue.update(extra)
23
+ return issue
24
+
25
+
26
+ def _fake_retrieve(responses, calls):
27
+ """Return a retrieve_data stub serving ``responses`` keyed by URL suffix."""
28
+
29
+ def _retrieve(args, template, query_args=None, **kwargs):
30
+ calls.append(template)
31
+ for suffix, payload in responses.items():
32
+ if template.endswith(suffix):
33
+ return payload
34
+ return []
35
+
36
+ return _retrieve
37
+
38
+
39
+ def _run(create_args, tmp_path, monkeypatch, responses, **arg_overrides):
40
+ args = create_args(include_issues=True, since=None, **arg_overrides)
41
+ calls = []
42
+ monkeypatch.setattr(
43
+ github_backup, "retrieve_data", _fake_retrieve(responses, calls)
44
+ )
45
+ github_backup.backup_issues(
46
+ args, str(tmp_path), {"full_name": "owner/repo"}, "https://api.github.com/repos"
47
+ )
48
+ issue_file = tmp_path / "issues" / "1.json"
49
+ assert issue_file.is_file(), "issue was not written to disk"
50
+ return json.loads(issue_file.read_text()), calls
51
+
52
+
53
+ def test_parse_args_issue_timeline_flag():
54
+ # The create_args fixture sets attributes directly, so only this test
55
+ # catches the flag being wired to the wrong dest.
56
+ args = github_backup.parse_args(["--issue-timeline", "testuser"])
57
+ assert args.include_issue_timeline is True
58
+
59
+
60
+ TIMELINE = [
61
+ {"id": 1, "event": "labeled", "label": {"name": "bug"}},
62
+ {"event": "cross-referenced", "source": {"type": "issue", "issue": {"number": 22}}},
63
+ {"id": 2, "event": "commented", "body": "a comment body"},
64
+ {"event": "committed", "sha": "abc123"},
65
+ ]
66
+
67
+
68
+ def test_comments_filtered_and_everything_else_stored_verbatim(
69
+ create_args, tmp_path, monkeypatch
70
+ ):
71
+ saved, calls = _run(
72
+ create_args,
73
+ tmp_path,
74
+ monkeypatch,
75
+ {"/issues": [_issue()], "/1/timeline": TIMELINE},
76
+ include_issue_timeline=True,
77
+ )
78
+
79
+ assert any(c.endswith("/1/timeline") for c in calls)
80
+ # "commented" is dropped because --issue-comments already covers it.
81
+ # Everything else is stored exactly as GitHub returned it, including the
82
+ # fat source object on cross-references.
83
+ assert saved["timeline_data"] == [TIMELINE[0], TIMELINE[1], TIMELINE[3]]
84
+
85
+
86
+ def test_timeline_not_fetched_without_flag(create_args, tmp_path, monkeypatch):
87
+ saved, calls = _run(
88
+ create_args,
89
+ tmp_path,
90
+ monkeypatch,
91
+ {"/issues": [_issue()], "/1/timeline": TIMELINE},
92
+ )
93
+
94
+ assert not any(c.endswith("/1/timeline") for c in calls)
95
+ assert "timeline_data" not in saved
96
+
97
+
98
+ def test_events_unchanged_by_timeline_flag(create_args, tmp_path, monkeypatch):
99
+ """--issue-events keeps reading /events, so event_data's shape is untouched."""
100
+ events = [{"id": 1, "event": "labeled", "label": {"name": "bug"}}]
101
+ saved, calls = _run(
102
+ create_args,
103
+ tmp_path,
104
+ monkeypatch,
105
+ {"/issues": [_issue()], "/1/events": events, "/1/timeline": TIMELINE},
106
+ include_issue_events=True,
107
+ include_issue_timeline=True,
108
+ )
109
+
110
+ assert any(c.endswith("/1/events") for c in calls)
111
+ assert saved["event_data"] == events
112
+ assert len(saved["timeline_data"]) == 3
113
+
114
+
115
+ def test_timeline_included_in_all(create_args, tmp_path, monkeypatch):
116
+ saved, calls = _run(
117
+ create_args,
118
+ tmp_path,
119
+ monkeypatch,
120
+ {"/issues": [_issue()], "/1/timeline": TIMELINE},
121
+ include_everything=True,
122
+ )
123
+
124
+ assert any(c.endswith("/1/timeline") for c in calls)
125
+ assert len(saved["timeline_data"]) == 3
126
+
127
+
128
+ def _issue_node(number, count, newest):
129
+ """An issues-connection node: totalCount honours the itemTypes filter."""
130
+ return {
131
+ "number": number,
132
+ "timelineItems": {
133
+ "totalCount": count,
134
+ "nodes": [{"createdAt": newest}] if newest else [],
135
+ },
136
+ }
137
+
138
+
139
+ def _pull_node(number, stamps):
140
+ """A pullRequests-connection node.
141
+
142
+ GitHub's totalCount ignores itemTypes here, so it is deliberately wrong:
143
+ anything reading it instead of counting the nodes will fail these tests.
144
+ """
145
+ return {
146
+ "number": number,
147
+ "timelineItems": {
148
+ "totalCount": 999,
149
+ "nodes": [{"createdAt": s} for s in stamps],
150
+ },
151
+ }
152
+
153
+
154
+ def _graphql_pages(issue_pages, pull_pages=None):
155
+ """Stub retrieve_graphql_data serving one or more pages per connection."""
156
+ pages = {"issues": issue_pages, "pullRequests": pull_pages or [[]]}
157
+ seen = []
158
+
159
+ def _retrieve(args, query, variables=None, log_context=None):
160
+ connection = "pullRequests" if "pullRequests" in query else "issues"
161
+ cursor = (variables or {}).get("after")
162
+ index = 0 if cursor is None else int(cursor)
163
+ seen.append((connection, cursor))
164
+ nodes = pages[connection][index]
165
+ has_next = index + 1 < len(pages[connection])
166
+ return {
167
+ "repository": {
168
+ connection: {
169
+ "nodes": nodes,
170
+ "pageInfo": {
171
+ "hasNextPage": has_next,
172
+ "endCursor": str(index + 1) if has_next else None,
173
+ },
174
+ }
175
+ }
176
+ }
177
+
178
+ _retrieve.seen = seen
179
+ return _retrieve
180
+
181
+
182
+ def _graphql_state(counts):
183
+ """Single-page issues-only stub, keyed {number: (count, newest)}."""
184
+ return _graphql_pages(
185
+ [[_issue_node(n, c, newest) for n, (c, newest) in counts.items()]]
186
+ )
187
+
188
+
189
+ def _sweep_args(create_args, **overrides):
190
+ """Args for the cross-reference sweep, which needs a token to reach GraphQL."""
191
+ return create_args(token_classic="faketoken", **overrides)
192
+
193
+
194
+ def _stored(tmp_path, number, timeline_data):
195
+ issues = tmp_path / "issues"
196
+ issues.mkdir(parents=True, exist_ok=True)
197
+ (issues / "{0}.json".format(number)).write_text(
198
+ json.dumps({"number": number, "timeline_data": timeline_data})
199
+ )
200
+ return str(issues)
201
+
202
+
203
+ def _xref(created_at):
204
+ return {"event": "cross-referenced", "created_at": created_at}
205
+
206
+
207
+ def test_stale_when_a_cross_reference_is_added(create_args, tmp_path, monkeypatch):
208
+ issue_cwd = _stored(tmp_path, 1, [_xref("2026-01-01T00:00:00Z")])
209
+ monkeypatch.setattr(
210
+ github_backup,
211
+ "retrieve_graphql_data",
212
+ _graphql_state({1: (2, "2026-07-01T00:00:00Z")}),
213
+ )
214
+
215
+ stale = github_backup.find_stale_timeline_issues(
216
+ _sweep_args(create_args), issue_cwd, {"full_name": "owner/repo"}
217
+ )
218
+
219
+ assert stale == {1}
220
+
221
+
222
+ def test_stale_when_one_added_and_one_deleted(create_args, tmp_path, monkeypatch):
223
+ """The count is unchanged, so only the newest timestamp reveals the change."""
224
+ issue_cwd = _stored(
225
+ tmp_path, 1, [_xref("2026-01-01T00:00:00Z"), _xref("2026-02-01T00:00:00Z")]
226
+ )
227
+ monkeypatch.setattr(
228
+ github_backup,
229
+ "retrieve_graphql_data",
230
+ _graphql_state({1: (2, "2026-07-01T00:00:00Z")}),
231
+ )
232
+
233
+ stale = github_backup.find_stale_timeline_issues(
234
+ _sweep_args(create_args), issue_cwd, {"full_name": "owner/repo"}
235
+ )
236
+
237
+ assert stale == {1}
238
+
239
+
240
+ def test_not_stale_when_unchanged(create_args, tmp_path, monkeypatch):
241
+ issue_cwd = _stored(tmp_path, 1, [_xref("2026-01-01T00:00:00Z")])
242
+ monkeypatch.setattr(
243
+ github_backup,
244
+ "retrieve_graphql_data",
245
+ _graphql_state({1: (1, "2026-01-01T00:00:00Z")}),
246
+ )
247
+
248
+ stale = github_backup.find_stale_timeline_issues(
249
+ _sweep_args(create_args), issue_cwd, {"full_name": "owner/repo"}
250
+ )
251
+
252
+ assert stale == set()
253
+
254
+
255
+ def test_graphql_failure_warns_and_continues(
256
+ create_args, tmp_path, monkeypatch, caplog
257
+ ):
258
+ """A backup must not abort because the cross-reference check failed."""
259
+ issue_cwd = _stored(tmp_path, 1, [])
260
+
261
+ def _boom(*a, **kw):
262
+ raise Exception("GraphQL unavailable")
263
+
264
+ monkeypatch.setattr(github_backup, "retrieve_graphql_data", _boom)
265
+
266
+ stale = github_backup.find_stale_timeline_issues(
267
+ _sweep_args(create_args), issue_cwd, {"full_name": "owner/repo"}
268
+ )
269
+
270
+ assert stale == set()
271
+ assert "may be missed" in caplog.text
272
+
273
+
274
+ def test_stale_issue_backed_up_though_since_excludes_it(
275
+ create_args, tmp_path, monkeypatch
276
+ ):
277
+ """The whole point: an issue since cannot see is still refreshed."""
278
+ _stored(tmp_path, 7, [])
279
+ args = _sweep_args(
280
+ create_args,
281
+ include_issues=True,
282
+ include_issue_timeline=True,
283
+ since="2026-07-01T00:00:00Z",
284
+ )
285
+ timeline = [_xref("2026-07-20T00:00:00Z")]
286
+ calls = []
287
+ monkeypatch.setattr(
288
+ github_backup,
289
+ "retrieve_data",
290
+ _fake_retrieve(
291
+ {"/issues": [], "/issues/7": [_issue(7)], "/7/timeline": timeline}, calls
292
+ ),
293
+ )
294
+ monkeypatch.setattr(
295
+ github_backup,
296
+ "retrieve_graphql_data",
297
+ _graphql_state({7: (1, "2026-07-20T00:00:00Z")}),
298
+ )
299
+
300
+ github_backup.backup_issues(
301
+ args, str(tmp_path), {"full_name": "owner/repo"}, "https://api.github.com/repos"
302
+ )
303
+
304
+ saved = json.loads((tmp_path / "issues" / "7.json").read_text())
305
+ assert saved["timeline_data"] == timeline
306
+
307
+
308
+ def test_sweep_follows_pagination_cursors(create_args, tmp_path, monkeypatch):
309
+ """A cursor bug would silently truncate the sweep and strand later issues."""
310
+ issue_cwd = _stored(tmp_path, 1, [])
311
+ _stored(tmp_path, 2, [])
312
+ _stored(tmp_path, 3, [])
313
+ fake = _graphql_pages(
314
+ [
315
+ [_issue_node(1, 0, None)],
316
+ [_issue_node(2, 0, None)],
317
+ [_issue_node(3, 1, "2026-07-01T00:00:00Z")],
318
+ ]
319
+ )
320
+ monkeypatch.setattr(github_backup, "retrieve_graphql_data", fake)
321
+
322
+ stale = github_backup.find_stale_timeline_issues(
323
+ _sweep_args(create_args), issue_cwd, {"full_name": "owner/repo"}
324
+ )
325
+
326
+ # Issue 3 is only reachable by following two cursors.
327
+ assert stale == {3}
328
+ assert [c for _, c in fake.seen] == [None, "1", "2"]
329
+
330
+
331
+ def test_pull_requests_are_counted_from_nodes_not_total_count(
332
+ create_args, tmp_path, monkeypatch
333
+ ):
334
+ """totalCount ignores the itemTypes filter on pull requests, so it lies."""
335
+ issue_cwd = _stored(tmp_path, 5, [_xref("2026-01-01T00:00:00Z")])
336
+ monkeypatch.setattr(
337
+ github_backup,
338
+ "retrieve_graphql_data",
339
+ _graphql_pages([[]], [[_pull_node(5, ["2026-01-01T00:00:00Z"])]]),
340
+ )
341
+
342
+ stale = github_backup.find_stale_timeline_issues(
343
+ _sweep_args(create_args),
344
+ issue_cwd,
345
+ {"full_name": "owner/repo"},
346
+ include_pulls=True,
347
+ )
348
+
349
+ # Stored state matches, so nothing to do. Reading totalCount (999) instead
350
+ # of counting the one node would flag this pull request on every run.
351
+ assert stale == set()
352
+
353
+
354
+ def test_pull_requests_not_swept_when_backed_up_separately(
355
+ create_args, tmp_path, monkeypatch
356
+ ):
357
+ issue_cwd = _stored(tmp_path, 5, [])
358
+ fake = _graphql_pages([[]], [[_pull_node(5, ["2026-07-01T00:00:00Z"])]])
359
+ monkeypatch.setattr(github_backup, "retrieve_graphql_data", fake)
360
+
361
+ stale = github_backup.find_stale_timeline_issues(
362
+ _sweep_args(create_args),
363
+ issue_cwd,
364
+ {"full_name": "owner/repo"},
365
+ include_pulls=False,
366
+ )
367
+
368
+ assert stale == set()
369
+ assert [connection for connection, _ in fake.seen] == ["issues"]
370
+
371
+
372
+ def test_truncated_node_count_compares_on_timestamp_only(
373
+ create_args, tmp_path, monkeypatch
374
+ ):
375
+ """At the node limit the count is unreliable; comparing it loops forever."""
376
+ limit = github_backup.CROSS_REFERENCE_NODE_LIMIT
377
+ stamps = [
378
+ "2026-01-01T{0:02d}:{1:02d}:00Z".format(i // 60, i % 60) for i in range(limit)
379
+ ]
380
+ # Stored has more cross-references than a single page can return.
381
+ stored = [_xref(s) for s in stamps] + [_xref("2025-12-01T00:00:00Z")]
382
+ issue_cwd = _stored(tmp_path, 5, stored)
383
+ monkeypatch.setattr(
384
+ github_backup,
385
+ "retrieve_graphql_data",
386
+ _graphql_pages([[]], [[_pull_node(5, stamps)]]),
387
+ )
388
+
389
+ stale = github_backup.find_stale_timeline_issues(
390
+ _sweep_args(create_args),
391
+ issue_cwd,
392
+ {"full_name": "owner/repo"},
393
+ include_pulls=True,
394
+ )
395
+
396
+ # Counts differ (101 stored vs 100 returned) but the newest matches, so
397
+ # this must not be reported stale on every run.
398
+ assert stale == set()
399
+
400
+
401
+ def test_incremental_by_files_still_refreshes_cross_referenced_issues(
402
+ create_args, tmp_path, monkeypatch
403
+ ):
404
+ """--incremental-by-files sets no since, and skips by mtime, so the
405
+ cross-reference sweep is the only thing that can catch these."""
406
+ _stored(tmp_path, 3, [])
407
+ issue_file = tmp_path / "issues" / "3.json"
408
+ os.utime(issue_file, (0, time.time())) # mtime newer than updated_at
409
+
410
+ args = _sweep_args(
411
+ create_args,
412
+ include_issues=True,
413
+ include_issue_timeline=True,
414
+ incremental_by_files=True,
415
+ since=None,
416
+ )
417
+ timeline = [_xref("2026-07-20T00:00:00Z")]
418
+ monkeypatch.setattr(
419
+ github_backup,
420
+ "retrieve_data",
421
+ _fake_retrieve({"/issues": [_issue(3)], "/3/timeline": timeline}, []),
422
+ )
423
+ monkeypatch.setattr(
424
+ github_backup,
425
+ "retrieve_graphql_data",
426
+ _graphql_pages([[_issue_node(3, 1, "2026-07-20T00:00:00Z")]]),
427
+ )
428
+
429
+ github_backup.backup_issues(
430
+ args, str(tmp_path), {"full_name": "owner/repo"}, "https://api.github.com/repos"
431
+ )
432
+
433
+ saved = json.loads(issue_file.read_text())
434
+ assert saved["timeline_data"] == timeline
@@ -1 +0,0 @@
1
- __version__ = "0.64.2"
File without changes
File without changes