diff --git a/check_done/checks.py b/check_done/checks.py new file mode 100644 index 0000000..b809212 --- /dev/null +++ b/check_done/checks.py @@ -0,0 +1,32 @@ +from check_done.done_project_items_info.done_project_items_info import done_project_items_info +from check_done.done_project_items_info.info import ProjectItemInfo + + +def check_done_issues_for_warnings() -> list[str | None]: + result = [] + done_issues = done_project_items_info() + criteria_checks = [ + _check_done_issues_are_closed, + ] + for issue in done_issues: + warnings = [check(issue) for check in criteria_checks if check(issue)] + result.extend(warnings) + return result + + +def _check_done_issues_are_closed(issue: ProjectItemInfo) -> str | None: + result = None + if not issue.closed: + result = _issue_warning_string( + issue, + "closed", + ) + return result + + +def _issue_warning_string(issue: ProjectItemInfo, reason_for_warning: str) -> str: + # TODO: Ponder better wording. + return ( + f" Done project item should be {reason_for_warning}." + f" - repository: '{issue.repository.name}', project item: '#{issue.number} {issue.title}'." + ) diff --git a/check_done/command.py b/check_done/command.py index d66b651..f9a27df 100644 --- a/check_done/command.py +++ b/check_done/command.py @@ -1,58 +1,25 @@ import logging import sys -from check_done.done_project_items_info.done_project_items_info import ( - done_project_items_info, -) -from check_done.done_project_items_info.info import IssueInfo, IssueState +from check_done.checks import check_done_issues_for_warnings logger = logging.getLogger(__name__) -def _issue_warning_string(issue: IssueInfo, reason_for_warning: str) -> str: - # TODO: Ponder better wording. - return ( - f" Done issue should be {reason_for_warning}." - f" - repository: '{issue.repository.name}', issue: '#{issue.number} {issue.title}'." - ) - - -def _check_done_issues_are_closed(issue: IssueInfo) -> str | None: - result = None - if issue.state == IssueState.OPEN: - result = _issue_warning_string( - issue, - f"of state {IssueState.CLOSED.value} but is of state {IssueState.OPEN.value}", - ) - return result - - -def _check_done_issues_for_warnings() -> list[str | None]: - result = [] - done_issues = done_project_items_info() - criteria_checks = [ - _check_done_issues_are_closed, - ] - for issue in done_issues: - warnings = [check(issue) for check in criteria_checks if check(issue)] - result.extend(warnings) - return result - - def check_done_command(): result = 1 try: - warnings = _check_done_issues_for_warnings() + warnings = check_done_issues_for_warnings() if len(warnings) == 0: - logging.info("No warnings found.") + logger.info("No warnings found.") result = 0 else: for warning in warnings: - logging.warning(warning) + logger.warning(warning) except KeyboardInterrupt: - logging.exception("Interrupted as requested by user.") + logger.exception("Interrupted as requested by user.") except Exception: - logging.exception("Cannot check done issues.") + logger.exception("Cannot check done issues.") return result 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 c7a04c9..8730771 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 @@ -16,10 +16,11 @@ github_organization_name_and_project_number_from_url_if_matches, ) from check_done.done_project_items_info.info import ( - IssueInfo, + GithubContentType, NodeByIdInfo, - NodesInfo, OrganizationInfo, + PaginatedQueryInfo, + ProjectItemInfo, ProjectV2ItemNodeInfo, ProjectV2NodeInfo, ProjectV2SingleSelectFieldNodeInfo, @@ -32,30 +33,26 @@ _PATH_TO_QUERIES = Path(__file__).parent.parent / "done_project_items_info" / "queries" _MAX_ENTRIES_PER_PAGE = 100 _GITHUB_PROJECT_STATUS_FIELD_NAME = "Status" -# TODO#4: Turn into an enum class when implementing pull requests -_GITHUB_ISSUE_TYPE_NAME = "ISSUE" logger = logging.getLogger(__name__) -def done_project_items_info() -> list[IssueInfo]: - result = [] +def done_project_items_info() -> list[ProjectItemInfo]: session = requests.Session() session.headers = {"Accept": "application/vnd.github+json"} session.auth = HttpBearerAuth(_ACCESS_TOKEN) - project_id_node_infos = _query_nodes_info(OrganizationInfo, _GraphQlQuery.PROJECTS_IDS.name, session) - project_id = matching_project_id(project_id_node_infos) + projects_ids_nodes_info = _query_nodes_info(OrganizationInfo, _GraphQlQuery.PROJECTS_IDS.name, session) + project_id = matching_project_id(projects_ids_nodes_info) - last_project_state_option_id_node_infos = _query_nodes_info( + last_project_state_option_ids_nodes_info = _query_nodes_info( NodeByIdInfo, _GraphQlQuery.PROJECT_STATE_OPTIONS_IDS.name, session, project_id ) - last_project_state_option_id = matching_last_project_state_option_id(last_project_state_option_id_node_infos) - - done_issues_node_infos = _query_nodes_info(NodeByIdInfo, _GraphQlQuery.PROJECT_V_2_ISSUES.name, session, project_id) - _done_issue_infos = done_issue_infos(done_issues_node_infos, last_project_state_option_id) + last_project_state_option_id = matching_last_project_state_option_id(last_project_state_option_ids_nodes_info) - result.extend(_done_issue_infos) - # TODO#4: Extend done PRs to result + project_items_nodes_info = _query_nodes_info( + NodeByIdInfo, _GraphQlQuery.PROJECT_V_2_ITEMS.name, session, project_id + ) + result = filtered_project_item_infos_by_done_status(project_items_nodes_info, last_project_state_option_id) return result @@ -88,25 +85,23 @@ def matching_last_project_state_option_id(node_infos: list[ProjectV2SingleSelect ) from error -def done_issue_infos(node_infos: list[ProjectV2ItemNodeInfo], last_project_state_option_id: str) -> list[IssueInfo]: +def filtered_project_item_infos_by_done_status( + node_infos: list[ProjectV2ItemNodeInfo], last_project_state_option_id: str +) -> list[ProjectItemInfo]: result = [] for node in node_infos: has_last_project_state_option = node.field_value_by_name.option_id == last_project_state_option_id - has_type_of_issue = node.type == _GITHUB_ISSUE_TYPE_NAME - if has_last_project_state_option and has_type_of_issue: + assert node.type == GithubContentType.ISSUE or GithubContentType.PULL_REQUEST + if has_last_project_state_option: result.append(node.content) if len(result) < 1: logger.warning( - f"No issues found with the last project status option selected in the GitHub project with number " + 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}'" ) return result -# TODO#4: Write a function that checks if there are done_issue_infos and done_pull_request_info combined, and if not -# raises an error. - - @lru_cache def minimized_graphql(graphql_query: str) -> str: single_spaced_query = re.sub(r"\s+", " ", graphql_query) @@ -125,7 +120,7 @@ def _graphql_query(item_name: str) -> str: class _GraphQlQuery(Enum): PROJECTS_IDS = _graphql_query("projects_ids") PROJECT_STATE_OPTIONS_IDS = _graphql_query("project_state_options_ids") - PROJECT_V_2_ISSUES = _graphql_query("project_v2_issues") + PROJECT_V_2_ITEMS = _graphql_query("project_v2_items") @staticmethod def query_for(name: str): @@ -154,22 +149,22 @@ def _query_nodes_info( response = session.post(_GRAPHQL_ENDPOINT, json=json_payload_map) response_map = checked_graphql_data_map(response) response_info = base_model(**response_map) - nodes_info = get_nodes_info_from_response_info(response_info) - nodes = nodes_info.nodes - page_info = nodes_info.page_info + paginated_query_info = get_paginated_query_info_from_response_info(response_info) + nodes_info = paginated_query_info.nodes + page_info = paginated_query_info.page_info after = page_info.endCursor has_more_pages = page_info.hasNextPage - result.extend(nodes) + result.extend(nodes_info) return result -def get_nodes_info_from_response_info(base_model: BaseModel) -> NodesInfo: - if isinstance(base_model, NodesInfo): +def get_paginated_query_info_from_response_info(base_model: BaseModel) -> PaginatedQueryInfo: + if isinstance(base_model, PaginatedQueryInfo): return base_model for model in base_model.__fields_set__: field_value = getattr(base_model, model) if isinstance(field_value, BaseModel): - page_info = get_nodes_info_from_response_info(field_value) + page_info = get_paginated_query_info_from_response_info(field_value) if page_info is not None: return page_info raise ValueError(f"Could not find nodes info in {base_model}.") diff --git a/check_done/done_project_items_info/info.py b/check_done/done_project_items_info/info.py index e5e1263..00e4fa8 100644 --- a/check_done/done_project_items_info/info.py +++ b/check_done/done_project_items_info/info.py @@ -1,4 +1,4 @@ -from enum import Enum, StrEnum +from enum import StrEnum from typing import Any from pydantic import BaseModel, ConfigDict, Field, NonNegativeInt, field_validator @@ -13,11 +13,16 @@ class NodesTypeName(StrEnum): SingleUserIssueCustomField = "SingleUserIssueCustomField" -class IssueState(Enum): +class ProjectItemState(StrEnum): CLOSED = "CLOSED" OPEN = "OPEN" +class GithubContentType(StrEnum): + ISSUE = "ISSUE" + PULL_REQUEST = "PULL_REQUEST" + + class _EmptyDict(BaseModel): model_config = ConfigDict(extra="forbid") @@ -27,7 +32,7 @@ class PageInfo(BaseModel): hasNextPage: bool -class NodesInfo(BaseModel): +class PaginatedQueryInfo(BaseModel): nodes: list[Any] page_info: PageInfo = Field(alias="pageInfo") @@ -46,8 +51,8 @@ class ProjectV2NodeInfo(BaseModel): id: str number: NonNegativeInt typename: str = Field(alias="__typename") - fields: NodesInfo | None = None - items: NodesInfo | None = None + fields: PaginatedQueryInfo | None = None + items: PaginatedQueryInfo | None = None class ProjectV2Options(BaseModel): @@ -71,18 +76,18 @@ class RepositoryInfo(BaseModel): name: str -class IssueInfo(BaseModel): +class ProjectItemInfo(BaseModel): number: NonNegativeInt - state: IssueState + closed: bool title: str repository: RepositoryInfo class ProjectV2ItemNodeInfo(BaseModel): - type: str + type: GithubContentType # TODO#4: When including pull requests, implement their info model # See: https://docs.github.com/en/graphql/reference/unions#projectv2itemcontent - content: IssueInfo | _EmptyDict = None + content: ProjectItemInfo | _EmptyDict = None field_value_by_name: ProjectV2ItemProjectStatusInfo | None = Field(alias="fieldValueByName", default=None) @@ -91,7 +96,7 @@ class NodeByIdInfo(BaseModel): class _ProjectsV2Info(BaseModel): - projects_v2: NodesInfo = Field(alias="projectsV2") + projects_v2: PaginatedQueryInfo = Field(alias="projectsV2") class OrganizationInfo(BaseModel): diff --git a/check_done/done_project_items_info/queries/project_v2_issues.graphql b/check_done/done_project_items_info/queries/project_v2_items.graphql similarity index 80% rename from check_done/done_project_items_info/queries/project_v2_issues.graphql rename to check_done/done_project_items_info/queries/project_v2_items.graphql index c550257..76a21e1 100644 --- a/check_done/done_project_items_info/queries/project_v2_issues.graphql +++ b/check_done/done_project_items_info/queries/project_v2_items.graphql @@ -17,7 +17,15 @@ query projectV2Issues( content { ... on Issue { number - state + closed + title + repository { + name + } + } + ... on PullRequest { + number + closed title repository { name diff --git a/tests/done_project_items_info/done_project_items_info.py b/tests/done_project_items_info/done_project_items_info.py index a621bac..309e0f9 100644 --- a/tests/done_project_items_info/done_project_items_info.py +++ b/tests/done_project_items_info/done_project_items_info.py @@ -8,18 +8,18 @@ PROJECT_NUMBER, GraphQlError, checked_graphql_data_map, - done_issue_infos, done_project_items_info, - get_nodes_info_from_response_info, + filtered_project_item_infos_by_done_status, + get_paginated_query_info_from_response_info, matching_last_project_state_option_id, matching_project_id, minimized_graphql, ) from check_done.done_project_items_info.info import ( - IssueInfo, - IssueState, - NodesInfo, + GithubContentType, PageInfo, + PaginatedQueryInfo, + ProjectItemInfo, ProjectV2ItemNodeInfo, ProjectV2ItemProjectStatusInfo, ProjectV2NodeInfo, @@ -125,18 +125,18 @@ class _MockedInfo(BaseModel): def test_can_get_nodes_info_from_response_info(): - mocked_node_info = NodesInfo(nodes=[], pageInfo=PageInfo(endCursor="a", hasNextPage=True)) - result = get_nodes_info_from_response_info(mocked_node_info) - assert isinstance(result, NodesInfo) + mocked_node_info = PaginatedQueryInfo(nodes=[], pageInfo=PageInfo(endCursor="a", hasNextPage=True)) + result = get_paginated_query_info_from_response_info(mocked_node_info) + assert isinstance(result, PaginatedQueryInfo) def test_fails_to_get_nodes_info_from_node_with_no_matching_node_info_type(): mocked_model = _MockedInfo(id=1) with pytest.raises(ValueError, match="Could not find nodes info in id=1."): - get_nodes_info_from_response_info(mocked_model) + get_paginated_query_info_from_response_info(mocked_model) -def test_can_get_project_items_info(): +def test_can_get_done_project_items_info(): assert len(done_project_items_info()) >= 1 @@ -190,43 +190,70 @@ def test_fails_to_find_matching_last_project_state_option_id(): ) -def test_can_get_done_issue_infos(): - _matching_project_id = "b2" +def test_can_get_filtered_project_item_infos_by_done_status(): + _in_progress_project_status_id = "a1" + _done_project_status_id = "b2" _mocked_done_issues_node_infos = [ ProjectV2ItemNodeInfo( - type="ISSUE", - content=IssueInfo( + type=GithubContentType.ISSUE, + content=ProjectItemInfo( number=1, - state=IssueState.OPEN, + closed=False, title="Do something", repository=RepositoryInfo(name="my_repo"), ), - fieldValueByName=ProjectV2ItemProjectStatusInfo(status="In Progress", optionId="a1"), + fieldValueByName=ProjectV2ItemProjectStatusInfo( + status="In Progress", optionId=_in_progress_project_status_id + ), ), ProjectV2ItemNodeInfo( - type="ISSUE", - content=IssueInfo( + type=GithubContentType.ISSUE, + content=ProjectItemInfo( number=2, - state=IssueState.CLOSED, + closed=True, title="Do something else", repository=RepositoryInfo(name="my_repo"), ), - fieldValueByName=ProjectV2ItemProjectStatusInfo(status="Done", optionId=_matching_project_id), + fieldValueByName=ProjectV2ItemProjectStatusInfo(status="Done", optionId=_done_project_status_id), + ), + ProjectV2ItemNodeInfo( + type=GithubContentType.PULL_REQUEST, + content=ProjectItemInfo( + number=3, + closed=False, + title="#1 Do something", + repository=RepositoryInfo(name="my_repo"), + ), + fieldValueByName=ProjectV2ItemProjectStatusInfo( + status="In Progress", optionId=_in_progress_project_status_id + ), + ), + ProjectV2ItemNodeInfo( + type=GithubContentType.PULL_REQUEST, + content=ProjectItemInfo( + number=4, + closed=True, + title="#2 Do something else", + repository=RepositoryInfo(name="my_repo"), + ), + fieldValueByName=ProjectV2ItemProjectStatusInfo(status="Done", optionId=_done_project_status_id), ), ] - _done_issue_infos = done_issue_infos(_mocked_done_issues_node_infos, _matching_project_id) - assert len(_done_issue_infos) == 1 + _done_issue_infos = filtered_project_item_infos_by_done_status( + _mocked_done_issues_node_infos, _done_project_status_id + ) + assert len(_done_issue_infos) == 2 -def test_fails_to_find_any_done_issue_infos(caplog): +def test_fails_to_find_any_filtered_project_item_infos_by_done_status(caplog): _matching_project_id = "a1" _other_project_id = "b2" _mocked_done_issues_node_infos = [ ProjectV2ItemNodeInfo( - type="ISSUE", - content=IssueInfo( + type=GithubContentType.ISSUE, + content=ProjectItemInfo( number=1, - state=IssueState.OPEN, + closed=False, title="Do something", repository=RepositoryInfo(name="my_repo"), ), @@ -234,8 +261,8 @@ def test_fails_to_find_any_done_issue_infos(caplog): ) ] - _done_issue_infos = done_issue_infos(_mocked_done_issues_node_infos, _matching_project_id) + _done_issue_infos = filtered_project_item_infos_by_done_status(_mocked_done_issues_node_infos, _matching_project_id) assert len(_done_issue_infos) < 1 - expected_log_warning = "No issues found with the last project status option selected " + expected_log_warning = "No project items found with the last project status option selected " assert expected_log_warning in caplog.text