diff --git a/README.md b/README.md index 9dba301d..9f60bdbe 100644 --- a/README.md +++ b/README.md @@ -151,6 +151,7 @@ The following sets of tools are available (all are on by default): | `issues` | Issue-related tools (create, read, update, comment) | | `users` | Anything relating to GitHub Users | | `pull_requests` | Pull request operations (create, merge, review) | +| `actions` | GitHub Actions workflows and CI/CD operations | | `code_security` | Code scanning alerts and security features | | `experiments` | Experimental features (not considered stable) | @@ -161,12 +162,12 @@ To specify toolsets you want available to the LLM, you can pass an allow-list in 1. **Using Command Line Argument**: ```bash - github-mcp-server --toolsets repos,issues,pull_requests,code_security + github-mcp-server --toolsets repos,issues,pull_requests,actions,code_security ``` 2. **Using Environment Variable**: ```bash - GITHUB_TOOLSETS="repos,issues,pull_requests,code_security" ./github-mcp-server + GITHUB_TOOLSETS="repos,issues,pull_requests,actions,code_security" ./github-mcp-server ``` The environment variable `GITHUB_TOOLSETS` takes precedence over the command line argument if both are provided. @@ -178,7 +179,7 @@ When using Docker, you can pass the toolsets as environment variables: ```bash docker run -i --rm \ -e GITHUB_PERSONAL_ACCESS_TOKEN= \ - -e GITHUB_TOOLSETS="repos,issues,pull_requests,code_security,experiments" \ + -e GITHUB_TOOLSETS="repos,issues,pull_requests,actions,code_security,experiments" \ ghcr.io/github/github-mcp-server ``` @@ -568,6 +569,110 @@ export GITHUB_MCP_TOOL_ADD_ISSUE_COMMENT_DESCRIPTION="an alternative description - `page`: Page number (number, optional) - `perPage`: Results per page (number, optional) +### Actions + +- **list_workflows** - List workflows in a repository + + - `owner`: Repository owner (string, required) + - `repo`: Repository name (string, required) + - `page`: Page number (number, optional) + - `perPage`: Results per page (number, optional) + +- **list_workflow_runs** - List workflow runs for a specific workflow + + - `owner`: Repository owner (string, required) + - `repo`: Repository name (string, required) + - `workflow_id`: Workflow ID or filename (string, required) + - `branch`: Filter by branch name (string, optional) + - `event`: Filter by event type (string, optional) + - `status`: Filter by run status (string, optional) + - `page`: Page number (number, optional) + - `perPage`: Results per page (number, optional) + +- **run_workflow** - Trigger a workflow via workflow_dispatch event + + - `owner`: Repository owner (string, required) + - `repo`: Repository name (string, required) + - `workflow_id`: Workflow ID or filename (string, required) + - `ref`: Git reference (branch, tag, or SHA) (string, required) + - `inputs`: Input parameters for the workflow (object, optional) + +- **get_workflow_run** - Get details of a specific workflow run + + - `owner`: Repository owner (string, required) + - `repo`: Repository name (string, required) + - `run_id`: Workflow run ID (number, required) + +- **get_workflow_run_logs** - Download logs for a workflow run + + - `owner`: Repository owner (string, required) + - `repo`: Repository name (string, required) + - `run_id`: Workflow run ID (number, required) + +- **list_workflow_jobs** - List jobs for a workflow run + + - `owner`: Repository owner (string, required) + - `repo`: Repository name (string, required) + - `run_id`: Workflow run ID (number, required) + - `filter`: Filter by job status (string, optional) + - `page`: Page number (number, optional) + - `perPage`: Results per page (number, optional) + +- **get_job_logs** - Download logs for a specific workflow job or efficiently get all failed job logs for a workflow run + + - `owner`: Repository owner (string, required) + - `repo`: Repository name (string, required) + - `job_id`: Job ID (number, required for single job logs) + - `run_id`: Workflow run ID (number, required when using failed_only) + - `failed_only`: When true, gets logs for all failed jobs in run_id (boolean, optional) + - `return_content`: Returns actual log content instead of URLs (boolean, optional) + +- **rerun_workflow_run** - Re-run an entire workflow + + - `owner`: Repository owner (string, required) + - `repo`: Repository name (string, required) + - `run_id`: Workflow run ID (number, required) + - `enable_debug_logging`: Enable debug logging for the re-run (boolean, optional) + +- **rerun_failed_jobs** - Re-run only the failed jobs in a workflow run + + - `owner`: Repository owner (string, required) + - `repo`: Repository name (string, required) + - `run_id`: Workflow run ID (number, required) + - `enable_debug_logging`: Enable debug logging for the re-run (boolean, optional) + +- **cancel_workflow_run** - Cancel a running workflow + + - `owner`: Repository owner (string, required) + - `repo`: Repository name (string, required) + - `run_id`: Workflow run ID (number, required) + +- **list_workflow_run_artifacts** - List artifacts from a workflow run + + - `owner`: Repository owner (string, required) + - `repo`: Repository name (string, required) + - `run_id`: Workflow run ID (number, required) + - `page`: Page number (number, optional) + - `perPage`: Results per page (number, optional) + +- **download_workflow_run_artifact** - Get download URL for a specific artifact + + - `owner`: Repository owner (string, required) + - `repo`: Repository name (string, required) + - `artifact_id`: Artifact ID (number, required) + +- **delete_workflow_run_logs** - Delete logs for a workflow run + + - `owner`: Repository owner (string, required) + - `repo`: Repository name (string, required) + - `run_id`: Workflow run ID (number, required) + +- **get_workflow_run_usage** - Get usage metrics for a workflow run + + - `owner`: Repository owner (string, required) + - `repo`: Repository name (string, required) + - `run_id`: Workflow run ID (number, required) + ### Code Scanning - **get_code_scanning_alert** - Get a code scanning alert diff --git a/pkg/github/actions.go b/pkg/github/actions.go new file mode 100644 index 00000000..543b1558 --- /dev/null +++ b/pkg/github/actions.go @@ -0,0 +1,1156 @@ +package github + +import ( + "context" + "encoding/json" + "fmt" + "io" + "net/http" + "strings" + + "github.com/github/github-mcp-server/pkg/translations" + "github.com/google/go-github/v72/github" + "github.com/mark3labs/mcp-go/mcp" + "github.com/mark3labs/mcp-go/server" +) + +// ListWorkflows creates a tool to list workflows in a repository +func ListWorkflows(getClient GetClientFn, t translations.TranslationHelperFunc) (tool mcp.Tool, handler server.ToolHandlerFunc) { + return mcp.NewTool("list_workflows", + mcp.WithDescription(t("TOOL_LIST_WORKFLOWS_DESCRIPTION", "List workflows in a repository")), + mcp.WithToolAnnotation(mcp.ToolAnnotation{ + ReadOnlyHint: ToBoolPtr(true), + }), + mcp.WithString("owner", + mcp.Required(), + mcp.Description("The account owner of the repository. The name is not case sensitive."), + ), + mcp.WithString("repo", + mcp.Required(), + mcp.Description("Repository name"), + ), + mcp.WithNumber("per_page", + mcp.Description("The number of results per page (max 100)"), + ), + mcp.WithNumber("page", + mcp.Description("The page number of the results to fetch"), + ), + ), + func(ctx context.Context, request mcp.CallToolRequest) (*mcp.CallToolResult, error) { + owner, err := RequiredParam[string](request, "owner") + if err != nil { + return mcp.NewToolResultError(err.Error()), nil + } + repo, err := RequiredParam[string](request, "repo") + if err != nil { + return mcp.NewToolResultError(err.Error()), nil + } + + // Get optional pagination parameters + perPage, err := OptionalIntParam(request, "per_page") + if err != nil { + return mcp.NewToolResultError(err.Error()), nil + } + page, err := OptionalIntParam(request, "page") + if err != nil { + return mcp.NewToolResultError(err.Error()), nil + } + + client, err := getClient(ctx) + if err != nil { + return nil, fmt.Errorf("failed to get GitHub client: %w", err) + } + + // Set up list options + opts := &github.ListOptions{ + PerPage: perPage, + Page: page, + } + + workflows, resp, err := client.Actions.ListWorkflows(ctx, owner, repo, opts) + if err != nil { + return nil, fmt.Errorf("failed to list workflows: %w", err) + } + defer func() { _ = resp.Body.Close() }() + + r, err := json.Marshal(workflows) + if err != nil { + return nil, fmt.Errorf("failed to marshal response: %w", err) + } + + return mcp.NewToolResultText(string(r)), nil + } +} + +// ListWorkflowRuns creates a tool to list workflow runs for a specific workflow +func ListWorkflowRuns(getClient GetClientFn, t translations.TranslationHelperFunc) (tool mcp.Tool, handler server.ToolHandlerFunc) { + return mcp.NewTool("list_workflow_runs", + mcp.WithDescription(t("TOOL_LIST_WORKFLOW_RUNS_DESCRIPTION", "List workflow runs for a specific workflow")), + mcp.WithToolAnnotation(mcp.ToolAnnotation{ + ReadOnlyHint: ToBoolPtr(true), + }), + mcp.WithString("owner", + mcp.Required(), + mcp.Description("The account owner of the repository. The name is not case sensitive."), + ), + mcp.WithString("repo", + mcp.Required(), + mcp.Description("Repository name"), + ), + mcp.WithString("workflow_id", + mcp.Required(), + mcp.Description("The workflow ID or workflow file name"), + ), + mcp.WithString("actor", + mcp.Description("Returns someone's workflow runs. Use the login for the user who created the workflow run."), + ), + mcp.WithString("branch", + mcp.Description("Returns workflow runs associated with a branch. Use the name of the branch."), + ), + mcp.WithString("event", + mcp.Description("Returns workflow runs for an event. For example, push, pull_request, or issue."), + ), + mcp.WithString("status", + mcp.Description("Returns workflow runs with the check run status. For example, completed, in_progress, or requested."), + ), + mcp.WithNumber("per_page", + mcp.Description("The number of results per page (max 100)"), + ), + mcp.WithNumber("page", + mcp.Description("The page number of the results to fetch"), + ), + ), + func(ctx context.Context, request mcp.CallToolRequest) (*mcp.CallToolResult, error) { + owner, err := RequiredParam[string](request, "owner") + if err != nil { + return mcp.NewToolResultError(err.Error()), nil + } + repo, err := RequiredParam[string](request, "repo") + if err != nil { + return mcp.NewToolResultError(err.Error()), nil + } + workflowID, err := RequiredParam[string](request, "workflow_id") + if err != nil { + return mcp.NewToolResultError(err.Error()), nil + } + + // Get optional filtering parameters + actor, err := OptionalParam[string](request, "actor") + if err != nil { + return mcp.NewToolResultError(err.Error()), nil + } + branch, err := OptionalParam[string](request, "branch") + if err != nil { + return mcp.NewToolResultError(err.Error()), nil + } + event, err := OptionalParam[string](request, "event") + if err != nil { + return mcp.NewToolResultError(err.Error()), nil + } + status, err := OptionalParam[string](request, "status") + if err != nil { + return mcp.NewToolResultError(err.Error()), nil + } + + // Get optional pagination parameters + perPage, err := OptionalIntParam(request, "per_page") + if err != nil { + return mcp.NewToolResultError(err.Error()), nil + } + page, err := OptionalIntParam(request, "page") + if err != nil { + return mcp.NewToolResultError(err.Error()), nil + } + + client, err := getClient(ctx) + if err != nil { + return nil, fmt.Errorf("failed to get GitHub client: %w", err) + } + + // Set up list options + opts := &github.ListWorkflowRunsOptions{ + Actor: actor, + Branch: branch, + Event: event, + Status: status, + ListOptions: github.ListOptions{ + PerPage: perPage, + Page: page, + }, + } + + workflowRuns, resp, err := client.Actions.ListWorkflowRunsByFileName(ctx, owner, repo, workflowID, opts) + if err != nil { + return nil, fmt.Errorf("failed to list workflow runs: %w", err) + } + defer func() { _ = resp.Body.Close() }() + + r, err := json.Marshal(workflowRuns) + if err != nil { + return nil, fmt.Errorf("failed to marshal response: %w", err) + } + + return mcp.NewToolResultText(string(r)), nil + } +} + +// RunWorkflow creates a tool to run an Actions workflow +func RunWorkflow(getClient GetClientFn, t translations.TranslationHelperFunc) (tool mcp.Tool, handler server.ToolHandlerFunc) { + return mcp.NewTool("run_workflow", + mcp.WithDescription(t("TOOL_RUN_WORKFLOW_DESCRIPTION", "Run an Actions workflow")), + mcp.WithToolAnnotation(mcp.ToolAnnotation{ + ReadOnlyHint: ToBoolPtr(false), + }), + mcp.WithString("owner", + mcp.Required(), + mcp.Description("The account owner of the repository. The name is not case sensitive."), + ), + mcp.WithString("repo", + mcp.Required(), + mcp.Description("Repository name"), + ), + mcp.WithString("workflow_file", + mcp.Required(), + mcp.Description("The workflow ID or workflow file name"), + ), + mcp.WithString("ref", + mcp.Required(), + mcp.Description("The git reference for the workflow. The reference can be a branch or tag name."), + ), + mcp.WithObject("inputs", + mcp.Description("Inputs the workflow accepts"), + ), + ), + func(ctx context.Context, request mcp.CallToolRequest) (*mcp.CallToolResult, error) { + owner, err := RequiredParam[string](request, "owner") + if err != nil { + return mcp.NewToolResultError(err.Error()), nil + } + repo, err := RequiredParam[string](request, "repo") + if err != nil { + return mcp.NewToolResultError(err.Error()), nil + } + workflowFile, err := RequiredParam[string](request, "workflow_file") + if err != nil { + return mcp.NewToolResultError(err.Error()), nil + } + ref, err := RequiredParam[string](request, "ref") + if err != nil { + return mcp.NewToolResultError(err.Error()), nil + } + + // Get optional inputs parameter + var inputs map[string]interface{} + if requestInputs, ok := request.GetArguments()["inputs"]; ok { + if inputsMap, ok := requestInputs.(map[string]interface{}); ok { + inputs = inputsMap + } + } + + client, err := getClient(ctx) + if err != nil { + return nil, fmt.Errorf("failed to get GitHub client: %w", err) + } + + event := github.CreateWorkflowDispatchEventRequest{ + Ref: ref, + Inputs: inputs, + } + + resp, err := client.Actions.CreateWorkflowDispatchEventByFileName(ctx, owner, repo, workflowFile, event) + if err != nil { + return 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": workflowFile, + "ref": ref, + "inputs": inputs, + "status": resp.Status, + "status_code": resp.StatusCode, + } + + r, err := json.Marshal(result) + if err != nil { + return nil, fmt.Errorf("failed to marshal response: %w", err) + } + + return mcp.NewToolResultText(string(r)), nil + } +} + +// GetWorkflowRun creates a tool to get details of a specific workflow run +func GetWorkflowRun(getClient GetClientFn, t translations.TranslationHelperFunc) (tool mcp.Tool, handler server.ToolHandlerFunc) { + return mcp.NewTool("get_workflow_run", + mcp.WithDescription(t("TOOL_GET_WORKFLOW_RUN_DESCRIPTION", "Get details of a specific workflow run")), + mcp.WithToolAnnotation(mcp.ToolAnnotation{ + ReadOnlyHint: ToBoolPtr(true), + }), + mcp.WithString("owner", + mcp.Required(), + mcp.Description("The account owner of the repository. The name is not case sensitive."), + ), + mcp.WithString("repo", + mcp.Required(), + mcp.Description("Repository name"), + ), + mcp.WithNumber("run_id", + mcp.Required(), + mcp.Description("The unique identifier of the workflow run"), + ), + ), + func(ctx context.Context, request mcp.CallToolRequest) (*mcp.CallToolResult, error) { + owner, err := RequiredParam[string](request, "owner") + if err != nil { + return mcp.NewToolResultError(err.Error()), nil + } + repo, err := RequiredParam[string](request, "repo") + if err != nil { + return mcp.NewToolResultError(err.Error()), nil + } + runIDInt, err := RequiredInt(request, "run_id") + if err != nil { + return mcp.NewToolResultError(err.Error()), nil + } + runID := int64(runIDInt) + + client, err := getClient(ctx) + if err != nil { + return nil, fmt.Errorf("failed to get GitHub client: %w", err) + } + + workflowRun, resp, err := client.Actions.GetWorkflowRunByID(ctx, owner, repo, runID) + if err != nil { + return 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, fmt.Errorf("failed to marshal response: %w", err) + } + + return mcp.NewToolResultText(string(r)), nil + } +} + +// GetWorkflowRunLogs creates a tool to download logs for a specific workflow run +func GetWorkflowRunLogs(getClient GetClientFn, t translations.TranslationHelperFunc) (tool mcp.Tool, handler server.ToolHandlerFunc) { + return mcp.NewTool("get_workflow_run_logs", + mcp.WithDescription(t("TOOL_GET_WORKFLOW_RUN_LOGS_DESCRIPTION", "Download logs for a specific workflow run (EXPENSIVE: downloads ALL logs as ZIP. Consider using get_job_logs with failed_only=true for debugging failed jobs)")), + mcp.WithToolAnnotation(mcp.ToolAnnotation{ + ReadOnlyHint: ToBoolPtr(true), + }), + mcp.WithString("owner", + mcp.Required(), + mcp.Description("The account owner of the repository. The name is not case sensitive."), + ), + mcp.WithString("repo", + mcp.Required(), + mcp.Description("Repository name"), + ), + mcp.WithNumber("run_id", + mcp.Required(), + mcp.Description("The unique identifier of the workflow run"), + ), + ), + func(ctx context.Context, request mcp.CallToolRequest) (*mcp.CallToolResult, error) { + owner, err := RequiredParam[string](request, "owner") + if err != nil { + return mcp.NewToolResultError(err.Error()), nil + } + repo, err := RequiredParam[string](request, "repo") + if err != nil { + return mcp.NewToolResultError(err.Error()), nil + } + runIDInt, err := RequiredInt(request, "run_id") + if err != nil { + return mcp.NewToolResultError(err.Error()), nil + } + runID := int64(runIDInt) + + client, err := getClient(ctx) + if err != nil { + return nil, fmt.Errorf("failed to get GitHub client: %w", err) + } + + // Get the download URL for the logs + url, resp, err := client.Actions.GetWorkflowRunLogs(ctx, owner, repo, runID, 1) + if err != nil { + return nil, fmt.Errorf("failed to get workflow run logs: %w", err) + } + defer func() { _ = resp.Body.Close() }() + + // Create response with the logs URL and information + result := map[string]any{ + "logs_url": url.String(), + "message": "Workflow run logs are available for download", + "note": "The logs_url provides a download link for the complete workflow run logs as a ZIP archive. You can download this archive to extract and examine individual job logs.", + "warning": "This downloads ALL logs as a ZIP file which can be large and expensive. For debugging failed jobs, consider using get_job_logs with failed_only=true and run_id instead.", + "optimization_tip": "Use: get_job_logs with parameters {run_id: " + fmt.Sprintf("%d", runID) + ", failed_only: true} for more efficient failed job debugging", + } + + r, err := json.Marshal(result) + if err != nil { + return nil, fmt.Errorf("failed to marshal response: %w", err) + } + + return mcp.NewToolResultText(string(r)), nil + } +} + +// ListWorkflowJobs creates a tool to list jobs for a specific workflow run +func ListWorkflowJobs(getClient GetClientFn, t translations.TranslationHelperFunc) (tool mcp.Tool, handler server.ToolHandlerFunc) { + return mcp.NewTool("list_workflow_jobs", + mcp.WithDescription(t("TOOL_LIST_WORKFLOW_JOBS_DESCRIPTION", "List jobs for a specific workflow run")), + mcp.WithToolAnnotation(mcp.ToolAnnotation{ + ReadOnlyHint: ToBoolPtr(true), + }), + mcp.WithString("owner", + mcp.Required(), + mcp.Description("The account owner of the repository. The name is not case sensitive."), + ), + mcp.WithString("repo", + mcp.Required(), + mcp.Description("Repository name"), + ), + mcp.WithNumber("run_id", + mcp.Required(), + mcp.Description("The unique identifier of the workflow run"), + ), + mcp.WithString("filter", + mcp.Description("Filters jobs by their completed_at timestamp. Can be one of: latest, all"), + ), + mcp.WithNumber("per_page", + mcp.Description("The number of results per page (max 100)"), + ), + mcp.WithNumber("page", + mcp.Description("The page number of the results to fetch"), + ), + ), + func(ctx context.Context, request mcp.CallToolRequest) (*mcp.CallToolResult, error) { + owner, err := RequiredParam[string](request, "owner") + if err != nil { + return mcp.NewToolResultError(err.Error()), nil + } + repo, err := RequiredParam[string](request, "repo") + if err != nil { + return mcp.NewToolResultError(err.Error()), nil + } + runIDInt, err := RequiredInt(request, "run_id") + if err != nil { + return mcp.NewToolResultError(err.Error()), nil + } + runID := int64(runIDInt) + + // Get optional filtering parameters + filter, err := OptionalParam[string](request, "filter") + if err != nil { + return mcp.NewToolResultError(err.Error()), nil + } + + // Get optional pagination parameters + perPage, err := OptionalIntParam(request, "per_page") + if err != nil { + return mcp.NewToolResultError(err.Error()), nil + } + page, err := OptionalIntParam(request, "page") + if err != nil { + return mcp.NewToolResultError(err.Error()), nil + } + + client, err := getClient(ctx) + if err != nil { + return nil, fmt.Errorf("failed to get GitHub client: %w", err) + } + + // Set up list options + opts := &github.ListWorkflowJobsOptions{ + Filter: filter, + ListOptions: github.ListOptions{ + PerPage: perPage, + Page: page, + }, + } + + jobs, resp, err := client.Actions.ListWorkflowJobs(ctx, owner, repo, runID, opts) + if err != nil { + return nil, fmt.Errorf("failed to list workflow jobs: %w", err) + } + defer func() { _ = resp.Body.Close() }() + + // Add optimization tip for failed job debugging + response := map[string]any{ + "jobs": jobs, + "optimization_tip": "For debugging failed jobs, consider using get_job_logs with failed_only=true and run_id=" + fmt.Sprintf("%d", runID) + " to get logs directly without needing to list jobs first", + } + + r, err := json.Marshal(response) + if err != nil { + return nil, fmt.Errorf("failed to marshal response: %w", err) + } + + return mcp.NewToolResultText(string(r)), nil + } +} + +// GetJobLogs creates a tool to download logs for a specific workflow job or get failed job logs efficiently +func GetJobLogs(getClient GetClientFn, t translations.TranslationHelperFunc) (tool mcp.Tool, handler server.ToolHandlerFunc) { + return mcp.NewTool("get_job_logs", + mcp.WithDescription(t("TOOL_GET_JOB_LOGS_DESCRIPTION", "Download logs for a specific workflow job or efficiently get all failed job logs for a workflow run")), + mcp.WithToolAnnotation(mcp.ToolAnnotation{ + ReadOnlyHint: ToBoolPtr(true), + }), + mcp.WithString("owner", + mcp.Required(), + mcp.Description("The account owner of the repository. The name is not case sensitive."), + ), + mcp.WithString("repo", + mcp.Required(), + mcp.Description("Repository name"), + ), + mcp.WithNumber("job_id", + mcp.Description("The unique identifier of the workflow job (required for single job logs)"), + ), + mcp.WithNumber("run_id", + mcp.Description("Workflow run ID (required when using failed_only)"), + ), + mcp.WithBoolean("failed_only", + mcp.Description("When true, gets logs for all failed jobs in run_id"), + ), + mcp.WithBoolean("return_content", + mcp.Description("Returns actual log content instead of URLs"), + ), + ), + func(ctx context.Context, request mcp.CallToolRequest) (*mcp.CallToolResult, error) { + owner, err := RequiredParam[string](request, "owner") + if err != nil { + return mcp.NewToolResultError(err.Error()), nil + } + repo, err := RequiredParam[string](request, "repo") + if err != nil { + return mcp.NewToolResultError(err.Error()), nil + } + + // Get optional parameters + jobID, err := OptionalIntParam(request, "job_id") + if err != nil { + return mcp.NewToolResultError(err.Error()), nil + } + runID, err := OptionalIntParam(request, "run_id") + if err != nil { + return mcp.NewToolResultError(err.Error()), nil + } + failedOnly, err := OptionalParam[bool](request, "failed_only") + if err != nil { + return mcp.NewToolResultError(err.Error()), nil + } + returnContent, err := OptionalParam[bool](request, "return_content") + if err != nil { + return mcp.NewToolResultError(err.Error()), nil + } + + client, err := getClient(ctx) + if err != nil { + return nil, fmt.Errorf("failed to get GitHub client: %w", err) + } + + // Validate parameters + if failedOnly && runID == 0 { + return mcp.NewToolResultError("run_id is required when failed_only is true"), nil + } + if !failedOnly && jobID == 0 { + return mcp.NewToolResultError("job_id is required when failed_only is false"), 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) + } else if jobID > 0 { + // Handle single job mode + return handleSingleJobLogs(ctx, client, owner, repo, int64(jobID), returnContent) + } + + return mcp.NewToolResultError("Either job_id must be provided for single job logs, or run_id with failed_only=true for failed job logs"), 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) (*mcp.CallToolResult, 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 mcp.NewToolResultError(fmt.Sprintf("failed to list workflow jobs: %v", err)), 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 mcp.NewToolResultText(string(r)), nil + } + + // Collect logs for all failed jobs + var logResults []map[string]any + for _, job := range failedJobs { + jobResult, err := getJobLogData(ctx, client, owner, repo, job.GetID(), job.GetName(), returnContent) + 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(), + } + } + 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, fmt.Errorf("failed to marshal response: %w", err) + } + + return mcp.NewToolResultText(string(r)), nil +} + +// handleSingleJobLogs gets logs for a single job +func handleSingleJobLogs(ctx context.Context, client *github.Client, owner, repo string, jobID int64, returnContent bool) (*mcp.CallToolResult, error) { + jobResult, err := getJobLogData(ctx, client, owner, repo, jobID, "", returnContent) + if err != nil { + return mcp.NewToolResultError(err.Error()), nil + } + + r, err := json.Marshal(jobResult) + if err != nil { + return nil, fmt.Errorf("failed to marshal response: %w", err) + } + + return mcp.NewToolResultText(string(r)), 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) (map[string]any, 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, 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, err := downloadLogContent(url.String()) + if err != nil { + return nil, fmt.Errorf("failed to download log content for job %d: %w", jobID, err) + } + result["logs_content"] = content + result["message"] = "Job logs content retrieved successfully" + } 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, nil +} + +// downloadLogContent downloads the actual log content from a GitHub logs URL +func downloadLogContent(logURL string) (string, error) { + httpResp, err := http.Get(logURL) + if err != nil { + return "", fmt.Errorf("failed to download logs: %w", err) + } + defer func() { _ = httpResp.Body.Close() }() + + if httpResp.StatusCode != http.StatusOK { + return "", fmt.Errorf("failed to download logs: HTTP %d", httpResp.StatusCode) + } + + content, err := io.ReadAll(httpResp.Body) + if err != nil { + return "", fmt.Errorf("failed to read log content: %w", err) + } + + // Clean up and format the log content for better readability + logContent := strings.TrimSpace(string(content)) + return logContent, nil +} + +// RerunWorkflowRun creates a tool to re-run an entire workflow run +func RerunWorkflowRun(getClient GetClientFn, t translations.TranslationHelperFunc) (tool mcp.Tool, handler server.ToolHandlerFunc) { + return mcp.NewTool("rerun_workflow_run", + mcp.WithDescription(t("TOOL_RERUN_WORKFLOW_RUN_DESCRIPTION", "Re-run an entire workflow run")), + mcp.WithToolAnnotation(mcp.ToolAnnotation{ + ReadOnlyHint: ToBoolPtr(false), + }), + mcp.WithString("owner", + mcp.Required(), + mcp.Description("The account owner of the repository. The name is not case sensitive."), + ), + mcp.WithString("repo", + mcp.Required(), + mcp.Description("Repository name"), + ), + mcp.WithNumber("run_id", + mcp.Required(), + mcp.Description("The unique identifier of the workflow run"), + ), + ), + func(ctx context.Context, request mcp.CallToolRequest) (*mcp.CallToolResult, error) { + owner, err := RequiredParam[string](request, "owner") + if err != nil { + return mcp.NewToolResultError(err.Error()), nil + } + repo, err := RequiredParam[string](request, "repo") + if err != nil { + return mcp.NewToolResultError(err.Error()), nil + } + runIDInt, err := RequiredInt(request, "run_id") + if err != nil { + return mcp.NewToolResultError(err.Error()), nil + } + runID := int64(runIDInt) + + client, err := getClient(ctx) + if err != nil { + return nil, fmt.Errorf("failed to get GitHub client: %w", err) + } + + resp, err := client.Actions.RerunWorkflowByID(ctx, owner, repo, runID) + if err != nil { + return nil, fmt.Errorf("failed to rerun workflow run: %w", err) + } + 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, fmt.Errorf("failed to marshal response: %w", err) + } + + return mcp.NewToolResultText(string(r)), nil + } +} + +// RerunFailedJobs creates a tool to re-run only the failed jobs in a workflow run +func RerunFailedJobs(getClient GetClientFn, t translations.TranslationHelperFunc) (tool mcp.Tool, handler server.ToolHandlerFunc) { + return mcp.NewTool("rerun_failed_jobs", + mcp.WithDescription(t("TOOL_RERUN_FAILED_JOBS_DESCRIPTION", "Re-run only the failed jobs in a workflow run")), + mcp.WithToolAnnotation(mcp.ToolAnnotation{ + ReadOnlyHint: ToBoolPtr(false), + }), + mcp.WithString("owner", + mcp.Required(), + mcp.Description("The account owner of the repository. The name is not case sensitive."), + ), + mcp.WithString("repo", + mcp.Required(), + mcp.Description("Repository name"), + ), + mcp.WithNumber("run_id", + mcp.Required(), + mcp.Description("The unique identifier of the workflow run"), + ), + ), + func(ctx context.Context, request mcp.CallToolRequest) (*mcp.CallToolResult, error) { + owner, err := RequiredParam[string](request, "owner") + if err != nil { + return mcp.NewToolResultError(err.Error()), nil + } + repo, err := RequiredParam[string](request, "repo") + if err != nil { + return mcp.NewToolResultError(err.Error()), nil + } + runIDInt, err := RequiredInt(request, "run_id") + if err != nil { + return mcp.NewToolResultError(err.Error()), nil + } + runID := int64(runIDInt) + + client, err := getClient(ctx) + if err != nil { + return nil, fmt.Errorf("failed to get GitHub client: %w", err) + } + + resp, err := client.Actions.RerunFailedJobsByID(ctx, owner, repo, runID) + if err != nil { + return nil, fmt.Errorf("failed to rerun failed jobs: %w", err) + } + 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, fmt.Errorf("failed to marshal response: %w", err) + } + + return mcp.NewToolResultText(string(r)), nil + } +} + +// CancelWorkflowRun creates a tool to cancel a workflow run +func CancelWorkflowRun(getClient GetClientFn, t translations.TranslationHelperFunc) (tool mcp.Tool, handler server.ToolHandlerFunc) { + return mcp.NewTool("cancel_workflow_run", + mcp.WithDescription(t("TOOL_CANCEL_WORKFLOW_RUN_DESCRIPTION", "Cancel a workflow run")), + mcp.WithToolAnnotation(mcp.ToolAnnotation{ + ReadOnlyHint: ToBoolPtr(false), + }), + mcp.WithString("owner", + mcp.Required(), + mcp.Description("The account owner of the repository. The name is not case sensitive."), + ), + mcp.WithString("repo", + mcp.Required(), + mcp.Description("Repository name"), + ), + mcp.WithNumber("run_id", + mcp.Required(), + mcp.Description("The unique identifier of the workflow run"), + ), + ), + func(ctx context.Context, request mcp.CallToolRequest) (*mcp.CallToolResult, error) { + owner, err := RequiredParam[string](request, "owner") + if err != nil { + return mcp.NewToolResultError(err.Error()), nil + } + repo, err := RequiredParam[string](request, "repo") + if err != nil { + return mcp.NewToolResultError(err.Error()), nil + } + runIDInt, err := RequiredInt(request, "run_id") + if err != nil { + return mcp.NewToolResultError(err.Error()), nil + } + runID := int64(runIDInt) + + client, err := getClient(ctx) + if err != nil { + return nil, fmt.Errorf("failed to get GitHub client: %w", err) + } + + resp, err := client.Actions.CancelWorkflowRunByID(ctx, owner, repo, runID) + if err != nil { + return nil, fmt.Errorf("failed to cancel workflow run: %w", err) + } + defer func() { _ = resp.Body.Close() }() + + result := map[string]any{ + "message": "Workflow run has been cancelled", + "run_id": runID, + "status": resp.Status, + "status_code": resp.StatusCode, + } + + r, err := json.Marshal(result) + if err != nil { + return nil, fmt.Errorf("failed to marshal response: %w", err) + } + + return mcp.NewToolResultText(string(r)), nil + } +} + +// ListWorkflowRunArtifacts creates a tool to list artifacts for a workflow run +func ListWorkflowRunArtifacts(getClient GetClientFn, t translations.TranslationHelperFunc) (tool mcp.Tool, handler server.ToolHandlerFunc) { + return mcp.NewTool("list_workflow_run_artifacts", + mcp.WithDescription(t("TOOL_LIST_WORKFLOW_RUN_ARTIFACTS_DESCRIPTION", "List artifacts for a workflow run")), + mcp.WithToolAnnotation(mcp.ToolAnnotation{ + ReadOnlyHint: ToBoolPtr(true), + }), + mcp.WithString("owner", + mcp.Required(), + mcp.Description("The account owner of the repository. The name is not case sensitive."), + ), + mcp.WithString("repo", + mcp.Required(), + mcp.Description("Repository name"), + ), + mcp.WithNumber("run_id", + mcp.Required(), + mcp.Description("The unique identifier of the workflow run"), + ), + mcp.WithNumber("per_page", + mcp.Description("The number of results per page (max 100)"), + ), + mcp.WithNumber("page", + mcp.Description("The page number of the results to fetch"), + ), + ), + func(ctx context.Context, request mcp.CallToolRequest) (*mcp.CallToolResult, error) { + owner, err := RequiredParam[string](request, "owner") + if err != nil { + return mcp.NewToolResultError(err.Error()), nil + } + repo, err := RequiredParam[string](request, "repo") + if err != nil { + return mcp.NewToolResultError(err.Error()), nil + } + runIDInt, err := RequiredInt(request, "run_id") + if err != nil { + return mcp.NewToolResultError(err.Error()), nil + } + runID := int64(runIDInt) + + // Get optional pagination parameters + perPage, err := OptionalIntParam(request, "per_page") + if err != nil { + return mcp.NewToolResultError(err.Error()), nil + } + page, err := OptionalIntParam(request, "page") + if err != nil { + return mcp.NewToolResultError(err.Error()), nil + } + + client, err := getClient(ctx) + if err != nil { + return nil, fmt.Errorf("failed to get GitHub client: %w", err) + } + + // Set up list options + opts := &github.ListOptions{ + PerPage: perPage, + Page: page, + } + + artifacts, resp, err := client.Actions.ListWorkflowRunArtifacts(ctx, owner, repo, runID, opts) + if err != nil { + return nil, fmt.Errorf("failed to list workflow run artifacts: %w", err) + } + defer func() { _ = resp.Body.Close() }() + + r, err := json.Marshal(artifacts) + if err != nil { + return nil, fmt.Errorf("failed to marshal response: %w", err) + } + + return mcp.NewToolResultText(string(r)), nil + } +} + +// DownloadWorkflowRunArtifact creates a tool to download a workflow run artifact +func DownloadWorkflowRunArtifact(getClient GetClientFn, t translations.TranslationHelperFunc) (tool mcp.Tool, handler server.ToolHandlerFunc) { + return mcp.NewTool("download_workflow_run_artifact", + mcp.WithDescription(t("TOOL_DOWNLOAD_WORKFLOW_RUN_ARTIFACT_DESCRIPTION", "Get download URL for a workflow run artifact")), + mcp.WithToolAnnotation(mcp.ToolAnnotation{ + ReadOnlyHint: ToBoolPtr(true), + }), + mcp.WithString("owner", + mcp.Required(), + mcp.Description("The account owner of the repository. The name is not case sensitive."), + ), + mcp.WithString("repo", + mcp.Required(), + mcp.Description("Repository name"), + ), + mcp.WithNumber("artifact_id", + mcp.Required(), + mcp.Description("The unique identifier of the artifact"), + ), + ), + func(ctx context.Context, request mcp.CallToolRequest) (*mcp.CallToolResult, error) { + owner, err := RequiredParam[string](request, "owner") + if err != nil { + return mcp.NewToolResultError(err.Error()), nil + } + repo, err := RequiredParam[string](request, "repo") + if err != nil { + return mcp.NewToolResultError(err.Error()), nil + } + artifactIDInt, err := RequiredInt(request, "artifact_id") + if err != nil { + return mcp.NewToolResultError(err.Error()), nil + } + artifactID := int64(artifactIDInt) + + client, err := getClient(ctx) + if err != nil { + return nil, fmt.Errorf("failed to get GitHub client: %w", err) + } + + // Get the download URL for the artifact + url, resp, err := client.Actions.DownloadArtifact(ctx, owner, repo, artifactID, 1) + if err != nil { + return nil, fmt.Errorf("failed to get artifact download URL: %w", err) + } + 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, fmt.Errorf("failed to marshal response: %w", err) + } + + return mcp.NewToolResultText(string(r)), nil + } +} + +// DeleteWorkflowRunLogs creates a tool to delete logs for a workflow run +func DeleteWorkflowRunLogs(getClient GetClientFn, t translations.TranslationHelperFunc) (tool mcp.Tool, handler server.ToolHandlerFunc) { + return mcp.NewTool("delete_workflow_run_logs", + mcp.WithDescription(t("TOOL_DELETE_WORKFLOW_RUN_LOGS_DESCRIPTION", "Delete logs for a workflow run")), + mcp.WithToolAnnotation(mcp.ToolAnnotation{ + ReadOnlyHint: ToBoolPtr(false), + DestructiveHint: ToBoolPtr(true), + }), + mcp.WithString("owner", + mcp.Required(), + mcp.Description("The account owner of the repository. The name is not case sensitive."), + ), + mcp.WithString("repo", + mcp.Required(), + mcp.Description("Repository name"), + ), + mcp.WithNumber("run_id", + mcp.Required(), + mcp.Description("The unique identifier of the workflow run"), + ), + ), + func(ctx context.Context, request mcp.CallToolRequest) (*mcp.CallToolResult, error) { + owner, err := RequiredParam[string](request, "owner") + if err != nil { + return mcp.NewToolResultError(err.Error()), nil + } + repo, err := RequiredParam[string](request, "repo") + if err != nil { + return mcp.NewToolResultError(err.Error()), nil + } + runIDInt, err := RequiredInt(request, "run_id") + if err != nil { + return mcp.NewToolResultError(err.Error()), nil + } + runID := int64(runIDInt) + + client, err := getClient(ctx) + if err != nil { + return nil, fmt.Errorf("failed to get GitHub client: %w", err) + } + + resp, err := client.Actions.DeleteWorkflowRunLogs(ctx, owner, repo, runID) + if err != nil { + return nil, fmt.Errorf("failed to delete workflow run logs: %w", err) + } + 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, fmt.Errorf("failed to marshal response: %w", err) + } + + return mcp.NewToolResultText(string(r)), nil + } +} + +// GetWorkflowRunUsage creates a tool to get usage metrics for a workflow run +func GetWorkflowRunUsage(getClient GetClientFn, t translations.TranslationHelperFunc) (tool mcp.Tool, handler server.ToolHandlerFunc) { + return mcp.NewTool("get_workflow_run_usage", + mcp.WithDescription(t("TOOL_GET_WORKFLOW_RUN_USAGE_DESCRIPTION", "Get usage metrics for a workflow run")), + mcp.WithToolAnnotation(mcp.ToolAnnotation{ + ReadOnlyHint: ToBoolPtr(true), + }), + mcp.WithString("owner", + mcp.Required(), + mcp.Description("The account owner of the repository. The name is not case sensitive."), + ), + mcp.WithString("repo", + mcp.Required(), + mcp.Description("Repository name"), + ), + mcp.WithNumber("run_id", + mcp.Required(), + mcp.Description("The unique identifier of the workflow run"), + ), + ), + func(ctx context.Context, request mcp.CallToolRequest) (*mcp.CallToolResult, error) { + owner, err := RequiredParam[string](request, "owner") + if err != nil { + return mcp.NewToolResultError(err.Error()), nil + } + repo, err := RequiredParam[string](request, "repo") + if err != nil { + return mcp.NewToolResultError(err.Error()), nil + } + runIDInt, err := RequiredInt(request, "run_id") + if err != nil { + return mcp.NewToolResultError(err.Error()), nil + } + runID := int64(runIDInt) + + client, err := getClient(ctx) + if err != nil { + return nil, fmt.Errorf("failed to get GitHub client: %w", err) + } + + usage, resp, err := client.Actions.GetWorkflowRunUsageByID(ctx, owner, repo, runID) + if err != nil { + return nil, fmt.Errorf("failed to get workflow run usage: %w", err) + } + defer func() { _ = resp.Body.Close() }() + + r, err := json.Marshal(usage) + if err != nil { + return nil, fmt.Errorf("failed to marshal response: %w", err) + } + + return mcp.NewToolResultText(string(r)), nil + } +} diff --git a/pkg/github/actions_test.go b/pkg/github/actions_test.go new file mode 100644 index 00000000..b81ac36f --- /dev/null +++ b/pkg/github/actions_test.go @@ -0,0 +1,1005 @@ +package github + +import ( + "context" + "encoding/json" + "net/http" + "net/http/httptest" + "testing" + + "github.com/github/github-mcp-server/pkg/translations" + "github.com/google/go-github/v72/github" + "github.com/migueleliasweb/go-github-mock/src/mock" + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" +) + +func Test_ListWorkflows(t *testing.T) { + // Verify tool definition once + mockClient := github.NewClient(nil) + tool, _ := ListWorkflows(stubGetClientFn(mockClient), translations.NullTranslationHelper) + + assert.Equal(t, "list_workflows", tool.Name) + assert.NotEmpty(t, tool.Description) + assert.Contains(t, tool.InputSchema.Properties, "owner") + assert.Contains(t, tool.InputSchema.Properties, "repo") + assert.Contains(t, tool.InputSchema.Properties, "per_page") + assert.Contains(t, tool.InputSchema.Properties, "page") + assert.ElementsMatch(t, tool.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: mock.NewMockedHTTPClient( + mock.WithRequestMatchHandler( + mock.GetReposActionsWorkflowsByOwnerByRepo, + http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) { + workflows := &github.Workflows{ + TotalCount: github.Int(2), + Workflows: []*github.Workflow{ + { + ID: github.Int64(123), + Name: github.String("CI"), + Path: github.String(".github/workflows/ci.yml"), + State: github.String("active"), + CreatedAt: &github.Timestamp{}, + UpdatedAt: &github.Timestamp{}, + URL: github.String("https://api.github.com/repos/owner/repo/actions/workflows/123"), + HTMLURL: github.String("https://github.com/owner/repo/actions/workflows/ci.yml"), + BadgeURL: github.String("https://github.com/owner/repo/workflows/CI/badge.svg"), + NodeID: github.String("W_123"), + }, + { + ID: github.Int64(456), + Name: github.String("Deploy"), + Path: github.String(".github/workflows/deploy.yml"), + State: github.String("active"), + CreatedAt: &github.Timestamp{}, + UpdatedAt: &github.Timestamp{}, + URL: github.String("https://api.github.com/repos/owner/repo/actions/workflows/456"), + HTMLURL: github.String("https://github.com/owner/repo/actions/workflows/deploy.yml"), + BadgeURL: github.String("https://github.com/owner/repo/workflows/Deploy/badge.svg"), + NodeID: github.String("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: mock.NewMockedHTTPClient(), + 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) + _, handler := ListWorkflows(stubGetClientFn(client), translations.NullTranslationHelper) + + // Create call request + request := createMCPRequest(tc.requestArgs) + + // Call handler + result, err := handler(context.Background(), request) + + require.NoError(t, err) + require.Equal(t, tc.expectError, result.IsError) + + // Parse the result and get the text content if no error + textContent := getTextResult(t, result) + + if tc.expectedErrMsg != "" { + assert.Equal(t, tc.expectedErrMsg, textContent.Text) + return + } + + // 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 + mockClient := github.NewClient(nil) + tool, _ := RunWorkflow(stubGetClientFn(mockClient), translations.NullTranslationHelper) + + assert.Equal(t, "run_workflow", tool.Name) + assert.NotEmpty(t, tool.Description) + assert.Contains(t, tool.InputSchema.Properties, "owner") + assert.Contains(t, tool.InputSchema.Properties, "repo") + assert.Contains(t, tool.InputSchema.Properties, "workflow_file") + assert.Contains(t, tool.InputSchema.Properties, "ref") + assert.Contains(t, tool.InputSchema.Properties, "inputs") + assert.ElementsMatch(t, tool.InputSchema.Required, []string{"owner", "repo", "workflow_file", "ref"}) + + tests := []struct { + name string + mockedClient *http.Client + requestArgs map[string]any + expectError bool + expectedErrMsg string + }{ + { + name: "successful workflow run", + mockedClient: mock.NewMockedHTTPClient( + mock.WithRequestMatchHandler( + mock.PostReposActionsWorkflowsDispatchesByOwnerByRepoByWorkflowId, + http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) { + w.WriteHeader(http.StatusNoContent) + }), + ), + ), + requestArgs: map[string]any{ + "owner": "owner", + "repo": "repo", + "workflow_file": "ci.yml", + "ref": "main", + }, + expectError: false, + }, + { + name: "missing required parameter workflow_file", + mockedClient: mock.NewMockedHTTPClient(), + requestArgs: map[string]any{ + "owner": "owner", + "repo": "repo", + "ref": "main", + }, + expectError: true, + expectedErrMsg: "missing required parameter: workflow_file", + }, + } + + for _, tc := range tests { + t.Run(tc.name, func(t *testing.T) { + // Setup client with mock + client := github.NewClient(tc.mockedClient) + _, handler := RunWorkflow(stubGetClientFn(client), translations.NullTranslationHelper) + + // Create call request + request := createMCPRequest(tc.requestArgs) + + // Call handler + result, err := handler(context.Background(), request) + + require.NoError(t, err) + require.Equal(t, tc.expectError, result.IsError) + + // Parse the result and get the text content if no error + textContent := getTextResult(t, result) + + if tc.expectedErrMsg != "" { + assert.Equal(t, tc.expectedErrMsg, textContent.Text) + return + } + + // 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"]) + }) + } +} + +func Test_CancelWorkflowRun(t *testing.T) { + // Verify tool definition once + mockClient := github.NewClient(nil) + tool, _ := CancelWorkflowRun(stubGetClientFn(mockClient), translations.NullTranslationHelper) + + assert.Equal(t, "cancel_workflow_run", tool.Name) + assert.NotEmpty(t, tool.Description) + assert.Contains(t, tool.InputSchema.Properties, "owner") + assert.Contains(t, tool.InputSchema.Properties, "repo") + assert.Contains(t, tool.InputSchema.Properties, "run_id") + assert.ElementsMatch(t, tool.InputSchema.Required, []string{"owner", "repo", "run_id"}) + + tests := []struct { + name string + mockedClient *http.Client + requestArgs map[string]any + expectError bool + expectedErrMsg string + }{ + { + name: "successful workflow run cancellation", + mockedClient: mock.NewMockedHTTPClient( + mock.WithRequestMatch( + mock.EndpointPattern{ + Pattern: "/repos/owner/repo/actions/runs/12345/cancel", + Method: "POST", + }, + "", // Empty response body for 202 Accepted + ), + ), + 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) { + // Setup client with mock + client := github.NewClient(tc.mockedClient) + _, handler := CancelWorkflowRun(stubGetClientFn(client), translations.NullTranslationHelper) + + // Create call request + request := createMCPRequest(tc.requestArgs) + + // Call handler + result, err := handler(context.Background(), request) + + require.NoError(t, err) + require.Equal(t, tc.expectError, result.IsError) + + // Parse the result and get the text content + 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 cancelled", response["message"]) + assert.Equal(t, float64(12345), response["run_id"]) + }) + } +} + +func Test_ListWorkflowRunArtifacts(t *testing.T) { + // Verify tool definition once + mockClient := github.NewClient(nil) + tool, _ := ListWorkflowRunArtifacts(stubGetClientFn(mockClient), translations.NullTranslationHelper) + + assert.Equal(t, "list_workflow_run_artifacts", tool.Name) + assert.NotEmpty(t, tool.Description) + assert.Contains(t, tool.InputSchema.Properties, "owner") + assert.Contains(t, tool.InputSchema.Properties, "repo") + assert.Contains(t, tool.InputSchema.Properties, "run_id") + assert.Contains(t, tool.InputSchema.Properties, "per_page") + assert.Contains(t, tool.InputSchema.Properties, "page") + assert.ElementsMatch(t, tool.InputSchema.Required, []string{"owner", "repo", "run_id"}) + + tests := []struct { + name string + mockedClient *http.Client + requestArgs map[string]any + expectError bool + expectedErrMsg string + }{ + { + name: "successful artifacts listing", + mockedClient: mock.NewMockedHTTPClient( + mock.WithRequestMatchHandler( + mock.GetReposActionsRunsArtifactsByOwnerByRepoByRunId, + http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) { + artifacts := &github.ArtifactList{ + TotalCount: github.Int64(2), + Artifacts: []*github.Artifact{ + { + ID: github.Int64(1), + NodeID: github.String("A_1"), + Name: github.String("build-artifacts"), + SizeInBytes: github.Int64(1024), + URL: github.String("https://api.github.com/repos/owner/repo/actions/artifacts/1"), + ArchiveDownloadURL: github.String("https://api.github.com/repos/owner/repo/actions/artifacts/1/zip"), + Expired: github.Bool(false), + CreatedAt: &github.Timestamp{}, + UpdatedAt: &github.Timestamp{}, + ExpiresAt: &github.Timestamp{}, + WorkflowRun: &github.ArtifactWorkflowRun{ + ID: github.Int64(12345), + RepositoryID: github.Int64(1), + HeadRepositoryID: github.Int64(1), + HeadBranch: github.String("main"), + HeadSHA: github.String("abc123"), + }, + }, + { + ID: github.Int64(2), + NodeID: github.String("A_2"), + Name: github.String("test-results"), + SizeInBytes: github.Int64(512), + URL: github.String("https://api.github.com/repos/owner/repo/actions/artifacts/2"), + ArchiveDownloadURL: github.String("https://api.github.com/repos/owner/repo/actions/artifacts/2/zip"), + Expired: github.Bool(false), + CreatedAt: &github.Timestamp{}, + UpdatedAt: &github.Timestamp{}, + ExpiresAt: &github.Timestamp{}, + WorkflowRun: &github.ArtifactWorkflowRun{ + ID: github.Int64(12345), + RepositoryID: github.Int64(1), + HeadRepositoryID: github.Int64(1), + HeadBranch: github.String("main"), + HeadSHA: github.String("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: mock.NewMockedHTTPClient(), + requestArgs: map[string]any{ + "owner": "owner", + "repo": "repo", + }, + expectError: true, + expectedErrMsg: "missing required parameter: run_id", + }, + } + + for _, tc := range tests { + t.Run(tc.name, func(t *testing.T) { + // Setup client with mock + client := github.NewClient(tc.mockedClient) + _, handler := ListWorkflowRunArtifacts(stubGetClientFn(client), translations.NullTranslationHelper) + + // Create call request + request := createMCPRequest(tc.requestArgs) + + // Call handler + result, err := handler(context.Background(), request) + + require.NoError(t, err) + require.Equal(t, tc.expectError, result.IsError) + + // Parse the result and get the text content if no error + textContent := getTextResult(t, result) + + if tc.expectedErrMsg != "" { + assert.Equal(t, tc.expectedErrMsg, textContent.Text) + return + } + + // 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 + mockClient := github.NewClient(nil) + tool, _ := DownloadWorkflowRunArtifact(stubGetClientFn(mockClient), translations.NullTranslationHelper) + + assert.Equal(t, "download_workflow_run_artifact", tool.Name) + assert.NotEmpty(t, tool.Description) + assert.Contains(t, tool.InputSchema.Properties, "owner") + assert.Contains(t, tool.InputSchema.Properties, "repo") + assert.Contains(t, tool.InputSchema.Properties, "artifact_id") + assert.ElementsMatch(t, tool.InputSchema.Required, []string{"owner", "repo", "artifact_id"}) + + tests := []struct { + name string + mockedClient *http.Client + requestArgs map[string]any + expectError bool + expectedErrMsg string + }{ + { + name: "successful artifact download URL", + mockedClient: mock.NewMockedHTTPClient( + mock.WithRequestMatchHandler( + mock.EndpointPattern{ + Pattern: "/repos/owner/repo/actions/artifacts/123/zip", + Method: "GET", + }, + http.HandlerFunc(func(w http.ResponseWriter, r *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: mock.NewMockedHTTPClient(), + 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) + _, handler := DownloadWorkflowRunArtifact(stubGetClientFn(client), translations.NullTranslationHelper) + + // Create call request + request := createMCPRequest(tc.requestArgs) + + // Call handler + result, err := handler(context.Background(), request) + + require.NoError(t, err) + require.Equal(t, tc.expectError, result.IsError) + + // Parse the result and get the text content if no error + textContent := getTextResult(t, result) + + if tc.expectedErrMsg != "" { + assert.Equal(t, tc.expectedErrMsg, textContent.Text) + return + } + + // 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 + mockClient := github.NewClient(nil) + tool, _ := DeleteWorkflowRunLogs(stubGetClientFn(mockClient), translations.NullTranslationHelper) + + assert.Equal(t, "delete_workflow_run_logs", tool.Name) + assert.NotEmpty(t, tool.Description) + assert.Contains(t, tool.InputSchema.Properties, "owner") + assert.Contains(t, tool.InputSchema.Properties, "repo") + assert.Contains(t, tool.InputSchema.Properties, "run_id") + assert.ElementsMatch(t, tool.InputSchema.Required, []string{"owner", "repo", "run_id"}) + + tests := []struct { + name string + mockedClient *http.Client + requestArgs map[string]any + expectError bool + expectedErrMsg string + }{ + { + name: "successful logs deletion", + mockedClient: mock.NewMockedHTTPClient( + mock.WithRequestMatchHandler( + mock.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: mock.NewMockedHTTPClient(), + requestArgs: map[string]any{ + "owner": "owner", + "repo": "repo", + }, + expectError: true, + expectedErrMsg: "missing required parameter: run_id", + }, + } + + for _, tc := range tests { + t.Run(tc.name, func(t *testing.T) { + // Setup client with mock + client := github.NewClient(tc.mockedClient) + _, handler := DeleteWorkflowRunLogs(stubGetClientFn(client), translations.NullTranslationHelper) + + // Create call request + request := createMCPRequest(tc.requestArgs) + + // Call handler + result, err := handler(context.Background(), request) + + require.NoError(t, err) + require.Equal(t, tc.expectError, result.IsError) + + // Parse the result and get the text content if no error + textContent := getTextResult(t, result) + + if tc.expectedErrMsg != "" { + assert.Equal(t, tc.expectedErrMsg, textContent.Text) + return + } + + // 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 + mockClient := github.NewClient(nil) + tool, _ := GetWorkflowRunUsage(stubGetClientFn(mockClient), translations.NullTranslationHelper) + + assert.Equal(t, "get_workflow_run_usage", tool.Name) + assert.NotEmpty(t, tool.Description) + assert.Contains(t, tool.InputSchema.Properties, "owner") + assert.Contains(t, tool.InputSchema.Properties, "repo") + assert.Contains(t, tool.InputSchema.Properties, "run_id") + assert.ElementsMatch(t, tool.InputSchema.Required, []string{"owner", "repo", "run_id"}) + + tests := []struct { + name string + mockedClient *http.Client + requestArgs map[string]any + expectError bool + expectedErrMsg string + }{ + { + name: "successful workflow run usage", + mockedClient: mock.NewMockedHTTPClient( + mock.WithRequestMatchHandler( + mock.GetReposActionsRunsTimingByOwnerByRepoByRunId, + http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) { + usage := &github.WorkflowRunUsage{ + Billable: &github.WorkflowRunBillMap{ + "UBUNTU": &github.WorkflowRunBill{ + TotalMS: github.Int64(120000), + Jobs: github.Int(2), + JobRuns: []*github.WorkflowRunJobRun{ + { + JobID: github.Int(1), + DurationMS: github.Int64(60000), + }, + { + JobID: github.Int(2), + DurationMS: github.Int64(60000), + }, + }, + }, + }, + RunDurationMS: github.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: mock.NewMockedHTTPClient(), + requestArgs: map[string]any{ + "owner": "owner", + "repo": "repo", + }, + expectError: true, + expectedErrMsg: "missing required parameter: run_id", + }, + } + + for _, tc := range tests { + t.Run(tc.name, func(t *testing.T) { + // Setup client with mock + client := github.NewClient(tc.mockedClient) + _, handler := GetWorkflowRunUsage(stubGetClientFn(client), translations.NullTranslationHelper) + + // Create call request + request := createMCPRequest(tc.requestArgs) + + // Call handler + result, err := handler(context.Background(), request) + + require.NoError(t, err) + require.Equal(t, tc.expectError, result.IsError) + + // Parse the result and get the text content if no error + textContent := getTextResult(t, result) + + if tc.expectedErrMsg != "" { + assert.Equal(t, tc.expectedErrMsg, textContent.Text) + return + } + + // 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 + mockClient := github.NewClient(nil) + tool, _ := GetJobLogs(stubGetClientFn(mockClient), translations.NullTranslationHelper) + + assert.Equal(t, "get_job_logs", tool.Name) + assert.NotEmpty(t, tool.Description) + assert.Contains(t, tool.InputSchema.Properties, "owner") + assert.Contains(t, tool.InputSchema.Properties, "repo") + assert.Contains(t, tool.InputSchema.Properties, "job_id") + assert.Contains(t, tool.InputSchema.Properties, "run_id") + assert.Contains(t, tool.InputSchema.Properties, "failed_only") + assert.Contains(t, tool.InputSchema.Properties, "return_content") + assert.ElementsMatch(t, tool.InputSchema.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: mock.NewMockedHTTPClient( + mock.WithRequestMatchHandler( + mock.GetReposActionsJobsLogsByOwnerByRepoByJobId, + http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) { + w.Header().Set("Location", "https://github.com/logs/job/123") + w.WriteHeader(http.StatusFound) + }), + ), + ), + 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: mock.NewMockedHTTPClient( + mock.WithRequestMatchHandler( + mock.GetReposActionsRunsJobsByOwnerByRepoByRunId, + http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) { + jobs := &github.Jobs{ + TotalCount: github.Int(3), + Jobs: []*github.WorkflowJob{ + { + ID: github.Int64(1), + Name: github.String("test-job-1"), + Conclusion: github.String("success"), + }, + { + ID: github.Int64(2), + Name: github.String("test-job-2"), + Conclusion: github.String("failure"), + }, + { + ID: github.Int64(3), + Name: github.String("test-job-3"), + Conclusion: github.String("failure"), + }, + }, + } + w.WriteHeader(http.StatusOK) + _ = json.NewEncoder(w).Encode(jobs) + }), + ), + mock.WithRequestMatchHandler( + mock.GetReposActionsJobsLogsByOwnerByRepoByJobId, + http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + w.Header().Set("Location", "https://github.com/logs/job/"+r.URL.Path[len(r.URL.Path)-1:]) + w.WriteHeader(http.StatusFound) + }), + ), + ), + 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: mock.NewMockedHTTPClient( + mock.WithRequestMatchHandler( + mock.GetReposActionsRunsJobsByOwnerByRepoByRunId, + http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) { + jobs := &github.Jobs{ + TotalCount: github.Int(2), + Jobs: []*github.WorkflowJob{ + { + ID: github.Int64(1), + Name: github.String("test-job-1"), + Conclusion: github.String("success"), + }, + { + ID: github.Int64(2), + Name: github.String("test-job-2"), + Conclusion: github.String("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: mock.NewMockedHTTPClient(), + 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: mock.NewMockedHTTPClient(), + 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: mock.NewMockedHTTPClient(), + requestArgs: map[string]any{ + "repo": "repo", + "job_id": float64(123), + }, + expectError: true, + expectedErrMsg: "missing required parameter: owner", + }, + { + name: "missing required parameter repo", + mockedClient: mock.NewMockedHTTPClient(), + 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: mock.NewMockedHTTPClient( + mock.WithRequestMatchHandler( + mock.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: mock.NewMockedHTTPClient( + mock.WithRequestMatchHandler( + mock.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) + _, handler := GetJobLogs(stubGetClientFn(client), translations.NullTranslationHelper) + + // Create call request + request := createMCPRequest(tc.requestArgs) + + // Call handler + result, err := handler(context.Background(), request) + + require.NoError(t, err) + require.Equal(t, tc.expectError, result.IsError) + + // Parse the result and get the text content + 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, r *http.Request) { + w.WriteHeader(http.StatusOK) + _, _ = w.Write([]byte(logContent)) + })) + defer testServer.Close() + + mockedClient := mock.NewMockedHTTPClient( + mock.WithRequestMatchHandler( + mock.GetReposActionsJobsLogsByOwnerByRepoByJobId, + http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) { + w.Header().Set("Location", testServer.URL) + w.WriteHeader(http.StatusFound) + }), + ), + ) + + client := github.NewClient(mockedClient) + _, handler := GetJobLogs(stubGetClientFn(client), translations.NullTranslationHelper) + + request := createMCPRequest(map[string]any{ + "owner": "owner", + "repo": "repo", + "job_id": float64(123), + "return_content": true, + }) + + result, err := handler(context.Background(), request) + require.NoError(t, err) + require.False(t, result.IsError) + + textContent := getTextResult(t, result) + var response map[string]any + err = json.Unmarshal([]byte(textContent.Text), &response) + require.NoError(t, err) + + assert.Equal(t, float64(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 +} diff --git a/pkg/github/tools.go b/pkg/github/tools.go index 550adddd..cca0d18c 100644 --- a/pkg/github/tools.go +++ b/pkg/github/tools.go @@ -103,6 +103,26 @@ func DefaultToolsetGroup(readOnly bool, getClient GetClientFn, getGQLClient GetG toolsets.NewServerTool(ManageRepositoryNotificationSubscription(getClient, t)), ) + actions := toolsets.NewToolset("actions", "GitHub Actions workflows and CI/CD operations"). + AddReadTools( + toolsets.NewServerTool(ListWorkflows(getClient, t)), + toolsets.NewServerTool(ListWorkflowRuns(getClient, t)), + toolsets.NewServerTool(GetWorkflowRun(getClient, t)), + toolsets.NewServerTool(GetWorkflowRunLogs(getClient, t)), + toolsets.NewServerTool(ListWorkflowJobs(getClient, t)), + toolsets.NewServerTool(GetJobLogs(getClient, t)), + toolsets.NewServerTool(ListWorkflowRunArtifacts(getClient, t)), + toolsets.NewServerTool(DownloadWorkflowRunArtifact(getClient, t)), + toolsets.NewServerTool(GetWorkflowRunUsage(getClient, t)), + ). + AddWriteTools( + toolsets.NewServerTool(RunWorkflow(getClient, t)), + toolsets.NewServerTool(RerunWorkflowRun(getClient, t)), + toolsets.NewServerTool(RerunFailedJobs(getClient, t)), + toolsets.NewServerTool(CancelWorkflowRun(getClient, t)), + toolsets.NewServerTool(DeleteWorkflowRunLogs(getClient, t)), + ) + // Keep experiments alive so the system doesn't error out when it's always enabled experiments := toolsets.NewToolset("experiments", "Experimental features that are not considered stable yet") @@ -111,6 +131,7 @@ func DefaultToolsetGroup(readOnly bool, getClient GetClientFn, getGQLClient GetG tsg.AddToolset(issues) tsg.AddToolset(users) tsg.AddToolset(pullRequests) + tsg.AddToolset(actions) tsg.AddToolset(codeSecurity) tsg.AddToolset(secretProtection) tsg.AddToolset(notifications)