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:
@@ -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()
|
||||
Reference in New Issue
Block a user