All checks were successful
Sync Beads to Gitea Issues / sync-beads (push) Successful in 7s
bd sync: 2026-01-19 21:41:13
155 lines
5.7 KiB
Python
155 lines
5.7 KiB
Python
import json
|
||
import os
|
||
import requests
|
||
import sys
|
||
|
||
# Configuration from Environment
|
||
TOKEN = os.getenv("GITEA_TOKEN")
|
||
URL = os.getenv("GITEA_URL")
|
||
REPO = os.getenv("REPO_NAME")
|
||
HEADERS = {"Authorization": f"token {TOKEN}", "Content-Type": "application/json"}
|
||
|
||
|
||
def sync():
|
||
beads_path = ".beads/issues.jsonl"
|
||
|
||
if not os.path.exists(beads_path):
|
||
print(f"❌ ERROR: {beads_path} not found.")
|
||
sys.exit(1)
|
||
|
||
print(f"🔍 Reading Beads from: {beads_path}")
|
||
|
||
# 1. Fetch existing issues from Gitea
|
||
try:
|
||
api_url = f"{URL}/api/v1/repos/{REPO}/issues?state=all"
|
||
resp = requests.get(api_url, headers=HEADERS)
|
||
resp.raise_for_status()
|
||
# Map by [ID] to identify existing ones
|
||
existing = {
|
||
i["title"].split("]")[0][1:]: i for i in resp.json() if "]" in i["title"]
|
||
}
|
||
print(f"📡 Found {len(existing)} existing issues in Gitea.")
|
||
except Exception as e:
|
||
print(f"❌ Gitea API Connection Failed: {e}")
|
||
sys.exit(1)
|
||
|
||
# 2. Parse the Beads JSONL file
|
||
processed_count = 0
|
||
skipped_count = 0
|
||
deleted_count = 0
|
||
|
||
with open(beads_path, "r") as f:
|
||
for line in f:
|
||
line = line.strip()
|
||
if not line:
|
||
continue
|
||
|
||
try:
|
||
data = json.loads(line)
|
||
bid = data.get("id")
|
||
status = data.get("status")
|
||
title = data.get("title")
|
||
itype = data.get("issue_type", "unknown")
|
||
|
||
if not bid:
|
||
print("⚠️ Skipping line: No ID found.")
|
||
continue
|
||
|
||
# --- DELETION / TOMBSTONE LOGIC ---
|
||
if status == "tombstone":
|
||
if bid in existing:
|
||
issue = existing[bid]
|
||
issue_num = issue["number"]
|
||
|
||
# Check if it's already closed/archived in Gitea
|
||
if issue["state"] == "closed" and issue["title"].startswith(
|
||
"[DELETED]"
|
||
):
|
||
print(
|
||
f"👻 Skipping tombstone: {bid} (Already archived in Gitea)"
|
||
)
|
||
skipped_count += 1
|
||
continue
|
||
|
||
print(
|
||
f"🔒 Archiving Gitea Issue #{issue_num} (Tombstone found for {bid})"
|
||
)
|
||
archive_payload = {
|
||
"state": "closed",
|
||
"title": f"[DELETED] {title}",
|
||
}
|
||
|
||
patch_resp = requests.patch(
|
||
f"{URL}/api/v1/repos/{REPO}/issues/{issue_num}",
|
||
headers=HEADERS,
|
||
json=archive_payload,
|
||
)
|
||
|
||
# Accept 200 or 201 as success
|
||
if patch_resp.status_code in [200, 201]:
|
||
print(f"✅ Successfully archived {bid}")
|
||
|
||
# Add a comment about the deletion
|
||
comment_payload = {
|
||
"body": f"⚠️ This issue was deleted in **Beads** (ID: `{bid}`). It has been automatically closed and renamed for archival purposes."
|
||
}
|
||
comment_url = (
|
||
f"{URL}/api/v1/repos/{REPO}/issues/{issue_num}/comments"
|
||
)
|
||
requests.post(
|
||
comment_url, headers=HEADERS, json=comment_payload
|
||
)
|
||
|
||
deleted_count += 1
|
||
else:
|
||
print(
|
||
f"⚠️ Could not archive {bid}: {patch_resp.status_code}"
|
||
)
|
||
else:
|
||
print(f"👻 Skipping tombstone: {bid} (Not found in Gitea)")
|
||
skipped_count += 1
|
||
continue
|
||
# --- STANDARD SYNC LOGIC ---
|
||
processed_count += 1
|
||
gitea_state = (
|
||
"closed" if status in ["closed", "done", "finished"] else "open"
|
||
)
|
||
|
||
payload = {
|
||
"title": f"[{bid}] {title}",
|
||
"body": f"{data.get('description', 'No description provided.')}\n\n---\n**Beads ID:** `{bid}`\n**Type:** `{itype}`",
|
||
"state": gitea_state,
|
||
}
|
||
|
||
if bid in existing:
|
||
print(f"✅ Updating {bid} (Issue #{existing[bid]['number']})")
|
||
requests.patch(
|
||
f"{URL}/api/v1/repos/{REPO}/issues/{existing[bid]['number']}",
|
||
headers=HEADERS,
|
||
json=payload,
|
||
)
|
||
else:
|
||
print(f"➕ Creating New Gitea Issue for {bid}")
|
||
r = requests.post(
|
||
f"{URL}/api/v1/repos/{REPO}/issues",
|
||
headers=HEADERS,
|
||
json=payload,
|
||
)
|
||
r.raise_for_status()
|
||
|
||
except Exception as e:
|
||
print(f"⚠️ Error processing bead line: {e}")
|
||
|
||
print(
|
||
f"🏁 Finished. Active: {processed_count}, Deleted: {deleted_count}, Skipped: {skipped_count}"
|
||
)
|
||
|
||
# Safety check to ensure we didn't process a completely empty file
|
||
if (processed_count + deleted_count + skipped_count) == 0:
|
||
print("❌ ERROR: File was found but it was EMPTY.")
|
||
sys.exit(1)
|
||
|
||
|
||
if __name__ == "__main__":
|
||
sync()
|