2 Commits
0.9.3 ... 0.9.4

Author SHA1 Message Date
b0c95323fd release: version 0.9.4 🚀
All checks were successful
Upload Python Package / Create Release (push) Successful in 24s
Upload Python Package / deploy (push) Successful in 56s
2026-04-11 13:06:54 +02:00
d60e753acf fix: add commit retry, refs NOISSUE 2026-04-11 13:06:48 +02:00
6 changed files with 498 additions and 43 deletions

View File

@@ -5,10 +5,21 @@ Changelog
(unreleased)
------------
Fix
~~~
- Add commit retry, refs NOISSUE. [Simon Diesenreiter]
0.9.3 (2026-04-11)
------------------
Fix
~~~
- Better home assistant integration, refs NOISSUE. [Simon Diesenreiter]
Other
~~~~~
0.9.2 (2026-04-11)
------------------

View File

@@ -118,6 +118,8 @@ The dashboard Health tab exposes operator controls for the prompt queue, includi
The dashboard System tab now also stores Home Assistant entity ids, queue toggles, thresholds, and batch settings in the database, so the environment only needs `HOME_ASSISTANT_URL` and `HOME_ASSISTANT_TOKEN` for that integration.
Projects that show `uncommitted`, `local_only`, or `pushed_no_pr` delivery warnings in the dashboard can now be retried in place from the UI before resorting to purging orphan audit rows.
Guardrail and system prompts are no longer environment-only in practice: the factory can persist DB-backed overrides for the editable LLM prompt set, expose them at `/llm/prompts`, and edit them from the dashboard System tab. Environment values still act as defaults and as the reset target.
## API Endpoints

View File

@@ -1 +1 @@
0.9.3
0.9.4

View File

