first commit

This commit is contained in:
Space-Banane
2026-05-29 19:15:00 +02:00
commit a54d1cfeaf
25 changed files with 1183 additions and 0 deletions

12
.env.example Normal file
View File

@@ -0,0 +1,12 @@
GITHUB_USERNAME=octocat
GITHUB_TOKEN=
GITEA_BASE_URL=https://gitea.example.com
GITEA_USERNAME=octocat
GITEA_TOKEN=
CACHE_TTL_SECONDS=3600
CACHE_DIR=/app/cache
DEFAULT_THEME=light
SERVICE_TITLE=git-activity-merge

6
.gitignore vendored Normal file
View File

@@ -0,0 +1,6 @@
__pycache__/
.pytest_cache/
.venv/
cache/
*.pyc
.env

19
Dockerfile Normal file
View File

@@ -0,0 +1,19 @@
FROM python:3.12-slim
ENV PYTHONDONTWRITEBYTECODE=1
ENV PYTHONUNBUFFERED=1
WORKDIR /app
RUN apt-get update \
&& apt-get install -y --no-install-recommends libcairo2 \
&& rm -rf /var/lib/apt/lists/*
COPY pyproject.toml README.md ./
COPY app ./app
RUN pip install --no-cache-dir .
EXPOSE 8000
CMD ["uvicorn", "app.main:app", "--host", "0.0.0.0", "--port", "8000", "--workers", "2", "--proxy-headers"]

115
README.md Normal file
View File

@@ -0,0 +1,115 @@
# git-activity-merge
Small self-hosted FastAPI service that merges GitHub + Gitea contribution data and returns embeddable contribution heatmap images.
Example for GitHub profile README:
```md
![Activity](https://example.com/activity.svg)
```
## What It Does
- Fetches contribution data from:
- GitHub GraphQL API (`GITHUB_USERNAME`, optional `GITHUB_TOKEN`)
- Gitea heatmap API (`{GITEA_BASE_URL}/api/v1/users/{GITEA_USERNAME}/heatmap`)
- Normalizes into date-based counts:
- `github`
- `gitea`
- `total`
- Caches source responses and rendered images on disk.
- Returns:
- merged JSON
- GitHub-style SVG heatmap
- PNG heatmap
## API Routes
- `GET /health`
- `GET /activity.json`
- `GET /activity.svg`
- `GET /activity.png`
Supported query params:
- `year=YYYY` or `days=365` (mutually exclusive)
- `theme=dark|light`
- `source=all|github|gitea`
Examples:
```bash
curl "http://localhost:8000/activity.json?days=365&source=all"
curl "http://localhost:8000/activity.svg?year=2026&theme=dark"
curl "http://localhost:8000/activity.png?days=180&source=gitea"
```
## Setup
### 1. Install
```bash
python -m venv .venv
. .venv/bin/activate # or .venv\Scripts\activate on Windows
pip install -e ".[dev]"
```
### 2. Configure
Copy `.env.example` to `.env` and edit values.
Required:
- `GITHUB_USERNAME`
- `GITEA_BASE_URL`
- `GITEA_USERNAME`
Optional:
- `GITHUB_TOKEN`
- `GITEA_TOKEN`
- `CACHE_TTL_SECONDS` (default: `3600`)
- `CACHE_DIR` (default: `./cache` locally, `/app/cache` in container)
- `DEFAULT_THEME` (`light` or `dark`, default: `light`)
- `SERVICE_TITLE` (default: `git-activity-merge`)
### 3. Run
```bash
uvicorn app.main:app --reload --port 8000
```
## Docker
```bash
docker compose up --build
```
Container serves on port `8000` and mounts a named cache volume at `/app/cache`.
## Reverse Proxy Example (Nginx)
```nginx
server {
server_name example.com;
location / {
proxy_pass http://127.0.0.1:8000;
proxy_set_header Host $host;
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
proxy_set_header X-Forwarded-Proto $scheme;
}
}
```
Then embed directly:
```md
![Activity](https://example.com/activity.svg?days=365&theme=dark)
```
## Notes
- GitHub GraphQL without token is possible but lower-rate/less reliable. Set `GITHUB_TOKEN` for stability.
- Gitea instances may require `GITEA_TOKEN` for private activity or stricter rate limits.
- If upstream APIs fail and stale cache exists, `/activity.json` includes `"stale": true` and stale data is served.

0
app/__init__.py Normal file
View File

63
app/cache.py Normal file
View File

@@ -0,0 +1,63 @@
import json
import os
import tempfile
import time
from dataclasses import dataclass
from pathlib import Path
from typing import Any
@dataclass
class CacheEntry:
value: Any
stale: bool
class FileCache:
def __init__(self, cache_dir: str, default_ttl_seconds: int) -> None:
self.cache_dir = Path(cache_dir)
self.cache_dir.mkdir(parents=True, exist_ok=True)
self.default_ttl_seconds = default_ttl_seconds
def _path_for(self, key: str) -> Path:
safe_key = "".join(c if c.isalnum() or c in ("-", "_") else "_" for c in key)
return self.cache_dir / f"{safe_key}.json"
def _read_raw(self, key: str) -> dict[str, Any] | None:
path = self._path_for(key)
if not path.exists():
return None
try:
return json.loads(path.read_text(encoding="utf-8"))
except (OSError, json.JSONDecodeError):
return None
def get_json(self, key: str, allow_stale: bool = False) -> CacheEntry | None:
payload = self._read_raw(key)
if payload is None:
return None
expires_at = float(payload.get("expires_at", 0))
value = payload.get("value")
stale = time.time() > expires_at
if stale and not allow_stale:
return None
return CacheEntry(value=value, stale=stale)
def set_json(self, key: str, value: Any, ttl_seconds: int | None = None) -> None:
ttl = ttl_seconds if ttl_seconds is not None else self.default_ttl_seconds
payload = {
"expires_at": time.time() + max(ttl, 0),
"value": value,
}
self._atomic_write_json(self._path_for(key), payload)
def _atomic_write_json(self, path: Path, payload: dict[str, Any]) -> None:
fd, tmp_name = tempfile.mkstemp(prefix="cache_", suffix=".tmp", dir=str(self.cache_dir))
try:
with os.fdopen(fd, "w", encoding="utf-8") as tmp_file:
json.dump(payload, tmp_file, separators=(",", ":"))
os.replace(tmp_name, path)
finally:
if os.path.exists(tmp_name):
os.remove(tmp_name)

268
app/main.py Normal file
View File

@@ -0,0 +1,268 @@
import hashlib
import json
import logging
import os
from dataclasses import dataclass
from datetime import UTC, date, datetime, timedelta
from typing import Literal
from fastapi import Depends, FastAPI, HTTPException, Query
from fastapi.responses import JSONResponse, Response
from app.cache import FileCache
from app.merge import filter_activity_source, merge_activity
from app.render.png import render_png_from_svg
from app.render.svg import render_activity_svg
from app.settings import Settings, get_settings
from app.sources.gitea import GiteaSourceError, fetch_gitea_activity
from app.sources.github import GitHubSourceError, fetch_github_activity
logger = logging.getLogger(__name__)
SourceType = Literal["all", "github", "gitea"]
ThemeType = Literal["light", "dark"]
@dataclass
class QueryOptions:
year: int | None
days: int
theme: ThemeType
source: SourceType
@dataclass
class ActivityResult:
merged: dict[str, dict[str, int]]
stale: bool
from_date: date
to_date: date
days_count: int
app = FastAPI(title=os.getenv("SERVICE_TITLE", "git-activity-merge"))
def get_cache(settings: Settings = Depends(get_settings)) -> FileCache:
return FileCache(cache_dir=settings.cache_dir, default_ttl_seconds=settings.cache_ttl_seconds)
def get_query_options(
settings: Settings = Depends(get_settings),
year: int | None = Query(default=None, ge=1970, le=2100),
days: int | None = Query(default=None, ge=1, le=5000),
theme: ThemeType | None = Query(default=None),
source: SourceType = Query(default="all"),
) -> QueryOptions:
if year is not None and days is not None:
raise HTTPException(status_code=400, detail="Provide either year or days, not both")
selected_theme = theme or ("dark" if settings.default_theme == "dark" else "light")
selected_days = days if days is not None else 365
return QueryOptions(year=year, days=selected_days, theme=selected_theme, source=source)
def compute_date_range(options: QueryOptions) -> tuple[date, date, int]:
today = datetime.now(tz=UTC).date()
if options.year is not None:
from_date = date(options.year, 1, 1)
to_date = date(options.year, 12, 31)
if options.year == today.year:
to_date = today
days_count = (to_date - from_date).days + 1
return from_date, to_date, days_count
to_date = today
from_date = to_date - timedelta(days=options.days - 1)
return from_date, to_date, options.days
def _date_keys(from_date: date, to_date: date) -> list[str]:
keys: list[str] = []
current = from_date
while current <= to_date:
keys.append(current.isoformat())
current += timedelta(days=1)
return keys
async def _fetch_with_cache(
cache: FileCache,
key: str,
fetcher,
) -> tuple[dict[str, int], bool]:
cached = cache.get_json(key, allow_stale=True)
if cached and not cached.stale:
return {k: int(v) for k, v in cached.value.items()}, False
try:
fresh = await fetcher()
cache.set_json(key, fresh)
return fresh, False
except (GitHubSourceError, GiteaSourceError, RuntimeError, ValueError) as exc:
if cached is not None:
logger.warning("source fetch failed for %s, serving stale cache: %s", key, exc)
return {k: int(v) for k, v in cached.value.items()}, True
raise
async def collect_merged_activity(
settings: Settings,
cache: FileCache,
options: QueryOptions,
) -> ActivityResult:
from_date, to_date, days_count = compute_date_range(options)
gh_key = f"github_{settings.github_username}_{from_date}_{to_date}"
gt_key = f"gitea_{settings.gitea_username}_{from_date}_{to_date}"
github_data: dict[str, int] = {}
gitea_data: dict[str, int] = {}
github_stale = False
gitea_stale = False
if options.source in ("all", "github"):
github_data, github_stale = await _fetch_with_cache(
cache,
gh_key,
lambda: fetch_github_activity(
username=settings.github_username,
token=settings.github_token,
from_date=from_date,
to_date=to_date,
),
)
if options.source in ("all", "gitea"):
gitea_data, gitea_stale = await _fetch_with_cache(
cache,
gt_key,
lambda: fetch_gitea_activity(
base_url=settings.gitea_base_url,
username=settings.gitea_username,
token=settings.gitea_token,
from_date=from_date,
to_date=to_date,
),
)
merged = merge_activity(github_data, gitea_data, dates=_date_keys(from_date, to_date))
merged = filter_activity_source(merged, options.source)
return ActivityResult(
merged=merged,
stale=github_stale or gitea_stale,
from_date=from_date,
to_date=to_date,
days_count=days_count,
)
def _daily_totals(merged: dict[str, dict[str, int]]) -> dict[str, int]:
return {day: int(payload.get("total", 0)) for day, payload in merged.items()}
def _image_cache_key(prefix: str, options: QueryOptions, result: ActivityResult) -> str:
digest = hashlib.sha1(
json.dumps(result.merged, sort_keys=True, separators=(",", ":")).encode("utf-8")
).hexdigest()
return f"{prefix}_{options.theme}_{options.source}_{result.from_date}_{result.to_date}_{digest}"
@app.get("/health")
async def health(settings: Settings = Depends(get_settings)) -> dict[str, str]:
return {"status": "ok", "service": settings.service_title}
@app.get("/activity.json")
async def activity_json(
settings: Settings = Depends(get_settings),
cache: FileCache = Depends(get_cache),
options: QueryOptions = Depends(get_query_options),
) -> JSONResponse:
try:
result = await collect_merged_activity(settings, cache, options)
except Exception as exc:
logger.exception("failed to fetch activity data")
raise HTTPException(status_code=502, detail="Failed to fetch activity data") from exc
total = sum(v.get("total", 0) for v in result.merged.values())
payload = {
"from": result.from_date.isoformat(),
"to": result.to_date.isoformat(),
"days": result.days_count,
"source": options.source,
"total": total,
"stale": result.stale,
"activity": result.merged,
}
return JSONResponse(content=payload)
@app.get("/activity.svg")
async def activity_svg(
settings: Settings = Depends(get_settings),
cache: FileCache = Depends(get_cache),
options: QueryOptions = Depends(get_query_options),
) -> Response:
try:
result = await collect_merged_activity(settings, cache, options)
except Exception as exc:
logger.exception("failed to fetch activity data")
raise HTTPException(status_code=502, detail="Failed to fetch activity data") from exc
cache_key = _image_cache_key("svg", options, result)
cached = cache.get_json(cache_key)
if cached is not None:
return Response(content=str(cached.value), media_type="image/svg+xml")
daily_totals = _daily_totals(result.merged)
total = sum(daily_totals.values())
svg = render_activity_svg(
daily_totals=daily_totals,
from_date=result.from_date,
to_date=result.to_date,
days_count=result.days_count,
total_contributions=total,
theme=options.theme,
)
cache.set_json(cache_key, svg)
return Response(content=svg, media_type="image/svg+xml")
@app.get("/activity.png")
async def activity_png(
settings: Settings = Depends(get_settings),
cache: FileCache = Depends(get_cache),
options: QueryOptions = Depends(get_query_options),
) -> Response:
try:
result = await collect_merged_activity(settings, cache, options)
except Exception as exc:
logger.exception("failed to fetch activity data")
raise HTTPException(status_code=502, detail="Failed to fetch activity data") from exc
cache_key = _image_cache_key("png", options, result)
cached = cache.get_json(cache_key)
if cached is not None:
png_data = bytes.fromhex(str(cached.value))
return Response(content=png_data, media_type="image/png")
daily_totals = _daily_totals(result.merged)
total = sum(daily_totals.values())
svg = render_activity_svg(
daily_totals=daily_totals,
from_date=result.from_date,
to_date=result.to_date,
days_count=result.days_count,
total_contributions=total,
theme=options.theme,
)
try:
png_data = render_png_from_svg(svg)
except Exception as exc:
logger.exception("failed to render png")
raise HTTPException(status_code=500, detail="PNG rendering failed") from exc
cache.set_json(cache_key, png_data.hex())
return Response(content=png_data, media_type="image/png")

42
app/merge.py Normal file
View File

@@ -0,0 +1,42 @@
from collections.abc import Iterable
def merge_activity(
github: dict[str, int],
gitea: dict[str, int],
dates: Iterable[str] | None = None,
) -> dict[str, dict[str, int]]:
keys = set(github) | set(gitea)
if dates is not None:
keys |= set(dates)
merged: dict[str, dict[str, int]] = {}
for date_key in sorted(keys):
gh = int(github.get(date_key, 0))
gt = int(gitea.get(date_key, 0))
merged[date_key] = {
"github": gh,
"gitea": gt,
"total": gh + gt,
}
return merged
def filter_activity_source(
merged: dict[str, dict[str, int]],
source: str,
) -> dict[str, dict[str, int]]:
if source == "all":
return merged
filtered: dict[str, dict[str, int]] = {}
for day, counts in merged.items():
github = counts.get("github", 0)
gitea = counts.get("gitea", 0)
if source == "github":
filtered[day] = {"github": github, "gitea": 0, "total": github}
elif source == "gitea":
filtered[day] = {"github": 0, "gitea": gitea, "total": gitea}
else:
filtered[day] = counts
return filtered

0
app/render/__init__.py Normal file
View File

4
app/render/png.py Normal file
View File

@@ -0,0 +1,4 @@
def render_png_from_svg(svg: str) -> bytes:
import cairosvg
return cairosvg.svg2png(bytestring=svg.encode("utf-8"))

125
app/render/svg.py Normal file
View File

@@ -0,0 +1,125 @@
from datetime import date, timedelta
THEMES: dict[str, dict[str, str | list[str]]] = {
"light": {
"bg": "#ffffff",
"text": "#24292f",
"muted": "#57606a",
"empty": "#ebedf0",
"levels": ["#ebedf0", "#9be9a8", "#40c463", "#30a14e", "#216e39"],
},
"dark": {
"bg": "#0d1117",
"text": "#c9d1d9",
"muted": "#8b949e",
"empty": "#161b22",
"levels": ["#161b22", "#0e4429", "#006d32", "#26a641", "#39d353"],
},
}
def _intensity_level(value: int, max_value: int) -> int:
if value <= 0 or max_value <= 0:
return 0
ratio = value / max_value
if ratio < 0.25:
return 1
if ratio < 0.5:
return 2
if ratio < 0.75:
return 3
return 4
def _build_calendar_days(from_date: date, to_date: date) -> list[date]:
days: list[date] = []
current = from_date
while current <= to_date:
days.append(current)
current += timedelta(days=1)
return days
def render_activity_svg(
daily_totals: dict[str, int],
from_date: date,
to_date: date,
days_count: int,
total_contributions: int,
theme: str,
) -> str:
palette = THEMES[theme if theme in THEMES else "light"]
levels = palette["levels"]
day_size = 11
gap = 3
left_padding = 42
top_padding = 30
month_label_height = 14
grid_top = top_padding + month_label_height
start = from_date - timedelta(days=(from_date.weekday() + 1) % 7)
end = to_date + timedelta(days=(5 - to_date.weekday()) % 7 + 1)
all_days = _build_calendar_days(start, end)
week_count = max(1, len(all_days) // 7)
grid_width = week_count * (day_size + gap)
grid_height = 7 * (day_size + gap)
width = left_padding + grid_width + 20
height = grid_top + grid_height + 34
max_value = max(daily_totals.values(), default=0)
lines: list[str] = []
lines.append(f'<svg xmlns="http://www.w3.org/2000/svg" width="{width}" height="{height}" role="img" aria-label="Contribution graph">')
lines.append(f'<rect width="100%" height="100%" fill="{palette["bg"]}" />')
lines.append(
f'<text x="{left_padding}" y="16" fill="{palette["text"]}" font-family="-apple-system,BlinkMacSystemFont,Segoe UI,Helvetica,Arial,sans-serif" font-size="12">{total_contributions} contributions in the last {days_count} days</text>'
)
month_x_positions: dict[str, int] = {}
for idx, day in enumerate(all_days):
if day.day == 1:
month_x_positions[day.strftime("%b")] = left_padding + (idx // 7) * (day_size + gap)
for label, x in month_x_positions.items():
lines.append(
f'<text x="{x}" y="{top_padding + 10}" fill="{palette["muted"]}" font-family="-apple-system,BlinkMacSystemFont,Segoe UI,Helvetica,Arial,sans-serif" font-size="10">{label}</text>'
)
weekday_labels = {1: "Mon", 3: "Wed", 5: "Fri"}
for row, label in weekday_labels.items():
y = grid_top + row * (day_size + gap) + 9
lines.append(
f'<text x="8" y="{y}" fill="{palette["muted"]}" font-family="-apple-system,BlinkMacSystemFont,Segoe UI,Helvetica,Arial,sans-serif" font-size="10">{label}</text>'
)
for idx, day in enumerate(all_days):
week = idx // 7
row = (day.weekday() + 1) % 7
x = left_padding + week * (day_size + gap)
y = grid_top + row * (day_size + gap)
value = daily_totals.get(day.isoformat(), 0)
level = _intensity_level(value, max_value)
fill = levels[level]
lines.append(
f'<rect x="{x}" y="{y}" width="{day_size}" height="{day_size}" rx="2" ry="2" fill="{fill}" />'
)
legend_y = grid_top + grid_height + 16
legend_x = left_padding + grid_width - 120
lines.append(
f'<text x="{legend_x}" y="{legend_y}" fill="{palette["muted"]}" font-family="-apple-system,BlinkMacSystemFont,Segoe UI,Helvetica,Arial,sans-serif" font-size="10">Less</text>'
)
for i, color in enumerate(levels):
lx = legend_x + 26 + i * (day_size + 2)
lines.append(
f'<rect x="{lx}" y="{legend_y - 9}" width="{day_size}" height="{day_size}" rx="2" ry="2" fill="{color}" />'
)
lines.append(
f'<text x="{legend_x + 26 + len(levels) * (day_size + 2) + 4}" y="{legend_y}" fill="{palette["muted"]}" font-family="-apple-system,BlinkMacSystemFont,Segoe UI,Helvetica,Arial,sans-serif" font-size="10">More</text>'
)
lines.append("</svg>")
return "".join(lines)

26
app/settings.py Normal file
View File

@@ -0,0 +1,26 @@
from functools import lru_cache
from pydantic import Field
from pydantic_settings import BaseSettings, SettingsConfigDict
class Settings(BaseSettings):
model_config = SettingsConfigDict(env_file=".env", case_sensitive=False)
github_username: str = Field(alias="GITHUB_USERNAME")
github_token: str | None = Field(default=None, alias="GITHUB_TOKEN")
gitea_base_url: str = Field(alias="GITEA_BASE_URL")
gitea_username: str = Field(alias="GITEA_USERNAME")
gitea_token: str | None = Field(default=None, alias="GITEA_TOKEN")
cache_ttl_seconds: int = Field(default=3600, alias="CACHE_TTL_SECONDS")
cache_dir: str = Field(default="./cache", alias="CACHE_DIR")
default_theme: str = Field(default="light", alias="DEFAULT_THEME")
service_title: str = Field(default="git-activity-merge", alias="SERVICE_TITLE")
@lru_cache(maxsize=1)
def get_settings() -> Settings:
return Settings()

0
app/sources/__init__.py Normal file
View File

71
app/sources/gitea.py Normal file
View File

@@ -0,0 +1,71 @@
from datetime import UTC, date, datetime
from typing import Any
import httpx
class GiteaSourceError(RuntimeError):
pass
def _parse_gitea_date(raw: Any) -> str | None:
if isinstance(raw, int):
return datetime.fromtimestamp(raw, tz=UTC).date().isoformat()
if isinstance(raw, float):
return datetime.fromtimestamp(int(raw), tz=UTC).date().isoformat()
if isinstance(raw, str):
try:
if len(raw) == 10 and raw[4] == "-" and raw[7] == "-":
return raw
if raw.isdigit():
return datetime.fromtimestamp(int(raw), tz=UTC).date().isoformat()
return datetime.fromisoformat(raw.replace("Z", "+00:00")).date().isoformat()
except ValueError:
return None
return None
async def fetch_gitea_activity(
base_url: str,
username: str,
token: str | None,
from_date: date,
to_date: date,
timeout_seconds: float = 20.0,
) -> dict[str, int]:
endpoint = f"{base_url.rstrip('/')}/api/v1/users/{username}/heatmap"
headers: dict[str, str] = {"Accept": "application/json"}
if token:
headers["Authorization"] = f"token {token}"
async with httpx.AsyncClient(timeout=timeout_seconds) as client:
response = await client.get(endpoint, headers=headers)
if response.status_code >= 400:
raise GiteaSourceError(f"Gitea heatmap request failed with status {response.status_code}")
payload = response.json()
if not isinstance(payload, list):
raise GiteaSourceError("Unexpected Gitea heatmap payload format")
normalized: dict[str, int] = {}
for item in payload:
if not isinstance(item, dict):
continue
date_key = _parse_gitea_date(item.get("date") or item.get("timestamp") or item.get("day"))
if not date_key:
continue
count = item.get("contributions")
if count is None:
count = item.get("count", 0)
try:
count_int = int(count)
except (TypeError, ValueError):
count_int = 0
if from_date.isoformat() <= date_key <= to_date.isoformat():
normalized[date_key] = normalized.get(date_key, 0) + count_int
return normalized

142
app/sources/github.py Normal file
View File

@@ -0,0 +1,142 @@
import re
from datetime import date, datetime, timezone
from typing import Any
import httpx
GITHUB_GRAPHQL_URL = "https://api.github.com/graphql"
class GitHubSourceError(RuntimeError):
pass
def _extract_attr(tag: str, attr: str) -> str | None:
match = re.search(rf'{attr}="([^"]+)"', tag)
return match.group(1) if match else None
def _parse_public_contributions_html(html: str, from_date: date, to_date: date) -> dict[str, int]:
tooltip_by_id: dict[str, int] = {}
for tooltip_match in re.finditer(r'<tool-tip[^>]*for="([^"]+)"[^>]*>(.*?)</tool-tip>', html, flags=re.S):
cell_id = tooltip_match.group(1)
tooltip_text = re.sub(r"<[^>]+>", "", tooltip_match.group(2)).strip()
count_match = re.search(r"(\d[\d,]*)\s+contribution", tooltip_text, flags=re.I)
if not count_match:
if "No contributions" in tooltip_text:
tooltip_by_id[cell_id] = 0
continue
tooltip_by_id[cell_id] = int(count_match.group(1).replace(",", ""))
normalized: dict[str, int] = {}
for td_match in re.finditer(r"<td[^>]*ContributionCalendar-day[^>]*></td>", html, flags=re.S):
tag = td_match.group(0)
date_key = _extract_attr(tag, "data-date")
cell_id = _extract_attr(tag, "id")
if not date_key or not cell_id:
continue
if from_date.isoformat() <= date_key <= to_date.isoformat():
normalized[date_key] = tooltip_by_id.get(cell_id, 0)
return normalized
async def _fetch_github_activity_public(
username: str,
from_date: date,
to_date: date,
timeout_seconds: float,
) -> dict[str, int]:
endpoint = (
f"https://github.com/users/{username}/contributions"
f"?from={from_date.isoformat()}&to={to_date.isoformat()}"
)
headers = {
"Accept": "text/html",
"User-Agent": "git-activity-merge/0.1",
}
async with httpx.AsyncClient(timeout=timeout_seconds, follow_redirects=True) as client:
response = await client.get(endpoint, headers=headers)
if response.status_code >= 400:
raise GitHubSourceError(
f"GitHub public contributions request failed with status {response.status_code}"
)
return _parse_public_contributions_html(response.text, from_date, to_date)
async def fetch_github_activity(
username: str,
token: str | None,
from_date: date,
to_date: date,
timeout_seconds: float = 20.0,
) -> dict[str, int]:
if not token:
return await _fetch_github_activity_public(
username=username,
from_date=from_date,
to_date=to_date,
timeout_seconds=timeout_seconds,
)
query = """
query($login: String!, $from: DateTime!, $to: DateTime!) {
user(login: $login) {
contributionsCollection(from: $from, to: $to) {
contributionCalendar {
weeks {
contributionDays {
date
contributionCount
}
}
}
}
}
}
"""
variables: dict[str, Any] = {
"login": username,
"from": datetime.combine(from_date, datetime.min.time(), tzinfo=timezone.utc).isoformat(),
"to": datetime.combine(to_date, datetime.max.time(), tzinfo=timezone.utc).isoformat(),
}
headers: dict[str, str] = {"Accept": "application/json"}
if token:
headers["Authorization"] = f"bearer {token}"
async with httpx.AsyncClient(timeout=timeout_seconds) as client:
response = await client.post(
GITHUB_GRAPHQL_URL,
headers=headers,
json={"query": query, "variables": variables},
)
if response.status_code >= 400:
raise GitHubSourceError(f"GitHub GraphQL request failed with status {response.status_code}")
payload = response.json()
if payload.get("errors"):
raise GitHubSourceError("GitHub GraphQL response included errors")
user = payload.get("data", {}).get("user")
if not user:
return {}
weeks = (
user.get("contributionsCollection", {})
.get("contributionCalendar", {})
.get("weeks", [])
)
normalized: dict[str, int] = {}
for week in weeks:
for day in week.get("contributionDays", []):
date_key = str(day.get("date", ""))
if not date_key:
continue
normalized[date_key] = int(day.get("contributionCount", 0))
return normalized

14
docker-compose.yml Normal file
View File

@@ -0,0 +1,14 @@
services:
git-activity-merge:
build: .
container_name: git-activity-merge
ports:
- "8000:8000"
env_file:
- .env
volumes:
- activity_cache:/app/cache
restart: unless-stopped
volumes:
activity_cache:

View File

@@ -0,0 +1,131 @@
Metadata-Version: 2.4
Name: git-activity-merge
Version: 0.1.0
Summary: FastAPI service that merges GitHub and Gitea contribution heatmaps and renders embeddable images.
Requires-Python: >=3.11
Description-Content-Type: text/markdown
Requires-Dist: fastapi>=0.115.0
Requires-Dist: uvicorn[standard]>=0.30.0
Requires-Dist: httpx>=0.27.0
Requires-Dist: pydantic-settings>=2.4.0
Requires-Dist: cairosvg>=2.7.1
Provides-Extra: dev
Requires-Dist: pytest>=8.3.0; extra == "dev"
Requires-Dist: pytest-asyncio>=0.24.0; extra == "dev"
Requires-Dist: anyio>=4.4.0; extra == "dev"
# git-activity-merge
Small self-hosted FastAPI service that merges GitHub + Gitea contribution data and returns embeddable contribution heatmap images.
Example for GitHub profile README:
```md
![Activity](https://example.com/activity.svg)
```
## What It Does
- Fetches contribution data from:
- GitHub GraphQL API (`GITHUB_USERNAME`, optional `GITHUB_TOKEN`)
- Gitea heatmap API (`{GITEA_BASE_URL}/api/v1/users/{GITEA_USERNAME}/heatmap`)
- Normalizes into date-based counts:
- `github`
- `gitea`
- `total`
- Caches source responses and rendered images on disk.
- Returns:
- merged JSON
- GitHub-style SVG heatmap
- PNG heatmap
## API Routes
- `GET /health`
- `GET /activity.json`
- `GET /activity.svg`
- `GET /activity.png`
Supported query params:
- `year=YYYY` or `days=365` (mutually exclusive)
- `theme=dark|light`
- `source=all|github|gitea`
Examples:
```bash
curl "http://localhost:8000/activity.json?days=365&source=all"
curl "http://localhost:8000/activity.svg?year=2026&theme=dark"
curl "http://localhost:8000/activity.png?days=180&source=gitea"
```
## Setup
### 1. Install
```bash
python -m venv .venv
. .venv/bin/activate # or .venv\Scripts\activate on Windows
pip install -e ".[dev]"
```
### 2. Configure
Copy `.env.example` to `.env` and edit values.
Required:
- `GITHUB_USERNAME`
- `GITEA_BASE_URL`
- `GITEA_USERNAME`
Optional:
- `GITHUB_TOKEN`
- `GITEA_TOKEN`
- `CACHE_TTL_SECONDS` (default: `3600`)
- `CACHE_DIR` (default: `./cache` locally, `/app/cache` in container)
- `DEFAULT_THEME` (`light` or `dark`, default: `light`)
- `SERVICE_TITLE` (default: `git-activity-merge`)
### 3. Run
```bash
uvicorn app.main:app --reload --port 8000
```
## Docker
```bash
docker compose up --build
```
Container serves on port `8000` and mounts a named cache volume at `/app/cache`.
## Reverse Proxy Example (Nginx)
```nginx
server {
server_name example.com;
location / {
proxy_pass http://127.0.0.1:8000;
proxy_set_header Host $host;
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
proxy_set_header X-Forwarded-Proto $scheme;
}
}
```
Then embed directly:
```md
![Activity](https://example.com/activity.svg?days=365&theme=dark)
```
## Notes
- GitHub GraphQL without token is possible but lower-rate/less reliable. Set `GITHUB_TOKEN` for stability.
- Gitea instances may require `GITEA_TOKEN` for private activity or stricter rate limits.
- If upstream APIs fail and stale cache exists, `/activity.json` includes `"stale": true` and stale data is served.

View File

@@ -0,0 +1,21 @@
README.md
pyproject.toml
app/__init__.py
app/cache.py
app/main.py
app/merge.py
app/settings.py
app/render/__init__.py
app/render/png.py
app/render/svg.py
app/sources/__init__.py
app/sources/gitea.py
app/sources/github.py
git_activity_merge.egg-info/PKG-INFO
git_activity_merge.egg-info/SOURCES.txt
git_activity_merge.egg-info/dependency_links.txt
git_activity_merge.egg-info/requires.txt
git_activity_merge.egg-info/top_level.txt
tests/test_cache.py
tests/test_merge.py
tests/test_routes.py

View File

@@ -0,0 +1 @@

View File

@@ -0,0 +1,10 @@
fastapi>=0.115.0
uvicorn[standard]>=0.30.0
httpx>=0.27.0
pydantic-settings>=2.4.0
cairosvg>=2.7.1
[dev]
pytest>=8.3.0
pytest-asyncio>=0.24.0
anyio>=4.4.0

View File

@@ -0,0 +1 @@
app

27
pyproject.toml Normal file
View File

@@ -0,0 +1,27 @@
[project]
name = "git-activity-merge"
version = "0.1.0"
description = "FastAPI service that merges GitHub and Gitea contribution heatmaps and renders embeddable images."
readme = "README.md"
requires-python = ">=3.11"
dependencies = [
"fastapi>=0.115.0",
"uvicorn[standard]>=0.30.0",
"httpx>=0.27.0",
"pydantic-settings>=2.4.0",
"cairosvg>=2.7.1"
]
[project.optional-dependencies]
dev = [
"pytest>=8.3.0",
"pytest-asyncio>=0.24.0",
"anyio>=4.4.0"
]
[tool.pytest.ini_options]
testpaths = ["tests"]
[build-system]
requires = ["setuptools>=68", "wheel"]
build-backend = "setuptools.build_meta"

28
tests/test_cache.py Normal file
View File

@@ -0,0 +1,28 @@
import time
from app.cache import FileCache
def test_cache_returns_fresh_entry(tmp_path) -> None:
cache = FileCache(cache_dir=str(tmp_path), default_ttl_seconds=10)
cache.set_json("sample", {"x": 1})
entry = cache.get_json("sample")
assert entry is not None
assert entry.stale is False
assert entry.value == {"x": 1}
def test_cache_can_serve_stale_when_requested(tmp_path) -> None:
cache = FileCache(cache_dir=str(tmp_path), default_ttl_seconds=0)
cache.set_json("sample", {"x": 1})
time.sleep(0.01)
fresh = cache.get_json("sample")
stale = cache.get_json("sample", allow_stale=True)
assert fresh is None
assert stale is not None
assert stale.stale is True
assert stale.value == {"x": 1}

22
tests/test_merge.py Normal file
View File

@@ -0,0 +1,22 @@
from app.merge import filter_activity_source, merge_activity
def test_merge_activity_combines_totals() -> None:
github = {"2026-01-01": 2, "2026-01-02": 1}
gitea = {"2026-01-02": 3, "2026-01-03": 4}
merged = merge_activity(github, gitea)
assert merged["2026-01-01"] == {"github": 2, "gitea": 0, "total": 2}
assert merged["2026-01-02"] == {"github": 1, "gitea": 3, "total": 4}
assert merged["2026-01-03"] == {"github": 0, "gitea": 4, "total": 4}
def test_filter_activity_source_github_only() -> None:
merged = {
"2026-01-01": {"github": 2, "gitea": 7, "total": 9},
}
filtered = filter_activity_source(merged, "github")
assert filtered["2026-01-01"] == {"github": 2, "gitea": 0, "total": 2}

35
tests/test_routes.py Normal file
View File

@@ -0,0 +1,35 @@
from datetime import date
from fastapi.testclient import TestClient
from app.main import ActivityResult, app
from app.settings import get_settings
def test_activity_svg_returns_svg_content_type(monkeypatch, tmp_path) -> None:
monkeypatch.setenv("GITHUB_USERNAME", "octocat")
monkeypatch.setenv("GITEA_BASE_URL", "https://gitea.example.com")
monkeypatch.setenv("GITEA_USERNAME", "octocat")
monkeypatch.setenv("CACHE_DIR", str(tmp_path))
get_settings.cache_clear()
async def fake_collect_merged_activity(settings, cache, options):
return ActivityResult(
merged={
"2026-01-01": {"github": 1, "gitea": 2, "total": 3},
"2026-01-02": {"github": 0, "gitea": 0, "total": 0},
},
stale=False,
from_date=date(2026, 1, 1),
to_date=date(2026, 1, 2),
days_count=2,
)
monkeypatch.setattr("app.main.collect_merged_activity", fake_collect_merged_activity)
client = TestClient(app)
response = client.get("/activity.svg")
assert response.status_code == 200
assert response.headers["content-type"].startswith("image/svg+xml")
assert "<svg" in response.text