168e77b419
License Check / license-check (push) Has been cancelled
Build and Test Go Project / build (macos-latest) (push) Has been cancelled
Build and Test Go Project / build (ubuntu-latest) (push) Has been cancelled
Build and Test Go Project / build (windows-latest) (push) Has been cancelled
CodeQL / Analyze (go) (push) Has been cancelled
CodeQL / Analyze (actions) (push) Has been cancelled
The go-github v79 library expects `options.name` to be an object (`*ProjectV2TextContent`) but the GitHub API returns a simple string. This fix: - Creates custom types (FlexibleString, ProjectField, etc.) that can handle both string and object formats for option names - Implements custom UnmarshalJSON for FlexibleString to handle either format - Updates ListProjectFields and GetProjectField to use raw API requests with our custom types instead of the go-github library's types - Adds tests for both string and object name formats Co-authored-by: JoannaaKL <67866556+JoannaaKL@users.noreply.github.com>
1093 lines
35 KiB
Go
1093 lines
35 KiB
Go
package github
|
|
|
|
import (
|
|
"context"
|
|
"encoding/json"
|
|
"fmt"
|
|
"io"
|
|
"net/http"
|
|
"strings"
|
|
|
|
ghErrors "github.com/github/github-mcp-server/pkg/errors"
|
|
"github.com/github/github-mcp-server/pkg/translations"
|
|
"github.com/google/go-github/v79/github"
|
|
"github.com/mark3labs/mcp-go/mcp"
|
|
"github.com/mark3labs/mcp-go/server"
|
|
)
|
|
|
|
const (
|
|
ProjectUpdateFailedError = "failed to update a project item"
|
|
ProjectAddFailedError = "failed to add a project item"
|
|
ProjectDeleteFailedError = "failed to delete a project item"
|
|
ProjectListFailedError = "failed to list project items"
|
|
MaxProjectsPerPage = 50
|
|
)
|
|
|
|
// FlexibleString handles JSON unmarshaling of fields that can be either
|
|
// a plain string or an object with "raw" and "html" fields.
|
|
// This is needed because the GitHub API returns option names as strings,
|
|
// while go-github v79 expects them to be ProjectV2TextContent objects.
|
|
type FlexibleString struct {
|
|
Raw string `json:"raw,omitempty"`
|
|
HTML string `json:"html,omitempty"`
|
|
}
|
|
|
|
// UnmarshalJSON implements custom unmarshaling for FlexibleString
|
|
func (f *FlexibleString) UnmarshalJSON(data []byte) error {
|
|
// Try to unmarshal as a plain string first
|
|
var s string
|
|
if err := json.Unmarshal(data, &s); err == nil {
|
|
f.Raw = s
|
|
f.HTML = s
|
|
return nil
|
|
}
|
|
|
|
// If that fails, try to unmarshal as an object
|
|
type flexibleStringAlias FlexibleString
|
|
var obj flexibleStringAlias
|
|
if err := json.Unmarshal(data, &obj); err != nil {
|
|
return err
|
|
}
|
|
*f = FlexibleString(obj)
|
|
return nil
|
|
}
|
|
|
|
// ProjectFieldOption represents an option for single_select or iteration fields.
|
|
// This is a custom type that handles the flexible name format from the GitHub API.
|
|
type ProjectFieldOption struct {
|
|
ID string `json:"id,omitempty"`
|
|
Name *FlexibleString `json:"name,omitempty"`
|
|
Color string `json:"color,omitempty"`
|
|
Description *FlexibleString `json:"description,omitempty"`
|
|
}
|
|
|
|
// ProjectFieldIteration represents an iteration within a project field.
|
|
type ProjectFieldIteration struct {
|
|
ID string `json:"id,omitempty"`
|
|
Title *FlexibleString `json:"title,omitempty"`
|
|
StartDate string `json:"start_date,omitempty"`
|
|
Duration int `json:"duration,omitempty"`
|
|
}
|
|
|
|
// ProjectFieldConfiguration represents the configuration for iteration fields.
|
|
type ProjectFieldConfiguration struct {
|
|
Duration int `json:"duration,omitempty"`
|
|
StartDay int `json:"start_day,omitempty"`
|
|
Iterations []*ProjectFieldIteration `json:"iterations,omitempty"`
|
|
}
|
|
|
|
// ProjectField represents a field in a GitHub Project V2.
|
|
// This is a custom type that properly handles the options array format from the GitHub API.
|
|
type ProjectField struct {
|
|
ID int64 `json:"id,omitempty"`
|
|
NodeID string `json:"node_id,omitempty"`
|
|
Name string `json:"name,omitempty"`
|
|
DataType string `json:"data_type,omitempty"`
|
|
ProjectURL string `json:"project_url,omitempty"`
|
|
Options []*ProjectFieldOption `json:"options,omitempty"`
|
|
Configuration *ProjectFieldConfiguration `json:"configuration,omitempty"`
|
|
CreatedAt string `json:"created_at,omitempty"`
|
|
UpdatedAt string `json:"updated_at,omitempty"`
|
|
}
|
|
|
|
func ListProjects(getClient GetClientFn, t translations.TranslationHelperFunc) (tool mcp.Tool, handler server.ToolHandlerFunc) {
|
|
return mcp.NewTool("list_projects",
|
|
mcp.WithDescription(t("TOOL_LIST_PROJECTS_DESCRIPTION", `List Projects for a user or organization`)),
|
|
mcp.WithToolAnnotation(mcp.ToolAnnotation{
|
|
Title: t("TOOL_LIST_PROJECTS_USER_TITLE", "List projects"),
|
|
ReadOnlyHint: ToBoolPtr(true),
|
|
}),
|
|
mcp.WithString("owner_type",
|
|
mcp.Required(), mcp.Description("Owner type"), mcp.Enum("user", "org"),
|
|
),
|
|
mcp.WithString("owner",
|
|
mcp.Required(),
|
|
mcp.Description("If owner_type == user it is the handle for the GitHub user account. If owner_type == org it is the name of the organization. The name is not case sensitive."),
|
|
),
|
|
mcp.WithString("query",
|
|
mcp.Description(`Filter projects by title text and open/closed state; permitted qualifiers: is:open, is:closed; examples: "roadmap is:open", "is:open feature planning".`),
|
|
),
|
|
mcp.WithNumber("per_page",
|
|
mcp.Description(fmt.Sprintf("Results per page (max %d)", MaxProjectsPerPage)),
|
|
),
|
|
mcp.WithString("after",
|
|
mcp.Description("Forward pagination cursor from previous pageInfo.nextCursor."),
|
|
),
|
|
mcp.WithString("before",
|
|
mcp.Description("Backward pagination cursor from previous pageInfo.prevCursor (rare)."),
|
|
),
|
|
), func(ctx context.Context, req mcp.CallToolRequest) (*mcp.CallToolResult, error) {
|
|
owner, err := RequiredParam[string](req, "owner")
|
|
if err != nil {
|
|
return mcp.NewToolResultError(err.Error()), nil
|
|
}
|
|
|
|
ownerType, err := RequiredParam[string](req, "owner_type")
|
|
if err != nil {
|
|
return mcp.NewToolResultError(err.Error()), nil
|
|
}
|
|
|
|
queryStr, err := OptionalParam[string](req, "query")
|
|
if err != nil {
|
|
return mcp.NewToolResultError(err.Error()), nil
|
|
}
|
|
|
|
pagination, err := extractPaginationOptions(req)
|
|
if err != nil {
|
|
return mcp.NewToolResultError(err.Error()), nil
|
|
}
|
|
|
|
client, err := getClient(ctx)
|
|
if err != nil {
|
|
return mcp.NewToolResultError(err.Error()), nil
|
|
}
|
|
|
|
var resp *github.Response
|
|
var projects []*github.ProjectV2
|
|
var queryPtr *string
|
|
|
|
if queryStr != "" {
|
|
queryPtr = &queryStr
|
|
}
|
|
|
|
minimalProjects := []MinimalProject{}
|
|
opts := &github.ListProjectsOptions{
|
|
ListProjectsPaginationOptions: pagination,
|
|
Query: queryPtr,
|
|
}
|
|
|
|
if ownerType == "org" {
|
|
projects, resp, err = client.Projects.ListOrganizationProjects(ctx, owner, opts)
|
|
} else {
|
|
projects, resp, err = client.Projects.ListUserProjects(ctx, owner, opts)
|
|
}
|
|
|
|
if err != nil {
|
|
return ghErrors.NewGitHubAPIErrorResponse(ctx,
|
|
"failed to list projects",
|
|
resp,
|
|
err,
|
|
), nil
|
|
}
|
|
defer func() { _ = resp.Body.Close() }()
|
|
|
|
for _, project := range projects {
|
|
minimalProjects = append(minimalProjects, *convertToMinimalProject(project))
|
|
}
|
|
|
|
response := map[string]any{
|
|
"projects": minimalProjects,
|
|
"pageInfo": buildPageInfo(resp),
|
|
}
|
|
|
|
r, err := json.Marshal(response)
|
|
if err != nil {
|
|
return nil, fmt.Errorf("failed to marshal response: %w", err)
|
|
}
|
|
|
|
return mcp.NewToolResultText(string(r)), nil
|
|
}
|
|
}
|
|
|
|
func GetProject(getClient GetClientFn, t translations.TranslationHelperFunc) (tool mcp.Tool, handler server.ToolHandlerFunc) {
|
|
return mcp.NewTool("get_project",
|
|
mcp.WithDescription(t("TOOL_GET_PROJECT_DESCRIPTION", "Get Project for a user or org")),
|
|
mcp.WithToolAnnotation(mcp.ToolAnnotation{
|
|
Title: t("TOOL_GET_PROJECT_USER_TITLE", "Get project"),
|
|
ReadOnlyHint: ToBoolPtr(true),
|
|
}),
|
|
mcp.WithNumber("project_number",
|
|
mcp.Required(),
|
|
mcp.Description("The project's number"),
|
|
),
|
|
mcp.WithString("owner_type",
|
|
mcp.Required(),
|
|
mcp.Description("Owner type"),
|
|
mcp.Enum("user", "org"),
|
|
),
|
|
mcp.WithString("owner",
|
|
mcp.Required(),
|
|
mcp.Description("If owner_type == user it is the handle for the GitHub user account. If owner_type == org it is the name of the organization. The name is not case sensitive."),
|
|
),
|
|
), func(ctx context.Context, req mcp.CallToolRequest) (*mcp.CallToolResult, error) {
|
|
|
|
projectNumber, err := RequiredInt(req, "project_number")
|
|
if err != nil {
|
|
return mcp.NewToolResultError(err.Error()), nil
|
|
}
|
|
|
|
owner, err := RequiredParam[string](req, "owner")
|
|
if err != nil {
|
|
return mcp.NewToolResultError(err.Error()), nil
|
|
}
|
|
|
|
ownerType, err := RequiredParam[string](req, "owner_type")
|
|
if err != nil {
|
|
return mcp.NewToolResultError(err.Error()), nil
|
|
}
|
|
|
|
client, err := getClient(ctx)
|
|
if err != nil {
|
|
return mcp.NewToolResultError(err.Error()), nil
|
|
}
|
|
|
|
var resp *github.Response
|
|
var project *github.ProjectV2
|
|
|
|
if ownerType == "org" {
|
|
project, resp, err = client.Projects.GetOrganizationProject(ctx, owner, projectNumber)
|
|
} else {
|
|
project, resp, err = client.Projects.GetUserProject(ctx, owner, projectNumber)
|
|
}
|
|
if err != nil {
|
|
return ghErrors.NewGitHubAPIErrorResponse(ctx,
|
|
"failed to get project",
|
|
resp,
|
|
err,
|
|
), nil
|
|
}
|
|
defer func() { _ = resp.Body.Close() }()
|
|
|
|
if resp.StatusCode != http.StatusOK {
|
|
body, err := io.ReadAll(resp.Body)
|
|
if err != nil {
|
|
return nil, fmt.Errorf("failed to read response body: %w", err)
|
|
}
|
|
return mcp.NewToolResultError(fmt.Sprintf("failed to get project: %s", string(body))), nil
|
|
}
|
|
|
|
minimalProject := convertToMinimalProject(project)
|
|
r, err := json.Marshal(minimalProject)
|
|
if err != nil {
|
|
return nil, fmt.Errorf("failed to marshal response: %w", err)
|
|
}
|
|
|
|
return mcp.NewToolResultText(string(r)), nil
|
|
}
|
|
}
|
|
|
|
func ListProjectFields(getClient GetClientFn, t translations.TranslationHelperFunc) (tool mcp.Tool, handler server.ToolHandlerFunc) {
|
|
return mcp.NewTool("list_project_fields",
|
|
mcp.WithDescription(t("TOOL_LIST_PROJECT_FIELDS_DESCRIPTION", "List Project fields for a user or org")),
|
|
mcp.WithToolAnnotation(mcp.ToolAnnotation{
|
|
Title: t("TOOL_LIST_PROJECT_FIELDS_USER_TITLE", "List project fields"),
|
|
ReadOnlyHint: ToBoolPtr(true),
|
|
}),
|
|
mcp.WithString("owner_type",
|
|
mcp.Required(),
|
|
mcp.Description("Owner type"),
|
|
mcp.Enum("user", "org")),
|
|
mcp.WithString("owner",
|
|
mcp.Required(),
|
|
mcp.Description("If owner_type == user it is the handle for the GitHub user account. If owner_type == org it is the name of the organization. The name is not case sensitive."),
|
|
),
|
|
mcp.WithNumber("project_number",
|
|
mcp.Required(),
|
|
mcp.Description("The project's number."),
|
|
),
|
|
mcp.WithNumber("per_page",
|
|
mcp.Description(fmt.Sprintf("Results per page (max %d)", MaxProjectsPerPage)),
|
|
),
|
|
mcp.WithString("after",
|
|
mcp.Description("Forward pagination cursor from previous pageInfo.nextCursor."),
|
|
),
|
|
mcp.WithString("before",
|
|
mcp.Description("Backward pagination cursor from previous pageInfo.prevCursor (rare)."),
|
|
),
|
|
), func(ctx context.Context, req mcp.CallToolRequest) (*mcp.CallToolResult, error) {
|
|
owner, err := RequiredParam[string](req, "owner")
|
|
if err != nil {
|
|
return mcp.NewToolResultError(err.Error()), nil
|
|
}
|
|
|
|
ownerType, err := RequiredParam[string](req, "owner_type")
|
|
if err != nil {
|
|
return mcp.NewToolResultError(err.Error()), nil
|
|
}
|
|
|
|
projectNumber, err := RequiredInt(req, "project_number")
|
|
if err != nil {
|
|
return mcp.NewToolResultError(err.Error()), nil
|
|
}
|
|
|
|
pagination, err := extractPaginationOptions(req)
|
|
if err != nil {
|
|
return mcp.NewToolResultError(err.Error()), nil
|
|
}
|
|
|
|
client, err := getClient(ctx)
|
|
if err != nil {
|
|
return mcp.NewToolResultError(err.Error()), nil
|
|
}
|
|
|
|
// Build the URL for the API request
|
|
var urlPath string
|
|
if ownerType == "org" {
|
|
urlPath = fmt.Sprintf("orgs/%s/projectsV2/%d/fields", owner, projectNumber)
|
|
} else {
|
|
urlPath = fmt.Sprintf("users/%s/projectsV2/%d/fields", owner, projectNumber)
|
|
}
|
|
|
|
// Create options for the request
|
|
opts := &github.ListProjectsOptions{
|
|
ListProjectsPaginationOptions: pagination,
|
|
}
|
|
|
|
// Make the raw API request using go-github's client
|
|
// We use our custom ProjectField type which handles flexible name format
|
|
projectFields, resp, err := listProjectFieldsRaw(ctx, client, urlPath, opts)
|
|
if err != nil {
|
|
return ghErrors.NewGitHubAPIErrorResponse(ctx,
|
|
"failed to list project fields",
|
|
resp,
|
|
err,
|
|
), nil
|
|
}
|
|
defer func() { _ = resp.Body.Close() }()
|
|
|
|
response := map[string]any{
|
|
"fields": projectFields,
|
|
"pageInfo": buildPageInfo(resp),
|
|
}
|
|
|
|
r, err := json.Marshal(response)
|
|
if err != nil {
|
|
return nil, fmt.Errorf("failed to marshal response: %w", err)
|
|
}
|
|
|
|
return mcp.NewToolResultText(string(r)), nil
|
|
}
|
|
}
|
|
|
|
// listProjectFieldsRaw makes a raw API request to list project fields and parses
|
|
// the response using our custom ProjectField type that handles flexible name formats.
|
|
func listProjectFieldsRaw(ctx context.Context, client *github.Client, urlPath string, opts *github.ListProjectsOptions) ([]*ProjectField, *github.Response, error) {
|
|
u, err := addProjectOptions(urlPath, opts)
|
|
if err != nil {
|
|
return nil, nil, err
|
|
}
|
|
|
|
req, err := client.NewRequest("GET", u, nil)
|
|
if err != nil {
|
|
return nil, nil, err
|
|
}
|
|
|
|
var fields []*ProjectField
|
|
resp, err := client.Do(ctx, req, &fields)
|
|
if err != nil {
|
|
return nil, resp, err
|
|
}
|
|
return fields, resp, nil
|
|
}
|
|
|
|
// addProjectOptions adds query parameters to a URL for project API requests.
|
|
func addProjectOptions(s string, opts *github.ListProjectsOptions) (string, error) {
|
|
if opts == nil {
|
|
return s, nil
|
|
}
|
|
|
|
// Build query parameters manually
|
|
params := make([]string, 0)
|
|
if opts.PerPage != nil && *opts.PerPage > 0 {
|
|
params = append(params, fmt.Sprintf("per_page=%d", *opts.PerPage))
|
|
}
|
|
if opts.After != nil && *opts.After != "" {
|
|
params = append(params, fmt.Sprintf("after=%s", *opts.After))
|
|
}
|
|
if opts.Before != nil && *opts.Before != "" {
|
|
params = append(params, fmt.Sprintf("before=%s", *opts.Before))
|
|
}
|
|
if opts.Query != nil && *opts.Query != "" {
|
|
params = append(params, fmt.Sprintf("q=%s", *opts.Query))
|
|
}
|
|
|
|
if len(params) > 0 {
|
|
s = s + "?" + strings.Join(params, "&")
|
|
}
|
|
return s, nil
|
|
}
|
|
|
|
// getProjectFieldRaw makes a raw API request to get a single project field and parses
|
|
// the response using our custom ProjectField type that handles flexible name formats.
|
|
func getProjectFieldRaw(ctx context.Context, client *github.Client, urlPath string) (*ProjectField, *github.Response, error) {
|
|
req, err := client.NewRequest("GET", urlPath, nil)
|
|
if err != nil {
|
|
return nil, nil, err
|
|
}
|
|
|
|
var field ProjectField
|
|
resp, err := client.Do(ctx, req, &field)
|
|
if err != nil {
|
|
return nil, resp, err
|
|
}
|
|
return &field, resp, nil
|
|
}
|
|
|
|
func GetProjectField(getClient GetClientFn, t translations.TranslationHelperFunc) (tool mcp.Tool, handler server.ToolHandlerFunc) {
|
|
return mcp.NewTool("get_project_field",
|
|
mcp.WithDescription(t("TOOL_GET_PROJECT_FIELD_DESCRIPTION", "Get Project field for a user or org")),
|
|
mcp.WithToolAnnotation(mcp.ToolAnnotation{
|
|
Title: t("TOOL_GET_PROJECT_FIELD_USER_TITLE", "Get project field"),
|
|
ReadOnlyHint: ToBoolPtr(true),
|
|
}),
|
|
mcp.WithString("owner_type",
|
|
mcp.Required(),
|
|
mcp.Description("Owner type"), mcp.Enum("user", "org")),
|
|
mcp.WithString("owner",
|
|
mcp.Required(),
|
|
mcp.Description("If owner_type == user it is the handle for the GitHub user account. If owner_type == org it is the name of the organization. The name is not case sensitive."),
|
|
),
|
|
mcp.WithNumber("project_number",
|
|
mcp.Required(),
|
|
mcp.Description("The project's number.")),
|
|
mcp.WithNumber("field_id",
|
|
mcp.Required(),
|
|
mcp.Description("The field's id."),
|
|
),
|
|
), func(ctx context.Context, req mcp.CallToolRequest) (*mcp.CallToolResult, error) {
|
|
owner, err := RequiredParam[string](req, "owner")
|
|
if err != nil {
|
|
return mcp.NewToolResultError(err.Error()), nil
|
|
}
|
|
ownerType, err := RequiredParam[string](req, "owner_type")
|
|
if err != nil {
|
|
return mcp.NewToolResultError(err.Error()), nil
|
|
}
|
|
projectNumber, err := RequiredInt(req, "project_number")
|
|
if err != nil {
|
|
return mcp.NewToolResultError(err.Error()), nil
|
|
}
|
|
fieldID, err := RequiredBigInt(req, "field_id")
|
|
if err != nil {
|
|
return mcp.NewToolResultError(err.Error()), nil
|
|
}
|
|
client, err := getClient(ctx)
|
|
if err != nil {
|
|
return mcp.NewToolResultError(err.Error()), nil
|
|
}
|
|
|
|
// Build the URL for the API request
|
|
var urlPath string
|
|
if ownerType == "org" {
|
|
urlPath = fmt.Sprintf("orgs/%s/projectsV2/%d/fields/%d", owner, projectNumber, fieldID)
|
|
} else {
|
|
urlPath = fmt.Sprintf("users/%s/projectsV2/%d/fields/%d", owner, projectNumber, fieldID)
|
|
}
|
|
|
|
// Make the raw API request using go-github's client
|
|
// We use our custom ProjectField type which handles flexible name format
|
|
projectField, resp, err := getProjectFieldRaw(ctx, client, urlPath)
|
|
if err != nil {
|
|
return ghErrors.NewGitHubAPIErrorResponse(ctx,
|
|
"failed to get project field",
|
|
resp,
|
|
err,
|
|
), nil
|
|
}
|
|
defer func() { _ = resp.Body.Close() }()
|
|
|
|
if resp.StatusCode != http.StatusOK {
|
|
body, err := io.ReadAll(resp.Body)
|
|
if err != nil {
|
|
return nil, fmt.Errorf("failed to read response body: %w", err)
|
|
}
|
|
return mcp.NewToolResultError(fmt.Sprintf("failed to get project field: %s", string(body))), nil
|
|
}
|
|
r, err := json.Marshal(projectField)
|
|
if err != nil {
|
|
return nil, fmt.Errorf("failed to marshal response: %w", err)
|
|
}
|
|
|
|
return mcp.NewToolResultText(string(r)), nil
|
|
}
|
|
}
|
|
|
|
func ListProjectItems(getClient GetClientFn, t translations.TranslationHelperFunc) (tool mcp.Tool, handler server.ToolHandlerFunc) {
|
|
return mcp.NewTool("list_project_items",
|
|
mcp.WithDescription(t("TOOL_LIST_PROJECT_ITEMS_DESCRIPTION", `Search project items with advanced filtering`)),
|
|
mcp.WithToolAnnotation(mcp.ToolAnnotation{
|
|
Title: t("TOOL_LIST_PROJECT_ITEMS_USER_TITLE", "List project items"),
|
|
ReadOnlyHint: ToBoolPtr(true),
|
|
}),
|
|
mcp.WithString("owner_type",
|
|
mcp.Required(),
|
|
mcp.Description("Owner type"),
|
|
mcp.Enum("user", "org"),
|
|
),
|
|
mcp.WithString("owner",
|
|
mcp.Required(),
|
|
mcp.Description("If owner_type == user it is the handle for the GitHub user account. If owner_type == org it is the name of the organization. The name is not case sensitive."),
|
|
),
|
|
mcp.WithNumber("project_number", mcp.Required(),
|
|
mcp.Description("The project's number."),
|
|
),
|
|
mcp.WithString("query",
|
|
mcp.Description(`Query string for advanced filtering of project items using GitHub's project filtering syntax.`),
|
|
),
|
|
mcp.WithNumber("per_page",
|
|
mcp.Description(fmt.Sprintf("Results per page (max %d)", MaxProjectsPerPage)),
|
|
),
|
|
mcp.WithString("after",
|
|
mcp.Description("Forward pagination cursor from previous pageInfo.nextCursor."),
|
|
),
|
|
mcp.WithString("before",
|
|
mcp.Description("Backward pagination cursor from previous pageInfo.prevCursor (rare)."),
|
|
),
|
|
mcp.WithArray("fields",
|
|
mcp.Description("Field IDs to include (e.g. [\"102589\", \"985201\"]). CRITICAL: Always provide to get field values. Without this, only titles returned."),
|
|
mcp.WithStringItems(),
|
|
),
|
|
), func(ctx context.Context, req mcp.CallToolRequest) (*mcp.CallToolResult, error) {
|
|
owner, err := RequiredParam[string](req, "owner")
|
|
if err != nil {
|
|
return mcp.NewToolResultError(err.Error()), nil
|
|
}
|
|
|
|
ownerType, err := RequiredParam[string](req, "owner_type")
|
|
if err != nil {
|
|
return mcp.NewToolResultError(err.Error()), nil
|
|
}
|
|
|
|
projectNumber, err := RequiredInt(req, "project_number")
|
|
if err != nil {
|
|
return mcp.NewToolResultError(err.Error()), nil
|
|
}
|
|
|
|
queryStr, err := OptionalParam[string](req, "query")
|
|
if err != nil {
|
|
return mcp.NewToolResultError(err.Error()), nil
|
|
}
|
|
|
|
fields, err := OptionalBigIntArrayParam(req, "fields")
|
|
if err != nil {
|
|
return mcp.NewToolResultError(err.Error()), nil
|
|
}
|
|
|
|
pagination, err := extractPaginationOptions(req)
|
|
if err != nil {
|
|
return mcp.NewToolResultError(err.Error()), nil
|
|
}
|
|
|
|
client, err := getClient(ctx)
|
|
if err != nil {
|
|
return mcp.NewToolResultError(err.Error()), nil
|
|
}
|
|
|
|
var resp *github.Response
|
|
var projectItems []*github.ProjectV2Item
|
|
var queryPtr *string
|
|
|
|
if queryStr != "" {
|
|
queryPtr = &queryStr
|
|
}
|
|
|
|
opts := &github.ListProjectItemsOptions{
|
|
Fields: fields,
|
|
ListProjectsOptions: github.ListProjectsOptions{
|
|
ListProjectsPaginationOptions: pagination,
|
|
Query: queryPtr,
|
|
},
|
|
}
|
|
|
|
if ownerType == "org" {
|
|
projectItems, resp, err = client.Projects.ListOrganizationProjectItems(ctx, owner, projectNumber, opts)
|
|
} else {
|
|
projectItems, resp, err = client.Projects.ListUserProjectItems(ctx, owner, projectNumber, opts)
|
|
}
|
|
|
|
if err != nil {
|
|
return ghErrors.NewGitHubAPIErrorResponse(ctx,
|
|
ProjectListFailedError,
|
|
resp,
|
|
err,
|
|
), nil
|
|
}
|
|
defer func() { _ = resp.Body.Close() }()
|
|
|
|
response := map[string]any{
|
|
"items": projectItems,
|
|
"pageInfo": buildPageInfo(resp),
|
|
}
|
|
|
|
r, err := json.Marshal(response)
|
|
if err != nil {
|
|
return nil, fmt.Errorf("failed to marshal response: %w", err)
|
|
}
|
|
|
|
return mcp.NewToolResultText(string(r)), nil
|
|
}
|
|
}
|
|
|
|
func GetProjectItem(getClient GetClientFn, t translations.TranslationHelperFunc) (tool mcp.Tool, handler server.ToolHandlerFunc) {
|
|
return mcp.NewTool("get_project_item",
|
|
mcp.WithDescription(t("TOOL_GET_PROJECT_ITEM_DESCRIPTION", "Get a specific Project item for a user or org")),
|
|
mcp.WithToolAnnotation(mcp.ToolAnnotation{
|
|
Title: t("TOOL_GET_PROJECT_ITEM_USER_TITLE", "Get project item"),
|
|
ReadOnlyHint: ToBoolPtr(true),
|
|
}),
|
|
mcp.WithString("owner_type",
|
|
mcp.Required(),
|
|
mcp.Description("Owner type"),
|
|
mcp.Enum("user", "org"),
|
|
),
|
|
mcp.WithString("owner",
|
|
mcp.Required(),
|
|
mcp.Description("If owner_type == user it is the handle for the GitHub user account. If owner_type == org it is the name of the organization. The name is not case sensitive."),
|
|
),
|
|
mcp.WithNumber("project_number",
|
|
mcp.Required(),
|
|
mcp.Description("The project's number."),
|
|
),
|
|
mcp.WithNumber("item_id",
|
|
mcp.Required(),
|
|
mcp.Description("The item's ID."),
|
|
),
|
|
mcp.WithArray("fields",
|
|
mcp.Description("Specific list of field IDs to include in the response (e.g. [\"102589\", \"985201\", \"169875\"]). If not provided, only the title field is included."),
|
|
mcp.WithStringItems(),
|
|
),
|
|
), func(ctx context.Context, req mcp.CallToolRequest) (*mcp.CallToolResult, error) {
|
|
owner, err := RequiredParam[string](req, "owner")
|
|
if err != nil {
|
|
return mcp.NewToolResultError(err.Error()), nil
|
|
}
|
|
|
|
ownerType, err := RequiredParam[string](req, "owner_type")
|
|
if err != nil {
|
|
return mcp.NewToolResultError(err.Error()), nil
|
|
}
|
|
|
|
projectNumber, err := RequiredInt(req, "project_number")
|
|
if err != nil {
|
|
return mcp.NewToolResultError(err.Error()), nil
|
|
}
|
|
itemID, err := RequiredBigInt(req, "item_id")
|
|
if err != nil {
|
|
return mcp.NewToolResultError(err.Error()), nil
|
|
}
|
|
fields, err := OptionalBigIntArrayParam(req, "fields")
|
|
if err != nil {
|
|
return mcp.NewToolResultError(err.Error()), nil
|
|
}
|
|
|
|
client, err := getClient(ctx)
|
|
if err != nil {
|
|
return mcp.NewToolResultError(err.Error()), nil
|
|
}
|
|
|
|
var resp *github.Response
|
|
var projectItem *github.ProjectV2Item
|
|
var opts *github.GetProjectItemOptions
|
|
|
|
if len(fields) > 0 {
|
|
opts = &github.GetProjectItemOptions{
|
|
Fields: fields,
|
|
}
|
|
}
|
|
|
|
if ownerType == "org" {
|
|
projectItem, resp, err = client.Projects.GetOrganizationProjectItem(ctx, owner, projectNumber, itemID, opts)
|
|
} else {
|
|
projectItem, resp, err = client.Projects.GetUserProjectItem(ctx, owner, projectNumber, itemID, opts)
|
|
}
|
|
|
|
if err != nil {
|
|
return ghErrors.NewGitHubAPIErrorResponse(ctx,
|
|
"failed to get project item",
|
|
resp,
|
|
err,
|
|
), nil
|
|
}
|
|
defer func() { _ = resp.Body.Close() }()
|
|
|
|
r, err := json.Marshal(projectItem)
|
|
if err != nil {
|
|
return nil, fmt.Errorf("failed to marshal response: %w", err)
|
|
}
|
|
|
|
return mcp.NewToolResultText(string(r)), nil
|
|
}
|
|
}
|
|
|
|
func AddProjectItem(getClient GetClientFn, t translations.TranslationHelperFunc) (tool mcp.Tool, handler server.ToolHandlerFunc) {
|
|
return mcp.NewTool("add_project_item",
|
|
mcp.WithDescription(t("TOOL_ADD_PROJECT_ITEM_DESCRIPTION", "Add a specific Project item for a user or org")),
|
|
mcp.WithToolAnnotation(mcp.ToolAnnotation{
|
|
Title: t("TOOL_ADD_PROJECT_ITEM_USER_TITLE", "Add project item"),
|
|
ReadOnlyHint: ToBoolPtr(false),
|
|
}),
|
|
mcp.WithString("owner_type",
|
|
mcp.Required(),
|
|
mcp.Description("Owner type"), mcp.Enum("user", "org"),
|
|
),
|
|
mcp.WithString("owner",
|
|
mcp.Required(),
|
|
mcp.Description("If owner_type == user it is the handle for the GitHub user account. If owner_type == org it is the name of the organization. The name is not case sensitive."),
|
|
),
|
|
mcp.WithNumber("project_number",
|
|
mcp.Required(),
|
|
mcp.Description("The project's number."),
|
|
),
|
|
mcp.WithString("item_type",
|
|
mcp.Required(),
|
|
mcp.Description("The item's type, either issue or pull_request."),
|
|
mcp.Enum("issue", "pull_request"),
|
|
),
|
|
mcp.WithNumber("item_id",
|
|
mcp.Required(),
|
|
mcp.Description("The numeric ID of the issue or pull request to add to the project."),
|
|
),
|
|
), func(ctx context.Context, req mcp.CallToolRequest) (*mcp.CallToolResult, error) {
|
|
owner, err := RequiredParam[string](req, "owner")
|
|
if err != nil {
|
|
return mcp.NewToolResultError(err.Error()), nil
|
|
}
|
|
ownerType, err := RequiredParam[string](req, "owner_type")
|
|
if err != nil {
|
|
return mcp.NewToolResultError(err.Error()), nil
|
|
}
|
|
projectNumber, err := RequiredInt(req, "project_number")
|
|
if err != nil {
|
|
return mcp.NewToolResultError(err.Error()), nil
|
|
}
|
|
itemID, err := RequiredBigInt(req, "item_id")
|
|
if err != nil {
|
|
return mcp.NewToolResultError(err.Error()), nil
|
|
}
|
|
|
|
itemType, err := RequiredParam[string](req, "item_type")
|
|
if err != nil {
|
|
return mcp.NewToolResultError(err.Error()), nil
|
|
}
|
|
if itemType != "issue" && itemType != "pull_request" {
|
|
return mcp.NewToolResultError("item_type must be either 'issue' or 'pull_request'"), nil
|
|
}
|
|
|
|
client, err := getClient(ctx)
|
|
if err != nil {
|
|
return mcp.NewToolResultError(err.Error()), nil
|
|
}
|
|
|
|
newItem := &github.AddProjectItemOptions{
|
|
ID: itemID,
|
|
Type: toNewProjectType(itemType),
|
|
}
|
|
|
|
var resp *github.Response
|
|
var addedItem *github.ProjectV2Item
|
|
|
|
if ownerType == "org" {
|
|
addedItem, resp, err = client.Projects.AddOrganizationProjectItem(ctx, owner, projectNumber, newItem)
|
|
} else {
|
|
addedItem, resp, err = client.Projects.AddUserProjectItem(ctx, owner, projectNumber, newItem)
|
|
}
|
|
|
|
if err != nil {
|
|
return ghErrors.NewGitHubAPIErrorResponse(ctx,
|
|
ProjectAddFailedError,
|
|
resp,
|
|
err,
|
|
), nil
|
|
}
|
|
defer func() { _ = resp.Body.Close() }()
|
|
|
|
if resp.StatusCode != http.StatusCreated {
|
|
body, err := io.ReadAll(resp.Body)
|
|
if err != nil {
|
|
return nil, fmt.Errorf("failed to read response body: %w", err)
|
|
}
|
|
return mcp.NewToolResultError(fmt.Sprintf("%s: %s", ProjectAddFailedError, string(body))), nil
|
|
}
|
|
r, err := json.Marshal(addedItem)
|
|
if err != nil {
|
|
return nil, fmt.Errorf("failed to marshal response: %w", err)
|
|
}
|
|
|
|
return mcp.NewToolResultText(string(r)), nil
|
|
}
|
|
}
|
|
|
|
func UpdateProjectItem(getClient GetClientFn, t translations.TranslationHelperFunc) (tool mcp.Tool, handler server.ToolHandlerFunc) {
|
|
return mcp.NewTool("update_project_item",
|
|
mcp.WithDescription(t("TOOL_UPDATE_PROJECT_ITEM_DESCRIPTION", "Update a specific Project item for a user or org")),
|
|
mcp.WithToolAnnotation(mcp.ToolAnnotation{
|
|
Title: t("TOOL_UPDATE_PROJECT_ITEM_USER_TITLE", "Update project item"),
|
|
ReadOnlyHint: ToBoolPtr(false),
|
|
}),
|
|
mcp.WithString("owner_type",
|
|
mcp.Required(), mcp.Description("Owner type"),
|
|
mcp.Enum("user", "org"),
|
|
),
|
|
mcp.WithString("owner",
|
|
mcp.Required(),
|
|
mcp.Description("If owner_type == user it is the handle for the GitHub user account. If owner_type == org it is the name of the organization. The name is not case sensitive."),
|
|
),
|
|
mcp.WithNumber("project_number",
|
|
mcp.Required(),
|
|
mcp.Description("The project's number."),
|
|
),
|
|
mcp.WithNumber("item_id",
|
|
mcp.Required(),
|
|
mcp.Description("The unique identifier of the project item. This is not the issue or pull request ID."),
|
|
),
|
|
mcp.WithObject("updated_field",
|
|
mcp.Required(),
|
|
mcp.Description("Object consisting of the ID of the project field to update and the new value for the field. To clear the field, set value to null. Example: {\"id\": 123456, \"value\": \"New Value\"}"),
|
|
),
|
|
), func(ctx context.Context, req mcp.CallToolRequest) (*mcp.CallToolResult, error) {
|
|
owner, err := RequiredParam[string](req, "owner")
|
|
if err != nil {
|
|
return mcp.NewToolResultError(err.Error()), nil
|
|
}
|
|
ownerType, err := RequiredParam[string](req, "owner_type")
|
|
if err != nil {
|
|
return mcp.NewToolResultError(err.Error()), nil
|
|
}
|
|
projectNumber, err := RequiredInt(req, "project_number")
|
|
if err != nil {
|
|
return mcp.NewToolResultError(err.Error()), nil
|
|
}
|
|
itemID, err := RequiredBigInt(req, "item_id")
|
|
if err != nil {
|
|
return mcp.NewToolResultError(err.Error()), nil
|
|
}
|
|
|
|
rawUpdatedField, exists := req.GetArguments()["updated_field"]
|
|
if !exists {
|
|
return mcp.NewToolResultError("missing required parameter: updated_field"), nil
|
|
}
|
|
|
|
fieldValue, ok := rawUpdatedField.(map[string]any)
|
|
if !ok || fieldValue == nil {
|
|
return mcp.NewToolResultError("field_value must be an object"), nil
|
|
}
|
|
|
|
updatePayload, err := buildUpdateProjectItem(fieldValue)
|
|
if err != nil {
|
|
return mcp.NewToolResultError(err.Error()), nil
|
|
}
|
|
|
|
client, err := getClient(ctx)
|
|
if err != nil {
|
|
return mcp.NewToolResultError(err.Error()), nil
|
|
}
|
|
|
|
var resp *github.Response
|
|
var updatedItem *github.ProjectV2Item
|
|
|
|
if ownerType == "org" {
|
|
updatedItem, resp, err = client.Projects.UpdateOrganizationProjectItem(ctx, owner, projectNumber, itemID, updatePayload)
|
|
} else {
|
|
updatedItem, resp, err = client.Projects.UpdateUserProjectItem(ctx, owner, projectNumber, itemID, updatePayload)
|
|
}
|
|
|
|
if err != nil {
|
|
return ghErrors.NewGitHubAPIErrorResponse(ctx,
|
|
ProjectUpdateFailedError,
|
|
resp,
|
|
err,
|
|
), nil
|
|
}
|
|
defer func() { _ = resp.Body.Close() }()
|
|
|
|
if resp.StatusCode != http.StatusOK {
|
|
body, err := io.ReadAll(resp.Body)
|
|
if err != nil {
|
|
return nil, fmt.Errorf("failed to read response body: %w", err)
|
|
}
|
|
return mcp.NewToolResultError(fmt.Sprintf("%s: %s", ProjectUpdateFailedError, string(body))), nil
|
|
}
|
|
r, err := json.Marshal(updatedItem)
|
|
if err != nil {
|
|
return nil, fmt.Errorf("failed to marshal response: %w", err)
|
|
}
|
|
|
|
return mcp.NewToolResultText(string(r)), nil
|
|
}
|
|
}
|
|
|
|
func DeleteProjectItem(getClient GetClientFn, t translations.TranslationHelperFunc) (tool mcp.Tool, handler server.ToolHandlerFunc) {
|
|
return mcp.NewTool("delete_project_item",
|
|
mcp.WithDescription(t("TOOL_DELETE_PROJECT_ITEM_DESCRIPTION", "Delete a specific Project item for a user or org")),
|
|
mcp.WithToolAnnotation(mcp.ToolAnnotation{
|
|
Title: t("TOOL_DELETE_PROJECT_ITEM_USER_TITLE", "Delete project item"),
|
|
ReadOnlyHint: ToBoolPtr(false),
|
|
}),
|
|
mcp.WithString("owner_type",
|
|
mcp.Required(),
|
|
mcp.Description("Owner type"),
|
|
mcp.Enum("user", "org"),
|
|
),
|
|
mcp.WithString("owner",
|
|
mcp.Required(),
|
|
mcp.Description("If owner_type == user it is the handle for the GitHub user account. If owner_type == org it is the name of the organization. The name is not case sensitive."),
|
|
),
|
|
mcp.WithNumber("project_number",
|
|
mcp.Required(),
|
|
mcp.Description("The project's number."),
|
|
),
|
|
mcp.WithNumber("item_id",
|
|
mcp.Required(),
|
|
mcp.Description("The internal project item ID to delete from the project (not the issue or pull request ID)."),
|
|
),
|
|
), func(ctx context.Context, req mcp.CallToolRequest) (*mcp.CallToolResult, error) {
|
|
owner, err := RequiredParam[string](req, "owner")
|
|
if err != nil {
|
|
return mcp.NewToolResultError(err.Error()), nil
|
|
}
|
|
ownerType, err := RequiredParam[string](req, "owner_type")
|
|
if err != nil {
|
|
return mcp.NewToolResultError(err.Error()), nil
|
|
}
|
|
projectNumber, err := RequiredInt(req, "project_number")
|
|
if err != nil {
|
|
return mcp.NewToolResultError(err.Error()), nil
|
|
}
|
|
itemID, err := RequiredBigInt(req, "item_id")
|
|
if err != nil {
|
|
return mcp.NewToolResultError(err.Error()), nil
|
|
}
|
|
client, err := getClient(ctx)
|
|
if err != nil {
|
|
return mcp.NewToolResultError(err.Error()), nil
|
|
}
|
|
|
|
var resp *github.Response
|
|
if ownerType == "org" {
|
|
resp, err = client.Projects.DeleteOrganizationProjectItem(ctx, owner, projectNumber, itemID)
|
|
} else {
|
|
resp, err = client.Projects.DeleteUserProjectItem(ctx, owner, projectNumber, itemID)
|
|
}
|
|
|
|
if err != nil {
|
|
return ghErrors.NewGitHubAPIErrorResponse(ctx,
|
|
ProjectDeleteFailedError,
|
|
resp,
|
|
err,
|
|
), nil
|
|
}
|
|
defer func() { _ = resp.Body.Close() }()
|
|
|
|
if resp.StatusCode != http.StatusNoContent {
|
|
body, err := io.ReadAll(resp.Body)
|
|
if err != nil {
|
|
return nil, fmt.Errorf("failed to read response body: %w", err)
|
|
}
|
|
return mcp.NewToolResultError(fmt.Sprintf("%s: %s", ProjectDeleteFailedError, string(body))), nil
|
|
}
|
|
return mcp.NewToolResultText("project item successfully deleted"), nil
|
|
}
|
|
}
|
|
|
|
type pageInfo struct {
|
|
HasNextPage bool `json:"hasNextPage"`
|
|
HasPreviousPage bool `json:"hasPreviousPage"`
|
|
NextCursor string `json:"nextCursor,omitempty"`
|
|
PrevCursor string `json:"prevCursor,omitempty"`
|
|
}
|
|
|
|
func toNewProjectType(projType string) string {
|
|
switch strings.ToLower(projType) {
|
|
case "issue":
|
|
return "Issue"
|
|
case "pull_request":
|
|
return "PullRequest"
|
|
default:
|
|
return ""
|
|
}
|
|
}
|
|
|
|
// validateAndConvertToInt64 ensures the value is a number and converts it to int64.
|
|
func validateAndConvertToInt64(value any) (int64, error) {
|
|
switch v := value.(type) {
|
|
case float64:
|
|
// Validate that the float64 can be safely converted to int64
|
|
intVal := int64(v)
|
|
if float64(intVal) != v {
|
|
return 0, fmt.Errorf("value must be a valid integer (got %v)", v)
|
|
}
|
|
return intVal, nil
|
|
case int64:
|
|
return v, nil
|
|
case int:
|
|
return int64(v), nil
|
|
default:
|
|
return 0, fmt.Errorf("value must be a number (got %T)", v)
|
|
}
|
|
}
|
|
|
|
// buildUpdateProjectItem constructs UpdateProjectItemOptions from the input map.
|
|
func buildUpdateProjectItem(input map[string]any) (*github.UpdateProjectItemOptions, error) {
|
|
if input == nil {
|
|
return nil, fmt.Errorf("updated_field must be an object")
|
|
}
|
|
|
|
idField, ok := input["id"]
|
|
if !ok {
|
|
return nil, fmt.Errorf("updated_field.id is required")
|
|
}
|
|
|
|
fieldID, err := validateAndConvertToInt64(idField)
|
|
if err != nil {
|
|
return nil, fmt.Errorf("updated_field.id: %w", err)
|
|
}
|
|
|
|
valueField, ok := input["value"]
|
|
if !ok {
|
|
return nil, fmt.Errorf("updated_field.value is required")
|
|
}
|
|
|
|
payload := &github.UpdateProjectItemOptions{
|
|
Fields: []*github.UpdateProjectV2Field{{
|
|
ID: fieldID,
|
|
Value: valueField,
|
|
}},
|
|
}
|
|
|
|
return payload, nil
|
|
}
|
|
|
|
func buildPageInfo(resp *github.Response) pageInfo {
|
|
return pageInfo{
|
|
HasNextPage: resp.After != "",
|
|
HasPreviousPage: resp.Before != "",
|
|
NextCursor: resp.After,
|
|
PrevCursor: resp.Before,
|
|
}
|
|
}
|
|
|
|
func extractPaginationOptions(request mcp.CallToolRequest) (github.ListProjectsPaginationOptions, error) {
|
|
perPage, err := OptionalIntParamWithDefault(request, "per_page", MaxProjectsPerPage)
|
|
if err != nil {
|
|
return github.ListProjectsPaginationOptions{}, err
|
|
}
|
|
if perPage > MaxProjectsPerPage {
|
|
perPage = MaxProjectsPerPage
|
|
}
|
|
|
|
after, err := OptionalParam[string](request, "after")
|
|
if err != nil {
|
|
return github.ListProjectsPaginationOptions{}, err
|
|
}
|
|
|
|
before, err := OptionalParam[string](request, "before")
|
|
if err != nil {
|
|
return github.ListProjectsPaginationOptions{}, err
|
|
}
|
|
|
|
opts := github.ListProjectsPaginationOptions{
|
|
PerPage: &perPage,
|
|
}
|
|
|
|
// Only set After/Before if they have non-empty values
|
|
if after != "" {
|
|
opts.After = &after
|
|
}
|
|
|
|
if before != "" {
|
|
opts.Before = &before
|
|
}
|
|
|
|
return opts, nil
|
|
}
|