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..e93890f3 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=80 +.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" 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