Complete Forgejo discussion MVP
This commit is contained in:
parent
d84a885fdb
commit
51706d2d11
17 changed files with 1708 additions and 127 deletions
|
|
@ -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,
|
||||
}
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue