diff --git a/internal/gitea/files.go b/internal/gitea/files.go index 1c2ccbf..d726781 100644 --- a/internal/gitea/files.go +++ b/internal/gitea/files.go @@ -123,6 +123,33 @@ func (c *Client) DeleteBranch(ctx context.Context, owner, repo, branch string) e return MapStatus(status, body) } +type BranchProtection struct { + Protected bool `json:"-"` + RequiredApprovals int64 `json:"required_approvals"` + PushWhitelist []string `json:"push_whitelist_usernames"` + MergeWhitelist []string `json:"merge_whitelist_usernames"` +} + +func (c *Client) GetBranchProtection(ctx context.Context, owner, repo, branch string) (*BranchProtection, error) { + p := fmt.Sprintf("/api/v1/repos/%s/%s/branch_protections/%s", owner, repo, branch) + body, status, err := c.GetJSON(ctx, p) + if err != nil { + return nil, err + } + if status == 404 { + return &BranchProtection{Protected: false}, nil + } + if err := MapStatus(status, body); err != nil { + return nil, err + } + var bp BranchProtection + if err := json.Unmarshal(body, &bp); err != nil { + return nil, err + } + bp.Protected = true + return &bp, nil +} + // UpsertFile creates a file when args.Sha is empty (POST) or updates an existing // file when args.Sha is set (PUT). Gitea routes both operations by HTTP method on // the same /contents/{path} URL, and rejects PUT without a sha. diff --git a/internal/gitea/files_test.go b/internal/gitea/files_test.go index ecffec2..ada8610 100644 --- a/internal/gitea/files_test.go +++ b/internal/gitea/files_test.go @@ -164,3 +164,37 @@ func TestDeleteBranchProtected(t *testing.T) { require.Error(t, err) assert.ErrorIs(t, err, gitea.ErrPermissionDenied) } + +func TestGetBranchProtectionFound(t *testing.T) { + srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + assert.Equal(t, "/api/v1/repos/o/r/branch_protections/main", r.URL.Path) + w.Header().Set("Content-Type", "application/json") + _, _ = w.Write([]byte(`{ + "required_approvals": 2, + "push_whitelist_usernames": ["alice"], + "merge_whitelist_usernames": ["bob"] + }`)) + })) + defer srv.Close() + + c := gitea.NewClient(srv.URL, "tok") + bp, err := c.GetBranchProtection(context.Background(), "o", "r", "main") + require.NoError(t, err) + assert.True(t, bp.Protected) + assert.Equal(t, int64(2), bp.RequiredApprovals) + assert.Equal(t, []string{"alice"}, bp.PushWhitelist) + assert.Equal(t, []string{"bob"}, bp.MergeWhitelist) +} + +func TestGetBranchProtectionNotFoundReturnsUnprotected(t *testing.T) { + srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + w.WriteHeader(http.StatusNotFound) + _, _ = w.Write([]byte(`{"message":"not found"}`)) + })) + defer srv.Close() + + c := gitea.NewClient(srv.URL, "tok") + bp, err := c.GetBranchProtection(context.Background(), "o", "r", "feat/x") + require.NoError(t, err) + assert.False(t, bp.Protected) +} diff --git a/internal/tools/branch_protection_get.go b/internal/tools/branch_protection_get.go new file mode 100644 index 0000000..821357c --- /dev/null +++ b/internal/tools/branch_protection_get.go @@ -0,0 +1,63 @@ +package tools + +import ( + "context" + "encoding/json" + + "gitea.d-ma.be/mathias/gitea-mcp/internal/allowlist" + "gitea.d-ma.be/mathias/gitea-mcp/internal/gitea" + "gitea.d-ma.be/mathias/gitea-mcp/internal/registry" +) + +type BranchProtectionGet struct { + c *gitea.Client + a *allowlist.Allowlist +} + +func NewBranchProtectionGet(c *gitea.Client, a *allowlist.Allowlist) *BranchProtectionGet { + return &BranchProtectionGet{c: c, a: a} +} + +func (t *BranchProtectionGet) Descriptor() registry.ToolDescriptor { + return registry.ToolDescriptor{ + Name: "branch_protection_get", + Description: "Get branch protection rules. Returns {protected:false} if no rule exists — never returns an error for unprotected branches.", + InputSchema: json.RawMessage(`{ + "type":"object", + "properties":{ + "owner":{"type":"string"}, + "name":{"type":"string"}, + "branch":{"type":"string"} + }, + "required":["owner","name","branch"] + }`), + } +} + +type branchProtectionGetArgs struct { + Owner string `json:"owner"` + Name string `json:"name"` + Branch string `json:"branch"` +} + +func (t *BranchProtectionGet) Call(ctx context.Context, raw json.RawMessage) (json.RawMessage, error) { + var args branchProtectionGetArgs + if err := parseArgs(raw, &args); err != nil { + return nil, err + } + if err := t.a.Check(args.Owner); err != nil { + return nil, err + } + + bp, err := t.c.GetBranchProtection(ctx, args.Owner, args.Name, args.Branch) + if err != nil { + return nil, err + } + + return textOK(map[string]any{ + "protected": bp.Protected, + "required_approvals": bp.RequiredApprovals, + "push_whitelist": bp.PushWhitelist, + "merge_whitelist": bp.MergeWhitelist, + }) +} diff --git a/internal/tools/branch_protection_get_test.go b/internal/tools/branch_protection_get_test.go new file mode 100644 index 0000000..a8da888 --- /dev/null +++ b/internal/tools/branch_protection_get_test.go @@ -0,0 +1,54 @@ +package tools_test + +import ( + "context" + "encoding/json" + "net/http" + "net/http/httptest" + "testing" + + "gitea.d-ma.be/mathias/gitea-mcp/internal/allowlist" + "gitea.d-ma.be/mathias/gitea-mcp/internal/gitea" + "gitea.d-ma.be/mathias/gitea-mcp/internal/tools" + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" +) + +func TestBranchProtectionGetProtected(t *testing.T) { + srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + w.Header().Set("Content-Type", "application/json") + _, _ = w.Write([]byte(`{"required_approvals":1,"push_whitelist_usernames":[],"merge_whitelist_usernames":[]}`)) + })) + defer srv.Close() + + tool := tools.NewBranchProtectionGet(gitea.NewClient(srv.URL, "tok"), allowlist.New([]string{"owner"})) + out, err := tool.Call(context.Background(), json.RawMessage(`{"owner":"owner","name":"repo","branch":"main"}`)) + require.NoError(t, err) + + var result map[string]any + require.NoError(t, json.Unmarshal(out, &result)) + assert.Equal(t, true, result["protected"]) + assert.Equal(t, float64(1), result["required_approvals"]) +} + +func TestBranchProtectionGetUnprotected(t *testing.T) { + srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + w.WriteHeader(http.StatusNotFound) + _, _ = w.Write([]byte(`{"message":"not found"}`)) + })) + defer srv.Close() + + tool := tools.NewBranchProtectionGet(gitea.NewClient(srv.URL, "tok"), allowlist.New([]string{"owner"})) + out, err := tool.Call(context.Background(), json.RawMessage(`{"owner":"owner","name":"repo","branch":"feat/x"}`)) + require.NoError(t, err) + + var result map[string]any + require.NoError(t, json.Unmarshal(out, &result)) + assert.Equal(t, false, result["protected"]) +} + +func TestBranchProtectionGetAllowlistRejects(t *testing.T) { + tool := tools.NewBranchProtectionGet(gitea.NewClient("http://unused", ""), allowlist.New([]string{"allowed"})) + _, err := tool.Call(context.Background(), json.RawMessage(`{"owner":"evil","name":"repo","branch":"main"}`)) + require.Error(t, err) +}