Complete Forgejo discussion MVP
This commit is contained in:
parent
d84a885fdb
commit
51706d2d11
17 changed files with 1708 additions and 127 deletions
|
|
@ -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
|
||||
|
|
|
|||
13
AGENTS.md
13
AGENTS.md
|
|
@ -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
|
||||
|
|
|
|||
11
README.md
11
README.md
|
|
@ -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
216
app.py
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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('/')}",
|
||||
|
|
|
|||
|
|
@ -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}
|
||||
|
|
|
|||
|
|
@ -20,7 +20,7 @@ function escapeHtml(value: string): string {
|
|||
.replace(/'/g, "'");
|
||||
}
|
||||
|
||||
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 }} />;
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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[];
|
||||
|
|
|
|||
|
|
@ -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
47
prototype_cache.py
Normal 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,
|
||||
)
|
||||
|
|
@ -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"
|
||||
|
|
|
|||
|
|
@ -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"),
|
||||
),
|
||||
|
|
|
|||
|
|
@ -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",
|
||||
|
|
|
|||
|
|
@ -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
64
update_events.py
Normal 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"
|
||||
Loading…
Add table
Add a link
Reference in a new issue