Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
50 changes: 29 additions & 21 deletions src/ghdcbot/adapters/storage/sqlite.py
Original file line number Diff line number Diff line change
Expand Up @@ -4,10 +4,17 @@
import sqlite3
from datetime import datetime, timezone
from pathlib import Path
from typing import Any, Iterable, Sequence
from typing import Any, Iterable, Literal, Sequence

from ghdcbot.config.models import IdentityMapping
from ghdcbot.core.models import ContributionEvent, ContributionSummary, Score
from ghdcbot.core.interfaces import (
AuditEventDict,
IdentityLinkDict,
IdentityStatusDict,
IssueRequestDict,
NotificationRecordDict,
UnlinkResultDict,
)
from ghdcbot.core.models import ContributionEvent, ContributionSummary, IdentityMapping, Score


class SqliteStorage:
Expand Down Expand Up @@ -435,7 +442,7 @@ def create_identity_claim(
),
)

def get_identity_link(self, discord_user_id: str, github_user: str) -> dict | None:
def get_identity_link(self, discord_user_id: str, github_user: str) -> IdentityLinkDict | None:
self.init_schema()
gh_norm = github_user.strip().lower()
with self._connect() as conn:
Expand Down Expand Up @@ -468,7 +475,7 @@ def mark_identity_verified(self, discord_user_id: str, github_user: str) -> None

def unlink_identity(
self, discord_user_id: str, cooldown_hours: int
) -> dict | None:
) -> UnlinkResultDict | None:
"""Unlink the verified identity for this Discord user (set verified=0, unlinked_at=now).
Rows are never deleted. Returns unlink info for audit, or None if no verified link.
Raises ValueError if inside cooldown window.
Expand Down Expand Up @@ -539,9 +546,9 @@ def list_verified_identity_mappings(self) -> list[IdentityMapping]:
for row in rows
]

def get_identity_links_for_discord_user(self, discord_user_id: str) -> list[dict]:
def get_identity_links_for_discord_user(self, discord_user_id: str) -> list[IdentityLinkDict]:
"""Return all identity link rows for a Discord user (verified and pending).
Optional method; not part of the Storage protocol. Used for /verify and /status.
Used for /verify and /status.
"""
with self._connect() as conn:
rows = conn.execute(
Expand All @@ -556,7 +563,7 @@ def get_identity_links_for_discord_user(self, discord_user_id: str) -> list[dict
).fetchall()
return [dict(row) for row in rows]

def get_identity_status(self, discord_user_id: str, max_age_days: int | None = None) -> dict:
def get_identity_status(self, discord_user_id: str, max_age_days: int | None = None) -> IdentityStatusDict:
"""Read-only: return current identity status for a Discord user.
Returns dict with github_user, status ('verified'|'verified_stale'|'pending'|'not_linked'),
verified_at (UTC ISO or None), is_stale (bool).
Expand Down Expand Up @@ -626,7 +633,7 @@ def insert_issue_request(
(request_id, discord_user_id, github_user, owner, repo, issue_number, issue_url, now),
)

def list_pending_issue_requests(self) -> list[dict]:
def list_pending_issue_requests(self) -> list[IssueRequestDict]:
"""Return all issue requests with status pending, ordered by created_at ascending."""
with self._connect() as conn:
rows = conn.execute(
Expand All @@ -640,7 +647,7 @@ def list_pending_issue_requests(self) -> list[dict]:
).fetchall()
return [dict(row) for row in rows]

def get_issue_request(self, request_id: str) -> dict | None:
def get_issue_request(self, request_id: str) -> IssueRequestDict | None:
"""Return a single issue request by request_id, or None."""
with self._connect() as conn:
row = conn.execute(
Expand All @@ -649,28 +656,30 @@ def get_issue_request(self, request_id: str) -> dict | None:
).fetchone()
return dict(row) if row else None

