From eb82898e600229b33438efd42a0d01e36b350741 Mon Sep 17 00:00:00 2001 From: Shivakar Vulli Date: Fri, 28 Mar 2025 22:41:44 -0400 Subject: [PATCH] Fix Integration issues (#1) * Fix auth using PAT * fix: update createIssue to use single input instead of array * feat: add updateIssue functionality for single issue updates * feat: enhance issue creation with input validation for estimate and priority * feat: add estimate field to toolSchemas for issue estimation points --- src/__tests__/graphql-client.test.ts | 144 +++++++++++++++++- src/auth.ts | 2 +- src/core/types/tool.types.ts | 15 ++ src/features/issues/handlers/issue.handler.ts | 74 +++++++-- src/features/issues/types/issue.types.ts | 1 + src/graphql/client.ts | 10 +- src/graphql/mutations.ts | 39 +++++ 7 files changed, 264 insertions(+), 21 deletions(-) diff --git a/src/__tests__/graphql-client.test.ts b/src/__tests__/graphql-client.test.ts index 3cd00ce..937a6b4 100644 --- a/src/__tests__/graphql-client.test.ts +++ b/src/__tests__/graphql-client.test.ts @@ -53,7 +53,7 @@ describe('LinearGraphQLClient', () => { }); describe('searchIssues', () => { - it('should successfully search issues', async () => { + it('should successfully search issues with project filter', async () => { const mockResponse = { data: { issues: { @@ -75,7 +75,7 @@ describe('LinearGraphQLClient', () => { mockRawRequest.mockResolvedValueOnce(mockResponse); - const searchInput: SearchIssuesInput = { + const searchInput = { filter: { project: { id: { @@ -93,12 +93,108 @@ describe('LinearGraphQLClient', () => { expect(result).toEqual(mockResponse.data); expect(mockRawRequest).toHaveBeenCalled(); + expect(mockRawRequest).toHaveBeenCalledWith( + expect.any(String), + expect.objectContaining({ + filter: searchInput.filter + }) + ); + }); + + it('should successfully search issues with text query', async () => { + const mockResponse = { + data: { + issues: { + pageInfo: { + hasNextPage: false, + endCursor: null + }, + nodes: [ + { + id: 'issue-1', + identifier: 'TEST-1', + title: 'Bug in search feature', + url: 'https://linear.app/test/issue/TEST-1' + } + ] + } + } + }; + + mockRawRequest.mockResolvedValueOnce(mockResponse); + + // This simulates what our handler would create for a text search + const filter: Record = { + or: [ + { title: { containsIgnoreCase: 'search' } }, + { number: { eq: null } } + ] + }; + + const result: SearchIssuesResponse = await graphqlClient.searchIssues( + filter, + 10 + ); + + expect(result).toEqual(mockResponse.data); + expect(mockRawRequest).toHaveBeenCalled(); + expect(mockRawRequest).toHaveBeenCalledWith( + expect.any(String), + expect.objectContaining({ + filter: filter + }) + ); + }); + + it('should successfully search issues with issue identifier', async () => { + const mockResponse = { + data: { + issues: { + pageInfo: { + hasNextPage: false, + endCursor: null + }, + nodes: [ + { + id: 'issue-1', + identifier: 'TEST-123', + title: 'Test Issue 123', + url: 'https://linear.app/test/issue/TEST-123' + } + ] + } + } + }; + + mockRawRequest.mockResolvedValueOnce(mockResponse); + + // This simulates what our handler would create for an identifier search + const filter: Record = { + or: [ + { title: { containsIgnoreCase: 'TEST-123' } }, + { number: { eq: 123 } } + ] + }; + + const result: SearchIssuesResponse = await graphqlClient.searchIssues( + filter, + 10 + ); + + expect(result).toEqual(mockResponse.data); + expect(mockRawRequest).toHaveBeenCalled(); + expect(mockRawRequest).toHaveBeenCalledWith( + expect.any(String), + expect.objectContaining({ + filter: filter + }) + ); }); it('should handle search errors', async () => { mockRawRequest.mockRejectedValueOnce(new Error('Search failed')); - const searchInput: SearchIssuesInput = { + const searchInput = { filter: { project: { id: { @@ -140,11 +236,11 @@ describe('LinearGraphQLClient', () => { const result: CreateIssueResponse = await graphqlClient.createIssue(input); - // Verify single mutation call with array input + // Verify single mutation call with direct input (not array) expect(mockRawRequest).toHaveBeenCalledWith( expect.any(String), expect.objectContaining({ - input: [input] + input: input }) ); @@ -589,4 +685,42 @@ describe('LinearGraphQLClient', () => { ).rejects.toThrow('GraphQL operation failed: Label creation failed'); }); }); + + describe('updateIssue', () => { + it('should update a single issue', async () => { + const mockResponse = { + data: { + issueUpdate: { + success: true, + issue: { + id: 'issue-1', + identifier: 'TEST-1', + title: 'Updated Issue', + url: 'https://linear.app/test/issue/TEST-1', + state: { + name: 'In Progress' + } + } + } + } + }; + + mockRawRequest.mockResolvedValueOnce(mockResponse); + + const id = 'issue-1'; + const updateInput: UpdateIssueInput = { stateId: 'state-2' }; + const result: UpdateIssuesResponse = await graphqlClient.updateIssue(id, updateInput); + + expect(result).toEqual(mockResponse.data); + // Verify single mutation call with direct id (not array) + expect(mockRawRequest).toHaveBeenCalledTimes(1); + expect(mockRawRequest).toHaveBeenCalledWith( + expect.any(String), + expect.objectContaining({ + id, + input: updateInput + }) + ); + }); + }); }); diff --git a/src/auth.ts b/src/auth.ts index 5b5f95e..9a8d4ff 100644 --- a/src/auth.ts +++ b/src/auth.ts @@ -176,7 +176,7 @@ export class LinearAuth { expiresAt: Number.MAX_SAFE_INTEGER, // PATs don't expire }; this.linearClient = new LinearClient({ - accessToken: config.accessToken, + apiKey: config.accessToken, }); } else { // OAuth flow diff --git a/src/core/types/tool.types.ts b/src/core/types/tool.types.ts index b97d288..bcc244f 100644 --- a/src/core/types/tool.types.ts +++ b/src/core/types/tool.types.ts @@ -70,6 +70,11 @@ export const toolSchemas = { description: 'Issue priority (0-4)', optional: true, }, + estimate: { + type: 'number', + description: 'Issue estimate points (typically 1, 2, 3, 5, 8, etc.)', + optional: true, + }, createAsUser: { type: 'string', description: 'Name to display for the created issue', @@ -212,6 +217,11 @@ export const toolSchemas = { description: 'New priority (0-4)', optional: true, }, + estimate: { + type: 'number', + description: 'Issue estimate points (typically 1, 2, 3, 5, 8, etc.)', + optional: true, + }, }, }, }, @@ -387,6 +397,11 @@ export const toolSchemas = { description: 'Project ID', optional: true, }, + estimate: { + type: 'number', + description: 'Issue estimate points (typically 1, 2, 3, 5, 8, etc.)', + optional: true, + }, labelIds: { type: 'array', items: { diff --git a/src/features/issues/handlers/issue.handler.ts b/src/features/issues/handlers/issue.handler.ts index a69d01d..beb89d5 100644 --- a/src/features/issues/handlers/issue.handler.ts +++ b/src/features/issues/handlers/issue.handler.ts @@ -35,7 +35,30 @@ export class IssueHandler extends BaseHandler implements IssueHandlerMethods { const client = this.verifyAuth(); this.validateRequiredParams(args, ['title', 'description', 'teamId']); - const result = await client.createIssue(args) as CreateIssueResponse; + // Process input to ensure correct types + const processedArgs = { ...args }; + + // Convert estimate to integer if present + if (processedArgs.estimate !== undefined) { + processedArgs.estimate = parseInt(String(processedArgs.estimate), 10); + + // If parsing fails, remove the estimate field + if (isNaN(processedArgs.estimate)) { + delete processedArgs.estimate; + } + } + + // Convert priority to integer if present + if (processedArgs.priority !== undefined) { + processedArgs.priority = parseInt(String(processedArgs.priority), 10); + + // If parsing fails or out of range, use default priority + if (isNaN(processedArgs.priority) || processedArgs.priority < 0 || processedArgs.priority > 4) { + processedArgs.priority = 0; + } + } + + const result = await client.createIssue(processedArgs) as CreateIssueResponse; if (!result.issueCreate.success || !result.issueCreate.issue) { throw new Error('Failed to create issue'); @@ -98,15 +121,29 @@ export class IssueHandler extends BaseHandler implements IssueHandlerMethods { throw new Error('IssueIds parameter must be an array'); } - const result = await client.updateIssues(args.issueIds, args.update) as UpdateIssuesResponse; - - if (!result.issueUpdate.success) { - throw new Error('Failed to update issues'); + let result; + + // Handle single issue update vs bulk update differently + if (args.issueIds.length === 1) { + // For a single issue, use updateIssue which uses the correct 'id' parameter + result = await client.updateIssue(args.issueIds[0], args.update) as UpdateIssuesResponse; + + if (!result.issueUpdate.success) { + throw new Error('Failed to update issue'); + } + + return this.createResponse(`Successfully updated issue`); + } else { + // For multiple issues, use updateIssues + result = await client.updateIssues(args.issueIds, args.update) as UpdateIssuesResponse; + + if (!result.issueUpdate.success) { + throw new Error('Failed to update issues'); + } + + const updatedCount = result.issueUpdate.issues.length; + return this.createResponse(`Successfully updated ${updatedCount} issues`); } - - const updatedCount = result.issueUpdate.issues.length; - - return this.createResponse(`Successfully updated ${updatedCount} issues`); } catch (error) { this.handleError(error, 'update issues'); } @@ -122,8 +159,14 @@ export class IssueHandler extends BaseHandler implements IssueHandlerMethods { const filter: Record = {}; if (args.query) { - filter.search = args.query; + // For both identifier and text searches, use the title filter with contains + // This is a workaround since Linear API doesn't directly support identifier filtering + filter.or = [ + { title: { containsIgnoreCase: args.query } }, + { number: { eq: this.extractIssueNumber(args.query) } } + ]; } + if (args.filter?.project?.id?.eq) { filter.project = { id: { eq: args.filter.project.id.eq } }; } @@ -153,6 +196,17 @@ export class IssueHandler extends BaseHandler implements IssueHandlerMethods { } } + /** + * Helper method to extract the issue number from an identifier (e.g., "IDE-11" -> 11) + */ + private extractIssueNumber(query: string): number | null { + const match = query.match(/^[A-Z]+-(\d+)$/); + if (match && match[1]) { + return parseInt(match[1], 10); + } + return null; + } + /** * Deletes a single issue. */ diff --git a/src/features/issues/types/issue.types.ts b/src/features/issues/types/issue.types.ts index cac4e12..2475e8c 100644 --- a/src/features/issues/types/issue.types.ts +++ b/src/features/issues/types/issue.types.ts @@ -11,6 +11,7 @@ export interface CreateIssueInput { assigneeId?: string; priority?: number; projectId?: string; + estimate?: number; } export interface CreateIssuesInput { diff --git a/src/graphql/client.ts b/src/graphql/client.ts index 4e5152f..dcc3e1a 100644 --- a/src/graphql/client.ts +++ b/src/graphql/client.ts @@ -54,8 +54,8 @@ 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_ISSUE_MUTATION } = await import('./mutations.js'); + return this.execute(CREATE_ISSUE_MUTATION, { input }); } // Create multiple issues @@ -107,9 +107,9 @@ export class LinearGraphQLClient { // Update a single issue async updateIssue(id: string, input: UpdateIssueInput): Promise { - const { UPDATE_ISSUES_MUTATION } = await import('./mutations.js'); - return this.execute(UPDATE_ISSUES_MUTATION, { - ids: [id], + const { UPDATE_ISSUE_MUTATION } = await import('./mutations.js'); + return this.execute(UPDATE_ISSUE_MUTATION, { + id, input, }); } diff --git a/src/graphql/mutations.ts b/src/graphql/mutations.ts index 851d4ea..2e19c21 100644 --- a/src/graphql/mutations.ts +++ b/src/graphql/mutations.ts @@ -1,5 +1,27 @@ import { gql } from 'graphql-tag'; +export const CREATE_ISSUE_MUTATION = gql` + mutation CreateIssue($input: IssueCreateInput!) { + issueCreate(input: $input) { + success + issue { + id + identifier + title + url + team { + id + name + } + project { + id + name + } + } + } + } +`; + export const CREATE_ISSUES_MUTATION = gql` mutation CreateIssues($input: [IssueCreateInput!]!) { issueCreate(input: $input) { @@ -51,6 +73,23 @@ export const CREATE_BATCH_ISSUES = gql` } `; +export const UPDATE_ISSUE_MUTATION = gql` + mutation UpdateIssue($id: String!, $input: IssueUpdateInput!) { + issueUpdate(id: $id, input: $input) { + success + issue { + id + identifier + title + url + state { + name + } + } + } + } +`; + export const UPDATE_ISSUES_MUTATION = gql` mutation UpdateIssues($ids: [String!]!, $input: IssueUpdateInput!) { issueUpdate(ids: $ids, input: $input) {