Skip to content

Commit 73193b5

Browse files
committed
Replace gh CLI with native GitHub OAuth device flow
Introduce pkg/githubauth with two-app architecture (CI App + User App), device flow login, token caching, and direct REST client. Remove all exec.Command("gh") calls. Rewrite tests to use httptest.
1 parent 88df868 commit 73193b5

18 files changed

Lines changed: 1767 additions & 697 deletions

File tree

cmd/apx/commands/init.go

Lines changed: 94 additions & 43 deletions
Original file line numberDiff line numberDiff line change
@@ -12,6 +12,7 @@ import (
1212
"github.com/infobloxopen/apx/internal/interactive"
1313
"github.com/infobloxopen/apx/internal/schema"
1414
"github.com/infobloxopen/apx/internal/ui"
15+
"github.com/infobloxopen/apx/pkg/githubauth"
1516
"github.com/spf13/cobra"
1617
)
1718

@@ -54,7 +55,7 @@ func newInitCanonicalCmd() *cobra.Command {
5455
cmd.Flags().String("site-url", "", "Custom domain for the catalog site (e.g. apis.internal.infoblox.dev)")
5556
cmd.Flags().Bool("skip-git", false, "Skip git initialization")
5657
cmd.Flags().Bool("non-interactive", false, "Disable interactive prompts and require all flags")
57-
cmd.Flags().Bool("setup-github", false, "Configure GitHub repo settings (branch/tag protection, org secrets) via gh CLI")
58+
cmd.Flags().Bool("setup-github", false, "Configure GitHub repo settings (apps, branch/tag protection, org secrets)")
5859
cmd.Flags().String("app-id", "", "GitHub App ID for org secrets (used with --setup-github)")
5960
cmd.Flags().String("app-pem-file", "", "Path to GitHub App private key PEM file (used with --setup-github)")
6061
return cmd
@@ -71,7 +72,7 @@ func newInitAppCmd() *cobra.Command {
7172
cmd.Flags().String("repo", "", "Repository name")
7273
cmd.Flags().String("import-root", "", "Custom public Go import prefix (e.g. go.acme.dev/apis)")
7374
cmd.Flags().Bool("non-interactive", false, "Disable interactive prompts and require all flags")
74-
cmd.Flags().Bool("setup-github", false, "Configure GitHub repo settings (branch protection) via gh CLI")
75+
cmd.Flags().Bool("setup-github", false, "Configure GitHub repo settings (branch protection)")
7576
return cmd
7677
}
7778

@@ -251,70 +252,117 @@ func initCanonicalAction(cmd *cobra.Command, args []string) error {
251252
ui.Success("\u2713 Generated .github/workflows/ci.yml")
252253
ui.Success("\u2713 Generated .github/workflows/on-merge.yml")
253254

254-
// --setup-github: configure GitHub repo settings via gh CLI
255+
// --setup-github: configure GitHub repo settings
255256
setupGitHub, _ := cmd.Flags().GetBool("setup-github")
256257
if setupGitHub {
257-
// Preflight: verify gh is installed, authenticated, and has required scopes.
258-
if err := gh.CheckGHAuth(); err != nil {
259-
return fmt.Errorf("GitHub setup preflight failed: %w", err)
260-
}
261-
if err := gh.CheckGHScopes(); err != nil {
262-
return fmt.Errorf("GitHub setup preflight failed: %w", err)
263-
}
264-
265258
appID, _ := cmd.Flags().GetString("app-id")
266259
pemFile, _ := cmd.Flags().GetString("app-pem-file")
267260

268-
// Try to resolve from cache if flags are not provided.
261+
// ── Step 1: User App ────────────────────────────────────────
262+
// Create the user-facing GitHub App (apx-{org}-user) if not cached.
263+
// This app provides device-flow OAuth for human users.
264+
userClientID := gh.GetCachedUserAppClientID(org)
265+
if userClientID == "" {
266+
if nonInteractive {
267+
return fmt.Errorf("user app not configured for org %q; run interactively first", org)
268+
}
269+
ui.Info("\nCreating user app %q via GitHub App manifest flow...", gh.UserAppName(org))
270+
creds, createErr := gh.CreateAppViaManifest(org, gh.UserAppName(org), gh.UserAppPermissions)
271+
if createErr != nil {
272+
return fmt.Errorf("failed to create user app: %w", createErr)
273+
}
274+
if err := gh.CacheUserAppClientID(org, creds.ClientID); err != nil {
275+
return fmt.Errorf("failed to cache user app client ID: %w", err)
276+
}
277+
if err := gh.CacheUserAppID(org, fmt.Sprintf("%d", creds.ID)); err != nil {
278+
return fmt.Errorf("failed to cache user app ID: %w", err)
279+
}
280+
if creds.Slug != "" {
281+
if err := gh.CacheUserAppSlug(org, creds.Slug); err != nil {
282+
ui.Warning("Failed to cache user app slug: %v", err)
283+
}
284+
}
285+
userClientID = creds.ClientID
286+
ui.Success("User app created! Client ID: %s", userClientID)
287+
} else {
288+
ui.Info("User app already configured (client_id cached).")
289+
}
290+
291+
// ── Step 2: Device flow login ───────────────────────────────
292+
// Authenticate the user via OAuth device flow to get a token.
293+
token, tokenErr := githubauth.EnsureToken(org)
294+
if tokenErr != nil {
295+
return fmt.Errorf("GitHub authentication failed: %w", tokenErr)
296+
}
297+
client := githubauth.NewClient(token)
298+
299+
// ── Step 3: Ensure user app is installed ────────────────────
300+
userAppIDStr := gh.GetCachedUserAppID(org)
301+
userAppSlug := gh.GetCachedUserAppSlug(org)
302+
if userAppIDStr != "" && userAppSlug != "" {
303+
userAppIDInt, _ := strconv.Atoi(userAppIDStr)
304+
if userAppIDInt > 0 {
305+
if err := gh.EnsureAppInstalled(client, org, userAppIDInt, userAppSlug); err != nil {
306+
ui.Warning("Could not verify user app installation: %v", err)
307+
}
308+
}
309+
}
310+
311+
// ── Step 4: CI App ──────────────────────────────────────────
312+
// Create the CI GitHub App (apx-{repo}-{org}) if not cached.
269313
if appID == "" {
270314
appID = gh.GetCachedAppID(org)
271315
}
272316
pemCached := false
273-
if cachePath, err := gh.PEMCachePath(org); err == nil {
317+
if cachePath, pemErr := gh.PEMCachePath(org); pemErr == nil {
274318
if _, statErr := os.Stat(cachePath); statErr == nil {
275319
pemCached = true
276320
}
277321
}
278322

279323
needsApp := appID == "" || (!pemCached && pemFile == "")
280324
if needsApp && !nonInteractive {
281-
ui.Info("\nNo GitHub App configured for org %q.", org)
282-
ui.Info("Creating one via the GitHub App manifest flow...\n")
283-
284-
newAppID, appSlug, pemContents, err := gh.CreateAppViaManifest(org, repo)
285-
if err != nil {
286-
return fmt.Errorf("failed to create GitHub App: %w", err)
325+
ui.Info("\nCreating CI app %q via GitHub App manifest flow...", gh.CIAppName(repo, org))
326+
creds, createErr := gh.CreateAppViaManifest(org, gh.CIAppName(repo, org), gh.CIAppPermissions)
327+
if createErr != nil {
328+
return fmt.Errorf("failed to create CI app: %w", createErr)
287329
}
288-
289-
if err := gh.CachePEMFromContents(org, pemContents); err != nil {
330+
if err := gh.CachePEMFromContents(org, creds.PEM); err != nil {
290331
return fmt.Errorf("failed to cache PEM: %w", err)
291332
}
292-
if err := gh.CacheAppID(org, newAppID); err != nil {
293-
return fmt.Errorf("failed to cache app ID: %w", err)
333+
if err := gh.CacheAppID(org, fmt.Sprintf("%d", creds.ID)); err != nil {
334+
return fmt.Errorf("failed to cache CI app ID: %w", err)
294335
}
295-
if appSlug != "" {
296-
if err := gh.CacheAppSlug(org, appSlug); err != nil {
297-
ui.Warning("Failed to cache app slug: %v", err)
336+
if creds.Slug != "" {
337+
if err := gh.CacheAppSlug(org, creds.Slug); err != nil {
338+
ui.Warning("Failed to cache CI app slug: %v", err)
298339
}
299340
}
341+
appID = fmt.Sprintf("%d", creds.ID)
342+
ui.Success("CI app created! App ID: %s", appID)
300343

301-
appID = newAppID
302-
ui.Success("GitHub App created! App ID: %s", appID)
344+
// Ensure CI app is installed on the org.
345+
if creds.Slug != "" {
346+
if err := gh.EnsureAppInstalled(client, org, creds.ID, creds.Slug); err != nil {
347+
ui.Warning("Could not verify CI app installation: %v", err)
348+
}
349+
}
303350
} else if needsApp {
304351
return fmt.Errorf("--app-id and --app-pem-file are required with --setup-github in non-interactive mode")
305352
} else {
306-
// App already exists – ensure it is installed on the org
307-
appSlug := gh.GetCachedAppSlug(org)
308-
if appSlug != "" {
353+
// CI app already exists – ensure it is installed on the org.
354+
ciSlug := gh.GetCachedAppSlug(org)
355+
if ciSlug != "" {
309356
appIDInt, _ := strconv.Atoi(appID)
310357
if appIDInt > 0 {
311-
if err := gh.EnsureAppInstalled(org, appIDInt, appSlug); err != nil {
312-
ui.Warning("Could not verify app installation: %v", err)
358+
if err := gh.EnsureAppInstalled(client, org, appIDInt, ciSlug); err != nil {
359+
ui.Warning("Could not verify CI app installation: %v", err)
313360
}
314361
}
315362
}
316363
}
317364

365+
// ── Step 5: Set up canonical repo ───────────────────────────
318366
// Resolve siteURL from config if not provided via flag.
319367
if siteURL == "" {
320368
if cfg, cfgErr := config.LoadRaw("apx.yaml"); cfgErr == nil && cfg.SiteURL != "" {
@@ -323,9 +371,9 @@ func initCanonicalAction(cmd *cobra.Command, args []string) error {
323371
}
324372

325373
ui.Info("\nConfiguring GitHub repository...")
326-
res, err := gh.SetupCanonicalRepo(org, repo, appID, pemFile, siteURL)
327-
if err != nil {
328-
return fmt.Errorf("GitHub setup failed: %w", err)
374+
res, setupErr := gh.SetupCanonicalRepo(client, org, repo, appID, pemFile, siteURL)
375+
if setupErr != nil {
376+
return fmt.Errorf("GitHub setup failed: %w", setupErr)
329377
}
330378
res.Print()
331379
}
@@ -342,7 +390,7 @@ func initCanonicalAction(cmd *cobra.Command, args []string) error {
342390
ui.Info(" - Restrict direct pushes to main")
343391
ui.Info("5. Push: git remote add origin <url> && git push -u origin main")
344392
ui.Info("")
345-
ui.Info("Or re-run with --setup-github to configure automatically via gh CLI.")
393+
ui.Info("Or re-run with --setup-github to configure automatically.")
346394
}
347395

348396
ui.Success("\n\u2713 Canonical API repository initialized successfully!")
@@ -460,16 +508,19 @@ func initAppAction(cmd *cobra.Command, args []string) error {
460508
ui.Success("\u2713 Generated buf.work.yaml")
461509
ui.Success("\u2713 Generated .github/workflows/apx-release.yml")
462510

463-
// --setup-github: configure GitHub repo settings via gh CLI
511+
// --setup-github: configure GitHub repo settings
464512
setupGitHub, _ := cmd.Flags().GetBool("setup-github")
465513
if setupGitHub {
466-
if err := gh.CheckGHAuth(); err != nil {
467-
return fmt.Errorf("GitHub setup preflight failed: %w", err)
514+
token, tokenErr := githubauth.EnsureToken(org)
515+
if tokenErr != nil {
516+
return fmt.Errorf("GitHub authentication failed: %w", tokenErr)
468517
}
518+
client := githubauth.NewClient(token)
519+
469520
ui.Info("\nConfiguring GitHub repository...")
470-
res, err := gh.SetupAppRepo(org, repo)
471-
if err != nil {
472-
return fmt.Errorf("GitHub setup failed: %w", err)
521+
res, setupErr := gh.SetupAppRepo(client, org, repo)
522+
if setupErr != nil {
523+
return fmt.Errorf("GitHub setup failed: %w", setupErr)
473524
}
474525
res.Print()
475526
}

cmd/apx/commands/release.go

Lines changed: 16 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -16,6 +16,7 @@ import (
1616
"github.com/infobloxopen/apx/internal/publisher"
1717
"github.com/infobloxopen/apx/internal/ui"
1818
"github.com/infobloxopen/apx/internal/validator"
19+
"github.com/infobloxopen/apx/pkg/githubauth"
1920
"github.com/spf13/cobra"
2021
)
2122

@@ -500,28 +501,37 @@ func releaseSubmitAction(cmd *cobra.Command, _ []string) error {
500501
return nil
501502
}
502503

503-
// ── Preflight: gh CLI check ──────────────────────────────────────
504-
if err := publisher.CheckGHCLI(); err != nil {
504+
// ── Auth: ensure GitHub token ────────────────────────────────────
505+
org, orgErr := githubauth.DetectOrg()
506+
if orgErr != nil {
505507
return publisher.NewReleaseError(
506508
publisher.ErrCodePRCreationFailed,
507-
"GitHub CLI (gh) is required for release submission",
508-
).WithHint("Install gh from https://cli.github.com and run: gh auth login")
509+
"Cannot detect GitHub org from git remote",
510+
).WithHint("Ensure you are in a git repository with a GitHub remote")
509511
}
512+
token, tokenErr := githubauth.EnsureToken(org)
513+
if tokenErr != nil {
514+
return publisher.NewReleaseError(
515+
publisher.ErrCodePRCreationFailed,
516+
fmt.Sprintf("GitHub authentication failed: %v", tokenErr),
517+
).WithHint("Run 'apx init canonical --setup-github' to set up GitHub authentication")
518+
}
519+
ghClient := githubauth.NewClient(token)
510520

511521
// ── Submit via PR ────────────────────────────────────────────────
512522
ui.Info("Submitting release %s @ %s", manifest.APIID, manifest.RequestedVersion)
513523

514524
// Build CI provenance extra for PR body
515525
prBodyExtra := buildCIProvenance()
516526

517-
resp, err := publisher.SubmitReleaseWithPR(manifest, manifest.SourcePath, prBodyExtra)
527+
resp, err := publisher.SubmitReleaseWithPR(ghClient, manifest, manifest.SourcePath, prBodyExtra)
518528
if err != nil {
519529
manifest.Fail(string(publisher.ErrCodePRCreationFailed), err.Error(), "submit")
520530
_ = publisher.WriteManifest(manifest, ".apx-release.yaml")
521531
return &publisher.ReleaseError{
522532
Code: publisher.ErrCodePRCreationFailed,
523533
Message: fmt.Sprintf("release submission failed: %v", err),
524-
Hint: "Check gh auth status and try 'apx release submit' again",
534+
Hint: "Check authentication and try 'apx release submit' again",
525535
}
526536
}
527537

go.mod

Lines changed: 3 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -40,9 +40,10 @@ require (
4040
github.com/rivo/uniseg v0.4.7 // indirect
4141
github.com/spf13/pflag v1.0.9 // indirect
4242
github.com/xo/terminfo v0.0.0-20220910002029-abceb7e1c41e // indirect
43+
golang.org/x/crypto v0.48.0 // indirect
4344
golang.org/x/mod v0.33.0 // indirect
4445
golang.org/x/sync v0.19.0 // indirect
45-
golang.org/x/sys v0.40.0 // indirect
46-
golang.org/x/text v0.23.0 // indirect
46+
golang.org/x/sys v0.41.0 // indirect
47+
golang.org/x/text v0.34.0 // indirect
4748
golang.org/x/tools v0.41.0 // indirect
4849
)

go.sum

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -85,6 +85,8 @@ github.com/stretchr/testify v1.11.1/go.mod h1:wZwfW3scLgRK+23gO65QZefKpKQRnfz6sD
8585
github.com/xo/terminfo v0.0.0-20220910002029-abceb7e1c41e h1:JVG44RsyaB9T2KIHavMF/ppJZNG9ZpyihvCd0w101no=
8686
github.com/xo/terminfo v0.0.0-20220910002029-abceb7e1c41e/go.mod h1:RbqR21r5mrJuqunuUZ/Dhy/avygyECGrLceyNeo4LiM=
8787
go.yaml.in/yaml/v3 v3.0.4/go.mod h1:DhzuOOF2ATzADvBadXxruRBLzYTpT36CKvDb3+aBEFg=
88+
golang.org/x/crypto v0.48.0 h1:/VRzVqiRSggnhY7gNRxPauEQ5Drw9haKdM0jqfcCFts=
89+
golang.org/x/crypto v0.48.0/go.mod h1:r0kV5h3qnFPlQnBSrULhlsRfryS2pmewsg+XfMgkVos=
8890
golang.org/x/exp v0.0.0-20231006140011-7918f672742d h1:jtJma62tbqLibJ5sFQz8bKtEM8rJBtfilJ2qTU199MI=
8991
golang.org/x/exp v0.0.0-20231006140011-7918f672742d/go.mod h1:ldy0pHrwJyGW56pPQzzkH36rKxoZW1tw7ZJpeKx+hdo=
9092
golang.org/x/mod v0.33.0 h1:tHFzIWbBifEmbwtGz65eaWyGiGZatSrT9prnU8DbVL8=
@@ -100,8 +102,12 @@ golang.org/x/sys v0.33.0 h1:q3i8TbbEz+JRD9ywIRlyRAQbM0qF7hu24q3teo2hbuw=
100102
golang.org/x/sys v0.33.0/go.mod h1:BJP2sWEmIv4KK5OTEluFJCKSidICx8ciO85XgH3Ak8k=
101103
golang.org/x/sys v0.40.0 h1:DBZZqJ2Rkml6QMQsZywtnjnnGvHza6BTfYFWY9kjEWQ=
102104
golang.org/x/sys v0.40.0/go.mod h1:OgkHotnGiDImocRcuBABYBEXf8A9a87e/uXjp9XT3ks=
105+
golang.org/x/sys v0.41.0 h1:Ivj+2Cp/ylzLiEU89QhWblYnOE9zerudt9Ftecq2C6k=
106+
golang.org/x/sys v0.41.0/go.mod h1:OgkHotnGiDImocRcuBABYBEXf8A9a87e/uXjp9XT3ks=
103107
golang.org/x/text v0.23.0 h1:D71I7dUrlY+VX0gQShAThNGHFxZ13dGLBHQLVl1mJlY=
104108
golang.org/x/text v0.23.0/go.mod h1:/BLNzu4aZCJ1+kcD0DNRotWKage4q2rGVAg4o22unh4=
109+
golang.org/x/text v0.34.0 h1:oL/Qq0Kdaqxa1KbNeMKwQq0reLCCaFtqu2eNuSeNHbk=
110+
golang.org/x/text v0.34.0/go.mod h1:homfLqTYRFyVYemLBFl5GgL/DWEiH5wcsQ5gSh1yziA=
105111
golang.org/x/tools v0.26.0 h1:v/60pFQmzmT9ExmjDv2gGIfi3OqfKoEP6I5+umXlbnQ=
106112
golang.org/x/tools v0.26.0/go.mod h1:TPVVj70c7JJ3WCazhD8OdXcZg/og+b9+tH/KxylGwH0=
107113
golang.org/x/tools v0.41.0 h1:a9b8iMweWG+S0OBnlU36rzLp20z1Rp10w+IY2czHTQc=

internal/catalog/discover.go

Lines changed: 21 additions & 9 deletions
Original file line numberDiff line numberDiff line change
@@ -3,8 +3,9 @@ package catalog
33
import (
44
"encoding/json"
55
"fmt"
6-
"os/exec"
76
"strings"
7+
8+
"github.com/infobloxopen/apx/pkg/githubauth"
89
)
910

1011
// ghPackage represents a minimal GitHub Package from the Packages API.
@@ -16,27 +17,38 @@ type ghPackage struct {
1617
// in the given org. It returns a RegistrySource for each package whose name
1718
// ends with "-catalog".
1819
//
19-
// Requires `gh` to be installed and authenticated with read:packages scope.
20-
// Returns nil (no sources) if gh is not available or no catalogs are found.
20+
// Uses the githubauth package for authentication. Returns nil (no sources)
21+
// if authentication is unavailable or no catalogs are found.
2122
func DiscoverRegistries(org string) []CatalogSource {
2223
return discoverRegistries(org, ghRunDiscover)
2324
}
2425

25-
// ghRunDiscover is the default implementation that calls `gh api`.
26+
// ghRunDiscover is the default implementation that calls the GitHub API
27+
// via githubauth.
2628
var ghRunDiscover = ghRunDiscoverReal
2729

2830
func ghRunDiscoverReal(org string) ([]byte, error) {
29-
return exec.Command("gh", "api",
30-
fmt.Sprintf("/orgs/%s/packages?package_type=container", org),
31-
"--paginate",
32-
).Output()
31+
token, err := githubauth.EnsureToken(org)
32+
if err != nil {
33+
return nil, fmt.Errorf("GitHub auth failed: %w", err)
34+
}
35+
36+
client := githubauth.NewClient(token)
37+
items, err := client.GetPaginated(fmt.Sprintf("/orgs/%s/packages?package_type=container", org))
38+
if err != nil {
39+
return nil, err
40+
}
41+
42+
// Re-serialize the items as a JSON array for backward compatibility
43+
// with the existing parser below.
44+
return json.Marshal(items)
3345
}
3446

3547
// discoverRegistries is the testable core — accepts a runner function.
3648
func discoverRegistries(org string, runner func(string) ([]byte, error)) []CatalogSource {
3749
output, err := runner(org)
3850
if err != nil {
39-
return nil // gh not available or API error — silent fallback
51+
return nil // auth not available or API error — silent fallback
4052
}
4153

4254
var packages []ghPackage

0 commit comments

Comments
 (0)