Skip to content

Commit 38df97f

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 38df97f

7 files changed

Lines changed: 152 additions & 64 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: 51 additions & 35 deletions
Original file line numberDiff line numberDiff line change
@@ -32,10 +32,7 @@ const (
3232
ProjectStatusUpdateCreateFailedError = "failed to create project status update"
3333
ProjectResolveIDFailedError = "failed to resolve project ID"
3434
MaxProjectsPerPage = 50
35-
// MaxProjectItemsPerBatch caps the number of items processed in a single
36-
// update_project_items call. This keeps a batch bounded, prevents runaway
37-
// resolution costs, and matches the practical scale of typical triage sweeps.
38-
MaxProjectItemsPerBatch = 100
35+
maxProjectItemsPerBatch = 100
3936
)
4037

4138
// Method constants for consolidated project tools
@@ -495,6 +492,54 @@ Use this tool to get details about individual projects, project fields, and proj
495492
return tool
496493
}
497494

495+
func updateProjectItemsItemSchema() *jsonschema.Schema {
496+
updatedField := &jsonschema.Schema{
497+
Type: "object",
498+
Description: "Per-item field update. Overrides the top-level 'updated_field'. Same shape as the top-level 'updated_field'.",
499+
}
500+
variant := func(required []string, properties map[string]*jsonschema.Schema) *jsonschema.Schema {
501+
properties["updated_field"] = updatedField
502+
return &jsonschema.Schema{
503+
Type: "object",
504+
AdditionalProperties: &jsonschema.Schema{Not: &jsonschema.Schema{}},
505+
Properties: properties,
506+
Required: required,
507+
}
508+
}
509+
510+
return &jsonschema.Schema{
511+
Type: "object",
512+
OneOf: []*jsonschema.Schema{
513+
variant([]string{"node_id"}, map[string]*jsonschema.Schema{
514+
"node_id": {
515+
Type: "string",
516+
Description: "The project item's GraphQL node ID, as returned by 'list_project_items' or 'add_project_item'.",
517+
},
518+
}),
519+
variant([]string{"item_id"}, map[string]*jsonschema.Schema{
520+
"item_id": {
521+
Type: "integer",
522+
Description: "The numeric project item ID.",
523+
},
524+
}),
525+
variant([]string{"item_owner", "item_repo", "issue_number"}, map[string]*jsonschema.Schema{
526+
"item_owner": {
527+
Type: "string",
528+
Description: "Owner of the repository containing the issue.",
529+
},
530+
"item_repo": {
531+
Type: "string",
532+
Description: "Repository containing the issue.",
533+
},
534+
"issue_number": {
535+
Type: "integer",
536+
Description: "Issue number used to resolve the project item.",
537+
},
538+
}),
539+
},
540+
}
541+
}
542+
498543
// ProjectsWrite returns the tool and handler for modifying GitHub Projects resources.
499544
func ProjectsWrite(t translations.TranslationHelperFunc) inventory.ServerTool {
500545
tool := NewTool(
@@ -571,37 +616,8 @@ func ProjectsWrite(t translations.TranslationHelperFunc) inventory.ServerTool {
571616
},
572617
"items": {
573618
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-
},
619+
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.",
620+
Items: updateProjectItemsItemSchema(),
605621
},
606622
"body": {
607623
Type: "string",

pkg/github/projects_batch.go

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -81,8 +81,8 @@ func updateProjectItemsBatch(ctx context.Context, client *github.Client, gqlClie
8181
if len(itemsRaw) == 0 {
8282
return utils.NewToolResultError("items must contain at least one entry"), nil, nil
8383
}
84-
if len(itemsRaw) > MaxProjectItemsPerBatch {
85-
return utils.NewToolResultError(fmt.Sprintf("items exceeds maximum of %d entries per call (got %d)", MaxProjectItemsPerBatch, len(itemsRaw))), nil, nil
84+
if len(itemsRaw) > maxProjectItemsPerBatch {
85+
return utils.NewToolResultError(fmt.Sprintf("items exceeds maximum of %d entries per call (got %d)", maxProjectItemsPerBatch, len(itemsRaw))), nil, nil
8686
}
8787

8888
var sharedField map[string]any

pkg/github/projects_batch_e2e_test.go

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -57,7 +57,7 @@ func projectIDMatcher(owner string, projectNumber int, projectNodeID string) git
5757
}
5858

5959
func Test_UpdateProjectItemsBatch_TopLevelGuards(t *testing.T) {
60-
tooMany := make([]any, MaxProjectItemsPerBatch+1)
60+
tooMany := make([]any, maxProjectItemsPerBatch+1)
6161
validItem := map[string]any{
6262
"node_id": "PVTI_item1",
6363
"updated_field": map[string]any{"name": "Notes", "value": "hello"},

pkg/github/projects_batch_mutation.go

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -11,7 +11,7 @@ import (
1111
)
1212

1313
// batchMutationWireChunkSize bounds how many aliased mutations are sent per
14-
// GraphQL request. It is smaller than MaxProjectItemsPerBatch (100 items/call)
14+
// GraphQL request. It is smaller than maxProjectItemsPerBatch (100 items/call)
1515
// to leave headroom under typical ~10s gateway/client timeouts. This value is
1616
// provisional, not derived from a measured benchmark against the live API.
1717
const batchMutationWireChunkSize = 20

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)