Skip to content

Commit

Permalink
#13 CLean up code and add TODO comments
Browse files Browse the repository at this point in the history
  • Loading branch information
roskakori committed Nov 11, 2024
1 parent 0725e0e commit fbf9d59
Show file tree
Hide file tree
Showing 10 changed files with 192 additions and 80 deletions.
2 changes: 2 additions & 0 deletions .gitignore
Original file line number Diff line number Diff line change
Expand Up @@ -287,4 +287,6 @@ poetry.toml
# LSP config files
pyrightconfig.json

/.idea/ruff.xml

# End of https://www.toptal.com/developers/gitignore/api/pycharm,python
4 changes: 4 additions & 0 deletions .pre-commit-config.yaml
Original file line number Diff line number Diff line change
Expand Up @@ -31,6 +31,10 @@ repos:
- id: end-of-file-fixer
- id: mixed-line-ending

# TODO#13 Add check for copyright. Use:
# Copyright (c) 2024, Siisurit e.U.
# All rights reserved. Distributed under the MIT License.

# NOTE: This should be the last check to ensure everything else is checked
# even for the rare case a commit should go into one of the protected
# branches.
Expand Down
63 changes: 41 additions & 22 deletions README.md
Original file line number Diff line number Diff line change
@@ -1,52 +1,63 @@
# check_done

Check done project items on a GitHub V2 project.
Check_done is a command line tool to check that finished issues and pull requests in a GitHub project board are really done.

## How to configure
## Configuration

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

#### project_url
### General settings

#### project_url (required)

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
#### project_status_name_to_check (optional)

#### personal_access_token
By default, check_done checks all issues and pull requests in the column rightmost / last column of the project board. If you left the default names when creating the GitHub project board, this will be the `"✅ Done"` column.

A personal access token with the permission: `read:project`.
If you want to check a different column, you can specify its name with this option. For example:

### For projects belonging to a GitHub organization
```yaml
project_status_name_to_check = "Done"
```

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:
The name takes the first column that partially matches case sensitively. For example, `"Done"` will also match `"✅ Done"`, but not `"done"`.

#### check_done_github_app_id
If no column matches, the resulting error messages will tell you the exact names of all available columns.

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.
### Authorization settings for a project belonging to a GitHub user

#### check_done_github_app_private_key
#### personal_access_token

The private key found in the same `General`, under the `Private keys` section. The key should be a private RSA key with PEM format.
A personal access token with the permission: `read:project`.

### Optional
Example:

#### project_status_name_to_check
```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.
```

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.
### Authorization settings for a project belonging to a GitHub organization

