Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
18 changes: 18 additions & 0 deletions docs/guides/getting-started/configuring-github.md
Original file line number Diff line number Diff line change
Expand Up @@ -13,6 +13,24 @@ 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.

> **Note:** If your organization restricts classic personal access tokens,
> `gh auth login` may produce a token that GitHub rejects with a 403. Create a
> [fine-grained PAT](https://github.com/settings/personal-access-tokens/new)
> scoped to the target repository with **Contents**, **Workflows**, **Secrets**,
> **Variables** (read/write), **Pull requests** (read/write — not needed with
> `--direct`), and **Metadata** (read-only), then `export GH_TOKEN=github_pat_...`
> before running setup. The CLI checks `GH_TOKEN` → `GITHUB_TOKEN` → `gh auth token`,
> in that order.
>
> **Fork limitation:** Fine-grained PATs are scoped to a single GitHub
> organization and cannot create forks across org boundaries. When the CLI
> detects a fine-grained token, the fork delivery option (`[f]`) is
> unavailable. The CLI will offer the upstream option (`[u]`), which pushes
> a branch to the target repository and creates a PR containing the fullsend
> scaffolding files. No changes are made to the default branch until the PR
> is merged. This limitation only applies to the initial setup — after setup,
> fullsend agents use their own GitHub App tokens and do not require a fork.

## Installing GitHub Applications

Install the following agent applications to your organization
Expand Down
58 changes: 57 additions & 1 deletion internal/cli/admin.go
Original file line number Diff line number Diff line change
Expand Up @@ -995,6 +995,9 @@ func runPerRepoInstall(ctx context.Context, c perRepoInstallConfig) error {
scaffoldCommitFn := func(ctx context.Context, owner, repo string, files []forge.TreeFile, direct bool) error {
targetRepo, repoErr := client.GetRepo(ctx, owner, repo)
if repoErr != nil {
if gh.IsPATForbiddenError(repoErr) {

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

[low] code-duplication

Classic PAT rejection handling appears identically at two call sites (~998 and ~1174). Both check gh.IsPATForbiddenError, print the same UI messages (StepFail, Blank, ErrorBox with patForbiddenGuidance), and return the same error. Consider extracting a shared helper.

Suggested fix: Extract the PAT rejection handling into a helper function like handlePATForbiddenError(printer, owner, repo, err) that can be called from both locations.

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

[low] code-duplication

Classic PAT rejection handling appears identically at two call sites (~998 and ~1172). Both check gh.IsPATForbiddenError, call handlePATForbidden, and return the result. Consider extracting a shared helper that encapsulates the check-and-handle pattern.

return handlePATForbidden(printer, owner, repo, repoErr)
}
return fmt.Errorf("getting repo info: %w", repoErr)
}
commitMsg := fmt.Sprintf("chore: initialize fullsend-%s per-repo installation", version)
Expand Down Expand Up @@ -1166,6 +1169,9 @@ func applyPerRepoScaffold(ctx context.Context, client forge.Client, printer *ui.

targetRepo, err := client.GetRepo(ctx, owner, repo)
if err != nil {
if gh.IsPATForbiddenError(err) {
return handlePATForbidden(printer, owner, repo, err)
}
return fmt.Errorf("getting repo info: %w", err)
}
commitMsg := fmt.Sprintf("chore: initialize fullsend-%s per-repo installation", version)
Expand Down Expand Up @@ -2068,7 +2074,15 @@ func runPreflight(ctx context.Context, stack *layers.Stack, op layers.Operation,
}

if result.Skipped {
printer.StepWarn("Preflight skipped: fine-grained token detected (scopes cannot be verified)")
switch result.SkippedReason {

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

[low] edge-case

The switch on result.SkippedReason uses a default case for fine-grained token guidance. If a new SkipReason variant is added, it will silently fall into the default branch and print fine-grained PAT guidance, which may be incorrect. Consider explicitly matching SkipFineGrained with a generic default fallback.

Suggested fix: Change the switch to explicitly handle SkipFineGrained and add a default case that prints a generic 'preflight skipped' message without fine-grained guidance.

case layers.SkipInstallationToken:
printer.StepWarn("Preflight skipped: installation token (OAuth scopes do not apply)")
case layers.SkipFineGrained:
printer.StepWarn("Preflight skipped: fine-grained token detected (scopes cannot be verified)")
printSkipGuidance(printer, result)
default:

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

[low] edge-case

The switch on result.SkippedReason uses a default case that prints a generic message without calling printSkipGuidance. If a new SkipReason variant is added and is semantically similar to fine-grained, the guidance would not be printed. Consider adding a code comment noting that new skip reasons should get explicit case handling.

printer.StepWarn(fmt.Sprintf("Preflight skipped: %s", result.SkippedReason))
}
Comment thread
qodo-code-review[bot] marked this conversation as resolved.
} else {
printer.StepDone("Token permissions verified")
}
Expand Down Expand Up @@ -2879,6 +2893,7 @@ func checkTokenScopes(ctx context.Context, client forge.Client, printer *ui.Prin

if granted == nil {
printer.StepWarn("Preflight skipped: fine-grained token detected (scopes cannot be verified)")
printSkipGuidance(printer, &layers.PreflightResult{Required: required, Skipped: true, SkippedReason: layers.SkipFineGrained})
return nil
}

Expand Down Expand Up @@ -2910,6 +2925,47 @@ func checkTokenScopes(ctx context.Context, client forge.Client, printer *ui.Prin
return nil
}

// printSkipGuidance prints fine-grained permission guidance when
// preflight is skipped due to a fine-grained token.
func printSkipGuidance(printer *ui.Printer, result *layers.PreflightResult) {
if guidance := result.SkipGuidance(); guidance != "" {
for _, line := range strings.Split(guidance, "\n") {
if line != "" {
printer.StepInfo(line)
}
}
}
}

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

[low] naming-convention

patForbiddenGuidance returns a string but lacks a verb prefix (e.g., buildPATForbiddenGuidance) to distinguish it from side-effect functions like printSkipGuidance.

func handlePATForbidden(printer *ui.Printer, owner, repo string, err error) error {

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

[low] error-wrapping

The error message 'organization forbids classic PATs' in handlePATForbidden is somewhat redundant with the underlying error's own message from IsPATForbiddenError which contains 'forbids access via a personal access token'.

printer.StepFail("Classic PAT rejected by organization")
printer.Blank()
printer.ErrorBox("Token not accepted", patForbiddenGuidance(owner, repo))
return fmt.Errorf("organization forbids classic PATs: %w", err)
}

func patForbiddenGuidance(owner, repo string) string {
var b strings.Builder

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

[low] naming-convention

patForbiddenGuidance returns a string but lacks a verb prefix (e.g., buildPATForbiddenGuidance) to distinguish it from side-effect functions like printSkipGuidance.

fmt.Fprintf(&b, "Organization %q forbids classic personal access tokens.\n\n", owner)
b.WriteString("The CLI resolves tokens in this order:\n")
b.WriteString(" 1. GH_TOKEN environment variable\n")
b.WriteString(" 2. GITHUB_TOKEN environment variable\n")
b.WriteString(" 3. gh auth token (GitHub CLI)\n\n")
b.WriteString("The token that resolved was rejected. To fix this, create a\n")
b.WriteString("fine-grained PAT at https://github.com/settings/personal-access-tokens/new\n")
fmt.Fprintf(&b, "scoped to %s/%s with these permissions:\n\n", owner, repo)
b.WriteString(" • Contents: read/write\n")
b.WriteString(" • Workflows: read/write\n")
b.WriteString(" • Secrets: read/write\n")
b.WriteString(" • Variables: read/write\n")
b.WriteString(" • Pull requests: read/write (without --direct)\n")
b.WriteString(" • Metadata: read-only (required by GitHub)\n\n")
b.WriteString("Then export it before running setup:\n")
fmt.Fprintf(&b, " export GH_TOKEN=github_pat_...\n")
fmt.Fprintf(&b, " fullsend github setup %s/%s", owner, repo)
return b.String()
}

// Helper functions.

func repoNameList(repos []forge.Repository) []string {
Expand Down
34 changes: 32 additions & 2 deletions internal/cli/admin_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -1256,10 +1256,16 @@ func TestCheckInstallScopes_FineGrainedToken(t *testing.T) {
client := &forge.FakeClient{
TokenScopes: nil,
}
printer := ui.New(&discardWriter{})
var buf bytes.Buffer
printer := ui.New(&buf)

err := checkInstallScopes(context.Background(), client, printer)
require.NoError(t, err)
output := buf.String()
assert.Contains(t, output, "fine-grained token detected")
assert.Contains(t, output, "repo")
assert.Contains(t, output, "workflow")
assert.Contains(t, output, "admin:org")
}

func TestCheckInstallScopes_InstallationToken(t *testing.T) {
Expand Down Expand Up @@ -1328,10 +1334,16 @@ func TestCheckPerRepoScopes_FineGrainedToken(t *testing.T) {
client := &forge.FakeClient{
TokenScopes: nil,
}
printer := ui.New(&discardWriter{})
var buf bytes.Buffer
printer := ui.New(&buf)

err := checkPerRepoScopes(context.Background(), client, printer)
require.NoError(t, err)
output := buf.String()
assert.Contains(t, output, "fine-grained token detected")
assert.Contains(t, output, "repo")
assert.Contains(t, output, "workflow")
assert.NotContains(t, output, "admin:org")
}

func TestCheckPerRepoScopes_InstallationToken(t *testing.T) {
Expand Down Expand Up @@ -1366,6 +1378,24 @@ func TestCheckPerRepoScopes_DoesNotRequireAdminOrg(t *testing.T) {
require.NoError(t, err, "per-repo should not require admin:org scope")
}

func TestPatForbiddenGuidance(t *testing.T) {
guidance := patForbiddenGuidance("test-org", "test-repo")
assert.Contains(t, guidance, `"test-org"`)
assert.Contains(t, guidance, "test-org/test-repo")
assert.Contains(t, guidance, "GH_TOKEN")
assert.Contains(t, guidance, "GITHUB_TOKEN")
assert.Contains(t, guidance, "gh auth token")
assert.Contains(t, guidance, "Contents:")
assert.Contains(t, guidance, "Workflows:")
assert.Contains(t, guidance, "Secrets:")
assert.Contains(t, guidance, "Variables:")
assert.Contains(t, guidance, "Pull requests:")
assert.Contains(t, guidance, "Metadata:")
assert.Contains(t, guidance, "https://github.com/settings/personal-access-tokens/new")
assert.Contains(t, guidance, "export GH_TOKEN=github_pat_")
assert.Contains(t, guidance, "fullsend github setup test-org/test-repo")
}

func TestPerRepoRequiredScopes_SubsetOfInstallScopes(t *testing.T) {
installSet := make(map[string]bool)
for _, s := range installRequiredScopes {
Expand Down
13 changes: 13 additions & 0 deletions internal/forge/github/github.go
Original file line number Diff line number Diff line change
Expand Up @@ -118,6 +118,19 @@ func IsRateLimitError(err error) bool {
return false
}

// IsPATForbiddenError reports whether err is a GitHub 403 indicating
// that the org forbids classic personal access tokens. This specific
// message means the caller needs a fine-grained PAT, GitHub App, or
// OAuth App token instead.
func IsPATForbiddenError(err error) bool {
var apiErr *APIError
if !errors.As(err, &apiErr) {
return false
}
return apiErr.StatusCode == http.StatusForbidden &&
strings.Contains(strings.ToLower(apiErr.Message), "forbids access via a personal access token")
}

const maxRetries = 5

// do performs an HTTP request against the GitHub API with retry on rate limits.
Expand Down
46 changes: 46 additions & 0 deletions internal/forge/github/github_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -1057,6 +1057,52 @@ func TestAPIError_ErrorStringWithDetails(t *testing.T) {
assert.Contains(t, err.Error(), "name already exists on this account")
}

func TestIsPATForbiddenError(t *testing.T) {
tests := []struct {
name string
err error
want bool
}{
{
name: "classic PAT forbidden by org",
err: &APIError{
StatusCode: 403,
Message: `"test-org" 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: "wrapped error",
err: fmt.Errorf("get repo: %w", &APIError{
StatusCode: 403,
Message: `"test-org" forbids access via a personal access token (classic)`,
}),
want: true,
},
{
name: "generic 403",
err: &APIError{StatusCode: 403, Message: "Resource not accessible by integration"},
want: false,
},
{
name: "non-API error",
err: fmt.Errorf("network error"),
want: false,
},
{
name: "nil error",
err: nil,
want: false,
},
}

for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
assert.Equal(t, tt.want, IsPATForbiddenError(tt.err))
})
}
}

func TestIsBranchProtectionError(t *testing.T) {
tests := []struct {
name string
Expand Down
77 changes: 76 additions & 1 deletion internal/layers/commit.go
Original file line number Diff line number Diff line change
Expand Up @@ -80,8 +80,28 @@ func commitScaffoldViaPR(ctx context.Context, client forge.Client, printer *ui.P
}

// No existing fork — decide whether to create one.
// Fine-grained PATs are scoped to a single org and cannot create cross-org
// forks, so the fork option is unavailable when one is detected.
isFineGrained, fgErr := isFineGrainedToken(ctx, client)
if fgErr != nil {
printer.StepWarn(fmt.Sprintf("Could not detect token type: %v", fgErr))
}

useFork := true
if in != nil {
if isFineGrained {
if in != nil {
confirmed, promptErr := promptUpstreamOnly(printer, in, owner, repo)
if promptErr != nil {
return false, fmt.Errorf("reading delivery choice: %w", promptErr)
}
if !confirmed {
return false, fmt.Errorf("upstream delivery declined by user")
}
} else {
printer.StepInfo("Non-interactive mode: fine-grained token detected, pushing to upstream")
}
useFork = false
} else if in != nil {
choice, promptErr := promptForkChoice(printer, in)
if promptErr != nil {
return false, fmt.Errorf("reading fork choice: %w", promptErr)
Expand Down Expand Up @@ -314,6 +334,61 @@ func promptForkChoice(printer *ui.Printer, in io.Reader) (bool, error) {
return true, fmt.Errorf("too many invalid attempts")
}

// isFineGrainedToken reports whether the current token is a fine-grained PAT.
// Fine-grained PATs return nil from GetTokenScopes because GitHub doesn't
// populate the X-OAuth-Scopes header for them.
func isFineGrainedToken(ctx context.Context, client forge.Client) (bool, error) {
isInstall, err := client.IsInstallationToken(ctx)
if err != nil {
return false, err
}
if isInstall {
return false, nil
}

scopes, err := client.GetTokenScopes(ctx)
if err != nil {
return false, err
}
return scopes == nil, nil
}

// promptUpstreamOnly explains that the fork option is unavailable with a
// fine-grained PAT and asks the user to confirm pushing to upstream. The
// upstream path creates a PR with the fullsend scaffolding files — it does
// not push directly to the default branch.
func promptUpstreamOnly(printer *ui.Printer, in io.Reader, owner, repo string) (bool, error) {
printer.Blank()
printer.StepWarn("Fine-grained token detected — fork option is not available.")
printer.StepInfo("Fine-grained PATs are scoped to a single organization and cannot")
printer.StepInfo("create forks in other accounts. This is a GitHub platform limitation.")
printer.Blank()
printer.StepInfo(fmt.Sprintf("Option [u] will push a branch to %s/%s and create a PR", owner, repo))
printer.StepInfo("containing the fullsend scaffolding files (config, workflows, secrets).")
printer.StepInfo("No changes are made to the default branch until the PR is merged.")
printer.Blank()

const maxAttempts = 5
reader := bufio.NewReader(in)
for i := 0; i < maxAttempts; i++ {
printer.StepInfo("Proceed with upstream PR? [y/n]: ")
line, err := reader.ReadString('\n')
if err != nil && line == "" {
return false, err
}
choice := strings.TrimSpace(strings.ToLower(line))
switch choice {
case "y", "yes":
return true, nil
case "n", "no":
return false, nil
default:
printer.StepWarn("Invalid choice. Please enter 'y' or 'n'.")
}
}
return false, fmt.Errorf("too many invalid attempts")
}

// commitScaffoldDirect pushes files directly to the default branch, falling
// back to a PR when branch protection blocks the push.
func commitScaffoldDirect(ctx context.Context, client forge.Client, printer *ui.Printer,
Expand Down
Loading
Loading