From 5185afad74d0a3a86e846ba67fd202c60799d0a4 Mon Sep 17 00:00:00 2001 From: Ville Korhonen Date: Thu, 18 Dec 2025 09:57:16 +0200 Subject: [PATCH] Enforce defining impacts This sets `Impact:` as required line in git commit messages. Goal is to make versioning easier, as each separate commit always defines it's impact on the overall stack. With this change, it should be easy to just read this line from all commits since previous version tag to see what would be the proper new version tag. Note: As pytest does not support executing parametrized tests when classes are based on top of unittest base classes, test cases have been duplicated manually. Gitlint base test suite (base.py) is imported from external repository, and Ruff is configured to ignore most of the issues with that file. Source: https://github.com/jorisroovers/gitlint/blob/4d9119760056492eabc201bfad5de2f9e660b85f/gitlint-core/gitlint/tests/base.py Commit: 4d9119760056492eabc201bfad5de2f9e660b85f License: MIT For more details about specs and features look for external documentation. See: https://docs.pytest.org/en/7.1.x/how-to/unittest.html#pytest-features-in-unittest-testcase-subclasses See: https://semver.org/ See: https://jorisroovers.com/gitlint/latest/rules/user_defined_rules/ Impact: minor Related: ICA-38 Closes: SGE-2228 --- .github/workflows/build-and-deploy.yml | 12 +- .github/workflows/format.yml | 22 +++ .github/workflows/gitlint.yml | 2 +- .github/workflows/pytest.yml | 27 +++ .github/workflows/python-lint.yml | 22 +++ .gitignore | 3 + .gitlint | 1 + Dockerfile | 1 + dev-requirements.in | 4 + dev-requirements.txt | 164 +++++++++++++++++ pyproject.toml | 89 +++++++++ requirements-seravo.txt | 0 requirements.in | 1 + requirements.txt | 1 + rules/define_impact.py | 24 +++ tests/__init__.py | 0 tests/base.py | 243 +++++++++++++++++++++++++ tests/test_define_impact.py | 76 ++++++++ 18 files changed, 685 insertions(+), 7 deletions(-) create mode 100644 .github/workflows/format.yml create mode 100644 .github/workflows/pytest.yml create mode 100644 .github/workflows/python-lint.yml create mode 100644 .gitignore create mode 100644 dev-requirements.in create mode 100644 dev-requirements.txt create mode 100644 pyproject.toml create mode 100644 requirements-seravo.txt create mode 100644 requirements.in create mode 100644 requirements.txt create mode 100644 rules/define_impact.py create mode 100644 tests/__init__.py create mode 100644 tests/base.py create mode 100644 tests/test_define_impact.py diff --git a/.github/workflows/build-and-deploy.yml b/.github/workflows/build-and-deploy.yml index 25f582a..d3b802d 100644 --- a/.github/workflows/build-and-deploy.yml +++ b/.github/workflows/build-and-deploy.yml @@ -31,7 +31,7 @@ jobs: name: Clone git repository - id: docker-login - uses: Seravo/actions/docker-login@v1.15.0 + uses: seravo/actions/docker-login@v1.16.0 name: Login to ghcr.io # To speed up builds, try to use previously built image as cache source. @@ -40,12 +40,12 @@ jobs: - if: ${{ github.event_name != 'schedule' }} name: Pull previously built image id: docker-pull - uses: Seravo/actions/docker-pull-previous@v1.15.0 + uses: seravo/actions/docker-pull-previous@v1.16.0 with: image: "${{ env.image }}" - id: docker-build - uses: Seravo/actions/docker-build@v1.15.0 + uses: seravo/actions/docker-build@v1.16.0 name: Build image with: image: "${{ env.image }}" @@ -53,20 +53,20 @@ jobs: - if: ${{ github.ref == 'refs/heads/main' }} name: Push new image to production id: docker-push-master - uses: Seravo/actions/docker-push@v1.15.0 + uses: seravo/actions/docker-push@v1.16.0 with: image: "${{ env.image }}" - id: docker-tag-push-commit name: Tag image with commit id - uses: Seravo/actions/docker-tag-and-push@v1.15.0 + uses: seravo/actions/docker-tag-and-push@v1.16.0 with: source: "${{ env.image }}" target: "${{ env.image }}:${{ github.sha }}" - id: docker-tag-push-refname name: Tag image with refname - uses: Seravo/actions/docker-tag-and-push@v1.15.0 + uses: seravo/actions/docker-tag-and-push@v1.16.0 with: source: "${{ env.image }}" target: "${{ env.image }}:${{ steps.refname.outputs.refname }}" diff --git a/.github/workflows/format.yml b/.github/workflows/format.yml new file mode 100644 index 0000000..5cd3d20 --- /dev/null +++ b/.github/workflows/format.yml @@ -0,0 +1,22 @@ +--- +name: Check code formatting with Ruff + +on: + push: + branches: + - main + - feature/** + - bugfix/** + +jobs: + format: + runs-on: ubuntu-latest + steps: + - name: Checkout repository + uses: actions/checkout@v4 + + - name: Run Ruff + id: format + uses: seravo/actions/pyruff-format@v1.16.0 + with: + dev_requirements_path: dev-requirements.txt diff --git a/.github/workflows/gitlint.yml b/.github/workflows/gitlint.yml index a702d80..206972d 100644 --- a/.github/workflows/gitlint.yml +++ b/.github/workflows/gitlint.yml @@ -13,4 +13,4 @@ jobs: ref: ${{ github.head_ref }} - name: Execute git linting - uses: seravo/gitlint@v1.0.1 + uses: seravo/gitlint@v1 diff --git a/.github/workflows/pytest.yml b/.github/workflows/pytest.yml new file mode 100644 index 0000000..87d6115 --- /dev/null +++ b/.github/workflows/pytest.yml @@ -0,0 +1,27 @@ +--- +name: Run unittests + +on: + push: + branches: + - main + - feature/** + - bugfix/** + +jobs: + testing: + runs-on: ubuntu-latest + strategy: + matrix: + PYTHON_VERSION: ["3.12"] + steps: + - name: Checkout repository with submodules + uses: actions/checkout@v4 + + - name: Run tests + id: tests + uses: seravo/actions/pytest@v1.16.0 + with: + python_version: ${{ matrix.PYTHON_VERSION }} + requirements_path: requirements.txt + dev_requirements_path: dev-requirements.txt diff --git a/.github/workflows/python-lint.yml b/.github/workflows/python-lint.yml new file mode 100644 index 0000000..d88abb6 --- /dev/null +++ b/.github/workflows/python-lint.yml @@ -0,0 +1,22 @@ +--- +name: Lint code with ruff + +on: + push: + branches: + - main + - feature/** + - bugfix/** + +jobs: + lint: + runs-on: ubuntu-latest + steps: + - name: Checkout repository + uses: actions/checkout@v4 + + - name: Run ruff + id: format + uses: seravo/actions/pyruff@v1.16.0 + with: + dev_requirements_path: dev-requirements.txt diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..c3ba83f --- /dev/null +++ b/.gitignore @@ -0,0 +1,3 @@ +*~ +*.swp +*.py[co] diff --git a/.gitlint b/.gitlint index f57534e..5aa9959 100644 --- a/.gitlint +++ b/.gitlint @@ -1,5 +1,6 @@ [general] regex-style-search = True +extra-path=/gitlint-rules.d # [title-max-length] # line-length=50 diff --git a/Dockerfile b/Dockerfile index c22599d..1f5488f 100644 --- a/Dockerfile +++ b/Dockerfile @@ -14,6 +14,7 @@ RUN useradd user && \ mkdir -p /workdir && \ mkdir -p /config +COPY rules /gitlint-rules.d COPY .gitlint /config/gitlint COPY --chmod=0755 entrypoint.sh /entrypoint.sh diff --git a/dev-requirements.in b/dev-requirements.in new file mode 100644 index 0000000..91ae08a --- /dev/null +++ b/dev-requirements.in @@ -0,0 +1,4 @@ +coverage>=7.13 +pytest>=9.0 +ruff>=0.14 +gitlint diff --git a/dev-requirements.txt b/dev-requirements.txt new file mode 100644 index 0000000..4edeb5b --- /dev/null +++ b/dev-requirements.txt @@ -0,0 +1,164 @@ +# This file was autogenerated by uv via the following command: +# uv pip compile --generate-hashes dev-requirements.in -o dev-requirements.txt +arrow==1.2.3 \ + --hash=sha256:3934b30ca1b9f292376d9db15b19446088d12ec58629bc3f0da28fd55fb633a1 \ + --hash=sha256:5a49ab92e3b7b71d96cd6bfcc4df14efefc9dfa96ea19045815914a6ab6b1fe2 + # via gitlint-core +click==8.1.3 \ + --hash=sha256:7682dc8afb30297001674575ea00d1814d808d6a36af415a82bd481d37ba7b8e \ + --hash=sha256:bb4d8133cb15a609f44e8213d9b391b0809795062913b383c62be0ee95b1db48 + # via gitlint-core +coverage==7.13.0 \ + --hash=sha256:0018f73dfb4301a89292c73be6ba5f58722ff79f51593352759c1790ded1cabe \ + --hash=sha256:00c3d22cf6fb1cf3bf662aaaa4e563be8243a5ed2630339069799835a9cc7f9b \ + --hash=sha256:02d9fb9eccd48f6843c98a37bd6817462f130b86da8660461e8f5e54d4c06070 \ + --hash=sha256:0602f701057c6823e5db1b74530ce85f17c3c5be5c85fc042ac939cbd909426e \ + --hash=sha256:06cac81bf10f74034e055e903f5f946e3e26fc51c09fc9f584e4a1605d977053 \ + --hash=sha256:086cede306d96202e15a4b77ace8472e39d9f4e5f9fd92dd4fecdfb2313b2080 \ + --hash=sha256:0900872f2fdb3ee5646b557918d02279dc3af3dfb39029ac4e945458b13f73bc \ + --hash=sha256:0a3a30f0e257df382f5f9534d4ce3d4cf06eafaf5192beb1a7bd066cb10e78fb \ + --hash=sha256:0b3d67d31383c4c68e19a88e28fc4c2e29517580f1b0ebec4a069d502ce1e0bf \ + --hash=sha256:0dfa3855031070058add1a59fdfda0192fd3e8f97e7c81de0596c145dea51820 \ + --hash=sha256:0f4872f5d6c54419c94c25dd6ae1d015deeb337d06e448cd890a1e89a8ee7f3b \ + --hash=sha256:11c21557d0e0a5a38632cbbaca5f008723b26a89d70db6315523df6df77d6232 \ + --hash=sha256:166ad2a22ee770f5656e1257703139d3533b4a0b6909af67c6b4a3adc1c98657 \ + --hash=sha256:193c3887285eec1dbdb3f2bd7fbc351d570ca9c02ca756c3afbc71b3c98af6ef \ + --hash=sha256:1d84e91521c5e4cb6602fe11ece3e1de03b2760e14ae4fcf1a4b56fa3c801fcd \ + --hash=sha256:1ed5630d946859de835a85e9a43b721123a8a44ec26e2830b296d478c7fd4259 \ + --hash=sha256:22486cdafba4f9e471c816a2a5745337742a617fef68e890d8baf9f3036d7833 \ + --hash=sha256:22ccfe8d9bb0d6134892cbe1262493a8c70d736b9df930f3f3afae0fe3ac924d \ + --hash=sha256:24e4e56304fdb56f96f80eabf840eab043b3afea9348b88be680ec5986780a0f \ + --hash=sha256:25dc33618d45456ccb1d37bce44bc78cf269909aa14c4db2e03d63146a8a1493 \ + --hash=sha256:263c3dbccc78e2e331e59e90115941b5f53e85cfcc6b3b2fbff1fd4e3d2c6ea8 \ + --hash=sha256:28ee1c96109974af104028a8ef57cec21447d42d0e937c0275329272e370ebcf \ + --hash=sha256:30a3a201a127ea57f7e14ba43c93c9c4be8b7d17a26e03bb49e6966d019eede9 \ + --hash=sha256:3188936845cd0cb114fa6a51842a304cdbac2958145d03be2377ec41eb285d19 \ + --hash=sha256:367449cf07d33dc216c083f2036bb7d976c6e4903ab31be400ad74ad9f85ce98 \ + --hash=sha256:37eee4e552a65866f15dedd917d5e5f3d59805994260720821e2c1b51ac3248f \ + --hash=sha256:3a10260e6a152e5f03f26db4a407c4c62d3830b9af9b7c0450b183615f05d43b \ + --hash=sha256:3a7b1cd820e1b6116f92c6128f1188e7afe421c7e1b35fa9836b11444e53ebd9 \ + --hash=sha256:3ab483ea0e251b5790c2aac03acde31bff0c736bf8a86829b89382b407cd1c3b \ + --hash=sha256:3ad968d1e3aa6ce5be295ab5fe3ae1bf5bb4769d0f98a80a0252d543a2ef2e9e \ + --hash=sha256:445badb539005283825959ac9fa4a28f712c214b65af3a2c464f1adc90f5fcbc \ + --hash=sha256:453b7ec753cf5e4356e14fe858064e5520c460d3bbbcb9c35e55c0d21155c256 \ + --hash=sha256:494f5459ffa1bd45e18558cd98710c36c0b8fbfa82a5eabcbe671d80ecffbfe8 \ + --hash=sha256:4b5de7d4583e60d5fd246dd57fcd3a8aa23c6e118a8c72b38adf666ba8e7e927 \ + --hash=sha256:4f3e223b2b2db5e0db0c2b97286aba0036ca000f06aca9b12112eaa9af3d92ae \ + --hash=sha256:4fdb6f54f38e334db97f72fa0c701e66d8479af0bc3f9bfb5b90f1c30f54500f \ + --hash=sha256:51a202e0f80f241ccb68e3e26e19ab5b3bf0f813314f2c967642f13ebcf1ddfe \ + --hash=sha256:581f086833d24a22c89ae0fe2142cfaa1c92c930adf637ddf122d55083fb5a0f \ + --hash=sha256:583221913fbc8f53b88c42e8dbb8fca1d0f2e597cb190ce45916662b8b9d9621 \ + --hash=sha256:58632b187be6f0be500f553be41e277712baa278147ecb7559983c6d9faf7ae1 \ + --hash=sha256:5c67dace46f361125e6b9cace8fe0b729ed8479f47e70c89b838d319375c8137 \ + --hash=sha256:5e70f92ef89bac1ac8a99b3324923b4749f008fdbd7aa9cb35e01d7a284a04f9 \ + --hash=sha256:5f5d9bd30756fff3e7216491a0d6d520c448d5124d3d8e8f56446d6412499e74 \ + --hash=sha256:5f8a0297355e652001015e93be345ee54393e45dc3050af4a0475c5a2b767d46 \ + --hash=sha256:62d7c4f13102148c78d7353c6052af6d899a7f6df66a32bddcc0c0eb7c5326f8 \ + --hash=sha256:69ac2c492918c2461bc6ace42d0479638e60719f2a4ef3f0815fa2df88e9f940 \ + --hash=sha256:6abb3a4c52f05e08460bd9acf04fec027f8718ecaa0d09c40ffbc3fbd70ecc39 \ + --hash=sha256:6e63ccc6e0ad8986386461c3c4b737540f20426e7ec932f42e030320896c311a \ + --hash=sha256:6e9e451dee940a86789134b6b0ffbe31c454ade3b849bb8a9d2cca2541a8e91d \ + --hash=sha256:6fb2d5d272341565f08e962cce14cdf843a08ac43bd621783527adb06b089c4b \ + --hash=sha256:71936a8b3b977ddd0b694c28c6a34f4fff2e9dd201969a4ff5d5fc7742d614b0 \ + --hash=sha256:73419b89f812f498aca53f757dd834919b48ce4799f9d5cad33ca0ae442bdb1a \ + --hash=sha256:739c6c051a7540608d097b8e13c76cfa85263ced467168dc6b477bae3df7d0e2 \ + --hash=sha256:7464663eaca6adba4175f6c19354feea61ebbdd735563a03d1e472c7072d27bb \ + --hash=sha256:74c136e4093627cf04b26a35dab8cbfc9b37c647f0502fc313376e11726ba303 \ + --hash=sha256:76541dc8d53715fb4f7a3a06b34b0dc6846e3c69bc6204c55653a85dd6220971 \ + --hash=sha256:7a485ff48fbd231efa32d58f479befce52dcb6bfb2a88bb7bf9a0b89b1bc8030 \ + --hash=sha256:7e442c013447d1d8d195be62852270b78b6e255b79b8675bad8479641e21fd96 \ + --hash=sha256:7f15a931a668e58087bc39d05d2b4bf4b14ff2875b49c994bbdb1c2217a8daeb \ + --hash=sha256:7f88ae3e69df2ab62fb0bc5219a597cb890ba5c438190ffa87490b315190bb33 \ + --hash=sha256:8069e831f205d2ff1f3d355e82f511eb7c5522d7d413f5db5756b772ec8697f8 \ + --hash=sha256:850d2998f380b1e266459ca5b47bc9e7daf9af1d070f66317972f382d46f1904 \ + --hash=sha256:898cce66d0836973f48dda4e3514d863d70142bdf6dfab932b9b6a90ea5b222d \ + --hash=sha256:9097818b6cc1cfb5f174e3263eba4a62a17683bcfe5c4b5d07f4c97fa51fbf28 \ + --hash=sha256:936bc20503ce24770c71938d1369461f0c5320830800933bc3956e2a4ded930e \ + --hash=sha256:9372dff5ea15930fea0445eaf37bbbafbc771a49e70c0aeed8b4e2c2614cc00e \ + --hash=sha256:9987a9e4f8197a1000280f7cc089e3ea2c8b3c0a64d750537809879a7b4ceaf9 \ + --hash=sha256:99acd4dfdfeb58e1937629eb1ab6ab0899b131f183ee5f23e0b5da5cba2fec74 \ + --hash=sha256:9b01c22bc74a7fb44066aaf765224c0d933ddf1f5047d6cdfe4795504a4493f8 \ + --hash=sha256:a00d3a393207ae12f7c49bb1c113190883b500f48979abb118d8b72b8c95c032 \ + --hash=sha256:a23e5a1f8b982d56fa64f8e442e037f6ce29322f1f9e6c2344cd9e9f4407ee57 \ + --hash=sha256:a2bdb3babb74079f021696cb46b8bb5f5661165c385d3a238712b031a12355be \ + --hash=sha256:a394aa27f2d7ff9bc04cf703817773a59ad6dfbd577032e690f961d2460ee936 \ + --hash=sha256:a6c6e16b663be828a8f0b6c5027d36471d4a9f90d28444aa4ced4d48d7d6ae8f \ + --hash=sha256:af0a583efaacc52ae2521f8d7910aff65cdb093091d76291ac5820d5e947fc1c \ + --hash=sha256:af827b7cbb303e1befa6c4f94fd2bf72f108089cfa0f8abab8f4ca553cf5ca5a \ + --hash=sha256:c4be718e51e86f553bcf515305a158a1cd180d23b72f07ae76d6017c3cc5d791 \ + --hash=sha256:cdb3c9f8fef0a954c632f64328a3935988d33a6604ce4bf67ec3e39670f12ae5 \ + --hash=sha256:d10fd186aac2316f9bbb46ef91977f9d394ded67050ad6d84d94ed6ea2e8e54e \ + --hash=sha256:d1e97353dcc5587b85986cda4ff3ec98081d7e84dd95e8b2a6d59820f0545f8a \ + --hash=sha256:d2a9d7f1c11487b1c69367ab3ac2d81b9b3721f097aa409a3191c3e90f8f3dd7 \ + --hash=sha256:de7f6748b890708578fc4b7bb967d810aeb6fcc9bff4bb77dbca77dab2f9df6a \ + --hash=sha256:e5330fa0cc1f5c3c4c3bb8e101b742025933e7848989370a1d4c8c5e401ea753 \ + --hash=sha256:e999e2dcc094002d6e2c7bbc1fb85b58ba4f465a760a8014d97619330cdbbbf3 \ + --hash=sha256:eb76670874fdd6091eedcc856128ee48c41a9bbbb9c3f1c7c3cf169290e3ffd6 \ + --hash=sha256:f1c23e24a7000da892a312fb17e33c5f94f8b001de44b7cf8ba2e36fbd15859e \ + --hash=sha256:f2ffc92b46ed6e6760f1d47a71e56b5664781bc68986dbd1836b2b70c0ce2071 \ + --hash=sha256:f4f72a85316d8e13234cafe0a9f81b40418ad7a082792fa4165bd7d45d96066b \ + --hash=sha256:f59883c643cb19630500f57016f76cfdcd6845ca8c5b5ea1f6e17f74c8e5f511 \ + --hash=sha256:f6aaef16d65d1787280943f1c8718dc32e9cf141014e4634d64446702d26e0ff \ + --hash=sha256:fe81055d8c6c9de76d60c94ddea73c290b416e061d40d542b24a5871bad498b7 \ + --hash=sha256:ff45e0cd8451e293b63ced93161e189780baf444119391b3e7d25315060368a6 + # via -r dev-requirements.in +gitlint==0.19.1 \ + --hash=sha256:26bb085959148d99fbbc178b4e56fda6c3edd7646b7c2a24d8ee1f8e036ed85d \ + --hash=sha256:b5b70fb894e80849b69abbb65ee7dbb3520fc3511f202a6e6b6ddf1a71ee8f61 + # via -r dev-requirements.in +gitlint-core==0.19.1 \ + --hash=sha256:7bf977b03ff581624a9e03f65ebb8502cc12dfaa3e92d23e8b2b54bbdaa29992 \ + --hash=sha256:f41effd1dcbc06ffbfc56b6888cce72241796f517b46bd9fd4ab1b145056988c + # via gitlint +iniconfig==2.0.0 \ + --hash=sha256:2d91e135bf72d31a410b17c16da610a82cb55f6b0477d1a902134b24a455b8b3 \ + --hash=sha256:b6a85871a79d2e3b22d2d1b94ac2824226a63c6b741c88f7ae975f18b6778374 + # via pytest +packaging==23.2 \ + --hash=sha256:048fb0e9405036518eaaf48a55953c750c11e1a1b68e0dd1a9d62ed0c092cfc5 \ + --hash=sha256:8c491190033a9af7e1d931d0b5dacc2ef47509b34dd0de67ed209b5203fc88c7 + # via pytest +pluggy==1.6.0 \ + --hash=sha256:7dcc130b76258d33b90f61b658791dede3486c3e6bfb003ee5c9bfb396dd22f3 \ + --hash=sha256:e920276dd6813095e9377c0bc5566d94c932c33b27a3e3945d8389c374dd4746 + # via pytest +pygments==2.19.2 \ + --hash=sha256:636cb2477cec7f8952536970bc533bc43743542f70392ae026374600add5b887 \ + --hash=sha256:86540386c03d588bb81d44bc3928634ff26449851e99741617ecb9037ee5ec0b + # via pytest +pytest==9.0.2 \ + --hash=sha256:711ffd45bf766d5264d487b917733b453d917afd2b0ad65223959f59089f875b \ + --hash=sha256:75186651a92bd89611d1d9fc20f0b4345fd827c41ccd5c299a868a05d70edf11 + # via -r dev-requirements.in +python-dateutil==2.9.0.post0 \ + --hash=sha256:37dd54208da7e1cd875388217d5e00ebd4179249f90fb72437e91a35459a0ad3 \ + --hash=sha256:a8b2bc7bffae282281c8140a97d3aa9c14da0b136dfe83f850eea9a5f7470427 + # via arrow +ruff==0.14.9 \ + --hash=sha256:1e5cb521e5ccf0008bd74d5595a4580313844a42b9103b7388eca5a12c970743 \ + --hash=sha256:347e3bf16197e8a2de17940cd75fd6491e25c0aa7edf7d61aa03f146a1aa885a \ + --hash=sha256:35f85b25dd586381c0cc053f48826109384c81c00ad7ef1bd977bfcc28119d5b \ + --hash=sha256:6a1cfb04eda979b20c8c19550c8b5f498df64ff8da151283311ce3199e8b3648 \ + --hash=sha256:712ff04f44663f1b90a1195f51525836e3413c8a773574a7b7775554269c30ed \ + --hash=sha256:72034534e5b11e8a593f517b2f2f2b273eb68a30978c6a2d40473ad0aaa4cb4a \ + --hash=sha256:7715d14e5bccf5b660f54516558aa94781d3eb0838f8e706fb60e3ff6eff03a8 \ + --hash=sha256:84bf7c698fc8f3cb8278830fb6b5a47f9bcc1ed8cb4f689b9dd02698fa840697 \ + --hash=sha256:8769efc71558fecc25eb295ddec7d1030d41a51e9dcf127cbd63ec517f22d567 \ + --hash=sha256:8e821c366517a074046d92f0e9213ed1c13dbc5b37a7fc20b07f79b64d62cc84 \ + --hash=sha256:a111fee1db6f1d5d5810245295527cda1d367c5aa8f42e0fca9a78ede9b4498b \ + --hash=sha256:aa733093d1f9d88a5d98988d8834ef5d6f9828d03743bf5e338bf980a19fce27 \ + --hash=sha256:ab208c1b7a492e37caeaf290b1378148f75e13c2225af5d44628b95fd7834273 \ + --hash=sha256:c0b53a10e61df15a42ed711ec0bda0c582039cf6c754c49c020084c55b5b0bc2 \ + --hash=sha256:cd429a8926be6bba4befa8cdcf3f4dd2591c413ea5066b1e99155ed245ae42bb \ + --hash=sha256:d5dc3473c3f0e4a1008d0ef1d75cee24a48e254c8bed3a7afdd2b4392657ed2c \ + --hash=sha256:df0937f30aaabe83da172adaf8937003ff28172f59ca9f17883b4213783df197 \ + --hash=sha256:ed9d7417a299fc6030b4f26333bf1117ed82a61ea91238558c0268c14e00d0c2 \ + --hash=sha256:f1ec5de1ce150ca6e43691f4a9ef5c04574ad9ca35c8b3b0e18877314aba7e75 + # via -r dev-requirements.in +sh==1.14.3 \ + --hash=sha256:e4045b6c732d9ce75d571c79f5ac2234edd9ae4f5fa9d59b09705082bdca18c7 + # via gitlint-core +six==1.17.0 \ + --hash=sha256:4721f391ed90541fddacab5acf947aa0d3dc7d27b2e1e8eda2be8970586c3274 \ + --hash=sha256:ff70335d468e7eb6ec65b95b99d3a2836546063f63acc5171de367e834932a81 + # via python-dateutil diff --git a/pyproject.toml b/pyproject.toml new file mode 100644 index 0000000..0947969 --- /dev/null +++ b/pyproject.toml @@ -0,0 +1,89 @@ +[project] +name = "seravo_gitlint" +description = "" +requires-python = ">=3.12" + + +[tool.pyright] +exclude = ["python-seravo"] +typeCheckingMode = "strict" +reportUnknownMemberType = "warning" +reportIncompatibleMethodOverride = "warning" +reportGeneralTypeIssues = "information" +reportUnknownVariableType = "information" +reportIncompatibleVariableOverride = "information" +reportPrivateImportUsage = false +reportMissingModuleSource = false +reportSelfClsParameterName = false +reportMissingTypeStubs = false +reportUnusedFunction = false +reportUntypedFunctionDecorator = false +reportUnnecessaryTypeIgnoreComment = "error" + + +[tool.ruff] +line-length = 79 +exclude = ["python-seravo", "migrations", "external"] +lint.select = [ + "C90", # mccabe + "E", # pycodestyle + "F", # pyflakes + "UP", # pyupgrade + "W", # pycodestyle + "I", # isort + "ANN001", # flake8-annotations name + "ANN002", # flake8-annotations args + "ANN003", # flake8-annotations kwargs + "ASYNC", # flake8-async + "ARG", # flake8-unused-arguments + "S", # flake8-bandit + "FBT", # flake8-boolean-trap + "B", # flake8-bugbear + "C4", # flake8-comprehensions + "TID252", # flake8-tidy-imports + "RET", # flake8-return + "SIM", # flake8-simplify + "PTH", # flake8-use-pathlib + "PLR1714", # pylint + "PERF", # Perflint + "RUF", # Ruff + "D201", # no-blank-line-before-function + "D202", # no-blank-line-after-function + "D204", # one-blank-line-after-class + "D419" # empty-docstring +] +lint.ignore = [ + "S101", # Use of assert detected + "S311", # Use of standard pseudo-random generators + "S603", # Subprocess without shell + "PERF203", # Use of try except in loop +] +target-version = "py312" + +[tool.ruff.lint.per-file-ignores] +# For parametrize and fixture, ignore boolean trap and unused args in tests +"tests/**.py" = ["FBT001", "ARG002", "ARG001"] +"tests/base.py" = ["ANN", "E", "RET", "PTH"] + +[tool.ruff.lint.mccabe] +max-complexity = 15 + +[tool.ruff.format] +preview = true +quote-style = "preserve" + +[tool.coverage.run] +branch = true +data_file = ".coverage" +omit = [ + "external/*", + "python-seravo/*", + "tests/*", + "ve/*" +] + +[tool.coverage.report] +fail_under = 95 +show_missing = true +skip_covered = true +skip_empty = true diff --git a/requirements-seravo.txt b/requirements-seravo.txt new file mode 100644 index 0000000..e69de29 diff --git a/requirements.in b/requirements.in new file mode 100644 index 0000000..8b13789 --- /dev/null +++ b/requirements.in @@ -0,0 +1 @@ + diff --git a/requirements.txt b/requirements.txt new file mode 100644 index 0000000..8b13789 --- /dev/null +++ b/requirements.txt @@ -0,0 +1 @@ + diff --git a/rules/define_impact.py b/rules/define_impact.py new file mode 100644 index 0000000..57d94f4 --- /dev/null +++ b/rules/define_impact.py @@ -0,0 +1,24 @@ +import re + +from gitlint.git import GitCommit +from gitlint.rules import CommitRule, RuleViolation + + +class ImpactDefined(CommitRule): + """Enforce that `Impact: ` has been defined for every + commit""" + + name = "body-requires-impact" + + id = "SERAVO0001" + + regexp = r'^Impact: (major|minor|patch)$' + + def validate(self, commit: GitCommit) -> list[RuleViolation] | None: + self.log.debug("Check that proper Impact: has been defined") + + for line in commit.message.body: + if re.match(self.regexp, line): + return None + msg = "Body does not contain a valid 'Impact' line" + return [RuleViolation(self.id, msg, line_nr=1)] diff --git a/tests/__init__.py b/tests/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/tests/base.py b/tests/base.py new file mode 100644 index 0000000..db4cdf2 --- /dev/null +++ b/tests/base.py @@ -0,0 +1,243 @@ +import contextlib +import copy +import logging +import os +import re +import shutil +import tempfile +import unittest +from pathlib import Path +from typing import Any +from unittest.mock import patch + +from gitlint.config import LintConfig +from gitlint.deprecation import LOG as DEPRECATION_LOG +from gitlint.deprecation import Deprecation +from gitlint.git import GitChangedFileStats, GitContext +from gitlint.utils import FILE_ENCODING, LOG_FORMAT + +EXPECTED_REGEX_STYLE_SEARCH_DEPRECATION_WARNING = ( + "WARNING: gitlint.deprecated.regex_style_search {0} - {1}: gitlint will be switching from using " + "Python regex 'match' (match beginning) to 'search' (match anywhere) semantics. " + "Please review your {1}.regex option accordingly. " + "To remove this warning, set general.regex-style-search=True. More details: " + "https://jorisroovers.github.io/gitlint/configuration/general_options/#regex-style-search" +) + + +class BaseTestCase(unittest.TestCase): + """Base class of which all gitlint unit test classes are derived. Provides a number of convenience methods.""" + + # In case of assert failures, print the full error message + maxDiff = None + + # Working directory in which tests in this class are executed + working_dir = None + # Originally working dir when the test was started + original_working_dir = None + + SAMPLES_DIR = os.path.join( + os.path.dirname(os.path.realpath(__file__)), "samples" + ) + EXPECTED_DIR = os.path.join( + os.path.dirname(os.path.realpath(__file__)), "expected" + ) + GITLINT_USE_SH_LIB = os.environ.get("GITLINT_USE_SH_LIB", "[NOT SET]") + + @classmethod + def setUpClass(cls): + # Run tests a temporary directory to shield them from any local git config + cls.original_working_dir = os.getcwd() + cls.working_dir = tempfile.mkdtemp() + os.chdir(cls.working_dir) + + @classmethod + def tearDownClass(cls): + # Go back to original working dir and remove our temp working dir + os.chdir(cls.original_working_dir) + shutil.rmtree(cls.working_dir) + + def setUp(self): + self.logcapture = LogCapture() + self.logcapture.setFormatter(logging.Formatter(LOG_FORMAT)) + logging.getLogger("gitlint").setLevel(logging.DEBUG) + logging.getLogger("gitlint").handlers = [self.logcapture] + DEPRECATION_LOG.handlers = [self.logcapture] + + # Make sure we don't propagate anything to child loggers, we need to do this explicitly here + # because if you run a specific test file like test_lint.py, we won't be calling the setupLogging() method + # in gitlint.cli that normally takes care of this + # Example test where this matters (for DEPRECATION_LOG): + # gitlint-core/gitlint/tests/rules/test_configuration_rules.py::ConfigurationRuleTests::test_ignore_by_title + logging.getLogger("gitlint").propagate = False + DEPRECATION_LOG.propagate = False + + # Make sure Deprecation has a clean config set at the start of each test. + # Tests that want to specifically test deprecation should override this. + Deprecation.config = LintConfig() + # Normally Deprecation only logs messages once per process. + # For tests we want to log every time, so we reset the warning_msgs set per test. + Deprecation.warning_msgs = set() + + @staticmethod + @contextlib.contextmanager + def tempdir(): + tmpdir = tempfile.mkdtemp() + try: + yield tmpdir + finally: + shutil.rmtree(tmpdir) + + @staticmethod + def get_sample_path(filename: str = "") -> str: + # Don't join up empty files names because this will add a trailing slash + if filename == "": + return BaseTestCase.SAMPLES_DIR + + return os.path.join(BaseTestCase.SAMPLES_DIR, filename) + + @staticmethod + def get_sample(filename: str = "") -> str: + """Read and return the contents of a file in gitlint/tests/samples""" + sample_path = BaseTestCase.get_sample_path(filename) + return Path(sample_path).read_text(encoding=FILE_ENCODING) + + @staticmethod + def patch_input(side_effect): + """Patches the built-in input() with a provided side-effect""" + module_path = "builtins.input" + patched_module = patch(module_path, side_effect=side_effect) + return patched_module + + @staticmethod + def get_expected( + filename: str = "", variable_dict: dict[str, Any] | None = None + ) -> str: + """Utility method to read an expected file from gitlint/tests/expected and return it as a string. + Optionally replace template variables specified by variable_dict.""" + expected_path = os.path.join(BaseTestCase.EXPECTED_DIR, filename) + expected = Path(expected_path).read_text(encoding=FILE_ENCODING) + + if variable_dict: + expected = expected.format(**variable_dict) + return expected + + @staticmethod + def get_user_rules_path(): + return os.path.join(BaseTestCase.SAMPLES_DIR, "user_rules") + + @staticmethod + def gitcontext(commit_msg_str, changed_files=None): + """Utility method to easily create gitcontext objects based on a given commit msg string and an optional set of + changed files""" + with patch("gitlint.git.git_commentchar") as comment_char: + comment_char.return_value = "#" + gitcontext = GitContext.from_commit_msg(commit_msg_str) + commit = gitcontext.commits[-1] + if changed_files: + changed_file_stats = { + filename: GitChangedFileStats(filename, 8, 3) + for filename in changed_files + } + commit.changed_files_stats = changed_file_stats + return gitcontext + + @staticmethod + def gitcommit(commit_msg_str, changed_files=None, **kwargs): + """Utility method to easily create git commit given a commit msg string and an optional set of changed files""" + gitcontext = BaseTestCase.gitcontext(commit_msg_str, changed_files) + commit = gitcontext.commits[-1] + for attr, value in kwargs.items(): + setattr(commit, attr, value) + return commit + + def assert_logged(self, expected): + """Asserts that the logs match an expected string or list. + This method knows how to compare a passed list of log lines as well as a newline concatenated string + of all loglines.""" + if isinstance(expected, list): + self.assertListEqual(self.logcapture.messages, expected) + else: + self.assertEqual("\n".join(self.logcapture.messages), expected) + + def assert_log_contains(self, line): + """Asserts that a certain line is in the logs""" + self.assertIn(line, self.logcapture.messages) + + def assertRaisesRegex( + self, expected_exception, expected_regex, *args, **kwargs + ): + """Pass-through method to unittest.TestCase.assertRaisesRegex that applies re.escape() to the passed + `expected_regex`. This is useful to automatically escape all file paths that might be present in the regex. + """ + return super().assertRaisesRegex( + expected_exception, re.escape(expected_regex), *args, **kwargs + ) + + def clearlog(self): + """Clears the log capture""" + self.logcapture.clear() + + @contextlib.contextmanager + def assertRaisesMessage(self, expected_exception, expected_msg): + """Asserts an exception has occurred with a given error message""" + try: + yield + except expected_exception as exc: + exception_msg = str(exc) + if exception_msg != expected_msg: # pragma: nocover + error = f"Right exception, wrong message:\n got: {exception_msg}\n expected: {expected_msg}" + raise self.fail(error) from exc + # else: everything is fine, just return + return + except Exception as exc: # pragma: nocover + raise self.fail( + f"Expected '{expected_exception.__name__}' got '{exc.__class__.__name__}'" + ) from exc + + # No exception raised while we expected one + raise self.fail( + f"Expected to raise {expected_exception.__name__}, didn't get an exception at all" + ) # pragma: nocover + + def object_equality_test(self, obj, attr_list, ctor_kwargs=None): + """Helper function to easily implement object equality tests. + Creates an object clone for every passed attribute and checks for (in)equality + of the original object with the clone based on those attributes' values. + This function assumes all attributes in `attr_list` can be passed to the ctor of `obj.__class__`. + """ + if not ctor_kwargs: + ctor_kwargs = {} + + attr_kwargs = {} + for attr in attr_list: + attr_kwargs[attr] = getattr(obj, attr) + + # For every attr, clone the object and assert the clone and the original object are equal + # Then, change the current attr and assert objects are unequal + for attr in attr_list: + attr_kwargs_copy = copy.deepcopy(attr_kwargs) + attr_kwargs_copy.update(ctor_kwargs) + clone = obj.__class__(**attr_kwargs_copy) + self.assertEqual(obj, clone) + + # Change attribute and assert objects are different (via both attribute set and ctor) + setattr(clone, attr, "föo") + self.assertNotEqual(obj, clone) + attr_kwargs_copy[attr] = "föo" + + self.assertNotEqual(obj, obj.__class__(**attr_kwargs_copy)) + + +class LogCapture(logging.Handler): + """Mock logging handler used to capture any log messages during tests.""" + + def __init__(self, *args, **kwargs): + logging.Handler.__init__(self, *args, **kwargs) + self.messages = [] + + def emit(self, record): + self.messages.append(self.format(record)) + + def clear(self): + self.messages = [] diff --git a/tests/test_define_impact.py b/tests/test_define_impact.py new file mode 100644 index 0000000..a28d248 --- /dev/null +++ b/tests/test_define_impact.py @@ -0,0 +1,76 @@ +from pathlib import Path + +from gitlint.config import LintConfig +from gitlint.lint import GitLinter + +from .base import BaseTestCase + +MESSAGE_HEAD = """Create cool feature + +This is the coolest feature of them all. +""" + +MESSAGE_TEMPLATE = """{head} + +Impact: {impact} +""" + + +class TestImpact(BaseTestCase): + # Based on + + def _get_linter(self) -> GitLinter: + config = LintConfig() + config.extra_path = Path(__file__).parent.parent / 'rules' + return GitLinter(config) + + def _test_impact(self, impact: str): + linter = self._get_linter() + gitcontext = self.gitcontext( + MESSAGE_TEMPLATE.format(head=MESSAGE_HEAD, impact=impact) + ) + results = linter.lint(gitcontext.commits[-1]) + # FIXME: Should we ensure that our custom rule was loaded? + assert len(results) == 0 + + def test_impact_major(self): + """Test that valid impact has been set""" + self._test_impact('major') + + def test_impact_minor(self): + """Test that valid impact has been set""" + self._test_impact('major') + + def test_impact_patch(self): + """Test that valid impact has been set""" + self._test_impact('patch') + + def _test_invalid_impact(self, impact: str): + """Test that invalid impact is not valid""" + linter = self._get_linter() + gitcontext = self.gitcontext( + MESSAGE_TEMPLATE.format(head=MESSAGE_HEAD, impact=impact) + ) + results = linter.lint(gitcontext.commits[-1]) + assert len(results) > 0 + assert 'SERAVO0001' in [x.rule_id for x in results] + + def test_invalid_impact_mainor(self): + self._test_invalid_impact('mainor') + + def test_invalid_impact_empty(self): + self._test_invalid_impact('') + + def test_invalid_impact_aku(self): + self._test_invalid_impact('aku') + + def test_invalid_impact_patchx(self): + self._test_invalid_impact('patchx') + + def test_missing_impact(self): + """Test that missing impact is not valid""" + linter = self._get_linter() + gitcontext = self.gitcontext(MESSAGE_HEAD) + results = linter.lint(gitcontext.commits[-1]) + assert len(results) > 0 + assert 'SERAVO0001' in [x.rule_id for x in results]