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.
145 lines
4.8 KiB
Go
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
|
|
}
|