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
|
|
@ -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)}")
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue