diff --git a/README.md b/README.md index c8a55ea7b..d129a7bc0 100644 --- a/README.md +++ b/README.md @@ -828,6 +828,10 @@ The following sets of tools are available (all are on by default): - `ref`: Accepts optional git refs such as `refs/tags/{tag}`, `refs/heads/{branch}` or `refs/pull/{pr_number}/head` (string, optional) - `repo`: Repository name (string, required) - `sha`: Accepts optional commit SHA. If specified, it will be used instead of ref (string, optional) + +- **get_latest_release** - Get latest release + - `owner`: Repository owner (string, required) + - `repo`: Repository name (string, required) - **get_tag** - Get tag details - `owner`: Repository owner (string, required) @@ -847,6 +851,12 @@ The following sets of tools are available (all are on by default): - `perPage`: Results per page for pagination (min 1, max 100) (number, optional) - `repo`: Repository name (string, required) - `sha`: Commit SHA, branch or tag name to list commits of. If not provided, uses the default branch of the repository. If a commit SHA is provided, will list commits up to that SHA. (string, optional) + +- **list_releases** - List releases + - `owner`: Repository owner (string, required) + - `page`: Page number for pagination (min 1) (number, optional) + - `perPage`: Results per page for pagination (min 1, max 100) (number, optional) + - `repo`: Repository name (string, required) - **list_tags** - List tags - `owner`: Repository owner (string, required) @@ -1075,4 +1085,4 @@ The exported Go API of this module should currently be considered unstable, and ## License -This project is licensed under the terms of the MIT open source license. Please refer to [MIT](./LICENSE) for the full terms. \ No newline at end of file +This project is licensed under the terms of the MIT open source license. Please refer to [MIT](./LICENSE) for the full terms. diff --git a/pkg/github/repositories.go b/pkg/github/repositories.go index 69aaaa899..0925829a1 100644 --- a/pkg/github/repositories.go +++ b/pkg/github/repositories.go @@ -1321,6 +1321,126 @@ func GetTag(getClient GetClientFn, t translations.TranslationHelperFunc) (tool m } } +// ListReleases creates a tool to list releases in a GitHub repository. +func ListReleases(getClient GetClientFn, t translations.TranslationHelperFunc) (tool mcp.Tool, handler server.ToolHandlerFunc) { + return mcp.NewTool("list_releases", + mcp.WithDescription(t("TOOL_LIST_RELEASES_DESCRIPTION", "List releases in a GitHub repository")), + mcp.WithToolAnnotation(mcp.ToolAnnotation{ + Title: t("TOOL_LIST_RELEASES_USER_TITLE", "List releases"), + ReadOnlyHint: ToBoolPtr(true), + }), + mcp.WithString("owner", + mcp.Required(), + mcp.Description("Repository owner"), + ), + mcp.WithString("repo", + mcp.Required(), + mcp.Description("Repository name"), + ), + WithPagination(), + ), + 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 + } + pagination, err := OptionalPaginationParams(request) + if err != nil { + return mcp.NewToolResultError(err.Error()), nil + } + + opts := &github.ListOptions{ + Page: pagination.Page, + PerPage: pagination.PerPage, + } + + client, err := getClient(ctx) + if err != nil { + return nil, fmt.Errorf("failed to get GitHub client: %w", err) + } + + releases, resp, err := client.Repositories.ListReleases(ctx, owner, repo, opts) + if err != nil { + return nil, fmt.Errorf("failed to list releases: %w", err) + } + defer func() { _ = resp.Body.Close() }() + + if resp.StatusCode != http.StatusOK { + body, err := io.ReadAll(resp.Body) + if err != nil { + return nil, fmt.Errorf("failed to read response body: %w", err) + } + return mcp.NewToolResultError(fmt.Sprintf("failed to list releases: %s", string(body))), nil + } + + r, err := json.Marshal(releases) + if err != nil { + return nil, fmt.Errorf("failed to marshal response: %w", err) + } + + return mcp.NewToolResultText(string(r)), nil + } +} + +// GetLatestRelease creates a tool to get the latest release in a GitHub repository. +func GetLatestRelease(getClient GetClientFn, t translations.TranslationHelperFunc) (tool mcp.Tool, handler server.ToolHandlerFunc) { + return mcp.NewTool("get_latest_release", + mcp.WithDescription(t("TOOL_GET_LATEST_RELEASE_DESCRIPTION", "Get the latest release in a GitHub repository")), + mcp.WithToolAnnotation(mcp.ToolAnnotation{ + Title: t("TOOL_GET_LATEST_RELEASE_USER_TITLE", "Get latest release"), + ReadOnlyHint: ToBoolPtr(true), + }), + mcp.WithString("owner", + mcp.Required(), + mcp.Description("Repository owner"), + ), + mcp.WithString("repo", + mcp.Required(), + mcp.Description("Repository name"), + ), + ), + 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 + } + + client, err := getClient(ctx) + if err != nil { + return nil, fmt.Errorf("failed to get GitHub client: %w", err) + } + + release, resp, err := client.Repositories.GetLatestRelease(ctx, owner, repo) + if err != nil { + return nil, fmt.Errorf("failed to get latest release: %w", err) + } + defer func() { _ = resp.Body.Close() }() + + if resp.StatusCode != http.StatusOK { + body, err := io.ReadAll(resp.Body) + if err != nil { + return nil, fmt.Errorf("failed to read response body: %w", err) + } + return mcp.NewToolResultError(fmt.Sprintf("failed to get latest release: %s", string(body))), nil + } + + r, err := json.Marshal(release) + if err != nil { + return nil, fmt.Errorf("failed to marshal response: %w", err) + } + + return mcp.NewToolResultText(string(r)), nil + } +} + // filterPaths filters the entries in a GitHub tree to find paths that // match the given suffix. // maxResults limits the number of results returned to first maxResults entries, diff --git a/pkg/github/repositories_test.go b/pkg/github/repositories_test.go index 36ef66d87..63e577600 100644 --- a/pkg/github/repositories_test.go +++ b/pkg/github/repositories_test.go @@ -2114,6 +2114,179 @@ func Test_GetTag(t *testing.T) { } } +func Test_ListReleases(t *testing.T) { + mockClient := github.NewClient(nil) + tool, _ := ListReleases(stubGetClientFn(mockClient), translations.NullTranslationHelper) + + assert.Equal(t, "list_releases", tool.Name) + assert.NotEmpty(t, tool.Description) + assert.Contains(t, tool.InputSchema.Properties, "owner") + assert.Contains(t, tool.InputSchema.Properties, "repo") + assert.ElementsMatch(t, tool.InputSchema.Required, []string{"owner", "repo"}) + + mockReleases := []*github.RepositoryRelease{ + { + ID: github.Ptr(int64(1)), + TagName: github.Ptr("v1.0.0"), + Name: github.Ptr("First Release"), + }, + { + ID: github.Ptr(int64(2)), + TagName: github.Ptr("v0.9.0"), + Name: github.Ptr("Beta Release"), + }, + } + + tests := []struct { + name string + mockedClient *http.Client + requestArgs map[string]interface{} + expectError bool + expectedResult []*github.RepositoryRelease + expectedErrMsg string + }{ + { + name: "successful releases list", + mockedClient: mock.NewMockedHTTPClient( + mock.WithRequestMatch( + mock.GetReposReleasesByOwnerByRepo, + mockReleases, + ), + ), + requestArgs: map[string]interface{}{ + "owner": "owner", + "repo": "repo", + }, + expectError: false, + expectedResult: mockReleases, + }, + { + name: "releases list fails", + mockedClient: mock.NewMockedHTTPClient( + mock.WithRequestMatchHandler( + mock.GetReposReleasesByOwnerByRepo, + http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) { + w.WriteHeader(http.StatusNotFound) + _, _ = w.Write([]byte(`{"message": "Not Found"}`)) + }), + ), + ), + requestArgs: map[string]interface{}{ + "owner": "owner", + "repo": "repo", + }, + expectError: true, + expectedErrMsg: "failed to list releases", + }, + } + + for _, tc := range tests { + t.Run(tc.name, func(t *testing.T) { + client := github.NewClient(tc.mockedClient) + _, handler := ListReleases(stubGetClientFn(client), translations.NullTranslationHelper) + request := createMCPRequest(tc.requestArgs) + result, err := handler(context.Background(), request) + + if tc.expectError { + require.Error(t, err) + assert.Contains(t, err.Error(), tc.expectedErrMsg) + return + } + + require.NoError(t, err) + textContent := getTextResult(t, result) + var returnedReleases []*github.RepositoryRelease + err = json.Unmarshal([]byte(textContent.Text), &returnedReleases) + require.NoError(t, err) + assert.Len(t, returnedReleases, len(tc.expectedResult)) + for i, rel := range returnedReleases { + assert.Equal(t, *tc.expectedResult[i].TagName, *rel.TagName) + } + }) + } +} +func Test_GetLatestRelease(t *testing.T) { + mockClient := github.NewClient(nil) + tool, _ := GetLatestRelease(stubGetClientFn(mockClient), translations.NullTranslationHelper) + + assert.Equal(t, "get_latest_release", tool.Name) + assert.NotEmpty(t, tool.Description) + assert.Contains(t, tool.InputSchema.Properties, "owner") + assert.Contains(t, tool.InputSchema.Properties, "repo") + assert.ElementsMatch(t, tool.InputSchema.Required, []string{"owner", "repo"}) + + mockRelease := &github.RepositoryRelease{ + ID: github.Ptr(int64(1)), + TagName: github.Ptr("v1.0.0"), + Name: github.Ptr("First Release"), + } + + tests := []struct { + name string + mockedClient *http.Client + requestArgs map[string]interface{} + expectError bool + expectedResult *github.RepositoryRelease + expectedErrMsg string + }{ + { + name: "successful latest release fetch", + mockedClient: mock.NewMockedHTTPClient( + mock.WithRequestMatch( + mock.GetReposReleasesLatestByOwnerByRepo, + mockRelease, + ), + ), + requestArgs: map[string]interface{}{ + "owner": "owner", + "repo": "repo", + }, + expectError: false, + expectedResult: mockRelease, + }, + { + name: "latest release fetch fails", + mockedClient: mock.NewMockedHTTPClient( + mock.WithRequestMatchHandler( + mock.GetReposReleasesLatestByOwnerByRepo, + http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) { + w.WriteHeader(http.StatusNotFound) + _, _ = w.Write([]byte(`{"message": "Not Found"}`)) + }), + ), + ), + requestArgs: map[string]interface{}{ + "owner": "owner", + "repo": "repo", + }, + expectError: true, + expectedErrMsg: "failed to get latest release", + }, + } + + for _, tc := range tests { + t.Run(tc.name, func(t *testing.T) { + client := github.NewClient(tc.mockedClient) + _, handler := GetLatestRelease(stubGetClientFn(client), translations.NullTranslationHelper) + request := createMCPRequest(tc.requestArgs) + result, err := handler(context.Background(), request) + + if tc.expectError { + require.Error(t, err) + assert.Contains(t, err.Error(), tc.expectedErrMsg) + return + } + + require.NoError(t, err) + textContent := getTextResult(t, result) + var returnedRelease github.RepositoryRelease + err = json.Unmarshal([]byte(textContent.Text), &returnedRelease) + require.NoError(t, err) + assert.Equal(t, *tc.expectedResult.TagName, *returnedRelease.TagName) + }) + } +} + func Test_filterPaths(t *testing.T) { tests := []struct { name string diff --git a/pkg/github/tools.go b/pkg/github/tools.go index b41ba9467..b0f961c60 100644 --- a/pkg/github/tools.go +++ b/pkg/github/tools.go @@ -31,6 +31,8 @@ func DefaultToolsetGroup(readOnly bool, getClient GetClientFn, getGQLClient GetG toolsets.NewServerTool(ListBranches(getClient, t)), toolsets.NewServerTool(ListTags(getClient, t)), toolsets.NewServerTool(GetTag(getClient, t)), + toolsets.NewServerTool(ListReleases(getClient, t)), + toolsets.NewServerTool(GetLatestRelease(getClient, t)), ). AddWriteTools( toolsets.NewServerTool(CreateOrUpdateFile(getClient, t)),