Complete Forgejo discussion MVP

This commit is contained in:
kacper 2026-04-13 18:19:50 -04:00
parent d84a885fdb
commit 51706d2d11
17 changed files with 1708 additions and 127 deletions

View file

@ -6,8 +6,11 @@ FORGEJO_TOKEN=
FORGEJO_OAUTH_CLIENT_ID= FORGEJO_OAUTH_CLIENT_ID=
FORGEJO_OAUTH_CLIENT_SECRET= FORGEJO_OAUTH_CLIENT_SECRET=
FORGEJO_OAUTH_SCOPES=openid profile FORGEJO_OAUTH_SCOPES=openid profile
FORGEJO_GENERAL_DISCUSSION_REPO=Robot-U/general_forum
FORGEJO_WEBHOOK_SECRET=
FORGEJO_REPO_SCAN_LIMIT=30 FORGEJO_REPO_SCAN_LIMIT=30
FORGEJO_RECENT_ISSUE_LIMIT=6 FORGEJO_RECENT_ISSUE_LIMIT=50
FORGEJO_CACHE_TTL_SECONDS=60.0
FORGEJO_REQUEST_TIMEOUT_SECONDS=10.0 FORGEJO_REQUEST_TIMEOUT_SECONDS=10.0
CALENDAR_FEED_URLS= CALENDAR_FEED_URLS=
CALENDAR_EVENT_LIMIT=3 CALENDAR_EVENT_LIMIT=3

View file

@ -21,6 +21,8 @@ It is a thin application layer over Forgejo:
- `app.py`: FastAPI app and SPA/static serving - `app.py`: FastAPI app and SPA/static serving
- `live_prototype.py`: live payload assembly for courses, lessons, discussions, and events - `live_prototype.py`: live payload assembly for courses, lessons, discussions, and events
- `prototype_cache.py`: server-side cache for the public Forgejo content payload
- `update_events.py`: in-process SSE broker for content update notifications
- `forgejo_client.py`: Forgejo API client - `forgejo_client.py`: Forgejo API client
- `calendar_feeds.py`: ICS/webcal feed loading and parsing - `calendar_feeds.py`: ICS/webcal feed loading and parsing
- `settings.py`: env-driven runtime settings - `settings.py`: env-driven runtime settings
@ -69,6 +71,9 @@ Useful variables:
- `FORGEJO_OAUTH_CLIENT_SECRET=...` - `FORGEJO_OAUTH_CLIENT_SECRET=...`
- `FORGEJO_OAUTH_SCOPES=openid profile` - `FORGEJO_OAUTH_SCOPES=openid profile`
- `FORGEJO_TOKEN=...` - `FORGEJO_TOKEN=...`
- `FORGEJO_GENERAL_DISCUSSION_REPO=Robot-U/general_forum`
- `FORGEJO_WEBHOOK_SECRET=...`
- `FORGEJO_CACHE_TTL_SECONDS=60.0`
- `CALENDAR_FEED_URLS=webcal://...` - `CALENDAR_FEED_URLS=webcal://...`
- `HOST=0.0.0.0` - `HOST=0.0.0.0`
- `PORT=8800` - `PORT=8800`
@ -76,8 +81,11 @@ Useful variables:
Notes: Notes:
- 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 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 in an encrypted `HttpOnly` cookie and may use it only after enforcing public-repository checks. - Browser OAuth requests only identity scopes. The backend stores the resulting Forgejo token in an encrypted `HttpOnly` cookie and may use it only after enforcing public-repository checks for writes.
- `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. - `FORGEJO_TOKEN` is optional and should be treated as a read-only local fallback for the public content cache. Browser sessions and API token calls may write issues/comments only after verifying the target repo is public.
- `/api/prototype` uses a server-side cache for public Forgejo content. `FORGEJO_CACHE_TTL_SECONDS=0` disables it; successful discussion replies invalidate it.
- General discussion creation requires `FORGEJO_GENERAL_DISCUSSION_REPO`. Linked discussions are created in the content repo and include canonical app URLs in the Forgejo issue body.
- Forgejo webhooks should POST to `/api/forgejo/webhook`; when `FORGEJO_WEBHOOK_SECRET` is set, the backend validates Forgejo/Gitea-style HMAC headers.
- API clients can query with `Authorization: token ...` or `Authorization: Bearer ...`. - API clients can query with `Authorization: token ...` or `Authorization: Bearer ...`.
- `CALENDAR_FEED_URLS` is optional and accepts comma-separated `webcal://` or `https://` ICS feeds. - `CALENDAR_FEED_URLS` is optional and accepts comma-separated `webcal://` or `https://` ICS feeds.
- Do not commit `.env` or `.env.local`. - Do not commit `.env` or `.env.local`.
@ -142,6 +150,7 @@ Run both before pushing:
- Each lesson folder is expected to contain one markdown file plus optional assets. - Each lesson folder is expected to contain one markdown file plus optional assets.
- Frontmatter is used when present for `title` and `summary`. - Frontmatter is used when present for `title` and `summary`.
- Discussions are loaded from Forgejo issues and comments. - Discussions are loaded from Forgejo issues and comments.
- Issue bodies are scanned for canonical post/lesson URLs and Forgejo file URLs to connect discussions back to content.
- Calendar events are loaded from ICS feeds, not managed in-app. - Calendar events are loaded from ICS feeds, not managed in-app.
## UI Expectations ## UI Expectations

View file

