diff --git a/frontend/components/playbooks/PlaybookEditor.tsx b/frontend/components/playbooks/PlaybookEditor.tsx index 7748dd6..bb1296c 100644 --- a/frontend/components/playbooks/PlaybookEditor.tsx +++ b/frontend/components/playbooks/PlaybookEditor.tsx @@ -9,7 +9,8 @@ import { useState, } from "react"; import { useSearchParams } from "next/navigation"; -import { api, ApiError, type Capabilities, type SyncState, type ToolEntry } from "@/lib/api"; +import { api, ApiError, type Capabilities, type LinkedRepo, type SyncState, type ToolEntry } from "@/lib/api"; +import { expandRepoAliases } from "@/lib/mcps"; import { useDialog } from "@/lib/dialog"; import { EditorChatDrawer } from "@/components/playbooks/EditorChatDrawer"; import { ProposalCard, type ProposalDraftPayload } from "@/components/playbooks/ProposalCard"; @@ -83,6 +84,11 @@ export function PlaybookEditor({ id, onBack, onMutated, onOpenPlaybook }: Props) // "latest" instead of the freshly-created commit. const [refetchKey, setRefetchKey] = useState(0); const [tools, setTools] = useState([]); + const [linkedRepos, setLinkedRepos] = useState([]); + // The picker resolves suggested_calls against per-repo server names + // (triagent-git-), so the logical catalog is expanded with + // one entry per linked repo before it reaches the node editor. + const catalog = useMemo(() => expandRepoAliases(tools, linkedRepos), [tools, linkedRepos]); const [capabilities, setCapabilities] = useState(null); const [selectedNode, setSelectedNode] = useState(null); // Right-aside view: "node" is the per-node editor; "tags" surfaces @@ -555,6 +561,10 @@ export function PlaybookEditor({ id, onBack, onMutated, onOpenPlaybook }: Props) // on capabilities.gh.authenticated && capabilities.repoPath.valid. useEffect(() => { api.listTools().then(setTools).catch(() => setTools([])); + api + .listRepos() + .then((r) => setLinkedRepos([...r.defaults, ...r.user])) + .catch(() => setLinkedRepos([])); api .listPlaybookTypes() .then((types) => @@ -1390,7 +1400,7 @@ export function PlaybookEditor({ id, onBack, onMutated, onOpenPlaybook }: Props) node={node} allNodeIds={allNodeIds} allPlaybookIds={allPlaybookIds.filter((p) => p !== draft.id)} - catalog={tools} + catalog={catalog} readOnly={readOnly} onChange={(next) => { if (!selectedNode) return; diff --git a/frontend/components/playbooks/ToolPicker.tsx b/frontend/components/playbooks/ToolPicker.tsx index 7f486f2..2b292f7 100644 --- a/frontend/components/playbooks/ToolPicker.tsx +++ b/frontend/components/playbooks/ToolPicker.tsx @@ -74,7 +74,7 @@ export function ToolPicker({ value, onChange, catalog, disabled }: Props) { {value && !known && ( unknown diff --git a/frontend/lib/mcps.test.ts b/frontend/lib/mcps.test.ts index 7a4e496..310f1c1 100644 --- a/frontend/lib/mcps.test.ts +++ b/frontend/lib/mcps.test.ts @@ -1,6 +1,6 @@ import { describe, it, expect } from "vitest"; -import type { Investigation } from "@/lib/api"; -import { activeMCPs, chipClasses } from "@/lib/mcps"; +import type { Investigation, LinkedRepo, ToolEntry } from "@/lib/api"; +import { activeMCPs, chipClasses, expandRepoAliases } from "@/lib/mcps"; function makeInv(overrides: Partial = {}): Investigation { return { @@ -47,3 +47,46 @@ describe("activeMCPs cloud sources", () => { expect(chipClasses("cloud")).not.toBe(chipClasses("docs")); }); }); + +describe("expandRepoAliases", () => { + const gitTool: ToolEntry = { + server: "triagent-git", + name: "latest_tags", + description: "List tags", + inputs: [{ name: "limit", type: "integer" }], + }; + const k8sTool: ToolEntry = { server: "triagent-k8s", name: "get_logs", description: "Logs" }; + + it("adds one copy of every git tool per linked repo alias, keeping the logical entry", () => { + const out = expandRepoAliases( + [k8sTool, gitTool], + [ + { owner: "camunda", name: "camunda-operator" }, + { owner: "camunda", name: "saas-argocd-apps", alias: "argocd" }, + ], + ); + expect(out.map((t) => `${t.server}/${t.name}`)).toEqual([ + "triagent-k8s/get_logs", + "triagent-git/latest_tags", + "triagent-git-camunda-operator/latest_tags", + "triagent-git-argocd/latest_tags", + ]); + // The aliased copy carries the same inputs so the editor can offer args. + expect(out[2].inputs).toEqual(gitTool.inputs); + }); + + it("dedupes repos that resolve to the same alias", () => { + const out = expandRepoAliases( + [gitTool], + [ + { owner: "camunda", name: "zeebe" }, + { owner: "fork", name: "zeebe" }, + ], + ); + expect(out.map((t) => t.server)).toEqual(["triagent-git", "triagent-git-zeebe"]); + }); + + it("returns the catalog untouched when no repos are linked", () => { + expect(expandRepoAliases([k8sTool, gitTool], [])).toEqual([k8sTool, gitTool]); + }); +}); diff --git a/frontend/lib/mcps.ts b/frontend/lib/mcps.ts index d3956a5..2c3f80c 100644 --- a/frontend/lib/mcps.ts +++ b/frontend/lib/mcps.ts @@ -2,7 +2,7 @@ // already on the Investigation DTO (promEnabled / docsPrefix / linkedRepos); // this module just turns it into a renderable list and sorts the entries. -import type { Investigation, MCPCallStats, MCPProbeResult, ToolEntry } from "./api"; +import type { Investigation, LinkedRepo, MCPCallStats, MCPProbeResult, ToolEntry } from "./api"; export type MCPCategory = "core" | "metrics" | "docs" | "git" | "wiki" | "slack" | "incidentio" | "cloud"; @@ -137,6 +137,26 @@ export function logicalServer(wireAlias: string): string { return wireAlias; } +// expandRepoAliases turns the launcher's logical git tool set into the +// per-repo entries a playbook actually addresses. The catalog lists each +// git tool once under "triagent-git", but every linked repo runs its own +// server named triagent-git-, and that aliased form is what +// suggested_calls carry. The logical entry stays so system playbooks +// written against the template form still resolve; one copy per alias +// follows in repo order, deduped when two repos resolve to one alias. +export function expandRepoAliases(tools: ToolEntry[], repos: LinkedRepo[]): ToolEntry[] { + const aliases = new Set(repos.map((r) => r.alias || r.name)); + if (aliases.size === 0) return tools; + const gitTools = tools.filter((t) => t.server === "triagent-git"); + const out = [...tools]; + for (const alias of aliases) { + for (const t of gitTools) { + out.push({ ...t, server: `triagent-git-${alias}` }); + } + } + return out; +} + // groupTools buckets the flat tool list by server alias. Returns a Map // so callers can iterate insertion-order-preserving. export function groupTools(tools: ToolEntry[]): Map {