Build Forgejo-backed community prototype
This commit is contained in:
parent
797ae5ea35
commit
6671a01d26
16 changed files with 2485 additions and 293 deletions
|
|
@ -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(
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue