feat: implement the wiki field in the issue domain
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) <noreply@anthropic.com>
This commit is contained in:
@@ -64,7 +64,7 @@ url: https://git.noodles.cam/claude-skills/tea/issues/42
|
|||||||
| `assignees` | domain | logins; may be empty |
|
| `assignees` | domain | logins; may be empty |
|
||||||
| `milestone` | domain | title, or `none` |
|
| `milestone` | domain | title, or `none` |
|
||||||
| `depends` | domain | ids this issue depends on — **the authoritative graph** |
|
| `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 "<title>"` (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 |
|
| `origin` | domain | `local`, or the name of a tracker this also lives in |
|
||||||
| `gitea` | sync | the handle in that tracker: `owner/repo#N` |
|
| `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 |
|
| `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 |
|
||||||
|
|||||||
@@ -25,6 +25,7 @@ domain has. The file name is the id:
|
|||||||
assignees: [naudachu]
|
assignees: [naudachu]
|
||||||
milestone: v0.2
|
milestone: v0.2
|
||||||
depends: [migrate-schema]
|
depends: [migrate-schema]
|
||||||
|
wiki: [Simple Chains/Ideas/Chain core]
|
||||||
origin: gitea
|
origin: gitea
|
||||||
gitea: owner/repo#42
|
gitea: owner/repo#42
|
||||||
synced: 2026-08-07T18:40:00Z
|
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,
|
# Domain-owned metadata, in render order. Foreign keys render after these,
|
||||||
# sorted, so the sync layer can add fields without touching this list.
|
# sorted, so the sync layer can add fields without touching this list.
|
||||||
DOMAIN_KEYS = ["id", "state", "labels", "assignees", "milestone", "depends", "origin"]
|
DOMAIN_KEYS = ["id", "state", "labels", "assignees", "milestone", "depends",
|
||||||
LIST_KEYS = {"labels", "assignees", "depends"}
|
"wiki", "origin"]
|
||||||
|
LIST_KEYS = {"labels", "assignees", "depends", "wiki"}
|
||||||
STATES = ("open", "closed")
|
STATES = ("open", "closed")
|
||||||
|
|
||||||
# `origin` is "does this issue exist anywhere but here" — a fact about the
|
# `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."""
|
"""One unit of work. `extra` holds metadata this layer does not own."""
|
||||||
|
|
||||||
def __init__(self, id="", title="", body="", state="open", labels=None,
|
def __init__(self, id="", title="", body="", state="open", labels=None,
|
||||||
assignees=None, milestone="", depends=None, origin=LOCAL,
|
assignees=None, milestone="", depends=None, wiki=None,
|
||||||
extra=None):
|
origin=LOCAL, extra=None):
|
||||||
self.id = id
|
self.id = id
|
||||||
self.title = title
|
self.title = title
|
||||||
self.body = body
|
self.body = body
|
||||||
@@ -257,6 +259,10 @@ class Issue(object):
|
|||||||
self.assignees = list(assignees or [])
|
self.assignees = list(assignees or [])
|
||||||
self.milestone = milestone or ""
|
self.milestone = milestone or ""
|
||||||
self.depends = list(depends 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.origin = origin or LOCAL
|
||||||
self.extra = dict(extra or {})
|
self.extra = dict(extra or {})
|
||||||
|
|
||||||
@@ -302,7 +308,7 @@ class Issue(object):
|
|||||||
state=meta.get("state") or "open",
|
state=meta.get("state") or "open",
|
||||||
labels=lst("labels"), assignees=lst("assignees"),
|
labels=lst("labels"), assignees=lst("assignees"),
|
||||||
milestone="" if ms == "none" else ms,
|
milestone="" if ms == "none" else ms,
|
||||||
depends=lst("depends"),
|
depends=lst("depends"), wiki=lst("wiki"),
|
||||||
origin=meta.get("origin") or LOCAL, extra=extra)
|
origin=meta.get("origin") or LOCAL, extra=extra)
|
||||||
|
|
||||||
def to_text(self):
|
def to_text(self):
|
||||||
@@ -314,6 +320,7 @@ class Issue(object):
|
|||||||
"assignees": self.assignees,
|
"assignees": self.assignees,
|
||||||
"milestone": self.milestone or "none",
|
"milestone": self.milestone or "none",
|
||||||
"depends": self.depends,
|
"depends": self.depends,
|
||||||
|
"wiki": self.wiki,
|
||||||
"origin": self.origin,
|
"origin": self.origin,
|
||||||
})
|
})
|
||||||
body = self.body.strip() or "(no body)"
|
body = self.body.strip() or "(no body)"
|
||||||
|
|||||||
@@ -155,6 +155,8 @@ def main():
|
|||||||
ap.add_argument("--assignee", action="append", default=[], help="assignee; repeat")
|
ap.add_argument("--assignee", action="append", default=[], help="assignee; repeat")
|
||||||
ap.add_argument("--depends", action="append", default=[],
|
ap.add_argument("--depends", action="append", default=[],
|
||||||
help="id this issue depends on; repeat")
|
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,
|
ap.add_argument("--out", default=issue.ISSUE_ROOT,
|
||||||
help="store root (default: <repo>/tmp/issues)")
|
help="store root (default: <repo>/tmp/issues)")
|
||||||
args = ap.parse_args()
|
args = ap.parse_args()
|
||||||
@@ -180,7 +182,7 @@ def main():
|
|||||||
id=id, title=args.title,
|
id=id, title=args.title,
|
||||||
body=with_depends(TEMPLATES[args.type], args.depends),
|
body=with_depends(TEMPLATES[args.type], args.depends),
|
||||||
labels=labels, assignees=args.assignee, milestone=args.milestone,
|
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
|
# 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.
|
# so — and it says where, because the path is absolute.
|
||||||
|
|||||||
@@ -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()
|
||||||
Reference in New Issue
Block a user