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:
parent
8330997c5a
commit
19104f1940
7 changed files with 3333 additions and 171 deletions
|
|
@ -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()
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue