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("owner", mcp.Required(), mcp.Description(params.OwnerDesc)),
|
||||||
mcp.WithString("repo", mcp.Required(), mcp.Description(params.RepoDesc)),
|
mcp.WithString("repo", mcp.Required(), mcp.Description(params.RepoDesc)),
|
||||||
mcp.WithString("state", mcp.DefaultString("all")),
|
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("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("since", mcp.Description("updated after ISO 8601")),
|
||||||
mcp.WithString("before", mcp.Description("updated before ISO 8601")),
|
mcp.WithString("before", mcp.Description("updated before ISO 8601")),
|
||||||
mcp.WithNumber("page", mcp.Description(params.PageDesc), mcp.DefaultNumber(1)),
|
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"
|
state = "all"
|
||||||
}
|
}
|
||||||
labels := params.GetStringSlice(req.GetArguments(), "labels")
|
labels := params.GetStringSlice(req.GetArguments(), "labels")
|
||||||
|
milestones := params.GetStringSlice(req.GetArguments(), "milestones")
|
||||||
page, pageSize := params.GetPagination(req.GetArguments(), 30)
|
page, pageSize := params.GetPagination(req.GetArguments(), 30)
|
||||||
opt := gitea_sdk.ListIssueOption{
|
opt := gitea_sdk.ListIssueOption{
|
||||||
State: gitea_sdk.StateType(state),
|
State: gitea_sdk.StateType(state),
|
||||||
Labels: labels,
|
Labels: labels,
|
||||||
|
Milestones: milestones,
|
||||||
ListOptions: gitea_sdk.ListOptions{
|
ListOptions: gitea_sdk.ListOptions{
|
||||||
Page: page,
|
Page: page,
|
||||||
PageSize: pageSize,
|
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 {
|
if t := params.GetOptionalTime(req.GetArguments(), "since"); t != nil {
|
||||||
opt.Since = *t
|
opt.Since = *t
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -65,7 +65,9 @@ func Test_listRepoIssuesFn_filters(t *testing.T) {
|
|||||||
Arguments: map[string]any{
|
Arguments: map[string]any{
|
||||||
"owner": owner,
|
"owner": owner,
|
||||||
"repo": repo,
|
"repo": repo,
|
||||||
|
"type": "issues",
|
||||||
"labels": []any{"bug", "enhancement"},
|
"labels": []any{"bug", "enhancement"},
|
||||||
|
"milestones": []any{"v1.0", "2"},
|
||||||
"since": "2026-01-01T00:00:00Z",
|
"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") {
|
if !strings.Contains(gotQuery, "since=2026-01-01") {
|
||||||
t.Fatalf("expected since query param, got %s", gotQuery)
|
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) {
|
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 {
|
if len(i.Labels) > 0 {
|
||||||
m["labels"] = slim.LabelNames(i.Labels)
|
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 != "" {
|
if i.Ref != "" {
|
||||||
m["ref"] = i.Ref
|
m["ref"] = i.Ref
|
||||||
}
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user