14 Commits
0.6.0 ... 0.7.0

Author SHA1 Message Date
e495775b91 release: version 0.7.0 🚀
All checks were successful
Upload Python Package / Create Release (push) Successful in 1m42s
Upload Python Package / deploy (push) Successful in 2m10s
2026-04-11 00:10:55 +02:00
356c388efb feat: gitea issue integration, refs NOISSUE 2026-04-11 00:10:51 +02:00
fd812476cc feat: better history data, refs NOISSUE 2026-04-10 23:52:08 +02:00
032139c14f release: version 0.6.5 🚀
All checks were successful
Upload Python Package / Create Release (push) Successful in 1m18s
Upload Python Package / deploy (push) Successful in 3m11s
2026-04-10 22:13:40 +02:00
194d5658a6 fix: better n8n workflow, refs NOISSUE 2026-04-10 22:13:33 +02:00
b9faac8d16 release: version 0.6.4 🚀
All checks were successful
Upload Python Package / Create Release (push) Successful in 52s
Upload Python Package / deploy (push) Successful in 2m14s
2026-04-10 21:47:54 +02:00
80d7716e65 fix: add Telegram helper functions, refs NOISSUE 2026-04-10 21:47:50 +02:00
321bf74aef release: version 0.6.3 🚀
All checks were successful
Upload Python Package / Create Release (push) Successful in 15s
Upload Python Package / deploy (push) Successful in 1m11s
2026-04-10 21:24:44 +02:00
55ee75106c fix: n8n workflow generation, refs NOISSUE 2026-04-10 21:24:39 +02:00
b2829caa02 release: version 0.6.2 🚀
All checks were successful
Upload Python Package / Create Release (push) Successful in 29s
Upload Python Package / deploy (push) Successful in 1m11s
2026-04-10 21:14:12 +02:00
d4b280cf75 fix: fix Quasar layout issues, refs NOISSUE 2026-04-10 21:14:09 +02:00
806db8537b release: version 0.6.1 🚀
All checks were successful
Upload Python Package / Create Release (push) Successful in 22s
Upload Python Package / deploy (push) Successful in 37s
2026-04-10 21:00:30 +02:00
360ed5c6f3 fix: fix commit for version push, refs NOISSUE 2026-04-10 21:00:26 +02:00
4b9eb2f359 chore: add more health info for n8n, refs NOISSUE 2026-04-10 20:55:43 +02:00
16 changed files with 3964 additions and 175 deletions

View File

