@@ -2,6 +2,7 @@ package main
22
33import (
44 "bytes"
5+ "context"
56 "encoding/json"
67 "fmt"
78 "io"
@@ -13,11 +14,13 @@ import (
1314
1415 "github.com/gastownhall/wasteland/internal/backend"
1516 "github.com/gastownhall/wasteland/internal/commons"
17+ "github.com/gastownhall/wasteland/internal/ctxutil"
1618 "github.com/gastownhall/wasteland/internal/federation"
1719 "github.com/gastownhall/wasteland/internal/remote"
1820 "github.com/gastownhall/wasteland/internal/sdk"
1921 "github.com/gastownhall/wasteland/internal/style"
2022 "github.com/spf13/cobra"
23+ "golang.org/x/sync/singleflight"
2124)
2225
2326type doltHubPRProvider interface {
@@ -27,6 +30,15 @@ type doltHubPRProvider interface {
2730 ClosePR (upstreamOrg , db , prID string ) error
2831}
2932
33+ func bindDoltHubPRProviderContext (ctx context.Context , provider doltHubPRProvider ) doltHubPRProvider {
34+ if withCtx , ok := provider .(interface {
35+ WithContext (context.Context ) * remote.DoltHubProvider
36+ }); ok {
37+ return withCtx .WithContext (ctx )
38+ }
39+ return provider
40+ }
41+
3042var (
3143 pushBranchToRemoteForce = commons .PushBranchToRemoteForce
3244 newGitHubPRClientFromPath = func (ghPath string ) GitHubPRClient {
@@ -470,7 +482,11 @@ func createGitHubPR(client GitHubPRClient, upstreamRepo, forkOrg, forkDB, wlBran
470482// findExistingPR checks for an open PR on upstream with the given head ref.
471483// Returns the PR URL and number, or empty strings if none found.
472484func findExistingPR (ghPath , upstreamRepo , head string ) (url , number string ) {
473- cmd := exec .Command (ghPath , "pr" , "list" , "--repo" , upstreamRepo , "--head" , head , "--state" , "open" , "--json" , "number,url" )
485+ return findExistingPRContext (context .Background (), ghPath , upstreamRepo , head )
486+ }
487+
488+ func findExistingPRContext (ctx context.Context , ghPath , upstreamRepo , head string ) (url , number string ) {
489+ cmd := exec .CommandContext (ctx , ghPath , "pr" , "list" , "--repo" , upstreamRepo , "--head" , head , "--state" , "open" , "--json" , "number,url" )
474490 out , err := cmd .CombinedOutput ()
475491 if err != nil {
476492 return "" , ""
@@ -761,15 +777,20 @@ func createPRForBranchRemote(cfg *federation.Config, cdb commons.DB, branch stri
761777// checkPRForBranch checks if an upstream PR already exists for the given branch.
762778// Returns the PR URL or empty string. Best-effort: returns "" on any error.
763779func checkPRForBranch (cfg * federation.Config , branch string ) string {
780+ return checkPRForBranchContext (context .Background (), cfg , branch )
781+ }
782+
783+ // checkPRForBranchContext checks if an upstream PR already exists for the given branch.
784+ // Returns the PR URL or empty string. Best-effort: returns "" on any error.
785+ func checkPRForBranchContext (ctx context.Context , cfg * federation.Config , branch string ) string {
764786 switch cfg .ResolveProviderType () {
765787 case "github" :
766788 ghPath , err := exec .LookPath ("gh" )
767789 if err != nil {
768790 return ""
769791 }
770- client := newGHClient (ghPath )
771792 head := cfg .ForkOrg + ":" + branch
772- url , _ := client . FindPR ( cfg .Upstream , head )
793+ url , _ := findExistingPRContext ( ctx , ghPath , cfg .Upstream , head )
773794 return url
774795 case "dolthub" :
775796 token := os .Getenv ("DOLTHUB_TOKEN" )
@@ -780,7 +801,7 @@ func checkPRForBranch(cfg *federation.Config, branch string) string {
780801 if err != nil {
781802 return ""
782803 }
783- provider := newDoltHubPRProvider (token )
804+ provider := bindDoltHubPRProviderContext ( ctx , newDoltHubPRProvider (token ) )
784805 url , _ := provider .FindPR (upstreamOrg , db , cfg .ForkOrg , branch )
785806 return url
786807 default :
@@ -842,6 +863,78 @@ func listPendingItemsFromPRs(cfg *federation.Config) func() (map[string][]sdk.Pe
842863 }
843864}
844865
866+ // listPendingItemsFromPRsContext returns a callback that lists wanted IDs with open
867+ // upstream PRs using request-scoped context. Returns nil if the provider type
868+ // does not support PR listing.
869+ func listPendingItemsFromPRsContext (cfg * federation.Config ) func (context.Context ) (map [string ][]sdk.PendingItem , error ) {
870+ switch cfg .ResolveProviderType () {
871+ case "dolthub" :
872+ return dolthubListPendingItemsContext (cfg )
873+ case "github" :
874+ ghPath , err := exec .LookPath ("gh" )
875+ if err != nil {
876+ return nil
877+ }
878+ return ghListPendingItemsContext (ghPath , cfg .Upstream )
879+ default :
880+ return nil
881+ }
882+ }
883+
884+ type pendingItemsFetcher func (context.Context ) (map [string ][]sdk.PendingItem , error )
885+
886+ type pendingItemsTTLCache struct {
887+ mu sync.RWMutex
888+ cached map [string ][]sdk.PendingItem
889+ cachedAt time.Time
890+ cacheTTL time.Duration
891+ group singleflight.Group
892+ fetch pendingItemsFetcher
893+ }
894+
895+ func newPendingItemsTTLCache (cacheTTL time.Duration , fetch pendingItemsFetcher ) func (context.Context ) (map [string ][]sdk.PendingItem , error ) {
896+ cache := & pendingItemsTTLCache {
897+ cacheTTL : cacheTTL ,
898+ fetch : fetch ,
899+ }
900+ return cache .GetContext
901+ }
902+
903+ func (c * pendingItemsTTLCache ) GetContext (ctx context.Context ) (map [string ][]sdk.PendingItem , error ) {
904+ c .mu .RLock ()
905+ cached := c .cached
906+ fresh := cached != nil && time .Since (c .cachedAt ) < c .cacheTTL
907+ c .mu .RUnlock ()
908+ if fresh {
909+ return cached , nil
910+ }
911+
912+ resultCh := c .group .DoChan ("refresh" , func () (any , error ) {
913+ sharedCtx , cancel := ctxutil .Detached (ctx , pendingRefreshTimeout )
914+ defer cancel ()
915+ items , err := c .fetch (sharedCtx )
916+ if err != nil {
917+ return nil , err
918+ }
919+ c .mu .Lock ()
920+ c .cached = items
921+ c .cachedAt = time .Now ()
922+ c .mu .Unlock ()
923+ return items , nil
924+ })
925+
926+ select {
927+ case result := <- resultCh :
928+ if result .Err != nil {
929+ return nil , result .Err
930+ }
931+ items , _ := result .Val .(map [string ][]sdk.PendingItem )
932+ return items , nil
933+ case <- ctx .Done ():
934+ return nil , ctx .Err ()
935+ }
936+ }
937+
845938func dolthubListPendingItems (cfg * federation.Config ) func () (map [string ][]sdk.PendingItem , error ) {
846939 token := commons .DoltHubToken ()
847940 if token == "" {
@@ -961,10 +1054,109 @@ func ghListPendingItems(ghPath, upstreamRepo string) func() (map[string][]sdk.Pe
9611054 }
9621055}
9631056
1057+ func dolthubListPendingItemsContext (cfg * federation.Config ) func (context.Context ) (map [string ][]sdk.PendingItem , error ) {
1058+ token := commons .DoltHubToken ()
1059+ if token == "" {
1060+ return nil
1061+ }
1062+ upstreamOrg , db , err := federation .ParseUpstream (cfg .Upstream )
1063+ if err != nil {
1064+ return nil
1065+ }
1066+ return newPendingItemsTTLCache (30 * time .Second , func (ctx context.Context ) (map [string ][]sdk.PendingItem , error ) {
1067+ states , err := remote .NewDoltHubProvider (token ).WithContext (ctx ).ListPendingWantedIDs (upstreamOrg , db )
1068+ if err != nil {
1069+ return nil , err
1070+ }
1071+ result := make (map [string ][]sdk.PendingItem , len (states ))
1072+ for id , pending := range states {
1073+ items := make ([]sdk.PendingItem , len (pending ))
1074+ for i , p := range pending {
1075+ items [i ] = sdk.PendingItem {
1076+ RigHandle : p .RigHandle ,
1077+ Status : p .Status ,
1078+ ClaimedBy : p .ClaimedBy ,
1079+ Branch : p .Branch ,
1080+ BranchURL : p .BranchURL ,
1081+ PRURL : p .PRURL ,
1082+ ForkOwner : p .ForkOwner ,
1083+ CompletedBy : p .CompletedBy ,
1084+ Evidence : p .Evidence ,
1085+ }
1086+ }
1087+ result [id ] = items
1088+ }
1089+ return result , nil
1090+ })
1091+ }
1092+
1093+ func ghListPendingItemsContext (ghPath , upstreamRepo string ) func (context.Context ) (map [string ][]sdk.PendingItem , error ) {
1094+ return newPendingItemsTTLCache (30 * time .Second , func (ctx context.Context ) (map [string ][]sdk.PendingItem , error ) {
1095+ out , err := exec .CommandContext (ctx , ghPath , "api" , "--paginate" ,
1096+ fmt .Sprintf ("repos/%s/pulls?state=open&per_page=100" , upstreamRepo ),
1097+ ).CombinedOutput ()
1098+ if err != nil {
1099+ return nil , fmt .Errorf ("listing GitHub PRs: %w" , err )
1100+ }
1101+ var prs []struct {
1102+ Head struct {
1103+ Ref string `json:"ref"`
1104+ } `json:"head"`
1105+ Title string `json:"title"`
1106+ User struct {
1107+ Login string `json:"login"`
1108+ } `json:"user"`
1109+ }
1110+ if err := json .Unmarshal (out , & prs ); err != nil {
1111+ return nil , fmt .Errorf ("parsing GitHub PRs: %w" , err )
1112+ }
1113+ ids := make (map [string ][]sdk.PendingItem )
1114+ for _ , pr := range prs {
1115+ var rigHandle , wantedID string
1116+
1117+ parts := strings .SplitN (pr .Head .Ref , "/" , 3 )
1118+ if len (parts ) == 3 && parts [0 ] == "wl" {
1119+ rigHandle = parts [1 ]
1120+ wantedID = parts [2 ]
1121+ }
1122+
1123+ if wantedID == "" {
1124+ if m := remote .WantedIDPattern .FindString (pr .Head .Ref ); m != "" {
1125+ wantedID = m
1126+ } else if m := remote .WantedIDPattern .FindString (pr .Title ); m != "" {
1127+ wantedID = m
1128+ }
1129+ }
1130+
1131+ if wantedID == "" {
1132+ continue
1133+ }
1134+ if rigHandle == "" {
1135+ rigHandle = pr .User .Login
1136+ }
1137+
1138+ ids [wantedID ] = append (ids [wantedID ], sdk.PendingItem {
1139+ RigHandle : rigHandle ,
1140+ })
1141+ }
1142+ return ids , nil
1143+ })
1144+ }
1145+
9641146// pendingItemLoaderCallback returns a callback that can read branch-only
9651147// pending item summaries from the correct DoltHub fork. Returns nil when the
9661148// current config does not support fork-aware remote reads.
9671149func pendingItemLoaderCallback (cfg * federation.Config ) func (string , sdk.PendingItem ) (* commons.WantedItem , error ) {
1150+ callback := pendingItemLoaderContextCallback (cfg )
1151+ if callback == nil {
1152+ return nil
1153+ }
1154+ return func (wantedID string , pending sdk.PendingItem ) (* commons.WantedItem , error ) {
1155+ return callback (context .Background (), wantedID , pending )
1156+ }
1157+ }
1158+
1159+ func pendingItemLoaderContextCallback (cfg * federation.Config ) func (context.Context , string , sdk.PendingItem ) (* commons.WantedItem , error ) {
9681160 if cfg .ResolveBackend () == federation .BackendLocal || cfg .ResolveProviderType () != "dolthub" {
9691161 return nil
9701162 }
@@ -974,23 +1166,40 @@ func pendingItemLoaderCallback(cfg *federation.Config) func(string, sdk.PendingI
9741166 return nil
9751167 }
9761168
977- return pendingItemLoader (upstreamOrg , db , cfg .ResolveMode (), commons .DoltHubToken ())
1169+ return pendingItemLoaderContext (upstreamOrg , db , cfg .ResolveMode (), commons .DoltHubToken ())
9781170}
9791171
9801172func pendingItemLoader (upstreamOrg , db , mode , token string ) func (string , sdk.PendingItem ) (* commons.WantedItem , error ) {
1173+ callback := pendingItemLoaderContext (upstreamOrg , db , mode , token )
9811174 return func (wantedID string , pending sdk.PendingItem ) (* commons.WantedItem , error ) {
1175+ return callback (context .Background (), wantedID , pending )
1176+ }
1177+ }
1178+
1179+ func pendingItemLoaderContext (upstreamOrg , db , mode , token string ) func (context.Context , string , sdk.PendingItem ) (* commons.WantedItem , error ) {
1180+ return func (ctx context.Context , wantedID string , pending sdk.PendingItem ) (* commons.WantedItem , error ) {
9821181 if pending .ForkOwner == "" || pending .Branch == "" {
9831182 return nil , fmt .Errorf ("pending item %q is missing fork owner or branch" , wantedID )
9841183 }
9851184 forkDB := backend .NewRemoteDB (token , upstreamOrg , db , pending .ForkOwner , db , mode )
986- return commons .QueryWantedDetailAsOf (forkDB , wantedID , pending .Branch )
1185+ return commons .QueryWantedDetailAsOf (forkDB . WithContext ( ctx ) , wantedID , pending .Branch )
9871186 }
9881187}
9891188
9901189// pendingDetailLoaderCallback returns a callback that can read branch-only
9911190// pending items from the correct DoltHub fork. Returns nil when the current
9921191// config does not support fork-aware remote reads.
9931192func pendingDetailLoaderCallback (cfg * federation.Config ) func (string , sdk.PendingItem ) (* commons.WantedItem , * commons.CompletionRecord , * commons.Stamp , error ) {
1193+ callback := pendingDetailLoaderContextCallback (cfg )
1194+ if callback == nil {
1195+ return nil
1196+ }
1197+ return func (wantedID string , pending sdk.PendingItem ) (* commons.WantedItem , * commons.CompletionRecord , * commons.Stamp , error ) {
1198+ return callback (context .Background (), wantedID , pending )
1199+ }
1200+ }
1201+
1202+ func pendingDetailLoaderContextCallback (cfg * federation.Config ) func (context.Context , string , sdk.PendingItem ) (* commons.WantedItem , * commons.CompletionRecord , * commons.Stamp , error ) {
9941203 if cfg .ResolveBackend () == federation .BackendLocal || cfg .ResolveProviderType () != "dolthub" {
9951204 return nil
9961205 }
@@ -1000,16 +1209,23 @@ func pendingDetailLoaderCallback(cfg *federation.Config) func(string, sdk.Pendin
10001209 return nil
10011210 }
10021211
1003- return pendingDetailLoader (upstreamOrg , db , cfg .ResolveMode (), commons .DoltHubToken ())
1212+ return pendingDetailLoaderContext (upstreamOrg , db , cfg .ResolveMode (), commons .DoltHubToken ())
10041213}
10051214
10061215func pendingDetailLoader (upstreamOrg , db , mode , token string ) func (string , sdk.PendingItem ) (* commons.WantedItem , * commons.CompletionRecord , * commons.Stamp , error ) {
1216+ callback := pendingDetailLoaderContext (upstreamOrg , db , mode , token )
10071217 return func (wantedID string , pending sdk.PendingItem ) (* commons.WantedItem , * commons.CompletionRecord , * commons.Stamp , error ) {
1218+ return callback (context .Background (), wantedID , pending )
1219+ }
1220+ }
1221+
1222+ func pendingDetailLoaderContext (upstreamOrg , db , mode , token string ) func (context.Context , string , sdk.PendingItem ) (* commons.WantedItem , * commons.CompletionRecord , * commons.Stamp , error ) {
1223+ return func (ctx context.Context , wantedID string , pending sdk.PendingItem ) (* commons.WantedItem , * commons.CompletionRecord , * commons.Stamp , error ) {
10081224 if pending .ForkOwner == "" || pending .Branch == "" {
10091225 return nil , nil , nil , fmt .Errorf ("pending item %q is missing fork owner or branch" , wantedID )
10101226 }
10111227 forkDB := backend .NewRemoteDB (token , upstreamOrg , db , pending .ForkOwner , db , mode )
1012- return commons .QueryFullDetailAsOf (forkDB , wantedID , pending .Branch )
1228+ return commons .QueryFullDetailAsOf (forkDB . WithContext ( ctx ) , wantedID , pending .Branch )
10131229 }
10141230}
10151231
0 commit comments