From b59885c9d5692613db145965c72042070fdcfa94 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Sebasti=C3=A1n=20Ram=C3=ADrez?= Date: Thu, 2 Jan 2025 17:03:21 +0000 Subject: [PATCH] =?UTF-8?q?=F0=9F=91=B7=20Add=20new=20GitHub=20Action=20to?= =?UTF-8?q?=20update=20contributors,=20translators,=20and=20translation=20?= =?UTF-8?q?reviewers=20(#13136)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .github/workflows/contributors.yml | 53 +++++ docs/en/data/contributors.yml | 0 docs/en/data/skip_users.yml | 5 + docs/en/data/translation_reviewers.yml | 0 docs/en/data/translators.yml | 0 docs/en/docs/fastapi-people.md | 56 +++-- docs/en/mkdocs.yml | 4 + requirements-github-actions.txt | 1 + scripts/contributors.py | 315 +++++++++++++++++++++++++ 9 files changed, 413 insertions(+), 21 deletions(-) create mode 100644 .github/workflows/contributors.yml create mode 100644 docs/en/data/contributors.yml create mode 100644 docs/en/data/skip_users.yml create mode 100644 docs/en/data/translation_reviewers.yml create mode 100644 docs/en/data/translators.yml create mode 100644 scripts/contributors.py diff --git a/.github/workflows/contributors.yml b/.github/workflows/contributors.yml new file mode 100644 index 000000000..87abfe3a1 --- /dev/null +++ b/.github/workflows/contributors.yml @@ -0,0 +1,53 @@ +name: FastAPI People Contributors + +on: + schedule: + - cron: "0 3 1 * *" + workflow_dispatch: + inputs: + debug_enabled: + description: "Run the build with tmate debugging enabled (https://github.com/marketplace/actions/debugging-with-tmate)" + required: false + default: "false" + +env: + UV_SYSTEM_PYTHON: 1 + +jobs: + job: + if: github.repository_owner == 'fastapi' + runs-on: ubuntu-latest + permissions: + contents: write + steps: + - name: Dump GitHub context + env: + GITHUB_CONTEXT: ${{ toJson(github) }} + run: echo "$GITHUB_CONTEXT" + - uses: actions/checkout@v4 + - name: Set up Python + uses: actions/setup-python@v5 + with: + python-version: "3.11" + - name: Setup uv + uses: astral-sh/setup-uv@v5 + with: + version: "0.4.15" + enable-cache: true + cache-dependency-glob: | + requirements**.txt + pyproject.toml + - name: Install Dependencies + run: uv pip install -r requirements-github-actions.txt + # Allow debugging with tmate + - name: Setup tmate session + uses: mxschmitt/action-tmate@v3 + if: ${{ github.event_name == 'workflow_dispatch' && github.event.inputs.debug_enabled == 'true' }} + with: + limit-access-to-actor: true + env: + GITHUB_TOKEN: ${{ secrets.FASTAPI_PR_TOKEN }} + - name: FastAPI People Contributors + run: python ./scripts/contributors.py + env: + GITHUB_TOKEN: ${{ secrets.FASTAPI_PR_TOKEN }} diff --git a/docs/en/data/contributors.yml b/docs/en/data/contributors.yml new file mode 100644 index 000000000..e69de29bb diff --git a/docs/en/data/skip_users.yml b/docs/en/data/skip_users.yml new file mode 100644 index 000000000..cf24003af --- /dev/null +++ b/docs/en/data/skip_users.yml @@ -0,0 +1,5 @@ +- tiangolo +- codecov +- github-actions +- pre-commit-ci +- dependabot diff --git a/docs/en/data/translation_reviewers.yml b/docs/en/data/translation_reviewers.yml new file mode 100644 index 000000000..e69de29bb diff --git a/docs/en/data/translators.yml b/docs/en/data/translators.yml new file mode 100644 index 000000000..e69de29bb diff --git a/docs/en/docs/fastapi-people.md b/docs/en/docs/fastapi-people.md index bf7954449..ffc579b10 100644 --- a/docs/en/docs/fastapi-people.md +++ b/docs/en/docs/fastapi-people.md @@ -13,15 +13,13 @@ Hey! 👋 This is me: -{% if people %}
{% for user in people.maintainers %} -
@{{ user.login }}
Answers: {{ user.answers }}
Pull Requests: {{ user.prs }}
+
@{{ contributors.tiangolo.login }}
Answers: {{ user.answers }}
Pull Requests: {{ contributors.tiangolo.count }}
{% endfor %}
-{% endif %} I'm the creator of **FastAPI**. You can read more about that in [Help FastAPI - Get Help - Connect with the author](help-fastapi.md#connect-with-the-author){.internal-link target=_blank}. @@ -84,7 +82,6 @@ You can see the **FastAPI Experts** for: These are the users that have been [helping others the most with questions in GitHub](help-fastapi.md#help-others-with-questions-in-github){.internal-link target=_blank} during the last month. 🤓 -{% if people %}
{% for user in people.last_month_experts[:10] %} @@ -92,13 +89,11 @@ These are the users that have been [helping others the most with questions in Gi {% endfor %}
-{% endif %} ### FastAPI Experts - 3 Months These are the users that have been [helping others the most with questions in GitHub](help-fastapi.md#help-others-with-questions-in-github){.internal-link target=_blank} during the last 3 months. 😎 -{% if people %}
{% for user in people.three_months_experts[:10] %} @@ -106,13 +101,11 @@ These are the users that have been [helping others the most with questions in Gi {% endfor %}
-{% endif %} ### FastAPI Experts - 6 Months These are the users that have been [helping others the most with questions in GitHub](help-fastapi.md#help-others-with-questions-in-github){.internal-link target=_blank} during the last 6 months. 🧐 -{% if people %}
{% for user in people.six_months_experts[:10] %} @@ -120,13 +113,11 @@ These are the users that have been [helping others the most with questions in Gi {% endfor %}
-{% endif %} ### FastAPI Experts - 1 Year These are the users that have been [helping others the most with questions in GitHub](help-fastapi.md#help-others-with-questions-in-github){.internal-link target=_blank} during the last year. 🧑‍🔬 -{% if people %}
{% for user in people.one_year_experts[:20] %} @@ -134,7 +125,6 @@ These are the users that have been [helping others the most with questions in Gi {% endfor %}
-{% endif %} ### FastAPI Experts - All Time @@ -142,7 +132,6 @@ Here are the all time **FastAPI Experts**. 🤓🤯 These are the users that have [helped others the most with questions in GitHub](help-fastapi.md#help-others-with-questions-in-github){.internal-link target=_blank} through *all time*. 🧙 -{% if people %}
{% for user in people.experts[:50] %} @@ -150,7 +139,6 @@ These are the users that have [helped others the most with questions in GitHub]( {% endfor %}
-{% endif %} ## Top Contributors @@ -158,19 +146,42 @@ Here are the **Top Contributors**. 👷 These users have [created the most Pull Requests](help-fastapi.md#create-a-pull-request){.internal-link target=_blank} that have been *merged*. -They have contributed source code, documentation, translations, etc. 📦 +They have contributed source code, documentation, etc. 📦 -{% if people %}
-{% for user in people.top_contributors[:50] %} +{% for user in (contributors.values() | list)[:50] %} + +{% if user.login not in skip_users %}
@{{ user.login }}
Pull Requests: {{ user.count }}
+ +{% endif %} + {% endfor %}
+ +There are hundreds of other contributors, you can see them all in the FastAPI GitHub Contributors page. 👷 + +## Top Translators + +These are the **Top Translators**. 🌐 + +These users have created the most Pull Requests with [translations to other languages](contributing.md#translations){.internal-link target=_blank} that have been *merged*. + +
+ +{% for user in (translators.values() | list)[:50] %} + +{% if user.login not in skip_users %} + +
@{{ user.login }}
Translations: {{ user.count }}
+ {% endif %} -There are many other contributors (more than a hundred), you can see them all in the FastAPI GitHub Contributors page. 👷 +{% endfor %} + +
## Top Translation Reviewers @@ -178,15 +189,18 @@ These users are the **Top Translation Reviewers**. 🕵️ I only speak a few languages (and not very well 😅). So, the reviewers are the ones that have the [**power to approve translations**](contributing.md#translations){.internal-link target=_blank} of the documentation. Without them, there wouldn't be documentation in several other languages. -{% if people %}
-{% for user in people.top_translations_reviewers[:50] %} +{% for user in (translation_reviewers.values() | list)[:50] %} + +{% if user.login not in skip_users %}
@{{ user.login }}
Reviews: {{ user.count }}
+ +{% endif %} + {% endfor %}
-{% endif %} ## Sponsors @@ -251,7 +265,7 @@ The main intention of this page is to highlight the effort of the community to h Especially including efforts that are normally less visible, and in many cases more arduous, like helping others with questions and reviewing Pull Requests with translations. -The data is calculated each month, you can read the source code here. +The data is calculated each month, you can read the source code here. Here I'm also highlighting contributions from sponsors. diff --git a/docs/en/mkdocs.yml b/docs/en/mkdocs.yml index 6443b290a..f2abf7f6b 100644 --- a/docs/en/mkdocs.yml +++ b/docs/en/mkdocs.yml @@ -65,6 +65,10 @@ plugins: - external_links: ../en/data/external_links.yml - github_sponsors: ../en/data/github_sponsors.yml - people: ../en/data/people.yml + - contributors: ../en/data/contributors.yml + - translators: ../en/data/translators.yml + - translation_reviewers: ../en/data/translation_reviewers.yml + - skip_users: ../en/data/skip_users.yml - members: ../en/data/members.yml - sponsors_badge: ../en/data/sponsors_badge.yml - sponsors: ../en/data/sponsors.yml diff --git a/requirements-github-actions.txt b/requirements-github-actions.txt index a6dace544..920aefea6 100644 --- a/requirements-github-actions.txt +++ b/requirements-github-actions.txt @@ -2,4 +2,5 @@ PyGithub>=2.3.0,<3.0.0 pydantic>=2.5.3,<3.0.0 pydantic-settings>=2.1.0,<3.0.0 httpx>=0.27.0,<0.28.0 +pyyaml >=5.3.1,<7.0.0 smokeshow diff --git a/scripts/contributors.py b/scripts/contributors.py new file mode 100644 index 000000000..251558de7 --- /dev/null +++ b/scripts/contributors.py @@ -0,0 +1,315 @@ +import logging +import secrets +import subprocess +from collections import Counter +from datetime import datetime +from pathlib import Path +from typing import Any + +import httpx +import yaml +from github import Github +from pydantic import BaseModel, SecretStr +from pydantic_settings import BaseSettings + +github_graphql_url = "https://api.github.com/graphql" + + +prs_query = """ +query Q($after: String) { + repository(name: "fastapi", owner: "fastapi") { + pullRequests(first: 100, after: $after) { + edges { + cursor + node { + number + labels(first: 100) { + nodes { + name + } + } + author { + login + avatarUrl + url + } + title + createdAt + lastEditedAt + updatedAt + state + reviews(first:100) { + nodes { + author { + login + avatarUrl + url + } + state + } + } + } + } + } + } +} +""" + + +class Author(BaseModel): + login: str + avatarUrl: str + url: str + + +class LabelNode(BaseModel): + name: str + + +class Labels(BaseModel): + nodes: list[LabelNode] + + +class ReviewNode(BaseModel): + author: Author | None = None + state: str + + +class Reviews(BaseModel): + nodes: list[ReviewNode] + + +class PullRequestNode(BaseModel): + number: int + labels: Labels + author: Author | None = None + title: str + createdAt: datetime + lastEditedAt: datetime | None = None + updatedAt: datetime | None = None + state: str + reviews: Reviews + + +class PullRequestEdge(BaseModel): + cursor: str + node: PullRequestNode + + +class PullRequests(BaseModel): + edges: list[PullRequestEdge] + + +class PRsRepository(BaseModel): + pullRequests: PullRequests + + +class PRsResponseData(BaseModel): + repository: PRsRepository + + +class PRsResponse(BaseModel): + data: PRsResponseData + + +class Settings(BaseSettings): + github_token: SecretStr + github_repository: str + httpx_timeout: int = 30 + + +def get_graphql_response( + *, + settings: Settings, + query: str, + after: str | None = None, +) -> dict[str, Any]: + headers = {"Authorization": f"token {settings.github_token.get_secret_value()}"} + variables = {"after": after} + 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}") + logging.error(response.text) + raise RuntimeError(response.text) + data = response.json() + if "errors" in data: + logging.error(f"Errors in response, after: {after}") + logging.error(data["errors"]) + logging.error(response.text) + raise RuntimeError(response.text) + return data + + +def get_graphql_pr_edges( + *, settings: Settings, after: str | None = None +) -> list[PullRequestEdge]: + data = get_graphql_response(settings=settings, query=prs_query, after=after) + graphql_response = PRsResponse.model_validate(data) + return graphql_response.data.repository.pullRequests.edges + + +def get_pr_nodes(settings: Settings) -> list[PullRequestNode]: + pr_nodes: list[PullRequestNode] = [] + pr_edges = get_graphql_pr_edges(settings=settings) + + while pr_edges: + for edge in pr_edges: + pr_nodes.append(edge.node) + last_edge = pr_edges[-1] + pr_edges = get_graphql_pr_edges(settings=settings, after=last_edge.cursor) + return pr_nodes + + +class ContributorsResults(BaseModel): + contributors: Counter[str] + translation_reviewers: Counter[str] + translators: Counter[str] + authors: dict[str, Author] + + +def get_contributors(pr_nodes: list[PullRequestNode]) -> ContributorsResults: + contributors = Counter[str]() + translation_reviewers = Counter[str]() + translators = Counter[str]() + authors: dict[str, Author] = {} + + for pr in pr_nodes: + if pr.author: + authors[pr.author.login] = pr.author + is_lang = False + for label in pr.labels.nodes: + if label.name == "lang-all": + is_lang = True + break + for review in pr.reviews.nodes: + if review.author: + authors[review.author.login] = review.author + if is_lang: + translation_reviewers[review.author.login] += 1 + if pr.state == "MERGED" and pr.author: + if is_lang: + translators[pr.author.login] += 1 + else: + contributors[pr.author.login] += 1 + return ContributorsResults( + contributors=contributors, + translation_reviewers=translation_reviewers, + translators=translators, + authors=authors, + ) + + +def get_users_to_write( + *, + counter: Counter[str], + authors: dict[str, Author], + min_count: int = 2, +) -> dict[str, Any]: + users: dict[str, Any] = {} + for user, count in counter.most_common(): + if count >= min_count: + author = authors[user] + users[user] = { + "login": user, + "count": count, + "avatarUrl": author.avatarUrl, + "url": author.url, + } + return users + + +def update_content(*, content_path: Path, new_content: Any) -> bool: + old_content = content_path.read_text(encoding="utf-8") + + new_content = yaml.dump(new_content, sort_keys=False, width=200, allow_unicode=True) + if old_content == new_content: + logging.info(f"The content hasn't changed for {content_path}") + return False + content_path.write_text(new_content, encoding="utf-8") + logging.info(f"Updated {content_path}") + return True + + +def main() -> None: + logging.basicConfig(level=logging.INFO) + settings = Settings() + logging.info(f"Using config: {settings.model_dump_json()}") + g = Github(settings.github_token.get_secret_value()) + repo = g.get_repo(settings.github_repository) + + pr_nodes = get_pr_nodes(settings=settings) + contributors_results = get_contributors(pr_nodes=pr_nodes) + authors = contributors_results.authors + + top_contributors = get_users_to_write( + counter=contributors_results.contributors, + authors=authors, + ) + + top_translators = get_users_to_write( + counter=contributors_results.translators, + authors=authors, + ) + top_translations_reviewers = get_users_to_write( + counter=contributors_results.translation_reviewers, + authors=authors, + ) + + # For local development + # contributors_path = Path("../docs/en/data/contributors.yml") + contributors_path = Path("./docs/en/data/contributors.yml") + # translators_path = Path("../docs/en/data/translators.yml") + translators_path = Path("./docs/en/data/translators.yml") + # translation_reviewers_path = Path("../docs/en/data/translation_reviewers.yml") + translation_reviewers_path = Path("./docs/en/data/translation_reviewers.yml") + + updated = [ + update_content(content_path=contributors_path, new_content=top_contributors), + update_content(content_path=translators_path, new_content=top_translators), + update_content( + content_path=translation_reviewers_path, + new_content=top_translations_reviewers, + ), + ] + + if not any(updated): + logging.info("The data hasn't changed, finishing.") + return + + logging.info("Setting up GitHub Actions git user") + subprocess.run(["git", "config", "user.name", "github-actions"], check=True) + subprocess.run( + ["git", "config", "user.email", "github-actions@github.com"], check=True + ) + branch_name = f"fastapi-people-contributors-{secrets.token_hex(4)}" + logging.info(f"Creating a new branch {branch_name}") + subprocess.run(["git", "checkout", "-b", branch_name], check=True) + logging.info("Adding updated file") + subprocess.run( + [ + "git", + "add", + str(contributors_path), + str(translators_path), + str(translation_reviewers_path), + ], + check=True, + ) + logging.info("Committing updated file") + message = "👥 Update FastAPI People - Contributors and Translators" + subprocess.run(["git", "commit", "-m", message], check=True) + logging.info("Pushing branch") + subprocess.run(["git", "push", "origin", branch_name], check=True) + logging.info("Creating PR") + pr = repo.create_pull(title=message, body=message, base="master", head=branch_name) + logging.info(f"Created PR: {pr.number}") + logging.info("Finished") + + +if __name__ == "__main__": + main()