Initial sanitary RepoRadar release
This commit is contained in:
Executable
+1
@@ -0,0 +1 @@
|
||||
"""RepoRadar utility package: git client, store helpers, and embed builders."""
|
||||
Executable
+235
@@ -0,0 +1,235 @@
|
||||
"""
|
||||
utils/embeds.py — cyberpunk / hacker-aesthetic embed builders for RepoRadar.
|
||||
|
||||
Every embed is built here so styling stays consistent: dark backdrops, neon
|
||||
accent colours, monospace code blocks, and tidy mobile + desktop formatting.
|
||||
Functions accept the normalised `Repo` dataclass from utils.git_api and return
|
||||
ready-to-send nextcord.Embed objects.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from datetime import datetime, timezone
|
||||
|
||||
import nextcord
|
||||
|
||||
from config import settings
|
||||
from utils.git_api import Repo
|
||||
from utils.helpers import discord_timestamp, truncate
|
||||
|
||||
BRAND = "RepoRadar // Project Intelligence"
|
||||
|
||||
|
||||
def _footer(embed: nextcord.Embed) -> nextcord.Embed:
|
||||
embed.set_footer(text=f"⛓ {BRAND}")
|
||||
embed.timestamp = datetime.now(timezone.utc)
|
||||
return embed
|
||||
|
||||
|
||||
# ──────────────────────────────────────────────────────────────
|
||||
# Error / utility embeds
|
||||
# ──────────────────────────────────────────────────────────────
|
||||
def build_error_embed(title: str, message: str) -> nextcord.Embed:
|
||||
embed = nextcord.Embed(
|
||||
title=f"⛔ {title}",
|
||||
description=message,
|
||||
color=settings.color_error,
|
||||
)
|
||||
return _footer(embed)
|
||||
|
||||
|
||||
def build_info_embed(title: str, message: str) -> nextcord.Embed:
|
||||
embed = nextcord.Embed(
|
||||
title=f"✅ {title}",
|
||||
description=message,
|
||||
color=settings.color_success,
|
||||
)
|
||||
return _footer(embed)
|
||||
|
||||
|
||||
# ──────────────────────────────────────────────────────────────
|
||||
# Project-drop embeds
|
||||
# ──────────────────────────────────────────────────────────────
|
||||
def _lang_emoji(language: str) -> str:
|
||||
"""A glanceable marker for the repo's primary language."""
|
||||
return {
|
||||
"python": "🐍", "go": "🐹", "rust": "🦀", "c": "🔧", "c++": "🔧",
|
||||
"java": "☕", "javascript": "🟨", "typescript": "🔷", "shell": "🐚",
|
||||
"ruby": "💎", "php": "🐘", "html": "🌐", "powershell": "💠",
|
||||
}.get((language or "").lower(), "📦")
|
||||
|
||||
|
||||
def build_repo_drop_embed(
|
||||
repo: Repo,
|
||||
*,
|
||||
votes: dict[str, int] | None = None,
|
||||
watch_label: str | None = None,
|
||||
highlight: bool = False,
|
||||
admin_note: str | None = None,
|
||||
) -> nextcord.Embed:
|
||||
"""The card posted when a brand-new project is discovered on the git server."""
|
||||
votes = votes or {"up": 0, "down": 0, "score": 0}
|
||||
title_prefix = "🌟 FEATURED DROP" if highlight else "🆕 NEW PROJECT"
|
||||
embed = nextcord.Embed(
|
||||
title=f"{_lang_emoji(repo.language)} {truncate(repo.full_name or repo.name, 240)}",
|
||||
url=repo.url or None,
|
||||
description=truncate(repo.description or "*No description provided.*", 1024),
|
||||
color=settings.color_highlight if highlight else settings.color_drop,
|
||||
)
|
||||
embed.set_author(name=f"⛓ {title_prefix}")
|
||||
|
||||
embed.add_field(name="◈ Owner", value=f"`{repo.owner or 'unknown'}`", inline=True)
|
||||
if repo.language:
|
||||
embed.add_field(name="◈ Language", value=f"`{repo.language}`", inline=True)
|
||||
embed.add_field(
|
||||
name="◈ Created",
|
||||
value=discord_timestamp(repo.created_at, "R"),
|
||||
inline=True,
|
||||
)
|
||||
embed.add_field(name="◈ ⭐ Stars", value=f"`{repo.stars}`", inline=True)
|
||||
embed.add_field(name="◈ 🍴 Forks", value=f"`{repo.forks}`", inline=True)
|
||||
embed.add_field(
|
||||
name="◈ 🗳 Community",
|
||||
value=f"👍 `{votes['up']}` 👎 `{votes['down']}` • net `{votes['score']:+d}`",
|
||||
inline=True,
|
||||
)
|
||||
|
||||
if repo.topics:
|
||||
embed.add_field(
|
||||
name="◈ Topics",
|
||||
value=" ".join(f"`{t}`" for t in repo.topics[:10]),
|
||||
inline=False,
|
||||
)
|
||||
|
||||
if admin_note:
|
||||
embed.add_field(name="◈ Curator Note", value=truncate(admin_note, 1024), inline=False)
|
||||
|
||||
if watch_label:
|
||||
embed.add_field(name="◈ Source", value=watch_label, inline=False)
|
||||
|
||||
if repo.url:
|
||||
embed.add_field(name="◈ Repository", value=f"[🔗 Open project]({repo.url})", inline=False)
|
||||
|
||||
return _footer(embed)
|
||||
|
||||
|
||||
def build_repo_list_embed(
|
||||
repos: list[Repo],
|
||||
*,
|
||||
title: str = "🛰 LATEST PROJECTS",
|
||||
limit: int = 10,
|
||||
) -> nextcord.Embed:
|
||||
"""Compact chronological list of the newest repositories (newest first)."""
|
||||
embed = nextcord.Embed(
|
||||
title=f"☣ {title}",
|
||||
description=(
|
||||
"```ansi\n\u001b[1;35m> Freshest projects from the git server\u001b[0m\n```"
|
||||
f"Showing the **{min(limit, len(repos))}** most recent."
|
||||
),
|
||||
color=settings.color_drop,
|
||||
)
|
||||
for repo in repos[:limit]:
|
||||
created = discord_timestamp(repo.created_at, "R")
|
||||
meta = f"⭐ {repo.stars} · 🍴 {repo.forks}"
|
||||
if repo.language:
|
||||
meta = f"`{repo.language}` · " + meta
|
||||
desc = truncate(repo.description or "*No description.*", 130)
|
||||
link = f"[open]({repo.url})" if repo.url else ""
|
||||
embed.add_field(
|
||||
name=f"{_lang_emoji(repo.language)} {truncate(repo.full_name or repo.name, 60)}",
|
||||
value=f"{desc}\n{meta} · {created} · {link}",
|
||||
inline=False,
|
||||
)
|
||||
if not repos:
|
||||
embed.description = "No projects could be retrieved right now."
|
||||
return _footer(embed)
|
||||
|
||||
|
||||
def build_git_leaderboard_embed(rows: list[dict], *, limit: int = 10) -> nextcord.Embed:
|
||||
"""Top community-voted projects."""
|
||||
embed = nextcord.Embed(
|
||||
title="🏆 COMMUNITY TOP PROJECTS",
|
||||
description="```ansi\n\u001b[1;32m> Ranked by community vote score\u001b[0m\n```",
|
||||
color=settings.color_success,
|
||||
)
|
||||
medals = {0: "🥇", 1: "🥈", 2: "🥉"}
|
||||
for i, row in enumerate(rows[:limit]):
|
||||
name = row.get("full_name") or row.get("repo_key", "unknown")
|
||||
url = row.get("url") or ""
|
||||
score = int(row.get("score") or 0)
|
||||
up = int(row.get("up") or 0)
|
||||
down = int(row.get("down") or 0)
|
||||
marker = medals.get(i, f"`#{i + 1}`")
|
||||
link = f"[open]({url})" if url else ""
|
||||
embed.add_field(
|
||||
name=f"{marker} {truncate(name, 60)}",
|
||||
value=f"net `{score:+d}` • 👍 `{up}` 👎 `{down}` {link}",
|
||||
inline=False,
|
||||
)
|
||||
if not rows:
|
||||
embed.description = "No votes have been cast yet. Be the first to vote on a drop!"
|
||||
return _footer(embed)
|
||||
|
||||
|
||||
def build_git_status_embed(
|
||||
*,
|
||||
provider: str,
|
||||
base_url: str,
|
||||
configured: bool,
|
||||
drop_channel_id: int | None,
|
||||
highlight_channel_id: int | None,
|
||||
highlight_role_id: int | None,
|
||||
min_stars: int,
|
||||
keywords: str,
|
||||
enabled: bool,
|
||||
watches: list[dict],
|
||||
poll_interval: int,
|
||||
) -> nextcord.Embed:
|
||||
"""Render the current monitor configuration for a guild."""
|
||||
embed = nextcord.Embed(
|
||||
title="⚙ REPORADAR — CONFIGURATION",
|
||||
color=settings.color_drop if configured else settings.color_medium,
|
||||
)
|
||||
status = "🟢 enabled" if enabled else "🔴 disabled"
|
||||
embed.add_field(name="◈ Provider", value=f"`{provider or 'unconfigured'}`", inline=True)
|
||||
embed.add_field(name="◈ Server", value=f"`{base_url or 'api.github.com'}`", inline=True)
|
||||
embed.add_field(name="◈ Monitor", value=status, inline=True)
|
||||
|
||||
embed.add_field(
|
||||
name="◈ Drop channel",
|
||||
value=f"<#{drop_channel_id}>" if drop_channel_id else "`not set`",
|
||||
inline=True,
|
||||
)
|
||||
embed.add_field(
|
||||
name="◈ Highlight channel",
|
||||
value=f"<#{highlight_channel_id}>" if highlight_channel_id else "`not set`",
|
||||
inline=True,
|
||||
)
|
||||
embed.add_field(
|
||||
name="◈ Highlight role",
|
||||
value=f"<@&{highlight_role_id}>" if highlight_role_id else "`none`",
|
||||
inline=True,
|
||||
)
|
||||
embed.add_field(name="◈ Min ⭐ to highlight", value=f"`{min_stars}`", inline=True)
|
||||
embed.add_field(name="◈ Poll interval", value=f"`{poll_interval}s`", inline=True)
|
||||
embed.add_field(
|
||||
name="◈ Highlight keywords",
|
||||
value=f"`{keywords}`" if keywords else "`none`",
|
||||
inline=False,
|
||||
)
|
||||
|
||||
if watches:
|
||||
lines = []
|
||||
for w in watches:
|
||||
scope = w["scope_type"]
|
||||
val = w["scope_value"] or "*entire server*"
|
||||
ch = f" → <#{w['channel_id']}>" if w.get("channel_id") else ""
|
||||
lines.append(f"• `{scope}` **{val}**{ch}")
|
||||
embed.add_field(name="◈ Active watches", value=truncate("\n".join(lines), 1024), inline=False)
|
||||
else:
|
||||
embed.add_field(
|
||||
name="◈ Active watches",
|
||||
value="None yet — add one with `/radar_watch`.",
|
||||
inline=False,
|
||||
)
|
||||
return _footer(embed)
|
||||
Executable
+357
@@ -0,0 +1,357 @@
|
||||
"""
|
||||
utils/git_api.py — async client for self-hosted / hosted git servers.
|
||||
|
||||
Supports three provider families behind one normalised interface so the cog and
|
||||
embeds never touch the upstream schemas directly:
|
||||
|
||||
* Gitea / Forgejo (REST API v1) — typical self-hosted "git server"
|
||||
* GitLab (REST API v4)
|
||||
* GitHub (REST API v3 / api.github.com)
|
||||
|
||||
Responsibilities:
|
||||
* Single shared aiohttp.ClientSession (started on bot boot, closed on shutdown).
|
||||
* Provider auto-detection (probe /api/v1/version then /api/v4/version).
|
||||
* Repository listing for a specific user/org OR server-wide, plus a single repo.
|
||||
* Normalises every payload into the simple `Repo` dataclass.
|
||||
|
||||
The `created_at` timestamp is what drives "newest projects only" — the monitor
|
||||
baselines existing repos on first sight, then only drops repos created after
|
||||
that baseline, in chronological order.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import asyncio
|
||||
import logging
|
||||
from dataclasses import dataclass, field
|
||||
from datetime import datetime, timezone
|
||||
from typing import Any
|
||||
from urllib.parse import quote
|
||||
|
||||
import aiohttp
|
||||
|
||||
from config import settings
|
||||
|
||||
log = logging.getLogger("reporadar.git_api")
|
||||
|
||||
# Provider identifiers.
|
||||
GITEA = "gitea"
|
||||
GITLAB = "gitlab"
|
||||
GITHUB = "github"
|
||||
|
||||
|
||||
def _parse_dt(raw: str | None) -> datetime | None:
|
||||
"""Parse an ISO8601 timestamp from any provider into an aware UTC datetime."""
|
||||
if not raw:
|
||||
return None
|
||||
cleaned = raw.replace("Z", "+00:00")
|
||||
for fmt in ("%Y-%m-%dT%H:%M:%S.%f%z", "%Y-%m-%dT%H:%M:%S%z"):
|
||||
try:
|
||||
return datetime.strptime(cleaned, fmt)
|
||||
except ValueError:
|
||||
continue
|
||||
try:
|
||||
parsed = datetime.fromisoformat(cleaned)
|
||||
return parsed if parsed.tzinfo else parsed.replace(tzinfo=timezone.utc)
|
||||
except ValueError:
|
||||
log.debug("Could not parse git datetime: %s", raw)
|
||||
return None
|
||||
|
||||
|
||||
@dataclass
|
||||
class Repo:
|
||||
"""A normalised view of a single repository across providers."""
|
||||
|
||||
# Stable, provider-unique key used for de-duplication + vote tallies.
|
||||
key: str
|
||||
name: str
|
||||
full_name: str
|
||||
owner: str
|
||||
description: str
|
||||
url: str
|
||||
created_at: datetime | None
|
||||
updated_at: datetime | None
|
||||
stars: int = 0
|
||||
forks: int = 0
|
||||
language: str = ""
|
||||
topics: list[str] = field(default_factory=list)
|
||||
default_branch: str = ""
|
||||
private: bool = False
|
||||
archived: bool = False
|
||||
|
||||
@property
|
||||
def created_sort_key(self) -> str:
|
||||
"""Sortable ISO string; empty timestamps sort first (oldest)."""
|
||||
return self.created_at.isoformat() if self.created_at else ""
|
||||
|
||||
|
||||
class GitClient:
|
||||
"""Async, provider-agnostic git server client (cache-light, retry-aware)."""
|
||||
|
||||
def __init__(self) -> None:
|
||||
self._session: aiohttp.ClientSession | None = None
|
||||
self.provider: str = settings.git_provider
|
||||
self.base_url: str = settings.git_base_url
|
||||
self._detected = False
|
||||
|
||||
# ── lifecycle ────────────────────────────────────────────
|
||||
async def start(self) -> None:
|
||||
if self._session is None or self._session.closed:
|
||||
headers = {"User-Agent": settings.user_agent, "Accept": "application/json"}
|
||||
timeout = aiohttp.ClientTimeout(total=settings.http_timeout)
|
||||
self._session = aiohttp.ClientSession(headers=headers, timeout=timeout)
|
||||
await self._detect_provider()
|
||||
log.info(
|
||||
"Git client ready (provider=%s, base=%s, auth=%s)",
|
||||
self.provider or "unconfigured",
|
||||
self.base_url or "<none>",
|
||||
bool(settings.git_token),
|
||||
)
|
||||
|
||||
async def close(self) -> None:
|
||||
if self._session and not self._session.closed:
|
||||
await self._session.close()
|
||||
|
||||
def _require_session(self) -> aiohttp.ClientSession:
|
||||
if self._session is None or self._session.closed:
|
||||
raise RuntimeError("GitClient session not started — call start() first.")
|
||||
return self._session
|
||||
|
||||
@property
|
||||
def configured(self) -> bool:
|
||||
"""True when we have enough config to talk to a git server."""
|
||||
if self.provider == GITHUB:
|
||||
return True # api.github.com default works without a base URL
|
||||
return bool(self.base_url)
|
||||
|
||||
# ── auth headers per provider ────────────────────────────
|
||||
def _auth_headers(self) -> dict[str, str]:
|
||||
token = settings.git_token
|
||||
if not token:
|
||||
return {}
|
||||
if self.provider == GITLAB:
|
||||
return {"PRIVATE-TOKEN": token}
|
||||
if self.provider == GITHUB:
|
||||
return {"Authorization": f"Bearer {token}"}
|
||||
# Gitea / Forgejo
|
||||
return {"Authorization": f"token {token}"}
|
||||
|
||||
def _api_root(self) -> str:
|
||||
if self.provider == GITHUB:
|
||||
return (self.base_url or "https://api.github.com").rstrip("/")
|
||||
if self.provider == GITLAB:
|
||||
return f"{self.base_url}/api/v4"
|
||||
return f"{self.base_url}/api/v1" # gitea / forgejo
|
||||
|
||||
# ── low-level GET with retry/backoff ─────────────────────
|
||||
async def _get(
|
||||
self, url: str, params: dict[str, Any] | None = None
|
||||
) -> tuple[Any | None, dict[str, str]]:
|
||||
"""GET returning (json, response_headers). None json on failure."""
|
||||
session = self._require_session()
|
||||
headers = self._auth_headers()
|
||||
delay = 2.0
|
||||
for attempt in range(1, settings.request_retries + 1):
|
||||
try:
|
||||
async with session.get(url, params=params, headers=headers) as resp:
|
||||
if resp.status == 200:
|
||||
return await resp.json(), dict(resp.headers)
|
||||
if resp.status in (403, 429, 500, 502, 503):
|
||||
log.warning(
|
||||
"GET %s → %s (attempt %s/%s), backoff %.1fs",
|
||||
url, resp.status, attempt, settings.request_retries, delay,
|
||||
)
|
||||
await asyncio.sleep(delay)
|
||||
delay *= 2
|
||||
continue
|
||||
log.error("GET %s → unexpected status %s", url, resp.status)
|
||||
return None, {}
|
||||
except (aiohttp.ClientError, asyncio.TimeoutError) as exc:
|
||||
log.warning("GET %s failed (%s/%s): %s", url, attempt, settings.request_retries, exc)
|
||||
await asyncio.sleep(delay)
|
||||
delay *= 2
|
||||
log.error("GET %s exhausted retries", url)
|
||||
return None, {}
|
||||
|
||||
# ── provider detection ───────────────────────────────────
|
||||
async def _detect_provider(self) -> None:
|
||||
if self._detected:
|
||||
return
|
||||
self._detected = True
|
||||
|
||||
if self.provider and self.provider != "auto":
|
||||
return # explicit provider — trust the operator
|
||||
if not self.base_url:
|
||||
self.provider = GITHUB
|
||||
return
|
||||
host = self.base_url.lower()
|
||||
if "github" in host:
|
||||
self.provider = GITHUB
|
||||
return
|
||||
if "gitlab" in host:
|
||||
self.provider = GITLAB
|
||||
return
|
||||
|
||||
# Probe Gitea/Forgejo, then GitLab.
|
||||
gitea_ver, _ = await self._get(f"{self.base_url}/api/v1/version")
|
||||
if isinstance(gitea_ver, dict) and gitea_ver.get("version"):
|
||||
self.provider = GITEA
|
||||
return
|
||||
gitlab_ver, _ = await self._get(f"{self.base_url}/api/v4/version")
|
||||
if isinstance(gitlab_ver, dict) and gitlab_ver.get("version"):
|
||||
self.provider = GITLAB
|
||||
return
|
||||
log.warning("Could not auto-detect git provider for %s; defaulting to gitea", self.base_url)
|
||||
self.provider = GITEA
|
||||
|
||||
# ── normalisation per provider ───────────────────────────
|
||||
def _norm_gitea(self, r: dict[str, Any]) -> Repo:
|
||||
owner = (r.get("owner") or {}).get("login") or (r.get("owner") or {}).get("username") or ""
|
||||
full = r.get("full_name") or f"{owner}/{r.get('name', '')}"
|
||||
return Repo(
|
||||
key=f"{self.provider}:{full}",
|
||||
name=r.get("name", ""),
|
||||
full_name=full,
|
||||
owner=owner,
|
||||
description=r.get("description") or "",
|
||||
url=r.get("html_url") or "",
|
||||
created_at=_parse_dt(r.get("created_at")),
|
||||
updated_at=_parse_dt(r.get("updated_at")),
|
||||
stars=int(r.get("stars_count", 0) or 0),
|
||||
forks=int(r.get("forks_count", 0) or 0),
|
||||
language=r.get("language") or "",
|
||||
topics=list(r.get("topics") or []),
|
||||
default_branch=r.get("default_branch") or "",
|
||||
private=bool(r.get("private", False)),
|
||||
archived=bool(r.get("archived", False)),
|
||||
)
|
||||
|
||||
def _norm_gitlab(self, r: dict[str, Any]) -> Repo:
|
||||
ns = r.get("namespace") or {}
|
||||
owner = ns.get("path") or ns.get("name") or ""
|
||||
full = r.get("path_with_namespace") or f"{owner}/{r.get('path', '')}"
|
||||
return Repo(
|
||||
key=f"{self.provider}:{r.get('id', full)}",
|
||||
name=r.get("name") or r.get("path", ""),
|
||||
full_name=full,
|
||||
owner=owner,
|
||||
description=r.get("description") or "",
|
||||
url=r.get("web_url") or "",
|
||||
created_at=_parse_dt(r.get("created_at")),
|
||||
updated_at=_parse_dt(r.get("last_activity_at")),
|
||||
stars=int(r.get("star_count", 0) or 0),
|
||||
forks=int(r.get("forks_count", 0) or 0),
|
||||
language="",
|
||||
topics=list(r.get("topics") or r.get("tag_list") or []),
|
||||
default_branch=r.get("default_branch") or "",
|
||||
private=(r.get("visibility") == "private"),
|
||||
archived=bool(r.get("archived", False)),
|
||||
)
|
||||
|
||||
def _norm_github(self, r: dict[str, Any]) -> Repo:
|
||||
owner = (r.get("owner") or {}).get("login") or ""
|
||||
full = r.get("full_name") or f"{owner}/{r.get('name', '')}"
|
||||
return Repo(
|
||||
key=f"{self.provider}:{full}",
|
||||
name=r.get("name", ""),
|
||||
full_name=full,
|
||||
owner=owner,
|
||||
description=r.get("description") or "",
|
||||
url=r.get("html_url") or "",
|
||||
created_at=_parse_dt(r.get("created_at")),
|
||||
updated_at=_parse_dt(r.get("pushed_at") or r.get("updated_at")),
|
||||
stars=int(r.get("stargazers_count", 0) or 0),
|
||||
forks=int(r.get("forks_count", 0) or 0),
|
||||
language=r.get("language") or "",
|
||||
topics=list(r.get("topics") or []),
|
||||
default_branch=r.get("default_branch") or "",
|
||||
private=bool(r.get("private", False)),
|
||||
archived=bool(r.get("archived", False)),
|
||||
)
|
||||
|
||||
def _normalize(self, raw: dict[str, Any]) -> Repo:
|
||||
if self.provider == GITLAB:
|
||||
return self._norm_gitlab(raw)
|
||||
if self.provider == GITHUB:
|
||||
return self._norm_github(raw)
|
||||
return self._norm_gitea(raw)
|
||||
|
||||
# ── public listing API ───────────────────────────────────
|
||||
async def list_repos_for_owner(self, owner: str, limit: int = 100) -> list[Repo]:
|
||||
"""Repos belonging to a user or organisation (newest first upstream)."""
|
||||
await self._detect_provider()
|
||||
root = self._api_root()
|
||||
out: list[Repo] = []
|
||||
|
||||
if self.provider == GITLAB:
|
||||
# Try group projects first, fall back to user projects.
|
||||
enc = quote(owner, safe="")
|
||||
for path in (f"/groups/{enc}/projects", f"/users/{enc}/projects"):
|
||||
data, _ = await self._get(
|
||||
f"{root}{path}",
|
||||
{"per_page": min(limit, 100), "order_by": "created_at", "sort": "desc",
|
||||
"include_subgroups": "true"},
|
||||
)
|
||||
if isinstance(data, list) and data:
|
||||
out = [self._normalize(x) for x in data]
|
||||
break
|
||||
elif self.provider == GITHUB:
|
||||
# Works for both users and orgs via the generic /users endpoint.
|
||||
data, _ = await self._get(
|
||||
f"{root}/users/{quote(owner, safe='')}/repos",
|
||||
{"per_page": min(limit, 100), "sort": "created", "direction": "desc"},
|
||||
)
|
||||
if isinstance(data, list):
|
||||
out = [self._normalize(x) for x in data]
|
||||
else: # gitea / forgejo — /users/{u}/repos covers orgs too on most builds
|
||||
for path in (f"/users/{quote(owner, safe='')}/repos",
|
||||
f"/orgs/{quote(owner, safe='')}/repos"):
|
||||
data, _ = await self._get(f"{root}{path}", {"limit": min(limit, 50)})
|
||||
if isinstance(data, list) and data:
|
||||
out = [self._normalize(x) for x in data]
|
||||
break
|
||||
|
||||
return out
|
||||
|
||||
async def list_all_repos(self, limit: int = 100) -> list[Repo]:
|
||||
"""Server-wide repository discovery (most recent first)."""
|
||||
await self._detect_provider()
|
||||
root = self._api_root()
|
||||
|
||||
if self.provider == GITLAB:
|
||||
data, _ = await self._get(
|
||||
f"{root}/projects",
|
||||
{"per_page": min(limit, 100), "order_by": "created_at", "sort": "desc",
|
||||
"simple": "true"},
|
||||
)
|
||||
return [self._normalize(x) for x in data] if isinstance(data, list) else []
|
||||
|
||||
if self.provider == GITHUB:
|
||||
# api.github.com has no "all repos" concept; use the public timeline.
|
||||
data, _ = await self._get(f"{root}/repositories", {"per_page": min(limit, 100)})
|
||||
repos = [self._normalize(x) for x in data] if isinstance(data, list) else []
|
||||
repos.sort(key=lambda r: r.created_sort_key, reverse=True)
|
||||
return repos[:limit]
|
||||
|
||||
# Gitea / Forgejo search endpoint sorted by newest.
|
||||
data, _ = await self._get(
|
||||
f"{root}/repos/search",
|
||||
{"limit": min(limit, 50), "sort": "created", "order": "desc"},
|
||||
)
|
||||
items = (data or {}).get("data") if isinstance(data, dict) else None
|
||||
return [self._normalize(x) for x in items] if isinstance(items, list) else []
|
||||
|
||||
async def get_repo(self, owner: str, name: str) -> Repo | None:
|
||||
"""Fetch a single repository by owner + name."""
|
||||
await self._detect_provider()
|
||||
root = self._api_root()
|
||||
if self.provider == GITLAB:
|
||||
enc = quote(f"{owner}/{name}", safe="")
|
||||
data, _ = await self._get(f"{root}/projects/{enc}")
|
||||
elif self.provider == GITHUB:
|
||||
data, _ = await self._get(f"{root}/repos/{quote(owner, safe='')}/{quote(name, safe='')}")
|
||||
else:
|
||||
data, _ = await self._get(
|
||||
f"{root}/repos/{quote(owner, safe='')}/{quote(name, safe='')}"
|
||||
)
|
||||
return self._normalize(data) if isinstance(data, dict) and data else None
|
||||
Executable
+430
@@ -0,0 +1,430 @@
|
||||
"""
|
||||
utils/helpers.py — small utilities + the async SQLite store for RepoRadar.
|
||||
|
||||
Contains:
|
||||
* `truncate` / `discord_timestamp` formatting helpers used by the embeds.
|
||||
* The async SQLite `Store` for per-guild config, watches, seen-repo de-dup,
|
||||
posted drops (vote anchors), and community votes.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import logging
|
||||
from datetime import datetime, timezone
|
||||
from pathlib import Path
|
||||
from typing import Any
|
||||
|
||||
import aiosqlite
|
||||
|
||||
log = logging.getLogger("reporadar.helpers")
|
||||
|
||||
|
||||
# ──────────────────────────────────────────────────────────────
|
||||
# Formatting helpers
|
||||
# ──────────────────────────────────────────────────────────────
|
||||
def discord_timestamp(dt: datetime | None, style: str = "R") -> str:
|
||||
"""Render a Discord dynamic timestamp tag, or 'N/A' if dt is None."""
|
||||
if dt is None:
|
||||
return "N/A"
|
||||
return f"<t:{int(dt.timestamp())}:{style}>"
|
||||
|
||||
|
||||
def truncate(text: str | None, limit: int) -> str:
|
||||
"""Trim text to `limit` chars (Discord field/desc safe), adding an ellipsis."""
|
||||
if text is None:
|
||||
return ""
|
||||
return text if len(text) <= limit else text[: limit - 1].rstrip() + "…"
|
||||
|
||||
|
||||
# ──────────────────────────────────────────────────────────────
|
||||
# Async SQLite store
|
||||
# ──────────────────────────────────────────────────────────────
|
||||
class Store:
|
||||
"""Async SQLite-backed persistence for RepoRadar.
|
||||
|
||||
Tables:
|
||||
git_config(guild_id PK, drop_channel_id, highlight_channel_id,
|
||||
highlight_role_id, min_stars, highlight_keywords, enabled, updated_at)
|
||||
git_watch(id PK, guild_id, scope_type, scope_value, channel_id, added_by, created_at)
|
||||
git_seen(guild_id, repo_key, ... , first_seen_at, announced) -- newest-only de-dup
|
||||
git_drop(id PK, guild_id, repo_key, channel_id, message_id, dropped_at) -- vote anchor
|
||||
git_vote(repo_key, guild_id, user_id, value, comment, created_at) -- community votes
|
||||
"""
|
||||
|
||||
def __init__(self, db_path: Path) -> None:
|
||||
self._db_path = str(db_path)
|
||||
|
||||
async def init(self) -> None:
|
||||
async with aiosqlite.connect(self._db_path) as db:
|
||||
await db.execute(
|
||||
"""
|
||||
CREATE TABLE IF NOT EXISTS git_config (
|
||||
guild_id INTEGER PRIMARY KEY,
|
||||
drop_channel_id INTEGER,
|
||||
highlight_channel_id INTEGER,
|
||||
highlight_role_id INTEGER,
|
||||
min_stars INTEGER DEFAULT 0,
|
||||
highlight_keywords TEXT DEFAULT '',
|
||||
enabled INTEGER DEFAULT 1,
|
||||
updated_at TEXT
|
||||
)
|
||||
"""
|
||||
)
|
||||
await db.execute(
|
||||
"""
|
||||
CREATE TABLE IF NOT EXISTS git_watch (
|
||||
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||
guild_id INTEGER NOT NULL,
|
||||
scope_type TEXT NOT NULL, -- 'user' | 'org' | 'server'
|
||||
scope_value TEXT NOT NULL DEFAULT '',
|
||||
channel_id INTEGER, -- override drop channel
|
||||
added_by INTEGER,
|
||||
created_at TEXT NOT NULL,
|
||||
UNIQUE(guild_id, scope_type, scope_value)
|
||||
)
|
||||
"""
|
||||
)
|
||||
await db.execute(
|
||||
"""
|
||||
CREATE TABLE IF NOT EXISTS git_seen (
|
||||
guild_id INTEGER NOT NULL,
|
||||
repo_key TEXT NOT NULL,
|
||||
name TEXT,
|
||||
full_name TEXT,
|
||||
url TEXT,
|
||||
description TEXT,
|
||||
created_remote TEXT,
|
||||
first_seen_at TEXT NOT NULL,
|
||||
announced INTEGER DEFAULT 0,
|
||||
PRIMARY KEY (guild_id, repo_key)
|
||||
)
|
||||
"""
|
||||
)
|
||||
await db.execute(
|
||||
"""
|
||||
CREATE TABLE IF NOT EXISTS git_drop (
|
||||
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||
guild_id INTEGER NOT NULL,
|
||||
repo_key TEXT NOT NULL,
|
||||
full_name TEXT,
|
||||
url TEXT,
|
||||
channel_id INTEGER NOT NULL,
|
||||
message_id INTEGER NOT NULL,
|
||||
dropped_at TEXT NOT NULL,
|
||||
UNIQUE(guild_id, message_id)
|
||||
)
|
||||
"""
|
||||
)
|
||||
await db.execute(
|
||||
"""
|
||||
CREATE TABLE IF NOT EXISTS git_vote (
|
||||
repo_key TEXT NOT NULL,
|
||||
guild_id INTEGER NOT NULL,
|
||||
user_id INTEGER NOT NULL,
|
||||
value INTEGER NOT NULL, -- +1 | -1
|
||||
comment TEXT DEFAULT '',
|
||||
created_at TEXT NOT NULL,
|
||||
PRIMARY KEY (repo_key, guild_id, user_id)
|
||||
)
|
||||
"""
|
||||
)
|
||||
await db.commit()
|
||||
log.info("SQLite store initialised at %s", self._db_path)
|
||||
|
||||
# ── per-guild config ─────────────────────────────────────
|
||||
async def set_git_config(self, guild_id: int, **fields: Any) -> None:
|
||||
"""Upsert git config columns for a guild (only provided fields change)."""
|
||||
allowed = {
|
||||
"drop_channel_id",
|
||||
"highlight_channel_id",
|
||||
"highlight_role_id",
|
||||
"min_stars",
|
||||
"highlight_keywords",
|
||||
"enabled",
|
||||
}
|
||||
cols = {k: v for k, v in fields.items() if k in allowed}
|
||||
now = datetime.now(timezone.utc).isoformat()
|
||||
async with aiosqlite.connect(self._db_path) as db:
|
||||
# Ensure a row exists, then patch the requested columns.
|
||||
await db.execute(
|
||||
"INSERT OR IGNORE INTO git_config (guild_id, updated_at) VALUES (?, ?)",
|
||||
(guild_id, now),
|
||||
)
|
||||
if cols:
|
||||
assignments = ", ".join(f"{k} = ?" for k in cols)
|
||||
await db.execute(
|
||||
f"UPDATE git_config SET {assignments}, updated_at = ? WHERE guild_id = ?",
|
||||
(*cols.values(), now, guild_id),
|
||||
)
|
||||
await db.commit()
|
||||
|
||||
async def get_git_config(self, guild_id: int) -> dict[str, Any] | None:
|
||||
async with aiosqlite.connect(self._db_path) as db:
|
||||
db.row_factory = aiosqlite.Row
|
||||
async with db.execute(
|
||||
"SELECT * FROM git_config WHERE guild_id = ?", (guild_id,)
|
||||
) as cur:
|
||||
row = await cur.fetchone()
|
||||
return dict(row) if row else None
|
||||
|
||||
# ── watches ──────────────────────────────────────────────
|
||||
async def add_git_watch(
|
||||
self,
|
||||
guild_id: int,
|
||||
scope_type: str,
|
||||
scope_value: str,
|
||||
channel_id: int | None,
|
||||
added_by: int | None,
|
||||
) -> None:
|
||||
async with aiosqlite.connect(self._db_path) as db:
|
||||
await db.execute(
|
||||
"""
|
||||
INSERT INTO git_watch
|
||||
(guild_id, scope_type, scope_value, channel_id, added_by, created_at)
|
||||
VALUES (?, ?, ?, ?, ?, ?)
|
||||
ON CONFLICT(guild_id, scope_type, scope_value) DO UPDATE SET
|
||||
channel_id = excluded.channel_id
|
||||
""",
|
||||
(
|
||||
guild_id,
|
||||
scope_type,
|
||||
scope_value,
|
||||
channel_id,
|
||||
added_by,
|
||||
datetime.now(timezone.utc).isoformat(),
|
||||
),
|
||||
)
|
||||
await db.commit()
|
||||
|
||||
async def remove_git_watch(self, guild_id: int, scope_type: str, scope_value: str) -> int:
|
||||
async with aiosqlite.connect(self._db_path) as db:
|
||||
cur = await db.execute(
|
||||
"DELETE FROM git_watch WHERE guild_id = ? AND scope_type = ? AND scope_value = ?",
|
||||
(guild_id, scope_type, scope_value),
|
||||
)
|
||||
await db.commit()
|
||||
return cur.rowcount
|
||||
|
||||
async def get_git_watches(self, guild_id: int) -> list[dict[str, Any]]:
|
||||
async with aiosqlite.connect(self._db_path) as db:
|
||||
db.row_factory = aiosqlite.Row
|
||||
async with db.execute(
|
||||
"SELECT * FROM git_watch WHERE guild_id = ? ORDER BY created_at",
|
||||
(guild_id,),
|
||||
) as cur:
|
||||
return [dict(r) for r in await cur.fetchall()]
|
||||
|
||||
async def all_git_watches(self) -> list[dict[str, Any]]:
|
||||
async with aiosqlite.connect(self._db_path) as db:
|
||||
db.row_factory = aiosqlite.Row
|
||||
async with db.execute("SELECT * FROM git_watch ORDER BY guild_id") as cur:
|
||||
return [dict(r) for r in await cur.fetchall()]
|
||||
|
||||
# ── seen repos (newest-only de-dup) ──────────────────────
|
||||
async def get_seen_keys(self, guild_id: int) -> set[str]:
|
||||
async with aiosqlite.connect(self._db_path) as db:
|
||||
async with db.execute(
|
||||
"SELECT repo_key FROM git_seen WHERE guild_id = ?", (guild_id,)
|
||||
) as cur:
|
||||
return {r[0] for r in await cur.fetchall()}
|
||||
|
||||
async def mark_seen(
|
||||
self,
|
||||
guild_id: int,
|
||||
repo_key: str,
|
||||
*,
|
||||
name: str = "",
|
||||
full_name: str = "",
|
||||
url: str = "",
|
||||
description: str = "",
|
||||
created_remote: str = "",
|
||||
announced: bool = False,
|
||||
) -> None:
|
||||
async with aiosqlite.connect(self._db_path) as db:
|
||||
await db.execute(
|
||||
"""
|
||||
INSERT OR IGNORE INTO git_seen
|
||||
(guild_id, repo_key, name, full_name, url, description,
|
||||
created_remote, first_seen_at, announced)
|
||||
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?)
|
||||
""",
|
||||
(
|
||||
guild_id,
|
||||
repo_key,
|
||||
name,
|
||||
full_name,
|
||||
url,
|
||||
description,
|
||||
created_remote,
|
||||
datetime.now(timezone.utc).isoformat(),
|
||||
1 if announced else 0,
|
||||
),
|
||||
)
|
||||
await db.commit()
|
||||
|
||||
async def has_any_seen(self, guild_id: int) -> bool:
|
||||
async with aiosqlite.connect(self._db_path) as db:
|
||||
async with db.execute(
|
||||
"SELECT 1 FROM git_seen WHERE guild_id = ? LIMIT 1", (guild_id,)
|
||||
) as cur:
|
||||
return (await cur.fetchone()) is not None
|
||||
|
||||
# ── drops (vote anchors) ─────────────────────────────────
|
||||
async def record_drop(
|
||||
self,
|
||||
guild_id: int,
|
||||
repo_key: str,
|
||||
full_name: str,
|
||||
url: str,
|
||||
channel_id: int,
|
||||
message_id: int,
|
||||
) -> None:
|
||||
async with aiosqlite.connect(self._db_path) as db:
|
||||
await db.execute(
|
||||
"""
|
||||
INSERT OR REPLACE INTO git_drop
|
||||
(guild_id, repo_key, full_name, url, channel_id, message_id, dropped_at)
|
||||
VALUES (?, ?, ?, ?, ?, ?, ?)
|
||||
""",
|
||||
(
|
||||
guild_id,
|
||||
repo_key,
|
||||
full_name,
|
||||
url,
|
||||
channel_id,
|
||||
message_id,
|
||||
datetime.now(timezone.utc).isoformat(),
|
||||
),
|
||||
)
|
||||
await db.commit()
|
||||
|
||||
async def get_drop_by_message(self, guild_id: int, message_id: int) -> dict[str, Any] | None:
|
||||
async with aiosqlite.connect(self._db_path) as db:
|
||||
db.row_factory = aiosqlite.Row
|
||||
async with db.execute(
|
||||
"SELECT * FROM git_drop WHERE guild_id = ? AND message_id = ?",
|
||||
(guild_id, message_id),
|
||||
) as cur:
|
||||
row = await cur.fetchone()
|
||||
return dict(row) if row else None
|
||||
|
||||
async def get_latest_drop_for_repo(self, guild_id: int, repo_key: str) -> dict[str, Any] | None:
|
||||
async with aiosqlite.connect(self._db_path) as db:
|
||||
db.row_factory = aiosqlite.Row
|
||||
async with db.execute(
|
||||
"""
|
||||
SELECT * FROM git_drop
|
||||
WHERE guild_id = ? AND repo_key = ?
|
||||
ORDER BY dropped_at DESC LIMIT 1
|
||||
""",
|
||||
(guild_id, repo_key),
|
||||
) as cur:
|
||||
row = await cur.fetchone()
|
||||
return dict(row) if row else None
|
||||
|
||||
async def find_drop_by_fullname(self, guild_id: int, full_name: str) -> dict[str, Any] | None:
|
||||
"""Find the most recent drop whose full_name matches (case-insensitive)."""
|
||||
async with aiosqlite.connect(self._db_path) as db:
|
||||
db.row_factory = aiosqlite.Row
|
||||
async with db.execute(
|
||||
"""
|
||||
SELECT * FROM git_drop
|
||||
WHERE guild_id = ? AND LOWER(full_name) = LOWER(?)
|
||||
ORDER BY dropped_at DESC LIMIT 1
|
||||
""",
|
||||
(guild_id, full_name),
|
||||
) as cur:
|
||||
row = await cur.fetchone()
|
||||
return dict(row) if row else None
|
||||
|
||||
# ── community votes ──────────────────────────────────────
|
||||
async def cast_vote(
|
||||
self,
|
||||
guild_id: int,
|
||||
repo_key: str,
|
||||
user_id: int,
|
||||
value: int,
|
||||
comment: str = "",
|
||||
) -> None:
|
||||
"""Insert/replace a single user's vote (+1/-1) for a repo."""
|
||||
async with aiosqlite.connect(self._db_path) as db:
|
||||
await db.execute(
|
||||
"""
|
||||
INSERT INTO git_vote
|
||||
(repo_key, guild_id, user_id, value, comment, created_at)
|
||||
VALUES (?, ?, ?, ?, ?, ?)
|
||||
ON CONFLICT(repo_key, guild_id, user_id) DO UPDATE SET
|
||||
value = excluded.value,
|
||||
comment = excluded.comment,
|
||||
created_at = excluded.created_at
|
||||
""",
|
||||
(
|
||||
repo_key,
|
||||
guild_id,
|
||||
user_id,
|
||||
1 if value >= 0 else -1,
|
||||
comment,
|
||||
datetime.now(timezone.utc).isoformat(),
|
||||
),
|
||||
)
|
||||
await db.commit()
|
||||
|
||||
async def clear_vote(self, guild_id: int, repo_key: str, user_id: int) -> int:
|
||||
async with aiosqlite.connect(self._db_path) as db:
|
||||
cur = await db.execute(
|
||||
"DELETE FROM git_vote WHERE guild_id = ? AND repo_key = ? AND user_id = ?",
|
||||
(guild_id, repo_key, user_id),
|
||||
)
|
||||
await db.commit()
|
||||
return cur.rowcount
|
||||
|
||||
async def get_vote_tally(self, guild_id: int, repo_key: str) -> dict[str, int]:
|
||||
"""Return {'up', 'down', 'score'} for a repo in a guild."""
|
||||
async with aiosqlite.connect(self._db_path) as db:
|
||||
async with db.execute(
|
||||
"""
|
||||
SELECT
|
||||
COALESCE(SUM(CASE WHEN value > 0 THEN 1 ELSE 0 END), 0) AS up,
|
||||
COALESCE(SUM(CASE WHEN value < 0 THEN 1 ELSE 0 END), 0) AS down
|
||||
FROM git_vote WHERE guild_id = ? AND repo_key = ?
|
||||
""",
|
||||
(guild_id, repo_key),
|
||||
) as cur:
|
||||
row = await cur.fetchone()
|
||||
up = int(row[0] or 0)
|
||||
down = int(row[1] or 0)
|
||||
return {"up": up, "down": down, "score": up - down}
|
||||
|
||||
async def get_user_vote(self, guild_id: int, repo_key: str, user_id: int) -> int | None:
|
||||
async with aiosqlite.connect(self._db_path) as db:
|
||||
async with db.execute(
|
||||
"SELECT value FROM git_vote WHERE guild_id = ? AND repo_key = ? AND user_id = ?",
|
||||
(guild_id, repo_key, user_id),
|
||||
) as cur:
|
||||
row = await cur.fetchone()
|
||||
return int(row[0]) if row else None
|
||||
|
||||
async def get_vote_leaderboard(self, guild_id: int, limit: int = 10) -> list[dict[str, Any]]:
|
||||
"""Top repos by net vote score, joined with their latest drop metadata."""
|
||||
async with aiosqlite.connect(self._db_path) as db:
|
||||
db.row_factory = aiosqlite.Row
|
||||
async with db.execute(
|
||||
"""
|
||||
SELECT
|
||||
v.repo_key AS repo_key,
|
||||
SUM(CASE WHEN v.value > 0 THEN 1 ELSE 0 END) AS up,
|
||||
SUM(CASE WHEN v.value < 0 THEN 1 ELSE 0 END) AS down,
|
||||
SUM(v.value) AS score,
|
||||
MAX(d.full_name) AS full_name,
|
||||
MAX(d.url) AS url
|
||||
FROM git_vote v
|
||||
LEFT JOIN git_drop d
|
||||
ON d.guild_id = v.guild_id AND d.repo_key = v.repo_key
|
||||
WHERE v.guild_id = ?
|
||||
GROUP BY v.repo_key
|
||||
ORDER BY score DESC, up DESC
|
||||
LIMIT ?
|
||||
""",
|
||||
(guild_id, limit),
|
||||
) as cur:
|
||||
return [dict(r) for r in await cur.fetchall()]
|
||||
Reference in New Issue
Block a user