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:
Rongsong Shen 2026-09-28 03:40:11 +00:00
parent 27b7a84980
commit d26ff8870d
Signed by untrusted user: rshen
GPG key ID: 80533536B0EB651C
512 changed files with 40724 additions and 17843 deletions

View file

@ -3,7 +3,7 @@
xce-gitea-sync: Sync cc-sdd specs and OpenWolf buglog to Gitea issues.
Reads .kiro/specs/<feature>/ (spec.json, brief.md, requirements.md, tasks.md)
and .wolf/buglog.json as ground truth. Creates/updates Gitea milestones, issues,
and the bug records in .wolf/bugs/*.md as ground truth. Creates/updates Gitea milestones, issues,
and labels to mirror the local state.
Sync is one-way: files -> Gitea. Never Gitea -> files.
@ -20,6 +20,8 @@ Environment:
GITEA_TOKEN - API token (required: write:issue, write:milestone scopes)
GITEA_OWNER - repo owner (default: nexa)
GITEA_REPO - repo name (default: xce)
GITEA_COMMIT_REPO - owner/repo that fix-commit links point at
(default: the `origin` remote's owner/repo)
"""
import argparse
@ -28,6 +30,7 @@ import hashlib
import json
import os
import re
import subprocess
import sys
import urllib.error
import urllib.parse
@ -35,7 +38,8 @@ import urllib.request
from pathlib import Path
sys.path.insert(0, str(Path(__file__).resolve().parent))
from buglog import BuglogEditError, set_fields # noqa: E402
import buglog # noqa: E402
from buglog import BuglogEditError # noqa: E402
# ── Helpers ──────────────────────────────────────────────────────────────────
@ -99,7 +103,6 @@ REPO_API = f"{API_BASE}/repos/{GITEA_OWNER}/{GITEA_REPO}"
SCRIPT_DIR = Path(__file__).resolve().parent
PROJECT_ROOT = SCRIPT_DIR.parent
SPECS_DIR = PROJECT_ROOT / ".kiro" / "specs"
BUGLOG_PATH = PROJECT_ROOT / ".wolf" / "buglog.json"
# ── Label Definitions ────────────────────────────────────────────────────────
@ -190,14 +193,14 @@ STATUS_PRIORITY = ["open", "investigating", "partial", None,
"cannot-reproduce", "duplicate", "wontfix", "fixed"]
def bug_status(bug_entry):
"""The entry's status, or None when it has none / an unknown one."""
status = bug_entry.get("status")
def bug_status(rec):
"""The record's status, or None when it has none / an unknown one."""
status = rec.meta.get("status")
return status if status in STATUS_MAP else None
def bug_state(bug_entry):
return STATUS_MAP.get(bug_status(bug_entry), ("open", None))[0]
def bug_state(rec):
return STATUS_MAP.get(bug_status(rec), ("open", None))[0]
def _text(value):
@ -342,14 +345,20 @@ def status_label(status):
STATUS_LABELS = {lbl for _, lbl in STATUS_MAP.values() if lbl}
def bug_labels(bug_entry):
"""Derive Gitea labels from the entry's status and tags."""
SEVERITY_TO_LABEL = {"crash": "bug:crash", "data-loss": "bug:data-loss"}
def bug_labels(rec):
"""Derive Gitea labels from the record's status, severity and tags."""
labels = ["bug"]
if status_label(bug_status(bug_entry)):
labels.append(status_label(bug_status(bug_entry)))
if status_label(bug_status(rec)):
labels.append(status_label(bug_status(rec)))
sev = SEVERITY_TO_LABEL.get(rec.meta.get("severity"))
if sev:
labels.append(sev)
# Map known tag patterns to labels
for tag in (t.lower() for t in bug_entry.get("tags", [])):
for tag in (t.lower() for t in rec.meta.get("tags") or []):
for pattern, label in TAG_TO_LABEL.items():
if pattern.lower() in tag and label not in labels:
labels.append(label)
@ -360,88 +369,73 @@ def bug_labels(bug_entry):
# ── Body Formatting ──────────────────────────────────────────────────────────
def bug_body(bug_entry):
"""Format a buglog entry as a Gitea issue body."""
parts = []
def _commit_repo():
"""owner/repo that fix-commit links point at: GITEA_COMMIT_REPO, else the
`origin` remote (fixes land on a fork branch before they reach
GITEA_OWNER/GITEA_REPO, so a bare hash there may not resolve yet)."""
repo = os.environ.get("GITEA_COMMIT_REPO")
if repo:
return repo
try:
url = subprocess.run(["git", "-C", str(PROJECT_ROOT), "remote", "get-url", "origin"],
capture_output=True, text=True, check=True).stdout.strip()
except (OSError, subprocess.CalledProcessError):
return f"{GITEA_OWNER}/{GITEA_REPO}"
m = re.search(r"[:/]([^/:]+/[^/]+?)(?:\.git)?$", url)
return m.group(1) if m else f"{GITEA_OWNER}/{GITEA_REPO}"
status = bug_entry.get("status") or "unknown (needs triage)"
note = _text(bug_entry.get("status_note"))
def bug_body(rec):
"""Format a bug record as a Gitea issue body: a status header, then the
record's own sections (.claude/skills/buglog/bug-record.md)."""
m = rec.meta
parts = []
status = m.get("status") or "unknown (needs triage)"
note = _text(m.get("status_note"))
parts.append(f"**Status:** {status}" + (f" — {note}" if note else ""))
if bug_entry.get("duplicate_of"):
parts.append(f"Duplicate of {bug_entry['duplicate_of']}")
if bug_entry.get("repro_attempts"):
parts.append(f"Repro attempts: {_text(bug_entry['repro_attempts'])}")
parts.append(f"**Component:** {m.get('component', 'unknown')} · "
f"**Severity:** {m.get('severity', 'unknown')}")
commits = m.get("fix_commits") or []
if commits:
base = f"{GITEA_URL}/{_commit_repo()}/commit"
parts.append("**Fix commits:** " + ", ".join(f"[`{c}`]({base}/{c})" for c in commits))
if m.get("duplicate_of"):
parts.append(f"**Duplicate of:** {m['duplicate_of']}")
if m.get("repro_attempts"):
parts.append(f"**Repro attempts:** {_text(m['repro_attempts'])}")
parts.append("")
error = _text(bug_entry.get("error_message"))
if error:
parts.append("## Error\n")
parts.append(error)
for name in buglog.SECTIONS:
text = buglog.unescape_text(rec.sections.get(name, "")).strip()
if not text or text == buglog.NOT_RECORDED:
continue
parts.append(f"## {name}\n")
parts.append(text)
parts.append("")
root_cause = _text(bug_entry.get("root_cause"))
if root_cause:
parts.append("## Root Cause\n")
parts.append(root_cause)
parts.append("")
fix = _text(bug_entry.get("fix"))
if fix:
parts.append("## Fix\n")
parts.append(fix)
parts.append("")
else:
parts.append("## Fix\n")
parts.append("*Not yet fixed*")
parts.append("")
files = [f.strip() for f in (bug_entry.get("file") or "").split(",")]
files += [str(f).strip() for f in bug_entry.get("files") or []]
files = [f for f in dict.fromkeys(files) if f]
files = m.get("files") or []
if files:
parts.append("## Affected Files\n")
for f in files:
parts.append(f"- `{f}`")
parts.extend(f"- `{f}`" for f in files)
parts.append("")
tags = bug_entry.get("tags", [])
tags = m.get("tags") or []
if tags:
parts.append("## Tags\n")
parts.append(" ".join(f"`{t}`" for t in tags))
parts.append("")
related = bug_entry.get("related_bugs", [])
if related:
parts.append("## Related Bugs\n")
for rb in related:
parts.append(f"- {rb}")
parts.append("")
parts.append("---")
parts.append(f"*Synced from `.wolf/buglog.json` "
f"({bug_entry.get('id', '?')})*")
parts.append(f"*Synced from `.wolf/bugs/{rec.id}.md`*")
return "\n".join(parts)
def bug_title(bug_entry):
"""Build a short title from a buglog entry."""
bid = bug_entry.get("id", "bug-?")
error = _text(bug_entry.get("error_message"))
# Drop a leading status banner ("OPEN, NOT FIXED -- ...", "PRIORITY: LOW --
# ...") -- status lives in labels/state, not the title.
error = re.sub(r"^(?:\s*(?:OPEN|NOT (?:YET )?FIXED|NOT A CRASH[^,.]*|"
r"NOT YET ROOT-CAUSED|DEAD-CODE-ONLY|PRIORITY: \w+|"
r"low priority|pre-existing)\b\s*(?:,|--|\.|;)?)+\s*",
"", error, flags=re.I) or error
# Take first sentence or first 100 chars
first_line = error.split("\n")[0].strip()
first_sentence = re.split(r'[.。]\s+', first_line)[0].strip()
if not first_sentence:
first_sentence = first_line
if len(first_sentence) > 120:
first_sentence = first_sentence[:117] + "..."
return f"[{bid}] {first_sentence}"
def bug_title(rec):
title = f"[{rec.id}] {rec.summary}"
return title if len(title) <= 120 else title[:119] + "…"
def body_hash(title, body):
return hashlib.sha1(f"{title}\n{body}".encode("utf-8")).hexdigest()[:12]
def spec_requirements_body(spec_name, spec_data, brief_path, req_path, design_path):
@ -789,88 +783,90 @@ def _resolve_label_ids(api, label_names):
# ── Bug Sync ─────────────────────────────────────────────────────────────────
def sync_bugs(api, dry_run=False, quiet=False, since=None, ids=None):
"""Sync buglog entries to Gitea issues.
"""Sync bug records (.wolf/bugs/*.md) to Gitea issues.
Open/closed and the status label come from each entry's `status` (see
STATUS_MAP). `gitea_synced_status` records the status last mirrored, so an
entry is only touched when its status changed -- including going back to
open (reopen). Sync state is written back per entry, surgically, so a
failure part-way through never loses an already-created issue number.
Open/closed and the status label come from each record's `status` (see
STATUS_MAP). `gitea_synced_status` records the status last mirrored, so a
record is only re-mirrored when its status changed -- including going back
to open (reopen). `gitea_synced_hash` records the title+body last written,
so an issue's body is refreshed whenever the record's text changes (a bug
created open gets its Fix/Verification/commits once it is fixed). Sync
state is written back per record, so a failure part-way through never
loses an already-created issue number.
Args:
since: ISO date (YYYY-MM-DD). Create issues only for bugs with
last_seen >= since; update already-synced ones whose
last_seen or status_updated >= since.
ids: set of bug ids to sync regardless of dates. Combined with
since, a bug is synced if it matches either -- bulk triage
has bumped last_seen/status_updated across most of the log,
so dates alone cannot pick out recently fixed older bugs.
since, a bug is synced if it matches either.
"""
if not BUGLOG_PATH.exists():
warn(f"buglog not found: {BUGLOG_PATH}")
records = buglog.load_all()
if not records:
warn(f"no bug records in {buglog.BUGS_DIR}")
return
buglog = json.loads(BUGLOG_PATH.read_text())["bugs"]
created = closed = reopened = updated = skipped = 0
created = closed = reopened = updated = refreshed = skipped = 0
needs_status = []
# Aggregate status per already-synced issue (see STATUS_PRIORITY).
issue_status = {}
for bug in buglog:
num = bug.get("gitea_issue")
for rec in records:
num = rec.meta.get("gitea_issue")
if num:
prev = issue_status.get(num, "fixed")
cur = bug_status(bug)
issue_status[num] = min(prev, cur, key=STATUS_PRIORITY.index)
issue_status[num] = min(prev, bug_status(rec), key=STATUS_PRIORITY.index)
done_issues = set()
for bug in buglog:
bid = bug.get("id")
if not bid:
continue
for rec in records:
m, bid = rec.meta, rec.id
# --since: new issues only for recently seen bugs; status changes
# (status_updated) are still mirrored for already-synced ones.
if since or ids:
recent = False
if since:
recent = (bug.get("last_seen") or "") >= since
if bug.get("gitea_issue"):
recent = recent or (bug.get("status_updated") or "") >= since
recent = (m.get("last_seen") or m.get("timestamp") or "") >= since
if m.get("gitea_issue"):
recent = recent or (m.get("status_updated") or "") >= since
if ids and bid in ids:
recent = True
if not recent:
skipped += 1
continue
status = bug_status(bug)
status = bug_status(rec)
if status is None:
needs_status.append(bid)
synced_status = status or "unknown"
state = bug_state(bug)
issue_num = bug.get("gitea_issue")
state = bug_state(rec)
issue_num = m.get("gitea_issue")
title, body = bug_title(rec), bug_body(rec)
digest = body_hash(title, body)
if issue_num and bug.get("gitea_synced_status") == synced_status:
if issue_num and m.get("gitea_synced_status") == synced_status \
and m.get("gitea_synced_hash") == digest:
skipped += 1
continue
if issue_num:
# Status changed since the last sync (or was never recorded:
# issues synced before the status field existed were closed
# whenever `fix` was non-empty, so re-assert state for them).
# The issue itself follows the aggregate status of every entry
# linked to it; only status labels are touched, so labels a
# person put on the issue survive.
# The issue follows the aggregate status of every record linked
# to it; only status labels are touched, so labels a person put
# on the issue survive.
agg = issue_status[issue_num]
agg_state = STATUS_MAP.get(agg, ("open", None))[0]
text_changed = m.get("gitea_synced_hash") != digest
if dry_run:
if issue_num not in done_issues:
print(f" [DRY-RUN] #{issue_num} ({bid}): -> {agg_state}"
f" ({agg or 'unknown'})")
done_issues.add(issue_num)
if m.get("gitea_synced_status") != synced_status and issue_num not in done_issues:
print(f" [DRY-RUN] #{issue_num} ({bid}): -> {agg_state} ({agg or 'unknown'})")
done_issues.add(issue_num)
if text_changed:
print(f" [DRY-RUN] #{issue_num} ({bid}): refresh title/body")
continue
try:
if issue_num not in done_issues:
if text_changed:
api.update_issue(issue_num, title=title, body=body)
refreshed += 1
if not quiet:
print(f" #{issue_num} {bid}: refreshed title/body")
if m.get("gitea_synced_status") != synced_status and issue_num not in done_issues:
_, issue = request("GET", f"/issues/{issue_num}")
if issue is None:
warn(f" {bid}: issue #{issue_num} not found, skipping")
@ -886,9 +882,11 @@ def sync_bugs(api, dry_run=False, quiet=False, since=None, ids=None):
api.add_labels(issue_num, [labels[want]["id"]])
if issue.get("state") != agg_state:
api.update_issue(issue_num, state=agg_state)
note = _text(bug.get("status_note"))
note = _text(m.get("status_note"))
commits = ", ".join(m.get("fix_commits") or [])
api.add_comment(issue_num, f"{bid} status: **{synced_status}**"
+ (f" — {note}" if note else ""))
+ (f" — {note}" if note else "")
+ (f" (fix commits: {commits})" if commits else ""))
if agg_state == "closed":
closed += 1
else:
@ -899,26 +897,27 @@ def sync_bugs(api, dry_run=False, quiet=False, since=None, ids=None):
else:
updated += 1
done_issues.add(issue_num)
set_fields(BUGLOG_PATH, bid, {"gitea_synced_status": synced_status})
buglog.set_meta(bid, {"gitea_synced_status": synced_status,
"gitea_synced_hash": digest})
except (RuntimeError, BuglogEditError) as e:
warn(f" {bid}: could not update issue #{issue_num}: {e}")
skipped += 1
continue
# New bug -- create issue
title = bug_title(bug)
if dry_run:
print(f" [DRY-RUN] create {state.upper()} issue: {title}")
created += 1
continue
label_ids = _resolve_label_ids(api, bug_labels(bug))
issue = api.create_issue(title, bug_body(bug), labels=label_ids)
label_ids = _resolve_label_ids(api, bug_labels(rec))
issue = api.create_issue(title, body, labels=label_ids)
if state == "closed":
# Gitea may ignore `state` on create; close explicitly.
api.update_issue(issue["number"], state="closed")
set_fields(BUGLOG_PATH, bid, {"gitea_issue": issue["number"],
"gitea_synced_status": synced_status})
buglog.set_meta(bid, {"gitea_issue": issue["number"],
"gitea_synced_status": synced_status,
"gitea_synced_hash": digest})
if not quiet:
mark = " (closed)" if state == "closed" else ""
print(f" created #{issue['number']}: {title}{mark}")
@ -926,7 +925,7 @@ def sync_bugs(api, dry_run=False, quiet=False, since=None, ids=None):
if not quiet:
print(f"Bugs: {created} created, {closed} closed, {reopened} reopened, "
f"{updated} relabelled, {skipped} skipped")
f"{updated} relabelled, {refreshed} refreshed, {skipped} skipped")
if needs_status:
warn(f"{len(needs_status)} bug(s) have no status -- triage per "
f".claude/rules/buglog-status.md: {' '.join(needs_status)}")