Skip to content

Commit 7d363b7

Browse files
author
Tom Elliott
committed
add fields
1 parent f51bd45 commit 7d363b7

File tree

6 files changed

+231
-41
lines changed

6 files changed

+231
-41
lines changed

pkg/github/__toolsnaps__/get_project_item.snap

Lines changed: 7 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -6,6 +6,13 @@
66
"description": "Get a specific Project item for a user or org",
77
"inputSchema": {
88
"properties": {
9+
"fields": {
10+
"description": "Specific list of field IDs to include in the response (e.g. [\"102589\", \"985201\", \"169875\"]). If not provided, only the title field is included.",
11+
"items": {
12+
"type": "string"
13+
},
14+
"type": "array"
15+
},
916
"item_id": {
1017
"description": "The item's ID.",
1118
"type": "number"

pkg/github/__toolsnaps__/list_project_items.snap

Lines changed: 7 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -6,6 +6,13 @@
66
"description": "List Project items for a user or org",
77
"inputSchema": {
88
"properties": {
9+
"fields": {
10+
"description": "Specific list of field IDs to include in the response (e.g. [\"102589\", \"985201\", \"169875\"]). If not provided, only the title field is included.",
11+
"items": {
12+
"type": "string"
13+
},
14+
"type": "array"
15+
},
916
"owner": {
1017
"description": "If owner_type == user it is the handle for the GitHub user account. If owner_type == org it is the name of the organization. The name is not case sensitive.",
1118
"type": "string"

pkg/github/minimal_types.go

Lines changed: 14 additions & 14 deletions
Original file line numberDiff line numberDiff line change
@@ -132,20 +132,20 @@ type MinimalProject struct {
132132
}
133133

134134
type MinimalProjectItem struct {
135-
ID *int64 `json:"id,omitempty"`
136-
NodeID *string `json:"node_id,omitempty"`
137-
Title *string `json:"title,omitempty"`
138-
Description *string `json:"description,omitempty"`
139-
ProjectNodeID *string `json:"project_node_id,omitempty"`
140-
ContentNodeID *string `json:"content_node_id,omitempty"`
141-
ProjectURL *string `json:"project_url,omitempty"`
142-
ContentType *string `json:"content_type,omitempty"`
143-
Creator *MinimalUser `json:"creator,omitempty"`
144-
CreatedAt *github.Timestamp `json:"created_at,omitempty"`
145-
UpdatedAt *github.Timestamp `json:"updated_at,omitempty"`
146-
ArchivedAt *github.Timestamp `json:"archived_at,omitempty"`
147-
ItemURL *string `json:"item_url,omitempty"`
148-
Fields []*projectV2Field `json:"fields,omitempty"`
135+
ID *int64 `json:"id,omitempty"`
136+
NodeID *string `json:"node_id,omitempty"`
137+
Title *string `json:"title,omitempty"`
138+
Description *string `json:"description,omitempty"`
139+
ProjectNodeID *string `json:"project_node_id,omitempty"`
140+
ContentNodeID *string `json:"content_node_id,omitempty"`
141+
ProjectURL *string `json:"project_url,omitempty"`
142+
ContentType *string `json:"content_type,omitempty"`
143+
Creator *MinimalUser `json:"creator,omitempty"`
144+
CreatedAt *github.Timestamp `json:"created_at,omitempty"`
145+
UpdatedAt *github.Timestamp `json:"updated_at,omitempty"`
146+
ArchivedAt *github.Timestamp `json:"archived_at,omitempty"`
147+
ItemURL *string `json:"item_url,omitempty"`
148+
Fields []*projectV2ItemFieldValue `json:"fields,omitempty"`
149149
}
150150

151151
// Helper functions

pkg/github/projects.go

Lines changed: 141 additions & 25 deletions
Original file line numberDiff line numberDiff line change
@@ -76,7 +76,8 @@ func ListProjects(getClient GetClientFn, t translations.TranslationHelperFunc) (
7676
projects := []github.ProjectV2{}
7777
minimalProjects := []MinimalProject{}
7878

79-
opts := listProjectsOptions{PerPage: perPage}
79+
opts := listProjectsOptions{}
80+
opts.PerPage = perPage
8081

8182
if queryStr != "" {
8283
opts.Query = queryStr
@@ -257,7 +258,9 @@ func ListProjectFields(getClient GetClientFn, t translations.TranslationHelperFu
257258
}
258259
projectFields := []projectV2Field{}
259260

260-
opts := listProjectsOptions{PerPage: perPage}
261+
opts := paginationOptions{}
262+
opts.PerPage = perPage
263+
261264
url, err = addOptions(url, opts)
262265
if err != nil {
263266
return nil, fmt.Errorf("failed to add options to request: %w", err)
@@ -402,6 +405,10 @@ func ListProjectItems(getClient GetClientFn, t translations.TranslationHelperFun
402405
mcp.WithNumber("per_page",
403406
mcp.Description("Number of results per page (max 100, default: 30)"),
404407
),
408+
mcp.WithArray("fields",
409+
mcp.Description("Specific list of field IDs to include in the response (e.g. [\"102589\", \"985201\", \"169875\"]). If not provided, only the title field is included."),
410+
mcp.WithStringItems(),
411+
),
405412
), func(ctx context.Context, req mcp.CallToolRequest) (*mcp.CallToolResult, error) {
406413
owner, err := RequiredParam[string](req, "owner")
407414
if err != nil {
@@ -423,6 +430,11 @@ func ListProjectItems(getClient GetClientFn, t translations.TranslationHelperFun
423430
if err != nil {
424431
return mcp.NewToolResultError(err.Error()), nil
425432
}
433+
fields, err := OptionalStringArrayParam(req, "fields")
434+
if err != nil {
435+
return mcp.NewToolResultError(err.Error()), nil
436+
}
437+
426438
client, err := getClient(ctx)
427439
if err != nil {
428440
return mcp.NewToolResultError(err.Error()), nil
@@ -436,10 +448,17 @@ func ListProjectItems(getClient GetClientFn, t translations.TranslationHelperFun
436448
}
437449
projectItems := []projectV2Item{}
438450

439-
opts := listProjectsOptions{PerPage: perPage}
451+
opts := listProjectItemsOptions{}
452+
opts.PerPage = perPage
453+
440454
if queryStr != "" {
441455
opts.Query = queryStr
442456
}
457+
458+
if len(fields) > 0 {
459+
opts.Fields = fields
460+
}
461+
443462
url, err = addOptions(url, opts)
444463
if err != nil {
445464
return nil, fmt.Errorf("failed to add options to request: %w", err)
@@ -504,6 +523,10 @@ func GetProjectItem(getClient GetClientFn, t translations.TranslationHelperFunc)
504523
mcp.Required(),
505524
mcp.Description("The item's ID."),
506525
),
526+
mcp.WithArray("fields",
527+
mcp.Description("Specific list of field IDs to include in the response (e.g. [\"102589\", \"985201\", \"169875\"]). If not provided, only the title field is included."),
528+
mcp.WithStringItems(),
529+
),
507530
), func(ctx context.Context, req mcp.CallToolRequest) (*mcp.CallToolResult, error) {
508531
owner, err := RequiredParam[string](req, "owner")
509532
if err != nil {
@@ -521,6 +544,10 @@ func GetProjectItem(getClient GetClientFn, t translations.TranslationHelperFunc)
521544
if err != nil {
522545
return mcp.NewToolResultError(err.Error()), nil
523546
}
547+
fields, err := OptionalStringArrayParam(req, "fields")
548+
if err != nil {
549+
return mcp.NewToolResultError(err.Error()), nil
550+
}
524551

525552
client, err := getClient(ctx)
526553
if err != nil {
@@ -533,6 +560,18 @@ func GetProjectItem(getClient GetClientFn, t translations.TranslationHelperFunc)
533560
} else {
534561
url = fmt.Sprintf("users/%s/projectsV2/%d/items/%d", owner, projectNumber, itemID)
535562
}
563+
564+
opts := fieldSelectionOptions{}
565+
566+
if len(fields) > 0 {
567+
opts.Fields = fields
568+
}
569+
570+
url, err = addOptions(url, opts)
571+
if err != nil {
572+
return mcp.NewToolResultError(err.Error()), nil
573+
}
574+
536575
projectItem := projectV2Item{}
537576

538577
httpRequest, err := client.NewRequest("GET", url, nil)
@@ -877,21 +916,53 @@ type projectV2Field struct {
877916
UpdatedAt *github.Timestamp `json:"updated_at,omitempty"` // The time when this field was last updated.
878917
}
879918

919+
type projectV2ItemFieldValue struct {
920+
ID *int64 `json:"id,omitempty"` // The unique identifier for this field.
921+
Name string `json:"name,omitempty"` // The display name of the field.
922+
DataType string `json:"data_type,omitempty"` // The data type of the field (e.g., "text", "number", "date", "single_select", "multi_select").
923+
Value interface{} `json:"value,omitempty"` // The value of the field for a specific project item.
924+
}
925+
880926
type projectV2Item struct {
881-
ID *int64 `json:"id,omitempty"`
882-
Title *string `json:"title,omitempty"`
883-
Description *string `json:"description,omitempty"`
884-
NodeID *string `json:"node_id,omitempty"`
885-
ProjectNodeID *string `json:"project_node_id,omitempty"`
886-
ContentNodeID *string `json:"content_node_id,omitempty"`
887-
ProjectURL *string `json:"project_url,omitempty"`
888-
ContentType *string `json:"content_type,omitempty"`
889-
Creator *github.User `json:"creator,omitempty"`
890-
CreatedAt *github.Timestamp `json:"created_at,omitempty"`
891-
UpdatedAt *github.Timestamp `json:"updated_at,omitempty"`
892-
ArchivedAt *github.Timestamp `json:"archived_at,omitempty"`
893-
ItemURL *string `json:"item_url,omitempty"`
894-
Fields []*projectV2Field `json:"fields,omitempty"`
927+
ID *int64 `json:"id,omitempty"`
928+
Title *string `json:"title,omitempty"`
929+
Description *string `json:"description,omitempty"`
930+
NodeID *string `json:"node_id,omitempty"`
931+
ProjectNodeID *string `json:"project_node_id,omitempty"`
932+
ContentNodeID *string `json:"content_node_id,omitempty"`
933+
ProjectURL *string `json:"project_url,omitempty"`
934+
ContentType *string `json:"content_type,omitempty"`
935+
Creator *github.User `json:"creator,omitempty"`
936+
CreatedAt *github.Timestamp `json:"created_at,omitempty"`
937+
UpdatedAt *github.Timestamp `json:"updated_at,omitempty"`
938+
ArchivedAt *github.Timestamp `json:"archived_at,omitempty"`
939+
ItemURL *string `json:"item_url,omitempty"`
940+
Fields []*projectV2ItemFieldValue `json:"fields,omitempty"`
941+
}
942+
943+
type paginationOptions struct {
944+
PerPage int `url:"per_page,omitempty"`
945+
}
946+
947+
type filterQueryOptions struct {
948+
Query string `url:"q,omitempty"`
949+
}
950+
951+
type fieldSelectionOptions struct {
952+
// Specific list of field IDs to include in the response. If not provided, only the title field is included.
953+
// Example: fields=102589,985201,169875 or fields[]=102589&fields[]=985201&fields[]=169875
954+
Fields []string `url:"fields,omitempty"`
955+
}
956+
957+
type listProjectsOptions struct {
958+
paginationOptions
959+
filterQueryOptions
960+
}
961+
962+
type listProjectItemsOptions struct {
963+
paginationOptions
964+
filterQueryOptions
965+
fieldSelectionOptions
895966
}
896967

897968
func toNewProjectType(projType string) string {
@@ -905,14 +976,6 @@ func toNewProjectType(projType string) string {
905976
}
906977
}
907978

908-
type listProjectsOptions struct {
909-
// For paginated result sets, the number of results to include per page.
910-
PerPage int `url:"per_page,omitempty"`
911-
912-
// Query Limit results to projects of the specified type.
913-
Query string `url:"q,omitempty"`
914-
}
915-
916979
func buildUpdateProjectItem(input map[string]any) (*updateProjectItem, error) {
917980
if input == nil {
918981
return nil, fmt.Errorf("updated_field must be an object")
@@ -958,3 +1021,56 @@ func addOptions(s string, opts any) (string, error) {
9581021
u.RawQuery = qs.Encode()
9591022
return u.String(), nil
9601023
}
1024+
1025+
func ManageProjectItemsPrompt(t translations.TranslationHelperFunc) (tool mcp.Prompt, handler server.PromptHandlerFunc) {
1026+
return mcp.NewPrompt("ManageProjectItems",
1027+
mcp.WithPromptDescription(t("PROMPT_MANAGE_PROJECT_ITEMS_DESCRIPTION", "Guide for working with GitHub Projects, including listing projects, viewing fields, querying items, and updating field values.")),
1028+
mcp.WithArgument("owner", mcp.ArgumentDescription("The owner of the project (user or organization name)"), mcp.RequiredArgument()),
1029+
mcp.WithArgument("owner_type", mcp.ArgumentDescription("Type of owner: 'user' or 'org'"), mcp.RequiredArgument()),
1030+
), func(_ context.Context, request mcp.GetPromptRequest) (*mcp.GetPromptResult, error) {
1031+
owner := request.Params.Arguments["owner"]
1032+
ownerType := request.Params.Arguments["owner_type"]
1033+
1034+
messages := []mcp.PromptMessage{
1035+
{
1036+
Role: "user",
1037+
Content: mcp.NewTextContent("You are an assistant helping users work with GitHub Projects (Projects V2). Your role is to help them discover projects, understand project fields, query items, and update field values on project items."),
1038+
},
1039+
{
1040+
Role: "user",
1041+
Content: mcp.NewTextContent(fmt.Sprintf("I want to work with projects owned by %s (owner_type: %s). Please help me understand what projects are available.", owner, ownerType)),
1042+
},
1043+
{
1044+
Role: "assistant",
1045+
Content: mcp.NewTextContent(fmt.Sprintf("I'll help you explore the projects for %s. Let me start by listing the available projects.", owner)),
1046+
},
1047+
{
1048+
Role: "user",
1049+
Content: mcp.NewTextContent("Great! Once you show me the projects, I'd like to understand the fields available in a specific project."),
1050+
},
1051+
{
1052+
Role: "assistant",
1053+
Content: mcp.NewTextContent("Perfect! After showing you the projects, I can help you:\n\n1. 📋 List all fields in a project (using `list_project_fields`)\n2. 🔍 Get details about specific fields including their IDs, data types, and options\n3. 📊 Query project items with specific field values (using `list_project_items`)\n\nIMPORTANT: When querying project items, you must provide a list of field IDs in the 'fields' parameter to access field values. For example: fields=[\"198354254\", \"198354255\"] to get Status and Assignees. Without this parameter, only the title field is returned."),
1054+
},
1055+
{
1056+
Role: "user",
1057+
Content: mcp.NewTextContent("How do I update field values on project items?"),
1058+
},
1059+
{
1060+
Role: "assistant",
1061+
Content: mcp.NewTextContent("To update field values on project items, you'll use the `update_project_item` tool. Here's what you need to know:\n\n1. **Get the item_id**: Use `list_project_items` to find the internal project item ID (not the issue/PR number)\n2. **Get the field_id**: Use `list_project_fields` to find the ID of the field you want to update\n3. **Update the field**: Call `update_project_item` with:\n - project_number: The project's number\n - item_id: The internal project item ID\n - updated_field: An object with {\"id\": <field_id>, \"value\": <new_value>}\n\nFor single_select fields, the value should be the option name (e.g., \"In Progress\").\nFor text fields, provide a string value.\nFor number fields, provide a numeric value.\nTo clear a field, set \"value\" to null."),
1062+
},
1063+
{
1064+
Role: "user",
1065+
Content: mcp.NewTextContent("Can you give me an example workflow for finding items and updating their status?"),
1066+
},
1067+
{
1068+
Role: "assistant",
1069+
Content: mcp.NewTextContent(fmt.Sprintf("Absolutely! Here's a complete workflow:\n\n**Step 1: Find your project**\nUse `list_projects` with owner=\"%s\" and owner_type=\"%s\"\n\n**Step 2: Get the Status field ID**\nUse `list_project_fields` with the project_number from step 1\nLook for the field with name=\"Status\" and note its ID (e.g., 198354254)\n\n**Step 3: Query items with the Status field**\nUse `list_project_items` with fields=[\"198354254\"] to see current status values\nOptionally add a query parameter to filter items (e.g., query=\"assignee:@me\")\n\n**Step 4: Update an item's status**\nUse `update_project_item` with:\n- item_id: The ID from the item you want to update\n- updated_field: {\"id\": 198354254, \"value\": \"In Progress\"}\n\nLet me start by listing your projects now.", owner, ownerType)),
1070+
},
1071+
}
1072+
return &mcp.GetPromptResult{
1073+
Messages: messages,
1074+
}, nil
1075+
}
1076+
}

pkg/github/projects_test.go

Lines changed: 59 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -609,10 +609,14 @@ func Test_ListProjectItems(t *testing.T) {
609609
assert.Contains(t, tool.InputSchema.Properties, "project_number")
610610
assert.Contains(t, tool.InputSchema.Properties, "query")
611611
assert.Contains(t, tool.InputSchema.Properties, "per_page")
612+
assert.Contains(t, tool.InputSchema.Properties, "fields")
612613
assert.ElementsMatch(t, tool.InputSchema.Required, []string{"owner_type", "owner", "project_number"})
613614

614615
orgItems := []map[string]any{
615-
{"id": 301, "content_type": "Issue", "project_node_id": "PR_1"},
616+
{"id": 301, "content_type": "Issue", "project_node_id": "PR_1", "fields": []map[string]any{
617+
{"id": 123, "name": "Status", "data_type": "single_select", "value": "value1"},
618+
{"id": 456, "name": "Priority", "data_type": "single_select", "value": "value2"},
619+
}},
616620
}
617621
userItems := []map[string]any{
618622
{"id": 401, "content_type": "PullRequest", "project_node_id": "PR_2"},
@@ -642,6 +646,32 @@ func Test_ListProjectItems(t *testing.T) {
642646
},
643647
expectedLength: 1,
644648
},
649+
{
650+
name: "success organization items with fields",
651+
mockedClient: mock.NewMockedHTTPClient(
652+
mock.WithRequestMatchHandler(
653+
mock.EndpointPattern{Pattern: "/orgs/{org}/projectsV2/{project}/items", Method: http.MethodGet},
654+
http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
655+
q := r.URL.Query()
656+
fieldParams := q["fields"]
657+
if len(fieldParams) == 3 && fieldParams[0] == "123" && fieldParams[1] == "456" && fieldParams[2] == "789" {
658+
w.WriteHeader(http.StatusOK)
659+
_, _ = w.Write(mock.MustMarshal(orgItems))
660+
return
661+
}
662+
w.WriteHeader(http.StatusBadRequest)
663+
_, _ = w.Write([]byte(`{"message":"unexpected query params"}`))
664+
}),
665+
),
666+
),
667+
requestArgs: map[string]interface{}{
668+
"owner": "octo-org",
669+
"owner_type": "org",
670+
"project_number": float64(123),
671+
"fields": []interface{}{"123", "456", "789"},
672+
},
673+
expectedLength: 1,
674+
},
645675
{
646676
name: "success user items",
647677
mockedClient: mock.NewMockedHTTPClient(
@@ -775,6 +805,7 @@ func Test_GetProjectItem(t *testing.T) {
775805
assert.Contains(t, tool.InputSchema.Properties, "owner")
776806
assert.Contains(t, tool.InputSchema.Properties, "project_number")
777807
assert.Contains(t, tool.InputSchema.Properties, "item_id")
808+
assert.Contains(t, tool.InputSchema.Properties, "fields")
778809
assert.ElementsMatch(t, tool.InputSchema.Required, []string{"owner_type", "owner", "project_number", "item_id"})
779810

780811
orgItem := map[string]any{
@@ -814,6 +845,33 @@ func Test_GetProjectItem(t *testing.T) {
814845
},
815846
expectedID: 301,
816847
},
848+
{
849+
name: "success organization item with fields",
850+
mockedClient: mock.NewMockedHTTPClient(
851+
mock.WithRequestMatchHandler(
852+
mock.EndpointPattern{Pattern: "/orgs/{org}/projectsV2/{project}/items/{item_id}", Method: http.MethodGet},
853+
http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
854+
q := r.URL.Query()
855+
fieldParams := q["fields"]
856+
if len(fieldParams) == 2 && fieldParams[0] == "123" && fieldParams[1] == "456" {
857+
w.WriteHeader(http.StatusOK)
858+
_, _ = w.Write(mock.MustMarshal(orgItem))
859+
return
860+
}
861+
w.WriteHeader(http.StatusBadRequest)
862+
_, _ = w.Write([]byte(`{"message":"unexpected query params"}`))
863+
}),
864+
),
865+
),
866+
requestArgs: map[string]any{
867+
"owner": "octo-org",
868+
"owner_type": "org",
869+
"project_number": float64(123),
870+
"item_id": float64(301),
871+
"fields": []interface{}{"123", "456"},
872+
},
873+
expectedID: 301,
874+
},
817875
{
818876
name: "success user item",
819877
mockedClient: mock.NewMockedHTTPClient(

0 commit comments

Comments
 (0)