From 9e0d4fa0efed6376a0e4066ddc59b8802fcd25b8 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Sebasti=C3=A1n=20Ram=C3=ADrez?= Date: Fri, 17 Jan 2025 17:51:19 +0000 Subject: [PATCH] =?UTF-8?q?=F0=9F=91=B7=20Add=20independent=20CI=20automat?= =?UTF-8?q?ion=20for=20FastAPI=20People=20-=20Sponsors=20(#13221)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .github/workflows/sponsors.yml | 53 ++++++++ scripts/sponsors.py | 220 +++++++++++++++++++++++++++++++++ 2 files changed, 273 insertions(+) create mode 100644 .github/workflows/sponsors.yml create mode 100644 scripts/sponsors.py diff --git a/.github/workflows/sponsors.yml b/.github/workflows/sponsors.yml new file mode 100644 index 000000000..590ac5487 --- /dev/null +++ b/.github/workflows/sponsors.yml @@ -0,0 +1,53 @@ +name: FastAPI People Sponsors + +on: + schedule: + - cron: "0 6 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 Sponsors + run: python ./scripts/sponsors.py + env: + GITHUB_TOKEN: ${{ secrets.FASTAPI_PR_TOKEN }} diff --git a/scripts/sponsors.py b/scripts/sponsors.py new file mode 100644 index 000000000..ed782a49e --- /dev/null +++ b/scripts/sponsors.py @@ -0,0 +1,220 @@ +import logging +import secrets +import subprocess +from collections import defaultdict +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" + + +sponsors_query = """ +query Q($after: String) { + user(login: "tiangolo") { + sponsorshipsAsMaintainer(first: 100, after: $after) { + edges { + cursor + node { + sponsorEntity { + ... on Organization { + login + avatarUrl + url + } + ... on User { + login + avatarUrl + url + } + } + tier { + name + monthlyPriceInDollars + } + } + } + } + } +} +""" + + +class SponsorEntity(BaseModel): + login: str + avatarUrl: str + url: str + + +class Tier(BaseModel): + name: str + monthlyPriceInDollars: float + + +class SponsorshipAsMaintainerNode(BaseModel): + sponsorEntity: SponsorEntity + tier: Tier + + +class SponsorshipAsMaintainerEdge(BaseModel): + cursor: str + node: SponsorshipAsMaintainerNode + + +class SponsorshipAsMaintainer(BaseModel): + edges: list[SponsorshipAsMaintainerEdge] + + +class SponsorsUser(BaseModel): + sponsorshipsAsMaintainer: SponsorshipAsMaintainer + + +class SponsorsResponseData(BaseModel): + user: SponsorsUser + + +class SponsorsResponse(BaseModel): + data: SponsorsResponseData + + +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_sponsor_edges( + *, settings: Settings, after: str | None = None +) -> list[SponsorshipAsMaintainerEdge]: + data = get_graphql_response(settings=settings, query=sponsors_query, after=after) + graphql_response = SponsorsResponse.model_validate(data) + return graphql_response.data.user.sponsorshipsAsMaintainer.edges + + +def get_individual_sponsors( + settings: Settings, +) -> defaultdict[float, dict[str, SponsorEntity]]: + nodes: list[SponsorshipAsMaintainerNode] = [] + edges = get_graphql_sponsor_edges(settings=settings) + + while edges: + for edge in edges: + nodes.append(edge.node) + last_edge = edges[-1] + edges = get_graphql_sponsor_edges(settings=settings, after=last_edge.cursor) + + tiers: defaultdict[float, dict[str, SponsorEntity]] = defaultdict(dict) + for node in nodes: + tiers[node.tier.monthlyPriceInDollars][node.sponsorEntity.login] = ( + node.sponsorEntity + ) + return tiers + + +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) + + tiers = get_individual_sponsors(settings=settings) + keys = list(tiers.keys()) + keys.sort(reverse=True) + sponsors = [] + for key in keys: + sponsor_group = [] + for login, sponsor in tiers[key].items(): + sponsor_group.append( + {"login": login, "avatarUrl": sponsor.avatarUrl, "url": sponsor.url} + ) + sponsors.append(sponsor_group) + github_sponsors = { + "sponsors": sponsors, + } + + # For local development + # github_sponsors_path = Path("../docs/en/data/github_sponsors.yml") + github_sponsors_path = Path("./docs/en/data/github_sponsors.yml") + updated = update_content( + content_path=github_sponsors_path, new_content=github_sponsors + ) + + if not 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-sponsors-{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(github_sponsors_path), + ], + check=True, + ) + logging.info("Committing updated file") + message = "👥 Update FastAPI People - Sponsors" + 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()