From 853e99ca5ff8cdac8343500ea0897389f663eb08 Mon Sep 17 00:00:00 2001 From: kacper Date: Tue, 14 Apr 2026 20:17:29 -0400 Subject: [PATCH] Prepare deployment and Forgejo CI --- .dockerignore | 13 ++ .env.example | 1 + .forgejo/workflows/ci.yml | 40 ++++ AGENTS.md | 31 ++- Dockerfile | 37 +++ README.md | 84 +++++++ app.py | 74 ++++-- auth.py | 22 +- docker-compose.yml | 25 ++ forgejo_client.py | 72 +++++- frontend/src/App.tsx | 204 +++++++++++++--- frontend/src/index.css | 370 +++++++++++++++++++++++++++++- frontend/src/types.ts | 3 + live_prototype.py | 34 ++- scripts/bootstrap_ci_clone_key.py | 173 ++++++++++++++ scripts/check_deploy_config.py | 140 +++++++++++ scripts/check_python_quality.sh | 4 +- scripts/run_prod.sh | 18 ++ settings.py | 22 +- tests/test_app.py | 75 +++++- tests/test_live_prototype.py | 37 ++- 21 files changed, 1402 insertions(+), 77 deletions(-) create mode 100644 .dockerignore create mode 100644 .forgejo/workflows/ci.yml create mode 100644 Dockerfile create mode 100644 docker-compose.yml create mode 100755 scripts/bootstrap_ci_clone_key.py create mode 100755 scripts/check_deploy_config.py create mode 100755 scripts/run_prod.sh 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 ( + + ); +} + 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.owner} + {post.repo} + {formatTimestamp(post.updated_at)} +
+

{post.title}

-

- {post.repo} · {formatTimestamp(post.updated_at)} -

+ {post.summary ?

{post.summary}

: null}
- {post.summary ?

{post.summary}

: null}
-
+

Post

@@ -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 -
-
+
+
+
+ + {discussion.author} + {formatTimestamp(postedAt)} +

{discussion.title}

-

- {discussion.repo} · Issue #{discussion.number} · {discussion.author} ·{" "} - {formatTimestamp(discussion.updated_at)} -

+

{replyCount} replies

Replies

-

{discussion.comments.length}

{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(