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>
This commit is contained in:
parent
27b7a84980
commit
d26ff8870d
512 changed files with 40724 additions and 17843 deletions
|
|
@ -1,48 +1,82 @@
|
|||
#!/usr/bin/env python3
|
||||
"""Read and edit .wolf/buglog.json without re-serializing it.
|
||||
"""Read, write and check the XCE bug records: one Markdown file per bug.
|
||||
|
||||
Re-serializing the whole buglog (json.dumps / jq) rewrites ~1,200 unrelated
|
||||
lines because existing entries mix raw UTF-8 and \\uXXXX escapes, so every
|
||||
write here edits only the lines of the entry it touches. Every write is
|
||||
re-parsed before it is saved and saved atomically (tmp file + rename), the
|
||||
same way OpenWolf's own hooks write it.
|
||||
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.
|
||||
|
||||
The `status` values and when to use each are defined in
|
||||
.claude/rules/buglog-status.md.
|
||||
`.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:
|
||||
add --error MSG [--root-cause T] [--fix T] [--file F] [--tags a,b]
|
||||
[--related bug-1,bug-2] [--status S] [--note T]
|
||||
Append a new entry with the next free id; prints the id.
|
||||
status ID STATUS [--note T] [--duplicate-of ID] [--repro T]
|
||||
Set status + status_updated (today) + status_note. --note is
|
||||
required except for `fixed`; duplicate needs --duplicate-of,
|
||||
cannot-reproduce needs --repro.
|
||||
note ID TEXT Append an "UPDATE (date): TEXT" paragraph to `fix`.
|
||||
recur ID [--note T] The bug was seen again: bump occurrences/last_seen;
|
||||
cannot-reproduce -> investigating, fixed/wontfix -> open.
|
||||
set ID key=<json> ... Set arbitrary single-line fields.
|
||||
show ID Print one entry.
|
||||
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]
|
||||
One line per matching entry.
|
||||
triage List entries with no (or an unknown) status.
|
||||
next-id Print the id `add` would use.
|
||||
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: set_fields(path, bug_id, {key: value}), BuglogEditError.
|
||||
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
|
||||
|
||||
DEFAULT_PATH = Path(__file__).resolve().parent.parent / ".wolf" / "buglog.json"
|
||||
|
||||
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):
|
||||
|
|
@ -57,178 +91,590 @@ def _now():
|
|||
return datetime.datetime.now(datetime.timezone.utc).strftime("%Y-%m-%dT%H:%M:%SZ")
|
||||
|
||||
|
||||
def _load(path):
|
||||
return json.loads(Path(path).read_text(encoding="utf-8"))["bugs"]
|
||||
|
||||
|
||||
def _save(path, text):
|
||||
"""Refuse to write text that no longer parses; write atomically."""
|
||||
json.loads(text)
|
||||
path = Path(path)
|
||||
tmp = path.with_name(f"{path.name}.{os.getpid()}.tmp")
|
||||
tmp.write_text(text, encoding="utf-8")
|
||||
os.replace(tmp, path)
|
||||
|
||||
|
||||
def _entry_span(lines, bug_id):
|
||||
"""(start, end, indent) of the entry: start = its "id" line, end = its '}'."""
|
||||
id_re = re.compile(r'^(\s+)"id": ' + re.escape(json.dumps(bug_id)) + r",?$")
|
||||
starts = [i for i, ln in enumerate(lines) if id_re.match(ln)]
|
||||
if len(starts) != 1:
|
||||
raise BuglogEditError(f"{bug_id}: expected exactly one entry, found {len(starts)}")
|
||||
start = starts[0]
|
||||
indent = id_re.match(lines[start]).group(1)
|
||||
close_re = re.compile(r"^" + re.escape(indent[:-2]) + r"\},?$")
|
||||
end = next(i for i in range(start, len(lines)) if close_re.match(lines[i]))
|
||||
return start, end, indent
|
||||
|
||||
|
||||
def set_fields(path, bug_id, fields):
|
||||
"""Set each key in `fields` on entry `bug_id`, touching only its lines.
|
||||
|
||||
An existing single-line field is replaced in place; a new one is inserted
|
||||
right after the entry's "id" line. Multi-line (array/object) fields are
|
||||
refused -- edit those by hand.
|
||||
"""
|
||||
path = Path(path)
|
||||
lines = path.read_text(encoding="utf-8").split("\n")
|
||||
start, end, indent = _entry_span(lines, bug_id)
|
||||
|
||||
for key, value in fields.items():
|
||||
rendered = json.dumps(value, ensure_ascii=False)
|
||||
key_re = re.compile(r"^" + re.escape(indent) + re.escape(json.dumps(key)) + r": (.*?)(,?)$")
|
||||
hit = next((i for i in range(start, end) if key_re.match(lines[i])), None)
|
||||
if hit is not None:
|
||||
old, comma = key_re.match(lines[hit]).groups()
|
||||
if old in ("[", "{"):
|
||||
raise BuglogEditError(f"{bug_id}.{key}: multi-line value, edit by hand")
|
||||
lines[hit] = f"{indent}{json.dumps(key)}: {rendered}{comma}"
|
||||
else:
|
||||
lines.insert(start + 1, f"{indent}{json.dumps(key)}: {rendered},")
|
||||
end += 1
|
||||
|
||||
text = "\n".join(lines)
|
||||
entry = next(b for b in json.loads(text)["bugs"] if b.get("id") == bug_id)
|
||||
for key, value in fields.items():
|
||||
if entry[key] != value:
|
||||
raise BuglogEditError(f"{bug_id}.{key}: wrote {entry[key]!r}, wanted {value!r}")
|
||||
_save(path, text)
|
||||
|
||||
|
||||
def next_id(bugs):
|
||||
"""One past the highest numeric id. (OpenWolf's own hook uses len(bugs)+1,
|
||||
which collides with existing ids once any were renumbered or skipped.)"""
|
||||
nums = [int(m.group(1)) for b in bugs
|
||||
if (m := re.fullmatch(r"bug-(\d+)", str(b.get("id", ""))))]
|
||||
return f"bug-{max(nums, default=0) + 1:03d}"
|
||||
|
||||
|
||||
def append_entry(path, entry):
|
||||
"""Append `entry` as the last element of the `bugs` array."""
|
||||
path = Path(path)
|
||||
lines = path.read_text(encoding="utf-8").split("\n")
|
||||
close = max(i for i, ln in enumerate(lines) if ln == " ]")
|
||||
prev = close - 1
|
||||
if lines[prev] == " }":
|
||||
lines[prev] = " },"
|
||||
elif lines[prev] != " \"bugs\": [":
|
||||
raise BuglogEditError(f"unexpected line before bugs array end: {lines[prev]!r}")
|
||||
body = json.dumps(entry, indent=2, ensure_ascii=False).split("\n")
|
||||
lines[close:close] = [" " + ln for ln in body]
|
||||
text = "\n".join(lines)
|
||||
if json.loads(text)["bugs"][-1] != entry:
|
||||
raise BuglogEditError("appended entry did not round-trip")
|
||||
_save(path, text)
|
||||
|
||||
|
||||
def _split(value):
|
||||
return [v.strip() for v in (value or "").split(",") if v.strip()]
|
||||
|
||||
|
||||
def _text(value):
|
||||
if isinstance(value, list):
|
||||
return " || ".join(str(v) for v in value)
|
||||
return value or ""
|
||||
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
|
||||
|
||||
|
||||
def _find(bugs, bug_id):
|
||||
for b in bugs:
|
||||
if b.get("id") == bug_id:
|
||||
return b
|
||||
raise BuglogEditError(f"{bug_id}: no such entry")
|
||||
# ── 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 _line(b):
|
||||
note = b.get("status_note") or _text(b.get("error_message"))
|
||||
return f"{b['id']:<10} {b.get('status') or '-':<16} {note[:110]}"
|
||||
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 cmd_add(a, path):
|
||||
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)}")
|
||||
bugs = _load(path)
|
||||
bid = next_id(bugs)
|
||||
now = _now()
|
||||
entry = {"id": bid, "status": a.status, "status_updated": _today()}
|
||||
if a.note:
|
||||
entry["status_note"] = a.note
|
||||
entry.update({
|
||||
"timestamp": now,
|
||||
"error_message": a.error,
|
||||
"file": a.file or "",
|
||||
"root_cause": a.root_cause or "",
|
||||
"fix": a.fix or "",
|
||||
"tags": _split(a.tags),
|
||||
"related_bugs": _split(a.related),
|
||||
"occurrences": 1,
|
||||
"last_seen": now,
|
||||
})
|
||||
append_entry(path, entry)
|
||||
print(bid)
|
||||
|
||||
|
||||
def cmd_status(a, path):
|
||||
if a.status not in STATUSES:
|
||||
sys.exit(f"unknown status {a.status!r}; one of {', '.join(STATUSES)}")
|
||||
if a.status == "duplicate" and not a.duplicate_of:
|
||||
sys.exit("duplicate needs --duplicate-of")
|
||||
if a.status == "cannot-reproduce" and not a.repro:
|
||||
sys.exit("cannot-reproduce needs --repro (what was tried, e.g. '0/40 runs, 2026-08-01')")
|
||||
if a.note is None and a.status != "fixed":
|
||||
sys.exit(f"{a.status} needs --note (what remains / why / what was seen)")
|
||||
# Always (re)write the note so a stale one never outlives its status.
|
||||
fields = {"status": a.status, "status_updated": _today(),
|
||||
"status_note": a.note or ""}
|
||||
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:
|
||||
fields["duplicate_of"] = a.duplicate_of
|
||||
rec.meta["duplicate_of"] = a.duplicate_of
|
||||
if a.repro:
|
||||
fields["repro_attempts"] = a.repro
|
||||
set_fields(path, a.id, fields)
|
||||
print(_line(_find(_load(path), a.id)))
|
||||
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, path):
|
||||
fix = _find(_load(path), a.id).get("fix") or ""
|
||||
if isinstance(fix, list):
|
||||
raise BuglogEditError(f"{a.id}.fix is a list, edit by hand")
|
||||
para = f"UPDATE ({_today()}): {a.text}"
|
||||
set_fields(path, a.id, {"fix": f"{fix}\n\n{para}" if fix else para})
|
||||
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, path):
|
||||
b = _find(_load(path), a.id)
|
||||
fields = {"occurrences": int(b.get("occurrences") or 1) + 1, "last_seen": _now()}
|
||||
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 b.get("status") in reopen:
|
||||
fields.update(status=reopen[b["status"]], status_updated=_today())
|
||||
fields["status_note"] = a.note or f"recurred {_today()} (was {b['status']})"
|
||||
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_fields(path, a.id, fields)
|
||||
print(_line(_find(_load(path), a.id)))
|
||||
set_meta(a.id, fields)
|
||||
print(_line(load(a.id)))
|
||||
|
||||
|
||||
def cmd_set(a, path):
|
||||
def cmd_set(a):
|
||||
fields = {}
|
||||
for kv in a.pairs:
|
||||
key, sep, raw = kv.partition("=")
|
||||
|
|
@ -238,55 +684,53 @@ def cmd_set(a, path):
|
|||
fields[key] = json.loads(raw)
|
||||
except json.JSONDecodeError:
|
||||
fields[key] = raw
|
||||
set_fields(path, a.id, fields)
|
||||
set_meta(a.id, fields)
|
||||
|
||||
|
||||
def cmd_show(a, path):
|
||||
print(json.dumps(_find(_load(path), a.id), indent=2, ensure_ascii=False))
|
||||
|
||||
|
||||
def cmd_list(a, path):
|
||||
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 b in _load(path):
|
||||
if want is not None and b.get("status") not in want:
|
||||
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 (b.get("tags") or []):
|
||||
if a.tag and a.tag not in (m.get("tags") or []):
|
||||
continue
|
||||
if a.since and max(b.get("last_seen") or "", b.get("status_updated") or "") < a.since:
|
||||
if a.since and max(m.get("last_seen") or "", m.get("status_updated") or "") < a.since:
|
||||
continue
|
||||
if rx and not rx.search(json.dumps(b, ensure_ascii=False)):
|
||||
if rx and not rx.search(render(r)):
|
||||
continue
|
||||
print(_line(b))
|
||||
|
||||
|
||||
def cmd_triage(a, path):
|
||||
for b in _load(path):
|
||||
if b.get("status") not in STATUSES:
|
||||
print(_line(b))
|
||||
print(_line(r))
|
||||
|
||||
|
||||
def main(argv=None):
|
||||
p = argparse.ArgumentParser(description=__doc__.split("\n")[0],
|
||||
epilog="Status values: " + ", ".join(STATUSES))
|
||||
p.add_argument("--path", default=str(DEFAULT_PATH), help=argparse.SUPPRESS)
|
||||
sub = p.add_subparsers(dest="cmd", required=True)
|
||||
|
||||
s = sub.add_parser("add")
|
||||
s.add_argument("--error", required=True)
|
||||
s.add_argument("--root-cause")
|
||||
s.add_argument("--fix")
|
||||
s.add_argument("--file")
|
||||
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.add_argument("--status", default="open")
|
||||
s.add_argument("--note")
|
||||
s.set_defaults(fn=cmd_add)
|
||||
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)
|
||||
|
|
@ -294,6 +738,7 @@ def main(argv=None):
|
|||
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")
|
||||
|
|
@ -308,7 +753,7 @@ def main(argv=None):
|
|||
|
||||
s = sub.add_parser("show")
|
||||
s.add_argument("id")
|
||||
s.set_defaults(fn=cmd_show)
|
||||
s.set_defaults(fn=lambda a: print(render(load(a.id)), end=""))
|
||||
|
||||
s = sub.add_parser("list")
|
||||
s.add_argument("--status")
|
||||
|
|
@ -319,14 +764,22 @@ def main(argv=None):
|
|||
s.set_defaults(fn=cmd_list)
|
||||
|
||||
s = sub.add_parser("triage")
|
||||
s.set_defaults(fn=cmd_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, path: print(next_id(_load(path))))
|
||||
s.set_defaults(fn=lambda a: print(next_id()))
|
||||
|
||||
a = p.parse_args(argv)
|
||||
try:
|
||||
a.fn(a, a.path)
|
||||
a.fn(a)
|
||||
except BuglogEditError as e:
|
||||
sys.exit(str(e))
|
||||
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue