Build Forgejo-backed community prototype

This commit is contained in:
kacper 2026-04-12 20:15:33 -04:00
parent 797ae5ea35
commit 6671a01d26
16 changed files with 2485 additions and 293 deletions

View file

@ -1,7 +1,11 @@
APP_BASE_URL=http://kacper-dev-pod:8800
FORGEJO_BASE_URL=https://aksal.cloud
FORGEJO_TOKEN=
FORGEJO_OAUTH_CLIENT_ID=
FORGEJO_OAUTH_CLIENT_SECRET=
FORGEJO_OAUTH_SCOPES=openid profile
FORGEJO_REPO_SCAN_LIMIT=30
FORGEJO_RECENT_ISSUE_LIMIT=6
FORGEJO_REQUEST_TIMEOUT_SECONDS=10.0
CALENDAR_FEED_URLS=
CALENDAR_EVENT_LIMIT=6
CALENDAR_EVENT_LIMIT=3

View file

@ -62,6 +62,10 @@ cp .env.example .env
Useful variables:
- `FORGEJO_BASE_URL=https://aksal.cloud`
- `APP_BASE_URL=http://kacper-dev-pod:8800`
- `FORGEJO_OAUTH_CLIENT_ID=...`
- `FORGEJO_OAUTH_CLIENT_SECRET=...`
- `FORGEJO_OAUTH_SCOPES=openid profile`
- `FORGEJO_TOKEN=...`
- `CALENDAR_FEED_URLS=webcal://...`
- `HOST=0.0.0.0`
@ -69,7 +73,10 @@ Useful variables:
Notes:
- `FORGEJO_TOKEN` is required for live repo and discussion reads on `aksal.cloud`.
- Browser sign-in uses Forgejo OAuth/OIDC. `APP_BASE_URL` must match the URL opened in the browser, and the Forgejo OAuth app must include `/api/auth/forgejo/callback` under that base URL.
- Browser OAuth requests only identity scopes. The backend stores the resulting Forgejo token server-side and may use it only after enforcing public-repository checks.
- `FORGEJO_TOKEN` is optional and should be treated as a read-only local fallback. Browser sessions and API token calls may write comments only after verifying the target repo is public.
- API clients can query with `Authorization: token ...` or `Authorization: Bearer ...`.
- `CALENDAR_FEED_URLS` is optional and accepts comma-separated `webcal://` or `https://` ICS feeds.
- Do not commit `.env` or `.env.local`.

View file

