375 lines
15 KiB
Python
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
|