From 213cf06a217ce48bedb4739a7e29dad4b2945066 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Mek=20Sk=C3=ABndo?= Date: Fri, 1 Nov 2024 19:09:30 +0100 Subject: [PATCH 1/2] #17 Check boards of individual users --- check_done/authentication.py | 2 +- check_done/common.py | 36 ++++++++++++++---- .../done_project_items_info.py | 37 +++++++++++++------ check_done/done_project_items_info/info.py | 17 ++++++--- ...phql => organization_projects_ids.graphql} | 6 ++- .../queries/user_projects_ids.graphql | 23 ++++++++++++ data/.check_done.yaml | 2 +- tests/test_common.py | 31 +++++++++++----- 8 files changed, 118 insertions(+), 36 deletions(-) rename check_done/done_project_items_info/queries/{projects_ids.graphql => organization_projects_ids.graphql} (75%) create mode 100644 check_done/done_project_items_info/queries/user_projects_ids.graphql diff --git a/check_done/authentication.py b/check_done/authentication.py index 1f06608..3d8d897 100644 --- a/check_done/authentication.py +++ b/check_done/authentication.py @@ -22,7 +22,7 @@ def github_app_access_token(organization: str) -> str: return authentication.access_token -# TODO: Refactor code into functions instead of a class. +# TODO#13: Refactor code into functions instead of a class. class _Authentication: diff --git a/check_done/common.py b/check_done/common.py index d30c860..41ba832 100644 --- a/check_done/common.py +++ b/check_done/common.py @@ -1,6 +1,7 @@ import os import re import string +from enum import StrEnum from pathlib import Path from typing import Any @@ -10,19 +11,24 @@ from requests.auth import AuthBase load_dotenv() +_ENVVAR_CHECK_DONE_GITHUB_PERSONAL_ACCESS_TOKEN = "CHECK_DONE_GITHUB_PERSONAL_ACCESS_TOKEN" +CHECK_DONE_GITHUB_PERSONAL_ACCESS_TOKEN = os.environ.get(_ENVVAR_CHECK_DONE_GITHUB_PERSONAL_ACCESS_TOKEN) +_CONFIG_PATH = Path(__file__).parent.parent / "data" / ".check_done.yaml" _GITHUB_ORGANIZATION_NAME_AND_PROJECT_NUMBER_URL_REGEX = re.compile( r"https://github\.com/orgs/(?P[a-zA-Z0-9\-]+)/projects/(?P[0-9]+).*" ) -_CONFIG_PATH = Path(__file__).parent.parent / "data" / ".check_done.yaml" +_GITHUB_USER_NAME_AND_PROJECT_NUMBER_URL_REGEX = re.compile( + r"https://github\.com/users/(?P[a-zA-Z0-9\-]+)/projects/(?P[0-9]+).*" +) class _ConfigInfo(BaseModel): - project_board_url: str + project_url: str check_done_github_app_id: str check_done_github_app_private_key: str - @field_validator("check_done_github_app_id", "check_done_github_app_private_key", mode="before") + @field_validator("project_url", "check_done_github_app_id", "check_done_github_app_private_key", mode="before") def value_from_env(cls, value: Any | None): if isinstance(value, str): stripped_value = value.strip() @@ -41,13 +47,27 @@ def config_info() -> _ConfigInfo: return _ConfigInfo(**config_map) -def github_organization_name_and_project_number_from_url_if_matches(url: str) -> tuple[str, int]: +class ProjectOwnerType(StrEnum): + User = "users" + Organization = "orgs" + + +def github_project_owner_type_and_project_owner_name_and_project_number_from_url_if_matches( + url: str, +) -> tuple[ProjectOwnerType, str, int]: organization_name_and_project_number_match = _GITHUB_ORGANIZATION_NAME_AND_PROJECT_NUMBER_URL_REGEX.match(url) if organization_name_and_project_number_match is None: - raise ValueError(f"Cannot parse GitHub organization name and project number from URL: {url}.") - organization_name = organization_name_and_project_number_match.group("organization_name") - project_number = int(organization_name_and_project_number_match.group("project_number")) - return organization_name, project_number + user_name_and_project_number_match = _GITHUB_USER_NAME_AND_PROJECT_NUMBER_URL_REGEX.match(url) + if organization_name_and_project_number_match is None and user_name_and_project_number_match is None: + raise ValueError(f"Cannot parse GitHub organization or user name, and project number from URL: {url}.") + project_owner_type = ProjectOwnerType.User + project_owner_name = user_name_and_project_number_match.group("user_name") + project_number = int(user_name_and_project_number_match.group("project_number")) + else: + project_owner_type = ProjectOwnerType.Organization + project_owner_name = organization_name_and_project_number_match.group("organization_name") + project_number = int(organization_name_and_project_number_match.group("project_number")) + return project_owner_type, project_owner_name, project_number def resolved_environment_variables(value: str, fail_on_missing_envvar=True) -> str: diff --git a/check_done/done_project_items_info/done_project_items_info.py b/check_done/done_project_items_info/done_project_items_info.py index 267172b..d6e6408 100644 --- a/check_done/done_project_items_info/done_project_items_info.py +++ b/check_done/done_project_items_info/done_project_items_info.py @@ -11,25 +11,34 @@ from check_done.authentication import github_app_access_token from check_done.common import ( + CHECK_DONE_GITHUB_PERSONAL_ACCESS_TOKEN, HttpBearerAuth, + ProjectOwnerType, config_info, - github_organization_name_and_project_number_from_url_if_matches, + github_project_owner_type_and_project_owner_name_and_project_number_from_url_if_matches, ) from check_done.done_project_items_info.info import ( GithubContentType, NodeByIdInfo, - OrganizationInfo, PaginatedQueryInfo, ProjectItemInfo, + ProjectOwnerInfo, ProjectV2ItemNodeInfo, ProjectV2NodeInfo, ProjectV2SingleSelectFieldNodeInfo, ) _GRAPHQL_ENDPOINT = "https://api.github.com/graphql" -_PROJECT_BOARD_URL = config_info().project_board_url -ORGANIZATION_NAME, PROJECT_NUMBER = github_organization_name_and_project_number_from_url_if_matches(_PROJECT_BOARD_URL) -_ACCESS_TOKEN = github_app_access_token(ORGANIZATION_NAME) +_PROJECT_BOARD_URL = config_info().project_url +_PROJECT_OWNER_TYPE, _PROJECT_OWNER_NAME, PROJECT_NUMBER = ( + github_project_owner_type_and_project_owner_name_and_project_number_from_url_if_matches(_PROJECT_BOARD_URL) +) +_IS_PROJECT_OWNER_OF_TYPE_ORGANIZATION = ProjectOwnerType.Organization == _PROJECT_OWNER_TYPE +_ACCESS_TOKEN = ( + github_app_access_token(_PROJECT_OWNER_NAME) + if _IS_PROJECT_OWNER_OF_TYPE_ORGANIZATION + else CHECK_DONE_GITHUB_PERSONAL_ACCESS_TOKEN +) _PATH_TO_QUERIES = Path(__file__).parent.parent / "done_project_items_info" / "queries" _MAX_ENTRIES_PER_PAGE = 100 _GITHUB_PROJECT_STATUS_FIELD_NAME = "Status" @@ -41,7 +50,12 @@ def done_project_items_info() -> list[ProjectItemInfo]: session.headers = {"Accept": "application/vnd.github+json"} session.auth = HttpBearerAuth(_ACCESS_TOKEN) - projects_ids_nodes_info = _query_nodes_info(OrganizationInfo, _GraphQlQuery.PROJECTS_IDS.name, session) + projects_ids_query_name = ( + _GraphQlQuery.ORGANIZATION_PROJECTS_IDS.name + if _IS_PROJECT_OWNER_OF_TYPE_ORGANIZATION + else _GraphQlQuery.USER_PROJECTS_IDS.name + ) + projects_ids_nodes_info = _query_nodes_info(ProjectOwnerInfo, projects_ids_query_name, session) project_id = matching_project_id(projects_ids_nodes_info) last_project_state_option_ids_nodes_info = _query_nodes_info( @@ -61,7 +75,7 @@ def matching_project_id(node_infos: list[ProjectV2NodeInfo]) -> str: return next(str(project_node.id) for project_node in node_infos if project_node.number == PROJECT_NUMBER) except StopIteration as error: raise ValueError( - f"Cannot find a project with number '{PROJECT_NUMBER}', in the GitHub organization '{ORGANIZATION_NAME}'." + f"Cannot find a project with number '{PROJECT_NUMBER}', owned by '{_PROJECT_OWNER_NAME}'." ) from error @@ -81,7 +95,7 @@ def matching_last_project_state_option_id(node_infos: list[ProjectV2SingleSelect except StopIteration as error: raise ValueError( f"Cannot find the project status selection field in the GitHub project with number `{PROJECT_NUMBER}` " - f"on the `{ORGANIZATION_NAME}` organization." + f"owned by `{_PROJECT_OWNER_NAME}`." ) from error @@ -97,7 +111,7 @@ def filtered_project_item_infos_by_done_status( if len(result) < 1: logger.warning( f"No project items found with the last project status option selected in the GitHub project with number " - f"`{PROJECT_NUMBER}` in organization '{ORGANIZATION_NAME}'" + f"`{PROJECT_NUMBER}` owned by '{_PROJECT_OWNER_NAME}'" ) return result @@ -118,7 +132,8 @@ def _graphql_query(item_name: str) -> str: class _GraphQlQuery(Enum): - PROJECTS_IDS = _graphql_query("projects_ids") + ORGANIZATION_PROJECTS_IDS = _graphql_query("organization_projects_ids") + USER_PROJECTS_IDS = _graphql_query("user_projects_ids") PROJECT_STATE_OPTIONS_IDS = _graphql_query("project_state_options_ids") PROJECT_V_2_ITEMS = _graphql_query("project_v2_items") @@ -133,7 +148,7 @@ def _query_nodes_info( base_model: type[BaseModel], query_name: str, session: Session, project_id: str | None = None ) -> list: result = [] - variables = {"login": ORGANIZATION_NAME, "maxEntriesPerPage": _MAX_ENTRIES_PER_PAGE} + variables = {"login": _PROJECT_OWNER_NAME, "maxEntriesPerPage": _MAX_ENTRIES_PER_PAGE} if project_id is not None: variables["projectId"] = project_id after = None diff --git a/check_done/done_project_items_info/info.py b/check_done/done_project_items_info/info.py index 2586b26..e15ac28 100644 --- a/check_done/done_project_items_info/info.py +++ b/check_done/done_project_items_info/info.py @@ -20,6 +20,7 @@ class ProjectItemState(StrEnum): class GithubContentType(StrEnum): ISSUE = "ISSUE" + DRAFT_ISSUE = "DRAFT_ISSUE" PULL_REQUEST = "PULL_REQUEST" @@ -93,9 +94,9 @@ class LinkedProjectItemInfo(BaseModel): nodes: list[LinkedProjectItemNodeInfo] -# NOTE: For simplicity, both issues and pull requests are treated the same under a generic "project item" type, -# since all their underlying properties needed for checking are essentially identical, -# there is no value in differentiating between them as long as this continues to be the case. +# NOTE: For simplicity, both issues and pull requests are treated the same under a generic "project item" type. +# Since all their underlying properties needed for checking are essentially identical, +# there is no value in differentiating between them as long as the above continues to be the case. class ProjectItemInfo(BaseModel): """A generic type representing both issues and pull requests in a project board.""" @@ -129,8 +130,14 @@ class _ProjectsV2Info(BaseModel): projects_v2: PaginatedQueryInfo = Field(alias="projectsV2") -class OrganizationInfo(BaseModel): - organization: _ProjectsV2Info +class ProjectOwnerInfo(BaseModel): + """ + A generic type representing the project owner whether they are an organization or user. + The difference between organization and user is irrelevant in check_done's case, + as we are only using the project owner to retrieve the projects ID's. + """ + + project_owner: _ProjectsV2Info = Field(validation_alias=AliasChoices("organization", "user")) class _NodeTypeName(StrEnum): diff --git a/check_done/done_project_items_info/queries/projects_ids.graphql b/check_done/done_project_items_info/queries/organization_projects_ids.graphql similarity index 75% rename from check_done/done_project_items_info/queries/projects_ids.graphql rename to check_done/done_project_items_info/queries/organization_projects_ids.graphql index 66da27f..e4a7b6f 100644 --- a/check_done/done_project_items_info/queries/projects_ids.graphql +++ b/check_done/done_project_items_info/queries/organization_projects_ids.graphql @@ -1,4 +1,8 @@ -query projectsIds($login: String!, $maxEntriesPerPage: Int!, $after: String) { +query organizationProjectsIds( + $login: String! + $maxEntriesPerPage: Int! + $after: String +) { organization(login: $login) { projectsV2( first: $maxEntriesPerPage diff --git a/check_done/done_project_items_info/queries/user_projects_ids.graphql b/check_done/done_project_items_info/queries/user_projects_ids.graphql new file mode 100644 index 0000000..9227c45 --- /dev/null +++ b/check_done/done_project_items_info/queries/user_projects_ids.graphql @@ -0,0 +1,23 @@ +query userProjectsIds( + $login: String! + $maxEntriesPerPage: Int! + $after: String +) { + user(login: $login) { + projectsV2( + first: $maxEntriesPerPage + after: $after + orderBy: { field: NUMBER, direction: ASC } + ) { + nodes { + __typename + id + number + } + pageInfo { + hasNextPage + endCursor + } + } + } +} diff --git a/data/.check_done.yaml b/data/.check_done.yaml index 1a0cfab..dca473f 100644 --- a/data/.check_done.yaml +++ b/data/.check_done.yaml @@ -1,3 +1,3 @@ -project_board_url: "https://github.com/orgs/siisurit/projects/2" +project_url: ${CHECK_DONE_GITHUB_PROJECT_URL} check_done_github_app_id: ${CHECK_DONE_GITHUB_APP_ID} check_done_github_app_private_key: ${CHECK_DONE_GITHUB_APP_PRIVATE_KEY} diff --git a/tests/test_common.py b/tests/test_common.py index bd53a92..b1e186b 100644 --- a/tests/test_common.py +++ b/tests/test_common.py @@ -5,8 +5,9 @@ import yaml from check_done.common import ( + ProjectOwnerType, config_map_from_yaml_file, - github_organization_name_and_project_number_from_url_if_matches, + github_project_owner_type_and_project_owner_name_and_project_number_from_url_if_matches, resolved_environment_variables, ) @@ -68,18 +69,30 @@ def test_fails_to_resolve_environment_variables_with_syntax_error(): resolved_environment_variables("${") -def test_can_extract_github_organization_name_and_project_number_from_url(): - assert github_organization_name_and_project_number_from_url_if_matches( +def test_can_extract_github_project_owner_type_and_project_owner_name_and_project_number_from_url(): + assert github_project_owner_type_and_project_owner_name_and_project_number_from_url_if_matches( "https://github.com/orgs/example-org/projects/1" - ) == ("example-org", 1) - - -def test_fails_to_extract_github_organization_name_and_project_number_from_url(): + ) == (ProjectOwnerType.Organization, "example-org", 1) + assert github_project_owner_type_and_project_owner_name_and_project_number_from_url_if_matches( + "https://github.com/orgs/example-org/projects/1/some-other-stuff" + ) == (ProjectOwnerType.Organization, "example-org", 1) + assert github_project_owner_type_and_project_owner_name_and_project_number_from_url_if_matches( + "https://github.com/users/example-username/projects/1" + ) == (ProjectOwnerType.User, "example-username", 1) + assert github_project_owner_type_and_project_owner_name_and_project_number_from_url_if_matches( + "https://github.com/users/example-username/projects/1/some-other-stuff" + ) == (ProjectOwnerType.User, "example-username", 1) + + +def test_fails_to_extract_github_project_owner_type_and_project_owner_name_and_project_number_from_url(): urls = [ "https://github.com/orgs/", + "https://github.com/users/", "https://github.com/orgs/example-org/projects/", + "https://github.com/users/example-username/projects/", "", ] for url in urls: - with pytest.raises(ValueError, match=r"Cannot parse GitHub organization name and project number from URL: .*"): - github_organization_name_and_project_number_from_url_if_matches(url) + expected_error_message = rf"Cannot parse GitHub organization or user name, and project number from URL: {url}." + with pytest.raises(ValueError, match=expected_error_message): + github_project_owner_type_and_project_owner_name_and_project_number_from_url_if_matches(url) From 79264e474afcb9ddc6c9185462f619c0dfdc24c5 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Mek=20Sk=C3=ABndo?= Date: Fri, 1 Nov 2024 19:49:52 +0100 Subject: [PATCH 2/2] #17 Add project url secret to GitHub workflow --- .github/workflows/build.yml | 1 + 1 file changed, 1 insertion(+) diff --git a/.github/workflows/build.yml b/.github/workflows/build.yml index 4263a65..e82b0ab 100644 --- a/.github/workflows/build.yml +++ b/.github/workflows/build.yml @@ -38,6 +38,7 @@ jobs: poetry build - name: Run the test suite env: + CHECK_DONE_GITHUB_PROJECT_URL: ${{ secrets.CHECK_DONE_GITHUB_PROJECT_URL }} CHECK_DONE_GITHUB_APP_ID: ${{ secrets.CHECK_DONE_GITHUB_APP_ID }} CHECK_DONE_GITHUB_APP_PRIVATE_KEY: | ${{ secrets.CHECK_DONE_GITHUB_APP_PRIVATE_KEY }}