### Using environment variables and examples
Follow the instructions to [Installing a GitHub App from a third party](https://docs.github.com/en/apps/using-github-apps/installing-a-github-app-from-a-third-party) using the [Siisurit's check_done app](https://github.com/apps/siisurit-s-check-done).

You can also derive your own GitHub app from it with the following permissions:

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}`.
- `pull requests`
- `projects`
- `read:issues`

Example configuration for a user owned repository:
Remember the App ID and the app private key. Then add the following settings to the configuration file:

```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.
check_done_github_app_id = ... # Typically a decimal number with at least 6 digits
check_done_github_app_private_key = ""-----BEGIN RSA PRIVATE KEY..."
```

Example configuration for an organization owned repository:
Example:

```yaml
project_url: "https://github.com/orgs/my_username/projects/1/views/1"
Expand All @@ -57,3 +68,11 @@ something_something
"
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.
```
### 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. For example:

```yaml
personal_access_token: ${MY_PERSONAL_ACCESS_TOKEN_ENVVAR}
```
53 changes: 34 additions & 19 deletions check_done/checks.py
Original file line number Diff line number Diff line change
Expand Up @@ -3,11 +3,14 @@
from check_done.info import GithubProjectItemType, ProjectItemInfo


# TODO#13 Naming: CQS -> warnings_from_checked_project_items
def check_done_project_items_for_warnings(done_project_items: list[ProjectItemInfo]) -> list[str | None]:
result = []
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)
warning_reason
for is_valid, warning_reason in CONDITION_CHECK_AND_WARNING_REASON_LIST
if is_valid(project_item)
]
if len(warning_reasons) >= 1:
warning = project_item_warning_string(project_item, warning_reasons)
Expand All @@ -32,6 +35,13 @@ def is_not_closed(project_item: ProjectItemInfo) -> bool:
return not project_item.closed


def possible_should_be_closed_warning(project_item: ProjectItemInfo) -> str | None:
return "should be closed" if not project_item.closed else None


# TODO#13 Change other checks to warning functions.


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

Expand All @@ -41,38 +51,43 @@ def has_no_milestone(project_item: ProjectItemInfo) -> bool:


def has_unfinished_goals(project_item: ProjectItemInfo) -> bool:
class _GoalsHTMLParser(HTMLParser):
def __init__(self):
super().__init__()
self.is_any_goal_unchecked = False

def handle_starttag(self, tag, attrs):
if tag == "input":
attr_dict = dict(attrs)
is_checkbox = attr_dict.get("type") == "checkbox"
is_unchecked = "checked" not in attr_dict
if is_checkbox and is_unchecked:
self.is_any_goal_unchecked = True

def has_unfinished_goals(self):
return self.is_any_goal_unchecked

parser = _GoalsHTMLParser()
parser = _AllTasksCheckedHtmlParser()
parser.feed(project_item.body_html)
return parser.has_unfinished_goals()


class _AllTasksCheckedHtmlParser(HTMLParser):
def __init__(self):
super().__init__()
self.all_tasks_are_checked = True

def handle_starttag(self, tag, attrs):
if tag == "input":
attr_dict = dict(attrs)
is_checkbox = attr_dict.get("type") == "checkbox"
if is_checkbox:
is_checked = "checked" in attr_dict
if not is_checked:
self.all_tasks_are_checked = False
# TODO Optimize: If any task is unchecked, stop parsing.

def has_unfinished_goals(self): # TODO#13 Possibly remove
return not self.all_tasks_are_checked


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 = [
CONDITION_CHECK_AND_WARNING_REASON_LIST = [ # TODO#13 Clean up: Remove, rename, or change to map.
(is_not_closed, "not closed"),
(is_not_assigned, "missing assignee"),
(has_no_milestone, "missing milestone"),
(has_unfinished_goals, "missing finished goals"),
(is_missing_linked_issue_in_pull_request, "missing linked issue"),
]

POSSIBLE_WARNINGS = [possible_should_be_closed_warning] # TODO#13 Add other warning functions.
2 changes: 1 addition & 1 deletion check_done/command.py
Original file line number Diff line number Diff line change
Expand Up @@ -45,7 +45,7 @@ def check_done_command(arguments=None):
command.execute()
result = 0
except KeyboardInterrupt:
logger.exception("Interrupted as requested by user.")
logger.error("Interrupted as requested by user.") # noqa: TRY400
except Exception:
logger.exception("Cannot check done project items.")
return result
Expand Down
57 changes: 56 additions & 1 deletion check_done/common.py
Original file line number Diff line number Diff line change
Expand Up @@ -7,9 +7,11 @@

import yaml
from dotenv import load_dotenv
from pydantic import BaseModel, field_validator, model_validator
from pydantic import BaseModel, Field, field_validator, model_validator
from requests.auth import AuthBase

# TODO#13: Move parts to separate module "config".

load_dotenv()

_CONFIGURATION_PATH = Path(__file__).parent.parent / "configuration" / ".check_done.yaml"
Expand All @@ -21,6 +23,59 @@
)


class ConfigurationInfo2(BaseModel):
project_url: str

# Required for user project
personal_access_token: str | None = None

# Required for organization project
check_done_github_app_id: str | None = None
check_done_github_app_private_key: str | None = None

# Optional
project_status_name_to_check: str | None = None

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

@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):
stripped_value = value.strip()
result = (
resolved_environment_variables(value, fail_on_missing_envvar=False)
if stripped_value.startswith("${") and stripped_value.endswith("}")
else stripped_value
)
return result

@model_validator(mode="after")
def validate_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)
)
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


class YamlInfo(BaseModel):
check_done_github_app_id: str | None = None
check_done_github_app_private_key: str | None = None
Expand Down
Loading

0 comments on commit fbf9d59

Please sign in to comment.