@@ -4,6 +4,69 @@ Changelog
(unreleased) (unreleased)
------------ ------------
- Feat: gitea issue integration, refs NOISSUE. [Simon Diesenreiter]
- Feat: better history data, refs NOISSUE. [Simon Diesenreiter]
0.6.5 (2026-04-10)
------------------
Fix
~~~
- Better n8n workflow, refs NOISSUE. [Simon Diesenreiter]
Other
~~~~~
0.6.4 (2026-04-10)
------------------
Fix
~~~
- Add Telegram helper functions, refs NOISSUE. [Simon Diesenreiter]
Other
~~~~~
0.6.3 (2026-04-10)
------------------
Fix
~~~
- N8n workflow generation, refs NOISSUE. [Simon Diesenreiter]
Other
~~~~~
0.6.2 (2026-04-10)
------------------
Fix
~~~
- Fix Quasar layout issues, refs NOISSUE. [Simon Diesenreiter]
Other
~~~~~
0.6.1 (2026-04-10)
------------------
Fix
~~~
- Fix commit for version push, refs NOISSUE. [Simon Diesenreiter]
Other
~~~~~
- Chore: add more health info for n8n, refs NOISSUE. [Simon
Diesenreiter]
0.6.0 (2026-04-10)
------------------
- Feat(api): expose database target in health refs NOISSUE. [Simon - Feat(api): expose database target in health refs NOISSUE. [Simon
Diesenreiter] Diesenreiter]
- Fix(db): prefer postgres config in production refs NOISSUE. [Simon - Fix(db): prefer postgres config in production refs NOISSUE. [Simon

View File

@@ -86,11 +86,14 @@ docker-compose up -d
1. **Send a request via Telegram:** 1. **Send a request via Telegram:**
``` ```
Name: My Awesome App Build an internal task management app for our operations team.
Description: A web application for managing tasks It should support user authentication, task CRUD, notifications, and reporting.
Features: user authentication, task CRUD, notifications Prefer FastAPI with PostgreSQL and a simple web dashboard.
``` ```
The backend now interprets free-form Telegram text with Ollama before generation.
If `TELEGRAM_CHAT_ID` is set, the Telegram-trigger workflow only reacts to messages from that specific chat.
2. **Monitor progress via Web UI:** 2. **Monitor progress via Web UI:**
Open `http://yourserver:8000/` to see the dashboard and `http://yourserver:8000/api` for API metadata Open `http://yourserver:8000/` to see the dashboard and `http://yourserver:8000/api` for API metadata
@@ -109,6 +112,7 @@ If you deploy the container with PostgreSQL environment variables set, the servi
| `/api` | GET | API information | | `/api` | GET | API information |
| `/health` | GET | Health check | | `/health` | GET | Health check |
| `/generate` | POST | Generate new software | | `/generate` | POST | Generate new software |
| `/generate/text` | POST | Interpret free-form text and generate software |
| `/status/{project_id}` | GET | Get project status | | `/status/{project_id}` | GET | Get project status |
| `/projects` | GET | List all projects | | `/projects` | GET | List all projects |

View File

@@ -1 +1 @@
0.6.0 0.7.0

View File

@@ -0,0 +1,136 @@
"""Generate concise chat-friendly summaries of software generation results."""
from __future__ import annotations
try:
from ..config import settings
except ImportError:
from config import settings
class ChangeSummaryGenerator:
"""Create a readable overview of generated changes for chat responses."""
def __init__(self, ollama_url: str | None = None, model: str | None = None):
self.ollama_url = (ollama_url or settings.ollama_url).rstrip('/')
self.model = model or settings.OLLAMA_MODEL
async def summarize(self, context: dict) -> str:
"""Summarize project changes with Ollama, or fall back to a deterministic overview."""
summary, _trace = await self.summarize_with_trace(context)
return summary
async def summarize_with_trace(self, context: dict) -> tuple[str, dict]:
"""Summarize project changes with Ollama, or fall back to a deterministic overview."""
prompt = self._prompt(context)
system_prompt = (
'You write concise but informative mobile chat summaries of software delivery work. '
'Write 3 to 5 sentences. Mention the application goal, main delivered pieces, '
'technical direction, and what the user should expect next. Avoid markdown bullets.'
)
try:
import aiohttp
async with aiohttp.ClientSession() as session:
async with session.post(
f'{self.ollama_url}/api/chat',
json={
'model': self.model,
'stream': False,
'messages': [
{
'role': 'system',
'content': system_prompt,
},
{'role': 'user', 'content': prompt},
],
},
) as resp:
payload = await resp.json()
if 200 <= resp.status < 300:
content = payload.get('message', {}).get('content', '').strip()
if content:
return content, {
'stage': 'change_summary',
'provider': 'ollama',
'model': self.model,
'system_prompt': system_prompt,
'user_prompt': prompt,
'assistant_response': content,
'raw_response': payload,
'fallback_used': False,
}
except Exception:
pass
fallback = self._fallback(context)
return fallback, {
'stage': 'change_summary',
'provider': 'fallback',
'model': self.model,
'system_prompt': system_prompt,
'user_prompt': prompt,
'assistant_response': fallback,
'raw_response': {'fallback': 'deterministic'},
'fallback_used': True,
}
def _prompt(self, context: dict) -> str:
features = ', '.join(context.get('features') or []) or 'No explicit features recorded'
tech_stack = ', '.join(context.get('tech_stack') or []) or 'No explicit tech stack recorded'
changed_files = ', '.join(context.get('changed_files') or []) or 'No files recorded'
logs = ' | '.join((context.get('logs') or [])[:4]) or 'No log excerpts'
return (
f"Project name: {context.get('name', 'Unknown project')}\n"
f"Description: {context.get('description', '')}\n"
f"Features: {features}\n"
f"Tech stack: {tech_stack}\n"
f"Changed files: {changed_files}\n"
f"Repository: {context.get('repository_url') or 'No repository URL'}\n"
f"Pull request: {context.get('pull_request_url') or 'No pull request URL'}\n"
f"Pull request state: {context.get('pull_request_state') or 'No pull request state'}\n"
f"Status message: {context.get('message') or ''}\n"
f"Log excerpts: {logs}\n"
"Write a broad but phone-friendly summary of what was done."
)
def _fallback(self, context: dict) -> str:
name = context.get('name', 'The project')
description = context.get('description') or 'a software request'
changed_files = context.get('changed_files') or []
features = context.get('features') or []
tech_stack = context.get('tech_stack') or []
repo_url = context.get('repository_url')
repo_status = context.get('repository_status')
pr_url = context.get('pull_request_url')
pr_state = context.get('pull_request_state')
first_sentence = f"{name} was generated from your request for {description}."
feature_sentence = (
f"The delivery focused on {', '.join(features[:3])}."
if features else
"The delivery focused on turning the request into an initial runnable application skeleton."
)
tech_sentence = (
f"The generated implementation currently targets {', '.join(tech_stack[:3])}."
if tech_stack else
"The implementation was created with the current default stack configured for the factory."
)
file_sentence = (
f"Key artifacts were updated across {len(changed_files)} files, including {', '.join(changed_files[:3])}."
if changed_files else
"The service completed the generation flow, but no changed file list was returned."
)
if repo_url:
repo_sentence = f"The resulting project is tracked at {repo_url}."
elif repo_status in {'pending', 'skipped', 'error'}:
repo_sentence = "Repository provisioning was not confirmed, so review the Gitea status in the dashboard before assuming a remote repo exists."
else:
repo_sentence = "The project is ready for further review in the dashboard."
if pr_url and pr_state == 'open':
pr_sentence = f"An open pull request is ready for review at {pr_url}, and later prompts will continue updating that same PR until it is merged."
elif pr_url:
pr_sentence = f"The latest pull request is available at {pr_url}."
else:
pr_sentence = "No pull request link was recorded for this delivery."
return ' '.join([first_sentence, feature_sentence, tech_sentence, file_sentence, repo_sentence, pr_sentence])

File diff suppressed because it is too large Load Diff

View File

@@ -2,6 +2,7 @@
import os import os
import subprocess import subprocess
import tempfile
from pathlib import Path from pathlib import Path
from typing import Optional from typing import Optional
@@ -14,10 +15,13 @@ except ImportError:
class GitManager: class GitManager:
"""Manages git operations for the project.""" """Manages git operations for the project."""
def __init__(self, project_id: str): def __init__(self, project_id: str, project_dir: str | None = None):
if not project_id: if not project_id:
raise ValueError("project_id cannot be empty or None") raise ValueError("project_id cannot be empty or None")
self.project_id = project_id self.project_id = project_id
if project_dir:
resolved = Path(project_dir).expanduser().resolve()
else:
project_path = Path(project_id) project_path = Path(project_id)
if project_path.is_absolute() or len(project_path.parts) > 1: if project_path.is_absolute() or len(project_path.parts) > 1:
resolved = project_path.expanduser().resolve() resolved = project_path.expanduser().resolve()
@@ -28,39 +32,113 @@ class GitManager:
resolved = (base_root / project_id).resolve() resolved = (base_root / project_id).resolve()
self.project_dir = str(resolved) self.project_dir = str(resolved)
def _run(self, args: list[str], env: dict | None = None, check: bool = True) -> subprocess.CompletedProcess:
"""Run a git command in the project directory."""
return subprocess.run(
args,
check=check,
capture_output=True,
text=True,
cwd=self.project_dir,
env=env,
)
def has_repo(self) -> bool:
"""Return whether the project directory already contains a git repository."""
return Path(self.project_dir, '.git').exists()
def init_repo(self): def init_repo(self):
"""Initialize git repository.""" """Initialize git repository."""
os.makedirs(self.project_dir, exist_ok=True) os.makedirs(self.project_dir, exist_ok=True)
os.chdir(self.project_dir) self._run(["git", "init", "-b", "main"])
subprocess.run(["git", "init"], check=True, capture_output=True) self._run(["git", "config", "user.name", "AI Software Factory"])
self._run(["git", "config", "user.email", "factory@local.invalid"])
def add_files(self, paths: list[str]): def add_files(self, paths: list[str]):
"""Add files to git staging.""" """Add files to git staging."""
subprocess.run(["git", "add"] + paths, check=True, capture_output=True) self._run(["git", "add"] + paths)
def commit(self, message: str): def checkout_branch(self, branch_name: str, create: bool = False, start_point: str | None = None) -> None:
"""Switch to a branch, optionally creating it from a start point."""
if create:
args = ["git", "checkout", "-B", branch_name]
if start_point:
args.append(start_point)
self._run(args)
return
self._run(["git", "checkout", branch_name])
def branch_exists(self, branch_name: str) -> bool:
"""Return whether a local branch exists."""
result = self._run(["git", "show-ref", "--verify", f"refs/heads/{branch_name}"], check=False)
return result.returncode == 0
def commit(self, message: str) -> str:
"""Create a git commit.""" """Create a git commit."""
subprocess.run( self._run(["git", "commit", "-m", message])
["git", "commit", "-m", message], return self.current_head()
check=True,
capture_output=True def create_empty_commit(self, message: str) -> str:
) """Create an empty commit."""
self._run(["git", "commit", "--allow-empty", "-m", message])
return self.current_head()
def push(self, remote: str = "origin", branch: str = "main"): def push(self, remote: str = "origin", branch: str = "main"):
"""Push changes to remote.""" """Push changes to remote."""
subprocess.run( self._run(["git", "push", "-u", remote, branch])
["git", "push", "-u", remote, branch],
check=True, def ensure_remote(self, remote: str, url: str) -> None:
capture_output=True """Create or update a remote URL."""
result = self._run(["git", "remote", "get-url", remote], check=False)
if result.returncode == 0:
self._run(["git", "remote", "set-url", remote, url])
else:
self._run(["git", "remote", "add", remote, url])
def push_with_credentials(
self,
remote_url: str,
username: str,
password: str,
remote: str = "origin",
branch: str = "main",
) -> None:
"""Push to a remote over HTTPS using an askpass helper."""
os.makedirs(self.project_dir, exist_ok=True)
self.ensure_remote(remote, remote_url)
helper_contents = "#!/bin/sh\ncase \"$1\" in\n *Username*) printf '%s\\n' \"$GIT_ASKPASS_USERNAME\" ;;\n *) printf '%s\\n' \"$GIT_ASKPASS_PASSWORD\" ;;\nesac\n"
helper_path: str | None = None
try:
with tempfile.NamedTemporaryFile('w', delete=False, dir=self.project_dir, prefix='git-askpass-', suffix='.sh') as helper_file:
helper_file.write(helper_contents)
helper_path = helper_file.name
os.chmod(helper_path, 0o700)
env = os.environ.copy()
env.update(
{
"GIT_TERMINAL_PROMPT": "0",
"GIT_ASKPASS": helper_path,
"GIT_ASKPASS_USERNAME": username,
"GIT_ASKPASS_PASSWORD": password,
}
) )
self._run(["git", "push", "-u", remote, branch], env=env)
finally:
if helper_path:
Path(helper_path).unlink(missing_ok=True)
def create_branch(self, branch_name: str): def create_branch(self, branch_name: str):
"""Create and switch to a new branch.""" """Create and switch to a new branch."""
subprocess.run( self._run(["git", "checkout", "-b", branch_name])
["git", "checkout", "-b", branch_name],
check=True, def revert_commit(self, commit_hash: str, no_edit: bool = True) -> str:
capture_output=True """Revert a commit and return the new HEAD."""
) args = ["git", "revert"]
if no_edit:
args.append("--no-edit")
args.append(commit_hash)
self._run(args)
return self.current_head()
def create_pr( def create_pr(
self, self,
@@ -84,6 +162,18 @@ class GitManager:
result = subprocess.run( result = subprocess.run(
["git", "status", "--porcelain"], ["git", "status", "--porcelain"],
capture_output=True, capture_output=True,
text=True text=True,
cwd=self.project_dir,
) )
return result.stdout.strip() return result.stdout.strip()
def current_head(self) -> str:
"""Return the current commit hash."""
return self._run(["git", "rev-parse", "HEAD"]).stdout.strip()
def current_head_or_none(self) -> str | None:
"""Return the current commit hash when the repository already has commits."""
result = self._run(["git", "rev-parse", "HEAD"], check=False)
if result.returncode != 0:
return None
return result.stdout.strip() or None

View File

@@ -1,6 +1,9 @@
"""Gitea API integration for repository and pull request operations.""" """Gitea API integration for repository and pull request operations."""
import os import os
import urllib.error
import urllib.request
import json
class GiteaAPI: class GiteaAPI:
@@ -41,6 +44,38 @@ class GiteaAPI:
"""Build a Gitea API URL from a relative path.""" """Build a Gitea API URL from a relative path."""
return f"{self.base_url}/api/v1/{path.lstrip('/')}" return f"{self.base_url}/api/v1/{path.lstrip('/')}"
def build_repo_git_url(self, owner: str | None = None, repo: str | None = None) -> str | None:
"""Build the clone URL for a repository."""
_owner = owner or self.owner
_repo = repo or self.repo
if not _owner or not _repo:
return None
return f"{self.base_url}/{_owner}/{_repo}.git"
def build_commit_url(self, commit_hash: str, owner: str | None = None, repo: str | None = None) -> str | None:
"""Build a browser URL for a commit."""
_owner = owner or self.owner
_repo = repo or self.repo
if not _owner or not _repo or not commit_hash:
return None
return f"{self.base_url}/{_owner}/{_repo}/commit/{commit_hash}"
def build_compare_url(self, base_ref: str, head_ref: str, owner: str | None = None, repo: str | None = None) -> str | None:
"""Build a browser URL for a compare view."""
_owner = owner or self.owner
_repo = repo or self.repo
if not _owner or not _repo or not base_ref or not head_ref:
return None
return f"{self.base_url}/{_owner}/{_repo}/compare/{base_ref}...{head_ref}"
def build_pull_request_url(self, pr_number: int, owner: str | None = None, repo: str | None = None) -> str | None:
"""Build a browser URL for a pull request."""
_owner = owner or self.owner
_repo = repo or self.repo
if not _owner or not _repo or not pr_number:
return None
return f"{self.base_url}/{_owner}/{_repo}/pulls/{pr_number}"
async def _request(self, method: str, path: str, payload: dict | None = None) -> dict: async def _request(self, method: str, path: str, payload: dict | None = None) -> dict:
"""Perform a Gitea API request and normalize the response.""" """Perform a Gitea API request and normalize the response."""
try: try:
@@ -59,6 +94,30 @@ class GiteaAPI:
except Exception as e: except Exception as e:
return {"error": str(e)} return {"error": str(e)}
def _request_sync(self, method: str, path: str, payload: dict | None = None) -> dict:
"""Perform a synchronous Gitea API request."""
request = urllib.request.Request(
self._api_url(path),
headers=self.get_auth_headers(),
method=method.upper(),
)
data = None
if payload is not None:
data = json.dumps(payload).encode('utf-8')
request.data = data
try:
with urllib.request.urlopen(request) as response:
body = response.read().decode('utf-8')
return json.loads(body) if body else {}
except urllib.error.HTTPError as exc:
try:
body = exc.read().decode('utf-8')
except Exception:
body = str(exc)
return {'error': body, 'status_code': exc.code}
except Exception as exc:
return {'error': str(exc)}
def build_project_repo_name(self, project_id: str, project_name: str | None = None) -> str: def build_project_repo_name(self, project_id: str, project_name: str | None = None) -> str:
"""Build a repository name for a generated project.""" """Build a repository name for a generated project."""
preferred = (project_name or project_id or "project").strip().lower().replace(" ", "-") preferred = (project_name or project_id or "project").strip().lower().replace(" ", "-")
@@ -97,6 +156,10 @@ class GiteaAPI:
result.setdefault("status", "created") result.setdefault("status", "created")
return result return result
async def get_current_user(self) -> dict:
"""Get the user associated with the configured token."""
return await self._request("GET", "user")
async def create_branch(self, branch: str, base: str = "main", owner: str | None = None, repo: str | None = None): async def create_branch(self, branch: str, base: str = "main", owner: str | None = None, repo: str | None = None):
"""Create a new branch.""" """Create a new branch."""
_owner = owner or self.owner _owner = owner or self.owner
@@ -127,6 +190,144 @@ class GiteaAPI:
} }
return await self._request("POST", f"repos/{_owner}/{_repo}/pulls", payload) return await self._request("POST", f"repos/{_owner}/{_repo}/pulls", payload)
async def list_pull_requests(
self,
owner: str | None = None,
repo: str | None = None,
state: str = 'open',
) -> dict | list:
"""List pull requests for a repository."""
_owner = owner or self.owner
_repo = repo or self.repo
return await self._request("GET", f"repos/{_owner}/{_repo}/pulls?state={state}")
def list_pull_requests_sync(
self,
owner: str | None = None,
repo: str | None = None,
state: str = 'open',
) -> dict | list:
"""Synchronously list pull requests for a repository."""
_owner = owner or self.owner
_repo = repo or self.repo
return self._request_sync("GET", f"repos/{_owner}/{_repo}/pulls?state={state}")
async def list_repositories(self, owner: str | None = None) -> dict | list:
"""List repositories within the configured organization."""
_owner = owner or self.owner
return await self._request("GET", f"orgs/{_owner}/repos")
def list_repositories_sync(self, owner: str | None = None) -> dict | list:
"""Synchronously list repositories within the configured organization."""
_owner = owner or self.owner
return self._request_sync("GET", f"orgs/{_owner}/repos")
async def list_branches(self, owner: str | None = None, repo: str | None = None) -> dict | list:
"""List repository branches."""
_owner = owner or self.owner
_repo = repo or self.repo
return await self._request("GET", f"repos/{_owner}/{_repo}/branches")
def list_branches_sync(self, owner: str | None = None, repo: str | None = None) -> dict | list:
"""Synchronously list repository branches."""
_owner = owner or self.owner
_repo = repo or self.repo
return self._request_sync("GET", f"repos/{_owner}/{_repo}/branches")
async def list_issues(
self,
owner: str | None = None,
repo: str | None = None,
state: str = 'open',
) -> dict | list:
"""List repository issues, excluding pull requests at the consumer layer."""
_owner = owner or self.owner
_repo = repo or self.repo
return await self._request("GET", f"repos/{_owner}/{_repo}/issues?state={state}")
def list_issues_sync(
self,
owner: str | None = None,
repo: str | None = None,
state: str = 'open',
) -> dict | list:
"""Synchronously list repository issues."""
_owner = owner or self.owner
_repo = repo or self.repo
return self._request_sync("GET", f"repos/{_owner}/{_repo}/issues?state={state}")
async def get_issue(self, issue_number: int, owner: str | None = None, repo: str | None = None) -> dict:
"""Return one repository issue by number."""
_owner = owner or self.owner
_repo = repo or self.repo
return await self._request("GET", f"repos/{_owner}/{_repo}/issues/{issue_number}")
def get_issue_sync(self, issue_number: int, owner: str | None = None, repo: str | None = None) -> dict:
"""Synchronously return one repository issue by number."""
_owner = owner or self.owner
_repo = repo or self.repo
return self._request_sync("GET", f"repos/{_owner}/{_repo}/issues/{issue_number}")
async def list_repo_commits(
self,
owner: str | None = None,
repo: str | None = None,
limit: int = 25,
branch: str | None = None,
) -> dict | list:
"""List recent commits for a repository."""
_owner = owner or self.owner
_repo = repo or self.repo
branch_query = f"&sha={branch}" if branch else ""
return await self._request("GET", f"repos/{_owner}/{_repo}/commits?limit={limit}{branch_query}")
def list_repo_commits_sync(
self,
owner: str | None = None,
repo: str | None = None,
limit: int = 25,
branch: str | None = None,
) -> dict | list:
"""Synchronously list recent commits for a repository."""
_owner = owner or self.owner
_repo = repo or self.repo
branch_query = f"&sha={branch}" if branch else ""
return self._request_sync("GET", f"repos/{_owner}/{_repo}/commits?limit={limit}{branch_query}")
async def get_commit(
self,
commit_hash: str,
owner: str | None = None,
repo: str | None = None,
) -> dict:
"""Return one commit by hash."""
_owner = owner or self.owner
_repo = repo or self.repo
return await self._request("GET", f"repos/{_owner}/{_repo}/git/commits/{commit_hash}")
def get_commit_sync(
self,
commit_hash: str,
owner: str | None = None,
repo: str | None = None,
) -> dict:
"""Synchronously return one commit by hash."""
_owner = owner or self.owner
_repo = repo or self.repo
return self._request_sync("GET", f"repos/{_owner}/{_repo}/git/commits/{commit_hash}")
async def get_pull_request(self, pr_number: int, owner: str | None = None, repo: str | None = None) -> dict:
"""Return one pull request by number."""
_owner = owner or self.owner
_repo = repo or self.repo
return await self._request("GET", f"repos/{_owner}/{_repo}/pulls/{pr_number}")
def get_pull_request_sync(self, pr_number: int, owner: str | None = None, repo: str | None = None) -> dict:
"""Synchronously return one pull request by number."""
_owner = owner or self.owner
_repo = repo or self.repo
return self._request_sync("GET", f"repos/{_owner}/{_repo}/pulls/{pr_number}")
async def push_commit( async def push_commit(
self, self,
branch: str, branch: str,

View File

@@ -1,6 +1,8 @@
"""n8n setup agent for automatic webhook configuration.""" """n8n setup agent for automatic webhook configuration."""
import json import json
from urllib import error as urllib_error
from urllib import request as urllib_request
from typing import Optional from typing import Optional
try: try:
@@ -46,6 +48,113 @@ class N8NSetupAgent:
headers["X-N8N-API-KEY"] = self.webhook_token headers["X-N8N-API-KEY"] = self.webhook_token
return headers return headers
def _extract_message(self, payload: object) -> str:
"""Extract a useful message from an n8n response payload."""
if isinstance(payload, dict):
for key in ("message", "error", "reason", "hint", "text"):
value = payload.get(key)
if value:
return str(value)
if payload:
return json.dumps(payload)
if payload is None:
return "No response body"
return str(payload)
def _normalize_success(self, method: str, url: str, status_code: int, payload: object) -> dict:
"""Normalize a successful n8n API response."""
if isinstance(payload, dict):
response = dict(payload)
response.setdefault("status_code", status_code)
response.setdefault("url", url)
response.setdefault("method", method)
return response
return {"data": payload, "status_code": status_code, "url": url, "method": method}
def _normalize_error(self, method: str, url: str, status_code: int | None, payload: object) -> dict:
"""Normalize an error response with enough detail for diagnostics."""
message = self._extract_message(payload)
prefix = f"{method} {url}"
if status_code is not None:
return {
"error": f"{prefix} returned {status_code}: {message}",
"message": message,
"status_code": status_code,
"url": url,
"method": method,
"payload": payload,
}
return {
"error": f"{prefix} failed: {message}",
"message": message,
"status_code": None,
"url": url,
"method": method,
"payload": payload,
}
def _health_check_row(self, name: str, result: dict) -> dict:
"""Convert a raw request result into a UI/API-friendly health check row."""
return {
"name": name,
"ok": not bool(result.get("error")),
"url": result.get("url"),
"method": result.get("method", "GET"),
"status_code": result.get("status_code"),
"message": result.get("message") or ("ok" if not result.get("error") else result.get("error")),
}
def _health_suggestion(self, checks: list[dict]) -> str | None:
"""Return a suggestion based on failed n8n health checks."""
status_codes = {check.get("status_code") for check in checks if check.get("status_code") is not None}
if status_codes and status_codes.issubset({404}):
return "Verify N8N_API_URL points to the base n8n URL, for example http://host:5678, not /api/v1 or a webhook URL."
if status_codes & {401, 403}:
return "Check the configured n8n API key or authentication method."
return "Verify the n8n URL, API key, and that the n8n API is reachable from this container."
def _build_health_result(self, healthz_result: dict, workflows_result: dict) -> dict:
"""Build a consolidated health result from the performed checks."""
checks = [
self._health_check_row("healthz", healthz_result),
self._health_check_row("workflows", workflows_result),
]
if not healthz_result.get("error"):
return {
"status": "ok",
"message": "n8n is reachable via /healthz.",
"api_url": self.api_url,
"auth_configured": bool(self.webhook_token),
"checked_via": "healthz",
"checks": checks,
}
if not workflows_result.get("error"):
workflows = workflows_result.get("data")
workflow_count = len(workflows) if isinstance(workflows, list) else None
return {
"status": "ok",
"message": "n8n is reachable via the workflows API, but /healthz is unavailable.",
"api_url": self.api_url,
"auth_configured": bool(self.webhook_token),
"checked_via": "workflows",
"workflow_count": workflow_count,
"checks": checks,
}
suggestion = self._health_suggestion(checks)
return {
"status": "error",
"error": "n8n health checks failed",
"message": "n8n health checks failed.",
"api_url": self.api_url,
"auth_configured": bool(self.webhook_token),
"checked_via": "none",
"checks": checks,
"suggestion": suggestion,
}
async def _request(self, method: str, path: str, **kwargs) -> dict: async def _request(self, method: str, path: str, **kwargs) -> dict:
"""Send a request to n8n and normalize the response.""" """Send a request to n8n and normalize the response."""
import aiohttp import aiohttp
@@ -62,15 +171,42 @@ class N8NSetupAgent:
payload = {"text": await resp.text()} payload = {"text": await resp.text()}
if 200 <= resp.status < 300: if 200 <= resp.status < 300:
if isinstance(payload, dict): return self._normalize_success(method, url, resp.status, payload)
payload.setdefault("status_code", resp.status)
return payload
return {"data": payload, "status_code": resp.status}
message = payload.get("message") if isinstance(payload, dict) else str(payload) return self._normalize_error(method, url, resp.status, payload)
return {"error": f"Status {resp.status}: {message}", "status_code": resp.status, "payload": payload}
except Exception as e: except Exception as e:
return {"error": str(e)} return self._normalize_error(method, url, None, {"message": str(e)})
def _request_sync(self, method: str, path: str, **kwargs) -> dict:
"""Send a synchronous request to n8n for dashboard health snapshots."""
headers = kwargs.pop("headers", None) or self.get_auth_headers()
payload = kwargs.pop("json", None)
timeout = kwargs.pop("timeout", 5)
url = self._api_path(path)
data = None
if payload is not None:
data = json.dumps(payload).encode("utf-8")
req = urllib_request.Request(url, data=data, headers=headers, method=method)
try:
with urllib_request.urlopen(req, timeout=timeout) as resp:
raw_body = resp.read().decode("utf-8")
content_type = resp.headers.get("Content-Type", "")
if "application/json" in content_type and raw_body:
parsed = json.loads(raw_body)
elif raw_body:
parsed = {"text": raw_body}
else:
parsed = {}
return self._normalize_success(method, url, resp.status, parsed)
except urllib_error.HTTPError as exc:
raw_body = exc.read().decode("utf-8") if exc.fp else ""
try:
parsed = json.loads(raw_body) if raw_body else {}
except json.JSONDecodeError:
parsed = {"text": raw_body} if raw_body else {}
return self._normalize_error(method, url, exc.code, parsed)
except Exception as exc:
return self._normalize_error(method, url, None, {"message": str(exc)})
async def get_workflow(self, workflow_name: str) -> Optional[dict]: async def get_workflow(self, workflow_name: str) -> Optional[dict]:
"""Get a workflow by name.""" """Get a workflow by name."""
@@ -84,11 +220,31 @@ class N8NSetupAgent:
async def create_workflow(self, workflow_json: dict) -> dict: async def create_workflow(self, workflow_json: dict) -> dict:
"""Create or update a workflow.""" """Create or update a workflow."""
return await self._request("POST", "workflows", json=workflow_json) return await self._request("POST", "workflows", json=self._workflow_payload(workflow_json))
def _workflow_payload(self, workflow_json: dict) -> dict:
"""Return a workflow payload without server-managed read-only fields."""
payload = dict(workflow_json)
payload.pop("active", None)
payload.pop("id", None)
payload.pop("createdAt", None)
payload.pop("updatedAt", None)
payload.pop("versionId", None)
return payload
async def _update_workflow_via_put(self, workflow_id: str, workflow_json: dict) -> dict:
"""Fallback update path for n8n instances that only support PUT."""
return await self._request("PUT", f"workflows/{workflow_id}", json=self._workflow_payload(workflow_json))
async def update_workflow(self, workflow_id: str, workflow_json: dict) -> dict: async def update_workflow(self, workflow_id: str, workflow_json: dict) -> dict:
"""Update an existing workflow.""" """Update an existing workflow."""
return await self._request("PATCH", f"workflows/{workflow_id}", json=workflow_json) result = await self._request("PATCH", f"workflows/{workflow_id}", json=self._workflow_payload(workflow_json))
if result.get("status_code") == 405:
fallback = await self._update_workflow_via_put(workflow_id, workflow_json)
if not fallback.get("error") and isinstance(fallback, dict):
fallback.setdefault("method", "PUT")
return fallback
return result
async def enable_workflow(self, workflow_id: str) -> dict: async def enable_workflow(self, workflow_id: str) -> dict:
"""Enable a workflow.""" """Enable a workflow."""
@@ -96,6 +252,11 @@ class N8NSetupAgent:
if result.get("error"): if result.get("error"):
fallback = await self._request("PATCH", f"workflows/{workflow_id}", json={"active": True}) fallback = await self._request("PATCH", f"workflows/{workflow_id}", json={"active": True})
if fallback.get("error"): if fallback.get("error"):
if fallback.get("status_code") == 405:
put_fallback = await self._request("PUT", f"workflows/{workflow_id}", json={"active": True})
if put_fallback.get("error"):
return put_fallback
return {"success": True, "id": workflow_id, "method": "put"}
return fallback return fallback
return {"success": True, "id": workflow_id, "method": "patch"} return {"success": True, "id": workflow_id, "method": "patch"}
return {"success": True, "id": workflow_id, "method": "activate"} return {"success": True, "id": workflow_id, "method": "activate"}
@@ -114,12 +275,12 @@ class N8NSetupAgent:
return value return value
return [] return []
def build_telegram_workflow(self, webhook_path: str, backend_url: str) -> dict: def build_telegram_workflow(self, webhook_path: str, backend_url: str, allowed_chat_id: str | None = None) -> dict:
"""Build the Telegram-to-backend workflow definition.""" """Build the Telegram-to-backend workflow definition."""
normalized_path = webhook_path.strip().strip("/") or "telegram" normalized_path = webhook_path.strip().strip("/") or "telegram"
allowed_chat = json.dumps(str(allowed_chat_id)) if allowed_chat_id else "''"
return { return {
"name": "Telegram to AI Software Factory", "name": "Telegram to AI Software Factory",
"active": False,
"settings": {"executionOrder": "v1"}, "settings": {"executionOrder": "v1"},
"nodes": [ "nodes": [
{ {
@@ -137,13 +298,13 @@ class N8NSetupAgent:
}, },
{ {
"id": "parse-node", "id": "parse-node",
"name": "Prepare Software Request", "name": "Prepare Freeform Request",
"type": "n8n-nodes-base.code", "type": "n8n-nodes-base.code",
"typeVersion": 2, "typeVersion": 2,
"position": [-200, 120], "position": [-200, 120],
"parameters": { "parameters": {
"language": "javaScript", "language": "javaScript",
"jsCode": "const body = $json.body ?? $json;\nconst message = body.message ?? body;\nconst text = String(message.text ?? '').trim();\nconst lines = text.split(/\\r?\\n/);\nconst request = { name: null, description: '', features: [], tech_stack: [] };\nlet nameIndex = -1;\nlet featuresIndex = -1;\nlet techIndex = -1;\nfor (let i = 0; i < lines.length; i += 1) {\n const line = lines[i].trim();\n if (line.toLowerCase().startsWith('name:')) { request.name = line.split(':', 2)[1]?.trim() || null; nameIndex = i; }\n if (line.toLowerCase().startsWith('features:') && featuresIndex === -1) { featuresIndex = i; }\n if (line.toLowerCase().startsWith('tech stack:') && techIndex === -1) { techIndex = i; }\n}\nif (nameIndex >= 0) {\n const descriptionEnd = featuresIndex >= 0 ? featuresIndex : (techIndex >= 0 ? techIndex : lines.length);\n request.description = lines.slice(nameIndex + 1, descriptionEnd).join('\\n').replace(/^description:\\s*/i, '').trim();\n}\nfunction collectList(startIndex, fieldName) {\n if (startIndex < 0) return;\n const firstLine = lines[startIndex].split(':').slice(1).join(':').trim();\n if (firstLine && !firstLine.startsWith('-') && !firstLine.startsWith('*')) {\n request[fieldName].push(...firstLine.split(',').map(item => item.trim()).filter(Boolean));\n }\n for (const rawLine of lines.slice(startIndex + 1)) {\n const line = rawLine.trim();\n if (!line) continue;\n if (/^[A-Za-z ]+:/.test(line)) break;\n if (line.startsWith('-') || line.startsWith('*')) {\n const value = line.slice(1).trim();\n if (value) request[fieldName].push(value);\n }\n }\n}\ncollectList(featuresIndex, 'features');\ncollectList(techIndex, 'tech_stack');\nif (!request.name || request.features.length === 0) { throw new Error('Could not parse software request from Telegram message'); }\nreturn [{ json: { ...request, _source: { raw_text: text, chat_id: message.chat?.id ?? null } } }];", "jsCode": f"const allowedChatId = {allowed_chat};\nconst body = $json.body ?? $json;\nconst message = body.message ?? body;\nconst text = String(message.text ?? '').trim();\nconst chatId = String(message.chat?.id ?? '');\nif (allowedChatId && chatId !== allowedChatId) {{\n return [{{ json: {{ ignored: true, message: `Ignoring message from chat ${{chatId}}`, prompt_text: text, source: 'telegram', chat_id: chatId, chat_type: message.chat?.type ?? null }} }}];\n}}\nreturn [{{ json: {{ prompt_text: text, source: 'telegram', chat_id: chatId, chat_type: message.chat?.type ?? null }} }}];",
}, },
}, },
{ {
@@ -174,8 +335,8 @@ class N8NSetupAgent:
}, },
], ],
"connections": { "connections": {
"Telegram Webhook": {"main": [[{"node": "Prepare Software Request", "type": "main", "index": 0}]]}, "Telegram Webhook": {"main": [[{"node": "Prepare Freeform Request", "type": "main", "index": 0}]]},
"Prepare Software Request": {"main": [[{"node": "AI Software Factory API", "type": "main", "index": 0}]]}, "Prepare Freeform Request": {"main": [[{"node": "AI Software Factory API", "type": "main", "index": 0}]]},
"AI Software Factory API": {"main": [[{"node": "Respond to Telegram Webhook", "type": "main", "index": 0}]]}, "AI Software Factory API": {"main": [[{"node": "Respond to Telegram Webhook", "type": "main", "index": 0}]]},
}, },
} }
@@ -184,11 +345,12 @@ class N8NSetupAgent:
self, self,
backend_url: str, backend_url: str,
credential_name: str, credential_name: str,
allowed_chat_id: str | None = None,
) -> dict: ) -> dict:
"""Build a production Telegram Trigger based workflow.""" """Build a production Telegram Trigger based workflow."""
allowed_chat = json.dumps(str(allowed_chat_id)) if allowed_chat_id else "''"
return { return {
"name": "Telegram to AI Software Factory", "name": "Telegram to AI Software Factory",
"active": False,
"settings": {"executionOrder": "v1"}, "settings": {"executionOrder": "v1"},
"nodes": [ "nodes": [
{ {
@@ -197,18 +359,18 @@ class N8NSetupAgent:
"type": "n8n-nodes-base.telegramTrigger", "type": "n8n-nodes-base.telegramTrigger",
"typeVersion": 1, "typeVersion": 1,
"position": [-520, 120], "position": [-520, 120],
"parameters": {"updates": ["message"]}, "parameters": {"updates": ["message", "channel_post"]},
"credentials": {"telegramApi": {"name": credential_name}}, "credentials": {"telegramApi": {"name": credential_name}},
}, },
{ {
"id": "parse-node", "id": "filter-node",
"name": "Prepare Software Request", "name": "Prepare Freeform Request",
"type": "n8n-nodes-base.code", "type": "n8n-nodes-base.code",
"typeVersion": 2, "typeVersion": 2,
"position": [-180, 120], "position": [-180, 120],
"parameters": { "parameters": {
"language": "javaScript", "language": "javaScript",
"jsCode": "const message = $json.message ?? $json;\nconst text = String(message.text ?? '').trim();\nconst lines = text.split(/\\r?\\n/);\nconst request = { name: null, description: '', features: [], tech_stack: [], _source: { raw_text: text, chat_id: message.chat?.id ?? null } };\nlet nameIndex = -1;\nlet featuresIndex = -1;\nlet techIndex = -1;\nfor (let i = 0; i < lines.length; i += 1) {\n const line = lines[i].trim();\n if (line.toLowerCase().startsWith('name:')) { request.name = line.split(':', 2)[1]?.trim() || null; nameIndex = i; }\n if (line.toLowerCase().startsWith('features:') && featuresIndex === -1) { featuresIndex = i; }\n if (line.toLowerCase().startsWith('tech stack:') && techIndex === -1) { techIndex = i; }\n}\nif (nameIndex >= 0) {\n const descriptionEnd = featuresIndex >= 0 ? featuresIndex : (techIndex >= 0 ? techIndex : lines.length);\n request.description = lines.slice(nameIndex + 1, descriptionEnd).join('\\n').replace(/^description:\\s*/i, '').trim();\n}\nfunction collectList(startIndex, fieldName) {\n if (startIndex < 0) return;\n const firstLine = lines[startIndex].split(':').slice(1).join(':').trim();\n if (firstLine && !firstLine.startsWith('-') && !firstLine.startsWith('*')) {\n request[fieldName].push(...firstLine.split(',').map(item => item.trim()).filter(Boolean));\n }\n for (const rawLine of lines.slice(startIndex + 1)) {\n const line = rawLine.trim();\n if (!line) continue;\n if (/^[A-Za-z ]+:/.test(line)) break;\n if (line.startsWith('-') || line.startsWith('*')) {\n const value = line.slice(1).trim();\n if (value) request[fieldName].push(value);\n }\n }\n}\ncollectList(featuresIndex, 'features');\ncollectList(techIndex, 'tech_stack');\nif (!request.name || request.features.length === 0) { throw new Error('Could not parse software request from Telegram message'); }\nreturn [{ json: request }];", "jsCode": f"const allowedChatId = {allowed_chat};\nconst message = $json.message ?? $json.channel_post ?? $json;\nconst text = String(message.text ?? '').trim();\nconst chatId = String(message.chat?.id ?? '');\nif (!text) return [];\nif (allowedChatId && chatId !== allowedChatId) return [];\nreturn [{{ json: {{ prompt_text: text, source: 'telegram', chat_id: chatId, chat_type: message.chat?.type ?? null }} }}];",
}, },
}, },
{ {
@@ -235,15 +397,15 @@ class N8NSetupAgent:
"parameters": { "parameters": {
"resource": "message", "resource": "message",
"operation": "sendMessage", "operation": "sendMessage",
"chatId": "={{ $('Telegram Trigger').item.json.message.chat.id }}", "chatId": "={{ ($('Telegram Trigger').item.json.message ?? $('Telegram Trigger').item.json.channel_post).chat.id }}",
"text": "={{ $json.data ? `Generated ${$json.data.name} (${($json.data.changed_files || []).length} files)` : ($json.message || 'Software generation request accepted') }}", "text": "={{ $json.summary_message || $json.data?.summary_message || $json.message || 'Software generation request accepted' }}",
}, },
"credentials": {"telegramApi": {"name": credential_name}}, "credentials": {"telegramApi": {"name": credential_name}},
}, },
], ],
"connections": { "connections": {
"Telegram Trigger": {"main": [[{"node": "Prepare Software Request", "type": "main", "index": 0}]]}, "Telegram Trigger": {"main": [[{"node": "Prepare Freeform Request", "type": "main", "index": 0}]]},
"Prepare Software Request": {"main": [[{"node": "AI Software Factory API", "type": "main", "index": 0}]]}, "Prepare Freeform Request": {"main": [[{"node": "AI Software Factory API", "type": "main", "index": 0}]]},
"AI Software Factory API": {"main": [[{"node": "Send Telegram Update", "type": "main", "index": 0}]]}, "AI Software Factory API": {"main": [[{"node": "Send Telegram Update", "type": "main", "index": 0}]]},
}, },
} }
@@ -297,19 +459,21 @@ class N8NSetupAgent:
""" """
return await self.setup( return await self.setup(
webhook_path=webhook_path, webhook_path=webhook_path,
backend_url=f"{settings.backend_public_url}/generate", backend_url=f"{settings.backend_public_url}/generate/text",
force_update=False, force_update=False,
) )
async def health_check(self) -> dict: async def health_check(self) -> dict:
"""Check n8n API health.""" """Check n8n API health."""
result = await self._request("GET", f"{self.api_url}/healthz") result = await self._request("GET", f"{self.api_url}/healthz")
if result.get("error"):
fallback = await self._request("GET", "workflows") fallback = await self._request("GET", "workflows")
if fallback.get("error"): return self._build_health_result(result, fallback)
return fallback
return {"status": "ok", "checked_via": "workflows"} def health_check_sync(self) -> dict:
return {"status": "ok", "checked_via": "healthz"} """Synchronously check n8n API health for UI rendering."""
result = self._request_sync("GET", f"{self.api_url}/healthz")
fallback = self._request_sync("GET", "workflows")
return self._build_health_result(result, fallback)
async def setup( async def setup(
self, self,
@@ -324,9 +488,15 @@ class N8NSetupAgent:
# First, verify n8n is accessible # First, verify n8n is accessible
health = await self.health_check() health = await self.health_check()
if health.get("error"): if health.get("error"):
return {"status": "error", "message": health.get("error")} return {
"status": "error",
"message": health.get("message") or health.get("error"),
"health": health,
"checks": health.get("checks", []),
"suggestion": health.get("suggestion"),
}
effective_backend_url = backend_url or f"{settings.backend_public_url}/generate" effective_backend_url = backend_url or f"{settings.backend_public_url}/generate/text"
effective_bot_token = telegram_bot_token or settings.telegram_bot_token effective_bot_token = telegram_bot_token or settings.telegram_bot_token
effective_credential_name = telegram_credential_name or settings.n8n_telegram_credential_name effective_credential_name = telegram_credential_name or settings.n8n_telegram_credential_name
trigger_mode = use_telegram_trigger if use_telegram_trigger is not None else bool(effective_bot_token) trigger_mode = use_telegram_trigger if use_telegram_trigger is not None else bool(effective_bot_token)
@@ -334,20 +504,22 @@ class N8NSetupAgent:
if trigger_mode: if trigger_mode:
credential = await self.ensure_telegram_credential(effective_bot_token, effective_credential_name) credential = await self.ensure_telegram_credential(effective_bot_token, effective_credential_name)
if credential.get("error"): if credential.get("error"):
return {"status": "error", "message": credential["error"]} return {"status": "error", "message": credential["error"], "details": credential}
workflow = self.build_telegram_trigger_workflow( workflow = self.build_telegram_trigger_workflow(
backend_url=effective_backend_url, backend_url=effective_backend_url,
credential_name=effective_credential_name, credential_name=effective_credential_name,
allowed_chat_id=settings.telegram_chat_id,
) )
else: else:
workflow = self.build_telegram_workflow( workflow = self.build_telegram_workflow(
webhook_path=webhook_path, webhook_path=webhook_path,
backend_url=effective_backend_url, backend_url=effective_backend_url,
allowed_chat_id=settings.telegram_chat_id,
) )
existing = await self.get_workflow(workflow["name"]) existing = await self.get_workflow(workflow["name"])
if isinstance(existing, dict) and existing.get("error"): if isinstance(existing, dict) and existing.get("error"):
return {"status": "error", "message": existing["error"]} return {"status": "error", "message": existing["error"], "details": existing}
workflow_id = None workflow_id = None
if existing and existing.get("id"): if existing and existing.get("id"):
@@ -361,12 +533,12 @@ class N8NSetupAgent:
workflow_id = str(result.get("id", "")) if isinstance(result, dict) else None workflow_id = str(result.get("id", "")) if isinstance(result, dict) else None
if isinstance(result, dict) and result.get("error"): if isinstance(result, dict) and result.get("error"):
return {"status": "error", "message": result["error"]} return {"status": "error", "message": result["error"], "details": result}
workflow_id = workflow_id or str(result.get("id", "")) workflow_id = workflow_id or str(result.get("id", ""))
enable_result = await self.enable_workflow(workflow_id) enable_result = await self.enable_workflow(workflow_id)
if enable_result.get("error"): if enable_result.get("error"):
return {"status": "error", "message": enable_result["error"], "workflow": result} return {"status": "error", "message": enable_result["error"], "workflow": result, "details": enable_result}
return { return {
"status": "success", "status": "success",

View File

@@ -2,7 +2,10 @@
from __future__ import annotations from __future__ import annotations
import difflib
import py_compile import py_compile
import re
import subprocess
from typing import Optional from typing import Optional
from datetime import datetime from datetime import datetime
@@ -33,6 +36,10 @@ class AgentOrchestrator:
db=None, db=None,
prompt_text: str | None = None, prompt_text: str | None = None,
prompt_actor: str = "api", prompt_actor: str = "api",
existing_history=None,
prompt_source_context: dict | None = None,
prompt_routing: dict | None = None,
related_issue_hint: dict | None = None,
): ):
"""Initialize orchestrator.""" """Initialize orchestrator."""
self.project_id = project_id self.project_id = project_id
@@ -49,6 +56,9 @@ class AgentOrchestrator:
self.db = db self.db = db
self.prompt_text = prompt_text self.prompt_text = prompt_text
self.prompt_actor = prompt_actor self.prompt_actor = prompt_actor
self.prompt_source_context = prompt_source_context or {}
self.prompt_routing = prompt_routing or {}
self.existing_history = existing_history
self.changed_files: list[str] = [] self.changed_files: list[str] = []
self.gitea_api = GiteaAPI( self.gitea_api = GiteaAPI(
token=settings.GITEA_TOKEN, token=settings.GITEA_TOKEN,
@@ -60,10 +70,16 @@ class AgentOrchestrator:
self.prompt_audit = None self.prompt_audit = None
self.repo_name = settings.gitea_repo or self.gitea_api.build_project_repo_name(project_id, project_name) self.repo_name = settings.gitea_repo or self.gitea_api.build_project_repo_name(project_id, project_name)
self.repo_owner = settings.gitea_owner self.repo_owner = settings.gitea_owner
self.repo_url = self._build_repo_url(self.repo_owner, self.repo_name) self.repo_url = None
self.branch_name = self._build_pr_branch_name(project_id)
self.active_pull_request = None
self._gitea_username: str | None = None
hinted_issue_number = (related_issue_hint or {}).get('number') if related_issue_hint else None
self.related_issue_number = hinted_issue_number if hinted_issue_number is not None else self._extract_issue_number(prompt_text)
self.related_issue: dict | None = DatabaseManager._normalize_issue(related_issue_hint)
# Initialize agents # Initialize agents
self.git_manager = GitManager(project_id) self.git_manager = GitManager(project_id, project_dir=str(self.project_root))
self.ui_manager = UIManager(project_id) self.ui_manager = UIManager(project_id)
# Initialize database manager if db session provided # Initialize database manager if db session provided
@@ -71,22 +87,37 @@ class AgentOrchestrator:
self.history = None self.history = None
if db: if db:
self.db_manager = DatabaseManager(db) self.db_manager = DatabaseManager(db)
# Log project start to database if existing_history is not None:
self.history = existing_history
self.project_id = existing_history.project_id
self.project_name = existing_history.project_name or project_name
self.description = existing_history.description or description
else:
self.history = self.db_manager.log_project_start( self.history = self.db_manager.log_project_start(
project_id=project_id, project_id=project_id,
project_name=project_name, project_name=project_name,
description=description description=description
) )
# Re-fetch with new history_id
self.db_manager = DatabaseManager(db) self.db_manager = DatabaseManager(db)
self.active_pull_request = self.db_manager.get_open_pull_request(project_id=self.project_id)
if existing_history is not None and self.history is not None:
latest_ui = self.db_manager._get_latest_ui_snapshot_data(self.history.id)
repository = latest_ui.get('repository') if isinstance(latest_ui, dict) else None
if isinstance(repository, dict) and repository:
self.repo_owner = repository.get('owner') or self.repo_owner
self.repo_name = repository.get('name') or self.repo_name
self.repo_url = repository.get('url') or self.repo_url
if self.prompt_text: if self.prompt_text:
self.prompt_audit = self.db_manager.log_prompt_submission( self.prompt_audit = self.db_manager.log_prompt_submission(
history_id=self.history.id, history_id=self.history.id,
project_id=project_id, project_id=self.project_id,
prompt_text=self.prompt_text, prompt_text=self.prompt_text,
features=self.features, features=self.features,
tech_stack=self.tech_stack, tech_stack=self.tech_stack,
actor_name=self.prompt_actor, actor_name=self.prompt_actor,
related_issue={'number': self.related_issue_number} if self.related_issue_number is not None else None,
source_context=self.prompt_source_context,
routing=self.prompt_routing,
) )
self.ui_manager.ui_data["project_root"] = str(self.project_root) self.ui_manager.ui_data["project_root"] = str(self.project_root)
@@ -95,19 +126,108 @@ class AgentOrchestrator:
self.ui_manager.ui_data["repository"] = { self.ui_manager.ui_data["repository"] = {
"owner": self.repo_owner, "owner": self.repo_owner,
"name": self.repo_name, "name": self.repo_name,
"url": self.repo_url,
"mode": "project" if settings.use_project_repositories else "shared", "mode": "project" if settings.use_project_repositories else "shared",
"status": "pending" if settings.use_project_repositories else "shared",
"provider": "gitea",
} }
if self.related_issue:
self.ui_manager.ui_data["related_issue"] = self.related_issue
if self.active_pull_request:
self.ui_manager.ui_data["pull_request"] = self.active_pull_request
def _build_pr_branch_name(self, project_id: str) -> str:
"""Build a stable branch name used until the PR is merged."""
return f"ai/{project_id}"
def _extract_issue_number(self, prompt_text: str | None) -> int | None:
"""Extract an issue reference from prompt text."""
if not prompt_text:
return None
match = re.search(r'(?:#|issue\s+)(\d+)', prompt_text, flags=re.IGNORECASE)
return int(match.group(1)) if match else None
def _build_repo_url(self, owner: str | None, repo: str | None) -> str | None: def _build_repo_url(self, owner: str | None, repo: str | None) -> str | None:
if not owner or not repo or not settings.gitea_url: if not owner or not repo or not settings.gitea_url:
return None return None
return f"{settings.gitea_url.rstrip('/')}/{owner}/{repo}" return f"{settings.gitea_url.rstrip('/')}/{owner}/{repo}"
def _log_generation_plan_trace(self) -> None:
"""Persist the current generation plan as an inspectable trace."""
if not self.db_manager or not self.history or not self.prompt_audit:
return
planned_files = list(self._template_files().keys())
self.db_manager.log_llm_trace(
project_id=self.project_id,
history_id=self.history.id,
prompt_id=self.prompt_audit.id,
stage='generation_plan',
provider='factory-planner',
model='template-generator',
system_prompt='Plan the generated project structure from the structured request and repository state.',
user_prompt=self.prompt_text or self.description,
assistant_response=(
f"Planned files: {', '.join(planned_files)}. "
f"Target branch: {self.branch_name}. "
f"Repository mode: {self.ui_manager.ui_data.get('repository', {}).get('mode', 'unknown')}."
+ (
f" Linked issue: #{self.related_issue.get('number')} {self.related_issue.get('title')}."
if self.related_issue else ''
)
),
raw_response={
'planned_files': planned_files,
'features': list(self.features),
'tech_stack': list(self.tech_stack),
'branch': self.branch_name,
'repository': self.ui_manager.ui_data.get('repository', {}),
'related_issue': self.related_issue,
},
fallback_used=False,
)
async def _sync_issue_context(self) -> None:
"""Sync repository issues and resolve a linked issue from the prompt when present."""
if not self.db_manager or not self.history:
return
repository = self.ui_manager.ui_data.get('repository') or {}
owner = repository.get('owner') or self.repo_owner
repo_name = repository.get('name') or self.repo_name
if not owner or not repo_name or not settings.gitea_url or not settings.gitea_token:
return
issues_result = self.db_manager.sync_repository_issues(project_id=self.project_id, gitea_api=self.gitea_api, state='open')
self.ui_manager.ui_data['issues'] = issues_result.get('issues', []) if issues_result.get('status') == 'success' else []
if self.related_issue_number is None:
return
issue_payload = await self.gitea_api.get_issue(issue_number=self.related_issue_number, owner=owner, repo=repo_name)
if isinstance(issue_payload, dict) and issue_payload.get('error'):
return
if issue_payload.get('pull_request'):
return
self.related_issue = DatabaseManager._normalize_issue(issue_payload)
self.ui_manager.ui_data['related_issue'] = self.related_issue
if self.prompt_audit:
self.db_manager.attach_issue_to_prompt(self.prompt_audit.id, self.related_issue)
async def _ensure_remote_repository(self) -> None: async def _ensure_remote_repository(self) -> None:
if not settings.use_project_repositories: if not settings.use_project_repositories:
self.ui_manager.ui_data["repository"]["status"] = "shared"
if settings.gitea_repo:
predicted_url = self._build_repo_url(self.repo_owner, self.repo_name)
if predicted_url:
self.repo_url = predicted_url
self.ui_manager.ui_data["repository"]["url"] = predicted_url
self.ui_manager.ui_data["repository"]["api_response"] = {
"status": "shared",
"detail": "Using the configured shared repository instead of provisioning a per-project repo.",
}
return return
if not self.repo_owner or not settings.gitea_token or not settings.gitea_url: if not self.repo_owner or not settings.gitea_token or not settings.gitea_url:
self.ui_manager.ui_data["repository"]["status"] = "skipped"
self.ui_manager.ui_data["repository"]["reason"] = "Missing Gitea owner, URL, or token configuration"
self.ui_manager.ui_data["repository"]["api_response"] = {
"status": "skipped",
"detail": "Missing Gitea owner, URL, or token configuration",
}
return return
repo_name = self.repo_name repo_name = self.repo_name
@@ -115,6 +235,7 @@ class AgentOrchestrator:
repo_name=repo_name, repo_name=repo_name,
owner=self.repo_owner, owner=self.repo_owner,
description=f"AI-generated project for {self.project_name}", description=f"AI-generated project for {self.project_name}",
auto_init=False,
) )
if result.get("status") == "exists" and repo_name == self.gitea_api.build_project_repo_name(self.project_id, self.project_name): if result.get("status") == "exists" and repo_name == self.gitea_api.build_project_repo_name(self.project_id, self.project_name):
repo_name = f"{repo_name}-{self.project_id.split('-')[-1]}" repo_name = f"{repo_name}-{self.project_id.split('-')[-1]}"
@@ -122,6 +243,7 @@ class AgentOrchestrator:
repo_name=repo_name, repo_name=repo_name,
owner=self.repo_owner, owner=self.repo_owner,
description=f"AI-generated project for {self.project_name}", description=f"AI-generated project for {self.project_name}",
auto_init=False,
) )
self.repo_name = repo_name self.repo_name = repo_name
self.ui_manager.ui_data["repository"]["name"] = repo_name self.ui_manager.ui_data["repository"]["name"] = repo_name
@@ -135,10 +257,172 @@ class AgentOrchestrator:
else f"Prepared repository {self.repo_owner}/{self.repo_name}" else f"Prepared repository {self.repo_owner}/{self.repo_name}"
), ),
) )
self.ui_manager.ui_data["repository"]["status"] = result.get("status", "error" if result.get("error") else "ready") repo_status = result.get("status", "error" if result.get("error") else "ready")
if result.get("html_url"): self.ui_manager.ui_data["repository"]["status"] = repo_status
self.ui_manager.ui_data["repository"]["api_response"] = {
key: value
for key, value in result.items()
if key not in {"private"}
}
if result.get("status_code") is not None:
self.ui_manager.ui_data["repository"]["api_status_code"] = result.get("status_code")
if result.get("error"):
self.ui_manager.ui_data["repository"]["reason"] = result.get("error")
self.ui_manager.ui_data["repository"].pop("url", None)
elif result.get("html_url"):
self.repo_url = result["html_url"] self.repo_url = result["html_url"]
self.ui_manager.ui_data["repository"]["url"] = self.repo_url self.ui_manager.ui_data["repository"]["url"] = self.repo_url
clone_url = result.get("clone_url") or self.gitea_api.build_repo_git_url(self.repo_owner, self.repo_name)
if clone_url:
self.ui_manager.ui_data["repository"]["clone_url"] = clone_url
self.ui_manager.ui_data["repository"].pop("reason", None)
elif repo_status == "exists":
predicted_url = self._build_repo_url(self.repo_owner, self.repo_name)
if predicted_url:
self.repo_url = predicted_url
self.ui_manager.ui_data["repository"]["url"] = predicted_url
clone_url = result.get("clone_url") or self.gitea_api.build_repo_git_url(self.repo_owner, self.repo_name)
if clone_url:
self.ui_manager.ui_data["repository"]["clone_url"] = clone_url
else:
self.ui_manager.ui_data["repository"].pop("url", None)
async def _resolve_gitea_username(self) -> str:
"""Resolve and cache the Gitea login used for authenticated git operations."""
if self._gitea_username:
return self._gitea_username
user_info = await self.gitea_api.get_current_user()
if user_info.get('error') or not user_info.get('login'):
raise RuntimeError(f"Unable to resolve Gitea user for push: {user_info.get('error', 'missing login')}")
self._gitea_username = user_info['login']
return self._gitea_username
async def _push_branch(self, branch: str) -> dict | None:
"""Push a branch to the configured project repository when available."""
repository = self.ui_manager.ui_data.get('repository') or {}
if repository.get('mode') != 'project':
return None
if repository.get('status') not in {'created', 'exists', 'ready'}:
return None
if not settings.gitea_token or not self.repo_owner or not self.repo_name:
return None
clone_url = repository.get('clone_url') or self.gitea_api.build_repo_git_url(self.repo_owner, self.repo_name)
if not clone_url:
return None
username = await self._resolve_gitea_username()
self.git_manager.push_with_credentials(
remote_url=clone_url,
username=username,
password=settings.gitea_token,
remote='origin',
branch=branch,
)
return {'status': 'pushed', 'remote': clone_url, 'branch': branch}
async def _prepare_git_workspace(self) -> None:
"""Initialize the local repo and ensure the PR branch exists before writing files."""
if not self.git_manager.has_repo():
self.git_manager.init_repo()
if not self.git_manager.current_head_or_none():
self.git_manager.create_empty_commit('Initialize project repository')
try:
await self._push_branch('main')
except (RuntimeError, subprocess.CalledProcessError, FileNotFoundError) as exc:
self.ui_manager.ui_data.setdefault('git', {})['remote_error'] = str(exc)
self._append_log(f'Initial main push skipped: {exc}')
if self.git_manager.branch_exists(self.branch_name):
self.git_manager.checkout_branch(self.branch_name)
else:
self.git_manager.checkout_branch(self.branch_name, create=True, start_point='main')
self.ui_manager.ui_data.setdefault('git', {})['active_branch'] = self.branch_name
async def _ensure_pull_request(self) -> dict | None:
"""Create the project pull request on first delivery and reuse it later."""
if self.active_pull_request:
self.ui_manager.ui_data['pull_request'] = self.active_pull_request
return self.active_pull_request
repository = self.ui_manager.ui_data.get('repository') or {}
if repository.get('mode') != 'project' or repository.get('status') not in {'created', 'exists', 'ready'}:
return None
title = f"AI delivery for {self.project_name}"
body = (
f"Automated software factory changes for {self.project_name}.\n\n"
f"Prompt: {self.prompt_text or self.description}\n\n"
f"Branch: {self.branch_name}"
)
result = await self.gitea_api.create_pull_request(
title=title,
body=body,
owner=self.repo_owner,
repo=self.repo_name,
base='main',
head=self.branch_name,
)
if result.get('error'):
raise RuntimeError(f"Unable to create pull request: {result.get('error')}")
pr_number = result.get('number') or result.get('id') or 0
pr_data = {
'pr_number': pr_number,
'title': result.get('title', title),
'body': result.get('body', body),
'state': result.get('state', 'open'),
'base': result.get('base', {}).get('ref', 'main') if isinstance(result.get('base'), dict) else 'main',
'user': result.get('user', {}).get('login', 'system') if isinstance(result.get('user'), dict) else 'system',
'pr_url': result.get('html_url') or self.gitea_api.build_pull_request_url(pr_number, self.repo_owner, self.repo_name),
'merged': bool(result.get('merged')),
'pr_state': result.get('state', 'open'),
}
if self.db_manager and self.history:
self.db_manager.save_pr_data(self.history.id, pr_data)
self.active_pull_request = self.db_manager.get_open_pull_request(project_id=self.project_id) if self.db_manager else pr_data
self.ui_manager.ui_data['pull_request'] = self.active_pull_request or pr_data
return self.active_pull_request or pr_data
async def _push_remote_commit(self, commit_hash: str, commit_message: str, changed_files: list[str], base_commit: str | None) -> dict | None:
"""Push the local commit to the provisioned Gitea repository and build browser links."""
repository = self.ui_manager.ui_data.get("repository") or {}
if repository.get("mode") != "project":
return None
if repository.get("status") not in {"created", "exists", "ready"}:
return None
push_result = await self._push_branch(self.branch_name)
if push_result is None:
return None
pull_request = await self._ensure_pull_request()
commit_url = self.gitea_api.build_commit_url(commit_hash, owner=self.repo_owner, repo=self.repo_name)
compare_url = self.gitea_api.build_compare_url(base_commit, commit_hash, owner=self.repo_owner, repo=self.repo_name) if base_commit else None
remote_record = {
"status": "pushed",
"remote": push_result.get('remote'),
"branch": self.branch_name,
"commit_url": commit_url,
"compare_url": compare_url,
"changed_files": changed_files,
"pull_request": pull_request,
}
self.ui_manager.ui_data.setdefault("git", {})["remote_push"] = remote_record
repository["last_commit_url"] = commit_url
if compare_url:
repository["last_compare_url"] = compare_url
self._append_log(f"Pushed generated commit to {self.repo_owner}/{self.repo_name}.")
return remote_record
def _build_diff_text(self, relative_path: str, previous_content: str, new_content: str) -> str:
"""Build a unified diff for display in the dashboard."""
previous_lines = previous_content.splitlines(keepends=True)
new_lines = new_content.splitlines(keepends=True)
diff = difflib.unified_diff(
previous_lines,
new_lines,
fromfile=f"a/{relative_path}",
tofile=f"b/{relative_path}",
)
return "".join(diff)
def _append_log(self, message: str) -> None: def _append_log(self, message: str) -> None:
timestamped = f"[{datetime.utcnow().isoformat()}] {message}" timestamped = f"[{datetime.utcnow().isoformat()}] {message}"
@@ -163,6 +447,8 @@ class AgentOrchestrator:
target = self.project_root / relative_path target = self.project_root / relative_path
target.parent.mkdir(parents=True, exist_ok=True) target.parent.mkdir(parents=True, exist_ok=True)
change_type = "UPDATE" if target.exists() else "CREATE" change_type = "UPDATE" if target.exists() else "CREATE"
previous_content = target.read_text(encoding="utf-8") if target.exists() else ""
diff_text = self._build_diff_text(relative_path, previous_content, content)
target.write_text(content, encoding="utf-8") target.write_text(content, encoding="utf-8")
self.changed_files.append(relative_path) self.changed_files.append(relative_path)
if self.db_manager and self.history: if self.db_manager and self.history:
@@ -176,6 +462,7 @@ class AgentOrchestrator:
history_id=self.history.id, history_id=self.history.id,
prompt_id=self.prompt_audit.id if self.prompt_audit else None, prompt_id=self.prompt_audit.id if self.prompt_audit else None,
diff_summary=f"Wrote {len(content.splitlines())} lines to {relative_path}", diff_summary=f"Wrote {len(content.splitlines())} lines to {relative_path}",
diff_text=diff_text,
) )
def _template_files(self) -> dict[str, str]: def _template_files(self) -> dict[str, str]:
@@ -215,6 +502,9 @@ class AgentOrchestrator:
self._append_log("Initializing project.") self._append_log("Initializing project.")
await self._ensure_remote_repository() await self._ensure_remote_repository()
await self._sync_issue_context()
await self._prepare_git_workspace()
self._log_generation_plan_trace()
# Step 2: Create project structure (skip git operations) # Step 2: Create project structure (skip git operations)
self._update_progress(20, "project-structure", "Creating project files...") self._update_progress(20, "project-structure", "Creating project files...")
@@ -228,6 +518,10 @@ class AgentOrchestrator:
self._update_progress(80, "validation", "Validating generated code...") self._update_progress(80, "validation", "Validating generated code...")
await self._run_tests() await self._run_tests()
# Step 5: Commit generated artifacts locally for traceability
self._update_progress(90, "git", "Recording generated changes in git...")
await self._commit_to_git()
# Step 7: Complete # Step 7: Complete
self.status = "completed" self.status = "completed"
self._update_progress(100, "completed", "Software generation complete!") self._update_progress(100, "completed", "Software generation complete!")
@@ -253,6 +547,8 @@ class AgentOrchestrator:
"project_root": str(self.project_root), "project_root": str(self.project_root),
"changed_files": list(dict.fromkeys(self.changed_files)), "changed_files": list(dict.fromkeys(self.changed_files)),
"repository": self.ui_manager.ui_data.get("repository"), "repository": self.ui_manager.ui_data.get("repository"),
"related_issue": self.related_issue,
"pull_request": self.ui_manager.ui_data.get("pull_request"),
} }
except Exception as e: except Exception as e:
@@ -279,6 +575,8 @@ class AgentOrchestrator:
"project_root": str(self.project_root), "project_root": str(self.project_root),
"changed_files": list(dict.fromkeys(self.changed_files)), "changed_files": list(dict.fromkeys(self.changed_files)),
"repository": self.ui_manager.ui_data.get("repository"), "repository": self.ui_manager.ui_data.get("repository"),
"related_issue": self.related_issue,
"pull_request": self.ui_manager.ui_data.get("pull_request"),
} }
async def _create_project_structure(self) -> None: async def _create_project_structure(self) -> None:
@@ -305,7 +603,74 @@ class AgentOrchestrator:
async def _commit_to_git(self) -> None: async def _commit_to_git(self) -> None:
"""Commit changes to git.""" """Commit changes to git."""
pass # Skip git operations in test environment unique_files = list(dict.fromkeys(self.changed_files))
if not unique_files:
return
try:
if not self.git_manager.has_repo():
self.git_manager.init_repo()
base_commit = self.git_manager.current_head_or_none()
self.git_manager.add_files(unique_files)
if not self.git_manager.get_status():
return
commit_message = f"AI generation for prompt: {self.project_name}"
commit_hash = self.git_manager.commit(commit_message)
commit_record = {
"hash": commit_hash,
"message": commit_message,
"files": unique_files,
"timestamp": datetime.utcnow().isoformat(),
"scope": "local",
"branch": self.branch_name,
}
remote_record = None
try:
remote_record = await self._push_remote_commit(commit_hash, commit_message, unique_files, base_commit)
except (RuntimeError, subprocess.CalledProcessError, FileNotFoundError) as remote_exc:
self.ui_manager.ui_data.setdefault("git", {})["remote_error"] = str(remote_exc)
self._append_log(f"Remote git push skipped: {remote_exc}")
if remote_record:
commit_record["scope"] = "remote"
commit_record["commit_url"] = remote_record.get("commit_url")
commit_record["compare_url"] = remote_record.get("compare_url")
if remote_record.get('pull_request'):
commit_record['pull_request'] = remote_record['pull_request']
self.ui_manager.ui_data['pull_request'] = remote_record['pull_request']
self.ui_manager.ui_data.setdefault("git", {})["latest_commit"] = commit_record
self.ui_manager.ui_data.setdefault("git", {})["commits"] = [commit_record]
self._append_log(f"Recorded git commit {commit_hash[:12]} for generated files.")
if self.db_manager:
self.db_manager.log_commit(
project_id=self.project_id,
commit_message=commit_message,
actor="orchestrator",
actor_type="agent",
history_id=self.history.id if self.history else None,
prompt_id=self.prompt_audit.id if self.prompt_audit else None,
commit_hash=commit_hash,
changed_files=unique_files,
branch=self.branch_name,
commit_url=remote_record.get("commit_url") if remote_record else None,
compare_url=remote_record.get("compare_url") if remote_record else None,
remote_status=remote_record.get("status") if remote_record else "local-only",
related_issue=self.related_issue,
)
if self.related_issue:
self.db_manager.log_issue_work(
project_id=self.project_id,
history_id=self.history.id if self.history else None,
prompt_id=self.prompt_audit.id if self.prompt_audit else None,
issue=self.related_issue,
actor='orchestrator',
commit_hash=commit_hash,
commit_url=remote_record.get('commit_url') if remote_record else None,
)
except (subprocess.CalledProcessError, FileNotFoundError) as exc:
self.ui_manager.ui_data.setdefault("git", {})["error"] = str(exc)
self._append_log(f"Git commit skipped: {exc}")
async def _create_pr(self) -> None: async def _create_pr(self) -> None:
"""Create pull request.""" """Create pull request."""

