diff --git a/github_backup/github_backup.py b/github_backup/github_backup.py index 0705497..9a2b7d4 100644 --- a/github_backup/github_backup.py +++ b/github_backup/github_backup.py @@ -2757,19 +2757,24 @@ def backup_issues(args, repo_cwd, repository, repos_template): stale_timelines = find_stale_timeline_issues( args, issue_cwd, repository, include_pulls=not should_include_pulls ) - for number in sorted(stale_timelines - set(issues)): - try: - issues[number] = retrieve_data( - args, "{0}/{1}".format(_issue_template, number), paginated=False - )[0] - except (HTTPError, RepositoryUnavailableError, IndexError) as e: - # Deleted or transferred between the sweep and this fetch. The - # refresh is opportunistic, so skip rather than fail the run. - logger.warning( - "Unable to refresh cross-references for issue {0}: {1}".format( - number, e - ) - ) + + # Issues selected only by the cross-reference sweep have not otherwise + # changed. Reuse their complete stored payload and refresh only timeline_data. + # Fetching the issue, comments, events and attachments again turns the first + # --issue-timeline run into a near-full backup and can discard optional data + # when the corresponding flags are absent. + timeline_only_issues = stale_timelines - set(issues) + for number in sorted(timeline_only_issues): + existing = read_json_file_if_exists( + "{0}/{1}.json".format(issue_cwd, number) + ) + if existing is None: + logger.warning( + "Unable to refresh cross-references for issue {0}: " + "stored issue data is unreadable".format(number) + ) + continue + issues[number] = existing if issues_skipped: issues_skipped_message = " (skipped {0} pull requests)".format(issues_skipped) @@ -2784,6 +2789,7 @@ def backup_issues(args, repo_cwd, repository, repos_template): timeline_template = _issue_template + "/{0}/timeline" for number, issue in list(issues.items()): issue_file = "{0}/{1}.json".format(issue_cwd, number) + timeline_only_refresh = number in timeline_only_issues if ( args.incremental_by_files and os.path.isfile(issue_file) @@ -2799,10 +2805,14 @@ def backup_issues(args, repo_cwd, repository, repos_template): ) continue - if args.include_issue_comments or args.include_everything: + if not timeline_only_refresh and ( + args.include_issue_comments or args.include_everything + ): template = comments_template.format(number) issues[number]["comment_data"] = retrieve_data(args, template) - if args.include_issue_events or args.include_everything: + if not timeline_only_refresh and ( + args.include_issue_events or args.include_everything + ): template = events_template.format(number) issues[number]["event_data"] = retrieve_data(args, template) if include_timeline: @@ -2815,7 +2825,7 @@ def backup_issues(args, repo_cwd, repository, repos_template): for item in retrieve_data(args, template) if item.get("event") != "commented" ] - if args.include_attachments: + if args.include_attachments and not timeline_only_refresh: download_attachments( args, issue_cwd, issues[number], number, repository, item_type="issue" ) diff --git a/tests/test_issue_timeline.py b/tests/test_issue_timeline.py index f552314..2653aea 100644 --- a/tests/test_issue_timeline.py +++ b/tests/test_issue_timeline.py @@ -274,12 +274,24 @@ def _boom(*a, **kw): def test_stale_issue_backed_up_though_since_excludes_it( create_args, tmp_path, monkeypatch ): - """The whole point: an issue since cannot see is still refreshed.""" - _stored(tmp_path, 7, []) + """A sweep-only issue refreshes just its timeline and preserves its data.""" + issue_cwd = tmp_path / "issues" + issue_cwd.mkdir() + stored = _issue( + 7, + body="stored body", + comment_data=[{"id": 1, "body": "stored comment"}], + event_data=[{"id": 2, "event": "referenced"}], + timeline_data=[], + ) + (issue_cwd / "7.json").write_text(json.dumps(stored)) args = _sweep_args( create_args, include_issues=True, + include_issue_comments=True, + include_issue_events=True, include_issue_timeline=True, + include_attachments=True, since="2026-07-01T00:00:00Z", ) timeline = [_xref("2026-07-20T00:00:00Z")] @@ -287,22 +299,35 @@ def test_stale_issue_backed_up_though_since_excludes_it( monkeypatch.setattr( github_backup, "retrieve_data", - _fake_retrieve( - {"/issues": [], "/issues/7": [_issue(7)], "/7/timeline": timeline}, calls - ), + _fake_retrieve({"/issues": [], "/7/timeline": timeline}, calls), ) monkeypatch.setattr( github_backup, "retrieve_graphql_data", _graphql_state({7: (1, "2026-07-20T00:00:00Z")}), ) + attachment_calls = [] + monkeypatch.setattr( + github_backup, + "download_attachments", + lambda *args, **kwargs: attachment_calls.append((args, kwargs)), + ) github_backup.backup_issues( args, str(tmp_path), {"full_name": "owner/repo"}, "https://api.github.com/repos" ) - saved = json.loads((tmp_path / "issues" / "7.json").read_text()) + saved = json.loads((issue_cwd / "7.json").read_text()) assert saved["timeline_data"] == timeline + assert saved["body"] == "stored body" + assert saved["comment_data"] == stored["comment_data"] + assert saved["event_data"] == stored["event_data"] + assert calls == [ + "https://api.github.com/repos/owner/repo/issues", + "https://api.github.com/repos/owner/repo/issues", + "https://api.github.com/repos/owner/repo/issues/7/timeline", + ] + assert attachment_calls == [] def test_sweep_follows_pagination_cursors(create_args, tmp_path, monkeypatch):