From 240d23d110645b57b12f3db1fd3ab9b547f47135 Mon Sep 17 00:00:00 2001 From: Kai Koenig Date: Fri, 10 Apr 2026 10:39:46 +1200 Subject: [PATCH 01/18] Add pytest test suite with coverage and CI workflow --- .github/workflows/tests.yml | 42 +++ pyproject.toml | 13 + tests/.gitkeep | 0 tests/conftest.py | 89 ++++++ tests/test_execution_context.py | 335 +++++++++++++++++++++ tests/test_integration.py | 507 ++++++++++++++++++++++++++++++++ tests/test_results.py | 129 ++++++++ 7 files changed, 1115 insertions(+) create mode 100644 .github/workflows/tests.yml delete mode 100644 tests/.gitkeep create mode 100644 tests/conftest.py create mode 100644 tests/test_execution_context.py create mode 100644 tests/test_integration.py create mode 100644 tests/test_results.py diff --git a/.github/workflows/tests.yml b/.github/workflows/tests.yml new file mode 100644 index 0000000..b1bb609 --- /dev/null +++ b/.github/workflows/tests.yml @@ -0,0 +1,42 @@ +name: Tests + +on: + push: + branches: [master] + pull_request: + branches: [master] + +jobs: + test: + runs-on: ubuntu-latest + strategy: + matrix: + python-version: ["3.13"] + + steps: + - uses: actions/checkout@v4 + + - name: Set up Python ${{ matrix.python-version }} + uses: actions/setup-python@v5 + with: + python-version: ${{ matrix.python-version }} + + - name: Install dependencies + run: | + python -m pip install --upgrade pip + pip install -e ".[test]" + + - name: Run tests with coverage + run: | + python -m pytest tests/ -v --tb=short \ + --cov=autohive_integrations_sdk \ + --cov-report=term-missing \ + --cov-report=xml:coverage.xml \ + | tee pytest-coverage.txt + + - name: Post coverage comment on PR + if: github.event_name == 'pull_request' + uses: MishaKav/pytest-coverage-comment@main + with: + pytest-coverage-path: pytest-coverage.txt + pytest-xml-coverage-path: coverage.xml diff --git a/pyproject.toml b/pyproject.toml index e87b8f8..3b47121 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -29,6 +29,19 @@ dependencies = [ "aiohttp", "jsonschema==4.17.3" ] + +[project.optional-dependencies] +test = [ + "pytest>=8.0", + "pytest-asyncio>=0.24", + "aioresponses>=0.7", + "pytest-cov>=6.0", +] + +[tool.pytest.ini_options] +testpaths = ["tests"] +asyncio_mode = "auto" + [project.urls] Homepage = "https://github.com/Autohive-AI/integrations-sdk" Issues = "https://github.com/Autohive-AI/integrations-sdk/issues" \ No newline at end of file diff --git a/tests/.gitkeep b/tests/.gitkeep deleted file mode 100644 index e69de29..0000000 diff --git a/tests/conftest.py b/tests/conftest.py new file mode 100644 index 0000000..d1cffba --- /dev/null +++ b/tests/conftest.py @@ -0,0 +1,89 @@ +"""Shared fixtures for the Autohive Integrations SDK test suite.""" + +import json +import pytest +from pathlib import Path + +from autohive_integrations_sdk import Integration, ExecutionContext + + +@pytest.fixture +def config_dict(): + """Minimal config.json content with one action, one trigger, and auth fields.""" + return { + "name": "test-integration", + "version": "0.1.0", + "description": "Integration used by the test suite", + "auth": { + "auth_type": "Custom", + "fields": { + "type": "object", + "properties": { + "api_key": {"type": "string"} + }, + "required": ["api_key"] + } + }, + "actions": { + "test_action": { + "description": "A simple test action", + "input_schema": { + "type": "object", + "properties": { + "name": {"type": "string"} + }, + "required": ["name"] + }, + "output_schema": { + "type": "object", + "properties": { + "greeting": {"type": "string"} + }, + "required": ["greeting"] + } + } + }, + "polling_triggers": { + "test_trigger": { + "description": "A simple test trigger", + "polling_interval": "5m", + "input_schema": { + "type": "object", + "properties": { + "channel": {"type": "string"} + }, + "required": ["channel"] + }, + "output_schema": { + "type": "object", + "properties": { + "message": {"type": "string"} + }, + "required": ["message"] + } + } + } + } + + +@pytest.fixture +def tmp_config(tmp_path, config_dict): + """Write the config dict to a temporary file and return its path.""" + config_file = tmp_path / "config.json" + config_file.write_text(json.dumps(config_dict)) + return config_file + + +@pytest.fixture +def integration(tmp_config): + """An Integration instance loaded from the temporary config.""" + return Integration.load(tmp_config) + + +@pytest.fixture +def execution_context(): + """A simple ExecutionContext with a Custom API key.""" + return ExecutionContext( + auth={"api_key": "test-key-123"}, + request_config={"max_retries": 1, "timeout": 1}, + ) diff --git a/tests/test_execution_context.py b/tests/test_execution_context.py new file mode 100644 index 0000000..1008bc7 --- /dev/null +++ b/tests/test_execution_context.py @@ -0,0 +1,335 @@ +"""Tests for ExecutionContext — HTTP fetching, auth injection, retries.""" + +from unittest.mock import patch + +import aiohttp +import pytest +from aioresponses import aioresponses +from yarl import URL + +from autohive_integrations_sdk import ( + ExecutionContext, + HTTPError, + RateLimitError, +) + + +BASE_URL = "https://api.example.com/resource" + + +@pytest.fixture +def mock_aio(): + with aioresponses() as m: + yield m + + +# ── Basic HTTP ─────────────────────────────────────────────────────────────── + + +async def test_fetch_get_json(mock_aio): + mock_aio.get(BASE_URL, payload={"ok": True}) + + async with ExecutionContext() as ctx: + data = await ctx.fetch(BASE_URL) + + assert data == {"ok": True} + + +async def test_fetch_post_json(mock_aio): + mock_aio.post(BASE_URL, payload={"id": 1}) + + async with ExecutionContext() as ctx: + data = await ctx.fetch(BASE_URL, method="POST", json={"name": "test"}) + + assert data == {"id": 1} + + +async def test_fetch_text_response(mock_aio): + mock_aio.get(BASE_URL, body="plain text", content_type="text/plain") + + async with ExecutionContext() as ctx: + data = await ctx.fetch(BASE_URL) + + assert data == "plain text" + + +async def test_fetch_empty_response_204(mock_aio): + mock_aio.get(BASE_URL, body="", status=204, content_type="text/plain") + + async with ExecutionContext() as ctx: + data = await ctx.fetch(BASE_URL) + + assert data is None + + +# ── Error handling ─────────────────────────────────────────────────────────── + + +async def test_fetch_rate_limit(mock_aio): + mock_aio.get( + BASE_URL, + status=429, + body="rate limited", + headers={"Retry-After": "120"}, + content_type="text/plain", + ) + + async with ExecutionContext() as ctx: + with pytest.raises(RateLimitError) as exc_info: + await ctx.fetch(BASE_URL) + + assert exc_info.value.retry_after == 120 + assert exc_info.value.status == 429 + + +async def test_fetch_http_error(mock_aio): + mock_aio.get(BASE_URL, status=500, body="server error", content_type="text/plain") + + async with ExecutionContext() as ctx: + with pytest.raises(HTTPError) as exc_info: + await ctx.fetch(BASE_URL) + + assert exc_info.value.status == 500 + + +# ── Auth injection ─────────────────────────────────────────────────────────── + + +async def test_fetch_oauth_bearer_token(mock_aio): + mock_aio.get(BASE_URL, payload={"ok": True}) + + auth = { + "auth_type": "PlatformOauth2", + "credentials": {"access_token": "tok_abc"}, + } + async with ExecutionContext(auth=auth) as ctx: + await ctx.fetch(BASE_URL) + + key = ("GET", URL(BASE_URL)) + request = mock_aio.requests[key][0] + assert request.kwargs["headers"]["Authorization"] == "Bearer tok_abc" + + +async def test_fetch_no_auth_injection_when_header_provided(mock_aio): + mock_aio.get(BASE_URL, payload={"ok": True}) + + auth = { + "auth_type": "PlatformOauth2", + "credentials": {"access_token": "tok_abc"}, + } + async with ExecutionContext(auth=auth) as ctx: + await ctx.fetch(BASE_URL, headers={"Authorization": "Custom xyz"}) + + key = ("GET", URL(BASE_URL)) + request = mock_aio.requests[key][0] + assert request.kwargs["headers"]["Authorization"] == "Custom xyz" + + +# ── Query params ───────────────────────────────────────────────────────────── + + +async def test_fetch_query_params(mock_aio): + # The SDK appends params to the URL string before making the request, + # so we need to register the mock with a pattern or the full URL. + url_with_params = f"{BASE_URL}?page=1&limit=10" + mock_aio.get(url_with_params, payload={"ok": True}) + + async with ExecutionContext() as ctx: + data = await ctx.fetch(BASE_URL, params={"page": 1, "limit": 10}) + + assert data == {"ok": True} + + +# ── Retry logic ────────────────────────────────────────────────────────────── + + +async def test_fetch_retry_on_client_error(mock_aio): + mock_aio.get(BASE_URL, exception=aiohttp.ClientError("connection reset")) + mock_aio.get(BASE_URL, payload={"ok": True}) + + cfg = {"max_retries": 1, "timeout": 1} + async with ExecutionContext(request_config=cfg) as ctx: + with patch("asyncio.sleep", return_value=None) as mock_sleep: + data = await ctx.fetch(BASE_URL) + + assert data == {"ok": True} + mock_sleep.assert_awaited_once() + + +# ── Context manager ────────────────────────────────────────────────────────── + + +async def test_context_manager(): + ctx = ExecutionContext() + assert ctx._session is None + + async with ctx: + assert ctx._session is not None + assert not ctx._session.closed + + assert ctx._session is None + + +# ── Form-encoded body ──────────────────────────────────────────────────────── + + +async def test_fetch_form_encoded_body(mock_aio): + mock_aio.post(BASE_URL, payload={"ok": True}) + + async with ExecutionContext() as ctx: + await ctx.fetch( + BASE_URL, + method="POST", + data={"username": "alice", "password": "secret"}, + content_type="application/x-www-form-urlencoded", + ) + + key = ("POST", URL(BASE_URL)) + request = mock_aio.requests[key][0] + body = request.kwargs["data"] + # urlencode produces "username=alice&password=secret" (order may vary) + assert "username=alice" in body + assert "password=secret" in body + + +# ── Nested params ──────────────────────────────────────────────────────────── + + +async def test_fetch_nested_params(mock_aio): + """Dicts/lists in params are JSON-serialized.""" + import json as _json + + expected_url = f"{BASE_URL}?filter=%7B%22status%22%3A+%22active%22%7D" + mock_aio.get(expected_url, payload={"ok": True}) + + async with ExecutionContext() as ctx: + data = await ctx.fetch(BASE_URL, params={"filter": {"status": "active"}}) + + assert data == {"ok": True} + + +# ── URL with existing query string ────────────────────────────────────────── + + +async def test_fetch_params_appended_with_ampersand(mock_aio): + """When URL already has '?', params are joined with '&'.""" + base_with_query = f"{BASE_URL}?existing=1" + full_url = f"{base_with_query}&page=2" + mock_aio.get(full_url, payload={"ok": True}) + + async with ExecutionContext() as ctx: + data = await ctx.fetch(base_with_query, params={"page": 2}) + + assert data == {"ok": True} + + +# ── Timeout triggers retry ────────────────────────────────────────────────── + + +async def test_fetch_retry_on_timeout(mock_aio): + """asyncio.TimeoutError triggers a retry just like ClientError.""" + import asyncio + + mock_aio.get(BASE_URL, exception=asyncio.TimeoutError()) + mock_aio.get(BASE_URL, payload={"ok": True}) + + cfg = {"max_retries": 1, "timeout": 1} + async with ExecutionContext(request_config=cfg) as ctx: + with patch("asyncio.sleep", return_value=None): + data = await ctx.fetch(BASE_URL) + + assert data == {"ok": True} + + +# ── Max retries exhausted ─────────────────────────────────────────────────── + + +async def test_fetch_max_retries_exhausted(mock_aio): + """After all retries fail, the error propagates.""" + mock_aio.get(BASE_URL, exception=aiohttp.ClientError("fail")) + mock_aio.get(BASE_URL, exception=aiohttp.ClientError("fail again")) + + cfg = {"max_retries": 1, "timeout": 1} + async with ExecutionContext(request_config=cfg) as ctx: + with patch("asyncio.sleep", return_value=None): + with pytest.raises(aiohttp.ClientError): + await ctx.fetch(BASE_URL) + + +# ── Non-OAuth auth types ──────────────────────────────────────────────────── + + +async def test_fetch_no_bearer_for_apikey_auth(mock_aio): + """ApiKey auth type should not auto-inject a Bearer token.""" + mock_aio.get(BASE_URL, payload={"ok": True}) + + auth = {"auth_type": "ApiKey", "credentials": {"access_token": "tok_abc"}} + async with ExecutionContext(auth=auth) as ctx: + await ctx.fetch(BASE_URL) + + key = ("GET", URL(BASE_URL)) + request = mock_aio.requests[key][0] + assert "Authorization" not in request.kwargs["headers"] + + +async def test_fetch_no_bearer_for_custom_auth(mock_aio): + """Custom auth type should not auto-inject a Bearer token.""" + mock_aio.get(BASE_URL, payload={"ok": True}) + + auth = {"auth_type": "Custom", "credentials": {"api_key": "xyz"}} + async with ExecutionContext(auth=auth) as ctx: + await ctx.fetch(BASE_URL) + + key = ("GET", URL(BASE_URL)) + request = mock_aio.requests[key][0] + assert "Authorization" not in request.kwargs["headers"] + + +async def test_fetch_no_bearer_for_basic_auth(mock_aio): + """Basic auth type should not auto-inject a Bearer token.""" + mock_aio.get(BASE_URL, payload={"ok": True}) + + auth = {"auth_type": "Basic", "credentials": {"username": "u", "password": "p"}} + async with ExecutionContext(auth=auth) as ctx: + await ctx.fetch(BASE_URL) + + key = ("GET", URL(BASE_URL)) + request = mock_aio.requests[key][0] + assert "Authorization" not in request.kwargs["headers"] + + +# ── Session auto-created ──────────────────────────────────────────────────── + + +async def test_fetch_creates_session_without_context_manager(mock_aio): + """fetch() creates a session if called without 'async with'.""" + mock_aio.get(BASE_URL, payload={"ok": True}) + + ctx = ExecutionContext() + assert ctx._session is None + + data = await ctx.fetch(BASE_URL) + assert data == {"ok": True} + assert ctx._session is not None + + # Clean up + await ctx._session.close() + + +# ── JSON error response ───────────────────────────────────────────────────── + + +async def test_fetch_http_error_json_body(mock_aio): + """Non-2xx with JSON body stores parsed dict in response_data.""" + mock_aio.get( + BASE_URL, + status=400, + payload={"error": "bad request", "code": "INVALID"}, + ) + + async with ExecutionContext() as ctx: + with pytest.raises(HTTPError) as exc_info: + await ctx.fetch(BASE_URL) + + assert exc_info.value.status == 400 + assert exc_info.value.response_data == {"error": "bad request", "code": "INVALID"} diff --git a/tests/test_integration.py b/tests/test_integration.py new file mode 100644 index 0000000..4fea20a --- /dev/null +++ b/tests/test_integration.py @@ -0,0 +1,507 @@ +"""Tests for the Integration class — config loading, handler registration, and execution.""" + +import json +from datetime import timedelta + +import pytest + +from autohive_integrations_sdk import ( + Integration, + ExecutionContext, + ActionHandler, + ActionResult, + ActionError, + ConnectedAccountHandler, + ConnectedAccountInfo, + IntegrationResult, + ResultType, + ValidationError, +) +from autohive_integrations_sdk.integration import ( + ConfigurationError, + PollingTriggerHandler, +) + + +# ── Config loading ─────────────────────────────────────────────────────────── + + +def test_load_config(integration, config_dict): + assert integration.config.name == config_dict["name"] + assert integration.config.version == config_dict["version"] + assert integration.config.description == config_dict["description"] + assert "test_action" in integration.config.actions + assert "test_trigger" in integration.config.polling_triggers + + +def test_load_config_missing_file(tmp_path): + with pytest.raises(ConfigurationError, match="not found"): + Integration.load(tmp_path / "nonexistent.json") + + +def test_load_config_invalid_json(tmp_path): + bad_file = tmp_path / "config.json" + bad_file.write_text("{invalid json") + with pytest.raises(ConfigurationError, match="Invalid JSON"): + Integration.load(bad_file) + + +# ── Action handler registration ───────────────────────────────────────────── + + +def test_register_action_handler(integration): + @integration.action("test_action") + class Handler(ActionHandler): + async def execute(self, inputs, context): + return ActionResult(data={}) + + assert "test_action" in integration._action_handlers + + +def test_register_action_not_in_config(integration): + with pytest.raises(ConfigurationError, match="not defined in config"): + + @integration.action("nonexistent_action") + class Handler(ActionHandler): + async def execute(self, inputs, context): + return ActionResult(data={}) + + +# ── Action execution ──────────────────────────────────────────────────────── + + +async def test_execute_action_success(integration): + @integration.action("test_action") + class Handler(ActionHandler): + async def execute(self, inputs, context): + return ActionResult(data={"greeting": f"Hello {inputs['name']}"}) + + ctx = ExecutionContext(auth={"api_key": "k"}) + result = await integration.execute_action("test_action", {"name": "World"}, ctx) + + assert result.type == ResultType.ACTION + assert result.result.data == {"greeting": "Hello World"} + assert result.version is not None + + +async def test_execute_action_with_cost(integration): + @integration.action("test_action") + class Handler(ActionHandler): + async def execute(self, inputs, context): + return ActionResult(data={"greeting": "hi"}, cost_usd=0.05) + + ctx = ExecutionContext(auth={"api_key": "k"}) + result = await integration.execute_action("test_action", {"name": "x"}, ctx) + + assert result.type == ResultType.ACTION + assert result.result.cost_usd == 0.05 + + +async def test_execute_action_invalid_inputs(integration): + @integration.action("test_action") + class Handler(ActionHandler): + async def execute(self, inputs, context): + return ActionResult(data={"greeting": "hi"}) + + ctx = ExecutionContext(auth={"api_key": "k"}) + result = await integration.execute_action("test_action", {}, ctx) + + assert result.type == ResultType.VALIDATION_ERROR + + +async def test_execute_action_invalid_output(integration): + @integration.action("test_action") + class Handler(ActionHandler): + async def execute(self, inputs, context): + return ActionResult(data={"wrong_key": 123}) + + ctx = ExecutionContext(auth={"api_key": "k"}) + result = await integration.execute_action("test_action", {"name": "x"}, ctx) + + assert result.type == ResultType.VALIDATION_ERROR + + +async def test_execute_action_wrong_return_type(integration): + @integration.action("test_action") + class Handler(ActionHandler): + async def execute(self, inputs, context): + return {"greeting": "plain dict"} + + ctx = ExecutionContext(auth={"api_key": "k"}) + result = await integration.execute_action("test_action", {"name": "x"}, ctx) + + assert result.type == ResultType.VALIDATION_ERROR + + +async def test_execute_action_not_registered(integration): + ctx = ExecutionContext(auth={"api_key": "k"}) + result = await integration.execute_action("test_action", {"name": "x"}, ctx) + + assert result.type == ResultType.VALIDATION_ERROR + + +async def test_execute_action_error(integration): + @integration.action("test_action") + class Handler(ActionHandler): + async def execute(self, inputs, context): + return ActionError(message="something went wrong", cost_usd=0.01) + + ctx = ExecutionContext(auth={"api_key": "k"}) + result = await integration.execute_action("test_action", {"name": "x"}, ctx) + + assert result.type == ResultType.ACTION_ERROR + assert result.result.message == "something went wrong" + assert result.result.cost_usd == 0.01 + + +# ── Polling trigger ───────────────────────────────────────────────────────── + + +def test_register_polling_trigger(integration): + @integration.polling_trigger("test_trigger") + class Handler(PollingTriggerHandler): + async def poll(self, inputs, last_poll_ts, context): + return [] + + assert "test_trigger" in integration._polling_handlers + + +async def test_execute_polling_trigger_success(integration): + @integration.polling_trigger("test_trigger") + class Handler(PollingTriggerHandler): + async def poll(self, inputs, last_poll_ts, context): + return [{"id": "1", "data": {"message": "hello"}}] + + ctx = ExecutionContext(auth={"api_key": "k"}) + records = await integration.execute_polling_trigger( + "test_trigger", {"channel": "general"}, None, ctx + ) + + assert len(records) == 1 + assert records[0]["id"] == "1" + assert records[0]["data"]["message"] == "hello" + + +async def test_execute_polling_trigger_missing_id(integration): + @integration.polling_trigger("test_trigger") + class Handler(PollingTriggerHandler): + async def poll(self, inputs, last_poll_ts, context): + return [{"data": {"message": "no id"}}] + + ctx = ExecutionContext(auth={"api_key": "k"}) + with pytest.raises(ValidationError, match="id"): + await integration.execute_polling_trigger( + "test_trigger", {"channel": "general"}, None, ctx + ) + + +# ── Connected account ─────────────────────────────────────────────────────── + + +def test_register_connected_account(integration): + @integration.connected_account() + class Handler(ConnectedAccountHandler): + async def get_account_info(self, context): + return ConnectedAccountInfo() + + assert integration._connected_account_handler is not None + + +async def test_get_connected_account_success(integration): + @integration.connected_account() + class Handler(ConnectedAccountHandler): + async def get_account_info(self, context): + return ConnectedAccountInfo(email="a@b.com", first_name="Alice") + + ctx = ExecutionContext(auth={"api_key": "k"}) + result = await integration.get_connected_account(ctx) + + assert result.type == ResultType.CONNECTED_ACCOUNT + assert result.result.email == "a@b.com" + assert result.result.first_name == "Alice" + + +async def test_get_connected_account_wrong_type(integration): + @integration.connected_account() + class Handler(ConnectedAccountHandler): + async def get_account_info(self, context): + return {"email": "raw dict"} + + ctx = ExecutionContext(auth={"api_key": "k"}) + with pytest.raises(ValidationError, match="ConnectedAccountInfo"): + await integration.get_connected_account(ctx) + + +# ── Interval parsing ──────────────────────────────────────────────────────── + + +def test_parse_interval(): + assert Integration._parse_interval("30s") == timedelta(seconds=30) + assert Integration._parse_interval("5m") == timedelta(minutes=5) + assert Integration._parse_interval("2h") == timedelta(hours=2) + assert Integration._parse_interval("1d") == timedelta(days=1) + + +def test_parse_interval_invalid(): + with pytest.raises(ConfigurationError, match="Invalid interval"): + Integration._parse_interval("10x") + + +# ── Auth validation in actions ────────────────────────────────────────────── + + +async def test_execute_action_auth_validation_failure(integration): + """Invalid auth credentials (missing required api_key) → VALIDATION_ERROR.""" + + @integration.action("test_action") + class Handler(ActionHandler): + async def execute(self, inputs, context): + return ActionResult(data={"greeting": "hi"}) + + ctx = ExecutionContext(auth={}) # missing required api_key + result = await integration.execute_action("test_action", {"name": "x"}, ctx) + + assert result.type == ResultType.VALIDATION_ERROR + + +async def test_execute_action_no_auth_fields_in_config(tmp_path): + """Config without auth.fields skips auth validation entirely.""" + config = { + "name": "no-auth", + "version": "1.0.0", + "description": "No auth fields", + "auth": {}, + "actions": { + "simple": { + "description": "A simple action", + "input_schema": { + "type": "object", + "properties": {"x": {"type": "string"}}, + "required": ["x"], + }, + "output_schema": { + "type": "object", + "properties": {"y": {"type": "string"}}, + "required": ["y"], + }, + } + }, + } + config_file = tmp_path / "config.json" + config_file.write_text(json.dumps(config)) + intg = Integration.load(config_file) + + @intg.action("simple") + class Handler(ActionHandler): + async def execute(self, inputs, context): + return ActionResult(data={"y": "ok"}) + + ctx = ExecutionContext() # no auth at all + result = await intg.execute_action("simple", {"x": "test"}, ctx) + + assert result.type == ResultType.ACTION + + +# ── Polling trigger edge cases ────────────────────────────────────────────── + + +async def test_execute_polling_trigger_not_registered(integration): + ctx = ExecutionContext(auth={"api_key": "k"}) + with pytest.raises(ValidationError, match="not registered"): + await integration.execute_polling_trigger( + "test_trigger", {"channel": "general"}, None, ctx + ) + + +async def test_execute_polling_trigger_missing_data(integration): + """Record has 'id' but no 'data' field → ValidationError.""" + + @integration.polling_trigger("test_trigger") + class Handler(PollingTriggerHandler): + async def poll(self, inputs, last_poll_ts, context): + return [{"id": "1"}] # missing 'data' + + ctx = ExecutionContext(auth={"api_key": "k"}) + with pytest.raises(ValidationError, match="data"): + await integration.execute_polling_trigger( + "test_trigger", {"channel": "general"}, None, ctx + ) + + +async def test_execute_polling_trigger_output_schema_mismatch(integration): + """Record data doesn't match output_schema → ValidationError.""" + + @integration.polling_trigger("test_trigger") + class Handler(PollingTriggerHandler): + async def poll(self, inputs, last_poll_ts, context): + return [{"id": "1", "data": {"wrong_field": 123}}] + + ctx = ExecutionContext(auth={"api_key": "k"}) + with pytest.raises(ValidationError): + await integration.execute_polling_trigger( + "test_trigger", {"channel": "general"}, None, ctx + ) + + +async def test_execute_polling_trigger_multiple_records(integration): + @integration.polling_trigger("test_trigger") + class Handler(PollingTriggerHandler): + async def poll(self, inputs, last_poll_ts, context): + return [ + {"id": "1", "data": {"message": "first"}}, + {"id": "2", "data": {"message": "second"}}, + {"id": "3", "data": {"message": "third"}}, + ] + + ctx = ExecutionContext(auth={"api_key": "k"}) + records = await integration.execute_polling_trigger( + "test_trigger", {"channel": "general"}, None, ctx + ) + + assert len(records) == 3 + assert [r["id"] for r in records] == ["1", "2", "3"] + + +# ── Connected account edge cases ──────────────────────────────────────────── + + +async def test_get_connected_account_no_handler(integration): + ctx = ExecutionContext(auth={"api_key": "k"}) + with pytest.raises(ValidationError, match="No connected account handler"): + await integration.get_connected_account(ctx) + + +async def test_get_connected_account_auth_validation_failure(integration): + @integration.connected_account() + class Handler(ConnectedAccountHandler): + async def get_account_info(self, context): + return ConnectedAccountInfo(email="a@b.com") + + ctx = ExecutionContext(auth={}) # missing required api_key + with pytest.raises(ValidationError): + await integration.get_connected_account(ctx) + + +async def test_get_connected_account_no_auth_fields(tmp_path): + """Config without auth.fields skips auth validation for connected accounts.""" + config = { + "name": "no-auth", + "version": "1.0.0", + "description": "No auth", + "auth": {}, + "actions": {}, + } + config_file = tmp_path / "config.json" + config_file.write_text(json.dumps(config)) + intg = Integration.load(config_file) + + @intg.connected_account() + class Handler(ConnectedAccountHandler): + async def get_account_info(self, context): + return ConnectedAccountInfo(email="a@b.com") + + ctx = ExecutionContext() + result = await intg.get_connected_account(ctx) + + assert result.type == ResultType.CONNECTED_ACCOUNT + assert result.result.email == "a@b.com" + + +# ── Config loading edge cases ─────────────────────────────────────────────── + + +def test_load_config_no_actions_no_triggers(tmp_path): + config = { + "name": "empty", + "version": "1.0.0", + "description": "No actions or triggers", + "auth": {}, + "actions": {}, + } + config_file = tmp_path / "config.json" + config_file.write_text(json.dumps(config)) + intg = Integration.load(config_file) + + assert intg.config.name == "empty" + assert intg.config.actions == {} + assert intg.config.polling_triggers == {} + + +# ── Multiple actions / re-registration ────────────────────────────────────── + + +async def test_multiple_actions(tmp_path): + """Register and execute two different actions independently.""" + config = { + "name": "multi", + "version": "1.0.0", + "description": "Multi-action", + "auth": {}, + "actions": { + "greet": { + "description": "Greet", + "input_schema": { + "type": "object", + "properties": {"name": {"type": "string"}}, + "required": ["name"], + }, + "output_schema": { + "type": "object", + "properties": {"msg": {"type": "string"}}, + "required": ["msg"], + }, + }, + "add": { + "description": "Add", + "input_schema": { + "type": "object", + "properties": {"a": {"type": "integer"}, "b": {"type": "integer"}}, + "required": ["a", "b"], + }, + "output_schema": { + "type": "object", + "properties": {"sum": {"type": "integer"}}, + "required": ["sum"], + }, + }, + }, + } + config_file = tmp_path / "config.json" + config_file.write_text(json.dumps(config)) + intg = Integration.load(config_file) + + @intg.action("greet") + class GreetHandler(ActionHandler): + async def execute(self, inputs, context): + return ActionResult(data={"msg": f"Hi {inputs['name']}"}) + + @intg.action("add") + class AddHandler(ActionHandler): + async def execute(self, inputs, context): + return ActionResult(data={"sum": inputs["a"] + inputs["b"]}) + + ctx = ExecutionContext() + + r1 = await intg.execute_action("greet", {"name": "Alice"}, ctx) + assert r1.type == ResultType.ACTION + assert r1.result.data == {"msg": "Hi Alice"} + + r2 = await intg.execute_action("add", {"a": 2, "b": 3}, ctx) + assert r2.type == ResultType.ACTION + assert r2.result.data == {"sum": 5} + + +def test_re_register_handler_overwrites(integration): + """Decorating the same action name twice overwrites the first handler.""" + + @integration.action("test_action") + class First(ActionHandler): + async def execute(self, inputs, context): + return ActionResult(data={"greeting": "first"}) + + @integration.action("test_action") + class Second(ActionHandler): + async def execute(self, inputs, context): + return ActionResult(data={"greeting": "second"}) + + assert integration._action_handlers["test_action"] is Second diff --git a/tests/test_results.py b/tests/test_results.py new file mode 100644 index 0000000..b41f7e5 --- /dev/null +++ b/tests/test_results.py @@ -0,0 +1,129 @@ +"""Tests for result dataclasses, exceptions, and enums.""" + +import pytest + +from autohive_integrations_sdk import ( + ActionResult, + ActionError, + ConnectedAccountInfo, + IntegrationResult, + ResultType, + ValidationError, + HTTPError, + RateLimitError, +) +from autohive_integrations_sdk.integration import AuthType + + +# ── ActionResult ───────────────────────────────────────────────────────────── + + +def test_action_result_defaults(): + r = ActionResult(data={"key": "value"}) + assert r.data == {"key": "value"} + assert r.cost_usd is None + + +def test_action_result_with_cost(): + r = ActionResult(data={}, cost_usd=1.23) + assert r.cost_usd == 1.23 + + +# ── ActionError ────────────────────────────────────────────────────────────── + + +def test_action_error(): + e = ActionError(message="boom", cost_usd=0.01) + assert e.message == "boom" + assert e.cost_usd == 0.01 + + +# ── ConnectedAccountInfo ──────────────────────────────────────────────────── + + +def test_connected_account_info_defaults(): + info = ConnectedAccountInfo() + assert info.email is None + assert info.first_name is None + assert info.last_name is None + assert info.username is None + assert info.user_id is None + assert info.avatar_url is None + assert info.organization is None + + +def test_connected_account_info_fields(): + info = ConnectedAccountInfo( + email="a@b.com", + first_name="Alice", + last_name="Smith", + username="asmith", + user_id="42", + avatar_url="https://img.example.com/a.png", + organization="Acme", + ) + assert info.email == "a@b.com" + assert info.first_name == "Alice" + assert info.organization == "Acme" + + +# ── IntegrationResult ─────────────────────────────────────────────────────── + + +def test_integration_result(): + ar = ActionResult(data={"x": 1}) + ir = IntegrationResult(version="1.0.0", type=ResultType.ACTION, result=ar) + assert ir.version == "1.0.0" + assert ir.type == ResultType.ACTION + assert ir.result is ar + + +# ── Exceptions ─────────────────────────────────────────────────────────────── + + +def test_validation_error(): + err = ValidationError( + message="bad input", + schema="the_schema", + inputs="the_inputs", + source="input", + ) + assert err.message == "bad input" + assert err.schema == "the_schema" + assert err.inputs == "the_inputs" + assert err.source == "input" + assert str(err) == "bad input" + + +def test_http_error(): + err = HTTPError(status=404, message="not found", response_data={"detail": "nope"}) + assert err.status == 404 + assert err.message == "not found" + assert err.response_data == {"detail": "nope"} + assert "404" in str(err) + + +def test_rate_limit_error(): + err = RateLimitError(retry_after=30, status=429, message="slow down") + assert isinstance(err, HTTPError) + assert err.retry_after == 30 + assert err.status == 429 + + +# ── Enums ──────────────────────────────────────────────────────────────────── + + +def test_result_type_enum(): + assert ResultType.ACTION.value == "action" + assert ResultType.ACTION_ERROR.value == "action_error" + assert ResultType.CONNECTED_ACCOUNT.value == "connected_account" + assert ResultType.ERROR.value == "error" + assert ResultType.VALIDATION_ERROR.value == "validation_error" + + +def test_auth_type_enum(): + assert AuthType.PlatformOauth2.value == "PlatformOauth2" + assert AuthType.PlatformTeams.value == "PlatformTeams" + assert AuthType.ApiKey.value == "ApiKey" + assert AuthType.Basic.value == "Basic" + assert AuthType.Custom.value == "Custom" From 1f208002da491246c403b5b1e7d4b0faa069e818 Mon Sep 17 00:00:00 2001 From: Kai Koenig Date: Fri, 10 Apr 2026 10:50:47 +1200 Subject: [PATCH 02/18] Fix ExecutionContext tests for FetchResponse return type --- tests/test_execution_context.py | 22 ++++++++++++---------- 1 file changed, 12 insertions(+), 10 deletions(-) diff --git a/tests/test_execution_context.py b/tests/test_execution_context.py index 1008bc7..cdc5f8a 100644 --- a/tests/test_execution_context.py +++ b/tests/test_execution_context.py @@ -32,7 +32,8 @@ async def test_fetch_get_json(mock_aio): async with ExecutionContext() as ctx: data = await ctx.fetch(BASE_URL) - assert data == {"ok": True} + assert data.data == {"ok": True} + assert data.status == 200 async def test_fetch_post_json(mock_aio): @@ -41,7 +42,7 @@ async def test_fetch_post_json(mock_aio): async with ExecutionContext() as ctx: data = await ctx.fetch(BASE_URL, method="POST", json={"name": "test"}) - assert data == {"id": 1} + assert data.data == {"id": 1} async def test_fetch_text_response(mock_aio): @@ -50,7 +51,7 @@ async def test_fetch_text_response(mock_aio): async with ExecutionContext() as ctx: data = await ctx.fetch(BASE_URL) - assert data == "plain text" + assert data.data == "plain text" async def test_fetch_empty_response_204(mock_aio): @@ -59,7 +60,8 @@ async def test_fetch_empty_response_204(mock_aio): async with ExecutionContext() as ctx: data = await ctx.fetch(BASE_URL) - assert data is None + assert data.data is None + assert data.status == 204 # ── Error handling ─────────────────────────────────────────────────────────── @@ -137,7 +139,7 @@ async def test_fetch_query_params(mock_aio): async with ExecutionContext() as ctx: data = await ctx.fetch(BASE_URL, params={"page": 1, "limit": 10}) - assert data == {"ok": True} + assert data.data == {"ok": True} # ── Retry logic ────────────────────────────────────────────────────────────── @@ -152,7 +154,7 @@ async def test_fetch_retry_on_client_error(mock_aio): with patch("asyncio.sleep", return_value=None) as mock_sleep: data = await ctx.fetch(BASE_URL) - assert data == {"ok": True} + assert data.data == {"ok": True} mock_sleep.assert_awaited_once() @@ -205,7 +207,7 @@ async def test_fetch_nested_params(mock_aio): async with ExecutionContext() as ctx: data = await ctx.fetch(BASE_URL, params={"filter": {"status": "active"}}) - assert data == {"ok": True} + assert data.data == {"ok": True} # ── URL with existing query string ────────────────────────────────────────── @@ -220,7 +222,7 @@ async def test_fetch_params_appended_with_ampersand(mock_aio): async with ExecutionContext() as ctx: data = await ctx.fetch(base_with_query, params={"page": 2}) - assert data == {"ok": True} + assert data.data == {"ok": True} # ── Timeout triggers retry ────────────────────────────────────────────────── @@ -238,7 +240,7 @@ async def test_fetch_retry_on_timeout(mock_aio): with patch("asyncio.sleep", return_value=None): data = await ctx.fetch(BASE_URL) - assert data == {"ok": True} + assert data.data == {"ok": True} # ── Max retries exhausted ─────────────────────────────────────────────────── @@ -309,7 +311,7 @@ async def test_fetch_creates_session_without_context_manager(mock_aio): assert ctx._session is None data = await ctx.fetch(BASE_URL) - assert data == {"ok": True} + assert data.data == {"ok": True} assert ctx._session is not None # Clean up From ea75516b56694736e28577e921c1e85731a67f6a Mon Sep 17 00:00:00 2001 From: Kai Koenig Date: Fri, 10 Apr 2026 10:51:26 +1200 Subject: [PATCH 03/18] Add tests for FetchResponse dataclass and headers --- tests/test_execution_context.py | 1 + tests/test_results.py | 22 ++++++++++++++++++++++ 2 files changed, 23 insertions(+) diff --git a/tests/test_execution_context.py b/tests/test_execution_context.py index cdc5f8a..f4c2a4c 100644 --- a/tests/test_execution_context.py +++ b/tests/test_execution_context.py @@ -34,6 +34,7 @@ async def test_fetch_get_json(mock_aio): assert data.data == {"ok": True} assert data.status == 200 + assert "Content-Type" in data.headers async def test_fetch_post_json(mock_aio): diff --git a/tests/test_results.py b/tests/test_results.py index b41f7e5..4b68bd7 100644 --- a/tests/test_results.py +++ b/tests/test_results.py @@ -11,6 +11,7 @@ ValidationError, HTTPError, RateLimitError, + FetchResponse, ) from autohive_integrations_sdk.integration import AuthType @@ -127,3 +128,24 @@ def test_auth_type_enum(): assert AuthType.ApiKey.value == "ApiKey" assert AuthType.Basic.value == "Basic" assert AuthType.Custom.value == "Custom" + + +# ── FetchResponse ─────────────────────────────────────────────────────────── + + +def test_fetch_response_json(): + r = FetchResponse(status=200, headers={"Content-Type": "application/json"}, data={"ok": True}) + assert r.status == 200 + assert r.data == {"ok": True} + assert r.headers["Content-Type"] == "application/json" + + +def test_fetch_response_none_data(): + r = FetchResponse(status=204, headers={}, data=None) + assert r.status == 204 + assert r.data is None + + +def test_fetch_response_text_data(): + r = FetchResponse(status=200, headers={"Content-Type": "text/plain"}, data="hello") + assert r.data == "hello" From c634fce1d9fcc47eea969e9578338b3d1f9fbf0c Mon Sep 17 00:00:00 2001 From: Kai Koenig Date: Fri, 10 Apr 2026 11:54:53 +1200 Subject: [PATCH 04/18] Fix: preserve pytest exit code with pipefail in CI --- .github/workflows/tests.yml | 1 + 1 file changed, 1 insertion(+) diff --git a/.github/workflows/tests.yml b/.github/workflows/tests.yml index b1bb609..a2acab2 100644 --- a/.github/workflows/tests.yml +++ b/.github/workflows/tests.yml @@ -28,6 +28,7 @@ jobs: - name: Run tests with coverage run: | + set -o pipefail python -m pytest tests/ -v --tb=short \ --cov=autohive_integrations_sdk \ --cov-report=term-missing \ From e9cef3f73eadb7a96be79b66254ffabba9b246dd Mon Sep 17 00:00:00 2001 From: Kai Koenig Date: Fri, 10 Apr 2026 11:57:36 +1200 Subject: [PATCH 05/18] Add testing and coverage documentation to AGENTS.md, README.md, and RELEASING.md --- AGENTS.md | 14 ++++++++++++++ README.md | 19 +++++++++++++++++++ RELEASING.md | 5 +++++ 3 files changed, 38 insertions(+) diff --git a/AGENTS.md b/AGENTS.md index cac0b80..db054c1 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -10,6 +10,20 @@ ``` - Always commit the regenerated docs alongside the code changes that caused them. +## Testing + +- Tests live in `tests/` and use **pytest** with **pytest-asyncio** and **aioresponses**. +- After any code change in `src/autohive_integrations_sdk/`, run the test suite: + ``` + python -m pytest tests/ -v + ``` +- Run with coverage to check for regressions: + ``` + python -m pytest tests/ -v --cov=autohive_integrations_sdk --cov-report=term-missing + ``` +- Add or update tests for any new or modified functionality. Aim to maintain ≥95% coverage. +- CI runs automatically on PRs via GitHub Actions (`.github/workflows/tests.yml`). + ## Releasing - Follow the process in [RELEASING.md](RELEASING.md). diff --git a/README.md b/README.md index 9271625..5e81813 100644 --- a/README.md +++ b/README.md @@ -26,6 +26,25 @@ Start with the **[Building Your First Integration](docs/manual/building_your_fir | [`samples/template/`](samples/template/) | Clean starter template — copy this to begin a new integration | | [`samples/api-fetch/`](samples/api-fetch/) | Working example with unauthenticated, Basic Auth, and Bearer token API calls | +## Testing + +Install test dependencies: +```bash +pip install -e ".[test]" +``` + +Run tests: +```bash +python -m pytest tests/ -v +``` + +Run with coverage: +```bash +python -m pytest tests/ -v --cov=autohive_integrations_sdk --cov-report=term-missing +``` + +CI runs automatically on PRs via GitHub Actions — see [`.github/workflows/tests.yml`](.github/workflows/tests.yml). + ## Validation & CI Integration validation is handled by the [autohive-integrations-tooling](https://github.com/Autohive-AI/autohive-integrations-tooling) repo. See its README for CI pipeline setup and the integration checklist. diff --git a/RELEASING.md b/RELEASING.md index c4be699..d359795 100644 --- a/RELEASING.md +++ b/RELEASING.md @@ -10,6 +10,11 @@ - Authors - Dependencies +* Run the test suite and ensure all tests pass: + ``` + python -m pytest tests/ -v --cov=autohive_integrations_sdk --cov-report=term-missing + ``` + * Release to PyPi: - `build` is required (`python3 -m pip install --upgrade build`) - `twine` is required (`python3 -m pip install --upgrade twine`) From c9deb10bbfc12ad3f2fb3f2246aab6daf75fff1b Mon Sep 17 00:00:00 2001 From: Kai Koenig Date: Fri, 10 Apr 2026 12:07:10 +1200 Subject: [PATCH 06/18] Add tests to reach 99% coverage: default config path, polling trigger validation, JSON parse fallback --- tests/test_execution_context.py | 18 +++++++++++++ tests/test_integration.py | 45 +++++++++++++++++++++++++++++++++ 2 files changed, 63 insertions(+) diff --git a/tests/test_execution_context.py b/tests/test_execution_context.py index f4c2a4c..3e22ae8 100644 --- a/tests/test_execution_context.py +++ b/tests/test_execution_context.py @@ -336,3 +336,21 @@ async def test_fetch_http_error_json_body(mock_aio): assert exc_info.value.status == 400 assert exc_info.value.response_data == {"error": "bad request", "code": "INVALID"} + + +# ── Response parse fallback ───────────────────────────────────────────────── + + +async def test_fetch_json_parse_failure_falls_back_to_text(mock_aio): + """When Content-Type says JSON but body isn't valid JSON, falls back to text.""" + mock_aio.get( + BASE_URL, + status=200, + body="not valid json", + content_type="application/json", + ) + + async with ExecutionContext() as ctx: + data = await ctx.fetch(BASE_URL) + + assert data.data == "not valid json" diff --git a/tests/test_integration.py b/tests/test_integration.py index 4fea20a..89342a5 100644 --- a/tests/test_integration.py +++ b/tests/test_integration.py @@ -410,6 +410,12 @@ async def get_account_info(self, context): # ── Config loading edge cases ─────────────────────────────────────────────── +def test_load_config_default_path(): + """Integration.load() with no args uses __file__-relative default path.""" + with pytest.raises(ConfigurationError, match="not found"): + Integration.load() # no config.json at the default location + + def test_load_config_no_actions_no_triggers(tmp_path): config = { "name": "empty", @@ -491,6 +497,45 @@ async def execute(self, inputs, context): assert r2.result.data == {"sum": 5} +def test_register_polling_trigger_not_in_config(integration): + with pytest.raises(ConfigurationError, match="not defined in config"): + + @integration.polling_trigger("nonexistent_trigger") + class Handler(PollingTriggerHandler): + async def poll(self, inputs, last_poll_ts, context): + return [] + + +async def test_execute_polling_trigger_invalid_inputs(integration): + """Invalid inputs against trigger input_schema → ValidationError.""" + + @integration.polling_trigger("test_trigger") + class Handler(PollingTriggerHandler): + async def poll(self, inputs, last_poll_ts, context): + return [] + + ctx = ExecutionContext(auth={"api_key": "k"}) + with pytest.raises(ValidationError): + await integration.execute_polling_trigger( + "test_trigger", {}, None, ctx # missing required 'channel' + ) + + +async def test_execute_polling_trigger_auth_failure(integration): + """Invalid auth credentials for polling trigger → ValidationError.""" + + @integration.polling_trigger("test_trigger") + class Handler(PollingTriggerHandler): + async def poll(self, inputs, last_poll_ts, context): + return [] + + ctx = ExecutionContext(auth={}) # missing required api_key + with pytest.raises(ValidationError): + await integration.execute_polling_trigger( + "test_trigger", {"channel": "general"}, None, ctx + ) + + def test_re_register_handler_overwrites(integration): """Decorating the same action name twice overwrites the first handler.""" From f03a688cb76b325d9c466a1fc2f5c62b7eb5d50f Mon Sep 17 00:00:00 2001 From: Kai Koenig Date: Fri, 10 Apr 2026 12:31:05 +1200 Subject: [PATCH 07/18] Add CI, PyPI, Python, and license badges to READMEs; add coverage badge support to CI --- .github/workflows/tests.yml | 18 ++++++++++++++++-- README.md | 5 +++++ README_PYPI.md | 1 + 3 files changed, 22 insertions(+), 2 deletions(-) diff --git a/.github/workflows/tests.yml b/.github/workflows/tests.yml index a2acab2..7c903dd 100644 --- a/.github/workflows/tests.yml +++ b/.github/workflows/tests.yml @@ -35,9 +35,23 @@ jobs: --cov-report=xml:coverage.xml \ | tee pytest-coverage.txt - - name: Post coverage comment on PR - if: github.event_name == 'pull_request' + - name: Coverage report uses: MishaKav/pytest-coverage-comment@main + id: coverage-comment with: pytest-coverage-path: pytest-coverage.txt pytest-xml-coverage-path: coverage.xml + create-new-comment: ${{ github.event_name == 'pull_request' }} + + - name: Update coverage badge + if: github.ref == 'refs/heads/master' && github.event_name == 'push' + uses: schneegans/dynamic-badges-action@v1.7.0 + with: + auth: ${{ secrets.GIST_SECRET }} + gistID: integrations-sdk-coverage + filename: coverage-badge.json + label: coverage + message: ${{ steps.coverage-comment.outputs.coverage }} + valColorRange: ${{ steps.coverage-comment.outputs.coverage }} + minColorRange: 50 + maxColorRange: 100 diff --git a/README.md b/README.md index 5e81813..54956d5 100644 --- a/README.md +++ b/README.md @@ -1,5 +1,10 @@ # Integrations SDK for Autohive +[![Tests](https://github.com/Autohive-AI/integrations-sdk/actions/workflows/tests.yml/badge.svg)](https://github.com/Autohive-AI/integrations-sdk/actions/workflows/tests.yml) +[![PyPI version](https://img.shields.io/pypi/v/autohive-integrations-sdk)](https://pypi.org/project/autohive-integrations-sdk/) +[![Python](https://img.shields.io/pypi/pyversions/autohive-integrations-sdk)](https://pypi.org/project/autohive-integrations-sdk/) +[![License: MIT](https://img.shields.io/pypi/l/autohive-integrations-sdk)](https://github.com/Autohive-AI/integrations-sdk/blob/master/LICENSE) + ## Overview This is the SDK for building integrations into Autohive's AI agent platform. diff --git a/README_PYPI.md b/README_PYPI.md index 17edc7e..f711f86 100644 --- a/README_PYPI.md +++ b/README_PYPI.md @@ -1,5 +1,6 @@ # Integrations SDK for Autohive +[![Tests](https://github.com/Autohive-AI/integrations-sdk/actions/workflows/tests.yml/badge.svg)](https://github.com/Autohive-AI/integrations-sdk/actions/workflows/tests.yml) [![PyPI version](https://img.shields.io/pypi/v/autohive-integrations-sdk)](https://pypi.org/project/autohive-integrations-sdk/) [![Python](https://img.shields.io/pypi/pyversions/autohive-integrations-sdk)](https://pypi.org/project/autohive-integrations-sdk/) [![License: MIT](https://img.shields.io/pypi/l/autohive-integrations-sdk)](https://github.com/Autohive-AI/integrations-sdk/blob/master/LICENSE) From 2352782c8bd80166acc526b1d42ec8cab710d0a1 Mon Sep 17 00:00:00 2001 From: Kai Koenig Date: Fri, 10 Apr 2026 12:39:27 +1200 Subject: [PATCH 08/18] Add coverage badge to READMEs --- README.md | 1 + README_PYPI.md | 1 + 2 files changed, 2 insertions(+) diff --git a/README.md b/README.md index 54956d5..56fbd01 100644 --- a/README.md +++ b/README.md @@ -1,6 +1,7 @@ # Integrations SDK for Autohive [![Tests](https://github.com/Autohive-AI/integrations-sdk/actions/workflows/tests.yml/badge.svg)](https://github.com/Autohive-AI/integrations-sdk/actions/workflows/tests.yml) +[![Coverage](https://img.shields.io/endpoint?url=https://gist.githubusercontent.com/TheRealAgentK/e8adf35c8508876ab8ba09422ddc2535/raw/coverage-badge.json)](https://github.com/Autohive-AI/integrations-sdk/actions/workflows/tests.yml) [![PyPI version](https://img.shields.io/pypi/v/autohive-integrations-sdk)](https://pypi.org/project/autohive-integrations-sdk/) [![Python](https://img.shields.io/pypi/pyversions/autohive-integrations-sdk)](https://pypi.org/project/autohive-integrations-sdk/) [![License: MIT](https://img.shields.io/pypi/l/autohive-integrations-sdk)](https://github.com/Autohive-AI/integrations-sdk/blob/master/LICENSE) diff --git a/README_PYPI.md b/README_PYPI.md index f711f86..95d5d9b 100644 --- a/README_PYPI.md +++ b/README_PYPI.md @@ -1,6 +1,7 @@ # Integrations SDK for Autohive [![Tests](https://github.com/Autohive-AI/integrations-sdk/actions/workflows/tests.yml/badge.svg)](https://github.com/Autohive-AI/integrations-sdk/actions/workflows/tests.yml) +[![Coverage](https://img.shields.io/endpoint?url=https://gist.githubusercontent.com/TheRealAgentK/e8adf35c8508876ab8ba09422ddc2535/raw/coverage-badge.json)](https://github.com/Autohive-AI/integrations-sdk/actions/workflows/tests.yml) [![PyPI version](https://img.shields.io/pypi/v/autohive-integrations-sdk)](https://pypi.org/project/autohive-integrations-sdk/) [![Python](https://img.shields.io/pypi/pyversions/autohive-integrations-sdk)](https://pypi.org/project/autohive-integrations-sdk/) [![License: MIT](https://img.shields.io/pypi/l/autohive-integrations-sdk)](https://github.com/Autohive-AI/integrations-sdk/blob/master/LICENSE) From 949272a1bc8f3f916a014245549a90221759ec78 Mon Sep 17 00:00:00 2001 From: Kai Koenig Date: Fri, 10 Apr 2026 12:39:51 +1200 Subject: [PATCH 09/18] Update gistID for coverage badge --- .github/workflows/tests.yml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/.github/workflows/tests.yml b/.github/workflows/tests.yml index 7c903dd..395fadd 100644 --- a/.github/workflows/tests.yml +++ b/.github/workflows/tests.yml @@ -48,7 +48,7 @@ jobs: uses: schneegans/dynamic-badges-action@v1.7.0 with: auth: ${{ secrets.GIST_SECRET }} - gistID: integrations-sdk-coverage + gistID: e8adf35c8508876ab8ba09422ddc2535 filename: coverage-badge.json label: coverage message: ${{ steps.coverage-comment.outputs.coverage }} From 856511dcf72184665adcba2f75d0c2f95025ccaa Mon Sep 17 00:00:00 2001 From: Kai Koenig Date: Fri, 10 Apr 2026 12:41:44 +1200 Subject: [PATCH 10/18] Include commit hash and message in coverage report title --- .github/workflows/tests.yml | 7 +++++++ 1 file changed, 7 insertions(+) diff --git a/.github/workflows/tests.yml b/.github/workflows/tests.yml index 395fadd..0acdd2d 100644 --- a/.github/workflows/tests.yml +++ b/.github/workflows/tests.yml @@ -26,6 +26,12 @@ jobs: python -m pip install --upgrade pip pip install -e ".[test]" + - name: Get commit info + id: commit-info + run: | + echo "short_sha=$(git rev-parse --short HEAD)" >> "$GITHUB_OUTPUT" + echo "message=$(git log -1 --pretty=%s)" >> "$GITHUB_OUTPUT" + - name: Run tests with coverage run: | set -o pipefail @@ -42,6 +48,7 @@ jobs: pytest-coverage-path: pytest-coverage.txt pytest-xml-coverage-path: coverage.xml create-new-comment: ${{ github.event_name == 'pull_request' }} + title: "Coverage — `${{ steps.commit-info.outputs.short_sha }}` ${{ steps.commit-info.outputs.message }}" - name: Update coverage badge if: github.ref == 'refs/heads/master' && github.event_name == 'push' From 773c8887094d44cbcb4884ced75e5f55c58d3db0 Mon Sep 17 00:00:00 2001 From: Kai Koenig Date: Fri, 10 Apr 2026 12:45:55 +1200 Subject: [PATCH 11/18] Add release notes step to RELEASING.md and action-error-demo sample to README --- README.md | 1 + RELEASING.md | 2 ++ 2 files changed, 3 insertions(+) diff --git a/README.md b/README.md index 56fbd01..71cac4d 100644 --- a/README.md +++ b/README.md @@ -31,6 +31,7 @@ Start with the **[Building Your First Integration](docs/manual/building_your_fir |--------|-------------| | [`samples/template/`](samples/template/) | Clean starter template — copy this to begin a new integration | | [`samples/api-fetch/`](samples/api-fetch/) | Working example with unauthenticated, Basic Auth, and Bearer token API calls | +| [`samples/action-error-demo/`](samples/action-error-demo/) | Demonstrates `ActionError` for expected application-level errors | ## Testing diff --git a/RELEASING.md b/RELEASING.md index d359795..24c2f13 100644 --- a/RELEASING.md +++ b/RELEASING.md @@ -10,6 +10,8 @@ - Authors - Dependencies +* Update `RELEASENOTES.md` with an entry for the new version. + * Run the test suite and ensure all tests pass: ``` python -m pytest tests/ -v --cov=autohive_integrations_sdk --cov-report=term-missing From 16aea076530b13df37e65fd5781f67957b27f8e4 Mon Sep 17 00:00:00 2001 From: Kai Koenig Date: Fri, 10 Apr 2026 12:48:36 +1200 Subject: [PATCH 12/18] Use actual PR head commit for coverage report title instead of merge commit --- .github/workflows/tests.yml | 9 +++++++-- 1 file changed, 7 insertions(+), 2 deletions(-) diff --git a/.github/workflows/tests.yml b/.github/workflows/tests.yml index 0acdd2d..666e422 100644 --- a/.github/workflows/tests.yml +++ b/.github/workflows/tests.yml @@ -29,8 +29,13 @@ jobs: - name: Get commit info id: commit-info run: | - echo "short_sha=$(git rev-parse --short HEAD)" >> "$GITHUB_OUTPUT" - echo "message=$(git log -1 --pretty=%s)" >> "$GITHUB_OUTPUT" + if [ "${{ github.event_name }}" = "pull_request" ]; then + COMMIT_SHA="${{ github.event.pull_request.head.sha }}" + else + COMMIT_SHA="${{ github.sha }}" + fi + echo "short_sha=$(echo "$COMMIT_SHA" | cut -c1-7)" >> "$GITHUB_OUTPUT" + echo "message=$(git log -1 --pretty=%s "$COMMIT_SHA")" >> "$GITHUB_OUTPUT" - name: Run tests with coverage run: | From 1061a3a3ca832d02c6cb7c16388f8d2f91defa98 Mon Sep 17 00:00:00 2001 From: Kai Koenig Date: Fri, 10 Apr 2026 12:52:43 +1200 Subject: [PATCH 13/18] Show commit message and author in coverage report title --- .github/workflows/tests.yml | 5 ++++- 1 file changed, 4 insertions(+), 1 deletion(-) diff --git a/.github/workflows/tests.yml b/.github/workflows/tests.yml index 666e422..4d1cce5 100644 --- a/.github/workflows/tests.yml +++ b/.github/workflows/tests.yml @@ -15,6 +15,8 @@ jobs: steps: - uses: actions/checkout@v4 + with: + fetch-depth: 0 - name: Set up Python ${{ matrix.python-version }} uses: actions/setup-python@v5 @@ -36,6 +38,7 @@ jobs: fi echo "short_sha=$(echo "$COMMIT_SHA" | cut -c1-7)" >> "$GITHUB_OUTPUT" echo "message=$(git log -1 --pretty=%s "$COMMIT_SHA")" >> "$GITHUB_OUTPUT" + echo "author=$(git log -1 --pretty=%an "$COMMIT_SHA")" >> "$GITHUB_OUTPUT" - name: Run tests with coverage run: | @@ -53,7 +56,7 @@ jobs: pytest-coverage-path: pytest-coverage.txt pytest-xml-coverage-path: coverage.xml create-new-comment: ${{ github.event_name == 'pull_request' }} - title: "Coverage — `${{ steps.commit-info.outputs.short_sha }}` ${{ steps.commit-info.outputs.message }}" + title: "Coverage — `${{ steps.commit-info.outputs.short_sha }}` (${{ steps.commit-info.outputs.message }}) by @${{ steps.commit-info.outputs.author }}" - name: Update coverage badge if: github.ref == 'refs/heads/master' && github.event_name == 'push' From 6dc609dd4534090f9729171938a4ef82fab6dcd2 Mon Sep 17 00:00:00 2001 From: Kai Koenig Date: Fri, 10 Apr 2026 12:54:57 +1200 Subject: [PATCH 14/18] Use GitHub username for coverage title and always show coverage table --- .github/workflows/tests.yml | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/.github/workflows/tests.yml b/.github/workflows/tests.yml index 4d1cce5..7d6b9bc 100644 --- a/.github/workflows/tests.yml +++ b/.github/workflows/tests.yml @@ -38,7 +38,6 @@ jobs: fi echo "short_sha=$(echo "$COMMIT_SHA" | cut -c1-7)" >> "$GITHUB_OUTPUT" echo "message=$(git log -1 --pretty=%s "$COMMIT_SHA")" >> "$GITHUB_OUTPUT" - echo "author=$(git log -1 --pretty=%an "$COMMIT_SHA")" >> "$GITHUB_OUTPUT" - name: Run tests with coverage run: | @@ -56,7 +55,8 @@ jobs: pytest-coverage-path: pytest-coverage.txt pytest-xml-coverage-path: coverage.xml create-new-comment: ${{ github.event_name == 'pull_request' }} - title: "Coverage — `${{ steps.commit-info.outputs.short_sha }}` (${{ steps.commit-info.outputs.message }}) by @${{ steps.commit-info.outputs.author }}" + hide-report: false + title: "Coverage — `${{ steps.commit-info.outputs.short_sha }}` (${{ steps.commit-info.outputs.message }}) by @${{ github.actor }}" - name: Update coverage badge if: github.ref == 'refs/heads/master' && github.event_name == 'push' From 11d472a8b0b9499cdcf1bac85aab7bc0f8b54f65 Mon Sep 17 00:00:00 2001 From: Kai Koenig Date: Fri, 10 Apr 2026 12:57:35 +1200 Subject: [PATCH 15/18] Replace MishaKav action with custom flat coverage comment --- .github/workflows/tests.yml | 72 +++++++++++++++++++++++++++++++------ 1 file changed, 62 insertions(+), 10 deletions(-) diff --git a/.github/workflows/tests.yml b/.github/workflows/tests.yml index 7d6b9bc..1323170 100644 --- a/.github/workflows/tests.yml +++ b/.github/workflows/tests.yml @@ -6,6 +6,10 @@ on: pull_request: branches: [master] +permissions: + contents: read + pull-requests: write + jobs: test: runs-on: ubuntu-latest @@ -48,15 +52,63 @@ jobs: --cov-report=xml:coverage.xml \ | tee pytest-coverage.txt - - name: Coverage report - uses: MishaKav/pytest-coverage-comment@main - id: coverage-comment + - name: Parse coverage + id: coverage + run: | + TOTAL=$(grep '^TOTAL' pytest-coverage.txt | awk '{print $(NF-1)}') + echo "total=${TOTAL}" >> "$GITHUB_OUTPUT" + + # Build per-file table rows + TABLE="" + while IFS= read -r line; do + FILE=$(echo "$line" | awk '{print $1}') + STMTS=$(echo "$line" | awk '{print $2}') + MISS=$(echo "$line" | awk '{print $3}') + COV=$(echo "$line" | awk '{print $4}') + MISSING=$(echo "$line" | awk '{for(i=5;i<=NF;i++) printf "%s ", $i; print ""}' | xargs) + TABLE="${TABLE}| \`${FILE}\` | ${STMTS} | ${MISS} | ${COV} | ${MISSING} |"$'\n' + done < <(grep '^src/' pytest-coverage.txt) + + EOF=$(dd if=/dev/urandom bs=15 count=1 status=none | base64) + echo "table<<${EOF}" >> "$GITHUB_OUTPUT" + echo "${TABLE}" >> "$GITHUB_OUTPUT" + echo "${EOF}" >> "$GITHUB_OUTPUT" + + - name: Post coverage comment on PR + if: github.event_name == 'pull_request' + uses: actions/github-script@v7 with: - pytest-coverage-path: pytest-coverage.txt - pytest-xml-coverage-path: coverage.xml - create-new-comment: ${{ github.event_name == 'pull_request' }} - hide-report: false - title: "Coverage — `${{ steps.commit-info.outputs.short_sha }}` (${{ steps.commit-info.outputs.message }}) by @${{ github.actor }}" + script: | + const title = `Coverage — \`${{ steps.commit-info.outputs.short_sha }}\` (${{ steps.commit-info.outputs.message }}) by @${{ github.actor }}`; + const total = `${{ steps.coverage.outputs.total }}`; + const table = `${{ steps.coverage.outputs.table }}`; + const marker = ''; + + const body = `${marker}\n### ${title}\n\n**Total coverage: ${total}**\n\n| File | Stmts | Miss | Cover | Missing |\n|------|-------|------|-------|---------|\n${table}`; + + // Find and update existing comment + const { data: comments } = await github.rest.issues.listComments({ + owner: context.repo.owner, + repo: context.repo.repo, + issue_number: context.issue.number, + }); + const existing = comments.find(c => c.body.includes(marker)); + + if (existing) { + await github.rest.issues.updateComment({ + owner: context.repo.owner, + repo: context.repo.repo, + comment_id: existing.id, + body, + }); + } else { + await github.rest.issues.createComment({ + owner: context.repo.owner, + repo: context.repo.repo, + issue_number: context.issue.number, + body, + }); + } - name: Update coverage badge if: github.ref == 'refs/heads/master' && github.event_name == 'push' @@ -66,7 +118,7 @@ jobs: gistID: e8adf35c8508876ab8ba09422ddc2535 filename: coverage-badge.json label: coverage - message: ${{ steps.coverage-comment.outputs.coverage }} - valColorRange: ${{ steps.coverage-comment.outputs.coverage }} + message: ${{ steps.coverage.outputs.total }} + valColorRange: ${{ steps.coverage.outputs.total }} minColorRange: 50 maxColorRange: 100 From 71364c5b7b3c8f0216a0ec162b9d3b734e82cd22 Mon Sep 17 00:00:00 2001 From: Kai Koenig Date: Fri, 10 Apr 2026 13:00:54 +1200 Subject: [PATCH 16/18] Fix coverage comment: write markdown to file to avoid backtick escaping issues --- .github/workflows/tests.yml | 44 ++++++++++++++++++------------------- 1 file changed, 22 insertions(+), 22 deletions(-) diff --git a/.github/workflows/tests.yml b/.github/workflows/tests.yml index 1323170..58874b8 100644 --- a/.github/workflows/tests.yml +++ b/.github/workflows/tests.yml @@ -52,41 +52,41 @@ jobs: --cov-report=xml:coverage.xml \ | tee pytest-coverage.txt - - name: Parse coverage + - name: Build coverage comment id: coverage run: | TOTAL=$(grep '^TOTAL' pytest-coverage.txt | awk '{print $(NF-1)}') echo "total=${TOTAL}" >> "$GITHUB_OUTPUT" - # Build per-file table rows - TABLE="" - while IFS= read -r line; do - FILE=$(echo "$line" | awk '{print $1}') - STMTS=$(echo "$line" | awk '{print $2}') - MISS=$(echo "$line" | awk '{print $3}') - COV=$(echo "$line" | awk '{print $4}') - MISSING=$(echo "$line" | awk '{for(i=5;i<=NF;i++) printf "%s ", $i; print ""}' | xargs) - TABLE="${TABLE}| \`${FILE}\` | ${STMTS} | ${MISS} | ${COV} | ${MISSING} |"$'\n' - done < <(grep '^src/' pytest-coverage.txt) - - EOF=$(dd if=/dev/urandom bs=15 count=1 status=none | base64) - echo "table<<${EOF}" >> "$GITHUB_OUTPUT" - echo "${TABLE}" >> "$GITHUB_OUTPUT" - echo "${EOF}" >> "$GITHUB_OUTPUT" + TITLE="Coverage — \`${{ steps.commit-info.outputs.short_sha }}\` (${{ steps.commit-info.outputs.message }}) by @${{ github.actor }}" + + { + echo "" + echo "### ${TITLE}" + echo "" + echo "**Total coverage: ${TOTAL}**" + echo "" + echo "| File | Stmts | Miss | Cover | Missing |" + echo "|------|-------|------|-------|---------|" + grep '^src/' pytest-coverage.txt | while IFS= read -r line; do + FILE=$(echo "$line" | awk '{print $1}') + STMTS=$(echo "$line" | awk '{print $2}') + MISS=$(echo "$line" | awk '{print $3}') + COV=$(echo "$line" | awk '{print $4}') + MISSING=$(echo "$line" | awk '{for(i=5;i<=NF;i++) printf "%s ", $i; print ""}' | xargs) + echo "| \`${FILE}\` | ${STMTS} | ${MISS} | ${COV} | ${MISSING} |" + done + } > coverage-comment.md - name: Post coverage comment on PR if: github.event_name == 'pull_request' uses: actions/github-script@v7 with: script: | - const title = `Coverage — \`${{ steps.commit-info.outputs.short_sha }}\` (${{ steps.commit-info.outputs.message }}) by @${{ github.actor }}`; - const total = `${{ steps.coverage.outputs.total }}`; - const table = `${{ steps.coverage.outputs.table }}`; + const fs = require('fs'); + const body = fs.readFileSync('coverage-comment.md', 'utf8'); const marker = ''; - const body = `${marker}\n### ${title}\n\n**Total coverage: ${total}**\n\n| File | Stmts | Miss | Cover | Missing |\n|------|-------|------|-------|---------|\n${table}`; - - // Find and update existing comment const { data: comments } = await github.rest.issues.listComments({ owner: context.repo.owner, repo: context.repo.repo, From 05d9044cbb43ebeaf4e1ac92690811b36ccbb449 Mon Sep 17 00:00:00 2001 From: Kai Koenig Date: Fri, 10 Apr 2026 13:03:22 +1200 Subject: [PATCH 17/18] Link commit SHA and missing line numbers in coverage comment --- .github/workflows/tests.yml | 36 ++++++++++++++++++++++++++++++++++-- 1 file changed, 34 insertions(+), 2 deletions(-) diff --git a/.github/workflows/tests.yml b/.github/workflows/tests.yml index 58874b8..c8064ad 100644 --- a/.github/workflows/tests.yml +++ b/.github/workflows/tests.yml @@ -40,6 +40,7 @@ jobs: else COMMIT_SHA="${{ github.sha }}" fi + echo "sha=${COMMIT_SHA}" >> "$GITHUB_OUTPUT" echo "short_sha=$(echo "$COMMIT_SHA" | cut -c1-7)" >> "$GITHUB_OUTPUT" echo "message=$(git log -1 --pretty=%s "$COMMIT_SHA")" >> "$GITHUB_OUTPUT" @@ -58,7 +59,37 @@ jobs: TOTAL=$(grep '^TOTAL' pytest-coverage.txt | awk '{print $(NF-1)}') echo "total=${TOTAL}" >> "$GITHUB_OUTPUT" - TITLE="Coverage — \`${{ steps.commit-info.outputs.short_sha }}\` (${{ steps.commit-info.outputs.message }}) by @${{ github.actor }}" + REPO_URL="${{ github.server_url }}/${{ github.repository }}" + SHA="${{ steps.commit-info.outputs.sha }}" + SHORT_SHA="${{ steps.commit-info.outputs.short_sha }}" + COMMIT_LINK="[\`${SHORT_SHA}\`](${REPO_URL}/commit/${SHA})" + TITLE="Coverage — ${COMMIT_LINK} (${{ steps.commit-info.outputs.message }}) by @${{ github.actor }}" + + # Convert "308, 315-320, 347" into linked line numbers + linkify_lines() { + local file="$1" missing="$2" + if [ -z "$missing" ]; then + echo "" + return + fi + local result="" + IFS=', ' read -ra PARTS <<< "$missing" + for part in "${PARTS[@]}"; do + [ -z "$part" ] && continue + if [[ "$part" == *-* ]]; then + local start="${part%-*}" end="${part#*-}" + local link="[${part}](${REPO_URL}/blob/${SHA}/${file}#L${start}-L${end})" + else + local link="[${part}](${REPO_URL}/blob/${SHA}/${file}#L${part})" + fi + if [ -n "$result" ]; then + result="${result}, ${link}" + else + result="${link}" + fi + done + echo "$result" + } { echo "" @@ -74,7 +105,8 @@ jobs: MISS=$(echo "$line" | awk '{print $3}') COV=$(echo "$line" | awk '{print $4}') MISSING=$(echo "$line" | awk '{for(i=5;i<=NF;i++) printf "%s ", $i; print ""}' | xargs) - echo "| \`${FILE}\` | ${STMTS} | ${MISS} | ${COV} | ${MISSING} |" + LINKED=$(linkify_lines "$FILE" "$MISSING") + echo "| \`${FILE}\` | ${STMTS} | ${MISS} | ${COV} | ${LINKED} |" done } > coverage-comment.md From 463238a99ec9942439f04a12bf6cc1628c6e3e07 Mon Sep 17 00:00:00 2001 From: Kai Koenig Date: Fri, 10 Apr 2026 13:12:45 +1200 Subject: [PATCH 18/18] Fix coverage parsing: use column 4 (Cover) not column 3 (Miss) --- .github/workflows/tests.yml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/.github/workflows/tests.yml b/.github/workflows/tests.yml index c8064ad..167eea0 100644 --- a/.github/workflows/tests.yml +++ b/.github/workflows/tests.yml @@ -56,7 +56,7 @@ jobs: - name: Build coverage comment id: coverage run: | - TOTAL=$(grep '^TOTAL' pytest-coverage.txt | awk '{print $(NF-1)}') + TOTAL=$(grep '^TOTAL' pytest-coverage.txt | awk '{print $4}') echo "total=${TOTAL}" >> "$GITHUB_OUTPUT" REPO_URL="${{ github.server_url }}/${{ github.repository }}"