Skip to content

Commit 4456c14

Browse files
committed
#13 Clean up implementation
1 parent 3cb6491 commit 4456c14

25 files changed

+850
-371
lines changed

README.md

Lines changed: 57 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,3 +1,59 @@
11
# check_done
22

3-
Check done issues on a GitHub project board.
3+
Check done project items on a GitHub V2 project.
4+
5+
## How to configure
6+
7+
To use check_done for your project, you need to configure the fields in the `yaml` file found in the `configuration` folder.
8+
9+
#### project_url
10+
11+
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"`
12+
13+
### For projects belonging to a GitHub user
14+
15+
#### personal_access_token
16+
17+
A personal access token with the permission: `read:project`.
18+
19+
### For projects belonging to a GitHub organization
20+
21+
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:
22+
23+
#### check_done_github_app_id
24+
25+
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.
26+
27+
#### check_done_github_app_private_key
28+
29+
The private key found in the same `General`, under the `Private keys` section. The key should be a private RSA key with PEM format.
30+
31+
### Optional
32+
33+
#### project_status_name_to_check
34+
35+
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.
36+
37+
### Using environment variables and examples
38+
39+
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}`.
40+
41+
Example configuration for a user owned repository:
42+
43+
```yaml
44+
project_url: "https://github.com/orgs/my_username/projects/1/views/1"
45+
personal_access_token: "ghp_xxxxxxxxxxxxxxxxxxxxxx"
46+
# Since no `project_status_name_to_check` was specified, the checks will apply to the last project status/column.
47+
```
48+
49+
Example configuration for an organization owned repository:
50+
51+
```yaml
52+
project_url: "https://github.com/orgs/my_username/projects/1/views/1"
53+
check_done_github_app_id: "0123456"
54+
check_done_github_app_private_key: "-----BEGIN RSA PRIVATE KEY-----
55+
something_something
56+
-----END RSA PRIVATE KEY-----
57+
"
58+
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.
59+
```

check_done/__init__.py

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,3 @@
1+
from importlib.metadata import version
2+
3+
__version__ = version(__name__)

check_done/authentication.py

Lines changed: 0 additions & 76 deletions
This file was deleted.

check_done/checks.py

Lines changed: 16 additions & 15 deletions
Original file line numberDiff line numberDiff line change
@@ -1,23 +1,21 @@
11
from html.parser import HTMLParser
22

3-
from check_done.done_project_items_info.done_project_items_info import done_project_items_info
4-
from check_done.done_project_items_info.info import ProjectItemInfo
3+
from check_done.info import GithubProjectItemType, ProjectItemInfo
54

65

7-
def check_done_project_items_for_warnings() -> list[str | None]:
6+
def check_done_project_items_for_warnings(done_project_items: list[ProjectItemInfo]) -> list[str | None]:
87
result = []
9-
done_project_items = done_project_items_info()
108
for project_item in done_project_items:
119
warning_reasons = [
1210
warning_reason for check, warning_reason in CONDITION_CHECK_AND_WARNING_REASON_LIST if check(project_item)
1311
]
1412
if len(warning_reasons) >= 1:
15-
warning = _project_item_warning_string(project_item, warning_reasons)
13+
warning = project_item_warning_string(project_item, warning_reasons)
1614
result.append(warning)
1715
return result
1816

1917

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

3230

33-
def _is_not_closed(project_item: ProjectItemInfo) -> bool:
31+
def is_not_closed(project_item: ProjectItemInfo) -> bool:
3432
return not project_item.closed
3533

3634

37-
def _is_not_assigned(project_item: ProjectItemInfo) -> bool:
35+
def is_not_assigned(project_item: ProjectItemInfo) -> bool:
3836
return project_item.assignees.total_count == 0
3937

4038

41-
def _has_no_milestone(project_item: ProjectItemInfo) -> bool:
39+
def has_no_milestone(project_item: ProjectItemInfo) -> bool:
4240
return project_item.milestone is None
4341

4442

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

6664

67-
def _is_missing_linked_project_item(project_item: ProjectItemInfo) -> bool:
68-
return len(project_item.linked_project_item.nodes) == 0
65+
def is_missing_linked_issue_in_pull_request(project_item: ProjectItemInfo) -> bool:
66+
result = False
67+
if project_item.typename is GithubProjectItemType.pull_request:
68+
result = len(project_item.linked_project_item.nodes) == 0
69+
return result
6970

7071

7172
CONDITION_CHECK_AND_WARNING_REASON_LIST = [
72-
(_is_not_closed, "not closed"),
73-
(_is_not_assigned, "missing assignee"),
74-
(_has_no_milestone, "missing milestone"),
73+
(is_not_closed, "not closed"),
74+
(is_not_assigned, "missing assignee"),
75+
(has_no_milestone, "missing milestone"),
7576
(has_unfinished_goals, "missing finished goals"),
76-
(_is_missing_linked_project_item, "missing linked project item"),
77+
(is_missing_linked_issue_in_pull_request, "missing linked issue"),
7778
]

check_done/command.py

Lines changed: 35 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -1,25 +1,53 @@
1+
import argparse
12
import logging
23
import sys
34

5+
import check_done
46
from check_done.checks import check_done_project_items_for_warnings
7+
from check_done.done_project_items_info import done_project_items_info
58

69
logger = logging.getLogger(__name__)
710

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

9-
def check_done_command():
10-
result = 1
11-
try:
12-
warnings = check_done_project_items_for_warnings()
15+
16+
class Command:
17+
"""Command interface for check_done"""
18+
19+
@staticmethod
20+
def argument_parser():
21+
parser = argparse.ArgumentParser(prog="check_done", description=_HELP_DESCRIPTION)
22+
parser.add_argument("--version", action="version", version="%(prog)s " + check_done.__version__)
23+
return parser
24+
25+
def apply_arguments(self, arguments=None):
26+
parser = self.argument_parser()
27+
parser.parse_args(arguments)
28+
29+
@staticmethod
30+
def execute():
31+
done_project_items = done_project_items_info()
32+
warnings = check_done_project_items_for_warnings(done_project_items)
1333
if len(warnings) == 0:
14-
logger.info("No warnings found.")
15-
result = 0
34+
logger.info("check_done found no problems with the items in the specified project state/column.")
1635
else:
1736
for warning in warnings:
1837
logger.warning(warning)
38+
39+
40+
def check_done_command(arguments=None):
41+
result = 1
42+
command = Command()
43+
try:
44+
command.apply_arguments(arguments)
45+
command.execute()
46+
result = 0
1947
except KeyboardInterrupt:
2048
logger.exception("Interrupted as requested by user.")
2149
except Exception:
22-
logger.exception("Cannot check done issues.")
50+
logger.exception("Cannot check done project items.")
2351
return result
2452

2553

check_done/common.py

Lines changed: 66 additions & 25 deletions
Original file line numberDiff line numberDiff line change
@@ -7,14 +7,12 @@
77

88
import yaml
99
from dotenv import load_dotenv
10-
from pydantic import BaseModel, field_validator
10+
from pydantic import BaseModel, field_validator, model_validator
1111
from requests.auth import AuthBase
1212

1313
load_dotenv()
14-
_ENVVAR_CHECK_DONE_GITHUB_PERSONAL_ACCESS_TOKEN = "CHECK_DONE_GITHUB_PERSONAL_ACCESS_TOKEN"
15-
CHECK_DONE_GITHUB_PERSONAL_ACCESS_TOKEN = os.environ.get(_ENVVAR_CHECK_DONE_GITHUB_PERSONAL_ACCESS_TOKEN)
1614

17-
_CONFIG_PATH = Path(__file__).parent.parent / "data" / ".check_done.yaml"
15+
_CONFIGURATION_PATH = Path(__file__).parent.parent / "configuration" / ".check_done.yaml"
1816
_GITHUB_ORGANIZATION_NAME_AND_PROJECT_NUMBER_URL_REGEX = re.compile(
1917
r"https://github\.com/orgs/(?P<organization_name>[a-zA-Z0-9\-]+)/projects/(?P<project_number>[0-9]+).*"
2018
)
@@ -23,28 +21,71 @@
2321
)
2422

2523

26-
class _ConfigInfo(BaseModel):
24+
class YamlInfo(BaseModel):
25+
check_done_github_app_id: str | None = None
26+
check_done_github_app_private_key: str | None = None
27+
personal_access_token: str | None = None
28+
project_status_name_to_check: str | None = None
2729
project_url: str
28-
check_done_github_app_id: str
29-
check_done_github_app_private_key: str
3030

31-
@field_validator("project_url", "check_done_github_app_id", "check_done_github_app_private_key", mode="before")
31+
@field_validator(
32+
"project_url",
33+
"project_status_name_to_check",
34+
"personal_access_token",
35+
"check_done_github_app_id",
36+
"check_done_github_app_private_key",
37+
mode="before",
38+
)
3239
def value_from_env(cls, value: Any | None):
33-
if isinstance(value, str):
34-
stripped_value = value.strip()
35-
result = (
36-
resolved_environment_variables(value)
37-
if stripped_value.startswith("${") and stripped_value.endswith("}")
38-
else stripped_value
39-
)
40-
else:
41-
result = value
40+
stripped_value = value.strip()
41+
result = (
42+
resolved_environment_variables(value)
43+
if stripped_value.startswith("${") and stripped_value.endswith("}")
44+
else stripped_value
45+
)
4246
return result
4347

4448

45-
def config_info() -> _ConfigInfo:
46-
config_map = config_map_from_yaml_file(_CONFIG_PATH)
47-
return _ConfigInfo(**config_map)
49+
class ConfigurationInfo(BaseModel):
50+
check_done_github_app_id: str | None = None
51+
check_done_github_app_private_key: str | None = None
52+
personal_access_token: str | None = None
53+
project_number: int
54+
project_owner_name: str
55+
project_owner_type: str
56+
project_status_name_to_check: str | None = None
57+
58+
@model_validator(mode="after")
59+
def validate_at_least_one_authentication_method_in_configuration(self):
60+
has_user_authentication = (
61+
self.personal_access_token is not None and self.project_owner_type == ProjectOwnerType.User.value
62+
)
63+
has_organizational_authentication = (
64+
self.check_done_github_app_id is not None
65+
and self.check_done_github_app_private_key is not None
66+
and self.project_owner_type == ProjectOwnerType.Organization.value
67+
)
68+
if not (has_user_authentication or has_organizational_authentication):
69+
raise ValueError("At least one authentication method must be configured.")
70+
return self
71+
72+
73+
def configuration_info() -> ConfigurationInfo:
74+
yaml_map = configuration_map_from_yaml_file(_CONFIGURATION_PATH)
75+
yaml_info = YamlInfo(**yaml_map)
76+
project_url = yaml_info.project_url
77+
project_owner_type, project_owner_name, project_number = (
78+
github_project_owner_type_and_project_owner_name_and_project_number_from_url_if_matches(project_url)
79+
)
80+
return ConfigurationInfo(
81+
check_done_github_app_id=yaml_info.check_done_github_app_id,
82+
check_done_github_app_private_key=yaml_info.check_done_github_app_private_key,
83+
personal_access_token=yaml_info.personal_access_token,
84+
project_number=project_number,
85+
project_owner_name=project_owner_name,
86+
project_owner_type=project_owner_type,
87+
project_status_name_to_check=yaml_info.project_status_name_to_check,
88+
)
4889

4990

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

84125

85-
def config_map_from_yaml_file(config_path: Path) -> dict:
126+
def configuration_map_from_yaml_file(configuration_path: Path) -> dict:
86127
try:
87-
with open(config_path) as config_file:
88-
result = yaml.safe_load(config_file)
128+
with open(configuration_path) as configuration_file:
129+
result = yaml.safe_load(configuration_file)
89130
if result is None:
90-
raise ValueError(f"The check_done configuration is empty. Path: {config_path}")
131+
raise ValueError(f"The check_done configuration is empty. Path: {configuration_path}")
91132
except FileNotFoundError as error:
92-
raise FileNotFoundError(f"Cannot find check_done configuration: {config_path}") from error
133+
raise FileNotFoundError(f"Cannot find check_done configuration: {configuration_path}") from error
93134
return result
94135

95136

0 commit comments

Comments
 (0)