Skip to content

Commit 28e8ce9

Browse files
committed
Refresh issue timelines when cross-references change (#168)
Cross-references do not change an issue's updated_at, so an incremental run never lists the issue and never re-fetches its timeline. An issue can be referenced from another repository years after its last activity and the backup would never see it. On incremental runs with --issue-timeline, ask GraphQL for each item's cross-reference count and newest timestamp, compare both against what is already saved, and re-fetch the timeline of anything that differs. Items the since listing did not return are fetched individually. Both values are needed: the count alone would miss a reference being added and another deleted between runs, and the timestamp alone would miss a deletion. Pull requests are swept too when they are being stored as issues, which is the case unless --pulls is used. They need their own query because the issues connection excludes them, and because totalCount ignores the itemTypes filter on a pull request although it honours it on an issue, so their cross-references are counted from the nodes instead. Where that count may have been truncated, the comparison falls back to the timestamp alone rather than re-fetching on every run. This costs one rate-limit point per 100 items against the GraphQL quota, measured at 6 requests for a repository with 528 issues and pull requests, and stores no new state: the comparison is against the cross-references already in each item's timeline_data. A GraphQL failure warns and leaves the rest of the backup unaffected. Closes #168
1 parent b29e948 commit 28e8ce9

4 files changed

Lines changed: 544 additions & 4 deletions

File tree

README.rst

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -366,9 +366,9 @@ Note that timeline entries are not all the same shape as events. ``cross-referen
366366

367367
``--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.
368368

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``.
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.
370370

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.
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.
372372

373373

374374
About pull request reviews

github_backup/github_backup.py

Lines changed: 171 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -18,6 +18,7 @@
1818
import sys
1919
import threading
2020
import time
21+
import traceback
2122
from collections.abc import Generator
2223
from datetime import datetime
2324
from http.client import IncompleteRead
@@ -33,10 +34,14 @@
3334
VERSION = "unknown"
3435

3536
from .graphql_queries import (
37+
CROSS_REFERENCE_NODE_LIMIT,
38+
CROSS_REFERENCE_PAGE_SIZE,
3639
DISCUSSION_DETAIL_QUERY,
3740
DISCUSSION_LIST_QUERY,
3841
DISCUSSION_PAGE_SIZE,
3942
DISCUSSION_REPLIES_QUERY,
43+
ISSUE_CROSS_REFERENCE_COUNT_QUERY,
44+
PULL_CROSS_REFERENCE_COUNT_QUERY,
4045
)
4146

4247
FILE_URI_PREFIX = "file://"
@@ -2575,6 +2580,141 @@ def backup_discussions(args, repo_cwd, repository):
25752580
)
25762581

25772582

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+
25782718
def backup_issues(args, repo_cwd, repository, repos_template):
25792719
has_issues_dir = os.path.isdir("{0}/issues/.git".format(repo_cwd))
25802720
if args.skip_existing and has_issues_dir:
@@ -2590,6 +2730,7 @@ def backup_issues(args, repo_cwd, repository, repos_template):
25902730
_issue_template = "{0}/{1}/issues".format(repos_template, repository["full_name"])
25912731

25922732
should_include_pulls = args.include_pulls or args.include_everything
2733+
include_timeline = args.include_issue_timeline or args.include_everything
25932734
issue_states = ["open", "closed"]
25942735
for issue_state in issue_states:
25952736
query_args = {"filter": "all", "state": issue_state}
@@ -2606,6 +2747,30 @@ def backup_issues(args, repo_cwd, repository, repos_template):
26062747

26072748
issues[issue["number"]] = issue
26082749

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+
26092774
if issues_skipped:
26102775
issues_skipped_message = " (skipped {0} pull requests)".format(issues_skipped)
26112776

@@ -2619,7 +2784,11 @@ def backup_issues(args, repo_cwd, repository, repos_template):
26192784
timeline_template = _issue_template + "/{0}/timeline"
26202785
for number, issue in list(issues.items()):
26212786
issue_file = "{0}/{1}.json".format(issue_cwd, number)
2622-
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+
):
26232792
modified = os.path.getmtime(issue_file)
26242793
modified = datetime.fromtimestamp(modified).strftime("%Y-%m-%dT%H:%M:%SZ")
26252794
if modified > issue["updated_at"]:
@@ -2636,7 +2805,7 @@ def backup_issues(args, repo_cwd, repository, repos_template):
26362805
if args.include_issue_events or args.include_everything:
26372806
template = events_template.format(number)
26382807
issues[number]["event_data"] = retrieve_data(args, template)
2639-
if args.include_issue_timeline or args.include_everything:
2808+
if include_timeline:
26402809
template = timeline_template.format(number)
26412810
# "commented" timeline entries embed the full comment body, which
26422811
# --issue-comments already backs up; storing them twice invites the

github_backup/graphql_queries.py

Lines changed: 60 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -2,6 +2,66 @@
22

33
DISCUSSION_PAGE_SIZE = 100
44

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+
565
DISCUSSION_LIST_QUERY = """
666
query($owner: String!, $name: String!, $after: String, $pageSize: Int!) {
767
repository(owner: $owner, name: $name) {

0 commit comments

Comments
 (0)