diff --git a/README.md b/README.md index 432541f434..7ef3cf686e 100644 --- a/README.md +++ b/README.md @@ -609,6 +609,7 @@ The following sets of tools are available: | tag | `labels` | GitHub Labels related tools | | bell | `notifications` | GitHub Notifications related tools | | organization | `orgs` | GitHub Organization related tools | +| package | `packages` | GitHub Packages related tools | | project | `projects` | GitHub Projects related tools | | git-pull-request | `pull_requests` | GitHub Pull Request related tools | | repo | `repos` | GitHub Repository related tools | @@ -1191,6 +1192,53 @@ For negotiated supported protocol versions `2026-07-28` or newer, the consolidat
+package Packages + +- **packages_read** - Read package information + - **OAuth Challenge Scopes**: `read:packages` + - `method`: The read operation to perform. + Options are: + - 'list_org_packages' - list packages for an organization. Requires 'org', 'package_type'. Optional filter: 'visibility'. + - 'get_org_package' - get a package owned by an organization. Requires 'org', 'package_type', 'package_name'. + - 'list_org_package_versions' - list versions of an organization package. Requires 'org', 'package_type', 'package_name'. Optional filter: 'state'. + - 'get_org_package_version' - get a single version of an organization package. Requires 'org', 'package_type', 'package_name', 'package_version_id'. + - 'list_user_packages' - list packages for a user (or the authenticated user when 'username' is omitted). Requires 'package_type'. Optional filter: 'visibility'. + - 'get_user_package' - get a package owned by a user. Requires 'package_type', 'package_name'. + - 'list_user_package_versions' - list versions of a user package. Requires 'package_type', 'package_name'. Optional filter: 'state'. + - 'get_user_package_version' - get a single version of a user package. Requires 'package_type', 'package_name', 'package_version_id'. + + When 'username' names a user other than the authenticated user, get_user_package, list_user_package_versions, and get_user_package_version only return that user's public packages. + Download statistics are not available through the GitHub REST API. (string, required) + - `org`: Organization name. Required for org_* methods, and not accepted by user_* methods. (string, optional) + - `package_name`: Package name. Required for every method except list_org_packages and list_user_packages. (string, optional) + - `package_type`: Package type. (string, required) + - `package_version_id`: Package version ID. Required for methods that target a single version. (number, optional) + - `page`: Page number for pagination (min 1) (number, optional) + - `perPage`: Results per page for pagination (min 1, max 100) (number, optional) + - `state`: Filter package versions by state (list_*_package_versions only). (string, optional) + - `username`: GitHub username for user_* methods. Omit to target the authenticated user. Not accepted by org_* methods. (string, optional) + - `visibility`: Filter packages by visibility (list_org_packages and list_user_packages only). (string, optional) + +- **packages_write** - Delete packages + - **OAuth Challenge Scopes**: `read:packages`, `delete:packages` + - `method`: The delete operation to perform. + Options are: + - 'delete_org_package' - delete an entire organization package, including all versions. Requires 'org', 'package_type', 'package_name'. + - 'delete_org_package_version' - delete a single version of an organization package. Requires 'org', 'package_type', 'package_name', 'package_version_id'. + - 'delete_user_package' - delete an entire user package, including all versions. Requires 'package_type', 'package_name'. Omit 'username' to target the authenticated user. + - 'delete_user_package_version' - delete a single version of a user package. Requires 'package_type', 'package_name', 'package_version_id'. Omit 'username' to target the authenticated user. + + Deleted packages and versions can be restored from the GitHub web interface within 30 days. (string, required) + - `org`: Organization name. Required for org_* methods, and not accepted by user_* methods. (string, optional) + - `package_name`: Package name. (string, required) + - `package_type`: Package type. (string, required) + - `package_version_id`: Package version ID. Required for methods that target a single version. (number, optional) + - `username`: GitHub username for user_* methods. Omit to target the authenticated user. Not accepted by org_* methods. (string, optional) + +
+ +
+ project Projects - **projects_get** - Get details of GitHub Projects resources diff --git a/docs/remote-server.md b/docs/remote-server.md index be91009148..fad2351a52 100644 --- a/docs/remote-server.md +++ b/docs/remote-server.md @@ -35,6 +35,7 @@ Below is a table of available toolsets for the remote GitHub MCP Server. Each to | 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) | | organization
`orgs` | GitHub Organization related tools | https://api.githubcopilot.com/mcp/x/orgs | [Install](https://insiders.vscode.dev/redirect/mcp/install?name=gh-orgs&config=%7B%22type%22%3A%20%22http%22%2C%22url%22%3A%20%22https%3A%2F%2Fapi.githubcopilot.com%2Fmcp%2Fx%2Forgs%22%7D) | [read-only](https://api.githubcopilot.com/mcp/x/orgs/readonly) | [Install read-only](https://insiders.vscode.dev/redirect/mcp/install?name=gh-orgs&config=%7B%22type%22%3A%20%22http%22%2C%22url%22%3A%20%22https%3A%2F%2Fapi.githubcopilot.com%2Fmcp%2Fx%2Forgs%2Freadonly%22%7D) | +| package
`packages` | GitHub Packages related tools | https://api.githubcopilot.com/mcp/x/packages | [Install](https://insiders.vscode.dev/redirect/mcp/install?name=gh-packages&config=%7B%22type%22%3A%20%22http%22%2C%22url%22%3A%20%22https%3A%2F%2Fapi.githubcopilot.com%2Fmcp%2Fx%2Fpackages%22%7D) | [read-only](https://api.githubcopilot.com/mcp/x/packages/readonly) | [Install read-only](https://insiders.vscode.dev/redirect/mcp/install?name=gh-packages&config=%7B%22type%22%3A%20%22http%22%2C%22url%22%3A%20%22https%3A%2F%2Fapi.githubcopilot.com%2Fmcp%2Fx%2Fpackages%2Freadonly%22%7D) | | project
`projects` | GitHub Projects related tools | https://api.githubcopilot.com/mcp/x/projects | [Install](https://insiders.vscode.dev/redirect/mcp/install?name=gh-projects&config=%7B%22type%22%3A%20%22http%22%2C%22url%22%3A%20%22https%3A%2F%2Fapi.githubcopilot.com%2Fmcp%2Fx%2Fprojects%22%7D) | [read-only](https://api.githubcopilot.com/mcp/x/projects/readonly) | [Install read-only](https://insiders.vscode.dev/redirect/mcp/install?name=gh-projects&config=%7B%22type%22%3A%20%22http%22%2C%22url%22%3A%20%22https%3A%2F%2Fapi.githubcopilot.com%2Fmcp%2Fx%2Fprojects%2Freadonly%22%7D) | | git-pull-request
`pull_requests` | GitHub Pull Request related tools | https://api.githubcopilot.com/mcp/x/pull_requests | [Install](https://insiders.vscode.dev/redirect/mcp/install?name=gh-pull_requests&config=%7B%22type%22%3A%20%22http%22%2C%22url%22%3A%20%22https%3A%2F%2Fapi.githubcopilot.com%2Fmcp%2Fx%2Fpull_requests%22%7D) | [read-only](https://api.githubcopilot.com/mcp/x/pull_requests/readonly) | [Install read-only](https://insiders.vscode.dev/redirect/mcp/install?name=gh-pull_requests&config=%7B%22type%22%3A%20%22http%22%2C%22url%22%3A%20%22https%3A%2F%2Fapi.githubcopilot.com%2Fmcp%2Fx%2Fpull_requests%2Freadonly%22%7D) | | repo
`repos` | GitHub Repository related tools | https://api.githubcopilot.com/mcp/x/repos | [Install](https://insiders.vscode.dev/redirect/mcp/install?name=gh-repos&config=%7B%22type%22%3A%20%22http%22%2C%22url%22%3A%20%22https%3A%2F%2Fapi.githubcopilot.com%2Fmcp%2Fx%2Frepos%22%7D) | [read-only](https://api.githubcopilot.com/mcp/x/repos/readonly) | [Install read-only](https://insiders.vscode.dev/redirect/mcp/install?name=gh-repos&config=%7B%22type%22%3A%20%22http%22%2C%22url%22%3A%20%22https%3A%2F%2Fapi.githubcopilot.com%2Fmcp%2Fx%2Frepos%2Freadonly%22%7D) | diff --git a/pkg/github/__toolsnaps__/packages_read.snap b/pkg/github/__toolsnaps__/packages_read.snap new file mode 100644 index 0000000000..fbdccda384 --- /dev/null +++ b/pkg/github/__toolsnaps__/packages_read.snap @@ -0,0 +1,533 @@ +{ + "annotations": { + "idempotentHint": false, + "readOnlyHint": true, + "title": "Read package information" + }, + "description": "Get information about GitHub Packages owned by organizations and users. Supports listing packages, getting package details, and inspecting package versions. The GitHub Packages API only accepts classic personal access tokens and OAuth app tokens.", + "inputSchema": { + "properties": { + "method": { + "description": "The read operation to perform.\nOptions are:\n- 'list_org_packages' - list packages for an organization. Requires 'org', 'package_type'. Optional filter: 'visibility'.\n- 'get_org_package' - get a package owned by an organization. Requires 'org', 'package_type', 'package_name'.\n- 'list_org_package_versions' - list versions of an organization package. Requires 'org', 'package_type', 'package_name'. Optional filter: 'state'.\n- 'get_org_package_version' - get a single version of an organization package. Requires 'org', 'package_type', 'package_name', 'package_version_id'.\n- 'list_user_packages' - list packages for a user (or the authenticated user when 'username' is omitted). Requires 'package_type'. Optional filter: 'visibility'.\n- 'get_user_package' - get a package owned by a user. Requires 'package_type', 'package_name'.\n- 'list_user_package_versions' - list versions of a user package. Requires 'package_type', 'package_name'. Optional filter: 'state'.\n- 'get_user_package_version' - get a single version of a user package. Requires 'package_type', 'package_name', 'package_version_id'.\n\nWhen 'username' names a user other than the authenticated user, get_user_package, list_user_package_versions, and get_user_package_version only return that user's public packages.\nDownload statistics are not available through the GitHub REST API.", + "enum": [ + "list_org_packages", + "get_org_package", + "list_org_package_versions", + "get_org_package_version", + "list_user_packages", + "get_user_package", + "list_user_package_versions", + "get_user_package_version" + ], + "type": "string" + }, + "org": { + "description": "Organization name. Required for org_* methods, and not accepted by user_* methods.", + "type": "string" + }, + "package_name": { + "description": "Package name. Required for every method except list_org_packages and list_user_packages.", + "type": "string" + }, + "package_type": { + "description": "Package type.", + "enum": [ + "npm", + "maven", + "rubygems", + "docker", + "nuget", + "container" + ], + "type": "string" + }, + "package_version_id": { + "description": "Package version ID. Required for methods that target a single version.", + "type": "number" + }, + "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" + }, + "state": { + "description": "Filter package versions by state (list_*_package_versions only).", + "enum": [ + "active", + "deleted" + ], + "type": "string" + }, + "username": { + "description": "GitHub username for user_* methods. Omit to target the authenticated user. Not accepted by org_* methods.", + "type": "string" + }, + "visibility": { + "description": "Filter packages by visibility (list_org_packages and list_user_packages only).", + "enum": [ + "public", + "private", + "internal" + ], + "type": "string" + } + }, + "required": [ + "method", + "package_type" + ], + "type": "object" + }, + "name": "packages_read", + "outputSchema": { + "additionalProperties": false, + "oneOf": [ + { + "properties": { + "method": { + "enum": [ + "list_org_packages" + ] + }, + "package": { + "not": { + "description": "Any value." + } + }, + "version": { + "not": { + "description": "Any value." + } + }, + "versions": { + "not": { + "description": "Any value." + } + } + }, + "required": [ + "packages" + ] + }, + { + "properties": { + "method": { + "enum": [ + "get_org_package" + ] + }, + "packages": { + "not": { + "description": "Any value." + } + }, + "version": { + "not": { + "description": "Any value." + } + }, + "versions": { + "not": { + "description": "Any value." + } + } + }, + "required": [ + "package" + ] + }, + { + "properties": { + "method": { + "enum": [ + "list_org_package_versions" + ] + }, + "package": { + "not": { + "description": "Any value." + } + }, + "packages": { + "not": { + "description": "Any value." + } + }, + "version": { + "not": { + "description": "Any value." + } + } + }, + "required": [ + "versions" + ] + }, + { + "properties": { + "method": { + "enum": [ + "get_org_package_version" + ] + }, + "package": { + "not": { + "description": "Any value." + } + }, + "packages": { + "not": { + "description": "Any value." + } + }, + "versions": { + "not": { + "description": "Any value." + } + } + }, + "required": [ + "version" + ] + }, + { + "properties": { + "method": { + "enum": [ + "list_user_packages" + ] + }, + "package": { + "not": { + "description": "Any value." + } + }, + "version": { + "not": { + "description": "Any value." + } + }, + "versions": { + "not": { + "description": "Any value." + } + } + }, + "required": [ + "packages" + ] + }, + { + "properties": { + "method": { + "enum": [ + "get_user_package" + ] + }, + "packages": { + "not": { + "description": "Any value." + } + }, + "version": { + "not": { + "description": "Any value." + } + }, + "versions": { + "not": { + "description": "Any value." + } + } + }, + "required": [ + "package" + ] + }, + { + "properties": { + "method": { + "enum": [ + "list_user_package_versions" + ] + }, + "package": { + "not": { + "description": "Any value." + } + }, + "packages": { + "not": { + "description": "Any value." + } + }, + "version": { + "not": { + "description": "Any value." + } + } + }, + "required": [ + "versions" + ] + }, + { + "properties": { + "method": { + "enum": [ + "get_user_package_version" + ] + }, + "package": { + "not": { + "description": "Any value." + } + }, + "packages": { + "not": { + "description": "Any value." + } + }, + "versions": { + "not": { + "description": "Any value." + } + } + }, + "required": [ + "version" + ] + } + ], + "properties": { + "method": { + "description": "The method that produced the corresponding typed output.", + "enum": [ + "list_org_packages", + "get_org_package", + "list_org_package_versions", + "get_org_package_version", + "list_user_packages", + "get_user_package", + "list_user_package_versions", + "get_user_package_version" + ], + "type": "string" + }, + "package": { + "additionalProperties": false, + "properties": { + "created_at": { + "type": "string" + }, + "html_url": { + "type": "string" + }, + "id": { + "type": "integer" + }, + "name": { + "type": "string" + }, + "owner": { + "description": "Login of the package owner.", + "type": "string" + }, + "package_type": { + "type": "string" + }, + "repository": { + "description": "Full name of the repository linked to the package.", + "type": "string" + }, + "updated_at": { + "type": "string" + }, + "version_count": { + "description": "Number of versions. Omitted when the API does not report it, as in list results.", + "type": "integer" + }, + "visibility": { + "type": "string" + } + }, + "required": [ + "id", + "name", + "package_type" + ], + "type": "object" + }, + "packages": { + "items": { + "additionalProperties": false, + "properties": { + "created_at": { + "type": "string" + }, + "html_url": { + "type": "string" + }, + "id": { + "type": "integer" + }, + "name": { + "type": "string" + }, + "owner": { + "description": "Login of the package owner.", + "type": "string" + }, + "package_type": { + "type": "string" + }, + "repository": { + "description": "Full name of the repository linked to the package.", + "type": "string" + }, + "updated_at": { + "type": "string" + }, + "version_count": { + "description": "Number of versions. Omitted when the API does not report it, as in list results.", + "type": "integer" + }, + "visibility": { + "type": "string" + } + }, + "required": [ + "id", + "name", + "package_type" + ], + "type": "object" + }, + "type": "array" + }, + "version": { + "additionalProperties": false, + "properties": { + "created_at": { + "type": "string" + }, + "description": { + "type": "string" + }, + "html_url": { + "type": "string" + }, + "id": { + "type": "integer" + }, + "license": { + "type": "string" + }, + "metadata": { + "additionalProperties": false, + "properties": { + "container": { + "additionalProperties": false, + "properties": { + "tags": { + "items": { + "type": "string" + }, + "type": "array" + } + }, + "required": [ + "tags" + ], + "type": "object" + }, + "package_type": { + "type": "string" + } + }, + "type": "object" + }, + "name": { + "type": "string" + }, + "updated_at": { + "type": "string" + } + }, + "required": [ + "id", + "name" + ], + "type": "object" + }, + "versions": { + "items": { + "additionalProperties": false, + "properties": { + "created_at": { + "type": "string" + }, + "description": { + "type": "string" + }, + "html_url": { + "type": "string" + }, + "id": { + "type": "integer" + }, + "license": { + "type": "string" + }, + "metadata": { + "additionalProperties": false, + "properties": { + "container": { + "additionalProperties": false, + "properties": { + "tags": { + "items": { + "type": "string" + }, + "type": "array" + } + }, + "required": [ + "tags" + ], + "type": "object" + }, + "package_type": { + "type": "string" + } + }, + "type": "object" + }, + "name": { + "type": "string" + }, + "updated_at": { + "type": "string" + } + }, + "required": [ + "id", + "name" + ], + "type": "object" + }, + "type": "array" + } + }, + "required": [ + "method" + ], + "type": "object" + } +} \ No newline at end of file diff --git a/pkg/github/__toolsnaps__/packages_write.snap b/pkg/github/__toolsnaps__/packages_write.snap new file mode 100644 index 0000000000..347fb03846 --- /dev/null +++ b/pkg/github/__toolsnaps__/packages_write.snap @@ -0,0 +1,81 @@ +{ + "annotations": { + "destructiveHint": true, + "idempotentHint": false, + "readOnlyHint": false, + "title": "Delete packages" + }, + "description": "Delete GitHub Packages or individual package versions owned by organizations and users. Requires the delete:packages scope. The GitHub Packages API only accepts classic personal access tokens and OAuth app tokens.", + "inputSchema": { + "properties": { + "method": { + "description": "The delete operation to perform.\nOptions are:\n- 'delete_org_package' - delete an entire organization package, including all versions. Requires 'org', 'package_type', 'package_name'.\n- 'delete_org_package_version' - delete a single version of an organization package. Requires 'org', 'package_type', 'package_name', 'package_version_id'.\n- 'delete_user_package' - delete an entire user package, including all versions. Requires 'package_type', 'package_name'. Omit 'username' to target the authenticated user.\n- 'delete_user_package_version' - delete a single version of a user package. Requires 'package_type', 'package_name', 'package_version_id'. Omit 'username' to target the authenticated user.\n\nDeleted packages and versions can be restored from the GitHub web interface within 30 days.", + "enum": [ + "delete_org_package", + "delete_org_package_version", + "delete_user_package", + "delete_user_package_version" + ], + "type": "string" + }, + "org": { + "description": "Organization name. Required for org_* methods, and not accepted by user_* methods.", + "type": "string" + }, + "package_name": { + "description": "Package name.", + "type": "string" + }, + "package_type": { + "description": "Package type.", + "enum": [ + "npm", + "maven", + "rubygems", + "docker", + "nuget", + "container" + ], + "type": "string" + }, + "package_version_id": { + "description": "Package version ID. Required for methods that target a single version.", + "type": "number" + }, + "username": { + "description": "GitHub username for user_* methods. Omit to target the authenticated user. Not accepted by org_* methods.", + "type": "string" + } + }, + "required": [ + "method", + "package_type", + "package_name" + ], + "type": "object" + }, + "name": "packages_write", + "outputSchema": { + "additionalProperties": false, + "properties": { + "message": { + "type": "string" + }, + "method": { + "description": "The method that produced the corresponding typed output.", + "enum": [ + "delete_org_package", + "delete_org_package_version", + "delete_user_package", + "delete_user_package_version" + ], + "type": "string" + } + }, + "required": [ + "method", + "message" + ], + "type": "object" + } +} \ No newline at end of file diff --git a/pkg/github/packages.go b/pkg/github/packages.go new file mode 100644 index 0000000000..d6be00e2b6 --- /dev/null +++ b/pkg/github/packages.go @@ -0,0 +1,490 @@ +package github + +import ( + "context" + "fmt" + "io" + "net/http" + "net/url" + "strconv" + + ghErrors "github.com/github/github-mcp-server/v2/pkg/errors" + "github.com/github/github-mcp-server/v2/pkg/inventory" + "github.com/github/github-mcp-server/v2/pkg/scopes" + "github.com/github/github-mcp-server/v2/pkg/translations" + "github.com/github/github-mcp-server/v2/pkg/utils" + "github.com/google/go-github/v92/github" + "github.com/google/jsonschema-go/jsonschema" + "github.com/modelcontextprotocol/go-sdk/mcp" +) + +// NOTE: GitHub's REST API for packages does not expose download statistics. +// While download counts are visible on the GitHub web interface, they are not +// included in the API responses. + +var packageTypes = []any{"npm", "maven", "rubygems", "docker", "nuget", "container"} + +// packagesAuthNote is appended to tool descriptions because the Packages REST +// API rejects fine-grained personal access tokens and GitHub App tokens. +const packagesAuthNote = "The GitHub Packages API only accepts classic personal access tokens and OAuth app tokens." + +// packageCoordinates identifies a package (and optionally a version) owned by +// an organization or a user. An empty user refers to the authenticated user. +type packageCoordinates struct { + owner string + packageType string + packageName string + versionID int64 +} + +func packageOwnerSchema() map[string]*jsonschema.Schema { + return map[string]*jsonschema.Schema{ + "org": { + Type: "string", + Description: "Organization name. Required for org_* methods, and not accepted by user_* methods.", + }, + "username": { + Type: "string", + Description: "GitHub username for user_* methods. Omit to target the authenticated user. Not accepted by org_* methods.", + }, + "package_type": { + Type: "string", + Description: "Package type.", + Enum: packageTypes, + }, + "package_name": { + Type: "string", + Description: "Package name. Required for every method except list_org_packages and list_user_packages.", + }, + "package_version_id": { + Type: "number", + Description: "Package version ID. Required for methods that target a single version.", + }, + } +} + +// requirePackageOwner reads the owner for an org_* or user_* method, rejecting +// the owner argument that belongs to the other kind of method so that a call +// is never silently redirected to a different owner. +func requirePackageOwner(args map[string]any, isOrg bool) (string, error) { + if isOrg { + username, err := OptionalParam[string](args, "username") + if err != nil { + return "", err + } + if username != "" { + return "", fmt.Errorf("'username' is not supported by org_* methods; use the user_* methods to target a user") + } + return RequiredParam[string](args, "org") + } + org, err := OptionalParam[string](args, "org") + if err != nil { + return "", err + } + if org != "" { + return "", fmt.Errorf("'org' is not supported by user_* methods; use the org_* methods to target an organization") + } + return OptionalParam[string](args, "username") +} + +// requirePackage reads the owner, package type, and package name, and the +// version ID when withVersion is true. +func requirePackage(args map[string]any, isOrg, withVersion bool) (packageCoordinates, error) { + owner, err := requirePackageOwner(args, isOrg) + if err != nil { + return packageCoordinates{}, err + } + packageType, err := RequiredParam[string](args, "package_type") + if err != nil { + return packageCoordinates{}, err + } + packageName, err := RequiredParam[string](args, "package_name") + if err != nil { + return packageCoordinates{}, err + } + c := packageCoordinates{owner: owner, packageType: packageType, packageName: packageName} + if withVersion { + c.versionID, err = RequiredBigInt(args, "package_version_id") + if err != nil { + return packageCoordinates{}, err + } + } + return c, nil +} + +// describeOwner returns a human-readable owner for error messages. +func describeOwner(owner string, isOrg bool) string { + switch { + case isOrg: + return fmt.Sprintf("organization '%s'", owner) + case owner == "": + return "the authenticated user" + default: + return fmt.Sprintf("user '%s'", owner) + } +} + +// PackagesRead creates a consolidated tool to read GitHub Packages information. +func PackagesRead(t translations.TranslationHelperFunc) inventory.ServerTool { + properties := packageOwnerSchema() + properties["method"] = &jsonschema.Schema{ + Type: "string", + Description: `The read operation to perform. +Options are: +- 'list_org_packages' - list packages for an organization. Requires 'org', 'package_type'. Optional filter: 'visibility'. +- 'get_org_package' - get a package owned by an organization. Requires 'org', 'package_type', 'package_name'. +- 'list_org_package_versions' - list versions of an organization package. Requires 'org', 'package_type', 'package_name'. Optional filter: 'state'. +- 'get_org_package_version' - get a single version of an organization package. Requires 'org', 'package_type', 'package_name', 'package_version_id'. +- 'list_user_packages' - list packages for a user (or the authenticated user when 'username' is omitted). Requires 'package_type'. Optional filter: 'visibility'. +- 'get_user_package' - get a package owned by a user. Requires 'package_type', 'package_name'. +- 'list_user_package_versions' - list versions of a user package. Requires 'package_type', 'package_name'. Optional filter: 'state'. +- 'get_user_package_version' - get a single version of a user package. Requires 'package_type', 'package_name', 'package_version_id'. + +When 'username' names a user other than the authenticated user, get_user_package, list_user_package_versions, and get_user_package_version only return that user's public packages. +Download statistics are not available through the GitHub REST API.`, + Enum: packagesReadMethods, + } + properties["visibility"] = &jsonschema.Schema{ + Type: "string", + Description: "Filter packages by visibility (list_org_packages and list_user_packages only).", + Enum: []any{"public", "private", "internal"}, + } + properties["state"] = &jsonschema.Schema{ + Type: "string", + Description: "Filter package versions by state (list_*_package_versions only).", + Enum: []any{"active", "deleted"}, + } + schema := &jsonschema.Schema{ + Type: "object", + Properties: properties, + Required: []string{"method", "package_type"}, + } + WithPagination(schema) + + return NewTool[map[string]any, *PackagesReadOutput]( + ToolsetMetadataPackages, + mcp.Tool{ + Name: "packages_read", + OutputSchema: packagesReadOutputSchema(), + Description: t("TOOL_PACKAGES_READ_DESCRIPTION", "Get information about GitHub Packages owned by organizations and users. Supports listing packages, getting package details, and inspecting package versions. "+packagesAuthNote), + Annotations: &mcp.ToolAnnotations{ + Title: t("TOOL_PACKAGES_READ_USER_TITLE", "Read package information"), + ReadOnlyHint: true, + }, + InputSchema: schema, + }, + scopes.RequireAll(scopes.ReadPackages), + func(ctx context.Context, deps ToolDependencies, _ *mcp.CallToolRequest, args map[string]any) (*mcp.CallToolResult, *PackagesReadOutput, error) { + method, err := RequiredParam[string](args, "method") + if err != nil { + return utils.NewToolResultError(err.Error()), nil, nil + } + + client, err := deps.GetClient(ctx) + if err != nil { + return nil, nil, fmt.Errorf("failed to get GitHub client: %w", err) + } + + switch method { + case packagesMethodListOrgPackages: + return listPackages(ctx, client, args, method, true) + case packagesMethodListUserPackages: + return listPackages(ctx, client, args, method, false) + case packagesMethodGetOrgPackage: + return getPackage(ctx, client, args, method, true) + case packagesMethodGetUserPackage: + return getPackage(ctx, client, args, method, false) + case packagesMethodListOrgPackageVersions: + return listPackageVersions(ctx, client, args, method, true) + case packagesMethodListUserVersions: + return listPackageVersions(ctx, client, args, method, false) + case packagesMethodGetOrgPackageVersion: + return getPackageVersion(ctx, client, args, method, true) + case packagesMethodGetUserPackageVersion: + return getPackageVersion(ctx, client, args, method, false) + default: + return utils.NewToolResultError(fmt.Sprintf("unknown method: %s", method)), nil, nil + } + }, + ) +} + +func listPackages(ctx context.Context, client *github.Client, args map[string]any, method string, isOrg bool) (*mcp.CallToolResult, *PackagesReadOutput, error) { + owner, err := requirePackageOwner(args, isOrg) + if err != nil { + return utils.NewToolResultError(err.Error()), nil, nil + } + // All list-packages endpoints reject requests without package_type. + packageType, err := RequiredParam[string](args, "package_type") + if err != nil { + return utils.NewToolResultError(err.Error()), nil, nil + } + visibility, err := OptionalParam[string](args, "visibility") + if err != nil { + return utils.NewToolResultError(err.Error()), nil, nil + } + pagination, err := OptionalPaginationParams(args) + if err != nil { + return utils.NewToolResultError(err.Error()), nil, nil + } + + opts := &github.PackageListOptions{ + PackageType: new(packageType), + Visibility: ToStringPtr(visibility), + Page: pagination.Page, + PerPage: pagination.PerPage, + } + + var packages []*github.Package + var resp *github.Response + if isOrg { + packages, resp, err = client.Organizations.ListPackages(ctx, owner, opts) + } else { + packages, resp, err = client.Users.ListPackages(ctx, owner, opts) + } + if resp != nil { + defer func() { _ = resp.Body.Close() }() + } + if err != nil { + return ghErrors.NewGitHubAPIErrorResponse(ctx, + fmt.Sprintf("failed to list packages for %s", describeOwner(owner, isOrg)), + resp, + err, + ), nil, nil + } + + out := convertToPackageOutputs(packages) + return MarshalledTextResult(out), &PackagesReadOutput{Method: method, Packages: &out}, nil +} + +func getPackage(ctx context.Context, client *github.Client, args map[string]any, method string, isOrg bool) (*mcp.CallToolResult, *PackagesReadOutput, error) { + c, err := requirePackage(args, isOrg, false) + if err != nil { + return utils.NewToolResultError(err.Error()), nil, nil + } + + var pkg *github.Package + var resp *github.Response + if isOrg { + pkg, resp, err = client.Organizations.GetPackage(ctx, c.owner, c.packageType, c.packageName) + } else { + pkg, resp, err = client.Users.GetPackage(ctx, c.owner, c.packageType, c.packageName) + } + if resp != nil { + defer func() { _ = resp.Body.Close() }() + } + if err != nil { + return ghErrors.NewGitHubAPIErrorResponse(ctx, + fmt.Sprintf("failed to get %s package '%s' for %s", c.packageType, c.packageName, describeOwner(c.owner, isOrg)), + resp, + err, + ), nil, nil + } + + out := convertToPackageOutput(pkg) + return MarshalledTextResult(out), &PackagesReadOutput{Method: method, Package: &out}, nil +} + +func listPackageVersions(ctx context.Context, client *github.Client, args map[string]any, method string, isOrg bool) (*mcp.CallToolResult, *PackagesReadOutput, error) { + c, err := requirePackage(args, isOrg, false) + if err != nil { + return utils.NewToolResultError(err.Error()), nil, nil + } + state, err := OptionalParam[string](args, "state") + if err != nil { + return utils.NewToolResultError(err.Error()), nil, nil + } + pagination, err := OptionalPaginationParams(args) + if err != nil { + return utils.NewToolResultError(err.Error()), nil, nil + } + listOptions := github.ListOptions{Page: pagination.Page, PerPage: pagination.PerPage} + + var versions []*github.PackageVersion + var resp *github.Response + switch { + case isOrg: + versions, resp, err = client.Organizations.PackageGetAllVersions(ctx, c.owner, c.packageType, c.packageName, + &github.PackageListOptions{State: ToStringPtr(state), ListOptions: listOptions}) + case c.owner == "": + versions, resp, err = client.Users.ListPackageVersions(ctx, c.packageType, c.packageName, + &github.ListPackageVersionsOptions{State: state, ListOptions: listOptions}) + default: + versions, resp, err = listOtherUserPackageVersions(ctx, client, c, state, listOptions) + } + if resp != nil { + defer func() { _ = resp.Body.Close() }() + } + if err != nil { + return ghErrors.NewGitHubAPIErrorResponse(ctx, + fmt.Sprintf("failed to list versions of %s package '%s' for %s", c.packageType, c.packageName, describeOwner(c.owner, isOrg)), + resp, + err, + ), nil, nil + } + + out := convertToPackageVersionOutputs(versions) + return MarshalledTextResult(out), &PackagesReadOutput{Method: method, Versions: &out}, nil +} + +func getPackageVersion(ctx context.Context, client *github.Client, args map[string]any, method string, isOrg bool) (*mcp.CallToolResult, *PackagesReadOutput, error) { + c, err := requirePackage(args, isOrg, true) + if err != nil { + return utils.NewToolResultError(err.Error()), nil, nil + } + + var version *github.PackageVersion + var resp *github.Response + if isOrg { + version, resp, err = client.Organizations.PackageGetVersion(ctx, c.owner, c.packageType, c.packageName, c.versionID) + } else { + version, resp, err = client.Users.PackageGetVersion(ctx, c.owner, c.packageType, c.packageName, c.versionID) + } + if resp != nil { + defer func() { _ = resp.Body.Close() }() + } + if err != nil { + return ghErrors.NewGitHubAPIErrorResponse(ctx, + fmt.Sprintf("failed to get version %d of %s package '%s' for %s", c.versionID, c.packageType, c.packageName, describeOwner(c.owner, isOrg)), + resp, + err, + ), nil, nil + } + + out := convertToPackageVersionOutput(version) + return MarshalledTextResult(out), &PackagesReadOutput{Method: method, Version: &out}, nil +} + +// listOtherUserPackageVersions lists versions of a package owned by another +// user. go-github's Users.ListUserPackageVersions does not accept list +// options, although the endpoint supports state filtering and pagination. +func listOtherUserPackageVersions(ctx context.Context, client *github.Client, c packageCoordinates, state string, listOptions github.ListOptions) ([]*github.PackageVersion, *github.Response, error) { + query := url.Values{} + if state != "" { + query.Set("state", state) + } + if listOptions.Page > 0 { + query.Set("page", strconv.Itoa(listOptions.Page)) + } + if listOptions.PerPage > 0 { + query.Set("per_page", strconv.Itoa(listOptions.PerPage)) + } + apiURL := fmt.Sprintf("users/%s/packages/%s/%s/versions?%s", + url.PathEscape(c.owner), url.PathEscape(c.packageType), url.PathEscape(c.packageName), query.Encode()) + + req, err := client.NewRequest(ctx, http.MethodGet, apiURL, nil) + if err != nil { + return nil, nil, err + } + var versions []*github.PackageVersion + resp, err := client.Do(req, &versions) + return versions, resp, err +} + +// PackagesWrite creates a consolidated tool to delete GitHub Packages and package versions. +func PackagesWrite(t translations.TranslationHelperFunc) inventory.ServerTool { + properties := packageOwnerSchema() + properties["method"] = &jsonschema.Schema{ + Type: "string", + Description: `The delete operation to perform. +Options are: +- 'delete_org_package' - delete an entire organization package, including all versions. Requires 'org', 'package_type', 'package_name'. +- 'delete_org_package_version' - delete a single version of an organization package. Requires 'org', 'package_type', 'package_name', 'package_version_id'. +- 'delete_user_package' - delete an entire user package, including all versions. Requires 'package_type', 'package_name'. Omit 'username' to target the authenticated user. +- 'delete_user_package_version' - delete a single version of a user package. Requires 'package_type', 'package_name', 'package_version_id'. Omit 'username' to target the authenticated user. + +Deleted packages and versions can be restored from the GitHub web interface within 30 days.`, + Enum: packagesWriteMethods, + } + properties["package_name"].Description = "Package name." + + return NewTool[map[string]any, *PackagesWriteOutput]( + ToolsetMetadataPackages, + mcp.Tool{ + Name: "packages_write", + OutputSchema: packagesWriteOutputSchema(), + Description: t("TOOL_PACKAGES_WRITE_DESCRIPTION", "Delete GitHub Packages or individual package versions owned by organizations and users. Requires the delete:packages scope. "+packagesAuthNote), + Annotations: &mcp.ToolAnnotations{ + Title: t("TOOL_PACKAGES_WRITE_USER_TITLE", "Delete packages"), + ReadOnlyHint: false, + DestructiveHint: new(true), + }, + InputSchema: &jsonschema.Schema{ + Type: "object", + Properties: properties, + Required: []string{"method", "package_type", "package_name"}, + }, + }, + scopes.RequireAll(scopes.ReadPackages, scopes.DeletePackages), + func(ctx context.Context, deps ToolDependencies, _ *mcp.CallToolRequest, args map[string]any) (*mcp.CallToolResult, *PackagesWriteOutput, error) { + method, err := RequiredParam[string](args, "method") + if err != nil { + return utils.NewToolResultError(err.Error()), nil, nil + } + + var isOrg, withVersion bool + switch method { + case packagesMethodDeleteOrgPackage: + isOrg = true + case packagesMethodDeleteOrgPackageVersion: + isOrg, withVersion = true, true + case packagesMethodDeleteUserPackage: + case packagesMethodDeleteUserPackageVersion: + withVersion = true + default: + return utils.NewToolResultError(fmt.Sprintf("unknown method: %s", method)), nil, nil + } + + c, err := requirePackage(args, isOrg, withVersion) + 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) + } + + var resp *github.Response + switch { + case isOrg && withVersion: + resp, err = client.Organizations.PackageDeleteVersion(ctx, c.owner, c.packageType, c.packageName, c.versionID) + case isOrg: + resp, err = client.Organizations.DeletePackage(ctx, c.owner, c.packageType, c.packageName) + case withVersion: + resp, err = client.Users.PackageDeleteVersion(ctx, c.owner, c.packageType, c.packageName, c.versionID) + default: + resp, err = client.Users.DeletePackage(ctx, c.owner, c.packageType, c.packageName) + } + + target := fmt.Sprintf("%s package '%s'", c.packageType, c.packageName) + if withVersion { + target = fmt.Sprintf("version %d of %s", c.versionID, target) + } + target = fmt.Sprintf("%s for %s", target, describeOwner(c.owner, isOrg)) + + return handleDeletionResponse(ctx, resp, err, method, target) + }, + ) +} + +// handleDeletionResponse converts the result of a package deletion API call +// into a tool result. +func handleDeletionResponse(ctx context.Context, resp *github.Response, err error, method, target string) (*mcp.CallToolResult, *PackagesWriteOutput, error) { + if resp != nil { + defer func() { _ = resp.Body.Close() }() + } + if err != nil { + return ghErrors.NewGitHubAPIErrorResponse(ctx, fmt.Sprintf("failed to delete %s", target), resp, err), nil, nil + } + if resp.StatusCode != http.StatusNoContent { + body, err := io.ReadAll(resp.Body) + if err != nil { + return utils.NewToolResultErrorFromErr("failed to read response body", err), nil, nil + } + return ghErrors.NewGitHubAPIStatusErrorResponse(ctx, fmt.Sprintf("failed to delete %s", target), resp, body), nil, nil + } + + out := &PackagesWriteOutput{Method: method, Message: fmt.Sprintf("Deleted %s", target)} + return MarshalledTextResult(out), out, nil +} diff --git a/pkg/github/packages_test.go b/pkg/github/packages_test.go new file mode 100644 index 0000000000..07bd2eca5c --- /dev/null +++ b/pkg/github/packages_test.go @@ -0,0 +1,640 @@ +package github + +import ( + "context" + "encoding/json" + "net/http" + "testing" + + "github.com/google/go-github/v92/github" + "github.com/google/jsonschema-go/jsonschema" + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" + + "github.com/github/github-mcp-server/v2/internal/toolsnaps" + "github.com/github/github-mcp-server/v2/pkg/translations" +) + +// packagesReadPayloadKeys maps each packages_read method to the property of +// PackagesReadOutput that carries its payload. +var packagesReadPayloadKeys = map[string]string{ + packagesMethodListOrgPackages: "packages", + packagesMethodListUserPackages: "packages", + packagesMethodGetOrgPackage: "package", + packagesMethodGetUserPackage: "package", + packagesMethodListOrgPackageVersions: "versions", + packagesMethodListUserVersions: "versions", + packagesMethodGetOrgPackageVersion: "version", + packagesMethodGetUserPackageVersion: "version", +} + +func Test_PackagesRead(t *testing.T) { + toolDef := PackagesRead(translations.NullTranslationHelper) + require.NoError(t, toolsnaps.Test(toolDef.Tool.Name, toolDef.Tool)) + + assert.Equal(t, "packages_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", "package_type"}) + assert.Contains(t, schema.Properties, "page") + assert.Contains(t, schema.Properties, "perPage") + + outputSchema, err := toolDef.Tool.OutputSchema.(*jsonschema.Schema).Resolve(nil) + require.NoError(t, err) + + mockPackages := []*github.Package{{ + ID: new(int64(1)), + Name: new("github-mcp-server"), + PackageType: new("container"), + Visibility: new("public"), + }} + mockPackage := mockPackages[0] + mockVersions := []*github.PackageVersion{ + {ID: new(int64(101)), Name: new("sha256:abc")}, + {ID: new(int64(102)), Name: new("sha256:def")}, + } + mockVersion := mockVersions[0] + + expectQuery := func(t *testing.T, expected map[string]string, body any) http.HandlerFunc { + return func(w http.ResponseWriter, r *http.Request) { + for k, v := range expected { + assert.Equal(t, v, r.URL.Query().Get(k), "query parameter %q", k) + } + mockResponse(t, http.StatusOK, body)(w, r) + } + } + + tests := []struct { + name string + handlers map[string]http.HandlerFunc + args map[string]any + expectError bool + expectedErrMsg string + expectedResult any + }{ + { + name: "list_org_packages with filters and pagination", + handlers: map[string]http.HandlerFunc{ + "GET /orgs/{org}/packages": expectQuery(t, map[string]string{ + "package_type": "container", + "visibility": "public", + "page": "2", + "per_page": "10", + }, mockPackages), + }, + args: map[string]any{ + "method": "list_org_packages", + "org": "github", + "package_type": "container", + "visibility": "public", + "page": float64(2), + "perPage": float64(10), + }, + expectedResult: convertToPackageOutputs(mockPackages), + }, + { + name: "list_org_packages requires org", + handlers: map[string]http.HandlerFunc{}, + args: map[string]any{"method": "list_org_packages", "package_type": "npm"}, + expectError: true, + expectedErrMsg: "missing required parameter: org", + }, + { + name: "list_org_packages requires package_type", + handlers: map[string]http.HandlerFunc{}, + args: map[string]any{"method": "list_org_packages", "org": "github"}, + expectError: true, + expectedErrMsg: "missing required parameter: package_type", + }, + { + name: "list_org_packages rejects username", + handlers: map[string]http.HandlerFunc{}, + args: map[string]any{"method": "list_org_packages", "org": "github", "username": "octocat", "package_type": "npm"}, + expectError: true, + expectedErrMsg: "'username' is not supported by org_* methods", + }, + { + name: "list_org_packages API error", + handlers: map[string]http.HandlerFunc{ + "GET /orgs/{org}/packages": mockResponse(t, http.StatusNotFound, `{"message": "Not Found"}`), + }, + args: map[string]any{"method": "list_org_packages", "org": "missing", "package_type": "npm"}, + expectError: true, + expectedErrMsg: "failed to list packages for organization 'missing'", + }, + { + name: "list_user_packages for authenticated user", + handlers: map[string]http.HandlerFunc{ + "GET /user/packages": expectQuery(t, map[string]string{"package_type": "container"}, mockPackages), + }, + args: map[string]any{"method": "list_user_packages", "package_type": "container"}, + expectedResult: convertToPackageOutputs(mockPackages), + }, + { + name: "list_user_packages for named user", + handlers: map[string]http.HandlerFunc{ + "GET /users/{username}/packages": expectQuery(t, map[string]string{"package_type": "npm"}, mockPackages), + }, + args: map[string]any{"method": "list_user_packages", "username": "octocat", "package_type": "npm"}, + expectedResult: convertToPackageOutputs(mockPackages), + }, + { + name: "list_user_packages with no results", + handlers: map[string]http.HandlerFunc{ + "GET /user/packages": mockResponse(t, http.StatusOK, []*github.Package{}), + }, + args: map[string]any{"method": "list_user_packages", "package_type": "npm"}, + expectedResult: []PackageOutput{}, + }, + { + name: "list_user_packages rejects org", + handlers: map[string]http.HandlerFunc{}, + args: map[string]any{"method": "list_user_packages", "org": "github", "package_type": "npm"}, + expectError: true, + expectedErrMsg: "'org' is not supported by user_* methods", + }, + { + name: "get_org_package", + handlers: map[string]http.HandlerFunc{ + "GET /orgs/{org}/packages/{package_type}/{package_name}": mockResponse(t, http.StatusOK, mockPackage), + }, + args: map[string]any{ + "method": "get_org_package", + "org": "github", + "package_type": "container", + "package_name": "github-mcp-server", + }, + expectedResult: convertToPackageOutput(mockPackage), + }, + { + name: "get_org_package requires package_name", + handlers: map[string]http.HandlerFunc{}, + args: map[string]any{"method": "get_org_package", "org": "github", "package_type": "container"}, + expectError: true, + expectedErrMsg: "missing required parameter: package_name", + }, + { + name: "get_user_package for named user", + handlers: map[string]http.HandlerFunc{ + "GET /users/{username}/packages/{package_type}/{package_name}": mockResponse(t, http.StatusOK, mockPackage), + }, + args: map[string]any{ + "method": "get_user_package", + "username": "octocat", + "package_type": "container", + "package_name": "github-mcp-server", + }, + expectedResult: convertToPackageOutput(mockPackage), + }, + { + name: "get_user_package API error", + handlers: map[string]http.HandlerFunc{ + "GET /user/packages/{package_type}/{package_name}": mockResponse(t, http.StatusNotFound, `{"message": "Not Found"}`), + }, + args: map[string]any{ + "method": "get_user_package", + "package_type": "npm", + "package_name": "missing", + }, + expectError: true, + expectedErrMsg: "failed to get npm package 'missing' for the authenticated user", + }, + { + name: "list_org_package_versions with state", + handlers: map[string]http.HandlerFunc{ + "GET /orgs/{org}/packages/{package_type}/{package_name}/versions": expectQuery(t, map[string]string{"state": "deleted"}, mockVersions), + }, + args: map[string]any{ + "method": "list_org_package_versions", + "org": "github", + "package_type": "container", + "package_name": "github-mcp-server", + "state": "deleted", + }, + expectedResult: convertToPackageVersionOutputs(mockVersions), + }, + { + name: "list_user_package_versions for authenticated user", + handlers: map[string]http.HandlerFunc{ + "GET /user/packages/{package_type}/{package_name}/versions": expectQuery(t, map[string]string{"state": "active"}, mockVersions), + }, + args: map[string]any{ + "method": "list_user_package_versions", + "package_type": "container", + "package_name": "github-mcp-server", + "state": "active", + }, + expectedResult: convertToPackageVersionOutputs(mockVersions), + }, + { + name: "list_user_package_versions for named user", + handlers: map[string]http.HandlerFunc{ + "GET /users/{username}/packages/{package_type}/{package_name}/versions": expectQuery(t, map[string]string{ + "state": "active", + "page": "2", + "per_page": "5", + }, mockVersions), + }, + args: map[string]any{ + "method": "list_user_package_versions", + "username": "octocat", + "package_type": "container", + "package_name": "github-mcp-server", + "state": "active", + "page": float64(2), + "perPage": float64(5), + }, + expectedResult: convertToPackageVersionOutputs(mockVersions), + }, + { + name: "get_org_package_version", + handlers: map[string]http.HandlerFunc{ + "GET /orgs/{org}/packages/{package_type}/{package_name}/versions/{package_version_id}": mockResponse(t, http.StatusOK, mockVersion), + }, + args: map[string]any{ + "method": "get_org_package_version", + "org": "github", + "package_type": "container", + "package_name": "github-mcp-server", + "package_version_id": float64(101), + }, + expectedResult: convertToPackageVersionOutput(mockVersion), + }, + { + name: "get_org_package_version requires package_version_id", + handlers: map[string]http.HandlerFunc{}, + args: map[string]any{ + "method": "get_org_package_version", + "org": "github", + "package_type": "container", + "package_name": "github-mcp-server", + }, + expectError: true, + expectedErrMsg: "missing required parameter: package_version_id", + }, + { + name: "get_user_package_version for authenticated user", + handlers: map[string]http.HandlerFunc{ + "GET /user/packages/{package_type}/{package_name}/versions/{package_version_id}": mockResponse(t, http.StatusOK, mockVersion), + }, + args: map[string]any{ + "method": "get_user_package_version", + "package_type": "container", + "package_name": "github-mcp-server", + "package_version_id": float64(101), + }, + expectedResult: convertToPackageVersionOutput(mockVersion), + }, + { + name: "unknown method", + handlers: map[string]http.HandlerFunc{}, + args: map[string]any{"method": "invalid", "package_type": "npm"}, + expectError: true, + expectedErrMsg: "unknown method: invalid", + }, + } + + for _, tc := range tests { + t.Run(tc.name, func(t *testing.T) { + deps := BaseDeps{Client: mustNewGHClient(t, MockHTTPClientWithHandlers(tc.handlers))} + handler := toolDef.Handler(deps) + request := createMCPRequest(tc.args) + + result, err := handler(ContextWithDeps(context.Background(), deps), &request) + require.NoError(t, err) + + if tc.expectError { + require.True(t, result.IsError) + assert.Contains(t, getErrorResult(t, result).Text, tc.expectedErrMsg) + return + } + + require.False(t, result.IsError, getTextResult(t, result).Text) + text := getTextResult(t, result).Text + expected, err := json.Marshal(tc.expectedResult) + require.NoError(t, err) + assert.JSONEq(t, string(expected), text) + + // The payload must also validate as the method-tagged typed output. + method := tc.args["method"].(string) + var typed map[string]any + require.NoError(t, json.Unmarshal([]byte(`{"method":"`+method+`","`+packagesReadPayloadKeys[method]+`":`+text+`}`), &typed)) + require.NoError(t, outputSchema.Validate(typed)) + }) + } +} + +func Test_PackagesReadTrimsOutput(t *testing.T) { + toolDef := PackagesRead(translations.NullTranslationHelper) + outputSchema, err := toolDef.Tool.OutputSchema.(*jsonschema.Schema).Resolve(nil) + require.NoError(t, err) + + rawPackage := `{ + "id": 1, + "name": "github-mcp-server", + "package_type": "container", + "visibility": "public", + "version_count": 3, + "url": "https://api.github.com/orgs/github/packages/container/github-mcp-server", + "html_url": "https://github.com/orgs/github/packages/container/package/github-mcp-server", + "created_at": "2025-01-02T03:04:05Z", + "updated_at": "2025-02-03T04:05:06Z", + "owner": {"login": "github", "id": 9919, "type": "Organization", "avatar_url": "https://avatars.example/9919"}, + "repository": {"id": 2, "name": "github-mcp-server", "full_name": "github/github-mcp-server", "owner": {"login": "github"}} + }` + rawVersion := `{ + "id": 101, + "name": "sha256:abc", + "url": "https://api.github.com/orgs/github/packages/container/github-mcp-server/versions/101", + "package_html_url": "https://github.com/orgs/github/packages/container/package/github-mcp-server", + "html_url": "https://github.com/orgs/github/packages/container/github-mcp-server/101", + "license": "MIT", + "created_at": "2025-01-02T03:04:05Z", + "updated_at": "2025-02-03T04:05:06Z", + "metadata": {"package_type": "container", "container": {"tags": ["latest", "v1.0.0"]}} + }` + + tests := []struct { + name string + handlers map[string]http.HandlerFunc + args map[string]any + expected string + }{ + { + name: "package keeps owner login and repository full name", + handlers: map[string]http.HandlerFunc{ + "GET /orgs/{org}/packages/{package_type}/{package_name}": mockResponse(t, http.StatusOK, rawPackage), + }, + args: map[string]any{"method": "get_org_package", "org": "github", "package_type": "container", "package_name": "github-mcp-server"}, + expected: `{ + "id": 1, + "name": "github-mcp-server", + "package_type": "container", + "visibility": "public", + "version_count": 3, + "owner": "github", + "repository": "github/github-mcp-server", + "html_url": "https://github.com/orgs/github/packages/container/package/github-mcp-server", + "created_at": "2025-01-02T03:04:05Z", + "updated_at": "2025-02-03T04:05:06Z" + }`, + }, + { + name: "version keeps container tags", + handlers: map[string]http.HandlerFunc{ + "GET /orgs/{org}/packages/{package_type}/{package_name}/versions/{package_version_id}": mockResponse(t, http.StatusOK, rawVersion), + }, + args: map[string]any{"method": "get_org_package_version", "org": "github", "package_type": "container", "package_name": "github-mcp-server", "package_version_id": float64(101)}, + expected: `{ + "id": 101, + "name": "sha256:abc", + "license": "MIT", + "html_url": "https://github.com/orgs/github/packages/container/github-mcp-server/101", + "created_at": "2025-01-02T03:04:05Z", + "updated_at": "2025-02-03T04:05:06Z", + "metadata": {"package_type": "container", "container": {"tags": ["latest", "v1.0.0"]}} + }`, + }, + } + + for _, tc := range tests { + t.Run(tc.name, func(t *testing.T) { + deps := BaseDeps{Client: mustNewGHClient(t, MockHTTPClientWithHandlers(tc.handlers))} + request := createMCPRequest(tc.args) + + result, err := toolDef.Handler(deps)(ContextWithDeps(context.Background(), deps), &request) + require.NoError(t, err) + require.False(t, result.IsError, getTextResult(t, result).Text) + text := getTextResult(t, result).Text + assert.JSONEq(t, tc.expected, text) + + method := tc.args["method"].(string) + var typed map[string]any + require.NoError(t, json.Unmarshal([]byte(`{"method":"`+method+`","`+packagesReadPayloadKeys[method]+`":`+text+`}`), &typed)) + require.NoError(t, outputSchema.Validate(typed)) + }) + } +} + +func Test_PackagesWrite(t *testing.T) { + toolDef := PackagesWrite(translations.NullTranslationHelper) + require.NoError(t, toolsnaps.Test(toolDef.Tool.Name, toolDef.Tool)) + + assert.Equal(t, "packages_write", toolDef.Tool.Name) + assert.NotEmpty(t, toolDef.Tool.Description) + assert.False(t, toolDef.Tool.Annotations.ReadOnlyHint) + require.NotNil(t, toolDef.Tool.Annotations.DestructiveHint) + assert.True(t, *toolDef.Tool.Annotations.DestructiveHint) + assert.ElementsMatch(t, toolDef.ScopeAccess.Scopes, []string{"read:packages", "delete:packages"}) + + schema, ok := toolDef.Tool.InputSchema.(*jsonschema.Schema) + require.True(t, ok, "InputSchema should be *jsonschema.Schema") + assert.ElementsMatch(t, schema.Required, []string{"method", "package_type", "package_name"}) + + outputSchema, err := toolDef.Tool.OutputSchema.(*jsonschema.Schema).Resolve(nil) + require.NoError(t, err) + + noContent := mockResponse(t, http.StatusNoContent, "") + + tests := []struct { + name string + handlers map[string]http.HandlerFunc + args map[string]any + expectError bool + expectedErrMsg string + expectedMessage string + }{ + { + name: "delete_org_package", + handlers: map[string]http.HandlerFunc{ + "DELETE /orgs/{org}/packages/{package_type}/{package_name}": noContent, + }, + args: map[string]any{ + "method": "delete_org_package", + "org": "github", + "package_type": "container", + "package_name": "old-image", + }, + expectedMessage: "Deleted container package 'old-image' for organization 'github'", + }, + { + name: "delete_org_package requires org", + handlers: map[string]http.HandlerFunc{}, + args: map[string]any{ + "method": "delete_org_package", + "package_type": "container", + "package_name": "old-image", + }, + expectError: true, + expectedErrMsg: "missing required parameter: org", + }, + { + name: "delete_org_package rejects username", + handlers: map[string]http.HandlerFunc{}, + args: map[string]any{ + "method": "delete_org_package", + "org": "github", + "username": "octocat", + "package_type": "container", + "package_name": "old-image", + }, + expectError: true, + expectedErrMsg: "'username' is not supported by org_* methods", + }, + { + name: "delete_org_package API error", + handlers: map[string]http.HandlerFunc{ + "DELETE /orgs/{org}/packages/{package_type}/{package_name}": mockResponse(t, http.StatusForbidden, `{"message": "Forbidden"}`), + }, + args: map[string]any{ + "method": "delete_org_package", + "org": "github", + "package_type": "container", + "package_name": "old-image", + }, + expectError: true, + expectedErrMsg: "failed to delete container package 'old-image' for organization 'github'", + }, + { + name: "delete_org_package unexpected status", + handlers: map[string]http.HandlerFunc{ + "DELETE /orgs/{org}/packages/{package_type}/{package_name}": mockResponse(t, http.StatusOK, `{"message": "unexpected"}`), + }, + args: map[string]any{ + "method": "delete_org_package", + "org": "github", + "package_type": "container", + "package_name": "old-image", + }, + expectError: true, + expectedErrMsg: "unexpected status 200", + }, + { + name: "delete_org_package_version", + handlers: map[string]http.HandlerFunc{ + "DELETE /orgs/{org}/packages/{package_type}/{package_name}/versions/{package_version_id}": noContent, + }, + args: map[string]any{ + "method": "delete_org_package_version", + "org": "github", + "package_type": "container", + "package_name": "old-image", + "package_version_id": float64(101), + }, + expectedMessage: "Deleted version 101 of container package 'old-image' for organization 'github'", + }, + { + name: "delete_org_package_version requires package_version_id", + handlers: map[string]http.HandlerFunc{}, + args: map[string]any{ + "method": "delete_org_package_version", + "org": "github", + "package_type": "container", + "package_name": "old-image", + }, + expectError: true, + expectedErrMsg: "missing required parameter: package_version_id", + }, + { + name: "delete_user_package for authenticated user", + handlers: map[string]http.HandlerFunc{ + "DELETE /user/packages/{package_type}/{package_name}": noContent, + }, + args: map[string]any{ + "method": "delete_user_package", + "package_type": "npm", + "package_name": "old-lib", + }, + expectedMessage: "Deleted npm package 'old-lib' for the authenticated user", + }, + { + name: "delete_user_package for named user", + handlers: map[string]http.HandlerFunc{ + "DELETE /users/{username}/packages/{package_type}/{package_name}": noContent, + }, + args: map[string]any{ + "method": "delete_user_package", + "username": "octocat", + "package_type": "npm", + "package_name": "old-lib", + }, + expectedMessage: "Deleted npm package 'old-lib' for user 'octocat'", + }, + { + name: "delete_user_package rejects org", + handlers: map[string]http.HandlerFunc{}, + args: map[string]any{ + "method": "delete_user_package", + "org": "github", + "package_type": "npm", + "package_name": "old-lib", + }, + expectError: true, + expectedErrMsg: "'org' is not supported by user_* methods", + }, + { + name: "delete_user_package_version", + handlers: map[string]http.HandlerFunc{ + "DELETE /user/packages/{package_type}/{package_name}/versions/{package_version_id}": noContent, + }, + args: map[string]any{ + "method": "delete_user_package_version", + "package_type": "npm", + "package_name": "old-lib", + "package_version_id": float64(7), + }, + expectedMessage: "Deleted version 7 of npm package 'old-lib' for the authenticated user", + }, + { + name: "delete_user_package_version rejects org", + handlers: map[string]http.HandlerFunc{}, + args: map[string]any{ + "method": "delete_user_package_version", + "org": "github", + "package_type": "npm", + "package_name": "old-lib", + "package_version_id": float64(7), + }, + expectError: true, + expectedErrMsg: "'org' is not supported by user_* methods", + }, + { + name: "unknown method", + handlers: map[string]http.HandlerFunc{}, + args: map[string]any{ + "method": "invalid", + "package_type": "npm", + "package_name": "old-lib", + }, + expectError: true, + expectedErrMsg: "unknown method: invalid", + }, + } + + for _, tc := range tests { + t.Run(tc.name, func(t *testing.T) { + deps := BaseDeps{Client: mustNewGHClient(t, MockHTTPClientWithHandlers(tc.handlers))} + handler := toolDef.Handler(deps) + request := createMCPRequest(tc.args) + + result, err := handler(ContextWithDeps(context.Background(), deps), &request) + require.NoError(t, err) + + if tc.expectError { + require.True(t, result.IsError) + assert.Contains(t, getErrorResult(t, result).Text, tc.expectedErrMsg) + return + } + + require.False(t, result.IsError, getTextResult(t, result).Text) + var response map[string]any + require.NoError(t, json.Unmarshal([]byte(getTextResult(t, result).Text), &response)) + assert.Equal(t, tc.args["method"], response["method"]) + assert.Equal(t, tc.expectedMessage, response["message"]) + require.NoError(t, outputSchema.Validate(response)) + }) + } +} diff --git a/pkg/github/packages_types.go b/pkg/github/packages_types.go new file mode 100644 index 0000000000..24a2755d6d --- /dev/null +++ b/pkg/github/packages_types.go @@ -0,0 +1,177 @@ +package github + +import ( + "encoding/json" + + "github.com/google/go-github/v92/github" + "github.com/google/jsonschema-go/jsonschema" +) + +const ( + packagesMethodListOrgPackages = "list_org_packages" + packagesMethodGetOrgPackage = "get_org_package" + packagesMethodListOrgPackageVersions = "list_org_package_versions" + packagesMethodGetOrgPackageVersion = "get_org_package_version" + packagesMethodListUserPackages = "list_user_packages" + packagesMethodGetUserPackage = "get_user_package" + packagesMethodListUserVersions = "list_user_package_versions" + packagesMethodGetUserPackageVersion = "get_user_package_version" + + packagesMethodDeleteOrgPackage = "delete_org_package" + packagesMethodDeleteOrgPackageVersion = "delete_org_package_version" + packagesMethodDeleteUserPackage = "delete_user_package" + packagesMethodDeleteUserPackageVersion = "delete_user_package_version" +) + +var packagesReadMethods = []any{ + packagesMethodListOrgPackages, packagesMethodGetOrgPackage, packagesMethodListOrgPackageVersions, packagesMethodGetOrgPackageVersion, + packagesMethodListUserPackages, packagesMethodGetUserPackage, packagesMethodListUserVersions, packagesMethodGetUserPackageVersion, +} + +var packagesWriteMethods = []any{ + packagesMethodDeleteOrgPackage, packagesMethodDeleteOrgPackageVersion, packagesMethodDeleteUserPackage, packagesMethodDeleteUserPackageVersion, +} + +// PackageOutput is the trimmed output type for a package. The owner and +// repository objects are reduced to the owner login and repository full name. +type PackageOutput struct { + ID int64 `json:"id"` + Name string `json:"name"` + PackageType string `json:"package_type"` + Visibility string `json:"visibility,omitempty"` + VersionCount *int64 `json:"version_count,omitempty"` + Owner string `json:"owner,omitempty"` + Repository string `json:"repository,omitempty"` + HTMLURL string `json:"html_url,omitempty"` + CreatedAt string `json:"created_at,omitempty"` + UpdatedAt string `json:"updated_at,omitempty"` +} + +// PackageVersionOutput is the trimmed output type for a package version. +type PackageVersionOutput struct { + ID int64 `json:"id"` + Name string `json:"name"` + Description string `json:"description,omitempty"` + License string `json:"license,omitempty"` + HTMLURL string `json:"html_url,omitempty"` + CreatedAt string `json:"created_at,omitempty"` + UpdatedAt string `json:"updated_at,omitempty"` + Metadata *PackageVersionMetadataOutput `json:"metadata,omitempty"` +} + +type PackageVersionMetadataOutput struct { + PackageType string `json:"package_type,omitempty"` + Container *PackageContainerMetadataOutput `json:"container,omitempty"` +} + +type PackageContainerMetadataOutput struct { + Tags []string `json:"tags"` +} + +type PackagesReadOutput struct { + Method string `json:"method"` + Packages *[]PackageOutput `json:"packages,omitempty"` + Package *PackageOutput `json:"package,omitempty"` + Versions *[]PackageVersionOutput `json:"versions,omitempty"` + Version *PackageVersionOutput `json:"version,omitempty"` +} + +type PackagesWriteOutput struct { + Method string `json:"method"` + Message string `json:"message"` +} + +func convertToPackageOutput(pkg *github.Package) PackageOutput { + out := PackageOutput{ + ID: pkg.GetID(), + Name: pkg.GetName(), + PackageType: pkg.GetPackageType(), + Visibility: pkg.GetVisibility(), + VersionCount: pkg.VersionCount, + HTMLURL: pkg.GetHTMLURL(), + CreatedAt: formatMinimalTimestamp(pkg.CreatedAt), + UpdatedAt: formatMinimalTimestamp(pkg.UpdatedAt), + } + if pkg.Owner != nil { + out.Owner = pkg.Owner.GetLogin() + } + if pkg.Repository != nil { + out.Repository = pkg.Repository.GetFullName() + } + return out +} + +func convertToPackageOutputs(packages []*github.Package) []PackageOutput { + out := make([]PackageOutput, 0, len(packages)) + for _, pkg := range packages { + out = append(out, convertToPackageOutput(pkg)) + } + return out +} + +func convertToPackageVersionOutput(version *github.PackageVersion) PackageVersionOutput { + out := PackageVersionOutput{ + ID: version.GetID(), + Name: version.GetName(), + Description: version.GetDescription(), + License: version.GetLicense(), + HTMLURL: version.GetHTMLURL(), + CreatedAt: formatMinimalTimestamp(version.CreatedAt), + UpdatedAt: formatMinimalTimestamp(version.UpdatedAt), + } + // REST responses carry metadata as an object. Anything else (such as the + // webhook array form) is dropped rather than failing the whole call. + var metadata github.PackageMetadata + if len(version.Metadata) > 0 && json.Unmarshal(version.Metadata, &metadata) == nil { + out.Metadata = &PackageVersionMetadataOutput{PackageType: metadata.GetPackageType()} + if metadata.Container != nil { + tags := metadata.Container.Tags + if tags == nil { + tags = []string{} + } + out.Metadata.Container = &PackageContainerMetadataOutput{Tags: tags} + } + } + return out +} + +func convertToPackageVersionOutputs(versions []*github.PackageVersion) []PackageVersionOutput { + out := make([]PackageVersionOutput, 0, len(versions)) + for _, version := range versions { + out = append(out, convertToPackageVersionOutput(version)) + } + return out +} + +func packagesReadOutputSchema() *jsonschema.Schema { + schema := forbidOmittedNulls(repositoryOutputSchema[PackagesReadOutput]()) + schema.Properties["method"].Enum = packagesReadMethods + schema.Properties["method"].Description = "The method that produced the corresponding typed output." + for _, pkg := range []*jsonschema.Schema{schema.Properties["package"], schema.Properties["packages"].Items} { + pkg.Properties["owner"].Description = "Login of the package owner." + pkg.Properties["repository"].Description = "Full name of the repository linked to the package." + pkg.Properties["version_count"].Description = "Number of versions. Omitted when the API does not report it, as in list results." + } + for _, version := range []*jsonschema.Schema{schema.Properties["version"], schema.Properties["versions"].Items} { + tags := version.Properties["metadata"].Properties["container"].Properties["tags"] + tags.Type, tags.Types = "array", nil + } + constrainMethodOutput(schema, map[string]string{ + packagesMethodListOrgPackages: "packages", + packagesMethodListUserPackages: "packages", + packagesMethodGetOrgPackage: "package", + packagesMethodGetUserPackage: "package", + packagesMethodListOrgPackageVersions: "versions", + packagesMethodListUserVersions: "versions", + packagesMethodGetOrgPackageVersion: "version", + packagesMethodGetUserPackageVersion: "version", + }) + return schema +} + +func packagesWriteOutputSchema() *jsonschema.Schema { + schema := repositoryOutputSchema[PackagesWriteOutput]() + schema.Properties["method"].Enum = packagesWriteMethods + schema.Properties["method"].Description = "The method that produced the corresponding typed output." + return schema +} diff --git a/pkg/github/tools.go b/pkg/github/tools.go index 47189398b8..15e0a162c0 100644 --- a/pkg/github/tools.go +++ b/pkg/github/tools.go @@ -119,6 +119,11 @@ var ( Description: "GitHub Gist related tools", Icon: "logo-gist", } + ToolsetMetadataPackages = inventory.ToolsetMetadata{ + ID: "packages", + Description: "GitHub Packages related tools", + Icon: "package", + } ToolsetMetadataSecurityAdvisories = inventory.ToolsetMetadata{ ID: "security_advisories", Description: "Security advisories related tools", @@ -347,6 +352,10 @@ func AllTools(t translations.TranslationHelperFunc, opts ...ToolOption) []invent CreateGist(t), UpdateGist(t), + // Package tools + PackagesRead(t), + PackagesWrite(t), + // Project tools ProjectsList(t), ProjectsGet(t), diff --git a/pkg/http/oauth/oauth_test.go b/pkg/http/oauth/oauth_test.go index 671a62a3fe..c7ac886c16 100644 --- a/pkg/http/oauth/oauth_test.go +++ b/pkg/http/oauth/oauth_test.go @@ -676,6 +676,7 @@ func TestSupportedScopes(t *testing.T) { "user:email", "read:packages", "write:packages", + "delete:packages", "read:project", "project", "gist", diff --git a/pkg/octicons/icons/package-dark.png b/pkg/octicons/icons/package-dark.png new file mode 100644 index 0000000000..4f60b28ceb Binary files /dev/null and b/pkg/octicons/icons/package-dark.png differ diff --git a/pkg/octicons/icons/package-light.png b/pkg/octicons/icons/package-light.png new file mode 100644 index 0000000000..7e3e95a465 Binary files /dev/null and b/pkg/octicons/icons/package-light.png differ diff --git a/pkg/octicons/icons_data_uris.txt b/pkg/octicons/icons_data_uris.txt index b5899f7589..a737b59584 100644 --- a/pkg/octicons/icons_data_uris.txt +++ b/pkg/octicons/icons_data_uris.txt @@ -38,6 +38,8 @@ mark-github-dark data:image/png;base64,iVBORw0KGgoAAAANSUhEUgAAABgAAAAYCAYAAADgd mark-github-light data:image/png;base64,iVBORw0KGgoAAAANSUhEUgAAABgAAAAYCAYAAADgdz34AAAABmJLR0QA/wD/AP+gvaeTAAADK0lEQVRIibWVQWhcVRSGv3Pfy0zGdBIMRWk7iziMTpORptpqiNSNG1dNizaFFlxIoYuCC7NwJdiVlhYprtwpighCF9GtuhCRtuCoaWdqGF7HkE4aWm1CpklmJpl3j4vMS99M2k4G9N+8d8659/8u9553H/zPkscVM5lMf03do6iOKbJXIAGgUBJ0GpHvolKfzOfzCx0BEonRWDRemRD0PaC3zSKXED1fLfdcLJUuV9oC0ukXdluxkyq81Ma4VX9Y4x4p5rOzjwSkUvsSdMkVYE+H5oHmjM9IoTA1FyRM8JJIjMbokm83zUW/Vvi5raXwC8rnjWiPdXRyYGCgOyi7wUs0XpkAXmyEa/XV8qmZmZnqc0P7XrXKW2BuKDovgorqLoWMFb4p3rj2I4w7qcHCMSAOctDt7n0X+GiDT6NbrPsXDw50xftzKg7odvcmtXf4DsJTjXBpzbXPzF6/vmgAauoepblbnGTyQLvu2VQmk4kgxEKpvui6cwSCM1Ada5qheq5YzC5tF5DP59dQzjYlZcOzccgyGK65mC+2ax7IWPfLcGxhKARgV7i4vBy70ymgUMj+A9SDWGB3GOCEB/f0VJ/sFJBKjfQS6srAOwD8HR7su/7BTgE2Umn98u9uAgR+ayqpnOkUIK1zZMPTACjyQzOA11ND+z+E8aate4TMs4PD7wu80ezP940nJJMH+ky0XgKeAHkH1TcRXgNywFeoueJN//5T2CCZGT7kWBlV9CSwvwV6n/XuhOddLRuAYjG7hMjHgEH1tEb0BEgWeB44J8aebF2243Nc0fMPMUfgguddLUOoe/r7dlwWJzqGMCxq6q5Wz/jSVQZ+Nb795N69u4thk/6dT7uInNiyYcoU9ZW3FxYWfAjdpp7n1XzjHAbmUJ3wTXQ8gvOZb+oXV1d3zLf6WEcWW3MoJXX9w57n1YKUCdeL+eysOv4oyrQqn65Tv+1adz4WrxzaskXWNs0V5Jq6/is3c7lb4byhRTdzuVvUV0ZQPgDuA6jqlj+fqhvkllE9q+vLL7eab4Afo1RqpJdI9Rhr3ZeCQwuUTqfjvoket7WuS51cjP+5/gWC8y5uIkrtDQAAAABJRU5ErkJggg== organization-dark data:image/png;base64,iVBORw0KGgoAAAANSUhEUgAAABgAAAAYCAYAAADgdz34AAAABmJLR0QA/wD/AP+gvaeTAAABLklEQVRIic2VsUoDQRCGvzVWVj5AUHwM38BoFzBo6zMIFlokqOAb+AYp7LTwEbRPZyWYtGIlIiqfhSssYe8uJ2f0a46ZuZ1/Z2duD+aFuqVOrM9Y7RTlDYnAGHgCrmvubRNYDiGsVFWg2q+ZHLWvWhRfqJuwLlkBtae+qtvRHqmjXKyKxQL/DXAM3Eb7rCQ2G3PtgdpW99V2tHtqLxeroqjJ68BpfAIcAYcFsenNLWUzNnFE6p76nH54TY/pJXAHnJcK/HRMQwiPwBWw+u379THNCoQQJsBJYg+LYlX8zVXRJEU9AL4aCqxNue9DCBeNCABDoDXl+wBmFqg6ohYwCBFgkBEsJa1gAnTVl6pF6kFidoG3xNcFxrlFnfh/TXlXd5J3dqOvjAd1o06V/5tPH0lBqyqxKbkAAAAASUVORK5CYII= organization-light data:image/png;base64,iVBORw0KGgoAAAANSUhEUgAAABgAAAAYCAYAAADgdz34AAAABmJLR0QA/wD/AP+gvaeTAAABqElEQVRIidWUsW4TQRCGv9kYFzwCRCDEUuxJsCelyiMAXSSs1Cn8ChQg4QiQeANIpHQoQumg4Q1IGxvJviiLKOIWOgQx8g1FOFux1z58SoTyVzvz7843ezs6uGBJsbjj0oeKbgHXF6qg9NXkzS/dzx9jdm20T/UNwnfg7YItPhA1W8CNuQCEZUR2QvegtUh9m6S/UH02yzeLFKuiKMAmvmGdP7FJ+gjAOt+xzndiXplq0exAP8kVea6DfB9AVF7N8ioBQuj0gRdFfJQd7M7yKgGsvbesddZlwLsQOn2b+AZA6Lb3Jr0yQPyR62ZVVF5SN6sAKE9F5UnUm9C1lZWr0ZrWebVJ2irraOpckras83q69hvW+R+3k7v359+gomr5yXvgUNS8nguoOqZZln1D5ANwcwSNtnKZxvT//CrOU/E3+Cub+AbKrTNJ4WvotvfOBYCyCyxN5IbAPwPKPtESIpuh15bQawsim1PAEo1voPRB16zzP8sOWecfj8/pGvB7lDuNj6cAavKm5GYbYTzzMFTNsyKQPD9UkSGc2VOoyB2r0WZZk5dHfwA6M7v5DAVu0gAAAABJRU5ErkJggg== +package-dark data:image/png;base64,iVBORw0KGgoAAAANSUhEUgAAABgAAAAYCAYAAADgdz34AAABzklEQVR4nMTUv2oUXRjH8TPvG0wRTSVIyhBTWSdegEUakZA6WOQGUgna2ViIldhYWOgNaGGXRrHRXlBBRBAMKmIIaYyY/cjB5wmTIZudJajf2QMzvz/nZCDzTJSe4HQpZb2UMiil3Gma5mt6xwITWMcWfsbaCm1izO0OgiW88psNnIu1EVr1ljLfG8zjcWzyFpfSS6oWnsjOpzcUTOMWdrGNKziRfpfqRWY7OrU7nf4BsIzP2MM9nElvFDUbnb3Y42J6++BbvO4jnEy9L7UT3fr7kvo+YXzAAJu4jCb9YdRMZDejW/eQfveA61jE83h+gfOZ6VK9yIjOYuwx/IC4b7CKj/FXPcBMKzsT2iAyq/m2vQ7IC1O4ge/YwbVYO6FVb6rT6X9Aglk8jIy4n03/qAN6fepN07wvpazgWTyvpDeKcWfJ4DDxqOu/vPlT/NUD9kopc/g/hXGJ7tlSyo/U9sHd+A95iQupt8HTuvK5Te1Et/5up37YwHsXoTqT5jr+k7o62lxrBtXucnqHgklcjY+pjuCbONU9oGrh7Ua2diZzn5HEOLgf4+AT1up3EGsttEFkZjr1/mChNfzaVx1uC5k7Fq3h9wav28Ptn/NrAFabsbk4q6WOAAAAAElFTkSuQmCC +package-light data:image/png;base64,iVBORw0KGgoAAAANSUhEUgAAABgAAAAYCAYAAADgdz34AAADAklEQVR4nMRVT2gcZRx9b2azhEgLxdrQg3+Sme5u0nQ8icWrihfxT69VUbwIQmPNqWmFgrbFgxYrgkKJVvQcKb2InrxU6sVs42Z3MtuleigrBVFJqNudefJb9lti1g1rKPb9ZuGb33vv93aW+b7NYUjsLRR2j/mjs8iUreuvD2/E8U3HbQW6xRbITRaj10meBLSja/tT0slrtfJHANpO+J8DwmL0lIizJKcEfYMWj3aIvM4SfFLSCoWjSa38tfMMFTA5NbWPyr9P4mlJCaW5pHb1ouOtwuKBZ0S+RzKUcElsvXltZWXV8f8aEIbhziw39hbJI5RuSXqn7qcfoFJpOc0/MD2dD1J/luQJkaOSznnt9beTJPmjLyAozDwHz/sEwG5Sn6a3dLzRWG46fitMTMyM+6M8JfEVADcz8NVGdemScZ4TwfMXSO4BcPF3pm8MO9zKtOYxr83wgQXH9QJI7JL0C4BndyoXB6Xopc0/4QDQtOYxb2cGcV9fQOeSFrK0fZDAdZIXgtLDlx/aFz3q6M0wzjSmNY95IfW+fX+APe5q5UqyUn4MKV4EdL/v4/JkMbrwQKm012lsbT3jTGNa8zRWK1ecxmHQTlYSL30xPh4t3rML8x4xN8L8oaAUne6+GvMERjLgzNpvON1sLq0547ABnWo2y2to4viDheh8zt55D50AZVi8Lc1dj8sNp91WgKvuIHuC7+y+XisfctwdCeiVkLnlsPAG9O8Y/r8ASSnpBQB819sGfNjhB7T6AgCeB/FCUIp+nCwceNx1h4V5zEvyMJR93BdQry69pjR9HsCY53vfBqVoMQz3BxsDSMg+7t7KNKY1j3ltRr16dbYvwK56vPwV2+vTkI6BeEIjfiWYit69t1jcgY1CANYzzjSmNY95bcbA/4ONZcfBiPJnQNih92smznvUy8Zl4mceZZtuD4TPb7N17Odq9YbzDhXgaqKw/xHf988BPLhpU3yfpumRRvzTD66zrYBuMSzMHJbnnbDtxiw7lcTLX9q6p7hb9fcAI+U69Y3LC/cAAAAASUVORK5CYII= people-dark data:image/png;base64,iVBORw0KGgoAAAANSUhEUgAAABgAAAAYCAYAAADgdz34AAAABmJLR0QA/wD/AP+gvaeTAAAB20lEQVRIidXUvUuVcRQH8PPc1JYUrKWUDCmwoKa2QsTKoKEwxWhr6G1tqDVqaAn8F6I5oS0ipBp6WZqiAi1bEgIrIpcorD4NHvFye+7Vrkv94Dc853zP93venl/E/36Keg60RsRgROyOiEpEPI+IB0VR/FyzKgYw48/zBv1rJe/HN7zDKNrzDmMqfc2JoAVvk3xjib8zfa/R0ozA4WzFaAPMWGKG8vskLuDIiqK4lMHtDTAdibmY3+9rZrSnGl+piV9YqcpY3jwREUVRdEXEhog4GhGtETGJznrZHchMhhtUcCIxh0p8u/ADV+sFV3KAU2VZYBNmE7OuDsdj3K+XYGAfvua2jGXPOzLz2VzT/Q3iH2GykUCByyU/2dK50iB2B77jWj3ATjxNos+4hfG8E2mDJ+irid2LaXzCljLyQcxjDmctvkW1mFacwwd8wUCV72GKH6+X+TxeYGvd/i3je/AqRfrSNo6F5DldDS6y5LnVkFfFbcPHHGqRtu24i184tQQcytLOrJa8SuR8xh6ssrXhTm5bd+BmDq+tCYH12aYbNfbe3KbrYfH9mPhb8iqy25gusd/Ds0pEbI6ImWYFImI6IrpK7C8jojcwgu5m2dGFYyX2How0y/vvnN8dpHfeBcHNQgAAAABJRU5ErkJggg== people-light data:image/png;base64,iVBORw0KGgoAAAANSUhEUgAAABgAAAAYCAYAAADgdz34AAAABmJLR0QA/wD/AP+gvaeTAAADWklEQVRIidWTT2hcVRTGf+e+mUn9Q3WMqbbWgJ1JnMw4meiAUkoxIRG1IKK4ELqoVkQXUsVuXbropoq4EWykuBDURV2I0r9GrC0VopnXTGaefYkhDahBOg4SnSTv3eMiTphMJ6Spq57dPd+933e/794DN3rJWkA+n49W54MB4AE1aoyawqVS1xn4PPzfAsnUg48iOgyaaIJ8I7r/5wn3u2sVMM2N7nTvbsQeB42J6nOOrW12bG2zIs8ohFblZHe6d/d1Oejv74/MzlU8lGibCfqKxeKVRrwzm43HAlMAatvviqdHRkaC9QScxkVk021DwAFRfalcGv+xeXN1bq52x5ats8Crf80vnL3yx29TiZ7e59s7tg7Ft3TEc5n09PT0tG08syoiEckBGF04seaVFtuOA1ixuf9OvQP6rqj5avb3SmlHKptdU0BhaT3L1gbLsVpRgMlSYdvNUb1VlKeAqBFzsjObjbcWMNYFCMxNg2sJmE3B4wCOmEK957ru/KVy4UtxnCeBO2OheaOONX9Tk+zJlRXsUsTunLl4sdIIplKp9oC2MYR//FJ3T6uZSKZzZ7Es+OXCIECkOQFVeUFET8UCU0im+w7WMydWeyKwHEboUCuDaw2cKGpFVx76qjmYLI+dRzkE3IvqZ0RrVaK1KsqnCNsROTTpjX3fijyReSip8LCo+aFlRN3d2ZR15COQnUAFOIVwedmbdCI6CMSBc47V/Z7neivkqWxexHwC2h4lmi2VRn9dJZC4Pzcghi9Qaoq8dfstztHR0dFVvyqfz0f/nA9fFNG3gRhqnvbLP30LkOzJfQP0Kzw7WSocW+Vg+ebmAjCjTrhncnz8cqsI6rUjk+80NvgauMex+ojnuV4ynTuMcgD4G/RNv+QO199ArGOGUWrXQg4wVRydCTF7gCVr5Agg/kThIE6YQjkH8mEi1bcPQLp6+h5T9AToy37JPbIeeWN1pXOvqPKBFR2amnBPA2QymdiidY4pMmhCEgbsXqDSZsKPN0IOoIvzR4GqsbK33isWi4vG8hogoaOvG0V2oXK6WCwublTA9/0FgTMIuxr7nuf+AowIDBjgboz6GyVfcSF4wLarAGEcuC8iyj4JuXC9Ak5o3g8j4fnmvpXIe44NWg7kjVX/Ap7dYx0LcmfJAAAAAElFTkSuQmCC person-dark data:image/png;base64,iVBORw0KGgoAAAANSUhEUgAAABgAAAAYCAYAAADgdz34AAAABmJLR0QA/wD/AP+gvaeTAAABaElEQVRIidWUvUrDcBTFz79YnRSii4N2cVB8AuvgByKuQqmrILQPoe9SfABxV5wUna2IVLq1k11adffn4A3UNPknjQ56IARyzz3ncj8i/Xe4LCSgIGlD0qp9epJ07Zz7+HEFQBl4YBRNoPwb4u9AB6gA0/bsAy3gDVjLK+6syg4wGxMPLHYPZGp1VGDLWlHxcKrG2UziFDwe4UAvPZyLCHcsgwlPLETYmkSuz6Bp7x0PZy/CzQ6gYENuAUFMfA7o2pB9hXpN1m0VOzbQGXsOTDz/mpqBA05ijizEcZpG4v4CK5IaksqS+pKuJHUtXNLXbAJJd5KOnHPP41S+DbwCL0ANKMZwikAd6AED3y2MVG7ij8BiBn7JuANgOY3sgFurPFU8YtIDbry/DWDXhlfLKj6UW7fc5LsBToE+MJnDYMra1PCR2sDZuOJD+efAt22KXuC8pHZeA8td8FVQBZIJKQCWgMO8+X8Tn12zhtgfmPjeAAAAAElFTkSuQmCC diff --git a/pkg/octicons/required_icons.txt b/pkg/octicons/required_icons.txt index ebc5d99048..6b5be3290b 100644 --- a/pkg/octicons/required_icons.txt +++ b/pkg/octicons/required_icons.txt @@ -33,6 +33,7 @@ law logo-gist mark-github organization +package people person project diff --git a/pkg/scopes/scopes.go b/pkg/scopes/scopes.go index 709fd050f0..8a6afa3849 100644 --- a/pkg/scopes/scopes.go +++ b/pkg/scopes/scopes.go @@ -69,6 +69,9 @@ const ( // WritePackages grants write access to packages WritePackages Scope = "write:packages" + // DeletePackages grants permission to delete packages + DeletePackages Scope = "delete:packages" + // Workflow grants permission to update GitHub Actions workflow files Workflow Scope = "workflow" @@ -92,6 +95,7 @@ var oauthScopeDefinitions = []oauthScopeDefinition{ {scope: UserEmail, byDefault: true}, {scope: ReadPackages, byDefault: true}, {scope: WritePackages, byDefault: true}, + {scope: DeletePackages}, {scope: ReadProject, byDefault: true}, {scope: Project, byDefault: true}, {scope: Gist, byDefault: true}, diff --git a/pkg/scopes/scopes_test.go b/pkg/scopes/scopes_test.go index e9deda9953..190ab55668 100644 --- a/pkg/scopes/scopes_test.go +++ b/pkg/scopes/scopes_test.go @@ -13,6 +13,8 @@ func TestOAuthScopeCatalog(t *testing.T) { assert.Subset(t, supported, defaults) assert.Contains(t, supported, string(DeleteRepo)) assert.NotContains(t, defaults, string(DeleteRepo)) + assert.Contains(t, supported, string(DeletePackages)) + assert.NotContains(t, defaults, string(DeletePackages)) assert.Contains(t, supported, string(Workflow)) assert.NotContains(t, defaults, string(Workflow)) assert.Contains(t, supported, string(Codespace))