import {
LinearClient,
Issue,
Team,
Project,
User,
IssueLabel,
Cycle,
WorkflowState,
CustomView,
} from '@linear/sdk';
/**
* Linear API client wrapper with error handling and type-safe operations
*/
export class LinearAPIClient {
private client: LinearClient;
constructor(apiKey: string) {
if (!apiKey || apiKey.trim() === '') {
throw new Error('LINEAR_API_KEY is required but not provided');
}
this.client = new LinearClient({ apiKey });
}
/**
* Test the API connection and return viewer information
*/
async testConnection() {
try {
const viewer = await this.client.viewer;
return {
id: viewer.id,
name: viewer.name,
email: viewer.email,
};
} catch (error) {
throw new Error(`Failed to connect to Linear API: ${this.getErrorMessage(error)}`);
}
}
/**
* List issues with optional filters
*/
async listIssues(filters: {
teamId?: string;
projectId?: string;
assigneeId?: string;
status?: string;
priority?: number;
label?: string;
limit?: number;
includeArchived?: boolean;
}) {
try {
const {
teamId,
projectId,
assigneeId,
status,
priority,
label,
limit = 25,
includeArchived = false,
} = filters;
// Build filter object
const filter: Record<string, unknown> = {};
if (teamId) filter.team = { id: { eq: teamId } };
if (projectId) filter.project = { id: { eq: projectId } };
if (assigneeId) filter.assignee = { id: { eq: assigneeId } };
if (priority !== undefined) filter.priority = { eq: priority };
if (label) filter.labels = { name: { eq: label } };
// Map status to state
if (status) {
const stateMapping: Record<string, string> = {
backlog: 'backlog',
unstarted: 'unstarted',
started: 'started',
completed: 'completed',
canceled: 'canceled',
triage: 'triage',
in_progress: 'started',
done: 'completed',
};
const mappedState = stateMapping[status];
if (mappedState) {
filter.state = { type: { eq: mappedState } };
}
}
const issues = await this.client.issues({
filter,
first: limit,
includeArchived,
});
const nodes = await issues.nodes;
return await this.formatIssues(nodes);
} catch (error) {
throw new Error(`Failed to list issues: ${this.getErrorMessage(error)}`);
}
}
/**
* Create a new issue
*/
async createIssue(data: {
title: string;
description?: string;
teamId: string;
projectId?: string;
assigneeId?: string;
priority?: number;
labelIds?: string[];
stateId?: string;
estimate?: number;
dueDate?: string;
parentId?: string;
cycleId?: string;
}) {
try {
// Validate and filter out parent labels if labelIds provided
let finalLabelIds = data.labelIds;
const warnings: string[] = [];
if (data.labelIds && data.labelIds.length > 0) {
const { validLabelIds, parentLabels } = await this.filterParentLabels(data.labelIds);
finalLabelIds = validLabelIds;
if (parentLabels.length > 0) {
const parentNames = parentLabels.map((l) => l.name).join(', ');
warnings.push(
`Skipped parent labels (cannot be assigned directly): ${parentNames}. Use linear_list_labels to find child labels.`
);
}
}
// Build payload, only including defined optional fields
const payload: {
title: string;
teamId: string;
description?: string;
projectId?: string;
assigneeId?: string;
priority?: number;
labelIds?: string[];
stateId?: string;
estimate?: number;
dueDate?: string;
parentId?: string;
cycleId?: string;
} = {
title: data.title,
teamId: data.teamId,
};
if (data.description !== undefined) payload.description = data.description;
if (data.projectId !== undefined) payload.projectId = data.projectId;
if (data.assigneeId !== undefined) payload.assigneeId = data.assigneeId;
if (data.priority !== undefined) payload.priority = data.priority;
if (finalLabelIds !== undefined) payload.labelIds = finalLabelIds;
if (data.stateId !== undefined) payload.stateId = data.stateId;
if (data.estimate !== undefined) payload.estimate = data.estimate;
if (data.dueDate !== undefined) payload.dueDate = data.dueDate;
if (data.parentId !== undefined) payload.parentId = data.parentId;
if (data.cycleId !== undefined) payload.cycleId = data.cycleId;
const issuePayload = await this.client.createIssue(payload);
const issue = await issuePayload.issue;
if (!issue) {
throw new Error('Issue creation failed: No issue returned');
}
const formattedIssue = await this.formatIssue(issue);
// Add warnings to the response
return {
...formattedIssue,
warnings: warnings.length > 0 ? warnings : undefined,
};
} catch (error) {
throw new Error(`Failed to create issue: ${this.getErrorMessage(error)}`);
}
}
/**
* Update an existing issue
*/
async updateIssue(
issueId: string,
data: {
title?: string;
description?: string;
assigneeId?: string;
priority?: number;
stateId?: string;
labelIds?: string[];
estimate?: number;
dueDate?: string;
projectId?: string;
cycleId?: string;
}
) {
try {
// Validate and filter out parent labels if labelIds provided
let finalLabelIds = data.labelIds;
const warnings: string[] = [];
if (data.labelIds && data.labelIds.length > 0) {
const { validLabelIds, parentLabels } = await this.filterParentLabels(data.labelIds);
finalLabelIds = validLabelIds;
if (parentLabels.length > 0) {
const parentNames = parentLabels.map((l) => l.name).join(', ');
warnings.push(
`Skipped parent labels (cannot be assigned directly): ${parentNames}. Use linear_list_labels to find child labels.`
);
}
}
// Build payload, only including defined optional fields
const payload: {
title?: string;
description?: string;
assigneeId?: string;
priority?: number;
stateId?: string;
labelIds?: string[];
estimate?: number;
dueDate?: string;
projectId?: string;
cycleId?: string;
} = {};
if (data.title !== undefined) payload.title = data.title;
if (data.description !== undefined) payload.description = data.description;
if (data.assigneeId !== undefined) payload.assigneeId = data.assigneeId;
if (data.priority !== undefined) payload.priority = data.priority;
if (data.stateId !== undefined) payload.stateId = data.stateId;
if (finalLabelIds !== undefined) payload.labelIds = finalLabelIds;
if (data.estimate !== undefined) payload.estimate = data.estimate;
if (data.dueDate !== undefined) payload.dueDate = data.dueDate;
if (data.projectId !== undefined) payload.projectId = data.projectId;
if (data.cycleId !== undefined) payload.cycleId = data.cycleId;
const issuePayload = await this.client.updateIssue(issueId, payload);
const issue = await issuePayload.issue;
if (!issue) {
throw new Error('Issue update failed: No issue returned');
}
const formattedIssue = await this.formatIssue(issue);
// Add warnings to the response
return {
...formattedIssue,
warnings: warnings.length > 0 ? warnings : undefined,
};
} catch (error) {
throw new Error(`Failed to update issue: ${this.getErrorMessage(error)}`);
}
}
/**
* Get issue details by ID or identifier
*/
async getIssue(issueId: string) {
try {
const issue = await this.client.issue(issueId);
if (!issue) {
throw new Error(`Issue not found: ${issueId}`);
}
return await this.formatIssue(issue);
} catch (error) {
throw new Error(`Failed to get issue: ${this.getErrorMessage(error)}`);
}
}
/**
* Search issues by text query
*/
async searchIssues(
query: string,
options?: {
teamId?: string;
limit?: number;
includeArchived?: boolean;
}
) {
try {
const { limit = 25, includeArchived = false } = options || {};
// Build filter
const filter: Record<string, unknown> = {};
if (options?.teamId) {
filter.team = { id: { eq: options.teamId } };
}
// Use issues with title/description search
const issues = await this.client.issues({
filter: {
...filter,
or: [{ title: { contains: query } }, { description: { contains: query } }],
},
first: limit,
includeArchived,
});
const nodes = await issues.nodes;
return await this.formatIssues(nodes);
} catch (error) {
throw new Error(`Failed to search issues: ${this.getErrorMessage(error)}`);
}
}
/**
* List all teams
*/
async listTeams(includeArchived = false) {
try {
const teams = await this.client.teams({
includeArchived,
});
const nodes = await teams.nodes;
return await this.formatTeams(nodes);
} catch (error) {
throw new Error(`Failed to list teams: ${this.getErrorMessage(error)}`);
}
}
/**
* Get team details by ID
*/
async getTeam(teamId: string) {
try {
const team = await this.client.team(teamId);
if (!team) {
throw new Error('Team not found');
}
return await this.formatTeam(team);
} catch (error) {
throw new Error(`Failed to get team: ${this.getErrorMessage(error)}`);
}
}
/**
* Create a new team
*/
async createTeam(data: {
name: string;
key: string;
description?: string;
icon?: string;
color?: string;
timezone?: string;
private?: boolean;
}) {
try {
const teamPayload = await this.client.createTeam({
name: data.name,
key: data.key,
description: data.description,
icon: data.icon,
color: data.color,
timezone: data.timezone,
private: data.private,
});
const team = await teamPayload.team;
if (!team) {
throw new Error('Team creation failed: No team returned');
}
return await this.formatTeam(team);
} catch (error) {
throw new Error(`Failed to create team: ${this.getErrorMessage(error)}`);
}
}
/**
* Update an existing team
*/
async updateTeam(
teamId: string,
data: {
name?: string;
key?: string;
description?: string;
icon?: string;
color?: string;
timezone?: string;
private?: boolean;
}
) {
try {
const updateData: Record<string, unknown> = {};
if (data.name !== undefined) updateData.name = data.name;
if (data.key !== undefined) updateData.key = data.key;
if (data.description !== undefined) updateData.description = data.description;
if (data.icon !== undefined) updateData.icon = data.icon;
if (data.color !== undefined) updateData.color = data.color;
if (data.timezone !== undefined) updateData.timezone = data.timezone;
if (data.private !== undefined) updateData.private = data.private;
const teamPayload = await this.client.updateTeam(teamId, updateData);
const team = await teamPayload.team;
if (!team) {
throw new Error('Team update failed: No team returned');
}
return await this.formatTeam(team);
} catch (error) {
throw new Error(`Failed to update team: ${this.getErrorMessage(error)}`);
}
}
/**
* Archive a team
* Note: Linear API doesn't support team archiving - must be done via UI
*/
async archiveTeam(_teamId: string) {
throw new Error(
'Team archiving is not supported via the Linear API. Please use the Linear web interface to archive teams.'
);
}
/**
* Unarchive a team
* Note: Linear API doesn't support team unarchiving - must be done via UI
*/
async unarchiveTeam(_teamId: string) {
throw new Error(
'Team unarchiving is not supported via the Linear API. Please use the Linear web interface to unarchive teams.'
);
}
/**
* List projects with optional team filter
*/
async listProjects(teamId?: string, includeArchived = false) {
try {
const filter: Record<string, unknown> = {};
if (teamId) filter.teams = { some: { id: { eq: teamId } } };
const projects = await this.client.projects({
filter,
includeArchived,
});
const nodes = await projects.nodes;
return await this.formatProjects(nodes);
} catch (error) {
throw new Error(`Failed to list projects: ${this.getErrorMessage(error)}`);
}
}
/**
* Create a new project
*/
async createProject(data: {
name: string;
teamIds: string[];
description?: string;
leadId?: string;
memberIds?: string[];
color?: string;
icon?: string;
priority?: number;
startDate?: string;
targetDate?: string;
statusId?: string;
content?: string;
}) {
try {
const projectPayload = await this.client.createProject({
name: data.name,
teamIds: data.teamIds,
description: data.description,
leadId: data.leadId,
memberIds: data.memberIds,
color: data.color,
icon: data.icon,
priority: data.priority,
startDate: data.startDate,
targetDate: data.targetDate,
statusId: data.statusId,
content: data.content,
});
const project = await projectPayload.project;
if (!project) {
throw new Error('Project creation failed: No project returned');
}
return await this.formatProject(project);
} catch (error) {
throw new Error(`Failed to create project: ${this.getErrorMessage(error)}`);
}
}
/**
* Update an existing project
*/
async updateProject(
projectId: string,
data: {
name?: string;
description?: string;
leadId?: string;
memberIds?: string[];
color?: string;
icon?: string;
priority?: number;
startDate?: string;
targetDate?: string;
statusId?: string;
content?: string;
teamIds?: string[];
state?: string;
}
) {
try {
const projectPayload = await this.client.updateProject(projectId, {
name: data.name,
description: data.description,
leadId: data.leadId,
memberIds: data.memberIds,
color: data.color,
icon: data.icon,
priority: data.priority,
startDate: data.startDate,
targetDate: data.targetDate,
statusId: data.statusId,
content: data.content,
teamIds: data.teamIds,
state: data.state,
});
const project = await projectPayload.project;
if (!project) {
throw new Error('Project update failed: No project returned');
}
return await this.formatProject(project);
} catch (error) {
throw new Error(`Failed to update project: ${this.getErrorMessage(error)}`);
}
}
/**
* Get project details by ID
*/
async getProject(projectId: string) {
try {
const project = await this.client.project(projectId);
if (!project) {
throw new Error(`Project not found: ${projectId}`);
}
return await this.formatProject(project);
} catch (error) {
throw new Error(`Failed to get project: ${this.getErrorMessage(error)}`);
}
}
/**
* Archive a project
*/
async archiveProject(projectId: string) {
try {
const archivePayload = await this.client.archiveProject(projectId);
const success = archivePayload.success;
return {
success,
message: success ? 'Project archived successfully' : 'Failed to archive project',
};
} catch (error) {
throw new Error(`Failed to archive project: ${this.getErrorMessage(error)}`);
}
}
/**
* Unarchive a project
*/
async unarchiveProject(projectId: string) {
try {
const project = await this.client.project(projectId);
if (!project) {
throw new Error(`Project not found: ${projectId}`);
}
const unarchivePayload = await project.unarchive();
const success = unarchivePayload.success;
return {
success,
message: success ? 'Project unarchived successfully' : 'Failed to unarchive project',
};
} catch (error) {
throw new Error(`Failed to unarchive project: ${this.getErrorMessage(error)}`);
}
}
/**
* List custom views
*/
async listCustomViews(includeArchived = false) {
try {
const views = await this.client.customViews({
includeArchived,
});
const nodes = await views.nodes;
return await this.formatCustomViews(nodes);
} catch (error) {
throw new Error(`Failed to list custom views: ${this.getErrorMessage(error)}`);
}
}
/**
* Create a new custom view
*/
async createCustomView(data: {
name: string;
description?: string;
teamId?: string;
projectId?: string;
icon?: string;
color?: string;
shared?: boolean;
filters?: Record<string, unknown>;
}) {
try {
// Build payload, only including defined optional fields
const payload: {
name: string;
description?: string;
teamId?: string;
projectId?: string;
icon?: string;
color?: string;
shared?: boolean;
filters?: Record<string, unknown>;
} = {
name: data.name,
};
if (data.description !== undefined) payload.description = data.description;
if (data.teamId !== undefined) payload.teamId = data.teamId;
if (data.projectId !== undefined) payload.projectId = data.projectId;
if (data.icon !== undefined) payload.icon = data.icon;
if (data.color !== undefined) payload.color = data.color;
if (data.shared !== undefined) payload.shared = data.shared;
if (data.filters !== undefined) payload.filters = data.filters;
const viewPayload = await this.client.createCustomView(payload);
const view = await viewPayload.customView;
if (!view) {
throw new Error('Custom view creation failed: No view returned');
}
return await this.formatCustomView(view);
} catch (error) {
throw new Error(`Failed to create custom view: ${this.getErrorMessage(error)}`);
}
}
/**
* Update an existing custom view
*/
async updateCustomView(
viewId: string,
data: {
name?: string;
description?: string;
teamId?: string;
projectId?: string;
icon?: string;
color?: string;
shared?: boolean;
filters?: Record<string, unknown>;
}
) {
try {
// Build payload, only including defined optional fields
const payload: {
name?: string;
description?: string;
teamId?: string;
projectId?: string;
icon?: string;
color?: string;
shared?: boolean;
filters?: Record<string, unknown>;
} = {};
if (data.name !== undefined) payload.name = data.name;
if (data.description !== undefined) payload.description = data.description;
if (data.teamId !== undefined) payload.teamId = data.teamId;
if (data.projectId !== undefined) payload.projectId = data.projectId;
if (data.icon !== undefined) payload.icon = data.icon;
if (data.color !== undefined) payload.color = data.color;
if (data.shared !== undefined) payload.shared = data.shared;
if (data.filters !== undefined) payload.filters = data.filters;
const viewPayload = await this.client.updateCustomView(viewId, payload);
const view = await viewPayload.customView;
if (!view) {
throw new Error('Custom view update failed: No view returned');
}
return await this.formatCustomView(view);
} catch (error) {
throw new Error(`Failed to update custom view: ${this.getErrorMessage(error)}`);
}
}
/**
* Get custom view details by ID
*/
async getCustomView(viewId: string) {
try {
const view = await this.client.customView(viewId);
if (!view) {
throw new Error(`Custom view not found: ${viewId}`);
}
return await this.formatCustomView(view);
} catch (error) {
throw new Error(`Failed to get custom view: ${this.getErrorMessage(error)}`);
}
}
/**
* Delete a custom view
*/
async deleteCustomView(viewId: string) {
try {
const deletePayload = await this.client.deleteCustomView(viewId);
const success = deletePayload.success;
return {
success,
message: success ? 'Custom view deleted successfully' : 'Failed to delete custom view',
};
} catch (error) {
throw new Error(`Failed to delete custom view: ${this.getErrorMessage(error)}`);
}
}
/**
* Create a new cycle (sprint)
*/
async createCycle(data: {
teamId: string;
name: string;
description?: string;
startsAt: string;
endsAt: string;
}) {
try {
const cyclePayload = await this.client.createCycle({
teamId: data.teamId,
name: data.name,
description: data.description,
startsAt: new Date(data.startsAt),
endsAt: new Date(data.endsAt),
});
const cycle = await cyclePayload.cycle;
if (!cycle) {
throw new Error('Cycle creation failed: No cycle returned');
}
return await this.formatCycle(cycle);
} catch (error) {
throw new Error(`Failed to create cycle: ${this.getErrorMessage(error)}`);
}
}
/**
* List cycles for a team
*/
async listCycles(teamId: string, includeArchived = false) {
try {
const cycles = await this.client.cycles({
filter: {
team: { id: { eq: teamId } },
},
includeArchived,
});
const nodes = await cycles.nodes;
return await this.formatCycles(nodes);
} catch (error) {
throw new Error(`Failed to list cycles: ${this.getErrorMessage(error)}`);
}
}
/**
* Get cycle details by ID
*/
async getCycle(cycleId: string) {
try {
const cycle = await this.client.cycle(cycleId);
if (!cycle) {
throw new Error('Cycle not found');
}
return await this.formatCycle(cycle);
} catch (error) {
throw new Error(`Failed to get cycle: ${this.getErrorMessage(error)}`);
}
}
/**
* Update an existing cycle
*/
async updateCycle(
cycleId: string,
data: {
name?: string;
description?: string;
startsAt?: string;
endsAt?: string;
}
) {
try {
const updateData: Record<string, unknown> = {};
if (data.name !== undefined) updateData.name = data.name;
if (data.description !== undefined) updateData.description = data.description;
if (data.startsAt !== undefined) updateData.startsAt = new Date(data.startsAt);
if (data.endsAt !== undefined) updateData.endsAt = new Date(data.endsAt);
const cyclePayload = await this.client.updateCycle(cycleId, updateData);
const cycle = await cyclePayload.cycle;
if (!cycle) {
throw new Error('Cycle update failed: No cycle returned');
}
return await this.formatCycle(cycle);
} catch (error) {
throw new Error(`Failed to update cycle: ${this.getErrorMessage(error)}`);
}
}
/**
* Archive a cycle
*/
async archiveCycle(cycleId: string) {
try {
const result = await this.client.archiveCycle(cycleId);
const success = await result.success;
if (!success) {
throw new Error('Cycle archive failed');
}
return { success: true, message: 'Cycle archived successfully' };
} catch (error) {
throw new Error(`Failed to archive cycle: ${this.getErrorMessage(error)}`);
}
}
/**
* Unarchive a cycle (restore from archive)
*/
async unarchiveCycle(cycleId: string) {
try {
// Linear doesn't provide a direct unarchive for cycles via SDK
// We need to update the cycle to set archivedAt to null
// This is done by updating the cycle with empty data which effectively unarchives it
const cyclePayload = await this.client.updateCycle(cycleId, {
// Setting the cycle back to active by updating it
// The Linear API will automatically unarchive when you update an archived cycle
});
const success = await cyclePayload.success;
if (!success) {
throw new Error('Cycle unarchive failed');
}
return { success: true, message: 'Cycle unarchived successfully' };
} catch (error) {
throw new Error(`Failed to unarchive cycle: ${this.getErrorMessage(error)}`);
}
}
/**
* List all users
*/
async listUsers(includeDisabled = false) {
try {
const users = await this.client.users({
includeDisabled,
});
const nodes = await users.nodes;
return await this.formatUsers(nodes);
} catch (error) {
throw new Error(`Failed to list users: ${this.getErrorMessage(error)}`);
}
}
/**
* List labels with optional team filter
*/
async listLabels(teamId?: string) {
try {
const filter: Record<string, unknown> = {};
if (teamId) filter.team = { id: { eq: teamId } };
const labels = await this.client.issueLabels({
filter,
});
const nodes = await labels.nodes;
return await this.formatLabelsWithHierarchy(nodes);
} catch (error) {
throw new Error(`Failed to list labels: ${this.getErrorMessage(error)}`);
}
}
/**
* Create a new label
*/
async createLabel(data: { name: string; teamId: string; color?: string; description?: string }) {
try {
const labelPayload = await this.client.createIssueLabel({
name: data.name,
teamId: data.teamId,
color: data.color,
description: data.description,
});
const label = await labelPayload.issueLabel;
if (!label) {
throw new Error('Label creation failed: No label returned');
}
return await this.formatLabel(label);
} catch (error) {
throw new Error(`Failed to create label: ${this.getErrorMessage(error)}`);
}
}
/**
* Add a comment to an issue
*/
async addComment(issueId: string, body: string) {
try {
const commentPayload = await this.client.createComment({
issueId,
body,
});
const comment = await commentPayload.comment;
if (!comment) {
throw new Error('Comment creation failed: No comment returned');
}
return {
id: comment.id,
body: comment.body,
createdAt: comment.createdAt.toISOString(),
};
} catch (error) {
throw new Error(`Failed to add comment: ${this.getErrorMessage(error)}`);
}
}
/**
* List workflow states for a team
*/
async listWorkflowStates(teamId: string) {
try {
const states = await this.client.workflowStates({
filter: {
team: { id: { eq: teamId } },
},
});
const nodes = await states.nodes;
return await this.formatWorkflowStates(nodes);
} catch (error) {
throw new Error(`Failed to list workflow states: ${this.getErrorMessage(error)}`);
}
}
/**
* Format a single issue
*/
private async formatIssue(issue: Issue) {
const [assignee, state, team, project, parent] = await Promise.all([
issue.assignee,
issue.state,
issue.team,
issue.project,
issue.parent,
]);
// Fetch labels separately - labels() is a function that returns a promise
let labelNodes: IssueLabel[] = [];
try {
const labelsConnection = await issue.labels();
if (labelsConnection && labelsConnection.nodes) {
labelNodes = await labelsConnection.nodes;
}
} catch (error) {
// If labels fail to load, just use empty array
labelNodes = [];
}
return {
id: issue.id,
identifier: issue.identifier,
title: issue.title,
description: issue.description,
priority: issue.priority,
priorityLabel: issue.priorityLabel,
estimate: issue.estimate,
url: issue.url,
createdAt: issue.createdAt.toISOString(),
updatedAt: issue.updatedAt.toISOString(),
dueDate: issue.dueDate,
state: state
? {
id: state.id,
name: state.name,
type: state.type,
color: state.color,
}
: null,
assignee: assignee
? {
id: assignee.id,
name: assignee.name,
email: assignee.email,
}
: null,
team: team
? {
id: team.id,
name: team.name,
key: team.key,
}
: {
id: 'unknown',
name: 'Unknown',
key: 'UNK',
},
project: project
? {
id: project.id,
name: project.name,
}
: null,
labels: labelNodes.map((label) => ({
id: label.id,
name: label.name,
color: label.color,
})),
parent: parent
? {
id: parent.id,
identifier: parent.identifier,
title: parent.title,
}
: null,
};
}
/**
* Format multiple issues
*/
private async formatIssues(issues: Issue[]) {
return await Promise.all(issues.map((issue) => this.formatIssue(issue)));
}
/**
* Format teams
*/
private async formatTeams(teams: Team[]) {
return teams.map((team) => ({
id: team.id,
name: team.name,
key: team.key,
description: team.description,
}));
}
/**
* Format a single team
*/
private async formatTeam(team: Team) {
return {
id: team.id,
name: team.name,
key: team.key,
description: team.description,
icon: team.icon,
color: team.color,
timezone: team.timezone,
private: team.private,
createdAt: team.createdAt.toISOString(),
updatedAt: team.updatedAt.toISOString(),
archivedAt: team.archivedAt ? team.archivedAt.toISOString() : null,
};
}
/**
* Format a single project
*/
private async formatProject(project: Project) {
const [lead, teams] = await Promise.all([project.lead, project.teams()]);
const teamNodes = await teams.nodes;
return {
id: project.id,
name: project.name,
description: project.description,
state: project.state,
progress: project.progress,
priority: project.priority,
color: project.color,
icon: project.icon,
startDate: project.startDate,
targetDate: project.targetDate,
url: project.url,
createdAt: project.createdAt.toISOString(),
updatedAt: project.updatedAt.toISOString(),
lead: lead
? {
id: lead.id,
name: lead.name,
email: lead.email,
}
: null,
teams: teamNodes.map((team) => ({
id: team.id,
name: team.name,
key: team.key,
})),
};
}
/**
* Format projects
*/
private async formatProjects(projects: Project[]) {
return await Promise.all(projects.map((project) => this.formatProject(project)));
}
/**
* Format a single cycle
*/
private async formatCycle(cycle: Cycle) {
const team = await cycle.team;
return {
id: cycle.id,
number: cycle.number,
name: cycle.name,
description: cycle.description,
startsAt: cycle.startsAt.toISOString(),
endsAt: cycle.endsAt.toISOString(),
progress: cycle.progress,
team: team
? {
id: team.id,
name: team.name,
key: team.key,
}
: {
id: 'unknown',
name: 'Unknown',
key: 'UNK',
},
};
}
/**
* Format multiple cycles
*/
private async formatCycles(cycles: Cycle[]) {
return await Promise.all(cycles.map((cycle) => this.formatCycle(cycle)));
}
/**
* Format users
*/
private formatUsers(users: User[]) {
return users.map((user) => ({
id: user.id,
name: user.name,
email: user.email,
displayName: user.displayName,
active: user.active,
admin: user.admin,
}));
}
/**
* Format labels with hierarchy information
*/
private async formatLabelsWithHierarchy(labels: IssueLabel[]) {
const formattedLabels = await Promise.all(
labels.map(async (label) => {
let isParent = false;
let childrenNames: string[] = [];
let parentName: string | null = null;
try {
// Check if this label has children (is a parent)
const children = await label.children();
const childNodes = await children.nodes;
if (childNodes.length > 0) {
isParent = true;
childrenNames = childNodes.map((child) => child.name);
}
// Check if this label has a parent
const parent = await label.parent;
if (parent) {
parentName = parent.name;
}
} catch (error) {
// If we can't get hierarchy info, just continue
}
return {
id: label.id,
name: label.name,
color: label.color,
description: label.description,
isParent,
parentName,
childrenNames: childrenNames.length > 0 ? childrenNames : undefined,
};
})
);
return formattedLabels;
}
/**
* Format a single label
*/
private formatLabel(label: IssueLabel) {
return {
id: label.id,
name: label.name,
color: label.color,
description: label.description,
};
}
/**
* Format workflow states
*/
private formatWorkflowStates(states: WorkflowState[]) {
return states.map((state) => ({
id: state.id,
name: state.name,
type: state.type,
color: state.color,
description: state.description,
position: state.position,
}));
}
/**
* Format a single custom view
*/
private async formatCustomView(view: CustomView) {
const [owner, team] = await Promise.all([view.owner, view.team]);
return {
id: view.id,
name: view.name,
description: view.description,
icon: view.icon,
color: view.color,
shared: view.shared,
filters: view.filters,
createdAt: view.createdAt.toISOString(),
updatedAt: view.updatedAt.toISOString(),
owner: owner
? {
id: owner.id,
name: owner.name,
email: owner.email,
}
: null,
team: team
? {
id: team.id,
name: team.name,
key: team.key,
}
: null,
};
}
/**
* Format multiple custom views
*/
private async formatCustomViews(views: CustomView[]) {
return await Promise.all(views.map((view) => this.formatCustomView(view)));
}
/**
* Filter out parent labels from label IDs
*/
async filterParentLabels(labelIds: string[]): Promise<{
validLabelIds: string[];
parentLabels: Array<{ id: string; name: string }>;
}> {
try {
const parentLabels: Array<{ id: string; name: string }> = [];
const validLabelIds: string[] = [];
// Fetch all labels to check which are parents
for (const labelId of labelIds) {
const label = await this.client.issueLabel(labelId);
if (!label) continue;
// Check if this label is a parent (has children)
const children = await label.children();
const childNodes = await children.nodes;
if (childNodes.length > 0) {
// This is a parent label
parentLabels.push({
id: label.id,
name: label.name,
});
} else {
// This is a valid (non-parent) label
validLabelIds.push(labelId);
}
}
return { validLabelIds, parentLabels };
} catch (error) {
// If validation fails, return original IDs
return { validLabelIds: labelIds, parentLabels: [] };
}
}
/**
* Extract error message from various error types
*/
private getErrorMessage(error: unknown): string {
if (error instanceof Error) {
const message = error.message;
// Enhance parent label error messages
if (message.includes('parent labels') || message.includes('group and cannot be assigned')) {
const labelMatch = message.match(/'([^']+)'/);
const labelName = labelMatch ? labelMatch[1] : 'unknown';
return `The label '${labelName}' is a parent/group label and cannot be assigned directly. Please use one of its child labels instead. Use linear_list_labels to see available labels.`;
}
return message;
}
return String(error);
}
}