chaindiff 0.1.0__py3-none-any.whl
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.
- chaindiff/__init__.py +3 -0
- chaindiff/__main__.py +4 -0
- chaindiff/catalog.py +179 -0
- chaindiff/cli.py +156 -0
- chaindiff/data/clients.json +68 -0
- chaindiff/data/releases/besu.json +1227 -0
- chaindiff/data/releases/erigon.json +1045 -0
- chaindiff/data/releases/geth.json +1647 -0
- chaindiff/data/releases/lighthouse.json +898 -0
- chaindiff/data/releases/nethermind.json +2319 -0
- chaindiff/data/releases/nimbus.json +793 -0
- chaindiff/data/releases/prysm.json +1248 -0
- chaindiff/data/releases/reth.json +709 -0
- chaindiff/data/releases/teku.json +926 -0
- chaindiff/evaluate.py +229 -0
- chaindiff/models.py +69 -0
- chaindiff/refresh.py +155 -0
- chaindiff/report.py +166 -0
- chaindiff/versions.py +131 -0
- chaindiff-0.1.0.dist-info/METADATA +146 -0
- chaindiff-0.1.0.dist-info/RECORD +25 -0
- chaindiff-0.1.0.dist-info/WHEEL +5 -0
- chaindiff-0.1.0.dist-info/entry_points.txt +2 -0
- chaindiff-0.1.0.dist-info/licenses/LICENSE +21 -0
- chaindiff-0.1.0.dist-info/top_level.txt +1 -0
chaindiff/evaluate.py
ADDED
|
@@ -0,0 +1,229 @@
|
|
|
1
|
+
"""Decide whether a client upgrade is safe."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import re
|
|
6
|
+
from datetime import datetime, timedelta
|
|
7
|
+
|
|
8
|
+
from chaindiff.models import (
|
|
9
|
+
CURRENT,
|
|
10
|
+
DOWNGRADE,
|
|
11
|
+
PRERELEASE,
|
|
12
|
+
REVIEW,
|
|
13
|
+
SAFE,
|
|
14
|
+
UNSAFE,
|
|
15
|
+
Advisory,
|
|
16
|
+
CheckResult,
|
|
17
|
+
Client,
|
|
18
|
+
Release,
|
|
19
|
+
)
|
|
20
|
+
from chaindiff.versions import Version
|
|
21
|
+
|
|
22
|
+
STALE_AFTER = timedelta(days=5)
|
|
23
|
+
_SECURITY = re.compile(r"(?i)\b(security|cve-\d+|vulnerability)\b")
|
|
24
|
+
_SEVERITY_RANK = {"none": 0, "note": 1, "deprecated": 2, "breaking": 3}
|
|
25
|
+
|
|
26
|
+
|
|
27
|
+
def latest_stable(releases: list[Release]) -> Release | None:
|
|
28
|
+
stable = [item for item in releases if not item.prerelease]
|
|
29
|
+
if not stable:
|
|
30
|
+
return None
|
|
31
|
+
return max(stable, key=lambda item: (item.version, item.published_at))
|
|
32
|
+
|
|
33
|
+
|
|
34
|
+
def newer_prerelease(releases: list[Release], stable: Release | None) -> Release | None:
|
|
35
|
+
if stable is None:
|
|
36
|
+
return None
|
|
37
|
+
newer = [item for item in releases if item.prerelease and item.version > stable.version]
|
|
38
|
+
if not newer:
|
|
39
|
+
return None
|
|
40
|
+
return max(newer, key=lambda item: (item.version, item.published_at))
|
|
41
|
+
|
|
42
|
+
|
|
43
|
+
def advisories_for(advisories: list[Advisory], version: Version) -> list[Advisory]:
|
|
44
|
+
return [item for item in advisories if item.version == version]
|
|
45
|
+
|
|
46
|
+
|
|
47
|
+
def _worst(advisories: list[Advisory]) -> Advisory | None:
|
|
48
|
+
if not advisories:
|
|
49
|
+
return None
|
|
50
|
+
return max(advisories, key=lambda item: _SEVERITY_RANK[item.severity])
|
|
51
|
+
|
|
52
|
+
|
|
53
|
+
def _backup_steps() -> list[str]:
|
|
54
|
+
return [
|
|
55
|
+
"Read the release notes for every release in this range.",
|
|
56
|
+
"Back up the data directory, or snapshot the disk, before changing the binary.",
|
|
57
|
+
"Keep the current binary so you can roll back.",
|
|
58
|
+
"Upgrade this client only. Confirm it is healthy before changing the client paired with it.",
|
|
59
|
+
"If the release notes require an order for a network upgrade, follow that order.",
|
|
60
|
+
"If the node fails to start or stops syncing, restore the previous binary.",
|
|
61
|
+
]
|
|
62
|
+
|
|
63
|
+
|
|
64
|
+
def _plan_steps(verdict: str, hops: list[Release], advisories: list[Advisory]) -> list[str]:
|
|
65
|
+
if verdict == CURRENT:
|
|
66
|
+
return ["No upgrade to plan."]
|
|
67
|
+
if verdict == DOWNGRADE:
|
|
68
|
+
return ["Stay on the newer version you already run."]
|
|
69
|
+
steps: list[str] = []
|
|
70
|
+
for hop in sorted(hops, key=lambda item: item.version):
|
|
71
|
+
for advisory in advisories_for(advisories, hop.version):
|
|
72
|
+
if advisory.severity in ("breaking", "deprecated") or (
|
|
73
|
+
advisory.severity == "note" and advisory.action
|
|
74
|
+
):
|
|
75
|
+
steps.append(f"{hop.tag}: {advisory.action} ({advisory.source})")
|
|
76
|
+
steps.extend(_backup_steps())
|
|
77
|
+
return steps
|
|
78
|
+
|
|
79
|
+
|
|
80
|
+
def _choose_target(releases: list[Release], target: Version) -> Release | None:
|
|
81
|
+
matches = [item for item in releases if item.version == target]
|
|
82
|
+
if not matches:
|
|
83
|
+
return None
|
|
84
|
+
if target.pre is None:
|
|
85
|
+
stable = [item for item in matches if not item.prerelease]
|
|
86
|
+
if stable:
|
|
87
|
+
matches = stable
|
|
88
|
+
return max(matches, key=lambda item: item.published_at)
|
|
89
|
+
|
|
90
|
+
|
|
91
|
+
def _major_crossings(client: Client, current: Version, hops: list[Release]) -> list[Release]:
|
|
92
|
+
"""First release in range that enters each new semver major."""
|
|
93
|
+
if client.versioning != "semver":
|
|
94
|
+
return []
|
|
95
|
+
crossings: list[Release] = []
|
|
96
|
+
seen: set[int] = set()
|
|
97
|
+
for hop in sorted(hops, key=lambda item: item.version):
|
|
98
|
+
major = hop.version.numbers[0]
|
|
99
|
+
if major <= current.numbers[0] or major in seen:
|
|
100
|
+
continue
|
|
101
|
+
seen.add(major)
|
|
102
|
+
crossings.append(hop)
|
|
103
|
+
return crossings
|
|
104
|
+
|
|
105
|
+
|
|
106
|
+
def evaluate(
|
|
107
|
+
*,
|
|
108
|
+
client: Client,
|
|
109
|
+
releases: list[Release],
|
|
110
|
+
advisories: list[Advisory],
|
|
111
|
+
current: Version,
|
|
112
|
+
target: Version,
|
|
113
|
+
target_is_latest: bool,
|
|
114
|
+
fetched_at: datetime,
|
|
115
|
+
now: datetime,
|
|
116
|
+
) -> CheckResult:
|
|
117
|
+
warnings: list[str] = []
|
|
118
|
+
reasons: list[str] = []
|
|
119
|
+
stale = now - fetched_at > STALE_AFTER
|
|
120
|
+
chosen = _choose_target(releases, target)
|
|
121
|
+
target_tag = chosen.tag if chosen else None
|
|
122
|
+
|
|
123
|
+
if not any(item.version == current and not item.prerelease for item in releases):
|
|
124
|
+
warnings.append(
|
|
125
|
+
"The current version is not a known stable tag. Comparison uses the version number only."
|
|
126
|
+
)
|
|
127
|
+
if chosen is None:
|
|
128
|
+
warnings.append("The target version is not in the catalog. The hop list may be incomplete.")
|
|
129
|
+
|
|
130
|
+
def finish(verdict: str, hops: list[Release]) -> CheckResult:
|
|
131
|
+
if stale and not (target_is_latest and verdict == REVIEW):
|
|
132
|
+
warnings.append("The release catalog is more than 5 days old.")
|
|
133
|
+
return CheckResult(
|
|
134
|
+
client=client,
|
|
135
|
+
current=current,
|
|
136
|
+
target=target,
|
|
137
|
+
target_tag=target_tag,
|
|
138
|
+
verdict=verdict,
|
|
139
|
+
reasons=reasons,
|
|
140
|
+
warnings=warnings,
|
|
141
|
+
releases=hops,
|
|
142
|
+
steps=_plan_steps(verdict, hops, advisories),
|
|
143
|
+
fetched_at=fetched_at,
|
|
144
|
+
stale=stale,
|
|
145
|
+
)
|
|
146
|
+
|
|
147
|
+
if current == target:
|
|
148
|
+
reasons.append("The target is the version you already run.")
|
|
149
|
+
if stale and target_is_latest:
|
|
150
|
+
reasons.append(
|
|
151
|
+
"The release catalog is more than 5 days old, so a newer stable release may exist."
|
|
152
|
+
)
|
|
153
|
+
result = finish(REVIEW, [])
|
|
154
|
+
result.steps = ["Refresh the release catalog, then run this check again."]
|
|
155
|
+
return result
|
|
156
|
+
return finish(CURRENT, [])
|
|
157
|
+
|
|
158
|
+
if target < current:
|
|
159
|
+
reasons.append("The target is older than the version you already run.")
|
|
160
|
+
return finish(DOWNGRADE, [])
|
|
161
|
+
|
|
162
|
+
include_prereleases = target.pre is not None or (chosen is not None and chosen.prerelease)
|
|
163
|
+
hops = [
|
|
164
|
+
item
|
|
165
|
+
for item in releases
|
|
166
|
+
if current < item.version <= target and (include_prereleases or not item.prerelease)
|
|
167
|
+
]
|
|
168
|
+
hops.sort(key=lambda item: item.version, reverse=True)
|
|
169
|
+
|
|
170
|
+
breaking: list[tuple[Release, Advisory]] = []
|
|
171
|
+
deprecated: list[tuple[Release, Advisory]] = []
|
|
172
|
+
unreviewed: list[Release] = []
|
|
173
|
+
for hop in hops:
|
|
174
|
+
matched = advisories_for(advisories, hop.version)
|
|
175
|
+
if not matched:
|
|
176
|
+
unreviewed.append(hop)
|
|
177
|
+
continue
|
|
178
|
+
worst = _worst(matched)
|
|
179
|
+
if worst is not None and worst.severity == "breaking":
|
|
180
|
+
breaking.append((hop, worst))
|
|
181
|
+
elif worst is not None and worst.severity == "deprecated":
|
|
182
|
+
deprecated.append((hop, worst))
|
|
183
|
+
|
|
184
|
+
unreviewed_versions = {hop.version for hop in unreviewed}
|
|
185
|
+
unreviewed_majors = [
|
|
186
|
+
hop
|
|
187
|
+
for hop in _major_crossings(client, current, hops)
|
|
188
|
+
if hop.version in unreviewed_versions
|
|
189
|
+
]
|
|
190
|
+
|
|
191
|
+
for hop, advisory in sorted(breaking, key=lambda item: item[0].version):
|
|
192
|
+
reasons.append(f"{hop.tag} is breaking: {advisory.summary}")
|
|
193
|
+
for hop in unreviewed_majors:
|
|
194
|
+
reasons.append(f"{hop.tag} raises the major version and has no review. Treat it as breaking.")
|
|
195
|
+
if len(unreviewed) == 1 and len(hops) == 1:
|
|
196
|
+
reasons.append("The release in this range has no advisory yet.")
|
|
197
|
+
elif len(unreviewed) == 1:
|
|
198
|
+
reasons.append(f"1 of {len(hops)} releases in this range has no advisory yet.")
|
|
199
|
+
elif unreviewed and len(unreviewed) == len(hops):
|
|
200
|
+
reasons.append(f"None of the {len(hops)} releases in this range has an advisory yet.")
|
|
201
|
+
elif unreviewed:
|
|
202
|
+
reasons.append(f"{len(unreviewed)} of {len(hops)} releases in this range have no advisory yet.")
|
|
203
|
+
for hop, advisory in sorted(deprecated, key=lambda item: item[0].version):
|
|
204
|
+
reasons.append(f"{hop.tag} deprecates something you may still be using: {advisory.summary}")
|
|
205
|
+
if not hops:
|
|
206
|
+
reasons.append("No catalog releases sit strictly between these versions.")
|
|
207
|
+
|
|
208
|
+
for hop in hops:
|
|
209
|
+
if _SECURITY.search(hop.name):
|
|
210
|
+
warnings.append(f"{hop.tag}: the release title mentions a security fix.")
|
|
211
|
+
|
|
212
|
+
target_is_pre = target.pre is not None or (chosen is not None and chosen.prerelease)
|
|
213
|
+
if target_is_pre:
|
|
214
|
+
reasons.append("The target is a prerelease. Do not run it on a mainnet node.")
|
|
215
|
+
if breaking or unreviewed_majors:
|
|
216
|
+
verdict = UNSAFE
|
|
217
|
+
elif target_is_pre:
|
|
218
|
+
verdict = PRERELEASE
|
|
219
|
+
elif not hops or unreviewed or deprecated or (stale and target_is_latest):
|
|
220
|
+
verdict = REVIEW
|
|
221
|
+
if stale and target_is_latest:
|
|
222
|
+
reasons.append(
|
|
223
|
+
"The release catalog is more than 5 days old, so this latest-stable result may be behind."
|
|
224
|
+
)
|
|
225
|
+
else:
|
|
226
|
+
verdict = SAFE
|
|
227
|
+
reasons.append("Every release in this range has been reviewed, and none is marked breaking.")
|
|
228
|
+
|
|
229
|
+
return finish(verdict, hops)
|
chaindiff/models.py
ADDED
|
@@ -0,0 +1,69 @@
|
|
|
1
|
+
"""Shared records for clients, releases, and advisories."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
from dataclasses import dataclass
|
|
6
|
+
from datetime import datetime
|
|
7
|
+
|
|
8
|
+
from chaindiff.versions import Version
|
|
9
|
+
|
|
10
|
+
CURRENT = "current"
|
|
11
|
+
SAFE = "safe"
|
|
12
|
+
REVIEW = "review"
|
|
13
|
+
UNSAFE = "unsafe"
|
|
14
|
+
DOWNGRADE = "downgrade"
|
|
15
|
+
PRERELEASE = "prerelease"
|
|
16
|
+
|
|
17
|
+
VERDICT_LABELS = {
|
|
18
|
+
CURRENT: "ALREADY CURRENT",
|
|
19
|
+
SAFE: "SAFE",
|
|
20
|
+
REVIEW: "REVIEW REQUIRED",
|
|
21
|
+
UNSAFE: "NOT SAFE",
|
|
22
|
+
DOWNGRADE: "DOWNGRADE",
|
|
23
|
+
PRERELEASE: "PRERELEASE",
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
SEVERITIES = ("none", "note", "deprecated", "breaking")
|
|
27
|
+
|
|
28
|
+
|
|
29
|
+
@dataclass(frozen=True)
|
|
30
|
+
class Client:
|
|
31
|
+
id: str
|
|
32
|
+
name: str
|
|
33
|
+
role: str
|
|
34
|
+
github: str
|
|
35
|
+
versioning: str
|
|
36
|
+
|
|
37
|
+
|
|
38
|
+
@dataclass(frozen=True)
|
|
39
|
+
class Release:
|
|
40
|
+
tag: str
|
|
41
|
+
name: str
|
|
42
|
+
published_at: str
|
|
43
|
+
prerelease: bool
|
|
44
|
+
url: str
|
|
45
|
+
version: Version
|
|
46
|
+
|
|
47
|
+
|
|
48
|
+
@dataclass(frozen=True)
|
|
49
|
+
class Advisory:
|
|
50
|
+
version: Version
|
|
51
|
+
severity: str
|
|
52
|
+
summary: str
|
|
53
|
+
action: str
|
|
54
|
+
source: str
|
|
55
|
+
|
|
56
|
+
|
|
57
|
+
@dataclass
|
|
58
|
+
class CheckResult:
|
|
59
|
+
client: Client
|
|
60
|
+
current: Version
|
|
61
|
+
target: Version
|
|
62
|
+
target_tag: str | None
|
|
63
|
+
verdict: str
|
|
64
|
+
reasons: list[str]
|
|
65
|
+
warnings: list[str]
|
|
66
|
+
releases: list[Release]
|
|
67
|
+
steps: list[str]
|
|
68
|
+
fetched_at: datetime
|
|
69
|
+
stale: bool
|
chaindiff/refresh.py
ADDED
|
@@ -0,0 +1,155 @@
|
|
|
1
|
+
"""Fetch client releases from GitHub."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import json
|
|
6
|
+
import os
|
|
7
|
+
import re
|
|
8
|
+
import urllib.error
|
|
9
|
+
import urllib.request
|
|
10
|
+
from collections.abc import Callable
|
|
11
|
+
from datetime import datetime, timezone
|
|
12
|
+
from typing import Any
|
|
13
|
+
|
|
14
|
+
from chaindiff.catalog import load_clients, save_releases
|
|
15
|
+
from chaindiff.models import Client, Release
|
|
16
|
+
from chaindiff.versions import parse_version, tag_is_prerelease
|
|
17
|
+
|
|
18
|
+
API_ROOT = "https://api.github.com"
|
|
19
|
+
_NEXT_LINK = re.compile(r'<([^>]+)>;\s*rel="next"')
|
|
20
|
+
Opener = Callable[[str, str | None], tuple[Any, str | None]]
|
|
21
|
+
|
|
22
|
+
|
|
23
|
+
class GitHubError(Exception):
|
|
24
|
+
pass
|
|
25
|
+
|
|
26
|
+
|
|
27
|
+
def _default_opener(url: str, token: str | None) -> tuple[Any, str | None]:
|
|
28
|
+
headers = {
|
|
29
|
+
"Accept": "application/vnd.github+json",
|
|
30
|
+
"User-Agent": "chaindiff/0.1",
|
|
31
|
+
"X-GitHub-Api-Version": "2022-11-28",
|
|
32
|
+
}
|
|
33
|
+
if token:
|
|
34
|
+
headers["Authorization"] = f"Bearer {token}"
|
|
35
|
+
request = urllib.request.Request(url, headers=headers)
|
|
36
|
+
try:
|
|
37
|
+
with urllib.request.urlopen(request, timeout=30) as response:
|
|
38
|
+
payload = json.load(response)
|
|
39
|
+
return payload, response.headers.get("Link")
|
|
40
|
+
except urllib.error.HTTPError as exc:
|
|
41
|
+
detail = exc.read().decode("utf-8", errors="replace")
|
|
42
|
+
message = ""
|
|
43
|
+
try:
|
|
44
|
+
message = json.loads(detail).get("message", "")
|
|
45
|
+
except json.JSONDecodeError:
|
|
46
|
+
message = detail.strip()
|
|
47
|
+
if exc.code == 403 and "rate limit" in message.lower():
|
|
48
|
+
raise GitHubError(
|
|
49
|
+
"GitHub rate limit reached. Set GH_TOKEN or GITHUB_TOKEN and run refresh again."
|
|
50
|
+
) from exc
|
|
51
|
+
raise GitHubError(f"GitHub returned {exc.code} for {url}: {message}") from exc
|
|
52
|
+
except urllib.error.URLError as exc:
|
|
53
|
+
raise GitHubError(f"Could not reach GitHub: {exc.reason}") from exc
|
|
54
|
+
|
|
55
|
+
|
|
56
|
+
def _next_url(link_header: str | None) -> str | None:
|
|
57
|
+
if not link_header:
|
|
58
|
+
return None
|
|
59
|
+
matched = _NEXT_LINK.search(link_header)
|
|
60
|
+
if not matched:
|
|
61
|
+
return None
|
|
62
|
+
return matched.group(1)
|
|
63
|
+
|
|
64
|
+
|
|
65
|
+
def releases_from_payload(items: list[dict[str, Any]]) -> tuple[list[Release], int]:
|
|
66
|
+
releases: list[Release] = []
|
|
67
|
+
ignored = 0
|
|
68
|
+
seen: set[str] = set()
|
|
69
|
+
for item in items:
|
|
70
|
+
if item.get("draft"):
|
|
71
|
+
ignored += 1
|
|
72
|
+
continue
|
|
73
|
+
tag = str(item.get("tag_name") or "")
|
|
74
|
+
if not tag or tag in seen:
|
|
75
|
+
ignored += 1
|
|
76
|
+
continue
|
|
77
|
+
version = parse_version(tag)
|
|
78
|
+
if version is None:
|
|
79
|
+
ignored += 1
|
|
80
|
+
continue
|
|
81
|
+
seen.add(tag)
|
|
82
|
+
published = str(item.get("published_at") or "")
|
|
83
|
+
prerelease = bool(item.get("prerelease")) or tag_is_prerelease(tag)
|
|
84
|
+
releases.append(
|
|
85
|
+
Release(
|
|
86
|
+
tag=tag,
|
|
87
|
+
name=str(item.get("name") or tag),
|
|
88
|
+
published_at=published,
|
|
89
|
+
prerelease=prerelease,
|
|
90
|
+
url=str(item.get("html_url") or ""),
|
|
91
|
+
version=version,
|
|
92
|
+
)
|
|
93
|
+
)
|
|
94
|
+
return releases, ignored
|
|
95
|
+
|
|
96
|
+
|
|
97
|
+
def fetch_releases(
|
|
98
|
+
client: Client,
|
|
99
|
+
token: str | None = None,
|
|
100
|
+
opener: Opener | None = None,
|
|
101
|
+
) -> tuple[list[Release], int]:
|
|
102
|
+
fetch = opener or _default_opener
|
|
103
|
+
url = f"{API_ROOT}/repos/{client.github}/releases?per_page=100"
|
|
104
|
+
pages: list[dict[str, Any]] = []
|
|
105
|
+
for _ in range(20):
|
|
106
|
+
payload, link = fetch(url, token)
|
|
107
|
+
if isinstance(payload, dict):
|
|
108
|
+
message = payload.get("message", "unexpected GitHub response")
|
|
109
|
+
raise GitHubError(f"{client.github}: {message}")
|
|
110
|
+
if not isinstance(payload, list):
|
|
111
|
+
raise GitHubError(f"{client.github}: unexpected GitHub response")
|
|
112
|
+
pages.extend(payload)
|
|
113
|
+
nxt = _next_url(link)
|
|
114
|
+
if nxt is None:
|
|
115
|
+
break
|
|
116
|
+
url = nxt
|
|
117
|
+
else:
|
|
118
|
+
raise GitHubError(f"{client.github}: too many release pages")
|
|
119
|
+
return releases_from_payload(pages)
|
|
120
|
+
|
|
121
|
+
|
|
122
|
+
def github_token() -> str | None:
|
|
123
|
+
return os.environ.get("GH_TOKEN") or os.environ.get("GITHUB_TOKEN") or None
|
|
124
|
+
|
|
125
|
+
|
|
126
|
+
def refresh_catalog(client_ids: list[str] | None = None) -> int:
|
|
127
|
+
clients = load_clients()
|
|
128
|
+
if client_ids:
|
|
129
|
+
wanted = {item.strip().lower() for item in client_ids}
|
|
130
|
+
unknown = wanted - {client.id for client in clients}
|
|
131
|
+
if unknown:
|
|
132
|
+
names = ", ".join(sorted(unknown))
|
|
133
|
+
print(f"Unknown client: {names}")
|
|
134
|
+
return 3
|
|
135
|
+
clients = [client for client in clients if client.id in wanted]
|
|
136
|
+
|
|
137
|
+
token = github_token()
|
|
138
|
+
failures: list[str] = []
|
|
139
|
+
fetched_at = datetime.now(timezone.utc)
|
|
140
|
+
for client in clients:
|
|
141
|
+
try:
|
|
142
|
+
releases, ignored = fetch_releases(client, token=token)
|
|
143
|
+
except GitHubError as exc:
|
|
144
|
+
failures.append(f"{client.id}: {exc}")
|
|
145
|
+
print(f"{client.id}: failed — {exc}")
|
|
146
|
+
continue
|
|
147
|
+
save_releases(client, releases, ignored, fetched_at)
|
|
148
|
+
stable = sum(1 for item in releases if not item.prerelease)
|
|
149
|
+
print(
|
|
150
|
+
f"{client.id}: {stable} stable releases, "
|
|
151
|
+
f"{len(releases) - stable} prereleases, {ignored} ignored tags"
|
|
152
|
+
)
|
|
153
|
+
if failures:
|
|
154
|
+
return 1
|
|
155
|
+
return 0
|
chaindiff/report.py
ADDED
|
@@ -0,0 +1,166 @@
|
|
|
1
|
+
"""Text and JSON reports."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
from datetime import datetime, timedelta, timezone
|
|
6
|
+
|
|
7
|
+
from chaindiff.catalog import format_time
|
|
8
|
+
from chaindiff.evaluate import latest_stable, newer_prerelease
|
|
9
|
+
from chaindiff.models import VERDICT_LABELS, CheckResult, Client, Release
|
|
10
|
+
|
|
11
|
+
|
|
12
|
+
def _day(value: str) -> str:
|
|
13
|
+
return value[:10] if len(value) >= 10 else "-"
|
|
14
|
+
|
|
15
|
+
|
|
16
|
+
def _shown_releases(releases: list[Release]) -> tuple[list[Release | None], int]:
|
|
17
|
+
if len(releases) <= 12:
|
|
18
|
+
return list(releases), 0
|
|
19
|
+
hidden = releases[8:-3]
|
|
20
|
+
shown: list[Release | None] = list(releases[:8]) + [None] + list(releases[-3:])
|
|
21
|
+
return shown, len(hidden)
|
|
22
|
+
|
|
23
|
+
|
|
24
|
+
def format_check(result: CheckResult, *, plan_only: bool = False) -> str:
|
|
25
|
+
lines = [
|
|
26
|
+
f"{result.client.name} {result.client.role}",
|
|
27
|
+
result.client.github,
|
|
28
|
+
f"Catalog: {format_time(result.fetched_at)}",
|
|
29
|
+
"",
|
|
30
|
+
f"{result.current.text} → {result.target.text}"
|
|
31
|
+
+ (f" ({result.target_tag})" if result.target_tag else ""),
|
|
32
|
+
"",
|
|
33
|
+
f"Verdict: {VERDICT_LABELS[result.verdict]}",
|
|
34
|
+
"",
|
|
35
|
+
]
|
|
36
|
+
if result.reasons:
|
|
37
|
+
lines.append("Why")
|
|
38
|
+
lines.extend(f" {reason}" for reason in result.reasons)
|
|
39
|
+
lines.append("")
|
|
40
|
+
if result.warnings:
|
|
41
|
+
lines.append("Warnings")
|
|
42
|
+
lines.extend(f" {warning}" for warning in result.warnings)
|
|
43
|
+
lines.append("")
|
|
44
|
+
if not plan_only and result.releases:
|
|
45
|
+
lines.append("Releases")
|
|
46
|
+
shown, hidden = _shown_releases(result.releases)
|
|
47
|
+
for item in shown:
|
|
48
|
+
if item is None:
|
|
49
|
+
lines.append(f" ({hidden} older releases omitted)")
|
|
50
|
+
continue
|
|
51
|
+
lines.append(f" {item.tag:<16} {_day(item.published_at)} {item.url}")
|
|
52
|
+
oldest = min(result.releases, key=lambda item: item.version)
|
|
53
|
+
if len(result.releases) > 1:
|
|
54
|
+
lines.append(
|
|
55
|
+
f" First release after {result.current.text}: {oldest.tag} on {_day(oldest.published_at)}"
|
|
56
|
+
)
|
|
57
|
+
lines.append("")
|
|
58
|
+
if result.steps:
|
|
59
|
+
lines.append("Before any upgrade")
|
|
60
|
+
for index, step in enumerate(result.steps, start=1):
|
|
61
|
+
lines.append(f" {index}. {step}")
|
|
62
|
+
lines.append("")
|
|
63
|
+
return "\n".join(lines).rstrip() + "\n"
|
|
64
|
+
|
|
65
|
+
|
|
66
|
+
def check_json(result: CheckResult) -> dict:
|
|
67
|
+
return {
|
|
68
|
+
"client": result.client.id,
|
|
69
|
+
"name": result.client.name,
|
|
70
|
+
"role": result.client.role,
|
|
71
|
+
"github": result.client.github,
|
|
72
|
+
"from": result.current.text,
|
|
73
|
+
"to": result.target.text,
|
|
74
|
+
"target_tag": result.target_tag,
|
|
75
|
+
"verdict": result.verdict,
|
|
76
|
+
"reasons": result.reasons,
|
|
77
|
+
"warnings": result.warnings,
|
|
78
|
+
"releases": [
|
|
79
|
+
{
|
|
80
|
+
"tag": item.tag,
|
|
81
|
+
"name": item.name,
|
|
82
|
+
"published_at": item.published_at,
|
|
83
|
+
"prerelease": item.prerelease,
|
|
84
|
+
"url": item.url,
|
|
85
|
+
}
|
|
86
|
+
for item in result.releases
|
|
87
|
+
],
|
|
88
|
+
"steps": result.steps,
|
|
89
|
+
"catalog_fetched_at": format_time(result.fetched_at),
|
|
90
|
+
"catalog_stale": result.stale,
|
|
91
|
+
}
|
|
92
|
+
|
|
93
|
+
|
|
94
|
+
def format_versions(
|
|
95
|
+
rows: list[tuple[Client, datetime | None, list[Release] | None]],
|
|
96
|
+
now: datetime | None = None,
|
|
97
|
+
) -> str:
|
|
98
|
+
moment = now or datetime.now(timezone.utc)
|
|
99
|
+
lines = [f"{'Client':<12} {'Role':<10} {'Latest':<18} {'Published':<12} Notes"]
|
|
100
|
+
stale = False
|
|
101
|
+
for client, fetched_at, releases in rows:
|
|
102
|
+
if releases is None or fetched_at is None:
|
|
103
|
+
lines.append(f"{client.id:<12} {client.role:<10} {'-':<18} {'-':<12} no catalog")
|
|
104
|
+
continue
|
|
105
|
+
is_stale = moment - fetched_at > timedelta(days=5)
|
|
106
|
+
stale = stale or is_stale
|
|
107
|
+
stable = latest_stable(releases)
|
|
108
|
+
ahead = newer_prerelease(releases, stable)
|
|
109
|
+
if stable is None:
|
|
110
|
+
latest = "-"
|
|
111
|
+
published = "-"
|
|
112
|
+
else:
|
|
113
|
+
latest = stable.tag
|
|
114
|
+
published = _day(stable.published_at)
|
|
115
|
+
notes = f"prerelease {ahead.tag} is newer" if ahead else ""
|
|
116
|
+
if is_stale:
|
|
117
|
+
notes = (notes + "; " if notes else "") + "catalog is stale"
|
|
118
|
+
lines.append(f"{client.id:<12} {client.role:<10} {latest:<18} {published:<12} {notes}".rstrip())
|
|
119
|
+
if stale:
|
|
120
|
+
lines.insert(0, "Release catalog is more than 5 days old. Run: chaindiff refresh")
|
|
121
|
+
lines.insert(1, "")
|
|
122
|
+
return "\n".join(lines) + "\n"
|
|
123
|
+
|
|
124
|
+
|
|
125
|
+
def versions_json(
|
|
126
|
+
rows: list[tuple[Client, datetime | None, list[Release] | None]],
|
|
127
|
+
now: datetime | None = None,
|
|
128
|
+
) -> dict:
|
|
129
|
+
moment = now or datetime.now(timezone.utc)
|
|
130
|
+
clients = []
|
|
131
|
+
for client, fetched_at, releases in rows:
|
|
132
|
+
if releases is None or fetched_at is None:
|
|
133
|
+
clients.append(
|
|
134
|
+
{
|
|
135
|
+
"id": client.id,
|
|
136
|
+
"name": client.name,
|
|
137
|
+
"role": client.role,
|
|
138
|
+
"github": client.github,
|
|
139
|
+
"catalog": None,
|
|
140
|
+
}
|
|
141
|
+
)
|
|
142
|
+
continue
|
|
143
|
+
stable = latest_stable(releases)
|
|
144
|
+
ahead = newer_prerelease(releases, stable)
|
|
145
|
+
clients.append(
|
|
146
|
+
{
|
|
147
|
+
"id": client.id,
|
|
148
|
+
"name": client.name,
|
|
149
|
+
"role": client.role,
|
|
150
|
+
"github": client.github,
|
|
151
|
+
"fetched_at": format_time(fetched_at),
|
|
152
|
+
"stale": moment - fetched_at > timedelta(days=5),
|
|
153
|
+
"latest_stable": None
|
|
154
|
+
if stable is None
|
|
155
|
+
else {
|
|
156
|
+
"tag": stable.tag,
|
|
157
|
+
"version": stable.version.text,
|
|
158
|
+
"published_at": stable.published_at,
|
|
159
|
+
"url": stable.url,
|
|
160
|
+
},
|
|
161
|
+
"prerelease_ahead": None
|
|
162
|
+
if ahead is None
|
|
163
|
+
else {"tag": ahead.tag, "published_at": ahead.published_at, "url": ahead.url},
|
|
164
|
+
}
|
|
165
|
+
)
|
|
166
|
+
return {"clients": clients}
|