diff --git a/scripts/profile_sync.py b/scripts/profile_sync.py index 24681a3c..c53efcea 100644 --- a/scripts/profile_sync.py +++ b/scripts/profile_sync.py @@ -2216,15 +2216,41 @@ def page_version(profile: dict, cache_dir: str, offline: bool) -> str | None: return match.group(1) if match else None -def version_warning(report: VersionReport | None, pin: str | None) -> str | None: - """Flag a declared version that names a tag away from the resolved pin.""" +def version_warning( + report: VersionReport | None, + pin: str | None, + relation: tuple[int, int] | None = None, + passed_release: str | None = None, +) -> str | None: + """Flag a declared version that names a tag away from the resolved pin. + + ``relation`` is the pin's (ahead, behind) count against the tag commit. + A pin after its tag is a build between two releases; a pin before it + describes code the declared release had already replaced. + ``passed_release`` is a later release the pin already contains, which + core_version should name instead. + """ if report is None or not report.tag_commit or not pin: return None - if report.tag_commit == pin: + if report.tag_commit == pin and not passed_release: return None + if passed_release: + return ( + f"core_version {report.declared} is older than the code: the pin " + f"{pin[:7]} already contains release {passed_release}" + ) + where = "" + if relation is not None: + ahead, behind = relation + if ahead and not behind: + where = f", {ahead} commits after the tag" + elif behind and not ahead: + where = f", {behind} commits before the tag: core_version is later than the code" + elif ahead and behind: + where = f", off the tag's history ({ahead} ahead, {behind} behind)" return ( f"core_version {report.declared} names tag commit {report.tag_commit[:7]}, " - f"the pin resolved to {pin[:7]}" + f"the pin resolved to {pin[:7]}{where}" ) @@ -3937,7 +3963,48 @@ def _attach_version( except upstream.UpstreamError as exc: print(f"{report.name}: version check skipped: {exc}", file=sys.stderr) return - report.version_warning = version_warning(report.version, report.pin) + relation = None + passed = None + version = report.version + try: + if version and version.tag_commit and report.pin and version.tag_commit != report.pin: + relation = upstream.ancestry( + repo, version.tag_commit, report.pin, args.cache_dir, args.offline + ) + passed = _passed_release(args, repo, version, report.pin) + except upstream.RateLimitError: + raise + except upstream.UpstreamError as exc: + print(f"{report.name}: tag ancestry unread: {exc}", file=sys.stderr) + report.version_warning = version_warning(version, report.pin, relation, passed) + + +def _passed_release( + args: argparse.Namespace, + repo: upstream.Repo, + version: VersionReport | None, + pin: str | None, +) -> str | None: + """The latest release, when the pin already contains it and core_version + names another one. + + The latest tag is no witness: nightly, continuous and Pre-release tags + sort first on many forges. The latest release is a version the project + published as such. + """ + if not version or not pin or not version.latest_release: + return None + if version.latest_release == version.declared: + return None + commit = upstream.tag_commit(repo, version.latest_release, args.cache_dir, args.offline) + if not commit: + return None + if commit == pin: + return version.latest_release + relation = upstream.ancestry(repo, commit, pin, args.cache_dir, args.offline) + if relation and relation[1] == 0: + return version.latest_release + return None def _print_extras( diff --git a/scripts/upstream.py b/scripts/upstream.py index 487c9e3c..c23eb2e3 100644 --- a/scripts/upstream.py +++ b/scripts/upstream.py @@ -742,6 +742,41 @@ def compare( return CompareResult(files, truncated, payload.get("status") == "diverged") +def _commits_ahead(payload: object, family: str) -> int | None: + """Commits the compare head carries that its base does not.""" + if not isinstance(payload, dict): + return None + if family == "github": + value = payload.get("ahead_by") + elif family == "gitlab": + value = len(payload.get("commits") or []) + else: + value = payload.get("total_commits") + return value if isinstance(value, int) else None + + +def ancestry( + repo: Repo, base: str, head: str, cache_dir: str, offline: bool = False +) -> tuple[int, int] | None: + """(ahead, behind): commits head has that base lacks, and the reverse. + + Two shas never change their history, so both answers are cached for + good. GitHub gives both counts in one answer, GitLab and Forgejo list + one direction only and are asked twice. + """ + forward = _api(_compare_url(repo, base, head), cache_dir, offline) + ahead = _commits_ahead(forward, repo.family) + if ahead is None: + return None + if repo.family == "github": + behind = forward.get("behind_by") + else: + behind = _commits_ahead( + _api(_compare_url(repo, head, base), cache_dir, offline), repo.family + ) + return (ahead, behind) if isinstance(behind, int) else None + + def find_renamed(result: CompareResult, path: str) -> str | None: """New path of a file the comparison reports as renamed.""" for change in result.files: diff --git a/tests/test_profile_sync.py b/tests/test_profile_sync.py index d5644cd0..f16e5f9f 100644 --- a/tests/test_profile_sync.py +++ b/tests/test_profile_sync.py @@ -3171,6 +3171,55 @@ class TestCheckVersion(unittest.TestCase): result = check_version({"core_version": "v3.0.0"}, self.repo, self.dir, False) self.assertIsNone(version_warning(result, "tagsha")) + def test_warning_says_which_side_of_the_tag_the_pin_is(self): + # 52 warnings read alike: a build after its release and a pin older + # than the release it claims could not be told apart. + result = check_version({"core_version": "v3.0.0"}, self.repo, self.dir, False) + self.assertIn("4 commits after the tag", version_warning(result, "p", (4, 0))) + self.assertIn("core_version is later than the code", + version_warning(result, "p", (0, 9))) + self.assertIn("off the tag's history", version_warning(result, "p", (2, 3))) + + def test_warning_names_a_release_the_pin_already_contains(self): + result = check_version({"core_version": "v3.0.0"}, self.repo, self.dir, False) + warning = version_warning(result, "tagsha", None, "v3.1.0") + self.assertIn("already contains release v3.1.0", warning) + + +class TestAncestry(unittest.TestCase): + def setUp(self): + self.tmp = tempfile.TemporaryDirectory() + self.saved = profile_sync.upstream._http_json + + def tearDown(self): + profile_sync.upstream._http_json = self.saved + self.tmp.cleanup() + + def test_github_answers_both_directions_at_once(self): + calls = [] + + def answer(url): + calls.append(url) + return {"ahead_by": 3, "behind_by": 0} + + profile_sync.upstream._http_json = answer + repo = profile_sync.upstream.parse_repo("https://github.com/o/n") + self.assertEqual( + profile_sync.upstream.ancestry(repo, "tag", "pin", self.tmp.name), (3, 0) + ) + self.assertEqual(len(calls), 1) + + def test_gitlab_is_asked_both_ways(self): + def answer(url): + forward = "from=tag&to=pin" in url + return {"commits": [{}] * (0 if forward else 5)} + + profile_sync.upstream._http_json = answer + repo = profile_sync.upstream.parse_repo("https://gitlab.com/o/n") + self.assertEqual( + profile_sync.upstream.ancestry(repo, "tag", "pin", self.tmp.name), (0, 5) + ) + HASH_PROFILE = { "files": [