@@ -29,7 +29,7 @@ import (
2929 "github.com/fullsend-ai/fullsend/internal/inference/vertex"
3030 "github.com/fullsend-ai/fullsend/internal/layers"
3131 "github.com/fullsend-ai/fullsend/internal/mintcore"
32- "github.com/fullsend-ai/fullsend/internal/scaffold "
32+ "github.com/fullsend-ai/fullsend/internal/repos "
3333 "github.com/fullsend-ai/fullsend/internal/ui"
3434)
3535
@@ -156,6 +156,12 @@ type perRepoInstallConfig struct {
156156 FullsendBinary string
157157 FullsendSource string
158158 Direct bool
159+
160+ // Testing overrides — when non-nil, used instead of resolving from
161+ // the environment. Not set by CLI flag parsing.
162+ testClient forge.Client
163+ testPrinter * ui.Printer
164+ testWIFProvisioner repos.WIFProvisioner
159165}
160166
161167// wifProviderPattern validates the full WIF provider resource name format
@@ -638,14 +644,20 @@ func runPerRepoInstall(ctx context.Context, c perRepoInstallConfig) error {
638644 return err
639645 }
640646
641- token , err := resolveToken ()
642- if err != nil {
643- return err
647+ var client forge.Client
648+ var printer * ui.Printer
649+ if c .testClient != nil {
650+ client = c .testClient
651+ printer = c .testPrinter
652+ } else {
653+ token , tokenErr := resolveToken ()
654+ if tokenErr != nil {
655+ return tokenErr
656+ }
657+ client = gh .New (token )
658+ printer = ui .New (os .Stdout )
644659 }
645660
646- client := gh .New (token )
647- printer := ui .New (os .Stdout )
648-
649661 printer .Banner (Version ())
650662 printer .Blank ()
651663 printer .Header ("Installing per-repo fullsend for " + repoFullName )
@@ -655,35 +667,7 @@ func runPerRepoInstall(ctx context.Context, c perRepoInstallConfig) error {
655667 printer .StepWarn ("Using provided WIF provider value — skipping inference provider auto-provisioning" )
656668 }
657669
658- cfg := config .NewPerRepoConfig (roles , repoFullName )
659- if err := cfg .Validate (); err != nil {
660- return fmt .Errorf ("invalid config: %w" , err )
661- }
662-
663- cfgYAML , err := cfg .Marshal ()
664- if err != nil {
665- return fmt .Errorf ("marshaling per-repo config: %w" , err )
666- }
667-
668670 upstreamRef , upstreamTag := resolveUpstreamRef ()
669- installFiles , err := scaffold .CollectPerRepoInstallFiles (vendor , upstreamRef , upstreamTag )
670- if err != nil {
671- return fmt .Errorf ("collecting per-repo scaffold files: %w" , err )
672- }
673-
674- var files []forge.TreeFile
675- for _ , f := range installFiles {
676- files = append (files , forge.TreeFile {
677- Path : f .Path ,
678- Content : f .Content ,
679- Mode : f .Mode ,
680- })
681- }
682- files = append (files , forge.TreeFile {
683- Path : ".fullsend/config.yaml" ,
684- Content : cfgYAML ,
685- Mode : "100644" ,
686- })
687671
688672 needsWIFProvision := inferenceWIFProvider == ""
689673
@@ -823,7 +807,14 @@ func runPerRepoInstall(ctx context.Context, c perRepoInstallConfig) error {
823807 printer .StepInfo (fmt .Sprintf (" Repo restriction: %s/%s" , owner , repo ))
824808 printer .Blank ()
825809 }
826- for _ , f := range files {
810+ dryRunFiles , dryRunErr := repos .BuildScaffoldFiles (repos.InstallConfig {
811+ Owner : owner , Repo : repo , Roles : roles ,
812+ VendorBinary : vendor , UpstreamRef : upstreamRef , UpstreamTag : upstreamTag ,
813+ })
814+ if dryRunErr != nil {
815+ return fmt .Errorf ("generating scaffold files for dry run: %w" , dryRunErr )
816+ }
817+ for _ , f := range dryRunFiles {
827818 printer .StepDone (fmt .Sprintf ("Would write: %s (%d bytes)" , f .Path , len (f .Content )))
828819 }
829820 printer .Blank ()
@@ -970,46 +961,127 @@ func runPerRepoInstall(ctx context.Context, c perRepoInstallConfig) error {
970961 printer .StepDone ("Mint validated and org registered" )
971962 }
972963
973- if needsWIFProvision {
974- printer .StepStart ("Provisioning WIF infrastructure" )
975- provisioner := gcf .NewProvisioner (gcf.Config {
976- ProjectID : inferenceProject ,
977- GitHubOrgs : []string {owner },
978- Repo : owner + "/" + repo ,
979- WIFPoolName : gcf .DefaultInferencePool ,
980- }, gcf .NewLiveGCFClient (inferenceProject ))
981- var provErr error
982- inferenceWIFProvider , provErr = provisioner .ProvisionWIF (ctx )
983- if provErr != nil {
984- printer .StepFail ("WIF provisioning failed" )
985- return fmt .Errorf ("provisioning WIF: %w" , provErr )
964+ // Delegate WIF provisioning, scaffold commit, and variable/secret
965+ // writes to the reusable repos.Install function. This enables the
966+ // future `fullsend repos install` command to share the same logic.
967+ var wifProvisioner repos.WIFProvisioner
968+ if c .testWIFProvisioner != nil {
969+ wifProvisioner = c .testWIFProvisioner
970+ } else if needsWIFProvision {
971+ wifProvisioner = & gcfWIFAdapter {
972+ provisioner : gcf .NewProvisioner (gcf.Config {
973+ ProjectID : inferenceProject ,
974+ GitHubOrgs : []string {owner },
975+ Repo : owner + "/" + repo ,
976+ WIFPoolName : gcf .DefaultInferencePool ,
977+ }, gcf .NewLiveGCFClient (inferenceProject )),
978+ }
979+ }
980+
981+ // Scaffold commit function wrapping layers.CommitScaffoldFiles, which
982+ // provides retry on non-fast-forward errors, branch-protection fallback
983+ // to PR delivery, and fork-based PR support for non-owner users.
984+ scaffoldCommitFn := func (ctx context.Context , owner , repo string , files []forge.TreeFile , direct bool ) error {
985+ targetRepo , repoErr := client .GetRepo (ctx , owner , repo )
986+ if repoErr != nil {
987+ return fmt .Errorf ("getting repo info: %w" , repoErr )
988+ }
989+ commitMsg := fmt .Sprintf ("chore: initialize fullsend-%s per-repo installation" , version )
990+ prTitle := "chore: initialize fullsend per-repo installation"
991+ prBody := "This PR adds the fullsend scaffold files for per-repo installation.\n \n " +
992+ "Merge this PR to activate fullsend workflows."
993+ if direct {
994+ printer .StepStart (fmt .Sprintf ("Committing scaffold files to %s/%s (%s branch)" ,
995+ owner , repo , targetRepo .DefaultBranch ))
996+ } else {
997+ printer .StepStart (fmt .Sprintf ("Creating scaffold PR for %s/%s (target: %s)" ,
998+ owner , repo , targetRepo .DefaultBranch ))
999+ }
1000+ _ , err := layers .CommitScaffoldFiles (ctx , client , printer , owner , repo ,
1001+ targetRepo .DefaultBranch , commitMsg , prTitle , prBody , files , direct , os .Stdin )
1002+ return err
1003+ }
1004+
1005+ installCfg := repos.InstallConfig {
1006+ Owner : owner ,
1007+ Repo : repo ,
1008+ Roles : roles ,
1009+ MintURL : mintURL ,
1010+ InferenceProject : inferenceProject ,
1011+ InferenceRegion : inferenceRegion ,
1012+ UpstreamRef : upstreamRef ,
1013+ UpstreamTag : upstreamTag ,
1014+ SkipMintCheck : true , // already handled above
1015+ SkipAppSetup : true , // already handled above
1016+ SkipGuardCheck : true , // admin.go handles guard check itself
1017+ SkipWIF : ! needsWIFProvision ,
1018+ WIFProvider : inferenceWIFProvider ,
1019+ VendorBinary : vendor ,
1020+ Direct : c .Direct ,
1021+ SkipScaffoldAndConfig : vendor , // vendor path commits scaffold+vendor atomically below
1022+ }
1023+
1024+ progressFn := func (_ string , phase , msg string ) {
1025+ switch phase {
1026+ case "wif" :
1027+ if strings .Contains (msg , "Provisioning" ) {
1028+ printer .StepStart (msg )
1029+ } else if strings .Contains (msg , "ready" ) {
1030+ printer .StepDone (msg )
1031+ printer .StepInfo ("IAM policy changes may take up to 7 minutes to propagate" )
1032+ printer .StepInfo ("Agent workflows that authenticate via WIF may fail until propagation completes" )
1033+ }
1034+ case "scaffold" :
1035+ if strings .Contains (msg , "Committing" ) || strings .Contains (msg , "Generating" ) {
1036+ printer .StepStart (msg )
1037+ } else {
1038+ printer .StepDone (msg )
1039+ }
1040+ case "vars" :
1041+ if strings .Contains (msg , "Configuring" ) {
1042+ printer .StepStart (msg )
1043+ } else {
1044+ printer .StepDone (msg )
1045+ }
1046+ case "secrets" :
1047+ if strings .Contains (msg , "Configuring" ) {
1048+ printer .StepStart (msg )
1049+ } else {
1050+ printer .StepDone (msg )
1051+ }
9861052 }
987- printer .StepDone ("WIF infrastructure ready" )
988- printer .StepInfo ("IAM policy changes may take up to 7 minutes to propagate" )
989- printer .StepInfo ("Agent workflows that authenticate via WIF may fail until propagation completes" )
9901053 }
9911054
992- repoVars := map [string ]string {
993- "FULLSEND_MINT_URL" : mintURL ,
994- "FULLSEND_GCP_REGION" : inferenceRegion ,
995- forge .PerRepoGuardVar : "true" ,
1055+ installResult , installErr := repos .Install (ctx , installCfg , client , wifProvisioner , scaffoldCommitFn , progressFn )
1056+ if installErr != nil {
1057+ return installErr
9961058 }
9971059
998- repoSecrets := map [string ]string {
999- "FULLSEND_GCP_PROJECT_ID" : inferenceProject ,
1000- "FULLSEND_GCP_WIF_PROVIDER" : inferenceWIFProvider ,
1060+ if installResult .WIFProvider != "" {
1061+ inferenceWIFProvider = installResult .WIFProvider
10011062 }
10021063
10031064 if vendor {
1004- var vendorErr error
1005- files , _ , vendorErr = appendVendorTreeFiles (printer , owner , repo , files , vendor , fullsendBinary , fullsendSource )
1065+ scaffoldFiles , buildErr := repos .BuildScaffoldFiles (installCfg )
1066+ if buildErr != nil {
1067+ return fmt .Errorf ("building scaffold files for vendor: %w" , buildErr )
1068+ }
1069+ vendorFiles , _ , vendorErr := appendVendorTreeFiles (printer , owner , repo , scaffoldFiles , vendor , fullsendBinary , fullsendSource )
10061070 if vendorErr != nil {
10071071 return fmt .Errorf ("collecting vendored assets: %w" , vendorErr )
10081072 }
1009- }
1010-
1011- if err := applyPerRepoScaffold (ctx , client , printer , owner , repo , files , repoVars , repoSecrets , c .Direct ); err != nil {
1012- return err
1073+ repoVars := map [string ]string {
1074+ "FULLSEND_MINT_URL" : mintURL ,
1075+ "FULLSEND_GCP_REGION" : inferenceRegion ,
1076+ forge .PerRepoGuardVar : "true" ,
1077+ }
1078+ repoSecrets := map [string ]string {
1079+ "FULLSEND_GCP_PROJECT_ID" : inferenceProject ,
1080+ "FULLSEND_GCP_WIF_PROVIDER" : inferenceWIFProvider ,
1081+ }
1082+ if err := applyPerRepoScaffold (ctx , client , printer , owner , repo , vendorFiles , repoVars , repoSecrets , c .Direct ); err != nil {
1083+ return err
1084+ }
10131085 }
10141086
10151087 if ! vendor {
@@ -1023,6 +1095,58 @@ func runPerRepoInstall(ctx context.Context, c perRepoInstallConfig) error {
10231095 return nil
10241096}
10251097
1098+ // gcfWIFAdapter wraps a gcf.Provisioner to implement repos.WIFProvisioner,
1099+ // bridging the GCF-specific provisioner to the package-agnostic interface.
1100+ type gcfWIFAdapter struct {
1101+ provisioner * gcf.Provisioner
1102+ }
1103+
1104+ func (a * gcfWIFAdapter ) DiscoverMint (ctx context.Context ) (* repos.MintDiscovery , error ) {
1105+ if a .provisioner == nil {
1106+ return nil , repos .ErrMintNotFound
1107+ }
1108+ d , err := a .provisioner .DiscoverMint (ctx )
1109+ if err != nil {
1110+ if errors .Is (err , gcf .ErrFunctionNotFound ) {
1111+ return nil , fmt .Errorf ("%w: %w" , repos .ErrMintNotFound , err )
1112+ }
1113+ return nil , err
1114+ }
1115+ return & repos.MintDiscovery {
1116+ URL : d .URL ,
1117+ RoleAppIDs : d .RoleAppIDs ,
1118+ PerRepoWIFRepos : d .PerRepoWIFRepos ,
1119+ }, nil
1120+ }
1121+
1122+ func (a * gcfWIFAdapter ) ProvisionWIF (ctx context.Context ) (string , error ) {
1123+ if a .provisioner == nil {
1124+ return "" , fmt .Errorf ("WIF provisioner not configured" )
1125+ }
1126+ return a .provisioner .ProvisionWIF (ctx )
1127+ }
1128+
1129+ func (a * gcfWIFAdapter ) RegisterPerRepoWIF (ctx context.Context , repo string ) error {
1130+ if a .provisioner == nil {
1131+ return fmt .Errorf ("WIF provisioner not configured" )
1132+ }
1133+ return a .provisioner .RegisterPerRepoWIF (ctx , repo )
1134+ }
1135+
1136+ func (a * gcfWIFAdapter ) EnsureOrgInMint (ctx context.Context , expectedURL string , org string ) error {
1137+ if a .provisioner == nil {
1138+ return fmt .Errorf ("WIF provisioner not configured" )
1139+ }
1140+ return a .provisioner .EnsureOrgInMint (ctx , expectedURL , org )
1141+ }
1142+
1143+ func (a * gcfWIFAdapter ) DeletePerRepoWIF (ctx context.Context , repo string ) error {
1144+ if a .provisioner == nil {
1145+ return fmt .Errorf ("WIF provisioner not configured" )
1146+ }
1147+ return a .provisioner .RemoveRepoFromMint (ctx , repo )
1148+ }
1149+
10261150// applyPerRepoScaffold commits scaffold files to the repo's default branch
10271151// and configures the repository variables and secrets needed for fullsend.
10281152func applyPerRepoScaffold (ctx context.Context , client forge.Client , printer * ui.Printer ,
0 commit comments