From 9479babfe998d362a5f018354aa7a770daf3c572 Mon Sep 17 00:00:00 2001 From: naudachu Date: Mon, 10 Aug 2026 19:55:10 +0500 Subject: [PATCH] feat: implement the wiki field in the issue domain MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit format.md listed `wiki:` among the domain fields, between depends and origin, and issue.py had never heard of it. The field fell into extra and rendered with the foreign keys — sorted in after the sync fields, which the same document forbids one line below the table. Written without brackets it parsed as a single string, and nothing but a text editor could set it. Implemented rather than de-documented: page_ls.py --titles already prints these titles, so the field was designed and only unwired. DOMAIN_KEYS and LIST_KEYS learn it, Issue carries it, and issue_new.py gets a repeatable --wiki flag. Titles only, as the format says: no path, no sub_url, no lookup. The tracker has no field for it, so it is never sent and a pull does not bring it back — format.md now says so. Closes #32 Co-Authored-By: Claude Opus 5 (1M context) --- skills/issue/references/format.md | 2 +- skills/issue/scripts/issue.py | 17 +++- skills/issue/scripts/issue_new.py | 4 +- tests/test_wiki_field.py | 148 ++++++++++++++++++++++++++++++ 4 files changed, 164 insertions(+), 7 deletions(-) create mode 100644 tests/test_wiki_field.py diff --git a/skills/issue/references/format.md b/skills/issue/references/format.md index 6869e94..63a4fa0 100644 --- a/skills/issue/references/format.md +++ b/skills/issue/references/format.md @@ -64,7 +64,7 @@ url: https://git.noodles.cam/claude-skills/tea/issues/42 | `assignees` | domain | logins; may be empty | | `milestone` | domain | title, or `none` | | `depends` | domain | ids this issue depends on — **the authoritative graph** | -| `wiki` | domain | page **titles** this issue is written up in; may be empty. Titles, not URLs — a title is a name for a document and stays in this layer, a URL is tracker bookkeeping. `/tea:page` owns what those titles mean; `page_ls.py --titles` prints them | +| `wiki` | domain | page **titles** this issue is written up in; may be empty. Titles, not URLs — a title is a name for a document and stays in this layer, a URL is tracker bookkeeping. `/tea:page` owns what those titles mean; `page_ls.py --titles` prints them. Set it with `issue_new.py --wiki ""` (repeatable) or by editing the line. The tracker has no field for it, so it is never sent — and a pull, which merges nothing but checkbox state, does not bring it back | | `origin` | domain | `local`, or the name of a tracker this also lives in | | `gitea` | sync | the handle in that tracker: `owner/repo#N` | | `branch` | sync | the tracker's branch link (Gitea `ref`); push fills an empty one with the current git branch, and never overwrites a filled one | diff --git a/skills/issue/scripts/issue.py b/skills/issue/scripts/issue.py index b52e079..4a13c4c 100644 --- a/skills/issue/scripts/issue.py +++ b/skills/issue/scripts/issue.py @@ -25,6 +25,7 @@ domain has. The file name is the id: assignees: [naudachu] milestone: v0.2 depends: [migrate-schema] + wiki: [Simple Chains/Ideas/Chain core] origin: gitea gitea: owner/repo#42 synced: 2026-08-07T18:40:00Z @@ -113,8 +114,9 @@ ISSUE_ROOT = store_root() # Domain-owned metadata, in render order. Foreign keys render after these, # sorted, so the sync layer can add fields without touching this list. -DOMAIN_KEYS = ["id", "state", "labels", "assignees", "milestone", "depends", "origin"] -LIST_KEYS = {"labels", "assignees", "depends"} +DOMAIN_KEYS = ["id", "state", "labels", "assignees", "milestone", "depends", + "wiki", "origin"] +LIST_KEYS = {"labels", "assignees", "depends", "wiki"} STATES = ("open", "closed") # `origin` is "does this issue exist anywhere but here" — a fact about the @@ -247,8 +249,8 @@ class Issue(object): """One unit of work. `extra` holds metadata this layer does not own.""" def __init__(self, id="", title="", body="", state="open", labels=None, - assignees=None, milestone="", depends=None, origin=LOCAL, - extra=None): + assignees=None, milestone="", depends=None, wiki=None, + origin=LOCAL, extra=None): self.id = id self.title = title self.body = body @@ -257,6 +259,10 @@ class Issue(object): self.assignees = list(assignees or []) self.milestone = milestone or "" self.depends = list(depends or []) + # Page TITLES this work is written up in — names for documents, which + # is why they are domain-owned. What a title resolves to is /tea:page's + # business, and this layer never asks: no path, no URL, no lookup. + self.wiki = list(wiki or []) self.origin = origin or LOCAL self.extra = dict(extra or {}) @@ -302,7 +308,7 @@ class Issue(object): state=meta.get("state") or "open", labels=lst("labels"), assignees=lst("assignees"), milestone="" if ms == "none" else ms, - depends=lst("depends"), + depends=lst("depends"), wiki=lst("wiki"), origin=meta.get("origin") or LOCAL, extra=extra) def to_text(self): @@ -314,6 +320,7 @@ class Issue(object): "assignees": self.assignees, "milestone": self.milestone or "none", "depends": self.depends, + "wiki": self.wiki, "origin": self.origin, }) body = self.body.strip() or "(no body)" diff --git a/skills/issue/scripts/issue_new.py b/skills/issue/scripts/issue_new.py index c60d95d..0611bf3 100644 --- a/skills/issue/scripts/issue_new.py +++ b/skills/issue/scripts/issue_new.py @@ -155,6 +155,8 @@ def main(): ap.add_argument("--assignee", action="append", default=[], help="assignee; repeat") ap.add_argument("--depends", action="append", default=[], help="id this issue depends on; repeat") + ap.add_argument("--wiki", action="append", default=[], + help="page title this work is written up in; repeat") ap.add_argument("--out", default=issue.ISSUE_ROOT, help="store root (default: <repo>/tmp/issues)") args = ap.parse_args() @@ -180,7 +182,7 @@ def main(): id=id, title=args.title, body=with_depends(TEMPLATES[args.type], args.depends), labels=labels, assignees=args.assignee, milestone=args.milestone, - depends=args.depends) + depends=args.depends, wiki=args.wiki) # The first issue in a fresh checkout has to create the store, but it says # so — and it says where, because the path is absolute. diff --git a/tests/test_wiki_field.py b/tests/test_wiki_field.py new file mode 100644 index 0000000..fa75520 --- /dev/null +++ b/tests/test_wiki_field.py @@ -0,0 +1,148 @@ +#!/usr/bin/env python3 +""" +`wiki:` is a domain field, and the parser now agrees with the format. + + python3 -m unittest discover -s tests -v + +The bug: `references/format.md` put `wiki:` in the table of domain fields, +between `depends` and `origin`, and `issue.py` had never heard of it. The +field fell into `extra` and rendered with the foreign keys — sorted in beside +`branch`, `gitea`, `synced`, `url`, i.e. AFTER the sync fields, which the same +document forbids one line further down. A list written without brackets parsed +as a single string, and nothing could set the field but a text editor. + +These tests pin the resolution: implemented in the domain, rendered among the +domain fields, parsed as a list in both forms, and reachable from the command +line. The layer rule rides along — a title is a name for a document, so the +field carries titles and this layer never resolves one. +""" +import os +import subprocess +import sys +import tempfile +import unittest + +REPO = os.path.dirname(os.path.dirname(os.path.abspath(__file__))) +ISSUE_SCRIPTS = os.path.join(REPO, "skills", "issue", "scripts") + +sys.path.insert(0, ISSUE_SCRIPTS) +import issue # noqa: E402 + +TITLES = ["Simple Chains/Ideas/Chain core", "Simple Chains/Ideas/Transport"] + +SYNCED = """\ +--- +id: wire-sqlc-appclick +state: open +labels: [type/task] +assignees: [] +milestone: none +depends: [migrate-schema] +wiki: [Simple Chains/Ideas/Chain core] +origin: gitea +branch: feat/wire-sqlc +gitea: claude-skills/tea#42 +synced: 2026-08-09T18:40:00Z +url: https://git.noodles.cam/claude-skills/tea/issues/42 +--- +# Wire sqlc into the appclick repo layer + +## Summary +Тело роли не играет. +""" + + +class TestTheFieldIsInTheDomain(unittest.TestCase): + + def test_it_is_a_domain_key_and_a_list_key(self): + self.assertIn("wiki", issue.DOMAIN_KEYS) + self.assertIn("wiki", issue.LIST_KEYS) + + def test_it_renders_between_depends_and_origin(self): + """`format.md` states the order and says domain fields render first. + The old behavior put it after the sync fields.""" + order = issue.DOMAIN_KEYS + self.assertEqual(order[order.index("depends") + 1], "wiki") + self.assertEqual(order[order.index("wiki") + 1], "origin") + + def test_it_survives_a_round_trip_among_the_domain_fields(self): + iss = issue.Issue.from_text(SYNCED, id="wire-sqlc-appclick") + self.assertEqual(iss.wiki, ["Simple Chains/Ideas/Chain core"]) + self.assertNotIn("wiki", iss.extra) + + text = iss.to_text() + keys = [line.split(":", 1)[0] + for line in text.splitlines()[1:] + if line != "---" and ":" in line] + keys = keys[:keys.index("origin") + 1] + self.assertEqual(keys[-3:], ["depends", "wiki", "origin"]) + self.assertLess(keys.index("wiki"), keys.index("origin")) + + again = issue.Issue.from_text(text, id="wire-sqlc-appclick") + self.assertEqual(again.wiki, iss.wiki) + + def test_a_bracketless_list_is_still_a_list(self): + """Without membership in LIST_KEYS this parsed as one string — + `wiki: A, B` became the single title "A, B".""" + text = SYNCED.replace("wiki: [Simple Chains/Ideas/Chain core]", + "wiki: %s" % ", ".join(TITLES)) + self.assertEqual(issue.Issue.from_text(text).wiki, TITLES) + + def test_the_bracketed_form_parses_the_same_way(self): + text = SYNCED.replace("wiki: [Simple Chains/Ideas/Chain core]", + "wiki: [%s]" % ", ".join(TITLES)) + self.assertEqual(issue.Issue.from_text(text).wiki, TITLES) + + def test_an_absent_field_is_an_empty_list_and_renders_as_one(self): + text = "\n".join(l for l in SYNCED.splitlines() + if not l.startswith("wiki:")) + iss = issue.Issue.from_text(text) + self.assertEqual(iss.wiki, []) + self.assertIn("wiki: []", iss.to_text()) + + def test_the_titles_are_carried_verbatim(self): + """A title with a slash in it is one title — the slash is hierarchy + inside the name, not a path this layer walks.""" + iss = issue.Issue(id="x", title="X", wiki=TITLES) + self.assertIn("wiki: [%s]" % ", ".join(TITLES), iss.to_text()) + + def test_the_domain_still_knows_nothing_about_a_wiki_it_could_reach(self): + """The layer rule: titles only. No page path, no sub_url, no HTTP.""" + with open(os.path.join(ISSUE_SCRIPTS, "issue.py")) as f: + body = f.read() + for banned in ("sub_url", "content_base64", "urllib"): + self.assertNotIn(banned, body) + imports = [l for l in body.splitlines() + if l.startswith("import ") or l.startswith("from ")] + self.assertNotIn("import subprocess", imports) + + +class TestIssueNewCanSetIt(unittest.TestCase): + """The script run for real, in a throwaway store — never the developer's.""" + + def setUp(self): + self._tmp = tempfile.TemporaryDirectory(prefix="tea-wiki-field-") + self.out = os.path.join(os.path.realpath(self._tmp.name), "issues") + self.addCleanup(self._tmp.cleanup) + + def new(self, *args): + p = subprocess.run( + [sys.executable, os.path.join(ISSUE_SCRIPTS, "issue_new.py"), + "--type", "task", "--title", "Write the chain core up", + "--out", self.out] + list(args), + capture_output=True, text=True) + self.assertEqual(p.returncode, 0, p.stdout + p.stderr) + with open(os.path.join(self.out, "write-the-chain-core-up.md")) as f: + return f.read() + + def test_the_flag_repeats_into_a_list(self): + text = self.new("--wiki", TITLES[0], "--wiki", TITLES[1]) + self.assertIn("wiki: [%s]" % ", ".join(TITLES), text) + self.assertEqual(issue.Issue.from_text(text).wiki, TITLES) + + def test_without_the_flag_the_field_is_present_and_empty(self): + self.assertIn("wiki: []", self.new()) + + +if __name__ == "__main__": + unittest.main()