feat(issue): add milestones and type filters to list_issues (#199)
## Summary
`list_issues` could not be scoped to a milestone, and the milestone was
absent from its list output. To find which milestone an issue belongs to,
callers had to read every issue individually — expensive on repositories
with hundreds of issues.
## Changes
- `list_issues`: add `milestones` (name or ID filter) and `type`
(`issues`/`pulls`) parameters, wiring the SDK's
`ListIssueOption.Milestones` and `ListIssueOption.Type`.
- Include the `milestone` `{id, title}` field in `slimIssues` (the list
output), consistent with the existing `slimIssue` (single-issue) output.
- Tests: extend `Test_listRepoIssuesFn_filters` to assert the `milestones`
and `type` query params, and add `Test_listRepoIssuesFn_includesMilestone`
for the milestone field in list output.
This is a natural continuation of #164 (which added `labels`/`since`/`before`
to `list_issues`); `milestones` and `type` are supported by the SDK but were
not yet wired through.
## Testing
- `go build ./...` — ok
- `go vet ./operation/issue/` — ok
- `go test ./...` — all pass
- `make fmt` — clean
---
This PR was written with the help of Claude Opus 4.8
Reviewed-on: https://gitea.com/gitea/gitea-mcp/pulls/199
Reviewed-by: Lunny Xiao <xiaolunwen@gmail.com>
Co-authored-by: jermey <186737+jermey@noreply.gitea.com>
Co-committed-by: jermey <186737+jermey@noreply.gitea.com>
This commit is contained in:
@@ -44,7 +44,9 @@ var (
|
||||
mcp.WithString("owner", mcp.Required(), mcp.Description(params.OwnerDesc)),
|
||||
mcp.WithString("repo", mcp.Required(), mcp.Description(params.RepoDesc)),
|
||||
mcp.WithString("state", mcp.DefaultString("all")),
|
||||
mcp.WithString("type", mcp.Description("issues or pulls"), mcp.Enum("issues", "pulls")),
|
||||
mcp.WithArray("labels", mcp.Description("label name filter"), mcp.Items(map[string]any{"type": "string"})),
|
||||
mcp.WithArray("milestones", mcp.Description("milestone name or ID filter"), mcp.Items(map[string]any{"type": "string"})),
|
||||
mcp.WithString("since", mcp.Description("updated after ISO 8601")),
|
||||
mcp.WithString("before", mcp.Description("updated before ISO 8601")),
|
||||
mcp.WithNumber("page", mcp.Description(params.PageDesc), mcp.DefaultNumber(1)),
|
||||
@@ -181,15 +183,23 @@ func listRepoIssuesFn(ctx context.Context, req mcp.CallToolRequest) (*mcp.CallTo
|
||||
state = "all"
|
||||
}
|
||||
labels := params.GetStringSlice(req.GetArguments(), "labels")
|
||||
milestones := params.GetStringSlice(req.GetArguments(), "milestones")
|
||||
page, pageSize := params.GetPagination(req.GetArguments(), 30)
|
||||
opt := gitea_sdk.ListIssueOption{
|
||||
State: gitea_sdk.StateType(state),
|
||||
Labels: labels,
|
||||
Milestones: milestones,
|
||||
ListOptions: gitea_sdk.ListOptions{
|
||||
Page: page,
|
||||
PageSize: pageSize,
|
||||
},
|
||||
}
|
||||
switch req.GetArguments()["type"] {
|
||||
case "issues":
|
||||
opt.Type = gitea_sdk.IssueTypeIssue
|
||||
case "pulls":
|
||||
opt.Type = gitea_sdk.IssueTypePull
|
||||
}
|
||||
if t := params.GetOptionalTime(req.GetArguments(), "since"); t != nil {
|
||||
opt.Since = *t
|
||||
}
|
||||
|
||||
@@ -65,7 +65,9 @@ func Test_listRepoIssuesFn_filters(t *testing.T) {
|
||||
Arguments: map[string]any{
|
||||
"owner": owner,
|
||||
"repo": repo,
|
||||
"type": "issues",
|
||||
"labels": []any{"bug", "enhancement"},
|
||||
"milestones": []any{"v1.0", "2"},
|
||||
"since": "2026-01-01T00:00:00Z",
|
||||
},
|
||||
},
|
||||
@@ -85,6 +87,59 @@ func Test_listRepoIssuesFn_filters(t *testing.T) {
|
||||
if !strings.Contains(gotQuery, "since=2026-01-01") {
|
||||
t.Fatalf("expected since query param, got %s", gotQuery)
|
||||
}
|
||||
if !strings.Contains(gotQuery, "milestones=v1.0%2C2") {
|
||||
t.Fatalf("expected milestones query param, got %s", gotQuery)
|
||||
}
|
||||
if !strings.Contains(gotQuery, "type=issues") {
|
||||
t.Fatalf("expected type query param, got %s", gotQuery)
|
||||
}
|
||||
}
|
||||
|
||||
func Test_listRepoIssuesFn_includesMilestone(t *testing.T) {
|
||||
const (
|
||||
owner = "octo"
|
||||
repo = "demo"
|
||||
)
|
||||
|
||||
handler := http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
switch r.URL.Path {
|
||||
case "/api/v1/version":
|
||||
w.Header().Set("Content-Type", "application/json")
|
||||
_, _ = w.Write([]byte(`{"version":"1.12.0"}`))
|
||||
case fmt.Sprintf("/api/v1/repos/%s/%s", owner, repo):
|
||||
w.Header().Set("Content-Type", "application/json")
|
||||
_, _ = w.Write([]byte(`{"private":false}`))
|
||||
case fmt.Sprintf("/api/v1/repos/%s/%s/issues", owner, repo):
|
||||
w.Header().Set("Content-Type", "application/json")
|
||||
_, _ = w.Write([]byte(`[
|
||||
{"number": 1, "title": "with milestone", "state": "closed", "milestone": {"id": 5, "title": "v1.0"}},
|
||||
{"number": 2, "title": "without milestone", "state": "open"}
|
||||
]`))
|
||||
default:
|
||||
http.NotFound(w, r)
|
||||
}
|
||||
})
|
||||
server := httptest.NewServer(handler)
|
||||
defer server.Close()
|
||||
|
||||
origHost, origToken, origVersion := flag.Host, flag.Token, flag.Version
|
||||
flag.Host, flag.Token, flag.Version = server.URL, "", "test"
|
||||
defer func() { flag.Host, flag.Token, flag.Version = origHost, origToken, origVersion }()
|
||||
|
||||
req := mcp.CallToolRequest{Params: mcp.CallToolParams{Arguments: map[string]any{
|
||||
"owner": owner, "repo": repo,
|
||||
}}}
|
||||
res, err := listRepoIssuesFn(context.Background(), req)
|
||||
if err != nil {
|
||||
t.Fatalf("listRepoIssuesFn() error = %v", err)
|
||||
}
|
||||
if res.IsError {
|
||||
t.Fatalf("unexpected error result: %v", res.Content)
|
||||
}
|
||||
body := res.Content[0].(mcp.TextContent).Text
|
||||
if !strings.Contains(body, `"milestone"`) || !strings.Contains(body, `"v1.0"`) {
|
||||
t.Fatalf("expected milestone in list output, got: %s", body)
|
||||
}
|
||||
}
|
||||
|
||||
func Test_createIssueFn_labels(t *testing.T) {
|
||||
|
||||
@@ -63,6 +63,12 @@ func slimIssues(issues []*gitea_sdk.Issue) []map[string]any {
|
||||
if len(i.Labels) > 0 {
|
||||
m["labels"] = slim.LabelNames(i.Labels)
|
||||
}
|
||||
if i.Milestone != nil {
|
||||
m["milestone"] = map[string]any{
|
||||
"id": i.Milestone.ID,
|
||||
"title": i.Milestone.Title,
|
||||
}
|
||||
}
|
||||
if i.Ref != "" {
|
||||
m["ref"] = i.Ref
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user