@ -38,17 +38,34 @@ HOST=0.0.0.0 PORT=8800 ./scripts/start.sh
Optional live Forgejo configuration:
```bash
export APP_BASE_URL="http://kacper-dev-pod:8800"
export FORGEJO_BASE_URL="https://aksal.cloud"
export FORGEJO_TOKEN="your-forgejo-api-token"
export FORGEJO_OAUTH_CLIENT_ID="your-forgejo-oauth-client-id"
export FORGEJO_OAUTH_CLIENT_SECRET="your-forgejo-oauth-client-secret"
export FORGEJO_OAUTH_SCOPES="openid profile"
export CALENDAR_FEED_URLS="webcal://example.com/calendar.ics,https://example.com/other.ics"
```
`APP_BASE_URL` must match the URL you use in the browser. Create the OAuth app in Forgejo with this redirect URI:
```text
http://kacper-dev-pod:8800/api/auth/forgejo/callback
```
`FORGEJO_TOKEN` is optional. When set, it is a read fallback for local development. Browser OAuth requests only identity scopes, then the backend uses the signed-in user's Forgejo identity for public repo reads and public issue replies. The backend must verify repositories are public before reading discussion data or writing comments.
Or put those values in `.env`:
```bash
cp .env.example .env
```
Sign in through `/signin` using Forgejo OAuth, or query the API directly with:
```bash
curl -H "Authorization: token your-forgejo-api-token" http://127.0.0.1:8800/api/prototype
```
### Frontend
```bash

269
app.py
View file

@ -1,14 +1,26 @@
from __future__ import annotations
from pathlib import Path
from typing import Any
from urllib.parse import urlencode
from fastapi import FastAPI
from fastapi import Body, FastAPI, HTTPException, Request
from fastapi.middleware.cors import CORSMiddleware
from fastapi.responses import FileResponse, JSONResponse
from fastapi.responses import FileResponse, JSONResponse, RedirectResponse
from fastapi.staticfiles import StaticFiles
from auth import (
OAuthStateRecord,
clear_login_session,
consume_oauth_state,
create_login_session,
create_oauth_state,
current_session_user,
resolve_forgejo_token,
)
from forgejo_client import ForgejoClient, ForgejoClientError
from live_prototype import build_live_prototype_payload
from settings import get_settings
from settings import Settings, get_settings
BASE_DIR = Path(__file__).resolve().parent
DIST_DIR = BASE_DIR / "frontend" / "dist"
@ -29,8 +41,135 @@ def create_app() -> FastAPI:
return JSONResponse({"status": "ok"})
@app.get("/api/prototype")
async def prototype() -> JSONResponse:
return JSONResponse(await build_live_prototype_payload(get_settings()))
async def prototype(request: Request) -> JSONResponse:
settings = get_settings()
session_user = current_session_user(request)
forgejo_token, auth_source = resolve_forgejo_token(request, settings)
return JSONResponse(
await build_live_prototype_payload(
settings,
forgejo_token=forgejo_token,
auth_source=auth_source,
session_user=session_user,
),
)
@app.get("/api/auth/session")
async def auth_session(request: Request) -> JSONResponse:
session_user = current_session_user(request)
if session_user:
return JSONResponse(_auth_payload(session_user, "session"))
settings = get_settings()
forgejo_token, auth_source = resolve_forgejo_token(request, settings)
if not forgejo_token or auth_source == "server":
return JSONResponse(_auth_payload(None, "none"))
async with ForgejoClient(settings, forgejo_token=forgejo_token) as client:
try:
user = await client.fetch_current_user()
except ForgejoClientError as error:
raise HTTPException(status_code=401, detail=str(error)) from error
return JSONResponse(_auth_payload(user, auth_source))
@app.get("/api/auth/forgejo/start")
async def forgejo_auth_start(request: Request, return_to: str = "/") -> RedirectResponse:
settings = get_settings()
if not _oauth_configured(settings):
return _signin_error_redirect("Forgejo OAuth is not configured on the site yet.")
redirect_uri = _oauth_redirect_uri(request, settings)
state, code_challenge = create_oauth_state(redirect_uri, return_to)
async with ForgejoClient(settings) as client:
try:
oidc = await client.fetch_openid_configuration()
except ForgejoClientError as error:
return _signin_error_redirect(str(error))
authorization_endpoint = str(oidc.get("authorization_endpoint") or "")
if not authorization_endpoint:
return _signin_error_redirect("Forgejo did not return an OAuth authorization endpoint.")
query = urlencode(
{
"client_id": settings.forgejo_oauth_client_id or "",
"redirect_uri": redirect_uri,
"response_type": "code",
"scope": " ".join(settings.forgejo_oauth_scopes),
"state": state,
"code_challenge": code_challenge,
"code_challenge_method": "S256",
},
)
return RedirectResponse(f"{authorization_endpoint}?{query}", status_code=303)
@app.get("/api/auth/forgejo/callback")
async def forgejo_auth_callback(
code: str | None = None,
state: str | None = None,
error: str | None = None,
) -> RedirectResponse:
if error:
return _signin_error_redirect(f"Forgejo sign-in failed: {error}")
if not code or not state:
return _signin_error_redirect("Forgejo did not return the expected sign-in data.")
settings = get_settings()
if not _oauth_configured(settings):
return _signin_error_redirect("Forgejo OAuth is not configured on the site yet.")
oauth_state = consume_oauth_state(state)
if oauth_state is None:
return _signin_error_redirect("The Forgejo sign-in request expired. Try again.")
try:
access_token = await _exchange_forgejo_code(settings, code, oauth_state)
user = await _fetch_forgejo_oidc_user(settings, access_token)
except ForgejoClientError as exchange_error:
return _signin_error_redirect(str(exchange_error))
response = RedirectResponse(oauth_state.return_to, status_code=303)
create_login_session(response, access_token, user)
return response
@app.delete("/api/auth/session")
async def delete_auth_session(request: Request) -> JSONResponse:
response = JSONResponse(_auth_payload(None, "none"))
clear_login_session(request, response)
return response
@app.post("/api/discussions/replies")
async def create_discussion_reply(
request: Request,
payload: dict[str, object] = Body(...),
) -> JSONResponse:
owner = _required_string(payload, "owner")
repo = _required_string(payload, "repo")
body = _required_string(payload, "body")
issue_number = _required_positive_int(payload, "number")
settings = get_settings()
forgejo_token, auth_source = resolve_forgejo_token(request, settings)
if not forgejo_token or auth_source == "server":
raise HTTPException(
status_code=401,
detail="Sign in or send an Authorization token before replying.",
)
async with ForgejoClient(settings, forgejo_token=forgejo_token) as client:
try:
repo_payload = await client.fetch_repository(owner, repo)
if repo_payload.get("private"):
raise HTTPException(
status_code=403,
detail="This site only writes to public Forgejo repositories.",
)
comment = await client.create_issue_comment(owner, repo, issue_number, body)
except ForgejoClientError as error:
raise HTTPException(status_code=502, detail=str(error)) from error
return JSONResponse(_discussion_reply(comment))
if DIST_DIR.exists():
assets_dir = DIST_DIR / "assets"
@ -53,3 +192,123 @@ def create_app() -> FastAPI:
app = create_app()
def _required_string(payload: dict[str, object], key: str) -> str:
value = payload.get(key)
if not isinstance(value, str) or not value.strip():
raise HTTPException(status_code=400, detail=f"{key} is required.")
return value.strip()
def _required_positive_int(payload: dict[str, object], key: str) -> int:
value = payload.get(key)
if isinstance(value, bool):
raise HTTPException(status_code=400, detail=f"{key} must be a positive integer.")
if isinstance(value, int):
parsed = value
elif isinstance(value, str) and value.isdigit():
parsed = int(value)
else:
raise HTTPException(status_code=400, detail=f"{key} must be a positive integer.")
if parsed < 1:
raise HTTPException(status_code=400, detail=f"{key} must be a positive integer.")
return parsed
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 _auth_payload(user: dict[str, Any] | None, source: str) -> dict[str, object]:
oauth_configured = _oauth_configured(get_settings())
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,
}
def _oauth_configured(settings: Settings) -> bool:
return bool(settings.forgejo_oauth_client_id and settings.forgejo_oauth_client_secret)
def _oauth_redirect_uri(request: Request, settings: Settings) -> str:
if settings.app_base_url:
return f"{settings.app_base_url}/api/auth/forgejo/callback"
return str(request.url_for("forgejo_auth_callback"))
def _signin_error_redirect(message: str) -> RedirectResponse:
return RedirectResponse(f"/signin?{urlencode({'error': message})}", status_code=303)
async def _exchange_forgejo_code(
settings: Settings,
code: str,
oauth_state: OAuthStateRecord,
) -> str:
async with ForgejoClient(settings) as client:
oidc = await client.fetch_openid_configuration()
token_endpoint = str(oidc.get("token_endpoint") or "")
if not token_endpoint:
raise ForgejoClientError("Forgejo did not return an OAuth token endpoint.")
token_payload = await client.exchange_oauth_code(
token_endpoint=token_endpoint,
client_id=settings.forgejo_oauth_client_id or "",
client_secret=settings.forgejo_oauth_client_secret or "",
code=code,
redirect_uri=oauth_state.redirect_uri,
code_verifier=oauth_state.code_verifier,
)
access_token = token_payload.get("access_token")
if not isinstance(access_token, str) or not access_token:
raise ForgejoClientError("Forgejo did not return an access token.")
return access_token
async def _fetch_forgejo_oidc_user(settings: Settings, access_token: str) -> dict[str, Any]:
async with ForgejoClient(settings) as client:
oidc = await client.fetch_openid_configuration()
userinfo_endpoint = str(oidc.get("userinfo_endpoint") or "")
if not userinfo_endpoint:
raise ForgejoClientError("Forgejo did not return an OIDC UserInfo endpoint.")
userinfo = await client.fetch_userinfo(userinfo_endpoint, access_token)
login = userinfo.get("preferred_username") or userinfo.get("name") or userinfo.get("sub")
if not isinstance(login, str) or not login:
raise ForgejoClientError("Forgejo did not return a usable user identity.")
return {
"login": login,
"avatar_url": userinfo.get("picture", ""),
"email": userinfo.get("email", ""),
}

142
auth.py Normal file
View file

@ -0,0 +1,142 @@
from __future__ import annotations
import secrets
import time
from base64 import urlsafe_b64encode
from dataclasses import dataclass
from hashlib import sha256
from typing import Any
from fastapi import Request, Response
from settings import Settings
SESSION_COOKIE_NAME = "robot_u_session"
SESSION_MAX_AGE_SECONDS = 60 * 60 * 24 * 14
OAUTH_STATE_MAX_AGE_SECONDS = 60 * 10
@dataclass
class SessionRecord:
forgejo_token: str | None
user: dict[str, Any]
created_at: float
@dataclass
class OAuthStateRecord:
redirect_uri: str
return_to: str
code_verifier: str
created_at: float
_SESSIONS: dict[str, SessionRecord] = {}
_OAUTH_STATES: dict[str, OAuthStateRecord] = {}
def resolve_forgejo_token(request: Request, settings: Settings) -> tuple[str | None, str]:
header_token = _authorization_token(request.headers.get("authorization"))
if header_token:
return header_token, "authorization"
session = _session_from_request(request)
if session and session.forgejo_token:
return session.forgejo_token, "session"
if settings.forgejo_token:
return settings.forgejo_token, "server"
return None, "none"
def current_session_user(request: Request) -> dict[str, Any] | None:
session = _session_from_request(request)
return session.user if session else None
def create_login_session(
response: Response,
forgejo_token: str | None,
user: dict[str, Any],
) -> None:
session_id = secrets.token_urlsafe(32)
_SESSIONS[session_id] = SessionRecord(
forgejo_token=forgejo_token,
user=user,
created_at=time.time(),
)
response.set_cookie(
SESSION_COOKIE_NAME,
session_id,
httponly=True,
samesite="lax",
max_age=SESSION_MAX_AGE_SECONDS,
path="/",
)
def clear_login_session(request: Request, response: Response) -> None:
session_id = request.cookies.get(SESSION_COOKIE_NAME)
if session_id:
_SESSIONS.pop(session_id, None)
response.delete_cookie(SESSION_COOKIE_NAME, path="/")
def create_oauth_state(redirect_uri: str, return_to: str) -> tuple[str, str]:
state = secrets.token_urlsafe(32)
code_verifier = secrets.token_urlsafe(64)
_OAUTH_STATES[state] = OAuthStateRecord(
redirect_uri=redirect_uri,
return_to=_safe_return_path(return_to),
code_verifier=code_verifier,
created_at=time.time(),
)
return state, code_challenge(code_verifier)
def consume_oauth_state(state: str) -> OAuthStateRecord | None:
record = _OAUTH_STATES.pop(state, None)
if not record:
return None
if time.time() - record.created_at > OAUTH_STATE_MAX_AGE_SECONDS:
return None
return record
def code_challenge(code_verifier: str) -> str:
digest = sha256(code_verifier.encode("ascii")).digest()
return urlsafe_b64encode(digest).decode("ascii").rstrip("=")
def _authorization_token(value: str | None) -> str | None:
if not value:
return None
parts = value.strip().split(None, 1)
if len(parts) == 2 and parts[0].lower() in {"bearer", "token"}:
return parts[1].strip() or None
return None
def _safe_return_path(value: str) -> str:
if not value.startswith("/") or value.startswith("//"):
return "/"
return value
def _session_from_request(request: Request) -> SessionRecord | None:
session_id = request.cookies.get(SESSION_COOKIE_NAME)
if not session_id:
return None
session = _SESSIONS.get(session_id)
if not session:
return None
if time.time() - session.created_at > SESSION_MAX_AGE_SECONDS:
_SESSIONS.pop(session_id, None)
return None
return session

View file

@ -0,0 +1,70 @@
---
title: Building Robot U
summary: How the Robot U community site became a thin, friendly layer over Forgejo.
---
# Building Robot U
Robot U started with a simple goal: give a small robotics community one place to learn, share projects, and ask for help without inventing a second source of truth.
The site is intentionally built as a thin web layer over Forgejo. Forgejo stores the real community content. Repositories hold courses and posts. Issues hold discussions. OAuth lets members sign in with the same identity they already use for code and collaboration.
## What The Site Does
The first prototype focuses on the core community loop:
- Visitors can read public courses, blog posts, discussions, replies, and upcoming events without signing in.
- Members can sign in with Forgejo OAuth.
- Signed-in members can reply to discussions from the site while still posting as their real Forgejo account.
- Courses are discovered from public repositories that contain a `lessons/` folder.
- Blog content is discovered from public repositories that contain a `blogs/` folder.
- Discussions are backed by Forgejo issues, so the Forgejo API remains the durable storage layer.
- Events are loaded from an ICS calendar feed.
That means the website is not competing with Forgejo. It is presenting Forgejo content in a way that makes sense for learners.
## Why Courses Are Repositories
Courses need more than one page of text. A robotics lesson might include diagrams, code samples, CAD files, firmware, or wiring references. Keeping a lesson in a repository makes those materials easy to version, review, and download.
The current course convention is:
```text
lessons/
01-getting-started/
01-blink-an-led/
index.md
example-code/
wiring.png
```
Each lesson is just a folder with a Markdown file and any supporting assets it needs. Adding a lesson is a normal Git change. Editing a lesson is a normal Git change. Creating a new course is creating a new public repository with a `lessons/` folder.
## Why Discussions Are Issues
Forum posts and blockers should not disappear into a custom database if the website changes. Backing discussions with Forgejo issues gives the community a durable model that already supports authorship, replies, labels, links, and permissions.
The site reads public issues for general discussions and shows replies under each discussion. When a signed-in user replies from the website, the backend uses that user's Forgejo session token so the comment belongs to the real person, not a bot account.
## Anonymous Reading, Signed-In Writing
A key design rule is that reading should be easy. Public learning content should be visible without an account.
Writing is different. Replies and future content actions should require a real Forgejo identity. This keeps the contribution model clear:
- Public visitors can read.
- Members sign in with Forgejo to participate.
- Forgejo remains the place that enforces repository and issue permissions.
## What Is Next
This prototype is enough to prove the shape of the system, but there is plenty left to improve:
- Render individual blog posts as first-class pages.
- Add user profiles and progress tracking.
- Improve course navigation for longer curricula.
- Add search across courses, posts, and discussions.
- Use Forgejo webhooks and server-sent events so site activity updates quickly.
- Add better issue-to-lesson linking so discussions can appear directly under the related lesson.
The important part is that the foundation is simple. Robot U can grow as a community learning site without hiding the tools and workflows that make technical collaboration work.

View file

@ -1,11 +1,15 @@
from __future__ import annotations
from dataclasses import dataclass
from datetime import UTC, date, datetime, time
from datetime import UTC, datetime, time, timedelta
from zoneinfo import ZoneInfo, ZoneInfoNotFoundError
from urllib.parse import urlparse
import httpx
MAX_RECURRENCE_OCCURRENCES = 120
RECURRENCE_LOOKAHEAD_DAYS = 365
class CalendarFeedError(RuntimeError):
pass
@ -29,7 +33,10 @@ class CalendarFeed:
async def fetch_calendar_feed(url: str, timeout_seconds: float) -> CalendarFeed:
normalized_url = _normalize_calendar_url(url)
async with httpx.AsyncClient(timeout=timeout_seconds, follow_redirects=True) as client:
response = await client.get(normalized_url)
response = await client.get(
normalized_url,
headers={"User-Agent": "RobotUCalendar/0.1 (+https://aksal.cloud)"},
)
if not response.is_success:
raise CalendarFeedError(f"{response.status_code} from calendar feed {normalized_url}")
@ -55,6 +62,7 @@ def _parse_ics(raw_text: str, feed_url: str) -> tuple[str, list[CalendarEvent]]:
lines = _unfold_ics_lines(raw_text)
calendar_name = _calendar_name(lines, feed_url)
now = datetime.now(UTC)
recurrence_until = now + timedelta(days=RECURRENCE_LOOKAHEAD_DAYS)
events: list[CalendarEvent] = []
current_event: dict[str, str] | None = None
@ -63,9 +71,14 @@ def _parse_ics(raw_text: str, feed_url: str) -> tuple[str, list[CalendarEvent]]:
current_event = {}
continue
if line == "END:VEVENT":
parsed_event = _event_from_properties(current_event or {}, calendar_name)
if parsed_event and parsed_event.starts_at >= now:
events.append(parsed_event)
events.extend(
_events_from_properties(
current_event or {},
calendar_name,
now=now,
recurrence_until=recurrence_until,
),
)
current_event = None
continue
if current_event is None or ":" not in line:
@ -105,23 +118,139 @@ def _calendar_host(feed_url: str) -> str:
return parsed.hostname or "Calendar"
def _event_from_properties(properties: dict[str, str], calendar_name: str) -> CalendarEvent | None:
def _events_from_properties(
properties: dict[str, str],
calendar_name: str,
*,
now: datetime,
recurrence_until: datetime,
) -> list[CalendarEvent]:
title = _decode_ics_text(properties.get("SUMMARY", "").strip())
start_key = next((key for key in properties if key.startswith("DTSTART")), None)
if not title or not start_key:
return None
return []
starts_at = _parse_ics_datetime(start_key, properties[start_key])
if starts_at is None:
return None
return []
location = _decode_ics_text(properties.get("LOCATION", "").strip())
return CalendarEvent(
title=title,
starts_at=starts_at,
source=calendar_name,
mode=location or "Calendar",
event_template = {
"title": title,
"source": calendar_name,
"mode": location or "Calendar",
}
recurrence_rule = properties.get("RRULE")
if not recurrence_rule:
if starts_at >= now:
return [CalendarEvent(starts_at=starts_at, **event_template)]
return []
return [
CalendarEvent(starts_at=occurrence, **event_template)
for occurrence in _recurrence_occurrences(
starts_at,
recurrence_rule,
now=now,
recurrence_until=recurrence_until,
start_key=start_key,
)
]
def _recurrence_occurrences(
starts_at: datetime,
raw_rule: str,
*,
now: datetime,
recurrence_until: datetime,
start_key: str,
) -> list[datetime]:
rule = _parse_recurrence_rule(raw_rule)
frequency = rule.get("FREQ", "").upper()
interval = _positive_int(rule.get("INTERVAL"), default=1)
count = _positive_int(rule.get("COUNT"), default=MAX_RECURRENCE_OCCURRENCES)
until = _parse_recurrence_until(rule.get("UNTIL"), start_key)
effective_until = min(
[candidate for candidate in (until, recurrence_until) if candidate is not None],
default=recurrence_until,
)
occurrences: list[datetime] = []
occurrence = starts_at
for index in range(count):
if occurrence > effective_until:
break
if occurrence >= now:
occurrences.append(occurrence)
if len(occurrences) >= MAX_RECURRENCE_OCCURRENCES:
break
next_occurrence = _next_recurrence_occurrence(occurrence, frequency, interval)
if next_occurrence is None or next_occurrence <= occurrence:
break
occurrence = next_occurrence
return occurrences
def _parse_recurrence_rule(raw_rule: str) -> dict[str, str]:
rule: dict[str, str] = {}
for segment in raw_rule.split(";"):
if "=" not in segment:
continue
key, value = segment.split("=", 1)
rule[key.strip().upper()] = value.strip()
return rule
def _positive_int(value: str | None, *, default: int) -> int:
if value is None:
return default
try:
parsed = int(value)
except ValueError:
return default
return parsed if parsed > 0 else default
def _parse_recurrence_until(value: str | None, start_key: str) -> datetime | None:
if not value:
return None
return _parse_ics_datetime(start_key, value)
def _next_recurrence_occurrence(
occurrence: datetime,
frequency: str,
interval: int,
) -> datetime | None:
if frequency == "DAILY":
return occurrence + timedelta(days=interval)
if frequency == "WEEKLY":
return occurrence + timedelta(weeks=interval)
if frequency == "MONTHLY":
return _add_months(occurrence, interval)
if frequency == "YEARLY":
return _add_months(occurrence, interval * 12)
return None
def _add_months(value: datetime, months: int) -> datetime:
month_index = value.month - 1 + months
year = value.year + month_index // 12
month = month_index % 12 + 1
day = min(value.day, _days_in_month(year, month))
return value.replace(year=year, month=month, day=day)
def _days_in_month(year: int, month: int) -> int:
if month == 12:
next_month = datetime(year + 1, 1, 1)
else:
next_month = datetime(year, month + 1, 1)
this_month = datetime(year, month, 1)
return (next_month - this_month).days
def _parse_ics_datetime(key: str, value: str) -> datetime | None:
@ -132,13 +261,31 @@ def _parse_ics_datetime(key: str, value: str) -> datetime | None:
if value.endswith("Z"):
return datetime.strptime(value, "%Y%m%dT%H%M%SZ").replace(tzinfo=UTC)
if "T" in value:
return datetime.strptime(value, "%Y%m%dT%H%M%S").replace(tzinfo=UTC)
return _with_ics_timezone(datetime.strptime(value, "%Y%m%dT%H%M%S"), key)
parsed_date = datetime.strptime(value, "%Y%m%d").date()
return datetime.combine(parsed_date, time.min, tzinfo=UTC)
except ValueError:
return None
def _with_ics_timezone(value: datetime, key: str) -> datetime:
timezone_name = _ics_timezone_name(key)
if not timezone_name:
return value.replace(tzinfo=UTC)
try:
return value.replace(tzinfo=ZoneInfo(timezone_name))
except ZoneInfoNotFoundError:
return value.replace(tzinfo=UTC)
def _ics_timezone_name(key: str) -> str | None:
for segment in key.split(";")[1:]:
if segment.startswith("TZID="):
return segment.split("=", 1)[1].strip() or None
return None
def _decode_ics_text(value: str) -> str:
return (
value.replace("\\n", "\n")

View file

@ -14,8 +14,9 @@ class ForgejoClientError(RuntimeError):
class ForgejoClient:
def __init__(self, settings: Settings) -> None:
def __init__(self, settings: Settings, forgejo_token: str | None = None) -> None:
self._settings = settings
self._forgejo_token = forgejo_token or settings.forgejo_token
self._client = httpx.AsyncClient(timeout=settings.forgejo_request_timeout_seconds)
async def __aenter__(self) -> ForgejoClient:
@ -30,6 +31,54 @@ class ForgejoClient:
absolute_url=True,
)
async def exchange_oauth_code(
self,
*,
token_endpoint: str,
client_id: str,
client_secret: str,
code: str,
redirect_uri: str,
code_verifier: str,
) -> dict[str, Any]:
response = await self._client.post(
token_endpoint,
data={
"grant_type": "authorization_code",
"client_id": client_id,
"client_secret": client_secret,
"code": code,
"redirect_uri": redirect_uri,
"code_verifier": code_verifier,
},
headers={"Accept": "application/json"},
)
if response.is_success:
payload = response.json()
if isinstance(payload, dict):
return payload
raise ForgejoClientError("Unexpected Forgejo OAuth token payload.")
message = self._extract_error_message(response)
raise ForgejoClientError(f"{response.status_code} from Forgejo OAuth: {message}")
async def fetch_userinfo(self, userinfo_endpoint: str, access_token: str) -> dict[str, Any]:
response = await self._client.get(
userinfo_endpoint,
headers={
"Accept": "application/json",
"Authorization": f"Bearer {access_token}",
},
)
if response.is_success:
payload = response.json()
if isinstance(payload, dict):
return payload
raise ForgejoClientError("Unexpected Forgejo UserInfo payload.")
message = self._extract_error_message(response)
raise ForgejoClientError(f"{response.status_code} from Forgejo UserInfo: {message}")
async def fetch_current_user(self) -> dict[str, Any]:
return await self._get_json("/api/v1/user", auth_required=True)
@ -39,22 +88,37 @@ class ForgejoClient:
params={
"limit": self._settings.forgejo_repo_scan_limit,
"page": 1,
"private": "false",
"is_private": "false",
},
auth_required=True,
)
data = payload.get("data", [])
return [repo for repo in data if isinstance(repo, dict)]
async def search_recent_issues(self) -> list[dict[str, Any]]:
async def fetch_repository(self, owner: str, repo: str) -> dict[str, Any]:
payload = await self._get_json(
"/api/v1/repos/issues/search",
f"/api/v1/repos/{owner}/{repo}",
)
if isinstance(payload, dict):
return payload
raise ForgejoClientError(f"Unexpected repository payload for {owner}/{repo}")
async def list_repo_issues(
self,
owner: str,
repo: str,
*,
limit: int,
) -> list[dict[str, Any]]:
payload = await self._get_json(
f"/api/v1/repos/{owner}/{repo}/issues",
params={
"state": "open",
"page": 1,
"limit": self._settings.forgejo_recent_issue_limit,
"limit": limit,
"type": "issues",
"sort": "recentupdate",
},
auth_required=True,
)
if isinstance(payload, list):
return [issue for issue in payload if isinstance(issue, dict)]
@ -65,7 +129,7 @@ class ForgejoClient:
if path:
endpoint = f"{endpoint}/{path.strip('/')}"
payload = await self._get_json(endpoint, auth_required=True)
payload = await self._get_json(endpoint)
if isinstance(payload, list):
return [entry for entry in payload if isinstance(entry, dict)]
if isinstance(payload, dict):
@ -80,16 +144,31 @@ class ForgejoClient:
) -> list[dict[str, Any]]:
payload = await self._get_json(
f"/api/v1/repos/{owner}/{repo}/issues/{issue_number}/comments",
auth_required=True,
)
if isinstance(payload, list):
return [comment for comment in payload if isinstance(comment, dict)]
return []
async def create_issue_comment(
self,
owner: str,
repo: str,
issue_number: int,
body: str,
) -> dict[str, Any]:
payload = await self._request_json(
"POST",
f"/api/v1/repos/{owner}/{repo}/issues/{issue_number}/comments",
json_payload={"body": body},
auth_required=True,
)
if isinstance(payload, dict):
return payload
raise ForgejoClientError(f"Unexpected comment payload for {owner}/{repo}#{issue_number}")
async def get_file_content(self, owner: str, repo: str, path: str) -> dict[str, str]:
payload = await self._get_json(
f"/api/v1/repos/{owner}/{repo}/contents/{path.strip('/')}",
auth_required=True,
)
if not isinstance(payload, dict):
raise ForgejoClientError(f"Unexpected file payload for {owner}/{repo}:{path}")
@ -118,17 +197,41 @@ class ForgejoClient:
params: Mapping[str, str | int] | None = None,
auth_required: bool = False,
) -> dict[str, Any] | list[Any]:
if auth_required and not self._settings.forgejo_token:
return await self._request_json(
"GET",
path,
absolute_url=absolute_url,
params=params,
auth_required=auth_required,
)
async def _request_json(
self,
method: str,
path: str,
*,
absolute_url: bool = False,
params: Mapping[str, str | int] | None = None,
json_payload: Mapping[str, object] | None = None,
auth_required: bool = False,
) -> dict[str, Any] | list[Any]:
if auth_required and not self._forgejo_token:
raise ForgejoClientError(
"This Forgejo instance requires an authenticated API token for repo and issue access.",
)
url = path if absolute_url else f"{self._settings.forgejo_base_url}{path}"
headers = {}
if self._settings.forgejo_token:
headers["Authorization"] = f"token {self._settings.forgejo_token}"
if self._forgejo_token:
headers["Authorization"] = f"token {self._forgejo_token}"
response = await self._client.get(url, params=params, headers=headers)
response = await self._client.request(
method,
url,
params=params,
headers=headers,
json=json_payload,
)
if response.is_success:
return response.json()

File diff suppressed because it is too large Load diff

View file

@ -1,16 +1,29 @@
:root {
color-scheme: dark;
color-scheme: light;
font-family: "IBM Plex Sans", "Avenir Next", "Segoe UI", sans-serif;
line-height: 1.5;
font-weight: 400;
--bg: #0d1117;
--panel: #151b23;
--panel-hover: #1a2230;
--border: #2b3442;
--text: #edf2f7;
--muted: #9aa6b2;
--accent: #84d7ff;
--bg: linear-gradient(180deg, #fffaf0 0%, #f3ede1 100%);
--panel: #fffdf8;
--panel-hover: #f1eadf;
--card: #ffffff;
--border: #ded5c7;
--text: #1f2933;
--muted: #667085;
--accent: #0f6f8f;
--accent-strong: #0b5d78;
--accent-soft: rgba(15, 111, 143, 0.12);
--accent-border: rgba(15, 111, 143, 0.28);
--topbar-bg: rgba(255, 253, 248, 0.92);
--code-bg: #edf3f5;
--warning-bg: rgba(191, 125, 22, 0.12);
--warning-border: rgba(191, 125, 22, 0.32);
--warning-text: #7a4700;
--button-text: #ffffff;
--disabled-bg: #ece6dc;
--disabled-text: #8a8174;
--error: #a64234;
}
* {
@ -52,6 +65,125 @@ textarea {
padding: 2rem 1rem 3rem;
}
.topbar {
position: sticky;
top: 0;
z-index: 10;
display: flex;
gap: 1rem;
align-items: center;
width: 100%;
border-bottom: 0.0625rem solid var(--accent-border);
padding: 0.75rem clamp(1rem, 4vw, 2rem);
background: var(--topbar-bg);
backdrop-filter: blur(1rem);
}
.topbar-menu {
display: flex;
flex: 1;
align-items: center;
gap: 1rem;
min-width: 0;
}
.topbar-brand,
.topbar-link,
.topbar-menu-button {
border: 0;
color: inherit;
background: transparent;
cursor: pointer;
}
.topbar-brand {
border-radius: 999rem;
padding: 0.45rem 0.7rem;
color: var(--accent);
font-weight: 800;
letter-spacing: 0.03em;
text-transform: uppercase;
}
.topbar-brand:hover,
.topbar-brand:focus-visible,
.topbar-brand.active {
color: var(--text);
background: var(--accent-soft);
outline: none;
}
.topbar-nav,
.topbar-auth {
display: flex;
align-items: center;
gap: 0.5rem;
}
.topbar-nav {
flex: 1;
flex-wrap: wrap;
justify-content: flex-start;
}
.topbar-link {
border-radius: 999rem;
padding: 0.5rem 0.7rem;
color: var(--muted);
}
.topbar-link:hover,
.topbar-link:focus-visible,
.topbar-link.active {
color: var(--text);
background: var(--panel-hover);
outline: none;
}
.topbar-auth {
margin-left: auto;
justify-content: flex-end;
color: var(--muted);
font-size: 0.9rem;
}
.topbar-auth p {
max-width: 12rem;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
.topbar-auth-muted {
display: none;
}
.topbar-menu-button {
display: none;
width: 2.5rem;
height: 2.5rem;
margin-left: auto;
align-items: center;
justify-content: center;
border-radius: 999rem;
flex-direction: column;
gap: 0.25rem;
}
.topbar-menu-button span {
width: 1.1rem;
height: 0.125rem;
border-radius: 999rem;
background: currentColor;
}
.topbar-menu-button:hover,
.topbar-menu-button:focus-visible {
color: var(--text);
background: var(--panel-hover);
outline: none;
}
.page-header,
.page-section,
.panel,
@ -123,13 +255,15 @@ textarea {
.card,
.discussion-preview-card,
.activity-card,
.reply-card {
border: 0.0625rem solid var(--border);
border-radius: 0.75rem;
background: #111722;
background: var(--card);
}
.card,
.activity-card,
.reply-card {
padding: 1rem;
}
@ -148,11 +282,17 @@ textarea {
}
.card h3,
.discussion-preview-card h3 {
.discussion-preview-card h3,
.activity-card h3 {
margin-bottom: 0.35rem;
font-size: 1rem;
}
.activity-list {
display: grid;
gap: 0.75rem;
}
.discussion-preview-card {
width: 100%;
padding: 1rem;
@ -161,8 +301,17 @@ textarea {
color: inherit;
}
.activity-card-button {
width: 100%;
cursor: pointer;
text-align: left;
color: inherit;
}
.discussion-preview-card:hover,
.discussion-preview-card:focus-visible {
.discussion-preview-card:focus-visible,
.activity-card-button:hover,
.activity-card-button:focus-visible {
background: var(--panel-hover);
outline: none;
}
@ -188,6 +337,7 @@ textarea {
.back-link,
.secondary-link,
.secondary-button,
.compose-button {
display: inline-flex;
align-items: center;
@ -205,6 +355,34 @@ textarea {
cursor: pointer;
}
.auth-bar {
display: flex;
align-items: center;
justify-content: space-between;
gap: 0.75rem;
margin-bottom: 1rem;
border: 0.0625rem solid var(--accent-border);
border-radius: 0.75rem;
padding: 0.75rem;
color: var(--muted);
background: var(--accent-soft);
}
.secondary-button {
flex: 0 0 auto;
color: var(--accent);
background: var(--panel);
cursor: pointer;
}
.secondary-button:hover,
.secondary-button:focus-visible,
.back-link:hover,
.back-link:focus-visible {
background: var(--panel-hover);
outline: none;
}
.thread-view {
display: grid;
gap: 1rem;
@ -336,7 +514,7 @@ textarea {
.markdown-content code {
border-radius: 0.35rem;
padding: 0.12rem 0.35rem;
background: #0b1017;
background: var(--code-bg);
font-family: "IBM Plex Mono", "SFMono-Regular", monospace;
font-size: 0.92em;
}
@ -347,7 +525,7 @@ textarea {
border: 0.0625rem solid var(--border);
border-radius: 0.75rem;
padding: 0.85rem 0.95rem;
background: #0b1017;
background: var(--code-bg);
}
.markdown-content pre code {
@ -378,15 +556,57 @@ textarea {
gap: 0.85rem;
}
.compose-input {
.signin-page {
display: grid;
}
.signin-panel {
max-width: 42rem;
}
.signin-actions {
display: grid;
gap: 0.85rem;
justify-items: start;
}
.signin-callout {
display: flex;
align-items: center;
justify-content: space-between;
gap: 0.75rem;
border: 0.0625rem solid var(--warning-border);
border-radius: 0.75rem;
padding: 0.75rem;
color: var(--warning-text);
background: var(--warning-bg);
}
.form-label {
color: var(--muted);
font-size: 0.9rem;
font-weight: 600;
}
.compose-input,
.token-input {
width: 100%;
min-height: 9rem;
resize: vertical;
border: 0.0625rem solid var(--border);
border-radius: 0.75rem;
padding: 0.85rem 0.95rem;
color: var(--text);
background: #0f141d;
background: var(--card);
}
.compose-input {
min-height: 9rem;
resize: vertical;
}
.compose-input:focus,
.token-input:focus {
border-color: var(--accent);
outline: none;
}
.compose-actions {
@ -397,9 +617,35 @@ textarea {
}
.compose-button {
color: #7a8696;
background: #101722;
border-color: var(--accent);
color: var(--button-text);
background: var(--accent);
cursor: pointer;
font-weight: 700;
transition:
background 160ms ease,
border-color 160ms ease,
color 160ms ease,
opacity 160ms ease;
}
.compose-button:hover:not(:disabled),
.compose-button:focus-visible:not(:disabled) {
border-color: var(--accent-strong);
background: var(--accent-strong);
outline: none;
}
.compose-button:disabled {
border-color: var(--border);
color: var(--disabled-text);
background: var(--disabled-bg);
cursor: not-allowed;
opacity: 0.72;
}
.compose-error {
color: var(--error);
}
.secondary-link {
@ -407,12 +653,57 @@ textarea {
}
@media (max-width: 48rem) {
.topbar {
display: grid;
grid-template-columns: auto auto;
gap: 0.75rem;
}
.topbar-menu-button {
display: inline-flex;
}
.topbar-menu {
display: none;
grid-column: 1 / -1;
width: 100%;
border-top: 0.0625rem solid var(--border);
padding-top: 0.75rem;
}
.topbar-menu.open {
display: grid;
gap: 0.75rem;
}
.topbar-nav,
.topbar-auth {
width: 100%;
justify-content: flex-start;
}
.topbar-nav {
align-items: stretch;
flex-direction: column;
}
.topbar-link {
width: 100%;
text-align: left;
}
.topbar-auth {
margin-left: 0;
}
.card-grid,
.two-column-grid {
grid-template-columns: 1fr;
}
.compose-actions,
.auth-bar,
.signin-callout,
.subsection-header {
align-items: flex-start;
flex-direction: column;

View file

@ -10,6 +10,14 @@ export interface SourceOfTruthCard {
description: string;
}
export interface AuthState {
authenticated: boolean;
login: string | null;
source: string;
can_reply: boolean;
oauth_configured: boolean;
}
export interface CourseCard {
title: string;
owner: string;
@ -21,6 +29,7 @@ export interface CourseCard {
summary: string;
status: string;
outline: CourseChapter[];
updated_at: string;
}
export interface CourseChapter {
@ -44,6 +53,7 @@ export interface PostCard {
repo: string;
kind: string;
summary: string;
updated_at: string;
}
export interface EventCard {
@ -81,6 +91,7 @@ export interface DiscussionReply {
export interface PrototypeData {
hero: HeroData;
auth: AuthState;
source_of_truth: SourceOfTruthCard[];
featured_courses: CourseCard[];
recent_posts: PostCard[];

View file

@ -8,8 +8,16 @@ from forgejo_client import ForgejoClient, ForgejoClientError
from settings import Settings
async def build_live_prototype_payload(settings: Settings) -> dict[str, object]:
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",
@ -17,11 +25,7 @@ async def build_live_prototype_payload(settings: Settings) -> dict[str, object]:
},
{
"title": "Access mode",
"description": (
"Server token configured for live API reads."
if settings.forgejo_token
else "Instance API requires auth. Set FORGEJO_TOKEN for live repo discovery."
),
"description": _access_mode_description(access_token, auth_source),
},
]
@ -34,7 +38,7 @@ async def build_live_prototype_payload(settings: Settings) -> dict[str, object]:
},
)
async with ForgejoClient(settings) as client:
async with ForgejoClient(settings, forgejo_token=access_token) as client:
try:
oidc = await client.fetch_openid_configuration()
except ForgejoClientError as error:
@ -49,32 +53,8 @@ async def build_live_prototype_payload(settings: Settings) -> dict[str, object]:
},
)
if not settings.forgejo_token:
warnings.append(
"aksal.cloud blocks anonymous API calls, so the prototype needs FORGEJO_TOKEN "
"before it can load repos or issues.",
)
source_cards.append(
{
"title": "Discovery state",
"description": "Waiting for FORGEJO_TOKEN to enable live repo and issue reads.",
},
)
return _empty_payload(
source_cards=source_cards,
warnings=warnings,
hero_summary=(
"Connected to aksal.cloud for identity and OIDC discovery, but live repo content "
"is gated until a Forgejo API token is configured on the backend."
),
)
try:
current_user, repos, issues = await asyncio.gather(
client.fetch_current_user(),
client.search_repositories(),
client.search_recent_issues(),
)
repos = await client.search_repositories()
except ForgejoClientError as error:
warnings.append(str(error))
source_cards.append(
@ -86,38 +66,46 @@ async def build_live_prototype_payload(settings: Settings) -> dict[str, object]:
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 repo discovery flow."
"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 repos
if not repo.get("fork") and not repo.get("private")
],
*[_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]
source_cards.append(
{
"title": "Signed-in API identity",
"description": str(current_user.get("login", "Unknown user")),
},
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(issues)} recent issues."
f"and {len(public_issues)} recent public issues."
),
},
)
auth_user = session_user or current_user
return {
"hero": {
@ -125,7 +113,7 @@ async def build_live_prototype_payload(settings: Settings) -> dict[str, object]:
"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 configured backend token."
"and recent issues available to the active token."
),
"highlights": [
"Repo discovery filters to public, non-fork repositories only",
@ -133,27 +121,54 @@ async def build_live_prototype_payload(settings: Settings) -> dict[str, object]:
"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 issues],
*[_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 real Forgejo issues visible to the configured token.",
"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 = repo.get("owner", {})
owner_login = owner.get("login")
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
@ -247,6 +262,7 @@ def _course_card(summary: dict[str, Any]) -> dict[str, object]:
"summary": summary["description"],
"status": "Live course repo",
"outline": summary["course_outline"],
"updated_at": summary["updated_at"],
}
@ -258,9 +274,65 @@ def _post_card(summary: dict[str, Any]) -> dict[str, object]:
"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],
@ -341,10 +413,17 @@ def _discussion_reply(comment: dict[str, Any]) -> dict[str, object]:
}
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 {
@ -355,16 +434,43 @@ def _empty_payload(
"highlights": [
"Forgejo remains the source of truth for content and discussions",
"The prototype now targets aksal.cloud by default",
"Live repo discovery unlocks as soon as a backend token is configured",
"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 no Forgejo token has been configured yet."],
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,
}
@ -468,7 +574,9 @@ async def _load_calendar_feeds(settings: Settings, warnings: list[str]) -> list[
def _format_event_datetime(value: Any) -> str:
return value.strftime("%b %-d, %-I:%M %p UTC")
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(

View file

@ -5,6 +5,8 @@ status=0
root_dir="$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd)"
python_files=(
"app.py"
"auth.py"
"calendar_feeds.py"
"forgejo_client.py"
"live_prototype.py"
"settings.py"
@ -43,7 +45,7 @@ run_check \
uv run --with "deptry>=0.24.0,<1.0.0" \
deptry . \
--requirements-files requirements.txt \
--known-first-party app,forgejo_client,live_prototype,settings \
--known-first-party app,auth,calendar_feeds,forgejo_client,live_prototype,settings \
--per-rule-ignores DEP002=uvicorn \
--extend-exclude ".*/frontend/.*" \
--extend-exclude ".*/\\.venv/.*" \
@ -51,7 +53,7 @@ run_check \
run_check \
"Vulture" \
uv run --with "vulture>=2.15,<3.0.0" \
vulture app.py forgejo_client.py live_prototype.py settings.py tests --min-confidence 80
vulture app.py auth.py calendar_feeds.py forgejo_client.py live_prototype.py settings.py tests --min-confidence 80
run_check \
"Backend Tests" \
"${python_cmd[@]}" -m unittest discover -s tests -p "test_*.py"

View file

@ -7,8 +7,12 @@ from functools import lru_cache
@dataclass(frozen=True)
class Settings:
app_base_url: str | None
forgejo_base_url: str
forgejo_token: str | None
forgejo_oauth_client_id: str | None
forgejo_oauth_client_secret: str | None
forgejo_oauth_scopes: tuple[str, ...]
forgejo_repo_scan_limit: int
forgejo_recent_issue_limit: int
forgejo_request_timeout_seconds: float
@ -33,16 +37,29 @@ def _parse_calendar_feed_urls(raw_value: str | None) -> tuple[str, ...]:
return tuple(entry.strip() for entry in value.replace("\n", ",").split(",") if entry.strip())
def _parse_scopes(raw_value: str | None) -> tuple[str, ...]:
value = (raw_value or "").strip()
if not value:
return ("openid", "profile")
return tuple(scope for scope in value.replace(",", " ").split() if scope)
@lru_cache(maxsize=1)
def get_settings() -> Settings:
return Settings(
app_base_url=_normalize_base_url(os.getenv("APP_BASE_URL"))
if os.getenv("APP_BASE_URL")
else None,
forgejo_base_url=_normalize_base_url(os.getenv("FORGEJO_BASE_URL")),
forgejo_token=os.getenv("FORGEJO_TOKEN") or None,
forgejo_oauth_client_id=os.getenv("FORGEJO_OAUTH_CLIENT_ID") or None,
forgejo_oauth_client_secret=os.getenv("FORGEJO_OAUTH_CLIENT_SECRET") or None,
forgejo_oauth_scopes=_parse_scopes(os.getenv("FORGEJO_OAUTH_SCOPES")),
forgejo_repo_scan_limit=int(os.getenv("FORGEJO_REPO_SCAN_LIMIT", "30")),
forgejo_recent_issue_limit=int(os.getenv("FORGEJO_RECENT_ISSUE_LIMIT", "6")),
forgejo_request_timeout_seconds=float(
os.getenv("FORGEJO_REQUEST_TIMEOUT_SECONDS", "10.0"),
),
calendar_feed_urls=_parse_calendar_feed_urls(os.getenv("CALENDAR_FEED_URLS")),
calendar_event_limit=int(os.getenv("CALENDAR_EVENT_LIMIT", "6")),
calendar_event_limit=int(os.getenv("CALENDAR_EVENT_LIMIT", "3")),
)

View file

@ -1,11 +1,11 @@
from __future__ import annotations
import asyncio
import json
import os
import unittest
from urllib.parse import parse_qs, urlparse
from unittest.mock import AsyncMock, patch
from fastapi.responses import JSONResponse
from fastapi.testclient import TestClient
from app import create_app
from settings import get_settings
@ -13,42 +13,400 @@ from settings import get_settings
class AppTestCase(unittest.TestCase):
def setUp(self) -> None:
self.env_patcher = patch.dict(
os.environ,
{
"APP_BASE_URL": "http://testserver",
"FORGEJO_TOKEN": "",
"FORGEJO_OAUTH_CLIENT_ID": "client-id",
"FORGEJO_OAUTH_CLIENT_SECRET": "client-secret",
},
)
self.env_patcher.start()
get_settings.cache_clear()
self.app = create_app()
self.client = TestClient(self.app)
def tearDown(self) -> None:
get_settings.cache_clear()
def _get_route_response(self, path: str) -> JSONResponse:
route = next(route for route in self.app.routes if getattr(route, "path", None) == path)
return asyncio.run(route.endpoint())
self.env_patcher.stop()
def test_health(self) -> None:
response = self._get_route_response("/health")
response = self.client.get("/health")
self.assertEqual(response.status_code, 200)
self.assertEqual(json.loads(response.body), {"status": "ok"})
self.assertEqual(response.json(), {"status": "ok"})
def test_prototype_payload_shape(self) -> None:
payload = {
"hero": {"title": "Robot U"},
"auth": {
"authenticated": False,
"login": None,
"source": "none",
"can_reply": False,
"oauth_configured": True,
},
"featured_courses": [],
"recent_posts": [],
"recent_discussions": [],
"upcoming_events": [],
"source_of_truth": [],
}
with patch("app.build_live_prototype_payload", new=AsyncMock(return_value=payload)):
response = self._get_route_response("/api/prototype")
payload = json.loads(response.body)
builder = AsyncMock(return_value=payload)
with patch("app.build_live_prototype_payload", new=builder):
response = self.client.get("/api/prototype")
response_payload = response.json()
self.assertEqual(response.status_code, 200)
self.assertIn("hero", payload)
self.assertIn("featured_courses", payload)
self.assertIn("recent_posts", payload)
self.assertIn("recent_discussions", payload)
self.assertIn("upcoming_events", payload)
self.assertIn("source_of_truth", payload)
self.assertIn("hero", response_payload)
self.assertIn("auth", response_payload)
self.assertIn("featured_courses", response_payload)
self.assertIn("recent_posts", response_payload)
self.assertIn("recent_discussions", response_payload)
self.assertIn("upcoming_events", response_payload)
self.assertIn("source_of_truth", response_payload)
self.assertEqual(builder.call_args.kwargs["forgejo_token"], None)
self.assertEqual(builder.call_args.kwargs["auth_source"], "none")
def test_prototype_accepts_authorization_token(self) -> None:
payload = {
"hero": {"title": "Robot U"},
"auth": {
"authenticated": True,
"login": "kacper",
"source": "authorization",
"can_reply": True,
"oauth_configured": True,
},
"featured_courses": [],
"recent_posts": [],
"recent_discussions": [],
"upcoming_events": [],
"source_of_truth": [],
}
builder = AsyncMock(return_value=payload)
with patch("app.build_live_prototype_payload", new=builder):
response = self.client.get(
"/api/prototype",
headers={"Authorization": "token test-token"},
)
self.assertEqual(response.status_code, 200)
self.assertEqual(builder.call_args.kwargs["forgejo_token"], "test-token")
self.assertEqual(builder.call_args.kwargs["auth_source"], "authorization")
def test_prototype_can_use_server_token_without_user_session(self) -> None:
payload = {
"hero": {"title": "Robot U"},
"auth": {
"authenticated": False,
"login": None,
"source": "server",
"can_reply": False,
"oauth_configured": True,
},
"featured_courses": [],
"recent_posts": [],
"recent_discussions": [],
"upcoming_events": [],
"source_of_truth": [],
}
builder = AsyncMock(return_value=payload)
get_settings.cache_clear()
with (
patch.dict(os.environ, {"FORGEJO_TOKEN": "server-token"}),
patch("app.build_live_prototype_payload", new=builder),
):
response = self.client.get("/api/prototype")
self.assertEqual(response.status_code, 200)
self.assertEqual(builder.call_args.kwargs["forgejo_token"], "server-token")
self.assertEqual(builder.call_args.kwargs["auth_source"], "server")
def test_auth_session_ignores_server_token_fallback(self) -> None:
get_settings.cache_clear()
with patch.dict(os.environ, {"FORGEJO_TOKEN": "server-token"}):
response = self.client.get("/api/auth/session")
payload = response.json()
self.assertEqual(response.status_code, 200)
self.assertEqual(payload["authenticated"], False)
self.assertEqual(payload["login"], None)
self.assertEqual(payload["source"], "none")
self.assertEqual(payload["can_reply"], False)
def test_forgejo_auth_start_redirects_to_authorization_endpoint(self) -> None:
fake_client = _FakeForgejoClient()
with patch("app.ForgejoClient", return_value=fake_client):
response = self.client.get(
"/api/auth/forgejo/start?return_to=/discussions/7",
follow_redirects=False,
)
location = response.headers["location"]
query = parse_qs(urlparse(location).query)
self.assertEqual(response.status_code, 303)
self.assertTrue(location.startswith("https://aksal.cloud/login/oauth/authorize?"))
self.assertEqual(query["client_id"], ["client-id"])
self.assertEqual(query["redirect_uri"], ["http://testserver/api/auth/forgejo/callback"])
self.assertEqual(query["response_type"], ["code"])
self.assertEqual(query["scope"], ["openid profile"])
self.assertEqual(query["code_challenge_method"], ["S256"])
self.assertIn("state", query)
self.assertIn("code_challenge", query)
def test_forgejo_auth_callback_sets_session_cookie(self) -> None:
fake_client = _FakeForgejoClient(user={"login": "kacper"}, access_token="oauth-token")
with patch("app.ForgejoClient", return_value=fake_client):
start_response = self.client.get(
"/api/auth/forgejo/start?return_to=/discussions/7",
follow_redirects=False,
)
state = parse_qs(urlparse(start_response.headers["location"]).query)["state"][0]
callback_response = self.client.get(
f"/api/auth/forgejo/callback?code=auth-code&state={state}",
follow_redirects=False,
)
self.assertEqual(callback_response.status_code, 303)
self.assertEqual(callback_response.headers["location"], "/discussions/7")
self.assertIn("robot_u_session", callback_response.cookies)
self.assertEqual(fake_client.exchanged_code, "auth-code")
session_response = self.client.get("/api/auth/session")
session_payload = session_response.json()
self.assertEqual(session_response.status_code, 200)
self.assertEqual(session_payload["authenticated"], True)
self.assertEqual(session_payload["login"], "kacper")
self.assertEqual(session_payload["source"], "session")
self.assertEqual(session_payload["can_reply"], True)
def test_prototype_uses_signed_in_forgejo_identity_token(self) -> None:
fake_client = _FakeForgejoClient(user={"login": "kacper"}, access_token="oauth-token")
with patch("app.ForgejoClient", return_value=fake_client):
start_response = self.client.get(
"/api/auth/forgejo/start",
follow_redirects=False,
)
state = parse_qs(urlparse(start_response.headers["location"]).query)["state"][0]
self.client.get(
f"/api/auth/forgejo/callback?code=auth-code&state={state}",
follow_redirects=False,
)
payload = {
"hero": {"title": "Robot U"},
"auth": {
"authenticated": True,
"login": "kacper",
"source": "session",
"can_reply": True,
"oauth_configured": True,
},
"featured_courses": [],
"recent_posts": [],
"recent_discussions": [],
"upcoming_events": [],
"source_of_truth": [],
}
builder = AsyncMock(return_value=payload)
with patch("app.build_live_prototype_payload", new=builder):
response = self.client.get("/api/prototype")
self.assertEqual(response.status_code, 200)
self.assertEqual(builder.call_args.kwargs["forgejo_token"], "oauth-token")
self.assertEqual(builder.call_args.kwargs["auth_source"], "session")
self.assertEqual(builder.call_args.kwargs["session_user"]["login"], "kacper")
def test_create_discussion_reply(self) -> None:
fake_client = _FakeForgejoClient(
comment={
"id": 123,
"body": "Thanks, this helped.",
"created_at": "2026-04-11T12:00:00Z",
"html_url": "https://aksal.cloud/Robot-U/RobotClass/issues/2#issuecomment-123",
"user": {
"login": "Kacper",
"avatar_url": "https://aksal.cloud/avatar.png",
},
},
)
with patch("app.ForgejoClient", return_value=fake_client) as client_factory:
response = self.client.post(
"/api/discussions/replies",
json={
"owner": "Robot-U",
"repo": "RobotClass",
"number": 2,
"body": "Thanks, this helped.",
},
headers={"Authorization": "token test-token"},
)
payload = response.json()
self.assertEqual(response.status_code, 200)
self.assertEqual(client_factory.call_args.kwargs["forgejo_token"], "test-token")
self.assertEqual(
fake_client.created_comment, ("Robot-U", "RobotClass", 2, "Thanks, this helped.")
)
self.assertEqual(payload["id"], 123)
self.assertEqual(payload["author"], "Kacper")
self.assertEqual(payload["body"], "Thanks, this helped.")
def test_create_discussion_reply_uses_signed_in_identity(self) -> None:
sign_in_client = _FakeForgejoClient(user={"login": "kacper"}, access_token="oauth-token")
with patch("app.ForgejoClient", return_value=sign_in_client):
start_response = self.client.get(
"/api/auth/forgejo/start",
follow_redirects=False,
)
state = parse_qs(urlparse(start_response.headers["location"]).query)["state"][0]
self.client.get(
f"/api/auth/forgejo/callback?code=auth-code&state={state}",
follow_redirects=False,
)
reply_client = _FakeForgejoClient(
comment={
"id": 123,
"body": "Thanks, this helped.",
"created_at": "2026-04-11T12:00:00Z",
"html_url": "https://aksal.cloud/Robot-U/RobotClass/issues/2#issuecomment-123",
"user": {"login": "Kacper", "avatar_url": ""},
},
)
with patch("app.ForgejoClient", return_value=reply_client) as client_factory:
response = self.client.post(
"/api/discussions/replies",
json={
"owner": "Robot-U",
"repo": "RobotClass",
"number": 2,
"body": "Thanks, this helped.",
},
)
self.assertEqual(response.status_code, 200)
self.assertEqual(client_factory.call_args.kwargs["forgejo_token"], "oauth-token")
self.assertEqual(
reply_client.created_comment, ("Robot-U", "RobotClass", 2, "Thanks, this helped.")
)
def test_create_discussion_reply_rejects_private_repo(self) -> None:
fake_client = _FakeForgejoClient(repo_private=True)
with patch("app.ForgejoClient", return_value=fake_client):
response = self.client.post(
"/api/discussions/replies",
json={
"owner": "Robot-U",
"repo": "PrivateRobotClass",
"number": 2,
"body": "Thanks, this helped.",
},
headers={"Authorization": "token test-token"},
)
self.assertEqual(response.status_code, 403)
self.assertIsNone(fake_client.created_comment)
def test_create_discussion_reply_rejects_server_token_fallback(self) -> None:
get_settings.cache_clear()
with patch.dict(os.environ, {"FORGEJO_TOKEN": "server-token"}):
response = self.client.post(
"/api/discussions/replies",
json={
"owner": "Robot-U",
"repo": "RobotClass",
"number": 2,
"body": "Thanks, this helped.",
},
)
self.assertEqual(response.status_code, 401)
class _FakeForgejoClient:
def __init__(
self,
comment: dict[str, object] | None = None,
user: dict[str, object] | None = None,
access_token: str = "test-oauth-token",
repo_private: bool = False,
) -> None:
self._comment = comment
self._user = user or {"login": "test-user"}
self._access_token = access_token
self._repo_private = repo_private
self.created_comment: tuple[str, str, int, str] | None = None
self.exchanged_code: str | None = None
async def __aenter__(self) -> _FakeForgejoClient:
return self
async def __aexit__(self, _exc_type: object, _exc: object, _tb: object) -> None:
return None
async def create_issue_comment(
self,
owner: str,
repo: str,
issue_number: int,
body: str,
) -> dict[str, object]:
self.created_comment = (owner, repo, issue_number, body)
if self._comment is None:
raise AssertionError("Fake comment was not configured.")
return self._comment
async def fetch_current_user(self) -> dict[str, object]:
return self._user
async def fetch_repository(self, owner: str, repo: str) -> dict[str, object]:
return {
"owner": {"login": owner},
"name": repo,
"full_name": f"{owner}/{repo}",
"private": self._repo_private,
}
async def fetch_openid_configuration(self) -> dict[str, object]:
return {
"authorization_endpoint": "https://aksal.cloud/login/oauth/authorize",
"token_endpoint": "https://aksal.cloud/login/oauth/access_token",
"userinfo_endpoint": "https://aksal.cloud/login/oauth/userinfo",
}
async def exchange_oauth_code(
self,
*,
token_endpoint: str,
client_id: str,
client_secret: str,
code: str,
redirect_uri: str,
code_verifier: str,
) -> dict[str, object]:
self.exchanged_code = code
self.exchanged_token_request = {
"token_endpoint": token_endpoint,
"client_id": client_id,
"client_secret": client_secret,
"redirect_uri": redirect_uri,
"code_verifier": code_verifier,
}
return {"access_token": self._access_token}
async def fetch_userinfo(self, userinfo_endpoint: str, access_token: str) -> dict[str, object]:
self.fetched_userinfo = {
"userinfo_endpoint": userinfo_endpoint,
"access_token": access_token,
}
return {
"preferred_username": self._user["login"],
"picture": self._user.get("avatar_url", ""),
"email": self._user.get("email", ""),
}
if __name__ == "__main__":

View file

@ -0,0 +1,42 @@
from __future__ import annotations
import unittest
from datetime import datetime, timedelta
from zoneinfo import ZoneInfo
from calendar_feeds import _parse_ics
class CalendarFeedTestCase(unittest.TestCase):
def test_weekly_recurrence_returns_future_occurrences(self) -> None:
timezone = ZoneInfo("America/New_York")
starts_at = datetime.now(timezone).replace(
hour=13,
minute=0,
second=0,
microsecond=0,
) - timedelta(days=7)
ends_at = starts_at.replace(hour=16)
raw_calendar = f"""BEGIN:VCALENDAR
X-WR-CALNAME:Robot-U
BEGIN:VEVENT
SUMMARY:Robotics Club
DTSTART;TZID=America/New_York:{starts_at.strftime("%Y%m%dT%H%M%S")}
DTEND;TZID=America/New_York:{ends_at.strftime("%Y%m%dT%H%M%S")}
RRULE:FREQ=WEEKLY
LOCATION:44 Portland Street
END:VEVENT
END:VCALENDAR
"""
_calendar_name, events = _parse_ics(raw_calendar, "https://example.com/calendar.ics")
self.assertGreaterEqual(len(events), 1)
self.assertEqual(events[0].title, "Robotics Club")
self.assertGreaterEqual(events[0].starts_at, datetime.now(ZoneInfo("UTC")))
self.assertEqual(events[0].starts_at.tzinfo, timezone)
self.assertEqual(events[0].mode, "44 Portland Street")
if __name__ == "__main__":
unittest.main()