pythonasyncioapiasyncfastapiframeworkjsonjson-schemaopenapiopenapi3pydanticpython-typespython3redocreststarletteswaggerswagger-uiuvicornweb
You can not select more than 25 topics
Topics must start with a letter or number, can include dashes ('-') and can be up to 35 characters long.
417 lines
12 KiB
417 lines
12 KiB
import logging
|
|
import random
|
|
import sys
|
|
import time
|
|
from pathlib import Path
|
|
from typing import Any, Dict, List, Union, cast
|
|
|
|
import httpx
|
|
from github import Github
|
|
from pydantic import BaseModel, BaseSettings, SecretStr
|
|
|
|
awaiting_label = "awaiting-review"
|
|
lang_all_label = "lang-all"
|
|
approved_label = "approved-1"
|
|
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: "fastapi") {
|
|
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: "fastapi") {
|
|
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):
|
|
github_repository: str
|
|
input_token: SecretStr
|
|
github_event_path: Path
|
|
github_event_name: Union[str, None] = None
|
|
httpx_timeout: int = 30
|
|
input_debug: Union[bool, None] = False
|
|
|
|
|
|
class PartialGitHubEventIssue(BaseModel):
|
|
number: int
|
|
|
|
|
|
class PartialGitHubEvent(BaseModel):
|
|
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__":
|
|
settings = Settings()
|
|
if settings.input_debug:
|
|
logging.basicConfig(level=logging.DEBUG)
|
|
else:
|
|
logging.basicConfig(level=logging.INFO)
|
|
logging.debug(f"Using config: {settings.json()}")
|
|
g = Github(settings.input_token.get_secret_value())
|
|
repo = g.get_repo(settings.github_repository)
|
|
if not settings.github_event_path.is_file():
|
|
raise RuntimeError(
|
|
f"No github event file available at: {settings.github_event_path}"
|
|
)
|
|
contents = settings.github_event_path.read_text()
|
|
github_event = PartialGitHubEvent.parse_raw(contents)
|
|
|
|
# Avoid race conditions with multiple labels
|
|
sleep_time = random.random() * 10 # random number between 0 and 10 seconds
|
|
logging.info(
|
|
f"Sleeping for {sleep_time} seconds to avoid "
|
|
"race conditions and multiple comments"
|
|
)
|
|
time.sleep(sleep_time)
|
|
|
|
# Get PR
|
|
logging.debug(f"Processing PR: #{github_event.pull_request.number}")
|
|
pr = repo.get_pull(github_event.pull_request.number)
|
|
label_strs = {label.name for label in pr.get_labels()}
|
|
langs = []
|
|
for label in label_strs:
|
|
if label.startswith("lang-") and not label == lang_all_label:
|
|
langs.append(label[5:])
|
|
logging.info(f"PR #{pr.number} has labels: {label_strs}")
|
|
if not langs or lang_all_label not in label_strs:
|
|
logging.info(f"PR #{pr.number} doesn't seem to be a translation PR, skipping")
|
|
sys.exit(0)
|
|
|
|
# Generate translation map, lang ID to discussion
|
|
discussions = get_graphql_translation_discussions(settings=settings)
|
|
lang_to_discussion_map: Dict[str, AllDiscussionsDiscussionNode] = {}
|
|
for discussion in discussions:
|
|
for edge in discussion.labels.edges:
|
|
label = edge.node.name
|
|
if label.startswith("lang-") and not label == lang_all_label:
|
|
lang = label[5:]
|
|
lang_to_discussion_map[lang] = discussion
|
|
logging.debug(f"Using translations map: {lang_to_discussion_map}")
|
|
|
|
# Messages to create or check
|
|
new_translation_message = f"Good news everyone! 😉 There's a new translation PR to be reviewed: #{pr.number} by @{pr.user.login}. 🎉 This requires 2 approvals from native speakers to be merged. 🤓"
|
|
done_translation_message = f"~There's a new translation PR to be reviewed: #{pr.number} by @{pr.user.login}~ Good job! This is done. 🍰☕"
|
|
|
|
# Normally only one language, but still
|
|
for lang in langs:
|
|
if lang not in lang_to_discussion_map:
|
|
log_message = f"Could not find discussion for language: {lang}"
|
|
logging.error(log_message)
|
|
raise RuntimeError(log_message)
|
|
discussion = lang_to_discussion_map[lang]
|
|
logging.info(
|
|
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")
|
|
|