Files
Gabor Nyerges da6476def8 feat: add GitHub Actions tools for workflow management (#491)
* feat: add GitHub Actions tools for workflow management

- Introduced new tools for managing GitHub Actions workflows, including listing workflows, running workflows, canceling workflow runs, and retrieving workflow run logs.
- Updated README.md to include new `actions` toolset and detailed descriptions of the new tools.
- Added comprehensive tests for the new functionality to ensure reliability and correctness.

* feat: enhance GitHub Actions toolset with additional workflow management capabilities

- Added new tools for managing GitHub Actions, including listing workflows, retrieving workflow run logs, and managing workflow runs.
- Integrated the new `actions` toolset into the default toolset group for improved accessibility.

* feat: enhance GetJobLogs functionality for improved job log retrieval

- Added new tests for GetJobLogs, including scenarios for retrieving logs for both single jobs and failed jobs.
- Updated GetJobLogs tool description to clarify its capabilities for fetching logs efficiently.
- Implemented error handling for missing required parameters and optimized responses for failed job logs.
- Introduced functionality to return actual log content instead of just URLs when requested.

* feat: enhance GetJobLogs functionality for improved job log retrieval

- Added new tests for GetJobLogs, including scenarios for retrieving logs for both single jobs and failed jobs.
- Updated GetJobLogs tool description to clarify its capabilities for fetching logs efficiently.
- Implemented error handling for missing required parameters and optimized responses for failed job logs.
- Introduced functionality to return actual log content instead of just URLs when requested.

* refactor: standardize parameter handling and read-only hints in GitHub Actions tools

- Replaced instances of `requiredParam` with `RequiredParam` for consistency across all tools.
- Updated `toBoolPtr` to `ToBoolPtr` in tool annotations to maintain uniformity in boolean pointer handling.
- Ensured all tools in the GitHub Actions suite adhere to the new naming conventions for improved readability and maintainability.

* docs: add missing actions toolset to Available Toolsets table

* feat: enhance GitHub Actions tool descriptions with enumerated options

- Updated descriptions for workflow run status and job filters to include enumerated options for clarity.
- Improved documentation for better usability and understanding of available parameters.

* feat: expand event type options in GitHub Actions tool descriptions

- Enhanced the event parameter description in the ListWorkflowRuns function to include a comprehensive list of supported event types.
- Improved clarity and usability for users by providing enumerated options for event types in the documentation.

* feat: add support for running workflows by ID and filename in GitHub Actions tools

- Introduced a new tool, RunWorkflowByFileName, to allow users to run workflows using the workflow filename.
- Updated the existing RunWorkflow tool to accept a numeric workflow ID instead of a filename.
- Enhanced tests to cover scenarios for both running workflows by ID and filename, including error handling for missing parameters.
- Improved tool descriptions for clarity and usability.

* feat: standardize repository parameter descriptions in GitHub Actions tools

- Introduced constants for repository owner and name descriptions to enhance consistency across multiple tools.
- Updated all relevant tools to use the new constants for improved clarity and maintainability in parameter descriptions.

* feat: enhance GitHub Actions tools with user-friendly titles

- Added user-friendly titles to tool annotations for various GitHub Actions tools, improving clarity and usability for end-users.
- Updated descriptions for tools including ListWorkflows, ListWorkflowRuns, RunWorkflow, and others to include new titles for better identification and understanding of their functionalities.

* feat: unify workflow execution in GitHub Actions tools

- Refactored the RunWorkflow tool to accept both numeric workflow IDs and filenames, enhancing flexibility for users.
- Updated the corresponding tests to reflect changes in parameter handling and added assertions for workflow type in responses.
- Removed the separate RunWorkflowByFileName tool to streamline functionality and improve code maintainability.

* fix: linting issues
2025-06-18 14:56:58 +02:00

1098 lines
34 KiB
Go

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.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: 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_id")
assert.Contains(t, tool.InputSchema.Properties, "ref")
assert.Contains(t, tool.InputSchema.Properties, "inputs")
assert.ElementsMatch(t, tool.InputSchema.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: mock.NewMockedHTTPClient(
mock.WithRequestMatchHandler(
mock.PostReposActionsWorkflowsDispatchesByOwnerByRepoByWorkflowId,
http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) {
w.WriteHeader(http.StatusNoContent)
}),
),
),
requestArgs: map[string]any{
"owner": "owner",
"repo": "repo",
"workflow_id": "12345",
"ref": "main",
},
expectError: false,
},
{
name: "missing required parameter workflow_id",
mockedClient: mock.NewMockedHTTPClient(),
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)
_, 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"])
assert.Contains(t, response, "workflow_type")
})
}
}
func Test_RunWorkflow_WithFilename(t *testing.T) {
// Test the unified RunWorkflow function with filenames
tests := []struct {
name string
mockedClient *http.Client
requestArgs map[string]any
expectError bool
expectedErrMsg string
}{
{
name: "successful workflow run by filename",
mockedClient: mock.NewMockedHTTPClient(
mock.WithRequestMatchHandler(
mock.PostReposActionsWorkflowsDispatchesByOwnerByRepoByWorkflowId,
http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) {
w.WriteHeader(http.StatusNoContent)
}),
),
),
requestArgs: map[string]any{
"owner": "owner",
"repo": "repo",
"workflow_id": "ci.yml",
"ref": "main",
},
expectError: false,
},
{
name: "successful workflow run by numeric ID as string",
mockedClient: mock.NewMockedHTTPClient(
mock.WithRequestMatchHandler(
mock.PostReposActionsWorkflowsDispatchesByOwnerByRepoByWorkflowId,
http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) {
w.WriteHeader(http.StatusNoContent)
}),
),
),
requestArgs: map[string]any{
"owner": "owner",
"repo": "repo",
"workflow_id": "12345",
"ref": "main",
},
expectError: false,
},
{
name: "missing required parameter workflow_id",
mockedClient: mock.NewMockedHTTPClient(),
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)
_, 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"])
assert.Contains(t, response, "workflow_type")
})
}
}
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.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: 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.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: 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.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)
}),
),
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.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: 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
}