View File

@@ -0,0 +1,127 @@
"""Helpers for prompt-level repository workflows such as undoing a prompt."""
from __future__ import annotations
import subprocess
try:
from ..config import settings
from .database_manager import DatabaseManager
from .git_manager import GitManager
from .gitea import GiteaAPI
except ImportError:
from config import settings
from agents.database_manager import DatabaseManager
from agents.git_manager import GitManager
from agents.gitea import GiteaAPI
class PromptWorkflowManager:
"""Coordinate prompt-level repository actions against git and Gitea."""
def __init__(self, db):
self.db_manager = DatabaseManager(db)
self.gitea_api = GiteaAPI(
token=settings.GITEA_TOKEN,
base_url=settings.GITEA_URL,
owner=settings.GITEA_OWNER,
repo=settings.GITEA_REPO or '',
)
async def undo_prompt(self, project_id: str, prompt_id: int) -> dict:
"""Revert the commit associated with a prompt and push the revert to the PR branch."""
history = self.db_manager.get_project_by_id(project_id)
if history is None:
return {'status': 'error', 'message': 'Project not found'}
correlations = self.db_manager.get_prompt_change_correlations(project_id=project_id, limit=500)
correlation = next((item for item in correlations if item.get('prompt_id') == prompt_id), None)
if correlation is None:
return {'status': 'error', 'message': 'Prompt not found for project'}
if correlation.get('revert'):
return {'status': 'ignored', 'message': 'Prompt has already been reverted', 'revert': correlation['revert']}
original_commit = next(
(commit for commit in correlation.get('commits', []) if commit.get('remote_status') != 'reverted' and commit.get('commit_hash')),
None,
)
if original_commit is None:
return {'status': 'error', 'message': 'No reversible commit was recorded for this prompt'}
branch = original_commit.get('branch') or f'ai/{project_id}'
project_root = settings.projects_root / project_id
git_manager = GitManager(project_id, project_dir=str(project_root))
if not git_manager.has_repo():
return {'status': 'error', 'message': 'Local project repository is not available for undo'}
try:
git_manager.checkout_branch(branch)
previous_head = git_manager.current_head_or_none()
revert_commit_hash = git_manager.revert_commit(original_commit['commit_hash'])
except (subprocess.CalledProcessError, FileNotFoundError) as exc:
return {'status': 'error', 'message': f'Unable to revert prompt commit: {exc}'}
repository = self.db_manager.get_project_audit_data(project_id).get('repository') or {}
commit_url = None
compare_url = None
if (
repository.get('mode') == 'project'
and repository.get('status') in {'created', 'exists', 'ready'}
and settings.gitea_token
and repository.get('owner')
and repository.get('name')
):
try:
user_info = await self.gitea_api.get_current_user()
username = user_info.get('login') if isinstance(user_info, dict) else None
if username and not user_info.get('error'):
remote_url = repository.get('clone_url') or self.gitea_api.build_repo_git_url(repository.get('owner'), repository.get('name'))
if remote_url:
git_manager.push_with_credentials(
remote_url=remote_url,
username=username,
password=settings.gitea_token,
branch=branch,
)
commit_url = self.gitea_api.build_commit_url(revert_commit_hash, repository.get('owner'), repository.get('name'))
if previous_head:
compare_url = self.gitea_api.build_compare_url(previous_head, revert_commit_hash, repository.get('owner'), repository.get('name'))
except (RuntimeError, subprocess.CalledProcessError, FileNotFoundError):
pass
self.db_manager.log_commit(
project_id=project_id,
commit_message=f'Revert prompt {prompt_id}',
actor='dashboard',
actor_type='user',
history_id=history.id,
prompt_id=prompt_id,
commit_hash=revert_commit_hash,
changed_files=original_commit.get('changed_files', []),
branch=branch,
commit_url=commit_url,
compare_url=compare_url,
remote_status='reverted',
)
self.db_manager.log_prompt_revert(
project_id=project_id,
prompt_id=prompt_id,
reverted_commit_hash=original_commit['commit_hash'],
revert_commit_hash=revert_commit_hash,
actor='dashboard',
commit_url=commit_url,
)
self.db_manager.log_system_event(
component='git',
level='INFO',
message=f'Reverted prompt {prompt_id} for project {project_id}',
)
return {
'status': 'success',
'project_id': project_id,
'prompt_id': prompt_id,
'reverted_commit_hash': original_commit['commit_hash'],
'revert_commit_hash': revert_commit_hash,
'commit_url': commit_url,
'compare_url': compare_url,
}

