diff --git a/.github/workflows/build.yml b/.github/workflows/build.yml index 9834f6e..333498b 100644 --- a/.github/workflows/build.yml +++ b/.github/workflows/build.yml @@ -8,7 +8,7 @@ jobs: runs-on: ubuntu-latest strategy: matrix: - python-version: ["3.12"] + python-version: ["3.11", "3.12", "3.13"] env: MAIN_PYTHON_VERSION: "3.12" # same as Ubuntu 24 LTS @@ -33,6 +33,9 @@ jobs: if: steps.cached-poetry-dependencies.outputs.cache-hit != 'true' run: | poetry install --no-interaction + - name: Run check_done script + run: | + poetry run check_done --version - name: Build check-done package run: | poetry build @@ -48,6 +51,7 @@ jobs: run: | poetry run pytest --cov=check_done --cov-branch --cov-report=xml - name: Upload coverage reports to Codecov + if: ${{ matrix.python-version == env.MAIN_PYTHON_VERSION }} uses: codecov/codecov-action@v4 with: token: ${{ secrets.CODECOV_TOKEN }} diff --git a/CHANGELOG.md b/CHANGELOG.md new file mode 100644 index 0000000..8b86ca8 --- /dev/null +++ b/CHANGELOG.md @@ -0,0 +1,5 @@ +# Check_done changes + +## Version 1.0.0, 2024-11-27 + +- Initial release diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md index ad15c68..2e76826 100644 --- a/CONTRIBUTING.md +++ b/CONTRIBUTING.md @@ -40,7 +40,7 @@ Repository permissions: - Issues: read-only - Pull requests: read-only -- Metadata: read-ony (mandatory and enabled automatically) +- Metadata: read-only (mandatory and enabled automatically) Organization permissions: @@ -56,14 +56,12 @@ Many coding guidelines are automatically enforced (and some even fixed automatic poetry run pre-commit run --all-files ``` -## Release cheatsheet +## Release cheat-sheet This section only relevant for developers with access to the PyPI project. To add a new release, first update the `pyproject.toml`: -.. code-block:: toml - ```toml [tool.poetry] version = "1.x.x" diff --git a/README.md b/README.md index 34ef2a4..6d496eb 100644 --- a/README.md +++ b/README.md @@ -1,6 +1,6 @@ -[![PyPI](https://img.shields.io/pypi/v/check_done)](https://pypi.org/project/pygount/) +[![PyPI](https://img.shields.io/pypi/v/check_done)](https://pypi.org/project/check_done/) [![Python Versions](https://img.shields.io/pypi/pyversions/check_done.svg)](https://www.python.org/downloads/) -[![Build Status](https://github.com/siisurit/check_done/actions/workflows/build.yml/badge.svg)](https://github.com/roskakori/pygount/actions/workflows/build.yml) +[![Build Status](https://github.com/siisurit/check_done/actions/workflows/build.yml/badge.svg)](https://github.com/siisurit/check_done/actions/workflows/build.yml) [![codecov](https://codecov.io/gh/siisurit/check_done/graph/badge.svg?token=UIJZUCUJII)](https://codecov.io/gh/siisurit/check_done) [![Ruff](https://img.shields.io/endpoint?url=https://raw.githubusercontent.com/astral-sh/ruff/main/assets/badge/v2.json)](https://github.com/astral-sh/ruff) [![License](https://img.shields.io/github/license/siisurit/check_done)](https://opensource.org/licenses/BSD-3-Clause) @@ -9,26 +9,24 @@ Check_done is a command line tool to check that GitHub issues and pull requests in a project board with a status of "Done" are really done. -For each issue or pull request in the "Done" column of the project board, it checks that: +It checks that: - It is closed. - It has an assignee. - It is assigned to a milestone. -- All tasks are completed (list with checkboxes in the description). +- All tasks are completed (checkboxes in the description). -For pull requests, it additionally checks if they reference an issue. +Additionally, for pull requests, it checks if they reference an issue. This ensures a consistent quality on done issues and pull requests, and helps to notice if they were accidentally deemed to be done too early. ## Installation -In order to gain access to your project board, issues, and pull requests, check_done needs to be authorized. The exact way to do that depends on whether your project belongs to a single user or a GitHub organization. +In order to gain access to your project board, issues, and pull requests, check_done needs to be authorized. The exact way to do that depends on whether your project belongs to a GitHub user or organization. For user projects, [create a personal access token](https://docs.github.com/en/authentication/keeping-your-account-and-data-secure/managing-your-personal-access-tokens) with the permission: `read:project`. -For organization projects, follow the instructions to [Installing a GitHub App from a third party](https://docs.github.com/en/apps/using-github-apps/installing-a-github-app-from-a-third-party) using the [Check_done app](https://github.com/apps/check-done-app). - -Remember the **app ID** and **private key** of the installed app. +For organization projects, follow the instructions to [Installing a GitHub App from a third party](https://docs.github.com/en/apps/using-github-apps/installing-a-github-app-from-a-third-party) using the [Check_done app](https://github.com/apps/check-done-app). Remember the **app ID** and **private key** of the installed app in order to use for configuration. ## Configuration @@ -57,8 +55,8 @@ For an organization project: ```yaml project_url: "https://github.com/orgs/my_username/projects/1/views/1" -check_done_github_app_id: "1234567" -check_done_github_app_private_key: "-----BEGIN RSA PRIVATE KEY----- +github_app_id: "1234567" +github_app_private_key: "-----BEGIN RSA PRIVATE KEY----- something_something -----END RSA PRIVATE KEY----- " @@ -70,16 +68,16 @@ In order to avoid having to commit tokens and keys into your repository, you can personal_access_token: ${MY_PERSONAL_ACCESS_TOKEN_ENVVAR} ``` -### Changing the board status column to check +### Changing the project status to check -By default, check_done checks all issues and pull requests in the column rightmost/last column of the project board. If you left the default names when creating the GitHub project board, this would be the `"✅ Done"` column. +By default, check_done checks all issues and pull requests in the last selectable project status. If you left the default names when creating the GitHub project board, this would be the `"✅ Done"` project status. -If you want to check a different column, you can specify its name with this option. For example: +If you want to check a different project status, you can specify a partial or exact matching name with this option. For example: ```yaml project_status_name_to_check = "Done" ``` -The name takes the first column that partially matches the case sensitivity. For example, `"Done"` will also match `"✅ Done"`, but not `"done"`. +The name takes the first project status that partially matches the case sensitivity. For example, `"Done"` will also match `"✅ Done"`, but not `"done"`. -If no column matches, the resulting error messages will tell you the exact names of all available columns. +If no project status matches, the resulting error messages will show you the exact name of the available project status selections. diff --git a/check_done/command.py b/check_done/command.py index 4755578..d0b94e1 100644 --- a/check_done/command.py +++ b/check_done/command.py @@ -17,7 +17,9 @@ logger = logging.getLogger(__name__) -_HELP_DESCRIPTION = "Checks that finished issues and pull requests in a GitHub project board column are really done." +_HELP_DESCRIPTION = ( + 'Check that GitHub issues and pull requests in a project board with a status of "Done" are really done.' +) def check_done_command(arguments=None) -> int: @@ -69,10 +71,10 @@ def _argument_parser(): return parser -def main(): +def main(): # pragma: no cover logging.basicConfig(level=logging.INFO) sys.exit(check_done_command()) -if __name__ == "__main__": +if __name__ == "__main__": # pragma: no cover main() diff --git a/check_done/config.py b/check_done/config.py index 261291b..4218e54 100644 --- a/check_done/config.py +++ b/check_done/config.py @@ -36,8 +36,8 @@ class ConfigurationInfo: personal_access_token: str | None = None # Required for organization owned project - check_done_github_app_id: str | None = None - check_done_github_app_private_key: str | None = None + github_app_id: str | None = None + github_app_private_key: str | None = None # Optional project_status_name_to_check: str | None = None @@ -51,8 +51,8 @@ class ConfigurationInfo: "project_url", "project_status_name_to_check", "personal_access_token", - "check_done_github_app_id", - "check_done_github_app_private_key", + "github_app_id", + "github_app_private_key", mode="before", ) def value_from_env(cls, value: Any | None): @@ -76,8 +76,8 @@ def validate_authentication_and_set_project_details(self): self.personal_access_token is not None and not self.is_project_owner_of_type_organization ) has_organizational_authentication = ( - self.check_done_github_app_id is not None - and self.check_done_github_app_private_key is not None + self.github_app_id is not None + and self.github_app_private_key is not None and self.is_project_owner_of_type_organization ) if not has_user_authentication ^ has_organizational_authentication: @@ -138,7 +138,7 @@ def default_config_path() -> Path: previous_config_folder = None result = None while result is None and config_folder != previous_config_folder: - # TODO#32 Check yaml and yml. + # TODO#32 Check yaml and yml, if both exist yaml is picked over yml. config_path_to_check = (config_folder / CONFIG_BASE_NAME).with_suffix(".yaml") if config_path_to_check.is_file(): result = config_path_to_check diff --git a/check_done/done_project_items_info.py b/check_done/done_project_items_info.py index 24fde58..2c03a64 100644 --- a/check_done/done_project_items_info.py +++ b/check_done/done_project_items_info.py @@ -25,11 +25,9 @@ def done_project_items_info(configuration_info: ConfigurationInfo) -> list[Proje is_project_owner_of_type_organization = configuration_info.is_project_owner_of_type_organization if is_project_owner_of_type_organization: - check_done_github_app_id = configuration_info.check_done_github_app_id - check_done_github_app_private_key = configuration_info.check_done_github_app_private_key - access_token = resolve_organization_access_token( - project_owner_name, check_done_github_app_id, check_done_github_app_private_key - ) + github_app_id = configuration_info.github_app_id + github_app_private_key = configuration_info.github_app_private_key + access_token = resolve_organization_access_token(project_owner_name, github_app_id, github_app_private_key) else: access_token = configuration_info.personal_access_token @@ -55,7 +53,7 @@ def done_project_items_info(configuration_info: ConfigurationInfo) -> list[Proje ) project_status_name_to_check = configuration_info.project_status_name_to_check - project_status_option_id = matching_project_state_option_id( + project_status_option_id = matching_project_status_option_id( project_single_select_field_infos, project_status_name_to_check, project_number, @@ -74,7 +72,7 @@ def matching_project_id(project_infos: list[ProjectV2Node], project_number: int, ) from None -def matching_project_state_option_id( +def matching_project_status_option_id( project_single_select_field_infos: list[ProjectV2SingleSelectFieldNode], project_status_name_to_check: str | None, project_number: int, @@ -117,14 +115,14 @@ def matching_project_state_option_id( def filtered_project_item_infos_by_done_status( project_item_infos: list[ProjectV2ItemNode], - project_state_option_id: str, + project_status_option_id: str, ) -> list[ProjectItemInfo]: result = [] for project_item_info in project_item_infos: - has_project_state_option = ( + has_project_status_option = ( project_item_info.field_value_by_name is not None - and project_item_info.field_value_by_name.option_id == project_state_option_id + and project_item_info.field_value_by_name.option_id == project_status_option_id ) - if has_project_state_option: + if has_project_status_option: result.append(project_item_info.content) return result diff --git a/check_done/organization_authentication.py b/check_done/organization_authentication.py index 81d0095..64acf67 100644 --- a/check_done/organization_authentication.py +++ b/check_done/organization_authentication.py @@ -17,23 +17,17 @@ class AuthenticationError(Exception): """Error raised due to failed JWT authentication process.""" -def resolve_organization_access_token( - organization_name: str, check_done_github_app_id: str, check_done_github_app_private_key: str -) -> str: +def resolve_organization_access_token(organization_name: str, github_app_id: str, github_app_private_key: str) -> str: """ Generates the necessary access token for an organization from the installed GitHub app instance in said organization """ - jwt_token = generate_jwt_token(check_done_github_app_id, check_done_github_app_private_key) + jwt_token = generate_jwt_token(github_app_id, github_app_private_key) session = requests.Session() session.headers = {"Accept": "application/vnd.github+json"} session.auth = HttpBearerAuth(jwt_token) try: - check_done_github_app_installation_id = resolve_check_done_github_app_installation_id( - session, organization_name - ) - result = resolve_access_token_from_check_done_github_app_installation_id( - session, check_done_github_app_installation_id - ) + github_app_installation_id = resolve_github_app_installation_id(session, organization_name) + result = resolve_access_token_from_github_app_installation_id(session, github_app_installation_id) except Exception as error: raise AuthenticationError( f"Cannot resolve organization access token from JWT authentication process: {error}" @@ -41,20 +35,20 @@ def resolve_organization_access_token( return result -def generate_jwt_token(check_done_github_app_id: str, check_done_github_app_private_key: str) -> str: +def generate_jwt_token(github_app_id: str, github_app_private_key: str) -> str: """Generates a JWT token for authentication with GitHub.""" try: payload = { "exp": _EXPIRES_AT, "iat": _ISSUED_AT, - "iss": check_done_github_app_id, + "iss": github_app_id, } - return jwt.encode(payload, check_done_github_app_private_key, algorithm="RS256") + return jwt.encode(payload, github_app_private_key, algorithm="RS256") except Exception as error: raise AuthenticationError(f"Cannot generate JWT token: {error}") from error -def resolve_check_done_github_app_installation_id(session: Session, organization_name: str) -> str: +def resolve_github_app_installation_id(session: Session, organization_name: str) -> str: """Fetches the installation ID for the organization.""" response = session.get(f"https://api.github.com/orgs/{organization_name}/installation") @@ -65,7 +59,7 @@ def resolve_check_done_github_app_installation_id(session: Session, organization ) -def resolve_access_token_from_check_done_github_app_installation_id(session: Session, installation_id: str) -> str: +def resolve_access_token_from_github_app_installation_id(session: Session, installation_id: str) -> str: """Retrieves the access token using the installation ID.""" response = session.post(f"https://api.github.com/app/installations/{installation_id}/access_tokens") if response.status_code == 201 and response.json().get("token") is not None: diff --git a/poetry.lock b/poetry.lock index 9f86a34..68966d6 100644 --- a/poetry.lock +++ b/poetry.lock @@ -777,13 +777,13 @@ testing = ["pytest", "pytest-benchmark"] [[package]] name = "pre-commit" -version = "3.8.0" +version = "4.0.1" description = "A framework for managing and maintaining multi-language pre-commit hooks." optional = false python-versions = ">=3.9" files = [ - {file = "pre_commit-3.8.0-py2.py3-none-any.whl", hash = "sha256:9a90a53bf82fdd8778d58085faf8d83df56e40dfe18f45b19446e26bf1b3a63f"}, - {file = "pre_commit-3.8.0.tar.gz", hash = "sha256:8bb6494d4a20423842e198980c9ecf9f96607a07ea29549e180eef9ae80fe7af"}, + {file = "pre_commit-4.0.1-py2.py3-none-any.whl", hash = "sha256:efde913840816312445dc98787724647c65473daefe420785f885e8ed9a06878"}, + {file = "pre_commit-4.0.1.tar.gz", hash = "sha256:80905ac375958c0444c65e9cebebd948b3cdb518f335a091a670a89d652139d2"}, ] [package.dependencies] @@ -991,17 +991,17 @@ dev = ["argcomplete", "attrs (>=19.2)", "hypothesis (>=3.56)", "mock", "pygments [[package]] name = "pytest-cov" -version = "5.0.0" +version = "6.0.0" description = "Pytest plugin for measuring coverage." optional = false -python-versions = ">=3.8" +python-versions = ">=3.9" files = [ - {file = "pytest-cov-5.0.0.tar.gz", hash = "sha256:5837b58e9f6ebd335b0f8060eecce69b662415b16dc503883a02f45dfeb14857"}, - {file = "pytest_cov-5.0.0-py3-none-any.whl", hash = "sha256:4f0764a1219df53214206bf1feea4633c3b558a2925c8b59f144f682861ce652"}, + {file = "pytest-cov-6.0.0.tar.gz", hash = "sha256:fde0b595ca248bb8e2d76f020b465f3b107c9632e6a1d1705f17834c89dcadc0"}, + {file = "pytest_cov-6.0.0-py3-none-any.whl", hash = "sha256:eee6f1b9e61008bd34975a4d5bab25801eb31898b032dd55addc93e96fcaaa35"}, ] [package.dependencies] -coverage = {version = ">=5.2.1", extras = ["toml"]} +coverage = {version = ">=7.5", extras = ["toml"]} pytest = ">=4.6" [package.extras] @@ -1231,29 +1231,29 @@ jupyter = ["ipywidgets (>=7.5.1,<9)"] [[package]] name = "ruff" -version = "0.5.7" +version = "0.8.0" description = "An extremely fast Python linter and code formatter, written in Rust." optional = false python-versions = ">=3.7" files = [ - {file = "ruff-0.5.7-py3-none-linux_armv6l.whl", hash = "sha256:548992d342fc404ee2e15a242cdbea4f8e39a52f2e7752d0e4cbe88d2d2f416a"}, - {file = "ruff-0.5.7-py3-none-macosx_10_12_x86_64.whl", hash = "sha256:00cc8872331055ee017c4f1071a8a31ca0809ccc0657da1d154a1d2abac5c0be"}, - {file = "ruff-0.5.7-py3-none-macosx_11_0_arm64.whl", hash = "sha256:eaf3d86a1fdac1aec8a3417a63587d93f906c678bb9ed0b796da7b59c1114a1e"}, - {file = "ruff-0.5.7-py3-none-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:a01c34400097b06cf8a6e61b35d6d456d5bd1ae6961542de18ec81eaf33b4cb8"}, - {file = "ruff-0.5.7-py3-none-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:fcc8054f1a717e2213500edaddcf1dbb0abad40d98e1bd9d0ad364f75c763eea"}, - {file = "ruff-0.5.7-py3-none-manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:7f70284e73f36558ef51602254451e50dd6cc479f8b6f8413a95fcb5db4a55fc"}, - {file = "ruff-0.5.7-py3-none-manylinux_2_17_ppc64.manylinux2014_ppc64.whl", hash = "sha256:a78ad870ae3c460394fc95437d43deb5c04b5c29297815a2a1de028903f19692"}, - {file = "ruff-0.5.7-py3-none-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:9ccd078c66a8e419475174bfe60a69adb36ce04f8d4e91b006f1329d5cd44bcf"}, - {file = "ruff-0.5.7-py3-none-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:7e31c9bad4ebf8fdb77b59cae75814440731060a09a0e0077d559a556453acbb"}, - {file = "ruff-0.5.7-py3-none-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:8d796327eed8e168164346b769dd9a27a70e0298d667b4ecee6877ce8095ec8e"}, - {file = "ruff-0.5.7-py3-none-musllinux_1_2_aarch64.whl", hash = "sha256:4a09ea2c3f7778cc635e7f6edf57d566a8ee8f485f3c4454db7771efb692c499"}, - {file = "ruff-0.5.7-py3-none-musllinux_1_2_armv7l.whl", hash = "sha256:a36d8dcf55b3a3bc353270d544fb170d75d2dff41eba5df57b4e0b67a95bb64e"}, - {file = "ruff-0.5.7-py3-none-musllinux_1_2_i686.whl", hash = "sha256:9369c218f789eefbd1b8d82a8cf25017b523ac47d96b2f531eba73770971c9e5"}, - {file = "ruff-0.5.7-py3-none-musllinux_1_2_x86_64.whl", hash = "sha256:b88ca3db7eb377eb24fb7c82840546fb7acef75af4a74bd36e9ceb37a890257e"}, - {file = "ruff-0.5.7-py3-none-win32.whl", hash = "sha256:33d61fc0e902198a3e55719f4be6b375b28f860b09c281e4bdbf783c0566576a"}, - {file = "ruff-0.5.7-py3-none-win_amd64.whl", hash = "sha256:083bbcbe6fadb93cd86709037acc510f86eed5a314203079df174c40bbbca6b3"}, - {file = "ruff-0.5.7-py3-none-win_arm64.whl", hash = "sha256:2dca26154ff9571995107221d0aeaad0e75a77b5a682d6236cf89a58c70b76f4"}, - {file = "ruff-0.5.7.tar.gz", hash = "sha256:8dfc0a458797f5d9fb622dd0efc52d796f23f0a1493a9527f4e49a550ae9a7e5"}, + {file = "ruff-0.8.0-py3-none-linux_armv6l.whl", hash = "sha256:fcb1bf2cc6706adae9d79c8d86478677e3bbd4ced796ccad106fd4776d395fea"}, + {file = "ruff-0.8.0-py3-none-macosx_10_12_x86_64.whl", hash = "sha256:295bb4c02d58ff2ef4378a1870c20af30723013f441c9d1637a008baaf928c8b"}, + {file = "ruff-0.8.0-py3-none-macosx_11_0_arm64.whl", hash = "sha256:7b1f1c76b47c18fa92ee78b60d2d20d7e866c55ee603e7d19c1e991fad933a9a"}, + {file = "ruff-0.8.0-py3-none-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:eb0d4f250a7711b67ad513fde67e8870109e5ce590a801c3722580fe98c33a99"}, + {file = "ruff-0.8.0-py3-none-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:0e55cce9aa93c5d0d4e3937e47b169035c7e91c8655b0974e61bb79cf398d49c"}, + {file = "ruff-0.8.0-py3-none-manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:3f4cd64916d8e732ce6b87f3f5296a8942d285bbbc161acee7fe561134af64f9"}, + {file = "ruff-0.8.0-py3-none-manylinux_2_17_ppc64.manylinux2014_ppc64.whl", hash = "sha256:c5c1466be2a2ebdf7c5450dd5d980cc87c8ba6976fb82582fea18823da6fa362"}, + {file = "ruff-0.8.0-py3-none-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:2dabfd05b96b7b8f2da00d53c514eea842bff83e41e1cceb08ae1966254a51df"}, + {file = "ruff-0.8.0-py3-none-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:facebdfe5a5af6b1588a1d26d170635ead6892d0e314477e80256ef4a8470cf3"}, + {file = "ruff-0.8.0-py3-none-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:87a8e86bae0dbd749c815211ca11e3a7bd559b9710746c559ed63106d382bd9c"}, + {file = "ruff-0.8.0-py3-none-musllinux_1_2_aarch64.whl", hash = "sha256:85e654f0ded7befe2d61eeaf3d3b1e4ef3894469cd664ffa85006c7720f1e4a2"}, + {file = "ruff-0.8.0-py3-none-musllinux_1_2_armv7l.whl", hash = "sha256:83a55679c4cb449fa527b8497cadf54f076603cc36779b2170b24f704171ce70"}, + {file = "ruff-0.8.0-py3-none-musllinux_1_2_i686.whl", hash = "sha256:812e2052121634cf13cd6fddf0c1871d0ead1aad40a1a258753c04c18bb71bbd"}, + {file = "ruff-0.8.0-py3-none-musllinux_1_2_x86_64.whl", hash = "sha256:780d5d8523c04202184405e60c98d7595bdb498c3c6abba3b6d4cdf2ca2af426"}, + {file = "ruff-0.8.0-py3-none-win32.whl", hash = "sha256:5fdb6efecc3eb60bba5819679466471fd7d13c53487df7248d6e27146e985468"}, + {file = "ruff-0.8.0-py3-none-win_amd64.whl", hash = "sha256:582891c57b96228d146725975fbb942e1f30a0c4ba19722e692ca3eb25cc9b4f"}, + {file = "ruff-0.8.0-py3-none-win_arm64.whl", hash = "sha256:ba93e6294e9a737cd726b74b09a6972e36bb511f9a102f1d9a7e1ce94dd206a6"}, + {file = "ruff-0.8.0.tar.gz", hash = "sha256:a7ccfe6331bf8c8dad715753e157457faf7351c2b69f62f32c165c2dbcbacd44"}, ] [[package]] @@ -1385,4 +1385,4 @@ type = ["pytest-mypy"] [metadata] lock-version = "2.0" python-versions = ">=3.10, <3.14" -content-hash = "568eb39232882ca3556d028f788e12ae7faa40f219d0ce1aef3f9f9a3eb382b5" +content-hash = "69e76556184e82d557dfabb5236dce01fd81f35b0ac3b8a7b1c52f7bbd5e11dd" diff --git a/pyproject.toml b/pyproject.toml index 9a14773..9ffc048 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -17,7 +17,7 @@ license = "MIT" homepage = "https://github.com/siisurit/check_done" repository = "https://github.com/siisurit/check_done.git" documentation = "https://github.com/siisurit/check_done/blob/main/README.md" -keywords = ["check", "done", "done done", "issue", "task"] +keywords = ["check", "closed", "done", "done done", "finished", "issue", "task", "validate"] classifiers = [ "Development Status :: 4 - Beta", "Environment :: Console", @@ -26,7 +26,6 @@ classifiers = [ "Natural Language :: English", "Operating System :: OS Independent", "Programming Language :: Python :: 3 :: Only", - "Programming Language :: Python :: 3.10", "Programming Language :: Python :: 3.11", "Programming Language :: Python :: 3.12", "Programming Language :: Python :: 3.13", @@ -46,7 +45,7 @@ gitpython = "^3" pyyaml = "^6.0.2" python-dotenv = "^1.0.1" pytest-mock = "^3.14.0" -pydantic = "^2.9.2" +pydantic = "^2.10.2" pyjwt = "^2.9.0" requests-mock = "^1.12.1" pytest-order = "^1.3.0" @@ -54,15 +53,16 @@ pytest-order = "^1.3.0" [tool.poetry.dev-dependencies] coverage = "^7" pytest = "^8" -pytest-cov = "^5" -pre-commit = "^3" -ruff = "^0.5.1" +pytest-cov = "^6" +pre-commit = "^4" +ruff = "^0.8" twine = "^5" [tool.poetry.scripts] check_done = "check_done.command:main" [tool.poetry.urls] +"Changelog" = "https://github.com/siisurit/check_done/blob/main/CHANGELOG.md" "Issue Tracker" = "https://github.com/siisurit/check_done/issues" [tool.ruff] @@ -80,7 +80,7 @@ exclude = [ "htmlcov", ] line-length = 120 -target-version = "py312" +target-version = "py311" [tool.ruff.lint] ignore = [ diff --git a/.check_done.yaml b/tests/data/test_configuration.yaml similarity index 59% rename from .check_done.yaml rename to tests/data/test_configuration.yaml index a312cf7..3ec17e6 100644 --- a/.check_done.yaml +++ b/tests/data/test_configuration.yaml @@ -1,5 +1,5 @@ project_url: ${CHECK_DONE_GITHUB_PROJECT_URL} project_status_name_to_check: ${CHECK_DONE_GITHUB_PROJECT_STATUS_NAME_TO_CHECK} personal_access_token: ${CHECK_DONE_PERSONAL_ACCESS_TOKEN} -check_done_github_app_id: ${CHECK_DONE_GITHUB_APP_ID} -check_done_github_app_private_key: ${CHECK_DONE_GITHUB_APP_PRIVATE_KEY} +github_app_id: ${CHECK_DONE_GITHUB_APP_ID} +github_app_private_key: ${CHECK_DONE_GITHUB_APP_PRIVATE_KEY} diff --git a/tests/test_command.py b/tests/test_command.py index a2cdaff..f1039a3 100644 --- a/tests/test_command.py +++ b/tests/test_command.py @@ -3,7 +3,6 @@ import logging import os import re -import subprocess import tempfile from pathlib import Path from unittest.mock import patch @@ -17,6 +16,8 @@ change_current_folder, ) +_PATH_TO_TEST_CONFIG = Path(__file__).parent / "data" / "test_configuration.yaml" + def test_can_show_help(): with pytest.raises(SystemExit) as error_info: @@ -40,8 +41,8 @@ def test_can_set_config_argument(): config_path = (current_folder / CONFIG_BASE_NAME).with_suffix(".yaml") config_path.write_text( "project_url: ${CHECK_DONE_GITHUB_PROJECT_URL}\n" - "check_done_github_app_id: ${CHECK_DONE_GITHUB_APP_ID}\n" - "check_done_github_app_private_key: ${CHECK_DONE_GITHUB_APP_PRIVATE_KEY}\n" + "github_app_id: ${CHECK_DONE_GITHUB_APP_ID}\n" + "github_app_private_key: ${CHECK_DONE_GITHUB_APP_PRIVATE_KEY}\n" ) exit_code = check_done_command(["--config", str(config_path)]) assert exit_code == 0 @@ -52,7 +53,7 @@ def test_can_set_config_argument(): reason=REASON_SHOULD_HAVE_DEMO_CHECK_DONE_ORGANIZATION_PROJECT_CONFIGURED, ) def test_can_execute_check_done_command_and_get_warnings(caplog): - exit_code = check_done_command([]) + exit_code = check_done_command(["--config", str(_PATH_TO_TEST_CONFIG)]) assert exit_code == 0 check_done_warning_messages = len(caplog.messages) assert check_done_warning_messages >= 1 @@ -63,7 +64,7 @@ def test_can_execute_check_done_command_and_get_warnings(caplog): reason=REASON_SHOULD_HAVE_DEMO_CHECK_DONE_ORGANIZATION_PROJECT_CONFIGURED, ) def test_can_check_done_demo_project(caplog): - exit_code = check_done_command([]) + exit_code = check_done_command(["--config", str(_PATH_TO_TEST_CONFIG)]) assert exit_code == 0 check_done_warning_messages = len(caplog.messages) @@ -73,9 +74,13 @@ def test_can_check_done_demo_project(caplog): warning_about_missing_assignee = caplog.messages[0] assert re.search(expected_warning_about_missing_assignee, warning_about_missing_assignee) - expected_warning_about_open_issue_with_dene_state = r"(?=.*be closed)(?=.*#4 Warning: Open issue with Done state)" - warning_about_open_issue_with_dene_state = caplog.messages[1] - assert re.search(expected_warning_about_open_issue_with_dene_state, warning_about_open_issue_with_dene_state) + expected_warning_about_open_issue_with_done_project_status = ( + r"(?=.*be closed)(?=.*#4 Warning: Open issue with Done project status)" + ) + warning_about_open_issue_with_done_project_status = caplog.messages[1] + assert re.search( + expected_warning_about_open_issue_with_done_project_status, warning_about_open_issue_with_done_project_status + ) expected_warning_about_missing_milestone = ( r"(?=.*have a milestone)(?=.*#11 Warning: Project item without milestone)" @@ -98,18 +103,18 @@ def test_can_check_done_demo_project(caplog): warning_about_pull_request_with_missing_closing_issue_reference, ) - expected_warning_about_open_pull_request_with_done_state = ( - r"(?=.*be closed)(?=.*#7 Warning: Open pull request with Done state)" + expected_warning_about_open_pull_request_with_done_project_status = ( + r"(?=.*be closed)(?=.*#7 Warning: Open pull request with Done project status)" ) - warning_about_open_pull_request_with_done_state = caplog.messages[5] + warning_about_open_pull_request_with_done_project_status = caplog.messages[5] assert re.search( - expected_warning_about_open_pull_request_with_done_state, - warning_about_open_pull_request_with_done_state, + expected_warning_about_open_pull_request_with_done_project_status, + warning_about_open_pull_request_with_done_project_status, ) ok_project_item_titles = [ "#2 Ok: Closed issue without PR", - "#10 Ok: Properly closed issue with Done state", + "#10 Ok: Properly closed issue with Done status", "#12 Ok: Project item with finished goals", "#5 Ok: Pull Request has linked issue", ] @@ -120,17 +125,6 @@ def test_can_check_done_demo_project(caplog): ) -@pytest.mark.skipif( - not HAS_DEMO_CHECK_DONE_ORGANIZATION_PROJECT_CONFIGURED, - reason=REASON_SHOULD_HAVE_DEMO_CHECK_DONE_ORGANIZATION_PROJECT_CONFIGURED, -) -def test_main_script(): - path = Path(__file__).resolve().parent.parent / "check_done" / "command.py" - command = f"poetry run python {path!s}" - result = subprocess.run(command, capture_output=True, text=True, shell=True, executable="/bin/bash", check=False) - assert "Checking project items" in result.stderr - - def test_can_handle_keyboard_interrupt(): with patch("check_done.command.execute", side_effect=KeyboardInterrupt): exit_code = check_done_command([]) @@ -153,7 +147,7 @@ def test_can_log_info_message_if_selected_project_status_is_empty(caplog): original_envar_value = os.environ[envvar_name] empty_project_status_name_in_check_done_demo_project = "In progress" os.environ[envvar_name] = empty_project_status_name_in_check_done_demo_project - check_done_command([]) + check_done_command(["--config", str(_PATH_TO_TEST_CONFIG)]) os.environ[envvar_name] = original_envar_value assert "Nothing to check. Project has no items in the selected project status." in caplog.messages[1] @@ -168,6 +162,6 @@ def test_can_log_info_message_if_no_warnings_were_found_for_checked_project_item original_envar_value = os.environ[envvar_name] project_status_name_in_check_done_demo_project_with_only_correct_issues = "Archived" os.environ[envvar_name] = project_status_name_in_check_done_demo_project_with_only_correct_issues - check_done_command([]) + check_done_command(["--config", str(_PATH_TO_TEST_CONFIG)]) os.environ[envvar_name] = original_envar_value assert "All project items are correct" in caplog.messages[1] diff --git a/tests/test_config.py b/tests/test_config.py index aba9c4e..5791aff 100644 --- a/tests/test_config.py +++ b/tests/test_config.py @@ -111,12 +111,12 @@ def test_can_validate_at_least_one_type_of_authentication_is_properly_configured fake_organization_project_url = "https://github.com/orgs/fake-organization/projects/1" fake_configuration_info_with_organization_project = ConfigurationInfo( project_url="https://github.com/orgs/fake-organization/projects/1", - check_done_github_app_id="fake_app_id", - check_done_github_app_private_key="fake_private_key", + github_app_id="fake_app_id", + github_app_private_key="fake_private_key", ) assert fake_configuration_info_with_organization_project.project_url == fake_organization_project_url - assert fake_configuration_info_with_organization_project.check_done_github_app_id == "fake_app_id" - assert fake_configuration_info_with_organization_project.check_done_github_app_private_key == "fake_private_key" + assert fake_configuration_info_with_organization_project.github_app_id == "fake_app_id" + assert fake_configuration_info_with_organization_project.github_app_private_key == "fake_private_key" def test_fails_on_no_authentication_method_configured(): @@ -130,7 +130,7 @@ def test_fails_on_organization_authentication_method_missing_private_key(): with pytest.raises(ValueError, match="A user or an organization authentication method must be configured."): ConfigurationInfo( project_url="https://github.com/orgs/fake-organization/projects/1", - check_done_github_app_id="fake_check_done_github_app_id", + github_app_id="fake_github_app_id", ) @@ -138,7 +138,7 @@ def test_fails_on_organization_authentication_method_missing_app_id(): with pytest.raises(ValueError, match="A user or an organization authentication method must be configured."): ConfigurationInfo( project_url="https://github.com/orgs/fake-organization/projects/1", - check_done_github_app_private_key="fake_check_done_github_app_private_key", + github_app_private_key="fake_github_app_private_key", ) @@ -166,8 +166,8 @@ def test_can_resolve_project_details_from_user_project_url(): def test_can_resolve_project_details_from_organization_project_url(): configuration_info = ConfigurationInfo( project_url="https://github.com/orgs/fake-organization-name/projects/1/views/2", - check_done_github_app_id="fake_check_done_github_app_id", - check_done_github_app_private_key="fake_check_done_github_app_private_key", + github_app_id="fake_github_app_id", + github_app_private_key="fake_github_app_private_key", ) assert configuration_info.is_project_owner_of_type_organization assert configuration_info.project_number == 1 diff --git a/tests/test_done_project_items_info.py b/tests/test_done_project_items_info.py index ada40b4..33b2377 100644 --- a/tests/test_done_project_items_info.py +++ b/tests/test_done_project_items_info.py @@ -9,7 +9,7 @@ done_project_items_info, filtered_project_item_infos_by_done_status, matching_project_id, - matching_project_state_option_id, + matching_project_status_option_id, ) from check_done.info import ( ProjectV2Node, @@ -48,8 +48,8 @@ def test_can_resolve_done_project_items_info(): fake_configuration_info = ConfigurationInfo( project_url=DEMO_CHECK_DONE_GITHUB_PROJECT_URL, - check_done_github_app_id=DEMO_CHECK_DONE_GITHUB_APP_ID, - check_done_github_app_private_key=DEMO_CHECK_DONE_GITHUB_APP_PRIVATE_KEY, + github_app_id=DEMO_CHECK_DONE_GITHUB_APP_ID, + github_app_private_key=DEMO_CHECK_DONE_GITHUB_APP_PRIVATE_KEY, ) assert len(done_project_items_info(fake_configuration_info)) >= 1 @@ -91,10 +91,10 @@ def test_fails_to_find_matching_project_id(): matching_project_id(fake_project_id_node_infos, project_number, "dummy_project_owner_name") -def test_can_find_matching_project_state_option_id(): +def test_can_find_matching_project_status_option_id(): expected_to_math_project_status_option_id = "2b" - fake_matching_project_state_option_name = "Finished" - fake_last_project_state_option_id_node_infos = [ + fake_matching_project_status_option_name = "Finished" + fake_last_project_status_option_id_node_infos = [ ProjectV2SingleSelectFieldNode( id="a1", name="Status", @@ -102,14 +102,14 @@ def test_can_find_matching_project_state_option_id(): options=[ ProjectV2Options(id="1a", name="In Progress"), ProjectV2Options( - id=expected_to_math_project_status_option_id, name=fake_matching_project_state_option_name + id=expected_to_math_project_status_option_id, name=fake_matching_project_status_option_name ), ], ) ] - matched_project_status_option_id = matching_project_state_option_id( - fake_last_project_state_option_id_node_infos, - fake_matching_project_state_option_name, + matched_project_status_option_id = matching_project_status_option_id( + fake_last_project_status_option_id_node_infos, + fake_matching_project_status_option_name, 1, "dummy_project_owner_name", ) @@ -120,9 +120,9 @@ def test_can_find_matching_project_state_option_id(): not _HAS_PROJECT_STATUS_NAME_TO_CHECK, reason=_REASON_SHOULD_HAVE_SET_ENV_PROJECT_STATUS_NAME_TO_CHECK, ) -def test_fails_to_find_matching_project_state_option_id(): - wrongly_assumed_matching_project_state_option_name = "Backlog" - fake_last_project_state_option_id_node_infos = [ +def test_fails_to_find_matching_project_status_option_id(): + wrongly_assumed_matching_project_status_option_name = "Backlog" + fake_last_project_status_option_id_node_infos = [ ProjectV2SingleSelectFieldNode( id="a1", name="Status", @@ -135,45 +135,45 @@ def test_fails_to_find_matching_project_state_option_id(): ] with pytest.raises( ValueError, - match=f"Cannot find the project status matching name '{wrongly_assumed_matching_project_state_option_name}' ", + match=f"Cannot find the project status matching name '{wrongly_assumed_matching_project_status_option_name}' ", ): - matching_project_state_option_id( - fake_last_project_state_option_id_node_infos, - wrongly_assumed_matching_project_state_option_name, + matching_project_status_option_id( + fake_last_project_status_option_id_node_infos, + wrongly_assumed_matching_project_status_option_name, 1, "dummy_project_owner_name", ) -def test_can_find_matching_last_project_state_option_id(): - expected_to_match_project_state_option_id = "2b" - fake_last_project_state_option_id_node_infos = [ +def test_can_find_matching_last_project_status_option_id(): + expected_to_match_project_status_option_id = "2b" + fake_last_project_status_option_id_node_infos = [ ProjectV2SingleSelectFieldNode( id="a1", name="Status", __typename="ProjectV2SingleSelectField", options=[ ProjectV2Options(id="1a", name="In Progress"), - ProjectV2Options(id=expected_to_match_project_state_option_id, name="Finished"), + ProjectV2Options(id=expected_to_match_project_status_option_id, name="Finished"), ], ) ] - matching_last_project_state_option_id = matching_project_state_option_id( - fake_last_project_state_option_id_node_infos, + matching_last_project_status_option_id = matching_project_status_option_id( + fake_last_project_status_option_id_node_infos, None, 1, "dummy_project_owner_name", ) - assert matching_last_project_state_option_id == expected_to_match_project_state_option_id + assert matching_last_project_status_option_id == expected_to_match_project_status_option_id -def test_fails_to_find_matching_last_project_state_option_id(): - fake_last_project_state_option_id_node_infos = [ +def test_fails_to_find_matching_last_project_status_option_id(): + fake_last_project_status_option_id_node_infos = [ ProjectV2SingleSelectFieldNode(id="a1", name="Milestone", __typename="ProjectV2SingleSelectField", options=[]) ] with pytest.raises(ValueError, match="Cannot find a project status selection field "): - matching_project_state_option_id( - fake_last_project_state_option_id_node_infos, + matching_project_status_option_id( + fake_last_project_status_option_id_node_infos, None, 1, "dummy_project_owner_name", diff --git a/tests/test_organization_authentication.py b/tests/test_organization_authentication.py index 226d0bc..aeaaf9b 100644 --- a/tests/test_organization_authentication.py +++ b/tests/test_organization_authentication.py @@ -10,7 +10,7 @@ from check_done.organization_authentication import ( AuthenticationError, generate_jwt_token, - resolve_check_done_github_app_installation_id, + resolve_github_app_installation_id, resolve_organization_access_token, ) from tests._common import ( @@ -73,9 +73,9 @@ def test_can_generate_jwt_token(): not HAS_DEMO_CHECK_DONE_ORGANIZATION_PROJECT_CONFIGURED, reason=REASON_SHOULD_HAVE_DEMO_CHECK_DONE_ORGANIZATION_PROJECT_CONFIGURED, ) -def test_can_resolve_check_done_github_app_installation_id(): +def test_can_resolve_github_app_installation_id(): session = _session() - fake_installation_id = resolve_check_done_github_app_installation_id(session, DEMO_CHECK_DONE_PROJECT_OWNER_NAME) + fake_installation_id = resolve_github_app_installation_id(session, DEMO_CHECK_DONE_PROJECT_OWNER_NAME) assert isinstance(fake_installation_id, int) @@ -83,20 +83,20 @@ def test_can_resolve_check_done_github_app_installation_id(): not HAS_DEMO_CHECK_DONE_ORGANIZATION_PROJECT_CONFIGURED, reason=REASON_SHOULD_HAVE_DEMO_CHECK_DONE_ORGANIZATION_PROJECT_CONFIGURED, ) -def test_fails_to_resolve_check_done_github_app_installation_id(): +def test_fails_to_resolve_github_app_installation_id(): session = _session() with pytest.raises(AuthenticationError, match="Could not retrieve installation ID: status=404 "): - resolve_check_done_github_app_installation_id(session, _DUMMY_ORGANIZATION_NAME) + resolve_github_app_installation_id(session, _DUMMY_ORGANIZATION_NAME) -def test_fails_to_resolve_check_done_github_app_installation_id_from_bad_request(): +def test_fails_to_resolve_github_app_installation_id_from_bad_request(): with requests_mock.Mocker() as mock: mock.get(f"https://api.github.com/orgs/{_DUMMY_ORGANIZATION_NAME}/installation", status_code=400) with pytest.raises(AuthenticationError, match="Could not retrieve installation ID: status=400 "): resolve_organization_access_token(_DUMMY_ORGANIZATION_NAME, _DUMMY_GITHUB_APP_ID, _FAKE_PEM_PRIVATE_KEY) -def test_fails_to_resolve_access_token_from_check_done_github_app_installation_id(): +def test_fails_to_resolve_access_token_from_github_app_installation_id(): with requests_mock.Mocker() as mock: mock.get( f"https://api.github.com/orgs/{_DUMMY_ORGANIZATION_NAME}/installation",