Prepare deployment and Forgejo CI
Some checks failed
CI / check (push) Failing after 8s

This commit is contained in:
kacper 2026-04-14 20:17:29 -04:00
parent 51706d2d11
commit 853e99ca5f
21 changed files with 1402 additions and 77 deletions

13
.dockerignore Normal file
View file

@ -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

View file

@ -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=

40
.forgejo/workflows/ci.yml Normal file
View file

@ -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

View file

@ -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

37
Dockerfile Normal file
View file

@ -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"]

View file

@ -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
```

74
app.py
View file

@ -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:

22
auth.py
View file

@ -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

25
docker-compose.yml Normal file
View file

@ -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

View file

@ -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,

View file

@ -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 <p className="empty-state">{props.copy}</p>;
}
function Avatar(props: { name: string; imageUrl?: string; className?: string }) {
const className = props.className ? `avatar ${props.className}` : "avatar";
if (props.imageUrl) {
return <img className={className} src={props.imageUrl} alt="" loading="lazy" />;
}
return (
<span className={className} aria-hidden="true">
{initialsFor(props.name)}
</span>
);
}
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);
}}
>
<span className="post-card-author-row">
<Avatar name={post.owner} imageUrl={post.owner_avatar_url} className="avatar-small" />
<span className="post-card-author">{post.owner}</span>
</span>
<span className="post-card-main">
<h3>{post.title}</h3>
<p className="muted-copy">{post.summary}</p>
<p className="meta-line">
{post.repo} · {post.file_path || post.path}
</p>
<p className="post-card-excerpt">{post.summary || plainTextExcerpt(post.body, 170)}</p>
<span className="topic-meta-row">
<span>{post.repo}</span>
<span>{formatTimestamp(post.updated_at)}</span>
{post.assets.length > 0 ? (
<span className="topic-badge">{post.assets.length} downloads</span>
) : null}
</span>
</span>
</button>
);
}
@ -454,25 +506,89 @@ function EventItem(props: { event: EventCard }) {
);
}
function discussionBadges(discussion: DiscussionCard): string[] {
const badges = new Set<string>();
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 (
<button
type="button"
className="discussion-preview-card"
className={cardClass}
onClick={() => {
onOpenDiscussion(discussion);
}}
>
<span className="discussion-compact-meta">
<Avatar
name={discussion.author}
imageUrl={discussion.author_avatar_url}
className="avatar-small"
/>
<span className="discussion-compact-author">{discussion.author}</span>
<span>{formatTimestamp(postedAt)}</span>
</span>
<span className="discussion-compact-preview">
<span className="discussion-compact-title">{discussion.title}</span>
<span className="topic-excerpt">{plainTextExcerpt(discussion.body)}</span>
</span>
<span className="discussion-compact-replies">{discussion.replies} replies</span>
</button>
);
}
return (
<button
type="button"
className={cardClass}
onClick={() => {
onOpenDiscussion(discussion);
}}
>
<Avatar name={discussion.author} imageUrl={discussion.author_avatar_url} />
<span className="topic-main">
<span className="topic-title-row">
<h3>{discussion.title}</h3>
<p className="meta-line">
{discussion.repo} · {discussion.author} · {formatTimestamp(discussion.updated_at)} ·{" "}
{discussion.replies} replies
</p>
<span className="status-pill">{discussion.state}</span>
</span>
<span className="topic-excerpt">{plainTextExcerpt(discussion.body)}</span>
<span className="topic-meta-row">
<span>{discussion.repo}</span>
<span>{discussion.author}</span>
{badges.map((badge) => (
<span key={badge} className="topic-badge">
{badge}
</span>
))}
</span>
</span>
<span className="topic-stats">
<span>
<strong>{discussion.replies}</strong>
replies
</span>
<span>{formatTimestamp(discussion.updated_at)}</span>
</span>
</button>
);
}
@ -482,9 +598,12 @@ function DiscussionReplyCard(props: { reply: DiscussionReply }) {
return (
<article className="reply-card">
<p className="reply-author">{reply.author}</p>
<p className="meta-line">{formatTimestamp(reply.created_at)}</p>
<MarkdownContent markdown={reply.body} className="thread-copy" />
<div className="reply-meta-row">
<Avatar name={reply.author} imageUrl={reply.avatar_url} className="avatar-small" />
<span className="reply-author">{reply.author}</span>
<span>{formatTimestamp(reply.created_at)}</span>
</div>
<MarkdownContent markdown={reply.body} className="thread-copy reply-copy" />
</article>
);
}
@ -998,17 +1117,22 @@ function PostPage(props: {
return (
<section className="thread-view">
<article className="panel">
<header className="thread-header">
<article className="panel post-hero-panel">
<header className="post-hero">
<div className="post-hero-meta">
<Avatar name={post.owner} imageUrl={post.owner_avatar_url} className="avatar-small" />
<div className="topic-meta-row">
<span>{post.owner}</span>
<span>{post.repo}</span>
<span>{formatTimestamp(post.updated_at)}</span>
</div>
</div>
<h1>{post.title}</h1>
<p className="meta-line">
{post.repo} · {formatTimestamp(post.updated_at)}
</p>
{post.summary ? <p className="post-hero-summary">{post.summary}</p> : null}
</header>
{post.summary ? <p className="muted-copy">{post.summary}</p> : null}
</article>
<article className="panel">
<article className="panel post-reading-panel">
<header className="subsection-header">
<h2>Post</h2>
</header>
@ -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 (
<section className="thread-view">
@ -1071,21 +1197,26 @@ function DiscussionPage(props: {
Back to discussions
</button>
<article className="panel">
<header className="thread-header">
<article className="panel discussion-detail-panel">
<header className="thread-header discussion-detail-header">
<div className="discussion-detail-meta">
<Avatar
name={discussion.author}
imageUrl={discussion.author_avatar_url}
className="avatar-small"
/>
<span className="discussion-compact-author">{discussion.author}</span>
<span>{formatTimestamp(postedAt)}</span>
</div>
<h1>{discussion.title}</h1>
<p className="meta-line">
{discussion.repo} · Issue #{discussion.number} · {discussion.author} ·{" "}
{formatTimestamp(discussion.updated_at)}
</p>
</header>
<MarkdownContent markdown={discussion.body} className="thread-copy" />
<p className="discussion-detail-replies">{replyCount} replies</p>
</article>
<article className="panel">
<header className="subsection-header">
<h2>Replies</h2>
<p className="meta-line">{discussion.comments.length}</p>
</header>
{discussion.comments.length > 0 ? (
<div className="reply-list">
@ -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}
/>
))}
</div>
) : (
<EmptyState copy="No visible Forgejo issues were returned for this account." />
<EmptyState copy="No Forgejo issues labeled `discussion` were returned for this account." />
)}
</section>
);
@ -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}
/>
);
}

View file

@ -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,

View file

@ -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[];

View file

@ -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,

173
scripts/bootstrap_ci_clone_key.py Executable file
View file

@ -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())

140
scripts/check_deploy_config.py Executable file
View file

@ -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())

View file

@ -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"

18
scripts/run_prod.sh Executable file
View file

@ -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

View file

@ -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,

View file

@ -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.")

View file

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