View File

@@ -0,0 +1,249 @@
"""Interpret free-form software requests into structured generation input."""
from __future__ import annotations
import json
import re
try:
from ..config import settings
except ImportError:
from config import settings
class RequestInterpreter:
"""Use Ollama to turn free-form text into a structured software request."""
def __init__(self, ollama_url: str | None = None, model: str | None = None):
self.ollama_url = (ollama_url or settings.ollama_url).rstrip('/')
self.model = model or settings.OLLAMA_MODEL
async def interpret(self, prompt_text: str, context: dict | None = None) -> dict:
"""Interpret free-form text into the request shape expected by the orchestrator."""
interpreted, _trace = await self.interpret_with_trace(prompt_text, context=context)
return interpreted
async def interpret_with_trace(self, prompt_text: str, context: dict | None = None) -> tuple[dict, dict]:
"""Interpret free-form text into the request shape expected by the orchestrator."""
normalized = prompt_text.strip()
if not normalized:
raise ValueError('Prompt text cannot be empty')
compact_context = self._build_compact_context(context or {})
system_prompt = (
'You route Telegram software prompts. '
'Decide whether the prompt starts a new project or continues an existing tracked project. '
'When continuing, identify the best matching project_id from the provided context and the issue number if one is mentioned or implied by recent chat history. '
'Return only JSON with keys request and routing. '
'request must contain name, description, features, tech_stack. '
'routing must contain intent, project_id, project_name, issue_number, confidence, and reasoning_summary. '
'Use the provided project catalog and recent chat history. '
'If the user says things like also, continue, work on this, that issue, or follow-up wording, prefer continuation of the most relevant recent project. '
'If the user explicitly asks for a new project, set intent to new_project.'
)
user_prompt = normalized
if compact_context:
user_prompt = (
f"Conversation context:\n{json.dumps(compact_context, indent=2)}\n\n"
f"User prompt:\n{normalized}"
)
try:
import aiohttp
async with aiohttp.ClientSession() as session:
async with session.post(
f'{self.ollama_url}/api/chat',
json={
'model': self.model,
'stream': False,
'format': 'json',
'messages': [
{
'role': 'system',
'content': system_prompt,
},
{'role': 'user', 'content': user_prompt},
],
},
) as resp:
payload = await resp.json()
if 200 <= resp.status < 300:
content = payload.get('message', {}).get('content', '')
if content:
parsed = json.loads(content)
interpreted = self._normalize_interpreted_request(parsed, normalized)
routing = self._normalize_routing(parsed.get('routing'), interpreted, compact_context)
return interpreted, {
'stage': 'request_interpretation',
'provider': 'ollama',
'model': self.model,
'system_prompt': system_prompt,
'user_prompt': user_prompt,
'assistant_response': content,
'raw_response': payload,
'routing': routing,
'context_excerpt': compact_context,
'fallback_used': False,
}
except Exception:
pass
interpreted, routing = self._heuristic_fallback(normalized, compact_context)
return interpreted, {
'stage': 'request_interpretation',
'provider': 'heuristic',
'model': self.model,
'system_prompt': system_prompt,
'user_prompt': user_prompt,
'assistant_response': json.dumps({'request': interpreted, 'routing': routing}),
'raw_response': {'fallback': 'heuristic'},
'routing': routing,
'context_excerpt': compact_context,
'fallback_used': True,
}
def _normalize_interpreted_request(self, interpreted: dict, original_prompt: str) -> dict:
"""Normalize LLM output into the required request shape."""
request_payload = interpreted.get('request') if isinstance(interpreted.get('request'), dict) else interpreted
name = str(interpreted.get('name') or '').strip() or self._derive_name(original_prompt)
if isinstance(request_payload, dict):
name = str(request_payload.get('name') or '').strip() or self._derive_name(original_prompt)
description = str((request_payload or {}).get('description') or '').strip() or original_prompt[:255]
features = self._normalize_list((request_payload or {}).get('features'))
tech_stack = self._normalize_list((request_payload or {}).get('tech_stack'))
if not features:
features = ['core workflow based on free-form request']
return {
'name': name[:255],
'description': description[:255],
'features': features,
'tech_stack': tech_stack,
}
def _build_compact_context(self, context: dict) -> dict:
"""Reduce interpreter context to the fields that help routing."""
projects = []
for project in context.get('projects', [])[:10]:
issues = []
for issue in project.get('open_issues', [])[:5]:
issues.append({'number': issue.get('number'), 'title': issue.get('title'), 'state': issue.get('state')})
projects.append(
{
'project_id': project.get('project_id'),
'name': project.get('name'),
'description': project.get('description'),
'repository': project.get('repository'),
'open_pull_request': bool(project.get('open_pull_request')),
'open_issues': issues,
}
)
return {
'chat_id': context.get('chat_id'),
'recent_chat_history': context.get('recent_chat_history', [])[:8],
'projects': projects,
}
def _normalize_routing(self, routing: dict | None, interpreted: dict, context: dict) -> dict:
"""Normalize routing metadata returned by the LLM."""
routing = routing or {}
project_id = routing.get('project_id')
project_name = routing.get('project_name')
issue_number = routing.get('issue_number')
if issue_number in ('', None):
issue_number = None
elif isinstance(issue_number, str) and issue_number.isdigit():
issue_number = int(issue_number)
matched_project = None
for project in context.get('projects', []):
if project_id and project.get('project_id') == project_id:
matched_project = project
break
if project_name and project.get('name') == project_name:
matched_project = project
break
intent = str(routing.get('intent') or '').strip() or ('continue_project' if matched_project else 'new_project')
return {
'intent': intent,
'project_id': matched_project.get('project_id') if matched_project else project_id,
'project_name': matched_project.get('name') if matched_project else (project_name or interpreted.get('name')),
'issue_number': issue_number,
'confidence': routing.get('confidence') or ('medium' if matched_project else 'low'),
'reasoning_summary': routing.get('reasoning_summary') or ('Matched prior project context' if matched_project else 'No strong prior project match found'),
}
def _normalize_list(self, value) -> list[str]:
if isinstance(value, list):
return [str(item).strip() for item in value if str(item).strip()]
if isinstance(value, str) and value.strip():
return [item.strip() for item in value.split(',') if item.strip()]
return []
def _derive_name(self, prompt_text: str) -> str:
"""Derive a stable project name when the LLM does not provide one."""
first_line = prompt_text.splitlines()[0].strip()
cleaned = re.sub(r'[^A-Za-z0-9 ]+', ' ', first_line)
words = [word.capitalize() for word in cleaned.split()[:4]]
return ' '.join(words) or 'Generated Project'
def _heuristic_fallback(self, prompt_text: str, context: dict | None = None) -> tuple[dict, dict]:
"""Fallback request extraction when Ollama is unavailable."""
lowered = prompt_text.lower()
tech_candidates = [
'python', 'fastapi', 'django', 'flask', 'postgresql', 'sqlite', 'react', 'vue', 'nicegui', 'docker'
]
tech_stack = [candidate for candidate in tech_candidates if candidate in lowered]
sentences = [part.strip() for part in re.split(r'[\n\.]+', prompt_text) if part.strip()]
features = sentences[:3] or ['Implement the user request from free-form text']
interpreted = {
'name': self._derive_name(prompt_text),
'description': sentences[0][:255] if sentences else prompt_text[:255],
'features': features,
'tech_stack': tech_stack,
}
routing = self._heuristic_routing(prompt_text, context or {})
if routing.get('project_name'):
interpreted['name'] = routing['project_name']
return interpreted, routing
def _heuristic_routing(self, prompt_text: str, context: dict) -> dict:
"""Best-effort routing when the LLM is unavailable."""
lowered = prompt_text.lower()
explicit_new = any(token in lowered for token in ['new project', 'start a new project', 'create a new project', 'build a new app'])
referenced_issue = self._extract_issue_number(prompt_text)
recent_history = context.get('recent_chat_history', [])
projects = context.get('projects', [])
last_project_id = recent_history[0].get('project_id') if recent_history else None
last_issue = ((recent_history[0].get('related_issue') or {}).get('number') if recent_history else None)
matched_project = None
for project in projects:
name = (project.get('name') or '').lower()
repo = ((project.get('repository') or {}).get('name') or '').lower()
if name and name in lowered:
matched_project = project
break
if repo and repo in lowered:
matched_project = project
break
if matched_project is None and not explicit_new:
follow_up_tokens = ['also', 'continue', 'for this project', 'for that project', 'work on this', 'work on that', 'fix that', 'add this']
if any(token in lowered for token in follow_up_tokens) and last_project_id:
matched_project = next((project for project in projects if project.get('project_id') == last_project_id), None)
issue_number = referenced_issue
if issue_number is None and any(token in lowered for token in ['that issue', 'this issue', 'the issue']) and last_issue is not None:
issue_number = last_issue
intent = 'new_project' if explicit_new or matched_project is None else 'continue_project'
return {
'intent': intent,
'project_id': matched_project.get('project_id') if matched_project else None,
'project_name': matched_project.get('name') if matched_project else self._derive_name(prompt_text),
'issue_number': issue_number,
'confidence': 'medium' if matched_project or explicit_new else 'low',
'reasoning_summary': 'Heuristic routing from chat history and project names.',
}
def _extract_issue_number(self, prompt_text: str) -> int | None:
match = re.search(r'(?:#|issue\s+)(\d+)', prompt_text, flags=re.IGNORECASE)
return int(match.group(1)) if match else None

