Skip to content

Commit

Permalink
#13 Clean up implementation
Browse files Browse the repository at this point in the history
  • Loading branch information
mcsken committed Nov 9, 2024
1 parent 3cb6491 commit 4456c14
Show file tree
Hide file tree
Showing 25 changed files with 850 additions and 371 deletions.
58 changes: 57 additions & 1 deletion README.md
Original file line number Diff line number Diff line change
@@ -1,3 +1,59 @@
# check_done

Check done issues on a GitHub project board.
Check done project items on a GitHub V2 project.

## How to configure

To use check_done for your project, you need to configure the fields in the `yaml` file found in the `configuration` folder.

#### project_url

The project URL as is when viewing your GitHub V2 project. E.g.: `"https://github.com/orgs/my_project_owner_name/projects/1/views/1"`

### For projects belonging to a GitHub user

#### personal_access_token

A personal access token with the permission: `read:project`.

### For projects belonging to a GitHub organization

You need to install the [Siisurit's check_done](https://github.com/apps/siisurit-s-check-done) GitHub app for authentication. Or create your own GitHub app with the following permissions: `read:issues, pull requests, projects`. Then provide the following configuration fields:

#### check_done_github_app_id

Provide the `App ID` that is found in the `General` view under the `About` section of the GitHub app installation instance. Should be a 6+ sequence of numbers.

#### check_done_github_app_private_key

The private key found in the same `General`, under the `Private keys` section. The key should be a private RSA key with PEM format.

### Optional

#### project_status_name_to_check

If you wish to specify a different status/column from the default of last, you can use this configuration field. It will try to match the name you give it, e.g.: If status is named `"✅ Done"` you can give it `"Done"` and it should find it, otherwise a list of available options will be given to you.

### Using environment variables and examples

You can use environment variables for the values in the configuration yaml by starting them with a `$` symbol and wrapping it with curly braces. E.g.: `personal_access_token: ${MY_PERSONAL_ACCESS_TOKEN_ENVVAR}`.

Example configuration for a user owned repository:

```yaml
project_url: "https://github.com/orgs/my_username/projects/1/views/1"
personal_access_token: "ghp_xxxxxxxxxxxxxxxxxxxxxx"
# Since no `project_status_name_to_check` was specified, the checks will apply to the last project status/column.
```

Example configuration for an organization owned repository:

```yaml
project_url: "https://github.com/orgs/my_username/projects/1/views/1"
check_done_github_app_id: "0123456"
check_done_github_app_private_key: "-----BEGIN RSA PRIVATE KEY-----
something_something
-----END RSA PRIVATE KEY-----
"
project_status_name_to_check: "Done" # This will match the name of a project status/column containing "Done" like "✅ Done". The checks will then be applied to this project status/column.
```
3 changes: 3 additions & 0 deletions check_done/__init__.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,3 @@
from importlib.metadata import version

__version__ = version(__name__)
76 changes: 0 additions & 76 deletions check_done/authentication.py

This file was deleted.

31 changes: 16 additions & 15 deletions check_done/checks.py
Original file line number Diff line number Diff line change
@@ -1,23 +1,21 @@
from html.parser import HTMLParser

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
from check_done.info import GithubProjectItemType, ProjectItemInfo


def check_done_project_items_for_warnings() -> list[str | None]:
def check_done_project_items_for_warnings(done_project_items: list[ProjectItemInfo]) -> list[str | None]:
result = []
done_project_items = done_project_items_info()
for project_item in done_project_items:
warning_reasons = [
warning_reason for check, warning_reason in CONDITION_CHECK_AND_WARNING_REASON_LIST if check(project_item)
]
if len(warning_reasons) >= 1:
warning = _project_item_warning_string(project_item, warning_reasons)
warning = project_item_warning_string(project_item, warning_reasons)
result.append(warning)
return result


def _project_item_warning_string(issue: ProjectItemInfo, reasons_for_warning: list[str]) -> str:
def project_item_warning_string(issue: ProjectItemInfo, reasons_for_warning: list[str]) -> str:
if len(reasons_for_warning) >= 3:
reasons_for_warning = f"{', '.join(reasons_for_warning[:-1])}, and {reasons_for_warning[-1]}"
elif len(reasons_for_warning) == 2:
Expand All @@ -30,15 +28,15 @@ def _project_item_warning_string(issue: ProjectItemInfo, reasons_for_warning: li
)


def _is_not_closed(project_item: ProjectItemInfo) -> bool:
def is_not_closed(project_item: ProjectItemInfo) -> bool:
return not project_item.closed


def _is_not_assigned(project_item: ProjectItemInfo) -> bool:
def is_not_assigned(project_item: ProjectItemInfo) -> bool:
return project_item.assignees.total_count == 0


def _has_no_milestone(project_item: ProjectItemInfo) -> bool:
def has_no_milestone(project_item: ProjectItemInfo) -> bool:
return project_item.milestone is None


Expand All @@ -64,14 +62,17 @@ def has_unfinished_goals(self):
return parser.has_unfinished_goals()


def _is_missing_linked_project_item(project_item: ProjectItemInfo) -> bool:
return len(project_item.linked_project_item.nodes) == 0
def is_missing_linked_issue_in_pull_request(project_item: ProjectItemInfo) -> bool:
result = False
if project_item.typename is GithubProjectItemType.pull_request:
result = len(project_item.linked_project_item.nodes) == 0
return result


CONDITION_CHECK_AND_WARNING_REASON_LIST = [
(_is_not_closed, "not closed"),
(_is_not_assigned, "missing assignee"),
(_has_no_milestone, "missing milestone"),
(is_not_closed, "not closed"),
(is_not_assigned, "missing assignee"),
(has_no_milestone, "missing milestone"),
(has_unfinished_goals, "missing finished goals"),
(_is_missing_linked_project_item, "missing linked project item"),
(is_missing_linked_issue_in_pull_request, "missing linked issue"),
]
42 changes: 35 additions & 7 deletions check_done/command.py
Original file line number Diff line number Diff line change
@@ -1,25 +1,53 @@
import argparse
import logging
import sys

import check_done
from check_done.checks import check_done_project_items_for_warnings
from check_done.done_project_items_info import done_project_items_info

logger = logging.getLogger(__name__)

_HELP_DESCRIPTION = (
'Analyzes for consistency a GitHub project state/column that is meant to represent "done" project items.'
)

def check_done_command():
result = 1
try:
warnings = check_done_project_items_for_warnings()

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

@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__)
return parser

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

