diff --git a/README.md b/README.md index faba32a2a..cc57d9baa 100644 --- a/README.md +++ b/README.md @@ -127,7 +127,7 @@ GitHub Enterprise Server does not support remote server hosting. Please refer to 1. To run the server in a container, you will need to have [Docker](https://www.docker.com/) installed. 2. Once Docker is installed, you will also need to ensure Docker is running. The image is public; if you get errors on pull, you may have an expired token and need to `docker logout ghcr.io`. 3. Lastly you will need to [Create a GitHub Personal Access Token](https://github.com/settings/personal-access-tokens/new). -The MCP server can use many of the GitHub APIs, so enable the permissions that you feel comfortable granting your AI tools (to learn more about access tokens, please check out the [documentation](https://docs.github.com/en/authentication/keeping-your-account-and-data-secure/managing-your-personal-access-tokens)). +The MCP server can use many of the GitHub APIs, so enable the permissions that you feel comfortable granting your AI tools (to learn more about access tokens, please check out the [documentation](https://docs.github.com/en/authentication/keeping-your-account-and-data-secure/managing-your-personal-access-tokens)). For a detailed list of which scopes and permissions are required by each tool, see the [Tool Permissions Documentation](docs/tool-permissions.md).
Handling PATs Securely @@ -426,6 +426,8 @@ The following sets of tools are available: ## Tools +> **📋 Authentication Requirements**: For detailed information about the OAuth scopes and fine-grained permissions required by each tool, see the [Tool Permissions Documentation](docs/tool-permissions.md). +
diff --git a/docs/tool-permissions.md b/docs/tool-permissions.md new file mode 100644 index 000000000..da746f621 --- /dev/null +++ b/docs/tool-permissions.md @@ -0,0 +1,312 @@ +# Tool Authentication Requirements + +This document provides a comprehensive reference for the authentication requirements of each tool in the GitHub MCP Server. It covers both OAuth scopes (for classic personal access tokens and OAuth apps) and fine-grained permissions (for fine-grained personal access tokens). + +## Quick Reference + +- **OAuth Scopes**: Used by OAuth apps and classic Personal Access Tokens (PATs) +- **Fine-Grained Permissions**: Used by fine-grained Personal Access Tokens + +For OAuth scopes documentation, see: [Scopes for OAuth Apps](https://docs.github.com/en/apps/oauth-apps/building-oauth-apps/scopes-for-oauth-apps) + +For fine-grained permission documentation, see: [Permissions for Fine-Grained PATs](https://docs.github.com/en/rest/authentication/permissions-required-for-fine-grained-personal-access-tokens) + +## Scope and Permission Hierarchy + +### OAuth Scope Hierarchy + +Some OAuth scopes include access to other scopes. If you have a parent scope, you automatically have access to all child scopes: + +| Parent Scope | Includes | +|-------------|----------| +| `repo` | `repo:status`, `repo_deployment`, `public_repo`, `repo:invite`, `security_events` | +| `user` | `read:user`, `user:email`, `user:follow` | +| `admin:org` | `write:org`, `read:org` | +| `write:org` | `read:org` | +| `admin:repo_hook` | `write:repo_hook`, `read:repo_hook` | +| `write:repo_hook` | `read:repo_hook` | +| `admin:public_key` | `write:public_key`, `read:public_key` | +| `write:public_key` | `read:public_key` | +| `admin:gpg_key` | `write:gpg_key`, `read:gpg_key` | +| `write:gpg_key` | `read:gpg_key` | +| `project` | `read:project` | +| `write:packages` | `read:packages` | + +### Fine-Grained Permission Levels + +Fine-grained permissions have three access levels: + +| Level | Description | +|-------|-------------| +| `read` | Read-only access to the resource | +| `write` | Read and write access to the resource | +| `admin` | Full administrative access to the resource | + +Write access typically includes read access, and admin access typically includes both read and write access. + +--- + +## Tools by Category + +### Repository Tools + +| Tool | OAuth Scope | Fine-Grained Permission | +|------|-------------|------------------------| +| `get_file_contents` | `repo` | `contents:read` | +| `create_or_update_file` | `repo` | `contents:write` | +| `delete_file` | `repo` | `contents:write` | +| `push_files` | `repo` | `contents:write` | +| `create_repository` | `repo` | `administration:write` | +| `fork_repository` | `repo` | `contents:read`, `administration:write` | +| `create_branch` | `repo` | `contents:write` | +| `list_branches` | `repo` | `contents:read` | +| `list_commits` | `repo` | `contents:read` | +| `get_commit` | `repo` | `contents:read` | +| `list_tags` | `repo` | `contents:read` | +| `get_tag` | `repo` | `contents:read` | +| `list_releases` | `repo` | `contents:read` | +| `get_latest_release` | `repo` | `contents:read` | +| `get_release_by_tag` | `repo` | `contents:read` | +| `star_repository` | `public_repo` | `starring:write` | +| `unstar_repository` | `public_repo` | `starring:write` | +| `list_starred_repositories` | *(none)* | `starring:read` | +| `get_repository_tree` | `repo` | `contents:read` | + +### Issue Tools + +| Tool | OAuth Scope | Fine-Grained Permission | +|------|-------------|------------------------| +| `list_issues` | `repo` | `issues:read` | +| `get_issue` | `repo` | `issues:read` | +| `create_issue` | `repo` | `issues:write` | +| `update_issue` | `repo` | `issues:write` | +| `add_issue_comment` | `repo` | `issues:write` | +| `list_issue_comments` | `repo` | `issues:read` | +| `search_issues` | `repo` | `issues:read` | +| `list_issue_types` | `read:org` | `issues:read` | +| `assign_copilot_to_issue` | `repo` | `issues:write` | + +### Pull Request Tools + +| Tool | OAuth Scope | Fine-Grained Permission | +|------|-------------|------------------------| +| `list_pull_requests` | `repo` | `pull_requests:read` | +| `get_pull_request` | `repo` | `pull_requests:read` | +| `create_pull_request` | `repo` | `pull_requests:write` | +| `update_pull_request` | `repo` | `pull_requests:write` | +| `merge_pull_request` | `repo` | `contents:write`, `pull_requests:write` | +| `list_pull_request_commits` | `repo` | `pull_requests:read` | +| `get_pull_request_diff` | `repo` | `pull_requests:read` | +| `get_pull_request_files` | `repo` | `pull_requests:read` | +| `update_pull_request_branch` | `repo` | `contents:write`, `pull_requests:write` | +| `list_pull_request_reviews` | `repo` | `pull_requests:read` | +| `create_pull_request_review` | `repo` | `pull_requests:write` | +| `add_pull_request_review_comment` | `repo` | `pull_requests:write` | +| `request_copilot_review` | `repo` | `pull_requests:write` | +| `get_pull_request_review` | `repo` | `pull_requests:read` | +| `get_pull_request_comments` | `repo` | `pull_requests:read` | +| `create_pending_pull_request_review` | `repo` | `pull_requests:write` | +| `submit_pending_pull_request_review` | `repo` | `pull_requests:write` | +| `delete_pending_pull_request_review` | `repo` | `pull_requests:write` | + +### Git Tools + +| Tool | OAuth Scope | Fine-Grained Permission | +|------|-------------|------------------------| +| `create_git_tag` | `repo` | `contents:write` | +| `create_tree` | `repo` | `contents:write` | + +### Actions Tools + +| Tool | OAuth Scope | Fine-Grained Permission | +|------|-------------|------------------------| +| `list_workflows` | `repo` | `actions:read` | +| `list_workflow_runs` | `repo` | `actions:read` | +| `get_workflow_run` | `repo` | `actions:read` | +| `get_workflow_run_logs` | `repo` | `actions:read` | +| `run_workflow` | `repo` | `actions:write` | +| `cancel_workflow_run` | `repo` | `actions:write` | +| `rerun_workflow` | `repo` | `actions:write` | +| `rerun_failed_jobs` | `repo` | `actions:write` | +| `list_workflow_jobs` | `repo` | `actions:read` | +| `get_job_logs` | `repo` | `actions:read` | +| `list_workflow_run_artifacts` | `repo` | `actions:read` | +| `download_workflow_run_artifact` | `repo` | `actions:read` | +| `get_workflow_run_usage` | `repo` | `actions:read` | + +### Label Tools + +| Tool | OAuth Scope | Fine-Grained Permission | +|------|-------------|------------------------| +| `list_labels` | `repo` | `issues:read` or `pull_requests:read` | +| `get_label` | `repo` | `issues:read` or `pull_requests:read` | +| `label_write` | `repo` | `issues:write` or `pull_requests:write` | + +### Notification Tools + +| Tool | OAuth Scope | Fine-Grained Permission | +|------|-------------|------------------------| +| `list_notifications` | `notifications` | *N/A - Requires classic token* | +| `get_notification_details` | `notifications` | *N/A - Requires classic token* | +| `dismiss_notification` | `notifications` | *N/A - Requires classic token* | +| `mark_all_notifications_read` | `notifications` | *N/A - Requires classic token* | +| `manage_notification_subscription` | `notifications` | *N/A - Requires classic token* | +| `manage_repository_notification_subscription` | `notifications` | *N/A - Requires classic token* | + +> **Note**: Notification endpoints are not available with fine-grained PATs. Use a classic PAT with the `notifications` scope. + +### Discussion Tools + +| Tool | OAuth Scope | Fine-Grained Permission | +|------|-------------|------------------------| +| `list_discussions` | `repo` | `discussions:read` | +| `get_discussion` | `repo` | `discussions:read` | +| `list_discussion_categories` | `repo` | `discussions:read` | +| `get_discussion_comments` | `repo` | `discussions:read` | + +### Project Tools + +| Tool | OAuth Scope | Fine-Grained Permission | +|------|-------------|------------------------| +| `list_projects` | `read:project` | `organization_projects:read` | +| `get_project` | `read:project` | `organization_projects:read` | +| `list_project_items` | `read:project` | `organization_projects:read` | +| `get_project_item` | `read:project` | `organization_projects:read` | +| `list_project_fields` | `read:project` | `organization_projects:read` | +| `update_project_item` | `project` | `organization_projects:write` | +| `create_project_draft` | `project` | `organization_projects:write` | +| `add_project_item` | `project` | `organization_projects:write` | +| `delete_project_item` | `project` | `organization_projects:write` | + +### Gist Tools + +| Tool | OAuth Scope | Fine-Grained Permission | +|------|-------------|------------------------| +| `list_gists` | *(none)* | `gists:read` | +| `get_gist` | *(none)* | `gists:read` | +| `create_gist` | `gist` | `gists:write` | +| `update_gist` | `gist` | `gists:write` | + +### Search Tools + +| Tool | OAuth Scope | Fine-Grained Permission | +|------|-------------|------------------------| +| `search_code` | `repo` | `contents:read` | +| `search_issues` | `repo` | `issues:read` | +| `search_users` | `repo` | `metadata:read` | +| `search_repositories` | `repo` | `metadata:read` | + +### Security Tools + +#### Code Scanning + +| Tool | OAuth Scope | Fine-Grained Permission | +|------|-------------|------------------------| +| `list_code_scanning_alerts` | `security_events` | `code_scanning_alerts:read` | +| `get_code_scanning_alert` | `security_events` | `code_scanning_alerts:read` | +| `update_code_scanning_alert` | `security_events` | `code_scanning_alerts:write` | + +#### Secret Scanning + +| Tool | OAuth Scope | Fine-Grained Permission | +|------|-------------|------------------------| +| `list_secret_scanning_alerts` | `security_events` | `secret_scanning_alerts:read` | +| `get_secret_scanning_alert` | `security_events` | `secret_scanning_alerts:read` | + +#### Dependabot + +| Tool | OAuth Scope | Fine-Grained Permission | +|------|-------------|------------------------| +| `list_dependabot_alerts` | `repo` | `dependabot_alerts:read` | +| `get_dependabot_alert` | `repo` | `dependabot_alerts:read` | +| `update_dependabot_alert` | `repo` | `dependabot_alerts:write` | + +#### Security Advisories + +| Tool | OAuth Scope | Fine-Grained Permission | +|------|-------------|------------------------| +| `list_repository_security_advisories` | `repo` | `repository_security_advisories:read` | +| `get_global_security_advisory` | *(none)* | *(none - public data)* | +| `list_global_security_advisories` | *(none)* | *(none - public data)* | + +### Context Tools + +| Tool | OAuth Scope | Fine-Grained Permission | +|------|-------------|------------------------| +| `get_me` | *(none)* | `metadata:read` | +| `list_teams` | `read:org` | `members:read` | +| `get_team_members` | `read:org` | `members:read` | + +### Dynamic Tools (Meta-tools) + +These tools are internal to the MCP server and don't call GitHub APIs: + +| Tool | OAuth Scope | Fine-Grained Permission | +|------|-------------|------------------------| +| `enable_toolset` | *(none)* | *(none)* | +| `list_available_toolsets` | *(none)* | *(none)* | +| `get_toolset_tools` | *(none)* | *(none)* | + +--- + +## Minimum Required Scopes by Use Case + +### Read-Only Access + +If you only need to read data (no modifications): + +**OAuth Scopes:** +- `repo` - For private repositories +- `public_repo` - For public repositories only +- `read:org` - For organization and team information +- `read:project` - For project boards + +**Fine-Grained Permissions:** +- `contents:read` +- `issues:read` +- `pull_requests:read` +- `actions:read` +- `metadata:read` + +### Full Development Workflow + +For a typical development workflow (read, write, manage PRs and issues): + +**OAuth Scopes:** +- `repo` - Covers most repository operations +- `notifications` - If using notification tools +- `project` - If using project boards + +**Fine-Grained Permissions:** +- `contents:write` +- `issues:write` +- `pull_requests:write` +- `actions:write` +- `metadata:read` + +### Security Scanning + +For security-related tools: + +**OAuth Scopes:** +- `security_events` - For code scanning and secret scanning +- `repo` - For Dependabot alerts (included in `repo`) + +**Fine-Grained Permissions:** +- `code_scanning_alerts:read` or `write` +- `secret_scanning_alerts:read` +- `dependabot_alerts:read` or `write` + +--- + +## Notes + +1. **Metadata Permission**: The `metadata:read` permission is automatically granted for all repositories that a fine-grained PAT has access to. + +2. **Private vs Public Repositories**: The `repo` scope covers both public and private repositories. Use `public_repo` if you only need access to public repositories with OAuth apps. + +3. **Organization Permissions**: Some tools require organization-level permissions (`read:org`, `write:org`, or `admin:org`), which are separate from repository permissions. + +4. **Notification Limitations**: Notification endpoints are not available with fine-grained PATs. You must use a classic PAT with the `notifications` scope for notification tools. + +5. **Copilot Tools**: The `assign_copilot_to_issue` and `request_copilot_review` tools require `repo` scope and work with repositories where Copilot is enabled. diff --git a/pkg/scopes/scopes.go b/pkg/scopes/scopes.go index 235b327ed..a0aa2d92c 100644 --- a/pkg/scopes/scopes.go +++ b/pkg/scopes/scopes.go @@ -1,11 +1,105 @@ // Package scopes provides OAuth scope definitions and utilities for the GitHub MCP Server. // These scopes correspond to GitHub OAuth app scopes as documented at: // https://docs.github.com/en/apps/oauth-apps/building-oauth-apps/scopes-for-oauth-apps +// +// Fine-grained permissions for personal access tokens are documented at: +// https://docs.github.com/en/rest/authentication/permissions-required-for-fine-grained-personal-access-tokens package scopes // Scope represents a GitHub OAuth scope. type Scope string +// PermissionLevel represents the access level for a fine-grained permission. +type PermissionLevel string + +const ( + // PermissionRead grants read-only access. + PermissionRead PermissionLevel = "read" + // PermissionWrite grants read and write access. + PermissionWrite PermissionLevel = "write" + // PermissionAdmin grants full administrative access. + PermissionAdmin PermissionLevel = "admin" +) + +// Permission represents a fine-grained permission for a personal access token. +// These map to the permissions shown in the GitHub REST API documentation. +type Permission string + +// Repository permissions for fine-grained PATs +const ( + // PermActions grants access to GitHub Actions workflows and artifacts. + PermActions Permission = "actions" + // PermAdministration grants access to repository administration. + PermAdministration Permission = "administration" + // PermCodeScanningAlerts grants access to code scanning alerts. + PermCodeScanningAlerts Permission = "code_scanning_alerts" + // PermContents grants access to repository contents, commits, branches, etc. + PermContents Permission = "contents" + // PermDependabotAlerts grants access to Dependabot alerts. + PermDependabotAlerts Permission = "dependabot_alerts" + // PermDeployments grants access to deployments. + PermDeployments Permission = "deployments" + // PermDiscussions grants access to repository discussions. + PermDiscussions Permission = "discussions" + // PermEnvironments grants access to environments. + PermEnvironments Permission = "environments" + // PermIssues grants access to issues. + PermIssues Permission = "issues" + // PermMetadata grants access to repository metadata (read-only by default for all PATs). + PermMetadata Permission = "metadata" + // PermPages grants access to GitHub Pages. + PermPages Permission = "pages" + // PermPullRequests grants access to pull requests. + PermPullRequests Permission = "pull_requests" + // PermRepositorySecurityAdvisories grants access to repository security advisories. + PermRepositorySecurityAdvisories Permission = "repository_security_advisories" + // PermSecretScanningAlerts grants access to secret scanning alerts. + PermSecretScanningAlerts Permission = "secret_scanning_alerts" + // PermSecrets grants access to repository secrets. + PermSecrets Permission = "secrets" + // PermVariables grants access to repository variables. + PermVariables Permission = "variables" + // PermWebhooks grants access to repository webhooks. + PermWebhooks Permission = "webhooks" + // PermWorkflows grants access to workflow files. + PermWorkflows Permission = "workflows" + // PermCommitStatuses grants access to commit statuses. + PermCommitStatuses Permission = "commit_statuses" +) + +// Organization permissions for fine-grained PATs +const ( + // PermOrgAdministration grants access to organization administration. + PermOrgAdministration Permission = "organization_administration" + // PermOrgMembers grants access to organization members. + PermOrgMembers Permission = "members" + // PermOrgProjects grants access to organization projects. + PermOrgProjects Permission = "organization_projects" +) + +// User permissions for fine-grained PATs +const ( + // PermGists grants access to gists. + PermGists Permission = "gists" + // PermNotifications grants access to notifications. + PermNotifications Permission = "notifications" + // PermStarring grants access to starring repositories. + PermStarring Permission = "starring" + // PermWatching grants access to watching repositories. + PermWatching Permission = "watching" +) + +// FineGrainedPermission represents a permission with its required level. +type FineGrainedPermission struct { + Permission Permission `json:"permission"` + Level PermissionLevel `json:"level"` +} + +// String returns a human-readable string for the permission. +func (p FineGrainedPermission) String() string { + return string(p.Permission) + ":" + string(p.Level) +} + // OAuth scope constants based on GitHub's OAuth app scopes. // See: https://docs.github.com/en/apps/oauth-apps/building-oauth-apps/scopes-for-oauth-apps const ( @@ -235,6 +329,9 @@ func ParseScopes(strs []string) []Scope { // MetaKey is the key used to store OAuth scopes in the mcp.Tool.Meta field. const MetaKey = "requiredOAuthScopes" +// MetaKeyPermissions is the key used to store fine-grained permissions in the mcp.Tool.Meta field. +const MetaKeyPermissions = "requiredFineGrainedPermissions" + // WithScopes returns a Meta map containing the required OAuth scopes. // This is used when defining an mcp.Tool to specify the required scopes. // @@ -255,6 +352,47 @@ func WithScopes(requiredScopes ...Scope) map[string]any { } } +// WithScopesAndPermissions returns a Meta map containing both OAuth scopes and fine-grained permissions. +// This is used when defining an mcp.Tool to specify both types of authentication requirements. +// +// Example usage: +// +// tool := mcp.Tool{ +// Name: "get_issue", +// Meta: scopes.WithScopesAndPermissions( +// []scopes.Scope{scopes.Repo}, +// []scopes.FineGrainedPermission{{scopes.PermIssues, scopes.PermissionRead}}, +// ), +// ... +// } +func WithScopesAndPermissions(requiredScopes []Scope, permissions []FineGrainedPermission) map[string]any { + meta := WithScopes(requiredScopes...) + if len(permissions) > 0 { + permStrings := make([]string, len(permissions)) + for i, p := range permissions { + permStrings[i] = p.String() + } + meta[MetaKeyPermissions] = permStrings + } + return meta +} + +// AddPermissions adds fine-grained permissions to an existing Meta map. +// This can be used to add permissions after calling WithScopes. +func AddPermissions(meta map[string]any, permissions ...FineGrainedPermission) map[string]any { + if meta == nil { + meta = make(map[string]any) + } + if len(permissions) > 0 { + permStrings := make([]string, len(permissions)) + for i, p := range permissions { + permStrings[i] = p.String() + } + meta[MetaKeyPermissions] = permStrings + } + return meta +} + // GetScopesFromMeta extracts the required OAuth scopes from an mcp.Tool.Meta field. // Returns nil if no scopes are defined. func GetScopesFromMeta(meta map[string]any) []Scope { @@ -283,3 +421,52 @@ func GetScopesFromMeta(meta map[string]any) []Scope { return nil } } + +// GetPermissionsFromMeta extracts the fine-grained permissions from an mcp.Tool.Meta field. +// Returns nil if no permissions are defined. +func GetPermissionsFromMeta(meta map[string]any) []string { + if meta == nil { + return nil + } + + permsVal, ok := meta[MetaKeyPermissions] + if !ok { + return nil + } + + // Handle both []string and []any (from JSON unmarshaling) + switch v := permsVal.(type) { + case []string: + return v + case []any: + strs := make([]string, 0, len(v)) + for _, s := range v { + if str, ok := s.(string); ok { + strs = append(strs, str) + } + } + return strs + default: + return nil + } +} + +// Perm is a convenience function to create a FineGrainedPermission. +func Perm(p Permission, level PermissionLevel) FineGrainedPermission { + return FineGrainedPermission{Permission: p, Level: level} +} + +// ReadPerm creates a read-level permission. +func ReadPerm(p Permission) FineGrainedPermission { + return FineGrainedPermission{Permission: p, Level: PermissionRead} +} + +// WritePerm creates a write-level permission. +func WritePerm(p Permission) FineGrainedPermission { + return FineGrainedPermission{Permission: p, Level: PermissionWrite} +} + +// AdminPerm creates an admin-level permission. +func AdminPerm(p Permission) FineGrainedPermission { + return FineGrainedPermission{Permission: p, Level: PermissionAdmin} +} diff --git a/pkg/scopes/scopes_test.go b/pkg/scopes/scopes_test.go index 0b48bb16b..97ae406eb 100644 --- a/pkg/scopes/scopes_test.go +++ b/pkg/scopes/scopes_test.go @@ -238,3 +238,137 @@ func TestScopeStringsAndParseScopes(t *testing.T) { parsed := ParseScopes(strings) assert.Equal(t, original, parsed) } + +// Tests for fine-grained permissions + +func TestFineGrainedPermissionString(t *testing.T) { + tests := []struct { + perm FineGrainedPermission + expected string + }{ + {ReadPerm(PermIssues), "issues:read"}, + {WritePerm(PermPullRequests), "pull_requests:write"}, + {AdminPerm(PermAdministration), "administration:admin"}, + {Perm(PermContents, PermissionRead), "contents:read"}, + } + + for _, tt := range tests { + t.Run(tt.expected, func(t *testing.T) { + assert.Equal(t, tt.expected, tt.perm.String()) + }) + } +} + +func TestWithScopesAndPermissions(t *testing.T) { + meta := WithScopesAndPermissions( + []Scope{Repo}, + []FineGrainedPermission{ + ReadPerm(PermIssues), + WritePerm(PermPullRequests), + }, + ) + + require.NotNil(t, meta) + + // Check scopes + scopeVal, ok := meta[MetaKey] + require.True(t, ok) + scopeStrings, ok := scopeVal.([]string) + require.True(t, ok) + assert.Equal(t, []string{"repo"}, scopeStrings) + + // Check permissions + permsVal, ok := meta[MetaKeyPermissions] + require.True(t, ok) + permStrings, ok := permsVal.([]string) + require.True(t, ok) + assert.Equal(t, []string{"issues:read", "pull_requests:write"}, permStrings) +} + +func TestAddPermissions(t *testing.T) { + // Start with scopes only + meta := WithScopes(Repo) + require.NotNil(t, meta) + + // Add permissions + meta = AddPermissions(meta, ReadPerm(PermContents)) + + // Check that scopes are still there + scopeVal, ok := meta[MetaKey] + require.True(t, ok) + scopeStrings, ok := scopeVal.([]string) + require.True(t, ok) + assert.Equal(t, []string{"repo"}, scopeStrings) + + // Check permissions were added + permsVal, ok := meta[MetaKeyPermissions] + require.True(t, ok) + permStrings, ok := permsVal.([]string) + require.True(t, ok) + assert.Equal(t, []string{"contents:read"}, permStrings) +} + +func TestAddPermissionsToNilMeta(t *testing.T) { + meta := AddPermissions(nil, WritePerm(PermActions)) + require.NotNil(t, meta) + + permsVal, ok := meta[MetaKeyPermissions] + require.True(t, ok) + permStrings, ok := permsVal.([]string) + require.True(t, ok) + assert.Equal(t, []string{"actions:write"}, permStrings) +} + +func TestGetPermissionsFromMeta(t *testing.T) { + tests := []struct { + name string + meta map[string]any + expected []string + }{ + { + name: "nil meta", + meta: nil, + expected: nil, + }, + { + name: "empty meta", + meta: map[string]any{}, + expected: nil, + }, + { + name: "string slice", + meta: map[string]any{ + MetaKeyPermissions: []string{"issues:read", "contents:write"}, + }, + expected: []string{"issues:read", "contents:write"}, + }, + { + name: "any slice (from JSON)", + meta: map[string]any{ + MetaKeyPermissions: []any{"pull_requests:read", "actions:write"}, + }, + expected: []string{"pull_requests:read", "actions:write"}, + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + result := GetPermissionsFromMeta(tt.meta) + assert.Equal(t, tt.expected, result) + }) + } +} + +func TestPermHelperFunctions(t *testing.T) { + readPerm := ReadPerm(PermIssues) + assert.Equal(t, PermIssues, readPerm.Permission) + assert.Equal(t, PermissionRead, readPerm.Level) + + writePerm := WritePerm(PermPullRequests) + assert.Equal(t, PermPullRequests, writePerm.Permission) + assert.Equal(t, PermissionWrite, writePerm.Level) + + adminPerm := AdminPerm(PermAdministration) + assert.Equal(t, PermAdministration, adminPerm.Permission) + assert.Equal(t, PermissionAdmin, adminPerm.Level) +}