committed by
GitHub
9 changed files with 413 additions and 21 deletions
@ -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 }} |
@ -0,0 +1,5 @@ |
|||
- tiangolo |
|||
- codecov |
|||
- github-actions |
|||
- pre-commit-ci |
|||
- dependabot |
@ -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", "[email protected]"], 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() |
Loading…
Reference in new issue