myxce/scripts/buglog.py
Rongsong Shen d26ff8870d
feat(buglog): one Markdown record per bug, checked against a record standard
The buglog was one 27K-line JSON file whose fields covered only symptom,
root cause and fix, as free text: 2% of entries named the code version a
bug was found on, 12% the build profile, 36% a way to reproduce, and 58% of
the file was OpenWolf hook auto-detections ("Missing guard clause",
"Function not marked async" on .md files).

- .claude/skills/buglog/bug-record.md: the record standard. Eight
  sections (Summary as the title, Description, Environment, Reproduction;
  Root cause, Fix, Verification, References once fixed), each with the
  content it must contain -- e.g. Environment needs the OS, the libc the
  failing binary loads (from its ELF interpreter: glibc 2.42 in the nix
  shell vs 2.40 on the host), toolchain, build profile and commit.
- .wolf/bugs/<id>.md: 500 records migrated from buglog.json; every field
  verified to round-trip. They carry legacy: true and an explicit "not
  recorded" where the old log had no such field; component derived from
  file paths, severity from keywords (else unknown), fix_commits from
  hashes in status_note/fix that git resolves (91 of 463 fixed).
- .wolf/buglog-archive.json: the 710 hook auto-detections, verbatim.
- .wolf/buglog.json is now an index generated from the records, keeping
  the fields OpenWolf's hooks and `openwolf bug search` read.
- scripts/buglog.py rewritten: new (template + Environment collected from
  the machine and --binary), check, status (refuses a status the record
  doesn't support; fixed needs Root cause, Fix, Verification and
  --commit hashes git resolves), note, recur, set, show, list, triage,
  env, index. 29 unittest cases in scripts/tests/test_buglog.py.
- scripts/git-hooks/pre-commit runs `check --staged`: incomplete staged
  records, a legacy record hand-changed to fixed without the fix-time
  sections, a new record claiming legacy: true, or a stale index refuse
  the commit.
- scripts/gitea-sync.py reads the records: issue body = the record's
  sections plus linked fix commits (origin remote, GITEA_COMMIT_REPO to
  override), and a body hash so an issue's text is refreshed when the
  record changes.
- bug-1866 rewritten to the full standard as the worked example.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
2026-09-28 03:40:11 +00:00

788 lines
30 KiB
Python

#!/usr/bin/env python3
"""Read, write and check the XCE bug records: one Markdown file per bug.
Each bug is `.wolf/bugs/<id>.md`: a front-matter block of short machine
fields (one `key: <JSON value>` per line, which is also valid YAML), then
`# <id>: <summary>` 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=<json> ... 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. <command>) 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, <date>'")
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 <sha>)")
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 <binary>`); 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, <date>\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()