mirror of
https://github.com/Abdess/retroarch_system.git
synced 2026-10-10 13:33:24 -05:00
feat: read libretro gitlab and mark unchecked refs
This commit is contained in:
1 parent
1a993edc0d
commit
17cecbf186
4 files changed
+177
-19
No files matched your search
+58
-15
@@ -35,8 +35,8 @@ ANON_QUOTA = 60
|
|||||||
TRIAGE_PATH_SAMPLE = 5
|
TRIAGE_PATH_SAMPLE = 5
|
||||||
|
|
||||||
STATUS_ORDER = (
|
STATUS_ORDER = (
|
||||||
"ANCHORED", "EXTERNAL", "BINARY", "SHIFTED", "RENAMED", "MOVED", "AMBIGUOUS",
|
"ANCHORED", "UNCHECKED", "EXTERNAL", "BINARY", "SHIFTED", "RENAMED", "MOVED",
|
||||||
"CHANGED", "GONE",
|
"AMBIGUOUS", "CHANGED", "GONE",
|
||||||
)
|
)
|
||||||
REVIEW_STATUSES = ("CHANGED", "GONE", "AMBIGUOUS")
|
REVIEW_STATUSES = ("CHANGED", "GONE", "AMBIGUOUS")
|
||||||
REBASE_STATUSES = ("SHIFTED", "RENAMED", "MOVED")
|
REBASE_STATUSES = ("SHIFTED", "RENAMED", "MOVED")
|
||||||
@@ -973,6 +973,10 @@ class ProfileReport:
|
|||||||
entries: list[EntryReport] = None
|
entries: list[EntryReport] = None
|
||||||
skipped: str | None = None
|
skipped: str | None = None
|
||||||
counts: dict[str, int] = None
|
counts: dict[str, int] = None
|
||||||
|
# Declared repositories whose forge the tool cannot read. They are named
|
||||||
|
# rather than dropped: a `source` on an unknown host used to fall back to
|
||||||
|
# `upstream` in silence, and a divergence between the two went unseen.
|
||||||
|
unread: list[str] = None
|
||||||
|
|
||||||
def needs_review(self) -> int:
|
def needs_review(self) -> int:
|
||||||
counts = self.counts or {}
|
counts = self.counts or {}
|
||||||
@@ -1109,7 +1113,14 @@ def verify_at_pin(part: RefPart, pin_lines, tokens, hash_tokens=()) -> PartResul
|
|||||||
part, "GONE", None, None, None, [], "beyond the end of the file"
|
part, "GONE", None, None, None, [], "beyond the end of the file"
|
||||||
)
|
)
|
||||||
if not tokens:
|
if not tokens:
|
||||||
return PartResult(part, "ANCHORED", None, None, None, [])
|
# Nothing to look for: an entry with neither a hash nor a name (an
|
||||||
|
# analysis block, a `filename:` field) cannot be checked against its
|
||||||
|
# own revision. Calling it anchored made the check vacuously true, and
|
||||||
|
# a pin advanced without a recale hid a ref that had become false.
|
||||||
|
return PartResult(
|
||||||
|
part, "UNCHECKED", None, None, None, [],
|
||||||
|
"entry declares no value to look for",
|
||||||
|
)
|
||||||
# An archive ref cites the line declaring the set; its members follow, one
|
# An archive ref cites the line declaring the set; its members follow, one
|
||||||
# per line, so the window reaches forward as far as the entry has members.
|
# per line, so the window reaches forward as far as the entry has members.
|
||||||
reach = SELF_CHECK_CONTEXT + len(tokens)
|
reach = SELF_CHECK_CONTEXT + len(tokens)
|
||||||
@@ -1256,6 +1267,11 @@ def build_report(
|
|||||||
cited_dirs.add(directory)
|
cited_dirs.add(directory)
|
||||||
directory = posixpath.dirname(directory)
|
directory = posixpath.dirname(directory)
|
||||||
|
|
||||||
|
report.unread = [
|
||||||
|
url
|
||||||
|
for _, _, url in declared_repositories(profile)
|
||||||
|
if upstream.parse_repo(url) is None
|
||||||
|
]
|
||||||
if select_repo(profile) is None:
|
if select_repo(profile) is None:
|
||||||
declared = str(profile.get("source") or profile.get("upstream") or "")
|
declared = str(profile.get("source") or profile.get("upstream") or "")
|
||||||
report.skipped = f"unsupported host: {declared or 'none declared'}"
|
report.skipped = f"unsupported host: {declared or 'none declared'}"
|
||||||
@@ -1651,6 +1667,8 @@ def format_report(report: ProfileReport, changed_only: bool = False) -> str:
|
|||||||
)
|
)
|
||||||
elif report.pin and report.pin == report.head:
|
elif report.pin and report.pin == report.head:
|
||||||
lines.append(" pin is HEAD: checked for self-consistency")
|
lines.append(" pin is HEAD: checked for self-consistency")
|
||||||
|
for url in report.unread or []:
|
||||||
|
lines.append(f" not read (unsupported host): {url}")
|
||||||
if report.skipped:
|
if report.skipped:
|
||||||
lines.append(f" skipped: {report.skipped}")
|
lines.append(f" skipped: {report.skipped}")
|
||||||
return "\n".join(lines)
|
return "\n".join(lines)
|
||||||
@@ -2629,8 +2647,17 @@ def build_parser() -> argparse.ArgumentParser:
|
|||||||
parser.add_argument("--tree-diff", action="store_true")
|
parser.add_argument("--tree-diff", action="store_true")
|
||||||
parser.add_argument("--triage", action="store_true")
|
parser.add_argument("--triage", action="store_true")
|
||||||
parser.add_argument("--backfill-commits", action="store_true")
|
parser.add_argument("--backfill-commits", action="store_true")
|
||||||
parser.add_argument("--rebase-refs", action="store_true")
|
parser.add_argument(
|
||||||
parser.add_argument("--bump-commit", action="store_true")
|
"--rebase-refs",
|
||||||
|
action="store_true",
|
||||||
|
help="rewrite shifted refs for HEAD and move source_commit with them, "
|
||||||
|
"all or nothing",
|
||||||
|
)
|
||||||
|
parser.add_argument(
|
||||||
|
"--bump-commit",
|
||||||
|
action="store_true",
|
||||||
|
help="move source_commit to HEAD when every ref already anchors there",
|
||||||
|
)
|
||||||
parser.add_argument(
|
parser.add_argument(
|
||||||
"--realign-prose",
|
"--realign-prose",
|
||||||
action="store_true",
|
action="store_true",
|
||||||
@@ -2698,13 +2725,14 @@ def _apply_writes(args, name: str, profile: dict, report: ProfileReport) -> None
|
|||||||
print(f"{name}: source_commit {report.pin[:7]}")
|
print(f"{name}: source_commit {report.pin[:7]}")
|
||||||
if not (args.rebase_refs or args.bump_commit):
|
if not (args.rebase_refs or args.bump_commit):
|
||||||
return
|
return
|
||||||
both = args.rebase_refs and args.bump_commit
|
|
||||||
with tempfile.TemporaryDirectory() as scratch:
|
with tempfile.TemporaryDirectory() as scratch:
|
||||||
# Always work on a copy. A dry run reports what it left there; a real
|
# Always work on a copy. A dry run reports what it left there; a real
|
||||||
# run promotes it. Asking for both writes makes the pass atomic: the
|
# run promotes it. A recale rewrites the refs for HEAD, so the pin
|
||||||
# pin has to follow the refs or neither moves, because a profile whose
|
# has to follow or neither moves: a profile whose refs describe one
|
||||||
# refs describe one revision and whose pin names another is exactly
|
# revision and whose pin names another is exactly what the
|
||||||
# what the all-or-nothing rule exists to prevent.
|
# all-or-nothing rule exists to prevent. Recaling alone used to
|
||||||
|
# leave that state behind, and a later bump then read the recaled
|
||||||
|
# refs at the old pin and saw them shifted again.
|
||||||
target = Path(scratch) / path.name
|
target = Path(scratch) / path.name
|
||||||
target.write_bytes(path.read_bytes())
|
target.write_bytes(path.read_bytes())
|
||||||
recale, bumped = (
|
recale, bumped = (
|
||||||
@@ -2714,10 +2742,8 @@ def _apply_writes(args, name: str, profile: dict, report: ProfileReport) -> None
|
|||||||
applied = []
|
applied = []
|
||||||
if args.rebase_refs and not report.skipped:
|
if args.rebase_refs and not report.skipped:
|
||||||
applied = rebase_refs(target, report, args.accept_changed)
|
applied = rebase_refs(target, report, args.accept_changed)
|
||||||
moved = bool(args.bump_commit) and bump_commit(
|
moved = bump_commit(target, report, args.accept_changed)
|
||||||
target, report, args.accept_changed
|
if args.rebase_refs and applied and not moved:
|
||||||
)
|
|
||||||
if both and applied and not moved:
|
|
||||||
print(
|
print(
|
||||||
f"{name}: refs recaled but the pin will not follow, so nothing "
|
f"{name}: refs recaled but the pin will not follow, so nothing "
|
||||||
"was written; the prose or an annotated ref has to move first",
|
"was written; the prose or an annotated ref has to move first",
|
||||||
@@ -2783,7 +2809,13 @@ def _print_detection(args, profile: dict, report: ProfileReport, repo) -> None:
|
|||||||
|
|
||||||
|
|
||||||
def _print_extras(args, profile: dict, report: ProfileReport) -> None:
|
def _print_extras(args, profile: dict, report: ProfileReport) -> None:
|
||||||
"""Optional per-profile sections beyond the ref report."""
|
"""Optional per-profile sections beyond the ref report.
|
||||||
|
|
||||||
|
The ref report mutes a forge that refuses; the extras read the same
|
||||||
|
forge again, so a refusal here is reported on the profile's own line
|
||||||
|
instead of ending a pass over every profile. A quota signal still
|
||||||
|
stops everything.
|
||||||
|
"""
|
||||||
wants = (
|
wants = (
|
||||||
args.check_version
|
args.check_version
|
||||||
or args.detect_new_files
|
or args.detect_new_files
|
||||||
@@ -2796,7 +2828,15 @@ def _print_extras(args, profile: dict, report: ProfileReport) -> None:
|
|||||||
repo = select_repo(profile)
|
repo = select_repo(profile)
|
||||||
if repo is None:
|
if repo is None:
|
||||||
return
|
return
|
||||||
|
try:
|
||||||
|
_print_extras_from(args, profile, report, repo)
|
||||||
|
except upstream.RateLimitError:
|
||||||
|
raise
|
||||||
|
except upstream.UpstreamError as exc:
|
||||||
|
print(f" extras skipped: {exc}")
|
||||||
|
|
||||||
|
|
||||||
|
def _print_extras_from(args, profile: dict, report: ProfileReport, repo) -> None:
|
||||||
if args.check_version:
|
if args.check_version:
|
||||||
_print_version(args, profile, report, repo)
|
_print_version(args, profile, report, repo)
|
||||||
if args.detect_new_files or args.watch_hashes:
|
if args.detect_new_files or args.watch_hashes:
|
||||||
@@ -2855,6 +2895,9 @@ def _print_triage(args, selected: dict, reports: list[ProfileReport]) -> None:
|
|||||||
ranked.append((drift_score(report, version, commits), report))
|
ranked.append((drift_score(report, version, commits), report))
|
||||||
for score, report in sorted(ranked, key=lambda item: -item[0]):
|
for score, report in sorted(ranked, key=lambda item: -item[0]):
|
||||||
state = report.skipped or f"{report.needs_review()} to review"
|
state = report.skipped or f"{report.needs_review()} to review"
|
||||||
|
unchecked = (report.counts or {}).get("UNCHECKED", 0)
|
||||||
|
if unchecked and not report.skipped:
|
||||||
|
state += f", {unchecked} unchecked"
|
||||||
print(f"{score:5d} {report.name:30s} {state}")
|
print(f"{score:5d} {report.name:30s} {state}")
|
||||||
|
|
||||||
|
|
||||||
|
|||||||
@@ -36,6 +36,13 @@ _HOSTS: dict[str, tuple[str, str, str]] = {
|
|||||||
"https://raw.githubusercontent.com",
|
"https://raw.githubusercontent.com",
|
||||||
),
|
),
|
||||||
"gitlab.com": ("gitlab", "https://gitlab.com/api/v4", "https://gitlab.com"),
|
"gitlab.com": ("gitlab", "https://gitlab.com/api/v4", "https://gitlab.com"),
|
||||||
|
# libretro builds a growing share of its cores from its own GitLab, and
|
||||||
|
# several ports (wqxemu, cemu-libretro, libretro-radio) live only there.
|
||||||
|
"git.libretro.com": (
|
||||||
|
"gitlab",
|
||||||
|
"https://git.libretro.com/api/v4",
|
||||||
|
"https://git.libretro.com",
|
||||||
|
),
|
||||||
"codeberg.org": (
|
"codeberg.org": (
|
||||||
"forgejo",
|
"forgejo",
|
||||||
"https://codeberg.org/api/v1",
|
"https://codeberg.org/api/v1",
|
||||||
|
|||||||
+106
-4
@@ -58,6 +58,7 @@ from profile_sync import (
|
|||||||
watch_hashes,
|
watch_hashes,
|
||||||
worst_status,
|
worst_status,
|
||||||
)
|
)
|
||||||
|
import upstream
|
||||||
from upstream import CompareResult, FileChange
|
from upstream import CompareResult, FileChange
|
||||||
|
|
||||||
|
|
||||||
@@ -474,6 +475,17 @@ class TestVerifyAtPin(unittest.TestCase):
|
|||||||
profile_sync.verify_at_pin(part, None, ["deadbeef"]).status, "GONE"
|
profile_sync.verify_at_pin(part, None, ["deadbeef"]).status, "GONE"
|
||||||
)
|
)
|
||||||
|
|
||||||
|
def test_an_entry_with_nothing_to_look_for_is_unchecked_not_anchored(self):
|
||||||
|
"""race's analysis.npbios entry carries a `filename:` and no hash: at a
|
||||||
|
pin equal to HEAD its ref was reported anchored while the cited block
|
||||||
|
had been deleted upstream. No token means no verdict."""
|
||||||
|
part = RefPart("a.c", 3, 3, "a.c:3")
|
||||||
|
result = profile_sync.verify_at_pin(part, self.LINES, [])
|
||||||
|
self.assertEqual(result.status, "UNCHECKED")
|
||||||
|
self.assertNotIn("UNCHECKED", profile_sync.REVIEW_STATUSES)
|
||||||
|
self.assertEqual(profile_sync.worst_status(["ANCHORED", "UNCHECKED"]), "UNCHECKED")
|
||||||
|
self.assertEqual(profile_sync.worst_status(["UNCHECKED", "SHIFTED"]), "SHIFTED")
|
||||||
|
|
||||||
def test_line_beyond_the_file(self):
|
def test_line_beyond_the_file(self):
|
||||||
part = RefPart("a.c", 99, 99, "a.c:99")
|
part = RefPart("a.c", 99, 99, "a.c:99")
|
||||||
self.assertEqual(
|
self.assertEqual(
|
||||||
@@ -487,11 +499,13 @@ class TestVerifyAtPin(unittest.TestCase):
|
|||||||
"ANCHORED",
|
"ANCHORED",
|
||||||
)
|
)
|
||||||
|
|
||||||
def test_an_entry_declaring_nothing_is_accepted(self):
|
def test_an_entry_declaring_nothing_is_accepted_but_not_called_anchored(self):
|
||||||
|
# Nothing to re-read, so no review is demanded; but the verdict says
|
||||||
|
# that nothing was checked rather than claiming the ref anchors.
|
||||||
part = RefPart("a.c", 1, 1, "a.c:1")
|
part = RefPart("a.c", 1, 1, "a.c:1")
|
||||||
self.assertEqual(
|
result = profile_sync.verify_at_pin(part, self.LINES, [])
|
||||||
profile_sync.verify_at_pin(part, self.LINES, []).status, "ANCHORED"
|
self.assertEqual(result.status, "UNCHECKED")
|
||||||
)
|
self.assertNotIn(result.status, profile_sync.REVIEW_STATUSES)
|
||||||
|
|
||||||
|
|
||||||
class TestExternalCitation(unittest.TestCase):
|
class TestExternalCitation(unittest.TestCase):
|
||||||
@@ -1176,6 +1190,21 @@ class TestBuildReport(unittest.TestCase):
|
|||||||
"test", {"emulator": "T", "source": "https://www.6809.org.uk/"}, self.dir
|
"test", {"emulator": "T", "source": "https://www.6809.org.uk/"}, self.dir
|
||||||
)
|
)
|
||||||
self.assertIn("unsupported host", report.skipped)
|
self.assertIn("unsupported host", report.skipped)
|
||||||
|
self.assertEqual(report.unread, ["https://www.6809.org.uk/"])
|
||||||
|
|
||||||
|
def test_unsupported_source_is_named_when_upstream_answers(self):
|
||||||
|
profile = self._profile(["a.c:1"])
|
||||||
|
profile["source"] = "https://forge.example.invalid/o/n"
|
||||||
|
profile["upstream"] = "https://github.com/o/n"
|
||||||
|
self.files[("pinsha", "a.c")] = ["x"]
|
||||||
|
self.files[("headsha", "a.c")] = ["x"]
|
||||||
|
report = build_report("test", profile, self.dir)
|
||||||
|
self.assertIsNone(report.skipped)
|
||||||
|
self.assertEqual(report.unread, ["https://forge.example.invalid/o/n"])
|
||||||
|
self.assertIn(
|
||||||
|
"not read (unsupported host): https://forge.example.invalid/o/n",
|
||||||
|
format_report(report),
|
||||||
|
)
|
||||||
|
|
||||||
def test_profile_without_refs_is_reported_not_empty(self):
|
def test_profile_without_refs_is_reported_not_empty(self):
|
||||||
profile = {
|
profile = {
|
||||||
@@ -1705,6 +1734,49 @@ class TestElidedSummary(unittest.TestCase):
|
|||||||
self.assertEqual(buffer.getvalue(), "")
|
self.assertEqual(buffer.getvalue(), "")
|
||||||
|
|
||||||
|
|
||||||
|
class TestExtrasSurviveARefusingForge(unittest.TestCase):
|
||||||
|
"""A pass over every profile used to die on the first forge answering
|
||||||
|
403 to the detection reads, after the ref report had already muted it."""
|
||||||
|
|
||||||
|
def _args(self):
|
||||||
|
return argparse.Namespace(
|
||||||
|
check_version=False, detect_new_files=True, watch_hashes=False,
|
||||||
|
full_diff=False, tree_diff=False, cache_dir="x", offline=False,
|
||||||
|
ref=None, context=3,
|
||||||
|
)
|
||||||
|
|
||||||
|
def _profile(self):
|
||||||
|
return {"emulator": "T", "source": "https://github.com/o/n", "files": []}
|
||||||
|
|
||||||
|
def test_a_refusal_is_printed_on_the_profile_not_raised(self):
|
||||||
|
original = upstream.fetch_file
|
||||||
|
|
||||||
|
def refuse(*_args, **_kwargs):
|
||||||
|
raise upstream.UpstreamError("https://x/y: HTTP 403")
|
||||||
|
|
||||||
|
upstream.fetch_file = refuse
|
||||||
|
try:
|
||||||
|
buffer = io.StringIO()
|
||||||
|
with contextlib.redirect_stdout(buffer):
|
||||||
|
profile_sync._print_extras(self._args(), self._profile(), _sample_report())
|
||||||
|
finally:
|
||||||
|
upstream.fetch_file = original
|
||||||
|
self.assertIn("extras skipped: https://x/y: HTTP 403", buffer.getvalue())
|
||||||
|
|
||||||
|
def test_a_quota_signal_still_stops_the_pass(self):
|
||||||
|
original = upstream.fetch_file
|
||||||
|
|
||||||
|
def quota(*_args, **_kwargs):
|
||||||
|
raise upstream.RateLimitError("quota")
|
||||||
|
|
||||||
|
upstream.fetch_file = quota
|
||||||
|
try:
|
||||||
|
with self.assertRaises(upstream.RateLimitError):
|
||||||
|
profile_sync._print_extras(self._args(), self._profile(), _sample_report())
|
||||||
|
finally:
|
||||||
|
upstream.fetch_file = original
|
||||||
|
|
||||||
|
|
||||||
class TestReportToDict(unittest.TestCase):
|
class TestReportToDict(unittest.TestCase):
|
||||||
def test_round_trips_through_json(self):
|
def test_round_trips_through_json(self):
|
||||||
payload = report_to_dict(_sample_report())
|
payload = report_to_dict(_sample_report())
|
||||||
@@ -2152,6 +2224,36 @@ class TestWriteDryRun(unittest.TestCase):
|
|||||||
self.assertNotIn("would", output)
|
self.assertNotIn("would", output)
|
||||||
self.assertIn('source_ref: "a.c:30-32"', self.path.read_text())
|
self.assertIn('source_ref: "a.c:30-32"', self.path.read_text())
|
||||||
|
|
||||||
|
def test_a_recale_carries_the_pin(self):
|
||||||
|
"""Refs rewritten for HEAD describe HEAD, so the pin follows in the
|
||||||
|
same write. Left behind, the profile named one revision and cited
|
||||||
|
another, and the next pass read the new refs at the old pin."""
|
||||||
|
output = self._run(
|
||||||
|
self._args(rebase_refs=True, dry_run=False), self._shifted()
|
||||||
|
)
|
||||||
|
self.assertIn("source_commit -> newhead", output)
|
||||||
|
self.assertIn('source_commit: "newhead"', self.path.read_text())
|
||||||
|
|
||||||
|
def test_a_recale_the_pin_cannot_follow_writes_nothing(self):
|
||||||
|
"""One ref recales, another is CHANGED and unread: neither the refs
|
||||||
|
nor the pin move, and the file is left as it was."""
|
||||||
|
before = self.path.read_text()
|
||||||
|
shifted = PartResult(
|
||||||
|
RefPart("a.c", 10, 12, "a.c:10-12"), "SHIFTED", None, 30, 32, []
|
||||||
|
)
|
||||||
|
changed = PartResult(RefPart("b.c", 5, 5, "b.c:5"), "CHANGED", None, 7, 7, [])
|
||||||
|
report = ProfileReport(
|
||||||
|
name="p", repo="o/n", pin="pin", head="newhead",
|
||||||
|
entries=[
|
||||||
|
EntryReport("a.bin", "a.c:10-12", "SHIFTED", [shifted]),
|
||||||
|
EntryReport("b.bin", "b.c:5", "CHANGED", [changed]),
|
||||||
|
],
|
||||||
|
counts={"SHIFTED": 1, "CHANGED": 1},
|
||||||
|
)
|
||||||
|
output = self._run(self._args(rebase_refs=True, dry_run=False), report)
|
||||||
|
self.assertNotIn("source_commit", output)
|
||||||
|
self.assertEqual(self.path.read_text(), before)
|
||||||
|
|
||||||
def test_a_planned_bump_reads_the_prose_the_rebase_would_have_left(self):
|
def test_a_planned_bump_reads_the_prose_the_rebase_would_have_left(self):
|
||||||
"""The pin is blocked by prose until the rebase moves it.
|
"""The pin is blocked by prose until the rebase moves it.
|
||||||
|
|
||||||
|
|||||||
@@ -53,6 +53,12 @@ class TestParseRepo(unittest.TestCase):
|
|||||||
repo = parse_repo("https://gitlab.com/recalbox/recalbox")
|
repo = parse_repo("https://gitlab.com/recalbox/recalbox")
|
||||||
self.assertEqual(repo.family, "gitlab")
|
self.assertEqual(repo.family, "gitlab")
|
||||||
|
|
||||||
|
def test_libretro_gitlab_is_a_gitlab(self):
|
||||||
|
repo = parse_repo("https://git.libretro.com/libretro/wqxemu.git")
|
||||||
|
self.assertEqual(repo.family, "gitlab")
|
||||||
|
self.assertEqual(repo.api_base, "https://git.libretro.com/api/v4")
|
||||||
|
self.assertEqual(repo.slug, "libretro/wqxemu")
|
||||||
|
|
||||||
def test_codeberg_is_forgejo(self):
|
def test_codeberg_is_forgejo(self):
|
||||||
self.assertEqual(parse_repo("https://codeberg.org/a/b").family, "forgejo")
|
self.assertEqual(parse_repo("https://codeberg.org/a/b").family, "forgejo")
|
||||||
|
|
||||||
|
|||||||
Reference in new issue
Block a user