Complete Forgejo discussion MVP

This commit is contained in:
kacper 2026-04-13 18:19:50 -04:00
parent d84a885fdb
commit 51706d2d11
17 changed files with 1708 additions and 127 deletions

View file

@ -1,7 +1,9 @@
from __future__ import annotations
import asyncio
import re
from typing import Any
from urllib.parse import unquote, urlparse
from calendar_feeds import CalendarFeed, CalendarFeedError, fetch_calendar_feed
from forgejo_client import ForgejoClient, ForgejoClientError
@ -75,6 +77,7 @@ async def build_live_prototype_payload(
),
)
repos = await _with_configured_discussion_repo(client, repos, settings, warnings)
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(
@ -132,8 +135,9 @@ async def build_live_prototype_payload(
settings,
),
"source_of_truth": source_cards,
"featured_courses": [_course_card(summary) for summary in course_repos[:6]],
"recent_posts": [_post_card(post) for post in blog_posts[:6]],
"discussion_settings": _discussion_settings(settings),
"featured_courses": [_course_card(summary) for summary in course_repos],
"recent_posts": [_post_card(post) for post in blog_posts],
"upcoming_events": _event_cards(calendar_feeds, settings.calendar_event_limit),
"recent_discussions": await asyncio.gather(
*[_discussion_card(client, issue) for issue in public_issues],
@ -169,6 +173,49 @@ async def _current_user_for_auth_source(
return None
async def _with_configured_discussion_repo(
client: ForgejoClient,
repos: list[dict[str, Any]],
settings: Settings,
warnings: list[str],
) -> list[dict[str, Any]]:
owner_repo = _configured_owner_repo(settings.forgejo_general_discussion_repo)
if owner_repo is None:
return repos
owner, repo = owner_repo
full_name = f"{owner}/{repo}".lower()
if any(str(candidate.get("full_name", "")).lower() == full_name for candidate in repos):
return repos
try:
configured_repo = await client.fetch_repository(owner, repo)
except ForgejoClientError as error:
warnings.append(f"General discussion repo could not be loaded: {error}")
return repos
return [*repos, configured_repo]
def _configured_owner_repo(value: str | None) -> tuple[str, str] | None:
if not value:
return None
owner, separator, repo = value.strip().partition("/")
if not separator or not owner or not repo or "/" in repo:
return None
return owner, repo
def _discussion_settings(settings: Settings) -> dict[str, object]:
return _discussion_settings_from_configured(
_configured_owner_repo(settings.forgejo_general_discussion_repo) is not None,
)
def _discussion_settings_from_configured(general_discussion_configured: bool) -> dict[str, object]:
return {"general_discussion_configured": general_discussion_configured}
async def _summarize_repo(
client: ForgejoClient,
repo: dict[str, Any],
@ -177,6 +224,7 @@ async def _summarize_repo(
repo_name = repo.get("name")
if not isinstance(owner_login, str) or not isinstance(repo_name, str):
return None
default_branch = str(repo.get("default_branch") or "main")
try:
root_entries = await client.list_directory(owner_login, repo_name)
@ -222,6 +270,8 @@ async def _summarize_repo(
client,
owner_login,
repo_name,
default_branch,
str(repo.get("html_url", "")),
chapter_name,
str(lesson_dir.get("name", "")),
)
@ -251,6 +301,8 @@ async def _summarize_repo(
str(repo.get("full_name", f"{owner_login}/{repo_name}")),
str(repo.get("description") or ""),
str(repo.get("updated_at", "")),
default_branch,
str(repo.get("html_url", "")),
str(blog_dir.get("name", "")),
)
for blog_dir in blog_dirs
@ -300,6 +352,8 @@ def _post_card(post: dict[str, Any]) -> dict[str, object]:
"path": post["path"],
"file_path": post["file_path"],
"html_url": post["html_url"],
"raw_base_url": post["raw_base_url"],
"assets": post["assets"],
"body": post["body"],
"updated_at": post["updated_at"],
}
@ -379,15 +433,7 @@ def _event_cards(calendar_feeds: list[CalendarFeed], limit: int) -> list[dict[st
async def _discussion_card(client: ForgejoClient, issue: dict[str, Any]) -> dict[str, object]:
repository = issue.get("repository") or {}
owner = repository.get("owner", "")
full_name = repository.get("full_name", "Unknown repo")
comments = issue.get("comments", 0)
issue_number = int(issue.get("number", 0))
issue_author = issue.get("user") or {}
labels = [
label.get("name")
for label in issue.get("labels", [])
if isinstance(label, dict) and isinstance(label.get("name"), str)
]
comment_items: list[dict[str, object]] = []
if isinstance(owner, str) and isinstance(repository.get("name"), str) and issue_number > 0:
try:
@ -402,7 +448,25 @@ async def _discussion_card(client: ForgejoClient, issue: dict[str, Any]) -> dict
except ForgejoClientError:
comment_items = []
return discussion_card_from_issue(issue, comments=comment_items)
def discussion_card_from_issue(
issue: dict[str, Any],
*,
comments: list[dict[str, object]] | None = None,
) -> dict[str, object]:
repository = issue.get("repository") or {}
full_name = repository.get("full_name", "Unknown repo")
issue_author = issue.get("user") or {}
issue_number = int(issue.get("number", 0) or 0)
labels = [
label.get("name")
for label in issue.get("labels", [])
if isinstance(label, dict) and isinstance(label.get("name"), str)
]
body = str(issue.get("body", "") or "").strip()
links = discussion_links_from_text(body)
if not body:
body = "No issue description yet. Right now the conversation starts in the replies."
@ -410,8 +474,8 @@ async def _discussion_card(client: ForgejoClient, issue: dict[str, Any]) -> dict
"id": int(issue.get("id", 0)),
"title": issue.get("title", "Untitled issue"),
"repo": full_name,
"replies": comments,
"context": "Live Forgejo issue",
"replies": int(issue.get("comments", 0) or 0),
"context": "Linked discussion" if links else "Live Forgejo issue",
"author": issue_author.get("login", "Unknown author"),
"author_avatar_url": issue_author.get("avatar_url", ""),
"state": issue.get("state", "open"),
@ -420,10 +484,122 @@ async def _discussion_card(client: ForgejoClient, issue: dict[str, Any]) -> dict
"updated_at": issue.get("updated_at", ""),
"html_url": issue.get("html_url", ""),
"labels": [label for label in labels if isinstance(label, str)],
"comments": comment_items,
"comments": comments or [],
"links": links,
}
def discussion_links_from_text(text: str) -> list[dict[str, object]]:
links: list[dict[str, object]] = []
seen: set[tuple[str, str, str, str]] = set()
for match in re.finditer(
r"(?:https?://[^\s)]+)?(/posts/([^/\s)]+)/([^/\s)]+)/([^/\s)#?]+))", text
):
owner = unquote(match.group(2))
repo = unquote(match.group(3))
slug = unquote(match.group(4).rstrip(".,"))
path = f"/posts/{owner}/{repo}/{slug}"
_append_discussion_link(
links,
seen,
{
"kind": "post",
"path": path,
"owner": owner,
"repo": repo,
"slug": slug,
"content_path": f"blogs/{slug}",
},
)
lesson_pattern = (
r"(?:https?://[^\s)]+)?"
r"(/courses/([^/\s)]+)/([^/\s)]+)/lessons/([^/\s)]+)/([^/\s)#?]+))"
)
for match in re.finditer(lesson_pattern, text):
owner = unquote(match.group(2))
repo = unquote(match.group(3))
chapter = unquote(match.group(4))
lesson = unquote(match.group(5).rstrip(".,"))
path = f"/courses/{owner}/{repo}/lessons/{chapter}/{lesson}"
_append_discussion_link(
links,
seen,
{
"kind": "lesson",
"path": path,
"owner": owner,
"repo": repo,
"chapter": chapter,
"lesson": lesson,
"content_path": f"lessons/{chapter}/{lesson}",
},
)
for raw_url in re.findall(r"https?://[^\s)]+", text):
file_link = _forgejo_file_link(raw_url)
if file_link is not None:
_append_discussion_link(links, seen, file_link)
return links
def _append_discussion_link(
links: list[dict[str, object]],
seen: set[tuple[str, str, str, str]],
link: dict[str, object],
) -> None:
key = (
str(link.get("kind", "")),
str(link.get("owner", "")),
str(link.get("repo", "")),
str(link.get("content_path", "")),
)
if key in seen:
return
seen.add(key)
links.append(link)
def _forgejo_file_link(raw_url: str) -> dict[str, object] | None:
parsed = urlparse(raw_url.rstrip(".,"))
path_parts = [unquote(part) for part in parsed.path.strip("/").split("/") if part]
if len(path_parts) < 6 or path_parts[2:4] != ["src", "branch"]:
return None
owner, repo = path_parts[0], path_parts[1]
content_parts = path_parts[5:]
if len(content_parts) < 2:
return None
if content_parts[0] == "blogs":
slug = content_parts[1]
return {
"kind": "post",
"path": f"/posts/{owner}/{repo}/{slug}",
"owner": owner,
"repo": repo,
"slug": slug,
"content_path": f"blogs/{slug}",
}
if content_parts[0] == "lessons" and len(content_parts) >= 3:
chapter = content_parts[1]
lesson = content_parts[2]
return {
"kind": "lesson",
"path": f"/courses/{owner}/{repo}/lessons/{chapter}/{lesson}",
"owner": owner,
"repo": repo,
"chapter": chapter,
"lesson": lesson,
"content_path": f"lessons/{chapter}/{lesson}",
}
return None
def _discussion_reply(comment: dict[str, Any]) -> dict[str, object]:
author = comment.get("user") or {}
body = str(comment.get("body", "") or "").strip()
@ -466,6 +642,7 @@ def _empty_payload(
},
"auth": auth,
"source_of_truth": source_cards,
"discussion_settings": _discussion_settings_from_configured(False),
"featured_courses": [],
"recent_posts": [],
"upcoming_events": [],
@ -510,10 +687,13 @@ async def _summarize_blog_post(
full_name: str,
repo_description: str,
updated_at: str,
default_branch: str,
repo_html_url: str,
post_name: str,
) -> dict[str, object]:
post_path = f"blogs/{post_name}"
fallback_title = _display_name(post_name)
raw_base_url = _raw_folder_url(repo_html_url, default_branch, post_path)
try:
post_entries = await client.list_directory(owner, repo, post_path)
@ -527,8 +707,10 @@ async def _summarize_blog_post(
repo_description,
updated_at,
post_path,
raw_base_url=raw_base_url,
)
assets = _content_assets(post_entries, raw_base_url, post_path)
markdown_files = _markdown_file_entries(post_entries)
if not markdown_files:
return _empty_blog_post(
@ -540,6 +722,8 @@ async def _summarize_blog_post(
repo_description,
updated_at,
post_path,
raw_base_url=raw_base_url,
assets=assets,
)
markdown_name = str(markdown_files[0]["name"])
@ -559,6 +743,8 @@ async def _summarize_blog_post(
post_path,
file_path=markdown_path,
html_url=str(markdown_files[0].get("html_url", "")),
raw_base_url=raw_base_url,
assets=assets,
)
metadata, body = _parse_frontmatter(str(file_payload.get("content", "")))
@ -572,6 +758,8 @@ async def _summarize_blog_post(
"path": post_path,
"file_path": str(file_payload.get("path", markdown_path)),
"html_url": str(file_payload.get("html_url", "")),
"raw_base_url": raw_base_url,
"assets": assets,
"body": body,
"updated_at": updated_at,
}
@ -581,20 +769,30 @@ async def _summarize_lesson(
client: ForgejoClient,
owner: str,
repo: str,
default_branch: str,
repo_html_url: str,
chapter_name: str,
lesson_name: str,
) -> dict[str, object]:
lesson_path = f"lessons/{chapter_name}/{lesson_name}"
fallback_title = _display_name(lesson_name)
raw_base_url = _raw_folder_url(repo_html_url, default_branch, lesson_path)
try:
lesson_entries = await client.list_directory(owner, repo, lesson_path)
except ForgejoClientError:
return _empty_lesson(lesson_name, fallback_title, lesson_path)
return _empty_lesson(lesson_name, fallback_title, lesson_path, raw_base_url=raw_base_url)
assets = _content_assets(lesson_entries, raw_base_url, lesson_path)
markdown_files = _markdown_file_entries(lesson_entries)
if not markdown_files:
return _empty_lesson(lesson_name, fallback_title, lesson_path)
return _empty_lesson(
lesson_name,
fallback_title,
lesson_path,
raw_base_url=raw_base_url,
assets=assets,
)
markdown_name = str(markdown_files[0]["name"])
markdown_path = f"{lesson_path}/{markdown_name}"
@ -608,6 +806,8 @@ async def _summarize_lesson(
lesson_path,
file_path=markdown_path,
html_url=str(markdown_files[0].get("html_url", "")),
raw_base_url=raw_base_url,
assets=assets,
)
metadata, body = _parse_frontmatter(str(file_payload.get("content", "")))
@ -618,6 +818,8 @@ async def _summarize_lesson(
"path": lesson_path,
"file_path": str(file_payload.get("path", markdown_path)),
"html_url": str(file_payload.get("html_url", "")),
"raw_base_url": raw_base_url,
"assets": assets,
"body": body,
}
@ -646,6 +848,45 @@ def _markdown_file_entries(entries: list[dict[str, Any]]) -> list[dict[str, Any]
)
def _content_assets(
entries: list[dict[str, Any]],
raw_base_url: str,
folder_path: str,
) -> list[dict[str, object]]:
assets: list[dict[str, object]] = []
for entry in entries:
if entry.get("type") != "file" or not isinstance(entry.get("name"), str):
continue
name = str(entry["name"])
if name.lower().endswith(".md"):
continue
path = str(entry.get("path") or f"{folder_path}/{name}")
assets.append(
{
"name": name,
"path": path,
"html_url": str(entry.get("html_url", "")),
"download_url": str(entry.get("download_url") or _raw_file_url(raw_base_url, name)),
},
)
return sorted(assets, key=lambda asset: str(asset["name"]))
def _raw_folder_url(repo_html_url: str, default_branch: str, folder_path: str) -> str:
if not repo_html_url:
return ""
branch = default_branch.strip("/") or "main"
return f"{repo_html_url.rstrip('/')}/raw/branch/{branch}/{folder_path.strip('/')}/"
def _raw_file_url(raw_base_url: str, name: str) -> str:
if not raw_base_url:
return ""
return f"{raw_base_url.rstrip('/')}/{name}"
def _display_name(value: str) -> str:
cleaned = value.strip().rsplit(".", 1)[0]
cleaned = cleaned.replace("_", " ").replace("-", " ")
@ -693,6 +934,8 @@ def _empty_lesson(
*,
file_path: str = "",
html_url: str = "",
raw_base_url: str = "",
assets: list[dict[str, object]] | None = None,
) -> dict[str, object]:
return {
"slug": lesson_name,
@ -701,6 +944,8 @@ def _empty_lesson(
"path": lesson_path,
"file_path": file_path,
"html_url": html_url,
"raw_base_url": raw_base_url,
"assets": assets or [],
"body": "",
}
@ -717,6 +962,8 @@ def _empty_blog_post(
*,
file_path: str = "",
html_url: str = "",
raw_base_url: str = "",
assets: list[dict[str, object]] | None = None,
) -> dict[str, object]:
return {
"slug": post_name,
@ -728,6 +975,8 @@ def _empty_blog_post(
"path": post_path,
"file_path": file_path,
"html_url": html_url,
"raw_base_url": raw_base_url,
"assets": assets or [],
"body": "",
"updated_at": updated_at,
}