Skip to content
Merged
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
222 changes: 55 additions & 167 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/v79/github"
"github.com/google/go-querystring/query"
"github.com/mark3labs/mcp-go/mcp"
"github.com/mark3labs/mcp-go/server"
)
Expand Down Expand Up @@ -256,30 +253,19 @@ func ListProjectFields(getClient GetClientFn, t translations.TranslationHelperFu
return mcp.NewToolResultError(err.Error()), nil
}

var resp *github.Response
var projectFields []*github.ProjectV2Field

opts := &github.ListProjectsOptions{
ListProjectsPaginationOptions: pagination,
}

var url string
if ownerType == "org" {
url = fmt.Sprintf("orgs/%s/projectsV2/%d/fields", owner, projectNumber)
projectFields, resp, err = client.Projects.ListOrganizationProjectFields(ctx, owner, projectNumber, opts)
} else {
url = fmt.Sprintf("users/%s/projectsV2/%d/fields", owner, projectNumber)
}

url, err = addOptions(url, opts)
if err != nil {
return mcp.NewToolResultError(err.Error()), nil
projectFields, resp, err = client.Projects.ListUserProjectFields(ctx, owner, projectNumber, opts)
}

httpRequest, err := client.NewRequest("GET", url, nil)
if err != nil {
return nil, fmt.Errorf("failed to create request: %w", err)
}

var projectFields []projectV2Field
resp, err := client.Do(ctx, httpRequest, &projectFields)