View File

@@ -1,8 +1,6 @@
"""Telegram bot integration for n8n webhook.""" """Telegram bot integration for n8n webhook."""
import asyncio import asyncio
import json
import re
from typing import Optional from typing import Optional
@@ -13,6 +11,59 @@ class TelegramHandler:
self.webhook_url = webhook_url self.webhook_url = webhook_url
self.api_url = "https://api.telegram.org/bot" self.api_url = "https://api.telegram.org/bot"
def build_prompt_guide_message(self, backend_url: str | None = None) -> str:
"""Build a Telegram message explaining the expected prompt format."""
lines = [
"AI Software Factory is listening in this chat.",
"",
"You can send free-form software requests in normal language.",
"",
"Example:",
"Build an internal inventory portal for our warehouse team.",
"It should support role-based login, stock dashboards, and purchase orders.",
"Prefer FastAPI, PostgreSQL, and a simple web UI.",
"",
"The backend will interpret the request and turn it into a structured project plan.",
]
if backend_url:
lines.extend(["", f"Backend target: {backend_url}"])
return "\n".join(lines)
async def send_message(self, bot_token: str, chat_id: str | int, text: str) -> dict:
"""Send a direct Telegram message using the configured bot."""
if not bot_token:
return {"status": "error", "message": "Telegram bot token is not configured"}
if chat_id in (None, ""):
return {"status": "error", "message": "Telegram chat id is not configured"}
api_endpoint = f"{self.api_url}{bot_token}/sendMessage"
try:
import aiohttp
async with aiohttp.ClientSession() as session:
async with session.post(
api_endpoint,
json={
"chat_id": str(chat_id),
"text": text,
},
) as resp:
payload = await resp.json()
if 200 <= resp.status < 300 and payload.get("ok"):
return {
"status": "success",
"message": "Telegram prompt guide sent successfully",
"payload": payload,
}
description = payload.get("description") or payload.get("message") or str(payload)
return {
"status": "error",
"message": f"Telegram API returned {resp.status}: {description}",
"payload": payload,
}
except Exception as exc:
return {"status": "error", "message": str(exc)}
async def handle_message(self, message_data: dict) -> dict: async def handle_message(self, message_data: dict) -> dict:
"""Handle incoming Telegram message.""" """Handle incoming Telegram message."""
text = message_data.get("text", "") text = message_data.get("text", "")

