From 4e387dc1935df4edf0e03048c8f5e957fb646ba4 Mon Sep 17 00:00:00 2001 From: Artem Saveliev Date: Wed, 9 Apr 2025 23:01:39 -0700 Subject: [PATCH] Added get_issue_events , get_issue_timeline and get_issue_event tools --- README.md | 21 +++ pkg/github/issues.go | 212 +++++++++++++++++++++++ pkg/github/issues_test.go | 355 ++++++++++++++++++++++++++++++++++++++ pkg/github/server.go | 16 ++ 4 files changed, 604 insertions(+) diff --git a/README.md b/README.md index b78be380..de558c22 100644 --- a/README.md +++ b/README.md @@ -159,6 +159,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 16c34141..a6146105 100644 --- a/pkg/github/issues.go +++ b/pkg/github/issues.go @@ -683,6 +683,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 9dee1596..3ce796d2 100644 --- a/pkg/github/server.go +++ b/pkg/github/server.go @@ -37,6 +37,9 @@ func NewServer(getClient GetClientFn, version string, readOnly bool, t translati s.AddTool(SearchIssues(getClient, t)) s.AddTool(ListIssues(getClient, t)) s.AddTool(GetIssueComments(getClient, t)) + s.AddTool(GetIssueTimeline(getClient, t)) + s.AddTool(GetIssueEvents(getClient, t)) + s.AddTool(GetIssueEvent(getClient, t)) if !readOnly { s.AddTool(CreateIssue(getClient, t)) s.AddTool(AddIssueComment(getClient, t)) @@ -188,6 +191,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