diff --git a/README.md b/README.md index 419f89297..7c9ac44d3 100644 --- a/README.md +++ b/README.md @@ -720,10 +720,33 @@ The following sets of tools are available: - **Required OAuth Scopes**: `security_events` - **Accepted OAuth Scopes**: `repo`, `security_events` - `owner`: The owner of the repository. (string, required) + - `page`: Page number for pagination (min 1) (number, optional) + - `perPage`: Results per page for pagination (min 1, max 100) (number, optional) - `repo`: The name of the repository. (string, required) - `severity`: Filter dependabot alerts by severity (string, optional) - `state`: Filter dependabot alerts by state. Defaults to open (string, optional) +- **list_org_dependabot_alerts** - List org Dependabot alerts + - **Required OAuth Scopes**: `security_events` + - **Accepted OAuth Scopes**: `repo`, `security_events` + - `ecosystem`: Filter Dependabot alerts by package ecosystem (e.g. npm, pip, maven) (string, optional) + - `org`: The organization name. (string, required) + - `package`: Filter Dependabot alerts by package name (string, optional) + - `page`: Page number for pagination (min 1) (number, optional) + - `perPage`: Results per page for pagination (min 1, max 100) (number, optional) + - `severity`: Filter Dependabot alerts by severity (string, optional) + - `state`: Filter Dependabot alerts by state. Defaults to open (string, optional) + +- **update_dependabot_alert** - Update Dependabot alert + - **Required OAuth Scopes**: `security_events` + - **Accepted OAuth Scopes**: `repo`, `security_events` + - `alertNumber`: The number of the alert. (number, required) + - `dismissedComment`: An optional comment associated with dismissing the alert. (string, optional) + - `dismissedReason`: Required when state is dismissed. The reason for dismissing the alert. (string, optional) + - `owner`: The owner of the repository. (string, required) + - `repo`: The name of the repository. (string, required) + - `state`: The state to set for the alert. (string, required) +
diff --git a/pkg/github/__toolsnaps__/list_dependabot_alerts.snap b/pkg/github/__toolsnaps__/list_dependabot_alerts.snap index 83f725987..55d543779 100644 --- a/pkg/github/__toolsnaps__/list_dependabot_alerts.snap +++ b/pkg/github/__toolsnaps__/list_dependabot_alerts.snap @@ -10,6 +10,17 @@ "description": "The owner of the repository.", "type": "string" }, + "page": { + "description": "Page number for pagination (min 1)", + "minimum": 1, + "type": "number" + }, + "perPage": { + "description": "Results per page for pagination (min 1, max 100)", + "maximum": 100, + "minimum": 1, + "type": "number" + }, "repo": { "description": "The name of the repository.", "type": "string" diff --git a/pkg/github/__toolsnaps__/list_org_dependabot_alerts.snap b/pkg/github/__toolsnaps__/list_org_dependabot_alerts.snap new file mode 100644 index 000000000..fb4f59d95 --- /dev/null +++ b/pkg/github/__toolsnaps__/list_org_dependabot_alerts.snap @@ -0,0 +1,60 @@ +{ + "annotations": { + "readOnlyHint": true, + "title": "List org Dependabot alerts" + }, + "description": "List Dependabot alerts for a GitHub organization.", + "inputSchema": { + "properties": { + "ecosystem": { + "description": "Filter Dependabot alerts by package ecosystem (e.g. npm, pip, maven)", + "type": "string" + }, + "org": { + "description": "The organization name.", + "type": "string" + }, + "package": { + "description": "Filter Dependabot alerts by package name", + "type": "string" + }, + "page": { + "description": "Page number for pagination (min 1)", + "minimum": 1, + "type": "number" + }, + "perPage": { + "description": "Results per page for pagination (min 1, max 100)", + "maximum": 100, + "minimum": 1, + "type": "number" + }, + "severity": { + "description": "Filter Dependabot alerts by severity", + "enum": [ + "low", + "medium", + "high", + "critical" + ], + "type": "string" + }, + "state": { + "default": "open", + "description": "Filter Dependabot alerts by state. Defaults to open", + "enum": [ + "open", + "fixed", + "dismissed", + "auto_dismissed" + ], + "type": "string" + } + }, + "required": [ + "org" + ], + "type": "object" + }, + "name": "list_org_dependabot_alerts" +} \ No newline at end of file diff --git a/pkg/github/__toolsnaps__/update_dependabot_alert.snap b/pkg/github/__toolsnaps__/update_dependabot_alert.snap new file mode 100644 index 000000000..dda9d3210 --- /dev/null +++ b/pkg/github/__toolsnaps__/update_dependabot_alert.snap @@ -0,0 +1,53 @@ +{ + "annotations": { + "title": "Update Dependabot alert" + }, + "description": "Update the state of a Dependabot alert in a GitHub repository.", + "inputSchema": { + "properties": { + "alertNumber": { + "description": "The number of the alert.", + "type": "number" + }, + "dismissedComment": { + "description": "An optional comment associated with dismissing the alert.", + "type": "string" + }, + "dismissedReason": { + "description": "Required when state is dismissed. The reason for dismissing the alert.", + "enum": [ + "fix_started", + "inaccurate", + "no_bandwidth", + "not_used", + "tolerable_risk" + ], + "type": "string" + }, + "owner": { + "description": "The owner of the repository.", + "type": "string" + }, + "repo": { + "description": "The name of the repository.", + "type": "string" + }, + "state": { + "description": "The state to set for the alert.", + "enum": [ + "open", + "dismissed" + ], + "type": "string" + } + }, + "required": [ + "owner", + "repo", + "alertNumber", + "state" + ], + "type": "object" + }, + "name": "update_dependabot_alert" +} \ No newline at end of file diff --git a/pkg/github/dependabot.go b/pkg/github/dependabot.go index 6f0da1b20..657989c17 100644 --- a/pkg/github/dependabot.go +++ b/pkg/github/dependabot.go @@ -94,6 +94,222 @@ func GetDependabotAlert(t translations.TranslationHelperFunc) inventory.ServerTo ) } +func ListOrgDependabotAlerts(t translations.TranslationHelperFunc) inventory.ServerTool { + return NewTool( + ToolsetMetadataDependabot, + mcp.Tool{ + Name: "list_org_dependabot_alerts", + Description: t("TOOL_LIST_ORG_DEPENDABOT_ALERTS_DESCRIPTION", "List Dependabot alerts for a GitHub organization."), + Annotations: &mcp.ToolAnnotations{ + Title: t("TOOL_LIST_ORG_DEPENDABOT_ALERTS_USER_TITLE", "List org Dependabot alerts"), + ReadOnlyHint: true, + }, + InputSchema: WithPagination(&jsonschema.Schema{ + Type: "object", + Properties: map[string]*jsonschema.Schema{ + "org": { + Type: "string", + Description: "The organization name.", + }, + "state": { + Type: "string", + Description: "Filter Dependabot alerts by state. Defaults to open", + Enum: []any{"open", "fixed", "dismissed", "auto_dismissed"}, + Default: json.RawMessage(`"open"`), + }, + "severity": { + Type: "string", + Description: "Filter Dependabot alerts by severity", + Enum: []any{"low", "medium", "high", "critical"}, + }, + "ecosystem": { + Type: "string", + Description: "Filter Dependabot alerts by package ecosystem (e.g. npm, pip, maven)", + }, + "package": { + Type: "string", + Description: "Filter Dependabot alerts by package name", + }, + }, + Required: []string{"org"}, + }), + }, + []scopes.Scope{scopes.SecurityEvents}, + func(ctx context.Context, deps ToolDependencies, _ *mcp.CallToolRequest, args map[string]any) (*mcp.CallToolResult, any, error) { + org, err := RequiredParam[string](args, "org") + if err != nil { + return utils.NewToolResultError(err.Error()), nil, nil + } + state, err := OptionalParam[string](args, "state") + if err != nil { + return utils.NewToolResultError(err.Error()), nil, nil + } + severity, err := OptionalParam[string](args, "severity") + if err != nil { + return utils.NewToolResultError(err.Error()), nil, nil + } + ecosystem, err := OptionalParam[string](args, "ecosystem") + if err != nil { + return utils.NewToolResultError(err.Error()), nil, nil + } + pkg, err := OptionalParam[string](args, "package") + if err != nil { + return utils.NewToolResultError(err.Error()), nil, nil + } + pagination, err := OptionalPaginationParams(args) + if err != nil { + return utils.NewToolResultError(err.Error()), nil, nil + } + + client, err := deps.GetClient(ctx) + if err != nil { + return utils.NewToolResultErrorFromErr("failed to get GitHub client", err), nil, err + } + + alerts, resp, err := client.Dependabot.ListOrgAlerts(ctx, org, &github.ListAlertsOptions{ + State: ToStringPtr(state), + Severity: ToStringPtr(severity), + Ecosystem: ToStringPtr(ecosystem), + Package: ToStringPtr(pkg), + ListOptions: github.ListOptions{ + Page: pagination.Page, + PerPage: pagination.PerPage, + }, + }) + if err != nil { + return ghErrors.NewGitHubAPIErrorResponse(ctx, + fmt.Sprintf("failed to list alerts for organisation '%s'", org), + resp, + err, + ), nil, nil + } + defer func() { _ = resp.Body.Close() }() + + if resp.StatusCode != http.StatusOK { + body, err := io.ReadAll(resp.Body) + if err != nil { + return utils.NewToolResultErrorFromErr("failed to read response body", err), nil, err + } + return ghErrors.NewGitHubAPIStatusErrorResponse(ctx, "failed to list alerts", resp, body), nil, nil + } + + r, err := json.Marshal(alerts) + if err != nil { + return utils.NewToolResultErrorFromErr("failed to marshal alerts", err), nil, err + } + + return utils.NewToolResultText(string(r)), nil, nil + }, + ) +} + +func UpdateDependabotAlert(t translations.TranslationHelperFunc) inventory.ServerTool { + return NewTool( + ToolsetMetadataDependabot, + mcp.Tool{ + Name: "update_dependabot_alert", + Description: t("TOOL_UPDATE_DEPENDABOT_ALERT_DESCRIPTION", "Update the state of a Dependabot alert in a GitHub repository."), + Annotations: &mcp.ToolAnnotations{ + Title: t("TOOL_UPDATE_DEPENDABOT_ALERT_USER_TITLE", "Update Dependabot alert"), + ReadOnlyHint: false, + }, + InputSchema: &jsonschema.Schema{ + Type: "object", + Properties: map[string]*jsonschema.Schema{ + "owner": { + Type: "string", + Description: "The owner of the repository.", + }, + "repo": { + Type: "string", + Description: "The name of the repository.", + }, + "alertNumber": { + Type: "number", + Description: "The number of the alert.", + }, + "state": { + Type: "string", + Description: "The state to set for the alert.", + Enum: []any{"open", "dismissed"}, + }, + "dismissedReason": { + Type: "string", + Description: "Required when state is dismissed. The reason for dismissing the alert.", + Enum: []any{"fix_started", "inaccurate", "no_bandwidth", "not_used", "tolerable_risk"}, + }, + "dismissedComment": { + Type: "string", + Description: "An optional comment associated with dismissing the alert.", + }, + }, + Required: []string{"owner", "repo", "alertNumber", "state"}, + }, + }, + []scopes.Scope{scopes.SecurityEvents}, + func(ctx context.Context, deps ToolDependencies, _ *mcp.CallToolRequest, args map[string]any) (*mcp.CallToolResult, any, error) { + owner, err := RequiredParam[string](args, "owner") + if err != nil { + return utils.NewToolResultError(err.Error()), nil, nil + } + repo, err := RequiredParam[string](args, "repo") + if err != nil { + return utils.NewToolResultError(err.Error()), nil, nil + } + alertNumber, err := RequiredInt(args, "alertNumber") + if err != nil { + return utils.NewToolResultError(err.Error()), nil, nil + } + state, err := RequiredParam[string](args, "state") + if err != nil { + return utils.NewToolResultError(err.Error()), nil, nil + } + dismissedReason, err := OptionalParam[string](args, "dismissedReason") + if err != nil { + return utils.NewToolResultError(err.Error()), nil, nil + } + dismissedComment, err := OptionalParam[string](args, "dismissedComment") + if err != nil { + return utils.NewToolResultError(err.Error()), nil, nil + } + + client, err := deps.GetClient(ctx) + if err != nil { + return utils.NewToolResultErrorFromErr("failed to get GitHub client", err), nil, err + } + + alert, resp, err := client.Dependabot.UpdateAlert(ctx, owner, repo, alertNumber, &github.DependabotAlertState{ + State: state, + DismissedReason: ToStringPtr(dismissedReason), + DismissedComment: ToStringPtr(dismissedComment), + }) + if err != nil { + return ghErrors.NewGitHubAPIErrorResponse(ctx, + fmt.Sprintf("failed to update alert with number '%d'", alertNumber), + resp, + err, + ), nil, nil + } + defer func() { _ = resp.Body.Close() }() + + if resp.StatusCode != http.StatusOK { + body, err := io.ReadAll(resp.Body) + if err != nil { + return utils.NewToolResultErrorFromErr("failed to read response body", err), nil, err + } + return ghErrors.NewGitHubAPIStatusErrorResponse(ctx, "failed to update alert", resp, body), nil, nil + } + + r, err := json.Marshal(alert) + if err != nil { + return utils.NewToolResultErrorFromErr("failed to marshal alert", err), nil, err + } + + return utils.NewToolResultText(string(r)), nil, nil + }, + ) +} + func ListDependabotAlerts(t translations.TranslationHelperFunc) inventory.ServerTool { return NewTool( ToolsetMetadataDependabot, @@ -104,7 +320,7 @@ func ListDependabotAlerts(t translations.TranslationHelperFunc) inventory.Server Title: t("TOOL_LIST_DEPENDABOT_ALERTS_USER_TITLE", "List dependabot alerts"), ReadOnlyHint: true, }, - InputSchema: &jsonschema.Schema{ + InputSchema: WithPagination(&jsonschema.Schema{ Type: "object", Properties: map[string]*jsonschema.Schema{ "owner": { @@ -128,7 +344,7 @@ func ListDependabotAlerts(t translations.TranslationHelperFunc) inventory.Server }, }, Required: []string{"owner", "repo"}, - }, + }), }, []scopes.Scope{scopes.SecurityEvents}, func(ctx context.Context, deps ToolDependencies, _ *mcp.CallToolRequest, args map[string]any) (*mcp.CallToolResult, any, error) { @@ -148,6 +364,10 @@ func ListDependabotAlerts(t translations.TranslationHelperFunc) inventory.Server if err != nil { return utils.NewToolResultError(err.Error()), nil, nil } + pagination, err := OptionalPaginationParams(args) + if err != nil { + return utils.NewToolResultError(err.Error()), nil, nil + } client, err := deps.GetClient(ctx) if err != nil { @@ -157,6 +377,10 @@ func ListDependabotAlerts(t translations.TranslationHelperFunc) inventory.Server alerts, resp, err := client.Dependabot.ListRepoAlerts(ctx, owner, repo, &github.ListAlertsOptions{ State: ToStringPtr(state), Severity: ToStringPtr(severity), + ListOptions: github.ListOptions{ + Page: pagination.Page, + PerPage: pagination.PerPage, + }, }) if err != nil { return ghErrors.NewGitHubAPIErrorResponse(ctx, diff --git a/pkg/github/dependabot_test.go b/pkg/github/dependabot_test.go index e20d2668f..3b113417a 100644 --- a/pkg/github/dependabot_test.go +++ b/pkg/github/dependabot_test.go @@ -109,6 +109,150 @@ func Test_GetDependabotAlert(t *testing.T) { } } +func Test_UpdateDependabotAlert(t *testing.T) { + // Verify tool definition + toolDef := UpdateDependabotAlert(translations.NullTranslationHelper) + tool := toolDef.Tool + require.NoError(t, toolsnaps.Test(tool.Name, tool)) + + assert.Equal(t, "update_dependabot_alert", tool.Name) + assert.NotEmpty(t, tool.Description) + assert.False(t, tool.Annotations.ReadOnlyHint, "update_dependabot_alert tool should not be read-only") + + mockDismissedAlert := &github.DependabotAlert{ + Number: github.Ptr(42), + State: github.Ptr("dismissed"), + DismissedReason: github.Ptr("tolerable_risk"), + DismissedComment: github.Ptr(""), + HTMLURL: github.Ptr("https://github.com/owner/repo/security/dependabot/42"), + } + mockOpenAlert := &github.DependabotAlert{ + Number: github.Ptr(42), + State: github.Ptr("open"), + HTMLURL: github.Ptr("https://github.com/owner/repo/security/dependabot/42"), + } + mockDismissedAlertWithComment := &github.DependabotAlert{ + Number: github.Ptr(42), + State: github.Ptr("dismissed"), + DismissedReason: github.Ptr("tolerable_risk"), + DismissedComment: github.Ptr("Acceptable risk in this context"), + HTMLURL: github.Ptr("https://github.com/owner/repo/security/dependabot/42"), + } + + tests := []struct { + name string + mockedClient *http.Client + requestArgs map[string]any + expectError bool + expectedAlert *github.DependabotAlert + expectedErrMsg string + }{ + { + name: "successfully dismiss alert", + mockedClient: MockHTTPClientWithHandlers(map[string]http.HandlerFunc{ + PatchReposDependabotAlertsByOwnerByRepoByAlertNumber: expectRequestBody(t, map[string]any{ + "state": "dismissed", + "dismissed_reason": "tolerable_risk", + }).andThen(mockResponse(t, http.StatusOK, mockDismissedAlert)), + }), + requestArgs: map[string]any{ + "owner": "owner", + "repo": "repo", + "alertNumber": float64(42), + "state": "dismissed", + "dismissedReason": "tolerable_risk", + }, + expectError: false, + expectedAlert: mockDismissedAlert, + }, + { + name: "successfully reopen alert", + mockedClient: MockHTTPClientWithHandlers(map[string]http.HandlerFunc{ + PatchReposDependabotAlertsByOwnerByRepoByAlertNumber: expectRequestBody(t, map[string]any{ + "state": "open", + }).andThen(mockResponse(t, http.StatusOK, mockOpenAlert)), + }), + requestArgs: map[string]any{ + "owner": "owner", + "repo": "repo", + "alertNumber": float64(42), + "state": "open", + }, + expectError: false, + expectedAlert: mockOpenAlert, + }, + { + name: "dismiss alert with comment", + mockedClient: MockHTTPClientWithHandlers(map[string]http.HandlerFunc{ + PatchReposDependabotAlertsByOwnerByRepoByAlertNumber: expectRequestBody(t, map[string]any{ + "state": "dismissed", + "dismissed_reason": "tolerable_risk", + "dismissed_comment": "Acceptable risk in this context", + }).andThen(mockResponse(t, http.StatusOK, mockDismissedAlertWithComment)), + }), + requestArgs: map[string]any{ + "owner": "owner", + "repo": "repo", + "alertNumber": float64(42), + "state": "dismissed", + "dismissedReason": "tolerable_risk", + "dismissedComment": "Acceptable risk in this context", + }, + expectError: false, + expectedAlert: mockDismissedAlertWithComment, + }, + { + name: "failed request", + mockedClient: MockHTTPClientWithHandlers(map[string]http.HandlerFunc{ + PatchReposDependabotAlertsByOwnerByRepoByAlertNumber: http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) { + w.WriteHeader(http.StatusForbidden) + _, _ = w.Write([]byte(`{"message": "Forbidden"}`)) + }), + }), + requestArgs: map[string]any{ + "owner": "owner", + "repo": "repo", + "alertNumber": float64(42), + "state": "dismissed", + }, + expectError: true, + expectedErrMsg: "failed to update alert", + }, + } + + for _, tc := range tests { + t.Run(tc.name, func(t *testing.T) { + client := github.NewClient(tc.mockedClient) + deps := BaseDeps{Client: client} + handler := toolDef.Handler(deps) + + request := createMCPRequest(tc.requestArgs) + + result, err := handler(ContextWithDeps(context.Background(), deps), &request) + + if tc.expectError { + require.NoError(t, err) + require.True(t, result.IsError) + errorContent := getErrorResult(t, result) + assert.Contains(t, errorContent.Text, tc.expectedErrMsg) + return + } + + require.NoError(t, err) + require.False(t, result.IsError) + + textContent := getTextResult(t, result) + + var returnedAlert github.DependabotAlert + err = json.Unmarshal([]byte(textContent.Text), &returnedAlert) + assert.NoError(t, err) + assert.Equal(t, *tc.expectedAlert.Number, *returnedAlert.Number) + assert.Equal(t, *tc.expectedAlert.State, *returnedAlert.State) + assert.Equal(t, *tc.expectedAlert.HTMLURL, *returnedAlert.HTMLURL) + }) + } +} + func Test_ListDependabotAlerts(t *testing.T) { // Verify tool definition once toolDef := ListDependabotAlerts(translations.NullTranslationHelper) @@ -149,7 +293,9 @@ func Test_ListDependabotAlerts(t *testing.T) { name: "successful open alerts listing", mockedClient: MockHTTPClientWithHandlers(map[string]http.HandlerFunc{ GetReposDependabotAlertsByOwnerByRepo: expectQueryParams(t, map[string]string{ - "state": "open", + "state": "open", + "page": "1", + "per_page": "30", }).andThen( mockResponse(t, http.StatusOK, []*github.DependabotAlert{&criticalAlert}), ), @@ -167,6 +313,8 @@ func Test_ListDependabotAlerts(t *testing.T) { mockedClient: MockHTTPClientWithHandlers(map[string]http.HandlerFunc{ GetReposDependabotAlertsByOwnerByRepo: expectQueryParams(t, map[string]string{ "severity": "high", + "page": "1", + "per_page": "30", }).andThen( mockResponse(t, http.StatusOK, []*github.DependabotAlert{&highSeverityAlert}), ), @@ -182,7 +330,10 @@ func Test_ListDependabotAlerts(t *testing.T) { { name: "successful all alerts listing", mockedClient: MockHTTPClientWithHandlers(map[string]http.HandlerFunc{ - GetReposDependabotAlertsByOwnerByRepo: expectQueryParams(t, map[string]string{}).andThen( + GetReposDependabotAlertsByOwnerByRepo: expectQueryParams(t, map[string]string{ + "page": "1", + "per_page": "30", + }).andThen( mockResponse(t, http.StatusOK, []*github.DependabotAlert{&criticalAlert, &highSeverityAlert}), ), }), @@ -250,3 +401,149 @@ func Test_ListDependabotAlerts(t *testing.T) { }) } } + +func Test_ListOrgDependabotAlerts(t *testing.T) { + // Verify tool definition once + toolDef := ListOrgDependabotAlerts(translations.NullTranslationHelper) + tool := toolDef.Tool + require.NoError(t, toolsnaps.Test(tool.Name, tool)) + + assert.Equal(t, "list_org_dependabot_alerts", tool.Name) + assert.NotEmpty(t, tool.Description) + assert.True(t, tool.Annotations.ReadOnlyHint, "list_org_dependabot_alerts tool should be read-only") + + // Setup mock alerts for success case + criticalAlert := github.DependabotAlert{ + Number: github.Ptr(1), + HTMLURL: github.Ptr("https://github.com/myorg/repo1/security/dependabot/1"), + State: github.Ptr("open"), + SecurityAdvisory: &github.DependabotSecurityAdvisory{ + Severity: github.Ptr("critical"), + }, + } + highSeverityAlert := github.DependabotAlert{ + Number: github.Ptr(2), + HTMLURL: github.Ptr("https://github.com/myorg/repo2/security/dependabot/2"), + State: github.Ptr("open"), + SecurityAdvisory: &github.DependabotSecurityAdvisory{ + Severity: github.Ptr("high"), + }, + } + + tests := []struct { + name string + mockedClient *http.Client + requestArgs map[string]any + expectError bool + expectedAlerts []*github.DependabotAlert + expectedErrMsg string + }{ + { + name: "successful listing with state filter", + mockedClient: MockHTTPClientWithHandlers(map[string]http.HandlerFunc{ + GetOrgsDependabotAlertsByOrg: expectQueryParams(t, map[string]string{ + "state": "open", + "page": "1", + "per_page": "30", + }).andThen( + mockResponse(t, http.StatusOK, []*github.DependabotAlert{&criticalAlert, &highSeverityAlert}), + ), + }), + requestArgs: map[string]any{ + "org": "myorg", + "state": "open", + }, + expectError: false, + expectedAlerts: []*github.DependabotAlert{&criticalAlert, &highSeverityAlert}, + }, + { + name: "successful listing with severity filter", + mockedClient: MockHTTPClientWithHandlers(map[string]http.HandlerFunc{ + GetOrgsDependabotAlertsByOrg: expectQueryParams(t, map[string]string{ + "severity": "critical", + "page": "1", + "per_page": "30", + }).andThen( + mockResponse(t, http.StatusOK, []*github.DependabotAlert{&criticalAlert}), + ), + }), + requestArgs: map[string]any{ + "org": "myorg", + "severity": "critical", + }, + expectError: false, + expectedAlerts: []*github.DependabotAlert{&criticalAlert}, + }, + { + name: "successful listing with ecosystem filter", + mockedClient: MockHTTPClientWithHandlers(map[string]http.HandlerFunc{ + GetOrgsDependabotAlertsByOrg: expectQueryParams(t, map[string]string{ + "ecosystem": "npm", + "page": "1", + "per_page": "30", + }).andThen( + mockResponse(t, http.StatusOK, []*github.DependabotAlert{&highSeverityAlert}), + ), + }), + requestArgs: map[string]any{ + "org": "myorg", + "ecosystem": "npm", + }, + expectError: false, + expectedAlerts: []*github.DependabotAlert{&highSeverityAlert}, + }, + { + name: "alerts listing fails", + mockedClient: MockHTTPClientWithHandlers(map[string]http.HandlerFunc{ + GetOrgsDependabotAlertsByOrg: http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) { + w.WriteHeader(http.StatusUnauthorized) + _, _ = w.Write([]byte(`{"message": "Unauthorized access"}`)) + }), + }), + requestArgs: map[string]any{ + "org": "myorg", + }, + expectError: true, + expectedErrMsg: "failed to list alerts", + }, + } + + for _, tc := range tests { + t.Run(tc.name, func(t *testing.T) { + client := github.NewClient(tc.mockedClient) + deps := BaseDeps{Client: client} + handler := toolDef.Handler(deps) + + request := createMCPRequest(tc.requestArgs) + + result, err := handler(ContextWithDeps(context.Background(), deps), &request) + + if tc.expectError { + require.NoError(t, err) + require.True(t, result.IsError) + errorContent := getErrorResult(t, result) + assert.Contains(t, errorContent.Text, tc.expectedErrMsg) + return + } + + require.NoError(t, err) + require.False(t, result.IsError) + + textContent := getTextResult(t, result) + + var returnedAlerts []*github.DependabotAlert + err = json.Unmarshal([]byte(textContent.Text), &returnedAlerts) + assert.NoError(t, err) + assert.Len(t, returnedAlerts, len(tc.expectedAlerts)) + for i, alert := range returnedAlerts { + assert.Equal(t, *tc.expectedAlerts[i].Number, *alert.Number) + assert.Equal(t, *tc.expectedAlerts[i].HTMLURL, *alert.HTMLURL) + assert.Equal(t, *tc.expectedAlerts[i].State, *alert.State) + if tc.expectedAlerts[i].SecurityAdvisory != nil && tc.expectedAlerts[i].SecurityAdvisory.Severity != nil && + alert.SecurityAdvisory != nil && alert.SecurityAdvisory.Severity != nil { + assert.Equal(t, *tc.expectedAlerts[i].SecurityAdvisory.Severity, *alert.SecurityAdvisory.Severity) + } + } + }) + } +} diff --git a/pkg/github/helper_test.go b/pkg/github/helper_test.go index ff752f5f3..2f9607bf1 100644 --- a/pkg/github/helper_test.go +++ b/pkg/github/helper_test.go @@ -107,8 +107,10 @@ const ( GetReposSecretScanningAlertsByOwnerByRepoByAlertNumber = "GET /repos/{owner}/{repo}/secret-scanning/alerts/{alert_number}" //nolint:gosec // False positive - this is an API endpoint pattern, not a credential // Dependabot endpoints - GetReposDependabotAlertsByOwnerByRepo = "GET /repos/{owner}/{repo}/dependabot/alerts" - GetReposDependabotAlertsByOwnerByRepoByAlertNumber = "GET /repos/{owner}/{repo}/dependabot/alerts/{alert_number}" + GetReposDependabotAlertsByOwnerByRepo = "GET /repos/{owner}/{repo}/dependabot/alerts" + GetReposDependabotAlertsByOwnerByRepoByAlertNumber = "GET /repos/{owner}/{repo}/dependabot/alerts/{alert_number}" + PatchReposDependabotAlertsByOwnerByRepoByAlertNumber = "PATCH /repos/{owner}/{repo}/dependabot/alerts/{alert_number}" + GetOrgsDependabotAlertsByOrg = "GET /orgs/{org}/dependabot/alerts" // Security advisories endpoints GetAdvisories = "GET /advisories" diff --git a/pkg/github/tools.go b/pkg/github/tools.go index 3f1c291a7..a44cd8a96 100644 --- a/pkg/github/tools.go +++ b/pkg/github/tools.go @@ -231,6 +231,8 @@ func AllTools(t translations.TranslationHelperFunc) []inventory.ServerTool { // Dependabot tools GetDependabotAlert(t), ListDependabotAlerts(t), + ListOrgDependabotAlerts(t), + UpdateDependabotAlert(t), // Notification tools ListNotifications(t),