committed by
GitHub
4 changed files with 0 additions and 912 deletions
@ -1,56 +0,0 @@ |
|||||
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" |
|
||||
|
|
||||
permissions: {} |
|
||||
|
|
||||
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@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7.0.0 |
|
||||
with: |
|
||||
persist-credentials: true # Required for `git push` in `contributors.py` |
|
||||
- name: Set up Python |
|
||||
uses: actions/setup-python@ece7cb06caefa5fff74198d8649806c4678c61a1 # v6.3.0 |
|
||||
with: |
|
||||
python-version-file: ".python-version" |
|
||||
- name: Setup uv |
|
||||
uses: astral-sh/setup-uv@d31148d669074a8d0a63714ba94f3201e7020bc3 # v8.3.0 |
|
||||
with: |
|
||||
# Before upgrading uv version, make sure astral-sh/setup-uv knows its checksum. |
|
||||
# See: https://github.com/astral-sh/setup-uv/issues/851#issuecomment-4282017837 |
|
||||
version: "0.11.18" |
|
||||
enable-cache: true |
|
||||
cache-dependency-glob: | |
|
||||
pyproject.toml |
|
||||
uv.lock |
|
||||
- name: Install Dependencies |
|
||||
run: uv sync --locked --no-dev --group github-actions |
|
||||
# Allow debugging with tmate |
|
||||
- name: Setup tmate session |
|
||||
uses: mxschmitt/action-tmate@35b54afac29c97fb54faba5b513f8fbd1882f113 # v3.24 |
|
||||
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 }} # zizmor: ignore[secrets-outside-env] |
|
||||
- name: FastAPI People Contributors |
|
||||
run: uv run ./scripts/contributors.py |
|
||||
env: |
|
||||
GITHUB_TOKEN: ${{ secrets.FASTAPI_PR_TOKEN }} # zizmor: ignore[secrets-outside-env] |
|
||||
@ -1,57 +0,0 @@ |
|||||
name: FastAPI People |
|
||||
|
|
||||
on: |
|
||||
schedule: |
|
||||
- cron: "0 14 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" |
|
||||
|
|
||||
permissions: {} |
|
||||
|
|
||||
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@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7.0.0 |
|
||||
with: |
|
||||
persist-credentials: true # Required for `git push` in `people.py` |
|
||||
- name: Set up Python |
|
||||
uses: actions/setup-python@ece7cb06caefa5fff74198d8649806c4678c61a1 # v6.3.0 |
|
||||
with: |
|
||||
python-version-file: ".python-version" |
|
||||
- name: Setup uv |
|
||||
uses: astral-sh/setup-uv@d31148d669074a8d0a63714ba94f3201e7020bc3 # v8.3.0 |
|
||||
with: |
|
||||
# Before upgrading uv version, make sure astral-sh/setup-uv knows its checksum. |
|
||||
# See: https://github.com/astral-sh/setup-uv/issues/851#issuecomment-4282017837 |
|
||||
version: "0.11.18" |
|
||||
enable-cache: true |
|
||||
cache-dependency-glob: | |
|
||||
pyproject.toml |
|
||||
uv.lock |
|
||||
- name: Install Dependencies |
|
||||
run: uv sync --locked --no-dev --group github-actions |
|
||||
# Allow debugging with tmate |
|
||||
- name: Setup tmate session |
|
||||
uses: mxschmitt/action-tmate@35b54afac29c97fb54faba5b513f8fbd1882f113 # v3.24 |
|
||||
if: ${{ github.event_name == 'workflow_dispatch' && github.event.inputs.debug_enabled == 'true' }} |
|
||||
with: |
|
||||
limit-access-to-actor: true |
|
||||
env: |
|
||||
GITHUB_TOKEN: ${{ secrets.FASTAPI_PEOPLE }} # zizmor: ignore[secrets-outside-env] |
|
||||
- name: FastAPI People Experts |
|
||||
run: uv run ./scripts/people.py |
|
||||
env: |
|
||||
GITHUB_TOKEN: ${{ secrets.FASTAPI_PEOPLE }} # zizmor: ignore[secrets-outside-env] |
|
||||
SLEEP_INTERVAL: ${{ vars.PEOPLE_SLEEP_INTERVAL }} |
|
||||
@ -1,316 +0,0 @@ |
|||||
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() # ty: ignore[missing-argument] |
|
||||
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[bot]"], check=True) |
|
||||
subprocess.run( |
|
||||
["git", "config", "user.email", "github-actions[bot]@users.noreply.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() |
|
||||
@ -1,483 +0,0 @@ |
|||||
import logging |
|
||||
import secrets |
|
||||
import subprocess |
|
||||
import time |
|
||||
from collections import Counter |
|
||||
from collections.abc import Container |
|
||||
from datetime import datetime, timedelta, timezone |
|
||||
from math import ceil |
|
||||
from pathlib import Path |
|
||||
from typing import Annotated, Any |
|
||||
|
|
||||
import httpx |
|
||||
import yaml |
|
||||
from github import Github |
|
||||
from pydantic import BaseModel, BeforeValidator, SecretStr |
|
||||
from pydantic_settings import BaseSettings |
|
||||
|
|
||||
github_graphql_url = "https://api.github.com/graphql" |
|
||||
questions_category_id = "DIC_kwDOCZduT84B6E2a" |
|
||||
|
|
||||
|
|
||||
POINTS_PER_MINUTE_LIMIT = 84 # 5000 points per hour |
|
||||
|
|
||||
MINIMIZED_COMMENTS_REASONS_TO_EXCLUDE = {"abuse", "off-topic", "duplicate", "spam"} |
|
||||
|
|
||||
|
|
||||
class RateLimiter: |
|
||||
def __init__(self) -> None: |
|
||||
self.last_query_cost: int = 1 |
|
||||
self.remaining_points: int = 5000 |
|
||||
self.reset_at: datetime = datetime.fromtimestamp(0, timezone.utc) |
|
||||
self.last_request_start_time: datetime = datetime.fromtimestamp(0, timezone.utc) |
|
||||
self.speed_multiplier: float = 1.0 |
|
||||
|
|
||||
def __enter__(self) -> "RateLimiter": |
|
||||
now = datetime.now(tz=timezone.utc) |
|
||||
|
|
||||
# Handle primary rate limits |
|
||||
primary_limit_wait_time = 0.0 |
|
||||
if self.remaining_points <= self.last_query_cost: |
|
||||
primary_limit_wait_time = (self.reset_at - now).total_seconds() + 2 |
|
||||
logging.warning( |
|
||||
f"Approaching GitHub API rate limit, remaining points: {self.remaining_points}, " |
|
||||
f"reset time in {primary_limit_wait_time} seconds" |
|
||||
) |
|
||||
|
|
||||
# Handle secondary rate limits |
|
||||
secondary_limit_wait_time = 0.0 |
|
||||
points_per_minute = POINTS_PER_MINUTE_LIMIT * self.speed_multiplier |
|
||||
interval = 60 / (points_per_minute / self.last_query_cost) |
|
||||
time_since_last_request = (now - self.last_request_start_time).total_seconds() |
|
||||
if time_since_last_request < interval: |
|
||||
secondary_limit_wait_time = interval - time_since_last_request |
|
||||
|
|
||||
final_wait_time = ceil(max(primary_limit_wait_time, secondary_limit_wait_time)) |
|
||||
logging.info(f"Sleeping for {final_wait_time} seconds to respect rate limit") |
|
||||
time.sleep(max(final_wait_time, 1)) |
|
||||
|
|
||||
self.last_request_start_time = datetime.now(tz=timezone.utc) |
|
||||
return self |
|
||||
|
|
||||
def __exit__(self, exc_type, exc_val, exc_tb) -> None: |
|
||||
pass |
|
||||
|
|
||||
def update_request_info(self, cost: int, remaining: int, reset_at: str) -> None: |
|
||||
self.last_query_cost = cost |
|
||||
self.remaining_points = remaining |
|
||||
self.reset_at = datetime.fromisoformat(reset_at.replace("Z", "+00:00")) |
|
||||
|
|
||||
|
|
||||
rate_limiter = RateLimiter() |
|
||||
|
|
||||
|
|
||||
discussions_query = """ |
|
||||
query Q($after: String, $category_id: ID) { |
|
||||
repository(name: "fastapi", owner: "fastapi") { |
|
||||
discussions(first: 30, after: $after, categoryId: $category_id) { |
|
||||
edges { |
|
||||
cursor |
|
||||
node { |
|
||||
number |
|
||||
author { |
|
||||
login |
|
||||
avatarUrl |
|
||||
url |
|
||||
} |
|
||||
createdAt |
|
||||
comments(first: 50) { |
|
||||
totalCount |
|
||||
nodes { |
|
||||
createdAt |
|
||||
author { |
|
||||
login |
|
||||
avatarUrl |
|
||||
url |
|
||||
} |
|
||||
isAnswer |
|
||||
replies(first: 10) { |
|
||||
totalCount |
|
||||
nodes { |
|
||||
createdAt |
|
||||
author { |
|
||||
login |
|
||||
avatarUrl |
|
||||
url |
|
||||
} |
|
||||
minimizedReason |
|
||||
} |
|
||||
} |
|
||||
minimizedReason |
|
||||
} |
|
||||
} |
|
||||
} |
|
||||
} |
|
||||
} |
|
||||
} |
|
||||
rateLimit { |
|
||||
cost |
|
||||
remaining |
|
||||
resetAt |
|
||||
} |
|
||||
} |
|
||||
""" |
|
||||
|
|
||||
LowerStr = Annotated[ |
|
||||
str, BeforeValidator(lambda v: v.lower() if isinstance(v, str) else v) |
|
||||
] |
|
||||
|
|
||||
|
|
||||
class Author(BaseModel): |
|
||||
login: str |
|
||||
avatarUrl: str | None = None |
|
||||
url: str | None = None |
|
||||
|
|
||||
|
|
||||
class CommentsNode(BaseModel): |
|
||||
createdAt: datetime |
|
||||
author: Author | None = None |
|
||||
minimizedReason: LowerStr | None = None |
|
||||
|
|
||||
|
|
||||
class Replies(BaseModel): |
|
||||
totalCount: int |
|
||||
nodes: list[CommentsNode] |
|
||||
|
|
||||
|
|
||||
class DiscussionsCommentsNode(CommentsNode): |
|
||||
minimizedReason: LowerStr | None = None |
|
||||
replies: Replies |
|
||||
|
|
||||
|
|
||||
class DiscussionsComments(BaseModel): |
|
||||
totalCount: int |
|
||||
nodes: list[DiscussionsCommentsNode] |
|
||||
|
|
||||
|
|
||||
class DiscussionsNode(BaseModel): |
|
||||
number: int |
|
||||
author: Author | None = None |
|
||||
title: str | None = None |
|
||||
createdAt: datetime |
|
||||
comments: DiscussionsComments |
|
||||
|
|
||||
|
|
||||
class DiscussionsEdge(BaseModel): |
|
||||
cursor: str |
|
||||
node: DiscussionsNode |
|
||||
|
|
||||
|
|
||||
class Discussions(BaseModel): |
|
||||
edges: list[DiscussionsEdge] |
|
||||
|
|
||||
|
|
||||
class DiscussionsRepository(BaseModel): |
|
||||
discussions: Discussions |
|
||||
|
|
||||
|
|
||||
class DiscussionsResponseData(BaseModel): |
|
||||
repository: DiscussionsRepository |
|
||||
|
|
||||
|
|
||||
class DiscussionsResponse(BaseModel): |
|
||||
data: DiscussionsResponseData |
|
||||
|
|
||||
|
|
||||
class Settings(BaseSettings): |
|
||||
github_token: SecretStr |
|
||||
github_repository: str |
|
||||
httpx_timeout: int = 30 |
|
||||
speed_multiplier: float = 1.0 |
|
||||
|
|
||||
|
|
||||
def get_graphql_response( |
|
||||
*, |
|
||||
settings: Settings, |
|
||||
query: str, |
|
||||
after: str | None = None, |
|
||||
category_id: str | None = None, |
|
||||
) -> dict[str, Any]: |
|
||||
headers = {"Authorization": f"token {settings.github_token.get_secret_value()}"} |
|
||||
variables = {"after": after, "category_id": category_id} |
|
||||
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}, category_id: {category_id}" |
|
||||
) |
|
||||
logging.error(response.text) |
|
||||
raise RuntimeError(response.text) |
|
||||
data = response.json() |
|
||||
if "errors" in data: |
|
||||
logging.error(f"Errors in response, after: {after}, category_id: {category_id}") |
|
||||
logging.error(data["errors"]) |
|
||||
logging.error(response.text) |
|
||||
raise RuntimeError(response.text) |
|
||||
return data |
|
||||
|
|
||||
|
|
||||
def get_graphql_question_discussion_edges( |
|
||||
*, |
|
||||
settings: Settings, |
|
||||
after: str | None = None, |
|
||||
) -> list[DiscussionsEdge]: |
|
||||
with rate_limiter: |
|
||||
data = get_graphql_response( |
|
||||
settings=settings, |
|
||||
query=discussions_query, |
|
||||
after=after, |
|
||||
category_id=questions_category_id, |
|
||||
) |
|
||||
|
|
||||
rate_limiter.update_request_info( |
|
||||
cost=data["data"]["rateLimit"]["cost"], |
|
||||
remaining=data["data"]["rateLimit"]["remaining"], |
|
||||
reset_at=data["data"]["rateLimit"]["resetAt"], |
|
||||
) |
|
||||
graphql_response = DiscussionsResponse.model_validate(data) |
|
||||
return graphql_response.data.repository.discussions.edges |
|
||||
|
|
||||
|
|
||||
class DiscussionExpertsResults(BaseModel): |
|
||||
commenters: Counter[str] |
|
||||
last_month_commenters: Counter[str] |
|
||||
three_months_commenters: Counter[str] |
|
||||
six_months_commenters: Counter[str] |
|
||||
one_year_commenters: Counter[str] |
|
||||
authors: dict[str, Author] |
|
||||
|
|
||||
|
|
||||
def get_discussion_nodes(settings: Settings) -> list[DiscussionsNode]: |
|
||||
discussion_nodes: list[DiscussionsNode] = [] |
|
||||
discussion_edges = get_graphql_question_discussion_edges(settings=settings) |
|
||||
|
|
||||
while discussion_edges: |
|
||||
for discussion_edge in discussion_edges: |
|
||||
discussion_nodes.append(discussion_edge.node) |
|
||||
last_edge = discussion_edges[-1] |
|
||||
discussion_edges = get_graphql_question_discussion_edges( |
|
||||
settings=settings, after=last_edge.cursor |
|
||||
) |
|
||||
return discussion_nodes |
|
||||
|
|
||||
|
|
||||
def get_discussions_experts( |
|
||||
discussion_nodes: list[DiscussionsNode], |
|
||||
) -> DiscussionExpertsResults: |
|
||||
commenters = Counter[str]() |
|
||||
last_month_commenters = Counter[str]() |
|
||||
three_months_commenters = Counter[str]() |
|
||||
six_months_commenters = Counter[str]() |
|
||||
one_year_commenters = Counter[str]() |
|
||||
authors: dict[str, Author] = {} |
|
||||
|
|
||||
now = datetime.now(tz=timezone.utc) |
|
||||
one_month_ago = now - timedelta(days=30) |
|
||||
three_months_ago = now - timedelta(days=90) |
|
||||
six_months_ago = now - timedelta(days=180) |
|
||||
one_year_ago = now - timedelta(days=365) |
|
||||
|
|
||||
for discussion in discussion_nodes: |
|
||||
discussion_author_name = None |
|
||||
if discussion.author: |
|
||||
authors[discussion.author.login] = discussion.author |
|
||||
discussion_author_name = discussion.author.login |
|
||||
discussion_commentors: dict[str, datetime] = {} |
|
||||
for comment in discussion.comments.nodes: |
|
||||
if ( |
|
||||
comment.minimizedReason not in MINIMIZED_COMMENTS_REASONS_TO_EXCLUDE |
|
||||
and comment.author |
|
||||
): |
|
||||
authors[comment.author.login] = comment.author |
|
||||
if comment.author.login != discussion_author_name: |
|
||||
author_time = discussion_commentors.get( |
|
||||
comment.author.login, comment.createdAt |
|
||||
) |
|
||||
discussion_commentors[comment.author.login] = max( |
|
||||
author_time, comment.createdAt |
|
||||
) |
|
||||
for reply in comment.replies.nodes: |
|
||||
if ( |
|
||||
reply.minimizedReason not in MINIMIZED_COMMENTS_REASONS_TO_EXCLUDE |
|
||||
and reply.author |
|
||||
): |
|
||||
authors[reply.author.login] = reply.author |
|
||||
if reply.author.login != discussion_author_name: |
|
||||
author_time = discussion_commentors.get( |
|
||||
reply.author.login, reply.createdAt |
|
||||
) |
|
||||
discussion_commentors[reply.author.login] = max( |
|
||||
author_time, reply.createdAt |
|
||||
) |
|
||||
for author_name, author_time in discussion_commentors.items(): |
|
||||
commenters[author_name] += 1 |
|
||||
if author_time > one_month_ago: |
|
||||
last_month_commenters[author_name] += 1 |
|
||||
if author_time > three_months_ago: |
|
||||
three_months_commenters[author_name] += 1 |
|
||||
if author_time > six_months_ago: |
|
||||
six_months_commenters[author_name] += 1 |
|
||||
if author_time > one_year_ago: |
|
||||
one_year_commenters[author_name] += 1 |
|
||||
discussion_experts_results = DiscussionExpertsResults( |
|
||||
authors=authors, |
|
||||
commenters=commenters, |
|
||||
last_month_commenters=last_month_commenters, |
|
||||
three_months_commenters=three_months_commenters, |
|
||||
six_months_commenters=six_months_commenters, |
|
||||
one_year_commenters=one_year_commenters, |
|
||||
) |
|
||||
return discussion_experts_results |
|
||||
|
|
||||
|
|
||||
def get_top_users( |
|
||||
*, |
|
||||
counter: Counter[str], |
|
||||
authors: dict[str, Author], |
|
||||
skip_users: Container[str], |
|
||||
min_count: int = 2, |
|
||||
) -> list[dict[str, Any]]: |
|
||||
users: list[dict[str, Any]] = [] |
|
||||
for commenter, count in counter.most_common(50): |
|
||||
if commenter in skip_users: |
|
||||
continue |
|
||||
if count >= min_count: |
|
||||
author = authors[commenter] |
|
||||
users.append( |
|
||||
{ |
|
||||
"login": commenter, |
|
||||
"count": count, |
|
||||
"avatarUrl": author.avatarUrl, |
|
||||
"url": author.url, |
|
||||
} |
|
||||
) |
|
||||
return users |
|
||||
|
|
||||
|
|
||||
def get_users_to_write( |
|
||||
*, |
|
||||
counter: Counter[str], |
|
||||
authors: dict[str, Author], |
|
||||
min_count: int = 2, |
|
||||
) -> list[dict[str, Any]]: |
|
||||
users: dict[str, Any] = {} |
|
||||
users_list: list[dict[str, Any]] = [] |
|
||||
for user, count in counter.most_common(60): |
|
||||
if count >= min_count: |
|
||||
author = authors[user] |
|
||||
user_data = { |
|
||||
"login": user, |
|
||||
"count": count, |
|
||||
"avatarUrl": author.avatarUrl, |
|
||||
"url": author.url, |
|
||||
} |
|
||||
users[user] = user_data |
|
||||
users_list.append(user_data) |
|
||||
return users_list |
|
||||
|
|
||||
|
|
||||
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() # ty: ignore[missing-argument] |
|
||||
logging.info(f"Using config: {settings.model_dump_json()}") |
|
||||
rate_limiter.speed_multiplier = settings.speed_multiplier |
|
||||
g = Github(settings.github_token.get_secret_value()) |
|
||||
repo = g.get_repo(settings.github_repository) |
|
||||
|
|
||||
discussion_nodes = get_discussion_nodes(settings=settings) |
|
||||
experts_results = get_discussions_experts(discussion_nodes=discussion_nodes) |
|
||||
|
|
||||
authors = experts_results.authors |
|
||||
maintainers_logins = {"tiangolo"} |
|
||||
maintainers = [] |
|
||||
for login in maintainers_logins: |
|
||||
user = authors[login] |
|
||||
maintainers.append( |
|
||||
{ |
|
||||
"login": login, |
|
||||
"answers": experts_results.commenters[login], |
|
||||
"avatarUrl": user.avatarUrl, |
|
||||
"url": user.url, |
|
||||
} |
|
||||
) |
|
||||
|
|
||||
experts = get_users_to_write( |
|
||||
counter=experts_results.commenters, |
|
||||
authors=authors, |
|
||||
) |
|
||||
last_month_experts = get_users_to_write( |
|
||||
counter=experts_results.last_month_commenters, |
|
||||
authors=authors, |
|
||||
) |
|
||||
three_months_experts = get_users_to_write( |
|
||||
counter=experts_results.three_months_commenters, |
|
||||
authors=authors, |
|
||||
) |
|
||||
six_months_experts = get_users_to_write( |
|
||||
counter=experts_results.six_months_commenters, |
|
||||
authors=authors, |
|
||||
) |
|
||||
one_year_experts = get_users_to_write( |
|
||||
counter=experts_results.one_year_commenters, |
|
||||
authors=authors, |
|
||||
) |
|
||||
|
|
||||
people = { |
|
||||
"maintainers": maintainers, |
|
||||
"experts": experts, |
|
||||
"last_month_experts": last_month_experts, |
|
||||
"three_months_experts": three_months_experts, |
|
||||
"six_months_experts": six_months_experts, |
|
||||
"one_year_experts": one_year_experts, |
|
||||
} |
|
||||
|
|
||||
# For local development |
|
||||
# people_path = Path("../docs/en/data/people.yml") |
|
||||
people_path = Path("./docs/en/data/people.yml") |
|
||||
|
|
||||
updated = update_content(content_path=people_path, new_content=people) |
|
||||
|
|
||||
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[bot]"], check=True) |
|
||||
subprocess.run( |
|
||||
["git", "config", "user.email", "github-actions[bot]@users.noreply.github.com"], |
|
||||
check=True, |
|
||||
) |
|
||||
branch_name = f"fastapi-people-experts-{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(people_path)], check=True) |
|
||||
logging.info("Committing updated file") |
|
||||
message = "👥 Update FastAPI People - Experts" |
|
||||
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