Initial sanitary RepoRadar release

This commit is contained in:
2026-06-27 21:20:43 -04:00
commit b90ec9cdd0
15 changed files with 2435 additions and 0 deletions
+1
View File
@@ -0,0 +1 @@
"""RepoRadar utility package: git client, store helpers, and embed builders."""
+235
View File
@@ -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)
+357
View File
@@ -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
+430
View File
@@ -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()]