diff --git a/scripts/profile_sync.py b/scripts/profile_sync.py index 2d670119..4d6c929f 100644 --- a/scripts/profile_sync.py +++ b/scripts/profile_sync.py @@ -939,6 +939,10 @@ class ProfileReport: # Repositories whose pin is no ancestor of the branch the profile # tracks: history was rewritten, or the pin lies on another branch. orphaned: list[str] = field(default_factory=list) + # Repositories the forge answers under another owner/name: renamed or + # transferred. The redirect is followed in silence, so the move is named + # and counted as review work, since a renamed project is re-profiled. + moved_repos: list[str] = field(default_factory=list) # A profile off any forge names the page announcing its releases # (version_source): the version read there, beside the one declared. declared_version: str | None = None @@ -976,6 +980,7 @@ class ProfileReport: sum(counts.get(status, 0) for status in REVIEW_STATUSES) + len(self.orphaned) + len(self.stale_prose or ()) + + len(self.moved_repos) ) @@ -1332,6 +1337,25 @@ def _path_roles(refs: list) -> dict[str, set[str]]: return roles +def moved_repositories( + views: list[RepoView], cache_dir: str, offline: bool +) -> list[str]: + """Declared repositories the forge now answers under another name.""" + moved = [] + seen: set[tuple[str, str]] = set() + for view in views: + key = (view.repo.host, view.repo.slug) + if key in seen: + continue + seen.add(key) + current = upstream.current_slug(view.repo, cache_dir, offline) + if current and current.lower() != view.repo.slug.lower(): + moved.append( + f"{view.repo.slug} answers as {current} (renamed or transferred)" + ) + return moved + + def build_report( name: str, profile: dict, cache_dir: str, offline: bool = False, text: str | None = None, @@ -1451,6 +1475,7 @@ def build_report( report.upstream_pin = view.pin report.upstream_head = view.head report.upstream_origin = view.origin + report.moved_repos = moved_repositories(views, cache_dir, offline) # A profile can hold its pin on purpose, documenting a build that is that # revision. Comparing it to HEAD says nothing and recaling would repoint # its refs at code the build never had, so it is judged like a frozen tag: @@ -1954,6 +1979,8 @@ def format_report(report: ProfileReport, changed_only: bool = False) -> str: lines.append(f" not read (unsupported host): {url}") for line in report.orphaned: lines.append(f" orphaned pin: {line}") + for line in report.moved_repos: + lines.append(f" moved repository: {line}") if report.declared_version or report.page_version: lines.append( f" version page: declared {report.declared_version or '-'}, " @@ -3509,9 +3536,9 @@ def _check_quota(count: int, offline: bool) -> None: """Refuse a run the anonymous quota cannot carry to completion.""" if offline or github_token(): return - if count * 4 > ANON_QUOTA: + if count * 5 > ANON_QUOTA: print( - f"{count} profiles need roughly {count * 4} API calls, the anonymous " + f"{count} profiles need roughly {count * 5} API calls, the anonymous " f"quota is {ANON_QUOTA} per hour. Log in with `gh auth login`, set " "GITHUB_TOKEN, or use --offline.", file=sys.stderr, diff --git a/scripts/upstream.py b/scripts/upstream.py index 4272cf55..50ddd9d3 100644 --- a/scripts/upstream.py +++ b/scripts/upstream.py @@ -455,6 +455,25 @@ def _first_sha(payload: object) -> str | None: return None +def current_slug(repo: Repo, cache_dir: str, offline: bool = False) -> str | None: + """The owner/name the forge answers for a repository today. + + A renamed or transferred repository keeps answering under its old name: + GitHub redirects (AloysHF/NicaiEmu answers as AloysHF/CoolbarEmu), GitLab + and Gitea do the same. Every later call follows the redirect, so only the + name in the payload tells the project moved. + """ + if repo.family == "gitlab": + url, key = f"{repo.api_base}/projects/{_project(repo)}", "path_with_namespace" + else: + url, key = f"{repo.api_base}/repos/{repo.slug}", "full_name" + payload = _api(url, cache_dir, offline, MOVING_TTL) + if not isinstance(payload, dict): + return None + value = payload.get(key) + return str(value) if value else None + + def resolve_head( repo: Repo, cache_dir: str, offline: bool = False, branch: str | None = None ) -> str | None: diff --git a/tests/test_profile_sync.py b/tests/test_profile_sync.py index 754fd28c..9b723d35 100644 --- a/tests/test_profile_sync.py +++ b/tests/test_profile_sync.py @@ -1163,6 +1163,13 @@ class TestBuildReport(unittest.TestCase): profile_sync.upstream._http_json, profile_sync.upstream._http_text, profile_sync.upstream.list_gitlinks, + profile_sync.upstream.current_slug, + ) + self.current_slugs: dict[str, str] = {} + profile_sync.upstream.current_slug = ( + lambda repo, cache_dir, offline=False: self.current_slugs.get( + repo.slug, repo.slug + ) ) self.tags: list[str] = [] self.gitlinks: dict[str, dict[str, str]] = {} @@ -1218,6 +1225,7 @@ class TestBuildReport(unittest.TestCase): profile_sync.upstream._http_json, profile_sync.upstream._http_text, profile_sync.upstream.list_gitlinks, + profile_sync.upstream.current_slug, ) = self._orig self.tmp.cleanup() @@ -1232,6 +1240,25 @@ class TestBuildReport(unittest.TestCase): ], } + def test_a_renamed_repository_is_review_work(self): + """AloysHF/NicaiEmu answers as AloysHF/CoolbarEmu: the redirect was + followed in silence and the profile read as up to date.""" + self.files[("pinsha", "a.c")] = ["x", "y"] + self.files[("headsha", "a.c")] = ["x", "y"] + self.current_slugs["o/n"] = "o/renamed" + report = build_report("test", self._profile(["a.c:2"]), self.dir) + self.assertEqual(len(report.moved_repos), 1) + self.assertIn("o/renamed", report.moved_repos[0]) + self.assertGreaterEqual(report.needs_review(), 1) + self.assertIn("moved repository", profile_sync.format_report(report)) + + def test_a_repository_under_its_own_name_is_not_moved(self): + self.files[("pinsha", "a.c")] = ["x", "y"] + self.files[("headsha", "a.c")] = ["x", "y"] + self.current_slugs["o/n"] = "O/N" + report = build_report("test", self._profile(["a.c:2"]), self.dir) + self.assertEqual(report.moved_repos, []) + def test_a_refusing_forge_reads_as_unread_not_gone(self): """git.eden-emu.dev answers 403 to scripts: the muted repository's citations read "absent at HEAD" for files that exist.""" diff --git a/tests/test_upstream.py b/tests/test_upstream.py index 282cd57b..c88ece29 100644 --- a/tests/test_upstream.py +++ b/tests/test_upstream.py @@ -805,5 +805,29 @@ class TestGiteaCommitAt(unittest.TestCase): ) + +class TestCurrentSlug(unittest.TestCase): + """The name a forge answers under, read from the repository payload.""" + + def test_github_and_gitlab_payloads(self): + answers = { + "https://api.github.com/repos/AloysHF/NicaiEmu": { + "full_name": "AloysHF/CoolbarEmu" + }, + "https://gitlab.com/api/v4/projects/g%2Fsub%2Fp": { + "path_with_namespace": "g/sub/p" + }, + } + with tempfile.TemporaryDirectory() as tmp, \ + mock.patch.object(upstream, "_http_json", side_effect=answers.get): + github = parse_repo("https://github.com/AloysHF/NicaiEmu") + gitlab = parse_repo("https://gitlab.com/g/sub/p") + self.assertEqual(upstream.current_slug(github, tmp), "AloysHF/CoolbarEmu") + self.assertEqual(upstream.current_slug(gitlab, tmp), "g/sub/p") + self.assertIsNone( + upstream.current_slug(parse_repo("https://github.com/a/b"), tmp) + ) + + if __name__ == "__main__": unittest.main()