@@ -35,6 +35,7 @@ from datetime import datetime
import json
import re
import shutil
from pathlib import Path
class DatabaseMigrations:
@@ -125,20 +126,54 @@ class DatabaseManager:
return sanitized.strip('-') or 'external-project'
@staticmethod
def _partition_code_changes(raw_code_changes: list[dict], commits: list[dict]) -> tuple[list[dict], list[dict]]:
"""Split code changes into visible committed rows and orphaned rows."""
committed_hashes = {commit.get('commit_hash') for commit in commits if commit.get('commit_hash')}
committed_prompt_ids = {commit.get('prompt_id') for commit in commits if commit.get('prompt_id') is not None}
def _partition_code_changes(raw_code_changes: list[dict], commits: list[dict]) -> tuple[list[dict], list[dict], list[dict]]:
"""Split code changes into remotely delivered, local-only, and orphaned rows."""
published_hashes = {
commit.get('commit_hash')
for commit in commits
if commit.get('commit_hash') and (
commit.get('remote_status') == 'pushed'
or commit.get('imported_from_remote')
or commit.get('commit_url')
)
}
published_prompt_ids = {
commit.get('prompt_id')
for commit in commits
if commit.get('prompt_id') is not None and (
commit.get('remote_status') == 'pushed'
or commit.get('imported_from_remote')
or commit.get('commit_url')
)
}
local_commit_hashes = {commit.get('commit_hash') for commit in commits if commit.get('commit_hash')}
local_prompt_ids = {commit.get('prompt_id') for commit in commits if commit.get('prompt_id') is not None}
visible_changes: list[dict] = []
local_only_changes: list[dict] = []
orphaned_changes: list[dict] = []
for change in raw_code_changes:
change_commit_hash = change.get('commit_hash')
prompt_id = change.get('prompt_id')
if (change_commit_hash and change_commit_hash in committed_hashes) or (prompt_id is not None and prompt_id in committed_prompt_ids):
if (change_commit_hash and change_commit_hash in published_hashes) or (prompt_id is not None and prompt_id in published_prompt_ids):
visible_changes.append(change)
elif (change_commit_hash and change_commit_hash in local_commit_hashes) or (prompt_id is not None and prompt_id in local_prompt_ids):
local_only_changes.append(change)
else:
orphaned_changes.append(change)
return visible_changes, orphaned_changes
return visible_changes, local_only_changes, orphaned_changes
@staticmethod
def _dedupe_preserve_order(values: list[str | None]) -> list[str]:
"""Return non-empty values in stable unique order."""
result: list[str] = []
seen: set[str] = set()
for value in values:
normalized = (value or '').strip()
if not normalized or normalized in seen:
continue
seen.add(normalized)
result.append(normalized)
return result
def get_project_by_repository(self, owner: str, repo_name: str, include_archived: bool = False) -> ProjectHistory | None:
"""Return the project currently associated with a repository."""
@@ -2260,21 +2295,35 @@ class DatabaseManager:
pull_requests = self.get_pull_requests(project_id=project_id)
llm_traces = self.get_llm_traces(project_id=project_id)
correlations = self.get_prompt_change_correlations(project_id=project_id)
code_changes, orphan_code_changes = self._partition_code_changes(raw_code_changes, commits)
code_changes, local_only_code_changes, orphan_code_changes = self._partition_code_changes(raw_code_changes, commits)
repository = self._get_project_repository(history)
timeline = self.get_project_timeline(project_id=project_id)
repository_sync = self.get_repository_sync_status(project_id=project_id)
issues = self.get_repository_issues(project_id=project_id)
issue_work = self.get_issue_work_events(project_id=project_id)
published_commits = [
commit for commit in commits
if commit.get('remote_status') == 'pushed' or commit.get('imported_from_remote') or commit.get('commit_url')
]
has_pull_request = any(pr.get('pr_state') == 'open' and not pr.get('merged') for pr in pull_requests)
if orphan_code_changes:
delivery_status = 'uncommitted'
delivery_message = (
f"{len(orphan_code_changes)} generated file change(s) were recorded without a matching git commit. "
"These changes never reached a PR-backed delivery."
)
elif commits:
elif local_only_code_changes:
delivery_status = 'local_only'
delivery_message = (
f"{len(local_only_code_changes)} generated file change(s) were committed only in the local workspace. "
"No remote repo push was recorded for this prompt yet."
)
elif published_commits and repository and repository.get('mode') == 'project' and not has_pull_request:
delivery_status = 'pushed_no_pr'
delivery_message = 'Changes were pushed to the remote repository, but no pull request is currently tracked for review.'
elif published_commits:
delivery_status = 'delivered'
delivery_message = 'Generated changes were recorded in git commits for this project.'
delivery_message = 'Generated changes were published to the tracked repository and are reviewable through the recorded pull request.'
else:
delivery_status = 'pending'
delivery_message = 'No git commit has been recorded for this project yet.'
@@ -2295,6 +2344,7 @@ class DatabaseManager:
"open_pull_requests": len([pr for pr in pull_requests if pr["pr_state"] == "open" and not pr["merged"]]),
"delivery_status": delivery_status,
"delivery_message": delivery_message,
"local_only_code_change_count": len(local_only_code_changes),
"orphan_code_change_count": len(orphan_code_changes),
"completed_at": history.completed_at.isoformat() if history.completed_at else None,
"created_at": history.started_at.isoformat() if history.started_at else None
@@ -2334,6 +2384,7 @@ class DatabaseManager:
],
"prompts": prompts,
"code_changes": code_changes,
"local_only_code_changes": local_only_code_changes,
"orphan_code_changes": orphan_code_changes,
"commits": commits,
"pull_requests": pull_requests,
@@ -2401,9 +2452,21 @@ class DatabaseManager:
"""Correlate prompts with the concrete code changes that followed them."""
correlations = self._build_correlations_from_links(project_id=project_id, limit=limit)
if correlations:
return [correlation for correlation in correlations if correlation.get('commits')]
return [
correlation for correlation in correlations
if any(
commit.get('remote_status') == 'pushed' or commit.get('imported_from_remote') or commit.get('commit_url')
for commit in correlation.get('commits', [])
)
]
fallback = self._build_correlations_from_audit_fallback(project_id=project_id, limit=limit)
return [correlation for correlation in fallback if correlation.get('commits')]
return [
correlation for correlation in fallback
if any(
commit.get('remote_status') == 'pushed' or commit.get('imported_from_remote') or commit.get('commit_url')
for commit in correlation.get('commits', [])
)
]
def get_dashboard_snapshot(self, limit: int = 8) -> dict:
"""Return DB-backed dashboard data for the UI."""
@@ -2467,6 +2530,329 @@ class DatabaseManager:
},
}
def _build_commit_url(self, owner: str, repo_name: str, commit_hash: str) -> str | None:
"""Build a browser commit URL from configured Gitea settings."""
if not settings.gitea_url or not owner or not repo_name or not commit_hash:
return None
return f"{str(settings.gitea_url).rstrip('/')}/{owner}/{repo_name}/commit/{commit_hash}"
def _update_project_audit_rows_for_delivery(
self,
project_id: str,
branch: str,
owner: str,
repo_name: str,
code_change_ids: list[int],
orphan_code_change_ids: list[int],
published_commit_hashes: list[str],
) -> None:
"""Mark matching commit and code-change rows as remotely published."""
commit_hashes = set(self._dedupe_preserve_order(published_commit_hashes))
for commit_row in self.db.query(AuditTrail).filter(
AuditTrail.project_id == project_id,
AuditTrail.action == 'GIT_COMMIT',
).all():
metadata = self._normalize_metadata(commit_row.metadata_json)
commit_hash = metadata.get('commit_hash')
if not commit_hash or commit_hash not in commit_hashes:
continue
metadata['branch'] = branch
metadata['remote_status'] = 'pushed'
metadata['commit_url'] = self._build_commit_url(owner, repo_name, commit_hash)
commit_row.metadata_json = metadata
retry_ids = set(code_change_ids)
orphan_ids = set(orphan_code_change_ids)
new_commit_hash = next(iter(commit_hashes), None)
for change_row in self.db.query(AuditTrail).filter(
AuditTrail.project_id == project_id,
AuditTrail.action == 'CODE_CHANGE',
).all():
if change_row.id not in retry_ids:
continue
metadata = self._normalize_metadata(change_row.metadata_json)
metadata['branch'] = branch
metadata['remote_status'] = 'pushed'
if change_row.id in orphan_ids and new_commit_hash:
metadata['commit_hash'] = new_commit_hash
change_row.metadata_json = metadata
self.db.commit()
def _find_or_create_delivery_pull_request(
self,
history: ProjectHistory,
gitea_api,
owner: str,
repo_name: str,
branch: str,
prompt_text: str | None,
) -> dict:
"""Return an open PR for the project branch, creating one if necessary."""
existing = self.get_open_pull_request(project_id=history.project_id)
if existing is not None:
return existing
remote_prs = gitea_api.list_pull_requests_sync(owner=owner, repo=repo_name, state='open')
if isinstance(remote_prs, list):
for item in remote_prs:
remote_head = ((item.get('head') or {}) if isinstance(item.get('head'), dict) else {})
if remote_head.get('ref') != branch:
continue
pr = self.save_pr_data(
history.id,
{
'pr_number': item.get('number') or item.get('id') or 0,
'title': item.get('title') or f"AI delivery for {history.project_name}",
'body': item.get('body') or '',
'state': item.get('state', 'open'),
'base': ((item.get('base') or {}) if isinstance(item.get('base'), dict) else {}).get('ref', 'main'),
'user': ((item.get('user') or {}) if isinstance(item.get('user'), dict) else {}).get('login', 'system'),
'pr_url': item.get('html_url') or gitea_api.build_pull_request_url(item.get('number') or item.get('id'), owner=owner, repo=repo_name),
'merged': bool(item.get('merged')),
'head': remote_head.get('ref'),
},
)
return {
'pr_number': pr.pr_number,
'title': pr.pr_title,
'body': pr.pr_body,
'pr_url': pr.pr_url,
'pr_state': pr.pr_state,
'merged': pr.merged,
}
title = f"AI delivery for {history.project_name}"
body = (
f"Automated software factory changes for {history.project_name}.\n\n"
f"Prompt: {prompt_text or history.description}\n\n"
f"Branch: {branch}"
)
created = gitea_api.create_pull_request_sync(
title=title,
body=body,
owner=owner,
repo=repo_name,
base='main',
head=branch,
)
if created.get('error'):
raise RuntimeError(f"Unable to create pull request: {created.get('error')}")
pr = self.save_pr_data(
history.id,
{
'pr_number': created.get('number') or created.get('id') or 0,
'title': created.get('title', title),
'body': created.get('body', body),
'state': created.get('state', 'open'),
'base': ((created.get('base') or {}) if isinstance(created.get('base'), dict) else {}).get('ref', 'main'),
'user': ((created.get('user') or {}) if isinstance(created.get('user'), dict) else {}).get('login', 'system'),
'pr_url': created.get('html_url') or gitea_api.build_pull_request_url(created.get('number') or created.get('id'), owner=owner, repo=repo_name),
'merged': bool(created.get('merged')),
'head': branch,
},
)
return {
'pr_number': pr.pr_number,
'title': pr.pr_title,
'body': pr.pr_body,
'pr_url': pr.pr_url,
'pr_state': pr.pr_state,
'merged': pr.merged,
}
def retry_project_delivery(self, project_id: str) -> dict:
"""Retry remote delivery for orphaned, local-only, or missing-PR project changes."""
history = self.get_project_by_id(project_id)
if history is None:
return {'status': 'error', 'message': 'Project not found'}
audit_data = self.get_project_audit_data(project_id)
project = audit_data.get('project') or {}
delivery_status = project.get('delivery_status')
if delivery_status not in {'uncommitted', 'local_only', 'pushed_no_pr'}:
return {'status': 'success', 'message': 'No failed delivery state was found for this project.', 'project_id': project_id}
snapshot_data = self._get_latest_ui_snapshot_data(history.id)
repository = self._get_project_repository(history) or {}
if repository.get('mode') != 'project':
return {'status': 'error', 'message': 'Only project-scoped repositories support delivery retry.', 'project_id': project_id}
owner = repository.get('owner') or settings.gitea_owner
repo_name = repository.get('name') or settings.gitea_repo
if not owner or not repo_name or not settings.gitea_url or not settings.gitea_token:
return {'status': 'error', 'message': 'Gitea repository settings are incomplete; cannot retry delivery.', 'project_id': project_id}
project_root = Path(snapshot_data.get('project_root') or (settings.projects_root / project_id)).expanduser().resolve()
if not project_root.exists():
return {'status': 'error', 'message': f'Project workspace does not exist at {project_root}', 'project_id': project_id}
try:
from .git_manager import GitManager
from .gitea import GiteaAPI
except ImportError:
from agents.git_manager import GitManager
from agents.gitea import GiteaAPI
git_manager = GitManager(project_id=project_id, project_dir=str(project_root))
if not git_manager.is_git_available():
return {'status': 'error', 'message': 'git executable is not available in PATH', 'project_id': project_id}
if not git_manager.has_repo():
return {'status': 'error', 'message': 'Local git repository is missing; cannot retry delivery safely.', 'project_id': project_id}
commits = audit_data.get('commits', [])
local_only_changes = audit_data.get('local_only_code_changes', [])
orphan_changes = audit_data.get('orphan_code_changes', [])
published_commits = [
commit for commit in commits
if commit.get('remote_status') == 'pushed' or commit.get('imported_from_remote') or commit.get('commit_url')
]
branch_candidates = [
*(change.get('branch') for change in local_only_changes),
*(change.get('branch') for change in orphan_changes),
*(commit.get('branch') for commit in commits),
((snapshot_data.get('git') or {}).get('active_branch') if isinstance(snapshot_data.get('git'), dict) else None),
f'ai/{project_id}',
]
branch = self._dedupe_preserve_order(branch_candidates)[0]
head = git_manager.current_head_or_none()
if head is None:
return {'status': 'error', 'message': 'Local repository has no commits; retry delivery cannot determine a safe base commit.', 'project_id': project_id}
if git_manager.branch_exists(branch):
git_manager.checkout_branch(branch)
else:
git_manager.checkout_branch(branch, create=True, start_point=head)
code_change_ids = [change['id'] for change in local_only_changes] + [change['id'] for change in orphan_changes]
orphan_ids = [change['id'] for change in orphan_changes]
published_commit_hashes = [commit.get('commit_hash') for commit in published_commits if commit.get('commit_hash')]
if orphan_changes:
files_to_commit = self._dedupe_preserve_order([change.get('file_path') for change in orphan_changes])
missing_files = [path for path in files_to_commit if not (project_root / path).exists()]
if missing_files:
return {
'status': 'error',
'message': f"Cannot retry delivery because generated files are missing locally: {', '.join(missing_files)}",
'project_id': project_id,
}
git_manager.add_files(files_to_commit)
if not git_manager.get_status():
return {
'status': 'error',
'message': 'No local git changes remain for the orphaned files; purge them or regenerate the project.',
'project_id': project_id,
}
commit_message = f"Retry AI delivery for prompt: {history.project_name}"
retried_commit_hash = git_manager.commit(commit_message)
prompt_id = max((change.get('prompt_id') for change in orphan_changes if change.get('prompt_id') is not None), default=None)
self.log_commit(
project_id=project_id,
commit_message=commit_message,
actor='dashboard',
actor_type='operator',
history_id=history.id,
prompt_id=prompt_id,
commit_hash=retried_commit_hash,
changed_files=files_to_commit,
branch=branch,
remote_status='local-only',
)
published_commit_hashes.append(retried_commit_hash)
gitea_api = GiteaAPI(token=settings.gitea_token, base_url=settings.gitea_url, owner=owner, repo=repo_name)
user = gitea_api.get_current_user_sync()
if user.get('error'):
return {'status': 'error', 'message': f"Unable to authenticate with Gitea: {user.get('error')}", 'project_id': project_id}
clone_url = repository.get('clone_url') or gitea_api.build_repo_git_url(owner=owner, repo=repo_name)
if not clone_url:
return {'status': 'error', 'message': 'Repository clone URL could not be determined for retry delivery.', 'project_id': project_id}
try:
git_manager.push_with_credentials(
remote_url=clone_url,
username=user.get('login') or 'git',
password=settings.gitea_token,
remote='origin',
branch=branch,
)
except Exception as exc:
self.log_system_event(component='git', level='ERROR', message=f'Retry delivery push failed for {project_id}: {exc}')
return {'status': 'error', 'message': f'Remote git push failed: {exc}', 'project_id': project_id}
if not published_commit_hashes:
head_commit = git_manager.current_head_or_none()
if head_commit:
published_commit_hashes.append(head_commit)
prompt_text = (audit_data.get('prompts') or [{}])[0].get('prompt_text') if audit_data.get('prompts') else None
try:
pull_request = self._find_or_create_delivery_pull_request(history, gitea_api, owner, repo_name, branch, prompt_text)
except Exception as exc:
self.log_system_event(component='gitea', level='ERROR', message=f'Retry delivery PR creation failed for {project_id}: {exc}')
return {'status': 'error', 'message': str(exc), 'project_id': project_id}
self._update_project_audit_rows_for_delivery(
project_id=project_id,
branch=branch,
owner=owner,
repo_name=repo_name,
code_change_ids=code_change_ids,
orphan_code_change_ids=orphan_ids,
published_commit_hashes=published_commit_hashes,
)
refreshed_snapshot = dict(snapshot_data)
refreshed_git = dict(refreshed_snapshot.get('git') or {})
latest_commit_hash = self._dedupe_preserve_order(published_commit_hashes)[-1]
latest_commit = dict(refreshed_git.get('latest_commit') or {})
latest_commit.update(
{
'hash': latest_commit_hash,
'scope': 'remote',
'branch': branch,
'commit_url': gitea_api.build_commit_url(latest_commit_hash, owner=owner, repo=repo_name),
}
)
refreshed_git['latest_commit'] = latest_commit
refreshed_git['active_branch'] = branch
refreshed_git['remote_error'] = None
refreshed_git['remote_push'] = {
'status': 'pushed',
'remote': clone_url,
'branch': branch,
'commit_url': latest_commit.get('commit_url'),
'pull_request': pull_request,
}
refreshed_snapshot['git'] = refreshed_git
refreshed_repository = dict(repository)
refreshed_repository['last_commit_url'] = latest_commit.get('commit_url')
refreshed_snapshot['repository'] = refreshed_repository
refreshed_snapshot['pull_request'] = pull_request
refreshed_snapshot['project_root'] = str(project_root)
self.save_ui_snapshot(history.id, refreshed_snapshot)
self._log_audit_trail(
project_id=project_id,
action='DELIVERY_RETRIED',
actor='dashboard',
action_type='RETRY',
details=f'Retried remote delivery for branch {branch}',
message='Remote delivery retried successfully',
metadata_json={
'history_id': history.id,
'branch': branch,
'commit_hashes': self._dedupe_preserve_order(published_commit_hashes),
'pull_request': pull_request,
},
)
self.log_system_event(component='git', level='INFO', message=f'Retried remote delivery for {project_id} on {branch}')
return {
'status': 'success',
'message': 'Remote delivery retried successfully.',
'project_id': project_id,
'branch': branch,
'commit_hashes': self._dedupe_preserve_order(published_commit_hashes),
'pull_request': pull_request,
}
def cleanup_orphan_code_changes(self, project_id: str | None = None) -> dict:
"""Delete code change rows that cannot be tied to any recorded commit."""
change_query = self.db.query(AuditTrail).filter(AuditTrail.action == 'CODE_CHANGE')
@@ -2493,7 +2879,7 @@ class DatabaseManager:
}
for change in change_rows
]
_, orphaned_changes = self._partition_code_changes(raw_code_changes, commits)
_, _, orphaned_changes = self._partition_code_changes(raw_code_changes, commits)
orphan_ids = [change['id'] for change in orphaned_changes]
orphan_projects = sorted({change['project_id'] for change in orphaned_changes if change.get('project_id')})

