Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
42 changes: 26 additions & 16 deletions github_backup/github_backup.py
Original file line number Diff line number Diff line change
Expand Up @@ -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)
Expand All @@ -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)
Expand All @@ -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:
Expand All @@ -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"
)
Expand Down
37 changes: 31 additions & 6 deletions tests/test_issue_timeline.py
Original file line number Diff line number Diff line change
Expand Up @@ -274,35 +274,60 @@ 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")]
calls = []
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):
Expand Down