Skip to content

Commit

Permalink
#13 Clean up code
Browse files Browse the repository at this point in the history
  • Loading branch information
mcsken committed Nov 20, 2024
1 parent 54cf057 commit 38e1ff5
Show file tree
Hide file tree
Showing 26 changed files with 1,060 additions and 645 deletions.
File renamed without changes.
8 changes: 4 additions & 4 deletions .pre-commit-config.yaml
Original file line number Diff line number Diff line change
Expand Up @@ -2,19 +2,19 @@ exclude: "^\\.idea"

repos:
- repo: https://github.com/astral-sh/ruff-pre-commit
rev: v0.6.8
rev: v0.7.4
hooks:
- id: ruff-format
- id: ruff
args: ["--fix"]

- repo: https://github.com/pre-commit/mirrors-prettier
rev: v3.1.0
rev: v4.0.0-alpha.8
hooks:
- id: prettier

- repo: https://github.com/pre-commit/pre-commit-hooks
rev: v4.6.0
rev: v5.0.0
hooks:
- id: fix-byte-order-marker
- id: trailing-whitespace
Expand Down Expand Up @@ -48,7 +48,7 @@ repos:
# even for the rare case a commit should go into one of the protected
# branches.
- repo: https://github.com/pre-commit/pre-commit-hooks
rev: v4.6.0
rev: v5.0.0
hooks:
- id: no-commit-to-branch
args: ["--branch", "main"]
2 changes: 1 addition & 1 deletion README.md
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
# check_done

check_done is a command line tool to check that finished issues and pull requests in a GitHub project board are really done.
check_done is a command line tool to check that finished issues and pull requests in a GitHub project board column are really done.

## Configuration

Expand Down
51 changes: 38 additions & 13 deletions check_done/command.py
Original file line number Diff line number Diff line change
Expand Up @@ -3,40 +3,65 @@
import argparse
import logging
import sys
from pathlib import Path

import check_done
from check_done.config import (
map_from_yaml_file_path,
resolve_configuration_yaml_file_path_from_root_path,
resolve_root_repository_path,
validate_configuration_info_from_yaml_map,
)
from check_done.done_project_items_info import done_project_items_info
from check_done.warning_checks import warnings_for_done_project_items

logger = logging.getLogger(__name__)

_HELP_DESCRIPTION = (
'Analyzes for consistency a GitHub project state/column that is meant to represent "done" project items.'
)
_HELP_DESCRIPTION = "Checks that finished issues and pull requests in a GitHub project board column are really done."


class Command:
"""Command interface for check_done"""

def __init__(self):
self.args = None

@staticmethod
def argument_parser():
parser = argparse.ArgumentParser(prog="check_done", description=_HELP_DESCRIPTION)
parser.add_argument("--version", action="version", version="%(prog)s " + check_done.__version__)
parser.add_argument("--version", "-v", action="version", version="%(prog)s " + check_done.__version__)
# TODO#13: Is this implementation of the --root-dir argument proper? If not how to improve it?
parser.add_argument(
"--root-dir",
"-r",
type=Path,
default=resolve_root_repository_path(),
help="Specify a different root directory to check for the YAML config file.",
)
return parser

def apply_arguments(self, arguments=None):
parser = self.argument_parser()
parser.parse_args(arguments)
self.args = parser.parse_args(arguments)

@staticmethod
def execute():
done_project_items = done_project_items_info()
warnings = warnings_for_done_project_items(done_project_items)
if len(warnings) == 0:
logger.info("check_done found no problems with the items in the specified project state/column.")
def execute(self):
configuration_yaml_path = resolve_configuration_yaml_file_path_from_root_path(self.args.root_dir)
yaml_map = map_from_yaml_file_path(configuration_yaml_path)
configuration_info = validate_configuration_info_from_yaml_map(yaml_map)
done_project_items = done_project_items_info(configuration_info)
done_project_items_count = len(done_project_items)
if done_project_items_count == 0:
logger.info("Nothing to check. Project has no items in the selected project status.")
else:
for warning in warnings:
logger.warning(warning)
warnings = warnings_for_done_project_items(done_project_items)
if len(warnings) == 0:
logger.info(
f"All project items are correct, "
f"{done_project_items_count!s} checked in the selected project status. "
)
else:
for warning in warnings:
logger.warning(warning)


def check_done_command(arguments=None):
Expand Down
67 changes: 42 additions & 25 deletions check_done/config.py
Original file line number Diff line number Diff line change
Expand Up @@ -9,12 +9,13 @@

import yaml
from dotenv import load_dotenv
from git import Repo
from pydantic import Field, field_validator, model_validator
from pydantic.dataclasses import dataclass

load_dotenv()

_CONFIGURATION_PATH = Path(__file__).parent.parent / "configuration" / ".check_done.yaml"
CONFIG_FILE_NAME = ".check_done.yaml"
_GITHUB_ORGANIZATION_NAME_AND_PROJECT_NUMBER_URL_REGEX = re.compile(
r"https://github\.com/orgs/(?P<organization_name>[a-zA-Z0-9\-]+)/projects/(?P<project_number>[0-9]+).*"
)
Expand All @@ -23,6 +24,11 @@
)


