Repository: pydantic/pydantic

Issue:
Add `Pydantic People` Section to Our Docs
### Initial Checks

- [X] I have searched Google & GitHub for similar requests and couldn't find anything
- [X] I have read and followed [the docs](https://docs.pydantic.dev) and still think this feature is missing

### Description

@sydney-runkle should work on adding a `Pydantic People` or `Pydantic Contributors` section to the docs that encourages open source contributions and highlights the work of regular contributors 😄.

### Affected Components

- [ ] [Compatibility between releases](https://docs.pydantic.dev/changelog/)
- [ ] [Data validation/parsing](https://docs.pydantic.dev/usage/models/#basic-model-usage)
- [ ] [Data serialization](https://docs.pydantic.dev/usage/exporting_models/) - `.model_dump()` and `.model_dump_json()`
- [ ] [JSON Schema](https://docs.pydantic.dev/usage/schema/)
- [ ] [Dataclasses](https://docs.pydantic.dev/usage/dataclasses/)
- [ ] [Model Config](https://docs.pydantic.dev/usage/model_config/)
- [ ] [Field Types](https://docs.pydantic.dev/usage/types/) - adding or changing a particular data type
- [ ] [Function validation decorator](https://docs.pydantic.dev/usage/validation_decorator/)
- [ ] [Generic Models](https://docs.pydantic.dev/usage/models/#generic-models)
- [ ] [Other Model behaviour](https://docs.pydantic.dev/usage/models/) - `model_construct()`, pickling, private attributes, ORM mode
- [ ] [Plugins](https://docs.pydantic.dev/) and integration with other tools - mypy, FastAPI, python-devtools, Hypothesis, VS Code, PyCharm, etc.


Discussion:
It could be cool to add a section that specifically highlights first time contributors every month 👍 

Resolving patch:
diff --git a/.github/actions/people/people.py b/.github/actions/people/people.py
new file mode 100644
--- /dev/null
+++ b/.github/actions/people/people.py
@@ -0,0 +1,613 @@
+"""Use the github API to get lists of people who have contributed in various ways to Pydantic.
+
+This logic is inspired by that of @tiangolo's
+[FastAPI people script](https://github.com/tiangolo/fastapi/blob/master/.github/actions/people/app/main.py).
+"""
+import requests
+import logging
+import subprocess
+import sys
+from collections import Counter
+from datetime import datetime, timedelta, timezone
+from pathlib import Path
+from typing import Any, Container, Dict, List, Set, Union
+
+import yaml
+from github import Github
+from pydantic import BaseModel, SecretStr
+from pydantic_settings import BaseSettings
+
+github_graphql_url = "https://api.github.com/graphql"
+
+discussions_query = """
+query Q($after: String) {
+  repository(name: "pydantic", owner: "samuelcolvin") {
+    discussions(first: 100, after: $after) {
+      edges {
+        cursor
+        node {
+          number
+          author {
+            login
+            avatarUrl
+            url
+          }
+          title
+          createdAt
+          comments(first: 100) {
+            nodes {
+              createdAt
+              author {
+                login
+                avatarUrl
+                url
+              }
+              isAnswer
+              replies(first: 10) {
+                nodes {
+                  createdAt
+                  author {
+                    login
+                    avatarUrl
+                    url
+                  }
+                }
+              }
+            }
+          }
+        }
+      }
+    }
+  }
+}
+"""
+
+issues_query = """
+query Q($after: String) {
+  repository(name: "pydantic", owner: "samuelcolvin") {
+    issues(first: 100, after: $after) {
+      edges {
+        cursor
+        node {
+          number
+          author {
+            login
+            avatarUrl
+            url
+          }
+          title
+          createdAt
+          state
+          comments(first: 100) {
+            nodes {
+              createdAt
+              author {
+                login
+                avatarUrl
+                url
+              }
+            }
+          }
+        }
+      }
+    }
+  }
+}
+"""
+
+prs_query = """
+query Q($after: String) {
+  repository(name: "pydantic", owner: "samuelcolvin") {
+    pullRequests(first: 100, after: $after) {
+      edges {
+        cursor
+        node {
+          number
+          labels(first: 100) {
+            nodes {
+              name
+            }
+          }
+          author {
+            login
+            avatarUrl
+            url
+          }
+          title
+          createdAt
+          state
+          comments(first: 100) {
+            nodes {
+              createdAt
+              author {
+                login
+                avatarUrl
+                url
+              }
+            }
+          }
+          reviews(first:100) {
+            nodes {
+              author {
+                login
+                avatarUrl
+                url
+              }
+              state
+            }
+          }
+        }
+      }
+    }
+  }
+}
+"""
+
+
+class Author(BaseModel):
+    login: str
+    avatarUrl: str
+    url: str
+
+
+# Issues and Discussions
+
+
+class CommentsNode(BaseModel):
+    createdAt: datetime
+    author: Union[Author, None] = None
+
+
+class Replies(BaseModel):
+    nodes: List[CommentsNode]
+
+
+class DiscussionsCommentsNode(CommentsNode):
+    replies: Replies
+
+
+class Comments(BaseModel):
+    nodes: List[CommentsNode]
+
+
+class DiscussionsComments(BaseModel):
+    nodes: List[DiscussionsCommentsNode]
+
+
+class IssuesNode(BaseModel):
+    number: int
+    author: Union[Author, None] = None
+    title: str
+    createdAt: datetime
+    state: str
+    comments: Comments
+
+
+class DiscussionsNode(BaseModel):
+    number: int
+    author: Union[Author, None] = None
+    title: str
+    createdAt: datetime
+    comments: DiscussionsComments
+
+
+class IssuesEdge(BaseModel):
+    cursor: str
+    node: IssuesNode
+
+
+class DiscussionsEdge(BaseModel):
+    cursor: str
+    node: DiscussionsNode
+
+
+class Issues(BaseModel):
+    edges: List[IssuesEdge]
+
+
+class Discussions(BaseModel):
+    edges: List[DiscussionsEdge]
+
+
+class IssuesRepository(BaseModel):
+    issues: Issues
+
+
+class DiscussionsRepository(BaseModel):
+    discussions: Discussions
+
+
+class IssuesResponseData(BaseModel):
+    repository: IssuesRepository
+
+
+class DiscussionsResponseData(BaseModel):
+    repository: DiscussionsRepository
+
+
+class IssuesResponse(BaseModel):
+    data: IssuesResponseData
+
+
+class DiscussionsResponse(BaseModel):
+    data: DiscussionsResponseData
+
+
+# PRs
+
+
+class LabelNode(BaseModel):
+    name: str
+
+
+class Labels(BaseModel):
+    nodes: List[LabelNode]
+
+
+class ReviewNode(BaseModel):
+    author: Union[Author, None] = None
+    state: str
+
+
+class Reviews(BaseModel):
+    nodes: List[ReviewNode]
+
+
+class PullRequestNode(BaseModel):
+    number: int
+    labels: Labels
+    author: Union[Author, None] = None
+    title: str
+    createdAt: datetime
+    state: str
+    comments: Comments
+    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):
+    input_token: SecretStr
+    github_repository: str = "pydantic/pydantic"
+    request_timeout: int = 30
+
+
+def get_graphql_response(
+    *,
+    settings: Settings,
+    query: str,
+    after: Union[str, None] = None,
+) -> Dict[str, Any]:
+    headers = {"Authorization": f"token {settings.input_token.get_secret_value()}"}
+    variables = {"after": after}
+    response = requests.post(
+        github_graphql_url,
+        headers=headers,
+        timeout=settings.request_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_issue_edges(*, settings: Settings, after: Union[str, None] = None):
+    data = get_graphql_response(settings=settings, query=issues_query, after=after)
+    graphql_response = IssuesResponse.model_validate(data)
+    return graphql_response.data.repository.issues.edges
+
+
+def get_graphql_question_discussion_edges(
+    *,
+    settings: Settings,
+    after: Union[str, None] = None,
+):
+    data = get_graphql_response(
+        settings=settings,
+        query=discussions_query,
+        after=after,
+    )
+    graphql_response = DiscussionsResponse.model_validate(data)
+    return graphql_response.data.repository.discussions.edges
+
+
+def get_graphql_pr_edges(*, settings: Settings, after: Union[str, None] = None):
+    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_issues_experts(settings: Settings):
+    issue_nodes: List[IssuesNode] = []
+    issue_edges = get_graphql_issue_edges(settings=settings)
+
+    while issue_edges:
+        for edge in issue_edges:
+            issue_nodes.append(edge.node)
+        last_edge = issue_edges[-1]
+        issue_edges = get_graphql_issue_edges(settings=settings, after=last_edge.cursor)
+
+    commentors = Counter()
+    last_month_commentors = Counter()
+    authors: Dict[str, Author] = {}
+
+    now = datetime.now(tz=timezone.utc)
+    one_month_ago = now - timedelta(days=30)
+
+    for issue in issue_nodes:
+        issue_author_name = None
+        if issue.author:
+            authors[issue.author.login] = issue.author
+            issue_author_name = issue.author.login
+        issue_commentors = set()
+        for comment in issue.comments.nodes:
+            if comment.author:
+                authors[comment.author.login] = comment.author
+                if comment.author.login != issue_author_name:
+                    issue_commentors.add(comment.author.login)
+        for author_name in issue_commentors:
+            commentors[author_name] += 1
+            if issue.createdAt > one_month_ago:
+                last_month_commentors[author_name] += 1
+
+    return commentors, last_month_commentors, authors
+
+
+def get_discussions_experts(settings: Settings):
+    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
+        )
+
+    commentors = Counter()
+    last_month_commentors = Counter()
+    authors: Dict[str, Author] = {}
+
+    now = datetime.now(tz=timezone.utc)
+    one_month_ago = now - timedelta(days=30)
+
+    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 = set()
+        for comment in discussion.comments.nodes:
+            if comment.author:
+                authors[comment.author.login] = comment.author
+                if comment.author.login != discussion_author_name:
+                    discussion_commentors.add(comment.author.login)
+            for reply in comment.replies.nodes:
+                if reply.author:
+                    authors[reply.author.login] = reply.author
+                    if reply.author.login != discussion_author_name:
+                        discussion_commentors.add(reply.author.login)
+        for author_name in discussion_commentors:
+            commentors[author_name] += 1
+            if discussion.createdAt > one_month_ago:
+                last_month_commentors[author_name] += 1
+    return commentors, last_month_commentors, authors
+
+
+def get_experts(settings: Settings):
+    # Migrated to only use GitHub Discussions
+    # (
+    #     issues_commentors,
+    #     issues_last_month_commentors,
+    #     issues_authors,
+    # ) = get_issues_experts(settings=settings)
+    (
+        discussions_commentors,
+        discussions_last_month_commentors,
+        discussions_authors,
+    ) = get_discussions_experts(settings=settings)
+    # commentors = issues_commentors + discussions_commentors
+    commentors = discussions_commentors
+    # last_month_commentors = (
+    #     issues_last_month_commentors + discussions_last_month_commentors
+    # )
+    last_month_commentors = discussions_last_month_commentors
+    # authors = {**issues_authors, **discussions_authors}
+    authors = {**discussions_authors}
+    return commentors, last_month_commentors, authors
+
+
+def get_contributors(settings: Settings):
+    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)
+
+    contributors = Counter()
+    commentors = Counter()
+    reviewers = Counter()
+    authors: Dict[str, Author] = {}
+
+    for pr in pr_nodes:
+        author_name = None
+        if pr.author:
+            authors[pr.author.login] = pr.author
+            author_name = pr.author.login
+        pr_commentors: Set[str] = set()
+        pr_reviewers: Set[str] = set()
+        for comment in pr.comments.nodes:
+            if comment.author:
+                authors[comment.author.login] = comment.author
+                if comment.author.login == author_name:
+                    continue
+                pr_commentors.add(comment.author.login)
+        for author_name in pr_commentors:
+            commentors[author_name] += 1
+        for review in pr.reviews.nodes:
+            if review.author:
+                authors[review.author.login] = review.author
+                pr_reviewers.add(review.author.login)
+        for reviewer in pr_reviewers:
+            reviewers[reviewer] += 1
+        if pr.state == "MERGED" and pr.author:
+            contributors[pr.author.login] += 1
+    return contributors, commentors, reviewers, authors
+
+
+def get_top_users(
+    *,
+    counter: Counter,
+    min_count: int,
+    authors: Dict[str, Author],
+    skip_users: Container[str],
+):
+    users = []
+    for commentor, count in counter.most_common(50):
+        if commentor in skip_users:
+            continue
+        if count >= min_count:
+            author = authors[commentor]
+            users.append(
+                {
+                    "login": commentor,
+                    "count": count,
+                    "avatarUrl": author.avatarUrl,
+                    "url": author.url,
+                }
+            )
+    return users
+
+
+if __name__ == "__main__":
+    logging.basicConfig(level=logging.INFO)
+    settings = Settings()
+    logging.info(f"Using config: {settings.model_dump_json()}")
+    g = Github(settings.input_token.get_secret_value())
+    repo = g.get_repo(settings.github_repository)
+    question_commentors, question_last_month_commentors, question_authors = get_experts(
+        settings=settings
+    )
+    contributors, pr_commentors, reviewers, pr_authors = get_contributors(
+        settings=settings
+    )
+    authors = {**question_authors, **pr_authors}
+    maintainers_logins = {'samuelcolvin', 'adriangb', 'dmontagu', 'hramezani', 'Kludex', 'davidhewitt', 'sydney-runkle'}
+    bot_names = {"codecov", "github-actions", "pre-commit-ci", "dependabot"}
+    maintainers = []
+    for login in maintainers_logins:
+        user = authors[login]
+        maintainers.append(
+            {
+                "login": login,
+                "answers": question_commentors[login],
+                "prs": contributors[login],
+                "avatarUrl": user.avatarUrl,
+                "url": user.url,
+            }
+        )
+
+    min_count_expert = 10
+    min_count_last_month = 3
+    min_count_contributor = 4
+    min_count_reviewer = 4
+    skip_users = maintainers_logins | bot_names
+    experts = get_top_users(
+        counter=question_commentors,
+        min_count=min_count_expert,
+        authors=authors,
+        skip_users=skip_users,
+    )
+    last_month_active = get_top_users(
+        counter=question_last_month_commentors,
+        min_count=min_count_last_month,
+        authors=authors,
+        skip_users=skip_users,
+    )
+    top_contributors = get_top_users(
+        counter=contributors,
+        min_count=min_count_contributor,
+        authors=authors,
+        skip_users=skip_users,
+    )
+    top_reviewers = get_top_users(
+        counter=reviewers,
+        min_count=min_count_reviewer,
+        authors=authors,
+        skip_users=skip_users,
+    )
+
+    people = {
+        "maintainers": maintainers,
+        "experts": experts,
+        "last_month_active": last_month_active,
+        "top_contributors": top_contributors,
+        "top_reviewers": top_reviewers,
+    }
+    people_path = Path("./docs/plugins/people.yml")
+    people_old_content = people_path.read_text(encoding="utf-8")
+    new_people_content = yaml.dump(
+        people, sort_keys=False, width=200, allow_unicode=True
+    )
+    if (
+        people_old_content == new_people_content
+    ):
+        logging.info("The Pydantic People data hasn't changed, finishing.")
+        sys.exit(0)
+    people_path.write_text(new_people_content, encoding="utf-8")
+
+    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 = "pydantic-people-update"
+    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 Pydantic People"
+    result = 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="main", head=branch_name)
+    logging.info(f"Created PR: {pr.number}")
+    logging.info("Finished")
diff --git a/docs/plugins/main.py b/docs/plugins/main.py
--- a/docs/plugins/main.py
+++ b/docs/plugins/main.py
@@ -8,9 +8,11 @@
 from pathlib import Path
 from textwrap import indent
 
-import autoflake  # type: ignore
+import autoflake
 import pyupgrade._main as pyupgrade_main  # type: ignore
 import tomli
+import yaml
+from jinja2 import Template  # type: ignore
 from mkdocs.config import Config
 from mkdocs.structure.files import Files
 from mkdocs.structure.pages import Page
@@ -55,6 +57,8 @@ def on_page_markdown(markdown: str, page: Page, config: Config, files: Files) ->
         return md
     elif md := devtools_example(markdown, page):
         return md
+    elif md := populate_pydantic_people(markdown, page):
+        return md
     else:
         return markdown
 
@@ -309,3 +313,116 @@ def devtools_example(markdown: str, page: Page) -> str | None:
     html = (THIS_DIR / 'devtools_output.html').read_text().strip('\n')
     full_html = f'<div class="highlight">\n<pre><code>{html}</code></pre>\n</div>'
     return re.sub(r'{{ *devtools_example *}}', full_html, markdown)
+
+
+experts_template = Template(
+    """
+<div class="user-list user-list-center">
+    {% for user in people.experts %}
+    <div class="user">
+        <a href="{{ user.url }}" target="_blank">
+            <div class="avatar-wrapper">
+                <img src="{{ user.avatarUrl }}"/>
+            </div>
+            <div class="title">@{{ user.login }}</div>
+        </a>
+        <div class="count">Questions replied: {{ user.count }}</div>
+    </div>
+    {% endfor %}
+</div>
+"""
+)
+
+most_active_users_template = Template(
+    """
+
+<div class="user-list user-list-center">
+    {% for user in people.last_month_active %}
+    <div class="user">
+        <a href="{{ user.url }}" target="_blank">
+            <div class="avatar-wrapper">
+                <img src="{{ user.avatarUrl }}"/>
+            </div>
+            <div class="title">@{{ user.login }}</div>
+        </a>
+        <div class="count">Questions replied: {{ user.count }}</div>
+    </div>
+    {% endfor %}
+</div>
+"""
+)
+
+top_contributors_template = Template(
+    """
+<div class="user-list user-list-center">
+    {% for user in people.top_contributors %}
+    <div class="user">
+        <a href="{{ user.url }}" target="_blank">
+            <div class="avatar-wrapper">
+                <img src="{{ user.avatarUrl }}"/>
+            </div>
+            <div class="title">@{{ user.login }}</div>
+        </a>
+        <div class="count">Contributions: {{ user.count }}</div>
+    </div>
+    {% endfor %}
+</div>
+"""
+)
+
+top_reviewers_template = Template(
+    """
+<div class="user-list user-list-center">
+    {% for user in people.top_reviewers %}
+    <div class="user">
+        <a href="{{ user.url }}" target="_blank">
+            <div class="avatar-wrapper">
+                <img src="{{ user.avatarUrl }}"/>
+            </div>
+            <div class="title">@{{ user.login }}</div>
+        </a>
+        <div class="count">Reviews: {{ user.count }}</div>
+    </div>
+    {% endfor %}
+</div>
+"""
+)
+
+maintainers_template = Template(
+    """
+<div class="user-list user-list-center">
+    {% for user in people.maintainers %}
+    <div class="user">
+        <a href="{{ user.url }}" target="_blank">
+            <div class="avatar-wrapper">
+                <img src="{{ user.avatarUrl }}"/>
+            </div>
+            <div class="title">@{{ user.login }}</div>
+        </a>
+    </div>
+    {% endfor %}
+</div>
+"""
+)
+
+
+def populate_pydantic_people(markdown: str, page: Page) -> None:
+    if page.file.src_uri != 'pydantic_people.md':
+        return None
+
+    # read people.yml file data
+    with (THIS_DIR / 'people.yml').open('rb') as f:
+        people = yaml.load(f, Loader=yaml.FullLoader)
+
+    # Render the templates
+    for name, template in [
+        ('experts', experts_template),
+        ('most_active_users', most_active_users_template),
+        ('top_contributors', top_contributors_template),
+        ('top_reviewers', top_reviewers_template),
+        ('maintainers', maintainers_template),
+    ]:
+        rendered = template.render(people=people)
+        markdown = re.sub(f'{{{{ {name} }}}}', rendered, markdown)
+
+    return markdown


Test patch: