chore: commit remaining working changes

Backend:
- agent: confirmation, persistence, app, langgraph_setup updates
- routes: agent_superset_explore, environments, git helpers/operations
- services: git sync refactoring
- tests: git_status_route expanded

Frontend:
- Navbar: minor cleanup
- Profile: i18n (en/ru), page enhancements, integration tests
- New: _llm_params.py
This commit is contained in:
2026-07-05 09:24:45 +03:00
parent b773a06d52
commit 45e781fb74
17 changed files with 1030 additions and 203 deletions

View File

@@ -9,6 +9,12 @@ from unittest.mock import MagicMock
from fastapi import HTTPException
from src.api.routes import git as git_routes
from src.api.routes.git import _helpers as git_helpers
from src.services.git._sync import GitServiceSyncMixin
async def _resolved_dashboard_id(*_args, **_kwargs):
return 12
# #region test_get_repository_status_returns_no_repo_payload_for_missing_repo [TYPE Function]
@@ -18,9 +24,9 @@ from src.api.routes import git as git_routes
# @POST Route returns a deterministic NO_REPO status payload.
def test_get_repository_status_returns_no_repo_payload_for_missing_repo(monkeypatch):
class MissingRepoGitService:
def _get_repo_path(self, dashboard_id: int) -> str:
async def _get_repo_path(self, dashboard_id: int) -> str:
return f"/tmp/missing-repo-{dashboard_id}"
def get_status(self, dashboard_id: int) -> dict:
async def get_status(self, dashboard_id: int) -> dict:
raise AssertionError("get_status must not be called when repository path is missing")
monkeypatch.setattr(git_routes, "git_service", MissingRepoGitService())
response = asyncio.run(git_routes.get_repository_status(34))
@@ -36,9 +42,9 @@ def test_get_repository_status_returns_no_repo_payload_for_missing_repo(monkeypa
# @POST Raised exception preserves original status and detail.
def test_get_repository_status_propagates_non_404_http_exception(monkeypatch):
class ConflictGitService:
def _get_repo_path(self, dashboard_id: int) -> str:
async def _get_repo_path(self, dashboard_id: int) -> str:
return f"/tmp/existing-repo-{dashboard_id}"
def get_status(self, dashboard_id: int) -> dict:
async def get_status(self, dashboard_id: int) -> dict:
raise HTTPException(status_code=409, detail="Conflict")
monkeypatch.setattr(git_routes, "git_service", ConflictGitService())
monkeypatch.setattr(git_routes.os.path, "exists", lambda _path: True)
@@ -54,7 +60,7 @@ def test_get_repository_status_propagates_non_404_http_exception(monkeypatch):
# @POST Endpoint raises same HTTPException values.
def test_get_repository_diff_propagates_http_exception(monkeypatch):
class DiffGitService:
def get_diff(self, dashboard_id: int, file_path=None, staged: bool = False) -> str:
async def get_diff(self, dashboard_id: int, file_path=None, staged: bool = False) -> str:
raise HTTPException(status_code=404, detail="Repository missing")
monkeypatch.setattr(git_routes, "git_service", DiffGitService())
with pytest.raises(HTTPException) as exc_info:
@@ -69,7 +75,7 @@ def test_get_repository_diff_propagates_http_exception(monkeypatch):
# @POST Endpoint returns HTTPException with status 500 and route context.
def test_get_history_wraps_unexpected_error_as_500(monkeypatch):
class HistoryGitService:
def get_commit_history(self, dashboard_id: int, limit: int = 50):
async def get_commit_history(self, dashboard_id: int, limit: int = 50):
raise ValueError("broken parser")
monkeypatch.setattr(git_routes, "git_service", HistoryGitService())
with pytest.raises(HTTPException) as exc_info:
@@ -84,7 +90,7 @@ def test_get_history_wraps_unexpected_error_as_500(monkeypatch):
# @POST Endpoint raises HTTPException(500) with route context.
def test_commit_changes_wraps_unexpected_error_as_500(monkeypatch):
class CommitGitService:
def commit_changes(self, dashboard_id: int, message: str, files):
async def commit_changes(self, dashboard_id: int, message: str, files):
raise RuntimeError("index lock")
class CommitPayload:
message = "test"
@@ -102,9 +108,9 @@ def test_commit_changes_wraps_unexpected_error_as_500(monkeypatch):
# @POST Returned map includes resolved status for each requested dashboard ID.
def test_get_repository_status_batch_returns_mixed_statuses(monkeypatch):
class BatchGitService:
def _get_repo_path(self, dashboard_id: int) -> str:
async def _get_repo_path(self, dashboard_id: int) -> str:
return f"/tmp/repo-{dashboard_id}"
def get_status(self, dashboard_id: int) -> dict:
async def get_status(self, dashboard_id: int) -> dict:
if dashboard_id == 2:
return {"sync_state": "SYNCED", "sync_status": "OK"}
raise HTTPException(status_code=404, detail="not found")
@@ -123,9 +129,9 @@ def test_get_repository_status_batch_returns_mixed_statuses(monkeypatch):
# @POST Failed dashboard status is marked as ERROR.
def test_get_repository_status_batch_marks_item_as_error_on_service_failure(monkeypatch):
class BatchErrorGitService:
def _get_repo_path(self, dashboard_id: int) -> str:
async def _get_repo_path(self, dashboard_id: int) -> str:
return f"/tmp/repo-{dashboard_id}"
def get_status(self, dashboard_id: int) -> dict:
async def get_status(self, dashboard_id: int) -> dict:
raise RuntimeError("boom")
monkeypatch.setattr(git_routes, "git_service", BatchErrorGitService())
monkeypatch.setattr(git_routes.os.path, "exists", lambda _path: True)
@@ -142,9 +148,9 @@ def test_get_repository_status_batch_marks_item_as_error_on_service_failure(monk
# @POST Result contains unique IDs up to configured cap.
def test_get_repository_status_batch_deduplicates_and_truncates_ids(monkeypatch):
class SafeBatchGitService:
def _get_repo_path(self, dashboard_id: int) -> str:
async def _get_repo_path(self, dashboard_id: int) -> str:
return f"/tmp/repo-{dashboard_id}"
def get_status(self, dashboard_id: int) -> dict:
async def get_status(self, dashboard_id: int) -> dict:
return {"sync_state": "SYNCED", "sync_status": "OK"}
monkeypatch.setattr(git_routes, "git_service", SafeBatchGitService())
monkeypatch.setattr(git_routes.os.path, "exists", lambda _path: True)
@@ -164,9 +170,9 @@ def test_commit_changes_applies_profile_identity_before_commit(monkeypatch):
def __init__(self):
self.configured_identity = None
self.commit_payload = None
def configure_identity(self, dashboard_id: int, git_username: str, git_email: str):
async def configure_identity(self, dashboard_id: int, git_username: str, git_email: str):
self.configured_identity = (dashboard_id, git_username, git_email)
def commit_changes(self, dashboard_id: int, message: str, files):
async def commit_changes(self, dashboard_id: int, message: str, files):
self.commit_payload = (dashboard_id, message, files)
class PreferenceRow:
git_username = "user_1"
@@ -189,7 +195,7 @@ def test_commit_changes_applies_profile_identity_before_commit(monkeypatch):
monkeypatch.setattr(
git_routes,
"_resolve_dashboard_id_from_ref",
lambda *_args, **_kwargs: 12,
_resolved_dashboard_id,
)
asyncio.run(
git_routes.commit_changes(
@@ -213,10 +219,10 @@ def test_pull_changes_applies_profile_identity_before_pull(monkeypatch):
def __init__(self):
self.configured_identity = None
self.pulled_dashboard_id = None
def configure_identity(self, dashboard_id: int, git_username: str, git_email: str):
async def configure_identity(self, dashboard_id: int, git_username: str, git_email: str):
self.configured_identity = (dashboard_id, git_username, git_email)
def pull_changes(self, dashboard_id: int):
self.pulled_dashboard_id = dashboard_id
async def pull_changes(self, dashboard_id: int, pat: str | None = None):
self.pulled_payload = (dashboard_id, pat)
class PreferenceRow:
git_username = "user_1"
git_email = "user1@mail.ru"
@@ -235,7 +241,7 @@ def test_pull_changes_applies_profile_identity_before_pull(monkeypatch):
monkeypatch.setattr(
git_routes,
"_resolve_dashboard_id_from_ref",
lambda *_args, **_kwargs: 12,
_resolved_dashboard_id,
)
asyncio.run(
git_routes.pull_changes(
@@ -246,8 +252,127 @@ def test_pull_changes_applies_profile_identity_before_pull(monkeypatch):
)
)
assert identity_service.configured_identity == (12, "user_1", "user1@mail.ru")
assert identity_service.pulled_dashboard_id == 12
assert identity_service.pulled_payload == (12, None)
# #endregion test_pull_changes_applies_profile_identity_before_pull
# #region test_push_changes_passes_decrypted_profile_pat [TYPE Function]
# @RELATION BINDS_TO -> TestGitStatusRoute
# @BRIEF Ensure push route decrypts current user's profile PAT and passes it to GitService.
# @PRE Profile preference contains encrypted git_personal_access_token.
# @POST git_service.push_changes receives the decrypted token via pat keyword.
def test_push_changes_passes_decrypted_profile_pat(monkeypatch):
class PushGitService:
def __init__(self):
self.pushed_payload = None
async def push_changes(self, dashboard_id: int, pat: str | None = None):
self.pushed_payload = (dashboard_id, pat)
class PreferenceRow:
git_personal_access_token_encrypted = "encrypted-token"
class PreferenceQuery:
def filter(self, *_args, **_kwargs):
return self
def first(self):
return PreferenceRow()
class DbStub:
def query(self, _model):
return PreferenceQuery()
class UserStub:
id = "u-1"
class EncryptionStub:
def decrypt(self, encrypted_data: str) -> str:
assert encrypted_data == "encrypted-token"
return "profile-pat"
push_service = PushGitService()
monkeypatch.setattr(git_routes, "git_service", push_service)
monkeypatch.setattr(git_helpers, "EncryptionManager", lambda: EncryptionStub())
monkeypatch.setattr(git_routes, "_resolve_dashboard_id_from_ref", _resolved_dashboard_id)
asyncio.run(
git_routes.push_changes(
"dashboard-12",
config_manager=MagicMock(),
db=DbStub(),
current_user=UserStub(),
)
)
assert push_service.pushed_payload == (12, "profile-pat")
# #endregion test_push_changes_passes_decrypted_profile_pat
# #region test_pull_changes_passes_decrypted_profile_pat [TYPE Function]
# @RELATION BINDS_TO -> TestGitStatusRoute
# @BRIEF Ensure pull route decrypts current user's profile PAT and passes it to GitService.
# @PRE Profile preference contains git identity and encrypted PAT.
# @POST git_service.pull_changes receives decrypted token after identity configuration.
def test_pull_changes_passes_decrypted_profile_pat(monkeypatch):
class PullGitService:
def __init__(self):
self.configured_identity = None
self.pulled_payload = None
async def configure_identity(self, dashboard_id: int, git_username: str, git_email: str):
self.configured_identity = (dashboard_id, git_username, git_email)
async def pull_changes(self, dashboard_id: int, pat: str | None = None):
self.pulled_payload = (dashboard_id, pat)
class PreferenceRow:
git_username = "user_1"
git_email = "user1@mail.ru"
git_personal_access_token_encrypted = "encrypted-token"
class PreferenceQuery:
def filter(self, *_args, **_kwargs):
return self
def first(self):
return PreferenceRow()
class DbStub:
def query(self, _model):
return PreferenceQuery()
class UserStub:
id = "u-1"
class EncryptionStub:
def decrypt(self, encrypted_data: str) -> str:
assert encrypted_data == "encrypted-token"
return "profile-pat"
pull_service = PullGitService()
monkeypatch.setattr(git_routes, "git_service", pull_service)
monkeypatch.setattr(git_helpers, "EncryptionManager", lambda: EncryptionStub())
monkeypatch.setattr(git_routes, "_resolve_dashboard_id_from_ref", _resolved_dashboard_id)
asyncio.run(
git_routes.pull_changes(
"dashboard-12",
config_manager=MagicMock(),
db=DbStub(),
current_user=UserStub(),
)
)
assert pull_service.configured_identity == (12, "user_1", "user1@mail.ru")
assert pull_service.pulled_payload == (12, "profile-pat")
# #endregion test_pull_changes_passes_decrypted_profile_pat
# #region test_git_sync_mixin_embeds_and_redacts_pat [TYPE Function]
# @RELATION BINDS_TO -> TestGitStatusRoute
# @BRIEF Ensure PAT embedding URL-encodes secrets and redaction removes them from messages.
# @PRE Origin remote uses an HTTPS URL and PAT contains URL-sensitive characters.
# @POST Origin URL receives encoded PAT; redaction hides raw and URL-shaped tokens.
def test_git_sync_mixin_embeds_and_redacts_pat():
class OriginStub:
def __init__(self):
self.urls = ["https://github.com/org/repo.git"]
self.set_urls = []
def set_url(self, url: str):
self.set_urls.append(url)
self.urls = [url]
class RepoStub:
def __init__(self):
self.origin = OriginStub()
def remote(self, name: str):
assert name == "origin"
return self.origin
repo = RepoStub()
original_url = GitServiceSyncMixin._embed_pat_in_origin_url(repo, "tok@:/secret")
assert original_url == "https://github.com/org/repo.git"
assert repo.origin.set_urls == ["https://git-user:tok%40%3A%2Fsecret@github.com/org/repo.git"]
redacted = GitServiceSyncMixin._redact_pat_from_message(
"fatal https://git-user:tok%40%3A%2Fsecret@github.com/org/repo.git tok@:/secret",
"tok@:/secret",
)
assert "tok@:/secret" not in redacted
assert "tok%40%3A%2Fsecret" not in redacted
assert "***" in redacted
# #endregion test_git_sync_mixin_embeds_and_redacts_pat
# #region test_get_merge_status_returns_service_payload [TYPE Function]
# @RELATION BINDS_TO -> TestGitStatusRoute
# @BRIEF Ensure merge status route returns service payload as-is.
@@ -255,7 +380,7 @@ def test_pull_changes_applies_profile_identity_before_pull(monkeypatch):
# @POST Route response contains has_unfinished_merge=True.
def test_get_merge_status_returns_service_payload(monkeypatch):
class MergeStatusGitService:
def get_merge_status(self, dashboard_id: int) -> dict:
async def get_merge_status(self, dashboard_id: int) -> dict:
return {
"has_unfinished_merge": True,
"repository_path": "/tmp/repo-12",
@@ -266,7 +391,7 @@ def test_get_merge_status_returns_service_payload(monkeypatch):
"conflicts_count": 2,
}
monkeypatch.setattr(git_routes, "git_service", MergeStatusGitService())
monkeypatch.setattr(git_routes, "_resolve_dashboard_id_from_ref", lambda *_args, **_kwargs: 12)
monkeypatch.setattr(git_routes, "_resolve_dashboard_id_from_ref", _resolved_dashboard_id)
response = asyncio.run(
git_routes.get_merge_status(
"dashboard-12",
@@ -284,7 +409,7 @@ def test_get_merge_status_returns_service_payload(monkeypatch):
def test_resolve_merge_conflicts_passes_resolution_items_to_service(monkeypatch):
captured = {}
class MergeResolveGitService:
def resolve_merge_conflicts(self, dashboard_id: int, resolutions):
async def resolve_merge_conflicts(self, dashboard_id: int, resolutions):
captured["dashboard_id"] = dashboard_id
captured["resolutions"] = resolutions
return ["dashboards/a.yaml"]
@@ -294,7 +419,7 @@ def test_resolve_merge_conflicts_passes_resolution_items_to_service(monkeypatch)
return {"file_path": "dashboards/a.yaml", "resolution": "mine", "content": None}
resolutions = [_Resolution()]
monkeypatch.setattr(git_routes, "git_service", MergeResolveGitService())
monkeypatch.setattr(git_routes, "_resolve_dashboard_id_from_ref", lambda *_args, **_kwargs: 12)
monkeypatch.setattr(git_routes, "_resolve_dashboard_id_from_ref", _resolved_dashboard_id)
response = asyncio.run(
git_routes.resolve_merge_conflicts(
"dashboard-12",
@@ -313,11 +438,11 @@ def test_resolve_merge_conflicts_passes_resolution_items_to_service(monkeypatch)
# @POST Route returns aborted status.
def test_abort_merge_calls_service_and_returns_result(monkeypatch):
class AbortGitService:
def abort_merge(self, dashboard_id: int):
async def abort_merge(self, dashboard_id: int):
assert dashboard_id == 12
return {"status": "aborted"}
monkeypatch.setattr(git_routes, "git_service", AbortGitService())
monkeypatch.setattr(git_routes, "_resolve_dashboard_id_from_ref", lambda *_args, **_kwargs: 12)
monkeypatch.setattr(git_routes, "_resolve_dashboard_id_from_ref", _resolved_dashboard_id)
response = asyncio.run(
git_routes.abort_merge(
"dashboard-12",
@@ -333,14 +458,14 @@ def test_abort_merge_calls_service_and_returns_result(monkeypatch):
# @POST Route returns committed status and hash.
def test_continue_merge_passes_message_and_returns_commit(monkeypatch):
class ContinueGitService:
def continue_merge(self, dashboard_id: int, message: str):
async def continue_merge(self, dashboard_id: int, message: str):
assert dashboard_id == 12
assert message == "Resolve all conflicts"
return {"status": "committed", "commit_hash": "abc123"}
class ContinueData:
message = "Resolve all conflicts"
monkeypatch.setattr(git_routes, "git_service", ContinueGitService())
monkeypatch.setattr(git_routes, "_resolve_dashboard_id_from_ref", lambda *_args, **_kwargs: 12)
monkeypatch.setattr(git_routes, "_resolve_dashboard_id_from_ref", _resolved_dashboard_id)
response = asyncio.run(
git_routes.continue_merge(
"dashboard-12",

View File

@@ -17,11 +17,13 @@ router = APIRouter(prefix="/api/agent/superset", tags=["Agent Superset"])
async def _get_superset_client(environment_id: str) -> SupersetClient:
"""Resolve a SupersetClient for the given environment_id."""
from ..api.routes.environments import _get_environment_by_id as _resolve_env
env_data = await _resolve_env(environment_id)
if not env_data:
from src.dependencies import get_config_manager
env = get_config_manager().get_environment(environment_id)
if env is None:
raise HTTPException(status_code=404, detail=f"Environment '{environment_id}' not found.")
env = Environment(**env_data)
if not isinstance(env, Environment):
env = Environment.model_validate(env)
return SupersetClient(env)

View File

@@ -56,6 +56,25 @@ class DatabaseResponse(BaseModel):
engine: str | None
# #endregion DatabaseResponse
# #region _get_environment_by_id [TYPE Function]
# @BRIEF Legacy resolver for agent Superset routes that still import environment by id.
# @PRE Environment id can be an id or configured environment name.
# @POST Returns full Environment-compatible dict or None when missing.
# @RATIONALE Keeps hot-reloaded/stale agent route code from failing while the direct
# ConfigManager resolver path is deployed in agent_superset_explore.py.
async def _get_environment_by_id(environment_id: str) -> dict | None:
env = get_config_manager().get_environment(environment_id)
if env is None:
return None
if hasattr(env, "model_dump"):
return env.model_dump()
if hasattr(env, "dict"):
return env.dict()
return dict(env)
# #endregion _get_environment_by_id
# #region get_environments [TYPE Function] [SEMANTICS list, environments, config]
# @ingroup Api
# @BRIEF List all configured environments.

View File

@@ -11,6 +11,7 @@ import os
from fastapi import HTTPException
from sqlalchemy.orm import Session
from src.core.encryption import EncryptionManager
from src.core.logger import logger
from src.core.superset_client import SupersetClient
from src.models.auth import User
@@ -313,6 +314,51 @@ def _resolve_current_user_git_identity(
# #endregion _resolve_current_user_git_identity
# #region _resolve_current_user_git_token [C:2] [TYPE Function]
# @BRIEF Resolve and decrypt the Git personal access token from current user's profile preferences.
def _resolve_current_user_git_token(
db: Session,
current_user: User | None,
) -> str | None:
if db is None or not hasattr(db, "query"):
return None
user_id = _sanitize_optional_identity_value(getattr(current_user, "id", None))
if not user_id:
return None
try:
preference = (
db.query(UserDashboardPreference)
.filter(UserDashboardPreference.user_id == user_id)
.first()
)
except Exception as resolve_error:
logger.explore(
"Failed to load profile preference for resolving git PAT",
extra={"src": "_resolve_current_user_git_token", "payload": {"user_id": user_id}, "error": str(resolve_error)},
)
return None
if not preference:
return None
encrypted_token = getattr(preference, "git_personal_access_token_encrypted", None)
if not encrypted_token:
return None
try:
encryption = EncryptionManager()
return encryption.decrypt(encrypted_token)
except Exception as decrypt_error:
logger.explore(
"Failed to decrypt git PAT from profile",
extra={"src": "_resolve_current_user_git_token", "user_id": user_id, "error": str(decrypt_error)},
)
return None
# #endregion _resolve_current_user_git_token
# #region _apply_git_identity_from_profile [C:2] [TYPE Function]
# @BRIEF Apply user-scoped Git identity to repository-local config before write/pull operations.
async def _apply_git_identity_from_profile(

View File

@@ -25,6 +25,7 @@ from ._helpers import (
_apply_git_identity_from_profile,
_build_no_repo_status_payload,
_handle_unexpected_git_route_error,
_resolve_current_user_git_token,
_resolve_repository_status,
)
from ._router import router
@@ -105,6 +106,8 @@ async def push_changes(
dashboard_ref: str,
env_id: str | None = None,
config_manager=Depends(get_config_manager),
db: Session = Depends(get_db),
current_user: User = Depends(get_current_user),
_=Depends(has_permission("plugin:git", "EXECUTE")),
):
_gs = get_git_service()
@@ -115,7 +118,8 @@ async def push_changes(
dashboard_id = await _resolve_dashboard_id_from_ref(
dashboard_ref, config_manager, env_id
)
await _gs.push_changes(dashboard_id)
pat = _resolve_current_user_git_token(db, current_user)
await _gs.push_changes(dashboard_id, pat=pat)
return {"status": "success"}
except HTTPException:
raise
@@ -178,7 +182,8 @@ async def pull_changes(
extra={"src": "pull_changes"},
)
await _apply_git_identity_from_profile(dashboard_id, db, current_user)
await _gs.pull_changes(dashboard_id)
pat = _resolve_current_user_git_token(db, current_user)
await _gs.pull_changes(dashboard_id, pat=pat)
return {"status": "success"}
except HTTPException:
raise