4 changed files with 454 additions and 2 deletions
|
|
@ -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.
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
|
|
|
|||
|
|
@ -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();
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue