From 0052398fc4dfe17f987df7ff68783c30872ed12c Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Geid=C5=8D?= <60598000+geido@users.noreply.github.com> Date: Sat, 18 Oct 2025 19:12:14 +0300 Subject: [PATCH] Add configurable repository groups with aggregated reports --- backend/api/groups.py | 95 +++++ backend/groups/ai.yaml | 7 + backend/main.py | 3 +- backend/services/group_report.py | 112 ++++++ backend/utils/group_config.py | 54 +++ backend/utils/url.py | 18 + frontend/src/App.tsx | 22 +- frontend/src/hooks/useGroupTLDRData.ts | 139 ++++++++ frontend/src/types/api.ts | 20 +- frontend/src/types/github.ts | 35 ++ frontend/src/utils/groupStorage.ts | 119 +++++++ frontend/src/utils/repoUtils.ts | 35 ++ frontend/src/utils/slugify.ts | 9 + frontend/src/views/DashboardView/index.tsx | 384 ++++++++++++++++++++- frontend/src/views/GroupTLDRView/index.tsx | 291 ++++++++++++++++ 15 files changed, 1329 insertions(+), 14 deletions(-) create mode 100644 backend/api/groups.py create mode 100644 backend/groups/ai.yaml create mode 100644 backend/services/group_report.py create mode 100644 backend/utils/group_config.py create mode 100644 frontend/src/hooks/useGroupTLDRData.ts create mode 100644 frontend/src/utils/groupStorage.ts create mode 100644 frontend/src/utils/repoUtils.ts create mode 100644 frontend/src/utils/slugify.ts create mode 100644 frontend/src/views/GroupTLDRView/index.tsx diff --git a/backend/api/groups.py b/backend/api/groups.py new file mode 100644 index 0000000..da91a1b --- /dev/null +++ b/backend/api/groups.py @@ -0,0 +1,95 @@ +from typing import Literal, Optional + +from fastapi import APIRouter, Depends, HTTPException +from pydantic import BaseModel, Field + +from middleware.auth import AuthenticatedRequest, get_current_user +from models.github import GitHubItem +from services.group_report import generate_group_report +from utils.group_config import GroupDefinition, get_group_definition, get_group_definitions + +router = APIRouter() + + +class GroupSummary(BaseModel): + id: str + name: str + description: Optional[str] = None + repos: list[str] + + +class GroupListResponse(BaseModel): + groups: list[GroupSummary] + + +class GroupRepoReport(BaseModel): + full_name: str + html_url: str + prs: list[GitHubItem] = Field(default_factory=list) + issues: list[GitHubItem] = Field(default_factory=list) + tldr: Optional[str] = None + + +class GroupReportRequest(BaseModel): + timeframe: Literal["last_day", "last_week", "last_month", "last_year"] + group_id: Optional[str] = None + name: Optional[str] = None + repos: Optional[list[str]] = None + + +class GroupReportResponse(BaseModel): + group_id: Optional[str] = None + name: str + timeframe: str + tldr: Optional[str] = None + repos: list[GroupRepoReport] + + +@router.get("/groups", response_model=GroupListResponse) +async def list_groups() -> GroupListResponse: + groups = [ + GroupSummary.model_validate(group.model_dump()) + for group in get_group_definitions().values() + ] + return GroupListResponse(groups=groups) + + +@router.post("/groups/report", response_model=GroupReportResponse) +async def generate_group_digest( + payload: GroupReportRequest, + auth: AuthenticatedRequest = Depends(get_current_user), +) -> GroupReportResponse: + group_definition: Optional[GroupDefinition] = None + + if payload.group_id: + group_definition = get_group_definition(payload.group_id) + if not group_definition: + raise HTTPException(status_code=404, detail="Group not found") + + group_name = payload.name or ( + group_definition.name if group_definition else None + ) + repos = payload.repos or ( + group_definition.repos if group_definition else None + ) + + if not group_name: + raise HTTPException(status_code=400, detail="Group name is required") + + if not repos: + raise HTTPException(status_code=400, detail="At least one repository is required") + + try: + repo_reports, group_tldr = await generate_group_report( + auth.github, repos, payload.timeframe + ) + except Exception as exc: # pragma: no cover - handled via HTTP response + raise HTTPException(status_code=400, detail=str(exc)) from exc + + return GroupReportResponse( + group_id=group_definition.id if group_definition else payload.group_id, + name=group_name, + timeframe=payload.timeframe, + tldr=group_tldr, + repos=[GroupRepoReport(**report) for report in repo_reports], + ) diff --git a/backend/groups/ai.yaml b/backend/groups/ai.yaml new file mode 100644 index 0000000..26bf805 --- /dev/null +++ b/backend/groups/ai.yaml @@ -0,0 +1,7 @@ +id: ai +name: AI Highlights +description: Key open-source projects in the AI and machine learning ecosystem. +repos: + - huggingface/transformers + - pytorch/pytorch + - openai/openai-python diff --git a/backend/main.py b/backend/main.py index 27a6360..8475749 100644 --- a/backend/main.py +++ b/backend/main.py @@ -2,7 +2,7 @@ from fastapi.middleware.cors import CORSMiddleware import os -from api import auth, deepdive, diff, issues, people, prs, repos, tldr +from api import auth, deepdive, diff, groups, issues, people, prs, repos, tldr app = FastAPI(title="OSS TL;DR Backend") @@ -33,3 +33,4 @@ def health_check() -> dict[str, str]: app.include_router(diff.router, prefix="/api/v1", tags=["diff"]) app.include_router(deepdive.router, prefix="/api/v1", tags=["deepdive"]) app.include_router(repos.router, prefix="/api/v1", tags=["repos"]) +app.include_router(groups.router, prefix="/api/v1", tags=["groups"]) diff --git a/backend/services/group_report.py b/backend/services/group_report.py new file mode 100644 index 0000000..d261e97 --- /dev/null +++ b/backend/services/group_report.py @@ -0,0 +1,112 @@ +from __future__ import annotations + +import asyncio +from typing import Any, Iterable, Sequence + +from github import Github + +from config import MAX_ITEMS_PER_SECTION +from models.github import GitHubItem +from services.github_client import get_repo, get_repo_activity +from services.issue_summary import summarize_items +from services.tldr_generator import tldr +from utils.dates import resolve_timeframe +from utils.serializers import serialize_github_item +from utils.url import normalize_repo_reference + + +async def _summarize_repository( + github: Github, + repo_identifier: str, + timeframe: str, +) -> dict[str, Any]: + owner, name = normalize_repo_reference(repo_identifier) + github_repo = get_repo(github, owner, name) + + start_date, end_date = resolve_timeframe(timeframe) + + prs_task = asyncio.create_task( + get_repo_activity(github, github_repo, "pr", start_date, end_date) + ) + issues_task = asyncio.create_task( + get_repo_activity(github, github_repo, "issue", start_date, end_date) + ) + + prs_raw, issues_raw = await asyncio.gather(prs_task, issues_task) + + serialized_prs = [ + serialize_github_item(item) for item in prs_raw[:MAX_ITEMS_PER_SECTION] + ] + serialized_issues = [ + serialize_github_item(item) for item in issues_raw[:MAX_ITEMS_PER_SECTION] + ] + + summarized_prs: Sequence[GitHubItem] + summarized_issues: Sequence[GitHubItem] + + if serialized_prs: + summarized_prs = await summarize_items(serialized_prs) + else: + summarized_prs = [] + + if serialized_issues: + summarized_issues = await summarize_items(serialized_issues) + else: + summarized_issues = [] + + repo_summaries = _collect_item_summaries( + summarized_prs, summarized_issues, github_repo.full_name + ) + + repo_tldr = None + if repo_summaries: + repo_tldr = await tldr("\n".join(repo_summaries), stream=False) + + return { + "full_name": github_repo.full_name, + "html_url": github_repo.html_url, + "prs": summarized_prs, + "issues": summarized_issues, + "tldr": repo_tldr if isinstance(repo_tldr, str) else None, + } + + +def _collect_item_summaries( + prs: Iterable[GitHubItem], + issues: Iterable[GitHubItem], + repo_name: str, +) -> list[str]: + summaries: list[str] = [] + for item in (*prs, *issues): + if item.summary: + summaries.append(f"[{repo_name}] {item.summary}") + return summaries + + +async def generate_group_report( + github: Github, + repos: Sequence[str], + timeframe: str, +) -> tuple[list[dict[str, Any]], str | None]: + if not repos: + return [], None + + normalized_repos = list(dict.fromkeys(repos)) + + repo_results = await asyncio.gather( + *(_summarize_repository(github, repo, timeframe) for repo in normalized_repos) + ) + + aggregate_summaries: list[str] = [] + for repo_data in repo_results: + aggregate_summaries.extend( + _collect_item_summaries( + repo_data["prs"], repo_data["issues"], repo_data["full_name"] + ) + ) + + group_tldr = None + if aggregate_summaries: + group_tldr = await tldr("\n".join(aggregate_summaries), stream=False) + + return repo_results, group_tldr if isinstance(group_tldr, str) else None diff --git a/backend/utils/group_config.py b/backend/utils/group_config.py new file mode 100644 index 0000000..298e4e5 --- /dev/null +++ b/backend/utils/group_config.py @@ -0,0 +1,54 @@ +from __future__ import annotations + +from functools import lru_cache +from pathlib import Path +from typing import Dict, Optional + +import yaml +from pydantic import BaseModel, ValidationError + + +class GroupDefinition(BaseModel): + id: str + name: str + description: Optional[str] = None + repos: list[str] + + +_GROUPS_PATH = Path(__file__).resolve().parent.parent / "groups" + + +def _load_group_files() -> Dict[str, GroupDefinition]: + groups: Dict[str, GroupDefinition] = {} + + if not _GROUPS_PATH.exists(): + return groups + + for path in sorted(_GROUPS_PATH.glob("*.yml")) + sorted( + _GROUPS_PATH.glob("*.yaml") + ): + try: + with path.open("r", encoding="utf-8") as handle: + raw = yaml.safe_load(handle) or {} + group = GroupDefinition(**raw) + except (OSError, ValidationError, yaml.YAMLError) as exc: + # Log-friendly representation while keeping backend resilient. + print(f"⚠️ Failed to load group config '{path.name}': {exc}") + continue + + groups[group.id] = group + + return groups + + +@lru_cache() +def get_group_definitions() -> Dict[str, GroupDefinition]: + return _load_group_files() + + +def get_group_definition(group_id: str) -> Optional[GroupDefinition]: + return get_group_definitions().get(group_id) + + +def refresh_groups_cache() -> None: + get_group_definitions.cache_clear() diff --git a/backend/utils/url.py b/backend/utils/url.py index 7c48fdd..da7ee06 100644 --- a/backend/utils/url.py +++ b/backend/utils/url.py @@ -7,3 +7,21 @@ def parse_repo_url(url: str) -> tuple[str, str]: if len(parts) >= 2: return parts[0], parts[1] raise ValueError("Invalid GitHub repository URL") + + +def normalize_repo_reference(value: str) -> tuple[str, str]: + value = value.strip() + if not value: + raise ValueError("Repository identifier cannot be empty") + + if value.startswith("http://") or value.startswith("https://"): + return parse_repo_url(value) + + if "/" in value: + owner, repo = value.split("/", 1) + owner = owner.strip() + repo = repo.strip() + if owner and repo: + return owner, repo + + raise ValueError(f"Invalid repository reference: {value}") diff --git a/frontend/src/App.tsx b/frontend/src/App.tsx index d9124f0..33cfc7a 100644 --- a/frontend/src/App.tsx +++ b/frontend/src/App.tsx @@ -4,26 +4,27 @@ import { ConfigProvider, theme } from "antd"; import { ThemeProvider } from "styled-components"; import DashboardView from "./views/DashboardView"; import TLDRView from "./views/TLDRView"; +import GroupTLDRView from "./views/GroupTLDRView"; import AuthCallback from "./components/AuthCallback"; import AuthGuard from "./components/AuthGuard"; import { AuthProvider } from "./contexts/AuthContext"; -import { Timeframe } from "./types/github"; +import { DigestTarget, Timeframe } from "./types/github"; const AppContent: React.FC = () => { const [hasStarted, setHasStarted] = useState(false); - const [repo, setRepo] = useState(""); + const [target, setTarget] = useState(null); const [initialTimeframe, setInitialTimeframe] = useState("last_week"); - const handleStart = (repo: string, timeframe: Timeframe) => { + const handleStart = (selection: DigestTarget, timeframe: Timeframe) => { setHasStarted(true); - setRepo(repo); + setTarget(selection); setInitialTimeframe(timeframe); }; const handleReset = () => { setHasStarted(false); - setRepo(""); + setTarget(null); setInitialTimeframe("last_week"); }; @@ -37,9 +38,16 @@ const AppContent: React.FC = () => { element={ {!hasStarted && } - {hasStarted && ( + {hasStarted && target?.kind === "repo" && ( + )} + {hasStarted && target?.kind === "group" && ( + diff --git a/frontend/src/hooks/useGroupTLDRData.ts b/frontend/src/hooks/useGroupTLDRData.ts new file mode 100644 index 0000000..b224aaf --- /dev/null +++ b/frontend/src/hooks/useGroupTLDRData.ts @@ -0,0 +1,139 @@ +import { useCallback, useEffect, useState } from "react"; + +import { + DigestTarget, + GroupReportData, + StoredGroupReport, + Timeframe, +} from "../types/github"; +import { GroupReportResponse } from "../types/api"; +import { useAuth } from "./useAuth"; +import { apiClient } from "../utils/apiClient"; +import { GroupTLDRStorage } from "../utils/groupStorage"; +import { normalizeRepoIdentifier } from "../utils/repoUtils"; + +type UseGroupTLDRDataReturn = { + data: GroupReportData; + loading: boolean; + error: string | null; + lastReport: StoredGroupReport | null; + hasData: boolean; + generateReport: () => Promise; +}; + +const emptyData: GroupReportData = { + tldr: null, + repos: [], +}; + +export const useGroupTLDRData = ( + group: Extract, + timeframe: Timeframe, +): UseGroupTLDRDataReturn => { + const { user } = useAuth(); + const [data, setData] = useState(emptyData); + const [loading, setLoading] = useState(false); + const [error, setError] = useState(null); + const [lastReport, setLastReport] = useState(null); + + const loadStoredReport = useCallback(() => { + try { + const normalized = group.repos.map((repo) => normalizeRepoIdentifier(repo)); + const stored = GroupTLDRStorage.getReport( + { id: group.id, name: group.name, repos: normalized }, + timeframe, + user, + ); + if (stored) { + setData(stored.data); + setLastReport(stored); + } else { + setData(emptyData); + setLastReport(null); + } + } catch (error) { + console.warn("Skipping stored group report lookup due to invalid repo", error); + setData(emptyData); + setLastReport(null); + } + }, [group.id, group.name, group.repos, timeframe, user]); + + useEffect(() => { + loadStoredReport(); + setError(null); + }, [loadStoredReport]); + + const generateReport = useCallback(async () => { + setLoading(true); + setError(null); + + const normalizedRepos = Array.from( + new Set(group.repos.map((repo) => normalizeRepoIdentifier(repo))), + ); + + const payload: Record = { + timeframe, + }; + + if (group.id) { + payload.group_id = group.id; + } + + if (!group.id || normalizedRepos.length) { + payload.repos = normalizedRepos; + } + + if (!group.id) { + payload.name = group.name; + } + + try { + const response = await apiClient.post( + "groups/report", + payload, + ); + + const reportData: GroupReportData = { + tldr: response.tldr, + repos: response.repos, + }; + + setData(reportData); + + GroupTLDRStorage.saveReport( + { id: response.group_id ?? group.id, name: response.name, repos: normalizedRepos }, + timeframe, + reportData, + user, + response.group_id ?? group.id, + ); + + const saved = GroupTLDRStorage.getReport( + { id: response.group_id ?? group.id, name: response.name, repos: normalizedRepos }, + timeframe, + user, + ); + setLastReport(saved); + } catch (err) { + console.error("Failed to load group report", err); + const message = + err instanceof Error + ? err.message + : "Failed to generate group TL;DR. Please try again."; + setError(message); + } finally { + setLoading(false); + } + }, [group.id, group.name, group.repos, timeframe, user]); + + const hasData = data.repos.length > 0 || Boolean(data.tldr); + + return { + data, + loading, + error, + lastReport, + hasData, + generateReport, + }; +}; diff --git a/frontend/src/types/api.ts b/frontend/src/types/api.ts index 277ec9d..816d432 100644 --- a/frontend/src/types/api.ts +++ b/frontend/src/types/api.ts @@ -1,5 +1,11 @@ // API Response Types -import { GitHubItem, PeopleData } from "./github"; +import { + GitHubItem, + PeopleData, + Timeframe, + GroupDefinition, + GroupRepoReport, +} from "./github"; export interface PatchItem { file: string; @@ -47,3 +53,15 @@ export interface PullRequestsResponse { export interface IssuesResponse { issues: GitHubItem[]; } + +export interface GroupListResponse { + groups: GroupDefinition[]; +} + +export interface GroupReportResponse { + group_id?: string | null; + name: string; + timeframe: Timeframe; + tldr: string | null; + repos: GroupRepoReport[]; +} diff --git a/frontend/src/types/github.ts b/frontend/src/types/github.ts index a887eb0..084ab00 100644 --- a/frontend/src/types/github.ts +++ b/frontend/src/types/github.ts @@ -72,3 +72,38 @@ export type RepoSummary = { stargazers_count: number; updated_at: string; }; + +export type GroupDefinition = { + id: string; + name: string; + description?: string | null; + repos: string[]; +}; + +export type GroupRepoReport = { + full_name: string; + html_url: string; + prs: GitHubItem[]; + issues: GitHubItem[]; + tldr?: string | null; +}; + +export type GroupReportData = { + tldr: string | null; + repos: GroupRepoReport[]; +}; + +export type StoredGroupReport = { + id: string; + groupId?: string | null; + name: string; + repos: string[]; + timeframe: Timeframe; + data: GroupReportData; + generatedAt: string; + version: number; +}; + +export type DigestTarget = + | { kind: "repo"; repo: string; label: string } + | { kind: "group"; id?: string | null; name: string; repos: string[]; preset?: boolean }; diff --git a/frontend/src/utils/groupStorage.ts b/frontend/src/utils/groupStorage.ts new file mode 100644 index 0000000..63977ac --- /dev/null +++ b/frontend/src/utils/groupStorage.ts @@ -0,0 +1,119 @@ +import { + GroupReportData, + StoredGroupReport, + Timeframe, +} from "../types/github"; +import { UserStorage } from "./userStorage"; +import { slugify } from "./slugify"; + +interface User { + id: number; + login: string; + name?: string; + avatar_url?: string; + email?: string; +} + +const BASE_STORAGE_KEY = "oss-tldr-group-reports"; +const STORAGE_VERSION = 1; +const MAX_REPORTS = 20; + +const buildReportId = ( + name: string, + repos: string[], + timeframe: Timeframe, + groupId?: string | null, +) => { + const base = groupId ? groupId : slugify(name); + const repoSignature = repos.slice().sort().join("|"); + return `${base}:${repoSignature}:${timeframe}`; +}; + +export class GroupTLDRStorage { + private static getStorageKey(user: User | null): string { + return UserStorage.getUserKey(BASE_STORAGE_KEY, user); + } + + private static getReports(user: User | null = null): StoredGroupReport[] { + try { + const storageKey = this.getStorageKey(user); + const stored = localStorage.getItem(storageKey); + if (!stored) return []; + + const reports = JSON.parse(stored) as StoredGroupReport[]; + return reports.filter((report) => report.version === STORAGE_VERSION); + } catch (error) { + console.error("Failed to load group reports from storage:", error); + return []; + } + } + + private static saveReports( + reports: StoredGroupReport[], + user: User | null = null, + ): void { + try { + const sorted = reports + .sort( + (a, b) => + new Date(b.generatedAt).getTime() - + new Date(a.generatedAt).getTime(), + ) + .slice(0, MAX_REPORTS); + + const storageKey = this.getStorageKey(user); + localStorage.setItem(storageKey, JSON.stringify(sorted)); + } catch (error) { + console.error("Failed to save group reports:", error); + } + } + + static getReport( + group: { id?: string | null; name: string; repos: string[] }, + timeframe: Timeframe, + user: User | null = null, + ): StoredGroupReport | null { + const reports = this.getReports(user); + const id = buildReportId(group.name, group.repos, timeframe, group.id); + return reports.find((report) => report.id === id) || null; + } + + static saveReport( + group: { id?: string | null; name: string; repos: string[] }, + timeframe: Timeframe, + data: GroupReportData, + user: User | null = null, + resolvedGroupId?: string | null, + ): void { + const normalizedRepos = Array.from( + new Set( + group.repos + .map((repo) => repo.trim()) + .filter((repo) => repo.length > 0), + ), + ); + const reportId = buildReportId( + group.name, + normalizedRepos, + timeframe, + resolvedGroupId ?? group.id, + ); + + const reports = this.getReports(user); + const filtered = reports.filter((report) => report.id !== reportId); + + const newReport: StoredGroupReport = { + id: reportId, + groupId: resolvedGroupId ?? group.id, + name: group.name, + repos: normalizedRepos, + timeframe, + data, + generatedAt: new Date().toISOString(), + version: STORAGE_VERSION, + }; + + filtered.push(newReport); + this.saveReports(filtered, user); + } +} diff --git a/frontend/src/utils/repoUtils.ts b/frontend/src/utils/repoUtils.ts new file mode 100644 index 0000000..0f9dfd8 --- /dev/null +++ b/frontend/src/utils/repoUtils.ts @@ -0,0 +1,35 @@ +export const normalizeRepoIdentifier = (input: string): string => { + const value = input.trim(); + + if (!value) { + throw new Error("Repository identifier cannot be empty"); + } + + if (value.startsWith("http://") || value.startsWith("https://")) { + try { + const url = new URL(value); + const parts = url.pathname.replace(/^\/+/, "").split("/"); + if (parts.length >= 2) { + return `${parts[0]}/${parts[1]}`; + } + } catch (error) { + throw new Error(`Invalid repository URL: ${value}`); + } + } + + if (value.includes("/")) { + const [owner, repo] = value.split("/", 2); + if (owner && repo) { + return `${owner.trim()}/${repo.trim()}`; + } + } + + throw new Error(`Invalid repository reference: ${value}`); +}; + +export const toGitHubUrl = (identifier: string): string => { + if (identifier.startsWith("http://") || identifier.startsWith("https://")) { + return identifier; + } + return `https://github.com/${identifier}`; +}; diff --git a/frontend/src/utils/slugify.ts b/frontend/src/utils/slugify.ts new file mode 100644 index 0000000..807b610 --- /dev/null +++ b/frontend/src/utils/slugify.ts @@ -0,0 +1,9 @@ +export const slugify = (value: string): string => { + return value + .toLowerCase() + .trim() + .replace(/[^a-z0-9]+/g, "-") + .replace(/^-+|-+$/g, "") + .replace(/-{2,}/g, "-") + .slice(0, 64) || "group"; +}; diff --git a/frontend/src/views/DashboardView/index.tsx b/frontend/src/views/DashboardView/index.tsx index 2903abd..2c0d3b2 100644 --- a/frontend/src/views/DashboardView/index.tsx +++ b/frontend/src/views/DashboardView/index.tsx @@ -1,4 +1,4 @@ -import React, { useState, useEffect, useCallback } from "react"; +import React, { useState, useEffect, useCallback, useMemo } from "react"; import { Card, Col, @@ -16,6 +16,7 @@ import { Avatar, theme, Popconfirm, + Select, } from "antd"; import { PlusOutlined, @@ -23,15 +24,25 @@ import { DeleteOutlined, StarOutlined, CodeOutlined, + AppstoreOutlined, } from "@ant-design/icons"; import styled from "styled-components"; -import { Timeframe, RepoSummary } from "../../types/github"; -// import { apiClient } from "../../utils/apiClient"; // Currently unused +import { + DigestTarget, + GroupDefinition, + RepoSummary, + Timeframe, +} from "../../types/github"; +import { apiClient } from "../../utils/apiClient"; import { useAuth } from "../../hooks/useAuth"; import { RepoAutocomplete } from "../../components"; import { UserStorage } from "../../utils/userStorage"; +import { GroupListResponse } from "../../types/api"; +import { normalizeRepoIdentifier } from "../../utils/repoUtils"; +import { slugify } from "../../utils/slugify"; const { Title, Text } = Typography; +const { Option } = Select; type SavedRepo = { id: string; @@ -44,7 +55,14 @@ type SavedRepo = { }; type DashboardProps = { - onStartDigest: (repo: string, timeframe: Timeframe) => void; + onStartDigest: (target: DigestTarget, timeframe: Timeframe) => void; +}; + +type SavedGroup = { + id: string; + name: string; + repos: string[]; + description?: string | null; }; const StyledCard = styled(Card)` @@ -160,6 +178,35 @@ const StatItem = styled.div` `; const BASE_STORAGE_KEY = "oss-tldr-repos"; +const BASE_GROUP_STORAGE_KEY = "oss-tldr-groups"; + +const GroupCard = styled(Card)` + height: 220px; + display: flex; + flex-direction: column; + justify-content: space-between; + border-radius: ${({ theme }) => theme.token.borderRadiusLG}px; + box-shadow: ${({ theme }) => theme.token.boxShadowSecondary}; + + .ant-card-body { + padding: ${({ theme }) => theme.token.paddingLG}px; + display: flex; + flex-direction: column; + gap: ${({ theme }) => theme.token.margin}px; + } +`; + +const GroupHeader = styled.div` + display: flex; + justify-content: space-between; + align-items: flex-start; + gap: ${({ theme }) => theme.token.marginSM}px; +`; + +const GroupName = styled(Text)` + font-size: ${({ theme }) => theme.token.fontSizeLG}px; + font-weight: 600; +`; const DashboardView: React.FC = ({ onStartDigest }) => { const { user, logout } = useAuth(); @@ -167,9 +214,24 @@ const DashboardView: React.FC = ({ onStartDigest }) => { const [isModalVisible, setIsModalVisible] = useState(false); const [validating, setValidating] = useState(false); const [loading, setLoading] = useState(true); + const [availableGroups, setAvailableGroups] = useState([]); + const [groupsLoading, setGroupsLoading] = useState(true); + const [customGroups, setCustomGroups] = useState([]); + const [groupModalVisible, setGroupModalVisible] = useState(false); + const [groupValidating, setGroupValidating] = useState(false); const [form] = Form.useForm(); + const [groupForm] = Form.useForm(); const { token } = theme.useToken(); + const repoOptions = useMemo( + () => + repos.map((repo) => ({ + value: repo.full_name, + label: repo.full_name, + })), + [repos], + ); + const loadReposFromStorage = useCallback(() => { try { const storageKey = UserStorage.getUserKey(BASE_STORAGE_KEY, user); @@ -198,6 +260,32 @@ const DashboardView: React.FC = ({ onStartDigest }) => { } }; + const loadGroupsFromStorage = useCallback(() => { + try { + const storageKey = UserStorage.getUserKey(BASE_GROUP_STORAGE_KEY, user); + const saved = localStorage.getItem(storageKey); + if (saved) { + setCustomGroups(JSON.parse(saved)); + } else { + setCustomGroups([]); + } + } catch (error) { + console.error("Failed to load groups from storage:", error); + setCustomGroups([]); + } + }, [user]); + + const saveGroupsToStorage = (groups: SavedGroup[]) => { + try { + const storageKey = UserStorage.getUserKey(BASE_GROUP_STORAGE_KEY, user); + localStorage.setItem(storageKey, JSON.stringify(groups)); + setCustomGroups(groups); + } catch (error) { + console.error("Failed to save groups to storage:", error); + message.error("Failed to save group"); + } + }; + const [selectedRepoData, setSelectedRepoData] = useState( null, ); @@ -300,6 +388,57 @@ const DashboardView: React.FC = ({ onStartDigest }) => { } }; + const handleAddGroup = async (values: { + name: string; + repos: string[]; + description?: string; + }) => { + setGroupValidating(true); + + try { + const normalizedRepos = Array.from( + new Set( + values.repos.map((repo) => normalizeRepoIdentifier(repo)).filter(Boolean), + ), + ); + + if (normalizedRepos.length === 0) { + throw new Error("Add at least one repository to the group"); + } + + const trimmedName = values.name.trim(); + if (!trimmedName) { + throw new Error("Group name is required"); + } + + const baseId = slugify(trimmedName); + let candidateId = baseId; + let suffix = 1; + while (customGroups.some((group) => group.id === candidateId)) { + candidateId = `${baseId}-${suffix}`; + suffix += 1; + } + + const newGroup: SavedGroup = { + id: candidateId, + name: trimmedName, + repos: normalizedRepos, + description: values.description?.trim() || undefined, + }; + + saveGroupsToStorage([...customGroups, newGroup]); + setGroupModalVisible(false); + groupForm.resetFields(); + message.success(`Created group “${newGroup.name}”`); + } catch (error) { + const errorMessage = + error instanceof Error ? error.message : "Failed to create group"; + message.error(errorMessage); + } finally { + setGroupValidating(false); + } + }; + const handleRemoveRepo = (repoId: string, repoName: string) => { const updatedRepos = repos.filter((repo) => repo.id !== repoId); saveReposToStorage(updatedRepos); @@ -308,13 +447,58 @@ const DashboardView: React.FC = ({ onStartDigest }) => { const handleRepoClick = (repo: SavedRepo) => { const repoUrl = `https://github.com/${repo.full_name}`; - onStartDigest(repoUrl, "last_week"); + onStartDigest( + { kind: "repo", repo: repoUrl, label: repo.full_name }, + "last_week", + ); + }; + + const handleGroupStart = ( + group: SavedGroup | GroupDefinition, + preset: boolean = false, + ) => { + onStartDigest( + { + kind: "group", + id: group.id, + name: group.name, + repos: group.repos, + preset, + }, + "last_week", + ); + }; + + const handleRemoveGroup = (groupId: string) => { + const updated = customGroups.filter((group) => group.id !== groupId); + saveGroupsToStorage(updated); + message.success("Group removed"); }; useEffect(() => { loadReposFromStorage(); }, [loadReposFromStorage]); // Reload when user changes + useEffect(() => { + loadGroupsFromStorage(); + }, [loadGroupsFromStorage]); + + useEffect(() => { + const fetchGroups = async () => { + try { + setGroupsLoading(true); + const response = await apiClient.get("groups"); + setAvailableGroups(response.groups); + } catch (error) { + console.error("Failed to load predefined groups:", error); + } finally { + setGroupsLoading(false); + } + }; + + fetchGroups(); + }, []); + return (
= ({ onStartDigest }) => { )} +
+
+
+ + Group Reports + + + Summaries across curated collections of repositories + +
+ +
+ + {groupsLoading ? ( +
+ +
+ ) : customGroups.length === 0 && availableGroups.length === 0 ? ( + + + + No groups yet + + + Create a group or use one of the curated presets to get started. + + + ) : ( + + {customGroups.map((group) => ( + + +
+ + {group.name} + handleRemoveGroup(group.id)} + okType="danger" + okText="Remove" + > +
+ + {group.repos.length} repos + + +
+ + ))} + + {availableGroups.map((group) => ( + + +
+ + {group.name} + + {group.description && ( + {group.description} + )} + + {group.repos.slice(0, 3).join(", ")} + {group.repos.length > 3 && " …"} + +
+ + {group.repos.length} repos + + +
+ + ))} +
+ )} +
+ @@ -607,6 +903,84 @@ const DashboardView: React.FC = ({ onStartDigest }) => { + + + + + Create Group + + + Combine multiple repositories into a themed report + +
+ } + open={groupModalVisible} + onCancel={() => { + setGroupModalVisible(false); + groupForm.resetFields(); + }} + footer={null} + width={520} + centered + > +
+ + + + + + + {timeframes.map((item) => ( + + ))} + + + + + {lastReport && ( + + + + {formatTimeAgo(lastReport.generatedAt)} + + + {formatDateRange(lastReport.timeframe)} + + + )} + + + + + + {error && ( + + )} + + + + {loading && !hasData ? ( + + ) : null} + + {hasData && data.tldr && ( + + Group TL;DR + + {data.tldr} + + + )} + + {hasData && ( + <> + {data.repos.map((repoSummary: GroupRepoReport) => ( + + + + + } size="small" /> + + {repoSummary.full_name} + + + {repoSummary.tldr && ( + + {repoSummary.tldr} + + )} + + + + Pull Requests + {renderItems( + repoSummary.html_url, + repoSummary.prs, + "No notable pull requests in this timeframe.", + )} + + Issues + {renderItems( + repoSummary.html_url, + repoSummary.issues, + "No notable issues in this timeframe.", + )} + + ))} + + )} + + {!loading && !hasData && ( + + + + )} + + ); +}; + +export default GroupTLDRView;