-
Notifications
You must be signed in to change notification settings - Fork 0
Normalize self-hosted CodeAlive URLs and add skills runtime tests #1
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
Changes from all commits
File filter
Filter by extension
Conversations
Jump to
Diff view
Diff view
There are no files selected for viewing
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,31 @@ | ||
| name: CI | ||
|
|
||
| on: | ||
| push: | ||
| branches: [main] | ||
| pull_request: | ||
| branches: [main] | ||
|
|
||
| permissions: | ||
| contents: read | ||
|
|
||
| jobs: | ||
| test: | ||
| name: Test Skills Runtime | ||
| runs-on: ubuntu-latest | ||
| steps: | ||
| - uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2 | ||
|
|
||
| - uses: actions/setup-python@a309ff8b426b58ec0e2a45f0f869d46889d02405 # v6.2.0 | ||
| with: | ||
| python-version: '3.11' | ||
| cache: 'pip' | ||
|
|
||
| - name: Install test dependencies | ||
| run: | | ||
| python -m pip install --upgrade pip | ||
| pip install pytest pytest-cov | ||
|
|
||
| - name: Run runtime tests | ||
| run: | | ||
| python -m pytest tests -v --cov=skills --cov-report=term-missing |
| Original file line number | Diff line number | Diff line change |
|---|---|---|
|
|
@@ -4,6 +4,7 @@ | |
| """ | ||
|
|
||
| import os | ||
| import urllib.parse | ||
| import sys | ||
| import json | ||
| import urllib.request | ||
|
|
@@ -90,6 +91,26 @@ class CREDENTIAL(ctypes.Structure): | |
| finally: | ||
| advapi32.CredFree(cred_ptr) | ||
|
|
||
| @staticmethod | ||
| def _normalize_base_url(base_url: Optional[str]) -> str: | ||
| """Normalize a CodeAlive base URL to the deployment origin.""" | ||
| raw = (base_url or "https://app.codealive.ai").strip() | ||
| if not raw: | ||
| raw = "https://app.codealive.ai" | ||
|
|
||
| if "://" not in raw: | ||
| normalized = raw.rstrip("/") | ||
| if normalized.endswith("/api"): | ||
| normalized = normalized[:-4] | ||
| return normalized | ||
|
|
||
| parts = urllib.parse.urlsplit(raw) | ||
| path = parts.path.rstrip("/") | ||
| if path.endswith("/api"): | ||
| path = path[:-4] | ||
|
|
||
| return urllib.parse.urlunsplit((parts.scheme, parts.netloc, path, parts.query, parts.fragment)).rstrip("/") | ||
|
Comment on lines
+94
to
+112
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. The URL normalization logic can be improved for robustness:
@staticmethod
def _normalize_base_url(base_url: Optional[str]) -> str:
"""Normalize a CodeAlive base URL to the deployment origin."""
raw = (base_url or "https://app.codealive.ai").strip()
if not raw:
raw = "https://app.codealive.ai"
if "://" not in raw:
raw = f"https://{raw}"
parts = urllib.parse.urlsplit(raw)
path = parts.path.rstrip("/")
if path.endswith("/api"):
path = path[:-4]
return urllib.parse.urlunsplit((parts.scheme, parts.netloc, path, "", "")).rstrip("/") |
||
|
|
||
| def __init__(self, api_key: Optional[str] = None, base_url: Optional[str] = None): | ||
| """ | ||
| Initialize the CodeAlive API client. | ||
|
|
@@ -103,6 +124,7 @@ def __init__(self, api_key: Optional[str] = None, base_url: Optional[str] = None | |
| """ | ||
| self.api_key = api_key or os.getenv("CODEALIVE_API_KEY") or self._get_key_from_keychain() | ||
| if not self.api_key: | ||
| resolved_base_url = self._normalize_base_url(base_url or os.getenv("CODEALIVE_BASE_URL", "https://app.codealive.ai")) | ||
|
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. |
||
| skill_dir = os.path.dirname(os.path.dirname(os.path.dirname(os.path.abspath(__file__)))) | ||
| setup_path = os.path.join(skill_dir, "setup.py") | ||
| raise ValueError( | ||
|
|
@@ -115,10 +137,10 @@ def __init__(self, api_key: Optional[str] = None, base_url: Optional[str] = None | |
| " Ask the user to paste their API key, then run:\n" | ||
| f" python {setup_path} --key THE_KEY\n" | ||
| "\n" | ||
| "Get API key at: https://app.codealive.ai/settings/api-keys" | ||
| f"Get API key at: {resolved_base_url}/settings/api-keys" | ||
| ) | ||
|
|
||
| self.base_url = base_url or os.getenv("CODEALIVE_BASE_URL", "https://app.codealive.ai") | ||
| self.base_url = self._normalize_base_url(base_url or os.getenv("CODEALIVE_BASE_URL", "https://app.codealive.ai")) | ||
| self.timeout = 60 | ||
|
|
||
| def _make_request( | ||
|
|
@@ -214,7 +236,7 @@ def get_datasources(self, alive_only: bool = True) -> List[Dict[str, Any]]: | |
| Returns: | ||
| List of data source objects with id, name, description, type, etc. | ||
| """ | ||
| endpoint = "/api/datasources/alive" if alive_only else "/api/datasources/all" | ||
| endpoint = "/api/datasources/ready" if alive_only else "/api/datasources/all" | ||
| return self._make_request("GET", endpoint) | ||
|
|
||
| def search( | ||
|
|
||
| Original file line number | Diff line number | Diff line change |
|---|---|---|
|
|
@@ -19,12 +19,36 @@ | |
| import json | ||
| import urllib.request | ||
| import urllib.error | ||
| import urllib.parse | ||
|
|
||
| SKILL_DIR = os.path.dirname(os.path.abspath(__file__)) | ||
| SERVICE_NAME = "codealive-api-key" | ||
| DEFAULT_BASE_URL = "https://app.codealive.ai" | ||
|
|
||
|
|
||
| def normalize_base_url(base_url: str | None) -> str: | ||
| """Normalize a CodeAlive base URL to the deployment origin. | ||
|
|
||
| Accepts both deployment origins and URLs that already end with `/api`. | ||
| """ | ||
| raw = (base_url or DEFAULT_BASE_URL).strip() | ||
| if not raw: | ||
| raw = DEFAULT_BASE_URL | ||
|
|
||
| if "://" not in raw: | ||
| normalized = raw.rstrip("/") | ||
| if normalized.endswith("/api"): | ||
| normalized = normalized[:-4] | ||
| return normalized | ||
|
|
||
| parts = urllib.parse.urlsplit(raw) | ||
| path = parts.path.rstrip("/") | ||
| if path.endswith("/api"): | ||
| path = path[:-4] | ||
|
|
||
| return urllib.parse.urlunsplit((parts.scheme, parts.netloc, path, parts.query, parts.fragment)).rstrip("/") | ||
|
Comment on lines
+29
to
+49
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. The URL normalization logic here should be updated to handle missing schemes and strip query parameters/fragments, matching the improvements suggested for the API client to ensure consistency and robustness. def normalize_base_url(base_url: str | None) -> str:
"""Normalize a CodeAlive base URL to the deployment origin.
Accepts both deployment origins and URLs that already end with `/api`.
"""
raw = (base_url or DEFAULT_BASE_URL).strip()
if not raw:
raw = DEFAULT_BASE_URL
if "://" not in raw:
raw = f"https://{raw}"
parts = urllib.parse.urlsplit(raw)
path = parts.path.rstrip("/")
if path.endswith("/api"):
path = path[:-4]
return urllib.parse.urlunsplit((parts.scheme, parts.netloc, path, "", "")).rstrip("/") |
||
|
|
||
|
|
||
| # ── Credential store helpers ────────────────────────────────────────────────── | ||
|
|
||
| def read_existing_key() -> str | None: | ||
|
|
@@ -135,7 +159,8 @@ def store_key(api_key: str) -> bool: | |
|
|
||
| def verify_key(api_key: str, base_url: str = DEFAULT_BASE_URL) -> tuple[bool, str]: | ||
| """Test the API key by fetching data sources. Returns (success, message).""" | ||
| url = f"{base_url}/api/datasources/alive" | ||
| normalized_base_url = normalize_base_url(base_url) | ||
| url = f"{normalized_base_url}/api/datasources/ready" | ||
| headers = { | ||
| "Authorization": f"Bearer {api_key}", | ||
| "Content-Type": "application/json", | ||
|
|
@@ -176,7 +201,7 @@ def main(): | |
| sys.exit(0) | ||
|
|
||
| system = platform.system() | ||
| base_url = os.getenv("CODEALIVE_BASE_URL", DEFAULT_BASE_URL) | ||
| base_url = normalize_base_url(os.getenv("CODEALIVE_BASE_URL", DEFAULT_BASE_URL)) | ||
|
|
||
| print() | ||
| print(" CodeAlive Context Engine — Setup") | ||
|
|
||
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,71 @@ | ||
| """Test helpers for CodeAlive skills runtime tests.""" | ||
|
|
||
| from __future__ import annotations | ||
|
|
||
| import json | ||
| import threading | ||
| from contextlib import contextmanager | ||
| from http.server import BaseHTTPRequestHandler, HTTPServer | ||
|
|
||
|
|
||
| @contextmanager | ||
| def mock_codealive_server(routes): | ||
| """Start a local mock HTTP server. | ||
|
|
||
| ``routes`` maps ``(method, path)`` to either: | ||
| - ``(status_code, payload)``, where payload is JSON-serializable | ||
| - a callable ``handler(request_info) -> (status_code, payload, headers)`` | ||
| """ | ||
|
|
||
| requests = [] | ||
|
|
||
| class Handler(BaseHTTPRequestHandler): | ||
| def _handle(self, method: str): | ||
| request_info = { | ||
| "method": method, | ||
| "path": self.path, | ||
| "headers": {k: v for k, v in self.headers.items()}, | ||
| "body": self.rfile.read(int(self.headers.get("Content-Length", "0"))).decode("utf-8") | ||
| if method in {"POST", "PUT", "PATCH"} | ||
| else "", | ||
| } | ||
| requests.append(request_info) | ||
|
|
||
| route = routes.get((method, self.path)) | ||
| if route is None: | ||
| self.send_response(404) | ||
| self.end_headers() | ||
| return | ||
|
|
||
| if callable(route): | ||
| status, payload, headers = route(request_info) | ||
| else: | ||
| status, payload = route | ||
| headers = {} | ||
|
|
||
| body = json.dumps(payload).encode("utf-8") | ||
| self.send_response(status) | ||
| self.send_header("Content-Type", "application/json") | ||
| self.send_header("Content-Length", str(len(body))) | ||
| for key, value in headers.items(): | ||
| self.send_header(key, value) | ||
| self.end_headers() | ||
| self.wfile.write(body) | ||
|
|
||
| def do_GET(self): | ||
| self._handle("GET") | ||
|
|
||
| def do_POST(self): | ||
| self._handle("POST") | ||
|
|
||
| def log_message(self, format, *args): | ||
| pass | ||
|
|
||
| server = HTTPServer(("127.0.0.1", 0), Handler) | ||
| thread = threading.Thread(target=server.serve_forever, daemon=True) | ||
| thread.start() | ||
| try: | ||
| yield f"http://127.0.0.1:{server.server_address[1]}", requests | ||
| finally: | ||
| server.shutdown() | ||
| thread.join(timeout=1) |
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,124 @@ | ||
| """CLI smoke tests for the CodeAlive skill scripts.""" | ||
|
|
||
| from __future__ import annotations | ||
|
|
||
| import json | ||
| import os | ||
| import subprocess | ||
| import sys | ||
| from pathlib import Path | ||
|
|
||
| from helpers import mock_codealive_server | ||
|
|
||
|
|
||
| REPO_ROOT = Path(__file__).resolve().parents[1] | ||
| SKILL_ROOT = REPO_ROOT / "skills" / "codealive-context-engine" | ||
|
|
||
|
|
||
| def _run(script_name: str, *args: str, env: dict[str, str]) -> subprocess.CompletedProcess[str]: | ||
| script = SKILL_ROOT / "scripts" / script_name | ||
| return subprocess.run( | ||
| [sys.executable, str(script), *args], | ||
| text=True, | ||
| capture_output=True, | ||
| env=env, | ||
| check=False, | ||
| ) | ||
|
|
||
|
|
||
| def test_datasources_search_fetch_and_chat_scripts_work_against_mock_backend(): | ||
| def search_handler(_request): | ||
| return 200, { | ||
| "results": [ | ||
| { | ||
| "identifier": "org/repo::src/auth.py::AuthService", | ||
| "kind": "Class", | ||
| "description": "Handles auth", | ||
| "location": {"path": "src/auth.py", "range": {"start": {"line": 10}, "end": {"line": 20}}}, | ||
| "contentByteSize": 2048, | ||
| } | ||
| ] | ||
| }, {} | ||
|
|
||
| def fetch_handler(_request): | ||
| return 200, { | ||
| "artifacts": [ | ||
| { | ||
| "identifier": "org/repo::src/auth.py::AuthService", | ||
| "content": "class AuthService:\n pass\n", | ||
| "startLine": 10, | ||
| "contentByteSize": 28, | ||
| } | ||
| ] | ||
| }, {} | ||
|
|
||
| def chat_handler(_request): | ||
| return 200, { | ||
| "id": "conv_123", | ||
| "choices": [{"message": {"content": "Auth is handled in AuthService."}}], | ||
| }, {} | ||
|
|
||
| with mock_codealive_server( | ||
| { | ||
| ("GET", "/api/datasources/ready"): ( | ||
| 200, | ||
| [{"id": "repo-1", "name": "backend", "type": "Repository", "description": "Main backend"}], | ||
| ), | ||
| ("GET", "/api/search?Query=auth&Mode=auto&IncludeContent=false&DescriptionDetail=Short&Names=backend"): search_handler, | ||
| ("POST", "/api/search/artifacts"): fetch_handler, | ||
| ("POST", "/api/chat/completions"): chat_handler, | ||
| } | ||
| ) as (base_url, requests): | ||
| env = { | ||
| **os.environ, | ||
| "CODEALIVE_API_KEY": "skill-test-key", | ||
| "CODEALIVE_BASE_URL": f"{base_url}/api", | ||
| } | ||
|
|
||
| datasources = _run("datasources.py", "--json", env=env) | ||
| search = _run("search.py", "auth", "backend", env=env) | ||
| fetch = _run("fetch.py", "org/repo::src/auth.py::AuthService", env=env) | ||
| chat = _run("chat.py", "How does auth work?", "backend", env=env) | ||
|
|
||
| assert datasources.returncode == 0, datasources.stderr | ||
| assert json.loads(datasources.stdout)[0]["name"] == "backend" | ||
|
|
||
| assert search.returncode == 0, search.stderr | ||
| assert "src/auth.py:10-20" in search.stdout | ||
| assert "Handles auth" in search.stdout | ||
|
|
||
| assert fetch.returncode == 0, fetch.stderr | ||
| assert "AuthService" in fetch.stdout | ||
| assert "10 | class AuthService:" in fetch.stdout | ||
|
|
||
| assert chat.returncode == 0, chat.stderr | ||
| assert "Auth is handled in AuthService." in chat.stdout | ||
| assert "Conversation ID: conv_123" in chat.stdout | ||
|
|
||
| assert [request["path"] for request in requests] == [ | ||
| "/api/datasources/ready", | ||
| "/api/search?Query=auth&Mode=auto&IncludeContent=false&DescriptionDetail=Short&Names=backend", | ||
| "/api/search/artifacts", | ||
| "/api/chat/completions", | ||
| ] | ||
|
|
||
|
|
||
| def test_check_auth_hook_normalizes_base_url_and_uses_repo_root_fallback(): | ||
| script = REPO_ROOT / "hooks" / "scripts" / "check_auth.sh" | ||
| env = { | ||
| "PATH": "/usr/bin:/bin", | ||
| "USER": "codealive-skills-test", | ||
| "CODEALIVE_BASE_URL": "https://codealive.example.com/api", | ||
| } | ||
|
|
||
| result = subprocess.run( | ||
| ["/bin/bash", str(script)], | ||
| text=True, | ||
| capture_output=True, | ||
| env=env, | ||
| check=False, | ||
| ) | ||
|
|
||
| assert result.returncode == 0 | ||
| assert "https://codealive.example.com/settings/api-keys" in result.stdout | ||
| assert str(REPO_ROOT / "skills" / "codealive-context-engine" / "setup.py") in result.stdout |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
This import is redundant as
urllib.parseis already imported on line 12.