feat(assign-issue): implement linked boards fetching for repository in AssignIssueAgentDialog

This commit is contained in:
null 2026-05-22 03:57:13 -05:00
parent ce8c58e953
commit ff545bff34
3 changed files with 205 additions and 119 deletions

View File

@ -11,8 +11,11 @@ from sqlmodel import select
from app.api.deps import require_org_member from app.api.deps import require_org_member
from app.db import crud from app.db import crud
from app.db.session import get_session from app.db.session import get_session
from app.models.boards import Board
from app.models.board_repository_links import BoardRepositoryLink
from app.models.forgejo_connections import ForgejoConnection from app.models.forgejo_connections import ForgejoConnection
from app.models.forgejo_repositories import ForgejoRepository from app.models.forgejo_repositories import ForgejoRepository
from app.schemas.boards import BoardRead
from app.schemas.common import OkResponse from app.schemas.common import OkResponse
from app.schemas.forgejo_repositories import ( from app.schemas.forgejo_repositories import (
ForgejoRepositoryCreate, ForgejoRepositoryCreate,
@ -348,3 +351,38 @@ def _mask_repository(repository: ForgejoRepository, connection: ForgejoConnectio
"created_at": repository.created_at, "created_at": repository.created_at,
"updated_at": repository.updated_at, "updated_at": repository.updated_at,
} }
@router.get("/{repository_id}/boards", response_model=list[BoardRead])
async def list_boards_for_repository(
repository_id: UUID,
session: AsyncSession = SESSION_DEP,
ctx: OrganizationContext = ORG_MEMBER_DEP,
) -> list[BoardRead]:
"""Return all boards that have this repository linked to them."""
repository = await crud.get_by_id(session, ForgejoRepository, repository_id)
if repository is None or repository.organization_id != ctx.organization.id:
raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="Repository not found")
links = (
await session.exec(
select(BoardRepositoryLink).where(
BoardRepositoryLink.organization_id == ctx.organization.id,
BoardRepositoryLink.repository_id == repository_id,
)
)
).all()
if not links:
return []
board_ids = [link.board_id for link in links]
boards = (
await session.exec(
select(Board).where(
Board.id.in_(board_ids),
Board.organization_id == ctx.organization.id,
)
)
).all()
return [BoardRead.model_validate(b) for b in boards]

View File

