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

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