from __future__ import annotations
import argparse
import json
import sys
from datetime import datetime, timezone
from pathlib import Path
REPO = Path(__file__).resolve().parents[1]
JSONL = REPO / ".beads" / "issues.jsonl"
def dump(record: dict) -> str:
return json.dumps(record)
def now() -> str:
return datetime.now(timezone.utc).strftime("%Y-%m-%dT%H:%M:%SZ")
def load() -> list[dict]:
return [json.loads(line) for line in JSONL.read_text().splitlines() if line.strip()]
def save(records: list[dict], before: list[dict]) -> None:
old = {r["id"]: r for r in before}
new = {r["id"]: r for r in records}
for lost in sorted(set(old) - set(new)):
raise SystemExit(f"refusing to write: {lost} would disappear")
for issue_id in sorted(set(old) & set(new)):
a, b = old[issue_id], new[issue_id]
if a.get("status") == "closed" and b.get("status") != "closed":
raise SystemExit(f"refusing to write: {issue_id} would reopen; that is a human action")
if len(b.get("notes") or "") < len(a.get("notes") or ""):
raise SystemExit(f"refusing to write: {issue_id} notes would shrink")
if len(b.get("comments") or []) < len(a.get("comments") or []):
raise SystemExit(f"refusing to write: {issue_id} comments would shrink")
JSONL.write_text("".join(dump(r) + "\n" for r in records))
def find(records: list[dict], issue_id: str) -> dict:
for record in records:
if record.get("id") == issue_id:
return record
raise SystemExit(f"no such issue: {issue_id}")
def main() -> int:
ap = argparse.ArgumentParser(description=__doc__.split("\n")[0])
sub = ap.add_subparsers(dest="cmd", required=True)
p_close = sub.add_parser("close", help="close an issue with a reason")
p_close.add_argument("id")
p_close.add_argument("--reason", required=True)
p_note = sub.add_parser("note", help="append to an issue's notes")
p_note.add_argument("id")
p_note.add_argument("--text", required=True)
p_list = sub.add_parser("list")
p_list.add_argument("--status")
p_create = sub.add_parser("create", help="file a new issue")
p_create.add_argument("title")
p_create.add_argument("--description", default="")
p_create.add_argument("--priority", type=int, default=3)
p_create.add_argument("--type", dest="issue_type", default="task")
p_create.add_argument("--label", action="append", default=[],
help="repeatable; e.g. --label pitch")
args = ap.parse_args()
records = load()
before = json.loads(json.dumps(records))
if args.cmd == "list":
for record in records:
if args.status and record.get("status") != args.status:
continue
print(f"{record['id']:<14} {record.get('status', '?'):<11} {record.get('title', '')}")
return 0
stamp = now()
if args.cmd == "create":
import hashlib
prefix = records[0]["id"].rsplit("-", 1)[0] if records else "issue"
existing = {r["id"] for r in records}
digest = hashlib.sha256((args.title + stamp).encode()).hexdigest()
for i in range(0, len(digest) - 3):
candidate = f"{prefix}-{digest[i:i + 3]}"
if candidate not in existing:
break
else:
raise SystemExit("could not derive a fresh id")
record = {
"id": candidate,
"title": args.title,
"description": args.description,
"status": "open",
"priority": args.priority,
"issue_type": args.issue_type,
"owner": "noreply@anthropic.com",
"created_at": stamp,
"created_by": "Claude",
"updated_at": stamp,
}
if args.label:
record["labels"] = sorted(args.label)
records.append(record)
save(records, before)
print(f"created {candidate}")
return 0
record = find(records, args.id)
if args.cmd == "close":
if record.get("status") == "closed":
print(f"{args.id} is already closed", file=sys.stderr)
return 1
record["status"] = "closed"
record["closed_at"] = stamp
record["close_reason"] = args.reason
record["updated_at"] = stamp
print(f"closed {args.id}")
elif args.cmd == "note":
existing = record.get("notes") or ""
record["notes"] = (existing + "\n\n" + args.text).strip() if existing else args.text
record["updated_at"] = stamp
print(f"noted {args.id}")
save(records, before)
return 0
if __name__ == "__main__":
raise SystemExit(main())