Skip to content
Open
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
85 changes: 85 additions & 0 deletions source-repo-scripts/project_contributors/README.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,85 @@
# GitHub Contributor Generator

This project provides a set of tools to generate a list of contributors from
merged pull requests in a GitHub organization.

## Installation

It is recommended to use a virtual environment to manage dependencies:

```bash
python3 -m venv venv
source venv/bin/activate
pip install --upgrade pip
pip install -e .
```

## Prerequisites

This project requires the [GitHub CLI (`gh`)](https://cli.github.com/) to be
installed and authenticated. Please ensure you have it set up before running
the scripts.

## Usage

### 1. Get Merged Pull Requests

The `get-merged-prs` script fetches all merged pull requests for a given
GitHub organization and date range. It handles pagination and bypasses the
GitHub API's 1000-item search limit.

**Example:**

To get all merged pull requests for the `gazebosim` organization from
October 1, 2024, to September 23, 2025 (corresponding to the Jetty Release),
run the following command:

```bash
get-merged-prs gazebosim 2024-10-01 2025-09-23 > gazebosim-prs.json
```

This will create a `gazebosim-prs.json` file containing the merged pull
requests.

### 2. Generate Contributors List and Collage

The `generate-contributors` script can be used to generate a Markdown file
with a list of contributors or a collage of their avatars.

#### Generate Markdown List

To generate a Markdown file with the list of contributors from the
`gazebosim-prs.json` file, run:

```bash
generate-contributors md gazebosim-prs.json contributors.md
```

This will create a `contributors.md` file.

#### Generate Avatar Collage

To generate a collage of contributor avatars, you can use either the JSON
file or a list of usernames.

**From JSON file:**

```bash
generate-contributors collage --input-json gazebosim-prs.json \
media/contributors.png --columns 15
```

**From a list of usernames:**

```bash
generate-contributors collage --usernames user1 user2 user3 \
media/contributors.png --rows 5
```

This will create a `media/contributors.png` file with the collage of
avatars. You can use the `--rows` or `--columns` flags to specify the
layout of the collage.

### Example Collage

![Contributors Collage](media/contributors.png)
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
33 changes: 33 additions & 0 deletions source-repo-scripts/project_contributors/pyproject.toml
Original file line number Diff line number Diff line change
@@ -0,0 +1,33 @@
[build-system]
requires = ["setuptools>=61.0"]
build-backend = "setuptools.build_meta"

[project]
name = "github-contributor-generator"
version = "0.1.0"
description = "Generates a Markdown list and/or an avatar collage of contributors from a GitHub pull request JSON file or a list of usernames."
readme = "README.md"
requires-python = ">=3.8"
license = { text = "Apache-2.0" }
authors = [
{ name = "Addisu Taddese", email = "addisuzt@intrinsic.ai" },
]
classifiers = [
"Programming Language :: Python :: 3",
"License :: OSI Approved :: Apache Software License",
"Operating System :: OS Independent",
"Topic :: Software Development :: Documentation",
"Topic :: Utilities",
]
dependencies = [
"requests",
"Pillow",
]

[project.scripts]
get-merged-prs = "get_merged_prs:main"
generate-contributors = "generate_contributors:main"

[tool.setuptools.packages.find]
where = ["src"]

126 changes: 126 additions & 0 deletions source-repo-scripts/project_contributors/src/collage.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,126 @@
# Copyright 2025 Open Source Robotics Foundation
#
# Licensed under the Apache License, Version 2.0 (the "License");
# you may not use this file except in compliance with the License.
# You may obtain a copy of the License at
#
# http://www.apache.org/licenses/LICENSE-2.0
#
# Unless required by applicable law or agreed to in writing, software
# distributed under the License is distributed on an "AS IS" BASIS,
# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
# See the License for the specific language governing permissions and
# limitations under the License.

# This file was generated by Gemini 2.5 Pro.

"""
Provides functionality to create a collage from a list of images.
"""

import os
import math
import requests
from PIL import Image, ImageDraw, ImageOps
from urllib.parse import urlparse

AVATAR_SIZE = 256 # The size (width/height) for each avatar in the collage
CACHE_DIR = ".avatar_cache" # Directory to store downloaded avatars

def download_avatar(username: str, url: str) -> str | None:
"""
Downloads a user's avatar and saves it to a local cache.

Args:
username: The GitHub username, used for the filename.
url: The URL of the avatar image.

Returns:
The local file path to the cached avatar, or None on failure.
"""
os.makedirs(CACHE_DIR, exist_ok=True)
file_extension = os.path.splitext(urlparse(url).path)[1] or '.png'
cache_path = os.path.join(CACHE_DIR, f"{username}{file_extension}")

if os.path.exists(cache_path):
return cache_path

try:
response = requests.get(url, stream=True)
response.raise_for_status()
with open(cache_path, 'wb') as f:
for chunk in response.iter_content(chunk_size=8192):
f.write(chunk)
return cache_path
except requests.exceptions.RequestException as e:
print(f"❌ Failed to download avatar for {username}: {e}")
return None

def create_circular_avatar(image_path: str) -> Image:
"""
Opens an image, crops it to a circle, and returns it.

Args:
image_path: Path to the square avatar image.

Returns:
A PIL Image object with a transparent background, cropped to a circle.
"""
img = Image.open(image_path).convert("RGBA")

mask = Image.new('L', img.size, 0)
draw = ImageDraw.Draw(mask)
draw.ellipse((0, 0) + img.size, fill=255)

output = ImageOps.fit(img, mask.size, centering=(0.5, 0.5))
output.putalpha(mask)
return output

def create_collage(image_paths: list[str], output_path: str, rows: int | None = None, columns: int | None = None):
"""
Creates a collage from a list of circular avatars with a specified aspect ratio.

Args:
image_paths: A list of file paths to the avatar images.
output_path: The path to save the final collage image.
rows: The number of rows in the collage grid.
columns: The number of columns in the collage grid.
"""
num_images = len(image_paths)
if not num_images:
print("⚠️ No images provided to create a collage.")
return

if rows and not columns:
grid_rows = rows
grid_cols = int(math.ceil(num_images / grid_rows))
elif columns and not rows:
grid_cols = columns
grid_rows = int(math.ceil(num_images / grid_cols))
else:
grid_cols = int(math.ceil(math.sqrt(num_images)))
if num_images > 0:
grid_rows = int(math.ceil(num_images / grid_cols))
else:
grid_rows = 0

canvas_width = grid_cols * AVATAR_SIZE
canvas_height = grid_rows * AVATAR_SIZE

collage = Image.new('RGBA', (canvas_width, canvas_height), (255, 255, 255, 0))

print(f"Creating a {grid_cols}x{grid_rows} collage for {num_images} contributors...")
for i, path in enumerate(image_paths):
try:
avatar = create_circular_avatar(path)
if avatar.size != (AVATAR_SIZE, AVATAR_SIZE):
avatar = avatar.resize((AVATAR_SIZE, AVATAR_SIZE), Image.Resampling.LANCZOS)

x = (i % grid_cols) * AVATAR_SIZE
y = (i // grid_cols) * AVATAR_SIZE
collage.paste(avatar, (x, y), avatar)
except Exception as e:
print(f"❌ Error processing image {path}: {e}")

collage.save(output_path, 'PNG')
print(f"✅ Collage saved successfully to '{output_path}'")
Loading
Loading