Skip to content

Commit 539d125

Browse files
zwickCopilot
andcommitted
Express batch item references with oneOf
Model node, numeric, and issue references as closed schema variants while retaining runtime validation. Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com> Copilot-Session: 7ae767ff-c1d0-46a9-b126-2e91403993a0
1 parent f2475e6 commit 539d125

4 files changed

Lines changed: 147 additions & 56 deletions

File tree

README.md

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1119,7 +1119,7 @@ The following sets of tools are available:
11191119
- `item_owner`: The owner (user or organization) of the repository containing the issue or pull request. Required for 'add_project_item' method. Also accepted by 'update_project_item' when resolving the item by issue number. (string, optional)
11201120
- `item_repo`: The name of the repository containing the issue or pull request. Required for 'add_project_item' method. Also accepted by 'update_project_item' when resolving the item by issue number. (string, optional)
11211121
- `item_type`: The item's type, either issue or pull_request. Required for 'add_project_item' method. (string, optional)
1122-
- `items`: The set of items to update. Required for 'update_project_items'. Each entry references an existing item by exactly one of: 'node_id' (the item's GraphQL node ID, e.g. as returned by 'list_project_items' or 'add_project_item'), 'item_id' (the numeric project item ID), or (item_owner + item_repo + issue_number). Each entry may optionally include its own 'updated_field'; if omitted, the top-level 'updated_field' is applied. Limit: 100 items per call. (object[], optional)
1122+
- `items`: The items to update. Required for 'update_project_items'. Each entry must match exactly one reference variant: 'node_id', numeric 'item_id', or 'item_owner' + 'item_repo' + 'issue_number'. Each entry may include 'updated_field'; otherwise the top-level 'updated_field' applies. Limit: 100 items per call. (object[], optional)
11231123
- `iteration_duration`: Duration in days for iterations of the field (e.g. 7 for weekly, 14 for bi-weekly). Required for 'create_iteration_field' method. (number, optional)
11241124
- `iterations`: Custom iterations for 'create_iteration_field' method. Only set this when you need iterations with varying durations, breaks between them, or specific titles. Otherwise omit it: GitHub auto-creates three iterations of 'iteration_duration' days starting on 'start_date', which is the right choice for most cases. (object[], optional)
11251125
- `method`: The method to execute (string, required)

pkg/github/__toolsnaps__/projects_write.snap

Lines changed: 60 additions & 24 deletions
Original file line numberDiff line numberDiff line change
@@ -41,35 +41,71 @@
4141
"type": "string"
4242
},
4343
"items": {
44-
"description": "The set of items to update. Required for 'update_project_items'. Each entry references an existing item by exactly one of: 'node_id' (the item's GraphQL node ID, e.g. as returned by 'list_project_items' or 'add_project_item'), 'item_id' (the numeric project item ID), or (item_owner + item_repo + issue_number). Each entry may optionally include its own 'updated_field'; if omitted, the top-level 'updated_field' is applied. Limit: 100 items per call.",
44+
"description": "The items to update. Required for 'update_project_items'. Each entry must match exactly one reference variant: 'node_id', numeric 'item_id', or 'item_owner' + 'item_repo' + 'issue_number'. Each entry may include 'updated_field'; otherwise the top-level 'updated_field' applies. Limit: 100 items per call.",
4545
"items": {
46-
"additionalProperties": false,
47-
"properties": {
48-
"issue_number": {
49-
"description": "Issue number used to resolve the project item together with item_owner and item_repo.",
50-
"type": "integer"
51-
},
52-
"item_id": {
53-
"description": "The project item ID. Provide exactly one of node_id, item_id, or (item_owner + item_repo + issue_number).",
54-
"type": "integer"
55-
},
56-
"item_owner": {
57-
"description": "Owner of the repository containing the issue. Combine with item_repo and issue_number to resolve the item.",
58-
"type": "string"
59-
},
60-
"item_repo": {
61-
"description": "Repository containing the issue. Combine with item_owner and issue_number to resolve the item.",
62-
"type": "string"
46+
"oneOf": [
47+
{
48+
"additionalProperties": false,
49+
"properties": {
50+
"node_id": {
51+
"description": "The project item's GraphQL node ID, as returned by 'list_project_items' or 'add_project_item'.",
52+
"type": "string"
53+
},
54+
"updated_field": {
55+
"description": "Per-item field update. Overrides the top-level 'updated_field'. Same shape as the top-level 'updated_field'.",
56+
"type": "object"
57+
}
58+
},
59+
"required": [
60+
"node_id"
61+
],
62+
"type": "object"
6363
},
64-
"node_id": {
65-
"description": "The project item's GraphQL node ID. Provide exactly one of node_id, item_id, or (item_owner + item_repo + issue_number).",
66-
"type": "string"
64+
{
65+
"additionalProperties": false,
66+
"properties": {
67+
"item_id": {
68+
"description": "The numeric project item ID.",
69+
"type": "integer"
70+
},
71+
"updated_field": {
72+
"description": "Per-item field update. Overrides the top-level 'updated_field'. Same shape as the top-level 'updated_field'.",
73+
"type": "object"
74+
}
75+
},
76+
"required": [
77+
"item_id"
78+
],
79+
"type": "object"
6780
},
68-
"updated_field": {
69-
"description": "Per-item field update. Overrides the top-level 'updated_field'. Same shape as the top-level 'updated_field'.",
81+
{
82+
"additionalProperties": false,
83+
"properties": {
84+
"issue_number": {
85+
"description": "Issue number used to resolve the project item.",
86+
"type": "integer"
87+
},
88+
"item_owner": {
89+
"description": "Owner of the repository containing the issue.",
90+
"type": "string"
91+
},
92+
"item_repo": {
93+
"description": "Repository containing the issue.",
94+
"type": "string"
95+
},
96+
"updated_field": {
97+
"description": "Per-item field update. Overrides the top-level 'updated_field'. Same shape as the top-level 'updated_field'.",
98+
"type": "object"
99+
}
100+
},
101+
"required": [
102+
"item_owner",
103+
"item_repo",
104+
"issue_number"
105+
],
70106
"type": "object"
71107
}
72-
},
108+
],
73109
"type": "object"
74110
},
75111
"type": "array"

pkg/github/projects.go

Lines changed: 50 additions & 31 deletions
Original file line numberDiff line numberDiff line change
@@ -495,6 +495,54 @@ Use this tool to get details about individual projects, project fields, and proj
495495
return tool
496496
}
497497

498+
func updateProjectItemsItemSchema() *jsonschema.Schema {
499+
updatedField := &jsonschema.Schema{
500+
Type: "object",
501+
Description: "Per-item field update. Overrides the top-level 'updated_field'. Same shape as the top-level 'updated_field'.",
502+
}
503+
variant := func(required []string, properties map[string]*jsonschema.Schema) *jsonschema.Schema {
504+
properties["updated_field"] = updatedField
505+
return &jsonschema.Schema{
506+
Type: "object",
507+
AdditionalProperties: &jsonschema.Schema{Not: &jsonschema.Schema{}},
508+
Properties: properties,
509+
Required: required,
510+
}
511+
}
512+
513+
return &jsonschema.Schema{
514+
Type: "object",
515+
OneOf: []*jsonschema.Schema{
516+
variant([]string{"node_id"}, map[string]*jsonschema.Schema{
517+
"node_id": {
518+
Type: "string",
519+
Description: "The project item's GraphQL node ID, as returned by 'list_project_items' or 'add_project_item'.",
520+
},
521+
}),
522+
variant([]string{"item_id"}, map[string]*jsonschema.Schema{
523+
"item_id": {
524+
Type: "integer",
525+
Description: "The numeric project item ID.",
526+
},
527+
}),
528+
variant([]string{"item_owner", "item_repo", "issue_number"}, map[string]*jsonschema.Schema{
529+
"item_owner": {
530+
Type: "string",
531+
Description: "Owner of the repository containing the issue.",
532+
},
533+
"item_repo": {
534+
Type: "string",
535+
Description: "Repository containing the issue.",
536+
},
537+
"issue_number": {
538+
Type: "integer",
539+
Description: "Issue number used to resolve the project item.",
540+
},
541+
}),
542+
},
543+
}
544+
}
545+
498546
// ProjectsWrite returns the tool and handler for modifying GitHub Projects resources.
499547
func ProjectsWrite(t translations.TranslationHelperFunc) inventory.ServerTool {
500548
tool := NewTool(
@@ -571,37 +619,8 @@ func ProjectsWrite(t translations.TranslationHelperFunc) inventory.ServerTool {
571619
},
572620
"items": {
573621
Type: "array",
574-
Description: "The set of items to update. Required for 'update_project_items'. Each entry references an existing item by exactly one of: 'node_id' (the item's GraphQL node ID, e.g. as returned by 'list_project_items' or 'add_project_item'), 'item_id' (the numeric project item ID), or (item_owner + item_repo + issue_number). Each entry may optionally include its own 'updated_field'; if omitted, the top-level 'updated_field' is applied. Limit: " + strconv.Itoa(MaxProjectItemsPerBatch) + " items per call.",
575-
Items: &jsonschema.Schema{
576-
Type: "object",
577-
AdditionalProperties: &jsonschema.Schema{Not: &jsonschema.Schema{}},
578-
Properties: map[string]*jsonschema.Schema{
579-
"node_id": {
580-
Type: "string",
581-
Description: "The project item's GraphQL node ID. Provide exactly one of node_id, item_id, or (item_owner + item_repo + issue_number).",
582-
},
583-
"item_id": {
584-
Type: "integer",
585-
Description: "The project item ID. Provide exactly one of node_id, item_id, or (item_owner + item_repo + issue_number).",
586-
},
587-
"item_owner": {
588-
Type: "string",
589-
Description: "Owner of the repository containing the issue. Combine with item_repo and issue_number to resolve the item.",
590-
},
591-
"item_repo": {
592-
Type: "string",
593-
Description: "Repository containing the issue. Combine with item_owner and issue_number to resolve the item.",
594-
},
595-
"issue_number": {
596-
Type: "integer",
597-
Description: "Issue number used to resolve the project item together with item_owner and item_repo.",
598-
},
599-
"updated_field": {
600-
Type: "object",
601-
Description: "Per-item field update. Overrides the top-level 'updated_field'. Same shape as the top-level 'updated_field'.",
602-
},
603-
},
604-
},
622+
Description: "The items to update. Required for 'update_project_items'. Each entry must match exactly one reference variant: 'node_id', numeric 'item_id', or 'item_owner' + 'item_repo' + 'issue_number'. Each entry may include 'updated_field'; otherwise the top-level 'updated_field' applies. Limit: " + strconv.Itoa(MaxProjectItemsPerBatch) + " items per call.",
623+
Items: updateProjectItemsItemSchema(),
605624
},
606625
"body": {
607626
Type: "string",

pkg/github/projects_test.go

Lines changed: 36 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -888,6 +888,42 @@ func Test_ProjectsWrite(t *testing.T) {
888888
assert.True(t, *toolDef.Tool.Annotations.DestructiveHint)
889889
}
890890

891+
func Test_ProjectsWrite_UpdateProjectItemsItemSchema(t *testing.T) {
892+
inputSchema := ProjectsWrite(translations.NullTranslationHelper).Tool.InputSchema.(*jsonschema.Schema)
893+
itemSchema := inputSchema.Properties["items"].Items
894+
895+
assert.Equal(t, "object", itemSchema.Type)
896+
assert.Empty(t, itemSchema.Properties, "item references should be modeled by oneOf, not flattened properties")
897+
require.Len(t, itemSchema.OneOf, 3)
898+
899+
expectedRequired := [][]string{
900+
{"node_id"},
901+
{"item_id"},
902+
{"item_owner", "item_repo", "issue_number"},
903+
}
904+
expectedProperties := [][]string{
905+
{"node_id", "updated_field"},
906+
{"item_id", "updated_field"},
907+
{"item_owner", "item_repo", "issue_number", "updated_field"},
908+
}
909+
for i, variant := range itemSchema.OneOf {
910+
properties := make([]string, 0, len(variant.Properties))
911+
for name := range variant.Properties {
912+
properties = append(properties, name)
913+
}
914+
assert.Equal(t, "object", variant.Type)
915+
assert.ElementsMatch(t, expectedRequired[i], variant.Required)
916+
assert.ElementsMatch(t, expectedProperties[i], properties)
917+
assert.Contains(t, variant.Properties, "updated_field")
918+
for _, property := range variant.Properties {
919+
assert.NotEmpty(t, property.Type)
920+
assert.NotEmpty(t, property.Description)
921+
}
922+
require.NotNil(t, variant.AdditionalProperties)
923+
assert.NotNil(t, variant.AdditionalProperties.Not, "variant must reject additional properties")
924+
}
925+
}
926+
891927
func Test_ProjectsWrite_AddProjectItem(t *testing.T) {
892928
toolDef := ProjectsWrite(translations.NullTranslationHelper)
893929

0 commit comments

Comments
 (0)