Skip to content

Commit 3bc8632

Browse files
committed
add support for list_issues
1 parent cb919d5 commit 3bc8632

File tree

4 files changed

+351
-0
lines changed

4 files changed

+351
-0
lines changed

README.md

+12
Original file line numberDiff line numberDiff line change
@@ -38,6 +38,18 @@ and set it as the GITHUB_PERSONAL_ACCESS_TOKEN environment variable.
3838
- `issue_number`: Issue number (number, required)
3939
- `body`: Comment text (string, required)
4040

41+
- **list_issues** - List and filter repository issues
42+
43+
- `owner`: Repository owner (string, required)
44+
- `repo`: Repository name (string, required)
45+
- `state`: Filter by state ('open', 'closed', 'all') (string, optional)
46+
- `labels`: Comma-separated list of labels to filter by (string, optional)
47+
- `sort`: Sort by ('created', 'updated', 'comments') (string, optional)
48+
- `direction`: Sort direction ('asc', 'desc') (string, optional)
49+
- `since`: Filter by date (ISO 8601 timestamp) (string, optional)
50+
- `page`: Page number (number, optional)
51+
- `per_page`: Results per page (number, optional)
52+
4153
- **search_issues** - Search for issues and pull requests
4254
- `query`: Search query (string, required)
4355
- `sort`: Sort field (string, optional)

pkg/github/issues.go

