diff --git a/internal/api/apitest/mock_client.go b/internal/api/apitest/mock_client.go index 5057934b..f16d2b34 100644 --- a/internal/api/apitest/mock_client.go +++ b/internal/api/apitest/mock_client.go @@ -24,6 +24,7 @@ type MockClient struct { UpdateConversationFunc func(ctx context.Context, id string, input gen.UpdateConversationInput) (*gen.UpdateConversationResponse, error) DeleteConversationFunc func(ctx context.Context, id string) (*gen.DeleteConversationResponse, error) CreateMessageFunc func(ctx context.Context, input gen.CreateMessageInput) (*gen.CreateMessageResponse, error) + ApproveLogEventPolicyFunc func(ctx context.Context, id string) (*gen.ApproveLogEventPolicyResponse, error) } // NewMockClient creates a MockClient with sensible defaults. @@ -134,3 +135,10 @@ func (m *MockClient) CreateMessage(ctx context.Context, input gen.CreateMessageI } return nil, nil } + +func (m *MockClient) ApproveLogEventPolicy(ctx context.Context, id string) (*gen.ApproveLogEventPolicyResponse, error) { + if m.ApproveLogEventPolicyFunc != nil { + return m.ApproveLogEventPolicyFunc(ctx, id) + } + return nil, nil +} diff --git a/internal/api/apitest/mock_policies.go b/internal/api/apitest/mock_policies.go new file mode 100644 index 00000000..1d4ab62c --- /dev/null +++ b/internal/api/apitest/mock_policies.go @@ -0,0 +1,22 @@ +package apitest + +import ( + "context" +) + +// MockPolicies implements api.LogEventPolicies for testing. +type MockPolicies struct { + ApproveFunc func(ctx context.Context, id string) error +} + +// NewMockPolicies creates a MockPolicies with sensible defaults. +func NewMockPolicies() *MockPolicies { + return &MockPolicies{} +} + +func (m *MockPolicies) Approve(ctx context.Context, id string) error { + if m.ApproveFunc != nil { + return m.ApproveFunc(ctx, id) + } + return nil +} diff --git a/internal/api/client.go b/internal/api/client.go index bc7395b6..242b6a14 100644 --- a/internal/api/client.go +++ b/internal/api/client.go @@ -51,6 +51,9 @@ type Client interface { // Message operations CreateMessage(ctx context.Context, input gen.CreateMessageInput) (*gen.CreateMessageResponse, error) + + // Policy operations + ApproveLogEventPolicy(ctx context.Context, id string) (*gen.ApproveLogEventPolicyResponse, error) } // client is the concrete implementation of Client. @@ -261,3 +264,13 @@ func (c *client) CreateMessage(ctx context.Context, input gen.CreateMessageInput } return gen.CreateMessage(ctx, gql, input) } + +// Policy operations + +func (c *client) ApproveLogEventPolicy(ctx context.Context, id string) (*gen.ApproveLogEventPolicyResponse, error) { + gql, err := c.gql(ctx) + if err != nil { + return nil, err + } + return gen.ApproveLogEventPolicy(ctx, gql, id) +} diff --git a/internal/api/gen/generated.go b/internal/api/gen/generated.go index 6c5dafca..70addef0 100644 --- a/internal/api/gen/generated.go +++ b/internal/api/gen/generated.go @@ -11,6 +11,42 @@ import ( "github.com/Khan/genqlient/graphql" ) +// ApproveLogEventPolicyApproveLogEventPolicy includes the requested fields of the GraphQL type LogEventPolicy. +type ApproveLogEventPolicyApproveLogEventPolicy struct { + // Unique identifier + Id string `json:"id"` + // Quality issue category this policy addresses, e.g. health_checks, bot_traffic, pii_leakage, duplicate_fields + Category string `json:"category"` + // When this policy was approved by a user + ApprovedAt *time.Time `json:"approvedAt"` + // User ID who approved this policy + ApprovedBy *string `json:"approvedBy"` +} + +// GetId returns ApproveLogEventPolicyApproveLogEventPolicy.Id, and is useful for accessing the field via an interface. +func (v *ApproveLogEventPolicyApproveLogEventPolicy) GetId() string { return v.Id } + +// GetCategory returns ApproveLogEventPolicyApproveLogEventPolicy.Category, and is useful for accessing the field via an interface. +func (v *ApproveLogEventPolicyApproveLogEventPolicy) GetCategory() string { return v.Category } + +// GetApprovedAt returns ApproveLogEventPolicyApproveLogEventPolicy.ApprovedAt, and is useful for accessing the field via an interface. +func (v *ApproveLogEventPolicyApproveLogEventPolicy) GetApprovedAt() *time.Time { return v.ApprovedAt } + +// GetApprovedBy returns ApproveLogEventPolicyApproveLogEventPolicy.ApprovedBy, and is useful for accessing the field via an interface. +func (v *ApproveLogEventPolicyApproveLogEventPolicy) GetApprovedBy() *string { return v.ApprovedBy } + +// ApproveLogEventPolicyResponse is returned by ApproveLogEventPolicy on success. +type ApproveLogEventPolicyResponse struct { + // Approve a log event policy, enabling it for enforcement. + // Clears any previous dismissal. + ApproveLogEventPolicy ApproveLogEventPolicyApproveLogEventPolicy `json:"approveLogEventPolicy"` +} + +// GetApproveLogEventPolicy returns ApproveLogEventPolicyResponse.ApproveLogEventPolicy, and is useful for accessing the field via an interface. +func (v *ApproveLogEventPolicyResponse) GetApproveLogEventPolicy() ApproveLogEventPolicyApproveLogEventPolicy { + return v.ApproveLogEventPolicy +} + // A content block in a message. Exactly one of the typed fields should be set. type ContentBlockInput struct { Type ContentBlockType `json:"type"` @@ -527,6 +563,44 @@ type DeleteConversationResponse struct { // GetDeleteConversation returns DeleteConversationResponse.DeleteConversation, and is useful for accessing the field via an interface. func (v *DeleteConversationResponse) GetDeleteConversation() bool { return v.DeleteConversation } +// DismissLogEventPolicyDismissLogEventPolicy includes the requested fields of the GraphQL type LogEventPolicy. +type DismissLogEventPolicyDismissLogEventPolicy struct { + // Unique identifier + Id string `json:"id"` + // Quality issue category this policy addresses, e.g. health_checks, bot_traffic, pii_leakage, duplicate_fields + Category string `json:"category"` + // When this policy was dismissed by a user + DismissedAt *time.Time `json:"dismissedAt"` + // User ID who dismissed this policy + DismissedBy *string `json:"dismissedBy"` +} + +// GetId returns DismissLogEventPolicyDismissLogEventPolicy.Id, and is useful for accessing the field via an interface. +func (v *DismissLogEventPolicyDismissLogEventPolicy) GetId() string { return v.Id } + +// GetCategory returns DismissLogEventPolicyDismissLogEventPolicy.Category, and is useful for accessing the field via an interface. +func (v *DismissLogEventPolicyDismissLogEventPolicy) GetCategory() string { return v.Category } + +// GetDismissedAt returns DismissLogEventPolicyDismissLogEventPolicy.DismissedAt, and is useful for accessing the field via an interface. +func (v *DismissLogEventPolicyDismissLogEventPolicy) GetDismissedAt() *time.Time { + return v.DismissedAt +} + +// GetDismissedBy returns DismissLogEventPolicyDismissLogEventPolicy.DismissedBy, and is useful for accessing the field via an interface. +func (v *DismissLogEventPolicyDismissLogEventPolicy) GetDismissedBy() *string { return v.DismissedBy } + +// DismissLogEventPolicyResponse is returned by DismissLogEventPolicy on success. +type DismissLogEventPolicyResponse struct { + // Dismiss a log event policy, hiding it from pending review. + // Clears any previous approval. + DismissLogEventPolicy DismissLogEventPolicyDismissLogEventPolicy `json:"dismissLogEventPolicy"` +} + +// GetDismissLogEventPolicy returns DismissLogEventPolicyResponse.DismissLogEventPolicy, and is useful for accessing the field via an interface. +func (v *DismissLogEventPolicyResponse) GetDismissLogEventPolicy() DismissLogEventPolicyDismissLogEventPolicy { + return v.DismissLogEventPolicy +} + // EnableServiceResponse is returned by EnableService on success. type EnableServiceResponse struct { UpdateService EnableServiceUpdateService `json:"updateService"` @@ -1956,6 +2030,14 @@ var AllWorkspacePurpose = []WorkspacePurpose{ WorkspacePurposeCompliance, } +// __ApproveLogEventPolicyInput is used internally by genqlient +type __ApproveLogEventPolicyInput struct { + Id string `json:"id"` +} + +// GetId returns __ApproveLogEventPolicyInput.Id, and is useful for accessing the field via an interface. +func (v *__ApproveLogEventPolicyInput) GetId() string { return v.Id } + // __CreateAccountInput is used internally by genqlient type __CreateAccountInput struct { Input CreateAccountInput `json:"input"` @@ -2006,6 +2088,14 @@ type __DeleteConversationInput struct { // GetId returns __DeleteConversationInput.Id, and is useful for accessing the field via an interface. func (v *__DeleteConversationInput) GetId() string { return v.Id } +// __DismissLogEventPolicyInput is used internally by genqlient +type __DismissLogEventPolicyInput struct { + Id string `json:"id"` +} + +// GetId returns __DismissLogEventPolicyInput.Id, and is useful for accessing the field via an interface. +func (v *__DismissLogEventPolicyInput) GetId() string { return v.Id } + // __EnableServiceInput is used internally by genqlient type __EnableServiceInput struct { ServiceId string `json:"serviceId"` @@ -2082,6 +2172,43 @@ type __ValidateDatadogApiKeyInput struct { // GetInput returns __ValidateDatadogApiKeyInput.Input, and is useful for accessing the field via an interface. func (v *__ValidateDatadogApiKeyInput) GetInput() ValidateDatadogApiKeyInput { return v.Input } +// The mutation executed by ApproveLogEventPolicy. +const ApproveLogEventPolicy_Operation = ` +mutation ApproveLogEventPolicy ($id: ID!) { + approveLogEventPolicy(id: $id) { + id + category + approvedAt + approvedBy + } +} +` + +func ApproveLogEventPolicy( + ctx_ context.Context, + client_ graphql.Client, + id string, +) (data_ *ApproveLogEventPolicyResponse, err_ error) { + req_ := &graphql.Request{ + OpName: "ApproveLogEventPolicy", + Query: ApproveLogEventPolicy_Operation, + Variables: &__ApproveLogEventPolicyInput{ + Id: id, + }, + } + + data_ = &ApproveLogEventPolicyResponse{} + resp_ := &graphql.Response{Data: data_} + + err_ = client_.MakeRequest( + ctx_, + req_, + resp_, + ) + + return data_, err_ +} + // The mutation executed by CreateAccount. const CreateAccount_Operation = ` mutation CreateAccount ($input: CreateAccountInput!) { @@ -2310,6 +2437,43 @@ func DeleteConversation( return data_, err_ } +// The mutation executed by DismissLogEventPolicy. +const DismissLogEventPolicy_Operation = ` +mutation DismissLogEventPolicy ($id: ID!) { + dismissLogEventPolicy(id: $id) { + id + category + dismissedAt + dismissedBy + } +} +` + +func DismissLogEventPolicy( + ctx_ context.Context, + client_ graphql.Client, + id string, +) (data_ *DismissLogEventPolicyResponse, err_ error) { + req_ := &graphql.Request{ + OpName: "DismissLogEventPolicy", + Query: DismissLogEventPolicy_Operation, + Variables: &__DismissLogEventPolicyInput{ + Id: id, + }, + } + + data_ = &DismissLogEventPolicyResponse{} + resp_ := &graphql.Response{Data: data_} + + err_ = client_.MakeRequest( + ctx_, + req_, + resp_, + ) + + return data_, err_ +} + // The mutation executed by EnableService. const EnableService_Operation = ` mutation EnableService ($serviceId: ID!) { diff --git a/internal/api/gen/queries/policies.graphql b/internal/api/gen/queries/policies.graphql new file mode 100644 index 00000000..9c127705 --- /dev/null +++ b/internal/api/gen/queries/policies.graphql @@ -0,0 +1,17 @@ +mutation ApproveLogEventPolicy($id: ID!) { + approveLogEventPolicy(id: $id) { + id + category + approvedAt + approvedBy + } +} + +mutation DismissLogEventPolicy($id: ID!) { + dismissLogEventPolicy(id: $id) { + id + category + dismissedAt + dismissedBy + } +} diff --git a/internal/api/policy_service.go b/internal/api/policy_service.go new file mode 100644 index 00000000..3f7f6e64 --- /dev/null +++ b/internal/api/policy_service.go @@ -0,0 +1,47 @@ +package api + +import ( + "context" + "fmt" + + "github.com/usetero/cli/internal/log" +) + +// LogEventPolicies provides access to log event policy operations. +type LogEventPolicies interface { + Approve(ctx context.Context, id string) error +} + +// PolicyService handles policy-related API operations. +type PolicyService struct { + client Client + scope log.Scope +} + +// Ensure PolicyService implements LogEventPolicies. +var _ LogEventPolicies = (*PolicyService)(nil) + +// NewPolicyService creates a new policy service. +func NewPolicyService(client Client, scope log.Scope) *PolicyService { + return &PolicyService{ + client: client, + scope: scope.Child("policies"), + } +} + +// Approve approves a log event policy. +func (s *PolicyService) Approve(ctx context.Context, id string) error { + s.scope.Debug("approving policy via API", "id", id) + + _, err := s.client.ApproveLogEventPolicy(ctx, id) + if err != nil { + s.scope.Error("failed to approve policy", "error", err, "id", id) + if classified := classifyError(err); classified != nil { + return fmt.Errorf("approve policy %s: %w", id, classified) + } + return err + } + + s.scope.Debug("approved policy via API", "id", id) + return nil +} diff --git a/internal/api/services.go b/internal/api/services.go index 95dfff9d..5de4ce06 100644 --- a/internal/api/services.go +++ b/internal/api/services.go @@ -18,6 +18,7 @@ type APIServices struct { DatadogAccounts DatadogAccounts Conversations Conversations Messages Messages + Policies LogEventPolicies } // NewServices creates APIServices with an internally-managed client. @@ -43,6 +44,7 @@ func newAPIServices(client Client, scope log.Scope) APIServices { DatadogAccounts: NewDatadogAccountService(client, scope), Conversations: NewConversationService(client, scope), Messages: NewMessageService(client, scope), + Policies: NewPolicyService(client, scope), } } diff --git a/internal/app/app.go b/internal/app/app.go index 86c1648e..0a1e0a50 100644 --- a/internal/app/app.go +++ b/internal/app/app.go @@ -19,6 +19,8 @@ import ( "github.com/usetero/cli/internal/app/onboarding" onboardingmsg "github.com/usetero/cli/internal/app/onboarding/msgs" "github.com/usetero/cli/internal/app/palette" + "github.com/usetero/cli/internal/app/policyapproval" + policyapprovalmsg "github.com/usetero/cli/internal/app/policyapproval/msgs" "github.com/usetero/cli/internal/app/statusbar" "github.com/usetero/cli/internal/app/toast" "github.com/usetero/cli/internal/auth" @@ -44,6 +46,7 @@ type state int const ( stateOnboarding state = iota stateChat + statePolicyApproval ) // Layout constants. @@ -84,14 +87,15 @@ type Model struct { workspace domain.Workspace // Components - statusBar *statusbar.Model - toast *toast.Model - keyBar *keybar.Model - onboarding *onboarding.Model - chat *chat.Model - quitDlg *quitDialog - palette *palette.Model - state state + statusBar *statusbar.Model + toast *toast.Model + keyBar *keybar.Model + onboarding *onboarding.Model + policyApproval *policyapproval.Model + chat *chat.Model + quitDlg *quitDialog + palette *palette.Model + state state // Dimensions width int @@ -222,6 +226,16 @@ func (m *Model) Update(msg tea.Msg) (tea.Model, tea.Cmd) { return m, tea.Quit } + if key.Matches(msg, key.NewBinding(key.WithKeys("ctrl+p"))) { + m.scope.Info("DEBUG: triggering policy approval") + return m, func() tea.Msg { + return policyapprovalmsg.Start{ + ToolUseID: "debug-test", + Policies: []policyapprovalmsg.Policy{{ID: "test-1", ServiceName: "Test Service"}}, + } + } + } + // When quit dialog is open, forward keys to it and consume if m.quitDlg != nil { return m, m.quitDlg.Update(msg) @@ -237,6 +251,13 @@ func (m *Model) Update(msg tea.Msg) (tea.Model, tea.Cmd) { m.statusBar.CloseDrawer() return m, nil } + // Esc in policy approval returns to chat + if m.state == statePolicyApproval { + m.scope.Info("policy approval cancelled") + m.state = stateChat + m.policyApproval = nil + return m, nil + } // Esc cancels the active round first; only show dialog if nothing to cancel if m.chat != nil && m.chat.CancelActiveRound() { return m, nil @@ -265,6 +286,13 @@ func (m *Model) Update(msg tea.Msg) (tea.Model, tea.Cmd) { m.quitDlg = newQuitDialog(m.theme) return m, nil } + // Intercept "approve", "approval", etc. to open wizard directly + if strings.HasPrefix(strings.ToLower(text), "approv") { + m.state = statePolicyApproval + m.policyApproval = policyapproval.New(m.ctx, m.theme, m.db, "", m.getUserID(), m.scope) + m.updateLayout() + return m, m.policyApproval.Init() + } case onboardingmsg.OrgSelected: return m, m.activateOrg(msg.Org.ID, msg) @@ -291,9 +319,10 @@ func (m *Model) Update(msg tea.Msg) (tea.Model, tea.Cmd) { // Create tool registry with executors m.toolRegistry = &chattools.Registry{ - Query: chattools.NewQueryTool(m.db), - StartJourney: chattools.NewStartJourneyTool(), - EndJourney: chattools.NewEndJourneyTool(), + Query: chattools.NewQueryTool(m.db), + StartPolicyApproval: chattools.NewStartPolicyApprovalTool(), + StartJourney: chattools.NewStartJourneyTool(), + EndJourney: chattools.NewEndJourneyTool(), } // Create chat client with tool definitions @@ -329,6 +358,29 @@ func (m *Model) Update(msg tea.Msg) (tea.Model, tea.Cmd) { return m, m.chat.Init() + case policyapprovalmsg.Start: + m.state = statePolicyApproval + m.policyApproval = policyapproval.New(m.ctx, m.theme, m.db, msg.ToolUseID, m.getUserID(), m.scope) + // m.policyApproval.SetPolicies(msg.Policies) + m.updateLayout() + return m, m.policyApproval.Init() + + case policyapprovalmsg.PolicyApprovalComplete: + m.scope.Info("policy approval complete", + "msg", "TODO: add details from message", + ) + + m.state = stateChat + m.policyApproval = nil + + // Create chat model (sizing happens via updateLayout) + m.chat = m.newChat() + + // Size the new chat component + m.updateLayout() + + return m, m.chat.Init() + case msgs.StreamCompleted: if msg.Title != "" && m.db != nil { m.statusBar.SetTitle(msg.Title) @@ -375,6 +427,10 @@ func (m *Model) Update(msg tea.Msg) (tea.Model, tea.Cmd) { if m.chat != nil { cmds = append(cmds, m.chat.Update(msg)) } + case statePolicyApproval: + if m.policyApproval != nil { + cmds = append(cmds, m.policyApproval.Update(msg)) + } } // Update keybar after page updates (bindings may have changed) @@ -383,6 +439,14 @@ func (m *Model) Update(msg tea.Msg) (tea.Model, tea.Cmd) { return m, tea.Batch(cmds...) } +// getUserID returns the authenticated user's ID, or empty string if not yet available. +func (m *Model) getUserID() string { + if m.user != nil { + return m.user.ID + } + return "" +} + // updateLayout propagates sizes to children based on current dimensions. func (m *Model) updateLayout() { contentWidth, contentHeight := m.contentSize() @@ -405,6 +469,10 @@ func (m *Model) updateLayout() { if m.onboarding != nil { m.onboarding.SetSize(contentWidth, pageHeight) } + case statePolicyApproval: + if m.policyApproval != nil { + m.policyApproval.SetSize(contentWidth, pageHeight) + } case stateChat: if m.chat != nil { m.chat.SetSize(contentWidth, pageHeight) @@ -436,6 +504,10 @@ func (m *Model) updateKeyBar() { if m.onboarding != nil { bindings = m.onboarding.ShortHelp() } + case statePolicyApproval: + if m.policyApproval != nil { + bindings = m.policyApproval.ShortHelp() + } case stateChat: if m.chat != nil { bindings = m.chat.ShortHelp() @@ -489,7 +561,7 @@ func (m *Model) startSync(accountID string) error { psClient := psapi.NewClient(m.cfg.PowerSyncEndpoint) // Create and start uploader - m.uploader = upload.New(m.db, psClient, m.authService, m.services.Conversations, m.services.Messages, m.scope) + m.uploader = upload.New(m.db, psClient, m.authService, m.services.Conversations, m.services.Messages, m.services.Policies, m.scope) go func() { if err := m.uploader.Run(sessionCtx); err != nil && !errors.Is(err, context.Canceled) { m.scope.Error("uploader error", "error", err) @@ -572,6 +644,10 @@ func (m *Model) renderContent() string { switch m.state { case stateOnboarding: pageView = m.onboarding.View() + case statePolicyApproval: + if m.policyApproval != nil { + pageView = m.policyApproval.View() + } case stateChat: if m.chat != nil { pageView = m.chat.View() diff --git a/internal/app/chat/messagelist/round/turn/assistant/assistant.go b/internal/app/chat/messagelist/round/turn/assistant/assistant.go index c10dca8b..81a9d5ec 100644 --- a/internal/app/chat/messagelist/round/turn/assistant/assistant.go +++ b/internal/app/chat/messagelist/round/turn/assistant/assistant.go @@ -6,6 +6,7 @@ import ( "github.com/usetero/cli/internal/app/chat/messagelist/round/turn/assistant/blocks" "github.com/usetero/cli/internal/app/chat/messagelist/round/turn/assistant/blocks/tools" "github.com/usetero/cli/internal/app/chat/messagelist/round/turn/assistant/blocks/tools/query" + "github.com/usetero/cli/internal/app/chat/messagelist/round/turn/assistant/blocks/tools/startpolicyapproval" "github.com/usetero/cli/internal/app/chat/msgs" chattools "github.com/usetero/cli/internal/chat/tools" "github.com/usetero/cli/internal/domain" @@ -153,6 +154,8 @@ func (m *Model) newToolBlock(index int, toolUse *domain.ToolUse, width int) *too switch toolUse.Name { case m.toolRegistry.Query.Name(): child = query.New(m.blockTheme, index, toolUse.ID, width, m.toolRegistry.Query, m.scope) + case m.toolRegistry.StartPolicyApproval.Name(): + child = startpolicyapproval.New(m.blockTheme, index, toolUse.ID, width, m.toolRegistry.StartPolicyApproval, m.scope) default: child = query.New(m.blockTheme, index, toolUse.ID, width, nil, m.scope) } diff --git a/internal/app/chat/messagelist/round/turn/assistant/blocks/tools/startpolicyapproval/startpolicyapproval.go b/internal/app/chat/messagelist/round/turn/assistant/blocks/tools/startpolicyapproval/startpolicyapproval.go new file mode 100644 index 00000000..78aee3da --- /dev/null +++ b/internal/app/chat/messagelist/round/turn/assistant/blocks/tools/startpolicyapproval/startpolicyapproval.go @@ -0,0 +1,144 @@ +package startpolicyapproval + +import ( + tea "charm.land/bubbletea/v2" + "github.com/usetero/cli/internal/app/chat/messagelist/round/turn/assistant/blocks/tools" + "github.com/usetero/cli/internal/app/chat/msgs" + policyapprovalmsg "github.com/usetero/cli/internal/app/policyapproval/msgs" + chattools "github.com/usetero/cli/internal/chat/tools" + "github.com/usetero/cli/internal/domain" + "github.com/usetero/cli/internal/log" + "github.com/usetero/cli/internal/styles" +) + +// Model handles start_policy_approval tool execution. +// Unlike other tools, this emits a message to open the wizard UI. +type Model struct { + theme styles.Theme + scope log.Scope + index int + toolID string + state tools.State + executor *chattools.StartPolicyApprovalTool + width int + + // Result + started bool + err error +} + +// New creates a new start policy approval tool model. +func New(theme styles.Theme, index int, toolID string, width int, executor *chattools.StartPolicyApprovalTool, scope log.Scope) *Model { + return &Model{ + theme: theme, + scope: scope.Child("startpolicyapproval"), + index: index, + toolID: toolID, + state: tools.StateAccumulating, + executor: executor, + width: width, + } +} + +// Update handles messages. +func (m *Model) Update(msg tea.Msg) tea.Cmd { + switch msg := msg.(type) { + case msgs.AssistantContentUpdated: + return m.handleContent(msg.Message.Content) + case msgs.StreamCompleted: + return m.handleContent(msg.Message.Content) + } + return nil +} + +// handleContent finds this tool's data and triggers execution. +func (m *Model) handleContent(content []domain.Block) tea.Cmd { + if m.state != tools.StateAccumulating { + return nil + } + + for _, b := range content { + if b.Index == m.index && b.Type == domain.BlockTypeToolUse && b.ToolUse != nil { + if b.ToolUse.InputComplete { + return m.execute() + } + return nil + } + } + return nil +} + +func (m *Model) execute() tea.Cmd { + m.state = tools.StateExecuting + m.scope.Info("starting policy approval wizard") + + // Emit message to open the wizard UI + // The wizard will query the DB for pending policies + m.started = true + m.state = tools.StateComplete + + return tea.Batch( + // Open the wizard + func() tea.Msg { + return policyapprovalmsg.Start{ + ToolUseID: m.toolID, + Policies: nil, // Wizard will query DB + } + }, + // Signal tool completion for chat flow + m.fireCompleted(), + ) +} + +func (m *Model) fireCompleted() tea.Cmd { + return func() tea.Msg { + return msgs.StartPolicyApprovalCompleted{ + ToolUseID: m.toolID, + Started: m.started, + Error: m.err, + } + } +} + +// Name returns the tool's display name. +func (m *Model) Name() string { + return "Policy Approval" +} + +// Status returns the status message shown while executing. +func (m *Model) Status() string { + return "Opening policy approval wizard" +} + +// Result returns the result message. +func (m *Model) Result() string { + if m.started { + return "Wizard opened" + } + return "Failed to open wizard" +} + +// View renders content (empty for this tool). +func (m *Model) View() string { + return "" +} + +// SetWidth sets the width. +func (m *Model) SetWidth(width int) { + m.width = width +} + +// ToolID returns the tool's ID. +func (m *Model) ToolID() string { + return m.toolID +} + +// State returns the tool's current state. +func (m *Model) State() tools.State { + return m.state +} + +// Err returns any error from execution. +func (m *Model) Err() error { + return m.err +} diff --git a/internal/app/chat/msgs/tools.go b/internal/app/chat/msgs/tools.go index b15669f6..ad3cbaba 100644 --- a/internal/app/chat/msgs/tools.go +++ b/internal/app/chat/msgs/tools.go @@ -67,6 +67,24 @@ func (m EndJourneyCompleted) GetResult() domaintools.Result { } func (m EndJourneyCompleted) toolCompleted() {} +// StartPolicyApprovalCompleted is fired when the policy approval wizard is triggered. +type StartPolicyApprovalCompleted struct { + ToolUseID string + Started bool + Error error +} + +func (m StartPolicyApprovalCompleted) GetToolUseID() string { return m.ToolUseID } +func (m StartPolicyApprovalCompleted) GetError() error { return m.Error } +func (m StartPolicyApprovalCompleted) GetResult() domaintools.Result { + return domaintools.Result{ + ToolUseID: m.ToolUseID, + StartPolicyApproval: &domaintools.StartPolicyApprovalResult{Started: m.Started}, + Error: errorResultFromErr(m.Error), + } +} +func (m StartPolicyApprovalCompleted) toolCompleted() {} + func errorResultFromErr(err error) *domaintools.ErrorResult { if err == nil { return nil @@ -79,4 +97,5 @@ var ( _ ToolCompleted = QueryCompleted{} _ ToolCompleted = StartJourneyCompleted{} _ ToolCompleted = EndJourneyCompleted{} + _ ToolCompleted = StartPolicyApprovalCompleted{} ) diff --git a/internal/app/onboarding/auth/check.go b/internal/app/onboarding/auth/check.go index cef79d73..47e7f1ec 100644 --- a/internal/app/onboarding/auth/check.go +++ b/internal/app/onboarding/auth/check.go @@ -17,6 +17,7 @@ import ( // checkResultMsg is the internal message for auth check completion. type checkResultMsg struct { hasValidAuth bool + userID string err error } @@ -63,7 +64,14 @@ func (m *CheckModel) checkAuth() tea.Cmd { return checkResultMsg{hasValidAuth: false} } - return checkResultMsg{hasValidAuth: true} + // Get user ID for the authenticated user + userID, err := m.auth.GetUserID(m.ctx) + if err != nil { + _ = m.auth.ClearTokens() + return checkResultMsg{hasValidAuth: false} + } + + return checkResultMsg{hasValidAuth: true, userID: userID} } } @@ -76,12 +84,16 @@ func (m *CheckModel) Update(msg tea.Msg) tea.Cmd { return appmsg.ErrorCmd("Authentication check failed", msg.err, false) } if msg.hasValidAuth { - m.scope.Info("auth valid") + m.scope.Info("auth valid", "user_id", msg.userID) } else { m.scope.Info("auth required") } return func() tea.Msg { - return msgs.AuthChecked{NeedsAuth: !msg.hasValidAuth} + result := msgs.AuthChecked{NeedsAuth: !msg.hasValidAuth} + if msg.hasValidAuth && msg.userID != "" { + result.User = &auth.User{ID: msg.userID} + } + return result } } return nil diff --git a/internal/app/onboarding/msgs/msgs.go b/internal/app/onboarding/msgs/msgs.go index 01b84e9f..49994d1c 100644 --- a/internal/app/onboarding/msgs/msgs.go +++ b/internal/app/onboarding/msgs/msgs.go @@ -16,8 +16,10 @@ const ( // Auth messages. // AuthChecked is emitted when the auth check completes. +// When NeedsAuth is false, User contains the authenticated user. type AuthChecked struct { NeedsAuth bool + User *auth.User } // Authenticated is emitted when authentication succeeds. diff --git a/internal/app/onboarding/onboarding.go b/internal/app/onboarding/onboarding.go index 7735b4c8..c0a596b3 100644 --- a/internal/app/onboarding/onboarding.go +++ b/internal/app/onboarding/onboarding.go @@ -131,6 +131,10 @@ func (m *Model) Update(msg tea.Msg) tea.Cmd { if msg.NeedsAuth { return m.setStep(auth.NewAuthenticate(m.ctx, m.theme, m.auth, m.scope)) } + // Set user from existing session + if msg.User != nil { + m.user = msg.User + } return m.setStep(role.New(m.theme, m.userPrefs, m.scope)) case msgs.Authenticated: diff --git a/internal/app/policyapproval/categorydetail/categorydetail.go b/internal/app/policyapproval/categorydetail/categorydetail.go new file mode 100644 index 00000000..184dbd22 --- /dev/null +++ b/internal/app/policyapproval/categorydetail/categorydetail.go @@ -0,0 +1,299 @@ +// Package categorydetail provides the category detail step for the policy approval wizard. +package categorydetail + +import ( + "context" + "fmt" + "math" + "strings" + + "charm.land/bubbles/v2/key" + tea "charm.land/bubbletea/v2" + "charm.land/lipgloss/v2" + + "github.com/usetero/cli/internal/app/policyapproval/msgs" + "github.com/usetero/cli/internal/domain" + "github.com/usetero/cli/internal/log" + "github.com/usetero/cli/internal/sqlite" + "github.com/usetero/cli/internal/styles" + "github.com/usetero/cli/internal/tea/components/tableselect" +) + +// Model handles the category detail view — the second step of the approval wizard. +// Shows individual pending policies within a selected category. User can toggle +// selection with space, select all/none, then press enter to proceed. +type Model struct { + ctx context.Context + theme styles.Theme + db sqlite.DB + scope log.Scope + category string + policies []domain.PolicyDetail + selected map[string]bool + tbl *tableselect.Model + width int + height int + err error +} + +// New creates a new category detail step. +func New(ctx context.Context, theme styles.Theme, db sqlite.DB, category string, scope log.Scope) *Model { + return &Model{ + ctx: ctx, + theme: theme, + db: db, + category: category, + scope: scope.Child("categorydetail"), + selected: make(map[string]bool), + } +} + +// policiesLoadedMsg is sent when policies are loaded from the database. +type policiesLoadedMsg struct { + policies []domain.PolicyDetail + err error +} + +// Init loads policies for the selected category. +func (m *Model) Init() tea.Cmd { + m.scope.Info("category detail step started", "category", m.category) + return m.loadPolicies() +} + +func (m *Model) loadPolicies() tea.Cmd { + return func() tea.Msg { + policies, err := m.db.LogEventPolicies().ListPendingByCategory(m.ctx, m.category) + return policiesLoadedMsg{policies: policies, err: err} + } +} + +// Update handles messages. +func (m *Model) Update(msg tea.Msg) tea.Cmd { + switch msg := msg.(type) { + case policiesLoadedMsg: + if msg.err != nil { + m.err = msg.err + m.scope.Error("failed to load policies", "error", msg.err) + return nil + } + m.policies = msg.policies + m.scope.Info("policies loaded", "count", len(m.policies)) + + if len(m.policies) == 0 { + return func() tea.Msg { return msgs.BackToSummary{} } + } + + // Select all by default. + for _, p := range m.policies { + m.selected[p.PolicyID] = true + } + m.buildTable() + return nil + + case tea.KeyPressMsg: + switch { + case key.Matches(msg, keySelectAll): + for _, p := range m.policies { + m.selected[p.PolicyID] = true + } + m.rebuildRows() + case key.Matches(msg, keySelectNone): + for _, p := range m.policies { + m.selected[p.PolicyID] = false + } + m.rebuildRows() + case key.Matches(msg, keyToggle): + if m.tbl != nil && len(m.policies) > 0 { + p := m.policies[m.tbl.Cursor()] + m.selected[p.PolicyID] = !m.selected[p.PolicyID] + m.rebuildRows() + } + case key.Matches(msg, keySubmit): + return m.submit() + case key.Matches(msg, keyBack): + return func() tea.Msg { return msgs.BackToSummary{} } + case key.Matches(msg, keyEscape): + return func() tea.Msg { return msgs.Cancelled{} } + default: + if m.tbl != nil { + return m.tbl.Update(msg) + } + } + } + return nil +} + +// submit emits PoliciesSelected with the currently selected policies. +func (m *Model) submit() tea.Cmd { + var selected []domain.PolicyDetail + for _, p := range m.policies { + if m.selected[p.PolicyID] { + selected = append(selected, p) + } + } + if len(selected) == 0 { + return nil + } + m.scope.Info("policies selected", "count", len(selected)) + return func() tea.Msg { + return msgs.PoliciesSelected{ + Category: m.category, + Policies: selected, + } + } +} + +// buildTable creates the selectable table from loaded policies. +func (m *Model) buildTable() { + cols := []tableselect.Column{ + {Title: " ", Width: 3}, + {Title: "Log Event", Width: 30}, + {Title: "Risk", Width: 8}, + {Title: "Savings", Width: 12}, + } + + rows := m.makeRows() + m.tbl = tableselect.New(m.theme, cols, rows) + m.updateTableHeight() +} + +// updateTableHeight constrains the table to the available viewport. +// View chrome: header (1) + blank (1) + border top/bottom (2) + blank (1) + footer (1) = 6 lines. +func (m *Model) updateTableHeight() { + if m.tbl == nil { + return + } + const chrome = 6 + maxRows := m.height - chrome + if maxRows < 2 { + maxRows = 2 // header + at least 1 row + } + total := len(m.policies) + 1 // +1 for table header + if total > maxRows { + m.tbl.SetHeight(maxRows) + } +} + +// rebuildRows updates table rows with current checkbox state. +func (m *Model) rebuildRows() { + if m.tbl != nil { + m.tbl.SetRows(m.makeRows()) + } +} + +// makeRows builds table rows from policies with checkbox state. +func (m *Model) makeRows() []tableselect.Row { + rows := make([]tableselect.Row, len(m.policies)) + for i, p := range m.policies { + check := "☐" + if m.selected[p.PolicyID] { + check = "☑" + } + rows[i] = tableselect.Row{ + check, + truncate(p.LogEventName, 28), + p.RiskLevel.String(), + formatSavings(p.EstimatedCostPerHour), + } + } + return rows +} + +// View renders the category detail table. +func (m *Model) View() string { + if m.err != nil { + errStyle := lipgloss.NewStyle().Foreground(m.theme.ErrorBg) + return errStyle.Render(fmt.Sprintf("Error loading policies: %v", m.err)) + } + + if m.tbl == nil { + muted := lipgloss.NewStyle().Foreground(m.theme.TextMuted) + return muted.Render("Loading policies...") + } + + if len(m.policies) == 0 { + muted := lipgloss.NewStyle().Foreground(m.theme.TextMuted) + return muted.Render("No pending policies in this category.") + } + + var lines []string + + // Header. + header := lipgloss.NewStyle().Foreground(m.theme.Text).Bold(true) + lines = append(lines, header.Render(m.category)) + lines = append(lines, "") + + // Policy table. + lines = append(lines, m.tbl.View()) + lines = append(lines, "") + + // Footer: selection count. + muted := lipgloss.NewStyle().Foreground(m.theme.TextMuted) + selectedCount := 0 + for _, sel := range m.selected { + if sel { + selectedCount++ + } + } + footer := fmt.Sprintf("%d of %d selected", selectedCount, len(m.policies)) + lines = append(lines, muted.Render(footer)) + + return strings.Join(lines, "\n") +} + +// SetSize updates dimensions and constrains the table to fit. +func (m *Model) SetSize(width, height int) { + m.width = width + m.height = height + m.updateTableHeight() +} + +// ShortHelp returns key bindings. +func (m *Model) ShortHelp() []key.Binding { + return []key.Binding{keyToggle, keySubmit, keySelectAll, keySelectNone, keyBack, keyEscape} +} + +// Key bindings. +var ( + keyToggle = key.NewBinding(key.WithKeys("space"), key.WithHelp("space", "toggle")) + keySubmit = key.NewBinding(key.WithKeys("enter"), key.WithHelp("enter", "submit")) + keySelectAll = key.NewBinding(key.WithKeys("a"), key.WithHelp("a", "select all")) + keySelectNone = key.NewBinding(key.WithKeys("n"), key.WithHelp("n", "select none")) + keyBack = key.NewBinding(key.WithKeys("b"), key.WithHelp("b", "back")) + keyEscape = key.NewBinding(key.WithKeys("esc"), key.WithHelp("esc", "home")) +) + +// formatSavings returns estimated yearly cost savings. +func formatSavings(costPerHour float64) string { + if costPerHour > 0 { + yearly := costPerHour * 8760 + if yearly >= 1 { + return "~" + formatCost(yearly) + "/yr" + } + } + return "—" +} + +// formatCost formats a dollar amount: $142, $9.4k, $1.2M. +func formatCost(dollars float64) string { + abs := math.Abs(dollars) + switch { + case abs >= 1_000_000: + return fmt.Sprintf("$%.1fM", dollars/1_000_000) + case abs >= 1_000: + return fmt.Sprintf("$%.1fk", dollars/1_000) + default: + return fmt.Sprintf("$%.0f", dollars) + } +} + +// truncate shortens a string to max length, adding ellipsis if needed. +func truncate(s string, max int) string { + if len(s) <= max { + return s + } + if max <= 3 { + return s[:max] + } + return s[:max-3] + "..." +} diff --git a/internal/app/policyapproval/categorysummary/categorysummary.go b/internal/app/policyapproval/categorysummary/categorysummary.go new file mode 100644 index 00000000..6176cb8a --- /dev/null +++ b/internal/app/policyapproval/categorysummary/categorysummary.go @@ -0,0 +1,247 @@ +// Package categorysummary provides the category summary step for the policy approval wizard. +package categorysummary + +import ( + "context" + "fmt" + "math" + "strings" + + "charm.land/bubbles/v2/key" + tea "charm.land/bubbletea/v2" + "charm.land/lipgloss/v2" + + "github.com/usetero/cli/internal/app/policyapproval/msgs" + "github.com/usetero/cli/internal/domain" + "github.com/usetero/cli/internal/log" + "github.com/usetero/cli/internal/sqlite" + "github.com/usetero/cli/internal/styles" + "github.com/usetero/cli/internal/tea/components/tableselect" +) + +// Model handles the category summary view — the first step of the approval wizard. +// Shows policy categories grouped from log_event_policy_statuses_cache, sorted by +// pending count. User can drill into a category or approve all low-risk at once. +type Model struct { + ctx context.Context + theme styles.Theme + db sqlite.DB + scope log.Scope + categories []domain.PolicyCategoryStatus + tbl *tableselect.Model + width int + height int + err error +} + +// New creates a new category summary step. +func New(ctx context.Context, theme styles.Theme, db sqlite.DB, scope log.Scope) *Model { + return &Model{ + ctx: ctx, + theme: theme, + db: db, + scope: scope.Child("categorysummary"), + } +} + +// categoriesLoadedMsg is sent when categories are loaded from the database. +type categoriesLoadedMsg struct { + categories []domain.PolicyCategoryStatus + err error +} + +// Init loads category data from the local SQLite database. +func (m *Model) Init() tea.Cmd { + m.scope.Info("category summary step started") + return m.loadCategories() +} + +func (m *Model) loadCategories() tea.Cmd { + return func() tea.Msg { + categories, err := m.db.LogEventPolicies().ListCategoryStatuses(m.ctx) + return categoriesLoadedMsg{categories: categories, err: err} + } +} + +// Update handles messages. +func (m *Model) Update(msg tea.Msg) tea.Cmd { + switch msg := msg.(type) { + case categoriesLoadedMsg: + if msg.err != nil { + m.err = msg.err + m.scope.Error("failed to load categories", "error", msg.err) + return nil + } + // Filter to categories with pending policies only. + var pending []domain.PolicyCategoryStatus + for _, c := range msg.categories { + if c.PendingCount > 0 { + pending = append(pending, c) + } + } + m.categories = pending + m.scope.Info("categories loaded", "count", len(m.categories)) + + if len(m.categories) == 0 { + // Nothing to approve — cancel the wizard. + return func() tea.Msg { return msgs.Cancelled{} } + } + + m.buildTable() + return nil + + case tea.KeyPressMsg: + switch { + case key.Matches(msg, keyEnter): + if m.tbl != nil && len(m.categories) > 0 { + selected := m.categories[m.tbl.Cursor()] + m.scope.Info("category selected", "category", selected.Category) + return func() tea.Msg { + return msgs.CategorySelected{Category: selected.Category} + } + } + case key.Matches(msg, keyApproveAllLowRisk): + return m.approveAllLowRisk() + case key.Matches(msg, keyEscape): + return func() tea.Msg { return msgs.Cancelled{} } + default: + // Delegate navigation to the table. + if m.tbl != nil { + return m.tbl.Update(msg) + } + } + } + return nil +} + +// buildTable creates the selectable table from loaded categories. +func (m *Model) buildTable() { + cols := []tableselect.Column{ + {Title: "Category", Width: 25}, + {Title: "Risk", Width: 8}, + {Title: "Pending", Width: 8}, + {Title: "Savings", Width: 12}, + } + + rows := make([]tableselect.Row, len(m.categories)) + for i, c := range m.categories { + rows[i] = tableselect.Row{ + c.Category, + c.RiskLevel.String(), + fmt.Sprintf("%d", c.PendingCount), + formatSavings(c), + } + } + + m.tbl = tableselect.New(m.theme, cols, rows) +} + +// approveAllLowRisk collects all policy IDs from low-risk categories. +func (m *Model) approveAllLowRisk() tea.Cmd { + var lowRiskCategories []string + for _, c := range m.categories { + if c.RiskLevel == domain.RiskLevelLow && c.PendingCount > 0 { + lowRiskCategories = append(lowRiskCategories, c.Category) + } + } + if len(lowRiskCategories) == 0 { + return nil + } + m.scope.Info("approve all low-risk", "categories", len(lowRiskCategories)) + return func() tea.Msg { + return msgs.ApproveAllLowRisk{Categories: lowRiskCategories} + } +} + +// View renders the category summary table. +func (m *Model) View() string { + if m.err != nil { + errStyle := lipgloss.NewStyle().Foreground(m.theme.ErrorBg) + return errStyle.Render(fmt.Sprintf("Error loading policies: %v", m.err)) + } + + if m.tbl == nil { + muted := lipgloss.NewStyle().Foreground(m.theme.TextMuted) + return muted.Render("Loading policies...") + } + + if len(m.categories) == 0 { + muted := lipgloss.NewStyle().Foreground(m.theme.TextMuted) + return muted.Render("No pending policies.") + } + + var lines []string + + // Header. + header := lipgloss.NewStyle().Foreground(m.theme.Text).Bold(true) + lines = append(lines, header.Render("Review policy categories")) + lines = append(lines, "") + + // Category table. + lines = append(lines, m.tbl.View()) + lines = append(lines, "") + + // Footer: totals. + muted := lipgloss.NewStyle().Foreground(m.theme.TextMuted) + totalPending, totalCost := m.totals() + footer := fmt.Sprintf("Total: %d pending", totalPending) + if totalCost > 0 { + footer += fmt.Sprintf(" · Est. savings: ~%s/yr", formatCost(totalCost*8760)) + } + lines = append(lines, muted.Render(footer)) + + return strings.Join(lines, "\n") +} + +// totals returns aggregate pending count and hourly cost across all categories. +func (m *Model) totals() (int64, float64) { + var pending int64 + var cost float64 + for _, c := range m.categories { + pending += c.PendingCount + cost += c.EstimatedCostPerHour + } + return pending, cost +} + +// SetSize updates dimensions. +func (m *Model) SetSize(width, height int) { + m.width = width + m.height = height +} + +// ShortHelp returns key bindings. +func (m *Model) ShortHelp() []key.Binding { + return []key.Binding{keyEnter, keyApproveAllLowRisk, keyEscape} +} + +// Key bindings. +var ( + keyEnter = key.NewBinding(key.WithKeys("enter"), key.WithHelp("enter", "select")) + keyApproveAllLowRisk = key.NewBinding(key.WithKeys("a"), key.WithHelp("a", "approve all low-risk")) + keyEscape = key.NewBinding(key.WithKeys("esc"), key.WithHelp("esc", "cancel")) +) + +// formatSavings returns estimated yearly cost savings for a category. +func formatSavings(c domain.PolicyCategoryStatus) string { + if c.EstimatedCostPerHour > 0 { + yearly := c.EstimatedCostPerHour * 8760 + if yearly >= 1 { + return "~" + formatCost(yearly) + "/yr" + } + } + return "—" +} + +// formatCost formats a dollar amount: $142, $9.4k, $1.2M. +func formatCost(dollars float64) string { + abs := math.Abs(dollars) + switch { + case abs >= 1_000_000: + return fmt.Sprintf("$%.1fM", dollars/1_000_000) + case abs >= 1_000: + return fmt.Sprintf("$%.1fk", dollars/1_000) + default: + return fmt.Sprintf("$%.0f", dollars) + } +} diff --git a/internal/app/policyapproval/confirm/confirm.go b/internal/app/policyapproval/confirm/confirm.go new file mode 100644 index 00000000..980eb05d --- /dev/null +++ b/internal/app/policyapproval/confirm/confirm.go @@ -0,0 +1,162 @@ +// Package confirm provides the confirmation dialog step for the policy approval wizard. +package confirm + +import ( + "fmt" + "math" + "strings" + + "charm.land/bubbles/v2/key" + tea "charm.land/bubbletea/v2" + "charm.land/lipgloss/v2" + + "github.com/usetero/cli/internal/app/policyapproval/msgs" + "github.com/usetero/cli/internal/domain" + "github.com/usetero/cli/internal/log" + "github.com/usetero/cli/internal/styles" +) + +// Model handles the confirmation dialog — the third step of the approval wizard. +type Model struct { + theme styles.Theme + scope log.Scope + category string + policies []domain.PolicyDetail + selectedApprove bool // which button is focused (true = Approve, false = Cancel) + width int + height int +} + +// New creates a new confirm step. +func New(theme styles.Theme, category string, policies []domain.PolicyDetail, scope log.Scope) *Model { + return &Model{ + theme: theme, + scope: scope.Child("confirm"), + category: category, + policies: policies, + selectedApprove: true, // default to Approve + } +} + +// Init returns nil — no async work needed. +func (m *Model) Init() tea.Cmd { + m.scope.Info("confirm step started", "category", m.category, "count", len(m.policies)) + return nil +} + +// Update handles key presses. +func (m *Model) Update(msg tea.Msg) tea.Cmd { + if msg, ok := msg.(tea.KeyPressMsg); ok { + switch { + case key.Matches(msg, keyToggle): + m.selectedApprove = !m.selectedApprove + case key.Matches(msg, keyConfirm): + if m.selectedApprove { + m.scope.Info("approval confirmed") + return func() tea.Msg { return msgs.Confirmed{} } + } + m.scope.Info("approval cancelled from confirm dialog") + return func() tea.Msg { return msgs.BackToSummary{} } + case key.Matches(msg, keyCancel): + m.scope.Info("confirm cancelled via esc") + return func() tea.Msg { return msgs.BackToSummary{} } + } + } + return nil +} + +// View renders the confirmation dialog. +func (m *Model) View() string { + colors := m.theme + count := len(m.policies) + savings := m.estimatedSavings() + + // Title + title := lipgloss.NewStyle(). + Foreground(colors.Text). + Bold(true). + Render(fmt.Sprintf("Approve %d policies in %s?", count, m.category)) + + // Description + muted := lipgloss.NewStyle().Foreground(colors.TextMuted) + var details []string + details = append(details, muted.Render("This will create Datadog exclusion filters for:")) + details = append(details, muted.Render(fmt.Sprintf(" • %d log events", count))) + if savings > 0 { + details = append(details, muted.Render(fmt.Sprintf(" • Estimated savings: ~%s/yr", formatCost(savings)))) + } + description := strings.Join(details, "\n") + + // Buttons + cancelBtn := m.renderButton("Cancel", !m.selectedApprove) + approveBtn := m.renderButton("Approve", m.selectedApprove) + buttons := cancelBtn + " " + approveBtn + + content := lipgloss.JoinVertical(lipgloss.Center, title, "", description, "", buttons) + + return lipgloss.NewStyle(). + Foreground(colors.Text). + BorderStyle(lipgloss.RoundedBorder()). + BorderForeground(colors.Accent). + Padding(1, 3). + Render(content) +} + +// renderButton renders a single button with selected/unselected styling. +func (m *Model) renderButton(label string, selected bool) string { + colors := m.theme + + if selected { + return lipgloss.NewStyle(). + Background(colors.Accent). + Foreground(colors.Bg). + Padding(0, 3). + Bold(true). + Render(label) + } + + return lipgloss.NewStyle(). + Foreground(colors.TextMuted). + Padding(0, 3). + Render(label) +} + +// estimatedSavings calculates total yearly savings for selected policies. +func (m *Model) estimatedSavings() float64 { + var total float64 + for _, p := range m.policies { + total += p.EstimatedCostPerHour + } + return total * 8760 // hours per year +} + +// SetSize updates dimensions. +func (m *Model) SetSize(width, height int) { + m.width = width + m.height = height +} + +// ShortHelp returns key bindings. +func (m *Model) ShortHelp() []key.Binding { + return []key.Binding{keyToggle, keyConfirm, keyCancel} +} + +// Key bindings. +var ( + keyToggle = key.NewBinding(key.WithKeys("tab", "left", "right"), key.WithHelp("tab", "switch")) + keyConfirm = key.NewBinding(key.WithKeys("enter"), key.WithHelp("enter", "confirm")) + keyCancel = key.NewBinding(key.WithKeys("esc"), key.WithHelp("esc", "cancel")) +) + +// formatCost formats a dollar amount: $142, $9.4k, $1.2M. +func formatCost(dollars float64) string { + abs := math.Abs(dollars) + switch { + case abs >= 1_000_000: + return fmt.Sprintf("$%.1fM", dollars/1_000_000) + case abs >= 1_000: + return fmt.Sprintf("$%.1fk", dollars/1_000) + default: + return fmt.Sprintf("$%.0f", dollars) + } +} diff --git a/internal/app/policyapproval/execute/execute.go b/internal/app/policyapproval/execute/execute.go new file mode 100644 index 00000000..e8b44a20 --- /dev/null +++ b/internal/app/policyapproval/execute/execute.go @@ -0,0 +1,233 @@ +// Package execute provides the sequential policy approval execution step. +package execute + +import ( + "context" + "fmt" + "strings" + + "charm.land/bubbles/v2/key" + tea "charm.land/bubbletea/v2" + "charm.land/lipgloss/v2" + + "github.com/usetero/cli/internal/app/policyapproval/msgs" + "github.com/usetero/cli/internal/domain" + "github.com/usetero/cli/internal/log" + "github.com/usetero/cli/internal/sqlite" + "github.com/usetero/cli/internal/styles" +) + +// Model handles sequential policy approval — the final step of the wizard. +type Model struct { + ctx context.Context + theme styles.Theme + db sqlite.DB + scope log.Scope + userID string + policies []domain.PolicyDetail + current int // index of policy being approved + results []result // per-policy outcome + done bool + width int + height int + + // Accumulated for completion message + toolUseID string + totalSavings float64 +} + +type result struct { + err error +} + +// policyApprovedMsg is sent when a single policy approval completes. +type policyApprovedMsg struct { + index int + err error +} + +// New creates a new execute step. +func New( + ctx context.Context, + theme styles.Theme, + db sqlite.DB, + userID string, + toolUseID string, + policies []domain.PolicyDetail, + scope log.Scope, +) *Model { + // Pre-calculate total savings. + var totalSavings float64 + for _, p := range policies { + totalSavings += p.EstimatedCostPerHour * 8760 + } + + return &Model{ + ctx: ctx, + theme: theme, + db: db, + userID: userID, + toolUseID: toolUseID, + policies: policies, + results: make([]result, len(policies)), + scope: scope.Child("execute"), + totalSavings: totalSavings, + } +} + +// Init starts approving the first policy. +func (m *Model) Init() tea.Cmd { + m.scope.Info("execute step started", "count", len(m.policies)) + if len(m.policies) == 0 { + return m.complete() + } + return m.approveNext() +} + +// Update handles approval results and progresses through policies. +func (m *Model) Update(msg tea.Msg) tea.Cmd { + switch msg := msg.(type) { + case policyApprovedMsg: + m.results[msg.index] = result{err: msg.err} + if msg.err != nil { + m.scope.Error("policy approval failed", + "index", msg.index, + "policy_id", m.policies[msg.index].PolicyID, + "error", msg.err, + ) + } else { + m.scope.Info("policy approved", + "index", msg.index, + "policy_id", m.policies[msg.index].PolicyID, + ) + } + + m.current++ + if m.current < len(m.policies) { + return m.approveNext() + } + m.done = true + return m.complete() + } + return nil +} + +// approveNext returns a command that approves the current policy. +func (m *Model) approveNext() tea.Cmd { + idx := m.current + policy := m.policies[idx] + return func() tea.Msg { + err := m.db.LogEventPolicies().Approve(m.ctx, policy.PolicyID, m.userID) + return policyApprovedMsg{index: idx, err: err} + } +} + +// complete emits the PolicyApprovalComplete message with counts. +func (m *Model) complete() tea.Cmd { + var approved, failed int + for _, r := range m.results { + if r.err != nil { + failed++ + } else { + approved++ + } + } + m.scope.Info("execution complete", "approved", approved, "failed", failed) + return func() tea.Msg { + return msgs.PolicyApprovalComplete{ + ToolUseID: m.toolUseID, + ApprovedCount: approved, + FailedCount: failed, + TotalSavings: m.totalSavings, + } + } +} + +// View renders the progress display. +func (m *Model) View() string { + colors := m.theme + + header := lipgloss.NewStyle(). + Foreground(colors.Text). + Bold(true). + Render("Approving policies...") + + // Determine visible window based on height. + const chrome = 5 // header + blank + blank + counter + margin + maxVisible := m.height - chrome + if maxVisible < 3 { + maxVisible = 3 + } + + // Scroll window: keep current item visible with some context. + start := 0 + end := len(m.policies) + if end > maxVisible { + // Center around current item. + start = m.current - maxVisible/2 + if start < 0 { + start = 0 + } + end = start + maxVisible + if end > len(m.policies) { + end = len(m.policies) + start = end - maxVisible + } + } + + var lines []string + for i := start; i < end; i++ { + line := m.renderPolicyLine(i) + lines = append(lines, line) + } + + policyList := strings.Join(lines, "\n") + + // Counter + muted := lipgloss.NewStyle().Foreground(colors.TextMuted) + counter := muted.Render(fmt.Sprintf("%d of %d", m.current, len(m.policies))) + + return lipgloss.JoinVertical(lipgloss.Left, header, "", policyList, "", counter) +} + +// renderPolicyLine renders a single policy line with status symbol. +func (m *Model) renderPolicyLine(index int) string { + colors := m.theme + name := m.policies[index].LogEventName + + var symbol string + var style lipgloss.Style + + switch { + case index < m.current: + // Completed + if m.results[index].err != nil { + symbol = "✗" + style = lipgloss.NewStyle().Foreground(colors.ErrorFg) + } else { + symbol = "✓" + style = lipgloss.NewStyle().Foreground(colors.SuccessFg) + } + case index == m.current && !m.done: + // In progress + symbol = "◐" + style = lipgloss.NewStyle().Foreground(colors.Accent) + default: + // Queued + symbol = "○" + style = lipgloss.NewStyle().Foreground(colors.TextMuted) + } + + return style.Render(fmt.Sprintf("%s %s", symbol, name)) +} + +// SetSize updates dimensions. +func (m *Model) SetSize(width, height int) { + m.width = width + m.height = height +} + +// ShortHelp returns key bindings (none during execution). +func (m *Model) ShortHelp() []key.Binding { + return nil +} diff --git a/internal/app/policyapproval/msgs/msgs.go b/internal/app/policyapproval/msgs/msgs.go new file mode 100644 index 00000000..cea126ce --- /dev/null +++ b/internal/app/policyapproval/msgs/msgs.go @@ -0,0 +1,44 @@ +package msgs + +import "github.com/usetero/cli/internal/domain" + +type Policy struct { + ID string + ServiceName string + Category string + EstimatedVolumePerHour float64 + EstimatedCostPerHour float64 +} + +type Start struct { + ToolUseID string + Policies []Policy +} + +type PoliciesSelected struct { + Category string + Policies []domain.PolicyDetail +} + +type Confirmed struct{} + +type CategorySelected struct { + Category string +} + +type ApproveAllLowRisk struct { + Categories []string +} + +type BackToSummary struct{} + +type Cancelled struct { + ToolUseID string +} + +type PolicyApprovalComplete struct { + ToolUseID string + ApprovedCount int + FailedCount int + TotalSavings float64 +} diff --git a/internal/app/policyapproval/policyapproval.go b/internal/app/policyapproval/policyapproval.go new file mode 100644 index 00000000..f23a6a1d --- /dev/null +++ b/internal/app/policyapproval/policyapproval.go @@ -0,0 +1,184 @@ +// Package policyapproval provides a wizard for bulk policy approval. +package policyapproval + +import ( + "context" + "strings" + + "charm.land/bubbles/v2/key" + tea "charm.land/bubbletea/v2" + "charm.land/lipgloss/v2" + + "github.com/usetero/cli/internal/app/policyapproval/categorydetail" + "github.com/usetero/cli/internal/app/policyapproval/categorysummary" + "github.com/usetero/cli/internal/app/policyapproval/confirm" + "github.com/usetero/cli/internal/app/policyapproval/execute" + "github.com/usetero/cli/internal/app/policyapproval/msgs" + "github.com/usetero/cli/internal/domain" + "github.com/usetero/cli/internal/log" + "github.com/usetero/cli/internal/sqlite" + "github.com/usetero/cli/internal/styles" +) + +const ( + sideBySideMinWidth = 125 // ~61 per table + 3 gap + sideBySideGap = 3 +) + +// Model is the policy approval wizard orchestrator. +type Model struct { + // Dependencies + ctx context.Context + theme styles.Theme + db sqlite.DB + scope log.Scope + + // Input from tool + toolUseID string + userID string + + // Accumulated from step completions + selectedCategory string + selectedPolicies []domain.PolicyDetail + + // Steps + summary *categorysummary.Model // persistent, created once + detail Step // nil when not drilled in + step Step // active step for input (summary or detail) + + width int + height int +} + +// New creates a new policy approval wizard. +func New( + ctx context.Context, + theme styles.Theme, + db sqlite.DB, + toolUseID string, + userID string, + scope log.Scope, +) *Model { + return &Model{ + ctx: ctx, + theme: theme, + db: db, + toolUseID: toolUseID, + userID: userID, + scope: scope.Child("policyapproval"), + } +} + +// Init starts the wizard with the category summary step. +func (m *Model) Init() tea.Cmd { + m.scope.Info("policy approval wizard started") + m.summary = categorysummary.New(m.ctx, m.theme, m.db, m.scope) + m.step = m.summary + m.propagateSize() + return m.summary.Init() +} + +// Update handles messages and orchestrates step transitions. +func (m *Model) Update(msg tea.Msg) tea.Cmd { + switch msg := msg.(type) { + case tea.WindowSizeMsg: + m.width = msg.Width + m.height = msg.Height + m.propagateSize() + return nil + + case msgs.CategorySelected: + m.detail = categorydetail.New(m.ctx, m.theme, m.db, msg.Category, m.scope) + m.step = m.detail + m.propagateSize() + return m.detail.Init() + + case msgs.BackToSummary: + m.detail = nil + m.step = m.summary + m.propagateSize() + return nil // summary already loaded, no re-init + + case msgs.PoliciesSelected: + m.selectedCategory = msg.Category + m.selectedPolicies = msg.Policies + c := confirm.New(m.theme, msg.Category, msg.Policies, m.scope) + m.detail = c + m.step = c + m.propagateSize() + return c.Init() + + case msgs.Confirmed: + exec := execute.New(m.ctx, m.theme, m.db, m.userID, m.toolUseID, m.selectedPolicies, m.scope) + m.detail = exec + m.step = exec + m.propagateSize() + return exec.Init() + } + + // Delegate to current step + if m.step != nil { + return m.step.Update(msg) + } + return nil +} + +// isWide returns true if the viewport is wide enough for side-by-side layout. +func (m *Model) isWide() bool { + return m.width >= sideBySideMinWidth +} + +// propagateSize distributes width/height to summary and detail based on layout. +func (m *Model) propagateSize() { + if m.summary == nil { + return + } + if m.detail != nil && m.isWide() { + half := (m.width - sideBySideGap) / 2 + m.summary.SetSize(half, m.height) + m.detail.SetSize(m.width-half-sideBySideGap, m.height) + } else if m.detail != nil { + m.detail.SetSize(m.width, m.height) + } else { + m.summary.SetSize(m.width, m.height) + } +} + +// View renders the current step. +func (m *Model) View() string { + if m.summary == nil { + return "" + } + + var content string + if m.detail != nil && m.isWide() { + gap := strings.Repeat(" ", sideBySideGap) + content = lipgloss.JoinHorizontal(lipgloss.Bottom, + m.summary.View(), gap, m.detail.View()) + } else if m.detail != nil { + content = m.detail.View() + } else { + content = m.summary.View() + } + + return lipgloss.NewStyle(). + Width(m.width). + Height(m.height). + AlignVertical(lipgloss.Bottom). + Render(content) +} + +// SetSize updates the model's dimensions. +func (m *Model) SetSize(width, height int) { + m.width = width + m.height = height + m.propagateSize() +} + +// ShortHelp returns the key bindings for the short help view. +func (m *Model) ShortHelp() []key.Binding { + if m.step != nil { + return m.step.ShortHelp() + } + return nil +} diff --git a/internal/app/policyapproval/select/select.go b/internal/app/policyapproval/select/select.go new file mode 100644 index 00000000..b2eaa7a4 --- /dev/null +++ b/internal/app/policyapproval/select/select.go @@ -0,0 +1,97 @@ +// Package select provides the policy selection step. +package policyselect + +import ( + "context" + + "charm.land/bubbles/v2/key" + tea "charm.land/bubbletea/v2" + "charm.land/lipgloss/v2" + + "github.com/usetero/cli/internal/app/policyapproval/msgs" + "github.com/usetero/cli/internal/log" + "github.com/usetero/cli/internal/styles" +) + +// Model handles policy selection with multiselect. +type Model struct { + ctx context.Context + theme styles.Theme + scope log.Scope + policies []msgs.Policy + selected map[string]bool // policy ID -> selected + cursor int + width int + height int +} + +// New creates a new policy selection step. +func New(ctx context.Context, theme styles.Theme, policies []msgs.Policy, scope log.Scope) *Model { + if ctx == nil { + panic("ctx is nil") + } + return &Model{ + ctx: ctx, + theme: theme, + scope: scope.Child("select"), + policies: policies, + selected: make(map[string]bool), + } +} + +// Init returns the initial command. +func (m *Model) Init() tea.Cmd { + m.scope.Info("select step started", "policy_count", len(m.policies)) + return nil +} + +// Update handles messages. +func (m *Model) Update(msg tea.Msg) tea.Cmd { + switch msg := msg.(type) { + case tea.KeyPressMsg: + switch { + // TODO: handle keys + // space - toggle current + // a - select all + // n - select none + // j/down - cursor down + // k/up - cursor up + // enter - confirm selection + case key.Matches(msg, key.NewBinding(key.WithKeys("enter"))): + return m.confirm() + } + } + return nil +} + +// confirm emits PoliciesSelected with the selected policies. +func (m *Model) confirm() tea.Cmd { + // TODO: convert msgs.Policy to domain.PolicyDetail when this step is fully implemented + m.scope.Info("policies selected (stub)") + return func() tea.Msg { + return msgs.PoliciesSelected{} + } +} + +// View renders the step. +func (m *Model) View() string { + // TODO: render policy list with checkboxes + return lipgloss.NewStyle(). + Foreground(m.theme.Text). + Render("Select policies to approve (TODO: render list)") +} + +// SetSize updates dimensions. +func (m *Model) SetSize(width, height int) { + m.width = width + m.height = height +} + +// ShortHelp returns key bindings. +func (m *Model) ShortHelp() []key.Binding { + return []key.Binding{ + key.NewBinding(key.WithKeys("space"), key.WithHelp("space", "toggle")), + key.NewBinding(key.WithKeys("enter"), key.WithHelp("enter", "confirm")), + key.NewBinding(key.WithKeys("esc"), key.WithHelp("esc", "cancel")), + } +} diff --git a/internal/app/policyapproval/step.go b/internal/app/policyapproval/step.go new file mode 100644 index 00000000..23edb0b1 --- /dev/null +++ b/internal/app/policyapproval/step.go @@ -0,0 +1,25 @@ +package policyapproval + +import ( + "charm.land/bubbles/v2/key" + tea "charm.land/bubbletea/v2" +) + +// Step represents a single step in the onboarding flow. +type Step interface { + // Init returns the initial command for the step. + Init() tea.Cmd + + // Update handles a message and returns a command. + // Steps emit completion messages (from msgs package) when done. + Update(msg tea.Msg) tea.Cmd + + // View renders the step. + View() string + + // SetSize updates the step's dimensions. + SetSize(width, height int) + + // ShortHelp returns the key bindings for the short help view. + ShortHelp() []key.Binding +} diff --git a/internal/chat/tools/registry.go b/internal/chat/tools/registry.go index dc6fc874..73200f08 100644 --- a/internal/chat/tools/registry.go +++ b/internal/chat/tools/registry.go @@ -4,9 +4,10 @@ import "github.com/usetero/cli/internal/chat" // Registry holds tool instances and provides definitions. type Registry struct { - Query *QueryTool - StartJourney *StartJourneyTool - EndJourney *EndJourneyTool + Query *QueryTool + StartPolicyApproval *StartPolicyApprovalTool + StartJourney *StartJourneyTool + EndJourney *EndJourneyTool } // Definitions returns tool definitions for the chat API. @@ -15,6 +16,9 @@ func (r *Registry) Definitions() []chat.Tool { if r.Query != nil { defs = append(defs, r.Query.Definition()) } + if r.StartPolicyApproval != nil { + defs = append(defs, r.StartPolicyApproval.Definition()) + } if r.StartJourney != nil { defs = append(defs, r.StartJourney.Definition()) } diff --git a/internal/chat/tools/start_policy_approval.go b/internal/chat/tools/start_policy_approval.go new file mode 100644 index 00000000..46466425 --- /dev/null +++ b/internal/chat/tools/start_policy_approval.go @@ -0,0 +1,53 @@ +package tools + +import ( + "encoding/json" + + "github.com/usetero/cli/internal/chat" + "github.com/usetero/cli/internal/domain/tools" +) + +// StartPolicyApprovalTool triggers the interactive policy approval wizard. +// Unlike other tools, this doesn't execute immediately - the UI block +// emits a message to open the wizard, then waits for completion. +type StartPolicyApprovalTool struct{} + +// NewStartPolicyApprovalTool creates a new start_policy_approval tool. +func NewStartPolicyApprovalTool() *StartPolicyApprovalTool { + return &StartPolicyApprovalTool{} +} + +// Name returns the tool name. +func (t *StartPolicyApprovalTool) Name() string { + return "start_policy_approval" +} + +// Definition returns the tool definition for the chat API. +func (t *StartPolicyApprovalTool) Definition() chat.Tool { + return chat.Tool{ + Name: t.Name(), + Description: `Start an interactive policy approval flow. + +This opens a wizard where the user can: +- Review all pending log event policies +- Select which policies to approve +- Confirm the approval with a summary of impact + +Use this when the user wants to: +- Review and approve pending policies +- Bulk approve multiple policies +- See what policies are available for approval + +IMPORTANT: Call this tool immediately without any preceding text. Do not explain what you're about to do - just call the tool directly. + +The tool returns after the user completes or cancels the wizard.`, + InputSchema: chat.NewObjectSchema(map[string]chat.Property{}, nil), + } +} + +// Execute is a no-op for this tool. The actual execution happens in the UI block +// which emits StartPolicyApprovalMsg to trigger the wizard. +func (t *StartPolicyApprovalTool) Execute(input json.RawMessage) (tools.StartPolicyApprovalResult, error) { + // This is never called directly - the UI block handles everything + return tools.StartPolicyApprovalResult{}, nil +} diff --git a/internal/domain/policy_detail.go b/internal/domain/policy_detail.go new file mode 100644 index 00000000..3a5829ef --- /dev/null +++ b/internal/domain/policy_detail.go @@ -0,0 +1,11 @@ +package domain + +// PolicyDetail represents an individual pending policy within a category. +type PolicyDetail struct { + PolicyID string + LogEventName string + RiskLevel RiskLevel + Benefits string + EstimatedCostPerHour float64 + EstimatedVolumePerHour float64 +} diff --git a/internal/domain/tools/start_policy_approval.go b/internal/domain/tools/start_policy_approval.go new file mode 100644 index 00000000..caa0a2a1 --- /dev/null +++ b/internal/domain/tools/start_policy_approval.go @@ -0,0 +1,11 @@ +package tools + +// StartPolicyApprovalResult holds the result of starting a policy approval wizard. +type StartPolicyApprovalResult struct { + Started bool +} + +// ToMap serializes the result for the GraphQL API. +func (r StartPolicyApprovalResult) ToMap() map[string]any { + return map[string]any{"started": r.Started} +} diff --git a/internal/domain/tools/tools.go b/internal/domain/tools/tools.go index 2df21d0a..cc7de037 100644 --- a/internal/domain/tools/tools.go +++ b/internal/domain/tools/tools.go @@ -11,11 +11,12 @@ type Tool interface { // Result holds a typed tool result. Exactly one field is set. type Result struct { - ToolUseID string - Query *QueryResult - StartJourney *StartJourneyResult - EndJourney *EndJourneyResult - Error *ErrorResult + ToolUseID string + Query *QueryResult + StartPolicyApproval *StartPolicyApprovalResult + StartJourney *StartJourneyResult + EndJourney *EndJourneyResult + Error *ErrorResult } // ToMap serializes the result for the GraphQL API. @@ -23,6 +24,8 @@ func (r Result) ToMap() map[string]any { switch { case r.Query != nil: return r.Query.ToMap() + case r.StartPolicyApproval != nil: + return r.StartPolicyApproval.ToMap() case r.StartJourney != nil: return r.StartJourney.ToMap() case r.EndJourney != nil: diff --git a/internal/sqlite/database.go b/internal/sqlite/database.go index 0b7a443f..49f86349 100644 --- a/internal/sqlite/database.go +++ b/internal/sqlite/database.go @@ -264,7 +264,7 @@ func (d *database) LogEvents() LogEvents { // LogEventPolicies returns type-safe log event policy operations. func (d *database) LogEventPolicies() LogEventPolicies { - return &logEventPoliciesImpl{queries: d.ReadQueries()} + return &logEventPoliciesImpl{read: d.ReadQueries(), write: d.WriteQueries()} } // --------------------------------------------------------------------------- diff --git a/internal/sqlite/gen/log_event_policies.sql.go b/internal/sqlite/gen/log_event_policies.sql.go index 3b347aaa..714bfbb3 100644 --- a/internal/sqlite/gen/log_event_policies.sql.go +++ b/internal/sqlite/gen/log_event_policies.sql.go @@ -9,6 +9,23 @@ import ( "context" ) +const approveLogEventPolicy = `-- name: ApproveLogEventPolicy :exec +UPDATE log_event_policies +SET approved_at = ?, approved_by = ? +WHERE id = ? +` + +type ApproveLogEventPolicyParams struct { + ApprovedAt *string + ApprovedBy *string + ID *string +} + +func (q *Queries) ApproveLogEventPolicy(ctx context.Context, arg ApproveLogEventPolicyParams) error { + _, err := q.db.ExecContext(ctx, approveLogEventPolicy, arg.ApprovedAt, arg.ApprovedBy, arg.ID) + return err +} + const countLogEventPolicies = `-- name: CountLogEventPolicies :one SELECT COUNT(*) FROM log_event_policies ` @@ -20,6 +37,112 @@ func (q *Queries) CountLogEventPolicies(ctx context.Context) (int64, error) { return count, err } +const listApprovedPoliciesByCategory = `-- name: ListApprovedPoliciesByCategory :many +SELECT + c.policy_id, + c.risk_level, + c.benefits, + c.estimated_cost_reduction_per_hour_usd, + c.estimated_volume_reduction_per_hour, + COALESCE(e.name, '') AS log_event_name +FROM log_event_policy_statuses_cache c +LEFT JOIN log_events e ON c.log_event_id = e.id +WHERE c.category = ? AND c.status = 'APPROVED' +ORDER BY c.estimated_cost_reduction_per_hour_usd DESC +` + +type ListApprovedPoliciesByCategoryRow struct { + PolicyID *string + RiskLevel *string + Benefits *string + EstimatedCostReductionPerHourUsd *float64 + EstimatedVolumeReductionPerHour *float64 + LogEventName string +} + +func (q *Queries) ListApprovedPoliciesByCategory(ctx context.Context, category *string) ([]ListApprovedPoliciesByCategoryRow, error) { + rows, err := q.db.QueryContext(ctx, listApprovedPoliciesByCategory, category) + if err != nil { + return nil, err + } + defer rows.Close() + var items []ListApprovedPoliciesByCategoryRow + for rows.Next() { + var i ListApprovedPoliciesByCategoryRow + if err := rows.Scan( + &i.PolicyID, + &i.RiskLevel, + &i.Benefits, + &i.EstimatedCostReductionPerHourUsd, + &i.EstimatedVolumeReductionPerHour, + &i.LogEventName, + ); err != nil { + return nil, err + } + items = append(items, i) + } + if err := rows.Close(); err != nil { + return nil, err + } + if err := rows.Err(); err != nil { + return nil, err + } + return items, nil +} + +const listPendingPoliciesByCategory = `-- name: ListPendingPoliciesByCategory :many +SELECT + c.policy_id, + c.risk_level, + c.benefits, + c.estimated_cost_reduction_per_hour_usd, + c.estimated_volume_reduction_per_hour, + COALESCE(e.name, '') AS log_event_name +FROM log_event_policy_statuses_cache c +LEFT JOIN log_events e ON c.log_event_id = e.id +WHERE c.category = ? AND c.status = 'PENDING' +ORDER BY c.estimated_cost_reduction_per_hour_usd DESC +` + +type ListPendingPoliciesByCategoryRow struct { + PolicyID *string + RiskLevel *string + Benefits *string + EstimatedCostReductionPerHourUsd *float64 + EstimatedVolumeReductionPerHour *float64 + LogEventName string +} + +func (q *Queries) ListPendingPoliciesByCategory(ctx context.Context, category *string) ([]ListPendingPoliciesByCategoryRow, error) { + rows, err := q.db.QueryContext(ctx, listPendingPoliciesByCategory, category) + if err != nil { + return nil, err + } + defer rows.Close() + var items []ListPendingPoliciesByCategoryRow + for rows.Next() { + var i ListPendingPoliciesByCategoryRow + if err := rows.Scan( + &i.PolicyID, + &i.RiskLevel, + &i.Benefits, + &i.EstimatedCostReductionPerHourUsd, + &i.EstimatedVolumeReductionPerHour, + &i.LogEventName, + ); err != nil { + return nil, err + } + items = append(items, i) + } + if err := rows.Close(); err != nil { + return nil, err + } + if err := rows.Err(); err != nil { + return nil, err + } + return items, nil +} + const listPolicyCategoryStatuses = `-- name: ListPolicyCategoryStatuses :many SELECT COALESCE(category, '') AS category, diff --git a/internal/sqlite/gen/querier.go b/internal/sqlite/gen/querier.go index 55702610..6ffa98a3 100644 --- a/internal/sqlite/gen/querier.go +++ b/internal/sqlite/gen/querier.go @@ -9,6 +9,7 @@ import ( ) type Querier interface { + ApproveLogEventPolicy(ctx context.Context, arg ApproveLogEventPolicyParams) error CountConversations(ctx context.Context) (int64, error) CountLogEventPolicies(ctx context.Context) (int64, error) CountLogEvents(ctx context.Context) (int64, error) @@ -24,9 +25,11 @@ type Querier interface { GetService(ctx context.Context, id *string) (Service, error) InsertConversation(ctx context.Context, arg InsertConversationParams) error InsertMessage(ctx context.Context, arg InsertMessageParams) error + ListApprovedPoliciesByCategory(ctx context.Context, category *string) ([]ListApprovedPoliciesByCategoryRow, error) ListConversationsByAccount(ctx context.Context, accountID *string) ([]Conversation, error) ListMessagesByConversation(ctx context.Context, conversationID *string) ([]Message, error) ListMessagesByConversationDesc(ctx context.Context, conversationID *string) ([]Message, error) + ListPendingPoliciesByCategory(ctx context.Context, category *string) ([]ListPendingPoliciesByCategoryRow, error) ListPolicyCategoryStatuses(ctx context.Context) ([]ListPolicyCategoryStatusesRow, error) ListServiceStatuses(ctx context.Context) ([]ListServiceStatusesRow, error) ListServices(ctx context.Context) ([]Service, error) diff --git a/internal/sqlite/log_event_policies.go b/internal/sqlite/log_event_policies.go index 9e4324a0..3b1ac574 100644 --- a/internal/sqlite/log_event_policies.go +++ b/internal/sqlite/log_event_policies.go @@ -2,6 +2,7 @@ package sqlite import ( "context" + "time" "github.com/usetero/cli/internal/domain" "github.com/usetero/cli/internal/sqlite/gen" @@ -11,16 +12,19 @@ import ( type LogEventPolicies interface { Count(ctx context.Context) (int64, error) ListCategoryStatuses(ctx context.Context) ([]domain.PolicyCategoryStatus, error) + ListPendingByCategory(ctx context.Context, category string) ([]domain.PolicyDetail, error) + Approve(ctx context.Context, id, userID string) error } // logEventPoliciesImpl implements LogEventPolicies. type logEventPoliciesImpl struct { - queries *gen.Queries + read *gen.Queries + write *gen.Queries } // Count returns the total number of log event policies. func (l *logEventPoliciesImpl) Count(ctx context.Context) (int64, error) { - count, err := l.queries.CountLogEventPolicies(ctx) + count, err := l.read.CountLogEventPolicies(ctx) if err != nil { return 0, WrapSQLiteError(err, "count log event policies") } @@ -29,7 +33,7 @@ func (l *logEventPoliciesImpl) Count(ctx context.Context) (int64, error) { // ListCategoryStatuses returns policy counts and impact grouped by category. func (l *logEventPoliciesImpl) ListCategoryStatuses(ctx context.Context) ([]domain.PolicyCategoryStatus, error) { - rows, err := l.queries.ListPolicyCategoryStatuses(ctx) + rows, err := l.read.ListPolicyCategoryStatuses(ctx) if err != nil { return nil, WrapSQLiteError(err, "list policy category statuses") } @@ -51,9 +55,50 @@ func (l *logEventPoliciesImpl) ListCategoryStatuses(ctx context.Context) ([]doma return result, nil } +// ListPendingByCategory returns individual pending policies for a category. +func (l *logEventPoliciesImpl) ListPendingByCategory(ctx context.Context, category string) ([]domain.PolicyDetail, error) { + rows, err := l.read.ListPendingPoliciesByCategory(ctx, &category) + if err != nil { + return nil, WrapSQLiteError(err, "list pending policies by category") + } + + result := make([]domain.PolicyDetail, len(rows)) + for i, row := range rows { + result[i] = domain.PolicyDetail{ + PolicyID: derefString(row.PolicyID), + LogEventName: row.LogEventName, + RiskLevel: domain.RiskLevel(derefString(row.RiskLevel)), + Benefits: derefString(row.Benefits), + EstimatedCostPerHour: derefFloat(row.EstimatedCostReductionPerHourUsd), + EstimatedVolumePerHour: derefFloat(row.EstimatedVolumeReductionPerHour), + } + } + return result, nil +} + +func (l *logEventPoliciesImpl) Approve(ctx context.Context, id, userID string) error { + now := time.Now().UTC().Format(time.RFC3339) + err := l.write.ApproveLogEventPolicy(ctx, gen.ApproveLogEventPolicyParams{ + ID: &id, + ApprovedAt: &now, + ApprovedBy: &userID, + }) + if err != nil { + return WrapSQLiteError(err, "approve log event policy") + } + return nil +} + func derefFloat(p *float64) float64 { if p == nil { return 0 } return *p } + +func derefString(p *string) string { + if p == nil { + return "" + } + return *p +} diff --git a/internal/sqlite/queries/log_event_policies.sql b/internal/sqlite/queries/log_event_policies.sql index c88d768c..eca0c42d 100644 --- a/internal/sqlite/queries/log_event_policies.sql +++ b/internal/sqlite/queries/log_event_policies.sql @@ -27,3 +27,34 @@ WHERE category IS NOT NULL AND category != '' GROUP BY category ORDER BY SUM(CASE WHEN status = 'PENDING' THEN 1 ELSE 0 END) DESC; + +-- name: ListPendingPoliciesByCategory :many +SELECT + c.policy_id, + c.risk_level, + c.benefits, + c.estimated_cost_reduction_per_hour_usd, + c.estimated_volume_reduction_per_hour, + COALESCE(e.name, '') AS log_event_name +FROM log_event_policy_statuses_cache c +LEFT JOIN log_events e ON c.log_event_id = e.id +WHERE c.category = ? AND c.status = 'PENDING' +ORDER BY c.estimated_cost_reduction_per_hour_usd DESC; + +-- name: ListApprovedPoliciesByCategory :many +SELECT + c.policy_id, + c.risk_level, + c.benefits, + c.estimated_cost_reduction_per_hour_usd, + c.estimated_volume_reduction_per_hour, + COALESCE(e.name, '') AS log_event_name +FROM log_event_policy_statuses_cache c +LEFT JOIN log_events e ON c.log_event_id = e.id +WHERE c.category = ? AND c.status = 'APPROVED' +ORDER BY c.estimated_cost_reduction_per_hour_usd DESC; + +-- name: ApproveLogEventPolicy :exec +UPDATE log_event_policies +SET approved_at = ?, approved_by = ? +WHERE id = ?; diff --git a/internal/tea/components/tableselect/tableselect.go b/internal/tea/components/tableselect/tableselect.go new file mode 100644 index 00000000..6f87cc3e --- /dev/null +++ b/internal/tea/components/tableselect/tableselect.go @@ -0,0 +1,107 @@ +// Package tableselect wraps bubbles/table with theme-aware selection styling. +package tableselect + +import ( + "charm.land/bubbles/v2/key" + "charm.land/bubbles/v2/table" + tea "charm.land/bubbletea/v2" + "charm.land/lipgloss/v2" + + "github.com/usetero/cli/internal/styles" +) + +// Re-export types for convenience. +type ( + Column = table.Column + Row = table.Row +) + +// Model wraps bubbles/table with themed selection highlight. +type Model struct { + theme styles.Theme + table table.Model +} + +// New creates a new selectable table with themed styles. +func New(theme styles.Theme, cols []Column, rows []Row) *Model { + s := table.DefaultStyles() + s.Header = lipgloss.NewStyle(). + Foreground(theme.Text). + Bold(true). + Padding(0, 1) + s.Cell = lipgloss.NewStyle(). + Padding(0, 1) + // Auto-size width from column definitions + cell padding. + w := 0 + for _, col := range cols { + w += col.Width + 2 // +2 for Padding(0, 1) + } + + s.Selected = lipgloss.NewStyle(). + Background(theme.SelectionBg). + Foreground(theme.SelectionFg). + Bold(true). + Width(w) + + t := table.New( + table.WithColumns(cols), + table.WithRows(rows), + table.WithStyles(s), + table.WithFocused(true), + table.WithWidth(w), + table.WithHeight(len(rows)+1), + ) + + // Remove space from PageDown so consuming components can use it for toggle/selection. + t.KeyMap.PageDown.SetKeys("f", "pgdown") + + return &Model{ + theme: theme, + table: t, + } +} + +// Update handles key events for table navigation. +func (m *Model) Update(msg tea.Msg) tea.Cmd { + var cmd tea.Cmd + m.table, cmd = m.table.Update(msg) + return cmd +} + +// View renders the table with a themed border. +func (m *Model) View() string { + border := lipgloss.NewStyle(). + Border(lipgloss.RoundedBorder()). + BorderForeground(m.theme.Border) + return border.Render(m.table.View()) +} + +// Cursor returns the index of the selected row. +func (m *Model) Cursor() int { + return m.table.Cursor() +} + +// SelectedRow returns the selected row data. +func (m *Model) SelectedRow() Row { + return m.table.SelectedRow() +} + +// SetRows replaces the table rows. +func (m *Model) SetRows(rows []Row) { + m.table.SetRows(rows) +} + +// SetHeight sets the table height. +func (m *Model) SetHeight(h int) { + m.table.SetHeight(h) +} + +// SetWidth sets the table width. +func (m *Model) SetWidth(w int) { + m.table.SetWidth(w) +} + +// ShortHelp returns key bindings for table navigation. +func (m *Model) ShortHelp() []key.Binding { + return m.table.KeyMap.ShortHelp() +} diff --git a/internal/upload/log_event_policy_handler.go b/internal/upload/log_event_policy_handler.go new file mode 100644 index 00000000..c980d838 --- /dev/null +++ b/internal/upload/log_event_policy_handler.go @@ -0,0 +1,52 @@ +package upload + +import ( + "context" + "fmt" + + "github.com/usetero/cli/internal/api" + "github.com/usetero/cli/internal/log" + "github.com/usetero/cli/internal/powersync/db" +) + +// policyHandler handles uploading policy changes to the GraphQL API. +type policyHandler struct { + policies api.LogEventPolicies + scope log.Scope +} + +func newPolicyHandler(policies api.LogEventPolicies, scope log.Scope) *policyHandler { + return &policyHandler{ + policies: policies, + scope: scope.Child("policies"), + } +} + +func (h *policyHandler) Handle(ctx context.Context, entry *db.CrudEntry, emit Emitter) error { + _ = emit // Policy handler doesn't emit events currently + + switch entry.Op { + case db.OpPatch: + return h.handlePatch(ctx, entry) + default: + // We only handle PATCH (updates) for policies - they're created server-side + h.scope.Debug("ignoring policy op", "op", entry.Op, "id", entry.RowID) + return nil + } +} + +func (h *policyHandler) handlePatch(ctx context.Context, entry *db.CrudEntry) error { + // Check if this is an approval (approved_at is set) + if approvedAt, ok := entry.Data["approved_at"]; ok && approvedAt != nil { + err := h.policies.Approve(ctx, entry.RowID) + if err != nil { + return fmt.Errorf("approve policy: %w", err) + } + h.scope.Debug("approved policy", "id", entry.RowID) + return nil + } + + // If neither, just log and skip + h.scope.Debug("policy patch with no approve", "id", entry.RowID, "data", entry.Data) + return nil +} diff --git a/internal/upload/log_event_policy_handler_test.go b/internal/upload/log_event_policy_handler_test.go new file mode 100644 index 00000000..fb3800c0 --- /dev/null +++ b/internal/upload/log_event_policy_handler_test.go @@ -0,0 +1,137 @@ +package upload + +import ( + "context" + "errors" + "testing" + + "github.com/usetero/cli/internal/log/logtest" + "github.com/usetero/cli/internal/powersync/db" +) + +// mockPolicies implements api.LogEventPolicies for testing. +type mockPolicies struct { + approveFunc func(ctx context.Context, id string) error +} + +func (m *mockPolicies) Approve(ctx context.Context, id string) error { + if m.approveFunc != nil { + return m.approveFunc(ctx, id) + } + return nil +} + +func TestPolicyHandler_Handle(t *testing.T) { + t.Parallel() + + t.Run("PATCH with approved_at calls Approve", func(t *testing.T) { + t.Parallel() + + var approvedID string + policies := &mockPolicies{ + approveFunc: func(ctx context.Context, id string) error { + approvedID = id + return nil + }, + } + + handler := newPolicyHandler(policies, logtest.NewScope(t)) + + entry := &db.CrudEntry{ + Table: "log_event_policies", + RowID: "policy-123", + Op: db.OpPatch, + Data: map[string]any{ + "approved_at": "2024-01-15T10:00:00Z", + "approved_by": "user-456", + }, + } + + err := handler.Handle(context.Background(), entry, nil) + if err != nil { + t.Fatalf("Handle() error = %v", err) + } + + if approvedID != "policy-123" { + t.Errorf("Approve called with id = %q, want %q", approvedID, "policy-123") + } + }) + + t.Run("PATCH returns error on Approve failure", func(t *testing.T) { + t.Parallel() + + policies := &mockPolicies{ + approveFunc: func(ctx context.Context, id string) error { + return errors.New("api error") + }, + } + + handler := newPolicyHandler(policies, logtest.NewScope(t)) + + entry := &db.CrudEntry{ + Table: "log_event_policies", + RowID: "policy-123", + Op: db.OpPatch, + Data: map[string]any{ + "approved_at": "2024-01-15T10:00:00Z", + }, + } + + err := handler.Handle(context.Background(), entry, nil) + if err == nil { + t.Fatal("Handle() expected error, got nil") + } + }) + + t.Run("ignores PUT operations", func(t *testing.T) { + t.Parallel() + + policies := &mockPolicies{ + approveFunc: func(ctx context.Context, id string) error { + t.Error("Approve should not be called") + return nil + }, + } + + handler := newPolicyHandler(policies, logtest.NewScope(t)) + + entry := &db.CrudEntry{ + Table: "log_event_policies", + RowID: "policy-123", + Op: db.OpPut, + Data: map[string]any{}, + } + + err := handler.Handle(context.Background(), entry, nil) + if err != nil { + t.Fatalf("Handle() error = %v", err) + } + }) + + t.Run("ignores PATCH without approve data", func(t *testing.T) { + t.Parallel() + + policies := &mockPolicies{ + approveFunc: func(ctx context.Context, id string) error { + t.Error("Approve should not be called") + return nil + }, + } + + handler := newPolicyHandler(policies, logtest.NewScope(t)) + + entry := &db.CrudEntry{ + Table: "log_event_policies", + RowID: "policy-123", + Op: db.OpPatch, + Data: map[string]any{ + "some_other_field": "value", + }, + } + + err := handler.Handle(context.Background(), entry, nil) + if err != nil { + t.Fatalf("Handle() error = %v", err) + } + }) +} diff --git a/internal/upload/uploader.go b/internal/upload/uploader.go index 55968bd4..f4b1d395 100644 --- a/internal/upload/uploader.go +++ b/internal/upload/uploader.go @@ -59,6 +59,7 @@ func New( tokenRefresher TokenRefresher, conversations api.Conversations, messages api.Messages, + policies api.LogEventPolicies, scope log.Scope, ) Uploader { scope = scope.Child("upload") @@ -68,8 +69,9 @@ func New( client: client, tokenRefresher: tokenRefresher, handlers: map[sqlite.Table]Handler{ - sqlite.TableConversations: newConversationHandler(conversations, scope), - sqlite.TableMessages: newMessageHandler(messages, database, scope), + sqlite.TableConversations: newConversationHandler(conversations, scope), + sqlite.TableMessages: newMessageHandler(messages, database, scope), + sqlite.TableLogEventPolicies: newPolicyHandler(policies, scope), }, scope: scope, pollInterval: defaultPollInterval, diff --git a/internal/upload/uploader_integration_test.go b/internal/upload/uploader_integration_test.go index e3a2f028..b53e7a6c 100644 --- a/internal/upload/uploader_integration_test.go +++ b/internal/upload/uploader_integration_test.go @@ -122,7 +122,7 @@ func TestIntegration_Upload(t *testing.T) { // Start upload loop powersyncClient := powersync.NewClient(cliConfig.PowerSyncEndpoint) - uploader := upload.New(db, powersyncClient, authSvc, services.Conversations, messagesSvc, logger) + uploader := upload.New(db, powersyncClient, authSvc, services.Conversations, messagesSvc, services.Policies, logger) uploadCtx, uploadCancel := context.WithCancel(ctx) defer uploadCancel() diff --git a/internal/upload/uploader_test.go b/internal/upload/uploader_test.go index 6e68cf55..ca600fa3 100644 --- a/internal/upload/uploader_test.go +++ b/internal/upload/uploader_test.go @@ -32,6 +32,7 @@ func TestUploader_Run(t *testing.T) { powersynctest.NewMockTokenRefresher("token"), apitest.NewMockConversations(), apitest.NewMockMessages(), + apitest.NewMockPolicies(), logtest.NewScope(t), ) @@ -55,6 +56,7 @@ func TestUploader_Run(t *testing.T) { powersynctest.NewMockTokenRefresher("token"), apitest.NewMockConversations(), apitest.NewMockMessages(), + apitest.NewMockPolicies(), logtest.NewScope(t), ) @@ -103,6 +105,7 @@ func TestUploader_Run(t *testing.T) { powersynctest.NewMockTokenRefresher("token"), conversations, apitest.NewMockMessages(), + apitest.NewMockPolicies(), logtest.NewScope(t), ) @@ -158,6 +161,7 @@ func TestUploader_Run(t *testing.T) { powersynctest.NewMockTokenRefresher("token"), apitest.NewMockConversations(), apitest.NewMockMessages(), + apitest.NewMockPolicies(), logtest.NewScope(t), ) @@ -222,6 +226,7 @@ func TestUploader_Run(t *testing.T) { powersynctest.NewMockTokenRefresher("token"), conversations, apitest.NewMockMessages(), + apitest.NewMockPolicies(), logtest.NewScope(t), )