forgejo-mcp/operation/branchprotection/resources_branchprotection.go
Christoph Görn 0826172c23
feat: ✨ branch protection management — CRUD tools + forgejo:// resources (uc6)
Implements the branch-protection OpenSpec change (#195). New
operation/branchprotection/ package:

- Tools: list/get/create/edit/delete_branch_protection over
  /repos/{owner}/{repo}/branch_protections.
- list_branch_protections is output-bounded (page/limit; echoes page/limit
  for resumability).
- create/edit round-trip status_check_contexts (comma-separated). edit uses
  PATCH pointer semantics (pkg/ptr.To): only caller-passed fields change;
  unpassed fields serialize as null (leave-unchanged), never false.
- Resources: bounded collection forgejo://repo/{o}/{r}/branch_protections
  (EmbeddedListCap sentinel -> list_branch_protections) and single
  forgejo://repo/{o}/{r}/branch_protection/{rule} (rule may contain slashes).
- ParseBranchProtection(s) added to operation/resource; wired into
  operation.go + cmd/cli.go domain grouping; README tool table updated.
- httptest coverage: list, get (ok/404), create (contexts round-trip;
  missing branch_name -> no call), edit (only-passed-fields null-safety;
  contexts), delete; parser + truncation + error-mapping tests.

Refines the edit scenario in the change spec to match SDK marshaling
(null = leave-unchanged). tasks.md complete.

Closes forgejo-mcp-uc6.
2026-06-02 16:22:24 +02:00

145 lines
4.8 KiB
Go

package branchprotection
import (
"context"
"encoding/json"
"fmt"
"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"
)
const (
branchProtectionsResourceURITemplate = "forgejo://repo/{owner}/{repo}/branch_protections"
branchProtectionResourceURITemplate = "forgejo://repo/{owner}/{repo}/branch_protection/{rule}"
)
// bpCollectionPayload is the JSON body for the branch_protections collection resource.
type bpCollectionPayload struct {
Owner string `json:"owner"`
Repo string `json:"repo"`
TotalCount int `json:"total_count"`
BranchProtections []*forgejo_sdk.BranchProtection `json:"branch_protections"`
Truncated bool `json:"truncated,omitempty"`
ListTool string `json:"list_tool,omitempty"`
}
// RegisterResource registers the branch protection collection and single-rule
// resource templates on the MCP server.
func RegisterResource(s *server.MCPServer) {
resource.RegisterTemplate(
s,
branchProtectionsResourceURITemplate,
"Forgejo Branch Protections",
branchProtectionsResourceHandler,
mcp.WithTemplateDescription(
"Branch protection rules for a Forgejo repository. "+
"URI: forgejo://repo/{owner}/{repo}/branch_protections. "+
"Returns the rules as a read-only JSON document, bounded to the first "+
"EmbeddedListCap rules; when truncated, use the list_branch_protections "+
"tool to page through the remainder.",
),
mcp.WithTemplateMIMEType("application/json"),
)
resource.RegisterTemplate(
s,
branchProtectionResourceURITemplate,
"Forgejo Branch Protection",
branchProtectionResourceHandler,
mcp.WithTemplateDescription(
"A single branch protection rule for a Forgejo repository. "+
"URI: forgejo://repo/{owner}/{repo}/branch_protection/{rule} — rule is the rule name. "+
"Returns the rule's full protection state as a read-only JSON document.",
),
mcp.WithTemplateMIMEType("application/json"),
)
log.Debug("Registered branch protection resource templates")
}
func branchProtectionsResourceHandler(ctx context.Context, req mcp.ReadResourceRequest) ([]mcp.ResourceContents, error) {
uri := req.Params.URI
p, err := resource.ParseBranchProtections(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)
}
// Request EmbeddedListCap+1 so Bounded can distinguish "at cap" from "over cap".
bps, resp, err := client.ListBranchProtections(p.Owner, p.Repo, forgejo_sdk.ListBranchProtectionsOptions{
ListOptions: forgejo_sdk.ListOptions{PageSize: resource.EmbeddedListCap + 1},
})
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)
}
names := make([]string, len(bps))
for i, bp := range bps {
names[i] = bp.RuleName
}
bounded := resource.Bounded(names, resource.EmbeddedListCap, "list_branch_protections")
shown := bps
if bounded.Truncated {
shown = bps[:resource.EmbeddedListCap]
}
payload := bpCollectionPayload{
Owner: p.Owner,
Repo: p.Repo,
TotalCount: len(bps),
BranchProtections: shown,
Truncated: bounded.Truncated,
}
if bounded.Truncated {
payload.ListTool = "list_branch_protections"
}
jsonBytes, err := json.Marshal(payload)
if err != nil {
return nil, fmt.Errorf("marshal branch protections payload: %w", err)
}
return []mcp.ResourceContents{
mcp.TextResourceContents{URI: uri, MIMEType: "application/json", Text: string(jsonBytes)},
}, nil
}
func branchProtectionResourceHandler(ctx context.Context, req mcp.ReadResourceRequest) ([]mcp.ResourceContents, error) {
uri := req.Params.URI
p, err := resource.ParseBranchProtection(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)
}
bp, resp, err := client.GetBranchProtection(p.Owner, p.Repo, p.Rule)
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)
}
jsonBytes, err := json.Marshal(bp)
if err != nil {
return nil, fmt.Errorf("marshal branch protection payload: %w", err)
}
return []mcp.ResourceContents{
mcp.TextResourceContents{URI: uri, MIMEType: "application/json", Text: string(jsonBytes)},
}, nil
}