+121
Original file line numberDiff line numberDiff line change
@@ -6,6 +6,7 @@ import (
66
"fmt"
77
"io"
88
"net/http"
9+
"time"
910

1011
"github.com/google/go-github/v69/github"
1112
"github.com/mark3labs/mcp-go/mcp"
@@ -262,3 +263,123 @@ func createIssue(client *github.Client) (tool mcp.Tool, handler server.ToolHandl
262263
return mcp.NewToolResultText(string(r)), nil
263264
}
264265
}
266+
267+
// listIssues creates a tool to list and filter repository issues
268+
func listIssues(client *github.Client) (tool mcp.Tool, handler server.ToolHandlerFunc) {
269+
return mcp.NewTool("list_issues",
270+
mcp.WithDescription("List issues in a GitHub repository with filtering options"),
271+
mcp.WithString("owner",
272+
mcp.Required(),
273+
mcp.Description("Repository owner"),
274+
),
275+
mcp.WithString("repo",
276+
mcp.Required(),
277+
mcp.Description("Repository name"),
278+
),
279+
mcp.WithString("state",
280+
mcp.Description("Filter by state ('open', 'closed', 'all')"),
281+
),
282+
mcp.WithString("labels",
283+
mcp.Description("Comma-separated list of labels to filter by"),
284+
),
285+
mcp.WithString("sort",
286+
mcp.Description("Sort by ('created', 'updated', 'comments')"),
287+
),
288+
mcp.WithString("direction",
289+
mcp.Description("Sort direction ('asc', 'desc')"),
290+
),
291+
mcp.WithString("since",
292+
mcp.Description("Filter by date (ISO 8601 timestamp)"),
293+
),
294+
mcp.WithNumber("page",
295+
mcp.Description("Page number"),
296+
),
297+
mcp.WithNumber("per_page",
298+
mcp.Description("Results per page"),
299+
),
300+
),
301+
func(ctx context.Context, request mcp.CallToolRequest) (*mcp.CallToolResult, error) {
302+
owner := request.Params.Arguments["owner"].(string)
303+
repo := request.Params.Arguments["repo"].(string)
304+
305+
opts := &github.IssueListByRepoOptions{}
306+
307+
// Set optional parameters if provided
308+
if state, ok := request.Params.Arguments["state"].(string); ok && state != "" {
309+
opts.State = state
310+
}
311+
312+
if labels, ok := request.Params.Arguments["labels"].(string); ok && labels != "" {
313+
opts.Labels = parseCommaSeparatedList(labels)
314+
}
315+
316+
if sort, ok := request.Params.Arguments["sort"].(string); ok && sort != "" {
317+
opts.Sort = sort
318+
}
319+
320+
if direction, ok := request.Params.Arguments["direction"].(string); ok && direction != "" {
321+
opts.Direction = direction
322+
}
323+
324+
if since, ok := request.Params.Arguments["since"].(string); ok && since != "" {
325+
timestamp, err := parseISOTimestamp(since)
326+
if err != nil {
327+
return mcp.NewToolResultError(fmt.Sprintf("failed to list issues: %s", err.Error())), nil
328+
}
329+
opts.Since = timestamp
330+
}
331+
332+
if page, ok := request.Params.Arguments["page"].(float64); ok {
333+
opts.Page = int(page)
334+
}
335+
336+
if perPage, ok := request.Params.Arguments["per_page"].(float64); ok {
337+
opts.PerPage = int(perPage)
338+
}
339+
340+
issues, resp, err := client.Issues.ListByRepo(ctx, owner, repo, opts)
341+
if err != nil {
342+
return nil, fmt.Errorf("failed to list issues: %w", err)
343+
}
344+
defer func() { _ = resp.Body.Close() }()
345+
346+
if resp.StatusCode != http.StatusOK {
347+
body, err := io.ReadAll(resp.Body)
348+
if err != nil {
349+
return nil, fmt.Errorf("failed to read response body: %w", err)
350+
}
351+
return mcp.NewToolResultError(fmt.Sprintf("failed to list issues: %s", string(body))), nil
352+
}
353+
354+
r, err := json.Marshal(issues)
355+
if err != nil {
356+
return nil, fmt.Errorf("failed to marshal issues: %w", err)
357+
}
358+
359+
return mcp.NewToolResultText(string(r)), nil
360+
}
361+
}
362+
363+
// parseISOTimestamp parses an ISO 8601 timestamp string into a time.Time object.
364+
// Returns the parsed time or an error if parsing fails.
365+
// Example formats supported: "2023-01-15T14:30:00Z", "2023-01-15"
366+
func parseISOTimestamp(timestamp string) (time.Time, error) {
367+
if timestamp == "" {
368+
return time.Time{}, fmt.Errorf("empty timestamp")
369+
}
370+
371+
// Try RFC3339 format (standard ISO 8601 with time)
372+
t, err := time.Parse(time.RFC3339, timestamp)
373+
if err == nil {
374+
return t, nil
375+
}
376+
377+
// Try simple date format (YYYY-MM-DD)
378+
t, err = time.Parse("2006-01-02", timestamp)
379+
if err == nil {
380+
return t, nil
381+
}
382+
383+
// Return error with supported formats
384+
return time.Time{}, fmt.Errorf("invalid ISO 8601 timestamp: %s (supported formats: YYYY-MM-DDThh:mm:ssZ or YYYY-MM-DD)", timestamp)
385+
}

pkg/github/issues_test.go

