diff --git a/README.md b/README.md
index d8d8695d2a..7febc7763d 100644
--- a/README.md
+++ b/README.md
@@ -597,6 +597,7 @@ The following sets of tools are available:
|
| `discussions` | GitHub Discussions related tools |
|
| `gists` | GitHub Gist related tools |
|
| `git` | GitHub Git API related tools for low-level Git operations |
+|
| `governance` | Repository governance tools for managing rulesets at the repository, organization, and enterprise levels |
|
| `issues` | GitHub Issues related tools |
|
| `labels` | GitHub Labels related tools |
|
| `notifications` | GitHub Notifications related tools |
@@ -883,6 +884,56 @@ The following sets of tools are available:
+
Governance
+
+- **create_repository_ruleset** - Create repository ruleset
+ - **OAuth Challenge Scopes**: `repo`, `admin:org`, `admin:enterprise`
+ - `bypass_actors`: The actors that can bypass the rules in this ruleset (object[], optional)
+ - `conditions`: Conditions for when this ruleset applies, e.g. {"ref_name": {"include": ["refs/heads/main"], "exclude": []}} (object, optional)
+ - `enforcement`: The enforcement level of the ruleset. 'evaluate' allows admins to test rules before enforcing them (string, required)
+ - `enterprise`: Enterprise slug. Required when level is 'enterprise'. (string, optional)
+ - `level`: The level at which the ruleset is configured:
+ - 'repository': A ruleset on a single repository (requires 'owner' and 'repo').
+ - 'organization': A ruleset covering repositories in an organization (requires 'org').
+ - 'enterprise': A ruleset covering repositories across an enterprise (requires 'enterprise'). (string, required)
+ - `name`: The name of the ruleset (string, required)
+ - `org`: Organization name. Required when level is 'organization'. (string, optional)
+ - `owner`: Repository owner. Required when level is 'repository'. (string, optional)
+ - `repo`: Repository name. Required when level is 'repository'. (string, optional)
+ - `rules`: An array of rules within the ruleset. Each rule is an object with a 'type' (e.g. 'creation', 'deletion', 'non_fast_forward', 'required_signatures', 'pull_request', 'required_status_checks') and, for rules that need configuration, a 'parameters' object (object[], required)
+ - `target`: The target of the ruleset. Defaults to 'branch'. 'repository' is only valid for 'organization' and 'enterprise' level rulesets. (string, optional)
+
+- **repository_ruleset_read** - Read repository rulesets
+ - **OAuth Challenge Scopes**: `repo`, `read:org`, `read:enterprise`
+ - `actor_name`: The handle for the GitHub user account to filter rule suites on. Used by the 'list_rule_suites' method. (string, optional)
+ - `branch`: Branch name. Required for the 'get_rules_for_branch' method. (string, optional)
+ - `enterprise`: Enterprise slug. Required when level is 'enterprise'. (string, optional)
+ - `includes_parents`: Include rulesets configured at higher levels that also apply. Defaults to true. Used by the 'get' and 'list' methods at the repository level. (boolean, optional)
+ - `level`: The level at which the ruleset is configured:
+ - 'repository': A ruleset on a single repository (requires 'owner' and 'repo').
+ - 'organization': A ruleset covering repositories in an organization (requires 'org').
+ - 'enterprise': A ruleset covering repositories across an enterprise (requires 'enterprise'). (string, required)
+ - `method`: Operation to perform:
+ - 'get': Get a specific ruleset by ID (requires 'ruleset_id'). Supported at every level.
+ - 'list': List all rulesets. Supported at every level.
+ - 'get_rules_for_branch': Get all rules that apply to a branch (requires 'branch'). Repository level only.
+ - 'list_rule_suites': List rule suites, the evaluations of rules against pushes. Repository level only.
+ - 'get_rule_suite': Get a specific rule suite by ID (requires 'rule_suite_id'). Repository level only. (string, required)
+ - `org`: Organization name. Required when level is 'organization'. (string, optional)
+ - `owner`: Repository owner. Required when level is 'repository'. (string, optional)
+ - `page`: Page number for pagination (min 1) (number, optional)
+ - `perPage`: Results per page for pagination (min 1, max 100) (number, optional)
+ - `ref`: The name of the ref (branch, tag, etc.) to filter rule suites by. Used by the 'list_rule_suites' method. (string, optional)
+ - `repo`: Repository name. Required when level is 'repository'. (string, optional)
+ - `rule_suite_id`: Rule suite ID. Required for the 'get_rule_suite' method. (number, optional)
+ - `rule_suite_result`: The rule suite result to filter by. Used by the 'list_rule_suites' method. (string, optional)
+ - `ruleset_id`: Ruleset ID. Required for the 'get' method. (number, optional)
+ - `time_period`: The time period to filter rule suites by. Used by the 'list_rule_suites' method. (string, optional)
+
+
+
+
+
Issues
- **add_issue_comment** - Add comment to issue or pull request
diff --git a/docs/remote-server.md b/docs/remote-server.md
index d8587a3116..78b5c906ac 100644
--- a/docs/remote-server.md
+++ b/docs/remote-server.md
@@ -30,6 +30,7 @@ Below is a table of available toolsets for the remote GitHub MCP Server. Each to
| 
`discussions` | GitHub Discussions related tools | https://api.githubcopilot.com/mcp/x/discussions | [Install](https://insiders.vscode.dev/redirect/mcp/install?name=gh-discussions&config=%7B%22type%22%3A%20%22http%22%2C%22url%22%3A%20%22https%3A%2F%2Fapi.githubcopilot.com%2Fmcp%2Fx%2Fdiscussions%22%7D) | [read-only](https://api.githubcopilot.com/mcp/x/discussions/readonly) | [Install read-only](https://insiders.vscode.dev/redirect/mcp/install?name=gh-discussions&config=%7B%22type%22%3A%20%22http%22%2C%22url%22%3A%20%22https%3A%2F%2Fapi.githubcopilot.com%2Fmcp%2Fx%2Fdiscussions%2Freadonly%22%7D) |
| 
`gists` | GitHub Gist related tools | https://api.githubcopilot.com/mcp/x/gists | [Install](https://insiders.vscode.dev/redirect/mcp/install?name=gh-gists&config=%7B%22type%22%3A%20%22http%22%2C%22url%22%3A%20%22https%3A%2F%2Fapi.githubcopilot.com%2Fmcp%2Fx%2Fgists%22%7D) | [read-only](https://api.githubcopilot.com/mcp/x/gists/readonly) | [Install read-only](https://insiders.vscode.dev/redirect/mcp/install?name=gh-gists&config=%7B%22type%22%3A%20%22http%22%2C%22url%22%3A%20%22https%3A%2F%2Fapi.githubcopilot.com%2Fmcp%2Fx%2Fgists%2Freadonly%22%7D) |
| 
`git` | GitHub Git API related tools for low-level Git operations | https://api.githubcopilot.com/mcp/x/git | [Install](https://insiders.vscode.dev/redirect/mcp/install?name=gh-git&config=%7B%22type%22%3A%20%22http%22%2C%22url%22%3A%20%22https%3A%2F%2Fapi.githubcopilot.com%2Fmcp%2Fx%2Fgit%22%7D) | [read-only](https://api.githubcopilot.com/mcp/x/git/readonly) | [Install read-only](https://insiders.vscode.dev/redirect/mcp/install?name=gh-git&config=%7B%22type%22%3A%20%22http%22%2C%22url%22%3A%20%22https%3A%2F%2Fapi.githubcopilot.com%2Fmcp%2Fx%2Fgit%2Freadonly%22%7D) |
+| 
`governance` | Repository governance tools for managing rulesets at the repository, organization, and enterprise levels | https://api.githubcopilot.com/mcp/x/governance | [Install](https://insiders.vscode.dev/redirect/mcp/install?name=gh-governance&config=%7B%22type%22%3A%20%22http%22%2C%22url%22%3A%20%22https%3A%2F%2Fapi.githubcopilot.com%2Fmcp%2Fx%2Fgovernance%22%7D) | [read-only](https://api.githubcopilot.com/mcp/x/governance/readonly) | [Install read-only](https://insiders.vscode.dev/redirect/mcp/install?name=gh-governance&config=%7B%22type%22%3A%20%22http%22%2C%22url%22%3A%20%22https%3A%2F%2Fapi.githubcopilot.com%2Fmcp%2Fx%2Fgovernance%2Freadonly%22%7D) |
| 
`issues` | GitHub Issues related tools | https://api.githubcopilot.com/mcp/x/issues | [Install](https://insiders.vscode.dev/redirect/mcp/install?name=gh-issues&config=%7B%22type%22%3A%20%22http%22%2C%22url%22%3A%20%22https%3A%2F%2Fapi.githubcopilot.com%2Fmcp%2Fx%2Fissues%22%7D) | [read-only](https://api.githubcopilot.com/mcp/x/issues/readonly) | [Install read-only](https://insiders.vscode.dev/redirect/mcp/install?name=gh-issues&config=%7B%22type%22%3A%20%22http%22%2C%22url%22%3A%20%22https%3A%2F%2Fapi.githubcopilot.com%2Fmcp%2Fx%2Fissues%2Freadonly%22%7D) |
| 
`labels` | GitHub Labels related tools | https://api.githubcopilot.com/mcp/x/labels | [Install](https://insiders.vscode.dev/redirect/mcp/install?name=gh-labels&config=%7B%22type%22%3A%20%22http%22%2C%22url%22%3A%20%22https%3A%2F%2Fapi.githubcopilot.com%2Fmcp%2Fx%2Flabels%22%7D) | [read-only](https://api.githubcopilot.com/mcp/x/labels/readonly) | [Install read-only](https://insiders.vscode.dev/redirect/mcp/install?name=gh-labels&config=%7B%22type%22%3A%20%22http%22%2C%22url%22%3A%20%22https%3A%2F%2Fapi.githubcopilot.com%2Fmcp%2Fx%2Flabels%2Freadonly%22%7D) |
| 
`notifications` | GitHub Notifications related tools | https://api.githubcopilot.com/mcp/x/notifications | [Install](https://insiders.vscode.dev/redirect/mcp/install?name=gh-notifications&config=%7B%22type%22%3A%20%22http%22%2C%22url%22%3A%20%22https%3A%2F%2Fapi.githubcopilot.com%2Fmcp%2Fx%2Fnotifications%22%7D) | [read-only](https://api.githubcopilot.com/mcp/x/notifications/readonly) | [Install read-only](https://insiders.vscode.dev/redirect/mcp/install?name=gh-notifications&config=%7B%22type%22%3A%20%22http%22%2C%22url%22%3A%20%22https%3A%2F%2Fapi.githubcopilot.com%2Fmcp%2Fx%2Fnotifications%2Freadonly%22%7D) |
diff --git a/docs/scope-filtering.md b/docs/scope-filtering.md
index 2055db1483..7e066fe760 100644
--- a/docs/scope-filtering.md
+++ b/docs/scope-filtering.md
@@ -63,6 +63,7 @@ Some scopes implicitly include others:
- `repo` → includes `public_repo`, `security_events`
- `admin:org` → includes `write:org` → includes `read:org`
+- `admin:enterprise` → includes `read:enterprise`
- `project` → includes `read:project`
This means if your token has `repo`, tools requiring `security_events` will also be available.
diff --git a/pkg/github/__toolsnaps__/create_repository_ruleset.snap b/pkg/github/__toolsnaps__/create_repository_ruleset.snap
new file mode 100644
index 0000000000..8ee7fbd724
--- /dev/null
+++ b/pkg/github/__toolsnaps__/create_repository_ruleset.snap
@@ -0,0 +1,128 @@
+{
+ "annotations": {
+ "idempotentHint": false,
+ "readOnlyHint": false,
+ "title": "Create repository ruleset"
+ },
+ "description": "Create a new ruleset at the repository, organization, or enterprise level",
+ "inputSchema": {
+ "properties": {
+ "bypass_actors": {
+ "description": "The actors that can bypass the rules in this ruleset",
+ "items": {
+ "properties": {
+ "actor_id": {
+ "description": "The ID of the actor that can bypass a ruleset",
+ "type": "number"
+ },
+ "actor_type": {
+ "description": "The type of actor that can bypass a ruleset. 'EnterpriseOwner' and 'EnterpriseRole' are only valid for 'enterprise' level rulesets.",
+ "enum": [
+ "Integration",
+ "OrganizationAdmin",
+ "RepositoryRole",
+ "Team",
+ "DeployKey",
+ "User",
+ "EnterpriseOwner",
+ "EnterpriseRole"
+ ],
+ "type": "string"
+ },
+ "bypass_mode": {
+ "description": "When the specified actor can bypass the ruleset. 'pull_request' only applies to branch rulesets and is not valid for the 'DeployKey' actor type. 'exempt' means rules are not run for that actor and no bypass audit entry is created.",
+ "enum": [
+ "always",
+ "pull_request",
+ "exempt"
+ ],
+ "type": "string"
+ }
+ },
+ "type": "object"
+ },
+ "type": "array"
+ },
+ "conditions": {
+ "description": "Conditions for when this ruleset applies, e.g. {\"ref_name\": {\"include\": [\"refs/heads/main\"], \"exclude\": []}}",
+ "type": "object"
+ },
+ "enforcement": {
+ "description": "The enforcement level of the ruleset. 'evaluate' allows admins to test rules before enforcing them",
+ "enum": [
+ "disabled",
+ "active",
+ "evaluate"
+ ],
+ "type": "string"
+ },
+ "enterprise": {
+ "description": "Enterprise slug. Required when level is 'enterprise'.",
+ "type": "string"
+ },
+ "level": {
+ "description": "The level at which the ruleset is configured:\n- 'repository': A ruleset on a single repository (requires 'owner' and 'repo').\n- 'organization': A ruleset covering repositories in an organization (requires 'org').\n- 'enterprise': A ruleset covering repositories across an enterprise (requires 'enterprise').",
+ "enum": [
+ "repository",
+ "organization",
+ "enterprise"
+ ],
+ "type": "string"
+ },
+ "name": {
+ "description": "The name of the ruleset",
+ "type": "string"
+ },
+ "org": {
+ "description": "Organization name. Required when level is 'organization'.",
+ "type": "string"
+ },
+ "owner": {
+ "description": "Repository owner. Required when level is 'repository'.",
+ "type": "string"
+ },
+ "repo": {
+ "description": "Repository name. Required when level is 'repository'.",
+ "type": "string"
+ },
+ "rules": {
+ "description": "An array of rules within the ruleset. Each rule is an object with a 'type' (e.g. 'creation', 'deletion', 'non_fast_forward', 'required_signatures', 'pull_request', 'required_status_checks') and, for rules that need configuration, a 'parameters' object",
+ "items": {
+ "properties": {
+ "parameters": {
+ "description": "Parameters for rule types that require additional configuration",
+ "type": "object"
+ },
+ "type": {
+ "description": "The type of rule, e.g. 'creation', 'deletion', 'non_fast_forward', 'required_signatures', 'pull_request', 'required_status_checks'",
+ "type": "string"
+ }
+ },
+ "required": [
+ "type"
+ ],
+ "type": "object"
+ },
+ "type": "array"
+ },
+ "target": {
+ "description": "The target of the ruleset. Defaults to 'branch'. 'repository' is only valid for 'organization' and 'enterprise' level rulesets.",
+ "enum": [
+ "branch",
+ "tag",
+ "push",
+ "repository"
+ ],
+ "type": "string"
+ }
+ },
+ "required": [
+ "level",
+ "name",
+ "enforcement",
+ "rules"
+ ],
+ "type": "object"
+ },
+ "name": "create_repository_ruleset"
+}
\ No newline at end of file
diff --git a/pkg/github/__toolsnaps__/repository_ruleset_read.snap b/pkg/github/__toolsnaps__/repository_ruleset_read.snap
new file mode 100644
index 0000000000..2a79b4cfc9
--- /dev/null
+++ b/pkg/github/__toolsnaps__/repository_ruleset_read.snap
@@ -0,0 +1,109 @@
+{
+ "annotations": {
+ "idempotentHint": false,
+ "readOnlyHint": true,
+ "title": "Read repository rulesets"
+ },
+ "description": "Read rulesets and rule suites at the repository, organization, or enterprise level. Select the level with the 'level' parameter and the operation with the 'method' parameter.",
+ "inputSchema": {
+ "properties": {
+ "actor_name": {
+ "description": "The handle for the GitHub user account to filter rule suites on. Used by the 'list_rule_suites' method.",
+ "type": "string"
+ },
+ "branch": {
+ "description": "Branch name. Required for the 'get_rules_for_branch' method.",
+ "type": "string"
+ },
+ "enterprise": {
+ "description": "Enterprise slug. Required when level is 'enterprise'.",
+ "type": "string"
+ },
+ "includes_parents": {
+ "description": "Include rulesets configured at higher levels that also apply. Defaults to true. Used by the 'get' and 'list' methods at the repository level.",
+ "type": "boolean"
+ },
+ "level": {
+ "description": "The level at which the ruleset is configured:\n- 'repository': A ruleset on a single repository (requires 'owner' and 'repo').\n- 'organization': A ruleset covering repositories in an organization (requires 'org').\n- 'enterprise': A ruleset covering repositories across an enterprise (requires 'enterprise').",
+ "enum": [
+ "repository",
+ "organization",
+ "enterprise"
+ ],
+ "type": "string"
+ },
+ "method": {
+ "description": "Operation to perform:\n- 'get': Get a specific ruleset by ID (requires 'ruleset_id'). Supported at every level.\n- 'list': List all rulesets. Supported at every level.\n- 'get_rules_for_branch': Get all rules that apply to a branch (requires 'branch'). Repository level only.\n- 'list_rule_suites': List rule suites, the evaluations of rules against pushes. Repository level only.\n- 'get_rule_suite': Get a specific rule suite by ID (requires 'rule_suite_id'). Repository level only.",
+ "enum": [
+ "get",
+ "list",
+ "get_rules_for_branch",
+ "list_rule_suites",
+ "get_rule_suite"
+ ],
+ "type": "string"
+ },
+ "org": {
+ "description": "Organization name. Required when level is 'organization'.",
+ "type": "string"
+ },
+ "owner": {
+ "description": "Repository owner. Required when level is 'repository'.",
+ "type": "string"
+ },
+ "page": {
+ "description": "Page number for pagination (min 1)",
+ "minimum": 1,
+ "type": "number"
+ },
+ "perPage": {
+ "description": "Results per page for pagination (min 1, max 100)",
+ "maximum": 100,
+ "minimum": 1,
+ "type": "number"
+ },
+ "ref": {
+ "description": "The name of the ref (branch, tag, etc.) to filter rule suites by. Used by the 'list_rule_suites' method.",
+ "type": "string"
+ },
+ "repo": {
+ "description": "Repository name. Required when level is 'repository'.",
+ "type": "string"
+ },
+ "rule_suite_id": {
+ "description": "Rule suite ID. Required for the 'get_rule_suite' method.",
+ "type": "number"
+ },
+ "rule_suite_result": {
+ "description": "The rule suite result to filter by. Used by the 'list_rule_suites' method.",
+ "enum": [
+ "pass",
+ "fail",
+ "bypass",
+ "all"
+ ],
+ "type": "string"
+ },
+ "ruleset_id": {
+ "description": "Ruleset ID. Required for the 'get' method.",
+ "type": "number"
+ },
+ "time_period": {
+ "description": "The time period to filter rule suites by. Used by the 'list_rule_suites' method.",
+ "enum": [
+ "hour",
+ "day",
+ "week",
+ "month"
+ ],
+ "type": "string"
+ }
+ },
+ "required": [
+ "level",
+ "method"
+ ],
+ "type": "object"
+ },
+ "name": "repository_ruleset_read"
+}
\ No newline at end of file
diff --git a/pkg/github/rulesets.go b/pkg/github/rulesets.go
new file mode 100644
index 0000000000..96c1ff66e5
--- /dev/null
+++ b/pkg/github/rulesets.go
@@ -0,0 +1,1003 @@
+package github
+
+import (
+ "context"
+ "encoding/json"
+ "fmt"
+ "net/http"
+ "net/url"
+ "strconv"
+ "strings"
+
+ ghErrors "github.com/github/github-mcp-server/pkg/errors"
+ "github.com/github/github-mcp-server/pkg/inventory"
+ "github.com/github/github-mcp-server/pkg/scopes"
+ "github.com/github/github-mcp-server/pkg/translations"
+ "github.com/github/github-mcp-server/pkg/utils"
+ "github.com/google/go-github/v89/github"
+ "github.com/google/jsonschema-go/jsonschema"
+ "github.com/modelcontextprotocol/go-sdk/mcp"
+)
+
+// rulesetLevelDescription documents the "level" parameter shared by the
+// ruleset read and write tools.
+const rulesetLevelDescription = "The level at which the ruleset is configured:\n" +
+ "- 'repository': A ruleset on a single repository (requires 'owner' and 'repo').\n" +
+ "- 'organization': A ruleset covering repositories in an organization (requires 'org').\n" +
+ "- 'enterprise': A ruleset covering repositories across an enterprise (requires 'enterprise')."
+
+// rulesetReadScopeAccess declares the exhaustive scope challenge policy for
+// repository_ruleset_read. The exact scope challenged depends on the "level"
+// argument: repository reads need "repo", organization reads need "read:org",
+// and enterprise reads need "read:enterprise". A missing or unrecognized
+// level returns no challenge so normal handler validation produces the error.
+func rulesetReadScopeAccess() inventory.ScopeAccess {
+ return scopes.DynamicChallenge(
+ []scopes.Scope{scopes.Repo, scopes.ReadOrg, scopes.ReadEnterprise},
+ func([]string) bool {
+ // Repository-level reads may target public repositories, so the
+ // tool stays visible even for tokens without any of these scopes.
+ return true
+ },
+ func(arguments map[string]any, activeScopes []string) []string {
+ level, ok := arguments["level"].(string)
+ if !ok {
+ return nil
+ }
+ switch level {
+ case "repository":
+ return scopes.ChallengeAll(activeScopes, scopes.Repo)
+ case "organization":
+ return scopes.ChallengeAll(activeScopes, scopes.ReadOrg)
+ case "enterprise":
+ return scopes.ChallengeAll(activeScopes, scopes.ReadEnterprise)
+ default:
+ return nil
+ }
+ },
+ )
+}
+
+// rulesetWriteScopeAccess declares the exhaustive scope challenge policy for
+// create_repository_ruleset. The exact scope challenged depends on the
+// "level" argument: repository writes need "repo", organization writes need
+// "admin:org", and enterprise writes need "admin:enterprise". A missing or
+// unrecognized level returns no challenge so normal handler validation
+// produces the error.
+func rulesetWriteScopeAccess() inventory.ScopeAccess {
+ return scopes.DynamicChallenge(
+ []scopes.Scope{scopes.Repo, scopes.AdminOrg, scopes.AdminEnterprise},
+ func([]string) bool { return true },
+ func(arguments map[string]any, activeScopes []string) []string {
+ level, ok := arguments["level"].(string)
+ if !ok {
+ return nil
+ }
+ switch level {
+ case "repository":
+ return scopes.ChallengeAll(activeScopes, scopes.Repo)
+ case "organization":
+ return scopes.ChallengeAll(activeScopes, scopes.AdminOrg)
+ case "enterprise":
+ return scopes.ChallengeAll(activeScopes, scopes.AdminEnterprise)
+ default:
+ return nil
+ }
+ },
+ )
+}
+
+// RepositoryRulesetRead creates a tool for read operations on rulesets and
+// rule suites at the repository, organization, or enterprise level. The
+// level is selected with the "level" parameter and the operation with the
+// "method" parameter.
+func RepositoryRulesetRead(t translations.TranslationHelperFunc) inventory.ServerTool {
+ return NewTool(
+ ToolsetMetadataGovernance,
+ mcp.Tool{
+ Name: "repository_ruleset_read",
+ Description: t("TOOL_REPOSITORY_RULESET_READ_DESCRIPTION", "Read rulesets and rule suites at the repository, organization, or enterprise level. Select the level with the 'level' parameter and the operation with the 'method' parameter."),
+ Annotations: &mcp.ToolAnnotations{
+ Title: t("TOOL_REPOSITORY_RULESET_READ_USER_TITLE", "Read repository rulesets"),
+ ReadOnlyHint: true,
+ },
+ InputSchema: WithPagination(&jsonschema.Schema{
+ Type: "object",
+ Properties: map[string]*jsonschema.Schema{
+ "level": {
+ Type: "string",
+ Enum: []any{"repository", "organization", "enterprise"},
+ Description: rulesetLevelDescription,
+ },
+ "method": {
+ Type: "string",
+ Enum: []any{"get", "list", "get_rules_for_branch", "list_rule_suites", "get_rule_suite"},
+ Description: "Operation to perform:\n" +
+ "- 'get': Get a specific ruleset by ID (requires 'ruleset_id'). Supported at every level.\n" +
+ "- 'list': List all rulesets. Supported at every level.\n" +
+ "- 'get_rules_for_branch': Get all rules that apply to a branch (requires 'branch'). Repository level only.\n" +
+ "- 'list_rule_suites': List rule suites, the evaluations of rules against pushes. Repository level only.\n" +
+ "- 'get_rule_suite': Get a specific rule suite by ID (requires 'rule_suite_id'). Repository level only.",
+ },
+ "owner": {
+ Type: "string",
+ Description: "Repository owner. Required when level is 'repository'.",
+ },
+ "repo": {
+ Type: "string",
+ Description: "Repository name. Required when level is 'repository'.",
+ },
+ "org": {
+ Type: "string",
+ Description: "Organization name. Required when level is 'organization'.",
+ },
+ "enterprise": {
+ Type: "string",
+ Description: "Enterprise slug. Required when level is 'enterprise'.",
+ },
+ "ruleset_id": {
+ Type: "number",
+ Description: "Ruleset ID. Required for the 'get' method.",
+ },
+ "includes_parents": {
+ Type: "boolean",
+ Description: "Include rulesets configured at higher levels that also apply. Defaults to true. Used by the 'get' and 'list' methods at the repository level.",
+ },
+ "branch": {
+ Type: "string",
+ Description: "Branch name. Required for the 'get_rules_for_branch' method.",
+ },
+ "ref": {
+ Type: "string",
+ Description: "The name of the ref (branch, tag, etc.) to filter rule suites by. Used by the 'list_rule_suites' method.",
+ },
+ "time_period": {
+ Type: "string",
+ Enum: []any{"hour", "day", "week", "month"},
+ Description: "The time period to filter rule suites by. Used by the 'list_rule_suites' method.",
+ },
+ "actor_name": {
+ Type: "string",
+ Description: "The handle for the GitHub user account to filter rule suites on. Used by the 'list_rule_suites' method.",
+ },
+ "rule_suite_result": {
+ Type: "string",
+ Enum: []any{"pass", "fail", "bypass", "all"},
+ Description: "The rule suite result to filter by. Used by the 'list_rule_suites' method.",
+ },
+ "rule_suite_id": {
+ Type: "number",
+ Description: "Rule suite ID. Required for the 'get_rule_suite' method.",
+ },
+ },
+ Required: []string{"level", "method"},
+ }),
+ },
+ rulesetReadScopeAccess(),
+ func(ctx context.Context, deps ToolDependencies, _ *mcp.CallToolRequest, args map[string]any) (*mcp.CallToolResult, any, error) {
+ level, err := RequiredParam[string](args, "level")
+ if err != nil {
+ return utils.NewToolResultError(err.Error()), nil, nil
+ }
+ method, err := RequiredParam[string](args, "method")
+ if err != nil {
+ return utils.NewToolResultError(err.Error()), nil, nil
+ }
+
+ client, err := deps.GetClient(ctx)
+ if err != nil {
+ return nil, nil, fmt.Errorf("failed to get GitHub client: %w", err)
+ }
+
+ switch level {
+ case "repository":
+ return repositoryRulesetReadRepository(ctx, client, strings.ToLower(method), args)
+ case "organization":
+ return repositoryRulesetReadOrganization(ctx, client, strings.ToLower(method), args)
+ case "enterprise":
+ return repositoryRulesetReadEnterprise(ctx, client, strings.ToLower(method), args)
+ default:
+ return utils.NewToolResultError(fmt.Sprintf("unknown level: %q (expected 'repository', 'organization', or 'enterprise')", level)), nil, nil
+ }
+ },
+ )
+}
+
+// repositoryRulesetReadRepository handles repository_ruleset_read calls with level="repository".
+func repositoryRulesetReadRepository(ctx context.Context, client *github.Client, method string, args map[string]any) (*mcp.CallToolResult, any, error) {
+ owner, err := RequiredParam[string](args, "owner")
+ if err != nil {
+ return utils.NewToolResultError(err.Error()), nil, nil
+ }
+ repo, err := RequiredParam[string](args, "repo")
+ if err != nil {
+ return utils.NewToolResultError(err.Error()), nil, nil
+ }
+
+ switch method {
+ case "get":
+ rulesetID, err := RequiredBigInt(args, "ruleset_id")
+ if err != nil {
+ return utils.NewToolResultError(err.Error()), nil, nil
+ }
+ // GetRuleset always sends includes_parents; default to the
+ // GitHub API default of true when the caller omits it.
+ includesParents := true
+ if _, ok := args["includes_parents"]; ok {
+ includesParents, err = OptionalParam[bool](args, "includes_parents")
+ if err != nil {
+ return utils.NewToolResultError(err.Error()), nil, nil
+ }
+ }
+ result, err := GetRepositoryRuleset(ctx, client, owner, repo, rulesetID, includesParents)
+ return result, nil, err
+ case "list":
+ pagination, err := OptionalPaginationParams(args)
+ if err != nil {
+ return utils.NewToolResultError(err.Error()), nil, nil
+ }
+ var includesParents *bool
+ if _, ok := args["includes_parents"]; ok {
+ v, err := OptionalParam[bool](args, "includes_parents")
+ if err != nil {
+ return utils.NewToolResultError(err.Error()), nil, nil
+ }
+ includesParents = &v
+ }
+ result, err := ListRepositoryRulesets(ctx, client, owner, repo, includesParents, pagination)
+ return result, nil, err
+ case "get_rules_for_branch":
+ branch, err := RequiredParam[string](args, "branch")
+ if err != nil {
+ return utils.NewToolResultError(err.Error()), nil, nil
+ }
+ pagination, err := OptionalPaginationParams(args)
+ if err != nil {
+ return utils.NewToolResultError(err.Error()), nil, nil
+ }
+ result, err := GetRepositoryRulesForBranch(ctx, client, owner, repo, branch, pagination)
+ return result, nil, err
+ case "list_rule_suites":
+ filters, err := ruleSuiteFiltersFromArgs(args)
+ if err != nil {
+ return utils.NewToolResultError(err.Error()), nil, nil
+ }
+ pagination, err := OptionalPaginationParams(args)
+ if err != nil {
+ return utils.NewToolResultError(err.Error()), nil, nil
+ }
+ result, err := ListRepositoryRuleSuites(ctx, client, owner, repo, filters, pagination)
+ return result, nil, err
+ case "get_rule_suite":
+ ruleSuiteID, err := RequiredBigInt(args, "rule_suite_id")
+ if err != nil {
+ return utils.NewToolResultError(err.Error()), nil, nil
+ }
+ result, err := GetRepositoryRuleSuite(ctx, client, owner, repo, ruleSuiteID)
+ return result, nil, err
+ default:
+ return utils.NewToolResultError(fmt.Sprintf("unknown method: %q", method)), nil, nil
+ }
+}
+
+// repositoryRulesetReadOrganization handles repository_ruleset_read calls with level="organization".
+func repositoryRulesetReadOrganization(ctx context.Context, client *github.Client, method string, args map[string]any) (*mcp.CallToolResult, any, error) {
+ org, err := RequiredParam[string](args, "org")
+ if err != nil {
+ return utils.NewToolResultError(err.Error()), nil, nil
+ }
+
+ switch method {
+ case "get":
+ rulesetID, err := RequiredBigInt(args, "ruleset_id")
+ if err != nil {
+ return utils.NewToolResultError(err.Error()), nil, nil
+ }
+ result, err := GetOrganizationRepositoryRuleset(ctx, client, org, rulesetID)
+ return result, nil, err
+ case "list":
+ pagination, err := OptionalPaginationParams(args)
+ if err != nil {
+ return utils.NewToolResultError(err.Error()), nil, nil
+ }
+ result, err := ListOrganizationRepositoryRulesets(ctx, client, org, pagination)
+ return result, nil, err
+ default:
+ return utils.NewToolResultError(fmt.Sprintf("method %q is not supported for level \"organization\"; supported methods: get, list", method)), nil, nil
+ }
+}
+
+// repositoryRulesetReadEnterprise handles repository_ruleset_read calls with level="enterprise".
+func repositoryRulesetReadEnterprise(ctx context.Context, client *github.Client, method string, args map[string]any) (*mcp.CallToolResult, any, error) {
+ enterprise, err := RequiredParam[string](args, "enterprise")
+ if err != nil {
+ return utils.NewToolResultError(err.Error()), nil, nil
+ }
+
+ switch method {
+ case "get":
+ rulesetID, err := RequiredBigInt(args, "ruleset_id")
+ if err != nil {
+ return utils.NewToolResultError(err.Error()), nil, nil
+ }
+ result, err := GetEnterpriseRepositoryRuleset(ctx, client, enterprise, rulesetID)
+ return result, nil, err
+ case "list":
+ pagination, err := OptionalPaginationParams(args)
+ if err != nil {
+ return utils.NewToolResultError(err.Error()), nil, nil
+ }
+ result, err := ListEnterpriseRepositoryRulesets(ctx, client, enterprise, pagination)
+ return result, nil, err
+ default:
+ return utils.NewToolResultError(fmt.Sprintf("method %q is not supported for level \"enterprise\"; supported methods: get, list", method)), nil, nil
+ }
+}
+
+// GetRepositoryRuleset gets a specific repository ruleset by ID.
+func GetRepositoryRuleset(ctx context.Context, client *github.Client, owner, repo string, rulesetID int64, includesParents bool) (*mcp.CallToolResult, error) {
+ ruleset, resp, err := client.Repositories.GetRuleset(ctx, owner, repo, rulesetID, includesParents)
+ if resp != nil {
+ defer func() { _ = resp.Body.Close() }()
+ }
+ if err != nil {
+ return ghErrors.NewGitHubAPIErrorResponse(ctx, "failed to get repository ruleset", resp, err), nil
+ }
+
+ return MarshalledTextResult(ruleset), nil
+}
+
+// ListRepositoryRulesets lists all rulesets for a repository. When
+// includesParents is nil GitHub's default behaviour (include parents) is used.
+func ListRepositoryRulesets(ctx context.Context, client *github.Client, owner, repo string, includesParents *bool, pagination PaginationParams) (*mcp.CallToolResult, error) {
+ opts := &github.RepositoryListRulesetsOptions{
+ ListOptions: github.ListOptions{
+ Page: pagination.Page,
+ PerPage: pagination.PerPage,
+ },
+ IncludesParents: includesParents,
+ }
+
+ rulesets, resp, err := client.Repositories.GetAllRulesets(ctx, owner, repo, opts)
+ if resp != nil {
+ defer func() { _ = resp.Body.Close() }()
+ }
+ if err != nil {
+ return ghErrors.NewGitHubAPIErrorResponse(ctx, "failed to list repository rulesets", resp, err), nil
+ }
+
+ return MarshalledTextResult(rulesets), nil
+}
+
+// GetRepositoryRulesForBranch gets all rules that apply to a specific branch.
+func GetRepositoryRulesForBranch(ctx context.Context, client *github.Client, owner, repo, branch string, pagination PaginationParams) (*mcp.CallToolResult, error) {
+ opts := &github.ListOptions{
+ Page: pagination.Page,
+ PerPage: pagination.PerPage,
+ }
+
+ branchRules, resp, err := client.Repositories.ListRulesForBranch(ctx, owner, repo, branch, opts)
+ if resp != nil {
+ defer func() { _ = resp.Body.Close() }()
+ }
+ if err != nil {
+ return ghErrors.NewGitHubAPIErrorResponse(ctx, "failed to get repository rules for branch", resp, err), nil
+ }
+
+ return MarshalledTextResult(branchRules), nil
+}
+
+// ruleSuiteFilters holds the optional filters for listing rule suites.
+type ruleSuiteFilters struct {
+ Ref string
+ TimePeriod string
+ ActorName string
+ RuleSuiteResult string
+}
+
+func ruleSuiteFiltersFromArgs(args map[string]any) (ruleSuiteFilters, error) {
+ ref, err := OptionalParam[string](args, "ref")
+ if err != nil {
+ return ruleSuiteFilters{}, err
+ }
+ timePeriod, err := OptionalParam[string](args, "time_period")
+ if err != nil {
+ return ruleSuiteFilters{}, err
+ }
+ actorName, err := OptionalParam[string](args, "actor_name")
+ if err != nil {
+ return ruleSuiteFilters{}, err
+ }
+ ruleSuiteResult, err := OptionalParam[string](args, "rule_suite_result")
+ if err != nil {
+ return ruleSuiteFilters{}, err
+ }
+ return ruleSuiteFilters{
+ Ref: ref,
+ TimePeriod: timePeriod,
+ ActorName: actorName,
+ RuleSuiteResult: ruleSuiteResult,
+ }, nil
+}
+
+// ListRepositoryRuleSuites lists rule suites (evaluations of rules against
+// pushes) for a repository. Rule suites are not supported by go-github, so the
+// request is issued directly.
+func ListRepositoryRuleSuites(ctx context.Context, client *github.Client, owner, repo string, filters ruleSuiteFilters, pagination PaginationParams) (*mcp.CallToolResult, error) {
+ apiURL := fmt.Sprintf("repos/%s/%s/rulesets/rule-suites", owner, repo)
+ query := url.Values{}
+ if filters.Ref != "" {
+ query.Set("ref", filters.Ref)
+ }
+ if filters.TimePeriod != "" {
+ query.Set("time_period", filters.TimePeriod)
+ }
+ if filters.ActorName != "" {
+ query.Set("actor_name", filters.ActorName)
+ }
+ if filters.RuleSuiteResult != "" {
+ query.Set("rule_suite_result", filters.RuleSuiteResult)
+ }
+ if pagination.Page > 0 {
+ query.Set("page", strconv.Itoa(pagination.Page))
+ }
+ if pagination.PerPage > 0 {
+ query.Set("per_page", strconv.Itoa(pagination.PerPage))
+ }
+ if len(query) > 0 {
+ apiURL += "?" + query.Encode()
+ }
+
+ req, err := client.NewRequest(ctx, http.MethodGet, apiURL, nil)
+ if err != nil {
+ return utils.NewToolResultErrorFromErr("failed to create request", err), nil
+ }
+
+ var ruleSuites any
+ resp, err := client.Do(req, &ruleSuites)
+ if resp != nil {
+ defer func() { _ = resp.Body.Close() }()
+ }
+ if err != nil {
+ return ghErrors.NewGitHubAPIErrorResponse(ctx, "failed to list repository rule suites", resp, err), nil
+ }
+
+ return MarshalledTextResult(ruleSuites), nil
+}
+
+// GetRepositoryRuleSuite gets details of a specific repository rule suite,
+// including the evaluation results for each rule. Rule suites are not supported
+// by go-github, so the request is issued directly.
+func GetRepositoryRuleSuite(ctx context.Context, client *github.Client, owner, repo string, ruleSuiteID int64) (*mcp.CallToolResult, error) {
+ apiURL := fmt.Sprintf("repos/%s/%s/rulesets/rule-suites/%d", owner, repo, ruleSuiteID)
+ req, err := client.NewRequest(ctx, http.MethodGet, apiURL, nil)
+ if err != nil {
+ return utils.NewToolResultErrorFromErr("failed to create request", err), nil
+ }
+
+ var ruleSuite any
+ resp, err := client.Do(req, &ruleSuite)
+ if resp != nil {
+ defer func() { _ = resp.Body.Close() }()
+ }
+ if err != nil {
+ return ghErrors.NewGitHubAPIErrorResponse(ctx, "failed to get repository rule suite", resp, err), nil
+ }
+
+ return MarshalledTextResult(ruleSuite), nil
+}
+
+// GetOrganizationRepositoryRuleset gets a specific organization repository
+// ruleset by ID.
+func GetOrganizationRepositoryRuleset(ctx context.Context, client *github.Client, org string, rulesetID int64) (*mcp.CallToolResult, error) {
+ ruleset, resp, err := client.Organizations.GetRepositoryRuleset(ctx, org, rulesetID)
+ if resp != nil {
+ defer func() { _ = resp.Body.Close() }()
+ }
+ if err != nil {
+ return ghErrors.NewGitHubAPIErrorResponse(ctx, "failed to get organization repository ruleset", resp, err), nil
+ }
+
+ return MarshalledTextResult(ruleset), nil
+}
+
+// ListOrganizationRepositoryRulesets lists all repository rulesets for an
+// organization.
+func ListOrganizationRepositoryRulesets(ctx context.Context, client *github.Client, org string, pagination PaginationParams) (*mcp.CallToolResult, error) {
+ opts := &github.ListOptions{
+ Page: pagination.Page,
+ PerPage: pagination.PerPage,
+ }
+
+ rulesets, resp, err := client.Organizations.ListAllRepositoryRulesets(ctx, org, opts)
+ if resp != nil {
+ defer func() { _ = resp.Body.Close() }()
+ }
+ if err != nil {
+ return ghErrors.NewGitHubAPIErrorResponse(ctx, "failed to list organization repository rulesets", resp, err), nil
+ }
+
+ return MarshalledTextResult(rulesets), nil
+}
+
+// GetEnterpriseRepositoryRuleset gets a specific enterprise repository
+// ruleset by ID.
+func GetEnterpriseRepositoryRuleset(ctx context.Context, client *github.Client, enterprise string, rulesetID int64) (*mcp.CallToolResult, error) {
+ ruleset, resp, err := client.Enterprise.GetRepositoryRuleset(ctx, enterprise, rulesetID)
+ if resp != nil {
+ defer func() { _ = resp.Body.Close() }()
+ }
+ if err != nil {
+ return ghErrors.NewGitHubAPIErrorResponse(ctx, "failed to get enterprise repository ruleset", resp, err), nil
+ }
+
+ return MarshalledTextResult(ruleset), nil
+}
+
+// ListEnterpriseRepositoryRulesets lists all repository rulesets for an
+// enterprise. Listing enterprise rulesets is not supported by go-github, so
+// the request is issued directly.
+func ListEnterpriseRepositoryRulesets(ctx context.Context, client *github.Client, enterprise string, pagination PaginationParams) (*mcp.CallToolResult, error) {
+ apiURL := fmt.Sprintf("enterprises/%s/rulesets", enterprise)
+ query := url.Values{}
+ if pagination.Page > 0 {
+ query.Set("page", strconv.Itoa(pagination.Page))
+ }
+ if pagination.PerPage > 0 {
+ query.Set("per_page", strconv.Itoa(pagination.PerPage))
+ }
+ if len(query) > 0 {
+ apiURL += "?" + query.Encode()
+ }
+
+ req, err := client.NewRequest(ctx, http.MethodGet, apiURL, nil)
+ if err != nil {
+ return utils.NewToolResultErrorFromErr("failed to create request", err), nil
+ }
+
+ var rulesets any
+ resp, err := client.Do(req, &rulesets)
+ if resp != nil {
+ defer func() { _ = resp.Body.Close() }()
+ }
+ if err != nil {
+ return ghErrors.NewGitHubAPIErrorResponse(ctx, "failed to list enterprise repository rulesets", resp, err), nil
+ }
+
+ return MarshalledTextResult(rulesets), nil
+}
+
+// CreateRepositoryRuleset creates a tool to create a new repository ruleset
+// at the repository, organization, or enterprise level. The level is
+// selected with the "level" parameter.
+func CreateRepositoryRuleset(t translations.TranslationHelperFunc) inventory.ServerTool {
+ properties := rulesetWriteProperties()
+ properties["level"] = &jsonschema.Schema{
+ Type: "string",
+ Enum: []any{"repository", "organization", "enterprise"},
+ Description: rulesetLevelDescription,
+ }
+ properties["owner"] = &jsonschema.Schema{Type: "string", Description: "Repository owner. Required when level is 'repository'."}
+ properties["repo"] = &jsonschema.Schema{Type: "string", Description: "Repository name. Required when level is 'repository'."}
+ properties["org"] = &jsonschema.Schema{Type: "string", Description: "Organization name. Required when level is 'organization'."}
+ properties["enterprise"] = &jsonschema.Schema{Type: "string", Description: "Enterprise slug. Required when level is 'enterprise'."}
+
+ return NewTool(
+ ToolsetMetadataGovernance,
+ mcp.Tool{
+ Name: "create_repository_ruleset",
+ Description: t("TOOL_CREATE_REPOSITORY_RULESET_DESCRIPTION", "Create a new ruleset at the repository, organization, or enterprise level"),
+ Annotations: &mcp.ToolAnnotations{
+ Title: t("TOOL_CREATE_REPOSITORY_RULESET_USER_TITLE", "Create repository ruleset"),
+ ReadOnlyHint: false,
+ },
+ InputSchema: &jsonschema.Schema{
+ Type: "object",
+ Properties: properties,
+ Required: []string{"level", "name", "enforcement", "rules"},
+ },
+ },
+ rulesetWriteScopeAccess(),
+ func(ctx context.Context, deps ToolDependencies, _ *mcp.CallToolRequest, args map[string]any) (*mcp.CallToolResult, any, error) {
+ level, err := RequiredParam[string](args, "level")
+ if err != nil {
+ return utils.NewToolResultError(err.Error()), nil, nil
+ }
+
+ ruleset, errResult := buildRepositoryRulesetFromArgs(args)
+ if errResult != nil {
+ return errResult, nil, nil
+ }
+
+ client, err := deps.GetClient(ctx)
+ if err != nil {
+ return nil, nil, fmt.Errorf("failed to get GitHub client: %w", err)
+ }
+
+ switch level {
+ case "repository":
+ owner, err := RequiredParam[string](args, "owner")
+ if err != nil {
+ return utils.NewToolResultError(err.Error()), nil, nil
+ }
+ repo, err := RequiredParam[string](args, "repo")
+ if err != nil {
+ return utils.NewToolResultError(err.Error()), nil, nil
+ }
+ created, resp, err := client.Repositories.CreateRuleset(ctx, owner, repo, ruleset)
+ if resp != nil {
+ defer func() { _ = resp.Body.Close() }()
+ }
+ if err != nil {
+ return ghErrors.NewGitHubAPIErrorResponse(ctx, "failed to create repository ruleset", resp, err), nil, nil
+ }
+ return MarshalledTextResult(created), nil, nil
+ case "organization":
+ org, err := RequiredParam[string](args, "org")
+ if err != nil {
+ return utils.NewToolResultError(err.Error()), nil, nil
+ }
+ created, resp, err := client.Organizations.CreateRepositoryRuleset(ctx, org, ruleset)
+ if resp != nil {
+ defer func() { _ = resp.Body.Close() }()
+ }
+ if err != nil {
+ return ghErrors.NewGitHubAPIErrorResponse(ctx, "failed to create organization repository ruleset", resp, err), nil, nil
+ }
+ return MarshalledTextResult(created), nil, nil
+ case "enterprise":
+ enterprise, err := RequiredParam[string](args, "enterprise")
+ if err != nil {
+ return utils.NewToolResultError(err.Error()), nil, nil
+ }
+ created, resp, err := client.Enterprise.CreateRepositoryRuleset(ctx, enterprise, ruleset)
+ if resp != nil {
+ defer func() { _ = resp.Body.Close() }()
+ }
+ if err != nil {
+ return ghErrors.NewGitHubAPIErrorResponse(ctx, "failed to create enterprise repository ruleset", resp, err), nil, nil
+ }
+ return MarshalledTextResult(created), nil, nil
+ default:
+ return utils.NewToolResultError(fmt.Sprintf("unknown level: %q (expected 'repository', 'organization', or 'enterprise')", level)), nil, nil
+ }
+ },
+ )
+}
+
+// rulesetWriteProperties returns the shared input schema properties for the
+// ruleset creation tool. Callers add the level-specific identifier
+// properties (owner/repo, org, or enterprise).
+func rulesetWriteProperties() map[string]*jsonschema.Schema {
+ return map[string]*jsonschema.Schema{
+ "name": {
+ Type: "string",
+ Description: "The name of the ruleset",
+ },
+ "enforcement": {
+ Type: "string",
+ Enum: []any{"disabled", "active", "evaluate"},
+ Description: "The enforcement level of the ruleset. 'evaluate' allows admins to test rules before enforcing them",
+ },
+ "target": {
+ Type: "string",
+ Enum: []any{"branch", "tag", "push", "repository"},
+ Description: "The target of the ruleset. Defaults to 'branch'. 'repository' is only valid for 'organization' and 'enterprise' level rulesets.",
+ },
+ "rules": {
+ Type: "array",
+ Description: "An array of rules within the ruleset. Each rule is an object with a 'type' (e.g. 'creation', 'deletion', 'non_fast_forward', 'required_signatures', 'pull_request', 'required_status_checks') and, for rules that need configuration, a 'parameters' object",
+ Items: &jsonschema.Schema{
+ Type: "object",
+ Properties: map[string]*jsonschema.Schema{
+ "type": {
+ Type: "string",
+ Description: "The type of rule, e.g. 'creation', 'deletion', 'non_fast_forward', 'required_signatures', 'pull_request', 'required_status_checks'",
+ },
+ "parameters": {
+ Type: "object",
+ Description: "Parameters for rule types that require additional configuration",
+ },
+ },
+ Required: []string{"type"},
+ },
+ },
+ "conditions": {
+ Type: "object",
+ Description: "Conditions for when this ruleset applies, e.g. {\"ref_name\": {\"include\": [\"refs/heads/main\"], \"exclude\": []}}",
+ },
+ "bypass_actors": {
+ Type: "array",
+ Description: "The actors that can bypass the rules in this ruleset",
+ Items: &jsonschema.Schema{
+ Type: "object",
+ Properties: map[string]*jsonschema.Schema{
+ "actor_id": {
+ Type: "number",
+ Description: "The ID of the actor that can bypass a ruleset",
+ },
+ "actor_type": {
+ Type: "string",
+ Enum: []any{"Integration", "OrganizationAdmin", "RepositoryRole", "Team", "DeployKey", "User", "EnterpriseOwner", "EnterpriseRole"},
+ Description: "The type of actor that can bypass a ruleset. 'EnterpriseOwner' and 'EnterpriseRole' are only valid for 'enterprise' level rulesets.",
+ },
+ "bypass_mode": {
+ Type: "string",
+ Enum: []any{"always", "pull_request", "exempt"},
+ Description: "When the specified actor can bypass the ruleset. 'pull_request' only applies to branch rulesets and is not valid for the 'DeployKey' actor type. 'exempt' means rules are not run for that actor and no bypass audit entry is created.",
+ },
+ },
+ },
+ },
+ }
+}
+
+// buildRepositoryRulesetFromArgs assembles a github.RepositoryRuleset from the
+// shared ruleset creation arguments. It returns a non-nil *mcp.CallToolResult
+// describing the problem when the arguments are invalid.
+func buildRepositoryRulesetFromArgs(args map[string]any) (github.RepositoryRuleset, *mcp.CallToolResult) {
+ name, err := RequiredParam[string](args, "name")
+ if err != nil {
+ return github.RepositoryRuleset{}, utils.NewToolResultError(err.Error())
+ }
+ enforcement, err := RequiredParam[string](args, "enforcement")
+ if err != nil {
+ return github.RepositoryRuleset{}, utils.NewToolResultError(err.Error())
+ }
+ target, err := OptionalParam[string](args, "target")
+ if err != nil {
+ return github.RepositoryRuleset{}, utils.NewToolResultError(err.Error())
+ }
+
+ rules, ok := args["rules"].([]any)
+ if !ok {
+ return github.RepositoryRuleset{}, utils.NewToolResultError("rules parameter must be an array of rule objects")
+ }
+
+ requestedRuleTypes := make([]string, 0, len(rules))
+ requestedRuleParameters := make(map[string]map[string]any, len(rules))
+ seenRuleTypes := make(map[string]bool, len(rules))
+ for _, rule := range rules {
+ ruleMap, ok := rule.(map[string]any)
+ if !ok {
+ return github.RepositoryRuleset{}, utils.NewToolResultError("each rule must be an object with a 'type' field")
+ }
+ ruleType, ok := ruleMap["type"].(string)
+ if !ok || ruleType == "" {
+ return github.RepositoryRuleset{}, utils.NewToolResultError("each rule must have a non-empty string 'type' field")
+ }
+ if seenRuleTypes[ruleType] {
+ // github.RepositoryRulesetRules has a single field per rule type, so a
+ // second rule of the same type would silently overwrite the first
+ // during the round-trip below rather than producing two rules.
+ return github.RepositoryRuleset{}, utils.NewToolResultError(fmt.Sprintf("duplicate rule type: %q (a ruleset may only have one rule of each type)", ruleType))
+ }
+ seenRuleTypes[ruleType] = true
+ requestedRuleTypes = append(requestedRuleTypes, ruleType)
+ if parameters, exists := ruleMap["parameters"]; exists && parameters != nil {
+ parametersMap, ok := parameters.(map[string]any)
+ if !ok {
+ return github.RepositoryRuleset{}, utils.NewToolResultError(fmt.Sprintf("rule %q: parameters must be an object", ruleType))
+ }
+ requestedRuleParameters[ruleType] = parametersMap
+ }
+ }
+
+ payload := map[string]any{
+ "name": name,
+ "enforcement": enforcement,
+ "rules": rules,
+ }
+ if target != "" {
+ payload["target"] = target
+ }
+ if conditions, exists := args["conditions"]; exists && conditions != nil {
+ conditionsMap, ok := conditions.(map[string]any)
+ if !ok {
+ return github.RepositoryRuleset{}, utils.NewToolResultError("conditions parameter must be an object")
+ }
+ payload["conditions"] = conditionsMap
+ }
+ if bypassActors, exists := args["bypass_actors"]; exists && bypassActors != nil {
+ bypassActorsArr, ok := bypassActors.([]any)
+ if !ok {
+ return github.RepositoryRuleset{}, utils.NewToolResultError("bypass_actors parameter must be an array of objects")
+ }
+ for i, actor := range bypassActorsArr {
+ actorMap, ok := actor.(map[string]any)
+ if !ok {
+ return github.RepositoryRuleset{}, utils.NewToolResultError(fmt.Sprintf("bypass_actors[%d] must be an object", i))
+ }
+ // github.BypassActor recognizes only these three keys; any other key
+ // (e.g. a "bypass_modes" typo) is silently discarded by JSON
+ // unmarshal, which would grant the actor the default "always" bypass
+ // mode instead of the caller's intended value.
+ for key := range actorMap {
+ if key != "actor_id" && key != "actor_type" && key != "bypass_mode" {
+ return github.RepositoryRuleset{}, utils.NewToolResultError(fmt.Sprintf("bypass_actors[%d]: unsupported or unrecognized key: %q", i, key))
+ }
+ }
+ }
+ payload["bypass_actors"] = bypassActorsArr
+ }
+
+ raw, err := json.Marshal(payload)
+ if err != nil {
+ return github.RepositoryRuleset{}, utils.NewToolResultErrorFromErr("failed to build ruleset request", err)
+ }
+ var ruleset github.RepositoryRuleset
+ if err := json.Unmarshal(raw, &ruleset); err != nil {
+ return github.RepositoryRuleset{}, utils.NewToolResultErrorFromErr("failed to parse ruleset request", err)
+ }
+
+ // github.RepositoryRulesetRules.UnmarshalJSON silently discards rule types and
+ // rule parameters it does not recognize, which would let a typo (e.g.
+ // "require_code_owners_review" instead of "require_code_owner_review") create
+ // a weaker ruleset than the caller requested. Verify every requested rule
+ // type, and every supplied parameter key within it (recursively), survived
+ // the round-trip.
+ appliedRules, errResult := rulesetAppliedRules(ruleset.Rules)
+ if errResult != nil {
+ return github.RepositoryRuleset{}, errResult
+ }
+ for _, ruleType := range requestedRuleTypes {
+ appliedParameters, ok := appliedRules[ruleType]
+ if !ok {
+ return github.RepositoryRuleset{}, utils.NewToolResultError(fmt.Sprintf("unsupported or unrecognized rule type: %q", ruleType))
+ }
+ if requested := requestedRuleParameters[ruleType]; requested != nil {
+ if droppedPath := droppedKeyPath(requested, appliedParameters); droppedPath != "" {
+ return github.RepositoryRuleset{}, utils.NewToolResultError(fmt.Sprintf("rule %q: unsupported or unrecognized parameter: %q", ruleType, droppedPath))
+ }
+ }
+ }
+
+ // github.RepositoryRulesetConditions has the same silent-drop behavior for
+ // unrecognized keys (e.g. "ref_names" instead of "ref_name"), so verify the
+ // requested conditions survived the round-trip the same way.
+ if requestedConditions, ok := payload["conditions"].(map[string]any); ok {
+ appliedConditions, errResult := rulesetAppliedConditions(ruleset.Conditions)
+ if errResult != nil {
+ return github.RepositoryRuleset{}, errResult
+ }
+ if droppedPath := droppedKeyPath(requestedConditions, appliedConditions); droppedPath != "" {
+ return github.RepositoryRuleset{}, utils.NewToolResultError(fmt.Sprintf("conditions: unsupported or unrecognized key: %q", droppedPath))
+ }
+ }
+
+ return ruleset, nil
+}
+
+// droppedKeyPath recursively compares a caller-supplied object against its
+// round-tripped counterpart and returns the path of the first key or array
+// element that did not survive (e.g. "required_status_checks[0].integration_id"),
+// or "" if everything survived. A caller-supplied value that is a JSON zero
+// value (false, 0, "", or an empty array/object) is exempt, since it is
+// indistinguishable from a field omitted by a `json:",omitempty"` struct tag
+// on the far side of the round-trip. This round-trip is entirely local (our
+// own JSON marshal/unmarshal of a go-github struct, not a remote API
+// response), so slice order and length are preserved deterministically and
+// array elements are safe to compare by index.
+func droppedKeyPath(requested, applied map[string]any) string {
+ for key, requestedValue := range requested {
+ appliedValue, ok := applied[key]
+ if !ok {
+ if isZeroJSONValue(requestedValue) {
+ continue
+ }
+ return key
+ }
+ if nested := droppedValuePath(requestedValue, appliedValue); nested != "" {
+ return key + nested
+ }
+ }
+ return ""
+}
+
+// droppedValuePath recurses into map and array values on behalf of
+// droppedKeyPath. It returns a path suffix beginning with "." (object key) or
+// "[i]" (array index), or "" when requested and applied agree closely enough.
+func droppedValuePath(requested, applied any) string {
+ switch requestedTyped := requested.(type) {
+ case map[string]any:
+ appliedMap, ok := applied.(map[string]any)
+ if !ok {
+ return ""
+ }
+ if nested := droppedKeyPath(requestedTyped, appliedMap); nested != "" {
+ return "." + nested
+ }
+ return ""
+ case []any:
+ appliedArr, ok := applied.([]any)
+ if !ok {
+ return ""
+ }
+ for i, requestedElem := range requestedTyped {
+ if i >= len(appliedArr) {
+ if isZeroJSONValue(requestedElem) {
+ continue
+ }
+ return fmt.Sprintf("[%d]", i)
+ }
+ if nested := droppedValuePath(requestedElem, appliedArr[i]); nested != "" {
+ return fmt.Sprintf("[%d]%s", i, nested)
+ }
+ }
+ return ""
+ default:
+ return ""
+ }
+}
+
+// isZeroJSONValue reports whether v is the JSON zero value for its type
+// (false, 0, "", nil, or an empty array/object). Such values are
+// indistinguishable from an omitted field once round-tripped through a Go
+// struct field tagged `omitempty`.
+func isZeroJSONValue(v any) bool {
+ switch value := v.(type) {
+ case nil:
+ return true
+ case bool:
+ return !value
+ case float64:
+ return value == 0
+ case string:
+ return value == ""
+ case []any:
+ return len(value) == 0
+ case map[string]any:
+ return len(value) == 0
+ default:
+ return false
+ }
+}
+
+// rulesetAppliedRules marshals the parsed rules back to the API's array form
+// and returns, for each rule type that was actually retained, the parameter
+// object the corresponding go-github struct recognized.
+func rulesetAppliedRules(rules *github.RepositoryRulesetRules) (map[string]map[string]any, *mcp.CallToolResult) {
+ applied := map[string]map[string]any{}
+ if rules == nil {
+ return applied, nil
+ }
+ raw, err := json.Marshal(rules)
+ if err != nil {
+ return nil, utils.NewToolResultErrorFromErr("failed to validate ruleset rules", err)
+ }
+ var ruleObjects []struct {
+ Type string `json:"type"`
+ Parameters map[string]any `json:"parameters"`
+ }
+ if err := json.Unmarshal(raw, &ruleObjects); err != nil {
+ return nil, utils.NewToolResultErrorFromErr("failed to validate ruleset rules", err)
+ }
+ for _, rule := range ruleObjects {
+ parameters := rule.Parameters
+ if parameters == nil {
+ parameters = map[string]any{}
+ }
+ applied[rule.Type] = parameters
+ }
+ return applied, nil
+}
+
+// rulesetAppliedConditions marshals the parsed conditions back to the API's
+// object form and returns the keys that were actually retained.
+func rulesetAppliedConditions(conditions *github.RepositoryRulesetConditions) (map[string]any, *mcp.CallToolResult) {
+ if conditions == nil {
+ return map[string]any{}, nil
+ }
+ raw, err := json.Marshal(conditions)
+ if err != nil {
+ return nil, utils.NewToolResultErrorFromErr("failed to validate ruleset conditions", err)
+ }
+ var applied map[string]any
+ if err := json.Unmarshal(raw, &applied); err != nil {
+ return nil, utils.NewToolResultErrorFromErr("failed to validate ruleset conditions", err)
+ }
+ if applied == nil {
+ applied = map[string]any{}
+ }
+ return applied, nil
+}
diff --git a/pkg/github/rulesets_test.go b/pkg/github/rulesets_test.go
new file mode 100644
index 0000000000..46c53825df
--- /dev/null
+++ b/pkg/github/rulesets_test.go
@@ -0,0 +1,1180 @@
+package github
+
+import (
+ "context"
+ "encoding/json"
+ "io"
+ "net/http"
+ "net/url"
+ "testing"
+
+ "github.com/google/go-github/v89/github"
+ "github.com/google/jsonschema-go/jsonschema"
+ "github.com/stretchr/testify/assert"
+ "github.com/stretchr/testify/require"
+
+ "github.com/github/github-mcp-server/internal/toolsnaps"
+ "github.com/github/github-mcp-server/pkg/inventory"
+ "github.com/github/github-mcp-server/pkg/translations"
+)
+
+func Test_RepositoryRulesetRead(t *testing.T) {
+ toolDef := RepositoryRulesetRead(translations.NullTranslationHelper)
+ require.NoError(t, toolsnaps.Test(toolDef.Tool.Name, toolDef.Tool))
+
+ assert.Equal(t, "repository_ruleset_read", toolDef.Tool.Name)
+ assert.NotEmpty(t, toolDef.Tool.Description)
+ assert.True(t, toolDef.Tool.Annotations.ReadOnlyHint)
+
+ schema, ok := toolDef.Tool.InputSchema.(*jsonschema.Schema)
+ require.True(t, ok, "InputSchema should be *jsonschema.Schema")
+ assert.ElementsMatch(t, schema.Required, []string{"level", "method"})
+
+ t.Run("repository level: get defaults includes_parents to true", func(t *testing.T) {
+ var capturedQuery url.Values
+ client := mustNewGHClient(t, MockHTTPClientWithHandlers(map[string]http.HandlerFunc{
+ "GET /repos/{owner}/{repo}/rulesets/{ruleset_id}": func(w http.ResponseWriter, r *http.Request) {
+ capturedQuery = r.URL.Query()
+ mockResponse(t, http.StatusOK, &github.RepositoryRuleset{Name: "main protection", Enforcement: "active"})(w, r)
+ },
+ }))
+ deps := BaseDeps{Client: client}
+ handler := toolDef.Handler(deps)
+ request := createMCPRequest(map[string]any{"level": "repository", "method": "get", "owner": "owner", "repo": "repo", "ruleset_id": float64(42)})
+
+ result, err := handler(ContextWithDeps(context.Background(), deps), &request)
+ require.NoError(t, err)
+ require.False(t, result.IsError)
+ assert.Equal(t, "true", capturedQuery.Get("includes_parents"))
+
+ var returned github.RepositoryRuleset
+ require.NoError(t, json.Unmarshal([]byte(getTextResult(t, result).Text), &returned))
+ assert.Equal(t, "main protection", returned.Name)
+ })
+
+ t.Run("repository level: get forwards explicit includes_parents=false", func(t *testing.T) {
+ var capturedQuery url.Values
+ client := mustNewGHClient(t, MockHTTPClientWithHandlers(map[string]http.HandlerFunc{
+ "GET /repos/{owner}/{repo}/rulesets/{ruleset_id}": func(w http.ResponseWriter, r *http.Request) {
+ capturedQuery = r.URL.Query()
+ mockResponse(t, http.StatusOK, &github.RepositoryRuleset{Name: "rs"})(w, r)
+ },
+ }))
+ deps := BaseDeps{Client: client}
+ handler := toolDef.Handler(deps)
+ request := createMCPRequest(map[string]any{"level": "repository", "method": "get", "owner": "owner", "repo": "repo", "ruleset_id": float64(42), "includes_parents": false})
+
+ result, err := handler(ContextWithDeps(context.Background(), deps), &request)
+ require.NoError(t, err)
+ require.False(t, result.IsError)
+ assert.Equal(t, "false", capturedQuery.Get("includes_parents"))
+ })
+
+ t.Run("repository level: get requires ruleset_id", func(t *testing.T) {
+ client := mustNewGHClient(t, MockHTTPClientWithHandlers(map[string]http.HandlerFunc{}))
+ deps := BaseDeps{Client: client}
+ handler := toolDef.Handler(deps)
+ request := createMCPRequest(map[string]any{"level": "repository", "method": "get", "owner": "owner", "repo": "repo"})
+
+ result, err := handler(ContextWithDeps(context.Background(), deps), &request)
+ require.NoError(t, err)
+ require.True(t, result.IsError)
+ assert.Contains(t, getErrorResult(t, result).Text, "ruleset_id")
+ })
+
+ t.Run("repository level: requires owner and repo", func(t *testing.T) {
+ client := mustNewGHClient(t, MockHTTPClientWithHandlers(map[string]http.HandlerFunc{}))
+ deps := BaseDeps{Client: client}
+ handler := toolDef.Handler(deps)
+ request := createMCPRequest(map[string]any{"level": "repository", "method": "list"})
+
+ result, err := handler(ContextWithDeps(context.Background(), deps), &request)
+ require.NoError(t, err)
+ require.True(t, result.IsError)
+ assert.Contains(t, getErrorResult(t, result).Text, "owner")
+ })
+
+ t.Run("repository level: list omits includes_parents when not provided", func(t *testing.T) {
+ var capturedQuery url.Values
+ client := mustNewGHClient(t, MockHTTPClientWithHandlers(map[string]http.HandlerFunc{
+ "GET /repos/{owner}/{repo}/rulesets": func(w http.ResponseWriter, r *http.Request) {
+ capturedQuery = r.URL.Query()
+ mockResponse(t, http.StatusOK, []*github.RepositoryRuleset{{Name: "rs1"}})(w, r)
+ },
+ }))
+ deps := BaseDeps{Client: client}
+ handler := toolDef.Handler(deps)
+ request := createMCPRequest(map[string]any{"level": "repository", "method": "list", "owner": "owner", "repo": "repo", "perPage": float64(50)})
+
+ result, err := handler(ContextWithDeps(context.Background(), deps), &request)
+ require.NoError(t, err)
+ require.False(t, result.IsError)
+ assert.False(t, capturedQuery.Has("includes_parents"), "includes_parents must not be sent when omitted")
+ assert.Equal(t, "50", capturedQuery.Get("per_page"))
+
+ var returned []*github.RepositoryRuleset
+ require.NoError(t, json.Unmarshal([]byte(getTextResult(t, result).Text), &returned))
+ require.Len(t, returned, 1)
+ assert.Equal(t, "rs1", returned[0].Name)
+ })
+
+ t.Run("repository level: list forwards explicit includes_parents=false", func(t *testing.T) {
+ var capturedQuery url.Values
+ client := mustNewGHClient(t, MockHTTPClientWithHandlers(map[string]http.HandlerFunc{
+ "GET /repos/{owner}/{repo}/rulesets": func(w http.ResponseWriter, r *http.Request) {
+ capturedQuery = r.URL.Query()
+ mockResponse(t, http.StatusOK, []*github.RepositoryRuleset{})(w, r)
+ },
+ }))
+ deps := BaseDeps{Client: client}
+ handler := toolDef.Handler(deps)
+ request := createMCPRequest(map[string]any{"level": "repository", "method": "list", "owner": "owner", "repo": "repo", "includes_parents": false})
+
+ result, err := handler(ContextWithDeps(context.Background(), deps), &request)
+ require.NoError(t, err)
+ require.False(t, result.IsError)
+ assert.Equal(t, "false", capturedQuery.Get("includes_parents"))
+ })
+
+ t.Run("repository level: get_rules_for_branch", func(t *testing.T) {
+ client := mustNewGHClient(t, MockHTTPClientWithHandlers(map[string]http.HandlerFunc{
+ "GET /repos/{owner}/{repo}/rules/branches/{branch}": mockResponse(t, http.StatusOK, []map[string]any{{"type": "creation"}}),
+ }))
+ deps := BaseDeps{Client: client}
+ handler := toolDef.Handler(deps)
+ request := createMCPRequest(map[string]any{"level": "repository", "method": "get_rules_for_branch", "owner": "owner", "repo": "repo", "branch": "main"})
+
+ result, err := handler(ContextWithDeps(context.Background(), deps), &request)
+ require.NoError(t, err)
+ require.False(t, result.IsError)
+ assert.Contains(t, getTextResult(t, result).Text, "Creation")
+ })
+
+ t.Run("repository level: get_rules_for_branch requires branch", func(t *testing.T) {
+ client := mustNewGHClient(t, MockHTTPClientWithHandlers(map[string]http.HandlerFunc{}))
+ deps := BaseDeps{Client: client}
+ handler := toolDef.Handler(deps)
+ request := createMCPRequest(map[string]any{"level": "repository", "method": "get_rules_for_branch", "owner": "owner", "repo": "repo"})
+
+ result, err := handler(ContextWithDeps(context.Background(), deps), &request)
+ require.NoError(t, err)
+ require.True(t, result.IsError)
+ assert.Contains(t, getErrorResult(t, result).Text, "branch")
+ })
+
+ t.Run("repository level: list_rule_suites forwards filters", func(t *testing.T) {
+ var capturedQuery url.Values
+ client := mustNewGHClient(t, MockHTTPClientWithHandlers(map[string]http.HandlerFunc{
+ "GET /repos/{owner}/{repo}/rulesets/rule-suites": func(w http.ResponseWriter, r *http.Request) {
+ capturedQuery = r.URL.Query()
+ mockResponse(t, http.StatusOK, []map[string]any{{"id": 101, "result": "pass"}})(w, r)
+ },
+ }))
+ deps := BaseDeps{Client: client}
+ handler := toolDef.Handler(deps)
+ request := createMCPRequest(map[string]any{
+ "level": "repository",
+ "method": "list_rule_suites",
+ "owner": "owner",
+ "repo": "repo",
+ "ref": "refs/heads/main",
+ "time_period": "week",
+ "actor_name": "octocat",
+ "rule_suite_result": "pass",
+ "perPage": float64(25),
+ })
+
+ result, err := handler(ContextWithDeps(context.Background(), deps), &request)
+ require.NoError(t, err)
+ require.False(t, result.IsError)
+ assert.Contains(t, getTextResult(t, result).Text, "pass")
+
+ assert.Equal(t, "refs/heads/main", capturedQuery.Get("ref"))
+ assert.Equal(t, "week", capturedQuery.Get("time_period"))
+ assert.Equal(t, "octocat", capturedQuery.Get("actor_name"))
+ assert.Equal(t, "pass", capturedQuery.Get("rule_suite_result"))
+ assert.Equal(t, "25", capturedQuery.Get("per_page"))
+ })
+
+ t.Run("repository level: get_rule_suite", func(t *testing.T) {
+ client := mustNewGHClient(t, MockHTTPClientWithHandlers(map[string]http.HandlerFunc{
+ "GET /repos/{owner}/{repo}/rulesets/rule-suites/{rule_suite_id}": mockResponse(t, http.StatusOK, map[string]any{"id": 101, "result": "fail"}),
+ }))
+ deps := BaseDeps{Client: client}
+ handler := toolDef.Handler(deps)
+ request := createMCPRequest(map[string]any{"level": "repository", "method": "get_rule_suite", "owner": "owner", "repo": "repo", "rule_suite_id": float64(101)})
+
+ result, err := handler(ContextWithDeps(context.Background(), deps), &request)
+ require.NoError(t, err)
+ require.False(t, result.IsError)
+ assert.Contains(t, getTextResult(t, result).Text, "fail")
+ })
+
+ t.Run("repository level: unknown method", func(t *testing.T) {
+ client := mustNewGHClient(t, MockHTTPClientWithHandlers(map[string]http.HandlerFunc{}))
+ deps := BaseDeps{Client: client}
+ handler := toolDef.Handler(deps)
+ request := createMCPRequest(map[string]any{"level": "repository", "method": "frobnicate", "owner": "owner", "repo": "repo"})
+
+ result, err := handler(ContextWithDeps(context.Background(), deps), &request)
+ require.NoError(t, err)
+ require.True(t, result.IsError)
+ assert.Contains(t, getErrorResult(t, result).Text, "unknown method")
+ })
+
+ t.Run("organization level: get", func(t *testing.T) {
+ client := mustNewGHClient(t, MockHTTPClientWithHandlers(map[string]http.HandlerFunc{
+ "GET /orgs/{org}/rulesets/{ruleset_id}": mockResponse(t, http.StatusOK, &github.RepositoryRuleset{Name: "org rs", Enforcement: "active"}),
+ }))
+ deps := BaseDeps{Client: client}
+ handler := toolDef.Handler(deps)
+ request := createMCPRequest(map[string]any{"level": "organization", "method": "get", "org": "octo", "ruleset_id": float64(7)})
+
+ result, err := handler(ContextWithDeps(context.Background(), deps), &request)
+ require.NoError(t, err)
+ require.False(t, result.IsError)
+
+ var returned github.RepositoryRuleset
+ require.NoError(t, json.Unmarshal([]byte(getTextResult(t, result).Text), &returned))
+ assert.Equal(t, "org rs", returned.Name)
+ })
+
+ t.Run("organization level: get requires ruleset_id", func(t *testing.T) {
+ client := mustNewGHClient(t, MockHTTPClientWithHandlers(map[string]http.HandlerFunc{}))
+ deps := BaseDeps{Client: client}
+ handler := toolDef.Handler(deps)
+ request := createMCPRequest(map[string]any{"level": "organization", "method": "get", "org": "octo"})
+
+ result, err := handler(ContextWithDeps(context.Background(), deps), &request)
+ require.NoError(t, err)
+ require.True(t, result.IsError)
+ assert.Contains(t, getErrorResult(t, result).Text, "ruleset_id")
+ })
+
+ t.Run("organization level: requires org", func(t *testing.T) {
+ client := mustNewGHClient(t, MockHTTPClientWithHandlers(map[string]http.HandlerFunc{}))
+ deps := BaseDeps{Client: client}
+ handler := toolDef.Handler(deps)
+ request := createMCPRequest(map[string]any{"level": "organization", "method": "list"})
+
+ result, err := handler(ContextWithDeps(context.Background(), deps), &request)
+ require.NoError(t, err)
+ require.True(t, result.IsError)
+ assert.Contains(t, getErrorResult(t, result).Text, "org")
+ })
+
+ t.Run("organization level: list", func(t *testing.T) {
+ client := mustNewGHClient(t, MockHTTPClientWithHandlers(map[string]http.HandlerFunc{
+ "GET /orgs/{org}/rulesets": mockResponse(t, http.StatusOK, []*github.RepositoryRuleset{{Name: "org rs"}}),
+ }))
+ deps := BaseDeps{Client: client}
+ handler := toolDef.Handler(deps)
+ request := createMCPRequest(map[string]any{"level": "organization", "method": "list", "org": "octo"})
+
+ result, err := handler(ContextWithDeps(context.Background(), deps), &request)
+ require.NoError(t, err)
+ require.False(t, result.IsError)
+
+ var returned []*github.RepositoryRuleset
+ require.NoError(t, json.Unmarshal([]byte(getTextResult(t, result).Text), &returned))
+ require.Len(t, returned, 1)
+ assert.Equal(t, "org rs", returned[0].Name)
+ })
+
+ t.Run("organization level: repository-only method defers to normal validation", func(t *testing.T) {
+ client := mustNewGHClient(t, MockHTTPClientWithHandlers(map[string]http.HandlerFunc{}))
+ deps := BaseDeps{Client: client}
+ handler := toolDef.Handler(deps)
+ request := createMCPRequest(map[string]any{"level": "organization", "method": "get_rules_for_branch", "org": "octo"})
+
+ result, err := handler(ContextWithDeps(context.Background(), deps), &request)
+ require.NoError(t, err)
+ require.True(t, result.IsError)
+ assert.Contains(t, getErrorResult(t, result).Text, "not supported for level \"organization\"")
+ })
+
+ t.Run("organization level: unknown method", func(t *testing.T) {
+ client := mustNewGHClient(t, MockHTTPClientWithHandlers(map[string]http.HandlerFunc{}))
+ deps := BaseDeps{Client: client}
+ handler := toolDef.Handler(deps)
+ request := createMCPRequest(map[string]any{"level": "organization", "method": "frobnicate", "org": "octo"})
+
+ result, err := handler(ContextWithDeps(context.Background(), deps), &request)
+ require.NoError(t, err)
+ require.True(t, result.IsError)
+ assert.Contains(t, getErrorResult(t, result).Text, "not supported for level")
+ })
+
+ t.Run("enterprise level: get", func(t *testing.T) {
+ client := mustNewGHClient(t, MockHTTPClientWithHandlers(map[string]http.HandlerFunc{
+ "GET /enterprises/{enterprise}/rulesets/{ruleset_id}": mockResponse(t, http.StatusOK, &github.RepositoryRuleset{Name: "enterprise rs", Enforcement: "active"}),
+ }))
+ deps := BaseDeps{Client: client}
+ handler := toolDef.Handler(deps)
+ request := createMCPRequest(map[string]any{"level": "enterprise", "method": "get", "enterprise": "acme", "ruleset_id": float64(9)})
+
+ result, err := handler(ContextWithDeps(context.Background(), deps), &request)
+ require.NoError(t, err)
+ require.False(t, result.IsError)
+
+ var returned github.RepositoryRuleset
+ require.NoError(t, json.Unmarshal([]byte(getTextResult(t, result).Text), &returned))
+ assert.Equal(t, "enterprise rs", returned.Name)
+ })
+
+ t.Run("enterprise level: requires enterprise", func(t *testing.T) {
+ client := mustNewGHClient(t, MockHTTPClientWithHandlers(map[string]http.HandlerFunc{}))
+ deps := BaseDeps{Client: client}
+ handler := toolDef.Handler(deps)
+ request := createMCPRequest(map[string]any{"level": "enterprise", "method": "list"})
+
+ result, err := handler(ContextWithDeps(context.Background(), deps), &request)
+ require.NoError(t, err)
+ require.True(t, result.IsError)
+ assert.Contains(t, getErrorResult(t, result).Text, "enterprise")
+ })
+
+ t.Run("enterprise level: list", func(t *testing.T) {
+ client := mustNewGHClient(t, MockHTTPClientWithHandlers(map[string]http.HandlerFunc{
+ "GET /enterprises/{enterprise}/rulesets": mockResponse(t, http.StatusOK, []*github.RepositoryRuleset{{Name: "enterprise rs"}}),
+ }))
+ deps := BaseDeps{Client: client}
+ handler := toolDef.Handler(deps)
+ request := createMCPRequest(map[string]any{"level": "enterprise", "method": "list", "enterprise": "acme"})
+
+ result, err := handler(ContextWithDeps(context.Background(), deps), &request)
+ require.NoError(t, err)
+ require.False(t, result.IsError)
+
+ var returned []*github.RepositoryRuleset
+ require.NoError(t, json.Unmarshal([]byte(getTextResult(t, result).Text), &returned))
+ require.Len(t, returned, 1)
+ assert.Equal(t, "enterprise rs", returned[0].Name)
+ })
+
+ t.Run("enterprise level: repository-only method defers to normal validation", func(t *testing.T) {
+ client := mustNewGHClient(t, MockHTTPClientWithHandlers(map[string]http.HandlerFunc{}))
+ deps := BaseDeps{Client: client}
+ handler := toolDef.Handler(deps)
+ request := createMCPRequest(map[string]any{"level": "enterprise", "method": "list_rule_suites", "enterprise": "acme"})
+
+ result, err := handler(ContextWithDeps(context.Background(), deps), &request)
+ require.NoError(t, err)
+ require.True(t, result.IsError)
+ assert.Contains(t, getErrorResult(t, result).Text, "not supported for level \"enterprise\"")
+ })
+
+ t.Run("unknown level defers to normal validation", func(t *testing.T) {
+ client := mustNewGHClient(t, MockHTTPClientWithHandlers(map[string]http.HandlerFunc{}))
+ deps := BaseDeps{Client: client}
+ handler := toolDef.Handler(deps)
+ request := createMCPRequest(map[string]any{"level": "planet", "method": "list"})
+
+ result, err := handler(ContextWithDeps(context.Background(), deps), &request)
+ require.NoError(t, err)
+ require.True(t, result.IsError)
+ assert.Contains(t, getErrorResult(t, result).Text, "unknown level")
+ })
+
+ t.Run("mismatched-case level is rejected rather than silently normalized", func(t *testing.T) {
+ // The scope challenge in rulesetReadScopeAccess matches "level" with an
+ // exact, case-sensitive comparison. If the handler instead normalized case
+ // (e.g. via strings.ToLower) before dispatching, a caller could send
+ // "Organization" to reach the organization-level read while the OAuth
+ // middleware -- which sees the raw, un-normalized argument -- would find no
+ // case matching "organization" and issue no scope challenge at all,
+ // letting an under-scoped token read organization rulesets for free.
+ called := false
+ client := mustNewGHClient(t, MockHTTPClientWithHandlers(map[string]http.HandlerFunc{
+ "GET /orgs/{org}/rulesets": func(w http.ResponseWriter, r *http.Request) {
+ called = true
+ mockResponse(t, http.StatusOK, []*github.RepositoryRuleset{})(w, r)
+ },
+ }))
+ deps := BaseDeps{Client: client}
+ handler := toolDef.Handler(deps)
+ request := createMCPRequest(map[string]any{"level": "Organization", "method": "list", "org": "octo"})
+
+ result, err := handler(ContextWithDeps(context.Background(), deps), &request)
+ require.NoError(t, err)
+ require.True(t, result.IsError)
+ assert.Contains(t, getErrorResult(t, result).Text, "unknown level")
+ assert.False(t, called, "a mismatched-case level must not reach the organization-level API call")
+
+ // The scope challenge must independently agree: it must not treat
+ // "Organization" as a recognized level either.
+ assert.Empty(t, toolDef.ScopeAccess.Challenge(map[string]any{"level": "Organization"}, nil))
+ })
+}
+
+func Test_CreateRepositoryRuleset(t *testing.T) {
+ toolDef := CreateRepositoryRuleset(translations.NullTranslationHelper)
+ require.NoError(t, toolsnaps.Test(toolDef.Tool.Name, toolDef.Tool))
+
+ assert.Equal(t, "create_repository_ruleset", toolDef.Tool.Name)
+ assert.False(t, toolDef.Tool.Annotations.ReadOnlyHint)
+
+ schema, ok := toolDef.Tool.InputSchema.(*jsonschema.Schema)
+ require.True(t, ok)
+ assert.ElementsMatch(t, schema.Required, []string{"level", "name", "enforcement", "rules"})
+
+ t.Run("repository level", func(t *testing.T) {
+ var capturedBody github.RepositoryRuleset
+ var capturedRaw []byte
+ client := mustNewGHClient(t, MockHTTPClientWithHandlers(map[string]http.HandlerFunc{
+ "POST /repos/{owner}/{repo}/rulesets": func(w http.ResponseWriter, r *http.Request) {
+ body, _ := io.ReadAll(r.Body)
+ capturedRaw = body
+ _ = json.Unmarshal(body, &capturedBody)
+ w.WriteHeader(http.StatusCreated)
+ _, _ = w.Write(body)
+ },
+ }))
+ deps := BaseDeps{Client: client}
+ handler := toolDef.Handler(deps)
+ request := createMCPRequest(map[string]any{
+ "level": "repository",
+ "owner": "owner",
+ "repo": "repo",
+ "name": "main protection",
+ "enforcement": "active",
+ "target": "branch",
+ "rules": []any{
+ map[string]any{"type": "creation"},
+ map[string]any{"type": "deletion"},
+ map[string]any{
+ "type": "pull_request",
+ "parameters": map[string]any{
+ "required_approving_review_count": float64(2),
+ },
+ },
+ },
+ "conditions": map[string]any{
+ "ref_name": map[string]any{
+ "include": []any{"refs/heads/main"},
+ "exclude": []any{},
+ },
+ },
+ })
+
+ result, err := handler(ContextWithDeps(context.Background(), deps), &request)
+ require.NoError(t, err)
+ require.False(t, result.IsError)
+
+ assert.Equal(t, "main protection", capturedBody.Name)
+ assert.Equal(t, github.RulesetEnforcement("active"), capturedBody.Enforcement)
+ require.NotNil(t, capturedBody.Rules)
+
+ // Verify the outbound body preserves all requested rules and the pull_request
+ // parameters, rather than silently dropping them in the JSON round-trip.
+ var outbound struct {
+ Rules []struct {
+ Type string `json:"type"`
+ Parameters map[string]any `json:"parameters"`
+ } `json:"rules"`
+ Conditions struct {
+ RefName struct {
+ Include []string `json:"include"`
+ } `json:"ref_name"`
+ } `json:"conditions"`
+ }
+ require.NoError(t, json.Unmarshal(capturedRaw, &outbound))
+
+ sentTypes := make([]string, 0, len(outbound.Rules))
+ var pullRequestParams map[string]any
+ for _, rule := range outbound.Rules {
+ sentTypes = append(sentTypes, rule.Type)
+ if rule.Type == "pull_request" {
+ pullRequestParams = rule.Parameters
+ }
+ }
+ assert.ElementsMatch(t, []string{"creation", "deletion", "pull_request"}, sentTypes)
+ require.NotNil(t, pullRequestParams)
+ assert.EqualValues(t, 2, pullRequestParams["required_approving_review_count"])
+ assert.Equal(t, []string{"refs/heads/main"}, outbound.Conditions.RefName.Include)
+ })
+
+ t.Run("repository level requires owner and repo", func(t *testing.T) {
+ client := mustNewGHClient(t, MockHTTPClientWithHandlers(map[string]http.HandlerFunc{}))
+ deps := BaseDeps{Client: client}
+ handler := toolDef.Handler(deps)
+ request := createMCPRequest(map[string]any{
+ "level": "repository",
+ "name": "x",
+ "enforcement": "active",
+ "rules": []any{map[string]any{"type": "creation"}},
+ })
+
+ result, err := handler(ContextWithDeps(context.Background(), deps), &request)
+ require.NoError(t, err)
+ require.True(t, result.IsError)
+ assert.Contains(t, getErrorResult(t, result).Text, "owner")
+ })
+
+ t.Run("unsupported rule type", func(t *testing.T) {
+ called := false
+ client := mustNewGHClient(t, MockHTTPClientWithHandlers(map[string]http.HandlerFunc{
+ "POST /repos/{owner}/{repo}/rulesets": func(w http.ResponseWriter, _ *http.Request) {
+ called = true
+ w.WriteHeader(http.StatusCreated)
+ },
+ }))
+ deps := BaseDeps{Client: client}
+ handler := toolDef.Handler(deps)
+ request := createMCPRequest(map[string]any{
+ "level": "repository",
+ "owner": "owner",
+ "repo": "repo",
+ "name": "x",
+ "enforcement": "active",
+ "rules": []any{
+ map[string]any{"type": "creation"},
+ map[string]any{"type": "totally_made_up_rule"},
+ },
+ })
+
+ result, err := handler(ContextWithDeps(context.Background(), deps), &request)
+ require.NoError(t, err)
+ require.True(t, result.IsError)
+ assert.Contains(t, getErrorResult(t, result).Text, "totally_made_up_rule")
+ assert.False(t, called, "request must not be sent when a rule type is unsupported")
+ })
+
+ t.Run("invalid rules", func(t *testing.T) {
+ client := mustNewGHClient(t, MockHTTPClientWithHandlers(map[string]http.HandlerFunc{}))
+ deps := BaseDeps{Client: client}
+ handler := toolDef.Handler(deps)
+ request := createMCPRequest(map[string]any{
+ "level": "repository",
+ "owner": "owner",
+ "repo": "repo",
+ "name": "x",
+ "enforcement": "active",
+ "rules": "not-an-array",
+ })
+
+ result, err := handler(ContextWithDeps(context.Background(), deps), &request)
+ require.NoError(t, err)
+ require.True(t, result.IsError)
+ assert.Contains(t, getErrorResult(t, result).Text, "rules parameter must be an array")
+ })
+
+ t.Run("duplicate rule type", func(t *testing.T) {
+ called := false
+ client := mustNewGHClient(t, MockHTTPClientWithHandlers(map[string]http.HandlerFunc{
+ "POST /repos/{owner}/{repo}/rulesets": func(w http.ResponseWriter, _ *http.Request) {
+ called = true
+ w.WriteHeader(http.StatusCreated)
+ },
+ }))
+ deps := BaseDeps{Client: client}
+ handler := toolDef.Handler(deps)
+ request := createMCPRequest(map[string]any{
+ "level": "repository",
+ "owner": "owner",
+ "repo": "repo",
+ "name": "x",
+ "enforcement": "active",
+ "rules": []any{
+ map[string]any{"type": "creation"},
+ map[string]any{"type": "creation"},
+ },
+ })
+
+ result, err := handler(ContextWithDeps(context.Background(), deps), &request)
+ require.NoError(t, err)
+ require.True(t, result.IsError)
+ assert.Contains(t, getErrorResult(t, result).Text, "duplicate rule type")
+ assert.False(t, called, "request must not be sent when rules contain a duplicate type")
+ })
+
+ t.Run("unrecognized rule parameter is rejected even though the rule type is valid", func(t *testing.T) {
+ // "require_code_owners_review" is a plausible typo for the real
+ // pull_request parameter "require_code_owner_review". go-github's
+ // generated UnmarshalJSON silently drops unknown parameter keys, so
+ // without this check the ruleset would be created with the weaker
+ // default (false) instead of surfacing the mistake.
+ called := false
+ client := mustNewGHClient(t, MockHTTPClientWithHandlers(map[string]http.HandlerFunc{
+ "POST /repos/{owner}/{repo}/rulesets": func(w http.ResponseWriter, _ *http.Request) {
+ called = true
+ w.WriteHeader(http.StatusCreated)
+ },
+ }))
+ deps := BaseDeps{Client: client}
+ handler := toolDef.Handler(deps)
+ request := createMCPRequest(map[string]any{
+ "level": "repository",
+ "owner": "owner",
+ "repo": "repo",
+ "name": "x",
+ "enforcement": "active",
+ "rules": []any{
+ map[string]any{
+ "type": "pull_request",
+ "parameters": map[string]any{
+ "require_code_owners_review": true, // typo: should be require_code_owner_review
+ },
+ },
+ },
+ })
+
+ result, err := handler(ContextWithDeps(context.Background(), deps), &request)
+ require.NoError(t, err)
+ require.True(t, result.IsError)
+ assert.Contains(t, getErrorResult(t, result).Text, "require_code_owners_review")
+ assert.False(t, called, "request must not be sent when a rule parameter is unrecognized")
+ })
+
+ t.Run("zero-valued parameters are not flagged as unrecognized", func(t *testing.T) {
+ // Scalar fields without `omitempty` (like required_approving_review_count)
+ // always round-trip, but slice fields with `omitempty` (like
+ // allowed_merge_methods) vanish from the response when empty. An
+ // explicit zero value supplied by the caller must not be misread as an
+ // unsupported parameter key.
+ var capturedBody []byte
+ client := mustNewGHClient(t, MockHTTPClientWithHandlers(map[string]http.HandlerFunc{
+ "POST /repos/{owner}/{repo}/rulesets": func(w http.ResponseWriter, r *http.Request) {
+ capturedBody, _ = io.ReadAll(r.Body)
+ w.WriteHeader(http.StatusCreated)
+ _, _ = w.Write(capturedBody)
+ },
+ }))
+ deps := BaseDeps{Client: client}
+ handler := toolDef.Handler(deps)
+ request := createMCPRequest(map[string]any{
+ "level": "repository",
+ "owner": "owner",
+ "repo": "repo",
+ "name": "x",
+ "enforcement": "active",
+ "rules": []any{
+ map[string]any{
+ "type": "pull_request",
+ "parameters": map[string]any{
+ "required_approving_review_count": float64(0),
+ "allowed_merge_methods": []any{},
+ },
+ },
+ },
+ })
+
+ result, err := handler(ContextWithDeps(context.Background(), deps), &request)
+ require.NoError(t, err)
+ if result.IsError {
+ t.Fatalf("unexpected error: %s", getErrorResult(t, result).Text)
+ }
+ assert.NotEmpty(t, capturedBody)
+ })
+
+ t.Run("unrecognized key inside a rule parameter array element is rejected", func(t *testing.T) {
+ // "integration_ids" is a plausible typo for the real per-check field
+ // "integration_id" on required_status_checks[]. Unlike the top-level
+ // rule/condition round-trip, this array is produced by our own local
+ // JSON marshal/unmarshal of the go-github struct (not a remote API
+ // response), so element order is guaranteed stable and comparing by
+ // index is safe. Without this check, the typo would silently vanish and
+ // the resulting rule would accept a status check from any integration
+ // instead of only the one requested.
+ called := false
+ client := mustNewGHClient(t, MockHTTPClientWithHandlers(map[string]http.HandlerFunc{
+ "POST /repos/{owner}/{repo}/rulesets": func(w http.ResponseWriter, _ *http.Request) {
+ called = true
+ w.WriteHeader(http.StatusCreated)
+ },
+ }))
+ deps := BaseDeps{Client: client}
+ handler := toolDef.Handler(deps)
+ request := createMCPRequest(map[string]any{
+ "level": "repository",
+ "owner": "owner",
+ "repo": "repo",
+ "name": "x",
+ "enforcement": "active",
+ "rules": []any{
+ map[string]any{
+ "type": "required_status_checks",
+ "parameters": map[string]any{
+ "required_status_checks": []any{
+ map[string]any{
+ "context": "ci",
+ "integration_ids": float64(42), // typo: should be integration_id
+ },
+ },
+ "strict_required_status_checks_policy": true,
+ },
+ },
+ },
+ })
+
+ result, err := handler(ContextWithDeps(context.Background(), deps), &request)
+ require.NoError(t, err)
+ require.True(t, result.IsError)
+ assert.Contains(t, getErrorResult(t, result).Text, "required_status_checks[0].integration_ids")
+ assert.False(t, called, "request must not be sent when a nested array element key is unrecognized")
+ })
+
+ t.Run("valid rule parameter array elements round-trip and are not misflagged", func(t *testing.T) {
+ var capturedBody []byte
+ client := mustNewGHClient(t, MockHTTPClientWithHandlers(map[string]http.HandlerFunc{
+ "POST /repos/{owner}/{repo}/rulesets": func(w http.ResponseWriter, r *http.Request) {
+ capturedBody, _ = io.ReadAll(r.Body)
+ w.WriteHeader(http.StatusCreated)
+ _, _ = w.Write(capturedBody)
+ },
+ }))
+ deps := BaseDeps{Client: client}
+ handler := toolDef.Handler(deps)
+ request := createMCPRequest(map[string]any{
+ "level": "repository",
+ "owner": "owner",
+ "repo": "repo",
+ "name": "x",
+ "enforcement": "active",
+ "rules": []any{
+ map[string]any{
+ "type": "required_status_checks",
+ "parameters": map[string]any{
+ "required_status_checks": []any{
+ map[string]any{
+ "context": "ci",
+ "integration_id": float64(42),
+ },
+ },
+ "strict_required_status_checks_policy": true,
+ },
+ },
+ },
+ })
+
+ result, err := handler(ContextWithDeps(context.Background(), deps), &request)
+ require.NoError(t, err)
+ if result.IsError {
+ t.Fatalf("unexpected error: %s", getErrorResult(t, result).Text)
+ }
+ assert.NotEmpty(t, capturedBody)
+ })
+
+ t.Run("bypass_actors accepts exempt bypass mode and enterprise actor types", func(t *testing.T) {
+ var capturedBody github.RepositoryRuleset
+ client := mustNewGHClient(t, MockHTTPClientWithHandlers(map[string]http.HandlerFunc{
+ "POST /enterprises/{enterprise}/rulesets": func(w http.ResponseWriter, r *http.Request) {
+ body, _ := io.ReadAll(r.Body)
+ _ = json.Unmarshal(body, &capturedBody)
+ w.WriteHeader(http.StatusCreated)
+ _, _ = w.Write(body)
+ },
+ }))
+ deps := BaseDeps{Client: client}
+ handler := toolDef.Handler(deps)
+ request := createMCPRequest(map[string]any{
+ "level": "enterprise",
+ "enterprise": "acme",
+ "name": "enterprise protection",
+ "enforcement": "active",
+ "rules": []any{map[string]any{"type": "creation"}},
+ "bypass_actors": []any{
+ map[string]any{"actor_type": "EnterpriseOwner", "bypass_mode": "exempt"},
+ },
+ })
+
+ result, err := handler(ContextWithDeps(context.Background(), deps), &request)
+ require.NoError(t, err)
+ require.False(t, result.IsError)
+ require.Len(t, capturedBody.BypassActors, 1)
+ assert.Equal(t, github.BypassActorType("EnterpriseOwner"), *capturedBody.BypassActors[0].ActorType)
+ assert.Equal(t, github.BypassMode("exempt"), *capturedBody.BypassActors[0].BypassMode)
+ })
+
+ t.Run("unrecognized bypass_actors key is rejected", func(t *testing.T) {
+ // "bypass_modes" is a plausible typo for "bypass_mode". github.BypassActor
+ // only recognizes actor_id/actor_type/bypass_mode, so an unknown key is
+ // silently discarded during JSON unmarshal -- and because the API
+ // defaults an omitted bypass_mode to "always", the resulting actor would
+ // get broader bypass rights than the caller requested.
+ called := false
+ client := mustNewGHClient(t, MockHTTPClientWithHandlers(map[string]http.HandlerFunc{
+ "POST /repos/{owner}/{repo}/rulesets": func(w http.ResponseWriter, _ *http.Request) {
+ called = true
+ w.WriteHeader(http.StatusCreated)
+ },
+ }))
+ deps := BaseDeps{Client: client}
+ handler := toolDef.Handler(deps)
+ request := createMCPRequest(map[string]any{
+ "level": "repository",
+ "owner": "owner",
+ "repo": "repo",
+ "name": "x",
+ "enforcement": "active",
+ "rules": []any{map[string]any{"type": "creation"}},
+ "bypass_actors": []any{
+ map[string]any{"actor_type": "Team", "actor_id": float64(1), "bypass_modes": "pull_request"},
+ },
+ })
+
+ result, err := handler(ContextWithDeps(context.Background(), deps), &request)
+ require.NoError(t, err)
+ require.True(t, result.IsError)
+ assert.Contains(t, getErrorResult(t, result).Text, "bypass_modes")
+ assert.False(t, called, "request must not be sent when a bypass_actors key is unrecognized")
+ })
+
+ t.Run("unrecognized top-level condition key is rejected", func(t *testing.T) {
+ // "ref_names" is a plausible typo for the real condition key "ref_name".
+ // github.RepositoryRulesetConditions silently drops unknown keys during
+ // JSON unmarshal, so without this check the ruleset would be created with
+ // no ref_name condition at all (applying to every ref) instead of
+ // surfacing the mistake.
+ called := false
+ client := mustNewGHClient(t, MockHTTPClientWithHandlers(map[string]http.HandlerFunc{
+ "POST /repos/{owner}/{repo}/rulesets": func(w http.ResponseWriter, _ *http.Request) {
+ called = true
+ w.WriteHeader(http.StatusCreated)
+ },
+ }))
+ deps := BaseDeps{Client: client}
+ handler := toolDef.Handler(deps)
+ request := createMCPRequest(map[string]any{
+ "level": "repository",
+ "owner": "owner",
+ "repo": "repo",
+ "name": "x",
+ "enforcement": "active",
+ "rules": []any{map[string]any{"type": "creation"}},
+ "conditions": map[string]any{
+ "ref_names": map[string]any{ // typo: should be ref_name
+ "include": []any{"refs/heads/main"},
+ "exclude": []any{},
+ },
+ },
+ })
+
+ result, err := handler(ContextWithDeps(context.Background(), deps), &request)
+ require.NoError(t, err)
+ require.True(t, result.IsError)
+ assert.Contains(t, getErrorResult(t, result).Text, "ref_names")
+ assert.False(t, called, "request must not be sent when a condition key is unrecognized")
+ })
+
+ t.Run("unrecognized nested condition key is rejected", func(t *testing.T) {
+ called := false
+ client := mustNewGHClient(t, MockHTTPClientWithHandlers(map[string]http.HandlerFunc{
+ "POST /repos/{owner}/{repo}/rulesets": func(w http.ResponseWriter, _ *http.Request) {
+ called = true
+ w.WriteHeader(http.StatusCreated)
+ },
+ }))
+ deps := BaseDeps{Client: client}
+ handler := toolDef.Handler(deps)
+ request := createMCPRequest(map[string]any{
+ "level": "repository",
+ "owner": "owner",
+ "repo": "repo",
+ "name": "x",
+ "enforcement": "active",
+ "rules": []any{map[string]any{"type": "creation"}},
+ "conditions": map[string]any{
+ "ref_name": map[string]any{
+ "includes": []any{"refs/heads/main"}, // typo: should be include
+ },
+ },
+ })
+
+ result, err := handler(ContextWithDeps(context.Background(), deps), &request)
+ require.NoError(t, err)
+ require.True(t, result.IsError)
+ assert.Contains(t, getErrorResult(t, result).Text, "ref_name.includes")
+ assert.False(t, called, "request must not be sent when a nested condition key is unrecognized")
+ })
+
+ t.Run("valid conditions round-trip and are not misflagged", func(t *testing.T) {
+ var capturedBody []byte
+ client := mustNewGHClient(t, MockHTTPClientWithHandlers(map[string]http.HandlerFunc{
+ "POST /repos/{owner}/{repo}/rulesets": func(w http.ResponseWriter, r *http.Request) {
+ capturedBody, _ = io.ReadAll(r.Body)
+ w.WriteHeader(http.StatusCreated)
+ _, _ = w.Write(capturedBody)
+ },
+ }))
+ deps := BaseDeps{Client: client}
+ handler := toolDef.Handler(deps)
+ request := createMCPRequest(map[string]any{
+ "level": "repository",
+ "owner": "owner",
+ "repo": "repo",
+ "name": "x",
+ "enforcement": "active",
+ "rules": []any{map[string]any{"type": "creation"}},
+ "conditions": map[string]any{
+ "ref_name": map[string]any{
+ "include": []any{"refs/heads/main"},
+ "exclude": []any{},
+ },
+ },
+ })
+
+ result, err := handler(ContextWithDeps(context.Background(), deps), &request)
+ require.NoError(t, err)
+ if result.IsError {
+ t.Fatalf("unexpected error: %s", getErrorResult(t, result).Text)
+ }
+ assert.NotEmpty(t, capturedBody)
+ })
+
+ t.Run("organization level", func(t *testing.T) {
+ client := mustNewGHClient(t, MockHTTPClientWithHandlers(map[string]http.HandlerFunc{
+ "POST /orgs/{org}/rulesets": func(w http.ResponseWriter, r *http.Request) {
+ body, _ := io.ReadAll(r.Body)
+ w.WriteHeader(http.StatusCreated)
+ _, _ = w.Write(body)
+ },
+ }))
+ deps := BaseDeps{Client: client}
+ handler := toolDef.Handler(deps)
+ request := createMCPRequest(map[string]any{
+ "level": "organization",
+ "org": "octo",
+ "name": "org protection",
+ "enforcement": "active",
+ "rules": []any{map[string]any{"type": "creation"}},
+ })
+
+ result, err := handler(ContextWithDeps(context.Background(), deps), &request)
+ require.NoError(t, err)
+ require.False(t, result.IsError)
+
+ var returned github.RepositoryRuleset
+ require.NoError(t, json.Unmarshal([]byte(getTextResult(t, result).Text), &returned))
+ assert.Equal(t, "org protection", returned.Name)
+ })
+
+ t.Run("organization level requires org", func(t *testing.T) {
+ client := mustNewGHClient(t, MockHTTPClientWithHandlers(map[string]http.HandlerFunc{}))
+ deps := BaseDeps{Client: client}
+ handler := toolDef.Handler(deps)
+ request := createMCPRequest(map[string]any{
+ "level": "organization",
+ "name": "org protection",
+ "enforcement": "active",
+ "rules": []any{map[string]any{"type": "creation"}},
+ })
+
+ result, err := handler(ContextWithDeps(context.Background(), deps), &request)
+ require.NoError(t, err)
+ require.True(t, result.IsError)
+ assert.Contains(t, getErrorResult(t, result).Text, "org")
+ })
+
+ t.Run("enterprise level", func(t *testing.T) {
+ client := mustNewGHClient(t, MockHTTPClientWithHandlers(map[string]http.HandlerFunc{
+ "POST /enterprises/{enterprise}/rulesets": func(w http.ResponseWriter, r *http.Request) {
+ body, _ := io.ReadAll(r.Body)
+ w.WriteHeader(http.StatusCreated)
+ _, _ = w.Write(body)
+ },
+ }))
+ deps := BaseDeps{Client: client}
+ handler := toolDef.Handler(deps)
+ request := createMCPRequest(map[string]any{
+ "level": "enterprise",
+ "enterprise": "acme",
+ "name": "enterprise protection",
+ "enforcement": "active",
+ "rules": []any{map[string]any{"type": "creation"}},
+ })
+
+ result, err := handler(ContextWithDeps(context.Background(), deps), &request)
+ require.NoError(t, err)
+ require.False(t, result.IsError)
+
+ var returned github.RepositoryRuleset
+ require.NoError(t, json.Unmarshal([]byte(getTextResult(t, result).Text), &returned))
+ assert.Equal(t, "enterprise protection", returned.Name)
+ })
+
+ t.Run("enterprise level requires enterprise", func(t *testing.T) {
+ client := mustNewGHClient(t, MockHTTPClientWithHandlers(map[string]http.HandlerFunc{}))
+ deps := BaseDeps{Client: client}
+ handler := toolDef.Handler(deps)
+ request := createMCPRequest(map[string]any{
+ "level": "enterprise",
+ "name": "enterprise protection",
+ "enforcement": "active",
+ "rules": []any{map[string]any{"type": "creation"}},
+ })
+
+ result, err := handler(ContextWithDeps(context.Background(), deps), &request)
+ require.NoError(t, err)
+ require.True(t, result.IsError)
+ assert.Contains(t, getErrorResult(t, result).Text, "enterprise")
+ })
+
+ t.Run("unknown level", func(t *testing.T) {
+ client := mustNewGHClient(t, MockHTTPClientWithHandlers(map[string]http.HandlerFunc{}))
+ deps := BaseDeps{Client: client}
+ handler := toolDef.Handler(deps)
+ request := createMCPRequest(map[string]any{
+ "level": "planet",
+ "name": "x",
+ "enforcement": "active",
+ "rules": []any{map[string]any{"type": "creation"}},
+ })
+
+ result, err := handler(ContextWithDeps(context.Background(), deps), &request)
+ require.NoError(t, err)
+ require.True(t, result.IsError)
+ assert.Contains(t, getErrorResult(t, result).Text, "unknown level")
+ })
+
+ t.Run("mismatched-case level is rejected rather than silently normalized", func(t *testing.T) {
+ // Mirrors the read-tool regression above: rulesetWriteScopeAccess only
+ // recognizes an exact, lowercase "organization"/"enterprise" match. If the
+ // handler normalized case before dispatching, "Organization" would reach
+ // client.Organizations.CreateRepositoryRuleset while the OAuth middleware
+ // -- which challenges on the raw argument -- would see no case match and
+ // require no admin:org scope at all, letting an under-scoped token create
+ // organization-wide rulesets for free.
+ called := false
+ client := mustNewGHClient(t, MockHTTPClientWithHandlers(map[string]http.HandlerFunc{
+ "POST /orgs/{org}/rulesets": func(w http.ResponseWriter, _ *http.Request) {
+ called = true
+ w.WriteHeader(http.StatusCreated)
+ },
+ }))
+ deps := BaseDeps{Client: client}
+ handler := toolDef.Handler(deps)
+ request := createMCPRequest(map[string]any{
+ "level": "Organization",
+ "org": "octo",
+ "name": "org protection",
+ "enforcement": "active",
+ "rules": []any{map[string]any{"type": "creation"}},
+ })
+
+ result, err := handler(ContextWithDeps(context.Background(), deps), &request)
+ require.NoError(t, err)
+ require.True(t, result.IsError)
+ assert.Contains(t, getErrorResult(t, result).Text, "unknown level")
+ assert.False(t, called, "a mismatched-case level must not reach the organization-level create call")
+
+ assert.Empty(t, toolDef.ScopeAccess.Challenge(map[string]any{"level": "Organization"}, nil))
+ })
+}
+
+// Test_RulesetScopeChallenges verifies that the ruleset read and write tools
+// challenge for the exact scope implied by the "level" argument, and defer to
+// normal handler validation (no challenge) when "level" is missing or not a
+// string.
+func Test_RulesetScopeChallenges(t *testing.T) {
+ tests := []struct {
+ name string
+ tool inventory.ServerTool
+ arguments map[string]any
+ allowed []string
+ disallowed []string
+ }{
+ {
+ name: "read repository level",
+ tool: RepositoryRulesetRead(translations.NullTranslationHelper),
+ arguments: map[string]any{"level": "repository", "method": "get"},
+ allowed: []string{"repo"},
+ disallowed: []string{"read:org"},
+ },
+ {
+ name: "read organization level",
+ tool: RepositoryRulesetRead(translations.NullTranslationHelper),
+ arguments: map[string]any{"level": "organization", "method": "list"},
+ allowed: []string{"read:org"},
+ disallowed: []string{"repo"},
+ },
+ {
+ name: "read enterprise level",
+ tool: RepositoryRulesetRead(translations.NullTranslationHelper),
+ arguments: map[string]any{"level": "enterprise", "method": "get"},
+ allowed: []string{"read:enterprise"},
+ disallowed: []string{"repo", "read:org"},
+ },
+ {
+ name: "read missing level defers to validation",
+ tool: RepositoryRulesetRead(translations.NullTranslationHelper),
+ arguments: map[string]any{"method": "get"},
+ allowed: nil,
+ disallowed: nil,
+ },
+ {
+ name: "read unknown level defers to validation",
+ tool: RepositoryRulesetRead(translations.NullTranslationHelper),
+ arguments: map[string]any{"level": "planet", "method": "get"},
+ allowed: nil,
+ disallowed: nil,
+ },
+ {
+ name: "write repository level",
+ tool: CreateRepositoryRuleset(translations.NullTranslationHelper),
+ arguments: map[string]any{"level": "repository"},
+ allowed: []string{"repo"},
+ disallowed: []string{"admin:org"},
+ },
+ {
+ name: "write organization level",
+ tool: CreateRepositoryRuleset(translations.NullTranslationHelper),
+ arguments: map[string]any{"level": "organization"},
+ allowed: []string{"admin:org"},
+ disallowed: []string{"repo"},
+ },
+ {
+ name: "write enterprise level",
+ tool: CreateRepositoryRuleset(translations.NullTranslationHelper),
+ arguments: map[string]any{"level": "enterprise"},
+ allowed: []string{"admin:enterprise"},
+ disallowed: []string{"repo", "admin:org"},
+ },
+ {
+ name: "write missing level defers to validation",
+ tool: CreateRepositoryRuleset(translations.NullTranslationHelper),
+ arguments: map[string]any{},
+ allowed: nil,
+ disallowed: nil,
+ },
+ {
+ name: "write malformed level defers to validation",
+ tool: CreateRepositoryRuleset(translations.NullTranslationHelper),
+ arguments: map[string]any{"level": 123},
+ allowed: nil,
+ disallowed: nil,
+ },
+ }
+
+ for _, tt := range tests {
+ t.Run(tt.name, func(t *testing.T) {
+ require.NotNil(t, tt.tool.ScopeAccess.Challenge)
+ assert.True(t, tt.tool.ScopeAccess.Dynamic)
+ assert.True(t, tt.tool.ScopeAccess.Visible(nil))
+ assert.Empty(t, tt.tool.ScopeAccess.Challenge(tt.arguments, tt.allowed))
+ if tt.disallowed == nil {
+ assert.Empty(t, tt.tool.ScopeAccess.Challenge(tt.arguments, nil))
+ } else {
+ assert.NotEmpty(t, tt.tool.ScopeAccess.Challenge(tt.arguments, tt.disallowed))
+ }
+ })
+ }
+}
+
+func Test_RulesetScopeMetadataIsExhaustive(t *testing.T) {
+ tests := []struct {
+ tool inventory.ServerTool
+ maxScopes []string
+ }{
+ {tool: RepositoryRulesetRead(translations.NullTranslationHelper), maxScopes: []string{"repo", "read:org", "read:enterprise"}},
+ {tool: CreateRepositoryRuleset(translations.NullTranslationHelper), maxScopes: []string{"repo", "admin:org", "admin:enterprise"}},
+ }
+
+ for _, tt := range tests {
+ t.Run(tt.tool.Tool.Name, func(t *testing.T) {
+ assert.True(t, tt.tool.ScopeAccess.Dynamic)
+ assert.Equal(t, tt.maxScopes, tt.tool.ScopeAccess.Scopes)
+ assert.NotNil(t, tt.tool.ScopeAccess.Challenge)
+ })
+ }
+}
diff --git a/pkg/github/tools.go b/pkg/github/tools.go
index f9b51159b5..4f70ff3c43 100644
--- a/pkg/github/tools.go
+++ b/pkg/github/tools.go
@@ -73,6 +73,11 @@ var (
Description: "GitHub Organization related tools",
Icon: "organization",
}
+ ToolsetMetadataGovernance = inventory.ToolsetMetadata{
+ ID: "governance",
+ Description: "Repository governance tools for managing rulesets at the repository, organization, and enterprise levels",
+ Icon: "law",
+ }
ToolsetMetadataActions = inventory.ToolsetMetadata{
ID: "actions",
Description: "GitHub Actions workflows and CI/CD operations",
@@ -264,6 +269,10 @@ func AllTools(t translations.TranslationHelperFunc, opts ...ToolOption) []invent
// Organization tools
SearchOrgs(t),
+ // Governance tools (rulesets)
+ RepositoryRulesetRead(t),
+ CreateRepositoryRuleset(t),
+
// Pull request tools
PullRequestRead(t),
ListPullRequests(t),
diff --git a/pkg/http/oauth/oauth_test.go b/pkg/http/oauth/oauth_test.go
index 52baae3b6c..fa47b6d715 100644
--- a/pkg/http/oauth/oauth_test.go
+++ b/pkg/http/oauth/oauth_test.go
@@ -588,6 +588,9 @@ func TestSupportedScopes(t *testing.T) {
"repo",
"delete_repo",
"read:org",
+ "admin:org",
+ "read:enterprise",
+ "admin:enterprise",
"read:user",
"user:email",
"read:packages",
@@ -610,6 +613,13 @@ func TestDefaultScopesRequiresExplicitDeleteRepoOptIn(t *testing.T) {
assert.Contains(t, DefaultScopes, "repo")
}
+func TestDefaultScopesRequiresExplicitHighPrivilegeOptIn(t *testing.T) {
+ for _, scope := range []string{"admin:org", "read:enterprise", "admin:enterprise"} {
+ assert.Contains(t, SupportedScopes, scope)
+ assert.NotContains(t, DefaultScopes, scope)
+ }
+}
+
func TestProtectedResourceResponseFormat(t *testing.T) {
t.Parallel()
diff --git a/pkg/octicons/icons/law-dark.png b/pkg/octicons/icons/law-dark.png
new file mode 100644
index 0000000000..3c71275748
Binary files /dev/null and b/pkg/octicons/icons/law-dark.png differ
diff --git a/pkg/octicons/icons/law-light.png b/pkg/octicons/icons/law-light.png
new file mode 100644
index 0000000000..3121c5b654
Binary files /dev/null and b/pkg/octicons/icons/law-light.png differ
diff --git a/pkg/octicons/icons_data_uris.txt b/pkg/octicons/icons_data_uris.txt
index 1083af68b8..b5899f7589 100644
--- a/pkg/octicons/icons_data_uris.txt
+++ b/pkg/octicons/icons_data_uris.txt
@@ -30,6 +30,8 @@ git-pull-request-dark data:image/png;base64,iVBORw0KGgoAAAANSUhEUgAAABgAAAAYCAYA
git-pull-request-light data:image/png;base64,iVBORw0KGgoAAAANSUhEUgAAABgAAAAYCAYAAADgdz34AAAABmJLR0QA/wD/AP+gvaeTAAACwUlEQVRIie2Vz28UZRjHP993pi0QIC3YahNjirtmd3bS3Q1eUHvQEPUiEv8A4kXjwRJ78MCFBLjBBRKCHowHE38cNCbGGx6IUoKiodtNpoNmTJp4oSJNQ3pw29l5POxus2wo3QTwxPc0887zfD7zvu9kXnjEUfdNrjj5vJOmMP4e9JrfR1G02tuQD8tvgpck0dxCPwK30ViqnJTcr4bOmfRlI/PrhUJ5313woDpDpu8ss7f6nYHrvDnGcYlPsoY/bKaXwHY3HWfvgmNnMX0zvMM7069A3c3pkEYWa7UVgFxQPSfs7SSeH3k2rEy5jMubMBoG1yQ+SBbm53of+gCybMkk/H8VAFdbZisZLAFsJ11oyL+BUURcwrjWAZixXeIwxs/5UuVAr0QAYRjubGR+HWy3mb6QCIBXQe8nce0jgIkwfMo3/xLG085x8I9ofkMyUa0O+w2rgS0mcf3lboEDiKJo1cvsIDDr4D1DhTb8407hYhTdTJW+AvrdMnuhG9Je1m9BBzbfjXbyQcXypeqJLQt7+0rVE/mgYr3j7l7FDzOPBY8FDx6vc1EolPeNjI5/Jpgw7Lm9o+Pry//c/K0PhnLFyrSMDxE79jwxvn9079gvt28vrUD7V1EoFHalbltd2C7DfS4sAF4DTSdx7cL96LliZVriPPADuL+geRh0Z8il5SiKVn2ATENvCCYw78U/b8xdBcgHlYuGHQXuK5A4ClxM4vnXW8Lqp5JdWWt6h4CvWnsgxgDSbRZ3Gg0tCJ7sY4nGwDZOt/WBZtzN9FswLgM2sGann5mcPDaw5pWEHQH7cUu86SdkR3LF6tfrA814MNVpwNrM1leUxPXrSKfMeHcwdctyNotY8c3NbMX3LJsB3ZHsymDqlkHvYHYyievXWxPpSj4o75eYIuPWZof+vRKG4c61pncIx6gZsx34/5L/ACy3ElqUYhuvAAAAAElFTkSuQmCC
issue-opened-dark data:image/png;base64,iVBORw0KGgoAAAANSUhEUgAAABgAAAAYCAYAAADgdz34AAAABmJLR0QA/wD/AP+gvaeTAAABxElEQVRIibWVvW4TURCFv0vlNEDcIHAkKAERJOIKSjoUArwBPwUFFaKIIngAJARCPIgdh4cgRBYt6ZIAEYEqdhpEw0fhCbkKa68dxyNtsfNzzpnZu3NhwpYGBdUqcA+4A1wEZiK0DawD74FWSml3JAJ1CngGLAIngU1gFfgZKWeAG8AFoAu8At6mlH6VtqTOqJ/UP2pDnRuQO6c27VlbrQ0Dvq121Fulag7q5qPmW18SdSqUd9Qrw4Jn9bNR21YrRQkvYixDKy/AuB3jWjocqKpdtXFU8AxrOTqZzp2PgvnaMRDUA+tB7mypG+OCZ3hbahPgRPguAR9LihaicEu9WcKxClzOi/fU1wPAk7rjgX0uEfNG3cs7mJjtE+wA5/olpZQEHgNf6K2NJyW4NeD7v7c4WptjSc0svlMjdzyM2fbdOyOA7x/T+7mzGj9H8xgIWuquevpw4HmsivkxwBdC/WJRsBKLqqPOHgH8aqybtcJlF0m1WLndUToJ5V31q9r3NOYk7Wh1Wa0PyK3HzA3l/4H3uzIrwFNgCThF7/x/AH5EylngOnAe6AAvgXcppd9DEWRE08DdeIou/RVgJaXUGYQzUfsL+zmwV7BtIq0AAAAASUVORK5CYII=
issue-opened-light data:image/png;base64,iVBORw0KGgoAAAANSUhEUgAAABgAAAAYCAYAAADgdz34AAAABmJLR0QA/wD/AP+gvaeTAAAC2UlEQVRIicWVMUyTaRjHf/+vVUpOJOdilOYoUvVreq2CDmLOwdlAS5xMbrrhBifjYG7QjYuJw108nZx1huLgYlw0QshxChUK2koxHHcuCmgsFfieG0or3kGPSoz/8X3f5/97nvfN8z7wmaVqm9FodFfR/EnMugy5giCAwYwgI9Ff9Hl9L9Lp1zUBgsGO+rqGwnlhF4CdwBSyAUwvS1G220zHBSFgHtmVxYWvfp2ZGSj8LyAcjgfZphTQZtAr7OdsZvSP9RIJR+LtQhcNusF+d1aUfPp05M8NAavmg8AOk3cmN56+s1Hpa7U/cviUYbcw3jgex9ZCKoBgsKM+0PDuAdBq2He5zOiTzZiXtc+NxRw598GeLRcWTuTz+UUAp3ygrqFwHmgzeWdqNQd4PpFOy/gedNQfaDz3UQXRaHRX0fNPGdzNZUZO12q+VuHIoV7g5Hu/1/IinX7tABTNnwR2SvRsxRzAzOsBGuuWfAkoX5FZFzCVHR95tFVAbiI9DEwj6wLwl5YVMTRYLTDsxjuRrgGYYz/kxkbvbXRWMOBB24cKYI9ks1X8hXQDaAaa5XG9WjImZgV71wI+m8qAv8y0t1pSmP0ITANTmHO2qqvRZDALlTdgHKyjWkx2YvQ2cHtTacMxYBhWK5DoF4TCkXj7Jg02VKsbOwI0Y+qvALZrOQXMC13cKkDSJWBuJUCqAhgbG3uF7IpBcn/k8KlPNQ+78U5QQuhy/vHjuQoAYPndwi9gw4bd2ufGYrWatxz8No50ExhaKsz9Vl6vAPL5/KKzoiTGG0fOg1oqCbvxTp/juw/M+zynu/yTwjoD58CBQ02ez/pAR4E+M69ntf3/o1Y3dqR050oAQz7P6Z6cfPRRw647MkOhUMAfaDyH+AloBKYNPRT292rQHoMOSp09J3TZlt5ezWazxX97VR3638RiX9ct+RImSyBcrDT0ETMyMp4ptRIgVX7QL6J/ALSUEwJ5rdg2AAAAAElFTkSuQmCC
+law-dark data:image/png;base64,iVBORw0KGgoAAAANSUhEUgAAABgAAAAYCAYAAADgdz34AAAABmJLR0QA/wD/AP+gvaeTAAAB20lEQVRIieWVz4uNYRTHP8+9t9ya7qiZNUWpmQ0LkaLGRcOOQpaUWbBipxE3439gacNiyg4LG12xRoNSmqZJidkxKTPl+lg4V++89+eblXxX7znne77fc56etwf+K6i31VtFeioFPSYL8gcbqMeAXRFujdyViBdSSo+LmraFx9V5B2NeHR8kNqWud2leU2fVSvCaajO+K1Fb69K3rk5lDcbUhnonCO/Va+pEbpA/BpncpHo9egyNhjrWbZObQVpVN3epdxhEflT9Gr1z2VopQ6oCF4C3QA043/dcN2IGGI3ei6HVMcVMTHBQfaout8++3wZqWV2KWj00OodTF9TXalJPBPHkEAangns84lfqGzVlSUeCdDbikrqoPhvC4HlsUI74XGgdzpIeqSvZs1MvB3FvLwN1d3AuZXKb1E/qw3Zih9pSG7nJauoX9W4fg3vdbpx6Q/2pTlSAaX7fpv3qGWAZGAEOAC3gKL0xDfwAZtUXwDdgG1AHEnAItarOqZ/diJb6RN3XZ4M96gP1e653Ub2qVlOGXAZ2AluAVeBdSmklt3oTIKVUz+VHYvIa8DGl9KHP1r3R7RYNQmkw5e/Q8z1QTwPbc+n8e9DGUkrpfiHn+COHxctC4v8UfgH+YI1qigrwsQAAAABJRU5ErkJggg==
+law-light data:image/png;base64,iVBORw0KGgoAAAANSUhEUgAAABgAAAAYCAYAAADgdz34AAAABmJLR0QA/wD/AP+gvaeTAAAC/klEQVRIieWVz2sUZxjHP887UzcqRmixelC6SSZhN9HdtQUvKq5SouKhFBo8qAextJU2Jj14sCKlgiiIYNAUWrAtvbSiByk9SC/Z/AHWbHfXjOusLq1CWjD422ycmacHk20Ss13TnsTvaXje5/v9PDO8PAMvlZx46ksnnuifi8eeG0LjIHNy1O1u6UhskVCSE90fPOXwNYAazZYKv138T4BYLPaaL5F+YHudGc7aWvnYdd3bNQFObPUGJPwFmDfjvILqF8uXvXo8k8n4Tjw5AOANZzem02n75sjofkQ+ByIzfOOo6fTcy4M2QMQaz1UC+wgQRdgNXBP4XoLwfLGYcz332ckymYwPHG1d+eYFguA9hV1AK8q3QDlijecADEChUBj13OxhFW5O+JcFFftUsZibJXq6ruV/HdYnDX3AUp4m/uG52cOFQmG0CgCIRqMNAh8BeWCRmefvqRdelT32PtAI5FH2RqPRhsmjKsCe37gTWIJKN+ggwr50Ov0c17jLQvgEyGjIPmCJPb9xxzMAwXQDOc8dGlTMSeCNWyOj79SLdzquvgs0IXKydDU7AAyB9DJxgQxAW3z124omVOUEoKXhoZ9ASqFITz2AhNID3PCutP4MICp9wMrm9sSmKiAg7EX5Kxi7c3bCFwKnBda3dSTX1Apvia16S2GdoH1wLgBQ/8EPwIhR6QUwzfFUq8BWoL9cLo9Nmq3w8RngbhjSXXN6zKfA/aDyyneTNc/zKoh8BWxra1sVsw10AgZhbUs8ud2IlkNhYRDIOiAANtf+PnQCvon4B5z21CUleCBqNaFsBCS0rE22//jOGXvB4tdRPhT4UVUQBYQQJYPowVr5oZFtRvUQSg+qDYIBFJASogf9R3e/mbKLuiynvZhAdAWBuRfY/vCNfP7PqYFTV8XUeiKRWPjwCU2i1qLQMreuFy79XvOt/01OPDkwCXlemfot/08117XTnupCtXlG97T/wT91ue5dGTo3W07tVaD6GZCaXqs+HZvRexmYFfDi629RIBtl1zP+PwAAAABJRU5ErkJggg==
logo-gist-dark data:image/png;base64,iVBORw0KGgoAAAANSUhEUgAAACYAAAAYCAYAAACWTY9zAAAABmJLR0QA/wD/AP+gvaeTAAACoElEQVRIie2WX2hXZRjHP8/4hW5UzoGFja2wdiF00S7EotAgIcKbMBoIu7HuuhANC3aRCTIQgmBBsEZ3s8hoIwomrLuB4E0YhqWFrjHY0NY2rGHh6uPFecXj2flt+/2mXoRfOHDO+z5/Ps973vO8B+7r/yi1U+1XL6gL6j/qZfWU+r7aca9YIgf1LtAL/AuMAOeA68AjQCfwLDAeEU8l+wpwCDgfEV/fFTq120zfq49XsWlVu3PPu5LPX2vI+4L6RrXJJnVanVEfqyHow+rnas8awH5Vx6pN7k2Vv1dvgnq1HFgD8Eq6//IOJ21QN6rr6w1wRp2t0/c3tb8w1qmeVK95S3+oJ9K2aVF7037+L9ldzF1dABWgHRivqypoBppyUB3AGHAFOAZMAY3Ao8DzwDqytzQBfAIcBa4CH+Rinr0J9mCaLFuRfcADheHvIqJaIftSzOci4soyBQ2k+O8A0xExUDSokPUyqwToAx4qjL1O9RVuA2ZWgFqVGoAFslUrUzvQkq7XVhHvZ6BVfUttWtF6BbBJ4ImyyYiYj4i5iJgDVtNI+4BvgI+BP9Vx9Vv1iNpWK9hPwCa1FK4WRcRCRLxKVmgXMEh2xPUAP6pbawEbTfd71gqWA5yIiKGIOJxAdwIbgDdrARsG5oC31eY7BVcAPQ38DZQdeQ2lYBExDxwBWoGv1JYaci6S+0OpJnUHsB74pTA1C2xRiy2JCkBEfJT+Kg4CF9QTZHtvkeyL3Qy8XJLzErBdfSYiflD3A9sSwO9kPfBpoBuYAT4t+H8BfAgMqceB+YgYpSj1JXVYnfd2Takj6oH861ZfVCfVwfS8Wx1Tr+Z8Z9XP1CdL8kWKOZ5sZ0qKX+LUlA7hyipslxzUyb/YnJeL0ahW66f3taJuAAWd129KkzycAAAAAElFTkSuQmCC
logo-gist-light data:image/png;base64,iVBORw0KGgoAAAANSUhEUgAAACYAAAAYCAYAAACWTY9zAAAABmJLR0QA/wD/AP+gvaeTAAAD2ElEQVRIie2WS2xUVRjHf9/tHSgjRCA+C5am3gFmhnIHJypEY4aVujAYfCUGF8SFRhONgiRsiInxFRIiGhcSxQVEhYhhRQyycuMjUWaG0mnLLQ9BUORRiK20nTl/F1DSDJe2VOLC+F/dc873/c/vfDfnAf/rPyIb2Qgy4SI5njNjKTAb8BG9GBHSbmfeZwcrxQP/KlgqnVsj9CZQM9jlYL+JIYxbgEXAYrBDUaUYABQKBf/Yb2dXy6yzp1Lceb3BfIAgE66Q9C7wcw1v+aHK3iP1gXPnhrOcr6XD7eO/nytgvG2oD5g6kclbs+H9npgbdZQ2XwHWlM8n6a+uB51ucA2PRF17j8eZdHeXfgW2Drfd0KQflRj43OT2TQQKwHN8KjgBXAl2Q9/QMpndZti6rqtAxSmKfjgPPD1RqLHkyexhAM9p+/X2bm5rm9HS0tI4kWQL0uFeYE5UKc281uQgHR6W+Lqns/T85b5MuAjHWxgFYBjqDLCnP+mvnHHhQuNALbEK00Nc3FQDGCP/1Nqoo7TdB5qBQxNZFTDdjORwozWdSyF9i8dJnN4R3nHPc1PkvFsxd58/ODh5aKjRk1c7guwjQ29gnJez9cMeDa5Whou7cipwPm7WVCZc6ZwlRvb5ct90dZVjF+IZKxG+fC3pKZdPjrKgTQBBOnxNcKKns7ipPsAHDENx2RIbzTRtZF+tgSe4WoWlOxCnxoAalzygz1z8OVSdbM2Dvps56LuZJh4b081UwZgVpHMvNOXzyTHjR5EPHJXREjd4uFjsHf4O5od/jmWW9NnYP6R7gA+T/dUP7kyHvxi0Y/aTvOonPe3tR8cL5gEdwM3z5+di4a5F5XK5L6qUH63htZj0JLAFVENaa7WGfakFd6XH6+Vj7EY8VUXLgQ3/FA7g0pV2BNgBEKQXLgb7Tq72LLB6PB5edZJ9BZzFeLUll5t+PcDqFVXK3wMXJGuqHzPDiwU7XCz2mul1YFZiQF9ms9lrOGhVpe7pFKdUuu0BoNGM7rqhMxKt+Xw+UZ/jAxzoKL8fZMI5Eq8MOL8rSIfbwDokqoamGna7TA/WJ5vsoIx7U9lc7sD+YjGVWfiSZHcbdGP84SDhwQKJFcApBt3HdRZfGGw491d1R5AJt+LojTpLuy+DAUQdpVWtmYW7GmQvClaAbrRLtXDohGFFYHM1YXsu1wtbg7RFTquAZ5DXI/Q4sAwxzQDBWUw78dy6qNJ+bCRVVCm9F6RzkvSywTZMp4GbRi1/Uz6fbG5rm1EoFPxRA4G4i7opn0/OmzdvWlx8nGbPXjIlm81O6F33vwD+Bhvyhr7wtSBQAAAAAElFTkSuQmCC
mark-github-dark data:image/png;base64,iVBORw0KGgoAAAANSUhEUgAAABgAAAAYCAYAAADgdz34AAAABmJLR0QA/wD/AP+gvaeTAAAB8ElEQVRIibWVu09UQRSHv9k1IgW7AY0RBWKsTGx9ND4qS5F/wEYLDb0xAY0UxkdrZ2NHYWfsaYyVT4iJwZpNjJooLBQEYz6LvZsdxmH3LtFfN3PO+X5zzzwu/GeFbkF1BJgCJoHjwFgRagDLwAvgeQjhR1+u6qA6q67ZW6vqjDpYFn5YfV0CnOqDOtELPqY2dgFvq6Ee6daWd1HyvPqyBPSV+jQav1H3tbkhMpgF7hXDLaAeQthUzwFXgE/AF0BgFDgBPAshLKhV4CcwVNTPhBAexKsfcfuGbqhdT1imA1+j+lV1GKBSxKeAWpRfTca94HuB+BTVgcuxwWRS8zCEsFbWIISwBcwl0x2m+jnZuKNl4RHjQMJYjoPNJFju0vxt8itiNKHTomqSO7wLeA3YE01VYoPvSf7Jfg2AU8n4W2zwPglO78Igrekw1enMDb1fXKCuUivq7Uz99Tiprq6rvwuzhSLpo3pLvZABn1Vv2nrkUjWLPdlWMFcEF9WD6tuo4EnG4HEG3Nad3KcOqEtRe/bb+ic8Uo9l8i/tAF9UB3bq54StJ3dTvaGOqofM3IuiRalW1PH8bnUKx4tVxLqYyTuf5Czl4JV0IoSwApwB7gLr7enMWtpzG7TeodNFbXmpNfWq6YloxYbUa2q9L+i/1h8/EAGdUrF9ZQAAAABJRU5ErkJggg==
diff --git a/pkg/octicons/required_icons.txt b/pkg/octicons/required_icons.txt
index 15dc444956..ebc5d99048 100644
--- a/pkg/octicons/required_icons.txt
+++ b/pkg/octicons/required_icons.txt
@@ -29,6 +29,7 @@ git-commit
git-merge
git-pull-request
issue-opened
+law
logo-gist
mark-github
organization
diff --git a/pkg/scopes/scopes.go b/pkg/scopes/scopes.go
index d845cc6dc8..2294ac01ef 100644
--- a/pkg/scopes/scopes.go
+++ b/pkg/scopes/scopes.go
@@ -31,6 +31,14 @@ const (
// AdminOrg grants full control of organizations and teams
AdminOrg Scope = "admin:org"
+ // ReadEnterprise grants read-only access to enterprise profile data, including
+ // enterprise-level custom properties
+ ReadEnterprise Scope = "read:enterprise"
+
+ // AdminEnterprise grants full control of enterprises, including enterprise-level
+ // rulesets and custom properties
+ AdminEnterprise Scope = "admin:enterprise"
+
// Gist grants write access to gists
Gist Scope = "gist"
@@ -77,6 +85,9 @@ var oauthScopeDefinitions = []oauthScopeDefinition{
{scope: Repo, byDefault: true},
{scope: DeleteRepo},
{scope: ReadOrg, byDefault: true},
+ {scope: AdminOrg},
+ {scope: ReadEnterprise},
+ {scope: AdminEnterprise},
{scope: ReadUser, byDefault: true},
{scope: UserEmail, byDefault: true},
{scope: ReadPackages, byDefault: true},
@@ -113,12 +124,13 @@ func oauthScopes(defaultOnly bool) []string {
// A parent scope implicitly grants access to all child scopes.
// For example, "repo" grants access to "public_repo" and "security_events".
var ScopeHierarchy = map[Scope][]Scope{
- Repo: {PublicRepo, SecurityEvents},
- AdminOrg: {WriteOrg, ReadOrg},
- WriteOrg: {ReadOrg},
- Project: {ReadProject},
- WritePackages: {ReadPackages},
- User: {ReadUser, UserEmail},
+ Repo: {PublicRepo, SecurityEvents},
+ AdminOrg: {WriteOrg, ReadOrg},
+ AdminEnterprise: {ReadEnterprise},
+ WriteOrg: {ReadOrg},
+ Project: {ReadProject},
+ WritePackages: {ReadPackages},
+ User: {ReadUser, UserEmail},
}
// RequireAll creates scope checks for a tool that always needs the given scopes.
diff --git a/pkg/scopes/scopes_test.go b/pkg/scopes/scopes_test.go
index 8240e8454b..c9a74781e2 100644
--- a/pkg/scopes/scopes_test.go
+++ b/pkg/scopes/scopes_test.go
@@ -17,16 +17,27 @@ func TestOAuthScopeCatalog(t *testing.T) {
assert.NotContains(t, defaults, string(Workflow))
assert.Contains(t, supported, string(Codespace))
assert.NotContains(t, defaults, string(Codespace))
+ assert.Contains(t, supported, string(AdminOrg))
+ assert.NotContains(t, defaults, string(AdminOrg))
+ assert.Contains(t, supported, string(ReadEnterprise))
+ assert.NotContains(t, defaults, string(ReadEnterprise))
+ assert.Contains(t, supported, string(AdminEnterprise))
+ assert.NotContains(t, defaults, string(AdminEnterprise))
}
func TestScopeChecks(t *testing.T) {
assert.True(t, HasAll([]string{"repo", "workflow"}, Repo, Workflow))
assert.False(t, HasAll([]string{"repo"}, Repo, Workflow))
assert.True(t, HasAll([]string{"admin:org"}, ReadOrg))
+ assert.True(t, HasAll([]string{"admin:enterprise"}, ReadEnterprise))
+ assert.False(t, HasAll([]string{"read:enterprise"}, AdminEnterprise))
assert.True(t, HasAllScopeNames([]string{"admin:org"}, []string{"read:org"}))
+ assert.True(t, HasAllScopeNames([]string{"admin:enterprise"}, []string{"read:enterprise"}))
assert.False(t, HasAllScopeNames([]string{"repo"}, []string{"repo", "workflow"}))
assert.Nil(t, ChallengeAll([]string{"repo", "workflow"}, Repo, Workflow))
assert.Equal(t, []string{"repo", "workflow"}, ChallengeAll([]string{"repo"}, Repo, Workflow))
+ assert.Nil(t, ChallengeAll([]string{"admin:enterprise"}, ReadEnterprise))
+ assert.Equal(t, []string{"read:enterprise"}, ChallengeAll([]string{"repo"}, ReadEnterprise))
}
func TestDynamicChallenge(t *testing.T) {
@@ -57,4 +68,5 @@ func TestScopeHierarchy(t *testing.T) {
assert.Contains(t, ScopeHierarchy[WritePackages], ReadPackages)
assert.Contains(t, ScopeHierarchy[User], ReadUser)
assert.Contains(t, ScopeHierarchy[User], UserEmail)
+ assert.Contains(t, ScopeHierarchy[AdminEnterprise], ReadEnterprise)
}