Ensure functions have docstrings for documentation
def get_graphql_response(
1import logging2import random3import sys4import time5from pathlib import Path6from typing import Any, cast78import httpx9from github import Github10from pydantic import BaseModel, SecretStr11from pydantic_settings import BaseSettings1213awaiting_label = "awaiting-review"14lang_all_label = "lang-all"15approved_label = "approved-1"161718github_graphql_url = "https://api.github.com/graphql"19questions_translations_category_id = "DIC_kwDOCZduT84CT5P9"2021all_discussions_query = """22query Q($category_id: ID) {23 repository(name: "fastapi", owner: "fastapi") {24 discussions(categoryId: $category_id, first: 100) {25 nodes {26 title27 id28 number29 labels(first: 10) {30 edges {31 node {32 id33 name34 }35 }36 }37 }38 }39 }40}41"""4243translation_discussion_query = """44query Q($after: String, $discussion_number: Int!) {45 repository(name: "fastapi", owner: "fastapi") {46 discussion(number: $discussion_number) {47 comments(first: 100, after: $after) {48 edges {49 cursor50 node {51 id52 url53 body54 }55 }56 }57 }58 }59}60"""6162add_comment_mutation = """63mutation Q($discussion_id: ID!, $body: String!) {64 addDiscussionComment(input: {discussionId: $discussion_id, body: $body}) {65 comment {66 id67 url68 body69 }70 }71}72"""7374update_comment_mutation = """75mutation Q($comment_id: ID!, $body: String!) {76 updateDiscussionComment(input: {commentId: $comment_id, body: $body}) {77 comment {78 id79 url80 body81 }82 }83}84"""858687class Comment(BaseModel):88 id: str89 url: str90 body: str919293class UpdateDiscussionComment(BaseModel):94 comment: Comment959697class UpdateCommentData(BaseModel):98 updateDiscussionComment: UpdateDiscussionComment99100101class UpdateCommentResponse(BaseModel):102 data: UpdateCommentData103104105class AddDiscussionComment(BaseModel):106 comment: Comment107108109class AddCommentData(BaseModel):110 addDiscussionComment: AddDiscussionComment111112113class AddCommentResponse(BaseModel):114 data: AddCommentData115116117class CommentsEdge(BaseModel):118 node: Comment119 cursor: str120121122class Comments(BaseModel):123 edges: list[CommentsEdge]124125126class CommentsDiscussion(BaseModel):127 comments: Comments128129130class CommentsRepository(BaseModel):131 discussion: CommentsDiscussion132133134class CommentsData(BaseModel):135 repository: CommentsRepository136137138class CommentsResponse(BaseModel):139 data: CommentsData140141142class AllDiscussionsLabelNode(BaseModel):143 id: str144 name: str145146147class AllDiscussionsLabelsEdge(BaseModel):148 node: AllDiscussionsLabelNode149150151class AllDiscussionsDiscussionLabels(BaseModel):152 edges: list[AllDiscussionsLabelsEdge]153154155class AllDiscussionsDiscussionNode(BaseModel):156 title: str157 id: str158 number: int159 labels: AllDiscussionsDiscussionLabels160161162class AllDiscussionsDiscussions(BaseModel):163 nodes: list[AllDiscussionsDiscussionNode]164165166class AllDiscussionsRepository(BaseModel):167 discussions: AllDiscussionsDiscussions168169170class AllDiscussionsData(BaseModel):171 repository: AllDiscussionsRepository172173174class AllDiscussionsResponse(BaseModel):175 data: AllDiscussionsData176177178class Settings(BaseSettings):179 model_config = {"env_ignore_empty": True}180181 github_repository: str182 github_token: SecretStr183 github_event_path: Path184 github_event_name: str | None = None185 httpx_timeout: int = 30186 debug: bool | None = False187 number: int | None = None188189190class PartialGitHubEventIssue(BaseModel):191 number: int | None = None192193194class PartialGitHubEvent(BaseModel):195 pull_request: PartialGitHubEventIssue | None = None196197198def get_graphql_response(199 *,200 settings: Settings,201 query: str,202 after: str | None = None,203 category_id: str | None = None,204 discussion_number: int | None = None,205 discussion_id: str | None = None,206 comment_id: str | None = None,207 body: str | None = None,208) -> dict[str, Any]:209 headers = {"Authorization": f"token {settings.github_token.get_secret_value()}"}210 variables = {211 "after": after,212 "category_id": category_id,213 "discussion_number": discussion_number,214 "discussion_id": discussion_id,215 "comment_id": comment_id,216 "body": body,217 }218 response = httpx.post(219 github_graphql_url,220 headers=headers,221 timeout=settings.httpx_timeout,222 json={"query": query, "variables": variables, "operationName": "Q"},223 )224 if response.status_code != 200:225 logging.error(226 f"Response was not 200, after: {after}, category_id: {category_id}"227 )228 logging.error(response.text)229 raise RuntimeError(response.text)230 data = response.json()231 if "errors" in data:232 logging.error(f"Errors in response, after: {after}, category_id: {category_id}")233 logging.error(data["errors"])234 logging.error(response.text)235 raise RuntimeError(response.text)236 return cast(dict[str, Any], data)237238239def get_graphql_translation_discussions(240 *, settings: Settings241) -> list[AllDiscussionsDiscussionNode]:242 data = get_graphql_response(243 settings=settings,244 query=all_discussions_query,245 category_id=questions_translations_category_id,246 )247 graphql_response = AllDiscussionsResponse.model_validate(data)248 return graphql_response.data.repository.discussions.nodes249250251def get_graphql_translation_discussion_comments_edges(252 *, settings: Settings, discussion_number: int, after: str | None = None253) -> list[CommentsEdge]:254 data = get_graphql_response(255 settings=settings,256 query=translation_discussion_query,257 discussion_number=discussion_number,258 after=after,259 )260 graphql_response = CommentsResponse.model_validate(data)261 return graphql_response.data.repository.discussion.comments.edges262263264def get_graphql_translation_discussion_comments(265 *, settings: Settings, discussion_number: int266) -> list[Comment]:267 comment_nodes: list[Comment] = []268 discussion_edges = get_graphql_translation_discussion_comments_edges(269 settings=settings, discussion_number=discussion_number270 )271272 while discussion_edges:273 for discussion_edge in discussion_edges:274 comment_nodes.append(discussion_edge.node)275 last_edge = discussion_edges[-1]276 discussion_edges = get_graphql_translation_discussion_comments_edges(277 settings=settings,278 discussion_number=discussion_number,279 after=last_edge.cursor,280 )281 return comment_nodes282283284def create_comment(*, settings: Settings, discussion_id: str, body: str) -> Comment:285 data = get_graphql_response(286 settings=settings,287 query=add_comment_mutation,288 discussion_id=discussion_id,289 body=body,290 )291 response = AddCommentResponse.model_validate(data)292 return response.data.addDiscussionComment.comment293294295def update_comment(*, settings: Settings, comment_id: str, body: str) -> Comment:296 data = get_graphql_response(297 settings=settings,298 query=update_comment_mutation,299 comment_id=comment_id,300 body=body,301 )302 response = UpdateCommentResponse.model_validate(data)303 return response.data.updateDiscussionComment.comment304305306def main() -> None:307 settings = Settings() # ty: ignore[missing-argument]308 if settings.debug:309 logging.basicConfig(level=logging.DEBUG)310 else:311 logging.basicConfig(level=logging.INFO)312 logging.debug(f"Using config: {settings.model_dump_json()}")313 g = Github(settings.github_token.get_secret_value())314 repo = g.get_repo(settings.github_repository)315 if not settings.github_event_path.is_file():316 raise RuntimeError(317 f"No github event file available at: {settings.github_event_path}"318 )319 contents = settings.github_event_path.read_text("utf-8")320 github_event = PartialGitHubEvent.model_validate_json(contents)321 logging.info(f"Using GitHub event: {github_event}")322 number = (323 github_event.pull_request and github_event.pull_request.number324 ) or settings.number325 if number is None:326 raise RuntimeError("No PR number available")327 number = cast(int, number)328329 # Avoid race conditions with multiple labels330 sleep_time = random.random() * 10 # random number between 0 and 10 seconds331 logging.info(332 f"Sleeping for {sleep_time} seconds to avoid "333 "race conditions and multiple comments"334 )335 time.sleep(sleep_time)336337 # Get PR338 logging.debug(f"Processing PR: #{number}")339 pr = repo.get_pull(number)340 label_strs = {label.name for label in pr.get_labels()}341 langs = []342 for label in label_strs:343 if label.startswith("lang-") and not label == lang_all_label:344 langs.append(label[5:])345 logging.info(f"PR #{pr.number} has labels: {label_strs}")346 if not langs or lang_all_label not in label_strs:347 logging.info(f"PR #{pr.number} doesn't seem to be a translation PR, skipping")348 sys.exit(0)349350 # Generate translation map, lang ID to discussion351 discussions = get_graphql_translation_discussions(settings=settings)352 lang_to_discussion_map: dict[str, AllDiscussionsDiscussionNode] = {}353 for discussion in discussions:354 for edge in discussion.labels.edges:355 label = edge.node.name356 if label.startswith("lang-") and not label == lang_all_label:357 lang = label[5:]358 lang_to_discussion_map[lang] = discussion359 logging.debug(f"Using translations map: {lang_to_discussion_map}")360361 # Messages to create or check362 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. 🤓"363 done_translation_message = f"~There's a new translation PR to be reviewed: #{pr.number} by @{pr.user.login}~ Good job! This is done. 🍰☕"364365 # Normally only one language, but still366 for lang in langs:367 if lang not in lang_to_discussion_map:368 log_message = f"Could not find discussion for language: {lang}"369 logging.error(log_message)370 raise RuntimeError(log_message)371 discussion = lang_to_discussion_map[lang]372 logging.info(373 f"Found a translation discussion for language: {lang} in discussion: #{discussion.number}"374 )375376 already_notified_comment: Comment | None = None377 already_done_comment: Comment | None = None378379 logging.info(380 f"Checking current comments in discussion: #{discussion.number} to see if already notified about this PR: #{pr.number}"381 )382 comments = get_graphql_translation_discussion_comments(383 settings=settings, discussion_number=discussion.number384 )385 for comment in comments:386 if new_translation_message in comment.body:387 already_notified_comment = comment388 elif done_translation_message in comment.body:389 already_done_comment = comment390 logging.info(391 f"Already notified comment: {already_notified_comment}, already done comment: {already_done_comment}"392 )393394 if pr.state == "open" and awaiting_label in label_strs:395 logging.info(396 f"This PR seems to be a language translation and awaiting reviews: #{pr.number}"397 )398 if already_notified_comment:399 logging.info(400 f"This PR #{pr.number} was already notified in comment: {already_notified_comment.url}"401 )402 else:403 logging.info(404 f"Writing notification comment about PR #{pr.number} in Discussion: #{discussion.number}"405 )406 comment = create_comment(407 settings=settings,408 discussion_id=discussion.id,409 body=new_translation_message,410 )411 logging.info(f"Notified in comment: {comment.url}")412 elif pr.state == "closed" or approved_label in label_strs:413 logging.info(f"Already approved or closed PR #{pr.number}")414 if already_done_comment:415 logging.info(416 f"This PR #{pr.number} was already marked as done in comment: {already_done_comment.url}"417 )418 elif already_notified_comment:419 updated_comment = update_comment(420 settings=settings,421 comment_id=already_notified_comment.id,422 body=done_translation_message,423 )424 logging.info(f"Marked as done in comment: {updated_comment.url}")425 else:426 logging.info(427 f"There doesn't seem to be anything to be done about PR #{pr.number}"428 )429 logging.info("Finished")430431432if __name__ == "__main__":433 main()
Same data, no extra tab — call code_get_file + code_get_findings over MCP from Claude/Cursor/Copilot.