Skip to content

Commit

Permalink
#17 Check boards of individual users
Browse files Browse the repository at this point in the history
  • Loading branch information
mcsken committed Nov 1, 2024
1 parent aa393c1 commit 213cf06
Show file tree
Hide file tree
Showing 8 changed files with 118 additions and 36 deletions.
2 changes: 1 addition & 1 deletion check_done/authentication.py
Original file line number Diff line number Diff line change
Expand Up @@ -22,7 +22,7 @@ def github_app_access_token(organization: str) -> str:
return authentication.access_token


# TODO: Refactor code into functions instead of a class.
# TODO#13: Refactor code into functions instead of a class.


class _Authentication:
Expand Down
36 changes: 28 additions & 8 deletions check_done/common.py
Original file line number Diff line number Diff line change
@@ -1,6 +1,7 @@
import os
import re
import string
from enum import StrEnum
from pathlib import Path
from typing import Any

Expand All @@ -10,19 +11,24 @@
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"
_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]+).*"
)
_CONFIG_PATH = Path(__file__).parent.parent / "data" / ".check_done.yaml"
_GITHUB_USER_NAME_AND_PROJECT_NUMBER_URL_REGEX = re.compile(
r"https://github\.com/users/(?P<user_name>[a-zA-Z0-9\-]+)/projects/(?P<project_number>[0-9]+).*"
)


class _ConfigInfo(BaseModel):
project_board_url: str
project_url: str
check_done_github_app_id: str
check_done_github_app_private_key: str

@field_validator("check_done_github_app_id", "check_done_github_app_private_key", mode="before")
@field_validator("project_url", "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()
Expand All @@ -41,13 +47,27 @@ def config_info() -> _ConfigInfo:
return _ConfigInfo(**config_map)


def github_organization_name_and_project_number_from_url_if_matches(url: str) -> tuple[str, int]:
class ProjectOwnerType(StrEnum):
User = "users"
Organization = "orgs"


def github_project_owner_type_and_project_owner_name_and_project_number_from_url_if_matches(
url: str,
) -> tuple[ProjectOwnerType, str, int]:
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:
raise ValueError(f"Cannot parse GitHub organization name and project number from URL: {url}.")
organization_name = organization_name_and_project_number_match.group("organization_name")
project_number = int(organization_name_and_project_number_match.group("project_number"))
return organization_name, project_number
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"))
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


def resolved_environment_variables(value: str, fail_on_missing_envvar=True) -> str:
Expand Down
37 changes: 26 additions & 11 deletions check_done/done_project_items_info/done_project_items_info.py
Original file line number Diff line number Diff line change
Expand Up @@ -11,25 +11,34 @@

from check_done.authentication import github_app_access_token
from check_done.common import (
CHECK_DONE_GITHUB_PERSONAL_ACCESS_TOKEN,
HttpBearerAuth,
ProjectOwnerType,
config_info,
github_organization_name_and_project_number_from_url_if_matches,
github_project_owner_type_and_project_owner_name_and_project_number_from_url_if_matches,
)
from check_done.done_project_items_info.info import (
GithubContentType,
NodeByIdInfo,
OrganizationInfo,
PaginatedQueryInfo,
ProjectItemInfo,
ProjectOwnerInfo,
ProjectV2ItemNodeInfo,
ProjectV2NodeInfo,
ProjectV2SingleSelectFieldNodeInfo,
)

_GRAPHQL_ENDPOINT = "https://api.github.com/graphql"
_PROJECT_BOARD_URL = config_info().project_board_url
ORGANIZATION_NAME, PROJECT_NUMBER = github_organization_name_and_project_number_from_url_if_matches(_PROJECT_BOARD_URL)
_ACCESS_TOKEN = github_app_access_token(ORGANIZATION_NAME)
_PROJECT_BOARD_URL = config_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_BOARD_URL)
)
_IS_PROJECT_OWNER_OF_TYPE_ORGANIZATION = ProjectOwnerType.Organization == _PROJECT_OWNER_TYPE
_ACCESS_TOKEN = (
github_app_access_token(_PROJECT_OWNER_NAME)
if _IS_PROJECT_OWNER_OF_TYPE_ORGANIZATION
else CHECK_DONE_GITHUB_PERSONAL_ACCESS_TOKEN
)
_PATH_TO_QUERIES = Path(__file__).parent.parent / "done_project_items_info" / "queries"
_MAX_ENTRIES_PER_PAGE = 100
_GITHUB_PROJECT_STATUS_FIELD_NAME = "Status"
Expand All @@ -41,7 +50,12 @@ def done_project_items_info() -> list[ProjectItemInfo]:
session.headers = {"Accept": "application/vnd.github+json"}
session.auth = HttpBearerAuth(_ACCESS_TOKEN)

