This commit is contained in:
parent
51706d2d11
commit
853e99ca5f
21 changed files with 1402 additions and 77 deletions
13
.dockerignore
Normal file
13
.dockerignore
Normal 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
|
||||||
|
|
@ -1,6 +1,7 @@
|
||||||
APP_BASE_URL=http://kacper-dev-pod:8800
|
APP_BASE_URL=http://kacper-dev-pod:8800
|
||||||
AUTH_SECRET_KEY=replace-with-a-random-32-byte-or-longer-secret
|
AUTH_SECRET_KEY=replace-with-a-random-32-byte-or-longer-secret
|
||||||
AUTH_COOKIE_SECURE=false
|
AUTH_COOKIE_SECURE=false
|
||||||
|
CORS_ALLOW_ORIGINS=http://kacper-dev-pod:8800
|
||||||
FORGEJO_BASE_URL=https://aksal.cloud
|
FORGEJO_BASE_URL=https://aksal.cloud
|
||||||
FORGEJO_TOKEN=
|
FORGEJO_TOKEN=
|
||||||
FORGEJO_OAUTH_CLIENT_ID=
|
FORGEJO_OAUTH_CLIENT_ID=
|
||||||
|
|
|
||||||
40
.forgejo/workflows/ci.yml
Normal file
40
.forgejo/workflows/ci.yml
Normal 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
|
||||||
31
AGENTS.md
31
AGENTS.md
|
|
@ -67,6 +67,7 @@ Useful variables:
|
||||||
- `APP_BASE_URL=http://kacper-dev-pod:8800`
|
- `APP_BASE_URL=http://kacper-dev-pod:8800`
|
||||||
- `AUTH_SECRET_KEY=...`
|
- `AUTH_SECRET_KEY=...`
|
||||||
- `AUTH_COOKIE_SECURE=false`
|
- `AUTH_COOKIE_SECURE=false`
|
||||||
|
- `CORS_ALLOW_ORIGINS=http://kacper-dev-pod:8800`
|
||||||
- `FORGEJO_OAUTH_CLIENT_ID=...`
|
- `FORGEJO_OAUTH_CLIENT_ID=...`
|
||||||
- `FORGEJO_OAUTH_CLIENT_SECRET=...`
|
- `FORGEJO_OAUTH_CLIENT_SECRET=...`
|
||||||
- `FORGEJO_OAUTH_SCOPES=openid profile`
|
- `FORGEJO_OAUTH_SCOPES=openid profile`
|
||||||
|
|
@ -80,7 +81,7 @@ Useful variables:
|
||||||
|
|
||||||
Notes:
|
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.
|
- 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.
|
- `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.
|
- `/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
|
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
|
## Development Commands
|
||||||
|
|
||||||
### Backend only
|
### Backend only
|
||||||
|
|
|
||||||
37
Dockerfile
Normal file
37
Dockerfile
Normal 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"]
|
||||||
84
README.md
84
README.md
|
|
@ -40,6 +40,8 @@ Optional live Forgejo configuration:
|
||||||
```bash
|
```bash
|
||||||
export APP_BASE_URL="http://kacper-dev-pod:8800"
|
export APP_BASE_URL="http://kacper-dev-pod:8800"
|
||||||
export AUTH_SECRET_KEY="$(openssl rand -hex 32)"
|
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_BASE_URL="https://aksal.cloud"
|
||||||
export FORGEJO_OAUTH_CLIENT_ID="your-forgejo-oauth-client-id"
|
export FORGEJO_OAUTH_CLIENT_ID="your-forgejo-oauth-client-id"
|
||||||
export FORGEJO_OAUTH_CLIENT_SECRET="your-forgejo-oauth-client-secret"
|
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.
|
`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_TOKEN` is optional. When set, it is a read fallback for the public content cache. Browser OAuth requests only identity scopes, then the backend uses the signed-in user's Forgejo identity from the encrypted session cookie for public discussion creation and replies. The backend must verify repositories are public before reading discussion data or writing issues/comments.
|
||||||
|
|
||||||
`FORGEJO_CACHE_TTL_SECONDS` controls how long the server reuses the public Forgejo content scan for `/api/prototype`. Set it to `0` to disable caching while debugging discovery behavior.
|
`FORGEJO_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_python_quality.sh
|
||||||
./scripts/check_frontend_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
74
app.py
|
|
@ -18,10 +18,14 @@ from auth import (
|
||||||
create_login_session,
|
create_login_session,
|
||||||
create_oauth_state,
|
create_oauth_state,
|
||||||
current_session_user,
|
current_session_user,
|
||||||
resolve_forgejo_token,
|
resolve_forgejo_auth,
|
||||||
)
|
)
|
||||||
from forgejo_client import ForgejoClient, ForgejoClientError
|
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 prototype_cache import PrototypePayloadCache
|
||||||
from settings import Settings, get_settings
|
from settings import Settings, get_settings
|
||||||
from update_events import UpdateBroker, stream_sse_events
|
from update_events import UpdateBroker, stream_sse_events
|
||||||
|
|
@ -31,15 +35,22 @@ DIST_DIR = BASE_DIR / "frontend" / "dist"
|
||||||
|
|
||||||
|
|
||||||
def create_app() -> FastAPI:
|
def create_app() -> FastAPI:
|
||||||
app = FastAPI(title="Robot U Community Prototype")
|
settings = get_settings()
|
||||||
|
app = FastAPI(title="Robot U Community Site")
|
||||||
prototype_cache = PrototypePayloadCache()
|
prototype_cache = PrototypePayloadCache()
|
||||||
update_broker = UpdateBroker()
|
update_broker = UpdateBroker()
|
||||||
|
|
||||||
app.add_middleware(
|
app.add_middleware(
|
||||||
CORSMiddleware,
|
CORSMiddleware,
|
||||||
allow_origins=["*"],
|
allow_origins=list(settings.cors_allow_origins),
|
||||||
allow_methods=["*"],
|
allow_methods=["GET", "POST", "DELETE", "OPTIONS"],
|
||||||
allow_headers=["*"],
|
allow_headers=[
|
||||||
|
"Authorization",
|
||||||
|
"Content-Type",
|
||||||
|
"X-Forgejo-Signature",
|
||||||
|
"X-Gitea-Signature",
|
||||||
|
"X-Hub-Signature-256",
|
||||||
|
],
|
||||||
)
|
)
|
||||||
|
|
||||||
@app.get("/health")
|
@app.get("/health")
|
||||||
|
|
@ -50,12 +61,13 @@ def create_app() -> FastAPI:
|
||||||
async def prototype(request: Request) -> JSONResponse:
|
async def prototype(request: Request) -> JSONResponse:
|
||||||
settings = get_settings()
|
settings = get_settings()
|
||||||
session_user = current_session_user(request, 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 = await prototype_cache.get(settings)
|
||||||
payload["auth"] = await _auth_payload_for_request(
|
payload["auth"] = await _auth_payload_for_request(
|
||||||
settings,
|
settings,
|
||||||
forgejo_token=forgejo_token,
|
forgejo_token=forgejo_token,
|
||||||
auth_source=auth_source,
|
auth_source=auth_source,
|
||||||
|
auth_scheme=auth_scheme,
|
||||||
session_user=session_user,
|
session_user=session_user,
|
||||||
)
|
)
|
||||||
return JSONResponse(payload)
|
return JSONResponse(payload)
|
||||||
|
|
@ -67,11 +79,13 @@ def create_app() -> FastAPI:
|
||||||
if session_user:
|
if session_user:
|
||||||
return JSONResponse(_auth_payload(session_user, "session"))
|
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":
|
if not forgejo_token or auth_source == "server":
|
||||||
return JSONResponse(_auth_payload(None, "none"))
|
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:
|
try:
|
||||||
user = await client.fetch_current_user()
|
user = await client.fetch_current_user()
|
||||||
except ForgejoClientError as error:
|
except ForgejoClientError as error:
|
||||||
|
|
@ -183,6 +197,11 @@ def create_app() -> FastAPI:
|
||||||
detail="This site only reads public Forgejo repositories.",
|
detail="This site only reads public Forgejo repositories.",
|
||||||
)
|
)
|
||||||
issue = await client.fetch_issue(owner, repo, issue_number)
|
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 = [
|
comments = [
|
||||||
_discussion_reply(comment)
|
_discussion_reply(comment)
|
||||||
for comment in await client.list_issue_comments(owner, repo, issue_number)
|
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")
|
body = _required_string(payload, "body")
|
||||||
issue_number = _required_positive_int(payload, "number")
|
issue_number = _required_positive_int(payload, "number")
|
||||||
settings = get_settings()
|
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":
|
if not forgejo_token or auth_source == "server":
|
||||||
raise HTTPException(
|
raise HTTPException(
|
||||||
status_code=401,
|
status_code=401,
|
||||||
detail="Sign in or send an Authorization token before replying.",
|
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:
|
try:
|
||||||
repo_payload = await client.fetch_repository(owner, repo)
|
repo_payload = await client.fetch_repository(owner, repo)
|
||||||
if repo_payload.get("private"):
|
if repo_payload.get("private"):
|
||||||
|
|
@ -248,14 +269,16 @@ def create_app() -> FastAPI:
|
||||||
)
|
)
|
||||||
|
|
||||||
settings = get_settings()
|
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":
|
if not forgejo_token or auth_source == "server":
|
||||||
raise HTTPException(
|
raise HTTPException(
|
||||||
status_code=401,
|
status_code=401,
|
||||||
detail="Sign in or send an Authorization token before starting a discussion.",
|
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:
|
try:
|
||||||
repo_payload = await client.fetch_repository(owner, repo)
|
repo_payload = await client.fetch_repository(owner, repo)
|
||||||
if repo_payload.get("private"):
|
if repo_payload.get("private"):
|
||||||
|
|
@ -263,11 +286,29 @@ def create_app() -> FastAPI:
|
||||||
status_code=403,
|
status_code=403,
|
||||||
detail="This site only writes to public Forgejo repositories.",
|
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:
|
except ForgejoClientError as error:
|
||||||
raise HTTPException(status_code=502, detail=str(error)) from error
|
raise HTTPException(status_code=502, detail=str(error)) from error
|
||||||
|
|
||||||
issue["repository"] = _issue_repository_payload(repo_payload, owner, repo)
|
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()
|
prototype_cache.invalidate()
|
||||||
await update_broker.publish(
|
await update_broker.publish(
|
||||||
"content-updated",
|
"content-updated",
|
||||||
|
|
@ -429,6 +470,7 @@ async def _auth_payload_for_request(
|
||||||
*,
|
*,
|
||||||
forgejo_token: str | None,
|
forgejo_token: str | None,
|
||||||
auth_source: str,
|
auth_source: str,
|
||||||
|
auth_scheme: str,
|
||||||
session_user: dict[str, Any] | None,
|
session_user: dict[str, Any] | None,
|
||||||
) -> dict[str, object]:
|
) -> dict[str, object]:
|
||||||
if session_user:
|
if session_user:
|
||||||
|
|
@ -437,7 +479,9 @@ async def _auth_payload_for_request(
|
||||||
if not forgejo_token or auth_source == "server":
|
if not forgejo_token or auth_source == "server":
|
||||||
return _auth_payload(None, "none")
|
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:
|
try:
|
||||||
user = await client.fetch_current_user()
|
user = await client.fetch_current_user()
|
||||||
except ForgejoClientError as error:
|
except ForgejoClientError as error:
|
||||||
|
|
|
||||||
22
auth.py
22
auth.py
|
|
@ -37,19 +37,20 @@ class OAuthStateRecord:
|
||||||
_OAUTH_STATES: dict[str, OAuthStateRecord] = {}
|
_OAUTH_STATES: dict[str, OAuthStateRecord] = {}
|
||||||
|
|
||||||
|
|
||||||
def resolve_forgejo_token(request: Request, settings: Settings) -> tuple[str | None, str]:
|
def resolve_forgejo_auth(request: Request, settings: Settings) -> tuple[str | None, str, str]:
|
||||||
header_token = _authorization_token(request.headers.get("authorization"))
|
header_credential = _authorization_credential(request.headers.get("authorization"))
|
||||||
if header_token:
|
if header_credential:
|
||||||
return header_token, "authorization"
|
header_token, auth_scheme = header_credential
|
||||||
|
return header_token, "authorization", auth_scheme
|
||||||
|
|
||||||
session = _session_from_request(request, settings)
|
session = _session_from_request(request, settings)
|
||||||
if session and session.forgejo_token:
|
if session and session.forgejo_token:
|
||||||
return session.forgejo_token, "session"
|
return session.forgejo_token, "session", "Bearer"
|
||||||
|
|
||||||
if settings.forgejo_token:
|
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:
|
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("=")
|
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:
|
if not value:
|
||||||
return None
|
return None
|
||||||
|
|
||||||
parts = value.strip().split(None, 1)
|
parts = value.strip().split(None, 1)
|
||||||
if len(parts) == 2 and parts[0].lower() in {"bearer", "token"}:
|
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
|
return None
|
||||||
|
|
||||||
|
|
||||||
|
|
|
||||||
25
docker-compose.yml
Normal file
25
docker-compose.yml
Normal 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
|
||||||
|
|
@ -14,9 +14,15 @@ class ForgejoClientError(RuntimeError):
|
||||||
|
|
||||||
|
|
||||||
class ForgejoClient:
|
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._settings = settings
|
||||||
self._forgejo_token = forgejo_token or settings.forgejo_token
|
self._forgejo_token = forgejo_token or settings.forgejo_token
|
||||||
|
self._auth_scheme = auth_scheme
|
||||||
self._client = httpx.AsyncClient(timeout=settings.forgejo_request_timeout_seconds)
|
self._client = httpx.AsyncClient(timeout=settings.forgejo_request_timeout_seconds)
|
||||||
|
|
||||||
async def __aenter__(self) -> ForgejoClient:
|
async def __aenter__(self) -> ForgejoClient:
|
||||||
|
|
@ -193,17 +199,76 @@ class ForgejoClient:
|
||||||
repo: str,
|
repo: str,
|
||||||
title: str,
|
title: str,
|
||||||
body: str,
|
body: str,
|
||||||
|
label_ids: list[int] | None = None,
|
||||||
) -> dict[str, Any]:
|
) -> 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(
|
payload = await self._request_json(
|
||||||
"POST",
|
"POST",
|
||||||
f"/api/v1/repos/{owner}/{repo}/issues",
|
f"/api/v1/repos/{owner}/{repo}/issues",
|
||||||
json_payload={"title": title, "body": body},
|
json_payload=payload_data,
|
||||||
auth_required=True,
|
auth_required=True,
|
||||||
)
|
)
|
||||||
if isinstance(payload, dict):
|
if isinstance(payload, dict):
|
||||||
return payload
|
return payload
|
||||||
raise ForgejoClientError(f"Unexpected issue payload for {owner}/{repo}")
|
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]:
|
async def get_file_content(self, owner: str, repo: str, path: str) -> dict[str, str]:
|
||||||
payload = await self._get_json(
|
payload = await self._get_json(
|
||||||
f"/api/v1/repos/{owner}/{repo}/contents/{path.strip('/')}",
|
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}"
|
url = path if absolute_url else f"{self._settings.forgejo_base_url}{path}"
|
||||||
headers = {}
|
headers = {}
|
||||||
if self._forgejo_token:
|
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(
|
response = await self._client.request(
|
||||||
method,
|
method,
|
||||||
|
|
|
||||||
|
|
@ -32,6 +32,35 @@ function formatTimestamp(value: string): string {
|
||||||
}).format(date);
|
}).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 {
|
function normalizePathname(pathname: string): string {
|
||||||
if (!pathname || pathname === "/") {
|
if (!pathname || pathname === "/") {
|
||||||
return "/";
|
return "/";
|
||||||
|
|
@ -256,6 +285,19 @@ function EmptyState(props: { copy: string }) {
|
||||||
return <p className="empty-state">{props.copy}</p>;
|
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 {
|
function canUseInteractiveAuth(auth: AuthState): boolean {
|
||||||
return auth.authenticated && auth.can_reply;
|
return auth.authenticated && auth.can_reply;
|
||||||
}
|
}
|
||||||
|
|
@ -433,11 +475,21 @@ function PostItem(props: { post: PostCard; onOpenPost: (post: PostCard) => void
|
||||||
onOpenPost(post);
|
onOpenPost(post);
|
||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
<h3>{post.title}</h3>
|
<span className="post-card-author-row">
|
||||||
<p className="muted-copy">{post.summary}</p>
|
<Avatar name={post.owner} imageUrl={post.owner_avatar_url} className="avatar-small" />
|
||||||
<p className="meta-line">
|
<span className="post-card-author">{post.owner}</span>
|
||||||
{post.repo} · {post.file_path || post.path}
|
</span>
|
||||||
</p>
|
<span className="post-card-main">
|
||||||
|
<h3>{post.title}</h3>
|
||||||
|
<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>
|
</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: {
|
function DiscussionPreviewItem(props: {
|
||||||
discussion: DiscussionCard;
|
discussion: DiscussionCard;
|
||||||
onOpenDiscussion: (discussion: DiscussionCard) => void;
|
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={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 (
|
return (
|
||||||
<button
|
<button
|
||||||
type="button"
|
type="button"
|
||||||
className="discussion-preview-card"
|
className={cardClass}
|
||||||
onClick={() => {
|
onClick={() => {
|
||||||
onOpenDiscussion(discussion);
|
onOpenDiscussion(discussion);
|
||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
<h3>{discussion.title}</h3>
|
<Avatar name={discussion.author} imageUrl={discussion.author_avatar_url} />
|
||||||
<p className="meta-line">
|
<span className="topic-main">
|
||||||
{discussion.repo} · {discussion.author} · {formatTimestamp(discussion.updated_at)} ·{" "}
|
<span className="topic-title-row">
|
||||||
{discussion.replies} replies
|
<h3>{discussion.title}</h3>
|
||||||
</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>
|
</button>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
@ -482,9 +598,12 @@ function DiscussionReplyCard(props: { reply: DiscussionReply }) {
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<article className="reply-card">
|
<article className="reply-card">
|
||||||
<p className="reply-author">{reply.author}</p>
|
<div className="reply-meta-row">
|
||||||
<p className="meta-line">{formatTimestamp(reply.created_at)}</p>
|
<Avatar name={reply.author} imageUrl={reply.avatar_url} className="avatar-small" />
|
||||||
<MarkdownContent markdown={reply.body} className="thread-copy" />
|
<span className="reply-author">{reply.author}</span>
|
||||||
|
<span>{formatTimestamp(reply.created_at)}</span>
|
||||||
|
</div>
|
||||||
|
<MarkdownContent markdown={reply.body} className="thread-copy reply-copy" />
|
||||||
</article>
|
</article>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
@ -998,17 +1117,22 @@ function PostPage(props: {
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<section className="thread-view">
|
<section className="thread-view">
|
||||||
<article className="panel">
|
<article className="panel post-hero-panel">
|
||||||
<header className="thread-header">
|
<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>
|
<h1>{post.title}</h1>
|
||||||
<p className="meta-line">
|
{post.summary ? <p className="post-hero-summary">{post.summary}</p> : null}
|
||||||
{post.repo} · {formatTimestamp(post.updated_at)}
|
|
||||||
</p>
|
|
||||||
</header>
|
</header>
|
||||||
{post.summary ? <p className="muted-copy">{post.summary}</p> : null}
|
|
||||||
</article>
|
</article>
|
||||||
|
|
||||||
<article className="panel">
|
<article className="panel post-reading-panel">
|
||||||
<header className="subsection-header">
|
<header className="subsection-header">
|
||||||
<h2>Post</h2>
|
<h2>Post</h2>
|
||||||
</header>
|
</header>
|
||||||
|
|
@ -1064,6 +1188,8 @@ function DiscussionPage(props: {
|
||||||
onReplyCreated: (discussionId: number, reply: DiscussionReply) => void;
|
onReplyCreated: (discussionId: number, reply: DiscussionReply) => void;
|
||||||
}) {
|
}) {
|
||||||
const { discussion, auth, onGoHome, onGoSignIn, onReplyCreated } = props;
|
const { discussion, auth, onGoHome, onGoSignIn, onReplyCreated } = props;
|
||||||
|
const postedAt = discussion.created_at || discussion.updated_at;
|
||||||
|
const replyCount = discussion.comments.length || discussion.replies;
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<section className="thread-view">
|
<section className="thread-view">
|
||||||
|
|
@ -1071,21 +1197,26 @@ function DiscussionPage(props: {
|
||||||
Back to discussions
|
Back to discussions
|
||||||
</button>
|
</button>
|
||||||
|
|
||||||
<article className="panel">
|
<article className="panel discussion-detail-panel">
|
||||||
<header className="thread-header">
|
<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>
|
<h1>{discussion.title}</h1>
|
||||||
<p className="meta-line">
|
|
||||||
{discussion.repo} · Issue #{discussion.number} · {discussion.author} ·{" "}
|
|
||||||
{formatTimestamp(discussion.updated_at)}
|
|
||||||
</p>
|
|
||||||
</header>
|
</header>
|
||||||
<MarkdownContent markdown={discussion.body} className="thread-copy" />
|
<MarkdownContent markdown={discussion.body} className="thread-copy" />
|
||||||
|
<p className="discussion-detail-replies">{replyCount} replies</p>
|
||||||
</article>
|
</article>
|
||||||
|
|
||||||
<article className="panel">
|
<article className="panel">
|
||||||
<header className="subsection-header">
|
<header className="subsection-header">
|
||||||
<h2>Replies</h2>
|
<h2>Replies</h2>
|
||||||
<p className="meta-line">{discussion.comments.length}</p>
|
|
||||||
</header>
|
</header>
|
||||||
{discussion.comments.length > 0 ? (
|
{discussion.comments.length > 0 ? (
|
||||||
<div className="reply-list">
|
<div className="reply-list">
|
||||||
|
|
@ -1138,8 +1269,16 @@ function DiscussionsView(props: {
|
||||||
onGoSignIn: () => void;
|
onGoSignIn: () => void;
|
||||||
onDiscussionCreated: (discussion: DiscussionCard) => void;
|
onDiscussionCreated: (discussion: DiscussionCard) => void;
|
||||||
showComposer?: boolean;
|
showComposer?: boolean;
|
||||||
|
compactItems?: boolean;
|
||||||
}) {
|
}) {
|
||||||
const { data, onOpenDiscussion, onGoSignIn, onDiscussionCreated, showComposer = true } = props;
|
const {
|
||||||
|
data,
|
||||||
|
onOpenDiscussion,
|
||||||
|
onGoSignIn,
|
||||||
|
onDiscussionCreated,
|
||||||
|
showComposer = true,
|
||||||
|
compactItems = false,
|
||||||
|
} = props;
|
||||||
const generalDiscussionConfigured =
|
const generalDiscussionConfigured =
|
||||||
data.discussion_settings?.general_discussion_configured ?? false;
|
data.discussion_settings?.general_discussion_configured ?? false;
|
||||||
|
|
||||||
|
|
@ -1175,11 +1314,12 @@ function DiscussionsView(props: {
|
||||||
key={discussion.id}
|
key={discussion.id}
|
||||||
discussion={discussion}
|
discussion={discussion}
|
||||||
onOpenDiscussion={onOpenDiscussion}
|
onOpenDiscussion={onOpenDiscussion}
|
||||||
|
compact={compactItems}
|
||||||
/>
|
/>
|
||||||
))}
|
))}
|
||||||
</div>
|
</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>
|
</section>
|
||||||
);
|
);
|
||||||
|
|
@ -1242,6 +1382,7 @@ function HomeView(props: {
|
||||||
onGoSignIn={onGoSignIn}
|
onGoSignIn={onGoSignIn}
|
||||||
onDiscussionCreated={onDiscussionCreated}
|
onDiscussionCreated={onDiscussionCreated}
|
||||||
showComposer={false}
|
showComposer={false}
|
||||||
|
compactItems={true}
|
||||||
/>
|
/>
|
||||||
</>
|
</>
|
||||||
);
|
);
|
||||||
|
|
@ -1708,6 +1849,7 @@ function AppContent(props: AppContentProps) {
|
||||||
onOpenDiscussion={props.onOpenDiscussion}
|
onOpenDiscussion={props.onOpenDiscussion}
|
||||||
onGoSignIn={props.onGoSignIn}
|
onGoSignIn={props.onGoSignIn}
|
||||||
onDiscussionCreated={props.onDiscussionCreated}
|
onDiscussionCreated={props.onDiscussionCreated}
|
||||||
|
compactItems={true}
|
||||||
/>
|
/>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -4,10 +4,14 @@
|
||||||
line-height: 1.5;
|
line-height: 1.5;
|
||||||
font-weight: 400;
|
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: #fffdf8;
|
||||||
--panel-hover: #f1eadf;
|
--panel-hover: #f1eadf;
|
||||||
--card: #ffffff;
|
--card: #ffffff;
|
||||||
|
--card-elevated: #fffffc;
|
||||||
--border: #ded5c7;
|
--border: #ded5c7;
|
||||||
--text: #1f2933;
|
--text: #1f2933;
|
||||||
--muted: #667085;
|
--muted: #667085;
|
||||||
|
|
@ -24,6 +28,8 @@
|
||||||
--disabled-bg: #ece6dc;
|
--disabled-bg: #ece6dc;
|
||||||
--disabled-text: #8a8174;
|
--disabled-text: #8a8174;
|
||||||
--error: #a64234;
|
--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 {
|
.app-shell {
|
||||||
width: min(72rem, 100%);
|
width: min(76rem, 100%);
|
||||||
margin: 0 auto;
|
margin: 0 auto;
|
||||||
padding: 2rem 1rem 3rem;
|
padding: 2.25rem 1rem 3.5rem;
|
||||||
}
|
}
|
||||||
|
|
||||||
.topbar {
|
.topbar {
|
||||||
|
|
@ -76,6 +82,7 @@ textarea {
|
||||||
border-bottom: 0.0625rem solid var(--accent-border);
|
border-bottom: 0.0625rem solid var(--accent-border);
|
||||||
padding: 0.75rem clamp(1rem, 4vw, 2rem);
|
padding: 0.75rem clamp(1rem, 4vw, 2rem);
|
||||||
background: var(--topbar-bg);
|
background: var(--topbar-bg);
|
||||||
|
box-shadow: 0 0.45rem 1.5rem rgba(31, 41, 51, 0.06);
|
||||||
backdrop-filter: blur(1rem);
|
backdrop-filter: blur(1rem);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -190,7 +197,8 @@ textarea {
|
||||||
.page-message {
|
.page-message {
|
||||||
border: 0.0625rem solid var(--border);
|
border: 0.0625rem solid var(--border);
|
||||||
background: var(--panel);
|
background: var(--panel);
|
||||||
border-radius: 0.9rem;
|
border-radius: 1rem;
|
||||||
|
box-shadow: var(--shadow-soft);
|
||||||
}
|
}
|
||||||
|
|
||||||
.page-header,
|
.page-header,
|
||||||
|
|
@ -201,6 +209,8 @@ textarea {
|
||||||
|
|
||||||
.page-header {
|
.page-header {
|
||||||
margin-bottom: 1rem;
|
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,
|
.page-header h1,
|
||||||
|
|
@ -258,8 +268,8 @@ textarea {
|
||||||
.activity-card,
|
.activity-card,
|
||||||
.reply-card {
|
.reply-card {
|
||||||
border: 0.0625rem solid var(--border);
|
border: 0.0625rem solid var(--border);
|
||||||
border-radius: 0.75rem;
|
border-radius: 0.9rem;
|
||||||
background: var(--card);
|
background: var(--card-elevated);
|
||||||
}
|
}
|
||||||
|
|
||||||
.card,
|
.card,
|
||||||
|
|
@ -276,11 +286,44 @@ textarea {
|
||||||
color: inherit;
|
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:hover,
|
||||||
.course-card-button:focus-visible,
|
.course-card-button:focus-visible,
|
||||||
.post-card-button:hover,
|
.post-card-button:hover,
|
||||||
.post-card-button:focus-visible {
|
.post-card-button:focus-visible {
|
||||||
background: var(--panel-hover);
|
background: var(--panel-hover);
|
||||||
|
box-shadow: var(--shadow-row);
|
||||||
|
transform: translateY(-0.0625rem);
|
||||||
outline: none;
|
outline: none;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -306,10 +349,18 @@ textarea {
|
||||||
|
|
||||||
.discussion-preview-card {
|
.discussion-preview-card {
|
||||||
width: 100%;
|
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;
|
cursor: pointer;
|
||||||
text-align: left;
|
text-align: left;
|
||||||
color: inherit;
|
color: inherit;
|
||||||
|
transition:
|
||||||
|
transform 160ms ease,
|
||||||
|
box-shadow 160ms ease,
|
||||||
|
background 160ms ease;
|
||||||
}
|
}
|
||||||
|
|
||||||
.activity-card-button {
|
.activity-card-button {
|
||||||
|
|
@ -324,9 +375,177 @@ textarea {
|
||||||
.activity-card-button:hover,
|
.activity-card-button:hover,
|
||||||
.activity-card-button:focus-visible {
|
.activity-card-button:focus-visible {
|
||||||
background: var(--panel-hover);
|
background: var(--panel-hover);
|
||||||
|
box-shadow: var(--shadow-row);
|
||||||
|
transform: translateY(-0.0625rem);
|
||||||
outline: none;
|
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,
|
.muted-copy,
|
||||||
.meta-line,
|
.meta-line,
|
||||||
.empty-state {
|
.empty-state {
|
||||||
|
|
@ -407,6 +626,94 @@ textarea {
|
||||||
margin-bottom: 1rem;
|
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 {
|
.panel-actions {
|
||||||
margin-top: 1rem;
|
margin-top: 1rem;
|
||||||
}
|
}
|
||||||
|
|
@ -417,7 +724,26 @@ textarea {
|
||||||
}
|
}
|
||||||
|
|
||||||
.reply-author {
|
.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,
|
.outline-list,
|
||||||
|
|
@ -744,6 +1070,34 @@ textarea {
|
||||||
grid-template-columns: 1fr;
|
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,
|
.compose-actions,
|
||||||
.auth-bar,
|
.auth-bar,
|
||||||
.signin-callout,
|
.signin-callout,
|
||||||
|
|
|
||||||
|
|
@ -28,6 +28,7 @@ export interface ContentAsset {
|
||||||
export interface CourseCard {
|
export interface CourseCard {
|
||||||
title: string;
|
title: string;
|
||||||
owner: string;
|
owner: string;
|
||||||
|
owner_avatar_url: string;
|
||||||
name: string;
|
name: string;
|
||||||
repo: string;
|
repo: string;
|
||||||
html_url: string;
|
html_url: string;
|
||||||
|
|
@ -60,6 +61,7 @@ export interface CourseLesson {
|
||||||
export interface PostCard {
|
export interface PostCard {
|
||||||
title: string;
|
title: string;
|
||||||
owner: string;
|
owner: string;
|
||||||
|
owner_avatar_url: string;
|
||||||
name: string;
|
name: string;
|
||||||
repo: string;
|
repo: string;
|
||||||
slug: string;
|
slug: string;
|
||||||
|
|
@ -92,6 +94,7 @@ export interface DiscussionCard {
|
||||||
state: string;
|
state: string;
|
||||||
body: string;
|
body: string;
|
||||||
number: number;
|
number: number;
|
||||||
|
created_at: string;
|
||||||
updated_at: string;
|
updated_at: string;
|
||||||
html_url: string;
|
html_url: string;
|
||||||
labels: string[];
|
labels: string[];
|
||||||
|
|
|
||||||
|
|
@ -9,6 +9,8 @@ from calendar_feeds import CalendarFeed, CalendarFeedError, fetch_calendar_feed
|
||||||
from forgejo_client import ForgejoClient, ForgejoClientError
|
from forgejo_client import ForgejoClient, ForgejoClientError
|
||||||
from settings import Settings
|
from settings import Settings
|
||||||
|
|
||||||
|
DISCUSSION_LABEL_NAME = "discussion"
|
||||||
|
|
||||||
|
|
||||||
async def build_live_prototype_payload(
|
async def build_live_prototype_payload(
|
||||||
settings: Settings,
|
settings: Settings,
|
||||||
|
|
@ -109,7 +111,7 @@ async def build_live_prototype_payload(
|
||||||
"title": "Discovery state",
|
"title": "Discovery state",
|
||||||
"description": (
|
"description": (
|
||||||
f"Detected {len(course_repos)} course repos, {len(post_repos)} post repos, "
|
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": [
|
"highlights": [
|
||||||
"Repo discovery filters to public, non-fork repositories only",
|
"Repo discovery filters to public, non-fork repositories only",
|
||||||
"Course repos are detected from /lessons/, post repos from /blogs/",
|
"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(
|
"auth": _auth_payload(
|
||||||
|
|
@ -301,6 +303,7 @@ async def _summarize_repo(
|
||||||
str(repo.get("full_name", f"{owner_login}/{repo_name}")),
|
str(repo.get("full_name", f"{owner_login}/{repo_name}")),
|
||||||
str(repo.get("description") or ""),
|
str(repo.get("description") or ""),
|
||||||
str(repo.get("updated_at", "")),
|
str(repo.get("updated_at", "")),
|
||||||
|
_repo_owner_avatar_url(repo),
|
||||||
default_branch,
|
default_branch,
|
||||||
str(repo.get("html_url", "")),
|
str(repo.get("html_url", "")),
|
||||||
str(blog_dir.get("name", "")),
|
str(blog_dir.get("name", "")),
|
||||||
|
|
@ -312,6 +315,7 @@ async def _summarize_repo(
|
||||||
return {
|
return {
|
||||||
"name": repo_name,
|
"name": repo_name,
|
||||||
"owner": owner_login,
|
"owner": owner_login,
|
||||||
|
"owner_avatar_url": _repo_owner_avatar_url(repo),
|
||||||
"full_name": repo.get("full_name", f"{owner_login}/{repo_name}"),
|
"full_name": repo.get("full_name", f"{owner_login}/{repo_name}"),
|
||||||
"html_url": repo.get("html_url", ""),
|
"html_url": repo.get("html_url", ""),
|
||||||
"description": repo.get("description") or "No repository description yet.",
|
"description": repo.get("description") or "No repository description yet.",
|
||||||
|
|
@ -328,6 +332,7 @@ def _course_card(summary: dict[str, Any]) -> dict[str, object]:
|
||||||
return {
|
return {
|
||||||
"title": summary["name"],
|
"title": summary["name"],
|
||||||
"owner": summary["owner"],
|
"owner": summary["owner"],
|
||||||
|
"owner_avatar_url": summary["owner_avatar_url"],
|
||||||
"name": summary["name"],
|
"name": summary["name"],
|
||||||
"repo": summary["full_name"],
|
"repo": summary["full_name"],
|
||||||
"html_url": summary["html_url"],
|
"html_url": summary["html_url"],
|
||||||
|
|
@ -344,6 +349,7 @@ def _post_card(post: dict[str, Any]) -> dict[str, object]:
|
||||||
return {
|
return {
|
||||||
"title": post["title"],
|
"title": post["title"],
|
||||||
"owner": post["owner"],
|
"owner": post["owner"],
|
||||||
|
"owner_avatar_url": post["owner_avatar_url"],
|
||||||
"name": post["name"],
|
"name": post["name"],
|
||||||
"repo": post["repo"],
|
"repo": post["repo"],
|
||||||
"slug": post["slug"],
|
"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(
|
async def _recent_public_issues(
|
||||||
client: ForgejoClient,
|
client: ForgejoClient,
|
||||||
repos: list[dict[str, Any]],
|
repos: list[dict[str, Any]],
|
||||||
|
|
@ -368,6 +382,7 @@ async def _recent_public_issues(
|
||||||
*[_repo_issues(client, repo, limit) for repo in repos],
|
*[_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_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]
|
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
|
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]]:
|
def _event_cards(calendar_feeds: list[CalendarFeed], limit: int) -> list[dict[str, object]]:
|
||||||
upcoming_events = sorted(
|
upcoming_events = sorted(
|
||||||
[event for feed in calendar_feeds for event in feed.events],
|
[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"),
|
"state": issue.get("state", "open"),
|
||||||
"body": body,
|
"body": body,
|
||||||
"number": issue_number,
|
"number": issue_number,
|
||||||
|
"created_at": issue.get("created_at", ""),
|
||||||
"updated_at": issue.get("updated_at", ""),
|
"updated_at": issue.get("updated_at", ""),
|
||||||
"html_url": issue.get("html_url", ""),
|
"html_url": issue.get("html_url", ""),
|
||||||
"labels": [label for label in labels if isinstance(label, str)],
|
"labels": [label for label in labels if isinstance(label, str)],
|
||||||
|
|
@ -687,6 +710,7 @@ async def _summarize_blog_post(
|
||||||
full_name: str,
|
full_name: str,
|
||||||
repo_description: str,
|
repo_description: str,
|
||||||
updated_at: str,
|
updated_at: str,
|
||||||
|
owner_avatar_url: str,
|
||||||
default_branch: str,
|
default_branch: str,
|
||||||
repo_html_url: str,
|
repo_html_url: str,
|
||||||
post_name: str,
|
post_name: str,
|
||||||
|
|
@ -708,6 +732,7 @@ async def _summarize_blog_post(
|
||||||
updated_at,
|
updated_at,
|
||||||
post_path,
|
post_path,
|
||||||
raw_base_url=raw_base_url,
|
raw_base_url=raw_base_url,
|
||||||
|
owner_avatar_url=owner_avatar_url,
|
||||||
)
|
)
|
||||||
|
|
||||||
assets = _content_assets(post_entries, raw_base_url, post_path)
|
assets = _content_assets(post_entries, raw_base_url, post_path)
|
||||||
|
|
@ -724,6 +749,7 @@ async def _summarize_blog_post(
|
||||||
post_path,
|
post_path,
|
||||||
raw_base_url=raw_base_url,
|
raw_base_url=raw_base_url,
|
||||||
assets=assets,
|
assets=assets,
|
||||||
|
owner_avatar_url=owner_avatar_url,
|
||||||
)
|
)
|
||||||
|
|
||||||
markdown_name = str(markdown_files[0]["name"])
|
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", "")),
|
html_url=str(markdown_files[0].get("html_url", "")),
|
||||||
raw_base_url=raw_base_url,
|
raw_base_url=raw_base_url,
|
||||||
assets=assets,
|
assets=assets,
|
||||||
|
owner_avatar_url=owner_avatar_url,
|
||||||
)
|
)
|
||||||
|
|
||||||
metadata, body = _parse_frontmatter(str(file_payload.get("content", "")))
|
metadata, body = _parse_frontmatter(str(file_payload.get("content", "")))
|
||||||
|
|
@ -752,6 +779,7 @@ async def _summarize_blog_post(
|
||||||
"slug": post_name,
|
"slug": post_name,
|
||||||
"title": str(metadata.get("title") or _display_name(markdown_name) or fallback_title),
|
"title": str(metadata.get("title") or _display_name(markdown_name) or fallback_title),
|
||||||
"owner": owner,
|
"owner": owner,
|
||||||
|
"owner_avatar_url": owner_avatar_url,
|
||||||
"name": repo,
|
"name": repo,
|
||||||
"repo": full_name,
|
"repo": full_name,
|
||||||
"summary": str(metadata.get("summary") or repo_description or ""),
|
"summary": str(metadata.get("summary") or repo_description or ""),
|
||||||
|
|
@ -964,11 +992,13 @@ def _empty_blog_post(
|
||||||
html_url: str = "",
|
html_url: str = "",
|
||||||
raw_base_url: str = "",
|
raw_base_url: str = "",
|
||||||
assets: list[dict[str, object]] | None = None,
|
assets: list[dict[str, object]] | None = None,
|
||||||
|
owner_avatar_url: str = "",
|
||||||
) -> dict[str, object]:
|
) -> dict[str, object]:
|
||||||
return {
|
return {
|
||||||
"slug": post_name,
|
"slug": post_name,
|
||||||
"title": title,
|
"title": title,
|
||||||
"owner": owner,
|
"owner": owner,
|
||||||
|
"owner_avatar_url": owner_avatar_url,
|
||||||
"name": repo,
|
"name": repo,
|
||||||
"repo": full_name,
|
"repo": full_name,
|
||||||
"summary": summary,
|
"summary": summary,
|
||||||
|
|
|
||||||
173
scripts/bootstrap_ci_clone_key.py
Executable file
173
scripts/bootstrap_ci_clone_key.py
Executable 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
140
scripts/check_deploy_config.py
Executable 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())
|
||||||
|
|
@ -11,6 +11,8 @@ python_files=(
|
||||||
"live_prototype.py"
|
"live_prototype.py"
|
||||||
"prototype_cache.py"
|
"prototype_cache.py"
|
||||||
"settings.py"
|
"settings.py"
|
||||||
|
"scripts/bootstrap_ci_clone_key.py"
|
||||||
|
"scripts/check_deploy_config.py"
|
||||||
"update_events.py"
|
"update_events.py"
|
||||||
"tests"
|
"tests"
|
||||||
)
|
)
|
||||||
|
|
@ -55,7 +57,7 @@ run_check \
|
||||||
run_check \
|
run_check \
|
||||||
"Vulture" \
|
"Vulture" \
|
||||||
uv run --with "vulture>=2.15,<3.0.0" \
|
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 \
|
run_check \
|
||||||
"Backend Tests" \
|
"Backend Tests" \
|
||||||
"${python_cmd[@]}" -m unittest discover -s tests -p "test_*.py"
|
"${python_cmd[@]}" -m unittest discover -s tests -p "test_*.py"
|
||||||
|
|
|
||||||
18
scripts/run_prod.sh
Executable file
18
scripts/run_prod.sh
Executable 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
|
||||||
22
settings.py
22
settings.py
|
|
@ -10,6 +10,7 @@ class Settings:
|
||||||
app_base_url: str | None
|
app_base_url: str | None
|
||||||
auth_secret_key: str | None
|
auth_secret_key: str | None
|
||||||
auth_cookie_secure: bool
|
auth_cookie_secure: bool
|
||||||
|
cors_allow_origins: tuple[str, ...]
|
||||||
forgejo_base_url: str
|
forgejo_base_url: str
|
||||||
forgejo_token: str | None
|
forgejo_token: str | None
|
||||||
forgejo_oauth_client_id: 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, ...]:
|
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()
|
value = (raw_value or "").strip()
|
||||||
if not value:
|
if not value:
|
||||||
return ()
|
return ()
|
||||||
|
|
@ -56,14 +61,25 @@ def _parse_bool(raw_value: str | None, *, default: bool = False) -> bool:
|
||||||
return value in {"1", "true", "yes", "on"}
|
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)
|
@lru_cache(maxsize=1)
|
||||||
def get_settings() -> Settings:
|
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(
|
return Settings(
|
||||||
app_base_url=_normalize_base_url(os.getenv("APP_BASE_URL"))
|
app_base_url=app_base_url,
|
||||||
if os.getenv("APP_BASE_URL")
|
|
||||||
else None,
|
|
||||||
auth_secret_key=os.getenv("AUTH_SECRET_KEY") or None,
|
auth_secret_key=os.getenv("AUTH_SECRET_KEY") or None,
|
||||||
auth_cookie_secure=_parse_bool(os.getenv("AUTH_COOKIE_SECURE")),
|
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_base_url=_normalize_base_url(os.getenv("FORGEJO_BASE_URL")),
|
||||||
forgejo_token=os.getenv("FORGEJO_TOKEN") or None,
|
forgejo_token=os.getenv("FORGEJO_TOKEN") or None,
|
||||||
forgejo_oauth_client_id=os.getenv("FORGEJO_OAUTH_CLIENT_ID") or None,
|
forgejo_oauth_client_id=os.getenv("FORGEJO_OAUTH_CLIENT_ID") or None,
|
||||||
|
|
|
||||||
|
|
@ -167,7 +167,7 @@ class AppTestCase(unittest.TestCase):
|
||||||
fake_client = _FakeForgejoClient(user={"login": "kacper"})
|
fake_client = _FakeForgejoClient(user={"login": "kacper"})
|
||||||
with (
|
with (
|
||||||
patch("prototype_cache.build_live_prototype_payload", new=builder),
|
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(
|
response = self.client.get(
|
||||||
"/api/prototype",
|
"/api/prototype",
|
||||||
|
|
@ -181,6 +181,38 @@ class AppTestCase(unittest.TestCase):
|
||||||
self.assertEqual(response_payload["auth"]["authenticated"], True)
|
self.assertEqual(response_payload["auth"]["authenticated"], True)
|
||||||
self.assertEqual(response_payload["auth"]["login"], "kacper")
|
self.assertEqual(response_payload["auth"]["login"], "kacper")
|
||||||
self.assertEqual(response_payload["auth"]["source"], "authorization")
|
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:
|
def test_prototype_can_use_server_token_without_user_session(self) -> None:
|
||||||
payload = {
|
payload = {
|
||||||
|
|
@ -378,7 +410,7 @@ class AppTestCase(unittest.TestCase):
|
||||||
"updated_at": "2026-04-11T12:00:00Z",
|
"updated_at": "2026-04-11T12:00:00Z",
|
||||||
"html_url": "https://aksal.cloud/Robot-U/robot-u-site/issues/9",
|
"html_url": "https://aksal.cloud/Robot-U/robot-u-site/issues/9",
|
||||||
"user": {"login": "Kacper", "avatar_url": ""},
|
"user": {"login": "Kacper", "avatar_url": ""},
|
||||||
"labels": [],
|
"labels": [{"name": "discussion"}],
|
||||||
"state": "open",
|
"state": "open",
|
||||||
},
|
},
|
||||||
comments=[
|
comments=[
|
||||||
|
|
@ -400,6 +432,26 @@ class AppTestCase(unittest.TestCase):
|
||||||
self.assertEqual(payload["comments"][0]["body"], "Reply body")
|
self.assertEqual(payload["comments"][0]["body"], "Reply body")
|
||||||
self.assertEqual(payload["links"][0]["kind"], "post")
|
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:
|
def test_create_discussion_reply_invalidates_prototype_cache(self) -> None:
|
||||||
initial_payload = {
|
initial_payload = {
|
||||||
"hero": {"title": "Before"},
|
"hero": {"title": "Before"},
|
||||||
|
|
@ -496,6 +548,7 @@ class AppTestCase(unittest.TestCase):
|
||||||
)
|
)
|
||||||
self.assertEqual(payload["id"], 456)
|
self.assertEqual(payload["id"], 456)
|
||||||
self.assertEqual(payload["repo"], "Robot-U/robot-u-site")
|
self.assertEqual(payload["repo"], "Robot-U/robot-u-site")
|
||||||
|
self.assertEqual(payload["labels"], ["discussion"])
|
||||||
self.assertEqual(payload["links"][0]["kind"], "post")
|
self.assertEqual(payload["links"][0]["kind"], "post")
|
||||||
|
|
||||||
def test_create_general_discussion_uses_configured_repo(self) -> None:
|
def test_create_general_discussion_uses_configured_repo(self) -> None:
|
||||||
|
|
@ -532,6 +585,7 @@ class AppTestCase(unittest.TestCase):
|
||||||
fake_client.created_issue,
|
fake_client.created_issue,
|
||||||
("Robot-U", "community", "General project help", "I need help choosing motors."),
|
("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:
|
def test_create_discussion_rejects_server_token_fallback(self) -> None:
|
||||||
get_settings.cache_clear()
|
get_settings.cache_clear()
|
||||||
|
|
@ -583,6 +637,7 @@ class AppTestCase(unittest.TestCase):
|
||||||
|
|
||||||
self.assertEqual(response.status_code, 200)
|
self.assertEqual(response.status_code, 200)
|
||||||
self.assertEqual(client_factory.call_args.kwargs["forgejo_token"], "oauth-token")
|
self.assertEqual(client_factory.call_args.kwargs["forgejo_token"], "oauth-token")
|
||||||
|
self.assertEqual(client_factory.call_args.kwargs["auth_scheme"], "Bearer")
|
||||||
self.assertEqual(
|
self.assertEqual(
|
||||||
reply_client.created_comment, ("Robot-U", "RobotClass", 2, "Thanks, this helped.")
|
reply_client.created_comment, ("Robot-U", "RobotClass", 2, "Thanks, this helped.")
|
||||||
)
|
)
|
||||||
|
|
@ -638,6 +693,7 @@ class _FakeForgejoClient:
|
||||||
self._repo_private = repo_private
|
self._repo_private = repo_private
|
||||||
self.created_comment: tuple[str, str, int, str] | None = None
|
self.created_comment: tuple[str, str, int, str] | None = None
|
||||||
self.created_issue: tuple[str, str, str, 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
|
self.exchanged_code: str | None = None
|
||||||
|
|
||||||
async def __aenter__(self) -> _FakeForgejoClient:
|
async def __aenter__(self) -> _FakeForgejoClient:
|
||||||
|
|
@ -664,12 +720,27 @@ class _FakeForgejoClient:
|
||||||
repo: str,
|
repo: str,
|
||||||
title: str,
|
title: str,
|
||||||
body: str,
|
body: str,
|
||||||
|
label_ids: list[int] | None = None,
|
||||||
) -> dict[str, object]:
|
) -> dict[str, object]:
|
||||||
self.created_issue = (owner, repo, title, body)
|
self.created_issue = (owner, repo, title, body)
|
||||||
|
self.created_issue_labels = label_ids
|
||||||
if self._issue is None:
|
if self._issue is None:
|
||||||
raise AssertionError("Fake issue was not configured.")
|
raise AssertionError("Fake issue was not configured.")
|
||||||
return self._issue
|
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]:
|
async def fetch_issue(self, _owner: str, _repo: str, _issue_number: int) -> dict[str, object]:
|
||||||
if self._issue is None:
|
if self._issue is None:
|
||||||
raise AssertionError("Fake issue was not configured.")
|
raise AssertionError("Fake issue was not configured.")
|
||||||
|
|
|
||||||
|
|
@ -3,7 +3,13 @@ from __future__ import annotations
|
||||||
import unittest
|
import unittest
|
||||||
from typing import Any
|
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):
|
class LivePrototypeTestCase(unittest.IsolatedAsyncioTestCase):
|
||||||
|
|
@ -16,7 +22,10 @@ class LivePrototypeTestCase(unittest.IsolatedAsyncioTestCase):
|
||||||
"full_name": "Robot-U/robot-u-site",
|
"full_name": "Robot-U/robot-u-site",
|
||||||
"description": "Robot U site source",
|
"description": "Robot U site source",
|
||||||
"updated_at": "2026-04-13T00:00:00Z",
|
"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)
|
self.assertEqual(summary["blog_count"], 1)
|
||||||
post = _post_card(summary["blog_posts"][0])
|
post = _post_card(summary["blog_posts"][0])
|
||||||
self.assertEqual(post["title"], "Building Robot U")
|
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["slug"], "building-robot-u-site")
|
||||||
self.assertEqual(post["repo"], "Robot-U/robot-u-site")
|
self.assertEqual(post["repo"], "Robot-U/robot-u-site")
|
||||||
self.assertEqual(post["path"], "blogs/building-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]["kind"], "post")
|
||||||
self.assertEqual(links[1]["path"], "/posts/Robot-U/robot-u-site/building-robot-u-site")
|
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:
|
class _FakeContentClient:
|
||||||
async def list_directory(
|
async def list_directory(
|
||||||
|
|
|
||||||
Loading…
Add table
Add a link
Reference in a new issue