forgejo-mcp/operation/issue/resources_label.go
Christoph Görn f3be12a1ba
fix: 🐛 bug-hunt sweep — 9 issues across tool handlers and resources
- page/limit defaulting tested stale 'ok' from earlier type assertion
  (list_repo_pull_requests, list_repo_commits, check_notifications,
  list_repo_notifications) — now zero-default like all other handlers
- list_repo_issues: wire 'type' filter to SDK ListIssueOption.Type
- list_repo_pull_requests: wire 'milestone' to opt.Milestone; drop
  unimplementable 'labels' param from schema (SDK has no labels filter)
- search_users / search_org_teams: honor page/limit via ListOptions
- delete_file: forward new_branch_name to FileOptions
- label resources: parse documented {?page,limit} query (cap 30) + tests
- cmd: -d/-debug default true→false; FORGEJO_DEBUG fallback reachable
- resources: rune-safe 200-byte excerpts via resource.Excerpt + tests
- docs: DoJSON isList comment, validateSHA lowercase claim

Closes beads: fcm 9ii cdf 3nu tc6 7f8 r7h cv4 yea
2026-06-10 23:54:24 +02:00

284 lines
7.8 KiB
Go

// SPDX-License-Identifier: GPL-3.0-or-later
package issue
import (
"context"
"encoding/json"
"fmt"
"net/url"
"strconv"
"codeberg.org/goern/forgejo-mcp/v2/operation/resource"
"codeberg.org/goern/forgejo-mcp/v2/pkg/forgejo"
"codeberg.org/goern/forgejo-mcp/v2/pkg/log"
forgejo_sdk "codeberg.org/mvdkleijn/forgejo-sdk/forgejo/v3"
"github.com/mark3labs/mcp-go/mcp"
"github.com/mark3labs/mcp-go/server"
)
// RegisterLabelResources registers the label resource templates.
func RegisterLabelResources(s *server.MCPServer) {
resource.RegisterTemplate(
s,
"forgejo://repo/{owner}/{repo}/label/{id}",
"Forgejo Repo Label",
repoLabelResourceHandler,
mcp.WithTemplateDescription(
"Single repository label by id. "+
"URI: forgejo://repo/{owner}/{repo}/label/{id}.",
),
mcp.WithTemplateMIMEType("application/json"),
)
resource.RegisterTemplate(
s,
"forgejo://repo/{owner}/{repo}/labels",
"Forgejo Repo Labels",
repoLabelsResourceHandler,
mcp.WithTemplateDescription(
"Bounded list of repository labels (page/limit client-controlled, ceiling "+
strconv.Itoa(resource.EmbeddedListCap)+"). "+
"URI: forgejo://repo/{owner}/{repo}/labels{?page,limit}. "+
"Use list_repo_labels tool for the unbounded enumeration path.",
),
mcp.WithTemplateMIMEType("application/json"),
)
resource.RegisterTemplate(
s,
"forgejo://org/{org}/labels",
"Forgejo Org Labels",
orgLabelsResourceHandler,
mcp.WithTemplateDescription(
"Bounded list of organization-level labels (page/limit client-controlled, ceiling "+
strconv.Itoa(resource.EmbeddedListCap)+"). "+
"URI: forgejo://org/{org}/labels{?page,limit}. "+
"Use list_org_labels tool for the unbounded enumeration path.",
),
mcp.WithTemplateMIMEType("application/json"),
)
log.Debug("Registered label resource templates")
}
// ---- single repo label ----
type labelResourcePayload struct {
ID int64 `json:"id"`
Name string `json:"name"`
Color string `json:"color"`
Description string `json:"description"`
URL string `json:"url"`
}
func repoLabelResourceHandler(ctx context.Context, req mcp.ReadResourceRequest) ([]mcp.ResourceContents, error) {
uri := req.Params.URI
p, err := resource.ParseLabel(uri)
if err != nil {
return nil, resource.MapForgejoError(uri, err)
}
client, err := forgejo.Client(ctx)
if err != nil {
return nil, fmt.Errorf("forgejo client: %w", err)
}
label, resp, err := client.GetRepoLabel(p.Owner, p.Repo, p.ID)
if err != nil {
if resp != nil {
return nil, resource.MapForgejoError(uri, fmt.Errorf("%d %s", resp.StatusCode, err.Error()))
}
return nil, resource.MapForgejoError(uri, err)
}
payload := labelResourcePayload{
ID: label.ID,
Name: label.Name,
Color: label.Color,
Description: label.Description,
URL: label.URL,
}
jsonBytes, err := json.Marshal(payload)
if err != nil {
return nil, fmt.Errorf("marshal label payload: %w", err)
}
return []mcp.ResourceContents{
mcp.TextResourceContents{URI: uri, MIMEType: "application/json", Text: string(jsonBytes)},
}, nil
}
// ---- repo labels list ----
type labelsListPayload struct {
Owner string `json:"owner"`
Repo string `json:"repo"`
Labels []labelResourcePayload `json:"labels"`
Truncated bool `json:"truncated,omitempty"`
ListTool string `json:"list_tool,omitempty"`
Sentinel string `json:"sentinel,omitempty"`
}
func repoLabelsResourceHandler(ctx context.Context, req mcp.ReadResourceRequest) ([]mcp.ResourceContents, error) {
uri := req.Params.URI
p, err := resource.ParseLabels(uri)
if err != nil {
return nil, resource.MapForgejoError(uri, err)
}
page, limit := pageLimit(req)
client, err := forgejo.Client(ctx)
if err != nil {
return nil, fmt.Errorf("forgejo client: %w", err)
}
// Request cap+1 so Bounded's >cap check fires.
fetch := limit + 1
if fetch > resource.EmbeddedListCap+1 {
fetch = resource.EmbeddedListCap + 1
}
rawLabels, resp, err := client.ListRepoLabels(p.Owner, p.Repo, forgejo_sdk.ListLabelsOptions{
ListOptions: forgejo_sdk.ListOptions{Page: page, PageSize: fetch},
})
if err != nil {
if resp != nil {
return nil, resource.MapForgejoError(uri, fmt.Errorf("%d %s", resp.StatusCode, err.Error()))
}
return nil, resource.MapForgejoError(uri, err)
}
items := make([]string, len(rawLabels))
for i, l := range rawLabels {
items[i] = strconv.FormatInt(l.ID, 10)
}
bounded := resource.Bounded(items, limit, ListRepoLabelsToolName)
labels := make([]labelResourcePayload, 0, len(bounded.Items))
for _, l := range rawLabels {
if len(labels) >= len(bounded.Items) {
break
}
labels = append(labels, labelResourcePayload{
ID: l.ID,
Name: l.Name,
Color: l.Color,
Description: l.Description,
URL: l.URL,
})
}
payload := labelsListPayload{
Owner: p.Owner,
Repo: p.Repo,
Labels: labels,
Truncated: bounded.Truncated,
}
if bounded.Truncated {
payload.ListTool = ListRepoLabelsToolName
payload.Sentinel = bounded.Sentinel()
}
jsonBytes, err := json.Marshal(payload)
if err != nil {
return nil, fmt.Errorf("marshal labels payload: %w", err)
}
return []mcp.ResourceContents{
mcp.TextResourceContents{URI: uri, MIMEType: "application/json", Text: string(jsonBytes)},
}, nil
}
// ---- org labels list ----
type orgLabelsListPayload struct {
Org string `json:"org"`
Labels []labelResourcePayload `json:"labels"`
Truncated bool `json:"truncated,omitempty"`
ListTool string `json:"list_tool,omitempty"`
Sentinel string `json:"sentinel,omitempty"`
}
func orgLabelsResourceHandler(ctx context.Context, req mcp.ReadResourceRequest) ([]mcp.ResourceContents, error) {
uri := req.Params.URI
p, err := resource.ParseOrgLabels(uri)
if err != nil {
return nil, resource.MapForgejoError(uri, err)
}
page, limit := pageLimit(req)
// Request cap+1 so Bounded's >cap check fires.
fetch := limit + 1
if fetch > resource.EmbeddedListCap+1 {
fetch = resource.EmbeddedListCap + 1
}
rawLabels, err := fetchOrgLabels(ctx, p.Org, page, fetch)
if err != nil {
return nil, resource.MapForgejoError(uri, err)
}
items := make([]string, len(rawLabels))
for i, l := range rawLabels {
items[i] = strconv.FormatInt(l.ID, 10)
}
bounded := resource.Bounded(items, limit, ListOrgLabelsToolName)
labels := make([]labelResourcePayload, 0, len(bounded.Items))
for _, l := range rawLabels {
if len(labels) >= len(bounded.Items) {
break
}
labels = append(labels, labelResourcePayload{
ID: l.ID,
Name: l.Name,
Color: l.Color,
Description: l.Description,
URL: l.URL,
})
}
payload := orgLabelsListPayload{
Org: p.Org,
Labels: labels,
Truncated: bounded.Truncated,
}
if bounded.Truncated {
payload.ListTool = ListOrgLabelsToolName
payload.Sentinel = bounded.Sentinel()
}
jsonBytes, err := json.Marshal(payload)
if err != nil {
return nil, fmt.Errorf("marshal org labels payload: %w", err)
}
return []mcp.ResourceContents{
mcp.TextResourceContents{URI: uri, MIMEType: "application/json", Text: string(jsonBytes)},
}, nil
}
// pageLimit extracts page and limit from resource URI query params with
// EmbeddedListCap as the ceiling for limit. Malformed or absent values
// fall back to page=1, limit=EmbeddedListCap.
func pageLimit(req mcp.ReadResourceRequest) (page, limit int) {
page = 1
limit = resource.EmbeddedListCap
u, err := url.Parse(req.Params.URI)
if err != nil {
return page, limit
}
q := u.Query()
if v, err := strconv.Atoi(q.Get("page")); err == nil && v >= 1 {
page = v
}
if v, err := strconv.Atoi(q.Get("limit")); err == nil && v >= 1 {
limit = v
if limit > resource.EmbeddedListCap {
limit = resource.EmbeddedListCap
}
}
return page, limit
}