diff --git a/.env b/.env new file mode 100644 index 0000000..e69de29 diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml new file mode 100644 index 0000000..bb2184f --- /dev/null +++ b/.github/workflows/ci.yml @@ -0,0 +1,46 @@ +name: CI +on: [pull_request] +concurrency: + group: ${{ github.workflow }}-${{ github.ref }} + cancel-in-progress: true +jobs: + lint: + runs-on: ubuntu-latest + steps: + - name: Checkout + uses: actions/checkout@v4 + - name: Install python + uses: actions/setup-python@v5 + with: + python-version: 3.11 + - name: Install uv + uses: astral-sh/setup-uv@v5 + with: + version: 0.7 + - name: Install dependencies + run: uv sync + - name: Lint check + run: uv run ruff check + - name: Lint fix check + run: | + uv run ruff check --fix + git diff --exit-code + - name: Formatting check + run: uv run ruff format --check + test: + runs-on: ubuntu-latest + steps: + - name: Checkout + uses: actions/checkout@v4 + - name: Install python + uses: actions/setup-python@v5 + with: + python-version: 3.11 + - name: Install uv + uses: astral-sh/setup-uv@v5 + with: + version: 0.7 + - name: Install dependencies + run: uv sync + - name: Run tests + run: PYTHON_ENV=test uv run pytest diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..bb0c944 --- /dev/null +++ b/.gitignore @@ -0,0 +1,4 @@ +__pycache__ +.pytest_cache +.venv +.ruff_cache diff --git a/README.md b/README.md index fd18029..d1a8ad5 100644 --- a/README.md +++ b/README.md @@ -1,2 +1,37 @@ # bot-python Starter kit for Python bot for Automa + +Please read the [Bot Development](https://docs.automa.app/bot-development) docs to understand how this bot works. + +* `/automa` endpoint is the receiver for the webhook from [Automa](https://automa.app) +* `update` function in `app/update.py` is the logic responsible for updating code. +* `AUTOMA_WEBHOOK_SECRET` environment variable is available to be set instead of hard-coding it. + +### Production + +Start the app in production mode: + +``` +PYTHON_ENV=production uv run fastapi run +``` + +### Development + +Start the app in development mode: + +``` +uv run fastapi dev +``` + +### Testing + +Run tests with: + +``` +uv run pytest +``` + +### Stack + +* Uses [uv](https://docs.astral.sh/uv/) as a package manager. +* Uses [fastapi](https://fastapi.tiangolo.com/) as a server. diff --git a/app/__init__.py b/app/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/app/env.py b/app/env.py new file mode 100644 index 0000000..b6d2962 --- /dev/null +++ b/app/env.py @@ -0,0 +1,17 @@ +import os +from functools import lru_cache + +from pydantic_settings import BaseSettings, SettingsConfigDict + +environment = os.getenv("PYTHON_ENV", "development") + + +class Config(BaseSettings): + model_config = SettingsConfigDict(env_file=".env") + + automa_webhook_secret: str = "atma_whsec_bot-python" + + +@lru_cache +def env(): + return Config() diff --git a/app/main.py b/app/main.py new file mode 100644 index 0000000..b7ea9af --- /dev/null +++ b/app/main.py @@ -0,0 +1,64 @@ +import json +import logging + +from automa.bot import AsyncAutoma +from automa.bot.webhook import verify_webhook +from fastapi import FastAPI, Request, Response + +from .env import env +from .update import update + +app = FastAPI() + + +@app.get("/health") +async def health_check(): + return Response(status_code=200) + + +@app.post("/automa") +async def automa_hook(request: Request): + signature = request.headers.get("webhook-signature") + payload = (await request.body()).decode("utf-8") + + # Verify request + if not verify_webhook(env().automa_webhook_secret, signature, payload): + logging.warning( + "Invalid signature", + ) + + return Response(status_code=401) + + base_url = request.headers.get("x-automa-server-host") + body = json.loads(payload) + + # Create client with base URL + automa = AsyncAutoma(base_url=base_url) + + # Download code + folder = await automa.code.download(body["data"]) + + try: + # Main logic for updating the code. It takes + # the folder location of the downloaded code + # and updates it. + # + # **NOTE**: If this takes a long time, make + # sure to return a response to the webhook + # before starting the update process. + update(folder) + + # Propose code + await automa.code.propose( + { + **body["data"], + "proposal": { + "message": "We changed your code", + }, + } + ) + finally: + # Clean up + await automa.code.cleanup(body["data"]) + + return Response(status_code=200) diff --git a/app/update.py b/app/update.py new file mode 100644 index 0000000..649277d --- /dev/null +++ b/app/update.py @@ -0,0 +1,4 @@ +def update(folder: str): + """ + Update code in the specified folder. + """ diff --git a/pyproject.toml b/pyproject.toml new file mode 100644 index 0000000..a549cf1 --- /dev/null +++ b/pyproject.toml @@ -0,0 +1,22 @@ +[project] +name = "bot-python" +version = "0.1.0" + +requires-python = ">=3.11" + +classifiers = ["Private :: Do Not Upload"] + +dependencies = [ + "automa-bot~=0.1.4", + "fastapi-cli~=0.0.7", + "fastapi~=0.115.11", + "pydantic-settings~=2.8.1", + "uvicorn~=0.34.0", +] + +[dependency-groups] +dev = ["httpx~=0.28.1", "pytest-cov~=6.0.0", "pytest~=8.3.5", "ruff~=0.11.2"] + +[tool.pytest.ini_options] +pythonpath = "." +testpaths = ["tests"] diff --git a/tests/__init__.py b/tests/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/tests/conftest.py b/tests/conftest.py new file mode 100644 index 0000000..188ac2b --- /dev/null +++ b/tests/conftest.py @@ -0,0 +1,11 @@ +import pytest +from fastapi.testclient import TestClient + +from app.main import app + + +@pytest.fixture +def client(): + """Create a test client for the app.""" + with TestClient(app) as test_client: + yield test_client diff --git a/tests/fixtures/code/.gitkeep b/tests/fixtures/code/.gitkeep new file mode 100644 index 0000000..e69de29 diff --git a/tests/fixtures/task.json b/tests/fixtures/task.json new file mode 100644 index 0000000..ea89d84 --- /dev/null +++ b/tests/fixtures/task.json @@ -0,0 +1,12 @@ +{ + "id": "whmsg_1", + "type": "task.created", + "timestamp": "2025-05-30T09:30:06.261Z", + "data": { + "task": { + "id": 1, + "token": "abcdef", + "title": "Running bot-python on sample-repo" + } + } +} diff --git a/tests/test_automa.py b/tests/test_automa.py new file mode 100644 index 0000000..5cd9865 --- /dev/null +++ b/tests/test_automa.py @@ -0,0 +1,175 @@ +from pathlib import Path +from unittest.mock import patch + +import pytest +from automa.bot.webhook import generate_webhook_signature + +from app.env import env + +fixtures = Path(__file__).parent / "fixtures" +fixture_code = fixtures / "code" + + +def call_with_fixture(client, filename): + fixture = open(fixtures / filename, "r").read() + + signature = generate_webhook_signature(env().automa_webhook_secret, fixture) + + return client.post( + "/automa", + content=fixture.encode(), + headers={ + "webhook-signature": signature, + "x-automa-server-host": "https://api.automa.app", + }, + ) + + +@pytest.mark.parametrize( + "signature", + [ + ("invalid"), + (None), + ], +) +def test_invalid_signature(client, signature): + """Test the Automa webhook endpoint with different invalid signature scenarios.""" + + headers = {} + + if signature: + headers["webhook-signature"] = signature + + response = client.post( + "/automa", + content=b'{ "id": "whmsg_1", "timestamp": "2025-05-30T09:30:06.261Z" }', + headers=headers, + ) + + assert response.status_code == 401 + + +@patch("automa.bot.AsyncCodeResource.cleanup") +@patch("automa.bot.AsyncCodeResource.propose") +@patch("automa.bot.AsyncCodeResource.download", return_value=fixture_code) +def test_valid_signature(download_mock, propose_mock, cleanup_mock, client): + """Test the Automa webhook endpoint with a valid signature.""" + + response = call_with_fixture(client, "task.json") + + # Returns 200 OK + assert response.status_code == 200 + + # Has empty body + assert response.content == b"" + + # Downloads the code + download_mock.assert_called_once_with( + { + "task": { + "id": 1, + "token": "abcdef", + "title": "Running bot-python on sample-repo", + } + } + ) + + # Proposes the code + propose_mock.assert_called_once_with( + { + "task": { + "id": 1, + "token": "abcdef", + "title": "Running bot-python on sample-repo", + }, + "proposal": {"message": "We changed your code"}, + } + ) + + # Cleans up the code + cleanup_mock.assert_called_once_with( + { + "task": { + "id": 1, + "token": "abcdef", + "title": "Running bot-python on sample-repo", + } + } + ) + + +@patch("automa.bot.AsyncCodeResource.cleanup") +@patch("automa.bot.AsyncCodeResource.propose") +@patch("automa.bot.AsyncCodeResource.download", side_effect=Exception("Download error")) +def test_download_error(download_mock, propose_mock, cleanup_mock, client): + """Test the Automa webhook endpoint with a download error.""" + + with pytest.raises(Exception): + response = call_with_fixture(client, "task.json") + + # Returns 500 Internal Server Error + assert response.status_code == 500 + + # Downsloads the code + download_mock.assert_called_once_with( + { + "task": { + "id": 1, + "token": "abcdef", + "title": "Running bot-python on sample-repo", + } + } + ) + + # Does not propose the code + propose_mock.assert_not_called() + + # Does not clean up the code + cleanup_mock.assert_not_called() + + +@patch("automa.bot.AsyncCodeResource.cleanup") +@patch("automa.bot.AsyncCodeResource.propose", side_effect=Exception("Propose error")) +@patch("automa.bot.AsyncCodeResource.download", return_value=fixture_code) +def test_propose_error(download_mock, propose_mock, cleanup_mock, client): + """Test the Automa webhook endpoint with a propose error.""" + + with pytest.raises(Exception): + response = call_with_fixture(client, "task.json") + + # Returns 500 Internal Server Error + assert response.status_code == 500 + + # Downloads the code + download_mock.assert_called_once_with( + { + "task": { + "id": 1, + "token": "abcdef", + "title": "Running bot-python on sample-repo", + } + } + ) + + # Proposes the code + propose_mock.assert_called_once_with( + { + "task": { + "id": 1, + "token": "abcdef", + "title": "Running bot-python on sample-repo", + }, + "proposal": {"message": "We changed your code"}, + } + ) + + # Cleans up the code + cleanup_mock.assert_called_once_with( + { + "task": { + "id": 1, + "token": "abcdef", + "title": "Running bot-python on sample-repo", + } + } + ) diff --git a/tests/test_error.py b/tests/test_error.py new file mode 100644 index 0000000..90102e4 --- /dev/null +++ b/tests/test_error.py @@ -0,0 +1,8 @@ +def test_nonexistent_route_returns_404(client): + """Test that accessing a non-existent route returns a 404 status code.""" + response = client.get("/unhandled-route") + + assert response.status_code == 404 + + assert "detail" in response.json() + assert response.json()["detail"] == "Not Found" diff --git a/tests/test_health.py b/tests/test_health.py new file mode 100644 index 0000000..dbfedbf --- /dev/null +++ b/tests/test_health.py @@ -0,0 +1,7 @@ +def test_healthcheck_endpoint(client): + """Test the healthcheck endpoint returns a 200 OK status.""" + response = client.get("/health") + + assert response.status_code == 200 + + assert response.content == b""