import { eq, isNotNull } from "drizzle-orm"; import { z } from "zod"; import { projectRoots as projectRootsTable, projects as projectsTable, } from "../db/schema"; import { type MapUnassignedResult, mapUnassignedGroup, } from "../queries/classify-unassigned"; import { getUnassignedWork as queryUnassignedWork } from "../queries/unassigned"; import { defineCapability } from "./context"; /** Match the REST route caps: bound the unassigned scan so a huge backlog can't stall. */ const MAX_UNASSIGNED_EVENTS_HOSTED = 10_000; const MAX_UNASSIGNED_EVENTS_LOCAL = 50_000; /** Normalize a name/repo for fuzzy suggestion matching (drop punctuation, case). */ function normalizeName(s: string): string { return s.toLowerCase().replace(/[^a-z0-9]/g, ""); } /** * Suggest the best existing project for an unassigned group by normalized-name * overlap (e.g. group "bopen-ai" -> project "bopen"; "scribe" -> "scribe"). * Exported + pure so the match rule has one tested source of truth. */ export function suggestProjectForGroup( groupName: string, projects: readonly { id: string; name: string }[], ): { id: string; name: string } | null { const n = normalizeName(groupName); if (!n) return null; return ( projects.find((p) => { const pn = normalizeName(p.name); return ( pn.length > 0 && (n === pn || n.startsWith(pn) || pn.startsWith(n)) ); }) ?? null ); } export const getUnassignedWork = defineCapability({ name: "getUnassignedWork", description: "Break down UNASSIGNED work (timeline events not yet mapped to any project) into groups by git repo, chat project path, or code folder, each with an event count. Use when the user asks what work is unassigned/ungrouped, or wants to see which repos or folders still need a project. Each group has a `key` and `path` to pass to mapUnassignedWork.", requiredScope: "projects:read", inputSchema: z.object({}), execute: async (ctx) => { const work = await queryUnassignedWork(ctx.db, { deviceIds: ctx.deviceIds, maxEvents: ctx.organizationId ? MAX_UNASSIGNED_EVENTS_HOSTED : MAX_UNASSIGNED_EVENTS_LOCAL, canClassify: true, }); const groups = [ ...work.gitGroups, ...work.claudeGroups, ...work.fileGroups, ].map((g) => ({ key: g.key, name: g.displayName, path: g.path, source: g.source, count: g.count, })); return { totalUnassigned: work.totalUnassigned, limitReached: work.limitReached, groupCount: groups.length, groups, }; }, }); export const mapUnassignedWork = defineCapability({ name: "mapUnassignedWork", description: "Map a group of UNASSIGNED work (from getUnassignedWork) to a project — either an EXISTING project (pass projectId) or a NEW one (pass newProjectName). This assigns the group's matching events AND records the repo/folder->project mapping so future scans and syncs connect the same work automatically. Call getUnassignedWork first to get the group's `key` and `path` (and listProjects for an existing project's ID).", requiredScope: "projects:write", requiredPermission: "projects:edit", inputSchema: z.object({ groupKey: z .string() .describe( "The group's `key` from getUnassignedWork (e.g. git:github.com/org/repo)", ), path: z .string() .describe( "The group's `path` from getUnassignedWork (repo remote or folder path)", ), projectId: z .string() .optional() .describe( "Existing project ID (from listProjects) to map the work to. Omit when creating a new project.", ), newProjectName: z .string() .min(1) .max(100) .optional() .describe( "Create a new project with this name and map the work to it. Omit when mapping to an existing project.", ), }), execute: async (ctx, { groupKey, path, projectId, newProjectName }) => { if (!projectId && !newProjectName) { return { error: "Provide either projectId (map to an existing project) or newProjectName (create a new one).", }; } if (projectId && newProjectName) { return { error: "Provide only one of projectId or newProjectName, not both.", }; } const result = await mapUnassignedGroup(ctx.db, { groupKey, path, action: newProjectName ? "create" : "assign", projectId, projectName: newProjectName, organizationId: ctx.organizationId, deviceIds: ctx.deviceIds, // On the hub, mark it a human decision so a later device sync can't // overwrite the mapping. manuallyAssigned: !!ctx.organizationId, }); if ("error" in result) return result; const mapped = result as MapUnassignedResult; const subject = groupKey.startsWith("git:") ? "repo" : "path"; return { success: true, projectId: mapped.projectId, eventsMapped: mapped.eventsUpdated ?? 0, message: `Mapped ${mapped.eventsUpdated ?? 0} event(s) to the project. Future scans/syncs for this ${subject} will connect automatically.`, }; }, }); export const proposeMappings = defineCapability({ name: "proposeMappings", description: "Propose how to map UNASSIGNED work to projects: returns each unassigned group with a SUGGESTED existing project (best name/repo match, or null) plus the full project list, so the user can confirm or change the target and map it. Use when the user wants to organize or map unassigned work. In the chat this renders as an interactive mapping UI — a project dropdown + Map button per group.", requiredScope: "projects:read", inputSchema: z.object({}), execute: async (ctx) => { const work = await queryUnassignedWork(ctx.db, { deviceIds: ctx.deviceIds, maxEvents: ctx.organizationId ? MAX_UNASSIGNED_EVENTS_HOSTED : MAX_UNASSIGNED_EVENTS_LOCAL, canClassify: true, }); const projectRows = ctx.organizationId ? await ctx.db .select({ id: projectsTable.id, name: projectsTable.name }) .from(projectsTable) .where(eq(projectsTable.organizationId, ctx.organizationId)) : await ctx.db .select({ id: projectsTable.id, name: projectsTable.name }) .from(projectsTable); const groups = [ ...work.gitGroups, ...work.claudeGroups, ...work.fileGroups, ].map((g) => { const s = suggestProjectForGroup(g.displayName, projectRows); return { groupKey: g.key, path: g.path, name: g.displayName, source: g.source, count: g.count, suggestedProjectId: s?.id ?? null, suggestedProjectName: s?.name ?? null, }; }); return { totalUnassigned: work.totalUnassigned, limitReached: work.limitReached, groupCount: groups.length, projects: projectRows, groups, }; }, }); export const clearLocalMappings = defineCapability({ name: "clearLocalMappings", description: "Un-map ALL local project mappings: every scanned root is returned to Ungrouped (project_id -> NULL) so a user who accidentally grouped their roots can start over. This does NOT delete any project groups, invoices, or timeline events — nothing is destroyed, only the root->project assignments are removed. Local (desktop) only: it is not available on the hosted hub, where cloud project mappings are managed separately. Destructive to the user's grouping, so confirm with the user before calling.", requiredScope: "projects:write", requiredPermission: "projects:edit", inputSchema: z.object({}), execute: async (ctx) => { // Local-only. On the hub a bulk clear would need the cloud-aware "clear all // vs only-non-cloud-matching" choice (OPL-3020) which is not built yet, so // refuse rather than silently clearing an organization's mappings. if (ctx.organizationId) { return { error: "Clearing mappings is a local (desktop) operation. Cloud project mappings are managed in the hub, not cleared here.", }; } const assigned = await ctx.db .select({ id: projectRootsTable.id }) .from(projectRootsTable) .where(isNotNull(projectRootsTable.projectId)); const count = assigned.length; if (count === 0) { return { success: true, cleared: 0, message: "No local project mappings to clear.", }; } await ctx.db .update(projectRootsTable) .set({ projectId: null, manuallyAssigned: false }) .where(isNotNull(projectRootsTable.projectId)); return { success: true, cleared: count, message: `Cleared ${count} local project mapping(s). Roots returned to Ungrouped; no project groups were deleted.`, }; }, });