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,
|
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)
|
||||||
|
|
|
||||||
|
|
@ -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}
|
||||||
|
|
|
||||||
|
|
@ -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>
|
||||||
<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
|
||||||
|
|
|
||||||
|
|
@ -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());
|
||||||
|
|
|
||||||
Loading…
Reference in New Issue