From 55f4c85fb9b7f99a9f21d6d1167a80b195fef719 Mon Sep 17 00:00:00 2001 From: Matt Holloway Date: Fri, 12 Dec 2025 13:56:59 +0000 Subject: [PATCH 1/4] initial actions toolset backport --- README.md | 105 +- pkg/github/__toolsnaps__/actions_get.snap | 43 + pkg/github/__toolsnaps__/actions_list.snap | 128 ++ .../__toolsnaps__/actions_run_trigger.snap | 53 + pkg/github/__toolsnaps__/get_job_logs.snap | 10 +- pkg/github/actions.go | 1601 +++++++---------- pkg/github/actions_test.go | 788 ++++---- pkg/github/deprecated_tool_aliases.go | 17 +- pkg/github/tools.go | 18 +- 9 files changed, 1288 insertions(+), 1475 deletions(-) create mode 100644 pkg/github/__toolsnaps__/actions_get.snap create mode 100644 pkg/github/__toolsnaps__/actions_list.snap create mode 100644 pkg/github/__toolsnaps__/actions_run_trigger.snap diff --git a/README.md b/README.md index bcd9f85c8..a3ed9173e 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). From a48e306da71ef7370aa445cdb66893987d66d3c7 Mon Sep 17 00:00:00 2001 From: Ksenia Bobrova Date: Mon, 15 Dec 2025 11:05:01 +0100 Subject: [PATCH 2/4] Improvements & refactoring of get_file_contents (#1582) * Improvements & refactoring of get_file_contents * Fix logical path when file or directory not found * Fix comment * Docs update * Do file matching when raw API returns error --- README.md | 2 +- .../__toolsnaps__/get_file_contents.snap | 2 +- pkg/github/repositories.go | 126 +++++++++--------- 3 files changed, 62 insertions(+), 68 deletions(-) diff --git a/README.md b/README.md index bcd9f85c8..b806d29ff 100644 --- a/README.md +++ b/README.md @@ -1092,7 +1092,7 @@ The following sets of tools are available: - **get_file_contents** - Get file or directory contents - `owner`: Repository owner (username or organization) (string, required) - - `path`: Path to file/directory (directories must end with a slash '/') (string, optional) + - `path`: Path to file/directory (string, optional) - `ref`: Accepts optional git refs such as `refs/tags/{tag}`, `refs/heads/{branch}` or `refs/pull/{pr_number}/head` (string, optional) - `repo`: Repository name (string, required) - `sha`: Accepts optional commit SHA. If specified, it will be used instead of ref (string, optional) diff --git a/pkg/github/__toolsnaps__/get_file_contents.snap b/pkg/github/__toolsnaps__/get_file_contents.snap index 767466dd3..638452fe7 100644 --- a/pkg/github/__toolsnaps__/get_file_contents.snap +++ b/pkg/github/__toolsnaps__/get_file_contents.snap @@ -17,7 +17,7 @@ }, "path": { "type": "string", - "description": "Path to file/directory (directories must end with a slash '/')", + "description": "Path to file/directory", "default": "/" }, "ref": { diff --git a/pkg/github/repositories.go b/pkg/github/repositories.go index ff81484f2..e5f6ec0c1 100644 --- a/pkg/github/repositories.go +++ b/pkg/github/repositories.go @@ -560,7 +560,7 @@ func GetFileContents(getClient GetClientFn, getRawClient raw.GetRawClientFn, t t }, "path": { Type: "string", - Description: "Path to file/directory (directories must end with a slash '/')", + Description: "Path to file/directory", Default: json.RawMessage(`"/"`), }, "ref": { @@ -608,28 +608,26 @@ func GetFileContents(getClient GetClientFn, getRawClient raw.GetRawClientFn, t t return utils.NewToolResultError(fmt.Sprintf("failed to resolve git reference: %s", err)), nil, nil } - // If the path is (most likely) not to be a directory, we will - // first try to get the raw content from the GitHub raw content API. + if rawOpts.SHA != "" { + ref = rawOpts.SHA + } - var rawAPIResponseCode int - if path != "" && !strings.HasSuffix(path, "/") { - // First, get file info from Contents API to retrieve SHA - var fileSHA string - opts := &github.RepositoryContentGetOptions{Ref: ref} - fileContent, _, respContents, err := client.Repositories.GetContents(ctx, owner, repo, path, opts) - if respContents != nil { - defer func() { _ = respContents.Body.Close() }() - } - if err != nil { - return ghErrors.NewGitHubAPIErrorResponse(ctx, - "failed to get file SHA", - respContents, - err, - ), nil, nil - } - if fileContent == nil || fileContent.SHA == nil { - return utils.NewToolResultError("file content SHA is nil, if a directory was requested, path parameters should end with a trailing slash '/'"), nil, nil - } + var fileSHA string + opts := &github.RepositoryContentGetOptions{Ref: ref} + + // Always call GitHub Contents API first to get metadata including SHA and determine if it's a file or directory + fileContent, dirContent, respContents, err := client.Repositories.GetContents(ctx, owner, repo, path, opts) + if respContents != nil { + defer func() { _ = respContents.Body.Close() }() + } + + // The path does not point to a file or directory. + // Instead let's try to find it in the Git Tree by matching the end of the path. + if err != nil || (fileContent == nil && dirContent == nil) { + return matchFiles(ctx, client, owner, repo, ref, path, rawOpts, 0) + } + + if fileContent != nil && fileContent.SHA != nil { fileSHA = *fileContent.SHA rawClient, err := getRawClient(ctx) @@ -702,55 +700,19 @@ func GetFileContents(getClient GetClientFn, getRawClient raw.GetRawClientFn, t t } return utils.NewToolResultResource("successfully downloaded binary file", result), nil, nil } - rawAPIResponseCode = resp.StatusCode - } - if rawOpts.SHA != "" { - ref = rawOpts.SHA - } - if strings.HasSuffix(path, "/") { - opts := &github.RepositoryContentGetOptions{Ref: ref} - _, dirContent, resp, err := client.Repositories.GetContents(ctx, owner, repo, path, opts) - if err == nil && resp.StatusCode == http.StatusOK { - defer func() { _ = resp.Body.Close() }() - r, err := json.Marshal(dirContent) - if err != nil { - return utils.NewToolResultError("failed to marshal response"), nil, nil - } - return utils.NewToolResultText(string(r)), nil, nil - } - } - - // The path does not point to a file or directory. - // Instead let's try to find it in the Git Tree by matching the end of the path. - - // Step 1: Get Git Tree recursively - tree, resp, err := client.Git.GetTree(ctx, owner, repo, ref, true) - if err != nil { - return ghErrors.NewGitHubAPIErrorResponse(ctx, - "failed to get git tree", - resp, - err, - ), nil, nil - } - defer func() { _ = resp.Body.Close() }() - - // Step 2: Filter tree for matching paths - const maxMatchingFiles = 3 - matchingFiles := filterPaths(tree.Entries, path, maxMatchingFiles) - if len(matchingFiles) > 0 { - matchingFilesJSON, err := json.Marshal(matchingFiles) + // Raw API call failed + return matchFiles(ctx, client, owner, repo, ref, path, rawOpts, resp.StatusCode) + } else if dirContent != nil { + // file content or file SHA is nil which means it's a directory + r, err := json.Marshal(dirContent) if err != nil { - return utils.NewToolResultError(fmt.Sprintf("failed to marshal matching files: %s", err)), nil, nil + return utils.NewToolResultError("failed to marshal response"), nil, nil } - resolvedRefs, err := json.Marshal(rawOpts) - if err != nil { - return utils.NewToolResultError(fmt.Sprintf("failed to marshal resolved refs: %s", err)), nil, nil - } - return utils.NewToolResultError(fmt.Sprintf("Resolved potential matches in the repository tree (resolved refs: %s, matching files: %s), but the raw content API returned an unexpected status code %d.", string(resolvedRefs), string(matchingFilesJSON), rawAPIResponseCode)), nil, nil + return utils.NewToolResultText(string(r)), nil, nil } - return utils.NewToolResultError("Failed to get file contents. The path does not point to a file or directory, or the file does not exist in the repository."), nil, nil + return utils.NewToolResultError("failed to get file contents"), nil, nil }) return tool, handler @@ -2115,3 +2077,35 @@ func UnstarRepository(getClient GetClientFn, t translations.TranslationHelperFun return tool, handler } + +func matchFiles(ctx context.Context, client *github.Client, owner, repo, ref, path string, rawOpts *raw.ContentOpts, rawAPIResponseCode int) (*mcp.CallToolResult, any, error) { + // Step 1: Get Git Tree recursively + tree, response, err := client.Git.GetTree(ctx, owner, repo, ref, true) + if err != nil { + return ghErrors.NewGitHubAPIErrorResponse(ctx, + "failed to get git tree", + response, + err, + ), nil, nil + } + defer func() { _ = response.Body.Close() }() + + // Step 2: Filter tree for matching paths + const maxMatchingFiles = 3 + matchingFiles := filterPaths(tree.Entries, path, maxMatchingFiles) + if len(matchingFiles) > 0 { + matchingFilesJSON, err := json.Marshal(matchingFiles) + if err != nil { + return utils.NewToolResultError(fmt.Sprintf("failed to marshal matching files: %s", err)), nil, nil + } + resolvedRefs, err := json.Marshal(rawOpts) + if err != nil { + return utils.NewToolResultError(fmt.Sprintf("failed to marshal resolved refs: %s", err)), nil, nil + } + if rawAPIResponseCode > 0 { + return utils.NewToolResultText(fmt.Sprintf("Resolved potential matches in the repository tree (resolved refs: %s, matching files: %s), but the content API returned an unexpected status code %d.", string(resolvedRefs), string(matchingFilesJSON), rawAPIResponseCode)), nil, nil + } + return utils.NewToolResultText(fmt.Sprintf("Resolved potential matches in the repository tree (resolved refs: %s, matching files: %s).", string(resolvedRefs), string(matchingFilesJSON))), nil, nil + } + return utils.NewToolResultError("Failed to get file contents. The path does not point to a file or directory, or the file does not exist in the repository."), nil, nil +} From 5a4338c685ac1d73c129193a50cbaad9808f6aab Mon Sep 17 00:00:00 2001 From: Tony Truong Date: Mon, 15 Dec 2025 12:59:53 +0100 Subject: [PATCH 3/4] adding review comments grouped as threads (#1554) * adding review comments grouped as threads * minor fix * minor edit * update docs * fix docs * increase limit to 100 * fixtext --- README.md | 2 +- .../__toolsnaps__/pull_request_read.snap | 2 +- pkg/github/pullrequests.go | 152 ++++++-- pkg/github/pullrequests_test.go | 344 ++++++++++++------ pkg/github/tools.go | 2 +- 5 files changed, 357 insertions(+), 145 deletions(-) diff --git a/README.md b/README.md index b806d29ff..3364d6f8c 100644 --- a/README.md +++ b/README.md @@ -991,7 +991,7 @@ The following sets of tools are available: 2. get_diff - Get the diff of a pull request. 3. get_status - Get status of a head commit in a pull request. This reflects status of builds and checks. 4. get_files - Get the list of files changed in a pull request. Use with pagination parameters to control the number of results returned. - 5. get_review_comments - Get the review comments on a pull request. They are comments made on a portion of the unified diff during a pull request review. Use with pagination parameters to control the number of results returned. + 5. get_review_comments - Get review threads on a pull request. Each thread contains logically grouped review comments made on the same code location during pull request reviews. Returns threads with metadata (isResolved, isOutdated, isCollapsed) and their associated comments. Use cursor-based pagination (perPage, after) to control results. 6. get_reviews - Get the reviews on a pull request. When asked for review comments, use get_review_comments method. 7. get_comments - Get comments on a pull request. Use this if user doesn't specifically want review comments. Use with pagination parameters to control the number of results returned. (string, required) diff --git a/pkg/github/__toolsnaps__/pull_request_read.snap b/pkg/github/__toolsnaps__/pull_request_read.snap index 434fba348..69b1bd901 100644 --- a/pkg/github/__toolsnaps__/pull_request_read.snap +++ b/pkg/github/__toolsnaps__/pull_request_read.snap @@ -15,7 +15,7 @@ "properties": { "method": { "type": "string", - "description": "Action to specify what pull request data needs to be retrieved from GitHub. \nPossible options: \n 1. get - Get details of a specific pull request.\n 2. get_diff - Get the diff of a pull request.\n 3. get_status - Get status of a head commit in a pull request. This reflects status of builds and checks.\n 4. get_files - Get the list of files changed in a pull request. Use with pagination parameters to control the number of results returned.\n 5. get_review_comments - Get the review comments on a pull request. They are comments made on a portion of the unified diff during a pull request review. Use with pagination parameters to control the number of results returned.\n 6. get_reviews - Get the reviews on a pull request. When asked for review comments, use get_review_comments method.\n 7. get_comments - Get comments on a pull request. Use this if user doesn't specifically want review comments. Use with pagination parameters to control the number of results returned.\n", + "description": "Action to specify what pull request data needs to be retrieved from GitHub. \nPossible options: \n 1. get - Get details of a specific pull request.\n 2. get_diff - Get the diff of a pull request.\n 3. get_status - Get status of a head commit in a pull request. This reflects status of builds and checks.\n 4. get_files - Get the list of files changed in a pull request. Use with pagination parameters to control the number of results returned.\n 5. get_review_comments - Get review threads on a pull request. Each thread contains logically grouped review comments made on the same code location during pull request reviews. Returns threads with metadata (isResolved, isOutdated, isCollapsed) and their associated comments. Use cursor-based pagination (perPage, after) to control results.\n 6. get_reviews - Get the reviews on a pull request. When asked for review comments, use get_review_comments method.\n 7. get_comments - Get comments on a pull request. Use this if user doesn't specifically want review comments. Use with pagination parameters to control the number of results returned.\n", "enum": [ "get", "get_diff", diff --git a/pkg/github/pullrequests.go b/pkg/github/pullrequests.go index 661384529..22794aa08 100644 --- a/pkg/github/pullrequests.go +++ b/pkg/github/pullrequests.go @@ -21,7 +21,7 @@ import ( ) // PullRequestRead creates a tool to get details of a specific pull request. -func PullRequestRead(getClient GetClientFn, cache *lockdown.RepoAccessCache, t translations.TranslationHelperFunc, flags FeatureFlags) (mcp.Tool, mcp.ToolHandlerFor[map[string]any, any]) { +func PullRequestRead(getClient GetClientFn, getGQLClient GetGQLClientFn, cache *lockdown.RepoAccessCache, t translations.TranslationHelperFunc, flags FeatureFlags) (mcp.Tool, mcp.ToolHandlerFor[map[string]any, any]) { schema := &jsonschema.Schema{ Type: "object", Properties: map[string]*jsonschema.Schema{ @@ -33,7 +33,7 @@ Possible options: 2. get_diff - Get the diff of a pull request. 3. get_status - Get status of a head commit in a pull request. This reflects status of builds and checks. 4. get_files - Get the list of files changed in a pull request. Use with pagination parameters to control the number of results returned. - 5. get_review_comments - Get the review comments on a pull request. They are comments made on a portion of the unified diff during a pull request review. Use with pagination parameters to control the number of results returned. + 5. get_review_comments - Get review threads on a pull request. Each thread contains logically grouped review comments made on the same code location during pull request reviews. Returns threads with metadata (isResolved, isOutdated, isCollapsed) and their associated comments. Use cursor-based pagination (perPage, after) to control results. 6. get_reviews - Get the reviews on a pull request. When asked for review comments, use get_review_comments method. 7. get_comments - Get comments on a pull request. Use this if user doesn't specifically want review comments. Use with pagination parameters to control the number of results returned. `, @@ -107,7 +107,15 @@ Possible options: result, err := GetPullRequestFiles(ctx, client, owner, repo, pullNumber, pagination) return result, nil, err case "get_review_comments": - result, err := GetPullRequestReviewComments(ctx, client, cache, owner, repo, pullNumber, pagination, flags) + gqlClient, err := getGQLClient(ctx) + if err != nil { + return utils.NewToolResultErrorFromErr("failed to get GitHub GQL client", err), nil, nil + } + cursorPagination, err := OptionalCursorPaginationParams(args) + if err != nil { + return utils.NewToolResultError(err.Error()), nil, nil + } + result, err := GetPullRequestReviewComments(ctx, gqlClient, cache, owner, repo, pullNumber, cursorPagination, flags) return result, nil, err case "get_reviews": result, err := GetPullRequestReviews(ctx, client, cache, owner, repo, pullNumber, flags) @@ -282,54 +290,124 @@ func GetPullRequestFiles(ctx context.Context, client *github.Client, owner, repo return utils.NewToolResultText(string(r)), nil } -func GetPullRequestReviewComments(ctx context.Context, client *github.Client, cache *lockdown.RepoAccessCache, owner, repo string, pullNumber int, pagination PaginationParams, ff FeatureFlags) (*mcp.CallToolResult, error) { - opts := &github.PullRequestListCommentsOptions{ - ListOptions: github.ListOptions{ - PerPage: pagination.PerPage, - Page: pagination.Page, - }, +// GraphQL types for review threads query +type reviewThreadsQuery struct { + Repository struct { + PullRequest struct { + ReviewThreads struct { + Nodes []reviewThreadNode + PageInfo pageInfoFragment + TotalCount githubv4.Int + } `graphql:"reviewThreads(first: $first, after: $after)"` + } `graphql:"pullRequest(number: $prNum)"` + } `graphql:"repository(owner: $owner, name: $repo)"` +} + +type reviewThreadNode struct { + ID githubv4.ID + IsResolved githubv4.Boolean + IsOutdated githubv4.Boolean + IsCollapsed githubv4.Boolean + Comments struct { + Nodes []reviewCommentNode + TotalCount githubv4.Int + } `graphql:"comments(first: $commentsPerThread)"` +} + +type reviewCommentNode struct { + ID githubv4.ID + Body githubv4.String + Path githubv4.String + Line *githubv4.Int + Author struct { + Login githubv4.String } + CreatedAt githubv4.DateTime + UpdatedAt githubv4.DateTime + URL githubv4.URI +} - comments, resp, err := client.PullRequests.ListComments(ctx, owner, repo, pullNumber, opts) +type pageInfoFragment struct { + HasNextPage githubv4.Boolean + HasPreviousPage githubv4.Boolean + StartCursor githubv4.String + EndCursor githubv4.String +} + +func GetPullRequestReviewComments(ctx context.Context, gqlClient *githubv4.Client, cache *lockdown.RepoAccessCache, owner, repo string, pullNumber int, pagination CursorPaginationParams, ff FeatureFlags) (*mcp.CallToolResult, error) { + // Convert pagination parameters to GraphQL format + gqlParams, err := pagination.ToGraphQLParams() if err != nil { - return ghErrors.NewGitHubAPIErrorResponse(ctx, - "failed to get pull request review comments", - resp, - err, - ), nil + return utils.NewToolResultError(fmt.Sprintf("invalid pagination parameters: %v", err)), nil } - defer func() { _ = resp.Body.Close() }() - if resp.StatusCode != http.StatusOK { - body, err := io.ReadAll(resp.Body) - if err != nil { - return nil, fmt.Errorf("failed to read response body: %w", err) - } - return utils.NewToolResultError(fmt.Sprintf("failed to get pull request review comments: %s", string(body))), nil + // Build variables for GraphQL query + vars := map[string]any{ + "owner": githubv4.String(owner), + "repo": githubv4.String(repo), + "prNum": githubv4.Int(int32(pullNumber)), //nolint:gosec // pullNumber is controlled by user input validation + "first": githubv4.Int(*gqlParams.First), + "commentsPerThread": githubv4.Int(100), + } + + // Add cursor if provided + if gqlParams.After != nil { + vars["after"] = githubv4.String(*gqlParams.After) + } else { + vars["after"] = (*githubv4.String)(nil) } + // Execute GraphQL query + var query reviewThreadsQuery + if err := gqlClient.Query(ctx, &query, vars); err != nil { + return ghErrors.NewGitHubGraphQLErrorResponse(ctx, + "failed to get pull request review threads", + err, + ), nil + } + + // Lockdown mode filtering if ff.LockdownMode { if cache == nil { return nil, fmt.Errorf("lockdown cache is not configured") } - filteredComments := make([]*github.PullRequestComment, 0, len(comments)) - for _, comment := range comments { - user := comment.GetUser() - if user == nil { - continue - } - isSafeContent, err := cache.IsSafeContent(ctx, user.GetLogin(), owner, repo) - if err != nil { - return utils.NewToolResultError(fmt.Sprintf("failed to check lockdown mode: %v", err)), nil - } - if isSafeContent { - filteredComments = append(filteredComments, comment) + + // Iterate through threads and filter comments + for i := range query.Repository.PullRequest.ReviewThreads.Nodes { + thread := &query.Repository.PullRequest.ReviewThreads.Nodes[i] + filteredComments := make([]reviewCommentNode, 0, len(thread.Comments.Nodes)) + + for _, comment := range thread.Comments.Nodes { + login := string(comment.Author.Login) + if login != "" { + isSafeContent, err := cache.IsSafeContent(ctx, login, owner, repo) + if err != nil { + return nil, fmt.Errorf("failed to check lockdown mode: %w", err) + } + if isSafeContent { + filteredComments = append(filteredComments, comment) + } + } } + + thread.Comments.Nodes = filteredComments + thread.Comments.TotalCount = githubv4.Int(int32(len(filteredComments))) //nolint:gosec // comment count is bounded by API limits } - comments = filteredComments } - r, err := json.Marshal(comments) + // Build response with review threads and pagination info + response := map[string]any{ + "reviewThreads": query.Repository.PullRequest.ReviewThreads.Nodes, + "pageInfo": map[string]any{ + "hasNextPage": query.Repository.PullRequest.ReviewThreads.PageInfo.HasNextPage, + "hasPreviousPage": query.Repository.PullRequest.ReviewThreads.PageInfo.HasPreviousPage, + "startCursor": string(query.Repository.PullRequest.ReviewThreads.PageInfo.StartCursor), + "endCursor": string(query.Repository.PullRequest.ReviewThreads.PageInfo.EndCursor), + }, + "totalCount": int(query.Repository.PullRequest.ReviewThreads.TotalCount), + } + + r, err := json.Marshal(response) if err != nil { return nil, fmt.Errorf("failed to marshal response: %w", err) } @@ -697,7 +775,7 @@ func UpdatePullRequest(getClient GetClientFn, getGQLClient GetGQLClientFn, t tra } `graphql:"repository(owner: $owner, name: $repo)"` } - err = gqlClient.Query(ctx, &prQuery, map[string]interface{}{ + err = gqlClient.Query(ctx, &prQuery, map[string]any{ "owner": githubv4.String(owner), "repo": githubv4.String(repo), "prNum": githubv4.Int(pullNumber), // #nosec G115 - pull request numbers are always small positive integers diff --git a/pkg/github/pullrequests_test.go b/pkg/github/pullrequests_test.go index 94313d4e3..b606da05b 100644 --- a/pkg/github/pullrequests_test.go +++ b/pkg/github/pullrequests_test.go @@ -9,6 +9,7 @@ import ( "github.com/github/github-mcp-server/internal/githubv4mock" "github.com/github/github-mcp-server/internal/toolsnaps" + "github.com/github/github-mcp-server/pkg/lockdown" "github.com/github/github-mcp-server/pkg/translations" "github.com/google/go-github/v79/github" "github.com/google/jsonschema-go/jsonschema" @@ -22,7 +23,7 @@ import ( func Test_GetPullRequest(t *testing.T) { // Verify tool definition once mockClient := github.NewClient(nil) - tool, _ := PullRequestRead(stubGetClientFn(mockClient), stubRepoAccessCache(githubv4.NewClient(githubv4mock.NewMockedHTTPClient()), 5*time.Minute), translations.NullTranslationHelper, stubFeatureFlags(map[string]bool{"lockdown-mode": false})) + tool, _ := PullRequestRead(stubGetClientFn(mockClient), stubGetGQLClientFn(nil), stubRepoAccessCache(githubv4.NewClient(githubv4mock.NewMockedHTTPClient()), 5*time.Minute), translations.NullTranslationHelper, stubFeatureFlags(map[string]bool{"lockdown-mode": false})) require.NoError(t, toolsnaps.Test(tool.Name, tool)) assert.Equal(t, "pull_request_read", tool.Name) @@ -104,7 +105,7 @@ func Test_GetPullRequest(t *testing.T) { t.Run(tc.name, func(t *testing.T) { // Setup client with mock client := github.NewClient(tc.mockedClient) - _, handler := PullRequestRead(stubGetClientFn(client), stubRepoAccessCache(githubv4.NewClient(githubv4mock.NewMockedHTTPClient()), 5*time.Minute), translations.NullTranslationHelper, stubFeatureFlags(map[string]bool{"lockdown-mode": false})) + _, handler := PullRequestRead(stubGetClientFn(client), stubGetGQLClientFn(nil), stubRepoAccessCache(githubv4.NewClient(githubv4mock.NewMockedHTTPClient()), 5*time.Minute), translations.NullTranslationHelper, stubFeatureFlags(map[string]bool{"lockdown-mode": false})) // Create call request request := createMCPRequest(tc.requestArgs) @@ -1142,7 +1143,7 @@ func Test_SearchPullRequests(t *testing.T) { func Test_GetPullRequestFiles(t *testing.T) { // Verify tool definition once mockClient := github.NewClient(nil) - tool, _ := PullRequestRead(stubGetClientFn(mockClient), stubRepoAccessCache(githubv4.NewClient(githubv4mock.NewMockedHTTPClient()), 5*time.Minute), translations.NullTranslationHelper, stubFeatureFlags(map[string]bool{"lockdown-mode": false})) + tool, _ := PullRequestRead(stubGetClientFn(mockClient), stubGetGQLClientFn(nil), stubRepoAccessCache(githubv4.NewClient(githubv4mock.NewMockedHTTPClient()), 5*time.Minute), translations.NullTranslationHelper, stubFeatureFlags(map[string]bool{"lockdown-mode": false})) require.NoError(t, toolsnaps.Test(tool.Name, tool)) assert.Equal(t, "pull_request_read", tool.Name) @@ -1246,7 +1247,7 @@ func Test_GetPullRequestFiles(t *testing.T) { t.Run(tc.name, func(t *testing.T) { // Setup client with mock client := github.NewClient(tc.mockedClient) - _, handler := PullRequestRead(stubGetClientFn(client), stubRepoAccessCache(githubv4.NewClient(githubv4mock.NewMockedHTTPClient()), 5*time.Minute), translations.NullTranslationHelper, stubFeatureFlags(map[string]bool{"lockdown-mode": false})) + _, handler := PullRequestRead(stubGetClientFn(client), stubGetGQLClientFn(nil), stubRepoAccessCache(githubv4.NewClient(githubv4mock.NewMockedHTTPClient()), 5*time.Minute), translations.NullTranslationHelper, stubFeatureFlags(map[string]bool{"lockdown-mode": false})) // Create call request request := createMCPRequest(tc.requestArgs) @@ -1287,7 +1288,7 @@ func Test_GetPullRequestFiles(t *testing.T) { func Test_GetPullRequestStatus(t *testing.T) { // Verify tool definition once mockClient := github.NewClient(nil) - tool, _ := PullRequestRead(stubGetClientFn(mockClient), stubRepoAccessCache(githubv4.NewClient(githubv4mock.NewMockedHTTPClient()), 5*time.Minute), translations.NullTranslationHelper, stubFeatureFlags(map[string]bool{"lockdown-mode": false})) + tool, _ := PullRequestRead(stubGetClientFn(mockClient), stubGetGQLClientFn(nil), stubRepoAccessCache(githubv4.NewClient(githubv4mock.NewMockedHTTPClient()), 5*time.Minute), translations.NullTranslationHelper, stubFeatureFlags(map[string]bool{"lockdown-mode": false})) require.NoError(t, toolsnaps.Test(tool.Name, tool)) assert.Equal(t, "pull_request_read", tool.Name) @@ -1415,7 +1416,7 @@ func Test_GetPullRequestStatus(t *testing.T) { t.Run(tc.name, func(t *testing.T) { // Setup client with mock client := github.NewClient(tc.mockedClient) - _, handler := PullRequestRead(stubGetClientFn(client), stubRepoAccessCache(githubv4.NewClient(nil), 5*time.Minute), translations.NullTranslationHelper, stubFeatureFlags(map[string]bool{"lockdown-mode": false})) + _, handler := PullRequestRead(stubGetClientFn(client), stubGetGQLClientFn(nil), stubRepoAccessCache(githubv4.NewClient(nil), 5*time.Minute), translations.NullTranslationHelper, stubFeatureFlags(map[string]bool{"lockdown-mode": false})) // Create call request request := createMCPRequest(tc.requestArgs) @@ -1578,7 +1579,7 @@ func Test_UpdatePullRequestBranch(t *testing.T) { func Test_GetPullRequestComments(t *testing.T) { // Verify tool definition once mockClient := github.NewClient(nil) - tool, _ := PullRequestRead(stubGetClientFn(mockClient), stubRepoAccessCache(githubv4.NewClient(nil), 5*time.Minute), translations.NullTranslationHelper, stubFeatureFlags(map[string]bool{"lockdown-mode": false})) + tool, _ := PullRequestRead(stubGetClientFn(mockClient), stubGetGQLClientFn(nil), stubRepoAccessCache(githubv4.NewClient(nil), 5*time.Minute), translations.NullTranslationHelper, stubFeatureFlags(map[string]bool{"lockdown-mode": false})) require.NoError(t, toolsnaps.Test(tool.Name, tool)) assert.Equal(t, "pull_request_read", tool.Name) @@ -1590,52 +1591,80 @@ func Test_GetPullRequestComments(t *testing.T) { assert.Contains(t, schema.Properties, "pullNumber") assert.ElementsMatch(t, schema.Required, []string{"method", "owner", "repo", "pullNumber"}) - // Setup mock PR comments for success case - mockComments := []*github.PullRequestComment{ - { - ID: github.Ptr(int64(101)), - Body: github.Ptr("This looks good"), - HTMLURL: github.Ptr("https://github.com/owner/repo/pull/42#discussion_r101"), - User: &github.User{ - Login: github.Ptr("reviewer1"), - }, - Path: github.Ptr("file1.go"), - Position: github.Ptr(5), - CommitID: github.Ptr("abcdef123456"), - CreatedAt: &github.Timestamp{Time: time.Now().Add(-24 * time.Hour)}, - UpdatedAt: &github.Timestamp{Time: time.Now().Add(-24 * time.Hour)}, - }, - { - ID: github.Ptr(int64(102)), - Body: github.Ptr("Please fix this"), - HTMLURL: github.Ptr("https://github.com/owner/repo/pull/42#discussion_r102"), - User: &github.User{ - Login: github.Ptr("reviewer2"), - }, - Path: github.Ptr("file2.go"), - Position: github.Ptr(10), - CommitID: github.Ptr("abcdef123456"), - CreatedAt: &github.Timestamp{Time: time.Now().Add(-12 * time.Hour)}, - UpdatedAt: &github.Timestamp{Time: time.Now().Add(-12 * time.Hour)}, - }, - } - tests := []struct { - name string - mockedClient *http.Client - gqlHTTPClient *http.Client - requestArgs map[string]interface{} - expectError bool - expectedComments []*github.PullRequestComment - expectedErrMsg string - lockdownEnabled bool + name string + gqlHTTPClient *http.Client + requestArgs map[string]interface{} + expectError bool + expectedErrMsg string + lockdownEnabled bool + validateResult func(t *testing.T, textContent string) }{ { - name: "successful comments fetch", - mockedClient: mock.NewMockedHTTPClient( - mock.WithRequestMatch( - mock.GetReposPullsCommentsByOwnerByRepoByPullNumber, - mockComments, + name: "successful review threads fetch", + gqlHTTPClient: githubv4mock.NewMockedHTTPClient( + githubv4mock.NewQueryMatcher( + reviewThreadsQuery{}, + map[string]interface{}{ + "owner": githubv4.String("owner"), + "repo": githubv4.String("repo"), + "prNum": githubv4.Int(42), + "first": githubv4.Int(30), + "commentsPerThread": githubv4.Int(100), + "after": (*githubv4.String)(nil), + }, + githubv4mock.DataResponse(map[string]any{ + "repository": map[string]any{ + "pullRequest": map[string]any{ + "reviewThreads": map[string]any{ + "nodes": []map[string]any{ + { + "id": "RT_kwDOA0xdyM4AX1Yz", + "isResolved": false, + "isOutdated": false, + "isCollapsed": false, + "comments": map[string]any{ + "totalCount": 2, + "nodes": []map[string]any{ + { + "id": "PRRC_kwDOA0xdyM4AX1Y0", + "body": "This looks good", + "path": "file1.go", + "line": 5, + "author": map[string]any{ + "login": "reviewer1", + }, + "createdAt": "2024-01-01T12:00:00Z", + "updatedAt": "2024-01-01T12:00:00Z", + "url": "https://github.com/owner/repo/pull/42#discussion_r101", + }, + { + "id": "PRRC_kwDOA0xdyM4AX1Y1", + "body": "Please fix this", + "path": "file1.go", + "line": 10, + "author": map[string]any{ + "login": "reviewer2", + }, + "createdAt": "2024-01-01T13:00:00Z", + "updatedAt": "2024-01-01T13:00:00Z", + "url": "https://github.com/owner/repo/pull/42#discussion_r102", + }, + }, + }, + }, + }, + "pageInfo": map[string]any{ + "hasNextPage": false, + "hasPreviousPage": false, + "startCursor": "cursor1", + "endCursor": "cursor2", + }, + "totalCount": 1, + }, + }, + }, + }), ), ), requestArgs: map[string]interface{}{ @@ -1644,18 +1673,63 @@ func Test_GetPullRequestComments(t *testing.T) { "repo": "repo", "pullNumber": float64(42), }, - expectError: false, - expectedComments: mockComments, + expectError: false, + validateResult: func(t *testing.T, textContent string) { + var result map[string]interface{} + err := json.Unmarshal([]byte(textContent), &result) + require.NoError(t, err) + + // Validate response structure + assert.Contains(t, result, "reviewThreads") + assert.Contains(t, result, "pageInfo") + assert.Contains(t, result, "totalCount") + + // Validate review threads + threads := result["reviewThreads"].([]interface{}) + assert.Len(t, threads, 1) + + thread := threads[0].(map[string]interface{}) + assert.Equal(t, "RT_kwDOA0xdyM4AX1Yz", thread["ID"]) + assert.Equal(t, false, thread["IsResolved"]) + assert.Equal(t, false, thread["IsOutdated"]) + assert.Equal(t, false, thread["IsCollapsed"]) + + // Validate comments within thread + comments := thread["Comments"].(map[string]interface{}) + commentNodes := comments["Nodes"].([]interface{}) + assert.Len(t, commentNodes, 2) + + // Validate first comment + comment1 := commentNodes[0].(map[string]interface{}) + assert.Equal(t, "PRRC_kwDOA0xdyM4AX1Y0", comment1["ID"]) + assert.Equal(t, "This looks good", comment1["Body"]) + assert.Equal(t, "file1.go", comment1["Path"]) + + // Validate pagination info + pageInfo := result["pageInfo"].(map[string]interface{}) + assert.Equal(t, false, pageInfo["hasNextPage"]) + assert.Equal(t, false, pageInfo["hasPreviousPage"]) + assert.Equal(t, "cursor1", pageInfo["startCursor"]) + assert.Equal(t, "cursor2", pageInfo["endCursor"]) + + // Validate total count + assert.Equal(t, float64(1), result["totalCount"]) + }, }, { - name: "comments fetch fails", - mockedClient: mock.NewMockedHTTPClient( - mock.WithRequestMatchHandler( - mock.GetReposPullsCommentsByOwnerByRepoByPullNumber, - http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) { - w.WriteHeader(http.StatusNotFound) - _, _ = w.Write([]byte(`{"message": "Not Found"}`)) - }), + name: "review threads fetch fails", + gqlHTTPClient: githubv4mock.NewMockedHTTPClient( + githubv4mock.NewQueryMatcher( + reviewThreadsQuery{}, + map[string]interface{}{ + "owner": githubv4.String("owner"), + "repo": githubv4.String("repo"), + "prNum": githubv4.Int(999), + "first": githubv4.Int(30), + "commentsPerThread": githubv4.Int(100), + "after": (*githubv4.String)(nil), + }, + githubv4mock.ErrorResponse("Could not resolve to a PullRequest with the number of 999."), ), ), requestArgs: map[string]interface{}{ @@ -1665,59 +1739,129 @@ func Test_GetPullRequestComments(t *testing.T) { "pullNumber": float64(999), }, expectError: true, - expectedErrMsg: "failed to get pull request review comments", + expectedErrMsg: "failed to get pull request review threads", }, { name: "lockdown enabled filters review comments without push access", - mockedClient: mock.NewMockedHTTPClient( - mock.WithRequestMatch( - mock.GetReposPullsCommentsByOwnerByRepoByPullNumber, - []*github.PullRequestComment{ - { - ID: github.Ptr(int64(2010)), - Body: github.Ptr("Maintainer review comment"), - User: &github.User{Login: github.Ptr("maintainer")}, - }, - { - ID: github.Ptr(int64(2011)), - Body: github.Ptr("External review comment"), - User: &github.User{Login: github.Ptr("testuser")}, - }, + gqlHTTPClient: githubv4mock.NewMockedHTTPClient( + githubv4mock.NewQueryMatcher( + reviewThreadsQuery{}, + map[string]interface{}{ + "owner": githubv4.String("owner"), + "repo": githubv4.String("repo"), + "prNum": githubv4.Int(42), + "first": githubv4.Int(30), + "commentsPerThread": githubv4.Int(100), + "after": (*githubv4.String)(nil), }, + githubv4mock.DataResponse(map[string]any{ + "repository": map[string]any{ + "pullRequest": map[string]any{ + "reviewThreads": map[string]any{ + "nodes": []map[string]any{ + { + "id": "RT_kwDOA0xdyM4AX1Yz", + "isResolved": false, + "isOutdated": false, + "isCollapsed": false, + "comments": map[string]any{ + "totalCount": 2, + "nodes": []map[string]any{ + { + "id": "PRRC_kwDOA0xdyM4AX1Y0", + "body": "Maintainer review comment", + "path": "file1.go", + "line": 5, + "author": map[string]any{ + "login": "maintainer", + }, + "createdAt": "2024-01-01T12:00:00Z", + "updatedAt": "2024-01-01T12:00:00Z", + "url": "https://github.com/owner/repo/pull/42#discussion_r2010", + }, + { + "id": "PRRC_kwDOA0xdyM4AX1Y1", + "body": "External review comment", + "path": "file1.go", + "line": 10, + "author": map[string]any{ + "login": "testuser", + }, + "createdAt": "2024-01-01T13:00:00Z", + "updatedAt": "2024-01-01T13:00:00Z", + "url": "https://github.com/owner/repo/pull/42#discussion_r2011", + }, + }, + }, + }, + }, + "pageInfo": map[string]any{ + "hasNextPage": false, + "hasPreviousPage": false, + "startCursor": "cursor1", + "endCursor": "cursor2", + }, + "totalCount": 1, + }, + }, + }, + }), ), ), - gqlHTTPClient: newRepoAccessHTTPClient(), requestArgs: map[string]interface{}{ "method": "get_review_comments", "owner": "owner", "repo": "repo", "pullNumber": float64(42), }, - expectError: false, - expectedComments: []*github.PullRequestComment{ - { - ID: github.Ptr(int64(2010)), - Body: github.Ptr("Maintainer review comment"), - User: &github.User{Login: github.Ptr("maintainer")}, - }, - }, + expectError: false, lockdownEnabled: true, + validateResult: func(t *testing.T, textContent string) { + var result map[string]interface{} + err := json.Unmarshal([]byte(textContent), &result) + require.NoError(t, err) + + // Validate that only maintainer comment is returned + threads := result["reviewThreads"].([]interface{}) + assert.Len(t, threads, 1) + + thread := threads[0].(map[string]interface{}) + comments := thread["Comments"].(map[string]interface{}) + + // Should only have 1 comment (maintainer) after filtering + assert.Equal(t, float64(1), comments["TotalCount"]) + + commentNodes := comments["Nodes"].([]interface{}) + assert.Len(t, commentNodes, 1) + + comment := commentNodes[0].(map[string]interface{}) + author := comment["Author"].(map[string]interface{}) + assert.Equal(t, "maintainer", author["Login"]) + assert.Equal(t, "Maintainer review comment", comment["Body"]) + }, }, } for _, tc := range tests { t.Run(tc.name, func(t *testing.T) { - // Setup client with mock - client := github.NewClient(tc.mockedClient) + // Setup GraphQL client with mock var gqlClient *githubv4.Client if tc.gqlHTTPClient != nil { gqlClient = githubv4.NewClient(tc.gqlHTTPClient) } else { gqlClient = githubv4.NewClient(nil) } - cache := stubRepoAccessCache(gqlClient, 5*time.Minute) + + // Setup cache for lockdown mode + var cache *lockdown.RepoAccessCache + if tc.lockdownEnabled { + cache = stubRepoAccessCache(githubv4.NewClient(newRepoAccessHTTPClient()), 5*time.Minute) + } else { + cache = stubRepoAccessCache(gqlClient, 5*time.Minute) + } + flags := stubFeatureFlags(map[string]bool{"lockdown-mode": tc.lockdownEnabled}) - _, handler := PullRequestRead(stubGetClientFn(client), cache, translations.NullTranslationHelper, flags) + _, handler := PullRequestRead(stubGetClientFn(github.NewClient(nil)), stubGetGQLClientFn(gqlClient), cache, translations.NullTranslationHelper, flags) // Create call request request := createMCPRequest(tc.requestArgs) @@ -1740,19 +1884,9 @@ func Test_GetPullRequestComments(t *testing.T) { // Parse the result and get the text content if no error textContent := getTextResult(t, result) - // Unmarshal and verify the result - var returnedComments []*github.PullRequestComment - err = json.Unmarshal([]byte(textContent.Text), &returnedComments) - require.NoError(t, err) - assert.Len(t, returnedComments, len(tc.expectedComments)) - for i, comment := range returnedComments { - require.NotNil(t, tc.expectedComments[i].User) - require.NotNil(t, comment.User) - assert.Equal(t, tc.expectedComments[i].GetID(), comment.GetID()) - assert.Equal(t, tc.expectedComments[i].GetBody(), comment.GetBody()) - assert.Equal(t, tc.expectedComments[i].GetUser().GetLogin(), comment.GetUser().GetLogin()) - assert.Equal(t, tc.expectedComments[i].GetPath(), comment.GetPath()) - assert.Equal(t, tc.expectedComments[i].GetHTMLURL(), comment.GetHTMLURL()) + // Use custom validation if provided + if tc.validateResult != nil { + tc.validateResult(t, textContent.Text) } }) } @@ -1761,7 +1895,7 @@ func Test_GetPullRequestComments(t *testing.T) { func Test_GetPullRequestReviews(t *testing.T) { // Verify tool definition once mockClient := github.NewClient(nil) - tool, _ := PullRequestRead(stubGetClientFn(mockClient), stubRepoAccessCache(githubv4.NewClient(nil), 5*time.Minute), translations.NullTranslationHelper, stubFeatureFlags(map[string]bool{"lockdown-mode": false})) + tool, _ := PullRequestRead(stubGetClientFn(mockClient), stubGetGQLClientFn(nil), stubRepoAccessCache(githubv4.NewClient(nil), 5*time.Minute), translations.NullTranslationHelper, stubFeatureFlags(map[string]bool{"lockdown-mode": false})) require.NoError(t, toolsnaps.Test(tool.Name, tool)) assert.Equal(t, "pull_request_read", tool.Name) @@ -1899,7 +2033,7 @@ func Test_GetPullRequestReviews(t *testing.T) { } cache := stubRepoAccessCache(gqlClient, 5*time.Minute) flags := stubFeatureFlags(map[string]bool{"lockdown-mode": tc.lockdownEnabled}) - _, handler := PullRequestRead(stubGetClientFn(client), cache, translations.NullTranslationHelper, flags) + _, handler := PullRequestRead(stubGetClientFn(client), stubGetGQLClientFn(nil), cache, translations.NullTranslationHelper, flags) // Create call request request := createMCPRequest(tc.requestArgs) @@ -2974,7 +3108,7 @@ func TestGetPullRequestDiff(t *testing.T) { // Verify tool definition once mockClient := github.NewClient(nil) - tool, _ := PullRequestRead(stubGetClientFn(mockClient), stubRepoAccessCache(githubv4.NewClient(nil), 5*time.Minute), translations.NullTranslationHelper, stubFeatureFlags(map[string]bool{"lockdown-mode": false})) + tool, _ := PullRequestRead(stubGetClientFn(mockClient), stubGetGQLClientFn(nil), stubRepoAccessCache(githubv4.NewClient(nil), 5*time.Minute), translations.NullTranslationHelper, stubFeatureFlags(map[string]bool{"lockdown-mode": false})) require.NoError(t, toolsnaps.Test(tool.Name, tool)) assert.Equal(t, "pull_request_read", tool.Name) @@ -3033,7 +3167,7 @@ index 5d6e7b2..8a4f5c3 100644 // Setup client with mock client := github.NewClient(tc.mockedClient) - _, handler := PullRequestRead(stubGetClientFn(client), stubRepoAccessCache(githubv4.NewClient(nil), 5*time.Minute), translations.NullTranslationHelper, stubFeatureFlags(map[string]bool{"lockdown-mode": false})) + _, handler := PullRequestRead(stubGetClientFn(client), stubGetGQLClientFn(nil), stubRepoAccessCache(githubv4.NewClient(nil), 5*time.Minute), translations.NullTranslationHelper, stubFeatureFlags(map[string]bool{"lockdown-mode": false})) // Create call request request := createMCPRequest(tc.requestArgs) diff --git a/pkg/github/tools.go b/pkg/github/tools.go index f21a9ae5b..ffb7f1852 100644 --- a/pkg/github/tools.go +++ b/pkg/github/tools.go @@ -225,7 +225,7 @@ func DefaultToolsetGroup(readOnly bool, getClient GetClientFn, getGQLClient GetG ) pullRequests := toolsets.NewToolset(ToolsetMetadataPullRequests.ID, ToolsetMetadataPullRequests.Description). AddReadTools( - toolsets.NewServerTool(PullRequestRead(getClient, cache, t, flags)), + toolsets.NewServerTool(PullRequestRead(getClient, getGQLClient, cache, t, flags)), toolsets.NewServerTool(ListPullRequests(getClient, t)), toolsets.NewServerTool(SearchPullRequests(getClient, t)), ). From 13909ece1265c2e6702b595f89ff72a8a9e9b59e Mon Sep 17 00:00:00 2001 From: Tommaso Moro <37270480+tommaso-moro@users.noreply.github.com> Date: Tue, 16 Dec 2025 17:50:30 +0000 Subject: [PATCH 4/4] add missing get_workflow_job_logs (#1625) --- pkg/github/deprecated_tool_aliases.go | 1 + 1 file changed, 1 insertion(+) diff --git a/pkg/github/deprecated_tool_aliases.go b/pkg/github/deprecated_tool_aliases.go index d83db1ed6..1aa18e0e6 100644 --- a/pkg/github/deprecated_tool_aliases.go +++ b/pkg/github/deprecated_tool_aliases.go @@ -20,6 +20,7 @@ var DeprecatedToolAliases = map[string]string{ "get_workflow_job": "actions_get", "get_workflow_run_usage": "actions_get", "get_workflow_run_logs": "actions_get", + "get_workflow_job_logs": "actions_get", "download_workflow_run_artifact": "actions_get", "run_workflow": "actions_run_trigger", "rerun_workflow_run": "actions_run_trigger",