Skip to content

Commit bcec1d0

Browse files
authored
Merge pull request #327 from open-craft/navin/auto-date
feat: fill date opened field and owner in ospr project board
2 parents 9f337f8 + 3355ff2 commit bcec1d0

9 files changed

+298
-40
lines changed

.gitignore

+1
Original file line numberDiff line numberDiff line change
@@ -15,3 +15,4 @@ htmlcov
1515
openedx_webhooks.egg-info/
1616
requirements/private.in
1717
requirements/private.txt
18+
.mise.toml

openedx_webhooks/bot_comments.py

+3-24
Original file line numberDiff line numberDiff line change
@@ -5,19 +5,18 @@
55
import binascii
66
import json
77
import re
8-
from collections import namedtuple
98

109
from enum import Enum, auto
11-
from typing import Dict, Literal
10+
from typing import Dict
1211

1312
import arrow
1413
from flask import render_template
1514

1615
from openedx_webhooks.info import (
1716
get_jira_server_info,
17+
get_repo_spec,
1818
is_draft_pull_request,
1919
pull_request_has_cla,
20-
get_catalog_info,
2120
)
2221
from openedx_webhooks.types import JiraId, PrDict
2322

@@ -94,26 +93,6 @@ def is_comment_kind(kind: BotComment, text: str) -> bool:
9493
return any(snip in text for snip in BOT_COMMENT_INDICATORS[kind])
9594

9695

97-
Lifecycle = Literal["experimental", "production", "deprecated"]
98-
RepoSpec: (str | None, Lifecycle | None) = namedtuple('RepoSpec', ['owner', 'lifecycle'])
99-
100-
101-
def _get_repo_spec(repo_full_name: str) -> RepoSpec:
102-
"""
103-
Get the owner of the repo from its catalog-info.yaml file.
104-
"""
105-
catalog_info = get_catalog_info(repo_full_name)
106-
if not catalog_info:
107-
return RepoSpec(None, None)
108-
owner = catalog_info["spec"].get("owner", "")
109-
owner_type = None
110-
if ":" in owner:
111-
owner_type, owner = owner.split(":")
112-
if owner_type == "group":
113-
owner = f"openedx/{owner}"
114-
return RepoSpec(owner, catalog_info["spec"]["lifecycle"])
115-
116-
11796
def github_community_pr_comment(pull_request: PrDict) -> str:
11897
"""
11998
For a newly-created pull request from an open source contributor,
@@ -124,7 +103,7 @@ def github_community_pr_comment(pull_request: PrDict) -> str:
124103
* contain a link to our process documentation
125104
"""
126105
is_first_time = pull_request.get("author_association", None) in GITHUB_NEW_AUTHOR_ASSOCIATIONS
127-
spec = _get_repo_spec(pull_request["base"]["repo"]["full_name"])
106+
spec = get_repo_spec(pull_request["base"]["repo"]["full_name"])
128107

