feat(buglog): status field, buglog skill, status-driven gitea-sync

The buglog had no status field, so "is this bug open?" meant reading free
text, and gitea-sync closed any issue whose `fix` text was non-empty --
including "NOT FIXED"/"PENDING"/"PARTIALLY FIXED" ones (bug-1855).

- Backfill `status` (+ status_updated) on all 1,193 entries. The 85 entries
  whose text said not-fixed/pending/out-of-scope were checked against HEAD
  and git history; the rest are `fixed`. Result: 36 open, 10 partial,
  2 investigating, 5 cannot-reproduce, 4 wontfix, 1 duplicate.
  Insert-only diff: existing lines are untouched.
- .claude/rules/buglog-status.md defines the status values (kept out of
  openwolf.md, which `openwolf update` overwrites).
- scripts/buglog.py + buglog skill: add (next free id), status, note,
  recur, list, show, triage. Edits only the touched entry's lines; a full
  json.dumps rewrite churns ~1,200 lines of mixed \u-escaping.
- gitea-sync (script + skill): state and a single bug:<status> label come
  from `status`; reopen supported; issues shared by several entries follow
  the most-open one; only status labels are swapped; per-entry write-back
  with gitea_synced_status so unchanged entries cost no API calls; list-typed
  `fix` no longer crashes; spec issues close on done phases (spec.json has
  no implementation approval); --since no longer floods Gitea after the
  backfill.
- Disable OpenWolf buglog auto-detection: its hook numbers new entries
  len(bugs)+1, which collides with existing ids (bug-1856, upstream).

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
This commit is contained in:
Rongsong Shen 2026-09-24 09:40:43 +00:00
parent 8330997c5a
commit 19104f1940
Signed by untrusted user: rshen
GPG key ID: 80533536B0EB651C
7 changed files with 3333 additions and 171 deletions

335
scripts/buglog.py Normal file
View file

