Skip to content

Commit 213cf06

Browse files
committed
#17 Check boards of individual users
1 parent aa393c1 commit 213cf06

File tree

8 files changed

+118
-36
lines changed

8 files changed

+118
-36
lines changed

check_done/authentication.py

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -22,7 +22,7 @@ def github_app_access_token(organization: str) -> str:
2222
return authentication.access_token
2323

2424

25-
# TODO: Refactor code into functions instead of a class.
25+
# TODO#13: Refactor code into functions instead of a class.
2626

2727

2828
class _Authentication:

check_done/common.py

Lines changed: 28 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,7 @@
11
import os
22
import re
33
import string
4+
from enum import StrEnum
45
from pathlib import Path
56
from typing import Any
67

@@ -10,19 +11,24 @@
1011
from requests.auth import AuthBase
1112

1213
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)
1316

17+
_CONFIG_PATH = Path(__file__).parent.parent / "data" / ".check_done.yaml"
1418
_GITHUB_ORGANIZATION_NAME_AND_PROJECT_NUMBER_URL_REGEX = re.compile(
1519
r"https://github\.com/orgs/(?P<organization_name>[a-zA-Z0-9\-]+)/projects/(?P<project_number>[0-9]+).*"
1620
)
17-
_CONFIG_PATH = Path(__file__).parent.parent / "data" / ".check_done.yaml"
21+
_GITHUB_USER_NAME_AND_PROJECT_NUMBER_URL_REGEX = re.compile(
22+
r"https://github\.com/users/(?P<user_name>[a-zA-Z0-9\-]+)/projects/(?P<project_number>[0-9]+).*"
23+
)
1824

1925

2026
class _ConfigInfo(BaseModel):
21-
project_board_url: str
27+
project_url: str
2228
check_done_github_app_id: str
2329
check_done_github_app_private_key: str
2430

25-
@field_validator("check_done_github_app_id", "check_done_github_app_private_key", mode="before")
31+
@field_validator("project_url", "check_done_github_app_id", "check_done_github_app_private_key", mode="before")
2632
def value_from_env(cls, value: Any | None):
2733
if isinstance(value, str):
2834
stripped_value = value.strip()
@@ -41,13 +47,27 @@ def config_info() -> _ConfigInfo:
4147
return _ConfigInfo(**config_map)
4248

4349

44-
def github_organization_name_and_project_number_from_url_if_matches(url: str) -> tuple[str, int]:
50+
class ProjectOwnerType(StrEnum):
51+
User = "users"
52+
Organization = "orgs"
53+
54+
55+
def github_project_owner_type_and_project_owner_name_and_project_number_from_url_if_matches(
56+
url: str,
57+
) -> tuple[ProjectOwnerType, str, int]:
4558
organization_name_and_project_number_match = _GITHUB_ORGANIZATION_NAME_AND_PROJECT_NUMBER_URL_REGEX.match(url)
4659
if organization_name_and_project_number_match is None:
47-
raise ValueError(f"Cannot parse GitHub organization name and project number from URL: {url}.")
48-
organization_name = organization_name_and_project_number_match.group("organization_name")
49-
project_number = int(organization_name_and_project_number_match.group("project_number"))
50-
return organization_name, project_number
60+
user_name_and_project_number_match = _GITHUB_USER_NAME_AND_PROJECT_NUMBER_URL_REGEX.match(url)
61+
if organization_name_and_project_number_match is None and user_name_and_project_number_match is None:
62+
raise ValueError(f"Cannot parse GitHub organization or user name, and project number from URL: {url}.")
63+
project_owner_type = ProjectOwnerType.User
64+
project_owner_name = user_name_and_project_number_match.group("user_name")
65+
project_number = int(user_name_and_project_number_match.group("project_number"))
66+
else:
67+
project_owner_type = ProjectOwnerType.Organization
68+
project_owner_name = organization_name_and_project_number_match.group("organization_name")
69+
project_number = int(organization_name_and_project_number_match.group("project_number"))
70+
return project_owner_type, project_owner_name, project_number
5171

5272

5373
def resolved_environment_variables(value: str, fail_on_missing_envvar=True) -> str:

check_done/done_project_items_info/done_project_items_info.py

Lines changed: 26 additions & 11 deletions
Original file line numberDiff line numberDiff line change
@@ -11,25 +11,34 @@
1111

1212
from check_done.authentication import github_app_access_token
1313
from check_done.common import (
14+
CHECK_DONE_GITHUB_PERSONAL_ACCESS_TOKEN,
1415
HttpBearerAuth,
16+
ProjectOwnerType,
1517
config_info,
16-
github_organization_name_and_project_number_from_url_if_matches,
18+
github_project_owner_type_and_project_owner_name_and_project_number_from_url_if_matches,
1719
)
1820
from check_done.done_project_items_info.info import (
1921
GithubContentType,
2022
NodeByIdInfo,
21-
OrganizationInfo,
2223
PaginatedQueryInfo,
2324
ProjectItemInfo,
25+
ProjectOwnerInfo,
2426
ProjectV2ItemNodeInfo,
2527
ProjectV2NodeInfo,
2628
ProjectV2SingleSelectFieldNodeInfo,
2729
)
2830

