diff --git a/README.md b/README.md index eacaef24..8b24a728 100644 --- a/README.md +++ b/README.md @@ -283,6 +283,27 @@ export GITHUB_MCP_TOOL_ADD_ISSUE_COMMENT_DESCRIPTION="an alternative description - `repo`: Repository name (string, required) - `issue_number`: Issue number (number, required) +- **get_issue_events** - Get events for a GitHub issue + - `owner`: Repository owner (string, required) + - `repo`: Repository name (string, required) + - `issue_number`: Issue number (number, required) + - `page`: Page number (number, optional) + - `perPage`: Results per page (number, optional) + +- **get_issue_timeline** - Get the timeline of events for a GitHub issue + + - `owner`: Repository owner (string, required) + - `repo`: Repository name (string, required) + - `issue_number`: Issue number (number, required) + - `page`: Page number (number, optional) + - `perPage`: Results per page (number, optional) + +- **get_issue_event** - Get a specific event for a GitHub issue + + - `owner`: Repository owner (string, required) + - `repo`: Repository name (string, required) + - `event_id`: Event ID (number, required) + - **create_issue** - Create a new issue in a GitHub repository - `owner`: Repository owner (string, required) diff --git a/pkg/github/issues.go b/pkg/github/issues.go index 0fcc2502..4fd1c86d 100644 --- a/pkg/github/issues.go +++ b/pkg/github/issues.go @@ -711,6 +711,218 @@ func GetIssueComments(getClient GetClientFn, t translations.TranslationHelperFun } } +// GetIssueTimeline creates a tool to get timeline for a GitHub issue. +func GetIssueTimeline(getClient GetClientFn, t translations.TranslationHelperFunc) (tool mcp.Tool, handler server.ToolHandlerFunc) { + return mcp.NewTool("get_issue_timeline", + mcp.WithDescription(t("TOOL_GET_ISSUE_TIMELINE_DESCRIPTION", "Get timeline for a GitHub issue")), + mcp.WithString("owner", + mcp.Required(), + mcp.Description("Repository owner"), + ), + mcp.WithString("repo", + mcp.Required(), + mcp.Description("Repository name"), + ), + mcp.WithNumber("issue_number", + mcp.Required(), + mcp.Description("Issue number"), + ), + mcp.WithNumber("page", + mcp.Description("Page number"), + ), + mcp.WithNumber("per_page", + mcp.Description("Number of records per page"), + ), + ), + 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 + } + issueNumber, err := RequiredInt(request, "issue_number") + if err != nil { + return mcp.NewToolResultError(err.Error()), nil + } + page, err := OptionalIntParamWithDefault(request, "page", 1) + if err != nil { + return mcp.NewToolResultError(err.Error()), nil + } + perPage, err := OptionalIntParamWithDefault(request, "per_page", 30) + if err != nil { + return mcp.NewToolResultError(err.Error()), nil + } + + opts := &github.ListOptions{ + Page: page, + PerPage: perPage, + } + + client, err := getClient(ctx) + if err != nil { + return nil, fmt.Errorf("failed to get GitHub client: %w", err) + } + events, resp, err := client.Issues.ListIssueTimeline(ctx, owner, repo, issueNumber, opts) + if err != nil { + return nil, fmt.Errorf("failed to get issue timeline: %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 issue timeline: %s", string(body))), nil + } + + r, err := json.Marshal(events) + if err != nil { + return nil, fmt.Errorf("failed to marshal response: %w", err) + } + + return mcp.NewToolResultText(string(r)), nil + } +} + +// GetIssueEvents creates a tool to get events for a GitHub issue. +func GetIssueEvents(getClient GetClientFn, t translations.TranslationHelperFunc) (tool mcp.Tool, handler server.ToolHandlerFunc) { + return mcp.NewTool("get_issue_events", + mcp.WithDescription(t("TOOL_GET_ISSUE_EVENTS_DESCRIPTION", "Get list of events for a GitHub issue")), + mcp.WithString("owner", + mcp.Required(), + mcp.Description("Repository owner"), + ), + mcp.WithString("repo", + mcp.Required(), + mcp.Description("Repository name"), + ), + mcp.WithNumber("issue_number", + mcp.Required(), + mcp.Description("Issue number"), + ), + mcp.WithNumber("page", + mcp.Description("Page number"), + ), + mcp.WithNumber("per_page", + mcp.Description("Number of records per page"), + ), + ), + 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 + } + issueNumber, err := RequiredInt(request, "issue_number") + if err != nil { + return mcp.NewToolResultError(err.Error()), nil + } + page, err := OptionalIntParamWithDefault(request, "page", 1) + if err != nil { + return mcp.NewToolResultError(err.Error()), nil + } + perPage, err := OptionalIntParamWithDefault(request, "per_page", 30) + if err != nil { + return mcp.NewToolResultError(err.Error()), nil + } + + opts := &github.ListOptions{ + Page: page, + PerPage: perPage, + } + + client, err := getClient(ctx) + if err != nil { + return nil, fmt.Errorf("failed to get GitHub client: %w", err) + } + events, resp, err := client.Issues.ListIssueEvents(ctx, owner, repo, issueNumber, opts) + if err != nil { + return nil, fmt.Errorf("failed to get issue events: %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 issue events: %s", string(body))), nil + } + + r, err := json.Marshal(events) + if err != nil { + return nil, fmt.Errorf("failed to marshal response: %w", err) + } + + return mcp.NewToolResultText(string(r)), nil + } +} + +// GetIssueEvent creates a tool to get an event for a GitHub issue. +func GetIssueEvent(getClient GetClientFn, t translations.TranslationHelperFunc) (tool mcp.Tool, handler server.ToolHandlerFunc) { + return mcp.NewTool("get_issue_event", + mcp.WithDescription(t("TOOL_GET_ISSUE_EVENT_DESCRIPTION", "Get single event for a GitHub issue")), + mcp.WithString("owner", + mcp.Required(), + mcp.Description("Repository owner"), + ), + mcp.WithString("repo", + mcp.Required(), + mcp.Description("Repository name"), + ), + mcp.WithNumber("event_id", + mcp.Required(), + mcp.Description("Event ID"), + ), + ), + 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 + } + eventID, err := RequiredInt64(request, "event_id") + 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) + } + event, resp, err := client.Issues.GetEvent(ctx, owner, repo, eventID) + if err != nil { + return nil, fmt.Errorf("failed to get issue event: %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 issue event: %s", string(body))), nil + } + + r, err := json.Marshal(event) + if err != nil { + return nil, fmt.Errorf("failed to marshal response: %w", err) + } + + return mcp.NewToolResultText(string(r)), nil + } +} + // parseISOTimestamp parses an ISO 8601 timestamp string into a time.Time object. // Returns the parsed time or an error if parsing fails. // Example formats supported: "2023-01-15T14:30:00Z", "2023-01-15" diff --git a/pkg/github/issues_test.go b/pkg/github/issues_test.go index 61ca0ae7..fdf0a40f 100644 --- a/pkg/github/issues_test.go +++ b/pkg/github/issues_test.go @@ -1130,3 +1130,358 @@ func Test_GetIssueComments(t *testing.T) { }) } } + +func Test_GetIssueTimeline(t *testing.T) { + // Verify tool definition once + mockClient := github.NewClient(nil) + tool, _ := GetIssueTimeline(stubGetClientFn(mockClient), translations.NullTranslationHelper) + + assert.Equal(t, "get_issue_timeline", 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, "issue_number") + assert.Contains(t, tool.InputSchema.Properties, "page") + assert.Contains(t, tool.InputSchema.Properties, "per_page") + assert.ElementsMatch(t, tool.InputSchema.Required, []string{"owner", "repo", "issue_number"}) + + // Setup mock timeline for success case + mockTimeline := []*github.Timeline{ + { + ID: github.Ptr(int64(123)), + URL: github.Ptr("https://api.github.com/repos/owner/repo/issues/events/17196710688"), + User: &github.User{ + Login: github.Ptr("user1"), + }, + Event: github.Ptr("connected"), + }, + { + ID: github.Ptr(int64(456)), + URL: github.Ptr("https://api.github.com/repos/owner/repo/issues/events/17196710689"), + User: &github.User{ + Login: github.Ptr("user2"), + }, + Event: github.Ptr("disconnected"), + }, + } + + tests := []struct { + name string + mockedClient *http.Client + requestArgs map[string]interface{} + expectError bool + expectedTimeline []*github.Timeline + expectedErrMsg string + }{ + { + name: "successful timeline retrieval", + mockedClient: mock.NewMockedHTTPClient( + mock.WithRequestMatch( + mock.GetReposIssuesTimelineByOwnerByRepoByIssueNumber, + mockTimeline, + ), + ), + requestArgs: map[string]interface{}{ + "owner": "owner", + "repo": "repo", + "issue_number": float64(42), + }, + expectError: false, + expectedTimeline: mockTimeline, + }, + { + name: "successful timeline retrieval with pagination", + mockedClient: mock.NewMockedHTTPClient( + mock.WithRequestMatchHandler( + mock.GetReposIssuesTimelineByOwnerByRepoByIssueNumber, + expectQueryParams(t, map[string]string{ + "page": "2", + "per_page": "10", + }).andThen( + mockResponse(t, http.StatusOK, mockTimeline), + ), + ), + ), + requestArgs: map[string]interface{}{ + "owner": "owner", + "repo": "repo", + "issue_number": float64(42), + "page": float64(2), + "per_page": float64(10), + }, + expectError: false, + expectedTimeline: mockTimeline, + }, + { + name: "issue not found", + mockedClient: mock.NewMockedHTTPClient( + mock.WithRequestMatchHandler( + mock.GetReposIssuesTimelineByOwnerByRepoByIssueNumber, + mockResponse(t, http.StatusNotFound, `{"message": "Issue not found"}`), + ), + ), + requestArgs: map[string]interface{}{ + "owner": "owner", + "repo": "repo", + "issue_number": float64(999), + }, + expectError: true, + expectedErrMsg: "failed to get issue timeline", + }, + } + + for _, tc := range tests { + t.Run(tc.name, func(t *testing.T) { + // Setup client with mock + client := github.NewClient(tc.mockedClient) + _, handler := GetIssueTimeline(stubGetClientFn(client), translations.NullTranslationHelper) + + // Create call request + request := createMCPRequest(tc.requestArgs) + + // Call handler + result, err := handler(context.Background(), request) + + // Verify results + if tc.expectError { + require.Error(t, err) + assert.Contains(t, err.Error(), tc.expectedErrMsg) + return + } + + require.NoError(t, err) + textContent := getTextResult(t, result) + + // Unmarshal and verify the result + var returnedTimeline []*github.Timeline + err = json.Unmarshal([]byte(textContent.Text), &returnedTimeline) + require.NoError(t, err) + assert.Equal(t, len(tc.expectedTimeline), len(returnedTimeline)) + if len(returnedTimeline) > 0 { + assert.Equal(t, *tc.expectedTimeline[0].URL, *returnedTimeline[0].URL) + assert.Equal(t, *tc.expectedTimeline[0].User.Login, *returnedTimeline[0].User.Login) + } + }) + } +} + +func Test_GetIssueEvents(t *testing.T) { + // Verify tool definition once + mockClient := github.NewClient(nil) + tool, _ := GetIssueEvents(stubGetClientFn(mockClient), translations.NullTranslationHelper) + + assert.Equal(t, "get_issue_events", 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, "issue_number") + assert.Contains(t, tool.InputSchema.Properties, "page") + assert.Contains(t, tool.InputSchema.Properties, "per_page") + assert.ElementsMatch(t, tool.InputSchema.Required, []string{"owner", "repo", "issue_number"}) + + // Setup mock events for success case + mockEvents := []*github.IssueEvent{ + { + ID: github.Ptr(int64(123)), + URL: github.Ptr("https://api.github.com/repos/owner/repo/issues/events/17196710688"), + Event: github.Ptr("connected"), + }, + { + ID: github.Ptr(int64(456)), + URL: github.Ptr("https://api.github.com/repos/owner/repo/issues/events/17196710689"), + Event: github.Ptr("disconnected"), + }, + } + + tests := []struct { + name string + mockedClient *http.Client + requestArgs map[string]interface{} + expectError bool + expectedEvents []*github.IssueEvent + expectedErrMsg string + }{ + { + name: "successful events retrieval", + mockedClient: mock.NewMockedHTTPClient( + mock.WithRequestMatch( + mock.GetReposIssuesEventsByOwnerByRepoByIssueNumber, + mockEvents, + ), + ), + requestArgs: map[string]interface{}{ + "owner": "owner", + "repo": "repo", + "issue_number": float64(42), + }, + expectError: false, + expectedEvents: mockEvents, + }, + { + name: "successful events retrieval with pagination", + mockedClient: mock.NewMockedHTTPClient( + mock.WithRequestMatchHandler( + mock.GetReposIssuesEventsByOwnerByRepoByIssueNumber, + expectQueryParams(t, map[string]string{ + "page": "2", + "per_page": "10", + }).andThen( + mockResponse(t, http.StatusOK, mockEvents), + ), + ), + ), + requestArgs: map[string]interface{}{ + "owner": "owner", + "repo": "repo", + "issue_number": float64(42), + "page": float64(2), + "per_page": float64(10), + }, + expectError: false, + expectedEvents: mockEvents, + }, + { + name: "issue not found", + mockedClient: mock.NewMockedHTTPClient( + mock.WithRequestMatchHandler( + mock.GetReposIssuesEventsByOwnerByRepoByIssueNumber, + mockResponse(t, http.StatusNotFound, `{"message": "Issue not found"}`), + ), + ), + requestArgs: map[string]interface{}{ + "owner": "owner", + "repo": "repo", + "issue_number": float64(999), + }, + expectError: true, + expectedErrMsg: "failed to get issue events", + }, + } + + for _, tc := range tests { + t.Run(tc.name, func(t *testing.T) { + // Setup client with mock + client := github.NewClient(tc.mockedClient) + _, handler := GetIssueEvents(stubGetClientFn(client), translations.NullTranslationHelper) + + // Create call request + request := createMCPRequest(tc.requestArgs) + + // Call handler + result, err := handler(context.Background(), request) + + // Verify results + if tc.expectError { + require.Error(t, err) + assert.Contains(t, err.Error(), tc.expectedErrMsg) + return + } + + require.NoError(t, err) + textContent := getTextResult(t, result) + + // Unmarshal and verify the result + var returnedEvents []*github.IssueEvent + err = json.Unmarshal([]byte(textContent.Text), &returnedEvents) + require.NoError(t, err) + assert.Equal(t, len(tc.expectedEvents), len(returnedEvents)) + if len(returnedEvents) > 0 { + assert.Equal(t, *tc.expectedEvents[0].URL, *returnedEvents[0].URL) + assert.Equal(t, *tc.expectedEvents[0].Event, *returnedEvents[0].Event) + } + }) + } +} + +func Test_GetIssueEvent(t *testing.T) { + // Verify tool definition once + mockClient := github.NewClient(nil) + tool, _ := GetIssueEvent(stubGetClientFn(mockClient), translations.NullTranslationHelper) + + assert.Equal(t, "get_issue_event", 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, "event_id") + assert.ElementsMatch(t, tool.InputSchema.Required, []string{"owner", "repo", "event_id"}) + + // Setup mock event for success case + mockEvent := github.IssueEvent{ + ID: github.Ptr(int64(17196710688)), + URL: github.Ptr("https://api.github.com/repos/owner/repo/issues/events/17196710688"), + Event: github.Ptr("connected"), + } + + tests := []struct { + name string + mockedClient *http.Client + requestArgs map[string]interface{} + expectError bool + expectedEvent github.IssueEvent + expectedErrMsg string + }{ + { + name: "successful event retrieval", + mockedClient: mock.NewMockedHTTPClient( + mock.WithRequestMatch( + mock.GetReposIssuesEventsByOwnerByRepoByEventId, + mockEvent, + ), + ), + requestArgs: map[string]interface{}{ + "owner": "owner", + "repo": "repo", + "event_id": float64(42), + }, + expectError: false, + expectedEvent: mockEvent, + }, + { + name: "event not found", + mockedClient: mock.NewMockedHTTPClient( + mock.WithRequestMatchHandler( + mock.GetReposIssuesEventsByOwnerByRepoByEventId, + mockResponse(t, http.StatusNotFound, `{"message": "Event not found"}`), + ), + ), + requestArgs: map[string]interface{}{ + "owner": "owner", + "repo": "repo", + "event_id": float64(999), + }, + expectError: true, + expectedErrMsg: "failed to get issue event", + }, + } + + for _, tc := range tests { + t.Run(tc.name, func(t *testing.T) { + // Setup client with mock + client := github.NewClient(tc.mockedClient) + _, handler := GetIssueEvent(stubGetClientFn(client), translations.NullTranslationHelper) + + // Create call request + request := createMCPRequest(tc.requestArgs) + + // Call handler + result, err := handler(context.Background(), request) + + // Verify results + if tc.expectError { + require.Error(t, err) + assert.Contains(t, err.Error(), tc.expectedErrMsg) + return + } + + require.NoError(t, err) + textContent := getTextResult(t, result) + + // Unmarshal and verify the result + var returnedEvent github.IssueEvent + err = json.Unmarshal([]byte(textContent.Text), &returnedEvent) + require.NoError(t, err) + assert.Equal(t, *tc.expectedEvent.URL, *returnedEvent.URL) + assert.Equal(t, *tc.expectedEvent.Event, *returnedEvent.Event) + }) + } +} diff --git a/pkg/github/server.go b/pkg/github/server.go index e4c24171..07de01ed 100644 --- a/pkg/github/server.go +++ b/pkg/github/server.go @@ -98,6 +98,19 @@ func RequiredInt(r mcp.CallToolRequest, p string) (int, error) { return int(v), nil } +// RequiredInt64 is a helper function that can be used to fetch a requested parameter from the request. +// It does the following checks: +// 1. Checks if the parameter is present in the request. +// 2. Checks if the parameter is of the expected type. +// 3. Checks if the parameter is not empty, i.e: non-zero value +func RequiredInt64(r mcp.CallToolRequest, p string) (int64, error) { + v, err := requiredParam[float64](r, p) + if err != nil { + return 0, err + } + return int64(v), nil +} + // OptionalParam is a helper function that can be used to fetch a requested parameter from the request. // It does the following checks: // 1. Checks if the parameter is present in the request, if not, it returns its zero-value diff --git a/pkg/github/tools.go b/pkg/github/tools.go index 3776a129..bba50aa9 100644 --- a/pkg/github/tools.go +++ b/pkg/github/tools.go @@ -43,6 +43,9 @@ func InitToolsets(passedToolsets []string, readOnly bool, getClient GetClientFn, toolsets.NewServerTool(SearchIssues(getClient, t)), toolsets.NewServerTool(ListIssues(getClient, t)), toolsets.NewServerTool(GetIssueComments(getClient, t)), + toolsets.NewServerTool(GetIssueTimeline(getClient, t)), + toolsets.NewServerTool(GetIssueEvents(getClient, t)), + toolsets.NewServerTool(GetIssueEvent(getClient, t)), ). AddWriteTools( toolsets.NewServerTool(CreateIssue(getClient, t)),