diff --git a/pkg/github/projects.go b/pkg/github/projects.go index 4a2a68bf2..e86c5310d 100644 --- a/pkg/github/projects.go +++ b/pkg/github/projects.go @@ -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`)), @@ -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", @@ -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")), @@ -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", diff --git a/pkg/github/projects_test.go b/pkg/github/projects_test.go index ac0019ac0..19d380785 100644 --- a/pkg/github/projects_test.go +++ b/pkg/github/projects_test.go @@ -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 @@ -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(