File diff suppressed because it is too large Load Diff

View File

@@ -10,9 +10,9 @@ from fastapi.responses import RedirectResponse
from nicegui import app, ui from nicegui import app, ui
try: try:
from .dashboard_ui import create_dashboard from .dashboard_ui import create_dashboard, create_health_page
except ImportError: except ImportError:
from dashboard_ui import create_dashboard from dashboard_ui import create_dashboard, create_health_page
def init(fastapi_app: FastAPI, storage_secret: str = 'Secr2t!') -> None: def init(fastapi_app: FastAPI, storage_secret: str = 'Secr2t!') -> None:
@@ -24,6 +24,7 @@ def init(fastapi_app: FastAPI, storage_secret: str = 'Secr2t!') -> None:
""" """
def render_dashboard_page() -> None: def render_dashboard_page() -> None:
ui.page_title('AI Software Factory')
create_dashboard() create_dashboard()
# NOTE dark mode will be persistent for each user across tabs and server restarts # NOTE dark mode will be persistent for each user across tabs and server restarts
@@ -38,6 +39,10 @@ def init(fastapi_app: FastAPI, storage_secret: str = 'Secr2t!') -> None:
def show() -> None: def show() -> None:
render_dashboard_page() render_dashboard_page()
@ui.page('/health-ui')
def health_ui() -> None:
create_health_page()
@fastapi_app.get('/dashboard', include_in_schema=False) @fastapi_app.get('/dashboard', include_in_schema=False)
def dashboard_redirect() -> RedirectResponse: def dashboard_redirect() -> RedirectResponse:
return RedirectResponse(url='/', status_code=307) return RedirectResponse(url='/', status_code=307)

