From 8972a2d1638ef1ee800579938f57cc005393cab2 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Sebasti=C3=A1n=20Ram=C3=ADrez?= Date: Wed, 21 Oct 2026 12:42:48 +0200 Subject: [PATCH] =?UTF-8?q?=F0=9F=91=B7=20Remove=20translation=20notificat?= =?UTF-8?q?ions=20and=20approval=20labeling=20(#16570)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .github/workflows/label-approved.yml | 50 --- .github/workflows/notify-translations.yml | 64 ---- docs/en/docs/translations.md | 2 - scripts/label_approved.py | 60 --- scripts/notify_translations.py | 433 ---------------------- 5 files changed, 609 deletions(-) delete mode 100644 .github/workflows/label-approved.yml delete mode 100644 .github/workflows/notify-translations.yml delete mode 100644 scripts/label_approved.py delete mode 100644 scripts/notify_translations.py diff --git a/.github/workflows/label-approved.yml b/.github/workflows/label-approved.yml deleted file mode 100644 index 1d5d3d6b75..0000000000 --- a/.github/workflows/label-approved.yml +++ /dev/null @@ -1,50 +0,0 @@ -name: Label Approved - -on: - schedule: - - cron: "0 12 * * *" - workflow_dispatch: - -permissions: {} - -jobs: - label-approved: - if: github.repository_owner == 'fastapi' - runs-on: ubuntu-latest - permissions: - pull-requests: write - timeout-minutes: 7 - steps: - - name: Dump GitHub context - env: - GITHUB_CONTEXT: ${{ toJson(github) }} - run: echo "$GITHUB_CONTEXT" - - uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 - with: - persist-credentials: false - - name: Set up Python - uses: actions/setup-python@5fda3b95a4ea91299a34e894583c3862153e4b97 # v7.0.0 - with: - python-version-file: ".python-version" - - name: Setup uv - uses: astral-sh/setup-uv@c18668ad3cf93ea998bef934396af7bb5c839dc7 # v10.2.0 - with: - version: "latest-known" - enable-cache: true - cache-dependency-glob: | - pyproject.toml - uv.lock - - name: Install GitHub Actions dependencies - run: uv sync --locked --no-dev --group github-actions - - name: Label Approved - run: uv run ./scripts/label_approved.py - env: - TOKEN: ${{ secrets.GITHUB_TOKEN }} - CONFIG: > - { - "approved-1": - { - "number": 1, - "await_label": "awaiting-review" - } - } diff --git a/.github/workflows/notify-translations.yml b/.github/workflows/notify-translations.yml deleted file mode 100644 index fc29384bd5..0000000000 --- a/.github/workflows/notify-translations.yml +++ /dev/null @@ -1,64 +0,0 @@ -name: Notify Translations - -on: - pull_request_target: # zizmor: ignore[dangerous-triggers] - types: - - labeled - - closed - branches: - - master - workflow_dispatch: - inputs: - number: - description: PR number - required: true - debug_enabled: - description: 'Run the build with tmate debugging enabled (https://github.com/marketplace/actions/debugging-with-tmate)' - required: false - default: 'false' - -permissions: {} - -jobs: - job: - runs-on: ubuntu-latest - permissions: - discussions: write - timeout-minutes: 5 - steps: - - name: Dump GitHub context - env: - GITHUB_CONTEXT: ${{ toJson(github) }} - run: echo "$GITHUB_CONTEXT" - - uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 - with: - ref: ${{ github.event.repository.default_branch }} - persist-credentials: false - - name: Set up Python - uses: actions/setup-python@5fda3b95a4ea91299a34e894583c3862153e4b97 # v7.0.0 - with: - python-version-file: ".python-version" - - name: Setup uv - uses: astral-sh/setup-uv@c18668ad3cf93ea998bef934396af7bb5c839dc7 # v10.2.0 - with: - version: "latest-known" - enable-cache: true - cache-dependency-glob: | - pyproject.toml - uv.lock - - name: Install Dependencies - run: uv sync --locked --no-dev --group github-actions - # Allow debugging with tmate - - name: Setup tmate session - uses: mxschmitt/action-tmate@35b54afac29c97fb54faba5b513f8fbd1882f113 # v3.24 - if: ${{ github.event_name == 'workflow_dispatch' && github.event.inputs.debug_enabled == 'true' }} - with: - limit-access-to-actor: true - env: - GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} - - name: Notify Translations - run: uv run ./scripts/notify_translations.py - env: - GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} - NUMBER: ${{ github.event.inputs.number || null }} - DEBUG: ${{ github.event.inputs.debug_enabled || 'false' }} diff --git a/docs/en/docs/translations.md b/docs/en/docs/translations.md index 2f1067255e..f9634fb704 100644 --- a/docs/en/docs/translations.md +++ b/docs/en/docs/translations.md @@ -24,5 +24,3 @@ Let's say that you want to request translations for a language that is not yet t Once there are several people in the discussion, the FastAPI team can evaluate it and can make it an official translation. Then the docs will be automatically translated using LLMs, and the team of native speakers can review the translation, and help tweak the LLM prompts. - -Once there's a new translation, for example if docs are updated or there's a new section, there will be a comment in the same discussion with the link to the new translation to review. diff --git a/scripts/label_approved.py b/scripts/label_approved.py deleted file mode 100644 index 81de92efbe..0000000000 --- a/scripts/label_approved.py +++ /dev/null @@ -1,60 +0,0 @@ -import logging -from typing import Literal - -from github import Github -from github.PullRequestReview import PullRequestReview -from pydantic import BaseModel, SecretStr -from pydantic_settings import BaseSettings - - -class LabelSettings(BaseModel): - await_label: str | None = None - number: int - - -default_config = {"approved-2": LabelSettings(await_label="awaiting-review", number=2)} - - -class Settings(BaseSettings): - github_repository: str - token: SecretStr - debug: bool | None = False - config: dict[str, LabelSettings] | Literal[""] = default_config - - -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.token.get_secret_value()) -repo = g.get_repo(settings.github_repository) -for pr in repo.get_pulls(state="open"): - logging.info(f"Checking PR: #{pr.number}") - pr_labels = list(pr.get_labels()) - pr_label_by_name = {label.name: label for label in pr_labels} - reviews = list(pr.get_reviews()) - review_by_user: dict[str, PullRequestReview] = {} - for review in reviews: - if review.user.login in review_by_user: - stored_review = review_by_user[review.user.login] - if review.submitted_at >= stored_review.submitted_at: - review_by_user[review.user.login] = review - else: - review_by_user[review.user.login] = review - approved_reviews = [ - review for review in review_by_user.values() if review.state == "APPROVED" - ] - config = settings.config or default_config - for approved_label, conf in config.items(): - logging.debug(f"Processing config: {conf.model_dump_json()}") - if conf.await_label is None or (conf.await_label in pr_label_by_name): - logging.debug(f"Processable PR: {pr.number}") - if len(approved_reviews) >= conf.number: - logging.info(f"Adding label to PR: {pr.number}") - pr.add_to_labels(approved_label) - if conf.await_label: - logging.info(f"Removing label from PR: {pr.number}") - pr.remove_from_labels(conf.await_label) -logging.info("Finished") diff --git a/scripts/notify_translations.py b/scripts/notify_translations.py deleted file mode 100644 index 85cf768031..0000000000 --- a/scripts/notify_translations.py +++ /dev/null @@ -1,433 +0,0 @@ -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") - number = cast(int, number) - - # 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()