From 2605d8afbae4ffbb520933b0d5cfd335f3ca2fd2 Mon Sep 17 00:00:00 2001 From: Claude Date: Sun, 12 Apr 2026 03:56:07 +0000 Subject: [PATCH 1/2] fix: vision(#623): disinto-chat escalation tools (CI run, issue create, PR create) (#712) --- docker/chat/server.py | 264 ++++++++++++++++++++++++++++++++++++++ docker/chat/ui/index.html | 184 +++++++++++++++++++++++++- lib/generators.sh | 6 + 3 files changed, 453 insertions(+), 1 deletion(-) diff --git a/docker/chat/server.py b/docker/chat/server.py index ad8897d..42e7cf8 100644 --- a/docker/chat/server.py +++ b/docker/chat/server.py @@ -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) diff --git a/docker/chat/ui/index.html b/docker/chat/ui/index.html index bd920f9..a11e632 100644 --- a/docker/chat/ui/index.html +++ b/docker/chat/ui/index.html @@ -161,6 +161,56 @@ white-space: pre-wrap; word-wrap: break-word; } + /* Action button container */ + .action-buttons { + margin-top: 0.75rem; + display: flex; + flex-wrap: wrap; + gap: 0.5rem; + } + .action-btn { + background: #0f3460; + border: 1px solid #e94560; + color: #e94560; + padding: 0.5rem 1rem; + border-radius: 6px; + font-size: 0.875rem; + font-weight: 600; + cursor: pointer; + transition: all 0.2s; + display: inline-flex; + align-items: center; + gap: 0.5rem; + } + .action-btn:hover { + background: #e94560; + color: white; + } + .action-btn:disabled { + opacity: 0.5; + cursor: not-allowed; + } + .action-btn .spinner { + width: 14px; + height: 14px; + border: 2px solid currentColor; + border-top-color: transparent; + border-radius: 50%; + animation: spin 0.8s linear infinite; + } + @keyframes spin { + to { transform: rotate(360deg); } + } + .action-btn.success { + background: #1a1a2e; + border-color: #4ade80; + color: #4ade80; + } + .action-btn.error { + background: #1a1a2e; + border-color: #f87171; + color: #f87171; + } .input-area { display: flex; gap: 0.5rem; @@ -404,11 +454,28 @@ function addMessage(role, content, streaming = false) { const msgDiv = document.createElement('div'); msgDiv.className = `message ${role}`; + + // Parse action markers if this is an assistant message + let contentHtml = escapeHtml(content); + let actions = []; + + if (role === 'assistant' && !streaming) { + const parsed = parseActionMarkers(content, messagesDiv.children.length); + contentHtml = parsed.html; + actions = parsed.actions; + } + msgDiv.innerHTML = `
${role}
-
${escapeHtml(content)}
+
${contentHtml}
`; messagesDiv.appendChild(msgDiv); + + // Render action buttons for assistant messages + if (actions.length > 0) { + renderActionButtons(msgDiv, actions, messagesDiv.children.length - 1); + } + messagesDiv.scrollTop = messagesDiv.scrollHeight; return msgDiv.querySelector('.content'); } @@ -430,6 +497,121 @@ return div.innerHTML.replace(/\n/g, '
'); } + // Action buttons state - track pending actions by message index + const pendingActions = new Map(); + + // Parse action markers from content and return HTML with action buttons + function parseActionMarkers(content, messageIndex) { + const actionPattern = /(.*?)<\/action>/gs; + const hasActions = actionPattern.test(content); + + if (!hasActions) { + return { html: escapeHtml(content), actions: [] }; + } + + // Reset pending actions for this message + pendingActions.set(messageIndex, []); + + let html = content; + const actions = []; + + // Replace action markers with placeholders and collect actions + html = html.replace(actionPattern, (match, type, jsonStr) => { + try { + const action = JSON.parse(jsonStr); + actions.push({ type, payload: action, id: `${messageIndex}-${actions.length}` }); + // Replace with placeholder that will be rendered as button + return `
`; + } catch (e) { + // If JSON parsing fails, keep the original marker + return match; + } + }); + + // Convert newlines to
for HTML output + html = html.replace(/\n/g, '
'); + + return { html, actions }; + } + + // Render action buttons for a message + function renderActionButtons(msgDiv, actions, messageIndex) { + if (actions.length === 0) return; + + const buttonsDiv = document.createElement('div'); + buttonsDiv.className = 'action-buttons'; + + actions.forEach(action => { + const btn = document.createElement('button'); + btn.className = 'action-btn'; + btn.dataset.actionId = action.id; + btn.dataset.messageIndex = messageIndex; + + let btnText = 'Execute'; + let icon = ''; + + switch (action.type) { + case 'ci-run': + icon = '🚀'; + btnText = `Run CI for ${action.payload.branch || 'default'}`; + break; + case 'issue-create': + icon = '📝'; + btnText = `Create Issue: ${action.payload.title ? action.payload.title.substring(0, 30) + (action.payload.title.length > 30 ? '...' : '') : 'New Issue'}`; + break; + case 'pr-create': + icon = '🔀'; + btnText = `Create PR: ${action.payload.title ? action.payload.title.substring(0, 30) + (action.payload.title.length > 30 ? '...' : '') : 'New PR'}`; + break; + default: + btnText = `Execute ${action.type}`; + } + + btn.innerHTML = `${icon}${btnText}`; + + btn.addEventListener('click', () => executeAction(btn, action)); + buttonsDiv.appendChild(btn); + }); + + msgDiv.appendChild(buttonsDiv); + } + + // Execute an action + async function executeAction(btn, action) { + const messageIndex = btn.dataset.messageIndex; + const actionId = btn.dataset.actionId; + + // Disable button and show loading state + btn.disabled = true; + btn.innerHTML = ' Executing...'; + + try { + const response = await fetch(`/chat/action/${action.type}`, { + method: 'POST', + headers: { + 'Content-Type': 'application/json', + }, + body: JSON.stringify({ + ...action.payload, + conversation_id: currentConversationId, + }), + }); + + const result = await response.json(); + + if (result.success) { + btn.className = 'action-btn success'; + btn.innerHTML = ' Executed successfully'; + } else { + btn.className = 'action-btn error'; + btn.innerHTML = ` Error: ${result.error || 'Unknown error'}`; + } + } catch (error) { + btn.className = 'action-btn error'; + btn.innerHTML = ` Error: ${error.message}`; + } + } + // Send message handler async function sendMessage() { const message = textarea.value.trim(); diff --git a/lib/generators.sh b/lib/generators.sh index 72f030e..9ad0f2a 100644 --- a/lib/generators.sh +++ b/lib/generators.sh @@ -518,6 +518,12 @@ services: CHAT_MAX_REQUESTS_PER_HOUR: ${CHAT_MAX_REQUESTS_PER_HOUR:-60} CHAT_MAX_REQUESTS_PER_DAY: ${CHAT_MAX_REQUESTS_PER_DAY:-500} CHAT_MAX_TOKENS_PER_DAY: ${CHAT_MAX_TOKENS_PER_DAY:-1000000} + # Action endpoints (#712) + WOODPECKER_TOKEN: ${WOODPECKER_TOKEN:-} + WOODPECKER_URL: http://woodpecker:8000 + FORGE_TOKEN: ${FORGE_TOKEN:-} + FORGE_OWNER: ${FORGE_OWNER:-} + FORGE_REPO: ${FORGE_REPO:-} networks: - disinto-net -- 2.49.1 From f398b3295277b4d36130077933ed21114ad58537 Mon Sep 17 00:00:00 2001 From: Claude Date: Sun, 12 Apr 2026 05:53:20 +0000 Subject: [PATCH 2/2] fix: agent-smoke.sh - add lib/env.sh as extra source for ci-debug.sh --- .woodpecker/agent-smoke.sh | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/.woodpecker/agent-smoke.sh b/.woodpecker/agent-smoke.sh index 86ee756..f963414 100644 --- a/.woodpecker/agent-smoke.sh +++ b/.woodpecker/agent-smoke.sh @@ -222,7 +222,7 @@ check_script lib/issue-lifecycle.sh lib/secret-scan.sh # Standalone lib scripts (not sourced by agents; run directly or as services). # Still checked for function resolution against LIB_FUNS + own definitions. -check_script lib/ci-debug.sh +check_script lib/ci-debug.sh lib/env.sh check_script lib/parse-deps.sh # Agent scripts — list cross-sourced files where function scope flows across files. -- 2.49.1