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_SECRET=
FORGEJO_OAUTH_SCOPES=openid profile
FORGEJO_GENERAL_DISCUSSION_REPO=Robot-U/general_forum
FORGEJO_WEBHOOK_SECRET=
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
CALENDAR_FEED_URLS=
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
- `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
- `calendar_feeds.py`: ICS/webcal feed loading and parsing
- `settings.py`: env-driven runtime settings
@ -69,6 +71,9 @@ Useful variables:
- `FORGEJO_OAUTH_CLIENT_SECRET=...`
- `FORGEJO_OAUTH_SCOPES=openid profile`
- `FORGEJO_TOKEN=...`
- `FORGEJO_GENERAL_DISCUSSION_REPO=Robot-U/general_forum`
- `FORGEJO_WEBHOOK_SECRET=...`
- `FORGEJO_CACHE_TTL_SECONDS=60.0`
- `CALENDAR_FEED_URLS=webcal://...`
- `HOST=0.0.0.0`
- `PORT=8800`
@ -76,8 +81,11 @@ Useful variables:
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 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.
- `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.
- 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 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 ...`.
- `CALENDAR_FEED_URLS` is optional and accepts comma-separated `webcal://` or `https://` ICS feeds.
- 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.
- Frontmatter is used when present for `title` and `summary`.
- 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.
## 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_SECRET="your-forgejo-oauth-client-secret"
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"
```
@ -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.
`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`:

216
app.py
View file

@ -1,12 +1,14 @@
from __future__ import annotations
import hmac
from hashlib import sha256
from pathlib import Path
from typing import Any
from urllib.parse import urlencode
from fastapi import Body, FastAPI, HTTPException, Request
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 auth import (
@ -19,8 +21,10 @@ from auth import (
resolve_forgejo_token,
)
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 update_events import UpdateBroker, stream_sse_events
BASE_DIR = Path(__file__).resolve().parent
DIST_DIR = BASE_DIR / "frontend" / "dist"
@ -28,6 +32,8 @@ DIST_DIR = BASE_DIR / "frontend" / "dist"
def create_app() -> FastAPI:
app = FastAPI(title="Robot U Community Prototype")
prototype_cache = PrototypePayloadCache()
update_broker = UpdateBroker()
app.add_middleware(
CORSMiddleware,
@ -45,14 +51,14 @@ def create_app() -> FastAPI:
settings = get_settings()
session_user = current_session_user(request, settings)
forgejo_token, auth_source = resolve_forgejo_token(request, settings)
return JSONResponse(
await build_live_prototype_payload(
payload = await prototype_cache.get(settings)
payload["auth"] = await _auth_payload_for_request(
settings,
forgejo_token=forgejo_token,
auth_source=auth_source,
session_user=session_user,
),
)
return JSONResponse(payload)
@app.get("/api/auth/session")
async def auth_session(request: Request) -> JSONResponse:
@ -73,6 +79,28 @@ def create_app() -> FastAPI:
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")
async def forgejo_auth_start(request: Request, return_to: str = "/") -> RedirectResponse:
settings = get_settings()
@ -140,6 +168,31 @@ def create_app() -> FastAPI:
clear_login_session(request, 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")
async def create_discussion_reply(
request: Request,
@ -169,8 +222,59 @@ def create_app() -> FastAPI:
except ForgejoClientError as 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))
@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():
assets_dir = DIST_DIR / "assets"
if assets_dir.exists():
@ -201,6 +305,16 @@ def _required_string(payload: dict[str, object], key: str) -> str:
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:
value = payload.get(key)
if isinstance(value, bool):
@ -218,6 +332,62 @@ def _required_positive_int(payload: dict[str, object], key: str) -> int:
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]:
author = comment.get("user") or {}
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:
return bool(
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
- Rendering markdown content from Forgejo repos
- Discussion creation and replies from this app, backed by Forgejo issues/comments
- Per-user lesson completion tracking
- Team-derived admin/moderator permissions
- Webhook-driven sync from Forgejo
- 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
- Search
- Rich public profiles
- Per-user lesson completion tracking
- Private repo indexing
- Admin-created calendar events in this app
- 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
1. Signs in with Forgejo
2. Reads lessons and marks them complete
2. Reads lessons
3. Creates general discussion threads
4. Creates post- or lesson-linked discussion threads from content pages
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 |
| Discussions / comments | Forgejo issues and issue comments |
| Events | External ICS feeds |
| Lesson progress | App database |
| Lesson progress | Post-MVP app database |
| Sessions | App backend |
| 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
- Mark lesson complete
- Create discussion threads
- Reply to discussions
- Edit supported discussion content through the app
@ -359,9 +358,9 @@ Events should continue to be managed in external calendar tools.
## 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
- completed
@ -435,6 +434,7 @@ The following are explicitly out of scope for MVP:
- browser-based asset uploads
- search
- rich user profiles
- per-user lesson completion tracking
- non-Forgejo auth providers
- private repo support
- complex moderation workflows

View file

@ -83,17 +83,32 @@ class ForgejoClient:
return await self._get_json("/api/v1/user", auth_required=True)
async def search_repositories(self) -> list[dict[str, Any]]:
scan_limit = max(self._settings.forgejo_repo_scan_limit, 1)
page_limit = min(scan_limit, 50)
repos: list[dict[str, Any]] = []
page = 1
while len(repos) < scan_limit:
payload = await self._get_json(
"/api/v1/repos/search",
params={
"limit": self._settings.forgejo_repo_scan_limit,
"page": 1,
"limit": page_limit,
"page": page,
"private": "false",
"is_private": "false",
},
)
if not isinstance(payload, dict):
break
data = payload.get("data", [])
return [repo for repo in data if isinstance(repo, dict)]
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]:
payload = await self._get_json(
@ -124,6 +139,12 @@ class ForgejoClient:
return [issue for issue in payload if isinstance(issue, dict)]
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]]:
endpoint = f"/api/v1/repos/{owner}/{repo}/contents"
if path:
@ -166,6 +187,23 @@ class ForgejoClient:
return payload
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]:
payload = await self._get_json(
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 type {
AuthState,
ContentAsset,
CourseCard,
CourseChapter,
CourseLesson,
@ -49,6 +50,26 @@ function parseDiscussionRoute(pathname: string): number | 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 {
return normalizePathname(pathname) === "/signin";
}
@ -179,6 +200,17 @@ function findLessonByRoute(
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() {
const [pathname, setPathname] = useState(() => normalizePathname(window.location.pathname));
@ -424,7 +456,7 @@ function EventItem(props: { event: EventCard }) {
function DiscussionPreviewItem(props: {
discussion: DiscussionCard;
onOpenDiscussion: (id: number) => void;
onOpenDiscussion: (discussion: DiscussionCard) => void;
}) {
const { discussion, onOpenDiscussion } = props;
@ -433,7 +465,7 @@ function DiscussionPreviewItem(props: {
type="button"
className="discussion-preview-card"
onClick={() => {
onOpenDiscussion(discussion.id);
onOpenDiscussion(discussion);
}}
>
<h3>{discussion.title}</h3>
@ -500,6 +532,42 @@ async function postDiscussionReply(
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: {
discussion: DiscussionCard;
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: {
course: CourseCard;
onGoHome: () => void;
@ -644,10 +899,26 @@ function LessonPage(props: {
course: CourseCard;
chapter: CourseChapter;
lesson: CourseLesson;
auth: AuthState;
discussions: DiscussionCard[];
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 routePath = lessonPath(course, chapter, lesson);
return (
<section className="thread-view">
@ -670,18 +941,60 @@ function LessonPage(props: {
<h2>Lesson</h2>
</header>
{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." />
)}
</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>
);
}
function PostPage(props: { post: PostCard }) {
const { post } = props;
function PostPage(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 routePath = postPath(post);
return (
<section className="thread-view">
@ -700,11 +1013,45 @@ function PostPage(props: { post: PostCard }) {
<h2>Post</h2>
</header>
{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." />
)}
</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>
);
}
@ -785,12 +1132,42 @@ function CoursesView(props: { data: PrototypeData; onOpenCourse: (course: Course
);
}
function DiscussionsView(props: { data: PrototypeData; onOpenDiscussion: (id: number) => void }) {
const { data, onOpenDiscussion } = props;
function DiscussionsView(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 (
<section className="page-section">
<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 ? (
<div className="stack">
{data.recent_discussions.map((discussion) => (
@ -812,9 +1189,12 @@ function HomeView(props: {
data: PrototypeData;
onOpenCourse: (course: CourseCard) => 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 (
<>
@ -856,7 +1236,13 @@ function HomeView(props: {
</div>
</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;
}
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(
currentData: PrototypeData | null,
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 {
id: string;
title: string;
@ -959,6 +1378,15 @@ function postPath(post: PostCard): string {
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[] {
const activities: ActivityEntry[] = [];
@ -992,7 +1420,7 @@ function buildActivityFeed(data: PrototypeData): ActivityEntry[] {
title: `Discussion updated: ${discussion.title}`,
detail: `${discussion.repo} · ${discussion.replies} replies`,
timestamp: discussion.updated_at,
route: `/discussions/${discussion.id}`,
route: discussionPath(discussion),
});
for (const reply of discussion.comments) {
@ -1001,7 +1429,7 @@ function buildActivityFeed(data: PrototypeData): ActivityEntry[] {
title: `${reply.author} replied`,
detail: discussion.title,
timestamp: reply.created_at,
route: `/discussions/${discussion.id}`,
route: discussionPath(discussion),
});
}
}
@ -1066,10 +1494,11 @@ interface AppContentProps {
onOpenCourse: (course: CourseCard) => void;
onOpenPost: (post: PostCard) => void;
onOpenLesson: (course: CourseCard, chapter: CourseChapter, lesson: CourseLesson) => void;
onOpenDiscussion: (id: number) => void;
onOpenDiscussion: (discussion: DiscussionCard) => void;
onOpenRoute: (route: string) => void;
onGoSignIn: () => void;
onReplyCreated: (discussionId: number, reply: DiscussionReply) => void;
onDiscussionCreated: (discussion: DiscussionCard) => void;
onGoHome: () => void;
onGoCourses: () => 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(
@ -1130,9 +1568,14 @@ function LessonRouteView(
course={selectedCourse}
chapter={selectedLesson.chapter}
lesson={selectedLesson.lesson}
auth={props.data.auth}
discussions={props.data.recent_discussions}
onGoCourse={() => {
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) {
if (isSignInRoute(props.pathname)) {
return <SignInPage auth={props.data.auth} />;
@ -1199,7 +1702,19 @@ function AppContent(props: AppContentProps) {
}
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);
@ -1225,6 +1740,8 @@ function AppContent(props: AppContentProps) {
onOpenCourse={props.onOpenCourse}
onOpenPost={props.onOpenPost}
onOpenDiscussion={props.onOpenDiscussion}
onGoSignIn={props.onGoSignIn}
onDiscussionCreated={props.onDiscussionCreated}
/>
);
}
@ -1261,6 +1778,7 @@ function LoadedApp(
onOpenRoute={props.onOpenRoute}
onGoSignIn={props.onGoSignIn}
onReplyCreated={props.onReplyCreated}
onDiscussionCreated={props.onDiscussionCreated}
onGoHome={props.onGoHome}
onGoCourses={props.onGoCourses}
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 [error, setError] = useState<string | null>(null);
const { pathname, navigate } = usePathname();
async function loadPrototype(signal?: AbortSignal) {
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() {
window.location.assign(forgejoSignInUrl(pathname));
@ -1329,15 +1863,18 @@ export default function App() {
const openPost = (post: PostCard) => navigate(postPath(post));
function openLesson(course: CourseCard, chapter: CourseChapter, lesson: CourseLesson) {
navigate(
`/courses/${encodeURIComponent(course.owner)}/${encodeURIComponent(course.name)}/lessons/${encodeURIComponent(chapter.slug)}/${encodeURIComponent(lesson.slug)}`,
);
navigate(lessonPath(course, chapter, lesson));
}
function addReplyToDiscussion(discussionId: number, reply: DiscussionReply) {
setData((currentData) => appendDiscussionReply(currentData, discussionId, reply));
}
function addDiscussion(discussion: DiscussionCard) {
setData((currentData) => prependDiscussion(currentData, discussion));
navigate(discussionPath(discussion));
}
const goHome = () => navigate("/");
async function signOut() {
@ -1367,6 +1904,7 @@ export default function App() {
onOpenRoute={navigate}
onGoSignIn={goSignIn}
onReplyCreated={addReplyToDiscussion}
onDiscussionCreated={addDiscussion}
onGoHome={goHome}
onGoCourses={goCourses}
onGoDiscussions={goDiscussions}

View file

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

View file

@ -296,6 +296,14 @@ textarea {
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 {
width: 100%;
padding: 1rem;
@ -546,6 +554,12 @@ textarea {
color: var(--accent);
}
.markdown-content img {
max-width: 100%;
border: 0.0625rem solid var(--border);
border-radius: 0.75rem;
}
.markdown-content hr {
width: 100%;
height: 0.0625rem;
@ -559,6 +573,32 @@ textarea {
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 {
display: grid;
}

View file

@ -18,6 +18,13 @@ export interface AuthState {
oauth_configured: boolean;
}
export interface ContentAsset {
name: string;
path: string;
html_url: string;
download_url: string;
}
export interface CourseCard {
title: string;
owner: string;
@ -44,6 +51,8 @@ export interface CourseLesson {
path: string;
file_path: string;
html_url: string;
raw_base_url: string;
assets: ContentAsset[];
summary: string;
body: string;
}
@ -59,6 +68,8 @@ export interface PostCard {
path: string;
file_path: string;
html_url: string;
raw_base_url: string;
assets: ContentAsset[];
body: string;
updated_at: string;
}
@ -85,6 +96,7 @@ export interface DiscussionCard {
html_url: string;
labels: string[];
comments: DiscussionReply[];
links: DiscussionLink[];
}
export interface DiscussionReply {
@ -96,9 +108,25 @@ export interface DiscussionReply {
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 {
hero: HeroData;
auth: AuthState;
discussion_settings: DiscussionSettings;
source_of_truth: SourceOfTruthCard[];
featured_courses: CourseCard[];
recent_posts: PostCard[];

View file

@ -1,7 +1,9 @@
from __future__ import annotations
import asyncio
import re
from typing import Any
from urllib.parse import unquote, urlparse
from calendar_feeds import CalendarFeed, CalendarFeedError, fetch_calendar_feed
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)
public_repos = [repo for repo in repos if not repo.get("fork") and not repo.get("private")]
repo_summaries = await asyncio.gather(
@ -132,8 +135,9 @@ async def build_live_prototype_payload(
settings,
),
"source_of_truth": source_cards,
"featured_courses": [_course_card(summary) for summary in course_repos[:6]],
"recent_posts": [_post_card(post) for post in blog_posts[:6]],
"discussion_settings": _discussion_settings(settings),
"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),
"recent_discussions": await asyncio.gather(
*[_discussion_card(client, issue) for issue in public_issues],
@ -169,6 +173,49 @@ async def _current_user_for_auth_source(
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(
client: ForgejoClient,
repo: dict[str, Any],
@ -177,6 +224,7 @@ async def _summarize_repo(
repo_name = repo.get("name")
if not isinstance(owner_login, str) or not isinstance(repo_name, str):
return None
default_branch = str(repo.get("default_branch") or "main")
try:
root_entries = await client.list_directory(owner_login, repo_name)
@ -222,6 +270,8 @@ async def _summarize_repo(
client,
owner_login,
repo_name,
default_branch,
str(repo.get("html_url", "")),
chapter_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("description") or ""),
str(repo.get("updated_at", "")),
default_branch,
str(repo.get("html_url", "")),
str(blog_dir.get("name", "")),
)
for blog_dir in blog_dirs
@ -300,6 +352,8 @@ def _post_card(post: dict[str, Any]) -> dict[str, object]:
"path": post["path"],
"file_path": post["file_path"],
"html_url": post["html_url"],
"raw_base_url": post["raw_base_url"],
"assets": post["assets"],
"body": post["body"],
"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]:
repository = issue.get("repository") or {}
owner = repository.get("owner", "")
full_name = repository.get("full_name", "Unknown repo")
comments = issue.get("comments", 0)
issue_number = int(issue.get("number", 0))
issue_author = issue.get("user") or {}
labels = [
label.get("name")
for label in issue.get("labels", [])
if isinstance(label, dict) and isinstance(label.get("name"), str)
]
comment_items: list[dict[str, object]] = []
if isinstance(owner, str) and isinstance(repository.get("name"), str) and issue_number > 0:
try:
@ -402,7 +448,25 @@ async def _discussion_card(client: ForgejoClient, issue: dict[str, Any]) -> dict
except ForgejoClientError:
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()
links = discussion_links_from_text(body)
if not body:
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)),
"title": issue.get("title", "Untitled issue"),
"repo": full_name,
"replies": comments,
"context": "Live Forgejo issue",
"replies": int(issue.get("comments", 0) or 0),
"context": "Linked discussion" if links else "Live Forgejo issue",
"author": issue_author.get("login", "Unknown author"),
"author_avatar_url": issue_author.get("avatar_url", ""),
"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", ""),
"html_url": issue.get("html_url", ""),
"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]:
author = comment.get("user") or {}
body = str(comment.get("body", "") or "").strip()
@ -466,6 +642,7 @@ def _empty_payload(
},
"auth": auth,
"source_of_truth": source_cards,
"discussion_settings": _discussion_settings_from_configured(False),
"featured_courses": [],
"recent_posts": [],
"upcoming_events": [],
@ -510,10 +687,13 @@ async def _summarize_blog_post(
full_name: str,
repo_description: str,
updated_at: str,
default_branch: str,
repo_html_url: str,
post_name: str,
) -> dict[str, object]:
post_path = f"blogs/{post_name}"
fallback_title = _display_name(post_name)
raw_base_url = _raw_folder_url(repo_html_url, default_branch, post_path)
try:
post_entries = await client.list_directory(owner, repo, post_path)
@ -527,8 +707,10 @@ async def _summarize_blog_post(
repo_description,
updated_at,
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)
if not markdown_files:
return _empty_blog_post(
@ -540,6 +722,8 @@ async def _summarize_blog_post(
repo_description,
updated_at,
post_path,
raw_base_url=raw_base_url,
assets=assets,
)
markdown_name = str(markdown_files[0]["name"])
@ -559,6 +743,8 @@ async def _summarize_blog_post(
post_path,
file_path=markdown_path,
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", "")))
@ -572,6 +758,8 @@ async def _summarize_blog_post(
"path": post_path,
"file_path": str(file_payload.get("path", markdown_path)),
"html_url": str(file_payload.get("html_url", "")),
"raw_base_url": raw_base_url,
"assets": assets,
"body": body,
"updated_at": updated_at,
}
@ -581,20 +769,30 @@ async def _summarize_lesson(
client: ForgejoClient,
owner: str,
repo: str,
default_branch: str,
repo_html_url: str,
chapter_name: str,
lesson_name: str,
) -> dict[str, object]:
lesson_path = f"lessons/{chapter_name}/{lesson_name}"
fallback_title = _display_name(lesson_name)
raw_base_url = _raw_folder_url(repo_html_url, default_branch, lesson_path)
try:
lesson_entries = await client.list_directory(owner, repo, lesson_path)
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)
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_path = f"{lesson_path}/{markdown_name}"
@ -608,6 +806,8 @@ async def _summarize_lesson(
lesson_path,
file_path=markdown_path,
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", "")))
@ -618,6 +818,8 @@ async def _summarize_lesson(
"path": lesson_path,
"file_path": str(file_payload.get("path", markdown_path)),
"html_url": str(file_payload.get("html_url", "")),
"raw_base_url": raw_base_url,
"assets": assets,
"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:
cleaned = value.strip().rsplit(".", 1)[0]
cleaned = cleaned.replace("_", " ").replace("-", " ")
@ -693,6 +934,8 @@ def _empty_lesson(
*,
file_path: str = "",
html_url: str = "",
raw_base_url: str = "",
assets: list[dict[str, object]] | None = None,
) -> dict[str, object]:
return {
"slug": lesson_name,
@ -701,6 +944,8 @@ def _empty_lesson(
"path": lesson_path,
"file_path": file_path,
"html_url": html_url,
"raw_base_url": raw_base_url,
"assets": assets or [],
"body": "",
}
@ -717,6 +962,8 @@ def _empty_blog_post(
*,
file_path: str = "",
html_url: str = "",
raw_base_url: str = "",
assets: list[dict[str, object]] | None = None,
) -> dict[str, object]:
return {
"slug": post_name,
@ -728,6 +975,8 @@ def _empty_blog_post(
"path": post_path,
"file_path": file_path,
"html_url": html_url,
"raw_base_url": raw_base_url,
"assets": assets or [],
"body": "",
"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"
"forgejo_client.py"
"live_prototype.py"
"prototype_cache.py"
"settings.py"
"update_events.py"
"tests"
)
@ -45,7 +47,7 @@ run_check \
uv run --with "deptry>=0.24.0,<1.0.0" \
deptry . \
--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 \
--extend-exclude ".*/frontend/.*" \
--extend-exclude ".*/\\.venv/.*" \
@ -53,7 +55,7 @@ run_check \
run_check \
"Vulture" \
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 \
"Backend Tests" \
"${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_secret: str | None
forgejo_oauth_scopes: tuple[str, ...]
forgejo_general_discussion_repo: str | None
forgejo_webhook_secret: str | None
forgejo_repo_scan_limit: int
forgejo_recent_issue_limit: int
forgejo_cache_ttl_seconds: float
forgejo_request_timeout_seconds: float
calendar_feed_urls: tuple[str, ...]
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_secret=os.getenv("FORGEJO_OAUTH_CLIENT_SECRET") or None,
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_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(
os.getenv("FORGEJO_REQUEST_TIMEOUT_SECONDS", "10.0"),
),

View file

@ -1,7 +1,9 @@
from __future__ import annotations
import hmac
import os
import unittest
from hashlib import sha256
from urllib.parse import parse_qs, urlparse
from unittest.mock import AsyncMock, patch
@ -55,7 +57,7 @@ class AppTestCase(unittest.TestCase):
"source_of_truth": [],
}
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_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["auth_source"], "none")
def test_prototype_accepts_authorization_token(self) -> None:
def test_prototype_reuses_cached_public_payload(self) -> None:
payload = {
"hero": {"title": "Robot U"},
"auth": {
"authenticated": True,
"login": "kacper",
"source": "authorization",
"can_reply": True,
"authenticated": False,
"login": None,
"source": "none",
"can_reply": False,
"oauth_configured": True,
},
"featured_courses": [],
@ -87,15 +89,98 @@ class AppTestCase(unittest.TestCase):
"source_of_truth": [],
}
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(
"/api/prototype",
headers={"Authorization": "token test-token"},
)
response_payload = response.json()
self.assertEqual(response.status_code, 200)
self.assertEqual(builder.call_args.kwargs["forgejo_token"], "test-token")
self.assertEqual(builder.call_args.kwargs["auth_source"], "authorization")
self.assertEqual(builder.call_args.kwargs["forgejo_token"], None)
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:
payload = {
@ -117,7 +202,7 @@ class AppTestCase(unittest.TestCase):
get_settings.cache_clear()
with (
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")
@ -213,13 +298,17 @@ class AppTestCase(unittest.TestCase):
"source_of_truth": [],
}
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_payload = response.json()
self.assertEqual(response.status_code, 200)
self.assertEqual(builder.call_args.kwargs["forgejo_token"], "oauth-token")
self.assertEqual(builder.call_args.kwargs["auth_source"], "session")
self.assertEqual(builder.call_args.kwargs["session_user"]["login"], "kacper")
self.assertEqual(builder.call_args.kwargs["forgejo_token"], None)
self.assertEqual(builder.call_args.kwargs["auth_source"], "none")
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:
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["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:
sign_in_client = _FakeForgejoClient(user={"login": "kacper"}, access_token="oauth-token")
with patch("app.ForgejoClient", return_value=sign_in_client):
@ -354,15 +624,20 @@ class _FakeForgejoClient:
def __init__(
self,
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,
access_token: str = "test-oauth-token",
repo_private: bool = False,
) -> None:
self._comment = comment
self._comments = comments or []
self._issue = issue
self._user = user or {"login": "test-user"}
self._access_token = access_token
self._repo_private = repo_private
self.created_comment: tuple[str, str, int, str] | None = None
self.created_issue: tuple[str, str, str, str] | None = None
self.exchanged_code: str | None = None
async def __aenter__(self) -> _FakeForgejoClient:
@ -383,6 +658,23 @@ class _FakeForgejoClient:
raise AssertionError("Fake comment was not configured.")
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]:
return self._user
@ -394,6 +686,14 @@ class _FakeForgejoClient:
"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]:
return {
"authorization_endpoint": "https://aksal.cloud/login/oauth/authorize",

View file

@ -3,7 +3,7 @@ from __future__ import annotations
import unittest
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):
@ -29,6 +29,25 @@ class LivePrototypeTestCase(unittest.IsolatedAsyncioTestCase):
self.assertEqual(post["repo"], "Robot-U/robot-u-site")
self.assertEqual(post["path"], "blogs/building-robot-u-site")
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:
@ -45,8 +64,15 @@ class _FakeContentClient:
{
"type": "file",
"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",
},
{
"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, [])

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"