@ -2,10 +2,6 @@
import { useEffect, useState } from "react"; import { useEffect, useState } from "react";
import {
useListBoardsApiV1BoardsGet,
type listBoardsApiV1BoardsGetResponse,
} from "@/api/generated/boards/boards";
import { import {
useListAgentsApiV1AgentsGet, useListAgentsApiV1AgentsGet,
type listAgentsApiV1AgentsGetResponse, type listAgentsApiV1AgentsGetResponse,
@ -22,7 +18,9 @@ import {
import { Button } from "@/components/ui/button"; import { Button } from "@/components/ui/button";
import { Textarea } from "@/components/ui/textarea"; import { Textarea } from "@/components/ui/textarea";
import type { ForgejoIssue, AssignIssueAgentResponse } from "@/lib/api-forgejo"; import type { ForgejoIssue, AssignIssueAgentResponse } from "@/lib/api-forgejo";
import { assignIssueToAgent } from "@/lib/api-forgejo"; import { assignIssueToAgent, getLinkedBoardsForRepository } from "@/lib/api-forgejo";
type LinkedBoard = { id: string; name: string };
type AssignIssueAgentDialogProps = { type AssignIssueAgentDialogProps = {
issue: ForgejoIssue | null; issue: ForgejoIssue | null;
@ -46,11 +44,31 @@ export function AssignIssueAgentDialog({
const [startImmediately, setStartImmediately] = useState(true); const [startImmediately, setStartImmediately] = useState(true);
const [isSubmitting, setIsSubmitting] = useState(false); const [isSubmitting, setIsSubmitting] = useState(false);
const [error, setError] = useState<string | null>(null); const [error, setError] = useState<string | null>(null);
const [linkedBoards, setLinkedBoards] = useState<LinkedBoard[]>([]);
const [boardsLoading, setBoardsLoading] = useState(false);
const boardsQuery = useListBoardsApiV1BoardsGet< // Fetch only boards linked to this issue's repository — prevents picking a
listBoardsApiV1BoardsGetResponse, // board that the backend will reject with "not linked to any board".
ApiError useEffect(() => {
>(undefined, { query: { enabled: open, refetchOnMount: "always" } }); if (!open || !issue) return;
let cancelled = false;
setBoardsLoading(true);
setLinkedBoards([]);
setBoardId("");
getLinkedBoardsForRepository(issue.repository_id)
.then((boards) => {
if (cancelled) return;
setLinkedBoards(boards);
if (boards.length === 1) setBoardId(boards[0].id);
})
.catch(() => {
if (!cancelled) setLinkedBoards([]);
})
.finally(() => {
if (!cancelled) setBoardsLoading(false);
});
return () => { cancelled = true; };
}, [open, issue]);
const agentsQuery = useListAgentsApiV1AgentsGet< const agentsQuery = useListAgentsApiV1AgentsGet<
listAgentsApiV1AgentsGetResponse, listAgentsApiV1AgentsGetResponse,
@ -60,10 +78,6 @@ export function AssignIssueAgentDialog({
{ query: { enabled: open && !!boardId, refetchOnMount: "always" } }, { query: { enabled: open && !!boardId, refetchOnMount: "always" } },
); );
const boards =
boardsQuery.data?.status === 200
? (boardsQuery.data.data.items ?? [])
: [];
const agents = const agents =
agentsQuery.data?.status === 200 agentsQuery.data?.status === 200
? (agentsQuery.data.data.items ?? []) ? (agentsQuery.data.data.items ?? [])
@ -80,18 +94,14 @@ export function AssignIssueAgentDialog({
} }
}, [open]); }, [open]);
useEffect(() => {
if (boards.length > 0 && !boardId) {
setBoardId(boards[0].id);
}
}, [boards, boardId]);
useEffect(() => { useEffect(() => {
setAgentId(""); setAgentId("");
}, [boardId]); }, [boardId]);
if (!issue) return null; if (!issue) return null;
const noLinkedBoards = !boardsLoading && linkedBoards.length === 0;
const handleSubmit = async () => { const handleSubmit = async () => {
if (!boardId) return; if (!boardId) return;
setIsSubmitting(true); setIsSubmitting(true);
@ -126,7 +136,9 @@ export function AssignIssueAgentDialog({
onClick={() => !disabled && setter(!value)} onClick={() => !disabled && setter(!value)}
disabled={disabled} disabled={disabled}
className={`mt-0.5 inline-flex h-6 w-11 shrink-0 items-center rounded-full border transition ${ className={`mt-0.5 inline-flex h-6 w-11 shrink-0 items-center rounded-full border transition ${
value ? "border-emerald-600 bg-emerald-600" : "border-[color:var(--border)] bg-[color:var(--surface-muted)]" value
? "border-emerald-600 bg-emerald-600"
: "border-[color:var(--border)] bg-[color:var(--surface-muted)]"
} ${disabled ? "cursor-not-allowed opacity-60" : "cursor-pointer"}`} } ${disabled ? "cursor-not-allowed opacity-60" : "cursor-pointer"}`}
> >
<span <span
@ -163,101 +175,123 @@ export function AssignIssueAgentDialog({
</p> </p>
</div> </div>
<div className="space-y-1.5"> {noLinkedBoards ? (
<label className="text-sm font-medium text-strong"> <div className="rounded-xl border border-[color:rgba(234,179,8,0.4)] bg-[color:rgba(234,179,8,0.08)] p-4 text-sm">
Board <span className="text-[color:var(--danger)]">*</span> <p className="font-semibold text-[color:var(--warning,#ca8a04)]">
</label> Repository not linked to any board
<select </p>
value={boardId} <p className="mt-1 text-muted">
onChange={(e) => setBoardId(e.target.value)} Before you can assign an agent, link{" "}
disabled={isSubmitting || boardsQuery.isLoading} <span className="font-medium text-strong">{repositoryName}</span>{" "}
className="w-full rounded-xl border border-[color:var(--border)] bg-[color:var(--surface)] px-3 py-2 text-sm text-strong focus:outline-none focus:ring-2 focus:ring-[color:var(--accent)]" to a board in{" "}
> <span className="font-medium text-strong">
{boardsQuery.isLoading ? ( Board Settings Git Repositories
<option>Loading boards</option>
) : boards.length === 0 ? (
<option>No boards available</option>
) : (
boards.map((b) => (
<option key={b.id} value={b.id}>
{b.name}
</option>
))
)}
</select>
</div>
<div className="space-y-1.5">
<label className="text-sm font-medium text-strong">
Agent{" "}
<span className="text-xs font-normal text-muted">
(optional leaves task unassigned if blank)
</span>
</label>
<select
value={agentId}
onChange={(e) => setAgentId(e.target.value)}
disabled={isSubmitting || !boardId || agentsQuery.isLoading}
className="w-full rounded-xl border border-[color:var(--border)] bg-[color:var(--surface)] px-3 py-2 text-sm text-strong focus:outline-none focus:ring-2 focus:ring-[color:var(--accent)]"
>
<option value="">Unassigned</option>
{agentsQuery.isLoading ? (
<option disabled>Loading agents</option>
) : (
agents.map((a) => (
<option key={a.id} value={a.id}>
{a.name}
{a.is_board_lead ? " (lead)" : ""}
</option>
))
)}
</select>
</div>
<div className="space-y-1.5">
<label className="text-sm font-medium text-strong">Priority</label>
<select
value={priority}
onChange={(e) => setPriority(e.target.value)}
disabled={isSubmitting}
className="w-full rounded-xl border border-[color:var(--border)] bg-[color:var(--surface)] px-3 py-2 text-sm text-strong focus:outline-none focus:ring-2 focus:ring-[color:var(--accent)]"
>
<option value="low">Low</option>
<option value="medium">Medium</option>
<option value="high">High</option>
<option value="critical">Critical</option>
</select>
</div>
<div className="space-y-1.5">
<label className="text-sm font-medium text-strong">
Instructions{" "}
<span className="text-xs font-normal text-muted">(optional)</span>
</label>
<Textarea
value={instructions}
onChange={(e) => setInstructions(e.target.value)}
disabled={isSubmitting}
rows={3}
placeholder="Additional context or instructions for the agent…"
className="resize-none text-sm"
/>
</div>
{agentId ? (
<div className="flex items-center gap-3">
{toggleSwitch(startImmediately, setStartImmediately, isSubmitting)}
<span className="space-y-0.5">
<span className="block text-sm font-medium text-strong">
Start immediately
</span> </span>
<span className="block text-xs text-muted"> .
Set task to <span className="font-mono">in_progress</span>{" "} </p>
on assign.
</span>
</span>
</div> </div>
) : null} ) : (
<>
<div className="space-y-1.5">
<label className="text-sm font-medium text-strong">
Board <span className="text-[color:var(--danger)]">*</span>
</label>
<select
value={boardId}
onChange={(e) => setBoardId(e.target.value)}
disabled={isSubmitting || boardsLoading}
className="w-full rounded-xl border border-[color:var(--border)] bg-[color:var(--surface)] px-3 py-2 text-sm text-strong focus:outline-none focus:ring-2 focus:ring-[color:var(--accent)]"
>
{boardsLoading ? (
<option>Loading boards</option>
) : (
<>
{linkedBoards.length > 1 && (
<option value="">Select a board</option>
)}
{linkedBoards.map((b) => (
<option key={b.id} value={b.id}>
{b.name}
</option>
))}
</>
)}
</select>
</div>
<div className="space-y-1.5">
<label className="text-sm font-medium text-strong">
Agent{" "}
<span className="text-xs font-normal text-muted">
(optional leaves task unassigned if blank)
</span>
</label>
<select
value={agentId}
onChange={(e) => setAgentId(e.target.value)}
disabled={isSubmitting || !boardId || agentsQuery.isLoading}
className="w-full rounded-xl border border-[color:var(--border)] bg-[color:var(--surface)] px-3 py-2 text-sm text-strong focus:outline-none focus:ring-2 focus:ring-[color:var(--accent)]"
>
<option value="">Unassigned</option>
{agentsQuery.isLoading ? (
<option disabled>Loading agents</option>
) : (
agents.map((a) => (
<option key={a.id} value={a.id}>
{a.name}
{a.is_board_lead ? " (lead)" : ""}
</option>
))
)}
</select>
</div>
<div className="space-y-1.5">
<label className="text-sm font-medium text-strong">Priority</label>
<select
value={priority}
onChange={(e) => setPriority(e.target.value)}
disabled={isSubmitting}
className="w-full rounded-xl border border-[color:var(--border)] bg-[color:var(--surface)] px-3 py-2 text-sm text-strong focus:outline-none focus:ring-2 focus:ring-[color:var(--accent)]"
>
<option value="low">Low</option>
<option value="medium">Medium</option>
<option value="high">High</option>
<option value="critical">Critical</option>
</select>
</div>
<div className="space-y-1.5">
<label className="text-sm font-medium text-strong">
Instructions{" "}
<span className="text-xs font-normal text-muted">(optional)</span>
</label>
<Textarea
value={instructions}
onChange={(e) => setInstructions(e.target.value)}
disabled={isSubmitting}
rows={3}
placeholder="Additional context or instructions for the agent…"
className="resize-none text-sm"
/>
</div>
{agentId ? (
<div className="flex items-center gap-3">
{toggleSwitch(startImmediately, setStartImmediately, isSubmitting)}
<span className="space-y-0.5">
<span className="block text-sm font-medium text-strong">
Start immediately
</span>
<span className="block text-xs text-muted">
Set task to{" "}
<span className="font-mono">in_progress</span> on assign.
</span>
</span>
</div>
) : null}
</>
)}
</div> </div>
{error ? ( {error ? (
@ -274,12 +308,18 @@ export function AssignIssueAgentDialog({
> >
Cancel Cancel
</Button> </Button>
<Button {!noLinkedBoards && (
onClick={handleSubmit} <Button
disabled={isSubmitting || !boardId} onClick={handleSubmit}
> disabled={isSubmitting || !boardId}
{isSubmitting ? "Assigning…" : agentId ? "Assign Agent" : "Create Task"} >
</Button> {isSubmitting
? "Assigning…"
: agentId
? "Assign Agent"
: "Create Task"}
</Button>
)}
</DialogFooter> </DialogFooter>
</DialogContent> </DialogContent>
</Dialog> </Dialog>

View File

@ -128,6 +128,14 @@ export async function deleteForgejoConnection(
} }
// Forgejo Repository API // Forgejo Repository API
export async function getLinkedBoardsForRepository(
repositoryId: string,
): Promise<{ id: string; name: string }[]> {
return fetchJson<{ id: string; name: string }[]>(
`/api/v1/forgejo/repositories/${repositoryId}/boards`,
);
}
export async function getForgejoRepositories(): Promise<ForgejoRepository[]> { export async function getForgejoRepositories(): Promise<ForgejoRepository[]> {
return fetchJson<ForgejoRepository[]>("/api/v1/forgejo/repositories"); return fetchJson<ForgejoRepository[]>("/api/v1/forgejo/repositories");
} }