From 53de0492eaae4cf89ce000d737383a9482e24cb7 Mon Sep 17 00:00:00 2001 From: Sam Morrow Date: Sat, 1 Aug 2026 00:21:29 +0200 Subject: [PATCH 1/2] feat(governance): add rulesets tools in new governance toolset Add a new non-default "governance" toolset (icon: law) with tools for managing GitHub repository rulesets at the repository, organization, and enterprise levels. Read operations are consolidated behind method-dispatch tools to match the current MCP surface: - repository_ruleset_read (get, list, get_rules_for_branch, list_rule_suites, get_rule_suite) - organization_repository_ruleset_read (get, list) Write operations remain single-purpose tools, split by level because each level requires a distinct OAuth scope for scope-challenge accuracy: - create_repository_ruleset (repo) - create_organization_repository_ruleset (admin:org) - create_enterprise_repository_ruleset (admin:enterprise) Adds the read:enterprise and admin:enterprise scopes and the law octicon as shared governance infrastructure. Supersedes #821. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> Copilot-Session: 1e886867-a922-419a-b02c-ac643716aea8 --- README.md | 70 ++ docs/remote-server.md | 1 + .../create_enterprise_repository_ruleset.snap | 101 +++ ...reate_organization_repository_ruleset.snap | 101 +++ .../create_repository_ruleset.snap | 105 +++ .../organization_repository_ruleset_read.snap | 44 ++ .../repository_ruleset_read.snap | 92 +++ pkg/github/rulesets.go | 744 ++++++++++++++++++ pkg/github/rulesets_test.go | 486 ++++++++++++ pkg/github/tools.go | 12 + pkg/octicons/icons/law-dark.png | Bin 0 -> 550 bytes pkg/octicons/icons/law-light.png | Bin 0 -> 841 bytes pkg/octicons/required_icons.txt | 1 + pkg/scopes/scopes.go | 21 +- 14 files changed, 1772 insertions(+), 6 deletions(-) create mode 100644 pkg/github/__toolsnaps__/create_enterprise_repository_ruleset.snap create mode 100644 pkg/github/__toolsnaps__/create_organization_repository_ruleset.snap create mode 100644 pkg/github/__toolsnaps__/create_repository_ruleset.snap create mode 100644 pkg/github/__toolsnaps__/organization_repository_ruleset_read.snap create mode 100644 pkg/github/__toolsnaps__/repository_ruleset_read.snap create mode 100644 pkg/github/rulesets.go create mode 100644 pkg/github/rulesets_test.go create mode 100644 pkg/octicons/icons/law-dark.png create mode 100644 pkg/octicons/icons/law-light.png diff --git a/README.md b/README.md index 614404c0a1..6ccb9172a0 100644 --- a/README.md +++ b/README.md @@ -562,6 +562,7 @@ The following sets of tools are available: | comment-discussion | `discussions` | GitHub Discussions related tools | | logo-gist | `gists` | GitHub Gist related tools | | git-branch | `git` | GitHub Git API related tools for low-level Git operations | +| law | `governance` | Repository governance tools for managing rulesets at the repository, organization, and enterprise levels | | issue-opened | `issues` | GitHub Issues related tools | | tag | `labels` | GitHub Labels related tools | | bell | `notifications` | GitHub Notifications related tools | @@ -837,6 +838,75 @@ The following sets of tools are available:
+law Governance + +- **create_enterprise_repository_ruleset** - Create enterprise repository ruleset + - **Required OAuth Scopes**: `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 (string, required) + - `name`: The name of the ruleset (string, required) + - `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' (string, optional) + +- **create_organization_repository_ruleset** - Create organization repository ruleset + - **Required OAuth Scopes**: `admin:org` + - `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) + - `name`: The name of the ruleset (string, required) + - `org`: Organization name (string, required) + - `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' (string, optional) + +- **create_repository_ruleset** - Create repository ruleset + - **Required OAuth Scopes**: `repo` + - `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) + - `name`: The name of the ruleset (string, required) + - `owner`: Repository owner (string, required) + - `repo`: Repository name (string, required) + - `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' (string, optional) + +- **organization_repository_ruleset_read** - Read organization repository rulesets + - **Required OAuth Scopes**: `read:org` + - **Accepted OAuth Scopes**: `admin:org`, `read:org`, `write:org` + - `method`: Operation to perform: + - 'get': Get a specific repository ruleset by ID (requires 'ruleset_id'). + - 'list': List all repository rulesets for the organization. (string, required) + - `org`: Organization name (string, required) + - `page`: Page number for pagination (min 1) (number, optional) + - `perPage`: Results per page for pagination (min 1, max 100) (number, optional) + - `ruleset_id`: Ruleset ID. Required for the 'get' method. (number, optional) + +- **repository_ruleset_read** - Read repository rulesets + - **Required OAuth Scopes**: `repo` + - `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) + - `includes_parents`: Include rulesets configured at higher levels that also apply. Defaults to true. Used by the 'get' and 'list' methods. (boolean, optional) + - `method`: Operation to perform: + - 'get': Get a specific ruleset by ID (requires 'ruleset_id'). + - 'list': List all rulesets for the repository. + - 'get_rules_for_branch': Get all rules that apply to a branch (requires 'branch'). + - 'list_rule_suites': List rule suites, the evaluations of rules against pushes. + - 'get_rule_suite': Get a specific rule suite by ID (requires 'rule_suite_id'). (string, required) + - `owner`: Repository owner (string, required) + - `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 (string, required) + - `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) + +
+ +
+ issue-opened Issues - **add_issue_comment** - Add comment to issue or pull request diff --git a/docs/remote-server.md b/docs/remote-server.md index 4665ba8044..85721a34bf 100644 --- a/docs/remote-server.md +++ b/docs/remote-server.md @@ -29,6 +29,7 @@ Below is a table of available toolsets for the remote GitHub MCP Server. Each to | comment-discussion
`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) | | logo-gist
`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-branch
`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) | +| law
`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) | | issue-opened
`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) | | tag
`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) | | bell
`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/pkg/github/__toolsnaps__/create_enterprise_repository_ruleset.snap b/pkg/github/__toolsnaps__/create_enterprise_repository_ruleset.snap new file mode 100644 index 0000000000..e4151d4e42 --- /dev/null +++ b/pkg/github/__toolsnaps__/create_enterprise_repository_ruleset.snap @@ -0,0 +1,101 @@ +{ + "annotations": { + "title": "Create enterprise repository ruleset" + }, + "description": "Create a new repository ruleset for an enterprise", + "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", + "enum": [ + "Integration", + "OrganizationAdmin", + "RepositoryRole", + "Team", + "DeployKey" + ], + "type": "string" + }, + "bypass_mode": { + "description": "When the specified actor can bypass the ruleset", + "enum": [ + "always", + "pull_request" + ], + "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", + "type": "string" + }, + "name": { + "description": "The name of the ruleset", + "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'", + "enum": [ + "branch", + "tag", + "push", + "repository" + ], + "type": "string" + } + }, + "required": [ + "enterprise", + "name", + "enforcement", + "rules" + ], + "type": "object" + }, + "name": "create_enterprise_repository_ruleset" +} \ No newline at end of file diff --git a/pkg/github/__toolsnaps__/create_organization_repository_ruleset.snap b/pkg/github/__toolsnaps__/create_organization_repository_ruleset.snap new file mode 100644 index 0000000000..99b19aa446 --- /dev/null +++ b/pkg/github/__toolsnaps__/create_organization_repository_ruleset.snap @@ -0,0 +1,101 @@ +{ + "annotations": { + "title": "Create organization repository ruleset" + }, + "description": "Create a new repository ruleset for an organization", + "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", + "enum": [ + "Integration", + "OrganizationAdmin", + "RepositoryRole", + "Team", + "DeployKey" + ], + "type": "string" + }, + "bypass_mode": { + "description": "When the specified actor can bypass the ruleset", + "enum": [ + "always", + "pull_request" + ], + "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" + }, + "name": { + "description": "The name of the ruleset", + "type": "string" + }, + "org": { + "description": "Organization name", + "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'", + "enum": [ + "branch", + "tag", + "push", + "repository" + ], + "type": "string" + } + }, + "required": [ + "org", + "name", + "enforcement", + "rules" + ], + "type": "object" + }, + "name": "create_organization_repository_ruleset" +} \ No newline at end of file diff --git a/pkg/github/__toolsnaps__/create_repository_ruleset.snap b/pkg/github/__toolsnaps__/create_repository_ruleset.snap new file mode 100644 index 0000000000..22f8f7771a --- /dev/null +++ b/pkg/github/__toolsnaps__/create_repository_ruleset.snap @@ -0,0 +1,105 @@ +{ + "annotations": { + "title": "Create repository ruleset" + }, + "description": "Create a new ruleset for a repository", + "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", + "enum": [ + "Integration", + "OrganizationAdmin", + "RepositoryRole", + "Team", + "DeployKey" + ], + "type": "string" + }, + "bypass_mode": { + "description": "When the specified actor can bypass the ruleset", + "enum": [ + "always", + "pull_request" + ], + "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" + }, + "name": { + "description": "The name of the ruleset", + "type": "string" + }, + "owner": { + "description": "Repository owner", + "type": "string" + }, + "repo": { + "description": "Repository name", + "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'", + "enum": [ + "branch", + "tag", + "push" + ], + "type": "string" + } + }, + "required": [ + "owner", + "repo", + "name", + "enforcement", + "rules" + ], + "type": "object" + }, + "name": "create_repository_ruleset" +} \ No newline at end of file diff --git a/pkg/github/__toolsnaps__/organization_repository_ruleset_read.snap b/pkg/github/__toolsnaps__/organization_repository_ruleset_read.snap new file mode 100644 index 0000000000..1ffe7f3a2c --- /dev/null +++ b/pkg/github/__toolsnaps__/organization_repository_ruleset_read.snap @@ -0,0 +1,44 @@ +{ + "annotations": { + "readOnlyHint": true, + "title": "Read organization repository rulesets" + }, + "description": "Read an organization's repository rulesets. Select the operation with the 'method' parameter.", + "inputSchema": { + "properties": { + "method": { + "description": "Operation to perform:\n- 'get': Get a specific repository ruleset by ID (requires 'ruleset_id').\n- 'list': List all repository rulesets for the organization.", + "enum": [ + "get", + "list" + ], + "type": "string" + }, + "org": { + "description": "Organization name", + "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" + }, + "ruleset_id": { + "description": "Ruleset ID. Required for the 'get' method.", + "type": "number" + } + }, + "required": [ + "method", + "org" + ], + "type": "object" + }, + "name": "organization_repository_ruleset_read" +} \ 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..3166d23270 --- /dev/null +++ b/pkg/github/__toolsnaps__/repository_ruleset_read.snap @@ -0,0 +1,92 @@ +{ + "annotations": { + "readOnlyHint": true, + "title": "Read repository rulesets" + }, + "description": "Read a repository's rulesets and rule suites. Select 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" + }, + "includes_parents": { + "description": "Include rulesets configured at higher levels that also apply. Defaults to true. Used by the 'get' and 'list' methods.", + "type": "boolean" + }, + "method": { + "description": "Operation to perform:\n- 'get': Get a specific ruleset by ID (requires 'ruleset_id').\n- 'list': List all rulesets for the repository.\n- 'get_rules_for_branch': Get all rules that apply to a branch (requires 'branch').\n- 'list_rule_suites': List rule suites, the evaluations of rules against pushes.\n- 'get_rule_suite': Get a specific rule suite by ID (requires 'rule_suite_id').", + "enum": [ + "get", + "list", + "get_rules_for_branch", + "list_rule_suites", + "get_rule_suite" + ], + "type": "string" + }, + "owner": { + "description": "Repository owner", + "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", + "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": [ + "method", + "owner", + "repo" + ], + "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..77b3a5d554 --- /dev/null +++ b/pkg/github/rulesets.go @@ -0,0 +1,744 @@ +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/v87/github" + "github.com/google/jsonschema-go/jsonschema" + "github.com/modelcontextprotocol/go-sdk/mcp" +) + +// RepositoryRulesetRead creates a tool for read operations on a repository's +// rulesets and rule suites. The operation is selected 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 a repository's rulesets and rule suites. Select 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{ + "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').\n" + + "- 'list': List all rulesets for the repository.\n" + + "- 'get_rules_for_branch': Get all rules that apply to a branch (requires 'branch').\n" + + "- 'list_rule_suites': List rule suites, the evaluations of rules against pushes.\n" + + "- 'get_rule_suite': Get a specific rule suite by ID (requires 'rule_suite_id').", + }, + "owner": { + Type: "string", + Description: "Repository owner", + }, + "repo": { + Type: "string", + Description: "Repository name", + }, + "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.", + }, + "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{"method", "owner", "repo"}, + }), + }, + []scopes.Scope{scopes.Repo}, + func(ctx context.Context, deps ToolDependencies, _ *mcp.CallToolRequest, args map[string]any) (*mcp.CallToolResult, any, error) { + method, err := RequiredParam[string](args, "method") + if err != nil { + return utils.NewToolResultError(err.Error()), nil, nil + } + 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 + } + + client, err := deps.GetClient(ctx) + if err != nil { + return nil, nil, fmt.Errorf("failed to get GitHub client: %w", err) + } + + switch strings.ToLower(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 + } + }, + ) +} + +// OrganizationRepositoryRulesetRead creates a tool for read operations on an +// organization's repository rulesets. The operation is selected with the +// "method" parameter. +func OrganizationRepositoryRulesetRead(t translations.TranslationHelperFunc) inventory.ServerTool { + return NewTool( + ToolsetMetadataGovernance, + mcp.Tool{ + Name: "organization_repository_ruleset_read", + Description: t("TOOL_ORGANIZATION_REPOSITORY_RULESET_READ_DESCRIPTION", "Read an organization's repository rulesets. Select the operation with the 'method' parameter."), + Annotations: &mcp.ToolAnnotations{ + Title: t("TOOL_ORGANIZATION_REPOSITORY_RULESET_READ_USER_TITLE", "Read organization repository rulesets"), + ReadOnlyHint: true, + }, + InputSchema: WithPagination(&jsonschema.Schema{ + Type: "object", + Properties: map[string]*jsonschema.Schema{ + "method": { + Type: "string", + Enum: []any{"get", "list"}, + Description: "Operation to perform:\n" + + "- 'get': Get a specific repository ruleset by ID (requires 'ruleset_id').\n" + + "- 'list': List all repository rulesets for the organization.", + }, + "org": { + Type: "string", + Description: "Organization name", + }, + "ruleset_id": { + Type: "number", + Description: "Ruleset ID. Required for the 'get' method.", + }, + }, + Required: []string{"method", "org"}, + }), + }, + []scopes.Scope{scopes.ReadOrg}, + func(ctx context.Context, deps ToolDependencies, _ *mcp.CallToolRequest, args map[string]any) (*mcp.CallToolResult, any, error) { + method, err := RequiredParam[string](args, "method") + if err != nil { + return utils.NewToolResultError(err.Error()), nil, nil + } + org, err := RequiredParam[string](args, "org") + 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 strings.ToLower(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("unknown method: %q", 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 err != nil { + return ghErrors.NewGitHubAPIErrorResponse(ctx, "failed to get repository ruleset", resp, err), nil + } + defer func() { _ = resp.Body.Close() }() + + 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 err != nil { + return ghErrors.NewGitHubAPIErrorResponse(ctx, "failed to list repository rulesets", resp, err), nil + } + defer func() { _ = resp.Body.Close() }() + + 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 err != nil { + return ghErrors.NewGitHubAPIErrorResponse(ctx, "failed to get repository rules for branch", resp, err), nil + } + defer func() { _ = resp.Body.Close() }() + + 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 err != nil { + return ghErrors.NewGitHubAPIErrorResponse(ctx, "failed to list repository rule suites", resp, err), nil + } + defer func() { _ = resp.Body.Close() }() + + 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 err != nil { + return ghErrors.NewGitHubAPIErrorResponse(ctx, "failed to get repository rule suite", resp, err), nil + } + defer func() { _ = resp.Body.Close() }() + + 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 err != nil { + return ghErrors.NewGitHubAPIErrorResponse(ctx, "failed to get organization repository ruleset", resp, err), nil + } + defer func() { _ = resp.Body.Close() }() + + 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 err != nil { + return ghErrors.NewGitHubAPIErrorResponse(ctx, "failed to list organization repository rulesets", resp, err), nil + } + defer func() { _ = resp.Body.Close() }() + + return MarshalledTextResult(rulesets), nil +} + +// CreateRepositoryRuleset creates a tool to create a new repository ruleset. +func CreateRepositoryRuleset(t translations.TranslationHelperFunc) inventory.ServerTool { + properties := rulesetWriteProperties([]any{"branch", "tag", "push"}) + properties["owner"] = &jsonschema.Schema{Type: "string", Description: "Repository owner"} + properties["repo"] = &jsonschema.Schema{Type: "string", Description: "Repository name"} + + return NewTool( + ToolsetMetadataGovernance, + mcp.Tool{ + Name: "create_repository_ruleset", + Description: t("TOOL_CREATE_REPOSITORY_RULESET_DESCRIPTION", "Create a new ruleset for a repository"), + 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{"owner", "repo", "name", "enforcement", "rules"}, + }, + }, + []scopes.Scope{scopes.Repo}, + func(ctx context.Context, deps ToolDependencies, _ *mcp.CallToolRequest, 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 + } + 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) + } + + created, resp, err := client.Repositories.CreateRuleset(ctx, owner, repo, ruleset) + if err != nil { + return ghErrors.NewGitHubAPIErrorResponse(ctx, "failed to create repository ruleset", resp, err), nil, nil + } + defer func() { _ = resp.Body.Close() }() + + return MarshalledTextResult(created), nil, nil + }, + ) +} + +// CreateOrganizationRepositoryRuleset creates a tool to create a new organization repository ruleset. +func CreateOrganizationRepositoryRuleset(t translations.TranslationHelperFunc) inventory.ServerTool { + properties := rulesetWriteProperties([]any{"branch", "tag", "push", "repository"}) + properties["org"] = &jsonschema.Schema{Type: "string", Description: "Organization name"} + + return NewTool( + ToolsetMetadataGovernance, + mcp.Tool{ + Name: "create_organization_repository_ruleset", + Description: t("TOOL_CREATE_ORGANIZATION_REPOSITORY_RULESET_DESCRIPTION", "Create a new repository ruleset for an organization"), + Annotations: &mcp.ToolAnnotations{ + Title: t("TOOL_CREATE_ORGANIZATION_REPOSITORY_RULESET_USER_TITLE", "Create organization repository ruleset"), + ReadOnlyHint: false, + }, + InputSchema: &jsonschema.Schema{ + Type: "object", + Properties: properties, + Required: []string{"org", "name", "enforcement", "rules"}, + }, + }, + []scopes.Scope{scopes.AdminOrg}, + func(ctx context.Context, deps ToolDependencies, _ *mcp.CallToolRequest, args map[string]any) (*mcp.CallToolResult, any, error) { + org, err := RequiredParam[string](args, "org") + 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) + } + + created, resp, err := client.Organizations.CreateRepositoryRuleset(ctx, org, ruleset) + if err != nil { + return ghErrors.NewGitHubAPIErrorResponse(ctx, "failed to create organization repository ruleset", resp, err), nil, nil + } + defer func() { _ = resp.Body.Close() }() + + return MarshalledTextResult(created), nil, nil + }, + ) +} + +// CreateEnterpriseRepositoryRuleset creates a tool to create a new enterprise repository ruleset. +func CreateEnterpriseRepositoryRuleset(t translations.TranslationHelperFunc) inventory.ServerTool { + properties := rulesetWriteProperties([]any{"branch", "tag", "push", "repository"}) + properties["enterprise"] = &jsonschema.Schema{Type: "string", Description: "Enterprise slug"} + + return NewTool( + ToolsetMetadataGovernance, + mcp.Tool{ + Name: "create_enterprise_repository_ruleset", + Description: t("TOOL_CREATE_ENTERPRISE_REPOSITORY_RULESET_DESCRIPTION", "Create a new repository ruleset for an enterprise"), + Annotations: &mcp.ToolAnnotations{ + Title: t("TOOL_CREATE_ENTERPRISE_REPOSITORY_RULESET_USER_TITLE", "Create enterprise repository ruleset"), + ReadOnlyHint: false, + }, + InputSchema: &jsonschema.Schema{ + Type: "object", + Properties: properties, + Required: []string{"enterprise", "name", "enforcement", "rules"}, + }, + }, + []scopes.Scope{scopes.AdminEnterprise}, + func(ctx context.Context, deps ToolDependencies, _ *mcp.CallToolRequest, args map[string]any) (*mcp.CallToolResult, any, error) { + enterprise, err := RequiredParam[string](args, "enterprise") + 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) + } + + created, resp, err := client.Enterprise.CreateRepositoryRuleset(ctx, enterprise, ruleset) + if err != nil { + return ghErrors.NewGitHubAPIErrorResponse(ctx, "failed to create enterprise repository ruleset", resp, err), nil, nil + } + defer func() { _ = resp.Body.Close() }() + + return MarshalledTextResult(created), nil, nil + }, + ) +} + +// rulesetWriteProperties returns the shared input schema properties for the +// ruleset creation tools. Callers pass the target values valid for the API +// level and add the owner/repo, org, or enterprise identifier properties. +func rulesetWriteProperties(targets []any) 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: targets, + Description: "The target of the ruleset. Defaults to 'branch'", + }, + "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"}, + Description: "The type of actor that can bypass a ruleset", + }, + "bypass_mode": { + Type: "string", + Enum: []any{"always", "pull_request"}, + Description: "When the specified actor can bypass the ruleset", + }, + }, + }, + }, + } +} + +// 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)) + 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") + } + requestedRuleTypes = append(requestedRuleTypes, ruleType) + } + + 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") + } + 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 it + // does not recognize, which would let a typo create a weaker ruleset than the + // caller requested. Verify every requested rule type survived the round-trip. + appliedRuleTypes, errResult := rulesetAppliedRuleTypes(ruleset.Rules) + if errResult != nil { + return github.RepositoryRuleset{}, errResult + } + for _, ruleType := range requestedRuleTypes { + if !appliedRuleTypes[ruleType] { + return github.RepositoryRuleset{}, utils.NewToolResultError(fmt.Sprintf("unsupported or unrecognized rule type: %q", ruleType)) + } + } + + return ruleset, nil +} + +// rulesetAppliedRuleTypes marshals the parsed rules back to the API's array form +// and returns the set of rule types that were actually retained. +func rulesetAppliedRuleTypes(rules *github.RepositoryRulesetRules) (map[string]bool, *mcp.CallToolResult) { + applied := map[string]bool{} + 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"` + } + if err := json.Unmarshal(raw, &ruleObjects); err != nil { + return nil, utils.NewToolResultErrorFromErr("failed to validate ruleset rules", err) + } + for _, rule := range ruleObjects { + applied[rule.Type] = true + } + return applied, nil +} diff --git a/pkg/github/rulesets_test.go b/pkg/github/rulesets_test.go new file mode 100644 index 0000000000..6135739a7e --- /dev/null +++ b/pkg/github/rulesets_test.go @@ -0,0 +1,486 @@ +package github + +import ( + "context" + "encoding/json" + "io" + "net/http" + "net/url" + "testing" + + "github.com/google/go-github/v87/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/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{"method", "owner", "repo"}) + + t.Run("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{"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("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{"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("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{"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("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{"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("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{"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("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{"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("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{"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("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{ + "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("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{"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("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{"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") + }) +} + +func Test_OrganizationRepositoryRulesetRead(t *testing.T) { + toolDef := OrganizationRepositoryRulesetRead(translations.NullTranslationHelper) + require.NoError(t, toolsnaps.Test(toolDef.Tool.Name, toolDef.Tool)) + + assert.Equal(t, "organization_repository_ruleset_read", toolDef.Tool.Name) + assert.True(t, toolDef.Tool.Annotations.ReadOnlyHint) + + schema, ok := toolDef.Tool.InputSchema.(*jsonschema.Schema) + require.True(t, ok) + assert.ElementsMatch(t, schema.Required, []string{"method", "org"}) + + t.Run("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{"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("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{"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("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{"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("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{"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, "unknown method") + }) +} + +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{"owner", "repo", "name", "enforcement", "rules"}) + + 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{ + "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) +} + +func Test_CreateRepositoryRuleset_UnsupportedRuleType(t *testing.T) { + toolDef := CreateRepositoryRuleset(translations.NullTranslationHelper) + 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{ + "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") +} + +func Test_CreateRepositoryRuleset_InvalidRules(t *testing.T) { + toolDef := CreateRepositoryRuleset(translations.NullTranslationHelper) + client := mustNewGHClient(t, MockHTTPClientWithHandlers(map[string]http.HandlerFunc{})) + deps := BaseDeps{Client: client} + handler := toolDef.Handler(deps) + request := createMCPRequest(map[string]any{ + "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") +} + +func Test_CreateOrganizationRepositoryRuleset(t *testing.T) { + toolDef := CreateOrganizationRepositoryRuleset(translations.NullTranslationHelper) + require.NoError(t, toolsnaps.Test(toolDef.Tool.Name, toolDef.Tool)) + + assert.Equal(t, "create_organization_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{"org", "name", "enforcement", "rules"}) + + 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{ + "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) +} + +func Test_CreateEnterpriseRepositoryRuleset(t *testing.T) { + toolDef := CreateEnterpriseRepositoryRuleset(translations.NullTranslationHelper) + require.NoError(t, toolsnaps.Test(toolDef.Tool.Name, toolDef.Tool)) + + assert.Equal(t, "create_enterprise_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{"enterprise", "name", "enforcement", "rules"}) + + 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{ + "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) +} diff --git a/pkg/github/tools.go b/pkg/github/tools.go index b937f8bfd6..c33485e56f 100644 --- a/pkg/github/tools.go +++ b/pkg/github/tools.go @@ -72,6 +72,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", @@ -223,6 +228,13 @@ func AllTools(t translations.TranslationHelperFunc) []inventory.ServerTool { // Organization tools SearchOrgs(t), + // Governance tools (rulesets) + RepositoryRulesetRead(t), + OrganizationRepositoryRulesetRead(t), + CreateRepositoryRuleset(t), + CreateOrganizationRepositoryRuleset(t), + CreateEnterpriseRepositoryRuleset(t), + // Pull request tools PullRequestRead(t), ListPullRequests(t), diff --git a/pkg/octicons/icons/law-dark.png b/pkg/octicons/icons/law-dark.png new file mode 100644 index 0000000000000000000000000000000000000000..3c7127574873c01e6c225a4743485ad0d63e356c GIT binary patch literal 550 zcmV+>0@?kEP)q*CT@h}L+?9wIQc91Dnzn_<)p-YFUL7AKHl?n2Xi^e5^xB$s?MpO-H4X@MBUeCgFWg4^?R;&QN5~GQ-)jy zb^=>~Rp4DpX(hLM6CkBj&*evA@*40lrSzfC*1gf~77apaFy-&i^F2^c1KV@y{Cm*G oQcCH5A?NrbhsDc6;{OzW0sdf(YKjW*u>b%707*qoM6N<$f`5AW&;S4c literal 0 HcmV?d00001 diff --git a/pkg/octicons/icons/law-light.png b/pkg/octicons/icons/law-light.png new file mode 100644 index 0000000000000000000000000000000000000000..3121c5b6547e0440a7eb2fe8fa3ed132600e174c GIT binary patch literal 841 zcmV-P1GfB$P)Th+xnZTCgp>w4w(oJxDLv`~lW%ch`*TtS+rWS}^$AY@C^= z2a|0Qvt84k#P4Z%-sk)MJTtsJ122`Fck~_L?t|eqei@8P3{T`Pg;c+cg1BZ1n~t`~{ddT5R`{u~#endV8A~SsBsOj|F%Y!yR#2U|40{aLMMc55o6Zl?& z@O-hDb3OOj$ugOY4;*b>`Q`q0-0Wc$Zcg}@1r_^L zv6!1Bdb+OVe;w8*4Sm2;o+kd`x!J>oLSeKD0EkAT4FVqld0>&5=kKW(UX?xeKF|o{ zQT9fo(T2&RD!`uKxD!}{N;l{r!oGAm{T$c0%R=}B$Y{tuV2M4yamOGH9G(df%GzQfeb)feD&LkCRF7FprzrVpFm2XNKZY0 zgwg}t@UFD}3?M3fz{=+K#HK1BAnZl?D;T?TcET3|rv=uxbtKo+3U7(6dP`xQpnY&I z08szI55R~~=>;&&@l>n8R-nIJE{{!`t?=;~;BFYwU2}TOr@;N7^2&FUHP7=Zhph{_u@_{?Dm_Q&+{*dLofn`0xo$qSm6&GPkZ5F8yBft^Z9O!@7M)$P;!9R7m?p+x=H(O5aRiOGAkeXhrJ{_CUdl{G&@Z#%lQ6L*- T*E9Y<00000NkvXXu0mjfWvYxK literal 0 HcmV?d00001 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 cb1b7681a7..acdb037ef9 100644 --- a/pkg/scopes/scopes.go +++ b/pkg/scopes/scopes.go @@ -29,6 +29,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" @@ -64,12 +72,13 @@ const ( // 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}, } // ScopeSet represents a set of OAuth scopes. From b912101d870595419014602e5e208e1945841834 Mon Sep 17 00:00:00 2001 From: Sam Morrow Date: Sat, 1 Aug 2026 00:22:32 +0200 Subject: [PATCH 2/2] feat(governance): add custom properties tools Add tools for reading and setting GitHub custom property values at the repository, organization, and enterprise levels within the governance toolset. Read and write are separate tools (ReadOnlyHint drives read-only-mode filtering) and split by level because each level requires a distinct OAuth scope for scope-challenge accuracy: - get_repository_custom_properties (repo) - create_or_update_repository_custom_properties (repo) - get_organization_custom_properties (read:org) - create_or_update_organization_custom_properties (admin:org) - get_enterprise_custom_properties (read:enterprise) - create_or_update_enterprise_custom_properties (admin:enterprise) Supersedes #821. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> Copilot-Session: 1e886867-a922-419a-b02c-ac643716aea8 --- README.md | 33 +- docs/remote-server.md | 2 +- ...r_update_enterprise_custom_properties.snap | 74 ++++ ...update_organization_custom_properties.snap | 74 ++++ ...r_update_repository_custom_properties.snap | 44 ++ .../get_enterprise_custom_properties.snap | 20 + .../get_organization_custom_properties.snap | 20 + .../get_repository_custom_properties.snap | 25 ++ pkg/github/custom_properties.go | 412 ++++++++++++++++++ pkg/github/custom_properties_test.go | 242 ++++++++++ pkg/github/tools.go | 10 +- 11 files changed, 952 insertions(+), 4 deletions(-) create mode 100644 pkg/github/__toolsnaps__/create_or_update_enterprise_custom_properties.snap create mode 100644 pkg/github/__toolsnaps__/create_or_update_organization_custom_properties.snap create mode 100644 pkg/github/__toolsnaps__/create_or_update_repository_custom_properties.snap create mode 100644 pkg/github/__toolsnaps__/get_enterprise_custom_properties.snap create mode 100644 pkg/github/__toolsnaps__/get_organization_custom_properties.snap create mode 100644 pkg/github/__toolsnaps__/get_repository_custom_properties.snap create mode 100644 pkg/github/custom_properties.go create mode 100644 pkg/github/custom_properties_test.go diff --git a/README.md b/README.md index 6ccb9172a0..aaa0f18cfb 100644 --- a/README.md +++ b/README.md @@ -562,7 +562,7 @@ The following sets of tools are available: | comment-discussion | `discussions` | GitHub Discussions related tools | | logo-gist | `gists` | GitHub Gist related tools | | git-branch | `git` | GitHub Git API related tools for low-level Git operations | -| law | `governance` | Repository governance tools for managing rulesets at the repository, organization, and enterprise levels | +| law | `governance` | Repository governance tools for managing rulesets and custom properties at the repository, organization, and enterprise levels | | issue-opened | `issues` | GitHub Issues related tools | | tag | `labels` | GitHub Labels related tools | | bell | `notifications` | GitHub Notifications related tools | @@ -850,6 +850,22 @@ The following sets of tools are available: - `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' (string, optional) +- **create_or_update_enterprise_custom_properties** - Define enterprise custom properties + - **Required OAuth Scopes**: `admin:enterprise` + - `enterprise`: Enterprise slug (string, required) + - `properties`: The custom property definitions to create or update (object[], required) + +- **create_or_update_organization_custom_properties** - Define organization custom properties + - **Required OAuth Scopes**: `admin:org` + - `org`: Organization name (string, required) + - `properties`: The custom property definitions to create or update (object[], required) + +- **create_or_update_repository_custom_properties** - Set repository custom property values + - **Required OAuth Scopes**: `repo` + - `owner`: Repository owner (string, required) + - `properties`: The custom property values to assign to the repository (object[], required) + - `repo`: Repository name (string, required) + - **create_organization_repository_ruleset** - Create organization repository ruleset - **Required OAuth Scopes**: `admin:org` - `bypass_actors`: The actors that can bypass the rules in this ruleset (object[], optional) @@ -871,6 +887,21 @@ The following sets of tools are available: - `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' (string, optional) +- **get_enterprise_custom_properties** - Get enterprise custom properties + - **Required OAuth Scopes**: `read:enterprise` + - **Accepted OAuth Scopes**: `admin:enterprise`, `read:enterprise` + - `enterprise`: Enterprise slug (string, required) + +- **get_organization_custom_properties** - Get organization custom properties + - **Required OAuth Scopes**: `read:org` + - **Accepted OAuth Scopes**: `admin:org`, `read:org`, `write:org` + - `org`: Organization name (string, required) + +- **get_repository_custom_properties** - Get repository custom property values + - **Required OAuth Scopes**: `repo` + - `owner`: Repository owner (string, required) + - `repo`: Repository name (string, required) + - **organization_repository_ruleset_read** - Read organization repository rulesets - **Required OAuth Scopes**: `read:org` - **Accepted OAuth Scopes**: `admin:org`, `read:org`, `write:org` diff --git a/docs/remote-server.md b/docs/remote-server.md index 85721a34bf..7156b96c8f 100644 --- a/docs/remote-server.md +++ b/docs/remote-server.md @@ -29,7 +29,7 @@ Below is a table of available toolsets for the remote GitHub MCP Server. Each to | comment-discussion
`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) | | logo-gist
`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-branch
`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) | -| law
`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) | +| law
`governance` | Repository governance tools for managing rulesets and custom properties 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) | | issue-opened
`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) | | tag
`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) | | bell
`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/pkg/github/__toolsnaps__/create_or_update_enterprise_custom_properties.snap b/pkg/github/__toolsnaps__/create_or_update_enterprise_custom_properties.snap new file mode 100644 index 0000000000..65c287a78f --- /dev/null +++ b/pkg/github/__toolsnaps__/create_or_update_enterprise_custom_properties.snap @@ -0,0 +1,74 @@ +{ + "annotations": { + "title": "Define enterprise custom properties" + }, + "description": "Create or update custom property definitions (schema) for an enterprise", + "inputSchema": { + "properties": { + "enterprise": { + "description": "Enterprise slug", + "type": "string" + }, + "properties": { + "description": "The custom property definitions to create or update", + "items": { + "properties": { + "allowed_values": { + "description": "An ordered list of the allowed values of the property (for single_select and multi_select)", + "items": { + "type": "string" + }, + "type": "array" + }, + "default_value": { + "description": "Default value of the property. A string or an array of strings" + }, + "description": { + "description": "Short description of the property", + "type": "string" + }, + "property_name": { + "description": "The name of the custom property", + "type": "string" + }, + "required": { + "description": "Whether the property is required", + "type": "boolean" + }, + "value_type": { + "description": "The type of the value for the property", + "enum": [ + "string", + "single_select", + "multi_select", + "true_false", + "url" + ], + "type": "string" + }, + "values_editable_by": { + "description": "Who can edit the values of the property", + "enum": [ + "org_actors", + "org_and_repo_actors" + ], + "type": "string" + } + }, + "required": [ + "property_name", + "value_type" + ], + "type": "object" + }, + "type": "array" + } + }, + "required": [ + "enterprise", + "properties" + ], + "type": "object" + }, + "name": "create_or_update_enterprise_custom_properties" +} \ No newline at end of file diff --git a/pkg/github/__toolsnaps__/create_or_update_organization_custom_properties.snap b/pkg/github/__toolsnaps__/create_or_update_organization_custom_properties.snap new file mode 100644 index 0000000000..5a0a3956ef --- /dev/null +++ b/pkg/github/__toolsnaps__/create_or_update_organization_custom_properties.snap @@ -0,0 +1,74 @@ +{ + "annotations": { + "title": "Define organization custom properties" + }, + "description": "Create or update custom property definitions (schema) for an organization", + "inputSchema": { + "properties": { + "org": { + "description": "Organization name", + "type": "string" + }, + "properties": { + "description": "The custom property definitions to create or update", + "items": { + "properties": { + "allowed_values": { + "description": "An ordered list of the allowed values of the property (for single_select and multi_select)", + "items": { + "type": "string" + }, + "type": "array" + }, + "default_value": { + "description": "Default value of the property. A string or an array of strings" + }, + "description": { + "description": "Short description of the property", + "type": "string" + }, + "property_name": { + "description": "The name of the custom property", + "type": "string" + }, + "required": { + "description": "Whether the property is required", + "type": "boolean" + }, + "value_type": { + "description": "The type of the value for the property", + "enum": [ + "string", + "single_select", + "multi_select", + "true_false", + "url" + ], + "type": "string" + }, + "values_editable_by": { + "description": "Who can edit the values of the property", + "enum": [ + "org_actors", + "org_and_repo_actors" + ], + "type": "string" + } + }, + "required": [ + "property_name", + "value_type" + ], + "type": "object" + }, + "type": "array" + } + }, + "required": [ + "org", + "properties" + ], + "type": "object" + }, + "name": "create_or_update_organization_custom_properties" +} \ No newline at end of file diff --git a/pkg/github/__toolsnaps__/create_or_update_repository_custom_properties.snap b/pkg/github/__toolsnaps__/create_or_update_repository_custom_properties.snap new file mode 100644 index 0000000000..8867e94875 --- /dev/null +++ b/pkg/github/__toolsnaps__/create_or_update_repository_custom_properties.snap @@ -0,0 +1,44 @@ +{ + "annotations": { + "title": "Set repository custom property values" + }, + "description": "Create or update the custom property values assigned to a repository. The properties must already be defined at the organization level", + "inputSchema": { + "properties": { + "owner": { + "description": "Repository owner", + "type": "string" + }, + "properties": { + "description": "The custom property values to assign to the repository", + "items": { + "properties": { + "property_name": { + "description": "The name of the custom property", + "type": "string" + }, + "value": { + "description": "The value to assign. A string, an array of strings, or null to clear the value" + } + }, + "required": [ + "property_name" + ], + "type": "object" + }, + "type": "array" + }, + "repo": { + "description": "Repository name", + "type": "string" + } + }, + "required": [ + "owner", + "repo", + "properties" + ], + "type": "object" + }, + "name": "create_or_update_repository_custom_properties" +} \ No newline at end of file diff --git a/pkg/github/__toolsnaps__/get_enterprise_custom_properties.snap b/pkg/github/__toolsnaps__/get_enterprise_custom_properties.snap new file mode 100644 index 0000000000..f65ee69ff2 --- /dev/null +++ b/pkg/github/__toolsnaps__/get_enterprise_custom_properties.snap @@ -0,0 +1,20 @@ +{ + "annotations": { + "readOnlyHint": true, + "title": "Get enterprise custom properties" + }, + "description": "Get all custom property definitions (schema) for an enterprise", + "inputSchema": { + "properties": { + "enterprise": { + "description": "Enterprise slug", + "type": "string" + } + }, + "required": [ + "enterprise" + ], + "type": "object" + }, + "name": "get_enterprise_custom_properties" +} \ No newline at end of file diff --git a/pkg/github/__toolsnaps__/get_organization_custom_properties.snap b/pkg/github/__toolsnaps__/get_organization_custom_properties.snap new file mode 100644 index 0000000000..8210ea287d --- /dev/null +++ b/pkg/github/__toolsnaps__/get_organization_custom_properties.snap @@ -0,0 +1,20 @@ +{ + "annotations": { + "readOnlyHint": true, + "title": "Get organization custom properties" + }, + "description": "Get all custom property definitions (schema) for an organization", + "inputSchema": { + "properties": { + "org": { + "description": "Organization name", + "type": "string" + } + }, + "required": [ + "org" + ], + "type": "object" + }, + "name": "get_organization_custom_properties" +} \ No newline at end of file diff --git a/pkg/github/__toolsnaps__/get_repository_custom_properties.snap b/pkg/github/__toolsnaps__/get_repository_custom_properties.snap new file mode 100644 index 0000000000..2116bd94a1 --- /dev/null +++ b/pkg/github/__toolsnaps__/get_repository_custom_properties.snap @@ -0,0 +1,25 @@ +{ + "annotations": { + "readOnlyHint": true, + "title": "Get repository custom property values" + }, + "description": "Get the custom property values that are assigned to a repository", + "inputSchema": { + "properties": { + "owner": { + "description": "Repository owner", + "type": "string" + }, + "repo": { + "description": "Repository name", + "type": "string" + } + }, + "required": [ + "owner", + "repo" + ], + "type": "object" + }, + "name": "get_repository_custom_properties" +} \ No newline at end of file diff --git a/pkg/github/custom_properties.go b/pkg/github/custom_properties.go new file mode 100644 index 0000000000..3f2ed15cb8 --- /dev/null +++ b/pkg/github/custom_properties.go @@ -0,0 +1,412 @@ +package github + +import ( + "context" + "encoding/json" + "fmt" + + 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/v87/github" + "github.com/google/jsonschema-go/jsonschema" + "github.com/modelcontextprotocol/go-sdk/mcp" +) + +// GetRepositoryCustomProperties creates a tool to get the custom property values assigned to a repository. +func GetRepositoryCustomProperties(t translations.TranslationHelperFunc) inventory.ServerTool { + return NewTool( + ToolsetMetadataGovernance, + mcp.Tool{ + Name: "get_repository_custom_properties", + Description: t("TOOL_GET_REPOSITORY_CUSTOM_PROPERTIES_DESCRIPTION", "Get the custom property values that are assigned to a repository"), + Annotations: &mcp.ToolAnnotations{ + Title: t("TOOL_GET_REPOSITORY_CUSTOM_PROPERTIES_USER_TITLE", "Get repository custom property values"), + ReadOnlyHint: true, + }, + InputSchema: &jsonschema.Schema{ + Type: "object", + Properties: map[string]*jsonschema.Schema{ + "owner": { + Type: "string", + Description: "Repository owner", + }, + "repo": { + Type: "string", + Description: "Repository name", + }, + }, + Required: []string{"owner", "repo"}, + }, + }, + []scopes.Scope{scopes.Repo}, + func(ctx context.Context, deps ToolDependencies, _ *mcp.CallToolRequest, 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 + } + + client, err := deps.GetClient(ctx) + if err != nil { + return nil, nil, fmt.Errorf("failed to get GitHub client: %w", err) + } + + properties, resp, err := client.Repositories.GetAllCustomPropertyValues(ctx, owner, repo) + if err != nil { + return ghErrors.NewGitHubAPIErrorResponse(ctx, "failed to get repository custom properties", resp, err), nil, nil + } + defer func() { _ = resp.Body.Close() }() + + return MarshalledTextResult(properties), nil, nil + }, + ) +} + +// CreateOrUpdateRepositoryCustomProperties creates a tool to set custom property values on a repository. +func CreateOrUpdateRepositoryCustomProperties(t translations.TranslationHelperFunc) inventory.ServerTool { + return NewTool( + ToolsetMetadataGovernance, + mcp.Tool{ + Name: "create_or_update_repository_custom_properties", + Description: t("TOOL_CREATE_OR_UPDATE_REPOSITORY_CUSTOM_PROPERTIES_DESCRIPTION", "Create or update the custom property values assigned to a repository. The properties must already be defined at the organization level"), + Annotations: &mcp.ToolAnnotations{ + Title: t("TOOL_CREATE_OR_UPDATE_REPOSITORY_CUSTOM_PROPERTIES_USER_TITLE", "Set repository custom property values"), + ReadOnlyHint: false, + }, + InputSchema: &jsonschema.Schema{ + Type: "object", + Properties: map[string]*jsonschema.Schema{ + "owner": { + Type: "string", + Description: "Repository owner", + }, + "repo": { + Type: "string", + Description: "Repository name", + }, + "properties": { + Type: "array", + Description: "The custom property values to assign to the repository", + Items: customPropertyValueItemSchema(), + }, + }, + Required: []string{"owner", "repo", "properties"}, + }, + }, + []scopes.Scope{scopes.Repo}, + func(ctx context.Context, deps ToolDependencies, _ *mcp.CallToolRequest, 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 + } + values, errResult := parseCustomProperties[*github.CustomPropertyValue](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) + } + + resp, err := client.Repositories.CreateOrUpdateCustomProperties(ctx, owner, repo, values) + if err != nil { + return ghErrors.NewGitHubAPIErrorResponse(ctx, "failed to update repository custom properties", resp, err), nil, nil + } + defer func() { _ = resp.Body.Close() }() + + return utils.NewToolResultText("Repository custom property values updated successfully"), nil, nil + }, + ) +} + +// GetOrganizationCustomProperties creates a tool to get the custom property definitions for an organization. +func GetOrganizationCustomProperties(t translations.TranslationHelperFunc) inventory.ServerTool { + return NewTool( + ToolsetMetadataGovernance, + mcp.Tool{ + Name: "get_organization_custom_properties", + Description: t("TOOL_GET_ORGANIZATION_CUSTOM_PROPERTIES_DESCRIPTION", "Get all custom property definitions (schema) for an organization"), + Annotations: &mcp.ToolAnnotations{ + Title: t("TOOL_GET_ORGANIZATION_CUSTOM_PROPERTIES_USER_TITLE", "Get organization custom properties"), + ReadOnlyHint: true, + }, + InputSchema: &jsonschema.Schema{ + Type: "object", + Properties: map[string]*jsonschema.Schema{ + "org": { + Type: "string", + Description: "Organization name", + }, + }, + Required: []string{"org"}, + }, + }, + []scopes.Scope{scopes.ReadOrg}, + func(ctx context.Context, deps ToolDependencies, _ *mcp.CallToolRequest, args map[string]any) (*mcp.CallToolResult, any, error) { + org, err := RequiredParam[string](args, "org") + 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) + } + + properties, resp, err := client.Organizations.GetAllCustomProperties(ctx, org) + if err != nil { + return ghErrors.NewGitHubAPIErrorResponse(ctx, "failed to get organization custom properties", resp, err), nil, nil + } + defer func() { _ = resp.Body.Close() }() + + return MarshalledTextResult(properties), nil, nil + }, + ) +} + +// CreateOrUpdateOrganizationCustomProperties creates a tool to define custom properties for an organization. +func CreateOrUpdateOrganizationCustomProperties(t translations.TranslationHelperFunc) inventory.ServerTool { + return NewTool( + ToolsetMetadataGovernance, + mcp.Tool{ + Name: "create_or_update_organization_custom_properties", + Description: t("TOOL_CREATE_OR_UPDATE_ORGANIZATION_CUSTOM_PROPERTIES_DESCRIPTION", "Create or update custom property definitions (schema) for an organization"), + Annotations: &mcp.ToolAnnotations{ + Title: t("TOOL_CREATE_OR_UPDATE_ORGANIZATION_CUSTOM_PROPERTIES_USER_TITLE", "Define organization custom properties"), + ReadOnlyHint: false, + }, + InputSchema: &jsonschema.Schema{ + Type: "object", + Properties: map[string]*jsonschema.Schema{ + "org": { + Type: "string", + Description: "Organization name", + }, + "properties": { + Type: "array", + Description: "The custom property definitions to create or update", + Items: customPropertyDefinitionItemSchema(), + }, + }, + Required: []string{"org", "properties"}, + }, + }, + []scopes.Scope{scopes.AdminOrg}, + func(ctx context.Context, deps ToolDependencies, _ *mcp.CallToolRequest, args map[string]any) (*mcp.CallToolResult, any, error) { + org, err := RequiredParam[string](args, "org") + if err != nil { + return utils.NewToolResultError(err.Error()), nil, nil + } + properties, errResult := parseCustomProperties[*github.CustomProperty](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) + } + + updated, resp, err := client.Organizations.CreateOrUpdateCustomProperties(ctx, org, properties) + if err != nil { + return ghErrors.NewGitHubAPIErrorResponse(ctx, "failed to update organization custom properties", resp, err), nil, nil + } + defer func() { _ = resp.Body.Close() }() + + return MarshalledTextResult(updated), nil, nil + }, + ) +} + +// GetEnterpriseCustomProperties creates a tool to get the custom property definitions for an enterprise. +func GetEnterpriseCustomProperties(t translations.TranslationHelperFunc) inventory.ServerTool { + return NewTool( + ToolsetMetadataGovernance, + mcp.Tool{ + Name: "get_enterprise_custom_properties", + Description: t("TOOL_GET_ENTERPRISE_CUSTOM_PROPERTIES_DESCRIPTION", "Get all custom property definitions (schema) for an enterprise"), + Annotations: &mcp.ToolAnnotations{ + Title: t("TOOL_GET_ENTERPRISE_CUSTOM_PROPERTIES_USER_TITLE", "Get enterprise custom properties"), + ReadOnlyHint: true, + }, + InputSchema: &jsonschema.Schema{ + Type: "object", + Properties: map[string]*jsonschema.Schema{ + "enterprise": { + Type: "string", + Description: "Enterprise slug", + }, + }, + Required: []string{"enterprise"}, + }, + }, + []scopes.Scope{scopes.ReadEnterprise}, + func(ctx context.Context, deps ToolDependencies, _ *mcp.CallToolRequest, args map[string]any) (*mcp.CallToolResult, any, error) { + enterprise, err := RequiredParam[string](args, "enterprise") + 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) + } + + properties, resp, err := client.Enterprise.GetAllCustomProperties(ctx, enterprise) + if err != nil { + return ghErrors.NewGitHubAPIErrorResponse(ctx, "failed to get enterprise custom properties", resp, err), nil, nil + } + defer func() { _ = resp.Body.Close() }() + + return MarshalledTextResult(properties), nil, nil + }, + ) +} + +// CreateOrUpdateEnterpriseCustomProperties creates a tool to define custom properties for an enterprise. +func CreateOrUpdateEnterpriseCustomProperties(t translations.TranslationHelperFunc) inventory.ServerTool { + return NewTool( + ToolsetMetadataGovernance, + mcp.Tool{ + Name: "create_or_update_enterprise_custom_properties", + Description: t("TOOL_CREATE_OR_UPDATE_ENTERPRISE_CUSTOM_PROPERTIES_DESCRIPTION", "Create or update custom property definitions (schema) for an enterprise"), + Annotations: &mcp.ToolAnnotations{ + Title: t("TOOL_CREATE_OR_UPDATE_ENTERPRISE_CUSTOM_PROPERTIES_USER_TITLE", "Define enterprise custom properties"), + ReadOnlyHint: false, + }, + InputSchema: &jsonschema.Schema{ + Type: "object", + Properties: map[string]*jsonschema.Schema{ + "enterprise": { + Type: "string", + Description: "Enterprise slug", + }, + "properties": { + Type: "array", + Description: "The custom property definitions to create or update", + Items: customPropertyDefinitionItemSchema(), + }, + }, + Required: []string{"enterprise", "properties"}, + }, + }, + []scopes.Scope{scopes.AdminEnterprise}, + func(ctx context.Context, deps ToolDependencies, _ *mcp.CallToolRequest, args map[string]any) (*mcp.CallToolResult, any, error) { + enterprise, err := RequiredParam[string](args, "enterprise") + if err != nil { + return utils.NewToolResultError(err.Error()), nil, nil + } + properties, errResult := parseCustomProperties[*github.CustomProperty](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) + } + + updated, resp, err := client.Enterprise.CreateOrUpdateCustomProperties(ctx, enterprise, properties) + if err != nil { + return ghErrors.NewGitHubAPIErrorResponse(ctx, "failed to update enterprise custom properties", resp, err), nil, nil + } + defer func() { _ = resp.Body.Close() }() + + return MarshalledTextResult(updated), nil, nil + }, + ) +} + +// customPropertyValueItemSchema describes a single custom property value assigned +// to a repository. +func customPropertyValueItemSchema() *jsonschema.Schema { + return &jsonschema.Schema{ + Type: "object", + Properties: map[string]*jsonschema.Schema{ + "property_name": { + Type: "string", + Description: "The name of the custom property", + }, + "value": { + Description: "The value to assign. A string, an array of strings, or null to clear the value", + }, + }, + Required: []string{"property_name"}, + } +} + +// customPropertyDefinitionItemSchema describes a single custom property definition +// in an organization or enterprise schema. +func customPropertyDefinitionItemSchema() *jsonschema.Schema { + return &jsonschema.Schema{ + Type: "object", + Properties: map[string]*jsonschema.Schema{ + "property_name": { + Type: "string", + Description: "The name of the custom property", + }, + "value_type": { + Type: "string", + Enum: []any{"string", "single_select", "multi_select", "true_false", "url"}, + Description: "The type of the value for the property", + }, + "required": { + Type: "boolean", + Description: "Whether the property is required", + }, + "default_value": { + Description: "Default value of the property. A string or an array of strings", + }, + "description": { + Type: "string", + Description: "Short description of the property", + }, + "allowed_values": { + Type: "array", + Description: "An ordered list of the allowed values of the property (for single_select and multi_select)", + Items: &jsonschema.Schema{Type: "string"}, + }, + "values_editable_by": { + Type: "string", + Enum: []any{"org_actors", "org_and_repo_actors"}, + Description: "Who can edit the values of the property", + }, + }, + Required: []string{"property_name", "value_type"}, + } +} + +// parseCustomProperties reads the "properties" array argument and decodes it into +// the requested go-github type. It returns a non-nil *mcp.CallToolResult +// describing the problem when the argument is missing or malformed. +func parseCustomProperties[T any](args map[string]any) ([]T, *mcp.CallToolResult) { + raw, ok := args["properties"] + if !ok || raw == nil { + return nil, utils.NewToolResultError("properties parameter is required") + } + arr, ok := raw.([]any) + if !ok { + return nil, utils.NewToolResultError("properties parameter must be an array") + } + + encoded, err := json.Marshal(arr) + if err != nil { + return nil, utils.NewToolResultErrorFromErr("failed to encode properties", err) + } + var out []T + if err := json.Unmarshal(encoded, &out); err != nil { + return nil, utils.NewToolResultErrorFromErr("failed to parse properties", err) + } + return out, nil +} diff --git a/pkg/github/custom_properties_test.go b/pkg/github/custom_properties_test.go new file mode 100644 index 0000000000..2189697d4a --- /dev/null +++ b/pkg/github/custom_properties_test.go @@ -0,0 +1,242 @@ +package github + +import ( + "context" + "encoding/json" + "io" + "net/http" + "testing" + + "github.com/google/go-github/v87/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/translations" +) + +func Test_GetRepositoryCustomProperties(t *testing.T) { + toolDef := GetRepositoryCustomProperties(translations.NullTranslationHelper) + require.NoError(t, toolsnaps.Test(toolDef.Tool.Name, toolDef.Tool)) + + assert.Equal(t, "get_repository_custom_properties", toolDef.Tool.Name) + assert.True(t, toolDef.Tool.Annotations.ReadOnlyHint) + + schema, ok := toolDef.Tool.InputSchema.(*jsonschema.Schema) + require.True(t, ok) + assert.ElementsMatch(t, schema.Required, []string{"owner", "repo"}) + + mockValues := []*github.CustomPropertyValue{{PropertyName: "environment", Value: "production"}} + client := mustNewGHClient(t, MockHTTPClientWithHandlers(map[string]http.HandlerFunc{ + "GET /repos/{owner}/{repo}/properties/values": mockResponse(t, http.StatusOK, mockValues), + })) + deps := BaseDeps{Client: client} + handler := toolDef.Handler(deps) + request := createMCPRequest(map[string]any{"owner": "owner", "repo": "repo"}) + + result, err := handler(ContextWithDeps(context.Background(), deps), &request) + require.NoError(t, err) + require.False(t, result.IsError) + + var returned []*github.CustomPropertyValue + require.NoError(t, json.Unmarshal([]byte(getTextResult(t, result).Text), &returned)) + require.Len(t, returned, 1) + assert.Equal(t, "environment", returned[0].PropertyName) +} + +func Test_CreateOrUpdateRepositoryCustomProperties(t *testing.T) { + toolDef := CreateOrUpdateRepositoryCustomProperties(translations.NullTranslationHelper) + require.NoError(t, toolsnaps.Test(toolDef.Tool.Name, toolDef.Tool)) + + assert.Equal(t, "create_or_update_repository_custom_properties", 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{"owner", "repo", "properties"}) + + var captured struct { + Properties []*github.CustomPropertyValue `json:"properties"` + } + client := mustNewGHClient(t, MockHTTPClientWithHandlers(map[string]http.HandlerFunc{ + "PATCH /repos/{owner}/{repo}/properties/values": func(w http.ResponseWriter, r *http.Request) { + body, _ := io.ReadAll(r.Body) + _ = json.Unmarshal(body, &captured) + w.WriteHeader(http.StatusNoContent) + }, + })) + deps := BaseDeps{Client: client} + handler := toolDef.Handler(deps) + request := createMCPRequest(map[string]any{ + "owner": "owner", + "repo": "repo", + "properties": []any{ + map[string]any{"property_name": "environment", "value": "production"}, + }, + }) + + result, err := handler(ContextWithDeps(context.Background(), deps), &request) + require.NoError(t, err) + require.False(t, result.IsError) + + require.Len(t, captured.Properties, 1) + assert.Equal(t, "environment", captured.Properties[0].PropertyName) + assert.Equal(t, "production", captured.Properties[0].Value) +} + +func Test_CreateOrUpdateRepositoryCustomProperties_MissingProperties(t *testing.T) { + toolDef := CreateOrUpdateRepositoryCustomProperties(translations.NullTranslationHelper) + client := mustNewGHClient(t, MockHTTPClientWithHandlers(map[string]http.HandlerFunc{})) + deps := BaseDeps{Client: client} + handler := toolDef.Handler(deps) + request := createMCPRequest(map[string]any{"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, "properties parameter is required") +} + +func Test_GetOrganizationCustomProperties(t *testing.T) { + toolDef := GetOrganizationCustomProperties(translations.NullTranslationHelper) + require.NoError(t, toolsnaps.Test(toolDef.Tool.Name, toolDef.Tool)) + + assert.Equal(t, "get_organization_custom_properties", toolDef.Tool.Name) + assert.True(t, toolDef.Tool.Annotations.ReadOnlyHint) + + schema, ok := toolDef.Tool.InputSchema.(*jsonschema.Schema) + require.True(t, ok) + assert.ElementsMatch(t, schema.Required, []string{"org"}) + + mockProps := []*github.CustomProperty{{PropertyName: github.Ptr("environment"), ValueType: "single_select"}} + client := mustNewGHClient(t, MockHTTPClientWithHandlers(map[string]http.HandlerFunc{ + "GET /orgs/{org}/properties/schema": mockResponse(t, http.StatusOK, mockProps), + })) + deps := BaseDeps{Client: client} + handler := toolDef.Handler(deps) + request := createMCPRequest(map[string]any{"org": "octo"}) + + result, err := handler(ContextWithDeps(context.Background(), deps), &request) + require.NoError(t, err) + require.False(t, result.IsError) + + var returned []*github.CustomProperty + require.NoError(t, json.Unmarshal([]byte(getTextResult(t, result).Text), &returned)) + require.Len(t, returned, 1) + assert.Equal(t, "environment", returned[0].GetPropertyName()) +} + +func Test_CreateOrUpdateOrganizationCustomProperties(t *testing.T) { + toolDef := CreateOrUpdateOrganizationCustomProperties(translations.NullTranslationHelper) + require.NoError(t, toolsnaps.Test(toolDef.Tool.Name, toolDef.Tool)) + + assert.Equal(t, "create_or_update_organization_custom_properties", 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{"org", "properties"}) + + var captured struct { + Properties []*github.CustomProperty `json:"properties"` + } + client := mustNewGHClient(t, MockHTTPClientWithHandlers(map[string]http.HandlerFunc{ + "PATCH /orgs/{org}/properties/schema": func(w http.ResponseWriter, r *http.Request) { + body, _ := io.ReadAll(r.Body) + _ = json.Unmarshal(body, &captured) + w.WriteHeader(http.StatusOK) + _, _ = w.Write([]byte(`[{"property_name":"environment","value_type":"single_select"}]`)) + }, + })) + deps := BaseDeps{Client: client} + handler := toolDef.Handler(deps) + request := createMCPRequest(map[string]any{ + "org": "octo", + "properties": []any{ + map[string]any{ + "property_name": "environment", + "value_type": "single_select", + "required": true, + "allowed_values": []any{"production", "staging"}, + }, + }, + }) + + result, err := handler(ContextWithDeps(context.Background(), deps), &request) + require.NoError(t, err) + require.False(t, result.IsError) + + require.Len(t, captured.Properties, 1) + assert.Equal(t, "environment", captured.Properties[0].GetPropertyName()) + assert.Equal(t, github.PropertyValueType("single_select"), captured.Properties[0].ValueType) + assert.ElementsMatch(t, []string{"production", "staging"}, captured.Properties[0].AllowedValues) +} + +func Test_GetEnterpriseCustomProperties(t *testing.T) { + toolDef := GetEnterpriseCustomProperties(translations.NullTranslationHelper) + require.NoError(t, toolsnaps.Test(toolDef.Tool.Name, toolDef.Tool)) + + assert.Equal(t, "get_enterprise_custom_properties", toolDef.Tool.Name) + assert.True(t, toolDef.Tool.Annotations.ReadOnlyHint) + + schema, ok := toolDef.Tool.InputSchema.(*jsonschema.Schema) + require.True(t, ok) + assert.ElementsMatch(t, schema.Required, []string{"enterprise"}) + + mockProps := []*github.CustomProperty{{PropertyName: github.Ptr("compliance"), ValueType: "true_false"}} + client := mustNewGHClient(t, MockHTTPClientWithHandlers(map[string]http.HandlerFunc{ + "GET /enterprises/{enterprise}/properties/schema": mockResponse(t, http.StatusOK, mockProps), + })) + deps := BaseDeps{Client: client} + handler := toolDef.Handler(deps) + request := createMCPRequest(map[string]any{"enterprise": "acme"}) + + result, err := handler(ContextWithDeps(context.Background(), deps), &request) + require.NoError(t, err) + require.False(t, result.IsError) + + var returned []*github.CustomProperty + require.NoError(t, json.Unmarshal([]byte(getTextResult(t, result).Text), &returned)) + require.Len(t, returned, 1) + assert.Equal(t, "compliance", returned[0].GetPropertyName()) +} + +func Test_CreateOrUpdateEnterpriseCustomProperties(t *testing.T) { + toolDef := CreateOrUpdateEnterpriseCustomProperties(translations.NullTranslationHelper) + require.NoError(t, toolsnaps.Test(toolDef.Tool.Name, toolDef.Tool)) + + assert.Equal(t, "create_or_update_enterprise_custom_properties", 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{"enterprise", "properties"}) + + var captured struct { + Properties []*github.CustomProperty `json:"properties"` + } + client := mustNewGHClient(t, MockHTTPClientWithHandlers(map[string]http.HandlerFunc{ + "PATCH /enterprises/{enterprise}/properties/schema": func(w http.ResponseWriter, r *http.Request) { + body, _ := io.ReadAll(r.Body) + _ = json.Unmarshal(body, &captured) + w.WriteHeader(http.StatusOK) + _, _ = w.Write([]byte(`[{"property_name":"compliance","value_type":"true_false"}]`)) + }, + })) + deps := BaseDeps{Client: client} + handler := toolDef.Handler(deps) + request := createMCPRequest(map[string]any{ + "enterprise": "acme", + "properties": []any{ + map[string]any{"property_name": "compliance", "value_type": "true_false"}, + }, + }) + + result, err := handler(ContextWithDeps(context.Background(), deps), &request) + require.NoError(t, err) + require.False(t, result.IsError) + + require.Len(t, captured.Properties, 1) + assert.Equal(t, "compliance", captured.Properties[0].GetPropertyName()) +} diff --git a/pkg/github/tools.go b/pkg/github/tools.go index c33485e56f..b77e1a303c 100644 --- a/pkg/github/tools.go +++ b/pkg/github/tools.go @@ -74,7 +74,7 @@ var ( } ToolsetMetadataGovernance = inventory.ToolsetMetadata{ ID: "governance", - Description: "Repository governance tools for managing rulesets at the repository, organization, and enterprise levels", + Description: "Repository governance tools for managing rulesets and custom properties at the repository, organization, and enterprise levels", Icon: "law", } ToolsetMetadataActions = inventory.ToolsetMetadata{ @@ -228,12 +228,18 @@ func AllTools(t translations.TranslationHelperFunc) []inventory.ServerTool { // Organization tools SearchOrgs(t), - // Governance tools (rulesets) + // Governance tools (rulesets & custom properties) RepositoryRulesetRead(t), OrganizationRepositoryRulesetRead(t), CreateRepositoryRuleset(t), CreateOrganizationRepositoryRuleset(t), CreateEnterpriseRepositoryRuleset(t), + GetRepositoryCustomProperties(t), + CreateOrUpdateRepositoryCustomProperties(t), + GetOrganizationCustomProperties(t), + CreateOrUpdateOrganizationCustomProperties(t), + GetEnterpriseCustomProperties(t), + CreateOrUpdateEnterpriseCustomProperties(t), // Pull request tools PullRequestRead(t),