Files
ThinkStorm/thinkstorm/services/gitea.py
T

375 lines
15 KiB
Python

"""
Gitea Service Adapter
Handles Idea Dossier repository creation, full document tree synchronization,
organization management (under 'thinkstorm' org), and OAuth2 authentication.
"""
import json
import time
import re
import base64
import urllib.request
import urllib.parse
import urllib.error
import asyncio
import subprocess
from pathlib import Path
from typing import Dict, Any, Optional, List
from .base import BaseServiceAdapter, ServiceHealth
from ..config import config, BASE_DIR
ARTIFACTS_DIR = BASE_DIR / "data" / "artifacts"
ARTIFACTS_DIR.mkdir(parents=True, exist_ok=True)
class GiteaAdapter(BaseServiceAdapter):
def __init__(self, endpoint: str = "https://git.labyricorn.com", api_token: str = ""):
super().__init__(service_id="gitea", endpoint=endpoint, api_key=api_token or config.services.gitea_api_token)
self.org_name = "thinkstorm"
def get_effective_token(self) -> str:
"""Retrieves configured API token from instance, DB configuration, or environment."""
if self.api_key:
return self.api_key
try:
from ..database import get_db
with get_db() as conn:
row = conn.execute("SELECT api_key_raw FROM service_configurations WHERE id = 'gitea'").fetchone()
if row and row["api_key_raw"]:
return row["api_key_raw"]
except Exception:
pass
return config.services.gitea_api_token or ""
async def check_health(self) -> ServiceHealth:
start = time.time()
try:
url = f"{self.endpoint}/api/v1/version"
req = urllib.request.Request(url, headers={"User-Agent": "ThinkStorm-Orchestrator/0.1"})
loop = asyncio.get_running_loop()
def fetch():
with urllib.request.urlopen(req, timeout=5.0) as resp:
return resp.read()
raw = await loop.run_in_executor(None, fetch)
data = json.loads(raw.decode("utf-8"))
version = data.get("version", "unknown")
elapsed = int((time.time() - start) * 1000)
return ServiceHealth(
service_id=self.service_id,
healthy=True,
endpoint=self.endpoint,
message=f"Gitea online (v{version})",
response_time_ms=elapsed,
extra={"version": version}
)
except Exception as e:
elapsed = int((time.time() - start) * 1000)
return ServiceHealth(
service_id=self.service_id,
healthy=False,
endpoint=self.endpoint,
message=f"Gitea connection error: {str(e)}",
response_time_ms=elapsed
)
def _sync_files_via_api(self, token: str, repo_slug: str, files_dict: Dict[str, str]) -> None:
"""Commits or updates multiple files directly into the Gitea repository via Contents API."""
script_payload = {
"token": token,
"repo": f"{self.org_name}/{repo_slug}",
"files": files_dict
}
py_code = f"""
import urllib.request, json, base64, sys
data = json.loads({json.dumps(json.dumps(script_payload))})
token = data['token']
repo = data['repo']
files = data['files']
for path, content in files.items():
get_url = f'https://git.labyricorn.com/api/v1/repos/{{repo}}/contents/{{path}}'
sha = None
try:
req = urllib.request.Request(get_url, headers={{'Authorization': f'token {{token}}', 'User-Agent': 'ThinkStorm/0.1'}})
with urllib.request.urlopen(req, timeout=8) as resp:
d = json.loads(resp.read().decode('utf-8'))
sha = d.get('sha')
except Exception:
pass
payload = {{
'content': base64.b64encode(content.encode('utf-8')).decode('utf-8'),
'message': f'Sync {{path}} into ThinkStorm dossier',
'branch': 'main'
}}
if sha:
payload['sha'] = sha
method = 'PUT'
else:
method = 'POST'
url = f'https://git.labyricorn.com/api/v1/repos/{{repo}}/contents/{{path}}'
req = urllib.request.Request(
url,
data=json.dumps(payload).encode('utf-8'),
headers={{'Content-Type': 'application/json', 'Authorization': f'token {{token}}', 'User-Agent': 'ThinkStorm/0.1'}},
method=method
)
try:
with urllib.request.urlopen(req, timeout=8) as resp:
pass
except Exception as e:
print(f'File sync notice for {{path}}: {{e}}')
"""
try:
subprocess.run(
["ssh", "-o", "BatchMode=yes", "-o", "StrictHostKeyChecking=no", "[email protected]", f"python3 -c {json.dumps(py_code)}"],
capture_output=True,
text=True,
timeout=25
)
except Exception as e:
print(f"[Gitea] Remote file commit notice: {e}")
async def persist_idea_dossier_repo(
self,
idea_id: str,
title: str,
summary: str,
original_text: str,
categories: List[str],
tags: List[str],
lifecycle_state: str,
research_docs: Dict[str, str],
outputs: Dict[str, str],
provenance_runs: List[Dict[str, Any]],
existing_repo_url: Optional[str] = None
) -> Dict[str, Any]:
"""Creates a dedicated Gitea Project repository and synchronizes all files into it."""
# 1. Local disk directory
idea_dir = ARTIFACTS_DIR / idea_id
idea_dir.mkdir(parents=True, exist_ok=True)
(idea_dir / "research").mkdir(parents=True, exist_ok=True)
(idea_dir / "provenance").mkdir(parents=True, exist_ok=True)
(idea_dir / "outputs").mkdir(parents=True, exist_ok=True)
cat_str = ", ".join(categories) if categories else "General"
tag_str = ", ".join(f"#{t}" for t in tags) if tags else "None"
# 2. Build README.md
readme_md = (
f"# {idea_id}: {title}\n\n"
f"**Lifecycle State:** `{lifecycle_state}` \n"
f"**Categories:** {cat_str} \n"
f"**Tags:** {tag_str} \n\n"
f"## Executive Summary\n{summary}\n\n"
f"## Original Submission Prompt\n> {original_text.strip()}\n\n"
f"## Project Structure\n"
f"- `research/`: Automated competitor analysis, prior art, deep research, and technical feasibility reports.\n"
f"- `outputs/`: Multi-modal work track deliverables (articles, code scaffolds, business models).\n"
f"- `provenance/`: Execution run telemetry, model audit trails, and token attribution logs.\n"
f"- `metadata.json`: Machine-readable metadata schema.\n"
)
(idea_dir / "README.md").write_text(readme_md, encoding="utf-8")
(idea_dir / "idea.md").write_text(readme_md, encoding="utf-8")
# 3. Build metadata.json
meta = {
"id": idea_id,
"title": title,
"summary": summary,
"categories": categories,
"tags": tags,
"lifecycle_state": lifecycle_state,
"updated_at": time.strftime("%Y-%m-%dT%H:%M:%SZ", time.gmtime()),
"organization": self.org_name
}
meta_json_str = json.dumps(meta, indent=2)
(idea_dir / "metadata.json").write_text(meta_json_str, encoding="utf-8")
# 4. Research docs
files_to_sync = {
"README.md": readme_md,
"metadata.json": meta_json_str
}
for filename, content in research_docs.items():
safe_name = filename.replace("/", "_")
(idea_dir / "research" / safe_name).write_text(content, encoding="utf-8")
files_to_sync[f"research/{safe_name}"] = content
# 5. Outputs
for filename, content in outputs.items():
out_path = idea_dir / "outputs" / filename
out_path.parent.mkdir(parents=True, exist_ok=True)
out_path.write_text(content, encoding="utf-8")
files_to_sync[f"outputs/{filename}"] = content
# 6. Provenance
for run in provenance_runs:
run_id = run.get("id", f"run-{int(time.time())}")
(idea_dir / "provenance" / f"{run_id}.json").write_text(json.dumps(run, indent=2), encoding="utf-8")
# 7. Gitea Repository sync under 'thinkstorm' organization
clean_slug = re.sub(r'[^a-zA-Z0-9_-]', '-', idea_id.lower()).strip('-')
repo_name = f"ts-{clean_slug.replace('ts-', '')}"
repo_url = existing_repo_url or f"{self.endpoint}/{self.org_name}/{repo_name}"
clone_url = f"{self.endpoint}/{self.org_name}/{repo_name}.git"
ssh_url = f"ssh://[email protected]:22/{self.org_name}/{repo_name}.git"
token = self.get_effective_token()
if token:
loop = asyncio.get_running_loop()
def sync_gitea():
try:
# 1. Create repo under org if missing
create_script = f"""
import urllib.request, json
token = '{token}'
repo_name = '{repo_name}'
org = '{self.org_name}'
# Ensure org
try:
req = urllib.request.Request(f'https://git.labyricorn.com/api/v1/orgs/{{org}}', headers={{'Authorization': f'token {{token}}'}})
with urllib.request.urlopen(req, timeout=5): pass
except Exception:
try:
req = urllib.request.Request('https://git.labyricorn.com/api/v1/orgs', data=json.dumps({{'username': org, 'visibility': 'public'}}).encode(), headers={{'Content-Type': 'application/json', 'Authorization': f'token {{token}}'}}, method='POST')
with urllib.request.urlopen(req, timeout=5): pass
except Exception: pass
# Create repo
try:
payload = {{'name': repo_name, 'description': f'[{idea_id}] {title}', 'private': False, 'auto_init': True}}
req = urllib.request.Request(f'https://git.labyricorn.com/api/v1/orgs/{{org}}/repos', data=json.dumps(payload).encode(), headers={{'Content-Type': 'application/json', 'Authorization': f'token {{token}}'}}, method='POST')
with urllib.request.urlopen(req, timeout=6): pass
except Exception: pass
"""
subprocess.run(
["ssh", "-o", "BatchMode=yes", "-o", "StrictHostKeyChecking=no", "[email protected]", f"python3 -c {json.dumps(create_script)}"],
capture_output=True,
text=True,
timeout=10
)
# 2. Push full file tree to Gitea
self._sync_files_via_api(token, repo_name, files_to_sync)
return f"{self.endpoint}/{self.org_name}/{repo_name}"
except Exception as e:
print(f"[Gitea] Sync error: {e}")
return repo_url
try:
actual_url = await loop.run_in_executor(None, sync_gitea)
repo_url = actual_url or repo_url
except Exception as e:
print(f"[Gitea] Async sync exception: {e}")
return {
"repo_name": f"{self.org_name}/{repo_name}",
"repo_url": repo_url,
"clone_url": clone_url,
"ssh_url": ssh_url,
"local_path": str(idea_dir)
}
async def persist_work_track_outputs(
self,
idea_id: str,
track_name: str,
outputs: Dict[str, str]
) -> Dict[str, Any]:
"""Persists Work Track deliverables to local dossier and commits to Gitea project."""
idea_dir = ARTIFACTS_DIR / idea_id
out_subdir = idea_dir / "outputs" / track_name.lower().replace(" ", "-")
out_subdir.mkdir(parents=True, exist_ok=True)
files_to_sync = {}
for filename, content in outputs.items():
file_path = out_subdir / filename
file_path.write_text(content, encoding="utf-8")
files_to_sync[f"outputs/{track_name.lower().replace(' ', '-')}/{filename}"] = content
clean_slug = re.sub(r'[^a-zA-Z0-9_-]', '-', idea_id.lower()).strip('-')
repo_name = f"ts-{clean_slug.replace('ts-', '')}"
repo_url = f"{self.endpoint}/{self.org_name}/{repo_name}"
token = self.get_effective_token()
if token and files_to_sync:
loop = asyncio.get_running_loop()
try:
await loop.run_in_executor(None, lambda: self._sync_files_via_api(token, repo_name, files_to_sync))
except Exception as e:
print(f"[Gitea] Work track sync notice: {e}")
return {
"status": "SUCCESS",
"path": str(out_subdir),
"repo_url": repo_url
}
async def graduate_project(self, idea_id: str, title: str, summary: str, repo_name: str = "") -> Dict[str, Any]:
"""Graduates an incubated Coding Project work track to a dedicated repository under 'thinkstorm'."""
clean_title = re.sub(r'[^a-zA-Z0-9_-]', '-', title.lower()).strip('-')[:30] or "project"
slug = repo_name or f"ts-{idea_id.lower()}-{clean_title}"
repo_url = f"{self.endpoint}/{self.org_name}/{slug}"
token = self.get_effective_token()
if token:
loop = asyncio.get_running_loop()
def create_remote():
try:
create_script = f"""
import urllib.request, json
token = '{token}'
slug = '{slug}'
org = '{self.org_name}'
payload = {{'name': slug, 'description': f'[{idea_id}] {title}', 'private': False, 'auto_init': True}}
req = urllib.request.Request(f'https://git.labyricorn.com/api/v1/orgs/{{org}}/repos', data=json.dumps(payload).encode(), headers={{'Content-Type': 'application/json', 'Authorization': f'token {{token}}'}}, method='POST')
try:
with urllib.request.urlopen(req, timeout=6): pass
except Exception: pass
"""
subprocess.run(
["ssh", "-o", "BatchMode=yes", "-o", "StrictHostKeyChecking=no", "[email protected]", f"python3 -c {json.dumps(create_script)}"],
capture_output=True,
text=True,
timeout=10
)
return f"{self.endpoint}/{self.org_name}/{slug}"
except Exception as e:
print(f"[Gitea] Project graduation notice: {e}")
return repo_url
actual_url = await loop.run_in_executor(None, create_remote)
repo_url = actual_url or repo_url
return {
"repo_name": f"{self.org_name}/{slug}",
"repo_url": repo_url,
"graduated_at": time.strftime("%Y-%m-%dT%H:%M:%SZ", time.gmtime())
}
async def verify_oauth_user(self, access_token: str) -> Optional[Dict[str, Any]]:
"""Fetches user profile details using Gitea OAuth access token."""
url = f"{self.endpoint}/api/v1/user"
req = urllib.request.Request(
url,
headers={
"Authorization": f"token {access_token}",
"User-Agent": "ThinkStorm-Orchestrator/0.1"
}
)
loop = asyncio.get_running_loop()
def fetch():
with urllib.request.urlopen(req, timeout=8.0) as resp:
return resp.read()
try:
raw = await loop.run_in_executor(None, fetch)
return json.loads(raw.decode("utf-8"))
except Exception as e:
print(f"[Gitea] OAuth user verify error: {e}")
return None