Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
36 changes: 36 additions & 0 deletions .github/workflows/contributors.yml
Original file line number Diff line number Diff line change
@@ -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
23 changes: 18 additions & 5 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -6,7 +6,6 @@
<a href="https://github.com/saksham2001/PulseLoopiOS/network/members"><img src="https://img.shields.io/github/forks/saksham2001/PulseLoopiOS?style=flat&logo=github" alt="Forks"></a>
<a href="https://github.com/saksham2001/PulseLoopiOS/issues"><img src="https://img.shields.io/github/issues/saksham2001/PulseLoopiOS" alt="Issues"></a>
<a href="https://github.com/saksham2001/PulseLoopiOS/pulls"><img src="https://img.shields.io/github/issues-pr/saksham2001/PulseLoopiOS?label=PRs&logo=github" alt="PRs"></a>
<!-- TODO: replace ci.yml with your actual workflow filename in .github/workflows/ -->
<a href="https://github.com/saksham2001/PulseLoopiOS/actions"><img src="https://img.shields.io/github/actions/workflow/status/saksham2001/PulseLoopiOS/ci.yml?label=CI&logo=githubactions&logoColor=white" alt="CI"></a>
<img src="https://img.shields.io/badge/platform-iOS%2018%2B-lightgrey?logo=apple" alt="Platform">
<img src="https://img.shields.io/badge/Swift-orange?logo=swift&logoColor=white" alt="Swift">
Expand Down Expand Up @@ -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 💜

<!-- CONTRIBUTORS:START -->
<!-- auto-generated by scripts/update_contributors.py — do not edit by hand -->
<table>
<tr>
<td align="center"><a href="https://github.com/saksham2001"><img src="https://avatars.githubusercontent.com/u/44300901?v=4&s=100" width="80" height="80" alt="@saksham2001" style="border-radius:50%" /><br /><sub>@saksham2001</sub></a></td>
<td align="center"><a href="https://github.com/rgvxsthi"><img src="https://avatars.githubusercontent.com/u/65727207?v=4&s=100" width="80" height="80" alt="@rgvxsthi" style="border-radius:50%" /><br /><sub>@rgvxsthi</sub></a></td>
<td align="center"><a href="https://github.com/hoveeman"><img src="https://avatars.githubusercontent.com/u/18269595?v=4&s=100" width="80" height="80" alt="@hoveeman" style="border-radius:50%" /><br /><sub>@hoveeman</sub></a></td>
<td align="center"><a href="https://github.com/radxp"><img src="https://avatars.githubusercontent.com/u/207566977?v=4&s=100" width="80" height="80" alt="@radxp" style="border-radius:50%" /><br /><sub>@radxp</sub></a></td>
</tr>
</table>
<!-- CONTRIBUTORS:END -->

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

Expand Down
26 changes: 26 additions & 0 deletions docs/project/contributors.md
Original file line number Diff line number Diff line change
@@ -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.

<!-- CONTRIBUTORS:START -->
<!-- auto-generated by scripts/update_contributors.py — do not edit by hand -->
<table>
<tr>
<td align="center"><a href="https://github.com/saksham2001"><img src="https://avatars.githubusercontent.com/u/44300901?v=4&s=100" width="80" height="80" alt="@saksham2001" style="border-radius:50%" /><br /><sub>@saksham2001</sub></a></td>
<td align="center"><a href="https://github.com/foureight84"><img src="https://avatars.githubusercontent.com/u/1146028?v=4&s=100" width="80" height="80" alt="@foureight84" style="border-radius:50%" /><br /><sub>@foureight84</sub></a></td>
<td align="center"><a href="https://github.com/rgvxsthi"><img src="https://avatars.githubusercontent.com/u/65727207?v=4&s=100" width="80" height="80" alt="@rgvxsthi" style="border-radius:50%" /><br /><sub>@rgvxsthi</sub></a></td>
<td align="center"><a href="https://github.com/hoveeman"><img src="https://avatars.githubusercontent.com/u/18269595?v=4&s=100" width="80" height="80" alt="@hoveeman" style="border-radius:50%" /><br /><sub>@hoveeman</sub></a></td>
<td align="center"><a href="https://github.com/radxp"><img src="https://avatars.githubusercontent.com/u/207566977?v=4&s=100" width="80" height="80" alt="@radxp" style="border-radius:50%" /><br /><sub>@radxp</sub></a></td>
<td align="center"><a href="https://github.com/robisaks"><img src="https://avatars.githubusercontent.com/u/87010?v=4&s=100" width="80" height="80" alt="@robisaks" style="border-radius:50%" /><br /><sub>@robisaks</sub></a></td>
</tr>
</table>
<!-- CONTRIBUTORS:END -->

!!! tip "Want to join them?"
See the [Contributing guide](contributing.md).
1 change: 1 addition & 0 deletions mkdocs.yml
Original file line number Diff line number Diff line change
Expand Up @@ -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
158 changes: 158 additions & 0 deletions scripts/update_contributors.py
Original file line number Diff line number Diff line change
@@ -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 = "<!-- CONTRIBUTORS:START -->"
MARKER_END = "<!-- CONTRIBUTORS:END -->"
GENERATED_NOTE = "<!-- auto-generated by scripts/update_contributors.py — do not edit by hand -->"


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'<td align="center"><a href="{c["html_url"]}">'
f'<img src="{avatar}" width="{AVATAR_SIZE}" height="{AVATAR_SIZE}" '
f'alt="@{login}" style="border-radius:50%" /><br />'
f"<sub>@{login}</sub></a></td>"
)
cells.append(cell)
rows.append(" <tr>\n " + "\n ".join(cells) + "\n </tr>")

return "<table>\n" + "\n".join(rows) + "\n</table>"


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()
Loading