version_port.py publish: the report's work items as GitHub issues, a dry run unless --yes
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
+24
-6
@@ -245,11 +245,27 @@ summary counts pass / oracle / fail / newly failing.
|
||||
files and harnesses, the data-diff excerpts that concern it, and the local path of its source diffs.
|
||||
3. Everything regenerated cleanly, so the reader knows what not to look at.
|
||||
|
||||
`version_port.py publish` opens one GitHub issue per item of ranks 1 to 3 (labels `version-port` and
|
||||
`port-NEW`, title `[NEW] <area>: <summary>`; an open issue with that title is updated instead), with the structured
|
||||
diff attached (names, signatures, data-report diffs, harness output; no decompiled code), and a Kira card per issue
|
||||
(`card_add` with the issue URL, column `todo`) so contributors and agents can claim them. Rank 4 and 5 go into one
|
||||
tracking issue.
|
||||
`version_port.py publish OLD NEW` (`tools/port_publish.py`) files the report as GitHub issues. **It is a dry run
|
||||
by default**: it prints every issue it would create or update and the first bodies (`--show N`; `--check-github`
|
||||
reads the open issues and labels, read-only, to tell creates from updates). `--yes` does it; the first `--yes` run
|
||||
and the `version-port` label are Ludovic's call.
|
||||
|
||||
- **One issue per 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 one of the changed methods: title `[NEW] <item>`, labels
|
||||
`version-port` and `port-NEW`. An open issue with the same title is updated, and only its generated part
|
||||
(between `<!-- version-port: generated -->` markers) is replaced: what people wrote around it stays.
|
||||
- **One tracking issue**, `[NEW] Port to Minecraft NEW`: the facts, the harness counts, a checklist of the item
|
||||
issues (made after them, so it has their numbers), and every other item: rank-2 leads no harness confirmed, rank-3
|
||||
classes Horizon names but none of whose changed methods, new content, unported changes.
|
||||
- **Kira**: the board already shows every open issue as a card in To do, with its labels, so contributors and agents
|
||||
claim them there (`task_claim`); `publish` adds one Kira card for the port itself, linking the tracking issue
|
||||
(`~/code/horizon-parity/tools/kira.mjs card_add`, skipped if the board has it).
|
||||
- **No decompiled code, ever**: bodies hold names and signatures only (vanilla classes, methods with their JVM
|
||||
descriptors, fields, packets, registry entries, harness names, javac's one-line errors about Horizon's own oracles,
|
||||
Horizon paths, and the local path of the diff). Every vanilla-derived string is quoted, and `assert_no_code`
|
||||
refuses a body with a code block, or a line or quoted span that reads like Java (declarations, statements, braces).
|
||||
|
||||
For 26.2 → 26.3 the dry run would file 48 issues (5 of rank 1, 43 of rank 3) and the tracking issue.
|
||||
|
||||
### The switch and the release
|
||||
|
||||
@@ -357,6 +373,7 @@ python3 tools/version_port.py diff 26.3 26.4 # summary; the full diff in
|
||||
python3 tools/version_port.py regen 26.4 # ports/26.4 on port/26.4: pinned, every table regenerated
|
||||
python3 tools/version_port.py report 26.3 26.4 [--results .local/parity-runner/state.json] # report.md, report.json
|
||||
python3 tools/version_port.py harnesses 26.4 --baseline .local/parity-runner/state.json # in ports/26.4
|
||||
python3 tools/version_port.py publish 26.3 26.4 # dry run: the issues it would file (--yes files them)
|
||||
python3 tools/version_port.py port 26.4 # prepare both, diff, regen, report
|
||||
python3 tools/version_port.py gc # keep the pinned version and the newest two others
|
||||
python3 tools/vanilla_map.py versions/26.3/classes.json net.minecraft.world.food.FoodData # its Rust files and harnesses
|
||||
@@ -372,4 +389,5 @@ python3 tools/vanilla_map.py versions/26.3/classes.json net.minecraft.world.food
|
||||
| Tools read the version from the lock (`lab.VERSION`, `lab.GAME`, `lab.RESOURCES`, `lab.version_info()`) | done |
|
||||
| `regen` (`tools/port_regen.py`): candidate worktree, its `.local/`, the generators' classes decompiled alone, every generator, `regen.json` in the report | done |
|
||||
| Candidate harness run (`tools/port_harness.py`, `harnesses`): horizon-prepare with sounds, every harness, pass / oracle / fail, newly failing against a baseline, in the report | done |
|
||||
| `publish`, launchd agent | later |
|
||||
| `publish` (`tools/port_publish.py`): dry run by default, issues, tracking issue, Kira card | done (never run with `--yes`) |
|
||||
| launchd agent | later |
|
||||
|
||||
@@ -0,0 +1,98 @@
|
||||
from pathlib import Path
|
||||
import unittest
|
||||
from unittest import mock
|
||||
|
||||
from tools import port_publish as publish
|
||||
|
||||
DIFF = {"classes": {"changed": {"net/minecraft/world/food/FoodData": {
|
||||
"methods": {"added": [], "removed": [], "changed": ["tick(Lnet/minecraft/server/level/ServerPlayer;)V"]},
|
||||
"fields": {"added": ["exhaustionLevel F"], "removed": [], "changed": []}}}}}
|
||||
|
||||
|
||||
def items():
|
||||
return [
|
||||
{"rank": 1, "kind": "protocol", "title": "Protocol: packets play/clientbound +1 -0 ~3",
|
||||
"files": ["crates/horizon-protocol/src/ids.rs"], "details": {"packets": {"play/clientbound": {
|
||||
"added": {"minecraft:punch": 3}, "removed": {}, "renumbered": {"minecraft:x": [1, 2]}}}}},
|
||||
{"rank": 2, "kind": "rerun", "title": "crates/w/src/food.rs: 1 vanilla classes changed", "harnesses": ["food"],
|
||||
"failed": [], "classes": {}},
|
||||
{"rank": 2, "kind": "harness-failed", "title": "Harness light: fails on 26.4", "failed": ["light"],
|
||||
"oracle": [], "harnesses": ["light"]},
|
||||
{"rank": 3, "kind": "unproved", "title": "crates/w/src/food.rs: 1 vanilla classes changed (1 methods, 1 named)",
|
||||
"files": ["crates/w/src/food.rs"], "classes": {"net/minecraft/world/food/FoodData": {
|
||||
"methods": 1, "named": ["tick"], "removed": False}}},
|
||||
{"rank": 3, "kind": "unproved", "title": "crates/c/src/sky.rs: 1 vanilla classes changed (2 methods, 0 named)",
|
||||
"classes": {"net/minecraft/client/Sky": {"methods": 2, "named": [], "removed": False}}},
|
||||
{"rank": 4, "kind": "content", "title": "New block: 2 entries", "entries": ["minecraft:a", "minecraft:b"]},
|
||||
]
|
||||
|
||||
|
||||
REPORT = {"old": "26.3", "new": "26.4", "summary": {"facts": {"protocol_version": [777, 780]}}, "items": items()}
|
||||
|
||||
|
||||
class PublishTests(unittest.TestCase):
|
||||
def test_which_items_get_an_issue(self):
|
||||
self.assertEqual([publish.published(i) for i in items()], [True, False, True, True, False, False])
|
||||
|
||||
def test_body_has_names_and_signatures_only(self):
|
||||
text = publish.body(items()[3], REPORT, DIFF, Path.home() / "code/horizon-parity/versions/diff-26.3-26.4")
|
||||
self.assertIn("`net.minecraft.world.food.FoodData`: 1 methods changed; Horizon's comments name tick", text)
|
||||
self.assertIn("methods changed: `tick(Lnet/minecraft/server/level/ServerPlayer;)V`", text)
|
||||
self.assertIn("fields added: `exhaustionLevel F`", text)
|
||||
self.assertIn("`~/code/horizon-parity/versions/diff-26.3-26.4`", text)
|
||||
self.assertTrue(text.startswith(publish.START) and text.rstrip().endswith(publish.END))
|
||||
|
||||
def test_refuses_code(self):
|
||||
for line in ["```java\nint x = 1;\n```", " return this.foodLevel;", "public void tick(Player player) {",
|
||||
"- `if (x) { y(); }`", "import net.minecraft.world.food.FoodData;"]:
|
||||
with self.assertRaises(ValueError, msg=line):
|
||||
publish.assert_no_code(line)
|
||||
publish.assert_no_code("- `<init>(Lnet/minecraft/core/RegistryAccess;Lnet/minecraft/world/Foo;)V`, `a()V`")
|
||||
publish.assert_no_code("- `ParityData.java:45: cannot find symbol: variable WORLD (class RegistryLayer)`")
|
||||
|
||||
def test_update_keeps_what_people_wrote(self):
|
||||
existing = f"Notes from a contributor.\n{publish.START}\nold\n{publish.END}\nMore notes."
|
||||
self.assertEqual(publish.merged(existing, f"{publish.START}\nnew\n{publish.END}\n"),
|
||||
f"Notes from a contributor.\n{publish.START}\nnew\n{publish.END}\nMore notes.")
|
||||
self.assertEqual(publish.merged("no markers", "generated"), "generated")
|
||||
|
||||
def test_plan_creates_updates_and_tracks(self):
|
||||
name = "[26.4] Protocol: packets play/clientbound +1 -0 ~3"
|
||||
actions = publish.plan(REPORT, DIFF, Path("/tmp/d"), {name: (12, "old body")})
|
||||
self.assertEqual([(a, t) for a, t, _, _ in actions], [
|
||||
("update", name), ("create", "[26.4] Harness light: fails on 26.4"),
|
||||
("create", "[26.4] crates/w/src/food.rs: 1 vanilla classes changed (1 methods, 1 named)"),
|
||||
("create", "[26.4] Port to Minecraft 26.4")])
|
||||
tracking = actions[-1][2]
|
||||
self.assertIn("- [ ] #12 " + name, tracking)
|
||||
self.assertIn("- rank 4: New block: 2 entries", tracking)
|
||||
|
||||
def test_apply_lists_the_new_issue_numbers_in_the_tracking_issue(self):
|
||||
calls = []
|
||||
|
||||
def gh(*args):
|
||||
calls.append(args)
|
||||
if args[:2] == ("label", "list"):
|
||||
return "version-port\nport-26.4\n"
|
||||
if args[:2] == ("issue", "create"):
|
||||
return f"https://github.com/o/r/issues/{100 + sum(1 for c in calls if c[:2] == ('issue', 'create'))}\n"
|
||||
return ""
|
||||
|
||||
actions = publish.plan(REPORT, DIFF, Path("/tmp/d"), {})
|
||||
with mock.patch.object(publish, "gh", side_effect=gh):
|
||||
bodies = {}
|
||||
real = publish.issue
|
||||
|
||||
def issue(action, name, text, number, new):
|
||||
bodies[name] = text
|
||||
return real(action, name, text, number, new)
|
||||
|
||||
with mock.patch.object(publish, "issue", side_effect=issue):
|
||||
urls = publish.apply(actions, REPORT, {})
|
||||
self.assertEqual(urls["[26.4] Port to Minecraft 26.4"], "https://github.com/o/r/issues/104")
|
||||
self.assertIn("- [ ] #101 [26.4] Protocol", bodies["[26.4] Port to Minecraft 26.4"])
|
||||
self.assertFalse([c for c in calls if c[:2] == ("label", "create")])
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
unittest.main()
|
||||
@@ -0,0 +1,251 @@
|
||||
#!/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] <item>" 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 = "<!-- version-port: generated -->", "<!-- /version-port -->"
|
||||
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("<!--") or not prose:
|
||||
continue
|
||||
if CODE.search(prose):
|
||||
raise ValueError(f"Refusing to publish a line that reads like source code: {line[:120]!r}")
|
||||
|
||||
|
||||
def merged(existing, generated):
|
||||
"""An issue's body with its generated part replaced, the rest kept."""
|
||||
if existing and START in existing and END in existing:
|
||||
head, rest = existing.split(START, 1)
|
||||
return head + generated.strip() + rest.split(END, 1)[1]
|
||||
return generated
|
||||
|
||||
|
||||
def tracking_body(report, issues, others, limit=200):
|
||||
lines = [START, f"Port of Horizon to Minecraft {report['new']} (from {report['old']}): `tools/version_port.py "
|
||||
f"report {report['old']} {report['new']}` (docs/version-ports.md).", ""]
|
||||
summary = report["summary"]
|
||||
lines += [f"- {key}: {a} → {b}" for key, (a, b) in summary["facts"].items()]
|
||||
if report.get("harnesses"):
|
||||
h = report["harnesses"]
|
||||
lines.append(f"- harnesses on {report['new']}: {h['pass']} pass, {h['oracle']} oracle no longer compiles, "
|
||||
f"{h['fail']} fail; {h['new']} newly failing")
|
||||
lines += ["", "Work items with their own issue:"]
|
||||
lines += [f"- [ ] {ref} {title}" for title, ref in issues] or ["- none"]
|
||||
lines += ["", "Other items (leads, new content, unported changes):"]
|
||||
lines += [f"- rank {i['rank']}: {i['title']}" for i in others[:limit]]
|
||||
if len(others) > limit:
|
||||
lines.append(f"- … and {len(others) - limit} more in report.md")
|
||||
lines.append(END)
|
||||
text = "\n".join(lines) + "\n"
|
||||
assert_no_code(text)
|
||||
return text
|
||||
|
||||
|
||||
def plan(report, diff, store_path, open_issues):
|
||||
"""[(action, title, body, number)]: "create" or "update" for each published item, then the tracking issue.
|
||||
open_issues: {title: (number, body)} of the open version-port issues."""
|
||||
new = report["new"]
|
||||
actions, others = [], []
|
||||
for item in report["items"]:
|
||||
if not published(item):
|
||||
others.append(item)
|
||||
continue
|
||||
name = title(new, item)
|
||||
number, existing = open_issues.get(name, (None, None))
|
||||
actions.append(("update" if number else "create", name, merged(existing, body(item, report, diff, store_path)),
|
||||
number))
|
||||
tracking = f"[{new}] Port to Minecraft {new}"
|
||||
refs = [(name, f"#{number}" if number else "(new)") for _, name, _, number in actions]
|
||||
number, existing = open_issues.get(tracking, (None, None))
|
||||
actions.append(("update" if number else "create", tracking,
|
||||
merged(existing, tracking_body(report, refs, others)), number))
|
||||
return actions
|
||||
|
||||
|
||||
# --- GitHub and Kira (only with --yes) ---------------------------------------------------------------------
|
||||
|
||||
def gh(*args):
|
||||
return subprocess.run(["gh", *args], capture_output=True, text=True, check=True).stdout
|
||||
|
||||
|
||||
def open_version_port_issues():
|
||||
found = json.loads(gh("issue", "list", "--label", LABEL, "--state", "open", "--limit", "1000",
|
||||
"--json", "number,title,body"))
|
||||
return {issue["title"]: (issue["number"], issue["body"]) for issue in found}
|
||||
|
||||
|
||||
def missing_labels(new):
|
||||
existing = set(gh("label", "list", "--limit", "500", "--json", "name", "--jq", ".[].name").split("\n"))
|
||||
return [label for label in (LABEL, f"port-{new}") if label not in existing]
|
||||
|
||||
|
||||
def apply(actions, report, open_issues):
|
||||
"""Create the labels, then each item's issue, then the tracking issue listing them; returns {title: url}."""
|
||||
new = report["new"]
|
||||
for label in missing_labels(new):
|
||||
gh("label", "create", label, "--color", "5319e7",
|
||||
"--description", "Work to port Horizon to a new Minecraft release (tools/version_port.py publish)")
|
||||
*items, tracking = actions
|
||||
urls = {name: issue(action, name, text, number, new) for action, name, text, number in items}
|
||||
refs = [(name, "#" + urls[name].rsplit("/", 1)[-1]) for _, name, _, _ in items]
|
||||
others = [i for i in report["items"] if not published(i)]
|
||||
action, name, _, number = tracking
|
||||
text = merged(open_issues.get(name, (None, None))[1], tracking_body(report, refs, others))
|
||||
urls[name] = issue(action, name, text, number, new)
|
||||
return urls
|
||||
|
||||
|
||||
def issue(action, name, text, number, new):
|
||||
"""Create or update one issue; its URL."""
|
||||
with tempfile.NamedTemporaryFile("w", suffix=".md", delete=False) as stream:
|
||||
stream.write(text)
|
||||
try:
|
||||
if action == "create":
|
||||
url = gh("issue", "create", "--title", name, "--body-file", stream.name, "--label", LABEL,
|
||||
"--label", f"port-{new}").strip()
|
||||
else:
|
||||
gh("issue", "edit", str(number), "--body-file", stream.name)
|
||||
url = gh("issue", "view", str(number), "--json", "url", "--jq", ".url").strip()
|
||||
finally:
|
||||
os.unlink(stream.name)
|
||||
print(f"{action}d {url} {name}", flush=True)
|
||||
return url
|
||||
|
||||
|
||||
def kira(tool, arguments):
|
||||
return json.loads(subprocess.run(["node", str(KIRA), tool, json.dumps(arguments)], capture_output=True,
|
||||
text=True, check=True).stdout or "null")
|
||||
|
||||
|
||||
def kira_card(new, url):
|
||||
"""One Kira card for the port, linking the tracking issue, unless the board has it already."""
|
||||
board = kira("board_list", {"label": LABEL, "kind": "card", "limit": 100}) or {}
|
||||
if any(card.get("url") == url or url in json.dumps(card.get("url")) for card in board.get("cards", [])):
|
||||
return None
|
||||
return kira("card_add", {"title": f"Port Horizon to Minecraft {new}", "url": url, "column": "todo",
|
||||
"labels": [LABEL]})
|
||||
+41
-2
@@ -14,6 +14,8 @@
|
||||
oracle (no longer compiles) or fail, and newly failing against
|
||||
a run on the pinned version (tools/port_harness.py)
|
||||
version_port.py report OLD NEW [--results S] the ranked port report: diff-OLD-NEW/report.md and .json
|
||||
version_port.py publish OLD NEW [--yes] the report's work items as GitHub issues (tools/port_publish.py);
|
||||
a dry run without --yes
|
||||
version_port.py port NEW [--old OLD] prepare both, diff, regen, report
|
||||
version_port.py gc [--keep VERSION...] keep the pinned, the newest two others and --keep
|
||||
|
||||
@@ -39,11 +41,12 @@ import urllib.request
|
||||
import zipfile
|
||||
|
||||
try:
|
||||
from . import class_index, lab, port_harness, port_regen, port_report, vanilla_map
|
||||
from . import class_index, lab, port_harness, port_publish, port_regen, port_report, vanilla_map
|
||||
except ImportError:
|
||||
import class_index
|
||||
import lab
|
||||
import port_harness
|
||||
import port_publish
|
||||
import port_regen
|
||||
import port_report
|
||||
import vanilla_map
|
||||
@@ -617,6 +620,35 @@ def report(args):
|
||||
return 0
|
||||
|
||||
|
||||
# --- publish -----------------------------------------------------------------------------------------------
|
||||
|
||||
def publish(args):
|
||||
directory = diff_dir(args.old, args.new)
|
||||
if not (directory / "report.json").exists():
|
||||
raise SystemExit(f"No report yet; run version_port.py report {args.old} {args.new}")
|
||||
built = json.loads((directory / "report.json").read_text())
|
||||
result = json.loads((directory / "diff.json").read_text())
|
||||
issues = port_publish.open_version_port_issues() if args.yes or args.check_github else {}
|
||||
actions = port_publish.plan(built, result, directory, issues)
|
||||
creates = sum(1 for a in actions if a[0] == "create")
|
||||
if not args.yes:
|
||||
for action, name, text, number in actions:
|
||||
print(f"would {action}{f' #{number}' if number else ''}: {name}")
|
||||
for action, name, text, number in actions[:args.show]:
|
||||
print(f"\n--- {name}\n{text}")
|
||||
missing = port_publish.missing_labels(args.new) if args.check_github else [port_publish.LABEL,
|
||||
f"port-{args.new}"]
|
||||
print(f"\nDry run: {creates} issues to create, {len(actions) - creates} to update"
|
||||
f"{'' if args.check_github else ' (open issues not checked: --check-github)'}; labels to create: "
|
||||
f"{', '.join(missing) or 'none'}; then one Kira card for the tracking issue. Rerun with --yes to do it.")
|
||||
return 0
|
||||
urls = port_publish.apply(actions, built, issues)
|
||||
tracking = urls[actions[-1][1]]
|
||||
card = port_publish.kira_card(args.new, tracking)
|
||||
print(f"Tracking issue {tracking}" + (f"; Kira card {card}" if card else "; Kira card already there"))
|
||||
return 0
|
||||
|
||||
|
||||
def port(args):
|
||||
"""prepare both, diff, regen, report."""
|
||||
old = args.old or pinned()
|
||||
@@ -691,6 +723,13 @@ def main():
|
||||
command.add_argument("--results", help="tools/parity.py's state.json from a run against NEW (default: "
|
||||
"harnesses.json, when version_port.py harnesses ran)")
|
||||
command.add_argument("--lines", type=int, default=60, help="how much of report.md to print")
|
||||
command = commands.add_parser("publish", help="the report's work items as GitHub issues (dry run by default)")
|
||||
command.add_argument("old")
|
||||
command.add_argument("new")
|
||||
command.add_argument("--yes", action="store_true", help="create and update the issues, labels and Kira card")
|
||||
command.add_argument("--check-github", action="store_true",
|
||||
help="dry run: read the open issues and labels to tell creates from updates")
|
||||
command.add_argument("--show", type=int, default=1, help="dry run: print this many issue bodies")
|
||||
command = commands.add_parser("port", help="prepare, diff and report in one go")
|
||||
command.add_argument("new")
|
||||
command.add_argument("--old", help="default: the pinned version")
|
||||
@@ -705,7 +744,7 @@ def main():
|
||||
args = parser.parse_args()
|
||||
STORE.mkdir(parents=True, exist_ok=True)
|
||||
return {"detect": detect, "prepare": prepare, "diff": diff, "regen": regen, "harnesses": harnesses,
|
||||
"report": report, "port": port, "gc": gc}[args.command](args)
|
||||
"report": report, "publish": publish, "port": port, "gc": gc}[args.command](args)
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
|
||||
Reference in New Issue
Block a user