3030
3131import { beforeEach , describe , expect , it , vi } from 'vitest'
3232
33- import { openSocketFixPr } from '../../../../src/commands/fix/pull-request.mts'
33+ import {
34+ cleanupSocketFixPrs ,
35+ getSocketFixPrs ,
36+ openSocketFixPr ,
37+ } from '../../../../src/commands/fix/pull-request.mts'
3438
3539const mockGetOctokit = vi . hoisted ( ( ) => vi . fn ( ) )
3640const mockCreatePrProvider = vi . hoisted ( ( ) => vi . fn ( ) )
@@ -52,25 +56,47 @@ const mockWithGitHubRetry = vi.hoisted(() =>
5256 } ) ,
5357)
5458
59+ const mockGetSocketFixBranchPattern = vi . hoisted ( ( ) =>
60+ vi . fn ( ( ) => / ^ s o c k e t \/ f i x \/ G H S A - .* / ) ,
61+ )
62+
5563// Mock dependencies.
5664vi . mock ( '../../../../src/commands/fix/git.mts' , ( ) => ( {
65+ getSocketFixBranchPattern : mockGetSocketFixBranchPattern ,
5766 getSocketFixPullRequestTitle : mockGetSocketFixPullRequestTitle ,
5867 getSocketFixPullRequestBody : mockGetSocketFixPullRequestBody ,
5968} ) )
6069
70+ // Mock debug.
71+ vi . mock ( '@socketsecurity/lib/debug' , ( ) => ( {
72+ debug : vi . fn ( ) ,
73+ debugDir : vi . fn ( ) ,
74+ } ) )
75+
76+ // Mock pr-lifecycle-logger.
77+ vi . mock ( '../../../../src/commands/fix/pr-lifecycle-logger.mts' , ( ) => ( {
78+ logPrEvent : vi . fn ( ) ,
79+ } ) )
80+
81+ const mockGetOctokitGraphql = vi . hoisted ( ( ) => vi . fn ( ) )
82+ const mockCacheFetch = vi . hoisted ( ( ) => vi . fn ( ) )
83+ const mockWriteCache = vi . hoisted ( ( ) => vi . fn ( ) )
84+ const mockHandleGraphqlError = vi . hoisted ( ( ) =>
85+ vi . fn ( ( ) => ( { ok : false , message : 'GraphQL error' } ) ) ,
86+ )
87+
6188vi . mock ( '../../../../src/utils/git/github.mts' , ( ) => ( {
89+ cacheFetch : mockCacheFetch ,
6290 getOctokit : mockGetOctokit ,
91+ getOctokitGraphql : mockGetOctokitGraphql ,
6392 handleGitHubApiError : vi . fn ( ( e : unknown , context : string ) => ( {
6493 ok : false ,
6594 message : 'GitHub API error' ,
6695 cause : `Error while ${ context } : ${ e instanceof Error ? e . message : String ( e ) } ` ,
6796 } ) ) ,
68- handleGraphqlError : vi . fn ( ( _e : unknown , context : string ) => ( {
69- ok : false ,
70- message : 'GitHub GraphQL error' ,
71- cause : `GraphQL error while ${ context } ` ,
72- } ) ) ,
97+ handleGraphqlError : mockHandleGraphqlError ,
7398 withGitHubRetry : mockWithGitHubRetry ,
99+ writeCache : mockWriteCache ,
74100} ) )
75101
76102vi . mock ( '../../../../src/utils/git/provider-factory.mts' , ( ) => ( {
@@ -321,4 +347,315 @@ describe('pull-request', () => {
321347 )
322348 } )
323349 } )
350+
351+ describe ( 'getSocketFixPrs' , ( ) => {
352+ beforeEach ( ( ) => {
353+ mockGetOctokitGraphql . mockReturnValue ( vi . fn ( ) )
354+ } )
355+
356+ it ( 'returns matching PRs' , async ( ) => {
357+ mockCacheFetch . mockResolvedValueOnce ( {
358+ repository : {
359+ pullRequests : {
360+ pageInfo : { hasNextPage : false , endCursor : null } ,
361+ nodes : [
362+ {
363+ author : { login : 'testuser' } ,
364+ baseRefName : 'main' ,
365+ headRefName : 'socket/fix/GHSA-xxxx-xxxx-xxxx' ,
366+ mergeStateStatus : 'CLEAN' ,
367+ number : 1 ,
368+ state : 'OPEN' ,
369+ title : 'Fix GHSA-xxxx' ,
370+ } ,
371+ {
372+ author : { login : 'otheruser' } ,
373+ baseRefName : 'main' ,
374+ headRefName : 'feature-branch' ,
375+ mergeStateStatus : 'CLEAN' ,
376+ number : 2 ,
377+ state : 'OPEN' ,
378+ title : 'Other PR' ,
379+ } ,
380+ ] ,
381+ } ,
382+ } ,
383+ } )
384+
385+ const result = await getSocketFixPrs ( 'owner' , 'repo' )
386+
387+ expect ( result ) . toHaveLength ( 1 )
388+ expect ( result [ 0 ] ?. number ) . toBe ( 1 )
389+ } )
390+
391+ it ( 'filters by author' , async ( ) => {
392+ mockCacheFetch . mockResolvedValueOnce ( {
393+ repository : {
394+ pullRequests : {
395+ pageInfo : { hasNextPage : false , endCursor : null } ,
396+ nodes : [
397+ {
398+ author : { login : 'testuser' } ,
399+ baseRefName : 'main' ,
400+ headRefName : 'socket/fix/GHSA-xxxx' ,
401+ mergeStateStatus : 'CLEAN' ,
402+ number : 1 ,
403+ state : 'OPEN' ,
404+ title : 'Fix GHSA-xxxx' ,
405+ } ,
406+ {
407+ author : { login : 'otheruser' } ,
408+ baseRefName : 'main' ,
409+ headRefName : 'socket/fix/GHSA-yyyy' ,
410+ mergeStateStatus : 'CLEAN' ,
411+ number : 2 ,
412+ state : 'OPEN' ,
413+ title : 'Fix GHSA-yyyy' ,
414+ } ,
415+ ] ,
416+ } ,
417+ } ,
418+ } )
419+
420+ const result = await getSocketFixPrs ( 'owner' , 'repo' , {
421+ author : 'testuser' ,
422+ } )
423+
424+ expect ( result ) . toHaveLength ( 1 )
425+ expect ( result [ 0 ] ?. author ) . toBe ( 'testuser' )
426+ } )
427+
428+ it ( 'handles GraphQL errors gracefully' , async ( ) => {
429+ mockCacheFetch . mockRejectedValueOnce ( new Error ( 'GraphQL error' ) )
430+
431+ const result = await getSocketFixPrs ( 'owner' , 'repo' )
432+
433+ expect ( result ) . toEqual ( [ ] )
434+ expect ( mockHandleGraphqlError ) . toHaveBeenCalled ( )
435+ } )
436+
437+ it ( 'handles empty response' , async ( ) => {
438+ mockCacheFetch . mockResolvedValueOnce ( {
439+ repository : {
440+ pullRequests : {
441+ pageInfo : { hasNextPage : false , endCursor : null } ,
442+ nodes : [ ] ,
443+ } ,
444+ } ,
445+ } )
446+
447+ const result = await getSocketFixPrs ( 'owner' , 'repo' )
448+
449+ expect ( result ) . toEqual ( [ ] )
450+ } )
451+
452+ it ( 'handles missing author in node' , async ( ) => {
453+ mockCacheFetch . mockResolvedValueOnce ( {
454+ repository : {
455+ pullRequests : {
456+ pageInfo : { hasNextPage : false , endCursor : null } ,
457+ nodes : [
458+ {
459+ author : null ,
460+ baseRefName : 'main' ,
461+ headRefName : 'socket/fix/GHSA-xxxx' ,
462+ mergeStateStatus : 'CLEAN' ,
463+ number : 1 ,
464+ state : 'OPEN' ,
465+ title : 'Fix GHSA-xxxx' ,
466+ } ,
467+ ] ,
468+ } ,
469+ } ,
470+ } )
471+
472+ const result = await getSocketFixPrs ( 'owner' , 'repo' )
473+
474+ expect ( result ) . toHaveLength ( 1 )
475+ // UNKNOWN_VALUE from @socketsecurity /lib/constants/core.
476+ expect ( result [ 0 ] ?. author ) . toBe ( '<unknown>' )
477+ } )
478+
479+ it ( 'stops pagination early when ghsaId match found' , async ( ) => {
480+ mockCacheFetch . mockResolvedValueOnce ( {
481+ repository : {
482+ pullRequests : {
483+ pageInfo : { hasNextPage : true , endCursor : 'cursor1' } ,
484+ nodes : [
485+ {
486+ author : { login : 'user1' } ,
487+ baseRefName : 'main' ,
488+ headRefName : 'socket/fix/GHSA-xxxx' ,
489+ mergeStateStatus : 'CLEAN' ,
490+ number : 1 ,
491+ state : 'OPEN' ,
492+ title : 'Fix GHSA-xxxx' ,
493+ } ,
494+ ] ,
495+ } ,
496+ } ,
497+ } )
498+
499+ const result = await getSocketFixPrs ( 'owner' , 'repo' , {
500+ ghsaId : 'GHSA-xxxx' ,
501+ } )
502+
503+ expect ( result ) . toHaveLength ( 1 )
504+ // Should have only called cacheFetch once due to early exit.
505+ expect ( mockCacheFetch ) . toHaveBeenCalledTimes ( 1 )
506+ } )
507+
508+ it ( 'handles null pullRequests response' , async ( ) => {
509+ mockCacheFetch . mockResolvedValueOnce ( {
510+ repository : {
511+ pullRequests : null ,
512+ } ,
513+ } )
514+
515+ const result = await getSocketFixPrs ( 'owner' , 'repo' )
516+
517+ expect ( result ) . toEqual ( [ ] )
518+ } )
519+ } )
520+
521+ describe ( 'cleanupSocketFixPrs' , ( ) => {
522+ beforeEach ( ( ) => {
523+ mockGetOctokitGraphql . mockReturnValue ( vi . fn ( ) )
524+ mockCreatePrProvider . mockReturnValue ( mockProvider )
525+ } )
526+
527+ it ( 'returns empty array when no matching PRs' , async ( ) => {
528+ mockCacheFetch . mockResolvedValueOnce ( {
529+ repository : {
530+ pullRequests : {
531+ pageInfo : { hasNextPage : false , endCursor : null } ,
532+ nodes : [ ] ,
533+ } ,
534+ } ,
535+ } )
536+
537+ const result = await cleanupSocketFixPrs ( 'owner' , 'repo' , 'GHSA-xxxx' )
538+
539+ expect ( result ) . toEqual ( [ ] )
540+ } )
541+
542+ it ( 'updates stale PRs with BEHIND status' , async ( ) => {
543+ mockCacheFetch . mockResolvedValueOnce ( {
544+ repository : {
545+ pullRequests : {
546+ pageInfo : { hasNextPage : false , endCursor : null } ,
547+ nodes : [
548+ {
549+ author : { login : 'testuser' } ,
550+ baseRefName : 'main' ,
551+ headRefName : 'socket/fix/GHSA-xxxx' ,
552+ mergeStateStatus : 'BEHIND' ,
553+ number : 1 ,
554+ state : 'OPEN' ,
555+ title : 'Fix GHSA-xxxx' ,
556+ } ,
557+ ] ,
558+ } ,
559+ } ,
560+ } )
561+ mockProvider . updatePr . mockResolvedValueOnce ( { } )
562+ mockWriteCache . mockResolvedValueOnce ( undefined )
563+
564+ const result = await cleanupSocketFixPrs ( 'owner' , 'repo' , 'GHSA-xxxx' )
565+
566+ expect ( result ) . toHaveLength ( 1 )
567+ expect ( mockProvider . updatePr ) . toHaveBeenCalledWith ( {
568+ owner : 'owner' ,
569+ repo : 'repo' ,
570+ prNumber : 1 ,
571+ head : 'socket/fix/GHSA-xxxx' ,
572+ base : 'main' ,
573+ } )
574+ } )
575+
576+ it ( 'deletes branches for merged PRs' , async ( ) => {
577+ mockCacheFetch . mockResolvedValueOnce ( {
578+ repository : {
579+ pullRequests : {
580+ pageInfo : { hasNextPage : false , endCursor : null } ,
581+ nodes : [
582+ {
583+ author : { login : 'testuser' } ,
584+ baseRefName : 'main' ,
585+ headRefName : 'socket/fix/GHSA-xxxx' ,
586+ mergeStateStatus : 'CLEAN' ,
587+ number : 1 ,
588+ state : 'MERGED' ,
589+ title : 'Fix GHSA-xxxx' ,
590+ } ,
591+ ] ,
592+ } ,
593+ } ,
594+ } )
595+ mockProvider . deleteBranch . mockResolvedValueOnce ( true )
596+
597+ const result = await cleanupSocketFixPrs ( 'owner' , 'repo' , 'GHSA-xxxx' )
598+
599+ expect ( result ) . toHaveLength ( 1 )
600+ expect ( mockProvider . deleteBranch ) . toHaveBeenCalledWith (
601+ 'socket/fix/GHSA-xxxx' ,
602+ )
603+ } )
604+
605+ it ( 'handles delete branch failure gracefully' , async ( ) => {
606+ mockCacheFetch . mockResolvedValueOnce ( {
607+ repository : {
608+ pullRequests : {
609+ pageInfo : { hasNextPage : false , endCursor : null } ,
610+ nodes : [
611+ {
612+ author : { login : 'testuser' } ,
613+ baseRefName : 'main' ,
614+ headRefName : 'socket/fix/GHSA-xxxx' ,
615+ mergeStateStatus : 'CLEAN' ,
616+ number : 1 ,
617+ state : 'MERGED' ,
618+ title : 'Fix GHSA-xxxx' ,
619+ } ,
620+ ] ,
621+ } ,
622+ } ,
623+ } )
624+ mockProvider . deleteBranch . mockRejectedValueOnce (
625+ new Error ( 'Branch not found' ) ,
626+ )
627+
628+ const result = await cleanupSocketFixPrs ( 'owner' , 'repo' , 'GHSA-xxxx' )
629+
630+ // Should still return the match even if branch deletion fails.
631+ expect ( result ) . toHaveLength ( 1 )
632+ } )
633+
634+ it ( 'handles update PR failure gracefully' , async ( ) => {
635+ mockCacheFetch . mockResolvedValueOnce ( {
636+ repository : {
637+ pullRequests : {
638+ pageInfo : { hasNextPage : false , endCursor : null } ,
639+ nodes : [
640+ {
641+ author : { login : 'testuser' } ,
642+ baseRefName : 'main' ,
643+ headRefName : 'socket/fix/GHSA-xxxx' ,
644+ mergeStateStatus : 'BEHIND' ,
645+ number : 1 ,
646+ state : 'OPEN' ,
647+ title : 'Fix GHSA-xxxx' ,
648+ } ,
649+ ] ,
650+ } ,
651+ } ,
652+ } )
653+ mockProvider . updatePr . mockRejectedValueOnce ( new Error ( 'Update failed' ) )
654+
655+ const result = await cleanupSocketFixPrs ( 'owner' , 'repo' , 'GHSA-xxxx' )
656+
657+ // Should still return the match even if update fails.
658+ expect ( result ) . toHaveLength ( 1 )
659+ } )
660+ } )
324661} )
0 commit comments