diff --git a/README.md b/README.md index b806d29ff..93e6af03f 100644 --- a/README.md +++ b/README.md @@ -490,94 +490,49 @@ The following sets of tools are available: Actions -- **cancel_workflow_run** - Cancel workflow run +- **actions_get** - Get details of GitHub Actions resources (workflows, workflow runs, jobs, and artifacts) + - `method`: The method to execute (string, required) - `owner`: Repository owner (string, required) - `repo`: Repository name (string, required) - - `run_id`: The unique identifier of the workflow run (number, required) + - `resource_id`: The unique identifier of the resource. This will vary based on the "method" provided, so ensure you provide the correct ID: + - Provide a workflow ID or workflow file name (e.g. ci.yaml) for 'get_workflow' method. + - Provide a workflow run ID for 'get_workflow_run', 'get_workflow_run_usage', and 'get_workflow_run_logs_url' methods. + - Provide an artifact ID for 'download_workflow_run_artifact' method. + - Provide a job ID for 'get_workflow_job' method. + (string, required) -- **delete_workflow_run_logs** - Delete workflow logs +- **actions_list** - List GitHub Actions workflows in a repository + - `method`: The action to perform (string, required) - `owner`: Repository owner (string, required) + - `page`: Page number for pagination (default: 1) (number, optional) + - `per_page`: Results per page for pagination (default: 30, max: 100) (number, optional) - `repo`: Repository name (string, required) - - `run_id`: The unique identifier of the workflow run (number, required) - -- **download_workflow_run_artifact** - Download workflow artifact - - `artifact_id`: The unique identifier of the artifact (number, required) + - `resource_id`: The unique identifier of the resource. This will vary based on the "method" provided, so ensure you provide the correct ID: + - Do not provide any resource ID for 'list_workflows' method. + - Provide a workflow ID or workflow file name (e.g. ci.yaml) for 'list_workflow_runs' method. + - Provide a workflow run ID for 'list_workflow_jobs' and 'list_workflow_run_artifacts' methods. + (string, optional) + - `workflow_jobs_filter`: Filters for workflow jobs. **ONLY** used when method is 'list_workflow_jobs' (object, optional) + - `workflow_runs_filter`: Filters for workflow runs. **ONLY** used when method is 'list_workflow_runs' (object, optional) + +- **actions_run_trigger** - Trigger GitHub Actions workflow actions + - `inputs`: Inputs the workflow accepts. Only used for 'run_workflow' method. (object, optional) + - `method`: The method to execute (string, required) - `owner`: Repository owner (string, required) + - `ref`: The git reference for the workflow. The reference can be a branch or tag name. Required for 'run_workflow' method. (string, optional) - `repo`: Repository name (string, required) + - `run_id`: The ID of the workflow run. Required for all methods except 'run_workflow'. (number, optional) + - `workflow_id`: The workflow ID (numeric) or workflow file name (e.g., main.yml, ci.yaml). Required for 'run_workflow' method. (string, optional) -- **get_job_logs** - Get job logs - - `failed_only`: When true, gets logs for all failed jobs in run_id (boolean, optional) - - `job_id`: The unique identifier of the workflow job (required for single job logs) (number, optional) +- **get_job_logs** - Get GitHub Actions workflow job logs + - `failed_only`: When true, gets logs for all failed jobs in the workflow run specified by run_id. Requires run_id to be provided. (boolean, optional) + - `job_id`: The unique identifier of the workflow job. Required when getting logs for a single job. (number, optional) - `owner`: Repository owner (string, required) - `repo`: Repository name (string, required) - `return_content`: Returns actual log content instead of URLs (boolean, optional) - - `run_id`: Workflow run ID (required when using failed_only) (number, optional) + - `run_id`: The unique identifier of the workflow run. Required when failed_only is true to get logs for all failed jobs in the run. (number, optional) - `tail_lines`: Number of lines to return from the end of the log (number, optional) -- **get_workflow_run** - Get workflow run - - `owner`: Repository owner (string, required) - - `repo`: Repository name (string, required) - - `run_id`: The unique identifier of the workflow run (number, required) - -- **get_workflow_run_logs** - Get workflow run logs - - `owner`: Repository owner (string, required) - - `repo`: Repository name (string, required) - - `run_id`: The unique identifier of the workflow run (number, required) - -- **get_workflow_run_usage** - Get workflow usage - - `owner`: Repository owner (string, required) - - `repo`: Repository name (string, required) - - `run_id`: The unique identifier of the workflow run (number, required) - -- **list_workflow_jobs** - List workflow jobs - - `filter`: Filters jobs by their completed_at timestamp (string, optional) - - `owner`: Repository owner (string, required) - - `page`: Page number for pagination (min 1) (number, optional) - - `perPage`: Results per page for pagination (min 1, max 100) (number, optional) - - `repo`: Repository name (string, required) - - `run_id`: The unique identifier of the workflow run (number, required) - -- **list_workflow_run_artifacts** - List workflow artifacts - - `owner`: Repository owner (string, required) - - `page`: Page number for pagination (min 1) (number, optional) - - `perPage`: Results per page for pagination (min 1, max 100) (number, optional) - - `repo`: Repository name (string, required) - - `run_id`: The unique identifier of the workflow run (number, required) - -- **list_workflow_runs** - List workflow runs - - `actor`: Returns someone's workflow runs. Use the login for the user who created the workflow run. (string, optional) - - `branch`: Returns workflow runs associated with a branch. Use the name of the branch. (string, optional) - - `event`: Returns workflow runs for a specific event type (string, optional) - - `owner`: Repository owner (string, required) - - `page`: Page number for pagination (min 1) (number, optional) - - `perPage`: Results per page for pagination (min 1, max 100) (number, optional) - - `repo`: Repository name (string, required) - - `status`: Returns workflow runs with the check run status (string, optional) - - `workflow_id`: The workflow ID or workflow file name (string, required) - -- **list_workflows** - List workflows - - `owner`: Repository owner (string, required) - - `page`: Page number for pagination (min 1) (number, optional) - - `perPage`: Results per page for pagination (min 1, max 100) (number, optional) - - `repo`: Repository name (string, required) - -- **rerun_failed_jobs** - Rerun failed jobs - - `owner`: Repository owner (string, required) - - `repo`: Repository name (string, required) - - `run_id`: The unique identifier of the workflow run (number, required) - -- **rerun_workflow_run** - Rerun workflow run - - `owner`: Repository owner (string, required) - - `repo`: Repository name (string, required) - - `run_id`: The unique identifier of the workflow run (number, required) - -- **run_workflow** - Run workflow - - `inputs`: Inputs the workflow accepts (object, optional) - - `owner`: Repository owner (string, required) - - `ref`: The git reference for the workflow. The reference can be a branch or tag name. (string, required) - - `repo`: Repository name (string, required) - - `workflow_id`: The workflow ID (numeric) or workflow file name (e.g., main.yml, ci.yaml) (string, required) -
diff --git a/pkg/github/__toolsnaps__/actions_get.snap b/pkg/github/__toolsnaps__/actions_get.snap new file mode 100644 index 000000000..b5f3b85bd --- /dev/null +++ b/pkg/github/__toolsnaps__/actions_get.snap @@ -0,0 +1,43 @@ +{ + "annotations": { + "readOnlyHint": true, + "title": "Get details of GitHub Actions resources (workflows, workflow runs, jobs, and artifacts)" + }, + "description": "Get details about specific GitHub Actions resources.\nUse this tool to get details about individual workflows, workflow runs, jobs, and artifacts by their unique IDs.\n", + "inputSchema": { + "type": "object", + "required": [ + "method", + "owner", + "repo", + "resource_id" + ], + "properties": { + "method": { + "type": "string", + "description": "The method to execute", + "enum": [ + "get_workflow", + "get_workflow_run", + "get_workflow_job", + "download_workflow_run_artifact", + "get_workflow_run_usage", + "get_workflow_run_logs_url" + ] + }, + "owner": { + "type": "string", + "description": "Repository owner" + }, + "repo": { + "type": "string", + "description": "Repository name" + }, + "resource_id": { + "type": "string", + "description": "The unique identifier of the resource. This will vary based on the \"method\" provided, so ensure you provide the correct ID:\n- Provide a workflow ID or workflow file name (e.g. ci.yaml) for 'get_workflow' method.\n- Provide a workflow run ID for 'get_workflow_run', 'get_workflow_run_usage', and 'get_workflow_run_logs_url' methods.\n- Provide an artifact ID for 'download_workflow_run_artifact' method.\n- Provide a job ID for 'get_workflow_job' method.\n" + } + } + }, + "name": "actions_get" +} \ No newline at end of file diff --git a/pkg/github/__toolsnaps__/actions_list.snap b/pkg/github/__toolsnaps__/actions_list.snap new file mode 100644 index 000000000..3968a6eae --- /dev/null +++ b/pkg/github/__toolsnaps__/actions_list.snap @@ -0,0 +1,128 @@ +{ + "annotations": { + "readOnlyHint": true, + "title": "List GitHub Actions workflows in a repository" + }, + "description": "Tools for listing GitHub Actions resources.\nUse this tool to list workflows in a repository, or list workflow runs, jobs, and artifacts for a specific workflow or workflow run.\n", + "inputSchema": { + "type": "object", + "required": [ + "method", + "owner", + "repo" + ], + "properties": { + "method": { + "type": "string", + "description": "The action to perform", + "enum": [ + "list_workflows", + "list_workflow_runs", + "list_workflow_jobs", + "list_workflow_run_artifacts" + ] + }, + "owner": { + "type": "string", + "description": "Repository owner" + }, + "page": { + "type": "number", + "description": "Page number for pagination (default: 1)", + "minimum": 1 + }, + "per_page": { + "type": "number", + "description": "Results per page for pagination (default: 30, max: 100)", + "minimum": 1, + "maximum": 100 + }, + "repo": { + "type": "string", + "description": "Repository name" + }, + "resource_id": { + "type": "string", + "description": "The unique identifier of the resource. This will vary based on the \"method\" provided, so ensure you provide the correct ID:\n- Do not provide any resource ID for 'list_workflows' method.\n- Provide a workflow ID or workflow file name (e.g. ci.yaml) for 'list_workflow_runs' method.\n- Provide a workflow run ID for 'list_workflow_jobs' and 'list_workflow_run_artifacts' methods.\n" + }, + "workflow_jobs_filter": { + "type": "object", + "description": "Filters for workflow jobs. **ONLY** used when method is 'list_workflow_jobs'", + "properties": { + "filter": { + "type": "string", + "description": "Filters jobs by their completed_at timestamp", + "enum": [ + "latest", + "all" + ] + } + } + }, + "workflow_runs_filter": { + "type": "object", + "description": "Filters for workflow runs. **ONLY** used when method is 'list_workflow_runs'", + "properties": { + "actor": { + "type": "string", + "description": "Filter to a specific GitHub user's workflow runs." + }, + "branch": { + "type": "string", + "description": "Filter workflow runs to a specific Git branch. Use the name of the branch." + }, + "event": { + "type": "string", + "description": "Filter workflow runs to a specific event type", + "enum": [ + "branch_protection_rule", + "check_run", + "check_suite", + "create", + "delete", + "deployment", + "deployment_status", + "discussion", + "discussion_comment", + "fork", + "gollum", + "issue_comment", + "issues", + "label", + "merge_group", + "milestone", + "page_build", + "public", + "pull_request", + "pull_request_review", + "pull_request_review_comment", + "pull_request_target", + "push", + "registry_package", + "release", + "repository_dispatch", + "schedule", + "status", + "watch", + "workflow_call", + "workflow_dispatch", + "workflow_run" + ] + }, + "status": { + "type": "string", + "description": "Filter workflow runs to only runs with a specific status", + "enum": [ + "queued", + "in_progress", + "completed", + "requested", + "waiting" + ] + } + } + } + } + }, + "name": "actions_list" +} \ No newline at end of file diff --git a/pkg/github/__toolsnaps__/actions_run_trigger.snap b/pkg/github/__toolsnaps__/actions_run_trigger.snap new file mode 100644 index 000000000..4e16f8958 --- /dev/null +++ b/pkg/github/__toolsnaps__/actions_run_trigger.snap @@ -0,0 +1,53 @@ +{ + "annotations": { + "destructiveHint": true, + "title": "Trigger GitHub Actions workflow actions" + }, + "description": "Trigger GitHub Actions workflow operations, including running, re-running, cancelling workflow runs, and deleting workflow run logs.", + "inputSchema": { + "type": "object", + "required": [ + "method", + "owner", + "repo" + ], + "properties": { + "inputs": { + "type": "object", + "description": "Inputs the workflow accepts. Only used for 'run_workflow' method." + }, + "method": { + "type": "string", + "description": "The method to execute", + "enum": [ + "run_workflow", + "rerun_workflow_run", + "rerun_failed_jobs", + "cancel_workflow_run", + "delete_workflow_run_logs" + ] + }, + "owner": { + "type": "string", + "description": "Repository owner" + }, + "ref": { + "type": "string", + "description": "The git reference for the workflow. The reference can be a branch or tag name. Required for 'run_workflow' method." + }, + "repo": { + "type": "string", + "description": "Repository name" + }, + "run_id": { + "type": "number", + "description": "The ID of the workflow run. Required for all methods except 'run_workflow'." + }, + "workflow_id": { + "type": "string", + "description": "The workflow ID (numeric) or workflow file name (e.g., main.yml, ci.yaml). Required for 'run_workflow' method." + } + } + }, + "name": "actions_run_trigger" +} \ No newline at end of file diff --git a/pkg/github/__toolsnaps__/get_job_logs.snap b/pkg/github/__toolsnaps__/get_job_logs.snap index 8b2319527..23e2b640f 100644 --- a/pkg/github/__toolsnaps__/get_job_logs.snap +++ b/pkg/github/__toolsnaps__/get_job_logs.snap @@ -1,9 +1,9 @@ { "annotations": { "readOnlyHint": true, - "title": "Get job logs" + "title": "Get GitHub Actions workflow job logs" }, - "description": "Download logs for a specific workflow job or efficiently get all failed job logs for a workflow run", + "description": "Get logs for GitHub Actions workflow jobs.\nUse this tool to retrieve logs for a specific job or all failed jobs in a workflow run.\nFor single job logs, provide job_id. For all failed jobs in a run, provide run_id with failed_only=true.\n", "inputSchema": { "type": "object", "required": [ @@ -13,11 +13,11 @@ "properties": { "failed_only": { "type": "boolean", - "description": "When true, gets logs for all failed jobs in run_id" + "description": "When true, gets logs for all failed jobs in the workflow run specified by run_id. Requires run_id to be provided." }, "job_id": { "type": "number", - "description": "The unique identifier of the workflow job (required for single job logs)" + "description": "The unique identifier of the workflow job. Required when getting logs for a single job." }, "owner": { "type": "string", @@ -33,7 +33,7 @@ }, "run_id": { "type": "number", - "description": "Workflow run ID (required when using failed_only)" + "description": "The unique identifier of the workflow run. Required when failed_only is true to get logs for all failed jobs in the run." }, "tail_lines": { "type": "number", diff --git a/pkg/github/actions.go b/pkg/github/actions.go index 81ed55296..453be8f98 100644 --- a/pkg/github/actions.go +++ b/pkg/github/actions.go @@ -3,6 +3,7 @@ package github import ( "context" "encoding/json" + "errors" "fmt" "net/http" "strconv" @@ -18,189 +19,169 @@ import ( "github.com/modelcontextprotocol/go-sdk/mcp" ) +// Method constants for consolidated actions tools const ( - DescriptionRepositoryOwner = "Repository owner" - DescriptionRepositoryName = "Repository name" + actionsMethodListWorkflows = "list_workflows" + actionsMethodListWorkflowRuns = "list_workflow_runs" + actionsMethodListWorkflowJobs = "list_workflow_jobs" + actionsMethodListWorkflowArtifacts = "list_workflow_run_artifacts" + actionsMethodGetWorkflow = "get_workflow" + actionsMethodGetWorkflowRun = "get_workflow_run" + actionsMethodGetWorkflowJob = "get_workflow_job" + actionsMethodGetWorkflowRunUsage = "get_workflow_run_usage" + actionsMethodGetWorkflowRunLogsURL = "get_workflow_run_logs_url" + actionsMethodDownloadWorkflowArtifact = "download_workflow_run_artifact" + actionsMethodRunWorkflow = "run_workflow" + actionsMethodRerunWorkflowRun = "rerun_workflow_run" + actionsMethodRerunFailedJobs = "rerun_failed_jobs" + actionsMethodCancelWorkflowRun = "cancel_workflow_run" + actionsMethodDeleteWorkflowRunLogs = "delete_workflow_run_logs" ) -// ListWorkflows creates a tool to list workflows in a repository -func ListWorkflows(getClient GetClientFn, t translations.TranslationHelperFunc) (mcp.Tool, mcp.ToolHandlerFor[map[string]any, any]) { +// ActionsList returns the tool and handler for listing GitHub Actions resources. +func ActionsList(getClient GetClientFn, t translations.TranslationHelperFunc) (mcp.Tool, mcp.ToolHandlerFor[map[string]any, any]) { return mcp.Tool{ - Name: "list_workflows", - Description: t("TOOL_LIST_WORKFLOWS_DESCRIPTION", "List workflows in a repository"), + Name: "actions_list", + Description: t("TOOL_ACTIONS_LIST_DESCRIPTION", `Tools for listing GitHub Actions resources. +Use this tool to list workflows in a repository, or list workflow runs, jobs, and artifacts for a specific workflow or workflow run. +`), Annotations: &mcp.ToolAnnotations{ - Title: t("TOOL_LIST_WORKFLOWS_USER_TITLE", "List workflows"), + Title: t("TOOL_ACTIONS_LIST_USER_TITLE", "List GitHub Actions workflows in a repository"), ReadOnlyHint: true, }, - InputSchema: WithPagination(&jsonschema.Schema{ + InputSchema: &jsonschema.Schema{ Type: "object", Properties: map[string]*jsonschema.Schema{ - "owner": { - Type: "string", - Description: DescriptionRepositoryOwner, - }, - "repo": { + "method": { Type: "string", - Description: DescriptionRepositoryName, + Description: "The action to perform", + Enum: []any{ + actionsMethodListWorkflows, + actionsMethodListWorkflowRuns, + actionsMethodListWorkflowJobs, + actionsMethodListWorkflowArtifacts, + }, }, - }, - Required: []string{"owner", "repo"}, - }), - }, - func(ctx context.Context, _ *mcp.CallToolRequest, args map[string]any) (*mcp.CallToolResult, any, error) { - owner, err := RequiredParam[string](args, "owner") - if err != nil { - return utils.NewToolResultError(err.Error()), nil, nil - } - repo, err := RequiredParam[string](args, "repo") - if err != nil { - return utils.NewToolResultError(err.Error()), nil, nil - } - - // Get optional pagination parameters - pagination, err := OptionalPaginationParams(args) - if err != nil { - return utils.NewToolResultError(err.Error()), nil, nil - } - - client, err := getClient(ctx) - if err != nil { - return nil, nil, fmt.Errorf("failed to get GitHub client: %w", err) - } - - // 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, nil, fmt.Errorf("failed to list workflows: %w", err) - } - defer func() { _ = resp.Body.Close() }() - - r, err := json.Marshal(workflows) - if err != nil { - return nil, nil, fmt.Errorf("failed to marshal response: %w", err) - } - - return utils.NewToolResultText(string(r)), nil, nil - } -} - -// ListWorkflowRuns creates a tool to list workflow runs for a specific workflow -func ListWorkflowRuns(getClient GetClientFn, t translations.TranslationHelperFunc) (mcp.Tool, mcp.ToolHandlerFor[map[string]any, any]) { - return mcp.Tool{ - Name: "list_workflow_runs", - Description: t("TOOL_LIST_WORKFLOW_RUNS_DESCRIPTION", "List workflow runs for a specific workflow"), - Annotations: &mcp.ToolAnnotations{ - Title: t("TOOL_LIST_WORKFLOW_RUNS_USER_TITLE", "List workflow runs"), - ReadOnlyHint: true, - }, - InputSchema: WithPagination(&jsonschema.Schema{ - Type: "object", - Properties: map[string]*jsonschema.Schema{ "owner": { Type: "string", - Description: DescriptionRepositoryOwner, + Description: "Repository owner", }, "repo": { Type: "string", - Description: DescriptionRepositoryName, - }, - "workflow_id": { - Type: "string", - Description: "The workflow ID or workflow file name", + Description: "Repository name", }, - "actor": { - Type: "string", - Description: "Returns someone's workflow runs. Use the login for the user who created the workflow run.", + "resource_id": { + Type: "string", + Description: `The unique identifier of the resource. This will vary based on the "method" provided, so ensure you provide the correct ID: +- Do not provide any resource ID for 'list_workflows' method. +- Provide a workflow ID or workflow file name (e.g. ci.yaml) for 'list_workflow_runs' method. +- Provide a workflow run ID for 'list_workflow_jobs' and 'list_workflow_run_artifacts' methods. +`, }, - "branch": { - Type: "string", - Description: "Returns workflow runs associated with a branch. Use the name of the branch.", + "workflow_runs_filter": { + Type: "object", + Description: "Filters for workflow runs. **ONLY** used when method is 'list_workflow_runs'", + Properties: map[string]*jsonschema.Schema{ + "actor": { + Type: "string", + Description: "Filter to a specific GitHub user's workflow runs.", + }, + "branch": { + Type: "string", + Description: "Filter workflow runs to a specific Git branch. Use the name of the branch.", + }, + "event": { + Type: "string", + Description: "Filter workflow runs to a specific event type", + Enum: []any{ + "branch_protection_rule", + "check_run", + "check_suite", + "create", + "delete", + "deployment", + "deployment_status", + "discussion", + "discussion_comment", + "fork", + "gollum", + "issue_comment", + "issues", + "label", + "merge_group", + "milestone", + "page_build", + "public", + "pull_request", + "pull_request_review", + "pull_request_review_comment", + "pull_request_target", + "push", + "registry_package", + "release", + "repository_dispatch", + "schedule", + "status", + "watch", + "workflow_call", + "workflow_dispatch", + "workflow_run", + }, + }, + "status": { + Type: "string", + Description: "Filter workflow runs to only runs with a specific status", + Enum: []any{"queued", "in_progress", "completed", "requested", "waiting"}, + }, + }, }, - "event": { - Type: "string", - Description: "Returns workflow runs for a specific event type", - Enum: []any{ - "branch_protection_rule", - "check_run", - "check_suite", - "create", - "delete", - "deployment", - "deployment_status", - "discussion", - "discussion_comment", - "fork", - "gollum", - "issue_comment", - "issues", - "label", - "merge_group", - "milestone", - "page_build", - "public", - "pull_request", - "pull_request_review", - "pull_request_review_comment", - "pull_request_target", - "push", - "registry_package", - "release", - "repository_dispatch", - "schedule", - "status", - "watch", - "workflow_call", - "workflow_dispatch", - "workflow_run", + "workflow_jobs_filter": { + Type: "object", + Description: "Filters for workflow jobs. **ONLY** used when method is 'list_workflow_jobs'", + Properties: map[string]*jsonschema.Schema{ + "filter": { + Type: "string", + Description: "Filters jobs by their completed_at timestamp", + Enum: []any{"latest", "all"}, + }, }, }, - "status": { - Type: "string", - Description: "Returns workflow runs with the check run status", - Enum: []any{"queued", "in_progress", "completed", "requested", "waiting"}, + "page": { + Type: "number", + Description: "Page number for pagination (default: 1)", + Minimum: jsonschema.Ptr(1.0), + }, + "per_page": { + Type: "number", + Description: "Results per page for pagination (default: 30, max: 100)", + Minimum: jsonschema.Ptr(1.0), + Maximum: jsonschema.Ptr(100.0), }, }, - Required: []string{"owner", "repo", "workflow_id"}, - }), + Required: []string{"method", "owner", "repo"}, + }, }, func(ctx context.Context, _ *mcp.CallToolRequest, args map[string]any) (*mcp.CallToolResult, any, error) { owner, err := RequiredParam[string](args, "owner") if err != nil { return utils.NewToolResultError(err.Error()), nil, nil } + repo, err := RequiredParam[string](args, "repo") if err != nil { return utils.NewToolResultError(err.Error()), nil, nil } - workflowID, err := RequiredParam[string](args, "workflow_id") - if err != nil { - return utils.NewToolResultError(err.Error()), nil, nil - } - // Get optional filtering parameters - actor, err := OptionalParam[string](args, "actor") - if err != nil { - return utils.NewToolResultError(err.Error()), nil, nil - } - branch, err := OptionalParam[string](args, "branch") - if err != nil { - return utils.NewToolResultError(err.Error()), nil, nil - } - event, err := OptionalParam[string](args, "event") + method, err := RequiredParam[string](args, "method") if err != nil { return utils.NewToolResultError(err.Error()), nil, nil } - status, err := OptionalParam[string](args, "status") + + resourceID, err := OptionalParam[string](args, "resource_id") if err != nil { return utils.NewToolResultError(err.Error()), nil, nil } - // Get optional pagination parameters pagination, err := OptionalPaginationParams(args) if err != nil { return utils.NewToolResultError(err.Error()), nil, nil @@ -211,67 +192,86 @@ func ListWorkflowRuns(getClient GetClientFn, t translations.TranslationHelperFun return nil, 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, - }, - } + var resourceIDInt int64 + var parseErr error + switch method { + case actionsMethodListWorkflows: + // Do nothing, no resource ID needed + default: + if resourceID == "" { + return utils.NewToolResultError(fmt.Sprintf("missing required parameter for method %s: resource_id", method)), nil, nil + } - workflowRuns, resp, err := client.Actions.ListWorkflowRunsByFileName(ctx, owner, repo, workflowID, opts) - if err != nil { - return nil, nil, fmt.Errorf("failed to list workflow runs: %w", err) + // For list_workflow_runs, resource_id could be a filename or numeric ID + // For other actions, resource ID must be an integer + if method != actionsMethodListWorkflowRuns { + resourceIDInt, parseErr = strconv.ParseInt(resourceID, 10, 64) + if parseErr != nil { + return utils.NewToolResultError(fmt.Sprintf("invalid resource_id, must be an integer for method %s: %v", method, parseErr)), nil, nil + } + } } - defer func() { _ = resp.Body.Close() }() - r, err := json.Marshal(workflowRuns) - if err != nil { - return nil, nil, fmt.Errorf("failed to marshal response: %w", err) + switch method { + case actionsMethodListWorkflows: + return listWorkflows(ctx, client, args, owner, repo, pagination) + case actionsMethodListWorkflowRuns: + return listWorkflowRuns(ctx, client, args, owner, repo, resourceID, pagination) + case actionsMethodListWorkflowJobs: + return listWorkflowJobs(ctx, client, args, owner, repo, resourceIDInt, pagination) + case actionsMethodListWorkflowArtifacts: + return listWorkflowArtifacts(ctx, client, args, owner, repo, resourceIDInt, pagination) + default: + return utils.NewToolResultError(fmt.Sprintf("unknown method: %s", method)), nil, nil } - - return utils.NewToolResultText(string(r)), nil, nil } } -// RunWorkflow creates a tool to run an Actions workflow -func RunWorkflow(getClient GetClientFn, t translations.TranslationHelperFunc) (mcp.Tool, mcp.ToolHandlerFor[map[string]any, any]) { +// ActionsGet returns the tool and handler for getting GitHub Actions resources. +func ActionsGet(getClient GetClientFn, t translations.TranslationHelperFunc) (mcp.Tool, mcp.ToolHandlerFor[map[string]any, any]) { return mcp.Tool{ - Name: "run_workflow", - Description: t("TOOL_RUN_WORKFLOW_DESCRIPTION", "Run an Actions workflow by workflow ID or filename"), + Name: "actions_get", + Description: t("TOOL_ACTIONS_GET_DESCRIPTION", `Get details about specific GitHub Actions resources. +Use this tool to get details about individual workflows, workflow runs, jobs, and artifacts by their unique IDs. +`), Annotations: &mcp.ToolAnnotations{ - Title: t("TOOL_RUN_WORKFLOW_USER_TITLE", "Run workflow"), - ReadOnlyHint: false, + Title: t("TOOL_ACTIONS_GET_USER_TITLE", "Get details of GitHub Actions resources (workflows, workflow runs, jobs, and artifacts)"), + ReadOnlyHint: true, }, InputSchema: &jsonschema.Schema{ Type: "object", Properties: map[string]*jsonschema.Schema{ - "owner": { - Type: "string", - Description: DescriptionRepositoryOwner, - }, - "repo": { + "method": { Type: "string", - Description: DescriptionRepositoryName, + Description: "The method to execute", + Enum: []any{ + actionsMethodGetWorkflow, + actionsMethodGetWorkflowRun, + actionsMethodGetWorkflowJob, + actionsMethodDownloadWorkflowArtifact, + actionsMethodGetWorkflowRunUsage, + actionsMethodGetWorkflowRunLogsURL, + }, }, - "workflow_id": { + "owner": { Type: "string", - Description: "The workflow ID (numeric) or workflow file name (e.g., main.yml, ci.yaml)", + Description: "Repository owner", }, - "ref": { + "repo": { Type: "string", - Description: "The git reference for the workflow. The reference can be a branch or tag name.", + Description: "Repository name", }, - "inputs": { - Type: "object", - Description: "Inputs the workflow accepts", + "resource_id": { + Type: "string", + Description: `The unique identifier of the resource. This will vary based on the "method" provided, so ensure you provide the correct ID: +- Provide a workflow ID or workflow file name (e.g. ci.yaml) for 'get_workflow' method. +- Provide a workflow run ID for 'get_workflow_run', 'get_workflow_run_usage', and 'get_workflow_run_logs_url' methods. +- Provide an artifact ID for 'download_workflow_run_artifact' method. +- Provide a job ID for 'get_workflow_job' method. +`, }, }, - Required: []string{"owner", "repo", "workflow_id", "ref"}, + Required: []string{"method", "owner", "repo", "resource_id"}, }, }, func(ctx context.Context, _ *mcp.CallToolRequest, args map[string]any) (*mcp.CallToolResult, any, error) { @@ -283,236 +283,104 @@ func RunWorkflow(getClient GetClientFn, t translations.TranslationHelperFunc) (m if err != nil { return utils.NewToolResultError(err.Error()), nil, nil } - workflowID, err := RequiredParam[string](args, "workflow_id") + method, err := RequiredParam[string](args, "method") if err != nil { return utils.NewToolResultError(err.Error()), nil, nil } - ref, err := RequiredParam[string](args, "ref") + + resourceID, err := RequiredParam[string](args, "resource_id") if err != nil { return utils.NewToolResultError(err.Error()), nil, nil } - // Get optional inputs parameter - var inputs map[string]interface{} - if requestInputs, ok := args["inputs"]; ok { - if inputsMap, ok := requestInputs.(map[string]interface{}); ok { - inputs = inputsMap - } - } - client, err := getClient(ctx) if err != nil { return nil, nil, fmt.Errorf("failed to get GitHub client: %w", err) } - event := github.CreateWorkflowDispatchEventRequest{ - Ref: ref, - Inputs: inputs, - } - - var resp *github.Response - var workflowType string - - if workflowIDInt, parseErr := strconv.ParseInt(workflowID, 10, 64); parseErr == nil { - resp, err = client.Actions.CreateWorkflowDispatchEventByID(ctx, owner, repo, workflowIDInt, event) - workflowType = "workflow_id" - } else { - resp, err = client.Actions.CreateWorkflowDispatchEventByFileName(ctx, owner, repo, workflowID, event) - workflowType = "workflow_file" - } - - if err != nil { - return nil, nil, fmt.Errorf("failed to run workflow: %w", err) - } - defer func() { _ = resp.Body.Close() }() - - result := map[string]any{ - "message": "Workflow run has been queued", - "workflow_type": workflowType, - "workflow_id": workflowID, - "ref": ref, - "inputs": inputs, - "status": resp.Status, - "status_code": resp.StatusCode, + var resourceIDInt int64 + var parseErr error + switch method { + case actionsMethodGetWorkflow: + // Do nothing, we accept both a string workflow ID or filename + default: + // For other methods, resource ID must be an integer + resourceIDInt, parseErr = strconv.ParseInt(resourceID, 10, 64) + if parseErr != nil { + return utils.NewToolResultError(fmt.Sprintf("invalid resource_id, must be an integer for method %s: %v", method, parseErr)), nil, nil + } } - r, err := json.Marshal(result) - if err != nil { - return nil, nil, fmt.Errorf("failed to marshal response: %w", err) + switch method { + case actionsMethodGetWorkflow: + return getWorkflow(ctx, client, args, owner, repo, resourceID) + case actionsMethodGetWorkflowRun: + return getWorkflowRun(ctx, client, args, owner, repo, resourceIDInt) + case actionsMethodGetWorkflowJob: + return getWorkflowJob(ctx, client, args, owner, repo, resourceIDInt) + case actionsMethodDownloadWorkflowArtifact: + return downloadWorkflowArtifact(ctx, client, args, owner, repo, resourceIDInt) + case actionsMethodGetWorkflowRunUsage: + return getWorkflowRunUsage(ctx, client, args, owner, repo, resourceIDInt) + case actionsMethodGetWorkflowRunLogsURL: + return getWorkflowRunLogs(ctx, client, args, owner, repo, resourceIDInt) + default: + return utils.NewToolResultError(fmt.Sprintf("unknown method: %s", method)), nil, nil } - - return utils.NewToolResultText(string(r)), nil, nil } } -// GetWorkflowRun creates a tool to get details of a specific workflow run -func GetWorkflowRun(getClient GetClientFn, t translations.TranslationHelperFunc) (mcp.Tool, mcp.ToolHandlerFor[map[string]any, any]) { +// ActionsRunTrigger returns the tool and handler for triggering GitHub Actions workflows. +func ActionsRunTrigger(getClient GetClientFn, t translations.TranslationHelperFunc) (mcp.Tool, mcp.ToolHandlerFor[map[string]any, any]) { return mcp.Tool{ - Name: "get_workflow_run", - Description: t("TOOL_GET_WORKFLOW_RUN_DESCRIPTION", "Get details of a specific workflow run"), + Name: "actions_run_trigger", + Description: t("TOOL_ACTIONS_RUN_TRIGGER_DESCRIPTION", "Trigger GitHub Actions workflow operations, including running, re-running, cancelling workflow runs, and deleting workflow run logs."), Annotations: &mcp.ToolAnnotations{ - Title: t("TOOL_GET_WORKFLOW_RUN_USER_TITLE", "Get workflow run"), - ReadOnlyHint: true, + Title: t("TOOL_ACTIONS_RUN_TRIGGER_USER_TITLE", "Trigger GitHub Actions workflow actions"), + ReadOnlyHint: false, + DestructiveHint: jsonschema.Ptr(true), }, InputSchema: &jsonschema.Schema{ Type: "object", Properties: map[string]*jsonschema.Schema{ - "owner": { + "method": { Type: "string", - Description: DescriptionRepositoryOwner, - }, - "repo": { - Type: "string", - Description: DescriptionRepositoryName, - }, - "run_id": { - Type: "number", - Description: "The unique identifier of the workflow run", + Description: "The method to execute", + Enum: []any{ + actionsMethodRunWorkflow, + actionsMethodRerunWorkflowRun, + actionsMethodRerunFailedJobs, + actionsMethodCancelWorkflowRun, + actionsMethodDeleteWorkflowRunLogs, + }, }, - }, - Required: []string{"owner", "repo", "run_id"}, - }, - }, - func(ctx context.Context, _ *mcp.CallToolRequest, args map[string]any) (*mcp.CallToolResult, any, error) { - owner, err := RequiredParam[string](args, "owner") - if err != nil { - return utils.NewToolResultError(err.Error()), nil, nil - } - repo, err := RequiredParam[string](args, "repo") - if err != nil { - return utils.NewToolResultError(err.Error()), nil, nil - } - runIDInt, err := RequiredInt(args, "run_id") - if err != nil { - return utils.NewToolResultError(err.Error()), nil, nil - } - runID := int64(runIDInt) - - client, err := getClient(ctx) - if err != nil { - return nil, nil, fmt.Errorf("failed to get GitHub client: %w", err) - } - - workflowRun, resp, err := client.Actions.GetWorkflowRunByID(ctx, owner, repo, runID) - if err != nil { - return nil, nil, fmt.Errorf("failed to get workflow run: %w", err) - } - defer func() { _ = resp.Body.Close() }() - - r, err := json.Marshal(workflowRun) - if err != nil { - return nil, nil, fmt.Errorf("failed to marshal response: %w", err) - } - - return utils.NewToolResultText(string(r)), nil, nil - } -} - -// GetWorkflowRunLogs creates a tool to download logs for a specific workflow run -func GetWorkflowRunLogs(getClient GetClientFn, t translations.TranslationHelperFunc) (mcp.Tool, mcp.ToolHandlerFor[map[string]any, any]) { - return mcp.Tool{ - Name: "get_workflow_run_logs", - Description: 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)"), - Annotations: &mcp.ToolAnnotations{ - Title: t("TOOL_GET_WORKFLOW_RUN_LOGS_USER_TITLE", "Get workflow run logs"), - ReadOnlyHint: true, - }, - InputSchema: &jsonschema.Schema{ - Type: "object", - Properties: map[string]*jsonschema.Schema{ "owner": { Type: "string", - Description: DescriptionRepositoryOwner, + Description: "Repository owner", }, "repo": { Type: "string", - Description: DescriptionRepositoryName, - }, - "run_id": { - Type: "number", - Description: "The unique identifier of the workflow run", + Description: "Repository name", }, - }, - Required: []string{"owner", "repo", "run_id"}, - }, - }, - func(ctx context.Context, _ *mcp.CallToolRequest, args map[string]any) (*mcp.CallToolResult, any, error) { - owner, err := RequiredParam[string](args, "owner") - if err != nil { - return utils.NewToolResultError(err.Error()), nil, nil - } - repo, err := RequiredParam[string](args, "repo") - if err != nil { - return utils.NewToolResultError(err.Error()), nil, nil - } - runIDInt, err := RequiredInt(args, "run_id") - if err != nil { - return utils.NewToolResultError(err.Error()), nil, nil - } - runID := int64(runIDInt) - - client, err := getClient(ctx) - if err != nil { - return nil, 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, 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, nil, fmt.Errorf("failed to marshal response: %w", err) - } - - return utils.NewToolResultText(string(r)), nil, nil - } -} - -// ListWorkflowJobs creates a tool to list jobs for a specific workflow run -func ListWorkflowJobs(getClient GetClientFn, t translations.TranslationHelperFunc) (mcp.Tool, mcp.ToolHandlerFor[map[string]any, any]) { - return mcp.Tool{ - Name: "list_workflow_jobs", - Description: t("TOOL_LIST_WORKFLOW_JOBS_DESCRIPTION", "List jobs for a specific workflow run"), - Annotations: &mcp.ToolAnnotations{ - Title: t("TOOL_LIST_WORKFLOW_JOBS_USER_TITLE", "List workflow jobs"), - ReadOnlyHint: true, - }, - InputSchema: WithPagination(&jsonschema.Schema{ - Type: "object", - Properties: map[string]*jsonschema.Schema{ - "owner": { + "workflow_id": { Type: "string", - Description: DescriptionRepositoryOwner, + Description: "The workflow ID (numeric) or workflow file name (e.g., main.yml, ci.yaml). Required for 'run_workflow' method.", }, - "repo": { + "ref": { Type: "string", - Description: DescriptionRepositoryName, + Description: "The git reference for the workflow. The reference can be a branch or tag name. Required for 'run_workflow' method.", + }, + "inputs": { + Type: "object", + Description: "Inputs the workflow accepts. Only used for 'run_workflow' method.", }, "run_id": { Type: "number", - Description: "The unique identifier of the workflow run", - }, - "filter": { - Type: "string", - Description: "Filters jobs by their completed_at timestamp", - Enum: []any{"latest", "all"}, + Description: "The ID of the workflow run. Required for all methods except 'run_workflow'.", }, }, - Required: []string{"owner", "repo", "run_id"}, - }), + Required: []string{"method", "owner", "repo"}, + }, }, func(ctx context.Context, _ *mcp.CallToolRequest, args map[string]any) (*mcp.CallToolResult, any, error) { owner, err := RequiredParam[string](args, "owner") @@ -523,22 +391,34 @@ func ListWorkflowJobs(getClient GetClientFn, t translations.TranslationHelperFun if err != nil { return utils.NewToolResultError(err.Error()), nil, nil } - runIDInt, err := RequiredInt(args, "run_id") + method, err := RequiredParam[string](args, "method") if err != nil { return utils.NewToolResultError(err.Error()), nil, nil } - runID := int64(runIDInt) - // Get optional filtering parameters - filter, err := OptionalParam[string](args, "filter") - if err != nil { - return utils.NewToolResultError(err.Error()), nil, nil + // Get optional parameters + workflowID, _ := OptionalParam[string](args, "workflow_id") + ref, _ := OptionalParam[string](args, "ref") + runID, _ := OptionalIntParam(args, "run_id") + + // Get optional inputs parameter + var inputs map[string]interface{} + if requestInputs, ok := args["inputs"]; ok { + if inputsMap, ok := requestInputs.(map[string]interface{}); ok { + inputs = inputsMap + } } - // Get optional pagination parameters - pagination, err := OptionalPaginationParams(args) - if err != nil { - return utils.NewToolResultError(err.Error()), nil, nil + // Validate required parameters based on action type + if method == actionsMethodRunWorkflow { + if workflowID == "" { + return utils.NewToolResultError("workflow_id is required for run_workflow action"), nil, nil + } + if ref == "" { + return utils.NewToolResultError("ref is required for run_workflow action"), nil, nil + } + } else if runID == 0 { + return utils.NewToolResultError("missing required parameter: run_id"), nil, nil } client, err := getClient(ctx) @@ -546,43 +426,33 @@ func ListWorkflowJobs(getClient GetClientFn, t translations.TranslationHelperFun return nil, 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, 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", + switch method { + case actionsMethodRunWorkflow: + return runWorkflow(ctx, client, args, owner, repo, workflowID, ref, inputs) + case actionsMethodRerunWorkflowRun: + return rerunWorkflowRun(ctx, client, args, owner, repo, int64(runID)) + case actionsMethodRerunFailedJobs: + return rerunFailedJobs(ctx, client, args, owner, repo, int64(runID)) + case actionsMethodCancelWorkflowRun: + return cancelWorkflowRun(ctx, client, args, owner, repo, int64(runID)) + case actionsMethodDeleteWorkflowRunLogs: + return deleteWorkflowRunLogs(ctx, client, args, owner, repo, int64(runID)) + default: + return utils.NewToolResultError(fmt.Sprintf("unknown method: %s", method)), nil, nil } - - r, err := json.Marshal(response) - if err != nil { - return nil, nil, fmt.Errorf("failed to marshal response: %w", err) - } - - return utils.NewToolResultText(string(r)), nil, 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) (mcp.Tool, mcp.ToolHandlerFor[map[string]any, any]) { +// ActionsGetJobLogs returns the tool and handler for getting workflow job logs. +func ActionsGetJobLogs(getClient GetClientFn, t translations.TranslationHelperFunc, contentWindowSize int) (mcp.Tool, mcp.ToolHandlerFor[map[string]any, any]) { return mcp.Tool{ - Name: "get_job_logs", - Description: t("TOOL_GET_JOB_LOGS_DESCRIPTION", "Download logs for a specific workflow job or efficiently get all failed job logs for a workflow run"), + Name: "get_job_logs", + Description: t("TOOL_GET_JOB_LOGS_DESCRIPTION", `Get logs for GitHub Actions workflow jobs. +Use this tool to retrieve logs for a specific job or all failed jobs in a workflow run. +For single job logs, provide job_id. For all failed jobs in a run, provide run_id with failed_only=true. +`), Annotations: &mcp.ToolAnnotations{ - Title: t("TOOL_GET_JOB_LOGS_USER_TITLE", "Get job logs"), + Title: t("TOOL_GET_JOB_LOGS_USER_TITLE", "Get GitHub Actions workflow job logs"), ReadOnlyHint: true, }, InputSchema: &jsonschema.Schema{ @@ -590,23 +460,23 @@ func GetJobLogs(getClient GetClientFn, t translations.TranslationHelperFunc, con Properties: map[string]*jsonschema.Schema{ "owner": { Type: "string", - Description: DescriptionRepositoryOwner, + Description: "Repository owner", }, "repo": { Type: "string", - Description: DescriptionRepositoryName, + Description: "Repository name", }, "job_id": { Type: "number", - Description: "The unique identifier of the workflow job (required for single job logs)", + Description: "The unique identifier of the workflow job. Required when getting logs for a single job.", }, "run_id": { Type: "number", - Description: "Workflow run ID (required when using failed_only)", + Description: "The unique identifier of the workflow run. Required when failed_only is true to get logs for all failed jobs in the run.", }, "failed_only": { Type: "boolean", - Description: "When true, gets logs for all failed jobs in run_id", + Description: "When true, gets logs for all failed jobs in the workflow run specified by run_id. Requires run_id to be provided.", }, "return_content": { Type: "boolean", @@ -631,23 +501,26 @@ func GetJobLogs(getClient GetClientFn, t translations.TranslationHelperFunc, con return utils.NewToolResultError(err.Error()), nil, nil } - // Get optional parameters jobID, err := OptionalIntParam(args, "job_id") if err != nil { return utils.NewToolResultError(err.Error()), nil, nil } + runID, err := OptionalIntParam(args, "run_id") if err != nil { return utils.NewToolResultError(err.Error()), nil, nil } + failedOnly, err := OptionalParam[bool](args, "failed_only") if err != nil { return utils.NewToolResultError(err.Error()), nil, nil } + returnContent, err := OptionalParam[bool](args, "return_content") if err != nil { return utils.NewToolResultError(err.Error()), nil, nil } + tailLines, err := OptionalIntParam(args, "tail_lines") if err != nil { return utils.NewToolResultError(err.Error()), nil, nil @@ -682,18 +555,393 @@ func GetJobLogs(getClient GetClientFn, t translations.TranslationHelperFunc, con } } -// handleFailedJobLogs gets logs for all failed jobs in a workflow run -func handleFailedJobLogs(ctx context.Context, client *github.Client, owner, repo string, runID int64, returnContent bool, tailLines int, contentWindowSize int) (*mcp.CallToolResult, any, error) { - // First, get all jobs for the workflow run - jobs, resp, err := client.Actions.ListWorkflowJobs(ctx, owner, repo, runID, &github.ListWorkflowJobsOptions{ - Filter: "latest", - }) +// Helper functions for consolidated actions tools + +func getWorkflow(ctx context.Context, client *github.Client, _ map[string]any, owner, repo, resourceID string) (*mcp.CallToolResult, any, error) { + var workflow *github.Workflow + var resp *github.Response + var err error + + if workflowIDInt, parseErr := strconv.ParseInt(resourceID, 10, 64); parseErr == nil { + workflow, resp, err = client.Actions.GetWorkflowByID(ctx, owner, repo, workflowIDInt) + } else { + workflow, resp, err = client.Actions.GetWorkflowByFileName(ctx, owner, repo, resourceID) + } + if err != nil { - return ghErrors.NewGitHubAPIErrorResponse(ctx, "failed to list workflow jobs", resp, err), nil, nil + return ghErrors.NewGitHubAPIErrorResponse(ctx, "failed to get workflow", resp, err), nil, nil } + defer func() { _ = resp.Body.Close() }() + r, err := json.Marshal(workflow) + if err != nil { + return nil, nil, fmt.Errorf("failed to marshal workflow: %w", err) + } - // Filter for failed jobs + return utils.NewToolResultText(string(r)), nil, nil +} + +func getWorkflowRun(ctx context.Context, client *github.Client, _ map[string]any, owner, repo string, resourceID int64) (*mcp.CallToolResult, any, error) { + workflowRun, resp, err := client.Actions.GetWorkflowRunByID(ctx, owner, repo, resourceID) + if err != nil { + return ghErrors.NewGitHubAPIErrorResponse(ctx, "failed to get workflow run", resp, err), nil, nil + } + defer func() { _ = resp.Body.Close() }() + r, err := json.Marshal(workflowRun) + if err != nil { + return nil, nil, fmt.Errorf("failed to marshal workflow run: %w", err) + } + return utils.NewToolResultText(string(r)), nil, nil +} + +func getWorkflowJob(ctx context.Context, client *github.Client, _ map[string]any, owner, repo string, resourceID int64) (*mcp.CallToolResult, any, error) { + workflowJob, resp, err := client.Actions.GetWorkflowJobByID(ctx, owner, repo, resourceID) + if err != nil { + return ghErrors.NewGitHubAPIErrorResponse(ctx, "failed to get workflow job", resp, err), nil, nil + } + defer func() { _ = resp.Body.Close() }() + r, err := json.Marshal(workflowJob) + if err != nil { + return nil, nil, fmt.Errorf("failed to marshal workflow job: %w", err) + } + return utils.NewToolResultText(string(r)), nil, nil +} + +func listWorkflows(ctx context.Context, client *github.Client, _ map[string]any, owner, repo string, pagination PaginationParams) (*mcp.CallToolResult, any, error) { + opts := &github.ListOptions{ + PerPage: pagination.PerPage, + Page: pagination.Page, + } + + workflows, resp, err := client.Actions.ListWorkflows(ctx, owner, repo, opts) + if err != nil { + return ghErrors.NewGitHubAPIErrorResponse(ctx, "failed to list workflows", resp, err), nil, nil + } + defer func() { _ = resp.Body.Close() }() + + r, err := json.Marshal(workflows) + if err != nil { + return nil, nil, fmt.Errorf("failed to marshal workflows: %w", err) + } + + return utils.NewToolResultText(string(r)), nil, nil +} + +func listWorkflowRuns(ctx context.Context, client *github.Client, args map[string]any, owner, repo, resourceID string, pagination PaginationParams) (*mcp.CallToolResult, any, error) { + filterArgs, err := OptionalParam[map[string]any](args, "workflow_runs_filter") + if err != nil { + return utils.NewToolResultError(err.Error()), nil, nil + } + + filterArgsTyped := make(map[string]string) + for k, v := range filterArgs { + if strVal, ok := v.(string); ok { + filterArgsTyped[k] = strVal + } else { + filterArgsTyped[k] = "" + } + } + + listWorkflowRunsOptions := &github.ListWorkflowRunsOptions{ + Actor: filterArgsTyped["actor"], + Branch: filterArgsTyped["branch"], + Event: filterArgsTyped["event"], + Status: filterArgsTyped["status"], + ListOptions: github.ListOptions{ + Page: pagination.Page, + PerPage: pagination.PerPage, + }, + } + + var workflowRuns *github.WorkflowRuns + var resp *github.Response + + if workflowIDInt, parseErr := strconv.ParseInt(resourceID, 10, 64); parseErr == nil { + workflowRuns, resp, err = client.Actions.ListWorkflowRunsByID(ctx, owner, repo, workflowIDInt, listWorkflowRunsOptions) + } else { + workflowRuns, resp, err = client.Actions.ListWorkflowRunsByFileName(ctx, owner, repo, resourceID, listWorkflowRunsOptions) + } + + if err != nil { + return ghErrors.NewGitHubAPIErrorResponse(ctx, "failed to list workflow runs", resp, err), nil, nil + } + + defer func() { _ = resp.Body.Close() }() + r, err := json.Marshal(workflowRuns) + if err != nil { + return nil, nil, fmt.Errorf("failed to marshal workflow runs: %w", err) + } + + return utils.NewToolResultText(string(r)), nil, nil +} + +func listWorkflowJobs(ctx context.Context, client *github.Client, args map[string]any, owner, repo string, resourceID int64, pagination PaginationParams) (*mcp.CallToolResult, any, error) { + filterArgs, err := OptionalParam[map[string]any](args, "workflow_jobs_filter") + if err != nil { + return utils.NewToolResultError(err.Error()), nil, nil + } + + filterArgsTyped := make(map[string]string) + for k, v := range filterArgs { + if strVal, ok := v.(string); ok { + filterArgsTyped[k] = strVal + } else { + filterArgsTyped[k] = "" + } + } + + workflowJobs, resp, err := client.Actions.ListWorkflowJobs(ctx, owner, repo, resourceID, &github.ListWorkflowJobsOptions{ + Filter: filterArgsTyped["filter"], + ListOptions: github.ListOptions{ + Page: pagination.Page, + PerPage: pagination.PerPage, + }, + }) + if err != nil { + return ghErrors.NewGitHubAPIErrorResponse(ctx, "failed to list workflow jobs", resp, err), nil, nil + } + + response := map[string]any{ + "jobs": workflowJobs, + } + + defer func() { _ = resp.Body.Close() }() + r, err := json.Marshal(response) + if err != nil { + return nil, nil, fmt.Errorf("failed to marshal workflow jobs: %w", err) + } + + return utils.NewToolResultText(string(r)), nil, nil +} + +func listWorkflowArtifacts(ctx context.Context, client *github.Client, _ map[string]any, owner, repo string, resourceID int64, pagination PaginationParams) (*mcp.CallToolResult, any, error) { + opts := &github.ListOptions{ + PerPage: pagination.PerPage, + Page: pagination.Page, + } + + artifacts, resp, err := client.Actions.ListWorkflowRunArtifacts(ctx, owner, repo, resourceID, opts) + if err != nil { + return ghErrors.NewGitHubAPIErrorResponse(ctx, "failed to list workflow run artifacts", resp, err), nil, nil + } + defer func() { _ = resp.Body.Close() }() + + r, err := json.Marshal(artifacts) + if err != nil { + return nil, nil, fmt.Errorf("failed to marshal response: %w", err) + } + + return utils.NewToolResultText(string(r)), nil, nil +} + +func downloadWorkflowArtifact(ctx context.Context, client *github.Client, _ map[string]any, owner, repo string, resourceID int64) (*mcp.CallToolResult, any, error) { + // Get the download URL for the artifact + url, resp, err := client.Actions.DownloadArtifact(ctx, owner, repo, resourceID, 1) + if err != nil { + return ghErrors.NewGitHubAPIErrorResponse(ctx, "failed to get artifact download URL", resp, err), nil, nil + } + defer func() { _ = resp.Body.Close() }() + + // Create response with the download URL and information + result := map[string]any{ + "download_url": url.String(), + "message": "Artifact is available for download", + "note": "The download_url provides a download link for the artifact as a ZIP archive. The link is temporary and expires after a short time.", + "artifact_id": resourceID, + } + + r, err := json.Marshal(result) + if err != nil { + return nil, nil, fmt.Errorf("failed to marshal response: %w", err) + } + + return utils.NewToolResultText(string(r)), nil, nil +} + +func getWorkflowRunLogs(ctx context.Context, client *github.Client, _ map[string]any, owner, repo string, runID int64) (*mcp.CallToolResult, any, error) { + // Get the download URL for the logs + url, resp, err := client.Actions.GetWorkflowRunLogs(ctx, owner, repo, runID, 1) + if err != nil { + return ghErrors.NewGitHubAPIErrorResponse(ctx, "failed to get workflow run logs", resp, err), nil, nil + } + defer func() { _ = resp.Body.Close() }() + + // Create response with the logs URL and information + result := map[string]any{ + "logs_url": url.String(), + "message": "Workflow run logs are available for download", + "note": "The logs_url provides a download link for the complete workflow run logs as a ZIP archive. You can download this archive to extract and examine individual job logs.", + "warning": "This downloads ALL logs as a ZIP file which can be large and expensive. For debugging failed jobs, consider using get_job_logs with failed_only=true and run_id instead.", + "optimization_tip": "Use: get_job_logs with parameters {run_id: " + fmt.Sprintf("%d", runID) + ", failed_only: true} for more efficient failed job debugging", + } + + r, err := json.Marshal(result) + if err != nil { + return nil, nil, fmt.Errorf("failed to marshal response: %w", err) + } + + return utils.NewToolResultText(string(r)), nil, nil +} + +func getWorkflowRunUsage(ctx context.Context, client *github.Client, _ map[string]any, owner, repo string, resourceID int64) (*mcp.CallToolResult, any, error) { + usage, resp, err := client.Actions.GetWorkflowRunUsageByID(ctx, owner, repo, resourceID) + if err != nil { + return ghErrors.NewGitHubAPIErrorResponse(ctx, "failed to get workflow run usage", resp, err), nil, nil + } + defer func() { _ = resp.Body.Close() }() + + r, err := json.Marshal(usage) + if err != nil { + return nil, nil, fmt.Errorf("failed to marshal response: %w", err) + } + + return utils.NewToolResultText(string(r)), nil, nil +} + +func runWorkflow(ctx context.Context, client *github.Client, _ map[string]any, owner, repo, workflowID, ref string, inputs map[string]interface{}) (*mcp.CallToolResult, any, error) { + event := github.CreateWorkflowDispatchEventRequest{ + Ref: ref, + Inputs: inputs, + } + + var resp *github.Response + var err error + var workflowType string + + if workflowIDInt, parseErr := strconv.ParseInt(workflowID, 10, 64); parseErr == nil { + resp, err = client.Actions.CreateWorkflowDispatchEventByID(ctx, owner, repo, workflowIDInt, event) + workflowType = "workflow_id" + } else { + resp, err = client.Actions.CreateWorkflowDispatchEventByFileName(ctx, owner, repo, workflowID, event) + workflowType = "workflow_file" + } + + if err != nil { + return ghErrors.NewGitHubAPIErrorResponse(ctx, "failed to run workflow", resp, err), nil, nil + } + defer func() { _ = resp.Body.Close() }() + + result := map[string]any{ + "message": "Workflow run has been queued", + "workflow_type": workflowType, + "workflow_id": workflowID, + "ref": ref, + "inputs": inputs, + "status": resp.Status, + "status_code": resp.StatusCode, + } + + r, err := json.Marshal(result) + if err != nil { + return nil, nil, fmt.Errorf("failed to marshal response: %w", err) + } + + return utils.NewToolResultText(string(r)), nil, nil +} + +func rerunWorkflowRun(ctx context.Context, client *github.Client, _ map[string]any, owner, repo string, runID int64) (*mcp.CallToolResult, any, error) { + resp, err := client.Actions.RerunWorkflowByID(ctx, owner, repo, runID) + if err != nil { + return ghErrors.NewGitHubAPIErrorResponse(ctx, "failed to rerun workflow run", resp, err), nil, nil + } + defer func() { _ = resp.Body.Close() }() + + result := map[string]any{ + "message": "Workflow run has been queued for re-run", + "run_id": runID, + "status": resp.Status, + "status_code": resp.StatusCode, + } + + r, err := json.Marshal(result) + if err != nil { + return nil, nil, fmt.Errorf("failed to marshal response: %w", err) + } + + return utils.NewToolResultText(string(r)), nil, nil +} + +func rerunFailedJobs(ctx context.Context, client *github.Client, _ map[string]any, owner, repo string, runID int64) (*mcp.CallToolResult, any, error) { + resp, err := client.Actions.RerunFailedJobsByID(ctx, owner, repo, runID) + if err != nil { + return ghErrors.NewGitHubAPIErrorResponse(ctx, "failed to rerun failed jobs", resp, err), nil, nil + } + defer func() { _ = resp.Body.Close() }() + + result := map[string]any{ + "message": "Failed jobs have been queued for re-run", + "run_id": runID, + "status": resp.Status, + "status_code": resp.StatusCode, + } + + r, err := json.Marshal(result) + if err != nil { + return nil, nil, fmt.Errorf("failed to marshal response: %w", err) + } + + return utils.NewToolResultText(string(r)), nil, nil +} + +func cancelWorkflowRun(ctx context.Context, client *github.Client, _ map[string]any, owner, repo string, runID int64) (*mcp.CallToolResult, any, error) { + resp, err := client.Actions.CancelWorkflowRunByID(ctx, owner, repo, runID) + if err != nil { + var acceptedErr *github.AcceptedError + if !errors.As(err, &acceptedErr) { + return ghErrors.NewGitHubAPIErrorResponse(ctx, "failed to cancel workflow run", resp, err), nil, nil + } + } + defer func() { _ = resp.Body.Close() }() + + result := map[string]any{ + "message": "Workflow run has been cancelled", + "run_id": runID, + "status": resp.Status, + "status_code": resp.StatusCode, + } + + r, err := json.Marshal(result) + if err != nil { + return nil, nil, fmt.Errorf("failed to marshal response: %w", err) + } + + return utils.NewToolResultText(string(r)), nil, nil +} + +func deleteWorkflowRunLogs(ctx context.Context, client *github.Client, _ map[string]any, owner, repo string, runID int64) (*mcp.CallToolResult, any, error) { + resp, err := client.Actions.DeleteWorkflowRunLogs(ctx, owner, repo, runID) + if err != nil { + return ghErrors.NewGitHubAPIErrorResponse(ctx, "failed to delete workflow run logs", resp, err), nil, nil + } + defer func() { _ = resp.Body.Close() }() + + result := map[string]any{ + "message": "Workflow run logs have been deleted", + "run_id": runID, + "status": resp.Status, + "status_code": resp.StatusCode, + } + + r, err := json.Marshal(result) + if err != nil { + return nil, nil, fmt.Errorf("failed to marshal response: %w", err) + } + + return utils.NewToolResultText(string(r)), nil, nil +} + +// handleFailedJobLogs gets logs for all failed jobs in a workflow run +func handleFailedJobLogs(ctx context.Context, client *github.Client, owner, repo string, runID int64, returnContent bool, tailLines, contentWindowSize int) (*mcp.CallToolResult, any, error) { + // First, get all jobs for the workflow run + jobs, resp, err := client.Actions.ListWorkflowJobs(ctx, owner, repo, runID, &github.ListWorkflowJobsOptions{ + Filter: "latest", + }) + if err != nil { + return ghErrors.NewGitHubAPIErrorResponse(ctx, "failed to list workflow jobs", resp, err), nil, nil + } + defer func() { _ = resp.Body.Close() }() + + // Filter for failed jobs var failedJobs []*github.WorkflowJob for _, job := range jobs.Jobs { if job.GetConclusion() == "failure" { @@ -748,7 +996,7 @@ func handleFailedJobLogs(ctx context.Context, client *github.Client, owner, repo } // handleSingleJobLogs gets logs for a single job -func handleSingleJobLogs(ctx context.Context, client *github.Client, owner, repo string, jobID int64, returnContent bool, tailLines int, contentWindowSize int) (*mcp.CallToolResult, any, error) { +func handleSingleJobLogs(ctx context.Context, client *github.Client, owner, repo string, jobID int64, returnContent bool, tailLines, contentWindowSize int) (*mcp.CallToolResult, any, error) { jobResult, resp, err := getJobLogData(ctx, client, owner, repo, jobID, "", returnContent, tailLines, contentWindowSize) if err != nil { return ghErrors.NewGitHubAPIErrorResponse(ctx, "failed to get job logs", resp, err), nil, nil @@ -763,7 +1011,7 @@ func handleSingleJobLogs(ctx context.Context, client *github.Client, owner, repo } // getJobLogData retrieves log data for a single job, either as URL or content -func getJobLogData(ctx context.Context, client *github.Client, owner, repo string, jobID int64, jobName string, returnContent bool, tailLines int, contentWindowSize int) (map[string]any, *github.Response, error) { +func getJobLogData(ctx context.Context, client *github.Client, owner, repo string, jobID int64, jobName string, returnContent bool, tailLines, contentWindowSize int) (map[string]any, *github.Response, error) { // Get the download URL for the job logs url, resp, err := client.Actions.GetWorkflowJobLogs(ctx, owner, repo, jobID, 1) if err != nil { @@ -801,7 +1049,7 @@ func getJobLogData(ctx context.Context, client *github.Client, owner, repo strin return result, resp, nil } -func downloadLogContent(ctx context.Context, logURL string, tailLines int, maxLines int) (string, int, *http.Response, error) { +func downloadLogContent(ctx context.Context, logURL string, tailLines, maxLines int) (string, int, *http.Response, error) { prof := profiler.New(nil, profiler.IsProfilingEnabled()) finish := prof.Start(ctx, "log_buffer_processing") @@ -835,496 +1083,3 @@ func downloadLogContent(ctx context.Context, logURL string, tailLines int, maxLi return finalResult, totalLines, httpResp, nil } - -// RerunWorkflowRun creates a tool to re-run an entire workflow run -func RerunWorkflowRun(getClient GetClientFn, t translations.TranslationHelperFunc) (mcp.Tool, mcp.ToolHandlerFor[map[string]any, any]) { - return mcp.Tool{ - Name: "rerun_workflow_run", - Description: t("TOOL_RERUN_WORKFLOW_RUN_DESCRIPTION", "Re-run an entire workflow run"), - Annotations: &mcp.ToolAnnotations{ - Title: t("TOOL_RERUN_WORKFLOW_RUN_USER_TITLE", "Rerun workflow run"), - ReadOnlyHint: false, - }, - InputSchema: &jsonschema.Schema{ - Type: "object", - Properties: map[string]*jsonschema.Schema{ - "owner": { - Type: "string", - Description: DescriptionRepositoryOwner, - }, - "repo": { - Type: "string", - Description: DescriptionRepositoryName, - }, - "run_id": { - Type: "number", - Description: "The unique identifier of the workflow run", - }, - }, - Required: []string{"owner", "repo", "run_id"}, - }, - }, - func(ctx context.Context, _ *mcp.CallToolRequest, args map[string]any) (*mcp.CallToolResult, any, error) { - owner, err := RequiredParam[string](args, "owner") - if err != nil { - return utils.NewToolResultError(err.Error()), nil, nil - } - repo, err := RequiredParam[string](args, "repo") - if err != nil { - return utils.NewToolResultError(err.Error()), nil, nil - } - runIDInt, err := RequiredInt(args, "run_id") - if err != nil { - return utils.NewToolResultError(err.Error()), nil, nil - } - runID := int64(runIDInt) - - client, err := getClient(ctx) - if err != nil { - return nil, 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, nil - } - defer func() { _ = resp.Body.Close() }() - - result := map[string]any{ - "message": "Workflow run has been queued for re-run", - "run_id": runID, - "status": resp.Status, - "status_code": resp.StatusCode, - } - - r, err := json.Marshal(result) - if err != nil { - return nil, nil, fmt.Errorf("failed to marshal response: %w", err) - } - - return utils.NewToolResultText(string(r)), nil, nil - } -} - -// RerunFailedJobs creates a tool to re-run only the failed jobs in a workflow run -func RerunFailedJobs(getClient GetClientFn, t translations.TranslationHelperFunc) (mcp.Tool, mcp.ToolHandlerFor[map[string]any, any]) { - return mcp.Tool{ - Name: "rerun_failed_jobs", - Description: t("TOOL_RERUN_FAILED_JOBS_DESCRIPTION", "Re-run only the failed jobs in a workflow run"), - Annotations: &mcp.ToolAnnotations{ - Title: t("TOOL_RERUN_FAILED_JOBS_USER_TITLE", "Rerun failed jobs"), - ReadOnlyHint: false, - }, - InputSchema: &jsonschema.Schema{ - Type: "object", - Properties: map[string]*jsonschema.Schema{ - "owner": { - Type: "string", - Description: DescriptionRepositoryOwner, - }, - "repo": { - Type: "string", - Description: DescriptionRepositoryName, - }, - "run_id": { - Type: "number", - Description: "The unique identifier of the workflow run", - }, - }, - Required: []string{"owner", "repo", "run_id"}, - }, - }, - func(ctx context.Context, _ *mcp.CallToolRequest, args map[string]any) (*mcp.CallToolResult, any, error) { - owner, err := RequiredParam[string](args, "owner") - if err != nil { - return utils.NewToolResultError(err.Error()), nil, nil - } - repo, err := RequiredParam[string](args, "repo") - if err != nil { - return utils.NewToolResultError(err.Error()), nil, nil - } - runIDInt, err := RequiredInt(args, "run_id") - if err != nil { - return utils.NewToolResultError(err.Error()), nil, nil - } - runID := int64(runIDInt) - - client, err := getClient(ctx) - if err != nil { - return nil, 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, nil - } - defer func() { _ = resp.Body.Close() }() - - result := map[string]any{ - "message": "Failed jobs have been queued for re-run", - "run_id": runID, - "status": resp.Status, - "status_code": resp.StatusCode, - } - - r, err := json.Marshal(result) - if err != nil { - return nil, nil, fmt.Errorf("failed to marshal response: %w", err) - } - - return utils.NewToolResultText(string(r)), nil, nil - } -} - -// CancelWorkflowRun creates a tool to cancel a workflow run -func CancelWorkflowRun(getClient GetClientFn, t translations.TranslationHelperFunc) (mcp.Tool, mcp.ToolHandlerFor[map[string]any, any]) { - return mcp.Tool{ - Name: "cancel_workflow_run", - Description: t("TOOL_CANCEL_WORKFLOW_RUN_DESCRIPTION", "Cancel a workflow run"), - Annotations: &mcp.ToolAnnotations{ - Title: t("TOOL_CANCEL_WORKFLOW_RUN_USER_TITLE", "Cancel workflow run"), - ReadOnlyHint: false, - }, - InputSchema: &jsonschema.Schema{ - Type: "object", - Properties: map[string]*jsonschema.Schema{ - "owner": { - Type: "string", - Description: DescriptionRepositoryOwner, - }, - "repo": { - Type: "string", - Description: DescriptionRepositoryName, - }, - "run_id": { - Type: "number", - Description: "The unique identifier of the workflow run", - }, - }, - Required: []string{"owner", "repo", "run_id"}, - }, - }, - func(ctx context.Context, _ *mcp.CallToolRequest, args map[string]any) (*mcp.CallToolResult, any, error) { - owner, err := RequiredParam[string](args, "owner") - if err != nil { - return utils.NewToolResultError(err.Error()), nil, nil - } - repo, err := RequiredParam[string](args, "repo") - if err != nil { - return utils.NewToolResultError(err.Error()), nil, nil - } - runIDInt, err := RequiredInt(args, "run_id") - if err != nil { - return utils.NewToolResultError(err.Error()), nil, nil - } - runID := int64(runIDInt) - - client, err := getClient(ctx) - if err != nil { - return nil, 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, nil - } - } - defer func() { _ = resp.Body.Close() }() - - result := map[string]any{ - "message": "Workflow run has been cancelled", - "run_id": runID, - "status": resp.Status, - "status_code": resp.StatusCode, - } - - r, err := json.Marshal(result) - if err != nil { - return nil, nil, fmt.Errorf("failed to marshal response: %w", err) - } - - return utils.NewToolResultText(string(r)), nil, nil - } -} - -// ListWorkflowRunArtifacts creates a tool to list artifacts for a workflow run -func ListWorkflowRunArtifacts(getClient GetClientFn, t translations.TranslationHelperFunc) (mcp.Tool, mcp.ToolHandlerFor[map[string]any, any]) { - return mcp.Tool{ - Name: "list_workflow_run_artifacts", - Description: t("TOOL_LIST_WORKFLOW_RUN_ARTIFACTS_DESCRIPTION", "List artifacts for a workflow run"), - Annotations: &mcp.ToolAnnotations{ - Title: t("TOOL_LIST_WORKFLOW_RUN_ARTIFACTS_USER_TITLE", "List workflow artifacts"), - ReadOnlyHint: true, - }, - InputSchema: WithPagination(&jsonschema.Schema{ - Type: "object", - Properties: map[string]*jsonschema.Schema{ - "owner": { - Type: "string", - Description: DescriptionRepositoryOwner, - }, - "repo": { - Type: "string", - Description: DescriptionRepositoryName, - }, - "run_id": { - Type: "number", - Description: "The unique identifier of the workflow run", - }, - }, - Required: []string{"owner", "repo", "run_id"}, - }), - }, - func(ctx context.Context, _ *mcp.CallToolRequest, args map[string]any) (*mcp.CallToolResult, any, error) { - owner, err := RequiredParam[string](args, "owner") - if err != nil { - return utils.NewToolResultError(err.Error()), nil, nil - } - repo, err := RequiredParam[string](args, "repo") - if err != nil { - return utils.NewToolResultError(err.Error()), nil, nil - } - runIDInt, err := RequiredInt(args, "run_id") - if err != nil { - return utils.NewToolResultError(err.Error()), nil, nil - } - runID := int64(runIDInt) - - // Get optional pagination parameters - pagination, err := OptionalPaginationParams(args) - if err != nil { - return utils.NewToolResultError(err.Error()), nil, nil - } - - client, err := getClient(ctx) - if err != nil { - return nil, 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, nil - } - defer func() { _ = resp.Body.Close() }() - - r, err := json.Marshal(artifacts) - if err != nil { - return nil, nil, fmt.Errorf("failed to marshal response: %w", err) - } - - return utils.NewToolResultText(string(r)), nil, nil - } -} - -// DownloadWorkflowRunArtifact creates a tool to download a workflow run artifact -func DownloadWorkflowRunArtifact(getClient GetClientFn, t translations.TranslationHelperFunc) (mcp.Tool, mcp.ToolHandlerFor[map[string]any, any]) { - return mcp.Tool{ - Name: "download_workflow_run_artifact", - Description: t("TOOL_DOWNLOAD_WORKFLOW_RUN_ARTIFACT_DESCRIPTION", "Get download URL for a workflow run artifact"), - Annotations: &mcp.ToolAnnotations{ - Title: t("TOOL_DOWNLOAD_WORKFLOW_RUN_ARTIFACT_USER_TITLE", "Download workflow artifact"), - ReadOnlyHint: true, - }, - InputSchema: &jsonschema.Schema{ - Type: "object", - Properties: map[string]*jsonschema.Schema{ - "owner": { - Type: "string", - Description: DescriptionRepositoryOwner, - }, - "repo": { - Type: "string", - Description: DescriptionRepositoryName, - }, - "artifact_id": { - Type: "number", - Description: "The unique identifier of the artifact", - }, - }, - Required: []string{"owner", "repo", "artifact_id"}, - }, - }, - func(ctx context.Context, _ *mcp.CallToolRequest, args map[string]any) (*mcp.CallToolResult, any, error) { - owner, err := RequiredParam[string](args, "owner") - if err != nil { - return utils.NewToolResultError(err.Error()), nil, nil - } - repo, err := RequiredParam[string](args, "repo") - if err != nil { - return utils.NewToolResultError(err.Error()), nil, nil - } - artifactIDInt, err := RequiredInt(args, "artifact_id") - if err != nil { - return utils.NewToolResultError(err.Error()), nil, nil - } - artifactID := int64(artifactIDInt) - - client, err := getClient(ctx) - if err != nil { - return nil, 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, 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": artifactID, - } - - r, err := json.Marshal(result) - if err != nil { - return nil, nil, fmt.Errorf("failed to marshal response: %w", err) - } - - return utils.NewToolResultText(string(r)), nil, nil - } -} - -// DeleteWorkflowRunLogs creates a tool to delete logs for a workflow run -func DeleteWorkflowRunLogs(getClient GetClientFn, t translations.TranslationHelperFunc) (mcp.Tool, mcp.ToolHandlerFor[map[string]any, any]) { - return mcp.Tool{ - Name: "delete_workflow_run_logs", - Description: t("TOOL_DELETE_WORKFLOW_RUN_LOGS_DESCRIPTION", "Delete logs for a workflow run"), - Annotations: &mcp.ToolAnnotations{ - Title: t("TOOL_DELETE_WORKFLOW_RUN_LOGS_USER_TITLE", "Delete workflow logs"), - ReadOnlyHint: false, - DestructiveHint: jsonschema.Ptr(true), - }, - InputSchema: &jsonschema.Schema{ - Type: "object", - Properties: map[string]*jsonschema.Schema{ - "owner": { - Type: "string", - Description: DescriptionRepositoryOwner, - }, - "repo": { - Type: "string", - Description: DescriptionRepositoryName, - }, - "run_id": { - Type: "number", - Description: "The unique identifier of the workflow run", - }, - }, - Required: []string{"owner", "repo", "run_id"}, - }, - }, - func(ctx context.Context, _ *mcp.CallToolRequest, args map[string]any) (*mcp.CallToolResult, any, error) { - owner, err := RequiredParam[string](args, "owner") - if err != nil { - return utils.NewToolResultError(err.Error()), nil, nil - } - repo, err := RequiredParam[string](args, "repo") - if err != nil { - return utils.NewToolResultError(err.Error()), nil, nil - } - runIDInt, err := RequiredInt(args, "run_id") - if err != nil { - return utils.NewToolResultError(err.Error()), nil, nil - } - runID := int64(runIDInt) - - client, err := getClient(ctx) - if err != nil { - return nil, nil, fmt.Errorf("failed to get GitHub client: %w", err) - } - - resp, err := client.Actions.DeleteWorkflowRunLogs(ctx, owner, repo, runID) - if err != nil { - return ghErrors.NewGitHubAPIErrorResponse(ctx, "failed to delete workflow run logs", resp, err), nil, nil - } - defer func() { _ = resp.Body.Close() }() - - result := map[string]any{ - "message": "Workflow run logs have been deleted", - "run_id": runID, - "status": resp.Status, - "status_code": resp.StatusCode, - } - - r, err := json.Marshal(result) - if err != nil { - return nil, nil, fmt.Errorf("failed to marshal response: %w", err) - } - - return utils.NewToolResultText(string(r)), nil, nil - } -} - -// GetWorkflowRunUsage creates a tool to get usage metrics for a workflow run -func GetWorkflowRunUsage(getClient GetClientFn, t translations.TranslationHelperFunc) (mcp.Tool, mcp.ToolHandlerFor[map[string]any, any]) { - return mcp.Tool{ - Name: "get_workflow_run_usage", - Description: t("TOOL_GET_WORKFLOW_RUN_USAGE_DESCRIPTION", "Get usage metrics for a workflow run"), - Annotations: &mcp.ToolAnnotations{ - Title: t("TOOL_GET_WORKFLOW_RUN_USAGE_USER_TITLE", "Get workflow usage"), - ReadOnlyHint: true, - }, - InputSchema: &jsonschema.Schema{ - Type: "object", - Properties: map[string]*jsonschema.Schema{ - "owner": { - Type: "string", - Description: DescriptionRepositoryOwner, - }, - "repo": { - Type: "string", - Description: DescriptionRepositoryName, - }, - "run_id": { - Type: "number", - Description: "The unique identifier of the workflow run", - }, - }, - Required: []string{"owner", "repo", "run_id"}, - }, - }, - func(ctx context.Context, _ *mcp.CallToolRequest, args map[string]any) (*mcp.CallToolResult, any, error) { - owner, err := RequiredParam[string](args, "owner") - if err != nil { - return utils.NewToolResultError(err.Error()), nil, nil - } - repo, err := RequiredParam[string](args, "repo") - if err != nil { - return utils.NewToolResultError(err.Error()), nil, nil - } - runIDInt, err := RequiredInt(args, "run_id") - if err != nil { - return utils.NewToolResultError(err.Error()), nil, nil - } - runID := int64(runIDInt) - - client, err := getClient(ctx) - if err != nil { - return nil, 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, nil - } - defer func() { _ = resp.Body.Close() }() - - r, err := json.Marshal(usage) - if err != nil { - return nil, nil, fmt.Errorf("failed to marshal response: %w", err) - } - - return utils.NewToolResultText(string(r)), nil, nil - } -} diff --git a/pkg/github/actions_test.go b/pkg/github/actions_test.go index 6d9921f2e..397dfc81d 100644 --- a/pkg/github/actions_test.go +++ b/pkg/github/actions_test.go @@ -23,21 +23,25 @@ import ( "github.com/stretchr/testify/require" ) -func Test_ListWorkflows(t *testing.T) { +func Test_ActionsList(t *testing.T) { // Verify tool definition once mockClient := github.NewClient(nil) - tool, _ := ListWorkflows(stubGetClientFn(mockClient), translations.NullTranslationHelper) + tool, _ := ActionsList(stubGetClientFn(mockClient), translations.NullTranslationHelper) require.NoError(t, toolsnaps.Test(tool.Name, tool)) - assert.Equal(t, "list_workflows", tool.Name) + assert.Equal(t, "actions_list", tool.Name) assert.NotEmpty(t, tool.Description) inputSchema := tool.InputSchema.(*jsonschema.Schema) + assert.Contains(t, inputSchema.Properties, "method") assert.Contains(t, inputSchema.Properties, "owner") assert.Contains(t, inputSchema.Properties, "repo") - assert.Contains(t, inputSchema.Properties, "perPage") - assert.Contains(t, inputSchema.Properties, "page") - assert.ElementsMatch(t, inputSchema.Required, []string{"owner", "repo"}) + assert.Contains(t, inputSchema.Properties, "resource_id") + assert.Contains(t, inputSchema.Properties, "workflow_runs_filter") + assert.Contains(t, inputSchema.Properties, "workflow_jobs_filter") + assert.ElementsMatch(t, inputSchema.Required, []string{"method", "owner", "repo"}) +} +func Test_ActionsList_ListWorkflows(t *testing.T) { tests := []struct { name string mockedClient *http.Client @@ -86,8 +90,9 @@ func Test_ListWorkflows(t *testing.T) { ), ), requestArgs: map[string]any{ - "owner": "owner", - "repo": "repo", + "method": "list_workflows", + "owner": "owner", + "repo": "repo", }, expectError: false, }, @@ -95,7 +100,8 @@ func Test_ListWorkflows(t *testing.T) { name: "missing required parameter owner", mockedClient: mock.NewMockedHTTPClient(), requestArgs: map[string]any{ - "repo": "repo", + "method": "list_workflows", + "repo": "repo", }, expectError: true, expectedErrMsg: "missing required parameter: owner", @@ -104,20 +110,14 @@ func Test_ListWorkflows(t *testing.T) { for _, tc := range tests { t.Run(tc.name, func(t *testing.T) { - // Setup client with mock client := github.NewClient(tc.mockedClient) - _, handler := ListWorkflows(stubGetClientFn(client), translations.NullTranslationHelper) - - // Create call request + _, handler := ActionsList(stubGetClientFn(client), translations.NullTranslationHelper) request := createMCPRequest(tc.requestArgs) - - // Call handler result, _, err := handler(context.Background(), &request, tc.requestArgs) 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 != "" { @@ -125,7 +125,6 @@ func Test_ListWorkflows(t *testing.T) { return } - // Unmarshal and verify the result var response github.Workflows err = json.Unmarshal([]byte(textContent.Text), &response) require.NoError(t, err) @@ -136,21 +135,7 @@ func Test_ListWorkflows(t *testing.T) { } } -func Test_RunWorkflow(t *testing.T) { - // Verify tool definition once - mockClient := github.NewClient(nil) - tool, _ := RunWorkflow(stubGetClientFn(mockClient), translations.NullTranslationHelper) - require.NoError(t, toolsnaps.Test(tool.Name, tool)) - - assert.Equal(t, "run_workflow", tool.Name) - assert.NotEmpty(t, tool.Description) - assert.Contains(t, tool.InputSchema.(*jsonschema.Schema).Properties, "owner") - assert.Contains(t, tool.InputSchema.(*jsonschema.Schema).Properties, "repo") - assert.Contains(t, tool.InputSchema.(*jsonschema.Schema).Properties, "workflow_id") - assert.Contains(t, tool.InputSchema.(*jsonschema.Schema).Properties, "ref") - assert.Contains(t, tool.InputSchema.(*jsonschema.Schema).Properties, "inputs") - assert.ElementsMatch(t, tool.InputSchema.(*jsonschema.Schema).Required, []string{"owner", "repo", "workflow_id", "ref"}) - +func Test_ActionsList_ListWorkflowRuns(t *testing.T) { tests := []struct { name string mockedClient *http.Client @@ -159,52 +144,57 @@ func Test_RunWorkflow(t *testing.T) { expectedErrMsg string }{ { - name: "successful workflow run", + name: "successful workflow runs listing", mockedClient: mock.NewMockedHTTPClient( mock.WithRequestMatchHandler( - mock.PostReposActionsWorkflowsDispatchesByOwnerByRepoByWorkflowId, + mock.GetReposActionsWorkflowsRunsByOwnerByRepoByWorkflowId, http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) { - w.WriteHeader(http.StatusNoContent) + workflowRuns := &github.WorkflowRuns{ + TotalCount: github.Ptr(1), + WorkflowRuns: []*github.WorkflowRun{ + { + ID: github.Ptr(int64(123)), + Name: github.Ptr("CI"), + Status: github.Ptr("completed"), + }, + }, + } + w.WriteHeader(http.StatusOK) + _ = json.NewEncoder(w).Encode(workflowRuns) }), ), ), requestArgs: map[string]any{ + "method": "list_workflow_runs", "owner": "owner", "repo": "repo", - "workflow_id": "12345", - "ref": "main", + "resource_id": "ci.yml", }, expectError: false, }, { - name: "missing required parameter workflow_id", + name: "missing resource_id for list_workflow_runs", mockedClient: mock.NewMockedHTTPClient(), requestArgs: map[string]any{ - "owner": "owner", - "repo": "repo", - "ref": "main", + "method": "list_workflow_runs", + "owner": "owner", + "repo": "repo", }, expectError: true, - expectedErrMsg: "missing required parameter: workflow_id", + expectedErrMsg: "missing required parameter for method list_workflow_runs: resource_id", }, } for _, tc := range tests { t.Run(tc.name, func(t *testing.T) { - // Setup client with mock client := github.NewClient(tc.mockedClient) - _, handler := RunWorkflow(stubGetClientFn(client), translations.NullTranslationHelper) - - // Create call request + _, handler := ActionsList(stubGetClientFn(client), translations.NullTranslationHelper) request := createMCPRequest(tc.requestArgs) - - // Call handler result, _, err := handler(context.Background(), &request, tc.requestArgs) 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 != "" { @@ -212,18 +202,15 @@ func Test_RunWorkflow(t *testing.T) { return } - // Unmarshal and verify the result - var response map[string]any + var response github.WorkflowRuns err = json.Unmarshal([]byte(textContent.Text), &response) require.NoError(t, err) - assert.Equal(t, "Workflow run has been queued", response["message"]) - assert.Contains(t, response, "workflow_type") + assert.NotNil(t, response.TotalCount) }) } } -func Test_RunWorkflow_WithFilename(t *testing.T) { - // Test the unified RunWorkflow function with filenames +func Test_ActionsList_ListWorkflowArtifacts(t *testing.T) { tests := []struct { name string mockedClient *http.Client @@ -232,70 +219,64 @@ func Test_RunWorkflow_WithFilename(t *testing.T) { expectedErrMsg string }{ { - name: "successful workflow run by filename", - mockedClient: mock.NewMockedHTTPClient( - mock.WithRequestMatchHandler( - mock.PostReposActionsWorkflowsDispatchesByOwnerByRepoByWorkflowId, - http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) { - w.WriteHeader(http.StatusNoContent) - }), - ), - ), - requestArgs: map[string]any{ - "owner": "owner", - "repo": "repo", - "workflow_id": "ci.yml", - "ref": "main", - }, - expectError: false, - }, - { - name: "successful workflow run by numeric ID as string", + name: "successful artifacts listing", mockedClient: mock.NewMockedHTTPClient( mock.WithRequestMatchHandler( - mock.PostReposActionsWorkflowsDispatchesByOwnerByRepoByWorkflowId, + mock.GetReposActionsRunsArtifactsByOwnerByRepoByRunId, http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) { - w.WriteHeader(http.StatusNoContent) + artifacts := &github.ArtifactList{ + TotalCount: github.Ptr(int64(2)), + Artifacts: []*github.Artifact{ + { + ID: github.Ptr(int64(1)), + NodeID: github.Ptr("A_1"), + Name: github.Ptr("build-artifacts"), + SizeInBytes: github.Ptr(int64(1024)), + URL: github.Ptr("https://api.github.com/repos/owner/repo/actions/artifacts/1"), + ArchiveDownloadURL: github.Ptr("https://api.github.com/repos/owner/repo/actions/artifacts/1/zip"), + Expired: github.Ptr(false), + CreatedAt: &github.Timestamp{}, + UpdatedAt: &github.Timestamp{}, + ExpiresAt: &github.Timestamp{}, + }, + }, + } + w.WriteHeader(http.StatusOK) + _ = json.NewEncoder(w).Encode(artifacts) }), ), ), requestArgs: map[string]any{ + "method": "list_workflow_run_artifacts", "owner": "owner", "repo": "repo", - "workflow_id": "12345", - "ref": "main", + "resource_id": "12345", }, expectError: false, }, { - name: "missing required parameter workflow_id", + name: "missing resource_id for list_workflow_run_artifacts", mockedClient: mock.NewMockedHTTPClient(), requestArgs: map[string]any{ - "owner": "owner", - "repo": "repo", - "ref": "main", + "method": "list_workflow_run_artifacts", + "owner": "owner", + "repo": "repo", }, expectError: true, - expectedErrMsg: "missing required parameter: workflow_id", + expectedErrMsg: "missing required parameter for method list_workflow_run_artifacts: resource_id", }, } for _, tc := range tests { t.Run(tc.name, func(t *testing.T) { - // Setup client with mock client := github.NewClient(tc.mockedClient) - _, handler := RunWorkflow(stubGetClientFn(client), translations.NullTranslationHelper) - - // Create call request + _, handler := ActionsList(stubGetClientFn(client), translations.NullTranslationHelper) request := createMCPRequest(tc.requestArgs) - - // Call handler result, _, err := handler(context.Background(), &request, tc.requestArgs) 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 != "" { @@ -303,29 +284,31 @@ func Test_RunWorkflow_WithFilename(t *testing.T) { return } - // Unmarshal and verify the result - var response map[string]any + var response github.ArtifactList err = json.Unmarshal([]byte(textContent.Text), &response) require.NoError(t, err) - assert.Equal(t, "Workflow run has been queued", response["message"]) - assert.Contains(t, response, "workflow_type") + assert.NotNil(t, response.TotalCount) }) } } -func Test_CancelWorkflowRun(t *testing.T) { +func Test_ActionsGet(t *testing.T) { // Verify tool definition once mockClient := github.NewClient(nil) - tool, _ := CancelWorkflowRun(stubGetClientFn(mockClient), translations.NullTranslationHelper) + tool, _ := ActionsGet(stubGetClientFn(mockClient), translations.NullTranslationHelper) require.NoError(t, toolsnaps.Test(tool.Name, tool)) - assert.Equal(t, "cancel_workflow_run", tool.Name) + assert.Equal(t, "actions_get", tool.Name) assert.NotEmpty(t, tool.Description) - assert.Contains(t, tool.InputSchema.(*jsonschema.Schema).Properties, "owner") - assert.Contains(t, tool.InputSchema.(*jsonschema.Schema).Properties, "repo") - assert.Contains(t, tool.InputSchema.(*jsonschema.Schema).Properties, "run_id") - assert.ElementsMatch(t, tool.InputSchema.(*jsonschema.Schema).Required, []string{"owner", "repo", "run_id"}) + inputSchema := tool.InputSchema.(*jsonschema.Schema) + assert.Contains(t, inputSchema.Properties, "method") + assert.Contains(t, inputSchema.Properties, "owner") + assert.Contains(t, inputSchema.Properties, "repo") + assert.Contains(t, inputSchema.Properties, "resource_id") + assert.ElementsMatch(t, inputSchema.Required, []string{"method", "owner", "repo", "resource_id"}) +} +func Test_ActionsGet_GetWorkflowRun(t *testing.T) { tests := []struct { name string mockedClient *http.Client @@ -334,106 +317,58 @@ func Test_CancelWorkflowRun(t *testing.T) { expectedErrMsg string }{ { - name: "successful workflow run cancellation", + name: "successful get workflow run", mockedClient: mock.NewMockedHTTPClient( mock.WithRequestMatchHandler( - mock.EndpointPattern{ - Pattern: "/repos/owner/repo/actions/runs/12345/cancel", - Method: "POST", - }, + mock.GetReposActionsRunsByOwnerByRepoByRunId, http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) { - w.WriteHeader(http.StatusAccepted) + workflowRun := &github.WorkflowRun{ + ID: github.Ptr(int64(12345)), + Name: github.Ptr("CI"), + Status: github.Ptr("completed"), + Conclusion: github.Ptr("success"), + } + w.WriteHeader(http.StatusOK) + _ = json.NewEncoder(w).Encode(workflowRun) }), ), ), requestArgs: map[string]any{ - "owner": "owner", - "repo": "repo", - "run_id": float64(12345), + "method": "get_workflow_run", + "owner": "owner", + "repo": "repo", + "resource_id": "12345", }, expectError: false, }, - { - name: "conflict when cancelling a workflow run", - mockedClient: mock.NewMockedHTTPClient( - mock.WithRequestMatchHandler( - mock.EndpointPattern{ - Pattern: "/repos/owner/repo/actions/runs/12345/cancel", - Method: "POST", - }, - http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) { - w.WriteHeader(http.StatusConflict) - }), - ), - ), - requestArgs: map[string]any{ - "owner": "owner", - "repo": "repo", - "run_id": float64(12345), - }, - expectError: true, - expectedErrMsg: "failed to cancel workflow run", - }, - { - name: "missing required parameter run_id", - mockedClient: mock.NewMockedHTTPClient(), - requestArgs: map[string]any{ - "owner": "owner", - "repo": "repo", - }, - expectError: true, - expectedErrMsg: "missing required parameter: run_id", - }, } for _, tc := range tests { t.Run(tc.name, func(t *testing.T) { - // Setup client with mock client := github.NewClient(tc.mockedClient) - _, handler := CancelWorkflowRun(stubGetClientFn(client), translations.NullTranslationHelper) - - // Create call request + _, handler := ActionsGet(stubGetClientFn(client), translations.NullTranslationHelper) request := createMCPRequest(tc.requestArgs) - - // Call handler result, _, err := handler(context.Background(), &request, tc.requestArgs) require.NoError(t, err) require.Equal(t, tc.expectError, result.IsError) - // Parse the result and get the text content textContent := getTextResult(t, result) if tc.expectedErrMsg != "" { - assert.Contains(t, textContent.Text, tc.expectedErrMsg) + assert.Equal(t, tc.expectedErrMsg, textContent.Text) return } - // Unmarshal and verify the result - var response map[string]any + var response github.WorkflowRun err = json.Unmarshal([]byte(textContent.Text), &response) require.NoError(t, err) - assert.Equal(t, "Workflow run has been cancelled", response["message"]) - assert.Equal(t, float64(12345), response["run_id"]) + assert.Equal(t, int64(12345), response.GetID()) }) } } -func Test_ListWorkflowRunArtifacts(t *testing.T) { - // Verify tool definition once - mockClient := github.NewClient(nil) - tool, _ := ListWorkflowRunArtifacts(stubGetClientFn(mockClient), translations.NullTranslationHelper) - require.NoError(t, toolsnaps.Test(tool.Name, tool)) - - assert.Equal(t, "list_workflow_run_artifacts", tool.Name) - assert.NotEmpty(t, tool.Description) - assert.Contains(t, tool.InputSchema.(*jsonschema.Schema).Properties, "owner") - assert.Contains(t, tool.InputSchema.(*jsonschema.Schema).Properties, "repo") - assert.Contains(t, tool.InputSchema.(*jsonschema.Schema).Properties, "run_id") - assert.Contains(t, tool.InputSchema.(*jsonschema.Schema).Properties, "perPage") - assert.Contains(t, tool.InputSchema.(*jsonschema.Schema).Properties, "page") - assert.ElementsMatch(t, tool.InputSchema.(*jsonschema.Schema).Required, []string{"owner", "repo", "run_id"}) - +func Test_ActionsGet_DownloadArtifact(t *testing.T) { tests := []struct { name string mockedClient *http.Client @@ -442,94 +377,50 @@ func Test_ListWorkflowRunArtifacts(t *testing.T) { expectedErrMsg string }{ { - name: "successful artifacts listing", + name: "successful artifact download URL", mockedClient: mock.NewMockedHTTPClient( mock.WithRequestMatchHandler( - mock.GetReposActionsRunsArtifactsByOwnerByRepoByRunId, + mock.EndpointPattern{ + Pattern: "/repos/owner/repo/actions/artifacts/123/zip", + Method: "GET", + }, http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) { - artifacts := &github.ArtifactList{ - TotalCount: github.Ptr(int64(2)), - Artifacts: []*github.Artifact{ - { - ID: github.Ptr(int64(1)), - NodeID: github.Ptr("A_1"), - Name: github.Ptr("build-artifacts"), - SizeInBytes: github.Ptr(int64(1024)), - URL: github.Ptr("https://api.github.com/repos/owner/repo/actions/artifacts/1"), - ArchiveDownloadURL: github.Ptr("https://api.github.com/repos/owner/repo/actions/artifacts/1/zip"), - Expired: github.Ptr(false), - CreatedAt: &github.Timestamp{}, - UpdatedAt: &github.Timestamp{}, - ExpiresAt: &github.Timestamp{}, - WorkflowRun: &github.ArtifactWorkflowRun{ - ID: github.Ptr(int64(12345)), - RepositoryID: github.Ptr(int64(1)), - HeadRepositoryID: github.Ptr(int64(1)), - HeadBranch: github.Ptr("main"), - HeadSHA: github.Ptr("abc123"), - }, - }, - { - ID: github.Ptr(int64(2)), - NodeID: github.Ptr("A_2"), - Name: github.Ptr("test-results"), - SizeInBytes: github.Ptr(int64(512)), - URL: github.Ptr("https://api.github.com/repos/owner/repo/actions/artifacts/2"), - ArchiveDownloadURL: github.Ptr("https://api.github.com/repos/owner/repo/actions/artifacts/2/zip"), - Expired: github.Ptr(false), - CreatedAt: &github.Timestamp{}, - UpdatedAt: &github.Timestamp{}, - ExpiresAt: &github.Timestamp{}, - WorkflowRun: &github.ArtifactWorkflowRun{ - ID: github.Ptr(int64(12345)), - RepositoryID: github.Ptr(int64(1)), - HeadRepositoryID: github.Ptr(int64(1)), - HeadBranch: github.Ptr("main"), - HeadSHA: github.Ptr("abc123"), - }, - }, - }, - } - w.WriteHeader(http.StatusOK) - _ = json.NewEncoder(w).Encode(artifacts) + w.Header().Set("Location", "https://api.github.com/repos/owner/repo/actions/artifacts/123/download") + w.WriteHeader(http.StatusFound) }), ), ), requestArgs: map[string]any{ - "owner": "owner", - "repo": "repo", - "run_id": float64(12345), + "method": "download_workflow_run_artifact", + "owner": "owner", + "repo": "repo", + "resource_id": "123", }, 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", + "method": "download_workflow_run_artifact", + "owner": "owner", + "repo": "repo", }, expectError: true, - expectedErrMsg: "missing required parameter: run_id", + expectedErrMsg: "missing required parameter: resource_id", }, } for _, tc := range tests { t.Run(tc.name, func(t *testing.T) { - // Setup client with mock client := github.NewClient(tc.mockedClient) - _, handler := ListWorkflowRunArtifacts(stubGetClientFn(client), translations.NullTranslationHelper) - - // Create call request + _, handler := ActionsGet(stubGetClientFn(client), translations.NullTranslationHelper) request := createMCPRequest(tc.requestArgs) - - // Call handler result, _, err := handler(context.Background(), &request, tc.requestArgs) 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 != "" { @@ -537,30 +428,37 @@ func Test_ListWorkflowRunArtifacts(t *testing.T) { return } - // Unmarshal and verify the result - var response github.ArtifactList + var response map[string]any err = json.Unmarshal([]byte(textContent.Text), &response) require.NoError(t, err) - assert.NotNil(t, response.TotalCount) - assert.Greater(t, *response.TotalCount, int64(0)) - assert.NotEmpty(t, response.Artifacts) + assert.Contains(t, response, "download_url") + assert.Contains(t, response, "message") + assert.Equal(t, "Artifact is available for download", response["message"]) + assert.Equal(t, float64(123), response["artifact_id"]) }) } } -func Test_DownloadWorkflowRunArtifact(t *testing.T) { +func Test_ActionsRunTrigger(t *testing.T) { // Verify tool definition once mockClient := github.NewClient(nil) - tool, _ := DownloadWorkflowRunArtifact(stubGetClientFn(mockClient), translations.NullTranslationHelper) + tool, _ := ActionsRunTrigger(stubGetClientFn(mockClient), translations.NullTranslationHelper) require.NoError(t, toolsnaps.Test(tool.Name, tool)) - assert.Equal(t, "download_workflow_run_artifact", tool.Name) + assert.Equal(t, "actions_run_trigger", tool.Name) assert.NotEmpty(t, tool.Description) - assert.Contains(t, tool.InputSchema.(*jsonschema.Schema).Properties, "owner") - assert.Contains(t, tool.InputSchema.(*jsonschema.Schema).Properties, "repo") - assert.Contains(t, tool.InputSchema.(*jsonschema.Schema).Properties, "artifact_id") - assert.ElementsMatch(t, tool.InputSchema.(*jsonschema.Schema).Required, []string{"owner", "repo", "artifact_id"}) + inputSchema := tool.InputSchema.(*jsonschema.Schema) + assert.Contains(t, inputSchema.Properties, "method") + assert.Contains(t, inputSchema.Properties, "owner") + assert.Contains(t, inputSchema.Properties, "repo") + assert.Contains(t, inputSchema.Properties, "workflow_id") + assert.Contains(t, inputSchema.Properties, "ref") + assert.Contains(t, inputSchema.Properties, "inputs") + assert.Contains(t, inputSchema.Properties, "run_id") + assert.ElementsMatch(t, inputSchema.Required, []string{"method", "owner", "repo"}) +} +func Test_ActionsRunTrigger_RunWorkflow(t *testing.T) { tests := []struct { name string mockedClient *http.Client @@ -569,55 +467,79 @@ func Test_DownloadWorkflowRunArtifact(t *testing.T) { expectedErrMsg string }{ { - name: "successful artifact download URL", + name: "successful workflow run", mockedClient: mock.NewMockedHTTPClient( mock.WithRequestMatchHandler( - mock.EndpointPattern{ - Pattern: "/repos/owner/repo/actions/artifacts/123/zip", - Method: "GET", - }, + mock.PostReposActionsWorkflowsDispatchesByOwnerByRepoByWorkflowId, http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) { - // GitHub returns a 302 redirect to the download URL - w.Header().Set("Location", "https://api.github.com/repos/owner/repo/actions/artifacts/123/download") - w.WriteHeader(http.StatusFound) + w.WriteHeader(http.StatusNoContent) }), ), ), requestArgs: map[string]any{ + "method": "run_workflow", "owner": "owner", "repo": "repo", - "artifact_id": float64(123), + "workflow_id": "12345", + "ref": "main", }, expectError: false, }, { - name: "missing required parameter artifact_id", + name: "successful workflow run by filename", + mockedClient: mock.NewMockedHTTPClient( + mock.WithRequestMatchHandler( + mock.PostReposActionsWorkflowsDispatchesByOwnerByRepoByWorkflowId, + http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) { + w.WriteHeader(http.StatusNoContent) + }), + ), + ), + requestArgs: map[string]any{ + "method": "run_workflow", + "owner": "owner", + "repo": "repo", + "workflow_id": "ci.yml", + "ref": "main", + }, + expectError: false, + }, + { + name: "missing required parameter workflow_id", mockedClient: mock.NewMockedHTTPClient(), requestArgs: map[string]any{ - "owner": "owner", - "repo": "repo", + "method": "run_workflow", + "owner": "owner", + "repo": "repo", + "ref": "main", + }, + expectError: true, + expectedErrMsg: "workflow_id is required for run_workflow action", + }, + { + name: "missing required parameter ref", + mockedClient: mock.NewMockedHTTPClient(), + requestArgs: map[string]any{ + "method": "run_workflow", + "owner": "owner", + "repo": "repo", + "workflow_id": "12345", }, expectError: true, - expectedErrMsg: "missing required parameter: artifact_id", + expectedErrMsg: "ref is required for run_workflow action", }, } for _, tc := range tests { t.Run(tc.name, func(t *testing.T) { - // Setup client with mock client := github.NewClient(tc.mockedClient) - _, handler := DownloadWorkflowRunArtifact(stubGetClientFn(client), translations.NullTranslationHelper) - - // Create call request + _, handler := ActionsRunTrigger(stubGetClientFn(client), translations.NullTranslationHelper) request := createMCPRequest(tc.requestArgs) - - // Call handler result, _, err := handler(context.Background(), &request, tc.requestArgs) 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 != "" { @@ -625,31 +547,16 @@ func Test_DownloadWorkflowRunArtifact(t *testing.T) { return } - // Unmarshal and verify the result var response map[string]any err = json.Unmarshal([]byte(textContent.Text), &response) require.NoError(t, err) - assert.Contains(t, response, "download_url") - assert.Contains(t, response, "message") - assert.Equal(t, "Artifact is available for download", response["message"]) - assert.Equal(t, float64(123), response["artifact_id"]) + assert.Equal(t, "Workflow run has been queued", response["message"]) + assert.Contains(t, response, "workflow_type") }) } } -func Test_DeleteWorkflowRunLogs(t *testing.T) { - // Verify tool definition once - mockClient := github.NewClient(nil) - tool, _ := DeleteWorkflowRunLogs(stubGetClientFn(mockClient), translations.NullTranslationHelper) - require.NoError(t, toolsnaps.Test(tool.Name, tool)) - - assert.Equal(t, "delete_workflow_run_logs", tool.Name) - assert.NotEmpty(t, tool.Description) - assert.Contains(t, tool.InputSchema.(*jsonschema.Schema).Properties, "owner") - assert.Contains(t, tool.InputSchema.(*jsonschema.Schema).Properties, "repo") - assert.Contains(t, tool.InputSchema.(*jsonschema.Schema).Properties, "run_id") - assert.ElementsMatch(t, tool.InputSchema.(*jsonschema.Schema).Required, []string{"owner", "repo", "run_id"}) - +func Test_ActionsRunTrigger_CancelWorkflowRun(t *testing.T) { tests := []struct { name string mockedClient *http.Client @@ -658,28 +565,55 @@ func Test_DeleteWorkflowRunLogs(t *testing.T) { expectedErrMsg string }{ { - name: "successful logs deletion", + name: "successful workflow run cancellation", mockedClient: mock.NewMockedHTTPClient( mock.WithRequestMatchHandler( - mock.DeleteReposActionsRunsLogsByOwnerByRepoByRunId, + mock.EndpointPattern{ + Pattern: "/repos/owner/repo/actions/runs/12345/cancel", + Method: "POST", + }, http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) { - w.WriteHeader(http.StatusNoContent) + w.WriteHeader(http.StatusAccepted) }), ), ), requestArgs: map[string]any{ + "method": "cancel_workflow_run", "owner": "owner", "repo": "repo", "run_id": float64(12345), }, expectError: false, }, + { + name: "conflict when cancelling a workflow run", + mockedClient: mock.NewMockedHTTPClient( + mock.WithRequestMatchHandler( + mock.EndpointPattern{ + Pattern: "/repos/owner/repo/actions/runs/12345/cancel", + Method: "POST", + }, + http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) { + w.WriteHeader(http.StatusConflict) + }), + ), + ), + requestArgs: map[string]any{ + "method": "cancel_workflow_run", + "owner": "owner", + "repo": "repo", + "run_id": float64(12345), + }, + expectError: true, + expectedErrMsg: "failed to cancel workflow run", + }, { name: "missing required parameter run_id", mockedClient: mock.NewMockedHTTPClient(), requestArgs: map[string]any{ - "owner": "owner", - "repo": "repo", + "method": "cancel_workflow_run", + "owner": "owner", + "repo": "repo", }, expectError: true, expectedErrMsg: "missing required parameter: run_id", @@ -688,50 +622,31 @@ func Test_DeleteWorkflowRunLogs(t *testing.T) { for _, tc := range tests { t.Run(tc.name, func(t *testing.T) { - // Setup client with mock client := github.NewClient(tc.mockedClient) - _, handler := DeleteWorkflowRunLogs(stubGetClientFn(client), translations.NullTranslationHelper) - - // Create call request + _, handler := ActionsRunTrigger(stubGetClientFn(client), translations.NullTranslationHelper) request := createMCPRequest(tc.requestArgs) - - // Call handler result, _, err := handler(context.Background(), &request, tc.requestArgs) 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) + assert.Contains(t, textContent.Text, tc.expectedErrMsg) 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, "Workflow run logs have been deleted", response["message"]) + assert.Equal(t, "Workflow run has been cancelled", response["message"]) assert.Equal(t, float64(12345), response["run_id"]) }) } } -func Test_GetWorkflowRunUsage(t *testing.T) { - // Verify tool definition once - mockClient := github.NewClient(nil) - tool, _ := GetWorkflowRunUsage(stubGetClientFn(mockClient), translations.NullTranslationHelper) - require.NoError(t, toolsnaps.Test(tool.Name, tool)) - - assert.Equal(t, "get_workflow_run_usage", tool.Name) - assert.NotEmpty(t, tool.Description) - assert.Contains(t, tool.InputSchema.(*jsonschema.Schema).Properties, "owner") - assert.Contains(t, tool.InputSchema.(*jsonschema.Schema).Properties, "repo") - assert.Contains(t, tool.InputSchema.(*jsonschema.Schema).Properties, "run_id") - assert.ElementsMatch(t, tool.InputSchema.(*jsonschema.Schema).Required, []string{"owner", "repo", "run_id"}) - +func Test_ActionsRunTrigger_DeleteWorkflowRunLogs(t *testing.T) { tests := []struct { name string mockedClient *http.Client @@ -740,36 +655,17 @@ func Test_GetWorkflowRunUsage(t *testing.T) { expectedErrMsg string }{ { - name: "successful workflow run usage", + name: "successful logs deletion", mockedClient: mock.NewMockedHTTPClient( mock.WithRequestMatchHandler( - mock.GetReposActionsRunsTimingByOwnerByRepoByRunId, + mock.DeleteReposActionsRunsLogsByOwnerByRepoByRunId, http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) { - usage := &github.WorkflowRunUsage{ - Billable: &github.WorkflowRunBillMap{ - "UBUNTU": &github.WorkflowRunBill{ - TotalMS: github.Ptr(int64(120000)), - Jobs: github.Ptr(2), - JobRuns: []*github.WorkflowRunJobRun{ - { - JobID: github.Ptr(1), - DurationMS: github.Ptr(int64(60000)), - }, - { - JobID: github.Ptr(2), - DurationMS: github.Ptr(int64(60000)), - }, - }, - }, - }, - RunDurationMS: github.Ptr(int64(120000)), - } - w.WriteHeader(http.StatusOK) - _ = json.NewEncoder(w).Encode(usage) + w.WriteHeader(http.StatusNoContent) }), ), ), requestArgs: map[string]any{ + "method": "delete_workflow_run_logs", "owner": "owner", "repo": "repo", "run_id": float64(12345), @@ -780,8 +676,9 @@ func Test_GetWorkflowRunUsage(t *testing.T) { name: "missing required parameter run_id", mockedClient: mock.NewMockedHTTPClient(), requestArgs: map[string]any{ - "owner": "owner", - "repo": "repo", + "method": "delete_workflow_run_logs", + "owner": "owner", + "repo": "repo", }, expectError: true, expectedErrMsg: "missing required parameter: run_id", @@ -790,20 +687,14 @@ func Test_GetWorkflowRunUsage(t *testing.T) { for _, tc := range tests { t.Run(tc.name, func(t *testing.T) { - // Setup client with mock client := github.NewClient(tc.mockedClient) - _, handler := GetWorkflowRunUsage(stubGetClientFn(client), translations.NullTranslationHelper) - - // Create call request + _, handler := ActionsRunTrigger(stubGetClientFn(client), translations.NullTranslationHelper) request := createMCPRequest(tc.requestArgs) - - // Call handler result, _, err := handler(context.Background(), &request, tc.requestArgs) 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 != "" { @@ -811,31 +702,31 @@ func Test_GetWorkflowRunUsage(t *testing.T) { return } - // Unmarshal and verify the result - var response github.WorkflowRunUsage + var response map[string]any err = json.Unmarshal([]byte(textContent.Text), &response) require.NoError(t, err) - assert.NotNil(t, response.RunDurationMS) - assert.NotNil(t, response.Billable) + assert.Equal(t, "Workflow run logs have been deleted", response["message"]) + assert.Equal(t, float64(12345), response["run_id"]) }) } } -func Test_GetJobLogs(t *testing.T) { +func Test_ActionsGetJobLogs(t *testing.T) { // Verify tool definition once mockClient := github.NewClient(nil) - tool, _ := GetJobLogs(stubGetClientFn(mockClient), translations.NullTranslationHelper, 5000) + tool, _ := ActionsGetJobLogs(stubGetClientFn(mockClient), translations.NullTranslationHelper, 5000) require.NoError(t, toolsnaps.Test(tool.Name, tool)) assert.Equal(t, "get_job_logs", tool.Name) assert.NotEmpty(t, tool.Description) - assert.Contains(t, tool.InputSchema.(*jsonschema.Schema).Properties, "owner") - assert.Contains(t, tool.InputSchema.(*jsonschema.Schema).Properties, "repo") - assert.Contains(t, tool.InputSchema.(*jsonschema.Schema).Properties, "job_id") - assert.Contains(t, tool.InputSchema.(*jsonschema.Schema).Properties, "run_id") - assert.Contains(t, tool.InputSchema.(*jsonschema.Schema).Properties, "failed_only") - assert.Contains(t, tool.InputSchema.(*jsonschema.Schema).Properties, "return_content") - assert.ElementsMatch(t, tool.InputSchema.(*jsonschema.Schema).Required, []string{"owner", "repo"}) + inputSchema := tool.InputSchema.(*jsonschema.Schema) + assert.Contains(t, inputSchema.Properties, "owner") + assert.Contains(t, inputSchema.Properties, "repo") + assert.Contains(t, inputSchema.Properties, "job_id") + assert.Contains(t, inputSchema.Properties, "run_id") + assert.Contains(t, inputSchema.Properties, "failed_only") + assert.Contains(t, inputSchema.Properties, "return_content") + assert.ElementsMatch(t, inputSchema.Required, []string{"owner", "repo"}) tests := []struct { name string @@ -1052,20 +943,14 @@ func Test_GetJobLogs(t *testing.T) { for _, tc := range tests { t.Run(tc.name, func(t *testing.T) { - // Setup client with mock client := github.NewClient(tc.mockedClient) - _, handler := GetJobLogs(stubGetClientFn(client), translations.NullTranslationHelper, 5000) - - // Create call request + _, handler := ActionsGetJobLogs(stubGetClientFn(client), translations.NullTranslationHelper, 5000) request := createMCPRequest(tc.requestArgs) - - // Call handler result, _, err := handler(context.Background(), &request, tc.requestArgs) require.NoError(t, err) require.Equal(t, tc.expectError, result.IsError) - // Parse the result and get the text content textContent := getTextResult(t, result) if tc.expectedErrMsg != "" { @@ -1074,12 +959,10 @@ func Test_GetJobLogs(t *testing.T) { } if tc.expectError { - // For API errors, just verify we got an error assert.True(t, result.IsError) return } - // Unmarshal and verify the result var response map[string]any err = json.Unmarshal([]byte(textContent.Text), &response) require.NoError(t, err) @@ -1091,11 +974,9 @@ func Test_GetJobLogs(t *testing.T) { } } -func Test_GetJobLogs_WithContentReturn(t *testing.T) { - // Test the return_content functionality with a mock HTTP server +func Test_ActionsGetJobLogs_WithContentReturn(t *testing.T) { logContent := "2023-01-01T10:00:00.000Z Starting job...\n2023-01-01T10:00:01.000Z Running tests...\n2023-01-01T10:00:02.000Z Job completed successfully" - // Create a test server to serve log content testServer := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) { w.WriteHeader(http.StatusOK) _, _ = w.Write([]byte(logContent)) @@ -1113,7 +994,7 @@ func Test_GetJobLogs_WithContentReturn(t *testing.T) { ) client := github.NewClient(mockedClient) - _, handler := GetJobLogs(stubGetClientFn(client), translations.NullTranslationHelper, 5000) + _, handler := ActionsGetJobLogs(stubGetClientFn(client), translations.NullTranslationHelper, 5000) request := createMCPRequest(map[string]any{ "owner": "owner", @@ -1140,15 +1021,13 @@ func Test_GetJobLogs_WithContentReturn(t *testing.T) { assert.Equal(t, float64(123), response["job_id"]) assert.Equal(t, logContent, response["logs_content"]) assert.Equal(t, "Job logs content retrieved successfully", response["message"]) - assert.NotContains(t, response, "logs_url") // Should not have URL when returning content + assert.NotContains(t, response, "logs_url") } -func Test_GetJobLogs_WithContentReturnAndTailLines(t *testing.T) { - // Test the return_content functionality with a mock HTTP server +func Test_ActionsGetJobLogs_WithContentReturnAndTailLines(t *testing.T) { logContent := "2023-01-01T10:00:00.000Z Starting job...\n2023-01-01T10:00:01.000Z Running tests...\n2023-01-01T10:00:02.000Z Job completed successfully" expectedLogContent := "2023-01-01T10:00:02.000Z Job completed successfully" - // Create a test server to serve log content testServer := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) { w.WriteHeader(http.StatusOK) _, _ = w.Write([]byte(logContent)) @@ -1166,14 +1045,14 @@ func Test_GetJobLogs_WithContentReturnAndTailLines(t *testing.T) { ) client := github.NewClient(mockedClient) - _, handler := GetJobLogs(stubGetClientFn(client), translations.NullTranslationHelper, 5000) + _, handler := ActionsGetJobLogs(stubGetClientFn(client), translations.NullTranslationHelper, 5000) request := createMCPRequest(map[string]any{ "owner": "owner", "repo": "repo", "job_id": float64(123), "return_content": true, - "tail_lines": float64(1), // Requesting last 1 line + "tail_lines": float64(1), }) args := map[string]any{ "owner": "owner", @@ -1196,10 +1075,10 @@ func Test_GetJobLogs_WithContentReturnAndTailLines(t *testing.T) { assert.Equal(t, float64(3), response["original_length"]) assert.Equal(t, expectedLogContent, response["logs_content"]) assert.Equal(t, "Job logs content retrieved successfully", response["message"]) - assert.NotContains(t, response, "logs_url") // Should not have URL when returning content + assert.NotContains(t, response, "logs_url") } -func Test_GetJobLogs_WithContentReturnAndLargeTailLines(t *testing.T) { +func Test_ActionsGetJobLogs_WithContentReturnAndLargeTailLines(t *testing.T) { logContent := "Line 1\nLine 2\nLine 3" expectedLogContent := "Line 1\nLine 2\nLine 3" @@ -1220,7 +1099,7 @@ func Test_GetJobLogs_WithContentReturnAndLargeTailLines(t *testing.T) { ) client := github.NewClient(mockedClient) - _, handler := GetJobLogs(stubGetClientFn(client), translations.NullTranslationHelper, 5000) + _, handler := ActionsGetJobLogs(stubGetClientFn(client), translations.NullTranslationHelper, 5000) request := createMCPRequest(map[string]any{ "owner": "owner", @@ -1351,92 +1230,87 @@ func Test_MemoryUsage_SlidingWindow_vs_NoWindow(t *testing.T) { t.Logf("No window: %s", profile2.String()) } -func Test_ListWorkflowRuns(t *testing.T) { - // Verify tool definition once - mockClient := github.NewClient(nil) - tool, _ := ListWorkflowRuns(stubGetClientFn(mockClient), translations.NullTranslationHelper) - require.NoError(t, toolsnaps.Test(tool.Name, tool)) - - assert.Equal(t, "list_workflow_runs", tool.Name) - assert.NotEmpty(t, tool.Description) - inputSchema := tool.InputSchema.(*jsonschema.Schema) - assert.Contains(t, inputSchema.Properties, "owner") - assert.Contains(t, inputSchema.Properties, "repo") - assert.Contains(t, inputSchema.Properties, "workflow_id") - assert.ElementsMatch(t, inputSchema.Required, []string{"owner", "repo", "workflow_id"}) -} - -func Test_GetWorkflowRun(t *testing.T) { - // Verify tool definition once - mockClient := github.NewClient(nil) - tool, _ := GetWorkflowRun(stubGetClientFn(mockClient), translations.NullTranslationHelper) - require.NoError(t, toolsnaps.Test(tool.Name, tool)) - - assert.Equal(t, "get_workflow_run", tool.Name) - assert.NotEmpty(t, tool.Description) - inputSchema := tool.InputSchema.(*jsonschema.Schema) - assert.Contains(t, inputSchema.Properties, "owner") - assert.Contains(t, inputSchema.Properties, "repo") - assert.Contains(t, inputSchema.Properties, "run_id") - assert.ElementsMatch(t, inputSchema.Required, []string{"owner", "repo", "run_id"}) -} - -func Test_GetWorkflowRunLogs(t *testing.T) { - // Verify tool definition once - mockClient := github.NewClient(nil) - tool, _ := GetWorkflowRunLogs(stubGetClientFn(mockClient), translations.NullTranslationHelper) - require.NoError(t, toolsnaps.Test(tool.Name, tool)) - - assert.Equal(t, "get_workflow_run_logs", tool.Name) - assert.NotEmpty(t, tool.Description) - inputSchema := tool.InputSchema.(*jsonschema.Schema) - assert.Contains(t, inputSchema.Properties, "owner") - assert.Contains(t, inputSchema.Properties, "repo") - assert.Contains(t, inputSchema.Properties, "run_id") - assert.ElementsMatch(t, inputSchema.Required, []string{"owner", "repo", "run_id"}) -} - -func Test_ListWorkflowJobs(t *testing.T) { - // Verify tool definition once - mockClient := github.NewClient(nil) - tool, _ := ListWorkflowJobs(stubGetClientFn(mockClient), translations.NullTranslationHelper) - require.NoError(t, toolsnaps.Test(tool.Name, tool)) +func Test_ActionsGet_GetWorkflowRunUsage(t *testing.T) { + tests := []struct { + name string + mockedClient *http.Client + requestArgs map[string]any + expectError bool + expectedErrMsg string + }{ + { + name: "successful workflow run usage", + mockedClient: mock.NewMockedHTTPClient( + mock.WithRequestMatchHandler( + mock.GetReposActionsRunsTimingByOwnerByRepoByRunId, + http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) { + usage := &github.WorkflowRunUsage{ + Billable: &github.WorkflowRunBillMap{ + "UBUNTU": &github.WorkflowRunBill{ + TotalMS: github.Ptr(int64(120000)), + Jobs: github.Ptr(2), + JobRuns: []*github.WorkflowRunJobRun{ + { + JobID: github.Ptr(1), + DurationMS: github.Ptr(int64(60000)), + }, + { + JobID: github.Ptr(2), + DurationMS: github.Ptr(int64(60000)), + }, + }, + }, + }, + RunDurationMS: github.Ptr(int64(120000)), + } + w.WriteHeader(http.StatusOK) + _ = json.NewEncoder(w).Encode(usage) + }), + ), + ), + requestArgs: map[string]any{ + "method": "get_workflow_run_usage", + "owner": "owner", + "repo": "repo", + "resource_id": "12345", + }, + expectError: false, + }, + { + name: "missing required parameter resource_id", + mockedClient: mock.NewMockedHTTPClient(), + requestArgs: map[string]any{ + "method": "get_workflow_run_usage", + "owner": "owner", + "repo": "repo", + }, + expectError: true, + expectedErrMsg: "missing required parameter: resource_id", + }, + } - assert.Equal(t, "list_workflow_jobs", tool.Name) - assert.NotEmpty(t, tool.Description) - inputSchema := tool.InputSchema.(*jsonschema.Schema) - assert.Contains(t, inputSchema.Properties, "owner") - assert.Contains(t, inputSchema.Properties, "repo") - assert.Contains(t, inputSchema.Properties, "run_id") - assert.ElementsMatch(t, inputSchema.Required, []string{"owner", "repo", "run_id"}) -} + for _, tc := range tests { + t.Run(tc.name, func(t *testing.T) { + client := github.NewClient(tc.mockedClient) + _, handler := ActionsGet(stubGetClientFn(client), translations.NullTranslationHelper) + request := createMCPRequest(tc.requestArgs) + result, _, err := handler(context.Background(), &request, tc.requestArgs) -func Test_RerunWorkflowRun(t *testing.T) { - // Verify tool definition once - mockClient := github.NewClient(nil) - tool, _ := RerunWorkflowRun(stubGetClientFn(mockClient), translations.NullTranslationHelper) - require.NoError(t, toolsnaps.Test(tool.Name, tool)) + require.NoError(t, err) + require.Equal(t, tc.expectError, result.IsError) - assert.Equal(t, "rerun_workflow_run", tool.Name) - assert.NotEmpty(t, tool.Description) - inputSchema := tool.InputSchema.(*jsonschema.Schema) - assert.Contains(t, inputSchema.Properties, "owner") - assert.Contains(t, inputSchema.Properties, "repo") - assert.Contains(t, inputSchema.Properties, "run_id") - assert.ElementsMatch(t, inputSchema.Required, []string{"owner", "repo", "run_id"}) -} + textContent := getTextResult(t, result) -func Test_RerunFailedJobs(t *testing.T) { - // Verify tool definition once - mockClient := github.NewClient(nil) - tool, _ := RerunFailedJobs(stubGetClientFn(mockClient), translations.NullTranslationHelper) - require.NoError(t, toolsnaps.Test(tool.Name, tool)) + if tc.expectedErrMsg != "" { + assert.Equal(t, tc.expectedErrMsg, textContent.Text) + return + } - assert.Equal(t, "rerun_failed_jobs", tool.Name) - assert.NotEmpty(t, tool.Description) - inputSchema := tool.InputSchema.(*jsonschema.Schema) - assert.Contains(t, inputSchema.Properties, "owner") - assert.Contains(t, inputSchema.Properties, "repo") - assert.Contains(t, inputSchema.Properties, "run_id") - assert.ElementsMatch(t, inputSchema.Required, []string{"owner", "repo", "run_id"}) + var response github.WorkflowRunUsage + err = json.Unmarshal([]byte(textContent.Text), &response) + require.NoError(t, err) + assert.NotNil(t, response.RunDurationMS) + assert.NotNil(t, response.Billable) + }) + } } diff --git a/pkg/github/deprecated_tool_aliases.go b/pkg/github/deprecated_tool_aliases.go index 4abdca14d..d83db1ed6 100644 --- a/pkg/github/deprecated_tool_aliases.go +++ b/pkg/github/deprecated_tool_aliases.go @@ -10,5 +10,20 @@ package github // "get_issue": "issue_read", // "create_pr": "pull_request_create", var DeprecatedToolAliases = map[string]string{ - // Add entries as tools are renamed + // Actions tools consolidated + "list_workflows": "actions_list", + "list_workflow_runs": "actions_list", + "list_workflow_jobs": "actions_list", + "list_workflow_run_artifacts": "actions_list", + "get_workflow": "actions_get", + "get_workflow_run": "actions_get", + "get_workflow_job": "actions_get", + "get_workflow_run_usage": "actions_get", + "get_workflow_run_logs": "actions_get", + "download_workflow_run_artifact": "actions_get", + "run_workflow": "actions_run_trigger", + "rerun_workflow_run": "actions_run_trigger", + "rerun_failed_jobs": "actions_run_trigger", + "cancel_workflow_run": "actions_run_trigger", + "delete_workflow_run_logs": "actions_run_trigger", } diff --git a/pkg/github/tools.go b/pkg/github/tools.go index f21a9ae5b..1f874f789 100644 --- a/pkg/github/tools.go +++ b/pkg/github/tools.go @@ -277,22 +277,12 @@ 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(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)), + toolsets.NewServerTool(ActionsGet(getClient, t)), + toolsets.NewServerTool(ActionsList(getClient, t)), + toolsets.NewServerTool(ActionsGetJobLogs(getClient, t, contentWindowSize)), ). AddWriteTools( - toolsets.NewServerTool(RunWorkflow(getClient, t)), - toolsets.NewServerTool(RerunWorkflowRun(getClient, t)), - toolsets.NewServerTool(RerunFailedJobs(getClient, t)), - toolsets.NewServerTool(CancelWorkflowRun(getClient, t)), - toolsets.NewServerTool(DeleteWorkflowRunLogs(getClient, t)), + toolsets.NewServerTool(ActionsRunTrigger(getClient, t)), ) securityAdvisories := toolsets.NewToolset(ToolsetMetadataSecurityAdvisories.ID, ToolsetMetadataSecurityAdvisories.Description).