def update_issue_request_status(self, request_id: str, status: str) -> None:
def update_issue_request_status(
self,
request_id: str,
status: Literal["pending", "approved", "rejected", "cancelled"],
) -> None:
"""Update request status to approved, rejected, or cancelled."""
if status not in ("pending", "approved", "rejected", "cancelled"):
raise ValueError(f"Invalid status: {status}")
with self._connect() as conn:
conn.execute("UPDATE issue_requests SET status = ? WHERE request_id = ?", (status, request_id))

def append_audit_event(self, event: dict) -> None:
"""Append a single audit event (append-only) to data_dir/audit_events.jsonl.
Optional method; not part of the Storage protocol.
"""
def append_audit_event(self, event: AuditEventDict) -> None:
"""Append a single audit event (append-only) to data_dir/audit_events.jsonl."""
path = self._db_path.parent / "audit_events.jsonl"
payload = dict(event)
if "timestamp" not in payload:
payload["timestamp"] = datetime.now(timezone.utc).isoformat()
line = json.dumps(payload, separators=(",", ":")) + "\n"
path.open("a", encoding="utf-8").write(line)
with path.open("a", encoding="utf-8") as f:
f.write(line)

def list_audit_events(self) -> list[dict]:
def list_audit_events(self) -> list[AuditEventDict]:
"""Read-only: return all audit events from audit_events.jsonl.
Returns empty list if file doesn't exist. Does not modify data.
Optional method; not part of the Storage protocol.
"""
path = self._db_path.parent / "audit_events.jsonl"
events = []
Expand All @@ -697,7 +706,7 @@ def was_notification_sent(self, dedupe_key: str) -> bool:
def mark_notification_sent(
self,
dedupe_key: str,
event: Any,
event: ContributionEvent,
discord_user_id: str,
channel_id: str | None,
target_github_user: str | None = None,
Expand Down Expand Up @@ -726,10 +735,9 @@ def mark_notification_sent(
),
)