129108
return render_template(
130109
"github_community_pr_comment.md.j2",

openedx_webhooks/gh_projects.py

+99-4
Original file line numberDiff line numberDiff line change
@@ -7,8 +7,8 @@
77
from glom import glom
88

99
from openedx_webhooks.tasks import logger
10-
from openedx_webhooks.types import GhProject, PrDict, PrId
11-
from openedx_webhooks.utils import graphql_query
10+
from openedx_webhooks.types import GhPrMetaDict, GhProject, PrDict, PrId
11+
from openedx_webhooks.utils import graphql_query, memoize_timed, value_graphql_type
1212

1313
# The name of the query is used by FakeGitHub while testing.
1414

@@ -22,6 +22,7 @@
2222
pullRequest (number: $number) {
2323
projectItems (first: 100) {
2424
nodes {
25+
id
2526
project {
2627
number
2728
owner {
@@ -37,6 +38,7 @@
3738
}
3839
"""
3940

41+
4042
def pull_request_projects(pr: PrDict) -> Set[GhProject]:
4143
"""Return the projects this PR is in.
4244
@@ -90,8 +92,9 @@ def pull_request_projects(pr: PrDict) -> Set[GhProject]:
9092
}
9193
"""
9294

93-
def add_pull_request_to_project(prid: PrId, pr_node_id: str, project: GhProject) -> None:
94-
"""Add a pull request to a project.
95+
96+
def add_pull_request_to_project(prid: PrId, pr_node_id: str, project: GhProject) -> str:
97+
"""Add a pull request to a project and returns its project item id.
9598
9699
The project is a tuple: (orgname, number)
97100
"""
@@ -104,3 +107,95 @@ def add_pull_request_to_project(prid: PrId, pr_node_id: str, project: GhProject)
104107
# Add the pull request.
105108
variables = {"projectId": proj_id, "prNodeId": pr_node_id}
106109
data = graphql_query(query=ADD_PROJECT_ITEM, variables=variables)
110+
# data = {'addProjectV2ItemById': {'item': {'id': '<item_id>'}}}
111+
return glom(data, "addProjectV2ItemById.item.id")
112+
113+
114+
ORG_PROJECT_METADATA = """\
115+
query OrgProjectMetadata ($orgname: String!, $number: Int!) {
116+
organization(login: $orgname) {
117+
projectV2(number: $number) {
118+
id
119+
fields(first: 100) {
120+
nodes {
121+
... on ProjectV2FieldCommon {
122+
id
123+
name
124+
dataType
125+
}
126+
... on ProjectV2SingleSelectField {
127+
options {
128+
id
129+
name
130+
}
131+
}
132+
}
133+
}
134+
}
135+
}
136+
}
137+
"""
138+
139+
140+
@memoize_timed(minutes=30)
141+
def get_project_metadata(project: GhProject) -> GhPrMetaDict:
142+
# Find the project metadata.
143+
variables = {"orgname": project[0], "number": project[1]}
144+
data: GhPrMetaDict = graphql_query(query=ORG_PROJECT_METADATA, variables=variables)
145+
return glom(data, {"id": "organization.projectV2.id", "fields": "organization.projectV2.fields.nodes"})
146+
147+
148+
UPDATE_PROJECT_ITEM = """\
149+
mutation UpdateProjectItem (
150+
$projectId: ID!
151+
$itemId: ID!
152+
$fieldId: ID!
153+
$value: {fieldType}
154+
) {{
155+
updateProjectV2ItemFieldValue (
156+
input: {{
157+
projectId: $projectId,
158+
itemId: $itemId,
159+
fieldId: $fieldId,
160+
value: {{
161+
{fieldTypeName}: $value,
162+
}}
163+
}}) {{
164+
projectV2Item {{
165+
id
166+
}}
167+
}}
168+
}}
169+
"""
170+
171+
172+
def update_project_pr_custom_field(field_name: str, field_value, item_id: str, project: GhProject) -> None:
173+
"""Add a pull request to a project.
174+
175+
The project is a tuple: (orgname, number)
176+
"""
177+
logger.info(f"Updating project {project} field {field_name} to {field_value}")
178+
179+
project_metadata = get_project_metadata(project)
180+
target_field = None
181+
for field in project_metadata["fields"]:
182+
if field["name"] == field_name:
183+
target_field = field
184+
break
185+
else:
186+
logger.error(f"Could not find field with name: {field_name} in project: {project}")
187+
return
188+
189+
# Update field value
190+
variables = {
191+
"projectId": project_metadata["id"],
192+
"fieldId": target_field["id"],
193+
"itemId": item_id,
194+
"value": field_value,
195+
}
196+
field_type_name = target_field["dataType"].lower()
197+
field_type = value_graphql_type(field_type_name)
198+
graphql_query(
199+
query=UPDATE_PROJECT_ITEM.format(fieldType=field_type, fieldTypeName=field_type_name),
200+
variables=variables
201+
)

openedx_webhooks/info.py

+36-1
Original file line numberDiff line numberDiff line change
@@ -1,11 +1,12 @@
11
"""
22
Get information about people, repos, orgs, pull requests, etc.
33
"""
4+
from collections import namedtuple
45
import csv
56
import fnmatch
67
import logging
78
import re
8-
from typing import Dict, Iterable, Optional
9+
from typing import Dict, Iterable, Literal, Optional
910

1011
import yaml
1112
from glom import glom
@@ -301,12 +302,46 @@ def get_bot_comments(prid: PrId) -> Iterable[PrCommentDict]:
301302
yield comment
302303

303304

305+
@memoize_timed(minutes=15)
304306
def get_catalog_info(repo_fullname: str) -> Dict:
305307
"""Get the parsed catalog-info.yaml data from a repo, or {} if missing."""
306308
yml = read_github_file(repo_fullname, "catalog-info.yaml", not_there="{}")
307309
return yaml.safe_load(yml)
308310

309311

312+
@memoize_timed(minutes=60)
313+
def get_github_user_info(username: str) -> Dict | None:
314+
"""Get github user information"""
315+
resp = get_github_session().get(f"/users/{username}")
316+
if resp.ok:
317+
return resp.json()
318+
logger.error(f"Could not find user information for user: {username} on github")
319+
return None
320+
321+
322+
Lifecycle = Literal["experimental", "production", "deprecated"]
323+
RepoSpec: (str | None, Lifecycle | None, bool) = namedtuple('RepoSpec', ['owner', 'lifecycle', 'is_owner_individual'])
324+
325+
326+
def get_repo_spec(repo_full_name: str) -> RepoSpec:
327+
"""
328+
Get the owner of the repo from its catalog-info.yaml file.
329+
"""
330+
catalog_info = get_catalog_info(repo_full_name)
331+
if not catalog_info:
332+
return RepoSpec(None, None, False)
333+
owner = catalog_info["spec"].get("owner", "")
334+
owner_type = None
335+
is_owner_individual = False
336+
if ":" in owner:
337+
owner_type, owner = owner.split(":")
338+
if owner_type == "group":
339+
owner = f"openedx/{owner}"
340+
elif owner_type == "user":
341+
is_owner_individual = True
342+
return RepoSpec(owner, catalog_info["spec"]["lifecycle"], is_owner_individual)
343+
344+
310345
def projects_for_pr(pull_request: PrDict) -> Iterable[GhProject]:
311346
"""
312347
Get the projects a pull request should be added to.

openedx_webhooks/tasks/pr_tracking.py

+48-5
Original file line numberDiff line numberDiff line change
@@ -39,12 +39,15 @@
3939
from openedx_webhooks.gh_projects import (
4040
add_pull_request_to_project,
4141
pull_request_projects,
42+
update_project_pr_custom_field,
4243
)
4344
from openedx_webhooks.info import (
4445
NoJiraMapping,
4546
NoJiraServer,
4647
get_blended_project_id,
4748
get_bot_comments,
49+
get_github_user_info,
50+
get_repo_spec,
4851
is_bot_pull_request,
4952
is_draft_pull_request,
5053
is_internal_pull_request,
@@ -402,10 +405,40 @@ def _fix_ospr(self) -> None:
402405
self._fix_comments()
403406

404407
# Check the GitHub projects.
408+
self._fix_projects()
409+
410+
def _fix_projects(self) -> None:
411+
"""
412+
Update projects for pr.
413+
"""
405414
for project in (self.desired.github_projects - self.current.github_projects):
406-
self.actions.add_pull_request_to_project(
415+
project_item_id = self.actions.add_pull_request_to_project(
407416
pr_node_id=self.pr["node_id"], project=project
408417
)
418+
if not project_item_id:
419+
continue
420+
self.actions.update_project_pr_custom_field(
421+
field_name="Date opened",
422+
field_value=self.pr["created_at"],
423+
item_id=project_item_id,
424+
project=project
425+
)
426+
# get base repo owner info
427+
repo_spec = get_repo_spec(self.pr["base"]["repo"]["full_name"])
428+
owner = repo_spec.owner
429+
if not owner:
430+
continue
431+
# get user info if owner is an individual
432+
if repo_spec.is_owner_individual:
433+
owner_info = get_github_user_info(owner)
434+
if owner_info:
435+
owner = f"{owner_info['name']} (@{owner})"
436+
self.actions.update_project_pr_custom_field(
437+
field_name="Repo Owner / Owning Team",
438+
field_value=owner,
439+
item_id=project_item_id,
440+
project=project
441+
)
409442

410443
def _make_jira_issue(self, jira_nick) -> None:
411444
"""
@@ -659,14 +692,24 @@ def update_labels_on_pull_request(self, *, labels: List[str]) -> None:
659692
resp = get_github_session().patch(url, json={"labels": labels})
660693
log_check_response(resp)
661694

662-
def add_pull_request_to_project(self, *, pr_node_id: str, project: GhProject) -> None:
695+
def add_pull_request_to_project(self, *, pr_node_id: str, project: GhProject) -> str | None:
663696
"""
664697
Add a pull request to a project.
665698
"""
666699
try:
667-
add_pull_request_to_project(self.prid, pr_node_id, project)
668-
except Exception as exc: # pylint: disable=broad-exception-caught
669-
logger.exception(f"Couldn't add PR to project: {exc}")
700+
return add_pull_request_to_project(self.prid, pr_node_id, project)
701+
except Exception: # pylint: disable=broad-exception-caught
702+
logger.exception("Couldn't add PR to project")
703+
return None
704+
705+
def update_project_pr_custom_field(self, *, field_name: str, field_value, item_id: str, project: GhProject) -> None:
706+
"""
707+
Add a pull request to a project.
708+
"""
709+
try:
710+
update_project_pr_custom_field(field_name, field_value, item_id, project)
711+
except Exception: # pylint: disable=broad-exception-caught
712+
logger.exception(f"Couldn't update: {field_name} for a PR in project")
670713

671714
def set_cla_status(self, *, status: Dict[str, str]) -> None:
672715
set_cla_status_on_pr(self.prid.full_name, self.prid.number, status)

openedx_webhooks/types.py

+3
Original file line numberDiff line numberDiff line change
@@ -17,6 +17,9 @@
1717
# A GitHub project: org name, and number.
1818
GhProject = Tuple[str, int]
1919

20+
# A GitHub project metadata json object.
21+
GhPrMetaDict = Dict
22+
2023

2124
@dataclasses.dataclass(frozen=True)
2225
class PrId:

openedx_webhooks/utils.py

+9
Original file line numberDiff line numberDiff line change
@@ -228,6 +228,15 @@ def jira_paginated_get(url, session=None,
228228
more_results = True # just keep going until there are no more results.
229229

230230

231+
def value_graphql_type(field_type: str) -> str:
232+
if field_type == "date":
233+
return "Date"
234+
elif field_type == "number":
235+
return "Float"
236+
else:
237+
return "String"
238+
239+
231240
def graphql_query(query: str, variables: Dict = {}) -> Dict: # pylint: disable=dangerous-default-value
232241
"""
233242
Make a GraphQL query against GitHub.

0 commit comments

Comments
 (0)