fastapi
417 строк · 12.4 Кб
1import logging2import random3import sys4import time5from pathlib import Path6from typing import Any, Dict, List, Union, cast7
8import httpx9from github import Github10from pydantic import BaseModel, BaseSettings, SecretStr11
12awaiting_label = "awaiting-review"13lang_all_label = "lang-all"14approved_label = "approved-2"15translations_path = Path(__file__).parent / "translations.yml"16
17github_graphql_url = "https://api.github.com/graphql"18questions_translations_category_id = "DIC_kwDOCZduT84CT5P9"19
20all_discussions_query = """21query Q($category_id: ID) {
22repository(name: "fastapi", owner: "tiangolo") {
23discussions(categoryId: $category_id, first: 100) {
24nodes {
25title
26id
27number
28labels(first: 10) {
29edges {
30node {
31id
32name
33}
34}
35}
36}
37}
38}
39}
40"""
41
42translation_discussion_query = """43query Q($after: String, $discussion_number: Int!) {
44repository(name: "fastapi", owner: "tiangolo") {
45discussion(number: $discussion_number) {
46comments(first: 100, after: $after) {
47edges {
48cursor
49node {
50id
51url
52body
53}
54}
55}
56}
57}
58}
59"""
60
61add_comment_mutation = """62mutation Q($discussion_id: ID!, $body: String!) {
63addDiscussionComment(input: {discussionId: $discussion_id, body: $body}) {
64comment {
65id
66url
67body
68}
69}
70}
71"""
72
73update_comment_mutation = """74mutation Q($comment_id: ID!, $body: String!) {
75updateDiscussionComment(input: {commentId: $comment_id, body: $body}) {
76comment {
77id
78url
79body
80}
81}
82}
83"""
84
85
86class Comment(BaseModel):87id: str88url: str89body: str90
91
92class UpdateDiscussionComment(BaseModel):93comment: Comment94
95
96class UpdateCommentData(BaseModel):97updateDiscussionComment: UpdateDiscussionComment98
99
100class UpdateCommentResponse(BaseModel):101data: UpdateCommentData102
103
104class AddDiscussionComment(BaseModel):105comment: Comment106
107
108class AddCommentData(BaseModel):109addDiscussionComment: AddDiscussionComment110
111
112class AddCommentResponse(BaseModel):113data: AddCommentData114
115
116class CommentsEdge(BaseModel):117node: Comment118cursor: str119
120
121class Comments(BaseModel):122edges: List[CommentsEdge]123
124
125class CommentsDiscussion(BaseModel):126comments: Comments127
128
129class CommentsRepository(BaseModel):130discussion: CommentsDiscussion131
132
133class CommentsData(BaseModel):134repository: CommentsRepository135
136
137class CommentsResponse(BaseModel):138data: CommentsData139
140
141class AllDiscussionsLabelNode(BaseModel):142id: str143name: str144
145
146class AllDiscussionsLabelsEdge(BaseModel):147node: AllDiscussionsLabelNode148
149
150class AllDiscussionsDiscussionLabels(BaseModel):151edges: List[AllDiscussionsLabelsEdge]152
153
154class AllDiscussionsDiscussionNode(BaseModel):155title: str156id: str157number: int158labels: AllDiscussionsDiscussionLabels159
160
161class AllDiscussionsDiscussions(BaseModel):162nodes: List[AllDiscussionsDiscussionNode]163
164
165class AllDiscussionsRepository(BaseModel):166discussions: AllDiscussionsDiscussions167
168
169class AllDiscussionsData(BaseModel):170repository: AllDiscussionsRepository171
172
173class AllDiscussionsResponse(BaseModel):174data: AllDiscussionsData175
176
177class Settings(BaseSettings):178github_repository: str179input_token: SecretStr180github_event_path: Path181github_event_name: Union[str, None] = None182httpx_timeout: int = 30183input_debug: Union[bool, None] = False184
185
186class PartialGitHubEventIssue(BaseModel):187number: int188
189
190class PartialGitHubEvent(BaseModel):191pull_request: PartialGitHubEventIssue192
193
194def get_graphql_response(195*,196settings: Settings,197query: str,198after: Union[str, None] = None,199category_id: Union[str, None] = None,200discussion_number: Union[int, None] = None,201discussion_id: Union[str, None] = None,202comment_id: Union[str, None] = None,203body: Union[str, None] = None,204) -> Dict[str, Any]:205headers = {"Authorization": f"token {settings.input_token.get_secret_value()}"}206# some fields are only used by one query, but GraphQL allows unused variables, so207# keep them here for simplicity208variables = {209"after": after,210"category_id": category_id,211"discussion_number": discussion_number,212"discussion_id": discussion_id,213"comment_id": comment_id,214"body": body,215}216response = httpx.post(217github_graphql_url,218headers=headers,219timeout=settings.httpx_timeout,220json={"query": query, "variables": variables, "operationName": "Q"},221)222if response.status_code != 200:223logging.error(224f"Response was not 200, after: {after}, category_id: {category_id}"225)226logging.error(response.text)227raise RuntimeError(response.text)228data = response.json()229if "errors" in data:230logging.error(f"Errors in response, after: {after}, category_id: {category_id}")231logging.error(response.text)232raise RuntimeError(response.text)233return cast(Dict[str, Any], data)234
235
236def get_graphql_translation_discussions(*, settings: Settings):237data = get_graphql_response(238settings=settings,239query=all_discussions_query,240category_id=questions_translations_category_id,241)242graphql_response = AllDiscussionsResponse.parse_obj(data)243return graphql_response.data.repository.discussions.nodes244
245
246def get_graphql_translation_discussion_comments_edges(247*, settings: Settings, discussion_number: int, after: Union[str, None] = None248):249data = get_graphql_response(250settings=settings,251query=translation_discussion_query,252discussion_number=discussion_number,253after=after,254)255graphql_response = CommentsResponse.parse_obj(data)256return graphql_response.data.repository.discussion.comments.edges257
258
259def get_graphql_translation_discussion_comments(260*, settings: Settings, discussion_number: int261):262comment_nodes: List[Comment] = []263discussion_edges = get_graphql_translation_discussion_comments_edges(264settings=settings, discussion_number=discussion_number265)266
267while discussion_edges:268for discussion_edge in discussion_edges:269comment_nodes.append(discussion_edge.node)270last_edge = discussion_edges[-1]271discussion_edges = get_graphql_translation_discussion_comments_edges(272settings=settings,273discussion_number=discussion_number,274after=last_edge.cursor,275)276return comment_nodes277
278
279def create_comment(*, settings: Settings, discussion_id: str, body: str):280data = get_graphql_response(281settings=settings,282query=add_comment_mutation,283discussion_id=discussion_id,284body=body,285)286response = AddCommentResponse.parse_obj(data)287return response.data.addDiscussionComment.comment288
289
290def update_comment(*, settings: Settings, comment_id: str, body: str):291data = get_graphql_response(292settings=settings,293query=update_comment_mutation,294comment_id=comment_id,295body=body,296)297response = UpdateCommentResponse.parse_obj(data)298return response.data.updateDiscussionComment.comment299
300
301if __name__ == "__main__":302settings = Settings()303if settings.input_debug:304logging.basicConfig(level=logging.DEBUG)305else:306logging.basicConfig(level=logging.INFO)307logging.debug(f"Using config: {settings.json()}")308g = Github(settings.input_token.get_secret_value())309repo = g.get_repo(settings.github_repository)310if not settings.github_event_path.is_file():311raise RuntimeError(312f"No github event file available at: {settings.github_event_path}"313)314contents = settings.github_event_path.read_text()315github_event = PartialGitHubEvent.parse_raw(contents)316
317# Avoid race conditions with multiple labels318sleep_time = random.random() * 10 # random number between 0 and 10 seconds319logging.info(320f"Sleeping for {sleep_time} seconds to avoid "321"race conditions and multiple comments"322)323time.sleep(sleep_time)324
325# Get PR326logging.debug(f"Processing PR: #{github_event.pull_request.number}")327pr = repo.get_pull(github_event.pull_request.number)328label_strs = {label.name for label in pr.get_labels()}329langs = []330for label in label_strs:331if label.startswith("lang-") and not label == lang_all_label:332langs.append(label[5:])333logging.info(f"PR #{pr.number} has labels: {label_strs}")334if not langs or lang_all_label not in label_strs:335logging.info(f"PR #{pr.number} doesn't seem to be a translation PR, skipping")336sys.exit(0)337
338# Generate translation map, lang ID to discussion339discussions = get_graphql_translation_discussions(settings=settings)340lang_to_discussion_map: Dict[str, AllDiscussionsDiscussionNode] = {}341for discussion in discussions:342for edge in discussion.labels.edges:343label = edge.node.name344if label.startswith("lang-") and not label == lang_all_label:345lang = label[5:]346lang_to_discussion_map[lang] = discussion347logging.debug(f"Using translations map: {lang_to_discussion_map}")348
349# Messages to create or check350new_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. 🤓"351done_translation_message = f"~There's a new translation PR to be reviewed: #{pr.number} by @{pr.user.login}~ Good job! This is done. 🍰☕"352
353# Normally only one language, but still354for lang in langs:355if lang not in lang_to_discussion_map:356log_message = f"Could not find discussion for language: {lang}"357logging.error(log_message)358raise RuntimeError(log_message)359discussion = lang_to_discussion_map[lang]360logging.info(361f"Found a translation discussion for language: {lang} in discussion: #{discussion.number}"362)363
364already_notified_comment: Union[Comment, None] = None365already_done_comment: Union[Comment, None] = None366
367logging.info(368f"Checking current comments in discussion: #{discussion.number} to see if already notified about this PR: #{pr.number}"369)370comments = get_graphql_translation_discussion_comments(371settings=settings, discussion_number=discussion.number372)373for comment in comments:374if new_translation_message in comment.body:375already_notified_comment = comment376elif done_translation_message in comment.body:377already_done_comment = comment378logging.info(379f"Already notified comment: {already_notified_comment}, already done comment: {already_done_comment}"380)381
382if pr.state == "open" and awaiting_label in label_strs:383logging.info(384f"This PR seems to be a language translation and awaiting reviews: #{pr.number}"385)386if already_notified_comment:387logging.info(388f"This PR #{pr.number} was already notified in comment: {already_notified_comment.url}"389)390else:391logging.info(392f"Writing notification comment about PR #{pr.number} in Discussion: #{discussion.number}"393)394comment = create_comment(395settings=settings,396discussion_id=discussion.id,397body=new_translation_message,398)399logging.info(f"Notified in comment: {comment.url}")400elif pr.state == "closed" or approved_label in label_strs:401logging.info(f"Already approved or closed PR #{pr.number}")402if already_done_comment:403logging.info(404f"This PR #{pr.number} was already marked as done in comment: {already_done_comment.url}"405)406elif already_notified_comment:407updated_comment = update_comment(408settings=settings,409comment_id=already_notified_comment.id,410body=done_translation_message,411)412logging.info(f"Marked as done in comment: {updated_comment.url}")413else:414logging.info(415f"There doesn't seem to be anything to be done about PR #{pr.number}"416)417logging.info("Finished")418