diff --git a/src/__tests__/graphql-client.test.ts b/src/__tests__/graphql-client.test.ts index c780e05..4f6f127 100644 --- a/src/__tests__/graphql-client.test.ts +++ b/src/__tests__/graphql-client.test.ts @@ -54,7 +54,7 @@ describe('LinearGraphQLClient', () => { }); describe('searchIssues', () => { - it('should successfully search issues', async () => { + it('should successfully search issues with project filter', async () => { const mockResponse = { data: { issues: { @@ -76,7 +76,7 @@ describe('LinearGraphQLClient', () => { mockRawRequest.mockResolvedValueOnce(mockResponse); - const searchInput: SearchIssuesInput = { + const searchInput = { filter: { project: { id: { @@ -94,12 +94,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: { @@ -141,7 +237,7 @@ 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({ @@ -590,4 +686,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/core/types/tool.types.ts b/src/core/types/tool.types.ts index 2900314..953c305 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, + }, projectId: { type: 'string', description: 'Project ID', @@ -217,6 +222,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, + }, }, }, }, @@ -402,6 +412,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 9319d31..737ad8b 100644 --- a/src/features/issues/handlers/issue.handler.ts +++ b/src/features/issues/handlers/issue.handler.ts @@ -36,7 +36,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'); @@ -99,15 +122,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'); } @@ -123,8 +160,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 } }; } @@ -154,6 +197,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 e0797b6..07a7b63 100644 --- a/src/graphql/client.ts +++ b/src/graphql/client.ts @@ -56,7 +56,7 @@ export class LinearGraphQLClient { // Create single issue async createIssue(input: CreateIssueInput): Promise { const { CREATE_ISSUE_MUTATION } = await import('./mutations.js'); - return this.execute(CREATE_ISSUE_MUTATION, { input: input }); + return this.execute(CREATE_ISSUE_MUTATION, { input }); } // Create multiple issues @@ -110,9 +110,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 47ba6b5..2e19c21 100644 --- a/src/graphql/mutations.ts +++ b/src/graphql/mutations.ts @@ -1,7 +1,29 @@ import { gql } from 'graphql-tag'; export const CREATE_ISSUE_MUTATION = gql` - mutation CreateIssues($input: IssueCreateInput!) { + 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) { success issue { @@ -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) {