2931
_GRAPHQL_ENDPOINT = "https://api.github.com/graphql"
30-
_PROJECT_BOARD_URL = config_info().project_board_url
31-
ORGANIZATION_NAME, PROJECT_NUMBER = github_organization_name_and_project_number_from_url_if_matches(_PROJECT_BOARD_URL)
32-
_ACCESS_TOKEN = github_app_access_token(ORGANIZATION_NAME)
32+
_PROJECT_BOARD_URL = config_info().project_url
33+
_PROJECT_OWNER_TYPE, _PROJECT_OWNER_NAME, PROJECT_NUMBER = (
34+
github_project_owner_type_and_project_owner_name_and_project_number_from_url_if_matches(_PROJECT_BOARD_URL)
35+
)
36+
_IS_PROJECT_OWNER_OF_TYPE_ORGANIZATION = ProjectOwnerType.Organization == _PROJECT_OWNER_TYPE
37+
_ACCESS_TOKEN = (
38+
github_app_access_token(_PROJECT_OWNER_NAME)
39+
if _IS_PROJECT_OWNER_OF_TYPE_ORGANIZATION
40+
else CHECK_DONE_GITHUB_PERSONAL_ACCESS_TOKEN
41+
)
3342
_PATH_TO_QUERIES = Path(__file__).parent.parent / "done_project_items_info" / "queries"
3443
_MAX_ENTRIES_PER_PAGE = 100
3544
_GITHUB_PROJECT_STATUS_FIELD_NAME = "Status"
@@ -41,7 +50,12 @@ def done_project_items_info() -> list[ProjectItemInfo]:
4150
session.headers = {"Accept": "application/vnd.github+json"}
4251
session.auth = HttpBearerAuth(_ACCESS_TOKEN)
4352

44-
projects_ids_nodes_info = _query_nodes_info(OrganizationInfo, _GraphQlQuery.PROJECTS_IDS.name, session)
53+
projects_ids_query_name = (
54+
_GraphQlQuery.ORGANIZATION_PROJECTS_IDS.name
55+
if _IS_PROJECT_OWNER_OF_TYPE_ORGANIZATION
56+
else _GraphQlQuery.USER_PROJECTS_IDS.name
57+
)
58+
projects_ids_nodes_info = _query_nodes_info(ProjectOwnerInfo, projects_ids_query_name, session)
4559
project_id = matching_project_id(projects_ids_nodes_info)
4660

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

6781

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

87101

