@@ -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.
2116type batchItemStatus string
2217
2318const (
@@ -26,21 +21,15 @@ const (
2621 batchItemUnknown batchItemStatus = "unknown"
2722)
2823
29- // batchItemResult is the per-item outcome returned by update_project_items.
3024type 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.
4433type 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.
5946type 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.
7254func 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.
238210func 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.
266234func 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