Update.
This commit is contained in:
commit
b4ff176805
2
.gitignore
vendored
Normal file
2
.gitignore
vendored
Normal file
@ -0,0 +1,2 @@
|
||||
.env
|
||||
.env.example
|
||||
297
app.py
Normal file
297
app.py
Normal file
@ -0,0 +1,297 @@
|
||||
import os
|
||||
import json
|
||||
import asyncio
|
||||
from typing import Optional
|
||||
from fastapi import FastAPI, WebSocket, WebSocketDisconnect, HTTPException
|
||||
from fastapi.responses import HTMLResponse, FileResponse
|
||||
from fastapi.middleware.cors import CORSMiddleware
|
||||
import aiohttp
|
||||
from dotenv import load_dotenv
|
||||
|
||||
import os
|
||||
if not os.path.exists(".env"):
|
||||
with open(".env.example", "w") as f:
|
||||
f.write("GITEA_BASE_URL=https://gitea.example.com\n")
|
||||
print("Created .env.example - please configure and rename to .env")
|
||||
|
||||
load_dotenv()
|
||||
|
||||
app = FastAPI()
|
||||
|
||||
token = os.getenv('GITEA_TOKEN')
|
||||
|
||||
app.add_middleware(
|
||||
CORSMiddleware,
|
||||
allow_origins=["*"],
|
||||
allow_credentials=True,
|
||||
allow_methods=["*"],
|
||||
allow_headers=["*"],
|
||||
)
|
||||
|
||||
class GiteaClient:
|
||||
def __init__(self, base_url, token=None, otp=None, sudo=None, timeout=30):
|
||||
self.base_url = base_url.rstrip("/")
|
||||
self.token = token
|
||||
self.otp = otp
|
||||
self.sudo = sudo
|
||||
self.timeout = aiohttp.ClientTimeout(total=timeout)
|
||||
self.session = None
|
||||
|
||||
async def start(self):
|
||||
headers = {"Accept": "application/json"}
|
||||
if self.token:
|
||||
headers["Authorization"] = f"token {self.token}"
|
||||
if self.otp:
|
||||
headers["X-Gitea-OTP"] = self.otp
|
||||
if self.sudo:
|
||||
headers["Sudo"] = self.sudo
|
||||
self.session = aiohttp.ClientSession(headers=headers, timeout=self.timeout)
|
||||
|
||||
async def stop(self):
|
||||
if self.session:
|
||||
await self.session.close()
|
||||
|
||||
async def _request(self, method, path, params=None, json=None):
|
||||
url = f"{self.base_url}/{path.lstrip('/')}"
|
||||
async with self.session.request(method, url, params=params, json=json) as resp:
|
||||
status = resp.status
|
||||
headers = dict(resp.headers)
|
||||
ct = headers.get("Content-Type", "")
|
||||
if "application/json" in ct:
|
||||
data = await resp.json()
|
||||
else:
|
||||
data = await resp.text()
|
||||
return {"status": status, "headers": headers, "data": data}
|
||||
|
||||
async def me(self):
|
||||
return await self._request("GET", "api/v1/user")
|
||||
|
||||
async def user(self, username):
|
||||
return await self._request("GET", f"api/v1/users/{username}")
|
||||
|
||||
async def user_tokens(self, username, page=None, limit=None):
|
||||
params = {}
|
||||
if page is not None:
|
||||
params["page"] = page
|
||||
if limit is not None:
|
||||
params["limit"] = limit
|
||||
return await self._request("GET", f"api/v1/users/{username}/tokens", params=params)
|
||||
|
||||
async def create_token(self, username, name):
|
||||
return await self._request("POST", f"api/v1/users/{username}/tokens", json={"name": name})
|
||||
|
||||
async def delete_token(self, username, token_id):
|
||||
return await self._request("DELETE", f"api/v1/users/{username}/tokens/{token_id}")
|
||||
|
||||
async def user_repos(self, username, page=None, limit=None, sort=None):
|
||||
params = {}
|
||||
if page is not None:
|
||||
params["page"] = page
|
||||
if limit is not None:
|
||||
params["limit"] = limit
|
||||
if sort is not None:
|
||||
params["sort"] = sort
|
||||
return await self._request("GET", f"api/v1/users/{username}/repos", params=params)
|
||||
|
||||
async def repo(self, owner, repo):
|
||||
return await self._request("GET", f"api/v1/repos/{owner}/{repo}")
|
||||
|
||||
async def create_repo(self, name, private=False, description=None, auto_init=False, default_branch=None):
|
||||
payload = {"name": name, "private": private, "auto_init": auto_init}
|
||||
if description is not None:
|
||||
payload["description"] = description
|
||||
if default_branch is not None:
|
||||
payload["default_branch"] = default_branch
|
||||
return await self._request("POST", "api/v1/user/repos", json=payload)
|
||||
|
||||
async def repo_issues(self, owner, repo, state=None, page=None, limit=None):
|
||||
params = {}
|
||||
if state is not None:
|
||||
params["state"] = state
|
||||
if page is not None:
|
||||
params["page"] = page
|
||||
if limit is not None:
|
||||
params["limit"] = limit
|
||||
return await self._request("GET", f"api/v1/repos/{owner}/{repo}/issues", params=params)
|
||||
|
||||
async def create_issue(self, owner, repo, title, body=None, assignees=None, labels=None, milestone=None):
|
||||
payload = {"title": title}
|
||||
if body is not None:
|
||||
payload["body"] = body
|
||||
if assignees is not None:
|
||||
payload["assignees"] = assignees
|
||||
if labels is not None:
|
||||
payload["labels"] = labels
|
||||
if milestone is not None:
|
||||
payload["milestone"] = milestone
|
||||
return await self._request("POST", f"api/v1/repos/{owner}/{repo}/issues", json=payload)
|
||||
|
||||
class OpenAIClient:
|
||||
def __init__(self, api_key):
|
||||
self.api_key = api_key
|
||||
self.base_url = "https://api.openai.com/v1"
|
||||
self.session = None
|
||||
|
||||
async def start(self):
|
||||
headers = {
|
||||
"Authorization": f"Bearer {self.api_key}",
|
||||
"Content-Type": "application/json"
|
||||
}
|
||||
self.session = aiohttp.ClientSession(headers=headers)
|
||||
|
||||
async def stop(self):
|
||||
if self.session:
|
||||
await self.session.close()
|
||||
|
||||
async def chat_completion(self, messages, model="gpt-4o-mini", tools=None, tool_choice=None):
|
||||
payload = {"model": model, "messages": messages}
|
||||
if tools:
|
||||
payload["tools"] = tools
|
||||
if tool_choice:
|
||||
payload["tool_choice"] = tool_choice
|
||||
async with self.session.post(f"{self.base_url}/chat/completions", json=payload) as resp:
|
||||
return await resp.json()
|
||||
|
||||
async def authenticate_gitea(username, password):
|
||||
global token
|
||||
base_url = os.getenv("GITEA_BASE_URL", "https://gitea.example.com")
|
||||
return token
|
||||
async with aiohttp.ClientSession() as session:
|
||||
auth = aiohttp.BasicAuth(username, password)
|
||||
token = os.getenv('GITEA_TOKEN')
|
||||
print("DE TOKEN:", token)
|
||||
async with session.post(f"{base_url}/api/v1/users/{username}/tokens",
|
||||
json={"name": "mrissue_temp"}, headers={"Accept": "application/json","Authorization": f"token {token}", "Content-Type": "application/json"}) as resp:
|
||||
print(resp)
|
||||
if resp.status == 201:
|
||||
data = await resp.json()
|
||||
token = data.get("sha1")
|
||||
token_id = data.get("id")
|
||||
async with session.delete(f"{base_url}/api/v1/users/{username}/tokens/{token_id}",
|
||||
headers={"Authorization": f"token {token}"}) as del_resp:
|
||||
pass
|
||||
return token
|
||||
return None
|
||||
|
||||
@app.get("/")
|
||||
async def read_root():
|
||||
return FileResponse("index.html")
|
||||
|
||||
@app.websocket("/ws")
|
||||
async def websocket_endpoint(websocket: WebSocket):
|
||||
await websocket.accept()
|
||||
|
||||
try:
|
||||
auth_data = await websocket.receive_json()
|
||||
username = auth_data.get("username")
|
||||
password = auth_data.get("password")
|
||||
|
||||
await websocket.send_json({"type": "log", "message": "Authenticating with Gitea..."})
|
||||
|
||||
token = await authenticate_gitea(username, password)
|
||||
if not token:
|
||||
await websocket.send_json({"type": "error", "message": "Authentication failed"})
|
||||
await websocket.close()
|
||||
return
|
||||
|
||||
await websocket.send_json({"type": "log", "message": "Authentication successful"})
|
||||
|
||||
prompt_data = await websocket.receive_json()
|
||||
prompt = prompt_data.get("prompt")
|
||||
|
||||
gitea_base = os.getenv("GITEA_BASE_URL", "https://gitea.example.com")
|
||||
openai_key = os.getenv("OPENAI_API_KEY")
|
||||
|
||||
gitea = GiteaClient(gitea_base, token=token)
|
||||
openai_client = OpenAIClient(openai_key)
|
||||
|
||||
await gitea.start()
|
||||
await openai_client.start()
|
||||
|
||||
try:
|
||||
await websocket.send_json({"type": "log", "message": "Fetching user repositories..."})
|
||||
repos = []
|
||||
page = 1
|
||||
while True:
|
||||
repos_resp = await gitea.user_repos(username, limit=100, page=page)
|
||||
page_repos = repos_resp.get("data", [])
|
||||
if not page_repos:
|
||||
break
|
||||
repos.extend(page_repos)
|
||||
page += 1
|
||||
repo_names = [r["name"] for r in repos]
|
||||
await websocket.send_json({"type": "log", "message": f"Found {len(repo_names)} repositories"})
|
||||
|
||||
await websocket.send_json({"type": "log", "message": "Analyzing prompt with AI..."})
|
||||
|
||||
messages = [
|
||||
{"role": "system", "content": "You are a helpful assistant that extracts project names from user prompts."},
|
||||
{"role": "user", "content": f"User prompt: {prompt}\n\nAvailable repositories: {', '.join(repo_names)}\n\nWhich repository should these issues be created in? Provide the exact repository name or the closest match. respond with the literal name only."}
|
||||
]
|
||||
|
||||
ai_resp = await openai_client.chat_completion(messages)
|
||||
project_match = ai_resp["choices"][0]["message"]["content"].strip()
|
||||
|
||||
await websocket.send_json({"type": "log", "message": f"AI suggested project: {project_match}"})
|
||||
|
||||
matched_repo = None
|
||||
for repo in repos:
|
||||
if repo["name"].lower().strip() == project_match.lower().strip() or project_match.lower().strip() in repo["name"].lower().strip():
|
||||
matched_repo = repo
|
||||
break
|
||||
|
||||
if not matched_repo:
|
||||
await websocket.send_json({"type": "error", "message": f"Project not found: {project_match}"})
|
||||
await websocket.close()
|
||||
return
|
||||
|
||||
await websocket.send_json({"type": "log", "message": f"Matched to repository: {matched_repo['name']}"})
|
||||
|
||||
await websocket.send_json({"type": "log", "message": "Generating issue list with AI..."})
|
||||
|
||||
issue_messages = [
|
||||
{"role": "system", "content": "You are a helpful assistant that creates structured issue lists from user prompts. Return ONLY a valid JSON array with objects containing 'title' and 'description' fields."},
|
||||
{"role": "user", "content": f"Create a list of issues based on this prompt: {prompt}\n\nReturn as valid JSON array: [{'{\"title\": \"...\", \"description\": \"...\"}'}] without markup."}
|
||||
]
|
||||
issues_resp = await openai_client.chat_completion(issue_messages)
|
||||
issues_text = issues_resp["choices"][0]["message"]["content"].strip()
|
||||
|
||||
if "```json" in issues_text:
|
||||
issues_text = issues_text.split("```json")[0].split("""```""")[0]
|
||||
elif "```" in issues_text:
|
||||
issues_text = issues_text.split("``````")[0].strip()
|
||||
|
||||
issues = json.loads(issues_text)
|
||||
|
||||
await websocket.send_json({"type": "log", "message": f"Generated {len(issues)} issues"})
|
||||
|
||||
for idx, issue in enumerate(issues, 1):
|
||||
await websocket.send_json({"type": "log", "message": f"Creating issue {idx}/{len(issues)}: {issue['title']}"})
|
||||
|
||||
create_resp = await gitea.create_issue(
|
||||
username,
|
||||
matched_repo["name"],
|
||||
issue["title"],
|
||||
body=issue.get("description")
|
||||
)
|
||||
|
||||
if create_resp["status"] == 201:
|
||||
await websocket.send_json({"type": "log", "message": f"✓ Created: {issue['title']}"})
|
||||
else:
|
||||
await websocket.send_json({"type": "log", "message": f"✗ Failed: {issue['title']}"})
|
||||
|
||||
await websocket.send_json({"type": "complete", "message": "All issues created successfully"})
|
||||
|
||||
finally:
|
||||
await gitea.stop()
|
||||
await openai_client.stop()
|
||||
|
||||
except WebSocketDisconnect:
|
||||
pass
|
||||
except Exception as e:
|
||||
await websocket.send_json({"type": "error", "message": str(e)})
|
||||
|
||||
if __name__ == "__main__":
|
||||
import uvicorn
|
||||
uvicorn.run(app, host="127.0.0.1", port=8590)
|
||||
|
||||
314
index.html
Normal file
314
index.html
Normal file
@ -0,0 +1,314 @@
|
||||
<!DOCTYPE html>
|
||||
<html lang="en">
|
||||
<head>
|
||||
<meta charset="UTF-8">
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
||||
<title>Mr. Issue</title>
|
||||
<style>
|
||||
* {
|
||||
margin: 0;
|
||||
padding: 0;
|
||||
box-sizing: border-box;
|
||||
}
|
||||
|
||||
body {
|
||||
font-family: Arial, sans-serif;
|
||||
background: #fff;
|
||||
color: #202124;
|
||||
padding: 20px;
|
||||
}
|
||||
|
||||
.container {
|
||||
max-width: 900px;
|
||||
margin: 0 auto;
|
||||
}
|
||||
|
||||
h1 {
|
||||
text-align: center;
|
||||
color: #4285f4;
|
||||
margin-bottom: 30px;
|
||||
font-size: 48px;
|
||||
font-weight: normal;
|
||||
}
|
||||
|
||||
.login-form {
|
||||
background: #fff;
|
||||
border: 1px solid #dfe1e5;
|
||||
border-radius: 8px;
|
||||
padding: 40px;
|
||||
margin-bottom: 30px;
|
||||
}
|
||||
|
||||
.form-group {
|
||||
margin-bottom: 20px;
|
||||
}
|
||||
|
||||
label {
|
||||
display: block;
|
||||
margin-bottom: 8px;
|
||||
color: #5f6368;
|
||||
font-size: 14px;
|
||||
}
|
||||
|
||||
input[type="text"],
|
||||
input[type="password"] {
|
||||
width: 100%;
|
||||
padding: 12px;
|
||||
border: 1px solid #dfe1e5;
|
||||
border-radius: 4px;
|
||||
font-size: 14px;
|
||||
}
|
||||
|
||||
input[type="text"]:focus,
|
||||
input[type="password"]:focus {
|
||||
outline: none;
|
||||
border-color: #4285f4;
|
||||
}
|
||||
|
||||
button {
|
||||
background: #4285f4;
|
||||
color: #fff;
|
||||
border: none;
|
||||
padding: 12px 24px;
|
||||
border-radius: 4px;
|
||||
cursor: pointer;
|
||||
font-size: 14px;
|
||||
font-weight: 500;
|
||||
}
|
||||
|
||||
button:hover {
|
||||
background: #3367d6;
|
||||
}
|
||||
|
||||
button:disabled {
|
||||
background: #dadce0;
|
||||
cursor: not-allowed;
|
||||
}
|
||||
|
||||
.editor-container {
|
||||
display: none;
|
||||
}
|
||||
|
||||
.editor-container.active {
|
||||
display: block;
|
||||
}
|
||||
|
||||
.progress-log {
|
||||
background: #f8f9fa;
|
||||
border: 1px solid #dfe1e5;
|
||||
border-radius: 8px;
|
||||
padding: 20px;
|
||||
margin-bottom: 20px;
|
||||
max-height: 300px;
|
||||
overflow-y: auto;
|
||||
display: none;
|
||||
}
|
||||
|
||||
.progress-log.active {
|
||||
display: block;
|
||||
}
|
||||
|
||||
.log-entry {
|
||||
padding: 8px 0;
|
||||
border-bottom: 1px solid #e8eaed;
|
||||
font-size: 14px;
|
||||
color: #5f6368;
|
||||
}
|
||||
|
||||
.log-entry:last-child {
|
||||
border-bottom: none;
|
||||
}
|
||||
|
||||
.log-entry.error {
|
||||
color: #d93025;
|
||||
}
|
||||
|
||||
.log-entry.complete {
|
||||
color: #1e8e3e;
|
||||
font-weight: 500;
|
||||
}
|
||||
|
||||
textarea {
|
||||
width: 100%;
|
||||
min-height: 400px;
|
||||
padding: 16px;
|
||||
border: 1px solid #dfe1e5;
|
||||
border-radius: 8px;
|
||||
font-size: 14px;
|
||||
font-family: 'Courier New', monospace;
|
||||
resize: vertical;
|
||||
}
|
||||
|
||||
textarea:focus {
|
||||
outline: none;
|
||||
border-color: #4285f4;
|
||||
}
|
||||
|
||||
.submit-btn {
|
||||
margin-top: 16px;
|
||||
width: 100%;
|
||||
}
|
||||
|
||||
.preview {
|
||||
background: #f8f9fa;
|
||||
border: 1px solid #dfe1e5;
|
||||
border-radius: 8px;
|
||||
padding: 20px;
|
||||
margin-top: 20px;
|
||||
min-height: 100px;
|
||||
font-size: 14px;
|
||||
line-height: 1.6;
|
||||
}
|
||||
|
||||
.logout-btn {
|
||||
background: #5f6368;
|
||||
float: right;
|
||||
margin-bottom: 20px;
|
||||
}
|
||||
|
||||
.logout-btn:hover {
|
||||
background: #3c4043;
|
||||
}
|
||||
</style>
|
||||
</head>
|
||||
<body>
|
||||
<div class="container">
|
||||
<h1>Mr. Issue</h1>
|
||||
|
||||
<div class="login-form" id="loginForm">
|
||||
<div class="form-group">
|
||||
<label for="username">Username</label>
|
||||
<input type="text" id="username" required>
|
||||
</div>
|
||||
<div class="form-group">
|
||||
<label for="password">Password</label>
|
||||
<input type="password" id="password" required>
|
||||
</div>
|
||||
<button onclick="login()">Sign In</button>
|
||||
</div>
|
||||
|
||||
<div class="editor-container" id="editorContainer">
|
||||
<button class="logout-btn" onclick="logout()">Sign Out</button>
|
||||
<div style="clear: both;"></div>
|
||||
|
||||
<div class="progress-log" id="progressLog"></div>
|
||||
|
||||
<textarea id="promptEditor" placeholder="Describe the issues you want to create...
|
||||
|
||||
Example:
|
||||
Create issues for project 'my-app' with the following tasks:
|
||||
- Fix login bug
|
||||
- Add dark mode
|
||||
- Update documentation"></textarea>
|
||||
|
||||
<div class="preview" id="preview"></div>
|
||||
|
||||
<button class="submit-btn" id="submitBtn" onclick="submitPrompt()">Create Issues</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<script>
|
||||
let ws = null;
|
||||
let credentials = null;
|
||||
|
||||
const loginForm = document.getElementById('loginForm');
|
||||
const editorContainer = document.getElementById('editorContainer');
|
||||
const progressLog = document.getElementById('progressLog');
|
||||
const promptEditor = document.getElementById('promptEditor');
|
||||
const preview = document.getElementById('preview');
|
||||
const submitBtn = document.getElementById('submitBtn');
|
||||
|
||||
promptEditor.addEventListener('input', () => {
|
||||
preview.textContent = promptEditor.value;
|
||||
});
|
||||
|
||||
function login() {
|
||||
const username = document.getElementById('username').value;
|
||||
const password = document.getElementById('password').value;
|
||||
|
||||
if (!username || !password) {
|
||||
alert('Please enter username and password');
|
||||
return;
|
||||
}
|
||||
|
||||
credentials = { username, password };
|
||||
loginForm.style.display = 'none';
|
||||
editorContainer.classList.add('active');
|
||||
}
|
||||
|
||||
function logout() {
|
||||
credentials = null;
|
||||
if (ws) {
|
||||
ws.close();
|
||||
ws = null;
|
||||
}
|
||||
loginForm.style.display = 'block';
|
||||
editorContainer.classList.remove('active');
|
||||
progressLog.classList.remove('active');
|
||||
progressLog.innerHTML = '';
|
||||
promptEditor.value = '';
|
||||
preview.textContent = '';
|
||||
}
|
||||
|
||||
function addLog(message, type = 'log') {
|
||||
const entry = document.createElement('div');
|
||||
entry.className = `log-entry ${type}`;
|
||||
entry.textContent = message;
|
||||
progressLog.appendChild(entry);
|
||||
progressLog.scrollTop = progressLog.scrollHeight;
|
||||
}
|
||||
|
||||
function submitPrompt() {
|
||||
const prompt = promptEditor.value.trim();
|
||||
if (!prompt) {
|
||||
alert('Please enter a prompt');
|
||||
return;
|
||||
}
|
||||
|
||||
progressLog.innerHTML = '';
|
||||
progressLog.classList.add('active');
|
||||
submitBtn.disabled = true;
|
||||
|
||||
ws = new WebSocket('ws://127.0.0.1:8590/ws');
|
||||
|
||||
ws.onopen = () => {
|
||||
addLog('Connected to server');
|
||||
ws.send(JSON.stringify(credentials));
|
||||
};
|
||||
|
||||
ws.onmessage = (event) => {
|
||||
const data = JSON.parse(event.data);
|
||||
|
||||
if (data.type === 'log') {
|
||||
addLog(data.message);
|
||||
} else if (data.type === 'error') {
|
||||
addLog(data.message, 'error');
|
||||
submitBtn.disabled = false;
|
||||
} else if (data.type === 'complete') {
|
||||
addLog(data.message, 'complete');
|
||||
submitBtn.disabled = false;
|
||||
ws.close();
|
||||
}
|
||||
};
|
||||
|
||||
ws.onerror = () => {
|
||||
addLog('Connection error', 'error');
|
||||
submitBtn.disabled = false;
|
||||
};
|
||||
|
||||
ws.onclose = () => {
|
||||
if (submitBtn.disabled) {
|
||||
submitBtn.disabled = false;
|
||||
}
|
||||
};
|
||||
|
||||
setTimeout(() => {
|
||||
if (ws.readyState === WebSocket.OPEN) {
|
||||
ws.send(JSON.stringify({ prompt }));
|
||||
}
|
||||
}, 100);
|
||||
}
|
||||
</script>
|
||||
</body>
|
||||
</html>
|
||||
|
||||
Loading…
Reference in New Issue
Block a user