@staticmethod
def execute():
done_project_items = done_project_items_info()
warnings = check_done_project_items_for_warnings(done_project_items)
if len(warnings) == 0:
logger.info("No warnings found.")
result = 0
logger.info("check_done found no problems with the items in the specified project state/column.")
else:
for warning in warnings:
logger.warning(warning)


def check_done_command(arguments=None):
result = 1
command = Command()
try:
command.apply_arguments(arguments)
command.execute()
result = 0
except KeyboardInterrupt:
logger.exception("Interrupted as requested by user.")
except Exception:
logger.exception("Cannot check done issues.")
logger.exception("Cannot check done project items.")
return result


Expand Down
91 changes: 66 additions & 25 deletions check_done/common.py
Original file line number Diff line number Diff line change
Expand Up @@ -7,14 +7,12 @@

import yaml
from dotenv import load_dotenv
from pydantic import BaseModel, field_validator
from pydantic import BaseModel, field_validator, model_validator
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"
_CONFIGURATION_PATH = Path(__file__).parent.parent / "configuration" / ".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,28 +21,71 @@
)


class _ConfigInfo(BaseModel):
class YamlInfo(BaseModel):
check_done_github_app_id: str | None = None
check_done_github_app_private_key: str | None = None
personal_access_token: str | None = None
project_status_name_to_check: str | None = None
project_url: str
check_done_github_app_id: str
check_done_github_app_private_key: str

@field_validator("project_url", "check_done_github_app_id", "check_done_github_app_private_key", mode="before")
@field_validator(
"project_url",
"project_status_name_to_check",
"personal_access_token",
"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()
result = (
resolved_environment_variables(value)
if stripped_value.startswith("${") and stripped_value.endswith("}")
else stripped_value
)
else:
result = value
stripped_value = value.strip()
result = (
resolved_environment_variables(value)
if stripped_value.startswith("${") and stripped_value.endswith("}")
else stripped_value
)
return result


def config_info() -> _ConfigInfo:
config_map = config_map_from_yaml_file(_CONFIG_PATH)
return _ConfigInfo(**config_map)
class ConfigurationInfo(BaseModel):
check_done_github_app_id: str | None = None
check_done_github_app_private_key: str | None = None
personal_access_token: str | None = None
project_number: int
project_owner_name: str
project_owner_type: str
project_status_name_to_check: str | None = None

@model_validator(mode="after")
def validate_at_least_one_authentication_method_in_configuration(self):
has_user_authentication = (
self.personal_access_token is not None and self.project_owner_type == ProjectOwnerType.User.value
)
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
)
if not (has_user_authentication or has_organizational_authentication):
raise ValueError("At least one authentication method must be configured.")
return self


def configuration_info() -> ConfigurationInfo:
yaml_map = configuration_map_from_yaml_file(_CONFIGURATION_PATH)
yaml_info = YamlInfo(**yaml_map)
project_url = yaml_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_url)
)
return ConfigurationInfo(
check_done_github_app_id=yaml_info.check_done_github_app_id,
check_done_github_app_private_key=yaml_info.check_done_github_app_private_key,
personal_access_token=yaml_info.personal_access_token,
project_number=project_number,
project_owner_name=project_owner_name,
project_owner_type=project_owner_type,
project_status_name_to_check=yaml_info.project_status_name_to_check,
)


class ProjectOwnerType(StrEnum):
Expand Down Expand Up @@ -82,14 +123,14 @@ def resolved_environment_variables(value: str, fail_on_missing_envvar=True) -> s
return result


def config_map_from_yaml_file(config_path: Path) -> dict:
def configuration_map_from_yaml_file(configuration_path: Path) -> dict:
try:
with open(config_path) as config_file:
result = yaml.safe_load(config_file)
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: {config_path}")
raise ValueError(f"The check_done configuration is empty. Path: {configuration_path}")
except FileNotFoundError as error:
raise FileNotFoundError(f"Cannot find check_done configuration: {config_path}") from error
raise FileNotFoundError(f"Cannot find check_done configuration: {configuration_path}") from error
return result


Expand Down
Loading

0 comments on commit 4456c14

Please sign in to comment.