diff --git a/.dockerignore b/.dockerignore
new file mode 100644
index 0000000..373aa5c
--- /dev/null
+++ b/.dockerignore
@@ -0,0 +1,13 @@
+.git/
+.venv/
+__pycache__/
+.pytest_cache/
+.ruff_cache/
+.env
+.env.*
+!.env.example
+frontend/node_modules/
+frontend/dist/
+frontend/.vite/
+examples/quadrature-encoder-course/
+*.pyc
diff --git a/.env.example b/.env.example
index f3cd181..5f8c1a1 100644
--- a/.env.example
+++ b/.env.example
@@ -1,6 +1,7 @@
APP_BASE_URL=http://kacper-dev-pod:8800
AUTH_SECRET_KEY=replace-with-a-random-32-byte-or-longer-secret
AUTH_COOKIE_SECURE=false
+CORS_ALLOW_ORIGINS=http://kacper-dev-pod:8800
FORGEJO_BASE_URL=https://aksal.cloud
FORGEJO_TOKEN=
FORGEJO_OAUTH_CLIENT_ID=
diff --git a/.forgejo/workflows/ci.yml b/.forgejo/workflows/ci.yml
new file mode 100644
index 0000000..0bfa9f3
--- /dev/null
+++ b/.forgejo/workflows/ci.yml
@@ -0,0 +1,40 @@
+name: CI
+
+on:
+ push:
+ branches:
+ - main
+ pull_request:
+
+jobs:
+ check:
+ runs-on: docker
+
+ steps:
+ - name: Install SSH clone key
+ run: |
+ set -euo pipefail
+ mkdir -p ~/.ssh
+ printf '%s\n' "${{ secrets.CI_REPO_SSH_KEY }}" > ~/.ssh/robot_u_site_clone
+ chmod 600 ~/.ssh/robot_u_site_clone
+ ssh-keyscan aksal.cloud >> ~/.ssh/known_hosts
+ cat > ~/.ssh/config <<'EOF'
+ Host aksal.cloud
+ IdentityFile ~/.ssh/robot_u_site_clone
+ IdentitiesOnly yes
+ EOF
+
+ - name: Clone repository over SSH
+ run: |
+ set -euo pipefail
+ git clone --depth 1 git@aksal.cloud:Robot-U/robot-u-site.git robot-u-site
+
+ - name: Check Python
+ run: |
+ cd robot-u-site
+ ./scripts/check_python_quality.sh
+
+ - name: Check Frontend
+ run: |
+ cd robot-u-site
+ ./scripts/check_frontend_quality.sh
diff --git a/AGENTS.md b/AGENTS.md
index 597773a..ad6c563 100644
--- a/AGENTS.md
+++ b/AGENTS.md
@@ -67,6 +67,7 @@ Useful variables:
- `APP_BASE_URL=http://kacper-dev-pod:8800`
- `AUTH_SECRET_KEY=...`
- `AUTH_COOKIE_SECURE=false`
+- `CORS_ALLOW_ORIGINS=http://kacper-dev-pod:8800`
- `FORGEJO_OAUTH_CLIENT_ID=...`
- `FORGEJO_OAUTH_CLIENT_SECRET=...`
- `FORGEJO_OAUTH_SCOPES=openid profile`
@@ -80,7 +81,7 @@ 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 sign-in uses Forgejo OAuth/OIDC. `APP_BASE_URL` must match the URL opened in the browser, `CORS_ALLOW_ORIGINS` should include that origin, 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 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.
@@ -110,6 +111,34 @@ Override host/port when needed:
HOST=0.0.0.0 PORT=8800 ./scripts/start.sh
```
+## Deployment Commands
+
+Bootstrap Forgejo Actions SSH clone credentials:
+
+```bash
+export FORGEJO_API_TOKEN=...
+./scripts/bootstrap_ci_clone_key.py
+```
+
+Validate production environment before starting:
+
+```bash
+./scripts/check_deploy_config.py
+```
+
+Container deployment:
+
+```bash
+docker compose up --build -d
+curl -fsS http://127.0.0.1:8800/health
+```
+
+Non-container production start after building `frontend/dist`:
+
+```bash
+HOST=0.0.0.0 PORT=8000 ./scripts/run_prod.sh
+```
+
## Development Commands
### Backend only
diff --git a/Dockerfile b/Dockerfile
new file mode 100644
index 0000000..4999a51
--- /dev/null
+++ b/Dockerfile
@@ -0,0 +1,37 @@
+# syntax=docker/dockerfile:1
+
+FROM oven/bun:1 AS frontend-build
+WORKDIR /app/frontend
+
+COPY frontend/package.json frontend/bun.lock ./
+RUN bun install --frozen-lockfile --ignore-scripts
+
+COPY frontend/ ./
+RUN bun run build
+
+FROM python:3.12-slim AS runtime
+
+ENV PYTHONDONTWRITEBYTECODE=1 \
+ PYTHONUNBUFFERED=1 \
+ HOST=0.0.0.0 \
+ PORT=8000
+
+WORKDIR /app
+
+RUN useradd --create-home --shell /usr/sbin/nologin robotu
+
+COPY requirements.txt ./
+RUN pip install --no-cache-dir -r requirements.txt
+
+COPY app.py auth.py calendar_feeds.py forgejo_client.py live_prototype.py prototype_cache.py settings.py update_events.py ./
+COPY --from=frontend-build /app/frontend/dist ./frontend/dist
+
+RUN chown -R robotu:robotu /app
+USER robotu
+
+EXPOSE 8000
+
+HEALTHCHECK --interval=30s --timeout=5s --start-period=20s --retries=3 \
+ CMD python -c "import urllib.request; urllib.request.urlopen('http://127.0.0.1:8000/health', timeout=3).read()"
+
+CMD ["python", "-m", "uvicorn", "app:app", "--host", "0.0.0.0", "--port", "8000", "--proxy-headers"]
diff --git a/README.md b/README.md
index 4a6a6e4..72de395 100644
--- a/README.md
+++ b/README.md
@@ -40,6 +40,8 @@ Optional live Forgejo configuration:
```bash
export APP_BASE_URL="http://kacper-dev-pod:8800"
export AUTH_SECRET_KEY="$(openssl rand -hex 32)"
+export AUTH_COOKIE_SECURE="false"
+export CORS_ALLOW_ORIGINS="http://kacper-dev-pod:8800"
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"
@@ -58,6 +60,8 @@ 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.
+`CORS_ALLOW_ORIGINS` defaults to `APP_BASE_URL` when that value is set. Use a comma-separated list if the API must be called from additional browser origins.
+
`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.
@@ -92,3 +96,83 @@ cd frontend
./scripts/check_python_quality.sh
./scripts/check_frontend_quality.sh
```
+
+## Deployment
+
+### Forgejo Actions SSH Clone Bootstrap
+
+If the Forgejo instance only supports SSH clone in Actions, create a repo deploy key and matching Actions secret:
+
+```bash
+export FORGEJO_API_TOKEN=your-forgejo-api-token
+./scripts/bootstrap_ci_clone_key.py
+```
+
+Defaults:
+
+```text
+FORGEJO_BASE_URL=https://aksal.cloud
+FORGEJO_REPO=Robot-U/robot-u-site
+CI_CLONE_KEY_TITLE=robot-u-site-actions-clone
+CI_CLONE_SECRET_NAME=CI_REPO_SSH_KEY
+```
+
+The script generates a temporary Ed25519 keypair, adds the public key as a read-only deploy key on the repo, and stores the private key in the repo Actions secret `CI_REPO_SSH_KEY`.
+
+### Required Production Settings
+
+Create a production `.env` from `.env.example` and set at least:
+
+```bash
+APP_BASE_URL=https://your-site.example
+AUTH_SECRET_KEY=$(openssl rand -hex 32)
+AUTH_COOKIE_SECURE=true
+CORS_ALLOW_ORIGINS=https://your-site.example
+FORGEJO_BASE_URL=https://aksal.cloud
+FORGEJO_OAUTH_CLIENT_ID=...
+FORGEJO_OAUTH_CLIENT_SECRET=...
+FORGEJO_GENERAL_DISCUSSION_REPO=Robot-U/general_forum
+FORGEJO_WEBHOOK_SECRET=$(openssl rand -hex 32)
+```
+
+Then validate the deployment environment:
+
+```bash
+./scripts/check_deploy_config.py
+```
+
+The Forgejo OAuth app must include this redirect URI:
+
+```text
+https://your-site.example/api/auth/forgejo/callback
+```
+
+### Docker Compose
+
+```bash
+cp .env.example .env
+$EDITOR .env
+./scripts/check_deploy_config.py
+docker compose up --build -d
+curl -fsS http://127.0.0.1:8800/health
+```
+
+The compose file exposes the app on host port `8800` and runs Uvicorn on container port `8000`.
+
+### Non-Container Deployment
+
+```bash
+python3 -m venv .venv
+.venv/bin/pip install -r requirements.txt
+cd frontend
+~/.bun/bin/bun install
+~/.bun/bin/bun run build
+cd ..
+HOST=0.0.0.0 PORT=8000 ./scripts/run_prod.sh
+```
+
+Put a reverse proxy in front of the app for TLS. Preserve long-lived connections for `/api/events/stream`, and configure Forgejo webhooks to POST to:
+
+```text
+https://your-site.example/api/forgejo/webhook
+```
diff --git a/app.py b/app.py
index 27ebcef..a0b8da4 100644
--- a/app.py
+++ b/app.py
@@ -18,10 +18,14 @@ from auth import (
create_login_session,
create_oauth_state,
current_session_user,
- resolve_forgejo_token,
+ resolve_forgejo_auth,
)
from forgejo_client import ForgejoClient, ForgejoClientError
-from live_prototype import discussion_card_from_issue
+from live_prototype import (
+ DISCUSSION_LABEL_NAME,
+ discussion_card_from_issue,
+ issue_has_discussion_label,
+)
from prototype_cache import PrototypePayloadCache
from settings import Settings, get_settings
from update_events import UpdateBroker, stream_sse_events
@@ -31,15 +35,22 @@ DIST_DIR = BASE_DIR / "frontend" / "dist"
def create_app() -> FastAPI:
- app = FastAPI(title="Robot U Community Prototype")
+ settings = get_settings()
+ app = FastAPI(title="Robot U Community Site")
prototype_cache = PrototypePayloadCache()
update_broker = UpdateBroker()
app.add_middleware(
CORSMiddleware,
- allow_origins=["*"],
- allow_methods=["*"],
- allow_headers=["*"],
+ allow_origins=list(settings.cors_allow_origins),
+ allow_methods=["GET", "POST", "DELETE", "OPTIONS"],
+ allow_headers=[
+ "Authorization",
+ "Content-Type",
+ "X-Forgejo-Signature",
+ "X-Gitea-Signature",
+ "X-Hub-Signature-256",
+ ],
)
@app.get("/health")
@@ -50,12 +61,13 @@ def create_app() -> FastAPI:
async def prototype(request: Request) -> JSONResponse:
settings = get_settings()
session_user = current_session_user(request, settings)
- forgejo_token, auth_source = resolve_forgejo_token(request, settings)
+ forgejo_token, auth_source, auth_scheme = resolve_forgejo_auth(request, settings)
payload = await prototype_cache.get(settings)
payload["auth"] = await _auth_payload_for_request(
settings,
forgejo_token=forgejo_token,
auth_source=auth_source,
+ auth_scheme=auth_scheme,
session_user=session_user,
)
return JSONResponse(payload)
@@ -67,11 +79,13 @@ def create_app() -> FastAPI:
if session_user:
return JSONResponse(_auth_payload(session_user, "session"))
- forgejo_token, auth_source = resolve_forgejo_token(request, settings)
+ forgejo_token, auth_source, auth_scheme = resolve_forgejo_auth(request, settings)
if not forgejo_token or auth_source == "server":
return JSONResponse(_auth_payload(None, "none"))
- async with ForgejoClient(settings, forgejo_token=forgejo_token) as client:
+ async with ForgejoClient(
+ settings, forgejo_token=forgejo_token, auth_scheme=auth_scheme
+ ) as client:
try:
user = await client.fetch_current_user()
except ForgejoClientError as error:
@@ -183,6 +197,11 @@ def create_app() -> FastAPI:
detail="This site only reads public Forgejo repositories.",
)
issue = await client.fetch_issue(owner, repo, issue_number)
+ if not issue_has_discussion_label(issue):
+ raise HTTPException(
+ status_code=404,
+ detail="This issue is not labeled as a discussion.",
+ )
comments = [
_discussion_reply(comment)
for comment in await client.list_issue_comments(owner, repo, issue_number)
@@ -203,14 +222,16 @@ def create_app() -> FastAPI:
body = _required_string(payload, "body")
issue_number = _required_positive_int(payload, "number")
settings = get_settings()
- forgejo_token, auth_source = resolve_forgejo_token(request, settings)
+ forgejo_token, auth_source, auth_scheme = resolve_forgejo_auth(request, settings)
if not forgejo_token or auth_source == "server":
raise HTTPException(
status_code=401,
detail="Sign in or send an Authorization token before replying.",
)
- async with ForgejoClient(settings, forgejo_token=forgejo_token) as client:
+ async with ForgejoClient(
+ settings, forgejo_token=forgejo_token, auth_scheme=auth_scheme
+ ) as client:
try:
repo_payload = await client.fetch_repository(owner, repo)
if repo_payload.get("private"):
@@ -248,14 +269,16 @@ def create_app() -> FastAPI:
)
settings = get_settings()
- forgejo_token, auth_source = resolve_forgejo_token(request, settings)
+ forgejo_token, auth_source, auth_scheme = resolve_forgejo_auth(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:
+ async with ForgejoClient(
+ settings, forgejo_token=forgejo_token, auth_scheme=auth_scheme
+ ) as client:
try:
repo_payload = await client.fetch_repository(owner, repo)
if repo_payload.get("private"):
@@ -263,11 +286,29 @@ def create_app() -> FastAPI:
status_code=403,
detail="This site only writes to public Forgejo repositories.",
)
- issue = await client.create_issue(owner, repo, title, issue_body)
+ discussion_label_id = await client.ensure_repo_label(
+ owner,
+ repo,
+ DISCUSSION_LABEL_NAME,
+ color="#0f6f8f",
+ description="Shown on Robot U as a community discussion.",
+ )
+ issue = await client.create_issue(
+ owner,
+ repo,
+ title,
+ issue_body,
+ label_ids=[discussion_label_id],
+ )
except ForgejoClientError as error:
raise HTTPException(status_code=502, detail=str(error)) from error
issue["repository"] = _issue_repository_payload(repo_payload, owner, repo)
+ if not issue_has_discussion_label(issue):
+ issue["labels"] = [
+ *[label for label in issue.get("labels", []) if isinstance(label, dict)],
+ {"id": discussion_label_id, "name": DISCUSSION_LABEL_NAME},
+ ]
prototype_cache.invalidate()
await update_broker.publish(
"content-updated",
@@ -429,6 +470,7 @@ async def _auth_payload_for_request(
*,
forgejo_token: str | None,
auth_source: str,
+ auth_scheme: str,
session_user: dict[str, Any] | None,
) -> dict[str, object]:
if session_user:
@@ -437,7 +479,9 @@ async def _auth_payload_for_request(
if not forgejo_token or auth_source == "server":
return _auth_payload(None, "none")
- async with ForgejoClient(settings, forgejo_token=forgejo_token) as client:
+ async with ForgejoClient(
+ settings, forgejo_token=forgejo_token, auth_scheme=auth_scheme
+ ) as client:
try:
user = await client.fetch_current_user()
except ForgejoClientError as error:
diff --git a/auth.py b/auth.py
index 2616a00..1076437 100644
--- a/auth.py
+++ b/auth.py
@@ -37,19 +37,20 @@ class OAuthStateRecord:
_OAUTH_STATES: dict[str, OAuthStateRecord] = {}
-def resolve_forgejo_token(request: Request, settings: Settings) -> tuple[str | None, str]:
- header_token = _authorization_token(request.headers.get("authorization"))
- if header_token:
- return header_token, "authorization"
+def resolve_forgejo_auth(request: Request, settings: Settings) -> tuple[str | None, str, str]:
+ header_credential = _authorization_credential(request.headers.get("authorization"))
+ if header_credential:
+ header_token, auth_scheme = header_credential
+ return header_token, "authorization", auth_scheme
session = _session_from_request(request, settings)
if session and session.forgejo_token:
- return session.forgejo_token, "session"
+ return session.forgejo_token, "session", "Bearer"
if settings.forgejo_token:
- return settings.forgejo_token, "server"
+ return settings.forgejo_token, "server", "token"
- return None, "none"
+ return None, "none", "token"
def current_session_user(request: Request, settings: Settings) -> dict[str, Any] | None:
@@ -113,13 +114,16 @@ def code_challenge(code_verifier: str) -> str:
return urlsafe_b64encode(digest).decode("ascii").rstrip("=")
-def _authorization_token(value: str | None) -> str | None:
+def _authorization_credential(value: str | None) -> tuple[str, str] | None:
if not value:
return None
parts = value.strip().split(None, 1)
if len(parts) == 2 and parts[0].lower() in {"bearer", "token"}:
- return parts[1].strip() or None
+ token = parts[1].strip()
+ if not token:
+ return None
+ return token, "Bearer" if parts[0].lower() == "bearer" else "token"
return None
diff --git a/docker-compose.yml b/docker-compose.yml
new file mode 100644
index 0000000..b36513d
--- /dev/null
+++ b/docker-compose.yml
@@ -0,0 +1,25 @@
+services:
+ robot-u-site:
+ build:
+ context: .
+ restart: unless-stopped
+ env_file:
+ - path: .env
+ required: false
+ environment:
+ HOST: 0.0.0.0
+ PORT: 8000
+ ports:
+ - "8800:8000"
+ healthcheck:
+ test:
+ [
+ "CMD",
+ "python",
+ "-c",
+ "import urllib.request; urllib.request.urlopen('http://127.0.0.1:8000/health', timeout=3).read()",
+ ]
+ interval: 30s
+ timeout: 5s
+ retries: 3
+ start_period: 20s
diff --git a/forgejo_client.py b/forgejo_client.py
index ebe5443..1df63a6 100644
--- a/forgejo_client.py
+++ b/forgejo_client.py
@@ -14,9 +14,15 @@ class ForgejoClientError(RuntimeError):
class ForgejoClient:
- def __init__(self, settings: Settings, forgejo_token: str | None = None) -> None:
+ def __init__(
+ self,
+ settings: Settings,
+ forgejo_token: str | None = None,
+ auth_scheme: str = "token",
+ ) -> None:
self._settings = settings
self._forgejo_token = forgejo_token or settings.forgejo_token
+ self._auth_scheme = auth_scheme
self._client = httpx.AsyncClient(timeout=settings.forgejo_request_timeout_seconds)
async def __aenter__(self) -> ForgejoClient:
@@ -193,17 +199,76 @@ class ForgejoClient:
repo: str,
title: str,
body: str,
+ label_ids: list[int] | None = None,
) -> dict[str, Any]:
+ payload_data: dict[str, object] = {"title": title, "body": body}
+ if label_ids:
+ payload_data["labels"] = label_ids
+
payload = await self._request_json(
"POST",
f"/api/v1/repos/{owner}/{repo}/issues",
- json_payload={"title": title, "body": body},
+ json_payload=payload_data,
auth_required=True,
)
if isinstance(payload, dict):
return payload
raise ForgejoClientError(f"Unexpected issue payload for {owner}/{repo}")
+ async def ensure_repo_label(
+ self,
+ owner: str,
+ repo: str,
+ name: str,
+ *,
+ color: str,
+ description: str,
+ ) -> int:
+ for label in await self.list_repo_labels(owner, repo):
+ if str(label.get("name", "")).strip().casefold() != name.casefold():
+ continue
+
+ label_id = int(label.get("id", 0) or 0)
+ if label_id > 0:
+ return label_id
+
+ label = await self.create_repo_label(
+ owner, repo, name, color=color, description=description
+ )
+ label_id = int(label.get("id", 0) or 0)
+ if label_id <= 0:
+ raise ForgejoClientError(f"Forgejo did not return an id for label {name!r}.")
+ return label_id
+
+ async def list_repo_labels(self, owner: str, repo: str) -> list[dict[str, Any]]:
+ payload = await self._get_json(
+ f"/api/v1/repos/{owner}/{repo}/labels",
+ params={"page": 1, "limit": 100},
+ auth_required=True,
+ )
+ if isinstance(payload, list):
+ return [label for label in payload if isinstance(label, dict)]
+ return []
+
+ async def create_repo_label(
+ self,
+ owner: str,
+ repo: str,
+ name: str,
+ *,
+ color: str,
+ description: str,
+ ) -> dict[str, Any]:
+ payload = await self._request_json(
+ "POST",
+ f"/api/v1/repos/{owner}/{repo}/labels",
+ json_payload={"name": name, "color": color, "description": description},
+ auth_required=True,
+ )
+ if isinstance(payload, dict):
+ return payload
+ raise ForgejoClientError(f"Unexpected label 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('/')}",
@@ -261,7 +326,8 @@ class ForgejoClient:
url = path if absolute_url else f"{self._settings.forgejo_base_url}{path}"
headers = {}
if self._forgejo_token:
- headers["Authorization"] = f"token {self._forgejo_token}"
+ scheme = "Bearer" if self._auth_scheme.casefold() == "bearer" else "token"
+ headers["Authorization"] = f"{scheme} {self._forgejo_token}"
response = await self._client.request(
method,
diff --git a/frontend/src/App.tsx b/frontend/src/App.tsx
index 100c093..8342ffd 100644
--- a/frontend/src/App.tsx
+++ b/frontend/src/App.tsx
@@ -32,6 +32,35 @@ function formatTimestamp(value: string): string {
}).format(date);
}
+function initialsFor(value: string): string {
+ const parts = value
+ .trim()
+ .split(/\s+|[-_]/)
+ .filter(Boolean);
+ const initials = parts
+ .slice(0, 2)
+ .map((part) => part[0]?.toUpperCase() || "")
+ .join("");
+ return initials || "?";
+}
+
+function plainTextExcerpt(markdown: string, limit = 150): string {
+ const text = markdown
+ .replace(/```[\s\S]*?```/g, " ")
+ .replace(/!\[[^\]]*]\([^)]+\)/g, " ")
+ .replace(/\[([^\]]+)]\([^)]+\)/g, "$1")
+ .replace(/[#>*_`-]/g, " ")
+ .replace(/\s+/g, " ")
+ .trim();
+ if (!text) {
+ return "No description yet. Open the thread to see the discussion.";
+ }
+ if (text.length <= limit) {
+ return text;
+ }
+ return `${text.slice(0, limit).trim()}...`;
+}
+
function normalizePathname(pathname: string): string {
if (!pathname || pathname === "/") {
return "/";
@@ -256,6 +285,19 @@ function EmptyState(props: { copy: string }) {
return
{props.copy}
;
}
+function Avatar(props: { name: string; imageUrl?: string; className?: string }) {
+ const className = props.className ? `avatar ${props.className}` : "avatar";
+ if (props.imageUrl) {
+ return
;
+ }
+
+ return (
+
+ {initialsFor(props.name)}
+
+ );
+}
+
function canUseInteractiveAuth(auth: AuthState): boolean {
return auth.authenticated && auth.can_reply;
}
@@ -433,11 +475,21 @@ function PostItem(props: { post: PostCard; onOpenPost: (post: PostCard) => void
onOpenPost(post);
}}
>
- {post.title}
- {post.summary}
-
- {post.repo} · {post.file_path || post.path}
-
+
+
+ {post.owner}
+
+
+ {post.title}
+ {post.summary || plainTextExcerpt(post.body, 170)}
+
+ {post.repo}
+ {formatTimestamp(post.updated_at)}
+ {post.assets.length > 0 ? (
+ {post.assets.length} downloads
+ ) : null}
+
+
);
}
@@ -454,25 +506,89 @@ function EventItem(props: { event: EventCard }) {
);
}
+function discussionBadges(discussion: DiscussionCard): string[] {
+ const badges = new Set();
+ if (discussion.links.length > 0) {
+ badges.add("linked");
+ }
+ if (discussion.repo.toLowerCase().endsWith("/general_forum")) {
+ badges.add("general");
+ }
+ for (const label of discussion.labels.slice(0, 3)) {
+ badges.add(label);
+ }
+ return Array.from(badges).slice(0, 4);
+}
+
function DiscussionPreviewItem(props: {
discussion: DiscussionCard;
onOpenDiscussion: (discussion: DiscussionCard) => void;
+ compact?: boolean;
}) {
- const { discussion, onOpenDiscussion } = props;
+ const { discussion, onOpenDiscussion, compact = false } = props;
+ const badges = discussionBadges(discussion);
+ const cardClass = compact ? "discussion-preview-card compact" : "discussion-preview-card";
+ const postedAt = discussion.created_at || discussion.updated_at;
+
+ if (compact) {
+ return (
+
+ );
+ }
return (
);
}
@@ -482,9 +598,12 @@ function DiscussionReplyCard(props: { reply: DiscussionReply }) {
return (
- {reply.author}
- {formatTimestamp(reply.created_at)}
-
+
+
+
{reply.author}
+
{formatTimestamp(reply.created_at)}
+
+
);
}
@@ -998,17 +1117,22 @@ function PostPage(props: {
return (
-
-
+
+
- {post.summary ? {post.summary}
: null}
-
+
@@ -1064,6 +1188,8 @@ function DiscussionPage(props: {
onReplyCreated: (discussionId: number, reply: DiscussionReply) => void;
}) {
const { discussion, auth, onGoHome, onGoSignIn, onReplyCreated } = props;
+ const postedAt = discussion.created_at || discussion.updated_at;
+ const replyCount = discussion.comments.length || discussion.replies;
return (
@@ -1071,21 +1197,26 @@ function DiscussionPage(props: {
Back to discussions
-
-
+
+
+ {replyCount} replies
{discussion.comments.length > 0 ? (
@@ -1138,8 +1269,16 @@ function DiscussionsView(props: {
onGoSignIn: () => void;
onDiscussionCreated: (discussion: DiscussionCard) => void;
showComposer?: boolean;
+ compactItems?: boolean;
}) {
- const { data, onOpenDiscussion, onGoSignIn, onDiscussionCreated, showComposer = true } = props;
+ const {
+ data,
+ onOpenDiscussion,
+ onGoSignIn,
+ onDiscussionCreated,
+ showComposer = true,
+ compactItems = false,
+ } = props;
const generalDiscussionConfigured =
data.discussion_settings?.general_discussion_configured ?? false;
@@ -1175,11 +1314,12 @@ function DiscussionsView(props: {
key={discussion.id}
discussion={discussion}
onOpenDiscussion={onOpenDiscussion}
+ compact={compactItems}
/>
))}
) : (
-
+
)}
);
@@ -1242,6 +1382,7 @@ function HomeView(props: {
onGoSignIn={onGoSignIn}
onDiscussionCreated={onDiscussionCreated}
showComposer={false}
+ compactItems={true}
/>
>
);
@@ -1708,6 +1849,7 @@ function AppContent(props: AppContentProps) {
onOpenDiscussion={props.onOpenDiscussion}
onGoSignIn={props.onGoSignIn}
onDiscussionCreated={props.onDiscussionCreated}
+ compactItems={true}
/>
);
}
diff --git a/frontend/src/index.css b/frontend/src/index.css
index 80efae0..d0e22cd 100644
--- a/frontend/src/index.css
+++ b/frontend/src/index.css
@@ -4,10 +4,14 @@
line-height: 1.5;
font-weight: 400;
- --bg: linear-gradient(180deg, #fffaf0 0%, #f3ede1 100%);
+ --bg:
+ radial-gradient(circle at 14% 8%, rgba(15, 111, 143, 0.12), transparent 28rem),
+ radial-gradient(circle at 84% 0%, rgba(191, 125, 22, 0.12), transparent 24rem),
+ linear-gradient(180deg, #fffaf0 0%, #f3ede1 100%);
--panel: #fffdf8;
--panel-hover: #f1eadf;
--card: #ffffff;
+ --card-elevated: #fffffc;
--border: #ded5c7;
--text: #1f2933;
--muted: #667085;
@@ -24,6 +28,8 @@
--disabled-bg: #ece6dc;
--disabled-text: #8a8174;
--error: #a64234;
+ --shadow-soft: 0 1.25rem 3.5rem rgba(80, 65, 42, 0.11);
+ --shadow-row: 0 0.65rem 1.6rem rgba(80, 65, 42, 0.08);
}
* {
@@ -60,9 +66,9 @@ textarea {
}
.app-shell {
- width: min(72rem, 100%);
+ width: min(76rem, 100%);
margin: 0 auto;
- padding: 2rem 1rem 3rem;
+ padding: 2.25rem 1rem 3.5rem;
}
.topbar {
@@ -76,6 +82,7 @@ textarea {
border-bottom: 0.0625rem solid var(--accent-border);
padding: 0.75rem clamp(1rem, 4vw, 2rem);
background: var(--topbar-bg);
+ box-shadow: 0 0.45rem 1.5rem rgba(31, 41, 51, 0.06);
backdrop-filter: blur(1rem);
}
@@ -190,7 +197,8 @@ textarea {
.page-message {
border: 0.0625rem solid var(--border);
background: var(--panel);
- border-radius: 0.9rem;
+ border-radius: 1rem;
+ box-shadow: var(--shadow-soft);
}
.page-header,
@@ -201,6 +209,8 @@ textarea {
.page-header {
margin-bottom: 1rem;
+ border-color: var(--accent-border);
+ background: linear-gradient(135deg, rgba(15, 111, 143, 0.09), transparent 42%), var(--panel);
}
.page-header h1,
@@ -258,8 +268,8 @@ textarea {
.activity-card,
.reply-card {
border: 0.0625rem solid var(--border);
- border-radius: 0.75rem;
- background: var(--card);
+ border-radius: 0.9rem;
+ background: var(--card-elevated);
}
.card,
@@ -276,11 +286,44 @@ textarea {
color: inherit;
}
+.post-card-button {
+ display: grid;
+ gap: 0.55rem;
+ min-height: 13rem;
+ align-content: start;
+ border-color: rgba(15, 111, 143, 0.22);
+ background:
+ linear-gradient(145deg, rgba(15, 111, 143, 0.09), transparent 58%), var(--card-elevated);
+ transition:
+ transform 160ms ease,
+ box-shadow 160ms ease,
+ background 160ms ease;
+}
+
+.post-card-author-row {
+ display: flex;
+ align-items: center;
+ gap: 0.55rem;
+}
+
+.post-card-main {
+ display: grid;
+ min-width: 0;
+ gap: 0.25rem;
+}
+
+.post-card-author {
+ color: var(--muted);
+ font-size: 0.82rem;
+}
+
.course-card-button:hover,
.course-card-button:focus-visible,
.post-card-button:hover,
.post-card-button:focus-visible {
background: var(--panel-hover);
+ box-shadow: var(--shadow-row);
+ transform: translateY(-0.0625rem);
outline: none;
}
@@ -306,10 +349,18 @@ textarea {
.discussion-preview-card {
width: 100%;
- padding: 1rem;
+ display: grid;
+ grid-template-columns: auto minmax(0, 1fr) minmax(6.5rem, auto);
+ gap: 0.9rem;
+ align-items: center;
+ padding: 0.95rem;
cursor: pointer;
text-align: left;
color: inherit;
+ transition:
+ transform 160ms ease,
+ box-shadow 160ms ease,
+ background 160ms ease;
}
.activity-card-button {
@@ -324,9 +375,177 @@ textarea {
.activity-card-button:hover,
.activity-card-button:focus-visible {
background: var(--panel-hover);
+ box-shadow: var(--shadow-row);
+ transform: translateY(-0.0625rem);
outline: none;
}
+.discussion-preview-card.compact {
+ grid-template-columns: 1fr;
+ gap: 0.65rem;
+ align-items: stretch;
+ padding: 0.9rem 0.95rem;
+}
+
+.avatar {
+ display: inline-grid;
+ width: 2.75rem;
+ height: 2.75rem;
+ place-items: center;
+ flex: 0 0 auto;
+ border: 0.125rem solid var(--panel);
+ border-radius: 999rem;
+ color: #ffffff;
+ background: linear-gradient(135deg, rgba(255, 255, 255, 0.2), transparent), var(--accent);
+ box-shadow: 0 0.35rem 0.8rem rgba(15, 111, 143, 0.18);
+ font-size: 0.9rem;
+ font-weight: 800;
+ object-fit: cover;
+}
+
+.avatar-small {
+ width: 2.2rem;
+ height: 2.2rem;
+ font-size: 0.75rem;
+}
+
+.topic-main {
+ display: grid;
+ min-width: 0;
+ gap: 0.4rem;
+}
+
+.topic-title-row {
+ display: flex;
+ align-items: center;
+ gap: 0.55rem;
+ min-width: 0;
+}
+
+.topic-title-row h1,
+.topic-title-row h3 {
+ min-width: 0;
+}
+
+.topic-title-row h3 {
+ margin-bottom: 0;
+}
+
+.status-pill,
+.topic-badge {
+ display: inline-flex;
+ width: max-content;
+ align-items: center;
+ border-radius: 999rem;
+ white-space: nowrap;
+ font-size: 0.72rem;
+ font-weight: 750;
+ letter-spacing: 0.02em;
+ text-transform: uppercase;
+}
+
+.status-pill {
+ border: 0.0625rem solid rgba(15, 111, 143, 0.18);
+ padding: 0.12rem 0.45rem;
+ color: var(--accent-strong);
+ background: rgba(15, 111, 143, 0.08);
+}
+
+.topic-excerpt {
+ display: -webkit-box;
+ overflow: hidden;
+ color: var(--muted);
+ font-size: 0.93rem;
+ -webkit-box-orient: vertical;
+ -webkit-line-clamp: 2;
+}
+
+.topic-meta-row {
+ display: flex;
+ flex-wrap: wrap;
+ align-items: center;
+ gap: 0.4rem;
+ color: var(--muted);
+ font-size: 0.82rem;
+}
+
+.topic-meta-row-spaced {
+ margin-top: 0.75rem;
+}
+
+.topic-badge {
+ border: 0.0625rem solid var(--border);
+ padding: 0.15rem 0.42rem;
+ color: #5f513f;
+ background: #f7efe2;
+}
+
+.post-card-excerpt {
+ display: -webkit-box;
+ overflow: hidden;
+ color: var(--muted);
+ -webkit-box-orient: vertical;
+ -webkit-line-clamp: 4;
+}
+
+.topic-stats {
+ display: grid;
+ gap: 0.3rem;
+ justify-items: end;
+ color: var(--muted);
+ font-size: 0.82rem;
+ text-align: right;
+}
+
+.topic-stats strong {
+ display: block;
+ color: var(--text);
+ font-size: 1.25rem;
+ line-height: 1;
+}
+
+.discussion-compact-meta {
+ display: flex;
+ min-width: 0;
+ align-items: center;
+ gap: 0.5rem;
+ color: var(--muted);
+ font-size: 0.82rem;
+}
+
+.discussion-compact-author {
+ overflow: hidden;
+ color: var(--text);
+ font-weight: 750;
+ text-overflow: ellipsis;
+ white-space: nowrap;
+}
+
+.discussion-compact-preview {
+ display: grid;
+ min-width: 0;
+ gap: 0.25rem;
+}
+
+.discussion-compact-title {
+ overflow: hidden;
+ color: var(--text);
+ font-weight: 800;
+ line-height: 1.25;
+ text-overflow: ellipsis;
+ white-space: nowrap;
+}
+
+.discussion-preview-card.compact .topic-excerpt {
+ -webkit-line-clamp: 2;
+}
+
+.discussion-compact-replies {
+ color: var(--muted);
+ font-size: 0.78rem;
+ font-weight: 700;
+}
+
.muted-copy,
.meta-line,
.empty-state {
@@ -407,6 +626,94 @@ textarea {
margin-bottom: 1rem;
}
+.discussion-detail-panel {
+ display: grid;
+ gap: 0.95rem;
+}
+
+.discussion-detail-header {
+ display: grid;
+ gap: 0.7rem;
+ margin-bottom: 0;
+}
+
+.discussion-detail-meta {
+ display: flex;
+ min-width: 0;
+ align-items: center;
+ gap: 0.55rem;
+ color: var(--muted);
+ font-size: 0.86rem;
+}
+
+.discussion-detail-replies {
+ width: max-content;
+ color: var(--muted);
+ font-size: 0.82rem;
+ font-weight: 750;
+}
+
+.forum-thread-header {
+ display: grid;
+ grid-template-columns: auto minmax(0, 1fr) auto;
+ gap: 1rem;
+ align-items: start;
+}
+
+.forum-thread-title {
+ min-width: 0;
+}
+
+.thread-count-card {
+ display: grid;
+ min-width: 5rem;
+ justify-items: center;
+ border: 0.0625rem solid var(--accent-border);
+ border-radius: 0.85rem;
+ padding: 0.7rem;
+ color: var(--muted);
+ background: var(--accent-soft);
+ font-size: 0.8rem;
+ text-transform: uppercase;
+}
+
+.thread-count-card strong {
+ color: var(--text);
+ font-size: 1.5rem;
+ line-height: 1;
+}
+
+.post-hero-panel {
+ overflow: hidden;
+ border-color: var(--accent-border);
+ background:
+ radial-gradient(circle at 95% 0%, rgba(15, 111, 143, 0.16), transparent 15rem),
+ radial-gradient(circle at 0% 100%, rgba(191, 125, 22, 0.12), transparent 14rem), var(--panel);
+}
+
+.post-hero {
+ display: grid;
+ gap: 0.9rem;
+}
+
+.post-hero-meta {
+ display: flex;
+ align-items: center;
+ gap: 0.65rem;
+ min-width: 0;
+}
+
+.post-hero-summary {
+ max-width: none;
+ color: var(--muted);
+ font-size: 1.08rem;
+}
+
+.post-reading-panel {
+ width: 100%;
+ padding: clamp(1.25rem, 3vw, 2rem);
+}
+
.panel-actions {
margin-top: 1rem;
}
@@ -417,7 +724,26 @@ textarea {
}
.reply-author {
- font-weight: 600;
+ color: var(--text);
+ font-weight: 750;
+}
+
+.reply-card {
+ display: grid;
+ gap: 0.8rem;
+}
+
+.reply-meta-row {
+ display: flex;
+ min-width: 0;
+ align-items: center;
+ gap: 0.55rem;
+ color: var(--muted);
+ font-size: 0.86rem;
+}
+
+.reply-copy {
+ min-width: 0;
}
.outline-list,
@@ -744,6 +1070,34 @@ textarea {
grid-template-columns: 1fr;
}
+ .discussion-preview-card {
+ grid-template-columns: auto minmax(0, 1fr);
+ }
+
+ .discussion-preview-card.compact {
+ grid-template-columns: 1fr;
+ }
+
+ .topic-stats {
+ grid-column: 2;
+ justify-items: start;
+ text-align: left;
+ }
+
+ .topic-title-row {
+ flex-wrap: wrap;
+ }
+
+ .forum-thread-header {
+ grid-template-columns: auto minmax(0, 1fr);
+ }
+
+ .thread-count-card {
+ grid-column: 1 / -1;
+ width: 100%;
+ justify-items: start;
+ }
+
.compose-actions,
.auth-bar,
.signin-callout,
diff --git a/frontend/src/types.ts b/frontend/src/types.ts
index 80f79c3..a1f6ff0 100644
--- a/frontend/src/types.ts
+++ b/frontend/src/types.ts
@@ -28,6 +28,7 @@ export interface ContentAsset {
export interface CourseCard {
title: string;
owner: string;
+ owner_avatar_url: string;
name: string;
repo: string;
html_url: string;
@@ -60,6 +61,7 @@ export interface CourseLesson {
export interface PostCard {
title: string;
owner: string;
+ owner_avatar_url: string;
name: string;
repo: string;
slug: string;
@@ -92,6 +94,7 @@ export interface DiscussionCard {
state: string;
body: string;
number: number;
+ created_at: string;
updated_at: string;
html_url: string;
labels: string[];
diff --git a/live_prototype.py b/live_prototype.py
index 8f8ac8f..3c917eb 100644
--- a/live_prototype.py
+++ b/live_prototype.py
@@ -9,6 +9,8 @@ from calendar_feeds import CalendarFeed, CalendarFeedError, fetch_calendar_feed
from forgejo_client import ForgejoClient, ForgejoClientError
from settings import Settings
+DISCUSSION_LABEL_NAME = "discussion"
+
async def build_live_prototype_payload(
settings: Settings,
@@ -109,7 +111,7 @@ async def build_live_prototype_payload(
"title": "Discovery state",
"description": (
f"Detected {len(course_repos)} course repos, {len(post_repos)} post repos, "
- f"and {len(public_issues)} recent public issues."
+ f"and {len(public_issues)} recent discussion issues."
),
},
)
@@ -126,7 +128,7 @@ async def build_live_prototype_payload(
"highlights": [
"Repo discovery filters to public, non-fork repositories only",
"Course repos are detected from /lessons/, post repos from /blogs/",
- "Recent discussions are loaded from live Forgejo issues",
+ "Recent discussions are loaded from live Forgejo issues labeled discussion",
],
},
"auth": _auth_payload(
@@ -301,6 +303,7 @@ async def _summarize_repo(
str(repo.get("full_name", f"{owner_login}/{repo_name}")),
str(repo.get("description") or ""),
str(repo.get("updated_at", "")),
+ _repo_owner_avatar_url(repo),
default_branch,
str(repo.get("html_url", "")),
str(blog_dir.get("name", "")),
@@ -312,6 +315,7 @@ async def _summarize_repo(
return {
"name": repo_name,
"owner": owner_login,
+ "owner_avatar_url": _repo_owner_avatar_url(repo),
"full_name": repo.get("full_name", f"{owner_login}/{repo_name}"),
"html_url": repo.get("html_url", ""),
"description": repo.get("description") or "No repository description yet.",
@@ -328,6 +332,7 @@ def _course_card(summary: dict[str, Any]) -> dict[str, object]:
return {
"title": summary["name"],
"owner": summary["owner"],
+ "owner_avatar_url": summary["owner_avatar_url"],
"name": summary["name"],
"repo": summary["full_name"],
"html_url": summary["html_url"],
@@ -344,6 +349,7 @@ def _post_card(post: dict[str, Any]) -> dict[str, object]:
return {
"title": post["title"],
"owner": post["owner"],
+ "owner_avatar_url": post["owner_avatar_url"],
"name": post["name"],
"repo": post["repo"],
"slug": post["slug"],
@@ -359,6 +365,14 @@ def _post_card(post: dict[str, Any]) -> dict[str, object]:
}
+def issue_has_discussion_label(issue: dict[str, Any]) -> bool:
+ return any(
+ str(label.get("name", "")).strip().casefold() == DISCUSSION_LABEL_NAME
+ for label in issue.get("labels", [])
+ if isinstance(label, dict)
+ )
+
+
async def _recent_public_issues(
client: ForgejoClient,
repos: list[dict[str, Any]],
@@ -368,6 +382,7 @@ async def _recent_public_issues(
*[_repo_issues(client, repo, limit) for repo in repos],
)
issues = [issue for issue_list in issue_lists for issue in issue_list]
+ issues = [issue for issue in issues if issue_has_discussion_label(issue)]
return sorted(issues, key=lambda issue: str(issue.get("updated_at", "")), reverse=True)[:limit]
@@ -414,6 +429,13 @@ def _repo_owner_login(repo: dict[str, Any]) -> str | None:
return None
+def _repo_owner_avatar_url(repo: dict[str, Any]) -> str:
+ owner = repo.get("owner", {})
+ if isinstance(owner, dict) and isinstance(owner.get("avatar_url"), str):
+ return owner["avatar_url"]
+ return ""
+
+
def _event_cards(calendar_feeds: list[CalendarFeed], limit: int) -> list[dict[str, object]]:
upcoming_events = sorted(
[event for feed in calendar_feeds for event in feed.events],
@@ -481,6 +503,7 @@ def discussion_card_from_issue(
"state": issue.get("state", "open"),
"body": body,
"number": issue_number,
+ "created_at": issue.get("created_at", ""),
"updated_at": issue.get("updated_at", ""),
"html_url": issue.get("html_url", ""),
"labels": [label for label in labels if isinstance(label, str)],
@@ -687,6 +710,7 @@ async def _summarize_blog_post(
full_name: str,
repo_description: str,
updated_at: str,
+ owner_avatar_url: str,
default_branch: str,
repo_html_url: str,
post_name: str,
@@ -708,6 +732,7 @@ async def _summarize_blog_post(
updated_at,
post_path,
raw_base_url=raw_base_url,
+ owner_avatar_url=owner_avatar_url,
)
assets = _content_assets(post_entries, raw_base_url, post_path)
@@ -724,6 +749,7 @@ async def _summarize_blog_post(
post_path,
raw_base_url=raw_base_url,
assets=assets,
+ owner_avatar_url=owner_avatar_url,
)
markdown_name = str(markdown_files[0]["name"])
@@ -745,6 +771,7 @@ async def _summarize_blog_post(
html_url=str(markdown_files[0].get("html_url", "")),
raw_base_url=raw_base_url,
assets=assets,
+ owner_avatar_url=owner_avatar_url,
)
metadata, body = _parse_frontmatter(str(file_payload.get("content", "")))
@@ -752,6 +779,7 @@ async def _summarize_blog_post(
"slug": post_name,
"title": str(metadata.get("title") or _display_name(markdown_name) or fallback_title),
"owner": owner,
+ "owner_avatar_url": owner_avatar_url,
"name": repo,
"repo": full_name,
"summary": str(metadata.get("summary") or repo_description or ""),
@@ -964,11 +992,13 @@ def _empty_blog_post(
html_url: str = "",
raw_base_url: str = "",
assets: list[dict[str, object]] | None = None,
+ owner_avatar_url: str = "",
) -> dict[str, object]:
return {
"slug": post_name,
"title": title,
"owner": owner,
+ "owner_avatar_url": owner_avatar_url,
"name": repo,
"repo": full_name,
"summary": summary,
diff --git a/scripts/bootstrap_ci_clone_key.py b/scripts/bootstrap_ci_clone_key.py
new file mode 100755
index 0000000..33ae17d
--- /dev/null
+++ b/scripts/bootstrap_ci_clone_key.py
@@ -0,0 +1,173 @@
+#!/usr/bin/env python3
+from __future__ import annotations
+
+import json
+import os
+import shutil
+import subprocess
+import sys
+import tempfile
+from pathlib import Path
+from urllib.error import HTTPError
+from urllib.parse import quote
+from urllib.request import Request, urlopen
+
+
+DEFAULT_FORGEJO_BASE_URL = "https://aksal.cloud"
+DEFAULT_REPO = "Robot-U/robot-u-site"
+DEFAULT_KEY_TITLE = "robot-u-site-actions-clone"
+DEFAULT_SECRET_NAME = "CI_REPO_SSH_KEY"
+
+
+def main() -> int:
+ root_dir = Path(__file__).resolve().parents[1]
+ _load_env_file(root_dir / ".env")
+ _load_env_file(root_dir / ".env.local")
+
+ token = os.getenv("FORGEJO_API_TOKEN") or os.getenv("FORGEJO_TOKEN")
+ if not token:
+ print(
+ "Set FORGEJO_API_TOKEN to a Forgejo token that can manage repo keys/secrets.",
+ file=sys.stderr,
+ )
+ return 1
+
+ base_url = (os.getenv("FORGEJO_BASE_URL") or DEFAULT_FORGEJO_BASE_URL).rstrip("/")
+ repo = os.getenv("FORGEJO_REPO") or DEFAULT_REPO
+ key_title = os.getenv("CI_CLONE_KEY_TITLE") or DEFAULT_KEY_TITLE
+ secret_name = os.getenv("CI_CLONE_SECRET_NAME") or DEFAULT_SECRET_NAME
+ owner, repo_name = _repo_parts(repo)
+
+ ssh_keygen = shutil.which("ssh-keygen")
+ if not ssh_keygen:
+ print("ssh-keygen is required.", file=sys.stderr)
+ return 1
+
+ with tempfile.TemporaryDirectory(prefix="robot-u-ci-key.") as temp_dir:
+ key_path = Path(temp_dir) / "id_ed25519"
+ subprocess.run(
+ [
+ ssh_keygen,
+ "-t",
+ "ed25519",
+ "-N",
+ "",
+ "-C",
+ key_title,
+ "-f",
+ str(key_path),
+ ],
+ check=True,
+ stdout=subprocess.DEVNULL,
+ stderr=subprocess.DEVNULL,
+ )
+
+ public_key = key_path.with_suffix(".pub").read_text().strip()
+ private_key = key_path.read_text()
+
+ deploy_key = _create_deploy_key(
+ base_url,
+ token,
+ owner,
+ repo_name,
+ key_title,
+ public_key,
+ )
+ _put_actions_secret(base_url, token, owner, repo_name, secret_name, private_key)
+
+ print(f"Added read-only deploy key {deploy_key.get('id', '(unknown id)')} to {repo}.")
+ print(f"Updated repository Actions secret {secret_name}.")
+ print("Private key material was only held in memory and a temporary directory.")
+ return 0
+
+
+def _load_env_file(path: Path) -> None:
+ if not path.exists():
+ return
+
+ for raw_line in path.read_text().splitlines():
+ line = raw_line.strip()
+ if not line or line.startswith("#") or "=" not in line:
+ continue
+
+ key, value = line.split("=", 1)
+ key = key.strip().removeprefix("export ").strip()
+ value = value.strip().strip('"').strip("'")
+ os.environ.setdefault(key, value)
+
+
+def _repo_parts(repo: str) -> tuple[str, str]:
+ parts = repo.split("/", 1)
+ if len(parts) != 2 or not parts[0] or not parts[1]:
+ raise SystemExit("FORGEJO_REPO must use owner/repo format.")
+ return parts[0], parts[1]
+
+
+def _create_deploy_key(
+ base_url: str,
+ token: str,
+ owner: str,
+ repo: str,
+ title: str,
+ public_key: str,
+) -> dict[str, object]:
+ path = f"/api/v1/repos/{quote(owner)}/{quote(repo)}/keys"
+ payload = {
+ "title": title,
+ "key": public_key,
+ "read_only": True,
+ }
+ return _request_json(base_url, token, "POST", path, payload)
+
+
+def _put_actions_secret(
+ base_url: str,
+ token: str,
+ owner: str,
+ repo: str,
+ name: str,
+ value: str,
+) -> None:
+ path = f"/api/v1/repos/{quote(owner)}/{quote(repo)}/actions/secrets/{quote(name)}"
+ _request_json(base_url, token, "PUT", path, {"data": value})
+
+
+def _request_json(
+ base_url: str,
+ token: str,
+ method: str,
+ path: str,
+ payload: dict[str, object],
+) -> dict[str, object]:
+ data = json.dumps(payload).encode("utf-8")
+ request = Request(
+ f"{base_url}{path}",
+ data=data,
+ method=method,
+ headers={
+ "Authorization": f"token {token}",
+ "Content-Type": "application/json",
+ "Accept": "application/json",
+ },
+ )
+ try:
+ with urlopen(request, timeout=20) as response:
+ if response.status == 204:
+ return {}
+ raw_body = response.read()
+ except HTTPError as error:
+ detail = error.read().decode("utf-8", errors="replace")
+ print(f"Forgejo API returned {error.code}: {detail}", file=sys.stderr)
+ raise SystemExit(1) from error
+
+ if not raw_body:
+ return {}
+
+ decoded = json.loads(raw_body.decode("utf-8"))
+ if isinstance(decoded, dict):
+ return decoded
+ return {}
+
+
+if __name__ == "__main__":
+ raise SystemExit(main())
diff --git a/scripts/check_deploy_config.py b/scripts/check_deploy_config.py
new file mode 100755
index 0000000..817265b
--- /dev/null
+++ b/scripts/check_deploy_config.py
@@ -0,0 +1,140 @@
+#!/usr/bin/env python3
+from __future__ import annotations
+
+import os
+import sys
+from pathlib import Path
+from urllib.parse import urlparse
+
+
+PLACEHOLDER_VALUES = {
+ "",
+ "replace-with-a-random-32-byte-or-longer-secret",
+ "your-forgejo-oauth-client-id",
+ "your-forgejo-oauth-client-secret",
+}
+
+
+def main() -> int:
+ root_dir = Path(__file__).resolve().parents[1]
+ _load_env_file(root_dir / ".env")
+ _load_env_file(root_dir / ".env.local")
+
+ errors: list[str] = []
+ warnings: list[str] = []
+
+ app_base_url = _required_env("APP_BASE_URL", errors)
+ parsed_app_url = urlparse(app_base_url)
+ if app_base_url and parsed_app_url.scheme not in {"http", "https"}:
+ errors.append("APP_BASE_URL must start with http:// or https://.")
+ if parsed_app_url.scheme == "http":
+ warnings.append("APP_BASE_URL uses http://. Use https:// for public deployment.")
+
+ auth_secret = _required_env("AUTH_SECRET_KEY", errors)
+ if auth_secret in PLACEHOLDER_VALUES or len(auth_secret) < 32:
+ errors.append("AUTH_SECRET_KEY must be a real random secret at least 32 characters long.")
+
+ auth_cookie_secure = _env_bool("AUTH_COOKIE_SECURE")
+ if parsed_app_url.scheme == "https" and not auth_cookie_secure:
+ errors.append("AUTH_COOKIE_SECURE=true is required when APP_BASE_URL uses https://.")
+ if parsed_app_url.scheme == "http" and auth_cookie_secure:
+ warnings.append("AUTH_COOKIE_SECURE=true will prevent cookies over plain HTTP.")
+
+ _required_env("FORGEJO_BASE_URL", errors)
+ _required_env("FORGEJO_OAUTH_CLIENT_ID", errors)
+ _required_env("FORGEJO_OAUTH_CLIENT_SECRET", errors)
+
+ general_repo = _required_env("FORGEJO_GENERAL_DISCUSSION_REPO", errors)
+ if general_repo and len(general_repo.split("/", 1)) != 2:
+ errors.append("FORGEJO_GENERAL_DISCUSSION_REPO must use owner/repo format.")
+
+ cors_origins = _csv_env("CORS_ALLOW_ORIGINS")
+ if not cors_origins:
+ warnings.append("CORS_ALLOW_ORIGINS is not set. The app will default to APP_BASE_URL.")
+ elif "*" in cors_origins:
+ warnings.append("CORS_ALLOW_ORIGINS includes '*'. Avoid that for public deployment.")
+
+ if not os.getenv("FORGEJO_WEBHOOK_SECRET"):
+ warnings.append(
+ "FORGEJO_WEBHOOK_SECRET is not set. Webhook cache invalidation is unauthenticated."
+ )
+
+ _positive_number_env("FORGEJO_REPO_SCAN_LIMIT", errors)
+ _positive_number_env("FORGEJO_RECENT_ISSUE_LIMIT", errors)
+ _positive_number_env("CALENDAR_EVENT_LIMIT", errors)
+ _non_negative_number_env("FORGEJO_CACHE_TTL_SECONDS", errors)
+ _positive_number_env("FORGEJO_REQUEST_TIMEOUT_SECONDS", errors)
+
+ for warning in warnings:
+ print(f"WARNING: {warning}", file=sys.stderr)
+
+ if errors:
+ for error in errors:
+ print(f"ERROR: {error}", file=sys.stderr)
+ return 1
+
+ print("Deployment configuration looks usable.")
+ return 0
+
+
+def _load_env_file(path: Path) -> None:
+ if not path.exists():
+ return
+
+ for raw_line in path.read_text().splitlines():
+ line = raw_line.strip()
+ if not line or line.startswith("#") or "=" not in line:
+ continue
+
+ key, value = line.split("=", 1)
+ key = key.strip().removeprefix("export ").strip()
+ value = value.strip().strip('"').strip("'")
+ os.environ.setdefault(key, value)
+
+
+def _required_env(name: str, errors: list[str]) -> str:
+ value = os.getenv(name, "").strip()
+ if value in PLACEHOLDER_VALUES:
+ errors.append(f"{name} is required.")
+ return value
+
+
+def _env_bool(name: str) -> bool:
+ return os.getenv(name, "").strip().lower() in {"1", "true", "yes", "on"}
+
+
+def _csv_env(name: str) -> tuple[str, ...]:
+ value = os.getenv(name, "").strip()
+ if not value:
+ return ()
+ return tuple(entry.strip() for entry in value.replace("\n", ",").split(",") if entry.strip())
+
+
+def _positive_number_env(name: str, errors: list[str]) -> None:
+ value = os.getenv(name, "").strip()
+ if not value:
+ return
+ try:
+ parsed_value = float(value)
+ except ValueError:
+ errors.append(f"{name} must be numeric.")
+ return
+ if parsed_value <= 0:
+ errors.append(f"{name} must be greater than zero.")
+
+
+def _non_negative_number_env(name: str, errors: list[str]) -> None:
+ value = os.getenv(name, "").strip()
+ if not value:
+ return
+ try:
+ parsed_value = float(value)
+ except ValueError:
+ errors.append(f"{name} must be numeric.")
+ return
+ if parsed_value < 0:
+ errors.append(f"{name} must be zero or greater.")
+
+
+if __name__ == "__main__":
+ raise SystemExit(main())
diff --git a/scripts/check_python_quality.sh b/scripts/check_python_quality.sh
index 9c555da..de6afe0 100755
--- a/scripts/check_python_quality.sh
+++ b/scripts/check_python_quality.sh
@@ -11,6 +11,8 @@ python_files=(
"live_prototype.py"
"prototype_cache.py"
"settings.py"
+ "scripts/bootstrap_ci_clone_key.py"
+ "scripts/check_deploy_config.py"
"update_events.py"
"tests"
)
@@ -55,7 +57,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 prototype_cache.py settings.py update_events.py tests --min-confidence 80
+ vulture app.py auth.py calendar_feeds.py forgejo_client.py live_prototype.py prototype_cache.py settings.py scripts/bootstrap_ci_clone_key.py scripts/check_deploy_config.py update_events.py tests --min-confidence 80
run_check \
"Backend Tests" \
"${python_cmd[@]}" -m unittest discover -s tests -p "test_*.py"
diff --git a/scripts/run_prod.sh b/scripts/run_prod.sh
new file mode 100755
index 0000000..35f1611
--- /dev/null
+++ b/scripts/run_prod.sh
@@ -0,0 +1,18 @@
+#!/usr/bin/env bash
+set -euo pipefail
+
+root_dir="$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd)"
+cd "${root_dir}"
+
+host="${HOST:-0.0.0.0}"
+port="${PORT:-8000}"
+
+if [[ ! -f "${root_dir}/frontend/dist/index.html" ]]; then
+ echo "frontend/dist/index.html is missing. Build the frontend before starting production." >&2
+ exit 1
+fi
+
+exec python3 -m uvicorn app:app \
+ --host "${host}" \
+ --port "${port}" \
+ --proxy-headers
diff --git a/settings.py b/settings.py
index 721c50b..0a95fb6 100644
--- a/settings.py
+++ b/settings.py
@@ -10,6 +10,7 @@ class Settings:
app_base_url: str | None
auth_secret_key: str | None
auth_cookie_secure: bool
+ cors_allow_origins: tuple[str, ...]
forgejo_base_url: str
forgejo_token: str | None
forgejo_oauth_client_id: str | None
@@ -35,6 +36,10 @@ def _normalize_base_url(raw_value: str | None) -> str:
def _parse_calendar_feed_urls(raw_value: str | None) -> tuple[str, ...]:
+ return _parse_csv_values(raw_value)
+
+
+def _parse_csv_values(raw_value: str | None) -> tuple[str, ...]:
value = (raw_value or "").strip()
if not value:
return ()
@@ -56,14 +61,25 @@ def _parse_bool(raw_value: str | None, *, default: bool = False) -> bool:
return value in {"1", "true", "yes", "on"}
+def _cors_allow_origins(app_base_url: str | None, raw_value: str | None) -> tuple[str, ...]:
+ configured_origins = _parse_csv_values(raw_value)
+ if configured_origins:
+ return configured_origins
+ if app_base_url:
+ return (app_base_url,)
+ return ("*",)
+
+
@lru_cache(maxsize=1)
def get_settings() -> Settings:
+ app_base_url = (
+ _normalize_base_url(os.getenv("APP_BASE_URL")) if os.getenv("APP_BASE_URL") else None
+ )
return Settings(
- app_base_url=_normalize_base_url(os.getenv("APP_BASE_URL"))
- if os.getenv("APP_BASE_URL")
- else None,
+ app_base_url=app_base_url,
auth_secret_key=os.getenv("AUTH_SECRET_KEY") or None,
auth_cookie_secure=_parse_bool(os.getenv("AUTH_COOKIE_SECURE")),
+ cors_allow_origins=_cors_allow_origins(app_base_url, os.getenv("CORS_ALLOW_ORIGINS")),
forgejo_base_url=_normalize_base_url(os.getenv("FORGEJO_BASE_URL")),
forgejo_token=os.getenv("FORGEJO_TOKEN") or None,
forgejo_oauth_client_id=os.getenv("FORGEJO_OAUTH_CLIENT_ID") or None,
diff --git a/tests/test_app.py b/tests/test_app.py
index 0f0b03b..db72d71 100644
--- a/tests/test_app.py
+++ b/tests/test_app.py
@@ -167,7 +167,7 @@ class AppTestCase(unittest.TestCase):
fake_client = _FakeForgejoClient(user={"login": "kacper"})
with (
patch("prototype_cache.build_live_prototype_payload", new=builder),
- patch("app.ForgejoClient", return_value=fake_client),
+ patch("app.ForgejoClient", return_value=fake_client) as client_factory,
):
response = self.client.get(
"/api/prototype",
@@ -181,6 +181,38 @@ class AppTestCase(unittest.TestCase):
self.assertEqual(response_payload["auth"]["authenticated"], True)
self.assertEqual(response_payload["auth"]["login"], "kacper")
self.assertEqual(response_payload["auth"]["source"], "authorization")
+ self.assertEqual(client_factory.call_args.kwargs["auth_scheme"], "token")
+
+ def test_prototype_preserves_bearer_authorization_scheme(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) as client_factory,
+ ):
+ response = self.client.get(
+ "/api/prototype",
+ headers={"Authorization": "Bearer oauth-token"},
+ )
+
+ self.assertEqual(response.status_code, 200)
+ self.assertEqual(client_factory.call_args.kwargs["forgejo_token"], "oauth-token")
+ self.assertEqual(client_factory.call_args.kwargs["auth_scheme"], "Bearer")
def test_prototype_can_use_server_token_without_user_session(self) -> None:
payload = {
@@ -378,7 +410,7 @@ class AppTestCase(unittest.TestCase):
"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": [],
+ "labels": [{"name": "discussion"}],
"state": "open",
},
comments=[
@@ -400,6 +432,26 @@ class AppTestCase(unittest.TestCase):
self.assertEqual(payload["comments"][0]["body"], "Reply body")
self.assertEqual(payload["links"][0]["kind"], "post")
+ def test_discussion_detail_rejects_issue_without_discussion_label(self) -> None:
+ fake_client = _FakeForgejoClient(
+ issue={
+ "id": 456,
+ "number": 9,
+ "title": "Encoder math question",
+ "body": "Regular issue",
+ "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):
+ response = self.client.get("/api/discussions/Robot-U/robot-u-site/9")
+
+ self.assertEqual(response.status_code, 404)
+
def test_create_discussion_reply_invalidates_prototype_cache(self) -> None:
initial_payload = {
"hero": {"title": "Before"},
@@ -496,6 +548,7 @@ class AppTestCase(unittest.TestCase):
)
self.assertEqual(payload["id"], 456)
self.assertEqual(payload["repo"], "Robot-U/robot-u-site")
+ self.assertEqual(payload["labels"], ["discussion"])
self.assertEqual(payload["links"][0]["kind"], "post")
def test_create_general_discussion_uses_configured_repo(self) -> None:
@@ -532,6 +585,7 @@ class AppTestCase(unittest.TestCase):
fake_client.created_issue,
("Robot-U", "community", "General project help", "I need help choosing motors."),
)
+ self.assertEqual(fake_client.created_issue_labels, [123])
def test_create_discussion_rejects_server_token_fallback(self) -> None:
get_settings.cache_clear()
@@ -583,6 +637,7 @@ class AppTestCase(unittest.TestCase):
self.assertEqual(response.status_code, 200)
self.assertEqual(client_factory.call_args.kwargs["forgejo_token"], "oauth-token")
+ self.assertEqual(client_factory.call_args.kwargs["auth_scheme"], "Bearer")
self.assertEqual(
reply_client.created_comment, ("Robot-U", "RobotClass", 2, "Thanks, this helped.")
)
@@ -638,6 +693,7 @@ class _FakeForgejoClient:
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.created_issue_labels: list[int] | None = None
self.exchanged_code: str | None = None
async def __aenter__(self) -> _FakeForgejoClient:
@@ -664,12 +720,27 @@ class _FakeForgejoClient:
repo: str,
title: str,
body: str,
+ label_ids: list[int] | None = None,
) -> dict[str, object]:
self.created_issue = (owner, repo, title, body)
+ self.created_issue_labels = label_ids
if self._issue is None:
raise AssertionError("Fake issue was not configured.")
return self._issue
+ async def ensure_repo_label(
+ self,
+ _owner: str,
+ _repo: str,
+ _name: str,
+ *,
+ color: str,
+ description: str,
+ ) -> int:
+ assert color
+ assert description
+ return 123
+
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.")
diff --git a/tests/test_live_prototype.py b/tests/test_live_prototype.py
index 98a2d7c..2130fd9 100644
--- a/tests/test_live_prototype.py
+++ b/tests/test_live_prototype.py
@@ -3,7 +3,13 @@ from __future__ import annotations
import unittest
from typing import Any
-from live_prototype import _post_card, _summarize_repo, discussion_links_from_text
+from live_prototype import (
+ _post_card,
+ _summarize_repo,
+ discussion_card_from_issue,
+ discussion_links_from_text,
+ issue_has_discussion_label,
+)
class LivePrototypeTestCase(unittest.IsolatedAsyncioTestCase):
@@ -16,7 +22,10 @@ class LivePrototypeTestCase(unittest.IsolatedAsyncioTestCase):
"full_name": "Robot-U/robot-u-site",
"description": "Robot U site source",
"updated_at": "2026-04-13T00:00:00Z",
- "owner": {"login": "Robot-U"},
+ "owner": {
+ "login": "Robot-U",
+ "avatar_url": "https://aksal.cloud/avatars/robot-u.png",
+ },
},
)
@@ -25,6 +34,7 @@ class LivePrototypeTestCase(unittest.IsolatedAsyncioTestCase):
self.assertEqual(summary["blog_count"], 1)
post = _post_card(summary["blog_posts"][0])
self.assertEqual(post["title"], "Building Robot U")
+ self.assertEqual(post["owner_avatar_url"], "https://aksal.cloud/avatars/robot-u.png")
self.assertEqual(post["slug"], "building-robot-u-site")
self.assertEqual(post["repo"], "Robot-U/robot-u-site")
self.assertEqual(post["path"], "blogs/building-robot-u-site")
@@ -49,6 +59,29 @@ class LivePrototypeTestCase(unittest.IsolatedAsyncioTestCase):
self.assertEqual(links[1]["kind"], "post")
self.assertEqual(links[1]["path"], "/posts/Robot-U/robot-u-site/building-robot-u-site")
+ def test_discussion_card_exposes_created_at(self) -> None:
+ card = discussion_card_from_issue(
+ {
+ "id": 42,
+ "number": 7,
+ "title": "Encoder count issue",
+ "body": "The count jumps when the motor turns.",
+ "comments": 3,
+ "created_at": "2026-04-12T10:00:00Z",
+ "updated_at": "2026-04-13T10:00:00Z",
+ "repository": {"full_name": "Robot-U/general_forum"},
+ "user": {"login": "kacper", "avatar_url": "https://aksal.cloud/avatar.png"},
+ },
+ )
+
+ self.assertEqual(card["created_at"], "2026-04-12T10:00:00Z")
+
+ def test_discussion_label_detection_is_case_insensitive(self) -> None:
+ self.assertTrue(
+ issue_has_discussion_label({"labels": [{"name": "Discussion"}, {"name": "bug"}]})
+ )
+ self.assertFalse(issue_has_discussion_label({"labels": [{"name": "question"}]}))
+
class _FakeContentClient:
async def list_directory(