Build Forgejo-backed community prototype

This commit is contained in:
kacper 2026-04-12 20:15:33 -04:00
parent 797ae5ea35
commit 6671a01d26
16 changed files with 2485 additions and 293 deletions

View file

@ -8,8 +8,16 @@ from forgejo_client import ForgejoClient, ForgejoClientError
from settings import Settings
async def build_live_prototype_payload(settings: Settings) -> dict[str, object]:
async def build_live_prototype_payload(
settings: Settings,
*,
forgejo_token: str | None = None,
auth_source: str = "none",
session_user: dict[str, Any] | None = None,
) -> dict[str, object]:
warnings: list[str] = []
access_token = forgejo_token or settings.forgejo_token
has_user_token = bool(access_token) and auth_source in {"authorization", "session"}
source_cards = [
{
"title": "Forgejo base URL",
@ -17,11 +25,7 @@ async def build_live_prototype_payload(settings: Settings) -> dict[str, object]:
},
{
"title": "Access mode",
"description": (
"Server token configured for live API reads."
if settings.forgejo_token
else "Instance API requires auth. Set FORGEJO_TOKEN for live repo discovery."
),
"description": _access_mode_description(access_token, auth_source),
},
]
@ -34,7 +38,7 @@ async def build_live_prototype_payload(settings: Settings) -> dict[str, object]:
},
)
async with ForgejoClient(settings) as client:
async with ForgejoClient(settings, forgejo_token=access_token) as client:
try:
oidc = await client.fetch_openid_configuration()
except ForgejoClientError as error:
@ -49,32 +53,8 @@ async def build_live_prototype_payload(settings: Settings) -> dict[str, object]:
},
)
if not settings.forgejo_token:
warnings.append(
"aksal.cloud blocks anonymous API calls, so the prototype needs FORGEJO_TOKEN "
"before it can load repos or issues.",
)
source_cards.append(
{
"title": "Discovery state",
"description": "Waiting for FORGEJO_TOKEN to enable live repo and issue reads.",
},
)
return _empty_payload(
source_cards=source_cards,
warnings=warnings,
hero_summary=(
"Connected to aksal.cloud for identity and OIDC discovery, but live repo content "
"is gated until a Forgejo API token is configured on the backend."
),
)
try:
current_user, repos, issues = await asyncio.gather(
client.fetch_current_user(),
client.search_repositories(),
client.search_recent_issues(),
)
repos = await client.search_repositories()
except ForgejoClientError as error:
warnings.append(str(error))
source_cards.append(
@ -86,38 +66,46 @@ async def build_live_prototype_payload(settings: Settings) -> dict[str, object]:
return _empty_payload(
source_cards=source_cards,
warnings=warnings,
auth=_auth_payload(
session_user, _display_auth_source(auth_source, session_user), settings
),
hero_summary=(
"The backend reached aksal.cloud, but the configured token could not complete "
"the repo discovery flow."
"the public repo discovery flow."
),
)
current_user = await _current_user_for_auth_source(client, has_user_token, warnings)
public_repos = [repo for repo in repos if not repo.get("fork") and not repo.get("private")]
repo_summaries = await asyncio.gather(
*[
_summarize_repo(client, repo)
for repo in repos
if not repo.get("fork") and not repo.get("private")
],
*[_summarize_repo(client, repo) for repo in public_repos],
)
content_repos = [summary for summary in repo_summaries if summary is not None]
course_repos = [summary for summary in content_repos if summary["lesson_count"] > 0]
post_repos = [summary for summary in content_repos if summary["blog_count"] > 0]
source_cards.append(
{
"title": "Signed-in API identity",
"description": str(current_user.get("login", "Unknown user")),
},
public_issues = await _recent_public_issues(
client,
public_repos,
settings.forgejo_recent_issue_limit,
)
if current_user is not None:
source_cards.append(
{
"title": "Signed-in API identity",
"description": str(current_user.get("login", "Unknown user")),
},
)
source_cards.append(
{
"title": "Discovery state",
"description": (
f"Detected {len(course_repos)} course repos, {len(post_repos)} post repos, "
f"and {len(issues)} recent issues."
f"and {len(public_issues)} recent public issues."
),
},
)
auth_user = session_user or current_user
return {
"hero": {
@ -125,7 +113,7 @@ async def build_live_prototype_payload(settings: Settings) -> dict[str, object]:
"title": "Robot U is reading from your aksal.cloud Forgejo instance.",
"summary": (
"This prototype now uses the real Forgejo base URL, OIDC metadata, visible repos, "
"and recent issues available to the configured backend token."
"and recent issues available to the active token."
),
"highlights": [
"Repo discovery filters to public, non-fork repositories only",
@ -133,27 +121,54 @@ async def build_live_prototype_payload(settings: Settings) -> dict[str, object]:
"Recent discussions are loaded from live Forgejo issues",
],
},
"auth": _auth_payload(
auth_user if has_user_token else session_user,
_display_auth_source(auth_source, session_user),
settings,
),
"source_of_truth": source_cards,
"featured_courses": [_course_card(summary) for summary in course_repos[:6]],
"recent_posts": [_post_card(summary) for summary in post_repos[:6]],
"upcoming_events": _event_cards(calendar_feeds, settings.calendar_event_limit),
"recent_discussions": await asyncio.gather(
*[_discussion_card(client, issue) for issue in issues],
*[_discussion_card(client, issue) for issue in public_issues],
),
"implementation_notes": [
"Live repo discovery is now driven by the Forgejo API instead of mock content.",
"Issues shown here are real Forgejo issues visible to the configured token.",
"Issues shown here are loaded only from public Forgejo repositories.",
*warnings,
],
}
def _access_mode_description(access_token: str | None, auth_source: str) -> str:
if auth_source in {"authorization", "session"} and access_token:
return f"Authenticated through {auth_source} token."
if auth_source == "server" or access_token:
return "Reading public content through the server-side Forgejo token."
return "Reading public content anonymously."
async def _current_user_for_auth_source(
client: ForgejoClient,
has_user_token: bool,
warnings: list[str],
) -> dict[str, Any] | None:
if not has_user_token:
return None
try:
return await client.fetch_current_user()
except ForgejoClientError as error:
warnings.append(str(error))
return None
async def _summarize_repo(
client: ForgejoClient,
repo: dict[str, Any],
) -> dict[str, Any] | None:
owner = repo.get("owner", {})
owner_login = owner.get("login")
owner_login = _repo_owner_login(repo)
repo_name = repo.get("name")
if not isinstance(owner_login, str) or not isinstance(repo_name, str):
return None
@ -247,6 +262,7 @@ def _course_card(summary: dict[str, Any]) -> dict[str, object]:
"summary": summary["description"],
"status": "Live course repo",
"outline": summary["course_outline"],
"updated_at": summary["updated_at"],
}
@ -258,9 +274,65 @@ def _post_card(summary: dict[str, Any]) -> dict[str, object]:
"repo": summary["full_name"],
"kind": "Repo with /blogs/",
"summary": f"{label}. {summary['description']}",
"updated_at": summary["updated_at"],
}
async def _recent_public_issues(
client: ForgejoClient,
repos: list[dict[str, Any]],
limit: int,
) -> list[dict[str, Any]]:
issue_lists = await asyncio.gather(
*[_repo_issues(client, repo, limit) for repo in repos],
)
issues = [issue for issue_list in issue_lists for issue in issue_list]
return sorted(issues, key=lambda issue: str(issue.get("updated_at", "")), reverse=True)[:limit]
async def _repo_issues(
client: ForgejoClient,
repo: dict[str, Any],
limit: int,
) -> list[dict[str, Any]]:
owner_login = _repo_owner_login(repo)
repo_name = repo.get("name")
if not isinstance(owner_login, str) or not isinstance(repo_name, str):
return []
try:
issues = await client.list_repo_issues(owner_login, repo_name, limit=limit)
except ForgejoClientError:
return []
return [_with_repository(issue, repo, owner_login, repo_name) for issue in issues]
def _with_repository(
issue: dict[str, Any],
repo: dict[str, Any],
owner_login: str,
repo_name: str,
) -> dict[str, Any]:
issue_with_repo = dict(issue)
issue_with_repo["repository"] = {
"owner": owner_login,
"name": repo_name,
"full_name": repo.get("full_name", f"{owner_login}/{repo_name}"),
"private": False,
}
return issue_with_repo
def _repo_owner_login(repo: dict[str, Any]) -> str | None:
owner = repo.get("owner", {})
if isinstance(owner, dict) and isinstance(owner.get("login"), str):
return owner["login"]
if isinstance(owner, str):
return owner
return None
def _event_cards(calendar_feeds: list[CalendarFeed], limit: int) -> list[dict[str, object]]:
upcoming_events = sorted(
[event for feed in calendar_feeds for event in feed.events],
@ -341,10 +413,17 @@ def _discussion_reply(comment: dict[str, Any]) -> dict[str, object]:
}
def _display_auth_source(auth_source: str, session_user: dict[str, Any] | None) -> str:
if session_user:
return "session"
return auth_source
def _empty_payload(
*,
source_cards: list[dict[str, str]],
warnings: list[str],
auth: dict[str, object],
hero_summary: str,
) -> dict[str, object]:
return {
@ -355,16 +434,43 @@ def _empty_payload(
"highlights": [
"Forgejo remains the source of truth for content and discussions",
"The prototype now targets aksal.cloud by default",
"Live repo discovery unlocks as soon as a backend token is configured",
"Public repo discovery works without signing in when Forgejo allows anonymous reads",
],
},
"auth": auth,
"source_of_truth": source_cards,
"featured_courses": [],
"recent_posts": [],
"upcoming_events": [],
"recent_discussions": [],
"implementation_notes": warnings
or ["Live repo discovery is ready, but no Forgejo token has been configured yet."],
or ["Live repo discovery is ready, but Forgejo did not return public content."],
}
def _auth_payload(
user: dict[str, Any] | None,
source: str,
settings: Settings,
) -> dict[str, object]:
oauth_configured = bool(
settings.forgejo_oauth_client_id and settings.forgejo_oauth_client_secret
)
if not user:
return {
"authenticated": False,
"login": None,
"source": source,
"can_reply": source in {"authorization", "session"},
"oauth_configured": oauth_configured,
}
return {
"authenticated": True,
"login": user.get("login", "Unknown user"),
"source": source,
"can_reply": source in {"authorization", "session"},
"oauth_configured": oauth_configured,
}
@ -468,7 +574,9 @@ async def _load_calendar_feeds(settings: Settings, warnings: list[str]) -> list[
def _format_event_datetime(value: Any) -> str:
return value.strftime("%b %-d, %-I:%M %p UTC")
timezone_name = value.strftime("%Z") if hasattr(value, "strftime") else ""
suffix = f" {timezone_name}" if timezone_name else ""
return f"{value.strftime('%b %-d, %-I:%M %p')}{suffix}"
def _empty_lesson(