From 5cd6ab177dd9d33e39305759a604b744635b1f55 Mon Sep 17 00:00:00 2001 From: Rian Stockbower Date: Tue, 15 Sep 2026 09:53:04 -0400 Subject: [PATCH 1/6] fix: harden database and provider defaults --- internal/gitprovider/gitlab/client.go | 14 ++++++-- internal/gitprovider/gitlab/client_test.go | 14 ++++++++ internal/ledger/ledger.go | 40 +++++++++++++-------- internal/ledger/ledger_test.go | 42 ++++++++++++++++++++++ internal/workbench/workbench_test.go | 16 ++++----- 5 files changed, 102 insertions(+), 24 deletions(-) diff --git a/internal/gitprovider/gitlab/client.go b/internal/gitprovider/gitlab/client.go index 01e6baab..159a12ac 100644 --- a/internal/gitprovider/gitlab/client.go +++ b/internal/gitprovider/gitlab/client.go @@ -7,13 +7,19 @@ import ( "net/http" "net/url" "strings" + "time" "github.com/open-cli-collective/codereview-cli/internal/config" "github.com/open-cli-collective/codereview-cli/internal/credentials" "github.com/open-cli-collective/codereview-cli/internal/gitprovider" ) -const credentialTypePAT = "pat" +const ( + credentialTypePAT = "pat" + // defaultHTTPTimeout bounds every REST request made with the default + // client, so an abandoned connection cannot park a review indefinitely. + defaultHTTPTimeout = 2 * time.Minute +) // ErrValidation identifies non-retryable adapter input or GitLab validation failures. var ErrValidation = errors.New("gitlab: validation error") @@ -100,7 +106,7 @@ func New(opts Options) (*Client, error) { } httpClient := opts.HTTPClient if httpClient == nil { - httpClient = http.DefaultClient + httpClient = defaultBoundedHTTPClient() } return &Client{ host: normalizedHost, @@ -110,6 +116,10 @@ func New(opts Options) (*Client, error) { }, nil } +func defaultBoundedHTTPClient() *http.Client { + return &http.Client{Timeout: defaultHTTPTimeout} +} + // Host returns the normalized host this client is bound to. func (c *Client) Host() string { if c == nil { diff --git a/internal/gitprovider/gitlab/client_test.go b/internal/gitprovider/gitlab/client_test.go index b9755e78..408c834c 100644 --- a/internal/gitprovider/gitlab/client_test.go +++ b/internal/gitprovider/gitlab/client_test.go @@ -37,6 +37,20 @@ func TestCapabilities(t *testing.T) { } } +func TestDefaultHTTPClientIsBounded(t *testing.T) { + fallback := defaultBoundedHTTPClient() + if fallback.Timeout <= 0 { + t.Fatal("default HTTP client has no timeout") + } + if fallback == http.DefaultClient { + t.Fatal("default HTTP client must not be http.DefaultClient") + } + client := mustClient(t, Options{Token: "token"}) + if client.httpClient.Timeout <= 0 { + t.Fatal("New without an explicit HTTPClient must use the bounded fallback") + } +} + func TestNewFromGitConfigBuildsPATClientAndCredential(t *testing.T) { store := tokenStore{"work": {credentials.GitTokenKey: "token"}} client, credential, err := NewFromGitConfig(config.GitConfig{ diff --git a/internal/ledger/ledger.go b/internal/ledger/ledger.go index 6e0c6f1b..9961233a 100644 --- a/internal/ledger/ledger.go +++ b/internal/ledger/ledger.go @@ -7,6 +7,7 @@ import ( "encoding/json" "errors" "fmt" + "net/url" "os" "path/filepath" "strings" @@ -411,17 +412,17 @@ func Open(ctx context.Context, path string) (*Store, error) { return nil, fmt.Errorf("ledger: create db parent: %w", err) } - db, err := sql.Open("sqlite", path) + dsn, err := sqliteDSN(path) + if err != nil { + return nil, err + } + db, err := sql.Open("sqlite", dsn) if err != nil { return nil, fmt.Errorf("ledger: open sqlite: %w", err) } db.SetMaxOpenConns(1) db.SetMaxIdleConns(1) - if err := configureSQLite(ctx, db); err != nil { - _ = db.Close() - return nil, err - } if _, err := dbmig.Apply(ctx, db, migrations()); err != nil { _ = db.Close() return nil, fmt.Errorf("ledger: migrate: %w", err) @@ -494,17 +495,28 @@ func (s *Store) checkOpen() error { return nil } -func configureSQLite(ctx context.Context, db *sql.DB) error { - if _, err := db.ExecContext(ctx, "PRAGMA foreign_keys = ON"); err != nil { - return fmt.Errorf("ledger: enable foreign keys: %w", err) - } - if _, err := db.ExecContext(ctx, "PRAGMA journal_mode = WAL"); err != nil { - return fmt.Errorf("ledger: enable WAL: %w", err) +func sqliteDSN(path string) (string, error) { + absPath, err := filepath.Abs(path) + if err != nil { + return "", fmt.Errorf("ledger: resolve db path: %w", err) } - if _, err := db.ExecContext(ctx, fmt.Sprintf("PRAGMA busy_timeout = %d", DefaultBusyTimeout.Milliseconds())); err != nil { - return fmt.Errorf("ledger: set busy timeout: %w", err) + query := url.Values{} + query.Add("_pragma", "foreign_keys=ON") + query.Add("_pragma", "journal_mode=WAL") + query.Add("_pragma", fmt.Sprintf("busy_timeout=%d", DefaultBusyTimeout.Milliseconds())) + return (&url.URL{ + Scheme: "file", + Path: sqliteURIPath(absPath), + RawQuery: query.Encode(), + }).String(), nil +} + +func sqliteURIPath(path string) string { + path = filepath.ToSlash(path) + if len(path) >= 2 && path[1] == ':' && path[0] != '/' { + path = "/" + path } - return nil + return path } func migrations() []dbmig.Migration { diff --git a/internal/ledger/ledger_test.go b/internal/ledger/ledger_test.go index 8fe96208..4b6d4f42 100644 --- a/internal/ledger/ledger_test.go +++ b/internal/ledger/ledger_test.go @@ -4,8 +4,10 @@ import ( "context" "database/sql" "errors" + "os" "path/filepath" "reflect" + "runtime" "slices" "strconv" "sync" @@ -52,6 +54,46 @@ func TestOpenMigratesFreshDatabaseAndAppliesStartupContract(t *testing.T) { } } +func TestOpenReappliesPragmasAfterCanceledQuery(t *testing.T) { + name := "ledger #&%.db" + if runtime.GOOS != "windows" { + name = "ledger #?&%.db" + } + path := filepath.Join(t.TempDir(), name) + store := openStoreAt(t, path) + if _, err := os.Stat(path); err != nil { + t.Fatalf("ledger file at requested path: %v", err) + } + run := allocateRun(t, store, validAllocateRunParams()) + session := validSession(run.RunID) + insertSession(t, store, session) + insertFinding(t, store, validFinding(run.RunID, session.SessionRowID)) + insertPlannedAction(t, store, validPlannedAction(run.RunID)) + + ctx, cancel := context.WithTimeout(context.Background(), 20*time.Millisecond) + defer cancel() + if _, err := store.db.ExecContext(ctx, `WITH RECURSIVE cnt(x) AS ( + VALUES(0) UNION ALL SELECT x+1 FROM cnt WHERE x < 1000000000 + ) SELECT sum(x) FROM cnt`); err == nil { + t.Fatal("canceled query error = nil, want cancellation") + } + + if got := queryInt(t, store.db, "PRAGMA foreign_keys"); got != 1 { + t.Fatalf("PRAGMA foreign_keys after cancellation = %d, want 1", got) + } + if got := queryInt(t, store.db, "PRAGMA busy_timeout"); int64(got) != DefaultBusyTimeout.Milliseconds() { + t.Fatalf("PRAGMA busy_timeout after cancellation = %d, want %d", got, DefaultBusyTimeout.Milliseconds()) + } + if err := store.DeleteRun(context.Background(), run.RunID); err != nil { + t.Fatalf("DeleteRun after cancellation: %v", err) + } + for _, table := range []string{"sessions", "findings", "planned_actions"} { + if count := queryInt(t, store.db, "SELECT COUNT(*) FROM "+table); count != 0 { + t.Fatalf("%s count after cascade = %d, want 0", table, count) + } + } +} + func TestReviewerCohortReplaceAndSessionUpdateAreAtomic(t *testing.T) { store := openStore(t) ctx := context.Background() diff --git a/internal/workbench/workbench_test.go b/internal/workbench/workbench_test.go index 79fb4f9e..8de2c17c 100644 --- a/internal/workbench/workbench_test.go +++ b/internal/workbench/workbench_test.go @@ -250,7 +250,7 @@ func TestReviewerWorkspaceSmokeAllowsReadAndWorkspaceWrites(t *testing.T) { if err != nil { t.Fatalf("PrepareReviewerRequest: %v", err) } - defer cleanupForTest(t, cleanup) + t.Cleanup(cleanupForTest(t, cleanup)) type smokeResult struct { ReadOK bool `json:"read_ok"` @@ -299,7 +299,7 @@ func TestReviewerWorkspaceAllowedFilesPreservesRealCheckout(t *testing.T) { if err != nil { t.Fatalf("prepareReviewerWorkspace: %v", err) } - defer cleanupForTest(t, cleanup) + t.Cleanup(cleanupForTest(t, cleanup)) if workspace.RepoDir == artifacts.WorkbenchRepoDir { t.Fatalf("repo dir = %q, want disposable checkout distinct from workbench repo", workspace.RepoDir) } @@ -331,7 +331,7 @@ func TestReviewerWorkspaceAllowedFilesAcceptsDeletedPaths(t *testing.T) { if err != nil { t.Fatalf("prepareReviewerWorkspace: %v", err) } - defer cleanupForTest(t, cleanup) + t.Cleanup(cleanupForTest(t, cleanup)) if _, err := os.Stat(filepath.Join(workspace.RepoDir, "main.go")); err != nil { t.Fatalf("Stat(main.go): %v", err) } @@ -353,7 +353,7 @@ func TestReviewerWorkspaceAllowedFilesResetsWorkspace(t *testing.T) { if err != nil { t.Fatalf("prepareReviewerWorkspace(second): %v", err) } - defer cleanupForTest(t, cleanup) + t.Cleanup(cleanupForTest(t, cleanup)) if _, err := os.ReadFile(filepath.Join(workspace.RepoDir, "other.go")); err != nil { // #nosec G304 -- test reads only fixture paths. t.Fatalf("ReadFile(other.go): %v", err) } @@ -389,7 +389,7 @@ func TestReviewerWorkspaceAllowedFilesAcceptsSymlinkTargets(t *testing.T) { if err != nil { t.Fatalf("prepareReviewerWorkspace: %v", err) } - defer cleanupForTest(t, cleanup) + t.Cleanup(cleanupForTest(t, cleanup)) info, err := os.Lstat(filepath.Join(workspace.RepoDir, "other.go")) if err != nil { t.Fatalf("Lstat(other.go): %v", err) @@ -417,7 +417,7 @@ func TestReviewerWorkspaceAllowedFilesAcceptsSubmoduleTargets(t *testing.T) { if err != nil { t.Fatalf("prepareReviewerWorkspace: %v", err) } - defer cleanupForTest(t, cleanup) + t.Cleanup(cleanupForTest(t, cleanup)) info, err := os.Stat(filepath.Join(workspace.RepoDir, "vendor", "shared")) if err != nil { t.Fatalf("Stat(vendor/shared): %v", err) @@ -476,7 +476,7 @@ func TestPrepareReviewerRequestAcceptsPermissionBoundedAdapter(t *testing.T) { if err != nil { t.Fatalf("PrepareReviewerRequest: %v", err) } - defer cleanupForTest(t, cleanup) + t.Cleanup(cleanupForTest(t, cleanup)) if req.ReviewerWorkspace == nil { t.Fatalf("ReviewerWorkspace = nil") } @@ -498,7 +498,7 @@ func TestPrepareReviewerRequestValidationRetryGetsFreshWorkspaceWithSameFixedDif if err != nil { t.Fatalf("PrepareReviewerRequest: %v", err) } - defer cleanupForTest(t, cleanup) + t.Cleanup(cleanupForTest(t, cleanup)) firstRepo := req.ReviewerWorkspace.RepoDir if err := os.WriteFile(filepath.Join(firstRepo, "untracked"), []byte("dirty"), 0o600); err != nil { t.Fatalf("WriteFile(untracked): %v", err) From e61369940e4f72167c60f9e16dfcc61c8e0f218f Mon Sep 17 00:00:00 2001 From: Rian Stockbower Date: Tue, 15 Sep 2026 10:04:59 -0400 Subject: [PATCH 2/6] refactor: simplify init and architecture guards --- docs/architecture.md | 4 + .../architecture/command_boundaries_test.go | 45 +---- internal/architecture/plannedactions_test.go | 15 +- internal/architecture/stdlib_imports_test.go | 70 ++++++++ .../architecture/thread_lifecycle_test.go | 30 ---- internal/cmd/initcmd/init_profile_v2.go | 7 - .../initcmd/init_secrets_management_editor.go | 165 +++--------------- internal/cmd/initcmd/initcmd_test.go | 53 +----- internal/fsatomic/imports_test.go | 83 --------- internal/gate/imports_test.go | 83 --------- internal/marker/imports_test.go | 83 --------- 11 files changed, 120 insertions(+), 518 deletions(-) create mode 100644 internal/architecture/stdlib_imports_test.go delete mode 100644 internal/fsatomic/imports_test.go delete mode 100644 internal/gate/imports_test.go delete mode 100644 internal/marker/imports_test.go diff --git a/docs/architecture.md b/docs/architecture.md index 85e3e358..39a8fa55 100644 --- a/docs/architecture.md +++ b/docs/architecture.md @@ -134,6 +134,10 @@ return typed domain data so command and view code remain replaceable shells. `internal/architecture/command_boundaries_test.go` enforces these dependency directions with narrow allowances for command-tree integration tests and keeps review/response application runtime contracts out of `internal/cmd/cmdruntime`. +The architecture checks enforce package ownership and dependency direction; +the command-runtime checks leave helper names free to change, the +planned-action payload check leaves its source filename free to change, and +the thread-lifecycle checks do not prescribe per-file call counts. Review behavior should be protected through named acceptance harnesses rather than cloned broad assertions. The command-level harness verifies `cr review` diff --git a/internal/architecture/command_boundaries_test.go b/internal/architecture/command_boundaries_test.go index eb3ffb59..4f4959ca 100644 --- a/internal/architecture/command_boundaries_test.go +++ b/internal/architecture/command_boundaries_test.go @@ -1,7 +1,6 @@ package architecture_test import ( - "go/ast" "go/parser" "go/token" "io/fs" @@ -114,7 +113,7 @@ func TestApplicationPackagesStayOutOfCommandAndViewLayers(t *testing.T) { } } -func TestCommandRuntimeDoesNotOwnApplicationRuntimeContracts(t *testing.T) { +func TestCommandRuntimeStaysWithinCommandLayer(t *testing.T) { repoRoot := repoRootFromTest(t) modulePath := "github.com/open-cli-collective/codereview-cli" cmdRuntimeDir := filepath.Join(repoRoot, "internal", "cmd", "cmdruntime") @@ -134,21 +133,6 @@ func TestCommandRuntimeDoesNotOwnApplicationRuntimeContracts(t *testing.T) { modulePath + "/internal/credentials": true, modulePath + "/internal/gitprovider": true, } - allowedExports := map[string]bool{ - "ConfigPath": true, - "MapRunError": true, - "MissingResponderError": true, - "ReadOptionalSecretIngress": true, - "ReadSecretIngress": true, - } - allowedFunctions := map[string]bool{ - "ConfigPath": true, - "ingressName": true, - "MapRunError": true, - "MissingResponderError": true, - "ReadOptionalSecretIngress": true, - "ReadSecretIngress": true, - } fset := token.NewFileSet() err := filepath.WalkDir(cmdRuntimeDir, func(path string, entry fs.DirEntry, walkErr error) error { if walkErr != nil { @@ -157,7 +141,7 @@ func TestCommandRuntimeDoesNotOwnApplicationRuntimeContracts(t *testing.T) { if entry.IsDir() || filepath.Ext(path) != ".go" || strings.HasSuffix(path, "_test.go") { return nil } - parsed, err := parser.ParseFile(fset, path, nil, 0) + parsed, err := parser.ParseFile(fset, path, nil, parser.ImportsOnly) if err != nil { return err } @@ -171,31 +155,6 @@ func TestCommandRuntimeDoesNotOwnApplicationRuntimeContracts(t *testing.T) { t.Fatalf("%s imports %s; cmdruntime should stay limited to command-layer config/error helpers", pos, importPath) } } - for _, decl := range parsed.Decls { - switch typed := decl.(type) { - case *ast.FuncDecl: - if typed.Name == nil { - continue - } - if !allowedFunctions[typed.Name.Name] { - pos := fset.Position(typed.Pos()) - t.Fatalf("%s declares %s; cmdruntime should only keep the approved command-layer helper surface", pos, typed.Name.Name) - } - if ast.IsExported(typed.Name.Name) && !allowedExports[typed.Name.Name] { - pos := fset.Position(typed.Pos()) - t.Fatalf("%s exports %s; cmdruntime should only export command-layer config/error helpers", pos, typed.Name.Name) - } - case *ast.GenDecl: - if typed.Tok == token.IMPORT { - continue - } - pos := fset.Position(typed.Pos()) - t.Fatalf("%s declares %s; cmdruntime should not own top-level %s beyond imports", pos, typed.Tok.String(), typed.Tok.String()) - default: - pos := fset.Position(decl.Pos()) - t.Fatalf("%s declares unsupported top-level syntax in cmdruntime", pos) - } - } return nil }) if err != nil { diff --git a/internal/architecture/plannedactions_test.go b/internal/architecture/plannedactions_test.go index 92b249ef..d18fc556 100644 --- a/internal/architecture/plannedactions_test.go +++ b/internal/architecture/plannedactions_test.go @@ -10,7 +10,7 @@ import ( "testing" ) -func TestPayloadStructsAreOwnedByPlannedActions(t *testing.T) { +func TestPayloadStructsAreDefinedOnceInPlannedActions(t *testing.T) { repoRoot := repoRootFromTest(t) want := map[string]bool{ "InlineCommentPayload": false, @@ -44,12 +44,12 @@ func TestPayloadStructsAreOwnedByPlannedActions(t *testing.T) { if _, tracked := want[typeSpec.Name.Name]; !tracked { continue } - rel := filepath.ToSlash(mustRel(t, repoRoot, path)) - if rel != "internal/plannedactions/plannedactions.go" { - t.Fatalf("%s declares %s; payload structs belong in internal/plannedactions", rel, typeSpec.Name.Name) + relDir := filepath.ToSlash(mustRel(t, repoRoot, filepath.Dir(path))) + if relDir != "internal/plannedactions" { + t.Fatalf("%s declares %s; payload structs belong in the internal/plannedactions package", relDir, typeSpec.Name.Name) } if want[typeSpec.Name.Name] { - t.Fatalf("%s declares %s more than once", rel, typeSpec.Name.Name) + t.Fatalf("%s declares %s more than once", relDir, typeSpec.Name.Name) } want[typeSpec.Name.Name] = true } @@ -109,6 +109,7 @@ func TestPlannedActionPayloadJSONIsLedgerPrivate(t *testing.T) { return err } rel := filepath.ToSlash(mustRel(t, repoRoot, path)) + relDir := filepath.ToSlash(mustRel(t, repoRoot, filepath.Dir(path))) ast.Inspect(parsed, func(node ast.Node) bool { switch node := node.(type) { case *ast.SelectorExpr: @@ -116,8 +117,8 @@ func TestPlannedActionPayloadJSONIsLedgerPrivate(t *testing.T) { t.Fatalf("%s exposes raw planned-action payload JSON", rel) } case *ast.BasicLit: - if strings.Contains(node.Value, "payload_json") && rel != "internal/ledger/ledger.go" { - t.Fatalf("%s accesses ledger payload_json outside ledger", rel) + if strings.Contains(node.Value, "payload_json") && relDir != "internal/ledger" { + t.Fatalf("%s accesses ledger payload_json outside the internal/ledger package", rel) } } return true diff --git a/internal/architecture/stdlib_imports_test.go b/internal/architecture/stdlib_imports_test.go new file mode 100644 index 00000000..90194b75 --- /dev/null +++ b/internal/architecture/stdlib_imports_test.go @@ -0,0 +1,70 @@ +package architecture_test + +import ( + "bytes" + "go/parser" + "go/token" + "io/fs" + "os/exec" + "path/filepath" + "strconv" + "strings" + "testing" +) + +func TestSelectedProductionPackagesStayStdlibOnly(t *testing.T) { + repoRoot := repoRootFromTest(t) + stdlib := standardLibraryImports(t, repoRoot) + for _, relDir := range []string{"internal/gate", "internal/fsatomic", "internal/marker"} { + t.Run(relDir, func(t *testing.T) { + checkStdlibOnlyPackage(t, repoRoot, relDir, stdlib) + }) + } +} + +func checkStdlibOnlyPackage(t *testing.T, repoRoot, relDir string, stdlib map[string]struct{}) { + t.Helper() + root := filepath.Join(repoRoot, filepath.FromSlash(relDir)) + fset := token.NewFileSet() + err := filepath.WalkDir(root, func(filePath string, entry fs.DirEntry, walkErr error) error { + if walkErr != nil { + return walkErr + } + if entry.IsDir() || filepath.Ext(filePath) != ".go" || strings.HasSuffix(filePath, "_test.go") { + return nil + } + parsed, err := parser.ParseFile(fset, filePath, nil, parser.ImportsOnly) + if err != nil { + return err + } + for _, imported := range parsed.Imports { + importedPath, err := strconv.Unquote(imported.Path.Value) + if err != nil { + return err + } + rel := filepath.ToSlash(mustRel(t, repoRoot, filePath)) + if _, ok := stdlib[importedPath]; !ok { + t.Fatalf("%s imports %q, want standard library only", rel, importedPath) + } + } + return nil + }) + if err != nil { + t.Fatalf("WalkDir(%s): %v", root, err) + } +} + +func standardLibraryImports(t *testing.T, repoRoot string) map[string]struct{} { + t.Helper() + cmd := exec.Command("go", "list", "std") + cmd.Dir = repoRoot + output, err := cmd.Output() + if err != nil { + t.Fatalf("go list std: %v", err) + } + imports := make(map[string]struct{}) + for _, path := range bytes.Fields(output) { + imports[string(path)] = struct{}{} + } + return imports +} diff --git a/internal/architecture/thread_lifecycle_test.go b/internal/architecture/thread_lifecycle_test.go index ac5d6b7b..56221b5c 100644 --- a/internal/architecture/thread_lifecycle_test.go +++ b/internal/architecture/thread_lifecycle_test.go @@ -120,36 +120,6 @@ func TestPackagesStayOnLayeredSeams(t *testing.T) { } } -func TestReviewAndRespondUseSharedThreadAnalysisBatch(t *testing.T) { - repoRoot := repoRootFromTest(t) - for _, rel := range []string{"internal/pipeline/pipeline.go", "internal/threadrespond/threadrespond.go"} { - path := filepath.Join(repoRoot, filepath.FromSlash(rel)) - parsed, err := parser.ParseFile(token.NewFileSet(), path, nil, 0) - if err != nil { - t.Fatalf("ParseFile(%s): %v", path, err) - } - calls := map[string]int{} - ast.Inspect(parsed, func(node ast.Node) bool { - call, ok := node.(*ast.CallExpr) - if !ok { - return true - } - selector, ok := call.Fun.(*ast.SelectorExpr) - if !ok { - return true - } - pkg, ok := selector.X.(*ast.Ident) - if ok && pkg.Name == "threadanalysis" { - calls[selector.Sel.Name]++ - } - return true - }) - if calls["AnalyzeThreads"] != 1 || calls["AnalyzeThread"] != 0 || calls["ResponseActions"] != 1 { - t.Fatalf("%s threadanalysis calls = %#v, want one shared batch and response conversion with no caller-owned single-thread loop", rel, calls) - } - } -} - func checkPackageImports(t *testing.T, repoRoot, dir string, blocked map[string]bool) { t.Helper() root := filepath.Join(repoRoot, filepath.FromSlash(dir)) diff --git a/internal/cmd/initcmd/init_profile_v2.go b/internal/cmd/initcmd/init_profile_v2.go index ed99308d..1110b69a 100644 --- a/internal/cmd/initcmd/init_profile_v2.go +++ b/internal/cmd/initcmd/init_profile_v2.go @@ -895,9 +895,6 @@ func (m initProfileV2ReadOnlyModel) validatedDraft() (initDraft, error) { draft.LLMCredentialStore = initCredentialStoreDefaultID() draft.LLMCredentialRef = "" } - if err := m.normalizeStorageLabels(&draft, selectedGitScope, selectedReviewerEntity, selectedLLMRuntime); err != nil { - return draft, err - } if m.document.fieldIndexByID(initProfileV2FieldReviewerModelTier) >= 0 { draft.LLMReviewerModelTier = m.document.selectedValue(initProfileV2FieldReviewerModelTier) } @@ -931,10 +928,6 @@ func (m initProfileV2ReadOnlyModel) validatedDraft() (initDraft, error) { return draft, nil } -func (m initProfileV2ReadOnlyModel) normalizeStorageLabels(*initDraft, string, string, string) error { - return nil -} - func (m *initProfileV2ReadOnlyModel) syncProfileNameDerivedCredentialFields() { index := m.document.fieldIndexByID(initProfileV2FieldLLMCredentialName) if index < 0 || m.document[index].Hidden || !m.document[index].AutoManaged { diff --git a/internal/cmd/initcmd/init_secrets_management_editor.go b/internal/cmd/initcmd/init_secrets_management_editor.go index 79bfbc96..706b2222 100644 --- a/internal/cmd/initcmd/init_secrets_management_editor.go +++ b/internal/cmd/initcmd/init_secrets_management_editor.go @@ -2,7 +2,6 @@ package initcmd import ( "fmt" - "sort" "strings" "github.com/charmbracelet/huh" @@ -32,59 +31,24 @@ const ( initSecretsManagementSectionServiceAccount initLinearFieldID = "secrets_management_section_service_account" ) -type initPendingSecretsManagementDelete struct { - ID string - Profile config.SecretsStore -} - func (p huhInitKeyringBackendPrompter) editKeyringBackendLinear(prompt initKeyringBackendPrompt) (initKeyringBackendEdit, error) { working := config.Normalize(cloneInitConfigFile(prompt.Config)) discoveryMode := p.resolvedDiscoveryMode() p.writeSecretsStorageDiscoveryNotice(discoveryMode) desktopDiscovery := p.discoverOnePasswordDesktopForMode(discoveryMode) p.writeSecretsStorageDiscoveryResults(discoveryMode, desktopDiscovery) - pendingDeletes := map[string]initPendingSecretsManagementDelete{} - pendingDeleteOrder := []string{} - for { - editor := initSecretsManagementLinearEditorWithPendingOrderAndDiscovery(working, pendingDeletes, pendingDeleteOrder, desktopDiscovery) - model, err := runInitEditor(editor, p.stdin, p.stderr, p.editorRunner, "secrets-management") - if err != nil { - return initKeyringBackendEdit{}, err - } - switch model.resultAction { - case initDetailActionEdit: - return initSecretsManagementEditFromDocumentWithDiscovery(working, model.document, desktopDiscovery) - case initLinearResultActionDelete: - edit, err := initSecretsManagementDeleteEditFromDocument(working, model.document) - if err != nil { - return initKeyringBackendEdit{}, err - } - return edit, nil - case initLinearResultActionRestore: - selection := model.document.selectedValue(initSecretsManagementFieldTarget) - id, ok := initLinearRestoreSelectionName("secrets_management", selection) - if !ok { - continue - } - pending, ok := pendingDeletes[id] - if !ok { - continue - } - patch := configedit.SecretsStorePatch{Backend: &pending.Profile.Backend} - if strings.TrimSpace(pending.Profile.DisplayName) != "" { - label := pending.Profile.DisplayName - patch.Label = &label - } - nextCfg, _, _, err := configedit.SetSecretsStore(working, id, patch) - if err != nil { - return initKeyringBackendEdit{}, err - } - delete(pendingDeletes, id) - pendingDeleteOrder = removeInitSecretsManagementPendingDeleteOrder(pendingDeleteOrder, id) - working = nextCfg - default: - return initKeyringBackendEdit{}, errInitNavigateBack - } + editor := initSecretsManagementLinearEditorWithDiscovery(working, desktopDiscovery) + model, err := runInitEditor(editor, p.stdin, p.stderr, p.editorRunner, "secrets-management") + if err != nil { + return initKeyringBackendEdit{}, err + } + switch model.resultAction { + case initDetailActionEdit: + return initSecretsManagementEditFromDocumentWithDiscovery(working, model.document, desktopDiscovery) + case initLinearResultActionDelete: + return initSecretsManagementDeleteEditFromDocument(working, model.document) + default: + return initKeyringBackendEdit{}, errInitNavigateBack } } @@ -109,19 +73,11 @@ func (p huhInitKeyringBackendPrompter) writeSecretsStorageDiscoveryNotice(mode i } func initSecretsManagementLinearEditor(cfg config.File) initLinearEditor { - return initSecretsManagementLinearEditorWithPending(cfg, nil) -} - -func initSecretsManagementLinearEditorWithPending(cfg config.File, pendingDeletes map[string]initPendingSecretsManagementDelete) initLinearEditor { - return initSecretsManagementLinearEditorWithPendingOrder(cfg, pendingDeletes, nil) -} - -func initSecretsManagementLinearEditorWithPendingOrder(cfg config.File, pendingDeletes map[string]initPendingSecretsManagementDelete, pendingDeleteOrder []string) initLinearEditor { - return initSecretsManagementLinearEditorWithPendingOrderAndDiscovery(cfg, pendingDeletes, pendingDeleteOrder, initOnePasswordDesktopDiscovery{}) + return initSecretsManagementLinearEditorWithDiscovery(cfg, initOnePasswordDesktopDiscovery{}) } -func initSecretsManagementLinearEditorWithPendingOrderAndDiscovery(cfg config.File, pendingDeletes map[string]initPendingSecretsManagementDelete, pendingDeleteOrder []string, desktopDiscovery initOnePasswordDesktopDiscovery) initLinearEditor { - targetOptions := initSecretsManagementTargetOptions(cfg, pendingDeletes, pendingDeleteOrder) +func initSecretsManagementLinearEditorWithDiscovery(cfg config.File, desktopDiscovery initOnePasswordDesktopDiscovery) initLinearEditor { + targetOptions := initSecretsManagementTargetOptions(cfg) selectedTarget := normalizeInitStringSelectionValue("", targetOptions) var document initLinearDocument document.addSection("Secrets storage", initSecretsManagementInventoryDescription()) @@ -159,11 +115,11 @@ func initSecretsManagementLinearEditorWithPendingOrderAndDiscovery(cfg config.Fi } id := model.document[index].ID if id == initSecretsManagementFieldTarget { - initSecretsManagementSyncLinearFields(model, cfg, pendingDeletes, pendingDeleteOrder, desktopDiscovery, true) + initSecretsManagementSyncLinearFields(model, cfg, desktopDiscovery, true) return } if id == initSecretsManagementFieldBackend || id == initSecretsManagementFieldDesktopAccount || id == initSecretsManagementFieldDesktopVault { - initSecretsManagementSyncLinearFields(model, cfg, pendingDeletes, pendingDeleteOrder, desktopDiscovery, false) + initSecretsManagementSyncLinearFields(model, cfg, desktopDiscovery, false) } }, OnEnter: initLinearActionEnterHandler(initSecretsManagementFieldAction, func(model *initLinearEditorModel, action string) (string, error) { @@ -182,14 +138,14 @@ func initSecretsManagementLinearEditorWithPendingOrderAndDiscovery(cfg config.Fi }), } model := newInitLinearEditorModel(editor, 100, 28) - initSecretsManagementSyncLinearFields(&model, cfg, pendingDeletes, pendingDeleteOrder, desktopDiscovery, true) + initSecretsManagementSyncLinearFields(&model, cfg, desktopDiscovery, true) editor.Document = model.document return editor } -func initSecretsManagementTargetOptions(cfg config.File, pendingDeletes map[string]initPendingSecretsManagementDelete, pendingDeleteOrder []string) []huh.Option[string] { +func initSecretsManagementTargetOptions(cfg config.File) []huh.Option[string] { rows := initSecretsManagementInventoryRows(cfg) - options := make([]huh.Option[string], 0, len(rows)+len(pendingDeletes)) + options := make([]huh.Option[string], 0, len(rows)) commandOptions := make([]huh.Option[string], 0, len(rows)) for _, row := range rows { if row.ID == initBackSelection || row.ID == config.LocalOSCredentialStoreID || !row.Selectable { @@ -202,12 +158,7 @@ func initSecretsManagementTargetOptions(cfg config.File, pendingDeletes map[stri } commandOptions = append(commandOptions, option) } - pendingIDs := orderedInitSecretsManagementPendingDeleteIDs(pendingDeletes, pendingDeleteOrder) options = append(options, commandOptions...) - for _, id := range pendingIDs { - pending := pendingDeletes[id] - options = append(options, huh.NewOption(initPendingDeleteLabel(initSecretsStorePendingDeleteTitle(id, pending.Profile)), initLinearRestoreSelection("secrets_management", id))) - } return dedupeInitStringOptions(options) } @@ -220,44 +171,11 @@ func initSecretsManagementBuiltInSectionDescription() string { return fmt.Sprintf("%s%s %s", prefix, initBuiltInOSCredentialStoreTitle(), description) } -func orderedInitSecretsManagementPendingDeleteIDs(pendingDeletes map[string]initPendingSecretsManagementDelete, pendingDeleteOrder []string) []string { - if len(pendingDeletes) == 0 { - return nil - } - seen := map[string]bool{} - ordered := make([]string, 0, len(pendingDeletes)) - for _, id := range pendingDeleteOrder { - if _, ok := pendingDeletes[id]; ok && !seen[id] { - ordered = append(ordered, id) - seen[id] = true - } - } - remainder := make([]string, 0, len(pendingDeletes)-len(ordered)) - for id := range pendingDeletes { - if !seen[id] { - remainder = append(remainder, id) - } - } - sort.Strings(remainder) - return append(ordered, remainder...) -} - -func removeInitSecretsManagementPendingDeleteOrder(order []string, id string) []string { - next := order[:0] - for _, existing := range order { - if existing != id { - next = append(next, existing) - } - } - return next -} - type initSecretsManagementSelectionState struct { Profile config.SecretsStore ID string Creating bool BuiltIn bool - Pending bool } func initSecretsManagementSelectionStateForDocument(cfg config.File, document initLinearDocument) (initSecretsManagementSelectionState, error) { @@ -275,9 +193,6 @@ func initSecretsManagementSelectionStateForSelection(cfg config.File, selection Creating: true, }, nil } - if id, ok := initLinearRestoreSelectionName("secrets_management", selection); ok { - return initSecretsManagementSelectionState{ID: id, Pending: true}, nil - } profile, ok := cfg.Secrets.Stores[selection] if !ok { return initSecretsManagementSelectionState{}, fmt.Errorf("%w: %s", config.ErrSecretsStoreNotFound, selection) @@ -288,14 +203,14 @@ func initSecretsManagementSelectionStateForSelection(cfg config.File, selection }, nil } -func initSecretsManagementSyncLinearFields(model *initLinearEditorModel, cfg config.File, pendingDeletes map[string]initPendingSecretsManagementDelete, pendingDeleteOrder []string, desktopDiscovery initOnePasswordDesktopDiscovery, resetDetails bool) { +func initSecretsManagementSyncLinearFields(model *initLinearEditorModel, cfg config.File, desktopDiscovery initOnePasswordDesktopDiscovery, resetDetails bool) { state, err := initSecretsManagementSelectionStateForDocument(cfg, model.document) if err != nil { return } - initSecretsManagementSetTargetOptions(model, cfg, pendingDeletes, pendingDeleteOrder, model.document.selectedValue(initSecretsManagementFieldTarget)) - profileVisible := !state.Pending && !state.BuiltIn - allowEdit := profileVisible || state.Pending + initSecretsManagementSetTargetOptions(model, cfg, model.document.selectedValue(initSecretsManagementFieldTarget)) + profileVisible := !state.BuiltIn + allowEdit := profileVisible model.setFieldHidden(initSecretsManagementSectionProfile, false) model.setFieldHidden(initSecretsManagementFieldLabel, !profileVisible) model.setFieldHidden(initSecretsManagementFieldBackend, !profileVisible || state.Creating) @@ -306,12 +221,6 @@ func initSecretsManagementSyncLinearFields(model *initLinearEditorModel, cfg con model.selectFieldValue(initSecretsManagementFieldAction, initDetailActionBack) return } - if state.Pending { - model.setFieldHidden(initSecretsManagementFieldAction, false) - model.setFieldDescription(initSecretsManagementSectionProfile, "This credential store is staged for deletion. Press r while it is selected to restore it.") - initSecretsManagementSetOnePasswordHidden(model, true, true, true, true) - return - } model.setFieldDescription(initSecretsManagementSectionProfile, initSecretsManagementProfileSectionDescription(model.document, state)) profile := state.Profile if strings.TrimSpace(string(profile.Backend.Kind)) == "" { @@ -374,17 +283,14 @@ func initSecretsManagementSyncLinearFields(model *initLinearEditorModel, cfg con } } -func initSecretsManagementSetTargetOptions(model *initLinearEditorModel, cfg config.File, pendingDeletes map[string]initPendingSecretsManagementDelete, pendingDeleteOrder []string, selected string) { +func initSecretsManagementSetTargetOptions(model *initLinearEditorModel, cfg config.File, selected string) { cfg = config.Normalize(cfg) - initLinearSetSelectionOptions(model, initSecretsManagementFieldTarget, initSecretsManagementTargetOptions(cfg, pendingDeletes, pendingDeleteOrder), selected, + initLinearSetSelectionOptions(model, initSecretsManagementFieldTarget, initSecretsManagementTargetOptions(cfg), selected, func(value string) bool { _, ok := cfg.Secrets.Stores[value] return ok && value != config.LocalOSCredentialStoreID }, - func(value string) bool { - _, ok := initLinearRestoreSelectionName("secrets_management", value) - return ok - }, + func(string) bool { return false }, ) } @@ -478,9 +384,6 @@ func initSecretsManagementBackendOptionLabel(kind config.SecretsBackendKind) str func initSecretsManagementProfileSectionDescription(document initLinearDocument, state initSecretsManagementSelectionState) string { target := initSecretsManagementSelectedOptionLabel(document, initSecretsManagementFieldTarget) - if state.Pending && target != "" { - return fmt.Sprintf("Selected target: %s. This credential store is staged for deletion. Press r to restore it.", target) - } if state.BuiltIn && target != "" { description := strings.TrimSpace(initBuiltInOSCredentialStoreDescription()) if description != "" { @@ -497,15 +400,6 @@ func initSecretsManagementProfileSectionDescription(document initLinearDocument, return "Configured credential stores are reusable destinations for secrets." } -func initSecretsStorePendingDeleteTitle(id string, profile config.SecretsStore) string { - return initSecretsStoreInventoryTitle(config.EffectiveSecretsStore{ - ID: id, - DisplayName: profile.DisplayName, - Backend: string(profile.Backend.Kind), - Source: config.EffectiveSecretsStoreSourceConfigured, - }) -} - func initSecretsManagementBackendFieldDescription(kind config.SecretsBackendKind, locked bool) string { description := strings.TrimSpace(initSecretsBackendDescription(kind)) if locked { @@ -550,7 +444,7 @@ func initSecretsManagementDeleteEditFromDocument(cfg config.File, document initL if err != nil { return initKeyringBackendEdit{}, err } - if state.Creating || state.BuiltIn || state.Pending || state.ID == "" { + if state.Creating || state.BuiltIn || state.ID == "" { return initKeyringBackendEdit{}, fmt.Errorf("only configured credential stores can be deleted") } working := config.Normalize(cloneInitConfigFile(cfg)) @@ -570,9 +464,6 @@ func initSecretsManagementEditFromDocumentWithDiscovery(cfg config.File, documen if state.BuiltIn { return initKeyringBackendEdit{}, nil } - if state.Pending { - return initKeyringBackendEdit{Apply: true, HasConfigEdit: true, Config: config.Normalize(working)}, nil - } edit, err := initSecretsManagementProfileEditFromDocument(state, document, desktopDiscovery) if err != nil { return initKeyringBackendEdit{}, err diff --git a/internal/cmd/initcmd/initcmd_test.go b/internal/cmd/initcmd/initcmd_test.go index 2ee43f55..5012e2c0 100644 --- a/internal/cmd/initcmd/initcmd_test.go +++ b/internal/cmd/initcmd/initcmd_test.go @@ -8034,45 +8034,8 @@ func TestHuhInitKeyringBackendPrompterLinearCanDeleteConfiguredSecretsStore(t *t } } -func TestInitSecretsManagementTargetOptionsMovesPendingDeletesToBottomInDeletionOrder(t *testing.T) { - cfg := config.File{ - Profiles: map[string]config.Profile{"default": basicProfile("default")}, - Secrets: config.SecretsConfig{ - Stores: map[string]config.SecretsStore{ - "personal": { - DisplayName: "Personal", - Backend: config.SecretsStoreBackend{Kind: config.SecretsBackendKind(credstore.BackendFile)}, - }, - }, - }, - } - pendingDeletes := map[string]initPendingSecretsManagementDelete{ - "alpha": {ID: "alpha", Profile: config.SecretsStore{ - DisplayName: "Alpha", - Backend: config.SecretsStoreBackend{Kind: config.SecretsBackendKind(credstore.BackendFile)}, - }}, - "beta": {ID: "beta", Profile: config.SecretsStore{ - DisplayName: "Beta", - Backend: config.SecretsStoreBackend{Kind: config.SecretsBackendKind(credstore.BackendFile)}, - }}, - } - - options := initSecretsManagementTargetOptions(cfg, pendingDeletes, []string{"alpha", "beta"}) - values := make([]string, 0, len(options)) - for _, option := range options { - values = append(values, option.Value) - } - wantSuffix := []string{ - initLinearRestoreSelection("secrets_management", "alpha"), - initLinearRestoreSelection("secrets_management", "beta"), - } - if len(values) < len(wantSuffix) || !reflect.DeepEqual(values[len(values)-len(wantSuffix):], wantSuffix) { - t.Fatalf("target option values = %#v, want pending deletes last in staging order %#v", values, wantSuffix) - } -} - func TestInitSecretsManagementTargetOptionsExcludeBuiltInOSStore(t *testing.T) { - options := initSecretsManagementTargetOptions(config.File{}, nil, nil) + options := initSecretsManagementTargetOptions(config.File{}) for _, option := range options { if option.Value == config.LocalOSCredentialStoreID { t.Fatalf("target options include built-in OS store as selectable row: %#v", options) @@ -8471,7 +8434,7 @@ func TestInitSecretsManagementLinearEditorDesktopDiscoverySelectsAccountVault(t cfg := config.File{ Profiles: map[string]config.Profile{"default": basicProfile("default")}, } - editor := initSecretsManagementLinearEditorWithPendingOrderAndDiscovery(cfg, nil, nil, discovery) + editor := initSecretsManagementLinearEditorWithDiscovery(cfg, discovery) model := newInitLinearEditorModel(editor, 180, 32) model = selectInitLinearFieldValue(t, model, initSecretsManagementFieldTarget, initConfigureSecretsStoreSelectionPrefix+string(credstore.BackendOPDesktop)) out := model.layout.Content @@ -8532,7 +8495,7 @@ func TestInitSecretsManagementLinearEditorCanCreateStoreBeforeReviewProfile(t *t Name: "Private", }}, }}} - editor := initSecretsManagementLinearEditorWithPendingOrderAndDiscovery(config.File{}, nil, nil, discovery) + editor := initSecretsManagementLinearEditorWithDiscovery(config.File{}, discovery) model := newInitLinearEditorModel(editor, 180, 40) model = selectInitLinearFieldValue(t, model, initSecretsManagementFieldTarget, initConfigureSecretsStoreSelectionPrefix+string(credstore.BackendOPDesktop)) model = focusInitLinearField(t, model, initSecretsManagementFieldAction) @@ -8588,7 +8551,7 @@ func TestInitSecretsManagementLinearEditorDesktopDiscoverySelectsAccountThenVaul cfg := config.File{ Profiles: map[string]config.Profile{"default": basicProfile("default")}, } - editor := initSecretsManagementLinearEditorWithPendingOrderAndDiscovery(cfg, nil, nil, discovery) + editor := initSecretsManagementLinearEditorWithDiscovery(cfg, discovery) model := newInitLinearEditorModel(editor, 180, 40) model = selectInitLinearFieldValue(t, model, initSecretsManagementFieldTarget, initConfigureSecretsStoreSelectionPrefix+string(credstore.BackendOPDesktop)) @@ -8646,7 +8609,7 @@ func TestInitSecretsManagementLinearEditorDesktopDiscoveryIncludesAccountWithout cfg := config.File{ Profiles: map[string]config.Profile{"default": basicProfile("default")}, } - editor := initSecretsManagementLinearEditorWithPendingOrderAndDiscovery(cfg, nil, nil, discovery) + editor := initSecretsManagementLinearEditorWithDiscovery(cfg, discovery) model := newInitLinearEditorModel(editor, 180, 40) model = selectInitLinearFieldValue(t, model, initSecretsManagementFieldTarget, initConfigureSecretsStoreSelectionPrefix+string(credstore.BackendOPDesktop)) @@ -8692,7 +8655,7 @@ func TestInitSecretsManagementLinearEditorDesktopDiscoveryAllowsManualVaultInSel cfg := config.File{ Profiles: map[string]config.Profile{"default": basicProfile("default")}, } - editor := initSecretsManagementLinearEditorWithPendingOrderAndDiscovery(cfg, nil, nil, discovery) + editor := initSecretsManagementLinearEditorWithDiscovery(cfg, discovery) model := newInitLinearEditorModel(editor, 180, 40) model = selectInitLinearFieldValue(t, model, initSecretsManagementFieldTarget, initConfigureSecretsStoreSelectionPrefix+string(credstore.BackendOPDesktop)) model = selectInitLinearFieldValue(t, model, initSecretsManagementFieldDesktopVault, initOnePasswordManualSelection) @@ -8739,7 +8702,7 @@ func TestInitSecretsManagementLinearEditorDesktopDiscoveryAllowsManualAccount(t cfg := config.File{ Profiles: map[string]config.Profile{"default": basicProfile("default")}, } - editor := initSecretsManagementLinearEditorWithPendingOrderAndDiscovery(cfg, nil, nil, discovery) + editor := initSecretsManagementLinearEditorWithDiscovery(cfg, discovery) model := newInitLinearEditorModel(editor, 180, 40) model = selectInitLinearFieldValue(t, model, initSecretsManagementFieldTarget, initConfigureSecretsStoreSelectionPrefix+string(credstore.BackendOPDesktop)) model = selectInitLinearFieldValue(t, model, initSecretsManagementFieldDesktopAccount, initOnePasswordManualSelection) @@ -8780,7 +8743,7 @@ func TestInitSecretsManagementLinearEditorDesktopDiscoveryFailureAllowsManualPro Profiles: map[string]config.Profile{"default": basicProfile("default")}, } discovery := initOnePasswordDesktopDiscovery{Err: os.ErrNotExist} - editor := initSecretsManagementLinearEditorWithPendingOrderAndDiscovery(cfg, nil, nil, discovery) + editor := initSecretsManagementLinearEditorWithDiscovery(cfg, discovery) model := newInitLinearEditorModel(editor, 180, 32) model = selectInitLinearFieldValue(t, model, initSecretsManagementFieldTarget, initConfigureSecretsStoreSelectionPrefix+string(credstore.BackendOPDesktop)) out := model.layout.Content diff --git a/internal/fsatomic/imports_test.go b/internal/fsatomic/imports_test.go deleted file mode 100644 index 918d85d7..00000000 --- a/internal/fsatomic/imports_test.go +++ /dev/null @@ -1,83 +0,0 @@ -package fsatomic - -import ( - "bytes" - "go/parser" - "go/token" - "io/fs" - "os/exec" - "path/filepath" - "runtime" - "strconv" - "strings" - "testing" -) - -func TestProductionImportsStayStdlibOnly(t *testing.T) { - _, testFile, _, ok := runtime.Caller(0) - if !ok { - t.Fatal("runtime.Caller failed") - } - dir := filepath.Dir(testFile) - repoRoot, modulePath := repoRootAndModule(t, dir) - stdlib := stdlibImports(t, repoRoot) - fset := token.NewFileSet() - err := filepath.WalkDir(dir, func(file string, entry fs.DirEntry, walkErr error) error { - if walkErr != nil { - return walkErr - } - if entry.IsDir() || filepath.Ext(file) != ".go" || strings.HasSuffix(file, "_test.go") { - return nil - } - parsed, err := parser.ParseFile(fset, file, nil, parser.ImportsOnly) - if err != nil { - return err - } - for _, imported := range parsed.Imports { - path, err := strconv.Unquote(imported.Path.Value) - if err != nil { - return err - } - if strings.HasPrefix(path, modulePath+"/") || path == modulePath { - t.Fatalf("production import %q is from this repo, want stdlib only", path) - } - if _, ok := stdlib[path]; !ok { - t.Fatalf("production import %q is not in the standard library", path) - } - } - return nil - }) - if err != nil { - t.Fatalf("WalkDir(%s): %v", dir, err) - } -} - -func repoRootAndModule(t *testing.T, dir string) (string, string) { - t.Helper() - cmd := exec.Command("go", "list", "-m", "-f", "{{.Dir}} {{.Path}}") - cmd.Dir = dir - output, err := cmd.Output() - if err != nil { - t.Fatalf("go list module: %v", err) - } - parts := strings.Fields(string(output)) - if len(parts) != 2 { - t.Fatalf("go list module output = %q, want dir and path", output) - } - return parts[0], parts[1] -} - -func stdlibImports(t *testing.T, repoRoot string) map[string]struct{} { - t.Helper() - cmd := exec.Command("go", "list", "std") - cmd.Dir = repoRoot - output, err := cmd.Output() - if err != nil { - t.Fatalf("go list std: %v", err) - } - imports := make(map[string]struct{}) - for _, path := range bytes.Fields(output) { - imports[string(path)] = struct{}{} - } - return imports -} diff --git a/internal/gate/imports_test.go b/internal/gate/imports_test.go deleted file mode 100644 index 1b657e46..00000000 --- a/internal/gate/imports_test.go +++ /dev/null @@ -1,83 +0,0 @@ -package gate - -import ( - "bytes" - "go/parser" - "go/token" - "io/fs" - "os/exec" - "path/filepath" - "runtime" - "strconv" - "strings" - "testing" -) - -func TestProductionImportsStayStdlibOnly(t *testing.T) { - _, testFile, _, ok := runtime.Caller(0) - if !ok { - t.Fatal("runtime.Caller failed") - } - dir := filepath.Dir(testFile) - repoRoot, modulePath := repoRootAndModule(t, dir) - stdlib := stdlibImports(t, repoRoot) - fset := token.NewFileSet() - err := filepath.WalkDir(dir, func(file string, entry fs.DirEntry, walkErr error) error { - if walkErr != nil { - return walkErr - } - if entry.IsDir() || filepath.Ext(file) != ".go" || strings.HasSuffix(file, "_test.go") { - return nil - } - parsed, err := parser.ParseFile(fset, file, nil, parser.ImportsOnly) - if err != nil { - return err - } - for _, imported := range parsed.Imports { - path, err := strconv.Unquote(imported.Path.Value) - if err != nil { - return err - } - if strings.HasPrefix(path, modulePath+"/") || path == modulePath { - t.Fatalf("production import %q is from this repo, want stdlib only", path) - } - if _, ok := stdlib[path]; !ok { - t.Fatalf("production import %q is not in the standard library", path) - } - } - return nil - }) - if err != nil { - t.Fatalf("WalkDir(%s): %v", dir, err) - } -} - -func repoRootAndModule(t *testing.T, dir string) (string, string) { - t.Helper() - cmd := exec.Command("go", "list", "-m", "-f", "{{.Dir}} {{.Path}}") - cmd.Dir = dir - output, err := cmd.Output() - if err != nil { - t.Fatalf("go list module: %v", err) - } - parts := strings.Fields(string(output)) - if len(parts) != 2 { - t.Fatalf("go list module output = %q, want dir and path", output) - } - return parts[0], parts[1] -} - -func stdlibImports(t *testing.T, repoRoot string) map[string]struct{} { - t.Helper() - cmd := exec.Command("go", "list", "std") - cmd.Dir = repoRoot - output, err := cmd.Output() - if err != nil { - t.Fatalf("go list std: %v", err) - } - imports := make(map[string]struct{}) - for _, path := range bytes.Fields(output) { - imports[string(path)] = struct{}{} - } - return imports -} diff --git a/internal/marker/imports_test.go b/internal/marker/imports_test.go deleted file mode 100644 index 9155856c..00000000 --- a/internal/marker/imports_test.go +++ /dev/null @@ -1,83 +0,0 @@ -package marker - -import ( - "bytes" - "go/parser" - "go/token" - "io/fs" - "os/exec" - "path/filepath" - "runtime" - "strconv" - "strings" - "testing" -) - -func TestProductionImportsStayStdlibOnly(t *testing.T) { - _, testFile, _, ok := runtime.Caller(0) - if !ok { - t.Fatal("runtime.Caller failed") - } - dir := filepath.Dir(testFile) - repoRoot, modulePath := repoRootAndModule(t, dir) - stdlib := stdlibImports(t, repoRoot) - fset := token.NewFileSet() - err := filepath.WalkDir(dir, func(file string, entry fs.DirEntry, walkErr error) error { - if walkErr != nil { - return walkErr - } - if entry.IsDir() || filepath.Ext(file) != ".go" || strings.HasSuffix(file, "_test.go") { - return nil - } - parsed, err := parser.ParseFile(fset, file, nil, parser.ImportsOnly) - if err != nil { - return err - } - for _, imported := range parsed.Imports { - path, err := strconv.Unquote(imported.Path.Value) - if err != nil { - return err - } - if strings.HasPrefix(path, modulePath+"/") || path == modulePath { - t.Fatalf("production import %q is from this repo, want stdlib only", path) - } - if _, ok := stdlib[path]; !ok { - t.Fatalf("production import %q is not in the standard library", path) - } - } - return nil - }) - if err != nil { - t.Fatalf("WalkDir(%s): %v", dir, err) - } -} - -func repoRootAndModule(t *testing.T, dir string) (string, string) { - t.Helper() - cmd := exec.Command("go", "list", "-m", "-f", "{{.Dir}} {{.Path}}") - cmd.Dir = dir - output, err := cmd.Output() - if err != nil { - t.Fatalf("go list module: %v", err) - } - parts := strings.Fields(string(output)) - if len(parts) != 2 { - t.Fatalf("go list module output = %q, want dir and path", output) - } - return parts[0], parts[1] -} - -func stdlibImports(t *testing.T, repoRoot string) map[string]struct{} { - t.Helper() - cmd := exec.Command("go", "list", "std") - cmd.Dir = repoRoot - output, err := cmd.Output() - if err != nil { - t.Fatalf("go list std: %v", err) - } - imports := make(map[string]struct{}) - for _, path := range bytes.Fields(output) { - imports[string(path)] = struct{}{} - } - return imports -} From 91087084470a407f005699d6dba77f993525fb7b Mon Sep 17 00:00:00 2001 From: Rian Stockbower Date: Tue, 15 Sep 2026 10:59:31 -0400 Subject: [PATCH 3/6] refactor(init): share linear editor components --- go.mod | 2 +- internal/cmd/initcmd/init_linear_editor.go | 419 ++++++++++------ internal/cmd/initcmd/init_profile_v2.go | 552 +++------------------ internal/cmd/initcmd/initcmd_test.go | 296 ++++------- 4 files changed, 451 insertions(+), 818 deletions(-) diff --git a/go.mod b/go.mod index 2aa1b181..9238ab1a 100644 --- a/go.mod +++ b/go.mod @@ -9,6 +9,7 @@ require ( github.com/charmbracelet/bubbletea v1.3.6 github.com/charmbracelet/huh v1.0.0 github.com/charmbracelet/lipgloss v1.1.0 + github.com/charmbracelet/x/ansi v0.9.3 github.com/creack/pty v1.1.24 github.com/gobwas/glob v0.2.3 github.com/google/uuid v1.6.0 @@ -29,7 +30,6 @@ require ( github.com/byteness/keyring v1.11.0 // indirect github.com/catppuccin/go v0.3.0 // indirect github.com/charmbracelet/colorprofile v0.2.3-0.20250311203215-f60798e515dc // indirect - github.com/charmbracelet/x/ansi v0.9.3 // indirect github.com/charmbracelet/x/cellbuf v0.0.13 // indirect github.com/charmbracelet/x/exp/strings v0.0.0-20240722160745-212f7b056ed0 // indirect github.com/charmbracelet/x/term v0.2.1 // indirect diff --git a/internal/cmd/initcmd/init_linear_editor.go b/internal/cmd/initcmd/init_linear_editor.go index 8d0d5519..6d688833 100644 --- a/internal/cmd/initcmd/init_linear_editor.go +++ b/internal/cmd/initcmd/init_linear_editor.go @@ -4,12 +4,15 @@ import ( "fmt" "io" "strings" - "unicode" + "github.com/charmbracelet/bubbles/cursor" + "github.com/charmbracelet/bubbles/textarea" + "github.com/charmbracelet/bubbles/textinput" "github.com/charmbracelet/bubbles/viewport" tea "github.com/charmbracelet/bubbletea" "github.com/charmbracelet/huh" "github.com/charmbracelet/lipgloss" + "github.com/charmbracelet/x/ansi" ) type initLinearFieldKind string @@ -60,6 +63,7 @@ type initLinearEditor struct { type initLinearEditorModel struct { viewport viewport.Model document initLinearDocument + fieldEditors []initLinearFieldEditor layout initLinearLayout focused int quitting bool @@ -71,6 +75,14 @@ type initLinearEditorModel struct { textareaHelp string } +// initLinearFieldEditor keeps the framework editor state alongside the +// document state. The outer editor owns form layout and masking; Bubbles owns +// text editing, cursor movement, and paste messages. +type initLinearFieldEditor struct { + input textinput.Model + textarea textarea.Model +} + type initLinearDocument []initLinearField type initLinearField struct { @@ -179,6 +191,107 @@ func initLinearSetSelectionOptions(model *initLinearEditorModel, fieldID initLin model.document[index].Options = linearOptions } +func newInitLinearFieldEditors(document initLinearDocument) []initLinearFieldEditor { + editors := make([]initLinearFieldEditor, len(document)) + for index, field := range document { + if !field.Editable { + continue + } + switch field.Kind { + case initLinearFieldInput: + input := textinput.New() + input.Prompt = "" + input.CharLimit = 0 + input.Width = 0 + input.KeyMap.Paste.SetEnabled(false) + input.Cursor.SetMode(cursor.CursorStatic) + if field.Secret { + input.EchoMode = textinput.EchoPassword + } + input.SetValue(field.Value) + input.SetCursor(field.Cursor) + input.Blur() + editors[index].input = input + case initLinearFieldTextarea: + textareaModel := textarea.New() + textareaModel.Prompt = "" + textareaModel.ShowLineNumbers = false + textareaModel.MaxHeight = 0 + textareaModel.MaxWidth = 0 + textareaModel.KeyMap.Paste.SetEnabled(false) + textareaModel.Cursor.SetMode(cursor.CursorStatic) + textareaModel.SetWidth(initLinearTextareaWidth(field.Value)) + textareaModel.SetHeight(max(len(strings.Split(field.Value, "\n")), 1)) + textareaModel.SetValue(field.Value) + initLinearSetTextareaCursor(&textareaModel, field.Cursor) + textareaModel.Blur() + editors[index].textarea = textareaModel + case initLinearFieldSection, initLinearFieldSelect: + continue + } + } + return editors +} + +func initLinearTextareaWidth(value string) int { + width := 1 + for _, line := range strings.Split(value, "\n") { + width = max(width, ansi.StringWidth(line)+1) + } + return width +} + +func initLinearSetTextareaCursor(model *textarea.Model, cursor int) { + if model == nil { + return + } + lines := strings.Split(model.Value(), "\n") + cursor = min(max(cursor, 0), len([]rune(model.Value()))) + row := 0 + column := cursor + for row < len(lines)-1 && column > len([]rune(lines[row])) { + column -= len([]rune(lines[row])) + 1 + row++ + } + for model.Line() > 0 { + model.CursorUp() + } + model.CursorStart() + for index := 0; index < row; index++ { + model.CursorDown() + } + model.SetCursor(column) +} + +func (m *initLinearEditorModel) syncFieldEditor(index int) { + if index < 0 || index >= len(m.document) || index >= len(m.fieldEditors) { + return + } + field := m.document[index] + switch field.Kind { + case initLinearFieldInput: + input := &m.fieldEditors[index].input + if input.Value() != field.Value { + input.SetValue(field.Value) + } + if input.Position() != field.Cursor { + input.SetCursor(field.Cursor) + } + case initLinearFieldTextarea: + textareaModel := &m.fieldEditors[index].textarea + if textareaModel.Value() != field.Value { + textareaModel.SetWidth(initLinearTextareaWidth(field.Value)) + textareaModel.SetHeight(max(len(strings.Split(field.Value, "\n")), 1)) + textareaModel.SetValue(field.Value) + initLinearSetTextareaCursor(textareaModel, field.Cursor) + } else if initLinearTextareaCursor(*textareaModel) != field.Cursor { + initLinearSetTextareaCursor(textareaModel, field.Cursor) + } + case initLinearFieldSection, initLinearFieldSelect: + return + } +} + func newInitLinearEditorModel(editor initLinearEditor, width, height int) initLinearEditorModel { if width <= 0 { width = 100 @@ -197,6 +310,7 @@ func newInitLinearEditorModel(editor initLinearEditor, width, height int) initLi model := initLinearEditorModel{ viewport: viewport.New(width, max(height-2, 1)), document: editor.Document, + fieldEditors: newInitLinearFieldEditors(editor.Document), focused: editor.Document.firstFocusableField(), onEnter: editor.OnEnter, onFieldChange: editor.OnFieldChange, @@ -207,6 +321,7 @@ func newInitLinearEditorModel(editor initLinearEditor, width, height int) initLi model.validateAll() model.relayout() model.ensureFocusedVisible() + model.focusField(model.focused) return model } @@ -223,10 +338,10 @@ func (m initLinearEditorModel) Update(msg tea.Msg) (tea.Model, tea.Cmd) { m.ensureFocusedVisible() return m, nil case tea.KeyMsg: - if m.handleFocusedInputKey(msg) { + if handled, cmd := m.handleFocusedInput(msg); handled { m.relayout() m.ensureFocusedVisible() - return m, nil + return m, cmd } if handled, cmd := m.handleFocusedSelectKey(msg); handled { m.relayout() @@ -253,12 +368,12 @@ func (m initLinearEditorModel) Update(msg tea.Msg) (tea.Model, tea.Cmd) { m.focused = m.document.previousFocusableField(m.focused) m.relayout() m.ensureFocusedVisible() - return m, nil + return m, m.focusField(m.focused) case "tab", "enter": m.focused = m.document.nextFocusableField(m.focused) m.relayout() m.ensureFocusedVisible() - return m, nil + return m, m.focusField(m.focused) case "pgup", "b": m.setYOffset(m.viewport.YOffset - max(m.viewport.Height/2, 1)) return m, nil @@ -273,14 +388,19 @@ func (m initLinearEditorModel) Update(msg tea.Msg) (tea.Model, tea.Cmd) { m.focused = m.document.firstFocusableField() m.relayout() m.ensureFocusedVisible() - return m, nil + return m, m.focusField(m.focused) case "end", "G": m.focused = m.document.lastFocusableField() m.relayout() m.ensureFocusedVisible() - return m, nil + return m, m.focusField(m.focused) } } + if handled, cmd := m.handleFocusedInput(msg); handled { + m.relayout() + m.ensureFocusedVisible() + return m, cmd + } return m, nil } @@ -483,59 +603,148 @@ func (d initLinearDocument) selectedValue(id initLinearFieldID) string { return "" } -func (m *initLinearEditorModel) handleFocusedInputKey(msg tea.KeyMsg) bool { +func (m *initLinearEditorModel) handleFocusedInput(msg tea.Msg) (bool, tea.Cmd) { if m.focused < 0 || m.focused >= len(m.document) { - return false + return false, nil } field := &m.document[m.focused] if (field.Kind != initLinearFieldInput && field.Kind != initLinearFieldTextarea) || !field.Editable { - return false + return false, nil } - if field.Kind == initLinearFieldTextarea && (msg.String() == "ctrl+j" || msg.String() == "alt+enter") { - field.Value = initLinearInsertRunes(field.Value, field.Cursor, []rune{'\n'}) - field.Cursor++ - m.afterFieldChange(m.focused) - return true + if m.focused >= len(m.fieldEditors) { + m.fieldEditors = newInitLinearFieldEditors(m.document) } - key := tea.Key(msg) - //nolint:exhaustive // The text input consumes only editing keys; all other keys fall through to form navigation. - switch key.Type { - case tea.KeyRunes: - if msg.Alt { - return false - } - field.Value = initLinearInsertRunes(field.Value, field.Cursor, key.Runes) - field.Cursor += len(key.Runes) - case tea.KeySpace: - if msg.Alt { - return false - } - field.Value = initLinearInsertRunes(field.Value, field.Cursor, []rune{' '}) - field.Cursor++ - case tea.KeyBackspace, tea.KeyCtrlH: - field.Value, field.Cursor = initLinearDeleteBeforeCursor(field.Value, field.Cursor) - case tea.KeyDelete, tea.KeyCtrlD: - field.Value = initLinearDeleteAtCursor(field.Value, field.Cursor) - case tea.KeyLeft, tea.KeyCtrlB: - field.Cursor = max(field.Cursor-1, 0) - case tea.KeyRight, tea.KeyCtrlF: - field.Cursor = min(field.Cursor+1, len([]rune(field.Value))) - case tea.KeyCtrlA: - field.Cursor = 0 - case tea.KeyCtrlE: - field.Cursor = len([]rune(field.Value)) - case tea.KeyCtrlU: - field.Value = "" - field.Cursor = 0 - case tea.KeyCtrlW: - field.Value, field.Cursor = initLinearDeleteWordBeforeCursor(field.Value, field.Cursor) - case tea.KeyCtrlK: - field.Value = initLinearDeleteAfterCursor(field.Value, field.Cursor) - default: - return false + focusCmd := m.focusField(m.focused) + index := m.focused + previousValue := field.Value + previousCursor := field.Cursor + + if keyMsg, ok := msg.(tea.KeyMsg); ok { + //nolint:exhaustive // These form-level keys must bypass text editing; all other keys belong to the focused component. + switch keyMsg.Type { + case tea.KeyEnter, tea.KeyTab, tea.KeyShiftTab, tea.KeyCtrlC, tea.KeyEsc, tea.KeyPgUp, tea.KeyPgDown, tea.KeyUp, tea.KeyDown, tea.KeyHome, tea.KeyEnd: + return false, nil + } + switch keyMsg.String() { + case "ctrl+u": + m.setFocusedInputValue("") + m.afterFieldChange(index) + return true, focusCmd + case "ctrl+j", "alt+enter": + if field.Kind != initLinearFieldTextarea { + return false, nil + } + m.insertFocusedTextareaRune('\n') + m.afterFieldChange(index) + return true, focusCmd + case " ": + if keyMsg.Alt { + return false, nil + } + m.insertFocusedInputRune(' ') + m.afterFieldChange(index) + return true, focusCmd + } } - m.afterFieldChange(m.focused) - return true + + var cmd tea.Cmd + switch field.Kind { + case initLinearFieldInput: + var next textinput.Model + next, cmd = m.fieldEditors[index].input.Update(msg) + m.fieldEditors[index].input = next + field.Value = next.Value() + field.Cursor = next.Position() + case initLinearFieldTextarea: + var next textarea.Model + next, cmd = m.fieldEditors[index].textarea.Update(msg) + m.fieldEditors[index].textarea = next + field.Value = next.Value() + field.Cursor = initLinearTextareaCursor(next) + case initLinearFieldSection, initLinearFieldSelect: + return false, nil + } + changed := field.Value != previousValue || field.Cursor != previousCursor + if changed { + m.afterFieldChange(index) + } + return changed || cmd != nil, tea.Batch(focusCmd, cmd) +} + +func (m *initLinearEditorModel) setFocusedInputValue(value string) { + if m.focused < 0 || m.focused >= len(m.document) || m.focused >= len(m.fieldEditors) { + return + } + field := &m.document[m.focused] + field.Value = value + field.Cursor = 0 + m.syncFieldEditor(m.focused) +} + +func (m *initLinearEditorModel) insertFocusedInputRune(value rune) { + if m.focused < 0 || m.focused >= len(m.document) || m.focused >= len(m.fieldEditors) { + return + } + index := m.focused + switch m.document[index].Kind { + case initLinearFieldInput: + next, _ := m.fieldEditors[index].input.Update(tea.KeyMsg{Type: tea.KeyRunes, Runes: []rune{value}}) + m.fieldEditors[index].input = next + m.document[index].Value = next.Value() + m.document[index].Cursor = next.Position() + case initLinearFieldTextarea: + m.fieldEditors[index].textarea.InsertRune(value) + m.document[index].Value = m.fieldEditors[index].textarea.Value() + m.document[index].Cursor = initLinearTextareaCursor(m.fieldEditors[index].textarea) + case initLinearFieldSection, initLinearFieldSelect: + return + } +} + +func (m *initLinearEditorModel) insertFocusedTextareaRune(value rune) { + if m.focused < 0 || m.focused >= len(m.document) || m.document[m.focused].Kind != initLinearFieldTextarea { + return + } + m.insertFocusedInputRune(value) +} + +func initLinearTextareaCursor(model textarea.Model) int { + lines := strings.Split(model.Value(), "\n") + row := min(max(model.Line(), 0), len(lines)-1) + column := model.LineInfo().StartColumn + model.LineInfo().ColumnOffset + cursor := 0 + for index := 0; index < row; index++ { + cursor += len([]rune(lines[index])) + 1 + } + return cursor + min(max(column, 0), len([]rune(lines[row]))) +} + +func (m *initLinearEditorModel) focusField(index int) tea.Cmd { + if len(m.fieldEditors) == 0 { + return nil + } + var cmds []tea.Cmd + for editorIndex := range m.fieldEditors { + if editorIndex == index && editorIndex < len(m.document) && m.document[editorIndex].Editable { + m.syncFieldEditor(editorIndex) + switch m.document[editorIndex].Kind { + case initLinearFieldInput: + if !m.fieldEditors[editorIndex].input.Focused() { + cmds = append(cmds, m.fieldEditors[editorIndex].input.Focus()) + } + case initLinearFieldTextarea: + if !m.fieldEditors[editorIndex].textarea.Focused() { + cmds = append(cmds, m.fieldEditors[editorIndex].textarea.Focus()) + } + case initLinearFieldSection, initLinearFieldSelect: + continue + } + continue + } + m.fieldEditors[editorIndex].input.Blur() + m.fieldEditors[editorIndex].textarea.Blur() + } + return tea.Batch(cmds...) } func (m *initLinearEditorModel) handleFocusedSelectKey(msg tea.KeyMsg) (bool, tea.Cmd) { @@ -641,6 +850,7 @@ func (m *initLinearEditorModel) setFieldValue(id initLinearFieldID, value string } m.document[index].Value = value m.document[index].Cursor = len([]rune(value)) + m.syncFieldEditor(index) } func (m *initLinearEditorModel) setFieldDescription(id initLinearFieldID, description string) { @@ -679,6 +889,7 @@ func (m *initLinearEditorModel) setFieldHidden(id initLinearFieldID, hidden bool func (m *initLinearEditorModel) relayout() { m.layout = initLinearLayoutDocument(m.document, m.viewport.Width, m.focused) + m.viewport.SetContent(m.layout.Content) m.setYOffset(m.viewport.YOffset) } @@ -829,47 +1040,21 @@ func initLinearAppendWrappedWithPrefixMarked(lines *[]string, selectedLines map[ } func initLinearAppendWrappedLineWithPrefix(lines *[]string, prefix string, text string, width int) { - remaining := trimInitLinearSpaceRunes([]rune(text)) - if len(remaining) == 0 { + text = strings.TrimSpace(text) + if text == "" { *lines = append(*lines, prefix) return } - linePrefix := prefix - for len(remaining) > 0 { - available := max(width-len([]rune(linePrefix)), 1) - cut := len(remaining) - if cut > available { - cut = available - for index := cut - 1; index > 0; index-- { - if remaining[index] == ' ' { - cut = index - break - } - } - } - if cut <= 0 { - cut = min(available, len(remaining)) - } - segmentRunes := trimInitLinearSpaceRunes(remaining[:cut]) - if len(segmentRunes) == 0 { - segmentRunes = remaining[:cut] + available := max(width-ansi.StringWidth(prefix), 1) + wrapped := ansi.Wrap(text, available, "") + continuationPrefix := strings.Repeat(" ", ansi.StringWidth(prefix)) + for index, line := range strings.Split(wrapped, "\n") { + if index == 0 { + *lines = append(*lines, prefix+line) + continue } - *lines = append(*lines, linePrefix+string(segmentRunes)) - remaining = trimInitLinearSpaceRunes(remaining[cut:]) - linePrefix = strings.Repeat(" ", len([]rune(prefix))) - } -} - -func trimInitLinearSpaceRunes(value []rune) []rune { - start := 0 - for start < len(value) && unicode.IsSpace(value[start]) { - start++ - } - end := len(value) - for end > start && unicode.IsSpace(value[end-1]) { - end-- + *lines = append(*lines, continuationPrefix+line) } - return value[start:end] } func markInitLinearSelectedLines(selectedLines map[int]bool, selected bool, start int, end int) { @@ -947,62 +1132,6 @@ func (m initLinearEditorModel) looksLikeHeading(line string) bool { return false } -func initLinearInsertRunes(value string, cursor int, runes []rune) string { - existing := []rune(value) - cursor = min(max(cursor, 0), len(existing)) - next := make([]rune, 0, len(existing)+len(runes)) - next = append(next, existing[:cursor]...) - next = append(next, runes...) - next = append(next, existing[cursor:]...) - return string(next) -} - -func initLinearDeleteBeforeCursor(value string, cursor int) (string, int) { - existing := []rune(value) - cursor = min(max(cursor, 0), len(existing)) - if cursor == 0 { - return value, cursor - } - next := make([]rune, 0, len(existing)-1) - next = append(next, existing[:cursor-1]...) - next = append(next, existing[cursor:]...) - return string(next), cursor - 1 -} - -func initLinearDeleteWordBeforeCursor(value string, cursor int) (string, int) { - existing := []rune(value) - cursor = min(max(cursor, 0), len(existing)) - index := cursor - for index > 0 && existing[index-1] == ' ' { - index-- - } - for index > 0 && existing[index-1] != ' ' { - index-- - } - next := make([]rune, 0, len(existing)-(cursor-index)) - next = append(next, existing[:index]...) - next = append(next, existing[cursor:]...) - return string(next), index -} - -func initLinearDeleteAtCursor(value string, cursor int) string { - existing := []rune(value) - cursor = min(max(cursor, 0), len(existing)) - if cursor >= len(existing) { - return value - } - next := make([]rune, 0, len(existing)-1) - next = append(next, existing[:cursor]...) - next = append(next, existing[cursor+1:]...) - return string(next) -} - -func initLinearDeleteAfterCursor(value string, cursor int) string { - existing := []rune(value) - cursor = min(max(cursor, 0), len(existing)) - return string(existing[:cursor]) -} - func initLinearValueWithCursor(value string, cursor int) string { existing := []rune(value) cursor = min(max(cursor, 0), len(existing)) diff --git a/internal/cmd/initcmd/init_profile_v2.go b/internal/cmd/initcmd/init_profile_v2.go index 1110b69a..c2b77e76 100644 --- a/internal/cmd/initcmd/init_profile_v2.go +++ b/internal/cmd/initcmd/init_profile_v2.go @@ -7,7 +7,6 @@ import ( "maps" "strings" - "github.com/charmbracelet/bubbles/viewport" tea "github.com/charmbracelet/bubbletea" "github.com/charmbracelet/huh" @@ -24,8 +23,6 @@ type bubbleTeaInitProfileV2Prompter struct { type initProfileV2EditorRunner func(initProfileV2Editor) (initProfileV2EditorResult, error) -var initProfileV2Theme = initLinearTheme - func (p bubbleTeaInitProfileV2Prompter) Run(ctx initPromptContext) (initDraft, error) { if initProfileV2ShouldCreateDirectly(ctx) { draft, staged, err := p.runProfileEditor(ctx, initCreateProfileSentinel) @@ -172,47 +169,45 @@ func initProfileV2InventoryRows(ctx initPromptContext) []initInventoryRow { } type initProfileV2ReadOnlyModel struct { - viewport viewport.Model - draft initDraft - gitScopes map[string]initGitScopeDraft - reviewerEntities map[string]initReviewerEntityDraft - llmRuntimes map[string]initLLMRuntimeDraft - credentialStoreOptions []huh.Option[string] - selectedGitScope string - initialGitStorageLabel string - gitStorageLabelUsesDefault bool - document initProfileV2Document - layout initProfileV2Layout - focused int - quitting bool - requestLLMRuntimeBootstrap bool - result initProfileV2EditorResult + initLinearEditorModel + *initProfileV2ReadOnlyState +} + +type initProfileV2ReadOnlyState struct { + draft initDraft + gitScopes map[string]initGitScopeDraft + reviewerEntities map[string]initReviewerEntityDraft + llmRuntimes map[string]initLLMRuntimeDraft + credentialStoreOptions []huh.Option[string] + selectedGitScope string + result initProfileV2EditorResult } func newInitProfileV2ReadOnlyModel(editor initProfileV2Editor, width, height int) initProfileV2ReadOnlyModel { - if width <= 0 { - width = 100 - } - if height <= 0 { - height = 28 - } selectedGitScope := editor.SelectedGitScope if selectedGitScope == "" { selectedGitScope = firstInitGitScopeName(editor.GitScopes) } - vp := viewport.New(width, max(height-2, 1)) + state := &initProfileV2ReadOnlyState{ + draft: editor.Draft, + gitScopes: maps.Clone(editor.GitScopes), + reviewerEntities: maps.Clone(editor.ReviewerEntities), + llmRuntimes: maps.Clone(editor.LLMRuntimes), + credentialStoreOptions: append([]huh.Option[string](nil), editor.CredentialStoreOptions...), + selectedGitScope: selectedGitScope, + } + linearEditor := initLinearEditor{ + Document: editor.Document, + OnEnter: func(linear *initLinearEditorModel) (bool, tea.Cmd) { + return initProfileV2HandleEnter(linear, state) + }, + OnFieldChange: func(linear *initLinearEditorModel, index int) { + initProfileV2AfterFieldChange(linear, state, index) + }, + } model := initProfileV2ReadOnlyModel{ - viewport: vp, - draft: editor.Draft, - gitScopes: maps.Clone(editor.GitScopes), - reviewerEntities: maps.Clone(editor.ReviewerEntities), - llmRuntimes: maps.Clone(editor.LLMRuntimes), - credentialStoreOptions: append([]huh.Option[string](nil), editor.CredentialStoreOptions...), - selectedGitScope: selectedGitScope, - initialGitStorageLabel: editor.InitialGitStorageLabel, - gitStorageLabelUsesDefault: editor.GitStorageLabelUsesDefault, - document: editor.Document, - focused: editor.Document.firstFocusableField(), + initLinearEditorModel: newInitLinearEditorModel(linearEditor, width, height), + initProfileV2ReadOnlyState: state, } model.syncGitScopeFields() model.syncReviewerGitHubAppInstallationFields(false) @@ -224,88 +219,45 @@ func newInitProfileV2ReadOnlyModel(editor initProfileV2Editor, width, height int return model } +func initProfileV2AfterFieldChange(linear *initLinearEditorModel, state *initProfileV2ReadOnlyState, index int) { + model := initProfileV2ReadOnlyModel{ + initLinearEditorModel: *linear, + initProfileV2ReadOnlyState: state, + } + model.afterFieldChange(index) + *linear = model.initLinearEditorModel +} + +func initProfileV2HandleEnter(linear *initLinearEditorModel, state *initProfileV2ReadOnlyState) (bool, tea.Cmd) { + model := initProfileV2ReadOnlyModel{ + initLinearEditorModel: *linear, + initProfileV2ReadOnlyState: state, + } + if model.handleLLMRuntimeBootstrapKey(tea.KeyMsg{Type: tea.KeyEnter}) { + state.result = initProfileV2EditorResult{BootstrapLLMRuntime: true} + return true, tea.Quit + } + next, handled, cmd := model.handleProfileActionKey(tea.KeyMsg{Type: tea.KeyEnter}) + *linear = next.initLinearEditorModel + return handled, cmd +} + func (m initProfileV2ReadOnlyModel) Init() tea.Cmd { - return nil + return m.initLinearEditorModel.Init() } func (m initProfileV2ReadOnlyModel) Update(msg tea.Msg) (tea.Model, tea.Cmd) { - switch msg := msg.(type) { - case tea.WindowSizeMsg: - m.viewport.Width = max(msg.Width, 1) - m.viewport.Height = max(msg.Height-2, 1) - m.relayout() - m.ensureFocusedVisible() - return m, nil - case tea.KeyMsg: - if m.handleFocusedInputKey(msg) { - m.relayout() - m.ensureFocusedVisible() - return m, nil - } - if m.handleFocusedSelectKey(msg) { - m.relayout() - m.ensureFocusedVisible() - return m, nil - } - if m.handleLLMRuntimeBootstrapKey(msg) { - m.requestLLMRuntimeBootstrap = true - m.result = initProfileV2EditorResult{BootstrapLLMRuntime: true} - m.quitting = true - return m, tea.Quit - } - if next, handled, cmd := m.handleProfileActionKey(msg); handled { - return next, cmd - } - switch msg.String() { - case "ctrl+c", "q", "esc": - m.quitting = true - return m, tea.Quit - case "shift+tab": - m.focused = m.document.previousFocusableField(m.focused) - m.relayout() - m.ensureFocusedVisible() - return m, nil - case "tab", "enter": - m.focused = m.document.nextFocusableField(m.focused) - m.relayout() - m.ensureFocusedVisible() - return m, nil - case "pgup", "b": - m.viewport.HalfPageUp() - return m, nil - case "pgdown", "f", " ": - m.viewport.HalfPageDown() - return m, nil - case "up", "down", "j", "k": - // Up/Down only changes the focused select. Inputs should not leak - // these keys to the viewport and scroll the whole form. - return m, nil - case "home", "g": - m.focused = m.document.firstFocusableField() - m.relayout() - m.ensureFocusedVisible() - return m, nil - case "end", "G": - m.focused = m.document.lastFocusableField() - m.relayout() - m.ensureFocusedVisible() - return m, nil - } + updated, cmd := m.initLinearEditorModel.Update(msg) + linear, ok := updated.(initLinearEditorModel) + if !ok { + return m, cmd } - var cmd tea.Cmd - m.viewport, cmd = m.viewport.Update(msg) + m.initLinearEditorModel = linear return m, cmd } func (m initProfileV2ReadOnlyModel) View() string { - if m.quitting { - return "" - } - help := "tab/enter next - shift+tab previous - up/down change select - esc back" - if m.focused >= 0 && m.focused < len(m.document) && m.document[m.focused].Kind == initProfileV2FieldTextarea { - help = "tab/enter next - shift+tab previous - ctrl+j newline - esc back" - } - return m.styleVisibleViewport() + "\n\n" + initProfileV2Theme.help.Render(help) + return m.initLinearEditorModel.View() } func initProfileV2ReadOnlyContent(ctx initPromptContext, selection string) (string, error) { @@ -313,7 +265,7 @@ func initProfileV2ReadOnlyContent(ctx initPromptContext, selection string) (stri if err != nil { return "", err } - return initProfileV2LayoutDocument(document, 100, document.firstFocusableField()).Content, nil + return initLinearLayoutDocument(document, 100, document.firstFocusableField()).Content, nil } func initProfileV2ReadOnlyDocument(ctx initPromptContext, selection string) (initProfileV2Document, error) { @@ -430,16 +382,8 @@ func initProfileV2Selection(ctx initPromptContext, selection string) (string, *c return selection, &profileCopy, ctx.RequestedProfileName } -type initProfileV2FieldKind = initLinearFieldKind type initProfileV2FieldID = initLinearFieldID -const ( - initProfileV2FieldSection initProfileV2FieldKind = initLinearFieldSection - initProfileV2FieldInput initProfileV2FieldKind = initLinearFieldInput - initProfileV2FieldSelect initProfileV2FieldKind = initLinearFieldSelect - initProfileV2FieldTextarea initProfileV2FieldKind = initLinearFieldTextarea -) - const ( initProfileV2FieldProfileName initProfileV2FieldID = "profile_name" initProfileV2FieldRoutes initProfileV2FieldID = "routes" @@ -470,22 +414,17 @@ func initProfileV2FieldModelMap(tier config.ModelTier) initProfileV2FieldID { } type initProfileV2Editor struct { - Draft initDraft - GitScopes map[string]initGitScopeDraft - ReviewerEntities map[string]initReviewerEntityDraft - LLMRuntimes map[string]initLLMRuntimeDraft - CredentialStoreOptions []huh.Option[string] - SelectedGitScope string - InitialGitStorageLabel string - GitStorageLabelUsesDefault bool - Document initProfileV2Document + Draft initDraft + GitScopes map[string]initGitScopeDraft + ReviewerEntities map[string]initReviewerEntityDraft + LLMRuntimes map[string]initLLMRuntimeDraft + CredentialStoreOptions []huh.Option[string] + SelectedGitScope string + Document initProfileV2Document } type initProfileV2Document = initLinearDocument -type initProfileV2Field = initLinearField type initProfileV2FieldOptions = initLinearFieldOptions -type initProfileV2Layout = initLinearLayout -type initProfileV2FieldBounds = initLinearFieldBounds func initProfileV2AppendRouteSection(document *initProfileV2Document, routeText string) { document.addSection("Automatic profile selection", "Routes tell cr when to use this profile automatically. Routes may be shared by multiple profiles; ambiguous matches require explicit --profile.") @@ -634,81 +573,6 @@ func initProfileV2AddSelect[T comparable](document *initProfileV2Document, title initLinearAddSelect(document, title, description, options, selected) } -func (m *initProfileV2ReadOnlyModel) handleFocusedInputKey(msg tea.KeyMsg) bool { - if m.focused < 0 || m.focused >= len(m.document) { - return false - } - field := &m.document[m.focused] - if (field.Kind != initProfileV2FieldInput && field.Kind != initProfileV2FieldTextarea) || !field.Editable { - return false - } - if field.Kind == initProfileV2FieldTextarea && (msg.String() == "ctrl+j" || msg.String() == "alt+enter") { - field.Value = initProfileV2InsertRunes(field.Value, field.Cursor, []rune{'\n'}) - field.Cursor++ - m.afterFieldChange(m.focused) - return true - } - key := tea.Key(msg) - //nolint:exhaustive // The text input consumes only editing keys; all other keys fall through to form navigation. - switch key.Type { - case tea.KeyRunes: - if msg.Alt { - return false - } - field.Value = initProfileV2InsertRunes(field.Value, field.Cursor, key.Runes) - field.Cursor += len(key.Runes) - case tea.KeySpace: - if msg.Alt { - return false - } - field.Value = initProfileV2InsertRunes(field.Value, field.Cursor, []rune{' '}) - field.Cursor++ - case tea.KeyBackspace, tea.KeyCtrlH: - field.Value, field.Cursor = initProfileV2DeleteBeforeCursor(field.Value, field.Cursor) - case tea.KeyDelete, tea.KeyCtrlD: - field.Value = initProfileV2DeleteAtCursor(field.Value, field.Cursor) - case tea.KeyLeft, tea.KeyCtrlB: - field.Cursor = max(field.Cursor-1, 0) - case tea.KeyRight, tea.KeyCtrlF: - field.Cursor = min(field.Cursor+1, len([]rune(field.Value))) - case tea.KeyCtrlA: - field.Cursor = 0 - case tea.KeyCtrlE: - field.Cursor = len([]rune(field.Value)) - case tea.KeyCtrlU: - field.Value = "" - field.Cursor = 0 - case tea.KeyCtrlW: - field.Value, field.Cursor = initLinearDeleteWordBeforeCursor(field.Value, field.Cursor) - case tea.KeyCtrlK: - field.Value = initProfileV2DeleteAfterCursor(field.Value, field.Cursor) - default: - return false - } - m.afterFieldChange(m.focused) - return true -} - -func (m *initProfileV2ReadOnlyModel) handleFocusedSelectKey(msg tea.KeyMsg) bool { - if m.focused < 0 || m.focused >= len(m.document) { - return false - } - field := &m.document[m.focused] - if field.Kind != initProfileV2FieldSelect || !field.Editable || len(field.Options) == 0 { - return false - } - switch msg.String() { - case "up", "k": - initProfileV2MoveSelection(field, -1) - case "down", "j", " ": - initProfileV2MoveSelection(field, 1) - default: - return false - } - m.afterFieldChange(m.focused) - return true -} - func (m initProfileV2ReadOnlyModel) handleLLMRuntimeBootstrapKey(msg tea.KeyMsg) bool { if msg.String() != "enter" || m.focused < 0 || m.focused >= len(m.document) { return false @@ -747,26 +611,6 @@ func (m initProfileV2ReadOnlyModel) handleProfileActionKey(msg tea.KeyMsg) (init } } -func initProfileV2MoveSelection(field *initProfileV2Field, offset int) { - if len(field.Options) == 0 { - return - } - selectedIndex := 0 - for index, option := range field.Options { - if option.Selected { - selectedIndex = index - break - } - } - next := (selectedIndex + offset) % len(field.Options) - if next < 0 { - next += len(field.Options) - } - for index := range field.Options { - field.Options[index].Selected = index == next - } -} - func (m *initProfileV2ReadOnlyModel) afterFieldChange(index int) { m.validateField(index) if index < 0 || index >= len(m.document) { @@ -802,26 +646,6 @@ func (m *initProfileV2ReadOnlyModel) afterFieldChange(index int) { } } -func (m *initProfileV2ReadOnlyModel) validateAll() { - for index := range m.document { - m.validateField(index) - } -} - -func (m *initProfileV2ReadOnlyModel) validateField(index int) { - if index < 0 || index >= len(m.document) { - return - } - field := &m.document[index] - field.Error = "" - if field.Validate == nil { - return - } - if err := field.Validate(field.Value); err != nil { - field.Error = err.Error() - } -} - func (m initProfileV2ReadOnlyModel) validatedDraft() (initDraft, error) { draft := m.draft profileName := m.document.fieldValue(initProfileV2FieldProfileName) @@ -1061,8 +885,7 @@ func (m *initProfileV2ReadOnlyModel) syncModelMapFields() { continue } value := initEffectiveModelMapInputValue(effective, tier) - m.document[index].Value = value - m.document[index].Cursor = len([]rune(value)) + m.setFieldValue(initProfileV2FieldModelMap(tier), value) m.document[index].Description = initModelMapInputDescription(tier, strings.TrimSpace(existing[string(tier)]), strings.TrimSpace(builtIns[string(tier)])) m.validateField(index) } @@ -1118,242 +941,7 @@ func initProfileV2ReviewPolicyFromDocument(document initProfileV2Document) (conf }, nil } -func (m *initProfileV2ReadOnlyModel) setFieldValue(id initProfileV2FieldID, value string) { - index := m.document.fieldIndexByID(id) - if index < 0 { - return - } - m.document[index].Value = value - m.document[index].Cursor = len([]rune(value)) -} - -func (m *initProfileV2ReadOnlyModel) selectFieldValue(id initProfileV2FieldID, value string) { - index := m.document.fieldIndexByID(id) - if index < 0 { - return - } - for optionIndex := range m.document[index].Options { - m.document[index].Options[optionIndex].Selected = m.document[index].Options[optionIndex].Value == value - } -} - -func (m *initProfileV2ReadOnlyModel) setFieldHidden(id initProfileV2FieldID, hidden bool) { - index := m.document.fieldIndexByID(id) - if index < 0 { - return - } - m.document[index].Hidden = hidden -} - -func (m *initProfileV2ReadOnlyModel) relayout() { - m.layout = initProfileV2LayoutDocument(m.document, m.viewport.Width, m.focused) - m.viewport.SetContent(m.layout.Content) -} - -func (m *initProfileV2ReadOnlyModel) ensureFocusedVisible() { - if m.focused < 0 || m.focused >= len(m.layout.Bounds) { - return - } - bounds := m.layout.Bounds[m.focused] - height := max(m.viewport.Height, 1) - top := m.viewport.YOffset - bottom := top + height - switch { - case bounds.Start < top: - m.viewport.SetYOffset(bounds.Start) - case bounds.Start >= bottom: - m.viewport.SetYOffset(bounds.Start) - case bounds.End > bottom: - if bounds.End-bounds.Start >= height { - m.viewport.SetYOffset(bounds.Start) - return - } - m.viewport.SetYOffset(max(bounds.End-height, 0)) - } -} - -func initProfileV2LayoutDocument(document initProfileV2Document, width int, focused int) initProfileV2Layout { - width = max(width, 20) - lines := []string{} - bounds := make([]initProfileV2FieldBounds, len(document)) - for index, field := range document { - if field.Hidden { - bounds[index] = initProfileV2FieldBounds{Start: len(lines), End: len(lines)} - continue - } - if len(lines) > 0 { - lines = append(lines, "") - } - start := len(lines) - initProfileV2AppendFieldLines(&lines, field, index == focused, width) - bounds[index] = initProfileV2FieldBounds{Start: start, End: len(lines)} - } - return initProfileV2Layout{ - Content: strings.TrimRight(strings.Join(lines, "\n"), "\n"), - Bounds: bounds, - Lines: len(lines), - } -} - -func initProfileV2AppendFieldLines(lines *[]string, field initProfileV2Field, focused bool, width int) { - titlePrefix := "" - initProfileV2AppendWrappedWithPrefix(lines, titlePrefix, field.Title, width) - initProfileV2AppendWrappedWithPrefix(lines, titlePrefix, field.Description, width) - if strings.TrimSpace(field.Error) != "" { - initProfileV2AppendWrappedWithPrefix(lines, titlePrefix+"! ", field.Error, width) - } - switch field.Kind { - case initProfileV2FieldSection: - case initProfileV2FieldInput, initProfileV2FieldTextarea: - value := field.Value - if focused && field.Editable { - value = initProfileV2ValueWithCursor(value, field.Cursor) - } - valueLines := strings.Split(value, "\n") - if len(valueLines) == 0 { - valueLines = []string{""} - } - for index, line := range valueLines { - prefix := " " - if focused && index == 0 { - prefix = "> " - } - initProfileV2AppendWrappedWithPrefix(lines, prefix, line, width) - } - case initProfileV2FieldSelect: - for _, option := range field.Options { - prefix := initSelectOptionPrefix(focused, option.Selected) - initProfileV2AppendWrappedWithPrefix(lines, prefix, option.Label, width) - } - } -} - -func initProfileV2AppendWrappedWithPrefix(lines *[]string, prefix string, text string, width int) { - initLinearAppendWrappedWithPrefix(lines, prefix, text, width) -} - -func (m initProfileV2ReadOnlyModel) styleVisibleViewport() string { - lines := strings.Split(m.viewport.View(), "\n") - activeStart := -1 - activeEnd := -1 - if m.focused >= 0 && m.focused < len(m.layout.Bounds) { - bounds := m.layout.Bounds[m.focused] - activeStart = bounds.Start - m.viewport.YOffset - activeEnd = bounds.End - m.viewport.YOffset - } - for index, line := range lines { - active := index >= activeStart && index < activeEnd - lines[index] = initProfileV2StyleViewportLine(line, active) - } - return strings.Join(lines, "\n") -} - -func initProfileV2StyleViewportLine(line string, active bool) string { - trimmed := strings.TrimSpace(line) - switch { - case trimmed == "": - return line - case strings.HasPrefix(trimmed, "! "): - return initProfileV2Theme.error.Render(line) - case strings.HasPrefix(trimmed, "> "): - return initLinearStyleSelectedLine(line) - case active && initProfileV2LooksLikeHeading(trimmed): - return initProfileV2Theme.activeTitle.Render(line) - case initProfileV2LooksLikeHeading(trimmed): - return initProfileV2Theme.title.Render(line) - default: - return line - } -} - -var initProfileV2HeadingSet = func() map[string]bool { - headings := map[string]bool{ - "Review profile": true, - "Profile name": true, - "Automatic profile selection": true, - "Route entries": true, - "Git scope": true, - "Git scope host": true, - "Git scope auth mode": true, - "Reviewer entity": true, - "LLM runtime": true, - "Minimum reviewer model tier": true, - "Model tier mapping": true, - "Additional reviewer-agent directories (optional)": true, - "Additional trusted reviewer-agent directories": true, - "Review policy": true, - "Major findings event": true, - "Allow self-approve": true, - "Resolve threads": true, - "Git credentials": true, - "Git credential store": true, - "Git credential name": true, - "LLM API key credentials": true, - "LLM credential store": true, - "LLM credential name": true, - "Profile action": true, - } - for _, tier := range config.ModelTiers() { - headings[fmt.Sprintf("%s model", tier)] = true - } - return headings -}() - -func initProfileV2LooksLikeHeading(line string) bool { - return initProfileV2HeadingSet[line] -} - func validateInitProfileV2RouteText(value string) error { _, err := parseInitRouteSpecs(value) return err } - -func initProfileV2InsertRunes(value string, cursor int, runes []rune) string { - existing := []rune(value) - cursor = min(max(cursor, 0), len(existing)) - next := make([]rune, 0, len(existing)+len(runes)) - next = append(next, existing[:cursor]...) - next = append(next, runes...) - next = append(next, existing[cursor:]...) - return string(next) -} - -func initProfileV2DeleteBeforeCursor(value string, cursor int) (string, int) { - existing := []rune(value) - cursor = min(max(cursor, 0), len(existing)) - if cursor == 0 { - return value, cursor - } - next := make([]rune, 0, len(existing)-1) - next = append(next, existing[:cursor-1]...) - next = append(next, existing[cursor:]...) - return string(next), cursor - 1 -} - -func initProfileV2DeleteAtCursor(value string, cursor int) string { - existing := []rune(value) - cursor = min(max(cursor, 0), len(existing)) - if cursor >= len(existing) { - return value - } - next := make([]rune, 0, len(existing)-1) - next = append(next, existing[:cursor]...) - next = append(next, existing[cursor+1:]...) - return string(next) -} - -func initProfileV2DeleteAfterCursor(value string, cursor int) string { - existing := []rune(value) - cursor = min(max(cursor, 0), len(existing)) - return string(existing[:cursor]) -} - -func initProfileV2ValueWithCursor(value string, cursor int) string { - existing := []rune(value) - cursor = min(max(cursor, 0), len(existing)) - next := make([]rune, 0, len(existing)+1) - next = append(next, existing[:cursor]...) - next = append(next, '|') - next = append(next, existing[cursor:]...) - return string(next) -} diff --git a/internal/cmd/initcmd/initcmd_test.go b/internal/cmd/initcmd/initcmd_test.go index 5012e2c0..8e268865 100644 --- a/internal/cmd/initcmd/initcmd_test.go +++ b/internal/cmd/initcmd/initcmd_test.go @@ -20,6 +20,7 @@ import ( tea "github.com/charmbracelet/bubbletea" "github.com/charmbracelet/huh" + "github.com/charmbracelet/x/ansi" "github.com/creack/pty" "github.com/open-cli-collective/cli-common/credstore" "github.com/spf13/cobra" @@ -7134,6 +7135,103 @@ func TestInitLinearEditorSpaceKeyEditsFocusedInputInsteadOfPagingDown(t *testing } } +func TestInitLinearEditorEditableFieldsConsumeReservedLetters(t *testing.T) { + const ( + inputField initLinearFieldID = "input" + textareaField initLinearFieldID = "textarea" + ) + var document initLinearDocument + document.addEditableInput(inputField, "Input", "", "", nil) + document.addEditableTextarea(textareaField, "Textarea", "", "") + model := newInitLinearEditorModel(initLinearEditor{Document: document}, 120, 12) + want := "qbfjkgG " + + model = typeInitLinearText(t, model, want) + inputIndex := model.document.fieldIndexByID(inputField) + if got := model.document.fieldValue(inputField); got != want { + t.Fatalf("input value = %q, want %q", got, want) + } + if model.focused != inputIndex || model.quitting { + t.Fatalf("after input typing focused=%d quitting=%t, want focused=%d and not quitting", model.focused, model.quitting, inputIndex) + } + + model = focusInitLinearField(t, model, textareaField) + model = typeInitLinearText(t, model, want) + textareaIndex := model.document.fieldIndexByID(textareaField) + if got := model.document.fieldValue(textareaField); got != want { + t.Fatalf("textarea value = %q, want %q", got, want) + } + if model.focused != textareaIndex || model.quitting { + t.Fatalf("after textarea typing focused=%d quitting=%t, want focused=%d and not quitting", model.focused, model.quitting, textareaIndex) + } +} + +func TestInitLinearEditorTextareaEditsFirstAndMiddleLines(t *testing.T) { + const textareaField initLinearFieldID = "textarea" + var document initLinearDocument + document.addEditableTextarea(textareaField, "Textarea", "", "alpha\nbravo\ncharlie") + model := newInitLinearEditorModel(initLinearEditor{Document: document}, 120, 12) + + model = updateInitLinearEditorModel(t, model, tea.KeyMsg{Type: tea.KeyCtrlHome}) + model = typeInitLinearText(t, model, "A") + model = updateInitLinearEditorModel(t, model, tea.KeyMsg{Type: tea.KeyCtrlN}) + model = updateInitLinearEditorModel(t, model, tea.KeyMsg{Type: tea.KeyCtrlA}) + model = typeInitLinearText(t, model, "B") + + if got, want := model.document.fieldValue(textareaField), "Aalpha\nBbravo\ncharlie"; got != want { + t.Fatalf("textarea value = %q, want %q", got, want) + } + if got, want := model.document[model.document.fieldIndexByID(textareaField)].Cursor, len([]rune("Aalpha\nB")); got != want { + t.Fatalf("textarea cursor = %d, want cursor after middle-line edit", got) + } +} + +func TestInitLinearEditorBracketedPastePreservesMultilineSecretAndMasksIt(t *testing.T) { + const textareaField initLinearFieldID = "secret" + var document initLinearDocument + document.addEditableSecretTextarea(textareaField, "Secret", "", "") + model := newInitLinearEditorModel(initLinearEditor{Document: document}, 120, 12) + pasted := strings.Repeat("known-line\n", 120) + "suffix" + + updated, cmd := model.Update(tea.KeyMsg{ + Type: tea.KeyRunes, + Runes: []rune(pasted), + Paste: true, + }) + next, ok := updated.(initLinearEditorModel) + if !ok { + t.Fatalf("Update returned %T, want initLinearEditorModel", updated) + } + if cmd != nil { + t.Fatalf("bracketed paste returned command %v, want no clipboard command", cmd) + } + if got := next.document.fieldValue(textareaField); got != pasted { + t.Fatalf("pasted secret = %q, want %q", got, pasted) + } + view := next.View() + for _, plaintext := range []string{"known-line", "suffix"} { + if strings.Contains(view, plaintext) { + t.Fatalf("secret value leaked through view (%q):\n%s", plaintext, view) + } + } + if !strings.Contains(view, "*") { + t.Fatalf("masked secret view contains no mask characters:\n%s", view) + } +} + +func TestInitLinearEditorWrapsWideRunesByTerminalCells(t *testing.T) { + var lines []string + initLinearAppendWrappedLineWithPrefix(&lines, "> ", strings.Repeat("界", 12), 20) + if len(lines) < 2 { + t.Fatalf("wide text produced %d line(s), want wrapping:\n%v", len(lines), lines) + } + for _, line := range lines { + if got := ansi.StringWidth(line); got > 20 { + t.Fatalf("wrapped line cell width = %d, want <= 20 for %q", got, line) + } + } +} + func TestInitLinearEditorOnlyFocusedSelectedFieldShowsCaret(t *testing.T) { const firstField initLinearFieldID = "first" const secondField initLinearFieldID = "second" @@ -9617,184 +9715,6 @@ func TestInitProfileV2ReadOnlyModelFocusNavigationPreservesRouteGuidance(t *test } } -func TestInitProfileV2ArrowKeysChangeSelectNotFocus(t *testing.T) { - const choiceField initProfileV2FieldID = "choice" - const inputField initProfileV2FieldID = "input" - var document initProfileV2Document - document.addEditableSelect(choiceField, "Choice", "", []huh.Option[string]{ - huh.NewOption("Alpha", "alpha"), - huh.NewOption("Beta", "beta"), - }, "alpha") - document.addEditableInput(inputField, "Input", "", "value", nil) - model := newInitProfileV2ReadOnlyModel(initProfileV2Editor{Document: document}, 120, 12) - choiceIndex := model.document.fieldIndexByID(choiceField) - - model = updateInitProfileV2ReadOnlyModel(t, model, tea.KeyMsg{Type: tea.KeyDown}) - if got := model.document.selectedValue(choiceField); got != "beta" { - t.Fatalf("selected value after down = %q, want beta", got) - } - if model.focused != choiceIndex { - t.Fatalf("focused after select down = %d, want unchanged choice index %d", model.focused, choiceIndex) - } - - model = updateInitProfileV2ReadOnlyModel(t, model, tea.KeyMsg{Type: tea.KeyUp}) - if got := model.document.selectedValue(choiceField); got != "alpha" { - t.Fatalf("selected value after up = %q, want alpha", got) - } - model = updateInitProfileV2ReadOnlyModel(t, model, tea.KeyMsg{Type: tea.KeyTab}) - inputIndex := model.document.fieldIndexByID(inputField) - if model.focused != inputIndex { - t.Fatalf("focused after tab = %d, want input index %d", model.focused, inputIndex) - } - model = updateInitProfileV2ReadOnlyModel(t, model, tea.KeyMsg{Type: tea.KeyDown}) - if model.focused != inputIndex { - t.Fatalf("focused after input down = %d, want unchanged input index %d", model.focused, inputIndex) - } -} - -func TestInitProfileV2ArrowKeysDoNotScrollFocusedInput(t *testing.T) { - const inputField initProfileV2FieldID = "input" - var document initProfileV2Document - document.addEditableInput(inputField, "Input", "", "value", nil) - for i := 0; i < 20; i++ { - document.addSection(fmt.Sprintf("Section %02d", i), "Context line") - } - model := newInitProfileV2ReadOnlyModel(initProfileV2Editor{Document: document}, 120, 5) - model.viewport.SetYOffset(1) - - model = updateInitProfileV2ReadOnlyModel(t, model, tea.KeyMsg{Type: tea.KeyDown}) - if got := model.viewport.YOffset; got != 1 { - t.Fatalf("viewport YOffset after input down = %d, want unchanged 1", got) - } - model = updateInitProfileV2ReadOnlyModel(t, model, tea.KeyMsg{Type: tea.KeyUp}) - if got := model.viewport.YOffset; got != 1 { - t.Fatalf("viewport YOffset after input up = %d, want unchanged 1", got) - } -} - -func TestInitProfileV2SpaceKeyEditsFocusedInputInsteadOfPagingDown(t *testing.T) { - model := newInitProfileV2ReadOnlyModel(newTestInitProfileV2EditorWithSelections("monit", "github.com/rianjs", nil, nil), 120, 8) - model = focusInitProfileV2Field(t, model, initProfileV2FieldRoutes) - beforeOffset := model.viewport.YOffset - beforeFocus := model.focused - - model = updateInitProfileV2ReadOnlyModel(t, model, tea.KeyMsg{Type: tea.KeySpace}) - - if model.focused != beforeFocus { - t.Fatalf("focused field = %d, want unchanged %d", model.focused, beforeFocus) - } - if got, want := model.document.fieldValue(initProfileV2FieldRoutes), "github.com/rianjs "; got != want { - t.Fatalf("route field value = %q, want %q", got, want) - } - if got := model.viewport.YOffset; got != beforeOffset { - t.Fatalf("viewport YOffset after input space = %d, want unchanged %d", got, beforeOffset) - } -} - -func TestInitProfileV2OnlyFocusedSelectedFieldShowsCaret(t *testing.T) { - const firstField initProfileV2FieldID = "first" - const secondField initProfileV2FieldID = "second" - var document initProfileV2Document - document.addEditableSelect(firstField, "First", "", []huh.Option[string]{ - huh.NewOption("Alpha", "alpha"), - huh.NewOption("Beta", "beta"), - }, "alpha") - document.addEditableSelect(secondField, "Second", "", []huh.Option[string]{ - huh.NewOption("Gamma", "gamma"), - huh.NewOption("Delta", "delta"), - }, "delta") - - model := newInitProfileV2ReadOnlyModel(initProfileV2Editor{Document: document}, 120, 12) - if got := strings.Count(model.layout.Content, "> "); got != 1 { - t.Fatalf("initial caret count = %d, want 1:\n%s", got, model.layout.Content) - } - if !strings.Contains(model.layout.Content, "> [x] Alpha") { - t.Fatalf("initial content missing focused selected option:\n%s", model.layout.Content) - } - if !strings.Contains(model.layout.Content, " [x] Delta") { - t.Fatalf("initial content missing unfocused selected option marker:\n%s", model.layout.Content) - } - if !strings.Contains(model.layout.Content, " [ ] Beta") { - t.Fatalf("initial content missing unselected option marker:\n%s", model.layout.Content) - } - if strings.Contains(model.layout.Content, "> [x] Delta") { - t.Fatalf("initial content shows caret on unfocused selected option:\n%s", model.layout.Content) - } - - model = updateInitProfileV2ReadOnlyModel(t, model, tea.KeyMsg{Type: tea.KeyTab}) - if got := strings.Count(model.layout.Content, "> "); got != 1 { - t.Fatalf("caret count after tab = %d, want 1:\n%s", got, model.layout.Content) - } - if !strings.Contains(model.layout.Content, "> [x] Delta") { - t.Fatalf("content after tab missing focused selected option:\n%s", model.layout.Content) - } - if !strings.Contains(model.layout.Content, " [x] Alpha") { - t.Fatalf("content after tab missing unfocused selected option marker:\n%s", model.layout.Content) - } - if strings.Contains(model.layout.Content, "> [x] Alpha") { - t.Fatalf("content after tab shows caret on unfocused selected option:\n%s", model.layout.Content) - } -} - -func TestInitProfileV2SelectMarkerWrapsUnfocusedSelectedOption(t *testing.T) { - const inputField initProfileV2FieldID = "input" - const choiceField initProfileV2FieldID = "choice" - const width = 24 - var document initProfileV2Document - document.addEditableInput(inputField, "Input", "", "value", nil) - document.addEditableSelect(choiceField, "Choice", "", []huh.Option[string]{ - huh.NewOption("Alpha selected option wraps cleanly", "alpha"), - huh.NewOption("Beta", "beta"), - }, "alpha") - - model := newInitProfileV2ReadOnlyModel(initProfileV2Editor{Document: document}, width, 12) - want := " [x] Alpha selected\n option wraps\n cleanly" - if !strings.Contains(model.layout.Content, want) { - t.Fatalf("wrapped unfocused selected option missing marker or aligned continuations:\n%s", model.layout.Content) - } - if strings.Contains(model.layout.Content, "> [x] Alpha selected") { - t.Fatalf("wrapped unfocused selected option shows caret:\n%s", model.layout.Content) - } - for _, line := range strings.Split(model.layout.Content, "\n") { - if len(line) > width { - t.Fatalf("wrapped line length = %d, want <= %d for %q\n%s", len(line), width, line, model.layout.Content) - } - } -} - -func TestInitProfileV2LayoutWrapsAndMeasuresSmallViewport(t *testing.T) { - var document initProfileV2Document - document.addSection("Profile", "This section has enough words to wrap across multiple lines in a narrow terminal.") - document.addInputField(initLinearFieldInput, "", "Profile name", "Short field that should remain measurable.", "monit", false, nil, initLinearFieldOptions{}) - document.addInputField(initLinearFieldInput, "", "Route entries", "Routes tell cr when to use this profile automatically in a narrow viewport.", "github.com/SignalFT", false, nil, initLinearFieldOptions{}) - document.addInputField(initLinearFieldInput, "", "Git credential name", "Full credential name under the selected store.", "codereview/monit", false, nil, initLinearFieldOptions{}) - - layout := initProfileV2LayoutDocument(document, 32, document.firstFocusableField()) - if len(layout.Bounds) != len(document) { - t.Fatalf("bounds count = %d, want %d", len(layout.Bounds), len(document)) - } - if layout.Lines <= len(document) { - t.Fatalf("layout lines = %d, want wrapped content larger than document length %d", layout.Lines, len(document)) - } - for _, line := range strings.Split(layout.Content, "\n") { - if len(line) > 32 { - t.Fatalf("line length = %d, want <= 32 for %q\n%s", len(line), line, layout.Content) - } - } - for index, bounds := range layout.Bounds { - if bounds.Start < 0 || bounds.End <= bounds.Start || bounds.End > layout.Lines { - t.Fatalf("bounds[%d] = %#v outside layout with %d lines", index, bounds, layout.Lines) - } - } - - model := newInitProfileV2ReadOnlyModel(initProfileV2Editor{Document: document}, 32, 6) - model = updateInitProfileV2ReadOnlyModel(t, model, tea.KeyMsg{Type: tea.KeyEnd}) - bounds := model.layout.Bounds[model.focused] - if bounds.Start < model.viewport.YOffset || bounds.Start >= model.viewport.YOffset+model.viewport.Height { - t.Fatalf("focused field start line %d not visible in viewport [%d,%d)", bounds.Start, model.viewport.YOffset, model.viewport.YOffset+model.viewport.Height) - } -} - func TestInitProfileV2TextInputsDraftProfileNameAndRoutes(t *testing.T) { model := newInitProfileV2ReadOnlyModel(newTestInitProfileV2Editor("monit", "github.com/SignalFT; github.com/OtherMonitOrg"), 160, 24) model = updateInitProfileV2ReadOnlyModel(t, model, tea.KeyMsg{Type: tea.KeyCtrlU}) @@ -10265,8 +10185,8 @@ func TestInitProfileV2NoRuntimeBootstrapRequestsExistingFlow(t *testing.T) { if !ok { t.Fatalf("Update returned %T, want initProfileV2ReadOnlyModel", updated) } - if !next.requestLLMRuntimeBootstrap { - t.Fatal("requestLLMRuntimeBootstrap = false, want existing runtime flow request") + if !next.result.BootstrapLLMRuntime { + t.Fatal("BootstrapLLMRuntime = false, want existing runtime flow request") } if cmd == nil { t.Fatal("Update returned nil command, want quit command for runtime bootstrap handoff") @@ -10403,7 +10323,6 @@ func TestInitProfileV2ReviewPolicyDraftsSelections(t *testing.T) { "github.com/SignalFT", config.ReviewPolicy{}, "codereview/monit", - true, testInitProfileV2GitScopes(), testInitProfileV2GitScopeName, ), 160, 40) @@ -10497,7 +10416,6 @@ func TestInitProfileV2GitCredentialNameFollowsChangedScopeDefaultWhenUnedited(t "github.com/SignalFT", config.ReviewPolicy{}, "codereview/old-git", - true, gitScopes, "old-git", ), 160, 24) @@ -11231,7 +11149,7 @@ func newTestInitProfileV2EditorWithAgentSources(profileName string, routeText st } } -func newTestInitProfileV2EditorWithReviewPolicyAndGitStorage(profileName string, routeText string, policy config.ReviewPolicy, gitStorageLabel string, gitLabelUsesDefault bool, gitScopes map[string]initGitScopeDraft, selectedGitScope string) initProfileV2Editor { +func newTestInitProfileV2EditorWithReviewPolicyAndGitStorage(profileName string, routeText string, policy config.ReviewPolicy, gitStorageLabel string, gitScopes map[string]initGitScopeDraft, selectedGitScope string) initProfileV2Editor { draft := initDraft{ OriginalProfileName: profileName, ProfileName: profileName, @@ -11257,13 +11175,11 @@ func newTestInitProfileV2EditorWithReviewPolicyAndGitStorage(profileName string, initProfileV2AppendReviewPolicySection(&document, policy, false) initProfileV2AppendGitStorageSection(&document, storeOptions, config.LocalOSCredentialStoreID, gitStorageLabel) return initProfileV2Editor{ - Draft: draft, - GitScopes: gitScopes, - CredentialStoreOptions: storeOptions, - SelectedGitScope: selectedGitScope, - InitialGitStorageLabel: gitStorageLabel, - GitStorageLabelUsesDefault: gitLabelUsesDefault, - Document: document, + Draft: draft, + GitScopes: gitScopes, + CredentialStoreOptions: storeOptions, + SelectedGitScope: selectedGitScope, + Document: document, } } From 9c42703a1d4cb79cfd2e761b00aedcfc84bd628f Mon Sep 17 00:00:00 2001 From: Rian Stockbower Date: Tue, 15 Sep 2026 11:02:42 -0400 Subject: [PATCH 4/6] build(deps): migrate to maintained yaml module --- go.mod | 2 +- go.sum | 2 ++ internal/agents/agents.go | 2 +- internal/benchmark/suite.go | 2 +- internal/cmd/configcmd/configcmd_test.go | 2 +- internal/cmd/initcmd/initcmd.go | 2 +- internal/config/config.go | 2 +- 7 files changed, 8 insertions(+), 6 deletions(-) diff --git a/go.mod b/go.mod index 9238ab1a..a98ab677 100644 --- a/go.mod +++ b/go.mod @@ -15,9 +15,9 @@ require ( github.com/google/uuid v1.6.0 github.com/open-cli-collective/cli-common v0.4.1 github.com/spf13/cobra v1.10.2 + go.yaml.in/yaml/v3 v3.0.5 golang.org/x/sys v0.46.0 golang.org/x/term v0.44.0 - gopkg.in/yaml.v3 v3.0.1 modernc.org/sqlite v1.51.0 ) diff --git a/go.sum b/go.sum index 92d0e4e0..ec09479f 100644 --- a/go.sum +++ b/go.sum @@ -152,6 +152,8 @@ go.opentelemetry.io/proto/otlp v1.9.0/go.mod h1:xE+Cx5E/eEHw+ISFkwPLwCZefwVjY+pq go.uber.org/atomic v1.11.0 h1:ZvwS0R+56ePWxUNi+Atn9dWONBPp/AUETXlHW0DxSjE= go.uber.org/atomic v1.11.0/go.mod h1:LUxbIzbOniOlMKjJjyPfpl4v+PKK2cNJn91OQbhoJI0= go.yaml.in/yaml/v3 v3.0.4/go.mod h1:DhzuOOF2ATzADvBadXxruRBLzYTpT36CKvDb3+aBEFg= +go.yaml.in/yaml/v3 v3.0.5 h1:N6y/pJk8buWs9NY5ERU2HSMfm+IuD/OtfdAnq6kESPw= +go.yaml.in/yaml/v3 v3.0.5/go.mod h1:HVTZu1O7/Vkt2N+BFy8Zza+lnLsABggaTM2ZpNIGuKg= golang.org/x/exp v0.0.0-20231006140011-7918f672742d h1:jtJma62tbqLibJ5sFQz8bKtEM8rJBtfilJ2qTU199MI= golang.org/x/exp v0.0.0-20231006140011-7918f672742d/go.mod h1:ldy0pHrwJyGW56pPQzzkH36rKxoZW1tw7ZJpeKx+hdo= golang.org/x/mod v0.35.0 h1:Ww1D637e6Pg+Zb2KrWfHQUnH2dQRLBQyAtpr/haaJeM= diff --git a/internal/agents/agents.go b/internal/agents/agents.go index 18be8d6c..ceae3ab7 100644 --- a/internal/agents/agents.go +++ b/internal/agents/agents.go @@ -19,7 +19,7 @@ import ( "strings" "github.com/gobwas/glob" - "gopkg.in/yaml.v3" + "go.yaml.in/yaml/v3" "github.com/open-cli-collective/codereview-cli/internal/gitprovider" "github.com/open-cli-collective/codereview-cli/internal/modelprefs" diff --git a/internal/benchmark/suite.go b/internal/benchmark/suite.go index 97b2b13a..1c422149 100644 --- a/internal/benchmark/suite.go +++ b/internal/benchmark/suite.go @@ -9,7 +9,7 @@ import ( "regexp" "strings" - "gopkg.in/yaml.v3" + "go.yaml.in/yaml/v3" "github.com/open-cli-collective/codereview-cli/internal/config" "github.com/open-cli-collective/codereview-cli/internal/modelprefs" diff --git a/internal/cmd/configcmd/configcmd_test.go b/internal/cmd/configcmd/configcmd_test.go index e6e00d25..3b19d4b6 100644 --- a/internal/cmd/configcmd/configcmd_test.go +++ b/internal/cmd/configcmd/configcmd_test.go @@ -15,7 +15,7 @@ import ( "github.com/open-cli-collective/cli-common/credstore" "github.com/open-cli-collective/cli-common/statedirtest" "github.com/spf13/cobra" - "gopkg.in/yaml.v3" + "go.yaml.in/yaml/v3" "github.com/open-cli-collective/codereview-cli/internal/agents" "github.com/open-cli-collective/codereview-cli/internal/cmd/cmdtest" diff --git a/internal/cmd/initcmd/initcmd.go b/internal/cmd/initcmd/initcmd.go index 8467c2df..6b1dd642 100644 --- a/internal/cmd/initcmd/initcmd.go +++ b/internal/cmd/initcmd/initcmd.go @@ -18,7 +18,7 @@ import ( "github.com/charmbracelet/huh" "github.com/open-cli-collective/cli-common/credstore" "github.com/spf13/cobra" - "gopkg.in/yaml.v3" + "go.yaml.in/yaml/v3" "github.com/open-cli-collective/codereview-cli/internal/cmd/cmderr" "github.com/open-cli-collective/codereview-cli/internal/cmd/cmdruntime" diff --git a/internal/config/config.go b/internal/config/config.go index 457fa951..e0fc5f9c 100644 --- a/internal/config/config.go +++ b/internal/config/config.go @@ -17,7 +17,7 @@ import ( "github.com/open-cli-collective/cli-common/credstore" "github.com/open-cli-collective/cli-common/statedir" - "gopkg.in/yaml.v3" + "go.yaml.in/yaml/v3" "github.com/open-cli-collective/codereview-cli/internal/modelprefs" ) From 388110599c455505a1259f3f95127fb70f3a1682 Mon Sep 17 00:00:00 2001 From: Rian Stockbower Date: Tue, 15 Sep 2026 11:20:21 -0400 Subject: [PATCH 5/6] ci: pin workflow dependencies --- .github/workflows/auto-release.yml | 2 +- .github/workflows/ci.yml | 26 +++++++++++++------------- .github/workflows/release.yml | 2 +- 3 files changed, 15 insertions(+), 15 deletions(-) diff --git a/.github/workflows/auto-release.yml b/.github/workflows/auto-release.yml index f427dce7..b02062fb 100644 --- a/.github/workflows/auto-release.yml +++ b/.github/workflows/auto-release.yml @@ -22,7 +22,7 @@ permissions: jobs: auto-release: - uses: open-cli-collective/.github/.github/workflows/auto-release.yml@v1 + uses: open-cli-collective/.github/.github/workflows/auto-release.yml@b6382514b809d960c6fbdc85745a60c14cde04f # shared .github with: # push is live (false); workflow_dispatch honors the checkbox. Compare # against both true and 'true' because a workflow_dispatch boolean input diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index b2f2bd74..93c7bc3f 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -25,8 +25,8 @@ jobs: os: [ubuntu-latest, macos-latest, windows-latest] runs-on: ${{ matrix.os }} steps: - - uses: actions/checkout@v4 - - uses: open-cli-collective/.github/actions/go-build@v1 + - uses: actions/checkout@fbc6f3992d24b796d5a048ff273f7fcc4a7b6c09 # v5.1.0 + - uses: open-cli-collective/.github/actions/go-build@b6382514b809d960c6fbdc85745a60c14cde04f # shared .github with: go-version-file: go.mod @@ -43,8 +43,8 @@ jobs: test: runs-on: ubuntu-latest steps: - - uses: actions/checkout@v4 - - uses: open-cli-collective/.github/actions/go-test@v1 + - uses: actions/checkout@fbc6f3992d24b796d5a048ff273f7fcc4a7b6c09 # v5.1.0 + - uses: open-cli-collective/.github/actions/go-test@b6382514b809d960c6fbdc85745a60c14cde04f # shared .github with: go-version-file: go.mod @@ -53,8 +53,8 @@ jobs: env: GOFLAGS: -tags=keyring_nopassage,keyring_no1password steps: - - uses: actions/checkout@v4 - - uses: open-cli-collective/.github/actions/go-test@v1 + - uses: actions/checkout@fbc6f3992d24b796d5a048ff273f7fcc4a7b6c09 # v5.1.0 + - uses: open-cli-collective/.github/actions/go-test@b6382514b809d960c6fbdc85745a60c14cde04f # shared .github with: go-version-file: go.mod @@ -64,8 +64,8 @@ jobs: CGO_ENABLED: "0" GOFLAGS: -tags=keyring_nopassage steps: - - uses: actions/checkout@v4 - - uses: open-cli-collective/.github/actions/go-test@v1 + - uses: actions/checkout@fbc6f3992d24b796d5a048ff273f7fcc4a7b6c09 # v5.1.0 + - uses: open-cli-collective/.github/actions/go-test@b6382514b809d960c6fbdc85745a60c14cde04f # shared .github with: go-version-file: go.mod target: test-static-smoke @@ -73,21 +73,21 @@ jobs: lint: runs-on: ubuntu-latest steps: - - uses: actions/checkout@v4 - - uses: open-cli-collective/.github/actions/go-lint@v1 + - uses: actions/checkout@fbc6f3992d24b796d5a048ff273f7fcc4a7b6c09 # v5.1.0 + - uses: open-cli-collective/.github/actions/go-lint@b6382514b809d960c6fbdc85745a60c14cde04f # shared .github with: go-version-file: go.mod identity-check: runs-on: ubuntu-latest steps: - - uses: actions/checkout@v4 - - uses: open-cli-collective/.github/actions/identity-check@v1 # distribution.md §8.2 + - uses: actions/checkout@fbc6f3992d24b796d5a048ff273f7fcc4a7b6c09 # v5.1.0 + - uses: open-cli-collective/.github/actions/identity-check@b6382514b809d960c6fbdc85745a60c14cde04f # shared .github; distribution.md §8.2 pr-title: if: github.event_name == 'pull_request' runs-on: ubuntu-latest steps: - - uses: open-cli-collective/.github/actions/pr-title@v1 + - uses: open-cli-collective/.github/actions/pr-title@b6382514b809d960c6fbdc85745a60c14cde04f # shared .github with: title: ${{ github.event.pull_request.title }} diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index e96e3740..67fa0b35 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -21,7 +21,7 @@ permissions: jobs: release: - uses: open-cli-collective/.github/.github/workflows/release.yml@v1 + uses: open-cli-collective/.github/.github/workflows/release.yml@b6382514b809d960c6fbdc85745a60c14cde04f # shared .github with: # tag push is live (false); workflow_dispatch honors the checkbox. Compare # against both true and 'true' because a workflow_dispatch boolean input From 226f14983562e62bb2d4cb89262ca627b16da785 Mon Sep 17 00:00:00 2001 From: Rian Stockbower Date: Tue, 15 Sep 2026 11:21:59 -0400 Subject: [PATCH 6/6] ci: correct shared action commit pins --- .github/workflows/auto-release.yml | 2 +- .github/workflows/ci.yml | 14 +++++++------- .github/workflows/release.yml | 2 +- 3 files changed, 9 insertions(+), 9 deletions(-) diff --git a/.github/workflows/auto-release.yml b/.github/workflows/auto-release.yml index b02062fb..88d33504 100644 --- a/.github/workflows/auto-release.yml +++ b/.github/workflows/auto-release.yml @@ -22,7 +22,7 @@ permissions: jobs: auto-release: - uses: open-cli-collective/.github/.github/workflows/auto-release.yml@b6382514b809d960c6fbdc85745a60c14cde04f # shared .github + uses: open-cli-collective/.github/.github/workflows/auto-release.yml@b6382514b809d960c6fbdc85745a60c14cde04f1 # shared .github with: # push is live (false); workflow_dispatch honors the checkbox. Compare # against both true and 'true' because a workflow_dispatch boolean input diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 93c7bc3f..d10ae486 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -26,7 +26,7 @@ jobs: runs-on: ${{ matrix.os }} steps: - uses: actions/checkout@fbc6f3992d24b796d5a048ff273f7fcc4a7b6c09 # v5.1.0 - - uses: open-cli-collective/.github/actions/go-build@b6382514b809d960c6fbdc85745a60c14cde04f # shared .github + - uses: open-cli-collective/.github/actions/go-build@b6382514b809d960c6fbdc85745a60c14cde04f1 # shared .github with: go-version-file: go.mod @@ -44,7 +44,7 @@ jobs: runs-on: ubuntu-latest steps: - uses: actions/checkout@fbc6f3992d24b796d5a048ff273f7fcc4a7b6c09 # v5.1.0 - - uses: open-cli-collective/.github/actions/go-test@b6382514b809d960c6fbdc85745a60c14cde04f # shared .github + - uses: open-cli-collective/.github/actions/go-test@b6382514b809d960c6fbdc85745a60c14cde04f1 # shared .github with: go-version-file: go.mod @@ -54,7 +54,7 @@ jobs: GOFLAGS: -tags=keyring_nopassage,keyring_no1password steps: - uses: actions/checkout@fbc6f3992d24b796d5a048ff273f7fcc4a7b6c09 # v5.1.0 - - uses: open-cli-collective/.github/actions/go-test@b6382514b809d960c6fbdc85745a60c14cde04f # shared .github + - uses: open-cli-collective/.github/actions/go-test@b6382514b809d960c6fbdc85745a60c14cde04f1 # shared .github with: go-version-file: go.mod @@ -65,7 +65,7 @@ jobs: GOFLAGS: -tags=keyring_nopassage steps: - uses: actions/checkout@fbc6f3992d24b796d5a048ff273f7fcc4a7b6c09 # v5.1.0 - - uses: open-cli-collective/.github/actions/go-test@b6382514b809d960c6fbdc85745a60c14cde04f # shared .github + - uses: open-cli-collective/.github/actions/go-test@b6382514b809d960c6fbdc85745a60c14cde04f1 # shared .github with: go-version-file: go.mod target: test-static-smoke @@ -74,7 +74,7 @@ jobs: runs-on: ubuntu-latest steps: - uses: actions/checkout@fbc6f3992d24b796d5a048ff273f7fcc4a7b6c09 # v5.1.0 - - uses: open-cli-collective/.github/actions/go-lint@b6382514b809d960c6fbdc85745a60c14cde04f # shared .github + - uses: open-cli-collective/.github/actions/go-lint@b6382514b809d960c6fbdc85745a60c14cde04f1 # shared .github with: go-version-file: go.mod @@ -82,12 +82,12 @@ jobs: runs-on: ubuntu-latest steps: - uses: actions/checkout@fbc6f3992d24b796d5a048ff273f7fcc4a7b6c09 # v5.1.0 - - uses: open-cli-collective/.github/actions/identity-check@b6382514b809d960c6fbdc85745a60c14cde04f # shared .github; distribution.md §8.2 + - uses: open-cli-collective/.github/actions/identity-check@b6382514b809d960c6fbdc85745a60c14cde04f1 # shared .github; distribution.md §8.2 pr-title: if: github.event_name == 'pull_request' runs-on: ubuntu-latest steps: - - uses: open-cli-collective/.github/actions/pr-title@b6382514b809d960c6fbdc85745a60c14cde04f # shared .github + - uses: open-cli-collective/.github/actions/pr-title@b6382514b809d960c6fbdc85745a60c14cde04f1 # shared .github with: title: ${{ github.event.pull_request.title }} diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index 67fa0b35..845305a2 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -21,7 +21,7 @@ permissions: jobs: release: - uses: open-cli-collective/.github/.github/workflows/release.yml@b6382514b809d960c6fbdc85745a60c14cde04f # shared .github + uses: open-cli-collective/.github/.github/workflows/release.yml@b6382514b809d960c6fbdc85745a60c14cde04f1 # shared .github with: # tag push is live (false); workflow_dispatch honors the checkbox. Compare # against both true and 'true' because a workflow_dispatch boolean input