anya/tools/linear-manager.ts

729 lines
20 KiB
TypeScript
Raw Normal View History

2025-01-09 16:27:26 +05:30
import { z } from "zod";
import { zodFunction } from ".";
import { LinearClient } from "@linear/sdk";
import { Message } from "../interfaces/message";
import { userConfigs } from "../config";
import { ask } from "./ask";
import { RunnableToolFunction } from "openai/lib/RunnableFunction.mjs";
import { memory_manager_guide, memory_manager_init } from "./memory-manager";
// Parameter Schemas
export const IssueParams = z.object({
teamId: z.string(),
title: z.string(),
description: z.string().optional(),
assigneeId: z.string().optional(),
priority: z.number().optional(),
labelIds: z.array(z.string()).optional(),
});
export const UpdateIssueParams = z.object({
issueId: z.string().describe("The ID of the issue to update"),
title: z.string().optional().describe("The issue title"),
description: z
.string()
.optional()
.describe("The issue description in markdown format"),
stateId: z.string().optional().describe("The team state/status of the issue"),
assigneeId: z
.string()
.optional()
.describe("The identifier of the user to assign the issue to"),
priority: z
.number()
.min(0)
.max(4)
.optional()
.describe(
"The priority of the issue. 0 = No priority, 1 = Urgent, 2 = High, 3 = Normal, 4 = Low"
),
addedLabelIds: z
.array(z.string())
.optional()
.describe("The identifiers of the issue labels to be added to this issue"),
removedLabelIds: z
.array(z.string())
.optional()
.describe(
"The identifiers of the issue labels to be removed from this issue"
),
labelIds: z
.array(z.string())
.optional()
.describe(
"The complete set of label IDs to set on the issue (replaces existing labels)"
),
autoClosedByParentClosing: z
.boolean()
.optional()
.describe(
"Whether the issue was automatically closed because its parent issue was closed"
),
boardOrder: z
.number()
.optional()
.describe("The position of the issue in its column on the board view"),
dueDate: z
.string()
.optional()
.describe("The date at which the issue is due (TimelessDate format)"),
parentId: z
.string()
.optional()
.describe("The identifier of the parent issue"),
projectId: z
.string()
.optional()
.describe("The project associated with the issue"),
sortOrder: z
.number()
.optional()
.describe("The position of the issue related to other issues"),
subIssueSortOrder: z
.number()
.optional()
.describe("The position of the issue in parent's sub-issue list"),
teamId: z
.string()
.optional()
.describe("The identifier of the team associated with the issue"),
});
export const GetIssueParams = z.object({
issueId: z.string(),
});
export const SearchIssuesParams = z.object({
query: z.string().describe("Search query string"),
teamId: z.string().optional(),
limit: z.number().max(5).describe("Number of results to return (default: 1)"),
});
export const ListTeamsParams = z.object({
limit: z.number().max(20).describe("Number of teams to return (default 3)"),
});
export const AdvancedSearchIssuesParams = z.object({
query: z.string().optional(),
teamId: z.string().optional(),
assigneeId: z.string().optional(),
status: z
.enum(["backlog", "todo", "in_progress", "done", "canceled"])
.optional(),
priority: z.number().min(0).max(4).optional(),
orderBy: z
.enum(["createdAt", "updatedAt"])
.optional()
.describe("Order by, defaults to updatedAt"),
limit: z
.number()
.max(10)
.describe("Number of results to return (default: 5)"),
});
export const SearchUsersParams = z.object({
query: z.string().describe("Search query for user names"),
limit: z
.number()
.max(10)
.describe("Number of results to return (default: 5)"),
});
// Add new Project Parameter Schemas
export const ProjectParams = z.object({
name: z.string().describe("The name of the project"),
teamIds: z
.array(z.string())
.describe("The identifiers of the teams this project is associated with"),
description: z
.string()
.optional()
.describe("The description for the project"),
content: z.string().optional().describe("The project content as markdown"),
color: z.string().optional().describe("The color of the project"),
icon: z.string().optional().describe("The icon of the project"),
leadId: z.string().optional().describe("The identifier of the project lead"),
memberIds: z
.array(z.string())
.optional()
.describe("The identifiers of the members of this project"),
priority: z
.number()
.min(0)
.max(4)
.optional()
.describe(
"The priority of the project. 0 = No priority, 1 = Urgent, 2 = High, 3 = Normal, 4 = Low"
),
sortOrder: z
.number()
.optional()
.describe("The sort order for the project within shared views"),
prioritySortOrder: z
.number()
.optional()
.describe(
"[ALPHA] The sort order for the project within shared views, when ordered by priority"
),
startDate: z
.string()
.optional()
.describe("The planned start date of the project (TimelessDate format)"),
targetDate: z
.string()
.optional()
.describe("The planned target date of the project (TimelessDate format)"),
statusId: z.string().optional().describe("The ID of the project status"),
state: z
.string()
.optional()
.describe("[DEPRECATED] The state of the project"),
id: z.string().optional().describe("The identifier in UUID v4 format"),
convertedFromIssueId: z
.string()
.optional()
.describe("The ID of the issue from which that project is created"),
lastAppliedTemplateId: z
.string()
.optional()
.describe("The ID of the last template applied to the project"),
});
export const UpdateProjectParams = z.object({
projectId: z.string().describe("The ID of the project to update"),
name: z.string().optional(),
description: z.string().optional(),
state: z
.enum(["planned", "started", "paused", "completed", "canceled"])
.optional(),
startDate: z.string().optional(),
targetDate: z.string().optional(),
sortOrder: z.number().optional(),
icon: z.string().optional(),
});
export const GetProjectParams = z.object({
projectId: z.string(),
});
export const SearchProjectsParams = z.object({
query: z.string().describe("Search query string"),
teamId: z.string().optional(),
limit: z.number().max(5).describe("Number of results to return (default: 1)"),
});
// Add new ListProjectsParams schema after other params
export const ListProjectsParams = z.object({
teamId: z.string().optional().describe("Filter projects by team ID"),
limit: z
.number()
.max(20)
.describe("Number of projects to return (default: 10)"),
state: z
.enum(["planned", "started", "paused", "completed", "canceled"])
.optional()
.describe("Filter projects by state"),
});
interface SimpleIssue {
id: string;
title: string;
status: string;
priority: number;
assignee?: string;
dueDate?: string;
labels?: string[];
}
interface SimpleTeam {
id: string;
name: string;
key: string;
}
interface SimpleUser {
id: string;
name: string;
email: string;
displayName?: string;
avatarUrl?: string;
}
interface SimpleProject {
id: string;
name: string;
state: string;
startDate?: string;
targetDate?: string;
description?: string;
teamIds: string[];
priority?: number;
leadId?: string;
memberIds?: string[];
color?: string;
icon?: string;
statusId?: string;
}
function formatIssue(issue: any): SimpleIssue {
return {
id: issue.id,
title: issue.title,
status: issue.state?.name || "Unknown",
priority: issue.priority,
assignee: issue.assignee?.name,
dueDate: issue.dueDate,
labels: issue.labels?.nodes?.map((l: any) => l.name) || [],
};
}
// Add after existing formatIssue function
function formatProject(project: any): SimpleProject {
return {
id: project.id,
name: project.name,
state: project.state,
startDate: project.startDate,
targetDate: project.targetDate,
description: project.description,
teamIds: project.teams?.nodes?.map((t: any) => t.id) || [],
priority: project.priority,
leadId: project.lead?.id,
memberIds: project.members?.nodes?.map((m: any) => m.id) || [],
color: project.color,
icon: project.icon,
statusId: project.status?.id,
};
}
// API Functions
async function createIssue(
client: LinearClient,
params: z.infer<typeof IssueParams>
) {
try {
return await client.createIssue(params);
} catch (error) {
return `Error: ${error}`;
}
}
async function updateIssue(
client: LinearClient,
params: z.infer<typeof UpdateIssueParams>
) {
try {
const { issueId, ...updateData } = params;
return await client.updateIssue(issueId, updateData);
} catch (error) {
return `Error: ${error}`;
}
}
async function getIssue(
client: LinearClient,
{ issueId }: z.infer<typeof GetIssueParams>
) {
try {
return await client.issue(issueId);
} catch (error) {
return `Error: ${error}`;
}
}
async function searchIssues(
client: LinearClient,
{ query, teamId, limit }: z.infer<typeof SearchIssuesParams>
) {
try {
const searchParams: any = { first: limit };
if (teamId) {
searchParams.filter = { team: { id: { eq: teamId } } };
}
const issues = await client.issues({
...searchParams,
filter: {
or: [
{ title: { containsIgnoreCase: query } },
{ description: { containsIgnoreCase: query } },
],
},
});
return issues.nodes.map(formatIssue);
} catch (error) {
return `Error: ${error}`;
}
}
async function listTeams(
client: LinearClient,
{ limit }: z.infer<typeof ListTeamsParams>
) {
try {
const teams = await client.teams({ first: limit });
return teams.nodes.map(
(team): SimpleTeam => ({
id: team.id,
name: team.name,
key: team.key,
})
);
} catch (error) {
return `Error: ${error}`;
}
}
async function advancedSearchIssues(
client: LinearClient,
params: z.infer<typeof AdvancedSearchIssuesParams>
) {
try {
const filter: any = {};
if (params.teamId) filter.team = { id: { eq: params.teamId } };
if (params.assigneeId) filter.assignee = { id: { eq: params.assigneeId } };
if (params.status) filter.state = { type: { eq: params.status } };
if (params.priority) filter.priority = { eq: params.priority };
if (params.query) {
filter.or = [
{ title: { containsIgnoreCase: params.query } },
{ description: { containsIgnoreCase: params.query } },
];
}
const issues = await client.issues({
first: params.limit,
filter,
orderBy: params.orderBy || ("updatedAt" as any),
});
return issues.nodes.map(formatIssue);
} catch (error) {
return `Error: ${error}`;
}
}
async function searchUsers(
client: LinearClient,
{ query, limit }: z.infer<typeof SearchUsersParams>
) {
try {
const users = await client.users({
filter: {
or: [
{ name: { containsIgnoreCase: query } },
{ displayName: { containsIgnoreCase: query } },
{ email: { containsIgnoreCase: query } },
],
},
first: limit,
});
return users.nodes.map(
(user): SimpleUser => ({
id: user.id,
name: user.name,
email: user.email,
displayName: user.displayName,
avatarUrl: user.avatarUrl,
})
);
} catch (error) {
return `Error: ${error}`;
}
}
// Add new Project API Functions
async function createProject(
client: LinearClient,
params: z.infer<typeof ProjectParams>
) {
try {
return await client.createProject(params);
} catch (error) {
return `Error: ${error}`;
}
}
async function updateProject(
client: LinearClient,
params: z.infer<typeof UpdateProjectParams>
) {
try {
const { projectId, ...updateData } = params;
return await client.updateProject(projectId, updateData);
} catch (error) {
return `Error: ${error}`;
}
}
async function getProject(
client: LinearClient,
{ projectId }: z.infer<typeof GetProjectParams>
) {
try {
return await client.project(projectId);
} catch (error) {
return `Error: ${error}`;
}
}
// Modify searchProjects function to handle empty queries
async function searchProjects(
client: LinearClient,
{ query, teamId, limit }: z.infer<typeof SearchProjectsParams>
) {
try {
const searchParams: any = { first: limit };
const filter: any = {};
if (teamId) {
filter.team = { id: { eq: teamId } };
}
if (query) {
filter.or = [{ name: { containsIgnoreCase: query } }];
}
if (Object.keys(filter).length > 0) {
searchParams.filter = filter;
}
const projects = await client.projects(searchParams);
return projects.nodes.map(formatProject);
} catch (error) {
return `Error: ${error}`;
}
}
// Add new listProjects function
async function listProjects(
client: LinearClient,
{ teamId, limit, state }: z.infer<typeof ListProjectsParams>
) {
try {
const filter: any = {};
if (teamId) {
filter.team = { id: { eq: teamId } };
}
if (state) {
filter.state = { eq: state };
}
const projects = await client.projects({
first: limit,
filter: Object.keys(filter).length > 0 ? filter : undefined,
orderBy: "updatedAt" as any,
});
return projects.nodes.map(formatProject);
} catch (error) {
return `Error: ${error}`;
}
}
// Main manager function
export const LinearManagerParams = z.object({
request: z
.string()
.describe("User's request regarding Linear project management"),
});
export type LinearManagerParams = z.infer<typeof LinearManagerParams>;
export async function linearManager(
{ request }: LinearManagerParams,
context_message: Message
) {
console.log(
"Context message",
context_message.author,
context_message.getUserRoles()
);
const userConfig = context_message.author.config;
// console.log("User config", userConfig);
const linearApiKey = userConfig?.identities.find(
(i) => i.platform === "linear_key"
)?.id;
// console.log("Linear API Key", linearApiKey);
const linearEmail = userConfig?.identities.find(
(i) => i.platform === "linear_email"
)?.id;
if (!linearApiKey) {
return {
response: "Please configure your Linear API key to use this tool.",
};
}
const client = new LinearClient({ apiKey: linearApiKey });
const linear_tools: RunnableToolFunction<any>[] = [
zodFunction({
function: (params) => createIssue(client, params),
name: "linearCreateIssue",
schema: IssueParams,
description: "Create a new issue in Linear",
}),
zodFunction({
function: (params) => updateIssue(client, params),
name: "linearUpdateIssue",
schema: UpdateIssueParams,
description: "Update an existing issue in Linear",
}),
zodFunction({
function: (params) => getIssue(client, params),
name: "linearGetIssue",
schema: GetIssueParams,
description: "Get details of a specific issue",
}),
zodFunction({
function: (params) => searchUsers(client, params),
name: "linearSearchUsers",
schema: SearchUsersParams,
description:
"Search for users across the workspace by name, display name, or email. Use display name for better results.",
}),
zodFunction({
function: (params) => searchIssues(client, params),
name: "linearSearchIssues",
schema: SearchIssuesParams,
description:
"Search for issues in Linear using a query string. Optionally filter by team and limit results.",
}),
zodFunction({
function: (params) => listTeams(client, params),
name: "linearListTeams",
schema: ListTeamsParams,
description: "List all teams in the workspace with optional limit",
}),
zodFunction({
function: (params) => advancedSearchIssues(client, params),
name: "linearAdvancedSearchIssues",
schema: AdvancedSearchIssuesParams,
description:
"Search for issues with advanced filters including status, assignee, and priority",
}),
zodFunction({
function: (params) => createProject(client, params),
name: "linearCreateProject",
schema: ProjectParams,
description: "Create a new project in Linear",
}),
zodFunction({
function: (params) => updateProject(client, params),
name: "linearUpdateProject",
schema: UpdateProjectParams,
description: "Update an existing project in Linear",
}),
zodFunction({
function: (params) => getProject(client, params),
name: "linearGetProject",
schema: GetProjectParams,
description: "Get details of a specific project",
}),
zodFunction({
function: (params) => searchProjects(client, params),
name: "linearSearchProjects",
schema: SearchProjectsParams,
description:
"Search for projects in Linear using a query string. Optionally filter by team and limit results.",
}),
zodFunction({
function: (params) => listProjects(client, params),
name: "linearListProjects",
schema: ListProjectsParams,
description:
"List projects in Linear, optionally filtered by team and state. Returns most recently updated projects first.",
}),
];
// fetch all labels available in each team
const teams = await client.teams({ first: 10 });
const teamLabels = await client.issueLabels();
// list all the possible states of issues
const states = await client.workflowStates();
const state_values = states.nodes.map((state) => ({
id: state.id,
name: state.name,
}));
// Only include teams and labels in the context if they exist
const teamsContext =
teams.nodes.length > 0
? `Teams:\n${teams.nodes.map((team) => ` - ${team.name}`).join("\n")}`
: "";
const labelsContext =
teamLabels.nodes.length > 0
? `All Labels:\n${teamLabels.nodes
.map((label) => ` - ${label.name} (${label.color})`)
.join("\n")}`
: "";
const issueStateContext =
state_values.length > 0
? `All Issue States:\n${state_values
.map((state) => ` - ${state.name}`)
.join("\n")}`
: "";
const workspaceContext = [teamsContext, labelsContext, issueStateContext]
.filter(Boolean)
.join("\n\n");
const response = await ask({
model: "gpt-4o-mini",
prompt: `You are a Linear project manager.
Your job is to understand the user's request and manage issues, teams, and projects using the available tools.
----
${memory_manager_guide("linear_manager", context_message.author.id)}
----
${
workspaceContext
? `Here is some more context on current linear workspace:\n${workspaceContext}`
: ""
}
The user you are currently assisting has the following details:
- Name: ${userConfig?.name}
- Linear Email: ${linearEmail}
When responding make sure to link the issues when returning the value.
linear issue links look like: \`https://linear.app/xcelerator/issue/XCE-205\`
Where \`XCE-205\` is the issue ID and \`xcelerator\` is the team name.
`,
message: request,
seed: `linear-${context_message.channelId}`,
tools: linear_tools.concat(
memory_manager_init(context_message, "linear_manager")
) as any,
});
return { response };
}
export const linear_manager_tool = (context_message: Message) =>
zodFunction({
function: (args) => linearManager(args, context_message),
name: "linear_manager",
schema: LinearManagerParams,
description: `Linear Issue Manager.
This tool allows you to create, update, close, or assign issues in Linear.
Provide detailed information to perform the requested action.
Use this when user explicitly asks for Linear/project management.`,
});