fix: vision(#623): disinto-chat escalation tools (CI run, issue create, PR create) (#712) #733

Closed
dev-qwen wants to merge 2 commits from fix/issue-712 into main
4 changed files with 454 additions and 2 deletions

View file

@ -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). # Standalone lib scripts (not sourced by agents; run directly or as services).
# Still checked for function resolution against LIB_FUNS + own definitions. # 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 check_script lib/parse-deps.sh
# Agent scripts — list cross-sourced files where function scope flows across files. # Agent scripts — list cross-sourced files where function scope flows across files.

View file

@ -30,6 +30,8 @@ import secrets
import subprocess import subprocess
import sys import sys
import time import time
import urllib.request
import urllib.error
from http.server import HTTPServer, BaseHTTPRequestHandler from http.server import HTTPServer, BaseHTTPRequestHandler
from urllib.parse import urlparse, parse_qs, urlencode 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_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)) 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 users - disinto-admin always allowed; CSV allowlist extends it
_allowed_csv = os.environ.get("DISINTO_CHAT_ALLOWED_USERS", "") _allowed_csv = os.environ.get("DISINTO_CHAT_ALLOWED_USERS", "")
ALLOWED_USERS = {"disinto-admin"} ALLOWED_USERS = {"disinto-admin"}
@ -423,6 +433,181 @@ def _delete_conversation(user, conv_id):
return False 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): class ChatHandler(BaseHTTPRequestHandler):
"""HTTP request handler for disinto-chat with Forgejo OAuth.""" """HTTP request handler for disinto-chat with Forgejo OAuth."""
@ -549,6 +734,16 @@ class ChatHandler(BaseHTTPRequestHandler):
parsed = urlparse(self.path) parsed = urlparse(self.path)
path = parsed.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) # New conversation endpoint (session required)
if path == "/chat/new": if path == "/chat/new":
user = self._require_session() user = self._require_session()
@ -901,6 +1096,75 @@ class ChatHandler(BaseHTTPRequestHandler):
self.end_headers() self.end_headers()
self.wfile.write(json.dumps({"conversation_id": conv_id}, ensure_ascii=False).encode("utf-8")) 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): def do_DELETE(self):
"""Handle DELETE requests.""" """Handle DELETE requests."""
parsed = urlparse(self.path) parsed = urlparse(self.path)

View file

@ -161,6 +161,56 @@
white-space: pre-wrap; white-space: pre-wrap;
word-wrap: break-word; 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 { .input-area {
display: flex; display: flex;
gap: 0.5rem; gap: 0.5rem;
@ -404,11 +454,28 @@
function addMessage(role, content, streaming = false) { function addMessage(role, content, streaming = false) {
const msgDiv = document.createElement('div'); const msgDiv = document.createElement('div');
msgDiv.className = `message ${role}`; 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 = ` msgDiv.innerHTML = `
<div class="role">${role}</div> <div class="role">${role}</div>
<div class="content${streaming ? ' streaming' : ''}">${escapeHtml(content)}</div> <div class="content${streaming ? ' streaming' : ''}">${contentHtml}</div>
`; `;
messagesDiv.appendChild(msgDiv); messagesDiv.appendChild(msgDiv);
// Render action buttons for assistant messages
if (actions.length > 0) {
renderActionButtons(msgDiv, actions, messagesDiv.children.length - 1);
}
messagesDiv.scrollTop = messagesDiv.scrollHeight; messagesDiv.scrollTop = messagesDiv.scrollHeight;
return msgDiv.querySelector('.content'); return msgDiv.querySelector('.content');
} }
@ -430,6 +497,121 @@
return div.innerHTML.replace(/\n/g, '<br>'); return div.innerHTML.replace(/\n/g, '<br>');
} }
// 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 type="([^"]+)">(.*?)<\/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 `<div class="action-placeholder" data-action-id="${actions[actions.length - 1].id}"></div>`;
} catch (e) {
// If JSON parsing fails, keep the original marker
return match;
}
});
// Convert newlines to <br> for HTML output
html = html.replace(/\n/g, '<br>');
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 = `<span>${icon}</span><span>${btnText}</span>`;
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 = '<span class="spinner"></span> 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 = '<span></span> Executed successfully';
} else {
btn.className = 'action-btn error';
btn.innerHTML = `<span></span> Error: ${result.error || 'Unknown error'}`;
}
} catch (error) {
btn.className = 'action-btn error';
btn.innerHTML = `<span></span> Error: ${error.message}`;
}
}
// Send message handler // Send message handler
async function sendMessage() { async function sendMessage() {
const message = textarea.value.trim(); const message = textarea.value.trim();

View file

@ -518,6 +518,12 @@ services:
CHAT_MAX_REQUESTS_PER_HOUR: ${CHAT_MAX_REQUESTS_PER_HOUR:-60} CHAT_MAX_REQUESTS_PER_HOUR: ${CHAT_MAX_REQUESTS_PER_HOUR:-60}
CHAT_MAX_REQUESTS_PER_DAY: ${CHAT_MAX_REQUESTS_PER_DAY:-500} CHAT_MAX_REQUESTS_PER_DAY: ${CHAT_MAX_REQUESTS_PER_DAY:-500}
CHAT_MAX_TOKENS_PER_DAY: ${CHAT_MAX_TOKENS_PER_DAY:-1000000} 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: networks:
- disinto-net - disinto-net