@ -0,0 +1,335 @@
#!/usr/bin/env python3
"""Read and edit .wolf/buglog.json without re-serializing it.
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.
The `status` values and when to use each are defined in
.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.
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.
Importable: set_fields(path, bug_id, {key: value}), BuglogEditError.
"""
import argparse
import datetime
import json
import os
import re
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"}
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 _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 _find(bugs, bug_id):
for b in bugs:
if b.get("id") == bug_id:
return b
raise BuglogEditError(f"{bug_id}: no such entry")
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 cmd_add(a, path):
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 ""}
if a.duplicate_of:
fields["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)))
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_recur(a, path):
b = _find(_load(path), a.id)
fields = {"occurrences": int(b.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']})"
elif a.note:
fields["status_note"] = a.note
set_fields(path, a.id, fields)
print(_line(_find(_load(path), a.id)))
def cmd_set(a, path):
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_fields(path, 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):
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:
continue
if a.tag and a.tag not in (b.get("tags") or []):
continue
if a.since and max(b.get("last_seen") or "", b.get("status_updated") or "") < a.since:
continue
if rx and not rx.search(json.dumps(b, ensure_ascii=False)):
continue
print(_line(b))
def cmd_triage(a, path):
for b in _load(path):
if b.get("status") not in STATUSES:
print(_line(b))
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.add_argument("--tags")
s.add_argument("--related")
s.add_argument("--status", default="open")
s.add_argument("--note")
s.set_defaults(fn=cmd_add)
s = sub.add_parser("status")
s.add_argument("id")
s.add_argument("status")
s.add_argument("--note")
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.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=cmd_show)
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=cmd_triage)
s = sub.add_parser("next-id")
s.set_defaults(fn=lambda a, path: print(next_id(_load(path))))
a = p.parse_args(argv)
try:
a.fn(a, a.path)
except BuglogEditError as e:
sys.exit(str(e))
if __name__ == "__main__":
main()

View file

@ -34,6 +34,9 @@ import urllib.parse
import urllib.request
from pathlib import Path
sys.path.insert(0, str(Path(__file__).resolve().parent))
from buglog import BuglogEditError, set_fields # noqa: E402
# ── Helpers ──────────────────────────────────────────────────────────────────
@ -120,6 +123,27 @@ LABELS = [
"description": "Bug found during development"},
{"name": "bug:fixed", "color": "27AE60",
"description": "Bug resolved and fix committed"},
{"name": "bug:partial", "color": "F39C12",
"description": "Partly fixed, remainder still open"},
{"name": "bug:investigating", "color": "E67E22",
"description": "Real failure, root cause not found yet"},
{"name": "bug:cannot-reproduce", "color": "95A5A6",
"description": "Reproduction attempted and failed"},
{"name": "bug:wontfix", "color": "7F8C8D",
"description": "Not a bug / accepted limitation"},
{"name": "bug:duplicate", "color": "BDC3C7",
"description": "Duplicate of another bug"},
# Bug category labels (from buglog tags, see TAG_TO_LABEL)
{"name": "bug:asan", "color": "9B59B6",
"description": "ASAN/UBSAN caught bug"},
{"name": "bug:crash", "color": "C0392B",
"description": "SEGV/UAF/double-free crash"},
{"name": "bug:data-loss", "color": "C0392B",
"description": "Committed data loss bug"},
{"name": "bug:ha", "color": "E67E22",
"description": "HA/Raft/consensus bug"},
{"name": "bug:race", "color": "F39C12",
"description": "Data race / TOCTOU bug"},
# State labels
{"name": "blocked", "color": "E74C3C",
"description": "Blocked on external dependency or upstream spec"},
@ -143,6 +167,45 @@ TAG_TO_LABEL = {
"failover": "bug:ha",
}
# buglog `status` -> (Gitea issue state, status label). The buglog's `status`
# field is the only source of truth for open/closed (see
# .claude/rules/buglog-status.md); never infer it from `fix`, which often says
# "NOT FIXED" / "PENDING" / "PARTIALLY FIXED". An entry with no status (e.g.
# created by an OpenWolf hook) is mirrored as open and reported for triage.
STATUS_MAP = {
"open": ("open", None),
"partial": ("open", "bug:partial"),
"investigating": ("open", "bug:investigating"),
"fixed": ("closed", "bug:fixed"),
"cannot-reproduce": ("closed", "bug:cannot-reproduce"),
"wontfix": ("closed", "bug:wontfix"),
"duplicate": ("closed", "bug:duplicate"),
}
# When several buglog entries share one Gitea issue (e.g. a user-reported
# issue whose cause was split into several bugs), the issue takes the status
# of its "most open" entry, first in this list.
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")
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 _text(value):
"""buglog text fields are a string or, in some entries, a list of strings."""
if isinstance(value, list):
return "\n\n".join(str(v).strip() for v in value if str(v).strip())
return (value or "").strip()
def api_url(path):
"""Build full API URL. All paths are repo-scoped."""
@ -245,9 +308,18 @@ class GiteaAPI:
return issue
def update_issue(self, issue_number, **fields):
"""Update issue fields (state, title, body, milestone, labels)."""
"""Update issue fields (state, title, body, milestone)."""
request("PATCH", f"/issues/{issue_number}", fields)
def add_labels(self, issue_number, label_ids):
request("POST", f"/issues/{issue_number}/labels", {"labels": label_ids})
def remove_label(self, issue_number, label_id):
request("DELETE", f"/issues/{issue_number}/labels/{label_id}")
def add_comment(self, issue_number, body):
request("POST", f"/issues/{issue_number}/comments", {"body": body})
# ── Label Sync ───────────────────────────────────────────────────────────────
@ -259,40 +331,27 @@ def ensure_all_labels(api):
# ── Bug Tag → Label Mapping ──────────────────────────────────────────────────
def bug_labels(bug_entry):
"""Derive Gitea labels from buglog tags."""
labels = ["bug"]
tags = [t.lower() for t in bug_entry.get("tags", [])]
def status_label(status):
return STATUS_MAP.get(status, (None, None))[1]
# Fixed bugs get bug:fixed label
fix = bug_entry.get("fix", "")
if fix and fix.strip():
labels.append("bug:fixed")
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."""
labels = ["bug"]
if status_label(bug_status(bug_entry)):
labels.append(status_label(bug_status(bug_entry)))
# Map known tag patterns to labels
for tag in tags:
for tag in (t.lower() for t in bug_entry.get("tags", [])):
for pattern, label in TAG_TO_LABEL.items():
if pattern.lower() in tag:
if label not in labels:
labels.append(label)
# Ensure any needed labels exist in Gitea
# (bug:asan, bug:crash, bug:data-loss, bug:ha, bug:race may not exist yet)
extra_labels = {
"bug:asan": ("9B59B6", "ASAN/UBSAN caught bug"),
"bug:crash": ("C0392B", "SEGV/UAF/double-free crash"),
"bug:data-loss": ("C0392B", "Committed data loss bug"),
"bug:ha": ("E67E22", "HA/Raft/consensus bug"),
"bug:race": ("F39C12", "Data race / TOCTOU bug"),
}
for lbl in labels:
if lbl in extra_labels and lbl not in LABEL_NAMES:
pass # handled below
return labels, extra_labels
if pattern.lower() in tag and label not in labels:
labels.append(label)
return labels
LABEL_NAMES = {l["name"] for l in LABELS}
# ── Body Formatting ──────────────────────────────────────────────────────────
@ -301,35 +360,44 @@ def bug_body(bug_entry):
"""Format a buglog entry as a Gitea issue body."""
parts = []
error = bug_entry.get("error_message", "")
status = bug_entry.get("status") or "unknown (needs triage)"
note = _text(bug_entry.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("")
error = _text(bug_entry.get("error_message"))
if error:
parts.append("## Error\n")
parts.append(error.strip())
parts.append(error)
parts.append("")
root_cause = bug_entry.get("root_cause", "")
root_cause = _text(bug_entry.get("root_cause"))
if root_cause:
parts.append("## Root Cause\n")
parts.append(root_cause.strip())
parts.append(root_cause)
parts.append("")
fix = bug_entry.get("fix", "")
if fix and fix.strip():
fix = _text(bug_entry.get("fix"))
if fix:
parts.append("## Fix\n")
parts.append(fix.strip())
parts.append(fix)
parts.append("")
else:
parts.append("## Fix\n")
parts.append("*Not yet fixed*")
parts.append("")
file_ = bug_entry.get("file", "")
if file_:
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]
if files:
parts.append("## Affected Files\n")
for f in file_.split(","):
f = f.strip()
if f:
parts.append(f"- `{f}`")
for f in files:
parts.append(f"- `{f}`")
parts.append("")
tags = bug_entry.get("tags", [])
@ -355,7 +423,13 @@ def bug_body(bug_entry):
def bug_title(bug_entry):
"""Build a short title from a buglog entry."""
bid = bug_entry.get("id", "bug-?")
error = bug_entry.get("error_message", "")
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()
@ -533,6 +607,27 @@ PHASE_DEFS = [
]
# spec.json `phase` values that mean the spec's work is finished. There is no
# implementation approval in spec.json (approvals cover requirements/design/
# tasks only), and no tool distinguishes "implemented" from
# "implementation-complete", so both count as done.
DONE_PHASES = {"implementation-complete", "implemented", "superseded"}
def _spec_desired_states(spec_data):
"""Desired Gitea state per phase-issue key, plus the milestone's."""
approvals = spec_data.get("approvals", {})
req_ok = approvals.get("requirements", {}).get("approved", False)
des_ok = approvals.get("design", {}).get("approved", False)
done = spec_data.get("phase") in DONE_PHASES
closed = lambda cond: "closed" if cond else "open"
return {
"requirements_design": closed((req_ok and des_ok) or done),
"implementation": closed(done),
"validation": closed(done),
}, closed(done)
def sync_spec(api, spec_name, dry_run=False, quiet=False):
"""Sync one spec directory to Gitea milestone + phase issues."""
spec_dir = SPECS_DIR / spec_name
@ -595,14 +690,7 @@ def sync_spec(api, spec_name, dry_run=False, quiet=False):
for phase_def in PHASE_DEFS:
title = f"[{spec_name}] {phase_def['title_suffix']}"
# Determine if this phase issue should start closed
state = "open"
if phase_def["key"] == "requirements_design":
approvals = spec_data.get("approvals", {})
req_ok = approvals.get("requirements", {}).get("approved", False)
des_ok = approvals.get("design", {}).get("approved", False)
if req_ok and des_ok:
state = "closed"
state = _spec_desired_states(spec_data)[0][phase_def["key"]]
# Build body
if phase_def.get("body_fn_extra"):
@ -638,25 +726,9 @@ def _sync_spec_phase_states(api, spec_name, spec_data, gitea_state,
print(f" [DRY-RUN] check phase states for '{spec_name}'")
return
approvals = spec_data.get("approvals", {})
req_ok = approvals.get("requirements", {}).get("approved", False)
des_ok = approvals.get("design", {}).get("approved", False)
tasks_ok = approvals.get("tasks", {}).get("approved", False)
impl_ok = approvals.get("implementation", {}).get("approved", False)
issues = gitea_state.get("issues", {})
# Build desired states for each phase issue
desired = {}
rd_issue = issues.get("requirements_design")
if rd_issue:
desired[rd_issue] = "closed" if (req_ok and des_ok) else "open"
impl_issue = issues.get("implementation")
if impl_issue:
desired[impl_issue] = "closed" if impl_ok else "open"
val_issue = issues.get("validation")
if val_issue:
desired[val_issue] = "closed" if impl_ok else "open"
by_key, milestone_state = _spec_desired_states(spec_data)
desired = {issues[k]: st for k, st in by_key.items() if issues.get(k)}
# Fetch existing issues once
existing_issues = api.list_issues(
@ -676,15 +748,14 @@ def _sync_spec_phase_states(api, spec_name, spec_data, gitea_state,
f"{current['state']} -> {target_state}")
# Update milestone state
all_approved = req_ok and des_ok and tasks_ok and impl_ok
milestone_id = gitea_state.get("milestone_id")
if all_approved and milestone_id:
all_milestones = api.list_milestones(state="all")
ms = all_milestones.get(spec_name)
if ms and ms.get("state") != "closed":
api.update_milestone(milestone_id, state="closed")
if milestone_id:
ms = api.list_milestones(state="all").get(spec_name)
if ms and ms.get("state") != milestone_state:
api.update_milestone(milestone_id, state=milestone_state)
if not quiet:
print(f" closed milestone '{spec_name}'")
print(f" milestone '{spec_name}': "
f"{ms['state']} -> {milestone_state}")
def _spec_milestone_desc(spec_name, spec_data):
@ -715,93 +786,137 @@ def _resolve_label_ids(api, label_names):
def sync_bugs(api, dry_run=False, quiet=False, since=None):
"""Sync buglog entries 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.
Args:
since: Only sync bugs with last_seen >= this date (ISO format: YYYY-MM-DD).
Helps avoid flooding Gitea with ancient auto-detected bugs.
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.
"""
if not BUGLOG_PATH.exists():
warn(f"buglog not found: {BUGLOG_PATH}")
return
buglog_wrapper = json.loads(BUGLOG_PATH.read_text())
buglog = buglog_wrapper["bugs"]
created = 0
closed = 0
skipped = 0
buglog = json.loads(BUGLOG_PATH.read_text())["bugs"]
created = closed = reopened = updated = skipped = 0
needs_status = []
for i, bug in enumerate(buglog):
bid = bug.get("id", f"bug-{i:04d}")
# Aggregate status per already-synced issue (see STATUS_PRIORITY).
issue_status = {}
for bug in buglog:
num = bug.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)
done_issues = set()
# Apply --since filter
for bug in buglog:
bid = bug.get("id")
if not bid:
continue
# --since: new issues only for recently seen bugs; status changes
# (status_updated) are still mirrored for already-synced ones.
if since:
last_seen = bug.get("last_seen", "")
if last_seen < since:
recent = (bug.get("last_seen") or "") >= since
if bug.get("gitea_issue"):
recent = recent or (bug.get("status_updated") or "") >= since
if not recent:
skipped += 1
continue
gitea_issue_num = bug.get("gitea_issue")
status = bug_status(bug)
if status is None:
needs_status.append(bid)
synced_status = status or "unknown"
state = bug_state(bug)
issue_num = bug.get("gitea_issue")
if gitea_issue_num:
# Already synced — check if state changed
fix_now = bool(bug.get("fix", "").strip())
# We don't track previous fix state, so check Gitea
if issue_num and bug.get("gitea_synced_status") == synced_status:
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.
agg = issue_status[issue_num]
agg_state = STATUS_MAP.get(agg, ("open", None))[0]
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)
continue
try:
_, issue = request("GET", f"/issues/{gitea_issue_num}")
if issue and issue.get("state") == "open" and fix_now:
if dry_run:
print(f" [DRY-RUN] close bug #{gitea_issue_num}: {bid}")
else:
api.update_issue(gitea_issue_num, state="closed")
# Add bug:fixed label via API
request(
"PUT",
f"/issues/{gitea_issue_num}/labels",
{"labels": [api.list_labels().get("bug:fixed", {}).get("id", 0)]}
)
if 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")
skipped += 1
continue
labels = api.list_labels()
want = status_label(agg)
for lbl in issue.get("labels") or []:
if lbl["name"] in STATUS_LABELS and lbl["name"] != want:
api.remove_label(issue_num, lbl["id"])
have = {lbl["name"] for lbl in issue.get("labels") or []}
if want and want not in have and want in labels:
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"))
api.add_comment(issue_num, f"{bid} status: **{synced_status}**"
+ (f" — {note}" if note else ""))
if agg_state == "closed":
closed += 1
else:
reopened += 1
if not quiet:
print(f" closed #{gitea_issue_num}: {bid}")
closed += 1
else:
skipped += 1
except Exception as e:
warn(f" {bid}: could not check issue #{gitea_issue_num}: {e}")
print(f" #{issue_num} {bid}: {issue['state']} -> "
f"{agg_state} ({agg or 'unknown'})")
else:
updated += 1
done_issues.add(issue_num)
set_fields(BUGLOG_PATH, bid, {"gitea_synced_status": synced_status})
except (RuntimeError, BuglogEditError) as e:
warn(f" {bid}: could not update issue #{issue_num}: {e}")
skipped += 1
continue
# New bug — create issue
# New bug -- create issue
title = bug_title(bug)
body = bug_body(bug)
fix = bug.get("fix", "").strip()
state = "closed" if fix else "open"
if dry_run:
print(f" [DRY-RUN] create {'CLOSED' if state == 'closed' else 'OPEN'} "
f"issue: {title}")
print(f" [DRY-RUN] create {state.upper()} issue: {title}")
created += 1
continue
# Determine labels
labels, extra = bug_labels(bug)
for ename, (ecolor, edesc) in extra.items():
if ename in labels:
api.ensure_label(ename, ecolor, edesc)
label_ids = _resolve_label_ids(api, labels)
issue = api.create_issue(title, body, labels=label_ids, state=state)
bug["gitea_issue"] = issue["number"]
state_mark = " (closed)" if state == "closed" else ""
label_ids = _resolve_label_ids(api, bug_labels(bug))
issue = api.create_issue(title, bug_body(bug), 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})
if not quiet:
print(f" created #{issue['number']}: {title}{state_mark}")
mark = " (closed)" if state == "closed" else ""
print(f" created #{issue['number']}: {title}{mark}")
created += 1
# Write back updated buglog if any new issues were created
if created > 0 and not dry_run:
buglog_wrapper["bugs"] = buglog
_write_json(BUGLOG_PATH, buglog_wrapper)
if not quiet:
print(f"Bugs: {created} created, {closed} closed, {skipped} skipped")
print(f"Bugs: {created} created, {closed} closed, {reopened} reopened, "
f"{updated} relabelled, {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)}")
# ── JSON Writing ─────────────────────────────────────────────────────────────
@ -828,7 +943,7 @@ def main():
parser.add_argument("--bugs", action="store_true",
help="Sync bugs only")
parser.add_argument("--since", metavar="DATE",
help="Only sync bugs with last_seen >= DATE (YYYY-MM-DD)")
help="Create issues only for bugs last seen >= DATE; update synced ones changed since (YYYY-MM-DD)")
parser.add_argument("--quiet", action="store_true",
help="Suppress progress output, print only errors")
args = parser.parse_args()