View File

@@ -230,6 +230,26 @@ class GiteaAPI:
}
return await self._request("POST", f"repos/{_owner}/{_repo}/pulls", payload)
def create_pull_request_sync(
self,
title: str,
body: str,
owner: str,
repo: str,
base: str = "main",
head: str | None = None,
) -> dict:
"""Synchronously create a pull request."""
_owner = owner or self.owner
_repo = repo or self.repo
payload = {
"title": title,
"body": body,
"base": base,
"head": head or f"{_owner}-{_repo}-ai-gen-{hash(title) % 10000}",
}
return self._request_sync("POST", f"repos/{_owner}/{_repo}/pulls", payload)
async def list_pull_requests(
self,
owner: str | None = None,
@@ -402,3 +422,13 @@ class GiteaAPI:
return {"error": "Repository name required for org operations"}
return await self._request("GET", f"repos/{_owner}/{_repo}")
def get_repo_info_sync(self, owner: str | None = None, repo: str | None = None) -> dict:
"""Synchronously get repository information."""
_owner = owner or self.owner
_repo = repo or self.repo
if not _repo:
return {"error": "Repository name required for org operations"}
return self._request_sync("GET", f"repos/{_owner}/{_repo}")

View File

@@ -1194,6 +1194,16 @@ def create_dashboard():
ui.notify(result.get('message', 'Audit cleanup completed'), color='positive')
_refresh_all_dashboard_sections()
def retry_project_delivery_action(project_id: str) -> None:
db = get_db_sync()
if db is None:
ui.notify('Database session could not be created', color='negative')
return
with closing(db):
result = DatabaseManager(db).retry_project_delivery(project_id)
ui.notify(result.get('message', 'Delivery retry completed'), color='positive' if result.get('status') == 'success' else 'negative')
_refresh_all_dashboard_sections()
def save_llm_prompt_action(prompt_key: str) -> None:
db = get_db_sync()
if db is None:
@@ -1472,11 +1482,15 @@ def create_dashboard():
with ui.row().classes('justify-between items-center'):
ui.label(project['project_name']).style('font-weight: 700; color: #2f241d;')
with ui.row().classes('items-center gap-2'):
if project.get('delivery_status') == 'uncommitted':
ui.label('uncommitted delivery').classes('factory-chip')
if project.get('delivery_status') in {'uncommitted', 'local_only', 'pushed_no_pr'}:
ui.label(project.get('delivery_status', 'delivery')).classes('factory-chip')
ui.label(project['status']).classes('factory-chip')
ui.linear_progress(value=(project['progress'] or 0) / 100, show_value=False).classes('w-full')
ui.label(project.get('delivery_message') if project.get('delivery_status') == 'uncommitted' else project['message'] or 'No status message').classes('factory-muted')
ui.label(
project.get('delivery_message')
if project.get('delivery_status') in {'uncommitted', 'local_only', 'pushed_no_pr'}
else project['message'] or 'No status message'
).classes('factory-muted')
else:
ui.label('No projects in the database yet.').classes('factory-muted')
@@ -1532,22 +1546,28 @@ def create_dashboard():
lambda: delete_project_action(project_id),
),
).props('outline color=negative')
if project.get('delivery_status') == 'uncommitted':
if project.get('delivery_status') in {'uncommitted', 'local_only', 'pushed_no_pr'}:
with ui.card().classes('q-ma-md q-pa-md').style('background: #fff4dd; border: 1px solid #e0b36a;'):
with ui.row().classes('items-center justify-between w-full gap-3'):
with ui.column().classes('gap-1'):
ui.label('Uncommitted delivery detected').style('font-weight: 700; color: #7a4b16;')
ui.label(project.get('delivery_message') or 'Generated changes were recorded without a matching commit.').classes('factory-muted')
ui.button(
'Purge project orphan rows',
on_click=lambda _=None, project_id=project['project_id']: _render_confirmation_dialog(
'Purge orphaned generated change rows for this project?',
'Delete only generated CODE_CHANGE audit rows for this project that have no matching git commit. Valid history remains intact.',
'Purge Project Orphans',
lambda: purge_orphan_code_changes_action(project_id),
color='warning',
),
).props('outline color=warning')
ui.label('Remote delivery attention needed').style('font-weight: 700; color: #7a4b16;')
ui.label(project.get('delivery_message') or 'Generated changes were not published to the tracked repository.').classes('factory-muted')
with ui.row().classes('items-center gap-2'):
ui.button(
'Retry delivery',
on_click=lambda _=None, project_id=project['project_id']: retry_project_delivery_action(project_id),
).props('outline color=positive')
if project.get('delivery_status') == 'uncommitted':
ui.button(
'Purge project orphan rows',
on_click=lambda _=None, project_id=project['project_id']: _render_confirmation_dialog(
'Purge orphaned generated change rows for this project?',
'Delete only generated CODE_CHANGE audit rows for this project that have no matching git commit. Valid history remains intact.',
'Purge Project Orphans',
lambda: purge_orphan_code_changes_action(project_id),
color='warning',
),
).props('outline color=warning')
with ui.grid(columns=2).classes('w-full gap-4 q-pa-md'):
with ui.card().classes('q-pa-md'):
ui.label('Repository').style('font-weight: 700; color: #3a281a;')
@@ -1598,20 +1618,26 @@ def create_dashboard():
lambda: delete_project_action(project_id),
),
).props('outline color=negative')
if project.get('delivery_status') == 'uncommitted':
if project.get('delivery_status') in {'uncommitted', 'local_only', 'pushed_no_pr'}:
with ui.card().classes('q-ma-md q-pa-md').style('background: #fff4dd; border: 1px solid #e0b36a;'):
ui.label('Archived project contains uncommitted generated change rows').style('font-weight: 700; color: #7a4b16;')
ui.label(project.get('delivery_message') or 'Generated changes were recorded without a matching commit.').classes('factory-muted')
ui.button(
'Purge archived project orphan rows',
on_click=lambda _=None, project_id=project['project_id']: _render_confirmation_dialog(
'Purge orphaned generated change rows for this archived project?',
'Delete only generated CODE_CHANGE audit rows for this project that have no matching git commit. Valid history remains intact.',
'Purge Archived Orphans',
lambda: purge_orphan_code_changes_action(project_id),
color='warning',
),
).props('outline color=warning').classes('q-mt-sm')
ui.label('Archived project needs delivery attention').style('font-weight: 700; color: #7a4b16;')
ui.label(project.get('delivery_message') or 'Generated changes were not published to the tracked repository.').classes('factory-muted')
with ui.row().classes('items-center gap-2 q-mt-sm'):
ui.button(
'Retry delivery',
on_click=lambda _=None, project_id=project['project_id']: retry_project_delivery_action(project_id),
).props('outline color=positive')
if project.get('delivery_status') == 'uncommitted':
ui.button(
'Purge archived project orphan rows',
on_click=lambda _=None, project_id=project['project_id']: _render_confirmation_dialog(
'Purge orphaned generated change rows for this archived project?',
'Delete only generated CODE_CHANGE audit rows for this project that have no matching git commit. Valid history remains intact.',
'Purge Archived Orphans',
lambda: purge_orphan_code_changes_action(project_id),
color='warning',
),
).props('outline color=warning')
with ui.grid(columns=2).classes('w-full gap-4 q-pa-md'):
with ui.card().classes('q-pa-md'):
ui.label('Repository').style('font-weight: 700; color: #3a281a;')