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
2 changes: 2 additions & 0 deletions docs/ADRs/0005-forge-abstraction-layer.md
Original file line number Diff line number Diff line change
Expand Up @@ -34,3 +34,5 @@ No code outside `internal/forge/` imports forge-specific packages directly.
- Forge-neutral naming occasionally feels awkward (e.g., `ChangeProposal`), but prevents GitHub-centric thinking from leaking into the model.
- The interface will grow as new operations are needed; keeping it cohesive requires discipline.
- The `FakeClient` enables deterministic testing of every layer without network calls.
- Sentinel errors (`ErrNotFound`, `ErrBranchProtected`, `ErrAlreadyExists`) with `errors.Is()` helpers provide forge-agnostic error classification. `ErrNotFound` and `ErrAlreadyExists` are mapped in `APIError.Unwrap()` for automatic propagation. `ErrBranchProtected` is wrapped contextually at the call site (e.g., `commitFilesTo`) where the operation context disambiguates branch-protection 422s from other validation failures.
- `CommitFilesToBranch` complements `CommitFiles` (default branch) by targeting a specific branch, enabling the protected-branch fallback path where scaffold files are committed to a feature branch and delivered via PR.
5 changes: 5 additions & 0 deletions docs/guides/dev/cli-internals.md
Original file line number Diff line number Diff line change
Expand Up @@ -175,6 +175,11 @@ Both per-org and per-repo modes share the same core pipeline. The code follows t
│ │ Phase 5: Write scaffold + config files │ │
│ │ │ │
│ │ Both modes: write workflow files + customized/ dirs │ │
│ │ CommitScaffoldFiles() handles protected-branch fallback: │ │
│ │ 1. Try CommitFiles (default branch) │ │
│ │ 2. If ErrBranchProtected → create feature branch │ │
│ │ 3. CommitFilesToBranch on feature branch │ │
│ │ 4. Open PR back to default branch │ │
│ │ ┌──────────────────────────────────────────┐ │ │
│ │ │ Per-org: create .fullsend config repo │ │ │
│ │ │ push reusable workflows │ │ │
Expand Down
4 changes: 4 additions & 0 deletions docs/reference/installation.md
Original file line number Diff line number Diff line change
Expand Up @@ -121,6 +121,8 @@ The `--inference-region` flag defaults to `global` for the broadest model availa

See [Setting up with pre-provisioned infrastructure](github-setup.md) for the full `github setup` reference, including per-repo mode, `--skip-app-setup`, and day-2 operations.

> **Protected default branch:** If the `.fullsend` config repo or target repo has branch protection rules that prevent direct pushes, the installer automatically falls back to creating a PR with the scaffold files instead of pushing directly. Merge the scaffold PR to complete setup.

### Step 4: Merge enrollment PRs

If you enrolled repositories during setup, the installer dispatches a workflow that creates an enrollment PR in each enrolled repo. These PRs add a shim workflow (`.github/workflows/fullsend.yaml`) that wires events to the agent pipeline.
Expand Down Expand Up @@ -214,6 +216,8 @@ During installation, you'll be prompted to choose repository enrollment:

The installer creates the `.fullsend` config repo as **public** by default. This is required for cross-repo `workflow_call` to work with enrolled repos of any visibility (public, private, or internal) across all GitHub plan tiers. If an admin later makes `.fullsend` private, only other private repos in the org will be able to trigger agent workflows — public and internal repos will fail silently.

If the default branch of the `.fullsend` config repo has branch protection rules, the installer creates a PR with the scaffold files instead of pushing directly. Merge the scaffold PR to complete setup.

If the installer fails partway through, run `fullsend admin uninstall "$ORG_NAME"` to clean up before retrying. The uninstall preflight will prompt you to add the `delete_repo` scope if it is missing.

Set the variables for your environment:
Expand Down
23 changes: 9 additions & 14 deletions internal/cli/admin.go
Original file line number Diff line number Diff line change
Expand Up @@ -1023,22 +1023,17 @@ func applyPerRepoScaffold(ctx context.Context, client forge.Client, printer *ui.
if err != nil {
return 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)",
owner, repo, targetRepo.DefaultBranch))
committed, err := client.CommitFiles(ctx, owner, repo,
fmt.Sprintf("chore: initialize fullsend-%s per-repo installation", version), files)
if err != nil {
printer.StepFail("Failed to commit scaffold files")
return fmt.Errorf("committing scaffold files: %w", err)
}
if committed {
noun := "files"
if len(files) == 1 {
noun = "file"
}
printer.StepDone(fmt.Sprintf("Pushed %d %s to %s", len(files), noun, targetRepo.DefaultBranch))
} else {
printer.StepDone("Scaffold up to date")
prBody := fmt.Sprintf("This PR adds the fullsend scaffold files for per-repo installation.\n\n"+
"The default branch (%s) has branch protection rules that prevent direct pushes, "+
"so these files are delivered via PR instead.\n\n"+
"Merge this PR to activate fullsend workflows.", targetRepo.DefaultBranch)
if err := layers.CommitScaffoldFiles(ctx, client, printer,
owner, repo, targetRepo.DefaultBranch,
commitMsg, "chore: initialize fullsend per-repo installation", prBody, files); err != nil {
return err
}

printer.StepStart("Configuring repository variables")
Expand Down
186 changes: 186 additions & 0 deletions internal/cli/admin_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -1980,6 +1980,8 @@ func TestApplyPerRepoScaffold_CommitFilesError(t *testing.T) {
"acme", "widget", files, nil, nil)
require.Error(t, err)
assert.Contains(t, err.Error(), "committing scaffold files")
assert.Empty(t, client.CreatedBranches, "should not attempt fallback for generic error")
assert.Empty(t, client.CreatedProposals, "should not attempt fallback for generic error")
}

