#!/usr/bin/env python3 """File a port report's work items as GitHub issues (docs/version-ports.md, "The port report"). Used through `version_port.py publish OLD NEW [--yes]`. A dry run by default: it prints what it would create or update; --yes does it (labels, issues, the Kira card). The maintainer approves the first --yes run and the `version-port` label. - One issue per work item of rank 1, of rank 2 when a harness newly fails on NEW (or its oracle no longer compiles), and of rank 3 when Horizon's comments name a changed method, titled "[NEW] " and labelled `version-port` and `port-NEW`. An open issue with the same title is updated: only the generated part of its body (between the markers) is replaced, so what people wrote around it stays. - One tracking issue, "[NEW] Port to Minecraft NEW", listing those issues and every other item (rank-2 leads no harness confirmed, new content, unported changes). - Kira shows every open issue as a card in To do, labelled, so contributors and agents can claim them there; publish adds one Kira card for the port itself, linking the tracking issue. Bodies hold names and signatures only: vanilla classes, methods with their descriptors, fields, registry entries, packets, harness names, javac's one-line errors (about Horizon's own oracles), Horizon file paths and the local path of the diff. Never decompiled code: `assert_no_code` refuses a body with a line that reads like Java source. """ import json import os from pathlib import Path import re import subprocess import tempfile try: from . import port_report except ImportError: import port_report LABEL = "version-port" START, END = "", "" KIRA = Path(os.environ.get("HORIZON_KIRA", Path.home() / "code/horizon-parity/tools/kira.mjs")) RANKS = {1: "blocks the switch", 2: "proved by a harness", 3: "ported, no harness", 4: "new content", 5: "not ported"} # A line of Java source: a declaration, a statement, an import (what decompiled code looks like). CODE = re.compile(r"^\s*(?:(?:public|private|protected|static|final|abstract)\s+[\w<>\[\], ]+[\s(]" r"|(?:return|throw|import|package)\s+\S|(?:if|for|while|switch)\s*\(|[}{]\s*$)|;\s*$") # Inside backticks: names, JVM descriptors (`m(Lx/Y;I)V`, no spaces) and javac's one-line errors, never braces, # statements or assignments. QUOTED_CODE = re.compile(r"[{}]|;\s+\S|\s=\s|\breturn\s|\bnew\s+[A-Z]") def published(item): """Whether an item gets its own issue: rank 1; rank 2 when a harness newly fails; rank 3 when Horizon's comments name one of the changed methods (the others are leads, listed in the tracking issue).""" if item["rank"] == 3: return any(info.get("named") for info in (item.get("classes") or {}).values()) return item["rank"] == 1 or (item["rank"] == 2 and bool(item.get("failed"))) def title(new, item): return f"[{new}] {item['title']}"[:240] def signatures(name, change, limit=20): """A changed class's members by name and descriptor (from the class index: no code).""" lines = [] for kind in ("methods", "fields"): for label in ("added", "removed", "changed"): members = change.get(kind, {}).get(label, []) if members: shown = ", ".join(f"`{m}`" for m in members[:limit]) more = f" (+{len(members) - limit})" if len(members) > limit else "" lines.append(f" - {kind} {label}: {shown}{more}") return lines def body(item, report, diff, store_path, limit=25): """The generated part of an item's issue.""" old, new = report["old"], report["new"] lines = [START, f"Minecraft {old} → {new}, rank {item['rank']} ({RANKS[item['rank']]}), from " f"`tools/version_port.py report {old} {new}` (docs/version-ports.md).", ""] if item.get("command"): lines.append(f"Run: `{item['command']}`") if item.get("compile_errors"): lines += ["javac, against " + new + ":", *(f"- `{e}`" for e in item["compile_errors"])] if item.get("failed"): lines.append(f"Harnesses failing on {new}: " + ", ".join(f"`{h}`" for h in item["failed"]) + (f" (oracle no longer compiles: {', '.join(item['oracle'])})" if item.get("oracle") else "")) if item.get("harnesses"): lines.append("Harnesses that prove it: " + ", ".join(f"`{h}`" for h in item["harnesses"][:limit])) if item.get("files"): lines.append("Horizon: " + ", ".join(f"`{f}`" for f in item["files"][:limit]) + (f" (+{len(item['files']) - limit})" if len(item["files"]) > limit else "")) classes = item.get("classes") if isinstance(classes, dict): lines += ["", "Vanilla classes (names and signatures):"] changed = (diff.get("classes") or {}).get("changed", {}) for name, info in list(classes.items())[:limit]: what = "removed" if info["removed"] else f"{info['methods']} methods changed" named = f"; Horizon's comments name {', '.join(info['named'])}" if info["named"] else "" lines.append(f"- `{name.replace('/', '.')}`: {what}{named}") lines += signatures(name, changed.get(name, {})) elif classes: lines.append("New classes: " + ", ".join(f"`{c.replace('/', '.')}`" for c in classes[:limit])) if item.get("entries"): lines.append("Entries: " + ", ".join(f"`{e}`" for e in item["entries"][:limit])) details = item.get("details", {}) if item["kind"] == "protocol": for flow, change in details["packets"].items(): lines.append(f"- {flow}: added {', '.join(change['added']) or 'none'}; removed " f"{', '.join(change['removed']) or 'none'}; {len(change['renumbered'])} renumbered") for key in ("schemas", "fixes"): if details.get(key): lines.append(f"{key.capitalize()}: " + ", ".join(f"`{port_report.short(n)}`" for n in details[key])) if item["kind"] == "components": types = details["types"] lines += [f"Types {label}: " + ", ".join(f"`{t}`" for t in types.get(label, [])) for label in ("added", "removed") if types.get(label)] lines += [f"- `{c}`: " + ", ".join(f"{k} on {n} items" for k, n in counts.items() if n) for c, counts in sorted(details["items"].items(), key=lambda kv: -sum(kv[1].values()))[:limit]] if item["kind"] == "facts": lines += [f"- {key}: {a} → {b}" for key, (a, b) in details.items()] where = str(store_path).replace(str(Path.home()), "~") lines += ["", f"The full diff stays on the maintainer's machine (`{where}`), with the vanilla files it was built " "from: they are never published.", END] text = "\n".join(lines) + "\n" assert_no_code(text) return text def assert_no_code(text): """Every name and signature goes in `backticks`, one line at most; what is left must not read like Java.""" if "```" in text: raise ValueError("Refusing to publish a code block") for line in text.splitlines(): if any(QUOTED_CODE.search(span) for span in re.findall(r"`([^`]*)`", line)): raise ValueError(f"Refusing to publish a quoted span that reads like source code: {line[:120]!r}") prose = re.sub(r"`[^`]*`", "", line).strip(" -*,;:()") if line.startswith("