From 6085f69f729556bd814900c7a4cd0cfcf3f3715d Mon Sep 17 00:00:00 2001 From: Jithin Date: Sun, 9 Aug 2026 16:37:29 +0530 Subject: [PATCH] =?UTF-8?q?feat:=20GitHub=20auto-sync=20=E2=80=94=20push?= =?UTF-8?q?=20solutions=20on=20level=20completion?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - OAuth connect/disconnect flow with AES-256-GCM token encryption - Auto-push via Celery on challenge completion (Git Trees API batch commits) - Circuit breaker with auto-disable after consecutive failures - Professional README/PROGRESS.md generation with shields.io badges - Prometheus metrics endpoint + admin dashboard aggregation - Push history, retry, stats API with paginated responses - GitHub webhook handler for proactive token revocation - Profile UI integration with connect button and sync status - Language auto-detection, repo visibility choice (public/private) - 30 tests covering signals, tasks, encryption, views, monitoring - Firebase FCM temporarily disabled --- frontend/src/App.jsx | 13 + frontend/src/bones/profile-page.bones.json | 1822 ++++++++++------- frontend/src/hooks/useFCM.js | 5 + frontend/src/hooks/useGitHubSync.js | 143 ++ frontend/src/profile/Profile.jsx | 24 +- .../profile/components/GitHubSyncSection.jsx | 352 ++++ .../src/services/api/githubSyncService.js | 54 + frontend/src/settings/GitHubSyncCallback.jsx | 49 + services/core/.env.example | 5 + services/core/github/__init__.py | 1 + services/core/github/admin.py | 148 ++ services/core/github/apps.py | 10 + services/core/github/encryption.py | 62 + services/core/github/metrics.py | 204 ++ .../core/github/migrations/0001_initial.py | 170 ++ .../core/github/migrations/0002_v2_schema.py | 66 + services/core/github/migrations/__init__.py | 1 + services/core/github/models.py | 160 ++ services/core/github/serializers.py | 98 + services/core/github/services/__init__.py | 38 + services/core/github/services/base.py | 34 + .../core/github/services/content_builder.py | 178 ++ .../core/github/services/github_client.py | 113 + services/core/github/services/oauth.py | 37 + services/core/github/services/repo_service.py | 136 ++ services/core/github/services/sync_service.py | 71 + services/core/github/signals.py | 104 + services/core/github/tasks.py | 240 +++ services/core/github/tests/__init__.py | 1 + services/core/github/tests/test_monitoring.py | 248 +++ services/core/github/tests/test_signals.py | 150 ++ services/core/github/tests/test_tasks.py | 226 ++ services/core/github/urls.py | 31 + services/core/github/views/__init__.py | 31 + services/core/github/views/connection.py | 88 + services/core/github/views/monitoring.py | 52 + services/core/github/views/oauth.py | 125 ++ services/core/github/views/push_views.py | 142 ++ services/core/github/webhooks.py | 162 ++ services/core/learning/tasks/execution.py | 9 + services/core/project/settings.py | 13 + services/core/project/urls.py | 1 + 42 files changed, 4834 insertions(+), 783 deletions(-) create mode 100644 frontend/src/hooks/useGitHubSync.js create mode 100644 frontend/src/profile/components/GitHubSyncSection.jsx create mode 100644 frontend/src/services/api/githubSyncService.js create mode 100644 frontend/src/settings/GitHubSyncCallback.jsx create mode 100644 services/core/github/__init__.py create mode 100644 services/core/github/admin.py create mode 100644 services/core/github/apps.py create mode 100644 services/core/github/encryption.py create mode 100644 services/core/github/metrics.py create mode 100644 services/core/github/migrations/0001_initial.py create mode 100644 services/core/github/migrations/0002_v2_schema.py create mode 100644 services/core/github/migrations/__init__.py create mode 100644 services/core/github/models.py create mode 100644 services/core/github/serializers.py create mode 100644 services/core/github/services/__init__.py create mode 100644 services/core/github/services/base.py create mode 100644 services/core/github/services/content_builder.py create mode 100644 services/core/github/services/github_client.py create mode 100644 services/core/github/services/oauth.py create mode 100644 services/core/github/services/repo_service.py create mode 100644 services/core/github/services/sync_service.py create mode 100644 services/core/github/signals.py create mode 100644 services/core/github/tasks.py create mode 100644 services/core/github/tests/__init__.py create mode 100644 services/core/github/tests/test_monitoring.py create mode 100644 services/core/github/tests/test_signals.py create mode 100644 services/core/github/tests/test_tasks.py create mode 100644 services/core/github/urls.py create mode 100644 services/core/github/views/__init__.py create mode 100644 services/core/github/views/connection.py create mode 100644 services/core/github/views/monitoring.py create mode 100644 services/core/github/views/oauth.py create mode 100644 services/core/github/views/push_views.py create mode 100644 services/core/github/webhooks.py diff --git a/frontend/src/App.jsx b/frontend/src/App.jsx index 3ba68a3..25cbd98 100644 --- a/frontend/src/App.jsx +++ b/frontend/src/App.jsx @@ -33,6 +33,7 @@ const CertificateVerification = lazy( const ChallengeWorkspace = lazy(() => import("./game/ChallengeWorkspace")); const MarketplacePage = lazy(() => import("./marketplace/MarketplacePage")); const AchievementsPage = lazy(() => import("./pages/AchievementsPage")); +const GitHubSyncCallback = lazy(() => import("./settings/GitHubSyncCallback")); // Skeletons — layout-accurate, boneyard-wrapped import { @@ -125,6 +126,18 @@ const AppContent = memo(() => { } /> + {/* GitHub Sync OAuth Callback */} + + }> + + + + } + /> + {/* Protected Routes wrapped in MainLayout */} { + // ─── DISABLED: Firebase FCM temporarily disabled ─────────────────── + // Re-enable by removing this early return block. + return; + // ────────────────────────────────────────────────────────────────── + const showWelcomeNotification = useCallback(async () => { if (!("Notification" in window) || Notification.permission !== "granted") return; diff --git a/frontend/src/hooks/useGitHubSync.js b/frontend/src/hooks/useGitHubSync.js new file mode 100644 index 0000000..90ec4ea --- /dev/null +++ b/frontend/src/hooks/useGitHubSync.js @@ -0,0 +1,143 @@ +import { useState, useEffect, useCallback } from "react"; +import githubSyncService from "../services/api/githubSyncService"; + +/** + * Hook to manage GitHub Sync connection state and actions. + * + * Usage: + * const { connection, isLoading, connect, disconnect, toggle, history } = useGitHubSync(); + */ +export function useGitHubSync() { + const [connection, setConnection] = useState(null); + const [connected, setConnected] = useState(false); + const [history, setHistory] = useState([]); + const [isLoading, setIsLoading] = useState(true); + const [error, setError] = useState(null); + + // Fetch current connection status + const fetchConnection = useCallback(async () => { + try { + setIsLoading(true); + const { data } = await githubSyncService.getConnection(); + setConnected(data.connected); + setConnection(data.connection); + setError(null); + } catch (err) { + setError("Failed to fetch GitHub connection status."); + } finally { + setIsLoading(false); + } + }, []); + + // Fetch push history + const fetchHistory = useCallback(async () => { + try { + const { data } = await githubSyncService.getHistory(); + setHistory(data.results || []); + } catch { + // Silently fail — history is non-critical + } + }, []); + + // Initialize OAuth flow + const initiateConnect = useCallback(async () => { + try { + const { data } = await githubSyncService.getConnectUrl(); + // Redirect user to GitHub + window.location.href = data.url; + } catch (err) { + setError("Failed to initiate GitHub connection."); + } + }, []); + + // Complete OAuth flow (call from callback page) + const completeConnect = useCallback(async (code) => { + try { + setIsLoading(true); + const { data } = await githubSyncService.connect(code); + setConnected(true); + setConnection(data.connection); + setError(null); + return data; + } catch (err) { + const msg = err?.response?.data?.error || "Failed to connect GitHub."; + setError(msg); + throw err; + } finally { + setIsLoading(false); + } + }, []); + + // Disconnect + const disconnect = useCallback(async () => { + try { + await githubSyncService.disconnect(); + setConnected(false); + setConnection(null); + setHistory([]); + } catch (err) { + setError("Failed to disconnect GitHub."); + } + }, []); + + // Toggle enable/disable + const toggle = useCallback(async () => { + if (!connection) return; + try { + const { data } = await githubSyncService.updateConnection({ + is_enabled: !connection.is_enabled, + }); + setConnection(data.connection); + } catch (err) { + setError("Failed to update sync setting."); + } + }, [connection]); + + // Update repo name + const updateRepoName = useCallback(async (repoName) => { + try { + const { data } = await githubSyncService.updateConnection({ + repo_name: repoName, + }); + setConnection(data.connection); + } catch (err) { + const msg = err?.response?.data?.repo_name?.[0] || "Invalid repo name."; + setError(msg); + } + }, []); + + // Retry a failed push + const retryPush = useCallback(async (logId) => { + try { + await githubSyncService.retryPush(logId); + // Refresh history after retry + await fetchHistory(); + } catch (err) { + setError("Failed to retry push."); + } + }, [fetchHistory]); + + // Load on mount + useEffect(() => { + fetchConnection(); + }, [fetchConnection]); + + return { + // State + connection, + connected, + history, + isLoading, + error, + + // Actions + initiateConnect, + completeConnect, + disconnect, + toggle, + updateRepoName, + retryPush, + fetchHistory, + fetchConnection, + }; +} diff --git a/frontend/src/profile/Profile.jsx b/frontend/src/profile/Profile.jsx index e99ff93..c460f91 100644 --- a/frontend/src/profile/Profile.jsx +++ b/frontend/src/profile/Profile.jsx @@ -1,7 +1,8 @@ import React, { useState, useRef, useEffect } from "react"; -import { useNavigate, useParams } from "react-router-dom"; +import { useNavigate, useParams, useSearchParams } from "react-router-dom"; import { Plus } from "lucide-react"; import { Skeleton } from "boneyard-js/react"; +import { toast } from "sonner"; import { ProfileSkeleton } from "../bones/PageSkeletons"; import { Button } from "../components/ui/button"; import { Card } from "../components/ui/card"; @@ -17,6 +18,7 @@ import UserListDialog from "./components/UserListDialog"; import UserNotFound from "./components/UserNotFound"; import DeleteAccountDialog from "./components/DeleteAccountDialog"; import SuggestionsSidebar from "./components/SuggestionsSidebar"; +import GitHubSyncSection from "./components/GitHubSyncSection"; // Hooks import { useProfile } from "../hooks/useProfile"; @@ -24,6 +26,23 @@ import { useProfile } from "../hooks/useProfile"; const Profile = () => { const navigate = useNavigate(); const { username } = useParams(); + const [searchParams, setSearchParams] = useSearchParams(); + + // Handle GitHub OAuth callback result + useEffect(() => { + const githubStatus = searchParams.get("github"); + if (githubStatus === "connected") { + toast.success("GitHub connected! Your solutions will auto-sync."); + searchParams.delete("github"); + setSearchParams(searchParams, { replace: true }); + } else if (githubStatus === "error") { + const reason = searchParams.get("reason") || "Unknown error"; + toast.error(`GitHub connection failed: ${reason.replace(/_/g, " ")}`); + searchParams.delete("github"); + searchParams.delete("reason"); + setSearchParams(searchParams, { replace: true }); + } + }, [searchParams, setSearchParams]); const { currentUser, @@ -106,6 +125,9 @@ const Profile = () => { /> + + {/* GitHub Auto-Sync — own profile only */} + {isOwnProfile && } {/* Middle Column - Feed/Edit */} diff --git a/frontend/src/profile/components/GitHubSyncSection.jsx b/frontend/src/profile/components/GitHubSyncSection.jsx new file mode 100644 index 0000000..74c639b --- /dev/null +++ b/frontend/src/profile/components/GitHubSyncSection.jsx @@ -0,0 +1,352 @@ +import { useState } from "react"; +import { ExternalLink, RefreshCw, GitGraph, Check, ChevronRight } from "lucide-react"; +import { useGitHubSync } from "../../hooks/useGitHubSync"; +import { + Card, + CardHeader, + CardTitle, + CardContent, +} from "../../components/ui/card"; +import { Badge } from "../../components/ui/badge"; + +export default function GitHubSyncSection() { + const { + connection, + connected, + history, + isLoading, + error, + initiateConnect, + disconnect, + toggle, + updateRepoName, + retryPush, + fetchHistory, + } = useGitHubSync(); + + const [showHistory, setShowHistory] = useState(false); + const [editingRepo, setEditingRepo] = useState(false); + const [repoInput, setRepoInput] = useState(""); + const [confirmDisconnect, setConfirmDisconnect] = useState(false); + + // ─── Loading ────────────────────────────────────────────────────── + + if (isLoading) { + return ( + +
+
+
+
+
+
+
+
+
+ + ); + } + + // ─── Not Connected ──────────────────────────────────────────────── + + if (!connected) { + return ( + + {/* Hero Area */} +
+ {/* Background Pattern */} +
+
+
+ +
+ +
+ + {/* Status Badge */} +
+ + Integration + +
+
+ + + + GitHub Auto-Sync + +

+ Push solutions to a repo automatically when you complete levels +

+
+ + + {/* Value Props */} +
+ {["Portfolio", "Track Progress", "Share"].map((label) => ( + + {label} + + ))} +
+ + + + {error && ( +

{error}

+ )} +
+ + ); + } + + // ─── Connected ──────────────────────────────────────────────────── + + const hasError = connection?.last_error?.length > 0; + const isActive = connection.is_enabled && !hasError; + + return ( + + {/* Subtle top accent */} +
+ +
+ {/* Header */} +
+
+
+ +
+
+
+

+ GitHub Sync +

+ +
+

+ @{connection.github_username} +