def list_recent_notifications(self, limit: int = 1000) -> list[dict]:
def list_recent_notifications(self, limit: int = 1000) -> list[NotificationRecordDict]:
"""List recent notifications (for snapshot export).
Returns list of notification dicts, ordered by sent_at DESC.
Optional method; not part of the Storage protocol.
"""
with self._connect() as conn:
rows = conn.execute(
Expand Down
172 changes: 171 additions & 1 deletion src/ghdcbot/core/interfaces.py
Original file line number Diff line number Diff line change
@@ -1,17 +1,94 @@
from __future__ import annotations

from collections.abc import Iterable, Sequence
from datetime import datetime
from typing import Iterable, Protocol, Sequence
from typing import Literal, Protocol, TypedDict

from ghdcbot.core.models import (
AssignmentPlan,
ContributionEvent,
ContributionSummary,
IdentityMapping,
ReviewPlan,
Score,
)


class _IdentityLinkRequired(TypedDict):
discord_user_id: str
github_user: str
github_user_normalized: str
verified: int
created_at: str


class IdentityLinkDict(_IdentityLinkRequired, total=False):
verification_code: str | None
expires_at: str | None
verified_at: str | None
unlinked_at: str | None


class _UnlinkResultRequired(TypedDict):
discord_user_id: str
github_user: str
verified_at: str
unlinked_at: str


class UnlinkResultDict(_UnlinkResultRequired, total=False):
cooldown_until: str | None
cooldown_hours: int


class IdentityStatusDict(TypedDict):
github_user: str | None
status: Literal["verified", "verified_stale", "pending", "not_linked"]
verified_at: str | None
is_stale: bool


class IssueRequestDict(TypedDict):
request_id: str
discord_user_id: str
github_user: str
owner: str
repo: str
issue_number: int
issue_url: str
created_at: str
status: Literal["pending", "approved", "rejected", "cancelled"]

Comment thread
coderabbitai[bot] marked this conversation as resolved.

class AuditEventContext(TypedDict, total=False):
org: str
repo: str
snapshot_dir: str
run_id: str
files_written: int
timestamp: str


class _AuditEventRequired(TypedDict):
event_type: str


class AuditEventDict(_AuditEventRequired, total=False):
timestamp: str
context: AuditEventContext


class NotificationRecordDict(TypedDict):
dedupe_key: str
event_type: str
github_user: str
discord_user_id: str
repo: str
target: str | None
channel_id: str | None
sent_at: str


class GitHubReader(Protocol):
def list_contributions(self, since: datetime) -> Iterable[ContributionEvent]:
"""Yield contributions since the given timestamp."""
Expand Down Expand Up @@ -59,6 +136,7 @@ def list_contribution_summaries(
period_start: datetime,
period_end: datetime,
weights: dict[str, int],
difficulty_weights: dict[str, int] | None = None,
) -> Sequence[ContributionSummary]:
"""Aggregate contribution counts and scores for the period."""

Expand All @@ -74,6 +152,98 @@ def get_cursor(self, source: str) -> datetime | None:
def set_cursor(self, source: str, cursor: datetime) -> None:
"""Persist last sync cursor for a source."""

# Identity linking

def create_identity_claim(
self,
discord_user_id: str,
github_user: str,
verification_code: str,
expires_at: datetime,
*,
max_age_days: int | None = None,
) -> None:
"""Create or refresh a pending identity claim for (discord_user_id, github_user)."""

def get_identity_link(
self, discord_user_id: str, github_user: str
) -> IdentityLinkDict | None:
"""Return identity link row for (discord_user_id, github_user), or None."""

def mark_identity_verified(self, discord_user_id: str, github_user: str) -> None:
"""Mark an identity claim as verified."""

def unlink_identity(
self, discord_user_id: str, cooldown_hours: int
) -> UnlinkResultDict | None:
"""Unlink the verified identity for a Discord user. Returns unlink info or None."""

def list_verified_identity_mappings(self) -> list[IdentityMapping]:
"""Return all verified identity mappings."""

def get_identity_links_for_discord_user(
self, discord_user_id: str
) -> list[IdentityLinkDict]:
"""Return all identity link rows for a Discord user (verified and pending)."""

def get_identity_status(
self, discord_user_id: str, max_age_days: int | None = None
) -> IdentityStatusDict:
"""Return current identity status dict for a Discord user."""

# Issue requests

def insert_issue_request(
self,
request_id: str,
discord_user_id: str,
github_user: str,
owner: str,
repo: str,
issue_number: int,
issue_url: str,
) -> None:
"""Store a new issue assignment request with status pending."""

def list_pending_issue_requests(self) -> list[IssueRequestDict]:
"""Return all pending issue requests ordered by created_at ascending."""

def get_issue_request(self, request_id: str) -> IssueRequestDict | None:
"""Return a single issue request by request_id, or None."""

def update_issue_request_status(
self,
request_id: str,
status: Literal["pending", "approved", "rejected", "cancelled"],
) -> None:
"""Update an issue request status (pending, approved, rejected, cancelled)."""

# Audit log

def append_audit_event(self, event: AuditEventDict) -> None:
"""Append an audit event (append-only)."""

def list_audit_events(self) -> list[AuditEventDict]:
"""Return all audit events."""

# Notifications

def was_notification_sent(self, dedupe_key: str) -> bool:
"""Check if a notification was already sent (deduplication)."""

def mark_notification_sent(
self,
dedupe_key: str,
event: ContributionEvent,
discord_user_id: str,
channel_id: str | None,
target_github_user: str | None = None,
) -> None:
"""Record that a notification was sent (deduplication tracking)."""
Comment thread
coderabbitai[bot] marked this conversation as resolved.

def list_recent_notifications(self, limit: int = 1000) -> list[NotificationRecordDict]:
"""Return recent sent notifications ordered by sent_at descending."""


class ScoreStrategy(Protocol):
def compute_scores(
Expand Down
6 changes: 6 additions & 0 deletions src/ghdcbot/core/models.py
Original file line number Diff line number Diff line change
Expand Up @@ -5,6 +5,12 @@
from typing import Any


@dataclass(frozen=True)
class IdentityMapping:
github_user: str
discord_user_id: str


@dataclass(frozen=True)
class ContributionEvent:
github_user: str
Expand Down
Loading
Loading