diff --git a/README.md b/README.md index 059256aaa..b015e984b 100644 --- a/README.md +++ b/README.md @@ -524,30 +524,6 @@ The following sets of tools are available: - `run_id`: The ID of the workflow run. Required for all methods except 'run_workflow'. (number, optional) - `workflow_id`: The workflow ID (numeric) or workflow file name (e.g., main.yml, ci.yaml). Required for 'run_workflow' method. (string, optional) -- **cancel_workflow_run** - Cancel workflow run - - `owner`: Repository owner (string, required) - - `repo`: Repository name (string, required) - - `run_id`: The unique identifier of the workflow run (number, required) - -- **delete_workflow_run_logs** - Delete workflow logs - - `owner`: Repository owner (string, required) - - `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) - - `owner`: Repository owner (string, required) - - `repo`: Repository name (string, required) - -- **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) - - `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) - - `tail_lines`: Number of lines to return from the end of the log (number, optional) - - **get_job_logs** - Get GitHub Actions workflow job logs - `failed_only`: When true, gets logs for all failed jobs in the workflow run specified by run_id. Requires run_id to be provided. (boolean, optional) - `job_id`: The unique identifier of the workflow job. Required when getting logs for a single job. (number, optional) @@ -557,70 +533,6 @@ The following sets of tools are available: - `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__/cancel_workflow_run.snap b/pkg/github/__toolsnaps__/cancel_workflow_run.snap deleted file mode 100644 index 83eb31a7f..000000000 --- a/pkg/github/__toolsnaps__/cancel_workflow_run.snap +++ /dev/null @@ -1,29 +0,0 @@ -{ - "annotations": { - "title": "Cancel workflow run" - }, - "description": "Cancel a workflow run", - "inputSchema": { - "type": "object", - "required": [ - "owner", - "repo", - "run_id" - ], - "properties": { - "owner": { - "type": "string", - "description": "Repository owner" - }, - "repo": { - "type": "string", - "description": "Repository name" - }, - "run_id": { - "type": "number", - "description": "The unique identifier of the workflow run" - } - } - }, - "name": "cancel_workflow_run" -} \ No newline at end of file diff --git a/pkg/github/__toolsnaps__/delete_workflow_run_logs.snap b/pkg/github/__toolsnaps__/delete_workflow_run_logs.snap deleted file mode 100644 index fc9a5cd46..000000000 --- a/pkg/github/__toolsnaps__/delete_workflow_run_logs.snap +++ /dev/null @@ -1,30 +0,0 @@ -{ - "annotations": { - "destructiveHint": true, - "title": "Delete workflow logs" - }, - "description": "Delete logs for a workflow run", - "inputSchema": { - "type": "object", - "required": [ - "owner", - "repo", - "run_id" - ], - "properties": { - "owner": { - "type": "string", - "description": "Repository owner" - }, - "repo": { - "type": "string", - "description": "Repository name" - }, - "run_id": { - "type": "number", - "description": "The unique identifier of the workflow run" - } - } - }, - "name": "delete_workflow_run_logs" -} \ No newline at end of file diff --git a/pkg/github/__toolsnaps__/download_workflow_run_artifact.snap b/pkg/github/__toolsnaps__/download_workflow_run_artifact.snap deleted file mode 100644 index c4d89872c..000000000 --- a/pkg/github/__toolsnaps__/download_workflow_run_artifact.snap +++ /dev/null @@ -1,30 +0,0 @@ -{ - "annotations": { - "readOnlyHint": true, - "title": "Download workflow artifact" - }, - "description": "Get download URL for a workflow run artifact", - "inputSchema": { - "type": "object", - "required": [ - "owner", - "repo", - "artifact_id" - ], - "properties": { - "artifact_id": { - "type": "number", - "description": "The unique identifier of the artifact" - }, - "owner": { - "type": "string", - "description": "Repository owner" - }, - "repo": { - "type": "string", - "description": "Repository name" - } - } - }, - "name": "download_workflow_run_artifact" -} \ No newline at end of file diff --git a/pkg/github/__toolsnaps__/get_job_logs.snap b/pkg/github/__toolsnaps__/get_job_logs.snap deleted file mode 100644 index 8b2319527..000000000 --- a/pkg/github/__toolsnaps__/get_job_logs.snap +++ /dev/null @@ -1,46 +0,0 @@ -{ - "annotations": { - "readOnlyHint": true, - "title": "Get job logs" - }, - "description": "Download logs for a specific workflow job or efficiently get all failed job logs for a workflow run", - "inputSchema": { - "type": "object", - "required": [ - "owner", - "repo" - ], - "properties": { - "failed_only": { - "type": "boolean", - "description": "When true, gets logs for all failed jobs in run_id" - }, - "job_id": { - "type": "number", - "description": "The unique identifier of the workflow job (required for single job logs)" - }, - "owner": { - "type": "string", - "description": "Repository owner" - }, - "repo": { - "type": "string", - "description": "Repository name" - }, - "return_content": { - "type": "boolean", - "description": "Returns actual log content instead of URLs" - }, - "run_id": { - "type": "number", - "description": "Workflow run ID (required when using failed_only)" - }, - "tail_lines": { - "type": "number", - "description": "Number of lines to return from the end of the log", - "default": 500 - } - } - }, - "name": "get_job_logs" -} \ No newline at end of file diff --git a/pkg/github/__toolsnaps__/get_workflow_run.snap b/pkg/github/__toolsnaps__/get_workflow_run.snap deleted file mode 100644 index 37921ffad..000000000 --- a/pkg/github/__toolsnaps__/get_workflow_run.snap +++ /dev/null @@ -1,30 +0,0 @@ -{ - "annotations": { - "readOnlyHint": true, - "title": "Get workflow run" - }, - "description": "Get details of a specific workflow run", - "inputSchema": { - "type": "object", - "required": [ - "owner", - "repo", - "run_id" - ], - "properties": { - "owner": { - "type": "string", - "description": "Repository owner" - }, - "repo": { - "type": "string", - "description": "Repository name" - }, - "run_id": { - "type": "number", - "description": "The unique identifier of the workflow run" - } - } - }, - "name": "get_workflow_run" -} \ No newline at end of file diff --git a/pkg/github/__toolsnaps__/get_workflow_run_logs.snap b/pkg/github/__toolsnaps__/get_workflow_run_logs.snap deleted file mode 100644 index 77fb619b7..000000000 --- a/pkg/github/__toolsnaps__/get_workflow_run_logs.snap +++ /dev/null @@ -1,30 +0,0 @@ -{ - "annotations": { - "readOnlyHint": true, - "title": "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)", - "inputSchema": { - "type": "object", - "required": [ - "owner", - "repo", - "run_id" - ], - "properties": { - "owner": { - "type": "string", - "description": "Repository owner" - }, - "repo": { - "type": "string", - "description": "Repository name" - }, - "run_id": { - "type": "number", - "description": "The unique identifier of the workflow run" - } - } - }, - "name": "get_workflow_run_logs" -} \ No newline at end of file diff --git a/pkg/github/__toolsnaps__/get_workflow_run_usage.snap b/pkg/github/__toolsnaps__/get_workflow_run_usage.snap deleted file mode 100644 index c9fe49f96..000000000 --- a/pkg/github/__toolsnaps__/get_workflow_run_usage.snap +++ /dev/null @@ -1,30 +0,0 @@ -{ - "annotations": { - "readOnlyHint": true, - "title": "Get workflow usage" - }, - "description": "Get usage metrics for a workflow run", - "inputSchema": { - "type": "object", - "required": [ - "owner", - "repo", - "run_id" - ], - "properties": { - "owner": { - "type": "string", - "description": "Repository owner" - }, - "repo": { - "type": "string", - "description": "Repository name" - }, - "run_id": { - "type": "number", - "description": "The unique identifier of the workflow run" - } - } - }, - "name": "get_workflow_run_usage" -} \ No newline at end of file diff --git a/pkg/github/__toolsnaps__/list_workflow_jobs.snap b/pkg/github/__toolsnaps__/list_workflow_jobs.snap deleted file mode 100644 index 59ff75afc..000000000 --- a/pkg/github/__toolsnaps__/list_workflow_jobs.snap +++ /dev/null @@ -1,49 +0,0 @@ -{ - "annotations": { - "readOnlyHint": true, - "title": "List workflow jobs" - }, - "description": "List jobs for a specific workflow run", - "inputSchema": { - "type": "object", - "required": [ - "owner", - "repo", - "run_id" - ], - "properties": { - "filter": { - "type": "string", - "description": "Filters jobs by their completed_at timestamp", - "enum": [ - "latest", - "all" - ] - }, - "owner": { - "type": "string", - "description": "Repository owner" - }, - "page": { - "type": "number", - "description": "Page number for pagination (min 1)", - "minimum": 1 - }, - "perPage": { - "type": "number", - "description": "Results per page for pagination (min 1, max 100)", - "minimum": 1, - "maximum": 100 - }, - "repo": { - "type": "string", - "description": "Repository name" - }, - "run_id": { - "type": "number", - "description": "The unique identifier of the workflow run" - } - } - }, - "name": "list_workflow_jobs" -} \ No newline at end of file diff --git a/pkg/github/__toolsnaps__/list_workflow_run_artifacts.snap b/pkg/github/__toolsnaps__/list_workflow_run_artifacts.snap deleted file mode 100644 index 6d6332d74..000000000 --- a/pkg/github/__toolsnaps__/list_workflow_run_artifacts.snap +++ /dev/null @@ -1,41 +0,0 @@ -{ - "annotations": { - "readOnlyHint": true, - "title": "List workflow artifacts" - }, - "description": "List artifacts for a workflow run", - "inputSchema": { - "type": "object", - "required": [ - "owner", - "repo", - "run_id" - ], - "properties": { - "owner": { - "type": "string", - "description": "Repository owner" - }, - "page": { - "type": "number", - "description": "Page number for pagination (min 1)", - "minimum": 1 - }, - "perPage": { - "type": "number", - "description": "Results per page for pagination (min 1, max 100)", - "minimum": 1, - "maximum": 100 - }, - "repo": { - "type": "string", - "description": "Repository name" - }, - "run_id": { - "type": "number", - "description": "The unique identifier of the workflow run" - } - } - }, - "name": "list_workflow_run_artifacts" -} \ No newline at end of file diff --git a/pkg/github/__toolsnaps__/list_workflow_runs.snap b/pkg/github/__toolsnaps__/list_workflow_runs.snap deleted file mode 100644 index e5353f490..000000000 --- a/pkg/github/__toolsnaps__/list_workflow_runs.snap +++ /dev/null @@ -1,98 +0,0 @@ -{ - "annotations": { - "readOnlyHint": true, - "title": "List workflow runs" - }, - "description": "List workflow runs for a specific workflow", - "inputSchema": { - "type": "object", - "required": [ - "owner", - "repo", - "workflow_id" - ], - "properties": { - "actor": { - "type": "string", - "description": "Returns someone's workflow runs. Use the login for the user who created the workflow run." - }, - "branch": { - "type": "string", - "description": "Returns workflow runs associated with a branch. Use the name of the branch." - }, - "event": { - "type": "string", - "description": "Returns workflow runs for 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" - ] - }, - "owner": { - "type": "string", - "description": "Repository owner" - }, - "page": { - "type": "number", - "description": "Page number for pagination (min 1)", - "minimum": 1 - }, - "perPage": { - "type": "number", - "description": "Results per page for pagination (min 1, max 100)", - "minimum": 1, - "maximum": 100 - }, - "repo": { - "type": "string", - "description": "Repository name" - }, - "status": { - "type": "string", - "description": "Returns workflow runs with the check run status", - "enum": [ - "queued", - "in_progress", - "completed", - "requested", - "waiting" - ] - }, - "workflow_id": { - "type": "string", - "description": "The workflow ID or workflow file name" - } - } - }, - "name": "list_workflow_runs" -} \ No newline at end of file diff --git a/pkg/github/__toolsnaps__/list_workflows.snap b/pkg/github/__toolsnaps__/list_workflows.snap deleted file mode 100644 index f3f52f042..000000000 --- a/pkg/github/__toolsnaps__/list_workflows.snap +++ /dev/null @@ -1,36 +0,0 @@ -{ - "annotations": { - "readOnlyHint": true, - "title": "List workflows" - }, - "description": "List workflows in a repository", - "inputSchema": { - "type": "object", - "required": [ - "owner", - "repo" - ], - "properties": { - "owner": { - "type": "string", - "description": "Repository owner" - }, - "page": { - "type": "number", - "description": "Page number for pagination (min 1)", - "minimum": 1 - }, - "perPage": { - "type": "number", - "description": "Results per page for pagination (min 1, max 100)", - "minimum": 1, - "maximum": 100 - }, - "repo": { - "type": "string", - "description": "Repository name" - } - } - }, - "name": "list_workflows" -} \ No newline at end of file diff --git a/pkg/github/__toolsnaps__/rerun_failed_jobs.snap b/pkg/github/__toolsnaps__/rerun_failed_jobs.snap deleted file mode 100644 index 2c627637c..000000000 --- a/pkg/github/__toolsnaps__/rerun_failed_jobs.snap +++ /dev/null @@ -1,29 +0,0 @@ -{ - "annotations": { - "title": "Rerun failed jobs" - }, - "description": "Re-run only the failed jobs in a workflow run", - "inputSchema": { - "type": "object", - "required": [ - "owner", - "repo", - "run_id" - ], - "properties": { - "owner": { - "type": "string", - "description": "Repository owner" - }, - "repo": { - "type": "string", - "description": "Repository name" - }, - "run_id": { - "type": "number", - "description": "The unique identifier of the workflow run" - } - } - }, - "name": "rerun_failed_jobs" -} \ No newline at end of file diff --git a/pkg/github/__toolsnaps__/rerun_workflow_run.snap b/pkg/github/__toolsnaps__/rerun_workflow_run.snap deleted file mode 100644 index 00514ee79..000000000 --- a/pkg/github/__toolsnaps__/rerun_workflow_run.snap +++ /dev/null @@ -1,29 +0,0 @@ -{ - "annotations": { - "title": "Rerun workflow run" - }, - "description": "Re-run an entire workflow run", - "inputSchema": { - "type": "object", - "required": [ - "owner", - "repo", - "run_id" - ], - "properties": { - "owner": { - "type": "string", - "description": "Repository owner" - }, - "repo": { - "type": "string", - "description": "Repository name" - }, - "run_id": { - "type": "number", - "description": "The unique identifier of the workflow run" - } - } - }, - "name": "rerun_workflow_run" -} \ No newline at end of file diff --git a/pkg/github/__toolsnaps__/run_workflow.snap b/pkg/github/__toolsnaps__/run_workflow.snap deleted file mode 100644 index bb35e8213..000000000 --- a/pkg/github/__toolsnaps__/run_workflow.snap +++ /dev/null @@ -1,38 +0,0 @@ -{ - "annotations": { - "title": "Run workflow" - }, - "description": "Run an Actions workflow by workflow ID or filename", - "inputSchema": { - "type": "object", - "required": [ - "owner", - "repo", - "workflow_id", - "ref" - ], - "properties": { - "inputs": { - "type": "object", - "description": "Inputs the workflow accepts" - }, - "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." - }, - "repo": { - "type": "string", - "description": "Repository name" - }, - "workflow_id": { - "type": "string", - "description": "The workflow ID (numeric) or workflow file name (e.g., main.yml, ci.yaml)" - } - } - }, - "name": "run_workflow" -} \ No newline at end of file diff --git a/pkg/github/actions.go b/pkg/github/actions.go index 6c7cdc367..e4e17224d 100644 --- a/pkg/github/actions.go +++ b/pkg/github/actions.go @@ -25,10 +25,6 @@ const ( DescriptionRepositoryName = "Repository name" ) -// FeatureFlagConsolidatedActions is the feature flag that disables individual actions tools -// in favor of the consolidated actions tools. -const FeatureFlagConsolidatedActions = "remote_mcp_consolidated_actions" - // Method constants for consolidated actions tools const ( actionsMethodListWorkflows = "list_workflows" @@ -48,1380 +44,158 @@ const ( actionsMethodDeleteWorkflowRunLogs = "delete_workflow_run_logs" ) -// ListWorkflows creates a tool to list workflows in a repository -func ListWorkflows(t translations.TranslationHelperFunc) inventory.ServerTool { - tool := NewTool( - ToolsetMetadataActions, - mcp.Tool{ - Name: "list_workflows", - Description: t("TOOL_LIST_WORKFLOWS_DESCRIPTION", "List workflows in a repository"), - Annotations: &mcp.ToolAnnotations{ - Title: t("TOOL_LIST_WORKFLOWS_USER_TITLE", "List workflows"), - ReadOnlyHint: true, - }, - InputSchema: WithPagination(&jsonschema.Schema{ - Type: "object", - Properties: map[string]*jsonschema.Schema{ - "owner": { - Type: "string", - Description: DescriptionRepositoryOwner, - }, - "repo": { - Type: "string", - Description: DescriptionRepositoryName, - }, - }, - Required: []string{"owner", "repo"}, - }), - }, - func(ctx context.Context, deps ToolDependencies, _ *mcp.CallToolRequest, args map[string]any) (*mcp.CallToolResult, any, error) { - client, err := deps.GetClient(ctx) - if err != nil { - return utils.NewToolResultErrorFromErr("failed to get GitHub client", err), 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 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", + }) + if err != nil { + return ghErrors.NewGitHubAPIErrorResponse(ctx, "failed to list workflow jobs", resp, err), nil, nil + } + defer func() { _ = resp.Body.Close() }() - 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 - } + // Filter for failed jobs + var failedJobs []*github.WorkflowJob + for _, job := range jobs.Jobs { + if job.GetConclusion() == "failure" { + failedJobs = append(failedJobs, job) + } + } - // Get optional pagination parameters - pagination, err := OptionalPaginationParams(args) - if err != nil { - return utils.NewToolResultError(err.Error()), nil, nil - } + if len(failedJobs) == 0 { + result := map[string]any{ + "message": "No failed jobs found in this workflow run", + "run_id": runID, + "total_jobs": len(jobs.Jobs), + "failed_jobs": 0, + } + r, _ := json.Marshal(result) + return utils.NewToolResultText(string(r)), nil, nil + } - // Set up list options - opts := &github.ListOptions{ - PerPage: pagination.PerPage, - Page: pagination.Page, + // Collect logs for all failed jobs + var logResults []map[string]any + for _, job := range failedJobs { + jobResult, resp, err := getJobLogData(ctx, client, owner, repo, job.GetID(), job.GetName(), returnContent, tailLines, contentWindowSize) + if err != nil { + // Continue with other jobs even if one fails + jobResult = map[string]any{ + "job_id": job.GetID(), + "job_name": job.GetName(), + "error": err.Error(), } + // Enable reporting of status codes and error causes + _, _ = ghErrors.NewGitHubAPIErrorToCtx(ctx, "failed to get job logs", resp, err) // Explicitly ignore error for graceful handling + } - 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() }() + logResults = append(logResults, jobResult) + } - r, err := json.Marshal(workflows) - if err != nil { - return nil, nil, fmt.Errorf("failed to marshal response: %w", err) - } + result := map[string]any{ + "message": fmt.Sprintf("Retrieved logs for %d failed jobs", len(failedJobs)), + "run_id": runID, + "total_jobs": len(jobs.Jobs), + "failed_jobs": len(failedJobs), + "logs": logResults, + "return_format": map[string]bool{"content": returnContent, "urls": !returnContent}, + } - return utils.NewToolResultText(string(r)), nil, nil - }, - ) - tool.FeatureFlagDisable = FeatureFlagConsolidatedActions - return tool -} + r, err := json.Marshal(result) + if err != nil { + return nil, nil, fmt.Errorf("failed to marshal response: %w", err) + } -// ListWorkflowRuns creates a tool to list workflow runs for a specific workflow -func ListWorkflowRuns(t translations.TranslationHelperFunc) inventory.ServerTool { - tool := NewTool( - ToolsetMetadataActions, - 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, - }, - "repo": { - Type: "string", - Description: DescriptionRepositoryName, - }, - "workflow_id": { - Type: "string", - Description: "The workflow ID or workflow file name", - }, - "actor": { - Type: "string", - Description: "Returns someone's workflow runs. Use the login for the user who created the workflow run.", - }, - "branch": { - Type: "string", - Description: "Returns workflow runs associated with a branch. Use the name of the branch.", - }, - "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", - }, - }, - "status": { - Type: "string", - Description: "Returns workflow runs with the check run status", - Enum: []any{"queued", "in_progress", "completed", "requested", "waiting"}, - }, - }, - Required: []string{"owner", "repo", "workflow_id"}, - }), - }, - func(ctx context.Context, deps ToolDependencies, _ *mcp.CallToolRequest, args map[string]any) (*mcp.CallToolResult, any, error) { - client, err := deps.GetClient(ctx) - if err != nil { - return utils.NewToolResultErrorFromErr("failed to get GitHub client", err), nil, nil - } + return utils.NewToolResultText(string(r)), nil, nil +} - 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 - } +// 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) { + 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 + } - // 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") - if err != nil { - return utils.NewToolResultError(err.Error()), nil, nil - } - status, err := OptionalParam[string](args, "status") - if err != nil { - return utils.NewToolResultError(err.Error()), nil, nil - } + r, err := json.Marshal(jobResult) + if err != nil { + return nil, nil, fmt.Errorf("failed to marshal response: %w", err) + } - // Get optional pagination parameters - pagination, err := OptionalPaginationParams(args) - if err != nil { - return utils.NewToolResultError(err.Error()), nil, nil - } + return utils.NewToolResultText(string(r)), nil, nil +} - // Set up list options - opts := &github.ListWorkflowRunsOptions{ - Actor: actor, - Branch: branch, - Event: event, - Status: status, - ListOptions: github.ListOptions{ - PerPage: pagination.PerPage, - Page: pagination.Page, - }, - } +// 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) { + // Get the download URL for the job logs + url, resp, err := client.Actions.GetWorkflowJobLogs(ctx, owner, repo, jobID, 1) + if err != nil { + return nil, resp, fmt.Errorf("failed to get job logs for job %d: %w", jobID, err) + } + defer func() { _ = resp.Body.Close() }() - 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) - } - defer func() { _ = resp.Body.Close() }() + result := map[string]any{ + "job_id": jobID, + } + if jobName != "" { + result["job_name"] = jobName + } - r, err := json.Marshal(workflowRuns) - if err != nil { - return nil, nil, fmt.Errorf("failed to marshal response: %w", err) + if returnContent { + // Download and return the actual log content + content, originalLength, httpResp, err := downloadLogContent(ctx, url.String(), tailLines, contentWindowSize) //nolint:bodyclose // Response body is closed in downloadLogContent, but we need to return httpResp + if err != nil { + // To keep the return value consistent wrap the response as a GitHub Response + ghRes := &github.Response{ + Response: httpResp, } + return nil, ghRes, fmt.Errorf("failed to download log content for job %d: %w", jobID, err) + } + result["logs_content"] = content + result["message"] = "Job logs content retrieved successfully" + result["original_length"] = originalLength + } else { + // Return just the URL + result["logs_url"] = url.String() + result["message"] = "Job logs are available for download" + result["note"] = "The logs_url provides a download link for the individual job logs in plain text format. Use return_content=true to get the actual log content." + } - return utils.NewToolResultText(string(r)), nil, nil - }, - ) - tool.FeatureFlagDisable = FeatureFlagConsolidatedActions - return tool + return result, resp, nil } -// RunWorkflow creates a tool to run an Actions workflow -func RunWorkflow(t translations.TranslationHelperFunc) inventory.ServerTool { - tool := NewTool( - ToolsetMetadataActions, - mcp.Tool{ - Name: "run_workflow", - Description: t("TOOL_RUN_WORKFLOW_DESCRIPTION", "Run an Actions workflow by workflow ID or filename"), - Annotations: &mcp.ToolAnnotations{ - Title: t("TOOL_RUN_WORKFLOW_USER_TITLE", "Run workflow"), - ReadOnlyHint: false, - }, - InputSchema: &jsonschema.Schema{ - Type: "object", - Properties: map[string]*jsonschema.Schema{ - "owner": { - Type: "string", - Description: DescriptionRepositoryOwner, - }, - "repo": { - Type: "string", - Description: DescriptionRepositoryName, - }, - "workflow_id": { - Type: "string", - Description: "The workflow ID (numeric) or workflow file name (e.g., main.yml, ci.yaml)", - }, - "ref": { - Type: "string", - Description: "The git reference for the workflow. The reference can be a branch or tag name.", - }, - "inputs": { - Type: "object", - Description: "Inputs the workflow accepts", - }, - }, - Required: []string{"owner", "repo", "workflow_id", "ref"}, - }, - }, - func(ctx context.Context, deps ToolDependencies, _ *mcp.CallToolRequest, args map[string]any) (*mcp.CallToolResult, any, error) { - client, err := deps.GetClient(ctx) - if err != nil { - return utils.NewToolResultErrorFromErr("failed to get GitHub client", err), nil, nil - } +func downloadLogContent(ctx context.Context, logURL string, tailLines int, maxLines int) (string, int, *http.Response, error) { + prof := profiler.New(nil, profiler.IsProfilingEnabled()) + finish := prof.Start(ctx, "log_buffer_processing") - 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 - } - ref, err := RequiredParam[string](args, "ref") - 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 - } - } - - 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, - } - - 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 - }, - ) - tool.FeatureFlagDisable = FeatureFlagConsolidatedActions - return tool -} - -// GetWorkflowRun creates a tool to get details of a specific workflow run -func GetWorkflowRun(t translations.TranslationHelperFunc) inventory.ServerTool { - tool := NewTool( - ToolsetMetadataActions, - mcp.Tool{ - Name: "get_workflow_run", - Description: t("TOOL_GET_WORKFLOW_RUN_DESCRIPTION", "Get details of a specific workflow run"), - Annotations: &mcp.ToolAnnotations{ - Title: t("TOOL_GET_WORKFLOW_RUN_USER_TITLE", "Get workflow run"), - 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, deps ToolDependencies, _ *mcp.CallToolRequest, args map[string]any) (*mcp.CallToolResult, any, error) { - client, err := deps.GetClient(ctx) - if err != nil { - return utils.NewToolResultErrorFromErr("failed to get GitHub client", err), nil, nil - } - - 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) - - 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 - }, - ) - tool.FeatureFlagDisable = FeatureFlagConsolidatedActions - return tool -} - -// GetWorkflowRunLogs creates a tool to download logs for a specific workflow run -func GetWorkflowRunLogs(t translations.TranslationHelperFunc) inventory.ServerTool { - tool := NewTool( - ToolsetMetadataActions, - 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, - }, - "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, deps ToolDependencies, _ *mcp.CallToolRequest, args map[string]any) (*mcp.CallToolResult, any, error) { - client, err := deps.GetClient(ctx) - if err != nil { - return utils.NewToolResultErrorFromErr("failed to get GitHub client", err), nil, nil - } - - 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 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 - }, - ) - tool.FeatureFlagDisable = FeatureFlagConsolidatedActions - return tool -} - -// ListWorkflowJobs creates a tool to list jobs for a specific workflow run -func ListWorkflowJobs(t translations.TranslationHelperFunc) inventory.ServerTool { - tool := NewTool( - ToolsetMetadataActions, - 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": { - Type: "string", - Description: DescriptionRepositoryOwner, - }, - "repo": { - Type: "string", - Description: DescriptionRepositoryName, - }, - "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"}, - }, - }, - Required: []string{"owner", "repo", "run_id"}, - }), - }, - func(ctx context.Context, deps ToolDependencies, _ *mcp.CallToolRequest, args map[string]any) (*mcp.CallToolResult, any, error) { - client, err := deps.GetClient(ctx) - if err != nil { - return utils.NewToolResultErrorFromErr("failed to get GitHub client", err), nil, nil - } - - 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 filtering parameters - filter, err := OptionalParam[string](args, "filter") - 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 - } - - // 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", - } - - 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 - }, - ) - tool.FeatureFlagDisable = FeatureFlagConsolidatedActions - return tool -} - -// GetJobLogs creates a tool to download logs for a specific workflow job or efficiently get all failed job logs for a workflow run -func GetJobLogs(t translations.TranslationHelperFunc) inventory.ServerTool { - tool := NewTool( - ToolsetMetadataActions, - 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"), - Annotations: &mcp.ToolAnnotations{ - Title: t("TOOL_GET_JOB_LOGS_USER_TITLE", "Get job logs"), - ReadOnlyHint: true, - }, - InputSchema: &jsonschema.Schema{ - Type: "object", - Properties: map[string]*jsonschema.Schema{ - "owner": { - Type: "string", - Description: DescriptionRepositoryOwner, - }, - "repo": { - Type: "string", - Description: DescriptionRepositoryName, - }, - "job_id": { - Type: "number", - Description: "The unique identifier of the workflow job (required for single job logs)", - }, - "run_id": { - Type: "number", - Description: "Workflow run ID (required when using failed_only)", - }, - "failed_only": { - Type: "boolean", - Description: "When true, gets logs for all failed jobs in run_id", - }, - "return_content": { - Type: "boolean", - Description: "Returns actual log content instead of URLs", - }, - "tail_lines": { - Type: "number", - Description: "Number of lines to return from the end of the log", - Default: json.RawMessage(`500`), - }, - }, - Required: []string{"owner", "repo"}, - }, - }, - func(ctx context.Context, deps ToolDependencies, _ *mcp.CallToolRequest, args map[string]any) (*mcp.CallToolResult, any, error) { - client, err := deps.GetClient(ctx) - if err != nil { - return utils.NewToolResultErrorFromErr("failed to get GitHub client", err), nil, nil - } - - 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 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 - } - // Default to 500 lines if not specified - if tailLines == 0 { - tailLines = 500 - } - - // Validate parameters - if failedOnly && runID == 0 { - return utils.NewToolResultError("run_id is required when failed_only is true"), nil, nil - } - if !failedOnly && jobID == 0 { - return utils.NewToolResultError("job_id is required when failed_only is false"), nil, nil - } - - if failedOnly && runID > 0 { - // Handle failed-only mode: get logs for all failed jobs in the workflow run - return handleFailedJobLogs(ctx, client, owner, repo, int64(runID), returnContent, tailLines, deps.GetContentWindowSize()) - } else if jobID > 0 { - // Handle single job mode - return handleSingleJobLogs(ctx, client, owner, repo, int64(jobID), returnContent, tailLines, deps.GetContentWindowSize()) - } - - return utils.NewToolResultError("Either job_id must be provided for single job logs, or run_id with failed_only=true for failed job logs"), nil, nil - }, - ) - tool.FeatureFlagDisable = FeatureFlagConsolidatedActions - return tool -} - -// 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", - }) - 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" { - failedJobs = append(failedJobs, job) - } - } - - if len(failedJobs) == 0 { - result := map[string]any{ - "message": "No failed jobs found in this workflow run", - "run_id": runID, - "total_jobs": len(jobs.Jobs), - "failed_jobs": 0, - } - r, _ := json.Marshal(result) - return utils.NewToolResultText(string(r)), nil, nil - } - - // Collect logs for all failed jobs - var logResults []map[string]any - for _, job := range failedJobs { - jobResult, resp, err := getJobLogData(ctx, client, owner, repo, job.GetID(), job.GetName(), returnContent, tailLines, contentWindowSize) - if err != nil { - // Continue with other jobs even if one fails - jobResult = map[string]any{ - "job_id": job.GetID(), - "job_name": job.GetName(), - "error": err.Error(), - } - // Enable reporting of status codes and error causes - _, _ = ghErrors.NewGitHubAPIErrorToCtx(ctx, "failed to get job logs", resp, err) // Explicitly ignore error for graceful handling - } - - logResults = append(logResults, jobResult) - } - - result := map[string]any{ - "message": fmt.Sprintf("Retrieved logs for %d failed jobs", len(failedJobs)), - "run_id": runID, - "total_jobs": len(jobs.Jobs), - "failed_jobs": len(failedJobs), - "logs": logResults, - "return_format": map[string]bool{"content": returnContent, "urls": !returnContent}, - } - - 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 -} - -// 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) { - 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 - } - - r, err := json.Marshal(jobResult) - if err != nil { - return nil, nil, fmt.Errorf("failed to marshal response: %w", err) - } - - return utils.NewToolResultText(string(r)), nil, nil -} - -// 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) { - // Get the download URL for the job logs - url, resp, err := client.Actions.GetWorkflowJobLogs(ctx, owner, repo, jobID, 1) - if err != nil { - return nil, resp, fmt.Errorf("failed to get job logs for job %d: %w", jobID, err) - } - defer func() { _ = resp.Body.Close() }() - - result := map[string]any{ - "job_id": jobID, - } - if jobName != "" { - result["job_name"] = jobName - } - - if returnContent { - // Download and return the actual log content - content, originalLength, httpResp, err := downloadLogContent(ctx, url.String(), tailLines, contentWindowSize) //nolint:bodyclose // Response body is closed in downloadLogContent, but we need to return httpResp - if err != nil { - // To keep the return value consistent wrap the response as a GitHub Response - ghRes := &github.Response{ - Response: httpResp, - } - return nil, ghRes, fmt.Errorf("failed to download log content for job %d: %w", jobID, err) - } - result["logs_content"] = content - result["message"] = "Job logs content retrieved successfully" - result["original_length"] = originalLength - } else { - // Return just the URL - result["logs_url"] = url.String() - result["message"] = "Job logs are available for download" - result["note"] = "The logs_url provides a download link for the individual job logs in plain text format. Use return_content=true to get the actual log content." - } - - return result, resp, nil -} - -func downloadLogContent(ctx context.Context, logURL string, tailLines int, maxLines int) (string, int, *http.Response, error) { - prof := profiler.New(nil, profiler.IsProfilingEnabled()) - finish := prof.Start(ctx, "log_buffer_processing") - - httpResp, err := http.Get(logURL) //nolint:gosec - if err != nil { - return "", 0, httpResp, fmt.Errorf("failed to download logs: %w", err) - } - defer func() { _ = httpResp.Body.Close() }() - - if httpResp.StatusCode != http.StatusOK { - return "", 0, httpResp, fmt.Errorf("failed to download logs: HTTP %d", httpResp.StatusCode) - } - - bufferSize := tailLines - if bufferSize > maxLines { - bufferSize = maxLines - } - - processedInput, totalLines, httpResp, err := buffer.ProcessResponseAsRingBufferToEnd(httpResp, bufferSize) - if err != nil { - return "", 0, httpResp, fmt.Errorf("failed to process log content: %w", err) - } - - lines := strings.Split(processedInput, "\n") - if len(lines) > tailLines { - lines = lines[len(lines)-tailLines:] - } - finalResult := strings.Join(lines, "\n") - - _ = finish(len(lines), int64(len(finalResult))) - - return finalResult, totalLines, httpResp, nil -} - -// RerunWorkflowRun creates a tool to re-run an entire workflow run -func RerunWorkflowRun(t translations.TranslationHelperFunc) inventory.ServerTool { - tool := NewTool( - ToolsetMetadataActions, - 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, deps ToolDependencies, _ *mcp.CallToolRequest, args map[string]any) (*mcp.CallToolResult, any, error) { - client, err := deps.GetClient(ctx) - if err != nil { - return utils.NewToolResultErrorFromErr("failed to get GitHub client", err), nil, nil - } - - 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) - - 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 - }, - ) - tool.FeatureFlagDisable = FeatureFlagConsolidatedActions - return tool -} - -// RerunFailedJobs creates a tool to re-run only the failed jobs in a workflow run -func RerunFailedJobs(t translations.TranslationHelperFunc) inventory.ServerTool { - tool := NewTool( - ToolsetMetadataActions, - 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, deps ToolDependencies, _ *mcp.CallToolRequest, args map[string]any) (*mcp.CallToolResult, any, error) { - client, err := deps.GetClient(ctx) - if err != nil { - return utils.NewToolResultErrorFromErr("failed to get GitHub client", err), nil, nil - } - - 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) - - 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 - }, - ) - tool.FeatureFlagDisable = FeatureFlagConsolidatedActions - return tool -} - -// CancelWorkflowRun creates a tool to cancel a workflow run -func CancelWorkflowRun(t translations.TranslationHelperFunc) inventory.ServerTool { - tool := NewTool( - ToolsetMetadataActions, - 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, deps ToolDependencies, _ *mcp.CallToolRequest, args map[string]any) (*mcp.CallToolResult, any, error) { - client, err := deps.GetClient(ctx) - if err != nil { - return utils.NewToolResultErrorFromErr("failed to get GitHub client", err), nil, nil - } - - 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) - - 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 - }, - ) - tool.FeatureFlagDisable = FeatureFlagConsolidatedActions - return tool -} - -// ListWorkflowRunArtifacts creates a tool to list artifacts for a workflow run -func ListWorkflowRunArtifacts(t translations.TranslationHelperFunc) inventory.ServerTool { - tool := NewTool( - ToolsetMetadataActions, - 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, deps ToolDependencies, _ *mcp.CallToolRequest, args map[string]any) (*mcp.CallToolResult, any, error) { - client, err := deps.GetClient(ctx) - if err != nil { - return utils.NewToolResultErrorFromErr("failed to get GitHub client", err), nil, nil - } - - 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 - } - - // 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 - }, - ) - tool.FeatureFlagDisable = FeatureFlagConsolidatedActions - return tool -} - -// DownloadWorkflowRunArtifact creates a tool to download a workflow run artifact -func DownloadWorkflowRunArtifact(t translations.TranslationHelperFunc) inventory.ServerTool { - tool := NewTool( - ToolsetMetadataActions, - 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, deps ToolDependencies, _ *mcp.CallToolRequest, args map[string]any) (*mcp.CallToolResult, any, error) { - client, err := deps.GetClient(ctx) - if err != nil { - return utils.NewToolResultErrorFromErr("failed to get GitHub client", err), nil, nil - } - - 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) - - // 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 - }, - ) - tool.FeatureFlagDisable = FeatureFlagConsolidatedActions - return tool -} - -// DeleteWorkflowRunLogs creates a tool to delete logs for a workflow run -func DeleteWorkflowRunLogs(t translations.TranslationHelperFunc) inventory.ServerTool { - tool := NewTool( - ToolsetMetadataActions, - 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, deps ToolDependencies, _ *mcp.CallToolRequest, args map[string]any) (*mcp.CallToolResult, any, error) { - client, err := deps.GetClient(ctx) - if err != nil { - return utils.NewToolResultErrorFromErr("failed to get GitHub client", err), nil, nil - } - - 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) - - 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) - } + httpResp, err := http.Get(logURL) //nolint:gosec + if err != nil { + return "", 0, httpResp, fmt.Errorf("failed to download logs: %w", err) + } + defer func() { _ = httpResp.Body.Close() }() - return utils.NewToolResultText(string(r)), nil, nil - }, - ) - tool.FeatureFlagDisable = FeatureFlagConsolidatedActions - return tool -} + if httpResp.StatusCode != http.StatusOK { + return "", 0, httpResp, fmt.Errorf("failed to download logs: HTTP %d", httpResp.StatusCode) + } -// GetWorkflowRunUsage creates a tool to get usage metrics for a workflow run -func GetWorkflowRunUsage(t translations.TranslationHelperFunc) inventory.ServerTool { - tool := NewTool( - ToolsetMetadataActions, - 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, deps ToolDependencies, _ *mcp.CallToolRequest, args map[string]any) (*mcp.CallToolResult, any, error) { - client, err := deps.GetClient(ctx) - if err != nil { - return utils.NewToolResultErrorFromErr("failed to get GitHub client", err), nil, nil - } + bufferSize := tailLines + if bufferSize > maxLines { + bufferSize = maxLines + } - 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) + processedInput, totalLines, httpResp, err := buffer.ProcessResponseAsRingBufferToEnd(httpResp, bufferSize) + if err != nil { + return "", 0, httpResp, fmt.Errorf("failed to process log content: %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() }() + lines := strings.Split(processedInput, "\n") + if len(lines) > tailLines { + lines = lines[len(lines)-tailLines:] + } + finalResult := strings.Join(lines, "\n") - r, err := json.Marshal(usage) - if err != nil { - return nil, nil, fmt.Errorf("failed to marshal response: %w", err) - } + _ = finish(len(lines), int64(len(finalResult))) - return utils.NewToolResultText(string(r)), nil, nil - }, - ) - tool.FeatureFlagDisable = FeatureFlagConsolidatedActions - return tool + return finalResult, totalLines, httpResp, nil } // ActionsList returns the tool and handler for listing GitHub Actions resources. @@ -1615,7 +389,6 @@ Use this tool to list workflows in a repository, or list workflow runs, jobs, an } }, ) - tool.FeatureFlagEnable = FeatureFlagConsolidatedActions return tool } @@ -1723,7 +496,6 @@ Use this tool to get details about individual workflows, workflow runs, jobs, an } }, ) - tool.FeatureFlagEnable = FeatureFlagConsolidatedActions return tool } @@ -1841,7 +613,6 @@ func ActionsRunTrigger(t translations.TranslationHelperFunc) inventory.ServerToo } }, ) - tool.FeatureFlagEnable = FeatureFlagConsolidatedActions return tool } @@ -1958,7 +729,6 @@ For single job logs, provide job_id. For all failed jobs in a run, provide run_i return utils.NewToolResultError("Either job_id must be provided for single job logs, or run_id with failed_only=true for failed job logs"), nil, nil }, ) - tool.FeatureFlagEnable = FeatureFlagConsolidatedActions return tool } diff --git a/pkg/github/actions_test.go b/pkg/github/actions_test.go index f2d336e21..e505381f5 100644 --- a/pkg/github/actions_test.go +++ b/pkg/github/actions_test.go @@ -3,18 +3,10 @@ package github import ( "context" "encoding/json" - "io" "net/http" - "net/http/httptest" - "os" - "runtime" - "runtime/debug" - "strings" "testing" - "github.com/github/github-mcp-server/internal/profiler" "github.com/github/github-mcp-server/internal/toolsnaps" - buffer "github.com/github/github-mcp-server/pkg/buffer" "github.com/github/github-mcp-server/pkg/translations" "github.com/google/go-github/v79/github" "github.com/google/jsonschema-go/jsonschema" @@ -23,1828 +15,6 @@ import ( "github.com/stretchr/testify/require" ) -func Test_ListWorkflows(t *testing.T) { - // Verify tool definition once - toolDef := ListWorkflows(translations.NullTranslationHelper) - require.NoError(t, toolsnaps.Test(toolDef.Tool.Name, toolDef.Tool)) - - assert.Equal(t, "list_workflows", toolDef.Tool.Name) - assert.NotEmpty(t, toolDef.Tool.Description) - inputSchema := toolDef.Tool.InputSchema.(*jsonschema.Schema) - assert.Contains(t, inputSchema.Properties, "owner") - assert.Contains(t, inputSchema.Properties, "repo") - assert.Contains(t, inputSchema.Properties, "perPage") - assert.Contains(t, inputSchema.Properties, "page") - assert.ElementsMatch(t, inputSchema.Required, []string{"owner", "repo"}) - - tests := []struct { - name string - mockedClient *http.Client - requestArgs map[string]any - expectError bool - expectedErrMsg string - }{ - { - name: "successful workflow listing", - mockedClient: MockHTTPClientWithHandlers(map[string]http.HandlerFunc{ - GetReposActionsWorkflowsByOwnerByRepo: http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) { - workflows := &github.Workflows{ - TotalCount: github.Ptr(2), - Workflows: []*github.Workflow{ - { - ID: github.Ptr(int64(123)), - Name: github.Ptr("CI"), - Path: github.Ptr(".github/workflows/ci.yml"), - State: github.Ptr("active"), - CreatedAt: &github.Timestamp{}, - UpdatedAt: &github.Timestamp{}, - URL: github.Ptr("https://api.github.com/repos/owner/repo/actions/workflows/123"), - HTMLURL: github.Ptr("https://github.com/owner/repo/actions/workflows/ci.yml"), - BadgeURL: github.Ptr("https://github.com/owner/repo/workflows/CI/badge.svg"), - NodeID: github.Ptr("W_123"), - }, - { - ID: github.Ptr(int64(456)), - Name: github.Ptr("Deploy"), - Path: github.Ptr(".github/workflows/deploy.yml"), - State: github.Ptr("active"), - CreatedAt: &github.Timestamp{}, - UpdatedAt: &github.Timestamp{}, - URL: github.Ptr("https://api.github.com/repos/owner/repo/actions/workflows/456"), - HTMLURL: github.Ptr("https://github.com/owner/repo/actions/workflows/deploy.yml"), - BadgeURL: github.Ptr("https://github.com/owner/repo/workflows/Deploy/badge.svg"), - NodeID: github.Ptr("W_456"), - }, - }, - } - w.WriteHeader(http.StatusOK) - _ = json.NewEncoder(w).Encode(workflows) - }), - }), - requestArgs: map[string]any{ - "owner": "owner", - "repo": "repo", - }, - expectError: false, - }, - { - name: "missing required parameter owner", - mockedClient: MockHTTPClientWithHandlers(map[string]http.HandlerFunc{}), - requestArgs: map[string]any{ - "repo": "repo", - }, - expectError: true, - expectedErrMsg: "missing required parameter: owner", - }, - } - - for _, tc := range tests { - t.Run(tc.name, func(t *testing.T) { - // Setup client with mock - client := github.NewClient(tc.mockedClient) - deps := BaseDeps{ - Client: client, - } - handler := toolDef.Handler(deps) - - // Create call request - request := createMCPRequest(tc.requestArgs) - - // Call handler - result, err := handler(ContextWithDeps(context.Background(), deps), &request) - - require.NoError(t, err) - require.Equal(t, tc.expectError, result.IsError) - - // Parse the result and get the text content if no error - textContent := getTextResult(t, result) - - if tc.expectedErrMsg != "" { - assert.Equal(t, tc.expectedErrMsg, textContent.Text) - return - } - - // Unmarshal and verify the result - var response github.Workflows - err = json.Unmarshal([]byte(textContent.Text), &response) - require.NoError(t, err) - assert.NotNil(t, response.TotalCount) - assert.Greater(t, *response.TotalCount, 0) - assert.NotEmpty(t, response.Workflows) - }) - } -} - -func Test_RunWorkflow(t *testing.T) { - // Verify tool definition once - toolDef := RunWorkflow(translations.NullTranslationHelper) - require.NoError(t, toolsnaps.Test(toolDef.Tool.Name, toolDef.Tool)) - - assert.Equal(t, "run_workflow", toolDef.Tool.Name) - assert.NotEmpty(t, toolDef.Tool.Description) - assert.Contains(t, toolDef.Tool.InputSchema.(*jsonschema.Schema).Properties, "owner") - assert.Contains(t, toolDef.Tool.InputSchema.(*jsonschema.Schema).Properties, "repo") - assert.Contains(t, toolDef.Tool.InputSchema.(*jsonschema.Schema).Properties, "workflow_id") - assert.Contains(t, toolDef.Tool.InputSchema.(*jsonschema.Schema).Properties, "ref") - assert.Contains(t, toolDef.Tool.InputSchema.(*jsonschema.Schema).Properties, "inputs") - assert.ElementsMatch(t, toolDef.Tool.InputSchema.(*jsonschema.Schema).Required, []string{"owner", "repo", "workflow_id", "ref"}) - - tests := []struct { - name string - mockedClient *http.Client - requestArgs map[string]any - expectError bool - expectedErrMsg string - }{ - { - name: "successful workflow run", - mockedClient: MockHTTPClientWithHandlers(map[string]http.HandlerFunc{ - PostReposActionsWorkflowsDispatchesByOwnerByRepoByWorkflowID: http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) { - w.WriteHeader(http.StatusNoContent) - }), - }), - requestArgs: map[string]any{ - "owner": "owner", - "repo": "repo", - "workflow_id": "12345", - "ref": "main", - }, - expectError: false, - }, - { - name: "missing required parameter workflow_id", - mockedClient: MockHTTPClientWithHandlers(map[string]http.HandlerFunc{}), - requestArgs: map[string]any{ - "owner": "owner", - "repo": "repo", - "ref": "main", - }, - expectError: true, - expectedErrMsg: "missing required parameter: workflow_id", - }, - } - - for _, tc := range tests { - t.Run(tc.name, func(t *testing.T) { - // Setup client with mock - client := github.NewClient(tc.mockedClient) - deps := BaseDeps{ - Client: client, - } - handler := toolDef.Handler(deps) - - // Create call request - request := createMCPRequest(tc.requestArgs) - - // Call handler - result, err := handler(ContextWithDeps(context.Background(), deps), &request) - - require.NoError(t, err) - require.Equal(t, tc.expectError, result.IsError) - - // Parse the result and get the text content if no error - textContent := getTextResult(t, result) - - if tc.expectedErrMsg != "" { - assert.Equal(t, tc.expectedErrMsg, textContent.Text) - return - } - - // 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 has been queued", response["message"]) - assert.Contains(t, response, "workflow_type") - }) - } -} - -func Test_RunWorkflow_WithFilename(t *testing.T) { - // Test the unified RunWorkflow function with filenames - toolDef := RunWorkflow(translations.NullTranslationHelper) - - tests := []struct { - name string - mockedClient *http.Client - requestArgs map[string]any - expectError bool - expectedErrMsg string - }{ - { - name: "successful workflow run by filename", - mockedClient: MockHTTPClientWithHandlers(map[string]http.HandlerFunc{ - 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", - mockedClient: MockHTTPClientWithHandlers(map[string]http.HandlerFunc{ - PostReposActionsWorkflowsDispatchesByOwnerByRepoByWorkflowID: http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) { - w.WriteHeader(http.StatusNoContent) - }), - }), - requestArgs: map[string]any{ - "owner": "owner", - "repo": "repo", - "workflow_id": "12345", - "ref": "main", - }, - expectError: false, - }, - { - name: "missing required parameter workflow_id", - mockedClient: MockHTTPClientWithHandlers(map[string]http.HandlerFunc{}), - requestArgs: map[string]any{ - "owner": "owner", - "repo": "repo", - "ref": "main", - }, - expectError: true, - expectedErrMsg: "missing required parameter: workflow_id", - }, - } - - for _, tc := range tests { - t.Run(tc.name, func(t *testing.T) { - // Setup client with mock - client := github.NewClient(tc.mockedClient) - deps := BaseDeps{ - Client: client, - } - handler := toolDef.Handler(deps) - - // Create call request - request := createMCPRequest(tc.requestArgs) - - // Call handler - result, err := handler(ContextWithDeps(context.Background(), deps), &request) - - require.NoError(t, err) - require.Equal(t, tc.expectError, result.IsError) - - // Parse the result and get the text content if no error - textContent := getTextResult(t, result) - - if tc.expectedErrMsg != "" { - assert.Equal(t, tc.expectedErrMsg, textContent.Text) - return - } - - // 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 has been queued", response["message"]) - assert.Contains(t, response, "workflow_type") - }) - } -} - -func Test_CancelWorkflowRun(t *testing.T) { - // Verify tool definition once - toolDef := CancelWorkflowRun(translations.NullTranslationHelper) - require.NoError(t, toolsnaps.Test(toolDef.Tool.Name, toolDef.Tool)) - - assert.Equal(t, "cancel_workflow_run", toolDef.Tool.Name) - assert.NotEmpty(t, toolDef.Tool.Description) - assert.Contains(t, toolDef.Tool.InputSchema.(*jsonschema.Schema).Properties, "owner") - assert.Contains(t, toolDef.Tool.InputSchema.(*jsonschema.Schema).Properties, "repo") - assert.Contains(t, toolDef.Tool.InputSchema.(*jsonschema.Schema).Properties, "run_id") - assert.ElementsMatch(t, toolDef.Tool.InputSchema.(*jsonschema.Schema).Required, []string{"owner", "repo", "run_id"}) - - tests := []struct { - name string - mockedClient *http.Client - requestArgs map[string]any - expectError bool - expectedErrMsg string - }{ - { - name: "successful workflow run cancellation", - mockedClient: MockHTTPClientWithHandlers(map[string]http.HandlerFunc{ - "POST /repos/owner/repo/actions/runs/12345/cancel": http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) { - w.WriteHeader(http.StatusAccepted) - }), - }), - requestArgs: map[string]any{ - "owner": "owner", - "repo": "repo", - "run_id": float64(12345), - }, - expectError: false, - }, - { - name: "conflict when cancelling a workflow run", - mockedClient: MockHTTPClientWithHandlers(map[string]http.HandlerFunc{ - "POST /repos/owner/repo/actions/runs/12345/cancel": 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: MockHTTPClientWithHandlers(map[string]http.HandlerFunc{}), - 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) - deps := BaseDeps{ - Client: client, - } - handler := toolDef.Handler(deps) - - // Create call request - request := createMCPRequest(tc.requestArgs) - - // Call handler - result, err := handler(ContextWithDeps(context.Background(), deps), &request) - - 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) - 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 has been cancelled", response["message"]) - assert.Equal(t, float64(12345), response["run_id"]) - }) - } -} - -func Test_ListWorkflowRunArtifacts(t *testing.T) { - // Verify tool definition once - toolDef := ListWorkflowRunArtifacts(translations.NullTranslationHelper) - require.NoError(t, toolsnaps.Test(toolDef.Tool.Name, toolDef.Tool)) - - assert.Equal(t, "list_workflow_run_artifacts", toolDef.Tool.Name) - assert.NotEmpty(t, toolDef.Tool.Description) - assert.Contains(t, toolDef.Tool.InputSchema.(*jsonschema.Schema).Properties, "owner") - assert.Contains(t, toolDef.Tool.InputSchema.(*jsonschema.Schema).Properties, "repo") - assert.Contains(t, toolDef.Tool.InputSchema.(*jsonschema.Schema).Properties, "run_id") - assert.Contains(t, toolDef.Tool.InputSchema.(*jsonschema.Schema).Properties, "perPage") - assert.Contains(t, toolDef.Tool.InputSchema.(*jsonschema.Schema).Properties, "page") - assert.ElementsMatch(t, toolDef.Tool.InputSchema.(*jsonschema.Schema).Required, []string{"owner", "repo", "run_id"}) - - tests := []struct { - name string - mockedClient *http.Client - requestArgs map[string]any - expectError bool - expectedErrMsg string - }{ - { - name: "successful artifacts listing", - mockedClient: MockHTTPClientWithHandlers(map[string]http.HandlerFunc{ - GetReposActionsRunsArtifactsByOwnerByRepoByRunID: 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) - }), - }), - requestArgs: map[string]any{ - "owner": "owner", - "repo": "repo", - "run_id": float64(12345), - }, - expectError: false, - }, - { - name: "missing required parameter run_id", - mockedClient: MockHTTPClientWithHandlers(map[string]http.HandlerFunc{}), - 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) - deps := BaseDeps{ - Client: client, - } - handler := toolDef.Handler(deps) - - // Create call request - request := createMCPRequest(tc.requestArgs) - - // Call handler - result, err := handler(ContextWithDeps(context.Background(), deps), &request) - - require.NoError(t, err) - require.Equal(t, tc.expectError, result.IsError) - - // Parse the result and get the text content if no error - textContent := getTextResult(t, result) - - if tc.expectedErrMsg != "" { - assert.Equal(t, tc.expectedErrMsg, textContent.Text) - return - } - - // Unmarshal and verify the result - var response github.ArtifactList - 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) - }) - } -} - -func Test_DownloadWorkflowRunArtifact(t *testing.T) { - // Verify tool definition once - toolDef := DownloadWorkflowRunArtifact(translations.NullTranslationHelper) - require.NoError(t, toolsnaps.Test(toolDef.Tool.Name, toolDef.Tool)) - - assert.Equal(t, "download_workflow_run_artifact", toolDef.Tool.Name) - assert.NotEmpty(t, toolDef.Tool.Description) - assert.Contains(t, toolDef.Tool.InputSchema.(*jsonschema.Schema).Properties, "owner") - assert.Contains(t, toolDef.Tool.InputSchema.(*jsonschema.Schema).Properties, "repo") - assert.Contains(t, toolDef.Tool.InputSchema.(*jsonschema.Schema).Properties, "artifact_id") - assert.ElementsMatch(t, toolDef.Tool.InputSchema.(*jsonschema.Schema).Required, []string{"owner", "repo", "artifact_id"}) - - tests := []struct { - name string - mockedClient *http.Client - requestArgs map[string]any - expectError bool - expectedErrMsg string - }{ - { - name: "successful artifact download URL", - mockedClient: MockHTTPClientWithHandlers(map[string]http.HandlerFunc{ - "GET /repos/owner/repo/actions/artifacts/123/zip": 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) - }), - }), - requestArgs: map[string]any{ - "owner": "owner", - "repo": "repo", - "artifact_id": float64(123), - }, - expectError: false, - }, - { - name: "missing required parameter artifact_id", - mockedClient: MockHTTPClientWithHandlers(map[string]http.HandlerFunc{}), - requestArgs: map[string]any{ - "owner": "owner", - "repo": "repo", - }, - expectError: true, - expectedErrMsg: "missing required parameter: artifact_id", - }, - } - - for _, tc := range tests { - t.Run(tc.name, func(t *testing.T) { - // Setup client with mock - client := github.NewClient(tc.mockedClient) - deps := BaseDeps{ - Client: client, - } - handler := toolDef.Handler(deps) - - // Create call request - request := createMCPRequest(tc.requestArgs) - - // Call handler - result, err := handler(ContextWithDeps(context.Background(), deps), &request) - - require.NoError(t, err) - require.Equal(t, tc.expectError, result.IsError) - - // Parse the result and get the text content if no error - textContent := getTextResult(t, result) - - if tc.expectedErrMsg != "" { - assert.Equal(t, tc.expectedErrMsg, textContent.Text) - return - } - - // 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"]) - }) - } -} - -func Test_DeleteWorkflowRunLogs(t *testing.T) { - // Verify tool definition once - toolDef := DeleteWorkflowRunLogs(translations.NullTranslationHelper) - require.NoError(t, toolsnaps.Test(toolDef.Tool.Name, toolDef.Tool)) - - assert.Equal(t, "delete_workflow_run_logs", toolDef.Tool.Name) - assert.NotEmpty(t, toolDef.Tool.Description) - assert.Contains(t, toolDef.Tool.InputSchema.(*jsonschema.Schema).Properties, "owner") - assert.Contains(t, toolDef.Tool.InputSchema.(*jsonschema.Schema).Properties, "repo") - assert.Contains(t, toolDef.Tool.InputSchema.(*jsonschema.Schema).Properties, "run_id") - assert.ElementsMatch(t, toolDef.Tool.InputSchema.(*jsonschema.Schema).Required, []string{"owner", "repo", "run_id"}) - - tests := []struct { - name string - mockedClient *http.Client - requestArgs map[string]any - expectError bool - expectedErrMsg string - }{ - { - name: "successful logs deletion", - mockedClient: MockHTTPClientWithHandlers(map[string]http.HandlerFunc{ - DeleteReposActionsRunsLogsByOwnerByRepoByRunID: http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) { - w.WriteHeader(http.StatusNoContent) - }), - }), - requestArgs: map[string]any{ - "owner": "owner", - "repo": "repo", - "run_id": float64(12345), - }, - expectError: false, - }, - { - name: "missing required parameter run_id", - mockedClient: MockHTTPClientWithHandlers(map[string]http.HandlerFunc{}), - 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) - deps := BaseDeps{ - Client: client, - } - handler := toolDef.Handler(deps) - - // Create call request - request := createMCPRequest(tc.requestArgs) - - // Call handler - result, err := handler(ContextWithDeps(context.Background(), deps), &request) - - require.NoError(t, err) - require.Equal(t, tc.expectError, result.IsError) - - // Parse the result and get the text content if no error - textContent := getTextResult(t, result) - - if tc.expectedErrMsg != "" { - assert.Equal(t, tc.expectedErrMsg, textContent.Text) - return - } - - // 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, float64(12345), response["run_id"]) - }) - } -} - -func Test_GetWorkflowRunUsage(t *testing.T) { - // Verify tool definition once - toolDef := GetWorkflowRunUsage(translations.NullTranslationHelper) - require.NoError(t, toolsnaps.Test(toolDef.Tool.Name, toolDef.Tool)) - - assert.Equal(t, "get_workflow_run_usage", toolDef.Tool.Name) - assert.NotEmpty(t, toolDef.Tool.Description) - assert.Contains(t, toolDef.Tool.InputSchema.(*jsonschema.Schema).Properties, "owner") - assert.Contains(t, toolDef.Tool.InputSchema.(*jsonschema.Schema).Properties, "repo") - assert.Contains(t, toolDef.Tool.InputSchema.(*jsonschema.Schema).Properties, "run_id") - assert.ElementsMatch(t, toolDef.Tool.InputSchema.(*jsonschema.Schema).Required, []string{"owner", "repo", "run_id"}) - - tests := []struct { - name string - mockedClient *http.Client - requestArgs map[string]any - expectError bool - expectedErrMsg string - }{ - { - name: "successful workflow run usage", - mockedClient: MockHTTPClientWithHandlers(map[string]http.HandlerFunc{ - 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{ - "owner": "owner", - "repo": "repo", - "run_id": float64(12345), - }, - expectError: false, - }, - { - name: "missing required parameter run_id", - mockedClient: MockHTTPClientWithHandlers(map[string]http.HandlerFunc{}), - 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) - deps := BaseDeps{ - Client: client, - } - handler := toolDef.Handler(deps) - - // Create call request - request := createMCPRequest(tc.requestArgs) - - // Call handler - result, err := handler(ContextWithDeps(context.Background(), deps), &request) - - require.NoError(t, err) - require.Equal(t, tc.expectError, result.IsError) - - // Parse the result and get the text content if no error - textContent := getTextResult(t, result) - - if tc.expectedErrMsg != "" { - assert.Equal(t, tc.expectedErrMsg, textContent.Text) - return - } - - // Unmarshal and verify the result - 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) - }) - } -} - -func Test_GetJobLogs(t *testing.T) { - // Verify tool definition once - toolDef := GetJobLogs(translations.NullTranslationHelper) - require.NoError(t, toolsnaps.Test(toolDef.Tool.Name, toolDef.Tool)) - - assert.Equal(t, "get_job_logs", toolDef.Tool.Name) - assert.NotEmpty(t, toolDef.Tool.Description) - assert.Contains(t, toolDef.Tool.InputSchema.(*jsonschema.Schema).Properties, "owner") - assert.Contains(t, toolDef.Tool.InputSchema.(*jsonschema.Schema).Properties, "repo") - assert.Contains(t, toolDef.Tool.InputSchema.(*jsonschema.Schema).Properties, "job_id") - assert.Contains(t, toolDef.Tool.InputSchema.(*jsonschema.Schema).Properties, "run_id") - assert.Contains(t, toolDef.Tool.InputSchema.(*jsonschema.Schema).Properties, "failed_only") - assert.Contains(t, toolDef.Tool.InputSchema.(*jsonschema.Schema).Properties, "return_content") - assert.ElementsMatch(t, toolDef.Tool.InputSchema.(*jsonschema.Schema).Required, []string{"owner", "repo"}) - - tests := []struct { - name string - mockedClient *http.Client - requestArgs map[string]any - expectError bool - expectedErrMsg string - checkResponse func(t *testing.T, response map[string]any) - }{ - { - name: "successful single job logs with URL", - mockedClient: MockHTTPClientWithHandlers(map[string]http.HandlerFunc{ - GetReposActionsJobsLogsByOwnerByRepoByJobID: http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) { - w.Header().Set("Location", "https://github.com/logs/job/123") - w.WriteHeader(http.StatusFound) - }), - }), - requestArgs: map[string]any{ - "owner": "owner", - "repo": "repo", - "job_id": float64(123), - }, - expectError: false, - checkResponse: func(t *testing.T, response map[string]any) { - assert.Equal(t, float64(123), response["job_id"]) - assert.Contains(t, response, "logs_url") - assert.Equal(t, "Job logs are available for download", response["message"]) - assert.Contains(t, response, "note") - }, - }, - { - name: "successful failed jobs logs", - mockedClient: MockHTTPClientWithHandlers(map[string]http.HandlerFunc{ - GetReposActionsRunsJobsByOwnerByRepoByRunID: http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) { - jobs := &github.Jobs{ - TotalCount: github.Ptr(3), - Jobs: []*github.WorkflowJob{ - { - ID: github.Ptr(int64(1)), - Name: github.Ptr("test-job-1"), - Conclusion: github.Ptr("success"), - }, - { - ID: github.Ptr(int64(2)), - Name: github.Ptr("test-job-2"), - Conclusion: github.Ptr("failure"), - }, - { - ID: github.Ptr(int64(3)), - Name: github.Ptr("test-job-3"), - Conclusion: github.Ptr("failure"), - }, - }, - } - w.WriteHeader(http.StatusOK) - _ = json.NewEncoder(w).Encode(jobs) - }), - GetReposActionsJobsLogsByOwnerByRepoByJobID: http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { - w.Header().Set("Location", "https://github.com/logs/job/"+r.URL.Path[len(r.URL.Path)-1:]) - w.WriteHeader(http.StatusFound) - }), - }), - requestArgs: map[string]any{ - "owner": "owner", - "repo": "repo", - "run_id": float64(456), - "failed_only": true, - }, - expectError: false, - checkResponse: func(t *testing.T, response map[string]any) { - assert.Equal(t, float64(456), response["run_id"]) - assert.Equal(t, float64(3), response["total_jobs"]) - assert.Equal(t, float64(2), response["failed_jobs"]) - assert.Contains(t, response, "logs") - assert.Equal(t, "Retrieved logs for 2 failed jobs", response["message"]) - - logs, ok := response["logs"].([]interface{}) - assert.True(t, ok) - assert.Len(t, logs, 2) - }, - }, - { - name: "no failed jobs found", - mockedClient: MockHTTPClientWithHandlers(map[string]http.HandlerFunc{ - GetReposActionsRunsJobsByOwnerByRepoByRunID: http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) { - jobs := &github.Jobs{ - TotalCount: github.Ptr(2), - Jobs: []*github.WorkflowJob{ - { - ID: github.Ptr(int64(1)), - Name: github.Ptr("test-job-1"), - Conclusion: github.Ptr("success"), - }, - { - ID: github.Ptr(int64(2)), - Name: github.Ptr("test-job-2"), - Conclusion: github.Ptr("success"), - }, - }, - } - w.WriteHeader(http.StatusOK) - _ = json.NewEncoder(w).Encode(jobs) - }), - }), - requestArgs: map[string]any{ - "owner": "owner", - "repo": "repo", - "run_id": float64(456), - "failed_only": true, - }, - expectError: false, - checkResponse: func(t *testing.T, response map[string]any) { - assert.Equal(t, "No failed jobs found in this workflow run", response["message"]) - assert.Equal(t, float64(456), response["run_id"]) - assert.Equal(t, float64(2), response["total_jobs"]) - assert.Equal(t, float64(0), response["failed_jobs"]) - }, - }, - { - name: "missing job_id when not using failed_only", - mockedClient: MockHTTPClientWithHandlers(map[string]http.HandlerFunc{}), - requestArgs: map[string]any{ - "owner": "owner", - "repo": "repo", - }, - expectError: true, - expectedErrMsg: "job_id is required when failed_only is false", - }, - { - name: "missing run_id when using failed_only", - mockedClient: MockHTTPClientWithHandlers(map[string]http.HandlerFunc{}), - requestArgs: map[string]any{ - "owner": "owner", - "repo": "repo", - "failed_only": true, - }, - expectError: true, - expectedErrMsg: "run_id is required when failed_only is true", - }, - { - name: "missing required parameter owner", - mockedClient: MockHTTPClientWithHandlers(map[string]http.HandlerFunc{}), - requestArgs: map[string]any{ - "repo": "repo", - "job_id": float64(123), - }, - expectError: true, - expectedErrMsg: "missing required parameter: owner", - }, - { - name: "missing required parameter repo", - mockedClient: MockHTTPClientWithHandlers(map[string]http.HandlerFunc{}), - requestArgs: map[string]any{ - "owner": "owner", - "job_id": float64(123), - }, - expectError: true, - expectedErrMsg: "missing required parameter: repo", - }, - { - name: "API error when getting single job logs", - mockedClient: MockHTTPClientWithHandlers(map[string]http.HandlerFunc{ - GetReposActionsJobsLogsByOwnerByRepoByJobID: http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) { - w.WriteHeader(http.StatusNotFound) - _ = json.NewEncoder(w).Encode(map[string]string{ - "message": "Not Found", - }) - }), - }), - requestArgs: map[string]any{ - "owner": "owner", - "repo": "repo", - "job_id": float64(999), - }, - expectError: true, - }, - { - name: "API error when listing workflow jobs for failed_only", - mockedClient: MockHTTPClientWithHandlers(map[string]http.HandlerFunc{ - GetReposActionsRunsJobsByOwnerByRepoByRunID: http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) { - w.WriteHeader(http.StatusNotFound) - _ = json.NewEncoder(w).Encode(map[string]string{ - "message": "Not Found", - }) - }), - }), - requestArgs: map[string]any{ - "owner": "owner", - "repo": "repo", - "run_id": float64(999), - "failed_only": true, - }, - expectError: true, - }, - } - - for _, tc := range tests { - t.Run(tc.name, func(t *testing.T) { - // Setup client with mock - client := github.NewClient(tc.mockedClient) - deps := BaseDeps{ - Client: client, - ContentWindowSize: 5000, - } - handler := toolDef.Handler(deps) - - // Create call request - request := createMCPRequest(tc.requestArgs) - - // Call handler - result, err := handler(ContextWithDeps(context.Background(), deps), &request) - - 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.Equal(t, tc.expectedErrMsg, textContent.Text) - return - } - - 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) - - if tc.checkResponse != nil { - tc.checkResponse(t, response) - } - }) - } -} - -func Test_GetJobLogs_WithContentReturn(t *testing.T) { - // Test the return_content functionality with a mock HTTP server - 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)) - })) - defer testServer.Close() - - mockedClient := MockHTTPClientWithHandlers(map[string]http.HandlerFunc{ - GetReposActionsJobsLogsByOwnerByRepoByJobID: http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) { - w.Header().Set("Location", testServer.URL) - w.WriteHeader(http.StatusFound) - }), - }) - - client := github.NewClient(mockedClient) - toolDef := GetJobLogs(translations.NullTranslationHelper) - deps := BaseDeps{ - Client: client, - ContentWindowSize: 5000, - } - handler := toolDef.Handler(deps) - - request := createMCPRequest(map[string]any{ - "owner": "owner", - "repo": "repo", - "job_id": float64(123), - "return_content": true, - }) - - result, err := handler(ContextWithDeps(context.Background(), deps), &request) - require.NoError(t, err) - require.False(t, result.IsError) - - textContent := getTextResult(t, result) - var response map[string]any - err = json.Unmarshal([]byte(textContent.Text), &response) - require.NoError(t, err) - - assert.Equal(t, float64(123), response["job_id"]) - assert.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 -} - -func Test_GetJobLogs_WithContentReturnAndTailLines(t *testing.T) { - // Test the return_content functionality with a mock HTTP server - 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)) - })) - defer testServer.Close() - - mockedClient := MockHTTPClientWithHandlers(map[string]http.HandlerFunc{ - GetReposActionsJobsLogsByOwnerByRepoByJobID: http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) { - w.Header().Set("Location", testServer.URL) - w.WriteHeader(http.StatusFound) - }), - }) - - client := github.NewClient(mockedClient) - toolDef := GetJobLogs(translations.NullTranslationHelper) - deps := BaseDeps{ - Client: client, - ContentWindowSize: 5000, - } - handler := toolDef.Handler(deps) - - request := createMCPRequest(map[string]any{ - "owner": "owner", - "repo": "repo", - "job_id": float64(123), - "return_content": true, - "tail_lines": float64(1), // Requesting last 1 line - }) - - result, err := handler(ContextWithDeps(context.Background(), deps), &request) - require.NoError(t, err) - require.False(t, result.IsError) - - textContent := getTextResult(t, result) - var response map[string]any - err = json.Unmarshal([]byte(textContent.Text), &response) - require.NoError(t, err) - - assert.Equal(t, float64(123), response["job_id"]) - assert.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 -} - -func Test_GetJobLogs_WithContentReturnAndLargeTailLines(t *testing.T) { - logContent := "Line 1\nLine 2\nLine 3" - expectedLogContent := "Line 1\nLine 2\nLine 3" - - testServer := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) { - w.WriteHeader(http.StatusOK) - _, _ = w.Write([]byte(logContent)) - })) - defer testServer.Close() - - mockedClient := MockHTTPClientWithHandlers(map[string]http.HandlerFunc{ - GetReposActionsJobsLogsByOwnerByRepoByJobID: http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) { - w.Header().Set("Location", testServer.URL) - w.WriteHeader(http.StatusFound) - }), - }) - - client := github.NewClient(mockedClient) - toolDef := GetJobLogs(translations.NullTranslationHelper) - deps := BaseDeps{ - Client: client, - ContentWindowSize: 5000, - } - handler := toolDef.Handler(deps) - - request := createMCPRequest(map[string]any{ - "owner": "owner", - "repo": "repo", - "job_id": float64(123), - "return_content": true, - "tail_lines": float64(100), - }) - - result, err := handler(ContextWithDeps(context.Background(), deps), &request) - require.NoError(t, err) - require.False(t, result.IsError) - - textContent := getTextResult(t, result) - var response map[string]any - err = json.Unmarshal([]byte(textContent.Text), &response) - require.NoError(t, err) - - assert.Equal(t, float64(123), response["job_id"]) - assert.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") -} - -func Test_MemoryUsage_SlidingWindow_vs_NoWindow(t *testing.T) { - if testing.Short() { - t.Skip("Skipping memory profiling test in short mode") - } - - const logLines = 100000 - const bufferSize = 5000 - largeLogContent := strings.Repeat("log line with some content\n", logLines-1) + "final log line" - - testServer := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) { - w.WriteHeader(http.StatusOK) - _, _ = w.Write([]byte(largeLogContent)) - })) - defer testServer.Close() - - os.Setenv("GITHUB_MCP_PROFILING_ENABLED", "true") - defer os.Unsetenv("GITHUB_MCP_PROFILING_ENABLED") - - profiler.InitFromEnv(nil) - ctx := context.Background() - - debug.SetGCPercent(-1) - defer debug.SetGCPercent(100) - - for i := 0; i < 3; i++ { - runtime.GC() - } - - var baselineStats runtime.MemStats - runtime.ReadMemStats(&baselineStats) - - profile1, err1 := profiler.ProfileFuncWithMetrics(ctx, "sliding_window", func() (int, int64, error) { - resp1, err := http.Get(testServer.URL) - if err != nil { - return 0, 0, err - } - defer resp1.Body.Close() //nolint:bodyclose - content, totalLines, _, err := buffer.ProcessResponseAsRingBufferToEnd(resp1, bufferSize) //nolint:bodyclose - return totalLines, int64(len(content)), err - }) - require.NoError(t, err1) - - for i := 0; i < 3; i++ { - runtime.GC() - } - - profile2, err2 := profiler.ProfileFuncWithMetrics(ctx, "no_window", func() (int, int64, error) { - resp2, err := http.Get(testServer.URL) - if err != nil { - return 0, 0, err - } - defer resp2.Body.Close() //nolint:bodyclose - - allContent, err := io.ReadAll(resp2.Body) - if err != nil { - return 0, 0, err - } - - allLines := strings.Split(string(allContent), "\n") - var nonEmptyLines []string - for _, line := range allLines { - if line != "" { - nonEmptyLines = append(nonEmptyLines, line) - } - } - totalLines := len(nonEmptyLines) - - var resultLines []string - if totalLines > bufferSize { - resultLines = nonEmptyLines[totalLines-bufferSize:] - } else { - resultLines = nonEmptyLines - } - - result := strings.Join(resultLines, "\n") - return totalLines, int64(len(result)), nil - }) - require.NoError(t, err2) - - assert.Greater(t, profile2.MemoryDelta, profile1.MemoryDelta, - "Sliding window should use less memory than reading all into memory") - - assert.Equal(t, profile1.LinesCount, profile2.LinesCount, - "Both approaches should count the same number of input lines") - assert.InDelta(t, profile1.BytesCount, profile2.BytesCount, 100, - "Both approaches should produce similar output sizes (within 100 bytes)") - - memoryReduction := float64(profile2.MemoryDelta-profile1.MemoryDelta) / float64(profile2.MemoryDelta) * 100 - t.Logf("Memory reduction: %.1f%% (%.2f MB vs %.2f MB)", - memoryReduction, - float64(profile2.MemoryDelta)/1024/1024, - float64(profile1.MemoryDelta)/1024/1024) - - t.Logf("Baseline: %d bytes", baselineStats.Alloc) - t.Logf("Sliding window: %s", profile1.String()) - t.Logf("No window: %s", profile2.String()) -} - -func Test_ListWorkflowRuns(t *testing.T) { - // Verify tool definition once - toolDef := ListWorkflowRuns(translations.NullTranslationHelper) - require.NoError(t, toolsnaps.Test(toolDef.Tool.Name, toolDef.Tool)) - - assert.Equal(t, "list_workflow_runs", toolDef.Tool.Name) - assert.NotEmpty(t, toolDef.Tool.Description) - inputSchema := toolDef.Tool.InputSchema.(*jsonschema.Schema) - assert.Contains(t, inputSchema.Properties, "owner") - assert.Contains(t, inputSchema.Properties, "repo") - assert.Contains(t, inputSchema.Properties, "workflow_id") - assert.ElementsMatch(t, inputSchema.Required, []string{"owner", "repo", "workflow_id"}) -} - -func Test_GetWorkflowRun(t *testing.T) { - // Verify tool definition once - toolDef := GetWorkflowRun(translations.NullTranslationHelper) - require.NoError(t, toolsnaps.Test(toolDef.Tool.Name, toolDef.Tool)) - - assert.Equal(t, "get_workflow_run", toolDef.Tool.Name) - assert.NotEmpty(t, toolDef.Tool.Description) - inputSchema := toolDef.Tool.InputSchema.(*jsonschema.Schema) - assert.Contains(t, inputSchema.Properties, "owner") - assert.Contains(t, inputSchema.Properties, "repo") - assert.Contains(t, inputSchema.Properties, "run_id") - assert.ElementsMatch(t, inputSchema.Required, []string{"owner", "repo", "run_id"}) -} - -func Test_GetWorkflowRunLogs(t *testing.T) { - // Verify tool definition once - toolDef := GetWorkflowRunLogs(translations.NullTranslationHelper) - require.NoError(t, toolsnaps.Test(toolDef.Tool.Name, toolDef.Tool)) - - assert.Equal(t, "get_workflow_run_logs", toolDef.Tool.Name) - assert.NotEmpty(t, toolDef.Tool.Description) - inputSchema := toolDef.Tool.InputSchema.(*jsonschema.Schema) - assert.Contains(t, inputSchema.Properties, "owner") - assert.Contains(t, inputSchema.Properties, "repo") - assert.Contains(t, inputSchema.Properties, "run_id") - assert.ElementsMatch(t, inputSchema.Required, []string{"owner", "repo", "run_id"}) -} - -func Test_ListWorkflowJobs(t *testing.T) { - // Verify tool definition once - toolDef := ListWorkflowJobs(translations.NullTranslationHelper) - require.NoError(t, toolsnaps.Test(toolDef.Tool.Name, toolDef.Tool)) - - assert.Equal(t, "list_workflow_jobs", toolDef.Tool.Name) - assert.NotEmpty(t, toolDef.Tool.Description) - inputSchema := toolDef.Tool.InputSchema.(*jsonschema.Schema) - assert.Contains(t, inputSchema.Properties, "owner") - assert.Contains(t, inputSchema.Properties, "repo") - assert.Contains(t, inputSchema.Properties, "run_id") - assert.ElementsMatch(t, inputSchema.Required, []string{"owner", "repo", "run_id"}) -} - -func Test_RerunWorkflowRun(t *testing.T) { - // Verify tool definition once - toolDef := RerunWorkflowRun(translations.NullTranslationHelper) - require.NoError(t, toolsnaps.Test(toolDef.Tool.Name, toolDef.Tool)) - - assert.Equal(t, "rerun_workflow_run", toolDef.Tool.Name) - assert.NotEmpty(t, toolDef.Tool.Description) - inputSchema := toolDef.Tool.InputSchema.(*jsonschema.Schema) - assert.Contains(t, inputSchema.Properties, "owner") - assert.Contains(t, inputSchema.Properties, "repo") - assert.Contains(t, inputSchema.Properties, "run_id") - assert.ElementsMatch(t, inputSchema.Required, []string{"owner", "repo", "run_id"}) -} - -func Test_RerunFailedJobs(t *testing.T) { - // Verify tool definition once - toolDef := RerunFailedJobs(translations.NullTranslationHelper) - require.NoError(t, toolsnaps.Test(toolDef.Tool.Name, toolDef.Tool)) - - assert.Equal(t, "rerun_failed_jobs", toolDef.Tool.Name) - assert.NotEmpty(t, toolDef.Tool.Description) - inputSchema := toolDef.Tool.InputSchema.(*jsonschema.Schema) - assert.Contains(t, inputSchema.Properties, "owner") - assert.Contains(t, inputSchema.Properties, "repo") - assert.Contains(t, inputSchema.Properties, "run_id") - assert.ElementsMatch(t, inputSchema.Required, []string{"owner", "repo", "run_id"}) - - tests := []struct { - name string - mockedClient *http.Client - requestArgs map[string]any - expectError bool - expectedErrMsg string - }{ - { - name: "successful rerun of failed jobs", - mockedClient: mock.NewMockedHTTPClient( - mock.WithRequestMatchHandler( - mock.EndpointPattern{ - Pattern: "/repos/owner/repo/actions/runs/12345/rerun-failed-jobs", - Method: "POST", - }, - http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) { - w.WriteHeader(http.StatusCreated) - }), - ), - ), - requestArgs: map[string]any{ - "owner": "owner", - "repo": "repo", - "run_id": float64(12345), - }, - expectError: false, - }, - { - name: "missing required parameter run_id", - mockedClient: mock.NewMockedHTTPClient(), - requestArgs: map[string]any{ - "owner": "owner", - "repo": "repo", - }, - expectError: true, - expectedErrMsg: "missing required parameter: run_id", - }, - } - - for _, tc := range tests { - t.Run(tc.name, func(t *testing.T) { - client := github.NewClient(tc.mockedClient) - deps := BaseDeps{ - Client: client, - } - handler := toolDef.Handler(deps) - - request := createMCPRequest(tc.requestArgs) - result, err := handler(ContextWithDeps(context.Background(), deps), &request) - - require.NoError(t, err) - require.Equal(t, tc.expectError, result.IsError) - - textContent := getTextResult(t, result) - - if tc.expectedErrMsg != "" { - assert.Equal(t, tc.expectedErrMsg, textContent.Text) - return - } - - var response map[string]any - err = json.Unmarshal([]byte(textContent.Text), &response) - require.NoError(t, err) - assert.Equal(t, "Failed jobs have been queued for re-run", response["message"]) - assert.Equal(t, float64(12345), response["run_id"]) - }) - } -} - -func Test_RerunWorkflowRun_Behavioral(t *testing.T) { - toolDef := RerunWorkflowRun(translations.NullTranslationHelper) - - tests := []struct { - name string - mockedClient *http.Client - requestArgs map[string]any - expectError bool - expectedErrMsg string - }{ - { - name: "successful rerun of workflow run", - mockedClient: mock.NewMockedHTTPClient( - mock.WithRequestMatchHandler( - mock.EndpointPattern{ - Pattern: "/repos/owner/repo/actions/runs/12345/rerun", - Method: "POST", - }, - http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) { - w.WriteHeader(http.StatusCreated) - }), - ), - ), - requestArgs: map[string]any{ - "owner": "owner", - "repo": "repo", - "run_id": float64(12345), - }, - expectError: false, - }, - { - name: "missing required parameter run_id", - mockedClient: mock.NewMockedHTTPClient(), - requestArgs: map[string]any{ - "owner": "owner", - "repo": "repo", - }, - expectError: true, - expectedErrMsg: "missing required parameter: run_id", - }, - } - - for _, tc := range tests { - t.Run(tc.name, func(t *testing.T) { - client := github.NewClient(tc.mockedClient) - deps := BaseDeps{ - Client: client, - } - handler := toolDef.Handler(deps) - - request := createMCPRequest(tc.requestArgs) - result, err := handler(ContextWithDeps(context.Background(), deps), &request) - - require.NoError(t, err) - require.Equal(t, tc.expectError, result.IsError) - - textContent := getTextResult(t, result) - - if tc.expectedErrMsg != "" { - assert.Equal(t, tc.expectedErrMsg, textContent.Text) - return - } - - var response map[string]any - err = json.Unmarshal([]byte(textContent.Text), &response) - require.NoError(t, err) - assert.Equal(t, "Workflow run has been queued for re-run", response["message"]) - assert.Equal(t, float64(12345), response["run_id"]) - }) - } -} - -func Test_ListWorkflowRuns_Behavioral(t *testing.T) { - toolDef := ListWorkflowRuns(translations.NullTranslationHelper) - - tests := []struct { - name string - mockedClient *http.Client - requestArgs map[string]any - expectError bool - expectedErrMsg string - }{ - { - name: "successful workflow runs listing", - mockedClient: mock.NewMockedHTTPClient( - mock.WithRequestMatchHandler( - mock.GetReposActionsWorkflowsRunsByOwnerByRepoByWorkflowId, - http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) { - runs := &github.WorkflowRuns{ - TotalCount: github.Ptr(2), - WorkflowRuns: []*github.WorkflowRun{ - { - ID: github.Ptr(int64(123)), - Name: github.Ptr("CI"), - Status: github.Ptr("completed"), - Conclusion: github.Ptr("success"), - }, - { - ID: github.Ptr(int64(456)), - Name: github.Ptr("CI"), - Status: github.Ptr("completed"), - Conclusion: github.Ptr("failure"), - }, - }, - } - w.WriteHeader(http.StatusOK) - _ = json.NewEncoder(w).Encode(runs) - }), - ), - ), - requestArgs: map[string]any{ - "owner": "owner", - "repo": "repo", - "workflow_id": "ci.yml", - }, - expectError: false, - }, - { - name: "missing required parameter workflow_id", - mockedClient: mock.NewMockedHTTPClient(), - requestArgs: map[string]any{ - "owner": "owner", - "repo": "repo", - }, - expectError: true, - expectedErrMsg: "missing required parameter: workflow_id", - }, - } - - for _, tc := range tests { - t.Run(tc.name, func(t *testing.T) { - client := github.NewClient(tc.mockedClient) - deps := BaseDeps{ - Client: client, - } - handler := toolDef.Handler(deps) - - request := createMCPRequest(tc.requestArgs) - result, err := handler(ContextWithDeps(context.Background(), deps), &request) - - require.NoError(t, err) - require.Equal(t, tc.expectError, result.IsError) - - textContent := getTextResult(t, result) - - if tc.expectedErrMsg != "" { - assert.Equal(t, tc.expectedErrMsg, textContent.Text) - return - } - - var response github.WorkflowRuns - err = json.Unmarshal([]byte(textContent.Text), &response) - require.NoError(t, err) - assert.NotNil(t, response.TotalCount) - assert.Greater(t, *response.TotalCount, 0) - }) - } -} - -func Test_GetWorkflowRun_Behavioral(t *testing.T) { - toolDef := GetWorkflowRun(translations.NullTranslationHelper) - - tests := []struct { - name string - mockedClient *http.Client - requestArgs map[string]any - expectError bool - expectedErrMsg string - }{ - { - name: "successful get workflow run", - mockedClient: mock.NewMockedHTTPClient( - mock.WithRequestMatchHandler( - mock.GetReposActionsRunsByOwnerByRepoByRunId, - http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) { - run := &github.WorkflowRun{ - ID: github.Ptr(int64(12345)), - Name: github.Ptr("CI"), - Status: github.Ptr("completed"), - Conclusion: github.Ptr("success"), - } - w.WriteHeader(http.StatusOK) - _ = json.NewEncoder(w).Encode(run) - }), - ), - ), - requestArgs: map[string]any{ - "owner": "owner", - "repo": "repo", - "run_id": float64(12345), - }, - expectError: false, - }, - { - name: "missing required parameter run_id", - mockedClient: mock.NewMockedHTTPClient(), - requestArgs: map[string]any{ - "owner": "owner", - "repo": "repo", - }, - expectError: true, - expectedErrMsg: "missing required parameter: run_id", - }, - } - - for _, tc := range tests { - t.Run(tc.name, func(t *testing.T) { - client := github.NewClient(tc.mockedClient) - deps := BaseDeps{ - Client: client, - } - handler := toolDef.Handler(deps) - - request := createMCPRequest(tc.requestArgs) - result, err := handler(ContextWithDeps(context.Background(), deps), &request) - - require.NoError(t, err) - require.Equal(t, tc.expectError, result.IsError) - - textContent := getTextResult(t, result) - - if tc.expectedErrMsg != "" { - assert.Equal(t, tc.expectedErrMsg, textContent.Text) - return - } - - var response github.WorkflowRun - err = json.Unmarshal([]byte(textContent.Text), &response) - require.NoError(t, err) - assert.NotNil(t, response.ID) - assert.Equal(t, int64(12345), *response.ID) - }) - } -} - -func Test_GetWorkflowRunLogs_Behavioral(t *testing.T) { - toolDef := GetWorkflowRunLogs(translations.NullTranslationHelper) - - tests := []struct { - name string - mockedClient *http.Client - requestArgs map[string]any - expectError bool - expectedErrMsg string - }{ - { - name: "successful get workflow run logs", - mockedClient: mock.NewMockedHTTPClient( - mock.WithRequestMatchHandler( - mock.GetReposActionsRunsLogsByOwnerByRepoByRunId, - http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) { - w.Header().Set("Location", "https://github.com/logs/run/12345") - w.WriteHeader(http.StatusFound) - }), - ), - ), - requestArgs: map[string]any{ - "owner": "owner", - "repo": "repo", - "run_id": float64(12345), - }, - expectError: false, - }, - { - name: "missing required parameter run_id", - mockedClient: mock.NewMockedHTTPClient(), - requestArgs: map[string]any{ - "owner": "owner", - "repo": "repo", - }, - expectError: true, - expectedErrMsg: "missing required parameter: run_id", - }, - } - - for _, tc := range tests { - t.Run(tc.name, func(t *testing.T) { - client := github.NewClient(tc.mockedClient) - deps := BaseDeps{ - Client: client, - } - handler := toolDef.Handler(deps) - - request := createMCPRequest(tc.requestArgs) - result, err := handler(ContextWithDeps(context.Background(), deps), &request) - - require.NoError(t, err) - require.Equal(t, tc.expectError, result.IsError) - - textContent := getTextResult(t, result) - - if tc.expectedErrMsg != "" { - assert.Equal(t, tc.expectedErrMsg, textContent.Text) - return - } - - var response map[string]any - err = json.Unmarshal([]byte(textContent.Text), &response) - require.NoError(t, err) - assert.Contains(t, response, "logs_url") - assert.Equal(t, "Workflow run logs are available for download", response["message"]) - }) - } -} - -func Test_ListWorkflowJobs_Behavioral(t *testing.T) { - toolDef := ListWorkflowJobs(translations.NullTranslationHelper) - - tests := []struct { - name string - mockedClient *http.Client - requestArgs map[string]any - expectError bool - expectedErrMsg string - }{ - { - name: "successful list workflow jobs", - mockedClient: mock.NewMockedHTTPClient( - mock.WithRequestMatchHandler( - mock.GetReposActionsRunsJobsByOwnerByRepoByRunId, - http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) { - jobs := &github.Jobs{ - TotalCount: github.Ptr(2), - Jobs: []*github.WorkflowJob{ - { - ID: github.Ptr(int64(1)), - Name: github.Ptr("build"), - Status: github.Ptr("completed"), - Conclusion: github.Ptr("success"), - }, - { - ID: github.Ptr(int64(2)), - Name: github.Ptr("test"), - Status: github.Ptr("completed"), - Conclusion: github.Ptr("failure"), - }, - }, - } - w.WriteHeader(http.StatusOK) - _ = json.NewEncoder(w).Encode(jobs) - }), - ), - ), - requestArgs: map[string]any{ - "owner": "owner", - "repo": "repo", - "run_id": float64(12345), - }, - expectError: false, - }, - { - name: "missing required parameter run_id", - mockedClient: mock.NewMockedHTTPClient(), - requestArgs: map[string]any{ - "owner": "owner", - "repo": "repo", - }, - expectError: true, - expectedErrMsg: "missing required parameter: run_id", - }, - } - - for _, tc := range tests { - t.Run(tc.name, func(t *testing.T) { - client := github.NewClient(tc.mockedClient) - deps := BaseDeps{ - Client: client, - } - handler := toolDef.Handler(deps) - - request := createMCPRequest(tc.requestArgs) - result, err := handler(ContextWithDeps(context.Background(), deps), &request) - - require.NoError(t, err) - require.Equal(t, tc.expectError, result.IsError) - - textContent := getTextResult(t, result) - - if tc.expectedErrMsg != "" { - assert.Equal(t, tc.expectedErrMsg, textContent.Text) - return - } - - var response map[string]any - err = json.Unmarshal([]byte(textContent.Text), &response) - require.NoError(t, err) - assert.Contains(t, response, "jobs") - }) - } -} - // Tests for consolidated actions tools func Test_ActionsList(t *testing.T) { diff --git a/pkg/github/tools.go b/pkg/github/tools.go index f6d4afa80..4d7d74717 100644 --- a/pkg/github/tools.go +++ b/pkg/github/tools.go @@ -236,21 +236,6 @@ func AllTools(t translations.TranslationHelperFunc) []inventory.ServerTool { ListDiscussionCategories(t), // Actions tools - ListWorkflows(t), - ListWorkflowRuns(t), - GetWorkflowRun(t), - GetWorkflowRunLogs(t), - ListWorkflowJobs(t), - GetJobLogs(t), - ListWorkflowRunArtifacts(t), - DownloadWorkflowRunArtifact(t), - GetWorkflowRunUsage(t), - RunWorkflow(t), - RerunWorkflowRun(t), - RerunFailedJobs(t), - CancelWorkflowRun(t), - DeleteWorkflowRunLogs(t), - // Consolidated Actions tools (enabled via feature flag) ActionsList(t), ActionsGet(t), ActionsRunTrigger(t),