From 9fada7dd5c138991408cc22439cf5a68dc12b94d Mon Sep 17 00:00:00 2001 From: null Date: Wed, 20 May 2026 03:27:14 -0500 Subject: [PATCH] feat: Add Board-Level Linked Issues Panel --- backend/app/api/forgejo_issues.py | 35 +++++ frontend/src/app/boards/[boardId]/page.tsx | 4 +- .../git/BoardForgejoIssuesPanel.tsx | 132 ++++++++++++++++++ .../components/organisms/DashboardSidebar.tsx | 35 ++--- frontend/src/lib/api-forgejo.ts | 2 + 5 files changed, 186 insertions(+), 22 deletions(-) create mode 100644 frontend/src/components/git/BoardForgejoIssuesPanel.tsx diff --git a/backend/app/api/forgejo_issues.py b/backend/app/api/forgejo_issues.py index bd592b9..263071d 100644 --- a/backend/app/api/forgejo_issues.py +++ b/backend/app/api/forgejo_issues.py @@ -34,6 +34,7 @@ async def list_issues( session: AsyncSession = SESSION_DEP, ctx: OrganizationContext = ORG_MEMBER_DEP, repository_id: str | None = Query(None, description="Filter by repository ID"), + board_id: str | None = Query(None, description="Filter by board ID (returns issues from all repos linked to this board)"), state: str | None = Query(None, description="Filter by state (open, closed)"), label: str | None = Query(None, description="Filter by label name"), assignee: str | None = Query(None, description="Filter by assignee login"), @@ -48,6 +49,23 @@ async def list_issues( ForgejoIssue.is_pull_request.is_(False), ) + if board_id: + try: + board_uuid = UUID(board_id) + except ValueError: + raise HTTPException(status_code=status.HTTP_422_UNPROCESSABLE_CONTENT, detail="Invalid board_id format") + linked_repo_ids = ( + await session.exec( + select(BoardRepositoryLink.repository_id).where( + BoardRepositoryLink.board_id == board_uuid, + BoardRepositoryLink.organization_id == ctx.organization.id, + ) + ) + ).all() + if not linked_repo_ids: + return ForgejoIssueListResponse(items=[], total=0, page=page, limit=limit) + statement = statement.where(ForgejoIssue.repository_id.in_(linked_repo_ids)) + if repository_id: try: repo_uuid = UUID(repository_id) @@ -82,6 +100,23 @@ async def list_issues( ForgejoIssue.organization_id == ctx.organization.id, ForgejoIssue.is_pull_request.is_(False), ) + if board_id: + try: + board_uuid = UUID(board_id) + linked_repo_ids_for_count = ( + await session.exec( + select(BoardRepositoryLink.repository_id).where( + BoardRepositoryLink.board_id == board_uuid, + BoardRepositoryLink.organization_id == ctx.organization.id, + ) + ) + ).all() + if linked_repo_ids_for_count: + total_statement = total_statement.where( + ForgejoIssue.repository_id.in_(linked_repo_ids_for_count) + ) + except ValueError: + pass if repository_id: try: repo_uuid = UUID(repository_id) diff --git a/frontend/src/app/boards/[boardId]/page.tsx b/frontend/src/app/boards/[boardId]/page.tsx index 32a7615..2ea3c85 100644 --- a/frontend/src/app/boards/[boardId]/page.tsx +++ b/frontend/src/app/boards/[boardId]/page.tsx @@ -37,6 +37,7 @@ import { DashboardShell } from "@/components/templates/DashboardShell"; import { BoardChatComposer } from "@/components/BoardChatComposer"; import { TaskCustomFieldsEditor } from "./TaskCustomFieldsEditor"; import { BoardForgejoRepositoryLinks } from "@/components/git/BoardForgejoRepositoryLinks"; +import { BoardForgejoIssuesPanel } from "@/components/git/BoardForgejoIssuesPanel"; import { Button } from "@/components/ui/button"; import { Dialog, @@ -3245,7 +3246,8 @@ export default function BoardDetailPage() { {canRead && boardId ? ( -
+
+
) : null} diff --git a/frontend/src/components/git/BoardForgejoIssuesPanel.tsx b/frontend/src/components/git/BoardForgejoIssuesPanel.tsx new file mode 100644 index 0000000..712017e --- /dev/null +++ b/frontend/src/components/git/BoardForgejoIssuesPanel.tsx @@ -0,0 +1,132 @@ +"use client"; + +import { useEffect, useState } from "react"; +import { AlertCircle, GitBranch } from "lucide-react"; + +import { + getBoardForgejoRepositories, + getForgejoIssues, + type ForgejoIssue, + type ForgejoRepository, + type ForgejoIssueListResponse, +} from "@/lib/api-forgejo"; +import { ForgejoIssuesTable } from "@/components/git/ForgejoIssuesTable"; + +interface BoardForgejoIssuesPanelProps { + boardId: string; + repositories?: ForgejoRepository[]; +} + +export function BoardForgejoIssuesPanel({ + boardId, + repositories = [], +}: BoardForgejoIssuesPanelProps) { + const [issues, setIssues] = useState([]); + const [hasLinkedRepos, setHasLinkedRepos] = useState(null); + const [isLoading, setIsLoading] = useState(true); + const [error, setError] = useState(null); + + useEffect(() => { + if (!boardId) return; + let cancelled = false; + + const load = async () => { + setIsLoading(true); + setError(null); + try { + const [linksResult, issuesResult] = await Promise.all([ + getBoardForgejoRepositories(boardId), + getForgejoIssues({ board_id: boardId, state: "open", limit: 50 }) as Promise, + ]); + if (cancelled) return; + const links = Array.isArray(linksResult) + ? linksResult + : (linksResult.repositories ?? []); + setHasLinkedRepos(links.length > 0); + setIssues(issuesResult.items); + } catch (err) { + if (cancelled) return; + setError( + err instanceof Error + ? err.message + : "Could not load Git Project issues.", + ); + } finally { + if (!cancelled) setIsLoading(false); + } + }; + + load(); + return () => { + cancelled = true; + }; + }, [boardId]); + + const handleRefresh = async () => { + setIsLoading(true); + setError(null); + try { + const issuesResult = await getForgejoIssues({ + board_id: boardId, + state: "open", + limit: 50, + }) as ForgejoIssueListResponse; + setIssues(issuesResult.items); + } catch (err) { + setError( + err instanceof Error + ? err.message + : "Could not refresh Git Project issues.", + ); + } finally { + setIsLoading(false); + } + }; + + return ( +
+
+
+

+ Git Project Issues +

+

+ Open issues from repositories linked to this board. +

+
+ {!isLoading && issues.length > 0 && ( + + {issues.length} open + + )} +
+ + {error ? ( +
+ + {error} +
+ ) : hasLinkedRepos === false && !isLoading ? ( +
+
+ +
+

+ No repositories linked +

+

+ Link a Git Project repository to this board to see its open issues + here. +

+
+ ) : ( + + )} +
+ ); +} diff --git a/frontend/src/components/organisms/DashboardSidebar.tsx b/frontend/src/components/organisms/DashboardSidebar.tsx index 02b9ba7..23d8ff0 100644 --- a/frontend/src/components/organisms/DashboardSidebar.tsx +++ b/frontend/src/components/organisms/DashboardSidebar.tsx @@ -87,7 +87,7 @@ export function DashboardSidebar() { Live feed @@ -102,42 +102,42 @@ export function DashboardSidebar() {
Board groups Boards Git Projects Issues Tags Approvals @@ -145,9 +145,7 @@ export function DashboardSidebar() { {isAdmin ? ( Custom fields @@ -165,19 +163,14 @@ export function DashboardSidebar() {
Marketplace Packs @@ -194,14 +187,14 @@ export function DashboardSidebar() {
Organization Settings @@ -209,7 +202,7 @@ export function DashboardSidebar() { {isAdmin ? ( Gateways @@ -218,7 +211,7 @@ export function DashboardSidebar() { {isAdmin ? ( Agents diff --git a/frontend/src/lib/api-forgejo.ts b/frontend/src/lib/api-forgejo.ts index 2fca81b..48be17c 100644 --- a/frontend/src/lib/api-forgejo.ts +++ b/frontend/src/lib/api-forgejo.ts @@ -337,6 +337,7 @@ export interface ForgejoIssueListResponse { // Forgejo Issue API export async function getForgejoIssues(params?: { repository_id?: string; + board_id?: string; state?: string; search?: string; page?: number; @@ -345,6 +346,7 @@ export async function getForgejoIssues(params?: { const searchParams = new URLSearchParams(); if (params?.repository_id) searchParams.set("repository_id", params.repository_id); + if (params?.board_id) searchParams.set("board_id", params.board_id); if (params?.state) searchParams.set("state", params.state); if (params?.search) searchParams.set("search", params.search); if (params?.page) searchParams.set("page", params.page.toString());