diff --git a/src/auth.ts b/src/auth.ts index 5b5f95e..62f2abf 100644 --- a/src/auth.ts +++ b/src/auth.ts @@ -1,33 +1,33 @@ -import { LinearClient } from '@linear/sdk'; -import { McpError, ErrorCode } from '@modelcontextprotocol/sdk/types.js'; +import { LinearClient } from "@linear/sdk"; +import { McpError, ErrorCode } from "@modelcontextprotocol/sdk/types.js"; /** * Solution Attempts: - * + * * 1. OAuth Flow with Browser (Initial Attempt) * - Used browser redirect and local server for OAuth flow * - Issues: Browser extensions interfering, CORS issues * - Status: Failed - Browser extensions and CORS blocking requests - * + * * 2. Personal Access Token (Current Attempt) * - Using PAT for initial integration tests * - Simpler approach without browser interaction * - Status: Working - Successfully authenticates and makes API calls - * + * * 3. Direct OAuth Token Exchange (Current Attempt) * - Using form-urlencoded content type as required by Linear * - Status: In Progress - Testing token exchange */ export interface OAuthConfig { - type: 'oauth'; + type: "oauth"; clientId: string; clientSecret: string; redirectUri: string; } export interface PersonalAccessTokenConfig { - type: 'pat'; + type: "pat"; accessToken: string; } @@ -40,8 +40,8 @@ export interface TokenData { } export class LinearAuth { - private static readonly OAUTH_AUTH_URL = 'https://linear.app/oauth'; - private static readonly OAUTH_TOKEN_URL = 'https://api.linear.app'; + private static readonly OAUTH_AUTH_URL = "https://linear.app/oauth"; + private static readonly OAUTH_TOKEN_URL = "https://api.linear.app"; private config?: AuthConfig; private tokenData?: TokenData; private linearClient?: LinearClient; @@ -49,61 +49,72 @@ export class LinearAuth { constructor() {} public getAuthorizationUrl(): string { - if (!this.config || this.config.type !== 'oauth') { + if (!this.config || this.config.type !== "oauth") { throw new McpError( ErrorCode.InvalidRequest, - 'OAuth config not initialized' + "OAuth config not initialized" ); } const params = new URLSearchParams({ client_id: this.config.clientId, redirect_uri: this.config.redirectUri, - response_type: 'code', - scope: 'read,write,issues:create,offline_access', - actor: 'application', // Enable OAuth Actor Authorization + response_type: "code", + scope: "read,write,issues:create,offline_access", + actor: "application", // Enable OAuth Actor Authorization state: this.generateState(), - access_type: 'offline', + access_type: "offline", }); return `${LinearAuth.OAUTH_AUTH_URL}/authorize?${params.toString()}`; } public async handleCallback(code: string): Promise { - if (!this.config || this.config.type !== 'oauth') { + if (!this.config || this.config.type !== "oauth") { throw new McpError( ErrorCode.InvalidRequest, - 'OAuth config not initialized' + "OAuth config not initialized" ); } try { const params = new URLSearchParams({ - grant_type: 'authorization_code', + grant_type: "authorization_code", client_id: this.config.clientId, client_secret: this.config.clientSecret, redirect_uri: this.config.redirectUri, code, - access_type: 'offline' + access_type: "offline", }); - const response = await fetch(`${LinearAuth.OAUTH_TOKEN_URL}/oauth/token`, { - method: 'POST', - headers: { - 'Content-Type': 'application/x-www-form-urlencoded', - 'Accept': 'application/json' - }, - body: params.toString() - }); + const response = await fetch( + `${LinearAuth.OAUTH_TOKEN_URL}/oauth/token`, + { + method: "POST", + headers: { + "Content-Type": "application/x-www-form-urlencoded", + Accept: "application/json", + }, + body: params.toString(), + } + ); if (!response.ok) { const errorText = await response.text(); - throw new Error(`Token request failed: ${response.statusText}. Response: ${errorText}`); + throw new Error( + `Token request failed: ${response.statusText}. Response: ${errorText}` + ); } const data = await response.json(); + + // Ensure the access token doesn't have a "Bearer" prefix + const accessToken = data.access_token.startsWith("Bearer ") + ? data.access_token.substring(7) + : data.access_token; + this.tokenData = { - accessToken: data.access_token, + accessToken: accessToken, refreshToken: data.refresh_token, expiresAt: Date.now() + data.expires_in * 1000, }; @@ -114,44 +125,61 @@ export class LinearAuth { } catch (error) { throw new McpError( ErrorCode.InternalError, - `OAuth token exchange failed: ${error instanceof Error ? error.message : 'Unknown error'}` + `OAuth token exchange failed: ${ + error instanceof Error ? error.message : "Unknown error" + }` ); } } public async refreshAccessToken(): Promise { - if (!this.config || this.config.type !== 'oauth' || !this.tokenData?.refreshToken) { + if ( + !this.config || + this.config.type !== "oauth" || + !this.tokenData?.refreshToken + ) { throw new McpError( ErrorCode.InvalidRequest, - 'OAuth not initialized or no refresh token available' + "OAuth not initialized or no refresh token available" ); } try { const params = new URLSearchParams({ - grant_type: 'refresh_token', + grant_type: "refresh_token", client_id: this.config.clientId, client_secret: this.config.clientSecret, - refresh_token: this.tokenData.refreshToken + refresh_token: this.tokenData.refreshToken, }); - const response = await fetch(`${LinearAuth.OAUTH_TOKEN_URL}/oauth/token`, { - method: 'POST', - headers: { - 'Content-Type': 'application/x-www-form-urlencoded', - 'Accept': 'application/json' - }, - body: params.toString() - }); + const response = await fetch( + `${LinearAuth.OAUTH_TOKEN_URL}/oauth/token`, + { + method: "POST", + headers: { + "Content-Type": "application/x-www-form-urlencoded", + Accept: "application/json", + }, + body: params.toString(), + } + ); if (!response.ok) { const errorText = await response.text(); - throw new Error(`Token refresh failed: ${response.statusText}. Response: ${errorText}`); + throw new Error( + `Token refresh failed: ${response.statusText}. Response: ${errorText}` + ); } const data = await response.json(); + + // Ensure the access token doesn't have a "Bearer" prefix + const accessToken = data.access_token.startsWith("Bearer ") + ? data.access_token.substring(7) + : data.access_token; + this.tokenData = { - accessToken: data.access_token, + accessToken: accessToken, refreshToken: data.refresh_token, expiresAt: Date.now() + data.expires_in * 1000, }; @@ -162,28 +190,32 @@ export class LinearAuth { } catch (error) { throw new McpError( ErrorCode.InternalError, - `Token refresh failed: ${error instanceof Error ? error.message : 'Unknown error'}` + `Token refresh failed: ${ + error instanceof Error ? error.message : "Unknown error" + }` ); } } public initialize(config: AuthConfig): void { - if (config.type === 'pat') { + if (config.type === "pat") { // Personal Access Token flow this.tokenData = { accessToken: config.accessToken, - refreshToken: '', // Not needed for PAT + refreshToken: "", // Not needed for PAT expiresAt: Number.MAX_SAFE_INTEGER, // PATs don't expire }; + + // Use apiKey instead of accessToken for PAT authentication this.linearClient = new LinearClient({ - accessToken: config.accessToken, + apiKey: config.accessToken, }); } else { // OAuth flow if (!config.clientId || !config.clientSecret || !config.redirectUri) { throw new McpError( ErrorCode.InvalidParams, - 'Missing required OAuth parameters: clientId, clientSecret, redirectUri' + "Missing required OAuth parameters: clientId, clientSecret, redirectUri" ); } this.config = config; @@ -194,7 +226,7 @@ export class LinearAuth { if (!this.linearClient) { throw new McpError( ErrorCode.InvalidRequest, - 'Linear client not initialized' + "Linear client not initialized" ); } return this.linearClient; @@ -205,16 +237,26 @@ export class LinearAuth { } public needsTokenRefresh(): boolean { - if (!this.tokenData || !this.config || this.config.type === 'pat') return false; + if (!this.tokenData || !this.config || this.config.type === "pat") + return false; return Date.now() >= this.tokenData.expiresAt - 300000; // Refresh 5 minutes before expiry } // For testing purposes public setTokenData(tokenData: TokenData): void { this.tokenData = tokenData; - this.linearClient = new LinearClient({ - accessToken: tokenData.accessToken, - }); + + // Use apiKey for PAT authentication if this is a PAT + if (this.config?.type === "pat") { + this.linearClient = new LinearClient({ + apiKey: tokenData.accessToken, + }); + } else { + // Otherwise use accessToken for OAuth + this.linearClient = new LinearClient({ + accessToken: tokenData.accessToken, + }); + } } private generateState(): string { diff --git a/src/features/issues/handlers/issue.handler.ts b/src/features/issues/handlers/issue.handler.ts index a69d01d..272d6c3 100644 --- a/src/features/issues/handlers/issue.handler.ts +++ b/src/features/issues/handlers/issue.handler.ts @@ -1,7 +1,7 @@ -import { BaseHandler } from '../../../core/handlers/base.handler.js'; -import { BaseToolResponse } from '../../../core/interfaces/tool-handler.interface.js'; -import { LinearAuth } from '../../../auth.js'; -import { LinearGraphQLClient } from '../../../graphql/client.js'; +import { BaseHandler } from "../../../core/handlers/base.handler.js"; +import { BaseToolResponse } from "../../../core/interfaces/tool-handler.interface.js"; +import { LinearAuth } from "../../../auth.js"; +import { LinearGraphQLClient } from "../../../graphql/client.js"; import { IssueHandlerMethods, CreateIssueInput, @@ -15,8 +15,9 @@ import { UpdateIssuesResponse, SearchIssuesResponse, DeleteIssueResponse, - Issue -} from '../types/issue.types.js'; + Issue, +} from "../types/issue.types.js"; +import { TeamState } from "../../teams/types/team.types.js"; /** * Handler for issue-related operations. @@ -33,25 +34,25 @@ export class IssueHandler extends BaseHandler implements IssueHandlerMethods { async handleCreateIssue(args: CreateIssueInput): Promise { try { const client = this.verifyAuth(); - this.validateRequiredParams(args, ['title', 'description', 'teamId']); + this.validateRequiredParams(args, ["title", "description", "teamId"]); - const result = await client.createIssue(args) as CreateIssueResponse; + const result = (await client.createIssue(args)) as CreateIssueResponse; if (!result.issueCreate.success || !result.issueCreate.issue) { - throw new Error('Failed to create issue'); + throw new Error("Failed to create issue"); } const issue = result.issueCreate.issue; return this.createResponse( `Successfully created issue\n` + - `Issue: ${issue.identifier}\n` + - `Title: ${issue.title}\n` + - `URL: ${issue.url}\n` + - `Project: ${issue.project ? issue.project.name : 'None'}` + `Issue: ${issue.identifier}\n` + + `Title: ${issue.title}\n` + + `URL: ${issue.url}\n` + + `Project: ${issue.project ? issue.project.name : "None"}` ); } catch (error) { - this.handleError(error, 'create issue'); + this.handleError(error, "create issue"); } } @@ -61,54 +62,103 @@ export class IssueHandler extends BaseHandler implements IssueHandlerMethods { async handleCreateIssues(args: CreateIssuesInput): Promise { try { const client = this.verifyAuth(); - this.validateRequiredParams(args, ['issues']); + this.validateRequiredParams(args, ["issues"]); if (!Array.isArray(args.issues)) { - throw new Error('Issues parameter must be an array'); + throw new Error("Issues parameter must be an array"); } - const result = await client.createIssues(args.issues) as CreateIssuesResponse; + const result = (await client.createIssues( + args.issues + )) as CreateIssuesResponse; if (!result.issueCreate.success) { - throw new Error('Failed to create issues'); + throw new Error("Failed to create issues"); } const createdIssues = result.issueCreate.issues as Issue[]; return this.createResponse( `Successfully created ${createdIssues.length} issues:\n` + - createdIssues.map(issue => - `- ${issue.identifier}: ${issue.title}\n URL: ${issue.url}` - ).join('\n') + createdIssues + .map( + (issue) => + `- ${issue.identifier}: ${issue.title}\n URL: ${issue.url}` + ) + .join("\n") ); } catch (error) { - this.handleError(error, 'create issues'); + this.handleError(error, "create issues"); } } /** * Updates multiple issues in bulk. */ - async handleBulkUpdateIssues(args: BulkUpdateIssuesInput): Promise { + async handleBulkUpdateIssues( + args: BulkUpdateIssuesInput + ): Promise { try { const client = this.verifyAuth(); - this.validateRequiredParams(args, ['issueIds', 'update']); + this.validateRequiredParams(args, ["issueIds", "update"]); if (!Array.isArray(args.issueIds)) { - throw new Error('IssueIds parameter must be an array'); + throw new Error("IssueIds parameter must be an array"); } - const result = await client.updateIssues(args.issueIds, args.update) as UpdateIssuesResponse; + // Handle state name instead of state ID + if ( + args.update.stateId && + typeof args.update.stateId === "string" && + !args.update.stateId.match( + /^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/i + ) + ) { + // This looks like a state name, not a UUID + const stateName = args.update.stateId.toLowerCase(); - if (!result.issueUpdate.success) { - throw new Error('Failed to update issues'); + // Get all teams to find the state + const teamsResponse = await client.getTeams(); + const teams = teamsResponse.teams.nodes; + + let stateId: string | undefined; + + // Search through all teams and their states to find a matching state name + for (const team of teams) { + const matchingState = team.states.find( + (state: TeamState) => state.name.toLowerCase() === stateName + ); + + if (matchingState) { + stateId = matchingState.id; + break; + } + } + + if (!stateId) { + throw new Error( + `Could not find state with name: ${args.update.stateId}` + ); + } + + // Replace the state name with the state ID + args.update.stateId = stateId; } - const updatedCount = result.issueUpdate.issues.length; + const result = (await client.updateIssues( + args.issueIds, + args.update + )) as UpdateIssuesResponse; - return this.createResponse(`Successfully updated ${updatedCount} issues`); + if (!result.issueUpdate.success) { + throw new Error("Failed to update issues"); + } + + return this.createResponse( + `Successfully updated ${args.issueIds.length} issues` + ); } catch (error) { - this.handleError(error, 'update issues'); + this.handleError(error, "update issues"); } } @@ -118,12 +168,21 @@ export class IssueHandler extends BaseHandler implements IssueHandlerMethods { async handleSearchIssues(args: SearchIssuesInput): Promise { try { const client = this.verifyAuth(); - const filter: Record = {}; - - if (args.query) { + + // Check if the query looks like an issue identifier (e.g., EXE-5143) + if (args.query && /^[A-Z]+-\d+$/.test(args.query.trim())) { + // If it's an issue identifier, parse the team key and issue number + const [teamKey, issueNumber] = args.query.trim().split("-"); + + // Use team.key and number filters instead of identifier + filter.team = { key: { eq: teamKey } }; + filter.number = { eq: parseInt(issueNumber, 10) }; + } else if (args.query) { + // Otherwise use it as a search term filter.search = args.query; } + if (args.filter?.project?.id?.eq) { filter.project = { id: { eq: args.filter.project.id.eq } }; } @@ -136,20 +195,20 @@ export class IssueHandler extends BaseHandler implements IssueHandlerMethods { if (args.states) { filter.state = { name: { in: args.states } }; } - if (typeof args.priority === 'number') { + if (typeof args.priority === "number") { filter.priority = { eq: args.priority }; } - const result = await client.searchIssues( - filter, + const result = (await client.searchIssues( + filter as SearchIssuesInput["filter"], args.first || 50, args.after, - args.orderBy || 'updatedAt' - ) as SearchIssuesResponse; + args.orderBy || "updatedAt" + )) as SearchIssuesResponse; return this.createJsonResponse(result); } catch (error) { - this.handleError(error, 'search issues'); + this.handleError(error, "search issues"); } } @@ -159,17 +218,17 @@ export class IssueHandler extends BaseHandler implements IssueHandlerMethods { async handleDeleteIssue(args: DeleteIssueInput): Promise { try { const client = this.verifyAuth(); - this.validateRequiredParams(args, ['id']); + this.validateRequiredParams(args, ["id"]); - const result = await client.deleteIssue(args.id) as DeleteIssueResponse; + const result = (await client.deleteIssue(args.id)) as DeleteIssueResponse; if (!result.issueDelete.success) { - throw new Error('Failed to delete issue'); + throw new Error("Failed to delete issue"); } return this.createResponse(`Successfully deleted issue ${args.id}`); } catch (error) { - this.handleError(error, 'delete issue'); + this.handleError(error, "delete issue"); } } @@ -179,23 +238,25 @@ export class IssueHandler extends BaseHandler implements IssueHandlerMethods { async handleDeleteIssues(args: DeleteIssuesInput): Promise { try { const client = this.verifyAuth(); - this.validateRequiredParams(args, ['ids']); + this.validateRequiredParams(args, ["ids"]); if (!Array.isArray(args.ids)) { - throw new Error('Ids parameter must be an array'); + throw new Error("Ids parameter must be an array"); } - const result = await client.deleteIssues(args.ids) as DeleteIssueResponse; + const result = (await client.deleteIssues( + args.ids + )) as DeleteIssueResponse; if (!result.issueDelete.success) { - throw new Error('Failed to delete issues'); + throw new Error("Failed to delete issues"); } return this.createResponse( - `Successfully deleted ${args.ids.length} issues: ${args.ids.join(', ')}` + `Successfully deleted ${args.ids.length} issues: ${args.ids.join(", ")}` ); } catch (error) { - this.handleError(error, 'delete issues'); + this.handleError(error, "delete issues"); } } } diff --git a/src/features/issues/types/issue.types.ts b/src/features/issues/types/issue.types.ts index cac4e12..dd07a09 100644 --- a/src/features/issues/types/issue.types.ts +++ b/src/features/issues/types/issue.types.ts @@ -1,4 +1,4 @@ -import { BaseToolResponse } from '../../../core/interfaces/tool-handler.interface.js'; +import { BaseToolResponse } from "../../../core/interfaces/tool-handler.interface.js"; /** * Input types for issue operations @@ -39,6 +39,18 @@ export interface SearchIssuesInput { eq?: string; }; }; + team?: { + key?: { + eq?: string; + }; + id?: { + in?: string[]; + }; + }; + number?: { + eq?: number; + }; + search?: string; }; teamIds?: string[]; assigneeIds?: string[]; @@ -96,7 +108,7 @@ export interface IssueBatchResponse { export interface UpdateIssuesResponse { issueUpdate: { success: boolean; - issues: Issue[]; + issue: Issue; }; } @@ -123,7 +135,9 @@ export interface DeleteIssueResponse { export interface IssueHandlerMethods { handleCreateIssue(args: CreateIssueInput): Promise; handleCreateIssues(args: CreateIssuesInput): Promise; - handleBulkUpdateIssues(args: BulkUpdateIssuesInput): Promise; + handleBulkUpdateIssues( + args: BulkUpdateIssuesInput + ): Promise; handleSearchIssues(args: SearchIssuesInput): Promise; handleDeleteIssue(args: DeleteIssueInput): Promise; handleDeleteIssues(args: DeleteIssuesInput): Promise; diff --git a/src/graphql/client.ts b/src/graphql/client.ts index 4e5152f..9ff2ad2 100644 --- a/src/graphql/client.ts +++ b/src/graphql/client.ts @@ -1,7 +1,7 @@ -import { LinearClient } from '@linear/sdk'; -import { DocumentNode } from 'graphql'; -import { - CreateIssueInput, +import { LinearClient } from "@linear/sdk"; +import { DocumentNode } from "graphql"; +import { + CreateIssueInput, CreateIssueResponse, CreateIssuesResponse, UpdateIssueInput, @@ -10,21 +10,19 @@ import { SearchIssuesResponse, DeleteIssueResponse, Issue, - IssueBatchResponse -} from '../features/issues/types/issue.types.js'; + IssueBatchResponse, +} from "../features/issues/types/issue.types.js"; import { ProjectInput, ProjectResponse, - SearchProjectsResponse -} from '../features/projects/types/project.types.js'; + SearchProjectsResponse, +} from "../features/projects/types/project.types.js"; import { TeamResponse, LabelInput, - LabelResponse -} from '../features/teams/types/team.types.js'; -import { - UserResponse -} from '../features/users/types/user.types.js'; + LabelResponse, +} from "../features/teams/types/team.types.js"; +import { UserResponse } from "../features/users/types/user.types.js"; export class LinearGraphQLClient { private linearClient: LinearClient; @@ -40,7 +38,7 @@ export class LinearGraphQLClient { const graphQLClient = this.linearClient.client; try { const response = await graphQLClient.rawRequest( - document.loc?.source.body || '', + document.loc?.source.body || "", variables ); return response.data as T; @@ -54,86 +52,119 @@ export class LinearGraphQLClient { // Create single issue async createIssue(input: CreateIssueInput): Promise { - const { CREATE_ISSUES_MUTATION } = await import('./mutations.js'); - return this.execute(CREATE_ISSUES_MUTATION, { input: [input] }); + const { CREATE_ISSUES_MUTATION } = await import("./mutations.js"); + return this.execute(CREATE_ISSUES_MUTATION, { + input: [input], + }); } // Create multiple issues - async createIssues(issues: CreateIssueInput[]): Promise { - const { CREATE_ISSUES_MUTATION } = await import('./mutations.js'); - return this.execute(CREATE_ISSUES_MUTATION, { input: issues }); + async createIssues( + issues: CreateIssueInput[] + ): Promise { + const { CREATE_ISSUES_MUTATION } = await import("./mutations.js"); + return this.execute(CREATE_ISSUES_MUTATION, { + input: issues, + }); } // Create a project async createProject(input: ProjectInput): Promise { - const { CREATE_PROJECT } = await import('./mutations.js'); + const { CREATE_PROJECT } = await import("./mutations.js"); return this.execute(CREATE_PROJECT, { input }); } // Create batch of issues - async createBatchIssues(issues: CreateIssueInput[]): Promise { - const { CREATE_BATCH_ISSUES } = await import('./mutations.js'); + async createBatchIssues( + issues: CreateIssueInput[] + ): Promise { + const { CREATE_BATCH_ISSUES } = await import("./mutations.js"); return this.execute(CREATE_BATCH_ISSUES, { - input: { issues } + input: { issues }, }); } // Helper method to create a project with associated issues - async createProjectWithIssues(projectInput: ProjectInput, issues: CreateIssueInput[]): Promise { + async createProjectWithIssues( + projectInput: ProjectInput, + issues: CreateIssueInput[] + ): Promise { // Create project first const projectResult = await this.createProject(projectInput); - + if (!projectResult.projectCreate.success) { - throw new Error('Failed to create project'); + throw new Error("Failed to create project"); } // Then create issues with project ID - const issuesWithProject = issues.map(issue => ({ + const issuesWithProject = issues.map((issue) => ({ ...issue, - projectId: projectResult.projectCreate.project.id + projectId: projectResult.projectCreate.project.id, })); const issuesResult = await this.createBatchIssues(issuesWithProject); if (!issuesResult.issueBatchCreate.success) { - throw new Error('Failed to create issues'); + throw new Error("Failed to create issues"); } return { projectCreate: projectResult.projectCreate, - issueBatchCreate: issuesResult.issueBatchCreate + issueBatchCreate: issuesResult.issueBatchCreate, }; } // Update a single issue - async updateIssue(id: string, input: UpdateIssueInput): Promise { - const { UPDATE_ISSUES_MUTATION } = await import('./mutations.js'); + async updateIssue( + id: string, + input: UpdateIssueInput + ): Promise { + const { UPDATE_ISSUES_MUTATION } = await import("./mutations.js"); return this.execute(UPDATE_ISSUES_MUTATION, { - ids: [id], + id, input, }); } - // Bulk update issues - async updateIssues(ids: string[], input: UpdateIssueInput): Promise { - const { UPDATE_ISSUES_MUTATION } = await import('./mutations.js'); - return this.execute(UPDATE_ISSUES_MUTATION, { ids, input }); + // Bulk update issues - now calls updateIssue for each ID + async updateIssues( + ids: string[], + input: UpdateIssueInput + ): Promise { + // Get the first ID to update + const firstId = ids[0]; + if (!firstId) { + throw new Error("No issue IDs provided for update"); + } + + // Update the first issue + const result = await this.updateIssue(firstId, input); + + // If there are more IDs, update them sequentially + if (ids.length > 1) { + for (let i = 1; i < ids.length; i++) { + await this.updateIssue(ids[i], input); + } + } + + // Return the result from the first update + return result; } // Create multiple labels async createIssueLabels(labels: LabelInput[]): Promise { - const { CREATE_ISSUE_LABELS } = await import('./mutations.js'); + const { CREATE_ISSUE_LABELS } = await import("./mutations.js"); return this.execute(CREATE_ISSUE_LABELS, { labels }); } // Search issues with pagination async searchIssues( - filter: SearchIssuesInput['filter'], - first: number = 50, - after?: string, + filter: SearchIssuesInput["filter"], + first: number = 50, + after?: string, orderBy: string = "updatedAt" ): Promise { - const { SEARCH_ISSUES_QUERY } = await import('./queries.js'); + const { SEARCH_ISSUES_QUERY } = await import("./queries.js"); return this.execute(SEARCH_ISSUES_QUERY, { filter, first, @@ -144,37 +175,43 @@ export class LinearGraphQLClient { // Get teams with their states and labels async getTeams(): Promise { - const { GET_TEAMS_QUERY } = await import('./queries.js'); + const { GET_TEAMS_QUERY } = await import("./queries.js"); return this.execute(GET_TEAMS_QUERY); } // Get current user info async getCurrentUser(): Promise { - const { GET_USER_QUERY } = await import('./queries.js'); + const { GET_USER_QUERY } = await import("./queries.js"); return this.execute(GET_USER_QUERY); } // Get project info async getProject(id: string): Promise { - const { GET_PROJECT_QUERY } = await import('./queries.js'); + const { GET_PROJECT_QUERY } = await import("./queries.js"); return this.execute(GET_PROJECT_QUERY, { id }); } // Search projects - async searchProjects(filter: { name?: { eq: string } }): Promise { - const { SEARCH_PROJECTS_QUERY } = await import('./queries.js'); - return this.execute(SEARCH_PROJECTS_QUERY, { filter }); + async searchProjects(filter: { + name?: { eq: string }; + }): Promise { + const { SEARCH_PROJECTS_QUERY } = await import("./queries.js"); + return this.execute(SEARCH_PROJECTS_QUERY, { + filter, + }); } // Delete a single issue async deleteIssue(id: string): Promise { - const { DELETE_ISSUES_MUTATION } = await import('./mutations.js'); - return this.execute(DELETE_ISSUES_MUTATION, { ids: [id] }); + const { DELETE_ISSUES_MUTATION } = await import("./mutations.js"); + return this.execute(DELETE_ISSUES_MUTATION, { + ids: [id], + }); } // Delete multiple issues async deleteIssues(ids: string[]): Promise { - const { DELETE_ISSUES_MUTATION } = await import('./mutations.js'); + const { DELETE_ISSUES_MUTATION } = await import("./mutations.js"); return this.execute(DELETE_ISSUES_MUTATION, { ids }); } } diff --git a/src/graphql/mutations.ts b/src/graphql/mutations.ts index 851d4ea..242b1db 100644 --- a/src/graphql/mutations.ts +++ b/src/graphql/mutations.ts @@ -1,4 +1,4 @@ -import { gql } from 'graphql-tag'; +import { gql } from "graphql-tag"; export const CREATE_ISSUES_MUTATION = gql` mutation CreateIssues($input: [IssueCreateInput!]!) { @@ -52,10 +52,10 @@ export const CREATE_BATCH_ISSUES = gql` `; export const UPDATE_ISSUES_MUTATION = gql` - mutation UpdateIssues($ids: [String!]!, $input: IssueUpdateInput!) { - issueUpdate(ids: $ids, input: $input) { + mutation UpdateIssues($id: String!, $input: IssueUpdateInput!) { + issueUpdate(id: $id, input: $input) { success - issues { + issue { id identifier title diff --git a/src/graphql/queries.ts b/src/graphql/queries.ts index 8f55426..945efd0 100644 --- a/src/graphql/queries.ts +++ b/src/graphql/queries.ts @@ -1,4 +1,4 @@ -import { gql } from 'graphql-tag'; +import { gql } from "graphql-tag"; export const SEARCH_ISSUES_QUERY = gql` query SearchIssues( @@ -7,12 +7,7 @@ export const SEARCH_ISSUES_QUERY = gql` $after: String $orderBy: PaginationOrderBy ) { - issues( - filter: $filter - first: $first - after: $after - orderBy: $orderBy - ) { + issues(filter: $filter, first: $first, after: $after, orderBy: $orderBy) { pageInfo { hasNextPage endCursor @@ -38,11 +33,11 @@ export const SEARCH_ISSUES_QUERY = gql` id name key - }, + } project { id name - }, + } priority labels { nodes {