Compare commits

...
Sign in to create a new pull request.

2 commits

Author SHA1 Message Date
Claude
f398b32952 fix: agent-smoke.sh - add lib/env.sh as extra source for ci-debug.sh
Some checks failed
ci/woodpecker/push/ci Pipeline was successful
ci/woodpecker/pr/ci Pipeline failed
ci/woodpecker/pr/smoke-init Pipeline was successful
2026-04-12 05:54:18 +00:00
Claude
2605d8afba fix: vision(#623): disinto-chat escalation tools (CI run, issue create, PR create) (#712) 2026-04-12 05:54:18 +00:00
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).
# 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.

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)

View file

@ -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 = `
<div class="role">${role}</div>
<div class="content${streaming ? ' streaming' : ''}">${escapeHtml(content)}</div>
<div class="content${streaming ? ' streaming' : ''}">${contentHtml}</div>
`;
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, '<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
async function sendMessage() {
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_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