👷 Update translations bot to use Discussions, and notify when a PR is done (#9183)

This commit is contained in:
Sebastián Ramírez 2023-03-04 11:39:28 +01:00 committed by GitHub
parent 4b95025d44
commit bd219c2bbf
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23
3 changed files with 368 additions and 75 deletions

View File

@ -1,10 +1,11 @@
import logging import logging
import random import random
import sys
import time import time
from pathlib import Path from pathlib import Path
from typing import Dict, Union from typing import Any, Dict, List, Union, cast
import yaml import httpx
from github import Github from github import Github
from pydantic import BaseModel, BaseSettings, SecretStr from pydantic import BaseModel, BaseSettings, SecretStr
@ -13,12 +14,172 @@ lang_all_label = "lang-all"
approved_label = "approved-2" approved_label = "approved-2"
translations_path = Path(__file__).parent / "translations.yml" translations_path = Path(__file__).parent / "translations.yml"
github_graphql_url = "https://api.github.com/graphql"
questions_translations_category_id = "DIC_kwDOCZduT84CT5P9"
all_discussions_query = """
query Q($category_id: ID) {
repository(name: "fastapi", owner: "tiangolo") {
discussions(categoryId: $category_id, first: 100) {
nodes {
title
id
number
labels(first: 10) {
edges {
node {
id
name
}
}
}
}
}
}
}
"""
translation_discussion_query = """
query Q($after: String, $discussion_number: Int!) {
repository(name: "fastapi", owner: "tiangolo") {
discussion(number: $discussion_number) {
comments(first: 100, after: $after) {
edges {
cursor
node {
id
url
body
}
}
}
}
}
}
"""
add_comment_mutation = """
mutation Q($discussion_id: ID!, $body: String!) {
addDiscussionComment(input: {discussionId: $discussion_id, body: $body}) {
comment {
id
url
body
}
}
}
"""
update_comment_mutation = """
mutation Q($comment_id: ID!, $body: String!) {
updateDiscussionComment(input: {commentId: $comment_id, body: $body}) {
comment {
id
url
body
}
}
}
"""
class Comment(BaseModel):
id: str
url: str
body: str
class UpdateDiscussionComment(BaseModel):
comment: Comment
class UpdateCommentData(BaseModel):
updateDiscussionComment: UpdateDiscussionComment
class UpdateCommentResponse(BaseModel):
data: UpdateCommentData
class AddDiscussionComment(BaseModel):
comment: Comment
class AddCommentData(BaseModel):
addDiscussionComment: AddDiscussionComment
class AddCommentResponse(BaseModel):
data: AddCommentData
class CommentsEdge(BaseModel):
node: Comment
cursor: str
class Comments(BaseModel):
edges: List[CommentsEdge]
class CommentsDiscussion(BaseModel):
comments: Comments
class CommentsRepository(BaseModel):
discussion: CommentsDiscussion
class CommentsData(BaseModel):
repository: CommentsRepository
class CommentsResponse(BaseModel):
data: CommentsData
class AllDiscussionsLabelNode(BaseModel):
id: str
name: str
class AllDiscussionsLabelsEdge(BaseModel):
node: AllDiscussionsLabelNode
class AllDiscussionsDiscussionLabels(BaseModel):
edges: List[AllDiscussionsLabelsEdge]
class AllDiscussionsDiscussionNode(BaseModel):
title: str
id: str
number: int
labels: AllDiscussionsDiscussionLabels
class AllDiscussionsDiscussions(BaseModel):
nodes: List[AllDiscussionsDiscussionNode]
class AllDiscussionsRepository(BaseModel):
discussions: AllDiscussionsDiscussions
class AllDiscussionsData(BaseModel):
repository: AllDiscussionsRepository
class AllDiscussionsResponse(BaseModel):
data: AllDiscussionsData
class Settings(BaseSettings): class Settings(BaseSettings):
github_repository: str github_repository: str
input_token: SecretStr input_token: SecretStr
github_event_path: Path github_event_path: Path
github_event_name: Union[str, None] = None github_event_name: Union[str, None] = None
httpx_timeout: int = 30
input_debug: Union[bool, None] = False input_debug: Union[bool, None] = False
@ -30,6 +191,113 @@ class PartialGitHubEvent(BaseModel):
pull_request: PartialGitHubEventIssue pull_request: PartialGitHubEventIssue
def get_graphql_response(
*,
settings: Settings,
query: str,
after: Union[str, None] = None,
category_id: Union[str, None] = None,
discussion_number: Union[int, None] = None,
discussion_id: Union[str, None] = None,
comment_id: Union[str, None] = None,
body: Union[str, None] = None,
) -> Dict[str, Any]:
headers = {"Authorization": f"token {settings.input_token.get_secret_value()}"}
# some fields are only used by one query, but GraphQL allows unused variables, so
# keep them here for simplicity
variables = {
"after": after,
"category_id": category_id,
"discussion_number": discussion_number,
"discussion_id": discussion_id,
"comment_id": comment_id,
"body": body,
}
response = httpx.post(
github_graphql_url,
headers=headers,
timeout=settings.httpx_timeout,
json={"query": query, "variables": variables, "operationName": "Q"},
)
if response.status_code != 200:
logging.error(
f"Response was not 200, after: {after}, category_id: {category_id}"
)
logging.error(response.text)
raise RuntimeError(response.text)
data = response.json()
if "errors" in data:
logging.error(f"Errors in response, after: {after}, category_id: {category_id}")
logging.error(response.text)
raise RuntimeError(response.text)
return cast(Dict[str, Any], data)
def get_graphql_translation_discussions(*, settings: Settings):
data = get_graphql_response(
settings=settings,
query=all_discussions_query,
category_id=questions_translations_category_id,
)
graphql_response = AllDiscussionsResponse.parse_obj(data)
return graphql_response.data.repository.discussions.nodes
def get_graphql_translation_discussion_comments_edges(
*, settings: Settings, discussion_number: int, after: Union[str, None] = None
):
data = get_graphql_response(
settings=settings,
query=translation_discussion_query,
discussion_number=discussion_number,
after=after,
)
graphql_response = CommentsResponse.parse_obj(data)
return graphql_response.data.repository.discussion.comments.edges
def get_graphql_translation_discussion_comments(
*, settings: Settings, discussion_number: int
):
comment_nodes: List[Comment] = []
discussion_edges = get_graphql_translation_discussion_comments_edges(
settings=settings, discussion_number=discussion_number
)
while discussion_edges:
for discussion_edge in discussion_edges:
comment_nodes.append(discussion_edge.node)
last_edge = discussion_edges[-1]
discussion_edges = get_graphql_translation_discussion_comments_edges(
settings=settings,
discussion_number=discussion_number,
after=last_edge.cursor,
)
return comment_nodes
def create_comment(*, settings: Settings, discussion_id: str, body: str):
data = get_graphql_response(
settings=settings,
query=add_comment_mutation,
discussion_id=discussion_id,
body=body,
)
response = AddCommentResponse.parse_obj(data)
return response.data.addDiscussionComment.comment
def update_comment(*, settings: Settings, comment_id: str, body: str):
data = get_graphql_response(
settings=settings,
query=update_comment_mutation,
comment_id=comment_id,
body=body,
)
response = UpdateCommentResponse.parse_obj(data)
return response.data.updateDiscussionComment.comment
if __name__ == "__main__": if __name__ == "__main__":
settings = Settings() settings = Settings()
if settings.input_debug: if settings.input_debug:
@ -45,60 +313,105 @@ if __name__ == "__main__":
) )
contents = settings.github_event_path.read_text() contents = settings.github_event_path.read_text()
github_event = PartialGitHubEvent.parse_raw(contents) github_event = PartialGitHubEvent.parse_raw(contents)
translations_map: Dict[str, int] = yaml.safe_load(translations_path.read_text())
logging.debug(f"Using translations map: {translations_map}") # Avoid race conditions with multiple labels
sleep_time = random.random() * 10 # random number between 0 and 10 seconds sleep_time = random.random() * 10 # random number between 0 and 10 seconds
pr = repo.get_pull(github_event.pull_request.number) logging.info(
logging.debug( f"Sleeping for {sleep_time} seconds to avoid "
f"Processing PR: {pr.number}, with anti-race condition sleep time: {sleep_time}" "race conditions and multiple comments"
) )
if pr.state == "open": time.sleep(sleep_time)
logging.debug(f"PR is open: {pr.number}")
label_strs = {label.name for label in pr.get_labels()} # Get PR
if lang_all_label in label_strs and awaiting_label in label_strs: logging.debug(f"Processing PR: #{github_event.pull_request.number}")
logging.info( pr = repo.get_pull(github_event.pull_request.number)
f"This PR seems to be a language translation and awaiting reviews: {pr.number}" label_strs = {label.name for label in pr.get_labels()}
) langs = []
if approved_label in label_strs: for label in label_strs:
message = ( if label.startswith("lang-") and not label == lang_all_label:
f"It seems this PR already has the approved label: {pr.number}" langs.append(label[5:])
) logging.info(f"PR #{pr.number} has labels: {label_strs}")
logging.error(message) if not langs or lang_all_label not in label_strs:
raise RuntimeError(message) logging.info(f"PR #{pr.number} doesn't seem to be a translation PR, skipping")
langs = [] sys.exit(0)
for label in label_strs:
if label.startswith("lang-") and not label == lang_all_label: # Generate translation map, lang ID to discussion
langs.append(label[5:]) discussions = get_graphql_translation_discussions(settings=settings)
for lang in langs: lang_to_discussion_map: Dict[str, AllDiscussionsDiscussionNode] = {}
if lang in translations_map: for discussion in discussions:
num = translations_map[lang] for edge in discussion.labels.edges:
logging.info( label = edge.node.name
f"Found a translation issue for language: {lang} in issue: {num}" if label.startswith("lang-") and not label == lang_all_label:
) lang = label[5:]
issue = repo.get_issue(num) lang_to_discussion_map[lang] = discussion
message = f"Good news everyone! 😉 There's a new translation PR to be reviewed: #{pr.number} 🎉" logging.debug(f"Using translations map: {lang_to_discussion_map}")
already_notified = False
time.sleep(sleep_time) # Messages to create or check
logging.info( new_translation_message = f"Good news everyone! 😉 There's a new translation PR to be reviewed: #{pr.number} by @{pr.user.login} 🎉"
f"Sleeping for {sleep_time} seconds to avoid race conditions and multiple comments" done_translation_message = f"Good news everyone! 😉 ~There's a new translation PR to be reviewed: #{pr.number} by @{pr.user.login}~ 🎉 Good job! This is done. 🍰"
)
logging.info( # Normally only one language, but still
f"Checking current comments in issue: {num} to see if already notified about this PR: {pr.number}" for lang in langs:
) if lang not in lang_to_discussion_map:
for comment in issue.get_comments(): log_message = f"Could not find discussion for language: {lang}"
if message in comment.body: logging.error(log_message)
already_notified = True raise RuntimeError(log_message)
if not already_notified: discussion = lang_to_discussion_map[lang]
logging.info(
f"Writing comment in issue: {num} about PR: {pr.number}"
)
issue.create_comment(message)
else:
logging.info(
f"Issue: {num} was already notified of PR: {pr.number}"
)
else:
logging.info( logging.info(
f"Changing labels in a closed PR doesn't trigger comments, PR: {pr.number}" f"Found a translation discussion for language: {lang} in discussion: #{discussion.number}"
) )
already_notified_comment: Union[Comment, None] = None
already_done_comment: Union[Comment, None] = None
logging.info(
f"Checking current comments in discussion: #{discussion.number} to see if already notified about this PR: #{pr.number}"
)
comments = get_graphql_translation_discussion_comments(
settings=settings, discussion_number=discussion.number
)
for comment in comments:
if new_translation_message in comment.body:
already_notified_comment = comment
elif done_translation_message in comment.body:
already_done_comment = comment
logging.info(
f"Already notified comment: {already_notified_comment}, already done comment: {already_done_comment}"
)
if pr.state == "open" and awaiting_label in label_strs:
logging.info(
f"This PR seems to be a language translation and awaiting reviews: #{pr.number}"
)
if already_notified_comment:
logging.info(
f"This PR #{pr.number} was already notified in comment: {already_notified_comment.url}"
)
else:
logging.info(
f"Writing notification comment about PR #{pr.number} in Discussion: #{discussion.number}"
)
comment = create_comment(
settings=settings,
discussion_id=discussion.id,
body=new_translation_message,
)
logging.info(f"Notified in comment: {comment.url}")
elif pr.state == "closed" or approved_label in label_strs:
logging.info(f"Already approved or closed PR #{pr.number}")
if already_done_comment:
logging.info(
f"This PR #{pr.number} was already marked as done in comment: {already_done_comment.url}"
)
elif already_notified_comment:
updated_comment = update_comment(
settings=settings,
comment_id=already_notified_comment.id,
body=done_translation_message,
)
logging.info(f"Marked as done in comment: {updated_comment.url}")
else:
logging.info(
f"There doesn't seem to be anything to be done about PR #{pr.number}"
)
logging.info("Finished") logging.info("Finished")

View File

@ -1,21 +0,0 @@
pt: 1211
es: 1218
zh: 1228
ru: 1362
it: 1556
ja: 1572
uk: 1748
tr: 1892
fr: 1972
ko: 2017
fa: 2041
pl: 3169
de: 3716
id: 3717
az: 3994
nl: 4701
uz: 4883
sv: 5146
he: 5157
ta: 5434
ar: 3349

View File

@ -4,6 +4,7 @@ on:
pull_request_target: pull_request_target:
types: types:
- labeled - labeled
- closed
jobs: jobs:
notify-translations: notify-translations: