Something went wrong. Try again.
Identities for entities did.bot
agent llm did
Something went wrong. Try again.
Python
123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350#!/usr/bin/env python3"""Generate the five tables in plan/README.md from the epics' own frontmatter.
Every row in the register used to repeat an id, a title and a status that theepic file already carried. That cost two things. Nothing checked the copy, so aretitled epic kept its old title in the table; and every branch that added anepic edited the same tables, so two of them conflicted by construction.
So the tables are output now. Each one sits between an HTML comment fence andis rewritten from the files; everything outside a fence is hand-written proseand is passed through untouched. Which table an epic lands in follows fromwhere it is and what its status says: plan/complete/ is Complete, `shipped`outside it is Shipped-with-loose-ends, `open` and `blocked` are Open,`continuous` is Continuous, and `declined` is Not pursued.
Row order comes from plan/order.txt, a plain list of ids that is advisory andoptional — an epic it does not name still appears, alphabetically, at the endof its table. That is the whole reason it can exist without being an `order`field on every epic: it is one shared line to move when a reading order isworth stating, not a rank every file carries and every insert renumbers.Adding an epic needs no edit here at all.
No third-party modules, for the same reason check-plan.py has none: this runson every commit that touches plan/. The frontmatter parser is that script's,imported rather than copied, so the two can never disagree about what an epicsays.
Rewrite the register: scripts/gen-plan-readme.pyAsk whether it is current: scripts/gen-plan-readme.py --check"""
import argparseimport difflibimport importlib.utilimport osimport reimport sys
SCRIPTS_DIR = os.path.dirname(os.path.abspath(__file__))REPO_ROOT = os.path.dirname(SCRIPTS_DIR)PLAN_DIR = os.path.join(REPO_ROOT, "plan")COMPLETE_DIR = os.path.join(PLAN_DIR, "complete")REGISTER = os.path.join(PLAN_DIR, "README.md")ORDER_FILE = os.path.join(PLAN_DIR, "order.txt")
FENCE_OPEN_RE = re.compile(r"^<!--\s*generated:\s*([a-z][a-z0-9-]*)\s*-->$")FENCE_CLOSE = "<!-- /generated -->"
# name -> (heading it lives under, does the row carry a status column,# which epics belong in it).## The status column is only on Open, because that is the only table holding# more than one status: `blocked` reads as a fact about an open epic, while a# column of nothing but `continuous` says nothing the heading did not.TABLES = [ ( "complete", False, lambda e: e["archived"], ), ( "shipped", False, lambda e: not e["archived"] and e["status"] == "shipped", ), ( "open", True, lambda e: not e["archived"] and e["status"] in ("open", "blocked"), ), ( "continuous", False, lambda e: not e["archived"] and e["status"] == "continuous", ), ( "declined", False, lambda e: not e["archived"] and e["status"] == "declined", ),]
def load_check_plan(): """Import scripts/check-plan.py for its frontmatter parser.
Its filename has a hyphen in it, so `import` cannot reach it by name. Going the long way round is still better than a second parser: the two scripts read the same files on the same commit, and a subset-of-YAML parser that drifts from its twin fails in a way nobody would look for.
Importing writes a scripts/__pycache__/ unless told not to, and this repo does not ignore one. Nothing here is imported twice anyway. """ path = os.path.join(SCRIPTS_DIR, "check-plan.py") spec = importlib.util.spec_from_file_location("check_plan", path) module = importlib.util.module_from_spec(spec) sys.dont_write_bytecode = True spec.loader.exec_module(module) return module
def read_epics(check_plan, errors): """Every epic in plan/ and plan/complete/, with the fields a row needs.""" epics = [] for directory, archived in ((PLAN_DIR, False), (COMPLETE_DIR, True)): if not os.path.isdir(directory): continue for name in sorted(os.listdir(directory)): if not name.endswith(".md") or name in check_plan.NOT_EPICS: continue path = os.path.join(directory, name) rel = os.path.relpath(path, REPO_ROOT) with open(path, encoding="utf-8") as fh: lines = fh.read().splitlines() data = check_plan.parse_frontmatter(rel, lines, errors) if data is None: # parse_frontmatter has already said what is wrong with it. continue missing = [k for k in ("id", "title", "status") if k not in data] if missing: errors.append( f"{rel}: frontmatter is missing {', '.join(missing)}, so " f"there is nothing to\n" f" put in the register. scripts/check-plan.py says what " f"an epic must carry." ) continue epics.append( { "id": data["id"], "title": data["title"], "status": data["status"], "archived": archived, "link": os.path.relpath(path, PLAN_DIR).replace(os.sep, "/"), "rel": rel, } ) return epics
def read_order(known_ids, errors): """plan/order.txt as a list of ids. Blank lines and # comments are notes.""" if not os.path.isfile(ORDER_FILE): return [] order = [] with open(ORDER_FILE, encoding="utf-8") as fh: for n, raw in enumerate(fh.read().splitlines(), 1): line = raw.split("#", 1)[0].strip() if not line: continue if line in order: errors.append( f"plan/order.txt:{n}: `{line}` is listed twice. " f"Delete one of them." ) continue if line not in known_ids: errors.append( f"plan/order.txt:{n}: `{line}` is not an epic.\n" f" Every line is the id of a file in plan/ or " f"plan/complete/. If the epic was\n" f" renamed or deleted, drop the line — an epic missing " f"from this file is fine,\n" f" it just sorts to the end of its table." ) continue order.append(line) return order
def escape_cell(text): """A pipe in a title would end the cell early; nothing else is special.""" return text.replace("|", "\\|")
def render_table(epics, with_status): header = ["| id | title | status |", "|---|---|---|"] if not with_status: header = ["| id | title |", "|---|---|"] rows = [] for epic in epics: cells = [f"[{epic['id']}]({epic['link']})", escape_cell(epic["title"])] if with_status: cells.append(epic["status"]) rows.append("| " + " | ".join(cells) + " |") return header + rows
def build_blocks(epics, order, errors): """The generated lines for each fence, keyed by the fence's name.""" rank = {epic_id: n for n, epic_id in enumerate(order)} unlisted = len(rank)
blocks = {} placed = set() for name, with_status, belongs in TABLES: rows = [e for e in epics if belongs(e)] rows.sort(key=lambda e: (rank.get(e["id"], unlisted), e["id"])) placed.update(e["id"] for e in rows) blocks[name] = render_table(rows, with_status)
for epic in epics: if epic["id"] not in placed: errors.append( f"{epic['rel']}: status `{epic['status']}` belongs to no " f"table, so the epic\n" f" would vanish from the register. Use one of: shipped, " f"open, blocked, continuous." ) return blocks
def rewrite(text, blocks, errors): """Replace what is inside each fence. Returns None if the fences are wrong.
Everything outside a fence — the headings, the paragraphs between the tables — is hand-written and comes through byte for byte. """ lines = text.split("\n") out = [] seen = [] i = 0 while i < len(lines): match = FENCE_OPEN_RE.match(lines[i].strip()) if not match: out.append(lines[i]) i += 1 continue
name = match.group(1) if name not in blocks: errors.append( f"plan/README.md:{i + 1}: `<!-- generated: {name} -->` names " f"no table.\n" f" The generated tables are: " f"{', '.join(n for n, _, _ in TABLES)}." ) return None if name in seen: errors.append( f"plan/README.md:{i + 1}: a second " f"`<!-- generated: {name} -->` fence.\n" f" Each table is generated in one place. Delete one of them." ) return None
end = None for j in range(i + 1, len(lines)): if lines[j].strip() == FENCE_CLOSE: end = j break if FENCE_OPEN_RE.match(lines[j].strip()): break if end is None: errors.append( f"plan/README.md:{i + 1}: the `{name}` fence is never closed.\n" f" Add a `{FENCE_CLOSE}` line after the table." ) return None
seen.append(name) out.append(lines[i]) out.extend(blocks[name]) out.append(lines[end]) i = end + 1
missing = [n for n, _, _ in TABLES if n not in seen] if missing: errors.append( f"plan/README.md: no fence for: {', '.join(missing)}.\n" f" Each table is written between " f"`<!-- generated: NAME -->` and `{FENCE_CLOSE}`.\n" f" Put the pair under the heading the table belongs to." ) return None
return "\n".join(out)
def main(): parser = argparse.ArgumentParser( description="Generate plan/README.md's tables from epic frontmatter." ) parser.add_argument( "--check", action="store_true", help="say whether the register is current, and change nothing", ) args = parser.parse_args()
if not os.path.isfile(REGISTER): print( "plan/README.md is missing. It is the register: every epic is " "linked from it.", file=sys.stderr, ) return 1
check_plan = load_check_plan() errors = []
epics = read_epics(check_plan, errors) order = read_order({e["id"] for e in epics}, errors) blocks = build_blocks(epics, order, errors)
with open(REGISTER, encoding="utf-8") as fh: before = fh.read() after = rewrite(before, blocks, errors)
if errors: print( f"plan/README.md cannot be generated ({len(errors)} problem(s)):\n", file=sys.stderr, ) for err in errors: print(err + "\n", file=sys.stderr) return 1
if after == before: if not args.check: print("plan/README.md is already current.") return 0
if args.check: diff = difflib.unified_diff( before.splitlines(keepends=True), after.splitlines(keepends=True), fromfile="plan/README.md (committed)", tofile="plan/README.md (generated)", ) print( "plan/README.md does not match the epics' frontmatter.\n" "Run scripts/gen-plan-readme.py and stage the result. What " "differs:\n", file=sys.stderr, ) sys.stderr.writelines(diff) print(file=sys.stderr) return 1
with open(REGISTER, "w", encoding="utf-8") as fh: fh.write(after) print("plan/README.md: tables rewritten.") return 0
if __name__ == "__main__": sys.exit(main())