diff --git a/docs/guides/getting-started/configuring-github.md b/docs/guides/getting-started/configuring-github.md index 60225d317..1ebfe7fd1 100644 --- a/docs/guides/getting-started/configuring-github.md +++ b/docs/guides/getting-started/configuring-github.md @@ -8,6 +8,42 @@ The goal of this document is that you configure Fullsend for your GitHub reposit * Download the latest [fullsend](https://github.com/fullsend-ai/fullsend/releases) CLI. * Download the latest [gh](https://cli.github.com/) CLI and authenticate with it. +### Token resolution + +The fullsend CLI resolves GitHub credentials in the following order: + +1. **`GH_TOKEN`** environment variable +2. **`GITHUB_TOKEN`** environment variable +3. **`gh auth token`** (from `gh auth login`) + +The first non-empty value wins. If your organization restricts token types +(see below), export a fine-grained PAT as `GH_TOKEN` to override the default +`gh` credential. + +### Organizations that restrict classic PATs + +Some GitHub organizations enforce a policy that blocks classic personal access +tokens. When this policy is active, the default token from `gh auth login` +(which is a classic PAT) will be rejected with a 403 error. + +To work with these organizations, create a **fine-grained personal access +token** and export it as `GH_TOKEN`: + +1. Go to +2. Scope the token to your target organization +3. Grant the following **repository permissions**: + +| Permission | Level | Why | +|---|---|---| +| Contents | Read and write | Commits `.fullsend/config.yaml` and scaffold files | +| Workflows | Read and write | Writes/updates files under `.github/workflows/` | +| Secrets | Read and write | Sets `FULLSEND_GCP_PROJECT_ID` / `FULLSEND_GCP_WIF_PROVIDER` | +| Variables | Read and write | Sets `FULLSEND_MINT_URL` / `FULLSEND_GCP_REGION` | +| Metadata | Read-only | GitHub-required baseline (automatically selected) | +| Pull requests | Read and write | Only needed without `--direct` | + +4. Export the token: `export GH_TOKEN='github_pat_...'` + ## Installing GitHub Applications In order to use Fullsend install the following applications to your organization diff --git a/internal/cli/admin.go b/internal/cli/admin.go index fcc9af3fc..1b37bb2af 100644 --- a/internal/cli/admin.go +++ b/internal/cli/admin.go @@ -81,6 +81,31 @@ func resolveToken() (string, error) { return "", fmt.Errorf("no GitHub token found: set GH_TOKEN, GITHUB_TOKEN, or run 'gh auth login'") } +// classicPATHint wraps err with remediation guidance when the +// organization blocks classic personal access tokens. For any other +// error it returns err unchanged. +func classicPATHint(err error) error { + if !forge.IsClassicPATForbidden(err) { + return err + } + return fmt.Errorf("%w\n\n"+ + "This organization requires a fine-grained personal access token.\n"+ + "The fullsend CLI resolves credentials in this order:\n"+ + " 1. GH_TOKEN environment variable\n"+ + " 2. GITHUB_TOKEN environment variable\n"+ + " 3. gh auth token (from 'gh auth login')\n\n"+ + "To fix this:\n"+ + " 1. Create a fine-grained PAT at https://github.com/settings/personal-access-tokens/new\n"+ + " 2. Scope it to your organization and grant these repository permissions:\n"+ + " Contents: Read and write\n"+ + " Workflows: Read and write\n"+ + " Secrets: Read and write\n"+ + " Variables: Read and write\n"+ + " Metadata: Read-only (automatically selected)\n"+ + " Pull requests: Read and write (only needed without --direct)\n"+ + " 3. Export it: export GH_TOKEN='github_pat_...'", err) +} + // validateOrgName checks that org is a valid GitHub organization name. func validateOrgName(org string) error { if org == "" { @@ -433,7 +458,7 @@ Inference authentication: // Discover all org repos upfront to avoid redundant API calls in runDryRun/runInstall. allRepos, err := client.ListOrgRepos(ctx, org) if err != nil { - return fmt.Errorf("listing org repos: %w", err) + return classicPATHint(fmt.Errorf("listing org repos: %w", err)) } var repos []string @@ -1021,7 +1046,7 @@ func applyPerRepoScaffold(ctx context.Context, client forge.Client, printer *ui. targetRepo, err := client.GetRepo(ctx, owner, repo) if err != nil { - return fmt.Errorf("getting repo info: %w", err) + return classicPATHint(fmt.Errorf("getting repo info: %w", err)) } commitMsg := fmt.Sprintf("chore: initialize fullsend-%s per-repo installation", version) printer.StepStart(fmt.Sprintf("Committing scaffold files to %s/%s (%s branch)", @@ -1163,7 +1188,7 @@ func runDryRun(ctx context.Context, client forge.Client, printer *ui.Printer, or } else { allRepos, err = client.ListOrgRepos(ctx, org) if err != nil { - return fmt.Errorf("listing org repos: %w", err) + return classicPATHint(fmt.Errorf("listing org repos: %w", err)) } printer.StepDone(fmt.Sprintf("Found %d repositories", len(allRepos))) } @@ -1429,7 +1454,7 @@ func ensureConfigRepoExists(ctx context.Context, client forge.Client, printer *u return nil } if !forge.IsNotFound(err) { - return fmt.Errorf("checking for config repo: %w", err) + return classicPATHint(fmt.Errorf("checking for config repo: %w", err)) } printer.StepStart("Creating " + forge.ConfigRepoName + " repository") @@ -1441,7 +1466,7 @@ func ensureConfigRepoExists(ctx context.Context, client forge.Client, printer *u return nil } printer.StepFail("Failed to create " + forge.ConfigRepoName + " repository") - return fmt.Errorf("creating config repo: %w", err) + return classicPATHint(fmt.Errorf("creating config repo: %w", err)) } printer.StepDone("Created " + forge.ConfigRepoName + " repository") return nil @@ -1492,7 +1517,7 @@ func runInstall(ctx context.Context, client forge.Client, printer *ui.Printer, o printer.Header("Discovering repositories") allRepos, err = client.ListOrgRepos(ctx, org) if err != nil { - return fmt.Errorf("listing org repos: %w", err) + return classicPATHint(fmt.Errorf("listing org repos: %w", err)) } printer.StepDone(fmt.Sprintf("Found %d repositories", len(allRepos))) } @@ -1785,7 +1810,7 @@ func runUninstall(ctx context.Context, client forge.Client, printer *ui.Printer, func runAnalyze(ctx context.Context, client forge.Client, printer *ui.Printer, org string) error { allRepos, err := client.ListOrgRepos(ctx, org) if err != nil { - return fmt.Errorf("listing org repos: %w", err) + return classicPATHint(fmt.Errorf("listing org repos: %w", err)) } repoNames := repoNameList(allRepos) @@ -2192,6 +2217,10 @@ func runEnableRepos(ctx context.Context, client forge.Client, printer *ui.Printe printer.StepStart("Discovering all organization repositories") allOrgRepos, err = client.ListOrgRepos(ctx, org) if err != nil { + if forge.IsClassicPATForbidden(err) { + printer.StepFail("Classic PAT rejected by organization policy") + return classicPATHint(fmt.Errorf("listing org repos: %w", err)) + } printer.StepFail("Failed to list organization repositories") printer.StepInfo("Hint: verify your token has 'repo' scope with: gh auth refresh -s repo") return fmt.Errorf("listing org repos: %w", err) @@ -2211,6 +2240,10 @@ func runEnableRepos(ctx context.Context, client forge.Client, printer *ui.Printe allOrgRepos, err = client.ListOrgRepos(ctx, org) if err != nil { + if forge.IsClassicPATForbidden(err) { + printer.StepFail("Classic PAT rejected by organization policy") + return classicPATHint(fmt.Errorf("listing org repos: %w", err)) + } printer.StepFail("Failed to list organization repositories") printer.StepInfo("Hint: verify your token has 'repo' scope with: gh auth refresh -s repo") return fmt.Errorf("listing org repos: %w", err) @@ -2443,7 +2476,11 @@ func runDisableRepos(ctx context.Context, client forge.Client, printer *ui.Print if cfg.Dispatch.Mode == "oidc-mint" { allOrgRepos, listErr := client.ListOrgRepos(ctx, org) if listErr != nil { - printer.StepWarn(fmt.Sprintf("could not list org repos for variable sync: %v", listErr)) + if forge.IsClassicPATForbidden(listErr) { + printer.StepWarn("Classic PAT rejected; skipping variable visibility sync") + } else { + printer.StepWarn(fmt.Sprintf("could not list org repos for variable sync: %v", listErr)) + } } else { syncOrgVariableVisibility(ctx, client, printer, org, cfg, allOrgRepos) } @@ -2479,6 +2516,10 @@ func loadRepoConfig(ctx context.Context, client forge.Client, printer *ui.Printe printer.StepFail(".fullsend repository not found") return nil, fmt.Errorf(".fullsend repository not found: run 'fullsend admin install %s' first", org) } + if forge.IsClassicPATForbidden(err) { + printer.StepFail("Classic PAT rejected by organization policy") + return nil, classicPATHint(fmt.Errorf("checking .fullsend repository: %w", err)) + } printer.StepFail("Failed to check .fullsend repository") printer.StepInfo("Hint: verify your token has 'repo' scope with: gh auth refresh -s repo") return nil, fmt.Errorf("checking .fullsend repository: %w", err) diff --git a/internal/cli/admin_test.go b/internal/cli/admin_test.go index 3363b574f..e282a2b4b 100644 --- a/internal/cli/admin_test.go +++ b/internal/cli/admin_test.go @@ -2235,3 +2235,19 @@ func TestApplyPerRepoScaffold_ProtectedBranch_BranchUpToDate(t *testing.T) { assert.Contains(t, buf.String(), "up to date") } + +func TestClassicPATHint(t *testing.T) { + t.Run("wraps classic PAT error with remediation", func(t *testing.T) { + inner := fmt.Errorf("listing org repos: %w", forge.ErrClassicPATForbidden) + got := classicPATHint(inner) + assert.ErrorIs(t, got, forge.ErrClassicPATForbidden) + assert.Contains(t, got.Error(), "fine-grained personal access token") + assert.Contains(t, got.Error(), "GH_TOKEN") + }) + + t.Run("passes through non-PAT error unchanged", func(t *testing.T) { + inner := fmt.Errorf("listing org repos: connection refused") + got := classicPATHint(inner) + assert.Equal(t, inner, got) + }) +} diff --git a/internal/cli/github.go b/internal/cli/github.go index 2dd31b06a..2b86641a2 100644 --- a/internal/cli/github.go +++ b/internal/cli/github.go @@ -351,7 +351,7 @@ func runGitHubSetupPerOrg(ctx context.Context, client forge.Client, printer *ui. allRepos, err := client.ListOrgRepos(ctx, org) if err != nil { - return fmt.Errorf("listing org repos: %w", err) + return classicPATHint(fmt.Errorf("listing org repos: %w", err)) } repoNames := repoNameList(allRepos) @@ -701,7 +701,7 @@ func runGitHubStatus(ctx context.Context, client forge.Client, printer *ui.Print printer.StepInfo("Run 'fullsend github setup " + org + "' to configure") return nil } - return fmt.Errorf("checking config repo: %w", err) + return classicPATHint(fmt.Errorf("checking config repo: %w", err)) } printer.StepDone(forge.ConfigRepoName + " repository exists") @@ -845,7 +845,7 @@ func runGitHubUninstall(ctx context.Context, client forge.Client, printer *ui.Pr if forge.IsNotFound(err) { printer.StepInfo(forge.ConfigRepoName + " repository already deleted") } else { - return fmt.Errorf("checking for config repo: %w", err) + return classicPATHint(fmt.Errorf("checking for config repo: %w", err)) } } else { printer.StepStart("Deleting " + forge.ConfigRepoName + " repository") @@ -916,7 +916,11 @@ func runGitHubUninstall(ctx context.Context, client forge.Client, printer *ui.Pr } } else { // Can't check — fall back to showing all of them. - printer.StepWarn("Could not verify which apps exist; showing all") + if forge.IsClassicPATForbidden(listErr) { + printer.StepWarn("Classic PAT rejected by organization policy; showing all apps") + } else { + printer.StepWarn("Could not verify which apps exist; showing all") + } existingSlugs = agentSlugs } if len(existingSlugs) > 0 { diff --git a/internal/forge/forge.go b/internal/forge/forge.go index b6b295aca..cd22331e8 100644 --- a/internal/forge/forge.go +++ b/internal/forge/forge.go @@ -41,6 +41,17 @@ func IsBranchProtected(err error) bool { return errors.Is(err, ErrBranchProtected) } +// ErrClassicPATForbidden indicates that the GitHub organization has a +// policy that blocks classic personal access tokens. The user must +// create a fine-grained PAT with the required repository permissions. +var ErrClassicPATForbidden = errors.New("classic PAT forbidden by organization policy") + +// IsClassicPATForbidden reports whether err indicates that a classic +// personal access token was rejected by an organization policy. +func IsClassicPATForbidden(err error) bool { + return errors.Is(err, ErrClassicPATForbidden) +} + // Repository represents a repository on a git forge. type Repository struct { ID int64 diff --git a/internal/forge/github/github.go b/internal/forge/github/github.go index b110b55c3..7023f65b6 100644 --- a/internal/forge/github/github.go +++ b/internal/forge/github/github.go @@ -86,6 +86,9 @@ func (e *APIError) Unwrap() error { if e.StatusCode == http.StatusUnprocessableEntity && isAlreadyExistsError(e) { return forge.ErrAlreadyExists } + if isClassicPATForbiddenError(e) { + return forge.ErrClassicPATForbidden + } return nil } @@ -804,6 +807,21 @@ func isAlreadyExistsError(apiErr *APIError) bool { return strings.Contains(msg, "already exists") } +// isClassicPATForbiddenError returns true when the API error indicates +// that the organization blocks classic personal access tokens. GitHub +// returns a 403 with a message containing "forbids access via a +// personal access token" in this case. +func isClassicPATForbiddenError(apiErr *APIError) bool { + if apiErr.StatusCode != http.StatusForbidden { + return false + } + msg := strings.ToLower(apiErr.Message) + for _, d := range apiErr.Errors { + msg += " " + strings.ToLower(d.Message) + } + return strings.Contains(msg, "forbids access via a personal access token") +} + // blobSHA computes the Git blob object SHA-1 for the given content. func blobSHA(content []byte) string { h := sha1.New() diff --git a/internal/forge/github/github_test.go b/internal/forge/github/github_test.go index 242fb9b5a..16870e055 100644 --- a/internal/forge/github/github_test.go +++ b/internal/forge/github/github_test.go @@ -776,6 +776,56 @@ func TestIsAlreadyExistsError(t *testing.T) { } } +func TestIsClassicPATForbiddenError(t *testing.T) { + tests := []struct { + name string + apiErr *APIError + want bool + }{ + { + name: "classic PAT forbidden by org policy", + apiErr: &APIError{StatusCode: 403, Message: "acme-corp forbids access via a personal access token (classic). Please use a GitHub App, OAuth App, or a personal access token with fine-grained permissions."}, + want: true, + }, + { + name: "classic PAT forbidden lowercase org name", + apiErr: &APIError{StatusCode: 403, Message: "my-org forbids access via a personal access token (classic)"}, + want: true, + }, + { + name: "generic 403 resource not accessible", + apiErr: &APIError{StatusCode: 403, Message: "Resource not accessible by integration"}, + want: false, + }, + { + name: "secondary rate limit 403", + apiErr: &APIError{StatusCode: 403, Message: "You have exceeded a secondary rate limit"}, + want: false, + }, + { + name: "classic PAT forbidden in Errors array only", + apiErr: &APIError{ + StatusCode: 403, + Message: "Forbidden", + Errors: []APIErrorDetail{ + {Message: "acme-corp forbids access via a personal access token (classic)"}, + }, + }, + want: true, + }, + { + name: "404 not found is not a PAT error", + apiErr: &APIError{StatusCode: 404, Message: "Not Found"}, + want: false, + }, + } + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + assert.Equal(t, tt.want, isClassicPATForbiddenError(tt.apiErr)) + }) + } +} + func TestAPIError_Unwrap(t *testing.T) { tests := []struct { name string @@ -814,6 +864,11 @@ func TestAPIError_Unwrap(t *testing.T) { apiErr: &APIError{StatusCode: 403, Message: "Resource not accessible by integration"}, wantNil: true, }, + { + name: "403 classic PAT forbidden unwraps to ErrClassicPATForbidden", + apiErr: &APIError{StatusCode: 403, Message: "acme-corp forbids access via a personal access token (classic)"}, + wantErr: forge.ErrClassicPATForbidden, + }, } for _, tt := range tests { t.Run(tt.name, func(t *testing.T) {