import logging import random import sys import time from pathlib import Path from typing import Any, cast import httpx from github import Github from pydantic import BaseModel, SecretStr from pydantic_settings import BaseSettings awaiting_label = "awaiting-review" lang_all_label = "lang-all" approved_label = "approved-1" 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): model_config = {"env_ignore_empty": True} github_repository: str github_token: SecretStr github_event_path: Path github_event_name: str | None = None httpx_timeout: int = 30 debug: bool | None = False number: int | None = None class PartialGitHubEventIssue(BaseModel): number: int | None = None class PartialGitHubEvent(BaseModel): pull_request: PartialGitHubEventIssue | None = None def get_graphql_response( *, settings: Settings, query: str, after: str | None = None, category_id: str | None = None, discussion_number: int | None = None, discussion_id: str | None = None, comment_id: str | None = None, body: str | None = None, ) -> dict[str, Any]: headers = {"Authorization": f"token {settings.github_token.get_secret_value()}"} 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(data["errors"]) logging.error(response.text) raise RuntimeError(response.text) return cast(dict[str, Any], data) def get_graphql_translation_discussions( *, settings: Settings ) -> list[AllDiscussionsDiscussionNode]: data = get_graphql_response( settings=settings, query=all_discussions_query, category_id=questions_translations_category_id, ) graphql_response = AllDiscussionsResponse.model_validate(data) return graphql_response.data.repository.discussions.nodes def get_graphql_translation_discussion_comments_edges( *, settings: Settings, discussion_number: int, after: str | None = None ) -> list[CommentsEdge]: data = get_graphql_response( settings=settings, query=translation_discussion_query, discussion_number=discussion_number, after=after, ) graphql_response = CommentsResponse.model_validate(data) return graphql_response.data.repository.discussion.comments.edges def get_graphql_translation_discussion_comments( *, settings: Settings, discussion_number: int ) -> list[Comment]: 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) -> Comment: data = get_graphql_response( settings=settings, query=add_comment_mutation, discussion_id=discussion_id, body=body, ) response = AddCommentResponse.model_validate(data) return response.data.addDiscussionComment.comment def update_comment(*, settings: Settings, comment_id: str, body: str) -> Comment: data = get_graphql_response( settings=settings, query=update_comment_mutation, comment_id=comment_id, body=body, ) response = UpdateCommentResponse.model_validate(data) return response.data.updateDiscussionComment.comment def main() -> None: settings = Settings() if settings.debug: logging.basicConfig(level=logging.DEBUG) else: logging.basicConfig(level=logging.INFO) logging.debug(f"Using config: {settings.model_dump_json()}") g = Github(settings.github_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("utf-8") github_event = PartialGitHubEvent.model_validate_json(contents) logging.info(f"Using GitHub event: {github_event}") number = ( github_event.pull_request and github_event.pull_request.number ) or settings.number if number is None: raise RuntimeError("No PR number available") # 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: #{number}") pr = repo.get_pull(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: Comment | None = None already_done_comment: 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") if __name__ == "__main__": main()