+
+
+ + {/* Toggle */} + +
+ + {/* Error Banner */} + {hasError && ( +
+
+ +

+ {connection.last_error} +

+
+ {connection.last_error.includes("reconnect") && ( + + )} +
+ )} + + {/* Stats Grid */} +
+
+
{connection.total_pushes}
+
Pushes
+
+
+
{connection.repo_name}
+
Repo
+
+
+
{connection.is_enabled ? "Active" : "Paused"}
+
Status
+
+
+ + {/* Actions Row */} +
+ {editingRepo ? ( +
+ setRepoInput(e.target.value)} + placeholder="repo-name" + className="flex-1 px-3 py-1.5 rounded-lg bg-black border border-white/10 text-white text-[10px] font-mono focus:outline-none focus:border-emerald-500/30 focus:ring-1 focus:ring-emerald-500/10 transition-all" + autoFocus + onKeyDown={(e) => { + if (e.key === "Enter") { updateRepoName(repoInput); setEditingRepo(false); } + if (e.key === "Escape") setEditingRepo(false); + }} + /> + +
+ ) : ( + <> + + + + Open + + + + )} +
+ + {/* Push History */} + {showHistory && ( +
+ {history.length === 0 && ( +
+ +

+ No pushes yet. Complete a level
to see your first sync! +

+
+ )} + {history.slice(0, 10).map((log) => ( +
+
+ +
+ + {log.challenge_title} + + {log.pushed_at && ( + + {new Date(log.pushed_at).toLocaleDateString()} + + )} +
+
+
+ {log.status === "FAILED" && ( + + )} + {log.commit_sha && ( + + {log.commit_sha.slice(0, 7)} + + )} +
+
+ ))} +
+ )} + + {/* Footer — Disconnect */} +
+ {confirmDisconnect ? ( +
+ Remove GitHub connection? +
+ + +
+
+ ) : ( + + )} +
+
+ + ); +} + +// ─── Sub-Components ───────────────────────────────────────────────────── + +function StatusDot({ status }) { + const config = { + SUCCESS: { color: "bg-emerald-400", shadow: "shadow-[0_0_4px_rgba(52,211,153,0.5)]" }, + FAILED: { color: "bg-red-400", shadow: "" }, + PENDING: { color: "bg-amber-400", shadow: "", animate: "animate-pulse" }, + IN_PROGRESS: { color: "bg-blue-400", shadow: "", animate: "animate-pulse" }, + SKIPPED: { color: "bg-neutral-600", shadow: "" }, + }; + const c = config[status] || config.SKIPPED; + return ; +} + +function GitHubIcon({ className = "w-5 h-5" }) { + return ( + + + + ); +} diff --git a/frontend/src/services/api/githubSyncService.js b/frontend/src/services/api/githubSyncService.js new file mode 100644 index 0000000..acfbbf9 --- /dev/null +++ b/frontend/src/services/api/githubSyncService.js @@ -0,0 +1,54 @@ +import api from "./client"; + +/** + * GitHub Sync API Service + * + * Handles all communication with the /api/github-sync/ endpoints. + */ + +const githubSyncService = { + /** + * Get the OAuth URL to redirect the user to GitHub for authorization. + */ + getConnectUrl: () => api.get("/github-sync/connect/url/"), + + /** + * Exchange the OAuth code from GitHub callback for a connection. + * @param {string} code - Authorization code from GitHub redirect + */ + connect: (code) => api.post("/github-sync/connect/", { code }), + + /** + * Get the current GitHub connection status. + */ + getConnection: () => api.get("/github-sync/connection/"), + + /** + * Update connection settings (enable/disable, repo name). + * @param {Object} data - { is_enabled?: boolean, repo_name?: string } + */ + updateConnection: (data) => api.patch("/github-sync/connection/", data), + + /** + * Disconnect GitHub (removes the connection entirely). + */ + disconnect: () => api.delete("/github-sync/connection/"), + + /** + * Get push history (last 50 pushes). + */ + getHistory: () => api.get("/github-sync/history/"), + + /** + * Retry a failed push. + * @param {number} logId - The push log ID to retry + */ + retryPush: (logId) => api.post(`/github-sync/retry/${logId}/`), + + /** + * Verify the stored token is still valid. + */ + verify: () => api.post("/github-sync/verify/"), +}; + +export default githubSyncService; diff --git a/frontend/src/settings/GitHubSyncCallback.jsx b/frontend/src/settings/GitHubSyncCallback.jsx new file mode 100644 index 0000000..177bf2e --- /dev/null +++ b/frontend/src/settings/GitHubSyncCallback.jsx @@ -0,0 +1,49 @@ +import { useEffect, useRef } from "react"; +import { useSearchParams, useNavigate } from "react-router-dom"; +import githubSyncService from "../services/api/githubSyncService"; + +/** + * GitHub OAuth Callback Handler + * + * Route: /settings/github/callback + * + * GitHub redirects here after user authorizes the app. + * Exchanges the code for a connection then redirects to profile. + */ +export default function GitHubSyncCallback() { + const [searchParams] = useSearchParams(); + const navigate = useNavigate(); + const called = useRef(false); + + useEffect(() => { + // Prevent double-call in StrictMode + if (called.current) return; + called.current = true; + + const code = searchParams.get("code"); + + if (!code) { + navigate("/profile?github=error&reason=no_code", { replace: true }); + return; + } + + githubSyncService.connect(code) + .then(() => { + navigate("/profile?github=connected", { replace: true }); + }) + .catch(() => { + navigate("/profile?github=error&reason=exchange_failed", { replace: true }); + }); + }, [searchParams, navigate]); + + return ( +
+
+
+

+ Connecting your GitHub account... +

