Skip to content
Merged
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
182 changes: 67 additions & 115 deletions pkg/github/projects.go
Original file line number Diff line number Diff line change
Expand Up @@ -69,13 +69,13 @@ func ListProjects(getClient GetClientFn, t translations.TranslationHelperFunc) (

var resp *github.Response
var projects []*github.ProjectV2
minimalProjects := []MinimalProject{}

var queryPtr *string

if queryStr != "" {
queryPtr = &queryStr
}

minimalProjects := []MinimalProject{}
opts := &github.ListProjectsOptions{
ListProjectsPaginationOptions: github.ListProjectsPaginationOptions{PerPage: &perPage},
Query: queryPtr,
Expand Down Expand Up @@ -237,27 +237,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 +309,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 +318,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, resp, err = client.Projects.GetUserProjectField(ctx, 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)
}

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 +402,37 @@ 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{}
var resp *github.Response
var projectItems []*github.ProjectV2Item
Copy link
Contributor Author

@stephenotalora stephenotalora Nov 6, 2025

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Standardized field parameters has changed from array notation (fields[]=123&fields[]=456) to comma-separated format (fields=123,456,789).

Why this change was needed:

  • The comma-separated format is handled natively by the github.com/google/go-querystring library using the ,comma tag option
  • Eliminates manual URL encoding complexity in our code
  • Better aligns with Go's standard query parameter handling patterns
  • Our tests in projects_test.go confirm the comma format works correctly: fieldParams == "123,456,789"

Technical details: The fieldSelectionOptions struct now uses url:"fields,omitempty,comma" which leverages go-querystring's built-in comma support rather than custom array parameter logic.

See ListProjectItemsOptions struct

// ListProjectItemsOptions specifies optional parameters when listing project items.
// Note: Pagination uses before/after cursor-style pagination similar to ListProjectsOptions.
// "Fields" can be used to restrict which field values are returned (by their numeric IDs).
type ListProjectItemsOptions struct {
	// Embed ListProjectsOptions to reuse pagination and query parameters.
	ListProjectsOptions
	// Fields restricts which field values are returned by numeric field IDs.
	Fields []int64 `url:"fields,omitempty,comma"`
}

I have also updated this internally to align the MCP server with the underlying google/go-github dependency since we're not quite ready to migrate GetProjectItem yet as per this PR's description.

var queryPtr *string

opts := listProjectItemsOptions{
paginationOptions: paginationOptions{PerPage: perPage},
filterQueryOptions: filterQueryOptions{Query: queryStr},
fieldSelectionOptions: fieldSelectionOptions{Fields: fields},
if queryStr != "" {
queryPtr = &queryStr
}

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: queryPtr,
},
}

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 +500,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 Down Expand Up @@ -624,7 +606,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 +624,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 @@ -827,7 +805,7 @@ func DeleteProjectItem(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 @@ -836,19 +814,13 @@ func DeleteProjectItem(getClient GetClientFn, t translations.TranslationHelperFu
return mcp.NewToolResultError(err.Error()), nil
}

var projectsURL string
var resp *github.Response
if ownerType == "org" {
projectsURL = fmt.Sprintf("orgs/%s/projectsV2/%d/items/%d", owner, projectNumber, itemID)
resp, err = client.Projects.DeleteOrganizationProjectItem(ctx, owner, projectNumber, itemID)
} else {
projectsURL = fmt.Sprintf("users/%s/projectsV2/%d/items/%d", owner, projectNumber, itemID)
resp, err = client.Projects.DeleteUserProjectItem(ctx, owner, projectNumber, itemID)
}

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

resp, err := client.Do(ctx, httpRequest, nil)
if err != nil {
return ghErrors.NewGitHubAPIErrorResponse(ctx,
ProjectDeleteFailedError,
Expand All @@ -869,9 +841,10 @@ func DeleteProjectItem(getClient GetClientFn, t translations.TranslationHelperFu
}
}

type newProjectItem struct {
ID int64 `json:"id,omitempty"`
Type string `json:"type,omitempty"`
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 {
Expand All @@ -883,17 +856,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 +893,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 @@ -994,18 +936,28 @@ func addOptions(s string, opts any) (string, error) {
return s, nil
}

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

qs, err := query.Values(opts)
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
}

u.RawQuery = qs.Encode()
return u.String(), nil
// Merge the values
for key, values := range newValues {
for _, value := range values {
origValues.Add(key, value)
}
}

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

func ManageProjectItemsPrompt(t translations.TranslationHelperFunc) (tool mcp.Prompt, handler server.PromptHandlerFunc) {
Expand Down
8 changes: 4 additions & 4 deletions pkg/github/projects_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -653,8 +653,8 @@ func Test_ListProjectItems(t *testing.T) {
mock.EndpointPattern{Pattern: "/orgs/{org}/projectsV2/{project}/items", Method: http.MethodGet},
http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
q := r.URL.Query()
fieldParams := q["fields"]
if len(fieldParams) == 3 && fieldParams[0] == "123" && fieldParams[1] == "456" && fieldParams[2] == "789" {
fieldParams := q.Get("fields")
if fieldParams == "123,456,789" {
Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

see this comment in relation to this change.

w.WriteHeader(http.StatusOK)
_, _ = w.Write(mock.MustMarshal(orgItems))
return
Expand Down Expand Up @@ -852,8 +852,8 @@ func Test_GetProjectItem(t *testing.T) {
mock.EndpointPattern{Pattern: "/orgs/{org}/projectsV2/{project}/items/{item_id}", Method: http.MethodGet},
http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
q := r.URL.Query()
fieldParams := q["fields"]
if len(fieldParams) == 2 && fieldParams[0] == "123" && fieldParams[1] == "456" {
fieldParams := q.Get("fields")
if fieldParams == "123,456" {
w.WriteHeader(http.StatusOK)
_, _ = w.Write(mock.MustMarshal(orgItem))
return
Expand Down
Loading