Skip to content

Commit ad9f0e4

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 ad9f0e4

9 files changed

Lines changed: 181 additions & 207 deletions

‎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 & 38 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",
@@ -1577,9 +1593,6 @@ func buildUpdateProjectItem(ctx context.Context, gqlClient *githubv4.Client, own
15771593
return buildUpdateProjectItemWithCache(ctx, gqlClient, nil, owner, ownerType, projectNumber, input)
15781594
}
15791595

1580-
// buildUpdateProjectItemWithCache is the cache-aware variant of buildUpdateProjectItem.
1581-
// When cache is non-nil, name-based field lookups reuse the cache's in-memory index; when
1582-
// nil, each name lookup performs its own GraphQL round trip (single-item behaviour).
15831596
func buildUpdateProjectItemWithCache(ctx context.Context, gqlClient *githubv4.Client, cache *projectFieldsCache, owner, ownerType string, projectNumber int, input map[string]any) (*github.UpdateProjectItemOptions, error) {
15841597
if input == nil {
15851598
return nil, fmt.Errorf("updated_field must be an object")

‎pkg/github/projects_batch.go‎

Lines changed: 11 additions & 43 deletions
Original file line numberDiff line numberDiff line change
@@ -11,13 +11,8 @@ import (
1111
"github.com/shurcooL/githubv4"
1212
)
1313

14-
// batchItemStatus is the tri-state outcome of one item in update_project_items.
15-
// - succeeded: the item's alias came back populated, confirming the write.
16-
// - failed: a deterministic failure before any mutation was sent (invalid
17-
// input, resolution failure, value-conversion failure, duplicate target).
18-
// - unknown: a mutation touching this item was not confirmed, or was never
19-
// sent after an earlier systemic failure. The pinned client drops
20-
// errors[].path, so we don't retry or issue a per-item fallback mutation.
14+
// Unknown outcomes cannot be attributed or retried safely because the pinned
15+
// client drops errors[].path.
2116
type batchItemStatus string
2217

2318
const (
@@ -26,21 +21,15 @@ const (
2621
batchItemUnknown batchItemStatus = "unknown"
2722
)
2823

29-
// batchItemResult is the per-item outcome returned by update_project_items.
3024
type batchItemResult struct {
3125
Index int `json:"index"`
3226
Status batchItemStatus `json:"status"`
3327
Item *batchItemIdentity `json:"item,omitempty"`
3428
Error *batchItemError `json:"error,omitempty"`
35-
// Ref echoes the identifying input for the item so agents can correlate results
36-
// with the request even when resolution fails.
29+
// Ref preserves the request identity when resolution fails.
3730
Ref map[string]any `json:"ref,omitempty"`
3831
}
3932

40-
// batchItemIdentity is the minimal item identity echoed back for a confirmed
41-
// write: the mutation response's `projectV2Item { id, fullDatabaseId }`, plus
42-
// the numeric item_id the request used (when known), so callers can correlate
43-
// without a follow-up read.
4433
type batchItemIdentity struct {
4534
NodeID string `json:"node_id,omitempty"`
4635
FullDatabaseID string `json:"full_database_id,omitempty"`
@@ -54,21 +43,14 @@ type batchItemError struct {
5443
Hint string `json:"hint,omitempty"`
5544
}
5645

57-
// resolvedBatchItem is a fully-resolved, validated write, ready to be included
58-
// in an aliased update or clear mutation.
5946
type resolvedBatchItem struct {
6047
index int
6148
nodeID string
6249
fullDatabaseID int64
6350
fieldNodeID string
64-
value githubv4.ProjectV2FieldValue // unused when this is a clear
51+
value githubv4.ProjectV2FieldValue
6552
}
6653

67-
// updateProjectItemsBatch handles the update_project_items method. It resolves
68-
// the project, every distinct field, and every item once up front, then writes
69-
// via chunked, aliased updateProjectV2ItemFieldValue / clearProjectV2ItemFieldValue
70-
// GraphQL mutations (pkg/github/projects_batch_mutation.go) instead of one REST
71-
// PATCH per item.
7254
func updateProjectItemsBatch(ctx context.Context, client *github.Client, gqlClient *githubv4.Client, owner, ownerType string, projectNumber int, args map[string]any) (*mcp.CallToolResult, any, error) {
7355
rawItems, exists := args["items"]
7456
if !exists {
@@ -81,8 +63,8 @@ func updateProjectItemsBatch(ctx context.Context, client *github.Client, gqlClie
8163
if len(itemsRaw) == 0 {
8264
return utils.NewToolResultError("items must contain at least one entry"), nil, nil
8365
}
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
66+
if len(itemsRaw) > maxProjectItemsPerBatch {
67+
return utils.NewToolResultError(fmt.Sprintf("items exceeds maximum of %d entries per call (got %d)", maxProjectItemsPerBatch, len(itemsRaw))), nil, nil
8668
}
8769

8870
var sharedField map[string]any
@@ -116,17 +98,14 @@ func updateProjectItemsBatch(ctx context.Context, client *github.Client, gqlClie
11698
return newUpdateProjectItemsResult(results)
11799
}
118100

119-
// Resolve the project node ID once; every mutation input needs it.
120101
projectID, err := resolveProjectNodeID(ctx, gqlClient, owner, ownerType, projectNumber)
121102
if err != nil {
122103
return utils.NewToolResultError(err.Error()), nil, nil
123104
}
124105

125-
// Resolve every distinct effective field once.
126106
cache := newProjectFieldsCache()
127107
fieldResolutions := resolveDistinctFields(ctx, gqlClient, cache, owner, ownerType, projectNumber, parsed)
128108

129-
// Resolve numeric item_id -> node id, deduplicated with bounded concurrency.
130109
var numericIDs []int64
131110
for _, p := range parsed {
132111
if p.err == nil && p.refKind == batchRefItemID {
@@ -135,16 +114,14 @@ func updateProjectItemsBatch(ctx context.Context, client *github.Client, gqlClie
135114
}
136115
itemIDLookups := resolveItemNodeIDsByNumericID(ctx, client, owner, ownerType, projectNumber, numericIDs)
137116

138-
// Resolve issue-ref items, deduplicated.
139117
issueLookups := resolveIssueRefs(ctx, gqlClient, projectID, parsed)
140118

141-
// Build the final resolved plan, rejecting duplicate item+field targets.
142119
var updates, clears []resolvedBatchItem
143-
seenTargets := make(map[string]int) // "nodeID|fieldNodeID" -> first index that claimed it
120+
seenTargets := make(map[string]int)
144121

145122
for i, p := range parsed {
146123
if p.err != nil {
147-
continue // deterministic parse failure already recorded above
124+
continue
148125
}
149126

150127
fr := fieldResolutions[p.fieldKey]
@@ -187,9 +164,7 @@ func updateProjectItemsBatch(ctx context.Context, client *github.Client, gqlClie
187164
updates = append(updates, resolvedBatchItem{index: i, nodeID: nodeID, fullDatabaseID: fullDatabaseID, fieldNodeID: fr.field.NodeID, value: value})
188165
}
189166

190-
// Execute update mutations, then clear mutations, in sequential chunks.
191-
// Clears never run if the update phase aborted: we do not know whether an
192-
// ambiguous failure landed, so we do not risk further writes this call.
167+
// Skip clears after an ambiguous update failure because an earlier write may have landed.
193168
aborted := executeBatchPartition(ctx, gqlClient, batchMutationUpdate, projectID, updates, results, parsed)
194169
if !aborted {
195170
executeBatchPartition(ctx, gqlClient, batchMutationClear, projectID, clears, results, parsed)
@@ -232,9 +207,6 @@ func newUpdateProjectItemsResult(results []batchItemResult) (*mcp.CallToolResult
232207
return result, nil, nil
233208
}
234209

235-
// resolveItemReference maps a parsed item's reference to its project item node
236-
// ID and (when known) numeric full database ID, using the pre-computed lookup
237-
// tables for the item_id and issue-ref cases.
238210
func resolveItemReference(p parsedBatchItem, itemIDLookups map[int64]itemLookupResult, issueLookups map[issueRefKey]itemLookupResult) (nodeID string, fullDatabaseID int64, err error) {
239211
switch p.refKind {
240212
case batchRefNodeID:
@@ -257,12 +229,8 @@ func resolveItemReference(p parsedBatchItem, itemIDLookups map[int64]itemLookupR
257229
}
258230
}
259231

260-
// executeBatchPartition executes a single partition (all updates, or all
261-
// clears) in sequential chunks of at most batchMutationWireChunkSize. It
262-
// writes outcomes directly into results (keyed by original index) and returns
263-
// true if execution was aborted before every item in the partition was
264-
// attempted, because an earlier chunk's failure looked systemic (transport
265-
// failure or context cancellation) rather than a per-mutation GraphQL error.
232+
// Transport, cancellation, or incomplete-data ambiguity stops later chunks;
233+
// GraphQL response errors do not because populated aliases still confirm writes.
266234
func executeBatchPartition(ctx context.Context, gqlClient *githubv4.Client, kind batchMutationKind, projectID githubv4.ID, items []resolvedBatchItem, results []batchItemResult, parsed []parsedBatchItem) bool {
267235
for start := 0; start < len(items); start += batchMutationWireChunkSize {
268236
if ctx.Err() != nil {

0 commit comments

Comments
 (0)