diff --git a/.github/workflows/contributors.yml b/.github/workflows/contributors.yml
new file mode 100644
index 0000000..aa7536c
--- /dev/null
+++ b/.github/workflows/contributors.yml
@@ -0,0 +1,36 @@
+name: Contributors
+
+on:
+ workflow_dispatch:
+ schedule:
+ - cron: "0 6 * * 1" # Mondays 06:00 UTC
+
+permissions:
+ contents: write
+
+jobs:
+ update:
+ runs-on: ubuntu-latest
+ steps:
+ - uses: actions/checkout@v4
+
+ - uses: actions/setup-python@v5
+ with:
+ python-version: "3.x"
+
+ - name: Regenerate contributor grids
+ run: python scripts/update_contributors.py
+ env:
+ GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
+
+ - name: Commit if changed
+ run: |
+ if [ -n "$(git status --porcelain)" ]; then
+ git config user.name "github-actions[bot]"
+ git config user.email "github-actions[bot]@users.noreply.github.com"
+ git add README.md docs/project/contributors.md
+ git commit -m "chore: refresh contributors"
+ git push
+ else
+ echo "No contributor changes."
+ fi
diff --git a/README.md b/README.md
index bb890c0..7de2137 100644
--- a/README.md
+++ b/README.md
@@ -6,7 +6,6 @@
-
@@ -192,10 +191,24 @@ place to start — ping us in Discord and we'll point you at the right files.
## Acknowledgements
-- [@foureight84](https://github.com/foureight84) for the
- [Android port](https://github.com/foureight84/PulseLoopAndroid).
-- Everyone who's filed an issue, opened a PR, or reported a device — see the
- [contributors graph](https://github.com/saksham2001/PulseLoopiOS/graphs/contributors).
+### Contributors
+
+Thanks to everyone who's contributed to the iOS app 💜
+
+
+
+
+
+
+Everyone who's filed an issue, opened a PR, or reported a device shows up in the
+[contributors graph](https://github.com/saksham2001/PulseLoopiOS/graphs/contributors).
## License
diff --git a/docs/project/contributors.md b/docs/project/contributors.md
new file mode 100644
index 0000000..ac3fe2e
--- /dev/null
+++ b/docs/project/contributors.md
@@ -0,0 +1,26 @@
+---
+title: Contributors
+description: The people building PulseLoop on iOS and Android.
+---
+
+# Contributors
+
+PulseLoop is built by a community across **iOS** and **Android**. This page is
+generated automatically from both repositories.
+
+
+
+
+
+
+!!! tip "Want to join them?"
+ See the [Contributing guide](contributing.md).
diff --git a/mkdocs.yml b/mkdocs.yml
index 24b4f05..3b0cba7 100644
--- a/mkdocs.yml
+++ b/mkdocs.yml
@@ -120,4 +120,5 @@ nav:
- Roadmap: project/roadmap.md
- Architecture: project/architecture.md
- Contributing: project/contributing.md
+ - Contributors: project/contributors.md
- Privacy: project/privacy.md
diff --git a/scripts/update_contributors.py b/scripts/update_contributors.py
new file mode 100644
index 0000000..d79e166
--- /dev/null
+++ b/scripts/update_contributors.py
@@ -0,0 +1,158 @@
+#!/usr/bin/env python3
+"""Generate contributor avatar grids from the GitHub API.
+
+Fetches contributors for the iOS repo (and, for the docs page, the Android repo
+too), renders an HTML avatar grid, and injects it between marker comments in
+README.md and docs/project/contributors.md.
+
+Stdlib only — no third-party dependencies. Set GITHUB_TOKEN to raise the API
+rate limit (reading public-repo contributors works without it).
+
+Usage:
+ python scripts/update_contributors.py
+"""
+
+import json
+import os
+import re
+import sys
+import urllib.error
+import urllib.request
+
+# Repos to pull from.
+IOS_REPO = ("saksham2001", "PulseLoopiOS")
+ANDROID_REPO = ("foureight84", "PulseLoopAndroid")
+
+# Paths, relative to the repo root (this script lives in scripts/).
+REPO_ROOT = os.path.dirname(os.path.dirname(os.path.abspath(__file__)))
+README_PATH = os.path.join(REPO_ROOT, "README.md")
+DOCS_PATH = os.path.join(REPO_ROOT, "docs", "project", "contributors.md")
+
+# Bots to exclude, in addition to accounts whose login ends with "[bot]".
+BOT_LOGINS = {"dependabot", "dependabot[bot]", "github-actions", "github-actions[bot]"}
+
+COLUMNS = 6
+AVATAR_SIZE = 80 # rendered px
+MARKER_START = ""
+MARKER_END = ""
+GENERATED_NOTE = ""
+
+
+def _is_bot(contributor):
+ login = contributor.get("login", "")
+ return (
+ contributor.get("type") != "User"
+ or login in BOT_LOGINS
+ or login.endswith("[bot]")
+ )
+
+
+def fetch_contributors(owner, repo):
+ """Return a list of human contributors for owner/repo, sorted by contributions."""
+ contributors = []
+ page = 1
+ token = os.environ.get("GITHUB_TOKEN")
+ while True:
+ url = (
+ f"https://api.github.com/repos/{owner}/{repo}/contributors"
+ f"?per_page=100&anon=0&page={page}"
+ )
+ req = urllib.request.Request(url)
+ req.add_header("Accept", "application/vnd.github+json")
+ req.add_header("X-GitHub-Api-Version", "2022-11-28")
+ req.add_header("User-Agent", "pulseloop-contributors-script")
+ if token:
+ req.add_header("Authorization", f"Bearer {token}")
+ try:
+ with urllib.request.urlopen(req) as resp:
+ batch = json.loads(resp.read().decode("utf-8"))
+ except urllib.error.HTTPError as err:
+ print(f"error: GitHub API {err.code} for {owner}/{repo}: {err.reason}", file=sys.stderr)
+ raise
+ if not batch:
+ break
+ contributors.extend(batch)
+ if len(batch) < 100:
+ break
+ page += 1
+
+ humans = [c for c in contributors if not _is_bot(c)]
+ humans.sort(key=lambda c: c.get("contributions", 0), reverse=True)
+ return humans
+
+
+def merge_dedup(*lists):
+ """Merge contributor lists, keyed by lowercased login, summing contributions."""
+ merged = {}
+ for contributors in lists:
+ for c in contributors:
+ key = c.get("login", "").lower()
+ if not key:
+ continue
+ if key in merged:
+ merged[key]["contributions"] += c.get("contributions", 0)
+ else:
+ merged[key] = dict(c)
+ return sorted(merged.values(), key=lambda c: c.get("contributions", 0), reverse=True)
+
+
+def render_grid(contributors, columns=COLUMNS):
+ """Render contributors as an HTML table of avatar + username cells."""
+ if not contributors:
+ return "_No contributors found._"
+
+ rows = []
+ for i in range(0, len(contributors), columns):
+ cells = []
+ for c in contributors[i : i + columns]:
+ login = c["login"]
+ avatar = f"{c['avatar_url']}&s=100"
+ cell = (
+ f''
+ f' '
+ f"@{login} | "
+ )
+ cells.append(cell)
+ rows.append(" \n " + "\n ".join(cells) + "\n
")
+
+ return "\n" + "\n".join(rows) + "\n
"
+
+
+def inject(path, grid):
+ """Replace the content between the marker comments in `path`. Returns True if changed."""
+ with open(path, "r", encoding="utf-8") as fh:
+ original = fh.read()
+
+ block = f"{MARKER_START}\n{GENERATED_NOTE}\n{grid}\n{MARKER_END}"
+ pattern = re.compile(
+ re.escape(MARKER_START) + r".*?" + re.escape(MARKER_END),
+ re.DOTALL,
+ )
+ if not pattern.search(original):
+ print(f"error: markers not found in {path}", file=sys.stderr)
+ raise SystemExit(1)
+
+ updated = pattern.sub(lambda _: block, original)
+ if updated == original:
+ print(f"unchanged: {path}")
+ return False
+
+ with open(path, "w", encoding="utf-8") as fh:
+ fh.write(updated)
+ print(f"updated: {path}")
+ return True
+
+
+def main():
+ ios = fetch_contributors(*IOS_REPO)
+ android = fetch_contributors(*ANDROID_REPO)
+
+ print(f"iOS contributors: {len(ios)}; Android contributors: {len(android)}")
+
+ inject(README_PATH, render_grid(ios))
+ inject(DOCS_PATH, render_grid(merge_dedup(ios, android)))
+
+
+if __name__ == "__main__":
+ main()