@@ -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+
111245func 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+
11371362func 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