diff --git a/.github/workflows/build_test_and_lint.yml b/.github/workflows/build_test_and_lint.yml new file mode 100644 index 0000000..0ebfb69 --- /dev/null +++ b/.github/workflows/build_test_and_lint.yml @@ -0,0 +1,60 @@ +name: Build Test and Lint + +on: + push: + branches: + - main + pull_request: + +jobs: + build-test-and-lint: + runs-on: ubuntu-latest + + steps: + - name: Checkout code + uses: actions/checkout@v3 + + - name: Set up Docker Buildx + uses: docker/setup-buildx-action@v2 + + - name: Cache Docker layers + uses: actions/cache@v3 + with: + path: /tmp/.buildx-cache + key: ${{ runner.os }}-buildx-${{ github.sha }} + restore-keys: | + ${{ runner.os }}-buildx- + + - name: Build Docker image + run: | + make docker/build + + - name: Run Docker container for tests + run: | + docker run --rm \ + -e DJANGO_SETTINGS_MODULE=intbot.settings \ + -e DATABASE_URL=postgres://testuser:testpassword@localhost:5432/testdb \ + --network host \ + intbot \ + make in-container/tests + + - name: Run Docker container for lint + run: | + docker run --rm intbot make ci/lint + docker run --rm intbot make ci/type-check + + services: + postgres: + image: postgres:16.4 + env: + POSTGRES_USER: intbot_user + POSTGRES_PASSWORD: intbot_password + POSTGRES_DB: intbot_database_test + ports: + - 14672:5432 + options: >- + --health-cmd="pg_isready -U intbot_user -d intbot_database_test" + --health-interval=10s + --health-timeout=5s + --health-retries=5 + diff --git a/.github/workflows/deploy.yml b/.github/workflows/deploy.yml new file mode 100644 index 0000000..c5aab98 --- /dev/null +++ b/.github/workflows/deploy.yml @@ -0,0 +1,20 @@ +name: Deploy latest version of the app + +on: + push: + branches: + - main + +jobs: + deploy: + runs-on: ubuntu-latest + + steps: + - name: Checkout code + uses: actions/checkout@v3 + + - name: Install uv + uses: astral-sh/setup-uv@v5 + + - name: Run deployment + run: make deploy/app diff --git a/Makefile b/Makefile index c8a1af6..dabbabb 100644 --- a/Makefile +++ b/Makefile @@ -7,7 +7,11 @@ UV_RUN_DEV=cd intbot && DJANGO_ENV="dev" uv run # Docker DOCKER_RUN_WITH_PORT=docker run -p 4672:4672 --add-host=host.internal:host-gateway -e DJANGO_ENV="local_container" -it intbot:$(V) +DOCKER_RUN=docker run --add-host=host.internal:host-gateway -e DJANGO_ENV="test" -it intbot:$(V) MANAGE=cd intbot && ./manage.py +# In container we run with migrations +CONTAINER_TEST_CMD=DJANGO_SETTINGS_MODULE="intbot.settings" DJANGO_ENV="test" pytest --migrations +CI_RUN=cd intbot && DJANGO_SETTINGS_MODULE="intbot.settings" DJANGO_ENV="ci" # Deployment DEPLOY_CMD=cd deploy && uvx --from "ansible-core" ansible-playbook -i hosts.yml @@ -108,16 +112,32 @@ in-container/migrate: in-container/manage: $(MANAGE) $(ARG) +in-container/tests: + $(CONTAINER_TEST_CMD) -vvv + +ci/lint: + $(CI_RUN) ruff check . + +ci/type-check: + $(CI_RUN) mypy intbot + # Docker management targets # ========================= docker/build: - docker build . -t intbot:$(current_git_hash) + docker build . -t intbot:$(current_git_hash) -t intbot:latest docker/run/gunicorn: $(DOCKER_RUN_WITH_PORT) make in-container/gunicorn +docker/run/tests: + $(DOCKER_RUN) make in-container/tests + +docker/run/lint: + $(DOCKER_RUN) make ci/lint + $(DOCKER_RUN) make ci/type-check + # Deploymenet targets # ==================== diff --git a/intbot/core/integrations/github.py b/intbot/core/integrations/github.py index 1a2a6ae..7cb217b 100644 --- a/intbot/core/integrations/github.py +++ b/intbot/core/integrations/github.py @@ -84,6 +84,8 @@ def fetch_github_item_details(item_id): class GithubProjectV2Item: + # NOTE: This might be something for pydantic schemas in the future + def __init__(self, content: dict): self.content = content @@ -107,30 +109,30 @@ def content_type(self): return self.content["projects_v2_item"]["content_type"] def node_id(self): - # NOTE(artcz): This is more relevant, because of how the graphql query - # above is constructed. + # NOTE(artcz): This is relevant, because of how the graphql query above + # is constructed. # Using node_id, which is an id of a ProjectV2Item we can get both # DraftIssue and Issue from one query. - # If we use the content_node_id we need to adjust the query as that ID - # points us directly either an Issue or DraftIssue + # If we use the content_node_id we would probably need two separate + # ways of getting that data. return self.content["projects_v2_item"]["node_id"] - def content_node_id(self): - return self.content["projects_v2_item"]["content_node_id"] - def changes(self) -> dict: if "changes" in self.content: fv = self.content["changes"]["field_value"] field_name = fv["field_name"] field_type = fv["field_type"] + if field_type == "date": changed_from = ( fv["from"].split("T")[0] if fv["from"] is not None else "None" ) changed_to = fv["to"].split("T")[0] if fv["to"] is not None else "None" + elif field_type == "single_select": changed_from = fv["from"]["name"] if fv["from"] is not None else "None" changed_to = fv["to"]["name"] if fv["to"] is not None else "None" + else: changed_from = "None" changed_to = "None" @@ -152,8 +154,9 @@ def as_discord_message(self, github_object: GithubDraftIssue | GithubIssue) -> s changes = self.changes() if changes: - details = "**{field}** of **{obj}** from **{from}** to **{to}**".format - details = details(**{"obj": github_object.as_discord_message(), **changes}) + details = "**{field}** of **{obj}** from **{from}** to **{to}**".format( + **{"obj": github_object.as_discord_message(), **changes} + ) else: details = github_object.as_discord_message() diff --git a/intbot/intbot/settings.py b/intbot/intbot/settings.py index 84832db..1e9463b 100644 --- a/intbot/intbot/settings.py +++ b/intbot/intbot/settings.py @@ -12,6 +12,7 @@ import os import warnings +from typing import Any from pathlib import Path # Build paths inside the project like this: BASE_DIR / 'subdir'. @@ -112,6 +113,9 @@ DJANGO_ENV = os.environ["DJANGO_ENV"] APP_VERSION = os.environ.get("APP_VERSION", "latest")[:8] +# Just to make mypy happy +TASKS: dict[str, Any] + if DJANGO_ENV == "dev": DEBUG = True ALLOWED_HOSTS = ["127.0.0.1", "localhost"] @@ -306,5 +310,8 @@ def warn_if_missing(name, default=""): # Currently used only for collecting staticfiles in docker DEBUG = False +elif DJANGO_ENV == "ci": + DEBUG = False + else: raise ValueError(f"Unsupported DJANGO_ENV `{DJANGO_ENV}`") diff --git a/intbot/tests/test_integrations/test_github.py b/intbot/tests/test_integrations/test_github.py new file mode 100644 index 0000000..818160d --- /dev/null +++ b/intbot/tests/test_integrations/test_github.py @@ -0,0 +1,203 @@ +import pytest +import respx +from core.integrations.github import ( + GITHUB_API_URL, + GithubProjectV2Item, + fetch_github_item_details, + parse_github_webhook, +) +from httpx import Response + + +def test_parse_github_webhook_raises_value_error_for_unsupported(): + headers = {"X-Github-Event": "random_event"} + content = {} + + with pytest.raises(ValueError): + parse_github_webhook(headers, content) + + +@pytest.fixture +def mocked_github_response(): + def _mock_response(item_type, content_data): + return {"data": {"node": {"content": content_data}}} + + return _mock_response + + +@pytest.fixture +def sample_content(): + return { + "sender": {"login": "testuser", "html_url": "https://github.com/testuser"}, + "projects_v2_item": { + "content_type": "Issue", + "node_id": "test_node_id", + }, + } + + +@respx.mock +def test_fetch_github_item_details(mocked_github_response): + mocked_response = mocked_github_response( + "Issue", + { + "id": "test_issue_id", + "title": "Test Issue", + "url": "https://github.com/test/repo/issues/1", + }, + ) + respx.post(GITHUB_API_URL).mock(return_value=Response(200, json=mocked_response)) + + result = fetch_github_item_details("test_node_id") + + assert result["id"] == "test_issue_id" + assert result["title"] == "Test Issue" + assert result["url"] == "https://github.com/test/repo/issues/1" + + +@respx.mock +def test_github_project_created_event(mocked_github_response, sample_content): + sample_content["action"] = "created" + mocked_response = mocked_github_response( + "Issue", + { + "id": "test_issue_id", + "title": "Test Issue", + "url": "https://github.com/test/repo/issues/1", + }, + ) + respx.post(GITHUB_API_URL).mock(return_value=Response(200, json=mocked_response)) + + parser = GithubProjectV2Item(sample_content) + message = parser.as_str() + + assert message == ( + "[@testuser](https://github.com/testuser) created " + "[Test Issue](https://github.com/test/repo/issues/1)" + ) + + +@respx.mock +def test_github_project_edited_event_for_status_change( + mocked_github_response, sample_content +): + sample_content["action"] = "edited" + sample_content["changes"] = { + "field_value": { + "field_name": "Status", + "field_type": "single_select", + "from": {"name": "To Do"}, + "to": {"name": "In Progress"}, + } + } + mocked_response = mocked_github_response( + "Issue", + { + "id": "test_issue_id", + "title": "Test Issue", + "url": "https://github.com/test/repo/issues/1", + }, + ) + respx.post(GITHUB_API_URL).mock(return_value=Response(200, json=mocked_response)) + + parser = GithubProjectV2Item(sample_content) + message = parser.as_str() + + assert message == ( + "[@testuser](https://github.com/testuser) changed **Status** of " + "**[Test Issue](https://github.com/test/repo/issues/1)** " + "from **To Do** to **In Progress**" + ) + + +@respx.mock +def test_github_project_edited_event_for_date_change( + mocked_github_response, sample_content +): + sample_content["action"] = "edited" + sample_content["changes"] = { + "field_value": { + "field_name": "Deadline", + "field_type": "date", + "from": "2024-01-01T10:20:30", + "to": "2025-01-05T20:30:10", + } + } + mocked_response = mocked_github_response( + "Issue", + { + "id": "test_issue_id", + "title": "Test Issue", + "url": "https://github.com/test/repo/issues/1", + }, + ) + respx.post(GITHUB_API_URL).mock(return_value=Response(200, json=mocked_response)) + + parser = GithubProjectV2Item(sample_content) + message = parser.as_str() + + assert message == ( + "[@testuser](https://github.com/testuser) changed **Deadline** of " + "**[Test Issue](https://github.com/test/repo/issues/1)** " + "from **2024-01-01** to **2025-01-05**" + ) + + +@respx.mock +def test_github_project_draft_issue_event(mocked_github_response, sample_content): + sample_content["action"] = "created" + sample_content["projects_v2_item"]["content_type"] = "DraftIssue" + mocked_response = mocked_github_response( + "DraftIssue", + { + "id": "draft_issue_id", + "title": "Draft Title", + }, + ) + respx.post(GITHUB_API_URL).mock(return_value=Response(200, json=mocked_response)) + + parser = GithubProjectV2Item(sample_content) + message = parser.as_str() + + assert message == "[@testuser](https://github.com/testuser) created Draft Title" + + +def test_github_project_unsupported_action(sample_content): + sample_content["action"] = "unsupported_action" + + parser = GithubProjectV2Item(sample_content) + + with pytest.raises(ValueError, match="Action unsupported unsupported_action"): + parser.action() + + +@respx.mock +def test_github_project_edited_event_no_changes(mocked_github_response, sample_content): + sample_content["action"] = "edited" + mocked_response = mocked_github_response( + "Issue", + { + "id": "test_issue_id", + "title": "Test Issue", + "url": "https://github.com/test/repo/issues/1", + }, + ) + respx.post(GITHUB_API_URL).mock(return_value=Response(200, json=mocked_response)) + + parser = GithubProjectV2Item(sample_content) + message = parser.as_str() + + assert message == ( + "[@testuser](https://github.com/testuser) changed " + "[Test Issue](https://github.com/test/repo/issues/1)" + ) + + +@respx.mock +def test_fetch_github_item_details_api_error(): + respx.post(GITHUB_API_URL).mock( + return_value=Response(500, json={"message": "Internal Server Error"}) + ) + + with pytest.raises(Exception, match="GitHub API error: 500 - .*"): + fetch_github_item_details("test_node_id") diff --git a/intbot/tests/test_tasks.py b/intbot/tests/test_tasks.py index 47d76c9..674bcab 100644 --- a/intbot/tests/test_tasks.py +++ b/intbot/tests/test_tasks.py @@ -41,7 +41,6 @@ def test_process_webhook_fails_if_unsupported_source(): result = process_webhook.enqueue(str(wh.uuid)) assert result.status == ResultStatus.FAILED - assert result._exception_class == ValueError assert result.traceback.endswith("ValueError: Unsupported source asdf\n") diff --git a/pyproject.toml b/pyproject.toml index 7132a11..3f18adb 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -21,6 +21,8 @@ dependencies = [ "django-stubs>=5.1.1", "pdbpp>=0.10.3", "pytest-cov>=6.0.0", + "pytest-socket>=0.7.0", + "respx>=0.22.0", ] [tool.pytest.ini_options] @@ -28,6 +30,10 @@ pythonpath = [ "intbot" ] +# Disable attempts of using the internet in tests, but allow connection to the +# database +addopts = "--disable-socket --allow-unix-socket" + [tool.coverage.run] branch = true omit = [ diff --git a/uv.lock b/uv.lock index f5b768e..dccf480 100644 --- a/uv.lock +++ b/uv.lock @@ -326,6 +326,8 @@ dependencies = [ { name = "pytest-asyncio" }, { name = "pytest-cov" }, { name = "pytest-django" }, + { name = "pytest-socket" }, + { name = "respx" }, { name = "ruff" }, { name = "whitenoise" }, ] @@ -346,6 +348,8 @@ requires-dist = [ { name = "pytest-asyncio", specifier = ">=0.25.2" }, { name = "pytest-cov", specifier = ">=6.0.0" }, { name = "pytest-django", specifier = ">=4.9.0" }, + { name = "pytest-socket", specifier = ">=0.7.0" }, + { name = "respx", specifier = ">=0.22.0" }, { name = "ruff", specifier = ">=0.8.6" }, { name = "whitenoise", specifier = ">=6.8.2" }, ] @@ -545,6 +549,30 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/47/fe/54f387ee1b41c9ad59e48fb8368a361fad0600fe404315e31a12bacaea7d/pytest_django-4.9.0-py3-none-any.whl", hash = "sha256:1d83692cb39188682dbb419ff0393867e9904094a549a7d38a3154d5731b2b99", size = 23723 }, ] +[[package]] +name = "pytest-socket" +version = "0.7.0" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "pytest" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/05/ff/90c7e1e746baf3d62ce864c479fd53410b534818b9437413903596f81580/pytest_socket-0.7.0.tar.gz", hash = "sha256:71ab048cbbcb085c15a4423b73b619a8b35d6a307f46f78ea46be51b1b7e11b3", size = 12389 } +wheels = [ + { url = "https://files.pythonhosted.org/packages/19/58/5d14cb5cb59409e491ebe816c47bf81423cd03098ea92281336320ae5681/pytest_socket-0.7.0-py3-none-any.whl", hash = "sha256:7e0f4642177d55d317bbd58fc68c6bd9048d6eadb2d46a89307fa9221336ce45", size = 6754 }, +] + +[[package]] +name = "respx" +version = "0.22.0" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "httpx" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/f4/7c/96bd0bc759cf009675ad1ee1f96535edcb11e9666b985717eb8c87192a95/respx-0.22.0.tar.gz", hash = "sha256:3c8924caa2a50bd71aefc07aa812f2466ff489f1848c96e954a5362d17095d91", size = 28439 } +wheels = [ + { url = "https://files.pythonhosted.org/packages/8e/67/afbb0978d5399bc9ea200f1d4489a23c9a1dad4eee6376242b8182389c79/respx-0.22.0-py2.py3-none-any.whl", hash = "sha256:631128d4c9aba15e56903fb5f66fb1eff412ce28dd387ca3a81339e52dbd3ad0", size = 25127 }, +] + [[package]] name = "ruff" version = "0.8.6"