func TestApplyPerRepoScaffold_Idempotent(t *testing.T) {
Expand Down Expand Up @@ -2046,3 +2048,187 @@ func TestApplyPerRepoScaffold_CreateSecretError(t *testing.T) {
require.Error(t, err)
assert.Contains(t, err.Error(), "setting repo secret")
}

func TestApplyPerRepoScaffold_ProtectedBranchFallback(t *testing.T) {
client := forge.NewFakeClient()
client.Repos = []forge.Repository{{FullName: "acme/widget", DefaultBranch: "main"}}
client.Errors["CommitFiles"] = fmt.Errorf("%w: github api: 422", forge.ErrBranchProtected)
var buf bytes.Buffer
printer := ui.New(&buf)

files := []forge.TreeFile{
{Path: ".github/workflows/fullsend.yaml", Content: []byte("workflow"), Mode: "100644"},
}
repoVars := map[string]string{"K": "V"}
repoSecrets := map[string]string{"S": "secret"}

err := applyPerRepoScaffold(context.Background(), client, printer,
"acme", "widget", files, repoVars, repoSecrets)
require.NoError(t, err)

require.Len(t, client.CreatedBranches, 1)
assert.Equal(t, "acme/widget/fullsend/scaffold-install", client.CreatedBranches[0])

require.Len(t, client.CommittedFilesToBranch, 1)
assert.Equal(t, "fullsend/scaffold-install", client.CommittedFilesToBranch[0].Branch)
assert.Len(t, client.CommittedFilesToBranch[0].Files, 1)

require.Len(t, client.CreatedProposals, 1)
assert.Contains(t, client.CreatedProposals[0].Title, "fullsend")

output := buf.String()
assert.Contains(t, output, "protected")
assert.Contains(t, output, "PR #1")
assert.Contains(t, output, "Merge the PR")
}

func TestApplyPerRepoScaffold_ProtectedBranch_ExistingBranch(t *testing.T) {
client := forge.NewFakeClient()
client.Repos = []forge.Repository{{FullName: "acme/widget", DefaultBranch: "main"}}
client.Errors["CommitFiles"] = fmt.Errorf("%w: github api: 422", forge.ErrBranchProtected)
client.Errors["CreateBranch"] = fmt.Errorf("branch: %w", forge.ErrAlreadyExists)
var buf bytes.Buffer
printer := ui.New(&buf)

files := []forge.TreeFile{
{Path: ".fullsend/config.yaml", Content: []byte("cfg"), Mode: "100644"},
}

err := applyPerRepoScaffold(context.Background(), client, printer,
"acme", "widget", files, nil, nil)
require.NoError(t, err)

require.Len(t, client.CommittedFilesToBranch, 1, "should proceed despite branch existing")
require.Len(t, client.CreatedProposals, 1)
}

func TestApplyPerRepoScaffold_ProtectedBranch_StillSetsVarsAndSecrets(t *testing.T) {
client := forge.NewFakeClient()
client.Repos = []forge.Repository{{FullName: "acme/widget", DefaultBranch: "main"}}
client.Errors["CommitFiles"] = fmt.Errorf("%w: github api: 422", forge.ErrBranchProtected)
printer := ui.New(&bytes.Buffer{})

files := []forge.TreeFile{
{Path: ".fullsend/config.yaml", Content: []byte("cfg"), Mode: "100644"},
}
repoVars := map[string]string{"FULLSEND_MINT_URL": "https://mint.example.run.app"}
repoSecrets := map[string]string{"FULLSEND_GCP_PROJECT_ID": "my-project"}

err := applyPerRepoScaffold(context.Background(), client, printer,
"acme", "widget", files, repoVars, repoSecrets)
require.NoError(t, err)

assert.Len(t, client.Variables, 1, "variables should be set even with PR fallback")
assert.Len(t, client.CreatedSecrets, 1, "secrets should be set even with PR fallback")
}

func TestApplyPerRepoScaffold_ProtectedBranch_CreateBranchFails(t *testing.T) {
client := forge.NewFakeClient()
client.Repos = []forge.Repository{{FullName: "acme/widget", DefaultBranch: "main"}}
client.Errors["CommitFiles"] = fmt.Errorf("%w: github api: 422", forge.ErrBranchProtected)
client.Errors["CreateBranch"] = fmt.Errorf("forbidden")
printer := ui.New(&bytes.Buffer{})

files := []forge.TreeFile{
{Path: ".fullsend/config.yaml", Content: []byte("cfg"), Mode: "100644"},
}

err := applyPerRepoScaffold(context.Background(), client, printer,
"acme", "widget", files, nil, nil)
require.Error(t, err)
assert.Contains(t, err.Error(), "creating scaffold branch")
}

func TestApplyPerRepoScaffold_ProtectedBranch_CommitToBranchFails(t *testing.T) {
client := forge.NewFakeClient()
client.Repos = []forge.Repository{{FullName: "acme/widget", DefaultBranch: "main"}}
client.Errors["CommitFiles"] = fmt.Errorf("%w: github api: 422", forge.ErrBranchProtected)
client.Errors["CommitFilesToBranch"] = fmt.Errorf("server error")
printer := ui.New(&bytes.Buffer{})

files := []forge.TreeFile{
{Path: ".fullsend/config.yaml", Content: []byte("cfg"), Mode: "100644"},
}

err := applyPerRepoScaffold(context.Background(), client, printer,
"acme", "widget", files, nil, nil)
require.Error(t, err)
assert.Contains(t, err.Error(), "committing scaffold files to branch")
}

func TestApplyPerRepoScaffold_ProtectedBranch_ScaffoldBranchAlsoProtected(t *testing.T) {
client := forge.NewFakeClient()
client.Repos = []forge.Repository{{FullName: "acme/widget", DefaultBranch: "main"}}
client.Errors["CommitFiles"] = fmt.Errorf("%w: github api: 422", forge.ErrBranchProtected)
client.Errors["CommitFilesToBranch"] = fmt.Errorf("%w: scaffold branch also protected", forge.ErrBranchProtected)
printer := ui.New(&bytes.Buffer{})

files := []forge.TreeFile{
{Path: ".fullsend/config.yaml", Content: []byte("cfg"), Mode: "100644"},
}

err := applyPerRepoScaffold(context.Background(), client, printer,
"acme", "widget", files, nil, nil)
require.Error(t, err)
assert.Contains(t, err.Error(), "scaffold branch")
assert.Contains(t, err.Error(), "configure branch protection")
}

func TestApplyPerRepoScaffold_ProtectedBranch_CreatePRFails(t *testing.T) {
client := forge.NewFakeClient()
client.Repos = []forge.Repository{{FullName: "acme/widget", DefaultBranch: "main"}}
client.Errors["CommitFiles"] = fmt.Errorf("%w: github api: 422", forge.ErrBranchProtected)
client.Errors["CreateChangeProposal"] = fmt.Errorf("forbidden")
printer := ui.New(&bytes.Buffer{})

files := []forge.TreeFile{
{Path: ".fullsend/config.yaml", Content: []byte("cfg"), Mode: "100644"},
}

err := applyPerRepoScaffold(context.Background(), client, printer,
"acme", "widget", files, nil, nil)
require.Error(t, err)
assert.Contains(t, err.Error(), "creating scaffold PR")
}

func TestApplyPerRepoScaffold_ProtectedBranch_DuplicatePR(t *testing.T) {
client := forge.NewFakeClient()
client.Repos = []forge.Repository{{FullName: "acme/widget", DefaultBranch: "main"}}
client.Errors["CommitFiles"] = fmt.Errorf("%w: github api: 422", forge.ErrBranchProtected)
client.Errors["CreateChangeProposal"] = fmt.Errorf("pr: %w", forge.ErrAlreadyExists)
var buf bytes.Buffer
printer := ui.New(&buf)

files := []forge.TreeFile{
{Path: ".fullsend/config.yaml", Content: []byte("cfg"), Mode: "100644"},
}

err := applyPerRepoScaffold(context.Background(), client, printer,
"acme", "widget", files, nil, nil)
require.NoError(t, err)

output := buf.String()
assert.Contains(t, output, "already exists")
assert.Contains(t, output, "Merge the PR")
}

func TestApplyPerRepoScaffold_ProtectedBranch_BranchUpToDate(t *testing.T) {
client := forge.NewFakeClient()
client.Repos = []forge.Repository{{FullName: "acme/widget", DefaultBranch: "main"}}
client.Errors["CommitFiles"] = fmt.Errorf("%w: github api: 422", forge.ErrBranchProtected)
client.Errors["CreateChangeProposal"] = fmt.Errorf("PR: %w", forge.ErrAlreadyExists)
noChange := false
client.CommitFilesChanged = &noChange
var buf bytes.Buffer
printer := ui.New(&buf)

files := []forge.TreeFile{
{Path: ".fullsend/config.yaml", Content: []byte("cfg"), Mode: "100644"},
}

err := applyPerRepoScaffold(context.Background(), client, printer,
"acme", "widget", files, nil, nil)
require.NoError(t, err)

assert.Contains(t, buf.String(), "up to date")
}
77 changes: 57 additions & 20 deletions internal/forge/fake.go
Original file line number Diff line number Diff line change
Expand Up @@ -95,6 +95,12 @@ type CommitFilesRecord struct {
Files []TreeFile
}

// CommitFilesToBranchRecord records a CommitFilesToBranch call.
type CommitFilesToBranchRecord struct {
Owner, Repo, Branch, Message string
Files []TreeFile
}

// FakeClient is a thread-safe test double for forge.Client.
// Pre-populate its fields to control return values, and inspect
// recorder slices after the test to verify which calls were made.
Expand Down Expand Up @@ -139,7 +145,10 @@ type FakeClient struct {
IssueComments map[string][]IssueComment // key: "owner/repo/number"
OpenIssues map[string][]Issue // key: "owner/repo"

// CommitFilesChanged controls the return value of CommitFiles (default true).
// CommitFilesChanged controls the return value of both CommitFiles and
// CommitFilesToBranch (default true). A single field suffices because
// callers that test the fallback path inject an error on CommitFiles,
// so only CommitFilesToBranch reads this value in practice.
CommitFilesChanged *bool

// Pull request head SHA for GetPullRequestHeadSHA.
Expand All @@ -158,25 +167,26 @@ type FakeClient struct {
Annotations []Annotation

// Call recorders
CreatedRepos []Repository
CreatedFiles []FileRecord
CreatedBranches []string // "owner/repo/branch"
CreatedProposals []ChangeProposal
DeletedRepos []string // "owner/repo"
DeletedFiles []FileRecord
CreatedSecrets []SecretRecord
Variables []VariableRecord
DeletedOrgSecrets []string // "org/name"
CreatedOrgSecrets []OrgSecretRecord
CreatedOrgVariables []OrgVariableRecord
DeletedOrgVariables []string // "org/name"
CreatedIssues []CreatedIssueRecord
UpdatedComments []UpdatedCommentRecord
MinimizedComments []MinimizedCommentRecord
CreatedReviews []ReviewRecord
DismissedReviews []DismissedReviewRecord
CommittedFiles []CommitFilesRecord
DeletedComments []int // comment IDs
CreatedRepos []Repository
CreatedFiles []FileRecord
CreatedBranches []string // "owner/repo/branch"
CreatedProposals []ChangeProposal
DeletedRepos []string // "owner/repo"
DeletedFiles []FileRecord
CreatedSecrets []SecretRecord
Variables []VariableRecord
DeletedOrgSecrets []string // "org/name"
CreatedOrgSecrets []OrgSecretRecord
CreatedOrgVariables []OrgVariableRecord
DeletedOrgVariables []string // "org/name"
CreatedIssues []CreatedIssueRecord
UpdatedComments []UpdatedCommentRecord
MinimizedComments []MinimizedCommentRecord
CreatedReviews []ReviewRecord
DismissedReviews []DismissedReviewRecord
CommittedFiles []CommitFilesRecord
CommittedFilesToBranch []CommitFilesToBranchRecord
DeletedComments []int // comment IDs

// internal counters
proposalCounter int
Expand Down Expand Up @@ -448,6 +458,33 @@ func (f *FakeClient) CommitFiles(_ context.Context, owner, repo, message string,
return changed, nil
}

func (f *FakeClient) CommitFilesToBranch(_ context.Context, owner, repo, branch, message string, files []TreeFile) (bool, error) {
f.mu.Lock()
defer f.mu.Unlock()

if e := f.err("CommitFilesToBranch"); e != nil {
return false, e
}

f.CommittedFilesToBranch = append(f.CommittedFilesToBranch, CommitFilesToBranchRecord{
Owner: owner,
Repo: repo,
Branch: branch,
Message: message,
Files: files,
})

if f.FileContents == nil {
f.FileContents = make(map[string][]byte)
}
for _, file := range files {
f.FileContents[owner+"/"+repo+"/"+file.Path] = file.Content
}

changed := f.CommitFilesChanged == nil || *f.CommitFilesChanged
return changed, nil
}

func (f *FakeClient) CreateBranch(_ context.Context, owner, repo, branchName string) error {
f.mu.Lock()
defer f.mu.Unlock()
Expand Down
Loading
Loading