projects_ids_nodes_info = _query_nodes_info(OrganizationInfo, _GraphQlQuery.PROJECTS_IDS.name, session)
projects_ids_query_name = (
_GraphQlQuery.ORGANIZATION_PROJECTS_IDS.name
if _IS_PROJECT_OWNER_OF_TYPE_ORGANIZATION
else _GraphQlQuery.USER_PROJECTS_IDS.name
)
projects_ids_nodes_info = _query_nodes_info(ProjectOwnerInfo, projects_ids_query_name, session)
project_id = matching_project_id(projects_ids_nodes_info)

last_project_state_option_ids_nodes_info = _query_nodes_info(
Expand All @@ -61,7 +75,7 @@ def matching_project_id(node_infos: list[ProjectV2NodeInfo]) -> str:
return next(str(project_node.id) for project_node in node_infos if project_node.number == PROJECT_NUMBER)
except StopIteration as error:
raise ValueError(
f"Cannot find a project with number '{PROJECT_NUMBER}', in the GitHub organization '{ORGANIZATION_NAME}'."
f"Cannot find a project with number '{PROJECT_NUMBER}', owned by '{_PROJECT_OWNER_NAME}'."
) from error


Expand All @@ -81,7 +95,7 @@ def matching_last_project_state_option_id(node_infos: list[ProjectV2SingleSelect
except StopIteration as error:
raise ValueError(
f"Cannot find the project status selection field in the GitHub project with number `{PROJECT_NUMBER}` "
f"on the `{ORGANIZATION_NAME}` organization."
f"owned by `{_PROJECT_OWNER_NAME}`."
) from error


Expand All @@ -97,7 +111,7 @@ def filtered_project_item_infos_by_done_status(
if len(result) < 1:
logger.warning(
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}'"
f"`{PROJECT_NUMBER}` owned by '{_PROJECT_OWNER_NAME}'"
)
return result

Expand All @@ -118,7 +132,8 @@ def _graphql_query(item_name: str) -> str:


class _GraphQlQuery(Enum):
PROJECTS_IDS = _graphql_query("projects_ids")
ORGANIZATION_PROJECTS_IDS = _graphql_query("organization_projects_ids")
USER_PROJECTS_IDS = _graphql_query("user_projects_ids")
PROJECT_STATE_OPTIONS_IDS = _graphql_query("project_state_options_ids")
PROJECT_V_2_ITEMS = _graphql_query("project_v2_items")

Expand All @@ -133,7 +148,7 @@ def _query_nodes_info(
base_model: type[BaseModel], query_name: str, session: Session, project_id: str | None = None
) -> list:
result = []
variables = {"login": ORGANIZATION_NAME, "maxEntriesPerPage": _MAX_ENTRIES_PER_PAGE}
variables = {"login": _PROJECT_OWNER_NAME, "maxEntriesPerPage": _MAX_ENTRIES_PER_PAGE}
if project_id is not None:
variables["projectId"] = project_id
after = None
Expand Down
17 changes: 12 additions & 5 deletions check_done/done_project_items_info/info.py
Original file line number Diff line number Diff line change
Expand Up @@ -20,6 +20,7 @@ class ProjectItemState(StrEnum):

class GithubContentType(StrEnum):
ISSUE = "ISSUE"
DRAFT_ISSUE = "DRAFT_ISSUE"
PULL_REQUEST = "PULL_REQUEST"


Expand Down Expand Up @@ -93,9 +94,9 @@ class LinkedProjectItemInfo(BaseModel):
nodes: list[LinkedProjectItemNodeInfo]


# NOTE: For simplicity, both issues and pull requests are treated the same under a generic "project item" type,
# since all their underlying properties needed for checking are essentially identical,
# there is no value in differentiating between them as long as this continues to be the case.
# NOTE: For simplicity, both issues and pull requests are treated the same under a generic "project item" type.
# Since all their underlying properties needed for checking are essentially identical,
# there is no value in differentiating between them as long as the above continues to be the case.
class ProjectItemInfo(BaseModel):
"""A generic type representing both issues and pull requests in a project board."""

Expand Down Expand Up @@ -129,8 +130,14 @@ class _ProjectsV2Info(BaseModel):
projects_v2: PaginatedQueryInfo = Field(alias="projectsV2")


class OrganizationInfo(BaseModel):
organization: _ProjectsV2Info
class ProjectOwnerInfo(BaseModel):
"""
A generic type representing the project owner whether they are an organization or user.
The difference between organization and user is irrelevant in check_done's case,
as we are only using the project owner to retrieve the projects ID's.
"""

project_owner: _ProjectsV2Info = Field(validation_alias=AliasChoices("organization", "user"))


class _NodeTypeName(StrEnum):
Expand Down
Original file line number Diff line number Diff line change
@@ -1,4 +1,8 @@
query projectsIds($login: String!, $maxEntriesPerPage: Int!, $after: String) {
query organizationProjectsIds(
$login: String!
$maxEntriesPerPage: Int!
$after: String
) {
organization(login: $login) {
projectsV2(
first: $maxEntriesPerPage
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,23 @@
query userProjectsIds(
$login: String!
$maxEntriesPerPage: Int!
$after: String
) {
user(login: $login) {
projectsV2(
first: $maxEntriesPerPage
after: $after
orderBy: { field: NUMBER, direction: ASC }
) {
nodes {
__typename
id
number
}
pageInfo {
hasNextPage
endCursor
}
}
}
}
2 changes: 1 addition & 1 deletion data/.check_done.yaml
Original file line number Diff line number Diff line change
@@ -1,3 +1,3 @@
project_board_url: "https://github.com/orgs/siisurit/projects/2"
project_url: ${CHECK_DONE_GITHUB_PROJECT_URL}
check_done_github_app_id: ${CHECK_DONE_GITHUB_APP_ID}
check_done_github_app_private_key: ${CHECK_DONE_GITHUB_APP_PRIVATE_KEY}
31 changes: 22 additions & 9 deletions tests/test_common.py
Original file line number Diff line number Diff line change
Expand Up @@ -5,8 +5,9 @@
import yaml

from check_done.common import (
ProjectOwnerType,
config_map_from_yaml_file,
github_organization_name_and_project_number_from_url_if_matches,
github_project_owner_type_and_project_owner_name_and_project_number_from_url_if_matches,
resolved_environment_variables,
)

Expand Down Expand Up @@ -68,18 +69,30 @@ def test_fails_to_resolve_environment_variables_with_syntax_error():
resolved_environment_variables("${")


def test_can_extract_github_organization_name_and_project_number_from_url():
assert github_organization_name_and_project_number_from_url_if_matches(
def test_can_extract_github_project_owner_type_and_project_owner_name_and_project_number_from_url():
assert github_project_owner_type_and_project_owner_name_and_project_number_from_url_if_matches(
"https://github.com/orgs/example-org/projects/1"
) == ("example-org", 1)


def test_fails_to_extract_github_organization_name_and_project_number_from_url():
) == (ProjectOwnerType.Organization, "example-org", 1)
assert github_project_owner_type_and_project_owner_name_and_project_number_from_url_if_matches(
"https://github.com/orgs/example-org/projects/1/some-other-stuff"
) == (ProjectOwnerType.Organization, "example-org", 1)
assert github_project_owner_type_and_project_owner_name_and_project_number_from_url_if_matches(
"https://github.com/users/example-username/projects/1"
) == (ProjectOwnerType.User, "example-username", 1)
assert github_project_owner_type_and_project_owner_name_and_project_number_from_url_if_matches(
"https://github.com/users/example-username/projects/1/some-other-stuff"
) == (ProjectOwnerType.User, "example-username", 1)


def test_fails_to_extract_github_project_owner_type_and_project_owner_name_and_project_number_from_url():
urls = [
"https://github.com/orgs/",
"https://github.com/users/",
"https://github.com/orgs/example-org/projects/",
"https://github.com/users/example-username/projects/",
"",
]
for url in urls:
with pytest.raises(ValueError, match=r"Cannot parse GitHub organization name and project number from URL: .*"):
github_organization_name_and_project_number_from_url_if_matches(url)
expected_error_message = rf"Cannot parse GitHub organization or user name, and project number from URL: {url}."
with pytest.raises(ValueError, match=expected_error_message):
github_project_owner_type_and_project_owner_name_and_project_number_from_url_if_matches(url)

0 comments on commit 213cf06

Please sign in to comment.