Skip to content
Draft
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
132 changes: 132 additions & 0 deletions pkg/github/projects_batch_mutation.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,132 @@
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
}
Loading