Pipeline/frontend/src/components/git/AssignIssueAgentDialog.tsx

468 lines
16 KiB
TypeScript

"use client";
import Link from "next/link";
import { useEffect, useState } from "react";
import { ExternalLink } from "lucide-react";
import {
useListAgentsApiV1AgentsGet,
type listAgentsApiV1AgentsGetResponse,
} from "@/api/generated/agents/agents";
import {
useListBoardsApiV1BoardsGet,
type listBoardsApiV1BoardsGetResponse,
} from "@/api/generated/boards/boards";
import { ApiError } from "@/api/mutator";
import {
Dialog,
DialogContent,
DialogDescription,
DialogFooter,
DialogHeader,
DialogTitle,
} from "@/components/ui/dialog";
import { Button } from "@/components/ui/button";
import { Textarea } from "@/components/ui/textarea";
import type { ForgejoIssue, AssignIssueAgentResponse } from "@/lib/api-forgejo";
import {
assignIssueToAgent,
getLinkedBoardsForRepository,
linkBoardForgejoRepository,
} from "@/lib/api-forgejo";
type LinkedBoard = { id: string; name: string };
type AssignIssueAgentDialogProps = {
issue: ForgejoIssue | null;
repositoryName: string;
open: boolean;
onOpenChange: (open: boolean) => void;
onSuccess: (result: AssignIssueAgentResponse) => void;
};
function buildDefaultAgentPrompt(issue: ForgejoIssue, repositoryName: string) {
const issueBody = (issue.body ?? issue.body_preview ?? "").trim();
const lines = [
`Work on ${repositoryName}#${issue.forgejo_issue_number}: ${issue.title}`,
"",
"Use this Forgejo issue as the source of truth.",
`Issue URL: ${issue.html_url}`,
];
if (issueBody) {
lines.push("", "Issue details:", issueBody);
}
lines.push(
"",
"Expected approach:",
"- Read the relevant Pipeline code before editing.",
"- Keep the work scoped to this issue.",
"- Run targeted validation and report what passed.",
"- Post progress, blockers, and handoff notes back to the Pipeline task.",
);
return lines.join("\n");
}
export function AssignIssueAgentDialog({
issue,
repositoryName,
open,
onOpenChange,
onSuccess,
}: AssignIssueAgentDialogProps) {
const [boardId, setBoardId] = useState("");
const [agentId, setAgentId] = useState("");
const [priority, setPriority] = useState("medium");
const [instructions, setInstructions] = useState("");
const [startImmediately, setStartImmediately] = useState(true);
const [isSubmitting, setIsSubmitting] = useState(false);
const [error, setError] = useState<string | null>(null);
const [linkedBoards, setLinkedBoards] = useState<LinkedBoard[]>([]);
const [boardsLoading, setBoardsLoading] = useState(false);
const [linkedBoardsLoaded, setLinkedBoardsLoaded] = useState(false);
const [linkBoardId, setLinkBoardId] = useState("");
const [isLinkingRepository, setIsLinkingRepository] = useState(false);
// Fetch only boards linked to this issue's repository — prevents picking a
// board that the backend will reject with "not linked to any board".
useEffect(() => {
if (!open || !issue) return;
let cancelled = false;
setBoardsLoading(true);
setLinkedBoardsLoaded(false);
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);
setLinkedBoardsLoaded(true);
}
});
return () => { cancelled = true; };
}, [open, issue]);
const agentsQuery = useListAgentsApiV1AgentsGet<
listAgentsApiV1AgentsGetResponse,
ApiError
>(
{ board_id: boardId || undefined, limit: 200 },
{ query: { enabled: open && !!boardId, refetchOnMount: "always" } },
);
const boardsQuery = useListBoardsApiV1BoardsGet<
listBoardsApiV1BoardsGetResponse,
ApiError
>(
{ limit: 200 },
{
query: {
enabled:
open &&
linkedBoardsLoaded &&
!boardsLoading &&
linkedBoards.length === 0,
refetchOnMount: "always",
},
},
);
const agents =
agentsQuery.data?.status === 200
? (agentsQuery.data.data.items ?? [])
: [];
const allBoards =
boardsQuery.data?.status === 200
? (boardsQuery.data.data.items ?? [])
: [];
useEffect(() => {
if (!open) {
setBoardId("");
setAgentId("");
setPriority("medium");
setInstructions("");
setStartImmediately(true);
setError(null);
setLinkedBoardsLoaded(false);
setLinkBoardId("");
setIsLinkingRepository(false);
}
}, [open]);
useEffect(() => {
if (!open || !issue) return;
setInstructions(buildDefaultAgentPrompt(issue, repositoryName));
}, [open, issue, repositoryName]);
useEffect(() => {
setAgentId("");
}, [boardId]);
if (!issue) return null;
const noLinkedBoards =
linkedBoardsLoaded && !boardsLoading && linkedBoards.length === 0;
const prompt = instructions.trim();
const handleSubmit = async () => {
if (!boardId || !agentId || !prompt) return;
setIsSubmitting(true);
setError(null);
try {
const result = await assignIssueToAgent(issue.id, {
board_id: boardId,
assigned_agent_id: agentId,
priority,
start_immediately: startImmediately,
status: startImmediately ? "in_progress" : "inbox",
instructions: prompt,
});
onSuccess(result);
onOpenChange(false);
} catch (err) {
setError(err instanceof Error ? err.message : "Failed to assign agent");
} finally {
setIsSubmitting(false);
}
};
const handleLinkRepositoryToBoard = async () => {
if (!issue || !linkBoardId) return;
setIsLinkingRepository(true);
setError(null);
try {
await linkBoardForgejoRepository(linkBoardId, issue.repository_id);
const linkedBoard = allBoards.find((board) => board.id === linkBoardId);
const nextBoard = {
id: linkBoardId,
name: linkedBoard?.name ?? "Selected board",
};
setLinkedBoards([nextBoard]);
setBoardId(nextBoard.id);
setLinkBoardId("");
} catch (err) {
setError(
err instanceof Error
? err.message
: "Failed to link repository to board",
);
} finally {
setIsLinkingRepository(false);
}
};
const toggleSwitch = (
value: boolean,
setter: (v: boolean) => void,
disabled: boolean,
) => (
<button
type="button"
role="switch"
aria-checked={value}
onClick={() => !disabled && setter(!value)}
disabled={disabled}
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)]"
} ${disabled ? "cursor-not-allowed opacity-60" : "cursor-pointer"}`}
>
<span
className={`inline-block h-5 w-5 rounded-full bg-[color:var(--surface)] shadow-sm transition ${
value ? "translate-x-5" : "translate-x-0.5"
}`}
/>
</button>
);
return (
<Dialog
open={open}
onOpenChange={(next) => {
if (!isSubmitting) onOpenChange(next);
}}
>
<DialogContent className="max-w-lg">
<DialogHeader>
<DialogTitle>Assign to agent</DialogTitle>
<DialogDescription>
Create a Pipeline task for{" "}
<span className="font-mono font-semibold text-strong">
{repositoryName}#{issue.forgejo_issue_number}
</span>{" "}
and assign an agent to work on it.
</DialogDescription>
</DialogHeader>
<div className="space-y-4">
<div className="rounded-xl border border-[color:var(--border)] bg-[color:var(--surface-muted)] px-3 py-2">
<p className="truncate text-sm font-medium text-strong">
{issue.title}
</p>
</div>
{noLinkedBoards ? (
<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">
<p className="font-semibold text-[color:var(--warning,#ca8a04)]">
Repository not linked to any board
</p>
<p className="mt-1 text-muted">
Before you can assign an agent, link{" "}
<span className="font-medium text-strong">{repositoryName}</span>{" "}
to the board that should own this task. Open the target board and
use its Git Project repositories panel, or manage tracked
repositories first.
</p>
<div className="mt-4 space-y-2">
<label className="text-sm font-medium text-strong">
Link to board <span className="text-[color:var(--danger)]">*</span>
</label>
<select
value={linkBoardId}
onChange={(e) => setLinkBoardId(e.target.value)}
disabled={boardsQuery.isLoading || isLinkingRepository}
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="">
{boardsQuery.isLoading ? "Loading boards..." : "Select a board..."}
</option>
{allBoards.map((board) => (
<option key={board.id} value={board.id}>
{board.name}
</option>
))}
</select>
{boardsQuery.isError ? (
<p className="text-xs text-[color:var(--danger)]">
Unable to load boards. You can still manage repositories
from Git Projects.
</p>
) : null}
</div>
<div className="mt-3 flex flex-wrap gap-2">
<Button
size="sm"
onClick={handleLinkRepositoryToBoard}
disabled={
!linkBoardId || boardsQuery.isLoading || isLinkingRepository
}
>
{isLinkingRepository ? "Linking..." : "Link and continue"}
</Button>
<Link
href="/git-projects/repositories"
className="inline-flex h-9 items-center gap-2 rounded-xl border border-[color:var(--border)] bg-[color:var(--surface)] px-3 text-xs font-semibold text-strong transition hover:border-[color:var(--accent)] hover:text-[color:var(--accent)]"
>
Manage Git repositories
<ExternalLink className="h-3.5 w-3.5" />
</Link>
</div>
</div>
) : (
<>
<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-[color:var(--danger)]">*</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="">Select an agent</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>
{boardId && !agentsQuery.isLoading && agents.length === 0 ? (
<p className="text-xs text-muted">
No agents are available on this board yet.
</p>
) : null}
</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">
Agent prompt <span className="text-[color:var(--danger)]">*</span>
</label>
<Textarea
value={instructions}
onChange={(e) => setInstructions(e.target.value)}
disabled={isSubmitting}
rows={9}
placeholder="Prompt the agent with the goal, context, and expected validation…"
className="resize-y text-sm"
/>
<p className="text-xs text-muted">
This prompt is copied into the Pipeline task and sent with the
assignment notification.
</p>
</div>
<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>{" "}
when assigned.
</span>
</span>
</div>
</>
)}
</div>
{error ? (
<div className="rounded-lg border border-[color:rgba(248,113,113,0.35)] bg-[color:rgba(248,113,113,0.08)] p-3 text-xs text-[color:var(--danger)]">
{error}
</div>
) : null}
<DialogFooter>
<Button
variant="outline"
onClick={() => onOpenChange(false)}
disabled={isSubmitting}
>
Cancel
</Button>
{!noLinkedBoards && (
<Button
onClick={handleSubmit}
disabled={isSubmitting || !boardId || !agentId || !prompt}
>
{isSubmitting ? "Assigning…" : "Assign Agent"}
</Button>
)}
</DialogFooter>
</DialogContent>
</Dialog>
);
}