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 %}
-
+
{% 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 %}
+
+{% 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 %}
+
+
+
{% 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 %}
+
+{% 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()