from __future__ import annotations import asyncio from typing import Any from calendar_feeds import CalendarFeed, CalendarFeedError, fetch_calendar_feed from forgejo_client import ForgejoClient, ForgejoClientError from settings import Settings async def build_live_prototype_payload( settings: Settings, *, forgejo_token: str | None = None, auth_source: str = "none", session_user: dict[str, Any] | None = None, ) -> dict[str, object]: warnings: list[str] = [] access_token = forgejo_token or settings.forgejo_token has_user_token = bool(access_token) and auth_source in {"authorization", "session"} source_cards = [ { "title": "Forgejo base URL", "description": settings.forgejo_base_url, }, { "title": "Access mode", "description": _access_mode_description(access_token, auth_source), }, ] calendar_feeds = await _load_calendar_feeds(settings, warnings) if settings.calendar_feed_urls: source_cards.append( { "title": "Calendar feeds", "description": f"{len(calendar_feeds)} configured feed(s)", }, ) async with ForgejoClient(settings, forgejo_token=access_token) as client: try: oidc = await client.fetch_openid_configuration() except ForgejoClientError as error: warnings.append(str(error)) oidc = {} issuer = oidc.get("issuer", "Unavailable") source_cards.append( { "title": "OIDC issuer", "description": str(issuer), }, ) try: repos = await client.search_repositories() except ForgejoClientError as error: warnings.append(str(error)) source_cards.append( { "title": "Discovery state", "description": "Forgejo connection exists, but live repo discovery failed.", }, ) return _empty_payload( source_cards=source_cards, warnings=warnings, auth=_auth_payload( session_user, _display_auth_source(auth_source, session_user), settings ), hero_summary=( "The backend reached aksal.cloud, but the configured token could not complete " "the public repo discovery flow." ), ) current_user = await _current_user_for_auth_source(client, has_user_token, warnings) public_repos = [repo for repo in repos if not repo.get("fork") and not repo.get("private")] repo_summaries = await asyncio.gather( *[_summarize_repo(client, repo) for repo in public_repos], ) content_repos = [summary for summary in repo_summaries if summary is not None] course_repos = [summary for summary in content_repos if summary["lesson_count"] > 0] post_repos = [summary for summary in content_repos if summary["blog_count"] > 0] public_issues = await _recent_public_issues( client, public_repos, settings.forgejo_recent_issue_limit, ) if current_user is not None: source_cards.append( { "title": "Signed-in API identity", "description": str(current_user.get("login", "Unknown user")), }, ) source_cards.append( { "title": "Discovery state", "description": ( f"Detected {len(course_repos)} course repos, {len(post_repos)} post repos, " f"and {len(public_issues)} recent public issues." ), }, ) auth_user = session_user or current_user return { "hero": { "eyebrow": "Live Forgejo integration", "title": "Robot U is reading from your aksal.cloud Forgejo instance.", "summary": ( "This prototype now uses the real Forgejo base URL, OIDC metadata, visible repos, " "and recent issues available to the active token." ), "highlights": [ "Repo discovery filters to public, non-fork repositories only", "Course repos are detected from /lessons/, post repos from /blogs/", "Recent discussions are loaded from live Forgejo issues", ], }, "auth": _auth_payload( auth_user if has_user_token else session_user, _display_auth_source(auth_source, session_user), settings, ), "source_of_truth": source_cards, "featured_courses": [_course_card(summary) for summary in course_repos[:6]], "recent_posts": [_post_card(summary) for summary in post_repos[:6]], "upcoming_events": _event_cards(calendar_feeds, settings.calendar_event_limit), "recent_discussions": await asyncio.gather( *[_discussion_card(client, issue) for issue in public_issues], ), "implementation_notes": [ "Live repo discovery is now driven by the Forgejo API instead of mock content.", "Issues shown here are loaded only from public Forgejo repositories.", *warnings, ], } def _access_mode_description(access_token: str | None, auth_source: str) -> str: if auth_source in {"authorization", "session"} and access_token: return f"Authenticated through {auth_source} token." if auth_source == "server" or access_token: return "Reading public content through the server-side Forgejo token." return "Reading public content anonymously." async def _current_user_for_auth_source( client: ForgejoClient, has_user_token: bool, warnings: list[str], ) -> dict[str, Any] | None: if not has_user_token: return None try: return await client.fetch_current_user() except ForgejoClientError as error: warnings.append(str(error)) return None async def _summarize_repo( client: ForgejoClient, repo: dict[str, Any], ) -> dict[str, Any] | None: owner_login = _repo_owner_login(repo) repo_name = repo.get("name") if not isinstance(owner_login, str) or not isinstance(repo_name, str): return None try: root_entries = await client.list_directory(owner_login, repo_name) except ForgejoClientError: return None entry_names = { entry.get("name") for entry in root_entries if entry.get("type") == "dir" and isinstance(entry.get("name"), str) } has_lessons = "lessons" in entry_names has_blogs = "blogs" in entry_names if not has_lessons and not has_blogs: return None chapter_count = 0 lesson_count = 0 course_outline: list[dict[str, object]] = [] if has_lessons: lesson_entries = await client.list_directory(owner_login, repo_name, "lessons") chapter_dirs = _sorted_dir_entries(lesson_entries) chapter_count = len(chapter_dirs) chapter_entry_lists = await asyncio.gather( *[ client.list_directory(owner_login, repo_name, f"lessons/{entry['name']}") for entry in chapter_dirs if isinstance(entry.get("name"), str) ], ) lesson_count = sum( 1 for chapter_entries in chapter_entry_lists for entry in chapter_entries if entry.get("type") == "dir" ) for chapter_dir, chapter_entries in zip(chapter_dirs, chapter_entry_lists, strict=False): chapter_name = str(chapter_dir.get("name", "")) lesson_dirs = _sorted_dir_entries(chapter_entries) lesson_summaries = await asyncio.gather( *[ _summarize_lesson( client, owner_login, repo_name, chapter_name, str(lesson_dir.get("name", "")), ) for lesson_dir in lesson_dirs ], ) course_outline.append( { "slug": chapter_name, "title": _display_name(chapter_name), "lessons": lesson_summaries, }, ) blog_count = 0 if has_blogs: blog_entries = await client.list_directory(owner_login, repo_name, "blogs") blog_count = sum(1 for entry in blog_entries if entry.get("type") == "dir") return { "name": repo_name, "owner": owner_login, "full_name": repo.get("full_name", f"{owner_login}/{repo_name}"), "html_url": repo.get("html_url", ""), "description": repo.get("description") or "No repository description yet.", "lesson_count": lesson_count, "chapter_count": chapter_count, "blog_count": blog_count, "updated_at": repo.get("updated_at", ""), "course_outline": course_outline, } def _course_card(summary: dict[str, Any]) -> dict[str, object]: return { "title": summary["name"], "owner": summary["owner"], "name": summary["name"], "repo": summary["full_name"], "html_url": summary["html_url"], "lessons": summary["lesson_count"], "chapters": summary["chapter_count"], "summary": summary["description"], "status": "Live course repo", "outline": summary["course_outline"], "updated_at": summary["updated_at"], } def _post_card(summary: dict[str, Any]) -> dict[str, object]: post_count = int(summary["blog_count"]) label = "1 post folder detected" if post_count == 1 else f"{post_count} post folders detected" return { "title": summary["name"], "repo": summary["full_name"], "kind": "Repo with /blogs/", "summary": f"{label}. {summary['description']}", "updated_at": summary["updated_at"], } async def _recent_public_issues( client: ForgejoClient, repos: list[dict[str, Any]], limit: int, ) -> list[dict[str, Any]]: issue_lists = await asyncio.gather( *[_repo_issues(client, repo, limit) for repo in repos], ) issues = [issue for issue_list in issue_lists for issue in issue_list] return sorted(issues, key=lambda issue: str(issue.get("updated_at", "")), reverse=True)[:limit] async def _repo_issues( client: ForgejoClient, repo: dict[str, Any], limit: int, ) -> list[dict[str, Any]]: owner_login = _repo_owner_login(repo) repo_name = repo.get("name") if not isinstance(owner_login, str) or not isinstance(repo_name, str): return [] try: issues = await client.list_repo_issues(owner_login, repo_name, limit=limit) except ForgejoClientError: return [] return [_with_repository(issue, repo, owner_login, repo_name) for issue in issues] def _with_repository( issue: dict[str, Any], repo: dict[str, Any], owner_login: str, repo_name: str, ) -> dict[str, Any]: issue_with_repo = dict(issue) issue_with_repo["repository"] = { "owner": owner_login, "name": repo_name, "full_name": repo.get("full_name", f"{owner_login}/{repo_name}"), "private": False, } return issue_with_repo def _repo_owner_login(repo: dict[str, Any]) -> str | None: owner = repo.get("owner", {}) if isinstance(owner, dict) and isinstance(owner.get("login"), str): return owner["login"] if isinstance(owner, str): return owner return None def _event_cards(calendar_feeds: list[CalendarFeed], limit: int) -> list[dict[str, object]]: upcoming_events = sorted( [event for feed in calendar_feeds for event in feed.events], key=lambda event: event.starts_at, )[:limit] return [ { "title": event.title, "when": _format_event_datetime(event.starts_at), "source": event.source, "mode": event.mode, } for event in upcoming_events ] async def _discussion_card(client: ForgejoClient, issue: dict[str, Any]) -> dict[str, object]: repository = issue.get("repository") or {} owner = repository.get("owner", "") full_name = repository.get("full_name", "Unknown repo") comments = issue.get("comments", 0) issue_number = int(issue.get("number", 0)) issue_author = issue.get("user") or {} labels = [ label.get("name") for label in issue.get("labels", []) if isinstance(label, dict) and isinstance(label.get("name"), str) ] comment_items: list[dict[str, object]] = [] if isinstance(owner, str) and isinstance(repository.get("name"), str) and issue_number > 0: try: comment_items = [ _discussion_reply(comment) for comment in await client.list_issue_comments( owner, repository["name"], issue_number, ) ] except ForgejoClientError: comment_items = [] body = str(issue.get("body", "") or "").strip() if not body: body = "No issue description yet. Right now the conversation starts in the replies." return { "id": int(issue.get("id", 0)), "title": issue.get("title", "Untitled issue"), "repo": full_name, "replies": comments, "context": "Live Forgejo issue", "author": issue_author.get("login", "Unknown author"), "author_avatar_url": issue_author.get("avatar_url", ""), "state": issue.get("state", "open"), "body": body, "number": issue_number, "updated_at": issue.get("updated_at", ""), "html_url": issue.get("html_url", ""), "labels": [label for label in labels if isinstance(label, str)], "comments": comment_items, } def _discussion_reply(comment: dict[str, Any]) -> dict[str, object]: author = comment.get("user") or {} body = str(comment.get("body", "") or "").strip() if not body: body = "No comment body provided." return { "id": int(comment.get("id", 0)), "author": author.get("login", "Unknown author"), "avatar_url": author.get("avatar_url", ""), "body": body, "created_at": comment.get("created_at", ""), "html_url": comment.get("html_url", ""), } def _display_auth_source(auth_source: str, session_user: dict[str, Any] | None) -> str: if session_user: return "session" return auth_source def _empty_payload( *, source_cards: list[dict[str, str]], warnings: list[str], auth: dict[str, object], hero_summary: str, ) -> dict[str, object]: return { "hero": { "eyebrow": "Forgejo connection status", "title": "Robot U is configured for aksal.cloud.", "summary": hero_summary, "highlights": [ "Forgejo remains the source of truth for content and discussions", "The prototype now targets aksal.cloud by default", "Public repo discovery works without signing in when Forgejo allows anonymous reads", ], }, "auth": auth, "source_of_truth": source_cards, "featured_courses": [], "recent_posts": [], "upcoming_events": [], "recent_discussions": [], "implementation_notes": warnings or ["Live repo discovery is ready, but Forgejo did not return public content."], } def _auth_payload( user: dict[str, Any] | None, source: str, settings: Settings, ) -> dict[str, object]: oauth_configured = bool( settings.forgejo_oauth_client_id and settings.forgejo_oauth_client_secret ) if not user: return { "authenticated": False, "login": None, "source": source, "can_reply": source in {"authorization", "session"}, "oauth_configured": oauth_configured, } return { "authenticated": True, "login": user.get("login", "Unknown user"), "source": source, "can_reply": source in {"authorization", "session"}, "oauth_configured": oauth_configured, } async def _summarize_lesson( client: ForgejoClient, owner: str, repo: str, chapter_name: str, lesson_name: str, ) -> dict[str, object]: lesson_path = f"lessons/{chapter_name}/{lesson_name}" fallback_title = _display_name(lesson_name) try: lesson_entries = await client.list_directory(owner, repo, lesson_path) except ForgejoClientError: return _empty_lesson(lesson_name, fallback_title, lesson_path) markdown_files = sorted( [ entry for entry in lesson_entries if entry.get("type") == "file" and isinstance(entry.get("name"), str) and str(entry.get("name", "")).lower().endswith(".md") ], key=lambda entry: str(entry["name"]), ) if not markdown_files: return _empty_lesson(lesson_name, fallback_title, lesson_path) markdown_name = str(markdown_files[0]["name"]) markdown_path = f"{lesson_path}/{markdown_name}" try: file_payload = await client.get_file_content(owner, repo, markdown_path) except ForgejoClientError: return _empty_lesson( lesson_name, fallback_title, lesson_path, file_path=markdown_path, html_url=str(markdown_files[0].get("html_url", "")), ) metadata, body = _parse_frontmatter(str(file_payload.get("content", ""))) return { "slug": lesson_name, "title": str(metadata.get("title") or _display_name(markdown_name) or fallback_title), "summary": str(metadata.get("summary") or ""), "path": lesson_path, "file_path": str(file_payload.get("path", markdown_path)), "html_url": str(file_payload.get("html_url", "")), "body": body, } def _sorted_dir_entries(entries: list[dict[str, Any]]) -> list[dict[str, Any]]: return sorted( [ entry for entry in entries if entry.get("type") == "dir" and isinstance(entry.get("name"), str) ], key=lambda entry: str(entry["name"]), ) def _display_name(value: str) -> str: cleaned = value.strip().rsplit(".", 1)[0] cleaned = cleaned.replace("_", " ").replace("-", " ") cleaned = " ".join(cleaned.split()) cleaned = cleaned.lstrip("0123456789 ").strip() return cleaned.title() or value async def _load_calendar_feeds(settings: Settings, warnings: list[str]) -> list[CalendarFeed]: if not settings.calendar_feed_urls: return [] results = await asyncio.gather( *[ fetch_calendar_feed(url, settings.forgejo_request_timeout_seconds) for url in settings.calendar_feed_urls ], return_exceptions=True, ) feeds: list[CalendarFeed] = [] for url, result in zip(settings.calendar_feed_urls, results, strict=False): if isinstance(result, CalendarFeed): feeds.append(result) continue if isinstance(result, CalendarFeedError): warnings.append(str(result)) continue if isinstance(result, Exception): warnings.append(f"Calendar feed failed for {url}: {result}") return feeds def _format_event_datetime(value: Any) -> str: timezone_name = value.strftime("%Z") if hasattr(value, "strftime") else "" suffix = f" {timezone_name}" if timezone_name else "" return f"{value.strftime('%b %-d, %-I:%M %p')}{suffix}" def _empty_lesson( lesson_name: str, title: str, lesson_path: str, *, file_path: str = "", html_url: str = "", ) -> dict[str, object]: return { "slug": lesson_name, "title": title, "summary": "", "path": lesson_path, "file_path": file_path, "html_url": html_url, "body": "", } def _parse_frontmatter(markdown: str) -> tuple[dict[str, str], str]: if not markdown.startswith("---\n"): return {}, markdown.strip() lines = markdown.splitlines() if not lines or lines[0].strip() != "---": return {}, markdown.strip() metadata: dict[str, str] = {} for index, line in enumerate(lines[1:], start=1): if line.strip() == "---": body = "\n".join(lines[index + 1 :]).strip() return metadata, body if ":" not in line: continue key, raw_value = line.split(":", 1) key = key.strip() value = raw_value.strip().strip("\"'") if key and value: metadata[key] = value return {}, markdown.strip()