scripts/contributors.py PYTHON 317 lines View on github.com → Search inside
1import logging2import secrets3import subprocess4from collections import Counter5from datetime import datetime6from pathlib import Path7from typing import Any89import httpx10import yaml11from github import Github12from pydantic import BaseModel, SecretStr13from pydantic_settings import BaseSettings1415github_graphql_url = "https://api.github.com/graphql"161718prs_query = """19query Q($after: String) {20  repository(name: "fastapi", owner: "fastapi") {21    pullRequests(first: 100, after: $after) {22      edges {23        cursor24        node {25          number26          labels(first: 100) {27            nodes {28              name29            }30          }31          author {32            login33            avatarUrl34            url35          }36          title37          createdAt38          lastEditedAt39          updatedAt40          state41          reviews(first:100) {42            nodes {43              author {44                login45                avatarUrl46                url47              }48              state49            }50          }51        }52      }53    }54  }55}56"""575859class Author(BaseModel):60    login: str61    avatarUrl: str62    url: str636465class LabelNode(BaseModel):66    name: str676869class Labels(BaseModel):70    nodes: list[LabelNode]717273class ReviewNode(BaseModel):74    author: Author | None = None75    state: str767778class Reviews(BaseModel):79    nodes: list[ReviewNode]808182class PullRequestNode(BaseModel):83    number: int84    labels: Labels85    author: Author | None = None86    title: str87    createdAt: datetime88    lastEditedAt: datetime | None = None89    updatedAt: datetime | None = None90    state: str91    reviews: Reviews929394class PullRequestEdge(BaseModel):95    cursor: str96    node: PullRequestNode979899class PullRequests(BaseModel):100    edges: list[PullRequestEdge]101102103class PRsRepository(BaseModel):104    pullRequests: PullRequests105106107class PRsResponseData(BaseModel):108    repository: PRsRepository109110111class PRsResponse(BaseModel):112    data: PRsResponseData113114115class Settings(BaseSettings):116    github_token: SecretStr117    github_repository: str118    httpx_timeout: int = 30119120121def get_graphql_response(122    *,123    settings: Settings,124    query: str,125    after: str | None = None,126) -> dict[str, Any]:127    headers = {"Authorization": f"token {settings.github_token.get_secret_value()}"}128    variables = {"after": after}129    response = httpx.post(130        github_graphql_url,131        headers=headers,132        timeout=settings.httpx_timeout,133        json={"query": query, "variables": variables, "operationName": "Q"},134    )135    if response.status_code != 200:136        logging.error(f"Response was not 200, after: {after}")137        logging.error(response.text)138        raise RuntimeError(response.text)139    data = response.json()140    if "errors" in data:141        logging.error(f"Errors in response, after: {after}")142        logging.error(data["errors"])143        logging.error(response.text)144        raise RuntimeError(response.text)145    return data146147148def get_graphql_pr_edges(149    *, settings: Settings, after: str | None = None150) -> list[PullRequestEdge]:151    data = get_graphql_response(settings=settings, query=prs_query, after=after)152    graphql_response = PRsResponse.model_validate(data)153    return graphql_response.data.repository.pullRequests.edges154155156def get_pr_nodes(settings: Settings) -> list[PullRequestNode]:157    pr_nodes: list[PullRequestNode] = []158    pr_edges = get_graphql_pr_edges(settings=settings)159160    while pr_edges:161        for edge in pr_edges:162            pr_nodes.append(edge.node)163        last_edge = pr_edges[-1]164        pr_edges = get_graphql_pr_edges(settings=settings, after=last_edge.cursor)165    return pr_nodes166167168class ContributorsResults(BaseModel):169    contributors: Counter[str]170    translation_reviewers: Counter[str]171    translators: Counter[str]172    authors: dict[str, Author]173174175def get_contributors(pr_nodes: list[PullRequestNode]) -> ContributorsResults:176    contributors = Counter[str]()177    translation_reviewers = Counter[str]()178    translators = Counter[str]()179    authors: dict[str, Author] = {}180181    for pr in pr_nodes:182        if pr.author:183            authors[pr.author.login] = pr.author184        is_lang = False185        for label in pr.labels.nodes:186            if label.name == "lang-all":187                is_lang = True188                break189        for review in pr.reviews.nodes:190            if review.author:191                authors[review.author.login] = review.author192                if is_lang:193                    translation_reviewers[review.author.login] += 1194        if pr.state == "MERGED" and pr.author:195            if is_lang:196                translators[pr.author.login] += 1197            else:198                contributors[pr.author.login] += 1199    return ContributorsResults(200        contributors=contributors,201        translation_reviewers=translation_reviewers,202        translators=translators,203        authors=authors,204    )205206207def get_users_to_write(208    *,209    counter: Counter[str],210    authors: dict[str, Author],211    min_count: int = 2,212) -> dict[str, Any]:213    users: dict[str, Any] = {}214    for user, count in counter.most_common():215        if count >= min_count:216            author = authors[user]217            users[user] = {218                "login": user,219                "count": count,220                "avatarUrl": author.avatarUrl,221                "url": author.url,222            }223    return users224225226def update_content(*, content_path: Path, new_content: Any) -> bool:227    old_content = content_path.read_text(encoding="utf-8")228229    new_content = yaml.dump(new_content, sort_keys=False, width=200, allow_unicode=True)230    if old_content == new_content:231        logging.info(f"The content hasn't changed for {content_path}")232        return False233    content_path.write_text(new_content, encoding="utf-8")234    logging.info(f"Updated {content_path}")235    return True236237238def main() -> None:239    logging.basicConfig(level=logging.INFO)240    settings = Settings()  # ty: ignore[missing-argument]241    logging.info(f"Using config: {settings.model_dump_json()}")242    g = Github(settings.github_token.get_secret_value())243    repo = g.get_repo(settings.github_repository)244245    pr_nodes = get_pr_nodes(settings=settings)246    contributors_results = get_contributors(pr_nodes=pr_nodes)247    authors = contributors_results.authors248249    top_contributors = get_users_to_write(250        counter=contributors_results.contributors,251        authors=authors,252    )253254    top_translators = get_users_to_write(255        counter=contributors_results.translators,256        authors=authors,257    )258    top_translations_reviewers = get_users_to_write(259        counter=contributors_results.translation_reviewers,260        authors=authors,261    )262263    # For local development264    # contributors_path = Path("../docs/en/data/contributors.yml")265    contributors_path = Path("./docs/en/data/contributors.yml")266    # translators_path = Path("../docs/en/data/translators.yml")267    translators_path = Path("./docs/en/data/translators.yml")268    # translation_reviewers_path = Path("../docs/en/data/translation_reviewers.yml")269    translation_reviewers_path = Path("./docs/en/data/translation_reviewers.yml")270271    updated = [272        update_content(content_path=contributors_path, new_content=top_contributors),273        update_content(content_path=translators_path, new_content=top_translators),274        update_content(275            content_path=translation_reviewers_path,276            new_content=top_translations_reviewers,277        ),278    ]279280    if not any(updated):281        logging.info("The data hasn't changed, finishing.")282        return283284    logging.info("Setting up GitHub Actions git user")285    subprocess.run(["git", "config", "user.name", "github-actions[bot]"], check=True)286    subprocess.run(287        ["git", "config", "user.email", "github-actions[bot]@users.noreply.github.com"],288        check=True,289    )290    branch_name = f"fastapi-people-contributors-{secrets.token_hex(4)}"291    logging.info(f"Creating a new branch {branch_name}")292    subprocess.run(["git", "checkout", "-b", branch_name], check=True)293    logging.info("Adding updated file")294    subprocess.run(295        [296            "git",297            "add",298            str(contributors_path),299            str(translators_path),300            str(translation_reviewers_path),301        ],302        check=True,303    )304    logging.info("Committing updated file")305    message = "👥 Update FastAPI People - Contributors and Translators"306    subprocess.run(["git", "commit", "-m", message], check=True)307    logging.info("Pushing branch")308    subprocess.run(["git", "push", "origin", branch_name], check=True)309    logging.info("Creating PR")310    pr = repo.create_pull(title=message, body=message, base="master", head=branch_name)311    logging.info(f"Created PR: {pr.number}")312    logging.info("Finished")313314315if __name__ == "__main__":316    main()

Code quality findings 7

Ensure functions have docstrings for documentation
missing-docstring
def get_graphql_response(
Ensure functions have docstrings for documentation
missing-docstring
def get_graphql_pr_edges(
Ensure functions have docstrings for documentation
missing-docstring
def get_pr_nodes(settings: Settings) -> list[PullRequestNode]:
Ensure functions have docstrings for documentation
missing-docstring
def get_contributors(pr_nodes: list[PullRequestNode]) -> ContributorsResults:
Ensure functions have docstrings for documentation
missing-docstring
def get_users_to_write(
Ensure functions have docstrings for documentation
missing-docstring
def update_content(*, content_path: Path, new_content: Any) -> bool:
Ensure functions have docstrings for documentation
missing-docstring
def main() -> None:

Get this view in your editor

Same data, no extra tab — call code_get_file + code_get_findings over MCP from Claude/Cursor/Copilot.