16 Commits
0.6.0 ... 0.7.1

Author SHA1 Message Date
ed8dc48280 release: version 0.7.1 🚀
All checks were successful
Upload Python Package / Create Release (push) Successful in 36s
Upload Python Package / deploy (push) Successful in 1m24s
2026-04-11 09:21:15 +02:00
c3cf8da42d fix: add additional deletion confirmation, refs NOISSUE 2026-04-11 09:21:12 +02:00
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 4357 additions and 187 deletions

View File

@@ -4,6 +4,78 @@ Changelog
(unreleased) (unreleased)
------------ ------------
Fix
~~~
- Add additional deletion confirmation, refs NOISSUE. [Simon
Diesenreiter]
0.7.0 (2026-04-10)
------------------
- 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.1

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

@@ -1,7 +1,9 @@
"""Git manager for project operations.""" """Git manager for project operations."""
import os import os
import shutil
import subprocess import subprocess
import tempfile
from pathlib import Path from pathlib import Path
from typing import Optional from typing import Optional
@@ -14,53 +16,140 @@ 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
project_path = Path(project_id) if project_dir:
if project_path.is_absolute() or len(project_path.parts) > 1: resolved = Path(project_dir).expanduser().resolve()
resolved = project_path.expanduser().resolve()
else: else:
base_root = settings.projects_root project_path = Path(project_id)
if base_root.name != "test-project": if project_path.is_absolute() or len(project_path.parts) > 1:
base_root = base_root / "test-project" resolved = project_path.expanduser().resolve()
resolved = (base_root / project_id).resolve() else:
base_root = settings.projects_root
if base_root.name != "test-project":
base_root = base_root / "test-project"
resolved = (base_root / project_id).resolve()
self.project_dir = str(resolved) self.project_dir = str(resolved)
def is_git_available(self) -> bool:
"""Return whether the git executable is available in the current environment."""
return shutil.which('git') is not None
def _ensure_git_available(self) -> None:
"""Raise a clear error when git is not installed in the runtime environment."""
if not self.is_git_available():
raise RuntimeError('git executable is not available in PATH')
def _run(self, args: list[str], env: dict | None = None, check: bool = True) -> subprocess.CompletedProcess:
"""Run a git command in the project directory."""
self._ensure_git_available()
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 +173,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,32 @@ class GiteaAPI:
result.setdefault("status", "created") result.setdefault("status", "created")
return result return result
async def delete_repo(self, owner: str | None = None, repo: str | None = None) -> dict:
"""Delete a repository from the configured organization/user."""
_owner = owner or self.owner
_repo = repo or self.repo
if not _owner or not _repo:
return {'error': 'Owner and repository name are required'}
result = await self._request('DELETE', f'repos/{_owner}/{_repo}')
if not result.get('error'):
result.setdefault('status', 'deleted')
return result
def delete_repo_sync(self, owner: str | None = None, repo: str | None = None) -> dict:
"""Synchronously delete a repository from the configured organization/user."""
_owner = owner or self.owner
_repo = repo or self.repo
if not _owner or not _repo:
return {'error': 'Owner and repository name are required'}
result = self._request_sync('DELETE', f'repos/{_owner}/{_repo}')
if not result.get('error'):
result.setdefault('status', 'deleted')
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 +212,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") return self._build_health_result(result, fallback)
if fallback.get("error"):
return fallback def health_check_sync(self) -> dict:
return {"status": "ok", "checked_via": "workflows"} """Synchronously check n8n API health for UI rendering."""
return {"status": "ok", "checked_via": "healthz"} 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 = self.db_manager.log_project_start( self.history = existing_history
project_id=project_id, self.project_id = existing_history.project_id
project_name=project_name, self.project_name = existing_history.project_name or project_name
description=description self.description = existing_history.description or description
) else:
# Re-fetch with new history_id self.history = self.db_manager.log_project_start(
self.db_manager = DatabaseManager(db) project_id=project_id,
project_name=project_name,
description=description
)
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,176 @@ 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.is_git_available():
self.ui_manager.ui_data.setdefault('git', {})['error'] = 'git executable is not available in PATH'
self._append_log('Local git workspace skipped: git executable is not available in PATH')
return
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 +451,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 +466,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 +506,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 +522,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 +551,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 +579,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 +607,78 @@ 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
if not self.git_manager.is_git_available():
self.ui_manager.ui_data.setdefault('git', {})['error'] = 'git executable is not available in PATH'
self._append_log('Git commit skipped: git executable is not available in PATH')
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 (RuntimeError, 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,280 @@
"""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()
quoted = re.search(r'["\']([^"\']{3,80})["\']', first_line)
if quoted:
return self._humanize_name(quoted.group(1))
noun_phrase = re.search(
r'(?:build|create|start|make|develop|generate|design|need|want)\s+'
r'(?:me\s+|us\s+|an?\s+|the\s+|new\s+|internal\s+|simple\s+|lightweight\s+|modern\s+|web\s+|mobile\s+)*'
r'([a-z0-9][a-z0-9\s-]{2,80}?(?:portal|dashboard|app|application|service|tool|system|platform|api|bot|assistant|website|site|workspace|tracker|manager))\b',
first_line,
flags=re.IGNORECASE,
)
if noun_phrase:
return self._humanize_name(noun_phrase.group(1))
cleaned = re.sub(r'[^A-Za-z0-9 ]+', ' ', first_line)
stopwords = {
'build', 'create', 'start', 'make', 'develop', 'generate', 'design', 'need', 'want', 'please', 'for', 'our', 'with', 'that', 'this',
'new', 'internal', 'simple', 'modern', 'web', 'mobile', 'app', 'application', 'tool', 'system',
}
tokens = [word for word in cleaned.split() if word and word.lower() not in stopwords]
if tokens:
return self._humanize_name(' '.join(tokens[:4]))
return 'Generated Project'
def _humanize_name(self, raw_name: str) -> str:
"""Normalize a candidate project name into a readable title."""
cleaned = re.sub(r'[^A-Za-z0-9\s-]+', ' ', raw_name).strip(' -')
cleaned = re.sub(r'\s+', ' ', cleaned)
special_upper = {'api', 'crm', 'erp', 'cms', 'hr', 'it', 'ui', 'qa'}
words = []
for word in cleaned.split()[:6]:
lowered = word.lower()
words.append(lowered.upper() if lowered in special_upper else lowered.capitalize())
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, include_archived=False) 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,20 @@ 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}/archive',
'/projects/{project_id}/unarchive',
'/projects/{project_id}',
'/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,47 +366,91 @@ 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'), include_archived=False) 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')
def list_projects(db: DbSession): def list_projects(
db: DbSession,
include_archived: bool = Query(default=False),
archived_only: bool = Query(default=False),
):
"""List recorded projects.""" """List recorded projects."""
manager = DatabaseManager(db) manager = DatabaseManager(db)
projects = manager.get_all_projects() projects = manager.get_all_projects(include_archived=include_archived, archived_only=archived_only)
return {'projects': [_serialize_project(project) for project in projects]} return {'projects': [_serialize_project(project) for project in projects]}
@@ -285,6 +493,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 +569,141 @@ 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."""
manager = DatabaseManager(db)
history = manager.get_project_by_id(project_id)
if history is None:
raise HTTPException(status_code=404, detail='Project not found')
if history.status == 'archived':
raise HTTPException(status_code=400, detail='Archived projects cannot be modified')
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}/archive')
def archive_project(project_id: str, db: DbSession):
"""Archive a project so it no longer participates in active automation."""
manager = DatabaseManager(db)
result = manager.archive_project(project_id)
if result.get('status') == 'error':
raise HTTPException(status_code=404, detail=result.get('message', 'Archive failed'))
return result
@app.post('/projects/{project_id}/unarchive')
def unarchive_project(project_id: str, db: DbSession):
"""Restore an archived project back into the active automation set."""
manager = DatabaseManager(db)
result = manager.unarchive_project(project_id)
if result.get('status') == 'error':
raise HTTPException(status_code=404, detail=result.get('message', 'Restore failed'))
return result
@app.delete('/projects/{project_id}')
def delete_project(project_id: str, db: DbSession):
"""Delete a project, its local project directory, and project-scoped DB traces."""
manager = DatabaseManager(db)
audit_data = manager.get_project_audit_data(project_id)
if audit_data.get('project') is None:
raise HTTPException(status_code=404, detail='Project not found')
repository = audit_data.get('repository') or audit_data['project'].get('repository') or {}
remote_delete = None
if repository and repository.get('mode') != 'shared' and repository.get('owner') and repository.get('name') and database_module.settings.gitea_url and database_module.settings.gitea_token:
remote_delete = _create_gitea_api().delete_repo_sync(owner=repository.get('owner'), repo=repository.get('name'))
if remote_delete.get('error') and remote_delete.get('status_code') not in {404, None}:
raise HTTPException(status_code=502, detail=remote_delete.get('error'))
result = manager.delete_project(project_id)
if result.get('status') == 'error':
raise HTTPException(status_code=400, detail=result.get('message', 'Project deletion failed'))
result['remote_repository_deleted'] = bool(remote_delete and not remote_delete.get('error'))
result['remote_repository'] = repository if repository else None
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)
history = manager.get_project_by_id(project_id)
if history is None:
raise HTTPException(status_code=404, detail='Project not found')
if history.status == 'archived':
raise HTTPException(status_code=400, detail='Archived projects cannot be synced')
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 +728,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 +753,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,