@@ -97,7 +111,7 @@ def filtered_project_item_infos_by_done_status(
97111
if len(result) < 1:
98112
logger.warning(
99113
f"No project items found with the last project status option selected in the GitHub project with number "
100-
f"`{PROJECT_NUMBER}` in organization '{ORGANIZATION_NAME}'"
114+
f"`{PROJECT_NUMBER}` owned by '{_PROJECT_OWNER_NAME}'"
101115
)
102116
return result
103117

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

119133

120134
class _GraphQlQuery(Enum):
121-
PROJECTS_IDS = _graphql_query("projects_ids")
135+
ORGANIZATION_PROJECTS_IDS = _graphql_query("organization_projects_ids")
136+
USER_PROJECTS_IDS = _graphql_query("user_projects_ids")
122137
PROJECT_STATE_OPTIONS_IDS = _graphql_query("project_state_options_ids")
123138
PROJECT_V_2_ITEMS = _graphql_query("project_v2_items")
124139

@@ -133,7 +148,7 @@ def _query_nodes_info(
133148
base_model: type[BaseModel], query_name: str, session: Session, project_id: str | None = None
134149
) -> list:
135150
result = []
136-
variables = {"login": ORGANIZATION_NAME, "maxEntriesPerPage": _MAX_ENTRIES_PER_PAGE}
151+
variables = {"login": _PROJECT_OWNER_NAME, "maxEntriesPerPage": _MAX_ENTRIES_PER_PAGE}
137152
if project_id is not None:
138153
variables["projectId"] = project_id
139154
after = None

check_done/done_project_items_info/info.py

Lines changed: 12 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -20,6 +20,7 @@ class ProjectItemState(StrEnum):
2020

2121
class GithubContentType(StrEnum):
2222
ISSUE = "ISSUE"
23+
DRAFT_ISSUE = "DRAFT_ISSUE"
2324
PULL_REQUEST = "PULL_REQUEST"
2425

2526

@@ -93,9 +94,9 @@ class LinkedProjectItemInfo(BaseModel):
9394
nodes: list[LinkedProjectItemNodeInfo]
9495

9596

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

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

131132

132-
class OrganizationInfo(BaseModel):
133-
organization: _ProjectsV2Info
133+
class ProjectOwnerInfo(BaseModel):
134+
"""
135+
A generic type representing the project owner whether they are an organization or user.
136+
The difference between organization and user is irrelevant in check_done's case,
137+
as we are only using the project owner to retrieve the projects ID's.
138+
"""
139+
140+
project_owner: _ProjectsV2Info = Field(validation_alias=AliasChoices("organization", "user"))
134141

135142

136143
class _NodeTypeName(StrEnum):

check_done/done_project_items_info/queries/projects_ids.graphql renamed to check_done/done_project_items_info/queries/organization_projects_ids.graphql

Lines changed: 5 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,8 @@
1-
query projectsIds($login: String!, $maxEntriesPerPage: Int!, $after: String) {
1+
query organizationProjectsIds(
2+
$login: String!
3+
$maxEntriesPerPage: Int!
4+
$after: String
5+
) {
26
organization(login: $login) {
37
projectsV2(
48
first: $maxEntriesPerPage
Lines changed: 23 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,23 @@
1+
query userProjectsIds(
2+
$login: String!
3+
$maxEntriesPerPage: Int!
4+
$after: String
5+
) {
6+
user(login: $login) {
7+
projectsV2(
8+
first: $maxEntriesPerPage
9+
after: $after
10+
orderBy: { field: NUMBER, direction: ASC }
11+
) {
12+
nodes {
13+
__typename
14+
id
15+
number
16+
}
17+
pageInfo {
18+
hasNextPage
19+
endCursor
20+
}
21+
}
22+
}
23+
}

data/.check_done.yaml

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,3 +1,3 @@
1-
project_board_url: "https://github.com/orgs/siisurit/projects/2"
1+
project_url: ${CHECK_DONE_GITHUB_PROJECT_URL}
22
check_done_github_app_id: ${CHECK_DONE_GITHUB_APP_ID}
33
check_done_github_app_private_key: ${CHECK_DONE_GITHUB_APP_PRIVATE_KEY}

tests/test_common.py

Lines changed: 22 additions & 9 deletions
Original file line numberDiff line numberDiff line change
@@ -5,8 +5,9 @@
55
import yaml
66

77
from check_done.common import (
8+
ProjectOwnerType,
89
config_map_from_yaml_file,
9-
github_organization_name_and_project_number_from_url_if_matches,
10+
github_project_owner_type_and_project_owner_name_and_project_number_from_url_if_matches,
1011
resolved_environment_variables,
1112
)
1213

@@ -68,18 +69,30 @@ def test_fails_to_resolve_environment_variables_with_syntax_error():
6869
resolved_environment_variables("${")
6970

7071

71-
def test_can_extract_github_organization_name_and_project_number_from_url():
72-
assert github_organization_name_and_project_number_from_url_if_matches(
72+
def test_can_extract_github_project_owner_type_and_project_owner_name_and_project_number_from_url():
73+
assert github_project_owner_type_and_project_owner_name_and_project_number_from_url_if_matches(
7374
"https://github.com/orgs/example-org/projects/1"
74-
) == ("example-org", 1)
75-
76-
77-
def test_fails_to_extract_github_organization_name_and_project_number_from_url():
75+
) == (ProjectOwnerType.Organization, "example-org", 1)
76+
assert github_project_owner_type_and_project_owner_name_and_project_number_from_url_if_matches(
77+
"https://github.com/orgs/example-org/projects/1/some-other-stuff"
78+
) == (ProjectOwnerType.Organization, "example-org", 1)
79+
assert github_project_owner_type_and_project_owner_name_and_project_number_from_url_if_matches(
80+
"https://github.com/users/example-username/projects/1"
81+
) == (ProjectOwnerType.User, "example-username", 1)
82+
assert github_project_owner_type_and_project_owner_name_and_project_number_from_url_if_matches(
83+
"https://github.com/users/example-username/projects/1/some-other-stuff"
84+
) == (ProjectOwnerType.User, "example-username", 1)
85+
86+
87+
def test_fails_to_extract_github_project_owner_type_and_project_owner_name_and_project_number_from_url():
7888
urls = [
7989
"https://github.com/orgs/",
90+
"https://github.com/users/",
8091
"https://github.com/orgs/example-org/projects/",
92+
"https://github.com/users/example-username/projects/",
8193
"",
8294
]
8395
for url in urls:
84-
with pytest.raises(ValueError, match=r"Cannot parse GitHub organization name and project number from URL: .*"):
85-
github_organization_name_and_project_number_from_url_if_matches(url)
96+
expected_error_message = rf"Cannot parse GitHub organization or user name, and project number from URL: {url}."
97+
with pytest.raises(ValueError, match=expected_error_message):
98+
github_project_owner_type_and_project_owner_name_and_project_number_from_url_if_matches(url)

0 commit comments

Comments
 (0)