feat: Add Board-Level Linked Issues Panel

This commit is contained in:
null 2026-05-20 03:27:14 -05:00
parent 08ea822edf
commit 9fada7dd5c
5 changed files with 186 additions and 22 deletions

View File

@ -34,6 +34,7 @@ async def list_issues(
session: AsyncSession = SESSION_DEP, session: AsyncSession = SESSION_DEP,
ctx: OrganizationContext = ORG_MEMBER_DEP, ctx: OrganizationContext = ORG_MEMBER_DEP,
repository_id: str | None = Query(None, description="Filter by repository ID"), 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)"), state: str | None = Query(None, description="Filter by state (open, closed)"),
label: str | None = Query(None, description="Filter by label name"), label: str | None = Query(None, description="Filter by label name"),
assignee: str | None = Query(None, description="Filter by assignee login"), assignee: str | None = Query(None, description="Filter by assignee login"),
@ -48,6 +49,23 @@ async def list_issues(
ForgejoIssue.is_pull_request.is_(False), 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: if repository_id:
try: try:
repo_uuid = UUID(repository_id) repo_uuid = UUID(repository_id)
@ -82,6 +100,23 @@ async def list_issues(
ForgejoIssue.organization_id == ctx.organization.id, ForgejoIssue.organization_id == ctx.organization.id,
ForgejoIssue.is_pull_request.is_(False), 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: if repository_id:
try: try:
repo_uuid = UUID(repository_id) repo_uuid = UUID(repository_id)

View File

@ -37,6 +37,7 @@ import { DashboardShell } from "@/components/templates/DashboardShell";
import { BoardChatComposer } from "@/components/BoardChatComposer"; import { BoardChatComposer } from "@/components/BoardChatComposer";
import { TaskCustomFieldsEditor } from "./TaskCustomFieldsEditor"; import { TaskCustomFieldsEditor } from "./TaskCustomFieldsEditor";
import { BoardForgejoRepositoryLinks } from "@/components/git/BoardForgejoRepositoryLinks"; import { BoardForgejoRepositoryLinks } from "@/components/git/BoardForgejoRepositoryLinks";
import { BoardForgejoIssuesPanel } from "@/components/git/BoardForgejoIssuesPanel";
import { Button } from "@/components/ui/button"; import { Button } from "@/components/ui/button";
import { import {
Dialog, Dialog,
@ -3245,7 +3246,8 @@ export default function BoardDetailPage() {
</div> </div>
{canRead && boardId ? ( {canRead && boardId ? (
<div className="w-full"> <div className="flex w-full flex-col gap-4">
<BoardForgejoIssuesPanel boardId={boardId} />
<BoardForgejoRepositoryLinks boardId={boardId} canWrite={canWrite} /> <BoardForgejoRepositoryLinks boardId={boardId} canWrite={canWrite} />
</div> </div>
) : null} ) : null}

View File

@ -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<ForgejoIssue[]>([]);
const [hasLinkedRepos, setHasLinkedRepos] = useState<boolean | null>(null);
const [isLoading, setIsLoading] = useState(true);
const [error, setError] = useState<string | null>(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<ForgejoIssueListResponse>,
]);
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 (
<section className="rounded-xl border border-[color:var(--border)] bg-[color:var(--surface)] p-4 shadow-lush sm:p-5">
<div className="mb-4 flex items-start justify-between gap-3">
<div className="min-w-0">
<h3 className="text-sm font-semibold text-strong">
Git Project Issues
</h3>
<p className="mt-1 text-sm text-muted">
Open issues from repositories linked to this board.
</p>
</div>
{!isLoading && issues.length > 0 && (
<span className="shrink-0 rounded-full border border-[color:var(--accent)]/30 bg-[color:var(--accent-soft)] px-2 py-0.5 text-xs font-medium text-[color:var(--accent)]">
{issues.length} open
</span>
)}
</div>
{error ? (
<div className="flex items-start gap-2 rounded-lg border border-[color:var(--danger)]/35 bg-[color:rgba(248,113,113,0.08)] px-3 py-2 text-sm text-[color:var(--danger)]">
<AlertCircle className="mt-0.5 h-4 w-4 shrink-0" />
<span>{error}</span>
</div>
) : hasLinkedRepos === false && !isLoading ? (
<div className="rounded-lg border border-dashed border-[color:var(--border)] bg-[color:var(--surface-muted)] px-4 py-6 text-center">
<div className="mx-auto mb-3 flex h-10 w-10 items-center justify-center rounded-full border border-[color:var(--border)] bg-[color:var(--surface)] text-muted">
<GitBranch className="h-5 w-5" />
</div>
<p className="text-sm font-medium text-strong">
No repositories linked
</p>
<p className="mt-1 text-sm text-muted">
Link a Git Project repository to this board to see its open issues
here.
</p>
</div>
) : (
<ForgejoIssuesTable
issues={issues}
repositories={repositories}
isLoading={isLoading}
onRefresh={handleRefresh}
/>
)}
</section>
);
}

View File

@ -87,7 +87,7 @@ export function DashboardSidebar() {
</Link> </Link>
<Link <Link
href="/activity" href="/activity"
className={navItemClass(pathname.startsWith("/activity"))} className={navItemClass(pathname === "/activity")}
> >
<Activity className="h-4 w-4" /> <Activity className="h-4 w-4" />
Live feed Live feed
@ -102,42 +102,42 @@ export function DashboardSidebar() {
<div className="mt-1 space-y-1"> <div className="mt-1 space-y-1">
<Link <Link
href="/board-groups" href="/board-groups"
className={navItemClass(pathname.startsWith("/board-groups"))} className={navItemClass(pathname === "/board-groups")}
> >
<Folder className="h-4 w-4" /> <Folder className="h-4 w-4" />
Board groups Board groups
</Link> </Link>
<Link <Link
href="/boards" href="/boards"
className={navItemClass(pathname.startsWith("/boards"))} className={navItemClass(pathname === "/boards")}
> >
<LayoutGrid className="h-4 w-4" /> <LayoutGrid className="h-4 w-4" />
Boards Boards
</Link> </Link>
<Link <Link
href="/git-projects" href="/git-projects"
className={navItemClass(pathname.startsWith("/git-projects"))} className={navItemClass(pathname === "/git-projects")}
> >
<FolderGit className="h-4 w-4" /> <FolderGit className="h-4 w-4" />
Git Projects Git Projects
</Link> </Link>
<Link <Link
href="/git-projects/issues" href="/git-projects/issues"
className={navItemClass(pathname.startsWith("/git-projects/issues"))} className={navItemClass(pathname === "/git-projects/issues")}
> >
<CircleDot className="h-4 w-4" /> <CircleDot className="h-4 w-4" />
Issues Issues
</Link> </Link>
<Link <Link
href="/tags" href="/tags"
className={navItemClass(pathname.startsWith("/tags"))} className={navItemClass(pathname === "/tags")}
> >
<Tags className="h-4 w-4" /> <Tags className="h-4 w-4" />
Tags Tags
</Link> </Link>
<Link <Link
href="/approvals" href="/approvals"
className={navItemClass(pathname.startsWith("/approvals"))} className={navItemClass(pathname === "/approvals")}
> >
<CheckCircle2 className="h-4 w-4" /> <CheckCircle2 className="h-4 w-4" />
Approvals Approvals
@ -145,9 +145,7 @@ export function DashboardSidebar() {
{isAdmin ? ( {isAdmin ? (
<Link <Link
href="/custom-fields" href="/custom-fields"
className={navItemClass( className={navItemClass(pathname === "/custom-fields")}
pathname.startsWith("/custom-fields"),
)}
> >
<Settings className="h-4 w-4" /> <Settings className="h-4 w-4" />
Custom fields Custom fields
@ -165,19 +163,14 @@ export function DashboardSidebar() {
<div className="mt-1 space-y-1"> <div className="mt-1 space-y-1">
<Link <Link
href="/skills/marketplace" href="/skills/marketplace"
className={navItemClass( className={navItemClass(pathname === "/skills/marketplace")}
pathname === "/skills" ||
pathname.startsWith("/skills/marketplace"),
)}
> >
<Store className="h-4 w-4" /> <Store className="h-4 w-4" />
Marketplace Marketplace
</Link> </Link>
<Link <Link
href="/skills/packs" href="/skills/packs"
className={navItemClass( className={navItemClass(pathname === "/skills/packs")}
pathname.startsWith("/skills/packs"),
)}
> >
<Boxes className="h-4 w-4" /> <Boxes className="h-4 w-4" />
Packs Packs
@ -194,14 +187,14 @@ export function DashboardSidebar() {
<div className="mt-1 space-y-1"> <div className="mt-1 space-y-1">
<Link <Link
href="/organization" href="/organization"
className={navItemClass(pathname.startsWith("/organization"))} className={navItemClass(pathname === "/organization")}
> >
<Building2 className="h-4 w-4" /> <Building2 className="h-4 w-4" />
Organization Organization
</Link> </Link>
<Link <Link
href="/settings" href="/settings"
className={navItemClass(pathname.startsWith("/settings"))} className={navItemClass(pathname === "/settings")}
> >
<Settings className="h-4 w-4" /> <Settings className="h-4 w-4" />
Settings Settings
@ -209,7 +202,7 @@ export function DashboardSidebar() {
{isAdmin ? ( {isAdmin ? (
<Link <Link
href="/gateways" href="/gateways"
className={navItemClass(pathname.startsWith("/gateways"))} className={navItemClass(pathname === "/gateways")}
> >
<Network className="h-4 w-4" /> <Network className="h-4 w-4" />
Gateways Gateways
@ -218,7 +211,7 @@ export function DashboardSidebar() {
{isAdmin ? ( {isAdmin ? (
<Link <Link
href="/agents" href="/agents"
className={navItemClass(pathname.startsWith("/agents"))} className={navItemClass(pathname === "/agents")}
> >
<Bot className="h-4 w-4" /> <Bot className="h-4 w-4" />
Agents Agents

View File

@ -337,6 +337,7 @@ export interface ForgejoIssueListResponse {
// Forgejo Issue API // Forgejo Issue API
export async function getForgejoIssues(params?: { export async function getForgejoIssues(params?: {
repository_id?: string; repository_id?: string;
board_id?: string;
state?: string; state?: string;
search?: string; search?: string;
page?: number; page?: number;
@ -345,6 +346,7 @@ export async function getForgejoIssues(params?: {
const searchParams = new URLSearchParams(); const searchParams = new URLSearchParams();
if (params?.repository_id) if (params?.repository_id)
searchParams.set("repository_id", 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?.state) searchParams.set("state", params.state);
if (params?.search) searchParams.set("search", params.search); if (params?.search) searchParams.set("search", params.search);
if (params?.page) searchParams.set("page", params.page.toString()); if (params?.page) searchParams.set("page", params.page.toString());