diff --git a/README.md b/README.md index c1f9857d1a..98043ec026 100644 --- a/README.md +++ b/README.md @@ -597,6 +597,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 | @@ -1165,6 +1166,52 @@ The following sets of tools are available:
+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'. Optional filters: 'package_type', '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). Optional filters: 'package_type', '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'. + + Download statistics are not available through the GitHub REST API. (string, required) + - `org`: Organization name. Required for org_* 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. Required for every method except list_org_packages and list_user_packages, where it is an optional filter. (string, optional) + - `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. (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. (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. (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..4d03ed8331 --- /dev/null +++ b/pkg/github/__toolsnaps__/packages_read.snap @@ -0,0 +1,87 @@ +{ + "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.", + "inputSchema": { + "properties": { + "method": { + "description": "The read operation to perform.\nOptions are:\n- 'list_org_packages' - list packages for an organization. Requires 'org'. Optional filters: 'package_type', '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). Optional filters: 'package_type', '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\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.", + "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. Required for every method except list_org_packages and list_user_packages, where it is an optional filter.", + "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.", + "type": "string" + }, + "visibility": { + "description": "Filter packages by visibility (list_org_packages and list_user_packages only).", + "enum": [ + "public", + "private", + "internal" + ], + "type": "string" + } + }, + "required": [ + "method" + ], + "type": "object" + }, + "name": "packages_read" +} \ 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..9b60a4de5b --- /dev/null +++ b/pkg/github/__toolsnaps__/packages_write.snap @@ -0,0 +1,58 @@ +{ + "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.", + "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.", + "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.", + "type": "string" + } + }, + "required": [ + "method", + "package_type", + "package_name" + ], + "type": "object" + }, + "name": "packages_write" +} \ No newline at end of file diff --git a/pkg/github/packages.go b/pkg/github/packages.go new file mode 100644 index 0000000000..98d2e7a470 --- /dev/null +++ b/pkg/github/packages.go @@ -0,0 +1,467 @@ +package github + +import ( + "context" + "fmt" + "io" + "net/http" + "net/url" + "strconv" + + ghErrors "github.com/github/github-mcp-server/pkg/errors" + "github.com/github/github-mcp-server/pkg/inventory" + "github.com/github/github-mcp-server/pkg/scopes" + "github.com/github/github-mcp-server/pkg/translations" + "github.com/github/github-mcp-server/pkg/utils" + "github.com/google/go-github/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"} + +// 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.", + }, + "username": { + Type: "string", + Description: "GitHub username for user_* methods. Omit to target the authenticated user.", + }, + "package_type": { + Type: "string", + Description: "Package type. Required for every method except list_org_packages and list_user_packages, where it is an optional filter.", + 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. +func requirePackageOwner(args map[string]any, isOrg bool) (string, error) { + if isOrg { + return RequiredParam[string](args, "org") + } + 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'. Optional filters: 'package_type', '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). Optional filters: 'package_type', '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'. + +Download statistics are not available through the GitHub REST API.`, + Enum: []any{ + "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", + }, + } + 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"}, + } + WithPagination(schema) + + return NewTool( + ToolsetMetadataPackages, + mcp.Tool{ + Name: "packages_read", + 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."), + 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, any, 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 "list_org_packages": + return listPackages(ctx, client, args, true) + case "list_user_packages": + return listPackages(ctx, client, args, false) + case "get_org_package": + return getPackage(ctx, client, args, true) + case "get_user_package": + return getPackage(ctx, client, args, false) + case "list_org_package_versions": + return listPackageVersions(ctx, client, args, true) + case "list_user_package_versions": + return listPackageVersions(ctx, client, args, false) + case "get_org_package_version": + return getPackageVersion(ctx, client, args, true) + case "get_user_package_version": + return getPackageVersion(ctx, client, args, 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, isOrg bool) (*mcp.CallToolResult, any, error) { + owner, err := requirePackageOwner(args, isOrg) + if err != nil { + return utils.NewToolResultError(err.Error()), nil, nil + } + packageType, err := OptionalParam[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: ToStringPtr(packageType), + Visibility: ToStringPtr(visibility), + ListOptions: github.ListOptions{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 + } + + return MarshalledTextResult(packages), nil, nil +} + +func getPackage(ctx context.Context, client *github.Client, args map[string]any, isOrg bool) (*mcp.CallToolResult, any, 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 + } + + return MarshalledTextResult(pkg), nil, nil +} + +func listPackageVersions(ctx context.Context, client *github.Client, args map[string]any, isOrg bool) (*mcp.CallToolResult, any, 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 + } + + return MarshalledTextResult(versions), nil, nil +} + +func getPackageVersion(ctx context.Context, client *github.Client, args map[string]any, isOrg bool) (*mcp.CallToolResult, any, 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 + } + + return MarshalledTextResult(version), nil, 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: []any{"delete_org_package", "delete_org_package_version", "delete_user_package", "delete_user_package_version"}, + } + properties["package_type"].Description = "Package type." + properties["package_name"].Description = "Package name." + + return NewTool( + ToolsetMetadataPackages, + mcp.Tool{ + Name: "packages_write", + Description: t("TOOL_PACKAGES_WRITE_DESCRIPTION", "Delete GitHub Packages or individual package versions owned by organizations and users. Requires the delete:packages scope."), + 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, any, error) { + method, err := RequiredParam[string](args, "method") + if err != nil { + return utils.NewToolResultError(err.Error()), nil, nil + } + + var isOrg, withVersion bool + switch method { + case "delete_org_package": + isOrg = true + case "delete_org_package_version": + isOrg, withVersion = true, true + case "delete_user_package": + case "delete_user_package_version": + 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, 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, target string) (*mcp.CallToolResult, any, 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 + } + + return MarshalledTextResult(map[string]any{ + "success": true, + "message": fmt.Sprintf("Deleted %s", target), + }), nil, nil +} diff --git a/pkg/github/packages_test.go b/pkg/github/packages_test.go new file mode 100644 index 0000000000..d95ed8cdb9 --- /dev/null +++ b/pkg/github/packages_test.go @@ -0,0 +1,454 @@ +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/internal/toolsnaps" + "github.com/github/github-mcp-server/pkg/translations" +) + +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"}) + assert.Contains(t, schema.Properties, "page") + assert.Contains(t, schema.Properties, "perPage") + + 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: mockPackages, + }, + { + name: "list_org_packages requires org", + handlers: map[string]http.HandlerFunc{}, + args: map[string]any{"method": "list_org_packages"}, + expectError: true, + expectedErrMsg: "missing required parameter: org", + }, + { + 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"}, + 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": mockResponse(t, http.StatusOK, mockPackages), + }, + args: map[string]any{"method": "list_user_packages"}, + expectedResult: 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: mockPackages, + }, + { + 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: 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: 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: 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: 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: 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: 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: mockVersion, + }, + { + name: "unknown method", + handlers: map[string]http.HandlerFunc{}, + args: map[string]any{"method": "invalid"}, + 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) + expected, err := json.Marshal(tc.expectedResult) + require.NoError(t, err) + assert.JSONEq(t, string(expected), getTextResult(t, result).Text) + }) + } +} + +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"}) + + 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 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_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: "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, true, response["success"]) + assert.Equal(t, tc.expectedMessage, response["message"]) + }) + } +} diff --git a/pkg/github/tools.go b/pkg/github/tools.go index e3177f668f..c56ec99115 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", @@ -345,6 +350,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 515a0a471f..dcd38855f1 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 2294ac01ef..391e10503c 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))