Compare commits
2 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| b0c95323fd | |||
| d60e753acf |
11
HISTORY.md
11
HISTORY.md
@@ -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)
|
||||
------------------
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -1 +1 @@
|
||||
0.9.3
|
||||
0.9.4
|
||||
|
||||
@@ -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')})
|
||||
|
||||
|
||||
@@ -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,
|
||||
@@ -401,4 +421,14 @@ class GiteaAPI:
|
||||
if not _repo:
|
||||
return {"error": "Repository name required for org operations"}
|
||||
|
||||
return await self._request("GET", f"repos/{_owner}/{_repo}")
|
||||
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}")
|
||||
@@ -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;')
|
||||
|
||||
Reference in New Issue
Block a user