Add normalized Gitea source metadata tool

This commit is contained in:
Chris Christiansen 2026-09-20 22:32:11 +00:00
parent 33a414c095
commit b519871a48
3 changed files with 225 additions and 1 deletions

View File

@ -140,6 +140,13 @@ _CAPABILITIES_TUPLE: Tuple[Capability, ...] = (
("authenticated_user", "gitea_read"), False, None, True, False, False, ("authenticated_user", "gitea_read"), False, None, True, False, False,
("git.vauco.no",), "internal_authoritative_only", ("git.vauco.no",), "internal_authoritative_only",
), ),
_capability(
"gitea.fetch_normalized_source_meta", "Fetch Normalized Source Metadata",
"Resolves a Git ref, fetches the source archive, normalizes it, and returns deterministic build metadata.",
"gitea", RiskLevel.READ, Availability.PLANNED, "opax-mcp", "fetch_and_normalize_source",
("authenticated_user", "gitea_read"), False, None, True, False, False,
("git.vauco.no",), "internal_authoritative_only",
),
_capability( _capability(
"cloudbuild.read_status", "Read Cloud Build status", "cloudbuild.read_status", "Read Cloud Build status",
"Read Cloud Build status in the approved VAUCO project.", "cloudbuild", RiskLevel.READ, "Read Cloud Build status in the approved VAUCO project.", "cloudbuild", RiskLevel.READ,

View File

@ -58,12 +58,21 @@ except Exception as e:
print(f"Failed to load provision_new_mcp_module: {e}") print(f"Failed to load provision_new_mcp_module: {e}")
provision_new_mcp_module = None provision_new_mcp_module = None
from emma_adapter import CanonicalEmma from emma_adapter import CanonicalEmma
from gitea_handler import handle_list_repo_files, list_allowed_namespace_repositories, handle_get_file_content from gitea_handler import (
handle_list_repo_files,
list_allowed_namespace_repositories,
handle_get_file_content,
resolve_branch_to_commit_sha,
download_repo_archive,
)
from capability_bridge import build_capability_system_context from capability_bridge import build_capability_system_context
from deployment_policy import get_deployment_target
from deployment_source import normalize_gitea_archive
from email.mime.text import MIMEText from email.mime.text import MIMEText
from datetime import datetime, timezone, timedelta from datetime import datetime, timezone, timedelta
from typing import Any, Optional, Dict, List from typing import Any, Optional, Dict, List
import logging import logging
import re
from pydantic import BaseModel, Field from pydantic import BaseModel, Field
import hashlib import hashlib
@ -977,6 +986,57 @@ async def push_file(p):
if "sha" in body: return await _gitea_put(f"/repos/{repo}/contents/{path}", body) if "sha" in body: return await _gitea_put(f"/repos/{repo}/contents/{path}", body)
return await _gitea_post(f"/repos/{repo}/contents/{path}", body) return await _gitea_post(f"/repos/{repo}/contents/{path}", body)
_SHA_RE = re.compile(r"^[0-9a-f]{40}$")
async def fetch_and_normalize_source(p: dict) -> dict:
"""
Resolves a service and Git ref, fetches the source archive, normalizes it,
and returns deterministic build metadata.
"""
service_key = p.get("service_key")
ref = p.get("ref")
if not isinstance(service_key, str) or not service_key:
raise ValueError("Missing or invalid 'service_key'")
if not isinstance(ref, str) or not ref:
raise ValueError("Missing or invalid 'ref'")
target_policy = get_deployment_target(service_key)
repo_id = target_policy["repository"]
required_paths = target_policy["required_source_paths"]
requested_ref_lower = ref.lower()
if _SHA_RE.fullmatch(requested_ref_lower):
resolved_commit_sha = requested_ref_lower
else:
resolved_commit_sha = await resolve_branch_to_commit_sha(
branch_name=ref,
repo_id=repo_id,
gitea_url=GITEA_URL,
)
archive_bytes = await download_repo_archive(
commit_sha=resolved_commit_sha,
repo_id=repo_id,
gitea_url=GITEA_URL,
)
_normalized_bytes, manifest = normalize_gitea_archive(
archive_bytes=archive_bytes,
required_paths=required_paths,
)
return {
"service_key": service_key,
"repository": repo_id,
"requested_ref": ref,
"resolved_commit_sha": resolved_commit_sha,
"sha256": manifest["sha256"],
"source_bytes": manifest["source_bytes"],
"wrapper_directory_stripped": manifest["wrapper_directory_stripped"],
}
# --------------------------------------------------------------------------- # ---------------------------------------------------------------------------
# Memory Bank & Deployment Tools # Memory Bank & Deployment Tools
# --------------------------------------------------------------------------- # ---------------------------------------------------------------------------
@ -1256,6 +1316,18 @@ TOOLS = {
"list_customers": (list_customers, "List alle kunder (alias for get_state)", {}), "list_customers": (list_customers, "List alle kunder (alias for get_state)", {}),
"run_terminal": (run_terminal, "Kjør terminalkommando på VM", {"type":"object","properties":{"command":{"type":"string"}}}), "run_terminal": (run_terminal, "Kjør terminalkommando på VM", {"type":"object","properties":{"command":{"type":"string"}}}),
# Gitea / VCS # Gitea / VCS
"fetch_and_normalize_source": (
fetch_and_normalize_source,
"Fetches and normalizes a repository source archive, returning build metadata.",
{
"type": "object",
"properties": {
"service_key": {"type": "string"},
"ref": {"type": "string"},
},
"required": ["service_key", "ref"],
},
),
"list_gitea_repositories": (list_gitea_repositories, "Lists repositories in the approved 'chris' namespace. Listing does not grant read, build, or deploy authority.", {}), "list_gitea_repositories": (list_gitea_repositories, "Lists repositories in the approved 'chris' namespace. Listing does not grant read, build, or deploy authority.", {}),
"list_commits": (list_commits, "List siste commits i Gitea-repo", {}), "list_commits": (list_commits, "List siste commits i Gitea-repo", {}),
"get_file": (get_file, "Hent fil fra Gitea-repo", {"type":"object","properties":{"path":{"type":"string"}, "ref":{"type":"string"}, "repo":{"type":"string"}},"required":["path", "ref"]}), "get_file": (get_file, "Hent fil fra Gitea-repo", {"type":"object","properties":{"path":{"type":"string"}, "ref":{"type":"string"}, "repo":{"type":"string"}},"required":["path", "ref"]}),

View File

@ -0,0 +1,145 @@
import unittest
from unittest.mock import AsyncMock, MagicMock, patch
import sys
from pathlib import Path
import httpx
# Add opax-mcp to path to allow direct import of 'server'
REPO_ROOT = Path(__file__).resolve().parents[1]
sys.path.insert(0, str(REPO_ROOT / "opax-mcp"))
# Stub unrelated dependencies before importing server, matching existing tests
sys.modules['emma_adapter'] = MagicMock()
# Now that the path and stubs are set, import the modules under test
from server import fetch_and_normalize_source
from deployment_source import SourceArtifactError
class TestFetchAndNormalizeSource(unittest.IsolatedAsyncioTestCase):
def setUp(self):
self.repo_id = "owner/repo"
self.required_paths = ("cloudbuild.deploy.yaml",)
self.resolved_sha = "b" * 40
self.mock_policy = {
"repository": self.repo_id,
"required_source_paths": self.required_paths,
}
self.mock_manifest = {
"sha256": "normalized_sha256_hash",
"source_bytes": 54321,
"wrapper_directory_stripped": True,
}
# Patch GITEA_URL as it's used directly by the handler
self.gitea_url_patch = patch('server.GITEA_URL', 'https://gitea.test')
self.gitea_url_patch.start()
def tearDown(self):
self.gitea_url_patch.stop()
@patch('server.normalize_gitea_archive')
@patch('server.download_repo_archive', new_callable=AsyncMock)
@patch('server.resolve_branch_to_commit_sha', new_callable=AsyncMock)
@patch('server.get_deployment_target')
async def test_branch_ref_success(self, m_get_target, m_resolve_sha, m_download, m_normalize):
m_get_target.return_value = self.mock_policy
m_resolve_sha.return_value = self.resolved_sha
m_download.return_value = b"archive data"
m_normalize.return_value = (b"normalized data", self.mock_manifest)
result = await fetch_and_normalize_source({"service_key": "test-svc", "ref": "main"})
m_get_target.assert_called_once_with("test-svc")
m_resolve_sha.assert_called_once_with(branch_name="main", repo_id=self.repo_id, gitea_url='https://gitea.test')
m_download.assert_awaited_once_with(commit_sha=self.resolved_sha, repo_id=self.repo_id, gitea_url='https://gitea.test')
m_normalize.assert_called_once_with(archive_bytes=b"archive data", required_paths=self.required_paths)
self.assertEqual(result["resolved_commit_sha"], self.resolved_sha)
self.assertEqual(result["requested_ref"], "main")
self.assertEqual(result["sha256"], self.mock_manifest["sha256"])
self.assertEqual(set(result.keys()), {
"service_key", "repository", "requested_ref", "resolved_commit_sha",
"sha256", "source_bytes", "wrapper_directory_stripped"
})
@patch('server.normalize_gitea_archive')
@patch('server.download_repo_archive', new_callable=AsyncMock)
@patch('server.resolve_branch_to_commit_sha', new_callable=AsyncMock)
@patch('server.get_deployment_target')
async def test_full_sha_success(self, m_get_target, m_resolve_sha, m_download, m_normalize):
m_get_target.return_value = self.mock_policy
m_download.return_value = b"archive data"
m_normalize.return_value = (b"normalized data", self.mock_manifest)
uppercase_sha = self.resolved_sha.upper()
result = await fetch_and_normalize_source({"service_key": "test-svc", "ref": uppercase_sha})
m_resolve_sha.assert_not_called()
m_download.assert_awaited_once_with(commit_sha=self.resolved_sha, repo_id=self.repo_id, gitea_url='https://gitea.test')
self.assertEqual(result["resolved_commit_sha"], self.resolved_sha)
self.assertEqual(result["requested_ref"], uppercase_sha)
@patch('server.normalize_gitea_archive')
@patch('server.download_repo_archive', new_callable=AsyncMock)
@patch('server.resolve_branch_to_commit_sha', new_callable=AsyncMock)
@patch('server.get_deployment_target')
async def test_invalid_service_key_fails_before_dependencies(self, m_get_target, m_resolve_sha, m_download, m_normalize):
for invalid_key in ["", None, 123]:
with self.subTest(key=invalid_key):
with self.assertRaisesRegex(ValueError, "Missing or invalid 'service_key'"):
await fetch_and_normalize_source({"service_key": invalid_key, "ref": "main"})
m_get_target.assert_not_called()
m_resolve_sha.assert_not_called()
m_download.assert_not_called()
m_normalize.assert_not_called()
@patch('server.normalize_gitea_archive')
@patch('server.download_repo_archive', new_callable=AsyncMock)
@patch('server.resolve_branch_to_commit_sha', new_callable=AsyncMock)
@patch('server.get_deployment_target')
async def test_invalid_ref_fails_before_dependencies(self, m_get_target, m_resolve_sha, m_download, m_normalize):
for invalid_ref in ["", None, 123]:
with self.subTest(ref=invalid_ref):
with self.assertRaisesRegex(ValueError, "Missing or invalid 'ref'"):
await fetch_and_normalize_source({"service_key": "test-svc", "ref": invalid_ref})
m_get_target.assert_not_called()
m_resolve_sha.assert_not_called()
m_download.assert_not_called()
m_normalize.assert_not_called()
@patch('server.normalize_gitea_archive')
@patch('server.download_repo_archive', new_callable=AsyncMock)
@patch('server.resolve_branch_to_commit_sha', new_callable=AsyncMock)
@patch('server.get_deployment_target', side_effect=ValueError("Unknown service"))
async def test_unknown_service_propagates_before_gitea_calls(self, m_get_target, m_resolve_sha, m_download, m_normalize):
with self.assertRaisesRegex(ValueError, "Unknown service"):
await fetch_and_normalize_source({"service_key": "unknown", "ref": "main"})
m_resolve_sha.assert_not_called()
m_download.assert_not_called()
m_normalize.assert_not_called()
@patch('server.normalize_gitea_archive')
@patch('server.download_repo_archive', new_callable=AsyncMock, side_effect=httpx.ReadTimeout("Timeout"))
@patch('server.get_deployment_target')
async def test_archive_download_error_propagates(self, m_get_target, m_download, m_normalize):
m_get_target.return_value = self.mock_policy
with self.assertRaises(httpx.ReadTimeout):
await fetch_and_normalize_source({"service_key": "test-svc", "ref": self.resolved_sha})
m_download.assert_awaited_once()
m_normalize.assert_not_called()
@patch('server.normalize_gitea_archive', side_effect=SourceArtifactError("Normalization failed"))
@patch('server.download_repo_archive', new_callable=AsyncMock)
@patch('server.get_deployment_target')
async def test_normalization_error_propagates(self, m_get_target, m_download, m_normalize):
m_get_target.return_value = self.mock_policy
m_download.return_value = b"archive data"
with self.assertRaisesRegex(SourceArtifactError, "Normalization failed"):
await fetch_and_normalize_source({"service_key": "test-svc", "ref": self.resolved_sha})
m_download.assert_awaited_once()
m_normalize.assert_called_once_with(
archive_bytes=b"archive data",
required_paths=self.required_paths,
)
if __name__ == '__main__':
unittest.main()