diff --git a/.env.default b/.env.default index f28208d..9008ecc 100644 --- a/.env.default +++ b/.env.default @@ -1,8 +1,11 @@ -APP_LIFECYCLE="dev" -SENTRY_ENABLED="False" -SENTRY_DSN="" ADMIN_EMAIL="" ADMIN_FIRST_NAME="" +APP_LIFECYCLE="dev" +APPROVED_DOMAINS="example.com,hello.com" +APPROVED_ROOMS="abc123,def456" +APPROVED_USERS="bob@example.com,john@me.com" BOT_NAME="" N8N_WEBHOOK_URL="" -WEBEX_API_KEY="" \ No newline at end of file +SENTRY_DSN="" +SENTRY_ENABLED="False" +WEBEX_API_KEY="" diff --git a/.github/CODEOWNERS b/.github/CODEOWNERS index 0d35f6e..55e6153 100644 --- a/.github/CODEOWNERS +++ b/.github/CODEOWNERS @@ -1 +1 @@ -* @luketainton \ No newline at end of file +* @luketainton diff --git a/.github/renovate.json b/.github/renovate.json index 67e7daa..979ffa0 100644 --- a/.github/renovate.json +++ b/.github/renovate.json @@ -1,47 +1,47 @@ { + "assignAutomerge": true, + "assigneesFromCodeOwners": true, + "baseBranches": [ + "main" + ], + "dependencyDashboardAutoclose": true, "extends": [ "config:base", ":semanticCommits", ":semanticCommitTypeAll(fix)" ], - "baseBranches": [ - "next" - ], - "platformCommit": true, - "dependencyDashboardAutoclose": true, - "assignAutomerge": true, - "assigneesFromCodeOwners": true, - "rebaseWhen": "behind-base-branch", - "rollbackPrs": true, "labels": [ "dependencies" ], "packageRules": [ { + "labels": [ + "linting" + ], "matchPackagePatterns": [ "black", "pylint" - ], - "labels": [ - "linting" ] }, { + "labels": [ + "unit-tests" + ], "matchPackagePatterns": [ "coverage", "pytest" - ], - "labels": [ - "unit-tests" ] } ], + "platformCommit": true, + "rebaseWhen": "behind-base-branch", + "rollbackPrs": true, "vulnerabilityAlerts": { + "commitMessagePrefix": "[SECURITY] ", "enabled": true, "labels": [ "security" ], - "commitMessagePrefix": "[SECURITY] ", "prCreation": "immediate" } -} \ No newline at end of file +} diff --git a/.github/workflows-old/release.yml b/.github/workflows-old/release.yml new file mode 100644 index 0000000..10e74be --- /dev/null +++ b/.github/workflows-old/release.yml @@ -0,0 +1,64 @@ +name: Build +on: + push: + branches: [main] + +env: + REGISTRY: ghcr.io + IMAGE_NAME: ${{ github.repository }} + +jobs: + release: + name: Release + runs-on: ubuntu-latest + outputs: + new_tag: ${{ steps.tag_version.outputs.new_tag }} + steps: + - uses: actions/checkout@v4 + - name: Bump version and push tag + id: tag_version + uses: mathieudutour/github-tag-action@v6.2 + with: + github_token: ${{ secrets.GITHUB_TOKEN }} + default_bump: minor + - name: Create a GitHub release + uses: ncipollo/release-action@v1 + with: + tag: ${{ steps.tag_version.outputs.new_tag }} + name: ${{ steps.tag_version.outputs.new_tag }} + body: ${{ steps.tag_version.outputs.changelog }} + generateReleaseNotes: true + + publish: + name: GitHub Container Registry + runs-on: ubuntu-latest + needs: release + permissions: + contents: read + packages: write + steps: + - uses: actions/checkout@v4 + - name: Login to GitHub Container Registry + run: echo ${{ secrets.GITHUB_TOKEN }} | docker login ${{ env.REGISTRY }} -u ${{ github.actor }} --password-stdin + - name: Build image for GitHub Package Registry + run: | + docker build . --file Dockerfile \ + --build-arg "version=${{ needs.release.outputs.new_tag }}" \ + --tag ${{ env.REGISTRY }}/${{ env.IMAGE_NAME }}:${{ needs.release.outputs.new_tag }} \ + --tag ${{ env.REGISTRY }}/${{ env.IMAGE_NAME }}:latest + - name: Push image to GitHub Package Registry + run: | + docker push ${{ env.REGISTRY }}/${{ env.IMAGE_NAME }}:latest + docker push ${{ env.REGISTRY }}/${{ env.IMAGE_NAME }}:${{ needs.release.outputs.new_tag }} + + # deploy: + # name: Update Portainer Deployment + # runs-on: ubuntu-latest + # needs: publish + # steps: + # - uses: fjogeleit/http-request-action@v1 + # with: + # url: ${{ secrets.PORTAINER_WEBHOOK_URL }} + # method: POST + # timeout: 60000 + # preventFailureOnNoResponse: "true" diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index 5d728b0..6012ad1 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -1,57 +1,18 @@ -name: Build +name: Release on: - push: - branches: [main] + workflow_dispatch: + schedule: + - cron: "0 9 * * 0" jobs: - release: - name: Release - runs-on: ubuntu-latest - outputs: - new_tag: ${{ steps.tag_version.outputs.new_tag }} - steps: - - uses: actions/checkout@v4 - - name: Bump version and push tag - id: tag_version - uses: mathieudutour/github-tag-action@v6.2 - with: - github_token: ${{ secrets.GITHUB_TOKEN }} - default_bump: minor - - name: Create a GitHub release - uses: ncipollo/release-action@v1 - with: - tag: ${{ steps.tag_version.outputs.new_tag }} - name: ${{ steps.tag_version.outputs.new_tag }} - body: ${{ steps.tag_version.outputs.changelog }} - generateReleaseNotes: true + create_release: + name: Create Release + uses: luketainton/gha-workflows/.github/workflows/create-release.yml@main - publish: - name: GitHub Container Registry - runs-on: ubuntu-latest - needs: release - steps: - - uses: actions/checkout@v4 - - name: Login to GitHub Container Registry - run: echo ${{ secrets.GHCR_ACCESS_TOKEN }} | docker login ghcr.io -u luketainton --password-stdin - - name: Build image for GitHub Package Registry - run: | - docker build . --file Dockerfile \ - --build-arg "version=${{ needs.release.outputs.new_tag }}" \ - --tag ghcr.io/luketainton/roboluke-tasks:${{ needs.release.outputs.new_tag }} \ - --tag ghcr.io/luketainton/roboluke-tasks:latest - - name: Push image to GitHub Package Registry - run: | - docker push ghcr.io/luketainton/roboluke-tasks:latest - docker push ghcr.io/luketainton/roboluke-tasks:${{ needs.release.outputs.new_tag }} - - deploy: - name: Update Portainer Deployment - runs-on: ubuntu-latest - needs: publish - steps: - - uses: fjogeleit/http-request-action@v1 - with: - url: ${{ secrets.PORTAINER_WEBHOOK_URL }} - method: POST - timeout: 60000 - preventFailureOnNoResponse: "true" + create_docker: + name: Create Docker Image + needs: create_release + if: ${{ needs.create_release.outputs.success == 'true' }} + uses: luketainton/gha-workflows/.github/workflows/build-push-attest-docker.yml@main + with: + release: ${{ needs.create_release.outputs.release_name }} diff --git a/.pre-commit-config.yaml b/.pre-commit-config.yaml new file mode 100644 index 0000000..9298bce --- /dev/null +++ b/.pre-commit-config.yaml @@ -0,0 +1,60 @@ +fail_fast: false + +minimum_pre_commit_version: 3.8.0 + +default_install_hook_types: [pre-commit, commit-msg] + +default_language_version: + python: python3.11 + +repos: + - repo: https://github.com/pre-commit/pre-commit-hooks + rev: v4.6.0 + hooks: + - id: trailing-whitespace + - id: mixed-line-ending + - id: end-of-file-fixer + - id: requirements-txt-fixer + - id: check-yaml + - id: check-added-large-files + - id: check-ast + - id: check-docstring-first + - id: check-json + - id: check-merge-conflict + - id: check-toml + - id: check-xml + - id: detect-private-key + - id: no-commit-to-branch + - id: requirements-txt-fixer + - id: name-tests-test + args: [--pytest-test-first] + - id: pretty-format-json + args: [--autofix] + + - repo: https://github.com/astral-sh/ruff-pre-commit + rev: v0.5.6 + hooks: + - id: ruff-format # Run the formatter. + - id: ruff # Run the linter. + args: [--fix] + + - repo: https://github.com/pycqa/isort + rev: 5.13.2 + hooks: + - id: isort + + - repo: https://github.com/pre-commit/pygrep-hooks + rev: v1.10.0 + hooks: + - id: python-use-type-annotations + + - repo: https://github.com/asottile/pyupgrade + rev: v3.17.0 + hooks: + - id: pyupgrade + + - repo: https://github.com/compilerla/conventional-pre-commit + rev: v3.4.0 + hooks: + - id: conventional-pre-commit + stages: [commit-msg] diff --git a/README.md b/README.md index 52be22e..d0349eb 100644 --- a/README.md +++ b/README.md @@ -9,8 +9,14 @@ Add tasks to a Wekan to do list via Webex and n8n. 3. Edit `.env` as required: - `ADMIN_EMAIL` - comma-separated list of admin (who owns the to-do list) email addresses - `ADMIN_FIRST_NAME` - admin first name + - `APP_LIFECYCLE` - for use in Sentry only, set the name of the environment + - `APPROVED_DOMAINS` - comma-separated list of domains that users are allowed to message the bot from + - `APPROVED_ROOMS` - comma-separated list of room IDs that users are allowed to message the bot from + - `APPROVED_USERS` - comma-separated list of email addresses of approved users - `BOT_NAME` - Webex bot name - `N8N_WEBHOOK_URL` - n8n webhook URL + - `SENTRY_DSN` - for use in Sentry only, set the DSN of the Sentry project + - `SENTRY_ENABLED` - for use in Sentry only, enable sending data to Sentry - `WEBEX_API_KEY` - Webex API key ## How to use diff --git a/app/main.py b/app/main.py index 19df55a..c222c89 100644 --- a/app/main.py +++ b/app/main.py @@ -2,14 +2,12 @@ import sentry_sdk from sentry_sdk.integrations.stdlib import StdlibIntegration - from webex_bot.webex_bot import WebexBot from app.commands.exit import ExitCommand from app.commands.submit_task import SubmitTaskCommand from app.utils.config import config - if config.sentry_enabled: apm = sentry_sdk.init( dsn=config.sentry_dsn, @@ -17,7 +15,7 @@ if config.sentry_enabled: environment=config.environment, release=config.version, integrations=[StdlibIntegration()], - spotlight=True + spotlight=True, ) @@ -26,7 +24,9 @@ def create_bot() -> WebexBot: webex_bot: WebexBot = WebexBot( bot_name=config.bot_name, teams_bot_token=config.webex_token, - approved_domains=["cisco.com"], + approved_domains=config.approved_domains, + approved_rooms=config.approved_rooms, + approved_users=config.approved_users, ) webex_bot.commands.clear() webex_bot.add_command(SubmitTaskCommand()) diff --git a/app/utils/config.py b/app/utils/config.py index b9e6231..5cce4b4 100644 --- a/app/utils/config.py +++ b/app/utils/config.py @@ -2,19 +2,19 @@ import os +from app.utils.helpers import validate_email_syntax + class Config: """Configuration module.""" + def __init__(self) -> None: """Configuration module.""" - self.__environment: str = os.environ.get("APP_LIFECYCLE", "DEV").upper() - self.__version: str = os.environ["APP_VERSION"] - self.__bot_name: str = os.environ["BOT_NAME"] - self.__webex_token: str = os.environ["WEBEX_API_KEY"] - self.__admin_first_name: str = os.environ["ADMIN_FIRST_NAME"] - self.__admin_emails: list = os.environ["ADMIN_EMAIL"].split(",") - self.__n8n_webhook_url: str = os.environ["N8N_WEBHOOK_URL"] + + # Sentry config needs to be processed first for loop prevention. + self.__sentry_dsn: str = os.environ.get("SENTRY_DSN", "") + self.__sentry_enabled: bool = bool( os.environ.get("SENTRY_ENABLED", "False").upper() == "TRUE" and self.__sentry_dsn != "" @@ -23,12 +23,12 @@ class Config: @property def environment(self) -> str: """Returns the current app lifecycle.""" - return self.__environment + return os.environ.get("APP_LIFECYCLE", "DEV").upper() @property def version(self) -> str: """Returns the current app version.""" - return self.__version + return os.environ["APP_VERSION"] @property def sentry_enabled(self) -> bool: @@ -46,27 +46,46 @@ class Config: @property def bot_name(self) -> str: """Returns the bot name.""" - return self.__bot_name + return os.environ["BOT_NAME"] @property def webex_token(self) -> str: """Returns the Webex API key.""" - return self.__webex_token + return os.environ["WEBEX_API_KEY"] @property def admin_first_name(self) -> str: """Returns the first name of the bot admin.""" - return self.__admin_first_name + return os.environ["ADMIN_FIRST_NAME"] @property def admin_emails(self) -> list: """Returns a list of admin email addresses.""" - return self.__admin_emails + return os.environ["ADMIN_EMAIL"].split(",") @property def n8n_webhook_url(self) -> str: """Returns the n8n webhook URL.""" - return self.__n8n_webhook_url + return os.environ["N8N_WEBHOOK_URL"] + + @property + def approved_users(self) -> list: + """Returns a list of approved users.""" + emails: list[str] = os.environ.get("APPROVED_USERS", "").split(",") + emails = [i.strip() for i in emails if validate_email_syntax(i.strip())] + return emails + + @property + def approved_rooms(self) -> list: + """Returns a list of approved rooms.""" + rooms: list[str] = os.environ.get("APPROVED_ROOMS", "").split(",") + return [i.strip() for i in rooms] + + @property + def approved_domains(self) -> list: + """Returns a list of approved domains.""" + domains: list[str] = os.environ.get("APPROVED_DOMAINS", "").split(",") + return [i.strip() for i in domains] config: Config = Config() diff --git a/app/utils/datetime.py b/app/utils/datetime.py index 472371c..f645d3b 100644 --- a/app/utils/datetime.py +++ b/app/utils/datetime.py @@ -1,18 +1,18 @@ #!/usr/bin/env python3 -from zoneinfo import ZoneInfo - from datetime import datetime +from zoneinfo import ZoneInfo def timestamp_to_date(timestamp: int) -> str: """Convert timestamp to date. - + Args: timestamp (int): Timestamp to convert. - + Returns: str: Date in the format YYYY-MM-DD. """ - return datetime.fromtimestamp(timestamp=timestamp, tz=ZoneInfo("UTC")).strftime("%Y-%m-%d") - + return datetime.fromtimestamp(timestamp=timestamp, tz=ZoneInfo("UTC")).strftime( + "%Y-%m-%d" + ) diff --git a/app/utils/helpers.py b/app/utils/helpers.py new file mode 100644 index 0000000..8130666 --- /dev/null +++ b/app/utils/helpers.py @@ -0,0 +1,16 @@ +"""Standalone helper functions.""" + +import re + + +def validate_email_syntax(email: str) -> bool: + """Validate email syntax. + + Args: + email (str): Email address. + + Returns: + bool: True if valid, else False. + """ + pattern = r"^[a-zA-Z0-9_.+-]+@[a-zA-Z0-9-]+\.[a-zA-Z0-9-.]+$" + return re.match(pattern, email) is not None diff --git a/app/utils/n8n.py b/app/utils/n8n.py index 4c0f84b..6940174 100644 --- a/app/utils/n8n.py +++ b/app/utils/n8n.py @@ -6,10 +6,10 @@ from app.utils.config import config def __n8n_post(data: dict) -> bool: """Post data to N8N webhook URL. - + Args: data (dict): Data to post to webhook URL. - + Returns: bool: True if successful, else False. """ @@ -26,13 +26,13 @@ def __n8n_post(data: dict) -> bool: def submit_task(summary, description, completion_date, requestor) -> bool: """Submit task to N8N webhook URL. - + Args: summary (str): Summary of task. description (str): Description of task. completion_date (str): Completion date of task. requestor (str): Requestor of task. - + Returns: bool: True if successful, else False. """ @@ -49,10 +49,10 @@ def submit_task(summary, description, completion_date, requestor) -> bool: def get_tasks(requestor) -> bool: """Get tasks from N8N webhook URL. - + Args: requestor (str): Requestor of tasks. - + Returns: bool: True if successful, else False. """ diff --git a/docker-compose.yml b/docker-compose.yml index ee034ed..dab1a66 100644 --- a/docker-compose.yml +++ b/docker-compose.yml @@ -7,4 +7,4 @@ services: dockerfile: Dockerfile restart: unless-stopped env_file: .env -... \ No newline at end of file +... diff --git a/requirements-dev.txt b/requirements-dev.txt index 5302a18..02acffb 100644 --- a/requirements-dev.txt +++ b/requirements-dev.txt @@ -4,3 +4,4 @@ isort pylint pylint-exit pytest +zipp>=3.19.1 # not directly required, pinned by Snyk to avoid a vulnerability diff --git a/requirements.txt b/requirements.txt index 50ec193..17be6b1 100644 --- a/requirements.txt +++ b/requirements.txt @@ -1,47 +1,48 @@ appdirs==1.4.4 -astroid==3.2.2 -attrs==23.2.0 -autopep8==2.2.0 +astroid==3.3.2 +attrs==24.2.0 +autopep8==2.3.1 backoff==2.2.1 -certifi==2024.6.2 +certifi==2024.8.30 cfgv==3.4.0 charset-normalizer==3.3.2 click==8.1.7 coloredlogs==15.0.1 dill==0.3.8 distlib==0.3.8 -filelock==3.14.0 +filelock==3.16.0 future==1.0.0 humanfriendly==10.0 -identify==2.5.36 -idna==3.7 +identify==2.6.0 +idna==3.8 iniconfig==2.0.0 lazy-object-proxy==1.10.0 mccabe==0.7.0 mypy-extensions==1.0.0 -nodeenv==1.9.0 -packaging==24.0 +nodeenv==1.9.1 +packaging==24.1 pathspec==0.12.1 -platformdirs==4.2.2 +platformdirs==4.3.2 pluggy==1.5.0 py==1.11.0 -pycodestyle==2.11.1 -PyJWT==2.8.0 -pyparsing==3.1.2 +pycodestyle==2.12.1 +PyJWT==2.9.0 +pyparsing==3.1.4 python-dateutil==2.9.0.post0 python-dotenv==1.0.1 -PyYAML==6.0.1 +PyYAML==6.0.2 requests==2.32.3 requests-toolbelt==1.0.0 -sentry-sdk==2.4.0 +sentry-sdk==2.14.0 six==1.16.0 toml==0.10.2 tomli==2.0.1 -tomlkit==0.12.5 -urllib3==2.2.1 -virtualenv==20.26.2 -webex-bot==0.5.1 +tomlkit==0.13.2 +urllib3==2.2.2 +virtualenv==20.26.4 +webex-bot==0.5.2 webexteamssdk==1.6.1 websockets==11.0.3 wrapt==1.16.0 xmltodict==0.13.0 +zipp>=3.19.1 # not directly required, pinned by Snyk to avoid a vulnerability diff --git a/test.sh b/test.sh index 1fcaf08..ee3861d 100755 --- a/test.sh +++ b/test.sh @@ -1,3 +1,3 @@ -export $(cat .env | xargs) +export $(cat .env.test | xargs) python -B -m app.main -unset APP_LIFECYCLE SENTRY_ENABLED SENTRY_DSN BOT_NAME WEBEX_API_KEY ADMIN_FIRST_NAME ADMIN_EMAIL N8N_WEBHOOK_URL +unset ADMIN_EMAIL ADMIN_FIRST_NAME APP_LIFECYCLE APP_VERSION APPROVED_DOMAINS APPROVED_ROOMS APPROVED_USERS BOT_NAME N8N_WEBHOOK_URL SENTRY_DSN SENTRY_ENABLED WEBEX_API_KEY diff --git a/tests/test_config.py b/tests/test_config.py index b26465f..070f734 100644 --- a/tests/test_config.py +++ b/tests/test_config.py @@ -1,10 +1,11 @@ #!/usr/bin/env python3 +# ruff: noqa: E402 pylint: disable=wrong-import-position + """Provides test cases for app/utils/config.py.""" import os - vars: dict = { "APP_VERSION": "dev", "BOT_NAME": "TestBot", @@ -13,7 +14,10 @@ vars: dict = { "ADMIN_EMAIL": "test@test.com", "N8N_WEBHOOK_URL": "https://n8n.test.com/webhook/abcdefg", "SENTRY_ENABLED": "false", - "SENTRY_DSN": "http://localhost" + "SENTRY_DSN": "http://localhost", + "APPROVED_USERS": "test@test.com", + "APPROVED_DOMAINS": "test.com", + "APPROVED_ROOMS": "test", } @@ -25,8 +29,18 @@ from app.utils.config import config # pragma: no cover def test_config() -> None: - assert config.bot_name == vars["BOT_NAME"] - assert config.webex_token == vars["WEBEX_API_KEY"] - assert config.admin_first_name == vars["ADMIN_FIRST_NAME"] assert config.admin_emails == vars["ADMIN_EMAIL"].split(",") + assert config.admin_first_name == vars["ADMIN_FIRST_NAME"] + assert config.approved_domains == vars["APPROVED_DOMAINS"].split(",") + assert config.approved_rooms == vars["APPROVED_ROOMS"].split(",") + assert config.approved_users == vars["APPROVED_USERS"].split(",") + assert config.bot_name == vars["BOT_NAME"] assert config.n8n_webhook_url == vars["N8N_WEBHOOK_URL"] + assert config.sentry_enabled == bool(vars["SENTRY_ENABLED"].upper() == "TRUE") + assert config.version == vars["APP_VERSION"] + assert config.webex_token == vars["WEBEX_API_KEY"] + + if config.sentry_enabled: + assert config.sentry_dsn == vars["SENTRY_DSN"] + else: + assert config.sentry_dsn == "" diff --git a/tests/test_helpers.py b/tests/test_helpers.py new file mode 100644 index 0000000..d5ac9a0 --- /dev/null +++ b/tests/test_helpers.py @@ -0,0 +1,23 @@ +#!/usr/bin/env python3 + +"""Provides test cases for app/utils/helpers.py.""" + +from app.utils.helpers import validate_email_syntax # pragma: no cover + + +def test_validate_email_syntax_true() -> None: + """Test validate_email_syntax() with a real email address.""" + email: str = "test@test.com" + assert validate_email_syntax(email) + + +def test_validate_email_syntax_false1() -> None: + """Test validate_email_syntax() with an invalid email address.""" + email: str = "test@test" + assert not validate_email_syntax(email) + + +def test_validate_email_syntax_false2() -> None: + """Test validate_email_syntax() with an invalid email address.""" + email: str = "test" + assert not validate_email_syntax(email)