From 94c7455c6b2125b93c02d7f6591d31daa822cfa9 Mon Sep 17 00:00:00 2001 From: Matt Holloway Date: Tue, 16 Dec 2025 16:20:47 +0000 Subject: [PATCH 1/5] add ff saupport and consolidated actions toolsets --- README.md | 43 ++ cmd/github-mcp-server/main.go | 12 +- pkg/github/actions.go | 1001 ++++++++++++++++++++++++++- pkg/github/tools.go | 5 + pkg/github/tools_validation_test.go | 11 +- 5 files changed, 1053 insertions(+), 19 deletions(-) diff --git a/README.md b/README.md index 0e96526f7..8c37ab1a7 100644 --- a/README.md +++ b/README.md @@ -490,6 +490,40 @@ The following sets of tools are available: Actions +- **actions_get** - Get details of GitHub Actions resources (workflows, workflow runs, jobs, and artifacts) + - `method`: The method to execute (string, required) + - `owner`: Repository owner (string, required) + - `repo`: Repository name (string, required) + - `resource_id`: The unique identifier of the resource. This will vary based on the "method" provided, so ensure you provide the correct ID: + - Provide a workflow ID or workflow file name (e.g. ci.yaml) for 'get_workflow' method. + - Provide a workflow run ID for 'get_workflow_run', 'get_workflow_run_usage', and 'get_workflow_run_logs_url' methods. + - Provide an artifact ID for 'download_workflow_run_artifact' method. + - Provide a job ID for 'get_workflow_job' method. + (string, required) + +- **actions_list** - List GitHub Actions workflows in a repository + - `method`: The action to perform (string, required) + - `owner`: Repository owner (string, required) + - `page`: Page number for pagination (default: 1) (number, optional) + - `per_page`: Results per page for pagination (default: 30, max: 100) (number, optional) + - `repo`: Repository name (string, required) + - `resource_id`: The unique identifier of the resource. This will vary based on the "method" provided, so ensure you provide the correct ID: + - Do not provide any resource ID for 'list_workflows' method. + - Provide a workflow ID or workflow file name (e.g. ci.yaml) for 'list_workflow_runs' method. + - Provide a workflow run ID for 'list_workflow_jobs' and 'list_workflow_run_artifacts' methods. + (string, optional) + - `workflow_jobs_filter`: Filters for workflow jobs. **ONLY** used when method is 'list_workflow_jobs' (object, optional) + - `workflow_runs_filter`: Filters for workflow runs. **ONLY** used when method is 'list_workflow_runs' (object, optional) + +- **actions_run_trigger** - Trigger GitHub Actions workflow actions + - `inputs`: Inputs the workflow accepts. Only used for 'run_workflow' method. (object, optional) + - `method`: The method to execute (string, required) + - `owner`: Repository owner (string, required) + - `ref`: The git reference for the workflow. The reference can be a branch or tag name. Required for 'run_workflow' method. (string, optional) + - `repo`: Repository name (string, required) + - `run_id`: The ID of the workflow run. Required for all methods except 'run_workflow'. (number, optional) + - `workflow_id`: The workflow ID (numeric) or workflow file name (e.g., main.yml, ci.yaml). Required for 'run_workflow' method. (string, optional) + - **cancel_workflow_run** - Cancel workflow run - `owner`: Repository owner (string, required) - `repo`: Repository name (string, required) @@ -514,6 +548,15 @@ The following sets of tools are available: - `run_id`: Workflow run ID (required when using failed_only) (number, optional) - `tail_lines`: Number of lines to return from the end of the log (number, optional) +- **get_job_logs** - Get GitHub Actions workflow job logs + - `failed_only`: When true, gets logs for all failed jobs in the workflow run specified by run_id. Requires run_id to be provided. (boolean, optional) + - `job_id`: The unique identifier of the workflow job. Required when getting logs for a single job. (number, optional) + - `owner`: Repository owner (string, required) + - `repo`: Repository name (string, required) + - `return_content`: Returns actual log content instead of URLs (boolean, optional) + - `run_id`: The unique identifier of the workflow run. Required when failed_only is true to get logs for all failed jobs in the run. (number, optional) + - `tail_lines`: Number of lines to return from the end of the log (number, optional) + - **get_workflow_run** - Get workflow run - `owner`: Repository owner (string, required) - `repo`: Repository name (string, required) diff --git a/cmd/github-mcp-server/main.go b/cmd/github-mcp-server/main.go index 034b0e238..cfb68be4e 100644 --- a/cmd/github-mcp-server/main.go +++ b/cmd/github-mcp-server/main.go @@ -54,14 +54,18 @@ var ( // Parse tools (similar to toolsets) var enabledTools []string - if err := viper.UnmarshalKey("tools", &enabledTools); err != nil { - return fmt.Errorf("failed to unmarshal tools: %w", err) + if viper.IsSet("tools") { + if err := viper.UnmarshalKey("tools", &enabledTools); err != nil { + return fmt.Errorf("failed to unmarshal tools: %w", err) + } } // Parse enabled features (similar to toolsets) var enabledFeatures []string - if err := viper.UnmarshalKey("features", &enabledFeatures); err != nil { - return fmt.Errorf("failed to unmarshal features: %w", err) + if viper.IsSet("features") { + if err := viper.UnmarshalKey("features", &enabledFeatures); err != nil { + return fmt.Errorf("failed to unmarshal features: %w", err) + } } ttl := viper.GetDuration("repo-access-cache-ttl") diff --git a/pkg/github/actions.go b/pkg/github/actions.go index 7b43f69ce..541547635 100644 --- a/pkg/github/actions.go +++ b/pkg/github/actions.go @@ -3,6 +3,7 @@ package github import ( "context" "encoding/json" + "errors" "fmt" "net/http" "strconv" @@ -24,9 +25,32 @@ const ( DescriptionRepositoryName = "Repository name" ) +// FeatureFlagConsolidatedActions is the feature flag that disables individual actions tools +// in favor of the consolidated actions tools. +const FeatureFlagConsolidatedActions = "remote_mcp_consolidated_actions" + +// Method constants for consolidated actions tools +const ( + actionsMethodListWorkflows = "list_workflows" + actionsMethodListWorkflowRuns = "list_workflow_runs" + actionsMethodListWorkflowJobs = "list_workflow_jobs" + actionsMethodListWorkflowArtifacts = "list_workflow_run_artifacts" + actionsMethodGetWorkflow = "get_workflow" + actionsMethodGetWorkflowRun = "get_workflow_run" + actionsMethodGetWorkflowJob = "get_workflow_job" + actionsMethodGetWorkflowRunUsage = "get_workflow_run_usage" + actionsMethodGetWorkflowRunLogsURL = "get_workflow_run_logs_url" + actionsMethodDownloadWorkflowArtifact = "download_workflow_run_artifact" + actionsMethodRunWorkflow = "run_workflow" + actionsMethodRerunWorkflowRun = "rerun_workflow_run" + actionsMethodRerunFailedJobs = "rerun_failed_jobs" + actionsMethodCancelWorkflowRun = "cancel_workflow_run" + actionsMethodDeleteWorkflowRunLogs = "delete_workflow_run_logs" +) + // ListWorkflows creates a tool to list workflows in a repository func ListWorkflows(t translations.TranslationHelperFunc) inventory.ServerTool { - return NewTool( + tool := NewTool( ToolsetMetadataActions, mcp.Tool{ Name: "list_workflows", @@ -93,11 +117,13 @@ func ListWorkflows(t translations.TranslationHelperFunc) inventory.ServerTool { } }, ) + tool.FeatureFlagDisable = FeatureFlagConsolidatedActions + return tool } // ListWorkflowRuns creates a tool to list workflow runs for a specific workflow func ListWorkflowRuns(t translations.TranslationHelperFunc) inventory.ServerTool { - return NewTool( + tool := NewTool( ToolsetMetadataActions, mcp.Tool{ Name: "list_workflow_runs", @@ -247,11 +273,13 @@ func ListWorkflowRuns(t translations.TranslationHelperFunc) inventory.ServerTool } }, ) + tool.FeatureFlagDisable = FeatureFlagConsolidatedActions + return tool } // RunWorkflow creates a tool to run an Actions workflow func RunWorkflow(t translations.TranslationHelperFunc) inventory.ServerTool { - return NewTool( + tool := NewTool( ToolsetMetadataActions, mcp.Tool{ Name: "run_workflow", @@ -359,11 +387,13 @@ func RunWorkflow(t translations.TranslationHelperFunc) inventory.ServerTool { } }, ) + tool.FeatureFlagDisable = FeatureFlagConsolidatedActions + return tool } // GetWorkflowRun creates a tool to get details of a specific workflow run func GetWorkflowRun(t translations.TranslationHelperFunc) inventory.ServerTool { - return NewTool( + tool := NewTool( ToolsetMetadataActions, mcp.Tool{ Name: "get_workflow_run", @@ -427,11 +457,13 @@ func GetWorkflowRun(t translations.TranslationHelperFunc) inventory.ServerTool { } }, ) + tool.FeatureFlagDisable = FeatureFlagConsolidatedActions + return tool } // GetWorkflowRunLogs creates a tool to download logs for a specific workflow run func GetWorkflowRunLogs(t translations.TranslationHelperFunc) inventory.ServerTool { - return NewTool( + tool := NewTool( ToolsetMetadataActions, mcp.Tool{ Name: "get_workflow_run_logs", @@ -505,11 +537,13 @@ func GetWorkflowRunLogs(t translations.TranslationHelperFunc) inventory.ServerTo } }, ) + tool.FeatureFlagDisable = FeatureFlagConsolidatedActions + return tool } // ListWorkflowJobs creates a tool to list jobs for a specific workflow run func ListWorkflowJobs(t translations.TranslationHelperFunc) inventory.ServerTool { - return NewTool( + tool := NewTool( ToolsetMetadataActions, mcp.Tool{ Name: "list_workflow_jobs", @@ -605,11 +639,13 @@ func ListWorkflowJobs(t translations.TranslationHelperFunc) inventory.ServerTool } }, ) + tool.FeatureFlagDisable = FeatureFlagConsolidatedActions + return tool } // GetJobLogs creates a tool to download logs for a specific workflow job or efficiently get all failed job logs for a workflow run func GetJobLogs(t translations.TranslationHelperFunc) inventory.ServerTool { - return NewTool( + tool := NewTool( ToolsetMetadataActions, mcp.Tool{ Name: "get_job_logs", @@ -716,6 +752,8 @@ func GetJobLogs(t translations.TranslationHelperFunc) inventory.ServerTool { } }, ) + tool.FeatureFlagDisable = FeatureFlagConsolidatedActions + return tool } // handleFailedJobLogs gets logs for all failed jobs in a workflow run @@ -874,7 +912,7 @@ func downloadLogContent(ctx context.Context, logURL string, tailLines int, maxLi // RerunWorkflowRun creates a tool to re-run an entire workflow run func RerunWorkflowRun(t translations.TranslationHelperFunc) inventory.ServerTool { - return NewTool( + tool := NewTool( ToolsetMetadataActions, mcp.Tool{ Name: "rerun_workflow_run", @@ -945,11 +983,13 @@ func RerunWorkflowRun(t translations.TranslationHelperFunc) inventory.ServerTool } }, ) + tool.FeatureFlagDisable = FeatureFlagConsolidatedActions + return tool } // RerunFailedJobs creates a tool to re-run only the failed jobs in a workflow run func RerunFailedJobs(t translations.TranslationHelperFunc) inventory.ServerTool { - return NewTool( + tool := NewTool( ToolsetMetadataActions, mcp.Tool{ Name: "rerun_failed_jobs", @@ -1020,11 +1060,13 @@ func RerunFailedJobs(t translations.TranslationHelperFunc) inventory.ServerTool } }, ) + tool.FeatureFlagDisable = FeatureFlagConsolidatedActions + return tool } // CancelWorkflowRun creates a tool to cancel a workflow run func CancelWorkflowRun(t translations.TranslationHelperFunc) inventory.ServerTool { - return NewTool( + tool := NewTool( ToolsetMetadataActions, mcp.Tool{ Name: "cancel_workflow_run", @@ -1097,11 +1139,13 @@ func CancelWorkflowRun(t translations.TranslationHelperFunc) inventory.ServerToo } }, ) + tool.FeatureFlagDisable = FeatureFlagConsolidatedActions + return tool } // ListWorkflowRunArtifacts creates a tool to list artifacts for a workflow run func ListWorkflowRunArtifacts(t translations.TranslationHelperFunc) inventory.ServerTool { - return NewTool( + tool := NewTool( ToolsetMetadataActions, mcp.Tool{ Name: "list_workflow_run_artifacts", @@ -1177,11 +1221,13 @@ func ListWorkflowRunArtifacts(t translations.TranslationHelperFunc) inventory.Se } }, ) + tool.FeatureFlagDisable = FeatureFlagConsolidatedActions + return tool } // DownloadWorkflowRunArtifact creates a tool to download a workflow run artifact func DownloadWorkflowRunArtifact(t translations.TranslationHelperFunc) inventory.ServerTool { - return NewTool( + tool := NewTool( ToolsetMetadataActions, mcp.Tool{ Name: "download_workflow_run_artifact", @@ -1254,11 +1300,13 @@ func DownloadWorkflowRunArtifact(t translations.TranslationHelperFunc) inventory } }, ) + tool.FeatureFlagDisable = FeatureFlagConsolidatedActions + return tool } // DeleteWorkflowRunLogs creates a tool to delete logs for a workflow run func DeleteWorkflowRunLogs(t translations.TranslationHelperFunc) inventory.ServerTool { - return NewTool( + tool := NewTool( ToolsetMetadataActions, mcp.Tool{ Name: "delete_workflow_run_logs", @@ -1330,11 +1378,13 @@ func DeleteWorkflowRunLogs(t translations.TranslationHelperFunc) inventory.Serve } }, ) + tool.FeatureFlagDisable = FeatureFlagConsolidatedActions + return tool } // GetWorkflowRunUsage creates a tool to get usage metrics for a workflow run func GetWorkflowRunUsage(t translations.TranslationHelperFunc) inventory.ServerTool { - return NewTool( + tool := NewTool( ToolsetMetadataActions, mcp.Tool{ Name: "get_workflow_run_usage", @@ -1398,4 +1448,927 @@ func GetWorkflowRunUsage(t translations.TranslationHelperFunc) inventory.ServerT } }, ) + tool.FeatureFlagDisable = FeatureFlagConsolidatedActions + return tool +} + +// ActionsList returns the tool and handler for listing GitHub Actions resources. +func ActionsList(t translations.TranslationHelperFunc) inventory.ServerTool { + tool := NewTool( + ToolsetMetadataActions, + mcp.Tool{ + Name: "actions_list", + Description: t("TOOL_ACTIONS_LIST_DESCRIPTION", + `Tools for listing GitHub Actions resources. +Use this tool to list workflows in a repository, or list workflow runs, jobs, and artifacts for a specific workflow or workflow run. +`), + Annotations: &mcp.ToolAnnotations{ + Title: t("TOOL_ACTIONS_LIST_USER_TITLE", "List GitHub Actions workflows in a repository"), + ReadOnlyHint: true, + }, + InputSchema: &jsonschema.Schema{ + Type: "object", + Properties: map[string]*jsonschema.Schema{ + "method": { + Type: "string", + Description: "The action to perform", + Enum: []any{ + actionsMethodListWorkflows, + actionsMethodListWorkflowRuns, + actionsMethodListWorkflowJobs, + actionsMethodListWorkflowArtifacts, + }, + }, + "owner": { + Type: "string", + Description: "Repository owner", + }, + "repo": { + Type: "string", + Description: "Repository name", + }, + "resource_id": { + Type: "string", + Description: `The unique identifier of the resource. This will vary based on the "method" provided, so ensure you provide the correct ID: +- Do not provide any resource ID for 'list_workflows' method. +- Provide a workflow ID or workflow file name (e.g. ci.yaml) for 'list_workflow_runs' method. +- Provide a workflow run ID for 'list_workflow_jobs' and 'list_workflow_run_artifacts' methods. +`, + }, + "workflow_runs_filter": { + Type: "object", + Description: "Filters for workflow runs. **ONLY** used when method is 'list_workflow_runs'", + Properties: map[string]*jsonschema.Schema{ + "actor": { + Type: "string", + Description: "Filter to a specific GitHub user's workflow runs.", + }, + "branch": { + Type: "string", + Description: "Filter workflow runs to a specific Git branch. Use the name of the branch.", + }, + "event": { + Type: "string", + Description: "Filter workflow runs to a specific event type", + Enum: []any{ + "branch_protection_rule", + "check_run", + "check_suite", + "create", + "delete", + "deployment", + "deployment_status", + "discussion", + "discussion_comment", + "fork", + "gollum", + "issue_comment", + "issues", + "label", + "merge_group", + "milestone", + "page_build", + "public", + "pull_request", + "pull_request_review", + "pull_request_review_comment", + "pull_request_target", + "push", + "registry_package", + "release", + "repository_dispatch", + "schedule", + "status", + "watch", + "workflow_call", + "workflow_dispatch", + "workflow_run", + }, + }, + "status": { + Type: "string", + Description: "Filter workflow runs to only runs with a specific status", + Enum: []any{"queued", "in_progress", "completed", "requested", "waiting"}, + }, + }, + }, + "workflow_jobs_filter": { + Type: "object", + Description: "Filters for workflow jobs. **ONLY** used when method is 'list_workflow_jobs'", + Properties: map[string]*jsonschema.Schema{ + "filter": { + Type: "string", + Description: "Filters jobs by their completed_at timestamp", + Enum: []any{"latest", "all"}, + }, + }, + }, + "page": { + Type: "number", + Description: "Page number for pagination (default: 1)", + Minimum: jsonschema.Ptr(1.0), + }, + "per_page": { + Type: "number", + Description: "Results per page for pagination (default: 30, max: 100)", + Minimum: jsonschema.Ptr(1.0), + Maximum: jsonschema.Ptr(100.0), + }, + }, + Required: []string{"method", "owner", "repo"}, + }, + }, + func(deps ToolDependencies) mcp.ToolHandlerFor[map[string]any, any] { + return func(ctx context.Context, _ *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 + } + + method, err := RequiredParam[string](args, "method") + if err != nil { + return utils.NewToolResultError(err.Error()), nil, nil + } + + resourceID, err := OptionalParam[string](args, "resource_id") + 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 nil, nil, fmt.Errorf("failed to get GitHub client: %w", err) + } + + var resourceIDInt int64 + var parseErr error + switch method { + case actionsMethodListWorkflows: + // Do nothing, no resource ID needed + default: + if resourceID == "" { + return utils.NewToolResultError(fmt.Sprintf("missing required parameter for method %s: resource_id", method)), nil, nil + } + + // For list_workflow_runs, resource_id could be a filename or numeric ID + // For other actions, resource ID must be an integer + if method != actionsMethodListWorkflowRuns { + resourceIDInt, parseErr = strconv.ParseInt(resourceID, 10, 64) + if parseErr != nil { + return utils.NewToolResultError(fmt.Sprintf("invalid resource_id, must be an integer for method %s: %v", method, parseErr)), nil, nil + } + } + } + + switch method { + case actionsMethodListWorkflows: + return listWorkflows(ctx, client, owner, repo, pagination) + case actionsMethodListWorkflowRuns: + return listWorkflowRuns(ctx, client, args, owner, repo, resourceID, pagination) + case actionsMethodListWorkflowJobs: + return listWorkflowJobs(ctx, client, args, owner, repo, resourceIDInt, pagination) + case actionsMethodListWorkflowArtifacts: + return listWorkflowArtifacts(ctx, client, owner, repo, resourceIDInt, pagination) + default: + return utils.NewToolResultError(fmt.Sprintf("unknown method: %s", method)), nil, nil + } + } + }, + ) + tool.FeatureFlagEnable = FeatureFlagConsolidatedActions + return tool +} + +// ActionsGet returns the tool and handler for getting GitHub Actions resources. +func ActionsGet(t translations.TranslationHelperFunc) inventory.ServerTool { + tool := NewTool( + ToolsetMetadataActions, + mcp.Tool{ + Name: "actions_get", + Description: t("TOOL_ACTIONS_GET_DESCRIPTION", `Get details about specific GitHub Actions resources. +Use this tool to get details about individual workflows, workflow runs, jobs, and artifacts by their unique IDs. +`), + Annotations: &mcp.ToolAnnotations{ + Title: t("TOOL_ACTIONS_GET_USER_TITLE", "Get details of GitHub Actions resources (workflows, workflow runs, jobs, and artifacts)"), + ReadOnlyHint: true, + }, + InputSchema: &jsonschema.Schema{ + Type: "object", + Properties: map[string]*jsonschema.Schema{ + "method": { + Type: "string", + Description: "The method to execute", + Enum: []any{ + actionsMethodGetWorkflow, + actionsMethodGetWorkflowRun, + actionsMethodGetWorkflowJob, + actionsMethodDownloadWorkflowArtifact, + actionsMethodGetWorkflowRunUsage, + actionsMethodGetWorkflowRunLogsURL, + }, + }, + "owner": { + Type: "string", + Description: "Repository owner", + }, + "repo": { + Type: "string", + Description: "Repository name", + }, + "resource_id": { + Type: "string", + Description: `The unique identifier of the resource. This will vary based on the "method" provided, so ensure you provide the correct ID: +- Provide a workflow ID or workflow file name (e.g. ci.yaml) for 'get_workflow' method. +- Provide a workflow run ID for 'get_workflow_run', 'get_workflow_run_usage', and 'get_workflow_run_logs_url' methods. +- Provide an artifact ID for 'download_workflow_run_artifact' method. +- Provide a job ID for 'get_workflow_job' method. +`, + }, + }, + Required: []string{"method", "owner", "repo", "resource_id"}, + }, + }, + func(deps ToolDependencies) mcp.ToolHandlerFor[map[string]any, any] { + return func(ctx context.Context, _ *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 + } + method, err := RequiredParam[string](args, "method") + if err != nil { + return utils.NewToolResultError(err.Error()), nil, nil + } + + resourceID, err := RequiredParam[string](args, "resource_id") + if err != nil { + return utils.NewToolResultError(err.Error()), nil, nil + } + + client, err := deps.GetClient(ctx) + if err != nil { + return nil, nil, fmt.Errorf("failed to get GitHub client: %w", err) + } + + var resourceIDInt int64 + var parseErr error + switch method { + case actionsMethodGetWorkflow: + // Do nothing, we accept both a string workflow ID or filename + default: + // For other methods, resource ID must be an integer + resourceIDInt, parseErr = strconv.ParseInt(resourceID, 10, 64) + if parseErr != nil { + return utils.NewToolResultError(fmt.Sprintf("invalid resource_id, must be an integer for method %s: %v", method, parseErr)), nil, nil + } + } + + switch method { + case actionsMethodGetWorkflow: + return getWorkflow(ctx, client, owner, repo, resourceID) + case actionsMethodGetWorkflowRun: + return getWorkflowRun(ctx, client, owner, repo, resourceIDInt) + case actionsMethodGetWorkflowJob: + return getWorkflowJob(ctx, client, owner, repo, resourceIDInt) + case actionsMethodDownloadWorkflowArtifact: + return downloadWorkflowArtifact(ctx, client, owner, repo, resourceIDInt) + case actionsMethodGetWorkflowRunUsage: + return getWorkflowRunUsage(ctx, client, owner, repo, resourceIDInt) + case actionsMethodGetWorkflowRunLogsURL: + return getWorkflowRunLogsURL(ctx, client, owner, repo, resourceIDInt) + default: + return utils.NewToolResultError(fmt.Sprintf("unknown method: %s", method)), nil, nil + } + } + }, + ) + tool.FeatureFlagEnable = FeatureFlagConsolidatedActions + return tool +} + +// ActionsRunTrigger returns the tool and handler for triggering GitHub Actions workflows. +func ActionsRunTrigger(t translations.TranslationHelperFunc) inventory.ServerTool { + tool := NewTool( + ToolsetMetadataActions, + mcp.Tool{ + Name: "actions_run_trigger", + Description: t("TOOL_ACTIONS_RUN_TRIGGER_DESCRIPTION", "Trigger GitHub Actions workflow operations, including running, re-running, cancelling workflow runs, and deleting workflow run logs."), + Annotations: &mcp.ToolAnnotations{ + Title: t("TOOL_ACTIONS_RUN_TRIGGER_USER_TITLE", "Trigger GitHub Actions workflow actions"), + ReadOnlyHint: false, + DestructiveHint: jsonschema.Ptr(true), + }, + InputSchema: &jsonschema.Schema{ + Type: "object", + Properties: map[string]*jsonschema.Schema{ + "method": { + Type: "string", + Description: "The method to execute", + Enum: []any{ + actionsMethodRunWorkflow, + actionsMethodRerunWorkflowRun, + actionsMethodRerunFailedJobs, + actionsMethodCancelWorkflowRun, + actionsMethodDeleteWorkflowRunLogs, + }, + }, + "owner": { + Type: "string", + Description: "Repository owner", + }, + "repo": { + Type: "string", + Description: "Repository name", + }, + "workflow_id": { + Type: "string", + Description: "The workflow ID (numeric) or workflow file name (e.g., main.yml, ci.yaml). Required for 'run_workflow' method.", + }, + "ref": { + Type: "string", + Description: "The git reference for the workflow. The reference can be a branch or tag name. Required for 'run_workflow' method.", + }, + "inputs": { + Type: "object", + Description: "Inputs the workflow accepts. Only used for 'run_workflow' method.", + }, + "run_id": { + Type: "number", + Description: "The ID of the workflow run. Required for all methods except 'run_workflow'.", + }, + }, + Required: []string{"method", "owner", "repo"}, + }, + }, + func(deps ToolDependencies) mcp.ToolHandlerFor[map[string]any, any] { + return func(ctx context.Context, _ *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 + } + method, err := RequiredParam[string](args, "method") + if err != nil { + return utils.NewToolResultError(err.Error()), nil, nil + } + + // Get optional parameters + workflowID, _ := OptionalParam[string](args, "workflow_id") + ref, _ := OptionalParam[string](args, "ref") + runID, _ := OptionalIntParam(args, "run_id") + + // Get optional inputs parameter + var inputs map[string]interface{} + if requestInputs, ok := args["inputs"]; ok { + if inputsMap, ok := requestInputs.(map[string]interface{}); ok { + inputs = inputsMap + } + } + + // Validate required parameters based on action type + if method == actionsMethodRunWorkflow { + if workflowID == "" { + return utils.NewToolResultError("workflow_id is required for run_workflow action"), nil, nil + } + if ref == "" { + return utils.NewToolResultError("ref is required for run_workflow action"), nil, nil + } + } else if runID == 0 { + return utils.NewToolResultError("missing required parameter: run_id"), nil, nil + } + + client, err := deps.GetClient(ctx) + if err != nil { + return nil, nil, fmt.Errorf("failed to get GitHub client: %w", err) + } + + switch method { + case actionsMethodRunWorkflow: + return runWorkflow(ctx, client, owner, repo, workflowID, ref, inputs) + case actionsMethodRerunWorkflowRun: + return rerunWorkflowRun(ctx, client, owner, repo, int64(runID)) + case actionsMethodRerunFailedJobs: + return rerunFailedJobs(ctx, client, owner, repo, int64(runID)) + case actionsMethodCancelWorkflowRun: + return cancelWorkflowRun(ctx, client, owner, repo, int64(runID)) + case actionsMethodDeleteWorkflowRunLogs: + return deleteWorkflowRunLogs(ctx, client, owner, repo, int64(runID)) + default: + return utils.NewToolResultError(fmt.Sprintf("unknown method: %s", method)), nil, nil + } + } + }, + ) + tool.FeatureFlagEnable = FeatureFlagConsolidatedActions + return tool +} + +// ActionsGetJobLogs returns the tool and handler for getting workflow job logs. +func ActionsGetJobLogs(t translations.TranslationHelperFunc) inventory.ServerTool { + tool := NewTool( + ToolsetMetadataActions, + mcp.Tool{ + Name: "get_job_logs", + Description: t("TOOL_GET_JOB_LOGS_CONSOLIDATED_DESCRIPTION", `Get logs for GitHub Actions workflow jobs. +Use this tool to retrieve logs for a specific job or all failed jobs in a workflow run. +For single job logs, provide job_id. For all failed jobs in a run, provide run_id with failed_only=true. +`), + Annotations: &mcp.ToolAnnotations{ + Title: t("TOOL_GET_JOB_LOGS_CONSOLIDATED_USER_TITLE", "Get GitHub Actions workflow job logs"), + ReadOnlyHint: true, + }, + InputSchema: &jsonschema.Schema{ + Type: "object", + Properties: map[string]*jsonschema.Schema{ + "owner": { + Type: "string", + Description: "Repository owner", + }, + "repo": { + Type: "string", + Description: "Repository name", + }, + "job_id": { + Type: "number", + Description: "The unique identifier of the workflow job. Required when getting logs for a single job.", + }, + "run_id": { + Type: "number", + Description: "The unique identifier of the workflow run. Required when failed_only is true to get logs for all failed jobs in the run.", + }, + "failed_only": { + Type: "boolean", + Description: "When true, gets logs for all failed jobs in the workflow run specified by run_id. Requires run_id to be provided.", + }, + "return_content": { + Type: "boolean", + Description: "Returns actual log content instead of URLs", + }, + "tail_lines": { + Type: "number", + Description: "Number of lines to return from the end of the log", + Default: json.RawMessage(`500`), + }, + }, + Required: []string{"owner", "repo"}, + }, + }, + func(deps ToolDependencies) mcp.ToolHandlerFor[map[string]any, any] { + return func(ctx context.Context, _ *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 + } + + jobID, err := OptionalIntParam(args, "job_id") + if err != nil { + return utils.NewToolResultError(err.Error()), nil, nil + } + + runID, err := OptionalIntParam(args, "run_id") + if err != nil { + return utils.NewToolResultError(err.Error()), nil, nil + } + + failedOnly, err := OptionalParam[bool](args, "failed_only") + if err != nil { + return utils.NewToolResultError(err.Error()), nil, nil + } + + returnContent, err := OptionalParam[bool](args, "return_content") + if err != nil { + return utils.NewToolResultError(err.Error()), nil, nil + } + + tailLines, err := OptionalIntParam(args, "tail_lines") + if err != nil { + return utils.NewToolResultError(err.Error()), nil, nil + } + // Default to 500 lines if not specified + if tailLines == 0 { + tailLines = 500 + } + + client, err := deps.GetClient(ctx) + if err != nil { + return nil, nil, fmt.Errorf("failed to get GitHub client: %w", err) + } + + // Validate parameters + if failedOnly && runID == 0 { + return utils.NewToolResultError("run_id is required when failed_only is true"), nil, nil + } + if !failedOnly && jobID == 0 { + return utils.NewToolResultError("job_id is required when failed_only is false"), nil, nil + } + + if failedOnly && runID > 0 { + // Handle failed-only mode: get logs for all failed jobs in the workflow run + return handleFailedJobLogs(ctx, client, owner, repo, int64(runID), returnContent, tailLines, deps.GetContentWindowSize()) + } else if jobID > 0 { + // Handle single job mode + return handleSingleJobLogs(ctx, client, owner, repo, int64(jobID), returnContent, tailLines, deps.GetContentWindowSize()) + } + + return utils.NewToolResultError("Either job_id must be provided for single job logs, or run_id with failed_only=true for failed job logs"), nil, nil + } + }, + ) + tool.FeatureFlagEnable = FeatureFlagConsolidatedActions + return tool +} + +// Helper functions for consolidated actions tools + +func getWorkflow(ctx context.Context, client *github.Client, owner, repo, resourceID string) (*mcp.CallToolResult, any, error) { + var workflow *github.Workflow + var resp *github.Response + var err error + + if workflowIDInt, parseErr := strconv.ParseInt(resourceID, 10, 64); parseErr == nil { + workflow, resp, err = client.Actions.GetWorkflowByID(ctx, owner, repo, workflowIDInt) + } else { + workflow, resp, err = client.Actions.GetWorkflowByFileName(ctx, owner, repo, resourceID) + } + + if err != nil { + return ghErrors.NewGitHubAPIErrorResponse(ctx, "failed to get workflow", resp, err), nil, nil + } + + defer func() { _ = resp.Body.Close() }() + r, err := json.Marshal(workflow) + if err != nil { + return nil, nil, fmt.Errorf("failed to marshal workflow: %w", err) + } + + return utils.NewToolResultText(string(r)), nil, nil +} + +func getWorkflowRun(ctx context.Context, client *github.Client, owner, repo string, resourceID int64) (*mcp.CallToolResult, any, error) { + workflowRun, resp, err := client.Actions.GetWorkflowRunByID(ctx, owner, repo, resourceID) + if err != nil { + return ghErrors.NewGitHubAPIErrorResponse(ctx, "failed to get workflow run", resp, err), nil, nil + } + defer func() { _ = resp.Body.Close() }() + r, err := json.Marshal(workflowRun) + if err != nil { + return nil, nil, fmt.Errorf("failed to marshal workflow run: %w", err) + } + return utils.NewToolResultText(string(r)), nil, nil +} + +func getWorkflowJob(ctx context.Context, client *github.Client, owner, repo string, resourceID int64) (*mcp.CallToolResult, any, error) { + workflowJob, resp, err := client.Actions.GetWorkflowJobByID(ctx, owner, repo, resourceID) + if err != nil { + return ghErrors.NewGitHubAPIErrorResponse(ctx, "failed to get workflow job", resp, err), nil, nil + } + defer func() { _ = resp.Body.Close() }() + r, err := json.Marshal(workflowJob) + if err != nil { + return nil, nil, fmt.Errorf("failed to marshal workflow job: %w", err) + } + return utils.NewToolResultText(string(r)), nil, nil +} + +func listWorkflows(ctx context.Context, client *github.Client, owner, repo string, pagination PaginationParams) (*mcp.CallToolResult, any, error) { + opts := &github.ListOptions{ + PerPage: pagination.PerPage, + Page: pagination.Page, + } + + workflows, resp, err := client.Actions.ListWorkflows(ctx, owner, repo, opts) + if err != nil { + return ghErrors.NewGitHubAPIErrorResponse(ctx, "failed to list workflows", resp, err), nil, nil + } + defer func() { _ = resp.Body.Close() }() + + r, err := json.Marshal(workflows) + if err != nil { + return nil, nil, fmt.Errorf("failed to marshal workflows: %w", err) + } + + return utils.NewToolResultText(string(r)), nil, nil +} + +func listWorkflowRuns(ctx context.Context, client *github.Client, args map[string]any, owner, repo, resourceID string, pagination PaginationParams) (*mcp.CallToolResult, any, error) { + filterArgs, err := OptionalParam[map[string]any](args, "workflow_runs_filter") + if err != nil { + return utils.NewToolResultError(err.Error()), nil, nil + } + + filterArgsTyped := make(map[string]string) + for k, v := range filterArgs { + if strVal, ok := v.(string); ok { + filterArgsTyped[k] = strVal + } else { + filterArgsTyped[k] = "" + } + } + + listWorkflowRunsOptions := &github.ListWorkflowRunsOptions{ + Actor: filterArgsTyped["actor"], + Branch: filterArgsTyped["branch"], + Event: filterArgsTyped["event"], + Status: filterArgsTyped["status"], + ListOptions: github.ListOptions{ + Page: pagination.Page, + PerPage: pagination.PerPage, + }, + } + + var workflowRuns *github.WorkflowRuns + var resp *github.Response + + if workflowIDInt, parseErr := strconv.ParseInt(resourceID, 10, 64); parseErr == nil { + workflowRuns, resp, err = client.Actions.ListWorkflowRunsByID(ctx, owner, repo, workflowIDInt, listWorkflowRunsOptions) + } else { + workflowRuns, resp, err = client.Actions.ListWorkflowRunsByFileName(ctx, owner, repo, resourceID, listWorkflowRunsOptions) + } + + if err != nil { + return ghErrors.NewGitHubAPIErrorResponse(ctx, "failed to list workflow runs", resp, err), nil, nil + } + + defer func() { _ = resp.Body.Close() }() + r, err := json.Marshal(workflowRuns) + if err != nil { + return nil, nil, fmt.Errorf("failed to marshal workflow runs: %w", err) + } + + return utils.NewToolResultText(string(r)), nil, nil +} + +func listWorkflowJobs(ctx context.Context, client *github.Client, args map[string]any, owner, repo string, resourceID int64, pagination PaginationParams) (*mcp.CallToolResult, any, error) { + filterArgs, err := OptionalParam[map[string]any](args, "workflow_jobs_filter") + if err != nil { + return utils.NewToolResultError(err.Error()), nil, nil + } + + filterArgsTyped := make(map[string]string) + for k, v := range filterArgs { + if strVal, ok := v.(string); ok { + filterArgsTyped[k] = strVal + } else { + filterArgsTyped[k] = "" + } + } + + workflowJobs, resp, err := client.Actions.ListWorkflowJobs(ctx, owner, repo, resourceID, &github.ListWorkflowJobsOptions{ + Filter: filterArgsTyped["filter"], + ListOptions: github.ListOptions{ + Page: pagination.Page, + PerPage: pagination.PerPage, + }, + }) + if err != nil { + return ghErrors.NewGitHubAPIErrorResponse(ctx, "failed to list workflow jobs", resp, err), nil, nil + } + + response := map[string]any{ + "jobs": workflowJobs, + } + + defer func() { _ = resp.Body.Close() }() + r, err := json.Marshal(response) + if err != nil { + return nil, nil, fmt.Errorf("failed to marshal workflow jobs: %w", err) + } + + return utils.NewToolResultText(string(r)), nil, nil +} + +func listWorkflowArtifacts(ctx context.Context, client *github.Client, owner, repo string, resourceID int64, pagination PaginationParams) (*mcp.CallToolResult, any, error) { + opts := &github.ListOptions{ + PerPage: pagination.PerPage, + Page: pagination.Page, + } + + artifacts, resp, err := client.Actions.ListWorkflowRunArtifacts(ctx, owner, repo, resourceID, opts) + if err != nil { + return ghErrors.NewGitHubAPIErrorResponse(ctx, "failed to list workflow run artifacts", resp, err), nil, nil + } + defer func() { _ = resp.Body.Close() }() + + r, err := json.Marshal(artifacts) + if err != nil { + return nil, nil, fmt.Errorf("failed to marshal response: %w", err) + } + + return utils.NewToolResultText(string(r)), nil, nil +} + +func downloadWorkflowArtifact(ctx context.Context, client *github.Client, owner, repo string, resourceID int64) (*mcp.CallToolResult, any, error) { + // Get the download URL for the artifact + url, resp, err := client.Actions.DownloadArtifact(ctx, owner, repo, resourceID, 1) + if err != nil { + return ghErrors.NewGitHubAPIErrorResponse(ctx, "failed to get artifact download URL", resp, err), nil, nil + } + defer func() { _ = resp.Body.Close() }() + + // Create response with the download URL and information + result := map[string]any{ + "download_url": url.String(), + "message": "Artifact is available for download", + "note": "The download_url provides a download link for the artifact as a ZIP archive. The link is temporary and expires after a short time.", + "artifact_id": resourceID, + } + + r, err := json.Marshal(result) + if err != nil { + return nil, nil, fmt.Errorf("failed to marshal response: %w", err) + } + + return utils.NewToolResultText(string(r)), nil, nil +} + +func getWorkflowRunLogsURL(ctx context.Context, client *github.Client, owner, repo string, runID int64) (*mcp.CallToolResult, any, error) { + // Get the download URL for the logs + url, resp, err := client.Actions.GetWorkflowRunLogs(ctx, owner, repo, runID, 1) + if err != nil { + return ghErrors.NewGitHubAPIErrorResponse(ctx, "failed to get workflow run logs", resp, err), nil, nil + } + defer func() { _ = resp.Body.Close() }() + + // Create response with the logs URL and information + result := map[string]any{ + "logs_url": url.String(), + "message": "Workflow run logs are available for download", + "note": "The logs_url provides a download link for the complete workflow run logs as a ZIP archive. You can download this archive to extract and examine individual job logs.", + "warning": "This downloads ALL logs as a ZIP file which can be large and expensive. For debugging failed jobs, consider using get_job_logs with failed_only=true and run_id instead.", + "optimization_tip": "Use: get_job_logs with parameters {run_id: " + fmt.Sprintf("%d", runID) + ", failed_only: true} for more efficient failed job debugging", + } + + r, err := json.Marshal(result) + if err != nil { + return nil, nil, fmt.Errorf("failed to marshal response: %w", err) + } + + return utils.NewToolResultText(string(r)), nil, nil +} + +func getWorkflowRunUsage(ctx context.Context, client *github.Client, owner, repo string, resourceID int64) (*mcp.CallToolResult, any, error) { + usage, resp, err := client.Actions.GetWorkflowRunUsageByID(ctx, owner, repo, resourceID) + if err != nil { + return ghErrors.NewGitHubAPIErrorResponse(ctx, "failed to get workflow run usage", resp, err), nil, nil + } + defer func() { _ = resp.Body.Close() }() + + r, err := json.Marshal(usage) + if err != nil { + return nil, nil, fmt.Errorf("failed to marshal response: %w", err) + } + + return utils.NewToolResultText(string(r)), nil, nil +} + +func runWorkflow(ctx context.Context, client *github.Client, owner, repo, workflowID, ref string, inputs map[string]interface{}) (*mcp.CallToolResult, any, error) { + event := github.CreateWorkflowDispatchEventRequest{ + Ref: ref, + Inputs: inputs, + } + + var resp *github.Response + var err error + var workflowType string + + if workflowIDInt, parseErr := strconv.ParseInt(workflowID, 10, 64); parseErr == nil { + resp, err = client.Actions.CreateWorkflowDispatchEventByID(ctx, owner, repo, workflowIDInt, event) + workflowType = "workflow_id" + } else { + resp, err = client.Actions.CreateWorkflowDispatchEventByFileName(ctx, owner, repo, workflowID, event) + workflowType = "workflow_file" + } + + if err != nil { + return ghErrors.NewGitHubAPIErrorResponse(ctx, "failed to run workflow", resp, err), nil, nil + } + defer func() { _ = resp.Body.Close() }() + + result := map[string]any{ + "message": "Workflow run has been queued", + "workflow_type": workflowType, + "workflow_id": workflowID, + "ref": ref, + "inputs": inputs, + "status": resp.Status, + "status_code": resp.StatusCode, + } + + r, err := json.Marshal(result) + if err != nil { + return nil, nil, fmt.Errorf("failed to marshal response: %w", err) + } + + return utils.NewToolResultText(string(r)), nil, nil +} + +func rerunWorkflowRun(ctx context.Context, client *github.Client, owner, repo string, runID int64) (*mcp.CallToolResult, any, error) { + resp, err := client.Actions.RerunWorkflowByID(ctx, owner, repo, runID) + if err != nil { + return ghErrors.NewGitHubAPIErrorResponse(ctx, "failed to rerun workflow run", resp, err), nil, nil + } + defer func() { _ = resp.Body.Close() }() + + result := map[string]any{ + "message": "Workflow run has been queued for re-run", + "run_id": runID, + "status": resp.Status, + "status_code": resp.StatusCode, + } + + r, err := json.Marshal(result) + if err != nil { + return nil, nil, fmt.Errorf("failed to marshal response: %w", err) + } + + return utils.NewToolResultText(string(r)), nil, nil +} + +func rerunFailedJobs(ctx context.Context, client *github.Client, owner, repo string, runID int64) (*mcp.CallToolResult, any, error) { + resp, err := client.Actions.RerunFailedJobsByID(ctx, owner, repo, runID) + if err != nil { + return ghErrors.NewGitHubAPIErrorResponse(ctx, "failed to rerun failed jobs", resp, err), nil, nil + } + defer func() { _ = resp.Body.Close() }() + + result := map[string]any{ + "message": "Failed jobs have been queued for re-run", + "run_id": runID, + "status": resp.Status, + "status_code": resp.StatusCode, + } + + r, err := json.Marshal(result) + if err != nil { + return nil, nil, fmt.Errorf("failed to marshal response: %w", err) + } + + return utils.NewToolResultText(string(r)), nil, nil +} + +func cancelWorkflowRun(ctx context.Context, client *github.Client, owner, repo string, runID int64) (*mcp.CallToolResult, any, error) { + resp, err := client.Actions.CancelWorkflowRunByID(ctx, owner, repo, runID) + if err != nil { + var acceptedErr *github.AcceptedError + if !errors.As(err, &acceptedErr) { + return ghErrors.NewGitHubAPIErrorResponse(ctx, "failed to cancel workflow run", resp, err), nil, nil + } + } + defer func() { _ = resp.Body.Close() }() + + result := map[string]any{ + "message": "Workflow run has been cancelled", + "run_id": runID, + "status": resp.Status, + "status_code": resp.StatusCode, + } + + r, err := json.Marshal(result) + if err != nil { + return nil, nil, fmt.Errorf("failed to marshal response: %w", err) + } + + return utils.NewToolResultText(string(r)), nil, nil +} + +func deleteWorkflowRunLogs(ctx context.Context, client *github.Client, owner, repo string, runID int64) (*mcp.CallToolResult, any, error) { + resp, err := client.Actions.DeleteWorkflowRunLogs(ctx, owner, repo, runID) + if err != nil { + return ghErrors.NewGitHubAPIErrorResponse(ctx, "failed to delete workflow run logs", resp, err), nil, nil + } + defer func() { _ = resp.Body.Close() }() + + result := map[string]any{ + "message": "Workflow run logs have been deleted", + "run_id": runID, + "status": resp.Status, + "status_code": resp.StatusCode, + } + + r, err := json.Marshal(result) + if err != nil { + return nil, nil, fmt.Errorf("failed to marshal response: %w", err) + } + + return utils.NewToolResultText(string(r)), nil, nil } diff --git a/pkg/github/tools.go b/pkg/github/tools.go index 62d2d8664..2caa05979 100644 --- a/pkg/github/tools.go +++ b/pkg/github/tools.go @@ -214,6 +214,11 @@ func AllTools(t translations.TranslationHelperFunc) []inventory.ServerTool { RerunFailedJobs(t), CancelWorkflowRun(t), DeleteWorkflowRunLogs(t), + // Consolidated Actions tools (enabled via feature flag) + ActionsList(t), + ActionsGet(t), + ActionsRunTrigger(t), + ActionsGetJobLogs(t), // Security advisories tools ListGlobalSecurityAdvisories(t), diff --git a/pkg/github/tools_validation_test.go b/pkg/github/tools_validation_test.go index 8821eedd1..90e3c744c 100644 --- a/pkg/github/tools_validation_test.go +++ b/pkg/github/tools_validation_test.go @@ -101,6 +101,7 @@ func TestToolReadOnlyHintConsistency(t *testing.T) { func TestNoDuplicateToolNames(t *testing.T) { tools := AllTools(stubTranslation) seen := make(map[string]bool) + featureFlagged := make(map[string]bool) // get_label is intentionally in both issues and labels toolsets for conformance // with original behavior where it was registered in both @@ -108,9 +109,17 @@ func TestNoDuplicateToolNames(t *testing.T) { "get_label": true, } + // First pass: identify tools that have feature flags (mutually exclusive at runtime) + for _, tool := range tools { + if tool.FeatureFlagEnable != "" || tool.FeatureFlagDisable != "" { + featureFlagged[tool.Tool.Name] = true + } + } + for _, tool := range tools { name := tool.Tool.Name - if !allowedDuplicates[name] { + // Allow duplicates for explicitly allowed tools and feature-flagged tools + if !allowedDuplicates[name] && !featureFlagged[name] { assert.False(t, seen[name], "Duplicate tool name found: %q", name) } From 3499aa7584f148062df8a00c5f22ddc50ef0dfa4 Mon Sep 17 00:00:00 2001 From: Matt Holloway Date: Thu, 18 Dec 2025 10:26:38 +0000 Subject: [PATCH 2/5] update tests --- pkg/github/__toolsnaps__/actions_get.snap | 43 + pkg/github/__toolsnaps__/actions_list.snap | 128 ++ .../__toolsnaps__/actions_run_trigger.snap | 53 + pkg/github/actions_test.go | 1122 +++++++++++++++++ 4 files changed, 1346 insertions(+) create mode 100644 pkg/github/__toolsnaps__/actions_get.snap create mode 100644 pkg/github/__toolsnaps__/actions_list.snap create mode 100644 pkg/github/__toolsnaps__/actions_run_trigger.snap diff --git a/pkg/github/__toolsnaps__/actions_get.snap b/pkg/github/__toolsnaps__/actions_get.snap new file mode 100644 index 000000000..b5f3b85bd --- /dev/null +++ b/pkg/github/__toolsnaps__/actions_get.snap @@ -0,0 +1,43 @@ +{ + "annotations": { + "readOnlyHint": true, + "title": "Get details of GitHub Actions resources (workflows, workflow runs, jobs, and artifacts)" + }, + "description": "Get details about specific GitHub Actions resources.\nUse this tool to get details about individual workflows, workflow runs, jobs, and artifacts by their unique IDs.\n", + "inputSchema": { + "type": "object", + "required": [ + "method", + "owner", + "repo", + "resource_id" + ], + "properties": { + "method": { + "type": "string", + "description": "The method to execute", + "enum": [ + "get_workflow", + "get_workflow_run", + "get_workflow_job", + "download_workflow_run_artifact", + "get_workflow_run_usage", + "get_workflow_run_logs_url" + ] + }, + "owner": { + "type": "string", + "description": "Repository owner" + }, + "repo": { + "type": "string", + "description": "Repository name" + }, + "resource_id": { + "type": "string", + "description": "The unique identifier of the resource. This will vary based on the \"method\" provided, so ensure you provide the correct ID:\n- Provide a workflow ID or workflow file name (e.g. ci.yaml) for 'get_workflow' method.\n- Provide a workflow run ID for 'get_workflow_run', 'get_workflow_run_usage', and 'get_workflow_run_logs_url' methods.\n- Provide an artifact ID for 'download_workflow_run_artifact' method.\n- Provide a job ID for 'get_workflow_job' method.\n" + } + } + }, + "name": "actions_get" +} \ No newline at end of file diff --git a/pkg/github/__toolsnaps__/actions_list.snap b/pkg/github/__toolsnaps__/actions_list.snap new file mode 100644 index 000000000..3968a6eae --- /dev/null +++ b/pkg/github/__toolsnaps__/actions_list.snap @@ -0,0 +1,128 @@ +{ + "annotations": { + "readOnlyHint": true, + "title": "List GitHub Actions workflows in a repository" + }, + "description": "Tools for listing GitHub Actions resources.\nUse this tool to list workflows in a repository, or list workflow runs, jobs, and artifacts for a specific workflow or workflow run.\n", + "inputSchema": { + "type": "object", + "required": [ + "method", + "owner", + "repo" + ], + "properties": { + "method": { + "type": "string", + "description": "The action to perform", + "enum": [ + "list_workflows", + "list_workflow_runs", + "list_workflow_jobs", + "list_workflow_run_artifacts" + ] + }, + "owner": { + "type": "string", + "description": "Repository owner" + }, + "page": { + "type": "number", + "description": "Page number for pagination (default: 1)", + "minimum": 1 + }, + "per_page": { + "type": "number", + "description": "Results per page for pagination (default: 30, max: 100)", + "minimum": 1, + "maximum": 100 + }, + "repo": { + "type": "string", + "description": "Repository name" + }, + "resource_id": { + "type": "string", + "description": "The unique identifier of the resource. This will vary based on the \"method\" provided, so ensure you provide the correct ID:\n- Do not provide any resource ID for 'list_workflows' method.\n- Provide a workflow ID or workflow file name (e.g. ci.yaml) for 'list_workflow_runs' method.\n- Provide a workflow run ID for 'list_workflow_jobs' and 'list_workflow_run_artifacts' methods.\n" + }, + "workflow_jobs_filter": { + "type": "object", + "description": "Filters for workflow jobs. **ONLY** used when method is 'list_workflow_jobs'", + "properties": { + "filter": { + "type": "string", + "description": "Filters jobs by their completed_at timestamp", + "enum": [ + "latest", + "all" + ] + } + } + }, + "workflow_runs_filter": { + "type": "object", + "description": "Filters for workflow runs. **ONLY** used when method is 'list_workflow_runs'", + "properties": { + "actor": { + "type": "string", + "description": "Filter to a specific GitHub user's workflow runs." + }, + "branch": { + "type": "string", + "description": "Filter workflow runs to a specific Git branch. Use the name of the branch." + }, + "event": { + "type": "string", + "description": "Filter workflow runs to a specific event type", + "enum": [ + "branch_protection_rule", + "check_run", + "check_suite", + "create", + "delete", + "deployment", + "deployment_status", + "discussion", + "discussion_comment", + "fork", + "gollum", + "issue_comment", + "issues", + "label", + "merge_group", + "milestone", + "page_build", + "public", + "pull_request", + "pull_request_review", + "pull_request_review_comment", + "pull_request_target", + "push", + "registry_package", + "release", + "repository_dispatch", + "schedule", + "status", + "watch", + "workflow_call", + "workflow_dispatch", + "workflow_run" + ] + }, + "status": { + "type": "string", + "description": "Filter workflow runs to only runs with a specific status", + "enum": [ + "queued", + "in_progress", + "completed", + "requested", + "waiting" + ] + } + } + } + } + }, + "name": "actions_list" +} \ No newline at end of file diff --git a/pkg/github/__toolsnaps__/actions_run_trigger.snap b/pkg/github/__toolsnaps__/actions_run_trigger.snap new file mode 100644 index 000000000..4e16f8958 --- /dev/null +++ b/pkg/github/__toolsnaps__/actions_run_trigger.snap @@ -0,0 +1,53 @@ +{ + "annotations": { + "destructiveHint": true, + "title": "Trigger GitHub Actions workflow actions" + }, + "description": "Trigger GitHub Actions workflow operations, including running, re-running, cancelling workflow runs, and deleting workflow run logs.", + "inputSchema": { + "type": "object", + "required": [ + "method", + "owner", + "repo" + ], + "properties": { + "inputs": { + "type": "object", + "description": "Inputs the workflow accepts. Only used for 'run_workflow' method." + }, + "method": { + "type": "string", + "description": "The method to execute", + "enum": [ + "run_workflow", + "rerun_workflow_run", + "rerun_failed_jobs", + "cancel_workflow_run", + "delete_workflow_run_logs" + ] + }, + "owner": { + "type": "string", + "description": "Repository owner" + }, + "ref": { + "type": "string", + "description": "The git reference for the workflow. The reference can be a branch or tag name. Required for 'run_workflow' method." + }, + "repo": { + "type": "string", + "description": "Repository name" + }, + "run_id": { + "type": "number", + "description": "The ID of the workflow run. Required for all methods except 'run_workflow'." + }, + "workflow_id": { + "type": "string", + "description": "The workflow ID (numeric) or workflow file name (e.g., main.yml, ci.yaml). Required for 'run_workflow' method." + } + } + }, + "name": "actions_run_trigger" +} \ No newline at end of file diff --git a/pkg/github/actions_test.go b/pkg/github/actions_test.go index 4d56f01aa..cac49a0ad 100644 --- a/pkg/github/actions_test.go +++ b/pkg/github/actions_test.go @@ -1450,4 +1450,1126 @@ func Test_RerunFailedJobs(t *testing.T) { assert.Contains(t, inputSchema.Properties, "repo") assert.Contains(t, inputSchema.Properties, "run_id") assert.ElementsMatch(t, inputSchema.Required, []string{"owner", "repo", "run_id"}) + + tests := []struct { + name string + mockedClient *http.Client + requestArgs map[string]any + expectError bool + expectedErrMsg string + }{ + { + name: "successful rerun of failed jobs", + mockedClient: mock.NewMockedHTTPClient( + mock.WithRequestMatchHandler( + mock.EndpointPattern{ + Pattern: "/repos/owner/repo/actions/runs/12345/rerun-failed-jobs", + Method: "POST", + }, + http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) { + w.WriteHeader(http.StatusCreated) + }), + ), + ), + requestArgs: map[string]any{ + "owner": "owner", + "repo": "repo", + "run_id": float64(12345), + }, + expectError: false, + }, + { + name: "missing required parameter run_id", + mockedClient: mock.NewMockedHTTPClient(), + requestArgs: map[string]any{ + "owner": "owner", + "repo": "repo", + }, + expectError: true, + expectedErrMsg: "missing required parameter: run_id", + }, + } + + 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(context.Background(), &request) + + require.NoError(t, err) + require.Equal(t, tc.expectError, result.IsError) + + textContent := getTextResult(t, result) + + if tc.expectedErrMsg != "" { + assert.Equal(t, tc.expectedErrMsg, textContent.Text) + return + } + + var response map[string]any + err = json.Unmarshal([]byte(textContent.Text), &response) + require.NoError(t, err) + assert.Equal(t, "Failed jobs have been queued for re-run", response["message"]) + assert.Equal(t, float64(12345), response["run_id"]) + }) + } +} + +func Test_RerunWorkflowRun_Behavioral(t *testing.T) { + toolDef := RerunWorkflowRun(translations.NullTranslationHelper) + + tests := []struct { + name string + mockedClient *http.Client + requestArgs map[string]any + expectError bool + expectedErrMsg string + }{ + { + name: "successful rerun of workflow run", + mockedClient: mock.NewMockedHTTPClient( + mock.WithRequestMatchHandler( + mock.EndpointPattern{ + Pattern: "/repos/owner/repo/actions/runs/12345/rerun", + Method: "POST", + }, + http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) { + w.WriteHeader(http.StatusCreated) + }), + ), + ), + requestArgs: map[string]any{ + "owner": "owner", + "repo": "repo", + "run_id": float64(12345), + }, + expectError: false, + }, + { + name: "missing required parameter run_id", + mockedClient: mock.NewMockedHTTPClient(), + requestArgs: map[string]any{ + "owner": "owner", + "repo": "repo", + }, + expectError: true, + expectedErrMsg: "missing required parameter: run_id", + }, + } + + 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(context.Background(), &request) + + require.NoError(t, err) + require.Equal(t, tc.expectError, result.IsError) + + textContent := getTextResult(t, result) + + if tc.expectedErrMsg != "" { + assert.Equal(t, tc.expectedErrMsg, textContent.Text) + return + } + + var response map[string]any + err = json.Unmarshal([]byte(textContent.Text), &response) + require.NoError(t, err) + assert.Equal(t, "Workflow run has been queued for re-run", response["message"]) + assert.Equal(t, float64(12345), response["run_id"]) + }) + } +} + +func Test_ListWorkflowRuns_Behavioral(t *testing.T) { + toolDef := ListWorkflowRuns(translations.NullTranslationHelper) + + tests := []struct { + name string + mockedClient *http.Client + requestArgs map[string]any + expectError bool + expectedErrMsg string + }{ + { + name: "successful workflow runs listing", + mockedClient: mock.NewMockedHTTPClient( + mock.WithRequestMatchHandler( + mock.GetReposActionsWorkflowsRunsByOwnerByRepoByWorkflowId, + http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) { + runs := &github.WorkflowRuns{ + TotalCount: github.Ptr(2), + WorkflowRuns: []*github.WorkflowRun{ + { + ID: github.Ptr(int64(123)), + Name: github.Ptr("CI"), + Status: github.Ptr("completed"), + Conclusion: github.Ptr("success"), + }, + { + ID: github.Ptr(int64(456)), + Name: github.Ptr("CI"), + Status: github.Ptr("completed"), + Conclusion: github.Ptr("failure"), + }, + }, + } + w.WriteHeader(http.StatusOK) + _ = json.NewEncoder(w).Encode(runs) + }), + ), + ), + requestArgs: map[string]any{ + "owner": "owner", + "repo": "repo", + "workflow_id": "ci.yml", + }, + expectError: false, + }, + { + name: "missing required parameter workflow_id", + mockedClient: mock.NewMockedHTTPClient(), + requestArgs: map[string]any{ + "owner": "owner", + "repo": "repo", + }, + expectError: true, + expectedErrMsg: "missing required parameter: workflow_id", + }, + } + + 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(context.Background(), &request) + + require.NoError(t, err) + require.Equal(t, tc.expectError, result.IsError) + + textContent := getTextResult(t, result) + + if tc.expectedErrMsg != "" { + assert.Equal(t, tc.expectedErrMsg, textContent.Text) + return + } + + var response github.WorkflowRuns + err = json.Unmarshal([]byte(textContent.Text), &response) + require.NoError(t, err) + assert.NotNil(t, response.TotalCount) + assert.Greater(t, *response.TotalCount, 0) + }) + } +} + +func Test_GetWorkflowRun_Behavioral(t *testing.T) { + toolDef := GetWorkflowRun(translations.NullTranslationHelper) + + tests := []struct { + name string + mockedClient *http.Client + requestArgs map[string]any + expectError bool + expectedErrMsg string + }{ + { + name: "successful get workflow run", + mockedClient: mock.NewMockedHTTPClient( + mock.WithRequestMatchHandler( + mock.GetReposActionsRunsByOwnerByRepoByRunId, + http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) { + run := &github.WorkflowRun{ + ID: github.Ptr(int64(12345)), + Name: github.Ptr("CI"), + Status: github.Ptr("completed"), + Conclusion: github.Ptr("success"), + } + w.WriteHeader(http.StatusOK) + _ = json.NewEncoder(w).Encode(run) + }), + ), + ), + requestArgs: map[string]any{ + "owner": "owner", + "repo": "repo", + "run_id": float64(12345), + }, + expectError: false, + }, + { + name: "missing required parameter run_id", + mockedClient: mock.NewMockedHTTPClient(), + requestArgs: map[string]any{ + "owner": "owner", + "repo": "repo", + }, + expectError: true, + expectedErrMsg: "missing required parameter: run_id", + }, + } + + 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(context.Background(), &request) + + require.NoError(t, err) + require.Equal(t, tc.expectError, result.IsError) + + textContent := getTextResult(t, result) + + if tc.expectedErrMsg != "" { + assert.Equal(t, tc.expectedErrMsg, textContent.Text) + return + } + + var response github.WorkflowRun + err = json.Unmarshal([]byte(textContent.Text), &response) + require.NoError(t, err) + assert.NotNil(t, response.ID) + assert.Equal(t, int64(12345), *response.ID) + }) + } +} + +func Test_GetWorkflowRunLogs_Behavioral(t *testing.T) { + toolDef := GetWorkflowRunLogs(translations.NullTranslationHelper) + + tests := []struct { + name string + mockedClient *http.Client + requestArgs map[string]any + expectError bool + expectedErrMsg string + }{ + { + name: "successful get workflow run logs", + mockedClient: mock.NewMockedHTTPClient( + mock.WithRequestMatchHandler( + mock.GetReposActionsRunsLogsByOwnerByRepoByRunId, + http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) { + w.Header().Set("Location", "https://github.com/logs/run/12345") + w.WriteHeader(http.StatusFound) + }), + ), + ), + requestArgs: map[string]any{ + "owner": "owner", + "repo": "repo", + "run_id": float64(12345), + }, + expectError: false, + }, + { + name: "missing required parameter run_id", + mockedClient: mock.NewMockedHTTPClient(), + requestArgs: map[string]any{ + "owner": "owner", + "repo": "repo", + }, + expectError: true, + expectedErrMsg: "missing required parameter: run_id", + }, + } + + 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(context.Background(), &request) + + require.NoError(t, err) + require.Equal(t, tc.expectError, result.IsError) + + textContent := getTextResult(t, result) + + if tc.expectedErrMsg != "" { + assert.Equal(t, tc.expectedErrMsg, textContent.Text) + return + } + + var response map[string]any + err = json.Unmarshal([]byte(textContent.Text), &response) + require.NoError(t, err) + assert.Contains(t, response, "logs_url") + assert.Equal(t, "Workflow run logs are available for download", response["message"]) + }) + } +} + +func Test_ListWorkflowJobs_Behavioral(t *testing.T) { + toolDef := ListWorkflowJobs(translations.NullTranslationHelper) + + tests := []struct { + name string + mockedClient *http.Client + requestArgs map[string]any + expectError bool + expectedErrMsg string + }{ + { + name: "successful list workflow jobs", + mockedClient: mock.NewMockedHTTPClient( + mock.WithRequestMatchHandler( + mock.GetReposActionsRunsJobsByOwnerByRepoByRunId, + http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) { + jobs := &github.Jobs{ + TotalCount: github.Ptr(2), + Jobs: []*github.WorkflowJob{ + { + ID: github.Ptr(int64(1)), + Name: github.Ptr("build"), + Status: github.Ptr("completed"), + Conclusion: github.Ptr("success"), + }, + { + ID: github.Ptr(int64(2)), + Name: github.Ptr("test"), + Status: github.Ptr("completed"), + Conclusion: github.Ptr("failure"), + }, + }, + } + w.WriteHeader(http.StatusOK) + _ = json.NewEncoder(w).Encode(jobs) + }), + ), + ), + requestArgs: map[string]any{ + "owner": "owner", + "repo": "repo", + "run_id": float64(12345), + }, + expectError: false, + }, + { + name: "missing required parameter run_id", + mockedClient: mock.NewMockedHTTPClient(), + requestArgs: map[string]any{ + "owner": "owner", + "repo": "repo", + }, + expectError: true, + expectedErrMsg: "missing required parameter: run_id", + }, + } + + 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(context.Background(), &request) + + require.NoError(t, err) + require.Equal(t, tc.expectError, result.IsError) + + textContent := getTextResult(t, result) + + if tc.expectedErrMsg != "" { + assert.Equal(t, tc.expectedErrMsg, textContent.Text) + return + } + + var response map[string]any + err = json.Unmarshal([]byte(textContent.Text), &response) + require.NoError(t, err) + assert.Contains(t, response, "jobs") + }) + } +} + +// Tests for consolidated actions tools + +func Test_ActionsList(t *testing.T) { + // Verify tool definition once + toolDef := ActionsList(translations.NullTranslationHelper) + require.NoError(t, toolsnaps.Test(toolDef.Tool.Name, toolDef.Tool)) + + assert.Equal(t, "actions_list", toolDef.Tool.Name) + assert.NotEmpty(t, toolDef.Tool.Description) + inputSchema := toolDef.Tool.InputSchema.(*jsonschema.Schema) + assert.Contains(t, inputSchema.Properties, "method") + assert.Contains(t, inputSchema.Properties, "owner") + assert.Contains(t, inputSchema.Properties, "repo") + assert.ElementsMatch(t, inputSchema.Required, []string{"method", "owner", "repo"}) +} + +func Test_ActionsList_ListWorkflows(t *testing.T) { + toolDef := ActionsList(translations.NullTranslationHelper) + + tests := []struct { + name string + mockedClient *http.Client + requestArgs map[string]any + expectError bool + expectedErrMsg string + }{ + { + name: "successful workflow list", + mockedClient: mock.NewMockedHTTPClient( + mock.WithRequestMatchHandler( + mock.GetReposActionsWorkflowsByOwnerByRepo, + http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) { + workflows := &github.Workflows{ + TotalCount: github.Ptr(2), + Workflows: []*github.Workflow{ + { + ID: github.Ptr(int64(1)), + Name: github.Ptr("CI"), + Path: github.Ptr(".github/workflows/ci.yml"), + State: github.Ptr("active"), + }, + { + ID: github.Ptr(int64(2)), + Name: github.Ptr("Deploy"), + Path: github.Ptr(".github/workflows/deploy.yml"), + State: github.Ptr("active"), + }, + }, + } + w.WriteHeader(http.StatusOK) + _ = json.NewEncoder(w).Encode(workflows) + }), + ), + ), + requestArgs: map[string]any{ + "method": "list_workflows", + "owner": "owner", + "repo": "repo", + }, + expectError: false, + }, + { + name: "missing required parameter method", + mockedClient: mock.NewMockedHTTPClient(), + requestArgs: map[string]any{ + "owner": "owner", + "repo": "repo", + }, + expectError: true, + expectedErrMsg: "missing required parameter: method", + }, + } + + 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(context.Background(), &request) + + require.NoError(t, err) + require.Equal(t, tc.expectError, result.IsError) + + textContent := getTextResult(t, result) + + if tc.expectedErrMsg != "" { + assert.Equal(t, tc.expectedErrMsg, textContent.Text) + return + } + + var response github.Workflows + err = json.Unmarshal([]byte(textContent.Text), &response) + require.NoError(t, err) + assert.NotNil(t, response.TotalCount) + assert.Greater(t, *response.TotalCount, 0) + }) + } +} + +func Test_ActionsList_ListWorkflowRuns(t *testing.T) { + toolDef := ActionsList(translations.NullTranslationHelper) + + t.Run("successful workflow runs list", func(t *testing.T) { + mockedClient := mock.NewMockedHTTPClient( + mock.WithRequestMatchHandler( + mock.GetReposActionsWorkflowsRunsByOwnerByRepoByWorkflowId, + http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) { + runs := &github.WorkflowRuns{ + TotalCount: github.Ptr(1), + WorkflowRuns: []*github.WorkflowRun{ + { + ID: github.Ptr(int64(123)), + Name: github.Ptr("CI"), + Status: github.Ptr("completed"), + Conclusion: github.Ptr("success"), + }, + }, + } + w.WriteHeader(http.StatusOK) + _ = json.NewEncoder(w).Encode(runs) + }), + ), + ) + + client := github.NewClient(mockedClient) + deps := BaseDeps{ + Client: client, + } + handler := toolDef.Handler(deps) + + request := createMCPRequest(map[string]any{ + "method": "list_workflow_runs", + "owner": "owner", + "repo": "repo", + "resource_id": "ci.yml", + }) + result, err := handler(context.Background(), &request) + + require.NoError(t, err) + require.False(t, result.IsError) + + textContent := getTextResult(t, result) + var response github.WorkflowRuns + err = json.Unmarshal([]byte(textContent.Text), &response) + require.NoError(t, err) + assert.NotNil(t, response.TotalCount) + }) + + t.Run("missing resource_id for list_workflow_runs", func(t *testing.T) { + mockedClient := mock.NewMockedHTTPClient() + + client := github.NewClient(mockedClient) + deps := BaseDeps{ + Client: client, + } + handler := toolDef.Handler(deps) + + request := createMCPRequest(map[string]any{ + "method": "list_workflow_runs", + "owner": "owner", + "repo": "repo", + }) + result, err := handler(context.Background(), &request) + + require.NoError(t, err) + require.True(t, result.IsError) + + textContent := getTextResult(t, result) + assert.Contains(t, textContent.Text, "missing required parameter") + }) +} + +func Test_ActionsGet(t *testing.T) { + // Verify tool definition once + toolDef := ActionsGet(translations.NullTranslationHelper) + require.NoError(t, toolsnaps.Test(toolDef.Tool.Name, toolDef.Tool)) + + assert.Equal(t, "actions_get", toolDef.Tool.Name) + assert.NotEmpty(t, toolDef.Tool.Description) + inputSchema := toolDef.Tool.InputSchema.(*jsonschema.Schema) + assert.Contains(t, inputSchema.Properties, "method") + assert.Contains(t, inputSchema.Properties, "owner") + assert.Contains(t, inputSchema.Properties, "repo") + assert.Contains(t, inputSchema.Properties, "resource_id") + assert.ElementsMatch(t, inputSchema.Required, []string{"method", "owner", "repo", "resource_id"}) +} + +func Test_ActionsGet_GetWorkflow(t *testing.T) { + toolDef := ActionsGet(translations.NullTranslationHelper) + + t.Run("successful workflow get", func(t *testing.T) { + mockedClient := mock.NewMockedHTTPClient( + mock.WithRequestMatchHandler( + mock.GetReposActionsWorkflowsByOwnerByRepoByWorkflowId, + http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) { + workflow := &github.Workflow{ + ID: github.Ptr(int64(1)), + Name: github.Ptr("CI"), + Path: github.Ptr(".github/workflows/ci.yml"), + State: github.Ptr("active"), + } + w.WriteHeader(http.StatusOK) + _ = json.NewEncoder(w).Encode(workflow) + }), + ), + ) + + client := github.NewClient(mockedClient) + deps := BaseDeps{ + Client: client, + } + handler := toolDef.Handler(deps) + + request := createMCPRequest(map[string]any{ + "method": "get_workflow", + "owner": "owner", + "repo": "repo", + "resource_id": "ci.yml", + }) + result, err := handler(context.Background(), &request) + + require.NoError(t, err) + require.False(t, result.IsError) + + textContent := getTextResult(t, result) + var response github.Workflow + err = json.Unmarshal([]byte(textContent.Text), &response) + require.NoError(t, err) + assert.NotNil(t, response.ID) + assert.Equal(t, "CI", *response.Name) + }) +} + +func Test_ActionsGet_GetWorkflowRun(t *testing.T) { + toolDef := ActionsGet(translations.NullTranslationHelper) + + t.Run("successful workflow run get", func(t *testing.T) { + mockedClient := mock.NewMockedHTTPClient( + mock.WithRequestMatchHandler( + mock.GetReposActionsRunsByOwnerByRepoByRunId, + http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) { + run := &github.WorkflowRun{ + ID: github.Ptr(int64(12345)), + Name: github.Ptr("CI"), + Status: github.Ptr("completed"), + Conclusion: github.Ptr("success"), + } + w.WriteHeader(http.StatusOK) + _ = json.NewEncoder(w).Encode(run) + }), + ), + ) + + client := github.NewClient(mockedClient) + deps := BaseDeps{ + Client: client, + } + handler := toolDef.Handler(deps) + + request := createMCPRequest(map[string]any{ + "method": "get_workflow_run", + "owner": "owner", + "repo": "repo", + "resource_id": "12345", + }) + result, err := handler(context.Background(), &request) + + require.NoError(t, err) + require.False(t, result.IsError) + + textContent := getTextResult(t, result) + var response github.WorkflowRun + err = json.Unmarshal([]byte(textContent.Text), &response) + require.NoError(t, err) + assert.NotNil(t, response.ID) + assert.Equal(t, int64(12345), *response.ID) + }) +} + +func Test_ActionsRunTrigger(t *testing.T) { + // Verify tool definition once + toolDef := ActionsRunTrigger(translations.NullTranslationHelper) + require.NoError(t, toolsnaps.Test(toolDef.Tool.Name, toolDef.Tool)) + + assert.Equal(t, "actions_run_trigger", toolDef.Tool.Name) + assert.NotEmpty(t, toolDef.Tool.Description) + inputSchema := toolDef.Tool.InputSchema.(*jsonschema.Schema) + assert.Contains(t, inputSchema.Properties, "method") + assert.Contains(t, inputSchema.Properties, "owner") + assert.Contains(t, inputSchema.Properties, "repo") + assert.Contains(t, inputSchema.Properties, "workflow_id") + assert.Contains(t, inputSchema.Properties, "ref") + assert.Contains(t, inputSchema.Properties, "run_id") + assert.ElementsMatch(t, inputSchema.Required, []string{"method", "owner", "repo"}) +} + +func Test_ActionsRunTrigger_RunWorkflow(t *testing.T) { + toolDef := ActionsRunTrigger(translations.NullTranslationHelper) + + tests := []struct { + name string + mockedClient *http.Client + requestArgs map[string]any + expectError bool + expectedErrMsg string + }{ + { + name: "successful workflow run", + mockedClient: mock.NewMockedHTTPClient( + mock.WithRequestMatchHandler( + mock.PostReposActionsWorkflowsDispatchesByOwnerByRepoByWorkflowId, + http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) { + w.WriteHeader(http.StatusNoContent) + }), + ), + ), + requestArgs: map[string]any{ + "method": "run_workflow", + "owner": "owner", + "repo": "repo", + "workflow_id": "12345", + "ref": "main", + }, + expectError: false, + }, + { + name: "missing required parameter workflow_id", + mockedClient: mock.NewMockedHTTPClient(), + requestArgs: map[string]any{ + "method": "run_workflow", + "owner": "owner", + "repo": "repo", + "ref": "main", + }, + expectError: true, + expectedErrMsg: "workflow_id is required for run_workflow action", + }, + { + name: "missing required parameter ref", + mockedClient: mock.NewMockedHTTPClient(), + requestArgs: map[string]any{ + "method": "run_workflow", + "owner": "owner", + "repo": "repo", + "workflow_id": "12345", + }, + expectError: true, + expectedErrMsg: "ref is required for run_workflow action", + }, + } + + 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(context.Background(), &request) + + require.NoError(t, err) + require.Equal(t, tc.expectError, result.IsError) + + textContent := getTextResult(t, result) + + if tc.expectedErrMsg != "" { + assert.Equal(t, tc.expectedErrMsg, textContent.Text) + return + } + + var response map[string]any + err = json.Unmarshal([]byte(textContent.Text), &response) + require.NoError(t, err) + assert.Equal(t, "Workflow run has been queued", response["message"]) + }) + } +} + +func Test_ActionsRunTrigger_CancelWorkflowRun(t *testing.T) { + toolDef := ActionsRunTrigger(translations.NullTranslationHelper) + + t.Run("successful workflow run cancellation", func(t *testing.T) { + mockedClient := mock.NewMockedHTTPClient( + mock.WithRequestMatchHandler( + mock.EndpointPattern{ + Pattern: "/repos/owner/repo/actions/runs/12345/cancel", + Method: "POST", + }, + http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) { + w.WriteHeader(http.StatusAccepted) + }), + ), + ) + + client := github.NewClient(mockedClient) + deps := BaseDeps{ + Client: client, + } + handler := toolDef.Handler(deps) + + request := createMCPRequest(map[string]any{ + "method": "cancel_workflow_run", + "owner": "owner", + "repo": "repo", + "run_id": float64(12345), + }) + result, err := handler(context.Background(), &request) + + require.NoError(t, err) + require.False(t, result.IsError) + + textContent := getTextResult(t, result) + var response map[string]any + err = json.Unmarshal([]byte(textContent.Text), &response) + require.NoError(t, err) + assert.Equal(t, "Workflow run has been cancelled", response["message"]) + }) + + t.Run("conflict when cancelling a workflow run", func(t *testing.T) { + mockedClient := mock.NewMockedHTTPClient( + mock.WithRequestMatchHandler( + mock.EndpointPattern{ + Pattern: "/repos/owner/repo/actions/runs/12345/cancel", + Method: "POST", + }, + http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) { + w.WriteHeader(http.StatusConflict) + }), + ), + ) + + client := github.NewClient(mockedClient) + deps := BaseDeps{ + Client: client, + } + handler := toolDef.Handler(deps) + + request := createMCPRequest(map[string]any{ + "method": "cancel_workflow_run", + "owner": "owner", + "repo": "repo", + "run_id": float64(12345), + }) + result, err := handler(context.Background(), &request) + + require.NoError(t, err) + require.True(t, result.IsError) + + textContent := getTextResult(t, result) + assert.Contains(t, textContent.Text, "failed to cancel workflow run") + }) + + t.Run("missing run_id for non-run_workflow methods", func(t *testing.T) { + mockedClient := mock.NewMockedHTTPClient() + + client := github.NewClient(mockedClient) + deps := BaseDeps{ + Client: client, + } + handler := toolDef.Handler(deps) + + request := createMCPRequest(map[string]any{ + "method": "cancel_workflow_run", + "owner": "owner", + "repo": "repo", + }) + result, err := handler(context.Background(), &request) + + require.NoError(t, err) + require.True(t, result.IsError) + + textContent := getTextResult(t, result) + assert.Equal(t, "missing required parameter: run_id", textContent.Text) + }) +} + +func Test_ActionsGetJobLogs(t *testing.T) { + // Verify tool definition once + toolDef := ActionsGetJobLogs(translations.NullTranslationHelper) + + // Note: consolidated ActionsGetJobLogs has same tool name "get_job_logs" as the individual tool + // but with different descriptions. We skip toolsnap validation here since the individual + // tool's toolsnap already exists and is tested in Test_GetJobLogs. + // The consolidated tool has FeatureFlagEnable set, so only one will be active at a time. + assert.Equal(t, "get_job_logs", toolDef.Tool.Name) + assert.NotEmpty(t, toolDef.Tool.Description) + inputSchema := toolDef.Tool.InputSchema.(*jsonschema.Schema) + assert.Contains(t, inputSchema.Properties, "owner") + assert.Contains(t, inputSchema.Properties, "repo") + assert.Contains(t, inputSchema.Properties, "job_id") + assert.Contains(t, inputSchema.Properties, "run_id") + assert.Contains(t, inputSchema.Properties, "failed_only") + assert.Contains(t, inputSchema.Properties, "return_content") + assert.ElementsMatch(t, inputSchema.Required, []string{"owner", "repo"}) +} + +func Test_ActionsGetJobLogs_SingleJob(t *testing.T) { + toolDef := ActionsGetJobLogs(translations.NullTranslationHelper) + + t.Run("successful single job logs with URL", func(t *testing.T) { + mockedClient := mock.NewMockedHTTPClient( + mock.WithRequestMatchHandler( + mock.GetReposActionsJobsLogsByOwnerByRepoByJobId, + http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) { + w.Header().Set("Location", "https://github.com/logs/job/123") + w.WriteHeader(http.StatusFound) + }), + ), + ) + + client := github.NewClient(mockedClient) + deps := BaseDeps{ + Client: client, + ContentWindowSize: 5000, + } + handler := toolDef.Handler(deps) + + request := createMCPRequest(map[string]any{ + "owner": "owner", + "repo": "repo", + "job_id": float64(123), + }) + result, err := handler(context.Background(), &request) + + require.NoError(t, err) + require.False(t, result.IsError) + + textContent := getTextResult(t, result) + var response map[string]any + err = json.Unmarshal([]byte(textContent.Text), &response) + require.NoError(t, err) + assert.Equal(t, float64(123), response["job_id"]) + assert.Contains(t, response, "logs_url") + assert.Equal(t, "Job logs are available for download", response["message"]) + }) +} + +func Test_ActionsGetJobLogs_FailedJobs(t *testing.T) { + toolDef := ActionsGetJobLogs(translations.NullTranslationHelper) + + t.Run("successful failed jobs logs", func(t *testing.T) { + mockedClient := mock.NewMockedHTTPClient( + mock.WithRequestMatchHandler( + mock.GetReposActionsRunsJobsByOwnerByRepoByRunId, + http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) { + jobs := &github.Jobs{ + TotalCount: github.Ptr(3), + Jobs: []*github.WorkflowJob{ + { + ID: github.Ptr(int64(1)), + Name: github.Ptr("test-job-1"), + Conclusion: github.Ptr("success"), + }, + { + ID: github.Ptr(int64(2)), + Name: github.Ptr("test-job-2"), + Conclusion: github.Ptr("failure"), + }, + { + ID: github.Ptr(int64(3)), + Name: github.Ptr("test-job-3"), + Conclusion: github.Ptr("failure"), + }, + }, + } + w.WriteHeader(http.StatusOK) + _ = json.NewEncoder(w).Encode(jobs) + }), + ), + mock.WithRequestMatchHandler( + mock.GetReposActionsJobsLogsByOwnerByRepoByJobId, + http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + w.Header().Set("Location", "https://github.com/logs/job/"+r.URL.Path[len(r.URL.Path)-1:]) + w.WriteHeader(http.StatusFound) + }), + ), + ) + + client := github.NewClient(mockedClient) + deps := BaseDeps{ + Client: client, + ContentWindowSize: 5000, + } + handler := toolDef.Handler(deps) + + request := createMCPRequest(map[string]any{ + "owner": "owner", + "repo": "repo", + "run_id": float64(456), + "failed_only": true, + }) + result, err := handler(context.Background(), &request) + + require.NoError(t, err) + require.False(t, result.IsError) + + textContent := getTextResult(t, result) + var response map[string]any + err = json.Unmarshal([]byte(textContent.Text), &response) + require.NoError(t, err) + assert.Equal(t, float64(456), response["run_id"]) + assert.Contains(t, response, "logs") + assert.Contains(t, response["message"], "Retrieved logs for") + }) + + t.Run("no failed jobs found", func(t *testing.T) { + mockedClient := mock.NewMockedHTTPClient( + mock.WithRequestMatchHandler( + mock.GetReposActionsRunsJobsByOwnerByRepoByRunId, + http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) { + jobs := &github.Jobs{ + TotalCount: github.Ptr(2), + Jobs: []*github.WorkflowJob{ + { + ID: github.Ptr(int64(1)), + Name: github.Ptr("test-job-1"), + Conclusion: github.Ptr("success"), + }, + { + ID: github.Ptr(int64(2)), + Name: github.Ptr("test-job-2"), + Conclusion: github.Ptr("success"), + }, + }, + } + w.WriteHeader(http.StatusOK) + _ = json.NewEncoder(w).Encode(jobs) + }), + ), + ) + + client := github.NewClient(mockedClient) + deps := BaseDeps{ + Client: client, + ContentWindowSize: 5000, + } + handler := toolDef.Handler(deps) + + request := createMCPRequest(map[string]any{ + "owner": "owner", + "repo": "repo", + "run_id": float64(456), + "failed_only": true, + }) + result, err := handler(context.Background(), &request) + + require.NoError(t, err) + require.False(t, result.IsError) + + textContent := getTextResult(t, result) + var response map[string]any + err = json.Unmarshal([]byte(textContent.Text), &response) + require.NoError(t, err) + assert.Equal(t, "No failed jobs found in this workflow run", response["message"]) + }) } From c294bd97dbfb01be2c7b05e42a15e7feb8d0fe49 Mon Sep 17 00:00:00 2001 From: Matt Holloway Date: Thu, 18 Dec 2025 14:31:34 +0000 Subject: [PATCH 3/5] update consolidated actions tools for new handler pattern --- pkg/github/actions.go | 410 ++++++++++++++++++------------------- pkg/github/actions_test.go | 36 ++-- 2 files changed, 219 insertions(+), 227 deletions(-) diff --git a/pkg/github/actions.go b/pkg/github/actions.go index 50e768ae7..6c7cdc367 100644 --- a/pkg/github/actions.go +++ b/pkg/github/actions.go @@ -1550,70 +1550,68 @@ Use this tool to list workflows in a repository, or list workflow runs, jobs, an Required: []string{"method", "owner", "repo"}, }, }, - func(deps ToolDependencies) mcp.ToolHandlerFor[map[string]any, any] { - return func(ctx context.Context, _ *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 - } + 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 - } + repo, err := RequiredParam[string](args, "repo") + if err != nil { + return utils.NewToolResultError(err.Error()), nil, nil + } - method, err := RequiredParam[string](args, "method") - if err != nil { - return utils.NewToolResultError(err.Error()), nil, nil - } + method, err := RequiredParam[string](args, "method") + if err != nil { + return utils.NewToolResultError(err.Error()), nil, nil + } - resourceID, err := OptionalParam[string](args, "resource_id") - if err != nil { - return utils.NewToolResultError(err.Error()), nil, nil - } + resourceID, err := OptionalParam[string](args, "resource_id") + if err != nil { + return utils.NewToolResultError(err.Error()), nil, nil + } - pagination, err := OptionalPaginationParams(args) - 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 nil, nil, fmt.Errorf("failed to get GitHub client: %w", err) - } + client, err := deps.GetClient(ctx) + if err != nil { + return nil, nil, fmt.Errorf("failed to get GitHub client: %w", err) + } - var resourceIDInt int64 - var parseErr error - switch method { - case actionsMethodListWorkflows: - // Do nothing, no resource ID needed - default: - if resourceID == "" { - return utils.NewToolResultError(fmt.Sprintf("missing required parameter for method %s: resource_id", method)), nil, nil - } + var resourceIDInt int64 + var parseErr error + switch method { + case actionsMethodListWorkflows: + // Do nothing, no resource ID needed + default: + if resourceID == "" { + return utils.NewToolResultError(fmt.Sprintf("missing required parameter for method %s: resource_id", method)), nil, nil + } - // For list_workflow_runs, resource_id could be a filename or numeric ID - // For other actions, resource ID must be an integer - if method != actionsMethodListWorkflowRuns { - resourceIDInt, parseErr = strconv.ParseInt(resourceID, 10, 64) - if parseErr != nil { - return utils.NewToolResultError(fmt.Sprintf("invalid resource_id, must be an integer for method %s: %v", method, parseErr)), nil, nil - } + // For list_workflow_runs, resource_id could be a filename or numeric ID + // For other actions, resource ID must be an integer + if method != actionsMethodListWorkflowRuns { + resourceIDInt, parseErr = strconv.ParseInt(resourceID, 10, 64) + if parseErr != nil { + return utils.NewToolResultError(fmt.Sprintf("invalid resource_id, must be an integer for method %s: %v", method, parseErr)), nil, nil } } + } - switch method { - case actionsMethodListWorkflows: - return listWorkflows(ctx, client, owner, repo, pagination) - case actionsMethodListWorkflowRuns: - return listWorkflowRuns(ctx, client, args, owner, repo, resourceID, pagination) - case actionsMethodListWorkflowJobs: - return listWorkflowJobs(ctx, client, args, owner, repo, resourceIDInt, pagination) - case actionsMethodListWorkflowArtifacts: - return listWorkflowArtifacts(ctx, client, owner, repo, resourceIDInt, pagination) - default: - return utils.NewToolResultError(fmt.Sprintf("unknown method: %s", method)), nil, nil - } + switch method { + case actionsMethodListWorkflows: + return listWorkflows(ctx, client, owner, repo, pagination) + case actionsMethodListWorkflowRuns: + return listWorkflowRuns(ctx, client, args, owner, repo, resourceID, pagination) + case actionsMethodListWorkflowJobs: + return listWorkflowJobs(ctx, client, args, owner, repo, resourceIDInt, pagination) + case actionsMethodListWorkflowArtifacts: + return listWorkflowArtifacts(ctx, client, owner, repo, resourceIDInt, pagination) + default: + return utils.NewToolResultError(fmt.Sprintf("unknown method: %s", method)), nil, nil } }, ) @@ -1670,60 +1668,58 @@ Use this tool to get details about individual workflows, workflow runs, jobs, an Required: []string{"method", "owner", "repo", "resource_id"}, }, }, - func(deps ToolDependencies) mcp.ToolHandlerFor[map[string]any, any] { - return func(ctx context.Context, _ *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 - } - method, err := RequiredParam[string](args, "method") - if err != nil { - return utils.NewToolResultError(err.Error()), nil, nil - } + 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 + } + method, err := RequiredParam[string](args, "method") + if err != nil { + return utils.NewToolResultError(err.Error()), nil, nil + } - resourceID, err := RequiredParam[string](args, "resource_id") - if err != nil { - return utils.NewToolResultError(err.Error()), nil, nil - } + resourceID, err := RequiredParam[string](args, "resource_id") + if err != nil { + return utils.NewToolResultError(err.Error()), nil, nil + } - client, err := deps.GetClient(ctx) - if err != nil { - return nil, nil, fmt.Errorf("failed to get GitHub client: %w", err) - } + client, err := deps.GetClient(ctx) + if err != nil { + return nil, nil, fmt.Errorf("failed to get GitHub client: %w", err) + } - var resourceIDInt int64 - var parseErr error - switch method { - case actionsMethodGetWorkflow: - // Do nothing, we accept both a string workflow ID or filename - default: - // For other methods, resource ID must be an integer - resourceIDInt, parseErr = strconv.ParseInt(resourceID, 10, 64) - if parseErr != nil { - return utils.NewToolResultError(fmt.Sprintf("invalid resource_id, must be an integer for method %s: %v", method, parseErr)), nil, nil - } + var resourceIDInt int64 + var parseErr error + switch method { + case actionsMethodGetWorkflow: + // Do nothing, we accept both a string workflow ID or filename + default: + // For other methods, resource ID must be an integer + resourceIDInt, parseErr = strconv.ParseInt(resourceID, 10, 64) + if parseErr != nil { + return utils.NewToolResultError(fmt.Sprintf("invalid resource_id, must be an integer for method %s: %v", method, parseErr)), nil, nil } + } - switch method { - case actionsMethodGetWorkflow: - return getWorkflow(ctx, client, owner, repo, resourceID) - case actionsMethodGetWorkflowRun: - return getWorkflowRun(ctx, client, owner, repo, resourceIDInt) - case actionsMethodGetWorkflowJob: - return getWorkflowJob(ctx, client, owner, repo, resourceIDInt) - case actionsMethodDownloadWorkflowArtifact: - return downloadWorkflowArtifact(ctx, client, owner, repo, resourceIDInt) - case actionsMethodGetWorkflowRunUsage: - return getWorkflowRunUsage(ctx, client, owner, repo, resourceIDInt) - case actionsMethodGetWorkflowRunLogsURL: - return getWorkflowRunLogsURL(ctx, client, owner, repo, resourceIDInt) - default: - return utils.NewToolResultError(fmt.Sprintf("unknown method: %s", method)), nil, nil - } + switch method { + case actionsMethodGetWorkflow: + return getWorkflow(ctx, client, owner, repo, resourceID) + case actionsMethodGetWorkflowRun: + return getWorkflowRun(ctx, client, owner, repo, resourceIDInt) + case actionsMethodGetWorkflowJob: + return getWorkflowJob(ctx, client, owner, repo, resourceIDInt) + case actionsMethodDownloadWorkflowArtifact: + return downloadWorkflowArtifact(ctx, client, owner, repo, resourceIDInt) + case actionsMethodGetWorkflowRunUsage: + return getWorkflowRunUsage(ctx, client, owner, repo, resourceIDInt) + case actionsMethodGetWorkflowRunLogsURL: + return getWorkflowRunLogsURL(ctx, client, owner, repo, resourceIDInt) + default: + return utils.NewToolResultError(fmt.Sprintf("unknown method: %s", method)), nil, nil } }, ) @@ -1785,65 +1781,63 @@ func ActionsRunTrigger(t translations.TranslationHelperFunc) inventory.ServerToo Required: []string{"method", "owner", "repo"}, }, }, - func(deps ToolDependencies) mcp.ToolHandlerFor[map[string]any, any] { - return func(ctx context.Context, _ *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 - } - method, err := RequiredParam[string](args, "method") - if err != nil { - return utils.NewToolResultError(err.Error()), nil, nil - } + 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 + } + method, err := RequiredParam[string](args, "method") + if err != nil { + return utils.NewToolResultError(err.Error()), nil, nil + } - // Get optional parameters - workflowID, _ := OptionalParam[string](args, "workflow_id") - ref, _ := OptionalParam[string](args, "ref") - runID, _ := OptionalIntParam(args, "run_id") + // Get optional parameters + workflowID, _ := OptionalParam[string](args, "workflow_id") + ref, _ := OptionalParam[string](args, "ref") + runID, _ := OptionalIntParam(args, "run_id") - // Get optional inputs parameter - var inputs map[string]interface{} - if requestInputs, ok := args["inputs"]; ok { - if inputsMap, ok := requestInputs.(map[string]interface{}); ok { - inputs = inputsMap - } + // Get optional inputs parameter + var inputs map[string]interface{} + if requestInputs, ok := args["inputs"]; ok { + if inputsMap, ok := requestInputs.(map[string]interface{}); ok { + inputs = inputsMap } + } - // Validate required parameters based on action type - if method == actionsMethodRunWorkflow { - if workflowID == "" { - return utils.NewToolResultError("workflow_id is required for run_workflow action"), nil, nil - } - if ref == "" { - return utils.NewToolResultError("ref is required for run_workflow action"), nil, nil - } - } else if runID == 0 { - return utils.NewToolResultError("missing required parameter: run_id"), nil, nil + // Validate required parameters based on action type + if method == actionsMethodRunWorkflow { + if workflowID == "" { + return utils.NewToolResultError("workflow_id is required for run_workflow action"), nil, nil } - - client, err := deps.GetClient(ctx) - if err != nil { - return nil, nil, fmt.Errorf("failed to get GitHub client: %w", err) + if ref == "" { + return utils.NewToolResultError("ref is required for run_workflow action"), nil, nil } + } else if runID == 0 { + return utils.NewToolResultError("missing required parameter: run_id"), nil, nil + } - switch method { - case actionsMethodRunWorkflow: - return runWorkflow(ctx, client, owner, repo, workflowID, ref, inputs) - case actionsMethodRerunWorkflowRun: - return rerunWorkflowRun(ctx, client, owner, repo, int64(runID)) - case actionsMethodRerunFailedJobs: - return rerunFailedJobs(ctx, client, owner, repo, int64(runID)) - case actionsMethodCancelWorkflowRun: - return cancelWorkflowRun(ctx, client, owner, repo, int64(runID)) - case actionsMethodDeleteWorkflowRunLogs: - return deleteWorkflowRunLogs(ctx, client, owner, repo, int64(runID)) - default: - return utils.NewToolResultError(fmt.Sprintf("unknown method: %s", method)), nil, nil - } + client, err := deps.GetClient(ctx) + if err != nil { + return nil, nil, fmt.Errorf("failed to get GitHub client: %w", err) + } + + switch method { + case actionsMethodRunWorkflow: + return runWorkflow(ctx, client, owner, repo, workflowID, ref, inputs) + case actionsMethodRerunWorkflowRun: + return rerunWorkflowRun(ctx, client, owner, repo, int64(runID)) + case actionsMethodRerunFailedJobs: + return rerunFailedJobs(ctx, client, owner, repo, int64(runID)) + case actionsMethodCancelWorkflowRun: + return cancelWorkflowRun(ctx, client, owner, repo, int64(runID)) + case actionsMethodDeleteWorkflowRunLogs: + return deleteWorkflowRunLogs(ctx, client, owner, repo, int64(runID)) + default: + return utils.NewToolResultError(fmt.Sprintf("unknown method: %s", method)), nil, nil } }, ) @@ -1901,69 +1895,67 @@ For single job logs, provide job_id. For all failed jobs in a run, provide run_i Required: []string{"owner", "repo"}, }, }, - func(deps ToolDependencies) mcp.ToolHandlerFor[map[string]any, any] { - return func(ctx context.Context, _ *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 - } - - jobID, err := OptionalIntParam(args, "job_id") - if err != nil { - return utils.NewToolResultError(err.Error()), nil, nil - } + 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 + } - runID, err := OptionalIntParam(args, "run_id") - if err != nil { - return utils.NewToolResultError(err.Error()), nil, nil - } + jobID, err := OptionalIntParam(args, "job_id") + if err != nil { + return utils.NewToolResultError(err.Error()), nil, nil + } - failedOnly, err := OptionalParam[bool](args, "failed_only") - if err != nil { - return utils.NewToolResultError(err.Error()), nil, nil - } + runID, err := OptionalIntParam(args, "run_id") + if err != nil { + return utils.NewToolResultError(err.Error()), nil, nil + } - returnContent, err := OptionalParam[bool](args, "return_content") - if err != nil { - return utils.NewToolResultError(err.Error()), nil, nil - } + failedOnly, err := OptionalParam[bool](args, "failed_only") + if err != nil { + return utils.NewToolResultError(err.Error()), nil, nil + } - tailLines, err := OptionalIntParam(args, "tail_lines") - if err != nil { - return utils.NewToolResultError(err.Error()), nil, nil - } - // Default to 500 lines if not specified - if tailLines == 0 { - tailLines = 500 - } + returnContent, err := OptionalParam[bool](args, "return_content") + if err != nil { + return utils.NewToolResultError(err.Error()), nil, nil + } - client, err := deps.GetClient(ctx) - if err != nil { - return nil, nil, fmt.Errorf("failed to get GitHub client: %w", err) - } + tailLines, err := OptionalIntParam(args, "tail_lines") + if err != nil { + return utils.NewToolResultError(err.Error()), nil, nil + } + // Default to 500 lines if not specified + if tailLines == 0 { + tailLines = 500 + } - // Validate parameters - if failedOnly && runID == 0 { - return utils.NewToolResultError("run_id is required when failed_only is true"), nil, nil - } - if !failedOnly && jobID == 0 { - return utils.NewToolResultError("job_id is required when failed_only is false"), nil, nil - } + client, err := deps.GetClient(ctx) + if err != nil { + return nil, nil, fmt.Errorf("failed to get GitHub client: %w", err) + } - if failedOnly && runID > 0 { - // Handle failed-only mode: get logs for all failed jobs in the workflow run - return handleFailedJobLogs(ctx, client, owner, repo, int64(runID), returnContent, tailLines, deps.GetContentWindowSize()) - } else if jobID > 0 { - // Handle single job mode - return handleSingleJobLogs(ctx, client, owner, repo, int64(jobID), returnContent, tailLines, deps.GetContentWindowSize()) - } + // Validate parameters + if failedOnly && runID == 0 { + return utils.NewToolResultError("run_id is required when failed_only is true"), nil, nil + } + if !failedOnly && jobID == 0 { + return utils.NewToolResultError("job_id is required when failed_only is false"), nil, nil + } - return utils.NewToolResultError("Either job_id must be provided for single job logs, or run_id with failed_only=true for failed job logs"), nil, nil + if failedOnly && runID > 0 { + // Handle failed-only mode: get logs for all failed jobs in the workflow run + return handleFailedJobLogs(ctx, client, owner, repo, int64(runID), returnContent, tailLines, deps.GetContentWindowSize()) + } else if jobID > 0 { + // Handle single job mode + return handleSingleJobLogs(ctx, client, owner, repo, int64(jobID), returnContent, tailLines, deps.GetContentWindowSize()) } + + return utils.NewToolResultError("Either job_id must be provided for single job logs, or run_id with failed_only=true for failed job logs"), nil, nil }, ) tool.FeatureFlagEnable = FeatureFlagConsolidatedActions diff --git a/pkg/github/actions_test.go b/pkg/github/actions_test.go index 627ddc9ad..6ab328cd1 100644 --- a/pkg/github/actions_test.go +++ b/pkg/github/actions_test.go @@ -1499,7 +1499,7 @@ func Test_RerunFailedJobs(t *testing.T) { handler := toolDef.Handler(deps) request := createMCPRequest(tc.requestArgs) - result, err := handler(context.Background(), &request) + result, err := handler(ContextWithDeps(context.Background(), deps), &request) require.NoError(t, err) require.Equal(t, tc.expectError, result.IsError) @@ -1571,7 +1571,7 @@ func Test_RerunWorkflowRun_Behavioral(t *testing.T) { handler := toolDef.Handler(deps) request := createMCPRequest(tc.requestArgs) - result, err := handler(context.Background(), &request) + result, err := handler(ContextWithDeps(context.Background(), deps), &request) require.NoError(t, err) require.Equal(t, tc.expectError, result.IsError) @@ -1658,7 +1658,7 @@ func Test_ListWorkflowRuns_Behavioral(t *testing.T) { handler := toolDef.Handler(deps) request := createMCPRequest(tc.requestArgs) - result, err := handler(context.Background(), &request) + result, err := handler(ContextWithDeps(context.Background(), deps), &request) require.NoError(t, err) require.Equal(t, tc.expectError, result.IsError) @@ -1734,7 +1734,7 @@ func Test_GetWorkflowRun_Behavioral(t *testing.T) { handler := toolDef.Handler(deps) request := createMCPRequest(tc.requestArgs) - result, err := handler(context.Background(), &request) + result, err := handler(ContextWithDeps(context.Background(), deps), &request) require.NoError(t, err) require.Equal(t, tc.expectError, result.IsError) @@ -1804,7 +1804,7 @@ func Test_GetWorkflowRunLogs_Behavioral(t *testing.T) { handler := toolDef.Handler(deps) request := createMCPRequest(tc.requestArgs) - result, err := handler(context.Background(), &request) + result, err := handler(ContextWithDeps(context.Background(), deps), &request) require.NoError(t, err) require.Equal(t, tc.expectError, result.IsError) @@ -1891,7 +1891,7 @@ func Test_ListWorkflowJobs_Behavioral(t *testing.T) { handler := toolDef.Handler(deps) request := createMCPRequest(tc.requestArgs) - result, err := handler(context.Background(), &request) + result, err := handler(ContextWithDeps(context.Background(), deps), &request) require.NoError(t, err) require.Equal(t, tc.expectError, result.IsError) @@ -1993,7 +1993,7 @@ func Test_ActionsList_ListWorkflows(t *testing.T) { handler := toolDef.Handler(deps) request := createMCPRequest(tc.requestArgs) - result, err := handler(context.Background(), &request) + result, err := handler(ContextWithDeps(context.Background(), deps), &request) require.NoError(t, err) require.Equal(t, tc.expectError, result.IsError) @@ -2051,7 +2051,7 @@ func Test_ActionsList_ListWorkflowRuns(t *testing.T) { "repo": "repo", "resource_id": "ci.yml", }) - result, err := handler(context.Background(), &request) + result, err := handler(ContextWithDeps(context.Background(), deps), &request) require.NoError(t, err) require.False(t, result.IsError) @@ -2077,7 +2077,7 @@ func Test_ActionsList_ListWorkflowRuns(t *testing.T) { "owner": "owner", "repo": "repo", }) - result, err := handler(context.Background(), &request) + result, err := handler(ContextWithDeps(context.Background(), deps), &request) require.NoError(t, err) require.True(t, result.IsError) @@ -2134,7 +2134,7 @@ func Test_ActionsGet_GetWorkflow(t *testing.T) { "repo": "repo", "resource_id": "ci.yml", }) - result, err := handler(context.Background(), &request) + result, err := handler(ContextWithDeps(context.Background(), deps), &request) require.NoError(t, err) require.False(t, result.IsError) @@ -2180,7 +2180,7 @@ func Test_ActionsGet_GetWorkflowRun(t *testing.T) { "repo": "repo", "resource_id": "12345", }) - result, err := handler(context.Background(), &request) + result, err := handler(ContextWithDeps(context.Background(), deps), &request) require.NoError(t, err) require.False(t, result.IsError) @@ -2275,7 +2275,7 @@ func Test_ActionsRunTrigger_RunWorkflow(t *testing.T) { handler := toolDef.Handler(deps) request := createMCPRequest(tc.requestArgs) - result, err := handler(context.Background(), &request) + result, err := handler(ContextWithDeps(context.Background(), deps), &request) require.NoError(t, err) require.Equal(t, tc.expectError, result.IsError) @@ -2323,7 +2323,7 @@ func Test_ActionsRunTrigger_CancelWorkflowRun(t *testing.T) { "repo": "repo", "run_id": float64(12345), }) - result, err := handler(context.Background(), &request) + result, err := handler(ContextWithDeps(context.Background(), deps), &request) require.NoError(t, err) require.False(t, result.IsError) @@ -2360,7 +2360,7 @@ func Test_ActionsRunTrigger_CancelWorkflowRun(t *testing.T) { "repo": "repo", "run_id": float64(12345), }) - result, err := handler(context.Background(), &request) + result, err := handler(ContextWithDeps(context.Background(), deps), &request) require.NoError(t, err) require.True(t, result.IsError) @@ -2383,7 +2383,7 @@ func Test_ActionsRunTrigger_CancelWorkflowRun(t *testing.T) { "owner": "owner", "repo": "repo", }) - result, err := handler(context.Background(), &request) + result, err := handler(ContextWithDeps(context.Background(), deps), &request) require.NoError(t, err) require.True(t, result.IsError) @@ -2439,7 +2439,7 @@ func Test_ActionsGetJobLogs_SingleJob(t *testing.T) { "repo": "repo", "job_id": float64(123), }) - result, err := handler(context.Background(), &request) + result, err := handler(ContextWithDeps(context.Background(), deps), &request) require.NoError(t, err) require.False(t, result.IsError) @@ -2508,7 +2508,7 @@ func Test_ActionsGetJobLogs_FailedJobs(t *testing.T) { "run_id": float64(456), "failed_only": true, }) - result, err := handler(context.Background(), &request) + result, err := handler(ContextWithDeps(context.Background(), deps), &request) require.NoError(t, err) require.False(t, result.IsError) @@ -2561,7 +2561,7 @@ func Test_ActionsGetJobLogs_FailedJobs(t *testing.T) { "run_id": float64(456), "failed_only": true, }) - result, err := handler(context.Background(), &request) + result, err := handler(ContextWithDeps(context.Background(), deps), &request) require.NoError(t, err) require.False(t, result.IsError) From f47157abf0d0fb00ba008e46786ed8e7fa01df09 Mon Sep 17 00:00:00 2001 From: Matt Holloway Date: Thu, 18 Dec 2025 14:39:03 +0000 Subject: [PATCH 4/5] update tests --- pkg/github/actions_test.go | 1 + pkg/github/helper_test.go | 23 +++++++++++++++-------- 2 files changed, 16 insertions(+), 8 deletions(-) diff --git a/pkg/github/actions_test.go b/pkg/github/actions_test.go index 1c9aec117..f2d336e21 100644 --- a/pkg/github/actions_test.go +++ b/pkg/github/actions_test.go @@ -18,6 +18,7 @@ import ( "github.com/github/github-mcp-server/pkg/translations" "github.com/google/go-github/v79/github" "github.com/google/jsonschema-go/jsonschema" + "github.com/migueleliasweb/go-github-mock/src/mock" "github.com/stretchr/testify/assert" "github.com/stretchr/testify/require" ) diff --git a/pkg/github/helper_test.go b/pkg/github/helper_test.go index 972903520..1b9429515 100644 --- a/pkg/github/helper_test.go +++ b/pkg/github/helper_test.go @@ -40,14 +40,14 @@ const ( DeleteReposSubscriptionByOwnerByRepo = "DELETE /repos/{owner}/{repo}/subscription" // Git endpoints - GetReposGitTreesByOwnerByRepoByTree = "GET /repos/{owner}/{repo}/git/trees/{tree}" - GetReposGitRefByOwnerByRepoByRef = "GET /repos/{owner}/{repo}/git/ref/{ref}" - PostReposGitRefsByOwnerByRepo = "POST /repos/{owner}/{repo}/git/refs" - PatchReposGitRefsByOwnerByRepoByRef = "PATCH /repos/{owner}/{repo}/git/refs/{ref}" - GetReposGitCommitsByOwnerByRepoByCommitSHA = "GET /repos/{owner}/{repo}/git/commits/{commit_sha}" - PostReposGitCommitsByOwnerByRepo = "POST /repos/{owner}/{repo}/git/commits" - GetReposGitTagsByOwnerByRepoByTagSHA = "GET /repos/{owner}/{repo}/git/tags/{tag_sha}" - PostReposGitTreesByOwnerByRepo = "POST /repos/{owner}/{repo}/git/trees" + GetReposGitTreesByOwnerByRepoByTree = "GET /repos/{owner}/{repo}/git/trees/{tree}" + GetReposGitRefByOwnerByRepoByRef = "GET /repos/{owner}/{repo}/git/ref/{ref}" + PostReposGitRefsByOwnerByRepo = "POST /repos/{owner}/{repo}/git/refs" + PatchReposGitRefsByOwnerByRepoByRef = "PATCH /repos/{owner}/{repo}/git/refs/{ref}" + GetReposGitCommitsByOwnerByRepoByCommitSHA = "GET /repos/{owner}/{repo}/git/commits/{commit_sha}" + PostReposGitCommitsByOwnerByRepo = "POST /repos/{owner}/{repo}/git/commits" + GetReposGitTagsByOwnerByRepoByTagSHA = "GET /repos/{owner}/{repo}/git/tags/{tag_sha}" + PostReposGitTreesByOwnerByRepo = "POST /repos/{owner}/{repo}/git/trees" GetReposCommitsStatusByOwnerByRepoByRef = "GET /repos/{owner}/{repo}/commits/{ref}/status" GetReposCommitsStatusesByOwnerByRepoByRef = "GET /repos/{owner}/{repo}/commits/{ref}/statuses" @@ -115,10 +115,17 @@ const ( // Actions endpoints GetReposActionsWorkflowsByOwnerByRepo = "GET /repos/{owner}/{repo}/actions/workflows" + GetReposActionsWorkflowsByOwnerByRepoByWorkflowID = "GET /repos/{owner}/{repo}/actions/workflows/{workflow_id}" PostReposActionsWorkflowsDispatchesByOwnerByRepoByWorkflowID = "POST /repos/{owner}/{repo}/actions/workflows/{workflow_id}/dispatches" + GetReposActionsWorkflowsRunsByOwnerByRepoByWorkflowID = "GET /repos/{owner}/{repo}/actions/workflows/{workflow_id}/runs" + GetReposActionsRunsByOwnerByRepoByRunID = "GET /repos/{owner}/{repo}/actions/runs/{run_id}" + GetReposActionsRunsLogsByOwnerByRepoByRunID = "GET /repos/{owner}/{repo}/actions/runs/{run_id}/logs" GetReposActionsRunsJobsByOwnerByRepoByRunID = "GET /repos/{owner}/{repo}/actions/runs/{run_id}/jobs" GetReposActionsRunsArtifactsByOwnerByRepoByRunID = "GET /repos/{owner}/{repo}/actions/runs/{run_id}/artifacts" GetReposActionsRunsTimingByOwnerByRepoByRunID = "GET /repos/{owner}/{repo}/actions/runs/{run_id}/timing" + PostReposActionsRunsRerunByOwnerByRepoByRunID = "POST /repos/{owner}/{repo}/actions/runs/{run_id}/rerun" + PostReposActionsRunsRerunFailedJobsByOwnerByRepoByRunID = "POST /repos/{owner}/{repo}/actions/runs/{run_id}/rerun-failed-jobs" + PostReposActionsRunsCancelByOwnerByRepoByRunID = "POST /repos/{owner}/{repo}/actions/runs/{run_id}/cancel" GetReposActionsJobsLogsByOwnerByRepoByJobID = "GET /repos/{owner}/{repo}/actions/jobs/{job_id}/logs" DeleteReposActionsRunsLogsByOwnerByRepoByRunID = "DELETE /repos/{owner}/{repo}/actions/runs/{run_id}/logs" From 87a902919a18c235cbe3395f1aec09a700bacf24 Mon Sep 17 00:00:00 2001 From: Matt Holloway Date: Thu, 18 Dec 2025 14:47:12 +0000 Subject: [PATCH 5/5] refine pattern matching logic to prioritise non-wildcard handlers in multiHandlerTransport --- pkg/github/helper_test.go | 33 +++++++++++++++++++++++++++++---- 1 file changed, 29 insertions(+), 4 deletions(-) diff --git a/pkg/github/helper_test.go b/pkg/github/helper_test.go index 1b9429515..56a236660 100644 --- a/pkg/github/helper_test.go +++ b/pkg/github/helper_test.go @@ -582,20 +582,45 @@ func (m *multiHandlerTransport) RoundTrip(req *http.Request) (*http.Response, er return executeHandler(handler, req), nil } - // Then try pattern matching + // Then try pattern matching, prioritizing patterns without wildcards + // This is important because wildcard patterns like /{owner}/{repo}/{sha}/{path:.*} + // can incorrectly match API paths like /repos/owner/repo/pulls/42 + var wildcardPattern string + var wildcardHandler http.HandlerFunc + for pattern, handler := range m.handlers { if pattern == "" { continue // Skip catch-all } parts := strings.SplitN(pattern, " ", 2) - if len(parts) == 2 { - method, pathPattern := parts[0], parts[1] - if req.Method == method && matchPath(pathPattern, req.URL.Path) { + if len(parts) != 2 { + continue + } + method, pathPattern := parts[0], parts[1] + if req.Method != method { + continue + } + + // Check if this pattern contains a wildcard like {path:.*} + isWildcard := strings.Contains(pathPattern, ":.*}") + + if matchPath(pathPattern, req.URL.Path) { + if isWildcard { + // Save wildcard match for later, prefer non-wildcard patterns + wildcardPattern = pattern + wildcardHandler = handler + } else { + // Non-wildcard pattern takes priority return executeHandler(handler, req), nil } } } + // If we found a wildcard match but no specific match, use it + if wildcardPattern != "" && wildcardHandler != nil { + return executeHandler(wildcardHandler, req), nil + } + // No handler found return &http.Response{ StatusCode: http.StatusNotFound,