refactor: turn the repo into a two-plugin marketplace

tea and tdl were two repositories, each carrying its own
.claude-plugin/marketplace.json — two marketplaces to register for what
is one collection. Fold them into one.

The repo root is now the marketplace and nothing else: a single
.claude-plugin/marketplace.json whose entries point at ./plugins/tea and
./plugins/tdl. A plugin's root is its own directory under plugins/, so
${CLAUDE_PLUGIN_ROOT} still resolves inside it and every path a plugin
uses stays relative to itself — the hooks and the test roots needed no
adjustment beyond the move.

tea's files move with git mv, so its history and blame follow. tdl
arrives as a plain copy; its history stays in claude-skills/threedotslab.

test_payload_root asserted `tmp/` was ignored by REPO/.gitignore. The
rule is that tmp/ is ignored, not which file says so, and git reads every
.gitignore on the way up — so the test now walks up to the repo root the
same way git does.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
This commit is contained in:
naudachu
2026-08-11 00:25:28 +05:00
parent 23f78beafb
commit 83f73c5cea
69 changed files with 4187 additions and 168 deletions
+743
View File
@@ -0,0 +1,743 @@
#!/usr/bin/env python3
r"""
issue.py — what an issue IS. The domain layer.
Not a command; the module every other issue script builds on. It knows the
canonical markdown format, the label taxonomy, validation, and the dependency
graph. It knows NOTHING about any tracker: no Gitea, no `tea`, no logins, no HTTP, no
issue numbers. The layering rule is mechanically checkable — every import in
this directory is stdlib, and `subprocess` is not among them:
grep -rhn '^import\|^from' skills/issue/scripts/ | sort -u
Delete skills/sync/ entirely and this layer keeps working: issues that live
only on this machine are first-class, not drafts on their way somewhere.
Identity is a slug derived from the title, and it is the only identity the
domain has. The file name is the id:
tmp/issues/wire-sqlc-appclick.md
---
id: wire-sqlc-appclick
state: open
labels: [type/task, tech/sql]
assignees: [naudachu]
milestone: v0.2
depends: [migrate-schema]
origin: gitea
gitea: owner/repo#42
synced: 2026-08-07T18:40:00Z
---
# Wire sqlc into the appclick repo layer
## Summary
...
Keys above `origin:` are owned here. Everything below is written by the sync
layer; this module carries those keys through load/save verbatim and never
reads them. That passthrough is what lets one file represent both a local
issue and a synced one without the domain learning a second vocabulary.
Every metadata field is one line and lists are inline, so plain grep works
without a parser:
grep -l 'labels:.*type/bug' tmp/issues/*.md
grep -ln 'depends:.*migrate-schema' tmp/issues/*.md # who depends on it
"""
import collections
import os
import re
# --------------------------------------------------------------------------
# where the store lives
# --------------------------------------------------------------------------
# `<repo root>/tmp/issues`, absolute, resolved once at import.
#
# It used to be the relative `tmp/issues`, which made "the store" whatever
# directory the shell happened to be standing in. One `cd` — and a `cd` outlives
# the command that ran it — was enough for readers to report an empty store on a
# full one and for writers to quietly build a second store beside the first.
#
# The anchor is THIS FILE, not the working directory. A script's own location is
# a fact about the installation; cwd is a fact about the last `cd`. Walking up
# from __file__ therefore hands every script in both layers the same answer no
# matter where it is invoked from — including from inside tmp/issues itself.
#
# An explicit --out still wins over all of this, and is used exactly as typed: a
# relative --out stays relative to cwd, because that is what the operator asked
# for. There is no environment override; the store is where the repo is.
STORE_PARTS = ("tmp", "issues")
# `.git` is a directory in a normal clone and a FILE in a worktree — hence
# exists(), not isdir(). AGENTS.md is the fallback for a plugin copied out of
# git; the agents-sync hook only ever puts one at a repository root.
REPO_MARKERS = (".git", "AGENTS.md")
_HERE = os.path.dirname(os.path.abspath(__file__))
def repo_root(start):
"""Nearest ancestor of `start` (inclusive) carrying a repo marker, or None.
Markers, not a fixed number of `..` hops: how deep this file sits below the
root is an implementation detail of the repo layout, and the layout is not
a promise."""
d = os.path.abspath(start)
while True:
if any(os.path.exists(os.path.join(d, m)) for m in REPO_MARKERS):
return d
parent = os.path.dirname(d)
if parent == d:
return None
d = parent
def store_root(start=None):
"""Absolute path of the issue store.
`start` overrides the anchor and exists so the resolution can be exercised
against a scratch tree. When these scripts are not inside a repository at
all, cwd gets a turn; failing that the historical cwd-relative location
stands, made absolute so an error message can name the directory it really
looked in."""
for anchor in ([start] if start is not None else [_HERE, os.getcwd()]):
root = repo_root(anchor)
if root:
return os.path.join(root, *STORE_PARTS)
return os.path.abspath(os.path.join(*STORE_PARTS))
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"}
STATES = ("open", "closed")
# `origin` is "does this issue exist anywhere but here" — a fact about the
# work, so it is owned here. Its value is `local` or a tracker's name; what
# that name means, and the handle that goes with it (`gitea: owner/repo#42`),
# stay foreign keys this layer carries but never reads.
LOCAL = "local"
# type/* is mandatory and exclusive; severity/* is optional and exclusive;
# tech/* and comp/* are free-form. Colors are NOT here — a hex code is how
# Gitea paints a chip, which makes it the sync layer's business.
TYPES = {
"bug": "Something behaves incorrectly in existing code",
"task": "Implementation of new functionality",
"refactor": "Internal restructuring; behavior must not change",
"test": "Writing or fixing tests",
"feature": "Container: several issues delivering one unit of business value",
"draft": "Idea captured for later; not ready for work",
}
SEVERITIES = ("low", "medium", "high", "showstopper", "critical")
EXCLUSIVE_NS = ("type/", "severity/")
# Sections every type must carry. type/draft is exempt from acceptance criteria.
REQUIRED_SECTIONS = ["## Summary", "## Spec"]
AC_SECTION = "## Acceptance criteria"
DEPENDS_SECTION = "## Depends on"
ISSUES_SECTION = "## Issues"
# Both sections name what an issue depends on, so both are edge sources and
# both point the same way. In a `type/feature` that reads container -> child:
# "the container is closed when its children are closed" IS a dependency.
# "a child belongs to a feature" is membership, and membership has no place in
# a dependency graph — which is why a child never names its container back.
DEP_SECTIONS = (DEPENDS_SECTION, ISSUES_SECTION)
# Per-type sections from the templates — absence is a warning, not a stop.
EXPECTED_SECTIONS = {
"bug": ["## Steps to reproduce", "## Expected", "## Actual", "## Environment"],
"task": ["## Motivation"],
"refactor": ["## Motivation", "## Invariants"],
"test": ["## Motivation", "## Test cases"],
"feature": ["## Motivation", ISSUES_SECTION],
"draft": ["## Notes"],
}
TITLE_PREFIX = re.compile(
r'^\s*(\[[^\]]+\]|(fix|feat|feature|bug|task|test|chore|refactor)\s*:)', re.I)
CYRILLIC = re.compile(r'[а-яё]', re.I)
SLUG_OK = re.compile(r'^[a-z0-9]+(-[a-z0-9]+)*$')
# --------------------------------------------------------------------------
# identity
# --------------------------------------------------------------------------
def slugify(text, maxlen=48):
"""Title -> id. Titles are English by format rule, so ASCII is enough;
anything else is dropped rather than transliterated."""
s = re.sub(r'[^a-z0-9]+', '-', (text or "").lower()).strip("-")
if len(s) > maxlen:
s = s[:maxlen].rsplit("-", 1)[0] or s[:maxlen]
return s.strip("-") or "issue"
def unique_id(root, base, taken=()):
"""`base`, or base-2, base-3… when the slug is already used."""
used = set(taken) | set(all_ids(root))
if base not in used:
return base
for i in range(2, 1000):
cand = "%s-%d" % (base, i)
if cand not in used:
return cand
raise ValueError("cannot allocate an id for %r" % base)
# --------------------------------------------------------------------------
# metadata block
# --------------------------------------------------------------------------
def parse_meta(text):
"""Split a file into (meta, title, body).
meta values are strings, or lists for the inline `[a, b]` form. title is
the first `# ` heading below the block and is stripped out of body."""
meta, rest = {}, text
if text.startswith("---"):
end = text.find("\n---", 3)
if end != -1:
for line in text[3:end].strip().splitlines():
if ":" not in line:
continue
k, v = line.split(":", 1)
k, v = k.strip(), v.strip()
if v.startswith("[") and v.endswith("]"):
v = [x.strip() for x in v[1:-1].split(",") if x.strip()]
elif k in LIST_KEYS:
v = [x.strip() for x in v.split(",") if x.strip()]
meta[k] = v
rest = text[end + 4:]
rest = rest.lstrip("\n")
title = ""
m = re.match(r'^#\s+(.+?)\s*\n', rest)
if m:
title = m.group(1).strip()
rest = rest[m.end():].lstrip("\n")
return meta, title, rest
def render_meta(meta):
"""Domain keys in DOMAIN_KEYS order, foreign keys after them, sorted.
Lists stay on one line so grep sees them whole."""
lines = ["---"]
foreign = sorted(k for k in meta if k not in DOMAIN_KEYS)
for k in DOMAIN_KEYS + foreign:
if k not in meta:
continue
v = meta[k]
if isinstance(v, (list, tuple)):
v = "[%s]" % ", ".join(str(x) for x in v)
lines.append("%s: %s" % (k, v))
lines.append("---")
return "\n".join(lines)
# --------------------------------------------------------------------------
# the issue
# --------------------------------------------------------------------------
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):
self.id = id
self.title = title
self.body = body
self.state = state or "open"
self.labels = list(labels or [])
self.assignees = list(assignees or [])
self.milestone = milestone or ""
self.depends = list(depends or [])
self.origin = origin or LOCAL
self.extra = dict(extra or {})
@property
def is_local(self):
"""True while this issue exists nowhere but here.
A complete state, not a pending one — and the state in which this file
is the only copy of the work. An issue whose `origin` names somewhere
else can be fetched from there again; this one cannot."""
return self.origin == LOCAL
# -- taxonomy views ----------------------------------------------------
@property
def type(self):
for l in self.labels:
if l.startswith("type/"):
return l.split("/", 1)[1]
return ""
@property
def severity(self):
for l in self.labels:
if l.startswith("severity/"):
return l.split("/", 1)[1]
return ""
# -- serialization -----------------------------------------------------
@classmethod
def from_text(cls, text, id=None):
meta, title, body = parse_meta(text)
extra = {k: v for k, v in meta.items() if k not in DOMAIN_KEYS}
def lst(key):
v = meta.get(key) or []
return [v] if isinstance(v, str) else list(v)
ms = meta.get("milestone") or ""
return cls(id=id or meta.get("id") or "",
title=title, body=body.strip(),
state=meta.get("state") or "open",
labels=lst("labels"), assignees=lst("assignees"),
milestone="" if ms == "none" else ms,
depends=lst("depends"),
origin=meta.get("origin") or LOCAL, extra=extra)
def to_text(self):
meta = dict(self.extra)
meta.update({
"id": self.id,
"state": self.state,
"labels": self.labels,
"assignees": self.assignees,
"milestone": self.milestone or "none",
"depends": self.depends,
"origin": self.origin,
})
body = self.body.strip() or "(no body)"
return "%s\n# %s\n\n%s\n" % (render_meta(meta), self.title, body)
# --------------------------------------------------------------------------
# body sections
# --------------------------------------------------------------------------
def section_body(body, header):
"""Text under `header`, up to the next `## ` heading."""
out, active = [], False
for line in (body or "").splitlines():
if line.startswith("## "):
if active:
break
active = line.strip() == header
continue
if active:
out.append(line)
return "\n".join(out).strip()
def body_dep_ref_sections(body):
"""[(section, ref)] for every reference under one of DEP_SECTIONS — never
from prose, or a graph walk would drag in half the backlog. Refs are
whatever was written there (slugs, and `#N` on issues that came from a
tracker), deduplicated on first sight.
The section is carried out with the ref so a caller can name the one the
reader actually has in front of them: a container's children come from
`## Issues`, and pointing at `## Depends on` would name a section that is
not in the file."""
out, seen, section = [], set(), ""
for line in (body or "").splitlines():
if line.startswith("## "):
head = line.strip()
section = head if head in DEP_SECTIONS else ""
continue
if not section:
continue
for tok in re.findall(r'#(\d+)|\b([a-z0-9]+(?:-[a-z0-9]+)+)\b', line):
ref = ("#" + tok[0]) if tok[0] else tok[1]
if ref not in seen:
seen.add(ref)
out.append((section, ref))
return out
def body_dep_refs(body):
"""Just the refs, in order of first appearance."""
return [ref for _, ref in body_dep_ref_sections(body)]
# --------------------------------------------------------------------------
# checkboxes
# --------------------------------------------------------------------------
# A checkbox is the one part of a body that is *state* and not prose, so the
# format gives it markup of its own (references/format.md:163-164). It is item
# markup, not a property of one section: `## Acceptance criteria` is the usual
# home, but a type/feature keeps its children as checkboxes under `## Issues`
# (format.md:275-277). The scan is therefore over the whole text and the
# heading is only recorded, never required.
CHECKBOX_RE = re.compile(
r'^(?P<indent>[ \t]*)(?P<marker>[-*+]|\d+[.)])[ \t]+'
r'\[(?P<box>[ xX])\](?=[ \t]|$)(?P<text>.*)$')
# Any list item — a sibling ends the item above it, checkbox or not.
LIST_ITEM_RE = re.compile(r'^[ \t]*([-*+]|\d+[.)])([ \t]|$)')
FENCE_RE = re.compile(r'^[ \t]{0,3}(`{3,}|~{3,})')
Checkbox = collections.namedtuple(
"Checkbox", "index line end_line checked text section")
def checkboxes(text):
"""Every checkbox item in `text`, in document order.
A pure function of the string it is given — no I/O, no store, no tracker.
Pass an issue body (`Issue.body`) to get body-relative line numbers, or a
whole file to get file-relative ones; nothing else changes.
Returns a list of `Checkbox` namedtuples:
index 1-based position in this list — what a user types to pick it
line 1-based line of the `- [ ]` marker, in the text given
end_line 1-based last line of the item, continuation lines included
checked True for `[x]` / `[X]`, False for `[ ]`
text the item's text; continuation lines joined with one space
section nearest preceding `## ` heading, "" above the first one
Rules:
- Only a line matching CHECKBOX_RE opens an item. A wrapped ("continuation")
line is part of the item above it, never an item of its own; the item
runs to the next blank line, heading, code fence, or list marker.
- Fenced code blocks are skipped whole: `- [ ]` inside a ``` fence is an
example of the markup, not a box anybody may tick.
- `-`, `*`, `+` and `1.` markers all count, at any indentation, so nested
lists are seen too.
"""
lines = (text or "").splitlines()
items, section, fence = [], "", ""
for n, line in enumerate(lines, 1):
m = FENCE_RE.match(line)
if m:
tok = m.group(1)
if not fence:
fence = tok
elif tok[0] == fence[0] and len(tok) >= len(fence):
fence = ""
continue
if fence:
continue
if line.startswith("## "):
section = line.strip()
continue
if line.startswith("# "):
section = ""
continue
m = CHECKBOX_RE.match(line)
if not m:
continue
end, parts = n, [m.group("text").strip()]
for k in range(n, len(lines)): # lines[k] is line number k + 1
nxt = lines[k]
if (not nxt.strip() or nxt.startswith("#")
or FENCE_RE.match(nxt) or LIST_ITEM_RE.match(nxt)):
break
end = k + 1
parts.append(nxt.strip())
items.append(Checkbox(len(items) + 1, n, end,
m.group("box") != " ",
" ".join(p for p in parts if p), section))
return items
def set_checkbox(text, item, checked=True):
"""Return `text` with one checkbox set to `checked`.
Pure, and deliberately surgical: exactly one character of the input
changes — the one between the brackets. Everything else, including
trailing whitespace and the item's own wording, comes back byte for byte.
That is the whole point of the function: ticking a box must not produce a
diff wider than the state that changed.
`item` is a `Checkbox` from `checkboxes(text)` — the same text, or the
line number will point at the wrong line — or a 1-based line number.
Already in the requested state is a no-op: `text` is returned unchanged,
and an existing `[X]` keeps its capital.
"""
line_no = item.line if isinstance(item, Checkbox) else int(item)
off = 0
for n, raw in enumerate(text.splitlines(True), 1):
if n == line_no:
m = CHECKBOX_RE.match(raw.rstrip("\r\n"))
if not m:
raise ValueError("line %d is not a checkbox item" % line_no)
if (m.group("box") != " ") == bool(checked):
return text
box = off + m.start("box")
return text[:box] + ("x" if checked else " ") + text[box + 1:]
off += len(raw)
raise ValueError("line %d is past the end of the text" % line_no)
def checkbox_progress(text):
"""(done, total) over every checkbox in `text`; (0, 0) when it has none.
Computed on the fly, on purpose. Progress is not a metadata field: it is
the body read back, and the body is the only place the state lives."""
items = checkboxes(text)
return sum(1 for c in items if c.checked), len(items)
# --------------------------------------------------------------------------
# validation
# --------------------------------------------------------------------------
def validate(issue, known_ids=None):
"""Return (errors, warnings). Errors mean the issue is not well-formed in
the canonical format; warnings mean it deviates from its type template."""
err, warn = [], []
if not issue.id:
err.append("no `id:` — the slug is the issue's identity")
elif not SLUG_OK.match(issue.id):
err.append("id %r is not a slug (lowercase, digits, single dashes)" % issue.id)
if issue.state not in STATES:
err.append("state %r must be one of: %s" % (issue.state, ", ".join(STATES)))
types = [l for l in issue.labels if l.startswith("type/")]
if len(types) != 1:
err.append("need exactly one type/* label, found %d: %s"
% (len(types), ", ".join(types) or "none"))
elif issue.type not in TYPES:
err.append("unknown type %r — known: %s" % (issue.type, ", ".join(sorted(TYPES))))
if len([l for l in issue.labels if l.startswith("severity/")]) > 1:
err.append("at most one severity/* label")
if issue.severity and issue.severity not in SEVERITIES:
warn.append("unknown severity %r" % issue.severity)
if not issue.title:
err.append("no `# Title` heading below the metadata block")
else:
if TITLE_PREFIX.match(issue.title):
err.append("title carries a type prefix (%r) — the type lives in the label"
% issue.title[:24])
if CYRILLIC.search(issue.title):
err.append("title must be English, imperative mood (prose stays Russian)")
for h in REQUIRED_SECTIONS:
if h not in issue.body:
err.append("missing section %s" % h)
if issue.type != "draft" and AC_SECTION not in issue.body:
err.append("missing section %s" % AC_SECTION)
if "## Spec" in issue.body and not section_body(issue.body, "## Spec"):
err.append("## Spec is empty — put a repo path, a URL, or the literal `none`")
for h in EXPECTED_SECTIONS.get(issue.type, []):
if h not in issue.body:
warn.append("type/%s template usually has %s" % (issue.type, h))
if issue.id in issue.depends:
err.append("depends on itself")
if known_ids is not None:
for d in issue.depends:
if d not in known_ids:
warn.append("depends on %r, which is not in the store" % d)
# `depends:` is the machine-readable graph; the body section is prose for
# humans. They drift silently unless something says so. Name the section
# the reference actually came from — for a container that is `## Issues`.
listed = set(issue.depends)
for section, ref in body_dep_ref_sections(issue.body):
if not ref.startswith("#") and ref not in listed:
warn.append("%s mentions %r but `depends:` does not list it"
% (section, ref))
# An unticked checkbox is never a finding — neither an error nor a
# warning. `- [ ]` is work not done yet, which is the normal state of a
# perfectly well-formed issue. Reading that state is issue_ac.py's job.
return err, warn
# --------------------------------------------------------------------------
# store
# --------------------------------------------------------------------------
class StoreMissing(Exception):
"""The store directory is not there.
Deliberately a different answer from "the store is empty". One is a path
that does not exist, the other is a repository with no issues filed yet, and
conflating the two is exactly what made a missed directory look like an
empty backlog."""
def __init__(self, root):
self.root = root
Exception.__init__(self, "store %s does not exist" % root)
def store_exists(root):
return os.path.isdir(root)
def require_store(root):
"""Assert the store is there before reading or writing it."""
if not os.path.isdir(root):
raise StoreMissing(root)
return root
def create_store(root):
"""Create the store; True when it actually made the directory.
Only the commands that legitimately bootstrap a store call this — issue_new
and pull — and both announce it. Nothing creates a store as a side effect of
a write any more: a missing directory is something to report, not something
to conjure."""
if os.path.isdir(root):
return False
os.makedirs(root)
return True
def store_error(root):
"""Why `root` cannot be read as a store, or None when it holds issues.
The two messages are distinct on purpose — see StoreMissing."""
if not os.path.isdir(root):
return ("store %s does not exist — nothing was created; pass --out to "
"point elsewhere" % root)
if not all_ids(root):
return "store %s exists but is empty" % root
return None
def path_of(root, id):
return os.path.join(root, "%s.md" % id)
def all_ids(root):
"""Every issue in the store, by slug.
An issue file is named by its slug and a slug has no dot in it (SLUG_OK),
so `<id>.comments.md` — the thread the sync layer parks beside an issue —
is not one, and neither is anything else that grew a second extension.
Without that rule `wire-sqlc.comments` reads as an issue called
`wire-sqlc.comments`, and a bare `push.py` tries to file the comment thread
as a unit of work."""
if not os.path.isdir(root):
return []
return sorted(f[:-3] for f in os.listdir(root)
if f.endswith(".md") and not f.startswith((".", "INDEX", "tree-"))
and "." not in f[:-3])
def slug_files(root, id):
"""Every file the store holds under one slug — the issue and its sidecars.
`<id>.md` is the issue. Anything named `<id>.<something>` beside it is a
companion another layer parked there (`<id>.comments.md` is the one that
exists today). `all_ids` already refuses to read those as issues because a
slug has no dot in it; this is the same rule read the other way round.
Which is how the domain can remove an issue *completely* without learning
what any of those companions are: it does not need to know that a comment
thread exists to know that a file named after this issue belongs to it and
goes when it goes. The issue's own file comes first — it is the headline of
any receipt printed from this list.
A missing store is an empty list, not an error: nothing is there to remove.
"""
if not os.path.isdir(root):
return []
own, sidecars = [], []
for name in sorted(os.listdir(root)):
if not name.startswith("%s." % id):
continue
p = os.path.join(root, name)
if not os.path.isfile(p):
continue
(own if name == "%s.md" % id else sidecars).append(p)
return own + sidecars
def load(root, id):
with open(path_of(root, id)) as f:
return Issue.from_text(f.read(), id=id)
def load_all(root):
return {i: load(root, i) for i in all_ids(root)}
def save(root, issue):
require_store(root)
p = path_of(root, issue.id)
with open(p, "w") as f:
f.write(issue.to_text())
return p
# --------------------------------------------------------------------------
# dependency graph
# --------------------------------------------------------------------------
def graph(issues):
"""{id: [dep ids]} from the `depends:` metadata — the authoritative edge
list. Body prose is never walked."""
return {i: list(iss.depends) for i, iss in issues.items()}
def dependents(issues, id):
"""Who depends on `id` (the upward direction)."""
return sorted(i for i, iss in issues.items() if id in iss.depends)
def topo_order(ids, edges):
"""Dependencies first. Cycles are broken deterministically rather than
raising: a cycle is a data problem for the caller to report, not a reason
to refuse to order the rest."""
order, state = [], {}
def visit(n):
if state.get(n) == "done":
return
if state.get(n) == "open":
return # cycle — leave the back edge unresolved
state[n] = "open"
for d in edges.get(n, []):
if d in edges:
visit(d)
state[n] = "done"
order.append(n)
for n in ids:
visit(n)
return order
def find_cycles(edges):
"""List of id lists, one per cycle found. Empty when the graph is a DAG."""
cycles, state, stack = [], {}, []
def visit(n):
state[n] = "open"
stack.append(n)
for d in edges.get(n, []):
if d not in edges:
continue
if state.get(d) == "open":
cycles.append(stack[stack.index(d):] + [d])
elif d not in state:
visit(d)
stack.pop()
state[n] = "done"
for n in edges:
if n not in state:
visit(n)
return cycles
@@ -0,0 +1,134 @@
#!/usr/bin/env python3
"""
issue_ac.py — list and tick the checkboxes in an issue's body. Offline.
issue_ac.py wire-sqlc-appclick numbered list with state
issue_ac.py wire-sqlc-appclick --check 3 by number
issue_ac.py wire-sqlc-appclick --check регресс by substring
issue_ac.py wire-sqlc-appclick --uncheck 3
A checkbox is the one part of a body that is *state* and not prose. Everything
else is written once; boxes get ticked as the work goes, and until now the only
ways to tick one were a human with an editor or a model rewriting the whole
body — the second worse than the first, because the rewrite re-flows the text
and the issue's diff swells around a change of one character. This changes that
one character and nothing else.
Named after `## Acceptance criteria`, where most boxes live, but every checkbox
in the body is listed and tickable: a type/feature keeps its children under
`## Issues`, and binding this to one heading would silently lose half of them.
A substring picks an item only when it picks exactly one. Two matches is an
error listing both — a coin flip would tick the wrong box and look like it
worked.
Delivering the changed body to a tracker is not part of this: that is
`push.py --update` in /tea:sync.
"""
import argparse
import os
import re
import sys
sys.path.insert(0, os.path.dirname(os.path.abspath(__file__)))
import issue # noqa: E402
import issue_index # noqa: E402
NUMBER = re.compile(r'^\d+$')
def box(c):
return "[x]" if c.checked else "[ ]"
def listing(items):
"""The numbered list, grouped by the heading each item sits under."""
out, section = [], None
for c in items:
if c.section != section:
section = c.section
out.append("")
out.append(section or "(above the first heading)")
out.append(" %2d %s %s" % (c.index, box(c), c.text))
return out
def select(items, needle):
"""Resolve a --check/--uncheck argument to exactly one item, or exit."""
needle = (needle or "").strip()
if not needle:
sys.exit("issue_ac.py: empty selector — give an item number or a substring")
if NUMBER.match(needle):
n = int(needle)
if not 1 <= n <= len(items):
sys.exit("issue_ac.py: no item %d — the issue has %d" % (n, len(items)))
return items[n - 1]
hits = [c for c in items if needle.lower() in c.text.lower()]
if not hits:
sys.exit("issue_ac.py: nothing matches %r" % needle)
if len(hits) > 1:
sys.exit("\n".join(
["issue_ac.py: %r matches %d items — narrow it down, or use a number:"
% (needle, len(hits))]
+ [" %2d %s %s" % (c.index, box(c), c.text) for c in hits]))
return hits[0]
def main(argv=None):
ap = argparse.ArgumentParser(
description="List and tick an issue's checkboxes (offline)")
ap.add_argument("id", help="issue id (the slug, without .md)")
g = ap.add_mutually_exclusive_group()
g.add_argument("--check", metavar="N|TEXT", help="tick one item: number or substring")
g.add_argument("--uncheck", metavar="N|TEXT", help="untick one item: number or substring")
ap.add_argument("--out", default=issue.ISSUE_ROOT, help="store root (default: tmp/issues)")
args = ap.parse_args(argv)
path = issue.path_of(args.out, args.id)
if not os.path.exists(path):
sys.exit("issue_ac.py: no issue %r in %s" % (args.id, args.out))
# newline="": no translation in either direction. Byte-for-byte means the
# line endings too — reading a CRLF file in text mode and writing it back
# would rewrite every line while claiming to have changed one character.
with open(path, newline="") as f:
text = f.read()
# The whole file, not just the body: line numbers then point at the file,
# and the metadata block is rewritten by nobody. Round-tripping through
# Issue.to_text() would re-render metadata and re-strip the body, which is
# exactly the byte-level churn this script exists to avoid.
items = issue.checkboxes(text)
needle = args.check if args.check is not None else args.uncheck
if not items:
if needle is not None:
sys.exit("issue_ac.py: %s has no checkboxes" % args.id)
print("%s — no checkboxes" % args.id)
return 0
if needle is None:
done = sum(1 for c in items if c.checked)
print("%s%d/%d %s" % (args.id, done, len(items), path))
print("\n".join(listing(items)))
return 0
checked = args.check is not None
item = select(items, needle)
new = issue.set_checkbox(text, item, checked)
verb = "checked" if checked else "unchecked"
if new == text:
print("unchanged %2d %s %s" % (item.index, box(item), item.text))
return 0
with open(path, "w", newline="") as f:
f.write(new)
issue_index.build(args.out)
done, total = issue.checkbox_progress(new)
print("%s %2d %s %s" % (verb, item.index, "[x]" if checked else "[ ]", item.text))
print("%s%d/%d %s:%d" % (args.id, done, total, path, item.line))
return 0
if __name__ == "__main__":
sys.exit(main())
@@ -0,0 +1,72 @@
#!/usr/bin/env python3
"""
issue_check.py — validate issues against the canonical format. Offline.
The same check the sync layer runs before it pushes anything, available on its
own so a local-only issue can be held to the format without a tracker being
involved. Errors mean malformed; warnings mean it deviates from its type's
template or its graph looks suspect.
issue_check.py every issue in the store
issue_check.py wire-sqlc one issue
issue_check.py --quiet exit code only (0 clean, 1 errors)
Format reference: ../references/format.md
"""
import argparse
import os
import sys
sys.path.insert(0, os.path.dirname(os.path.abspath(__file__)))
import issue # noqa: E402
def main():
ap = argparse.ArgumentParser(description="Validate local issues (offline)")
ap.add_argument("ids", nargs="*", help="ids to check (default: all)")
ap.add_argument("--quiet", action="store_true", help="exit code only")
ap.add_argument("--strict", action="store_true", help="treat warnings as errors")
ap.add_argument("--out", default=issue.ISSUE_ROOT,
help="store root (default: <repo>/tmp/issues)")
args = ap.parse_args()
problem = issue.store_error(args.out)
if problem:
sys.exit("issue_check.py: %s" % problem)
issues = issue.load_all(args.out)
ids = args.ids or sorted(issues)
for i in ids:
if i not in issues:
sys.exit("issue_check.py: no issue %r in %s" % (i, args.out))
known = set(issues)
bad = 0
for i in ids:
err, warn = issue.validate(issues[i], known_ids=known)
if args.strict:
err, warn = err + warn, []
if err:
bad += 1
if args.quiet:
continue
if not err and not warn:
print("ok %s" % i)
continue
for e in err:
print("ERROR %s: %s" % (i, e))
for w in warn:
print("warn %s: %s" % (i, w))
for c in issue.find_cycles(issue.graph(issues)):
bad += 1
if not args.quiet:
print("ERROR cycle: %s" % " -> ".join(c))
if not args.quiet:
print("%d issue(s) checked, %d with errors" % (len(ids), bad))
return 1 if bad else 0
if __name__ == "__main__":
sys.exit(main())
@@ -0,0 +1,177 @@
#!/usr/bin/env python3
"""
issue_evict.py — closed issues leave the store. Offline.
issue_evict.py every closed issue that is not origin: local
issue_evict.py old-thing … only these
issue_evict.py --dry-run print what would go; touch nothing
The store is a working set, not an archive. A closed issue is not a unit of
work any more, and `pull.py` has kept new ones out of filter mode for a while —
but the files already on disk were nobody's job, so the only way to remove one
was `rm` past every script, followed by rebuilding `INDEX.md` by hand. This is
that job.
WHAT IS EVICTED, and it is two conditions, both read off the file:
state: closed the work is done
origin: <tracker> the work is somewhere else too
TWO CONDITIONS, AND THE SECOND ONE IS THE WHOLE SAFETY ARGUMENT. `origin:
local` means this file IS the issue — there is no other copy and deleting it
deletes the work. It is therefore never evicted, in any state, not even when
named explicitly on the command line: a closed local issue is reported and
kept. The only files that go are ones whose own metadata says the work can be
fetched back (`pull.py <n>`), which is the same trade `push.py` makes when it
drops a file the tracker has just confirmed.
That parallel is exact except for where the confirmation comes from. Push has
to ask Gitea, because it is Gitea that just changed. Eviction asks the file,
because `state:` and `origin:` are domain fields and the answer is already in
the store — which is why this command lives in the domain layer and needs no
network, no login, and no `tea`. See `skills/sync/scripts/evict.py` for the
variant that refreshes `state:` from the tracker first; it makes the deletion
decision by calling `run()` below, so there is exactly one implementation of
"what may be evicted" and it is this one.
NOT A ONE-OFF MIGRATION. `pull.py <n>` fetches an issue in any state — a number
is an address, not a query — so a closed issue pulled after an eviction lands on
disk again. That is the tracker being asked a direct question, not a regression,
and the answer is to evict again when you are done with it.
`.remote.json` is deliberately NOT pruned. It is the local number -> slug
ledger, its entries outlive the files they name (that is what makes `pull.py
<n>` land on the same slug after a push deleted the file), and an evicted issue
is in exactly that state. `INDEX.md` is rebuilt, because it *is* a view of the
directory.
"""
import argparse
import os
import sys
sys.path.insert(0, os.path.dirname(os.path.abspath(__file__)))
import issue # noqa: E402
import issue_index # noqa: E402
CLOSED = "closed"
# Why an issue was kept, in the receipt. `LOCAL_REASON` is the one that matters:
# it is printed whether or not the issue was named, because "this closed thing
# is still here" needs an answer every time.
LOCAL_REASON = "origin: %s — this file IS the issue" % issue.LOCAL
def classify(issues, ids=None):
"""Split the store into (evict, protected, still_open).
Pure — it reads the loaded issues and decides; nothing here touches disk.
evict closed, and lives in a tracker too: safe to remove
protected closed, but `origin: local`: the only copy of the work
still_open not closed
`ids` restricts the question to those issues; without it the whole store is
considered. A protected issue is returned as such even when it was named
explicitly — naming a file does not make deleting it safe.
"""
chosen = list(ids) if ids else sorted(issues)
evict, protected, still_open = [], [], []
for id in chosen:
iss = issues[id]
if iss.state != CLOSED:
still_open.append(id)
elif iss.is_local:
protected.append(id)
else:
evict.append(id)
return evict, protected, still_open
def remove(root, id):
"""Delete everything the store holds under one slug; return the paths.
Deliberately dumb, and for the same reason `push.drop_local` is: it takes an
id, not a decision. Whether an issue may go is settled by `classify` before
this is reached, so the dangerous half of the operation has no branches in
it at all. There is exactly one call site.
"""
gone = []
for p in issue.slug_files(root, id):
os.remove(p)
gone.append(p)
return gone
def run(root, issues, ids=None, dry_run=False, out=None):
"""Classify, report, remove, rebuild the index. Returns (gone, kept).
The one implementation of eviction, called both by `main` below and by the
sync layer's `evict.py` — which does nothing to this decision except hand
over issues whose `state:` it has just refreshed from the tracker.
`gone` is {id: [paths]} and is empty on a dry run; `kept` is
[(id, why)] for everything considered and not removed.
"""
out = out or sys.stdout
evict, protected, still_open = classify(issues, ids)
gone, kept = {}, []
for id in evict:
paths = issue.slug_files(root, id) if dry_run else remove(root, id)
if not dry_run:
gone[id] = paths
out.write("%-11s %s\n" % ("would evict" if dry_run else "evicted", id))
for p in paths:
out.write(" %s\n" % p)
for id in protected:
kept.append((id, LOCAL_REASON))
out.write("%-11s %s closed, %s\n" % ("kept", id, LOCAL_REASON))
# An open issue is the normal case and says nothing worth a line — unless
# the operator named it, in which case they are owed the reason.
for id in still_open:
kept.append((id, "state: %s" % issues[id].state))
if ids:
out.write("%-11s %s state: %s\n" % ("kept", id, issues[id].state))
if dry_run:
out.write("%d issue(s) would be evicted, %d kept — nothing was touched\n"
% (len(evict), len(kept)))
return gone, kept
out.write("%d issue(s) evicted, %d kept\n" % (len(gone), len(kept)))
# Only when something actually went: the index is a view of the directory,
# and rewriting it after a run that changed nothing is a write nobody asked
# for.
if gone:
path, n = issue_index.build(root)
out.write("index: %s%d issue(s)\n" % (path, n))
return gone, kept
def main(argv=None):
ap = argparse.ArgumentParser(
description="Evict closed issues from the local store (offline)")
ap.add_argument("ids", nargs="*",
help="issue ids (default: every closed issue in the store)")
ap.add_argument("--dry-run", action="store_true",
help="print what would be removed; touch nothing")
ap.add_argument("--out", default=issue.ISSUE_ROOT,
help="store root (default: <repo>/tmp/issues)")
args = ap.parse_args(argv)
root = args.out
if not issue.store_exists(root):
sys.exit("issue_evict.py: store %s does not exist — nothing to evict" % root)
issues = issue.load_all(root)
missing = [i for i in args.ids if i not in issues]
if missing:
sys.exit("issue_evict.py: no such issue(s) in the store: %s"
% ", ".join(missing))
run(root, issues, args.ids, args.dry_run)
return 0
if __name__ == "__main__":
sys.exit(main())
@@ -0,0 +1,116 @@
#!/usr/bin/env python3
"""
issue_index.py — rebuild tmp/issues/INDEX.md from what is on disk. Offline.
A map of the local store, nothing else. The `origin` column is the only place
the index acknowledges that a tracker exists: `local` means the issue has never
left this machine, `gitea` means the sync layer has pushed or pulled it. Both
are ordinary issues here.
The store is <repo root>/tmp/issues unless --out says otherwise; an existing
store with nothing in it gets an "_empty_" table, a store that is not there is
an error rather than a directory to create.
Usage:
issue_index.py [--out DIR]
"""
import argparse
import os
import re
import sys
sys.path.insert(0, os.path.dirname(os.path.abspath(__file__)))
import issue # noqa: E402
def cell(v):
if isinstance(v, (list, tuple)):
return ", ".join(str(x) for x in v) or ""
v = str(v or "").strip()
return v.replace("|", "\\|") or ""
def progress(body):
"""`3/7` for a body with checkboxes, "" for one without.
Counted from the body every time the index is built and stored nowhere —
the boxes are the state, and a second copy of it in a metadata field would
be wrong by the next edit."""
done, total = issue.checkbox_progress(body)
return "%d/%d" % (done, total) if total else ""
def build(root):
# An index of a store that is not there is not an empty index, it is a bad
# path. Raising beats writing INDEX.md into a directory nobody asked for.
issue.require_store(root)
issues = issue.load_all(root)
rows = []
for i in sorted(issues):
iss = issues[i]
rest = [l for l in iss.labels if not l.startswith("type/")]
rows.append({
"id": i,
"state": cell(iss.state),
"progress": progress(iss.body),
"type": cell(iss.type),
"labels": cell(rest),
"title": cell(iss.title),
"milestone": cell(iss.milestone),
"depends": cell(iss.depends),
"origin": cell(iss.origin),
})
listing = os.listdir(root) if os.path.isdir(root) else []
trees = sorted(f for f in listing if re.match(r'^tree-.+\.md$', f))
out = ["# Issue store", "",
"Every issue this project knows about. `origin: local` means it "
"exists nowhere else — a complete state, not a pending one. Any "
"other value names the tracker it also lives in; the handle is in "
"the file. `progress` counts the body's checkboxes, ticked over "
"total, and is blank for an issue that has none — read off the "
"body at build time, stored nowhere. Rebuild with `issue_index.py`; "
"tick a box with `issue_ac.py`.", ""]
if rows:
out += ["| id | state | progress | type | labels | title | milestone | depends | origin |",
"|---|---|---|---|---|---|---|---|---|"]
out += ["| [%s](%s.md) | %s | %s | %s | %s | %s | %s | %s | %s |" % (
r["id"], r["id"], r["state"], r["progress"], r["type"], r["labels"],
r["title"], r["milestone"], r["depends"], r["origin"]) for r in rows]
else:
out.append("_empty_")
if trees:
out += ["", "## Dependency trees", ""]
out += ["- [%s](%s)" % (t, t) for t in trees]
cycles = issue.find_cycles(issue.graph(issues))
if cycles:
out += ["", "## Dependency cycles", ""]
out += ["- %s" % " -> ".join(c) for c in cycles]
out.append("")
path = os.path.join(root, "INDEX.md")
with open(path, "w") as f:
f.write("\n".join(out))
return path, len(rows)
def main():
ap = argparse.ArgumentParser(description="Rebuild the local issue index (offline)")
ap.add_argument("--out", default=issue.ISSUE_ROOT,
help="store root (default: <repo>/tmp/issues)")
args = ap.parse_args()
# An existing store with nothing in it is a legitimate thing to index — it
# gets an "_empty_" table. A store that is not there is not.
try:
path, n = build(args.out)
except issue.StoreMissing as e:
sys.exit("issue_index.py: %s — nothing was created; create an issue with "
"issue_new.py, or pass --out" % e)
print("%s%d issue(s)" % (path, n))
if __name__ == "__main__":
main()
@@ -0,0 +1,197 @@
#!/usr/bin/env python3
"""
issue_new.py — create an issue in the local store. Offline, always.
The issue is real the moment this writes the file. Nothing is pending, nothing
is a draft awaiting a tracker: `origin: local` is a complete state and pushing
it to Gitea later (see /tea:sync) is optional.
While it says `local`, this file is the ONLY copy of the work — the store, not
a cache of anything. That is what a push changes: it hands the issue to the
tracker and removes the file.
issue_new.py --type task --title "Wire sqlc into the appclick repo layer" \
--label tech/sql --label comp/appclick
issue_new.py --type bug --title "Fix tea-guard crash on empty settings" \
--depends wire-sqlc-appclick --milestone v0.2
Writes tmp/issues/<slug>.md prefilled with the type's template, prints the
path, and rebuilds INDEX.md. Fill the sections in an editor or with Edit; run
issue_check.py when done.
Body prose is Russian, section headers and the title are English — see
../references/format.md.
"""
import argparse
import os
import sys
sys.path.insert(0, os.path.dirname(os.path.abspath(__file__)))
import issue # noqa: E402
import issue_index # noqa: E402
SPEC = """## Spec
none
"""
TEMPLATES = {
"bug": """## Summary
Что сломано и где проявляется, одно-два предложения.
""" + SPEC + """
## Steps to reproduce
1. …
2. …
## Expected
Что должно было произойти.
## Actual
Что происходит на самом деле: вывод команды, лог.
## Environment
Только релевантное: версии, ОС, конфигурация.
## Acceptance criteria
- [ ] баг не воспроизводится по шагам выше
- [ ] добавлена проверка на регрессию (если применимо)
""",
"task": """## Summary
Что нужно сделать, одно-два предложения.
""" + SPEC + """
## Motivation
Какую проблему пользователя/системы это решает.
## Acceptance criteria
- [ ] проверяемое условие
- [ ] …
""",
"refactor": """## Summary
Что перестраиваем и в каких файлах (`path/file:line`).
""" + SPEC + """
## Motivation
Чем плохо текущее состояние: дублирование, связность, читаемость.
## Invariants
Что НЕ должно измениться: поведение, публичные API, форматы данных.
## Acceptance criteria
- [ ] проверяемое условие (тесты зелёные, старый путь удалён, …)
""",
"test": """## Summary
Что покрываем тестами и где (`path/file:line`).
""" + SPEC + """
## Motivation
Зачем: регрессия после бага, пробел в покрытии, флаки-тест.
## Test cases
- сценарий → ожидаемый результат
- …
## Acceptance criteria
- [ ] перечисленные кейсы покрыты и зелёные
- [ ] тесты проходят в CI
""",
"feature": """## Summary
Бизнес-ценность одним-двумя предложениями.
""" + SPEC + """
## Motivation
Какую проблему пользователя/системы это решает.
## Issues
- [ ] slug-дочернего-issue — краткое описание части
- [ ] …
## Acceptance criteria
- [ ] все дочерние issues закрыты
- [ ] проверяемое условие уровня фичи
""",
"draft": """## Summary
Идея одним-двумя предложениями.
""" + SPEC + """
## Notes
Свободные заметки: что известно, открытые вопросы, варианты.
""",
}
DEPENDS_BLOCK = """## Depends on
%s
"""
def with_depends(body, depends):
"""Insert `## Depends on` right after `## Spec`, per the format."""
if not depends:
return body
block = DEPENDS_BLOCK % "\n".join("- %s" % d for d in depends)
lines, out, placed = body.splitlines(True), [], False
for line in lines:
if not placed and line.startswith("## ") and not line.startswith("## Summary") \
and not line.startswith("## Spec") and out:
out.append(block + "\n")
placed = True
out.append(line)
if not placed:
out.append("\n" + block)
return "".join(out)
def main():
ap = argparse.ArgumentParser(description="Create a local issue from its type template")
ap.add_argument("--type", required=True, choices=sorted(issue.TYPES),
help="issue type (becomes the exclusive type/* label)")
ap.add_argument("--title", required=True, help="English, imperative, no type prefix")
ap.add_argument("--id", help="slug (default: derived from the title)")
ap.add_argument("--label", action="append", default=[],
help="extra label, e.g. tech/sql; repeat")
ap.add_argument("--severity", choices=list(issue.SEVERITIES), help="severity/* label")
ap.add_argument("--milestone", default="", help="milestone title")
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("--out", default=issue.ISSUE_ROOT,
help="store root (default: <repo>/tmp/issues)")
args = ap.parse_args()
labels = ["type/%s" % args.type]
if args.severity:
labels.append("severity/%s" % args.severity)
labels += [l for l in args.label if l not in labels]
id = args.id or issue.unique_id(args.out, issue.slugify(args.title))
if args.id and not issue.SLUG_OK.match(args.id):
sys.exit("issue_new.py: --id %r is not a slug (lowercase, digits, single dashes)"
% args.id)
if os.path.exists(issue.path_of(args.out, id)):
sys.exit("issue_new.py: %s already exists" % issue.path_of(args.out, id))
known = set(issue.all_ids(args.out))
for d in args.depends:
if d not in known:
sys.stderr.write("warning: depends on %r, which is not in the store yet\n" % d)
iss = issue.Issue(
id=id, title=args.title,
body=with_depends(TEMPLATES[args.type], args.depends),
labels=labels, assignees=args.assignee, milestone=args.milestone,
depends=args.depends)
# 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.
if issue.create_store(args.out):
sys.stderr.write("created store %s\n" % os.path.abspath(args.out))
path = issue.save(args.out, iss)
issue_index.build(args.out)
print("%s [type/%s] %s" % (path, args.type, args.title))
print("fill the sections, then: issue_check.py %s" % id)
if __name__ == "__main__":
main()
@@ -0,0 +1,99 @@
#!/usr/bin/env python3
"""
issue_tree.py — draw the dependency graph of the local store. Offline.
Edges come from the `depends:` metadata, which is the authoritative edge list;
prose in the body is never walked. Because the graph is slugs all the way down,
this works identically for issues that were never pushed anywhere.
issue_tree.py every root (nothing depends on it)
issue_tree.py wire-sqlc-appclick one subtree
issue_tree.py --depth 2 --write
Downwards is what this draws (what an issue depends on). The other direction is
a grep, not a flag:
grep -ln 'depends:.*migrate-schema' tmp/issues/*.md
"""
import argparse
import os
import sys
sys.path.insert(0, os.path.dirname(os.path.abspath(__file__)))
import issue # noqa: E402
def label(id, issues, seen, edges):
iss = issues.get(id)
if not iss:
return "%s (not in the store)" % id
tail = " (see above)" if id in seen and edges.get(id) else ""
return "%s [%s] %s%s %s.md%s" % (
id, iss.type or "-", iss.title, iss.state, id, tail)
def render(roots, issues, edges, depth):
lines, seen = [], set()
def walk(id, prefix, is_last, is_root, level):
connector = "" if is_root else ("└── " if is_last else "├── ")
lines.append(prefix + connector + label(id, issues, seen, edges))
if id in seen or level >= depth:
return
seen.add(id)
kids = edges.get(id) or []
child_prefix = prefix if is_root else prefix + (" " if is_last else "")
for i, k in enumerate(kids):
walk(k, child_prefix, i == len(kids) - 1, False, level + 1)
for r in roots:
if r in seen:
continue # already drawn as somebody's child — one tree, not two
walk(r, "", True, True, 0)
lines.append("")
head = roots[0] if len(roots) == 1 else "%d root(s)" % len(roots)
out = "# Dependency tree — %s\n\n```\n%s```\n" % (head, "\n".join(lines))
cycles = issue.find_cycles(edges)
if cycles:
out += "\n## Cycles\n\n" + "\n".join("- %s" % " -> ".join(c) for c in cycles) + "\n"
return out
def main():
ap = argparse.ArgumentParser(description="Draw the local dependency graph (offline)")
ap.add_argument("ids", nargs="*", help="roots (default: issues nothing depends on)")
ap.add_argument("--depth", type=int, default=6, help="max depth (default: 6)")
ap.add_argument("--write", action="store_true",
help="also write tmp/issues/tree-<slug>.md")
ap.add_argument("--out", default=issue.ISSUE_ROOT,
help="store root (default: <repo>/tmp/issues)")
args = ap.parse_args()
problem = issue.store_error(args.out)
if problem:
sys.exit("issue_tree.py: %s" % problem)
issues = issue.load_all(args.out)
edges = issue.graph(issues)
roots = args.ids
for r in roots:
if r not in issues:
sys.exit("issue_tree.py: no issue %r in %s" % (r, args.out))
if not roots:
depended_on = {d for deps in edges.values() for d in deps}
roots = sorted(i for i in issues if i not in depended_on) or sorted(issues)
text = render(roots, issues, edges, args.depth)
sys.stdout.write(text)
if args.write:
slug = roots[0] if len(roots) == 1 else "all"
path = os.path.join(args.out, "tree-%s.md" % slug)
with open(path, "w") as f:
f.write(text)
print("written: %s" % path)
if __name__ == "__main__":
main()