|
#!/usr/bin/env python3 |
|
""" |
|
Claude Code Ship Logger - posts "build in public" entries to Notion. |
|
|
|
Reads a JSON payload from stdin with structured ship data and creates |
|
a rich Notion page with properties + content blocks suitable for |
|
social media content generation. |
|
""" |
|
|
|
import json |
|
import sys |
|
import os |
|
import urllib.request |
|
import urllib.error |
|
from datetime import datetime, timezone |
|
|
|
|
|
# -- Config ------------------------------------------------------------------ |
|
NOTION_TOKEN = os.environ.get("NOTION_TOKEN", "") |
|
NOTION_DATABASE_ID = "8be59ed4-fc5c-42fe-9f2c-a333d2a872fa" |
|
NOTION_API_VERSION = "2022-06-28" |
|
|
|
|
|
# -- Notion API helpers ------------------------------------------------------ |
|
def notion_request(method: str, path: str, body: dict | None = None) -> dict: |
|
url = f"https://api.notion.com/v1{path}" |
|
data = json.dumps(body).encode() if body else None |
|
req = urllib.request.Request( |
|
url, |
|
data=data, |
|
method=method, |
|
headers={ |
|
"Authorization": f"Bearer {NOTION_TOKEN}", |
|
"Notion-Version": NOTION_API_VERSION, |
|
"Content-Type": "application/json", |
|
}, |
|
) |
|
try: |
|
with urllib.request.urlopen(req, timeout=15) as resp: |
|
return json.loads(resp.read()) |
|
except urllib.error.HTTPError as e: |
|
err_body = e.read().decode() |
|
raise RuntimeError(f"Notion API {e.code}: {err_body}") from e |
|
|
|
|
|
def rich_text(content: str) -> list: |
|
"""Split text into 2000-char Notion rich_text blocks.""" |
|
if not content: |
|
return [{"text": {"content": ""}}] |
|
blocks = [] |
|
for i in range(0, len(content), 2000): |
|
blocks.append({"text": {"content": content[i:i+2000]}}) |
|
return blocks |
|
|
|
|
|
def make_page_blocks(ship: dict) -> list: |
|
"""Build Notion block children for the page body.""" |
|
blocks = [] |
|
|
|
# Summary section |
|
summary = ship.get("summary", "") |
|
if summary: |
|
blocks.append({ |
|
"object": "block", |
|
"type": "heading_2", |
|
"heading_2": {"rich_text": [{"text": {"content": "Summary"}}]} |
|
}) |
|
for paragraph in summary.split("\n\n"): |
|
paragraph = paragraph.strip() |
|
if paragraph: |
|
blocks.append({ |
|
"object": "block", |
|
"type": "paragraph", |
|
"paragraph": {"rich_text": rich_text(paragraph)} |
|
}) |
|
|
|
# Key Changes section |
|
changes = ship.get("changes", []) |
|
if changes: |
|
blocks.append({ |
|
"object": "block", |
|
"type": "heading_2", |
|
"heading_2": {"rich_text": [{"text": {"content": "Key Changes"}}]} |
|
}) |
|
for change in changes: |
|
blocks.append({ |
|
"object": "block", |
|
"type": "bulleted_list_item", |
|
"bulleted_list_item": {"rich_text": [{"text": {"content": str(change)}}]} |
|
}) |
|
|
|
# Impact section |
|
impact = ship.get("impact", "") |
|
if impact: |
|
blocks.append({ |
|
"object": "block", |
|
"type": "heading_2", |
|
"heading_2": {"rich_text": [{"text": {"content": "Impact"}}]} |
|
}) |
|
blocks.append({ |
|
"object": "block", |
|
"type": "paragraph", |
|
"paragraph": {"rich_text": rich_text(impact)} |
|
}) |
|
|
|
# Technical Details section |
|
technical = ship.get("technical_details", "") |
|
if technical: |
|
blocks.append({ |
|
"object": "block", |
|
"type": "heading_2", |
|
"heading_2": {"rich_text": [{"text": {"content": "Technical Details"}}]} |
|
}) |
|
blocks.append({ |
|
"object": "block", |
|
"type": "paragraph", |
|
"paragraph": {"rich_text": rich_text(technical)} |
|
}) |
|
|
|
return blocks |
|
|
|
|
|
def create_ship_entry(ship: dict) -> dict: |
|
"""Create a Notion page with properties and rich body content.""" |
|
tech_stack = ship.get("tech_stack", []) |
|
if isinstance(tech_stack, str): |
|
tech_stack = [t.strip() for t in tech_stack.split(",")] |
|
|
|
changes_text = "\n".join(f"- {c}" for c in ship.get("changes", [])) |
|
|
|
properties = { |
|
"Title": { |
|
"title": [{"text": {"content": ship.get("title", "Untitled Ship")[:100]}}] |
|
}, |
|
"Project": { |
|
"rich_text": rich_text(ship.get("project", "unknown")) |
|
}, |
|
"Date": { |
|
"date": {"start": datetime.now(timezone.utc).isoformat()} |
|
}, |
|
"Summary": { |
|
"rich_text": rich_text(ship.get("summary", "")[:2000]) |
|
}, |
|
"Key Changes": { |
|
"rich_text": rich_text(changes_text[:2000]) |
|
}, |
|
"Tech Stack": { |
|
"multi_select": [{"name": t[:100]} for t in tech_stack[:10]] |
|
}, |
|
"Type": { |
|
"select": {"name": ship.get("type", "Feature")} |
|
}, |
|
"Status": { |
|
"select": {"name": ship.get("status", "Shipped")} |
|
}, |
|
"Commit/PR": { |
|
"rich_text": rich_text(ship.get("commit_or_pr", "")) |
|
}, |
|
"Repository": { |
|
"rich_text": rich_text(ship.get("repository", "")) |
|
}, |
|
} |
|
|
|
body = { |
|
"parent": {"database_id": NOTION_DATABASE_ID}, |
|
"properties": properties, |
|
} |
|
|
|
# Add page content blocks |
|
children = make_page_blocks(ship) |
|
if children: |
|
body["children"] = children |
|
|
|
return notion_request("POST", "/pages", body) |
|
|
|
|
|
# -- Entry point ------------------------------------------------------------- |
|
def main(): |
|
if not NOTION_TOKEN: |
|
print("ERROR: NOTION_TOKEN env var not set", file=sys.stderr) |
|
sys.exit(1) |
|
|
|
try: |
|
ship = json.load(sys.stdin) |
|
except json.JSONDecodeError as e: |
|
print(f"ERROR: Invalid JSON input: {e}", file=sys.stderr) |
|
sys.exit(1) |
|
|
|
required = ["title", "summary"] |
|
missing = [f for f in required if not ship.get(f)] |
|
if missing: |
|
print(f"ERROR: Missing required fields: {', '.join(missing)}", file=sys.stderr) |
|
sys.exit(1) |
|
|
|
try: |
|
result = create_ship_entry(ship) |
|
page_url = result.get("url", "") |
|
print(f"Logged to Notion: {ship['title']}") |
|
if page_url: |
|
print(page_url) |
|
except Exception as e: |
|
print(f"Notion logger error: {e}", file=sys.stderr) |
|
sys.exit(1) |
|
|
|
|
|
if __name__ == "__main__": |
|
main() |