View File

@@ -1,3 +0,0 @@
#!/bin/bash
echo "Hello world"

View File

@@ -27,17 +27,23 @@ from sqlalchemy.orm import Session
try: try:
from . import __version__, frontend from . import __version__, frontend
from . import database as database_module from . import database as database_module
from .agents.change_summary import ChangeSummaryGenerator
from .agents.database_manager import DatabaseManager from .agents.database_manager import DatabaseManager
from .agents.request_interpreter import RequestInterpreter
from .agents.orchestrator import AgentOrchestrator from .agents.orchestrator import AgentOrchestrator
from .agents.n8n_setup import N8NSetupAgent from .agents.n8n_setup import N8NSetupAgent
from .agents.prompt_workflow import PromptWorkflowManager
from .agents.ui_manager import UIManager from .agents.ui_manager import UIManager
from .models import ProjectHistory, ProjectLog, SystemLog from .models import ProjectHistory, ProjectLog, SystemLog
except ImportError: except ImportError:
import frontend import frontend
import database as database_module import database as database_module
from agents.change_summary import ChangeSummaryGenerator
from agents.database_manager import DatabaseManager from agents.database_manager import DatabaseManager
from agents.request_interpreter import RequestInterpreter
from agents.orchestrator import AgentOrchestrator from agents.orchestrator import AgentOrchestrator
from agents.n8n_setup import N8NSetupAgent from agents.n8n_setup import N8NSetupAgent
from agents.prompt_workflow import PromptWorkflowManager
from agents.ui_manager import UIManager from agents.ui_manager import UIManager
from models import ProjectHistory, ProjectLog, SystemLog from models import ProjectHistory, ProjectLog, SystemLog
@@ -79,6 +85,24 @@ class N8NSetupRequest(BaseModel):
force_update: bool = False force_update: bool = False
class FreeformSoftwareRequest(BaseModel):
"""Request body for free-form software generation."""
prompt_text: str = Field(min_length=1)
source: str = 'telegram'
chat_id: str | None = None
chat_type: str | None = None
class GiteaRepositoryOnboardRequest(BaseModel):
"""Request body for onboarding a manually created Gitea repository."""
repo_name: str = Field(min_length=1, max_length=255)
owner: str | None = None
sync_commits: bool = True
commit_limit: int = Field(default=25, ge=1, le=200)
def _build_project_id(name: str) -> str: def _build_project_id(name: str) -> str:
"""Create a stable project id from the requested name.""" """Create a stable project id from the requested name."""
slug = PROJECT_ID_PATTERN.sub("-", name.strip().lower()).strip("-") or "project" slug = PROJECT_ID_PATTERN.sub("-", name.strip().lower()).strip("-") or "project"
@@ -144,11 +168,138 @@ def _compose_prompt_text(request: SoftwareRequest) -> str:
) )
async def _run_generation(
request: SoftwareRequest,
db: Session,
prompt_text: str | None = None,
prompt_actor: str = 'api',
prompt_source_context: dict | None = None,
prompt_routing: dict | None = None,
preferred_project_id: str | None = None,
related_issue: dict | None = None,
) -> dict:
"""Run the shared generation pipeline for a structured request."""
database_module.init_db()
manager = DatabaseManager(db)
reusable_history = manager.get_project_by_id(preferred_project_id) if preferred_project_id else manager.get_latest_project_by_name(request.name)
if reusable_history and database_module.settings.gitea_url and database_module.settings.gitea_token:
try:
from .agents.gitea import GiteaAPI
except ImportError:
from agents.gitea import GiteaAPI
manager.sync_pull_request_states(
GiteaAPI(
token=database_module.settings.GITEA_TOKEN,
base_url=database_module.settings.GITEA_URL,
owner=database_module.settings.GITEA_OWNER,
repo=database_module.settings.GITEA_REPO or '',
),
project_id=reusable_history.project_id,
)
if preferred_project_id and reusable_history is not None:
project_id = reusable_history.project_id
elif reusable_history and manager.get_open_pull_request(project_id=reusable_history.project_id):
project_id = reusable_history.project_id
else:
project_id = _build_project_id(request.name)
reusable_history = None
resolved_prompt_text = prompt_text or _compose_prompt_text(request)
orchestrator = AgentOrchestrator(
project_id=project_id,
project_name=request.name,
description=request.description,
features=request.features,
tech_stack=request.tech_stack,
db=db,
prompt_text=resolved_prompt_text,
prompt_actor=prompt_actor,
existing_history=reusable_history,
prompt_source_context=prompt_source_context,
prompt_routing=prompt_routing,
related_issue_hint=related_issue,
)
result = await orchestrator.run()
manager = DatabaseManager(db)
manager.log_system_event(
component='api',
level='INFO' if result['status'] == 'completed' else 'ERROR',
message=f"Generated project {project_id} with {len(result.get('changed_files', []))} artifact(s)",
)
history = manager.get_project_by_id(project_id)
project_logs = manager.get_project_logs(history.id)
response_data = _serialize_project(history)
response_data['logs'] = [_serialize_project_log(log) for log in project_logs]
response_data['ui_data'] = result.get('ui_data')
response_data['features'] = request.features
response_data['tech_stack'] = request.tech_stack
response_data['project_root'] = result.get('project_root', str(_project_root(project_id)))
response_data['changed_files'] = result.get('changed_files', [])
response_data['repository'] = result.get('repository')
response_data['related_issue'] = result.get('related_issue') or (result.get('ui_data') or {}).get('related_issue')
response_data['pull_request'] = result.get('pull_request') or manager.get_open_pull_request(project_id=project_id)
summary_context = {
'name': response_data['name'],
'description': response_data['description'],
'features': response_data['features'],
'tech_stack': response_data['tech_stack'],
'changed_files': response_data['changed_files'],
'repository_url': (
(response_data.get('repository') or {}).get('url')
if isinstance(response_data.get('repository'), dict)
and (response_data.get('repository') or {}).get('status') in {'created', 'exists', 'ready', 'shared'}
else None
),
'repository_status': (response_data.get('repository') or {}).get('status') if isinstance(response_data.get('repository'), dict) else None,
'pull_request_url': (response_data.get('pull_request') or {}).get('pr_url') if isinstance(response_data.get('pull_request'), dict) else None,
'pull_request_state': (response_data.get('pull_request') or {}).get('pr_state') if isinstance(response_data.get('pull_request'), dict) else None,
'related_issue': response_data.get('related_issue'),
'message': response_data.get('message'),
'logs': [log.get('message', '') for log in response_data.get('logs', []) if isinstance(log, dict)],
}
summary_message, summary_trace = await ChangeSummaryGenerator().summarize_with_trace(summary_context)
if orchestrator.db_manager and orchestrator.history and orchestrator.prompt_audit:
orchestrator.db_manager.log_llm_trace(
project_id=project_id,
history_id=orchestrator.history.id,
prompt_id=orchestrator.prompt_audit.id,
stage=summary_trace['stage'],
provider=summary_trace['provider'],
model=summary_trace['model'],
system_prompt=summary_trace['system_prompt'],
user_prompt=summary_trace['user_prompt'],
assistant_response=summary_trace['assistant_response'],
raw_response=summary_trace.get('raw_response'),
fallback_used=summary_trace.get('fallback_used', False),
)
response_data['summary_message'] = summary_message
response_data['pull_request'] = result.get('pull_request') or manager.get_open_pull_request(project_id=project_id)
return {'status': result['status'], 'data': response_data, 'summary_message': summary_message}
def _project_root(project_id: str) -> Path: def _project_root(project_id: str) -> Path:
"""Resolve the filesystem location for a generated project.""" """Resolve the filesystem location for a generated project."""
return database_module.settings.projects_root / project_id return database_module.settings.projects_root / project_id
def _create_gitea_api():
"""Create a configured Gitea client or raise an HTTP error if unavailable."""
if not database_module.settings.gitea_url or not database_module.settings.gitea_token:
raise HTTPException(status_code=400, detail='Gitea integration is not configured')
try:
from .agents.gitea import GiteaAPI
except ImportError:
from agents.gitea import GiteaAPI
return GiteaAPI(
token=database_module.settings.GITEA_TOKEN,
base_url=database_module.settings.GITEA_URL,
owner=database_module.settings.GITEA_OWNER,
repo=database_module.settings.GITEA_REPO or '',
)
def _resolve_n8n_api_url(explicit_url: str | None = None) -> str: def _resolve_n8n_api_url(explicit_url: str | None = None) -> str:
"""Resolve the effective n8n API URL from explicit input or settings.""" """Resolve the effective n8n API URL from explicit input or settings."""
if explicit_url and explicit_url.strip(): if explicit_url and explicit_url.strip():
@@ -172,6 +323,7 @@ def read_api_info():
'/api', '/api',
'/health', '/health',
'/generate', '/generate',
'/generate/text',
'/projects', '/projects',
'/status/{project_id}', '/status/{project_id}',
'/audit/projects', '/audit/projects',
@@ -179,8 +331,17 @@ def read_api_info():
'/audit/system/logs', '/audit/system/logs',
'/audit/prompts', '/audit/prompts',
'/audit/changes', '/audit/changes',
'/audit/issues',
'/audit/commit-context',
'/audit/timeline',
'/audit/llm-traces',
'/audit/pull-requests',
'/audit/lineage', '/audit/lineage',
'/audit/correlations', '/audit/correlations',
'/projects/{project_id}/prompts/{prompt_id}/undo',
'/projects/{project_id}/sync-repository',
'/gitea/repos',
'/gitea/repos/onboard',
'/n8n/health', '/n8n/health',
'/n8n/setup', '/n8n/setup',
], ],
@@ -202,40 +363,80 @@ def health_check():
@app.post('/generate') @app.post('/generate')
async def generate_software(request: SoftwareRequest, db: DbSession): async def generate_software(request: SoftwareRequest, db: DbSession):
"""Create and record a software-generation request.""" """Create and record a software-generation request."""
database_module.init_db() return await _run_generation(request, db)
project_id = _build_project_id(request.name)
prompt_text = _compose_prompt_text(request) @app.post('/generate/text')
orchestrator = AgentOrchestrator( async def generate_software_from_text(request: FreeformSoftwareRequest, db: DbSession):
project_id=project_id, """Interpret a free-form request and run generation."""
project_name=request.name, if (
description=request.description, request.source == 'telegram'
features=request.features, and database_module.settings.telegram_chat_id
tech_stack=request.tech_stack, and request.chat_id
db=db, and str(request.chat_id) != str(database_module.settings.telegram_chat_id)
prompt_text=prompt_text, ):
) return {
result = await orchestrator.run() 'status': 'ignored',
'message': f"Ignoring Telegram message from chat {request.chat_id}",
'source': {
'type': request.source,
'chat_id': request.chat_id,
'chat_type': request.chat_type,
},
}
manager = DatabaseManager(db) manager = DatabaseManager(db)
manager.log_system_event( interpreter_context = manager.get_interpreter_context(chat_id=request.chat_id, source=request.source)
component='api', interpreted, interpretation_trace = await RequestInterpreter().interpret_with_trace(
level='INFO' if result['status'] == 'completed' else 'ERROR', request.prompt_text,
message=f"Generated project {project_id} with {len(result.get('changed_files', []))} artifact(s)", context=interpreter_context,
) )
routing = interpretation_trace.get('routing') or {}
history = manager.get_project_by_id(project_id) selected_history = manager.get_project_by_id(routing.get('project_id')) if routing.get('project_id') else None
project_logs = manager.get_project_logs(history.id) if selected_history is not None and routing.get('intent') != 'new_project':
response_data = _serialize_project(history) interpreted['name'] = selected_history.project_name
response_data['logs'] = [_serialize_project_log(log) for log in project_logs] interpreted['description'] = selected_history.description or interpreted['description']
response_data['ui_data'] = result.get('ui_data') structured_request = SoftwareRequest(**interpreted)
response_data['features'] = request.features response = await _run_generation(
response_data['tech_stack'] = request.tech_stack structured_request,
response_data['project_root'] = result.get('project_root', str(_project_root(project_id))) db,
response_data['changed_files'] = result.get('changed_files', []) prompt_text=request.prompt_text,
response_data['repository'] = result.get('repository') prompt_actor=request.source,
prompt_source_context={
return {'status': result['status'], 'data': response_data} 'chat_id': request.chat_id,
'chat_type': request.chat_type,
},
prompt_routing=routing,
preferred_project_id=routing.get('project_id') if routing.get('intent') != 'new_project' else None,
related_issue={'number': routing.get('issue_number')} if routing.get('issue_number') is not None else None,
)
project_data = response.get('data', {})
if project_data.get('history_id') is not None:
manager = DatabaseManager(db)
prompts = manager.get_prompt_events(project_id=project_data.get('project_id'))
prompt_id = prompts[0]['id'] if prompts else None
manager.log_llm_trace(
project_id=project_data.get('project_id'),
history_id=project_data.get('history_id'),
prompt_id=prompt_id,
stage=interpretation_trace['stage'],
provider=interpretation_trace['provider'],
model=interpretation_trace['model'],
system_prompt=interpretation_trace['system_prompt'],
user_prompt=interpretation_trace['user_prompt'],
assistant_response=interpretation_trace['assistant_response'],
raw_response=interpretation_trace.get('raw_response'),
fallback_used=interpretation_trace.get('fallback_used', False),
)
response['interpreted_request'] = interpreted
response['routing'] = routing
response['llm_trace'] = interpretation_trace
response['source'] = {
'type': request.source,
'chat_id': request.chat_id,
'chat_type': request.chat_type,
}
return response
@app.get('/projects') @app.get('/projects')
@@ -285,6 +486,68 @@ def get_code_change_audit(db: DbSession, project_id: str | None = Query(default=
return {'changes': [_serialize_audit_item(item) for item in manager.get_code_changes(project_id=project_id)]} return {'changes': [_serialize_audit_item(item) for item in manager.get_code_changes(project_id=project_id)]}
@app.get('/audit/issues')
def get_issue_audit(
db: DbSession,
project_id: str | None = Query(default=None),
state: str | None = Query(default=None),
):
"""Return tracked repository issues and issue-work events."""
manager = DatabaseManager(db)
return {
'issues': manager.get_repository_issues(project_id=project_id, state=state),
'issue_work': manager.get_issue_work_events(project_id=project_id),
}
@app.get('/audit/commit-context')
def get_commit_context_audit(
db: DbSession,
commit_hash: str = Query(min_length=4),
project_id: str | None = Query(default=None),
branch_scope: str | None = Query(default=None, pattern='^(main|pr|manual)?$'),
):
"""Return the recorded context explaining how a commit came to be."""
manager = DatabaseManager(db)
context = manager.get_commit_context(commit_hash=commit_hash, project_id=project_id, branch_scope=branch_scope)
if context is None:
raise HTTPException(status_code=404, detail='Commit context not found')
return context
@app.get('/audit/timeline')
def get_project_timeline_audit(
db: DbSession,
project_id: str = Query(min_length=1),
branch_scope: str | None = Query(default=None, pattern='^(main|pr|manual)?$'),
):
"""Return the mixed audit timeline for one project."""
manager = DatabaseManager(db)
return {'timeline': manager.get_project_timeline(project_id=project_id, branch_scope=branch_scope)}
@app.get('/audit/llm-traces')
def get_llm_trace_audit(
db: DbSession,
project_id: str | None = Query(default=None),
prompt_id: int | None = Query(default=None),
stage: str | None = Query(default=None),
model: str | None = Query(default=None),
search: str | None = Query(default=None),
):
"""Return persisted LLM traces."""
manager = DatabaseManager(db)
return {
'llm_traces': manager.get_llm_traces(
project_id=project_id,
prompt_id=prompt_id,
stage=stage,
model=model,
search_query=search,
)
}
@app.get('/audit/lineage') @app.get('/audit/lineage')
def get_prompt_change_lineage(db: DbSession, project_id: str | None = Query(default=None)): def get_prompt_change_lineage(db: DbSession, project_id: str | None = Query(default=None)):
"""Return explicit prompt-to-code lineage rows.""" """Return explicit prompt-to-code lineage rows."""
@@ -299,6 +562,87 @@ def get_prompt_change_correlations(db: DbSession, project_id: str | None = Query
return {'correlations': manager.get_prompt_change_correlations(project_id=project_id)} return {'correlations': manager.get_prompt_change_correlations(project_id=project_id)}
@app.get('/audit/pull-requests')
def get_pull_request_audit(db: DbSession, project_id: str | None = Query(default=None), open_only: bool = Query(default=False)):
"""Return tracked pull requests for generated projects."""
manager = DatabaseManager(db)
return {'pull_requests': manager.get_pull_requests(project_id=project_id, only_open=open_only)}
@app.post('/projects/{project_id}/prompts/{prompt_id}/undo')
async def undo_prompt_changes(project_id: str, prompt_id: int, db: DbSession):
"""Undo all changes associated with a specific prompt."""
result = await PromptWorkflowManager(db).undo_prompt(project_id=project_id, prompt_id=prompt_id)
if result.get('status') == 'error':
raise HTTPException(status_code=400, detail=result.get('message', 'Undo failed'))
return result
@app.post('/projects/{project_id}/sync-repository')
def sync_project_repository(project_id: str, db: DbSession, commit_limit: int = Query(default=25, ge=1, le=200)):
"""Import recent repository activity from Gitea for a tracked project."""
manager = DatabaseManager(db)
gitea_api = _create_gitea_api()
result = manager.sync_repository_activity(project_id=project_id, gitea_api=gitea_api, commit_limit=commit_limit)
if result.get('status') == 'error':
raise HTTPException(status_code=400, detail=result.get('message', 'Repository sync failed'))
manager.sync_repository_issues(project_id=project_id, gitea_api=gitea_api, state='open')
return result
@app.get('/gitea/repos')
def list_gitea_repositories(db: DbSession, owner: str | None = Query(default=None)):
"""List repositories in the configured Gitea organization and whether they are already onboarded."""
gitea_api = _create_gitea_api()
resolved_owner = owner or database_module.settings.gitea_owner
repos = gitea_api.list_repositories_sync(owner=resolved_owner)
if isinstance(repos, dict) and repos.get('error'):
raise HTTPException(status_code=502, detail=repos.get('error'))
manager = DatabaseManager(db)
items = []
for repo in repos if isinstance(repos, list) else []:
tracked_project = manager.get_project_by_repository(resolved_owner, repo.get('name', ''))
items.append(
{
'name': repo.get('name'),
'full_name': repo.get('full_name') or f"{resolved_owner}/{repo.get('name')}",
'description': repo.get('description'),
'html_url': repo.get('html_url'),
'clone_url': repo.get('clone_url'),
'default_branch': repo.get('default_branch'),
'private': bool(repo.get('private', False)),
'onboarded': tracked_project is not None,
'project_id': tracked_project.project_id if tracked_project is not None else None,
}
)
return {'repositories': items}
@app.post('/gitea/repos/onboard')
async def onboard_gitea_repository(request: GiteaRepositoryOnboardRequest, db: DbSession):
"""Onboard a manually created Gitea repository into the factory dashboard."""
gitea_api = _create_gitea_api()
owner = request.owner or database_module.settings.gitea_owner
repo = await gitea_api.get_repo_info(owner=owner, repo=request.repo_name)
if isinstance(repo, dict) and repo.get('error'):
raise HTTPException(status_code=404, detail=repo.get('error'))
manager = DatabaseManager(db)
onboarded = manager.onboard_repository(owner=owner, repo_name=request.repo_name, repository_data=repo)
manager.sync_repository_issues(project_id=onboarded['project_id'], gitea_api=gitea_api, state='open')
sync_result = None
if request.sync_commits:
sync_result = manager.sync_repository_activity(
project_id=onboarded['project_id'],
gitea_api=gitea_api,
commit_limit=request.commit_limit,
)
return {
'status': 'success',
'onboarded': onboarded,
'sync_result': sync_result,
}
@app.get('/audit/logs') @app.get('/audit/logs')
def get_audit_logs(db: DbSession): def get_audit_logs(db: DbSession):
"""Return all project logs ordered newest first.""" """Return all project logs ordered newest first."""
@@ -323,10 +667,16 @@ async def get_n8n_health():
"""Check whether the configured n8n instance is reachable.""" """Check whether the configured n8n instance is reachable."""
api_url = _resolve_n8n_api_url() api_url = _resolve_n8n_api_url()
if not api_url: if not api_url:
return {'status': 'error', 'message': 'N8N_API_URL or N8N_WEBHOOK_URL is not configured'} return {
'status': 'error',
'message': 'N8N_API_URL or N8N_WEBHOOK_URL is not configured.',
'api_url': '',
'auth_configured': bool(database_module.settings.n8n_api_key),
'checks': [],
'suggestion': 'Set N8N_API_URL to the base n8n address before provisioning workflows.',
}
agent = N8NSetupAgent(api_url=api_url, webhook_token=database_module.settings.n8n_api_key) agent = N8NSetupAgent(api_url=api_url, webhook_token=database_module.settings.n8n_api_key)
result = await agent.health_check() return await agent.health_check()
return {'status': 'ok' if not result.get('error') else 'error', 'data': result}
@app.post('/n8n/setup') @app.post('/n8n/setup')
@@ -342,7 +692,7 @@ async def setup_n8n_workflow(request: N8NSetupRequest, db: DbSession):
) )
result = await agent.setup( result = await agent.setup(
webhook_path=request.webhook_path, webhook_path=request.webhook_path,
backend_url=request.backend_url or f"{database_module.settings.backend_public_url}/generate", backend_url=request.backend_url or f"{database_module.settings.backend_public_url}/generate/text",
force_update=request.force_update, force_update=request.force_update,
telegram_bot_token=database_module.settings.telegram_bot_token, telegram_bot_token=database_module.settings.telegram_bot_token,
telegram_credential_name=database_module.settings.n8n_telegram_credential_name, telegram_credential_name=database_module.settings.n8n_telegram_credential_name,