if err != nil {
return ghErrors.NewGitHubAPIErrorResponse(ctx,
"failed to list project fields",
Expand Down Expand Up @@ -452,7 +438,7 @@ func ListProjectItems(getClient GetClientFn, t translations.TranslationHelperFun
}

var resp *github.Response
var projectItems []projectV2Item
var projectItems []*github.ProjectV2Item
var queryPtr *string

if queryStr != "" {
Expand All @@ -467,25 +453,12 @@ func ListProjectItems(getClient GetClientFn, t translations.TranslationHelperFun
},
}

var url string
if ownerType == "org" {
url = fmt.Sprintf("orgs/%s/projectsV2/%d/items", owner, projectNumber)
projectItems, resp, err = client.Projects.ListOrganizationProjectItems(ctx, owner, projectNumber, opts)
} else {
url = fmt.Sprintf("users/%s/projectsV2/%d/items", owner, projectNumber)
projectItems, resp, err = client.Projects.ListUserProjectItems(ctx, owner, projectNumber, opts)
}

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

httpRequest, err := client.NewRequest("GET", url, nil)
if err != nil {
return nil, fmt.Errorf("failed to create request: %w", err)
}

resp, err = client.Do(ctx, httpRequest, &projectItems)

if err != nil {
return ghErrors.NewGitHubAPIErrorResponse(ctx,
ProjectListFailedError,
Expand Down Expand Up @@ -566,32 +539,22 @@ 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{}
var resp *github.Response
var projectItem *github.ProjectV2Item
var opts *github.GetProjectItemOptions

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

url, err = addOptions(url, opts)
if err != nil {
return mcp.NewToolResultError(err.Error()), nil
opts = &github.GetProjectItemOptions{
Fields: fields,
}
}

projectItem := 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 @@ -748,7 +711,7 @@ func UpdateProjectItem(getClient GetClientFn, t translations.TranslationHelperFu
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 @@ -773,21 +736,15 @@ func UpdateProjectItem(getClient GetClientFn, t translations.TranslationHelperFu
return mcp.NewToolResultError(err.Error()), nil
}

var projectsURL string
var resp *github.Response
var updatedItem *github.ProjectV2Item

if ownerType == "org" {
projectsURL = fmt.Sprintf("orgs/%s/projectsV2/%d/items/%d", owner, projectNumber, itemID)
updatedItem, resp, err = client.Projects.UpdateOrganizationProjectItem(ctx, owner, projectNumber, itemID, updatePayload)
} else {
projectsURL = fmt.Sprintf("users/%s/projectsV2/%d/items/%d", owner, projectNumber, itemID)
}
httpRequest, err := client.NewRequest("PATCH", projectsURL, updateProjectItemPayload{
Fields: []updateProjectItem{*updatePayload},
})
if err != nil {
return nil, fmt.Errorf("failed to create request: %w", err)
updatedItem, resp, err = client.Projects.UpdateUserProjectItem(ctx, owner, projectNumber, itemID, updatePayload)
}
updatedItem := projectV2Item{}

resp, err := client.Do(ctx, httpRequest, &updatedItem)
if err != nil {
return ghErrors.NewGitHubAPIErrorResponse(ctx,
ProjectUpdateFailedError,
Expand Down Expand Up @@ -886,76 +843,13 @@ func DeleteProjectItem(getClient GetClientFn, t translations.TranslationHelperFu
}
}

type fieldSelectionOptions struct {
// Specific list of field IDs to include in the response. If not provided, only the title field is included.
// The comma tag encodes the slice as comma-separated values: fields=102589,985201,169875
Fields []int64 `url:"fields,omitempty,comma"`
}

type updateProjectItemPayload struct {
Fields []updateProjectItem `json:"fields"`
}

type updateProjectItem struct {
ID int `json:"id"`
Value any `json:"value"`
}

type projectV2ItemFieldValue struct {
ID *int64 `json:"id,omitempty"`
Name string `json:"name,omitempty"`
DataType string `json:"data_type,omitempty"`
Value any `json:"value,omitempty"`
}

type projectV2Item struct {
ArchivedAt *github.Timestamp `json:"archived_at,omitempty"`
Content *projectV2ItemContent `json:"content,omitempty"`
ContentType *string `json:"content_type,omitempty"`
CreatedAt *github.Timestamp `json:"created_at,omitempty"`
Creator *github.User `json:"creator,omitempty"`
Description *string `json:"description,omitempty"`
Fields []*projectV2ItemFieldValue `json:"fields,omitempty"`
ID *int64 `json:"id,omitempty"`
ItemURL *string `json:"item_url,omitempty"`
NodeID *string `json:"node_id,omitempty"`
ProjectURL *string `json:"project_url,omitempty"`
Title *string `json:"title,omitempty"`
UpdatedAt *github.Timestamp `json:"updated_at,omitempty"`
}

type projectV2ItemContent struct {
Body *string `json:"body,omitempty"`
ClosedAt *github.Timestamp `json:"closed_at,omitempty"`
CreatedAt *github.Timestamp `json:"created_at,omitempty"`
ID *int64 `json:"id,omitempty"`
Number *int `json:"number,omitempty"`
State *string `json:"state,omitempty"`
StateReason *string `json:"stateReason,omitempty"`
Title *string `json:"title,omitempty"`
UpdatedAt *github.Timestamp `json:"updated_at,omitempty"`
URL *string `json:"url,omitempty"`
}

type pageInfo struct {
HasNextPage bool `json:"hasNextPage"`
HasPreviousPage bool `json:"hasPreviousPage"`
NextCursor string `json:"nextCursor,omitempty"`
PrevCursor string `json:"prevCursor,omitempty"`
}

type projectV2Field 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 []any `json:"options,omitempty"`
Configuration any `json:"configuration,omitempty"`
CreatedAt *github.Timestamp `json:"created_at,omitempty"`
UpdatedAt *github.Timestamp `json:"updated_at,omitempty"`
}

func toNewProjectType(projType string) string {
switch strings.ToLower(projType) {
case "issue":
Expand All @@ -967,7 +861,27 @@ func toNewProjectType(projType string) string {
}
}

func buildUpdateProjectItem(input map[string]any) (*updateProjectItem, error) {
// 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")
}
Expand All @@ -977,16 +891,22 @@ func buildUpdateProjectItem(input map[string]any) (*updateProjectItem, error) {
return nil, fmt.Errorf("updated_field.id is required")
}

idFieldAsFloat64, ok := idField.(float64) // JSON numbers are float64
if !ok {
return nil, fmt.Errorf("updated_field.id must be a number")
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 := &updateProjectItem{ID: int(idFieldAsFloat64), Value: valueField}

payload := &github.UpdateProjectItemOptions{
Fields: []*github.UpdateProjectV2Field{{
ID: fieldID,
Value: valueField,
}},
}

return payload, nil
}
Expand Down Expand Up @@ -1034,35 +954,3 @@ func extractPaginationOptions(request mcp.CallToolRequest) (github.ListProjectsP

return opts, 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
}

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

origValues := origURL.Query()

// Use the github.com/google/go-querystring library to parse the struct
newValues, err := query.Values(opts)
if err != nil {
return s, err
}

// Merge the values
for key, values := range newValues {
for _, value := range values {
origValues.Add(key, value)
}
}

origURL.RawQuery = origValues.Encode()
return origURL.String(), nil
}
Loading