Compare commits

..
1 Commits
Author SHA1 Message Date
rootiest 23beb115fa feat: integrate beads
Sync Beads to Gitea Issues / sync-beads (push) Successful in 7s
bd sync: 2026-01-19 21:41:13
2026-01-19 21:51:41 -05:00
2 changed files with 139 additions and 35 deletions
+4
View File
@@ -0,0 +1,4 @@
{"id":"gitea-welcome-0kp","title":"Reverse-sync Test","status":"tombstone","priority":2,"issue_type":"task","owner":"chris@rootiest.com","created_at":"2026-01-19T21:09:52.497127424-05:00","created_by":"rootiest","updated_at":"2026-01-19T21:22:16.221889237-05:00","comments":[{"id":2,"issue_id":"gitea-welcome-0kp","author":"rootiest","text":"It","created_at":"2026-01-20T02:11:02Z"}],"deleted_at":"2026-01-19T21:22:16.221889237-05:00","deleted_by":"batch delete","delete_reason":"batch delete","original_type":"task"}
{"id":"gitea-welcome-24k","title":"Reverse-sync Test","status":"tombstone","priority":2,"issue_type":"task","owner":"chris@rootiest.com","created_at":"2026-01-19T21:13:44.940389131-05:00","created_by":"rootiest","updated_at":"2026-01-19T21:22:05.359383908-05:00","deleted_at":"2026-01-19T21:22:05.359383908-05:00","deleted_by":"batch delete","delete_reason":"batch delete","original_type":"task"}
{"id":"gitea-welcome-5a4","title":"Test Issue","status":"tombstone","priority":2,"issue_type":"task","owner":"chris@rootiest.com","created_at":"2026-01-19T19:24:28.988421899-05:00","created_by":"rootiest","updated_at":"2026-01-19T21:41:09.873551106-05:00","close_reason":"Closed","comments":[{"id":1,"issue_id":"gitea-welcome-5a4","author":"rootiest","text":"Completed!","created_at":"2026-01-20T01:07:50Z"},{"id":3,"issue_id":"gitea-welcome-5a4","author":"rootiest","text":"New","created_at":"2026-01-20T02:29:01Z"}],"deleted_at":"2026-01-19T21:41:09.873551106-05:00","deleted_by":"daemon","delete_reason":"delete","original_type":"task"}
{"id":"gitea-welcome-d6r","title":"Reverse-sync Test #2","status":"tombstone","priority":2,"issue_type":"task","owner":"chris@rootiest.com","created_at":"2026-01-19T21:13:44.746027824-05:00","created_by":"rootiest","updated_at":"2026-01-19T21:22:10.935659182-05:00","deleted_at":"2026-01-19T21:22:10.935659182-05:00","deleted_by":"batch delete","delete_reason":"batch delete","original_type":"task"}
+128 -28
View File
@@ -1,54 +1,154 @@
import json import json
import os import os
import requests import requests
import sys
# Configuration # Configuration from Environment
TOKEN = os.getenv("GITEA_TOKEN") TOKEN = os.getenv("GITEA_TOKEN")
URL = os.getenv("GITEA_URL") URL = os.getenv("GITEA_URL")
REPO = os.getenv("REPO_NAME") REPO = os.getenv("REPO_NAME")
HEADERS = {"Authorization": f"token {TOKEN}", "Content-Type": "application/json"} HEADERS = {"Authorization": f"token {TOKEN}", "Content-Type": "application/json"}
def get_gitea_issues():
"""Fetch all issues from Gitea to map by their Beads ID."""
url = f"{URL}/api/v1/repos/{REPO}/issues?state=all"
resp = requests.get(url, headers=HEADERS)
resp.raise_for_status()
# We store the Beads ID in the title or a hidden comment to track them
return {issue['title'].split(']')[0][1:]: issue for issue in resp.json() if ']' in issue['title']}
def sync(): def sync():
beads_file = ".beads/issues.jsonl" beads_path = ".beads/issues.jsonl"
if not os.path.exists(beads_file):
print("No beads found.")
return
gitea_issues = get_gitea_issues() if not os.path.exists(beads_path):
print(f"❌ ERROR: {beads_path} not found.")
sys.exit(1)
with open(beads_file, "r") as f: 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: for line in f:
line = line.strip()
if not line:
continue
try:
data = json.loads(line) data = json.loads(line)
bid = data.get("id") bid = data.get("id")
status = data.get("status")
title = data.get("title") title = data.get("title")
desc = data.get("description", "") itype = data.get("issue_type", "unknown")
# Mapping Beads status to Gitea states
is_closed = data.get("status") in ["closed", "done", "finished"] if not bid:
target_state = "closed" if is_closed else "open" 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 = { payload = {
"title": f"[{bid}] {title}", "title": f"[{bid}] {title}",
"body": f"{desc}\n\n---\n**Beads ID:** `{bid}`\n**Priority:** {data.get('priority', 'N/A')}", "body": f"{data.get('description', 'No description provided.')}\n\n---\n**Beads ID:** `{bid}`\n**Type:** `{itype}`",
"state": target_state "state": gitea_state,
} }
if bid in gitea_issues: if bid in existing:
# Update existing issue if state or title changed print(f"✅ Updating {bid} (Issue #{existing[bid]['number']})")
issue_number = gitea_issues[bid]['number'] requests.patch(
print(f"Updating Issue #{issue_number} ({bid})") f"{URL}/api/v1/repos/{REPO}/issues/{existing[bid]['number']}",
requests.patch(f"{URL}/api/v1/repos/{REPO}/issues/{issue_number}", headers=HEADERS, json=payload) headers=HEADERS,
json=payload,
)
else: else:
# Create new issue print(f" Creating New Gitea Issue for {bid}")
print(f"Creating New Issue for {bid}") r = requests.post(
requests.post(f"{URL}/api/v1/repos/{REPO}/issues", headers=HEADERS, json=payload) 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__": if __name__ == "__main__":
sync() sync()