diff --git a/biome.json b/biome.json index 49164d5..6e069bf 100644 --- a/biome.json +++ b/biome.json @@ -9,7 +9,7 @@ "enabled": true }, "files": { - "includes": ["!worker-configuration.d.ts", "src/**/*"] + "includes": ["src/**/*"] }, "formatter": { "enabled": true, diff --git a/src/factory.ts b/src/factory.ts index 044fa85..64478c2 100644 --- a/src/factory.ts +++ b/src/factory.ts @@ -8,6 +8,8 @@ import registerListIssues from "./tools/linearListIssues"; import registerGetIssue from "./tools/linearGetIssue"; import registerListTeams from "./tools/linearListTeams"; import registerListStates from "./tools/linearListStates"; +import registerListLabels from "./tools/linearListLabels"; +import registerListUsers from "./tools/linearListUsers"; export function registerTools(server: McpServer, env: Env): void { registerCreate(server, env); @@ -19,6 +21,8 @@ export function registerTools(server: McpServer, env: Env): void { registerGetIssue(server, env); registerListTeams(server, env); registerListStates(server, env); + registerListLabels(server, env); + registerListUsers(server, env); } diff --git a/src/tools/linear.ts b/src/tools/linear.ts index 21a006e..6e380a3 100644 --- a/src/tools/linear.ts +++ b/src/tools/linear.ts @@ -48,10 +48,38 @@ export const GQL = { updatedAt: { gte: $updatedAfter, lte: $updatedBefore } createdAt: { gte: $createdAfter, lte: $createdBefore } } - ) { nodes { id identifier title state { id name } assignee { id name } createdAt updatedAt } } + ) { + nodes { + id + identifier + title + state { id name } + assignee { id name } + priority + project { id name } + labels { nodes { id name } } + createdAt + updatedAt + } + } }`, issueByIdentifier: ` - query ($id: String!) { issue(id: $id) { id identifier title description state { id name } assignee { id name email } team { id key name } createdAt updatedAt } }`, + query ($id: String!) { + issue(id: $id) { + id + identifier + title + description + state { id name } + assignee { id name email } + team { id key name } + priority + project { id name } + labels { nodes { id name } } + createdAt + updatedAt + } + }`, issueCreate: ` mutation ($input: IssueCreateInput!) { issueCreate(input: $input) { success issue { id identifier title } } @@ -70,6 +98,18 @@ export const GQL = { webhookDelete: `mutation ($id: String!) { webhookDelete(id: $id) { success } }`, teamIdByKey: `query ($key: String!) { teams(filter: { key: { eq: $key } }) { nodes { id } } }`, teamsList: `query { teams(first: 50) { nodes { id key name } } }`, + issueLabelsByTeam: ` + query ($teamId: ID) { + issueLabels(filter: { team: { id: { eq: $teamId } } }) { nodes { id name } } + }`, + usersList: ` + query { + users { nodes { id name email } } + }`, + projectsByTeam: ` + query ($teamId: ID) { + projects(filter: { team: { id: { eq: $teamId } } }) { nodes { id name } } + }`, }; export function isUuidLike(value: string): boolean { diff --git a/src/tools/linearComment.ts b/src/tools/linearComment.ts index a32ab14..5d6d95f 100644 --- a/src/tools/linearComment.ts +++ b/src/tools/linearComment.ts @@ -5,16 +5,26 @@ import { linearFetch, GQL, resolveIssueId } from "./linear"; export default function register(server: McpServer, env: Env) { server.tool( "linearComment", + "Add a Markdown comment to a Linear issue by id or key (e.g., ENG-123).", { issueIdOrKey: z.string(), body: z.string(), }, async (input) => { const issueId = await resolveIssueId(env, input.issueIdOrKey); - const r = await linearFetch(env, GQL.commentCreate, { + const r = await linearFetch<{ + commentCreate?: { comment?: { id?: string } }; + }>(env, GQL.commentCreate, { input: { issueId, body: input.body }, }); - return { content: [{ type: "text", text: `Comentario ${r.commentCreate?.comment?.id ?? "ok"}` }] }; + return { + content: [ + { + type: "text", + text: `Comentario ${r.commentCreate?.comment?.id ?? "ok"}`, + }, + ], + }; }, ); } diff --git a/src/tools/linearCreateIssue.ts b/src/tools/linearCreateIssue.ts index 0abb79c..05798c7 100644 --- a/src/tools/linearCreateIssue.ts +++ b/src/tools/linearCreateIssue.ts @@ -5,31 +5,93 @@ import { linearFetch, GQL, resolveTeamIdFromKey } from "./linear"; export default function register(server: McpServer, env: Env) { server.tool( "linearCreateIssue", + "Create a Linear issue. Supports project/labels by name, optional assignee and due date.", { teamKey: z.string(), title: z.string(), description: z.string().optional(), assigneeEmail: z.string().email().optional(), projectId: z.string().optional(), + projectName: z.string().optional(), + priority: z.number().int().min(0).max(4).optional(), + labelIds: z.array(z.string()).optional(), + labelNames: z.array(z.string()).optional(), dueToday: z.boolean().optional(), - dueDate: z.string().regex(/^\d{4}-\d{2}-\d{2}$/).optional(), + dueDate: z + .string() + .regex(/^\d{4}-\d{2}-\d{2}$/) + .optional(), }, async (input) => { const teamId = await resolveTeamIdFromKey(env, input.teamKey); let assigneeId: string | undefined; if (input.assigneeEmail) { - const ud = await linearFetch(env, GQL.userByEmail, { email: input.assigneeEmail }); + const ud = await linearFetch<{ + users: { nodes: Array<{ id: string }> }; + }>(env, GQL.userByEmail, { + email: input.assigneeEmail, + }); assigneeId = ud.users.nodes[0]?.id; - if (!assigneeId) throw new Error(`Usuario no encontrado: ${input.assigneeEmail}`); + if (!assigneeId) + throw new Error(`Usuario no encontrado: ${input.assigneeEmail}`); } - const payload: Record = { + type IssueCreatePayload = { + teamId: string; + title: string; + description?: string; + assigneeId?: string; + projectId?: string; + priority?: number; + labelIds?: string[]; + dueDate?: string; + }; + let resolvedProjectId: string | undefined = input.projectId; + if (!resolvedProjectId && input.projectName) { + const pd = await linearFetch<{ + projects: { nodes: Array<{ id: string; name: string }> }; + }>(env, GQL.projectsByTeam, { teamId }); + const needle = (input.projectName ?? "").trim().toLowerCase(); + const proj = pd.projects.nodes.find( + (p) => p.name.trim().toLowerCase() === needle, + ); + if (!proj) + throw new Error( + `Proyecto no encontrado por nombre: ${input.projectName}`, + ); + resolvedProjectId = proj.id; + } + + let resolvedLabelIds: string[] | undefined = input.labelIds; + if ( + (!resolvedLabelIds || resolvedLabelIds.length === 0) && + input.labelNames && + input.labelNames.length > 0 + ) { + const ld = await linearFetch<{ + issueLabels: { nodes: Array<{ id: string; name: string }> }; + }>(env, GQL.issueLabelsByTeam, { teamId }); + const mapByName = new Map( + ld.issueLabels.nodes.map( + (l) => [l.name.trim().toLowerCase(), l.id] as const, + ), + ); + resolvedLabelIds = input.labelNames.map((n) => { + const id = mapByName.get(n.trim().toLowerCase()); + if (!id) throw new Error(`Label no encontrado: ${n}`); + return id; + }); + } + + const payload: IssueCreatePayload = { teamId, title: input.title, description: input.description, assigneeId, - projectId: input.projectId, + projectId: resolvedProjectId, + priority: input.priority, + labelIds: resolvedLabelIds, }; if (input.dueToday) { @@ -37,16 +99,22 @@ export default function register(server: McpServer, env: Env) { const yyyy = now.getFullYear(); const mm = String(now.getMonth() + 1).padStart(2, "0"); const dd = String(now.getDate()).padStart(2, "0"); - (payload as any).dueDate = `${yyyy}-${mm}-${dd}`; + payload.dueDate = `${yyyy}-${mm}-${dd}`; } else if (input.dueDate) { - (payload as any).dueDate = input.dueDate; + payload.dueDate = input.dueDate; } // Optional: allow setting state via name/type alias at creation if 'state' provided // Linear's IssueCreateInput supports stateId; resolve if description contains state alias not needed - const r = await linearFetch(env, GQL.issueCreate, { input: payload }); + const r = await linearFetch<{ + issueCreate: { issue: { identifier: string; title: string } }; + }>(env, GQL.issueCreate, { input: payload }); const issue = r.issueCreate.issue; - return { content: [{ type: "text", text: `Creado ${issue.identifier}: ${issue.title}` }] }; + return { + content: [ + { type: "text", text: `Creado ${issue.identifier}: ${issue.title}` }, + ], + }; }, ); } diff --git a/src/tools/linearDeleteIssue.ts b/src/tools/linearDeleteIssue.ts index cfceb77..08bde44 100644 --- a/src/tools/linearDeleteIssue.ts +++ b/src/tools/linearDeleteIssue.ts @@ -5,6 +5,7 @@ import { linearFetch, GQL, resolveIssueId } from "./linear"; export default function register(server: McpServer, env: Env) { server.tool( "linearDeleteIssue", + "Delete a Linear issue by id or key (e.g., ENG-123).", { idOrKey: z.string() }, async (input) => { const id = await resolveIssueId(env, input.idOrKey); diff --git a/src/tools/linearGetIssue.ts b/src/tools/linearGetIssue.ts index 6372b30..40ab0dc 100644 --- a/src/tools/linearGetIssue.ts +++ b/src/tools/linearGetIssue.ts @@ -5,13 +5,27 @@ import { linearFetch, GQL, resolveIssueId } from "./linear"; export default function register(server: McpServer, env: Env) { server.tool( "linearGetIssue", + "Get detailed info for a Linear issue by id or key.", { idOrKey: z.string() }, async (input) => { const id = await resolveIssueId(env, input.idOrKey); - const data = await linearFetch(env, GQL.issueByIdentifier, { id }); + const data = await linearFetch<{ + issue?: { + identifier: string; + title: string; + state?: { name?: string }; + assignee?: { name?: string }; + team?: { key?: string }; + priority?: number; + project?: { name?: string }; + labels?: { nodes?: Array<{ id: string; name: string }> }; + updatedAt?: string; + }; + }>(env, GQL.issueByIdentifier, { id }); const i = data.issue; if (!i) return { content: [{ type: "text", text: "Issue no encontrado" }] }; - const text = `${i.identifier} ${i.title}\nEstado: ${i.state?.name ?? ""}\nAsignado: ${i.assignee?.name ?? ""}\nEquipo: ${i.team?.key ?? ""}\nActualizado: ${i.updatedAt}`; + const labels = (i.labels?.nodes || []).map((l) => l.name).join(", "); + const text = `${i.identifier} ${i.title}\nEstado: ${i.state?.name ?? ""}\nAsignado: ${i.assignee?.name ?? ""}\nEquipo: ${i.team?.key ?? ""}\nPrioridad: ${i.priority ?? ""}\nProyecto: ${i.project?.name ?? ""}\nLabels: ${labels}\nActualizado: ${i.updatedAt}`; return { content: [{ type: "text", text }] }; }, ); diff --git a/src/tools/linearListIssues.ts b/src/tools/linearListIssues.ts index e1efa92..038859a 100644 --- a/src/tools/linearListIssues.ts +++ b/src/tools/linearListIssues.ts @@ -12,37 +12,53 @@ function isoDate(d: Date): string { export default function register(server: McpServer, env: Env) { server.tool( "linearListIssues", + "List Linear issues filtered by team, assignee, and date ranges.", { teamKey: z.string().optional(), assigneeEmail: z.string().email().optional(), - createdOn: z.string().regex(/^\d{4}-\d{2}-\d{2}$/).optional(), - updatedOn: z.string().regex(/^\d{4}-\d{2}-\d{2}$/).optional(), - createdAfter: z.string().regex(/^\d{4}-\d{2}-\d{2}$/).optional(), - createdBefore: z.string().regex(/^\d{4}-\d{2}-\d{2}$/).optional(), - updatedAfter: z.string().regex(/^\d{4}-\d{2}-\d{2}$/).optional(), - updatedBefore: z.string().regex(/^\d{4}-\d{2}-\d{2}$/).optional(), + createdOn: z + .string() + .regex(/^\d{4}-\d{2}-\d{2}$/) + .optional(), + updatedOn: z + .string() + .regex(/^\d{4}-\d{2}-\d{2}$/) + .optional(), + createdAfter: z + .string() + .regex(/^\d{4}-\d{2}-\d{2}$/) + .optional(), + createdBefore: z + .string() + .regex(/^\d{4}-\d{2}-\d{2}$/) + .optional(), + updatedAfter: z + .string() + .regex(/^\d{4}-\d{2}-\d{2}$/) + .optional(), + updatedBefore: z + .string() + .regex(/^\d{4}-\d{2}-\d{2}$/) + .optional(), }, async (input) => { let teamId: string | undefined; if (input.teamKey) { - const td = await linearFetch<{ teams: { nodes: Array<{ id: string }> } }>( - env, - GQL.teamByKey, - { key: input.teamKey }, - ); + const td = await linearFetch<{ + teams: { nodes: Array<{ id: string }> }; + }>(env, GQL.teamByKey, { key: input.teamKey }); teamId = td.teams.nodes[0]?.id; if (!teamId) throw new Error(`Team no encontrado: ${input.teamKey}`); } let assigneeId: string | undefined; if (input.assigneeEmail) { - const ud = await linearFetch<{ users: { nodes: Array<{ id: string }> } }>( - env, - GQL.userByEmail, - { email: input.assigneeEmail }, - ); + const ud = await linearFetch<{ + users: { nodes: Array<{ id: string }> }; + }>(env, GQL.userByEmail, { email: input.assigneeEmail }); assigneeId = ud.users.nodes[0]?.id; - if (!assigneeId) throw new Error(`Usuario no encontrado: ${input.assigneeEmail}`); + if (!assigneeId) + throw new Error(`Usuario no encontrado: ${input.assigneeEmail}`); } let createdAfter = input.createdAfter; @@ -61,7 +77,8 @@ export default function register(server: McpServer, env: Env) { } const toDateTime = (s?: string) => (s ? `${s}T00:00:00.000Z` : undefined); - const toDateTimeEnd = (s?: string) => (s ? `${s}T23:59:59.999Z` : undefined); + const toDateTimeEnd = (s?: string) => + s ? `${s}T23:59:59.999Z` : undefined; const data = await linearFetch<{ issues: { @@ -81,14 +98,27 @@ export default function register(server: McpServer, env: Env) { }); const items = (data.issues?.nodes || []).map( - (n) => `${n.identifier} ${n.title} [${n.state?.name ?? ""}]`, + (n: { + identifier: string; + title: string; + state?: { name?: string }; + priority?: number; + project?: { name?: string }; + labels?: { nodes?: Array<{ id: string; name: string }> }; + }) => { + const labels = (n.labels?.nodes || []).map((l) => l.name).join(", "); + return `${n.identifier} ${n.title} [${n.state?.name ?? ""}] P:${n.priority ?? ""} Proy:${n.project?.name ?? ""} Labels:${labels}`; + }, ); - return { content: [{ type: "text", text: items.join("\n") || "Sin resultados" }] }; + return { + content: [{ type: "text", text: items.join("\n") || "Sin resultados" }], + }; }, ); server.tool( "linearListIssuesToday", + "List issues updated today, optionally filtered by team and assignee.", { teamKey: z.string().optional(), assigneeEmail: z.string().email().optional(), @@ -96,24 +126,21 @@ export default function register(server: McpServer, env: Env) { async (input) => { let teamId: string | undefined; if (input.teamKey) { - const td = await linearFetch<{ teams: { nodes: Array<{ id: string }> } }>( - env, - GQL.teamByKey, - { key: input.teamKey }, - ); + const td = await linearFetch<{ + teams: { nodes: Array<{ id: string }> }; + }>(env, GQL.teamByKey, { key: input.teamKey }); teamId = td.teams.nodes[0]?.id; if (!teamId) throw new Error(`Team no encontrado: ${input.teamKey}`); } let assigneeId: string | undefined; if (input.assigneeEmail) { - const ud = await linearFetch<{ users: { nodes: Array<{ id: string }> } }>( - env, - GQL.userByEmail, - { email: input.assigneeEmail }, - ); + const ud = await linearFetch<{ + users: { nodes: Array<{ id: string }> }; + }>(env, GQL.userByEmail, { email: input.assigneeEmail }); assigneeId = ud.users.nodes[0]?.id; - if (!assigneeId) throw new Error(`Usuario no encontrado: ${input.assigneeEmail}`); + if (!assigneeId) + throw new Error(`Usuario no encontrado: ${input.assigneeEmail}`); } const today = isoDate(new Date()); @@ -134,9 +161,21 @@ export default function register(server: McpServer, env: Env) { updatedBefore: toDateTimeEnd(today), }); const items = (data.issues?.nodes || []).map( - (n) => `${n.identifier} ${n.title} [${n.state?.name ?? ""}]`, + (n: { + identifier: string; + title: string; + state?: { name?: string }; + priority?: number; + project?: { name?: string }; + labels?: { nodes?: Array<{ id: string; name: string }> }; + }) => { + const labels = (n.labels?.nodes || []).map((l) => l.name).join(", "); + return `${n.identifier} ${n.title} [${n.state?.name ?? ""}] P:${n.priority ?? ""} Proy:${n.project?.name ?? ""} Labels:${labels}`; + }, ); - return { content: [{ type: "text", text: items.join("\n") || "Sin resultados" }] }; + return { + content: [{ type: "text", text: items.join("\n") || "Sin resultados" }], + }; }, ); } diff --git a/src/tools/linearListLabels.ts b/src/tools/linearListLabels.ts new file mode 100644 index 0000000..a352ef7 --- /dev/null +++ b/src/tools/linearListLabels.ts @@ -0,0 +1,26 @@ +import { z } from "zod"; +import type { McpServer } from "@modelcontextprotocol/sdk/server/mcp.js"; +import { GQL, linearFetch, resolveTeamIdFromKey } from "./linear"; + +export default function register(server: McpServer, env: Env) { + server.tool( + "linearListLabels", + "List labels for a team (or all teams when omitted).", + { teamKey: z.string().optional() }, + async (input) => { + let teamId: string | undefined; + if (input.teamKey) + teamId = await resolveTeamIdFromKey(env, input.teamKey); + const data = await linearFetch<{ + issueLabels: { nodes: Array<{ id: string; name: string }> }; + }>(env, GQL.issueLabelsByTeam, { teamId }); + const rows = data.issueLabels?.nodes || []; + const items = rows.map((l) => `${l.id}\t${l.name}`); + return { + content: [{ type: "text", text: items.join("\n") || "Sin labels" }], + }; + }, + ); +} + + diff --git a/src/tools/linearListStates.ts b/src/tools/linearListStates.ts index df785d5..1c2cab7 100644 --- a/src/tools/linearListStates.ts +++ b/src/tools/linearListStates.ts @@ -5,18 +5,21 @@ import { GQL, linearFetch, resolveTeamIdFromKey } from "./linear"; export default function register(server: McpServer, env: Env) { server.tool( "linearListStates", + "List workflow states for a given team.", { teamKey: z.string() }, async (input) => { const teamId = await resolveTeamIdFromKey(env, input.teamKey); - const data = await linearFetch<{ workflowStates: { nodes: Array<{ id: string; name: string; type?: string }> } }>( - env, - GQL.teamWorkflowStates, - { teamId }, - ); + const data = await linearFetch<{ + workflowStates: { + nodes: Array<{ id: string; name: string; type?: string }>; + }; + }>(env, GQL.teamWorkflowStates, { teamId }); const items = (data.workflowStates?.nodes || []).map( (s) => `${s.id}\t${s.name}\t${s.type ?? ""}`, ); - return { content: [{ type: "text", text: items.join("\n") || "Sin estados" }] }; + return { + content: [{ type: "text", text: items.join("\n") || "Sin estados" }], + }; }, ); } diff --git a/src/tools/linearListTeams.ts b/src/tools/linearListTeams.ts index 6ecdf27..25f5ead 100644 --- a/src/tools/linearListTeams.ts +++ b/src/tools/linearListTeams.ts @@ -4,15 +4,16 @@ import { GQL, linearFetch } from "./linear"; export default function register(server: McpServer, env: Env) { server.tool( "linearListTeams", + "List available Linear teams (key and name).", {}, async () => { - const data = await linearFetch<{ teams: { nodes: Array<{ id: string; key: string; name: string }> } }>( - env, - GQL.teamsList, - {}, - ); + const data = await linearFetch<{ + teams: { nodes: Array<{ id: string; key: string; name: string }> }; + }>(env, GQL.teamsList, {}); const items = (data.teams?.nodes || []).map((t) => `${t.key}\t${t.name}`); - return { content: [{ type: "text", text: items.join("\n") || "Sin equipos" }] }; + return { + content: [{ type: "text", text: items.join("\n") || "Sin equipos" }], + }; }, ); } diff --git a/src/tools/linearListUsers.ts b/src/tools/linearListUsers.ts new file mode 100644 index 0000000..7c1ee4a --- /dev/null +++ b/src/tools/linearListUsers.ts @@ -0,0 +1,23 @@ +import type { McpServer } from "@modelcontextprotocol/sdk/server/mcp.js"; +import { GQL, linearFetch } from "./linear"; + +export default function register(server: McpServer, env: Env) { + server.tool( + "linearListUsers", + "List users in the Linear workspace (id, name, email).", + {}, + async () => { + const data = await linearFetch<{ + users: { nodes: Array<{ id: string; name: string; email: string }> }; + }>(env, GQL.usersList, {}); + const items = (data.users?.nodes || []).map( + (u) => `${u.id}\t${u.name}\t${u.email}`, + ); + return { + content: [{ type: "text", text: items.join("\n") || "Sin usuarios" }], + }; + }, + ); +} + + diff --git a/src/tools/linearUpdateIssue.ts b/src/tools/linearUpdateIssue.ts index 47fd7ae..b48caf2 100644 --- a/src/tools/linearUpdateIssue.ts +++ b/src/tools/linearUpdateIssue.ts @@ -7,25 +7,40 @@ function isUuidLike(value: string): boolean { return /^[0-9a-fA-F-]{36}$/.test(value); } +const updateIssueArgsSchema = { + idOrKey: z.string(), + title: z.string().optional(), + description: z.string().optional(), + stateId: z.string().optional(), + state: z.string().optional().describe("Human-friendly status name or type"), + assigneeEmail: z.string().email().optional(), + dueDate: z + .string() + .regex(/^\d{4}-\d{2}-\d{2}$/) + .optional(), + projectId: z.string().optional(), + projectName: z.string().optional(), + labelIds: z.array(z.string()).optional(), + labelNames: z.array(z.string()).optional(), +} satisfies z.ZodRawShape; + export default function register(server: McpServer, env: Env) { server.tool( "linearUpdateIssue", - { - idOrKey: z.string(), - title: z.string().optional(), - description: z.string().optional(), - stateId: z.string().optional(), - state: z.string().optional().describe("Human-friendly status name or type"), - assigneeEmail: z.string().email().optional(), - dueDate: z.string().regex(/^\d{4}-\d{2}-\d{2}$/).optional(), - }, + "Update a Linear issue: title, description, state (alias), assignee, due date, project and labels by name or ID.", + updateIssueArgsSchema, async (input) => { const issueId = await resolveIssueId(env, input.idOrKey); let assigneeId: string | undefined; if (input.assigneeEmail) { - const ud = await linearFetch(env, GQL.userByEmail, { email: input.assigneeEmail }); + const ud = await linearFetch<{ + users: { nodes: Array<{ id: string }> }; + }>(env, GQL.userByEmail, { + email: input.assigneeEmail, + }); assigneeId = ud.users.nodes[0]?.id; - if (!assigneeId) throw new Error(`Usuario no encontrado: ${input.assigneeEmail}`); + if (!assigneeId) + throw new Error(`Usuario no encontrado: ${input.assigneeEmail}`); } const update: Record = {}; @@ -34,33 +49,115 @@ export default function register(server: McpServer, env: Env) { if (assigneeId) update.assigneeId = assigneeId; if (input.dueDate) update.dueDate = input.dueDate; + // We may need the issue's team for resolving aliases/names + let teamId: string | undefined; + // Resolve state: prefer explicit stateId if it is a UUID; otherwise map via name/type let desiredAlias: string | undefined = input.state; - if (input.stateId && !isUuidLike(input.stateId)) desiredAlias = input.stateId; - if (input.stateId && isUuidLike(input.stateId)) update.stateId = input.stateId; + if (input.stateId && !isUuidLike(input.stateId)) + desiredAlias = input.stateId; + if (input.stateId && isUuidLike(input.stateId)) + update.stateId = input.stateId; if (!update.stateId && desiredAlias) { - const issueData = await linearFetch(env, GQL.issueById, { id: issueId }); - const teamId: string | undefined = issueData.issue?.team?.id; + const issueData = await linearFetch<{ + issue?: { team?: { id: string } }; + }>(env, GQL.issueById, { + id: issueId, + }); + teamId = issueData.issue?.team?.id; if (!teamId) throw new Error("No se pudo determinar el team del issue"); - const statesData = await linearFetch(env, GQL.teamWorkflowStates, { teamId }); - const nodes: Array<{ id: string; name: string; type?: string }> = statesData.workflowStates.nodes; + const statesData = await linearFetch<{ + workflowStates: { + nodes: Array<{ id: string; name: string; type?: string }>; + }; + }>(env, GQL.teamWorkflowStates, { + teamId, + }); + const nodes: Array<{ id: string; name: string; type?: string }> = + statesData.workflowStates.nodes; const wanted = desiredAlias.trim().toLowerCase(); - const byName = nodes.find((s) => s.name.trim().toLowerCase() === wanted); - const byType = nodes.find((s) => (s.type || "").trim().toLowerCase() === wanted); + const byName = nodes.find( + (s) => s.name.trim().toLowerCase() === wanted, + ); + const byType = nodes.find( + (s) => (s.type || "").trim().toLowerCase() === wanted, + ); const stateId = (byName || byType)?.id; if (!stateId) throw new Error(`Estado no encontrado: ${desiredAlias}`); update.stateId = stateId; } - const r = await linearFetch(env, GQL.issueUpdate, { + if (input.projectId) { + // Project resolution: projectId direct or projectName via team + update.projectId = input.projectId; + } else if (input.projectName) { + if (!teamId) { + const issueData = await linearFetch<{ + issue?: { team?: { id: string } }; + }>(env, GQL.issueById, { id: issueId }); + teamId = issueData.issue?.team?.id; + if (!teamId) + throw new Error("No se pudo determinar el team del issue"); + } + const pd = await linearFetch<{ + projects: { nodes: Array<{ id: string; name: string }> }; + }>(env, GQL.projectsByTeam, { teamId }); + const needle = input.projectName.trim().toLowerCase(); + const proj = pd.projects.nodes.find( + (p) => p.name.trim().toLowerCase() === needle, + ); + if (!proj) + throw new Error(`Proyecto no encontrado por nombre: ${input.projectName}`); + update.projectId = proj.id; + } + + if (input.labelIds !== undefined) { + // Labels resolution: labelIds direct or labelNames via team + update.labelIds = input.labelIds; + } else if (input.labelNames !== undefined) { + if (input.labelNames.length === 0) { + update.labelIds = []; + } else { + if (!teamId) { + const issueData = await linearFetch<{ + issue?: { team?: { id: string } }; + }>(env, GQL.issueById, { id: issueId }); + teamId = issueData.issue?.team?.id; + if (!teamId) + throw new Error("No se pudo determinar el team del issue"); + } + const ld = await linearFetch<{ + issueLabels: { nodes: Array<{ id: string; name: string }> }; + }>(env, GQL.issueLabelsByTeam, { teamId }); + const mapByName = new Map( + ld.issueLabels.nodes.map( + (l) => [l.name.trim().toLowerCase(), l.id] as const, + ), + ); + update.labelIds = input.labelNames.map((n) => { + const id = mapByName.get(n.trim().toLowerCase()); + if (!id) throw new Error(`Label no encontrado: ${n}`); + return id; + }); + } + } + + const r = await linearFetch<{ + issueUpdate: { issue: { identifier: string; title: string } }; + }>(env, GQL.issueUpdate, { id: issueId, input: update, }); const issue = r.issueUpdate.issue; - return { content: [{ type: "text", text: `Actualizado ${issue.identifier}: ${issue.title}` }] }; + return { + content: [ + { + type: "text", + text: `Actualizado ${issue.identifier}: ${issue.title}`, + }, + ], + }; }, ); -} - - +} \ No newline at end of file diff --git a/src/tools/linearWebhooks.ts b/src/tools/linearWebhooks.ts index 2c8a991..71869d9 100644 --- a/src/tools/linearWebhooks.ts +++ b/src/tools/linearWebhooks.ts @@ -5,21 +5,28 @@ import { GQL, linearFetch } from "./linear"; export default function register(server: McpServer, env: Env) { server.tool( "linearWebhookCreate", + "Create a Linear webhook (optionally scoped to a team).", { url: z.string().url().describe("Ej: https:///webhooks/linear"), teamKey: z.string().optional(), allPublicTeams: z.boolean().optional().default(false), - resourceTypes: z.array(z.string()).default(["Issue", "Comment", "Project"]), + resourceTypes: z + .array(z.string()) + .default(["Issue", "Comment", "Project"]), enabled: z.boolean().optional().default(true), }, async (input) => { let teamId: string | undefined; if (input.teamKey && !input.allPublicTeams) { - const td = await linearFetch(env, GQL.teamIdByKey, { key: input.teamKey }); + const td = await linearFetch<{ + teams: { nodes: Array<{ id: string }> }; + }>(env, GQL.teamIdByKey, { key: input.teamKey }); teamId = td.teams.nodes[0]?.id; if (!teamId) throw new Error(`Team no encontrado: ${input.teamKey}`); } - const r = await linearFetch(env, GQL.webhookCreate, { + const r = await linearFetch<{ + webhookCreate: { webhook: { id: string; enabled: boolean } }; + }>(env, GQL.webhookCreate, { input: { url: input.url, enabled: input.enabled, @@ -29,12 +36,17 @@ export default function register(server: McpServer, env: Env) { }, }); const w = r.webhookCreate.webhook; - return { content: [{ type: "text", text: `Webhook id=${w.id} enabled=${w.enabled}` }] }; + return { + content: [ + { type: "text", text: `Webhook id=${w.id} enabled=${w.enabled}` }, + ], + }; }, ); server.tool( "linearWebhookDelete", + "Delete a Linear webhook by id.", { id: z.string() }, async (input) => { await linearFetch(env, GQL.webhookDelete, { id: input.id });