This commit is contained in:
parent
9d778f6fd6
commit
8649b278a0
3 changed files with 453 additions and 1 deletions
|
|
@ -30,6 +30,8 @@ import secrets
|
|||
import subprocess
|
||||
import sys
|
||||
import time
|
||||
import urllib.request
|
||||
import urllib.error
|
||||
from http.server import HTTPServer, BaseHTTPRequestHandler
|
||||
from urllib.parse import urlparse, parse_qs, urlencode
|
||||
|
||||
|
|
@ -57,6 +59,14 @@ CHAT_MAX_REQUESTS_PER_HOUR = int(os.environ.get("CHAT_MAX_REQUESTS_PER_HOUR", 60
|
|||
CHAT_MAX_REQUESTS_PER_DAY = int(os.environ.get("CHAT_MAX_REQUESTS_PER_DAY", 500))
|
||||
CHAT_MAX_TOKENS_PER_DAY = int(os.environ.get("CHAT_MAX_TOKENS_PER_DAY", 1000000))
|
||||
|
||||
# Action endpoints configuration (#712)
|
||||
WOODPECKER_TOKEN = os.environ.get("WOODPECKER_TOKEN", "")
|
||||
WOODPECKER_URL = os.environ.get("WOODPECKER_URL", "http://woodpecker:8000")
|
||||
FORGE_TOKEN = os.environ.get("FORGE_TOKEN", "")
|
||||
FORGE_URL = os.environ.get("FORGE_URL", "http://forgejo:3000")
|
||||
FORGE_OWNER = os.environ.get("FORGE_OWNER", "")
|
||||
FORGE_REPO = os.environ.get("FORGE_REPO", "")
|
||||
|
||||
# Allowed users - disinto-admin always allowed; CSV allowlist extends it
|
||||
_allowed_csv = os.environ.get("DISINTO_CHAT_ALLOWED_USERS", "")
|
||||
ALLOWED_USERS = {"disinto-admin"}
|
||||
|
|
@ -423,6 +433,181 @@ def _delete_conversation(user, conv_id):
|
|||
return False
|
||||
|
||||
|
||||
# =============================================================================
|
||||
# Action Endpoints (#712)
|
||||
# =============================================================================
|
||||
|
||||
def _write_action_record(user, conv_id, action_type, payload, response_data):
|
||||
"""Write an action record to the conversation history."""
|
||||
record = {
|
||||
"ts": time.strftime("%Y-%m-%dT%H:%M:%SZ", time.gmtime()),
|
||||
"user": user,
|
||||
"role": "action",
|
||||
"action_type": action_type,
|
||||
"payload": payload,
|
||||
"response": response_data,
|
||||
}
|
||||
conv_path = _get_conversation_path(user, conv_id)
|
||||
_ensure_user_dir(user)
|
||||
with open(conv_path, "a", encoding="utf-8") as f:
|
||||
f.write(json.dumps(record, ensure_ascii=False) + "\n")
|
||||
|
||||
|
||||
def _trigger_woodpecker_pipeline(repo, branch):
|
||||
"""Trigger a Woodpecker CI pipeline for the given repo and branch.
|
||||
|
||||
Woodpecker API: POST /api/v1/repos/{owner}/{repo}/pipeline
|
||||
Returns dict with success status and response data.
|
||||
"""
|
||||
if not WOODPECKER_TOKEN:
|
||||
return {"success": False, "error": "WOODPECKER_TOKEN not configured"}
|
||||
|
||||
if not FORGE_OWNER or not FORGE_REPO:
|
||||
return {"success": False, "error": "FORGE_OWNER and FORGE_REPO not configured"}
|
||||
|
||||
try:
|
||||
url = f"{WOODPECKER_URL}/api/v1/repos/{FORGE_OWNER}/{FORGE_REPO}/pipeline"
|
||||
data = json.dumps({"branch": branch, "event": "push"}).encode("utf-8")
|
||||
|
||||
req = urllib.request.Request(
|
||||
url,
|
||||
data=data,
|
||||
headers={
|
||||
"Authorization": f"token {WOODPECKER_TOKEN}",
|
||||
"Content-Type": "application/json",
|
||||
"Accept": "application/json",
|
||||
},
|
||||
method="POST",
|
||||
)
|
||||
|
||||
with urllib.request.urlopen(req, timeout=30) as resp:
|
||||
result = json.loads(resp.read().decode())
|
||||
return {"success": True, "data": result}
|
||||
|
||||
except urllib.error.HTTPError as e:
|
||||
try:
|
||||
body = json.loads(e.read().decode())
|
||||
return {"success": False, "error": str(e.reason), "details": body}
|
||||
except (json.JSONDecodeError, UnicodeDecodeError):
|
||||
return {"success": False, "error": str(e.reason)}
|
||||
except urllib.error.URLError as e:
|
||||
return {"success": False, "error": f"Network error: {e.reason}"}
|
||||
except json.JSONDecodeError as e:
|
||||
return {"success": False, "error": f"Invalid JSON response: {e}"}
|
||||
except Exception as e:
|
||||
return {"success": False, "error": str(e)}
|
||||
|
||||
|
||||
def _create_forgejo_issue(title, body, labels=None):
|
||||
"""Create a Forgejo issue.
|
||||
|
||||
Forgejo API: POST /api/v1/repos/{owner}/{repo}/issues
|
||||
Returns dict with success status and response data.
|
||||
"""
|
||||
if not FORGE_TOKEN:
|
||||
return {"success": False, "error": "FORGE_TOKEN not configured"}
|
||||
|
||||
if not FORGE_OWNER or not FORGE_REPO:
|
||||
return {"success": False, "error": "FORGE_OWNER and FORGE_REPO not configured"}
|
||||
|
||||
if not title:
|
||||
return {"success": False, "error": "Title is required"}
|
||||
|
||||
try:
|
||||
url = f"{FORGE_URL}/api/v1/repos/{FORGE_OWNER}/{FORGE_REPO}/issues"
|
||||
payload = {
|
||||
"title": title,
|
||||
"body": body or "",
|
||||
}
|
||||
if labels:
|
||||
payload["labels"] = labels
|
||||
|
||||
data = json.dumps(payload).encode("utf-8")
|
||||
|
||||
req = urllib.request.Request(
|
||||
url,
|
||||
data=data,
|
||||
headers={
|
||||
"Authorization": f"token {FORGE_TOKEN}",
|
||||
"Content-Type": "application/json",
|
||||
"Accept": "application/json",
|
||||
},
|
||||
method="POST",
|
||||
)
|
||||
|
||||
with urllib.request.urlopen(req, timeout=30) as resp:
|
||||
result = json.loads(resp.read().decode())
|
||||
return {"success": True, "data": result}
|
||||
|
||||
except urllib.error.HTTPError as e:
|
||||
try:
|
||||
body = json.loads(e.read().decode())
|
||||
return {"success": False, "error": str(e.reason), "details": body}
|
||||
except (json.JSONDecodeError, UnicodeDecodeError):
|
||||
return {"success": False, "error": str(e.reason)}
|
||||
except urllib.error.URLError as e:
|
||||
return {"success": False, "error": f"Network error: {e.reason}"}
|
||||
except json.JSONDecodeError as e:
|
||||
return {"success": False, "error": f"Invalid JSON response: {e}"}
|
||||
except Exception as e:
|
||||
return {"success": False, "error": str(e)}
|
||||
|
||||
|
||||
def _create_forgejo_pull_request(head, base, title, body=None):
|
||||
"""Create a Forgejo pull request.
|
||||
|
||||
Forgejo API: POST /api/v1/repos/{owner}/{repo}/pulls
|
||||
Returns dict with success status and response data.
|
||||
"""
|
||||
if not FORGE_TOKEN:
|
||||
return {"success": False, "error": "FORGE_TOKEN not configured"}
|
||||
|
||||
if not FORGE_OWNER or not FORGE_REPO:
|
||||
return {"success": False, "error": "FORGE_OWNER and FORGE_REPO not configured"}
|
||||
|
||||
if not head or not base or not title:
|
||||
return {"success": False, "error": "head, base, and title are required"}
|
||||
|
||||
try:
|
||||
url = f"{FORGE_URL}/api/v1/repos/{FORGE_OWNER}/{FORGE_REPO}/pulls"
|
||||
payload = {
|
||||
"head": head,
|
||||
"base": base,
|
||||
"title": title,
|
||||
"body": body or "",
|
||||
}
|
||||
|
||||
data = json.dumps(payload).encode("utf-8")
|
||||
|
||||
req = urllib.request.Request(
|
||||
url,
|
||||
data=data,
|
||||
headers={
|
||||
"Authorization": f"token {FORGE_TOKEN}",
|
||||
"Content-Type": "application/json",
|
||||
"Accept": "application/json",
|
||||
},
|
||||
method="POST",
|
||||
)
|
||||
|
||||
with urllib.request.urlopen(req, timeout=30) as resp:
|
||||
result = json.loads(resp.read().decode())
|
||||
return {"success": True, "data": result}
|
||||
|
||||
except urllib.error.HTTPError as e:
|
||||
try:
|
||||
body = json.loads(e.read().decode())
|
||||
return {"success": False, "error": str(e.reason), "details": body}
|
||||
except (json.JSONDecodeError, UnicodeDecodeError):
|
||||
return {"success": False, "error": str(e.reason)}
|
||||
except urllib.error.URLError as e:
|
||||
return {"success": False, "error": f"Network error: {e.reason}"}
|
||||
except json.JSONDecodeError as e:
|
||||
return {"success": False, "error": f"Invalid JSON response: {e}"}
|
||||
except Exception as e:
|
||||
return {"success": False, "error": str(e)}
|
||||
|
||||
|
||||
class ChatHandler(BaseHTTPRequestHandler):
|
||||
"""HTTP request handler for disinto-chat with Forgejo OAuth."""
|
||||
|
||||
|
|
@ -549,6 +734,16 @@ class ChatHandler(BaseHTTPRequestHandler):
|
|||
parsed = urlparse(self.path)
|
||||
path = parsed.path
|
||||
|
||||
# Action endpoints (#712)
|
||||
if path in ("/chat/action/ci-run", "/chat/action/issue-create", "/chat/action/pr-create"):
|
||||
user = self._require_session()
|
||||
if not user:
|
||||
return
|
||||
if not self._check_forwarded_user(user):
|
||||
return
|
||||
self.handle_action(user, path)
|
||||
return
|
||||
|
||||
# New conversation endpoint (session required)
|
||||
if path == "/chat/new":
|
||||
user = self._require_session()
|
||||
|
|
@ -901,6 +1096,75 @@ class ChatHandler(BaseHTTPRequestHandler):
|
|||
self.end_headers()
|
||||
self.wfile.write(json.dumps({"conversation_id": conv_id}, ensure_ascii=False).encode("utf-8"))
|
||||
|
||||
def handle_action(self, user, path):
|
||||
"""Handle action requests (ci-run, issue-create, pr-create)."""
|
||||
# Determine action type from path
|
||||
action_type = path.replace("/chat/action/", "")
|
||||
|
||||
# Read request body
|
||||
content_length = int(self.headers.get("Content-Length", 0))
|
||||
if content_length == 0:
|
||||
self.send_error_page(400, "No request body provided")
|
||||
return
|
||||
|
||||
body = self.rfile.read(content_length)
|
||||
try:
|
||||
request_data = json.loads(body.decode("utf-8"))
|
||||
except json.JSONDecodeError:
|
||||
self.send_error_page(400, "Invalid JSON in request body")
|
||||
return
|
||||
|
||||
# Get conversation ID from request or session
|
||||
conv_id = request_data.get("conversation_id")
|
||||
if not conv_id or not _validate_conversation_id(conv_id):
|
||||
# Fall back to session-based conversation if available
|
||||
# For now, we'll use a default or generate one
|
||||
conv_id = request_data.get("conversation_id")
|
||||
if not conv_id:
|
||||
self.send_error_page(400, "conversation_id is required")
|
||||
return
|
||||
|
||||
# Route to appropriate handler
|
||||
if action_type == "ci-run":
|
||||
repo = request_data.get("repo")
|
||||
branch = request_data.get("branch")
|
||||
if not repo or not branch:
|
||||
self.send_error_page(400, "repo and branch are required for ci-run")
|
||||
return
|
||||
payload = {"repo": repo, "branch": branch}
|
||||
result = _trigger_woodpecker_pipeline(repo, branch)
|
||||
elif action_type == "issue-create":
|
||||
title = request_data.get("title")
|
||||
body_text = request_data.get("body", "")
|
||||
labels = request_data.get("labels", [])
|
||||
if not title:
|
||||
self.send_error_page(400, "title is required for issue-create")
|
||||
return
|
||||
payload = {"title": title, "body": body_text, "labels": labels}
|
||||
result = _create_forgejo_issue(title, body_text, labels)
|
||||
elif action_type == "pr-create":
|
||||
head = request_data.get("head")
|
||||
base = request_data.get("base")
|
||||
title = request_data.get("title")
|
||||
body_text = request_data.get("body", "")
|
||||
if not head or not base or not title:
|
||||
self.send_error_page(400, "head, base, and title are required for pr-create")
|
||||
return
|
||||
payload = {"head": head, "base": base, "title": title, "body": body_text}
|
||||
result = _create_forgejo_pull_request(head, base, title, body_text)
|
||||
else:
|
||||
self.send_error_page(404, f"Unknown action type: {action_type}")
|
||||
return
|
||||
|
||||
# Log the action to history
|
||||
_write_action_record(user, conv_id, action_type, payload, result)
|
||||
|
||||
# Send response
|
||||
self.send_response(200)
|
||||
self.send_header("Content-Type", "application/json; charset=utf-8")
|
||||
self.end_headers()
|
||||
self.wfile.write(json.dumps(result, ensure_ascii=False).encode("utf-8"))
|
||||
|
||||
def do_DELETE(self):
|
||||
"""Handle DELETE requests."""
|
||||
parsed = urlparse(self.path)
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue