4ed4f816cc
* Extract aliased project mutation primitive Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com> Copilot-Session: d7dc302d-e6f2-41e9-a2c8-ed598de47067 * Test partial GraphQL mutation data Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com> Copilot-Session: 4d3df48a-5aa0-4cf0-a067-4aa5618c2887 --------- Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com>
133 lines
3.8 KiB
Go
133 lines
3.8 KiB
Go
package github
|
|
|
|
import (
|
|
"context"
|
|
"errors"
|
|
"fmt"
|
|
"reflect"
|
|
"sync"
|
|
|
|
"github.com/shurcooL/githubv4"
|
|
)
|
|
|
|
const batchMutationWireChunkSize = 20
|
|
|
|
type batchMutationKind int
|
|
|
|
const (
|
|
batchMutationUpdate batchMutationKind = iota
|
|
batchMutationClear
|
|
)
|
|
|
|
func (k batchMutationKind) fieldName() string {
|
|
if k == batchMutationClear {
|
|
return "clearProjectV2ItemFieldValue"
|
|
}
|
|
return "updateProjectV2ItemFieldValue"
|
|
}
|
|
|
|
type projectV2ItemMutationResult struct {
|
|
ProjectV2Item struct {
|
|
ID string
|
|
FullDatabaseID string `graphql:"fullDatabaseId"`
|
|
} `graphql:"projectV2Item"`
|
|
}
|
|
|
|
type reflectedMutationTypeKey struct {
|
|
kind batchMutationKind
|
|
size int
|
|
}
|
|
|
|
var reflectedMutationTypeCache sync.Map
|
|
|
|
// Reflected types are cached only by operation and chunk size to bound
|
|
// reflect.StructOf's runtime cache; positional names and tags keep request data
|
|
// out of type identities. The pinned Client.Mutate binds its third argument to
|
|
// $input, so item0 uses $input and later aliases use $input1, $input2, ...
|
|
// supplied through the variables map.
|
|
func buildAliasedMutationType(kind batchMutationKind, size int) reflect.Type {
|
|
key := reflectedMutationTypeKey{kind: kind, size: size}
|
|
if cached, ok := reflectedMutationTypeCache.Load(key); ok {
|
|
return cached.(reflect.Type)
|
|
}
|
|
|
|
resultType := reflect.TypeFor[projectV2ItemMutationResult]()
|
|
fields := make([]reflect.StructField, size)
|
|
for i := range size {
|
|
varName := "input"
|
|
if i > 0 {
|
|
varName = fmt.Sprintf("input%d", i)
|
|
}
|
|
fields[i] = reflect.StructField{
|
|
Name: fmt.Sprintf("Item%d", i),
|
|
Type: resultType,
|
|
Tag: reflect.StructTag(fmt.Sprintf(`graphql:"item%d: %s(input: $%s)"`, i, kind.fieldName(), varName)),
|
|
}
|
|
}
|
|
|
|
t := reflect.StructOf(fields)
|
|
actual, _ := reflectedMutationTypeCache.LoadOrStore(key, t)
|
|
return actual.(reflect.Type)
|
|
}
|
|
|
|
type mutationAliasOutcome struct {
|
|
// Populated confirms this alias returned a project item, even when the
|
|
// response also contains GraphQL errors.
|
|
Populated bool
|
|
NodeID string
|
|
FullDatabaseID string
|
|
}
|
|
|
|
// The pinned client decodes partial data before returning GraphQL errors but
|
|
// discards errors[].path. Populated aliases confirm writes; unpopulated aliases
|
|
// remain unknown and must not be retried individually.
|
|
func executeAliasedMutation(ctx context.Context, gqlClient *githubv4.Client, kind batchMutationKind, inputs []githubv4.Input) ([]mutationAliasOutcome, error) {
|
|
if len(inputs) == 0 {
|
|
return nil, nil
|
|
}
|
|
if len(inputs) > batchMutationWireChunkSize {
|
|
return nil, fmt.Errorf("internal error: chunk of %d exceeds wire chunk size %d", len(inputs), batchMutationWireChunkSize)
|
|
}
|
|
|
|
mutationType := buildAliasedMutationType(kind, len(inputs))
|
|
mutationPtr := reflect.New(mutationType)
|
|
|
|
var variables map[string]any
|
|
if len(inputs) > 1 {
|
|
variables = make(map[string]any, len(inputs)-1)
|
|
for i := 1; i < len(inputs); i++ {
|
|
variables[fmt.Sprintf("input%d", i)] = inputs[i]
|
|
}
|
|
}
|
|
|
|
mutateErr := gqlClient.Mutate(ctx, mutationPtr.Interface(), inputs[0], variables)
|
|
|
|
outcomes := make([]mutationAliasOutcome, len(inputs))
|
|
elem := mutationPtr.Elem()
|
|
for i := range inputs {
|
|
result, ok := elem.Field(i).Interface().(projectV2ItemMutationResult)
|
|
if !ok || result.ProjectV2Item.ID == "" {
|
|
continue
|
|
}
|
|
outcomes[i] = mutationAliasOutcome{
|
|
Populated: true,
|
|
NodeID: result.ProjectV2Item.ID,
|
|
FullDatabaseID: result.ProjectV2Item.FullDatabaseID,
|
|
}
|
|
}
|
|
return outcomes, mutateErr
|
|
}
|
|
|
|
// The pinned client's GraphQL response error type is unexported; transport and
|
|
// decoding failures must remain distinguishable.
|
|
func isGraphQLResponseError(err error) bool {
|
|
for err != nil {
|
|
errType := reflect.TypeOf(err)
|
|
if errType.PkgPath() == "github.com/shurcooL/graphql" && errType.Name() == "errors" {
|
|
return true
|
|
}
|
|
err = errors.Unwrap(err)
|
|
}
|
|
return false
|
|
}
|