Skip to content
Draft
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
193 changes: 47 additions & 146 deletions pkg/github/projects.go
Original file line number Diff line number Diff line change
Expand Up @@ -6,14 +6,11 @@ import (
"fmt"
"io"
"net/http"
"net/url"
"reflect"
"strings"

ghErrors "github.com/github/github-mcp-server/pkg/errors"
"github.com/github/github-mcp-server/pkg/translations"
"github.com/google/go-github/v77/github"
"github.com/google/go-querystring/query"
"github.com/mark3labs/mcp-go/mcp"
"github.com/mark3labs/mcp-go/server"
)
Expand Down Expand Up @@ -237,27 +234,19 @@ func ListProjectFields(getClient GetClientFn, t translations.TranslationHelperFu
return mcp.NewToolResultError(err.Error()), nil
}

var url string
if ownerType == "org" {
url = fmt.Sprintf("orgs/%s/projectsV2/%d/fields", owner, projectNumber)
} else {
url = fmt.Sprintf("users/%s/projectsV2/%d/fields", owner, projectNumber)
}
projectFields := []projectV2Field{}

opts := paginationOptions{PerPage: perPage}
var resp *github.Response
var projectFields []*github.ProjectV2Field

url, err = addOptions(url, opts)
if err != nil {
return nil, fmt.Errorf("failed to add options to request: %w", err)
opts := &github.ListProjectsOptions{
ListProjectsPaginationOptions: github.ListProjectsPaginationOptions{PerPage: &perPage},
}

httpRequest, err := client.NewRequest("GET", url, nil)
if err != nil {
return nil, fmt.Errorf("failed to create request: %w", err)
if ownerType == "org" {
projectFields, resp, err = client.Projects.ListOrganizationProjectFields(ctx, owner, projectNumber, opts)
} else {
projectFields, resp, err = client.Projects.ListUserProjectFields(ctx, owner, projectNumber, opts)
}

resp, err := client.Do(ctx, httpRequest, &projectFields)
if err != nil {
return ghErrors.NewGitHubAPIErrorResponse(ctx,
"failed to list project fields",
Expand Down Expand Up @@ -317,7 +306,7 @@ func GetProjectField(getClient GetClientFn, t translations.TranslationHelperFunc
if err != nil {
return mcp.NewToolResultError(err.Error()), nil
}
fieldID, err := RequiredInt(req, "field_id")
fieldID, err := RequiredBigInt(req, "field_id")
if err != nil {
return mcp.NewToolResultError(err.Error()), nil
}
Expand All @@ -326,21 +315,15 @@ func GetProjectField(getClient GetClientFn, t translations.TranslationHelperFunc
return mcp.NewToolResultError(err.Error()), nil
}

var url string
var resp *github.Response
var projectField *github.ProjectV2Field

if ownerType == "org" {
url = fmt.Sprintf("orgs/%s/projectsV2/%d/fields/%d", owner, projectNumber, fieldID)
projectField, resp, err = client.Projects.GetOrganizationProjectField(ctx, owner, projectNumber, fieldID)
} else {
url = fmt.Sprintf("users/%s/projectsV2/%d/fields/%d", owner, projectNumber, fieldID)
}

projectField := projectV2Field{}

httpRequest, err := client.NewRequest("GET", url, nil)
if err != nil {
return nil, fmt.Errorf("failed to create request: %w", err)
projectField, resp, err = client.Projects.GetUserProjectField(ctx, owner, projectNumber, fieldID)
}

resp, err := client.Do(ctx, httpRequest, &projectField)
if err != nil {
return ghErrors.NewGitHubAPIErrorResponse(ctx,
"failed to get project field",
Expand Down Expand Up @@ -416,41 +399,32 @@ func ListProjectItems(getClient GetClientFn, t translations.TranslationHelperFun
if err != nil {
return mcp.NewToolResultError(err.Error()), nil
}
fields, err := OptionalStringArrayParam(req, "fields")
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 url string
if ownerType == "org" {
url = fmt.Sprintf("orgs/%s/projectsV2/%d/items", owner, projectNumber)
} else {
url = fmt.Sprintf("users/%s/projectsV2/%d/items", owner, projectNumber)
}
projectItems := []projectV2Item{}

opts := listProjectItemsOptions{
paginationOptions: paginationOptions{PerPage: perPage},
filterQueryOptions: filterQueryOptions{Query: queryStr},
fieldSelectionOptions: fieldSelectionOptions{Fields: fields},
}
var resp *github.Response
var projectItems []*github.ProjectV2Item

url, err = addOptions(url, opts)
if err != nil {
return nil, fmt.Errorf("failed to add options to request: %w", err)
opts := &github.ListProjectItemsOptions{
Fields: fields,
ListProjectsOptions: github.ListProjectsOptions{
ListProjectsPaginationOptions: github.ListProjectsPaginationOptions{PerPage: &perPage},
Query: &queryStr,
},
}

httpRequest, err := client.NewRequest("GET", url, nil)
if err != nil {
return nil, fmt.Errorf("failed to create request: %w", err)
if ownerType == "org" {
projectItems, resp, err = client.Projects.ListOrganizationProjectItems(ctx, owner, projectNumber, opts)
} else {
projectItems, resp, err = client.Projects.ListUserProjectItems(ctx, owner, projectNumber, opts)
}

resp, err := client.Do(ctx, httpRequest, &projectItems)
if err != nil {
return ghErrors.NewGitHubAPIErrorResponse(ctx,
ProjectListFailedError,
Expand Down Expand Up @@ -518,11 +492,11 @@ func GetProjectItem(getClient GetClientFn, t translations.TranslationHelperFunc)
if err != nil {
return mcp.NewToolResultError(err.Error()), nil
}
itemID, err := RequiredInt(req, "item_id")
itemID, err := RequiredBigInt(req, "item_id")
if err != nil {
return mcp.NewToolResultError(err.Error()), nil
}
fields, err := OptionalStringArrayParam(req, "fields")
fields, err := OptionalBigIntArrayParam(req, "fields")
if err != nil {
return mcp.NewToolResultError(err.Error()), nil
}
Expand All @@ -532,32 +506,21 @@ func GetProjectItem(getClient GetClientFn, t translations.TranslationHelperFunc)
return mcp.NewToolResultError(err.Error()), nil
}

var url string
if ownerType == "org" {
url = fmt.Sprintf("orgs/%s/projectsV2/%d/items/%d", owner, projectNumber, itemID)
} else {
url = fmt.Sprintf("users/%s/projectsV2/%d/items/%d", owner, projectNumber, itemID)
}

opts := fieldSelectionOptions{}
opts := &github.GetProjectItemOptions{}

if len(fields) > 0 {
opts.Fields = fields
}

url, err = addOptions(url, opts)
if err != nil {
return mcp.NewToolResultError(err.Error()), nil
}

projectItem := projectV2Item{}
var resp *github.Response
var projectItem *github.ProjectV2Item

httpRequest, err := client.NewRequest("GET", url, nil)
if err != nil {
return nil, fmt.Errorf("failed to create request: %w", err)
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)
}

resp, err := client.Do(ctx, httpRequest, &projectItem)
if err != nil {
return ghErrors.NewGitHubAPIErrorResponse(ctx,
"failed to get project item",
Expand Down Expand Up @@ -624,7 +587,7 @@ func AddProjectItem(getClient GetClientFn, t translations.TranslationHelperFunc)
if err != nil {
return mcp.NewToolResultError(err.Error()), nil
}
itemID, err := RequiredInt(req, "item_id")
itemID, err := RequiredBigInt(req, "item_id")
if err != nil {
return mcp.NewToolResultError(err.Error()), nil
}
Expand All @@ -642,24 +605,20 @@ func AddProjectItem(getClient GetClientFn, t translations.TranslationHelperFunc)
return mcp.NewToolResultError(err.Error()), nil
}

var projectsURL string
if ownerType == "org" {
projectsURL = fmt.Sprintf("orgs/%s/projectsV2/%d/items", owner, projectNumber)
} else {
projectsURL = fmt.Sprintf("users/%s/projectsV2/%d/items", owner, projectNumber)
}

newItem := &newProjectItem{
ID: int64(itemID),
newItem := &github.AddProjectItemOptions{
ID: itemID,
Type: toNewProjectType(itemType),
}
httpRequest, err := client.NewRequest("POST", projectsURL, newItem)
if err != nil {
return nil, fmt.Errorf("failed to create request: %w", err)

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)
}
addedItem := projectV2Item{}

resp, err := client.Do(ctx, httpRequest, &addedItem)
if err != nil {
return ghErrors.NewGitHubAPIErrorResponse(ctx,
ProjectAddFailedError,
Expand Down Expand Up @@ -869,11 +828,6 @@ func DeleteProjectItem(getClient GetClientFn, t translations.TranslationHelperFu
}
}

type newProjectItem struct {
ID int64 `json:"id,omitempty"`
Type string `json:"type,omitempty"`
}

type updateProjectItemPayload struct {
Fields []updateProjectItem `json:"fields"`
}
Expand All @@ -883,17 +837,6 @@ type updateProjectItem struct {
Value any `json:"value"`
}

type projectV2Field struct {
ID *int64 `json:"id,omitempty"` // The unique identifier for this field.
NodeID string `json:"node_id,omitempty"` // The GraphQL node ID for this field.
Name string `json:"name,omitempty"` // The display name of the field.
DataType string `json:"data_type,omitempty"` // The data type of the field (e.g., "text", "number", "date", "single_select", "multi_select").
URL string `json:"url,omitempty"` // The API URL for this field.
Options []*any `json:"options,omitempty"` // Available options for single_select and multi_select fields.
CreatedAt *github.Timestamp `json:"created_at,omitempty"` // The time when this field was created.
UpdatedAt *github.Timestamp `json:"updated_at,omitempty"` // The time when this field was last updated.
}

type projectV2ItemFieldValue struct {
ID *int64 `json:"id,omitempty"` // The unique identifier for this field.
Name string `json:"name,omitempty"` // The display name of the field.
Expand Down Expand Up @@ -931,26 +874,6 @@ type projectV2ItemContent struct {
URL *string `json:"url,omitempty"`
}

type paginationOptions struct {
PerPage int `url:"per_page,omitempty"`
}

type filterQueryOptions struct {
Query string `url:"q,omitempty"`
}

type fieldSelectionOptions struct {
// Specific list of field IDs to include in the response. If not provided, only the title field is included.
// Example: fields=102589,985201,169875 or fields[]=102589&fields[]=985201&fields[]=169875
Fields []string `url:"fields,omitempty"`
}

type listProjectItemsOptions struct {
paginationOptions
filterQueryOptions
fieldSelectionOptions
}

func toNewProjectType(projType string) string {
switch strings.ToLower(projType) {
case "issue":
Expand Down Expand Up @@ -986,28 +909,6 @@ func buildUpdateProjectItem(input map[string]any) (*updateProjectItem, error) {
return payload, nil
}

// addOptions adds the parameters in opts as URL query parameters to s. opts
// must be a struct whose fields may contain "url" tags.
func addOptions(s string, opts any) (string, error) {
v := reflect.ValueOf(opts)
if v.Kind() == reflect.Ptr && v.IsNil() {
return s, nil
}

u, err := url.Parse(s)
if err != nil {
return s, err
}

qs, err := query.Values(opts)
if err != nil {
return s, err
}

u.RawQuery = qs.Encode()
return u.String(), nil
}

func ManageProjectItemsPrompt(t translations.TranslationHelperFunc) (tool mcp.Prompt, handler server.PromptHandlerFunc) {
return mcp.NewPrompt("ManageProjectItems",
mcp.WithPromptDescription(t("PROMPT_MANAGE_PROJECT_ITEMS_DESCRIPTION", "Interactive guide for managing GitHub Projects V2, including discovery, field management, querying, and updates.")),
Expand Down
57 changes: 57 additions & 0 deletions pkg/github/server.go
Original file line number Diff line number Diff line change
Expand Up @@ -99,6 +99,19 @@
return int(v), nil
}

// RequiredBigInt is a helper function that can be used to fetch a requested parameter from the request.
// It does the following checks:
// 1. Checks if the parameter is present in the request.
// 2. Checks if the parameter is of the expected type.
// 3. Checks if the parameter is not empty, i.e: non-zero value
func RequiredBigInt(r mcp.CallToolRequest, p string) (int64, error) {
v, err := RequiredParam[float64](r, p)
if err != nil {
return 0, err
}
return int64(v), nil
}

// OptionalParam is a helper function that can be used to fetch a requested parameter from the request.
// It does the following checks:
// 1. Checks if the parameter is present in the request, if not, it returns its zero-value
Expand Down Expand Up @@ -189,6 +202,50 @@
}
}

func convertStringSliceToInt64Slice(s []string) []int64 {
int64Slice := make([]int64, len(s))
for i, str := range s {
int64Slice[i] = convertStringToInt64(str)
}
return int64Slice
}

func convertStringToInt64(s string) int64 {
var i int64
fmt.Sscan(s, &i)

Check failure on line 215 in pkg/github/server.go

View workflow job for this annotation

GitHub Actions / lint

Error return value of `fmt.Sscan` is not checked (errcheck)
return i
}

// OptionalStringArrayParam is a helper function that can be used to fetch a requested parameter from the request.
// It does the following checks:
// 1. Checks if the parameter is present in the request, if not, it returns its zero-value
// 2. If it is present, iterates the elements and checks each is a string
func OptionalBigIntArrayParam(r mcp.CallToolRequest, p string) ([]int64, error) {
// Check if the parameter is present in the request
if _, ok := r.GetArguments()[p]; !ok {
return []int64{}, nil
}

switch v := r.GetArguments()[p].(type) {
case nil:
return []int64{}, nil
case []string:
return convertStringSliceToInt64Slice(v), nil
case []any:
int64Slice := make([]int64, len(v))
for i, v := range v {
s, ok := v.(string)
if !ok {
return []int64{}, fmt.Errorf("parameter %s is not of type string, is %T", p, v)
}
int64Slice[i] = convertStringToInt64(s)
}
return int64Slice, nil
default:
return []int64{}, fmt.Errorf("parameter %s could not be coerced to []int64, is %T", p, r.GetArguments()[p])
}
}

// WithPagination adds REST API pagination parameters to a tool.
// https://docs.github.com/en/rest/using-the-rest-api/using-pagination-in-the-rest-api
func WithPagination() mcp.ToolOption {
Expand Down
Loading