feat: Add Board-Level Linked Issues Panel
This commit is contained in:
parent
08ea822edf
commit
9fada7dd5c
|
|
@ -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)
|
||||
|
|
|
|||
|
|
@ -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() {
|
|||
</div>
|
||||
|
||||
{canRead && boardId ? (
|
||||
<div className="w-full">
|
||||
<div className="flex w-full flex-col gap-4">
|
||||
<BoardForgejoIssuesPanel boardId={boardId} />
|
||||
<BoardForgejoRepositoryLinks boardId={boardId} canWrite={canWrite} />
|
||||
</div>
|
||||
) : null}
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
);
|
||||
}
|
||||
|
|
@ -87,7 +87,7 @@ export function DashboardSidebar() {
|
|||
</Link>
|
||||
<Link
|
||||
href="/activity"
|
||||
className={navItemClass(pathname.startsWith("/activity"))}
|
||||
className={navItemClass(pathname === "/activity")}
|
||||
>
|
||||
<Activity className="h-4 w-4" />
|
||||
Live feed
|
||||
|
|
@ -102,42 +102,42 @@ export function DashboardSidebar() {
|
|||
<div className="mt-1 space-y-1">
|
||||
<Link
|
||||
href="/board-groups"
|
||||
className={navItemClass(pathname.startsWith("/board-groups"))}
|
||||
className={navItemClass(pathname === "/board-groups")}
|
||||
>
|
||||
<Folder className="h-4 w-4" />
|
||||
Board groups
|
||||
</Link>
|
||||
<Link
|
||||
href="/boards"
|
||||
className={navItemClass(pathname.startsWith("/boards"))}
|
||||
className={navItemClass(pathname === "/boards")}
|
||||
>
|
||||
<LayoutGrid className="h-4 w-4" />
|
||||
Boards
|
||||
</Link>
|
||||
<Link
|
||||
href="/git-projects"
|
||||
className={navItemClass(pathname.startsWith("/git-projects"))}
|
||||
className={navItemClass(pathname === "/git-projects")}
|
||||
>
|
||||
<FolderGit className="h-4 w-4" />
|
||||
Git Projects
|
||||
</Link>
|
||||
<Link
|
||||
href="/git-projects/issues"
|
||||
className={navItemClass(pathname.startsWith("/git-projects/issues"))}
|
||||
className={navItemClass(pathname === "/git-projects/issues")}
|
||||
>
|
||||
<CircleDot className="h-4 w-4" />
|
||||
Issues
|
||||
</Link>
|
||||
<Link
|
||||
href="/tags"
|
||||
className={navItemClass(pathname.startsWith("/tags"))}
|
||||
className={navItemClass(pathname === "/tags")}
|
||||
>
|
||||
<Tags className="h-4 w-4" />
|
||||
Tags
|
||||
</Link>
|
||||
<Link
|
||||
href="/approvals"
|
||||
className={navItemClass(pathname.startsWith("/approvals"))}
|
||||
className={navItemClass(pathname === "/approvals")}
|
||||
>
|
||||
<CheckCircle2 className="h-4 w-4" />
|
||||
Approvals
|
||||
|
|
@ -145,9 +145,7 @@ export function DashboardSidebar() {
|
|||
{isAdmin ? (
|
||||
<Link
|
||||
href="/custom-fields"
|
||||
className={navItemClass(
|
||||
pathname.startsWith("/custom-fields"),
|
||||
)}
|
||||
className={navItemClass(pathname === "/custom-fields")}
|
||||
>
|
||||
<Settings className="h-4 w-4" />
|
||||
Custom fields
|
||||
|
|
@ -165,19 +163,14 @@ export function DashboardSidebar() {
|
|||
<div className="mt-1 space-y-1">
|
||||
<Link
|
||||
href="/skills/marketplace"
|
||||
className={navItemClass(
|
||||
pathname === "/skills" ||
|
||||
pathname.startsWith("/skills/marketplace"),
|
||||
)}
|
||||
className={navItemClass(pathname === "/skills/marketplace")}
|
||||
>
|
||||
<Store className="h-4 w-4" />
|
||||
Marketplace
|
||||
</Link>
|
||||
<Link
|
||||
href="/skills/packs"
|
||||
className={navItemClass(
|
||||
pathname.startsWith("/skills/packs"),
|
||||
)}
|
||||
className={navItemClass(pathname === "/skills/packs")}
|
||||
>
|
||||
<Boxes className="h-4 w-4" />
|
||||
Packs
|
||||
|
|
@ -194,14 +187,14 @@ export function DashboardSidebar() {
|
|||
<div className="mt-1 space-y-1">
|
||||
<Link
|
||||
href="/organization"
|
||||
className={navItemClass(pathname.startsWith("/organization"))}
|
||||
className={navItemClass(pathname === "/organization")}
|
||||
>
|
||||
<Building2 className="h-4 w-4" />
|
||||
Organization
|
||||
</Link>
|
||||
<Link
|
||||
href="/settings"
|
||||
className={navItemClass(pathname.startsWith("/settings"))}
|
||||
className={navItemClass(pathname === "/settings")}
|
||||
>
|
||||
<Settings className="h-4 w-4" />
|
||||
Settings
|
||||
|
|
@ -209,7 +202,7 @@ export function DashboardSidebar() {
|
|||
{isAdmin ? (
|
||||
<Link
|
||||
href="/gateways"
|
||||
className={navItemClass(pathname.startsWith("/gateways"))}
|
||||
className={navItemClass(pathname === "/gateways")}
|
||||
>
|
||||
<Network className="h-4 w-4" />
|
||||
Gateways
|
||||
|
|
@ -218,7 +211,7 @@ export function DashboardSidebar() {
|
|||
{isAdmin ? (
|
||||
<Link
|
||||
href="/agents"
|
||||
className={navItemClass(pathname.startsWith("/agents"))}
|
||||
className={navItemClass(pathname === "/agents")}
|
||||
>
|
||||
<Bot className="h-4 w-4" />
|
||||
Agents
|
||||
|
|
|
|||
|
|
@ -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());
|
||||
|
|
|
|||
Loading…
Reference in New Issue