@ -44,6 +44,9 @@ export FORGEJO_BASE_URL="https://aksal.cloud"
export FORGEJO_OAUTH_CLIENT_ID="your-forgejo-oauth-client-id" export FORGEJO_OAUTH_CLIENT_ID="your-forgejo-oauth-client-id"
export FORGEJO_OAUTH_CLIENT_SECRET="your-forgejo-oauth-client-secret" export FORGEJO_OAUTH_CLIENT_SECRET="your-forgejo-oauth-client-secret"
export FORGEJO_OAUTH_SCOPES="openid profile" export FORGEJO_OAUTH_SCOPES="openid profile"
export FORGEJO_GENERAL_DISCUSSION_REPO="Robot-U/general_forum"
export FORGEJO_WEBHOOK_SECRET="shared-webhook-secret"
export FORGEJO_CACHE_TTL_SECONDS="60.0"
export CALENDAR_FEED_URLS="webcal://example.com/calendar.ics,https://example.com/other.ics" export CALENDAR_FEED_URLS="webcal://example.com/calendar.ics,https://example.com/other.ics"
``` ```
@ -55,7 +58,13 @@ http://kacper-dev-pod:8800/api/auth/forgejo/callback
`AUTH_SECRET_KEY` is required for Forgejo OAuth sign-in. It encrypts the `HttpOnly` browser session cookie that carries the signed-in user's Forgejo token and identity. Set `AUTH_COOKIE_SECURE=true` when serving over HTTPS. `AUTH_SECRET_KEY` is required for Forgejo OAuth sign-in. It encrypts the `HttpOnly` browser session cookie that carries the signed-in user's Forgejo token and identity. Set `AUTH_COOKIE_SECURE=true` when serving over HTTPS.
`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 from the encrypted session cookie for public repo reads and public issue replies. The backend must verify repositories are public before reading discussion data or writing comments. `FORGEJO_TOKEN` is optional. When set, it is a read fallback for the public content cache. Browser OAuth requests only identity scopes, then the backend uses the signed-in user's Forgejo identity from the encrypted session cookie for public discussion creation and replies. The backend must verify repositories are public before reading discussion data or writing issues/comments.
`FORGEJO_CACHE_TTL_SECONDS` controls how long the server reuses the public Forgejo content scan for `/api/prototype`. Set it to `0` to disable caching while debugging discovery behavior.
`FORGEJO_GENERAL_DISCUSSION_REPO` should point at the public `owner/repo` used for general discussion threads. Post- and lesson-linked discussions are created in the same repo as the content being discussed.
`FORGEJO_WEBHOOK_SECRET` is optional but recommended. Configure Forgejo webhooks to POST to `/api/forgejo/webhook`; matching webhook events invalidate the public content cache and notify open browsers over `/api/events/stream`.
Or put those values in `.env`: Or put those values in `.env`:

224
app.py
View file

@ -1,12 +1,14 @@
from __future__ import annotations from __future__ import annotations
import hmac
from hashlib import sha256
from pathlib import Path from pathlib import Path
from typing import Any from typing import Any
from urllib.parse import urlencode from urllib.parse import urlencode
from fastapi import Body, FastAPI, HTTPException, Request from fastapi import Body, FastAPI, HTTPException, Request
from fastapi.middleware.cors import CORSMiddleware from fastapi.middleware.cors import CORSMiddleware
from fastapi.responses import FileResponse, JSONResponse, RedirectResponse from fastapi.responses import FileResponse, JSONResponse, RedirectResponse, StreamingResponse
from fastapi.staticfiles import StaticFiles from fastapi.staticfiles import StaticFiles
from auth import ( from auth import (
@ -19,8 +21,10 @@ from auth import (
resolve_forgejo_token, resolve_forgejo_token,
) )
from forgejo_client import ForgejoClient, ForgejoClientError from forgejo_client import ForgejoClient, ForgejoClientError
from live_prototype import build_live_prototype_payload from live_prototype import discussion_card_from_issue
from prototype_cache import PrototypePayloadCache
from settings import Settings, get_settings from settings import Settings, get_settings
from update_events import UpdateBroker, stream_sse_events
BASE_DIR = Path(__file__).resolve().parent BASE_DIR = Path(__file__).resolve().parent
DIST_DIR = BASE_DIR / "frontend" / "dist" DIST_DIR = BASE_DIR / "frontend" / "dist"
@ -28,6 +32,8 @@ DIST_DIR = BASE_DIR / "frontend" / "dist"
def create_app() -> FastAPI: def create_app() -> FastAPI:
app = FastAPI(title="Robot U Community Prototype") app = FastAPI(title="Robot U Community Prototype")
prototype_cache = PrototypePayloadCache()
update_broker = UpdateBroker()
app.add_middleware( app.add_middleware(
CORSMiddleware, CORSMiddleware,
@ -45,14 +51,14 @@ def create_app() -> FastAPI:
settings = get_settings() settings = get_settings()
session_user = current_session_user(request, settings) session_user = current_session_user(request, settings)
forgejo_token, auth_source = resolve_forgejo_token(request, settings) forgejo_token, auth_source = resolve_forgejo_token(request, settings)
return JSONResponse( payload = await prototype_cache.get(settings)
await build_live_prototype_payload( payload["auth"] = await _auth_payload_for_request(
settings, settings,
forgejo_token=forgejo_token, forgejo_token=forgejo_token,
auth_source=auth_source, auth_source=auth_source,
session_user=session_user, session_user=session_user,
),
) )
return JSONResponse(payload)
@app.get("/api/auth/session") @app.get("/api/auth/session")
async def auth_session(request: Request) -> JSONResponse: async def auth_session(request: Request) -> JSONResponse:
@ -73,6 +79,28 @@ def create_app() -> FastAPI:
return JSONResponse(_auth_payload(user, auth_source)) return JSONResponse(_auth_payload(user, auth_source))
@app.get("/api/events/stream")
async def events_stream() -> StreamingResponse:
return StreamingResponse(
stream_sse_events(update_broker),
media_type="text/event-stream",
headers={
"Cache-Control": "no-store",
"X-Accel-Buffering": "no",
},
)
@app.post("/api/forgejo/webhook")
async def forgejo_webhook(request: Request) -> JSONResponse:
settings = get_settings()
body = await request.body()
if not _valid_webhook_signature(request, settings, body):
raise HTTPException(status_code=401, detail="Invalid Forgejo webhook signature.")
prototype_cache.invalidate()
await update_broker.publish("content-updated", {"reason": "forgejo-webhook"})
return JSONResponse({"status": "accepted"})
@app.get("/api/auth/forgejo/start") @app.get("/api/auth/forgejo/start")
async def forgejo_auth_start(request: Request, return_to: str = "/") -> RedirectResponse: async def forgejo_auth_start(request: Request, return_to: str = "/") -> RedirectResponse:
settings = get_settings() settings = get_settings()
@ -140,6 +168,31 @@ def create_app() -> FastAPI:
clear_login_session(request, response) clear_login_session(request, response)
return response return response
@app.get("/api/discussions/{owner}/{repo}/{issue_number}")
async def discussion_detail(owner: str, repo: str, issue_number: int) -> JSONResponse:
if issue_number < 1:
raise HTTPException(status_code=400, detail="issue_number must be positive.")
settings = get_settings()
async with ForgejoClient(settings, forgejo_token=settings.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 reads public Forgejo repositories.",
)
issue = await client.fetch_issue(owner, repo, issue_number)
comments = [
_discussion_reply(comment)
for comment in await client.list_issue_comments(owner, repo, issue_number)
]
except ForgejoClientError as error:
raise HTTPException(status_code=502, detail=str(error)) from error
issue["repository"] = _issue_repository_payload(repo_payload, owner, repo)
return JSONResponse(discussion_card_from_issue(issue, comments=comments))
@app.post("/api/discussions/replies") @app.post("/api/discussions/replies")
async def create_discussion_reply( async def create_discussion_reply(
request: Request, request: Request,
@ -169,8 +222,59 @@ def create_app() -> FastAPI:
except ForgejoClientError as error: except ForgejoClientError as error:
raise HTTPException(status_code=502, detail=str(error)) from error raise HTTPException(status_code=502, detail=str(error)) from error
prototype_cache.invalidate()
await update_broker.publish(
"content-updated",
{"reason": "discussion-reply", "repo": f"{owner}/{repo}", "number": issue_number},
)
return JSONResponse(_discussion_reply(comment)) return JSONResponse(_discussion_reply(comment))
@app.post("/api/discussions")
async def create_discussion(
request: Request,
payload: dict[str, object] = Body(...),
) -> JSONResponse:
title = _required_string(payload, "title")
body = _required_string(payload, "body")
owner, repo = _discussion_target(payload, get_settings())
context_url = _optional_string(payload, "context_url")
context_title = _optional_string(payload, "context_title")
context_path = _optional_string(payload, "context_path")
issue_body = _discussion_issue_body(
body,
context_url=context_url,
context_title=context_title,
context_path=context_path,
)
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 starting a discussion.",
)
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.",
)
issue = await client.create_issue(owner, repo, title, issue_body)
except ForgejoClientError as error:
raise HTTPException(status_code=502, detail=str(error)) from error
issue["repository"] = _issue_repository_payload(repo_payload, owner, repo)
prototype_cache.invalidate()
await update_broker.publish(
"content-updated",
{"reason": "discussion-created", "repo": f"{owner}/{repo}"},
)
return JSONResponse(discussion_card_from_issue(issue, comments=[]))
if DIST_DIR.exists(): if DIST_DIR.exists():
assets_dir = DIST_DIR / "assets" assets_dir = DIST_DIR / "assets"
if assets_dir.exists(): if assets_dir.exists():
@ -201,6 +305,16 @@ def _required_string(payload: dict[str, object], key: str) -> str:
return value.strip() return value.strip()
def _optional_string(payload: dict[str, object], key: str) -> str | None:
value = payload.get(key)
if value is None:
return None
if not isinstance(value, str):
raise HTTPException(status_code=400, detail=f"{key} must be a string.")
stripped = value.strip()
return stripped or None
def _required_positive_int(payload: dict[str, object], key: str) -> int: def _required_positive_int(payload: dict[str, object], key: str) -> int:
value = payload.get(key) value = payload.get(key)
if isinstance(value, bool): if isinstance(value, bool):
@ -218,6 +332,62 @@ def _required_positive_int(payload: dict[str, object], key: str) -> int:
return parsed return parsed
def _discussion_target(payload: dict[str, object], settings: Settings) -> tuple[str, str]:
owner = _optional_string(payload, "owner")
repo = _optional_string(payload, "repo")
if owner and repo:
return owner, repo
if owner or repo:
raise HTTPException(status_code=400, detail="owner and repo must be provided together.")
configured_repo = settings.forgejo_general_discussion_repo
if configured_repo:
parts = [part for part in configured_repo.strip().split("/", 1) if part]
if len(parts) == 2:
return parts[0], parts[1]
raise HTTPException(
status_code=400,
detail="General discussion repo is not configured.",
)
def _discussion_issue_body(
body: str,
*,
context_url: str | None,
context_title: str | None,
context_path: str | None,
) -> str:
related_lines: list[str] = []
if context_title:
related_lines.append(f"Related content: {context_title}")
if context_url:
related_lines.append(f"Canonical URL: {context_url}")
if context_path:
related_lines.append(f"Source path: {context_path}")
if not related_lines:
return body
return f"{body}\n\n---\n" + "\n".join(related_lines)
def _issue_repository_payload(
repo_payload: dict[str, Any],
owner: str,
repo: str,
) -> dict[str, object]:
repo_owner = repo_payload.get("owner") or {}
owner_login = repo_owner.get("login") if isinstance(repo_owner, dict) else owner
return {
"owner": owner_login or owner,
"name": repo_payload.get("name") or repo,
"full_name": repo_payload.get("full_name") or f"{owner}/{repo}",
"private": False,
}
def _discussion_reply(comment: dict[str, Any]) -> dict[str, object]: def _discussion_reply(comment: dict[str, Any]) -> dict[str, object]:
author = comment.get("user") or {} author = comment.get("user") or {}
body = str(comment.get("body", "") or "").strip() body = str(comment.get("body", "") or "").strip()
@ -254,6 +424,42 @@ def _auth_payload(user: dict[str, Any] | None, source: str) -> dict[str, object]
} }
async def _auth_payload_for_request(
settings: Settings,
*,
forgejo_token: str | None,
auth_source: str,
session_user: dict[str, Any] | None,
) -> dict[str, object]:
if session_user:
return _auth_payload(session_user, "session")
if not forgejo_token or auth_source == "server":
return _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 _auth_payload(user, auth_source)
def _valid_webhook_signature(request: Request, settings: Settings, body: bytes) -> bool:
secret = settings.forgejo_webhook_secret
if not secret:
return True
expected = hmac.new(secret.encode("utf-8"), body, sha256).hexdigest()
candidates = [
request.headers.get("x-forgejo-signature", ""),
request.headers.get("x-gitea-signature", ""),
request.headers.get("x-hub-signature-256", "").removeprefix("sha256="),
]
return any(hmac.compare_digest(expected, candidate.strip()) for candidate in candidates)
def _oauth_configured(settings: Settings) -> bool: def _oauth_configured(settings: Settings) -> bool:
return bool( return bool(
settings.auth_secret_key settings.auth_secret_key

View file

@ -80,7 +80,6 @@ The app should own only the state that does not naturally belong in Forgejo, suc
- Auto-discovery of eligible public repos - Auto-discovery of eligible public repos
- Rendering markdown content from Forgejo repos - Rendering markdown content from Forgejo repos
- Discussion creation and replies from this app, backed by Forgejo issues/comments - Discussion creation and replies from this app, backed by Forgejo issues/comments
- Per-user lesson completion tracking
- Team-derived admin/moderator permissions - Team-derived admin/moderator permissions
- Webhook-driven sync from Forgejo - Webhook-driven sync from Forgejo
- SSE updates for open sessions when content/discussions change - SSE updates for open sessions when content/discussions change
@ -92,6 +91,7 @@ The app should own only the state that does not naturally belong in Forgejo, suc
- Non-Forgejo authentication providers - Non-Forgejo authentication providers
- Search - Search
- Rich public profiles - Rich public profiles
- Per-user lesson completion tracking
- Private repo indexing - Private repo indexing
- Admin-created calendar events in this app - Admin-created calendar events in this app
- Review or approval workflow before publishing - Review or approval workflow before publishing
@ -108,7 +108,7 @@ The app should own only the state that does not naturally belong in Forgejo, suc
### Signed-In Member ### Signed-In Member
1. Signs in with Forgejo 1. Signs in with Forgejo
2. Reads lessons and marks them complete 2. Reads lessons
3. Creates general discussion threads 3. Creates general discussion threads
4. Creates post- or lesson-linked discussion threads from content pages 4. Creates post- or lesson-linked discussion threads from content pages
5. Replies, edits, and otherwise interacts through the app UI 5. Replies, edits, and otherwise interacts through the app UI
@ -131,7 +131,7 @@ The app should own only the state that does not naturally belong in Forgejo, suc
| Content assets / downloads | Forgejo repos | | Content assets / downloads | Forgejo repos |
| Discussions / comments | Forgejo issues and issue comments | | Discussions / comments | Forgejo issues and issue comments |
| Events | External ICS feeds | | Events | External ICS feeds |
| Lesson progress | App database | | Lesson progress | Post-MVP app database |
| Sessions | App backend | | Sessions | App backend |
| Cached index metadata | App database | | Cached index metadata | App database |
@ -147,7 +147,6 @@ The app should own only the state that does not naturally belong in Forgejo, suc
### Authenticated Capabilities ### Authenticated Capabilities
- Mark lesson complete
- Create discussion threads - Create discussion threads
- Reply to discussions - Reply to discussions
- Edit supported discussion content through the app - Edit supported discussion content through the app
@ -359,9 +358,9 @@ Events should continue to be managed in external calendar tools.
## 15. Progress Tracking ## 15. Progress Tracking
The app should track lesson progress per signed-in user. Lesson progress is no longer part of the MVP.
MVP state model: Post-MVP state model:
- not started - not started
- completed - completed
@ -435,6 +434,7 @@ The following are explicitly out of scope for MVP:
- browser-based asset uploads - browser-based asset uploads
- search - search
- rich user profiles - rich user profiles
- per-user lesson completion tracking
- non-Forgejo auth providers - non-Forgejo auth providers
- private repo support - private repo support
- complex moderation workflows - complex moderation workflows

View file

@ -83,17 +83,32 @@ class ForgejoClient:
return await self._get_json("/api/v1/user", auth_required=True) return await self._get_json("/api/v1/user", auth_required=True)
async def search_repositories(self) -> list[dict[str, Any]]: async def search_repositories(self) -> list[dict[str, Any]]:
payload = await self._get_json( scan_limit = max(self._settings.forgejo_repo_scan_limit, 1)
"/api/v1/repos/search", page_limit = min(scan_limit, 50)
params={ repos: list[dict[str, Any]] = []
"limit": self._settings.forgejo_repo_scan_limit, page = 1
"page": 1,
"private": "false", while len(repos) < scan_limit:
"is_private": "false", payload = await self._get_json(
}, "/api/v1/repos/search",
) params={
data = payload.get("data", []) "limit": page_limit,
return [repo for repo in data if isinstance(repo, dict)] "page": page,
"private": "false",
"is_private": "false",
},
)
if not isinstance(payload, dict):
break
data = payload.get("data", [])
page_repos = [repo for repo in data if isinstance(repo, dict)]
repos.extend(page_repos)
if len(page_repos) < page_limit:
break
page += 1
return repos[:scan_limit]
async def fetch_repository(self, owner: str, repo: str) -> dict[str, Any]: async def fetch_repository(self, owner: str, repo: str) -> dict[str, Any]:
payload = await self._get_json( payload = await self._get_json(
@ -124,6 +139,12 @@ class ForgejoClient:
return [issue for issue in payload if isinstance(issue, dict)] return [issue for issue in payload if isinstance(issue, dict)]
return [] return []
async def fetch_issue(self, owner: str, repo: str, issue_number: int) -> dict[str, Any]:
payload = await self._get_json(f"/api/v1/repos/{owner}/{repo}/issues/{issue_number}")
if isinstance(payload, dict):
return payload
raise ForgejoClientError(f"Unexpected issue payload for {owner}/{repo}#{issue_number}")
async def list_directory(self, owner: str, repo: str, path: str = "") -> list[dict[str, Any]]: async def list_directory(self, owner: str, repo: str, path: str = "") -> list[dict[str, Any]]:
endpoint = f"/api/v1/repos/{owner}/{repo}/contents" endpoint = f"/api/v1/repos/{owner}/{repo}/contents"
if path: if path:
@ -166,6 +187,23 @@ class ForgejoClient:
return payload return payload
raise ForgejoClientError(f"Unexpected comment payload for {owner}/{repo}#{issue_number}") raise ForgejoClientError(f"Unexpected comment payload for {owner}/{repo}#{issue_number}")
async def create_issue(
self,
owner: str,
repo: str,
title: str,
body: str,
) -> dict[str, Any]:
payload = await self._request_json(
"POST",
f"/api/v1/repos/{owner}/{repo}/issues",
json_payload={"title": title, "body": body},
auth_required=True,
)
if isinstance(payload, dict):
return payload
raise ForgejoClientError(f"Unexpected issue payload for {owner}/{repo}")
async def get_file_content(self, owner: str, repo: str, path: str) -> dict[str, str]: async def get_file_content(self, owner: str, repo: str, path: str) -> dict[str, str]:
payload = await self._get_json( payload = await self._get_json(
f"/api/v1/repos/{owner}/{repo}/contents/{path.strip('/')}", f"/api/v1/repos/{owner}/{repo}/contents/{path.strip('/')}",

View file

@ -3,6 +3,7 @@ import { useEffect, useState } from "preact/hooks";
import { MarkdownContent, stripLeadingTitleHeading } from "./MarkdownContent"; import { MarkdownContent, stripLeadingTitleHeading } from "./MarkdownContent";
import type { import type {
AuthState, AuthState,
ContentAsset,
CourseCard, CourseCard,
CourseChapter, CourseChapter,
CourseLesson, CourseLesson,
@ -49,6 +50,26 @@ function parseDiscussionRoute(pathname: string): number | null {
return Number.isFinite(issueId) ? issueId : null; return Number.isFinite(issueId) ? issueId : null;
} }
function parseDiscussionRepoRoute(
pathname: string,
): { owner: string; repo: string; number: number } | null {
const match = normalizePathname(pathname).match(/^\/discussions\/([^/]+)\/([^/]+)\/(\d+)$/);
if (!match) {
return null;
}
const issueNumber = Number(match[3]);
if (!Number.isFinite(issueNumber)) {
return null;
}
return {
owner: decodeURIComponent(match[1]),
repo: decodeURIComponent(match[2]),
number: issueNumber,
};
}
function isSignInRoute(pathname: string): boolean { function isSignInRoute(pathname: string): boolean {
return normalizePathname(pathname) === "/signin"; return normalizePathname(pathname) === "/signin";
} }
@ -179,6 +200,17 @@ function findLessonByRoute(
return undefined; return undefined;
} }
function findDiscussionByRoute(
discussions: DiscussionCard[],
route: { owner: string; repo: string; number: number },
): DiscussionCard | undefined {
const routeRepo = `${normalizeRouteKey(route.owner)}/${normalizeRouteKey(route.repo)}`;
return discussions.find(
(discussion) =>
normalizeRouteKey(discussion.repo) === routeRepo && discussion.number === route.number,
);
}
function usePathname() { function usePathname() {
const [pathname, setPathname] = useState(() => normalizePathname(window.location.pathname)); const [pathname, setPathname] = useState(() => normalizePathname(window.location.pathname));
@ -424,7 +456,7 @@ function EventItem(props: { event: EventCard }) {
function DiscussionPreviewItem(props: { function DiscussionPreviewItem(props: {
discussion: DiscussionCard; discussion: DiscussionCard;
onOpenDiscussion: (id: number) => void; onOpenDiscussion: (discussion: DiscussionCard) => void;
}) { }) {
const { discussion, onOpenDiscussion } = props; const { discussion, onOpenDiscussion } = props;
@ -433,7 +465,7 @@ function DiscussionPreviewItem(props: {
type="button" type="button"
className="discussion-preview-card" className="discussion-preview-card"
onClick={() => { onClick={() => {
onOpenDiscussion(discussion.id); onOpenDiscussion(discussion);
}} }}
> >
<h3>{discussion.title}</h3> <h3>{discussion.title}</h3>
@ -500,6 +532,42 @@ async function postDiscussionReply(
return (await response.json()) as DiscussionReply; return (await response.json()) as DiscussionReply;
} }
interface DiscussionCreateContext {
owner?: string;
repo?: string;
contextPath?: string;
contextUrl?: string;
contextTitle?: string;
}
async function postDiscussion(
title: string,
body: string,
context?: DiscussionCreateContext,
): Promise<DiscussionCard> {
const response = await fetch("/api/discussions", {
method: "POST",
headers: {
"Content-Type": "application/json",
},
body: JSON.stringify({
title,
body,
owner: context?.owner,
repo: context?.repo,
context_path: context?.contextPath,
context_url: context?.contextUrl,
context_title: context?.contextTitle,
}),
});
if (!response.ok) {
throw await responseError(response, `Discussion creation failed with ${response.status}`);
}
return (await response.json()) as DiscussionCard;
}
function ComposeBox(props: { function ComposeBox(props: {
discussion: DiscussionCard; discussion: DiscussionCard;
onReplyCreated: (discussionId: number, reply: DiscussionReply) => void; onReplyCreated: (discussionId: number, reply: DiscussionReply) => void;
@ -578,6 +646,193 @@ function ComposeBox(props: {
); );
} }
function DiscussionCreateBox(props: {
auth: AuthState;
context?: DiscussionCreateContext;
titlePlaceholder: string;
bodyPlaceholder: string;
submitLabel: string;
onGoSignIn: () => void;
onDiscussionCreated: (discussion: DiscussionCard) => void;
}) {
const {
auth,
context,
titlePlaceholder,
bodyPlaceholder,
submitLabel,
onGoSignIn,
onDiscussionCreated,
} = props;
const [title, setTitle] = useState("");
const [body, setBody] = useState("");
const [error, setError] = useState<string | null>(null);
const [isSubmitting, setIsSubmitting] = useState(false);
const trimmedTitle = title.trim();
const trimmedBody = body.trim();
const canCreate = canUseInteractiveAuth(auth);
async function submitDiscussion(event: SubmitEvent) {
event.preventDefault();
if (!canCreate) {
setError("Sign in before starting a discussion.");
return;
}
if (!trimmedTitle || !trimmedBody || isSubmitting) {
return;
}
setError(null);
setIsSubmitting(true);
try {
const discussion = await postDiscussion(trimmedTitle, trimmedBody, context);
setTitle("");
setBody("");
onDiscussionCreated(discussion);
} catch (createError) {
const message =
createError instanceof Error ? createError.message : "Discussion could not be created.";
setError(message);
} finally {
setIsSubmitting(false);
}
}
return (
<form className="compose-box" onSubmit={submitDiscussion}>
{!canCreate ? (
<div className="signin-callout">
<p>
{auth.authenticated
? "Discussion creation is unavailable for this session."
: "Sign in before starting a discussion."}
</p>
{!auth.authenticated ? (
<button type="button" className="secondary-button" onClick={onGoSignIn}>
Sign in
</button>
) : null}
</div>
) : null}
<input
className="token-input"
placeholder={titlePlaceholder}
value={title}
disabled={!canCreate}
onInput={(event) => {
setTitle(event.currentTarget.value);
}}
/>
<textarea
className="compose-input"
placeholder={bodyPlaceholder}
value={body}
disabled={!canCreate}
onInput={(event) => {
setBody(event.currentTarget.value);
}}
/>
<div className="compose-actions">
<button
type="submit"
className="compose-button"
disabled={!canCreate || !trimmedTitle || !trimmedBody || isSubmitting}
>
{isSubmitting ? "Creating..." : submitLabel}
</button>
</div>
{error ? <p className="compose-error">{error}</p> : null}
</form>
);
}
function AssetsPanel(props: { assets: ContentAsset[] }) {
if (props.assets.length === 0) {
return null;
}
return (
<article className="panel">
<header className="subsection-header">
<h2>Downloads</h2>
</header>
<div className="asset-list">
{props.assets.map((asset) => (
<a
key={asset.path}
className="asset-link"
href={asset.download_url || asset.html_url}
target="_blank"
rel="noreferrer"
>
<span>{asset.name}</span>
<span className="meta-line">{asset.path}</span>
</a>
))}
</div>
</article>
);
}
function lessonPath(course: CourseCard, chapter: CourseChapter, lesson: CourseLesson): string {
return `/courses/${encodeURIComponent(course.owner)}/${encodeURIComponent(course.name)}/lessons/${encodeURIComponent(chapter.slug)}/${encodeURIComponent(lesson.slug)}`;
}
function absoluteSiteUrl(path: string): string {
return new URL(path, window.location.origin).toString();
}
function discussionMatchesContent(
discussion: DiscussionCard,
routePath: string,
contentPath: string,
): boolean {
const normalizedRoute = normalizeRouteKey(routePath);
const normalizedContentPath = normalizeRouteKey(contentPath);
return (discussion.links || []).some((link) => {
const linkPath = normalizeRouteKey(link.path || "");
const linkContentPath = normalizeRouteKey(link.content_path || "");
return (
linkPath === normalizedRoute ||
linkContentPath === normalizedContentPath ||
normalizedContentPath.startsWith(`${linkContentPath}/`)
);
});
}
function RelatedDiscussionsPanel(props: {
discussions: DiscussionCard[];
routePath: string;
contentPath: string;
onOpenDiscussion: (discussion: DiscussionCard) => void;
}) {
const relatedDiscussions = props.discussions.filter((discussion) =>
discussionMatchesContent(discussion, props.routePath, props.contentPath),
);
return (
<article className="panel">
<header className="subsection-header">
<h2>Related discussions</h2>
<p className="meta-line">{relatedDiscussions.length}</p>
</header>
{relatedDiscussions.length > 0 ? (
<div className="reply-list">
{relatedDiscussions.map((discussion) => (
<DiscussionPreviewItem
key={discussion.id}
discussion={discussion}
onOpenDiscussion={props.onOpenDiscussion}
/>
))}
</div>
) : (
<EmptyState copy="No linked discussions yet." />
)}
</article>
);
}
function CoursePage(props: { function CoursePage(props: {
course: CourseCard; course: CourseCard;
onGoHome: () => void; onGoHome: () => void;
@ -644,10 +899,26 @@ function LessonPage(props: {
course: CourseCard; course: CourseCard;
chapter: CourseChapter; chapter: CourseChapter;
lesson: CourseLesson; lesson: CourseLesson;
auth: AuthState;
discussions: DiscussionCard[];
onGoCourse: () => void; onGoCourse: () => void;
onGoSignIn: () => void;
onOpenDiscussion: (discussion: DiscussionCard) => void;
onDiscussionCreated: (discussion: DiscussionCard) => void;
}) { }) {
const { course, chapter, lesson, onGoCourse } = props; const {
course,
chapter,
lesson,
auth,
discussions,
onGoCourse,
onGoSignIn,
onOpenDiscussion,
onDiscussionCreated,
} = props;
const lessonBody = stripLeadingTitleHeading(lesson.body, lesson.title); const lessonBody = stripLeadingTitleHeading(lesson.body, lesson.title);
const routePath = lessonPath(course, chapter, lesson);
return ( return (
<section className="thread-view"> <section className="thread-view">
@ -670,18 +941,60 @@ function LessonPage(props: {
<h2>Lesson</h2> <h2>Lesson</h2>
</header> </header>
{lessonBody ? ( {lessonBody ? (
<MarkdownContent markdown={lessonBody} className="lesson-body" /> <MarkdownContent
markdown={lessonBody}
className="lesson-body"
baseUrl={lesson.raw_base_url}
/>
) : ( ) : (
<EmptyState copy="This lesson file is empty or could not be read from Forgejo." /> <EmptyState copy="This lesson file is empty or could not be read from Forgejo." />
)} )}
</article> </article>
<AssetsPanel assets={lesson.assets} />
<RelatedDiscussionsPanel
discussions={discussions}
routePath={routePath}
contentPath={lesson.path}
onOpenDiscussion={onOpenDiscussion}
/>
<article className="panel">
<header className="subsection-header">
<h2>Start a lesson discussion</h2>
</header>
<DiscussionCreateBox
auth={auth}
context={{
owner: course.owner,
repo: course.name,
contextPath: lesson.file_path || lesson.path,
contextUrl: absoluteSiteUrl(routePath),
contextTitle: lesson.title,
}}
titlePlaceholder="What should this lesson discussion be called?"
bodyPlaceholder="Describe the blocker, question, or project note."
submitLabel="Start discussion"
onGoSignIn={onGoSignIn}
onDiscussionCreated={onDiscussionCreated}
/>
</article>
</section> </section>
); );
} }
function PostPage(props: { post: PostCard }) { function PostPage(props: {
const { post } = props; post: PostCard;
auth: AuthState;
discussions: DiscussionCard[];
onGoSignIn: () => void;
onOpenDiscussion: (discussion: DiscussionCard) => void;
onDiscussionCreated: (discussion: DiscussionCard) => void;
}) {
const { post, auth, discussions, onGoSignIn, onOpenDiscussion, onDiscussionCreated } = props;
const postBody = stripLeadingTitleHeading(post.body, post.title); const postBody = stripLeadingTitleHeading(post.body, post.title);
const routePath = postPath(post);
return ( return (
<section className="thread-view"> <section className="thread-view">
@ -700,11 +1013,45 @@ function PostPage(props: { post: PostCard }) {
<h2>Post</h2> <h2>Post</h2>
</header> </header>
{postBody ? ( {postBody ? (
<MarkdownContent markdown={postBody} className="thread-copy" /> <MarkdownContent
markdown={postBody}
className="thread-copy"
baseUrl={post.raw_base_url}
/>
) : ( ) : (
<EmptyState copy="This post file is empty or could not be read from Forgejo." /> <EmptyState copy="This post file is empty or could not be read from Forgejo." />
)} )}
</article> </article>
<AssetsPanel assets={post.assets} />
<RelatedDiscussionsPanel
discussions={discussions}
routePath={routePath}
contentPath={post.path}
onOpenDiscussion={onOpenDiscussion}
/>
<article className="panel">
<header className="subsection-header">
<h2>Start a post discussion</h2>
</header>
<DiscussionCreateBox
auth={auth}
context={{
owner: post.owner,
repo: post.name,
contextPath: post.file_path || post.path,
contextUrl: absoluteSiteUrl(routePath),
contextTitle: post.title,
}}
titlePlaceholder="What should this post discussion be called?"
bodyPlaceholder="Share a question, project note, or blocker."
submitLabel="Start discussion"
onGoSignIn={onGoSignIn}
onDiscussionCreated={onDiscussionCreated}
/>
</article>
</section> </section>
); );
} }
@ -785,12 +1132,42 @@ function CoursesView(props: { data: PrototypeData; onOpenCourse: (course: Course
); );
} }
function DiscussionsView(props: { data: PrototypeData; onOpenDiscussion: (id: number) => void }) { function DiscussionsView(props: {
const { data, onOpenDiscussion } = props; data: PrototypeData;
onOpenDiscussion: (discussion: DiscussionCard) => void;
onGoSignIn: () => void;
onDiscussionCreated: (discussion: DiscussionCard) => void;
showComposer?: boolean;
}) {
const { data, onOpenDiscussion, onGoSignIn, onDiscussionCreated, showComposer = true } = props;
const generalDiscussionConfigured =
data.discussion_settings?.general_discussion_configured ?? false;
return ( return (
<section className="page-section"> <section className="page-section">
<SectionHeader title="Discussions" /> <SectionHeader title="Discussions" />
{showComposer && generalDiscussionConfigured ? (
<article className="discussion-create-panel">
<header className="subsection-header">
<h2>Start a discussion</h2>
</header>
<DiscussionCreateBox
auth={data.auth}
titlePlaceholder="What should the discussion be called?"
bodyPlaceholder="Share a project update, blocker, or question."
submitLabel="Start discussion"
onGoSignIn={onGoSignIn}
onDiscussionCreated={onDiscussionCreated}
/>
</article>
) : null}
{showComposer && !generalDiscussionConfigured ? (
<article className="discussion-create-panel">
<p className="muted-copy">
General discussion creation needs a configured public org repo.
</p>
</article>
) : null}
{data.recent_discussions.length > 0 ? ( {data.recent_discussions.length > 0 ? (
<div className="stack"> <div className="stack">
{data.recent_discussions.map((discussion) => ( {data.recent_discussions.map((discussion) => (
@ -812,9 +1189,12 @@ function HomeView(props: {
data: PrototypeData; data: PrototypeData;
onOpenCourse: (course: CourseCard) => void; onOpenCourse: (course: CourseCard) => void;
onOpenPost: (post: PostCard) => void; onOpenPost: (post: PostCard) => void;
onOpenDiscussion: (id: number) => void; onOpenDiscussion: (discussion: DiscussionCard) => void;
onGoSignIn: () => void;
onDiscussionCreated: (discussion: DiscussionCard) => void;
}) { }) {
const { data, onOpenCourse, onOpenPost, onOpenDiscussion } = props; const { data, onOpenCourse, onOpenPost, onOpenDiscussion, onGoSignIn, onDiscussionCreated } =
props;
return ( return (
<> <>
@ -856,7 +1236,13 @@ function HomeView(props: {
</div> </div>
</section> </section>
<DiscussionsView data={data} onOpenDiscussion={onOpenDiscussion} /> <DiscussionsView
data={data}
onOpenDiscussion={onOpenDiscussion}
onGoSignIn={onGoSignIn}
onDiscussionCreated={onDiscussionCreated}
showComposer={false}
/>
</> </>
); );
} }
@ -912,6 +1298,22 @@ async function fetchPrototypeData(signal?: AbortSignal): Promise<PrototypeData>
return (await response.json()) as PrototypeData; return (await response.json()) as PrototypeData;
} }
async function fetchDiscussionDetail(
route: { owner: string; repo: string; number: number },
signal?: AbortSignal,
): Promise<DiscussionCard> {
const response = await fetch(
`/api/discussions/${encodeURIComponent(route.owner)}/${encodeURIComponent(route.repo)}/${route.number}`,
{ signal },
);
if (!response.ok) {
throw new Error(`Discussion request failed with ${response.status}`);
}
return (await response.json()) as DiscussionCard;
}
function appendDiscussionReply( function appendDiscussionReply(
currentData: PrototypeData | null, currentData: PrototypeData | null,
discussionId: number, discussionId: number,
@ -938,6 +1340,23 @@ function appendDiscussionReply(
}; };
} }
function prependDiscussion(
currentData: PrototypeData | null,
discussion: DiscussionCard,
): PrototypeData | null {
if (!currentData) {
return currentData;
}
return {
...currentData,
recent_discussions: [
discussion,
...currentData.recent_discussions.filter((entry) => entry.id !== discussion.id),
],
};
}
interface ActivityEntry { interface ActivityEntry {
id: string; id: string;
title: string; title: string;
@ -959,6 +1378,15 @@ function postPath(post: PostCard): string {
return `/posts/${encodeURIComponent(post.owner)}/${encodeURIComponent(post.name)}/${encodeURIComponent(post.slug)}`; return `/posts/${encodeURIComponent(post.owner)}/${encodeURIComponent(post.name)}/${encodeURIComponent(post.slug)}`;
} }
function discussionPath(discussion: DiscussionCard): string {
const repo = repoParts(discussion.repo);
if (!repo || discussion.number < 1) {
return `/discussions/${discussion.id}`;
}
return `/discussions/${encodeURIComponent(repo.owner)}/${encodeURIComponent(repo.repo)}/${discussion.number}`;
}
function buildActivityFeed(data: PrototypeData): ActivityEntry[] { function buildActivityFeed(data: PrototypeData): ActivityEntry[] {
const activities: ActivityEntry[] = []; const activities: ActivityEntry[] = [];
@ -992,7 +1420,7 @@ function buildActivityFeed(data: PrototypeData): ActivityEntry[] {
title: `Discussion updated: ${discussion.title}`, title: `Discussion updated: ${discussion.title}`,
detail: `${discussion.repo} · ${discussion.replies} replies`, detail: `${discussion.repo} · ${discussion.replies} replies`,
timestamp: discussion.updated_at, timestamp: discussion.updated_at,
route: `/discussions/${discussion.id}`, route: discussionPath(discussion),
}); });
for (const reply of discussion.comments) { for (const reply of discussion.comments) {
@ -1001,7 +1429,7 @@ function buildActivityFeed(data: PrototypeData): ActivityEntry[] {
title: `${reply.author} replied`, title: `${reply.author} replied`,
detail: discussion.title, detail: discussion.title,
timestamp: reply.created_at, timestamp: reply.created_at,
route: `/discussions/${discussion.id}`, route: discussionPath(discussion),
}); });
} }
} }
@ -1066,10 +1494,11 @@ interface AppContentProps {
onOpenCourse: (course: CourseCard) => void; onOpenCourse: (course: CourseCard) => void;
onOpenPost: (post: PostCard) => void; onOpenPost: (post: PostCard) => void;
onOpenLesson: (course: CourseCard, chapter: CourseChapter, lesson: CourseLesson) => void; onOpenLesson: (course: CourseCard, chapter: CourseChapter, lesson: CourseLesson) => void;
onOpenDiscussion: (id: number) => void; onOpenDiscussion: (discussion: DiscussionCard) => void;
onOpenRoute: (route: string) => void; onOpenRoute: (route: string) => void;
onGoSignIn: () => void; onGoSignIn: () => void;
onReplyCreated: (discussionId: number, reply: DiscussionReply) => void; onReplyCreated: (discussionId: number, reply: DiscussionReply) => void;
onDiscussionCreated: (discussion: DiscussionCard) => void;
onGoHome: () => void; onGoHome: () => void;
onGoCourses: () => void; onGoCourses: () => void;
onGoDiscussions: () => void; onGoDiscussions: () => void;
@ -1087,7 +1516,16 @@ function PostRouteView(
); );
} }
return <PostPage post={selectedPost} />; return (
<PostPage
post={selectedPost}
auth={props.data.auth}
discussions={props.data.recent_discussions}
onGoSignIn={props.onGoSignIn}
onOpenDiscussion={props.onOpenDiscussion}
onDiscussionCreated={props.onDiscussionCreated}
/>
);
} }
function LessonRouteView( function LessonRouteView(
@ -1130,9 +1568,14 @@ function LessonRouteView(
course={selectedCourse} course={selectedCourse}
chapter={selectedLesson.chapter} chapter={selectedLesson.chapter}
lesson={selectedLesson.lesson} lesson={selectedLesson.lesson}
auth={props.data.auth}
discussions={props.data.recent_discussions}
onGoCourse={() => { onGoCourse={() => {
props.onOpenCourse(selectedCourse); props.onOpenCourse(selectedCourse);
}} }}
onGoSignIn={props.onGoSignIn}
onOpenDiscussion={props.onOpenDiscussion}
onDiscussionCreated={props.onDiscussionCreated}
/> />
); );
} }
@ -1185,6 +1628,66 @@ function DiscussionRouteView(props: AppContentProps & { discussionId: number })
); );
} }
function DiscussionRepoRouteView(
props: AppContentProps & { route: { owner: string; repo: string; number: number } },
) {
const indexedDiscussion = findDiscussionByRoute(props.data.recent_discussions, props.route);
const [loadedDiscussion, setLoadedDiscussion] = useState<DiscussionCard | null>(null);
const [error, setError] = useState<string | null>(null);
const selectedDiscussion = indexedDiscussion || loadedDiscussion;
useEffect(() => {
if (indexedDiscussion) {
setLoadedDiscussion(null);
setError(null);
return;
}
const controller = new AbortController();
fetchDiscussionDetail(props.route, controller.signal)
.then((discussion) => {
setLoadedDiscussion(discussion);
setError(null);
})
.catch((fetchError) => {
if (controller.signal.aborted) {
return;
}
setError(fetchError instanceof Error ? fetchError.message : "Discussion did not load.");
});
return () => {
controller.abort();
};
}, [indexedDiscussion, props.route.owner, props.route.repo, props.route.number]);
if (error) {
return (
<section className="page-message">
<h1>Discussion did not load.</h1>
<p className="muted-copy">{error}</p>
</section>
);
}
if (!selectedDiscussion) {
return (
<section className="page-message">
<h1>Loading discussion.</h1>
</section>
);
}
return (
<DiscussionPage
discussion={selectedDiscussion}
auth={props.data.auth}
onGoHome={props.onGoDiscussions}
onGoSignIn={props.onGoSignIn}
onReplyCreated={props.onReplyCreated}
/>
);
}
function AppContent(props: AppContentProps) { function AppContent(props: AppContentProps) {
if (isSignInRoute(props.pathname)) { if (isSignInRoute(props.pathname)) {
return <SignInPage auth={props.data.auth} />; return <SignInPage auth={props.data.auth} />;
@ -1199,7 +1702,19 @@ function AppContent(props: AppContentProps) {
} }
if (isDiscussionsIndexRoute(props.pathname)) { if (isDiscussionsIndexRoute(props.pathname)) {
return <DiscussionsView data={props.data} onOpenDiscussion={props.onOpenDiscussion} />; return (
<DiscussionsView
data={props.data}
onOpenDiscussion={props.onOpenDiscussion}
onGoSignIn={props.onGoSignIn}
onDiscussionCreated={props.onDiscussionCreated}
/>
);
}
const discussionRepoRoute = parseDiscussionRepoRoute(props.pathname);
if (discussionRepoRoute !== null) {
return <DiscussionRepoRouteView {...props} route={discussionRepoRoute} />;
} }
const postRoute = parsePostRoute(props.pathname); const postRoute = parsePostRoute(props.pathname);
@ -1225,6 +1740,8 @@ function AppContent(props: AppContentProps) {
onOpenCourse={props.onOpenCourse} onOpenCourse={props.onOpenCourse}
onOpenPost={props.onOpenPost} onOpenPost={props.onOpenPost}
onOpenDiscussion={props.onOpenDiscussion} onOpenDiscussion={props.onOpenDiscussion}
onGoSignIn={props.onGoSignIn}
onDiscussionCreated={props.onDiscussionCreated}
/> />
); );
} }
@ -1261,6 +1778,7 @@ function LoadedApp(
onOpenRoute={props.onOpenRoute} onOpenRoute={props.onOpenRoute}
onGoSignIn={props.onGoSignIn} onGoSignIn={props.onGoSignIn}
onReplyCreated={props.onReplyCreated} onReplyCreated={props.onReplyCreated}
onDiscussionCreated={props.onDiscussionCreated}
onGoHome={props.onGoHome} onGoHome={props.onGoHome}
onGoCourses={props.onGoCourses} onGoCourses={props.onGoCourses}
onGoDiscussions={props.onGoDiscussions} onGoDiscussions={props.onGoDiscussions}
@ -1281,10 +1799,9 @@ function AppStatusPage(props: { title: string; copy?: string }) {
); );
} }
export default function App() { function usePrototypeData() {
const [data, setData] = useState<PrototypeData | null>(null); const [data, setData] = useState<PrototypeData | null>(null);
const [error, setError] = useState<string | null>(null); const [error, setError] = useState<string | null>(null);
const { pathname, navigate } = usePathname();
async function loadPrototype(signal?: AbortSignal) { async function loadPrototype(signal?: AbortSignal) {
try { try {
@ -1310,7 +1827,24 @@ export default function App() {
}; };
}, []); }, []);
const openDiscussion = (id: number) => navigate(`/discussions/${id}`); useEffect(() => {
const eventSource = new EventSource("/api/events/stream");
eventSource.addEventListener("content-updated", () => {
loadPrototype();
});
return () => {
eventSource.close();
};
}, []);
return { data, setData, error, loadPrototype };
}
export default function App() {
const { data, setData, error, loadPrototype } = usePrototypeData();
const { pathname, navigate } = usePathname();
const openDiscussion = (discussion: DiscussionCard) => navigate(discussionPath(discussion));
function goSignIn() { function goSignIn() {
window.location.assign(forgejoSignInUrl(pathname)); window.location.assign(forgejoSignInUrl(pathname));
@ -1329,15 +1863,18 @@ export default function App() {
const openPost = (post: PostCard) => navigate(postPath(post)); const openPost = (post: PostCard) => navigate(postPath(post));
function openLesson(course: CourseCard, chapter: CourseChapter, lesson: CourseLesson) { function openLesson(course: CourseCard, chapter: CourseChapter, lesson: CourseLesson) {
navigate( navigate(lessonPath(course, chapter, lesson));
`/courses/${encodeURIComponent(course.owner)}/${encodeURIComponent(course.name)}/lessons/${encodeURIComponent(chapter.slug)}/${encodeURIComponent(lesson.slug)}`,
);
} }
function addReplyToDiscussion(discussionId: number, reply: DiscussionReply) { function addReplyToDiscussion(discussionId: number, reply: DiscussionReply) {
setData((currentData) => appendDiscussionReply(currentData, discussionId, reply)); setData((currentData) => appendDiscussionReply(currentData, discussionId, reply));
} }
function addDiscussion(discussion: DiscussionCard) {
setData((currentData) => prependDiscussion(currentData, discussion));
navigate(discussionPath(discussion));
}
const goHome = () => navigate("/"); const goHome = () => navigate("/");
async function signOut() { async function signOut() {
@ -1367,6 +1904,7 @@ export default function App() {
onOpenRoute={navigate} onOpenRoute={navigate}
onGoSignIn={goSignIn} onGoSignIn={goSignIn}
onReplyCreated={addReplyToDiscussion} onReplyCreated={addReplyToDiscussion}
onDiscussionCreated={addDiscussion}
onGoHome={goHome} onGoHome={goHome}
onGoCourses={goCourses} onGoCourses={goCourses}
onGoDiscussions={goDiscussions} onGoDiscussions={goDiscussions}

View file

@ -20,7 +20,7 @@ function escapeHtml(value: string): string {
.replace(/'/g, "&#39;"); .replace(/'/g, "&#39;");
} }
function normalizeLinkTarget(value: string): string | null { function normalizeLinkTarget(value: string, baseUrl?: string): string | null {
const trimmed = value.trim(); const trimmed = value.trim();
if (!trimmed) { if (!trimmed) {
return null; return null;
@ -31,7 +31,7 @@ function normalizeLinkTarget(value: string): string | null {
} }
try { try {
const url = new URL(trimmed); const url = new URL(trimmed, baseUrl || undefined);
if (url.protocol === "http:" || url.protocol === "https:") { if (url.protocol === "http:" || url.protocol === "https:") {
return escapeHtml(url.toString()); return escapeHtml(url.toString());
} }
@ -42,7 +42,7 @@ function normalizeLinkTarget(value: string): string | null {
return null; return null;
} }
function renderInline(markdown: string): string { function renderInline(markdown: string, baseUrl?: string): string {
const codeTokens: string[] = []; const codeTokens: string[] = [];
let rendered = escapeHtml(markdown); let rendered = escapeHtml(markdown);
@ -51,10 +51,21 @@ function renderInline(markdown: string): string {
codeTokens.push(`<code>${code}</code>`); codeTokens.push(`<code>${code}</code>`);
return token; return token;
}); });
rendered = rendered.replace(
/!\[([^\]]*)\]\(([^)\s]+)(?:\s+"[^"]*")?\)/g,
(_match, label: string, href: string) => {
const safeHref = normalizeLinkTarget(href, baseUrl);
if (!safeHref) {
return label;
}
return `<img src="${safeHref}" alt="${label}" loading="lazy" />`;
},
);
rendered = rendered.replace( rendered = rendered.replace(
/\[([^\]]+)\]\(([^)\s]+)(?:\s+"[^"]*")?\)/g, /\[([^\]]+)\]\(([^)\s]+)(?:\s+"[^"]*")?\)/g,
(_match, label: string, href: string) => { (_match, label: string, href: string) => {
const safeHref = normalizeLinkTarget(href); const safeHref = normalizeLinkTarget(href, baseUrl);
if (!safeHref) { if (!safeHref) {
return label; return label;
} }
@ -83,12 +94,12 @@ function createParserState(): ParserState {
}; };
} }
function flushParagraph(state: ParserState) { function flushParagraph(state: ParserState, baseUrl?: string) {
if (state.paragraphLines.length === 0) { if (state.paragraphLines.length === 0) {
return; return;
} }
state.output.push(`<p>${renderInline(state.paragraphLines.join(" "))}</p>`); state.output.push(`<p>${renderInline(state.paragraphLines.join(" "), baseUrl)}</p>`);
state.paragraphLines.length = 0; state.paragraphLines.length = 0;
} }
@ -104,13 +115,13 @@ function flushList(state: ParserState) {
state.listType = null; state.listType = null;
} }
function flushBlockquote(state: ParserState) { function flushBlockquote(state: ParserState, baseUrl?: string) {
if (state.blockquoteLines.length === 0) { if (state.blockquoteLines.length === 0) {
return; return;
} }
state.output.push( state.output.push(
`<blockquote><p>${renderInline(state.blockquoteLines.join(" "))}</p></blockquote>`, `<blockquote><p>${renderInline(state.blockquoteLines.join(" "), baseUrl)}</p></blockquote>`,
); );
state.blockquoteLines.length = 0; state.blockquoteLines.length = 0;
} }
@ -131,10 +142,10 @@ function flushCodeBlock(state: ParserState) {
state.codeLines.length = 0; state.codeLines.length = 0;
} }
function flushInlineBlocks(state: ParserState) { function flushInlineBlocks(state: ParserState, baseUrl?: string) {
flushParagraph(state); flushParagraph(state, baseUrl);
flushList(state); flushList(state);
flushBlockquote(state); flushBlockquote(state, baseUrl);
} }
function handleCodeBlockLine(state: ParserState, line: string): boolean { function handleCodeBlockLine(state: ParserState, line: string): boolean {
@ -151,124 +162,129 @@ function handleCodeBlockLine(state: ParserState, line: string): boolean {
return true; return true;
} }
function handleFenceStart(state: ParserState, line: string): boolean { function handleFenceStart(state: ParserState, line: string, baseUrl?: string): boolean {
if (!line.trim().startsWith("```")) { if (!line.trim().startsWith("```")) {
return false; return false;
} }
flushInlineBlocks(state); flushInlineBlocks(state, baseUrl);
state.inCodeBlock = true; state.inCodeBlock = true;
state.codeLanguage = line.trim().slice(3).trim(); state.codeLanguage = line.trim().slice(3).trim();
return true; return true;
} }
function handleBlankLine(state: ParserState, line: string): boolean { function handleBlankLine(state: ParserState, line: string, baseUrl?: string): boolean {
if (line.trim()) { if (line.trim()) {
return false; return false;
} }
flushInlineBlocks(state); flushInlineBlocks(state, baseUrl);
return true; return true;
} }
function handleHeadingLine(state: ParserState, line: string): boolean { function handleHeadingLine(state: ParserState, line: string, baseUrl?: string): boolean {
const headingMatch = line.match(/^(#{1,6})\s+(.*)$/); const headingMatch = line.match(/^(#{1,6})\s+(.*)$/);
if (!headingMatch) { if (!headingMatch) {
return false; return false;
} }
flushInlineBlocks(state); flushInlineBlocks(state, baseUrl);
const level = headingMatch[1].length; const level = headingMatch[1].length;
state.output.push(`<h${level}>${renderInline(headingMatch[2].trim())}</h${level}>`); state.output.push(`<h${level}>${renderInline(headingMatch[2].trim(), baseUrl)}</h${level}>`);
return true; return true;
} }
function handleRuleLine(state: ParserState, line: string): boolean { function handleRuleLine(state: ParserState, line: string, baseUrl?: string): boolean {
if (!/^(-{3,}|\*{3,})$/.test(line.trim())) { if (!/^(-{3,}|\*{3,})$/.test(line.trim())) {
return false; return false;
} }
flushInlineBlocks(state); flushInlineBlocks(state, baseUrl);
state.output.push("<hr />"); state.output.push("<hr />");
return true; return true;
} }
function handleListLine(state: ParserState, line: string, listType: ListType): boolean { function handleListLine(
state: ParserState,
line: string,
listType: ListType,
baseUrl?: string,
): boolean {
const pattern = listType === "ul" ? /^[-*+]\s+(.*)$/ : /^\d+\.\s+(.*)$/; const pattern = listType === "ul" ? /^[-*+]\s+(.*)$/ : /^\d+\.\s+(.*)$/;
const match = line.match(pattern); const match = line.match(pattern);
if (!match) { if (!match) {
return false; return false;
} }
flushParagraph(state); flushParagraph(state, baseUrl);
flushBlockquote(state); flushBlockquote(state, baseUrl);
if (state.listType !== listType) { if (state.listType !== listType) {
flushList(state); flushList(state);
state.listType = listType; state.listType = listType;
} }
state.listItems.push(renderInline(match[1].trim())); state.listItems.push(renderInline(match[1].trim(), baseUrl));
return true; return true;
} }
function handleBlockquoteLine(state: ParserState, line: string): boolean { function handleBlockquoteLine(state: ParserState, line: string, baseUrl?: string): boolean {
const match = line.match(/^>\s?(.*)$/); const match = line.match(/^>\s?(.*)$/);
if (!match) { if (!match) {
return false; return false;
} }
flushParagraph(state); flushParagraph(state, baseUrl);
flushList(state); flushList(state);
state.blockquoteLines.push(match[1].trim()); state.blockquoteLines.push(match[1].trim());
return true; return true;
} }
function handleParagraphLine(state: ParserState, line: string) { function handleParagraphLine(state: ParserState, line: string, baseUrl?: string) {
flushList(state); flushList(state);
flushBlockquote(state); flushBlockquote(state, baseUrl);
state.paragraphLines.push(line.trim()); state.paragraphLines.push(line.trim());
} }
function processMarkdownLine(state: ParserState, line: string) { function processMarkdownLine(state: ParserState, line: string, baseUrl?: string) {
if (handleCodeBlockLine(state, line)) { if (handleCodeBlockLine(state, line)) {
return; return;
} }
if (handleFenceStart(state, line)) { if (handleFenceStart(state, line, baseUrl)) {
return; return;
} }
if (handleBlankLine(state, line)) { if (handleBlankLine(state, line, baseUrl)) {
return; return;
} }
if (handleHeadingLine(state, line)) { if (handleHeadingLine(state, line, baseUrl)) {
return; return;
} }
if (handleRuleLine(state, line)) { if (handleRuleLine(state, line, baseUrl)) {
return; return;
} }
if (handleListLine(state, line, "ul") || handleListLine(state, line, "ol")) { if (handleListLine(state, line, "ul", baseUrl) || handleListLine(state, line, "ol", baseUrl)) {
return; return;
} }
if (handleBlockquoteLine(state, line)) { if (handleBlockquoteLine(state, line, baseUrl)) {
return; return;
} }
handleParagraphLine(state, line); handleParagraphLine(state, line, baseUrl);
} }
function markdownToHtml(markdown: string): string { function markdownToHtml(markdown: string, baseUrl?: string): string {
const state = createParserState(); const state = createParserState();
const lines = markdown.replace(/\r\n/g, "\n").split("\n"); const lines = markdown.replace(/\r\n/g, "\n").split("\n");
for (const line of lines) { for (const line of lines) {
processMarkdownLine(state, line); processMarkdownLine(state, line, baseUrl);
} }
flushInlineBlocks(state); flushInlineBlocks(state, baseUrl);
flushCodeBlock(state); flushCodeBlock(state);
return state.output.join(""); return state.output.join("");
} }
@ -288,8 +304,8 @@ export function stripLeadingTitleHeading(markdown: string, title: string): strin
return markdown; return markdown;
} }
export function MarkdownContent(props: { markdown: string; className?: string }) { export function MarkdownContent(props: { markdown: string; className?: string; baseUrl?: string }) {
const html = markdownToHtml(props.markdown); const html = markdownToHtml(props.markdown, props.baseUrl);
const className = props.className ? `markdown-content ${props.className}` : "markdown-content"; const className = props.className ? `markdown-content ${props.className}` : "markdown-content";
return <div className={className} dangerouslySetInnerHTML={{ __html: html }} />; return <div className={className} dangerouslySetInnerHTML={{ __html: html }} />;

View file

@ -296,6 +296,14 @@ textarea {
gap: 0.75rem; gap: 0.75rem;
} }
.discussion-create-panel {
margin-bottom: 1rem;
border: 0.0625rem solid var(--accent-border);
border-radius: 0.75rem;
padding: 1rem;
background: var(--accent-soft);
}
.discussion-preview-card { .discussion-preview-card {
width: 100%; width: 100%;
padding: 1rem; padding: 1rem;
@ -546,6 +554,12 @@ textarea {
color: var(--accent); color: var(--accent);
} }
.markdown-content img {
max-width: 100%;
border: 0.0625rem solid var(--border);
border-radius: 0.75rem;
}
.markdown-content hr { .markdown-content hr {
width: 100%; width: 100%;
height: 0.0625rem; height: 0.0625rem;
@ -559,6 +573,32 @@ textarea {
gap: 0.85rem; gap: 0.85rem;
} }
.asset-list {
display: grid;
gap: 0.75rem;
}
.asset-link {
display: grid;
gap: 0.2rem;
border: 0.0625rem solid var(--border);
border-radius: 0.75rem;
padding: 0.85rem;
color: inherit;
background: var(--card);
text-decoration: none;
}
.asset-link:hover,
.asset-link:focus-visible {
background: var(--panel-hover);
outline: none;
}
.asset-link .meta-line {
margin-top: 0;
}
.signin-page { .signin-page {
display: grid; display: grid;
} }

View file

@ -18,6 +18,13 @@ export interface AuthState {
oauth_configured: boolean; oauth_configured: boolean;
} }
export interface ContentAsset {
name: string;
path: string;
html_url: string;
download_url: string;
}
export interface CourseCard { export interface CourseCard {
title: string; title: string;
owner: string; owner: string;
@ -44,6 +51,8 @@ export interface CourseLesson {
path: string; path: string;
file_path: string; file_path: string;
html_url: string; html_url: string;
raw_base_url: string;
assets: ContentAsset[];
summary: string; summary: string;
body: string; body: string;
} }
@ -59,6 +68,8 @@ export interface PostCard {
path: string; path: string;
file_path: string; file_path: string;
html_url: string; html_url: string;
raw_base_url: string;
assets: ContentAsset[];
body: string; body: string;
updated_at: string; updated_at: string;
} }
@ -85,6 +96,7 @@ export interface DiscussionCard {
html_url: string; html_url: string;
labels: string[]; labels: string[];
comments: DiscussionReply[]; comments: DiscussionReply[];
links: DiscussionLink[];
} }
export interface DiscussionReply { export interface DiscussionReply {
@ -96,9 +108,25 @@ export interface DiscussionReply {
html_url: string; html_url: string;
} }
export interface DiscussionLink {
kind: "post" | "lesson";
path: string;
owner: string;
repo: string;
slug?: string;
chapter?: string;
lesson?: string;
content_path: string;
}
export interface DiscussionSettings {
general_discussion_configured: boolean;
}
export interface PrototypeData { export interface PrototypeData {
hero: HeroData; hero: HeroData;
auth: AuthState; auth: AuthState;
discussion_settings: DiscussionSettings;
source_of_truth: SourceOfTruthCard[]; source_of_truth: SourceOfTruthCard[];
featured_courses: CourseCard[]; featured_courses: CourseCard[];
recent_posts: PostCard[]; recent_posts: PostCard[];

View file

@ -1,7 +1,9 @@
from __future__ import annotations from __future__ import annotations
import asyncio import asyncio
import re
from typing import Any from typing import Any
from urllib.parse import unquote, urlparse
from calendar_feeds import CalendarFeed, CalendarFeedError, fetch_calendar_feed from calendar_feeds import CalendarFeed, CalendarFeedError, fetch_calendar_feed
from forgejo_client import ForgejoClient, ForgejoClientError from forgejo_client import ForgejoClient, ForgejoClientError
@ -75,6 +77,7 @@ async def build_live_prototype_payload(
), ),
) )
repos = await _with_configured_discussion_repo(client, repos, settings, warnings)
current_user = await _current_user_for_auth_source(client, has_user_token, warnings) 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")] public_repos = [repo for repo in repos if not repo.get("fork") and not repo.get("private")]
repo_summaries = await asyncio.gather( repo_summaries = await asyncio.gather(
@ -132,8 +135,9 @@ async def build_live_prototype_payload(
settings, settings,
), ),
"source_of_truth": source_cards, "source_of_truth": source_cards,
"featured_courses": [_course_card(summary) for summary in course_repos[:6]], "discussion_settings": _discussion_settings(settings),
"recent_posts": [_post_card(post) for post in blog_posts[:6]], "featured_courses": [_course_card(summary) for summary in course_repos],
"recent_posts": [_post_card(post) for post in blog_posts],
"upcoming_events": _event_cards(calendar_feeds, settings.calendar_event_limit), "upcoming_events": _event_cards(calendar_feeds, settings.calendar_event_limit),
"recent_discussions": await asyncio.gather( "recent_discussions": await asyncio.gather(
*[_discussion_card(client, issue) for issue in public_issues], *[_discussion_card(client, issue) for issue in public_issues],
@ -169,6 +173,49 @@ async def _current_user_for_auth_source(
return None return None
async def _with_configured_discussion_repo(
client: ForgejoClient,
repos: list[dict[str, Any]],
settings: Settings,
warnings: list[str],
) -> list[dict[str, Any]]:
owner_repo = _configured_owner_repo(settings.forgejo_general_discussion_repo)
if owner_repo is None:
return repos
owner, repo = owner_repo
full_name = f"{owner}/{repo}".lower()
if any(str(candidate.get("full_name", "")).lower() == full_name for candidate in repos):
return repos
try:
configured_repo = await client.fetch_repository(owner, repo)
except ForgejoClientError as error:
warnings.append(f"General discussion repo could not be loaded: {error}")
return repos
return [*repos, configured_repo]
def _configured_owner_repo(value: str | None) -> tuple[str, str] | None:
if not value:
return None
owner, separator, repo = value.strip().partition("/")
if not separator or not owner or not repo or "/" in repo:
return None
return owner, repo
def _discussion_settings(settings: Settings) -> dict[str, object]:
return _discussion_settings_from_configured(
_configured_owner_repo(settings.forgejo_general_discussion_repo) is not None,
)
def _discussion_settings_from_configured(general_discussion_configured: bool) -> dict[str, object]:
return {"general_discussion_configured": general_discussion_configured}
async def _summarize_repo( async def _summarize_repo(
client: ForgejoClient, client: ForgejoClient,
repo: dict[str, Any], repo: dict[str, Any],
@ -177,6 +224,7 @@ async def _summarize_repo(
repo_name = repo.get("name") repo_name = repo.get("name")
if not isinstance(owner_login, str) or not isinstance(repo_name, str): if not isinstance(owner_login, str) or not isinstance(repo_name, str):
return None return None
default_branch = str(repo.get("default_branch") or "main")
try: try:
root_entries = await client.list_directory(owner_login, repo_name) root_entries = await client.list_directory(owner_login, repo_name)
@ -222,6 +270,8 @@ async def _summarize_repo(
client, client,
owner_login, owner_login,
repo_name, repo_name,
default_branch,
str(repo.get("html_url", "")),
chapter_name, chapter_name,
str(lesson_dir.get("name", "")), str(lesson_dir.get("name", "")),
) )
@ -251,6 +301,8 @@ async def _summarize_repo(
str(repo.get("full_name", f"{owner_login}/{repo_name}")), str(repo.get("full_name", f"{owner_login}/{repo_name}")),
str(repo.get("description") or ""), str(repo.get("description") or ""),
str(repo.get("updated_at", "")), str(repo.get("updated_at", "")),
default_branch,
str(repo.get("html_url", "")),
str(blog_dir.get("name", "")), str(blog_dir.get("name", "")),
) )
for blog_dir in blog_dirs for blog_dir in blog_dirs
@ -300,6 +352,8 @@ def _post_card(post: dict[str, Any]) -> dict[str, object]:
"path": post["path"], "path": post["path"],
"file_path": post["file_path"], "file_path": post["file_path"],
"html_url": post["html_url"], "html_url": post["html_url"],
"raw_base_url": post["raw_base_url"],
"assets": post["assets"],
"body": post["body"], "body": post["body"],
"updated_at": post["updated_at"], "updated_at": post["updated_at"],
} }
@ -379,15 +433,7 @@ def _event_cards(calendar_feeds: list[CalendarFeed], limit: int) -> list[dict[st
async def _discussion_card(client: ForgejoClient, issue: dict[str, Any]) -> dict[str, object]: async def _discussion_card(client: ForgejoClient, issue: dict[str, Any]) -> dict[str, object]:
repository = issue.get("repository") or {} repository = issue.get("repository") or {}
owner = repository.get("owner", "") 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_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]] = [] comment_items: list[dict[str, object]] = []
if isinstance(owner, str) and isinstance(repository.get("name"), str) and issue_number > 0: if isinstance(owner, str) and isinstance(repository.get("name"), str) and issue_number > 0:
try: try:
@ -402,7 +448,25 @@ async def _discussion_card(client: ForgejoClient, issue: dict[str, Any]) -> dict
except ForgejoClientError: except ForgejoClientError:
comment_items = [] comment_items = []
return discussion_card_from_issue(issue, comments=comment_items)
def discussion_card_from_issue(
issue: dict[str, Any],
*,
comments: list[dict[str, object]] | None = None,
) -> dict[str, object]:
repository = issue.get("repository") or {}
full_name = repository.get("full_name", "Unknown repo")
issue_author = issue.get("user") or {}
issue_number = int(issue.get("number", 0) or 0)
labels = [
label.get("name")
for label in issue.get("labels", [])
if isinstance(label, dict) and isinstance(label.get("name"), str)
]
body = str(issue.get("body", "") or "").strip() body = str(issue.get("body", "") or "").strip()
links = discussion_links_from_text(body)
if not body: if not body:
body = "No issue description yet. Right now the conversation starts in the replies." body = "No issue description yet. Right now the conversation starts in the replies."
@ -410,8 +474,8 @@ async def _discussion_card(client: ForgejoClient, issue: dict[str, Any]) -> dict
"id": int(issue.get("id", 0)), "id": int(issue.get("id", 0)),
"title": issue.get("title", "Untitled issue"), "title": issue.get("title", "Untitled issue"),
"repo": full_name, "repo": full_name,
"replies": comments, "replies": int(issue.get("comments", 0) or 0),
"context": "Live Forgejo issue", "context": "Linked discussion" if links else "Live Forgejo issue",
"author": issue_author.get("login", "Unknown author"), "author": issue_author.get("login", "Unknown author"),
"author_avatar_url": issue_author.get("avatar_url", ""), "author_avatar_url": issue_author.get("avatar_url", ""),
"state": issue.get("state", "open"), "state": issue.get("state", "open"),
@ -420,10 +484,122 @@ async def _discussion_card(client: ForgejoClient, issue: dict[str, Any]) -> dict
"updated_at": issue.get("updated_at", ""), "updated_at": issue.get("updated_at", ""),
"html_url": issue.get("html_url", ""), "html_url": issue.get("html_url", ""),
"labels": [label for label in labels if isinstance(label, str)], "labels": [label for label in labels if isinstance(label, str)],
"comments": comment_items, "comments": comments or [],
"links": links,
} }
def discussion_links_from_text(text: str) -> list[dict[str, object]]:
links: list[dict[str, object]] = []
seen: set[tuple[str, str, str, str]] = set()
for match in re.finditer(
r"(?:https?://[^\s)]+)?(/posts/([^/\s)]+)/([^/\s)]+)/([^/\s)#?]+))", text
):
owner = unquote(match.group(2))
repo = unquote(match.group(3))
slug = unquote(match.group(4).rstrip(".,"))
path = f"/posts/{owner}/{repo}/{slug}"
_append_discussion_link(
links,
seen,
{
"kind": "post",
"path": path,
"owner": owner,
"repo": repo,
"slug": slug,
"content_path": f"blogs/{slug}",
},
)
lesson_pattern = (
r"(?:https?://[^\s)]+)?"
r"(/courses/([^/\s)]+)/([^/\s)]+)/lessons/([^/\s)]+)/([^/\s)#?]+))"
)
for match in re.finditer(lesson_pattern, text):
owner = unquote(match.group(2))
repo = unquote(match.group(3))
chapter = unquote(match.group(4))
lesson = unquote(match.group(5).rstrip(".,"))
path = f"/courses/{owner}/{repo}/lessons/{chapter}/{lesson}"
_append_discussion_link(
links,
seen,
{
"kind": "lesson",
"path": path,
"owner": owner,
"repo": repo,
"chapter": chapter,
"lesson": lesson,
"content_path": f"lessons/{chapter}/{lesson}",
},
)
for raw_url in re.findall(r"https?://[^\s)]+", text):
file_link = _forgejo_file_link(raw_url)
if file_link is not None:
_append_discussion_link(links, seen, file_link)
return links
def _append_discussion_link(
links: list[dict[str, object]],
seen: set[tuple[str, str, str, str]],
link: dict[str, object],
) -> None:
key = (
str(link.get("kind", "")),
str(link.get("owner", "")),
str(link.get("repo", "")),
str(link.get("content_path", "")),
)
if key in seen:
return
seen.add(key)
links.append(link)
def _forgejo_file_link(raw_url: str) -> dict[str, object] | None:
parsed = urlparse(raw_url.rstrip(".,"))
path_parts = [unquote(part) for part in parsed.path.strip("/").split("/") if part]
if len(path_parts) < 6 or path_parts[2:4] != ["src", "branch"]:
return None
owner, repo = path_parts[0], path_parts[1]
content_parts = path_parts[5:]
if len(content_parts) < 2:
return None
if content_parts[0] == "blogs":
slug = content_parts[1]
return {
"kind": "post",
"path": f"/posts/{owner}/{repo}/{slug}",
"owner": owner,
"repo": repo,
"slug": slug,
"content_path": f"blogs/{slug}",
}
if content_parts[0] == "lessons" and len(content_parts) >= 3:
chapter = content_parts[1]
lesson = content_parts[2]
return {
"kind": "lesson",
"path": f"/courses/{owner}/{repo}/lessons/{chapter}/{lesson}",
"owner": owner,
"repo": repo,
"chapter": chapter,
"lesson": lesson,
"content_path": f"lessons/{chapter}/{lesson}",
}
return None
def _discussion_reply(comment: dict[str, Any]) -> dict[str, object]: def _discussion_reply(comment: dict[str, Any]) -> dict[str, object]:
author = comment.get("user") or {} author = comment.get("user") or {}
body = str(comment.get("body", "") or "").strip() body = str(comment.get("body", "") or "").strip()
@ -466,6 +642,7 @@ def _empty_payload(
}, },
"auth": auth, "auth": auth,
"source_of_truth": source_cards, "source_of_truth": source_cards,
"discussion_settings": _discussion_settings_from_configured(False),
"featured_courses": [], "featured_courses": [],
"recent_posts": [], "recent_posts": [],
"upcoming_events": [], "upcoming_events": [],
@ -510,10 +687,13 @@ async def _summarize_blog_post(
full_name: str, full_name: str,
repo_description: str, repo_description: str,
updated_at: str, updated_at: str,
default_branch: str,
repo_html_url: str,
post_name: str, post_name: str,
) -> dict[str, object]: ) -> dict[str, object]:
post_path = f"blogs/{post_name}" post_path = f"blogs/{post_name}"
fallback_title = _display_name(post_name) fallback_title = _display_name(post_name)
raw_base_url = _raw_folder_url(repo_html_url, default_branch, post_path)
try: try:
post_entries = await client.list_directory(owner, repo, post_path) post_entries = await client.list_directory(owner, repo, post_path)
@ -527,8 +707,10 @@ async def _summarize_blog_post(
repo_description, repo_description,
updated_at, updated_at,
post_path, post_path,
raw_base_url=raw_base_url,
) )
assets = _content_assets(post_entries, raw_base_url, post_path)
markdown_files = _markdown_file_entries(post_entries) markdown_files = _markdown_file_entries(post_entries)
if not markdown_files: if not markdown_files:
return _empty_blog_post( return _empty_blog_post(
@ -540,6 +722,8 @@ async def _summarize_blog_post(
repo_description, repo_description,
updated_at, updated_at,
post_path, post_path,
raw_base_url=raw_base_url,
assets=assets,
) )
markdown_name = str(markdown_files[0]["name"]) markdown_name = str(markdown_files[0]["name"])
@ -559,6 +743,8 @@ async def _summarize_blog_post(
post_path, post_path,
file_path=markdown_path, file_path=markdown_path,
html_url=str(markdown_files[0].get("html_url", "")), html_url=str(markdown_files[0].get("html_url", "")),
raw_base_url=raw_base_url,
assets=assets,
) )
metadata, body = _parse_frontmatter(str(file_payload.get("content", ""))) metadata, body = _parse_frontmatter(str(file_payload.get("content", "")))
@ -572,6 +758,8 @@ async def _summarize_blog_post(
"path": post_path, "path": post_path,
"file_path": str(file_payload.get("path", markdown_path)), "file_path": str(file_payload.get("path", markdown_path)),
"html_url": str(file_payload.get("html_url", "")), "html_url": str(file_payload.get("html_url", "")),
"raw_base_url": raw_base_url,
"assets": assets,
"body": body, "body": body,
"updated_at": updated_at, "updated_at": updated_at,
} }
@ -581,20 +769,30 @@ async def _summarize_lesson(
client: ForgejoClient, client: ForgejoClient,
owner: str, owner: str,
repo: str, repo: str,
default_branch: str,
repo_html_url: str,
chapter_name: str, chapter_name: str,
lesson_name: str, lesson_name: str,
) -> dict[str, object]: ) -> dict[str, object]:
lesson_path = f"lessons/{chapter_name}/{lesson_name}" lesson_path = f"lessons/{chapter_name}/{lesson_name}"
fallback_title = _display_name(lesson_name) fallback_title = _display_name(lesson_name)
raw_base_url = _raw_folder_url(repo_html_url, default_branch, lesson_path)
try: try:
lesson_entries = await client.list_directory(owner, repo, lesson_path) lesson_entries = await client.list_directory(owner, repo, lesson_path)
except ForgejoClientError: except ForgejoClientError:
return _empty_lesson(lesson_name, fallback_title, lesson_path) return _empty_lesson(lesson_name, fallback_title, lesson_path, raw_base_url=raw_base_url)
assets = _content_assets(lesson_entries, raw_base_url, lesson_path)
markdown_files = _markdown_file_entries(lesson_entries) markdown_files = _markdown_file_entries(lesson_entries)
if not markdown_files: if not markdown_files:
return _empty_lesson(lesson_name, fallback_title, lesson_path) return _empty_lesson(
lesson_name,
fallback_title,
lesson_path,
raw_base_url=raw_base_url,
assets=assets,
)
markdown_name = str(markdown_files[0]["name"]) markdown_name = str(markdown_files[0]["name"])
markdown_path = f"{lesson_path}/{markdown_name}" markdown_path = f"{lesson_path}/{markdown_name}"
@ -608,6 +806,8 @@ async def _summarize_lesson(
lesson_path, lesson_path,
file_path=markdown_path, file_path=markdown_path,
html_url=str(markdown_files[0].get("html_url", "")), html_url=str(markdown_files[0].get("html_url", "")),
raw_base_url=raw_base_url,
assets=assets,
) )
metadata, body = _parse_frontmatter(str(file_payload.get("content", ""))) metadata, body = _parse_frontmatter(str(file_payload.get("content", "")))
@ -618,6 +818,8 @@ async def _summarize_lesson(
"path": lesson_path, "path": lesson_path,
"file_path": str(file_payload.get("path", markdown_path)), "file_path": str(file_payload.get("path", markdown_path)),
"html_url": str(file_payload.get("html_url", "")), "html_url": str(file_payload.get("html_url", "")),
"raw_base_url": raw_base_url,
"assets": assets,
"body": body, "body": body,
} }
@ -646,6 +848,45 @@ def _markdown_file_entries(entries: list[dict[str, Any]]) -> list[dict[str, Any]
) )
def _content_assets(
entries: list[dict[str, Any]],
raw_base_url: str,
folder_path: str,
) -> list[dict[str, object]]:
assets: list[dict[str, object]] = []
for entry in entries:
if entry.get("type") != "file" or not isinstance(entry.get("name"), str):
continue
name = str(entry["name"])
if name.lower().endswith(".md"):
continue
path = str(entry.get("path") or f"{folder_path}/{name}")
assets.append(
{
"name": name,
"path": path,
"html_url": str(entry.get("html_url", "")),
"download_url": str(entry.get("download_url") or _raw_file_url(raw_base_url, name)),
},
)
return sorted(assets, key=lambda asset: str(asset["name"]))
def _raw_folder_url(repo_html_url: str, default_branch: str, folder_path: str) -> str:
if not repo_html_url:
return ""
branch = default_branch.strip("/") or "main"
return f"{repo_html_url.rstrip('/')}/raw/branch/{branch}/{folder_path.strip('/')}/"
def _raw_file_url(raw_base_url: str, name: str) -> str:
if not raw_base_url:
return ""
return f"{raw_base_url.rstrip('/')}/{name}"
def _display_name(value: str) -> str: def _display_name(value: str) -> str:
cleaned = value.strip().rsplit(".", 1)[0] cleaned = value.strip().rsplit(".", 1)[0]
cleaned = cleaned.replace("_", " ").replace("-", " ") cleaned = cleaned.replace("_", " ").replace("-", " ")
@ -693,6 +934,8 @@ def _empty_lesson(
*, *,
file_path: str = "", file_path: str = "",
html_url: str = "", html_url: str = "",
raw_base_url: str = "",
assets: list[dict[str, object]] | None = None,
) -> dict[str, object]: ) -> dict[str, object]:
return { return {
"slug": lesson_name, "slug": lesson_name,
@ -701,6 +944,8 @@ def _empty_lesson(
"path": lesson_path, "path": lesson_path,
"file_path": file_path, "file_path": file_path,
"html_url": html_url, "html_url": html_url,
"raw_base_url": raw_base_url,
"assets": assets or [],
"body": "", "body": "",
} }
@ -717,6 +962,8 @@ def _empty_blog_post(
*, *,
file_path: str = "", file_path: str = "",
html_url: str = "", html_url: str = "",
raw_base_url: str = "",
assets: list[dict[str, object]] | None = None,
) -> dict[str, object]: ) -> dict[str, object]:
return { return {
"slug": post_name, "slug": post_name,
@ -728,6 +975,8 @@ def _empty_blog_post(
"path": post_path, "path": post_path,
"file_path": file_path, "file_path": file_path,
"html_url": html_url, "html_url": html_url,
"raw_base_url": raw_base_url,
"assets": assets or [],
"body": "", "body": "",
"updated_at": updated_at, "updated_at": updated_at,
} }

47
prototype_cache.py Normal file
View file

@ -0,0 +1,47 @@
from __future__ import annotations
import asyncio
import copy
import time
from live_prototype import build_live_prototype_payload
from settings import Settings
class PrototypePayloadCache:
def __init__(self) -> None:
self._payload: dict[str, object] | None = None
self._expires_at = 0.0
self._lock = asyncio.Lock()
async def get(self, settings: Settings) -> dict[str, object]:
if settings.forgejo_cache_ttl_seconds <= 0:
return copy.deepcopy(await self._build_public_payload(settings))
now = time.monotonic()
if self._payload is not None and now < self._expires_at:
return copy.deepcopy(self._payload)
async with self._lock:
now = time.monotonic()
if self._payload is not None and now < self._expires_at:
return copy.deepcopy(self._payload)
payload = await self._build_public_payload(settings)
self._payload = copy.deepcopy(payload)
self._expires_at = time.monotonic() + settings.forgejo_cache_ttl_seconds
return copy.deepcopy(self._payload)
def invalidate(self) -> None:
self._payload = None
self._expires_at = 0.0
async def _build_public_payload(self, settings: Settings) -> dict[str, object]:
read_token = settings.forgejo_token
auth_source = "server" if read_token else "none"
return await build_live_prototype_payload(
settings,
forgejo_token=read_token,
auth_source=auth_source,
session_user=None,
)

View file

@ -9,7 +9,9 @@ python_files=(
"calendar_feeds.py" "calendar_feeds.py"
"forgejo_client.py" "forgejo_client.py"
"live_prototype.py" "live_prototype.py"
"prototype_cache.py"
"settings.py" "settings.py"
"update_events.py"
"tests" "tests"
) )
@ -45,7 +47,7 @@ run_check \
uv run --with "deptry>=0.24.0,<1.0.0" \ uv run --with "deptry>=0.24.0,<1.0.0" \
deptry . \ deptry . \
--requirements-files requirements.txt \ --requirements-files requirements.txt \
--known-first-party app,auth,calendar_feeds,forgejo_client,live_prototype,settings \ --known-first-party app,auth,calendar_feeds,forgejo_client,live_prototype,prototype_cache,settings,update_events \
--per-rule-ignores DEP002=uvicorn \ --per-rule-ignores DEP002=uvicorn \
--extend-exclude ".*/frontend/.*" \ --extend-exclude ".*/frontend/.*" \
--extend-exclude ".*/\\.venv/.*" \ --extend-exclude ".*/\\.venv/.*" \
@ -53,7 +55,7 @@ run_check \
run_check \ run_check \
"Vulture" \ "Vulture" \
uv run --with "vulture>=2.15,<3.0.0" \ uv run --with "vulture>=2.15,<3.0.0" \
vulture app.py auth.py calendar_feeds.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 prototype_cache.py settings.py update_events.py tests --min-confidence 80
run_check \ run_check \
"Backend Tests" \ "Backend Tests" \
"${python_cmd[@]}" -m unittest discover -s tests -p "test_*.py" "${python_cmd[@]}" -m unittest discover -s tests -p "test_*.py"

View file

@ -15,8 +15,11 @@ class Settings:
forgejo_oauth_client_id: str | None forgejo_oauth_client_id: str | None
forgejo_oauth_client_secret: str | None forgejo_oauth_client_secret: str | None
forgejo_oauth_scopes: tuple[str, ...] forgejo_oauth_scopes: tuple[str, ...]
forgejo_general_discussion_repo: str | None
forgejo_webhook_secret: str | None
forgejo_repo_scan_limit: int forgejo_repo_scan_limit: int
forgejo_recent_issue_limit: int forgejo_recent_issue_limit: int
forgejo_cache_ttl_seconds: float
forgejo_request_timeout_seconds: float forgejo_request_timeout_seconds: float
calendar_feed_urls: tuple[str, ...] calendar_feed_urls: tuple[str, ...]
calendar_event_limit: int calendar_event_limit: int
@ -66,8 +69,11 @@ def get_settings() -> Settings:
forgejo_oauth_client_id=os.getenv("FORGEJO_OAUTH_CLIENT_ID") 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_client_secret=os.getenv("FORGEJO_OAUTH_CLIENT_SECRET") or None,
forgejo_oauth_scopes=_parse_scopes(os.getenv("FORGEJO_OAUTH_SCOPES")), forgejo_oauth_scopes=_parse_scopes(os.getenv("FORGEJO_OAUTH_SCOPES")),
forgejo_general_discussion_repo=os.getenv("FORGEJO_GENERAL_DISCUSSION_REPO") or None,
forgejo_webhook_secret=os.getenv("FORGEJO_WEBHOOK_SECRET") or None,
forgejo_repo_scan_limit=int(os.getenv("FORGEJO_REPO_SCAN_LIMIT", "30")), 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_recent_issue_limit=int(os.getenv("FORGEJO_RECENT_ISSUE_LIMIT", "50")),
forgejo_cache_ttl_seconds=float(os.getenv("FORGEJO_CACHE_TTL_SECONDS", "60.0")),
forgejo_request_timeout_seconds=float( forgejo_request_timeout_seconds=float(
os.getenv("FORGEJO_REQUEST_TIMEOUT_SECONDS", "10.0"), os.getenv("FORGEJO_REQUEST_TIMEOUT_SECONDS", "10.0"),
), ),

View file

@ -1,7 +1,9 @@
from __future__ import annotations from __future__ import annotations
import hmac
import os import os
import unittest import unittest
from hashlib import sha256
from urllib.parse import parse_qs, urlparse from urllib.parse import parse_qs, urlparse
from unittest.mock import AsyncMock, patch from unittest.mock import AsyncMock, patch
@ -55,7 +57,7 @@ class AppTestCase(unittest.TestCase):
"source_of_truth": [], "source_of_truth": [],
} }
builder = AsyncMock(return_value=payload) builder = AsyncMock(return_value=payload)
with patch("app.build_live_prototype_payload", new=builder): with patch("prototype_cache.build_live_prototype_payload", new=builder):
response = self.client.get("/api/prototype") response = self.client.get("/api/prototype")
response_payload = response.json() response_payload = response.json()
@ -70,14 +72,14 @@ class AppTestCase(unittest.TestCase):
self.assertEqual(builder.call_args.kwargs["forgejo_token"], None) self.assertEqual(builder.call_args.kwargs["forgejo_token"], None)
self.assertEqual(builder.call_args.kwargs["auth_source"], "none") self.assertEqual(builder.call_args.kwargs["auth_source"], "none")
def test_prototype_accepts_authorization_token(self) -> None: def test_prototype_reuses_cached_public_payload(self) -> None:
payload = { payload = {
"hero": {"title": "Robot U"}, "hero": {"title": "Robot U"},
"auth": { "auth": {
"authenticated": True, "authenticated": False,
"login": "kacper", "login": None,
"source": "authorization", "source": "none",
"can_reply": True, "can_reply": False,
"oauth_configured": True, "oauth_configured": True,
}, },
"featured_courses": [], "featured_courses": [],
@ -87,15 +89,98 @@ class AppTestCase(unittest.TestCase):
"source_of_truth": [], "source_of_truth": [],
} }
builder = AsyncMock(return_value=payload) builder = AsyncMock(return_value=payload)
with patch("app.build_live_prototype_payload", new=builder): with patch("prototype_cache.build_live_prototype_payload", new=builder):
first_response = self.client.get("/api/prototype")
second_response = self.client.get("/api/prototype")
self.assertEqual(first_response.status_code, 200)
self.assertEqual(second_response.status_code, 200)
self.assertEqual(builder.await_count, 1)
def test_forgejo_webhook_invalidates_prototype_cache(self) -> None:
initial_payload = {
"hero": {"title": "Before"},
"auth": {
"authenticated": False,
"login": None,
"source": "none",
"can_reply": False,
"oauth_configured": True,
},
"featured_courses": [],
"recent_posts": [],
"recent_discussions": [],
"upcoming_events": [],
"source_of_truth": [],
}
refreshed_payload = {
**initial_payload,
"hero": {"title": "After"},
}
builder = AsyncMock(side_effect=[initial_payload, refreshed_payload])
with patch("prototype_cache.build_live_prototype_payload", new=builder):
first_response = self.client.get("/api/prototype")
webhook_response = self.client.post("/api/forgejo/webhook", json={"ref": "main"})
second_response = self.client.get("/api/prototype")
self.assertEqual(first_response.json()["hero"]["title"], "Before")
self.assertEqual(webhook_response.status_code, 200)
self.assertEqual(second_response.json()["hero"]["title"], "After")
self.assertEqual(builder.await_count, 2)
def test_forgejo_webhook_validates_signature_when_secret_is_configured(self) -> None:
get_settings.cache_clear()
body = b'{"ref":"main"}'
signature = hmac.new(b"webhook-secret", body, sha256).hexdigest()
with patch.dict(os.environ, {"FORGEJO_WEBHOOK_SECRET": "webhook-secret"}):
bad_response = self.client.post(
"/api/forgejo/webhook",
content=body,
headers={"X-Forgejo-Signature": "bad-signature"},
)
good_response = self.client.post(
"/api/forgejo/webhook",
content=body,
headers={"X-Forgejo-Signature": signature},
)
self.assertEqual(bad_response.status_code, 401)
self.assertEqual(good_response.status_code, 200)
def test_prototype_accepts_authorization_token(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": [],
}
builder = AsyncMock(return_value=payload)
fake_client = _FakeForgejoClient(user={"login": "kacper"})
with (
patch("prototype_cache.build_live_prototype_payload", new=builder),
patch("app.ForgejoClient", return_value=fake_client),
):
response = self.client.get( response = self.client.get(
"/api/prototype", "/api/prototype",
headers={"Authorization": "token test-token"}, headers={"Authorization": "token test-token"},
) )
response_payload = response.json()
self.assertEqual(response.status_code, 200) self.assertEqual(response.status_code, 200)
self.assertEqual(builder.call_args.kwargs["forgejo_token"], "test-token") self.assertEqual(builder.call_args.kwargs["forgejo_token"], None)
self.assertEqual(builder.call_args.kwargs["auth_source"], "authorization") self.assertEqual(builder.call_args.kwargs["auth_source"], "none")
self.assertEqual(response_payload["auth"]["authenticated"], True)
self.assertEqual(response_payload["auth"]["login"], "kacper")
self.assertEqual(response_payload["auth"]["source"], "authorization")
def test_prototype_can_use_server_token_without_user_session(self) -> None: def test_prototype_can_use_server_token_without_user_session(self) -> None:
payload = { payload = {
@ -117,7 +202,7 @@ class AppTestCase(unittest.TestCase):
get_settings.cache_clear() get_settings.cache_clear()
with ( with (
patch.dict(os.environ, {"FORGEJO_TOKEN": "server-token"}), patch.dict(os.environ, {"FORGEJO_TOKEN": "server-token"}),
patch("app.build_live_prototype_payload", new=builder), patch("prototype_cache.build_live_prototype_payload", new=builder),
): ):
response = self.client.get("/api/prototype") response = self.client.get("/api/prototype")
@ -213,13 +298,17 @@ class AppTestCase(unittest.TestCase):
"source_of_truth": [], "source_of_truth": [],
} }
builder = AsyncMock(return_value=payload) builder = AsyncMock(return_value=payload)
with patch("app.build_live_prototype_payload", new=builder): with patch("prototype_cache.build_live_prototype_payload", new=builder):
response = self.client.get("/api/prototype") response = self.client.get("/api/prototype")
response_payload = response.json()
self.assertEqual(response.status_code, 200) self.assertEqual(response.status_code, 200)
self.assertEqual(builder.call_args.kwargs["forgejo_token"], "oauth-token") self.assertEqual(builder.call_args.kwargs["forgejo_token"], None)
self.assertEqual(builder.call_args.kwargs["auth_source"], "session") self.assertEqual(builder.call_args.kwargs["auth_source"], "none")
self.assertEqual(builder.call_args.kwargs["session_user"]["login"], "kacper") self.assertIsNone(builder.call_args.kwargs["session_user"])
self.assertEqual(response_payload["auth"]["authenticated"], True)
self.assertEqual(response_payload["auth"]["login"], "kacper")
self.assertEqual(response_payload["auth"]["source"], "session")
def test_encrypted_session_cookie_survives_new_app_instance(self) -> None: def test_encrypted_session_cookie_survives_new_app_instance(self) -> None:
fake_client = _FakeForgejoClient(user={"login": "kacper"}, access_token="oauth-token") fake_client = _FakeForgejoClient(user={"login": "kacper"}, access_token="oauth-token")
@ -278,6 +367,187 @@ class AppTestCase(unittest.TestCase):
self.assertEqual(payload["author"], "Kacper") self.assertEqual(payload["author"], "Kacper")
self.assertEqual(payload["body"], "Thanks, this helped.") self.assertEqual(payload["body"], "Thanks, this helped.")
def test_discussion_detail_fetches_public_issue(self) -> None:
fake_client = _FakeForgejoClient(
issue={
"id": 456,
"number": 9,
"title": "Encoder math question",
"body": "Canonical URL: http://testserver/posts/Robot-U/robot-u-site/building-robot-u-site",
"comments": 1,
"updated_at": "2026-04-11T12:00:00Z",
"html_url": "https://aksal.cloud/Robot-U/robot-u-site/issues/9",
"user": {"login": "Kacper", "avatar_url": ""},
"labels": [],
"state": "open",
},
comments=[
{
"id": 777,
"body": "Reply body",
"created_at": "2026-04-11T12:30:00Z",
"html_url": "https://aksal.cloud/Robot-U/robot-u-site/issues/9#issuecomment-777",
"user": {"login": "Ada", "avatar_url": ""},
}
],
)
with patch("app.ForgejoClient", return_value=fake_client):
response = self.client.get("/api/discussions/Robot-U/robot-u-site/9")
payload = response.json()
self.assertEqual(response.status_code, 200)
self.assertEqual(payload["title"], "Encoder math question")
self.assertEqual(payload["comments"][0]["body"], "Reply body")
self.assertEqual(payload["links"][0]["kind"], "post")
def test_create_discussion_reply_invalidates_prototype_cache(self) -> None:
initial_payload = {
"hero": {"title": "Before"},
"auth": {
"authenticated": False,
"login": None,
"source": "none",
"can_reply": False,
"oauth_configured": True,
},
"featured_courses": [],
"recent_posts": [],
"recent_discussions": [],
"upcoming_events": [],
"source_of_truth": [],
}
refreshed_payload = {
**initial_payload,
"hero": {"title": "After"},
}
builder = AsyncMock(side_effect=[initial_payload, refreshed_payload])
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": ""},
},
)
with (
patch("prototype_cache.build_live_prototype_payload", new=builder),
patch("app.ForgejoClient", return_value=fake_client),
):
first_response = self.client.get("/api/prototype")
reply_response = self.client.post(
"/api/discussions/replies",
json={
"owner": "Robot-U",
"repo": "RobotClass",
"number": 2,
"body": "Thanks, this helped.",
},
headers={"Authorization": "token test-token"},
)
second_response = self.client.get("/api/prototype")
self.assertEqual(first_response.json()["hero"]["title"], "Before")
self.assertEqual(reply_response.status_code, 200)
self.assertEqual(second_response.json()["hero"]["title"], "After")
self.assertEqual(builder.await_count, 2)
def test_create_linked_discussion(self) -> None:
fake_client = _FakeForgejoClient(
issue={
"id": 456,
"number": 9,
"title": "Encoder math question",
"body": "How should I debounce this?\n\n---\nCanonical URL: http://testserver/posts/Robot-U/robot-u-site/building-robot-u-site",
"comments": 0,
"updated_at": "2026-04-11T12:00:00Z",
"html_url": "https://aksal.cloud/Robot-U/robot-u-site/issues/9",
"user": {"login": "Kacper", "avatar_url": ""},
"labels": [],
"state": "open",
},
)
with patch("app.ForgejoClient", return_value=fake_client) as client_factory:
response = self.client.post(
"/api/discussions",
json={
"owner": "Robot-U",
"repo": "robot-u-site",
"title": "Encoder math question",
"body": "How should I debounce this?",
"context_url": "http://testserver/posts/Robot-U/robot-u-site/building-robot-u-site",
"context_path": "blogs/building-robot-u-site/index.md",
"context_title": "Building Robot U",
},
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.assertIsNotNone(fake_client.created_issue)
assert fake_client.created_issue is not None
self.assertEqual(
fake_client.created_issue[:3], ("Robot-U", "robot-u-site", "Encoder math question")
)
self.assertIn(
"Canonical URL: http://testserver/posts/Robot-U/robot-u-site/building-robot-u-site",
fake_client.created_issue[3],
)
self.assertEqual(payload["id"], 456)
self.assertEqual(payload["repo"], "Robot-U/robot-u-site")
self.assertEqual(payload["links"][0]["kind"], "post")
def test_create_general_discussion_uses_configured_repo(self) -> None:
get_settings.cache_clear()
fake_client = _FakeForgejoClient(
issue={
"id": 457,
"number": 10,
"title": "General project help",
"body": "I need help choosing motors.",
"comments": 0,
"updated_at": "2026-04-11T12:00:00Z",
"html_url": "https://aksal.cloud/Robot-U/community/issues/10",
"user": {"login": "Kacper", "avatar_url": ""},
"labels": [],
"state": "open",
},
)
with (
patch.dict(os.environ, {"FORGEJO_GENERAL_DISCUSSION_REPO": "Robot-U/community"}),
patch("app.ForgejoClient", return_value=fake_client),
):
response = self.client.post(
"/api/discussions",
json={
"title": "General project help",
"body": "I need help choosing motors.",
},
headers={"Authorization": "token test-token"},
)
self.assertEqual(response.status_code, 200)
self.assertEqual(
fake_client.created_issue,
("Robot-U", "community", "General project help", "I need help choosing motors."),
)
def test_create_discussion_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",
json={
"owner": "Robot-U",
"repo": "robot-u-site",
"title": "General project help",
"body": "I need help choosing motors.",
},
)
self.assertEqual(response.status_code, 401)
def test_create_discussion_reply_uses_signed_in_identity(self) -> None: def test_create_discussion_reply_uses_signed_in_identity(self) -> None:
sign_in_client = _FakeForgejoClient(user={"login": "kacper"}, access_token="oauth-token") sign_in_client = _FakeForgejoClient(user={"login": "kacper"}, access_token="oauth-token")
with patch("app.ForgejoClient", return_value=sign_in_client): with patch("app.ForgejoClient", return_value=sign_in_client):
@ -354,15 +624,20 @@ class _FakeForgejoClient:
def __init__( def __init__(
self, self,
comment: dict[str, object] | None = None, comment: dict[str, object] | None = None,
comments: list[dict[str, object]] | None = None,
issue: dict[str, object] | None = None,
user: dict[str, object] | None = None, user: dict[str, object] | None = None,
access_token: str = "test-oauth-token", access_token: str = "test-oauth-token",
repo_private: bool = False, repo_private: bool = False,
) -> None: ) -> None:
self._comment = comment self._comment = comment
self._comments = comments or []
self._issue = issue
self._user = user or {"login": "test-user"} self._user = user or {"login": "test-user"}
self._access_token = access_token self._access_token = access_token
self._repo_private = repo_private self._repo_private = repo_private
self.created_comment: tuple[str, str, int, str] | None = None self.created_comment: tuple[str, str, int, str] | None = None
self.created_issue: tuple[str, str, str, str] | None = None
self.exchanged_code: str | None = None self.exchanged_code: str | None = None
async def __aenter__(self) -> _FakeForgejoClient: async def __aenter__(self) -> _FakeForgejoClient:
@ -383,6 +658,23 @@ class _FakeForgejoClient:
raise AssertionError("Fake comment was not configured.") raise AssertionError("Fake comment was not configured.")
return self._comment return self._comment
async def create_issue(
self,
owner: str,
repo: str,
title: str,
body: str,
) -> dict[str, object]:
self.created_issue = (owner, repo, title, body)
if self._issue is None:
raise AssertionError("Fake issue was not configured.")
return self._issue
async def fetch_issue(self, _owner: str, _repo: str, _issue_number: int) -> dict[str, object]:
if self._issue is None:
raise AssertionError("Fake issue was not configured.")
return self._issue
async def fetch_current_user(self) -> dict[str, object]: async def fetch_current_user(self) -> dict[str, object]:
return self._user return self._user
@ -394,6 +686,14 @@ class _FakeForgejoClient:
"private": self._repo_private, "private": self._repo_private,
} }
async def list_issue_comments(
self,
_owner: str,
_repo: str,
_issue_number: int,
) -> list[dict[str, object]]:
return self._comments
async def fetch_openid_configuration(self) -> dict[str, object]: async def fetch_openid_configuration(self) -> dict[str, object]:
return { return {
"authorization_endpoint": "https://aksal.cloud/login/oauth/authorize", "authorization_endpoint": "https://aksal.cloud/login/oauth/authorize",

View file

@ -3,7 +3,7 @@ from __future__ import annotations
import unittest import unittest
from typing import Any from typing import Any
from live_prototype import _post_card, _summarize_repo from live_prototype import _post_card, _summarize_repo, discussion_links_from_text
class LivePrototypeTestCase(unittest.IsolatedAsyncioTestCase): class LivePrototypeTestCase(unittest.IsolatedAsyncioTestCase):
@ -29,6 +29,25 @@ class LivePrototypeTestCase(unittest.IsolatedAsyncioTestCase):
self.assertEqual(post["repo"], "Robot-U/robot-u-site") self.assertEqual(post["repo"], "Robot-U/robot-u-site")
self.assertEqual(post["path"], "blogs/building-robot-u-site") self.assertEqual(post["path"], "blogs/building-robot-u-site")
self.assertIn("thin layer over Forgejo", post["body"]) self.assertIn("thin layer over Forgejo", post["body"])
self.assertEqual(post["assets"][0]["name"], "worksheet.pdf")
def test_discussion_links_detect_app_and_forgejo_urls(self) -> None:
links = discussion_links_from_text(
"\n".join(
[
"Canonical URL: https://robot-u.test/courses/Robot-U/encoder/lessons/01-basics/01-counts",
"Source: https://aksal.cloud/Robot-U/robot-u-site/src/branch/main/blogs/building-robot-u-site/index.md",
],
),
)
self.assertEqual(links[0]["kind"], "lesson")
self.assertEqual(
links[0]["path"],
"/courses/Robot-U/encoder/lessons/01-basics/01-counts",
)
self.assertEqual(links[1]["kind"], "post")
self.assertEqual(links[1]["path"], "/posts/Robot-U/robot-u-site/building-robot-u-site")
class _FakeContentClient: class _FakeContentClient:
@ -45,8 +64,15 @@ class _FakeContentClient:
{ {
"type": "file", "type": "file",
"name": "index.md", "name": "index.md",
"path": "blogs/building-robot-u-site/index.md",
"html_url": "https://aksal.cloud/Robot-U/robot-u-site/src/branch/main/blogs/building-robot-u-site/index.md", "html_url": "https://aksal.cloud/Robot-U/robot-u-site/src/branch/main/blogs/building-robot-u-site/index.md",
}, },
{
"type": "file",
"name": "worksheet.pdf",
"path": "blogs/building-robot-u-site/worksheet.pdf",
"download_url": "https://aksal.cloud/Robot-U/robot-u-site/raw/branch/main/blogs/building-robot-u-site/worksheet.pdf",
},
], ],
} }
return entries.get(path, []) return entries.get(path, [])

64
update_events.py Normal file
View file

@ -0,0 +1,64 @@
from __future__ import annotations
import asyncio
import json
import time
from collections.abc import AsyncIterator
from typing import Any
class UpdateBroker:
def __init__(self) -> None:
self._subscribers: set[asyncio.Queue[dict[str, object]]] = set()
def subscribe(self) -> asyncio.Queue[dict[str, object]]:
queue: asyncio.Queue[dict[str, object]] = asyncio.Queue(maxsize=20)
self._subscribers.add(queue)
return queue
def unsubscribe(self, queue: asyncio.Queue[dict[str, object]]) -> None:
self._subscribers.discard(queue)
async def publish(self, event: str, payload: dict[str, object] | None = None) -> None:
message = {
"event": event,
"data": {
"type": event,
"published_at": time.time(),
**(payload or {}),
},
}
stale_queues: list[asyncio.Queue[dict[str, object]]] = []
for queue in tuple(self._subscribers):
try:
queue.put_nowait(message)
except asyncio.QueueFull:
stale_queues.append(queue)
for queue in stale_queues:
self.unsubscribe(queue)
async def stream_sse_events(
broker: UpdateBroker,
) -> AsyncIterator[str]:
queue = broker.subscribe()
try:
yield _sse_event("connected", {"type": "connected", "published_at": time.time()})
while True:
try:
message = await asyncio.wait_for(queue.get(), timeout=25)
except TimeoutError:
yield ": keepalive\n\n"
continue
yield _sse_event(
str(message.get("event", "message")),
message.get("data") if isinstance(message.get("data"), dict) else {},
)
finally:
broker.unsubscribe(queue)
def _sse_event(event: str, payload: dict[str, Any]) -> str:
return f"event: {event}\ndata: {json.dumps(payload, separators=(',', ':'))}\n\n"