Apply deferred update mutations in parallel for gh issue edit

The Issues 2.0 mutations (issue type, parent set/remove, sub-issues,
blocked-by, blocking) are deferred until after the main UpdateIssue
because they target IDs that the standard mutation does not handle.

Move them behind a shared api.DeferredUpdateIssue orchestrator that
fans them out in parallel and joins all errors so a single failure
does not abort the rest.

editRun no longer carries its own applyEditParent / applyEditSubIssues
/ applyEditRelationships helpers; the per-issue goroutine resolves
refs to node IDs via a small deferredUpdateIssueOptions builder, then
hands the populated DeferredUpdateIssueOptions to api.DeferredUpdateIssue.

Also moves ResolveIssueRef and ResolveIssueTypeName from the deleted
resolve.go into lookup.go.

Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
This commit is contained in:
Kynan Ware 2026-05-12 19:38:55 -06:00
parent 315dafbf74
commit 02457482a5
5 changed files with 261 additions and 182 deletions

View file

@ -2,7 +2,9 @@ package edit
import (
"bytes"
"encoding/json"
"fmt"
"io"
"net/http"
"os"
"path/filepath"
@ -1052,34 +1054,31 @@ func Test_editRun(t *testing.T) {
httpStubs: func(t *testing.T, reg *httpmock.Registry) {
mockIssueNumberGet(t, reg, 100)
reg.Register(
httpmock.GraphQL(`query IssueNodeID\b`),
httpmock.StringResponse(`
{ "data": { "repository": { "issue": { "id": "SUB_123_ID" } } } }
`),
issueNodeIDByNumberMatcher(123),
httpmock.StringResponse(`{ "data": { "repository": { "issue": { "id": "SUB_123_ID" } } } }`),
)
reg.Register(
httpmock.GraphQL(`mutation AddSubIssue\b`),
httpmock.GraphQLMutation(`
{ "data": { "addSubIssue": { "issue": { "id": "100" } } } }`,
issueNodeIDByNumberMatcher(124),
httpmock.StringResponse(`{ "data": { "repository": { "issue": { "id": "SUB_124_ID" } } } }`),
)
reg.Register(
httpmock.GraphQLMutationMatcher(`mutation AddSubIssue\b`, func(input map[string]interface{}) bool {
return input["subIssueId"] == "SUB_123_ID"
}),
httpmock.GraphQLMutation(`{ "data": { "addSubIssue": { "issue": { "id": "100" } } } }`,
func(inputs map[string]interface{}) {
assert.Equal(t, "100", inputs["issueId"])
assert.Equal(t, "SUB_123_ID", inputs["subIssueId"])
assert.Equal(t, false, inputs["replaceParent"])
}),
)
reg.Register(
httpmock.GraphQL(`query IssueNodeID\b`),
httpmock.StringResponse(`
{ "data": { "repository": { "issue": { "id": "SUB_124_ID" } } } }
`),
)
reg.Register(
httpmock.GraphQL(`mutation AddSubIssue\b`),
httpmock.GraphQLMutation(`
{ "data": { "addSubIssue": { "issue": { "id": "100" } } } }`,
httpmock.GraphQLMutationMatcher(`mutation AddSubIssue\b`, func(input map[string]interface{}) bool {
return input["subIssueId"] == "SUB_124_ID"
}),
httpmock.GraphQLMutation(`{ "data": { "addSubIssue": { "issue": { "id": "100" } } } }`,
func(inputs map[string]interface{}) {
assert.Equal(t, "100", inputs["issueId"])
assert.Equal(t, "SUB_124_ID", inputs["subIssueId"])
assert.Equal(t, false, inputs["replaceParent"])
}),
)
},
@ -1732,3 +1731,30 @@ func TestProjectsV1Deprecation(t *testing.T) {
reg.Verify(t)
})
}
// issueNodeIDByNumberMatcher matches an IssueNodeID GraphQL query whose
// number variable equals the given value. Used by tests that issue
// multiple IssueNodeID lookups and need stubs to route by issue number
// rather than by registration order.
func issueNodeIDByNumberMatcher(number int) httpmock.Matcher {
queryMatcher := httpmock.GraphQL(`query IssueNodeID\b`)
return func(req *http.Request) bool {
if !queryMatcher(req) {
return false
}
body, err := io.ReadAll(req.Body)
if err != nil {
return false
}
req.Body = io.NopCloser(bytes.NewReader(body))
var b struct {
Variables struct {
Number int `json:"number"`
} `json:"variables"`
}
if err := json.Unmarshal(body, &b); err != nil {
return false
}
return b.Variables.Number == number
}
}