bloombase/scripts/sync_notion_to_forgejo.py
Stephan Kerkman 21f9759d3a feat: require Naomi verification before closing Forgejo issues
Done items stay open in Forgejo until Naomi ticks the new Verified
checkbox in Notion. Shows ' Awaiting Naomi's verification' or
' Verified by Naomi' in the issue body accordingly.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-05-31 12:57:17 +02:00

129 lines
4.1 KiB
Python

#!/usr/bin/env python3
"""
Sync BloomBase Notion feature tracker → Forgejo issues.
Notion is source of truth. Runs one-way: Notion → Forgejo.
Idempotent: tracks Notion page IDs in issue body to avoid duplicates.
Usage:
FORGEJO_TOKEN=<token> python3 scripts/sync_notion_to_forgejo.py
(Notion access via Claude MCP — run from a Claude Code session instead)
"""
import json
import os
import re
import urllib.request
import urllib.parse
FORGEJO_BASE = "https://git.kerkman.io/api/v1"
FORGEJO_REPO = "stephan/bloombase"
NOTION_MARKER = "notion-id:"
LABEL_MAP = {
"Aesthetics": 1,
"Functionality": 2,
"Bug": 3,
}
STATUS_MAP = {
"Open": "open",
"In progress": "open", # + "In progress" label (id=4)
"Done": "open", # stays open until Naomi ticks Verified
}
IN_PROGRESS_LABEL_ID = 4
def forgejo(method, path, data=None):
token = os.environ["FORGEJO_TOKEN"]
url = f"{FORGEJO_BASE}{path}"
body = json.dumps(data).encode() if data else None
req = urllib.request.Request(
url, data=body, method=method,
headers={
"Authorization": f"token {token}",
"Content-Type": "application/json",
}
)
with urllib.request.urlopen(req) as r:
return json.loads(r.read())
def get_existing_issues():
"""Return dict of notion_page_id → issue number for already-synced issues."""
issues = forgejo("GET", f"/repos/{FORGEJO_REPO}/issues?type=issues&state=open&limit=50")
issues += forgejo("GET", f"/repos/{FORGEJO_REPO}/issues?type=issues&state=closed&limit=50")
mapping = {}
for issue in issues:
body = issue.get("body") or ""
m = re.search(rf"{NOTION_MARKER}(\S+)", body)
if m:
mapping[m.group(1)] = issue["number"]
return mapping
def sync_item(item, existing):
"""Create or update a single Forgejo issue from a Notion item dict."""
notion_id = item["id"]
title = item["title"].strip()
category = item.get("category")
status = item.get("status", "Open")
verified = item.get("verified", False)
notion_url = item["url"]
has_screenshot = item.get("has_screenshot", False)
labels = []
if category and category in LABEL_MAP:
labels.append(LABEL_MAP[category])
if status == "In progress":
labels.append(IN_PROGRESS_LABEL_ID)
screenshot_note = "\n\n📎 Screenshot attached in Notion" if has_screenshot else ""
if status == "Done" and not verified:
verification_note = "\n\n⏳ *Awaiting Naomi's verification*"
elif status == "Done" and verified:
verification_note = "\n\n✅ *Verified by Naomi*"
else:
verification_note = ""
body = (
f"*Synced from [🌸 BloomBase Notion]({notion_url})*"
f"{screenshot_note}"
f"{verification_note}"
f"\n\n<!-- {NOTION_MARKER}{notion_id} -->"
)
# Only close the Forgejo issue once Naomi has verified
if status == "Done" and verified:
state = "closed"
else:
state = STATUS_MAP.get(status, "open")
if notion_id in existing:
issue_num = existing[notion_id]
forgejo("PATCH", f"/repos/{FORGEJO_REPO}/issues/{issue_num}", {
"title": title,
"body": body,
"state": state,
"labels": labels,
})
print(f" updated #{issue_num}: {title[:60]}")
else:
issue = forgejo("POST", f"/repos/{FORGEJO_REPO}/issues", {
"title": title,
"body": body,
"labels": labels,
})
if state == "closed":
forgejo("PATCH", f"/repos/{FORGEJO_REPO}/issues/{issue['number']}", {"state": "closed"})
print(f" created #{issue['number']}: {title[:60]}")
if __name__ == "__main__":
# Items passed in as JSON via stdin or hard-coded for manual runs.
# In practice: run from a Claude Code session using the Notion MCP,
# build the items list from MCP data, then call sync_item() for each.
print("Run this from a Claude Code session — Notion data comes via MCP.")
print("See: scripts/sync_notion_to_forgejo.py sync_item() for the API contract.")