diff --git a/docs/remote-server.md b/docs/remote-server.md index fa55168e5..1e718b24a 100644 --- a/docs/remote-server.md +++ b/docs/remote-server.md @@ -22,6 +22,8 @@ Below is a table of available toolsets for the remote GitHub MCP Server. Each to | all | All available GitHub MCP tools | https://api.githubcopilot.com/mcp/ | [Install](https://insiders.vscode.dev/redirect/mcp/install?name=github&config=%7B%22type%22%3A%20%22http%22%2C%22url%22%3A%20%22https%3A%2F%2Fapi.githubcopilot.com%2Fmcp%2F%22%7D) | [read-only](https://api.githubcopilot.com/mcp/readonly) | [Install read-only](https://insiders.vscode.dev/redirect/mcp/install?name=github&config=%7B%22type%22%3A%20%22http%22%2C%22url%22%3A%20%22https%3A%2F%2Fapi.githubcopilot.com%2Fmcp%2Freadonly%22%7D) | | Actions | GitHub Actions workflows and CI/CD operations | https://api.githubcopilot.com/mcp/x/actions | [Install](https://insiders.vscode.dev/redirect/mcp/install?name=gh-actions&config=%7B%22type%22%3A%20%22http%22%2C%22url%22%3A%20%22https%3A%2F%2Fapi.githubcopilot.com%2Fmcp%2Fx%2Factions%22%7D) | [read-only](https://api.githubcopilot.com/mcp/x/actions/readonly) | [Install read-only](https://insiders.vscode.dev/redirect/mcp/install?name=gh-actions&config=%7B%22type%22%3A%20%22http%22%2C%22url%22%3A%20%22https%3A%2F%2Fapi.githubcopilot.com%2Fmcp%2Fx%2Factions%2Freadonly%22%7D) | | Code Security | Code security related tools, such as GitHub Code Scanning | https://api.githubcopilot.com/mcp/x/code_security | [Install](https://insiders.vscode.dev/redirect/mcp/install?name=gh-code_security&config=%7B%22type%22%3A%20%22http%22%2C%22url%22%3A%20%22https%3A%2F%2Fapi.githubcopilot.com%2Fmcp%2Fx%2Fcode_security%22%7D) | [read-only](https://api.githubcopilot.com/mcp/x/code_security/readonly) | [Install read-only](https://insiders.vscode.dev/redirect/mcp/install?name=gh-code_security&config=%7B%22type%22%3A%20%22http%22%2C%22url%22%3A%20%22https%3A%2F%2Fapi.githubcopilot.com%2Fmcp%2Fx%2Fcode_security%2Freadonly%22%7D) | +| Copilot | Copilot related tools | https://api.githubcopilot.com/mcp/x/copilot | [Install](https://insiders.vscode.dev/redirect/mcp/install?name=gh-copilot_spaces&config=%7B%22type%22%3A%20%22http%22%2C%22url%22%3A%20%22https%3A%2F%2Fapi.githubcopilot.com%2Fmcp%2Fx%2Fcopilot_spaces%22%7D) | [read-only](https://api.githubcopilot.com/mcp/x/copilot_spaces/readonly) | [Install read-only](https://insiders.vscode.dev/redirect/mcp/install?name=gh-code_security&config=%7B%22type%22%3A%20%22http%22%2C%22url%22%3A%20%22https%3A%2F%2Fapi.githubcopilot.com%2Fmcp%2Fx%2Fcopilot_spaces%2Freadonly%22%7D) | +| Copilot Spaces | Copilot Spaces tools | https://api.githubcopilot.com/mcp/x/copilot_spaces | [Install](https://insiders.vscode.dev/redirect/mcp/install?name=gh-copilot&config=%7B%22type%22%3A%20%22http%22%2C%22url%22%3A%20%22https%3A%2F%2Fapi.githubcopilot.com%2Fmcp%2Fx%2Fcopilot_spaces%22%7D) | [read-only](https://api.githubcopilot.com/mcp/x/copilot_spaces/readonly) | [Install read-only](https://insiders.vscode.dev/redirect/mcp/install?name=gh-copilot&config=%7B%22type%22%3A%20%22http%22%2C%22url%22%3A%20%22https%3A%2F%2Fapi.githubcopilot.com%2Fmcp%2Fx%2Fcopilot%2Freadonly%22%7D) | | Dependabot | Dependabot tools | https://api.githubcopilot.com/mcp/x/dependabot | [Install](https://insiders.vscode.dev/redirect/mcp/install?name=gh-dependabot&config=%7B%22type%22%3A%20%22http%22%2C%22url%22%3A%20%22https%3A%2F%2Fapi.githubcopilot.com%2Fmcp%2Fx%2Fdependabot%22%7D) | [read-only](https://api.githubcopilot.com/mcp/x/dependabot/readonly) | [Install read-only](https://insiders.vscode.dev/redirect/mcp/install?name=gh-dependabot&config=%7B%22type%22%3A%20%22http%22%2C%22url%22%3A%20%22https%3A%2F%2Fapi.githubcopilot.com%2Fmcp%2Fx%2Fdependabot%2Freadonly%22%7D) | | Discussions | GitHub Discussions related tools | https://api.githubcopilot.com/mcp/x/discussions | [Install](https://insiders.vscode.dev/redirect/mcp/install?name=gh-discussions&config=%7B%22type%22%3A%20%22http%22%2C%22url%22%3A%20%22https%3A%2F%2Fapi.githubcopilot.com%2Fmcp%2Fx%2Fdiscussions%22%7D) | [read-only](https://api.githubcopilot.com/mcp/x/discussions/readonly) | [Install read-only](https://insiders.vscode.dev/redirect/mcp/install?name=gh-discussions&config=%7B%22type%22%3A%20%22http%22%2C%22url%22%3A%20%22https%3A%2F%2Fapi.githubcopilot.com%2Fmcp%2Fx%2Fdiscussions%2Freadonly%22%7D) | | Experiments | Experimental features that are not considered stable yet | https://api.githubcopilot.com/mcp/x/experiments | [Install](https://insiders.vscode.dev/redirect/mcp/install?name=gh-experiments&config=%7B%22type%22%3A%20%22http%22%2C%22url%22%3A%20%22https%3A%2F%2Fapi.githubcopilot.com%2Fmcp%2Fx%2Fexperiments%22%7D) | [read-only](https://api.githubcopilot.com/mcp/x/experiments/readonly) | [Install read-only](https://insiders.vscode.dev/redirect/mcp/install?name=gh-experiments&config=%7B%22type%22%3A%20%22http%22%2C%22url%22%3A%20%22https%3A%2F%2Fapi.githubcopilot.com%2Fmcp%2Fx%2Fexperiments%2Freadonly%22%7D) | diff --git a/pkg/github/actions.go b/pkg/github/actions.go index 734109587..12ee7de90 100644 --- a/pkg/github/actions.go +++ b/pkg/github/actions.go @@ -22,14 +22,78 @@ const ( DescriptionRepositoryName = "Repository name" ) -// ListWorkflows creates a tool to list workflows in a repository -func ListWorkflows(getClient GetClientFn, t translations.TranslationHelperFunc) (tool mcp.Tool, handler server.ToolHandlerFunc) { - return mcp.NewTool("list_workflows", - mcp.WithDescription(t("TOOL_LIST_WORKFLOWS_DESCRIPTION", "List workflows in a repository")), +type actionsActionType int + +const ( + actionsActionTypeUnknown actionsActionType = iota + actionsActionTypeListWorkflows + actionsActionTypeListWorkflowRuns + actionsActionTypeListWorkflowJobs + actionsActionTypeListWorkflowArtifacts + actionsActionTypeGetWorkflow + actionsActionTypeGetWorkflowRun + actionsActionTypeGetWorkflowJob + actionsActionTypeGetWorkflowRunUsage + actionsActionTypeDownloadWorkflowArtifact + actionsActionTypeRunWorkflow + actionsActionTypeRerunWorkflowRun + actionsActionTypeRerunFailedJobs + actionsActionTypeCancelWorkflowRun +) + +var actionsResourceTypes = map[actionsActionType]string{ + actionsActionTypeListWorkflows: "list_workflows", + actionsActionTypeListWorkflowRuns: "list_workflow_runs", + actionsActionTypeListWorkflowJobs: "list_workflow_jobs", + actionsActionTypeListWorkflowArtifacts: "list_workflow_run_artifacts", + actionsActionTypeGetWorkflow: "get_workflow", + actionsActionTypeGetWorkflowRun: "get_workflow_run", + actionsActionTypeGetWorkflowJob: "get_workflow_job", + actionsActionTypeGetWorkflowRunUsage: "get_workflow_run_usage", + actionsActionTypeDownloadWorkflowArtifact: "download_workflow_run_artifact", + actionsActionTypeRunWorkflow: "run_workflow", + actionsActionTypeRerunWorkflowRun: "rerun_workflow_run", + actionsActionTypeRerunFailedJobs: "rerun_failed_jobs", + actionsActionTypeCancelWorkflowRun: "cancel_workflow_run", +} + +func (r actionsActionType) String() string { + if str, ok := actionsResourceTypes[r]; ok { + return str + } + + return "unknown" +} + +func actionFromString(s string) actionsActionType { + for r, str := range actionsResourceTypes { + if str == strings.ToLower(s) { + return r + } + } + return actionsActionTypeUnknown +} + +func ActionsList(getClient GetClientFn, t translations.TranslationHelperFunc) (tool mcp.Tool, handler server.ToolHandlerFunc) { + return mcp.NewTool("actions_list", + mcp.WithDescription(t("TOOL_ACTIONS_LIST_DESCRIPTION", "List GitHub Actions workflows in a repository.")), + mcp.WithDescription(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. +`)), mcp.WithToolAnnotation(mcp.ToolAnnotation{ - Title: t("TOOL_LIST_WORKFLOWS_USER_TITLE", "List workflows"), + Title: t("TOOL_ACTIONS_LIST_USER_TITLE", "List GitHub Actions workflows in a repository"), ReadOnlyHint: ToBoolPtr(true), }), + mcp.WithString("action", + mcp.Required(), + mcp.Description("The action to perform"), + mcp.Enum( + actionsActionTypeListWorkflows.String(), + actionsActionTypeListWorkflowRuns.String(), + actionsActionTypeListWorkflowJobs.String(), + actionsActionTypeListWorkflowArtifacts.String(), + ), + ), mcp.WithString("owner", mcp.Required(), mcp.Description(DescriptionRepositoryOwner), @@ -38,6 +102,79 @@ func ListWorkflows(getClient GetClientFn, t translations.TranslationHelperFunc) mcp.Required(), mcp.Description(DescriptionRepositoryName), ), + mcp.WithString("resource_id", + mcp.Description(`The unique identifier of the resource. This will vary based on the "action" provided, so ensure you provide the correct ID: +- Do not provide any resource ID for 'list_workflows' action. +- Provide a workflow ID or workflow file name (e.g. ci.yaml) for 'list_workflow_runs' actions. +- Provide a workflow run ID for 'list_workflow_jobs' and 'list_workflow_run_artifacts' actions. +`), + ), + mcp.WithObject("workflow_runs_filter", + mcp.Description("Filters for workflow runs. **ONLY** used when action is 'list_workflow_runs'"), + mcp.Properties(map[string]any{ + "actor": map[string]any{ + "type": "string", + "description": "Filter to a specific GitHub user's workflow runs.", + }, + "branch": map[string]any{ + "type": "string", + "description": "Filter workflow runs to a specific Git branch. Use the name of the branch.", + }, + "event": map[string]any{ + "type": "string", + "description": "Filter workflow runs to a specific event type", + "enum": []string{ + "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": map[string]any{ + "type": "string", + "description": "Filter workflow runs to only runs with a specific status", + "enum": []string{"queued", "in_progress", "completed", "requested", "waiting"}, + }, + }), + ), + mcp.WithObject("workflow_jobs_filter", + mcp.Description("Filters for workflow jobs. **ONLY** used when action is 'list_workflow_jobs'"), + mcp.Properties(map[string]any{ + "filter": map[string]any{ + "type": "string", + "description": "Filters jobs by their completed_at timestamp", + "enum": []string{"latest", "all"}, + }, + }), + ), WithPagination(), ), func(ctx context.Context, request mcp.CallToolRequest) (*mcp.CallToolResult, error) { @@ -45,51 +182,92 @@ func ListWorkflows(getClient GetClientFn, t translations.TranslationHelperFunc) if err != nil { return mcp.NewToolResultError(err.Error()), nil } + repo, err := RequiredParam[string](request, "repo") if err != nil { return mcp.NewToolResultError(err.Error()), nil } - // Get optional pagination parameters - pagination, err := OptionalPaginationParams(request) + actionTypeStr, err := RequiredParam[string](request, "action") if err != nil { return mcp.NewToolResultError(err.Error()), nil } - client, err := getClient(ctx) - if err != nil { - return nil, fmt.Errorf("failed to get GitHub client: %w", err) + resourceType := actionFromString(actionTypeStr) + if resourceType == actionsActionTypeUnknown { + return mcp.NewToolResultError(fmt.Sprintf("unknown action: %s", actionTypeStr)), nil } - // Set up list options - opts := &github.ListOptions{ - PerPage: pagination.PerPage, - Page: pagination.Page, + resourceID, err := OptionalParam[string](request, "resource_id") + if err != nil { + return mcp.NewToolResultError(err.Error()), nil } - workflows, resp, err := client.Actions.ListWorkflows(ctx, owner, repo, opts) + pagination, err := OptionalPaginationParams(request) if err != nil { - return nil, fmt.Errorf("failed to list workflows: %w", err) + return mcp.NewToolResultError(err.Error()), nil } - defer func() { _ = resp.Body.Close() }() - r, err := json.Marshal(workflows) + client, err := getClient(ctx) if err != nil { - return nil, fmt.Errorf("failed to marshal response: %w", err) + return nil, fmt.Errorf("failed to get GitHub client: %w", err) } - return mcp.NewToolResultText(string(r)), nil + var resourceIDInt int64 + var parseErr error + switch resourceType { + case actionsActionTypeListWorkflows: + // Do nothing, no resource ID needed + default: + if resourceID == "" { + return mcp.NewToolResultError(fmt.Sprintf("missing required parameter for action %s: resource_id", actionTypeStr)), nil + } + + // For other actions, resource ID must be an integer + resourceIDInt, parseErr = strconv.ParseInt(resourceID, 10, 64) + if parseErr != nil { + return mcp.NewToolResultError(fmt.Sprintf("invalid resource_id, must be an integer for action %s: %v", actionTypeStr, parseErr)), nil + } + } + + switch resourceType { + case actionsActionTypeListWorkflows: + return listWorkflows(ctx, client, request, owner, repo, pagination) + case actionsActionTypeListWorkflowRuns: + return listWorkflowRuns(ctx, client, request, owner, repo, resourceID, pagination) + case actionsActionTypeListWorkflowJobs: + return listWorkflowJobs(ctx, client, request, owner, repo, resourceIDInt, pagination) + case actionsActionTypeListWorkflowArtifacts: + return listWorkflowArtifacts(ctx, client, request, owner, repo, resourceIDInt, pagination) + case actionsActionTypeUnknown: + return mcp.NewToolResultError(fmt.Sprintf("unknown action: %s", actionTypeStr)), nil + default: + // Should not reach here + return mcp.NewToolResultError(fmt.Sprintf("unknown action: %s", actionTypeStr)), nil + } } } -// ListWorkflowRuns creates a tool to list workflow runs for a specific workflow -func ListWorkflowRuns(getClient GetClientFn, t translations.TranslationHelperFunc) (tool mcp.Tool, handler server.ToolHandlerFunc) { - return mcp.NewTool("list_workflow_runs", - mcp.WithDescription(t("TOOL_LIST_WORKFLOW_RUNS_DESCRIPTION", "List workflow runs for a specific workflow")), +func ActionsGet(getClient GetClientFn, t translations.TranslationHelperFunc) (tool mcp.Tool, handler server.ToolHandlerFunc) { + return mcp.NewTool("actions_get", + mcp.WithDescription(t("TOOL_ACTIONS_READ_DESCRIPTION", `Tools for reading specific GitHub Actions resources. +Use this tool to get details about individual workflows, workflow runs, jobs, and artifacts, by using their unique IDs. +`)), mcp.WithToolAnnotation(mcp.ToolAnnotation{ - Title: t("TOOL_LIST_WORKFLOW_RUNS_USER_TITLE", "List workflow runs"), + Title: t("TOOL_ACTIONS_READ_USER_TITLE", "Get details of GitHub Actions resources (workflows, workflow runs, jobs, and artifacts)"), ReadOnlyHint: ToBoolPtr(true), }), + mcp.WithString("action", + mcp.Required(), + mcp.Description("The action to perform"), + mcp.Enum( + actionsActionTypeGetWorkflow.String(), + actionsActionTypeGetWorkflowRun.String(), + actionsActionTypeGetWorkflowJob.String(), + actionsActionTypeDownloadWorkflowArtifact.String(), + actionsActionTypeGetWorkflowRunUsage.String(), + ), + ), mcp.WithString("owner", mcp.Required(), mcp.Description(DescriptionRepositoryOwner), @@ -98,58 +276,14 @@ func ListWorkflowRuns(getClient GetClientFn, t translations.TranslationHelperFun mcp.Required(), mcp.Description(DescriptionRepositoryName), ), - mcp.WithString("workflow_id", + mcp.WithString("resource_id", mcp.Required(), - mcp.Description("The workflow ID or workflow file name"), - ), - mcp.WithString("actor", - mcp.Description("Returns someone's workflow runs. Use the login for the user who created the workflow run."), + mcp.Description(`The unique identifier of the resource. This will vary based on the "action" provided, so ensure you provide the correct ID: +- Provide a workflow ID or workflow file name (e.g. ci.yaml) for 'get_workflow' action. +- Provide a workflow run ID for 'get_workflow_run', 'download_workflow_run_artifact' and 'get_workflow_run_usage' actions. +- Provide a job ID for the 'get_workflow_job' action. +`), ), - mcp.WithString("branch", - mcp.Description("Returns workflow runs associated with a branch. Use the name of the branch."), - ), - mcp.WithString("event", - mcp.Description("Returns workflow runs for a specific event type"), - mcp.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", - ), - ), - mcp.WithString("status", - mcp.Description("Returns workflow runs with the check run status"), - mcp.Enum("queued", "in_progress", "completed", "requested", "waiting"), - ), - WithPagination(), ), func(ctx context.Context, request mcp.CallToolRequest) (*mcp.CallToolResult, error) { owner, err := RequiredParam[string](request, "owner") @@ -160,31 +294,112 @@ func ListWorkflowRuns(getClient GetClientFn, t translations.TranslationHelperFun if err != nil { return mcp.NewToolResultError(err.Error()), nil } - workflowID, err := RequiredParam[string](request, "workflow_id") + actionTypeStr, err := RequiredParam[string](request, "action") if err != nil { return mcp.NewToolResultError(err.Error()), nil } - // Get optional filtering parameters - actor, err := OptionalParam[string](request, "actor") + resourceType := actionFromString(actionTypeStr) + if resourceType == actionsActionTypeUnknown { + return mcp.NewToolResultError(fmt.Sprintf("unknown action: %s", actionTypeStr)), nil + } + + resourceID, err := RequiredParam[string](request, "resource_id") if err != nil { return mcp.NewToolResultError(err.Error()), nil } - branch, err := OptionalParam[string](request, "branch") + + client, err := getClient(ctx) + if err != nil { + return nil, fmt.Errorf("failed to get GitHub client: %w", err) + } + + var resourceIDInt int64 + var parseErr error + switch resourceType { + case actionsActionTypeGetWorkflow: + // Do nothing, we accept both a string workflow ID or filename + default: + if resourceID == "" { + return mcp.NewToolResultError(fmt.Sprintf("missing required parameter for action %s: resource_id", actionTypeStr)), nil + } + + // For other actions, resource ID must be an integer + resourceIDInt, parseErr = strconv.ParseInt(resourceID, 10, 64) + if parseErr != nil { + return mcp.NewToolResultError(fmt.Sprintf("invalid resource_id, must be an integer for action %s: %v", actionTypeStr, parseErr)), nil + } + } + + switch resourceType { + case actionsActionTypeGetWorkflow: + return getWorkflow(ctx, client, request, owner, repo, resourceID) + case actionsActionTypeGetWorkflowRun: + return getWorkflowRun(ctx, client, request, owner, repo, resourceIDInt) + case actionsActionTypeGetWorkflowJob: + return getWorkflowJob(ctx, client, request, owner, repo, resourceIDInt) + case actionsActionTypeDownloadWorkflowArtifact: + return downloadWorkflowArtifact(ctx, client, request, owner, repo, resourceIDInt) + case actionsActionTypeGetWorkflowRunUsage: + return getWorkflowRunUsage(ctx, client, request, owner, repo, resourceIDInt) + case actionsActionTypeUnknown: + return mcp.NewToolResultError(fmt.Sprintf("unknown action: %s", actionTypeStr)), nil + default: + // Should not reach here + return mcp.NewToolResultError("unhandled action type"), nil + } + } +} + +func ActionsRunTrigger(getClient GetClientFn, t translations.TranslationHelperFunc) (tool mcp.Tool, handler server.ToolHandlerFunc) { + return mcp.NewTool("actions_run_trigger", + mcp.WithDescription(t("TOOL_ACTIONS_TRIGGER_DESCRIPTION", "Trigger GitHub Actions workflow actions for a specific workflow run.")), + mcp.WithToolAnnotation(mcp.ToolAnnotation{ + Title: t("TOOL_ACTIONS_TRIGGER_USER_TITLE", "Trigger GitHub Actions workflow actions for a specific workflow run."), + ReadOnlyHint: ToBoolPtr(false), + }), + mcp.WithString("action", + mcp.Required(), + mcp.Description("The action to trigger"), + mcp.Enum( + actionsActionTypeRerunWorkflowRun.String(), + actionsActionTypeRerunFailedJobs.String(), + actionsActionTypeCancelWorkflowRun.String(), + ), + ), + mcp.WithNumber("run_id", + mcp.Required(), + mcp.Description("The ID of the workflow run to trigger"), + ), + mcp.WithString("owner", + mcp.Required(), + mcp.Description(DescriptionRepositoryOwner), + ), + mcp.WithString("repo", + mcp.Required(), + mcp.Description(DescriptionRepositoryName), + ), + ), + func(ctx context.Context, request mcp.CallToolRequest) (*mcp.CallToolResult, error) { + owner, err := RequiredParam[string](request, "owner") if err != nil { return mcp.NewToolResultError(err.Error()), nil } - event, err := OptionalParam[string](request, "event") + repo, err := RequiredParam[string](request, "repo") if err != nil { return mcp.NewToolResultError(err.Error()), nil } - status, err := OptionalParam[string](request, "status") + actionTypeStr, err := RequiredParam[string](request, "action") if err != nil { return mcp.NewToolResultError(err.Error()), nil } - // Get optional pagination parameters - pagination, err := OptionalPaginationParams(request) + resourceType := actionFromString(actionTypeStr) + if resourceType == actionsActionTypeUnknown { + return mcp.NewToolResultError(fmt.Sprintf("unknown action: %s", actionTypeStr)), nil + } + + runID, err := RequiredInt(request, "run_id") if err != nil { return mcp.NewToolResultError(err.Error()), nil } @@ -194,31 +409,226 @@ func ListWorkflowRuns(getClient GetClientFn, t translations.TranslationHelperFun return nil, fmt.Errorf("failed to get GitHub client: %w", err) } - // Set up list options - opts := &github.ListWorkflowRunsOptions{ - Actor: actor, - Branch: branch, - Event: event, - Status: status, - ListOptions: github.ListOptions{ - PerPage: pagination.PerPage, - Page: pagination.Page, - }, + switch resourceType { + case actionsActionTypeRerunWorkflowRun: + return rerunWorkflowRun(ctx, client, request, owner, repo, int64(runID)) + case actionsActionTypeRerunFailedJobs: + return rerunFailedJobs(ctx, client, request, owner, repo, int64(runID)) + case actionsActionTypeCancelWorkflowRun: + return cancelWorkflowRun(ctx, client, request, owner, repo, int64(runID)) + case actionsActionTypeUnknown: + return mcp.NewToolResultError(fmt.Sprintf("unknown action: %s", actionTypeStr)), nil + default: + // Should not reach here + return mcp.NewToolResultError("unhandled action type"), nil } + } +} - workflowRuns, resp, err := client.Actions.ListWorkflowRunsByFileName(ctx, owner, repo, workflowID, opts) - if err != nil { - return nil, fmt.Errorf("failed to list workflow runs: %w", err) - } - defer func() { _ = resp.Body.Close() }() +func getWorkflow(ctx context.Context, client *github.Client, _ mcp.CallToolRequest, owner, repo string, resourceID string) (*mcp.CallToolResult, error) { + var workflow *github.Workflow + var resp *github.Response + var err error - r, err := json.Marshal(workflowRuns) - if err != nil { - return nil, fmt.Errorf("failed to marshal response: %w", err) - } + 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) + } - return mcp.NewToolResultText(string(r)), nil + if err != nil { + return ghErrors.NewGitHubAPIErrorResponse(ctx, "failed to get workflow", resp, err), nil + } + + defer func() { _ = resp.Body.Close() }() + r, err := json.Marshal(workflow) + if err != nil { + return nil, fmt.Errorf("failed to marshal workflow: %w", err) + } + + return mcp.NewToolResultText(string(r)), nil +} + +func getWorkflowRun(ctx context.Context, client *github.Client, _ mcp.CallToolRequest, owner, repo string, resourceID int64) (*mcp.CallToolResult, 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 + } + defer func() { _ = resp.Body.Close() }() + r, err := json.Marshal(workflowRun) + if err != nil { + return nil, fmt.Errorf("failed to marshal workflow run: %w", err) + } + return mcp.NewToolResultText(string(r)), nil +} + +func listWorkflowRuns(ctx context.Context, client *github.Client, request mcp.CallToolRequest, owner, repo string, resourceID string, pagination PaginationParams) (*mcp.CallToolResult, error) { + filterArgs, err := OptionalParam[map[string]any](request, "workflow_runs_filter") + if err != nil { + return mcp.NewToolResultError(err.Error()), 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 + } + + defer func() { _ = resp.Body.Close() }() + r, err := json.Marshal(workflowRuns) + if err != nil { + return nil, fmt.Errorf("failed to marshal workflow runs: %w", err) + } + + return mcp.NewToolResultText(string(r)), nil +} + +func getWorkflowJob(ctx context.Context, client *github.Client, _ mcp.CallToolRequest, owner, repo string, resourceID int64) (*mcp.CallToolResult, 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 + } + defer func() { _ = resp.Body.Close() }() + r, err := json.Marshal(workflowJob) + if err != nil { + return nil, fmt.Errorf("failed to marshal workflow job: %w", err) + } + return mcp.NewToolResultText(string(r)), nil +} + +func listWorkflowJobs(ctx context.Context, client *github.Client, request mcp.CallToolRequest, owner, repo string, resourceID int64, pagination PaginationParams) (*mcp.CallToolResult, error) { + filterArgs, err := OptionalParam[map[string]any](request, "workflow_jobs_filter") + if err != nil { + return mcp.NewToolResultError(err.Error()), 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 + } + + // Add optimization tip for failed job debugging + response := map[string]any{ + "jobs": workflowJobs, + "optimization_tip": "For debugging failed jobs, consider using get_job_logs with failed_only=true and run_id=" + fmt.Sprintf("%d", resourceID) + " to get logs directly without needing to list jobs first", + } + + defer func() { _ = resp.Body.Close() }() + r, err := json.Marshal(response) + if err != nil { + return nil, fmt.Errorf("failed to marshal workflow jobs: %w", err) + } + + return mcp.NewToolResultText(string(r)), nil +} + +func downloadWorkflowArtifact(ctx context.Context, client *github.Client, _ mcp.CallToolRequest, owner, repo string, resourceID int64) (*mcp.CallToolResult, 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 + } + 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, fmt.Errorf("failed to marshal response: %w", err) + } + + return mcp.NewToolResultText(string(r)), nil +} + +func listWorkflowArtifacts(ctx context.Context, client *github.Client, _ mcp.CallToolRequest, owner, repo string, resourceID int64, pagination PaginationParams) (*mcp.CallToolResult, error) { + // Set up list options + 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 + } + defer func() { _ = resp.Body.Close() }() + + r, err := json.Marshal(artifacts) + if err != nil { + return nil, fmt.Errorf("failed to marshal response: %w", err) + } + + return mcp.NewToolResultText(string(r)), nil +} + +// ListWorkflows creates a tool to list workflows in a repository +func listWorkflows(ctx context.Context, client *github.Client, _ mcp.CallToolRequest, owner, repo string, pagination PaginationParams) (*mcp.CallToolResult, error) { + // Set up list options + opts := &github.ListOptions{ + PerPage: pagination.PerPage, + Page: pagination.Page, + } + + workflows, resp, err := client.Actions.ListWorkflows(ctx, owner, repo, opts) + if err != nil { + return nil, fmt.Errorf("failed to list workflows: %w", err) + } + defer func() { _ = resp.Body.Close() }() + + r, err := json.Marshal(workflows) + if err != nil { + return nil, fmt.Errorf("failed to marshal response: %w", err) + } + + return mcp.NewToolResultText(string(r)), nil } // RunWorkflow creates a tool to run an Actions workflow @@ -320,12 +730,12 @@ func RunWorkflow(getClient GetClientFn, t translations.TranslationHelperFunc) (t } } -// GetWorkflowRun creates a tool to get details of a specific workflow run -func GetWorkflowRun(getClient GetClientFn, t translations.TranslationHelperFunc) (tool mcp.Tool, handler server.ToolHandlerFunc) { - return mcp.NewTool("get_workflow_run", - mcp.WithDescription(t("TOOL_GET_WORKFLOW_RUN_DESCRIPTION", "Get details of a specific workflow run")), +// GetWorkflowRunLogs creates a tool to download logs for a specific workflow run +func GetWorkflowRunLogs(getClient GetClientFn, t translations.TranslationHelperFunc) (tool mcp.Tool, handler server.ToolHandlerFunc) { + return mcp.NewTool("get_workflow_run_logs", + mcp.WithDescription(t("TOOL_GET_WORKFLOW_RUN_LOGS_DESCRIPTION", "Download logs for a specific workflow run (EXPENSIVE: downloads ALL logs as ZIP. Consider using get_job_logs with failed_only=true for debugging failed jobs)")), mcp.WithToolAnnotation(mcp.ToolAnnotation{ - Title: t("TOOL_GET_WORKFLOW_RUN_USER_TITLE", "Get workflow run"), + Title: t("TOOL_GET_WORKFLOW_RUN_LOGS_USER_TITLE", "Get workflow run logs"), ReadOnlyHint: ToBoolPtr(true), }), mcp.WithString("owner", @@ -361,13 +771,23 @@ func GetWorkflowRun(getClient GetClientFn, t translations.TranslationHelperFunc) return nil, fmt.Errorf("failed to get GitHub client: %w", err) } - workflowRun, resp, err := client.Actions.GetWorkflowRunByID(ctx, owner, repo, runID) + // Get the download URL for the logs + url, resp, err := client.Actions.GetWorkflowRunLogs(ctx, owner, repo, runID, 1) if err != nil { - return nil, fmt.Errorf("failed to get workflow run: %w", err) + return nil, fmt.Errorf("failed to get workflow run logs: %w", err) } defer func() { _ = resp.Body.Close() }() - r, err := json.Marshal(workflowRun) + // 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, fmt.Errorf("failed to marshal response: %w", err) } @@ -376,12 +796,12 @@ func GetWorkflowRun(getClient GetClientFn, t translations.TranslationHelperFunc) } } -// GetWorkflowRunLogs creates a tool to download logs for a specific workflow run -func GetWorkflowRunLogs(getClient GetClientFn, t translations.TranslationHelperFunc) (tool mcp.Tool, handler server.ToolHandlerFunc) { - return mcp.NewTool("get_workflow_run_logs", - mcp.WithDescription(t("TOOL_GET_WORKFLOW_RUN_LOGS_DESCRIPTION", "Download logs for a specific workflow run (EXPENSIVE: downloads ALL logs as ZIP. Consider using get_job_logs with failed_only=true for debugging failed jobs)")), +// 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(getClient GetClientFn, t translations.TranslationHelperFunc, contentWindowSize int) (tool mcp.Tool, handler server.ToolHandlerFunc) { + return mcp.NewTool("get_job_logs", + mcp.WithDescription(t("TOOL_GET_JOB_LOGS_DESCRIPTION", "Download logs for a specific workflow job or efficiently get all failed job logs for a workflow run")), mcp.WithToolAnnotation(mcp.ToolAnnotation{ - Title: t("TOOL_GET_WORKFLOW_RUN_LOGS_USER_TITLE", "Get workflow run logs"), + Title: t("TOOL_GET_JOB_LOGS_USER_TITLE", "Get job logs"), ReadOnlyHint: ToBoolPtr(true), }), mcp.WithString("owner", @@ -392,165 +812,11 @@ func GetWorkflowRunLogs(getClient GetClientFn, t translations.TranslationHelperF mcp.Required(), mcp.Description(DescriptionRepositoryName), ), + mcp.WithNumber("job_id", + mcp.Description("The unique identifier of the workflow job (required for single job logs)"), + ), mcp.WithNumber("run_id", - mcp.Required(), - mcp.Description("The unique identifier of the workflow run"), - ), - ), - func(ctx context.Context, request mcp.CallToolRequest) (*mcp.CallToolResult, error) { - owner, err := RequiredParam[string](request, "owner") - if err != nil { - return mcp.NewToolResultError(err.Error()), nil - } - repo, err := RequiredParam[string](request, "repo") - if err != nil { - return mcp.NewToolResultError(err.Error()), nil - } - runIDInt, err := RequiredInt(request, "run_id") - if err != nil { - return mcp.NewToolResultError(err.Error()), nil - } - runID := int64(runIDInt) - - client, err := getClient(ctx) - if err != nil { - return nil, fmt.Errorf("failed to get GitHub client: %w", err) - } - - // Get the download URL for the logs - url, resp, err := client.Actions.GetWorkflowRunLogs(ctx, owner, repo, runID, 1) - if err != nil { - return nil, fmt.Errorf("failed to get workflow run logs: %w", err) - } - 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, fmt.Errorf("failed to marshal response: %w", err) - } - - return mcp.NewToolResultText(string(r)), nil - } -} - -// ListWorkflowJobs creates a tool to list jobs for a specific workflow run -func ListWorkflowJobs(getClient GetClientFn, t translations.TranslationHelperFunc) (tool mcp.Tool, handler server.ToolHandlerFunc) { - return mcp.NewTool("list_workflow_jobs", - mcp.WithDescription(t("TOOL_LIST_WORKFLOW_JOBS_DESCRIPTION", "List jobs for a specific workflow run")), - mcp.WithToolAnnotation(mcp.ToolAnnotation{ - Title: t("TOOL_LIST_WORKFLOW_JOBS_USER_TITLE", "List workflow jobs"), - ReadOnlyHint: ToBoolPtr(true), - }), - mcp.WithString("owner", - mcp.Required(), - mcp.Description(DescriptionRepositoryOwner), - ), - mcp.WithString("repo", - mcp.Required(), - mcp.Description(DescriptionRepositoryName), - ), - mcp.WithNumber("run_id", - mcp.Required(), - mcp.Description("The unique identifier of the workflow run"), - ), - mcp.WithString("filter", - mcp.Description("Filters jobs by their completed_at timestamp"), - mcp.Enum("latest", "all"), - ), - WithPagination(), - ), - func(ctx context.Context, request mcp.CallToolRequest) (*mcp.CallToolResult, error) { - owner, err := RequiredParam[string](request, "owner") - if err != nil { - return mcp.NewToolResultError(err.Error()), nil - } - repo, err := RequiredParam[string](request, "repo") - if err != nil { - return mcp.NewToolResultError(err.Error()), nil - } - runIDInt, err := RequiredInt(request, "run_id") - if err != nil { - return mcp.NewToolResultError(err.Error()), nil - } - runID := int64(runIDInt) - - // Get optional filtering parameters - filter, err := OptionalParam[string](request, "filter") - if err != nil { - return mcp.NewToolResultError(err.Error()), nil - } - - // Get optional pagination parameters - pagination, err := OptionalPaginationParams(request) - if err != nil { - return mcp.NewToolResultError(err.Error()), nil - } - - client, err := getClient(ctx) - if err != nil { - return nil, fmt.Errorf("failed to get GitHub client: %w", err) - } - - // Set up list options - opts := &github.ListWorkflowJobsOptions{ - Filter: filter, - ListOptions: github.ListOptions{ - PerPage: pagination.PerPage, - Page: pagination.Page, - }, - } - - jobs, resp, err := client.Actions.ListWorkflowJobs(ctx, owner, repo, runID, opts) - if err != nil { - return nil, fmt.Errorf("failed to list workflow jobs: %w", err) - } - defer func() { _ = resp.Body.Close() }() - - // Add optimization tip for failed job debugging - response := map[string]any{ - "jobs": jobs, - "optimization_tip": "For debugging failed jobs, consider using get_job_logs with failed_only=true and run_id=" + fmt.Sprintf("%d", runID) + " to get logs directly without needing to list jobs first", - } - - r, err := json.Marshal(response) - if err != nil { - return nil, fmt.Errorf("failed to marshal response: %w", err) - } - - return mcp.NewToolResultText(string(r)), nil - } -} - -// 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(getClient GetClientFn, t translations.TranslationHelperFunc, contentWindowSize int) (tool mcp.Tool, handler server.ToolHandlerFunc) { - return mcp.NewTool("get_job_logs", - mcp.WithDescription(t("TOOL_GET_JOB_LOGS_DESCRIPTION", "Download logs for a specific workflow job or efficiently get all failed job logs for a workflow run")), - mcp.WithToolAnnotation(mcp.ToolAnnotation{ - Title: t("TOOL_GET_JOB_LOGS_USER_TITLE", "Get job logs"), - ReadOnlyHint: ToBoolPtr(true), - }), - mcp.WithString("owner", - mcp.Required(), - mcp.Description(DescriptionRepositoryOwner), - ), - mcp.WithString("repo", - mcp.Required(), - mcp.Description(DescriptionRepositoryName), - ), - mcp.WithNumber("job_id", - mcp.Description("The unique identifier of the workflow job (required for single job logs)"), - ), - mcp.WithNumber("run_id", - mcp.Description("Workflow run ID (required when using failed_only)"), + mcp.Description("Workflow run ID (required when using failed_only)"), ), mcp.WithBoolean("failed_only", mcp.Description("When true, gets logs for all failed jobs in run_id"), @@ -779,328 +1045,74 @@ func downloadLogContent(ctx context.Context, logURL string, tailLines int, maxLi } // RerunWorkflowRun creates a tool to re-run an entire workflow run -func RerunWorkflowRun(getClient GetClientFn, t translations.TranslationHelperFunc) (tool mcp.Tool, handler server.ToolHandlerFunc) { - return mcp.NewTool("rerun_workflow_run", - mcp.WithDescription(t("TOOL_RERUN_WORKFLOW_RUN_DESCRIPTION", "Re-run an entire workflow run")), - mcp.WithToolAnnotation(mcp.ToolAnnotation{ - Title: t("TOOL_RERUN_WORKFLOW_RUN_USER_TITLE", "Rerun workflow run"), - ReadOnlyHint: ToBoolPtr(false), - }), - mcp.WithString("owner", - mcp.Required(), - mcp.Description(DescriptionRepositoryOwner), - ), - mcp.WithString("repo", - mcp.Required(), - mcp.Description(DescriptionRepositoryName), - ), - mcp.WithNumber("run_id", - mcp.Required(), - mcp.Description("The unique identifier of the workflow run"), - ), - ), - func(ctx context.Context, request mcp.CallToolRequest) (*mcp.CallToolResult, error) { - owner, err := RequiredParam[string](request, "owner") - if err != nil { - return mcp.NewToolResultError(err.Error()), nil - } - repo, err := RequiredParam[string](request, "repo") - if err != nil { - return mcp.NewToolResultError(err.Error()), nil - } - runIDInt, err := RequiredInt(request, "run_id") - if err != nil { - return mcp.NewToolResultError(err.Error()), nil - } - runID := int64(runIDInt) - - client, err := getClient(ctx) - if err != nil { - return nil, fmt.Errorf("failed to get GitHub client: %w", err) - } - - resp, err := client.Actions.RerunWorkflowByID(ctx, owner, repo, runID) - if err != nil { - return ghErrors.NewGitHubAPIErrorResponse(ctx, "failed to rerun workflow run", resp, err), nil - } - defer func() { _ = resp.Body.Close() }() +func rerunWorkflowRun(ctx context.Context, client *github.Client, _ mcp.CallToolRequest, owner, repo string, runID int64) (*mcp.CallToolResult, 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 + } + 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, - } + 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, fmt.Errorf("failed to marshal response: %w", err) - } + r, err := json.Marshal(result) + if err != nil { + return nil, fmt.Errorf("failed to marshal response: %w", err) + } - return mcp.NewToolResultText(string(r)), nil - } + return mcp.NewToolResultText(string(r)), nil } // RerunFailedJobs creates a tool to re-run only the failed jobs in a workflow run -func RerunFailedJobs(getClient GetClientFn, t translations.TranslationHelperFunc) (tool mcp.Tool, handler server.ToolHandlerFunc) { - return mcp.NewTool("rerun_failed_jobs", - mcp.WithDescription(t("TOOL_RERUN_FAILED_JOBS_DESCRIPTION", "Re-run only the failed jobs in a workflow run")), - mcp.WithToolAnnotation(mcp.ToolAnnotation{ - Title: t("TOOL_RERUN_FAILED_JOBS_USER_TITLE", "Rerun failed jobs"), - ReadOnlyHint: ToBoolPtr(false), - }), - mcp.WithString("owner", - mcp.Required(), - mcp.Description(DescriptionRepositoryOwner), - ), - mcp.WithString("repo", - mcp.Required(), - mcp.Description(DescriptionRepositoryName), - ), - mcp.WithNumber("run_id", - mcp.Required(), - mcp.Description("The unique identifier of the workflow run"), - ), - ), - func(ctx context.Context, request mcp.CallToolRequest) (*mcp.CallToolResult, error) { - owner, err := RequiredParam[string](request, "owner") - if err != nil { - return mcp.NewToolResultError(err.Error()), nil - } - repo, err := RequiredParam[string](request, "repo") - if err != nil { - return mcp.NewToolResultError(err.Error()), nil - } - runIDInt, err := RequiredInt(request, "run_id") - if err != nil { - return mcp.NewToolResultError(err.Error()), nil - } - runID := int64(runIDInt) - - client, err := getClient(ctx) - if err != nil { - return nil, fmt.Errorf("failed to get GitHub client: %w", err) - } - - resp, err := client.Actions.RerunFailedJobsByID(ctx, owner, repo, runID) - if err != nil { - return ghErrors.NewGitHubAPIErrorResponse(ctx, "failed to rerun failed jobs", resp, err), nil - } - defer func() { _ = resp.Body.Close() }() +func rerunFailedJobs(ctx context.Context, client *github.Client, _ mcp.CallToolRequest, owner, repo string, runID int64) (*mcp.CallToolResult, 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 + } + 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, - } + 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, fmt.Errorf("failed to marshal response: %w", err) - } + r, err := json.Marshal(result) + if err != nil { + return nil, fmt.Errorf("failed to marshal response: %w", err) + } - return mcp.NewToolResultText(string(r)), nil - } + return mcp.NewToolResultText(string(r)), nil } // CancelWorkflowRun creates a tool to cancel a workflow run -func CancelWorkflowRun(getClient GetClientFn, t translations.TranslationHelperFunc) (tool mcp.Tool, handler server.ToolHandlerFunc) { - return mcp.NewTool("cancel_workflow_run", - mcp.WithDescription(t("TOOL_CANCEL_WORKFLOW_RUN_DESCRIPTION", "Cancel a workflow run")), - mcp.WithToolAnnotation(mcp.ToolAnnotation{ - Title: t("TOOL_CANCEL_WORKFLOW_RUN_USER_TITLE", "Cancel workflow run"), - ReadOnlyHint: ToBoolPtr(false), - }), - mcp.WithString("owner", - mcp.Required(), - mcp.Description(DescriptionRepositoryOwner), - ), - mcp.WithString("repo", - mcp.Required(), - mcp.Description(DescriptionRepositoryName), - ), - mcp.WithNumber("run_id", - mcp.Required(), - mcp.Description("The unique identifier of the workflow run"), - ), - ), - func(ctx context.Context, request mcp.CallToolRequest) (*mcp.CallToolResult, error) { - owner, err := RequiredParam[string](request, "owner") - if err != nil { - return mcp.NewToolResultError(err.Error()), nil - } - repo, err := RequiredParam[string](request, "repo") - if err != nil { - return mcp.NewToolResultError(err.Error()), nil - } - runIDInt, err := RequiredInt(request, "run_id") - if err != nil { - return mcp.NewToolResultError(err.Error()), nil - } - runID := int64(runIDInt) - - client, err := getClient(ctx) - if err != nil { - return nil, fmt.Errorf("failed to get GitHub client: %w", err) - } - - resp, err := client.Actions.CancelWorkflowRunByID(ctx, owner, repo, runID) - if err != nil { - if _, ok := err.(*github.AcceptedError); !ok { - return ghErrors.NewGitHubAPIErrorResponse(ctx, "failed to cancel workflow run", resp, err), 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, fmt.Errorf("failed to marshal response: %w", err) - } - - return mcp.NewToolResultText(string(r)), nil - } -} - -// ListWorkflowRunArtifacts creates a tool to list artifacts for a workflow run -func ListWorkflowRunArtifacts(getClient GetClientFn, t translations.TranslationHelperFunc) (tool mcp.Tool, handler server.ToolHandlerFunc) { - return mcp.NewTool("list_workflow_run_artifacts", - mcp.WithDescription(t("TOOL_LIST_WORKFLOW_RUN_ARTIFACTS_DESCRIPTION", "List artifacts for a workflow run")), - mcp.WithToolAnnotation(mcp.ToolAnnotation{ - Title: t("TOOL_LIST_WORKFLOW_RUN_ARTIFACTS_USER_TITLE", "List workflow artifacts"), - ReadOnlyHint: ToBoolPtr(true), - }), - mcp.WithString("owner", - mcp.Required(), - mcp.Description(DescriptionRepositoryOwner), - ), - mcp.WithString("repo", - mcp.Required(), - mcp.Description(DescriptionRepositoryName), - ), - mcp.WithNumber("run_id", - mcp.Required(), - mcp.Description("The unique identifier of the workflow run"), - ), - WithPagination(), - ), - func(ctx context.Context, request mcp.CallToolRequest) (*mcp.CallToolResult, error) { - owner, err := RequiredParam[string](request, "owner") - if err != nil { - return mcp.NewToolResultError(err.Error()), nil - } - repo, err := RequiredParam[string](request, "repo") - if err != nil { - return mcp.NewToolResultError(err.Error()), nil - } - runIDInt, err := RequiredInt(request, "run_id") - if err != nil { - return mcp.NewToolResultError(err.Error()), nil - } - runID := int64(runIDInt) - - // Get optional pagination parameters - pagination, err := OptionalPaginationParams(request) - if err != nil { - return mcp.NewToolResultError(err.Error()), nil - } - - client, err := getClient(ctx) - if err != nil { - return nil, fmt.Errorf("failed to get GitHub client: %w", err) - } - - // Set up list options - opts := &github.ListOptions{ - PerPage: pagination.PerPage, - Page: pagination.Page, - } - - artifacts, resp, err := client.Actions.ListWorkflowRunArtifacts(ctx, owner, repo, runID, opts) - if err != nil { - return ghErrors.NewGitHubAPIErrorResponse(ctx, "failed to list workflow run artifacts", resp, err), nil - } - defer func() { _ = resp.Body.Close() }() - - r, err := json.Marshal(artifacts) - if err != nil { - return nil, fmt.Errorf("failed to marshal response: %w", err) - } - - return mcp.NewToolResultText(string(r)), nil +func cancelWorkflowRun(ctx context.Context, client *github.Client, _ mcp.CallToolRequest, owner, repo string, runID int64) (*mcp.CallToolResult, error) { + resp, err := client.Actions.CancelWorkflowRunByID(ctx, owner, repo, runID) + if err != nil { + if _, ok := err.(*github.AcceptedError); !ok { + return ghErrors.NewGitHubAPIErrorResponse(ctx, "failed to cancel workflow run", resp, err), nil } -} - -// DownloadWorkflowRunArtifact creates a tool to download a workflow run artifact -func DownloadWorkflowRunArtifact(getClient GetClientFn, t translations.TranslationHelperFunc) (tool mcp.Tool, handler server.ToolHandlerFunc) { - return mcp.NewTool("download_workflow_run_artifact", - mcp.WithDescription(t("TOOL_DOWNLOAD_WORKFLOW_RUN_ARTIFACT_DESCRIPTION", "Get download URL for a workflow run artifact")), - mcp.WithToolAnnotation(mcp.ToolAnnotation{ - Title: t("TOOL_DOWNLOAD_WORKFLOW_RUN_ARTIFACT_USER_TITLE", "Download workflow artifact"), - ReadOnlyHint: ToBoolPtr(true), - }), - mcp.WithString("owner", - mcp.Required(), - mcp.Description(DescriptionRepositoryOwner), - ), - mcp.WithString("repo", - mcp.Required(), - mcp.Description(DescriptionRepositoryName), - ), - mcp.WithNumber("artifact_id", - mcp.Required(), - mcp.Description("The unique identifier of the artifact"), - ), - ), - func(ctx context.Context, request mcp.CallToolRequest) (*mcp.CallToolResult, error) { - owner, err := RequiredParam[string](request, "owner") - if err != nil { - return mcp.NewToolResultError(err.Error()), nil - } - repo, err := RequiredParam[string](request, "repo") - if err != nil { - return mcp.NewToolResultError(err.Error()), nil - } - artifactIDInt, err := RequiredInt(request, "artifact_id") - if err != nil { - return mcp.NewToolResultError(err.Error()), nil - } - artifactID := int64(artifactIDInt) - - client, err := getClient(ctx) - if err != nil { - return nil, fmt.Errorf("failed to get GitHub client: %w", err) - } - - // Get the download URL for the artifact - url, resp, err := client.Actions.DownloadArtifact(ctx, owner, repo, artifactID, 1) - if err != nil { - return ghErrors.NewGitHubAPIErrorResponse(ctx, "failed to get artifact download URL", resp, err), nil - } - defer func() { _ = resp.Body.Close() }() + } + 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": artifactID, - } + 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, fmt.Errorf("failed to marshal response: %w", err) - } + r, err := json.Marshal(result) + if err != nil { + return nil, fmt.Errorf("failed to marshal response: %w", err) + } - return mcp.NewToolResultText(string(r)), nil - } + return mcp.NewToolResultText(string(r)), nil } // DeleteWorkflowRunLogs creates a tool to delete logs for a workflow run @@ -1168,57 +1180,17 @@ func DeleteWorkflowRunLogs(getClient GetClientFn, t translations.TranslationHelp } // GetWorkflowRunUsage creates a tool to get usage metrics for a workflow run -func GetWorkflowRunUsage(getClient GetClientFn, t translations.TranslationHelperFunc) (tool mcp.Tool, handler server.ToolHandlerFunc) { - return mcp.NewTool("get_workflow_run_usage", - mcp.WithDescription(t("TOOL_GET_WORKFLOW_RUN_USAGE_DESCRIPTION", "Get usage metrics for a workflow run")), - mcp.WithToolAnnotation(mcp.ToolAnnotation{ - Title: t("TOOL_GET_WORKFLOW_RUN_USAGE_USER_TITLE", "Get workflow usage"), - ReadOnlyHint: ToBoolPtr(true), - }), - mcp.WithString("owner", - mcp.Required(), - mcp.Description(DescriptionRepositoryOwner), - ), - mcp.WithString("repo", - mcp.Required(), - mcp.Description(DescriptionRepositoryName), - ), - mcp.WithNumber("run_id", - mcp.Required(), - mcp.Description("The unique identifier of the workflow run"), - ), - ), - func(ctx context.Context, request mcp.CallToolRequest) (*mcp.CallToolResult, error) { - owner, err := RequiredParam[string](request, "owner") - if err != nil { - return mcp.NewToolResultError(err.Error()), nil - } - repo, err := RequiredParam[string](request, "repo") - if err != nil { - return mcp.NewToolResultError(err.Error()), nil - } - runIDInt, err := RequiredInt(request, "run_id") - if err != nil { - return mcp.NewToolResultError(err.Error()), nil - } - runID := int64(runIDInt) - - client, err := getClient(ctx) - if err != nil { - return nil, fmt.Errorf("failed to get GitHub client: %w", err) - } - - usage, resp, err := client.Actions.GetWorkflowRunUsageByID(ctx, owner, repo, runID) - if err != nil { - return ghErrors.NewGitHubAPIErrorResponse(ctx, "failed to get workflow run usage", resp, err), nil - } - defer func() { _ = resp.Body.Close() }() +func getWorkflowRunUsage(ctx context.Context, client *github.Client, _ mcp.CallToolRequest, owner, repo string, resourceID int64) (*mcp.CallToolResult, 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 + } + defer func() { _ = resp.Body.Close() }() - r, err := json.Marshal(usage) - if err != nil { - return nil, fmt.Errorf("failed to marshal response: %w", err) - } + r, err := json.Marshal(usage) + if err != nil { + return nil, fmt.Errorf("failed to marshal response: %w", err) + } - return mcp.NewToolResultText(string(r)), nil - } + return mcp.NewToolResultText(string(r)), nil } diff --git a/pkg/github/actions_test.go b/pkg/github/actions_test.go index 04863ba1d..69a664c48 100644 --- a/pkg/github/actions_test.go +++ b/pkg/github/actions_test.go @@ -3,10 +3,12 @@ package github import ( "context" "encoding/json" + "fmt" "io" "net/http" "net/http/httptest" "os" + "regexp" "runtime" "runtime/debug" "strings" @@ -21,19 +23,7 @@ import ( "github.com/stretchr/testify/require" ) -func Test_ListWorkflows(t *testing.T) { - // Verify tool definition once - mockClient := github.NewClient(nil) - tool, _ := ListWorkflows(stubGetClientFn(mockClient), translations.NullTranslationHelper) - - assert.Equal(t, "list_workflows", tool.Name) - assert.NotEmpty(t, tool.Description) - assert.Contains(t, tool.InputSchema.Properties, "owner") - assert.Contains(t, tool.InputSchema.Properties, "repo") - assert.Contains(t, tool.InputSchema.Properties, "perPage") - assert.Contains(t, tool.InputSchema.Properties, "page") - assert.ElementsMatch(t, tool.InputSchema.Required, []string{"owner", "repo"}) - +func Test_ActionsList_ListWorkflows(t *testing.T) { tests := []struct { name string mockedClient *http.Client @@ -82,8 +72,9 @@ func Test_ListWorkflows(t *testing.T) { ), ), requestArgs: map[string]any{ - "owner": "owner", - "repo": "repo", + "action": actionsActionTypeListWorkflows.String(), + "owner": "owner", + "repo": "repo", }, expectError: false, }, @@ -91,7 +82,8 @@ func Test_ListWorkflows(t *testing.T) { name: "missing required parameter owner", mockedClient: mock.NewMockedHTTPClient(), requestArgs: map[string]any{ - "repo": "repo", + "action": actionsActionTypeListWorkflows.String(), + "repo": "repo", }, expectError: true, expectedErrMsg: "missing required parameter: owner", @@ -102,7 +94,7 @@ func Test_ListWorkflows(t *testing.T) { t.Run(tc.name, func(t *testing.T) { // Setup client with mock client := github.NewClient(tc.mockedClient) - _, handler := ListWorkflows(stubGetClientFn(client), translations.NullTranslationHelper) + _, handler := ActionsList(stubGetClientFn(client), translations.NullTranslationHelper) // Create call request request := createMCPRequest(tc.requestArgs) @@ -308,18 +300,21 @@ func Test_RunWorkflow_WithFilename(t *testing.T) { } } -func Test_CancelWorkflowRun(t *testing.T) { +func Test_ActionsRunTrigger(t *testing.T) { // Verify tool definition once mockClient := github.NewClient(nil) - tool, _ := CancelWorkflowRun(stubGetClientFn(mockClient), translations.NullTranslationHelper) + tool, _ := ActionsRunTrigger(stubGetClientFn(mockClient), translations.NullTranslationHelper) - assert.Equal(t, "cancel_workflow_run", tool.Name) + assert.Equal(t, "actions_run_trigger", tool.Name) assert.NotEmpty(t, tool.Description) + assert.Contains(t, tool.InputSchema.Properties, "action") assert.Contains(t, tool.InputSchema.Properties, "owner") assert.Contains(t, tool.InputSchema.Properties, "repo") assert.Contains(t, tool.InputSchema.Properties, "run_id") - assert.ElementsMatch(t, tool.InputSchema.Required, []string{"owner", "repo", "run_id"}) + assert.ElementsMatch(t, tool.InputSchema.Required, []string{"action", "owner", "repo", "run_id"}) +} +func Test_CancelWorkflowRun(t *testing.T) { tests := []struct { name string mockedClient *http.Client @@ -341,6 +336,7 @@ func Test_CancelWorkflowRun(t *testing.T) { ), ), requestArgs: map[string]any{ + "action": actionsActionTypeCancelWorkflowRun.String(), "owner": "owner", "repo": "repo", "run_id": float64(12345), @@ -361,6 +357,7 @@ func Test_CancelWorkflowRun(t *testing.T) { ), ), requestArgs: map[string]any{ + "action": actionsActionTypeCancelWorkflowRun.String(), "owner": "owner", "repo": "repo", "run_id": float64(12345), @@ -372,8 +369,9 @@ func Test_CancelWorkflowRun(t *testing.T) { name: "missing required parameter run_id", mockedClient: mock.NewMockedHTTPClient(), requestArgs: map[string]any{ - "owner": "owner", - "repo": "repo", + "action": actionsActionTypeCancelWorkflowRun.String(), + "owner": "owner", + "repo": "repo", }, expectError: true, expectedErrMsg: "missing required parameter: run_id", @@ -384,7 +382,7 @@ func Test_CancelWorkflowRun(t *testing.T) { t.Run(tc.name, func(t *testing.T) { // Setup client with mock client := github.NewClient(tc.mockedClient) - _, handler := CancelWorkflowRun(stubGetClientFn(client), translations.NullTranslationHelper) + _, handler := ActionsRunTrigger(stubGetClientFn(client), translations.NullTranslationHelper) // Create call request request := createMCPRequest(tc.requestArgs) @@ -414,19 +412,6 @@ func Test_CancelWorkflowRun(t *testing.T) { } func Test_ListWorkflowRunArtifacts(t *testing.T) { - // Verify tool definition once - mockClient := github.NewClient(nil) - tool, _ := ListWorkflowRunArtifacts(stubGetClientFn(mockClient), translations.NullTranslationHelper) - - assert.Equal(t, "list_workflow_run_artifacts", tool.Name) - assert.NotEmpty(t, tool.Description) - assert.Contains(t, tool.InputSchema.Properties, "owner") - assert.Contains(t, tool.InputSchema.Properties, "repo") - assert.Contains(t, tool.InputSchema.Properties, "run_id") - assert.Contains(t, tool.InputSchema.Properties, "perPage") - assert.Contains(t, tool.InputSchema.Properties, "page") - assert.ElementsMatch(t, tool.InputSchema.Required, []string{"owner", "repo", "run_id"}) - tests := []struct { name string mockedClient *http.Client @@ -489,21 +474,23 @@ func Test_ListWorkflowRunArtifacts(t *testing.T) { ), ), requestArgs: map[string]any{ - "owner": "owner", - "repo": "repo", - "run_id": float64(12345), + "action": actionsActionTypeListWorkflowArtifacts.String(), + "owner": "owner", + "repo": "repo", + "resource_id": "12345", }, expectError: false, }, { - name: "missing required parameter run_id", + name: "missing required parameter resource_id", mockedClient: mock.NewMockedHTTPClient(), requestArgs: map[string]any{ - "owner": "owner", - "repo": "repo", + "action": actionsActionTypeListWorkflowArtifacts.String(), + "owner": "owner", + "repo": "repo", }, expectError: true, - expectedErrMsg: "missing required parameter: run_id", + expectedErrMsg: fmt.Sprintf("missing required parameter for action %s: resource_id", actionsActionTypeListWorkflowArtifacts.String()), }, } @@ -511,7 +498,7 @@ func Test_ListWorkflowRunArtifacts(t *testing.T) { t.Run(tc.name, func(t *testing.T) { // Setup client with mock client := github.NewClient(tc.mockedClient) - _, handler := ListWorkflowRunArtifacts(stubGetClientFn(client), translations.NullTranslationHelper) + _, handler := ActionsList(stubGetClientFn(client), translations.NullTranslationHelper) // Create call request request := createMCPRequest(tc.requestArgs) @@ -542,17 +529,6 @@ func Test_ListWorkflowRunArtifacts(t *testing.T) { } func Test_DownloadWorkflowRunArtifact(t *testing.T) { - // Verify tool definition once - mockClient := github.NewClient(nil) - tool, _ := DownloadWorkflowRunArtifact(stubGetClientFn(mockClient), translations.NullTranslationHelper) - - assert.Equal(t, "download_workflow_run_artifact", tool.Name) - assert.NotEmpty(t, tool.Description) - assert.Contains(t, tool.InputSchema.Properties, "owner") - assert.Contains(t, tool.InputSchema.Properties, "repo") - assert.Contains(t, tool.InputSchema.Properties, "artifact_id") - assert.ElementsMatch(t, tool.InputSchema.Required, []string{"owner", "repo", "artifact_id"}) - tests := []struct { name string mockedClient *http.Client @@ -576,21 +552,23 @@ func Test_DownloadWorkflowRunArtifact(t *testing.T) { ), ), requestArgs: map[string]any{ + "action": actionsActionTypeDownloadWorkflowArtifact.String(), "owner": "owner", "repo": "repo", - "artifact_id": float64(123), + "resource_id": "123", }, expectError: false, }, { - name: "missing required parameter artifact_id", + name: "missing required parameter resource_id", mockedClient: mock.NewMockedHTTPClient(), requestArgs: map[string]any{ - "owner": "owner", - "repo": "repo", + "action": actionsActionTypeDownloadWorkflowArtifact.String(), + "owner": "owner", + "repo": "repo", }, expectError: true, - expectedErrMsg: "missing required parameter: artifact_id", + expectedErrMsg: "missing required parameter: resource_id", }, } @@ -598,7 +576,7 @@ func Test_DownloadWorkflowRunArtifact(t *testing.T) { t.Run(tc.name, func(t *testing.T) { // Setup client with mock client := github.NewClient(tc.mockedClient) - _, handler := DownloadWorkflowRunArtifact(stubGetClientFn(client), translations.NullTranslationHelper) + _, handler := ActionsGet(stubGetClientFn(client), translations.NullTranslationHelper) // Create call request request := createMCPRequest(tc.requestArgs) @@ -630,17 +608,6 @@ func Test_DownloadWorkflowRunArtifact(t *testing.T) { } func Test_DeleteWorkflowRunLogs(t *testing.T) { - // Verify tool definition once - mockClient := github.NewClient(nil) - tool, _ := DeleteWorkflowRunLogs(stubGetClientFn(mockClient), translations.NullTranslationHelper) - - assert.Equal(t, "delete_workflow_run_logs", tool.Name) - assert.NotEmpty(t, tool.Description) - assert.Contains(t, tool.InputSchema.Properties, "owner") - assert.Contains(t, tool.InputSchema.Properties, "repo") - assert.Contains(t, tool.InputSchema.Properties, "run_id") - assert.ElementsMatch(t, tool.InputSchema.Required, []string{"owner", "repo", "run_id"}) - tests := []struct { name string mockedClient *http.Client @@ -711,17 +678,6 @@ func Test_DeleteWorkflowRunLogs(t *testing.T) { } func Test_GetWorkflowRunUsage(t *testing.T) { - // Verify tool definition once - mockClient := github.NewClient(nil) - tool, _ := GetWorkflowRunUsage(stubGetClientFn(mockClient), translations.NullTranslationHelper) - - assert.Equal(t, "get_workflow_run_usage", tool.Name) - assert.NotEmpty(t, tool.Description) - assert.Contains(t, tool.InputSchema.Properties, "owner") - assert.Contains(t, tool.InputSchema.Properties, "repo") - assert.Contains(t, tool.InputSchema.Properties, "run_id") - assert.ElementsMatch(t, tool.InputSchema.Required, []string{"owner", "repo", "run_id"}) - tests := []struct { name string mockedClient *http.Client @@ -760,21 +716,23 @@ func Test_GetWorkflowRunUsage(t *testing.T) { ), ), requestArgs: map[string]any{ - "owner": "owner", - "repo": "repo", - "run_id": float64(12345), + "action": actionsActionTypeGetWorkflowRunUsage.String(), + "owner": "owner", + "repo": "repo", + "resource_id": "12345", }, expectError: false, }, { - name: "missing required parameter run_id", + name: "missing required parameter resource_id", mockedClient: mock.NewMockedHTTPClient(), requestArgs: map[string]any{ - "owner": "owner", - "repo": "repo", + "action": actionsActionTypeGetWorkflowRunUsage.String(), + "owner": "owner", + "repo": "repo", }, expectError: true, - expectedErrMsg: "missing required parameter: run_id", + expectedErrMsg: "missing required parameter: resource_id", }, } @@ -782,7 +740,7 @@ func Test_GetWorkflowRunUsage(t *testing.T) { t.Run(tc.name, func(t *testing.T) { // Setup client with mock client := github.NewClient(tc.mockedClient) - _, handler := GetWorkflowRunUsage(stubGetClientFn(client), translations.NullTranslationHelper) + _, handler := ActionsGet(stubGetClientFn(client), translations.NullTranslationHelper) // Create call request request := createMCPRequest(tc.requestArgs) @@ -1319,3 +1277,892 @@ func Test_MemoryUsage_SlidingWindow_vs_NoWindow(t *testing.T) { t.Logf("Sliding window: %s", profile1.String()) t.Logf("No window: %s", profile2.String()) } + +func Test_ActionsGet(t *testing.T) { + // Verify tool definition once + mockClient := github.NewClient(nil) + tool, _ := ActionsGet(stubGetClientFn(mockClient), translations.NullTranslationHelper) + + assert.Equal(t, "actions_get", tool.Name) + assert.NotEmpty(t, tool.Description) + assert.Contains(t, tool.InputSchema.Properties, "action") + assert.Contains(t, tool.InputSchema.Properties, "owner") + assert.Contains(t, tool.InputSchema.Properties, "repo") + assert.Contains(t, tool.InputSchema.Properties, "resource_id") + assert.ElementsMatch(t, tool.InputSchema.Required, []string{"action", "owner", "repo", "resource_id"}) + + tests := []struct { + name string + mockedClient *http.Client + requestArgs map[string]any + expectError bool + expectedErrMsg string + }{ + { + name: "missing required parameter action", + mockedClient: mock.NewMockedHTTPClient(), + requestArgs: map[string]any{ + "owner": "owner", + "repo": "repo", + "resource_id": "123", + }, + expectError: true, + expectedErrMsg: "missing required parameter: action", + }, + { + name: "missing required parameter owner", + mockedClient: mock.NewMockedHTTPClient(), + requestArgs: map[string]any{ + "action": actionsActionTypeGetWorkflow.String(), + "repo": "repo", + "resource_id": "123", + }, + expectError: true, + expectedErrMsg: "missing required parameter: owner", + }, + { + name: "missing required parameter repo", + mockedClient: mock.NewMockedHTTPClient(), + requestArgs: map[string]any{ + "action": actionsActionTypeGetWorkflow.String(), + "owner": "owner", + "resource_id": "123", + }, + expectError: true, + expectedErrMsg: "missing required parameter: repo", + }, + { + name: "missing required parameter resource_id", + mockedClient: mock.NewMockedHTTPClient(), + requestArgs: map[string]any{ + "action": actionsActionTypeGetWorkflow.String(), + "owner": "owner", + "repo": "repo", + }, + expectError: true, + expectedErrMsg: "missing required parameter: resource_id", + }, + { + name: "unknown resource", + mockedClient: mock.NewMockedHTTPClient(), + requestArgs: map[string]any{ + "action": "random", + "owner": "owner", + "repo": "repo", + "resource_id": "123", + }, + expectError: true, + expectedErrMsg: "unknown action: random", + }, + } + + for _, tc := range tests { + t.Run(tc.name, func(t *testing.T) { + // Setup client with mock + client := github.NewClient(tc.mockedClient) + _, handler := ActionsGet(stubGetClientFn(client), translations.NullTranslationHelper) + + // Create call request + request := createMCPRequest(tc.requestArgs) + + // Call handler + result, err := handler(context.Background(), request) + + require.NoError(t, err) + require.Equal(t, tc.expectError, result.IsError) + + // Parse the result and get the text content if no error + textContent := getTextResult(t, result) + + if tc.expectedErrMsg != "" { + assert.Equal(t, tc.expectedErrMsg, textContent.Text) + return + } + }) + } +} + +func Test_ActionsResourceRead_GetWorkflow(t *testing.T) { + tests := []struct { + name string + mockedClient *http.Client + requestArgs map[string]any + expectError bool + expectedErrMsg string + expectedErrMsgRegexp *regexp.Regexp + }{ + { + name: "successful workflow read", + mockedClient: mock.NewMockedHTTPClient( + mock.WithRequestMatchHandler( + mock.GetReposActionsWorkflowsByOwnerByRepoByWorkflowId, + http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) { + workflow := &github.Workflow{ + ID: github.Ptr(int64(1)), + NodeID: github.Ptr("W_1"), + Name: github.Ptr("CI"), + Path: github.Ptr(".github/workflows/test.yaml"), + State: github.Ptr("active"), + } + w.WriteHeader(http.StatusOK) + _ = json.NewEncoder(w).Encode(workflow) + }), + ), + ), + requestArgs: map[string]any{ + "action": actionsActionTypeGetWorkflow.String(), + "owner": "owner", + "repo": "repo", + "resource_id": "1", + }, + expectError: false, + }, + { + name: "missing workflow read", + mockedClient: mock.NewMockedHTTPClient( + mock.WithRequestMatchHandler( + mock.GetReposActionsWorkflowsByOwnerByRepoByWorkflowId, + http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) { + w.WriteHeader(http.StatusNotFound) + }), + ), + ), + requestArgs: map[string]any{ + "action": actionsActionTypeGetWorkflow.String(), + "owner": "owner", + "repo": "repo", + "resource_id": "2", + }, + expectError: true, + expectedErrMsgRegexp: regexp.MustCompile(`^failed to get workflow: GET .*/repos/owner/repo/actions/workflows/2: 404.*$`), + }, + } + + for _, tc := range tests { + t.Run(tc.name, func(t *testing.T) { + // Setup client with mock + client := github.NewClient(tc.mockedClient) + _, handler := ActionsGet(stubGetClientFn(client), translations.NullTranslationHelper) + + // Create call request + request := createMCPRequest(tc.requestArgs) + + // Call handler + result, err := handler(context.Background(), request) + + require.NoError(t, err) + require.Equal(t, tc.expectError, result.IsError) + + // Parse the result and get the text content if no error + textContent := getTextResult(t, result) + + if tc.expectedErrMsg != "" { + assert.Contains(t, tc.expectedErrMsg, textContent.Text) + return + } + + if tc.expectedErrMsgRegexp != nil { + assert.Regexp(t, tc.expectedErrMsgRegexp, textContent.Text) + return + } + + // Unmarshal and verify the result + var response github.Workflow + err = json.Unmarshal([]byte(textContent.Text), &response) + require.NoError(t, err) + assert.NotNil(t, response.ID) + assert.NotNil(t, response.Name) + assert.NotNil(t, response.Path) + }) + } +} + +func Test_ActionsResourceRead_GetWorkflowRun(t *testing.T) { + tests := []struct { + name string + mockedClient *http.Client + requestArgs map[string]any + expectError bool + expectedErrMsg string + expectedErrMsgRegexp *regexp.Regexp + }{ + { + name: "successful workflow run read", + mockedClient: mock.NewMockedHTTPClient( + mock.WithRequestMatchHandler( + mock.GetReposActionsRunsByOwnerByRepoByRunId, + http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) { + run := &github.WorkflowRun{ + ID: github.Ptr(int64(12345)), + RunNumber: github.Ptr(int(1)), + Status: github.Ptr("completed"), + Conclusion: github.Ptr("success"), + WorkflowID: github.Ptr(int64(1)), + } + w.WriteHeader(http.StatusOK) + _ = json.NewEncoder(w).Encode(run) + }), + ), + ), + requestArgs: map[string]any{ + "action": actionsActionTypeGetWorkflowRun.String(), + "owner": "owner", + "repo": "repo", + "resource_id": "12345", + }, + expectError: false, + }, + { + name: "missing workflow run read", + mockedClient: mock.NewMockedHTTPClient( + mock.WithRequestMatchHandler( + mock.GetReposActionsRunsByOwnerByRepoByRunId, + http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) { + w.WriteHeader(http.StatusNotFound) + }), + ), + ), + requestArgs: map[string]any{ + "action": actionsActionTypeGetWorkflowRun.String(), + "owner": "owner", + "repo": "repo", + "resource_id": "99999", + }, + expectError: true, + expectedErrMsgRegexp: regexp.MustCompile(`^failed to get workflow run: GET .*/repos/owner/repo/actions/runs/99999: 404.*$`), + }, + } + + for _, tc := range tests { + t.Run(tc.name, func(t *testing.T) { + // Setup client with mock + client := github.NewClient(tc.mockedClient) + _, handler := ActionsGet(stubGetClientFn(client), translations.NullTranslationHelper) + + // Create call request + request := createMCPRequest(tc.requestArgs) + + // Call handler + result, err := handler(context.Background(), request) + + require.NoError(t, err) + require.Equal(t, tc.expectError, result.IsError) + + // Parse the result and get the text content if no error + textContent := getTextResult(t, result) + + if tc.expectedErrMsg != "" { + assert.Contains(t, tc.expectedErrMsg, textContent.Text) + return + } + + if tc.expectedErrMsgRegexp != nil { + assert.Regexp(t, tc.expectedErrMsgRegexp, textContent.Text) + return + } + + // Unmarshal and verify the result + var response map[string]any + err = json.Unmarshal([]byte(textContent.Text), &response) + require.NoError(t, err) + assert.Equal(t, float64(12345), response["id"]) + assert.Equal(t, float64(1), response["run_number"]) + assert.Equal(t, "completed", response["status"]) + assert.Equal(t, "success", response["conclusion"]) + assert.Equal(t, float64(1), response["workflow_id"]) + }) + } +} + +func Test_ActionsResourceRead_ListWorkflowRuns(t *testing.T) { + tests := []struct { + name string + mockedClient *http.Client + requestArgs map[string]any + expectError bool + expectedErrMsg string + expectedErrMsgRegexp *regexp.Regexp + }{ + { + name: "successful workflow runs read", + 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(12345)), + RunNumber: github.Ptr(int(1)), + Status: github.Ptr("completed"), + Conclusion: github.Ptr("success"), + WorkflowID: github.Ptr(int64(1)), + }, + { + ID: github.Ptr(int64(12346)), + RunNumber: github.Ptr(int(2)), + Status: github.Ptr("completed"), + Conclusion: github.Ptr("failure"), + WorkflowID: github.Ptr(int64(1)), + }, + }, + } + w.WriteHeader(http.StatusOK) + _ = json.NewEncoder(w).Encode(runs) + }), + ), + ), + requestArgs: map[string]any{ + "action": actionsActionTypeListWorkflowRuns.String(), + "owner": "owner", + "repo": "repo", + "resource_id": "1", + }, + expectError: false, + }, + { + name: "successful workflow runs read with filters", + mockedClient: mock.NewMockedHTTPClient( + mock.WithRequestMatchHandler( + mock.GetReposActionsWorkflowsRunsByOwnerByRepoByWorkflowId, + http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + // Verify query parameters + query := r.URL.Query() + assert.Equal(t, "omgitsads", query.Get("actor")) + assert.Equal(t, "completed", query.Get("status")) + + runs := &github.WorkflowRuns{ + TotalCount: github.Ptr(1), + WorkflowRuns: []*github.WorkflowRun{ + { + ID: github.Ptr(int64(12345)), + RunNumber: github.Ptr(int(1)), + Status: github.Ptr("completed"), + Conclusion: github.Ptr("success"), + WorkflowID: github.Ptr(int64(1)), + Actor: &github.User{ + Login: github.Ptr("omgitsads"), + }, + }, + }, + } + w.WriteHeader(http.StatusOK) + _ = json.NewEncoder(w).Encode(runs) + }), + ), + ), + requestArgs: map[string]any{ + "action": actionsActionTypeListWorkflowRuns.String(), + "owner": "owner", + "repo": "repo", + "resource_id": "1", + "workflow_runs_filter": map[string]any{ + "actor": "omgitsads", + "status": "completed", + }, + }, + expectError: false, + }, + { + name: "missing workflow runs read", + mockedClient: mock.NewMockedHTTPClient( + mock.WithRequestMatchHandler( + mock.GetReposActionsWorkflowsRunsByOwnerByRepoByWorkflowId, + http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) { + w.WriteHeader(http.StatusNotFound) + }), + ), + ), + requestArgs: map[string]any{ + "action": actionsActionTypeListWorkflowRuns.String(), + "owner": "owner", + "repo": "repo", + "resource_id": "99999", + }, + expectError: true, + expectedErrMsgRegexp: regexp.MustCompile(`^failed to list workflow runs: GET .*/repos/owner/repo/actions/workflows/99999/runs.* 404.*$`), + }, + } + + for _, tc := range tests { + t.Run(tc.name, func(t *testing.T) { + // Setup client with mock + client := github.NewClient(tc.mockedClient) + _, handler := ActionsList(stubGetClientFn(client), translations.NullTranslationHelper) + + // Create call request + request := createMCPRequest(tc.requestArgs) + + // Call handler + result, err := handler(context.Background(), request) + + require.NoError(t, err) + require.Equal(t, tc.expectError, result.IsError) + + // Parse the result and get the text content if no error + textContent := getTextResult(t, result) + + if tc.expectedErrMsg != "" { + assert.Contains(t, tc.expectedErrMsg, textContent.Text) + return + } + + if tc.expectedErrMsgRegexp != nil { + assert.Regexp(t, tc.expectedErrMsgRegexp, textContent.Text) + return + } + }) + } +} + +func Test_ActionsResourceRead_GetWorkflowJob(t *testing.T) { + tests := []struct { + name string + mockedClient *http.Client + requestArgs map[string]any + expectError bool + expectedErrMsg string + expectedErrMsgRegexp *regexp.Regexp + }{ + { + name: "successful workflow job read", + mockedClient: mock.NewMockedHTTPClient( + mock.WithRequestMatchHandler( + mock.GetReposActionsJobsByOwnerByRepoByJobId, + http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) { + job := &github.WorkflowJob{ + ID: github.Ptr(int64(12345)), + RunID: github.Ptr(int64(1)), + Status: github.Ptr("completed"), + Conclusion: github.Ptr("success"), + } + w.WriteHeader(http.StatusOK) + _ = json.NewEncoder(w).Encode(job) + }), + ), + ), + requestArgs: map[string]any{ + "action": actionsActionTypeGetWorkflowJob.String(), + "owner": "owner", + "repo": "repo", + "resource_id": "12345", + }, + expectError: false, + }, + { + name: "missing workflow job read", + mockedClient: mock.NewMockedHTTPClient( + mock.WithRequestMatchHandler( + mock.GetReposActionsJobsByOwnerByRepoByJobId, + http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) { + w.WriteHeader(http.StatusNotFound) + }), + ), + ), + requestArgs: map[string]any{ + "action": actionsActionTypeGetWorkflowJob.String(), + "owner": "owner", + "repo": "repo", + "resource_id": "99999", + }, + expectError: true, + expectedErrMsgRegexp: regexp.MustCompile(`^failed to get workflow job: GET .*/repos/owner/repo/actions/jobs/99999: 404.*$`), + }, + } + + for _, tc := range tests { + t.Run(tc.name, func(t *testing.T) { + // Setup client with mock + client := github.NewClient(tc.mockedClient) + _, handler := ActionsGet(stubGetClientFn(client), translations.NullTranslationHelper) + + // Create call request + request := createMCPRequest(tc.requestArgs) + + // Call handler + result, err := handler(context.Background(), request) + + require.NoError(t, err) + require.Equal(t, tc.expectError, result.IsError) + + // Parse the result and get the text content if no error + textContent := getTextResult(t, result) + + if tc.expectedErrMsg != "" { + assert.Contains(t, tc.expectedErrMsg, textContent.Text) + return + } + + if tc.expectedErrMsgRegexp != nil { + assert.Regexp(t, tc.expectedErrMsgRegexp, textContent.Text) + return + } + }) + } +} + +func Test_ActionsResourceRead_ListWorkflowJobs(t *testing.T) { + tests := []struct { + name string + mockedClient *http.Client + requestArgs map[string]any + expectError bool + expectedErrMsg string + expectedErrMsgRegexp *regexp.Regexp + }{ + { + name: "successful workflow jobs read", + 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(12345)), + RunID: github.Ptr(int64(1)), + Status: github.Ptr("completed"), + Conclusion: github.Ptr("success"), + }, + { + ID: github.Ptr(int64(12346)), + RunID: github.Ptr(int64(1)), + Status: github.Ptr("completed"), + Conclusion: github.Ptr("failure"), + }, + }, + } + w.WriteHeader(http.StatusOK) + _ = json.NewEncoder(w).Encode(jobs) + }), + ), + ), + requestArgs: map[string]any{ + "action": actionsActionTypeListWorkflowJobs.String(), + "owner": "owner", + "repo": "repo", + "resource_id": "1", + }, + expectError: false, + }, + { + name: "missing workflow runs read", + mockedClient: mock.NewMockedHTTPClient( + mock.WithRequestMatchHandler( + mock.GetReposActionsRunsJobsByOwnerByRepoByRunId, + http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) { + w.WriteHeader(http.StatusNotFound) + }), + ), + ), + requestArgs: map[string]any{ + "action": actionsActionTypeListWorkflowJobs.String(), + "owner": "owner", + "repo": "repo", + "resource_id": "99999", + }, + expectError: true, + expectedErrMsgRegexp: regexp.MustCompile(`^failed to list workflow jobs: GET .*/repos/owner/repo/actions/runs/99999/jobs.* 404.*$`), + }, + } + + for _, tc := range tests { + t.Run(tc.name, func(t *testing.T) { + // Setup client with mock + client := github.NewClient(tc.mockedClient) + _, handler := ActionsList(stubGetClientFn(client), translations.NullTranslationHelper) + + // Create call request + request := createMCPRequest(tc.requestArgs) + + // Call handler + result, err := handler(context.Background(), request) + + require.NoError(t, err) + require.Equal(t, tc.expectError, result.IsError) + + // Parse the result and get the text content if no error + textContent := getTextResult(t, result) + + if tc.expectedErrMsg != "" { + assert.Contains(t, tc.expectedErrMsg, textContent.Text) + return + } + + if tc.expectedErrMsgRegexp != nil { + assert.Regexp(t, tc.expectedErrMsgRegexp, textContent.Text) + return + } + }) + } +} + +func Test_ActionsResourceRead_DownloadWorkflowArtifact(t *testing.T) { + tests := []struct { + name string + mockedClient *http.Client + requestArgs map[string]any + expectError bool + expectedErrMsg string + expectedErrMsgRegexp *regexp.Regexp + }{ + { + name: "successful workflow artifact download", + mockedClient: mock.NewMockedHTTPClient( + mock.WithRequestMatchHandler( + mock.GetReposActionsArtifactsByOwnerByRepoByArtifactIdByArchiveFormat, + http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) { + w.WriteHeader(http.StatusFound) + w.Header().Set("Location", "https://github.com/artifact/download/url") + }), + ), + ), + requestArgs: map[string]any{ + "action": actionsActionTypeDownloadWorkflowArtifact.String(), + "owner": "owner", + "repo": "repo", + "resource_id": "12345", + }, + expectError: false, + }, + { + name: "missing workflow artifact download", + mockedClient: mock.NewMockedHTTPClient( + mock.WithRequestMatchHandler( + mock.GetReposActionsArtifactsByOwnerByRepoByArtifactId, + http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) { + w.WriteHeader(http.StatusNotFound) + }), + ), + ), + requestArgs: map[string]any{ + "action": actionsActionTypeDownloadWorkflowArtifact.String(), + "owner": "owner", + "repo": "repo", + "resource_id": "99999", + }, + expectError: true, + expectedErrMsg: "failed to get artifact download URL: unexpected status code: 404 Not Found", + }, + } + + for _, tc := range tests { + t.Run(tc.name, func(t *testing.T) { + // Setup client with mock + client := github.NewClient(tc.mockedClient) + _, handler := ActionsGet(stubGetClientFn(client), translations.NullTranslationHelper) + + // Create call request + request := createMCPRequest(tc.requestArgs) + + // Call handler + result, err := handler(context.Background(), request) + + require.NoError(t, err) + require.Equal(t, tc.expectError, result.IsError) + + // Parse the result and get the text content if no error + textContent := getTextResult(t, result) + + if tc.expectedErrMsg != "" { + assert.Contains(t, tc.expectedErrMsg, textContent.Text) + return + } + + if tc.expectedErrMsgRegexp != nil { + assert.Regexp(t, tc.expectedErrMsgRegexp, textContent.Text) + return + } + }) + } +} + +func Test_ActionsResourceRead_ListWorkflowArtifacts(t *testing.T) { + tests := []struct { + name string + mockedClient *http.Client + requestArgs map[string]any + expectError bool + expectedErrMsg string + expectedErrMsgRegexp *regexp.Regexp + }{ + { + name: "successful workflow artifacts read", + mockedClient: mock.NewMockedHTTPClient( + mock.WithRequestMatchHandler( + mock.GetReposActionsRunsArtifactsByOwnerByRepoByRunId, + http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) { + artifacts := &github.ArtifactList{ + TotalCount: github.Ptr(int64(2)), + Artifacts: []*github.Artifact{ + { + ID: github.Ptr(int64(12345)), + Name: github.Ptr("artifact-1"), + }, + { + ID: github.Ptr(int64(12346)), + Name: github.Ptr("artifact-2"), + }, + }, + } + w.WriteHeader(http.StatusOK) + _ = json.NewEncoder(w).Encode(artifacts) + }), + ), + ), + requestArgs: map[string]any{ + "action": actionsActionTypeListWorkflowArtifacts.String(), + "owner": "owner", + "repo": "repo", + "resource_id": "1", + }, + expectError: false, + }, + { + name: "missing workflow artifacts read", + mockedClient: mock.NewMockedHTTPClient( + mock.WithRequestMatchHandler( + mock.GetReposActionsRunsArtifactsByOwnerByRepoByRunId, + http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) { + w.WriteHeader(http.StatusNotFound) + }), + ), + ), + requestArgs: map[string]any{ + "action": actionsActionTypeListWorkflowArtifacts.String(), + "owner": "owner", + "repo": "repo", + "resource_id": "99999", + }, + expectError: true, + expectedErrMsgRegexp: regexp.MustCompile(`^failed to list workflow run artifacts: GET .*/repos/owner/repo/actions/runs/99999/artifacts.* 404.*$`), + }, + } + + for _, tc := range tests { + t.Run(tc.name, func(t *testing.T) { + // Setup client with mock + client := github.NewClient(tc.mockedClient) + _, handler := ActionsList(stubGetClientFn(client), translations.NullTranslationHelper) + + // Create call request + request := createMCPRequest(tc.requestArgs) + + // Call handler + result, err := handler(context.Background(), request) + + require.NoError(t, err) + require.Equal(t, tc.expectError, result.IsError) + + // Parse the result and get the text content if no error + textContent := getTextResult(t, result) + + if tc.expectedErrMsg != "" { + assert.Contains(t, tc.expectedErrMsg, textContent.Text) + return + } + + if tc.expectedErrMsgRegexp != nil { + assert.Regexp(t, tc.expectedErrMsgRegexp, textContent.Text) + return + } + }) + } +} + +func Test_ActionsResourceRead_GetWorkflowUsage(t *testing.T) { + tests := []struct { + name string + mockedClient *http.Client + requestArgs map[string]any + expectError bool + expectedErrMsg string + expectedErrMsgRegexp *regexp.Regexp + }{ + { + name: "successful workflow usage read", + mockedClient: mock.NewMockedHTTPClient( + mock.WithRequestMatchHandler( + mock.GetReposActionsRunsTimingByOwnerByRepoByRunId, + http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) { + workflowUsage := &github.WorkflowRunUsage{ + Billable: &github.WorkflowRunBillMap{ + "UBUNTU": &github.WorkflowRunBill{ + TotalMS: github.Ptr(int64(60000)), + Jobs: github.Ptr(1), + JobRuns: []*github.WorkflowRunJobRun{ + &github.WorkflowRunJobRun{ + JobID: github.Ptr(1), + DurationMS: github.Ptr(int64(600)), + }, + }, + }, + }, + RunDurationMS: github.Ptr(int64(60000)), + } + w.WriteHeader(http.StatusOK) + _ = json.NewEncoder(w).Encode(workflowUsage) + }), + ), + ), + requestArgs: map[string]any{ + "action": actionsActionTypeGetWorkflowRunUsage.String(), + "owner": "owner", + "repo": "repo", + "resource_id": "1", + }, + expectError: false, + }, + { + name: "missing workflow usage read", + mockedClient: mock.NewMockedHTTPClient( + mock.WithRequestMatchHandler( + mock.GetReposActionsRunsTimingByOwnerByRepoByRunId, + http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) { + w.WriteHeader(http.StatusNotFound) + }), + ), + ), + requestArgs: map[string]any{ + "action": actionsActionTypeGetWorkflowRunUsage.String(), + "owner": "owner", + "repo": "repo", + "resource_id": "99999", + }, + expectError: true, + expectedErrMsgRegexp: regexp.MustCompile(`^failed to get workflow run usage: GET .*/repos/owner/repo/actions/runs/99999/timing.* 404.*$`), + }, + } + + for _, tc := range tests { + t.Run(tc.name, func(t *testing.T) { + // Setup client with mock + client := github.NewClient(tc.mockedClient) + _, handler := ActionsGet(stubGetClientFn(client), translations.NullTranslationHelper) + + // Create call request + request := createMCPRequest(tc.requestArgs) + + // Call handler + result, err := handler(context.Background(), request) + + require.NoError(t, err) + require.Equal(t, tc.expectError, result.IsError) + + // Parse the result and get the text content if no error + textContent := getTextResult(t, result) + + if tc.expectedErrMsg != "" { + assert.Contains(t, tc.expectedErrMsg, textContent.Text) + return + } + + if tc.expectedErrMsgRegexp != nil { + assert.Regexp(t, tc.expectedErrMsgRegexp, textContent.Text) + return + } + }) + } +} diff --git a/pkg/github/tools.go b/pkg/github/tools.go index 4296aaa72..2f56d855f 100644 --- a/pkg/github/tools.go +++ b/pkg/github/tools.go @@ -269,21 +269,14 @@ func DefaultToolsetGroup(readOnly bool, getClient GetClientFn, getGQLClient GetG actions := toolsets.NewToolset(ToolsetMetadataActions.ID, ToolsetMetadataActions.Description). AddReadTools( - toolsets.NewServerTool(ListWorkflows(getClient, t)), - toolsets.NewServerTool(ListWorkflowRuns(getClient, t)), - toolsets.NewServerTool(GetWorkflowRun(getClient, t)), + toolsets.NewServerTool(ActionsGet(getClient, t)), + toolsets.NewServerTool(ActionsList(getClient, t)), toolsets.NewServerTool(GetWorkflowRunLogs(getClient, t)), - toolsets.NewServerTool(ListWorkflowJobs(getClient, t)), toolsets.NewServerTool(GetJobLogs(getClient, t, contentWindowSize)), - toolsets.NewServerTool(ListWorkflowRunArtifacts(getClient, t)), - toolsets.NewServerTool(DownloadWorkflowRunArtifact(getClient, t)), - toolsets.NewServerTool(GetWorkflowRunUsage(getClient, t)), ). AddWriteTools( toolsets.NewServerTool(RunWorkflow(getClient, t)), - toolsets.NewServerTool(RerunWorkflowRun(getClient, t)), - toolsets.NewServerTool(RerunFailedJobs(getClient, t)), - toolsets.NewServerTool(CancelWorkflowRun(getClient, t)), + toolsets.NewServerTool(ActionsRunTrigger(getClient, t)), toolsets.NewServerTool(DeleteWorkflowRunLogs(getClient, t)), )