Skip to content

Commit a7b9cf3

Browse files
committed
Extract aliased project mutation primitive
Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com> Copilot-Session: d7dc302d-e6f2-41e9-a2c8-ed598de47067
1 parent 9a5fb0b commit a7b9cf3

2 files changed

Lines changed: 420 additions & 0 deletions

File tree

Lines changed: 132 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,132 @@
1+
package github
2+
3+
import (
4+
"context"
5+
"errors"
6+
"fmt"
7+
"reflect"
8+
"sync"
9+
10+
"github.com/shurcooL/githubv4"
11+
)
12+
13+
const batchMutationWireChunkSize = 20
14+
15+
type batchMutationKind int
16+
17+
const (
18+
batchMutationUpdate batchMutationKind = iota
19+
batchMutationClear
20+
)
21+
22+
func (k batchMutationKind) fieldName() string {
23+
if k == batchMutationClear {
24+
return "clearProjectV2ItemFieldValue"
25+
}
26+
return "updateProjectV2ItemFieldValue"
27+
}
28+
29+
type projectV2ItemMutationResult struct {
30+
ProjectV2Item struct {
31+
ID string
32+
FullDatabaseID string `graphql:"fullDatabaseId"`
33+
} `graphql:"projectV2Item"`
34+
}
35+
36+
type reflectedMutationTypeKey struct {
37+
kind batchMutationKind
38+
size int
39+
}
40+
41+
var reflectedMutationTypeCache sync.Map
42+
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.
48+
func buildAliasedMutationType(kind batchMutationKind, size int) reflect.Type {
49+
key := reflectedMutationTypeKey{kind: kind, size: size}
50+
if cached, ok := reflectedMutationTypeCache.Load(key); ok {
51+
return cached.(reflect.Type)
52+
}
53+
54+
resultType := reflect.TypeFor[projectV2ItemMutationResult]()
55+
fields := make([]reflect.StructField, size)
56+
for i := range size {
57+
varName := "input"
58+
if i > 0 {
59+
varName = fmt.Sprintf("input%d", i)
60+
}
61+
fields[i] = reflect.StructField{
62+
Name: fmt.Sprintf("Item%d", i),
63+
Type: resultType,
64+
Tag: reflect.StructTag(fmt.Sprintf(`graphql:"item%d: %s(input: $%s)"`, i, kind.fieldName(), varName)),
65+
}
66+
}
67+
68+
t := reflect.StructOf(fields)
69+
actual, _ := reflectedMutationTypeCache.LoadOrStore(key, t)
70+
return actual.(reflect.Type)
71+
}
72+
73+
type mutationAliasOutcome struct {
74+
// Populated confirms this alias returned a project item, even when the
75+
// response also contains GraphQL errors.
76+
Populated bool
77+
NodeID string
78+
FullDatabaseID string
79+
}
80+
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.
84+
func executeAliasedMutation(ctx context.Context, gqlClient *githubv4.Client, kind batchMutationKind, inputs []githubv4.Input) ([]mutationAliasOutcome, error) {
85+
if len(inputs) == 0 {
86+
return nil, nil
87+
}
88+
if len(inputs) > batchMutationWireChunkSize {
89+
return nil, fmt.Errorf("internal error: chunk of %d exceeds wire chunk size %d", len(inputs), batchMutationWireChunkSize)
90+
}
91+
92+
mutationType := buildAliasedMutationType(kind, len(inputs))
93+
mutationPtr := reflect.New(mutationType)
94+
95+
var variables map[string]any
96+
if len(inputs) > 1 {
97+
variables = make(map[string]any, len(inputs)-1)
98+
for i := 1; i < len(inputs); i++ {
99+
variables[fmt.Sprintf("input%d", i)] = inputs[i]
100+
}
101+
}
102+
103+
mutateErr := gqlClient.Mutate(ctx, mutationPtr.Interface(), inputs[0], variables)
104+
105+
outcomes := make([]mutationAliasOutcome, len(inputs))
106+
elem := mutationPtr.Elem()
107+
for i := range inputs {
108+
result, ok := elem.Field(i).Interface().(projectV2ItemMutationResult)
109+
if !ok || result.ProjectV2Item.ID == "" {
110+
continue
111+
}
112+
outcomes[i] = mutationAliasOutcome{
113+
Populated: true,
114+
NodeID: result.ProjectV2Item.ID,
115+
FullDatabaseID: result.ProjectV2Item.FullDatabaseID,
116+
}
117+
}
118+
return outcomes, mutateErr
119+
}
120+
121+
// The pinned client's GraphQL response error type is unexported; transport and
122+
// decoding failures must remain distinguishable.
123+
func isGraphQLResponseError(err error) bool {
124+
for err != nil {
125+
errType := reflect.TypeOf(err)
126+
if errType.PkgPath() == "github.com/shurcooL/graphql" && errType.Name() == "errors" {
127+
return true
128+
}
129+
err = errors.Unwrap(err)
130+
}
131+
return false
132+
}

0 commit comments

Comments
 (0)