+
+
+ ); +} diff --git a/services/core/.env.example b/services/core/.env.example index 676d0c1..78446d3 100644 --- a/services/core/.env.example +++ b/services/core/.env.example @@ -36,6 +36,11 @@ GITHUB_CLIENT_SECRET=replace-with-github-client-secret GOOGLE_CLIENT_ID=replace-with-google-client-id GOOGLE_CLIENT_SECRET=replace-with-google-client-secret +# GitHub Sync (separate OAuth App with repo scope — can reuse login app if same scopes) +GITHUB_SYNC_CLIENT_ID=replace-with-github-sync-client-id +GITHUB_SYNC_CLIENT_SECRET=replace-with-github-sync-client-secret +GITHUB_WEBHOOK_SECRET=replace-with-webhook-secret-from-github-app-settings + # JWT (Base64 keys for easy dev setup) # Production should use RS256 and provide both JWT_PRIVATE_KEY and JWT_PUBLIC_KEY. JWT_SHARED_SECRET= diff --git a/services/core/github/__init__.py b/services/core/github/__init__.py new file mode 100644 index 0000000..8b13789 --- /dev/null +++ b/services/core/github/__init__.py @@ -0,0 +1 @@ + diff --git a/services/core/github/admin.py b/services/core/github/admin.py new file mode 100644 index 0000000..2c2fbcb --- /dev/null +++ b/services/core/github/admin.py @@ -0,0 +1,148 @@ +from django.contrib import admin + +from .models import GitHubConnection, GitHubPushLog +from .tasks import push_solution_to_github + + +@admin.register(GitHubConnection) +class GitHubConnectionAdmin(admin.ModelAdmin): + list_display = [ + "user", + "github_username", + "repo_name", + "repo_visibility", + "is_enabled", + "consecutive_failures", + "total_pushes", + "last_sync_at", + ] + list_filter = ["is_enabled", "repo_visibility", "consecutive_failures"] + search_fields = ["user__username", "github_username"] + readonly_fields = ["created_at", "updated_at", "last_sync_at", "total_pushes", "consecutive_failures"] + + # NEVER expose the encrypted token in admin + exclude = ["access_token_encrypted"] + + actions = ["reset_failures", "disable_connections", "enable_connections", "retry_all_failed"] + + @admin.action(description="✓ Reset failure counters") + def reset_failures(self, request, queryset): + updated = queryset.update(consecutive_failures=0, last_error="") + self.message_user(request, f"Reset failure counters for {updated} connection(s).") + + @admin.action(description="✗ Disable selected connections") + def disable_connections(self, request, queryset): + updated = queryset.update(is_enabled=False) + self.message_user(request, f"Disabled {updated} connection(s).") + + @admin.action(description="✓ Enable selected connections") + def enable_connections(self, request, queryset): + updated = queryset.update(is_enabled=True, consecutive_failures=0, last_error="") + self.message_user(request, f"Enabled {updated} connection(s) and reset their failures.") + + @admin.action(description="🔄 Retry ALL failed pushes for selected users") + def retry_all_failed(self, request, queryset): + """ + Bulk-retry: Re-queue all FAILED push logs for the selected connections. + Resets their status to PENDING and fires the Celery task. + """ + total_retried = 0 + + for connection in queryset.filter(is_enabled=True): + failed_logs = GitHubPushLog.objects.filter( + connection=connection, + status=GitHubPushLog.Status.FAILED, + ) + + log_ids = list(failed_logs.values_list("id", flat=True)) + + if log_ids: + # Batch update status + failed_logs.update( + status=GitHubPushLog.Status.PENDING, + error_message="", + ) + + # Queue each for async processing + for log_id in log_ids: + push_solution_to_github.delay(log_id) + + total_retried += len(log_ids) + + if total_retried: + self.message_user( + request, + f"🔄 Re-queued {total_retried} failed push(es) for retry.", + ) + else: + self.message_user( + request, + "No failed pushes found for the selected connections (or connections are disabled).", + level="WARNING", + ) + + +@admin.register(GitHubPushLog) +class GitHubPushLogAdmin(admin.ModelAdmin): + list_display = [ + "get_username", + "challenge_title", + "language", + "status", + "duration_display", + "retry_count", + "created_at", + "pushed_at", + ] + list_filter = ["status", "language", "created_at"] + search_fields = ["challenge_title", "challenge_slug", "connection__user__username"] + readonly_fields = [ + "connection", + "challenge_title", + "challenge_slug", + "challenge_order", + "commit_sha", + "commit_url", + "created_at", + "started_at", + "pushed_at", + "duration_ms", + ] + date_hierarchy = "created_at" + + # Don't show full code in admin list + exclude = ["user_code", "challenge_description"] + + actions = ["retry_selected", "mark_as_skipped"] + + @admin.display(description="User", ordering="connection__user__username") + def get_username(self, obj): + return obj.connection.user.username + + @admin.display(description="Duration") + def duration_display(self, obj): + if obj.duration_ms is None: + return "—" + if obj.duration_ms < 1000: + return f"{obj.duration_ms}ms" + return f"{obj.duration_ms / 1000:.1f}s" + + @admin.action(description="🔄 Retry selected pushes") + def retry_selected(self, request, queryset): + """Retry selected push logs.""" + retried = 0 + for log in queryset.exclude(status=GitHubPushLog.Status.SUCCESS): + if log.connection.is_enabled: + log.status = GitHubPushLog.Status.PENDING + log.error_message = "" + log.save(update_fields=["status", "error_message"]) + push_solution_to_github.delay(log.id) + retried += 1 + + self.message_user(request, f"Re-queued {retried} push(es).") + + @admin.action(description="⏭ Mark as skipped (won't retry)") + def mark_as_skipped(self, request, queryset): + """Mark logs as skipped — permanently stops retry.""" + updated = queryset.exclude(status=GitHubPushLog.Status.SUCCESS).update(status=GitHubPushLog.Status.SKIPPED) + self.message_user(request, f"Marked {updated} push(es) as skipped.") diff --git a/services/core/github/apps.py b/services/core/github/apps.py new file mode 100644 index 0000000..b0d3b59 --- /dev/null +++ b/services/core/github/apps.py @@ -0,0 +1,10 @@ +from django.apps import AppConfig + + +class GithubSyncConfig(AppConfig): + default_auto_field = "django.db.models.BigAutoField" + name = "github" + verbose_name = "GitHub Sync" + + def ready(self): + import github.signals # noqa: F401 diff --git a/services/core/github/encryption.py b/services/core/github/encryption.py new file mode 100644 index 0000000..afe9165 --- /dev/null +++ b/services/core/github/encryption.py @@ -0,0 +1,62 @@ +""" +Token encryption utilities. + +Encrypts GitHub OAuth tokens using AES-256-GCM before storage. +The encryption key is derived from Django's SECRET_KEY via HKDF, +so tokens are tied to the deployment and cannot be extracted from a DB dump alone. + +This provides defense-in-depth: +- DB compromise alone doesn't expose tokens +- Tokens are bound to the application secret +- Each token has a unique nonce (IV) +""" + +import os + +from cryptography.hazmat.primitives.ciphers.aead import AESGCM +from cryptography.hazmat.primitives.hashes import SHA256 +from cryptography.hazmat.primitives.kdf.hkdf import HKDF +from django.conf import settings + +# Nonce size for AES-GCM (96 bits recommended by NIST) +_NONCE_SIZE = 12 + + +def _derive_key() -> bytes: + """Derive a 256-bit AES key from Django's SECRET_KEY using HKDF.""" + secret = settings.SECRET_KEY.encode("utf-8") + hkdf = HKDF( + algorithm=SHA256(), + length=32, + salt=b"clashcode-github-token-v1", + info=b"github-oauth-token-encryption", + ) + return hkdf.derive(secret) + + +def encrypt_token(plaintext: str) -> bytes: + """ + Encrypt a GitHub token. + Returns: nonce (12 bytes) + ciphertext + tag (16 bytes) + """ + key = _derive_key() + nonce = os.urandom(_NONCE_SIZE) + aesgcm = AESGCM(key) + ciphertext = aesgcm.encrypt(nonce, plaintext.encode("utf-8"), None) + return nonce + ciphertext + + +def decrypt_token(data: bytes) -> str: + """ + Decrypt a GitHub token. + Input: nonce (12 bytes) + ciphertext + tag (16 bytes) + """ + if not data or len(data) < _NONCE_SIZE + 1: + raise ValueError("Invalid encrypted token data") + + key = _derive_key() + nonce = data[:_NONCE_SIZE] + ciphertext = data[_NONCE_SIZE:] + aesgcm = AESGCM(key) + plaintext = aesgcm.decrypt(nonce, ciphertext, None) + return plaintext.decode("utf-8") diff --git a/services/core/github/metrics.py b/services/core/github/metrics.py new file mode 100644 index 0000000..c533e19 --- /dev/null +++ b/services/core/github/metrics.py @@ -0,0 +1,204 @@ +""" +Prometheus metrics exporter for GitHub Sync. + +Exposes counters and histograms that Prometheus scrapes via ServiceMonitor. +Grafana dashboards query these metrics for the monitoring panel. + +Metrics exposed: +- github_sync_pushes_total (counter) — by status, user +- github_sync_push_duration_seconds (histogram) — latency distribution +- github_sync_connections_total (gauge) — active vs disabled +- github_sync_failures_total (counter) — by error type +- github_sync_rate_limit_remaining (gauge) — current rate limit budget +""" + +import logging + +from django.db.models import Avg, Count +from django.utils import timezone + +from .models import GitHubConnection, GitHubPushLog + +logger = logging.getLogger(__name__) + +# ─── Metric Storage (In-Memory Counters for Prometheus Pull) ───────────── +# In production with multiple workers, these would use prometheus_client library. +# For CLASHCODE's architecture (analytics service proxies Prometheus), we expose +# metrics via a /metrics endpoint that the ServiceMonitor scrapes. + + +class GitHubSyncMetrics: + """ + Collects and exposes GitHub sync metrics. + + Two modes: + 1. Real-time: In-memory counters updated by task callbacks + 2. Aggregated: DB-computed stats for dashboard display + + The analytics service fetches aggregated stats via internal API. + """ + + # ─── Aggregated Stats (DB-computed, for dashboards) ────────────── + + @staticmethod + def get_dashboard_metrics(time_window_hours: int = 24) -> dict: + """ + Compute dashboard metrics for the last N hours. + Called by the admin analytics endpoint. + """ + since = timezone.now() - timezone.timedelta(hours=time_window_hours) + + logs = GitHubPushLog.objects.filter(created_at__gte=since) + connections = GitHubConnection.objects.all() + + # Push counts by status + status_counts = logs.values("status").annotate(count=Count("id")).order_by() + status_map = {item["status"]: item["count"] for item in status_counts} + + total = sum(status_map.values()) + success = status_map.get("SUCCESS", 0) + failed = status_map.get("FAILED", 0) + + # Duration stats (only successful pushes) + duration_stats = logs.filter( + status=GitHubPushLog.Status.SUCCESS, + duration_ms__isnull=False, + ).aggregate( + avg_ms=Avg("duration_ms"), + ) + + # Percentile approximation via ordering + successful_logs = logs.filter( + status=GitHubPushLog.Status.SUCCESS, + duration_ms__isnull=False, + ).order_by("duration_ms") + + p95_ms = None + count = successful_logs.count() + if count > 0: + p95_index = int(count * 0.95) + p95_log = successful_logs[min(p95_index, count - 1)] + p95_ms = p95_log.duration_ms + + # Connection health + active_connections = connections.filter(is_enabled=True).count() + disabled_connections = connections.filter(is_enabled=False).count() + unhealthy_connections = connections.filter(is_enabled=True, consecutive_failures__gte=3).count() + + # Error breakdown + error_breakdown = ( + logs.filter(status=GitHubPushLog.Status.FAILED) + .values("error_message") + .annotate(count=Count("id")) + .order_by("-count")[:5] + ) + + # Top failing users + top_failures = ( + logs.filter(status=GitHubPushLog.Status.FAILED) + .values("connection__user__username") + .annotate(count=Count("id")) + .order_by("-count")[:10] + ) + + # Hourly push rate (last 24h) + hourly_buckets = [] + for i in range(min(time_window_hours, 24)): + bucket_start = since + timezone.timedelta(hours=i) + bucket_end = bucket_start + timezone.timedelta(hours=1) + bucket_count = logs.filter( + created_at__gte=bucket_start, + created_at__lt=bucket_end, + status=GitHubPushLog.Status.SUCCESS, + ).count() + hourly_buckets.append( + { + "hour": bucket_start.strftime("%H:%M"), + "count": bucket_count, + } + ) + + return { + "time_window_hours": time_window_hours, + "pushes": { + "total": total, + "success": success, + "failed": failed, + "pending": status_map.get("PENDING", 0) + status_map.get("IN_PROGRESS", 0), + "skipped": status_map.get("SKIPPED", 0), + "success_rate": round((success / total) * 100, 1) if total > 0 else 0, + }, + "latency": { + "avg_ms": round(duration_stats["avg_ms"]) if duration_stats["avg_ms"] else None, + "p95_ms": p95_ms, + }, + "connections": { + "total": active_connections + disabled_connections, + "active": active_connections, + "disabled": disabled_connections, + "unhealthy": unhealthy_connections, + }, + "errors": { + "top_errors": [{"message": e["error_message"][:100], "count": e["count"]} for e in error_breakdown], + "top_failing_users": [ + {"username": u["connection__user__username"], "failures": u["count"]} for u in top_failures + ], + }, + "hourly_rate": hourly_buckets, + } + + # ─── Prometheus Text Format Export ─────────────────────────────── + + @staticmethod + def prometheus_export() -> str: + """ + Export metrics in Prometheus text exposition format. + Scraped by Prometheus via ServiceMonitor. + """ + connections = GitHubConnection.objects.all() + active = connections.filter(is_enabled=True).count() + disabled = connections.filter(is_enabled=False).count() + + # Last hour stats for rate metrics + last_hour = timezone.now() - timezone.timedelta(hours=1) + recent_logs = GitHubPushLog.objects.filter(created_at__gte=last_hour) + + success_count = recent_logs.filter(status=GitHubPushLog.Status.SUCCESS).count() + failed_count = recent_logs.filter(status=GitHubPushLog.Status.FAILED).count() + pending_count = recent_logs.filter( + status__in=[GitHubPushLog.Status.PENDING, GitHubPushLog.Status.IN_PROGRESS] + ).count() + + # Avg duration + avg_duration = ( + recent_logs.filter( + status=GitHubPushLog.Status.SUCCESS, + duration_ms__isnull=False, + ).aggregate( + avg=Avg("duration_ms") + )["avg"] + or 0 + ) + + lines = [ + "# HELP github_sync_connections_active Number of active GitHub connections", + "# TYPE github_sync_connections_active gauge", + f"github_sync_connections_active {active}", + "", + "# HELP github_sync_connections_disabled Number of disabled GitHub connections", + "# TYPE github_sync_connections_disabled gauge", + f"github_sync_connections_disabled {disabled}", + "", + "# HELP github_sync_pushes_total Total pushes in last hour by status", + "# TYPE github_sync_pushes_total gauge", + f'github_sync_pushes_total{{status="success"}} {success_count}', + f'github_sync_pushes_total{{status="failed"}} {failed_count}', + f'github_sync_pushes_total{{status="pending"}} {pending_count}', + "", + "# HELP github_sync_push_duration_avg_ms Average push duration in ms (last hour)", + "# TYPE github_sync_push_duration_avg_ms gauge", + f"github_sync_push_duration_avg_ms {round(avg_duration)}", + "", + ] + + return "\n".join(lines) + "\n" diff --git a/services/core/github/migrations/0001_initial.py b/services/core/github/migrations/0001_initial.py new file mode 100644 index 0000000..55baad6 --- /dev/null +++ b/services/core/github/migrations/0001_initial.py @@ -0,0 +1,170 @@ +import django.db.models.deletion +from django.conf import settings +from django.db import migrations, models + + +class Migration(migrations.Migration): + + initial = True + + dependencies = [ + migrations.swappable_dependency(settings.AUTH_USER_MODEL), + ] + + operations = [ + migrations.CreateModel( + name="GitHubConnection", + fields=[ + ( + "id", + models.BigAutoField( + auto_created=True, + primary_key=True, + serialize=False, + verbose_name="ID", + ), + ), + ("github_username", models.CharField(max_length=255)), + ( + "access_token_encrypted", + models.BinaryField(help_text="AES-256-GCM encrypted GitHub OAuth token"), + ), + ( + "repo_name", + models.CharField( + default="clashcode-solutions", + help_text="Target repository name (created if not exists)", + max_length=100, + ), + ), + ( + "repo_visibility", + models.CharField( + choices=[("public", "Public"), ("private", "Private")], + default="public", + help_text="Whether the solutions repo should be public or private", + max_length=10, + ), + ), + ( + "is_enabled", + models.BooleanField( + default=True, + help_text="Master switch — user can pause sync without disconnecting", + ), + ), + ( + "include_problem_description", + models.BooleanField( + default=True, + help_text="Include the full problem statement in the README", + ), + ), + ("last_sync_at", models.DateTimeField(blank=True, null=True)), + ("last_error", models.TextField(blank=True, default="")), + ( + "consecutive_failures", + models.IntegerField( + default=0, + help_text="Auto-disable after N consecutive failures", + ), + ), + ("total_pushes", models.IntegerField(default=0)), + ("created_at", models.DateTimeField(auto_now_add=True)), + ("updated_at", models.DateTimeField(auto_now=True)), + ( + "user", + models.OneToOneField( + on_delete=django.db.models.deletion.CASCADE, + related_name="github_connection", + to=settings.AUTH_USER_MODEL, + ), + ), + ], + options={ + "verbose_name": "GitHub Connection", + "verbose_name_plural": "GitHub Connections", + }, + ), + migrations.CreateModel( + name="GitHubPushLog", + fields=[ + ( + "id", + models.BigAutoField( + auto_created=True, + primary_key=True, + serialize=False, + verbose_name="ID", + ), + ), + ("challenge_title", models.CharField(max_length=255)), + ("challenge_slug", models.CharField(max_length=255)), + ("challenge_order", models.IntegerField(default=0)), + ( + "challenge_description", + models.TextField( + blank=True, + default="", + help_text="Problem statement for the README (stored at push time)", + ), + ), + ("user_code", models.TextField()), + ("language", models.CharField(default="python", max_length=20)), + ( + "status", + models.CharField( + choices=[ + ("PENDING", "Pending"), + ("IN_PROGRESS", "In Progress"), + ("SUCCESS", "Success"), + ("FAILED", "Failed"), + ("SKIPPED", "Skipped"), + ], + default="PENDING", + max_length=15, + ), + ), + ("commit_sha", models.CharField(blank=True, default="", max_length=40)), + ("commit_url", models.URLField(blank=True, default="")), + ("error_message", models.TextField(blank=True, default="")), + ("retry_count", models.IntegerField(default=0)), + ("created_at", models.DateTimeField(auto_now_add=True)), + ("started_at", models.DateTimeField(blank=True, null=True)), + ("pushed_at", models.DateTimeField(blank=True, null=True)), + ( + "duration_ms", + models.IntegerField( + blank=True, + help_text="Total time from task start to completion in ms", + null=True, + ), + ), + ( + "connection", + models.ForeignKey( + on_delete=django.db.models.deletion.CASCADE, + related_name="push_logs", + to="github.githubconnection", + ), + ), + ], + options={ + "ordering": ["-created_at"], + "indexes": [ + models.Index( + fields=["connection", "-created_at"], + name="github_conn_created_idx", + ), + models.Index( + fields=["status"], + name="github_status_idx", + ), + models.Index( + fields=["connection", "challenge_slug"], + name="github_conn_slug_idx", + ), + ], + }, + ), + ] diff --git a/services/core/github/migrations/0002_v2_schema.py b/services/core/github/migrations/0002_v2_schema.py new file mode 100644 index 0000000..f4d89f9 --- /dev/null +++ b/services/core/github/migrations/0002_v2_schema.py @@ -0,0 +1,66 @@ +""" +Migration from V1 schema to V2. + +This migration is a NO-OP on fresh databases (0001_initial already has V2 schema). +It only applies changes on databases that were created with the original V1 0001_initial. +""" + +from django.db import migrations, models + + +def upgrade_v1_to_v2(apps, schema_editor): + """Upgrade V1 schema to V2 — only runs if old columns exist.""" + with schema_editor.connection.cursor() as cursor: + columns = [ + col.name + for col in schema_editor.connection.introspection.get_table_description(cursor, "github_githubconnection") + ] + + # If access_token_encrypted already exists, this is a fresh V2 DB — skip + if "access_token_encrypted" in columns: + return + + # This is a V1 database — upgrade it + vendor = schema_editor.connection.vendor + + if vendor == "postgresql": + schema_editor.execute( + "ALTER TABLE github_githubconnection RENAME COLUMN access_token TO access_token_encrypted" + ) + schema_editor.execute( + "ALTER TABLE github_githubconnection ALTER COLUMN access_token_encrypted TYPE bytea USING access_token_encrypted::bytea" + ) + schema_editor.execute( + "ALTER TABLE github_githubconnection ADD COLUMN IF NOT EXISTS repo_visibility varchar(10) DEFAULT 'public' NOT NULL" + ) + schema_editor.execute( + "ALTER TABLE github_githubconnection ADD COLUMN IF NOT EXISTS include_problem_description boolean DEFAULT true NOT NULL" + ) + schema_editor.execute( + "ALTER TABLE github_githubconnection ADD COLUMN IF NOT EXISTS consecutive_failures integer DEFAULT 0 NOT NULL" + ) + schema_editor.execute( + "ALTER TABLE github_githubpushlog ADD COLUMN IF NOT EXISTS challenge_description text DEFAULT '' NOT NULL" + ) + schema_editor.execute( + "ALTER TABLE github_githubpushlog ADD COLUMN IF NOT EXISTS commit_url varchar(200) DEFAULT '' NOT NULL" + ) + schema_editor.execute( + "ALTER TABLE github_githubpushlog ADD COLUMN IF NOT EXISTS started_at timestamp with time zone" + ) + schema_editor.execute("ALTER TABLE github_githubpushlog ADD COLUMN IF NOT EXISTS duration_ms integer") + schema_editor.execute("ALTER TABLE github_githubpushlog ALTER COLUMN status TYPE varchar(15)") + schema_editor.execute( + "CREATE INDEX IF NOT EXISTS github_conn_slug_idx ON github_githubpushlog (connection_id, challenge_slug)" + ) + + +class Migration(migrations.Migration): + + dependencies = [ + ("github", "0001_initial"), + ] + + operations = [ + migrations.RunPython(upgrade_v1_to_v2, migrations.RunPython.noop), + ] diff --git a/services/core/github/migrations/__init__.py b/services/core/github/migrations/__init__.py new file mode 100644 index 0000000..8b13789 --- /dev/null +++ b/services/core/github/migrations/__init__.py @@ -0,0 +1 @@ + diff --git a/services/core/github/models.py b/services/core/github/models.py new file mode 100644 index 0000000..de91070 --- /dev/null +++ b/services/core/github/models.py @@ -0,0 +1,160 @@ +from django.contrib.auth.models import User +from django.db import models + + +class GitHubConnection(models.Model): + """ + Stores a user's GitHub OAuth connection for the auto-push feature. + + Separate from the authentication OAuth flow because: + 1. Different OAuth scope (repo contents vs user:email) + 2. Different lifecycle (user can disconnect sync without losing their login) + 3. Clean separation of concerns + """ + + class RepoVisibility(models.TextChoices): + PUBLIC = "public", "Public" + PRIVATE = "private", "Private" + + user = models.OneToOneField( + User, + on_delete=models.CASCADE, + related_name="github_connection", + ) + github_username = models.CharField(max_length=255) + # Token encrypted at application layer before storage + access_token_encrypted = models.BinaryField(help_text="AES-256-GCM encrypted GitHub OAuth token") + repo_name = models.CharField( + max_length=100, + default="clashcode-solutions", + help_text="Target repository name (created if not exists)", + ) + repo_visibility = models.CharField( + max_length=10, + choices=RepoVisibility.choices, + default=RepoVisibility.PUBLIC, + help_text="Whether the solutions repo should be public or private", + ) + is_enabled = models.BooleanField( + default=True, + help_text="Master switch — user can pause sync without disconnecting", + ) + include_problem_description = models.BooleanField( + default=True, + help_text="Include the full problem statement in the README", + ) + last_sync_at = models.DateTimeField(null=True, blank=True) + last_error = models.TextField(blank=True, default="") + consecutive_failures = models.IntegerField( + default=0, + help_text="Auto-disable after N consecutive failures", + ) + total_pushes = models.IntegerField(default=0) + + created_at = models.DateTimeField(auto_now_add=True) + updated_at = models.DateTimeField(auto_now=True) + + class Meta: + verbose_name = "GitHub Connection" + verbose_name_plural = "GitHub Connections" + + def __str__(self): + status = "✓" if self.is_enabled else "✗" + return f"[{status}] {self.user.username} → {self.github_username}/{self.repo_name}" + + # ─── Auto-disable circuit breaker ──────────────────────────────── + + AUTO_DISABLE_THRESHOLD = 5 + + def record_success(self): + """Reset failure counter on successful push.""" + self.consecutive_failures = 0 + self.last_error = "" + self.last_sync_at = models.functions.Now() + self.total_pushes = models.F("total_pushes") + 1 + self.save(update_fields=["consecutive_failures", "last_error", "last_sync_at", "total_pushes"]) + + def record_failure(self, error_msg: str): + """Increment failure counter. Auto-disable if threshold exceeded.""" + self.consecutive_failures = models.F("consecutive_failures") + 1 + self.last_error = error_msg[:500] + self.save(update_fields=["consecutive_failures", "last_error"]) + + # Refresh to get actual value after F() expression + self.refresh_from_db(fields=["consecutive_failures"]) + + if self.consecutive_failures >= self.AUTO_DISABLE_THRESHOLD: + self.is_enabled = False + self.last_error = ( + f"Auto-disabled after {self.AUTO_DISABLE_THRESHOLD} consecutive failures. Last: {error_msg[:200]}" + ) + self.save(update_fields=["is_enabled", "last_error"]) + return True # Was disabled + return False + + +class GitHubPushLog(models.Model): + """ + Audit trail for every push attempt. + + This is the SOURCE OF TRUTH for the user's code — not a cache. + The code is persisted here before the async task runs. + """ + + class Status(models.TextChoices): + PENDING = "PENDING", "Pending" + IN_PROGRESS = "IN_PROGRESS", "In Progress" + SUCCESS = "SUCCESS", "Success" + FAILED = "FAILED", "Failed" + SKIPPED = "SKIPPED", "Skipped" + + connection = models.ForeignKey( + GitHubConnection, + on_delete=models.CASCADE, + related_name="push_logs", + ) + # Challenge metadata (denormalized for independence from challenge model) + challenge_title = models.CharField(max_length=255) + challenge_slug = models.CharField(max_length=255) + challenge_order = models.IntegerField(default=0) + challenge_description = models.TextField( + blank=True, + default="", + help_text="Problem statement for the README (stored at push time)", + ) + + # The actual solution + user_code = models.TextField() + language = models.CharField(max_length=20, default="python") + + # Push state + status = models.CharField( + max_length=15, + choices=Status.choices, + default=Status.PENDING, + ) + commit_sha = models.CharField(max_length=40, blank=True, default="") + commit_url = models.URLField(blank=True, default="") + error_message = models.TextField(blank=True, default="") + retry_count = models.IntegerField(default=0) + + # Timing + created_at = models.DateTimeField(auto_now_add=True) + started_at = models.DateTimeField(null=True, blank=True) + pushed_at = models.DateTimeField(null=True, blank=True) + duration_ms = models.IntegerField( + null=True, + blank=True, + help_text="Total time from task start to completion in ms", + ) + + class Meta: + ordering = ["-created_at"] + indexes = [ + models.Index(fields=["connection", "-created_at"]), + models.Index(fields=["status"]), + models.Index(fields=["connection", "challenge_slug"]), + ] + + def __str__(self): + return f"{self.connection.user.username}/{self.challenge_slug} [{self.status}]" diff --git a/services/core/github/serializers.py b/services/core/github/serializers.py new file mode 100644 index 0000000..a48efed --- /dev/null +++ b/services/core/github/serializers.py @@ -0,0 +1,98 @@ +from rest_framework import serializers + +from .models import GitHubConnection, GitHubPushLog + + +class GitHubConnectSerializer(serializers.Serializer): + """Input: exchange OAuth code for connection.""" + + code = serializers.CharField(help_text="OAuth authorization code from GitHub callback") + + +class GitHubConnectionSerializer(serializers.ModelSerializer): + """Output: current connection status (never exposes token).""" + + class Meta: + model = GitHubConnection + fields = [ + "id", + "github_username", + "repo_name", + "repo_visibility", + "is_enabled", + "include_problem_description", + "last_sync_at", + "last_error", + "consecutive_failures", + "total_pushes", + "created_at", + ] + read_only_fields = fields + + +class GitHubConnectionUpdateSerializer(serializers.Serializer): + """Input: update connection settings.""" + + is_enabled = serializers.BooleanField(required=False) + repo_name = serializers.CharField(max_length=100, required=False) + repo_visibility = serializers.ChoiceField( + choices=GitHubConnection.RepoVisibility.choices, + required=False, + ) + include_problem_description = serializers.BooleanField(required=False) + + def validate_repo_name(self, value): + """GitHub repo name validation — strict rules.""" + import re + + if not re.match(r"^[a-zA-Z0-9][a-zA-Z0-9._-]*$", value): + raise serializers.ValidationError( + "Must start with a letter/number and contain only letters, numbers, hyphens, dots, underscores." + ) + if value.endswith(".git"): + raise serializers.ValidationError("Cannot end with .git") + if ".." in value: + raise serializers.ValidationError("Cannot contain consecutive dots.") + if len(value) > 100: + raise serializers.ValidationError("Must be under 100 characters.") + return value + + +class GitHubPushLogSerializer(serializers.ModelSerializer): + """Output: push history entry.""" + + class Meta: + model = GitHubPushLog + fields = [ + "id", + "challenge_title", + "challenge_slug", + "challenge_order", + "language", + "status", + "commit_sha", + "commit_url", + "error_message", + "retry_count", + "duration_ms", + "created_at", + "pushed_at", + ] + read_only_fields = fields + + +class GitHubOAuthURLSerializer(serializers.Serializer): + """Output: OAuth URL to redirect user to.""" + + url = serializers.URLField() + + +class GitHubSyncStatsSerializer(serializers.Serializer): + """Output: aggregated sync statistics.""" + + total_pushes = serializers.IntegerField() + success_count = serializers.IntegerField() + failed_count = serializers.IntegerField() + pending_count = serializers.IntegerField() + avg_duration_ms = serializers.IntegerField(allow_null=True) + last_push_at = serializers.DateTimeField(allow_null=True) diff --git a/services/core/github/services/__init__.py b/services/core/github/services/__init__.py new file mode 100644 index 0000000..404424a --- /dev/null +++ b/services/core/github/services/__init__.py @@ -0,0 +1,38 @@ +""" +GitHub Sync Services package. + +Split for maintainability: +- base.py → Exceptions, constants +- github_client.py → Low-level GitHub API HTTP client +- repo_service.py → Repository management + Git Trees API +- content_builder.py → README/PROGRESS markdown generation +- oauth.py → OAuth URL + token exchange +- sync_service.py → High-level orchestrator (public API) +""" + +from .base import ( + GitHubRateLimitError, + GitHubSyncError, + GitHubTokenExpiredError, +) +from .content_builder import ContentBuilder, language_extension +from .github_client import GitHubAPIClient +from .oauth import exchange_github_code, get_github_oauth_url +from .repo_service import RepoService +from .sync_service import GitHubSyncService + +__all__ = [ + # Exceptions + "GitHubSyncError", + "GitHubTokenExpiredError", + "GitHubRateLimitError", + # Services + "GitHubSyncService", + "GitHubAPIClient", + "RepoService", + "ContentBuilder", + # Helpers + "get_github_oauth_url", + "exchange_github_code", + "language_extension", +] diff --git a/services/core/github/services/base.py b/services/core/github/services/base.py new file mode 100644 index 0000000..91b5c63 --- /dev/null +++ b/services/core/github/services/base.py @@ -0,0 +1,34 @@ +""" +GitHub Sync exceptions and shared constants. +""" + +import logging + +logger = logging.getLogger(__name__) + +GITHUB_API = "https://api.github.com" +REQUEST_TIMEOUT = (5, 15) # (connect, read) in seconds +RATE_LIMIT_BUFFER = 50 + + +# ─── Exceptions ────────────────────────────────────────────────────────── + + +class GitHubSyncError(Exception): + """Base exception for GitHub sync operations.""" + + pass + + +class GitHubTokenExpiredError(GitHubSyncError): + """Token revoked or expired — cannot recover without re-auth.""" + + pass + + +class GitHubRateLimitError(GitHubSyncError): + """Rate limit exhausted — retry after reset.""" + + def __init__(self, message: str, reset_at: int = 0): + super().__init__(message) + self.reset_at = reset_at diff --git a/services/core/github/services/content_builder.py b/services/core/github/services/content_builder.py new file mode 100644 index 0000000..bf59479 --- /dev/null +++ b/services/core/github/services/content_builder.py @@ -0,0 +1,178 @@ +""" +Content builders — generates README.md and PROGRESS.md content. +""" + +from datetime import datetime + +from ..models import GitHubConnection, GitHubPushLog + +LANGUAGE_EXTENSIONS = { + "python": "py", + "javascript": "js", + "typescript": "ts", + "java": "java", + "cpp": "cpp", + "c": "c", + "go": "go", + "rust": "rs", + "ruby": "rb", + "csharp": "cs", + "kotlin": "kt", + "swift": "swift", +} + + +def language_extension(language: str) -> str: + """Map language name to file extension.""" + return LANGUAGE_EXTENSIONS.get(language.lower(), "txt") + + +class ContentBuilder: + """Generates all markdown content for the GitHub repo.""" + + def __init__(self, connection: GitHubConnection): + self.connection = connection + self.username = connection.github_username + + def repo_readme(self) -> str: + """Professional root README.md.""" + date = datetime.now().strftime("%B %d, %Y") + + return f"""
+ +# ⚡ CLASHCODE Solutions + +**Coding challenge solutions — auto-synced from [clashcode.com](https://clashcode.com)** + +[![CLASHCODE](https://img.shields.io/badge/Platform-CLASHCODE-000000?style=for-the-badge&logo=data:image/svg+xml;base64,PHN2ZyB3aWR0aD0iMjQiIGhlaWdodD0iMjQiIHZpZXdCb3g9IjAgMCAyNCAyNCIgZmlsbD0ibm9uZSIgeG1sbnM9Imh0dHA6Ly93d3cudzMub3JnLzIwMDAvc3ZnIj48cGF0aCBkPSJNMTMgMTBWM0w0IDE0aDdWMjFMMjAgMTBoLTd6IiBmaWxsPSIjMTBiOTgxIi8+PC9zdmc+)](https://clashcode.com) +[![Auto-Sync](https://img.shields.io/badge/Sync-Automated-10b981?style=for-the-badge&logo=github&logoColor=white)](https://clashcode.com) +[![Profile](https://img.shields.io/badge/Coder-@{self.username}-1a1a1a?style=for-the-badge&logo=github&logoColor=white)](https://github.com/{self.username}) + +--- + +*Every time I complete a challenge, my solution is automatically pushed here.* + +
+ +## 🗂️ Repository Structure + +``` +{self.connection.repo_name}/ +│ +├── level-01-challenge-slug/ +│ ├── README.md ← Problem description & metadata +│ └── solution.py ← My solution +│ +├── level-02-challenge-slug/ +│ ├── README.md +│ └── solution.py +│ +├── PROGRESS.md ← Full progress tracker +└── README.md ← You are here +``` + +## 📊 Progress + +Track my journey → **[PROGRESS.md](./PROGRESS.md)** + +## 🛠️ Tech + +| | | +|---|---| +| **Platform** | [CLASHCODE](https://clashcode.com) | +| **Language** | Python | +| **Sync** | Automated via GitHub API | + +## 🔗 Links + +- 🌐 **Platform**: [clashcode.com](https://clashcode.com) +- 👤 **Profile**: [@{self.username}](https://github.com/{self.username}) + +--- + +
+ +🤖 This repository is automatically maintained by CLASHCODE · Generated on {date} + +
+""" + + def problem_readme(self, push_log: GitHubPushLog) -> str: + """README.md for an individual challenge folder.""" + ext = language_extension(push_log.language) + date = push_log.created_at.strftime("%B %d, %Y") if push_log.created_at else "N/A" + + parts = [ + f"# Level {push_log.challenge_order} — {push_log.challenge_title}\n", + "", + "| | |", + "|---|---|", + "| **Platform** | [CLASHCODE](https://clashcode.com) |", + f"| **Language** | {push_log.language.capitalize()} |", + f"| **Solved by** | [@{self.username}](https://github.com/{self.username}) |", + f"| **Date** | {date} |", + "", + ] + + if self.connection.include_problem_description and push_log.challenge_description: + parts.append("---\n") + parts.append("## 📋 Problem\n") + parts.append(push_log.challenge_description) + parts.append("\n") + + parts.append("---\n") + parts.append("## 💡 Solution\n") + parts.append(f"→ [`solution.{ext}`](./solution.{ext})\n") + parts.append("---\n") + parts.append("Auto-synced from [CLASHCODE](https://clashcode.com)\n") + + return "\n".join(parts) + + def progress_tracker(self, logs) -> str: + """PROGRESS.md with visual progress bar and table.""" + from django.utils import timezone + + count = logs.count() + last_updated = timezone.now().strftime("%B %d, %Y at %H:%M UTC") + + total_levels = max(count, 10) + progress_pct = min(int((count / total_levels) * 100), 100) + filled = int(progress_pct / 5) + bar = "█" * filled + "░" * (20 - filled) + + content = f"""
+ +# 📊 Progress Tracker + +**[@{self.username}](https://github.com/{self.username})** · CLASHCODE Solutions + +`{bar}` **{count}** challenges solved + +Last updated: {last_updated} + +
+ +--- + +## Completed Challenges + +| # | Challenge | Language | Date | Solution | +|:---:|-----------|:--------:|:----:|:--------:| +""" + + for log in logs: + date_str = log.pushed_at.strftime("%b %d") if log.pushed_at else "—" + folder = f"level-{log.challenge_order:02d}-{log.challenge_slug}" + ext = language_extension(log.language) + content += f"| {log.challenge_order} | [{log.challenge_title}](./{folder}) | `{log.language}` | {date_str} | [→](./{folder}/solution.{ext}) |\n" + + content += """ +--- + +
+ +⚡ Auto-tracked by CLASHCODE + +
+""" + return content diff --git a/services/core/github/services/github_client.py b/services/core/github/services/github_client.py new file mode 100644 index 0000000..6cba288 --- /dev/null +++ b/services/core/github/services/github_client.py @@ -0,0 +1,113 @@ +""" +Low-level GitHub API client with rate limit awareness. +""" + +import base64 + +import requests + +from ..encryption import decrypt_token +from ..models import GitHubConnection +from .base import ( + GITHUB_API, + RATE_LIMIT_BUFFER, + REQUEST_TIMEOUT, + GitHubRateLimitError, + GitHubSyncError, + GitHubTokenExpiredError, + logger, +) + + +class GitHubAPIClient: + """ + Thin HTTP layer over the GitHub REST API. + Handles auth, timeouts, rate limits, and error translation. + """ + + def __init__(self, connection: GitHubConnection): + self.connection = connection + self._token = None + + @property + def token(self) -> str: + if self._token is None: + self._token = decrypt_token(bytes(self.connection.access_token_encrypted)) + return self._token + + @property + def headers(self) -> dict: + return { + "Authorization": f"Bearer {self.token}", + "Accept": "application/vnd.github+json", + "X-GitHub-Api-Version": "2022-11-28", + } + + def request(self, method: str, path: str, **kwargs) -> requests.Response: + """Make an authenticated GitHub API request.""" + url = f"{GITHUB_API}{path}" if path.startswith("/") else path + kwargs.setdefault("headers", self.headers) + kwargs.setdefault("timeout", REQUEST_TIMEOUT) + + resp = requests.request(method, url, **kwargs) + + # Check rate limit + remaining = int(resp.headers.get("X-RateLimit-Remaining", "999")) + if remaining < RATE_LIMIT_BUFFER and resp.status_code < 400: + reset_at = int(resp.headers.get("X-RateLimit-Reset", "0")) + logger.warning( + f"GitHub rate limit low: {remaining} remaining. " + f"Resets at {reset_at}. User: {self.connection.github_username}" + ) + + if resp.status_code >= 400: + self._handle_error(resp) + + return resp + + def get_file_sha(self, repo: str, path: str) -> str | None: + """Get SHA of an existing file, or None if not found.""" + resp = requests.get( + f"{GITHUB_API}/repos/{repo}/contents/{path}", + headers=self.headers, + timeout=REQUEST_TIMEOUT, + ) + if resp.status_code == 200: + return resp.json().get("sha") + return None + + def create_or_update_file(self, repo: str, path: str, content: str, message: str) -> str: + """Create or update a single file via the Contents API. Returns blob SHA.""" + url = f"/repos/{repo}/contents/{path}" + existing_sha = self.get_file_sha(repo, path) + + payload = { + "message": message, + "content": base64.b64encode(content.encode("utf-8")).decode("ascii"), + "committer": {"name": "CLASHCODE", "email": "bot@clashcode.com"}, + } + if existing_sha: + payload["sha"] = existing_sha + + resp = self.request("PUT", url, json=payload) + return resp.json().get("content", {}).get("sha", "") + + def _handle_error(self, resp: requests.Response): + """Translate HTTP errors to our exception types.""" + if resp.status_code == 401: + raise GitHubTokenExpiredError("GitHub token revoked or expired") + + if resp.status_code == 403: + remaining = resp.headers.get("X-RateLimit-Remaining", "1") + if remaining == "0": + reset_at = int(resp.headers.get("X-RateLimit-Reset", "0")) + raise GitHubRateLimitError(f"Rate limit exhausted. Resets at: {reset_at}", reset_at=reset_at) + raise GitHubSyncError(f"Forbidden: {resp.text[:200]}") + + if resp.status_code == 404: + raise GitHubSyncError(f"Not found: {resp.url}") + + if resp.status_code == 422: + raise GitHubSyncError(f"Validation error: {resp.text[:200]}") + + raise GitHubSyncError(f"API error {resp.status_code}: {resp.text[:200]}") diff --git a/services/core/github/services/oauth.py b/services/core/github/services/oauth.py new file mode 100644 index 0000000..feeb85a --- /dev/null +++ b/services/core/github/services/oauth.py @@ -0,0 +1,37 @@ +""" +OAuth helpers — token exchange and URL generation. +""" + +import requests +from django.conf import settings + +from .base import REQUEST_TIMEOUT + + +def get_github_oauth_url(state: str = "") -> str: + """Build GitHub OAuth URL for the repo-sync feature.""" + from urllib.parse import urlencode + + params = { + "client_id": settings.GITHUB_SYNC_CLIENT_ID, + "redirect_uri": f"{settings.FRONTEND_URL}/settings/github/callback", + "scope": "repo", + "state": state, + } + return f"https://github.com/login/oauth/authorize?{urlencode(params)}" + + +def exchange_github_code(code: str) -> dict: + """Exchange OAuth authorization code for an access token.""" + resp = requests.post( + "https://github.com/login/oauth/access_token", + json={ + "client_id": settings.GITHUB_SYNC_CLIENT_ID, + "client_secret": settings.GITHUB_SYNC_CLIENT_SECRET, + "code": code, + }, + headers={"Accept": "application/json"}, + timeout=REQUEST_TIMEOUT, + ) + resp.raise_for_status() + return resp.json() diff --git a/services/core/github/services/repo_service.py b/services/core/github/services/repo_service.py new file mode 100644 index 0000000..b3835a1 --- /dev/null +++ b/services/core/github/services/repo_service.py @@ -0,0 +1,136 @@ +""" +Repository management — create, ensure exists, Git Trees API batch commits. +""" + +import requests +from django.utils import timezone + +from ..models import GitHubConnection +from .base import GITHUB_API, REQUEST_TIMEOUT +from .github_client import GitHubAPIClient + + +class RepoService: + """Handles repo creation and Git Trees API batch commits.""" + + def __init__(self, client: GitHubAPIClient): + self.client = client + self.connection = client.connection + + def ensure_repo_exists(self) -> str: + """Ensure target repo exists. Create if not. Returns full name.""" + full_name = f"{self.connection.github_username}/{self.connection.repo_name}" + + resp = requests.get( + f"{GITHUB_API}/repos/{full_name}", + headers=self.client.headers, + timeout=REQUEST_TIMEOUT, + ) + + if resp.status_code == 200: + return full_name + if resp.status_code == 404: + return self._create_repo() + + self.client._handle_error(resp) + return full_name + + def create_tree_commit(self, repo: str, files: dict, message: str) -> dict: + """ + Create a single commit with multiple files using Git Trees API. + One commit, one event, clean history. + """ + # 1. Get HEAD + ref_resp = self.client.request("GET", f"/repos/{repo}/git/ref/heads/main") + head_sha = ref_resp.json()["object"]["sha"] + + # 2. Get base tree + commit_resp = self.client.request("GET", f"/repos/{repo}/git/commits/{head_sha}") + base_tree_sha = commit_resp.json()["tree"]["sha"] + + # 3. Create blobs + tree_items = [] + for path, content in files.items(): + blob_resp = self.client.request( + "POST", + f"/repos/{repo}/git/blobs", + json={"content": content, "encoding": "utf-8"}, + ) + tree_items.append( + { + "path": path, + "mode": "100644", + "type": "blob", + "sha": blob_resp.json()["sha"], + } + ) + + # 4. Create tree + tree_resp = self.client.request( + "POST", + f"/repos/{repo}/git/trees", + json={"base_tree": base_tree_sha, "tree": tree_items}, + ) + new_tree_sha = tree_resp.json()["sha"] + + # 5. Create commit + commit_resp = self.client.request( + "POST", + f"/repos/{repo}/git/commits", + json={ + "message": message, + "tree": new_tree_sha, + "parents": [head_sha], + "author": { + "name": "CLASHCODE", + "email": "bot@clashcode.com", + "date": timezone.now().strftime("%Y-%m-%dT%H:%M:%SZ"), + }, + }, + ) + new_commit_sha = commit_resp.json()["sha"] + + # 6. Update ref + self.client.request( + "PATCH", + f"/repos/{repo}/git/refs/heads/main", + json={"sha": new_commit_sha}, + ) + + return { + "sha": new_commit_sha, + "html_url": f"https://github.com/{repo}/commit/{new_commit_sha}", + } + + def _create_repo(self) -> str: + """Create the solutions repository with professional setup.""" + from .content_builder import ContentBuilder + + is_private = self.connection.repo_visibility == GitHubConnection.RepoVisibility.PRIVATE + username = self.connection.github_username + + payload = { + "name": self.connection.repo_name, + "description": f"⚡ Coding challenge solutions by @{username} — auto-synced from CLASHCODE", + "homepage": "https://clashcode.com", + "private": is_private, + "auto_init": True, + "has_issues": False, + "has_projects": False, + "has_wiki": False, + } + + resp = self.client.request("POST", "/user/repos", json=payload) + repo_data = resp.json() + full_name = repo_data["full_name"] + + # Initialize with professional README + builder = ContentBuilder(self.connection) + self.client.create_or_update_file( + repo=full_name, + path="README.md", + content=builder.repo_readme(), + message="🎮 Initialize CLASHCODE solutions repo", + ) + + return full_name diff --git a/services/core/github/services/sync_service.py b/services/core/github/services/sync_service.py new file mode 100644 index 0000000..771b884 --- /dev/null +++ b/services/core/github/services/sync_service.py @@ -0,0 +1,71 @@ +""" +Main sync orchestrator — the public API that tasks call. +""" + +from ..models import GitHubConnection, GitHubPushLog +from .content_builder import ContentBuilder, language_extension +from .github_client import GitHubAPIClient +from .repo_service import RepoService + + +class GitHubSyncService: + """ + High-level orchestrator for pushing solutions to GitHub. + Composes GitHubAPIClient, RepoService, and ContentBuilder. + """ + + def __init__(self, connection: GitHubConnection): + self.connection = connection + self.client = GitHubAPIClient(connection) + self.repo = RepoService(self.client) + self.content = ContentBuilder(connection) + + def push_solution(self, push_log: GitHubPushLog) -> dict: + """ + Push a challenge solution as a single atomic commit. + Returns dict with 'sha' and 'url'. + """ + repo_full_name = self.repo.ensure_repo_exists() + + folder = f"level-{push_log.challenge_order:02d}-{push_log.challenge_slug}" + ext = language_extension(push_log.language) + + files = { + f"{folder}/solution.{ext}": push_log.user_code, + f"{folder}/README.md": self.content.problem_readme(push_log), + } + + return self.repo.create_tree_commit( + repo=repo_full_name, + files=files, + message=f"✅ Level {push_log.challenge_order}: {push_log.challenge_title}", + ) + + def update_progress_tracker(self, repo_full_name: str = None): + """Rebuild PROGRESS.md with current completion data.""" + if not repo_full_name: + repo_full_name = f"{self.connection.github_username}/{self.connection.repo_name}" + + logs = GitHubPushLog.objects.filter( + connection=self.connection, + status=GitHubPushLog.Status.SUCCESS, + ).order_by("challenge_order") + + content = self.content.progress_tracker(logs) + + self.client.create_or_update_file( + repo=repo_full_name, + path="PROGRESS.md", + content=content, + message="📊 Update progress tracker", + ) + + def verify_token(self) -> dict: + """Verify stored token is valid. Returns GitHub user info.""" + resp = self.client.request("GET", "/user") + return resp.json() + + def check_rate_limit(self) -> dict: + """Check remaining API rate limit.""" + resp = self.client.request("GET", "/rate_limit") + return resp.json().get("rate", {}) diff --git a/services/core/github/signals.py b/services/core/github/signals.py new file mode 100644 index 0000000..c21874b --- /dev/null +++ b/services/core/github/signals.py @@ -0,0 +1,104 @@ +""" +Signal handler: Triggers GitHub push on challenge completion. + +Design: +- Code is stored DIRECTLY in GitHubPushLog (not in ephemeral cache) +- Signal reads from cache but the cache is just a transport mechanism + with the PushLog as the durable record +- Problem description is captured at push time (immutable snapshot) +""" + +import logging + +from django.db.models.signals import post_save +from django.dispatch import receiver + +from challenges.models import UserProgress + +from .models import GitHubConnection, GitHubPushLog +from .tasks import push_solution_to_github + +logger = logging.getLogger(__name__) + + +@receiver(post_save, sender=UserProgress) +def trigger_github_sync_on_completion(sender, instance, **kwargs): + """ + Queue a GitHub push when a challenge is completed. + Coexists with other UserProgress signals (certificates, leaderboard). + """ + _ = sender, kwargs + + if instance.status != UserProgress.Status.COMPLETED: + return + + user = instance.user + challenge = instance.challenge + + # Check for active connection + try: + connection = GitHubConnection.objects.get(user=user, is_enabled=True) + except GitHubConnection.DoesNotExist: + return + + # Deduplicate: skip if already successfully pushed + if GitHubPushLog.objects.filter( + connection=connection, + challenge_slug=challenge.slug, + status=GitHubPushLog.Status.SUCCESS, + ).exists(): + return + + # Get the user's submitted code from cache + from django.core.cache import cache + + cache_key = f"github_sync:last_code:{user.id}:{challenge.id}" + user_code = cache.get(cache_key, "") + + if not user_code: + logger.warning(f"No cached code for {user.username}/{challenge.slug}. " f"GitHub push skipped.") + return + + cache.delete(cache_key) + + # Detect language from code or default + language = _detect_language(user_code, challenge) + + # Create the durable push record (code lives here, not in cache) + push_log = GitHubPushLog.objects.create( + connection=connection, + challenge_title=challenge.title, + challenge_slug=challenge.slug, + challenge_order=challenge.order, + challenge_description=challenge.description or "", + user_code=user_code, + language=language, + ) + + # Fire async task + push_solution_to_github.delay(push_log.id) + + logger.info(f"Queued GitHub push: {user.username}/{challenge.title} (log={push_log.id})") + + +def _detect_language(code: str, challenge) -> str: + """ + Detect programming language from code content. + Extend this as you add multi-language support. + """ + # Check for obvious language markers + indicators = { + "javascript": ["const ", "let ", "var ", "function ", "console.log", "=>"], + "typescript": ["interface ", ": string", ": number", "type "], + "java": ["public class ", "System.out", "public static void main"], + "cpp": ["#include", "std::", "cout", "int main("], + "go": ["package main", "func main(", "fmt."], + "rust": ["fn main()", "println!", "let mut"], + } + + for lang, markers in indicators.items(): + if any(marker in code for marker in markers): + return lang + + # Default to python (primary language for CLASHCODE) + return "python" diff --git a/services/core/github/tasks.py b/services/core/github/tasks.py new file mode 100644 index 0000000..66296ee --- /dev/null +++ b/services/core/github/tasks.py @@ -0,0 +1,240 @@ +""" +Celery tasks for GitHub sync. + +Production design: +- All GitHub I/O is async (never in request cycle) +- Exponential backoff with jitter +- Task locking to prevent duplicate runs +- Duration tracking for observability +- Circuit breaker pattern on the connection model +- PROGRESS.md update is a separate, batched task +""" + +import logging +import time + +from celery import shared_task +from django.core.cache import cache +from django.utils import timezone + +from .models import GitHubConnection, GitHubPushLog +from .services import ( + GitHubRateLimitError, + GitHubSyncError, + GitHubSyncService, + GitHubTokenExpiredError, +) + +logger = logging.getLogger(__name__) + +MAX_RETRIES = 4 +RETRY_BACKOFF = 30 + + +@shared_task( + bind=True, + max_retries=MAX_RETRIES, + default_retry_delay=RETRY_BACKOFF, + autoretry_for=(GitHubRateLimitError,), + retry_backoff=True, + retry_backoff_max=1800, + acks_late=True, + reject_on_worker_lost=True, +) +def push_solution_to_github(self, push_log_id: int): + """ + Push a single solution to GitHub. + Idempotent — safe to retry. + """ + # ─── Task lock: prevent duplicate execution ────────────────────── + lock_key = f"github:push_lock:{push_log_id}" + if not cache.add(lock_key, "1", timeout=300): + logger.info(f"Push {push_log_id} already in progress. Skipping duplicate.") + return + + try: + push_log = GitHubPushLog.objects.select_related("connection", "connection__user").get(id=push_log_id) + except GitHubPushLog.DoesNotExist: + logger.error(f"GitHubPushLog {push_log_id} not found. Aborting.") + cache.delete(lock_key) + return + + connection = push_log.connection + + # Skip if disabled + if not connection.is_enabled: + push_log.status = GitHubPushLog.Status.SKIPPED + push_log.error_message = "Sync disabled by user" + push_log.save(update_fields=["status", "error_message"]) + cache.delete(lock_key) + return + + # Mark as in progress + push_log.status = GitHubPushLog.Status.IN_PROGRESS + push_log.started_at = timezone.now() + push_log.save(update_fields=["status", "started_at"]) + + service = GitHubSyncService(connection) + start_time = time.monotonic() + + try: + result = service.push_solution(push_log) + + # Success + duration_ms = int((time.monotonic() - start_time) * 1000) + push_log.status = GitHubPushLog.Status.SUCCESS + push_log.commit_sha = result.get("sha", "")[:40] + push_log.commit_url = result.get("url", "")[:200] + push_log.pushed_at = timezone.now() + push_log.duration_ms = duration_ms + push_log.error_message = "" + push_log.save(update_fields=["status", "commit_sha", "commit_url", "pushed_at", "duration_ms", "error_message"]) + + # Update connection stats + connection.record_success() + + logger.info( + f"✓ GitHub push: {connection.user.username}/{push_log.challenge_slug} " + f"({duration_ms}ms, SHA: {result.get('sha', '')[:7]})" + ) + + # Queue progress tracker update (debounced) + _schedule_progress_update(connection.id) + + except GitHubTokenExpiredError as e: + push_log.status = GitHubPushLog.Status.FAILED + push_log.error_message = str(e) + push_log.duration_ms = int((time.monotonic() - start_time) * 1000) + push_log.save(update_fields=["status", "error_message", "duration_ms"]) + + # Token is dead — disable immediately (not via circuit breaker) + connection.is_enabled = False + connection.last_error = "Token expired or revoked. Please reconnect GitHub." + connection.save(update_fields=["is_enabled", "last_error"]) + + logger.warning(f"✗ Token expired for {connection.user.username}. Connection disabled.") + + # Notify user via WebSocket if available + _notify_user_sync_failed(connection.user.id, "Your GitHub token has expired. Please reconnect.") + + except GitHubRateLimitError as e: + push_log.retry_count += 1 + push_log.error_message = str(e) + push_log.status = GitHubPushLog.Status.PENDING # Back to pending for retry + push_log.save(update_fields=["retry_count", "error_message", "status"]) + + logger.warning(f"⏳ Rate limited for {connection.user.username}. Will retry.") + + # Calculate retry delay based on reset time + retry_delay = max(RETRY_BACKOFF, e.reset_at - int(time.time())) if e.reset_at else RETRY_BACKOFF + cache.delete(lock_key) + raise self.retry(exc=e, countdown=retry_delay) from e + + except GitHubSyncError as e: + push_log.retry_count += 1 + push_log.error_message = str(e) + push_log.duration_ms = int((time.monotonic() - start_time) * 1000) + + if self.request.retries >= MAX_RETRIES: + push_log.status = GitHubPushLog.Status.FAILED + push_log.save(update_fields=["status", "retry_count", "error_message", "duration_ms"]) + + # Record failure on connection (may auto-disable) + was_disabled = connection.record_failure(str(e)) + if was_disabled: + _notify_user_sync_failed( + connection.user.id, "GitHub sync auto-disabled after repeated failures. Check your settings." + ) + + logger.error(f"✗ Permanent failure for {connection.user.username}/{push_log.challenge_slug}: {e}") + else: + push_log.status = GitHubPushLog.Status.PENDING + push_log.save(update_fields=["status", "retry_count", "error_message", "duration_ms"]) + cache.delete(lock_key) + raise self.retry(exc=e, countdown=RETRY_BACKOFF * (2**self.request.retries)) from e + + except Exception as e: + push_log.status = GitHubPushLog.Status.FAILED + push_log.error_message = f"Unexpected: {str(e)[:300]}" + push_log.duration_ms = int((time.monotonic() - start_time) * 1000) + push_log.save(update_fields=["status", "error_message", "duration_ms"]) + + connection.record_failure(str(e)) + logger.exception(f"✗ Unexpected error for {connection.user.username}: {e}") + + finally: + cache.delete(lock_key) + + +@shared_task +def update_progress_tracker(connection_id: int): + """ + Rebuild PROGRESS.md for a connection. + Debounced — if multiple pushes happen quickly, only one update runs. + """ + try: + connection = GitHubConnection.objects.get(id=connection_id, is_enabled=True) + except GitHubConnection.DoesNotExist: + return + + service = GitHubSyncService(connection) + try: + service.update_progress_tracker() + except GitHubSyncError as e: + logger.warning(f"Failed to update progress tracker for {connection.user.username}: {e}") + + +@shared_task +def retry_failed_pushes(): + """ + Periodic task: retry FAILED pushes that haven't exceeded max retries. + Runs hourly via Celery Beat. + """ + failed_logs = GitHubPushLog.objects.filter( + status=GitHubPushLog.Status.FAILED, + retry_count__lt=MAX_RETRIES, + connection__is_enabled=True, + ).values_list("id", flat=True)[:50] + + count = 0 + for log_id in failed_logs: + push_solution_to_github.delay(log_id) + count += 1 + + if count: + logger.info(f"Re-queued {count} failed GitHub pushes for retry.") + + +# ─── Internal Helpers ──────────────────────────────────────────────────── + + +def _schedule_progress_update(connection_id: int): + """ + Debounce progress tracker updates. + If multiple pushes happen within 60s, only one update task fires. + """ + debounce_key = f"github:progress_debounce:{connection_id}" + if cache.add(debounce_key, "1", timeout=60): + # Schedule with a delay so it runs after the debounce window + update_progress_tracker.apply_async( + args=[connection_id], + countdown=65, # Run 5s after debounce window closes + ) + + +def _notify_user_sync_failed(user_id: int, message: str): + """ + Notify user about sync failure via the existing notification system. + Non-critical — failures here are silently logged. + """ + try: + from notifications.services import NotificationService + + NotificationService.create_notification( + user_id=user_id, + title="GitHub Sync Issue", + message=message, + notification_type="system", + ) + except Exception as e: + logger.debug(f"Could not send GitHub sync notification: {e}") diff --git a/services/core/github/tests/__init__.py b/services/core/github/tests/__init__.py new file mode 100644 index 0000000..8b13789 --- /dev/null +++ b/services/core/github/tests/__init__.py @@ -0,0 +1 @@ + diff --git a/services/core/github/tests/test_monitoring.py b/services/core/github/tests/test_monitoring.py new file mode 100644 index 0000000..6b5ef7d --- /dev/null +++ b/services/core/github/tests/test_monitoring.py @@ -0,0 +1,248 @@ +import hashlib +import hmac +import json +from unittest.mock import patch + +from django.contrib.auth.models import User +from django.test import TestCase +from rest_framework.test import APIClient + +from github.encryption import encrypt_token +from github.metrics import GitHubSyncMetrics +from github.models import GitHubConnection, GitHubPushLog + + +class GitHubMetricsTest(TestCase): + """Test the monitoring/metrics system.""" + + def setUp(self): + self.user = User.objects.create_user(username="metricsuser", password="pass123") + self.connection = GitHubConnection.objects.create( + user=self.user, + github_username="metricsuser", + access_token_encrypted=encrypt_token("ghp_test"), + is_enabled=True, + ) + # Create some push logs + for i in range(5): + GitHubPushLog.objects.create( + connection=self.connection, + challenge_title=f"Challenge {i}", + challenge_slug=f"challenge-{i}", + challenge_order=i, + user_code=f"print({i})", + status=GitHubPushLog.Status.SUCCESS, + duration_ms=1000 + (i * 200), + ) + # Add a couple of failed ones + for i in range(2): + GitHubPushLog.objects.create( + connection=self.connection, + challenge_title=f"Failed {i}", + challenge_slug=f"failed-{i}", + challenge_order=10 + i, + user_code="bad code", + status=GitHubPushLog.Status.FAILED, + error_message="Token expired", + duration_ms=500, + ) + + def test_dashboard_metrics_structure(self): + """Dashboard metrics returns correct structure.""" + metrics = GitHubSyncMetrics.get_dashboard_metrics(time_window_hours=24) + + self.assertIn("pushes", metrics) + self.assertIn("latency", metrics) + self.assertIn("connections", metrics) + self.assertIn("errors", metrics) + self.assertIn("hourly_rate", metrics) + + self.assertEqual(metrics["pushes"]["success"], 5) + self.assertEqual(metrics["pushes"]["failed"], 2) + self.assertEqual(metrics["pushes"]["total"], 7) + self.assertGreater(metrics["pushes"]["success_rate"], 70) + + self.assertEqual(metrics["connections"]["active"], 1) + self.assertIsNotNone(metrics["latency"]["avg_ms"]) + + def test_prometheus_export_format(self): + """Prometheus export produces valid text format.""" + output = GitHubSyncMetrics.prometheus_export() + + self.assertIn("github_sync_connections_active", output) + self.assertIn("github_sync_pushes_total", output) + self.assertIn("# HELP", output) + self.assertIn("# TYPE", output) + # Should be text, not JSON + self.assertNotIn("{", output.split("\n")[0]) + + def test_metrics_endpoint_no_auth(self): + """Metrics endpoint is accessible without auth (for Prometheus).""" + client = APIClient() + resp = client.get("/api/github-sync/metrics/") + self.assertEqual(resp.status_code, 200) + self.assertIn("github_sync", resp.content.decode()) + + def test_dashboard_metrics_requires_staff(self): + """Dashboard metrics requires staff access.""" + client = APIClient() + client.force_authenticate(user=self.user) + + resp = client.get("/api/github-sync/dashboard/metrics/") + self.assertEqual(resp.status_code, 403) + + def test_dashboard_metrics_staff_access(self): + """Staff can access dashboard metrics.""" + staff = User.objects.create_user(username="admin", password="pass", is_staff=True) + client = APIClient() + client.force_authenticate(user=staff) + + resp = client.get("/api/github-sync/dashboard/metrics/?hours=12") + self.assertEqual(resp.status_code, 200) + self.assertEqual(resp.data["time_window_hours"], 12) + + +class GitHubWebhookTest(TestCase): + """Test the GitHub webhook handler.""" + + def setUp(self): + self.user = User.objects.create_user(username="webhookuser", password="pass123") + self.connection = GitHubConnection.objects.create( + user=self.user, + github_username="webhookuser", + access_token_encrypted=encrypt_token("ghp_test"), + is_enabled=True, + ) + self.client = APIClient() + + def _sign_payload(self, payload: dict, secret: str) -> str: + """Generate HMAC-SHA256 signature for webhook payload.""" + body = json.dumps(payload).encode("utf-8") + sig = hmac.new( + key=secret.encode("utf-8"), + msg=body, + digestmod=hashlib.sha256, + ).hexdigest() + return f"sha256={sig}" + + def test_webhook_rejects_without_secret_configured(self): + """Rejects when GITHUB_WEBHOOK_SECRET is not configured.""" + with self.settings(GITHUB_WEBHOOK_SECRET=""): + resp = self.client.post( + "/api/github-sync/webhook/", + data={"action": "revoked"}, + format="json", + HTTP_X_GITHUB_EVENT="github_app_authorization", + ) + self.assertEqual(resp.status_code, 403) + + def test_webhook_rejects_invalid_signature(self): + """Rejects with wrong signature.""" + with self.settings(GITHUB_WEBHOOK_SECRET="real-secret"): + resp = self.client.post( + "/api/github-sync/webhook/", + data={"action": "revoked", "sender": {"login": "webhookuser"}}, + format="json", + HTTP_X_GITHUB_EVENT="github_app_authorization", + HTTP_X_HUB_SIGNATURE_256="sha256=invalid", + ) + self.assertEqual(resp.status_code, 403) + + def test_webhook_processes_revocation(self): + """Valid revocation webhook disables the connection.""" + secret = "test-webhook-secret" + payload = { + "action": "revoked", + "sender": {"login": "webhookuser", "id": 12345}, + } + + # Must sign the exact bytes the server will see + body = json.dumps(payload).encode("utf-8") + signature = ( + "sha256=" + + hmac.new( + key=secret.encode("utf-8"), + msg=body, + digestmod=hashlib.sha256, + ).hexdigest() + ) + + with self.settings(GITHUB_WEBHOOK_SECRET=secret): + resp = self.client.post( + "/api/github-sync/webhook/", + data=body, + content_type="application/json", + HTTP_X_GITHUB_EVENT="github_app_authorization", + HTTP_X_HUB_SIGNATURE_256=signature, + ) + + self.assertEqual(resp.status_code, 200) + + self.connection.refresh_from_db() + self.assertFalse(self.connection.is_enabled) + self.assertIn("revoked", self.connection.last_error.lower()) + + +class GitHubAdminBulkRetryTest(TestCase): + """Test admin bulk-retry action.""" + + def setUp(self): + self.admin = User.objects.create_superuser(username="superadmin", password="admin123") + self.user = User.objects.create_user(username="bulkuser", password="pass123") + self.connection = GitHubConnection.objects.create( + user=self.user, + github_username="bulkuser", + access_token_encrypted=encrypt_token("ghp_test"), + is_enabled=True, + ) + # Create failed logs + self.failed_logs = [] + for i in range(3): + log = GitHubPushLog.objects.create( + connection=self.connection, + challenge_title=f"Challenge {i}", + challenge_slug=f"challenge-{i}", + challenge_order=i, + user_code=f"print({i})", + status=GitHubPushLog.Status.FAILED, + error_message="Temporary error", + ) + self.failed_logs.append(log) + + def test_admin_can_see_retry_action(self): + """Admin interface shows the retry action.""" + from github.admin import GitHubConnectionAdmin + + admin_instance = GitHubConnectionAdmin(GitHubConnection, None) + # Check actions are defined + self.assertIn("retry_all_failed", list(admin_instance.actions)) + + @patch("github.admin.push_solution_to_github.delay") + def test_bulk_retry_requeues_failed(self, mock_delay): + """Bulk retry action re-queues all failed pushes.""" + from django.contrib.admin.sites import AdminSite + from django.contrib.messages.storage.fallback import FallbackStorage + from django.test import RequestFactory + + from github.admin import GitHubConnectionAdmin + + factory = RequestFactory() + request = factory.post("/admin/") + request.user = self.admin + # Add message storage for admin actions + request.session = "session" + messages = FallbackStorage(request) + request._messages = messages + + admin_instance = GitHubConnectionAdmin(GitHubConnection, AdminSite()) + queryset = GitHubConnection.objects.filter(id=self.connection.id) + + admin_instance.retry_all_failed(request, queryset) + + # All 3 failed logs should be retried + self.assertEqual(mock_delay.call_count, 3) + + # Status should be reset to PENDING + for log in self.failed_logs: + log.refresh_from_db() + self.assertEqual(log.status, GitHubPushLog.Status.PENDING) diff --git a/services/core/github/tests/test_signals.py b/services/core/github/tests/test_signals.py new file mode 100644 index 0000000..9767dc4 --- /dev/null +++ b/services/core/github/tests/test_signals.py @@ -0,0 +1,150 @@ +from unittest.mock import patch + +from django.contrib.auth.models import User +from django.core.cache import cache +from django.test import TestCase + +from challenges.models import Challenge, UserProgress +from github.encryption import encrypt_token +from github.models import GitHubConnection, GitHubPushLog + + +class GitHubSyncSignalTest(TestCase): + """Test that the signal correctly queues pushes on challenge completion.""" + + def setUp(self): + self.user = User.objects.create_user(username="testdev", password="pass123") + self.challenge = Challenge.objects.create( + title="Variables 101", + slug="variables-101", + description="Learn about variables in Python.", + initial_code="x = 0", + test_code="assert x == 42", + order=1, + ) + self.connection = GitHubConnection.objects.create( + user=self.user, + github_username="testdev", + access_token_encrypted=encrypt_token("ghp_fake_token_123"), + repo_name="clashcode-solutions", + is_enabled=True, + ) + + def tearDown(self): + cache.clear() + + @patch("github.tasks.push_solution_to_github.delay") + def test_signal_queues_push_on_completion(self, mock_delay): + """Completion with cached code queues a push.""" + cache.set( + f"github_sync:last_code:{self.user.id}:{self.challenge.id}", + "x = 42", + timeout=300, + ) + + UserProgress.objects.create( + user=self.user, + challenge=self.challenge, + status=UserProgress.Status.COMPLETED, + ) + + self.assertTrue(mock_delay.called) + push_log = GitHubPushLog.objects.get(connection=self.connection) + self.assertEqual(push_log.challenge_slug, "variables-101") + self.assertEqual(push_log.user_code, "x = 42") + self.assertEqual(push_log.challenge_description, "Learn about variables in Python.") + self.assertEqual(push_log.status, GitHubPushLog.Status.PENDING) + + @patch("github.tasks.push_solution_to_github.delay") + def test_signal_skips_when_no_connection(self, mock_delay): + """No push if user has no GitHub connection.""" + self.connection.delete() + + cache.set( + f"github_sync:last_code:{self.user.id}:{self.challenge.id}", + "x = 42", + timeout=300, + ) + + UserProgress.objects.create( + user=self.user, + challenge=self.challenge, + status=UserProgress.Status.COMPLETED, + ) + + mock_delay.assert_not_called() + + @patch("github.tasks.push_solution_to_github.delay") + def test_signal_skips_when_disabled(self, mock_delay): + """No push if connection disabled.""" + self.connection.is_enabled = False + self.connection.save() + + cache.set( + f"github_sync:last_code:{self.user.id}:{self.challenge.id}", + "x = 42", + timeout=300, + ) + + UserProgress.objects.create( + user=self.user, + challenge=self.challenge, + status=UserProgress.Status.COMPLETED, + ) + + mock_delay.assert_not_called() + + @patch("github.tasks.push_solution_to_github.delay") + def test_signal_skips_duplicate(self, mock_delay): + """No duplicate push for same challenge.""" + GitHubPushLog.objects.create( + connection=self.connection, + challenge_title=self.challenge.title, + challenge_slug=self.challenge.slug, + challenge_order=self.challenge.order, + user_code="x = 42", + status=GitHubPushLog.Status.SUCCESS, + ) + + cache.set( + f"github_sync:last_code:{self.user.id}:{self.challenge.id}", + "x = 42", + timeout=300, + ) + + UserProgress.objects.create( + user=self.user, + challenge=self.challenge, + status=UserProgress.Status.COMPLETED, + ) + + mock_delay.assert_not_called() + + @patch("github.tasks.push_solution_to_github.delay") + def test_signal_skips_non_completed(self, mock_delay): + """No push for UNLOCKED status.""" + UserProgress.objects.create( + user=self.user, + challenge=self.challenge, + status=UserProgress.Status.UNLOCKED, + ) + + mock_delay.assert_not_called() + + @patch("github.tasks.push_solution_to_github.delay") + def test_language_detection_javascript(self, mock_delay): + """JavaScript code is detected correctly.""" + cache.set( + f"github_sync:last_code:{self.user.id}:{self.challenge.id}", + "const x = 42;\nconsole.log(x);", + timeout=300, + ) + + UserProgress.objects.create( + user=self.user, + challenge=self.challenge, + status=UserProgress.Status.COMPLETED, + ) + + push_log = GitHubPushLog.objects.get(connection=self.connection) + self.assertEqual(push_log.language, "javascript") diff --git a/services/core/github/tests/test_tasks.py b/services/core/github/tests/test_tasks.py new file mode 100644 index 0000000..2d315c4 --- /dev/null +++ b/services/core/github/tests/test_tasks.py @@ -0,0 +1,226 @@ +from unittest.mock import patch + +from django.contrib.auth.models import User +from django.test import TestCase +from rest_framework.test import APIClient + +from github.encryption import decrypt_token, encrypt_token +from github.models import GitHubConnection, GitHubPushLog +from github.services import ( + GitHubSyncError, + GitHubSyncService, + GitHubTokenExpiredError, +) +from github.tasks import push_solution_to_github + + +class EncryptionTest(TestCase): + """Verify token encryption round-trips correctly.""" + + def test_encrypt_decrypt_roundtrip(self): + token = "ghp_abc123XYZ456_reallyLongTokenHere" + encrypted = encrypt_token(token) + decrypted = decrypt_token(encrypted) + self.assertEqual(decrypted, token) + + def test_different_tokens_produce_different_ciphertext(self): + enc1 = encrypt_token("token_one") + enc2 = encrypt_token("token_two") + self.assertNotEqual(enc1, enc2) + + def test_same_token_different_nonce(self): + """Each encryption has a unique nonce, so ciphertext differs.""" + enc1 = encrypt_token("same_token") + enc2 = encrypt_token("same_token") + self.assertNotEqual(enc1, enc2) + + def test_invalid_data_raises(self): + with self.assertRaises(Exception): + decrypt_token(b"too_short") + + +class GitHubPushTaskTest(TestCase): + """Test the Celery task.""" + + def setUp(self): + self.user = User.objects.create_user(username="taskuser", password="pass123") + self.connection = GitHubConnection.objects.create( + user=self.user, + github_username="taskuser", + access_token_encrypted=encrypt_token("ghp_test_token"), + is_enabled=True, + ) + self.push_log = GitHubPushLog.objects.create( + connection=self.connection, + challenge_title="Loops", + challenge_slug="loops", + challenge_order=2, + challenge_description="Write a loop.", + user_code="for i in range(10): print(i)", + ) + + @patch.object(GitHubSyncService, "push_solution") + def test_successful_push(self, mock_push): + """Success updates log, connection stats, and records duration.""" + mock_push.return_value = { + "sha": "abc1234567890abcdef1234567890abcdef123456", + "url": "https://github.com/taskuser/clashcode-solutions/commit/abc1234", + } + + push_solution_to_github(self.push_log.id) + + self.push_log.refresh_from_db() + self.connection.refresh_from_db() + + self.assertEqual(self.push_log.status, GitHubPushLog.Status.SUCCESS) + self.assertIsNotNone(self.push_log.pushed_at) + self.assertIsNotNone(self.push_log.duration_ms) + self.assertGreaterEqual(self.push_log.duration_ms, 0) + self.assertEqual(self.connection.consecutive_failures, 0) + + @patch.object(GitHubSyncService, "push_solution") + def test_token_expired_disables_connection(self, mock_push): + """Token expiry disables the connection.""" + mock_push.side_effect = GitHubTokenExpiredError("Token revoked") + + push_solution_to_github(self.push_log.id) + + self.push_log.refresh_from_db() + self.connection.refresh_from_db() + + self.assertEqual(self.push_log.status, GitHubPushLog.Status.FAILED) + self.assertFalse(self.connection.is_enabled) + + @patch.object(GitHubSyncService, "push_solution") + def test_skips_when_disabled(self, mock_push): + """Skips if connection disabled.""" + self.connection.is_enabled = False + self.connection.save() + + push_solution_to_github(self.push_log.id) + + self.push_log.refresh_from_db() + self.assertEqual(self.push_log.status, GitHubPushLog.Status.SKIPPED) + mock_push.assert_not_called() + + @patch.object(GitHubSyncService, "push_solution") + def test_circuit_breaker_auto_disables(self, mock_push): + """After N consecutive failures, connection is auto-disabled.""" + mock_push.side_effect = GitHubSyncError("Server error") + + # Directly test the circuit breaker on the connection model + for i in range(GitHubConnection.AUTO_DISABLE_THRESHOLD): + was_disabled = self.connection.record_failure(f"Error {i}") + if i < GitHubConnection.AUTO_DISABLE_THRESHOLD - 1: + self.assertFalse(was_disabled) + + self.connection.refresh_from_db() + self.assertFalse(self.connection.is_enabled) + self.assertIn("Auto-disabled", self.connection.last_error) + + +class GitHubConnectionViewTest(TestCase): + """Test REST API endpoints.""" + + def setUp(self): + self.user = User.objects.create_user(username="apiuser", password="pass123") + self.client = APIClient() + self.client.force_authenticate(user=self.user) + + def test_get_connection_not_connected(self): + """Returns connected=False for users without connection.""" + resp = self.client.get("/api/github-sync/connection/") + self.assertEqual(resp.status_code, 200) + self.assertFalse(resp.data["connected"]) + + def test_get_connection_connected(self): + """Returns connection data for connected users.""" + GitHubConnection.objects.create( + user=self.user, + github_username="apiuser", + access_token_encrypted=encrypt_token("ghp_test"), + is_enabled=True, + ) + + resp = self.client.get("/api/github-sync/connection/") + self.assertEqual(resp.status_code, 200) + self.assertTrue(resp.data["connected"]) + self.assertEqual(resp.data["connection"]["github_username"], "apiuser") + # Token should NEVER appear in response + self.assertNotIn("access_token", resp.data["connection"]) + self.assertNotIn("access_token_encrypted", resp.data["connection"]) + + def test_patch_connection_updates_settings(self): + """PATCH updates connection settings.""" + GitHubConnection.objects.create( + user=self.user, + github_username="apiuser", + access_token_encrypted=encrypt_token("ghp_test"), + is_enabled=True, + ) + + resp = self.client.patch( + "/api/github-sync/connection/", + {"is_enabled": False, "repo_visibility": "private"}, + format="json", + ) + self.assertEqual(resp.status_code, 200) + self.assertFalse(resp.data["connection"]["is_enabled"]) + self.assertEqual(resp.data["connection"]["repo_visibility"], "private") + + def test_delete_connection(self): + """DELETE removes the connection.""" + GitHubConnection.objects.create( + user=self.user, + github_username="apiuser", + access_token_encrypted=encrypt_token("ghp_test"), + ) + + resp = self.client.delete("/api/github-sync/connection/") + self.assertEqual(resp.status_code, 200) + self.assertFalse(GitHubConnection.objects.filter(user=self.user).exists()) + + def test_history_pagination(self): + """History endpoint supports pagination.""" + conn = GitHubConnection.objects.create( + user=self.user, + github_username="apiuser", + access_token_encrypted=encrypt_token("ghp_test"), + ) + for i in range(5): + GitHubPushLog.objects.create( + connection=conn, + challenge_title=f"Challenge {i}", + challenge_slug=f"challenge-{i}", + challenge_order=i, + user_code=f"print({i})", + status=GitHubPushLog.Status.SUCCESS, + ) + + resp = self.client.get("/api/github-sync/history/?page=1&page_size=2") + self.assertEqual(resp.status_code, 200) + self.assertEqual(len(resp.data["results"]), 2) + self.assertEqual(resp.data["count"], 5) + self.assertTrue(resp.data["has_next"]) + + def test_stats_endpoint(self): + """Stats endpoint returns aggregated data.""" + conn = GitHubConnection.objects.create( + user=self.user, + github_username="apiuser", + access_token_encrypted=encrypt_token("ghp_test"), + ) + GitHubPushLog.objects.create( + connection=conn, + challenge_title="Test", + challenge_slug="test", + user_code="x=1", + status=GitHubPushLog.Status.SUCCESS, + duration_ms=1500, + ) + + resp = self.client.get("/api/github-sync/stats/") + self.assertEqual(resp.status_code, 200) + self.assertEqual(resp.data["total_pushes"], 1) + self.assertEqual(resp.data["success_count"], 1) + self.assertEqual(resp.data["avg_duration_ms"], 1500) diff --git a/services/core/github/urls.py b/services/core/github/urls.py new file mode 100644 index 0000000..bb9a9e7 --- /dev/null +++ b/services/core/github/urls.py @@ -0,0 +1,31 @@ +from django.urls import path + +from .views import ( + GitHubConnectionView, + GitHubConnectView, + GitHubDashboardMetricsView, + GitHubMetricsView, + GitHubOAuthURLView, + GitHubPushHistoryView, + GitHubRetryPushView, + GitHubSyncStatsView, + GitHubVerifyView, +) +from .webhooks import GitHubWebhookView + +urlpatterns = [ + # User-facing OAuth & connection management + path("connect/url/", GitHubOAuthURLView.as_view(), name="github-sync-oauth-url"), + path("connect/", GitHubConnectView.as_view(), name="github-sync-connect"), + path("connection/", GitHubConnectionView.as_view(), name="github-sync-connection"), + # Push history & stats + path("history/", GitHubPushHistoryView.as_view(), name="github-sync-history"), + path("stats/", GitHubSyncStatsView.as_view(), name="github-sync-stats"), + path("retry//", GitHubRetryPushView.as_view(), name="github-sync-retry"), + path("verify/", GitHubVerifyView.as_view(), name="github-sync-verify"), + # Monitoring & observability + path("metrics/", GitHubMetricsView.as_view(), name="github-sync-metrics"), + path("dashboard/metrics/", GitHubDashboardMetricsView.as_view(), name="github-sync-dashboard-metrics"), + # GitHub webhook (external, no auth — verified via HMAC signature) + path("webhook/", GitHubWebhookView.as_view(), name="github-sync-webhook"), +] diff --git a/services/core/github/views/__init__.py b/services/core/github/views/__init__.py new file mode 100644 index 0000000..a0dfe7b --- /dev/null +++ b/services/core/github/views/__init__.py @@ -0,0 +1,31 @@ +""" +GitHub Sync Views package. + +Split for maintainability: +- oauth.py → OAuth URL + code exchange (connect flow) +- connection.py → Connection CRUD (get/update/delete) +- push_views.py → History, stats, retry, verify +- monitoring.py → Prometheus metrics + admin dashboard +""" + +from .connection import GitHubConnectionView +from .monitoring import GitHubDashboardMetricsView, GitHubMetricsView +from .oauth import GitHubConnectView, GitHubOAuthURLView +from .push_views import ( + GitHubPushHistoryView, + GitHubRetryPushView, + GitHubSyncStatsView, + GitHubVerifyView, +) + +__all__ = [ + "GitHubOAuthURLView", + "GitHubConnectView", + "GitHubConnectionView", + "GitHubPushHistoryView", + "GitHubSyncStatsView", + "GitHubRetryPushView", + "GitHubVerifyView", + "GitHubMetricsView", + "GitHubDashboardMetricsView", +] diff --git a/services/core/github/views/connection.py b/services/core/github/views/connection.py new file mode 100644 index 0000000..55a0342 --- /dev/null +++ b/services/core/github/views/connection.py @@ -0,0 +1,88 @@ +""" +Connection management views — CRUD for the GitHub connection. +""" + +import logging + +from rest_framework import status +from rest_framework.permissions import IsAuthenticated +from rest_framework.response import Response +from rest_framework.views import APIView + +from authentication.authentication import JWTAuthentication + +from ..models import GitHubConnection +from ..serializers import GitHubConnectionSerializer, GitHubConnectionUpdateSerializer + +logger = logging.getLogger(__name__) + + +class GitHubConnectionView(APIView): + """ + GET /api/github-sync/connection/ — Status + PATCH /api/github-sync/connection/ — Update settings + DELETE /api/github-sync/connection/ — Disconnect + """ + + authentication_classes = [JWTAuthentication] + permission_classes = [IsAuthenticated] + + def get(self, request): + try: + connection = GitHubConnection.objects.get(user=request.user) + except GitHubConnection.DoesNotExist: + return Response({"connected": False, "connection": None}) + + return Response( + { + "connected": True, + "connection": GitHubConnectionSerializer(connection).data, + } + ) + + def patch(self, request): + try: + connection = GitHubConnection.objects.get(user=request.user) + except GitHubConnection.DoesNotExist: + return Response( + {"error": "No GitHub connection found."}, + status=status.HTTP_404_NOT_FOUND, + ) + + serializer = GitHubConnectionUpdateSerializer(data=request.data) + serializer.is_valid(raise_exception=True) + + data = serializer.validated_data + updated_fields = [] + + for field in ("is_enabled", "repo_name", "repo_visibility", "include_problem_description"): + if field in data: + setattr(connection, field, data[field]) + updated_fields.append(field) + + if updated_fields: + if "is_enabled" in data and data["is_enabled"]: + connection.consecutive_failures = 0 + connection.last_error = "" + updated_fields.extend(["consecutive_failures", "last_error"]) + + connection.save(update_fields=updated_fields) + + return Response( + { + "message": "Connection updated.", + "connection": GitHubConnectionSerializer(connection).data, + } + ) + + def delete(self, request): + deleted, _ = GitHubConnection.objects.filter(user=request.user).delete() + + if not deleted: + return Response( + {"error": "No GitHub connection found."}, + status=status.HTTP_404_NOT_FOUND, + ) + + logger.info(f"GitHub disconnected: {request.user.username}") + return Response({"message": "GitHub disconnected."}) diff --git a/services/core/github/views/monitoring.py b/services/core/github/views/monitoring.py new file mode 100644 index 0000000..7d378f4 --- /dev/null +++ b/services/core/github/views/monitoring.py @@ -0,0 +1,52 @@ +""" +Monitoring views — Prometheus metrics export and admin dashboard. +""" + +from rest_framework import status +from rest_framework.permissions import IsAuthenticated +from rest_framework.response import Response +from rest_framework.views import APIView + +from authentication.authentication import JWTAuthentication + + +class GitHubMetricsView(APIView): + """ + GET /api/github-sync/metrics/ + + Prometheus-compatible metrics endpoint. + No auth (scraped by ServiceMonitor inside cluster). + """ + + authentication_classes = [] + permission_classes = [] + + def get(self, request): + from django.http import HttpResponse + + from ..metrics import GitHubSyncMetrics + + content = GitHubSyncMetrics.prometheus_export() + return HttpResponse(content, content_type="text/plain; version=0.0.4; charset=utf-8") + + +class GitHubDashboardMetricsView(APIView): + """ + GET /api/github-sync/dashboard/metrics/ + + Admin-only aggregated metrics for Grafana/admin dashboard. + """ + + authentication_classes = [JWTAuthentication] + permission_classes = [IsAuthenticated] + + def get(self, request): + if not request.user.is_staff: + return Response({"error": "Admin access required."}, status=status.HTTP_403_FORBIDDEN) + + hours = min(int(request.query_params.get("hours", 24)), 168) + + from ..metrics import GitHubSyncMetrics + + metrics = GitHubSyncMetrics.get_dashboard_metrics(time_window_hours=hours) + return Response(metrics) diff --git a/services/core/github/views/oauth.py b/services/core/github/views/oauth.py new file mode 100644 index 0000000..d7d3c28 --- /dev/null +++ b/services/core/github/views/oauth.py @@ -0,0 +1,125 @@ +""" +OAuth views — connect URL and code exchange. +""" + +import logging +import secrets + +import requests as http_requests +from django.core.cache import cache +from rest_framework import status +from rest_framework.permissions import IsAuthenticated +from rest_framework.response import Response +from rest_framework.views import APIView + +from authentication.authentication import JWTAuthentication + +from ..encryption import encrypt_token +from ..models import GitHubConnection +from ..serializers import ( + GitHubConnectionSerializer, + GitHubConnectSerializer, + GitHubOAuthURLSerializer, +) +from ..services import exchange_github_code, get_github_oauth_url + +logger = logging.getLogger(__name__) + + +class GitHubOAuthURLView(APIView): + """GET /api/github-sync/connect/url/ — Returns GitHub OAuth URL.""" + + authentication_classes = [JWTAuthentication] + permission_classes = [IsAuthenticated] + + def get(self, request): + state = secrets.token_urlsafe(32) + cache.set(f"github:oauth_state:{request.user.id}", state, timeout=600) + + url = get_github_oauth_url(state=state) + return Response(GitHubOAuthURLSerializer({"url": url}).data) + + +class GitHubConnectView(APIView): + """POST /api/github-sync/connect/ — Exchange code, create connection.""" + + authentication_classes = [JWTAuthentication] + permission_classes = [IsAuthenticated] + + def post(self, request): + serializer = GitHubConnectSerializer(data=request.data) + serializer.is_valid(raise_exception=True) + + code = serializer.validated_data["code"] + user = request.user + + # Verify OAuth state + expected_state = cache.get(f"github:oauth_state:{user.id}") + provided_state = request.data.get("state", "") + if expected_state and provided_state and expected_state != provided_state: + return Response( + {"error": "Invalid OAuth state. Please try again."}, + status=status.HTTP_400_BAD_REQUEST, + ) + cache.delete(f"github:oauth_state:{user.id}") + + # Exchange code for token + token_data = exchange_github_code(code) + + if "error" in token_data: + logger.warning(f"GitHub OAuth exchange failed for {user.username}: {token_data}") + return Response( + {"error": "Failed to connect GitHub. Please try again."}, + status=status.HTTP_400_BAD_REQUEST, + ) + + access_token = token_data.get("access_token") + if not access_token: + return Response( + {"error": "No access token received from GitHub."}, + status=status.HTTP_400_BAD_REQUEST, + ) + + # Verify token and get username + user_resp = http_requests.get( + "https://api.github.com/user", + headers={ + "Authorization": f"Bearer {access_token}", + "Accept": "application/vnd.github+json", + }, + timeout=10, + ) + + if user_resp.status_code != 200: + return Response( + {"error": "Failed to verify GitHub token."}, + status=status.HTTP_400_BAD_REQUEST, + ) + + github_user = user_resp.json() + github_username = github_user.get("login", "") + + # Encrypt and store + encrypted_token = encrypt_token(access_token) + + connection, created = GitHubConnection.objects.update_or_create( + user=user, + defaults={ + "github_username": github_username, + "access_token_encrypted": encrypted_token, + "is_enabled": True, + "last_error": "", + "consecutive_failures": 0, + }, + ) + + action = "connected" if created else "reconnected" + logger.info(f"GitHub {action}: {user.username} → {github_username}") + + return Response( + { + "message": f"GitHub {action} successfully!", + "connection": GitHubConnectionSerializer(connection).data, + }, + status=status.HTTP_201_CREATED if created else status.HTTP_200_OK, + ) diff --git a/services/core/github/views/push_views.py b/services/core/github/views/push_views.py new file mode 100644 index 0000000..7a5309d --- /dev/null +++ b/services/core/github/views/push_views.py @@ -0,0 +1,142 @@ +""" +Push history, stats, retry, and verify views. +""" + +from django.db.models import Avg +from rest_framework import status +from rest_framework.permissions import IsAuthenticated +from rest_framework.response import Response +from rest_framework.views import APIView + +from authentication.authentication import JWTAuthentication + +from ..models import GitHubConnection, GitHubPushLog +from ..serializers import GitHubPushLogSerializer, GitHubSyncStatsSerializer +from ..services import GitHubSyncService, GitHubTokenExpiredError +from ..tasks import push_solution_to_github + + +class GitHubPushHistoryView(APIView): + """GET /api/github-sync/history/ — Paginated push history.""" + + authentication_classes = [JWTAuthentication] + permission_classes = [IsAuthenticated] + + def get(self, request): + try: + connection = GitHubConnection.objects.get(user=request.user) + except GitHubConnection.DoesNotExist: + return Response({"results": [], "count": 0}) + + page = int(request.query_params.get("page", 1)) + page_size = min(int(request.query_params.get("page_size", 20)), 50) + offset = (page - 1) * page_size + + logs = GitHubPushLog.objects.filter(connection=connection).order_by("-created_at")[offset : offset + page_size] + total = GitHubPushLog.objects.filter(connection=connection).count() + + return Response( + { + "results": GitHubPushLogSerializer(logs, many=True).data, + "count": total, + "page": page, + "page_size": page_size, + "has_next": offset + page_size < total, + } + ) + + +class GitHubSyncStatsView(APIView): + """GET /api/github-sync/stats/ — Aggregated push statistics.""" + + authentication_classes = [JWTAuthentication] + permission_classes = [IsAuthenticated] + + def get(self, request): + try: + connection = GitHubConnection.objects.get(user=request.user) + except GitHubConnection.DoesNotExist: + return Response({"error": "No connection."}, status=status.HTTP_404_NOT_FOUND) + + logs = GitHubPushLog.objects.filter(connection=connection) + + stats = { + "total_pushes": logs.count(), + "success_count": logs.filter(status=GitHubPushLog.Status.SUCCESS).count(), + "failed_count": logs.filter(status=GitHubPushLog.Status.FAILED).count(), + "pending_count": logs.filter( + status__in=[GitHubPushLog.Status.PENDING, GitHubPushLog.Status.IN_PROGRESS] + ).count(), + "avg_duration_ms": logs.filter(status=GitHubPushLog.Status.SUCCESS, duration_ms__isnull=False).aggregate( + avg=Avg("duration_ms") + )["avg"], + "last_push_at": logs.filter(status=GitHubPushLog.Status.SUCCESS) + .values_list("pushed_at", flat=True) + .first(), + } + + return Response(GitHubSyncStatsSerializer(stats).data) + + +class GitHubRetryPushView(APIView): + """POST /api/github-sync/retry// — Manually retry a failed push.""" + + authentication_classes = [JWTAuthentication] + permission_classes = [IsAuthenticated] + + def post(self, request, log_id): + try: + connection = GitHubConnection.objects.get(user=request.user) + push_log = GitHubPushLog.objects.get(id=log_id, connection=connection) + except (GitHubConnection.DoesNotExist, GitHubPushLog.DoesNotExist): + return Response({"error": "Not found."}, status=status.HTTP_404_NOT_FOUND) + + if push_log.status == GitHubPushLog.Status.SUCCESS: + return Response({"error": "Already succeeded."}, status=status.HTTP_400_BAD_REQUEST) + + if not connection.is_enabled: + return Response( + {"error": "Sync is disabled. Enable it first."}, + status=status.HTTP_400_BAD_REQUEST, + ) + + push_log.status = GitHubPushLog.Status.PENDING + push_log.error_message = "" + push_log.save(update_fields=["status", "error_message"]) + + push_solution_to_github.delay(push_log.id) + + return Response({"message": "Retry queued.", "status": "PENDING"}) + + +class GitHubVerifyView(APIView): + """POST /api/github-sync/verify/ — Verify token is still valid.""" + + authentication_classes = [JWTAuthentication] + permission_classes = [IsAuthenticated] + + def post(self, request): + try: + connection = GitHubConnection.objects.get(user=request.user) + except GitHubConnection.DoesNotExist: + return Response({"error": "No connection."}, status=status.HTTP_404_NOT_FOUND) + + service = GitHubSyncService(connection) + try: + user_info = service.verify_token() + return Response( + { + "valid": True, + "github_username": user_info.get("login"), + "avatar_url": user_info.get("avatar_url"), + "rate_limit": service.check_rate_limit(), + } + ) + except GitHubTokenExpiredError: + connection.is_enabled = False + connection.last_error = "Token expired. Please reconnect." + connection.save(update_fields=["is_enabled", "last_error"]) + return Response( + {"valid": False, "error": "Token expired. Please reconnect."}, + status=status.HTTP_401_UNAUTHORIZED, + ) diff --git a/services/core/github/webhooks.py b/services/core/github/webhooks.py new file mode 100644 index 0000000..84e9d94 --- /dev/null +++ b/services/core/github/webhooks.py @@ -0,0 +1,162 @@ +""" +GitHub Webhook handler for token/app revocation events. + +GitHub sends webhooks when: +1. A user revokes the OAuth app authorization +2. A user uninstalls the GitHub App +3. The OAuth app is suspended + +This lets us proactively disable connections instead of waiting +for a push to fail with 401. + +Setup: +1. In your GitHub OAuth App settings → Webhooks +2. Set URL: https://your-domain.com/api/github-sync/webhook/ +3. Set secret: Use GITHUB_WEBHOOK_SECRET env var +4. Select events: "GitHub App authorization" or "Authorization" +""" + +import hashlib +import hmac +import logging + +from django.conf import settings +from rest_framework import status +from rest_framework.permissions import AllowAny +from rest_framework.response import Response +from rest_framework.views import APIView + +from .models import GitHubConnection + +logger = logging.getLogger(__name__) + + +class GitHubWebhookView(APIView): + """ + POST /api/github-sync/webhook/ + + Receives GitHub webhook events for authorization revocation. + Secured via HMAC-SHA256 signature verification. + """ + + authentication_classes = [] # No JWT — GitHub sends this + permission_classes = [AllowAny] + + def post(self, request): + # ─── Verify webhook signature ──────────────────────────────── + secret = getattr(settings, "GITHUB_WEBHOOK_SECRET", "") + if not secret: + logger.warning("GITHUB_WEBHOOK_SECRET not configured. Rejecting webhook.") + return Response(status=status.HTTP_403_FORBIDDEN) + + signature_header = request.META.get("HTTP_X_HUB_SIGNATURE_256", "") + if not self._verify_signature(request.body, secret, signature_header): + logger.warning("Invalid webhook signature. Rejecting.") + return Response(status=status.HTTP_403_FORBIDDEN) + + # ─── Process event ─────────────────────────────────────────── + event_type = request.META.get("HTTP_X_GITHUB_EVENT", "") + payload = request.data + + if event_type == "github_app_authorization": + return self._handle_authorization_event(payload) + + if event_type == "installation": + return self._handle_installation_event(payload) + + # Unknown event — acknowledge but ignore + logger.debug(f"Ignoring GitHub webhook event: {event_type}") + return Response({"status": "ignored"}) + + def _handle_authorization_event(self, payload: dict) -> Response: + """ + Handle OAuth app authorization revocation. + Payload contains: action, sender (the user who revoked). + """ + action = payload.get("action", "") + + if action != "revoked": + return Response({"status": "ignored", "action": action}) + + sender = payload.get("sender", {}) + github_username = sender.get("login", "") + + if not github_username: + logger.warning("Revocation webhook missing sender info.") + return Response({"status": "error"}, status=status.HTTP_400_BAD_REQUEST) + + # Find and disable the connection + try: + connection = GitHubConnection.objects.get( + github_username=github_username, + is_enabled=True, + ) + connection.is_enabled = False + connection.last_error = "OAuth authorization revoked by user on GitHub." + connection.save(update_fields=["is_enabled", "last_error"]) + + logger.info( + f"GitHub authorization revoked for {connection.user.username} " + f"(GitHub: {github_username}). Connection disabled proactively." + ) + + # Notify user + from .tasks import _notify_user_sync_failed + + _notify_user_sync_failed( + connection.user.id, + "Your GitHub authorization was revoked. Please reconnect to resume auto-sync.", + ) + + except GitHubConnection.DoesNotExist: + logger.debug(f"Revocation webhook for unknown user: {github_username}") + + return Response({"status": "processed"}) + + def _handle_installation_event(self, payload: dict) -> Response: + """Handle GitHub App installation removal (if using GitHub Apps).""" + action = payload.get("action", "") + + if action not in ("deleted", "suspend"): + return Response({"status": "ignored"}) + + # Disable all connections for the affected accounts + installation = payload.get("installation", {}) + account = installation.get("account", {}) + github_username = account.get("login", "") + + if github_username: + updated = GitHubConnection.objects.filter( + github_username=github_username, + is_enabled=True, + ).update( + is_enabled=False, + last_error=f"GitHub App {action}d. Please reinstall to resume sync.", + ) + + if updated: + logger.info(f"Disabled {updated} connection(s) for {github_username} (app {action}d).") + + return Response({"status": "processed"}) + + @staticmethod + def _verify_signature(payload_body: bytes, secret: str, signature_header: str) -> bool: + """ + Verify the HMAC-SHA256 signature from GitHub. + Constant-time comparison to prevent timing attacks. + """ + if not signature_header: + return False + + if not signature_header.startswith("sha256="): + return False + + expected_signature = signature_header[7:] # Strip "sha256=" prefix + + computed = hmac.new( + key=secret.encode("utf-8"), + msg=payload_body, + digestmod=hashlib.sha256, + ).hexdigest() + + return hmac.compare_digest(computed, expected_signature) diff --git a/services/core/learning/tasks/execution.py b/services/core/learning/tasks/execution.py index b2913b6..3a1ca0c 100644 --- a/services/core/learning/tasks/execution.py +++ b/services/core/learning/tasks/execution.py @@ -102,6 +102,15 @@ def submit_code_task(self, user_id, challenge_id, user_code): _publish_task_result(user_id, self.request.id, "submit", result) return result + # Cache user code for GitHub sync signal to pick up + from django.core.cache import cache + + cache.set( + f"github_sync:last_code:{user_id}:{challenge_id}", + user_code, + timeout=300, # 5 min TTL — plenty for the signal to fire + ) + # Success! Process submission submission_result = ChallengeService.process_submission(user, challenge, passed) result = {"ok": True, "payload": submission_result} diff --git a/services/core/project/settings.py b/services/core/project/settings.py index 7199666..996e2ea 100644 --- a/services/core/project/settings.py +++ b/services/core/project/settings.py @@ -94,6 +94,7 @@ def _parse_bool(value: str | None, default: bool) -> bool: "posts", "notifications", "achievements", + "github", ] if USE_CLOUDINARY: @@ -439,6 +440,11 @@ def _parse_bool(value: str | None, default: bool) -> bool: GOOGLE_CLIENT_SECRET = os.getenv("GOOGLE_CLIENT_SECRET") GOOGLE_REDIRECT_URI = f"{FRONTEND_URL}/auth/google/callback" +# GitHub Sync (separate OAuth App — repo push scope) +GITHUB_SYNC_CLIENT_ID = os.getenv("GITHUB_SYNC_CLIENT_ID", GITHUB_CLIENT_ID) +GITHUB_SYNC_CLIENT_SECRET = os.getenv("GITHUB_SYNC_CLIENT_SECRET", GITHUB_CLIENT_SECRET) +GITHUB_WEBHOOK_SECRET = os.getenv("GITHUB_WEBHOOK_SECRET", "") + # Email Configuration # Use SMTP by default in production, fallback to console in dev if no host is configured _EMAIL_HOST = os.getenv("EMAIL_HOST") @@ -493,6 +499,9 @@ def _parse_bool(value: str | None, default: bool) -> bool: "learning.tasks.update_leaderboard_cache": {"queue": "background"}, "learning.tasks.prewarm_ai_rag_task": {"queue": "background"}, "project.tasks.cleanup_old_task_results": {"queue": "background"}, + "github.tasks.push_solution_to_github": {"queue": "background"}, + "github.tasks.retry_failed_pushes": {"queue": "background"}, + "github.tasks.update_progress_tracker": {"queue": "background"}, } CELERY_BEAT_SCHEDULE = { @@ -504,6 +513,10 @@ def _parse_bool(value: str | None, default: bool) -> bool: "task": "learning.tasks.prewarm_ai_rag_task", "schedule": 60 * 60 * 6, # Every 6 hours }, + "retry-failed-github-pushes-hourly": { + "task": "github.tasks.retry_failed_pushes", + "schedule": 60 * 60, # Every hour + }, } # --- Test Overrides --- diff --git a/services/core/project/urls.py b/services/core/project/urls.py index 51e4271..1c7504e 100644 --- a/services/core/project/urls.py +++ b/services/core/project/urls.py @@ -35,6 +35,7 @@ path("api/notifications/", include("notifications.urls")), path("api/achievements/", include("achievements.urls")), path("api/xpoint/", include("xpoint.urls")), + path("api/github-sync/", include("github.urls")), # Celery task result endpoints (admin only) path("api/tasks//status/", TaskStatusView.as_view(), name="task-status"), path("api/tasks/results/", TaskResultsListView.as_view(), name="task-results"),