#!/usr/bin/env python3 """Read, write and check the XCE bug records: one Markdown file per bug. Each bug is `.wolf/bugs/.md`: a front-matter block of short machine fields (one `key: ` per line, which is also valid YAML), then `# : ` and the sections of the record standard (.claude/skills/buglog/bug-record.md). `check` enforces that standard. `.wolf/buglog.json` is an index generated from the records for OpenWolf's hooks and `jq` queries; every write here regenerates it, never edit it by hand. The retired hook-generated entries live in `.wolf/buglog-archive.json`. Status values and when to use each: .claude/rules/buglog-status.md. Commands: new --summary S --component C --severity V [--binary B] [--tags a,b] [--files f,g] [--related bug-1,bug-2] Create a record from the template, Environment filled in. check [ID ...] [--all] [--staged] Check records against the standard. status ID STATUS [--note T] [--commit SHA]... [--duplicate-of ID] [--repro T] Change status; refused if the record does not meet what the new status requires. note ID TEXT [--section S] Append "UPDATE (date): TEXT" to a section. recur ID [--note T] Seen again: bump occurrences/last_seen; cannot-reproduce -> investigating, fixed/wontfix -> open. set ID key= ... Set front-matter fields. show ID Print one record. list [--status S,S | --open] [--tag T] [--grep RE] [--since DATE] triage Records with no (or an unknown) status. env [BINARY] Print an Environment section for this machine. index Regenerate .wolf/buglog.json. next-id Print the id `new` would use. Importable: load, load_all, set_meta, check, build_index, BuglogEditError. """ import argparse import datetime import json import os import platform import re import shutil import subprocess import sys from pathlib import Path STATUSES = ["open", "partial", "investigating", "fixed", "cannot-reproduce", "wontfix", "duplicate"] OPEN_STATUSES = {"open", "partial", "investigating"} COMPONENTS = ["controller", "scheduler", "worker", "lib", "harness", "apiserver", "xcecli", "xceadmin", "autoscaler", "build", "config", "tooling"] SEVERITIES = ["data-loss", "crash", "hang", "wrong-result", "leak", "security", "noise"] SECTIONS = ["Description", "Environment", "Reproduction", "Root cause", "Fix", "Verification", "References", "Legacy notes"] META_ORDER = ["id", "status", "status_updated", "status_note", "component", "severity", "timestamp", "last_seen", "occurrences", "files", "tags", "related", "fix_commits", "duplicate_of", "repro_attempts", "legacy", "gitea_issue", "gitea_synced_status", "gitea_synced_hash"] ID_RE = re.compile(r"bug-\d+(?:-[a-z0-9-]+)?") # Text a migrated record carries where the old log had no such field. NOT_RECORDED = "_Not recorded (migrated from buglog.json)._" STATUS_WORDS = re.compile(r"\b(OPEN|FIXED|NOT FIXED|PRIORITY)\b") ROOT = BUGS_DIR = INDEX_PATH = ARCHIVE_PATH = None def configure(root): """Point the module at a repository root (tests use a temp dir).""" global ROOT, BUGS_DIR, INDEX_PATH, ARCHIVE_PATH ROOT = Path(root) BUGS_DIR = ROOT / ".wolf" / "bugs" INDEX_PATH = ROOT / ".wolf" / "buglog.json" ARCHIVE_PATH = ROOT / ".wolf" / "buglog-archive.json" configure(Path(__file__).resolve().parent.parent) class BuglogEditError(Exception): pass def _today(): return datetime.date.today().isoformat() def _now(): return datetime.datetime.now(datetime.timezone.utc).strftime("%Y-%m-%dT%H:%M:%SZ") def _split(value): return [v.strip() for v in (value or "").split(",") if v.strip()] def _run(cmd, **kw): try: r = subprocess.run(cmd, capture_output=True, text=True, timeout=180, **kw) except (OSError, subprocess.TimeoutExpired): return None return r.stdout.strip() if r.returncode == 0 else None # ── Record format ──────────────────────────────────────────────────────────── class Record: def __init__(self, meta, summary, sections, path=None): self.meta = meta self.summary = summary self.sections = sections self.path = path @property def id(self): return self.meta.get("id") def render_meta(meta): keys = [k for k in META_ORDER if k in meta] + \ sorted(k for k in meta if k not in META_ORDER) lines = ["---"] + [f"{k}: {json.dumps(meta[k], ensure_ascii=False)}" for k in keys] + ["---"] return "\n".join(lines) + "\n" def render(rec): out = [render_meta(rec.meta), f"# {rec.id}: {rec.summary}", ""] for name in SECTIONS: if name in rec.sections: out += [f"## {name}", rec.sections[name].strip("\n"), ""] return "\n".join(out) _HEADER_RE = re.compile(r"^## (" + "|".join(map(re.escape, SECTIONS)) + r")\s*$") _ESCAPED_RE = re.compile(r"^\\(## (" + "|".join(map(re.escape, SECTIONS)) + r")\s*)$", re.M) def escape_text(text): """Stop a line of free text from being read as a section header.""" return re.sub(_HEADER_RE.pattern, r"\\\g<0>", text, flags=re.M) def unescape_text(text): return _ESCAPED_RE.sub(r"\1", text) def parse(text, path=None): lines = text.split("\n") if not lines or lines[0] != "---": raise BuglogEditError(f"{path}: no front matter") meta = {} i = 1 while i < len(lines) and lines[i] != "---": key, sep, raw = lines[i].partition(": ") if not sep: raise BuglogEditError(f"{path}:{i + 1}: expected 'key: value'") try: meta[key] = json.loads(raw) except json.JSONDecodeError as e: raise BuglogEditError(f"{path}:{i + 1}: {key}: {e}") from None i += 1 if i == len(lines): raise BuglogEditError(f"{path}: front matter not closed") summary, sections, current, buf, fence = None, {}, None, [], None for ln in lines[i + 1:]: m = re.match(r"^\s*(`{3,}|~{3,})", ln) if m: mark = m.group(1) if fence is None: fence = mark elif mark[0] == fence[0] and len(mark) >= len(fence): fence = None if fence is None and not m: h = _HEADER_RE.match(ln) if h: if current: sections[current] = "\n".join(buf).strip("\n") current, buf = h.group(1), [] continue if summary is None and current is None and ln.startswith("# "): title = ln[2:].strip() prefix = f"{meta.get('id')}: " summary = title[len(prefix):] if title.startswith(prefix) else title continue if current: buf.append(ln) if current: sections[current] = "\n".join(buf).strip("\n") return Record(meta, summary or "", sections, path) def record_path(bug_id): if not ID_RE.fullmatch(bug_id or ""): raise BuglogEditError(f"{bug_id!r}: not a bug id") return BUGS_DIR / f"{bug_id}.md" def load(bug_id): path = record_path(bug_id) if not path.exists(): raise BuglogEditError(f"{bug_id}: no such record") return parse(path.read_text(encoding="utf-8"), path) def _id_key(bug_id): m = re.match(r"bug-(\d+)(.*)", bug_id) return (int(m.group(1)), m.group(2)) if m else (0, bug_id) def load_all(): recs = [parse(p.read_text(encoding="utf-8"), p) for p in BUGS_DIR.glob("*.md")] return sorted(recs, key=lambda r: _id_key(r.id or "")) def _write_atomic(path, text): tmp = path.with_name(f".{path.name}.{os.getpid()}.tmp") tmp.write_text(text, encoding="utf-8") os.replace(tmp, path) def save(rec, reindex=True): """Write a record; refuses text that would not parse back identically.""" text = render(rec) again = parse(text, rec.path) if again.meta != rec.meta or again.summary != rec.summary or \ again.sections != {k: v.strip("\n") for k, v in rec.sections.items()}: raise BuglogEditError(f"{rec.id}: record would not round-trip") path = record_path(rec.id) _write_atomic(path, text) if reindex: build_index() def set_meta(bug_id, fields, reindex=True): """Set front-matter fields only; the body bytes are left untouched.""" path = record_path(bug_id) text = path.read_text(encoding="utf-8") rec = parse(text, path) rec.meta.update(fields) head_end = text.index("\n---\n", 3) + len("\n---\n") new = render_meta(rec.meta) + text[head_end:] if parse(new, path).meta != rec.meta: raise BuglogEditError(f"{bug_id}: front matter would not round-trip") _write_atomic(path, new) if reindex: build_index() # Kept for scripts/gitea-sync.py and older callers. def set_fields(_path_ignored, bug_id, fields): set_meta(bug_id, fields) # ── Index ──────────────────────────────────────────────────────────────────── def index_entry(rec): s, m = rec.sections, rec.meta unesc = lambda k: unescape_text(s.get(k, "")) # noqa: E731 fix = unesc("Fix") if s.get("Verification") and s["Verification"] != NOT_RECORDED: fix += "\n\nVerification:\n" + unesc("Verification") entry = { "id": rec.id, "status": m.get("status"), "status_updated": m.get("status_updated"), "status_note": m.get("status_note", ""), "component": m.get("component"), "severity": m.get("severity"), "timestamp": m.get("timestamp"), "last_seen": m.get("last_seen", m.get("timestamp")), "occurrences": m.get("occurrences", 1), "error_message": (rec.summary + "\n\n" + unesc("Description")).strip(), "file": ", ".join(m.get("files") or []), "root_cause": unesc("Root cause"), "fix": fix.strip(), "tags": m.get("tags") or [], "related_bugs": m.get("related") or [], "fix_commits": m.get("fix_commits") or [], "record": str(record_path(rec.id).relative_to(ROOT)), } for k in ("duplicate_of", "repro_attempts", "gitea_issue", "gitea_synced_status"): if k in m: entry[k] = m[k] return entry def build_index(): data = { "version": 1, "generated": "by scripts/buglog.py from .wolf/bugs/*.md -- do not edit", "bugs": [index_entry(r) for r in load_all()], } _write_atomic(INDEX_PATH, json.dumps(data, indent=2, ensure_ascii=False) + "\n") def next_id(): ids = [p.stem for p in BUGS_DIR.glob("*.md")] if ARCHIVE_PATH.exists(): ids += [b.get("id", "") for b in json.loads(ARCHIVE_PATH.read_text(encoding="utf-8")).get("bugs", [])] nums = [int(m.group(1)) for i in ids if (m := re.match(r"bug-(\d+)", i))] return f"bug-{max(nums, default=0) + 1:03d}" # ── Checks against the record standard ────────────────────────────────────── def _empty(value): v = (value or "").strip() return not v or v.upper().startswith("TODO") or v == NOT_RECORDED def _item(section, label): """Value of a `- Label: value` list item, or None.""" m = re.search(r"^\s*[-*] " + re.escape(label) + r":[ \t]*(.*)$", section or "", re.M) return m.group(1).strip() if m else None def _bold(section, label): """Text after `**Label:**` up to the next bold label.""" m = re.search(r"\*\*" + re.escape(label) + r":\*\*(.*?)(?=^\*\*[\w ]+:\*\*|\Z)", section or "", re.S | re.M) return m.group(1).strip() if m else None def bad_commits(shas): bad = [] for sha in shas: if not re.fullmatch(r"[0-9a-f]{7,40}", str(sha)) or \ _run(["git", "-C", str(ROOT), "cat-file", "-e", f"{sha}^{{commit}}"]) is None: bad.append(sha) return bad def check(rec, target_status=None): """Problems with `rec` under the record standard, as a list of strings. `target_status` checks the record as if it were moved to that status: that is how `status` refuses an incomplete change. Legacy (migrated) records are exempt from the reporting sections, but a status change to fixed/partial still needs the fix-time sections. """ m, s, P = rec.meta, rec.sections, [] status = target_status or m.get("status") legacy = bool(m.get("legacy")) strict_fix = not legacy or target_status is not None if not ID_RE.fullmatch(str(m.get("id", ""))): P.append(f"id {m.get('id')!r} is not a bug id") elif rec.path and Path(rec.path).stem != m["id"]: P.append(f"id {m['id']} does not match file name {Path(rec.path).name}") if status not in STATUSES: P.append(f"status {status!r} is not one of {', '.join(STATUSES)}") if status not in ("fixed", None) and _empty(m.get("status_note")): P.append(f"status {status} needs a status_note (what remains / why / what was seen)") if status == "duplicate" and not m.get("duplicate_of"): P.append("status duplicate needs duplicate_of") if status == "cannot-reproduce" and not m.get("repro_attempts"): P.append("status cannot-reproduce needs repro_attempts (runs tried, date)") if not legacy: if m.get("component") not in COMPONENTS: P.append(f"component {m.get('component')!r} is not one of {', '.join(COMPONENTS)}") if m.get("severity") not in SEVERITIES: P.append(f"severity {m.get('severity')!r} is not one of {', '.join(SEVERITIES)}") if not re.fullmatch(r"\d{4}-\d\d-\d\dT\d\d:\d\d:\d\d(\.\d+)?Z", str(m.get("timestamp", ""))): P.append("timestamp must be ISO-8601 UTC, e.g. 2026-09-27T14:05:00Z") if _empty(rec.summary): P.append("Summary (the # title) is empty") elif len(rec.summary) > 100: P.append(f"Summary is {len(rec.summary)} chars, max 100") elif STATUS_WORDS.search(rec.summary): P.append("Summary must not carry status words (OPEN/FIXED/PRIORITY)") d = s.get("Description") for label in ("Observed", "Expected", "Impact", "Found by"): if _empty(_bold(d, label)): P.append(f"Description: **{label}:** is missing or empty") e = s.get("Environment") for label, rx, why in ( ("OS", r"\d+\.\d+", "distribution/version and kernel version"), ("libc", r"\d+\.\d+", "a version, from the failing binary's interpreter"), ("Toolchain", r"\d+\.\d+", "compiler and build tool versions"), ("Build", r"\b(asan|debug|release)\b", "the build profile: asan, debug or release"), ("Code", r"\b[0-9a-f]{7,40}\b", "the commit the binary was built from"), ("Deployment", r"\S", "topology, worker kind, hosts")): v = _item(e, label) if _empty(v): P.append(f"Environment: - {label}: is missing or empty ({why})") elif not re.search(rx, v): P.append(f"Environment: - {label}: needs {why}") r = s.get("Reproduction") or "" steps = re.search(r"^\s*\d+\.\s+(?!TODO)\S", r, re.M) or "```" in r if not steps: P.append("Reproduction: needs numbered steps (1. ) or a code block") freq = _item(r, "Frequency") if _empty(freq): P.append("Reproduction: - Frequency: is missing (deterministic, or N/M runs with the date)") elif not re.search(r"deterministic|\d+\s*/\s*\d+", freq, re.I): P.append("Reproduction: - Frequency: must be 'deterministic' or 'N/M runs, '") if _empty(_item(r, "Evidence")): P.append("Reproduction: - Evidence: is missing (a durable path, or 'not kept')") if strict_fix and status in ("fixed", "partial"): for name in ("Root cause", "Fix"): if _empty(s.get(name)): P.append(f"{name}: required for status {status}") if strict_fix and status == "fixed": v = s.get("Verification") if _empty(v): P.append("Verification: required for status fixed") else: for label in ("Test", "Suites"): if _empty(_item(v, label)): P.append(f"Verification: - {label}: is missing or empty") commits = m.get("fix_commits") or [] if not commits: P.append("fix_commits: status fixed needs the fix commit(s) (status ... --commit )") for sha in bad_commits(commits): P.append(f"fix_commits: {sha} is not a commit in this repository") return P # ── Environment collection ─────────────────────────────────────────────────── def _os_line(): kernel = f"{platform.system()} {platform.release()} {platform.machine()}" distro = None for f in ("/etc/os-release", "/usr/lib/os-release"): if os.path.exists(f): m = re.search(r'^PRETTY_NAME="?([^"\n]*)', Path(f).read_text(), re.M) distro = m.group(1) if m else None break parts = [distro or "no /etc/os-release"] if os.path.exists("/.dockerenv"): parts.append("Docker container") try: if "NixOS" in Path("/proc/version").read_text(): parts.append("NixOS kernel") except OSError: pass return "; ".join(parts + [f"kernel {kernel}"]) def _libc_line(binary): if binary: out = _run(["readelf", "-l", str(binary)]) or "" m = re.search(r"interpreter: ([^\]]+)\]", out) if m: v = re.search(r"glibc-(\d+\.\d+[\w.-]*)", m.group(1)) name = f"glibc {v.group(1)}" if v else "unknown libc" return f"{name} (interpreter {m.group(1)})" out = _run(["ldd", "--version"]) or "" v = re.search(r"(\d+\.\d+)", out.split("\n")[0]) if out else None host = f"glibc {v.group(1)}" if v else "unknown" return f"TODO: pass the failing binary (`buglog.py env `); host ldd reports {host}" def _toolchain_line(): probe = "gcc --version | head -1; meson --version" if os.environ.get("IN_NIX_SHELL") or not shutil.which("nix"): out = _run(["bash", "-c", probe]) else: out = _run(["nix", "develop", str(ROOT / "src"), "--command", "bash", "-c", probe], cwd=str(ROOT)) if not out: return "TODO: gcc and meson versions from the shell that built the binary" gcc, _, meson = out.partition("\n") return f"{gcc.strip()}, meson {meson.strip()}" def _build_line(binary): profile = None if binary: for d, p in (("xce.build", "asan"), ("build.dbg", "debug"), ("build.release", "release")): if d in str(binary): profile = f"{p} ({d})" line = profile or "TODO: asan (xce.build) | debug (build.dbg) | release (build.release)" if os.environ.get("ASAN_OPTIONS"): line += f", ASAN_OPTIONS={os.environ['ASAN_OPTIONS']}" return line def _code_line(): sha = _run(["git", "-C", str(ROOT), "rev-parse", "--short=8", "HEAD"]) if not sha: return "TODO: branch @ commit" branch = _run(["git", "-C", str(ROOT), "rev-parse", "--abbrev-ref", "HEAD"]) or "?" dirty = _run(["git", "-C", str(ROOT), "status", "--porcelain", "--untracked-files=no"]) return f"{branch} @ {sha}" + ("-dirty" if dirty else "") def collect_env(binary=None): return "\n".join([ f"- OS: {_os_line()}", f"- libc: {_libc_line(binary)}", f"- Toolchain: {_toolchain_line()}", f"- Build: {_build_line(binary)}", f"- Code: {_code_line()}", "- Deployment: TODO: topology (single / 3-node HA / multihost), worker kind, hosts", ]) TEMPLATE = { "Description": "**Observed:**\nTODO: the exact error, log line or sanitizer text, in a code block\n\n" "**Expected:** TODO\n**Impact:** TODO\n" "**Found by:** TODO: user report | soak | ASAN | audit | review | test | spec task", "Reproduction": "1. TODO: commands someone else can paste\n" "- Frequency: TODO: deterministic | N/M runs, \n" "- Evidence: TODO: durable path to logs/cores/replays, or 'not kept'", "Root cause": "TODO: required for fixed/partial", "Fix": "TODO: required for fixed/partial", "Verification": "- Test: TODO: name + file; RED before the fix, GREEN after\n" "- Suites: TODO: suite results and build profile\n" "- Live: TODO: soak/deploy run, or n/a and why", "References": "- Related: none", } # ── Commands ───────────────────────────────────────────────────────────────── def _line(r): note = r.meta.get("status_note") or r.summary return f"{r.id:<10} {r.meta.get('status') or '-':<16} {note[:110]}" def _fail(problems, what): sys.exit(f"{what}:\n" + "\n".join(f" - {p}" for p in problems) + "\nStandard: .claude/skills/buglog/bug-record.md") def cmd_new(a): if a.component not in COMPONENTS: sys.exit(f"--component must be one of {', '.join(COMPONENTS)}") if a.severity not in SEVERITIES: sys.exit(f"--severity must be one of {', '.join(SEVERITIES)}") bid, now = next_id(), _now() meta = {"id": bid, "status": "open", "status_updated": _today(), "status_note": "newly logged", "component": a.component, "severity": a.severity, "timestamp": now, "last_seen": now, "occurrences": 1, "files": _split(a.files), "tags": _split(a.tags), "related": _split(a.related)} sections = dict(TEMPLATE) sections["Environment"] = collect_env(a.binary) rec = Record(meta, a.summary, {k: sections[k] for k in SECTIONS if k in sections}) save(rec) print(record_path(bid).relative_to(ROOT)) probs = check(load(bid)) if probs: print(f"{len(probs)} item(s) still to fill in; run `buglog.py check {bid}` when done.") def _staged(pathspec, diff_filter="ACMD"): out = _run(["git", "-C", str(ROOT), "diff", "--cached", "--name-only", f"--diff-filter={diff_filter}", "--", pathspec]) or "" return [p for p in out.split("\n") if p] def _staged_records(): return [Path(p).stem for p in _staged(".wolf/bugs/", "ACM") if p.endswith(".md")] _HEAD_INDEX = None def _head_status(bug_id): """(found, status) of the bug at HEAD: its record, else its entry in HEAD's buglog.json (how the migration commit is recognised).""" global _HEAD_INDEX text = _run(["git", "-C", str(ROOT), "show", f"HEAD:.wolf/bugs/{bug_id}.md"]) if text: try: return True, parse(text).meta.get("status") except BuglogEditError: return True, None if _HEAD_INDEX is None: raw = _run(["git", "-C", str(ROOT), "show", "HEAD:.wolf/buglog.json"]) try: _HEAD_INDEX = {b.get("id"): b.get("status") for b in json.loads(raw).get("bugs", [])} if raw else {} except (json.JSONDecodeError, AttributeError): _HEAD_INDEX = {} if bug_id in _HEAD_INDEX: return True, _HEAD_INDEX[bug_id] return False, None def cmd_check(a): global _HEAD_INDEX _HEAD_INDEX = None # read HEAD afresh on every check ids = a.ids or [] if a.staged: ids = _staged_records() elif a.all or not ids: ids = [r.id for r in load_all()] failed = 0 for bid in ids: rec = load(bid) target, extra = None, [] if a.staged and rec.meta.get("legacy"): found, head = _head_status(bid) if not found: extra.append("a new record cannot be legacy: true (only migrated records are)") elif head != rec.meta.get("status"): # a legacy record whose status changed in this commit must # meet what the new status requires target = rec.meta.get("status") probs = extra + check(rec, target_status=target) if probs: failed += 1 print(f"{bid}:") for p in probs: print(f" - {p}") if a.staged and (_staged(".wolf/bugs/") or _staged(".wolf/buglog.json")): expect = json.dumps({"version": 1, "generated": "by scripts/buglog.py from .wolf/bugs/*.md -- do not edit", "bugs": [index_entry(r) for r in load_all()]}, indent=2, ensure_ascii=False) + "\n" if not INDEX_PATH.exists() or INDEX_PATH.read_text(encoding="utf-8") != expect: failed += 1 print(".wolf/buglog.json is stale: run `python3 scripts/buglog.py index` and stage it") if failed: sys.exit(f"{failed} record(s) do not meet .claude/skills/buglog/bug-record.md") if not a.staged: print(f"{len(ids)} record(s) OK") def cmd_status(a): if a.status not in STATUSES: sys.exit(f"unknown status {a.status!r}; one of {', '.join(STATUSES)}") rec = load(a.id) rec.meta.update({"status": a.status, "status_updated": _today(), "status_note": a.note or ""}) if a.commit: rec.meta["fix_commits"] = list(dict.fromkeys((rec.meta.get("fix_commits") or []) + a.commit)) if a.duplicate_of: rec.meta["duplicate_of"] = a.duplicate_of if a.repro: rec.meta["repro_attempts"] = a.repro probs = check(rec, target_status=a.status) if probs: _fail(probs, f"{a.id}: not changed to {a.status}") set_meta(a.id, {k: rec.meta[k] for k in rec.meta}) print(_line(load(a.id))) def cmd_note(a): if a.section not in SECTIONS: sys.exit(f"--section must be one of {', '.join(SECTIONS)}") rec = load(a.id) old = rec.sections.get(a.section, "") old = "" if _empty(old) else old para = f"UPDATE ({_today()}): {escape_text(a.text)}" rec.sections[a.section] = f"{old}\n\n{para}" if old else para save(rec) def cmd_recur(a): rec = load(a.id) fields = {"occurrences": int(rec.meta.get("occurrences") or 1) + 1, "last_seen": _now()} reopen = {"cannot-reproduce": "investigating", "fixed": "open", "wontfix": "open"} if rec.meta.get("status") in reopen: fields.update(status=reopen[rec.meta["status"]], status_updated=_today()) fields["status_note"] = a.note or f"recurred {_today()} (was {rec.meta['status']})" elif a.note: fields["status_note"] = a.note set_meta(a.id, fields) print(_line(load(a.id))) def cmd_set(a): fields = {} for kv in a.pairs: key, sep, raw = kv.partition("=") if not sep or not key: sys.exit(f"bad argument {kv!r}: expected key=value") try: fields[key] = json.loads(raw) except json.JSONDecodeError: fields[key] = raw set_meta(a.id, fields) def cmd_list(a): want = OPEN_STATUSES if a.open else set(_split(a.status)) or None rx = re.compile(a.grep, re.I) if a.grep else None for r in load_all(): m = r.meta if want is not None and m.get("status") not in want: continue if a.tag and a.tag not in (m.get("tags") or []): continue if a.since and max(m.get("last_seen") or "", m.get("status_updated") or "") < a.since: continue if rx and not rx.search(render(r)): continue print(_line(r)) def main(argv=None): p = argparse.ArgumentParser(description=__doc__.split("\n")[0], epilog="Status values: " + ", ".join(STATUSES)) sub = p.add_subparsers(dest="cmd", required=True) s = sub.add_parser("new") s.add_argument("--summary", required=True) s.add_argument("--component", required=True, help=", ".join(COMPONENTS)) s.add_argument("--severity", required=True, help=", ".join(SEVERITIES)) s.add_argument("--binary", help="the failing binary, for its libc and build profile") s.add_argument("--tags") s.add_argument("--files") s.add_argument("--related") s.set_defaults(fn=cmd_new) s = sub.add_parser("check") s.add_argument("ids", nargs="*") s.add_argument("--all", action="store_true") s.add_argument("--staged", action="store_true", help="records staged for commit (the pre-commit hook)") s.set_defaults(fn=cmd_check) s = sub.add_parser("status") s.add_argument("id") s.add_argument("status") s.add_argument("--note") s.add_argument("--commit", action="append", default=[], help="fix commit (repeatable); required for fixed") s.add_argument("--duplicate-of") s.add_argument("--repro") s.set_defaults(fn=cmd_status) s = sub.add_parser("note") s.add_argument("id") s.add_argument("text") s.add_argument("--section", default="Description") s.set_defaults(fn=cmd_note) s = sub.add_parser("recur") s.add_argument("id") s.add_argument("--note") s.set_defaults(fn=cmd_recur) s = sub.add_parser("set") s.add_argument("id") s.add_argument("pairs", nargs="+") s.set_defaults(fn=cmd_set) s = sub.add_parser("show") s.add_argument("id") s.set_defaults(fn=lambda a: print(render(load(a.id)), end="")) s = sub.add_parser("list") s.add_argument("--status") s.add_argument("--open", action="store_true") s.add_argument("--tag") s.add_argument("--grep") s.add_argument("--since") s.set_defaults(fn=cmd_list) s = sub.add_parser("triage") s.set_defaults(fn=lambda a: [print(_line(r)) for r in load_all() if r.meta.get("status") not in STATUSES]) s = sub.add_parser("env") s.add_argument("binary", nargs="?") s.set_defaults(fn=lambda a: print(collect_env(a.binary))) s = sub.add_parser("index") s.set_defaults(fn=lambda a: build_index()) s = sub.add_parser("next-id") s.set_defaults(fn=lambda a: print(next_id())) a = p.parse_args(argv) try: a.fn(a) except BuglogEditError as e: sys.exit(str(e)) if __name__ == "__main__": main()