Skip to content

Commit 85ec9ef

Browse files
committed
Trim batch implementation commentary
Remove redundant implementation narration while retaining comments for non-obvious client and failure semantics. Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com> Copilot-Session: d7dc302d-e6f2-41e9-a2c8-ed598de47067
1 parent 539d125 commit 85ec9ef

6 files changed

Lines changed: 35 additions & 152 deletions

File tree

‎pkg/github/projects.go‎

Lines changed: 2 additions & 8 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
@@ -619,7 +616,7 @@ func ProjectsWrite(t translations.TranslationHelperFunc) inventory.ServerTool {
619616
},
620617
"items": {
621618
Type: "array",
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.",
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.",
623620
Items: updateProjectItemsItemSchema(),
624621
},
625622
"body": {
@@ -1596,9 +1593,6 @@ func buildUpdateProjectItem(ctx context.Context, gqlClient *githubv4.Client, own
15961593
return buildUpdateProjectItemWithCache(ctx, gqlClient, nil, owner, ownerType, projectNumber, input)
15971594
}
15981595

1599-
// buildUpdateProjectItemWithCache is the cache-aware variant of buildUpdateProjectItem.
1600-
// When cache is non-nil, name-based field lookups reuse the cache's in-memory index; when
1601-
// nil, each name lookup performs its own GraphQL round trip (single-item behaviour).
16021596
func buildUpdateProjectItemWithCache(ctx context.Context, gqlClient *githubv4.Client, cache *projectFieldsCache, owner, ownerType string, projectNumber int, input map[string]any) (*github.UpdateProjectItemOptions, error) {
16031597
if input == nil {
16041598
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 {

‎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: 13 additions & 37 deletions
Original file line numberDiff line numberDiff line change
@@ -10,14 +10,8 @@ import (
1010
"github.com/shurcooL/githubv4"
1111
)
1212

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

19-
// batchMutationKind selects which ProjectV2 item field-value mutation an
20-
// aliased request builds.
2115
type batchMutationKind int
2216

2317
const (
@@ -32,33 +26,25 @@ func (k batchMutationKind) fieldName() string {
3226
return "updateProjectV2ItemFieldValue"
3327
}
3428

35-
// projectV2ItemMutationResult is the minimal response shape shared by both
36-
// updateProjectV2ItemFieldValue and clearProjectV2ItemFieldValue: both expose
37-
// a `projectV2Item { id, fullDatabaseId }` field.
3829
type projectV2ItemMutationResult struct {
3930
ProjectV2Item struct {
4031
ID string
4132
FullDatabaseID string `graphql:"fullDatabaseId"`
4233
} `graphql:"projectV2Item"`
4334
}
4435

45-
// reflectedMutationTypeKey identifies a cached reflected mutation struct type,
46-
// keyed only by operation kind and chunk size (never owner/project/field/value).
4736
type reflectedMutationTypeKey struct {
4837
kind batchMutationKind
4938
size int
5039
}
5140

52-
var reflectedMutationTypeCache sync.Map // map[reflectedMutationTypeKey]reflect.Type
41+
var reflectedMutationTypeCache sync.Map
5342

54-
// buildAliasedMutationType returns a reflect.Type for a struct with exactly
55-
// size fields, named Item0..ItemN-1, each tagged with a positional GraphQL
56-
// alias (item0, item1, ...) for kind and a positional input variable
57-
// reference. The pinned shurcooL/githubv4 Client.Mutate always sends its
58-
// third argument under the "input" variable name, so alias 0 must be the only
59-
// mutation referencing $input; aliases 1.. reference $input1, $input2, ...
60-
// supplied via the extra variables map. Struct tags are purely positional:
61-
// no owner, project, item, field, or value data is ever embedded in them.
43+
// Reflected types are cached only by operation and chunk size to bound
44+
// reflect.StructOf's runtime cache; positional names and tags keep request data
45+
// out of type identities. The pinned Client.Mutate binds its third argument to
46+
// $input, so item0 uses $input and later aliases use $input1, $input2, ...
47+
// supplied through the variables map.
6248
func buildAliasedMutationType(kind batchMutationKind, size int) reflect.Type {
6349
key := reflectedMutationTypeKey{kind: kind, size: size}
6450
if cached, ok := reflectedMutationTypeCache.Load(key); ok {
@@ -84,26 +70,17 @@ func buildAliasedMutationType(kind batchMutationKind, size int) reflect.Type {
8470
return actual.(reflect.Type)
8571
}
8672

87-
// mutationAliasOutcome is the per-alias outcome of one executeAliasedMutation call.
8873
type mutationAliasOutcome struct {
89-
// Populated is true when the server returned a non-empty projectV2Item.id
90-
// for this alias, confirming the write happened even if the overall
91-
// request also returned a GraphQL error.
74+
// Populated confirms this alias returned a project item, even when the
75+
// response also contains GraphQL errors.
9276
Populated bool
9377
NodeID string
9478
FullDatabaseID string
9579
}
9680

97-
// executeAliasedMutation sends a single GraphQL request containing up to
98-
// batchMutationWireChunkSize aliased update/clear mutations (one per input),
99-
// using reflect.StructOf to build the request/response shape at runtime; it
100-
// still uses the existing authenticated *githubv4.Client.
101-
//
102-
// The pinned shurcooL/graphql client decodes any partial `data` payload
103-
// before returning the transport/GraphQL error, so outcomes[i].Populated
104-
// reports whether alias i's write is confirmed even when err is non-nil. The
105-
// client discards errors[].path, so a non-nil err cannot be attributed to a
106-
// specific alias; callers must rely on which outcomes are Populated instead.
81+
// The pinned client decodes partial data before returning GraphQL errors but
82+
// discards errors[].path. Populated aliases confirm writes; unpopulated aliases
83+
// remain unknown and must not be retried individually.
10784
func executeAliasedMutation(ctx context.Context, gqlClient *githubv4.Client, kind batchMutationKind, inputs []githubv4.Input) ([]mutationAliasOutcome, error) {
10885
if len(inputs) == 0 {
10986
return nil, nil
@@ -123,7 +100,6 @@ func executeAliasedMutation(ctx context.Context, gqlClient *githubv4.Client, kin
123100
}
124101
}
125102

126-
// Alias 0's input is bound positionally to $input; aliases 1.. use variables.
127103
mutateErr := gqlClient.Mutate(ctx, mutationPtr.Interface(), inputs[0], variables)
128104

129105
outcomes := make([]mutationAliasOutcome, len(inputs))
@@ -142,8 +118,8 @@ func executeAliasedMutation(ctx context.Context, gqlClient *githubv4.Client, kin
142118
return outcomes, mutateErr
143119
}
144120

145-
// isGraphQLResponseError distinguishes the pinned client's unexported
146-
// graphql.errors response type from transport and decoding failures.
121+
// The pinned client's GraphQL response error type is unexported; transport and
122+
// decoding failures must remain distinguishable.
147123
func isGraphQLResponseError(err error) bool {
148124
for err != nil {
149125
errType := reflect.TypeOf(err)

0 commit comments

Comments
 (0)