class ProjectOwnerType(StrEnum):
User = "users"
Organization = "orgs"


@dataclass
class ConfigurationInfo:
project_url: str
Expand All @@ -38,9 +44,9 @@ class ConfigurationInfo:
project_status_name_to_check: str | None = None

# Fields computed during initialization
is_project_owner_of_type_organization: bool = Field(init=False)
project_number: int = Field(init=False)
project_owner_name: str = Field(init=False)
project_owner_type: str = Field(init=False)

@field_validator(
"project_url",
Expand All @@ -60,50 +66,46 @@ def value_from_env(cls, value: Any | None):
return result

@model_validator(mode="after")
def validate_project_details_from_url_and_at_least_one_authentication_method_in_configuration(self):
self.project_owner_type, self.project_owner_name, self.project_number = (
github_project_owner_type_and_project_owner_name_and_project_number_from_url_if_matches(self.project_url)
def validate_authentication_and_set_project_details(self):
self.project_owner_name, self.project_number, self.is_project_owner_of_type_organization = (
github_project_owner_name_and_project_number_and_is_project_owner_of_type_organization_from_url_if_matches(
self.project_url
)
)

has_user_authentication = (
self.personal_access_token is not None and self.project_owner_type == ProjectOwnerType.User.value
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
and self.project_owner_type == ProjectOwnerType.Organization.value
and self.is_project_owner_of_type_organization
)
if not (has_user_authentication or has_organizational_authentication):
raise ValueError("At least one authentication method must be configured.")
if not has_user_authentication ^ has_organizational_authentication:
raise ValueError("A user or an organization authentication method must be configured.")
return self


def configuration_info() -> ConfigurationInfo:
yaml_map = configuration_map_from_yaml_file(_CONFIGURATION_PATH)
def validate_configuration_info_from_yaml_map(yaml_map: dict) -> ConfigurationInfo:
return ConfigurationInfo(**yaml_map)


class ProjectOwnerType(StrEnum):
User = "users"
Organization = "orgs"


def github_project_owner_type_and_project_owner_name_and_project_number_from_url_if_matches(
def github_project_owner_name_and_project_number_and_is_project_owner_of_type_organization_from_url_if_matches(
url: str,
) -> tuple[ProjectOwnerType, str, int]:
) -> tuple[str, int, bool]:
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:
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"))
is_project_owner_of_type_organization = False
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
is_project_owner_of_type_organization = True
return project_owner_name, project_number, is_project_owner_of_type_organization


def resolved_environment_variables(value: str, fail_on_missing_envvar=True) -> str:
Expand All @@ -118,12 +120,27 @@ def resolved_environment_variables(value: str, fail_on_missing_envvar=True) -> s
return result


def configuration_map_from_yaml_file(configuration_path: Path) -> dict:
def map_from_yaml_file_path(configuration_path: Path) -> dict:
try:
with open(configuration_path) as configuration_file:
result = yaml.safe_load(configuration_file)
if result is None:
raise ValueError(f"The check_done configuration is empty. Path: {configuration_path}")
except FileNotFoundError as error:
raise FileNotFoundError(f"Cannot find check_done configuration: {configuration_path}") from error
raise ValueError(
f"The check_done configuration yaml file is empty. Path: {configuration_path}"
) from None
except FileNotFoundError:
raise FileNotFoundError(f"Cannot find check_done configuration yaml file: {configuration_path}") from None
return result


def resolve_root_repository_path(path: Path | str = ".", search_parent_directories=True) -> Path:
root_repository = Repo(path, search_parent_directories=search_parent_directories)
result = Path(root_repository.git.rev_parse("--show-toplevel"))
return result


def resolve_configuration_yaml_file_path_from_root_path(root_path: Path) -> Path:
result = Path(root_path / CONFIG_FILE_NAME)
if not Path.is_file(result):
raise FileNotFoundError(f"Configuration file missing in the specified root path: {root_path}") from None
return result
78 changes: 44 additions & 34 deletions check_done/done_project_items_info.py
Original file line number Diff line number Diff line change
Expand Up @@ -4,67 +4,81 @@

import requests

from check_done.config import configuration_info
from check_done.config import ConfigurationInfo
from check_done.graphql import GraphQlQuery, HttpBearerAuth, query_infos
from check_done.info import (
IS_PROJECT_OWNER_OF_TYPE_ORGANIZATION,
PROJECT_NUMBER,
PROJECT_OWNER_NAME,
NodeByIdInfo,
ProjectItemInfo,
ProjectOwnerInfo,
ProjectV2ItemNode,
ProjectV2Node,
ProjectV2SingleSelectFieldNode,
)
from check_done.organization_authentication import access_token_from_organization
from check_done.organization_authentication import resolve_organization_access_token

_PROJECT_STATUS_NAME_TO_CHECK = configuration_info().project_status_name_to_check
_PERSONAL_ACCESS_TOKEN = configuration_info().personal_access_token
_ORGANIZATION_ACCESS_TOKEN = (
access_token_from_organization(PROJECT_OWNER_NAME) if IS_PROJECT_OWNER_OF_TYPE_ORGANIZATION else None
)
_ACCESS_TOKEN = _ORGANIZATION_ACCESS_TOKEN or _PERSONAL_ACCESS_TOKEN
_GITHUB_PROJECT_STATUS_FIELD_NAME = "Status"
logger = logging.getLogger(__name__)


def done_project_items_info() -> list[ProjectItemInfo]:
def done_project_items_info(configuration_info: ConfigurationInfo) -> list[ProjectItemInfo]:
project_owner_name = configuration_info.project_owner_name

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
)
else:
access_token = configuration_info.personal_access_token

with requests.Session() as session:
session.headers = {"Accept": "application/vnd.github+json"}
session.auth = HttpBearerAuth(_ACCESS_TOKEN)
session.auth = HttpBearerAuth(access_token)

project_query_name = (
GraphQlQuery.ORGANIZATION_PROJECTS.name
if IS_PROJECT_OWNER_OF_TYPE_ORGANIZATION
if is_project_owner_of_type_organization
else GraphQlQuery.USER_PROJECTS.name
)
project_infos = query_infos(ProjectOwnerInfo, project_query_name, session)
project_id = matching_project_id(project_infos)
project_infos = query_infos(ProjectOwnerInfo, project_query_name, session, project_owner_name)

project_number = configuration_info.project_number
project_id = matching_project_id(project_infos, project_number, project_owner_name)
project_single_select_field_infos = query_infos(
NodeByIdInfo, GraphQlQuery.PROJECT_SINGLE_SELECT_FIELDS.name, session, project_id
NodeByIdInfo, GraphQlQuery.PROJECT_SINGLE_SELECT_FIELDS.name, session, project_owner_name, project_id
)

project_item_infos = query_infos(
NodeByIdInfo, GraphQlQuery.PROJECT_V2_ITEMS.name, session, project_owner_name, project_id
)
project_item_infos = query_infos(NodeByIdInfo, GraphQlQuery.PROJECT_V2_ITEMS.name, session, project_id)

project_status_name_to_check = configuration_info.project_status_name_to_check
project_status_option_id = matching_project_state_option_id(
project_single_select_field_infos, _PROJECT_STATUS_NAME_TO_CHECK
project_single_select_field_infos,
project_status_name_to_check,
project_number,
project_owner_name,
)
result = filtered_project_item_infos_by_done_status(project_item_infos, project_status_option_id)
return result


def matching_project_id(project_infos: list[ProjectV2Node]) -> str:
def matching_project_id(project_infos: list[ProjectV2Node], project_number: int, project_owner_name: str) -> str:
try:
return next(str(project_info.id) for project_info in project_infos if project_info.number == PROJECT_NUMBER)
return next(str(project_info.id) for project_info in project_infos if project_info.number == project_number)
except StopIteration:
raise ValueError(
f"Cannot find a project with number '{PROJECT_NUMBER}', owned by '{PROJECT_OWNER_NAME}'."
f"Cannot find a project with number '{project_number}', owned by '{project_owner_name}'."
) from None


def matching_project_state_option_id(
project_single_select_field_infos: list[ProjectV2SingleSelectFieldNode],
project_status_name_to_check: str | None,
project_number: int,
project_owner_name: str,
) -> str:
try:
status_options = next(
Expand All @@ -74,8 +88,8 @@ def matching_project_state_option_id(
)
except StopIteration:
raise ValueError(
f"Cannot find a project status selection field in the GitHub project with number `{PROJECT_NUMBER}` "
f"owned by `{PROJECT_OWNER_NAME}`."
f"Cannot find a project status selection field in the GitHub project with number `{project_number}` "
f"owned by `{project_owner_name}`."
) from None
if project_status_name_to_check:
logger.info(f"Checking project items with status matching: '{project_status_name_to_check}'")
Expand All @@ -90,13 +104,14 @@ def matching_project_state_option_id(
option.name for field_info in project_single_select_field_infos for option in field_info.options
]
raise ValueError(
f"Cannot find the project status matching name '{_PROJECT_STATUS_NAME_TO_CHECK}' "
f"in the GitHub project with number `{PROJECT_NUMBER}` owned by `{PROJECT_OWNER_NAME}`. "
f"Available options are: {project_status_options_names}"
f"Cannot find the project status matching name {project_status_name_to_check!r} "
f"in the GitHub project with number '{project_number}' owned by {project_owner_name!r}. "
f"Available options are: {project_status_options_names!r}"
) from None
else:
logger.info("Checking project items in the last status/board column.")
result = status_options[-1].id
last_status_option = status_options[-1]
logger.info(f"Checking project items with the last project status selected: {last_status_option.name!r}.")
result = last_status_option.id
return result


Expand All @@ -112,9 +127,4 @@ def filtered_project_item_infos_by_done_status(
)
if has_project_state_option:
result.append(project_item_info.content)
if len(result) < 1:
logger.warning(
f"No project items found for the specified project status in the GitHub project "
f"with number '{PROJECT_NUMBER}' owned by '{PROJECT_OWNER_NAME}'."
)
return result
Loading

0 comments on commit 38e1ff5

Please sign in to comment.