scripts/notify_translations.py PYTHON 434 lines View on github.com → Search inside
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()

Code quality findings 8

Ensure functions have docstrings for documentation
missing-docstring
def get_graphql_response(
Ensure functions have docstrings for documentation
missing-docstring
def get_graphql_translation_discussions(
Ensure functions have docstrings for documentation
missing-docstring
def get_graphql_translation_discussion_comments_edges(
Ensure functions have docstrings for documentation
missing-docstring
def get_graphql_translation_discussion_comments(
Ensure functions have docstrings for documentation
missing-docstring
def create_comment(*, settings: Settings, discussion_id: str, body: str) -> Comment:
Ensure functions have docstrings for documentation
missing-docstring
def update_comment(*, settings: Settings, comment_id: str, body: str) -> Comment:
Ensure functions have docstrings for documentation
missing-docstring
def main() -> None:
Avoid blocking; use threading.Timer or asyncio.sleep for non-blocking delays
time-sleep
time.sleep(sleep_time)

Get this view in your editor

Same data, no extra tab — call code_get_file + code_get_findings over MCP from Claude/Cursor/Copilot.