]> git.ipfire.org Git - thirdparty/fastapi/fastapi.git/commitdiff
👷 Add new GitHub Action to update contributors, translators, and translation reviewer...
authorSebastián Ramírez <tiangolo@gmail.com>
Thu, 2 Jan 2025 17:03:21 +0000 (17:03 +0000)
committerGitHub <noreply@github.com>
Thu, 2 Jan 2025 17:03:21 +0000 (17:03 +0000)
.github/workflows/contributors.yml [new file with mode: 0644]
docs/en/data/contributors.yml [new file with mode: 0644]
docs/en/data/skip_users.yml [new file with mode: 0644]
docs/en/data/translation_reviewers.yml [new file with mode: 0644]
docs/en/data/translators.yml [new file with mode: 0644]
docs/en/docs/fastapi-people.md
docs/en/mkdocs.yml
requirements-github-actions.txt
scripts/contributors.py [new file with mode: 0644]

diff --git a/.github/workflows/contributors.yml b/.github/workflows/contributors.yml
new file mode 100644 (file)
index 0000000..87abfe3
--- /dev/null
@@ -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 (file)
index 0000000..e69de29
diff --git a/docs/en/data/skip_users.yml b/docs/en/data/skip_users.yml
new file mode 100644 (file)
index 0000000..cf24003
--- /dev/null
@@ -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 (file)
index 0000000..e69de29
diff --git a/docs/en/data/translators.yml b/docs/en/data/translators.yml
new file mode 100644 (file)
index 0000000..e69de29
index bf7954449b9f658b19e1f8c7856a6e585cb0ec74..ffc579b1005c7f4013f0a74f8657cc53edc6e161 100644 (file)
@@ -13,15 +13,13 @@ Hey! 👋
 
 This is me:
 
-{% if people %}
 <div class="user-list user-list-center">
 {% for user in people.maintainers %}
 
-<div class="user"><a href="{{ user.url }}" target="_blank"><div class="avatar-wrapper"><img src="{{ user.avatarUrl }}"/></div><div class="title">@{{ user.login }}</div></a> <div class="count">Answers: {{ user.answers }}</div><div class="count">Pull Requests: {{ user.prs }}</div></div>
+<div class="user"><a href="{{ contributors.tiangolo.url }}" target="_blank"><div class="avatar-wrapper"><img src="{{ contributors.tiangolo.avatarUrl }}"/></div><div class="title">@{{ contributors.tiangolo.login }}</div></a> <div class="count">Answers: {{ user.answers }}</div><div class="count">Pull Requests: {{ contributors.tiangolo.count }}</div></div>
 {% endfor %}
 
 </div>
-{% 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 %}
 <div class="user-list user-list-center">
 {% 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 %}
 
 </div>
-{% 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 %}
 <div class="user-list user-list-center">
 {% 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 %}
 
 </div>
-{% 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 %}
 <div class="user-list user-list-center">
 {% 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 %}
 
 </div>
-{% 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 %}
 <div class="user-list user-list-center">
 {% 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 %}
 
 </div>
-{% 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 %}
 <div class="user-list user-list-center">
 {% 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 %}
 
 </div>
-{% 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 %}
 <div class="user-list user-list-center">
-{% for user in people.top_contributors[:50] %}
+{% for user in (contributors.values() | list)[:50] %}
+
+{% if user.login not in skip_users %}
 
 <div class="user"><a href="{{ user.url }}" target="_blank"><div class="avatar-wrapper"><img src="{{ user.avatarUrl }}"/></div><div class="title">@{{ user.login }}</div></a> <div class="count">Pull Requests: {{ user.count }}</div></div>
+
+{% endif %}
+
 {% endfor %}
 
 </div>
+
+There are hundreds of other contributors, you can see them all in the <a href="https://github.com/fastapi/fastapi/graphs/contributors" class="external-link" target="_blank">FastAPI GitHub Contributors page</a>. 👷
+
+## 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*.
+
+<div class="user-list user-list-center">
+
+{% for user in (translators.values() | list)[:50] %}
+
+{% if user.login not in skip_users %}
+
+<div class="user"><a href="{{ user.url }}" target="_blank"><div class="avatar-wrapper"><img src="{{ user.avatarUrl }}"/></div><div class="title">@{{ user.login }}</div></a> <div class="count">Translations: {{ user.count }}</div></div>
+
 {% endif %}
 
-There are many other contributors (more than a hundred), you can see them all in the <a href="https://github.com/fastapi/fastapi/graphs/contributors" class="external-link" target="_blank">FastAPI GitHub Contributors page</a>. 👷
+{% endfor %}
+
+</div>
 
 ## 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 %}
 <div class="user-list user-list-center">
-{% for user in people.top_translations_reviewers[:50] %}
+{% for user in (translation_reviewers.values() | list)[:50] %}
+
+{% if user.login not in skip_users %}
 
 <div class="user"><a href="{{ user.url }}" target="_blank"><div class="avatar-wrapper"><img src="{{ user.avatarUrl }}"/></div><div class="title">@{{ user.login }}</div></a> <div class="count">Reviews: {{ user.count }}</div></div>
+
+{% endif %}
+
 {% endfor %}
 
 </div>
-{% 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 <a href="https://github.com/fastapi/fastapi/blob/master/.github/actions/people/app/main.py" class="external-link" target="_blank">source code here</a>.
+The data is calculated each month, you can read the <a href="https://github.com/fastapi/fastapi/blob/master/scripts/" class="external-link" target="_blank">source code here</a>.
 
 Here I'm also highlighting contributions from sponsors.
 
index 6443b290a5ec462226e9c27237fbd0ba26546030..f2abf7f6b966140e18e9734f254e1a08cd726e33 100644 (file)
@@ -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
index a6dace544fb53dff511698890f6ba0bf5882a70e..920aefea6b89070fc0d9e859924eec476685db4e 100644 (file)
@@ -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 (file)
index 0000000..251558d
--- /dev/null
@@ -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()