Skip to content

Commit a387c39

Browse files
zwickCopilot
andcommitted
Resolve issue references concurrently
Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com> Copilot-Session: 7ae767ff-c1d0-46a9-b126-2e91403993a0 Copilot-Session: 5709a470-df75-43ec-9a9c-98868e6065d2
1 parent 8681029 commit a387c39

2 files changed

Lines changed: 270 additions & 4 deletions

File tree

pkg/github/projects_batch.go

Lines changed: 45 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -739,18 +739,59 @@ type issueRefKey struct {
739739
}
740740

741741
func resolveIssueRefs(ctx context.Context, gqlClient *githubv4.Client, projectID githubv4.ID, items []parsedBatchItem) map[issueRefKey]itemLookupResult {
742-
out := make(map[issueRefKey]itemLookupResult)
742+
seen := make(map[issueRefKey]struct{}, len(items))
743+
var unique []issueRefKey
743744
for _, it := range items {
744745
if it.err != nil || it.refKind != batchRefIssue {
745746
continue
746747
}
747748
key := issueRefKey{owner: it.issueOwner, repo: it.issueRepo, number: it.issueNumber}
748-
if _, done := out[key]; done {
749+
if _, dup := seen[key]; dup {
749750
continue
750751
}
751-
nodeID, itemID, err := resolveProjectItemByIssueNumberWithProjectID(ctx, gqlClient, projectID, it.issueOwner, it.issueRepo, it.issueNumber)
752-
out[key] = itemLookupResult{nodeID: nodeID, fullDatabaseID: itemID, err: err}
752+
seen[key] = struct{}{}
753+
unique = append(unique, key)
753754
}
755+
756+
out := make(map[issueRefKey]itemLookupResult, len(unique))
757+
if len(unique) == 0 {
758+
return out
759+
}
760+
761+
var mu sync.Mutex
762+
var wg sync.WaitGroup
763+
sem := make(chan struct{}, batchItemLookupConcurrency)
764+
765+
for _, key := range unique {
766+
wg.Add(1)
767+
go func(key issueRefKey) {
768+
defer wg.Done()
769+
770+
select {
771+
case sem <- struct{}{}:
772+
case <-ctx.Done():
773+
mu.Lock()
774+
out[key] = itemLookupResult{err: ctx.Err()}
775+
mu.Unlock()
776+
return
777+
}
778+
defer func() { <-sem }()
779+
780+
if ctx.Err() != nil {
781+
mu.Lock()
782+
out[key] = itemLookupResult{err: ctx.Err()}
783+
mu.Unlock()
784+
return
785+
}
786+
787+
nodeID, itemID, err := resolveProjectItemByIssueNumberWithProjectID(ctx, gqlClient, projectID, key.owner, key.repo, key.number)
788+
789+
mu.Lock()
790+
out[key] = itemLookupResult{nodeID: nodeID, fullDatabaseID: itemID, err: err}
791+
mu.Unlock()
792+
}(key)
793+
}
794+
wg.Wait()
754795
return out
755796
}
756797

pkg/github/projects_batch_test.go

Lines changed: 225 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -5,11 +5,14 @@ import (
55
"encoding/json"
66
"fmt"
77
"io"
8+
"maps"
89
"math"
910
"net/http"
1011
"strings"
12+
"sync"
1113
"sync/atomic"
1214
"testing"
15+
"time"
1316

1417
"github.com/github/github-mcp-server/internal/githubv4mock"
1518
ghErrors "github.com/github/github-mcp-server/pkg/errors"
@@ -108,6 +111,137 @@ func (m *mutationAwareTransport) RoundTrip(req *http.Request) (*http.Response, e
108111
}, nil
109112
}
110113

114+
type gatedIssueLookupTransport struct {
115+
gate <-chan struct{}
116+
started chan int
117+
projectID string
118+
119+
mu sync.Mutex
120+
active int
121+
peak int
122+
calls map[int]int
123+
}
124+
125+
func newGatedIssueLookupTransport(gate <-chan struct{}, projectID string) *gatedIssueLookupTransport {
126+
return &gatedIssueLookupTransport{
127+
gate: gate,
128+
started: make(chan int, maxProjectItemsPerBatch),
129+
projectID: projectID,
130+
calls: make(map[int]int),
131+
}
132+
}
133+
134+
func (t *gatedIssueLookupTransport) RoundTrip(req *http.Request) (*http.Response, error) {
135+
raw, err := io.ReadAll(req.Body)
136+
if err != nil {
137+
return nil, err
138+
}
139+
_ = req.Body.Close()
140+
141+
var parsed struct {
142+
Variables map[string]any `json:"variables"`
143+
}
144+
if err := json.Unmarshal(raw, &parsed); err != nil {
145+
return nil, err
146+
}
147+
rawIssueNumber, ok := parsed.Variables["issueNumber"].(float64)
148+
if !ok {
149+
return nil, fmt.Errorf("issueNumber variable is missing or invalid")
150+
}
151+
issueNumber := int(rawIssueNumber)
152+
153+
t.mu.Lock()
154+
t.calls[issueNumber]++
155+
t.active++
156+
t.peak = max(t.peak, t.active)
157+
t.mu.Unlock()
158+
defer func() {
159+
t.mu.Lock()
160+
t.active--
161+
t.mu.Unlock()
162+
}()
163+
164+
t.started <- issueNumber
165+
select {
166+
case <-t.gate:
167+
case <-req.Context().Done():
168+
return nil, req.Context().Err()
169+
}
170+
171+
body, err := json.Marshal(map[string]any{
172+
"data": map[string]any{
173+
"repository": map[string]any{
174+
"issue": map[string]any{
175+
"projectItems": map[string]any{
176+
"nodes": []any{
177+
map[string]any{
178+
"id": fmt.Sprintf("PVTI_item%d", issueNumber),
179+
"fullDatabaseId": fmt.Sprintf("%d", 1000+issueNumber),
180+
"project": map[string]any{"id": t.projectID},
181+
},
182+
},
183+
"pageInfo": map[string]any{
184+
"hasNextPage": false, "hasPreviousPage": false,
185+
"startCursor": "page-one", "endCursor": "page-one",
186+
},
187+
},
188+
},
189+
},
190+
},
191+
})
192+
if err != nil {
193+
return nil, err
194+
}
195+
return &http.Response{
196+
StatusCode: http.StatusOK,
197+
Body: io.NopCloser(strings.NewReader(string(body))),
198+
Header: http.Header{"Content-Type": []string{"application/json"}},
199+
}, nil
200+
}
201+
202+
func (t *gatedIssueLookupTransport) snapshot() (active int, peak int, calls map[int]int) {
203+
t.mu.Lock()
204+
defer t.mu.Unlock()
205+
206+
return t.active, t.peak, maps.Clone(t.calls)
207+
}
208+
209+
func issueBatchItems(issueNumbers ...int) []parsedBatchItem {
210+
items := make([]parsedBatchItem, 0, len(issueNumbers))
211+
for index, issueNumber := range issueNumbers {
212+
items = append(items, parsedBatchItem{
213+
index: index,
214+
refKind: batchRefIssue,
215+
issueOwner: "octo-org",
216+
issueRepo: "roadmap",
217+
issueNumber: issueNumber,
218+
})
219+
}
220+
return items
221+
}
222+
223+
func waitForIssueLookups(ctx context.Context, t *testing.T, started <-chan int, count int) {
224+
t.Helper()
225+
for range count {
226+
select {
227+
case <-started:
228+
case <-ctx.Done():
229+
t.Fatalf("timed out waiting for %d issue lookups to start: %v", count, ctx.Err())
230+
}
231+
}
232+
}
233+
234+
func waitForIssueLookupResults(ctx context.Context, t *testing.T, results <-chan map[issueRefKey]itemLookupResult) map[issueRefKey]itemLookupResult {
235+
t.Helper()
236+
select {
237+
case resolved := <-results:
238+
return resolved
239+
case <-ctx.Done():
240+
t.Fatalf("timed out waiting for issue lookups to finish: %v", ctx.Err())
241+
return nil
242+
}
243+
}
244+
111245
func Test_UpdateProjectItemsBatch_TopLevelGuards(t *testing.T) {
112246
tooMany := make([]any, maxProjectItemsPerBatch+1)
113247
validItem := map[string]any{"node_id": "PVTI_item1"}
@@ -1134,6 +1268,97 @@ func Test_ResolveItemNodeIDsByNumericID_DeduplicatesOrgAndUserLookups(t *testing
11341268
}
11351269
}
11361270

1271+
func Test_ResolveIssueRefs_DeduplicatesAndBoundsConcurrency(t *testing.T) {
1272+
ctx, cancel := context.WithTimeout(t.Context(), 5*time.Second)
1273+
defer cancel()
1274+
1275+
gate := make(chan struct{})
1276+
transport := newGatedIssueLookupTransport(gate, "PVT_project")
1277+
results := make(chan map[issueRefKey]itemLookupResult, 1)
1278+
go func() {
1279+
results <- resolveIssueRefs(
1280+
ctx,
1281+
newTestGQLClient(transport),
1282+
githubv4.ID("PVT_project"),
1283+
issueBatchItems(1, 2, 3, 4, 5, 6, 1),
1284+
)
1285+
}()
1286+
1287+
waitForIssueLookups(ctx, t, transport.started, batchItemLookupConcurrency)
1288+
active, peak, calls := transport.snapshot()
1289+
assert.Equal(t, batchItemLookupConcurrency, active)
1290+
assert.Equal(t, batchItemLookupConcurrency, peak)
1291+
assert.Len(t, calls, batchItemLookupConcurrency)
1292+
1293+
close(gate)
1294+
resolved := waitForIssueLookupResults(ctx, t, results)
1295+
1296+
require.Len(t, resolved, 6)
1297+
for issueNumber := 1; issueNumber <= 6; issueNumber++ {
1298+
key := issueRefKey{owner: "octo-org", repo: "roadmap", number: issueNumber}
1299+
result, ok := resolved[key]
1300+
require.True(t, ok)
1301+
require.NoError(t, result.err)
1302+
assert.Equal(t, fmt.Sprintf("PVTI_item%d", issueNumber), result.nodeID)
1303+
assert.Equal(t, int64(1000+issueNumber), result.fullDatabaseID)
1304+
}
1305+
1306+
active, peak, calls = transport.snapshot()
1307+
assert.Zero(t, active)
1308+
assert.Equal(t, batchItemLookupConcurrency, peak)
1309+
require.Len(t, calls, 6)
1310+
for issueNumber := 1; issueNumber <= 6; issueNumber++ {
1311+
assert.Equal(t, 1, calls[issueNumber])
1312+
}
1313+
}
1314+
1315+
func Test_ResolveIssueRefs_CancellationPopulatesWaitingRefs(t *testing.T) {
1316+
testCtx, stop := context.WithTimeout(t.Context(), 5*time.Second)
1317+
defer stop()
1318+
ctx, cancel := context.WithCancel(testCtx)
1319+
defer cancel()
1320+
1321+
gate := make(chan struct{})
1322+
defer close(gate)
1323+
transport := newGatedIssueLookupTransport(gate, "PVT_project")
1324+
results := make(chan map[issueRefKey]itemLookupResult, 1)
1325+
go func() {
1326+
results <- resolveIssueRefs(
1327+
ctx,
1328+
newTestGQLClient(transport),
1329+
githubv4.ID("PVT_project"),
1330+
issueBatchItems(1, 2, 3, 4, 5, 6, 7),
1331+
)
1332+
}()
1333+
1334+
waitForIssueLookups(testCtx, t, transport.started, batchItemLookupConcurrency)
1335+
_, peak, startedCalls := transport.snapshot()
1336+
require.Equal(t, batchItemLookupConcurrency, peak)
1337+
require.Len(t, startedCalls, batchItemLookupConcurrency)
1338+
1339+
cancel()
1340+
resolved := waitForIssueLookupResults(testCtx, t, results)
1341+
1342+
require.Len(t, resolved, 7)
1343+
waiting := 0
1344+
for issueNumber := 1; issueNumber <= 7; issueNumber++ {
1345+
key := issueRefKey{owner: "octo-org", repo: "roadmap", number: issueNumber}
1346+
result, ok := resolved[key]
1347+
require.True(t, ok)
1348+
require.ErrorIs(t, result.err, context.Canceled)
1349+
if _, started := startedCalls[issueNumber]; !started {
1350+
waiting++
1351+
assert.Equal(t, context.Canceled, result.err)
1352+
}
1353+
}
1354+
assert.Equal(t, 2, waiting)
1355+
1356+
active, peak, calls := transport.snapshot()
1357+
assert.Zero(t, active)
1358+
assert.Equal(t, batchItemLookupConcurrency, peak)
1359+
assert.Equal(t, startedCalls, calls)
1360+
}
1361+
11371362
func Test_BatchErrorFromResolution(t *testing.T) {
11381363
t.Run("generic wrapped error", func(t *testing.T) {
11391364
err := batchErrorFromResolution(fmt.Errorf("item lookup failed: %w", context.DeadlineExceeded))

0 commit comments

Comments
 (0)