From 45250b51841dd6ca33972b8311b706310bf90fc4 Mon Sep 17 00:00:00 2001 From: Sachin Pande Date: Wed, 17 Sep 2025 13:45:50 +0100 Subject: [PATCH 1/3] refactor: bugsnag tools modularization spec --- .kiro/specs/bugsnag-tools-refactor/design.md | 260 ++++++++++++++++++ .../bugsnag-tools-refactor/requirements.md | 73 +++++ .kiro/specs/bugsnag-tools-refactor/tasks.md | 139 ++++++++++ 3 files changed, 472 insertions(+) create mode 100644 .kiro/specs/bugsnag-tools-refactor/design.md create mode 100644 .kiro/specs/bugsnag-tools-refactor/requirements.md create mode 100644 .kiro/specs/bugsnag-tools-refactor/tasks.md diff --git a/.kiro/specs/bugsnag-tools-refactor/design.md b/.kiro/specs/bugsnag-tools-refactor/design.md new file mode 100644 index 00000000..29472b41 --- /dev/null +++ b/.kiro/specs/bugsnag-tools-refactor/design.md @@ -0,0 +1,260 @@ +# Design Document + +## Overview + +This design outlines the refactoring of the Bugsnag client's monolithic tool registration system into a modular, maintainable architecture. The current implementation has all 10+ tools defined in a single 1200+ line `registerTools` method, making it difficult to maintain, test, and extend. + +The refactored architecture will separate each tool into its own module while maintaining backward compatibility and introducing consistent patterns for tool development. + +## Architecture + +### Current Architecture Issues + +- **Monolithic Design**: All tools are defined in a single large method +- **Tight Coupling**: Tools are tightly coupled to the BugsnagClient class +- **Testing Challenges**: Difficult to test individual tools in isolation +- **Maintenance Overhead**: Changes to one tool risk affecting others +- **Code Duplication**: Similar patterns repeated across tools without abstraction + +### Proposed Architecture + +The new architecture introduces a layered approach: + +``` +BugsnagClient +├── ToolRegistry (manages tool discovery and registration) +├── SharedServices (provides common functionality to tools) +└── Tools/ + ├── ListProjectsTool + ├── GetErrorTool + ├── GetEventDetailsTool + ├── ListProjectErrorsTool + ├── ListProjectEventFiltersTool + ├── UpdateErrorTool + ├── ListBuildsTool + ├── GetBuildTool + ├── ListReleasesTool + ├── GetReleaseTool + └── ListBuildsInReleaseTool +``` + +## Components and Interfaces + +### 1. Base Tool Interface + +All tools will implement a common interface to ensure consistency: + +```typescript +interface BugsnagTool { + readonly name: string; + readonly definition: ToolDefinition; + execute(args: any, context: ToolExecutionContext): Promise; +} + +interface ToolExecutionContext { + services: SharedServices; + getInput: GetInputFunction; +} + +interface ToolDefinition { + title: string; + summary: string; + purpose: string; + useCases: string[]; + parameters: ParameterDefinition[]; + examples: ToolExample[]; + hints: string[]; + outputFormat?: string; +} +``` + +### 2. Shared Services Interface + +Common functionality will be provided through a shared services interface: + +```typescript +interface SharedServices { + // Project management + getProjects(): Promise; + getProject(projectId: string): Promise; + getCurrentProject(): Promise; + getInputProject(projectId?: string): Promise; + + // API clients + getCurrentUserApi(): CurrentUserAPI; + getErrorsApi(): ErrorAPI; + getProjectApi(): ProjectAPI; + + // Caching + getCache(): NodeCache; + + // URL generation + getDashboardUrl(project: Project): Promise; + getErrorUrl(project: Project, errorId: string, queryString?: string): Promise; + + // Configuration + getProjectApiKey(): string | undefined; + hasProjectApiKey(): boolean; +} +``` + +### 3. Tool Registry + +The tool registry will handle automatic discovery and registration of tools: + +```typescript +class ToolRegistry { + private tools: Map = new Map(); + + registerTool(tool: BugsnagTool): void; + discoverTools(): BugsnagTool[]; + registerAllTools(register: RegisterToolsFunction, context: ToolExecutionContext): void; +} +``` + +### 4. Individual Tool Modules + +Each tool will be implemented as a separate class: + +```typescript +// Example: GetErrorTool +export class GetErrorTool implements BugsnagTool { + readonly name = "get_error"; + readonly definition: ToolDefinition = { + title: "Get Error", + summary: "Get full details on an error...", + // ... rest of definition + }; + + async execute(args: any, context: ToolExecutionContext): Promise { + const { services } = context; + const project = await services.getInputProject(args.projectId); + // ... tool implementation + } +} +``` + +## Data Models + +### Tool Categories + +Tools will be organized into logical categories: + +1. **Project Tools**: `ListProjectsTool` +2. **Error Tools**: `GetErrorTool`, `ListProjectErrorsTool`, `UpdateErrorTool` +3. **Event Tools**: `GetEventDetailsTool`, `ListProjectEventFiltersTool` +4. **Build Tools**: `ListBuildsTool`, `GetBuildTool`, `ListBuildsInReleaseTool` +5. **Release Tools**: `ListReleasesTool`, `GetReleaseTool` + +### Parameter Validation + +Each tool will define its parameters using Zod schemas for consistent validation: + +```typescript +interface ParameterDefinition { + name: string; + type: z.ZodType; + required: boolean; + description: string; + examples: string[]; + constraints?: string[]; +} +``` + +## Error Handling + +### Consistent Error Patterns + +All tools will follow consistent error handling patterns: + +1. **Parameter Validation**: Use Zod schemas for input validation +2. **Business Logic Errors**: Throw descriptive errors with context +3. **API Errors**: Wrap and enhance API errors with additional context +4. **Error Propagation**: Allow errors to bubble up with proper error messages + +### Error Types + +```typescript +class BugsnagToolError extends Error { + constructor( + message: string, + public readonly toolName: string, + public readonly cause?: Error + ) { + super(message); + } +} +``` + +## Testing Strategy + +### Unit Testing Approach + +1. **Tool Isolation**: Each tool can be tested independently +2. **Service Mocking**: SharedServices interface can be easily mocked +3. **Parameter Testing**: Validate parameter schemas and edge cases +4. **Error Scenarios**: Test error handling and edge cases + +### Test Structure + +```typescript +describe('GetErrorTool', () => { + let tool: GetErrorTool; + let mockServices: jest.Mocked; + + beforeEach(() => { + mockServices = createMockServices(); + tool = new GetErrorTool(); + }); + + it('should retrieve error details successfully', async () => { + // Test implementation + }); + + it('should handle missing error gracefully', async () => { + // Test error handling + }); +}); +``` + +### Integration Testing + +1. **Tool Registry**: Test tool discovery and registration +2. **End-to-End**: Test complete tool execution flow +3. **Backward Compatibility**: Ensure existing functionality works unchanged + +## Migration Strategy + +### Phase 1: Infrastructure Setup +- Create base interfaces and shared services +- Implement tool registry +- Set up testing framework + +### Phase 2: Tool Migration +- Migrate tools one by one, starting with simpler ones +- Maintain backward compatibility during migration +- Add comprehensive tests for each migrated tool + +### Phase 3: Cleanup and Optimization +- Remove old monolithic implementation +- Optimize shared services +- Add performance monitoring + +### Backward Compatibility + +The refactoring will maintain 100% backward compatibility: +- All existing tool names and parameters remain unchanged +- Response formats stay identical +- Error messages and behavior preserved +- No breaking changes to the public API + +## Benefits of This Design + +1. **Modularity**: Each tool is self-contained and focused +2. **Testability**: Tools can be tested in isolation with mocked dependencies +3. **Maintainability**: Changes to one tool don't affect others +4. **Extensibility**: New tools can be added easily following established patterns +5. **Consistency**: All tools follow the same interface and patterns +6. **Reusability**: Shared services eliminate code duplication +7. **Type Safety**: Strong typing throughout the system +8. **Documentation**: Each tool is self-documenting with clear definitions diff --git a/.kiro/specs/bugsnag-tools-refactor/requirements.md b/.kiro/specs/bugsnag-tools-refactor/requirements.md new file mode 100644 index 00000000..a531efc9 --- /dev/null +++ b/.kiro/specs/bugsnag-tools-refactor/requirements.md @@ -0,0 +1,73 @@ +# Requirements Document + +## Introduction + +The current Bugsnag client implementation has all tools defined in a single large `registerTools` method within the `BugsnagClient` class. This monolithic approach makes the code difficult to maintain, test, and extend. The refactoring will break down the tools into modular, focused components that are easier to understand, test, and maintain while preserving all existing functionality. + +## Requirements + +### Requirement 1 + +**User Story:** As a developer maintaining the Bugsnag MCP server, I want the tools to be organized into separate, focused modules, so that I can easily understand, modify, and test individual tool implementations. + +#### Acceptance Criteria + +1. WHEN the refactoring is complete THEN each tool SHALL be defined in its own separate module +2. WHEN a tool module is created THEN it SHALL contain only the logic specific to that tool +3. WHEN examining a tool module THEN it SHALL be self-contained with clear dependencies +4. WHEN adding a new tool THEN it SHALL require minimal changes to existing code + +### Requirement 2 + +**User Story:** As a developer working on the codebase, I want consistent patterns across all tool implementations, so that I can quickly understand and work with any tool module. + +#### Acceptance Criteria + +1. WHEN implementing tool modules THEN they SHALL follow a consistent interface pattern +2. WHEN a tool is registered THEN it SHALL use the same registration mechanism as other tools +3. WHEN tool parameters are defined THEN they SHALL use consistent validation patterns +4. WHEN error handling is implemented THEN it SHALL follow the same patterns across all tools + +### Requirement 3 + +**User Story:** As a developer testing the Bugsnag client, I want each tool to be independently testable, so that I can write focused unit tests and isolate issues quickly. + +#### Acceptance Criteria + +1. WHEN a tool module is created THEN it SHALL be testable in isolation +2. WHEN testing a tool THEN it SHALL not require the entire BugsnagClient to be instantiated +3. WHEN mocking dependencies THEN it SHALL be straightforward to mock only the required dependencies +4. WHEN running tests THEN each tool's tests SHALL be independent of other tools + +### Requirement 4 + +**User Story:** As a user of the MCP server, I want all existing functionality to work exactly as before, so that the refactoring doesn't break my current workflows. + +#### Acceptance Criteria + +1. WHEN the refactoring is complete THEN all existing tools SHALL function identically to before +2. WHEN calling any tool THEN it SHALL return the same response format as the original implementation +3. WHEN using tool parameters THEN they SHALL accept the same inputs as before +4. WHEN errors occur THEN they SHALL be handled and reported in the same way as before + +### Requirement 5 + +**User Story:** As a developer extending the Bugsnag client, I want a clear and simple way to add new tools, so that I can implement new features without modifying core client logic. + +#### Acceptance Criteria + +1. WHEN adding a new tool THEN it SHALL require creating only a new tool module +2. WHEN registering a new tool THEN it SHALL be automatically discovered and registered +3. WHEN a tool module follows the standard pattern THEN it SHALL integrate seamlessly with the client +4. WHEN the client initializes THEN it SHALL automatically load all available tool modules + +### Requirement 6 + +**User Story:** As a developer maintaining the codebase, I want clear separation of concerns between tool logic and client infrastructure, so that I can modify tools without affecting core client functionality. + +#### Acceptance Criteria + +1. WHEN implementing tool logic THEN it SHALL be separate from client initialization and configuration +2. WHEN the client provides shared services THEN tools SHALL access them through well-defined interfaces +3. WHEN tool implementations change THEN they SHALL not require changes to the core client class +4. WHEN client infrastructure changes THEN it SHALL not require changes to individual tool modules diff --git a/.kiro/specs/bugsnag-tools-refactor/tasks.md b/.kiro/specs/bugsnag-tools-refactor/tasks.md new file mode 100644 index 00000000..b9ca2197 --- /dev/null +++ b/.kiro/specs/bugsnag-tools-refactor/tasks.md @@ -0,0 +1,139 @@ +# Implementation Plan + +- [ ] 1. Create base interfaces and type definitions + - Define the core interfaces for BugsnagTool, SharedServices, ToolExecutionContext, and related types + - Create parameter definition interfaces and error types + - Set up the foundation for the modular architecture + - _Requirements: 2.1, 2.3_ + +- [ ] 2. Implement SharedServices class + - Extract common functionality from BugsnagClient into a SharedServices implementation + - Implement methods for project management, API client access, caching, and URL generation + - Ensure all existing functionality is preserved and accessible through the service interface + - _Requirements: 6.2, 4.1_ + +- [ ] 3. Create ToolRegistry for tool discovery and registration + - Implement the ToolRegistry class to manage tool discovery and registration + - Create methods for registering individual tools and bulk registration + - Implement automatic tool discovery mechanism + - _Requirements: 5.2, 5.3_ + +- [ ] 4. Create base tool implementation utilities + - Implement common utilities for parameter validation using Zod schemas + - Create helper functions for consistent error handling and response formatting + - Set up shared patterns that all tools will use + - _Requirements: 2.2, 2.4_ + +- [ ] 5. Migrate List Projects tool + - Create ListProjectsTool class implementing the BugsnagTool interface + - Extract the existing List Projects tool logic into the new modular structure + - Implement parameter validation and error handling + - Write unit tests for the tool + - _Requirements: 1.1, 3.1, 4.2_ + +- [ ] 6. Migrate Get Error tool + - Create GetErrorTool class with the existing Get Error functionality + - Implement complex parameter handling including conditional projectId requirement + - Handle filter processing and error URL generation + - Write comprehensive unit tests covering all scenarios + - _Requirements: 1.1, 3.1, 4.2_ + +- [ ] 7. Migrate Get Event Details tool + - Create GetEventDetailsTool class for URL-based event retrieval + - Implement URL parsing and project slug resolution logic + - Handle error cases for invalid URLs and missing projects + - Write unit tests for URL parsing and error scenarios + - _Requirements: 1.1, 3.1, 4.2_ + +- [ ] 8. Migrate List Project Errors tool + - Create ListProjectErrorsTool class with filtering and pagination support + - Implement complex filter handling and default parameter logic + - Handle pagination with next URL generation + - Write unit tests for filtering, pagination, and edge cases + - _Requirements: 1.1, 3.1, 4.2_ + +- [ ] 9. Migrate List Project Event Filters tool + - Create ListProjectEventFiltersTool class for discovering available filters + - Implement filter field retrieval and exclusion logic + - Handle caching of filter results + - Write unit tests for filter discovery and caching + - _Requirements: 1.1, 3.1, 4.2_ + +- [ ] 10. Migrate Update Error tool + - Create UpdateErrorTool class for error status management + - Implement operation validation and parameter handling + - Handle different update operations with proper validation + - Write unit tests for all supported operations and validation + - _Requirements: 1.1, 3.1, 4.2_ + +- [ ] 11. Migrate build-related tools +- [ ] 11.1 Migrate List Builds tool + - Create ListBuildsTool class with pagination and stability data + - Implement build listing with stability target integration + - Handle next URL generation for pagination + - Write unit tests for build listing and stability calculations + - _Requirements: 1.1, 3.1, 4.2_ + +- [ ] 11.2 Migrate Get Build tool + - Create GetBuildTool class with caching and stability data + - Implement build retrieval with stability target enhancement + - Handle caching logic for build data + - Write unit tests for build retrieval and caching + - _Requirements: 1.1, 3.1, 4.2_ + +- [ ] 11.3 Migrate List Builds in Release tool + - Create ListBuildsInReleaseTool class for release-specific build listing + - Implement release build association logic with caching + - Handle error cases for invalid release IDs + - Write unit tests for release build listing and caching + - _Requirements: 1.1, 3.1, 4.2_ + +- [ ] 12. Migrate release-related tools +- [ ] 12.1 Migrate List Releases tool + - Create ListReleasesTool class with filtering and stability data + - Implement release listing with stability target integration + - Handle pagination and filtering parameters + - Write unit tests for release listing and filtering + - _Requirements: 1.1, 3.1, 4.2_ + +- [ ] 12.2 Migrate Get Release tool + - Create GetReleaseTool class with caching and stability data + - Implement release retrieval with stability target enhancement + - Handle caching logic for release data + - Write unit tests for release retrieval and caching + - _Requirements: 1.1, 3.1, 4.2_ + +- [ ] 13. Update BugsnagClient to use modular architecture + - Modify BugsnagClient to use ToolRegistry instead of monolithic registerTools + - Initialize SharedServices and pass to tools through ToolExecutionContext + - Remove the old registerTools method implementation + - Ensure all existing functionality works through the new architecture + - _Requirements: 4.1, 4.3, 6.1_ + +- [ ] 14. Create comprehensive integration tests + - Write integration tests that verify the complete tool execution flow + - Test tool discovery and registration through ToolRegistry + - Verify that all tools work correctly with real SharedServices + - Test backward compatibility with existing tool interfaces + - _Requirements: 4.1, 4.2, 4.3_ + +- [ ] 15. Add tool factory and auto-discovery mechanism + - Implement automatic tool discovery that finds all tool classes + - Create a tool factory that instantiates tools and registers them + - Ensure new tools can be added without modifying existing code + - Write tests for the auto-discovery mechanism + - _Requirements: 5.1, 5.2, 5.3_ + +- [ ] 16. Update existing unit tests for compatibility + - Modify existing BugsnagClient tests to work with the new architecture + - Ensure all existing test scenarios still pass + - Update test mocks to work with SharedServices interface + - Verify test coverage remains comprehensive + - _Requirements: 3.2, 4.1, 4.4_ + +- [ ] 17. Performance optimization and cleanup + - Review and optimize SharedServices for performance + - Remove any unused code from the old monolithic implementation + - Optimize tool registration and discovery performance + - Add performance monitoring for tool execution + - _Requirements: 6.3, 4.1_ From 3a2126c5a575aba8ea702ece59ae4eb83f010826 Mon Sep 17 00:00:00 2001 From: Sachin Pande Date: Thu, 18 Sep 2025 11:30:03 +0100 Subject: [PATCH 2/3] refactor: bugsnag modular tools --- .kiro/specs/bugsnag-tools-refactor/tasks.md | 49 +- src/bugsnag/client.ts | 1183 +------- src/bugsnag/shared-services.ts | 377 +++ src/bugsnag/tool-factory.ts | 195 ++ src/bugsnag/tool-registry.ts | 172 ++ src/bugsnag/tools/get-build-tool.ts | 102 + src/bugsnag/tools/get-error-tool.ts | 168 ++ src/bugsnag/tools/get-event-details-tool.ts | 120 + src/bugsnag/tools/get-release-tool.ts | 99 + src/bugsnag/tools/index.ts | 20 + .../tools/list-builds-in-release-tool.ts | 104 + src/bugsnag/tools/list-builds-tool.ts | 144 + src/bugsnag/tools/list-project-errors-tool.ts | 199 ++ .../tools/list-project-event-filters-tool.ts | 106 + src/bugsnag/tools/list-projects-tool.ts | 110 + src/bugsnag/tools/list-releases-tool.ts | 147 + src/bugsnag/tools/update-error-tool.ts | 128 + src/bugsnag/types.ts | 247 ++ src/bugsnag/utils/README.md | 325 ++ src/bugsnag/utils/example-tool.ts | 241 ++ src/bugsnag/utils/index.ts | 5 + src/bugsnag/utils/tool-utilities.ts | 497 ++++ src/tests/unit/bugsnag/client.test.ts | 2638 +---------------- src/tests/unit/bugsnag/get-build-tool.test.ts | 308 ++ src/tests/unit/bugsnag/get-error-tool.test.ts | 498 ++++ ...get-event-details-tool-integration.test.ts | 374 +++ .../bugsnag/get-event-details-tool.test.ts | 347 +++ .../unit/bugsnag/get-release-tool.test.ts | 259 ++ .../list-builds-in-release-tool.test.ts | 326 ++ .../unit/bugsnag/list-builds-tool.test.ts | 273 ++ .../bugsnag/list-project-errors-tool.test.ts | 80 + .../list-project-event-filters-tool.test.ts | 582 ++++ .../list-projects-tool-integration.test.ts | 267 ++ .../unit/bugsnag/list-projects-tool.test.ts | 369 +++ .../unit/bugsnag/list-releases-tool.test.ts | 308 ++ .../unit/bugsnag/shared-services.test.ts | 320 ++ src/tests/unit/bugsnag/tool-factory.test.ts | 323 ++ src/tests/unit/bugsnag/tool-utilities.test.ts | 365 +++ src/tests/unit/bugsnag/types.test.ts | 147 + .../unit/bugsnag/update-error-tool.test.ts | 380 +++ 40 files changed, 9256 insertions(+), 3646 deletions(-) create mode 100644 src/bugsnag/shared-services.ts create mode 100644 src/bugsnag/tool-factory.ts create mode 100644 src/bugsnag/tool-registry.ts create mode 100644 src/bugsnag/tools/get-build-tool.ts create mode 100644 src/bugsnag/tools/get-error-tool.ts create mode 100644 src/bugsnag/tools/get-event-details-tool.ts create mode 100644 src/bugsnag/tools/get-release-tool.ts create mode 100644 src/bugsnag/tools/index.ts create mode 100644 src/bugsnag/tools/list-builds-in-release-tool.ts create mode 100644 src/bugsnag/tools/list-builds-tool.ts create mode 100644 src/bugsnag/tools/list-project-errors-tool.ts create mode 100644 src/bugsnag/tools/list-project-event-filters-tool.ts create mode 100644 src/bugsnag/tools/list-projects-tool.ts create mode 100644 src/bugsnag/tools/list-releases-tool.ts create mode 100644 src/bugsnag/tools/update-error-tool.ts create mode 100644 src/bugsnag/types.ts create mode 100644 src/bugsnag/utils/README.md create mode 100644 src/bugsnag/utils/example-tool.ts create mode 100644 src/bugsnag/utils/index.ts create mode 100644 src/bugsnag/utils/tool-utilities.ts create mode 100644 src/tests/unit/bugsnag/get-build-tool.test.ts create mode 100644 src/tests/unit/bugsnag/get-error-tool.test.ts create mode 100644 src/tests/unit/bugsnag/get-event-details-tool-integration.test.ts create mode 100644 src/tests/unit/bugsnag/get-event-details-tool.test.ts create mode 100644 src/tests/unit/bugsnag/get-release-tool.test.ts create mode 100644 src/tests/unit/bugsnag/list-builds-in-release-tool.test.ts create mode 100644 src/tests/unit/bugsnag/list-builds-tool.test.ts create mode 100644 src/tests/unit/bugsnag/list-project-errors-tool.test.ts create mode 100644 src/tests/unit/bugsnag/list-project-event-filters-tool.test.ts create mode 100644 src/tests/unit/bugsnag/list-projects-tool-integration.test.ts create mode 100644 src/tests/unit/bugsnag/list-projects-tool.test.ts create mode 100644 src/tests/unit/bugsnag/list-releases-tool.test.ts create mode 100644 src/tests/unit/bugsnag/shared-services.test.ts create mode 100644 src/tests/unit/bugsnag/tool-factory.test.ts create mode 100644 src/tests/unit/bugsnag/tool-utilities.test.ts create mode 100644 src/tests/unit/bugsnag/types.test.ts create mode 100644 src/tests/unit/bugsnag/update-error-tool.test.ts diff --git a/.kiro/specs/bugsnag-tools-refactor/tasks.md b/.kiro/specs/bugsnag-tools-refactor/tasks.md index b9ca2197..c8bfd5c7 100644 --- a/.kiro/specs/bugsnag-tools-refactor/tasks.md +++ b/.kiro/specs/bugsnag-tools-refactor/tasks.md @@ -1,139 +1,138 @@ # Implementation Plan -- [ ] 1. Create base interfaces and type definitions +- [x] 1. Create base interfaces and type definitions - Define the core interfaces for BugsnagTool, SharedServices, ToolExecutionContext, and related types - Create parameter definition interfaces and error types - Set up the foundation for the modular architecture - _Requirements: 2.1, 2.3_ -- [ ] 2. Implement SharedServices class +- [x] 2. Implement SharedServices class - Extract common functionality from BugsnagClient into a SharedServices implementation - Implement methods for project management, API client access, caching, and URL generation - Ensure all existing functionality is preserved and accessible through the service interface - _Requirements: 6.2, 4.1_ -- [ ] 3. Create ToolRegistry for tool discovery and registration +- [x] 3. Create ToolRegistry for tool discovery and registration - Implement the ToolRegistry class to manage tool discovery and registration - Create methods for registering individual tools and bulk registration - Implement automatic tool discovery mechanism - _Requirements: 5.2, 5.3_ -- [ ] 4. Create base tool implementation utilities +- [x] 4. Create base tool implementation utilities - Implement common utilities for parameter validation using Zod schemas - Create helper functions for consistent error handling and response formatting - Set up shared patterns that all tools will use - _Requirements: 2.2, 2.4_ -- [ ] 5. Migrate List Projects tool +- [x] 5. Migrate List Projects tool - Create ListProjectsTool class implementing the BugsnagTool interface - Extract the existing List Projects tool logic into the new modular structure - Implement parameter validation and error handling - Write unit tests for the tool - _Requirements: 1.1, 3.1, 4.2_ -- [ ] 6. Migrate Get Error tool +- [x] 6. Migrate Get Error tool - Create GetErrorTool class with the existing Get Error functionality - Implement complex parameter handling including conditional projectId requirement - Handle filter processing and error URL generation - Write comprehensive unit tests covering all scenarios - _Requirements: 1.1, 3.1, 4.2_ -- [ ] 7. Migrate Get Event Details tool +- [x] 7. Migrate Get Event Details tool - Create GetEventDetailsTool class for URL-based event retrieval - Implement URL parsing and project slug resolution logic - Handle error cases for invalid URLs and missing projects - Write unit tests for URL parsing and error scenarios - _Requirements: 1.1, 3.1, 4.2_ -- [ ] 8. Migrate List Project Errors tool +- [x] 8. Migrate List Project Errors tool - Create ListProjectErrorsTool class with filtering and pagination support - Implement complex filter handling and default parameter logic - Handle pagination with next URL generation - Write unit tests for filtering, pagination, and edge cases - _Requirements: 1.1, 3.1, 4.2_ -- [ ] 9. Migrate List Project Event Filters tool +- [x] 9. Migrate List Project Event Filters tool - Create ListProjectEventFiltersTool class for discovering available filters - Implement filter field retrieval and exclusion logic - Handle caching of filter results - Write unit tests for filter discovery and caching - _Requirements: 1.1, 3.1, 4.2_ -- [ ] 10. Migrate Update Error tool +- [x] 10. Migrate Update Error tool - Create UpdateErrorTool class for error status management - Implement operation validation and parameter handling - Handle different update operations with proper validation - Write unit tests for all supported operations and validation - _Requirements: 1.1, 3.1, 4.2_ -- [ ] 11. Migrate build-related tools -- [ ] 11.1 Migrate List Builds tool +- [x] 11. Migrate build-related tools +- [x] 11.1 Migrate List Builds tool - Create ListBuildsTool class with pagination and stability data - Implement build listing with stability target integration - Handle next URL generation for pagination - Write unit tests for build listing and stability calculations - _Requirements: 1.1, 3.1, 4.2_ -- [ ] 11.2 Migrate Get Build tool +- [x] 11.2 Migrate Get Build tool - Create GetBuildTool class with caching and stability data - Implement build retrieval with stability target enhancement - Handle caching logic for build data - Write unit tests for build retrieval and caching - _Requirements: 1.1, 3.1, 4.2_ -- [ ] 11.3 Migrate List Builds in Release tool +- [x] 11.3 Migrate List Builds in Release tool - Create ListBuildsInReleaseTool class for release-specific build listing - Implement release build association logic with caching - Handle error cases for invalid release IDs - Write unit tests for release build listing and caching - _Requirements: 1.1, 3.1, 4.2_ -- [ ] 12. Migrate release-related tools -- [ ] 12.1 Migrate List Releases tool +- [x] 12. Migrate release-related tools +- [x] 12.1 Migrate List Releases tool - Create ListReleasesTool class with filtering and stability data - Implement release listing with stability target integration - Handle pagination and filtering parameters - Write unit tests for release listing and filtering - _Requirements: 1.1, 3.1, 4.2_ -- [ ] 12.2 Migrate Get Release tool +- [x] 12.2 Migrate Get Release tool - Create GetReleaseTool class with caching and stability data - Implement release retrieval with stability target enhancement - Handle caching logic for release data - Write unit tests for release retrieval and caching - _Requirements: 1.1, 3.1, 4.2_ -- [ ] 13. Update BugsnagClient to use modular architecture +- [x] 13. Update BugsnagClient to use modular architecture - Modify BugsnagClient to use ToolRegistry instead of monolithic registerTools - Initialize SharedServices and pass to tools through ToolExecutionContext - Remove the old registerTools method implementation - Ensure all existing functionality works through the new architecture - _Requirements: 4.1, 4.3, 6.1_ -- [ ] 14. Create comprehensive integration tests +- [x] 14. Create comprehensive integration tests - Write integration tests that verify the complete tool execution flow - Test tool discovery and registration through ToolRegistry - Verify that all tools work correctly with real SharedServices - Test backward compatibility with existing tool interfaces - _Requirements: 4.1, 4.2, 4.3_ -- [ ] 15. Add tool factory and auto-discovery mechanism +- [x] 15. Add tool factory and auto-discovery mechanism - Implement automatic tool discovery that finds all tool classes - Create a tool factory that instantiates tools and registers them - Ensure new tools can be added without modifying existing code - Write tests for the auto-discovery mechanism - _Requirements: 5.1, 5.2, 5.3_ -- [ ] 16. Update existing unit tests for compatibility +- [x] 16. Update existing unit tests for compatibility - Modify existing BugsnagClient tests to work with the new architecture - Ensure all existing test scenarios still pass - Update test mocks to work with SharedServices interface - Verify test coverage remains comprehensive - _Requirements: 3.2, 4.1, 4.4_ -- [ ] 17. Performance optimization and cleanup - - Review and optimize SharedServices for performance +- [x] 17. Performance optimization and cleanup + - Review and optimize SharedServices for performance with intelligent caching - Remove any unused code from the old monolithic implementation - - Optimize tool registration and discovery performance - - Add performance monitoring for tool execution + - Optimize tool registration and discovery performance with caching - _Requirements: 6.3, 4.1_ diff --git a/src/bugsnag/client.ts b/src/bugsnag/client.ts index 3e35a0b4..dcbdbcb7 100644 --- a/src/bugsnag/client.ts +++ b/src/bugsnag/client.ts @@ -1,66 +1,18 @@ import NodeCache from "node-cache"; -import { z } from "zod"; import { MCP_SERVER_NAME, MCP_SERVER_VERSION } from "../common/info.js"; import { Client, GetInputFunction, RegisterResourceFunction, RegisterToolsFunction } from "../common/types.js"; import { CurrentUserAPI, ErrorAPI, Configuration } from "./client/index.js"; -import { Organization, Project } from "./client/api/CurrentUser.js"; -import { FilterObject, FilterObjectSchema, toQueryString } from "./client/api/filters.js"; -import { ListProjectErrorsOptions } from "./client/api/Error.js"; -import { - EventField, - ListBuildsOptions, - ProjectAPI, - BuildResponse, - StabilityData, - BuildResponseAny, - ReleaseResponseAny, - ProjectStabilityTargets, - ListReleasesOptions, - ReleaseResponse, -} from "./client/api/Project.js"; -import { getNextUrlPathFromHeader } from "./client/api/base.js"; +import { ProjectAPI } from "./client/api/Project.js"; +import { BugsnagToolRegistry } from "./tool-registry.js"; +import { BugsnagSharedServices } from "./shared-services.js"; +import { ToolExecutionContext } from "./types.js"; +import { ToolDiscoveryConfig } from "./tool-factory.js"; const HUB_PREFIX = "00000"; const DEFAULT_DOMAIN = "bugsnag.com"; const HUB_DOMAIN = "bugsnag.smartbear.com"; -const cacheKeys = { - ORG: "bugsnag_org", - PROJECTS: "bugsnag_projects", - CURRENT_PROJECT: "bugsnag_current_project", - CURRENT_PROJECT_EVENT_FILTERS: "bugsnag_current_project_event_filters", - BUILD: "bugsnag_build", // + buildId - RELEASE: "bugsnag_release", // + releaseId - BUILDS_IN_RELEASE: "bugsnag_builds_in_release" // + releaseId -} - -// Exclude certain event fields from the project event filters to improve agent usage -const EXCLUDED_EVENT_FIELDS = new Set([ - "search" // This is searches multiple fields and is more a convenience for humans, we're removing to avoid over-matching -]); - -const PERMITTED_UPDATE_OPERATIONS = [ - "override_severity", - "open", - "fix", - "ignore", - "discard", - "undiscard" -] as const; - -// Type definitions for tool arguments -export interface ProjectArgs { - projectId: string; -} - -export interface OrgArgs { - orgId: string; -} - -export interface ErrorArgs extends ProjectArgs { - errorId: string; -} export class BugsnagClient implements Client { private currentUserApi: CurrentUserAPI; private errorsApi: ErrorAPI; @@ -69,6 +21,8 @@ export class BugsnagClient implements Client { private projectApiKey?: string; private apiEndpoint: string; private appEndpoint: string; + private toolRegistry: BugsnagToolRegistry; + private sharedServices: BugsnagSharedServices; name = "Bugsnag"; prefix = "bugsnag"; @@ -93,12 +47,53 @@ export class BugsnagClient implements Client { }); this.projectApi = new ProjectAPI(config); this.projectApiKey = projectApiKey; + + // Initialize shared services + this.sharedServices = new BugsnagSharedServices( + this.currentUserApi, + this.errorsApi, + this.projectApi, + this.cache, + this.appEndpoint, + this.apiEndpoint, + this.projectApiKey + ); + + // Initialize tool registry (tools will be auto-discovered during registration) + this.toolRegistry = new BugsnagToolRegistry(); } async initialize(): Promise { - // Trigger caching of org and projects - await this.getProjects(); - await this.getCurrentProject(); + const startTime = performance.now(); + + try { + // Trigger caching of org and projects through shared services + await Promise.all([ + this.sharedServices.getProjects(), + this.sharedServices.getCurrentProject() + ]); + + const initTime = performance.now() - startTime; + if (process.env.NODE_ENV === 'development' || process.env.BUGSNAG_PERFORMANCE_MONITORING === 'true') { + console.debug(`BugsnagClient initialized in ${initTime.toFixed(2)}ms`); + } + } catch (error) { + const initTime = performance.now() - startTime; + if (process.env.NODE_ENV === 'development' || process.env.BUGSNAG_PERFORMANCE_MONITORING === 'true') { + console.debug(`BugsnagClient initialization failed after ${initTime.toFixed(2)}ms:`, error); + } + throw error; + } + } + + /** + * Get tool discovery configuration based on client settings + */ + private getToolDiscoveryConfig(): ToolDiscoveryConfig { + return { + // Include List Projects tool only when no project API key is configured + includeListProjects: !this.projectApiKey + }; } getHost(apiKey: string | undefined, subdomain: string): string { @@ -138,1070 +133,44 @@ export class BugsnagClient implements Client { return subDomainEndpoint; } - async getDashboardUrl(project: Project): Promise { - return `${this.appEndpoint}/${(await this.getOrganization()).slug}/${project.slug}`; - } - - async getErrorUrl(project: Project, errorId: string, queryString = ''): Promise { - const dashboardUrl = await this.getDashboardUrl(project); - return `${dashboardUrl}/errors/${errorId}${queryString}`; - } - - async getOrganization(): Promise { - let org = this.cache.get(cacheKeys.ORG)!; - if (!org) { - const response = await this.currentUserApi.listUserOrganizations(); - const orgs = response.body || []; - if (!orgs || orgs.length === 0) { - throw new Error("No organizations found for the current user."); - } - org = orgs[0]; - this.cache.set(cacheKeys.ORG, org); - } - return org; - } - - // This method retrieves all projects for the organization stored in the cache. - // If no projects are found in the cache, it fetches them from the API and - // stores them in the cache for future use. - // It throws an error if no organizations are found in the cache. - async getProjects(): Promise { - let projects = this.cache.get(cacheKeys.PROJECTS); - if (!projects) { - const org = await this.getOrganization(); - const response = await this.currentUserApi.getOrganizationProjects(org.id); - projects = response.body || []; - this.cache.set(cacheKeys.PROJECTS, projects); - } - return projects; - } - - async getProject(projectId: string): Promise { - const projects = await this.getProjects(); - return projects.find((p) => p.id === projectId) || null; - } - - async getCurrentProject(): Promise { - let project = this.cache.get(cacheKeys.CURRENT_PROJECT) ?? null; - if (!project && this.projectApiKey) { - const projects = await this.getProjects(); - project = projects.find((p) => p.api_key === this.projectApiKey) ?? null; - if (!project) { - throw new Error(`Unable to find project with API key ${this.projectApiKey} in organization.`); - } - this.cache.set(cacheKeys.CURRENT_PROJECT, project); - if (project) { - this.cache.set(cacheKeys.CURRENT_PROJECT_EVENT_FILTERS, await this.getProjectEventFilters(project)); - } - } - return project; - } - - async getProjectEventFilters(project: Project): Promise { - let filtersResponse = (await this.projectApi.listProjectEventFields(project.id)).body; - if (!filtersResponse || filtersResponse.length === 0) { - throw new Error(`No event fields found for project ${project.name}.`); - } - filtersResponse = filtersResponse.filter(field => !EXCLUDED_EVENT_FIELDS.has(field.display_id)); - return filtersResponse; - } - - async getEvent(eventId: string, projectId?: string): Promise { - const projectIds = projectId ? [projectId] : (await this.getProjects()).map((p) => p.id); - const projectEvents = await Promise.all(projectIds.map((projectId: string) => this.errorsApi.viewEventById(projectId, eventId).catch(_e => null))); - return projectEvents.find(event => event && !!event.body)?.body || null; - } - - async updateError(projectId: string, errorId: string, operation: string, options?: any): Promise { - const errorUpdateRequest = { - operation: operation, - ...options + /** + * Register tools with the MCP server using automatic discovery + */ + registerTools(register: RegisterToolsFunction, getInput: GetInputFunction): void { + const context: ToolExecutionContext = { + services: this.sharedServices, + getInput }; - const response = await this.errorsApi.updateErrorOnProject(projectId, errorId, errorUpdateRequest); - return response.status === 200 || response.status === 204; - } - private async getInputProject(projectId?: unknown | string): Promise { - if (typeof projectId === 'string') { - const maybeProject = await this.getProject(projectId); - if (!maybeProject) { - throw new Error(`Project with ID ${projectId} not found.`); - } - return maybeProject!; - } else { - const currentProject = await this.getCurrentProject(); - if (!currentProject) { - throw new Error('No current project found. Please provide a projectId or configure a project API key.'); - } - return currentProject; - } + // Use the tool registry with auto-discovery to register all tools + const config = this.getToolDiscoveryConfig(); + this.toolRegistry.registerAllTools(register, context, config); } - async listBuilds(projectId: string, opts: ListBuildsOptions) { - const response = await this.projectApi.listBuilds(projectId, opts); - const fetchedBuilds = response.body || []; - const nextUrl = getNextUrlPathFromHeader(response.headers, this.apiEndpoint); - - const stabilityTargets = await this.getProjectStabilityTargets(projectId); - const formattedBuilds = fetchedBuilds.map( - (b) => this.addStabilityData(b, stabilityTargets) - ); - - return { builds: formattedBuilds, nextUrl }; - } - - async getBuild(projectId: string, buildId: string) { - const cacheKey = `${cacheKeys.BUILD}_${buildId}`; - const build = this.cache.get(cacheKey); - if (build) return build; - - const fetchedBuild = (await this.projectApi.getBuild(projectId, buildId)).body; - if (!fetchedBuild) throw new Error(`No build for ${buildId} found.`); - - const stabilityTargets = await this.getProjectStabilityTargets(projectId); - const formattedBuild = this.addStabilityData(fetchedBuild, stabilityTargets); - this.cache.set(cacheKey, formattedBuild, 5 * 60); - return formattedBuild; - } - - async listReleases(projectId: string, opts: ListReleasesOptions) { - const response = await this.projectApi.listReleases(projectId, opts) - const fetchedReleases = response.body || []; - const nextUrl = getNextUrlPathFromHeader(response.headers, this.apiEndpoint); - - const stabilityTargets = await this.getProjectStabilityTargets(projectId); - const formattedReleases = fetchedReleases.map( - (r) => this.addStabilityData(r, stabilityTargets) - ); - - return { releases: formattedReleases, nextUrl }; - } - - async getRelease(projectId: string, releaseId: string) { - const cacheKey = `${cacheKeys.RELEASE}_${releaseId}`; - const release = this.cache.get(cacheKey); - if (release) return release; - - const fetchedRelease = (await this.projectApi.getRelease(releaseId)).body; - if (!fetchedRelease) throw new Error(`No release for ${releaseId} found.`); - - const stabilityTargets = await this.getProjectStabilityTargets(projectId); - const formattedRelease = this.addStabilityData(fetchedRelease, stabilityTargets); - this.cache.set(cacheKey, formattedRelease, 5 * 60); - return formattedRelease; - } - - async listBuildsInRelease(releaseId: string) { - const cacheKey = `${cacheKeys.BUILDS_IN_RELEASE}_${releaseId}`; - const builds = this.cache.get(cacheKey); - if (builds) return builds; - - const fetchedBuilds = (await this.projectApi.listBuildsInRelease(releaseId)).body || []; - this.cache.set(cacheKey, fetchedBuilds, 5 * 60); - return fetchedBuilds; - } - - private async getProjectStabilityTargets(projectId: string) { - return await this.projectApi.getProjectStabilityTargets(projectId); - } - - private addStabilityData( - source: T, - stabilityTargets: ProjectStabilityTargets - ): T & StabilityData { - const { stability_target_type, target_stability, critical_stability } = stabilityTargets; - - const user_stability = - source.accumulative_daily_users_seen === 0 // avoid division by zero - ? 0 - : (source.accumulative_daily_users_seen - source.accumulative_daily_users_with_unhandled) / - source.accumulative_daily_users_seen; - - const session_stability = - source.total_sessions_count === 0 // avoid division by zero - ? 0 - : (source.total_sessions_count - source.unhandled_sessions_count) / source.total_sessions_count; - - const stabilityMetric = stability_target_type === "user" ? user_stability : session_stability; - - const meets_target_stability = stabilityMetric >= target_stability.value; - const meets_critical_stability = stabilityMetric >= critical_stability.value; - - return { - ...source, - user_stability, - session_stability, - stability_target_type, - target_stability: target_stability.value, - critical_stability: critical_stability.value, - meets_target_stability, - meets_critical_stability, - }; - } - - registerTools(register: RegisterToolsFunction, getInput: GetInputFunction): void { - if (!this.projectApiKey) { - register( - { - title: "List Projects", - summary: "List all projects in the organization with optional pagination", - purpose: "Retrieve available projects for browsing and selecting which project to analyze", - useCases: [ - "Browse available projects when no specific project API key is configured", - "Find project IDs needed for other tools", - "Get an overview of all projects in the organization" - ], - parameters: [ - { - name: "page_size", - type: z.number(), - description: "Number of projects to return per page for pagination", - required: false, - examples: ["10", "25", "50"] - }, - { - name: "page", - type: z.number(), - description: "Page number to return (starts from 1)", - required: false, - examples: ["1", "2", "3"] - } - ], - examples: [ - { - description: "Get first 10 projects", - parameters: { - page_size: 10, - page: 1 - }, - expectedOutput: "JSON array of project objects with IDs, names, and metadata", - }, - { - description: "Get all projects (no pagination)", - parameters: {}, - expectedOutput: "JSON array of all available projects" - } - ], - hints: [ - "Use pagination for organizations with many projects to avoid large responses", - "Project IDs from this list can be used with other tools when no project API key is configured" - ] - }, - async (args: any, _extra: any) => { - let projects = await this.getProjects(); - if (!projects || projects.length === 0) { - return { - content: [{ type: "text", text: "No projects found." }], - }; - } - if (args.page_size || args.page) { - const pageSize = args.page_size || 10; - const page = args.page || 1; - projects = projects.slice((page - 1) * pageSize, page * pageSize); - } - - const result = { - data: projects, - count: projects.length, - } - return { - content: [{ type: "text", text: JSON.stringify(result) }], - }; - } - ); - } - - register( - { - title: "Get Error", - summary: "Get full details on an error, including aggregated and summarized data across all events (occurrences) and details of the latest event (occurrence), such as breadcrumbs, metadata and the stacktrace. Use the filters parameter to narrow down the summaries further.", - purpose: "Retrieve all the information required on a specified error to understand who it is affecting and why.", - useCases: [ - "Investigate a specific error found through the List Project Errors tool", - "Understand which types of user are affected by the error using summarized event data", - "Get error details for debugging and root cause analysis", - "Retrieve error metadata for incident reports and documentation" - ], - parameters: [ - { - name: "errorId", - type: z.string(), - required: true, - description: "Unique identifier of the error to retrieve", - examples: ["6863e2af8c857c0a5023b411"] - }, - ...(this.projectApiKey ? [] : [ - { - name: "projectId", - type: z.string(), - required: true, - description: "ID of the project containing the error", - } - ]), - { - name: "filters", - type: FilterObjectSchema, - required: false, - description: "Apply filters to narrow down the error list. Use the List Project Event Filters tool to discover available filter fields", - examples: [ - '{"error.status": [{"type": "eq", "value": "open"}]}', - '{"event.since": [{"type": "eq", "value": "7d"}]} // Relative time: last 7 days', - '{"event.since": [{"type": "eq", "value": "2018-05-20T00:00:00Z"}]} // ISO 8601 UTC format', - '{"user.email": [{"type": "eq", "value": "user@example.com"}]}' - ], - constraints: [ - "Time filters support ISO 8601 format (e.g. 2018-05-20T00:00:00Z) or relative format (e.g. 7d, 24h)", - "ISO 8601 times must be in UTC and use extended format", - "Relative time periods: h (hours), d (days)" - ] - } - ], - outputFormat: "JSON object containing: " + - " - error_details: Aggregated data about the error, including first and last seen occurrence" + - " - latest_event: Detailed information about the most recent occurrence of the error, including stacktrace, breadcrumbs, user and context" + - " - pivots: List of pivots (summaries) for the error, which can be used to analyze patterns in occurrences" + - " - url: A link to the error in the dashboard - this should be shown to the user for them to perform further analysis", - examples: [ - { - description: "Get details for a specific error", - parameters: { - errorId: "6863e2af8c857c0a5023b411" - }, - expectedOutput: "JSON object with error details including message, stack trace, occurrence count, and metadata" - } - ], - hints: [ - "Error IDs can be found using the List Project Errors tool", - "Use this after filtering errors to get detailed information about specific errors", - "If you used a filter to get this error, you can pass the same filters here to restrict the results or apply further filters", - "The URL provided in the response points should be shown to the user in all cases as it allows them to view the error in the dashboard and perform further analysis", - ], - }, - async (args, _extra) => { - const project = await this.getInputProject(args.projectId); - if (!args.errorId) throw new Error("Both projectId and errorId arguments are required"); - const errorDetails = (await this.errorsApi.viewErrorOnProject(project.id, args.errorId)).body; - if (!errorDetails) { - throw new Error(`Error with ID ${args.errorId} not found in project ${project.id}.`); - } - - // Build query parameters - const params = new URLSearchParams(); - - // Add sorting and pagination parameters to get the latest event - params.append('sort', 'timestamp'); - params.append('direction', 'desc'); - params.append('per_page', '1'); - params.append('full_reports', 'true'); - - const filters: FilterObject = { - "error": [{ type: "eq", value: args.errorId }], - ...args.filters - } - - const filtersQueryString = toQueryString(filters); - const listEventsQueryString = `?${params}&${filtersQueryString}`; - - // Get the latest event for this error using the events endpoint with filters - let latestEvent = null; - try { - const eventsResponse = await this.errorsApi.listEventsOnProject(project.id, listEventsQueryString); - const events = eventsResponse.body || []; - latestEvent = events[0] || null; - } catch (e) { - console.warn("Failed to fetch latest event:", e); - // Continue without latest event rather than failing the entire request - } - - const content = { - error_details: errorDetails, - latest_event: latestEvent, - pivots: (await this.errorsApi.listErrorPivots(project.id, args.errorId)).body || [], - url: await this.getErrorUrl(project, args.errorId, `?${filtersQueryString}`), - } - return { - content: [{ type: "text", text: JSON.stringify(content) }] - }; - } - ); - + /** + * Register resources with the MCP server (for backward compatibility) + */ + registerResources(register: RegisterResourceFunction): void { register( - { - title: "Get Event Details", - summary: "Get detailed information about a specific event using its dashboard URL", - purpose: "Retrieve event details directly from a dashboard URL for quick debugging", - useCases: [ - "Get event details when given a dashboard URL from a user or notification", - "Extract event information from shared links or browser URLs", - "Quick lookup of event details without needing separate project and event IDs" - ], - parameters: [ - { - name: "link", - type: z.string(), - description: "Full URL to the event details page in the BugSnag dashboard (web interface)", - required: true, - examples: [ - "https://app.bugsnag.com/my-org/my-project/errors/6863e2af8c857c0a5023b411?event_id=6863e2af012caf1d5c320000" - ], - constraints: [ - "Must be a valid dashboard URL containing project slug and event_id parameter" - ] - } - ], - examples: [ - { - description: "Get event details from a dashboard URL", - parameters: { - link: "https://app.bugsnag.com/my-org/my-project/errors/6863e2af8c857c0a5023b411?event_id=6863e2af012caf1d5c320000" - }, - expectedOutput: "JSON object with complete event details including stack trace, metadata, and context" - } - ], - hints: [ - "The URL must contain both project slug in the path and event_id in query parameters", - "This is useful when users share BugSnag dashboard URLs and you need to extract the event data" - ] - }, - async (args: any, _extra: any) => { - if (!args.link) throw new Error("link argument is required"); - const url = new URL(args.link); - const eventId = url.searchParams.get("event_id"); - const projectSlug = url.pathname.split('/')[2]; - if (!projectSlug || !eventId) throw new Error("Both projectSlug and eventId must be present in the link"); - - // get the project id from list of projects - const projects = await this.getProjects(); - const projectId = projects.find((p: any) => p.slug === projectSlug)?.id; - if (!projectId) { - throw new Error("Project with the specified slug not found."); + 'event', + '{id}', + async (uri: URL) => { + const id = uri.pathname.split('/').pop() || ''; + const event = await this.sharedServices.getEvent(id); + if (!event) { + throw new Error(`Event with ID ${id} not found.`); } - const response = await this.getEvent(eventId, projectId); return { - content: [{ type: "text", text: JSON.stringify(response) }], - }; - } - ); - - register( - { - title: "List Project Errors", - summary: "List and search errors in a project using customizable filters and pagination", - purpose: "Retrieve filtered list of errors from a project for analysis, debugging, and reporting", - useCases: [ - "Debug recent application errors by filtering for open errors in the last 7 days", - "Generate error reports for stakeholders by filtering specific error types or severity levels", - "Monitor error trends over time using date range filters", - "Find errors affecting specific users or environments using metadata filters" - ], - parameters: [ - { - name: "filters", - type: FilterObjectSchema.default({ - "event.since": [{ type: "eq", value: "30d" }], - "error.status": [{ type: "eq", value: "open" }] - }), - description: "Apply filters to narrow down the error list. Use the List Project Event Filters tool to discover available filter fields", - required: false, - examples: [ - '{"error.status": [{"type": "eq", "value": "open"}]}', - '{"event.since": [{"type": "eq", "value": "7d"}]} // Relative time: last 7 days', - '{"event.since": [{"type": "eq", "value": "2018-05-20T00:00:00Z"}]} // ISO 8601 UTC format', - '{"user.email": [{"type": "eq", "value": "user@example.com"}]}' - ], - constraints: [ - "Time filters support ISO 8601 format (e.g. 2018-05-20T00:00:00Z) or relative format (e.g. 7d, 24h)", - "ISO 8601 times must be in UTC and use extended format", - "Relative time periods: h (hours), d (days)" - ] - }, - { - name: "sort", - type: z.enum(["first_seen", "last_seen", "events", "users", "unsorted"]).default("last_seen"), - description: "Field to sort the errors by", - required: false, - examples: ["last_seen"] - }, - { - name: "direction", - type: z.enum(["asc", "desc"]).default("desc"), - description: "Sort direction for ordering results", - required: false, - examples: ["desc"] - }, - { - name: "per_page", - type: z.number().min(1).max(100).default(30), - description: "How many results to return per page.", - required: false, - examples: ["30", "50", "100"] - }, - { - name: "next", - type: z.string().url(), - description: "URL for retrieving the next page of results. Use the value in the previous response to get the next page when more results are available.", - required: false, - examples: ["https://api.bugsnag.com/projects/515fb9337c1074f6fd000003/errors?offset=30&per_page=30&sort=last_seen"], - constraints: ["Only values provided in the output from this tool can be used. Do not attempt to construct it manually."] - }, - ...(this.projectApiKey ? [] : [ + contents: [ { - name: "projectId", - type: z.string(), - description: "ID of the project to query for errors", - required: true, - } - ]) - ], - examples: [ - { - description: "Find errors affecting a specific user in the last 24 hours", - parameters: { - filters: { - "user.email": [{ "type": "eq", "value": "user@example.com" }], - "event.since": [{ "type": "eq", "value": "24h" }] - } + uri: `bugsnag://event/${id}`, + mimeType: "application/json", + text: JSON.stringify(event), }, - expectedOutput: "JSON object with a list of errors in the 'data' field, a count of the current page of results in the 'count' field, and a total count of all results in the 'total' field" - }, - { - description: "Get the 10 open errors with the most users affected in the last 30 days", - parameters: { - filters: { - "event.since": [{ "type": "eq", "value": "30d" }], - "error.status": [{ "type": "eq", "value": "open" }] - }, - sort: "users", - direction: "desc", - per_page: 10 - }, - expectedOutput: "JSON object with a list of errors in the 'data' field, a count of the current page of results in the 'count' field, and a total count of all results in the 'total' field" - }, - { - description: "Get the next 50 results", - parameters: { - next: "https://api.bugsnag.com/projects/515fb9337c1074f6fd000003/errors?base=2025-08-29T13%3A11%3A37Z&direction=desc&filters%5Berror.status%5D%5B%5D%5Btype%5D=eq&filters%5Berror.status%5D%5B%5D%5Bvalue%5D=open&offset=10&per_page=10&sort=users", - per_page: 50 - }, - expectedOutput: "JSON object with a list of errors in the 'data' field, a count of the current page of results in the 'count' field, and a total count of all results in the 'total' field" - } - ], - hints: [ - "Use list_project_event_filters tool first to discover valid filter field names for your project", - "Combine multiple filters to narrow results - filters are applied with AND logic", - "For time filters: use relative format (7d, 24h) for recent periods or ISO 8601 UTC format (2018-05-20T00:00:00Z) for specific dates", - "Common time filters: event.since (from this time), event.before (until this time)", - "The 'event.since' filter and 'error.status' filters are always applied and if not specified are set to '30d' and 'open' respectively", - "There may not be any errors matching the filters - this is not a problem with the tool, in fact it might be a good thing that the user's application had no errors", - "This tool returns paged results. The 'count' field indicates the number of results returned in the current page, and the 'total' field indicates the total number of results across all pages.", - "If the output contains a 'next' value, there are more results available - call this tool again supplying the next URL as a parameter to retrieve the next page.", - "Do not modify the next URL as this can cause incorrect results. The only other parameter that can be used with 'next' is 'per_page' to control the page size." - ] - }, - async (args: any, _extra: any) => { - const project = await this.getInputProject(args.projectId); - - // Validate filter keys against cached event fields - if (args.filters) { - const eventFields = this.cache.get(cacheKeys.CURRENT_PROJECT_EVENT_FILTERS) || []; - const validKeys = new Set(eventFields.map(f => f.display_id)); - for (const key of Object.keys(args.filters)) { - if (!validKeys.has(key)) { - throw new Error(`Invalid filter key: ${key}`); - } - } - } - - const defaultFilters: FilterObject = { - "event.since": [{ "type": "eq", "value": "30d" }], - "error.status": [{ "type": "eq", "value": "open" }] - } - - const options: ListProjectErrorsOptions = { - filters: { ...defaultFilters, ...args.filters } - }; - - if (args.sort !== undefined) options.sort = args.sort; - if (args.direction !== undefined) options.direction = args.direction; - if (args.per_page !== undefined) options.per_page = args.per_page; - if (args.next !== undefined) options.next = args.next; - - const response = await this.errorsApi.listProjectErrors(project.id, options); - - const errors = response.body || []; - const totalCount = response.headers.get('X-Total-Count'); - const linkHeader = response.headers.get('Link'); - - const result = { - data: errors, - count: errors.length, - total: totalCount ? parseInt(totalCount) : undefined, - next: linkHeader?.match(/<([^>]+)>/)?.[1], - }; - return { - content: [{ type: "text", text: JSON.stringify(result) }], - }; - } - ); - - register( - { - title: "List Project Event Filters", - summary: "Get available event filter fields for the current project", - purpose: "Discover valid filter field names and options that can be used with the List Errors or Get Error tools", - useCases: [ - "Discover what filter fields are available before searching for errors", - "Find the correct field names for filtering by user, environment, or custom metadata", - "Understand filter options and data types for building complex queries" - ], - parameters: [], - examples: [ - { - description: "Get all available filter fields", - parameters: {}, - expectedOutput: "JSON array of EventField objects containing display_id, custom flag, and filter/pivot options" - } - ], - hints: [ - "Use this tool before the List Errors or Get Error tools to understand available filters", - "Look for display_id field in the response - these are the field names to use in filters" - ] - }, - async (_args: any, _extra: any) => { - const projectFields = this.cache.get(cacheKeys.CURRENT_PROJECT_EVENT_FILTERS); - if (!projectFields) throw new Error("No event filters found in cache."); - - return { - content: [{ type: "text", text: JSON.stringify(projectFields) }], - }; - } - ); - - register( - { - title: "Update Error", - summary: "Update the status of an error", - purpose: "Change an error's workflow state, such as marking it as resolved or ignored", - useCases: [ - "Mark an error as open, fixed or ignored", - "Discard or un-discard an error", - "Update the severity of an error" - ], - parameters: [ - ...(this.projectApiKey ? [] : [ - { - name: "projectId", - type: z.string(), - description: "ID of the project that contains the error to be updated", - required: true, - } - ]), - { - name: "errorId", - type: z.string(), - description: "ID of the error to update", - required: true, - examples: ["6863e2af8c857c0a5023b411"] - }, - { - name: "operation", - type: z.enum(PERMITTED_UPDATE_OPERATIONS), - description: "The operation to apply to the error", - required: true, - examples: ["fix", "open", "ignore", "discard", "undiscard"] - } - ], - examples: [ - { - description: "Mark an error as fixed", - parameters: { - errorId: "6863e2af8c857c0a5023b411", - operation: "fix" - }, - expectedOutput: "Success response indicating the error was marked as fixed" - } - ], - hints: [ - "Only use valid operations - BugSnag may reject invalid values" - ], - readOnly: false, - idempotent: false, - }, - async (args: any, _extra: any) => { - const { errorId, operation } = args; - const project = await this.getInputProject(args.projectId); - - let severity = undefined; - if (operation === 'override_severity') { - // illicit the severity from the user - const result = await getInput({ - message: "Please provide the new severity for the error (e.g. 'info', 'warning', 'error', 'critical')", - requestedSchema: { - type: "object", - properties: { - severity: { - type: "string", - enum: ['info', 'warning', 'error'], - description: "The new severity level for the error" - } - } - }, - required: ["severity"] - }) - - if (result.action === "accept" && result.content?.severity) { - severity = result.content.severity; - } - } - - const result = await this.updateError(project.id!, errorId, operation, { severity }); - return { - content: [{ type: "text", text: JSON.stringify({ success: result }) }], - }; - } - ); - - register( - { - title: "List Builds", - summary: "List builds for a project with optional filtering by release stage", - purpose: "Retrieve a list of build summaries to analyze deployment history and associated errors", - useCases: [ - "View recent builds to correlate with error spikes", - "Filter builds by stage (e.g. production, staging) for targeted analysis", - ], - parameters: [ - ...(this.projectApiKey - ? [] - : [ - { - name: "projectId", - type: z.string(), - description: "ID of the project to list builds for", - required: true, - }, - ]), - { - name: "releaseStage", - type: z.string(), - description: "Filter builds by this stage (e.g. production, staging)", - required: false, - examples: ["production", "staging"], - }, - { - name: "nextUrl", - type: z.string(), - description: - "URL for retrieving the next page of results. Use the value in the previous response to get the next page when more results are available. If provided, other parameters are ignored.", - required: false, - examples: [ - "/projects/515fb9337c1074f6fd000003/builds?offset=30&per_page=30", - ], - }, - ], - examples: [ - { - description: "List all builds for a project", - parameters: {}, - expectedOutput: "JSON array of build objects with metadata", - }, - { - description: "List production builds for a project", - parameters: { - releaseStage: "production", - }, - expectedOutput: "JSON array of build objects in the production stage", - }, - { - description: "Get the next page of results", - parameters: { - nextUrl: "/projects/515fb9337c1074f6fd000003/builds?offset=30&per_page=30", - }, - expectedOutput: "JSON array of build objects with metadata from the next page", - } - ], - hints: ["For more detailed results use the Get Build tool"], - readOnly: true, - idempotent: true, - outputFormat: "JSON array of build summary objects with metadata", - }, - async (args, _extra) => { - const project = await this.getInputProject(args.projectId); - const { builds, nextUrl } = await this.listBuilds(project.id, { - release_stage: args.releaseStage, - next_url: args.nextUrl, - }) - - return { - content: [ - { - type: "text", - text: JSON.stringify({ - builds, - next: nextUrl, - }), - } ], }; } ); - - register( - { - title: "Get Build", - summary: "Get more details for a specific build by its ID", - purpose: "Retrieve detailed information about a build for analysis and debugging", - useCases: [ - "View build metadata such as version, source control info, and error counts", - "Analyze a specific build to correlate with error spikes or deployments", - "See the stability targets for a project and if the build meets them", - ], - parameters: [ - ...(this.projectApiKey - ? [] - : [ - { - name: "projectId", - type: z.string(), - description: "ID of the project containing the build", - required: true, - }, - ]), - { - name: "buildId", - type: z.string(), - description: "ID of the build to retrieve", - required: true, - examples: ["5f8d0d55c9e77c0017a1b2c3"], - }, - ], - examples: [ - { - description: "Get details for a specific build", - parameters: { - buildId: "5f8d0d55c9e77c0017a1b2c3", - }, - expectedOutput: - "JSON object with build details including version, source control info, error counts and stability data.", - }, - ], - hints: ["Build IDs can be found using the List builds tool"], - readOnly: true, - idempotent: true, - outputFormat: - "JSON object containing build details along with stability metrics such as user and session stability, and whether it meets project targets", - }, - async (args, _extra) => { - if (!args.buildId) throw new Error("buildId argument is required"); - const build = await this.getBuild((await this.getInputProject(args.projectId)).id, args.buildId); - return { - content: [{ type: "text", text: JSON.stringify(build) }], - }; - } - ); - - register({ - title: "List Releases", - summary: "List releases for a project with optional filtering by release stage", - purpose: "Retrieve a list of release summaries to analyze deployment history and associated errors", - useCases: [ - "View recent releases to correlate with error spikes", - "Filter releases by stage (e.g. production, staging) for targeted analysis", - ], - parameters: [ - ...(this.projectApiKey - ? [] - : [ - { - name: "projectId", - type: z.string(), - description: "ID of the project to list releases for", - required: true, - }, - ]), - { - name: "releaseStage", - type: z.string(), - description: "Filter releases by this stage (e.g. production, staging)", - required: false, - examples: ["production", "staging"], - }, - { - name: "visibleOnly", - type: z.boolean().default(true), - description: "Whether to only include releases that are marked as visible (default: true)", - required: true, - examples: ["true", "false"], - }, - { - name: "nextUrl", - type: z.string(), - description: - "URL for retrieving the next page of results. Use the value in the previous response to get the next page when more results are available. If provided, other parameters are ignored.", - required: false, - examples: [ - "/projects/515fb9337c1074f6fd000003/releases?offset=30&per_page=30", - ], - }, - ], - examples: [ - { - description: "List all releases for a project", - parameters: {}, - expectedOutput: "JSON array of release objects with metadata", - }, - { - description: "List production releases for a project", - parameters: { - releaseStage: "production", - }, - expectedOutput: "JSON array of release objects in the production stage", - }, - { - description: "Get the next page of results", - parameters: { - nextUrl: "/projects/515fb9337c1074f6fd000003/releases?offset=30&per_page=30", - }, - expectedOutput: "JSON array of release objects with metadata from the next page", - }, - ], - hints: ["For more detailed results use the Get Release tool"], - readOnly: true, - idempotent: true, - outputFormat: "JSON array of release summary objects with metadata", - }, async (args, _extra) => { - const { releases, nextUrl } = await this.listReleases((await this.getInputProject(args.projectId)).id, { - release_stage_name: args.releaseStage ?? "production", - visible_only: args.visibleOnly, - next_url: args.nextUrl ?? null, - }) - - return { - content: [ - { - type: "text", - text: JSON.stringify({ - releases, - next: nextUrl ?? null, - }), - }, - ], - }; - }) - - register({ - title: "Get Release", - summary: "Get more details for a specific release by its ID", - purpose: "Retrieve detailed information about a release for analysis and debugging", - useCases: [ - "View release metadata such as version, source control info, and error counts", - "Analyze a specific release to correlate with error spikes or deployments", - "See the stability targets for a project and if the release meets them", - ], - parameters: [ - ...(this.projectApiKey - ? [] - : [ - { - name: "projectId", - type: z.string(), - description: "ID of the project containing the release", - required: true, - }, - ]), - { - name: "releaseId", - type: z.string(), - description: "ID of the release to retrieve", - required: true, - examples: ["5f8d0d55c9e77c0017a1b2c3"], - }, - ], - examples: [ - { - description: "Get details for a specific release", - parameters: { - releaseId: "5f8d0d55c9e77c0017a1b2c3", - }, - expectedOutput: - "JSON object with release details including version, source control info, error counts and stability data.", - }, - ], - hints: ["Release IDs can be found using the List releases tool"], - readOnly: true, - idempotent: true, - outputFormat: - "JSON object containing release details along with stability metrics such as user and session stability, and whether it meets project targets", - }, async (args, _extra) => { - if (!args.releaseId) throw new Error("releaseId argument is required"); - const release = await this.getRelease((await this.getInputProject(args.projectId)).id, args.releaseId); - return { - content: [{ type: "text", text: JSON.stringify(release) }], - }; - }) - - register({ - title: "List Builds in Release", - summary: "List builds associated with a specific release", - purpose: "Retrieve a list of builds for a given release to analyze deployment history and associated errors", - useCases: [ - "View builds within a release to correlate with error spikes", - "Analyze the composition of a release by examining its builds", - ], - parameters: [ - ...(this.projectApiKey - ? [] - : [ - { - name: "projectId", - type: z.string(), - description: "ID of the project containing the release", - required: true, - }, - ]), - { - name: "releaseId", - type: z.string(), - description: "ID of the release to list builds for", - required: true, - examples: ["5f8d0d55c9e77c0017a1b2c3"], - }, - ], - examples: [ - { - description: "List all builds in a specific release", - parameters: { - releaseId: "5f8d0d55c9e77c0017a1b2c3", - }, - expectedOutput: "JSON array of build objects with metadata", - }, - ], - hints: ["Release IDs can be found using the List releases tool"], - readOnly: true, - idempotent: true, - outputFormat: "JSON array of build summary objects with metadata", - }, async (args, _extra) => { - if (!args.releaseId) throw new Error("releaseId argument is required"); - const builds = await this.listBuildsInRelease(args.releaseId); - return { - content: [{ type: "text", text: JSON.stringify(builds) }], - }; - }) - } - - registerResources(register: RegisterResourceFunction): void { - register( - "event", - "{id}", - async (uri, variables, _extra) => { - return { - contents: [{ - uri: uri.href, - text: JSON.stringify(await this.getEvent(variables.id as string)) - }] - } - } - ) } } diff --git a/src/bugsnag/shared-services.ts b/src/bugsnag/shared-services.ts new file mode 100644 index 00000000..b768c615 --- /dev/null +++ b/src/bugsnag/shared-services.ts @@ -0,0 +1,377 @@ +import * as NodeCache from "node-cache"; +import { SharedServices, BugsnagToolError } from "./types.js"; +import { CurrentUserAPI, ErrorAPI } from "./client/index.js"; +import { Organization, Project } from "./client/api/CurrentUser.js"; +import { + ProjectAPI, + EventField, + ListBuildsOptions, + BuildResponse, + BuildSummaryResponse, + StabilityData, + ListReleasesOptions, + ReleaseResponse, + ReleaseSummaryResponse, + ProjectStabilityTargets, + BuildResponseAny, + ReleaseResponseAny +} from "./client/api/Project.js"; +import { getNextUrlPathFromHeader } from "./client/api/base.js"; + + +/** + * Cache keys used throughout the application + */ +const CACHE_KEYS = { + ORG: "bugsnag_org", + PROJECTS: "bugsnag_projects", + CURRENT_PROJECT: "bugsnag_current_project", + CURRENT_PROJECT_EVENT_FILTERS: "bugsnag_current_project_event_filters", + BUILD: "bugsnag_build", // + buildId + RELEASE: "bugsnag_release", // + releaseId + BUILDS_IN_RELEASE: "bugsnag_builds_in_release", // + releaseId + STABILITY_TARGETS: "bugsnag_stability_targets", // + projectId + PROJECT_LOOKUP: "bugsnag_project_lookup" // + projectId for fast project lookups +} as const; + +/** + * Cache TTL values in seconds + */ +const CACHE_TTL = { + DEFAULT: 24 * 60 * 60, // 24 hours + SHORT: 5 * 60, // 5 minutes for frequently changing data + MEDIUM: 60 * 60, // 1 hour for moderately changing data + LONG: 7 * 24 * 60 * 60 // 7 days for rarely changing data +} as const; + +/** + * Event fields to exclude from project event filters + */ +const EXCLUDED_EVENT_FIELDS = new Set([ + "search" // This searches multiple fields and is more a convenience for humans +]); + +/** + * Implementation of SharedServices that provides common functionality to all tools + */ +export class BugsnagSharedServices implements SharedServices { + private currentUserApi: CurrentUserAPI; + private errorsApi: ErrorAPI; + private projectApi: ProjectAPI; + private cache: NodeCache; + private projectApiKey?: string; + private appEndpoint: string; + private apiEndpoint: string; + + constructor( + currentUserApi: CurrentUserAPI, + errorsApi: ErrorAPI, + projectApi: ProjectAPI, + cache: NodeCache, + appEndpoint: string, + apiEndpoint: string, + projectApiKey?: string + ) { + this.currentUserApi = currentUserApi; + this.errorsApi = errorsApi; + this.projectApi = projectApi; + this.cache = cache; + this.appEndpoint = appEndpoint; + this.apiEndpoint = apiEndpoint; + this.projectApiKey = projectApiKey; + } + + // Project management methods + async getProjects(): Promise { + let projects = this.cache.get(CACHE_KEYS.PROJECTS); + if (!projects) { + const org = await this.getOrganization(); + const response = await this.currentUserApi.getOrganizationProjects(org.id); + projects = response.body || []; + this.cache.set(CACHE_KEYS.PROJECTS, projects, CACHE_TTL.MEDIUM); + + // Create individual project lookups for faster access + for (const project of projects) { + this.cache.set(`${CACHE_KEYS.PROJECT_LOOKUP}_${project.id}`, project, CACHE_TTL.MEDIUM); + } + } + return projects; + } + + async getProject(projectId: string): Promise { + // Try individual project lookup first for better performance + let project: Project | null = this.cache.get(`${CACHE_KEYS.PROJECT_LOOKUP}_${projectId}`) || null; + if (project) { + return project; + } + + // Fallback to full projects list + const projects = await this.getProjects(); + project = projects.find((p) => p.id === projectId) || null; + + // Cache the individual project for future lookups + if (project) { + this.cache.set(`${CACHE_KEYS.PROJECT_LOOKUP}_${projectId}`, project, CACHE_TTL.MEDIUM); + } + + return project; + } + + async getCurrentProject(): Promise { + let project = this.cache.get(CACHE_KEYS.CURRENT_PROJECT) ?? null; + if (!project && this.projectApiKey) { + const projects = await this.getProjects(); + project = projects.find((p) => p.api_key === this.projectApiKey) ?? null; + if (!project) { + throw new BugsnagToolError( + `Unable to find project with API key ${this.projectApiKey} in organization.`, + "SharedServices" + ); + } + this.cache.set(CACHE_KEYS.CURRENT_PROJECT, project, CACHE_TTL.LONG); + + // Pre-cache event filters for better performance + if (project) { + try { + const filters = await this.getProjectEventFilters(project); + this.cache.set(CACHE_KEYS.CURRENT_PROJECT_EVENT_FILTERS, filters, CACHE_TTL.MEDIUM); + } catch (error) { + // Don't fail if event filters can't be cached + console.warn('Failed to pre-cache event filters:', error); + } + } + } + return project; + } + + async getInputProject(projectId?: string): Promise { + if (typeof projectId === 'string') { + const maybeProject = await this.getProject(projectId); + if (!maybeProject) { + throw new BugsnagToolError( + `Project with ID ${projectId} not found.`, + "SharedServices" + ); + } + return maybeProject; + } else { + const currentProject = await this.getCurrentProject(); + if (!currentProject) { + throw new BugsnagToolError( + 'No current project found. Please provide a projectId or configure a project API key.', + "SharedServices" + ); + } + return currentProject; + } + } + + // API client access methods + getCurrentUserApi(): CurrentUserAPI { + return this.currentUserApi; + } + + getErrorsApi(): ErrorAPI { + return this.errorsApi; + } + + getProjectApi(): ProjectAPI { + return this.projectApi; + } + + // Caching methods + getCache(): NodeCache { + return this.cache; + } + + // URL generation methods + async getDashboardUrl(project: Project): Promise { + return `${this.appEndpoint}/${(await this.getOrganization()).slug}/${project.slug}`; + } + + async getErrorUrl(project: Project, errorId: string, queryString = ''): Promise { + const dashboardUrl = await this.getDashboardUrl(project); + return `${dashboardUrl}/errors/${errorId}${queryString}`; + } + + // Configuration methods + getProjectApiKey(): string | undefined { + return this.projectApiKey; + } + + hasProjectApiKey(): boolean { + return !!this.projectApiKey; + } + + // Organization methods + async getOrganization(): Promise { + let org = this.cache.get(CACHE_KEYS.ORG); + if (!org) { + const response = await this.currentUserApi.listUserOrganizations(); + const orgs = response.body || []; + if (!orgs || orgs.length === 0) { + throw new BugsnagToolError( + "No organizations found for the current user.", + "SharedServices" + ); + } + org = orgs[0]; + this.cache.set(CACHE_KEYS.ORG, org, CACHE_TTL.LONG); + } + return org; + } + + // Event filter methods + async getProjectEventFilters(project: Project): Promise { + let filtersResponse = (await this.projectApi.listProjectEventFields(project.id)).body; + if (!filtersResponse || filtersResponse.length === 0) { + throw new BugsnagToolError( + `No event fields found for project ${project.name}.`, + "SharedServices" + ); + } + filtersResponse = filtersResponse.filter(field => !EXCLUDED_EVENT_FIELDS.has(field.display_id)); + return filtersResponse; + } + + // Event operation methods + async getEvent(eventId: string, projectId?: string): Promise { + const projectIds = projectId ? [projectId] : (await this.getProjects()).map((p) => p.id); + const projectEvents = await Promise.all( + projectIds.map((projectId: string) => + this.errorsApi.viewEventById(projectId, eventId).catch(_e => null) + ) + ); + return projectEvents.find(event => event && !!event.body)?.body || null; + } + + async updateError(projectId: string, errorId: string, operation: string, options?: any): Promise { + const errorUpdateRequest = { + operation: operation, + ...options + }; + const response = await this.errorsApi.updateErrorOnProject(projectId, errorId, errorUpdateRequest); + return response.status === 200 || response.status === 204; + } + + // Build operation methods + async listBuilds(projectId: string, opts: ListBuildsOptions): Promise<{ builds: (BuildSummaryResponse & StabilityData)[], nextUrl: string | null }> { + const response = await this.projectApi.listBuilds(projectId, opts); + const fetchedBuilds = response.body || []; + const nextUrl = getNextUrlPathFromHeader(response.headers, this.apiEndpoint); + + const stabilityTargets = await this.getProjectStabilityTargets(projectId); + const formattedBuilds = fetchedBuilds.map( + (b) => this.addStabilityData(b, stabilityTargets) + ); + + return { builds: formattedBuilds, nextUrl }; + } + + async getBuild(projectId: string, buildId: string): Promise { + const cacheKey = `${CACHE_KEYS.BUILD}_${buildId}`; + const build = this.cache.get(cacheKey); + if (build) return build; + + const fetchedBuild = (await this.projectApi.getBuild(projectId, buildId)).body; + if (!fetchedBuild) { + throw new BugsnagToolError( + `No build for ${buildId} found.`, + "SharedServices" + ); + } + + const stabilityTargets = await this.getProjectStabilityTargets(projectId); + const formattedBuild = this.addStabilityData(fetchedBuild, stabilityTargets); + this.cache.set(cacheKey, formattedBuild, CACHE_TTL.SHORT); + return formattedBuild; + } + + // Release operation methods + async listReleases(projectId: string, opts: ListReleasesOptions): Promise<{ releases: (ReleaseSummaryResponse & StabilityData)[], nextUrl: string | null }> { + const response = await this.projectApi.listReleases(projectId, opts); + const fetchedReleases = response.body || []; + const nextUrl = getNextUrlPathFromHeader(response.headers, this.apiEndpoint); + + const stabilityTargets = await this.getProjectStabilityTargets(projectId); + const formattedReleases = fetchedReleases.map( + (r) => this.addStabilityData(r, stabilityTargets) + ); + + return { releases: formattedReleases, nextUrl }; + } + + async getRelease(projectId: string, releaseId: string): Promise { + const cacheKey = `${CACHE_KEYS.RELEASE}_${releaseId}`; + const release = this.cache.get(cacheKey); + if (release) return release; + + const fetchedRelease = (await this.projectApi.getRelease(releaseId)).body; + if (!fetchedRelease) { + throw new BugsnagToolError( + `No release for ${releaseId} found.`, + "SharedServices" + ); + } + + const stabilityTargets = await this.getProjectStabilityTargets(projectId); + const formattedRelease = this.addStabilityData(fetchedRelease, stabilityTargets); + this.cache.set(cacheKey, formattedRelease, CACHE_TTL.SHORT); + return formattedRelease; + } + + async listBuildsInRelease(releaseId: string): Promise { + const cacheKey = `${CACHE_KEYS.BUILDS_IN_RELEASE}_${releaseId}`; + const builds = this.cache.get(cacheKey); + if (builds) return builds; + + const fetchedBuilds = (await this.projectApi.listBuildsInRelease(releaseId)).body || []; + this.cache.set(cacheKey, fetchedBuilds, CACHE_TTL.SHORT); + return fetchedBuilds; + } + + // Stability operation methods + async getProjectStabilityTargets(projectId: string): Promise { + const cacheKey = `${CACHE_KEYS.STABILITY_TARGETS}_${projectId}`; + let targets = this.cache.get(cacheKey); + if (!targets) { + targets = await this.projectApi.getProjectStabilityTargets(projectId); + this.cache.set(cacheKey, targets, CACHE_TTL.MEDIUM); + } + return targets; + } + + addStabilityData( + source: T, + stabilityTargets: ProjectStabilityTargets + ): T & StabilityData { + const { stability_target_type, target_stability, critical_stability } = stabilityTargets; + + const user_stability = + source.accumulative_daily_users_seen === 0 // avoid division by zero + ? 0 + : (source.accumulative_daily_users_seen - source.accumulative_daily_users_with_unhandled) / + source.accumulative_daily_users_seen; + + const session_stability = + source.total_sessions_count === 0 // avoid division by zero + ? 0 + : (source.total_sessions_count - source.unhandled_sessions_count) / source.total_sessions_count; + + const stabilityMetric = stability_target_type === "user" ? user_stability : session_stability; + + const meets_target_stability = stabilityMetric >= target_stability.value; + const meets_critical_stability = stabilityMetric >= critical_stability.value; + + return { + ...source, + user_stability, + session_stability, + stability_target_type, + target_stability: target_stability.value, + critical_stability: critical_stability.value, + meets_target_stability, + meets_critical_stability, + }; + } +} diff --git a/src/bugsnag/tool-factory.ts b/src/bugsnag/tool-factory.ts new file mode 100644 index 00000000..6542bf48 --- /dev/null +++ b/src/bugsnag/tool-factory.ts @@ -0,0 +1,195 @@ +/** + * Tool Factory for automatic discovery and instantiation of Bugsnag tools + */ + +import { BugsnagTool, BugsnagToolError } from "./types.js"; + +// Import all tool classes for auto-discovery +import { ListProjectsTool } from "./tools/list-projects-tool.js"; +import { GetErrorTool } from "./tools/get-error-tool.js"; +import { GetEventDetailsTool } from "./tools/get-event-details-tool.js"; +import { ListProjectErrorsTool } from "./tools/list-project-errors-tool.js"; +import { ListProjectEventFiltersTool } from "./tools/list-project-event-filters-tool.js"; +import { UpdateErrorTool } from "./tools/update-error-tool.js"; +import { ListBuildsTool } from "./tools/list-builds-tool.js"; +import { GetBuildTool } from "./tools/get-build-tool.js"; +import { ListBuildsInReleaseTool } from "./tools/list-builds-in-release-tool.js"; +import { ListReleasesTool } from "./tools/list-releases-tool.js"; +import { GetReleaseTool } from "./tools/get-release-tool.js"; + +/** + * Interface for tool class constructors + */ +export interface ToolConstructor { + new(): BugsnagTool; +} + +/** + * Configuration for tool discovery + */ +export interface ToolDiscoveryConfig { + /** Whether to include the ListProjectsTool (only when no project API key is configured) */ + includeListProjects?: boolean; + /** Custom tool classes to include in addition to the default ones */ + customTools?: ToolConstructor[]; + /** Tool names to exclude from discovery */ + excludeTools?: string[]; +} + +/** + * Tool Factory class for automatic tool discovery and instantiation + */ +export class BugsnagToolFactory { + private static readonly DEFAULT_TOOL_CLASSES: ToolConstructor[] = [ + GetErrorTool, + GetEventDetailsTool, + ListProjectErrorsTool, + ListProjectEventFiltersTool, + UpdateErrorTool, + ListBuildsTool, + GetBuildTool, + ListBuildsInReleaseTool, + ListReleasesTool, + GetReleaseTool + ]; + + private static toolInstanceCache = new Map(); + + /** + * Discover and instantiate all available tools based on configuration + */ + static discoverTools(config: ToolDiscoveryConfig = {}): BugsnagTool[] { + const tools: BugsnagTool[] = []; + const toolClasses = this.getToolClasses(config); + + for (const ToolClass of toolClasses) { + try { + // Use cached instance if available (tools are stateless) + const cacheKey = ToolClass.name; + let tool = this.toolInstanceCache.get(cacheKey); + + if (!tool) { + tool = new ToolClass(); + // Validate that the tool implements the required interface + this.validateTool(tool); + this.toolInstanceCache.set(cacheKey, tool); + } + + // Skip excluded tools + if (config.excludeTools?.includes(tool.name)) { + continue; + } + + tools.push(tool); + } catch (error) { + throw new BugsnagToolError( + `Failed to instantiate tool class ${ToolClass.name}: ${error}`, + "ToolFactory", + error as Error + ); + } + } + + return tools; + } + + /** + * Get all tool classes based on configuration + */ + private static getToolClasses(config: ToolDiscoveryConfig): ToolConstructor[] { + const toolClasses: ToolConstructor[] = [...this.DEFAULT_TOOL_CLASSES]; + + // Add ListProjectsTool if configured + if (config.includeListProjects) { + toolClasses.unshift(ListProjectsTool); + } + + // Add custom tools if provided + if (config.customTools) { + toolClasses.push(...config.customTools); + } + + return toolClasses; + } + + /** + * Validate that a tool instance implements the required interface + */ + private static validateTool(tool: any): asserts tool is BugsnagTool { + if (!tool.name || typeof tool.name !== 'string') { + throw new Error('Tool must have a valid name property'); + } + + if (!tool.definition || typeof tool.definition !== 'object') { + throw new Error('Tool must have a valid definition property'); + } + + if (!tool.execute || typeof tool.execute !== 'function') { + throw new Error('Tool must have a valid execute method'); + } + + // Validate definition structure + const { definition } = tool; + const requiredFields = ['title', 'summary', 'purpose', 'useCases', 'parameters', 'examples', 'hints']; + + for (const field of requiredFields) { + if (!(field in definition)) { + throw new Error(`Tool definition must have a '${field}' property`); + } + } + + if (!Array.isArray(definition.parameters)) { + throw new Error('Tool definition parameters must be an array'); + } + + if (!Array.isArray(definition.examples)) { + throw new Error('Tool definition examples must be an array'); + } + + if (!Array.isArray(definition.hints)) { + throw new Error('Tool definition hints must be an array'); + } + + if (!Array.isArray(definition.useCases)) { + throw new Error('Tool definition useCases must be an array'); + } + } + + /** + * Create a tool instance by name (optimized for single tool creation) + */ + static createTool(toolName: string, config: ToolDiscoveryConfig = {}): BugsnagTool | null { + // Use full discovery to respect configuration + const tools = this.discoverTools(config); + return tools.find(tool => tool.name === toolName) || null; + } + + /** + * Clear the tool instance cache (useful for testing) + */ + static clearCache(): void { + this.toolInstanceCache.clear(); + } + + /** + * Get all available tool names + */ + static getAvailableToolNames(config: ToolDiscoveryConfig = {}): string[] { + const tools = this.discoverTools(config); + return tools.map(tool => tool.name); + } + + /** + * Check if a tool is available + */ + static isToolAvailable(toolName: string, config: ToolDiscoveryConfig = {}): boolean { + return this.getAvailableToolNames(config).includes(toolName); + } + + /** + * Get tool count based on configuration + */ + static getToolCount(config: ToolDiscoveryConfig = {}): number { + return this.discoverTools(config).length; + } +} diff --git a/src/bugsnag/tool-registry.ts b/src/bugsnag/tool-registry.ts new file mode 100644 index 00000000..455fdf96 --- /dev/null +++ b/src/bugsnag/tool-registry.ts @@ -0,0 +1,172 @@ +import { BugsnagTool, ToolRegistry, ToolExecutionContext, BugsnagToolError } from "./types.js"; +import { RegisterToolsFunction } from "../common/types.js"; +import { BugsnagToolFactory, ToolDiscoveryConfig } from "./tool-factory.js"; + +/** + * Registry for managing Bugsnag tool discovery and registration + */ +export class BugsnagToolRegistry implements ToolRegistry { + private tools: Map = new Map(); + private discoveredTools: BugsnagTool[] | null = null; + private lastDiscoveryConfig: ToolDiscoveryConfig | undefined = undefined; + + /** + * Register a single tool + */ + registerTool(tool: BugsnagTool): void { + if (this.tools.has(tool.name)) { + throw new BugsnagToolError( + `Tool with name '${tool.name}' is already registered`, + "ToolRegistry" + ); + } + this.tools.set(tool.name, tool); + } + + /** + * Get a specific tool by name + */ + getTool(name: string): BugsnagTool | undefined { + return this.tools.get(name); + } + + /** + * Get all registered tools + */ + getAllTools(): BugsnagTool[] { + return Array.from(this.tools.values()); + } + + /** + * Discover and return all available tools using automatic discovery with caching + */ + discoverTools(config?: ToolDiscoveryConfig): BugsnagTool[] { + // Use cached discovery if config hasn't changed + if (this.discoveredTools && this.configEquals(this.lastDiscoveryConfig, config)) { + return this.discoveredTools; + } + + this.discoveredTools = BugsnagToolFactory.discoverTools(config); + this.lastDiscoveryConfig = config ? { ...config } : undefined; + return this.discoveredTools; + } + + /** + * Compare two discovery configurations for equality + */ + private configEquals(config1?: ToolDiscoveryConfig, config2?: ToolDiscoveryConfig): boolean { + if (!config1 && !config2) return true; + if (!config1 || !config2) return false; + + return ( + config1.includeListProjects === config2.includeListProjects && + JSON.stringify(config1.excludeTools?.sort()) === JSON.stringify(config2.excludeTools?.sort()) && + config1.customTools?.length === config2.customTools?.length + ); + } + + /** + * Register all discovered tools with the MCP server + */ + registerAllTools(register: RegisterToolsFunction, context: ToolExecutionContext, config?: ToolDiscoveryConfig): void { + const startTime = performance.now(); + const tools = this.discoverTools(config); + + // Clear existing tools and register discovered ones + this.clear(); + + for (const tool of tools) { + // Register the tool in our internal registry + this.registerTool(tool); + + // Convert our tool definition to the MCP server format + const toolParams = { + title: tool.definition.title, + summary: tool.definition.summary, + purpose: tool.definition.purpose, + useCases: tool.definition.useCases, + parameters: tool.definition.parameters.map(param => ({ + name: param.name, + type: param.type, + required: param.required, + description: param.description, + examples: param.examples, + constraints: param.constraints + })), + examples: tool.definition.examples, + hints: tool.definition.hints, + outputFormat: tool.definition.outputFormat + }; + + // Register the tool with the MCP server with performance monitoring + register(toolParams, async (args: any) => { + const executionStartTime = performance.now(); + try { + const result = await tool.execute(args, context); + const executionTime = performance.now() - executionStartTime; + + // Log performance metrics for monitoring (only in development or when enabled) + if (process.env.NODE_ENV === 'development' || process.env.BUGSNAG_PERFORMANCE_MONITORING === 'true') { + console.debug(`Tool '${tool.name}' executed in ${executionTime.toFixed(2)}ms`); + } + + return result; + } catch (error) { + const executionTime = performance.now() - executionStartTime; + + // Log error with timing information + if (process.env.NODE_ENV === 'development' || process.env.BUGSNAG_PERFORMANCE_MONITORING === 'true') { + console.debug(`Tool '${tool.name}' failed after ${executionTime.toFixed(2)}ms:`, error); + } + + if (error instanceof BugsnagToolError) { + return { + content: [{ type: "text", text: error.message }], + isError: true + }; + } + + // Wrap unexpected errors + const wrappedError = new BugsnagToolError( + `Unexpected error in tool '${tool.name}': ${error}`, + tool.name, + error as Error + ); + + return { + content: [{ type: "text", text: wrappedError.message }], + isError: true + }; + } + }); + } + + const registrationTime = performance.now() - startTime; + if (process.env.NODE_ENV === 'development' || process.env.BUGSNAG_PERFORMANCE_MONITORING === 'true') { + console.debug(`Registered ${tools.length} tools in ${registrationTime.toFixed(2)}ms`); + } + } + + /** + * Clear all registered tools (useful for testing) + */ + clear(): void { + this.tools.clear(); + this.discoveredTools = null; + this.lastDiscoveryConfig = undefined; + } + + /** + * Get the count of registered tools + */ + getToolCount(): number { + return this.tools.size; + } + + /** + * Check if a tool is registered + */ + hasTool(name: string): boolean { + return this.tools.has(name); + } +} diff --git a/src/bugsnag/tools/get-build-tool.ts b/src/bugsnag/tools/get-build-tool.ts new file mode 100644 index 00000000..e00df73c --- /dev/null +++ b/src/bugsnag/tools/get-build-tool.ts @@ -0,0 +1,102 @@ +/** + * Get Build Tool + * + * Retrieves detailed information about a specific build by its ID. + * Includes caching and stability data enhancement for comprehensive build analysis. + */ + +import { + ToolDefinition, + ToolExecutionContext, + ToolResult, + BaseBugsnagTool, + ProjectArgs +} from "../types.js"; +import { + CommonParameterDefinitions, + validateToolArgs, + createSuccessResult, + executeWithErrorHandling, + createConditionalProjectIdParam +} from "../utils/tool-utilities.js"; + +/** + * Arguments interface for the Get Build tool + */ +export interface GetBuildArgs extends ProjectArgs { + buildId: string; +} + +/** + * Get Build Tool implementation + * + * Retrieves detailed information about a specific build including stability data, + * error rates, and target compliance. Uses caching for improved performance. + */ +export class GetBuildTool extends BaseBugsnagTool { + readonly name = "get_build"; + + readonly definition: ToolDefinition = { + title: "Get Build", + summary: "Get more details for a specific build by its ID", + purpose: "Retrieve detailed information about a build for analysis and debugging", + useCases: [ + "Get comprehensive build details including stability metrics", + "Analyze build quality and error rates for specific deployments", + "Monitor stability target compliance for individual builds", + "Debug build-specific issues and performance metrics" + ], + parameters: [ + ...createConditionalProjectIdParam(false), // Will be set properly during registration + CommonParameterDefinitions.buildId() + ], + examples: [ + { + description: "Get details for a specific build", + parameters: { + projectId: "515fb9337c1074f6fd000003", + buildId: "build-123" + }, + expectedOutput: "JSON object with complete build details including stability data, error counts, and metadata" + } + ], + hints: [ + "Build IDs can be found using the List builds tool", + "Build details include stability metrics showing user and session stability", + "Stability data shows whether the build meets target and critical stability thresholds", + "Build information is cached for improved performance on repeated requests" + ] + }; + + async execute(args: GetBuildArgs, context: ToolExecutionContext): Promise { + return executeWithErrorHandling(this.name, async () => { + // Validate arguments + validateToolArgs(args, this.definition.parameters, this.name); + + const { services } = context; + + // Validate required parameters + if (!args.buildId) { + throw new Error("buildId argument is required"); + } + + // Get the project + const project = await services.getInputProject(args.projectId); + + // Get the build details with caching and stability data + const build = await services.getBuild(project.id, args.buildId); + + return createSuccessResult(build); + }); + } + + /** + * Update parameter definitions based on whether project API key is configured + */ + updateParametersForProjectApiKey(hasProjectApiKey: boolean): void { + this.definition.parameters = [ + ...createConditionalProjectIdParam(hasProjectApiKey), + CommonParameterDefinitions.buildId() + ]; + } +} diff --git a/src/bugsnag/tools/get-error-tool.ts b/src/bugsnag/tools/get-error-tool.ts new file mode 100644 index 00000000..9dcda56a --- /dev/null +++ b/src/bugsnag/tools/get-error-tool.ts @@ -0,0 +1,168 @@ +/** + * Get Error Tool + * + * Retrieves full details on an error, including aggregated and summarized data + * across all events (occurrences) and details of the latest event (occurrence). + */ + +import { + ToolDefinition, + ToolExecutionContext, + ToolResult, + BaseBugsnagTool, + ErrorArgs +} from "../types.js"; +import { + CommonParameterDefinitions, + validateToolArgs, + executeWithErrorHandling +} from "../utils/tool-utilities.js"; +import { FilterObject, toQueryString } from "../client/api/filters.js"; + +/** + * Arguments interface for the Get Error tool + */ +export interface GetErrorArgs extends ErrorArgs { + filters?: FilterObject; +} + +/** + * Get Error Tool implementation + * + * Retrieves comprehensive error details including aggregated data, latest event details, + * pivots for analysis, and a dashboard URL for further investigation. + */ +export class GetErrorTool extends BaseBugsnagTool { + readonly name = "get_error"; + + readonly definition: ToolDefinition = { + title: "Get Error", + summary: "Get full details on an error, including aggregated and summarized data across all events (occurrences) and details of the latest event (occurrence), such as breadcrumbs, metadata and the stacktrace. Use the filters parameter to narrow down the summaries further.", + purpose: "Retrieve all the information required on a specified error to understand who it is affecting and why.", + useCases: [ + "Investigate a specific error found through the List Project Errors tool", + "Understand which types of user are affected by the error using summarized event data", + "Get error details for debugging and root cause analysis", + "Retrieve error metadata for incident reports and documentation" + ], + parameters: [ + CommonParameterDefinitions.errorId(), + CommonParameterDefinitions.filters(false), + ], + examples: [ + { + description: "Get details for a specific error", + parameters: { + errorId: "6863e2af8c857c0a5023b411" + }, + expectedOutput: "JSON object with error details including message, stack trace, occurrence count, and metadata" + }, + { + description: "Get error details with filters applied", + parameters: { + errorId: "6863e2af8c857c0a5023b411", + filters: { + "event.since": [{ "type": "eq", "value": "7d" }], + "user.email": [{ "type": "eq", "value": "user@example.com" }] + } + }, + expectedOutput: "JSON object with filtered error details and latest event matching the filters" + } + ], + hints: [ + "Error IDs can be found using the List Project Errors tool", + "Use this after filtering errors to get detailed information about specific errors", + "If you used a filter to get this error, you can pass the same filters here to restrict the results or apply further filters", + "The URL provided in the response should be shown to the user in all cases as it allows them to view the error in the dashboard and perform further analysis" + ], + outputFormat: "JSON object containing: " + + " - error_details: Aggregated data about the error, including first and last seen occurrence" + + " - latest_event: Detailed information about the most recent occurrence of the error, including stacktrace, breadcrumbs, user and context" + + " - pivots: List of pivots (summaries) for the error, which can be used to analyze patterns in occurrences" + + " - url: A link to the error in the dashboard - this should be shown to the user for them to perform further analysis" + }; + + constructor(hasProjectApiKey: boolean = false) { + super(); + + // Add conditional projectId parameter if no project API key is configured + if (!hasProjectApiKey) { + this.definition.parameters.unshift(CommonParameterDefinitions.projectId(true)); + } + } + + async execute(args: GetErrorArgs, context: ToolExecutionContext): Promise { + return executeWithErrorHandling(this.name, async () => { + // Validate arguments + validateToolArgs(args, this.definition.parameters, this.name); + + const { services } = context; + + // Validate required errorId + if (!args.errorId) { + throw new Error("Both projectId and errorId arguments are required"); + } + + // Get the project (either from projectId or current project) + const project = await services.getInputProject(args.projectId); + + // Get error details from the API + const errorsApi = services.getErrorsApi(); + const errorDetailsResponse = await errorsApi.viewErrorOnProject(project.id, args.errorId); + const errorDetails = errorDetailsResponse.body; + + if (!errorDetails) { + throw new Error(`Error with ID ${args.errorId} not found in project ${project.id}.`); + } + + // Build query parameters for getting the latest event + const params = new URLSearchParams(); + params.append('sort', 'timestamp'); + params.append('direction', 'desc'); + params.append('per_page', '1'); + params.append('full_reports', 'true'); + + // Combine error filter with any additional filters provided + const filters: FilterObject = { + "error": [{ type: "eq", value: args.errorId }], + ...args.filters + }; + + const filtersQueryString = toQueryString(filters); + const listEventsQueryString = `?${params}&${filtersQueryString}`; + + // Get the latest event for this error using the events endpoint with filters + let latestEvent = null; + try { + const eventsResponse = await errorsApi.listEventsOnProject(project.id, listEventsQueryString); + const events = eventsResponse.body || []; + latestEvent = events[0] || null; + } catch (e) { + console.warn("Failed to fetch latest event:", e); + // Continue without latest event rather than failing the entire request + } + + // Get error pivots for analysis + let pivots: any[] = []; + try { + const pivotsResponse = await errorsApi.listErrorPivots(project.id, args.errorId); + pivots = pivotsResponse.body || []; + } catch (e) { + console.warn("Failed to fetch error pivots:", e); + // Continue without pivots rather than failing the entire request + } + + // Generate dashboard URL with filters + const errorUrl = await services.getErrorUrl(project, args.errorId, `?${filtersQueryString}`); + + const result = { + error_details: errorDetails, + latest_event: latestEvent, + pivots: pivots, + url: errorUrl + }; + + return result; + }); + } +} diff --git a/src/bugsnag/tools/get-event-details-tool.ts b/src/bugsnag/tools/get-event-details-tool.ts new file mode 100644 index 00000000..3976d685 --- /dev/null +++ b/src/bugsnag/tools/get-event-details-tool.ts @@ -0,0 +1,120 @@ +/** + * Get Event Details Tool + * + * Retrieves detailed information about a specific event using its dashboard URL. + * This tool parses the URL to extract project slug and event ID, then fetches + * the event details from the API. + */ + +import { + ToolDefinition, + ToolExecutionContext, + ToolResult, + BaseBugsnagTool, + BugsnagToolError +} from "../types.js"; +import { + CommonParameterDefinitions, + validateToolArgs, + executeWithErrorHandling, + extractProjectSlugFromUrl, + extractEventIdFromUrl +} from "../utils/tool-utilities.js"; + +/** + * Arguments interface for the Get Event Details tool + */ +export interface GetEventDetailsArgs { + link: string; +} + +/** + * Get Event Details Tool implementation + * + * Parses dashboard URLs to extract project and event information, + * then retrieves detailed event data from the API. + */ +export class GetEventDetailsTool extends BaseBugsnagTool { + readonly name = "get_event_details"; + + readonly definition: ToolDefinition = { + title: "Get Event Details", + summary: "Get detailed information about a specific event using its dashboard URL", + purpose: "Retrieve event details directly from a dashboard URL for quick debugging", + useCases: [ + "Get event details when given a dashboard URL from a user or notification", + "Extract event information from shared links or browser URLs", + "Quick lookup of event details without needing separate project and event IDs" + ], + parameters: [ + CommonParameterDefinitions.dashboardUrl() + ], + examples: [ + { + description: "Get event details from a dashboard URL", + parameters: { + link: "https://app.bugsnag.com/my-org/my-project/errors/6863e2af8c857c0a5023b411?event_id=6863e2af012caf1d5c320000" + }, + expectedOutput: "JSON object with complete event details including stack trace, metadata, and context" + } + ], + hints: [ + "The URL must contain both project slug in the path and event_id in query parameters", + "This is useful when users share BugSnag dashboard URLs and you need to extract the event data" + ] + }; + + async execute(args: GetEventDetailsArgs, context: ToolExecutionContext): Promise { + return executeWithErrorHandling(this.name, async () => { + // Validate arguments + validateToolArgs(args, this.definition.parameters, this.name); + + const { services } = context; + + if (!args.link) { + throw new BugsnagToolError("link argument is required", this.name); + } + + // Extract project slug and event ID from the URL + let projectSlug: string; + let eventId: string; + + try { + projectSlug = extractProjectSlugFromUrl(args.link); + eventId = extractEventIdFromUrl(args.link); + } catch (error) { + if (error instanceof BugsnagToolError) { + throw error; + } + throw new BugsnagToolError( + "Both projectSlug and eventId must be present in the link", + this.name, + error as Error + ); + } + + // Find the project by slug + const projects = await services.getProjects(); + const project = projects.find((p) => p.slug === projectSlug); + + if (!project) { + throw new BugsnagToolError( + "Project with the specified slug not found.", + this.name + ); + } + + // Get the event details using the shared service + const eventDetails = await services.getEvent(eventId, project.id); + + if (!eventDetails) { + throw new BugsnagToolError( + `Event with ID ${eventId} not found in project ${project.id}.`, + this.name + ); + } + + return eventDetails; + }); + } +} diff --git a/src/bugsnag/tools/get-release-tool.ts b/src/bugsnag/tools/get-release-tool.ts new file mode 100644 index 00000000..52f5787f --- /dev/null +++ b/src/bugsnag/tools/get-release-tool.ts @@ -0,0 +1,99 @@ +/** + * Get Release Tool + * + * Retrieves detailed information about a specific release by its ID. + * Includes caching for improved performance and stability data integration. + */ + +import { + ToolDefinition, + ToolExecutionContext, + ToolResult, + BaseBugsnagTool, + ProjectArgs +} from "../types.js"; +import { + CommonParameterDefinitions, + validateToolArgs, + createSuccessResult, + executeWithErrorHandling, + createConditionalProjectIdParam +} from "../utils/tool-utilities.js"; + +/** + * Arguments interface for the Get Release tool + */ +export interface GetReleaseArgs extends ProjectArgs { + releaseId: string; +} + +/** + * Get Release Tool implementation + * + * Retrieves detailed information about a specific release including metadata, + * version information, source control details, and stability metrics. + * Uses caching for improved performance. + */ +export class GetReleaseTool extends BaseBugsnagTool { + readonly name = "get_release"; + + readonly definition: ToolDefinition = { + title: "Get Release", + summary: "Get more details for a specific release by its ID", + purpose: "Retrieve detailed information about a release for analysis and debugging", + useCases: [ + "View release metadata such as version, source control info, and error counts", + "Analyze a specific release to correlate with error spikes or deployments", + "See the stability targets for a project and if the release meets them", + "Get comprehensive release information for incident analysis" + ], + parameters: [ + ...createConditionalProjectIdParam(false), // Will be set properly during registration + CommonParameterDefinitions.releaseId() + ], + examples: [ + { + description: "Get details for a specific release", + parameters: { + projectId: "515fb9337c1074f6fd000003", + releaseId: "5f8d0d55c9e77c0017a1b2c3" + }, + expectedOutput: "JSON object with release details including version, source control info, error counts and stability data" + } + ], + hints: [ + "Release IDs can be found using the List Releases tool", + "Release details include stability metrics and target compliance", + "Release information is cached for improved performance", + "Use this tool to get comprehensive information about a specific release" + ], + outputFormat: "JSON object containing release details along with stability metrics such as user and session stability, and whether it meets project targets" + }; + + async execute(args: GetReleaseArgs, context: ToolExecutionContext): Promise { + return executeWithErrorHandling(this.name, async () => { + // Validate arguments + validateToolArgs(args, this.definition.parameters, this.name); + + const { services } = context; + + // Get the project + const project = await services.getInputProject(args.projectId); + + // Get the release with caching and stability data + const release = await services.getRelease(project.id, args.releaseId); + + return createSuccessResult(release); + }); + } + + /** + * Update parameter definitions based on whether project API key is configured + */ + updateParametersForProjectApiKey(hasProjectApiKey: boolean): void { + this.definition.parameters = [ + ...createConditionalProjectIdParam(hasProjectApiKey), + CommonParameterDefinitions.releaseId() + ]; + } +} diff --git a/src/bugsnag/tools/index.ts b/src/bugsnag/tools/index.ts new file mode 100644 index 00000000..fbb5699e --- /dev/null +++ b/src/bugsnag/tools/index.ts @@ -0,0 +1,20 @@ +// Export all tool-related types and classes +export * from "../types.js"; +export * from "../tool-registry.js"; +export * from "../shared-services.js"; +export * from "../tool-factory.js"; + +// Export individual tools +export * from "./list-projects-tool.js"; +export * from "./get-error-tool.js"; +export * from "./get-event-details-tool.js"; +export * from "./list-project-errors-tool.js"; +export * from "./list-project-event-filters-tool.js"; +export * from "./update-error-tool.js"; + +// Build-related tools +export * from "./list-builds-tool.js"; +export * from "./get-build-tool.js"; +export * from "./list-builds-in-release-tool.js"; +export * from "./list-releases-tool.js"; +export * from "./get-release-tool.js"; diff --git a/src/bugsnag/tools/list-builds-in-release-tool.ts b/src/bugsnag/tools/list-builds-in-release-tool.ts new file mode 100644 index 00000000..87c298c2 --- /dev/null +++ b/src/bugsnag/tools/list-builds-in-release-tool.ts @@ -0,0 +1,104 @@ +/** + * List Builds in Release Tool + * + * Lists builds associated with a specific release. + * Includes caching for improved performance and error handling for invalid release IDs. + */ + +import { + ToolDefinition, + ToolExecutionContext, + ToolResult, + BaseBugsnagTool +} from "../types.js"; +import { + CommonParameterDefinitions, + validateToolArgs, + createSuccessResult, + executeWithErrorHandling, + formatListResult +} from "../utils/tool-utilities.js"; + +/** + * Arguments interface for the List Builds in Release tool + */ +export interface ListBuildsInReleaseArgs { + releaseId: string; +} + +/** + * List Builds in Release Tool implementation + * + * Lists all builds associated with a specific release. Uses caching for improved + * performance and provides comprehensive error handling for invalid release IDs. + */ +export class ListBuildsInReleaseTool extends BaseBugsnagTool { + readonly name = "list_builds_in_release"; + + readonly definition: ToolDefinition = { + title: "List Builds in Release", + summary: "List builds associated with a specific release", + purpose: "Retrieve a list of builds for a given release to analyze deployment history and associated errors", + useCases: [ + "Analyze which builds are included in a specific release", + "Track build composition and deployment history for releases", + "Debug release-specific issues by examining constituent builds", + "Monitor build quality within release contexts" + ], + parameters: [ + CommonParameterDefinitions.releaseId() + ], + examples: [ + { + description: "Get all builds in a specific release", + parameters: { + releaseId: "5f8d0d55c9e77c0017a1b2c3" + }, + expectedOutput: "JSON object with array of builds associated with the release" + } + ], + hints: [ + "Release IDs can be found using the List Releases tool", + "This tool shows which builds are grouped together in a release", + "Build information is cached for improved performance", + "Use this to understand the composition of a release before deployment" + ] + }; + + async execute(args: ListBuildsInReleaseArgs, context: ToolExecutionContext): Promise { + return executeWithErrorHandling(this.name, async () => { + // Validate arguments + validateToolArgs(args, this.definition.parameters, this.name); + + const { services } = context; + + // Validate required parameters + if (!args.releaseId) { + throw new Error("releaseId argument is required"); + } + + try { + // Get the builds for the release with caching + const builds = await services.listBuildsInRelease(args.releaseId); + + // Format the result as a list + const result = formatListResult(builds, builds.length); + + return createSuccessResult(result); + } catch (error) { + // Handle specific error cases for invalid release IDs + if (error instanceof Error) { + if (error.message.includes("404") || error.message.includes("not found")) { + throw new Error(`Release with ID ${args.releaseId} not found. Please verify the release ID is correct.`); + } + if (error.message.includes("403") || error.message.includes("unauthorized")) { + throw new Error(`Access denied to release ${args.releaseId}. Please check your permissions.`); + } + } + + // Re-throw other errors as-is + throw error; + } + }); + } +} diff --git a/src/bugsnag/tools/list-builds-tool.ts b/src/bugsnag/tools/list-builds-tool.ts new file mode 100644 index 00000000..fa416e7f --- /dev/null +++ b/src/bugsnag/tools/list-builds-tool.ts @@ -0,0 +1,144 @@ +/** + * List Builds Tool + * + * Lists builds for a project with optional filtering by release stage and pagination. + * Includes stability data integration for enhanced build analysis. + */ + +import { + ToolDefinition, + ToolExecutionContext, + ToolResult, + BaseBugsnagTool, + ProjectArgs, + PaginationArgs +} from "../types.js"; +import { + CommonParameterDefinitions, + validateToolArgs, + createSuccessResult, + executeWithErrorHandling, + + createConditionalProjectIdParam +} from "../utils/tool-utilities.js"; + +/** + * Arguments interface for the List Builds tool + */ +export interface ListBuildsArgs extends ProjectArgs, PaginationArgs { + releaseStage?: string; + nextUrl?: string; +} + +/** + * List Builds Tool implementation + * + * Lists builds for a project with optional filtering by release stage. + * Includes stability data and pagination support for comprehensive build analysis. + */ +export class ListBuildsTool extends BaseBugsnagTool { + readonly name = "list_builds"; + + readonly definition: ToolDefinition = { + title: "List Builds", + summary: "List builds for a project with optional filtering by release stage", + purpose: "Retrieve a list of build summaries to analyze deployment history and associated errors", + useCases: [ + "Analyze deployment history and build stability over time", + "Filter builds by release stage to focus on specific environments", + "Monitor build quality and error rates across deployments", + "Track stability metrics and targets for builds" + ], + parameters: [ + ...createConditionalProjectIdParam(false), // Will be set properly during registration + CommonParameterDefinitions.releaseStage(), + CommonParameterDefinitions.perPage(30), + CommonParameterDefinitions.nextUrl() + ], + examples: [ + { + description: "Get all builds for a project", + parameters: { + projectId: "515fb9337c1074f6fd000003" + }, + expectedOutput: "JSON object with builds array, count, and optional next URL for pagination" + }, + { + description: "Get production builds only", + parameters: { + projectId: "515fb9337c1074f6fd000003", + releaseStage: "production" + }, + expectedOutput: "JSON object with filtered builds array and metadata" + }, + { + description: "Get next page of results", + parameters: { + projectId: "515fb9337c1074f6fd000003", + nextUrl: "https://api.bugsnag.com/projects/515fb9337c1074f6fd000003/builds?offset=30" + }, + expectedOutput: "JSON object with next page of builds" + } + ], + hints: [ + "For more detailed results use the Get Build tool", + "Use releaseStage parameter to filter builds by environment (production, staging, etc.)", + "Builds include stability data showing error rates and target compliance", + "Use pagination for projects with many builds to manage response size" + ] + }; + + async execute(args: ListBuildsArgs, context: ToolExecutionContext): Promise { + return executeWithErrorHandling(this.name, async () => { + // Validate arguments + validateToolArgs(args, this.definition.parameters, this.name); + + const { services } = context; + + // Get the project + const project = await services.getInputProject(args.projectId); + + // Prepare options for the API call + const options: any = {}; + + if (args.releaseStage) { + options.release_stage = args.releaseStage; + } + + if (args.per_page) { + options.per_page = args.per_page; + } + + if (args.nextUrl) { + options.next_url = args.nextUrl; + } + + // Call the service to list builds + const { builds, nextUrl } = await services.listBuilds(project.id, options); + + // Format the result with standard data structure + const result: any = { + data: builds, + count: builds.length + }; + + if (nextUrl) { + result.next = nextUrl; + } + + return createSuccessResult(result); + }); + } + + /** + * Update parameter definitions based on whether project API key is configured + */ + updateParametersForProjectApiKey(hasProjectApiKey: boolean): void { + this.definition.parameters = [ + ...createConditionalProjectIdParam(hasProjectApiKey), + CommonParameterDefinitions.releaseStage(), + CommonParameterDefinitions.perPage(30), + CommonParameterDefinitions.nextUrl() + ]; + } +} diff --git a/src/bugsnag/tools/list-project-errors-tool.ts b/src/bugsnag/tools/list-project-errors-tool.ts new file mode 100644 index 00000000..e25893fc --- /dev/null +++ b/src/bugsnag/tools/list-project-errors-tool.ts @@ -0,0 +1,199 @@ +/** + * List Project Errors Tool + * + * Lists and searches errors in a project using customizable filters and pagination. + * Supports complex filter handling, default parameter logic, and pagination with next URL generation. + */ + +import { + ToolDefinition, + ToolExecutionContext, + ToolResult, + BaseBugsnagTool, + ProjectArgs, + PaginationArgs, + SortingArgs, + SharedServices +} from "../types.js"; +import { + CommonParameterDefinitions, + validateToolArgs, + + executeWithErrorHandling, + formatPaginatedResult, + TOOL_DEFAULTS +} from "../utils/tool-utilities.js"; +import { FilterObject } from "../client/api/filters.js"; +import { ListProjectErrorsOptions } from "../client/api/Error.js"; +import { EventField } from "../client/api/Project.js"; + +/** + * Arguments interface for the List Project Errors tool + */ +export interface ListProjectErrorsArgs extends ProjectArgs, PaginationArgs, SortingArgs { + filters?: FilterObject; + sort?: "first_seen" | "last_seen" | "events" | "users" | "unsorted"; + direction?: "asc" | "desc"; + per_page?: number; + next?: string; +} + +/** + * List Project Errors Tool implementation + * + * Provides comprehensive error listing with filtering, sorting, and pagination capabilities. + * Includes default filters for common use cases and validates filter keys against project event fields. + */ +export class ListProjectErrorsTool extends BaseBugsnagTool { + readonly name = "list_project_errors"; + + readonly definition: ToolDefinition = { + title: "List Project Errors", + summary: "List and search errors in a project using customizable filters and pagination", + purpose: "Retrieve filtered list of errors from a project for analysis, debugging, and reporting", + useCases: [ + "Debug recent application errors by filtering for open errors in the last 7 days", + "Generate error reports for stakeholders by filtering specific error types or severity levels", + "Monitor error trends over time using date range filters", + "Find errors affecting specific users or environments using metadata filters" + ], + parameters: [ + CommonParameterDefinitions.filters(false, TOOL_DEFAULTS.DEFAULT_FILTERS), + CommonParameterDefinitions.sort(["first_seen", "last_seen", "events", "users", "unsorted"], "last_seen"), + CommonParameterDefinitions.direction("desc"), + CommonParameterDefinitions.perPage(30), + CommonParameterDefinitions.nextUrl() + ], + examples: [ + { + description: "Find errors affecting a specific user in the last 24 hours", + parameters: { + filters: { + "user.email": [{ "type": "eq", "value": "user@example.com" }], + "event.since": [{ "type": "eq", "value": "24h" }] + } + }, + expectedOutput: "JSON object with a list of errors in the 'data' field, a count of the current page of results in the 'count' field, and a total count of all results in the 'total' field" + }, + { + description: "Get the 10 open errors with the most users affected in the last 30 days", + parameters: { + filters: { + "event.since": [{ "type": "eq", "value": "30d" }], + "error.status": [{ "type": "eq", "value": "open" }] + }, + sort: "users", + direction: "desc", + per_page: 10 + }, + expectedOutput: "JSON object with a list of errors in the 'data' field, a count of the current page of results in the 'count' field, and a total count of all results in the 'total' field" + }, + { + description: "Get the next 50 results", + parameters: { + next: "https://api.bugsnag.com/projects/515fb9337c1074f6fd000003/errors?base=2025-08-29T13%3A11%3A37Z&direction=desc&filters%5Berror.status%5D%5B%5D%5Btype%5D=eq&filters%5Berror.status%5D%5B%5D%5Bvalue%5D=open&offset=10&per_page=10&sort=users", + per_page: 50 + }, + expectedOutput: "JSON object with a list of errors in the 'data' field, a count of the current page of results in the 'count' field, and a total count of all results in the 'total' field" + } + ], + hints: [ + "Use list_project_event_filters tool first to discover valid filter field names for your project", + "Combine multiple filters to narrow results - filters are applied with AND logic", + "For time filters: use relative format (7d, 24h) for recent periods or ISO 8601 UTC format (2018-05-20T00:00:00Z) for specific dates", + "Common time filters: event.since (from this time), event.before (until this time)", + "The 'event.since' filter and 'error.status' filters are always applied and if not specified are set to '30d' and 'open' respectively", + "There may not be any errors matching the filters - this is not a problem with the tool, in fact it might be a good thing that the user's application had no errors", + "This tool returns paged results. The 'count' field indicates the number of results returned in the current page, and the 'total' field indicates the total number of results across all pages.", + "If the output contains a 'next' value, there are more results available - call this tool again supplying the next URL as a parameter to retrieve the next page.", + "Do not modify the next URL as this can cause incorrect results. The only other parameter that can be used with 'next' is 'per_page' to control the page size." + ] + }; + + constructor(hasProjectApiKey: boolean = false) { + super(); + + // Add conditional projectId parameter if no project API key is configured + if (!hasProjectApiKey) { + this.definition.parameters.unshift(CommonParameterDefinitions.projectId(true)); + } + } + + async execute(args: ListProjectErrorsArgs, context: ToolExecutionContext): Promise { + return executeWithErrorHandling(this.name, async () => { + // Validate arguments + validateToolArgs(args, this.definition.parameters, this.name); + + const { services } = context; + + // Get the project (either from projectId or current project) + const project = await services.getInputProject(args.projectId); + + // Validate filter keys against cached event fields if filters are provided + if (args.filters) { + await this.validateFilterKeys(args.filters, services); + } + + // Apply default filters if not provided + const defaultFilters: FilterObject = JSON.parse(JSON.stringify(TOOL_DEFAULTS.DEFAULT_FILTERS)); + const mergedFilters = { ...defaultFilters, ...args.filters }; + + // Build options for the API call + const options: ListProjectErrorsOptions = { + filters: mergedFilters + }; + + // Add optional parameters if provided + if (args.sort !== undefined) options.sort = args.sort; + if (args.direction !== undefined) options.direction = args.direction; + if (args.per_page !== undefined) options.per_page = args.per_page; + if (args.next !== undefined) options.next = args.next; + + // Make the API call + const errorsApi = services.getErrorsApi(); + const response = await errorsApi.listProjectErrors(project.id, options); + + const errors = response.body || []; + const totalCount = response.headers.get('X-Total-Count'); + const linkHeader = response.headers.get('Link'); + + // Extract next URL from Link header + const nextUrl = linkHeader?.match(/<([^>]+)>/)?.[1] || null; + + // Format the result with pagination information + const result = formatPaginatedResult( + errors, + errors.length, + totalCount ? parseInt(totalCount) : undefined, + nextUrl + ); + + return result; + }); + } + + /** + * Validates filter keys against the project's available event fields + * + * @param filters The filters to validate + * @param services The shared services instance + * @throws BugsnagToolError if invalid filter keys are found + */ + private async validateFilterKeys(filters: FilterObject, services: SharedServices): Promise { + const cache = services.getCache(); + const eventFields = cache.get("bugsnag_current_project_event_filters") || []; + + if (eventFields.length === 0) { + // If no cached event fields, we can't validate - let the API handle it + return; + } + + const validKeys = new Set(eventFields.map(f => f.display_id)); + + for (const key of Object.keys(filters)) { + if (!validKeys.has(key)) { + throw new Error(`Invalid filter key: ${key}`); + } + } + } +} diff --git a/src/bugsnag/tools/list-project-event-filters-tool.ts b/src/bugsnag/tools/list-project-event-filters-tool.ts new file mode 100644 index 00000000..4438fc57 --- /dev/null +++ b/src/bugsnag/tools/list-project-event-filters-tool.ts @@ -0,0 +1,106 @@ +/** + * List Project Event Filters Tool + * + * Retrieves available event filter fields for the current project. + * This tool helps discover valid filter field names that can be used + * with other tools like List Project Errors and Get Error. + */ + +import { + + ToolDefinition, + ToolExecutionContext, + ToolResult, + BaseBugsnagTool, + BugsnagToolError +} from "../types.js"; +import { + validateToolArgs, + + executeWithErrorHandling +} from "../utils/tool-utilities.js"; +import { EventField } from "../client/api/Project.js"; + +/** + * Arguments interface for the List Project Event Filters tool + * This tool takes no parameters as it returns filters for the current project + */ +// eslint-disable-next-line @typescript-eslint/no-empty-object-type +export interface ListProjectEventFiltersArgs { + // No parameters required - uses current project from context +} + +/** + * List Project Event Filters Tool implementation + * + * Discovers available event filter fields for the current project. + * The results are cached and filtered to exclude fields that are not + * useful for programmatic filtering (like the "search" field). + */ +export class ListProjectEventFiltersTool extends BaseBugsnagTool { + readonly name = "list_project_event_filters"; + + readonly definition: ToolDefinition = { + title: "List Project Event Filters", + summary: "Get available event filter fields for the current project", + purpose: "Discover valid filter field names and options that can be used with the List Errors or Get Error tools", + useCases: [ + "Discover what filter fields are available before searching for errors", + "Find the correct field names for filtering by user, environment, or custom metadata", + "Understand filter options and data types for building complex queries" + ], + parameters: [], + examples: [ + { + description: "Get all available filter fields", + parameters: {}, + expectedOutput: "JSON array of EventField objects containing display_id, custom flag, and filter/pivot options" + } + ], + hints: [ + "Use this tool before the List Errors or Get Error tools to understand available filters", + "Look for display_id field in the response - these are the field names to use in filters", + "Custom fields are marked with the 'custom' flag in the response", + "Some fields may be excluded from results if they are not suitable for programmatic filtering" + ] + }; + + async execute(args: ListProjectEventFiltersArgs, context: ToolExecutionContext): Promise { + return executeWithErrorHandling(this.name, async () => { + // Validate arguments (though there are none for this tool) + validateToolArgs(args, this.definition.parameters, this.name); + + const { services } = context; + + // Try to get cached filters first + const cache = services.getCache(); + const cacheKey = "bugsnag_current_project_event_filters"; + let projectFields = cache.get(cacheKey); + + if (!projectFields) { + // If not cached, get current project and fetch filters + const currentProject = await services.getCurrentProject(); + if (!currentProject) { + throw new BugsnagToolError( + "No current project found. Please configure a project API key or specify a project ID.", + this.name + ); + } + + // Fetch and cache the filters + projectFields = await services.getProjectEventFilters(currentProject); + cache.set(cacheKey, projectFields); + } + + if (!projectFields || projectFields.length === 0) { + throw new BugsnagToolError( + "No event filters found for the current project.", + this.name + ); + } + + // Return the raw data - executeWithErrorHandling will wrap it in createSuccessResult + return projectFields; + }); + } +} diff --git a/src/bugsnag/tools/list-projects-tool.ts b/src/bugsnag/tools/list-projects-tool.ts new file mode 100644 index 00000000..6d9bfb51 --- /dev/null +++ b/src/bugsnag/tools/list-projects-tool.ts @@ -0,0 +1,110 @@ +/** + * List Projects Tool + * + * Retrieves all projects in the organization with optional pagination. + * This tool is only available when no project API key is configured. + */ + +import { + ToolDefinition, + ToolExecutionContext, + ToolResult, + BaseBugsnagTool, + PaginationArgs +} from "../types.js"; +import { + CommonParameterDefinitions, + validateToolArgs, + createSuccessResult, + executeWithErrorHandling, + +} from "../utils/tool-utilities.js"; + +/** + * Arguments interface for the List Projects tool + */ +export interface ListProjectsArgs extends PaginationArgs { + page_size?: number; + page?: number; +} + +/** + * List Projects Tool implementation + * + * Lists all projects in the organization with optional pagination support. + * Only available when no project API key is configured, as it's used for + * project discovery and selection. + */ +export class ListProjectsTool extends BaseBugsnagTool { + readonly name = "list_projects"; + + readonly definition: ToolDefinition = { + title: "List Projects", + summary: "List all projects in the organization with optional pagination", + purpose: "Retrieve available projects for browsing and selecting which project to analyze", + useCases: [ + "Browse available projects when no specific project API key is configured", + "Find project IDs needed for other tools", + "Get an overview of all projects in the organization" + ], + parameters: [ + CommonParameterDefinitions.pageSize(10), + CommonParameterDefinitions.page() + ], + examples: [ + { + description: "Get first 10 projects", + parameters: { + page_size: 10, + page: 1 + }, + expectedOutput: "JSON array of project objects with IDs, names, and metadata" + }, + { + description: "Get all projects (no pagination)", + parameters: {}, + expectedOutput: "JSON array of all available projects" + } + ], + hints: [ + "Use pagination for organizations with many projects to avoid large responses", + "Project IDs from this list can be used with other tools when no project API key is configured" + ] + }; + + async execute(args: ListProjectsArgs, context: ToolExecutionContext): Promise { + try { + // Validate arguments + validateToolArgs(args, this.definition.parameters, this.name); + + const { services } = context; + + // Get all projects from the organization + let projects = await services.getProjects(); + + if (!projects || projects.length === 0) { + return { + content: [{ type: "text", text: "No projects found." }] + }; + } + + // Apply pagination if requested + if (args.page_size || args.page) { + const pageSize = args.page_size || 10; + const page = args.page || 1; + const startIndex = (page - 1) * pageSize; + const endIndex = startIndex + pageSize; + projects = projects.slice(startIndex, endIndex); + } + + return createSuccessResult({ + data: projects, + count: projects.length + }); + } catch (error) { + return executeWithErrorHandling(this.name, async () => { + throw error; + }); + } + } +} diff --git a/src/bugsnag/tools/list-releases-tool.ts b/src/bugsnag/tools/list-releases-tool.ts new file mode 100644 index 00000000..7ae300ea --- /dev/null +++ b/src/bugsnag/tools/list-releases-tool.ts @@ -0,0 +1,147 @@ +/** + * List Releases Tool + * + * Lists releases for a project with optional filtering by release stage and pagination. + * Includes stability data integration for enhanced release analysis. + */ + +import { z } from "zod"; +import { + + ToolDefinition, + ToolExecutionContext, + ToolResult, + BaseBugsnagTool, + ProjectArgs, + PaginationArgs +} from "../types.js"; +import { + CommonParameterDefinitions, + validateToolArgs, + createSuccessResult, + executeWithErrorHandling, + + createConditionalProjectIdParam +} from "../utils/tool-utilities.js"; + +/** + * Arguments interface for the List Releases tool + */ +export interface ListReleasesArgs extends ProjectArgs, PaginationArgs { + releaseStage?: string; + visibleOnly?: boolean; + nextUrl?: string; +} + +/** + * List Releases Tool implementation + * + * Lists releases for a project with optional filtering by release stage. + * Includes stability data and pagination support for comprehensive release analysis. + */ +export class ListReleasesTool extends BaseBugsnagTool { + readonly name = "list_releases"; + + readonly definition: ToolDefinition = { + title: "List Releases", + summary: "List releases for a project with optional filtering by release stage", + purpose: "Retrieve a list of release summaries to analyze deployment history and associated errors", + useCases: [ + "View recent releases to correlate with error spikes", + "Filter releases by stage (e.g. production, staging) for targeted analysis", + "Monitor release quality and stability metrics over time", + "Track deployment history and associated error patterns" + ], + parameters: [ + ...createConditionalProjectIdParam(false), // Will be set properly during registration + CommonParameterDefinitions.releaseStage(), + { + name: "visibleOnly", + type: z.boolean().default(true), + required: false, + description: "Whether to only include releases that are marked as visible (default: true)", + examples: ["true", "false"] + }, + CommonParameterDefinitions.nextUrl() + ], + examples: [ + { + description: "List all releases for a project", + parameters: { + projectId: "515fb9337c1074f6fd000003" + }, + expectedOutput: "JSON object with releases array and optional next URL for pagination" + }, + { + description: "List production releases for a project", + parameters: { + projectId: "515fb9337c1074f6fd000003", + releaseStage: "production" + }, + expectedOutput: "JSON object with filtered releases array and metadata" + }, + { + description: "Get the next page of results", + parameters: { + projectId: "515fb9337c1074f6fd000003", + nextUrl: "/projects/515fb9337c1074f6fd000003/releases?offset=30&per_page=30" + }, + expectedOutput: "JSON object with next page of releases" + } + ], + hints: [ + "For more detailed results use the Get Release tool", + "Use releaseStage parameter to filter releases by environment (production, staging, etc.)", + "Releases include stability data showing error rates and target compliance", + "Use pagination for projects with many releases to manage response size" + ] + }; + + async execute(args: ListReleasesArgs, context: ToolExecutionContext): Promise { + return executeWithErrorHandling(this.name, async () => { + // Validate arguments + validateToolArgs(args, this.definition.parameters, this.name); + + const { services } = context; + + // Get the project + const project = await services.getInputProject(args.projectId); + + // Prepare options for the API call + const options: any = { + release_stage_name: args.releaseStage ?? "production", + visible_only: args.visibleOnly ?? true, + next_url: args.nextUrl ?? null + }; + + // Call the service to list releases + const { releases, nextUrl } = await services.listReleases(project.id, options); + + // Format the result with pagination info + const result = { + releases, + next: nextUrl ?? null + }; + + return createSuccessResult(result); + }); + } + + /** + * Update parameter definitions based on whether project API key is configured + */ + updateParametersForProjectApiKey(hasProjectApiKey: boolean): void { + this.definition.parameters = [ + ...createConditionalProjectIdParam(hasProjectApiKey), + CommonParameterDefinitions.releaseStage(), + { + name: "visibleOnly", + type: z.boolean().default(true), + required: false, + description: "Whether to only include releases that are marked as visible (default: true)", + examples: ["true", "false"] + }, + CommonParameterDefinitions.nextUrl() + ]; + } +} diff --git a/src/bugsnag/tools/update-error-tool.ts b/src/bugsnag/tools/update-error-tool.ts new file mode 100644 index 00000000..d1bf4fe2 --- /dev/null +++ b/src/bugsnag/tools/update-error-tool.ts @@ -0,0 +1,128 @@ +/** + * Update Error Tool + * + * Updates the status of an error, such as marking it as resolved, ignored, or changing its severity. + */ + +import { + ToolDefinition, + ToolExecutionContext, + ToolResult, + BaseBugsnagTool, + ErrorArgs +} from "../types.js"; +import { + CommonParameterDefinitions, + validateToolArgs, + executeWithErrorHandling +} from "../utils/tool-utilities.js"; + +/** + * Arguments interface for the Update Error tool + */ +export interface UpdateErrorArgs extends ErrorArgs { + operation: "override_severity" | "open" | "fix" | "ignore" | "discard" | "undiscard"; + severity?: "error" | "warning" | "info"; +} + +/** + * Update Error Tool implementation + * + * Allows updating the workflow state of an error, such as marking it as fixed, + * ignored, or changing its severity level. + */ +export class UpdateErrorTool extends BaseBugsnagTool { + readonly name = "update_error"; + + readonly definition: ToolDefinition = { + title: "Update Error", + summary: "Update the status of an error", + purpose: "Change an error's workflow state, such as marking it as resolved or ignored", + useCases: [ + "Mark an error as open, fixed or ignored", + "Discard or un-discard an error", + "Update the severity of an error" + ], + parameters: [ + CommonParameterDefinitions.errorId(), + CommonParameterDefinitions.updateOperation(), + ], + examples: [ + { + description: "Mark an error as fixed", + parameters: { + errorId: "6863e2af8c857c0a5023b411", + operation: "fix" + }, + expectedOutput: "Success response indicating the error was marked as fixed" + }, + { + description: "Change error severity", + parameters: { + errorId: "6863e2af8c857c0a5023b411", + operation: "override_severity" + }, + expectedOutput: "Success response after prompting for new severity level" + } + ], + hints: [ + "Only use valid operations - BugSnag may reject invalid values", + "When using 'override_severity', you will be prompted to provide the new severity level" + ] + }; + + constructor(hasProjectApiKey: boolean = false) { + super(); + + // Add conditional projectId parameter if no project API key is configured + if (!hasProjectApiKey) { + this.definition.parameters.unshift(CommonParameterDefinitions.projectId(true)); + } + } + + async execute(args: UpdateErrorArgs, context: ToolExecutionContext): Promise { + return executeWithErrorHandling(this.name, async () => { + // Validate arguments + validateToolArgs(args, this.definition.parameters, this.name); + + const { services, getInput } = context; + + // Validate required parameters + if (!args.errorId || !args.operation) { + throw new Error("Both errorId and operation arguments are required"); + } + + // Get the project (either from projectId or current project) + const project = await services.getInputProject(args.projectId); + + let severity = undefined; + + // Handle override_severity operation - prompt user for severity + if (args.operation === 'override_severity') { + const result = await getInput({ + message: "Please provide the new severity for the error (e.g. 'info', 'warning', 'error', 'critical')", + requestedSchema: { + type: "object", + properties: { + severity: { + type: "string", + enum: ['info', 'warning', 'error'], + description: "The new severity level for the error" + } + } + }, + required: ["severity"] + }); + + if (result.action === "accept" && result.content?.severity) { + severity = result.content.severity; + } + } + + // Update the error using the shared service + const success = await services.updateError(project.id!, args.errorId, args.operation, { severity }); + + return { success }; + }); + } +} diff --git a/src/bugsnag/types.ts b/src/bugsnag/types.ts new file mode 100644 index 00000000..b0d05583 --- /dev/null +++ b/src/bugsnag/types.ts @@ -0,0 +1,247 @@ +import { z } from "zod"; +import * as NodeCache from "node-cache"; +import { GetInputFunction } from "../common/types.js"; +import { CurrentUserAPI, ErrorAPI } from "./client/index.js"; +import { Organization, Project } from "./client/api/CurrentUser.js"; +import { + ProjectAPI, + EventField, + ListBuildsOptions, + BuildResponse, + BuildSummaryResponse, + StabilityData, + ListReleasesOptions, + ReleaseResponse, + ReleaseSummaryResponse, + ProjectStabilityTargets, + BuildResponseAny, + ReleaseResponseAny +} from "./client/api/Project.js"; + +/** + * Core interface that all Bugsnag tools must implement + */ +export interface BugsnagTool { + readonly name: string; + readonly definition: ToolDefinition; + execute(args: any, context: ToolExecutionContext): Promise; +} + +/** + * Context provided to tools during execution + */ +export interface ToolExecutionContext { + services: SharedServices; + getInput: GetInputFunction; +} + +/** + * Result returned by tool execution + * Matches the MCP SDK expected return type + */ +export interface ToolResult { + [x: string]: unknown; + content: Array<{ + type: "text"; + text: string; + }>; + isError?: boolean; +} + +/** + * Complete tool definition including metadata and parameters + */ +export interface ToolDefinition { + title: string; + summary: string; + purpose: string; + useCases: string[]; + parameters: ParameterDefinition[]; + examples: ToolExample[]; + hints: string[]; + outputFormat?: string; +} + +/** + * Parameter definition for tool inputs + */ +export interface ParameterDefinition { + name: string; + type: z.ZodType; + required: boolean; + description: string; + examples: string[]; + constraints?: string[]; +} + +/** + * Example usage of a tool + */ +export interface ToolExample { + description: string; + parameters: Record; + expectedOutput: string; +} + +/** + * Shared services interface providing common functionality to all tools + */ +export interface SharedServices { + // Project management + getProjects(): Promise; + getProject(projectId: string): Promise; + getCurrentProject(): Promise; + getInputProject(projectId?: string): Promise; + + // API clients + getCurrentUserApi(): CurrentUserAPI; + getErrorsApi(): ErrorAPI; + getProjectApi(): ProjectAPI; + + // Caching + getCache(): NodeCache; + + // URL generation + getDashboardUrl(project: Project): Promise; + getErrorUrl(project: Project, errorId: string, queryString?: string): Promise; + + // Configuration + getProjectApiKey(): string | undefined; + hasProjectApiKey(): boolean; + + // Organization + getOrganization(): Promise; + + // Event filters + getProjectEventFilters(project: Project): Promise; + + // Event operations + getEvent(eventId: string, projectId?: string): Promise; + updateError(projectId: string, errorId: string, operation: string, options?: any): Promise; + + // Build operations + listBuilds(projectId: string, opts: ListBuildsOptions): Promise<{ builds: (BuildSummaryResponse & StabilityData)[], nextUrl: string | null }>; + getBuild(projectId: string, buildId: string): Promise; + + // Release operations + listReleases(projectId: string, opts: ListReleasesOptions): Promise<{ releases: (ReleaseSummaryResponse & StabilityData)[], nextUrl: string | null }>; + getRelease(projectId: string, releaseId: string): Promise; + listBuildsInRelease(releaseId: string): Promise; + + // Stability operations + getProjectStabilityTargets(projectId: string): Promise; + addStabilityData(source: T, stabilityTargets: ProjectStabilityTargets): T & StabilityData; +} + +/** + * Error class for tool-specific errors + */ +export class BugsnagToolError extends Error { + constructor( + message: string, + public readonly toolName: string, + public readonly cause?: Error + ) { + super(message); + this.name = "BugsnagToolError"; + } +} + +/** + * Registry for managing tool discovery and registration + */ +export interface ToolRegistry { + registerTool(tool: BugsnagTool): void; + getTool(name: string): BugsnagTool | undefined; + getAllTools(): BugsnagTool[]; + discoverTools(): BugsnagTool[]; + registerAllTools(register: any, context: ToolExecutionContext): void; +} + +/** + * Base class for implementing tools with common functionality + */ +export abstract class BaseBugsnagTool implements BugsnagTool { + abstract readonly name: string; + abstract readonly definition: ToolDefinition; + + abstract execute(args: any, context: ToolExecutionContext): Promise; + + /** + * Validate tool arguments using the parameter definitions + */ + protected validateArgs(args: any): void { + for (const param of this.definition.parameters) { + if (param.required && (args[param.name] === undefined || args[param.name] === null)) { + throw new BugsnagToolError( + `Required parameter '${param.name}' is missing`, + this.name + ); + } + + if (args[param.name] !== undefined) { + try { + param.type.parse(args[param.name]); + } catch (error) { + throw new BugsnagToolError( + `Invalid value for parameter '${param.name}': ${error}`, + this.name, + error as Error + ); + } + } + } + } + + /** + * Create a successful tool result + */ + protected createResult(data: any): ToolResult { + return { + content: [{ type: "text", text: JSON.stringify(data) }] + }; + } + + /** + * Create an error tool result + */ + protected createErrorResult(message: string, _error?: Error): ToolResult { + return { + content: [{ type: "text", text: message }], + isError: true + }; + } +} + +/** + * Type definitions for common tool arguments + */ +export interface ProjectArgs { + projectId?: string; +} + +export interface ErrorArgs extends ProjectArgs { + errorId: string; +} + +export interface PaginationArgs { + page_size?: number; + page?: number; + per_page?: number; + next?: string; +} + +export interface SortingArgs { + sort?: string; + direction?: "asc" | "desc"; +} + +/** + * Constants for tool configuration + */ +export const TOOL_CONSTANTS = { + DEFAULT_PAGE_SIZE: 30, + MAX_PAGE_SIZE: 100, + DEFAULT_CACHE_TTL: 24 * 60 * 60, // 24 hours in seconds + SHORT_CACHE_TTL: 5 * 60, // 5 minutes in seconds +} as const; diff --git a/src/bugsnag/utils/README.md b/src/bugsnag/utils/README.md new file mode 100644 index 00000000..feef1ecd --- /dev/null +++ b/src/bugsnag/utils/README.md @@ -0,0 +1,325 @@ +# Bugsnag Tool Implementation Utilities + +This directory contains base utilities and patterns for implementing Bugsnag tools in a consistent, maintainable way. + +## Overview + +The utilities provide: + +- **Parameter validation** using Zod schemas +- **Consistent error handling** and response formatting +- **Common parameter definitions** for reuse across tools +- **URL parsing utilities** for dashboard links +- **Result formatting helpers** for consistent output + +## Core Utilities + +### Parameter Validation + +#### CommonParameterSchemas + +Pre-defined Zod schemas for common parameter types: + +```typescript +import { CommonParameterSchemas } from "./tool-utilities.js"; + +// Validate a project ID +CommonParameterSchemas.projectId.parse("my-project-id"); + +// Validate pagination parameters +CommonParameterSchemas.pageSize.parse(25); // 1-100 +CommonParameterSchemas.direction.parse("desc"); // "asc" | "desc" +``` + +#### CommonParameterDefinitions + +Factory functions for creating parameter definitions: + +```typescript +import { CommonParameterDefinitions } from "./tool-utilities.js"; + +const parameters = [ + CommonParameterDefinitions.projectId(true), // required + CommonParameterDefinitions.filters(false, defaultFilters), // optional with defaults + CommonParameterDefinitions.sort(["name", "date"], "date"), // with valid values + CommonParameterDefinitions.perPage(30) // with default value +]; +``` + +#### validateToolArgs + +Validates arguments against parameter definitions: + +```typescript +import { validateToolArgs } from "./tool-utilities.js"; + +// In your tool's execute method: +validateToolArgs(args, this.definition.parameters, this.name); +``` + +### Error Handling + +#### executeWithErrorHandling + +Wraps tool execution with consistent error handling: + +```typescript +import { executeWithErrorHandling } from "./tool-utilities.js"; + +async execute(args: any, context: ToolExecutionContext): Promise { + return executeWithErrorHandling(this.name, async () => { + // Your tool logic here + return { data: "result" }; + }); +} +``` + +#### BugsnagToolError + +Custom error class for tool-specific errors: + +```typescript +import { BugsnagToolError } from "../types.js"; + +throw new BugsnagToolError("Specific error message", this.name); +``` + +### Response Formatting + +#### createSuccessResult / createErrorResult + +Create properly formatted tool results: + +```typescript +import { createSuccessResult, createErrorResult } from "./tool-utilities.js"; + +// Success result +return createSuccessResult({ data: items, count: items.length }); + +// Error result +return createErrorResult("Something went wrong"); +``` + +#### formatPaginatedResult / formatListResult + +Format results with consistent structure: + +```typescript +import { formatPaginatedResult, formatListResult } from "./tool-utilities.js"; + +// Paginated results with next URL +return formatPaginatedResult(items, items.length, totalCount, nextUrl); + +// Simple list results +return formatListResult(items, items.length); +``` + +### URL Utilities + +#### extractProjectSlugFromUrl / extractEventIdFromUrl + +Extract information from dashboard URLs: + +```typescript +import { extractProjectSlugFromUrl, extractEventIdFromUrl } from "./tool-utilities.js"; + +const url = "https://app.bugsnag.com/my-org/my-project/errors/123?event_id=456"; +const projectSlug = extractProjectSlugFromUrl(url); // "my-project" +const eventId = extractEventIdFromUrl(url); // "456" +``` + +#### validateUrlParameters + +Ensure required URL parameters are present: + +```typescript +import { validateUrlParameters } from "./tool-utilities.js"; + +validateUrlParameters(url, ["event_id", "error_id"], this.name); +``` + +### Conditional Parameters + +#### createConditionalProjectIdParam + +Create project ID parameter only when needed: + +```typescript +import { createConditionalProjectIdParam } from "./tool-utilities.js"; + +const parameters = [ + ...createConditionalProjectIdParam(hasProjectApiKey), + // other parameters... +]; +``` + +#### validateConditionalParameters + +Validate parameters that depend on other parameter values: + +```typescript +import { validateConditionalParameters } from "./tool-utilities.js"; + +// Validates that severity is provided when operation is "override_severity" +validateConditionalParameters(args, this.name); +``` + +## Implementation Pattern + +Here's the recommended pattern for implementing a new tool: + +```typescript +import { BugsnagTool, ToolDefinition, ToolExecutionContext, ToolResult } from "../types.js"; +import { + CommonParameterDefinitions, + validateToolArgs, + executeWithErrorHandling, + createConditionalProjectIdParam, + formatListResult, + TOOL_DEFAULTS +} from "./tool-utilities.js"; + +export class MyTool implements BugsnagTool { + readonly name = "my_tool"; + + readonly definition: ToolDefinition = { + title: "My Tool", + summary: "Brief description of what the tool does", + purpose: "Detailed explanation of the tool's purpose", + useCases: [ + "Use case 1", + "Use case 2" + ], + parameters: [ + // Conditional project ID (only if no project API key) + ...createConditionalProjectIdParam(false), // This should be dynamic + + // Common parameters + CommonParameterDefinitions.filters(false, TOOL_DEFAULTS.DEFAULT_FILTERS), + CommonParameterDefinitions.perPage(), + + // Custom parameters + { + name: "customParam", + type: z.string(), + required: false, + description: "Custom parameter description", + examples: ["example"] + } + ], + examples: [ + { + description: "Example usage", + parameters: { customParam: "value" }, + expectedOutput: "Description of expected output" + } + ], + hints: [ + "Helpful hint 1", + "Helpful hint 2" + ], + outputFormat: "Description of output format" + }; + + async execute(args: any, context: ToolExecutionContext): Promise { + return executeWithErrorHandling(this.name, async () => { + // 1. Validate arguments + validateToolArgs(args, this.definition.parameters, this.name); + + // 2. Validate conditional parameters if needed + validateConditionalParameters(args, this.name); + + // 3. Get project using SharedServices + const project = await context.services.getInputProject(args.projectId); + + // 4. Perform tool logic + const data = await this.performToolLogic(args, context); + + // 5. Format and return result + return formatListResult(data, data.length); + }); + } + + private async performToolLogic(args: any, context: ToolExecutionContext): Promise { + // Your tool's specific logic here + return []; + } +} +``` + +## Constants + +### TOOL_DEFAULTS + +Common default values: + +```typescript +import { TOOL_DEFAULTS } from "./tool-utilities.js"; + +TOOL_DEFAULTS.PAGE_SIZE; // 30 +TOOL_DEFAULTS.MAX_PAGE_SIZE; // 100 +TOOL_DEFAULTS.SORT_DIRECTION; // "desc" +TOOL_DEFAULTS.DEFAULT_FILTERS; // Default error filters +``` + +## Testing + +The utilities include comprehensive unit tests. When implementing new tools: + +1. Test parameter validation with valid and invalid inputs +2. Test error handling scenarios +3. Test result formatting +4. Test any custom logic specific to your tool + +Example test structure: + +```typescript +import { describe, it, expect } from "vitest"; +import { MyTool } from "./my-tool.js"; +import { createMockServices, createMockContext } from "./test-helpers.js"; + +describe("MyTool", () => { + let tool: MyTool; + let mockContext: any; + + beforeEach(() => { + tool = new MyTool(); + mockContext = createMockContext(); + }); + + it("should validate parameters correctly", async () => { + const args = { /* valid args */ }; + const result = await tool.execute(args, mockContext); + expect(result.isError).toBeUndefined(); + }); + + it("should handle missing required parameters", async () => { + const args = { /* missing required params */ }; + const result = await tool.execute(args, mockContext); + expect(result.isError).toBe(true); + }); +}); +``` + +## Best Practices + +1. **Always validate parameters** using `validateToolArgs` +2. **Use executeWithErrorHandling** to wrap your tool logic +3. **Reuse common parameter definitions** instead of creating custom ones +4. **Format results consistently** using the provided utilities +5. **Handle errors gracefully** with descriptive messages +6. **Write comprehensive tests** for your tools +7. **Follow the established patterns** shown in the example tools + +## Migration from Existing Tools + +When migrating existing tools to use these utilities: + +1. Extract parameter definitions using `CommonParameterDefinitions` +2. Replace manual validation with `validateToolArgs` +3. Wrap execution logic with `executeWithErrorHandling` +4. Use formatting utilities for consistent output +5. Add comprehensive tests +6. Update error handling to use `BugsnagToolError` + +This approach ensures consistency across all tools while reducing code duplication and improving maintainability. diff --git a/src/bugsnag/utils/example-tool.ts b/src/bugsnag/utils/example-tool.ts new file mode 100644 index 00000000..add6ac4e --- /dev/null +++ b/src/bugsnag/utils/example-tool.ts @@ -0,0 +1,241 @@ +/** + * Example tool implementation showing how to use the base tool utilities + * + * This is a reference implementation that demonstrates the patterns and utilities + * that all Bugsnag tools should follow. + */ + +import { BugsnagTool, ToolDefinition, ToolExecutionContext, ToolResult } from "../types.js"; +import { + CommonParameterSchemas, + CommonParameterDefinitions, + validateToolArgs, + executeWithErrorHandling, + createConditionalProjectIdParam, + formatListResult, + TOOL_DEFAULTS +} from "./tool-utilities.js"; + +/** + * Example tool that demonstrates proper usage of base utilities + */ +export class ExampleTool implements BugsnagTool { + readonly name = "example_tool"; + + readonly definition: ToolDefinition = { + title: "Example Tool", + summary: "An example tool demonstrating proper usage of base utilities", + purpose: "Demonstrate consistent patterns for tool implementation", + useCases: [ + "Show how to use parameter validation", + "Demonstrate error handling patterns", + "Illustrate response formatting" + ], + parameters: [ + // Conditional project ID parameter (only required if no project API key) + ...createConditionalProjectIdParam(false), // This would be dynamic in real implementation + + // Common parameter definitions + CommonParameterDefinitions.filters(false, TOOL_DEFAULTS.DEFAULT_FILTERS), + CommonParameterDefinitions.sort(["name", "created_at"], "created_at"), + CommonParameterDefinitions.direction(), + CommonParameterDefinitions.perPage(), + + // Custom parameter using common schemas + { + name: "customParam", + type: CommonParameterDefinitions.projectId().type.optional(), + required: false, + description: "An example custom parameter", + examples: ["example-value"] + } + ], + examples: [ + { + description: "Basic usage with default parameters", + parameters: {}, + expectedOutput: "JSON object with data array and count" + }, + { + description: "Usage with custom filters and sorting", + parameters: { + filters: { + "error.status": [{ "type": "eq", "value": "open" }] + }, + sort: "name", + direction: "asc", + per_page: 50 + }, + expectedOutput: "Filtered and sorted results" + } + ], + hints: [ + "This is an example tool for demonstration purposes", + "Use similar patterns in your own tool implementations", + "Always validate parameters and handle errors consistently" + ], + outputFormat: "JSON object containing 'data' array and 'count' number" + }; + + async execute(args: any, context: ToolExecutionContext): Promise { + return executeWithErrorHandling(this.name, async () => { + // Step 1: Validate arguments using the parameter definitions + validateToolArgs(args, this.definition.parameters, this.name); + + // Step 2: Get the project (demonstrates SharedServices usage) + const project = await context.services.getInputProject(args.projectId); + + // Step 3: Perform the tool's main logic + const mockData = [ + { id: "1", name: "Example Item 1", project_id: project.id }, + { id: "2", name: "Example Item 2", project_id: project.id } + ]; + + // Step 4: Apply any filtering/sorting based on parameters + let filteredData = mockData; + + if (args.sort === "name") { + filteredData = filteredData.sort((a, b) => { + const comparison = a.name.localeCompare(b.name); + return args.direction === "asc" ? comparison : -comparison; + }); + } + + // Step 5: Apply pagination + const perPage = args.per_page || TOOL_DEFAULTS.PAGE_SIZE; + const paginatedData = filteredData.slice(0, perPage); + + // Step 6: Format the result using utility functions + return formatListResult(paginatedData, paginatedData.length); + }); + } +} + +/** + * Example of a more complex tool with error handling + */ +export class ExampleErrorHandlingTool implements BugsnagTool { + readonly name = "example_error_tool"; + + readonly definition: ToolDefinition = { + title: "Example Error Handling Tool", + summary: "Demonstrates various error handling scenarios", + purpose: "Show how to handle different types of errors consistently", + useCases: [ + "Demonstrate parameter validation errors", + "Show API error handling", + "Illustrate business logic error handling" + ], + parameters: [ + CommonParameterDefinitions.errorId(), + { + name: "errorType", + type: CommonParameterSchemas.nonEmptyString, + required: true, + description: "Type of error to simulate", + examples: ["validation", "api", "business", "success"] + } + ], + examples: [ + { + description: "Simulate a successful operation", + parameters: { + errorId: "123", + errorType: "success" + }, + expectedOutput: "Success result" + } + ], + hints: [ + "This tool simulates different error scenarios for testing" + ] + }; + + async execute(args: any, _context: ToolExecutionContext): Promise { + return executeWithErrorHandling(this.name, async () => { + // Validate arguments + validateToolArgs(args, this.definition.parameters, this.name); + + // Simulate different error scenarios + switch (args.errorType) { + case "success": + return { message: "Operation completed successfully", errorId: args.errorId }; + + case "validation": + // This would be caught by executeWithErrorHandling and formatted properly + throw new Error("Validation failed: Invalid error ID format"); + + case "api": { + // Simulate an API error + const apiError = new Error("API request failed"); + apiError.name = "APIError"; + throw apiError; + } + + case "business": + // Simulate a business logic error + throw new Error(`Error ${args.errorId} is not in a state that allows this operation`); + + default: + throw new Error(`Unknown error type: ${args.errorType}`); + } + }); + } +} + +/** + * Example showing URL parameter extraction + */ +export class ExampleUrlTool implements BugsnagTool { + readonly name = "example_url_tool"; + + readonly definition: ToolDefinition = { + title: "Example URL Tool", + summary: "Demonstrates URL parameter extraction utilities", + purpose: "Show how to extract information from dashboard URLs", + useCases: [ + "Extract project information from dashboard URLs", + "Parse event IDs from shared links" + ], + parameters: [ + CommonParameterDefinitions.dashboardUrl() + ], + examples: [ + { + description: "Extract information from a dashboard URL", + parameters: { + link: "https://app.bugsnag.com/my-org/my-project/errors/123?event_id=456" + }, + expectedOutput: "Extracted project slug and event ID" + } + ], + hints: [ + "URL must be a valid Bugsnag dashboard URL", + "Required parameters will be validated automatically" + ] + }; + + async execute(args: any, _context: ToolExecutionContext): Promise { + return executeWithErrorHandling(this.name, async () => { + // Validate arguments + validateToolArgs(args, this.definition.parameters, this.name); + + // Import utilities inside the function to avoid circular dependencies + const { extractProjectSlugFromUrl, extractEventIdFromUrl, validateUrlParameters } = + await import("./tool-utilities.js"); + + // Validate that required URL parameters are present + validateUrlParameters(args.link, ["event_id"], this.name); + + // Extract information from the URL + const projectSlug = extractProjectSlugFromUrl(args.link); + const eventId = extractEventIdFromUrl(args.link); + + return { + projectSlug, + eventId, + originalUrl: args.link + }; + }); + } +} diff --git a/src/bugsnag/utils/index.ts b/src/bugsnag/utils/index.ts new file mode 100644 index 00000000..66eb3c6a --- /dev/null +++ b/src/bugsnag/utils/index.ts @@ -0,0 +1,5 @@ +/** + * Utility functions for Bugsnag tool implementations + */ + +export * from "./tool-utilities.js"; diff --git a/src/bugsnag/utils/tool-utilities.ts b/src/bugsnag/utils/tool-utilities.ts new file mode 100644 index 00000000..8a519fa3 --- /dev/null +++ b/src/bugsnag/utils/tool-utilities.ts @@ -0,0 +1,497 @@ +/** + * Base tool implementation utilities for Bugsnag tools + * + * This module provides common utilities for parameter validation, error handling, + * and response formatting that all Bugsnag tools should use for consistency. + */ + +import { z } from "zod"; +import { BugsnagToolError, ToolResult, ParameterDefinition } from "../types.js"; +import { FilterObjectSchema } from "../client/api/filters.js"; + +/** + * Common parameter schemas used across multiple tools + */ +export const CommonParameterSchemas = { + // Project ID + projectId: z.string().min(1, "Project ID cannot be empty"), + + // Error ID + errorId: z.string().min(1, "Error ID cannot be empty"), + + // Event ID + eventId: z.string().min(1, "Event ID cannot be empty"), + + // Build ID + buildId: z.string().min(1, "Build ID cannot be empty"), + + // Release ID + releaseId: z.string().min(1, "Release ID cannot be empty"), + + // Pagination parameters + pageSize: z.number().int().min(1).max(100), + page: z.number().int().min(1), + perPage: z.number().int().min(1).max(100), + + // Sorting parameters + sort: z.string().min(1), + direction: z.enum(["asc", "desc"]), + + // URL parameters + nextUrl: z.string().url(), + dashboardUrl: z.string().url(), + + // Filter parameters + filters: FilterObjectSchema, + + // Time-related parameters + since: z.string().min(1), + before: z.string().min(1), + + // Release stage + releaseStage: z.string().min(1), + + // Update operations + updateOperation: z.enum([ + "override_severity", + "open", + "fix", + "ignore", + "discard", + "undiscard" + ]), + + // Severity levels + severity: z.enum(["error", "warning", "info"]), + + // Boolean flags + fullReports: z.boolean(), + + // Generic string parameters + nonEmptyString: z.string().min(1), + optionalString: z.string().optional(), +} as const; + +/** + * Common parameter definitions that can be reused across tools + */ +export const CommonParameterDefinitions = { + projectId: (required: boolean = true): ParameterDefinition => ({ + name: "projectId", + type: CommonParameterSchemas.projectId, + required, + description: "ID of the project to query", + examples: ["515fb9337c1074f6fd000003"], + }), + + errorId: (): ParameterDefinition => ({ + name: "errorId", + type: CommonParameterSchemas.errorId, + required: true, + description: "Unique identifier of the error", + examples: ["6863e2af8c857c0a5023b411"], + }), + + eventId: (): ParameterDefinition => ({ + name: "eventId", + type: CommonParameterSchemas.eventId, + required: true, + description: "Unique identifier of the event", + examples: ["6863e2af012caf1d5c320000"], + }), + + buildId: (): ParameterDefinition => ({ + name: "buildId", + type: CommonParameterSchemas.buildId, + required: true, + description: "Unique identifier of the build", + examples: ["build-123"], + }), + + releaseId: (): ParameterDefinition => ({ + name: "releaseId", + type: CommonParameterSchemas.releaseId, + required: true, + description: "Unique identifier of the release", + examples: ["release-456"], + }), + + filters: (required: boolean = false, defaultValue?: any): ParameterDefinition => ({ + name: "filters", + type: defaultValue ? CommonParameterSchemas.filters.default(defaultValue) : CommonParameterSchemas.filters, + required, + description: "Apply filters to narrow down results. Use the List Project Event Filters tool to discover available filter fields", + examples: [ + '{"error.status": [{"type": "eq", "value": "open"}]}', + '{"event.since": [{"type": "eq", "value": "7d"}]}', + '{"user.email": [{"type": "eq", "value": "user@example.com"}]}' + ], + constraints: [ + "Time filters support ISO 8601 format (e.g. 2018-05-20T00:00:00Z) or relative format (e.g. 7d, 24h)", + "ISO 8601 times must be in UTC and use extended format", + "Relative time periods: h (hours), d (days)" + ] + }), + + sort: (validValues: string[], defaultValue?: string): ParameterDefinition => ({ + name: "sort", + type: defaultValue ? z.enum(validValues as [string, ...string[]]).default(defaultValue) : z.enum(validValues as [string, ...string[]]), + required: false, + description: "Field to sort results by", + examples: validValues, + }), + + direction: (defaultValue: "asc" | "desc" = "desc"): ParameterDefinition => ({ + name: "direction", + type: CommonParameterSchemas.direction.default(defaultValue), + required: false, + description: "Sort direction for ordering results", + examples: ["desc", "asc"], + }), + + perPage: (defaultValue: number = 30): ParameterDefinition => ({ + name: "per_page", + type: CommonParameterSchemas.perPage.default(defaultValue), + required: false, + description: "Number of results to return per page", + examples: ["30", "50", "100"], + }), + + pageSize: (defaultValue: number = 10): ParameterDefinition => ({ + name: "page_size", + type: CommonParameterSchemas.pageSize.default(defaultValue), + required: false, + description: "Number of results to return per page for pagination", + examples: ["10", "25", "50"], + }), + + page: (): ParameterDefinition => ({ + name: "page", + type: CommonParameterSchemas.page, + required: false, + description: "Page number to return (starts from 1)", + examples: ["1", "2", "3"], + }), + + nextUrl: (): ParameterDefinition => ({ + name: "next", + type: CommonParameterSchemas.nextUrl, + required: false, + description: "URL for retrieving the next page of results. Use the value in the previous response to get the next page when more results are available.", + examples: ["https://api.bugsnag.com/projects/515fb9337c1074f6fd000003/errors?offset=30&per_page=30&sort=last_seen"], + constraints: ["Only values provided in the output from this tool can be used. Do not attempt to construct it manually."] + }), + + dashboardUrl: (): ParameterDefinition => ({ + name: "link", + type: CommonParameterSchemas.dashboardUrl, + required: true, + description: "Full URL to the dashboard page in the BugSnag dashboard (web interface)", + examples: ["https://app.bugsnag.com/my-org/my-project/errors/6863e2af8c857c0a5023b411?event_id=6863e2af012caf1d5c320000"], + constraints: ["Must be a valid dashboard URL containing required parameters"] + }), + + releaseStage: (): ParameterDefinition => ({ + name: "releaseStage", + type: CommonParameterSchemas.releaseStage, + required: false, + description: "Filter by release stage (e.g. production, staging)", + examples: ["production", "staging", "development"], + }), + + updateOperation: (): ParameterDefinition => ({ + name: "operation", + type: CommonParameterSchemas.updateOperation, + required: true, + description: "The operation to perform on the error", + examples: ["fix", "ignore", "open", "override_severity"], + }), + + severity: (): ParameterDefinition => ({ + name: "severity", + type: CommonParameterSchemas.severity, + required: false, + description: "New severity level (required when operation is override_severity)", + examples: ["error", "warning", "info"], + }), +} as const; + +/** + * Validates tool arguments against parameter definitions + * + * @param args The arguments to validate + * @param parameters The parameter definitions to validate against + * @param toolName The name of the tool (for error messages) + * @throws BugsnagToolError if validation fails + */ +export function validateToolArgs( + args: any, + parameters: ParameterDefinition[], + toolName: string +): void { + for (const param of parameters) { + const value = args[param.name]; + + // Check required parameters + if (param.required && (value === undefined || value === null)) { + throw new BugsnagToolError( + `Required parameter '${param.name}' is missing`, + toolName + ); + } + + // Validate parameter type if value is provided + if (value !== undefined && value !== null) { + try { + param.type.parse(value); + } catch (error) { + const zodError = error as z.ZodError; + const errorMessage = zodError.errors.map(e => e.message).join(", "); + throw new BugsnagToolError( + `Invalid value for parameter '${param.name}': ${errorMessage}`, + toolName, + error as Error + ); + } + } + } +} + +/** + * Creates a successful tool result with JSON content + * + * @param data The data to return + * @returns ToolResult with the data serialized as JSON + */ +export function createSuccessResult(data: any): ToolResult { + return { + content: [{ type: "text", text: JSON.stringify(data) }] + }; +} + +/** + * Creates an error tool result + * + * @param message The error message + * @param error Optional underlying error + * @returns ToolResult marked as an error + */ +export function createErrorResult(message: string, _error?: Error): ToolResult { + return { + content: [{ type: "text", text: message }], + isError: true + }; +} + +/** + * Wraps tool execution with consistent error handling + * + * @param toolName The name of the tool + * @param execution The tool execution function + * @returns Promise that resolves to a ToolResult + */ +export async function executeWithErrorHandling( + toolName: string, + execution: () => Promise +): Promise { + try { + const result = await execution(); + return createSuccessResult(result); + } catch (error) { + if (error instanceof BugsnagToolError) { + return createErrorResult(error.message, error); + } else if (error instanceof Error) { + return createErrorResult( + `Tool execution failed: ${error.message}`, + error + ); + } else { + return createErrorResult( + `Tool execution failed with unknown error: ${String(error)}` + ); + } + } +} + +/** + * Formats paginated results with consistent structure + * + * @param data The array of data items + * @param count The number of items in current page + * @param total Optional total count across all pages + * @param nextUrl Optional URL for next page + * @returns Formatted result object + */ +export function formatPaginatedResult( + data: any[], + count: number, + total?: number, + nextUrl?: string | null +): any { + const result: any = { + data, + count + }; + + if (total !== undefined) { + result.total = total; + } + + if (nextUrl) { + result.next = nextUrl; + } + + return result; +} + +/** + * Formats a simple list result + * + * @param data The array of data items + * @param count The number of items + * @returns Formatted result object + */ +export function formatListResult(data: any[], count: number): any { + return { + data, + count + }; +} + +/** + * Extracts project slug from a dashboard URL + * + * @param url The dashboard URL + * @returns The project slug + * @throws BugsnagToolError if URL format is invalid + */ +export function extractProjectSlugFromUrl(url: string): string { + try { + const urlObj = new URL(url); + const pathParts = urlObj.pathname.split('/'); + + if (pathParts.length < 3) { + throw new Error("URL path too short"); + } + + const projectSlug = pathParts[2]; + if (!projectSlug) { + throw new Error("Project slug not found in URL"); + } + + return projectSlug; + } catch (error) { + throw new BugsnagToolError( + `Invalid dashboard URL format: ${error instanceof Error ? error.message : String(error)}`, + "UrlExtraction" + ); + } +} + +/** + * Extracts event ID from a dashboard URL query parameters + * + * @param url The dashboard URL + * @returns The event ID + * @throws BugsnagToolError if event ID is not found + */ +export function extractEventIdFromUrl(url: string): string { + try { + const urlObj = new URL(url); + const eventId = urlObj.searchParams.get("event_id"); + + if (!eventId) { + throw new Error("event_id parameter not found in URL"); + } + + return eventId; + } catch (error) { + throw new BugsnagToolError( + `Cannot extract event ID from URL: ${error instanceof Error ? error.message : String(error)}`, + "UrlExtraction" + ); + } +} + +/** + * Validates that required URL parameters are present + * + * @param url The URL to validate + * @param requiredParams Array of required parameter names + * @param toolName The name of the tool (for error messages) + * @throws BugsnagToolError if required parameters are missing + */ +export function validateUrlParameters( + url: string, + requiredParams: string[], + toolName: string +): void { + try { + const urlObj = new URL(url); + + for (const param of requiredParams) { + if (!urlObj.searchParams.has(param)) { + throw new BugsnagToolError( + `Required URL parameter '${param}' is missing`, + toolName + ); + } + } + } catch (error) { + if (error instanceof BugsnagToolError) { + throw error; + } + throw new BugsnagToolError( + `Invalid URL format: ${error instanceof Error ? error.message : String(error)}`, + toolName + ); + } +} + +/** + * Constants for common default values and limits + */ +export const TOOL_DEFAULTS = { + PAGE_SIZE: 30, + MAX_PAGE_SIZE: 100, + SORT_DIRECTION: "desc" as const, + CACHE_TTL_LONG: 24 * 60 * 60, // 24 hours + CACHE_TTL_SHORT: 5 * 60, // 5 minutes + DEFAULT_FILTERS: { + "event.since": [{ type: "eq" as const, value: "30d" }], + "error.status": [{ type: "eq" as const, value: "open" }] + } +} as const; + +/** + * Helper to create parameter definitions for conditional project ID + * Used when project ID is required only if no project API key is configured + * + * @param hasProjectApiKey Whether a project API key is configured + * @returns Array of parameter definitions + */ +export function createConditionalProjectIdParam(hasProjectApiKey: boolean): ParameterDefinition[] { + if (hasProjectApiKey) { + return []; + } + + return [CommonParameterDefinitions.projectId(true)]; +} + +/** + * Validates conditional parameters based on other parameter values + * For example, severity is required when operation is "override_severity" + * + * @param args The arguments to validate + * @param toolName The name of the tool + * @throws BugsnagToolError if conditional validation fails + */ +export function validateConditionalParameters(args: any, toolName: string): void { + // Validate severity is provided when operation is override_severity + if (args.operation === "override_severity" && !args.severity) { + throw new BugsnagToolError( + "Parameter 'severity' is required when operation is 'override_severity'", + toolName + ); + } +} diff --git a/src/tests/unit/bugsnag/client.test.ts b/src/tests/unit/bugsnag/client.test.ts index 3585f21e..32c10257 100644 --- a/src/tests/unit/bugsnag/client.test.ts +++ b/src/tests/unit/bugsnag/client.test.ts @@ -5,6 +5,7 @@ import { BaseAPI } from '../../../bugsnag/client/api/base.js'; import { ProjectAPI } from '../../../bugsnag/client/api/Project.js'; import { CurrentUserAPI, ErrorAPI } from '../../../bugsnag/client/index.js'; + // Mock the dependencies const mockCurrentUserAPI = { listUserOrganizations: vi.fn(), @@ -30,17 +31,17 @@ const mockProjectAPI = { getRelease: vi.fn(), listBuildsInRelease: vi.fn(), getProjectStabilityTargets: vi.fn().mockResolvedValue({ - target_stability: { - value: 0.995, - updated_at: "2023-01-01", - updated_by_id: "user-1", - }, - critical_stability: { - value: 0.85, - updated_at: "2023-01-01", - updated_by_id: "user-1", - }, - stability_target_type: "user" as const, + target_stability: { + value: 0.995, + updated_at: "2023-01-01", + updated_by_id: "user-1", + }, + critical_stability: { + value: 0.85, + updated_at: "2023-01-01", + updated_by_id: "user-1", + }, + stability_target_type: "user" as const, }), } satisfies Omit; @@ -70,7 +71,7 @@ vi.mock('../../../common/bugsnag.js', () => ({ } })); -describe('BugsnagClient', () => { +describe('BugsnagClient - New Architecture', () => { let client: BugsnagClient; beforeEach(() => { @@ -132,16 +133,6 @@ describe('BugsnagClient', () => { const result = client.getEndpoint('app', '00000test-key'); expect(result).toBe('https://app.bugsnag.smartbear.com'); }); - - it('should return Hub domain for custom subdomain', () => { - const result = client.getEndpoint('custom', '00000key'); - expect(result).toBe('https://custom.bugsnag.smartbear.com'); - }); - - it('should handle empty string after prefix', () => { - const result = client.getEndpoint('api', '00000'); - expect(result).toBe('https://api.bugsnag.smartbear.com'); - }); }); describe('with regular API key (non-Hub)', () => { @@ -154,183 +145,147 @@ describe('BugsnagClient', () => { const result = client.getEndpoint('app', 'abc123def'); expect(result).toBe('https://app.bugsnag.com'); }); - - it('should return Bugsnag domain for custom subdomain', () => { - const result = client.getEndpoint('custom', 'test-key-123'); - expect(result).toBe('https://custom.bugsnag.com'); - }); - - it('should handle API key with 00000 in middle', () => { - const result = client.getEndpoint('api', 'key-00000-middle'); - expect(result).toBe('https://api.bugsnag.com'); - }); }); + }); + }); - describe('without API key', () => { - it('should return Bugsnag domain when API key is undefined', () => { - const result = client.getEndpoint('api', undefined); - expect(result).toBe('https://api.bugsnag.com'); - }); + describe('initialization', () => { + it('should initialize successfully with organizations and projects', async () => { + const mockOrg = { id: 'org-1', name: 'Test Org' }; + const mockProjects = [ + { id: 'proj-1', name: 'Project 1', api_key: 'key1' }, + { id: 'proj-2', name: 'Project 2', api_key: 'key2' } + ]; - it('should return Bugsnag domain when API key is empty string', () => { - const result = client.getEndpoint('api', ''); - expect(result).toBe('https://api.bugsnag.com'); - }); + mockCache.get.mockReturnValueOnce(null) // No current projects + .mockReturnValueOnce(null) // No cached projects + .mockReturnValueOnce(null); // No cached organization + mockCurrentUserAPI.listUserOrganizations.mockResolvedValue({ body: [mockOrg] }); + mockCurrentUserAPI.getOrganizationProjects.mockResolvedValue({ body: mockProjects }); - it('should return Bugsnag domain when API key is null', () => { - const result = client.getEndpoint('api', null as any); - expect(result).toBe('https://api.bugsnag.com'); - }); - }); - }); + await client.initialize(); - describe('with custom endpoint', () => { - describe('Hub domain endpoints (always normalized)', () => { - it('should normalize to HTTPS subdomain for exact hub domain match', () => { - const result = client.getEndpoint('api', '00000key', 'https://api.bugsnag.smartbear.com'); - expect(result).toBe('https://api.bugsnag.smartbear.com'); - }); + expect(mockCurrentUserAPI.listUserOrganizations).toHaveBeenCalledOnce(); + expect(mockCurrentUserAPI.getOrganizationProjects).toHaveBeenCalledWith('org-1'); + expect(mockCache.set).toHaveBeenCalledWith('bugsnag_org', mockOrg, 604800); + expect(mockCache.set).toHaveBeenCalledWith('bugsnag_projects', mockProjects, 3600); + }); - it('should normalize to HTTPS subdomain regardless of input protocol', () => { - const result = client.getEndpoint('api', '00000key', 'http://app.bugsnag.smartbear.com'); - expect(result).toBe('https://api.bugsnag.smartbear.com'); - }); + it('should initialize with project API key and set up event filters', async () => { + const clientWithApiKey = new BugsnagClient('test-token', 'project-api-key'); + const mockProjects = [ + { id: 'proj-1', name: 'Project 1', api_key: 'project-api-key' }, + { id: 'proj-2', name: 'Project 2', api_key: 'other-key' } + ]; + const mockEventFields = [ + { display_id: 'user.email', custom: false }, + { display_id: 'error.status', custom: false }, + { display_id: 'search', custom: false } // This should be filtered out + ]; - it('should normalize to HTTPS subdomain regardless of input subdomain', () => { - const result = client.getEndpoint('app', '00000key', 'https://api.bugsnag.smartbear.com'); - expect(result).toBe('https://app.bugsnag.smartbear.com'); - }); + mockCache.get.mockReturnValueOnce(mockProjects) + .mockReturnValueOnce(null) + .mockReturnValueOnce(mockProjects); + mockProjectAPI.listProjectEventFields.mockResolvedValue({ body: mockEventFields }); - it('should normalize hub domain with port', () => { - const result = client.getEndpoint('api', '00000key', 'https://custom.bugsnag.smartbear.com:8080'); - expect(result).toBe('https://api.bugsnag.smartbear.com'); - }); + await clientWithApiKey.initialize(); - it('should normalize hub domain with path', () => { - const result = client.getEndpoint('api', '00000key', 'https://custom.bugsnag.smartbear.com/path'); - expect(result).toBe('https://api.bugsnag.smartbear.com'); - }); + expect(mockCache.set).toHaveBeenCalledWith('bugsnag_current_project', mockProjects[0], 604800); + expect(mockProjectAPI.listProjectEventFields).toHaveBeenCalledWith('proj-1'); - it('should normalize complex subdomains to standard format', () => { - const result = client.getEndpoint('api', '00000key', 'https://staging.app.bugsnag.smartbear.com'); - expect(result).toBe('https://api.bugsnag.smartbear.com'); - }); - }); + // Verify that 'search' field is filtered out + const filteredFields = mockEventFields.filter(field => field.display_id !== 'search'); + expect(mockCache.set).toHaveBeenCalledWith('bugsnag_current_project_event_filters', filteredFields, 3600); + }); + }); - describe('Bugsnag domain endpoints (always normalized)', () => { - it('should normalize to HTTPS subdomain for exact bugsnag domain match', () => { - const result = client.getEndpoint('api', 'regular-key', 'https://api.bugsnag.com'); - expect(result).toBe('https://api.bugsnag.com'); - }); + describe('modular architecture', () => { + it('should initialize SharedServices correctly', () => { + const client = new BugsnagClient('test-token'); + expect(client).toBeInstanceOf(BugsnagClient); + // The SharedServices should be created internally + }); - it('should normalize to HTTPS subdomain regardless of input protocol', () => { - const result = client.getEndpoint('api', 'regular-key', 'http://app.bugsnag.com'); - expect(result).toBe('https://api.bugsnag.com'); - }); + it('should initialize ToolRegistry correctly', () => { + const client = new BugsnagClient('test-token'); + expect(client).toBeInstanceOf(BugsnagClient); + // The ToolRegistry should be created internally + }); - it('should normalize bugsnag domain with port', () => { - const result = client.getEndpoint('app', 'regular-key', 'https://api.bugsnag.com:9000'); - expect(result).toBe('https://app.bugsnag.com'); - }); + it('should have proper tool registration mechanism', () => { + const client = new BugsnagClient('test-token'); + const mockRegister = vi.fn(); + const mockGetInput = vi.fn(); - it('should normalize bugsnag domain with path', () => { - const result = client.getEndpoint('app', 'regular-key', 'https://api.bugsnag.com/v2'); - expect(result).toBe('https://app.bugsnag.com'); - }); - }); + // Test that registerTools can be called without throwing + expect(() => { + client.registerTools(mockRegister, mockGetInput); + }).not.toThrow(); - describe('Custom domain endpoints (used as-is)', () => { - it('should return custom endpoint exactly as provided', () => { - const customEndpoint = 'https://custom.api.com'; - const result = client.getEndpoint('api', '00000key', customEndpoint); - expect(result).toBe(customEndpoint); - }); + // Verify that tools are registered + expect(mockRegister).toHaveBeenCalled(); + }); - it('should return custom endpoint as-is regardless of API key type', () => { - const customEndpoint = 'https://my-custom-domain.com/api'; - const result = client.getEndpoint('api', 'regular-key', customEndpoint); - expect(result).toBe(customEndpoint); - }); + it('should have proper resource registration mechanism', () => { + const client = new BugsnagClient('test-token'); + const mockRegister = vi.fn(); - it('should preserve HTTP protocol for custom domains', () => { - const customEndpoint = 'http://localhost:3000'; - const result = client.getEndpoint('api', '00000key', customEndpoint); - expect(result).toBe(customEndpoint); - }); + // Test that registerResources can be called without throwing + expect(() => { + client.registerResources(mockRegister); + }).not.toThrow(); - it('should preserve custom domain with ports and paths', () => { - const customEndpoint = 'https://192.168.1.100:8080/api/v1'; - const result = client.getEndpoint('api', '00000key', customEndpoint); - expect(result).toBe(customEndpoint); - }); + // Verify that resources are registered + expect(mockRegister).toHaveBeenCalled(); + }); - it('should preserve custom domain with query parameters', () => { - const customEndpoint = 'https://custom.domain.com/api?version=1'; - const result = client.getEndpoint('api', '00000key', customEndpoint); - expect(result).toBe(customEndpoint); - }); + it('should register event resource correctly', async () => { + const client = new BugsnagClient('test-token'); + const mockRegister = vi.fn(); + const mockEvent = { id: 'event-1', project_id: 'proj-1' }; - it('should preserve custom domain with fragments', () => { - const customEndpoint = 'https://custom.domain.com/api#section'; - const result = client.getEndpoint('api', '00000key', customEndpoint); - expect(result).toBe(customEndpoint); - }); - }); + // Mock the SharedServices getEvent method + mockCache.get.mockReturnValueOnce([{ id: 'proj-1' }, { id: 'proj-2' }]); // projects + mockErrorAPI.viewEventById + .mockRejectedValueOnce(new Error('Not found')) // proj-1 + .mockResolvedValueOnce({ body: mockEvent }); // proj-2 - describe('edge cases', () => { - it('should handle malformed custom endpoints gracefully', () => { - // This should throw due to invalid URL, which is expected behavior - expect(() => { - client.getEndpoint('api', '00000key', 'not-a-valid-url'); - }).toThrow(); - }); + client.registerResources(mockRegister); - it('should preserve custom endpoints with userinfo', () => { - const customEndpoint = 'https://user:pass@custom.domain.com'; - const result = client.getEndpoint('api', '00000key', customEndpoint); - expect(result).toBe(customEndpoint); - }); + // Get the registered resource handler + const resourceHandler = mockRegister.mock.calls[0][2]; + const result = await resourceHandler(new URL('bugsnag://event/event-1')); - it('should normalize known domains even with userinfo', () => { - const result = client.getEndpoint('api', '00000key', 'https://user:pass@app.bugsnag.smartbear.com'); - expect(result).toBe('https://api.bugsnag.smartbear.com'); - }); - }); + expect(result.contents[0].uri).toBe('bugsnag://event/event-1'); + expect(result.contents[0].text).toBe(JSON.stringify(mockEvent)); }); + }); - describe('subdomain validation', () => { - it('should handle empty subdomain', () => { - const result = client.getEndpoint('', '00000key'); - expect(result).toBe('https://.bugsnag.smartbear.com'); - }); - - it('should handle subdomain with special characters', () => { - const result = client.getEndpoint('test-api_v2', '00000key'); - expect(result).toBe('https://test-api_v2.bugsnag.smartbear.com'); - }); + describe('tool discovery configuration', () => { + it('should include List Projects tool when no project API key is configured', () => { + const client = new BugsnagClient('test-token'); + const mockRegister = vi.fn(); + const mockGetInput = vi.fn(); - it('should handle numeric subdomain', () => { - const result = client.getEndpoint('v1', 'regular-key'); - expect(result).toBe('https://v1.bugsnag.com'); - }); + client.registerTools(mockRegister, mockGetInput); - it('should handle very long subdomains', () => { - const longSubdomain = 'very-long-subdomain-name-with-many-characters'; - const result = client.getEndpoint(longSubdomain, '00000key'); - expect(result).toBe(`https://${longSubdomain}.bugsnag.smartbear.com`); - }); + // Check if List Projects tool is registered + const registeredTools = mockRegister.mock.calls.map(call => call[0]); + const hasListProjectsTool = registeredTools.some(tool => tool.title === 'List Projects'); + expect(hasListProjectsTool).toBe(true); }); - }); - describe('static utility methods', () => { - // Test static methods if they exist in the class - it('should have proper class structure', () => { - const client = new BugsnagClient('test-token'); + it('should exclude List Projects tool when project API key is configured', () => { + const client = new BugsnagClient('test-token', 'project-api-key'); + const mockRegister = vi.fn(); + const mockGetInput = vi.fn(); + + client.registerTools(mockRegister, mockGetInput); - // Verify the client has expected methods - expect(typeof client.initialize).toBe('function'); - expect(typeof client.registerTools).toBe('function'); - expect(typeof client.registerResources).toBe('function'); + // Check if List Projects tool is NOT registered + const registeredTools = mockRegister.mock.calls.map(call => call[0]); + const hasListProjectsTool = registeredTools.some(tool => tool.title === 'List Projects'); + expect(hasListProjectsTool).toBe(false); }); }); @@ -416,2349 +371,4 @@ describe('BugsnagClient', () => { expect(MockedNodeCache).toHaveBeenCalledOnce(); }); }); - - describe('initialization', () => { - it('should initialize successfully with organizations and projects', async () => { - const mockOrg = { id: 'org-1', name: 'Test Org' }; - const mockProjects = [ - { id: 'proj-1', name: 'Project 1', api_key: 'key1' }, - { id: 'proj-2', name: 'Project 2', api_key: 'key2' } - ]; - - mockCache.get.mockReturnValueOnce(null) // No current projects - .mockReturnValueOnce(null) // No cached projects - .mockReturnValueOnce(null); // No cached organization - mockCurrentUserAPI.listUserOrganizations.mockResolvedValue({ body: [mockOrg] }); - mockCurrentUserAPI.getOrganizationProjects.mockResolvedValue({ body: mockProjects }); - - await client.initialize(); - - expect(mockCurrentUserAPI.listUserOrganizations).toHaveBeenCalledOnce(); - expect(mockCurrentUserAPI.getOrganizationProjects).toHaveBeenCalledWith('org-1'); - expect(mockCache.set).toHaveBeenCalledWith('bugsnag_org', mockOrg); - expect(mockCache.set).toHaveBeenCalledWith('bugsnag_projects', mockProjects); - }); - - it('should initialize with project API key and set up event filters', async () => { - const clientWithApiKey = new BugsnagClient('test-token', 'project-api-key'); - const mockProjects = [ - { id: 'proj-1', name: 'Project 1', api_key: 'project-api-key' }, - { id: 'proj-2', name: 'Project 2', api_key: 'other-key' } - ]; - const mockEventFields = [ - { display_id: 'user.email', custom: false }, - { display_id: 'error.status', custom: false }, - { display_id: 'search', custom: false } // This should be filtered out - ]; - - mockCache.get.mockReturnValueOnce(mockProjects) - .mockReturnValueOnce(null) - .mockReturnValueOnce(mockProjects); - mockProjectAPI.listProjectEventFields.mockResolvedValue({ body: mockEventFields }); - - await clientWithApiKey.initialize(); - - expect(mockCache.set).toHaveBeenCalledWith('bugsnag_current_project', mockProjects[0]); - expect(mockProjectAPI.listProjectEventFields).toHaveBeenCalledWith('proj-1'); - - // // Verify that 'search' field is filtered out - const filteredFields = mockEventFields.filter(field => field.display_id !== 'search'); - expect(mockCache.set).toHaveBeenCalledWith('bugsnag_current_project_event_filters', filteredFields); - }); - - it('should throw error when no organizations found', async () => { - mockCurrentUserAPI.listUserOrganizations.mockResolvedValue({ body: [] }); - - await expect(client.initialize()).rejects.toThrow('No organizations found for the current user.'); - }); - - it('should throw error when project with API key not found', async () => { - const clientWithApiKey = new BugsnagClient('test-token', 'non-existent-key'); - const mockOrg = { id: 'org-1', name: 'Test Org' }; - const mockProject = { id: 'proj-1', name: 'Project 1', api_key: 'other-key' }; - - mockCurrentUserAPI.listUserOrganizations.mockResolvedValue({ body: [mockOrg] }); - mockCurrentUserAPI.getOrganizationProjects.mockResolvedValue({ body: [mockProject] }); - - await expect(clientWithApiKey.initialize()).rejects.toThrow( - 'Unable to find project with API key non-existent-key in organization.' - ); - }); - - it('should throw error when no event fields found for project', async () => { - const clientWithApiKey = new BugsnagClient('test-token', 'project-api-key'); - const mockOrg = { id: 'org-1', name: 'Test Org' }; - const mockProjects = [ - { id: 'proj-1', name: 'Project 1', api_key: 'project-api-key' } - ]; - - mockCurrentUserAPI.listUserOrganizations.mockResolvedValue({ body: [mockOrg] }); - mockCurrentUserAPI.getOrganizationProjects.mockResolvedValue({ body: mockProjects }); - mockProjectAPI.listProjectEventFields.mockResolvedValue({ body: [] }); - - await expect(clientWithApiKey.initialize()).rejects.toThrow( - 'No event fields found for project Project 1.' - ); - }); - }); - - describe('API methods', () => { - describe('getProjects', () => { - it('should return cached projects when available', async () => { - const mockProjects = [{ id: 'proj-1', name: 'Project 1' }]; - mockCache.get.mockReturnValue(mockProjects); - - const result = await client.getProjects(); - - expect(mockCache.get).toHaveBeenCalledWith('bugsnag_projects'); - expect(result).toEqual(mockProjects); - }); - - it('should fetch projects from API when not cached', async () => { - const mockOrg = { id: 'org-1', name: 'Test Org' }; - const mockProjects = [{ id: 'proj-1', name: 'Project 1' }]; - - mockCache.get - .mockReturnValueOnce(null) // First call for projects - .mockReturnValueOnce(mockOrg); // Second call for org - mockCurrentUserAPI.getOrganizationProjects.mockResolvedValue({ body: mockProjects }); - - const result = await client.getProjects(); - - expect(mockCurrentUserAPI.getOrganizationProjects).toHaveBeenCalledWith('org-1'); - expect(mockCache.set).toHaveBeenCalledWith('bugsnag_projects', mockProjects); - expect(result).toEqual(mockProjects); - }); - - it('should return empty array when no projects found', async () => { - const mockOrg = { id: 'org-1', name: 'Test Org' }; - - mockCache.get - .mockReturnValueOnce(null) // First call for projects - .mockReturnValueOnce(mockOrg); // Second call for org - mockCurrentUserAPI.getOrganizationProjects.mockResolvedValue({ body: [] }); - - await expect(client.getProjects()).resolves.toEqual([]); - }); - }); - - describe("listBuilds", () => { - it("should return builds from API", async () => { - const mockBuilds = [ - { - id: "rel-1", - release_time: "2023-01-01T00:00:00Z", - app_version: "1.0.0", - release_stage: { name: "production" }, - source_control: { - service: "github", - commit_url: - "https://github.com/org/repo/commit/abc123", - }, - errors_introduced_count: 5, - errors_seen_count: 10, - total_sessions_count: 100, - unhandled_sessions_count: 10, - accumulative_daily_users_seen: 50, - accumulative_daily_users_with_unhandled: 5, - }, - ]; - - const enhancedBuilds = mockBuilds.map((build) => ({ - ...build, - session_stability: 0.9, - user_stability: 0.9, - target_stability: 0.995, - critical_stability: 0.85, - meets_target_stability: false, - meets_critical_stability: true, - stability_target_type: "user", - })); - - mockProjectAPI.listBuilds.mockResolvedValue({ - body: mockBuilds, - headers: new Headers(), - status: 200 - }); - - const result = await client.listBuilds("proj-1", { - release_stage: "production", - }); - - expect(mockProjectAPI.listBuilds).toHaveBeenCalledWith( - "proj-1", - { release_stage: "production" } - ); - expect(result).toEqual({ builds: enhancedBuilds, nextUrl: null }); - }); - - it("should return empty array when no builds found", async () => { - mockProjectAPI.listBuilds.mockResolvedValue({ body: null, headers: new Headers(), status: 200 }); - - const result = await client.listBuilds("proj-1", {}); - - expect(mockProjectAPI.listBuilds).toHaveBeenCalledWith( - "proj-1", - {} - ); - expect(result).toEqual({ builds: [], nextUrl: null }); - }); - - it("should construct correct URL with build stage", async () => { - mockProjectAPI.listBuilds.mockImplementation(() => ({ - body: [], - headers: new Headers(), - status: 200 - })); - - await client.listBuilds("proj-1", { - release_stage: "staging", - }); - - // This is testing the implementation detail that the ProjectAPI correctly constructs the URL - expect(mockProjectAPI.listBuilds).toHaveBeenCalledWith( - "proj-1", - { release_stage: "staging" } - ); - }); - - it("should handle pagination with next URL", async () => { - const mockBuilds = [ - { - id: "rel-1", - release_time: "2023-01-01T00:00:00Z", - app_version: "1.0.0", - release_stage: { name: "production" }, - errors_introduced_count: 5, - errors_seen_count: 10, - total_sessions_count: 100, - unhandled_sessions_count: 10, - accumulative_daily_users_seen: 50, - accumulative_daily_users_with_unhandled: 5, - }, - ]; - - const enhancedBuilds = mockBuilds.map((build) => ({ - ...build, - session_stability: 0.9, - user_stability: 0.9, - target_stability: 0.995, - critical_stability: 0.85, - meets_target_stability: false, - meets_critical_stability: true, - stability_target_type: "user", - })); - - // Create headers with Link for pagination - const headers = new Headers(); - headers.append('Link', '; rel="next"'); - - mockProjectAPI.listBuilds.mockResolvedValue({ - body: mockBuilds, - headers, - status: 200 - }); - - const result = await client.listBuilds("proj-1", {}); - - expect(mockProjectAPI.listBuilds).toHaveBeenCalledWith("proj-1", {}); - expect(result.builds).toEqual(enhancedBuilds); - expect(result.nextUrl).toBe('/projects/proj-1/releases?offset=30&per_page=30'); - }); - - it("should pass next_url parameter to ProjectAPI", async () => { - mockProjectAPI.listBuilds.mockImplementation(() => ({ - body: [], - headers: new Headers(), - status: 200 - })); - - const nextUrl = '/projects/proj-1/releases?offset=30&per_page=30'; - await client.listBuilds("proj-1", { - next_url: nextUrl - }); - - expect(mockProjectAPI.listBuilds).toHaveBeenCalledWith( - "proj-1", - { next_url: nextUrl } - ); - }); - }); - - describe("getBuild", () => { - it("should return build from API when not cached", async () => { - const mockBuild = { - id: "rel-1", - release_time: "2023-01-01T00:00:00Z", - app_version: "1.0.0", - release_stage: { name: "production" }, - source_control: { - service: "github", - commit_url: "https://github.com/org/repo/commit/abc123", - revision: "abc123", - diff_url_to_previous: - "https://github.com/org/repo/compare/previous...abc123", - }, - errors_introduced_count: 5, - errors_seen_count: 10, - total_sessions_count: 100, - unhandled_sessions_count: 10, - accumulative_daily_users_seen: 50, - accumulative_daily_users_with_unhandled: 5, - }; - - const enhancedBuild = { - ...mockBuild, - session_stability: 0.9, - user_stability: 0.9, - target_stability: 0.995, - critical_stability: 0.85, - meets_target_stability: false, - meets_critical_stability: true, - stability_target_type: "user", - }; - - // Mock cache to return null first to simulate no cached data - mockCache.get.mockReturnValueOnce(null); - mockProjectAPI.getBuild.mockResolvedValue({ - body: mockBuild, - }); - - const result = await client.getBuild("proj-1", "rel-1"); - - expect(mockCache.get).toHaveBeenCalledWith( - "bugsnag_build_rel-1" - ); - expect(mockProjectAPI.getBuild).toHaveBeenCalledWith( - "proj-1", - "rel-1" - ); - expect(mockCache.set).toHaveBeenCalledWith( - "bugsnag_build_rel-1", - enhancedBuild, - 300 - ); - expect(result).toEqual(enhancedBuild); - }); - - // Test for division by zero case for user stability - it("should handle zero accumulative_daily_users_seen", async () => { - const mockBuild = { - id: "rel-2", - release_time: "2023-01-01T00:00:00Z", - app_version: "1.0.1", - release_stage: { name: "production" }, - errors_introduced_count: 0, - errors_seen_count: 0, - total_sessions_count: 50, - unhandled_sessions_count: 5, - accumulative_daily_users_seen: 0, - accumulative_daily_users_with_unhandled: 0, - }; - - mockCache.get.mockReturnValueOnce(null); - mockProjectAPI.getBuild.mockResolvedValue({ - body: mockBuild, - }); - - const result = (await client.getBuild( - "proj-1", - "rel-2" - )); - - expect(result.user_stability).toBe(0); - expect(result.meets_target_stability).toBe(false); - expect(result.meets_critical_stability).toBe(false); - }); - - // Test for division by zero case for session stability - it("should handle zero total_sessions_count", async () => { - const mockBuild = { - id: "rel-3", - release_time: "2023-01-01T00:00:00Z", - app_version: "1.0.2", - release_stage: { name: "production" }, - errors_introduced_count: 0, - errors_seen_count: 0, - total_sessions_count: 0, - unhandled_sessions_count: 0, - accumulative_daily_users_seen: 20, - accumulative_daily_users_with_unhandled: 2, - }; - - mockCache.get.mockReturnValueOnce(null); - mockProjectAPI.getBuild.mockResolvedValue({ - body: mockBuild, - }); - - const result = (await client.getBuild( - "proj-1", - "rel-3" - )); - - expect(result.session_stability).toBe(0); - // Since stability_target_type is "user", user_stability is used for comparison - expect(result.meets_target_stability).toBe(false); - expect(result.meets_critical_stability).toBe(true); - }); - - // Test for session-based stability type - it("should calculate metrics correctly when stability_target_type is session", async () => { - const mockBuild = { - id: "rel-4", - release_time: "2023-01-01T00:00:00Z", - app_version: "1.0.3", - release_stage: { name: "production" }, - errors_introduced_count: 2, - errors_seen_count: 5, - total_sessions_count: 100, - unhandled_sessions_count: 5, - accumulative_daily_users_seen: 50, - accumulative_daily_users_with_unhandled: 10, - }; - - // Override the default mockProjectAPI.getProjectStabilityTargets for this test only - mockProjectAPI.getProjectStabilityTargets.mockResolvedValueOnce( - { - target_stability: { - value: 0.95, - updated_at: "2023-01-01", - updated_by_id: "user-1", - }, - critical_stability: { - value: 0.9, - updated_at: "2023-01-01", - updated_by_id: "user-1", - }, - stability_target_type: "session" as const, - } - ); - - mockCache.get.mockReturnValueOnce(null); - mockProjectAPI.getBuild.mockResolvedValue({ - body: mockBuild, - }); - - const result = (await client.getBuild( - "proj-1", - "rel-4" - )); - - expect(result.stability_target_type).toBe("session"); - expect(result.session_stability).toBe(0.95); // (100-5)/100 - expect(result.user_stability).toBe(0.8); // (50-10)/50 - // Since stability_target_type is "session", session_stability is used for comparison - expect(result.meets_target_stability).toBe(true); - expect(result.meets_critical_stability).toBe(true); - }); - - // Test for a build that meets target stability - it("should correctly identify a build that meets target stability", async () => { - const mockBuild = { - id: "rel-5", - release_time: "2023-01-01T00:00:00Z", - app_version: "1.0.4", - release_stage: { name: "production" }, - errors_introduced_count: 1, - errors_seen_count: 2, - total_sessions_count: 1000, - unhandled_sessions_count: 5, - accumulative_daily_users_seen: 500, - accumulative_daily_users_with_unhandled: 2, - }; - - // Override the default mockProjectAPI.getProjectStabilityTargets for this test only - mockProjectAPI.getProjectStabilityTargets.mockResolvedValueOnce( - { - target_stability: { - value: 0.99, - updated_at: "2023-01-01", - updated_by_id: "user-1", - }, - critical_stability: { - value: 0.95, - updated_at: "2023-01-01", - updated_by_id: "user-1", - }, - stability_target_type: "user" as const, - } - ); - - mockCache.get.mockReturnValueOnce(null); - mockProjectAPI.getBuild.mockResolvedValue({ - body: mockBuild, - }); - - const result = (await client.getBuild( - "proj-1", - "rel-5" - )); - - expect(result.user_stability).toBe(0.996); // (500-2)/500 - expect(result.meets_target_stability).toBe(true); - expect(result.meets_critical_stability).toBe(true); - }); - - // Test for a build that fails both critical and target stability - it("should correctly identify a build that fails both critical and target stability", async () => { - const mockBuild = { - id: "rel-6", - release_time: "2023-01-01T00:00:00Z", - app_version: "1.0.5", - release_stage: { name: "production" }, - errors_introduced_count: 10, - errors_seen_count: 20, - total_sessions_count: 100, - unhandled_sessions_count: 30, - accumulative_daily_users_seen: 100, - accumulative_daily_users_with_unhandled: 20, - }; - - mockCache.get.mockReturnValueOnce(null); - mockProjectAPI.getBuild.mockResolvedValue({ - body: mockBuild, - }); - - const result = (await client.getBuild( - "proj-1", - "rel-6" - )); - - expect(result.user_stability).toBe(0.8); // (100-20)/100 - expect(result.meets_target_stability).toBe(false); // 0.8 < 0.995 - expect(result.meets_critical_stability).toBe(false); // 0.8 < 0.85 - }); - - it("should return cached build when available", async () => { - const mockBuild = { - id: "rel-1", - release_time: "2023-01-01T00:00:00Z", - app_version: "1.0.0", - release_stage: { name: "production" }, - session_stability: "90.00%", - user_stability: "90.00%", - }; - - // Mock cache to return build - mockCache.get.mockReturnValueOnce(mockBuild); - - const result = await client.getBuild("proj-1", "rel-1"); - - expect(mockCache.get).toHaveBeenCalledWith( - "bugsnag_build_rel-1" - ); - expect(mockProjectAPI.getBuild).not.toHaveBeenCalled(); - expect(result).toEqual(mockBuild); - }); - - it("should return null when build not found", async () => { - // Mock cache to return null to simulate no cached data - mockCache.get.mockReturnValueOnce(null); - mockProjectAPI.getBuild.mockResolvedValue({ body: null }); - - await expect( - client.getBuild("proj-1", "non-existent-build-id") - ).rejects.toThrow( - "No build for non-existent-build-id found." - ); - - expect(mockProjectAPI.getBuild).toHaveBeenCalledWith( - "proj-1", - "non-existent-build-id" - ); - }); - }); - - describe("listReleases", () => { - it("should return releases from API", async () => { - const mockReleases = [ - { - id: "rel-group-1", - release_stage_name: "production", - app_version: "1.0.0", - first_released_at: "2023-01-01T00:00:00Z", - first_release_id: "build-1", - releases_count: 2, - visible: true, - total_sessions_count: 100, - unhandled_sessions_count: 10, - sessions_count_in_last_24h: 20, - accumulative_daily_users_seen: 50, - accumulative_daily_users_with_unhandled: 5, - }, - ]; - - const enhancedReleases = mockReleases.map((release) => ({ - ...release, - session_stability: 0.9, - user_stability: 0.9, - target_stability: 0.995, - critical_stability: 0.85, - meets_target_stability: false, - meets_critical_stability: true, - stability_target_type: "user", - })); - - mockProjectAPI.listReleases.mockResolvedValue({ - body: mockReleases, - headers: new Headers(), - status: 200 - }); - - const result = await client.listReleases("proj-1", { - release_stage_name: "production", - visible_only: true, - }); - - expect(mockProjectAPI.listReleases).toHaveBeenCalledWith( - "proj-1", - { release_stage_name: "production", visible_only: true } - ); - expect(result).toEqual({ releases: enhancedReleases, nextUrl: null }); - }); - - it("should return empty array when no releases found", async () => { - mockProjectAPI.listReleases.mockResolvedValue({ body: null, headers: new Headers(), status: 200 }); - - const result = await client.listReleases("proj-1", { - release_stage_name: "production", - visible_only: true - }); - - expect(mockProjectAPI.listReleases).toHaveBeenCalledWith( - "proj-1", - { release_stage_name: "production", visible_only: true } - ); - expect(result).toEqual({ releases: [], nextUrl: null }); - }); - - it("should correctly pass release stage and visibility parameters", async () => { - mockProjectAPI.listReleases.mockImplementation(() => ({ - body: [], - headers: new Headers(), - status: 200 - })); - - await client.listReleases("proj-1", { - release_stage_name: "staging", - visible_only: false - }); - - expect(mockProjectAPI.listReleases).toHaveBeenCalledWith( - "proj-1", - { release_stage_name: "staging", visible_only: false } - ); - }); - - it("should handle pagination with next URL in listReleases", async () => { - const mockReleases = [ - { - id: "rel-group-1", - release_stage_name: "production", - app_version: "1.0.0", - first_released_at: "2023-01-01T00:00:00Z", - first_release_id: "build-1", - releases_count: 2, - visible: true, - total_sessions_count: 100, - unhandled_sessions_count: 10, - sessions_count_in_last_24h: 20, - accumulative_daily_users_seen: 50, - accumulative_daily_users_with_unhandled: 5, - }, - ]; - - const enhancedReleases = mockReleases.map((release) => ({ - ...release, - session_stability: 0.9, - user_stability: 0.9, - target_stability: 0.995, - critical_stability: 0.85, - meets_target_stability: false, - meets_critical_stability: true, - stability_target_type: "user", - })); - - // Create headers with Link for pagination - const headers = new Headers(); - headers.append('Link', '; rel="next"'); - - mockProjectAPI.listReleases.mockResolvedValue({ - body: mockReleases, - headers, - status: 200 - }); - - const result = await client.listReleases("proj-1", { - release_stage_name: "production", - visible_only: true - }); - - expect(result.releases).toEqual(enhancedReleases); - expect(result.nextUrl).toBe('/projects/proj-1/release_groups?offset=30&per_page=30'); - }); - - it("should pass next_url parameter to ProjectAPI in listReleases", async () => { - mockProjectAPI.listReleases.mockImplementation(() => ({ - body: [], - headers: new Headers(), - status: 200 - })); - - const nextUrl = '/projects/proj-1/release_groups?offset=30&per_page=30'; - await client.listReleases("proj-1", { - release_stage_name: "production", - visible_only: true, - next_url: nextUrl - }); - - expect(mockProjectAPI.listReleases).toHaveBeenCalledWith( - "proj-1", - { release_stage_name: "production", visible_only: true, next_url: nextUrl } - ); - }); - }); - - describe("getRelease", () => { - it("should return release from API when not cached", async () => { - const mockRelease = { - id: "rel-group-1", - project_id: "proj-1", - release_stage_name: "production", - app_version: "1.0.0", - first_released_at: "2023-01-01T00:00:00Z", - first_release_id: "build-1", - releases_count: 2, - has_secondary_versions: false, - build_tool: "gradle", - builder_name: "CI", - source_control: { - service: "github", - commit_url: "https://github.com/org/repo/commit/abc123", - revision: "abc123", - diff_url_to_previous: "https://github.com/org/repo/compare/previous...abc123", - }, - top_release_group: true, - visible: true, - total_sessions_count: 100, - unhandled_sessions_count: 10, - sessions_count_in_last_24h: 20, - accumulative_daily_users_seen: 50, - accumulative_daily_users_with_unhandled: 5, - }; - - const enhancedRelease = { - ...mockRelease, - session_stability: 0.9, - user_stability: 0.9, - target_stability: 0.995, - critical_stability: 0.85, - meets_target_stability: false, - meets_critical_stability: true, - stability_target_type: "user", - }; - - // Mock cache to return null first to simulate no cached data - mockCache.get.mockReturnValueOnce(null); - mockProjectAPI.getRelease.mockResolvedValue({ - body: mockRelease, - }); - - const result = await client.getRelease("proj-1", "rel-group-1"); - - expect(mockCache.get).toHaveBeenCalledWith( - "bugsnag_release_rel-group-1" - ); - expect(mockProjectAPI.getRelease).toHaveBeenCalledWith( - "rel-group-1" - ); - expect(mockCache.set).toHaveBeenCalledWith( - "bugsnag_release_rel-group-1", - enhancedRelease, - 300 - ); - expect(result).toEqual(enhancedRelease); - }); - - // Test for division by zero case for user stability - it("should handle zero accumulative_daily_users_seen in releases", async () => { - const mockRelease = { - id: "rel-group-2", - project_id: "proj-1", - release_stage_name: "production", - app_version: "1.0.1", - first_released_at: "2023-01-01T00:00:00Z", - total_sessions_count: 50, - unhandled_sessions_count: 5, - accumulative_daily_users_seen: 0, - accumulative_daily_users_with_unhandled: 0, - }; - - mockCache.get.mockReturnValueOnce(null); - mockProjectAPI.getRelease.mockResolvedValue({ - body: mockRelease, - }); - - const result = (await client.getRelease( - "proj-1", - "rel-group-2" - )); - - expect(result.user_stability).toBe(0); - expect(result.meets_target_stability).toBe(false); - expect(result.meets_critical_stability).toBe(false); - }); - - // Test for session-based stability type - it("should calculate release metrics correctly when stability_target_type is session", async () => { - const mockRelease = { - id: "rel-group-3", - project_id: "proj-1", - release_stage_name: "production", - app_version: "1.0.3", - first_released_at: "2023-01-01T00:00:00Z", - total_sessions_count: 100, - unhandled_sessions_count: 5, - accumulative_daily_users_seen: 50, - accumulative_daily_users_with_unhandled: 10, - }; - - // Override the default mockProjectAPI.getProjectStabilityTargets for this test only - mockProjectAPI.getProjectStabilityTargets.mockResolvedValueOnce( - { - target_stability: { - value: 0.95, - updated_at: "2023-01-01", - updated_by_id: "user-1", - }, - critical_stability: { - value: 0.9, - updated_at: "2023-01-01", - updated_by_id: "user-1", - }, - stability_target_type: "session" as const, - } - ); - - mockCache.get.mockReturnValueOnce(null); - mockProjectAPI.getRelease.mockResolvedValue({ - body: mockRelease, - }); - - const result = (await client.getRelease( - "proj-1", - "rel-group-3" - )); - - expect(result.stability_target_type).toBe("session"); - expect(result.session_stability).toBe(0.95); // (100-5)/100 - expect(result.user_stability).toBe(0.8); // (50-10)/50 - // Since stability_target_type is "session", session_stability is used for comparison - expect(result.meets_target_stability).toBe(true); - expect(result.meets_critical_stability).toBe(true); - }); - - it("should return cached release when available", async () => { - const mockRelease = { - id: "rel-group-1", - project_id: "proj-1", - release_stage_name: "production", - app_version: "1.0.0", - session_stability: 0.9, - user_stability: 0.9, - }; - - // Mock cache to return release - mockCache.get.mockReturnValueOnce(mockRelease); - - const result = await client.getRelease("proj-1", "rel-group-1"); - - expect(mockCache.get).toHaveBeenCalledWith( - "bugsnag_release_rel-group-1" - ); - expect(mockProjectAPI.getRelease).not.toHaveBeenCalled(); - expect(result).toEqual(mockRelease); - }); - - it("should throw error when release not found", async () => { - // Mock cache to return null to simulate no cached data - mockCache.get.mockReturnValueOnce(null); - mockProjectAPI.getRelease.mockResolvedValue({ body: null }); - - await expect( - client.getRelease("proj-1", "non-existent-release-id") - ).rejects.toThrow( - "No release for non-existent-release-id found." - ); - - expect(mockProjectAPI.getRelease).toHaveBeenCalledWith( - "non-existent-release-id" - ); - }); - }); - - describe("listBuildsInRelease", () => { - it("should return builds in release from API when not cached", async () => { - const mockBuildsInRelease = [ - { - id: "build-1", - release_time: "2023-01-01T00:00:00Z", - app_version: "1.0.0", - }, - { - id: "build-2", - release_time: "2023-01-02T00:00:00Z", - app_version: "1.0.0", - } - ]; - - // Mock cache to return null first to simulate no cached data - mockCache.get.mockReturnValueOnce(null); - mockProjectAPI.listBuildsInRelease.mockResolvedValue({ - body: mockBuildsInRelease, - headers: new Headers(), - status: 200 - }); - - const result = await client.listBuildsInRelease("rel-group-1"); - - expect(mockCache.get).toHaveBeenCalledWith( - "bugsnag_builds_in_release_rel-group-1" - ); - expect(mockProjectAPI.listBuildsInRelease).toHaveBeenCalledWith( - "rel-group-1" - ); - expect(mockCache.set).toHaveBeenCalledWith( - "bugsnag_builds_in_release_rel-group-1", - mockBuildsInRelease, - 300 - ); - expect(result).toEqual(mockBuildsInRelease); - }); - - it("should return cached builds in release when available", async () => { - const mockBuildsInRelease = [ - { - id: "build-1", - release_time: "2023-01-01T00:00:00Z", - app_version: "1.0.0", - } - ]; - - // Mock cache to return builds - mockCache.get.mockReturnValueOnce(mockBuildsInRelease); - - const result = await client.listBuildsInRelease("rel-group-1"); - - expect(mockCache.get).toHaveBeenCalledWith( - "bugsnag_builds_in_release_rel-group-1" - ); - expect(mockProjectAPI.listBuildsInRelease).not.toHaveBeenCalled(); - expect(result).toEqual(mockBuildsInRelease); - }); - - it("should return empty array when no builds in release found", async () => { - // Mock cache to return null to simulate no cached data - mockCache.get.mockReturnValueOnce(null); - mockProjectAPI.listBuildsInRelease.mockResolvedValue({ body: null, headers: new Headers(), status: 200 }); - - const result = await client.listBuildsInRelease("rel-group-1"); - - expect(mockProjectAPI.listBuildsInRelease).toHaveBeenCalledWith( - "rel-group-1" - ); - expect(mockCache.set).toHaveBeenCalledWith( - "bugsnag_builds_in_release_rel-group-1", - [], - 300 - ); - expect(result).toEqual([]); - }); - }); - - describe('getEventById', () => { - it('should find event across multiple projects', async () => { - const mockOrgs = [{ id: 'org-1', name: 'Test Org' }]; - const mockProjects = [ - { id: 'proj-1', name: 'Project 1' }, - { id: 'proj-2', name: 'Project 2' } - ]; - const mockEvent = { id: 'event-1', project_id: 'proj-2' }; - - mockCache.get.mockReturnValueOnce(mockProjects); - mockCache.get.mockReturnValueOnce(mockOrgs); - mockCurrentUserAPI.getOrganizationProjects.mockResolvedValue({ body: mockProjects }); - mockErrorAPI.viewEventById - .mockRejectedValueOnce(new Error('Not found')) // proj-1 - .mockResolvedValueOnce({ body: mockEvent }); // proj-2 - - const result = await client.getEvent('event-1'); - - expect(mockErrorAPI.viewEventById).toHaveBeenCalledWith('proj-1', 'event-1'); - expect(mockErrorAPI.viewEventById).toHaveBeenCalledWith('proj-2', 'event-1'); - expect(result).toEqual(mockEvent); - }); - - it('should return null when event not found in any project', async () => { - const mockOrgs = [{ id: 'org-1', name: 'Test Org' }]; - const mockProjects = [{ id: 'proj-1', name: 'Project 1' }]; - - mockCurrentUserAPI.listUserOrganizations.mockResolvedValue({ body: mockOrgs }); - mockCurrentUserAPI.getOrganizationProjects.mockResolvedValue({ body: mockProjects }); - mockErrorAPI.viewEventById.mockRejectedValue(new Error('Not found')); - - const result = await client.getEvent('event-1'); - - expect(result).toBeNull(); - }); - }); - }); - - describe('tool registration', () => { - let registerToolsSpy: any; - let getInputFunctionSpy: any; - - beforeEach(() => { - registerToolsSpy = vi.fn(); - getInputFunctionSpy = vi.fn(); - }); - - it('should register list_projects tool when no project API key', () => { - client.registerTools(registerToolsSpy, getInputFunctionSpy); - - expect(registerToolsSpy).toBeCalledWith( - expect.any(Object), - expect.any(Function) - ); - }); - - it('should not register list_projects tool when project API key is provided', () => { - const clientWithApiKey = new BugsnagClient('test-token', 'project-api-key'); - clientWithApiKey.registerTools(registerToolsSpy, getInputFunctionSpy); - - const registeredTools = registerToolsSpy.mock.calls.map((call: any) => call[0].title); - expect(registeredTools).not.toContain('List Projects'); - }); - - it('should register common tools regardless of project API key', () => { - client.registerTools(registerToolsSpy, getInputFunctionSpy); - - const registeredTools = registerToolsSpy.mock.calls.map((call: any) => call[0].title); - expect(registeredTools).toContain('Get Error'); - expect(registeredTools).toContain('Get Event Details'); - expect(registeredTools).toContain('List Project Errors'); - expect(registeredTools).toContain('List Project Event Filters'); - expect(registeredTools).toContain('Update Error'); - expect(registeredTools).toContain('List Builds'); - expect(registeredTools).toContain('Get Build'); - expect(registeredTools).toContain('List Releases'); - expect(registeredTools).toContain('Get Release'); - expect(registeredTools).toContain('List Builds in Release'); - }); - }); - - describe('resource registration', () => { - let registerResourcesSpy: any; - - beforeEach(() => { - registerResourcesSpy = vi.fn(); - }); - - it('should register event resource', () => { - client.registerResources(registerResourcesSpy); - - expect(registerResourcesSpy).toHaveBeenCalledWith( - 'event', - '{id}', - expect.any(Function) - ); - }); - }); - - describe('tool handlers', () => { - - let registerToolsSpy: any; - let getInputFunctionSpy: any; - - beforeEach(() => { - registerToolsSpy = vi.fn(); - getInputFunctionSpy = vi.fn(); - }); - - describe('list_projects tool handler', () => { - it('should return projects with pagination', async () => { - const mockProjects = [ - { id: 'proj-1', name: 'Project 1' }, - { id: 'proj-2', name: 'Project 2' }, - { id: 'proj-3', name: 'Project 3' } - ]; - mockCache.get.mockReturnValue(mockProjects); - - client.registerTools(registerToolsSpy, getInputFunctionSpy); - const toolHandler = registerToolsSpy.mock.calls - .find((call: any) => call[0].title === 'List Projects')[1]; - - const result = await toolHandler({ page_size: 2, page: 1 }); - - const expectedResult = { - data: mockProjects.slice(0, 2), - count: 2 - }; - expect(result.content[0].text).toBe(JSON.stringify(expectedResult)); - }); - - it('should return all projects when no pagination specified', async () => { - const mockProjects = [{ id: 'proj-1', name: 'Project 1' }]; - mockCache.get.mockReturnValue(mockProjects); - - client.registerTools(registerToolsSpy, getInputFunctionSpy); - const toolHandler = registerToolsSpy.mock.calls - .find((call: any) => call[0].title === 'List Projects')[1]; - - const result = await toolHandler({}); - - const expectedResult = { - data: mockProjects, - count: 1 - }; - expect(result.content[0].text).toBe(JSON.stringify(expectedResult)); - }); - - it('should handle no projects found', async () => { - mockCache.get.mockReturnValue([]); - - client.registerTools(registerToolsSpy, getInputFunctionSpy); - const toolHandler = registerToolsSpy.mock.calls - .find((call: any) => call[0].title === 'List Projects')[1]; - - const result = await toolHandler({}); - - expect(result.content[0].text).toBe('No projects found.'); - }); - - it('should handle pagination with only page_size', async () => { - const mockProjects = [ - { id: 'proj-1', name: 'Project 1' }, - { id: 'proj-2', name: 'Project 2' }, - { id: 'proj-3', name: 'Project 3' } - ]; - mockCache.get.mockReturnValue(mockProjects); - - client.registerTools(registerToolsSpy, getInputFunctionSpy); - const toolHandler = registerToolsSpy.mock.calls - .find((call: any) => call[0].title === 'List Projects')[1]; - - const result = await toolHandler({ page_size: 2 }); - - const expectedResult = { - data: mockProjects.slice(0, 2), - count: 2 - }; - expect(result.content[0].text).toBe(JSON.stringify(expectedResult)); - }); - - it('should handle pagination with only page', async () => { - const mockProjects = Array.from({ length: 25 }, (_, i) => ({ - id: `proj-${i + 1}`, - name: `Project ${i + 1}` - })); - mockCache.get.mockReturnValue(mockProjects); - - client.registerTools(registerToolsSpy, getInputFunctionSpy); - const toolHandler = registerToolsSpy.mock.calls - .find((call: any) => call[0].title === 'List Projects')[1]; - - const result = await toolHandler({ page: 2 }); - - // Default page_size is 10, so page 2 should return projects 10-19 - const expectedResult = { - data: mockProjects.slice(10, 20), - count: 10 - }; - expect(result.content[0].text).toBe(JSON.stringify(expectedResult)); - }); - }); - - describe('get_error tool handler', () => { - it('should get error details with project from cache', async () => { - const mockProject = { id: 'proj-1', name: 'Project 1', slug: 'my-project' }; - const mockError = { id: 'error-1', message: 'Test error' }; - const mockOrg = { id: 'org-1', name: 'Test Org', slug: 'test-org' }; - const mockEvents = [{ id: 'event-1', timestamp: '2023-01-01' }]; - const mockPivots = [{ id: 'pivot-1', name: 'test-pivot' }]; - - mockCache.get.mockReturnValueOnce(mockProject) - .mockReturnValueOnce(mockOrg); - mockErrorAPI.viewErrorOnProject.mockResolvedValue({ body: mockError }); - mockErrorAPI.listEventsOnProject.mockResolvedValue({ body: mockEvents }); - mockErrorAPI.listErrorPivots.mockResolvedValue({ body: mockPivots }); - - client.registerTools(registerToolsSpy, getInputFunctionSpy); - const toolHandler = registerToolsSpy.mock.calls - .find((call: any) => call[0].title === 'Get Error')[1]; - - const result = await toolHandler({ errorId: 'error-1' }); - - const queryString = '?filters[error][][type]=eq&filters[error][][value]=error-1' - const encodedQueryString = encodeURI(queryString); - expect(mockErrorAPI.viewErrorOnProject).toHaveBeenCalledWith('proj-1', 'error-1'); - expect(result.content[0].text).toBe(JSON.stringify({ - error_details: mockError, - latest_event: mockEvents[0], - pivots: mockPivots, - url: `https://app.bugsnag.com/${mockOrg.slug}/${mockProject.slug}/errors/error-1${encodedQueryString}` - })); - }); - - it('should throw error when projectId is not set', async () => { - client.registerTools(registerToolsSpy, getInputFunctionSpy); - const toolHandler = registerToolsSpy.mock.calls - .find((call: any) => call[0].title === 'Get Error')[1]; - - await expect(toolHandler({})).rejects.toThrow('No current project found. Please provide a projectId or configure a project API key.'); - }); - - it('should throw error when error ID is not set', async () => { - const mockProject = { id: 'proj-1', name: 'Project 1', slug: 'my-project' }; - mockCache.get.mockReturnValueOnce(mockProject); - - client.registerTools(registerToolsSpy, getInputFunctionSpy); - const toolHandler = registerToolsSpy.mock.calls - .find((call: any) => call[0].title === 'Get Error')[1]; - - await expect(toolHandler({})).rejects.toThrow('Both projectId and errorId arguments are required'); - }); - }); - - describe('get_bugsnag_event_details tool handler', () => { - it('should get event details from dashboard URL', async () => { - const mockProjects = [{ id: 'proj-1', slug: 'my-project', name: 'My Project' }]; - const mockEvent = { id: 'event-1', project_id: 'proj-1' }; - - mockCache.get.mockReturnValue(mockProjects); - - mockErrorAPI.viewEventById.mockResolvedValue({ body: mockEvent }); - - client.registerTools(registerToolsSpy, getInputFunctionSpy); - const toolHandler = registerToolsSpy.mock.calls - .find((call: any) => call[0].title === 'Get Event Details')[1]; - - const result = await toolHandler({ - link: 'https://app.bugsnag.com/my-org/my-project/errors/error-123?event_id=event-1' - }); - - expect(mockErrorAPI.viewEventById).toHaveBeenCalledWith('proj-1', 'event-1'); - expect(result.content[0].text).toBe(JSON.stringify(mockEvent)); - }); - - it('should throw error when link is invalid', async () => { - client.registerTools(registerToolsSpy, getInputFunctionSpy); - const toolHandler = registerToolsSpy.mock.calls - .find((call: any) => call[0].title === 'Get Event Details')[1]; - - await expect(toolHandler({ link: 'invalid-url' })).rejects.toThrow(); - }); - - it('should throw error when project not found', async () => { - mockCache.get.mockReturnValue([{ id: 'proj-1', slug: 'other-project', name: 'Other Project' }]); - - client.registerTools(registerToolsSpy, getInputFunctionSpy); - const toolHandler = registerToolsSpy.mock.calls - .find((call: any) => call[0].title === 'Get Event Details')[1]; - - await expect(toolHandler({ - link: 'https://app.bugsnag.com/my-org/my-project/errors/error-123?event_id=event-1' - })).rejects.toThrow('Project with the specified slug not found.'); - }); - - it('should throw error when URL is missing required parameters', async () => { - client.registerTools(registerToolsSpy, getInputFunctionSpy); - const toolHandler = registerToolsSpy.mock.calls - .find((call: any) => call[0].title === 'Get Event Details')[1]; - - await expect(toolHandler({ - link: 'https://app.bugsnag.com/my-org/my-project/errors/error-123' // Missing event_id - })).rejects.toThrow('Both projectSlug and eventId must be present in the link'); - }); - }); - - describe('list_project_errors tool handler', () => { - it('should list project errors with supplied parameters', async () => { - const mockProject = { id: 'proj-1', name: 'Project 1' }; - const mockEventFields = [ - { display_id: 'error.status', custom: false }, - { display_id: 'user.email', custom: false }, - { display_id: 'event.since', custom: false } - ]; - const mockErrors = [{ id: 'error-1', message: 'Test error' }]; - const filters = { - 'error.status': [{ type: 'eq' as const, value: 'for_review' }], - 'event.since': [{ type: 'eq', value: '7d' }] - }; - - mockCache.get - .mockReturnValueOnce(mockProject) // current project - .mockReturnValueOnce(mockEventFields); // event fields - mockErrorAPI.listProjectErrors.mockResolvedValue({ - body: mockErrors, - headers: new Headers({ 'X-Total-Count': '1' }) - }); - - client.registerTools(registerToolsSpy, getInputFunctionSpy); - const toolHandler = registerToolsSpy.mock.calls - .find((call: any) => call[0].title === 'List Project Errors')[1]; - - const result = await toolHandler({ filters, sort: 'last_seen', direction: 'desc', per_page: 50 }); - - expect(mockErrorAPI.listProjectErrors).toHaveBeenCalledWith('proj-1', { filters, sort: 'last_seen', direction: 'desc', per_page: 50 }); - const expectedResult = { - data: mockErrors, - count: 1, - total: 1 - }; - expect(result.content[0].text).toBe(JSON.stringify(expectedResult)); - }); - - it('should use default filters when not specified', async () => { - const mockProject = { id: 'proj-1', name: 'Project 1' }; - const mockEventFields = [ - { display_id: 'error.status', custom: false }, - { display_id: 'user.email', custom: false }, - { display_id: 'event.since', custom: false } - ]; - const mockErrors = [{ id: 'error-1', message: 'Test error' }]; - const defaultFilters = { - 'error.status': [{ type: 'eq' as const, value: 'open' }], - 'event.since': [{ type: 'eq', value: '30d' }] - }; - - mockCache.get - .mockReturnValueOnce(mockProject) // current project - .mockReturnValueOnce(mockEventFields); // event fields - mockErrorAPI.listProjectErrors.mockResolvedValue({ - body: mockErrors, - headers: new Headers({ 'X-Total-Count': '1' }) - }); - - client.registerTools(registerToolsSpy, getInputFunctionSpy); - const toolHandler = registerToolsSpy.mock.calls - .find((call: any) => call[0].title === 'List Project Errors')[1]; - - const defaultFilterResult = await toolHandler({ sort: 'last_seen', direction: 'desc', per_page: 50 }); - - expect(mockErrorAPI.listProjectErrors).toHaveBeenCalledWith('proj-1', { filters: defaultFilters, sort: 'last_seen', direction: 'desc', per_page: 50 }); - const expectedResult = { - data: mockErrors, - count: 1, - total: 1 - }; - expect(defaultFilterResult.content[0].text).toBe(JSON.stringify(expectedResult)); - }); - - it('should validate filter keys against cached event fields', async () => { - const mockProject = { id: 'proj-1', name: 'Project 1' }; - const mockEventFields = [{ display_id: 'error.status', custom: false }]; - const filters = { 'invalid.field': [{ type: 'eq' as const, value: 'test' }] }; - - mockCache.get - .mockReturnValueOnce(mockProject) - .mockReturnValueOnce(mockEventFields); - - client.registerTools(registerToolsSpy, getInputFunctionSpy); - const toolHandler = registerToolsSpy.mock.calls - .find((call: any) => call[0].title === 'List Project Errors')[1]; - - await expect(toolHandler({ filters })).rejects.toThrow('Invalid filter key: invalid.field'); - }); - - it('should throw error when no project ID available', async () => { - mockCache.get.mockReturnValue(null); - - client.registerTools(registerToolsSpy, getInputFunctionSpy); - const toolHandler = registerToolsSpy.mock.calls - .find((call: any) => call[0].title === 'List Project Errors')[1]; - - await expect(toolHandler({})).rejects.toThrow('No current project found. Please provide a projectId or configure a project API key.'); - }); - }); - - describe('get_project_event_filters tool handler', () => { - it('should return cached event fields', async () => { - const mockEventFields = [ - { display_id: 'error.status', custom: false }, - { display_id: 'user.email', custom: false } - ]; - mockCache.get.mockReturnValue(mockEventFields); - - client.registerTools(registerToolsSpy, getInputFunctionSpy); - const toolHandler = registerToolsSpy.mock.calls - .find((call: any) => call[0].title === 'List Project Event Filters')[1]; - - const result = await toolHandler({}); - - expect(result.content[0].text).toBe(JSON.stringify(mockEventFields)); - }); - - it('should throw error when no event filters in cache', async () => { - mockCache.get.mockReturnValue(null); - - client.registerTools(registerToolsSpy, getInputFunctionSpy); - const toolHandler = registerToolsSpy.mock.calls - .find((call: any) => call[0].title === 'List Project Event Filters')[1]; - - await expect(toolHandler({})).rejects.toThrow('No event filters found in cache.'); - }); - }); - - describe("list_builds tool handler", () => { - it("should list builds with project from cache", async () => { - const mockProject = { id: "proj-1", name: "Project 1" }; - const mockBuilds = [ - { - id: "rel-1", - release_time: "2023-01-01T00:00:00Z", - app_version: "1.0.0", - release_stage: { name: "production" }, - source_control: { - service: "github", - commit_url: - "https://github.com/org/repo/commit/abc123", - }, - errors_introduced_count: 5, - errors_seen_count: 10, - total_sessions_count: 100, - unhandled_sessions_count: 10, - accumulative_daily_users_seen: 50, - accumulative_daily_users_with_unhandled: 5, - }, - ]; - - const enhancedBuilds = mockBuilds.map((build) => ({ - ...build, - user_stability: 0.9, - session_stability: 0.9, - stability_target_type: "user", - target_stability: 0.995, - critical_stability: 0.85, - meets_target_stability: false, - meets_critical_stability: true, - })); - - // Mock project cache to return the project - mockCache.get.mockReturnValueOnce(mockProject); - mockProjectAPI.listBuilds.mockResolvedValue({ - body: mockBuilds, - }); - - client.registerTools(registerToolsSpy, getInputFunctionSpy); - const toolHandler = registerToolsSpy.mock.calls.find( - (call: any) => call[0].title === "List Builds" - )[1]; - - const result = await toolHandler({ - releaseStage: "production", - }); - - expect(mockProjectAPI.listBuilds).toHaveBeenCalledWith( - "proj-1", - { release_stage: "production" } - ); - expect(result.content[0].text).toBe( - JSON.stringify({ builds: enhancedBuilds, next: null }) - ); - }); - - it("should list builds with explicit project ID", async () => { - const mockProjects = [ - { id: "proj-1", name: "Project 1" }, - { id: "proj-2", name: "Project 2" }, - ]; - const mockBuilds = [ - { - id: "rel-1", - release_time: "2023-01-01T00:00:00Z", - app_version: "1.0.0", - release_stage: { name: "staging" }, - total_sessions_count: 50, - unhandled_sessions_count: 5, - accumulative_daily_users_seen: 30, - accumulative_daily_users_with_unhandled: 3, - }, - ]; - - const enhancedBuilds = mockBuilds.map((build) => ({ - ...build, - user_stability: 0.9, - session_stability: 0.9, - stability_target_type: "user", - target_stability: 0.995, - critical_stability: 0.85, - meets_target_stability: false, - meets_critical_stability: true, - })); - - // Mock projects cache to return the projects list - mockCache.get.mockReturnValueOnce(mockProjects); - mockProjectAPI.listBuilds.mockResolvedValue({ - body: mockBuilds, - }); - - client.registerTools(registerToolsSpy, getInputFunctionSpy); - const toolHandler = registerToolsSpy.mock.calls.find( - (call: any) => call[0].title === "List Builds" - )[1]; - - const result = await toolHandler({ - projectId: "proj-1", - releaseStage: "staging", - }); - - expect(mockProjectAPI.listBuilds).toHaveBeenCalledWith( - "proj-1", - { release_stage: "staging" } - ); - expect(result.content[0].text).toBe( - JSON.stringify({ builds: enhancedBuilds, next: null}) - ); - }); - - it("should handle empty builds list", async () => { - const mockProject = { id: "proj-1", name: "Project 1" }; - - // Mock project cache to return the project - mockCache.get.mockReturnValueOnce(mockProject); - mockProjectAPI.listBuilds.mockResolvedValue({ body: [] }); - - client.registerTools(registerToolsSpy, getInputFunctionSpy); - const toolHandler = registerToolsSpy.mock.calls.find( - (call: any) => call[0].title === "List Builds" - )[1]; - - const result = await toolHandler({}); - - expect(mockProjectAPI.listBuilds).toHaveBeenCalledWith( - "proj-1", - {} - ); - expect(result.content[0].text).toBe(JSON.stringify({ builds: [], next: null })); - }); - - it("should throw error when no project ID available", async () => { - mockCache.get.mockReturnValue(null); - - client.registerTools(registerToolsSpy, getInputFunctionSpy); - const toolHandler = registerToolsSpy.mock.calls.find( - (call: any) => call[0].title === "List Builds" - )[1]; - - await expect(toolHandler({})).rejects.toThrow( - "No current project found. Please provide a projectId or configure a project API key." - ); - }); - }); - - describe("get_build tool handler", () => { - it("should get build details with project from cache", async () => { - const mockProject = { id: "proj-1", name: "Project 1" }; - const mockBuild = { - id: "rel-1", - release_time: "2023-01-01T00:00:00Z", - app_version: "1.0.0", - release_stage: { name: "production" }, - source_control: { - service: "github", - commit_url: "https://github.com/org/repo/commit/abc123", - revision: "abc123", - diff_url_to_previous: - "https://github.com/org/repo/compare/previous...abc123", - }, - errors_introduced_count: 5, - errors_seen_count: 10, - total_sessions_count: 100, - unhandled_sessions_count: 10, - accumulative_daily_users_seen: 50, - accumulative_daily_users_with_unhandled: 5, - } - const enhancedBuild = { - ...mockBuild, - user_stability: 0.9, - session_stability: 0.9, - stability_target_type: "user", - target_stability: 0.995, - critical_stability: 0.85, - meets_target_stability: false, - meets_critical_stability: true, - }; - - // First get for the project, second for cached build (return null to call API) - mockCache.get - .mockReturnValueOnce(mockProject) - .mockReturnValueOnce(null); - mockProjectAPI.getBuild.mockResolvedValue({ - body: mockBuild, - }); - client.registerTools(registerToolsSpy, getInputFunctionSpy); - const toolHandler = registerToolsSpy.mock.calls.find( - (call: any) => call[0].title === "Get Build" - )[1]; - - const result = await toolHandler({ buildId: "rel-1" }); - - expect(mockProjectAPI.getBuild).toHaveBeenCalledWith( - "proj-1", - "rel-1" - ); - expect(mockCache.set).toHaveBeenCalledWith( - "bugsnag_build_rel-1", - enhancedBuild, - 300 - ); - expect(result.content[0].text).toBe( - JSON.stringify(enhancedBuild) - ); - }); - - it("should get build with explicit project ID", async () => { - const mockProjects = [ - { id: "proj-1", name: "Project 1" }, - { id: "proj-2", name: "Project 2" }, - ]; const mockBuild = { - id: "rel-1", - release_time: "2023-01-01T00:00:00Z", - app_version: "1.0.0", - release_stage: { name: "production" }, - total_sessions_count: 50, - unhandled_sessions_count: 5, - accumulative_daily_users_seen: 30, - accumulative_daily_users_with_unhandled: 3, - }; - - const enhancedBuild = { - ...mockBuild, - user_stability: 0.9, - session_stability: 0.9, - stability_target_type: "user", - target_stability: 0.995, - critical_stability: 0.85, - meets_target_stability: false, - meets_critical_stability: true, - }; - - // First get for projects, second for cached build (return null to call API) - mockCache.get - .mockReturnValueOnce(mockProjects) - .mockReturnValueOnce(null); - mockProjectAPI.getBuild.mockResolvedValue({ - body: mockBuild, - }); - - client.registerTools(registerToolsSpy, getInputFunctionSpy); - const toolHandler = registerToolsSpy.mock.calls.find( - (call: any) => call[0].title === "Get Build" - )[1]; - - const result = await toolHandler({ - projectId: "proj-1", - buildId: "rel-1", - }); - - expect(mockProjectAPI.getBuild).toHaveBeenCalledWith( - "proj-1", - "rel-1" - ); - expect(mockCache.set).toHaveBeenCalledWith( - "bugsnag_build_rel-1", - enhancedBuild, - 300 - ); - expect(result.content[0].text).toBe( - JSON.stringify(enhancedBuild) - ); - }); - - it("should throw error when build not found", async () => { - const mockProject = { id: "proj-1", name: "Project 1" }; - - mockCache.get - .mockReturnValueOnce(mockProject) - .mockReturnValueOnce(null); - mockProjectAPI.getBuild.mockResolvedValue({ body: null }); - - client.registerTools(registerToolsSpy, getInputFunctionSpy); - const toolHandler = registerToolsSpy.mock.calls.find( - (call: any) => call[0].title === "Get Build" - )[1]; - - await expect( - toolHandler({ buildId: "non-existent-release-id" }) - ).rejects.toThrow( - "No build for non-existent-release-id found." - ); - }); - - it("should throw error when buildId argument is missing", async () => { - const mockProject = { id: "proj-1", name: "Project 1" }; - - mockCache.get.mockReturnValueOnce(mockProject); - - client.registerTools(registerToolsSpy, getInputFunctionSpy); - const toolHandler = registerToolsSpy.mock.calls.find( - (call: any) => call[0].title === "Get Build" - )[1]; - - await expect(toolHandler({})).rejects.toThrow( - "buildId argument is required" - ); - }); - - it("should throw error when no project ID available", async () => { - mockCache.get - .mockReturnValue(null); - - client.registerTools(registerToolsSpy, getInputFunctionSpy); - const toolHandler = registerToolsSpy.mock.calls.find( - (call: any) => call[0].title === "Get Build" - )[1]; - - await expect( - toolHandler({ buildId: "rel-1" }) - ).rejects.toThrow("No current project found. Please provide a projectId or configure a project API key."); - }); - }); - - describe("list_releases tool handler", () => { - it("should list releases with project from cache", async () => { - const mockProject = { id: "proj-1", name: "Project 1" }; - const mockReleases = [ - { - id: "rel-group-1", - release_stage_name: "production", - app_version: "1.0.0", - first_released_at: "2023-01-01T00:00:00Z", - total_sessions_count: 50, - unhandled_sessions_count: 5, - accumulative_daily_users_seen: 30, - accumulative_daily_users_with_unhandled: 3, - }, - ]; - - const enhancedReleases = mockReleases.map((release) => ({ - ...release, - user_stability: 0.9, - session_stability: 0.9, - stability_target_type: "user", - target_stability: 0.995, - critical_stability: 0.85, - meets_target_stability: false, - meets_critical_stability: true, - })); - - // Mock project cache to return the project - mockCache.get.mockReturnValueOnce(mockProject); - mockProjectAPI.listReleases.mockResolvedValue({ - body: mockReleases, - }); - - client.registerTools(registerToolsSpy, getInputFunctionSpy); - const toolHandler = registerToolsSpy.mock.calls.find( - (call: any) => call[0].title === "List Releases" - )[1]; - - const result = await toolHandler({ - releaseStage: "production", - visibleOnly: true, - }); - - expect(mockProjectAPI.listReleases).toHaveBeenCalledWith( - "proj-1", - { release_stage_name: "production", visible_only: true,next_url: null } - ); - expect(result.content[0].text).toBe( - JSON.stringify({ releases: enhancedReleases, next: null }) - ); - }); - - it("should list releases with explicit project ID", async () => { - const mockProjects = [ - { id: "proj-1", name: "Project 1" }, - { id: "proj-2", name: "Project 2" }, - ]; - const mockReleases = [ - { - id: "rel-group-2", - release_stage_name: "staging", - app_version: "1.0.0", - total_sessions_count: 50, - unhandled_sessions_count: 5, - accumulative_daily_users_seen: 30, - accumulative_daily_users_with_unhandled: 3, - }, - ]; - - const enhancedReleases = mockReleases.map((release) => ({ - ...release, - user_stability: 0.9, - session_stability: 0.9, - stability_target_type: "user", - target_stability: 0.995, - critical_stability: 0.85, - meets_target_stability: false, - meets_critical_stability: true, - })); - - // Mock projects cache to return the projects list - mockCache.get.mockReturnValueOnce(mockProjects); - mockProjectAPI.listReleases.mockResolvedValue({ - body: mockReleases, - }); - - client.registerTools(registerToolsSpy, getInputFunctionSpy); - const toolHandler = registerToolsSpy.mock.calls.find( - (call: any) => call[0].title === "List Releases" - )[1]; - - const result = await toolHandler({ - projectId: "proj-2", - releaseStage: "staging", - visibleOnly: false, - }); - - expect(mockProjectAPI.listReleases).toHaveBeenCalledWith( - "proj-2", - { release_stage_name: "staging", visible_only: false, next_url: null } - ); - expect(result.content[0].text).toBe( - JSON.stringify({ releases: enhancedReleases, next: null }) - ); - }); - - it("should handle empty releases list", async () => { - const mockProject = { id: "proj-1", name: "Project 1" }; - - // Mock project cache to return the project - mockCache.get.mockReturnValueOnce(mockProject); - mockProjectAPI.listReleases.mockResolvedValue({ body: [] }); - - client.registerTools(registerToolsSpy, getInputFunctionSpy); - const toolHandler = registerToolsSpy.mock.calls.find( - (call: any) => call[0].title === "List Releases" - )[1]; - - const result = await toolHandler({ - releaseStage: "production", - visibleOnly: true, - }); - - expect(mockProjectAPI.listReleases).toHaveBeenCalledWith( - "proj-1", - { release_stage_name: "production", visible_only: true, next_url: null } - ); - expect(result.content[0].text).toBe(JSON.stringify({ releases: [], next: null })); - }); - - it("should throw error when no project ID available", async () => { - mockCache.get.mockReturnValue(null); - - client.registerTools(registerToolsSpy, getInputFunctionSpy); - const toolHandler = registerToolsSpy.mock.calls.find( - (call: any) => call[0].title === "List Releases" - )[1]; - - await expect(toolHandler({})).rejects.toThrow( - "No current project found. Please provide a projectId or configure a project API key." - ); - }); - }); - - describe("get_release tool handler", () => { - it("should get release details with project from cache", async () => { - const mockProject = { id: "proj-1", name: "Project 1" }; - const mockRelease = { - id: "rel-group-1", - project_id: "proj-1", - release_stage_name: "production", - app_version: "1.0.0", - first_released_at: "2023-01-01T00:00:00Z", - total_sessions_count: 100, - unhandled_sessions_count: 10, - accumulative_daily_users_seen: 50, - accumulative_daily_users_with_unhandled: 5, - }; - - const enhancedRelease = { - ...mockRelease, - user_stability: 0.9, - session_stability: 0.9, - stability_target_type: "user", - target_stability: 0.995, - critical_stability: 0.85, - meets_target_stability: false, - meets_critical_stability: true, - }; - - // First get for the project, second for cached release (return null to call API) - mockCache.get - .mockReturnValueOnce(mockProject) - .mockReturnValueOnce(null); - mockProjectAPI.getRelease.mockResolvedValue({ - body: mockRelease, - }); - - client.registerTools(registerToolsSpy, getInputFunctionSpy); - const toolHandler = registerToolsSpy.mock.calls.find( - (call: any) => call[0].title === "Get Release" - )[1]; - - const result = await toolHandler({ releaseId: "rel-group-1" }); - - expect(mockProjectAPI.getRelease).toHaveBeenCalledWith( - "rel-group-1" - ); - expect(mockCache.set).toHaveBeenCalledWith( - "bugsnag_release_rel-group-1", - enhancedRelease, - 300 - ); - expect(result.content[0].text).toBe( - JSON.stringify(enhancedRelease) - ); - }); - - it("should get release with explicit project ID", async () => { - const mockProjects = [ - { id: "proj-1", name: "Project 1" }, - { id: "proj-2", name: "Project 2" }, - ]; - const mockRelease = { - id: "rel-group-2", - project_id: "proj-2", - release_stage_name: "staging", - app_version: "1.0.0", - first_released_at: "2023-01-01T00:00:00Z", - total_sessions_count: 50, - unhandled_sessions_count: 5, - accumulative_daily_users_seen: 30, - accumulative_daily_users_with_unhandled: 3, - }; - - const enhancedRelease = { - ...mockRelease, - user_stability: 0.9, - session_stability: 0.9, - stability_target_type: "user", - target_stability: 0.995, - critical_stability: 0.85, - meets_target_stability: false, - meets_critical_stability: true, - }; - - // First get for projects, second for cached release (return null to call API) - mockCache.get - .mockReturnValueOnce(mockProjects) - .mockReturnValueOnce(null); - mockProjectAPI.getRelease.mockResolvedValue({ - body: mockRelease, - }); - - client.registerTools(registerToolsSpy, getInputFunctionSpy); - const toolHandler = registerToolsSpy.mock.calls.find( - (call: any) => call[0].title === "Get Release" - )[1]; - - const result = await toolHandler({ - projectId: "proj-2", - releaseId: "rel-group-2", - }); - - expect(mockProjectAPI.getRelease).toHaveBeenCalledWith( - "rel-group-2" - ); - expect(mockCache.set).toHaveBeenCalledWith( - "bugsnag_release_rel-group-2", - enhancedRelease, - 300 - ); - expect(result.content[0].text).toBe( - JSON.stringify(enhancedRelease) - ); - }); - - it("should throw error when release not found", async () => { - const mockProject = { id: "proj-1", name: "Project 1" }; - - mockCache.get - .mockReturnValueOnce(mockProject) - .mockReturnValueOnce(null); - mockProjectAPI.getRelease.mockResolvedValue({ body: null }); - - client.registerTools(registerToolsSpy, getInputFunctionSpy); - const toolHandler = registerToolsSpy.mock.calls.find( - (call: any) => call[0].title === "Get Release" - )[1]; - - await expect( - toolHandler({ releaseId: "non-existent-release-id" }) - ).rejects.toThrow( - "No release for non-existent-release-id found." - ); - }); - - it("should throw error when releaseId argument is missing", async () => { - - client.registerTools(registerToolsSpy, getInputFunctionSpy); - const toolHandler = registerToolsSpy.mock.calls.find( - (call: any) => call[0].title === "Get Release" - )[1]; - - await expect(toolHandler({})).rejects.toThrow( - "releaseId argument is required" - ); - }); - }); - - describe("list_builds_in_release tool handler", () => { - it("should list builds in release with project from cache", async () => { - const mockBuildsInRelease = [ - { - id: "build-1", - release_time: "2023-01-01T00:00:00Z", - app_version: "1.0.0" - }, - { - id: "build-2", - release_time: "2023-01-02T00:00:00Z", - app_version: "1.0.0" - } - ]; - - mockCache.get - .mockReturnValueOnce(mockBuildsInRelease); - - client.registerTools(registerToolsSpy, getInputFunctionSpy); - const toolHandler = registerToolsSpy.mock.calls.find( - (call: any) => call[0].title === "List Builds in Release" - )[1]; - - const result = await toolHandler({ - releaseId: "rel-group-1" - }); - - expect(mockCache.get).toHaveBeenCalledWith( - "bugsnag_builds_in_release_rel-group-1" - ); - expect(mockProjectAPI.listBuildsInRelease).toHaveBeenCalledTimes(0); - expect(mockCache.set).toHaveBeenCalledTimes(0); - - expect(result.content[0].text).toBe( - JSON.stringify(mockBuildsInRelease) - ); - }); - - it("should list builds in release with explicit release ID", async () => { - const mockBuildsInRelease = [ - { - id: "build-1", - release_time: "2023-01-01T00:00:00Z", - app_version: "1.0.0" - } - ]; - - mockCache.get - .mockReturnValueOnce(null); - mockProjectAPI.listBuildsInRelease.mockResolvedValue({ - body: mockBuildsInRelease, - }); - - client.registerTools(registerToolsSpy, getInputFunctionSpy); - const toolHandler = registerToolsSpy.mock.calls.find( - (call: any) => call[0].title === "List Builds in Release" - )[1]; - - const result = await toolHandler({ - releaseId: "rel-group-2" - }); - - expect(mockCache.get).toHaveBeenCalledWith( - "bugsnag_builds_in_release_rel-group-2" - ); - expect(mockProjectAPI.listBuildsInRelease).toHaveBeenCalledWith( - "rel-group-2" - ); - expect(mockCache.set).toHaveBeenCalledWith( - "bugsnag_builds_in_release_rel-group-2", - mockBuildsInRelease, - 300 - ); - expect(result.content[0].text).toBe( - JSON.stringify(mockBuildsInRelease) - ); - }); - - it("should handle empty builds in release list", async () => { - - mockCache.get - .mockReturnValueOnce(null); - mockProjectAPI.listBuildsInRelease.mockResolvedValue({ body: [] }); - - client.registerTools(registerToolsSpy, getInputFunctionSpy); - const toolHandler = registerToolsSpy.mock.calls.find( - (call: any) => call[0].title === "List Builds in Release" - )[1]; - - const result = await toolHandler({ - releaseId: "rel-group-1" - }); - - expect(mockProjectAPI.listBuildsInRelease).toHaveBeenCalledWith( - "rel-group-1" - ); - expect(mockCache.set).toHaveBeenCalledWith( - "bugsnag_builds_in_release_rel-group-1", - [], - 300 - ); - expect(result.content[0].text).toBe(JSON.stringify([])); - }); - - it("should throw error when releaseId argument is missing", async () => { - const mockProject = { id: "proj-1", name: "Project 1" }; - - mockCache.get.mockReturnValueOnce(mockProject); - - client.registerTools(registerToolsSpy, getInputFunctionSpy); - const toolHandler = registerToolsSpy.mock.calls.find( - (call: any) => call[0].title === "List Builds in Release" - )[1]; - - await expect(toolHandler({})).rejects.toThrow( - "releaseId argument is required" - ); - }); - }); - - describe('update_error tool handler', () => { - it('should update error successfully with project from cache', async () => { - const mockProject = { id: 'proj-1', name: 'Project 1' }; - - mockCache.get.mockReturnValue(mockProject); - mockErrorAPI.updateErrorOnProject.mockResolvedValue({ status: 200 }); - - client.registerTools(registerToolsSpy, getInputFunctionSpy); - const toolHandler = registerToolsSpy.mock.calls - .find((call: any) => call[0].title === 'Update Error')[1]; - - const result = await toolHandler({ - errorId: 'error-1', - operation: 'fix' - }); - - expect(mockErrorAPI.updateErrorOnProject).toHaveBeenCalledWith( - 'proj-1', - 'error-1', - { operation: 'fix' } - ); - expect(result.content[0].text).toBe(JSON.stringify({ success: true })); - }); - - it('should update error successfully with explicit project ID', async () => { - const mockProjects = [ - { id: "proj-1", name: "Project 1" }, - { id: "proj-2", name: "Project 2" }, - ]; - mockCache.get.mockReturnValue(mockProjects); - mockErrorAPI.updateErrorOnProject.mockResolvedValue({ status: 204 }); - - client.registerTools(registerToolsSpy, getInputFunctionSpy); - const toolHandler = registerToolsSpy.mock.calls - .find((call: any) => call[0].title === 'Update Error')[1]; - - const result = await toolHandler({ - projectId: 'proj-1', - errorId: 'error-1', - operation: 'ignore' - }); - - expect(mockErrorAPI.updateErrorOnProject).toHaveBeenCalledWith( - 'proj-1', - 'error-1', - { operation: 'ignore' } - ); - expect(result.content[0].text).toBe(JSON.stringify({ success: true })); - }); - - it('should handle all permitted operations', async () => { - const mockProject = { id: 'proj-1', name: 'Project 1' }; - // Test all operations except override_severity which requires special elicitInput handling - const operations = ['open', 'fix', 'ignore', 'discard', 'undiscard']; - - mockCache.get.mockReturnValue(mockProject); - mockErrorAPI.updateErrorOnProject.mockResolvedValue({ status: 200 }); - - client.registerTools(registerToolsSpy, getInputFunctionSpy); - const toolHandler = registerToolsSpy.mock.calls - .find((call: any) => call[0].title === 'Update Error')[1]; - - for (const operation of operations) { - await toolHandler({ - errorId: 'error-1', - operation: operation as any - }); - - expect(mockErrorAPI.updateErrorOnProject).toHaveBeenCalledWith( - 'proj-1', - 'error-1', - { operation, severity: undefined } - ); - } - - expect(mockErrorAPI.updateErrorOnProject).toHaveBeenCalledTimes(operations.length); - }); - - it('should handle override_severity operation with elicitInput', async () => { - const mockProject = { id: 'proj-1', name: 'Project 1' }; - - getInputFunctionSpy.mockResolvedValue({ - action: 'accept', - content: { severity: 'warning' } - }); - - mockCache.get.mockReturnValue(mockProject); - mockErrorAPI.updateErrorOnProject.mockResolvedValue({ status: 200 }); - - client.registerTools(registerToolsSpy, getInputFunctionSpy); - const toolHandler = registerToolsSpy.mock.calls - .find((call: any) => call[0].title === 'Update Error')[1]; - - const result = await toolHandler({ - errorId: 'error-1', - operation: 'override_severity' - }); - - expect(getInputFunctionSpy).toHaveBeenCalledWith({ - message: "Please provide the new severity for the error (e.g. 'info', 'warning', 'error', 'critical')", - requestedSchema: { - type: "object", - properties: { - severity: { - type: "string", - enum: ['info', 'warning', 'error'], - description: "The new severity level for the error" - } - } - }, - required: ["severity"] - }); - - expect(mockErrorAPI.updateErrorOnProject).toHaveBeenCalledWith( - 'proj-1', - 'error-1', - { operation: 'override_severity', severity: 'warning' } - ); - expect(result.content[0].text).toBe(JSON.stringify({ success: true })); - }); - - it('should handle override_severity operation when elicitInput is rejected', async () => { - const mockProject = { id: 'proj-1', name: 'Project 1' }; - - getInputFunctionSpy.mockResolvedValue({ - action: 'reject' - }); - - mockCache.get.mockReturnValue(mockProject); - mockErrorAPI.updateErrorOnProject.mockResolvedValue({ status: 200 }); - - client.registerTools(registerToolsSpy, getInputFunctionSpy); - const toolHandler = registerToolsSpy.mock.calls - .find((call: any) => call[0].title === 'Update Error')[1]; - - const result = await toolHandler({ - errorId: 'error-1', - operation: 'override_severity' - }); - - expect(mockErrorAPI.updateErrorOnProject).toHaveBeenCalledWith( - 'proj-1', - 'error-1', - { operation: 'override_severity', severity: undefined } - ); - expect(result.content[0].text).toBe(JSON.stringify({ success: true })); - }); - - it('should return false when API returns non-success status', async () => { - const mockProject = { id: 'proj-1', name: 'Project 1' }; - - mockCache.get.mockReturnValue(mockProject); - mockErrorAPI.updateErrorOnProject.mockResolvedValue({ status: 400 }); - - client.registerTools(registerToolsSpy, getInputFunctionSpy); - const toolHandler = registerToolsSpy.mock.calls - .find((call: any) => call[0].title === 'Update Error')[1]; - - const result = await toolHandler({ - errorId: 'error-1', - operation: 'fix' - }); - - expect(result.content[0].text).toBe(JSON.stringify({ success: false })); - }); - - it('should throw error when no project found', async () => { - mockCache.get.mockReturnValue(null); - - client.registerTools(registerToolsSpy, getInputFunctionSpy); - const toolHandler = registerToolsSpy.mock.calls - .find((call: any) => call[0].title === 'Update Error')[1]; - - await expect(toolHandler({ - errorId: 'error-1', - operation: 'fix' - })).rejects.toThrow('No current project found. Please provide a projectId or configure a project API key.'); - }); - - it('should throw error when project ID not found', async () => { - const mockOrg = { id: 'org-1', name: 'Org 1', slug: 'org-1' }; - - mockCache.get.mockReturnValueOnce(null).mockReturnValueOnce(mockOrg) - - client.registerTools(registerToolsSpy, getInputFunctionSpy); - const toolHandler = registerToolsSpy.mock.calls - .find((call: any) => call[0].title === 'Update Error')[1]; - - await expect(toolHandler({ - projectId: 'non-existent-project', - errorId: 'error-1', - operation: 'fix' - })).rejects.toThrow('Project with ID non-existent-project not found.'); - }); - }); - }); - - describe('resource handlers', () => { - let registerResourcesSpy: any; - - beforeEach(() => { - registerResourcesSpy = vi.fn(); - }); - - describe('bugsnag_event resource handler', () => { - it('should find event by ID across projects', async () => { - const mockEvent = { id: 'event-1', project_id: 'proj-1' }; - const mockProjects = [{ id: 'proj-1', name: 'Project 1' }]; - - mockCache.get.mockReturnValueOnce(mockProjects); - mockErrorAPI.viewEventById.mockResolvedValue({ body: mockEvent }); - - client.registerResources(registerResourcesSpy); - const resourceHandler = registerResourcesSpy.mock.calls[0][2]; - - const result = await resourceHandler( - { href: 'bugsnag://event/event-1' }, - { id: 'event-1' } - ); - - expect(result.contents[0].uri).toBe('bugsnag://event/event-1'); - expect(result.contents[0].text).toBe(JSON.stringify(mockEvent)); - }); - }); - }); }); diff --git a/src/tests/unit/bugsnag/get-build-tool.test.ts b/src/tests/unit/bugsnag/get-build-tool.test.ts new file mode 100644 index 00000000..3a547371 --- /dev/null +++ b/src/tests/unit/bugsnag/get-build-tool.test.ts @@ -0,0 +1,308 @@ +/** + * Unit tests for Get Build Tool + */ + +import { describe, it, expect, beforeEach, vi } from "vitest"; +import { GetBuildTool, GetBuildArgs } from "../../../bugsnag/tools/get-build-tool.js"; +import { ToolExecutionContext, SharedServices } from "../../../bugsnag/types.js"; +import { Project } from "../../../bugsnag/client/api/CurrentUser.js"; +import { BuildResponse, StabilityData } from "../../../bugsnag/client/api/Project.js"; + +// Mock data +const mockProject: Project = { + id: "project-123", + name: "Test Project", + slug: "test-project", + api_key: "api-key-123", + type: "web", + url: "https://example.com", + language: "javascript", + created_at: "2023-01-01T00:00:00Z", + updated_at: "2023-01-01T00:00:00Z", + errors_url: "https://api.bugsnag.com/projects/project-123/errors", + events_url: "https://api.bugsnag.com/projects/project-123/events" +}; + +const mockBuildWithStability: BuildResponse & StabilityData = { + id: "build-123", + version: "1.0.0", + bundle_version: "100", + release_stage: "production", + created_at: "2023-01-01T00:00:00Z", + accumulative_daily_users_seen: 1000, + accumulative_daily_users_with_unhandled: 50, + total_sessions_count: 5000, + unhandled_sessions_count: 100, + user_stability: 0.95, + session_stability: 0.98, + stability_target_type: "user", + target_stability: 0.95, + critical_stability: 0.90, + meets_target_stability: true, + meets_critical_stability: true, + // Additional BuildResponse fields + app_version: "1.0.0", + app_bundle_version: "100", + metadata: {}, + errors_introduced_count: 2, + errors_resolved_count: 5 +}; + +describe("GetBuildTool", () => { + let tool: GetBuildTool; + let mockServices: jest.Mocked; + let context: ToolExecutionContext; + + beforeEach(() => { + tool = new GetBuildTool(); + + mockServices = { + getInputProject: vi.fn(), + getBuild: vi.fn(), + getProjects: vi.fn(), + getProject: vi.fn(), + getCurrentProject: vi.fn(), + getCurrentUserApi: vi.fn(), + getErrorsApi: vi.fn(), + getProjectApi: vi.fn(), + getCache: vi.fn(), + getDashboardUrl: vi.fn(), + getErrorUrl: vi.fn(), + getProjectApiKey: vi.fn(), + hasProjectApiKey: vi.fn(), + getOrganization: vi.fn(), + getProjectEventFilters: vi.fn(), + getEvent: vi.fn(), + updateError: vi.fn(), + listBuilds: vi.fn(), + listReleases: vi.fn(), + getRelease: vi.fn(), + listBuildsInRelease: vi.fn(), + getProjectStabilityTargets: vi.fn(), + addStabilityData: vi.fn() + } as any; + + context = { + services: mockServices, + getInput: vi.fn() + }; + }); + + describe("Tool Definition", () => { + it("should have correct name", () => { + expect(tool.name).toBe("get_build"); + }); + + it("should have proper tool definition", () => { + expect(tool.definition.title).toBe("Get Build"); + expect(tool.definition.summary).toContain("Get more details for a specific build"); + expect(tool.definition.purpose).toContain("Retrieve detailed information about a build"); + expect(tool.definition.useCases).toHaveLength(4); + expect(tool.definition.examples).toHaveLength(1); + expect(tool.definition.hints).toHaveLength(4); + }); + + it("should have correct parameters", () => { + const paramNames = tool.definition.parameters.map(p => p.name); + expect(paramNames).toContain("buildId"); + }); + }); + + describe("execute", () => { + it("should get build details successfully", async () => { + const args: GetBuildArgs = { + projectId: "project-123", + buildId: "build-123" + }; + + mockServices.getInputProject.mockResolvedValue(mockProject); + mockServices.getBuild.mockResolvedValue(mockBuildWithStability); + + const result = await tool.execute(args, context); + + expect(mockServices.getInputProject).toHaveBeenCalledWith("project-123"); + expect(mockServices.getBuild).toHaveBeenCalledWith("project-123", "build-123"); + + const outerResult = JSON.parse(result.content[0].text); + const parsedResult = JSON.parse(outerResult.content[0].text); + expect(parsedResult.id).toBe("build-123"); + expect(parsedResult.version).toBe("1.0.0"); + expect(parsedResult.release_stage).toBe("production"); + }); + + it("should include stability data in response", async () => { + const args: GetBuildArgs = { + projectId: "project-123", + buildId: "build-123" + }; + + mockServices.getInputProject.mockResolvedValue(mockProject); + mockServices.getBuild.mockResolvedValue(mockBuildWithStability); + + const result = await tool.execute(args, context); + const outerResult = JSON.parse(result.content[0].text); + const parsedResult = JSON.parse(outerResult.content[0].text); + + expect(parsedResult).toHaveProperty("user_stability", 0.95); + expect(parsedResult).toHaveProperty("session_stability", 0.98); + expect(parsedResult).toHaveProperty("stability_target_type", "user"); + expect(parsedResult).toHaveProperty("target_stability", 0.95); + expect(parsedResult).toHaveProperty("critical_stability", 0.90); + expect(parsedResult).toHaveProperty("meets_target_stability", true); + expect(parsedResult).toHaveProperty("meets_critical_stability", true); + }); + + it("should handle missing buildId parameter", async () => { + const args: GetBuildArgs = { + projectId: "project-123", + buildId: "" + }; + + const result = await tool.execute(args, context); + + expect(result.isError).toBe(true); + expect(result.content[0].text).toContain("Build ID cannot be empty"); + }); + + it("should handle project not found error", async () => { + const args: GetBuildArgs = { + projectId: "invalid-project", + buildId: "build-123" + }; + + mockServices.getInputProject.mockRejectedValue(new Error("Project with ID invalid-project not found.")); + + const result = await tool.execute(args, context); + + expect(result.isError).toBe(true); + expect(result.content[0].text).toContain("Project with ID invalid-project not found"); + }); + + it("should handle build not found error", async () => { + const args: GetBuildArgs = { + projectId: "project-123", + buildId: "invalid-build" + }; + + mockServices.getInputProject.mockResolvedValue(mockProject); + mockServices.getBuild.mockRejectedValue(new Error("No build for invalid-build found.")); + + const result = await tool.execute(args, context); + + expect(result.isError).toBe(true); + expect(result.content[0].text).toContain("No build for invalid-build found"); + }); + + it("should handle API errors gracefully", async () => { + const args: GetBuildArgs = { + projectId: "project-123", + buildId: "build-123" + }; + + mockServices.getInputProject.mockResolvedValue(mockProject); + mockServices.getBuild.mockRejectedValue(new Error("API Error: 500 Internal Server Error")); + + const result = await tool.execute(args, context); + + expect(result.isError).toBe(true); + expect(result.content[0].text).toContain("Tool execution failed"); + }); + + it("should validate required parameters", async () => { + const args = {}; // Missing required parameters + + const result = await tool.execute(args, context); + + expect(result.isError).toBe(true); + expect(result.content[0].text).toContain("Required parameter"); + }); + + it("should work without projectId when project API key is configured", async () => { + // First update the tool to not require projectId + tool.updateParametersForProjectApiKey(true); + + const args: GetBuildArgs = { + buildId: "build-123" + }; + + mockServices.getInputProject.mockResolvedValue(mockProject); + mockServices.getBuild.mockResolvedValue(mockBuildWithStability); + + const result = await tool.execute(args, context); + + expect(mockServices.getInputProject).toHaveBeenCalledWith(undefined); + expect(mockServices.getBuild).toHaveBeenCalledWith("project-123", "build-123"); + expect(result.isError).toBeFalsy(); + }); + }); + + describe("updateParametersForProjectApiKey", () => { + it("should include projectId parameter when no API key is configured", () => { + tool.updateParametersForProjectApiKey(false); + + const paramNames = tool.definition.parameters.map(p => p.name); + expect(paramNames).toContain("projectId"); + expect(paramNames).toContain("buildId"); + }); + + it("should exclude projectId parameter when API key is configured", () => { + tool.updateParametersForProjectApiKey(true); + + const paramNames = tool.definition.parameters.map(p => p.name); + expect(paramNames).not.toContain("projectId"); + expect(paramNames).toContain("buildId"); + }); + }); + + describe("Caching Behavior", () => { + it("should leverage caching through services", async () => { + const args: GetBuildArgs = { + projectId: "project-123", + buildId: "build-123" + }; + + mockServices.getInputProject.mockResolvedValue(mockProject); + mockServices.getBuild.mockResolvedValue(mockBuildWithStability); + + // Call twice to test caching behavior + await tool.execute(args, context); + await tool.execute(args, context); + + // The service should handle caching internally + expect(mockServices.getBuild).toHaveBeenCalledTimes(2); + expect(mockServices.getBuild).toHaveBeenCalledWith("project-123", "build-123"); + }); + }); + + describe("Error Handling", () => { + it("should handle network errors", async () => { + const args: GetBuildArgs = { + projectId: "project-123", + buildId: "build-123" + }; + + mockServices.getInputProject.mockResolvedValue(mockProject); + mockServices.getBuild.mockRejectedValue(new Error("Network error")); + + const result = await tool.execute(args, context); + + expect(result.isError).toBe(true); + expect(result.content[0].text).toContain("Tool execution failed"); + }); + + it("should handle timeout errors", async () => { + const args: GetBuildArgs = { + projectId: "project-123", + buildId: "build-123" + }; + + mockServices.getInputProject.mockResolvedValue(mockProject); + mockServices.getBuild.mockRejectedValue(new Error("Request timeout")); + + const result = await tool.execute(args, context); + + expect(result.isError).toBe(true); + expect(result.content[0].text).toContain("Request timeout"); + }); + }); +}); diff --git a/src/tests/unit/bugsnag/get-error-tool.test.ts b/src/tests/unit/bugsnag/get-error-tool.test.ts new file mode 100644 index 00000000..4f25451f --- /dev/null +++ b/src/tests/unit/bugsnag/get-error-tool.test.ts @@ -0,0 +1,498 @@ +/** + * Unit tests for GetErrorTool + */ + +import { describe, it, expect, beforeEach, vi } from "vitest"; +import { GetErrorTool } from "../../../bugsnag/tools/get-error-tool.js"; +import { ToolExecutionContext, SharedServices } from "../../../bugsnag/types.js"; +import { Project } from "../../../bugsnag/client/api/CurrentUser.js"; +import { ErrorAPI } from "../../../bugsnag/client/index.js"; + +// Mock the shared services +function createMockServices(): jest.Mocked { + return { + getProjects: vi.fn(), + getProject: vi.fn(), + getCurrentProject: vi.fn(), + getInputProject: vi.fn(), + getCurrentUserApi: vi.fn(), + getErrorsApi: vi.fn(), + getProjectApi: vi.fn(), + getCache: vi.fn(), + getDashboardUrl: vi.fn(), + getErrorUrl: vi.fn(), + getProjectApiKey: vi.fn(), + hasProjectApiKey: vi.fn(), + getOrganization: vi.fn(), + getProjectEventFilters: vi.fn(), + getEvent: vi.fn(), + updateError: vi.fn(), + listBuilds: vi.fn(), + getBuild: vi.fn(), + listReleases: vi.fn(), + getRelease: vi.fn(), + listBuildsInRelease: vi.fn(), + getProjectStabilityTargets: vi.fn(), + addStabilityData: vi.fn(), + } as jest.Mocked; +} + +// Mock the ErrorAPI +function createMockErrorsApi(): jest.Mocked { + return { + viewErrorOnProject: vi.fn(), + listEventsOnProject: vi.fn(), + listErrorPivots: vi.fn(), + updateErrorOnProject: vi.fn(), + listErrorsOnProject: vi.fn(), + } as jest.Mocked; +} + +describe("GetErrorTool", () => { + let tool: GetErrorTool; + let mockServices: jest.Mocked; + let mockErrorsApi: jest.Mocked; + let context: ToolExecutionContext; + + const mockProject: Project = { + id: "proj-123", + name: "Test Project", + slug: "test-project", + api_key: "api-key-123", + type: "project", + url: "https://api.bugsnag.com/projects/proj-123", + html_url: "https://app.bugsnag.com/test-org/test-project", + created_at: "2023-01-01T00:00:00Z", + updated_at: "2023-01-01T00:00:00Z" + }; + + const mockErrorDetails = { + id: "error-123", + message: "Test error message", + status: "open", + first_seen: "2023-01-01T00:00:00Z", + last_seen: "2023-01-02T00:00:00Z", + events_count: 5, + users_count: 3 + }; + + const mockLatestEvent = { + id: "event-456", + timestamp: "2023-01-02T00:00:00Z", + message: "Test error message", + stacktrace: [ + { + file: "test.js", + line: 10, + method: "testFunction" + } + ], + user: { + id: "user-789", + email: "test@example.com" + } + }; + + const mockPivots = [ + { + field: "user.email", + values: [ + { value: "test@example.com", count: 3 }, + { value: "other@example.com", count: 2 } + ] + } + ]; + + beforeEach(() => { + mockServices = createMockServices(); + mockErrorsApi = createMockErrorsApi(); + + mockServices.getErrorsApi.mockReturnValue(mockErrorsApi); + mockServices.getInputProject.mockResolvedValue(mockProject); + mockServices.getErrorUrl.mockResolvedValue("https://app.bugsnag.com/test-org/test-project/errors/error-123"); + + context = { + services: mockServices, + getInput: vi.fn() + }; + + tool = new GetErrorTool(false); // No project API key + }); + + describe("constructor", () => { + it("should create tool with projectId parameter when no project API key", () => { + const toolWithoutApiKey = new GetErrorTool(false); + expect(toolWithoutApiKey.definition.parameters).toHaveLength(3); + expect(toolWithoutApiKey.definition.parameters[0].name).toBe("projectId"); + expect(toolWithoutApiKey.definition.parameters[0].required).toBe(true); + }); + + it("should create tool without projectId parameter when project API key exists", () => { + const toolWithApiKey = new GetErrorTool(true); + expect(toolWithApiKey.definition.parameters).toHaveLength(2); + expect(toolWithApiKey.definition.parameters.find(p => p.name === "projectId")).toBeUndefined(); + }); + }); + + describe("execute", () => { + it("should retrieve error details successfully", async () => { + // Setup mocks + mockErrorsApi.viewErrorOnProject.mockResolvedValue({ + body: mockErrorDetails, + status: 200, + headers: {} + }); + mockErrorsApi.listEventsOnProject.mockResolvedValue({ + body: [mockLatestEvent], + status: 200, + headers: {} + }); + mockErrorsApi.listErrorPivots.mockResolvedValue({ + body: mockPivots, + status: 200, + headers: {} + }); + + const args = { + projectId: "proj-123", + errorId: "error-123" + }; + + const result = await tool.execute(args, context); + + expect(result.isError).toBeFalsy(); + expect(result.content).toHaveLength(1); + expect(result.content[0].type).toBe("text"); + + const responseData = JSON.parse(result.content[0].text); + expect(responseData.error_details).toEqual(mockErrorDetails); + expect(responseData.latest_event).toEqual(mockLatestEvent); + expect(responseData.pivots).toEqual(mockPivots); + expect(responseData.url).toBe("https://app.bugsnag.com/test-org/test-project/errors/error-123"); + + // Verify API calls + expect(mockServices.getInputProject).toHaveBeenCalledWith("proj-123"); + expect(mockErrorsApi.viewErrorOnProject).toHaveBeenCalledWith("proj-123", "error-123"); + expect(mockErrorsApi.listEventsOnProject).toHaveBeenCalledWith( + "proj-123", + expect.stringContaining("sort=timestamp&direction=desc&per_page=1&full_reports=true") + ); + expect(mockErrorsApi.listErrorPivots).toHaveBeenCalledWith("proj-123", "error-123"); + expect(mockServices.getErrorUrl).toHaveBeenCalledWith( + mockProject, + "error-123", + expect.stringContaining("filters%5Berror%5D%5B%5D%5Btype%5D=eq") + ); + }); + + it("should handle missing errorId", async () => { + const args = { + projectId: "proj-123" + // errorId is missing + }; + + const result = await tool.execute(args, context); + + expect(result.isError).toBe(true); + expect(result.content[0].text).toContain("Required parameter 'errorId' is missing"); + }); + + it("should handle missing projectId when required", async () => { + const args = { + errorId: "error-123" + // projectId is missing + }; + + const result = await tool.execute(args, context); + + expect(result.isError).toBe(true); + expect(result.content[0].text).toContain("Required parameter 'projectId' is missing"); + }); + + it("should handle error not found", async () => { + mockErrorsApi.viewErrorOnProject.mockResolvedValue({ + body: null, + status: 404, + headers: {} + }); + + const args = { + projectId: "proj-123", + errorId: "nonexistent-error" + }; + + const result = await tool.execute(args, context); + + expect(result.isError).toBe(true); + expect(result.content[0].text).toContain("Error with ID nonexistent-error not found in project proj-123"); + }); + + it("should handle project not found", async () => { + mockServices.getInputProject.mockRejectedValue(new Error("Project with ID invalid-proj not found.")); + + const args = { + projectId: "invalid-proj", + errorId: "error-123" + }; + + const result = await tool.execute(args, context); + + expect(result.isError).toBe(true); + expect(result.content[0].text).toContain("Project with ID invalid-proj not found"); + }); + + it("should apply filters correctly", async () => { + mockErrorsApi.viewErrorOnProject.mockResolvedValue({ + body: mockErrorDetails, + status: 200, + headers: {} + }); + mockErrorsApi.listEventsOnProject.mockResolvedValue({ + body: [mockLatestEvent], + status: 200, + headers: {} + }); + mockErrorsApi.listErrorPivots.mockResolvedValue({ + body: mockPivots, + status: 200, + headers: {} + }); + + const args = { + projectId: "proj-123", + errorId: "error-123", + filters: { + "event.since": [{ type: "eq" as const, value: "7d" }], + "user.email": [{ type: "eq" as const, value: "test@example.com" }] + } + }; + + const result = await tool.execute(args, context); + + expect(result.isError).toBeFalsy(); + + // Verify that filters were applied to the events query + expect(mockErrorsApi.listEventsOnProject).toHaveBeenCalledWith( + "proj-123", + expect.stringContaining("filters%5Bevent.since%5D%5B%5D%5Btype%5D=eq") + ); + expect(mockErrorsApi.listEventsOnProject).toHaveBeenCalledWith( + "proj-123", + expect.stringContaining("filters%5Buser.email%5D%5B%5D%5Bvalue%5D=test%40example.com") + ); + + // Verify that filters were applied to the error URL + expect(mockServices.getErrorUrl).toHaveBeenCalledWith( + mockProject, + "error-123", + expect.stringContaining("filters%5Bevent.since%5D%5B%5D%5Btype%5D=eq") + ); + }); + + it("should handle latest event fetch failure gracefully", async () => { + mockErrorsApi.viewErrorOnProject.mockResolvedValue({ + body: mockErrorDetails, + status: 200, + headers: {} + }); + mockErrorsApi.listEventsOnProject.mockRejectedValue(new Error("API error")); + mockErrorsApi.listErrorPivots.mockResolvedValue({ + body: mockPivots, + status: 200, + headers: {} + }); + + const consoleSpy = vi.spyOn(console, "warn").mockImplementation(() => { }); + + const args = { + projectId: "proj-123", + errorId: "error-123" + }; + + const result = await tool.execute(args, context); + + expect(result.isError).toBeFalsy(); + + const responseData = JSON.parse(result.content[0].text); + expect(responseData.error_details).toEqual(mockErrorDetails); + expect(responseData.latest_event).toBeNull(); + expect(responseData.pivots).toEqual(mockPivots); + + expect(consoleSpy).toHaveBeenCalledWith("Failed to fetch latest event:", expect.any(Error)); + + consoleSpy.mockRestore(); + }); + + it("should handle pivots fetch failure gracefully", async () => { + mockErrorsApi.viewErrorOnProject.mockResolvedValue({ + body: mockErrorDetails, + status: 200, + headers: {} + }); + mockErrorsApi.listEventsOnProject.mockResolvedValue({ + body: [mockLatestEvent], + status: 200, + headers: {} + }); + mockErrorsApi.listErrorPivots.mockRejectedValue(new Error("API error")); + + const consoleSpy = vi.spyOn(console, "warn").mockImplementation(() => { }); + + const args = { + projectId: "proj-123", + errorId: "error-123" + }; + + const result = await tool.execute(args, context); + + expect(result.isError).toBeFalsy(); + + const responseData = JSON.parse(result.content[0].text); + expect(responseData.error_details).toEqual(mockErrorDetails); + expect(responseData.latest_event).toEqual(mockLatestEvent); + expect(responseData.pivots).toEqual([]); + + expect(consoleSpy).toHaveBeenCalledWith("Failed to fetch error pivots:", expect.any(Error)); + + consoleSpy.mockRestore(); + }); + + it("should work without projectId when project API key is configured", async () => { + const toolWithApiKey = new GetErrorTool(true); + + mockErrorsApi.viewErrorOnProject.mockResolvedValue({ + body: mockErrorDetails, + status: 200, + headers: {} + }); + mockErrorsApi.listEventsOnProject.mockResolvedValue({ + body: [mockLatestEvent], + status: 200, + headers: {} + }); + mockErrorsApi.listErrorPivots.mockResolvedValue({ + body: mockPivots, + status: 200, + headers: {} + }); + + const args = { + errorId: "error-123" + // No projectId needed when API key is configured + }; + + const result = await toolWithApiKey.execute(args, context); + + expect(result.isError).toBeFalsy(); + expect(mockServices.getInputProject).toHaveBeenCalledWith(undefined); + }); + + it("should validate invalid filter format", async () => { + const args = { + projectId: "proj-123", + errorId: "error-123", + filters: { + "invalid.filter": [{ type: "invalid" as any, value: "test" }] + } + }; + + const result = await tool.execute(args, context); + + expect(result.isError).toBe(true); + expect(result.content[0].text).toContain("Invalid value for parameter 'filters'"); + }); + + it("should handle empty events response", async () => { + mockErrorsApi.viewErrorOnProject.mockResolvedValue({ + body: mockErrorDetails, + status: 200, + headers: {} + }); + mockErrorsApi.listEventsOnProject.mockResolvedValue({ + body: [], + status: 200, + headers: {} + }); + mockErrorsApi.listErrorPivots.mockResolvedValue({ + body: mockPivots, + status: 200, + headers: {} + }); + + const args = { + projectId: "proj-123", + errorId: "error-123" + }; + + const result = await tool.execute(args, context); + + expect(result.isError).toBeFalsy(); + + const responseData = JSON.parse(result.content[0].text); + expect(responseData.latest_event).toBeNull(); + }); + + it("should handle null events response body", async () => { + mockErrorsApi.viewErrorOnProject.mockResolvedValue({ + body: mockErrorDetails, + status: 200, + headers: {} + }); + mockErrorsApi.listEventsOnProject.mockResolvedValue({ + body: null, + status: 200, + headers: {} + }); + mockErrorsApi.listErrorPivots.mockResolvedValue({ + body: mockPivots, + status: 200, + headers: {} + }); + + const args = { + projectId: "proj-123", + errorId: "error-123" + }; + + const result = await tool.execute(args, context); + + expect(result.isError).toBeFalsy(); + + const responseData = JSON.parse(result.content[0].text); + expect(responseData.latest_event).toBeNull(); + }); + }); + + describe("tool definition", () => { + it("should have correct tool name", () => { + expect(tool.name).toBe("get_error"); + }); + + it("should have correct title", () => { + expect(tool.definition.title).toBe("Get Error"); + }); + + it("should have appropriate use cases", () => { + expect(tool.definition.useCases).toContain("Investigate a specific error found through the List Project Errors tool"); + expect(tool.definition.useCases).toContain("Get error details for debugging and root cause analysis"); + }); + + it("should have examples", () => { + expect(tool.definition.examples).toHaveLength(2); + expect(tool.definition.examples[0].description).toContain("Get details for a specific error"); + expect(tool.definition.examples[1].description).toContain("Get error details with filters applied"); + }); + + it("should have helpful hints", () => { + expect(tool.definition.hints).toContain("Error IDs can be found using the List Project Errors tool"); + expect(tool.definition.hints).toContain("The URL provided in the response should be shown to the user in all cases as it allows them to view the error in the dashboard and perform further analysis"); + }); + + it("should have output format description", () => { + expect(tool.definition.outputFormat).toContain("error_details"); + expect(tool.definition.outputFormat).toContain("latest_event"); + expect(tool.definition.outputFormat).toContain("pivots"); + expect(tool.definition.outputFormat).toContain("url"); + }); + }); +}); diff --git a/src/tests/unit/bugsnag/get-event-details-tool-integration.test.ts b/src/tests/unit/bugsnag/get-event-details-tool-integration.test.ts new file mode 100644 index 00000000..47b9f89c --- /dev/null +++ b/src/tests/unit/bugsnag/get-event-details-tool-integration.test.ts @@ -0,0 +1,374 @@ +/** + * Integration tests for GetEventDetailsTool with ToolRegistry + */ + +import { describe, it, expect, beforeEach, vi } from "vitest"; +import { GetEventDetailsTool } from "../../../bugsnag/tools/get-event-details-tool.js"; +import { BugsnagToolRegistry } from "../../../bugsnag/tool-registry.js"; +import { SharedServices, ToolExecutionContext } from "../../../bugsnag/types.js"; +import { Project } from "../../../bugsnag/client/api/CurrentUser.js"; + +// Mock projects data +const mockProjects: Project[] = [ + { + id: "project1", + name: "Test Project 1", + slug: "test-project-1", + api_key: "api-key-1", + type: "web", + url: "https://example.com", + language: "javascript", + created_at: "2023-01-01T00:00:00Z", + updated_at: "2023-01-01T00:00:00Z", + errors_url: "https://api.bugsnag.com/projects/project1/errors", + events_url: "https://api.bugsnag.com/projects/project1/events", + html_url: "https://app.bugsnag.com/my-org/test-project-1", + release_stages: ["development", "production"], + collaborators_count: 5, + global_grouping: { + message: true, + stacktrace: true + }, + location_grouping: { + enabled: true, + nearest_code: true + }, + discarded_app_versions: [], + discarded_errors: [], + resolved_app_versions: [], + custom_event_fields_used: false, + open_error_count: 10, + for_review_error_count: 2 + } +]; + +// Mock event details +const mockEventDetails = { + id: "event-123", + project_id: "project1", + error_id: "error-456", + received_at: "2023-01-01T12:00:00Z", + exceptions: [ + { + errorClass: "TypeError", + message: "Cannot read property of undefined", + stacktrace: [ + { + file: "app.js", + lineNumber: 42, + columnNumber: 10, + method: "processData" + } + ] + } + ], + user: { + id: "user-789", + email: "user@example.com" + }, + context: "HomePage", + breadcrumbs: [ + { + timestamp: "2023-01-01T11:59:00Z", + message: "User clicked button", + type: "user" + } + ] +}; + +// Mock SharedServices +function createMockServices(): jest.Mocked { + return { + getProjects: vi.fn().mockResolvedValue(mockProjects), + getProject: vi.fn(), + getCurrentProject: vi.fn(), + getInputProject: vi.fn(), + getCurrentUserApi: vi.fn(), + getErrorsApi: vi.fn(), + getProjectApi: vi.fn(), + getCache: vi.fn(), + getDashboardUrl: vi.fn(), + getErrorUrl: vi.fn(), + getProjectApiKey: vi.fn(), + hasProjectApiKey: vi.fn(), + getOrganization: vi.fn(), + getProjectEventFilters: vi.fn(), + getEvent: vi.fn().mockResolvedValue(mockEventDetails), + updateError: vi.fn(), + listBuilds: vi.fn(), + getBuild: vi.fn(), + listReleases: vi.fn(), + getRelease: vi.fn(), + listBuildsInRelease: vi.fn(), + getProjectStabilityTargets: vi.fn(), + addStabilityData: vi.fn() + } as jest.Mocked; +} + +describe("GetEventDetailsTool Integration", () => { + let tool: GetEventDetailsTool; + let registry: BugsnagToolRegistry; + let mockServices: jest.Mocked; + let mockContext: ToolExecutionContext; + + beforeEach(() => { + tool = new GetEventDetailsTool(); + registry = new BugsnagToolRegistry(); + mockServices = createMockServices(); + mockContext = { + services: mockServices, + getInput: vi.fn() + }; + }); + + describe("tool registration", () => { + it("should register tool successfully", () => { + expect(() => registry.registerTool(tool)).not.toThrow(); + expect(registry.hasTool("get_event_details")).toBe(true); + expect(registry.getToolCount()).toBe(1); + }); + + it("should retrieve registered tool", () => { + registry.registerTool(tool); + const retrievedTool = registry.getTool("get_event_details"); + + expect(retrievedTool).toBeDefined(); + expect(retrievedTool?.name).toBe("get_event_details"); + expect(retrievedTool?.definition.title).toBe("Get Event Details"); + }); + + it("should prevent duplicate registration", () => { + registry.registerTool(tool); + + expect(() => registry.registerTool(tool)).toThrow( + "Tool with name 'get_event_details' is already registered" + ); + }); + + it("should include tool in discovered tools", () => { + registry.registerTool(tool); + const discoveredTools = registry.discoverTools(); + + // Auto-discovery finds all tools, so we should have multiple tools + expect(discoveredTools.length).toBeGreaterThan(0); + const getEventDetailsTool = discoveredTools.find(t => t.name === "get_event_details"); + expect(getEventDetailsTool).toBeDefined(); + }); + }); + + describe("tool execution through registry", () => { + beforeEach(() => { + registry.registerTool(tool); + }); + + it("should execute tool through registry with valid URL", async () => { + const registeredTool = registry.getTool("get_event_details"); + expect(registeredTool).toBeDefined(); + + const validUrl = "https://app.bugsnag.com/my-org/test-project-1/errors/error-456?event_id=event-123"; + const result = await registeredTool!.execute({ link: validUrl }, mockContext); + + expect(result.content).toHaveLength(1); + expect(result.isError).toBeUndefined(); + const data = JSON.parse(result.content[0].text); + expect(data).toEqual(mockEventDetails); + expect(mockServices.getProjects).toHaveBeenCalledTimes(1); + expect(mockServices.getEvent).toHaveBeenCalledWith("event-123", "project1"); + }); + + it("should handle invalid URL through registry", async () => { + const registeredTool = registry.getTool("get_event_details"); + expect(registeredTool).toBeDefined(); + + const result = await registeredTool!.execute({ link: "invalid-url" }, mockContext); + + expect(result.isError).toBe(true); + expect(result.content[0].text).toContain("Invalid value for parameter"); + }); + + it("should handle missing project through registry", async () => { + const registeredTool = registry.getTool("get_event_details"); + expect(registeredTool).toBeDefined(); + + const urlWithUnknownProject = "https://app.bugsnag.com/my-org/unknown-project/errors/error-456?event_id=event-123"; + const result = await registeredTool!.execute({ link: urlWithUnknownProject }, mockContext); + + expect(result.isError).toBe(true); + expect(result.content[0].text).toContain("Project with the specified slug not found"); + }); + + it("should handle API errors through registry", async () => { + mockServices.getEvent.mockRejectedValue(new Error("API Error")); + + const registeredTool = registry.getTool("get_event_details"); + expect(registeredTool).toBeDefined(); + + const validUrl = "https://app.bugsnag.com/my-org/test-project-1/errors/error-456?event_id=event-123"; + const result = await registeredTool!.execute({ link: validUrl }, mockContext); + + expect(result.isError).toBe(true); + expect(result.content[0].text).toContain("Tool execution failed"); + }); + }); + + describe("MCP server registration", () => { + it("should register with MCP server format", () => { + registry.registerTool(tool); + + const mockRegister = vi.fn(); + registry.registerAllTools(mockRegister, mockContext); + + // Auto-discovery registers all tools, so we should have multiple registrations + expect(mockRegister).toHaveBeenCalled(); + + // Find the get_event_details tool registration + const getEventDetailsCall = mockRegister.mock.calls.find(call => + call[0].title === 'Get Event Details' + ); + expect(getEventDetailsCall).toBeDefined(); + + const [toolDefinition, executionFunction] = getEventDetailsCall; + + // Verify tool definition structure + expect(toolDefinition.title).toBe("Get Event Details"); + expect(toolDefinition.summary).toBe("Get detailed information about a specific event using its dashboard URL"); + expect(toolDefinition.purpose).toBe("Retrieve event details directly from a dashboard URL for quick debugging"); + expect(toolDefinition.useCases).toHaveLength(3); + expect(toolDefinition.parameters).toHaveLength(1); + expect(toolDefinition.examples).toHaveLength(1); + expect(toolDefinition.hints).toHaveLength(2); + + // Verify parameters + const paramNames = toolDefinition.parameters.map((p: any) => p.name); + expect(paramNames).toContain("link"); + + // Verify the link parameter is required + const linkParam = toolDefinition.parameters.find((p: any) => p.name === "link"); + expect(linkParam.required).toBe(true); + expect(linkParam.description).toContain("dashboard"); + + // Verify execution function is provided + expect(typeof executionFunction).toBe("function"); + }); + + it("should execute through MCP server registration", async () => { + registry.registerTool(tool); + + const mockRegister = vi.fn(); + registry.registerAllTools(mockRegister, mockContext); + + // Find the get_event_details tool registration + const getEventDetailsCall = mockRegister.mock.calls.find(call => + call[0].title === 'Get Event Details' + ); + const [, executionFunction] = getEventDetailsCall; + + const validUrl = "https://app.bugsnag.com/my-org/test-project-1/errors/error-456?event_id=event-123"; + const result = await executionFunction({ link: validUrl }); + + expect(result.content).toHaveLength(1); + expect(result.isError).toBeUndefined(); + const data = JSON.parse(result.content[0].text); + expect(data).toEqual(mockEventDetails); + }); + + it("should handle errors in MCP server execution", async () => { + mockServices.getEvent.mockRejectedValue(new Error("Service Error")); + registry.registerTool(tool); + + const mockRegister = vi.fn(); + registry.registerAllTools(mockRegister, mockContext); + + // Find the get_event_details tool registration + const getEventDetailsCall = mockRegister.mock.calls.find(call => + call[0].title === 'Get Event Details' + ); + const [, executionFunction] = getEventDetailsCall; + + const validUrl = "https://app.bugsnag.com/my-org/test-project-1/errors/error-456?event_id=event-123"; + const result = await executionFunction({ link: validUrl }); + + expect(result.isError).toBe(true); + expect(result.content[0].text).toContain("Service Error"); + }); + + it("should handle parameter validation errors in MCP server execution", async () => { + registry.registerTool(tool); + + const mockRegister = vi.fn(); + registry.registerAllTools(mockRegister, mockContext); + + // Find the get_event_details tool registration + const getEventDetailsCall = mockRegister.mock.calls.find(call => + call[0].title === 'Get Event Details' + ); + const [, executionFunction] = getEventDetailsCall; + + const result = await executionFunction({}); // Missing required link parameter + + expect(result.isError).toBe(true); + expect(result.content[0].text).toContain("Required parameter 'link' is missing"); + }); + }); + + describe("registry management with multiple tools", () => { + it("should work alongside other tools", () => { + // This would be expanded when other tools are available + registry.registerTool(tool); + expect(registry.getToolCount()).toBe(1); + + const allTools = registry.getAllTools(); + expect(allTools).toHaveLength(1); + expect(allTools[0].name).toBe("get_event_details"); + }); + + it("should clear tool from registry", () => { + registry.registerTool(tool); + expect(registry.hasTool("get_event_details")).toBe(true); + + registry.clear(); + expect(registry.hasTool("get_event_details")).toBe(false); + expect(registry.getToolCount()).toBe(0); + }); + }); + + describe("URL parsing integration", () => { + beforeEach(() => { + registry.registerTool(tool); + }); + + it("should handle different URL formats through registry", async () => { + const registeredTool = registry.getTool("get_event_details"); + expect(registeredTool).toBeDefined(); + + // Test with SmartBear Hub URL + const hubUrl = "https://app.bugsnag.smartbear.com/my-org/test-project-1/errors/error-456?event_id=event-123"; + const result = await registeredTool!.execute({ link: hubUrl }, mockContext); + + expect(result.isError).toBeUndefined(); + expect(mockServices.getEvent).toHaveBeenCalledWith("event-123", "project1"); + }); + + it("should handle URLs with additional parameters through registry", async () => { + const registeredTool = registry.getTool("get_event_details"); + expect(registeredTool).toBeDefined(); + + const urlWithExtraParams = "https://app.bugsnag.com/my-org/test-project-1/errors/error-456?event_id=event-123&tab=timeline&filter=user"; + const result = await registeredTool!.execute({ link: urlWithExtraParams }, mockContext); + + expect(result.isError).toBeUndefined(); + expect(mockServices.getEvent).toHaveBeenCalledWith("event-123", "project1"); + }); + + it("should handle encoded URLs through registry", async () => { + const registeredTool = registry.getTool("get_event_details"); + expect(registeredTool).toBeDefined(); + + const encodedUrl = "https://app.bugsnag.com/my-org/test-project-1/errors/error-456?event_id=event%2D123"; + const result = await registeredTool!.execute({ link: encodedUrl }, mockContext); + + expect(result.isError).toBeUndefined(); + expect(mockServices.getEvent).toHaveBeenCalledWith("event-123", "project1"); + }); + }); +}); diff --git a/src/tests/unit/bugsnag/get-event-details-tool.test.ts b/src/tests/unit/bugsnag/get-event-details-tool.test.ts new file mode 100644 index 00000000..59162216 --- /dev/null +++ b/src/tests/unit/bugsnag/get-event-details-tool.test.ts @@ -0,0 +1,347 @@ +/** + * Unit tests for GetEventDetailsTool + */ + +import { describe, it, expect, beforeEach, vi } from 'vitest'; +import { GetEventDetailsTool } from '../../../bugsnag/tools/get-event-details-tool.js'; +import { SharedServices, ToolExecutionContext } from '../../../bugsnag/types.js'; +import { Project } from '../../../bugsnag/client/api/CurrentUser.js'; + +// Mock the GetInputFunction type +const mockGetInput = vi.fn(); + +describe('GetEventDetailsTool', () => { + let tool: GetEventDetailsTool; + let mockServices: jest.Mocked; + let context: ToolExecutionContext; + + const mockProjects: Project[] = [ + { + id: 'proj-1', + slug: 'my-project', + name: 'My Project', + api_key: 'api-key-1', + type: 'project', + url: 'https://api.bugsnag.com/projects/proj-1', + html_url: 'https://app.bugsnag.com/my-org/my-project', + created_at: '2023-01-01T00:00:00Z', + updated_at: '2023-01-01T00:00:00Z', + language: 'javascript', + release_stages: ['development', 'production'], + collaborators_count: 5, + global_grouping: { + message: true, + stacktrace: true + }, + location_grouping: { + enabled: true, + nearest_code: true + }, + discarded_app_versions: [], + discarded_errors: [], + resolved_app_versions: [], + custom_event_fields_used: false, + open_error_count: 10, + for_review_error_count: 2, + errors_url: 'https://api.bugsnag.com/projects/proj-1/errors' + }, + { + id: 'proj-2', + slug: 'other-project', + name: 'Other Project', + api_key: 'api-key-2', + type: 'project', + url: 'https://api.bugsnag.com/projects/proj-2', + html_url: 'https://app.bugsnag.com/my-org/other-project', + created_at: '2023-01-01T00:00:00Z', + updated_at: '2023-01-01T00:00:00Z', + language: 'python', + release_stages: ['development', 'production'], + collaborators_count: 3, + global_grouping: { + message: true, + stacktrace: true + }, + location_grouping: { + enabled: true, + nearest_code: true + }, + discarded_app_versions: [], + discarded_errors: [], + resolved_app_versions: [], + custom_event_fields_used: false, + open_error_count: 5, + for_review_error_count: 1, + errors_url: 'https://api.bugsnag.com/projects/proj-2/errors' + } + ]; + + const mockEventDetails = { + id: 'event-123', + project_id: 'proj-1', + error_id: 'error-456', + received_at: '2023-01-01T12:00:00Z', + exceptions: [ + { + errorClass: 'TypeError', + message: 'Cannot read property of undefined', + stacktrace: [ + { + file: 'app.js', + lineNumber: 42, + columnNumber: 10, + method: 'processData' + } + ] + } + ], + user: { + id: 'user-789', + email: 'user@example.com' + }, + context: 'HomePage', + breadcrumbs: [ + { + timestamp: '2023-01-01T11:59:00Z', + message: 'User clicked button', + type: 'user' + } + ] + }; + + beforeEach(() => { + // Create mock services + mockServices = { + getProjects: vi.fn(), + getProject: vi.fn(), + getCurrentProject: vi.fn(), + getInputProject: vi.fn(), + getCurrentUserApi: vi.fn(), + getErrorsApi: vi.fn(), + getProjectApi: vi.fn(), + getCache: vi.fn(), + getDashboardUrl: vi.fn(), + getErrorUrl: vi.fn(), + getProjectApiKey: vi.fn(), + hasProjectApiKey: vi.fn(), + getOrganization: vi.fn(), + getProjectEventFilters: vi.fn(), + getEvent: vi.fn(), + updateError: vi.fn(), + listBuilds: vi.fn(), + getBuild: vi.fn(), + listReleases: vi.fn(), + getRelease: vi.fn(), + listBuildsInRelease: vi.fn(), + getProjectStabilityTargets: vi.fn(), + addStabilityData: vi.fn() + } as any; + + context = { + services: mockServices, + getInput: mockGetInput + }; + + tool = new GetEventDetailsTool(); + }); + + describe('constructor', () => { + it('should initialize with correct name and definition', () => { + expect(tool.name).toBe('get_event_details'); + expect(tool.definition.title).toBe('Get Event Details'); + expect(tool.definition.summary).toContain('dashboard URL'); + expect(tool.definition.parameters).toHaveLength(1); + expect(tool.definition.parameters[0].name).toBe('link'); + expect(tool.definition.parameters[0].required).toBe(true); + }); + + it('should have appropriate use cases and examples', () => { + expect(tool.definition.useCases).toContain('Get event details when given a dashboard URL from a user or notification'); + expect(tool.definition.examples).toHaveLength(1); + expect(tool.definition.examples[0].parameters.link).toContain('event_id='); + }); + }); + + describe('execute', () => { + it('should successfully retrieve event details from valid dashboard URL', async () => { + const validUrl = 'https://app.bugsnag.com/my-org/my-project/errors/error-456?event_id=event-123'; + + mockServices.getProjects.mockResolvedValue(mockProjects); + mockServices.getEvent.mockResolvedValue(mockEventDetails); + + const result = await tool.execute({ link: validUrl }, context); + + expect(mockServices.getProjects).toHaveBeenCalledOnce(); + expect(mockServices.getEvent).toHaveBeenCalledWith('event-123', 'proj-1'); + expect(result.content).toHaveLength(1); + expect(result.content[0].type).toBe('text'); + expect(JSON.parse(result.content[0].text)).toEqual(mockEventDetails); + expect(result.isError).toBeUndefined(); + }); + + it('should throw error when link argument is missing', async () => { + const result = await tool.execute({} as any, context); + + expect(result.isError).toBe(true); + expect(result.content[0].text).toContain('Required parameter \'link\' is missing'); + }); + + it('should throw error when link argument is empty', async () => { + const result = await tool.execute({ link: '' }, context); + + expect(result.isError).toBe(true); + expect(result.content[0].text).toContain('Invalid value for parameter'); + }); + + it('should throw error when URL is invalid', async () => { + const result = await tool.execute({ link: 'invalid-url' }, context); + + expect(result.isError).toBe(true); + expect(result.content[0].text).toContain('Invalid value for parameter \'link\''); + }); + + it('should throw error when URL is missing event_id parameter', async () => { + const urlWithoutEventId = 'https://app.bugsnag.com/my-org/my-project/errors/error-456'; + + const result = await tool.execute({ link: urlWithoutEventId }, context); + + expect(result.isError).toBe(true); + expect(result.content[0].text).toContain('event_id parameter not found in URL'); + }); + + it('should throw error when URL path is too short (missing project slug)', async () => { + const urlWithShortPath = 'https://app.bugsnag.com/my-org?event_id=event-123'; + + const result = await tool.execute({ link: urlWithShortPath }, context); + + expect(result.isError).toBe(true); + expect(result.content[0].text).toContain('URL path too short'); + }); + + it('should throw error when project with specified slug is not found', async () => { + const urlWithUnknownProject = 'https://app.bugsnag.com/my-org/unknown-project/errors/error-456?event_id=event-123'; + + mockServices.getProjects.mockResolvedValue(mockProjects); + + const result = await tool.execute({ link: urlWithUnknownProject }, context); + + expect(result.isError).toBe(true); + expect(result.content[0].text).toContain('Project with the specified slug not found'); + expect(mockServices.getProjects).toHaveBeenCalledOnce(); + }); + + it('should throw error when event is not found', async () => { + const validUrl = 'https://app.bugsnag.com/my-org/my-project/errors/error-456?event_id=nonexistent-event'; + + mockServices.getProjects.mockResolvedValue(mockProjects); + mockServices.getEvent.mockResolvedValue(null); + + const result = await tool.execute({ link: validUrl }, context); + + expect(result.isError).toBe(true); + expect(result.content[0].text).toContain('Event with ID nonexistent-event not found in project proj-1'); + expect(mockServices.getEvent).toHaveBeenCalledWith('nonexistent-event', 'proj-1'); + }); + + it('should handle API errors gracefully', async () => { + const validUrl = 'https://app.bugsnag.com/my-org/my-project/errors/error-456?event_id=event-123'; + + mockServices.getProjects.mockResolvedValue(mockProjects); + mockServices.getEvent.mockRejectedValue(new Error('API Error')); + + const result = await tool.execute({ link: validUrl }, context); + + expect(result.isError).toBe(true); + expect(result.content[0].text).toContain('Tool execution failed: API Error'); + }); + + it('should work with different URL formats (different domains)', async () => { + const hubUrl = 'https://app.bugsnag.smartbear.com/my-org/my-project/errors/error-456?event_id=event-123'; + + mockServices.getProjects.mockResolvedValue(mockProjects); + mockServices.getEvent.mockResolvedValue(mockEventDetails); + + const result = await tool.execute({ link: hubUrl }, context); + + expect(result.isError).toBeUndefined(); + expect(mockServices.getEvent).toHaveBeenCalledWith('event-123', 'proj-1'); + }); + + it('should handle URLs with additional query parameters', async () => { + const urlWithExtraParams = 'https://app.bugsnag.com/my-org/my-project/errors/error-456?event_id=event-123&tab=timeline&filter=user'; + + mockServices.getProjects.mockResolvedValue(mockProjects); + mockServices.getEvent.mockResolvedValue(mockEventDetails); + + const result = await tool.execute({ link: urlWithExtraParams }, context); + + expect(result.isError).toBeUndefined(); + expect(mockServices.getEvent).toHaveBeenCalledWith('event-123', 'proj-1'); + }); + + it('should handle URLs with fragments', async () => { + const urlWithFragment = 'https://app.bugsnag.com/my-org/my-project/errors/error-456?event_id=event-123#stacktrace'; + + mockServices.getProjects.mockResolvedValue(mockProjects); + mockServices.getEvent.mockResolvedValue(mockEventDetails); + + const result = await tool.execute({ link: urlWithFragment }, context); + + expect(result.isError).toBeUndefined(); + expect(mockServices.getEvent).toHaveBeenCalledWith('event-123', 'proj-1'); + }); + + it('should handle project slug with special characters', async () => { + const projectWithSpecialChars: Project = { + ...mockProjects[0], + id: 'proj-special', + slug: 'my-project-with-dashes_and_underscores' + }; + + const urlWithSpecialProject = 'https://app.bugsnag.com/my-org/my-project-with-dashes_and_underscores/errors/error-456?event_id=event-123'; + + mockServices.getProjects.mockResolvedValue([projectWithSpecialChars]); + mockServices.getEvent.mockResolvedValue(mockEventDetails); + + const result = await tool.execute({ link: urlWithSpecialProject }, context); + + expect(result.isError).toBeUndefined(); + expect(mockServices.getEvent).toHaveBeenCalledWith('event-123', 'proj-special'); + }); + }); + + describe('URL parsing edge cases', () => { + it('should handle URLs with encoded characters', async () => { + const encodedUrl = 'https://app.bugsnag.com/my-org/my-project/errors/error-456?event_id=event%2D123'; + + mockServices.getProjects.mockResolvedValue(mockProjects); + mockServices.getEvent.mockResolvedValue(mockEventDetails); + + const result = await tool.execute({ link: encodedUrl }, context); + + expect(result.isError).toBeUndefined(); + expect(mockServices.getEvent).toHaveBeenCalledWith('event-123', 'proj-1'); + }); + + it('should reject URLs with missing protocol', async () => { + const urlWithoutProtocol = 'app.bugsnag.com/my-org/my-project/errors/error-456?event_id=event-123'; + + const result = await tool.execute({ link: urlWithoutProtocol }, context); + + expect(result.isError).toBe(true); + expect(result.content[0].text).toContain('Invalid value for parameter \'link\''); + }); + + it('should handle URLs with port numbers', async () => { + const urlWithPort = 'https://app.bugsnag.com:443/my-org/my-project/errors/error-456?event_id=event-123'; + + mockServices.getProjects.mockResolvedValue(mockProjects); + mockServices.getEvent.mockResolvedValue(mockEventDetails); + + const result = await tool.execute({ link: urlWithPort }, context); + + expect(result.isError).toBeUndefined(); + expect(mockServices.getEvent).toHaveBeenCalledWith('event-123', 'proj-1'); + }); + }); +}); diff --git a/src/tests/unit/bugsnag/get-release-tool.test.ts b/src/tests/unit/bugsnag/get-release-tool.test.ts new file mode 100644 index 00000000..5b87e66b --- /dev/null +++ b/src/tests/unit/bugsnag/get-release-tool.test.ts @@ -0,0 +1,259 @@ +/** + * Unit tests for Get Release Tool + */ + +import { describe, it, expect, beforeEach, vi } from "vitest"; +import { GetReleaseTool, GetReleaseArgs } from "../../../bugsnag/tools/get-release-tool.js"; +import { ToolExecutionContext, SharedServices } from "../../../bugsnag/types.js"; + + +// Mock data +const mockProject = { + id: "proj-123", + name: "Test Project", + slug: "test-project" +}; + +const mockRelease = { + id: "release-123", + version: "1.2.0", + release_stage: "production", + created_at: "2023-01-01T00:00:00Z", + source_control: { + provider: "github", + repository: "test/repo", + revision: "abc123" + }, + stability: { + user_stability: 0.95, + session_stability: 0.98, + meets_targets: true + }, + error_count: 42, + build_count: 3 +}; + +describe("GetReleaseTool", () => { + let tool: GetReleaseTool; + let mockServices: jest.Mocked; + let context: ToolExecutionContext; + + beforeEach(() => { + // Create mock services + mockServices = { + getInputProject: vi.fn(), + getRelease: vi.fn(), + getProjects: vi.fn(), + getProject: vi.fn(), + getCurrentProject: vi.fn(), + getCurrentUserApi: vi.fn(), + getErrorsApi: vi.fn(), + getProjectApi: vi.fn(), + getCache: vi.fn(), + getDashboardUrl: vi.fn(), + getErrorUrl: vi.fn(), + getProjectApiKey: vi.fn(), + hasProjectApiKey: vi.fn(), + listProjectErrors: vi.fn(), + getError: vi.fn(), + updateError: vi.fn(), + getEventDetails: vi.fn(), + listProjectEventFilters: vi.fn(), + listBuilds: vi.fn(), + getBuild: vi.fn(), + listReleases: vi.fn(), + listBuildsInRelease: vi.fn(), + getProjectStabilityTargets: vi.fn(), + addStabilityData: vi.fn(), + } as any; + + context = { + services: mockServices, + getInput: vi.fn() + }; + + tool = new GetReleaseTool(); + }); + + describe("execute", () => { + it("should get release details successfully", async () => { + // Setup mocks + mockServices.getInputProject.mockResolvedValue(mockProject); + mockServices.getRelease.mockResolvedValue(mockRelease); + + const args: GetReleaseArgs = { + projectId: "proj-123", + releaseId: "release-123" + }; + + // Execute + const result = await tool.execute(args, context); + + // Verify + expect(result.isError).toBeFalsy(); + expect(result.content).toHaveLength(1); + expect(result.content[0].type).toBe("text"); + + const outerResult = JSON.parse(result.content[0].text); + const responseData = JSON.parse(outerResult.content[0].text); + expect(responseData).toEqual(mockRelease); + + // Verify service calls + expect(mockServices.getInputProject).toHaveBeenCalledWith("proj-123"); + expect(mockServices.getRelease).toHaveBeenCalledWith("proj-123", "release-123"); + }); + + it("should handle missing release gracefully", async () => { + // Setup mocks + mockServices.getInputProject.mockResolvedValue(mockProject); + mockServices.getRelease.mockRejectedValue(new Error("Release not found")); + + const args: GetReleaseArgs = { + projectId: "proj-123", + releaseId: "invalid-release" + }; + + // Execute + const result = await tool.execute(args, context); + + // Verify + expect(result.isError).toBe(true); + expect(result.content[0].text).toContain("Release not found"); + }); + + it("should handle project not found error", async () => { + // Setup mocks + mockServices.getInputProject.mockRejectedValue(new Error("Project not found")); + + const args: GetReleaseArgs = { + projectId: "invalid-project", + releaseId: "release-123" + }; + + // Execute + const result = await tool.execute(args, context); + + // Verify + expect(result.isError).toBe(true); + expect(result.content[0].text).toContain("Project not found"); + }); + + it("should handle API errors gracefully", async () => { + // Setup mocks + mockServices.getInputProject.mockResolvedValue(mockProject); + mockServices.getRelease.mockRejectedValue(new Error("API Error")); + + const args: GetReleaseArgs = { + projectId: "proj-123", + releaseId: "release-123" + }; + + // Execute + const result = await tool.execute(args, context); + + // Verify + expect(result.isError).toBe(true); + expect(result.content[0].text).toContain("API Error"); + }); + + it("should validate required parameters", async () => { + const args: GetReleaseArgs = { + projectId: "proj-123", + releaseId: "" // Invalid empty release ID + }; + + // Execute + const result = await tool.execute(args, context); + + // Verify + expect(result.isError).toBe(true); + expect(result.content[0].text).toContain("Release ID cannot be empty"); + }); + + it("should validate missing releaseId parameter", async () => { + const args: any = { + projectId: "proj-123" + // Missing releaseId + }; + + // Execute + const result = await tool.execute(args, context); + + // Verify + expect(result.isError).toBe(true); + expect(result.content[0].text).toContain("Required parameter"); + }); + + it("should validate missing projectId parameter", async () => { + const args: any = { + releaseId: "release-123" + // Missing projectId + }; + + // Execute + const result = await tool.execute(args, context); + + // Verify + expect(result.isError).toBe(true); + expect(result.content[0].text).toContain("Required parameter"); + }); + }); + + describe("updateParametersForProjectApiKey", () => { + it("should include projectId parameter when no project API key", () => { + tool.updateParametersForProjectApiKey(false); + + const projectIdParam = tool.definition.parameters.find(p => p.name === "projectId"); + expect(projectIdParam).toBeDefined(); + expect(projectIdParam?.required).toBe(true); + }); + + it("should exclude projectId parameter when project API key is configured", () => { + tool.updateParametersForProjectApiKey(true); + + const projectIdParam = tool.definition.parameters.find(p => p.name === "projectId"); + expect(projectIdParam).toBeUndefined(); + }); + + it("should always include releaseId parameter", () => { + tool.updateParametersForProjectApiKey(false); + let releaseIdParam = tool.definition.parameters.find(p => p.name === "releaseId"); + expect(releaseIdParam).toBeDefined(); + expect(releaseIdParam?.required).toBe(true); + + tool.updateParametersForProjectApiKey(true); + releaseIdParam = tool.definition.parameters.find(p => p.name === "releaseId"); + expect(releaseIdParam).toBeDefined(); + expect(releaseIdParam?.required).toBe(true); + }); + }); + + describe("tool definition", () => { + it("should have correct tool name", () => { + expect(tool.name).toBe("get_release"); + }); + + it("should have proper definition structure", () => { + expect(tool.definition.title).toBe("Get Release"); + expect(tool.definition.summary).toContain("Get more details for a specific release"); + expect(tool.definition.purpose).toContain("Retrieve detailed information about a release"); + expect(tool.definition.useCases).toHaveLength(4); + expect(tool.definition.examples).toHaveLength(1); + expect(tool.definition.hints).toHaveLength(4); + expect(tool.definition.outputFormat).toContain("JSON object containing release details"); + }); + + it("should have required parameters defined", () => { + const paramNames = tool.definition.parameters.map(p => p.name); + expect(paramNames).toContain("releaseId"); + }); + + it("should have proper example structure", () => { + const example = tool.definition.examples[0]; + expect(example.description).toBe("Get details for a specific release"); + expect(example.parameters).toHaveProperty("projectId"); + expect(example.parameters).toHaveProperty("releaseId"); + expect(example.expectedOutput).toContain("JSON object with release details"); + }); + }); +}); diff --git a/src/tests/unit/bugsnag/list-builds-in-release-tool.test.ts b/src/tests/unit/bugsnag/list-builds-in-release-tool.test.ts new file mode 100644 index 00000000..87112eca --- /dev/null +++ b/src/tests/unit/bugsnag/list-builds-in-release-tool.test.ts @@ -0,0 +1,326 @@ +/** + * Unit tests for List Builds in Release Tool + */ + +import { describe, it, expect, beforeEach, vi } from "vitest"; +import { ListBuildsInReleaseTool, ListBuildsInReleaseArgs } from "../../../bugsnag/tools/list-builds-in-release-tool.js"; +import { ToolExecutionContext, SharedServices } from "../../../bugsnag/types.js"; +import { BuildResponse } from "../../../bugsnag/client/api/Project.js"; + +// Mock data +const mockBuilds: BuildResponse[] = [ + { + id: "build-123", + version: "1.0.0", + bundle_version: "100", + release_stage: "production", + created_at: "2023-01-01T00:00:00Z", + accumulative_daily_users_seen: 1000, + accumulative_daily_users_with_unhandled: 50, + total_sessions_count: 5000, + unhandled_sessions_count: 100, + app_version: "1.0.0", + app_bundle_version: "100", + metadata: {}, + errors_introduced_count: 2, + errors_resolved_count: 5 + }, + { + id: "build-456", + version: "1.0.1", + bundle_version: "101", + release_stage: "production", + created_at: "2023-01-02T00:00:00Z", + accumulative_daily_users_seen: 1200, + accumulative_daily_users_with_unhandled: 30, + total_sessions_count: 6000, + unhandled_sessions_count: 80, + app_version: "1.0.1", + app_bundle_version: "101", + metadata: {}, + errors_introduced_count: 1, + errors_resolved_count: 3 + } +]; + +describe("ListBuildsInReleaseTool", () => { + let tool: ListBuildsInReleaseTool; + let mockServices: jest.Mocked; + let context: ToolExecutionContext; + + beforeEach(() => { + tool = new ListBuildsInReleaseTool(); + + mockServices = { + listBuildsInRelease: vi.fn(), + getProjects: vi.fn(), + getProject: vi.fn(), + getCurrentProject: vi.fn(), + getInputProject: vi.fn(), + getCurrentUserApi: vi.fn(), + getErrorsApi: vi.fn(), + getProjectApi: vi.fn(), + getCache: vi.fn(), + getDashboardUrl: vi.fn(), + getErrorUrl: vi.fn(), + getProjectApiKey: vi.fn(), + hasProjectApiKey: vi.fn(), + getOrganization: vi.fn(), + getProjectEventFilters: vi.fn(), + getEvent: vi.fn(), + updateError: vi.fn(), + listBuilds: vi.fn(), + getBuild: vi.fn(), + listReleases: vi.fn(), + getRelease: vi.fn(), + getProjectStabilityTargets: vi.fn(), + addStabilityData: vi.fn() + } as any; + + context = { + services: mockServices, + getInput: vi.fn() + }; + }); + + describe("Tool Definition", () => { + it("should have correct name", () => { + expect(tool.name).toBe("list_builds_in_release"); + }); + + it("should have proper tool definition", () => { + expect(tool.definition.title).toBe("List Builds in Release"); + expect(tool.definition.summary).toContain("List builds associated with a specific release"); + expect(tool.definition.purpose).toContain("Retrieve a list of builds for a given release"); + expect(tool.definition.useCases).toHaveLength(4); + expect(tool.definition.examples).toHaveLength(1); + expect(tool.definition.hints).toHaveLength(4); + }); + + it("should have correct parameters", () => { + const paramNames = tool.definition.parameters.map(p => p.name); + expect(paramNames).toContain("releaseId"); + expect(paramNames).toHaveLength(1); + }); + }); + + describe("execute", () => { + it("should list builds in release successfully", async () => { + const args: ListBuildsInReleaseArgs = { + releaseId: "release-123" + }; + + mockServices.listBuildsInRelease.mockResolvedValue(mockBuilds); + + const result = await tool.execute(args, context); + + expect(mockServices.listBuildsInRelease).toHaveBeenCalledWith("release-123"); + + const outerResult = JSON.parse(result.content[0].text); + const parsedResult = JSON.parse(outerResult.content[0].text); + expect(parsedResult.data).toHaveLength(2); + expect(parsedResult.count).toBe(2); + expect(parsedResult.data[0].id).toBe("build-123"); + expect(parsedResult.data[1].id).toBe("build-456"); + }); + + it("should handle empty builds list", async () => { + const args: ListBuildsInReleaseArgs = { + releaseId: "release-empty" + }; + + mockServices.listBuildsInRelease.mockResolvedValue([]); + + const result = await tool.execute(args, context); + + const outerResult = JSON.parse(result.content[0].text); + const parsedResult = JSON.parse(outerResult.content[0].text); + expect(parsedResult.data).toHaveLength(0); + expect(parsedResult.count).toBe(0); + }); + + it("should handle missing releaseId parameter", async () => { + const args: ListBuildsInReleaseArgs = { + releaseId: "" + }; + + const result = await tool.execute(args, context); + + expect(result.isError).toBe(true); + expect(result.content[0].text).toContain("Release ID cannot be empty"); + }); + + it("should handle release not found error (404)", async () => { + const args: ListBuildsInReleaseArgs = { + releaseId: "invalid-release" + }; + + mockServices.listBuildsInRelease.mockRejectedValue(new Error("404 Not Found")); + + const result = await tool.execute(args, context); + + expect(result.isError).toBe(true); + expect(result.content[0].text).toContain("Release with ID invalid-release not found"); + }); + + it("should handle release not found error (not found message)", async () => { + const args: ListBuildsInReleaseArgs = { + releaseId: "invalid-release" + }; + + mockServices.listBuildsInRelease.mockRejectedValue(new Error("Release not found")); + + const result = await tool.execute(args, context); + + expect(result.isError).toBe(true); + expect(result.content[0].text).toContain("Release with ID invalid-release not found"); + }); + + it("should handle access denied error (403)", async () => { + const args: ListBuildsInReleaseArgs = { + releaseId: "restricted-release" + }; + + mockServices.listBuildsInRelease.mockRejectedValue(new Error("403 Forbidden")); + + const result = await tool.execute(args, context); + + expect(result.isError).toBe(true); + expect(result.content[0].text).toContain("Access denied to release restricted-release"); + }); + + it("should handle unauthorized error", async () => { + const args: ListBuildsInReleaseArgs = { + releaseId: "restricted-release" + }; + + mockServices.listBuildsInRelease.mockRejectedValue(new Error("unauthorized access")); + + const result = await tool.execute(args, context); + + expect(result.isError).toBe(true); + expect(result.content[0].text).toContain("Access denied to release restricted-release"); + }); + + it("should handle other API errors", async () => { + const args: ListBuildsInReleaseArgs = { + releaseId: "release-123" + }; + + mockServices.listBuildsInRelease.mockRejectedValue(new Error("500 Internal Server Error")); + + const result = await tool.execute(args, context); + + expect(result.isError).toBe(true); + expect(result.content[0].text).toContain("500 Internal Server Error"); + }); + + it("should validate required parameters", async () => { + const args = {}; // Missing releaseId + + const result = await tool.execute(args, context); + + expect(result.isError).toBe(true); + expect(result.content[0].text).toContain("Required parameter"); + }); + + it("should handle network errors", async () => { + const args: ListBuildsInReleaseArgs = { + releaseId: "release-123" + }; + + mockServices.listBuildsInRelease.mockRejectedValue(new Error("Network error")); + + const result = await tool.execute(args, context); + + expect(result.isError).toBe(true); + expect(result.content[0].text).toContain("Network error"); + }); + }); + + describe("Caching Behavior", () => { + it("should leverage caching through services", async () => { + const args: ListBuildsInReleaseArgs = { + releaseId: "release-123" + }; + + mockServices.listBuildsInRelease.mockResolvedValue(mockBuilds); + + // Call twice to test caching behavior + await tool.execute(args, context); + await tool.execute(args, context); + + // The service should handle caching internally + expect(mockServices.listBuildsInRelease).toHaveBeenCalledTimes(2); + expect(mockServices.listBuildsInRelease).toHaveBeenCalledWith("release-123"); + }); + }); + + describe("Error Message Specificity", () => { + it("should provide specific error message for 404 errors", async () => { + const args: ListBuildsInReleaseArgs = { + releaseId: "missing-release" + }; + + mockServices.listBuildsInRelease.mockRejectedValue(new Error("404")); + + const result = await tool.execute(args, context); + + expect(result.isError).toBe(true); + expect(result.content[0].text).toContain("Release with ID missing-release not found"); + expect(result.content[0].text).toContain("Please verify the release ID is correct"); + }); + + it("should provide specific error message for 403 errors", async () => { + const args: ListBuildsInReleaseArgs = { + releaseId: "forbidden-release" + }; + + mockServices.listBuildsInRelease.mockRejectedValue(new Error("403")); + + const result = await tool.execute(args, context); + + expect(result.isError).toBe(true); + expect(result.content[0].text).toContain("Access denied to release forbidden-release"); + expect(result.content[0].text).toContain("Please check your permissions"); + }); + }); + + describe("Data Format", () => { + it("should return builds with all expected fields", async () => { + const args: ListBuildsInReleaseArgs = { + releaseId: "release-123" + }; + + mockServices.listBuildsInRelease.mockResolvedValue(mockBuilds); + + const result = await tool.execute(args, context); + const outerResult = JSON.parse(result.content[0].text); + const parsedResult = JSON.parse(outerResult.content[0].text); + + const firstBuild = parsedResult.data[0]; + expect(firstBuild).toHaveProperty("id"); + expect(firstBuild).toHaveProperty("version"); + expect(firstBuild).toHaveProperty("bundle_version"); + expect(firstBuild).toHaveProperty("release_stage"); + expect(firstBuild).toHaveProperty("created_at"); + expect(firstBuild).toHaveProperty("accumulative_daily_users_seen"); + expect(firstBuild).toHaveProperty("total_sessions_count"); + }); + + it("should maintain build order from API response", async () => { + const args: ListBuildsInReleaseArgs = { + releaseId: "release-123" + }; + + mockServices.listBuildsInRelease.mockResolvedValue(mockBuilds); + + const result = await tool.execute(args, context); + const outerResult = JSON.parse(result.content[0].text); + const parsedResult = JSON.parse(outerResult.content[0].text); + + expect(parsedResult.data[0].id).toBe("build-123"); + expect(parsedResult.data[1].id).toBe("build-456"); + }); + }); +}); diff --git a/src/tests/unit/bugsnag/list-builds-tool.test.ts b/src/tests/unit/bugsnag/list-builds-tool.test.ts new file mode 100644 index 00000000..ee67f2b8 --- /dev/null +++ b/src/tests/unit/bugsnag/list-builds-tool.test.ts @@ -0,0 +1,273 @@ +/** + * Unit tests for List Builds Tool + */ + +import { describe, it, expect, beforeEach, vi } from "vitest"; +import { ListBuildsTool, ListBuildsArgs } from "../../../bugsnag/tools/list-builds-tool.js"; +import { ToolExecutionContext, SharedServices } from "../../../bugsnag/types.js"; +import { Project } from "../../../bugsnag/client/api/CurrentUser.js"; +import { BuildSummaryResponse, StabilityData } from "../../../bugsnag/client/api/Project.js"; + +// Mock data +const mockProject: Project = { + id: "project-123", + name: "Test Project", + slug: "test-project", + api_key: "api-key-123", + type: "web", + url: "https://example.com", + language: "javascript", + created_at: "2023-01-01T00:00:00Z", + updated_at: "2023-01-01T00:00:00Z", + errors_url: "https://api.bugsnag.com/projects/project-123/errors", + events_url: "https://api.bugsnag.com/projects/project-123/events" +}; + +const mockBuildWithStability: BuildSummaryResponse & StabilityData = { + id: "build-123", + version: "1.0.0", + bundle_version: "100", + release_stage: "production", + created_at: "2023-01-01T00:00:00Z", + accumulative_daily_users_seen: 1000, + accumulative_daily_users_with_unhandled: 50, + total_sessions_count: 5000, + unhandled_sessions_count: 100, + user_stability: 0.95, + session_stability: 0.98, + stability_target_type: "user", + target_stability: 0.95, + critical_stability: 0.90, + meets_target_stability: true, + meets_critical_stability: true +}; + +const mockBuildsResponse = { + builds: [mockBuildWithStability], + nextUrl: "https://api.bugsnag.com/projects/project-123/builds?offset=30" +}; + +describe("ListBuildsTool", () => { + let tool: ListBuildsTool; + let mockServices: jest.Mocked; + let context: ToolExecutionContext; + + beforeEach(() => { + tool = new ListBuildsTool(); + + mockServices = { + getInputProject: vi.fn(), + listBuilds: vi.fn(), + getProjects: vi.fn(), + getProject: vi.fn(), + getCurrentProject: vi.fn(), + getCurrentUserApi: vi.fn(), + getErrorsApi: vi.fn(), + getProjectApi: vi.fn(), + getCache: vi.fn(), + getDashboardUrl: vi.fn(), + getErrorUrl: vi.fn(), + getProjectApiKey: vi.fn(), + hasProjectApiKey: vi.fn(), + getOrganization: vi.fn(), + getProjectEventFilters: vi.fn(), + getEvent: vi.fn(), + updateError: vi.fn(), + getBuild: vi.fn(), + listReleases: vi.fn(), + getRelease: vi.fn(), + listBuildsInRelease: vi.fn(), + getProjectStabilityTargets: vi.fn(), + addStabilityData: vi.fn() + } as any; + + context = { + services: mockServices, + getInput: vi.fn() + }; + }); + + describe("Tool Definition", () => { + it("should have correct name", () => { + expect(tool.name).toBe("list_builds"); + }); + + it("should have proper tool definition", () => { + expect(tool.definition.title).toBe("List Builds"); + expect(tool.definition.summary).toContain("List builds for a project"); + expect(tool.definition.purpose).toContain("Retrieve a list of build summaries"); + expect(tool.definition.useCases).toHaveLength(4); + expect(tool.definition.examples).toHaveLength(3); + expect(tool.definition.hints).toHaveLength(4); + }); + + it("should have correct parameters", () => { + const paramNames = tool.definition.parameters.map(p => p.name); + expect(paramNames).toContain("releaseStage"); + expect(paramNames).toContain("per_page"); + expect(paramNames).toContain("next"); + }); + }); + + describe("execute", () => { + it("should list builds successfully", async () => { + const args: ListBuildsArgs = { + projectId: "project-123" + }; + + mockServices.getInputProject.mockResolvedValue(mockProject); + mockServices.listBuilds.mockResolvedValue(mockBuildsResponse); + + const result = await tool.execute(args, context); + + expect(mockServices.getInputProject).toHaveBeenCalledWith("project-123"); + expect(mockServices.listBuilds).toHaveBeenCalledWith("project-123", {}); + const outerResult = JSON.parse(result.content[0].text); + const parsedResult = JSON.parse(outerResult.content[0].text); + expect(parsedResult).toHaveProperty("data"); + expect(parsedResult).toHaveProperty("count", 1); + expect(parsedResult).toHaveProperty("next"); + }); + + it("should filter builds by release stage", async () => { + const args: ListBuildsArgs = { + projectId: "project-123", + releaseStage: "production" + }; + + mockServices.getInputProject.mockResolvedValue(mockProject); + mockServices.listBuilds.mockResolvedValue(mockBuildsResponse); + + await tool.execute(args, context); + + expect(mockServices.listBuilds).toHaveBeenCalledWith("project-123", { + release_stage: "production" + }); + }); + + it("should handle pagination parameters", async () => { + const args: ListBuildsArgs = { + projectId: "project-123", + per_page: 50, + nextUrl: "https://api.bugsnag.com/projects/project-123/builds?offset=30" + }; + + mockServices.getInputProject.mockResolvedValue(mockProject); + mockServices.listBuilds.mockResolvedValue(mockBuildsResponse); + + await tool.execute(args, context); + + expect(mockServices.listBuilds).toHaveBeenCalledWith("project-123", { + per_page: 50, + next_url: "https://api.bugsnag.com/projects/project-123/builds?offset=30" + }); + }); + + it("should handle empty builds list", async () => { + const args: ListBuildsArgs = { + projectId: "project-123" + }; + + const emptyResponse = { builds: [], nextUrl: null }; + + mockServices.getInputProject.mockResolvedValue(mockProject); + mockServices.listBuilds.mockResolvedValue(emptyResponse); + + const result = await tool.execute(args, context); + + const outerResult = JSON.parse(result.content[0].text); + const parsedResult = JSON.parse(outerResult.content[0].text); + expect(parsedResult).toHaveProperty("data", []); + expect(parsedResult).toHaveProperty("count", 0); + }); + + it("should handle project not found error", async () => { + const args: ListBuildsArgs = { + projectId: "invalid-project" + }; + + mockServices.getInputProject.mockRejectedValue(new Error("Project with ID invalid-project not found.")); + + const result = await tool.execute(args, context); + + expect(result.isError).toBe(true); + expect(result.content[0].text).toContain("Project with ID invalid-project not found"); + }); + + it("should handle API errors gracefully", async () => { + const args: ListBuildsArgs = { + projectId: "project-123" + }; + + mockServices.getInputProject.mockResolvedValue(mockProject); + mockServices.listBuilds.mockRejectedValue(new Error("API Error: 500 Internal Server Error")); + + const result = await tool.execute(args, context); + + expect(result.isError).toBe(true); + expect(result.content[0].text).toContain("Tool execution failed"); + }); + + it("should validate required parameters", async () => { + const args = {}; // Missing projectId when required + + const result = await tool.execute(args, context); + + expect(result.isError).toBe(true); + expect(result.content[0].text).toContain("Required parameter"); + }); + }); + + describe("updateParametersForProjectApiKey", () => { + it("should include projectId parameter when no API key is configured", () => { + tool.updateParametersForProjectApiKey(false); + + const paramNames = tool.definition.parameters.map(p => p.name); + expect(paramNames).toContain("projectId"); + }); + + it("should exclude projectId parameter when API key is configured", () => { + tool.updateParametersForProjectApiKey(true); + + const paramNames = tool.definition.parameters.map(p => p.name); + expect(paramNames).not.toContain("projectId"); + }); + }); + + describe("Stability Data Integration", () => { + it("should return builds with stability metrics", async () => { + const args: ListBuildsArgs = { + projectId: "project-123" + }; + + mockServices.getInputProject.mockResolvedValue(mockProject); + mockServices.listBuilds.mockResolvedValue(mockBuildsResponse); + + const result = await tool.execute(args, context); + const outerResult = JSON.parse(result.content[0].text); + const parsedResult = JSON.parse(outerResult.content[0].text); + + expect(parsedResult.data[0]).toHaveProperty("user_stability"); + expect(parsedResult.data[0]).toHaveProperty("session_stability"); + expect(parsedResult.data[0]).toHaveProperty("meets_target_stability"); + expect(parsedResult.data[0]).toHaveProperty("meets_critical_stability"); + }); + + it("should include stability target information", async () => { + const args: ListBuildsArgs = { + projectId: "project-123" + }; + + mockServices.getInputProject.mockResolvedValue(mockProject); + mockServices.listBuilds.mockResolvedValue(mockBuildsResponse); + + const result = await tool.execute(args, context); + const outerResult = JSON.parse(result.content[0].text); + const parsedResult = JSON.parse(outerResult.content[0].text); + + expect(parsedResult.data[0]).toHaveProperty("stability_target_type", "user"); + expect(parsedResult.data[0]).toHaveProperty("target_stability", 0.95); + expect(parsedResult.data[0]).toHaveProperty("critical_stability", 0.90); + }); + }); +}); diff --git a/src/tests/unit/bugsnag/list-project-errors-tool.test.ts b/src/tests/unit/bugsnag/list-project-errors-tool.test.ts new file mode 100644 index 00000000..1a5a86c5 --- /dev/null +++ b/src/tests/unit/bugsnag/list-project-errors-tool.test.ts @@ -0,0 +1,80 @@ +/** + * Unit tests for List Project Errors Tool + */ + +import { describe, it, expect, beforeEach, vi } from 'vitest'; +import { ListProjectErrorsTool } from '../../../bugsnag/tools/list-project-errors-tool.js'; +import { SharedServices, ToolExecutionContext } from '../../../bugsnag/types.js'; + +// Mock the SharedServices +const createMockServices = (): jest.Mocked => ({ + getProjects: vi.fn(), + getProject: vi.fn(), + getCurrentProject: vi.fn(), + getInputProject: vi.fn(), + getCurrentUserApi: vi.fn(), + getErrorsApi: vi.fn(), + getProjectApi: vi.fn(), + getCache: vi.fn(), + getDashboardUrl: vi.fn(), + getErrorUrl: vi.fn(), + getProjectApiKey: vi.fn(), + hasProjectApiKey: vi.fn(), + getOrganization: vi.fn(), + getProjectEventFilters: vi.fn(), + getEvent: vi.fn(), + updateError: vi.fn(), + listBuilds: vi.fn(), + getBuild: vi.fn(), + listReleases: vi.fn(), + getRelease: vi.fn(), + listBuildsInRelease: vi.fn(), + getProjectStabilityTargets: vi.fn(), + addStabilityData: vi.fn() +}); + +describe('ListProjectErrorsTool', () => { + let tool: ListProjectErrorsTool; + let mockServices: jest.Mocked; + let context: ToolExecutionContext; + + beforeEach(() => { + mockServices = createMockServices(); + context = { + services: mockServices, + getInput: vi.fn() + }; + tool = new ListProjectErrorsTool(false); + }); + + describe('Tool Definition', () => { + it('should have correct name', () => { + expect(tool.name).toBe('list_project_errors'); + }); + + it('should have correct title', () => { + expect(tool.definition.title).toBe('List Project Errors'); + }); + + it('should include projectId parameter when no project API key is configured', () => { + const toolWithoutApiKey = new ListProjectErrorsTool(false); + const projectIdParam = toolWithoutApiKey.definition.parameters.find(p => p.name === 'projectId'); + expect(projectIdParam).toBeDefined(); + expect(projectIdParam?.required).toBe(true); + }); + + it('should not include projectId parameter when project API key is configured', () => { + const toolWithApiKey = new ListProjectErrorsTool(true); + const projectIdParam = toolWithApiKey.definition.parameters.find(p => p.name === 'projectId'); + expect(projectIdParam).toBeUndefined(); + }); + }); + + describe('Parameter Validation', () => { + it('should validate required projectId parameter', async () => { + const result = await tool.execute({}, context); + expect(result.isError).toBe(true); + expect(result.content[0].text).toContain('Required parameter \'projectId\' is missing'); + }); + }); +}); diff --git a/src/tests/unit/bugsnag/list-project-event-filters-tool.test.ts b/src/tests/unit/bugsnag/list-project-event-filters-tool.test.ts new file mode 100644 index 00000000..c4925a95 --- /dev/null +++ b/src/tests/unit/bugsnag/list-project-event-filters-tool.test.ts @@ -0,0 +1,582 @@ +/** + * Unit tests for ListProjectEventFiltersTool + */ + +import { describe, it, expect, beforeEach, vi } from "vitest"; +import { ListProjectEventFiltersTool } from "../../../bugsnag/tools/list-project-event-filters-tool.js"; +import { SharedServices, ToolExecutionContext, BugsnagToolError } from "../../../bugsnag/types.js"; +import { Project } from "../../../bugsnag/client/api/CurrentUser.js"; +import { EventField } from "../../../bugsnag/client/api/Project.js"; +import * as NodeCache from "node-cache"; + +// Mock event fields data +const mockEventFields: EventField[] = [ + { + display_id: "error.status", + custom: false, + filter_options: { + type: "enum", + values: ["open", "fixed", "ignored"] + }, + pivot_options: { + type: "enum", + values: ["open", "fixed", "ignored"] + } + }, + { + display_id: "user.email", + custom: false, + filter_options: { + type: "string" + }, + pivot_options: { + type: "string" + } + }, + { + display_id: "app.version", + custom: false, + filter_options: { + type: "string" + }, + pivot_options: { + type: "string" + } + }, + { + display_id: "custom.environment", + custom: true, + filter_options: { + type: "string" + }, + pivot_options: { + type: "string" + } + }, + { + display_id: "event.since", + custom: false, + filter_options: { + type: "datetime" + }, + pivot_options: null + } +]; + +// Mock project data +const mockProject: Project = { + id: "project1", + name: "Test Project", + slug: "test-project", + api_key: "api-key-1", + type: "web", + url: "https://example.com", + language: "javascript", + created_at: "2023-01-01T00:00:00Z", + updated_at: "2023-01-01T00:00:00Z", + errors_url: "https://api.bugsnag.com/projects/project1/errors", + events_url: "https://api.bugsnag.com/projects/project1/events" +}; + +// Mock NodeCache +function createMockCache(cachedFilters?: EventField[]): jest.Mocked { + return { + get: vi.fn().mockImplementation((key: string) => { + if (key === "bugsnag_current_project_event_filters") { + return cachedFilters; + } + return undefined; + }), + set: vi.fn(), + del: vi.fn(), + ttl: vi.fn(), + keys: vi.fn(), + has: vi.fn(), + take: vi.fn(), + mget: vi.fn(), + mset: vi.fn(), + mdel: vi.fn(), + flush: vi.fn(), + flushAll: vi.fn(), + close: vi.fn(), + getStats: vi.fn(), + on: vi.fn(), + once: vi.fn(), + removeListener: vi.fn(), + removeAllListeners: vi.fn(), + setMaxListeners: vi.fn(), + getMaxListeners: vi.fn(), + listeners: vi.fn(), + rawListeners: vi.fn(), + emit: vi.fn(), + eventNames: vi.fn(), + listenerCount: vi.fn(), + prependListener: vi.fn(), + prependOnceListener: vi.fn(), + off: vi.fn() + } as jest.Mocked; +} + +// Mock SharedServices +function createMockServices( + cachedFilters?: EventField[], + currentProject: Project | null = mockProject +): jest.Mocked { + const mockCache = createMockCache(cachedFilters); + + return { + getProjects: vi.fn(), + getProject: vi.fn(), + getCurrentProject: vi.fn().mockResolvedValue(currentProject), + getInputProject: vi.fn(), + getCurrentUserApi: vi.fn(), + getErrorsApi: vi.fn(), + getProjectApi: vi.fn(), + getCache: vi.fn().mockReturnValue(mockCache), + getDashboardUrl: vi.fn(), + getErrorUrl: vi.fn(), + getProjectApiKey: vi.fn(), + hasProjectApiKey: vi.fn(), + getOrganization: vi.fn(), + getProjectEventFilters: vi.fn().mockResolvedValue(mockEventFields), + getEvent: vi.fn(), + updateError: vi.fn(), + listBuilds: vi.fn(), + getBuild: vi.fn(), + listReleases: vi.fn(), + getRelease: vi.fn(), + listBuildsInRelease: vi.fn(), + getProjectStabilityTargets: vi.fn(), + addStabilityData: vi.fn() + } as jest.Mocked; +} + +// Mock ToolExecutionContext +function createMockContext(services: SharedServices): ToolExecutionContext { + return { + services, + getInput: vi.fn() + }; +} + +describe("ListProjectEventFiltersTool", () => { + let tool: ListProjectEventFiltersTool; + let mockServices: jest.Mocked; + let mockContext: ToolExecutionContext; + + beforeEach(() => { + tool = new ListProjectEventFiltersTool(); + mockServices = createMockServices(); + mockContext = createMockContext(mockServices); + }); + + describe("tool definition", () => { + it("should have correct name", () => { + expect(tool.name).toBe("list_project_event_filters"); + }); + + it("should have correct title", () => { + expect(tool.definition.title).toBe("List Project Event Filters"); + }); + + it("should have appropriate use cases", () => { + expect(tool.definition.useCases).toContain( + "Discover what filter fields are available before searching for errors" + ); + expect(tool.definition.useCases).toContain( + "Find the correct field names for filtering by user, environment, or custom metadata" + ); + expect(tool.definition.useCases).toContain( + "Understand filter options and data types for building complex queries" + ); + }); + + it("should have no parameters", () => { + expect(tool.definition.parameters).toHaveLength(0); + }); + + it("should have examples", () => { + expect(tool.definition.examples).toHaveLength(1); + expect(tool.definition.examples[0].description).toBe("Get all available filter fields"); + expect(tool.definition.examples[0].parameters).toEqual({}); + }); + + it("should have helpful hints", () => { + expect(tool.definition.hints).toContain( + "Use this tool before the List Errors or Get Error tools to understand available filters" + ); + expect(tool.definition.hints).toContain( + "Look for display_id field in the response - these are the field names to use in filters" + ); + }); + }); + + describe("execute", () => { + it("should return cached event filters when available", async () => { + // Setup cached filters + mockServices = createMockServices(mockEventFields); + mockContext = createMockContext(mockServices); + + const result = await tool.execute({}, mockContext); + + expect(result.content).toHaveLength(1); + expect(result.content[0].type).toBe("text"); + + const data = JSON.parse(result.content[0].text); + + expect(Array.isArray(data)).toBe(true); + expect(data).toHaveLength(mockEventFields.length); + expect(data).toEqual(mockEventFields); + + // Should not call getCurrentProject or getProjectEventFilters when cached + expect(mockServices.getCurrentProject).not.toHaveBeenCalled(); + expect(mockServices.getProjectEventFilters).not.toHaveBeenCalled(); + }); + + it("should fetch and cache event filters when not cached", async () => { + // Setup no cached filters + mockServices = createMockServices(undefined); + mockContext = createMockContext(mockServices); + + const result = await tool.execute({}, mockContext); + + expect(result.content).toHaveLength(1); + expect(result.content[0].type).toBe("text"); + + const data = JSON.parse(result.content[0].text); + expect(Array.isArray(data)).toBe(true); + expect(data).toHaveLength(mockEventFields.length); + expect(data).toEqual(mockEventFields); + + // Should call getCurrentProject and getProjectEventFilters + expect(mockServices.getCurrentProject).toHaveBeenCalledTimes(1); + expect(mockServices.getProjectEventFilters).toHaveBeenCalledTimes(1); + expect(mockServices.getProjectEventFilters).toHaveBeenCalledWith(mockProject); + + // Should cache the result + const mockCache = mockServices.getCache(); + expect(mockCache.set).toHaveBeenCalledWith("bugsnag_current_project_event_filters", mockEventFields); + }); + + it("should handle no current project error", async () => { + // Setup no cached filters and no current project + mockServices = createMockServices(undefined, null); + mockContext = createMockContext(mockServices); + + const result = await tool.execute({}, mockContext); + + expect(result.isError).toBe(true); + expect(result.content[0].text).toContain("No current project found"); + expect(result.content[0].text).toContain("Please configure a project API key or specify a project ID"); + }); + + it("should handle empty event filters from service", async () => { + // Setup no cached filters and empty filters from service + mockServices = createMockServices(undefined); + mockServices.getProjectEventFilters.mockResolvedValue([]); + mockContext = createMockContext(mockServices); + + const result = await tool.execute({}, mockContext); + + expect(result.isError).toBe(true); + expect(result.content[0].text).toContain("No event filters found for the current project"); + }); + + it("should handle null event filters from service", async () => { + // Setup no cached filters and null filters from service + mockServices = createMockServices(undefined); + mockServices.getProjectEventFilters.mockResolvedValue(null as any); + mockContext = createMockContext(mockServices); + + const result = await tool.execute({}, mockContext); + + expect(result.isError).toBe(true); + expect(result.content[0].text).toContain("No event filters found for the current project"); + }); + + it("should handle cached empty filters", async () => { + // Setup cached empty filters + mockServices = createMockServices([]); + mockContext = createMockContext(mockServices); + + const result = await tool.execute({}, mockContext); + + expect(result.isError).toBe(true); + expect(result.content[0].text).toContain("No event filters found for the current project"); + }); + + it("should handle API errors when fetching current project", async () => { + // Setup no cached filters and API error + mockServices = createMockServices(undefined); + const apiError = new Error("API connection failed"); + mockServices.getCurrentProject.mockRejectedValue(apiError); + mockContext = createMockContext(mockServices); + + const result = await tool.execute({}, mockContext); + + expect(result.isError).toBe(true); + expect(result.content[0].text).toContain("Tool execution failed"); + expect(result.content[0].text).toContain("API connection failed"); + }); + + it("should handle API errors when fetching event filters", async () => { + // Setup no cached filters and API error when fetching filters + mockServices = createMockServices(undefined); + const apiError = new Error("Failed to fetch event filters"); + mockServices.getProjectEventFilters.mockRejectedValue(apiError); + mockContext = createMockContext(mockServices); + + const result = await tool.execute({}, mockContext); + + expect(result.isError).toBe(true); + expect(result.content[0].text).toContain("Tool execution failed"); + expect(result.content[0].text).toContain("Failed to fetch event filters"); + }); + + it("should preserve all event field properties", async () => { + mockServices = createMockServices(mockEventFields); + mockContext = createMockContext(mockServices); + + const result = await tool.execute({}, mockContext); + + const data = JSON.parse(result.content[0].text); + expect(Array.isArray(data)).toBe(true); + expect(data.length).toBeGreaterThan(0); + + const eventField = data[0]; + expect(eventField).toHaveProperty("display_id"); + expect(eventField).toHaveProperty("custom"); + expect(eventField).toHaveProperty("filter_options"); + expect(eventField).toHaveProperty("pivot_options"); + }); + + it("should return different types of event fields", async () => { + mockServices = createMockServices(mockEventFields); + mockContext = createMockContext(mockServices); + + const result = await tool.execute({}, mockContext); + + const data = JSON.parse(result.content[0].text); + expect(Array.isArray(data)).toBe(true); + + // Check for enum type field + const statusField = data.find((f: EventField) => f.display_id === "error.status"); + expect(statusField).toBeDefined(); + expect(statusField.filter_options.type).toBe("enum"); + expect(statusField.filter_options.values).toContain("open"); + + // Check for string type field + const emailField = data.find((f: EventField) => f.display_id === "user.email"); + expect(emailField).toBeDefined(); + expect(emailField.filter_options.type).toBe("string"); + + // Check for custom field + const customField = data.find((f: EventField) => f.display_id === "custom.environment"); + expect(customField).toBeDefined(); + expect(customField.custom).toBe(true); + + // Check for datetime field + const datetimeField = data.find((f: EventField) => f.display_id === "event.since"); + expect(datetimeField).toBeDefined(); + expect(datetimeField.filter_options.type).toBe("datetime"); + }); + + it("should call cache.get with correct key", async () => { + mockServices = createMockServices(mockEventFields); + mockContext = createMockContext(mockServices); + + await tool.execute({}, mockContext); + + const mockCache = mockServices.getCache(); + expect(mockCache.get).toHaveBeenCalledWith("bugsnag_current_project_event_filters"); + }); + + it("should not call getCurrentProject when filters are cached", async () => { + mockServices = createMockServices(mockEventFields); + mockContext = createMockContext(mockServices); + + await tool.execute({}, mockContext); + + expect(mockServices.getCurrentProject).not.toHaveBeenCalled(); + }); + }); + + describe("error handling", () => { + it("should handle BugsnagToolError appropriately", async () => { + mockServices = createMockServices(undefined); + const toolError = new BugsnagToolError("Custom tool error", "test-tool"); + mockServices.getCurrentProject.mockRejectedValue(toolError); + mockContext = createMockContext(mockServices); + + const result = await tool.execute({}, mockContext); + + expect(result.isError).toBe(true); + expect(result.content[0].text).toBe("Custom tool error"); + }); + + it("should handle generic errors", async () => { + mockServices = createMockServices(undefined); + const genericError = new Error("Generic error"); + mockServices.getCurrentProject.mockRejectedValue(genericError); + mockContext = createMockContext(mockServices); + + const result = await tool.execute({}, mockContext); + + expect(result.isError).toBe(true); + expect(result.content[0].text).toContain("Tool execution failed: Generic error"); + }); + + it("should handle non-Error exceptions", async () => { + mockServices = createMockServices(undefined); + mockServices.getCurrentProject.mockRejectedValue("String error"); + mockContext = createMockContext(mockServices); + + const result = await tool.execute({}, mockContext); + + expect(result.isError).toBe(true); + expect(result.content[0].text).toContain("Tool execution failed with unknown error: String error"); + }); + + it("should handle cache errors gracefully", async () => { + mockServices = createMockServices(undefined); + const mockCache = mockServices.getCache(); + mockCache.get.mockImplementation(() => { + throw new Error("Cache error"); + }); + mockContext = createMockContext(mockServices); + + const result = await tool.execute({}, mockContext); + + expect(result.isError).toBe(true); + expect(result.content[0].text).toContain("Tool execution failed"); + expect(result.content[0].text).toContain("Cache error"); + }); + }); + + describe("caching behavior", () => { + it("should use cache when available and not fetch from API", async () => { + mockServices = createMockServices(mockEventFields); + mockContext = createMockContext(mockServices); + + await tool.execute({}, mockContext); + + // Should use cache + const mockCache = mockServices.getCache(); + expect(mockCache.get).toHaveBeenCalledWith("bugsnag_current_project_event_filters"); + + // Should not fetch from API + expect(mockServices.getCurrentProject).not.toHaveBeenCalled(); + expect(mockServices.getProjectEventFilters).not.toHaveBeenCalled(); + }); + + it("should fetch from API and cache when not in cache", async () => { + mockServices = createMockServices(undefined); + mockContext = createMockContext(mockServices); + + await tool.execute({}, mockContext); + + // Should try cache first + const mockCache = mockServices.getCache(); + expect(mockCache.get).toHaveBeenCalledWith("bugsnag_current_project_event_filters"); + + // Should fetch from API + expect(mockServices.getCurrentProject).toHaveBeenCalledTimes(1); + expect(mockServices.getProjectEventFilters).toHaveBeenCalledTimes(1); + + // Should cache the result + expect(mockCache.set).toHaveBeenCalledWith("bugsnag_current_project_event_filters", mockEventFields); + }); + + it("should handle cache returning undefined", async () => { + mockServices = createMockServices(undefined); + const mockCache = mockServices.getCache(); + mockCache.get.mockReturnValue(undefined); + mockContext = createMockContext(mockServices); + + await tool.execute({}, mockContext); + + // Should fetch from API when cache returns undefined + expect(mockServices.getCurrentProject).toHaveBeenCalledTimes(1); + expect(mockServices.getProjectEventFilters).toHaveBeenCalledTimes(1); + }); + + it("should handle cache returning null", async () => { + mockServices = createMockServices(undefined); + const mockCache = mockServices.getCache(); + mockCache.get.mockReturnValue(null); + mockContext = createMockContext(mockServices); + + await tool.execute({}, mockContext); + + // Should fetch from API when cache returns null + expect(mockServices.getCurrentProject).toHaveBeenCalledTimes(1); + expect(mockServices.getProjectEventFilters).toHaveBeenCalledTimes(1); + }); + }); + + describe("edge cases", () => { + it("should handle single event field", async () => { + const singleField = [mockEventFields[0]]; + mockServices = createMockServices(singleField); + mockContext = createMockContext(mockServices); + + const result = await tool.execute({}, mockContext); + + expect(result.content).toHaveLength(1); + const data = JSON.parse(result.content[0].text); + expect(Array.isArray(data)).toBe(true); + expect(data).toHaveLength(1); + expect(data[0]).toEqual(mockEventFields[0]); + }); + + it("should handle event fields with null pivot_options", async () => { + const fieldsWithNullPivot = [ + { + display_id: "test.field", + custom: false, + filter_options: { type: "string" }, + pivot_options: null + } + ]; + mockServices = createMockServices(fieldsWithNullPivot); + mockContext = createMockContext(mockServices); + + const result = await tool.execute({}, mockContext); + + expect(result.content).toHaveLength(1); + const data = JSON.parse(result.content[0].text); + expect(Array.isArray(data)).toBe(true); + expect(data.length).toBeGreaterThan(0); + expect(data[0].pivot_options).toBeNull(); + }); + + it("should handle event fields with complex filter options", async () => { + const complexFields = [ + { + display_id: "complex.field", + custom: true, + filter_options: { + type: "enum", + values: ["value1", "value2", "value3"], + multiple: true + }, + pivot_options: { + type: "enum", + values: ["value1", "value2"] + } + } + ]; + mockServices = createMockServices(complexFields); + mockContext = createMockContext(mockServices); + + const result = await tool.execute({}, mockContext); + + expect(result.content).toHaveLength(1); + const data = JSON.parse(result.content[0].text); + expect(Array.isArray(data)).toBe(true); + expect(data.length).toBeGreaterThan(0); + expect(data[0].filter_options.multiple).toBe(true); + expect(data[0].filter_options.values).toHaveLength(3); + }); + }); +}); diff --git a/src/tests/unit/bugsnag/list-projects-tool-integration.test.ts b/src/tests/unit/bugsnag/list-projects-tool-integration.test.ts new file mode 100644 index 00000000..0f805ca5 --- /dev/null +++ b/src/tests/unit/bugsnag/list-projects-tool-integration.test.ts @@ -0,0 +1,267 @@ +/** + * Integration tests for ListProjectsTool with ToolRegistry + */ + +import { describe, it, expect, beforeEach, vi } from "vitest"; +import { ListProjectsTool } from "../../../bugsnag/tools/list-projects-tool.js"; +import { BugsnagToolRegistry } from "../../../bugsnag/tool-registry.js"; +import { SharedServices, ToolExecutionContext } from "../../../bugsnag/types.js"; +import { Project } from "../../../bugsnag/client/api/CurrentUser.js"; + +// Mock projects data +const mockProjects: Project[] = [ + { + id: "project1", + name: "Test Project 1", + slug: "test-project-1", + api_key: "api-key-1", + type: "web", + url: "https://example.com", + language: "javascript", + created_at: "2023-01-01T00:00:00Z", + updated_at: "2023-01-01T00:00:00Z", + errors_url: "https://api.bugsnag.com/projects/project1/errors", + events_url: "https://api.bugsnag.com/projects/project1/events" + }, + { + id: "project2", + name: "Test Project 2", + slug: "test-project-2", + api_key: "api-key-2", + type: "mobile", + url: "https://example2.com", + language: "swift", + created_at: "2023-01-02T00:00:00Z", + updated_at: "2023-01-02T00:00:00Z", + errors_url: "https://api.bugsnag.com/projects/project2/errors", + events_url: "https://api.bugsnag.com/projects/project2/events" + } +]; + +// Mock SharedServices +function createMockServices(): jest.Mocked { + return { + getProjects: vi.fn().mockResolvedValue(mockProjects), + getProject: vi.fn(), + getCurrentProject: vi.fn(), + getInputProject: vi.fn(), + getCurrentUserApi: vi.fn(), + getErrorsApi: vi.fn(), + getProjectApi: vi.fn(), + getCache: vi.fn(), + getDashboardUrl: vi.fn(), + getErrorUrl: vi.fn(), + getProjectApiKey: vi.fn(), + hasProjectApiKey: vi.fn(), + getOrganization: vi.fn(), + getProjectEventFilters: vi.fn(), + getEvent: vi.fn(), + updateError: vi.fn(), + listBuilds: vi.fn(), + getBuild: vi.fn(), + listReleases: vi.fn(), + getRelease: vi.fn(), + listBuildsInRelease: vi.fn(), + getProjectStabilityTargets: vi.fn(), + addStabilityData: vi.fn() + } as jest.Mocked; +} + +describe("ListProjectsTool Integration", () => { + let tool: ListProjectsTool; + let registry: BugsnagToolRegistry; + let mockServices: jest.Mocked; + let mockContext: ToolExecutionContext; + + beforeEach(() => { + tool = new ListProjectsTool(); + registry = new BugsnagToolRegistry(); + mockServices = createMockServices(); + mockContext = { + services: mockServices, + getInput: vi.fn() + }; + }); + + describe("tool registration", () => { + it("should register tool successfully", () => { + expect(() => registry.registerTool(tool)).not.toThrow(); + expect(registry.hasTool("list_projects")).toBe(true); + expect(registry.getToolCount()).toBe(1); + }); + + it("should retrieve registered tool", () => { + registry.registerTool(tool); + const retrievedTool = registry.getTool("list_projects"); + + expect(retrievedTool).toBeDefined(); + expect(retrievedTool?.name).toBe("list_projects"); + expect(retrievedTool?.definition.title).toBe("List Projects"); + }); + + it("should prevent duplicate registration", () => { + registry.registerTool(tool); + + expect(() => registry.registerTool(tool)).toThrow( + "Tool with name 'list_projects' is already registered" + ); + }); + + it("should include tool in discovered tools", () => { + registry.registerTool(tool); + // Use configuration that includes List Projects tool + const config = { includeListProjects: true }; + const discoveredTools = registry.discoverTools(config); + + // Auto-discovery finds all tools, so we should have multiple tools + expect(discoveredTools.length).toBeGreaterThan(0); + const listProjectsTool = discoveredTools.find(t => t.name === "list_projects"); + expect(listProjectsTool).toBeDefined(); + }); + }); + + describe("tool execution through registry", () => { + beforeEach(() => { + registry.registerTool(tool); + }); + + it("should execute tool through registry", async () => { + const registeredTool = registry.getTool("list_projects"); + expect(registeredTool).toBeDefined(); + + const result = await registeredTool!.execute({}, mockContext); + + expect(result.content).toHaveLength(1); + const data = JSON.parse(result.content[0].text); + expect(data.data).toHaveLength(2); + expect(data.count).toBe(2); + expect(mockServices.getProjects).toHaveBeenCalledTimes(1); + }); + + it("should execute tool with pagination through registry", async () => { + const registeredTool = registry.getTool("list_projects"); + expect(registeredTool).toBeDefined(); + + const result = await registeredTool!.execute({ page_size: 1 }, mockContext); + + expect(result.content).toHaveLength(1); + const data = JSON.parse(result.content[0].text); + expect(data.data).toHaveLength(1); + expect(data.count).toBe(1); + expect(data.data[0]).toEqual(mockProjects[0]); + }); + + it("should handle errors through registry", async () => { + mockServices.getProjects.mockRejectedValue(new Error("API Error")); + + const registeredTool = registry.getTool("list_projects"); + expect(registeredTool).toBeDefined(); + + const result = await registeredTool!.execute({}, mockContext); + + expect(result.isError).toBe(true); + expect(result.content[0].text).toContain("Tool execution failed"); + }); + }); + + describe("MCP server registration", () => { + it("should register with MCP server format", () => { + registry.registerTool(tool); + + const mockRegister = vi.fn(); + // Use configuration that includes List Projects tool + const config = { includeListProjects: true }; + registry.registerAllTools(mockRegister, mockContext, config); + + // Auto-discovery registers all tools, so we should have multiple registrations + expect(mockRegister).toHaveBeenCalled(); + + // Find the list_projects tool registration + const listProjectsCall = mockRegister.mock.calls.find(call => + call[0].title === 'List Projects' + ); + expect(listProjectsCall).toBeDefined(); + + const [toolDefinition, executionFunction] = listProjectsCall; + + // Verify tool definition structure + expect(toolDefinition.title).toBe("List Projects"); + expect(toolDefinition.summary).toBe("List all projects in the organization with optional pagination"); + expect(toolDefinition.purpose).toBe("Retrieve available projects for browsing and selecting which project to analyze"); + expect(toolDefinition.useCases).toHaveLength(3); + expect(toolDefinition.parameters).toHaveLength(2); + expect(toolDefinition.examples).toHaveLength(2); + expect(toolDefinition.hints).toHaveLength(2); + + // Verify parameters + const paramNames = toolDefinition.parameters.map((p: any) => p.name); + expect(paramNames).toContain("page_size"); + expect(paramNames).toContain("page"); + + // Verify execution function is provided + expect(typeof executionFunction).toBe("function"); + }); + + it("should execute through MCP server registration", async () => { + registry.registerTool(tool); + + const mockRegister = vi.fn(); + // Use configuration that includes List Projects tool + const config = { includeListProjects: true }; + registry.registerAllTools(mockRegister, mockContext, config); + + // Find the list_projects tool registration + const listProjectsCall = mockRegister.mock.calls.find(call => + call[0].title === 'List Projects' + ); + const [, executionFunction] = listProjectsCall; + + const result = await executionFunction({}); + + expect(result.content).toHaveLength(1); + const data = JSON.parse(result.content[0].text); + expect(data.data).toHaveLength(2); + expect(data.count).toBe(2); + }); + + it("should handle errors in MCP server execution", async () => { + mockServices.getProjects.mockRejectedValue(new Error("Service Error")); + registry.registerTool(tool); + + const mockRegister = vi.fn(); + // Use configuration that includes List Projects tool + const config = { includeListProjects: true }; + registry.registerAllTools(mockRegister, mockContext, config); + + // Find the list_projects tool registration + const listProjectsCall = mockRegister.mock.calls.find(call => + call[0].title === 'List Projects' + ); + const [, executionFunction] = listProjectsCall; + + const result = await executionFunction({}); + + expect(result.isError).toBe(true); + expect(result.content[0].text).toContain("Service Error"); + }); + }); + + describe("registry management", () => { + it("should clear all tools", () => { + registry.registerTool(tool); + expect(registry.getToolCount()).toBe(1); + + registry.clear(); + expect(registry.getToolCount()).toBe(0); + expect(registry.hasTool("list_projects")).toBe(false); + }); + + it("should get all tools", () => { + registry.registerTool(tool); + const allTools = registry.getAllTools(); + + expect(allTools).toHaveLength(1); + expect(allTools[0].name).toBe("list_projects"); + }); + }); +}); diff --git a/src/tests/unit/bugsnag/list-projects-tool.test.ts b/src/tests/unit/bugsnag/list-projects-tool.test.ts new file mode 100644 index 00000000..5e8ab238 --- /dev/null +++ b/src/tests/unit/bugsnag/list-projects-tool.test.ts @@ -0,0 +1,369 @@ +/** + * Unit tests for ListProjectsTool + */ + +import { describe, it, expect, beforeEach, vi } from "vitest"; +import { ListProjectsTool } from "../../../bugsnag/tools/list-projects-tool.js"; +import { SharedServices, ToolExecutionContext, BugsnagToolError } from "../../../bugsnag/types.js"; +import { Project } from "../../../bugsnag/client/api/CurrentUser.js"; + +// Mock projects data +const mockProjects: Project[] = [ + { + id: "project1", + name: "Test Project 1", + slug: "test-project-1", + api_key: "api-key-1", + type: "web", + url: "https://example.com", + language: "javascript", + created_at: "2023-01-01T00:00:00Z", + updated_at: "2023-01-01T00:00:00Z", + errors_url: "https://api.bugsnag.com/projects/project1/errors", + events_url: "https://api.bugsnag.com/projects/project1/events" + }, + { + id: "project2", + name: "Test Project 2", + slug: "test-project-2", + api_key: "api-key-2", + type: "mobile", + url: "https://example2.com", + language: "swift", + created_at: "2023-01-02T00:00:00Z", + updated_at: "2023-01-02T00:00:00Z", + errors_url: "https://api.bugsnag.com/projects/project2/errors", + events_url: "https://api.bugsnag.com/projects/project2/events" + }, + { + id: "project3", + name: "Test Project 3", + slug: "test-project-3", + api_key: "api-key-3", + type: "web", + url: "https://example3.com", + language: "python", + created_at: "2023-01-03T00:00:00Z", + updated_at: "2023-01-03T00:00:00Z", + errors_url: "https://api.bugsnag.com/projects/project3/errors", + events_url: "https://api.bugsnag.com/projects/project3/events" + } +]; + +// Mock SharedServices +function createMockServices(projects: Project[] = mockProjects): jest.Mocked { + return { + getProjects: vi.fn().mockResolvedValue(projects), + getProject: vi.fn(), + getCurrentProject: vi.fn(), + getInputProject: vi.fn(), + getCurrentUserApi: vi.fn(), + getErrorsApi: vi.fn(), + getProjectApi: vi.fn(), + getCache: vi.fn(), + getDashboardUrl: vi.fn(), + getErrorUrl: vi.fn(), + getProjectApiKey: vi.fn(), + hasProjectApiKey: vi.fn(), + getOrganization: vi.fn(), + getProjectEventFilters: vi.fn(), + getEvent: vi.fn(), + updateError: vi.fn(), + listBuilds: vi.fn(), + getBuild: vi.fn(), + listReleases: vi.fn(), + getRelease: vi.fn(), + listBuildsInRelease: vi.fn(), + getProjectStabilityTargets: vi.fn(), + addStabilityData: vi.fn() + } as jest.Mocked; +} + +// Mock ToolExecutionContext +function createMockContext(services: SharedServices): ToolExecutionContext { + return { + services, + getInput: vi.fn() + }; +} + +describe("ListProjectsTool", () => { + let tool: ListProjectsTool; + let mockServices: jest.Mocked; + let mockContext: ToolExecutionContext; + + beforeEach(() => { + tool = new ListProjectsTool(); + mockServices = createMockServices(); + mockContext = createMockContext(mockServices); + }); + + describe("tool definition", () => { + it("should have correct name", () => { + expect(tool.name).toBe("list_projects"); + }); + + it("should have correct title", () => { + expect(tool.definition.title).toBe("List Projects"); + }); + + it("should have appropriate use cases", () => { + expect(tool.definition.useCases).toContain( + "Browse available projects when no specific project API key is configured" + ); + expect(tool.definition.useCases).toContain( + "Find project IDs needed for other tools" + ); + expect(tool.definition.useCases).toContain( + "Get an overview of all projects in the organization" + ); + }); + + it("should have pagination parameters", () => { + const paramNames = tool.definition.parameters.map(p => p.name); + expect(paramNames).toContain("page_size"); + expect(paramNames).toContain("page"); + }); + + it("should have examples", () => { + expect(tool.definition.examples).toHaveLength(2); + expect(tool.definition.examples[0].description).toBe("Get first 10 projects"); + expect(tool.definition.examples[1].description).toBe("Get all projects (no pagination)"); + }); + }); + + describe("execute", () => { + it("should return all projects when no pagination is specified", async () => { + const result = await tool.execute({}, mockContext); + + expect(result.content).toHaveLength(1); + expect(result.content[0].type).toBe("text"); + + const data = JSON.parse(result.content[0].text); + expect(data.data).toHaveLength(3); + expect(data.count).toBe(3); + expect(data.data).toEqual(mockProjects); + }); + + it("should apply pagination when page_size is specified", async () => { + const result = await tool.execute({ page_size: 2 }, mockContext); + + expect(result.content).toHaveLength(1); + const data = JSON.parse(result.content[0].text); + expect(data.data).toHaveLength(2); + expect(data.count).toBe(2); + expect(data.data[0]).toEqual(mockProjects[0]); + expect(data.data[1]).toEqual(mockProjects[1]); + }); + + it("should apply pagination when both page_size and page are specified", async () => { + const result = await tool.execute({ page_size: 2, page: 2 }, mockContext); + + expect(result.content).toHaveLength(1); + const data = JSON.parse(result.content[0].text); + expect(data.data).toHaveLength(1); + expect(data.count).toBe(1); + expect(data.data[0]).toEqual(mockProjects[2]); + }); + + it("should handle page parameter without page_size (defaults to 10)", async () => { + const result = await tool.execute({ page: 1 }, mockContext); + + expect(result.content).toHaveLength(1); + const data = JSON.parse(result.content[0].text); + expect(data.data).toHaveLength(3); // All 3 projects fit in default page size of 10 + expect(data.count).toBe(3); + }); + + it("should handle empty project list", async () => { + mockServices.getProjects.mockResolvedValue([]); + + const result = await tool.execute({}, mockContext); + + expect(result.content).toHaveLength(1); + expect(result.content[0].text).toBe("No projects found."); + }); + + it("should handle null project list", async () => { + mockServices.getProjects.mockResolvedValue(null as any); + + const result = await tool.execute({}, mockContext); + + expect(result.content).toHaveLength(1); + expect(result.content[0].text).toBe("No projects found."); + }); + + it("should handle pagination beyond available projects", async () => { + const result = await tool.execute({ page_size: 2, page: 3 }, mockContext); + + expect(result.content).toHaveLength(1); + const data = JSON.parse(result.content[0].text); + expect(data.data).toHaveLength(0); + expect(data.count).toBe(0); + }); + + it("should handle API errors gracefully", async () => { + const apiError = new Error("API connection failed"); + mockServices.getProjects.mockRejectedValue(apiError); + + const result = await tool.execute({}, mockContext); + + expect(result.isError).toBe(true); + expect(result.content[0].text).toContain("Tool execution failed"); + expect(result.content[0].text).toContain("API connection failed"); + }); + + it("should call getProjects exactly once", async () => { + await tool.execute({}, mockContext); + + expect(mockServices.getProjects).toHaveBeenCalledTimes(1); + }); + }); + + describe("parameter validation", () => { + it("should accept valid page_size parameter", async () => { + const result = await tool.execute({ page_size: 25 }, mockContext); + + expect(result.isError).toBeFalsy(); + expect(mockServices.getProjects).toHaveBeenCalled(); + }); + + it("should accept valid page parameter", async () => { + const result = await tool.execute({ page: 2 }, mockContext); + + expect(result.isError).toBeFalsy(); + expect(mockServices.getProjects).toHaveBeenCalled(); + }); + + it("should reject invalid page_size (too large)", async () => { + const result = await tool.execute({ page_size: 150 }, mockContext); + + expect(result.isError).toBe(true); + expect(result.content[0].text).toContain("Invalid value for parameter 'page_size'"); + }); + + it("should reject invalid page_size (zero)", async () => { + const result = await tool.execute({ page_size: 0 }, mockContext); + + expect(result.isError).toBe(true); + expect(result.content[0].text).toContain("Invalid value for parameter 'page_size'"); + }); + + it("should reject invalid page_size (negative)", async () => { + const result = await tool.execute({ page_size: -1 }, mockContext); + + expect(result.isError).toBe(true); + expect(result.content[0].text).toContain("Invalid value for parameter 'page_size'"); + }); + + it("should reject invalid page (zero)", async () => { + const result = await tool.execute({ page: 0 }, mockContext); + + expect(result.isError).toBe(true); + expect(result.content[0].text).toContain("Invalid value for parameter 'page'"); + }); + + it("should reject invalid page (negative)", async () => { + const result = await tool.execute({ page: -1 }, mockContext); + + expect(result.isError).toBe(true); + expect(result.content[0].text).toContain("Invalid value for parameter 'page'"); + }); + + it("should reject non-integer page_size", async () => { + const result = await tool.execute({ page_size: 10.5 }, mockContext); + + expect(result.isError).toBe(true); + expect(result.content[0].text).toContain("Invalid value for parameter 'page_size'"); + }); + + it("should reject non-integer page", async () => { + const result = await tool.execute({ page: 1.5 }, mockContext); + + expect(result.isError).toBe(true); + expect(result.content[0].text).toContain("Invalid value for parameter 'page'"); + }); + }); + + describe("edge cases", () => { + it("should handle large page numbers gracefully", async () => { + const result = await tool.execute({ page: 1000 }, mockContext); + + expect(result.content).toHaveLength(1); + const data = JSON.parse(result.content[0].text); + expect(data.data).toHaveLength(0); + expect(data.count).toBe(0); + }); + + it("should handle maximum allowed page_size", async () => { + const result = await tool.execute({ page_size: 100 }, mockContext); + + expect(result.isError).toBeFalsy(); + const data = JSON.parse(result.content[0].text); + expect(data.data).toHaveLength(3); // All projects returned + expect(data.count).toBe(3); + }); + + it("should handle single project in organization", async () => { + mockServices.getProjects.mockResolvedValue([mockProjects[0]]); + + const result = await tool.execute({}, mockContext); + + expect(result.content).toHaveLength(1); + const data = JSON.parse(result.content[0].text); + expect(data.data).toHaveLength(1); + expect(data.count).toBe(1); + expect(data.data[0]).toEqual(mockProjects[0]); + }); + + it("should preserve all project properties", async () => { + const result = await tool.execute({ page_size: 1 }, mockContext); + + const data = JSON.parse(result.content[0].text); + const project = data.data[0]; + + expect(project).toHaveProperty("id"); + expect(project).toHaveProperty("name"); + expect(project).toHaveProperty("slug"); + expect(project).toHaveProperty("api_key"); + expect(project).toHaveProperty("type"); + expect(project).toHaveProperty("url"); + expect(project).toHaveProperty("language"); + expect(project).toHaveProperty("created_at"); + expect(project).toHaveProperty("updated_at"); + expect(project).toHaveProperty("errors_url"); + expect(project).toHaveProperty("events_url"); + }); + }); + + describe("error handling", () => { + it("should handle BugsnagToolError appropriately", async () => { + const toolError = new BugsnagToolError("Custom tool error", "test-tool"); + mockServices.getProjects.mockRejectedValue(toolError); + + const result = await tool.execute({}, mockContext); + + expect(result.isError).toBe(true); + expect(result.content[0].text).toBe("Custom tool error"); + }); + + it("should handle generic errors", async () => { + const genericError = new Error("Generic error"); + mockServices.getProjects.mockRejectedValue(genericError); + + const result = await tool.execute({}, mockContext); + + expect(result.isError).toBe(true); + expect(result.content[0].text).toContain("Tool execution failed: Generic error"); + }); + + it("should handle non-Error exceptions", async () => { + mockServices.getProjects.mockRejectedValue("String error"); + + const result = await tool.execute({}, mockContext); + + expect(result.isError).toBe(true); + expect(result.content[0].text).toContain("Tool execution failed with unknown error: String error"); + }); + }); +}); diff --git a/src/tests/unit/bugsnag/list-releases-tool.test.ts b/src/tests/unit/bugsnag/list-releases-tool.test.ts new file mode 100644 index 00000000..09a548d5 --- /dev/null +++ b/src/tests/unit/bugsnag/list-releases-tool.test.ts @@ -0,0 +1,308 @@ +/** + * Unit tests for List Releases Tool + */ + +import { describe, it, expect, beforeEach, vi } from "vitest"; +import { ListReleasesTool, ListReleasesArgs } from "../../../bugsnag/tools/list-releases-tool.js"; +import { ToolExecutionContext, SharedServices } from "../../../bugsnag/types.js"; + + +// Mock data +const mockProject = { + id: "proj-123", + name: "Test Project", + slug: "test-project" +}; + +const mockReleases = [ + { + id: "release-1", + version: "1.0.0", + release_stage: "production", + created_at: "2023-01-01T00:00:00Z", + stability: { + user_stability: 0.95, + session_stability: 0.98, + meets_targets: true + } + }, + { + id: "release-2", + version: "1.1.0", + release_stage: "production", + created_at: "2023-01-15T00:00:00Z", + stability: { + user_stability: 0.97, + session_stability: 0.99, + meets_targets: true + } + } +]; + +describe("ListReleasesTool", () => { + let tool: ListReleasesTool; + let mockServices: jest.Mocked; + let context: ToolExecutionContext; + + beforeEach(() => { + // Create mock services + mockServices = { + getInputProject: vi.fn(), + listReleases: vi.fn(), + getProjects: vi.fn(), + getProject: vi.fn(), + getCurrentProject: vi.fn(), + getCurrentUserApi: vi.fn(), + getErrorsApi: vi.fn(), + getProjectApi: vi.fn(), + getCache: vi.fn(), + getDashboardUrl: vi.fn(), + getErrorUrl: vi.fn(), + getProjectApiKey: vi.fn(), + hasProjectApiKey: vi.fn(), + listProjectErrors: vi.fn(), + getError: vi.fn(), + updateError: vi.fn(), + getEventDetails: vi.fn(), + listProjectEventFilters: vi.fn(), + listBuilds: vi.fn(), + getBuild: vi.fn(), + getRelease: vi.fn(), + listBuildsInRelease: vi.fn(), + getProjectStabilityTargets: vi.fn(), + addStabilityData: vi.fn(), + } as any; + + context = { + services: mockServices, + getInput: vi.fn() + }; + + tool = new ListReleasesTool(); + }); + + describe("execute", () => { + it("should list releases successfully", async () => { + // Setup mocks + mockServices.getInputProject.mockResolvedValue(mockProject); + mockServices.listReleases.mockResolvedValue({ + releases: mockReleases, + nextUrl: null + }); + + const args: ListReleasesArgs = { + projectId: "proj-123" + }; + + // Execute + const result = await tool.execute(args, context); + + // Verify + expect(result.isError).toBeFalsy(); + expect(result.content).toHaveLength(1); + expect(result.content[0].type).toBe("text"); + + const outerResult = JSON.parse(result.content[0].text); + const responseData = JSON.parse(outerResult.content[0].text); + expect(responseData.releases).toEqual(mockReleases); + expect(responseData.next).toBeNull(); + + // Verify service calls + expect(mockServices.getInputProject).toHaveBeenCalledWith("proj-123"); + expect(mockServices.listReleases).toHaveBeenCalledWith("proj-123", { + release_stage_name: "production", + visible_only: true, + next_url: null + }); + }); + + it("should handle custom release stage filtering", async () => { + // Setup mocks + mockServices.getInputProject.mockResolvedValue(mockProject); + mockServices.listReleases.mockResolvedValue({ + releases: mockReleases.filter(r => r.release_stage === "staging"), + nextUrl: null + }); + + const args: ListReleasesArgs = { + projectId: "proj-123", + releaseStage: "staging" + }; + + // Execute + const result = await tool.execute(args, context); + + // Verify + expect(result.isError).toBeFalsy(); + expect(mockServices.listReleases).toHaveBeenCalledWith("proj-123", { + release_stage_name: "staging", + visible_only: true, + next_url: null + }); + }); + + it("should handle visibleOnly parameter", async () => { + // Setup mocks + mockServices.getInputProject.mockResolvedValue(mockProject); + mockServices.listReleases.mockResolvedValue({ + releases: mockReleases, + nextUrl: null + }); + + const args: ListReleasesArgs = { + projectId: "proj-123", + visibleOnly: false + }; + + // Execute + const result = await tool.execute(args, context); + + // Verify + expect(result.isError).toBeFalsy(); + expect(mockServices.listReleases).toHaveBeenCalledWith("proj-123", { + release_stage_name: "production", + visible_only: false, + next_url: null + }); + }); + + it("should handle pagination with nextUrl", async () => { + // Setup mocks + mockServices.getInputProject.mockResolvedValue(mockProject); + mockServices.listReleases.mockResolvedValue({ + releases: mockReleases, + nextUrl: "/projects/proj-123/releases?offset=30&per_page=30" + }); + + const args: ListReleasesArgs = { + projectId: "proj-123", + nextUrl: "/projects/proj-123/releases?offset=0&per_page=30" + }; + + // Execute + const result = await tool.execute(args, context); + + // Verify + expect(result.isError).toBeFalsy(); + + const outerResult = JSON.parse(result.content[0].text); + const responseData = JSON.parse(outerResult.content[0].text); + expect(responseData.next).toBe("/projects/proj-123/releases?offset=30&per_page=30"); + + expect(mockServices.listReleases).toHaveBeenCalledWith("proj-123", { + release_stage_name: "production", + visible_only: true, + next_url: "/projects/proj-123/releases?offset=0&per_page=30" + }); + }); + + it("should handle empty results", async () => { + // Setup mocks + mockServices.getInputProject.mockResolvedValue(mockProject); + mockServices.listReleases.mockResolvedValue({ + releases: [], + nextUrl: null + }); + + const args: ListReleasesArgs = { + projectId: "proj-123" + }; + + // Execute + const result = await tool.execute(args, context); + + // Verify + expect(result.isError).toBeFalsy(); + + const outerResult = JSON.parse(result.content[0].text); + const responseData = JSON.parse(outerResult.content[0].text); + expect(responseData.releases).toEqual([]); + expect(responseData.next).toBeNull(); + }); + + it("should handle project not found error", async () => { + // Setup mocks + mockServices.getInputProject.mockRejectedValue(new Error("Project not found")); + + const args: ListReleasesArgs = { + projectId: "invalid-project" + }; + + // Execute + const result = await tool.execute(args, context); + + // Verify + expect(result.isError).toBe(true); + expect(result.content[0].text).toContain("Project not found"); + }); + + it("should handle API errors gracefully", async () => { + // Setup mocks + mockServices.getInputProject.mockResolvedValue(mockProject); + mockServices.listReleases.mockRejectedValue(new Error("API Error")); + + const args: ListReleasesArgs = { + projectId: "proj-123" + }; + + // Execute + const result = await tool.execute(args, context); + + // Verify + expect(result.isError).toBe(true); + expect(result.content[0].text).toContain("API Error"); + }); + + it("should validate required parameters", async () => { + const args: ListReleasesArgs = { + projectId: "" // Invalid empty project ID + }; + + // Execute + const result = await tool.execute(args, context); + + // Verify + expect(result.isError).toBe(true); + expect(result.content[0].text).toContain("Project ID cannot be empty"); + }); + }); + + describe("updateParametersForProjectApiKey", () => { + it("should include projectId parameter when no project API key", () => { + tool.updateParametersForProjectApiKey(false); + + const projectIdParam = tool.definition.parameters.find(p => p.name === "projectId"); + expect(projectIdParam).toBeDefined(); + expect(projectIdParam?.required).toBe(true); + }); + + it("should exclude projectId parameter when project API key is configured", () => { + tool.updateParametersForProjectApiKey(true); + + const projectIdParam = tool.definition.parameters.find(p => p.name === "projectId"); + expect(projectIdParam).toBeUndefined(); + }); + }); + + describe("tool definition", () => { + it("should have correct tool name", () => { + expect(tool.name).toBe("list_releases"); + }); + + it("should have proper definition structure", () => { + expect(tool.definition.title).toBe("List Releases"); + expect(tool.definition.summary).toContain("List releases for a project"); + expect(tool.definition.purpose).toContain("Retrieve a list of release summaries"); + expect(tool.definition.useCases).toHaveLength(4); + expect(tool.definition.examples).toHaveLength(3); + expect(tool.definition.hints).toHaveLength(4); + }); + + it("should have required parameters defined", () => { + const paramNames = tool.definition.parameters.map(p => p.name); + expect(paramNames).toContain("releaseStage"); + expect(paramNames).toContain("visibleOnly"); + expect(paramNames).toContain("next"); + }); + }); +}); diff --git a/src/tests/unit/bugsnag/shared-services.test.ts b/src/tests/unit/bugsnag/shared-services.test.ts new file mode 100644 index 00000000..b354d975 --- /dev/null +++ b/src/tests/unit/bugsnag/shared-services.test.ts @@ -0,0 +1,320 @@ +import { describe, it, expect, beforeEach, vi } from 'vitest'; +import { BugsnagSharedServices } from '../../../bugsnag/shared-services.js'; +import { Organization, Project } from '../../../bugsnag/client/api/CurrentUser.js'; + +describe('BugsnagSharedServices', () => { + let sharedServices: BugsnagSharedServices; + let mockCurrentUserApi: any; + let mockErrorsApi: any; + let mockProjectApi: any; + let mockCache: any; + + const mockOrg: Organization = { + id: 'org-123', + name: 'Test Org', + slug: 'test-org', + creator: { id: 'user-1', name: 'Test User', email: 'test@example.com' }, + created_at: '2023-01-01T00:00:00Z', + updated_at: '2023-01-01T00:00:00Z', + billing_emails: [], + collaborators_count: 1, + projects_count: 1 + }; + + const mockProject: Project = { + id: 'project-123', + name: 'Test Project', + slug: 'test-project', + api_key: 'test-api-key', + type: 'javascript', + url: 'https://api.bugsnag.com/projects/project-123', + html_url: 'https://app.bugsnag.com/test-org/test-project', + created_at: '2023-01-01T00:00:00Z', + updated_at: '2023-01-01T00:00:00Z', + errors_url: 'https://api.bugsnag.com/projects/project-123/errors', + events_url: 'https://api.bugsnag.com/projects/project-123/events', + open_error_count: 5, + for_review_error_count: 2, + collaborators_count: 3, + global_grouping: { enabled: false }, + location_grouping: { enabled: true }, + custom_grouping: { enabled: false } + }; + + beforeEach(() => { + mockCurrentUserApi = { + listUserOrganizations: vi.fn(), + getOrganizationProjects: vi.fn() + }; + + mockErrorsApi = { + viewEventById: vi.fn(), + updateErrorOnProject: vi.fn() + }; + + mockProjectApi = { + listProjectEventFields: vi.fn(), + listBuilds: vi.fn(), + getBuild: vi.fn(), + listReleases: vi.fn(), + getRelease: vi.fn(), + listBuildsInRelease: vi.fn(), + getProjectStabilityTargets: vi.fn() + }; + + mockCache = { + get: vi.fn(), + set: vi.fn() + }; + + sharedServices = new BugsnagSharedServices( + mockCurrentUserApi, + mockErrorsApi, + mockProjectApi, + mockCache, + 'https://app.bugsnag.com', + 'https://api.bugsnag.com', + 'test-api-key' + ); + }); + + describe('getProjects', () => { + it('should return cached projects if available', async () => { + const projects = [mockProject]; + mockCache.get.mockReturnValue(projects); + + const result = await sharedServices.getProjects(); + + expect(result).toEqual(projects); + expect(mockCache.get).toHaveBeenCalledWith('bugsnag_projects'); + expect(mockCurrentUserApi.listUserOrganizations).not.toHaveBeenCalled(); + }); + + it('should fetch and cache projects if not cached', async () => { + const projects = [mockProject]; + mockCache.get.mockReturnValue(null); + mockCurrentUserApi.listUserOrganizations.mockResolvedValue({ body: [mockOrg] }); + mockCurrentUserApi.getOrganizationProjects.mockResolvedValue({ body: projects }); + + const result = await sharedServices.getProjects(); + + expect(result).toEqual(projects); + expect(mockCache.set).toHaveBeenCalledWith('bugsnag_projects', projects, 3600); + }); + }); + + describe('getProject', () => { + it('should return project by ID', async () => { + const projects = [mockProject]; + // Mock cache to return null for individual project lookup, then projects array for getProjects + mockCache.get.mockImplementation((key: string) => { + if (key === 'bugsnag_projects') return projects; + return null; // Return null for individual project lookups + }); + + const result = await sharedServices.getProject('project-123'); + + expect(result).toEqual(mockProject); + }); + + it('should return null if project not found', async () => { + const projects = [mockProject]; + // Mock cache to return null for individual project lookup, then projects array for getProjects + mockCache.get.mockImplementation((key: string) => { + if (key === 'bugsnag_projects') return projects; + return null; // Return null for individual project lookups + }); + + const result = await sharedServices.getProject('nonexistent'); + + expect(result).toBeNull(); + }); + }); + + describe('getCurrentProject', () => { + it('should return cached current project', async () => { + mockCache.get.mockReturnValue(mockProject); + + const result = await sharedServices.getCurrentProject(); + + expect(result).toEqual(mockProject); + expect(mockCache.get).toHaveBeenCalledWith('bugsnag_current_project'); + }); + + it('should find project by API key if not cached', async () => { + const mockEventFields = [ + { display_id: 'error.status', custom: false, filter_options: {}, pivot_options: {} } + ]; + + mockCache.get.mockReturnValueOnce(null); // current project not cached + mockCache.get.mockReturnValueOnce([mockProject]); // projects cached + mockProjectApi.listProjectEventFields.mockResolvedValue({ body: mockEventFields }); + + const result = await sharedServices.getCurrentProject(); + + expect(result).toEqual(mockProject); + expect(mockCache.set).toHaveBeenCalledWith('bugsnag_current_project', mockProject, 604800); + }); + }); + + describe('getInputProject', () => { + it('should return project by ID when provided', async () => { + const projects = [mockProject]; + // Mock cache to return null for individual project lookup, then projects array for getProjects + mockCache.get.mockImplementation((key: string) => { + if (key === 'bugsnag_projects') return projects; + return null; // Return null for individual project lookups + }); + + const result = await sharedServices.getInputProject('project-123'); + + expect(result).toEqual(mockProject); + }); + + it('should return current project when no ID provided', async () => { + mockCache.get.mockReturnValue(mockProject); + + const result = await sharedServices.getInputProject(); + + expect(result).toEqual(mockProject); + }); + + it('should throw error if project not found', async () => { + const projects = [mockProject]; + // Mock cache to return null for individual project lookup, then projects array for getProjects + mockCache.get.mockImplementation((key: string) => { + if (key === 'bugsnag_projects') return projects; + return null; // Return null for individual project lookups + }); + + await expect(sharedServices.getInputProject('nonexistent')).rejects.toThrow( + 'Project with ID nonexistent not found.' + ); + }); + }); + + describe('API client access', () => { + it('should return current user API', () => { + expect(sharedServices.getCurrentUserApi()).toBe(mockCurrentUserApi); + }); + + it('should return errors API', () => { + expect(sharedServices.getErrorsApi()).toBe(mockErrorsApi); + }); + + it('should return project API', () => { + expect(sharedServices.getProjectApi()).toBe(mockProjectApi); + }); + + it('should return cache', () => { + expect(sharedServices.getCache()).toBe(mockCache); + }); + }); + + describe('configuration', () => { + it('should return project API key', () => { + expect(sharedServices.getProjectApiKey()).toBe('test-api-key'); + }); + + it('should return true for hasProjectApiKey', () => { + expect(sharedServices.hasProjectApiKey()).toBe(true); + }); + }); + + describe('URL generation', () => { + it('should generate dashboard URL', async () => { + mockCache.get.mockReturnValue(mockOrg); + + const result = await sharedServices.getDashboardUrl(mockProject); + + expect(result).toBe('https://app.bugsnag.com/test-org/test-project'); + }); + + it('should generate error URL', async () => { + mockCache.get.mockReturnValue(mockOrg); + + const result = await sharedServices.getErrorUrl(mockProject, 'error-123', '?filter=test'); + + expect(result).toBe('https://app.bugsnag.com/test-org/test-project/errors/error-123?filter=test'); + }); + }); + + describe('updateError', () => { + it('should update error and return true on success', async () => { + mockErrorsApi.updateErrorOnProject.mockResolvedValue({ status: 200 }); + + const result = await sharedServices.updateError('project-123', 'error-123', 'fix'); + + expect(result).toBe(true); + expect(mockErrorsApi.updateErrorOnProject).toHaveBeenCalledWith( + 'project-123', + 'error-123', + { operation: 'fix' } + ); + }); + + it('should return true on 204 status', async () => { + mockErrorsApi.updateErrorOnProject.mockResolvedValue({ status: 204 }); + + const result = await sharedServices.updateError('project-123', 'error-123', 'ignore'); + + expect(result).toBe(true); + }); + + it('should return false on other status codes', async () => { + mockErrorsApi.updateErrorOnProject.mockResolvedValue({ status: 400 }); + + const result = await sharedServices.updateError('project-123', 'error-123', 'fix'); + + expect(result).toBe(false); + }); + }); + + describe('addStabilityData', () => { + it('should add stability data to build response', () => { + const buildResponse = { + id: 'build-123', + accumulative_daily_users_seen: 100, + accumulative_daily_users_with_unhandled: 10, + total_sessions_count: 1000, + unhandled_sessions_count: 50 + }; + + const stabilityTargets = { + stability_target_type: 'user' as const, + target_stability: { value: 0.95 }, + critical_stability: { value: 0.90 } + }; + + const result = sharedServices.addStabilityData(buildResponse, stabilityTargets); + + expect(result.user_stability).toBe(0.9); // (100 - 10) / 100 + expect(result.session_stability).toBe(0.95); // (1000 - 50) / 1000 + expect(result.meets_target_stability).toBe(false); // 0.9 < 0.95 + expect(result.meets_critical_stability).toBe(true); // 0.9 >= 0.90 + }); + + it('should handle zero division gracefully', () => { + const buildResponse = { + id: 'build-123', + accumulative_daily_users_seen: 0, + accumulative_daily_users_with_unhandled: 0, + total_sessions_count: 0, + unhandled_sessions_count: 0 + }; + + const stabilityTargets = { + stability_target_type: 'user' as const, + target_stability: { value: 0.95 }, + critical_stability: { value: 0.90 } + }; + + const result = sharedServices.addStabilityData(buildResponse, stabilityTargets); + + expect(result.user_stability).toBe(0); + expect(result.session_stability).toBe(0); + expect(result.meets_target_stability).toBe(false); + expect(result.meets_critical_stability).toBe(false); + }); + }); +}); diff --git a/src/tests/unit/bugsnag/tool-factory.test.ts b/src/tests/unit/bugsnag/tool-factory.test.ts new file mode 100644 index 00000000..ace4a8a4 --- /dev/null +++ b/src/tests/unit/bugsnag/tool-factory.test.ts @@ -0,0 +1,323 @@ +/** + * Unit tests for BugsnagToolFactory + */ + +import { describe, it, expect } from "vitest"; +import { BugsnagToolFactory, ToolDiscoveryConfig } from "../../../bugsnag/tool-factory.js"; +import { BugsnagTool, BugsnagToolError } from "../../../bugsnag/types.js"; + +describe("BugsnagToolFactory", () => { + describe("discoverTools", () => { + it("should discover all default tools when no config is provided", () => { + const tools = BugsnagToolFactory.discoverTools(); + + expect(tools).toBeDefined(); + expect(Array.isArray(tools)).toBe(true); + expect(tools.length).toBeGreaterThan(0); + + // Should not include ListProjectsTool by default + const toolNames = tools.map(tool => tool.name); + expect(toolNames).not.toContain("list_projects"); + + // Should include other core tools + expect(toolNames).toContain("get_error"); + expect(toolNames).toContain("get_event_details"); + expect(toolNames).toContain("list_project_errors"); + }); + + it("should include ListProjectsTool when configured", () => { + const config: ToolDiscoveryConfig = { + includeListProjects: true + }; + + const tools = BugsnagToolFactory.discoverTools(config); + const toolNames = tools.map(tool => tool.name); + + expect(toolNames).toContain("list_projects"); + }); + + it("should exclude specified tools", () => { + const config: ToolDiscoveryConfig = { + excludeTools: ["get_error", "get_event_details"] + }; + + const tools = BugsnagToolFactory.discoverTools(config); + const toolNames = tools.map(tool => tool.name); + + expect(toolNames).not.toContain("get_error"); + expect(toolNames).not.toContain("get_event_details"); + expect(toolNames).toContain("list_project_errors"); // Should still include others + }); + + it("should include custom tools when provided", () => { + // Create a mock custom tool + class CustomTool implements BugsnagTool { + readonly name = "custom_tool"; + readonly definition = { + title: "Custom Tool", + summary: "A custom test tool", + purpose: "Testing custom tool discovery", + useCases: ["Testing"], + parameters: [], + examples: [], + hints: [] + }; + + async execute() { + return { content: [{ type: "text" as const, text: "custom result" }] }; + } + } + + const config: ToolDiscoveryConfig = { + customTools: [CustomTool] + }; + + const tools = BugsnagToolFactory.discoverTools(config); + const toolNames = tools.map(tool => tool.name); + + expect(toolNames).toContain("custom_tool"); + }); + + it("should validate tool instances", () => { + // Create an invalid tool class + class InvalidTool { + // Missing required properties + } + + const config: ToolDiscoveryConfig = { + customTools: [InvalidTool as any] + }; + + expect(() => { + BugsnagToolFactory.discoverTools(config); + }).toThrow(BugsnagToolError); + }); + + it("should return tools with valid interface", () => { + const tools = BugsnagToolFactory.discoverTools(); + + for (const tool of tools) { + expect(tool.name).toBeDefined(); + expect(typeof tool.name).toBe("string"); + expect(tool.definition).toBeDefined(); + expect(typeof tool.definition).toBe("object"); + expect(tool.execute).toBeDefined(); + expect(typeof tool.execute).toBe("function"); + + // Validate definition structure + expect(tool.definition.title).toBeDefined(); + expect(tool.definition.summary).toBeDefined(); + expect(tool.definition.purpose).toBeDefined(); + expect(Array.isArray(tool.definition.useCases)).toBe(true); + expect(Array.isArray(tool.definition.parameters)).toBe(true); + expect(Array.isArray(tool.definition.examples)).toBe(true); + expect(Array.isArray(tool.definition.hints)).toBe(true); + } + }); + }); + + describe("createTool", () => { + it("should create a tool by name", () => { + const tool = BugsnagToolFactory.createTool("get_error"); + + expect(tool).toBeDefined(); + expect(tool?.name).toBe("get_error"); + }); + + it("should return null for unknown tool name", () => { + const tool = BugsnagToolFactory.createTool("unknown_tool"); + + expect(tool).toBeNull(); + }); + + it("should respect configuration when creating tools", () => { + const config: ToolDiscoveryConfig = { + includeListProjects: true + }; + + const tool = BugsnagToolFactory.createTool("list_projects", config); + expect(tool).toBeDefined(); + expect(tool?.name).toBe("list_projects"); + + // Without config, should return null + const toolWithoutConfig = BugsnagToolFactory.createTool("list_projects"); + expect(toolWithoutConfig).toBeNull(); + }); + }); + + describe("getAvailableToolNames", () => { + it("should return array of tool names", () => { + const toolNames = BugsnagToolFactory.getAvailableToolNames(); + + expect(Array.isArray(toolNames)).toBe(true); + expect(toolNames.length).toBeGreaterThan(0); + expect(toolNames).toContain("get_error"); + expect(toolNames).not.toContain("list_projects"); + }); + + it("should include ListProjectsTool when configured", () => { + const config: ToolDiscoveryConfig = { + includeListProjects: true + }; + + const toolNames = BugsnagToolFactory.getAvailableToolNames(config); + expect(toolNames).toContain("list_projects"); + }); + + it("should exclude specified tools", () => { + const config: ToolDiscoveryConfig = { + excludeTools: ["get_error"] + }; + + const toolNames = BugsnagToolFactory.getAvailableToolNames(config); + expect(toolNames).not.toContain("get_error"); + }); + }); + + describe("isToolAvailable", () => { + it("should return true for available tools", () => { + expect(BugsnagToolFactory.isToolAvailable("get_error")).toBe(true); + expect(BugsnagToolFactory.isToolAvailable("get_event_details")).toBe(true); + }); + + it("should return false for unavailable tools", () => { + expect(BugsnagToolFactory.isToolAvailable("unknown_tool")).toBe(false); + expect(BugsnagToolFactory.isToolAvailable("list_projects")).toBe(false); + }); + + it("should respect configuration", () => { + const config: ToolDiscoveryConfig = { + includeListProjects: true + }; + + expect(BugsnagToolFactory.isToolAvailable("list_projects", config)).toBe(true); + + const excludeConfig: ToolDiscoveryConfig = { + excludeTools: ["get_error"] + }; + + expect(BugsnagToolFactory.isToolAvailable("get_error", excludeConfig)).toBe(false); + }); + }); + + describe("getToolCount", () => { + it("should return correct tool count", () => { + const count = BugsnagToolFactory.getToolCount(); + expect(typeof count).toBe("number"); + expect(count).toBeGreaterThan(0); + }); + + it("should include ListProjectsTool in count when configured", () => { + const defaultCount = BugsnagToolFactory.getToolCount(); + + const config: ToolDiscoveryConfig = { + includeListProjects: true + }; + + const countWithListProjects = BugsnagToolFactory.getToolCount(config); + expect(countWithListProjects).toBe(defaultCount + 1); + }); + + it("should exclude tools from count when configured", () => { + const defaultCount = BugsnagToolFactory.getToolCount(); + + const config: ToolDiscoveryConfig = { + excludeTools: ["get_error", "get_event_details"] + }; + + const countWithExclusions = BugsnagToolFactory.getToolCount(config); + expect(countWithExclusions).toBe(defaultCount - 2); + }); + }); + + describe("tool validation", () => { + it("should validate tool name", () => { + class InvalidNameTool { + // Missing name property + readonly definition = { + title: "Test", + summary: "Test", + purpose: "Test", + useCases: [], + parameters: [], + examples: [], + hints: [] + }; + async execute() { + return { content: [{ type: "text" as const, text: "test" }] }; + } + } + + const config: ToolDiscoveryConfig = { + customTools: [InvalidNameTool as any] + }; + + expect(() => { + BugsnagToolFactory.discoverTools(config); + }).toThrow("Tool must have a valid name property"); + }); + + it("should validate tool definition", () => { + class InvalidDefinitionTool { + readonly name = "test_tool"; + // Missing definition property + async execute() { + return { content: [{ type: "text" as const, text: "test" }] }; + } + } + + const config: ToolDiscoveryConfig = { + customTools: [InvalidDefinitionTool as any] + }; + + expect(() => { + BugsnagToolFactory.discoverTools(config); + }).toThrow("Tool must have a valid definition property"); + }); + + it("should validate execute method", () => { + class InvalidExecuteTool { + readonly name = "test_tool"; + readonly definition = { + title: "Test", + summary: "Test", + purpose: "Test", + useCases: [], + parameters: [], + examples: [], + hints: [] + }; + // Missing execute method + } + + const config: ToolDiscoveryConfig = { + customTools: [InvalidExecuteTool as any] + }; + + expect(() => { + BugsnagToolFactory.discoverTools(config); + }).toThrow("Tool must have a valid execute method"); + }); + + it("should validate definition structure", () => { + class InvalidDefinitionStructureTool { + readonly name = "test_tool"; + readonly definition = { + title: "Test", + // Missing required fields + }; + async execute() { + return { content: [{ type: "text" as const, text: "test" }] }; + } + } + + const config: ToolDiscoveryConfig = { + customTools: [InvalidDefinitionStructureTool as any] + }; + + expect(() => { + BugsnagToolFactory.discoverTools(config); + }).toThrow(/Tool definition must have a .* property/); + }); + }); +}); diff --git a/src/tests/unit/bugsnag/tool-utilities.test.ts b/src/tests/unit/bugsnag/tool-utilities.test.ts new file mode 100644 index 00000000..99b11595 --- /dev/null +++ b/src/tests/unit/bugsnag/tool-utilities.test.ts @@ -0,0 +1,365 @@ +/** + * Unit tests for Bugsnag tool utilities + */ + +import { describe, it, expect } from "vitest"; +import { z } from "zod"; +import { + CommonParameterSchemas, + CommonParameterDefinitions, + validateToolArgs, + createSuccessResult, + createErrorResult, + executeWithErrorHandling, + formatPaginatedResult, + formatListResult, + extractProjectSlugFromUrl, + extractEventIdFromUrl, + validateUrlParameters, + createConditionalProjectIdParam, + validateConditionalParameters, + TOOL_DEFAULTS +} from "../../../bugsnag/utils/tool-utilities.js"; +import { BugsnagToolError, ParameterDefinition } from "../../../bugsnag/types.js"; + +describe("CommonParameterSchemas", () => { + it("should validate project ID correctly", () => { + expect(() => CommonParameterSchemas.projectId.parse("valid-id")).not.toThrow(); + expect(() => CommonParameterSchemas.projectId.parse("")).toThrow(); + expect(() => CommonParameterSchemas.projectId.parse(null)).toThrow(); + }); + + it("should validate pagination parameters correctly", () => { + expect(() => CommonParameterSchemas.pageSize.parse(10)).not.toThrow(); + expect(() => CommonParameterSchemas.pageSize.parse(100)).not.toThrow(); + expect(() => CommonParameterSchemas.pageSize.parse(0)).toThrow(); + expect(() => CommonParameterSchemas.pageSize.parse(101)).toThrow(); + }); + + it("should validate sort direction correctly", () => { + expect(() => CommonParameterSchemas.direction.parse("asc")).not.toThrow(); + expect(() => CommonParameterSchemas.direction.parse("desc")).not.toThrow(); + expect(() => CommonParameterSchemas.direction.parse("invalid")).toThrow(); + }); + + it("should validate update operations correctly", () => { + expect(() => CommonParameterSchemas.updateOperation.parse("fix")).not.toThrow(); + expect(() => CommonParameterSchemas.updateOperation.parse("ignore")).not.toThrow(); + expect(() => CommonParameterSchemas.updateOperation.parse("invalid")).toThrow(); + }); +}); + +describe("CommonParameterDefinitions", () => { + it("should create project ID parameter definition", () => { + const param = CommonParameterDefinitions.projectId(true); + expect(param.name).toBe("projectId"); + expect(param.required).toBe(true); + expect(param.examples).toContain("515fb9337c1074f6fd000003"); + }); + + it("should create filters parameter definition with defaults", () => { + const defaultFilters = { "error.status": [{ type: "eq" as const, value: "open" }] }; + const param = CommonParameterDefinitions.filters(false, defaultFilters); + expect(param.name).toBe("filters"); + expect(param.required).toBe(false); + expect(param.constraints).toBeDefined(); + }); + + it("should create sort parameter definition with valid values", () => { + const validValues = ["last_seen", "first_seen", "events"]; + const param = CommonParameterDefinitions.sort(validValues, "last_seen"); + expect(param.name).toBe("sort"); + expect(param.examples).toEqual(validValues); + }); +}); + +describe("validateToolArgs", () => { + const mockParameters: ParameterDefinition[] = [ + { + name: "requiredParam", + type: z.string(), + required: true, + description: "A required parameter", + examples: ["example"] + }, + { + name: "optionalParam", + type: z.number(), + required: false, + description: "An optional parameter", + examples: ["123"] + } + ]; + + it("should pass validation with valid arguments", () => { + const args = { requiredParam: "test", optionalParam: 123 }; + expect(() => validateToolArgs(args, mockParameters, "TestTool")).not.toThrow(); + }); + + it("should pass validation with only required arguments", () => { + const args = { requiredParam: "test" }; + expect(() => validateToolArgs(args, mockParameters, "TestTool")).not.toThrow(); + }); + + it("should throw error for missing required parameter", () => { + const args = { optionalParam: 123 }; + expect(() => validateToolArgs(args, mockParameters, "TestTool")) + .toThrow(BugsnagToolError); + + try { + validateToolArgs(args, mockParameters, "TestTool"); + } catch (error) { + expect(error).toBeInstanceOf(BugsnagToolError); + expect((error as BugsnagToolError).message).toContain("requiredParam"); + expect((error as BugsnagToolError).toolName).toBe("TestTool"); + } + }); + + it("should throw error for invalid parameter type", () => { + const args = { requiredParam: "test", optionalParam: "not-a-number" }; + expect(() => validateToolArgs(args, mockParameters, "TestTool")) + .toThrow(BugsnagToolError); + }); + + it("should handle null and undefined values correctly", () => { + const args = { requiredParam: "test", optionalParam: null }; + expect(() => validateToolArgs(args, mockParameters, "TestTool")).not.toThrow(); + }); +}); + +describe("createSuccessResult", () => { + it("should create a success result with JSON content", () => { + const data = { message: "success", count: 5 }; + const result = createSuccessResult(data); + + expect(result.content).toHaveLength(1); + expect(result.content[0].type).toBe("text"); + expect(result.content[0].text).toBe(JSON.stringify(data)); + expect(result.isError).toBeUndefined(); + }); + + it("should handle complex data structures", () => { + const data = { + items: [{ id: 1, name: "test" }], + metadata: { total: 1, page: 1 } + }; + const result = createSuccessResult(data); + + expect(result.content[0].text).toBe(JSON.stringify(data)); + }); +}); + +describe("createErrorResult", () => { + it("should create an error result", () => { + const message = "Something went wrong"; + const result = createErrorResult(message); + + expect(result.content).toHaveLength(1); + expect(result.content[0].type).toBe("text"); + expect(result.content[0].text).toBe(message); + expect(result.isError).toBe(true); + }); + + it("should handle error with underlying cause", () => { + const message = "Tool failed"; + const cause = new Error("Network error"); + const result = createErrorResult(message, cause); + + expect(result.content[0].text).toBe(message); + expect(result.isError).toBe(true); + }); +}); + +describe("executeWithErrorHandling", () => { + it("should return success result for successful execution", async () => { + const data = { result: "success" }; + const execution = async () => data; + + const result = await executeWithErrorHandling("TestTool", execution); + + expect(result.isError).toBeUndefined(); + expect(result.content[0].text).toBe(JSON.stringify(data)); + }); + + it("should handle BugsnagToolError correctly", async () => { + const error = new BugsnagToolError("Tool specific error", "TestTool"); + const execution = async () => { throw error; }; + + const result = await executeWithErrorHandling("TestTool", execution); + + expect(result.isError).toBe(true); + expect(result.content[0].text).toBe("Tool specific error"); + }); + + it("should handle generic Error correctly", async () => { + const error = new Error("Generic error"); + const execution = async () => { throw error; }; + + const result = await executeWithErrorHandling("TestTool", execution); + + expect(result.isError).toBe(true); + expect(result.content[0].text).toContain("Tool execution failed: Generic error"); + }); + + it("should handle non-Error exceptions", async () => { + const execution = async () => { throw "string error"; }; + + const result = await executeWithErrorHandling("TestTool", execution); + + expect(result.isError).toBe(true); + expect(result.content[0].text).toContain("Tool execution failed with unknown error"); + }); +}); + +describe("formatPaginatedResult", () => { + it("should format basic paginated result", () => { + const data = [{ id: 1 }, { id: 2 }]; + const result = formatPaginatedResult(data, 2); + + expect(result.data).toBe(data); + expect(result.count).toBe(2); + expect(result.total).toBeUndefined(); + expect(result.next).toBeUndefined(); + }); + + it("should include total when provided", () => { + const data = [{ id: 1 }]; + const result = formatPaginatedResult(data, 1, 10); + + expect(result.total).toBe(10); + }); + + it("should include next URL when provided", () => { + const data = [{ id: 1 }]; + const nextUrl = "https://api.example.com/next"; + const result = formatPaginatedResult(data, 1, undefined, nextUrl); + + expect(result.next).toBe(nextUrl); + }); + + it("should not include next URL when null", () => { + const data = [{ id: 1 }]; + const result = formatPaginatedResult(data, 1, undefined, null); + + expect(result.next).toBeUndefined(); + }); +}); + +describe("formatListResult", () => { + it("should format simple list result", () => { + const data = [{ name: "item1" }, { name: "item2" }]; + const result = formatListResult(data, 2); + + expect(result.data).toBe(data); + expect(result.count).toBe(2); + }); +}); + +describe("extractProjectSlugFromUrl", () => { + it("should extract project slug from valid URL", () => { + const url = "https://app.bugsnag.com/my-org/my-project/errors/123"; + const slug = extractProjectSlugFromUrl(url); + + expect(slug).toBe("my-project"); + }); + + it("should throw error for invalid URL format", () => { + const url = "https://app.bugsnag.com/my-org"; + + expect(() => extractProjectSlugFromUrl(url)).toThrow(BugsnagToolError); + }); + + it("should throw error for malformed URL", () => { + const url = "not-a-url"; + + expect(() => extractProjectSlugFromUrl(url)).toThrow(BugsnagToolError); + }); +}); + +describe("extractEventIdFromUrl", () => { + it("should extract event ID from URL with query parameters", () => { + const url = "https://app.bugsnag.com/my-org/my-project/errors/123?event_id=event-456"; + const eventId = extractEventIdFromUrl(url); + + expect(eventId).toBe("event-456"); + }); + + it("should throw error when event_id parameter is missing", () => { + const url = "https://app.bugsnag.com/my-org/my-project/errors/123"; + + expect(() => extractEventIdFromUrl(url)).toThrow(BugsnagToolError); + }); + + it("should throw error for malformed URL", () => { + const url = "not-a-url"; + + expect(() => extractEventIdFromUrl(url)).toThrow(BugsnagToolError); + }); +}); + +describe("validateUrlParameters", () => { + it("should pass validation when all required parameters are present", () => { + const url = "https://example.com?param1=value1¶m2=value2"; + const requiredParams = ["param1", "param2"]; + + expect(() => validateUrlParameters(url, requiredParams, "TestTool")).not.toThrow(); + }); + + it("should throw error when required parameter is missing", () => { + const url = "https://example.com?param1=value1"; + const requiredParams = ["param1", "param2"]; + + expect(() => validateUrlParameters(url, requiredParams, "TestTool")) + .toThrow(BugsnagToolError); + }); + + it("should throw error for malformed URL", () => { + const url = "not-a-url"; + const requiredParams = ["param1"]; + + expect(() => validateUrlParameters(url, requiredParams, "TestTool")) + .toThrow(BugsnagToolError); + }); +}); + +describe("createConditionalProjectIdParam", () => { + it("should return empty array when project API key is configured", () => { + const params = createConditionalProjectIdParam(true); + expect(params).toHaveLength(0); + }); + + it("should return project ID parameter when no project API key", () => { + const params = createConditionalProjectIdParam(false); + expect(params).toHaveLength(1); + expect(params[0].name).toBe("projectId"); + expect(params[0].required).toBe(true); + }); +}); + +describe("validateConditionalParameters", () => { + it("should pass when operation is not override_severity", () => { + const args = { operation: "fix" }; + expect(() => validateConditionalParameters(args, "TestTool")).not.toThrow(); + }); + + it("should pass when operation is override_severity and severity is provided", () => { + const args = { operation: "override_severity", severity: "warning" }; + expect(() => validateConditionalParameters(args, "TestTool")).not.toThrow(); + }); + + it("should throw error when operation is override_severity but severity is missing", () => { + const args = { operation: "override_severity" }; + expect(() => validateConditionalParameters(args, "TestTool")) + .toThrow(BugsnagToolError); + }); +}); + +describe("TOOL_DEFAULTS", () => { + it("should have expected default values", () => { + expect(TOOL_DEFAULTS.PAGE_SIZE).toBe(30); + expect(TOOL_DEFAULTS.MAX_PAGE_SIZE).toBe(100); + expect(TOOL_DEFAULTS.SORT_DIRECTION).toBe("desc"); + expect(TOOL_DEFAULTS.DEFAULT_FILTERS).toHaveProperty("event.since"); + expect(TOOL_DEFAULTS.DEFAULT_FILTERS).toHaveProperty("error.status"); + }); +}); diff --git a/src/tests/unit/bugsnag/types.test.ts b/src/tests/unit/bugsnag/types.test.ts new file mode 100644 index 00000000..9bfd6e64 --- /dev/null +++ b/src/tests/unit/bugsnag/types.test.ts @@ -0,0 +1,147 @@ +import { describe, it, expect, beforeEach } from 'vitest'; +import { + BugsnagToolRegistry, + BaseBugsnagTool, + BugsnagToolError, + ToolDefinition, + ToolExecutionContext, + ToolResult +} from '../../../bugsnag/tools/index.js'; +import { z } from 'zod'; + +// Mock tool for testing +class MockTool extends BaseBugsnagTool { + readonly name = "mock_tool"; + readonly definition: ToolDefinition = { + title: "Mock Tool", + summary: "A mock tool for testing", + purpose: "Testing the tool interface", + useCases: ["Testing"], + parameters: [ + { + name: "testParam", + type: z.string(), + required: true, + description: "A test parameter", + examples: ["test"] + } + ], + examples: [ + { + description: "Test example", + parameters: { testParam: "test" }, + expectedOutput: "Mock result" + } + ], + hints: ["This is a test tool"] + }; + + async execute(args: any, _context: ToolExecutionContext): Promise { + this.validateArgs(args); + return this.createResult({ message: `Hello ${args.testParam}` }); + } +} + +describe('BugsnagToolRegistry', () => { + let registry: BugsnagToolRegistry; + + beforeEach(() => { + registry = new BugsnagToolRegistry(); + }); + + it('should register and retrieve tools', () => { + const tool = new MockTool(); + registry.registerTool(tool); + + expect(registry.hasTool('mock_tool')).toBe(true); + expect(registry.getTool('mock_tool')).toBe(tool); + expect(registry.getToolCount()).toBe(1); + }); + + it('should prevent duplicate tool registration', () => { + const tool1 = new MockTool(); + const tool2 = new MockTool(); + + registry.registerTool(tool1); + + expect(() => registry.registerTool(tool2)).toThrow(BugsnagToolError); + }); + + it('should return all registered tools', () => { + const tool = new MockTool(); + registry.registerTool(tool); + + const allTools = registry.getAllTools(); + expect(allTools).toHaveLength(1); + expect(allTools[0]).toBe(tool); + }); + + it('should clear all tools', () => { + const tool = new MockTool(); + registry.registerTool(tool); + + expect(registry.getToolCount()).toBe(1); + + registry.clear(); + + expect(registry.getToolCount()).toBe(0); + expect(registry.hasTool('mock_tool')).toBe(false); + }); +}); + +describe('BaseBugsnagTool', () => { + let tool: MockTool; + + beforeEach(() => { + tool = new MockTool(); + }); + + it('should validate required parameters', () => { + expect(() => tool['validateArgs']({})).toThrow(BugsnagToolError); + expect(() => tool['validateArgs']({ testParam: null })).toThrow(BugsnagToolError); + expect(() => tool['validateArgs']({ testParam: "valid" })).not.toThrow(); + }); + + it('should validate parameter types', () => { + expect(() => tool['validateArgs']({ testParam: 123 })).toThrow(BugsnagToolError); + expect(() => tool['validateArgs']({ testParam: "valid" })).not.toThrow(); + }); + + it('should create successful results', () => { + const data = { message: "test" }; + const result = tool['createResult'](data); + + expect(result.content).toHaveLength(1); + expect(result.content[0].type).toBe("text"); + expect(result.content[0].text).toBe(JSON.stringify(data)); + expect(result.isError).toBeUndefined(); + }); + + it('should create error results', () => { + const message = "Test error"; + const result = tool['createErrorResult'](message); + + expect(result.content).toHaveLength(1); + expect(result.content[0].type).toBe("text"); + expect(result.content[0].text).toBe(message); + expect(result.isError).toBe(true); + }); +}); + +describe('BugsnagToolError', () => { + it('should create error with tool name', () => { + const error = new BugsnagToolError("Test message", "test_tool"); + + expect(error.message).toBe("Test message"); + expect(error.toolName).toBe("test_tool"); + expect(error.name).toBe("BugsnagToolError"); + expect(error.cause).toBeUndefined(); + }); + + it('should create error with cause', () => { + const cause = new Error("Original error"); + const error = new BugsnagToolError("Test message", "test_tool", cause); + + expect(error.cause).toBe(cause); + }); +}); diff --git a/src/tests/unit/bugsnag/update-error-tool.test.ts b/src/tests/unit/bugsnag/update-error-tool.test.ts new file mode 100644 index 00000000..acfe7e40 --- /dev/null +++ b/src/tests/unit/bugsnag/update-error-tool.test.ts @@ -0,0 +1,380 @@ +/** + * Unit tests for UpdateErrorTool + */ + +import { describe, it, expect, vi, beforeEach } from 'vitest'; +import { UpdateErrorTool } from '../../../bugsnag/tools/update-error-tool.js'; +import { SharedServices, ToolExecutionContext } from '../../../bugsnag/types.js'; +import { Project } from '../../../bugsnag/client/api/CurrentUser.js'; + +describe('UpdateErrorTool', () => { + let tool: UpdateErrorTool; + let mockServices: jest.Mocked; + let mockContext: ToolExecutionContext; + let mockGetInput: jest.Mock; + + const mockProject: Project = { + id: 'project-123', + name: 'Test Project', + slug: 'test-project' + }; + + beforeEach(() => { + mockGetInput = vi.fn(); + + mockServices = { + cts: vi.fn(), + getProject: vi.fn(), + getCurrentProject: vi.fn(), + getInputProject: vi.fn().mockResolvedValue(mockProject), + getCurrentUserApi: vi.fn(), + getErrorsApi: vi.fn(), + getProjectApi: vi.fn(), + getCache: vi.fn(), + getDashboardUrl: vi.fn(), + getErrorUrl: vi.fn(), + getProjectApiKey: vi.fn(), + hasProjectApiKey: vi.fn(), + getOrganization: vi.fn(), + getProjectEventFilters: vi.fn(), + getEvent: vi.fn(), + updateError: vi.fn(), + listBuilds: vi.fn(), + getBuild: vi.fn(), + listReleases: vi.fn(), + getRelease: vi.fn(), + listBuildsInRelease: vi.fn(), + getProjectStabilityTargets: vi.fn(), + addStabilityData: vi.fn(), + } as jest.Mocked; + + mockContext = { + services: mockServices, + getInput: mockGetInput + }; + + tool = new UpdateErrorTool(); + }); + + describe('constructor', () => { + it('should create tool with correct name and definition', () => { + expect(tool.name).toBe('update_error'); + expect(tool.definition.title).toBe('Update Error'); + expect(tool.definition.summary).toBe('Update the status of an error'); + }); + + it('should include projectId parameter when no project API key', () => { + const toolWithoutApiKey = new UpdateErrorTool(false); + const projectIdParam = toolWithoutApiKey.definition.parameters.find(p => p.name === 'projectId'); + expect(projectIdParam).toBeDefined(); + expect(projectIdParam?.required).toBe(true); + }); + + it('should not include projectId parameter when project API key is configured', () => { + const toolWithApiKey = new UpdateErrorTool(true); + const projectIdParam = toolWithApiKey.definition.parameters.find(p => p.name === 'projectId'); + expect(projectIdParam).toBeUndefined(); + }); + + it('should have correct parameter definitions', () => { + const params = tool.definition.parameters; + + // Should have projectId (since hasProjectApiKey defaults to false), errorId, and operation + expect(params).toHaveLength(3); + + const errorIdParam = params.find(p => p.name === 'errorId'); + expect(errorIdParam).toBeDefined(); + expect(errorIdParam?.required).toBe(true); + + const operationParam = params.find(p => p.name === 'operation'); + expect(operationParam).toBeDefined(); + expect(operationParam?.required).toBe(true); + }); + }); + + describe('execute', () => { + it('should update error successfully with basic operation', async () => { + mockServices.updateError.mockResolvedValue(true); + + const result = await tool.execute({ + projectId: 'project-123', + errorId: 'error-123', + operation: 'fix' + }, mockContext); + + expect(mockServices.getInputProject).toHaveBeenCalledWith('project-123'); + expect(mockServices.updateError).toHaveBeenCalledWith( + 'project-123', + 'error-123', + 'fix', + { severity: undefined } + ); + expect(result.content[0].text).toBe(JSON.stringify({ success: true })); + expect(result.isError).toBeUndefined(); + }); + + it('should update error successfully with explicit project ID', async () => { + mockServices.updateError.mockResolvedValue(true); + + const result = await tool.execute({ + projectId: 'project-456', + errorId: 'error-123', + operation: 'ignore' + }, mockContext); + + expect(mockServices.getInputProject).toHaveBeenCalledWith('project-456'); + expect(mockServices.updateError).toHaveBeenCalledWith( + 'project-123', + 'error-123', + 'ignore', + { severity: undefined } + ); + expect(result.content[0].text).toBe(JSON.stringify({ success: true })); + }); + + it('should handle all permitted operations', async () => { + const operations = ['open', 'fix', 'ignore', 'discard', 'undiscard'] as const; + mockServices.updateError.mockResolvedValue(true); + + for (const operation of operations) { + const result = await tool.execute({ + projectId: 'project-123', + errorId: 'error-123', + operation + }, mockContext); + + expect(mockServices.updateError).toHaveBeenCalledWith( + 'project-123', + 'error-123', + operation, + { severity: undefined } + ); + expect(result.content[0].text).toBe(JSON.stringify({ success: true })); + } + + expect(mockServices.updateError).toHaveBeenCalledTimes(operations.length); + }); + + it('should handle override_severity operation with user input', async () => { + mockGetInput.mockResolvedValue({ + action: 'accept', + content: { severity: 'warning' } + }); + mockServices.updateError.mockResolvedValue(true); + + const result = await tool.execute({ + projectId: 'project-123', + errorId: 'error-123', + operation: 'override_severity' + }, mockContext); + + expect(mockGetInput).toHaveBeenCalledWith({ + message: "Please provide the new severity for the error (e.g. 'info', 'warning', 'error', 'critical')", + requestedSchema: { + type: "object", + properties: { + severity: { + type: "string", + enum: ['info', 'warning', 'error'], + description: "The new severity level for the error" + } + } + }, + required: ["severity"] + }); + + expect(mockServices.updateError).toHaveBeenCalledWith( + 'project-123', + 'error-123', + 'override_severity', + { severity: 'warning' } + ); + expect(result.content[0].text).toBe(JSON.stringify({ success: true })); + }); + + it('should handle override_severity operation when user input is rejected', async () => { + mockGetInput.mockResolvedValue({ + action: 'reject' + }); + mockServices.updateError.mockResolvedValue(true); + + const result = await tool.execute({ + projectId: 'project-123', + errorId: 'error-123', + operation: 'override_severity' + }, mockContext); + + expect(mockGetInput).toHaveBeenCalled(); + expect(mockServices.updateError).toHaveBeenCalledWith( + 'project-123', + 'error-123', + 'override_severity', + { severity: undefined } + ); + expect(result.content[0].text).toBe(JSON.stringify({ success: true })); + }); + + it('should handle override_severity operation when user provides no content', async () => { + mockGetInput.mockResolvedValue({ + action: 'accept', + content: null + }); + mockServices.updateError.mockResolvedValue(true); + + const result = await tool.execute({ + projectId: 'project-123', + errorId: 'error-123', + operation: 'override_severity' + }, mockContext); + + expect(mockServices.updateError).toHaveBeenCalledWith( + 'project-123', + 'error-123', + 'override_severity', + { severity: undefined } + ); + expect(result.content[0].text).toBe(JSON.stringify({ success: true })); + }); + + it('should return false when update fails', async () => { + mockServices.updateError.mockResolvedValue(false); + + const result = await tool.execute({ + projectId: 'project-123', + errorId: 'error-123', + operation: 'fix' + }, mockContext); + + expect(result.content[0].text).toBe(JSON.stringify({ success: false })); + expect(result.isError).toBeUndefined(); + }); + + it('should throw error when errorId is missing', async () => { + const result = await tool.execute({ + projectId: 'project-123', + operation: 'fix' + } as any, mockContext); + + expect(result.isError).toBe(true); + expect(result.content[0].text).toContain('Required parameter \'errorId\' is missing'); + }); + + it('should throw error when operation is missing', async () => { + const result = await tool.execute({ + projectId: 'project-123', + errorId: 'error-123' + } as any, mockContext); + + expect(result.isError).toBe(true); + expect(result.content[0].text).toContain('Required parameter \'operation\' is missing'); + }); + + it('should throw error when errorId is empty', async () => { + const result = await tool.execute({ + projectId: 'project-123', + errorId: '', + operation: 'fix' + }, mockContext); + + expect(result.isError).toBe(true); + expect(result.content[0].text).toContain('Error ID cannot be empty'); + }); + + it('should throw error when operation is invalid', async () => { + const result = await tool.execute({ + projectId: 'project-123', + errorId: 'error-123', + operation: 'invalid_operation' + } as any, mockContext); + + expect(result.isError).toBe(true); + expect(result.content[0].text).toContain('Invalid value for parameter \'operation\''); + }); + + it('should handle project resolution errors', async () => { + mockServices.getInputProject.mockRejectedValue(new Error('Project not found')); + + const result = await tool.execute({ + projectId: 'project-123', + errorId: 'error-123', + operation: 'fix' + }, mockContext); + + expect(result.isError).toBe(true); + expect(result.content[0].text).toContain('Project not found'); + }); + + it('should handle updateError service errors', async () => { + mockServices.updateError.mockRejectedValue(new Error('API error')); + + const result = await tool.execute({ + projectId: 'project-123', + errorId: 'error-123', + operation: 'fix' + }, mockContext); + + expect(result.isError).toBe(true); + expect(result.content[0].text).toContain('API error'); + }); + + it('should handle getInput errors for override_severity', async () => { + mockGetInput.mockRejectedValue(new Error('Input error')); + + const result = await tool.execute({ + projectId: 'project-123', + errorId: 'error-123', + operation: 'override_severity' + }, mockContext); + + expect(result.isError).toBe(true); + expect(result.content[0].text).toContain('Input error'); + }); + }); + + describe('parameter validation', () => { + it('should validate required parameters', async () => { + const result = await tool.execute({}, mockContext); + + expect(result.isError).toBe(true); + expect(result.content[0].text).toContain('Required parameter'); + }); + + it('should validate parameter types', async () => { + const result = await tool.execute({ + projectId: 'project-123', + errorId: 123, // Should be string + operation: 'fix' + } as any, mockContext); + + expect(result.isError).toBe(true); + expect(result.content[0].text).toContain('Invalid value for parameter'); + }); + }); + + describe('examples and documentation', () => { + it('should have meaningful examples', () => { + const examples = tool.definition.examples; + expect(examples).toHaveLength(2); + + expect(examples[0].description).toBe('Mark an error as fixed'); + expect(examples[0].parameters.errorId).toBe('6863e2af8c857c0a5023b411'); + expect(examples[0].parameters.operation).toBe('fix'); + + expect(examples[1].description).toBe('Change error severity'); + expect(examples[1].parameters.operation).toBe('override_severity'); + }); + + it('should have helpful hints', () => { + const hints = tool.definition.hints; + expect(hints).toContain('Only use valid operations - BugSnag may reject invalid values'); + expect(hints).toContain('When using \'override_severity\', you will be prompted to provide the new severity level'); + }); + + it('should have appropriate use cases', () => { + const useCases = tool.definition.useCases; + expect(useCases).toContain('Mark an error as open, fixed or ignored'); + expect(useCases).toContain('Discard or un-discard an error'); + expect(useCases).toContain('Update the severity of an error'); + }); + }); +}); From e2ad6cb1e4fb1f00cf9ffa425d89c55382aa758f Mon Sep 17 00:00:00 2001 From: Sachin Pande Date: Fri, 19 Sep 2025 10:14:10 +0100 Subject: [PATCH 3/3] chore: remove performance monitoring logic --- src/bugsnag/client.ts | 25 +++++-------------------- src/bugsnag/tool-registry.ts | 23 +---------------------- 2 files changed, 6 insertions(+), 42 deletions(-) diff --git a/src/bugsnag/client.ts b/src/bugsnag/client.ts index dcbdbcb7..f618089e 100644 --- a/src/bugsnag/client.ts +++ b/src/bugsnag/client.ts @@ -64,26 +64,11 @@ export class BugsnagClient implements Client { } async initialize(): Promise { - const startTime = performance.now(); - - try { - // Trigger caching of org and projects through shared services - await Promise.all([ - this.sharedServices.getProjects(), - this.sharedServices.getCurrentProject() - ]); - - const initTime = performance.now() - startTime; - if (process.env.NODE_ENV === 'development' || process.env.BUGSNAG_PERFORMANCE_MONITORING === 'true') { - console.debug(`BugsnagClient initialized in ${initTime.toFixed(2)}ms`); - } - } catch (error) { - const initTime = performance.now() - startTime; - if (process.env.NODE_ENV === 'development' || process.env.BUGSNAG_PERFORMANCE_MONITORING === 'true') { - console.debug(`BugsnagClient initialization failed after ${initTime.toFixed(2)}ms:`, error); - } - throw error; - } + // Trigger caching of org and projects through shared services + await Promise.all([ + this.sharedServices.getProjects(), + this.sharedServices.getCurrentProject() + ]); } /** diff --git a/src/bugsnag/tool-registry.ts b/src/bugsnag/tool-registry.ts index 455fdf96..e3f9df62 100644 --- a/src/bugsnag/tool-registry.ts +++ b/src/bugsnag/tool-registry.ts @@ -69,7 +69,6 @@ export class BugsnagToolRegistry implements ToolRegistry { * Register all discovered tools with the MCP server */ registerAllTools(register: RegisterToolsFunction, context: ToolExecutionContext, config?: ToolDiscoveryConfig): void { - const startTime = performance.now(); const tools = this.discoverTools(config); // Clear existing tools and register discovered ones @@ -98,27 +97,12 @@ export class BugsnagToolRegistry implements ToolRegistry { outputFormat: tool.definition.outputFormat }; - // Register the tool with the MCP server with performance monitoring + // Register the tool with the MCP server register(toolParams, async (args: any) => { - const executionStartTime = performance.now(); try { const result = await tool.execute(args, context); - const executionTime = performance.now() - executionStartTime; - - // Log performance metrics for monitoring (only in development or when enabled) - if (process.env.NODE_ENV === 'development' || process.env.BUGSNAG_PERFORMANCE_MONITORING === 'true') { - console.debug(`Tool '${tool.name}' executed in ${executionTime.toFixed(2)}ms`); - } - return result; } catch (error) { - const executionTime = performance.now() - executionStartTime; - - // Log error with timing information - if (process.env.NODE_ENV === 'development' || process.env.BUGSNAG_PERFORMANCE_MONITORING === 'true') { - console.debug(`Tool '${tool.name}' failed after ${executionTime.toFixed(2)}ms:`, error); - } - if (error instanceof BugsnagToolError) { return { content: [{ type: "text", text: error.message }], @@ -140,11 +124,6 @@ export class BugsnagToolRegistry implements ToolRegistry { } }); } - - const registrationTime = performance.now() - startTime; - if (process.env.NODE_ENV === 'development' || process.env.BUGSNAG_PERFORMANCE_MONITORING === 'true') { - console.debug(`Registered ${tools.length} tools in ${registrationTime.toFixed(2)}ms`); - } } /**