basic BMAD setup
This commit is contained in:
@@ -0,0 +1,124 @@
|
||||
#!/usr/bin/env python3
|
||||
# /// script
|
||||
# requires-python = ">=3.11"
|
||||
# ///
|
||||
"""Read the ticket store's config without loading the rest into context.
|
||||
|
||||
The config is two layers. The base is the skill's starter for the store, `config/<store>-ticketing.toml`.
|
||||
Over it goes the project's `_bmad/custom/ticketing-store-config.toml`: its `[tickets] store` names the
|
||||
store, and any other key it sets replaces the starter's, tables merging key by key. With no project file
|
||||
the store is `repo`. A store with no starter is read from the project file alone.
|
||||
|
||||
read_store.py --project-root <root> [-k <dotted key> ...]
|
||||
read_store.py --starters
|
||||
|
||||
One key prints its value, a string bare and anything else as JSON; several print one JSON object; none
|
||||
prints the whole merged config. `--starters` prints each shipped store with its description.
|
||||
Exit 1 when a file cannot be read, 2 when a key is missing.
|
||||
"""
|
||||
|
||||
import argparse
|
||||
import json
|
||||
import re
|
||||
import sys
|
||||
import tomllib
|
||||
from pathlib import Path
|
||||
|
||||
sys.dont_write_bytecode = True
|
||||
|
||||
_MISSING = object()
|
||||
STARTERS = Path(__file__).resolve().parents[1] / "config"
|
||||
PROJECT_FILE = Path("_bmad") / "custom" / "ticketing-store-config.toml"
|
||||
STORE_RE = re.compile(r"^[a-z0-9][a-z0-9-]*$")
|
||||
|
||||
|
||||
def load(path: Path) -> dict:
|
||||
# utf-8-sig: Windows editors can save a byte-order mark.
|
||||
return tomllib.loads(path.read_text(encoding="utf-8-sig"))
|
||||
|
||||
|
||||
def merge(base: dict, over: dict) -> dict:
|
||||
out = dict(base)
|
||||
for key, value in over.items():
|
||||
out[key] = merge(out[key], value) if isinstance(value, dict) and isinstance(out.get(key), dict) else value
|
||||
return out
|
||||
|
||||
|
||||
def store_config(project_root: Path, starters: Path) -> dict:
|
||||
path = project_root / PROJECT_FILE
|
||||
project = load(path) if path.is_file() else {}
|
||||
tickets = project.get("tickets")
|
||||
store = tickets.get("store") if isinstance(tickets, dict) else None
|
||||
if not isinstance(store, str) or not store:
|
||||
store = "repo" # as tickets.py reads it
|
||||
starter = starters / f"{store}-ticketing.toml"
|
||||
config = merge(load(starter), project) if STORE_RE.match(store) and starter.is_file() else project
|
||||
if isinstance(config.get("tickets"), dict):
|
||||
config["tickets"]["store"] = store
|
||||
return config
|
||||
|
||||
|
||||
def extract(data, dotted: str):
|
||||
current = data
|
||||
for part in dotted.split("."):
|
||||
if isinstance(current, dict) and part in current:
|
||||
current = current[part]
|
||||
else:
|
||||
return _MISSING
|
||||
return current
|
||||
|
||||
|
||||
def main() -> int:
|
||||
parser = argparse.ArgumentParser(description=__doc__, formatter_class=argparse.RawDescriptionHelpFormatter)
|
||||
parser.add_argument("--project-root", help="project holding _bmad/")
|
||||
parser.add_argument("--key", "-k", action="append", default=[], help="dotted key (repeatable)")
|
||||
parser.add_argument("--starters", action="store_true", help="list the shipped stores and their descriptions")
|
||||
parser.add_argument(
|
||||
"--starters-dir", default=str(STARTERS), help="folder of starters; default: the skill's config/"
|
||||
)
|
||||
args = parser.parse_args()
|
||||
starters = Path(args.starters_dir).expanduser()
|
||||
|
||||
try:
|
||||
if args.starters:
|
||||
found = {}
|
||||
for path in sorted(starters.glob("*-ticketing.toml")):
|
||||
data = load(path)
|
||||
found[data.get("tickets", {}).get("store", path.name)] = data.get("description", "")
|
||||
print(json.dumps(found, indent=2, ensure_ascii=False))
|
||||
return 0
|
||||
if not args.project_root:
|
||||
parser.error("--project-root is required")
|
||||
data = store_config(Path(args.project_root).expanduser(), starters)
|
||||
except (OSError, ValueError) as error: # ValueError covers a TOML or encoding failure
|
||||
sys.stderr.write(f"error: cannot read the store config: {error}\n")
|
||||
return 1
|
||||
|
||||
if not args.key:
|
||||
print(json.dumps(data, indent=2, ensure_ascii=False))
|
||||
return 0
|
||||
|
||||
found = {}
|
||||
missing = []
|
||||
for key in args.key:
|
||||
value = extract(data, key)
|
||||
(missing.append(key) if value is _MISSING else found.__setitem__(key, value))
|
||||
for key in missing:
|
||||
sys.stderr.write(f"missing: {key}\n")
|
||||
|
||||
if len(args.key) == 1:
|
||||
if missing:
|
||||
return 2
|
||||
value = found[args.key[0]]
|
||||
print(value.rstrip("\n") if isinstance(value, str) else json.dumps(value, indent=2, ensure_ascii=False))
|
||||
else:
|
||||
print(json.dumps(found, indent=2, ensure_ascii=False))
|
||||
return 2 if missing else 0
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
if sys.platform == "win32":
|
||||
# Piped output on Windows defaults to a legacy code page, not UTF-8.
|
||||
sys.stdout.reconfigure(encoding="utf-8")
|
||||
sys.stderr.reconfigure(encoding="utf-8")
|
||||
raise SystemExit(main())
|
||||
@@ -0,0 +1,153 @@
|
||||
import json
|
||||
import subprocess
|
||||
import sys
|
||||
import tempfile
|
||||
import unittest
|
||||
from pathlib import Path
|
||||
|
||||
SCRIPT = Path(__file__).resolve().parents[1] / "read_store.py"
|
||||
|
||||
REPO = '''
|
||||
# comment that must never be printed
|
||||
description = "Files in the repo."
|
||||
|
||||
[tickets]
|
||||
store = "repo"
|
||||
access = "Read and write the files."
|
||||
|
||||
[verbs]
|
||||
write = """
|
||||
Edit the file.
|
||||
Commit it.
|
||||
"""
|
||||
|
||||
[tickets.status]
|
||||
backlog = "backlog"
|
||||
done = "done"
|
||||
'''
|
||||
|
||||
JIRA = """
|
||||
description = "Jira through acli."
|
||||
|
||||
[tickets]
|
||||
store = "jira"
|
||||
key = ""
|
||||
site = ""
|
||||
|
||||
[verbs]
|
||||
write = "acli create"
|
||||
query = "acli view"
|
||||
|
||||
[tickets.status]
|
||||
backlog = "To Do"
|
||||
review = "In Review"
|
||||
done = "Done"
|
||||
"""
|
||||
|
||||
|
||||
class ReadStoreTests(unittest.TestCase):
|
||||
def setUp(self):
|
||||
self.tmp = tempfile.TemporaryDirectory()
|
||||
self.root = Path(self.tmp.name) / "project"
|
||||
self.starters = Path(self.tmp.name) / "config"
|
||||
(self.root / "_bmad" / "custom").mkdir(parents=True)
|
||||
self.starters.mkdir()
|
||||
(self.starters / "repo-ticketing.toml").write_text(REPO, encoding="utf-8")
|
||||
(self.starters / "jira-ticketing.toml").write_text(JIRA, encoding="utf-8")
|
||||
|
||||
def tearDown(self):
|
||||
self.tmp.cleanup()
|
||||
|
||||
def project(self, text):
|
||||
(self.root / "_bmad" / "custom" / "ticketing-store-config.toml").write_text(text, encoding="utf-8")
|
||||
|
||||
def run_script(self, *args):
|
||||
return subprocess.run(
|
||||
[sys.executable, str(SCRIPT), "--starters-dir", str(self.starters), *args],
|
||||
encoding="utf-8",
|
||||
capture_output=True,
|
||||
check=False,
|
||||
)
|
||||
|
||||
def read(self, *keys):
|
||||
args = [a for k in keys for a in ("-k", k)]
|
||||
return self.run_script("--project-root", str(self.root), *args)
|
||||
|
||||
def test_no_project_file_reads_the_repo_starter(self):
|
||||
result = self.read("tickets.store")
|
||||
self.assertEqual((result.returncode, result.stdout), (0, "repo\n"))
|
||||
result = self.read("verbs.write")
|
||||
self.assertEqual(result.stdout, "Edit the file.\nCommit it.\n")
|
||||
self.assertNotIn("comment", self.read().stdout)
|
||||
|
||||
def test_the_project_file_names_the_store_and_its_keys_win_one_by_one(self):
|
||||
self.project('[tickets]\nstore = "jira"\nkey = "SHOP"\n\n[tickets.status]\nreview = ""\n')
|
||||
result = self.read("tickets", "verbs.query")
|
||||
self.assertEqual(result.returncode, 0, result.stderr)
|
||||
self.assertEqual(
|
||||
json.loads(result.stdout),
|
||||
{
|
||||
"tickets": {
|
||||
"store": "jira",
|
||||
"key": "SHOP",
|
||||
"site": "",
|
||||
"status": {"backlog": "To Do", "review": "", "done": "Done"},
|
||||
},
|
||||
"verbs.query": "acli view",
|
||||
},
|
||||
)
|
||||
|
||||
def test_a_verb_the_project_file_sets_replaces_the_starters(self):
|
||||
self.project('[tickets]\nstore = "jira"\n\n[verbs]\nwrite = "our own create"\n')
|
||||
self.assertEqual(self.read("verbs.write").stdout, "our own create\n")
|
||||
self.assertEqual(self.read("verbs.query").stdout, "acli view\n")
|
||||
|
||||
def test_a_store_with_no_starter_is_read_from_the_project_file_alone(self):
|
||||
self.project('[tickets]\nstore = "azure"\n\n[verbs]\nwrite = "az boards"\n')
|
||||
self.assertEqual(self.read("verbs.write").stdout, "az boards\n")
|
||||
result = self.read("verbs.query")
|
||||
self.assertEqual(result.returncode, 2)
|
||||
self.assertIn("missing: verbs.query", result.stderr)
|
||||
|
||||
def test_a_store_name_that_is_no_file_name_reads_no_starter(self):
|
||||
self.project('[tickets]\nstore = "../repo"\n')
|
||||
self.assertEqual(self.read("verbs.write").returncode, 2)
|
||||
|
||||
def test_a_project_file_that_names_no_store_reads_the_repo_starter(self):
|
||||
for text in ("[workflow]\nx = 1\n", "[tickets]\nstore = 5\n", '[tickets]\nstore = ""\nkey = "K"\n'):
|
||||
self.project(text)
|
||||
self.assertEqual(self.read("verbs.write").stdout, "Edit the file.\nCommit it.\n", text)
|
||||
self.assertEqual(self.read("tickets.store").stdout, "repo\n", text)
|
||||
self.assertEqual(self.read("tickets.key").stdout, "K\n")
|
||||
|
||||
def test_several_keys_with_one_missing_print_the_rest_and_exit_2(self):
|
||||
result = self.read("tickets.store", "tickets.nope")
|
||||
self.assertEqual(result.returncode, 2)
|
||||
self.assertEqual(json.loads(result.stdout), {"tickets.store": "repo"})
|
||||
self.assertIn("missing: tickets.nope", result.stderr)
|
||||
|
||||
def test_an_unreadable_project_file_exits_1(self):
|
||||
self.project("[tickets\nstore = ")
|
||||
result = self.read("tickets.store")
|
||||
self.assertEqual(result.returncode, 1)
|
||||
self.assertIn("cannot read the store config", result.stderr)
|
||||
|
||||
def test_starters_lists_each_store_with_its_description(self):
|
||||
result = self.run_script("--starters")
|
||||
self.assertEqual(result.returncode, 0, result.stderr)
|
||||
self.assertEqual(json.loads(result.stdout), {"jira": "Jira through acli.", "repo": "Files in the repo."})
|
||||
|
||||
def test_every_shipped_starter_parses_and_is_named_for_its_store(self):
|
||||
shipped = SCRIPT.parents[1] / "config"
|
||||
result = subprocess.run(
|
||||
[sys.executable, str(SCRIPT), "--starters"], encoding="utf-8", capture_output=True, check=False
|
||||
)
|
||||
self.assertEqual(result.returncode, 0, result.stderr)
|
||||
self.assertEqual(
|
||||
sorted(f"{store}-ticketing.toml" for store in json.loads(result.stdout)),
|
||||
sorted(p.name for p in shipped.glob("*-ticketing.toml")),
|
||||
)
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
unittest.main()
|
||||
File diff suppressed because it is too large
Load Diff
File diff suppressed because it is too large
Load Diff
Reference in New Issue
Block a user