Skip to content

Commit b29e948

Browse files
committed
Add --issue-timeline flag to capture cross-reference events (#168)
The issue events endpoint never returns cross-references, so an issue mentioned from another issue or pull request leaves no trace in the backup. GitHub only exposes those through the timeline endpoint. Full backups miss them as well as incremental ones. Adds --issue-timeline, which stores the timeline under a separate timeline_data key alongside the unmodified event_data. The two endpoints are complementary rather than nested: the timeline adds cross-references, commits and reviews, while events carries referenced entries the timeline drops, so neither replaces the other. Timeline entries for comments are filtered out, since --issue-comments already saves the bodies. Everything else is stored verbatim. The flag is included in --all. This makes the data obtainable, but does not change incremental selection: a cross-reference does not bump the referenced issue's updated_at, so an incremental run still never lists it. That half of the issue remains open. References #168
1 parent b1358ee commit b29e948

3 files changed

Lines changed: 167 additions & 3 deletions

File tree

README.rst

Lines changed: 27 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -41,7 +41,8 @@ CLI Help output::
4141
[--incremental-by-files]
4242
[--starred] [--all-starred] [--starred-skip-size-over MB]
4343
[--watched] [--followers] [--following] [--all]
44-
[--issues] [--issue-comments] [--issue-events] [--pulls]
44+
[--issues] [--issue-comments] [--issue-events]
45+
[--issue-timeline] [--pulls]
4546
[--pull-comments] [--pull-reviews] [--pull-commits]
4647
[--pull-details]
4748
[--labels] [--hooks] [--milestones] [--security-advisories]
@@ -96,6 +97,8 @@ CLI Help output::
9697
--issues include issues in backup
9798
--issue-comments include issue comments in backup
9899
--issue-events include issue events in backup
100+
--issue-timeline include issue timeline in backup (cross-references,
101+
commits and reviews; complements --issue-events)
99102
--pulls include pull requests in backup
100103
--pull-comments include pull request review comments in backup
101104
--pull-reviews include pull request reviews in backup
@@ -347,6 +350,27 @@ For finer control, avoid using ``--assets`` with starred repos, or use ``--skip-
347350

348351
Alternatively, consider just storing links to starred repos in JSON format with ``--starred``.
349352

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+
If you enable ``--issue-timeline`` on an existing incremental backup, run once without ``--incremental`` to backfill it. There is no automatic backfill, so an incremental run only revisits recently updated issues and every older issue keeps a JSON file with no ``timeline_data`` until something touches it. The same applies to ``--incremental-by-files``.
370+
371+
Incremental backups select issues by ``updated_at``, and a cross-reference does not change the referenced issue's ``updated_at``. An issue whose only new activity is being referenced elsewhere is therefore not revisited on that run. It corrects itself the next time the issue is touched, since each issue's timeline is fetched in full, so the only lasting gap is an issue that is referenced and then never touched again. Run occasionally without ``--incremental`` if that matters to you.
372+
373+
350374
About pull request reviews
351375
--------------------------
352376

@@ -450,14 +474,14 @@ Quietly and incrementally backup useful Github user data (public and private rep
450474
FINE_ACCESS_TOKEN=SOME-GITHUB-TOKEN
451475
GH_USER=YOUR-GITHUB-USER
452476

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
454478

455479
Debug an error/block or incomplete backup into a temporary directory. Omit "incremental" to fill a previous incomplete backup. ::
456480

457481
FINE_ACCESS_TOKEN=SOME-GITHUB-TOKEN
458482
GH_USER=YOUR-GITHUB-USER
459483

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
461485

462486
Pipe a token from stdin to avoid storing it in environment variables or command history (Unix-like systems only)::
463487

github_backup/github_backup.py

Lines changed: 17 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -278,6 +278,12 @@ def parse_args(args=None):
278278
dest="include_issue_events",
279279
help="include issue events in backup",
280280
)
281+
parser.add_argument(
282+
"--issue-timeline",
283+
action="store_true",
284+
dest="include_issue_timeline",
285+
help="include issue timeline in backup (cross-references, commits and reviews; complements --issue-events)",
286+
)
281287
parser.add_argument(
282288
"--pulls",
283289
action="store_true",
@@ -2610,6 +2616,7 @@ def backup_issues(args, repo_cwd, repository, repos_template):
26102616
)
26112617
comments_template = _issue_template + "/{0}/comments"
26122618
events_template = _issue_template + "/{0}/events"
2619+
timeline_template = _issue_template + "/{0}/timeline"
26132620
for number, issue in list(issues.items()):
26142621
issue_file = "{0}/{1}.json".format(issue_cwd, number)
26152622
if args.incremental_by_files and os.path.isfile(issue_file):
@@ -2629,6 +2636,16 @@ def backup_issues(args, repo_cwd, repository, repos_template):
26292636
if args.include_issue_events or args.include_everything:
26302637
template = events_template.format(number)
26312638
issues[number]["event_data"] = retrieve_data(args, template)
2639+
if args.include_issue_timeline or args.include_everything:
2640+
template = timeline_template.format(number)
2641+
# "commented" timeline entries embed the full comment body, which
2642+
# --issue-comments already backs up; storing them twice invites the
2643+
# two copies to diverge.
2644+
issues[number]["timeline_data"] = [
2645+
item
2646+
for item in retrieve_data(args, template)
2647+
if item.get("event") != "commented"
2648+
]
26322649
if args.include_attachments:
26332650
download_attachments(
26342651
args, issue_cwd, issues[number], number, repository, item_type="issue"

tests/test_issue_timeline.py

Lines changed: 123 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,123 @@
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+
11+
from github_backup import github_backup
12+
13+
14+
def _issue(number=1, **extra):
15+
issue = {
16+
"number": number,
17+
"title": "an issue",
18+
"updated_at": "2026-07-01T00:00:00Z",
19+
}
20+
issue.update(extra)
21+
return issue
22+
23+
24+
def _fake_retrieve(responses, calls):
25+
"""Return a retrieve_data stub serving ``responses`` keyed by URL suffix."""
26+
27+
def _retrieve(args, template, query_args=None, **kwargs):
28+
calls.append(template)
29+
for suffix, payload in responses.items():
30+
if template.endswith(suffix):
31+
return payload
32+
return []
33+
34+
return _retrieve
35+
36+
37+
def _run(create_args, tmp_path, monkeypatch, responses, **arg_overrides):
38+
args = create_args(include_issues=True, since=None, **arg_overrides)
39+
calls = []
40+
monkeypatch.setattr(
41+
github_backup, "retrieve_data", _fake_retrieve(responses, calls)
42+
)
43+
github_backup.backup_issues(
44+
args, str(tmp_path), {"full_name": "owner/repo"}, "https://api.github.com/repos"
45+
)
46+
issue_file = tmp_path / "issues" / "1.json"
47+
assert issue_file.is_file(), "issue was not written to disk"
48+
return json.loads(issue_file.read_text()), calls
49+
50+
51+
def test_parse_args_issue_timeline_flag():
52+
# The create_args fixture sets attributes directly, so only this test
53+
# catches the flag being wired to the wrong dest.
54+
args = github_backup.parse_args(["--issue-timeline", "testuser"])
55+
assert args.include_issue_timeline is True
56+
57+
58+
TIMELINE = [
59+
{"id": 1, "event": "labeled", "label": {"name": "bug"}},
60+
{"event": "cross-referenced", "source": {"type": "issue", "issue": {"number": 22}}},
61+
{"id": 2, "event": "commented", "body": "a comment body"},
62+
{"event": "committed", "sha": "abc123"},
63+
]
64+
65+
66+
def test_comments_filtered_and_everything_else_stored_verbatim(
67+
create_args, tmp_path, monkeypatch
68+
):
69+
saved, calls = _run(
70+
create_args,
71+
tmp_path,
72+
monkeypatch,
73+
{"/issues": [_issue()], "/1/timeline": TIMELINE},
74+
include_issue_timeline=True,
75+
)
76+
77+
assert any(c.endswith("/1/timeline") for c in calls)
78+
# "commented" is dropped because --issue-comments already covers it.
79+
# Everything else is stored exactly as GitHub returned it, including the
80+
# fat source object on cross-references.
81+
assert saved["timeline_data"] == [TIMELINE[0], TIMELINE[1], TIMELINE[3]]
82+
83+
84+
def test_timeline_not_fetched_without_flag(create_args, tmp_path, monkeypatch):
85+
saved, calls = _run(
86+
create_args,
87+
tmp_path,
88+
monkeypatch,
89+
{"/issues": [_issue()], "/1/timeline": TIMELINE},
90+
)
91+
92+
assert not any(c.endswith("/1/timeline") for c in calls)
93+
assert "timeline_data" not in saved
94+
95+
96+
def test_events_unchanged_by_timeline_flag(create_args, tmp_path, monkeypatch):
97+
"""--issue-events keeps reading /events, so event_data's shape is untouched."""
98+
events = [{"id": 1, "event": "labeled", "label": {"name": "bug"}}]
99+
saved, calls = _run(
100+
create_args,
101+
tmp_path,
102+
monkeypatch,
103+
{"/issues": [_issue()], "/1/events": events, "/1/timeline": TIMELINE},
104+
include_issue_events=True,
105+
include_issue_timeline=True,
106+
)
107+
108+
assert any(c.endswith("/1/events") for c in calls)
109+
assert saved["event_data"] == events
110+
assert len(saved["timeline_data"]) == 3
111+
112+
113+
def test_timeline_included_in_all(create_args, tmp_path, monkeypatch):
114+
saved, calls = _run(
115+
create_args,
116+
tmp_path,
117+
monkeypatch,
118+
{"/issues": [_issue()], "/1/timeline": TIMELINE},
119+
include_everything=True,
120+
)
121+
122+
assert any(c.endswith("/1/timeline") for c in calls)
123+
assert len(saved["timeline_data"]) == 3

0 commit comments

Comments
 (0)