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.
155 lines
5.1 KiB
Go
155 lines
5.1 KiB
Go
package branchprotection
|
|
|
|
import (
|
|
"context"
|
|
"encoding/json"
|
|
"fmt"
|
|
"net/http"
|
|
"net/http/httptest"
|
|
"testing"
|
|
|
|
"codeberg.org/goern/forgejo-mcp/v2/operation/resource"
|
|
"codeberg.org/goern/forgejo-mcp/v2/pkg/forgejo"
|
|
forgejo_sdk "codeberg.org/mvdkleijn/forgejo-sdk/forgejo/v3"
|
|
"github.com/mark3labs/mcp-go/mcp"
|
|
)
|
|
|
|
func setupBPResourceServer(t *testing.T, status int, body interface{}) *httptest.Server {
|
|
t.Helper()
|
|
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
|
w.Header().Set("Content-Type", "application/json")
|
|
w.WriteHeader(status)
|
|
if body != nil {
|
|
_ = json.NewEncoder(w).Encode(body)
|
|
}
|
|
}))
|
|
client, err := forgejo_sdk.NewClient(srv.URL, forgejo_sdk.SetForgejoVersion("7.0.0"))
|
|
if err != nil {
|
|
t.Fatalf("creating test client: %v", err)
|
|
}
|
|
forgejo.SetClientForTesting(client)
|
|
return srv
|
|
}
|
|
|
|
func readResourceReq(uri string) mcp.ReadResourceRequest {
|
|
return mcp.ReadResourceRequest{Params: mcp.ReadResourceParams{URI: uri}}
|
|
}
|
|
|
|
func resourceText(t *testing.T, contents []mcp.ResourceContents) string {
|
|
t.Helper()
|
|
if len(contents) != 1 {
|
|
t.Fatalf("expected 1 content block, got %d", len(contents))
|
|
}
|
|
tc, ok := contents[0].(mcp.TextResourceContents)
|
|
if !ok {
|
|
t.Fatalf("expected TextResourceContents, got %T", contents[0])
|
|
}
|
|
return tc.Text
|
|
}
|
|
|
|
func TestBranchProtectionsResource_HappyPath(t *testing.T) {
|
|
srv := setupBPResourceServer(t, http.StatusOK, []map[string]interface{}{
|
|
{"rule_name": "main", "branch_name": "main", "required_approvals": 1},
|
|
{"rule_name": "release/*", "branch_name": "release/*"},
|
|
})
|
|
defer srv.Close()
|
|
|
|
contents, err := branchProtectionsResourceHandler(context.Background(),
|
|
readResourceReq("forgejo://repo/goern/forgejo-mcp/branch_protections"))
|
|
if err != nil {
|
|
t.Fatalf("unexpected error: %v", err)
|
|
}
|
|
var payload bpCollectionPayload
|
|
if err := json.Unmarshal([]byte(resourceText(t, contents)), &payload); err != nil {
|
|
t.Fatalf("unmarshal payload: %v", err)
|
|
}
|
|
if payload.TotalCount != 2 || len(payload.BranchProtections) != 2 {
|
|
t.Errorf("expected 2 rules, got total=%d shown=%d", payload.TotalCount, len(payload.BranchProtections))
|
|
}
|
|
if payload.Truncated {
|
|
t.Error("did not expect truncation for 2 rules")
|
|
}
|
|
}
|
|
|
|
func TestBranchProtectionsResource_Truncation(t *testing.T) {
|
|
rules := make([]map[string]interface{}, resource.EmbeddedListCap+1)
|
|
for i := range rules {
|
|
rules[i] = map[string]interface{}{"rule_name": fmt.Sprintf("rule-%d", i), "branch_name": fmt.Sprintf("b-%d", i)}
|
|
}
|
|
srv := setupBPResourceServer(t, http.StatusOK, rules)
|
|
defer srv.Close()
|
|
|
|
contents, err := branchProtectionsResourceHandler(context.Background(),
|
|
readResourceReq("forgejo://repo/goern/forgejo-mcp/branch_protections"))
|
|
if err != nil {
|
|
t.Fatalf("unexpected error: %v", err)
|
|
}
|
|
var payload bpCollectionPayload
|
|
if err := json.Unmarshal([]byte(resourceText(t, contents)), &payload); err != nil {
|
|
t.Fatalf("unmarshal payload: %v", err)
|
|
}
|
|
if !payload.Truncated {
|
|
t.Error("expected truncation when over EmbeddedListCap")
|
|
}
|
|
if len(payload.BranchProtections) != resource.EmbeddedListCap {
|
|
t.Errorf("expected %d shown rules, got %d", resource.EmbeddedListCap, len(payload.BranchProtections))
|
|
}
|
|
if payload.ListTool != "list_branch_protections" {
|
|
t.Errorf("expected list_tool sentinel, got %q", payload.ListTool)
|
|
}
|
|
}
|
|
|
|
func TestBranchProtectionsResource_NotFound(t *testing.T) {
|
|
srv := setupBPResourceServer(t, http.StatusNotFound, map[string]string{"message": "Not Found"})
|
|
defer srv.Close()
|
|
|
|
_, err := branchProtectionsResourceHandler(context.Background(),
|
|
readResourceReq("forgejo://repo/goern/missing/branch_protections"))
|
|
if err == nil {
|
|
t.Fatal("expected error for 404")
|
|
}
|
|
re, ok := err.(*resource.ResourceError)
|
|
if !ok {
|
|
t.Fatalf("expected *resource.ResourceError, got %T", err)
|
|
}
|
|
if re.Code != -32003 {
|
|
t.Errorf("expected code -32003 (not found), got %d", re.Code)
|
|
}
|
|
}
|
|
|
|
func TestBranchProtectionResource_HappyPath(t *testing.T) {
|
|
srv := setupBPResourceServer(t, http.StatusOK, map[string]interface{}{
|
|
"rule_name": "main", "branch_name": "main",
|
|
"status_check_contexts": []string{"ci/build"}, "required_approvals": 2,
|
|
})
|
|
defer srv.Close()
|
|
|
|
contents, err := branchProtectionResourceHandler(context.Background(),
|
|
readResourceReq("forgejo://repo/goern/forgejo-mcp/branch_protection/main"))
|
|
if err != nil {
|
|
t.Fatalf("unexpected error: %v", err)
|
|
}
|
|
var bp forgejo_sdk.BranchProtection
|
|
if err := json.Unmarshal([]byte(resourceText(t, contents)), &bp); err != nil {
|
|
t.Fatalf("unmarshal payload: %v", err)
|
|
}
|
|
if bp.RuleName != "main" || bp.RequiredApprovals != 2 || len(bp.StatusCheckContexts) != 1 {
|
|
t.Errorf("unexpected protection state: %+v", bp)
|
|
}
|
|
}
|
|
|
|
func TestBranchProtectionResource_MalformedURI(t *testing.T) {
|
|
// No server needed: parse fails before any client call.
|
|
_, err := branchProtectionResourceHandler(context.Background(),
|
|
readResourceReq("forgejo://repo/goern/forgejo-mcp/branch_protection"))
|
|
if err == nil {
|
|
t.Fatal("expected error for malformed URI (missing rule)")
|
|
}
|
|
re, ok := err.(*resource.ResourceError)
|
|
if !ok {
|
|
t.Fatalf("expected *resource.ResourceError, got %T", err)
|
|
}
|
|
if re.Code != -32602 {
|
|
t.Errorf("expected code -32602 (invalid params), got %d", re.Code)
|
|
}
|
|
}
|