fix: vision(#623): disinto-chat escalation tools (CI run, issue create, PR create) (#712)
Some checks failed
ci/woodpecker/push/ci Pipeline was successful
ci/woodpecker/pr/ci Pipeline failed
ci/woodpecker/pr/smoke-init Pipeline was successful

This commit is contained in:
Claude 2026-04-12 03:56:07 +00:00
parent 9d778f6fd6
commit 8649b278a0
3 changed files with 453 additions and 1 deletions

View file

@ -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)