+217
Original file line numberDiff line numberDiff line change
@@ -5,6 +5,7 @@ import (
55
"encoding/json"
66
"net/http"
77
"testing"
8+
"time"
89

910
"github.com/google/go-github/v69/github"
1011
"github.com/mark3labs/mcp-go/mcp"
@@ -524,3 +525,219 @@ func Test_CreateIssue(t *testing.T) {
524525
})
525526
}
526527
}
528+
529+
func Test_ListIssues(t *testing.T) {
530+
// Verify tool definition
531+
mockClient := github.NewClient(nil)
532+
tool, _ := listIssues(mockClient)
533+
534+
assert.Equal(t, "list_issues", tool.Name)
535+
assert.NotEmpty(t, tool.Description)
536+
assert.Contains(t, tool.InputSchema.Properties, "owner")
537+
assert.Contains(t, tool.InputSchema.Properties, "repo")
538+
assert.Contains(t, tool.InputSchema.Properties, "state")
539+
assert.Contains(t, tool.InputSchema.Properties, "labels")
540+
assert.Contains(t, tool.InputSchema.Properties, "sort")
541+
assert.Contains(t, tool.InputSchema.Properties, "direction")
542+
assert.Contains(t, tool.InputSchema.Properties, "since")
543+
assert.Contains(t, tool.InputSchema.Properties, "page")
544+
assert.Contains(t, tool.InputSchema.Properties, "per_page")
545+
assert.ElementsMatch(t, tool.InputSchema.Required, []string{"owner", "repo"})
546+
547+
// Setup mock issues for success case
548+
mockIssues := []*github.Issue{
549+
{
550+
Number: github.Ptr(123),
551+
Title: github.Ptr("First Issue"),
552+
Body: github.Ptr("This is the first test issue"),
553+
State: github.Ptr("open"),
554+
HTMLURL: github.Ptr("https://github.com/owner/repo/issues/123"),
555+
CreatedAt: &github.Timestamp{Time: time.Date(2023, 1, 1, 0, 0, 0, 0, time.UTC)},
556+
},
557+
{
558+
Number: github.Ptr(456),
559+
Title: github.Ptr("Second Issue"),
560+
Body: github.Ptr("This is the second test issue"),
561+
State: github.Ptr("open"),
562+
HTMLURL: github.Ptr("https://github.com/owner/repo/issues/456"),
563+
Labels: []*github.Label{{Name: github.Ptr("bug")}},
564+
CreatedAt: &github.Timestamp{Time: time.Date(2023, 2, 1, 0, 0, 0, 0, time.UTC)},
565+
},
566+
}
567+
568+
tests := []struct {
569+
name string
570+
mockedClient *http.Client
571+
requestArgs map[string]interface{}
572+
expectError bool
573+
expectedIssues []*github.Issue
574+
expectedErrMsg string
575+
}{
576+
{
577+
name: "list issues with minimal parameters",
578+
mockedClient: mock.NewMockedHTTPClient(
579+
mock.WithRequestMatch(
580+
mock.GetReposIssuesByOwnerByRepo,
581+
mockIssues,
582+
),
583+
),
584+
requestArgs: map[string]interface{}{
585+
"owner": "owner",
586+
"repo": "repo",
587+
},
588+
expectError: false,
589+
expectedIssues: mockIssues,
590+
},
591+
{
592+
name: "list issues with all parameters",
593+
mockedClient: mock.NewMockedHTTPClient(
594+
mock.WithRequestMatch(
595+
mock.GetReposIssuesByOwnerByRepo,
596+
mockIssues,
597+
),
598+
),
599+
requestArgs: map[string]interface{}{
600+
"owner": "owner",
601+
"repo": "repo",
602+
"state": "open",
603+
"labels": "bug,enhancement",
604+
"sort": "created",
605+
"direction": "desc",
606+
"since": "2023-01-01T00:00:00Z",
607+
"page": float64(1),
608+
"per_page": float64(30),
609+
},
610+
expectError: false,
611+
expectedIssues: mockIssues,
612+
},
613+
{
614+
name: "invalid since parameter",
615+
mockedClient: mock.NewMockedHTTPClient(
616+
mock.WithRequestMatch(
617+
mock.GetReposIssuesByOwnerByRepo,
618+
mockIssues,
619+
),
620+
),
621+
requestArgs: map[string]interface{}{
622+
"owner": "owner",
623+
"repo": "repo",
624+
"since": "invalid-date",
625+
},
626+
expectError: true,
627+
expectedErrMsg: "invalid ISO 8601 timestamp",
628+
},
629+
{
630+
name: "list issues fails with error",
631+
mockedClient: mock.NewMockedHTTPClient(
632+
mock.WithRequestMatchHandler(
633+
mock.GetReposIssuesByOwnerByRepo,
634+
http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
635+
w.WriteHeader(http.StatusNotFound)
636+
_, _ = w.Write([]byte(`{"message": "Repository not found"}`))
637+
}),
638+
),
639+
),
640+
requestArgs: map[string]interface{}{
641+
"owner": "nonexistent",
642+
"repo": "repo",
643+
},
644+
expectError: true,
645+
expectedErrMsg: "failed to list issues",
646+
},
647+
}
648+
649+
for _, tc := range tests {
650+
t.Run(tc.name, func(t *testing.T) {
651+
// Setup client with mock
652+
client := github.NewClient(tc.mockedClient)
653+
_, handler := listIssues(client)
654+
655+
// Create call request
656+
request := createMCPRequest(tc.requestArgs)
657+
658+
// Call handler
659+
result, err := handler(context.Background(), request)
660+
661+
// Verify results
662+
if tc.expectError {
663+
if err != nil {
664+
assert.Contains(t, err.Error(), tc.expectedErrMsg)
665+
} else {
666+
// For errors returned as part of the result, not as an error
667+
assert.NotNil(t, result)
668+
textContent := getTextResult(t, result)
669+
assert.Contains(t, textContent.Text, tc.expectedErrMsg)
670+
}
671+
return
672+
}
673+
674+
require.NoError(t, err)
675+
676+
// Parse the result and get the text content if no error
677+
textContent := getTextResult(t, result)
678+
679+
// Unmarshal and verify the result
680+
var returnedIssues []*github.Issue
681+
err = json.Unmarshal([]byte(textContent.Text), &returnedIssues)
682+
require.NoError(t, err)
683+
684+
assert.Len(t, returnedIssues, len(tc.expectedIssues))
685+
for i, issue := range returnedIssues {
686+
assert.Equal(t, *tc.expectedIssues[i].Number, *issue.Number)
687+
assert.Equal(t, *tc.expectedIssues[i].Title, *issue.Title)
688+
assert.Equal(t, *tc.expectedIssues[i].State, *issue.State)
689+
assert.Equal(t, *tc.expectedIssues[i].HTMLURL, *issue.HTMLURL)
690+
}
691+
})
692+
}
693+
}
694+
695+
func Test_ParseISOTimestamp(t *testing.T) {
696+
tests := []struct {
697+
name string
698+
input string
699+
expectedErr bool
700+
expectedTime time.Time
701+
}{
702+
{
703+
name: "valid RFC3339 format",
704+
input: "2023-01-15T14:30:00Z",
705+
expectedErr: false,
706+
expectedTime: time.Date(2023, 1, 15, 14, 30, 0, 0, time.UTC),
707+
},
708+
{
709+
name: "valid date only format",
710+
input: "2023-01-15",
711+
expectedErr: false,
712+
expectedTime: time.Date(2023, 1, 15, 0, 0, 0, 0, time.UTC),
713+
},
714+
{
715+
name: "empty timestamp",
716+
input: "",
717+
expectedErr: true,
718+
},
719+
{
720+
name: "invalid format",
721+
input: "15/01/2023",
722+
expectedErr: true,
723+
},
724+
{
725+
name: "invalid date",
726+
input: "2023-13-45",
727+
expectedErr: true,
728+
},
729+
}
730+
731+
for _, tc := range tests {
732+
t.Run(tc.name, func(t *testing.T) {
733+
parsedTime, err := parseISOTimestamp(tc.input)
734+
735+
if tc.expectedErr {
736+
assert.Error(t, err)
737+
} else {
738+
assert.NoError(t, err)
739+
assert.Equal(t, tc.expectedTime, parsedTime)
740+
}
741+
})
742+
}
743+
}

pkg/github/server.go

+1
Original file line numberDiff line numberDiff line change
@@ -37,6 +37,7 @@ func NewServer(client *github.Client) *server.MCPServer {
3737
s.AddTool(addIssueComment(client))
3838
s.AddTool(createIssue(client))
3939
s.AddTool(searchIssues(client))
40+
s.AddTool(listIssues(client))
4041

4142
// Add GitHub tools - Pull Requests
4243
s.AddTool(getPullRequest(client))

0 commit comments

Comments
 (0)