From ba88344c71f74c4ac183a58022163f588a53faed Mon Sep 17 00:00:00 2001 From: Jennifer Power Date: Wed, 26 Jul 2023 13:35:52 -0400 Subject: [PATCH 1/3] chore: adds additional project checks for security and code coverage Adds a workflow to check code coverage and fail if below the threshold Adds dependency checks with safety Adds security checks with bandit Signed-off-by: Jennifer Power --- .github/workflows/ci.yml | 7 ++ .github/workflows/codecov.yml | 28 +++++ Makefile | 14 +++ poetry.lock | 205 +++++++++++++++++++++++++++++++++- pyproject.toml | 2 + 5 files changed, 252 insertions(+), 4 deletions(-) create mode 100644 .github/workflows/codecov.yml diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 817d803e..aebd6c74 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -39,6 +39,12 @@ jobs: - name: Run checks run: make lint + + - name: Run bandit + run: make security-check + + - name: Check dependencies + run: make dep-cve-check test: runs-on: ubuntu-latest @@ -57,3 +63,4 @@ jobs: - name: Run tests run: make test + diff --git a/.github/workflows/codecov.yml b/.github/workflows/codecov.yml new file mode 100644 index 00000000..f48f3f40 --- /dev/null +++ b/.github/workflows/codecov.yml @@ -0,0 +1,28 @@ +--- +name: Code Coverage Check + +on: + pull_request: + branches: main + +jobs: + test: + runs-on: ubuntu-latest + steps: + - name: Check out + uses: actions/checkout@v3 + + - name: Set up poetry and install + uses: ./.github/actions/setup-poetry + with: + python-version: "3.9" + + - name: Run tests + run: make test-code-cov + + - name: Upload artifact + uses: actions/upload-artifact@v2 + with: + name: coverage + path: coverage.xml + \ No newline at end of file diff --git a/Makefile b/Makefile index dd0239a1..e555af4d 100644 --- a/Makefile +++ b/Makefile @@ -27,6 +27,20 @@ test: @poetry run pytest --cov --cov-config=pyproject.toml --cov-report=xml .PHONY: test +test-code-cov: + @poetry run pytest --cov=trestlebot --exitfirst --cov-config=pyproject.toml --cov-report=xml --cov-fail-under=85 +.PHONY: test-code-cov + +# https://github.com/python-poetry/poetry/issues/994#issuecomment-831598242 +# Check for CVEs locally. For automated/scheduled checks, use dependabot. +dep-cve-check: + @poetry export -f requirements.txt --without-hashes | poetry run safety check --stdin +.PHONY: dep-cve-check + +security-check: + @poetry run bandit -r $(PYMODULE) +.PHONY: security-check + build: clean-build @poetry build .PHONY: build diff --git a/poetry.lock b/poetry.lock index abb8d48c..d6aecc11 100644 --- a/poetry.lock +++ b/poetry.lock @@ -53,6 +53,29 @@ docs = ["furo", "myst-parser", "sphinx", "sphinx-notfound-page", "sphinxcontrib- tests = ["attrs[tests-no-zope]", "zope-interface"] tests-no-zope = ["cloudpickle", "hypothesis", "mypy (>=1.1.1)", "pympler", "pytest (>=4.3.0)", "pytest-mypy-plugins", "pytest-xdist[psutil]"] +[[package]] +name = "bandit" +version = "1.7.5" +description = "Security oriented static analyser for python code." +optional = false +python-versions = ">=3.7" +files = [ + {file = "bandit-1.7.5-py3-none-any.whl", hash = "sha256:75665181dc1e0096369112541a056c59d1c5f66f9bb74a8d686c3c362b83f549"}, + {file = "bandit-1.7.5.tar.gz", hash = "sha256:bdfc739baa03b880c2d15d0431b31c658ffc348e907fe197e54e0389dd59e11e"}, +] + +[package.dependencies] +colorama = {version = ">=0.3.9", markers = "platform_system == \"Windows\""} +GitPython = ">=1.0.1" +PyYAML = ">=5.3.1" +rich = "*" +stevedore = ">=1.20.0" + +[package.extras] +test = ["beautifulsoup4 (>=4.8.0)", "coverage (>=4.5.4)", "fixtures (>=3.0.0)", "flake8 (>=4.0.0)", "pylint (==1.9.4)", "stestr (>=2.5.0)", "testscenarios (>=0.5.0)", "testtools (>=2.3.0)", "tomli (>=1.1.0)"] +toml = ["tomli (>=1.1.0)"] +yaml = ["PyYAML"] + [[package]] name = "bcrypt" version = "4.0.1" @@ -621,6 +644,25 @@ idna = ["idna (>=2.1,<4.0)"] trio = ["trio (>=0.14,<0.23)"] wmi = ["wmi (>=1.5.1,<2.0.0)"] +[[package]] +name = "dparse" +version = "0.6.3" +description = "A parser for Python dependency files" +optional = false +python-versions = ">=3.6" +files = [ + {file = "dparse-0.6.3-py3-none-any.whl", hash = "sha256:0d8fe18714056ca632d98b24fbfc4e9791d4e47065285ab486182288813a5318"}, + {file = "dparse-0.6.3.tar.gz", hash = "sha256:27bb8b4bcaefec3997697ba3f6e06b2447200ba273c0b085c3d012a04571b528"}, +] + +[package.dependencies] +packaging = "*" +tomli = {version = "*", markers = "python_version < \"3.11\""} + +[package.extras] +conda = ["pyyaml"] +pipenv = ["pipenv (<=2022.12.19)"] + [[package]] name = "email-validator" version = "2.0.0.post2" @@ -992,6 +1034,30 @@ files = [ {file = "lazy_object_proxy-1.9.0-cp39-cp39-win_amd64.whl", hash = "sha256:db1c1722726f47e10e0b5fdbf15ac3b8adb58c091d12b3ab713965795036985f"}, ] +[[package]] +name = "markdown-it-py" +version = "3.0.0" +description = "Python port of markdown-it. Markdown parsing, done right!" +optional = false +python-versions = ">=3.8" +files = [ + {file = "markdown-it-py-3.0.0.tar.gz", hash = "sha256:e3f60a94fa066dc52ec76661e37c851cb232d92f9886b15cb560aaada2df8feb"}, + {file = "markdown_it_py-3.0.0-py3-none-any.whl", hash = "sha256:355216845c60bd96232cd8d8c40e8f9765cc86f46880e43a8fd22dc1a1a8cab1"}, +] + +[package.dependencies] +mdurl = ">=0.1,<1.0" + +[package.extras] +benchmarking = ["psutil", "pytest", "pytest-benchmark"] +code-style = ["pre-commit (>=3.0,<4.0)"] +compare = ["commonmark (>=0.9,<1.0)", "markdown (>=3.4,<4.0)", "mistletoe (>=1.0,<2.0)", "mistune (>=2.0,<3.0)", "panflute (>=2.3,<3.0)"] +linkify = ["linkify-it-py (>=1,<3)"] +plugins = ["mdit-py-plugins"] +profiling = ["gprof2dot"] +rtd = ["jupyter_sphinx", "mdit-py-plugins", "myst-parser", "pyyaml", "sphinx", "sphinx-copybutton", "sphinx-design", "sphinx_book_theme"] +testing = ["coverage", "pytest", "pytest-cov", "pytest-regressions"] + [[package]] name = "markupsafe" version = "2.1.3" @@ -1051,6 +1117,26 @@ files = [ {file = "MarkupSafe-2.1.3.tar.gz", hash = "sha256:af598ed32d6ae86f1b747b82783958b1a4ab8f617b06fe68795c7f026abbdcad"}, ] +[[package]] +name = "marshmallow" +version = "3.20.1" +description = "A lightweight library for converting complex datatypes to and from native Python datatypes." +optional = false +python-versions = ">=3.8" +files = [ + {file = "marshmallow-3.20.1-py3-none-any.whl", hash = "sha256:684939db93e80ad3561392f47be0230743131560a41c5110684c16e21ade0a5c"}, + {file = "marshmallow-3.20.1.tar.gz", hash = "sha256:5d2371bbe42000f2b3fb5eaa065224df7d8f8597bc19a1bbfa5bfe7fba8da889"}, +] + +[package.dependencies] +packaging = ">=17.0" + +[package.extras] +dev = ["flake8 (==6.0.0)", "flake8-bugbear (==23.7.10)", "mypy (==1.4.1)", "pre-commit (>=2.4,<4.0)", "pytest", "pytz", "simplejson", "tox"] +docs = ["alabaster (==0.7.13)", "autodocsumm (==0.2.11)", "sphinx (==7.0.1)", "sphinx-issues (==3.0.1)", "sphinx-version-warning (==1.1.2)"] +lint = ["flake8 (==6.0.0)", "flake8-bugbear (==23.7.10)", "mypy (==1.4.1)", "pre-commit (>=2.4,<4.0)"] +tests = ["pytest", "pytz", "simplejson"] + [[package]] name = "mccabe" version = "0.7.0" @@ -1062,6 +1148,17 @@ files = [ {file = "mccabe-0.7.0.tar.gz", hash = "sha256:348e0240c33b60bbdf4e523192ef919f28cb2c3d7d5c7794f74009290f236325"}, ] +[[package]] +name = "mdurl" +version = "0.1.2" +description = "Markdown URL utilities" +optional = false +python-versions = ">=3.7" +files = [ + {file = "mdurl-0.1.2-py3-none-any.whl", hash = "sha256:84008a41e51615a49fc9966191ff91509e3c40b939176e643fd50a5c2196b8f8"}, + {file = "mdurl-0.1.2.tar.gz", hash = "sha256:bb413d29f5eea38f31dd4754dd7377d4465116fb207585f97bf925588687c1ba"}, +] + [[package]] name = "mypy" version = "1.4.1" @@ -1243,13 +1340,13 @@ files = [ [[package]] name = "packaging" -version = "23.1" +version = "23.0" description = "Core utilities for Python packages" optional = false python-versions = ">=3.7" files = [ - {file = "packaging-23.1-py3-none-any.whl", hash = "sha256:994793af429502c4ea2ebf6bf664629d07c1a9fe974af92966e4b8d2df7edc61"}, - {file = "packaging-23.1.tar.gz", hash = "sha256:a392980d2b6cffa644431898be54b0045151319d1e7ec34f0cfed48767dd334f"}, + {file = "packaging-23.0-py3-none-any.whl", hash = "sha256:714ac14496c3e68c99c29b00845f7a2b85f3bb6f1078fd9f72fd20f0570002b2"}, + {file = "packaging-23.0.tar.gz", hash = "sha256:b6ad297f8907de0fa2fe1ccbd26fdaf387f5f47c7275fedf8cce89f99446cf97"}, ] [[package]] @@ -1295,6 +1392,17 @@ files = [ {file = "pathspec-0.11.1.tar.gz", hash = "sha256:2798de800fa92780e33acca925945e9a19a133b715067cf165b8866c15a31687"}, ] +[[package]] +name = "pbr" +version = "5.11.1" +description = "Python Build Reasonableness" +optional = false +python-versions = ">=2.6" +files = [ + {file = "pbr-5.11.1-py2.py3-none-any.whl", hash = "sha256:567f09558bae2b3ab53cb3c1e2e33e726ff3338e7bae3db5dc954b3a44eef12b"}, + {file = "pbr-5.11.1.tar.gz", hash = "sha256:aefc51675b0b533d56bb5fd1c8c6c0522fe31896679882e1c4c63d5e4a0fccb3"}, +] + [[package]] name = "pkgutil-resolve-name" version = "1.3.10" @@ -1448,6 +1556,20 @@ files = [ {file = "pyflakes-3.0.1.tar.gz", hash = "sha256:ec8b276a6b60bd80defed25add7e439881c19e64850afd9b346283d4165fd0fd"}, ] +[[package]] +name = "pygments" +version = "2.15.1" +description = "Pygments is a syntax highlighting package written in Python." +optional = false +python-versions = ">=3.7" +files = [ + {file = "Pygments-2.15.1-py3-none-any.whl", hash = "sha256:db2db3deb4b4179f399a09054b023b6a586b76499d36965813c71aa8ed7b5fd1"}, + {file = "Pygments-2.15.1.tar.gz", hash = "sha256:8ace4d3c1dd481894b2005f560ead0f9f19ee64fe983366be1a21e171d12775c"}, +] + +[package.extras] +plugins = ["importlib-metadata"] + [[package]] name = "pyjwt" version = "2.8.0" @@ -1737,6 +1859,25 @@ files = [ [package.dependencies] six = "*" +[[package]] +name = "rich" +version = "13.4.2" +description = "Render rich text, tables, progress bars, syntax highlighting, markdown and more to the terminal" +optional = false +python-versions = ">=3.7.0" +files = [ + {file = "rich-13.4.2-py3-none-any.whl", hash = "sha256:8f87bc7ee54675732fa66a05ebfe489e27264caeeff3728c945d25971b6485ec"}, + {file = "rich-13.4.2.tar.gz", hash = "sha256:d653d6bccede5844304c605d5aac802c7cf9621efd700b46c7ec2b51ea914898"}, +] + +[package.dependencies] +markdown-it-py = ">=2.2.0" +pygments = ">=2.13.0,<3.0.0" +typing-extensions = {version = ">=4.0.0,<5.0", markers = "python_version < \"3.9\""} + +[package.extras] +jupyter = ["ipywidgets (>=7.5.1,<9)"] + [[package]] name = "ruamel-yaml" version = "0.17.32" @@ -1801,6 +1942,48 @@ files = [ {file = "ruamel.yaml.clib-0.2.7.tar.gz", hash = "sha256:1f08fd5a2bea9c4180db71678e850b995d2a5f4537be0e94557668cf0f5f9497"}, ] +[[package]] +name = "safety" +version = "2.4.0b1" +description = "Checks installed dependencies for known vulnerabilities and licenses." +optional = false +python-versions = "*" +files = [ + {file = "safety-2.4.0b1-py3-none-any.whl", hash = "sha256:95570bfdb0ca17bb2acdf34963966ce8f8b26bffeb76722d98251cee2f81b215"}, + {file = "safety-2.4.0b1.tar.gz", hash = "sha256:26b3000eec09f64fdd323db29c44c0446607b0c9b4ce65c3f8f9570e2c640958"}, +] + +[package.dependencies] +Click = ">=8.0.2" +dparse = ">=0.6.2" +jinja2 = {version = ">=3.1.0", markers = "python_version >= \"3.7\""} +marshmallow = {version = ">=3.15.0", markers = "python_version >= \"3.7\""} +packaging = ">=21.0,<=23.0" +requests = "*" +"ruamel.yaml" = ">=0.17.21" +setuptools = {version = ">=65.5.1", markers = "python_version >= \"3.7\""} +urllib3 = ">=1.26.5" + +[package.extras] +github = ["pygithub (>=1.43.3)"] +gitlab = ["python-gitlab (>=1.3.0)"] + +[[package]] +name = "setuptools" +version = "68.0.0" +description = "Easily download, build, install, upgrade, and uninstall Python packages" +optional = false +python-versions = ">=3.7" +files = [ + {file = "setuptools-68.0.0-py3-none-any.whl", hash = "sha256:11e52c67415a381d10d6b462ced9cfb97066179f0e871399e006c4ab101fc85f"}, + {file = "setuptools-68.0.0.tar.gz", hash = "sha256:baf1fdb41c6da4cd2eae722e135500da913332ab3f2f5c7d33af9b492acb5235"}, +] + +[package.extras] +docs = ["furo", "jaraco.packaging (>=9)", "jaraco.tidelift (>=1.4)", "pygments-github-lexers (==0.0.5)", "rst.linker (>=1.9)", "sphinx (>=3.5)", "sphinx-favicon", "sphinx-hoverxref (<2)", "sphinx-inline-tabs", "sphinx-lint", "sphinx-notfound-page (==0.8.3)", "sphinx-reredirects", "sphinxcontrib-towncrier"] +testing = ["build[virtualenv]", "filelock (>=3.4.0)", "flake8-2020", "ini2toml[lite] (>=0.9)", "jaraco.envs (>=2.2)", "jaraco.path (>=3.2.0)", "pip (>=19.1)", "pip-run (>=8.8)", "pytest (>=6)", "pytest-black (>=0.3.7)", "pytest-checkdocs (>=2.4)", "pytest-cov", "pytest-enabler (>=1.3)", "pytest-mypy (>=0.9.1)", "pytest-perf", "pytest-ruff", "pytest-timeout", "pytest-xdist", "tomli-w (>=1.0.0)", "virtualenv (>=13.0.0)", "wheel"] +testing-integration = ["build[virtualenv]", "filelock (>=3.4.0)", "jaraco.envs (>=2.2)", "jaraco.path (>=3.2.0)", "pytest", "pytest-enabler", "pytest-xdist", "tomli", "virtualenv (>=13.0.0)", "wheel"] + [[package]] name = "six" version = "1.16.0" @@ -1834,6 +2017,20 @@ files = [ {file = "sniffio-1.3.0.tar.gz", hash = "sha256:e60305c5e5d314f5389259b7f22aaa33d8f7dee49763119234af3755c55b9101"}, ] +[[package]] +name = "stevedore" +version = "5.1.0" +description = "Manage dynamic plugins for Python applications" +optional = false +python-versions = ">=3.8" +files = [ + {file = "stevedore-5.1.0-py3-none-any.whl", hash = "sha256:8cc040628f3cea5d7128f2e76cf486b2251a4e543c7b938f58d9a377f6694a2d"}, + {file = "stevedore-5.1.0.tar.gz", hash = "sha256:a54534acf9b89bc7ed264807013b505bf07f74dbe4bcfa37d32bd063870b087c"}, +] + +[package.dependencies] +pbr = ">=2.0.0,<2.1.0 || >2.1.0" + [[package]] name = "toml" version = "0.10.2" @@ -1913,4 +2110,4 @@ testing = ["big-O", "jaraco.functools", "jaraco.itertools", "more-itertools", "p [metadata] lock-version = "2.0" python-versions = "^3.8.1" -content-hash = "aa9e7e64ce4e697f32752efd601ad5bb84647cf31491127adb333338979e92dd" +content-hash = "c9d160ad6ca95aea7a6bb7e3b6d877945c57407ba8d33590ffaaa417a785831b" diff --git a/pyproject.toml b/pyproject.toml index ec0db2ba..08244068 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -29,6 +29,8 @@ flake8 = "^6.0.0" black = "^23.3.0" mypy = "^1.3.0" isort = "^5.12.0" +safety = "^2.3.5" +bandit = "^1.7.5" [tool.poetry.group.tests.dependencies] pytest = "^7.3.2" From abf0c8392f130e25332b8c7511869191ef470b0c Mon Sep 17 00:00:00 2001 From: Jennifer Power Date: Wed, 26 Jul 2023 14:44:21 -0400 Subject: [PATCH 2/3] tests: adds unit tests for tasks and bot Adds unit tests to isolate tasks from AuthoredObject Adds unit tests to test different combinations for staged files Adds unit test to text exception handling for git failures Signed-off-by: Jennifer Power --- tests/trestlebot/tasks/test_assemble_task.py | 29 ++++ .../trestlebot/tasks/test_regenerate_task .py | 29 ++++ tests/trestlebot/test_bot.py | 163 ++++++++++-------- trestlebot/bot.py | 1 - 4 files changed, 149 insertions(+), 73 deletions(-) diff --git a/tests/trestlebot/tasks/test_assemble_task.py b/tests/trestlebot/tasks/test_assemble_task.py index ce314851..717c8719 100644 --- a/tests/trestlebot/tasks/test_assemble_task.py +++ b/tests/trestlebot/tasks/test_assemble_task.py @@ -18,6 +18,7 @@ import os import pathlib +from unittest.mock import Mock, patch import pytest from trestle.core.commands.author.catalog import CatalogGenerate @@ -27,6 +28,7 @@ from tests import testutils from trestlebot.tasks.assemble_task import AssembleTask +from trestlebot.tasks.authored.base_authored import AuthorObjectBase from trestlebot.tasks.authored.types import AuthoredType @@ -41,6 +43,33 @@ ssp_md_dir = "md_ssp" +def test_assemble_task_isolated(tmp_trestle_dir: str) -> None: + """Test the assemble task isolated from AuthoredObject implementation""" + trestle_root = pathlib.Path(tmp_trestle_dir) + md_path = os.path.join(cat_md_dir, test_cat) + args = testutils.setup_for_catalog(trestle_root, test_cat, md_path) + cat_generate = CatalogGenerate() + assert cat_generate._run(args) == 0 + + mock = Mock(spec=AuthorObjectBase) + + assemble_task = AssembleTask( + tmp_trestle_dir, + AuthoredType.CATALOG.value, + cat_md_dir, + "", + ) + + with patch( + "trestlebot.tasks.authored.types.get_authored_object" + ) as mock_get_authored_object: + mock_get_authored_object.return_value = mock + + assert assemble_task.execute() == 0 + + mock.assemble.assert_called_once_with(markdown_path=md_path) + + def test_catalog_assemble_task(tmp_trestle_dir: str) -> None: """Test catalog assemble at the task level""" trestle_root = pathlib.Path(tmp_trestle_dir) diff --git a/tests/trestlebot/tasks/test_regenerate_task .py b/tests/trestlebot/tasks/test_regenerate_task .py index b6983df7..db0bfd0e 100644 --- a/tests/trestlebot/tasks/test_regenerate_task .py +++ b/tests/trestlebot/tasks/test_regenerate_task .py @@ -19,11 +19,13 @@ import argparse import os import pathlib +from unittest.mock import Mock, patch import pytest from trestle.core.commands.author.ssp import SSPAssemble, SSPGenerate from tests import testutils +from trestlebot.tasks.authored.base_authored import AuthorObjectBase from trestlebot.tasks.authored.types import AuthoredType from trestlebot.tasks.regenerate_task import RegenerateTask @@ -39,6 +41,33 @@ ssp_md_dir = "md_ssp" +def test_regenerate_task_isolated(tmp_trestle_dir: str) -> None: + """Test the regenerate task isolated from AuthoredObject implementation""" + trestle_root = pathlib.Path(tmp_trestle_dir) + md_path = os.path.join(cat_md_dir, test_cat) + _ = testutils.setup_for_catalog(trestle_root, test_cat, md_path) + + mock = Mock(spec=AuthorObjectBase) + + regenerate_task = RegenerateTask( + tmp_trestle_dir, + AuthoredType.CATALOG.value, + cat_md_dir, + "", + ) + + with patch( + "trestlebot.tasks.authored.types.get_authored_object" + ) as mock_get_authored_object: + mock_get_authored_object.return_value = mock + + assert regenerate_task.execute() == 0 + + mock.regenerate.assert_called_once_with( + model_path=f"catalogs/{test_cat}", markdown_path=cat_md_dir + ) + + def test_catalog_regenerate_task(tmp_trestle_dir: str) -> None: """Test catalog regenerate at the task level""" trestle_root = pathlib.Path(tmp_trestle_dir) diff --git a/tests/trestlebot/test_bot.py b/tests/trestlebot/test_bot.py index abf90f3c..5f929bff 100644 --- a/tests/trestlebot/test_bot.py +++ b/tests/trestlebot/test_bot.py @@ -16,20 +16,38 @@ """Test for top-level Trestle Bot logic.""" -import json import os -from typing import Tuple +from typing import Callable, List, Tuple from unittest.mock import Mock, patch import pytest +from git import GitCommandError from git.repo import Repo import trestlebot.bot as bot from tests.testutils import clean -from trestlebot.provider import GitProvider - - -def test_stage_files(tmp_repo: Tuple[str, Repo]) -> None: +from trestlebot.provider import GitProvider, GitProviderException + + +def check_lists_equal(list1: List[str], list2: List[str]) -> bool: + return sorted(list1) == sorted(list2) + + +@pytest.mark.parametrize( + "file_patterns, expected_files", + [ + (["*.txt"], ["file1.txt", "file2.txt"]), + (["file*.txt"], ["file1.txt", "file2.txt"]), + (["*.csv"], ["file3.csv"]), + (["file*.csv"], ["file3.csv"]), + (["*.txt", "*.csv"], ["file1.txt", "file2.txt", "file3.csv"]), + (["."], ["file1.txt", "file2.txt", "file3.csv"]), + ([], []), + ], +) +def test_stage_files( + tmp_repo: Tuple[str, Repo], file_patterns: List[str], expected_files: List[str] +) -> None: """Test staging files by patterns""" repo_path, repo = tmp_repo @@ -38,16 +56,16 @@ def test_stage_files(tmp_repo: Tuple[str, Repo]) -> None: f.write("Test file 1 content") with open(os.path.join(repo_path, "file2.txt"), "w") as f: f.write("Test file 2 content") + with open(os.path.join(repo_path, "file3.csv"), "w") as f: + f.write("test,") # Stage the files - bot._stage_files(repo, ["*.txt"]) + bot._stage_files(repo, file_patterns) # Verify that files are staged staged_files = [item.a_path for item in repo.index.diff(repo.head.commit)] - assert len(staged_files) == 2 - assert "file1.txt" in staged_files - assert "file2.txt" in staged_files + assert check_lists_equal(staged_files, expected_files) is True clean(repo_path, repo) @@ -201,28 +219,24 @@ def test_run_dry_run(tmp_repo: Tuple[str, Repo]) -> None: with open(test_file_path, "w") as f: f.write("Test content") - # Test running the bot - commit_sha = bot.run( - working_dir=repo_path, - branch="main", - commit_name="Test User", - commit_email="test@example.com", - commit_message="Test commit message", - author_name="The Author", - author_email="author@test.com", - patterns=["*.txt"], - dry_run=True, - ) - assert commit_sha != "" + with patch("git.remote.Remote.push") as mock_push: + mock_push.return_value = "Mocked result" - # Verify that the commit is made - commit = next(repo.iter_commits()) - assert commit.message.strip() == "Test commit message" - assert commit.author.name == "The Author" - assert commit.author.email == "author@test.com" + # Test running the bot + commit_sha = bot.run( + working_dir=repo_path, + branch="main", + commit_name="Test User", + commit_email="test@example.com", + commit_message="Test commit message", + author_name="The Author", + author_email="author@test.com", + patterns=["*.txt"], + dry_run=True, + ) + assert commit_sha != "" - # Verify that the file is tracked by the commit - assert os.path.basename(test_file_path) in commit.stats.files + mock_push.assert_not_called() clean(repo_path, repo) @@ -248,48 +262,6 @@ def test_empty_commit(tmp_repo: Tuple[str, Repo]) -> None: clean(repo_path, repo) -def test_non_matching_files(tmp_repo: Tuple[str, Repo]) -> None: - """Test that non-matching files are ignored""" - repo_path, repo = tmp_repo - - # Create a test file - test_file_path = os.path.join(repo_path, "test.txt") - with open(test_file_path, "w") as f: - f.write("Test content") - - # Create a test file - data = {"test": "file"} - test_json_path = os.path.join(repo_path, "test.json") - with open(test_json_path, "w") as f: - json.dump(data, f, indent=4) - - # Test running the bot - commit_sha = bot.run( - working_dir=repo_path, - branch="main", - commit_name="Test User", - commit_email="test@example.com", - commit_message="Test commit message", - author_name="The Author", - author_email="author@test.com", - patterns=["*.json"], - dry_run=True, - ) - assert commit_sha != "" - - # Verify that the commit is made - commit = next(repo.iter_commits()) - assert commit.message.strip() == "Test commit message" - assert commit.author.name == "The Author" - assert commit.author.email == "author@test.com" - - # Verify that only the JSON file is tracked in the commits - assert os.path.basename(test_file_path) not in commit.stats.files - assert os.path.basename(test_json_path) in commit.stats.files - - clean(repo_path, repo) - - def test_run_check_only(tmp_repo: Tuple[str, Repo]) -> None: """Test bot run with check_only""" repo_path, repo = tmp_repo @@ -319,6 +291,53 @@ def test_run_check_only(tmp_repo: Tuple[str, Repo]) -> None: clean(repo_path, repo) +def push_side_effect(refspec: str) -> None: + raise GitCommandError("example") + + +def pull_side_effect(refspec: str) -> None: + raise GitProviderException("example") + + +@pytest.mark.parametrize( + "side_effect, msg", + [ + (push_side_effect, "Git push to .* failed: .*"), + (pull_side_effect, "Git pull request to .* failed: example"), + ], +) +def test_run_with_exception( + tmp_repo: Tuple[str, Repo], side_effect: Callable[[str], None], msg: str +) -> None: + """Test bot run with mocked push with side effects that throw exceptions""" + repo_path, repo = tmp_repo + + # Create a test file + test_file_path = os.path.join(repo_path, "test.txt") + with open(test_file_path, "w") as f: + f.write("Test content") + + repo.create_remote("origin", url="git.test.com/test/repo.git") + + with patch("git.remote.Remote.push") as mock_push: + mock_push.side_effect = side_effect + + with pytest.raises(bot.RepoException, match=msg): + _ = bot.run( + working_dir=repo_path, + branch="main", + commit_name="Test User", + commit_email="test@example.com", + commit_message="Test commit message", + author_name="The Author", + author_email="author@test.com", + patterns=["*.txt"], + dry_run=False, + ) + + clean(repo_path, repo) + + def test_run_with_provider(tmp_repo: Tuple[str, Repo]) -> None: """Test bot run with mock git provider""" repo_path, repo = tmp_repo diff --git a/trestlebot/bot.py b/trestlebot/bot.py index b13f63fa..2522bc94 100644 --- a/trestlebot/bot.py +++ b/trestlebot/bot.py @@ -39,7 +39,6 @@ class RepoException(Exception): def _stage_files(gitwd: Repo, patterns: List[str]) -> None: """Stages files in git based on file patterns""" for pattern in patterns: - gitwd.index.add(pattern) if pattern == ".": logger.info("Staging all repository changes") # Using check to avoid adding git directory From b614cbd6fb1e300f8dd7401a195a742960a10327 Mon Sep 17 00:00:00 2001 From: Jennifer Power Date: Wed, 26 Jul 2023 15:04:01 -0400 Subject: [PATCH 3/3] chore: updates code coverage threshold to 80 Signed-off-by: Jennifer Power --- Makefile | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/Makefile b/Makefile index e555af4d..e93890f3 100644 --- a/Makefile +++ b/Makefile @@ -28,7 +28,7 @@ test: .PHONY: test test-code-cov: - @poetry run pytest --cov=trestlebot --exitfirst --cov-config=pyproject.toml --cov-report=xml --cov-fail-under=85 + @poetry run pytest --cov=trestlebot --exitfirst --cov-config=pyproject.toml --cov-report=xml --cov-fail-under=80 .PHONY: test-code-cov # https://github.com/python-poetry/poetry/issues/994#issuecomment-831598242