Skip to content

Commit 168e77b

Browse files
CopilotJoannaaKL
andcommitted
Fix list_project_fields JSON unmarshal error for single_select fields
The go-github v79 library expects `options.name` to be an object (`*ProjectV2TextContent`) but the GitHub API returns a simple string. This fix: - Creates custom types (FlexibleString, ProjectField, etc.) that can handle both string and object formats for option names - Implements custom UnmarshalJSON for FlexibleString to handle either format - Updates ListProjectFields and GetProjectField to use raw API requests with our custom types instead of the go-github library's types - Adds tests for both string and object name formats Co-authored-by: JoannaaKL <[email protected]>
1 parent dfa74f3 commit 168e77b

File tree

2 files changed

+208
-13
lines changed

2 files changed

+208
-13
lines changed

pkg/github/projects.go

Lines changed: 149 additions & 13 deletions
Original file line numberDiff line numberDiff line change
@@ -23,6 +23,73 @@ const (
2323
MaxProjectsPerPage = 50
2424
)
2525

26+
// FlexibleString handles JSON unmarshaling of fields that can be either
27+
// a plain string or an object with "raw" and "html" fields.
28+
// This is needed because the GitHub API returns option names as strings,
29+
// while go-github v79 expects them to be ProjectV2TextContent objects.
30+
type FlexibleString struct {
31+
Raw string `json:"raw,omitempty"`
32+
HTML string `json:"html,omitempty"`
33+
}
34+
35+
// UnmarshalJSON implements custom unmarshaling for FlexibleString
36+
func (f *FlexibleString) UnmarshalJSON(data []byte) error {
37+
// Try to unmarshal as a plain string first
38+
var s string
39+
if err := json.Unmarshal(data, &s); err == nil {
40+
f.Raw = s
41+
f.HTML = s
42+
return nil
43+
}
44+
45+
// If that fails, try to unmarshal as an object
46+
type flexibleStringAlias FlexibleString
47+
var obj flexibleStringAlias
48+
if err := json.Unmarshal(data, &obj); err != nil {
49+
return err
50+
}
51+
*f = FlexibleString(obj)
52+
return nil
53+
}
54+
55+
// ProjectFieldOption represents an option for single_select or iteration fields.
56+
// This is a custom type that handles the flexible name format from the GitHub API.
57+
type ProjectFieldOption struct {
58+
ID string `json:"id,omitempty"`
59+
Name *FlexibleString `json:"name,omitempty"`
60+
Color string `json:"color,omitempty"`
61+
Description *FlexibleString `json:"description,omitempty"`
62+
}
63+
64+
// ProjectFieldIteration represents an iteration within a project field.
65+
type ProjectFieldIteration struct {
66+
ID string `json:"id,omitempty"`
67+
Title *FlexibleString `json:"title,omitempty"`
68+
StartDate string `json:"start_date,omitempty"`
69+
Duration int `json:"duration,omitempty"`
70+
}
71+
72+
// ProjectFieldConfiguration represents the configuration for iteration fields.
73+
type ProjectFieldConfiguration struct {
74+
Duration int `json:"duration,omitempty"`
75+
StartDay int `json:"start_day,omitempty"`
76+
Iterations []*ProjectFieldIteration `json:"iterations,omitempty"`
77+
}
78+
79+
// ProjectField represents a field in a GitHub Project V2.
80+
// This is a custom type that properly handles the options array format from the GitHub API.
81+
type ProjectField struct {
82+
ID int64 `json:"id,omitempty"`
83+
NodeID string `json:"node_id,omitempty"`
84+
Name string `json:"name,omitempty"`
85+
DataType string `json:"data_type,omitempty"`
86+
ProjectURL string `json:"project_url,omitempty"`
87+
Options []*ProjectFieldOption `json:"options,omitempty"`
88+
Configuration *ProjectFieldConfiguration `json:"configuration,omitempty"`
89+
CreatedAt string `json:"created_at,omitempty"`
90+
UpdatedAt string `json:"updated_at,omitempty"`
91+
}
92+
2693
func ListProjects(getClient GetClientFn, t translations.TranslationHelperFunc) (tool mcp.Tool, handler server.ToolHandlerFunc) {
2794
return mcp.NewTool("list_projects",
2895
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
253320
return mcp.NewToolResultError(err.Error()), nil
254321
}
255322

256-
var resp *github.Response
257-
var projectFields []*github.ProjectV2Field
323+
// Build the URL for the API request
324+
var urlPath string
325+
if ownerType == "org" {
326+
urlPath = fmt.Sprintf("orgs/%s/projectsV2/%d/fields", owner, projectNumber)
327+
} else {
328+
urlPath = fmt.Sprintf("users/%s/projectsV2/%d/fields", owner, projectNumber)
329+
}
258330

331+
// Create options for the request
259332
opts := &github.ListProjectsOptions{
260333
ListProjectsPaginationOptions: pagination,
261334
}
262335

263-
if ownerType == "org" {
264-
projectFields, resp, err = client.Projects.ListOrganizationProjectFields(ctx, owner, projectNumber, opts)
265-
} else {
266-
projectFields, resp, err = client.Projects.ListUserProjectFields(ctx, owner, projectNumber, opts)
267-
}
268-
336+
// Make the raw API request using go-github's client
337+
// We use our custom ProjectField type which handles flexible name format
338+
projectFields, resp, err := listProjectFieldsRaw(ctx, client, urlPath, opts)
269339
if err != nil {
270340
return ghErrors.NewGitHubAPIErrorResponse(ctx,
271341
"failed to list project fields",
@@ -289,6 +359,70 @@ func ListProjectFields(getClient GetClientFn, t translations.TranslationHelperFu
289359
}
290360
}
291361

362+
// listProjectFieldsRaw makes a raw API request to list project fields and parses
363+
// the response using our custom ProjectField type that handles flexible name formats.
364+
func listProjectFieldsRaw(ctx context.Context, client *github.Client, urlPath string, opts *github.ListProjectsOptions) ([]*ProjectField, *github.Response, error) {
365+
u, err := addProjectOptions(urlPath, opts)
366+
if err != nil {
367+
return nil, nil, err
368+
}
369+
370+
req, err := client.NewRequest("GET", u, nil)
371+
if err != nil {
372+
return nil, nil, err
373+
}
374+
375+
var fields []*ProjectField
376+
resp, err := client.Do(ctx, req, &fields)
377+
if err != nil {
378+
return nil, resp, err
379+
}
380+
return fields, resp, nil
381+
}
382+
383+
// addProjectOptions adds query parameters to a URL for project API requests.
384+
func addProjectOptions(s string, opts *github.ListProjectsOptions) (string, error) {
385+
if opts == nil {
386+
return s, nil
387+
}
388+
389+
// Build query parameters manually
390+
params := make([]string, 0)
391+
if opts.PerPage != nil && *opts.PerPage > 0 {
392+
params = append(params, fmt.Sprintf("per_page=%d", *opts.PerPage))
393+
}
394+
if opts.After != nil && *opts.After != "" {
395+
params = append(params, fmt.Sprintf("after=%s", *opts.After))
396+
}
397+
if opts.Before != nil && *opts.Before != "" {
398+
params = append(params, fmt.Sprintf("before=%s", *opts.Before))
399+
}
400+
if opts.Query != nil && *opts.Query != "" {
401+
params = append(params, fmt.Sprintf("q=%s", *opts.Query))
402+
}
403+
404+
if len(params) > 0 {
405+
s = s + "?" + strings.Join(params, "&")
406+
}
407+
return s, nil
408+
}
409+
410+
// getProjectFieldRaw makes a raw API request to get a single project field and parses
411+
// the response using our custom ProjectField type that handles flexible name formats.
412+
func getProjectFieldRaw(ctx context.Context, client *github.Client, urlPath string) (*ProjectField, *github.Response, error) {
413+
req, err := client.NewRequest("GET", urlPath, nil)
414+
if err != nil {
415+
return nil, nil, err
416+
}
417+
418+
var field ProjectField
419+
resp, err := client.Do(ctx, req, &field)
420+
if err != nil {
421+
return nil, resp, err
422+
}
423+
return &field, resp, nil
424+
}
425+
292426
func GetProjectField(getClient GetClientFn, t translations.TranslationHelperFunc) (tool mcp.Tool, handler server.ToolHandlerFunc) {
293427
return mcp.NewTool("get_project_field",
294428
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
332466
return mcp.NewToolResultError(err.Error()), nil
333467
}
334468

335-
var resp *github.Response
336-
var projectField *github.ProjectV2Field
337-
469+
// Build the URL for the API request
470+
var urlPath string
338471
if ownerType == "org" {
339-
projectField, resp, err = client.Projects.GetOrganizationProjectField(ctx, owner, projectNumber, fieldID)
472+
urlPath = fmt.Sprintf("orgs/%s/projectsV2/%d/fields/%d", owner, projectNumber, fieldID)
340473
} else {
341-
projectField, resp, err = client.Projects.GetUserProjectField(ctx, owner, projectNumber, fieldID)
474+
urlPath = fmt.Sprintf("users/%s/projectsV2/%d/fields/%d", owner, projectNumber, fieldID)
342475
}
343476

477+
// Make the raw API request using go-github's client
478+
// We use our custom ProjectField type which handles flexible name format
479+
projectField, resp, err := getProjectFieldRaw(ctx, client, urlPath)
344480
if err != nil {
345481
return ghErrors.NewGitHubAPIErrorResponse(ctx,
346482
"failed to get project field",

pkg/github/projects_test.go

Lines changed: 59 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -320,6 +320,29 @@ func Test_ListProjectFields(t *testing.T) {
320320
orgFields := []map[string]any{{"id": 101, "name": "Status", "data_type": "single_select"}}
321321
userFields := []map[string]any{{"id": 201, "name": "Priority", "data_type": "single_select"}}
322322

323+
// Test data with single_select options using string names (as GitHub API returns)
324+
fieldsWithStringOptions := []map[string]any{{
325+
"id": 102,
326+
"name": "Status",
327+
"data_type": "single_select",
328+
"options": []map[string]any{
329+
{"id": "aeba538c", "name": "Backlog", "color": "GREEN"},
330+
{"id": "f75ad846", "name": "Ready", "color": "YELLOW"},
331+
{"id": "47fc9ee4", "name": "In Progress", "color": "ORANGE"},
332+
},
333+
}}
334+
335+
// Test data with single_select options using object names (alternative format)
336+
fieldsWithObjectOptions := []map[string]any{{
337+
"id": 103,
338+
"name": "Priority",
339+
"data_type": "single_select",
340+
"options": []map[string]any{
341+
{"id": "opt1", "name": map[string]string{"raw": "High", "html": "High"}, "color": "RED"},
342+
{"id": "opt2", "name": map[string]string{"raw": "Low", "html": "Low"}, "color": "GREEN"},
343+
},
344+
}}
345+
323346
tests := []struct {
324347
name string
325348
mockedClient *http.Client
@@ -346,6 +369,42 @@ func Test_ListProjectFields(t *testing.T) {
346369
},
347370
expectedLength: 1,
348371
},
372+
{
373+
name: "success with single_select options using string names",
374+
mockedClient: mock.NewMockedHTTPClient(
375+
mock.WithRequestMatchHandler(
376+
mock.EndpointPattern{Pattern: "/orgs/{org}/projectsV2/{project}/fields", Method: http.MethodGet},
377+
http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) {
378+
w.WriteHeader(http.StatusOK)
379+
_, _ = w.Write(mock.MustMarshal(fieldsWithStringOptions))
380+
}),
381+
),
382+
),
383+
requestArgs: map[string]interface{}{
384+
"owner": "octo-org",
385+
"owner_type": "org",
386+
"project_number": float64(124),
387+
},
388+
expectedLength: 1,
389+
},
390+
{
391+
name: "success with single_select options using object names",
392+
mockedClient: mock.NewMockedHTTPClient(
393+
mock.WithRequestMatchHandler(
394+
mock.EndpointPattern{Pattern: "/orgs/{org}/projectsV2/{project}/fields", Method: http.MethodGet},
395+
http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) {
396+
w.WriteHeader(http.StatusOK)
397+
_, _ = w.Write(mock.MustMarshal(fieldsWithObjectOptions))
398+
}),
399+
),
400+
),
401+
requestArgs: map[string]interface{}{
402+
"owner": "octo-org",
403+
"owner_type": "org",
404+
"project_number": float64(125),
405+
},
406+
expectedLength: 1,
407+
},
349408
{
350409
name: "success user fields with per_page override",
351410
mockedClient: mock.NewMockedHTTPClient(

0 commit comments

Comments
 (0)