diff --git a/scripts/upstream.py b/scripts/upstream.py index e8e08134..d16cd078 100644 --- a/scripts/upstream.py +++ b/scripts/upstream.py @@ -18,6 +18,7 @@ import urllib.error import urllib.parse import urllib.request from dataclasses import dataclass +from datetime import datetime from pathlib import Path USER_AGENT = "retrobios-profile-sync/1.0" @@ -388,7 +389,56 @@ def resolve_commit_at( branch: str | None = None, ) -> str | None: """Last commit on or before a date, on the default branch or a named one.""" - return _first_sha(_api(_commits_url(repo, date, branch), cache_dir, offline)) + if repo.family in ("github", "gitlab"): + return _first_sha(_api(_commits_url(repo, date, branch), cache_dir, offline)) + return _gitea_commit_at(repo, date, cache_dir, offline, branch) + + +# Gitea and Forgejo serve 50 commits a page at most. +GITEA_PAGE = 50 + + +def _gitea_commit_at( + repo: Repo, date: str, cache_dir: str, offline: bool, + branch: str | None, +) -> str | None: + """Walk the history newest first to the first commit dated by the cutoff. + + The Gitea and Forgejo commits API ignores ``until`` and answers with the + tip: gam4980's profiled_date of 2026-03-23 resolved to a commit of July, + a revision the profile never read. + """ + cutoff = datetime.fromisoformat(f"{date}T23:59:59+00:00") + base = ( + f"{repo.api_base}/repos/{repo.slug}/commits?limit={GITEA_PAGE}" + "&stat=false&verification=false&files=false" + ) + if branch: + base += f"&sha={urllib.parse.quote(branch, safe='')}" + page = 1 + while True: + payload = _api(f"{base}&page={page}", cache_dir, offline, MOVING_TTL) + if not isinstance(payload, list) or not payload: + return None + for entry in payload: + stamp = _gitea_commit_date(entry) + if stamp is not None and stamp <= cutoff: + return entry.get("sha") + page += 1 + + +def _gitea_commit_date(entry: object) -> datetime | None: + if not isinstance(entry, dict): + return None + commit = entry.get("commit") + committer = commit.get("committer") if isinstance(commit, dict) else None + raw = committer.get("date") if isinstance(committer, dict) else None + if not isinstance(raw, str): + return None + try: + return datetime.fromisoformat(raw.replace("Z", "+00:00")) + except ValueError: + return None def _tags_url(repo: Repo) -> str: diff --git a/tests/test_upstream.py b/tests/test_upstream.py index 05d2dc35..48972e2e 100644 --- a/tests/test_upstream.py +++ b/tests/test_upstream.py @@ -648,5 +648,46 @@ class TestGitlabTree(unittest.TestCase): self.assertTrue(truncated) +class TestGiteaCommitAt(unittest.TestCase): + """Gitea and Forgejo ignore `until` and answer with the tip: the date + pin of a Codeberg view was the tip of the day of the first fetch.""" + + PAGES = { + 1: [{"sha": f"t{i}", "commit": {"committer": {"date": "2026-07-04T10:00:00+02:00"}}} + for i in range(50)], + 2: [ + {"sha": "late", "commit": {"committer": {"date": "2026-03-24T05:00:00+02:00"}}}, + {"sha": "pin", "commit": {"committer": {"date": "2026-03-17T19:49:15+08:00"}}}, + ], + } + + def setUp(self): + self.tmp = tempfile.TemporaryDirectory() + self.addCleanup(self.tmp.cleanup) + self.urls: list[str] = [] + original = upstream._http_json + + def fake(url): + self.urls.append(url) + self.assertNotIn("until=", url) + page = int(url.rsplit("page=", 1)[1]) + return self.PAGES.get(page, []) + + upstream._http_json = fake + self.addCleanup(setattr, upstream, "_http_json", original) + + def test_the_first_commit_by_the_cutoff(self): + repo = parse_repo("https://codeberg.org/iyzsong/gam4980") + sha = upstream.resolve_commit_at(repo, "2026-03-23", self.tmp.name) + self.assertEqual(sha, "pin") + self.assertEqual(len(self.urls), 2) + + def test_a_date_before_the_history_resolves_nothing(self): + repo = parse_repo("https://codeberg.org/iyzsong/gam4980") + self.assertIsNone( + upstream.resolve_commit_at(repo, "2020-01-01", self.tmp.name) + ) + + if __name__ == "__main__": unittest.main()