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

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
95 changes: 95 additions & 0 deletions backend/api/groups.py
Original file line number Diff line number Diff line change
@@ -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],
)
7 changes: 7 additions & 0 deletions backend/groups/ai.yaml
Original file line number Diff line number Diff line change
@@ -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
3 changes: 2 additions & 1 deletion backend/main.py
Original file line number Diff line number Diff line change
Expand Up @@ -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")

Expand Down Expand Up @@ -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"])
112 changes: 112 additions & 0 deletions backend/services/group_report.py
Original file line number Diff line number Diff line change
@@ -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
54 changes: 54 additions & 0 deletions backend/utils/group_config.py
Original file line number Diff line number Diff line change
@@ -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()
18 changes: 18 additions & 0 deletions backend/utils/url.py
Original file line number Diff line number Diff line change
Expand Up @@ -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}")
22 changes: 15 additions & 7 deletions frontend/src/App.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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<DigestTarget | null>(null);
const [initialTimeframe, setInitialTimeframe] =
useState<Timeframe>("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");
};

Expand All @@ -37,9 +38,16 @@ const AppContent: React.FC = () => {
element={
<AuthGuard>
{!hasStarted && <DashboardView onStartDigest={handleStart} />}
{hasStarted && (
{hasStarted && target?.kind === "repo" && (
<TLDRView
repo={repo}
repo={target.repo}
onReset={handleReset}
initialTimeframe={initialTimeframe}
/>
)}
{hasStarted && target?.kind === "group" && (
<GroupTLDRView
group={target}
onReset={handleReset}
initialTimeframe={initialTimeframe}
/>
Expand Down
Loading