From 1b49d6c5234ecf2272718867a6350c780ed9f526 Mon Sep 17 00:00:00 2001 From: fullsend-code <278716306+fullsend-ai-coder[bot]@users.noreply.github.com> Date: Mon, 8 Jun 2026 13:10:36 +0000 Subject: [PATCH 1/3] fix(#2017): unenroll repos during uninstall via repo-maintenance workflow MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Previously, EnrollmentLayer.Uninstall() was a no-op — enrolled repos kept their shim workflows and .fullsend references after running fullsend admin uninstall. This left stale agent configuration in repos when users later reinstalled with a different set of agents. The fix makes two changes: 1. runUninstall() now extracts enabled repos from config.yaml and passes them as disabledRepos to the EnrollmentLayer. 2. EnrollmentLayer.Uninstall() now updates config.yaml to mark all repos as disabled, then dispatches the repo-maintenance workflow to create unenrollment PRs that remove shim workflows from each enrolled repo. Errors are non-fatal — the uninstall continues and the user is informed of any repos needing manual cleanup. The unenrollment runs before ConfigRepoLayer deletes the .fullsend repo (layers uninstall in reverse order), so the workflow is still available to dispatch. Note: pre-commit could not run in the sandbox (shellcheck hook failed to install due to network restrictions). The post-script runs pre-commit authoritatively on the runner. Closes #2017 --- internal/cli/admin.go | 4 +- internal/layers/enrollment.go | 94 +++++++++++++++++++++++- internal/layers/enrollment_test.go | 114 +++++++++++++++++++++++++++-- 3 files changed, 201 insertions(+), 11 deletions(-) diff --git a/internal/cli/admin.go b/internal/cli/admin.go index 54a4152c3..3cfbf9f48 100644 --- a/internal/cli/admin.go +++ b/internal/cli/admin.go @@ -1638,6 +1638,7 @@ func runUninstall(ctx context.Context, client forge.Client, printer *ui.Printer, // apps that block reinstallation (PEM keys are one-shot). var agentSlugs []string var configMode string + var enrolledRepos []string cfgData, err := client.GetFileContent(ctx, org, forge.ConfigRepoName, "config.yaml") if err == nil { if parsedCfg, parseErr := config.ParseOrgConfig(cfgData); parseErr == nil { @@ -1645,6 +1646,7 @@ func runUninstall(ctx context.Context, client forge.Client, printer *ui.Printer, agentSlugs = append(agentSlugs, agent.Slug) } configMode = parsedCfg.Dispatch.Mode + enrolledRepos = parsedCfg.EnabledRepos() } else { printer.StepWarn(fmt.Sprintf("Could not parse existing config: %v; using defaults", parseErr)) } @@ -1702,7 +1704,7 @@ func runUninstall(ctx context.Context, client forge.Client, printer *ui.Printer, layers.NewSecretsLayer(org, client, nil, printer), layers.NewInferenceLayer(org, client, nil, printer), dispatchLayer, - layers.NewEnrollmentLayer(org, client, nil, nil, printer), + layers.NewEnrollmentLayer(org, client, nil, enrolledRepos, printer), ) if err := runPreflight(ctx, stack, layers.OpUninstall, client, printer); err != nil { diff --git a/internal/layers/enrollment.go b/internal/layers/enrollment.go index ed3159377..8b72abfdb 100644 --- a/internal/layers/enrollment.go +++ b/internal/layers/enrollment.go @@ -5,6 +5,7 @@ import ( "fmt" "time" + "github.com/fullsend-ai/fullsend/internal/config" "github.com/fullsend-ai/fullsend/internal/forge" "github.com/fullsend-ai/fullsend/internal/ui" ) @@ -53,7 +54,10 @@ func (l *EnrollmentLayer) RequiredScopes(op Operation) []string { // Enrollment dispatches repo-maintenance.yml on .fullsend. return []string{"repo"} case OpUninstall: - return nil // no-op + if len(l.disabledRepos) > 0 { + return []string{"repo"} + } + return nil case OpAnalyze: return []string{"repo"} default: @@ -176,9 +180,91 @@ func (l *EnrollmentLayer) reportPRByTitle(ctx context.Context, repo, title strin } } -// Uninstall is a no-op. Individual repo cleanup is not automated — -// repos keep their shim workflows. -func (l *EnrollmentLayer) Uninstall(_ context.Context) error { +// Uninstall updates config.yaml to mark all repos as disabled and +// dispatches the repo-maintenance workflow to create unenrollment PRs +// that remove shim workflows from enrolled repos. This runs before +// ConfigRepoLayer deletes the .fullsend repo (layers uninstall in +// reverse order), so the workflow is still available to dispatch. +// +// Errors during unenrollment are non-fatal — the user is informed but +// the uninstall continues. Repos that cannot be unenrolled +// automatically will need manual removal of .github/workflows/fullsend.yaml. +func (l *EnrollmentLayer) Uninstall(ctx context.Context) error { + if len(l.disabledRepos) == 0 { + l.ui.StepInfo("no repositories to unenroll") + return nil + } + + // Read current config and mark all repos as disabled so the + // reconcile script knows to create unenrollment PRs. + cfgData, err := l.client.GetFileContent(ctx, l.org, forge.ConfigRepoName, "config.yaml") + if err != nil { + if forge.IsNotFound(err) { + l.ui.StepInfo("config repo unavailable, skipping unenrollment") + return nil + } + l.ui.StepWarn(fmt.Sprintf("could not read config for unenrollment: %v", err)) + return nil + } + + cfg, err := config.ParseOrgConfig(cfgData) + if err != nil { + l.ui.StepWarn(fmt.Sprintf("could not parse config for unenrollment: %v", err)) + return nil + } + + for name, rc := range cfg.Repos { + rc.Enabled = false + cfg.Repos[name] = rc + } + + data, err := cfg.Marshal() + if err != nil { + l.ui.StepWarn(fmt.Sprintf("could not marshal config for unenrollment: %v", err)) + return nil + } + + l.ui.StepStart("Updating config to disable all repos") + err = l.client.CreateOrUpdateFile(ctx, l.org, forge.ConfigRepoName, "config.yaml", + "chore: disable all repos for uninstall", data) + if err != nil { + l.ui.StepWarn(fmt.Sprintf("could not update config: %v", err)) + return nil + } + l.ui.StepDone("Disabled all repos in config") + + // Dispatch repo-maintenance to create unenrollment PRs. + dispatchTime := time.Now().UTC().Add(-30 * time.Second) + l.ui.StepStart("Dispatching repo-maintenance for unenrollment") + err = l.client.DispatchWorkflow(ctx, l.org, forge.ConfigRepoName, repoMaintenanceWorkflow, "main", nil) + if err != nil { + l.ui.StepWarn(fmt.Sprintf("could not dispatch unenrollment workflow: %v", err)) + l.ui.StepInfo("repos may need manual cleanup of .github/workflows/fullsend.yaml") + return nil + } + l.ui.StepDone("Dispatched repo-maintenance for unenrollment") + + // Wait for the workflow run to complete. + run, err := l.awaitWorkflowRun(ctx, dispatchTime) + if err != nil { + l.ui.StepWarn(fmt.Sprintf("could not confirm unenrollment: %v", err)) + l.ui.StepInfo("check the repo-maintenance workflow in .fullsend for results") + return nil + } + + if run.Conclusion == "success" { + l.ui.StepDone("Unenrollment completed successfully") + } else { + l.ui.StepWarn(fmt.Sprintf("unenrollment workflow completed with conclusion: %s", run.Conclusion)) + l.showWorkflowLogs(ctx, run.ID) + } + l.ui.StepInfo(fmt.Sprintf("workflow run: %s", run.HTMLURL)) + + // Report unenrollment PRs. + for _, repo := range l.disabledRepos { + l.reportPRByTitle(ctx, repo, "chore: disconnect from fullsend agent pipeline") + } + return nil } diff --git a/internal/layers/enrollment_test.go b/internal/layers/enrollment_test.go index db56277ba..2d243af95 100644 --- a/internal/layers/enrollment_test.go +++ b/internal/layers/enrollment_test.go @@ -155,17 +155,119 @@ func TestEnrollmentLayer_Install_WorkflowWarning(t *testing.T) { assert.Contains(t, output, "conclusion: failure") } -func TestEnrollmentLayer_Uninstall_Noop(t *testing.T) { +func TestEnrollmentLayer_Uninstall_NoRepos(t *testing.T) { client := &forge.FakeClient{} - layer, _ := newEnrollmentLayer(t, client, []string{"repo-a"}, nil) + layer, buf := newEnrollmentLayer(t, client, nil, nil) + + err := layer.Uninstall(context.Background()) + require.NoError(t, err) + + output := buf.String() + assert.Contains(t, output, "no repositories to unenroll") +} + +func TestEnrollmentLayer_Uninstall_DisablesAndDispatches(t *testing.T) { + now := time.Now().UTC() + + // Seed config.yaml with an enabled repo. + cfgYAML := `version: "1" +dispatch: + platform: github-actions +defaults: + roles: [triage] + max_implementation_retries: 2 + auto_merge: false +agents: [] +repos: + repo-a: + enabled: true + repo-b: + enabled: true +` + client := &forge.FakeClient{ + FileContents: map[string][]byte{ + "test-org/.fullsend/config.yaml": []byte(cfgYAML), + }, + WorkflowRuns: map[string]*forge.WorkflowRun{ + "test-org/.fullsend/repo-maintenance.yml": { + ID: 42, + Status: "completed", + Conclusion: "success", + CreatedAt: now.Add(time.Minute).Format(time.RFC3339), + HTMLURL: "https://github.com/test-org/.fullsend/actions/runs/42", + }, + }, + PullRequests: map[string][]forge.ChangeProposal{ + "test-org/repo-a": { + {Title: "chore: disconnect from fullsend agent pipeline", URL: "https://github.com/test-org/repo-a/pull/10"}, + }, + "test-org/repo-b": { + {Title: "chore: disconnect from fullsend agent pipeline", URL: "https://github.com/test-org/repo-b/pull/11"}, + }, + }, + } + + layer, buf := newEnrollmentLayer(t, client, nil, []string{"repo-a", "repo-b"}) err := layer.Uninstall(context.Background()) require.NoError(t, err) - assert.Empty(t, client.CreatedBranches) - assert.Empty(t, client.CreatedFiles) - assert.Empty(t, client.CreatedProposals) - assert.Empty(t, client.DeletedRepos) + output := buf.String() + assert.Contains(t, output, "Disabled all repos in config") + assert.Contains(t, output, "Dispatched repo-maintenance for unenrollment") + assert.Contains(t, output, "Unenrollment completed successfully") + assert.Contains(t, output, "repo-a/pull/10") + assert.Contains(t, output, "repo-b/pull/11") + + // Verify config was updated with all repos disabled. + require.Len(t, client.CreatedFiles, 1) + assert.Equal(t, "config.yaml", client.CreatedFiles[0].Path) + assert.Contains(t, string(client.CreatedFiles[0].Content), "enabled: false") + assert.NotContains(t, string(client.CreatedFiles[0].Content), "enabled: true") +} + +func TestEnrollmentLayer_Uninstall_ConfigNotFound(t *testing.T) { + client := &forge.FakeClient{ + FileContents: map[string][]byte{}, + } + layer, buf := newEnrollmentLayer(t, client, nil, []string{"repo-a"}) + + err := layer.Uninstall(context.Background()) + require.NoError(t, err) + + output := buf.String() + assert.Contains(t, output, "config repo unavailable") +} + +func TestEnrollmentLayer_Uninstall_DispatchError(t *testing.T) { + cfgYAML := `version: "1" +dispatch: + platform: github-actions +defaults: + roles: [triage] + max_implementation_retries: 2 + auto_merge: false +agents: [] +repos: + repo-a: + enabled: true +` + client := &forge.FakeClient{ + FileContents: map[string][]byte{ + "test-org/.fullsend/config.yaml": []byte(cfgYAML), + }, + Errors: map[string]error{ + "DispatchWorkflow": assert.AnError, + }, + } + layer, buf := newEnrollmentLayer(t, client, nil, []string{"repo-a"}) + + err := layer.Uninstall(context.Background()) + require.NoError(t, err) // non-fatal + + output := buf.String() + assert.Contains(t, output, "could not dispatch unenrollment workflow") + assert.Contains(t, output, "manual cleanup") } func TestEnrollmentLayer_Analyze_AllEnrolled(t *testing.T) { From 25c69ce234f4536ae1fa5b362af3dc08109c95a0 Mon Sep 17 00:00:00 2001 From: Ralph Bean Date: Wed, 10 Jun 2026 17:07:31 -0400 Subject: [PATCH 2/3] fix: reuse reportReconciliationPRs in Uninstall Replace the manual loop over disabledRepos with a call to the existing reportReconciliationPRs method, which already iterates both enabledRepos and disabledRepos with the correct PR titles. This avoids duplicating the title string that must match UNENROLL_PR_TITLE in reconcile-repos.sh. Assisted-by: Claude Opus 4.6 Signed-off-by: Ralph Bean --- internal/layers/enrollment.go | 5 +---- 1 file changed, 1 insertion(+), 4 deletions(-) diff --git a/internal/layers/enrollment.go b/internal/layers/enrollment.go index 8b72abfdb..d418ec442 100644 --- a/internal/layers/enrollment.go +++ b/internal/layers/enrollment.go @@ -260,10 +260,7 @@ func (l *EnrollmentLayer) Uninstall(ctx context.Context) error { } l.ui.StepInfo(fmt.Sprintf("workflow run: %s", run.HTMLURL)) - // Report unenrollment PRs. - for _, repo := range l.disabledRepos { - l.reportPRByTitle(ctx, repo, "chore: disconnect from fullsend agent pipeline") - } + l.reportReconciliationPRs(ctx) return nil } From 3336cb69b3cdd0041fe8510d4bdf60357613e49f Mon Sep 17 00:00:00 2001 From: Ralph Bean Date: Wed, 10 Jun 2026 17:16:11 -0400 Subject: [PATCH 3/3] fix: remove stale comment about uninstall layers The comment claimed only ConfigRepoLayer matters for uninstall since other layers are no-ops. This is no longer true now that EnrollmentLayer.Uninstall() does real work. Assisted-by: Claude Opus 4.6 Signed-off-by: Ralph Bean --- internal/cli/admin.go | 1 - 1 file changed, 1 deletion(-) diff --git a/internal/cli/admin.go b/internal/cli/admin.go index 3cfbf9f48..1dc7f6850 100644 --- a/internal/cli/admin.go +++ b/internal/cli/admin.go @@ -1696,7 +1696,6 @@ func runUninstall(ctx context.Context, client forge.Client, printer *ui.Printer, } // Build a minimal stack for uninstall. - // Only ConfigRepoLayer matters for uninstall since other layers are no-ops. emptyCfg := config.NewOrgConfig(nil, nil, nil, nil, "") stack := layers.NewStack( layers.NewConfigRepoLayer(org, client, emptyCfg, printer, false),