fix(gitea-sync): survive live Gitea replies; point project memory at /buglog

gitea-sync.py fixes found on the first live run (bug-1855 follow-up):
- 204/empty-body replies (label DELETE) no longer crash in json.loads
- every request has a 60s timeout, so a stalled call fails instead of hanging
- spec.json sync state is inserted as a trailing "gitea" block instead of
  re-serializing the file (15 hand-formatted spec.json files would reflow)
- implementation issue body labels generated vs approved correctly; the
  validation body uses the same done-phase set as the close rules

Project memory now routes all buglog writes through the buglog skill:
CLAUDE.md key rule, a cerebrum Do-Not-Repeat entry (with the older
duplicate-id / json.dump / tags-vs-fix lessons tagged as superseded), and
xce-audit-invariants step 5.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
This commit is contained in:
Rongsong Shen 2026-09-24 10:21:51 +00:00
parent 19104f1940
commit e915f8def4
Signed by untrusted user: rshen
GPG key ID: 80533536B0EB651C
4 changed files with 53 additions and 20 deletions

View file

@ -223,8 +223,10 @@ def request(method, path, body=None):
req.add_header("Accept", "application/json")
try:
with urllib.request.urlopen(req) as resp:
return resp.status, json.loads(resp.read().decode("utf-8"))
with urllib.request.urlopen(req, timeout=60) as resp:
raw = resp.read().decode("utf-8")
# DELETE (and some PUT/POST) answer 204 with an empty body.
return resp.status, (json.loads(raw) if raw.strip() else None)
except urllib.error.HTTPError as e:
if e.code == 404:
return None, None
@ -240,6 +242,8 @@ def request(method, path, body=None):
raise RuntimeError(
f"Gitea API unreachable: {method} {path}\n{e.reason}"
) from e
except TimeoutError as e:
raise RuntimeError(f"Gitea API timed out: {method} {path}") from e
# ── GiteaAPI ─────────────────────────────────────────────────────────────────
@ -493,9 +497,11 @@ def spec_implementation_body(spec_name, spec_data, tasks_path):
parts.append("")
# Phase status
tasks_approved = spec_data.get("approvals", {}).get("tasks", {}).get("approved", False)
tasks = spec_data.get("approvals", {}).get("tasks", {})
ready = spec_data.get("ready_for_implementation", False)
parts.append(f"**Tasks generated**: {tasks_approved}")
parts.append(f"**Phase**: {spec_data.get('phase', 'unknown')}")
parts.append(f"**Tasks generated**: {tasks.get('generated', False)}")
parts.append(f"**Tasks approved**: {tasks.get('approved', False)}")
parts.append(f"**Ready for implementation**: {ready}")
parts.append("")
@ -539,10 +545,9 @@ def spec_validation_body(spec_name, spec_data):
if v.get("generated") and not v.get("approved")
]
if "complete" in phase or (
approvals.get("implementation") and
approvals["implementation"].get("approved")
):
if phase == "superseded":
parts.append(f"**Status**: Superseded by {spec_data.get('superseded_by', '?')}")
elif phase in DONE_PHASES:
parts.append("**Status**: ✅ Complete")
elif open_phases:
parts.append(f"**Status**: ⏳ Pending — awaiting: {', '.join(open_phases)}")
@ -715,7 +720,7 @@ def sync_spec(api, spec_name, dry_run=False, quiet=False):
"milestone_id": milestone_id,
"issues": phase_issues,
}
_write_json(spec_json_path, spec_data)
_append_top_level_key(spec_json_path, "gitea", spec_data["gitea"])
def _sync_spec_phase_states(api, spec_name, spec_data, gitea_state,
@ -919,6 +924,29 @@ def sync_bugs(api, dry_run=False, quiet=False, since=None):
f".claude/rules/buglog-status.md: {' '.join(needs_status)}")
def _append_top_level_key(path, key, value):
"""Add `key` as the last member of a JSON file's top-level object without
re-serializing the rest (spec.json files use hand-formatted inline arrays
that a json.dumps rewrite would reflow). Falls back to a full rewrite when
the key already exists or the file doesn't end in a plain '}' line."""
text = path.read_text()
data = json.loads(text)
lines = text.rstrip("\n").split("\n")
if key in data or lines[-1] != "}":
data[key] = value
_write_json(path, data)
return
body = json.dumps(value, indent=2, ensure_ascii=False).split("\n")
last = len(lines) - 2
if lines[last].strip() and not lines[last].rstrip().endswith(("{", ",")):
lines[last] = lines[last].rstrip() + ","
lines[-1:] = [f" {json.dumps(key)}: {body[0]}"] + [" " + b for b in body[1:]] + ["}"]
out = "\n".join(lines) + "\n"
if json.loads(out) != {**data, key: value}:
raise RuntimeError(f"{path}: surgical write did not round-trip")
path.write_text(out)
# ── JSON Writing ─────────────────────────────────────────────────────────────
def _write_json(path, data):