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
162 changes: 149 additions & 13 deletions pkg/github/projects.go
Original file line number Diff line number Diff line change
Expand Up @@ -23,6 +23,73 @@ const (
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`)),
Expand Down Expand Up @@ -253,19 +320,22 @@ func ListProjectFields(getClient GetClientFn, t translations.TranslationHelperFu
return mcp.NewToolResultError(err.Error()), nil
}

var resp *github.Response
var projectFields []*github.ProjectV2Field
// 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,
}

if ownerType == "org" {
projectFields, resp, err = client.Projects.ListOrganizationProjectFields(ctx, owner, projectNumber, opts)
} else {
projectFields, resp, err = client.Projects.ListUserProjectFields(ctx, owner, projectNumber, opts)
}

// 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",
Expand All @@ -289,6 +359,70 @@ func ListProjectFields(getClient GetClientFn, t translations.TranslationHelperFu
}
}

// 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")),
Expand Down Expand Up @@ -332,15 +466,17 @@ func GetProjectField(getClient GetClientFn, t translations.TranslationHelperFunc
return mcp.NewToolResultError(err.Error()), nil
}

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

// Build the URL for the API request
var urlPath string
if ownerType == "org" {
projectField, resp, err = client.Projects.GetOrganizationProjectField(ctx, owner, projectNumber, fieldID)
urlPath = fmt.Sprintf("orgs/%s/projectsV2/%d/fields/%d", owner, projectNumber, fieldID)
} else {
projectField, resp, err = client.Projects.GetUserProjectField(ctx, owner, projectNumber, fieldID)
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",
Expand Down
59 changes: 59 additions & 0 deletions pkg/github/projects_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -320,6 +320,29 @@ func Test_ListProjectFields(t *testing.T) {
orgFields := []map[string]any{{"id": 101, "name": "Status", "data_type": "single_select"}}
userFields := []map[string]any{{"id": 201, "name": "Priority", "data_type": "single_select"}}

// Test data with single_select options using string names (as GitHub API returns)
fieldsWithStringOptions := []map[string]any{{
"id": 102,
"name": "Status",
"data_type": "single_select",
"options": []map[string]any{
{"id": "aeba538c", "name": "Backlog", "color": "GREEN"},
{"id": "f75ad846", "name": "Ready", "color": "YELLOW"},
{"id": "47fc9ee4", "name": "In Progress", "color": "ORANGE"},
},
}}

// Test data with single_select options using object names (alternative format)
fieldsWithObjectOptions := []map[string]any{{
"id": 103,
"name": "Priority",
"data_type": "single_select",
"options": []map[string]any{
{"id": "opt1", "name": map[string]string{"raw": "High", "html": "High"}, "color": "RED"},
{"id": "opt2", "name": map[string]string{"raw": "Low", "html": "Low"}, "color": "GREEN"},
},
}}

tests := []struct {
name string
mockedClient *http.Client
Expand All @@ -346,6 +369,42 @@ func Test_ListProjectFields(t *testing.T) {
},
expectedLength: 1,
},
{
name: "success with single_select options using string names",
mockedClient: mock.NewMockedHTTPClient(
mock.WithRequestMatchHandler(
mock.EndpointPattern{Pattern: "/orgs/{org}/projectsV2/{project}/fields", Method: http.MethodGet},
http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) {
w.WriteHeader(http.StatusOK)
_, _ = w.Write(mock.MustMarshal(fieldsWithStringOptions))
}),
),
),
requestArgs: map[string]interface{}{
"owner": "octo-org",
"owner_type": "org",
"project_number": float64(124),
},
expectedLength: 1,
},
{
name: "success with single_select options using object names",
mockedClient: mock.NewMockedHTTPClient(
mock.WithRequestMatchHandler(
mock.EndpointPattern{Pattern: "/orgs/{org}/projectsV2/{project}/fields", Method: http.MethodGet},
http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) {
w.WriteHeader(http.StatusOK)
_, _ = w.Write(mock.MustMarshal(fieldsWithObjectOptions))
}),
),
),
requestArgs: map[string]interface{}{
"owner": "octo-org",
"owner_type": "org",
"project_number": float64(125),
},
expectedLength: 1,
},
{
name: "success user fields with per_page override",
mockedClient: mock.NewMockedHTTPClient(
Expand Down